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