feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s

- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
This commit is contained in:
2026-09-15 11:54:56 +08:00
parent 93e072e1e2
commit 70e680eb0a
67 changed files with 3951 additions and 147 deletions

View File

@@ -0,0 +1,165 @@
package phone_asset_association
import (
"context"
stderrors "errors"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
)
// associationUniqueConstraint 是「有效关系」部分唯一索引名。
// 换绑迁移撞上它说明新号已存在同资产有效关系,必须整次失败而不是产生并存关系。
const associationUniqueConstraint = "uq_phone_asset_association_valid"
// AssociationWriter 承载手机号—资产关联的写入规则:幂等建联、换绑迁移与十项上限。
// 关联只能由 H5 短信验证建立,因此这里不提供任何后台创建入口。
type AssociationWriter struct {
store *postgres.PhoneAssetAssociationStore
audit accessauditapp.Writer
}
// NewAssociationWriter 创建关联写入规则实例。
func NewAssociationWriter(store *postgres.PhoneAssetAssociationStore, auditWriter accessauditapp.Writer) *AssociationWriter {
return &AssociationWriter{store: store, audit: auditWriter}
}
// Establish 建立手机号与当前访问资产的有效关联;同一关系已存在时保持幂等。
// 串行化点:先取手机号事务级 advisory lock再锁定该号码现有有效关系行
// 使「计数」与「插入」同处临界区;新号没有行可锁时仍由 advisory lock 保证串行。
// 已达十项的手机号对已关联资产重复验证必须返回成功且不产生第二条关系,
// 因此先判成员关系,仅当请求的是新资产时才做上限判定。
// 返回 true 表示本次确实新建了关系。
func (w *AssociationWriter) Establish(
ctx context.Context,
tx *gorm.DB,
customer *model.PersonalCustomer,
phone, assetType string,
assetID uint,
) (bool, error) {
// 请求不含当前访问资产身份:只完成账号手机号绑定,不建立关联。
if assetType == "" || assetID == 0 {
return false, nil
}
store := w.store.WithTx(tx)
if err := store.LockPhoneScopes(ctx, phone); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败")
}
locked, err := store.LockValidByPhones(ctx, phone)
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号关联资产失败")
}
for _, row := range locked {
if row.AssetType == assetType && row.AssetID == assetID {
// 重复验证同一资产:幂等成功,不报上限也不插入第二条关系。
return false, nil
}
}
if len(locked) >= constants.PhoneAssetAssociationMaxValidPerPhone {
return false, errors.New(errors.CodeInvalidStatus, constants.PhoneAssetAssociationLimitMessage)
}
association := &model.PhoneAssetAssociation{
Phone: phone, AssetType: assetType, AssetID: assetID,
Status: constants.PhoneAssetAssociationStatusValid,
Source: constants.PhoneAssetAssociationSourceH5SMSVerification,
EstablishedAt: time.Now(),
}
created, err := store.CreateIfAbsent(ctx, tx, association)
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "建立手机号资产关联失败")
}
if !created {
// 并发同资产建联由部分唯一索引兜底,冲突一律映射为幂等成功。
return false, nil
}
// 审计只写脱敏手机号,不复用会写明文手机号的既有资源路径。
phoneMasked := sanitizer.MaskPhone(phone)
if err := w.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPhoneAssetAssociationCreated,
Summary: "验证手机号后建立资产关联",
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PhoneAssociations: []accessauditapp.PhoneAssetAssociationChange{{
AssociationID: association.ID, PhoneMasked: phoneMasked,
AssetType: assetType, AssetID: assetID,
Status: constants.PhoneAssetAssociationStatusValid,
Source: constants.PhoneAssetAssociationSourceH5SMSVerification,
AfterData: map[string]any{
"phone_masked": phoneMasked, "asset_type": assetType, "asset_id": assetID,
"status": constants.PhoneAssetAssociationStatusValid,
},
}},
}); err != nil {
return false, err
}
return true, nil
}
// Migrate 在同一事务内把旧号全部有效关联原子迁移到新号。
// 串行化点:先按号码字符串升序取旧、新手机号 advisory lock再按 id ASC 锁定两侧有效关系行。
// 迁移前比较「新号现有有效关系数 + 旧号待迁移有效关系数」:超过十项整次失败,旧、新关系均不变。
// 与「新号已存在的同资产有效关系」冲突时整次失败回滚,不产生并存的有效关系。
// 返回每次迁移的审计事实;无有效关系时返回空。
func (w *AssociationWriter) Migrate(ctx context.Context, tx *gorm.DB, oldPhone, newPhone string) ([]accessauditapp.PhoneAssetAssociationChange, error) {
store := w.store.WithTx(tx)
if err := store.LockPhoneScopes(ctx, oldPhone, newPhone); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败")
}
locked, err := store.LockValidByPhones(ctx, oldPhone, newPhone)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号关联资产失败")
}
pending := make([]*model.PhoneAssetAssociation, 0, len(locked))
newPhoneValidCount := 0
for _, row := range locked {
if row.Phone == oldPhone {
pending = append(pending, row)
continue
}
newPhoneValidCount++
}
if newPhoneValidCount+len(pending) > constants.PhoneAssetAssociationMaxValidPerPhone {
return nil, errors.New(errors.CodeInvalidStatus, constants.PhoneAssetAssociationLimitMessage)
}
if len(pending) == 0 {
return nil, nil
}
now := time.Now()
if err := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).
Where("phone = ? AND status = ?", oldPhone, constants.PhoneAssetAssociationStatusValid).
Updates(map[string]any{"phone": newPhone, "updated_at": now}).Error; err != nil {
if isAssociationUniqueViolation(err) {
return nil, errors.New(errors.CodeInvalidStatus, "新手机号已存在与待迁移资产相同的有效关联,换绑已回滚")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移手机号资产关联失败")
}
oldPhoneMasked, newPhoneMasked := sanitizer.MaskPhone(oldPhone), sanitizer.MaskPhone(newPhone)
changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(pending))
for _, row := range pending {
changes = append(changes, accessauditapp.PhoneAssetAssociationChange{
AssociationID: row.ID, PhoneMasked: newPhoneMasked,
AssetType: row.AssetType, AssetID: row.AssetID,
Status: constants.PhoneAssetAssociationStatusValid, Source: row.Source,
BeforeData: map[string]any{"phone_masked": oldPhoneMasked},
AfterData: map[string]any{"phone_masked": newPhoneMasked},
})
}
return changes, nil
}
// isAssociationUniqueViolation 判断错误是否为有效关系部分唯一索引冲突。
func isAssociationUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == associationUniqueConstraint
}

