// 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, middleware.GetUsernameFromContext(ctx), 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, middleware.GetUsernameFromContext(ctx), 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 } // 当前有效关联数量必须与「最多关联十项」判定同口径,且按当页手机号集合一次聚合,禁止逐行查询。 validCounts, err := s.loadValidAssociationCounts(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), ValidAssociationCount: validCounts[row.Phone], } 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 // 最近解绑人只取解绑当时的名称快照与账号标识:有效关系保持为空,不以创建人或更新时间填充。 item.InvalidatorID = row.Invalidator item.InvalidatorName = row.InvalidatorNameSnapshot } items = append(items, item) } return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil } // loadValidAssociationCounts 按当页涉及的手机号集合一次聚合当前有效关联数量,禁止逐手机号查询。 func (s *Service) loadValidAssociationCounts(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]int64, error) { phones := make([]string, 0, len(rows)) seen := make(map[string]struct{}, len(rows)) for _, row := range rows { if _, ok := seen[row.Phone]; ok { continue } seen[row.Phone] = struct{}{} phones = append(phones, row.Phone) } counts, err := s.associationStore.CountValidByPhones(ctx, phones) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量统计手机号有效关联数量失败") } return counts, 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 }