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,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
}