View File

@@ -0,0 +1,235 @@
package phone_asset_association
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// TaskPayload 手机号—资产关联解绑导入 Worker 结构化载荷,与 Worker 侧载荷保持同一 JSON 契约。
type TaskPayload struct {
TaskID uint `json:"task_id"`
}
// New 创建手机号—资产关联后台服务。
// associationStore 用于解除关联taskStore 与 queueClient 用于受理 CSV 解绑导入任务。
func New(
db *gorm.DB,
associationStore *postgres.PhoneAssetAssociationStore,
taskStore *postgres.PhoneAssetUnbindImportTaskStore,
assetIdentifierStore *postgres.AssetIdentifierStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
queueClient *queue.Client,
auditWriter *audit.Writer,
) *Service {
return &Service{
db: db, associationStore: associationStore, taskStore: taskStore,
assetIdentifierStore: assetIdentifierStore, iotCardStore: iotCardStore,
deviceStore: deviceStore, queueClient: queueClient, auditWriter: auditWriter,
}
}
// CreateImportTask 创建 CSV 解绑导入任务并在同一事务写入创建审计,随后投递到独立导入队列。
// 解绑原因与二次确认在 DTO 层强制;任务级原因随任务行落库,供 Worker 读取后写入每次解除的失效原因。
func (s *Service) CreateImportTask(ctx context.Context, request *dto.CreatePhoneAssetUnbindImportRequest) (*dto.PhoneAssetUnbindImportTaskResponse, error) {
if request == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if !strings.HasPrefix(request.FileKey, constants.PhoneAssetUnbindImportStoragePrefix+"/") {
return nil, errors.New(errors.CodeInvalidParam, "导入文件Key不属于指定上传目录")
}
if !strings.EqualFold(filepath.Ext(request.FileKey), ".csv") {
return nil, errors.New(errors.CodeInvalidParam, "解绑导入文件必须为CSV格式")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "手机号资产解绑导入统一审计接缝未配置")
}
taskRecord := &model.PhoneAssetUnbindImportTask{
TaskNo: s.taskStore.GenerateTaskNo(), FileName: filepath.Base(request.FileKey),
StorageKey: request.FileKey, UnbindReason: request.Reason, Status: model.ImportTaskStatusPending,
ResultItems: model.PhoneAssetUnbindImportResults{},
CreatorName: middleware.GetUsernameFromContext(ctx),
BaseModel: model.BaseModel{Creator: userID, Updater: userID},
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, taskRecord); err != nil {
return err
}
return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultSuccess, nil, nil, "")
}); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建手机号资产解绑导入任务失败")
}
var enqueueErr error
if s.queueClient == nil {
enqueueErr = errors.New(errors.CodeTaskQueueError, "手机号资产解绑导入任务队列未配置")
} else {
enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypePhoneAssetUnbindImport,
TaskPayload{TaskID: taskRecord.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePhoneAssetUnbindImport)),
asynq.Timeout(constants.PhoneAssetUnbindImportTaskTimeout))
}
if enqueueErr != nil {
message := "解绑导入任务入队失败"
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := unbindImportTaskState(taskRecord)
hit, err := s.taskStore.WithTx(tx).MarkFailed(ctx, taskRecord.ID, message)
if err != nil {
return err
}
if !hit {
// 任务已到终态Enqueue 报错但消息实际已投递且 Worker 已跑完。
// 库内才是事实,绝不回写失败态,也不写失败审计。
return nil
}
// 失败原因必须回填到内存快照,响应与失败审计才与库内一致。
taskRecord.Status, taskRecord.ErrorMessage = model.ImportTaskStatusFailed, message
now := time.Now()
taskRecord.CompletedAt = &now
return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultFailed, before, unbindImportTaskState(taskRecord), strconv.Itoa(errors.CodeTaskQueueError))
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionPhoneAssetUnbindImportTaskCreated,
taskRecord.TaskNo, "", taskRecord.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
} else if taskRecord.Status != model.ImportTaskStatusFailed {
// 未命中非终态时重新读取任务行,让响应反映库内真实终态。
if stored, err := s.taskStore.GetByID(ctx, taskRecord.ID); err == nil {
taskRecord = stored
}
}
}
return toUnbindImportTaskResponse(taskRecord), nil
}
// ListImportTasks 分页查询解绑导入任务。
func (s *Service) ListImportTasks(ctx context.Context, request *dto.ListPhoneAssetUnbindImportRequest) (*dto.PhoneAssetUnbindImportTaskPageResult, error) {
if request == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
page, pageSize := request.Page, request.PageSize
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
tasks, total, err := s.taskStore.List(ctx, &store.QueryOptions{Page: page, PageSize: pageSize}, request.Status)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产解绑导入任务失败")
}
items := make([]*dto.PhoneAssetUnbindImportTaskResponse, 0, len(tasks))
for _, taskRecord := range tasks {
items = append(items, toUnbindImportTaskResponse(taskRecord))
}
return &dto.PhoneAssetUnbindImportTaskPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// GetImportTask 查询解绑导入任务详情与逐行结果。
// 逐行结果含解绑当时的完整手机号快照:关系已失效,只有快照能事后展示被解绑的手机号。
func (s *Service) GetImportTask(ctx context.Context, id uint) (*dto.PhoneAssetUnbindImportTaskDetailResponse, error) {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
taskRecord, err := s.taskStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "手机号资产解绑导入任务不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产解绑导入任务失败")
}
items := make([]dto.PhoneAssetUnbindImportItemResponse, 0, len(taskRecord.ResultItems))
for _, item := range taskRecord.ResultItems {
items = append(items, dto.PhoneAssetUnbindImportItemResponse{
Line: item.Line, AssetType: item.AssetType, AssetIdentifier: item.AssetIdentifier,
AssetID: item.AssetID, UnboundCount: item.UnboundCount, AssociatedPhones: item.AssociatedPhones,
Status: item.Status, StatusName: constants.GetPhoneAssetAssociationImportItemStatusName(item.Status),
Reason: item.Reason,
})
}
return &dto.PhoneAssetUnbindImportTaskDetailResponse{
PhoneAssetUnbindImportTaskResponse: *toUnbindImportTaskResponse(taskRecord), Items: items,
}, nil
}
// writeTaskAudit 在调用方事务内写导入任务创建或入队失败审计。
// 任务资源身份快照只保留注册表允许的最小字段。
func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, task *model.PhoneAssetUnbindImportTask, result string, before, after map[string]any, errorCode string) error {
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, task.ID, unbindImportTaskAuditPhase(result)),
ActionCode: constants.AuditActionPhoneAssetUnbindImportTaskCreated, Summary: "创建手机号资产解绑导入任务",
TaskID: task.ID, TaskNo: task.TaskNo,
Actor: audit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: task.ErrorMessage,
// 与 Worker 侧完成事件使用同一关联键,使同一任务的全部事件可按 correlation 串成一条时间线。
CorrelationID: task.TaskNo,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
},
BeforeData: before, AfterData: after,
})
}
// unbindImportTaskAuditPhase 返回任务创建阶段的稳定事件阶段名,失败入队使用独立阶段避免覆盖首次事件。
func unbindImportTaskAuditPhase(result string) string {
if result == constants.AuditResultSuccess {
return "created"
}
return "enqueue_failed"
}
// unbindImportTaskState 生成解绑导入任务状态快照。
func unbindImportTaskState(task *model.PhoneAssetUnbindImportTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "total_count": task.TotalCount,
"success_count": task.SuccessCount, "fail_count": task.FailCount,
}
}
func toUnbindImportTaskResponse(taskRecord *model.PhoneAssetUnbindImportTask) *dto.PhoneAssetUnbindImportTaskResponse {
response := &dto.PhoneAssetUnbindImportTaskResponse{
ID: taskRecord.ID, TaskNo: taskRecord.TaskNo, FileName: taskRecord.FileName,
UnbindReason: taskRecord.UnbindReason,
Status: taskRecord.Status, StatusName: model.ImportTaskStatusName(taskRecord.Status),
TotalCount: taskRecord.TotalCount, SuccessCount: taskRecord.SuccessCount, FailCount: taskRecord.FailCount,
ErrorMessage: taskRecord.ErrorMessage, CreatorName: taskRecord.CreatorName,
CreatedAt: taskRecord.CreatedAt.Format(time.RFC3339),
}
if taskRecord.StartedAt != nil {
response.StartedAt = taskRecord.StartedAt.Format(time.RFC3339)
}
if taskRecord.CompletedAt != nil {
response.CompletedAt = taskRecord.CompletedAt.Format(time.RFC3339)
}
return response
}

