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 通过
103 lines
3.6 KiB
Go
103 lines
3.6 KiB
Go
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)
|
||
}
|