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,51 @@
package asset
import (
"context"
"gorm.io/gorm"
"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"
)
// ResolveIdentifier 按资产标识定位资产:(资产类型, 资产ID)。
// 先查全局标识注册表,再按设备与卡的既有标识回退,与资产详情解析口径一致;
// 未命中返回空类型,由调用方按「不可区分」处理。
func ResolveIdentifier(
ctx context.Context,
registry *postgres.AssetIdentifierStore,
cardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
identifier string,
) (string, uint, error) {
if registry != nil {
record, err := registry.FindByIdentifier(ctx, identifier)
if err != nil {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败")
}
if record != nil {
return record.AssetType, record.AssetID, nil
}
}
if deviceStore != nil {
device, err := deviceStore.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
return constants.AssetTypeDevice, device.ID, nil
}
if err != nil && err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if cardStore != nil {
card, err := cardStore.GetByIdentifier(ctx, identifier)
if err == nil && card != nil {
return constants.AssetTypeIotCard, card.ID, nil
}
if err != nil && err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
}
}
return "", 0, nil
}

View File

@@ -52,6 +52,7 @@ type Service struct {
iotCardService IotCardRefresher
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
associationStore *postgres.PhoneAssetAssociationStore
auditWriter *infraAudit.Writer
packageExpiryQuery PackageExpiryResolver
}
@@ -61,6 +62,11 @@ func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
// SetPhoneAssetAssociationStore 注入手机号—资产关联 store供资产详情投影关联手机号。
func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) {
s.associationStore = store
}
// New 创建资产服务实例
func New(
db *gorm.DB,
@@ -282,9 +288,29 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
// 查 Redis 保护期
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID)
// 关联手机号:详情为单资产单次查询,按数据范围返回完整手机号。
phones, err := s.resolveAssociatedPhones(ctx, constants.AssetTypeDevice, device.ID)
if err != nil {
return nil, err
}
resp.AssociatedPhones = phones
return resp, nil
}
// resolveAssociatedPhones 读取单项资产当前全部有效关联手机号。
// 资产本身已按数据范围解析,关联手机号不额外放行或收紧范围。
func (s *Service) resolveAssociatedPhones(ctx context.Context, assetType string, assetID uint) ([]string, error) {
if s.associationStore == nil {
return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化")
}
phones, err := s.associationStore.ListValidByAsset(ctx, assetType, assetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联手机号失败")
}
return phones, nil
}
// buildCardResolveResponse 构建卡类型的资产解析响应
func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) {
resp := &dto.AssetResolveResponse{
@@ -341,6 +367,13 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC
// 查套餐系列名称
s.fillSeriesName(ctx, resp)
// 关联手机号:详情为单资产单次查询,按数据范围返回完整手机号。
phones, err := s.resolveAssociatedPhones(ctx, constants.AssetTypeIotCard, card.ID)
if err != nil {
return nil, err
}
resp.AssociatedPhones = phones
return resp, nil
}

View File

@@ -0,0 +1,102 @@
package client_auth
import (
"context"
"sort"
"gorm.io/gorm"
"gorm.io/gorm/clause"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// lockPhoneRowsInIDOrder 在事务内按手机号行 id ASC 固定顺序加锁。
// bind-phone 只锁一行、change-phone 锁两行;统一升序后两条路径不会形成 A→B / B→A 死锁环。
// 调用方必须已先取手机号 advisory lock行锁无法覆盖「尚无手机号行」的新号。
func (s *Service) lockPhoneRowsInIDOrder(ctx context.Context, tx *gorm.DB, ids []uint) error {
ordered := make([]uint, 0, len(ids))
seen := make(map[uint]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ordered = append(ordered, id)
}
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
for _, id := range ordered {
var row model.PersonalCustomerPhone
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
continue
}
return errors.Wrap(errors.CodeInternalError, err, "锁定手机号行失败")
}
}
return nil
}
// phoneRowIDsByPhone 查询指定手机号当前启用的手机号行 id。
func (s *Service) phoneRowIDsByPhone(ctx context.Context, tx *gorm.DB, phones ...string) ([]uint, error) {
var ids []uint
if err := tx.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
Where("phone IN ? AND status = ?", phones, 1).
Order("id ASC").Pluck("id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号行失败")
}
return ids, nil
}
// requirePhoneBinding 返回全局强制绑定开关取值,默认开启。
// 开关关闭时本特性必须完全惰性:不查询、不建立、不迁移、不失效任何关联。
func requirePhoneBinding() bool {
cfg := config.Get()
if cfg == nil {
return true
}
return cfg.Client.RequirePhoneBinding
}
// lockPhoneScopesInOrder 在事务内、任何行锁之前为手机号取稳定串行化点。
// 按号码字符串升序取事务级 advisory lock等价于「手机号」这一逻辑实体的固定加锁次序。
func (s *Service) lockPhoneScopesInOrder(ctx context.Context, tx *gorm.DB, phones ...string) error {
if err := s.associationStore.WithTx(tx).LockPhoneScopes(ctx, phones...); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "锁定手机号串行化点失败")
}
return nil
}
// establishAssociation 委托关联写入规则完成幂等建联与十项上限判定。
// 已建立有效关系时返回 false 且不产生第二条关系;无当前访问资产身份时不建立关联。
// 开关关闭时完全短路,只保留账号手机号绑定语义。
func (s *Service) establishAssociation(
ctx context.Context,
tx *gorm.DB,
customer *model.PersonalCustomer,
phone, assetType string,
assetID uint,
) (bool, error) {
if !requirePhoneBinding() {
return false, nil
}
return s.associationWriter.Establish(ctx, tx, customer, phone, assetType, assetID)
}
// migrateAssociations 委托关联写入规则完成换绑原子迁移与上限/冲突判定。
// 开关关闭时完全短路,不迁移任何关联。
func (s *Service) migrateAssociations(
ctx context.Context,
tx *gorm.DB,
oldPhone, newPhone string,
) ([]accessauditapp.PhoneAssetAssociationChange, error) {
if !requirePhoneBinding() {
return nil, nil
}
return s.associationWriter.Migrate(ctx, tx, oldPhone, newPhone)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -20,6 +21,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
@@ -46,6 +48,7 @@ type Service struct {
openidStore *postgres.PersonalCustomerOpenIDStore
customerStore *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
associationStore *postgres.PhoneAssetAssociationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
wechatConfigService *wechatConfigSvc.Service
@@ -56,6 +59,7 @@ type Service struct {
wechatCache kernel.CacheInterface
customerBinding *customerBinding.Service
accessAudit accessauditapp.Writer
associationWriter *associationSvc.AssociationWriter
}
// New 创建 C 端认证服务实例
@@ -64,6 +68,7 @@ func New(
openidStore *postgres.PersonalCustomerOpenIDStore,
customerStore *postgres.PersonalCustomerStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
associationStore *postgres.PhoneAssetAssociationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
wechatConfigService *wechatConfigSvc.Service,
@@ -79,6 +84,7 @@ func New(
openidStore: openidStore,
customerStore: customerStore,
phoneStore: phoneStore,
associationStore: associationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
wechatConfigService: wechatConfigService,
@@ -89,6 +95,7 @@ func New(
wechatCache: wechat.NewRedisCache(redisClient),
customerBinding: binding,
accessAudit: accessAudit,
associationWriter: associationSvc.NewAssociationWriter(associationStore, accessAudit),
}
}
@@ -291,26 +298,28 @@ func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest,
}
// BindPhone A5 绑定手机号
func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
// POST /api/c/v1/auth/bind-phone
// 无主手机号时建立账号手机号;已有主手机号且提交号码与主号一致、验证码有效时,
// 幂等建立当前访问资产与该手机号的关联且不修改账号手机号;提交号码与主号不一致仍拒绝。
// 请求不含当前访问资产身份时只完成账号手机号绑定,不建立关联。
func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType string, assetID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
if s.db == nil || s.accessAudit == nil || s.associationStore == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
}
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
primary, primaryErr := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if primaryErr == nil {
return s.bindExistingPrimaryPhone(ctx, customer, primary, assetType, assetID, req)
}
if primaryErr != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
@@ -338,6 +347,10 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
Status: 1,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 固定加锁次序先取手机号串行化点advisory再按 id ASC 锁行。
if err := s.lockPhoneScopesInOrder(ctx, tx, req.Phone); err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
@@ -361,10 +374,17 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
if err := tx.Create(record).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
}
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess,
))
map[string]any{"phone": sanitizer.MaskPhone(record.Phone)}, "手机号已绑定", constants.AuditResultSuccess,
)); err != nil {
return err
}
// 建联与账号手机号同事务:超限或写入失败时账号手机号一并回滚。
if _, err := s.establishAssociation(ctx, tx, customer, record.Phone, assetType, assetID); err != nil {
return err
}
return nil
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err)
@@ -377,6 +397,63 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
}, nil
}
// bindExistingPrimaryPhone 处理已有主手机号的绑定请求。
// 提交号码等于主号且验证码有效时幂等建立关联且不改账号手机号;号码不一致仍拒绝换号。
func (s *Service) bindExistingPrimaryPhone(
ctx context.Context,
customer *model.PersonalCustomer,
primary *model.PersonalCustomerPhone,
assetType string,
assetID uint,
req *dto.BindPhoneRequest,
) (*dto.BindPhoneResponse, error) {
if primary.Phone != req.Phone {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
now := time.Now()
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 固定加锁次序先取手机号串行化点advisory再按 id ASC 锁行。
if err := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone); err != nil {
return err
}
if err := s.lockPhoneRowsInIDOrder(ctx, tx, []uint{primary.ID}); err != nil {
return err
}
var locked model.PersonalCustomerPhone
if err := tx.WithContext(ctx).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customer.ID, true, 1).
First(&locked).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAlreadyBoundPhone)
}
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if locked.Phone != req.Phone {
// 并发换绑已把主号改成其他号码:提交号码不再等于账号手机号,按既有语义拒绝。
return errors.New(errors.CodeAlreadyBoundPhone)
}
_, err := s.establishAssociation(ctx, tx, customer, locked.Phone, assetType, assetID)
return err
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, primary, err)
return nil, err
}
return &dto.BindPhoneResponse{
Phone: req.Phone,
BoundAt: now.Format("2006-01-02 15:04:05"),
}, nil
}
// ChangePhone A6 换绑手机号
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
if req == nil {
@@ -421,8 +498,22 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
now := time.Now()
var beforeData map[string]any
var failurePhone *model.PersonalCustomerPhone
var migrationChanges []accessauditapp.PhoneAssetAssociationChange
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
// 统一加锁顺序:先按手机号行 id ASC 锁定旧、新手机号行,再由关联迁移按 id ASC 锁定两侧有效关系行。
// 换绑两行的加锁顺序与 bind-phone 的单行加锁一致,两条路径不会形成 A→B / B→A 死锁环。
if idErr := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone, req.NewPhone); idErr != nil {
return idErr
}
phoneIDs, idErr := s.phoneRowIDsByPhone(ctx, tx, primary.Phone, req.NewPhone)
if idErr != nil {
return idErr
}
if idErr := s.lockPhoneRowsInIDOrder(ctx, tx, phoneIDs); idErr != nil {
return idErr
}
// 手机号行已在本事务内锁定,此处只复核主号归属与号码,不重复加锁。
if err := tx.WithContext(ctx).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1).
First(primary).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -435,13 +526,19 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
}
current := *primary
failurePhone = &current
beforeData = map[string]any{"phone": primary.Phone}
beforeData = map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
}
// 关联迁移先于账号手机号改写:超限或与既有关系冲突时整次失败,旧、新关系均保持原状。
changes, migrateErr := s.migrateAssociations(ctx, tx, primary.Phone, req.NewPhone)
if migrateErr != nil {
return migrateErr
}
migrationChanges = changes
if err := tx.Model(primary).Updates(map[string]any{
"phone": req.NewPhone,
"verified_at": now,
@@ -451,10 +548,22 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
}
primary.Phone = req.NewPhone
primary.VerifiedAt = &now
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess,
))
map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}, "手机号已更换", constants.AuditResultSuccess,
)); err != nil {
return err
}
if len(migrationChanges) == 0 {
return nil
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPhoneAssetAssociationMigrated,
Summary: "换绑手机号并迁移资产关联",
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PhoneAssociations: migrationChanges,
})
})
if err != nil {
if failurePhone == nil {
@@ -889,6 +998,11 @@ func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, a
return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID)
}
// issueLoginToken 签发登录令牌并判定是否需要手机号验证。
// 三支判定:无主手机号 → true有主手机号但未与当前访问资产存在有效关系 → true
// 已存在有效关系 → false。全局开关关闭时恒为 false且不查询、不创建、不删除任何关系。
// 关联查询使用当前访问资产的 asset_type/asset_id不使用 phone claimphone 是登录时快照,
// 换绑后到下次登录前仍是旧号。该字段只作前端提示,不改变任何资源授权。
func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) {
// 查询用户已绑定的主手机号,写入 JWT供后续接口直接从 context 取用
var boundPhone string
@@ -903,8 +1017,19 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp
boundPhone = primaryPhone.Phone
} else if phoneErr != gorm.ErrRecordNotFound {
return "", false, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败")
} else if requirePhoneBinding {
needBindPhone = true
}
// 开关关闭必须完全短路:既不查询也不写任何关系,否则会出现「关闭开关却写库」的越权写入。
if requirePhoneBinding {
if boundPhone == "" {
needBindPhone = true
} else {
associated, err := s.associationStore.ExistsValid(ctx, boundPhone, assetType, assetID)
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号资产关联失败")
}
needBindPhone = !associated
}
}
token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID)