View File

@@ -0,0 +1,396 @@
// Package phone_asset_association 提供手机号—资产关联的后台查看与解除能力。
// 关联只能由 H5 短信验证建立:本包不提供任何创建或补录入口。
// 解除必须二次确认并填写原因,逐资产独立执行并返回逐项结果,部分成功不回滚成功项。
package phone_asset_association
import (
"context"
stderrors "errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
)
// Service 手机号—资产关联后台服务:关联查看、单项解除、按资产批量解除与解绑导入任务受理。
type Service struct {
db *gorm.DB
associationStore *postgres.PhoneAssetAssociationStore
taskStore *postgres.PhoneAssetUnbindImportTaskStore
assetIdentifierStore *postgres.AssetIdentifierStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
queueClient *queue.Client
auditWriter *audit.Writer
}
// Unbind 解除指定的一条手机号—资产关联。
// 路径主键即指定关系;锁定关系后复核资产数据范围,标记失效并与审计同事务提交。
func (s *Service) Unbind(ctx context.Context, request *dto.UnbindPhoneAssetAssociationRequest) (*dto.UnbindPhoneAssetAssociationResponse, error) {
if request == nil || request.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
now := time.Now()
var association *model.PhoneAssetAssociation
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := s.associationStore.WithTx(tx).LockValidByID(ctx, tx, request.ID)
if err != nil {
if err == gorm.ErrRecordNotFound {
// 已无有效关系:与越权、资产不存在返回同一文案。
return deniedError()
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号资产关联失败")
}
if _, err := s.ensureAssetInScope(ctx, tx, locked.AssetType, locked.AssetID); err != nil {
return err
}
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, []uint{locked.ID},
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID, now)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
}
if affected != 1 {
return deniedError()
}
if err := s.writeUnbindAudit(ctx, tx, operatorID, []*model.PhoneAssetAssociation{locked},
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, now); err != nil {
return err
}
association = locked
return nil
})
if err != nil {
s.recordUnbindFailure(ctx, operatorID, []accessauditapp.PhoneAssetAssociationChange{{AssociationID: request.ID}}, err)
return nil, err
}
return &dto.UnbindPhoneAssetAssociationResponse{
ID: association.ID,
AssetType: association.AssetType,
AssetID: association.AssetID,
UnboundCount: 1,
InvalidatedAt: now.Format(time.RFC3339),
}, nil
}
// BatchUnbind 按资产集合解除全部当前有效关系。
// 资产集合先去重,再逐资产独立事务执行:成功项提交、失败项保留原状,返回成功数、失败数与逐项结果。
func (s *Service) BatchUnbind(ctx context.Context, request *dto.BatchUnbindPhoneAssetAssociationRequest) (*dto.BatchUnbindPhoneAssetAssociationResponse, error) {
if request == nil || len(request.Assets) == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
assets := dedupeAssets(request.Assets)
items := make([]dto.BatchUnbindAssetResult, 0, len(assets))
successCount, failCount := 0, 0
for _, asset := range assets {
item := dto.BatchUnbindAssetResult{AssetType: asset.AssetType, AssetID: asset.AssetID}
unbound, err := s.unbindAsset(ctx, asset, request.Reason, operatorID)
if err != nil {
item.Success = false
item.Reason = unbindFailureReason(err)
failCount++
} else {
item.Success = true
item.UnboundCount = unbound
successCount++
}
items = append(items, item)
}
return &dto.BatchUnbindPhoneAssetAssociationResponse{
SuccessCount: successCount,
FailCount: failCount,
Items: items,
}, nil
}
// unbindAsset 解除单项资产的全部当前有效关系;该项独立事务,失败不影响其他项。
func (s *Service) unbindAsset(ctx context.Context, asset dto.BatchUnbindAssetItem, reason string, operatorID uint) (int, error) {
now := time.Now()
unbound := 0
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := s.ensureAssetInScope(ctx, tx, asset.AssetType, asset.AssetID); err != nil {
return err
}
rows, err := s.associationStore.WithTx(tx).LockValidByAsset(ctx, tx, asset.AssetType, asset.AssetID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产关联失败")
}
if len(rows) == 0 {
// 已无有效关系:与越权、资产不存在返回同一文案。
return deniedError()
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids,
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID, now)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
}
if int(affected) != len(rows) {
return deniedError()
}
if err := s.writeUnbindAudit(ctx, tx, operatorID, rows,
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, now); err != nil {
return err
}
unbound = len(rows)
return nil
})
if err != nil {
change := accessauditapp.PhoneAssetAssociationChange{AssetType: asset.AssetType, AssetID: asset.AssetID}
s.recordUnbindFailure(ctx, operatorID, []accessauditapp.PhoneAssetAssociationChange{change}, err)
return 0, err
}
return unbound, nil
}
// ensureAssetInScope 复核资产数据范围,返回资产展示名供审计使用。
// 关联表没有 shop_id数据范围必须落在资产归属店铺上ENG-AUTHZ-001
// 范围外资产与不存在资产一律返回同一文案,不形成可枚举差异。
func (s *Service) ensureAssetInScope(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) {
switch assetType {
case constants.AssetTypeIotCard:
var card model.IotCard
query := middleware.ApplyShopFilter(ctx, tx.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", assetID))
if err := query.Select("id", "iccid").First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", deniedError()
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "复核资产数据范围失败")
}
return card.ICCID, nil
case constants.AssetTypeDevice:
var device model.Device
query := middleware.ApplyShopFilter(ctx, tx.WithContext(ctx).Model(&model.Device{}).Where("id = ?", assetID))
if err := query.Select("id", "virtual_no").First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", deniedError()
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "复核资产数据范围失败")
}
return device.VirtualNo, nil
default:
return "", deniedError()
}
}
// writeUnbindAudit 在解除事务内逐条记录失效事实,手机号一律为脱敏值。
func (s *Service) writeUnbindAudit(
ctx context.Context,
tx *gorm.DB,
operatorID uint,
rows []*model.PhoneAssetAssociation,
method, reason string,
now time.Time,
) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "手机号资产关联审计接缝未配置")
}
displayName, err := s.ensureAssetInScope(ctx, tx, rows[0].AssetType, rows[0].AssetID)
if err != nil {
return err
}
changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(rows))
for _, row := range rows {
changes = append(changes, accessauditapp.PhoneAssetAssociationChange{
AssociationID: row.ID, PhoneMasked: sanitizer.MaskPhone(row.Phone),
AssetType: row.AssetType, AssetID: row.AssetID,
AssetDisplayName: displayName,
Status: constants.PhoneAssetAssociationStatusInvalid, Source: row.Source,
InvalidatedAt: &now, InvalidationMethod: method, InvalidationReason: reason,
BeforeData: map[string]any{"status": constants.PhoneAssetAssociationStatusValid},
AfterData: map[string]any{
"status": constants.PhoneAssetAssociationStatusInvalid,
"invalidated_at": now, "invalidation_method": method, "invalidation_reason": reason,
},
})
}
return s.auditWriter.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPhoneAssetAssociationUnbound,
Summary: "解除手机号资产关联",
OperatorID: operatorID, ActorKind: constants.AuditActorAccount,
ActorName: middleware.GetUsernameFromContext(ctx),
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
PhoneAssociations: changes,
})
}
// recordUnbindFailure 在业务回滚后以独立短事务补记解除失败或拒绝事实。
func (s *Service) recordUnbindFailure(ctx context.Context, operatorID uint, changes []accessauditapp.PhoneAssetAssociationChange, originalErr error) {
if s.auditWriter == nil {
return
}
accessauditapp.RecordFailure(ctx, s.db, s.auditWriter, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPhoneAssetAssociationUnbound,
Summary: "解除手机号资产关联被拒绝",
OperatorID: operatorID, ActorKind: constants.AuditActorAccount,
ActorName: middleware.GetUsernameFromContext(ctx),
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
PhoneAssociations: changes,
}, originalErr)
}
// unbindFailureReason 返回逐项失败原因;复用稳定错误文案,不拼接底层错误。
func unbindFailureReason(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.Message != "" {
return appErr.Message
}
return "解除失败"
}
// deniedError 构造越权、资产不存在与已无有效关系共用的统一失败。
func deniedError() error {
return errors.New(errors.CodeForbidden, constants.PhoneAssetAssociationDeniedMessage)
}
// dedupeAssets 按 (资产类型, 资产ID) 去重并保持首次出现顺序。
func dedupeAssets(items []dto.BatchUnbindAssetItem) []dto.BatchUnbindAssetItem {
seen := make(map[string]struct{}, len(items))
result := make([]dto.BatchUnbindAssetItem, 0, len(items))
for _, item := range items {
key := item.AssetType + ":" + strconv.FormatUint(uint64(item.AssetID), 10)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
// List 分页查询手机号—资产关联,返回资产、完整手机号、建立时间、建立来源与状态。
// 数据范围经资产归属店铺约束:关联表没有 shop_id必须落到卡与设备表判断
// 越权与不存在都表现为结果集为空,不形成可枚举差异。
// 关联手机号不做脱敏(读侧按数据范围返回完整值),审计与日志仍只写脱敏值。
func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociationRequest) (*dto.PhoneAssetAssociationPageResult, error) {
if request == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
filter := postgres.PhoneAssetAssociationListFilter{
Phone: strings.TrimSpace(request.Phone),
Status: request.Status,
CreatedAtStart: request.CreatedAtStart,
CreatedAtEnd: request.CreatedAtEnd,
ScopedShopIDs: middleware.GetSubordinateShopIDs(ctx),
}
if identifier := strings.TrimSpace(request.AssetIdentifier); identifier != "" {
assetType, assetID, err := assetSvc.ResolveIdentifier(ctx, s.assetIdentifierStore, s.iotCardStore, s.deviceStore, identifier)
if err != nil {
return nil, err
}
if assetType == "" {
// 标识无法定位资产:返回空页,与范围内无关联不可区分。
return &dto.PhoneAssetAssociationPageResult{
Items: []*dto.PhoneAssetAssociationResponse{}, Total: 0,
Page: normalizeListPage(request.Page), Size: normalizeListPageSize(request.PageSize),
}, nil
}
filter.AssetType, filter.AssetID = assetType, assetID
}
page, pageSize := normalizeListPage(request.Page), normalizeListPageSize(request.PageSize)
rows, total, err := s.associationStore.List(ctx, &store.QueryOptions{Page: page, PageSize: pageSize}, filter)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产关联失败")
}
identifiers, err := s.loadAssetIdentifiers(ctx, rows)
if err != nil {
return nil, err
}
items := make([]*dto.PhoneAssetAssociationResponse, 0, len(rows))
for _, row := range rows {
item := &dto.PhoneAssetAssociationResponse{
ID: row.ID, Phone: row.Phone, AssetType: row.AssetType, AssetID: row.AssetID,
AssetIdentifier: identifiers[assetIdentifierKey(row.AssetType, row.AssetID)],
Status: row.Status, StatusName: constants.GetPhoneAssetAssociationStatusName(row.Status),
Source: row.Source, EstablishedAt: row.EstablishedAt.Format(time.RFC3339),
}
if row.InvalidatedAt != nil {
item.InvalidatedAt = row.InvalidatedAt.Format(time.RFC3339)
item.InvalidationMethod = row.InvalidationMethod
item.InvalidationMethodName = constants.PhoneAssetAssociationInvalidateMethodName(row.InvalidationMethod)
item.InvalidationReason = row.InvalidationReason
}
items = append(items, item)
}
return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// loadAssetIdentifiers 按资产类型各一次 IN 批量读取资产当前标识,禁止逐行查询。
func (s *Service) loadAssetIdentifiers(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]string, error) {
result := make(map[string]string, len(rows))
cardIDs := make([]uint, 0, len(rows))
deviceIDs := make([]uint, 0, len(rows))
for _, row := range rows {
switch row.AssetType {
case constants.AssetTypeIotCard:
cardIDs = append(cardIDs, row.AssetID)
case constants.AssetTypeDevice:
deviceIDs = append(deviceIDs, row.AssetID)
}
}
if len(cardIDs) > 0 {
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询卡标识失败")
}
for _, card := range cards {
result[assetIdentifierKey(constants.AssetTypeIotCard, card.ID)] = card.ICCID
}
}
if len(deviceIDs) > 0 {
devices, err := s.deviceStore.GetByIDs(ctx, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询设备标识失败")
}
for _, device := range devices {
result[assetIdentifierKey(constants.AssetTypeDevice, device.ID)] = device.VirtualNo
}
}
return result, nil
}
func assetIdentifierKey(assetType string, assetID uint) string {
return assetType + ":" + strconv.FormatUint(uint64(assetID), 10)
}
// normalizeListPage / normalizeListPageSize 归一化分页,最大页大小沿用接口上限。
func normalizeListPage(page int) int {
if page <= 0 {
return constants.DefaultPage
}
return page
}
func normalizeListPageSize(pageSize int) int {
if pageSize <= 0 {
return constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
return constants.MaxPageSize
}
return pageSize
}