Files
junhong_cmp_fiber/internal/service/phone_asset_association/association_write.go
邹进 9ced14fa1b fix: 第1批 F1、M12 一资产一号与换货按手机号关联校验
- F1 一个资产同时只能有一个有效手机号关联,他号绑定返回 1192(不含号码),同号幂等
- 登录 need_bind_phone 改看资产是否已有有效关联,新增 asset_phone_bound、asset_phone_is_own;H5 绑定按客户/资产两侧状态分别写入
- 客户换号不再迁移资产关联
- M12 换货新资产有有效手机号关联即拒绝,号码只在 data.phone;换货完成清空旧资产客户绑定并失效旧资产关联(exchange_completed),均不迁移
- OpenSpec Change enforce-single-phone-per-asset:11/16,H5 短信与换货端到端验证待测试部署后完成
2026-10-12 00:10:41 +09:00

113 lines
4.8 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 phone_asset_association
import (
"context"
"time"
"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"
)
// 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)
// 一资产一号:先查后写,不加资产锁(两个号码同时为同一资产完成验证属已接受的极小概率,由只读统计发现后后台解除)。
assetPhones, err := store.ListValidByAsset(ctx, assetType, assetID)
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询资产有效手机号关联失败")
}
for _, bound := range assetPhones {
if bound == phone {
return false, nil
}
}
if len(assetPhones) > 0 {
// 提示与响应不含任何号码。
return false, errors.New(errors.CodeAssetPhoneAlreadyBound)
}
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
}