View File

@@ -42,6 +42,7 @@ type Service struct {
assetIdentifierStore *postgres.AssetIdentifierStore
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
associationStore *postgres.PhoneAssetAssociationStore
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
@@ -59,6 +60,11 @@ func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.B
s.observationSeries = dispatcher
}
// SetPhoneAssetAssociationStore 注入手机号—资产关联 store用于列表响应回填关联手机号
func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) {
s.associationStore = store
}
type deviceControlObservationSnapshot struct {
SourceCardID uint
TargetCardID uint
@@ -260,6 +266,14 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if err != nil {
return nil, err
}
// 关联手机号按当页资产集合一次 IN 批量聚合后装配,禁止逐资产查询形成 N+1。
if s.associationStore == nil {
return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化")
}
associatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备关联手机号失败")
}
shopMap := s.loadShopData(ctx, devices)
seriesMap := s.loadSeriesNames(ctx, devices)
@@ -294,6 +308,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
item.PackageExpiryEstimate = expiryEstimates[device.ID]
item.AssociatedPhones = associatedPhones[device.ID]
if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

View File

@@ -69,6 +69,7 @@ type Service struct {
assetIdentifierStore *postgres.AssetIdentifierStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
associationStore *postgres.PhoneAssetAssociationStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
@@ -137,6 +138,11 @@ func (s *Service) SetEnterpriseCardAuthStore(store *postgres.EnterpriseCardAutho
s.enterpriseCardAuthStore = store
}
// SetPhoneAssetAssociationStore 注入手机号—资产关联 store用于列表响应回填关联手机号
func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) {
s.associationStore = store
}
// SetEnterpriseStore 注入企业 store用于批量加载企业名称
func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) {
s.enterpriseStore = store
@@ -299,6 +305,14 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if err != nil {
return nil, err
}
// 关联手机号按当页资产集合一次 IN 批量聚合后装配,禁止逐资产查询形成 N+1。
if s.associationStore == nil {
return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化")
}
associatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeIotCard, cardIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡关联手机号失败")
}
shopMap := s.loadShopNames(ctx, cards)
//TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的
@@ -340,6 +354,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap)
item.PackageExpiryEstimate = expiryEstimates[card.ID]
item.AssociatedPhones = associatedPhones[card.ID]
if eid, ok := cardAuthMap[card.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

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
}