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