Files
junhong_cmp_fiber/internal/service/client_auth/phone_asset_association.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 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 通过
2026-09-15 11:54:56 +08:00

103 lines
3.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}