All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
- 提交写事务内先取事务级 advisory lock,再校验既有未删除账号/店铺与其它待审批申请: 手机号 1014、用户名 1013、店铺编号 1031、待审批占用 1007,冲突不落库且不消费短信验证码 - 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册,形成新记录与新审批实例 - 并发同关键字段提交串行裁决,同一关键字段至多一条待审批记录 - 审批通过建店建号前复检关键字段,冲突返回可定位错误并整体回滚,不再以裸数据库错误收场 - 归档 Change fix-agent-distribution-registration-duplicate-guard 并同步主 Spec 验证:junhong_cmp_test + Redis DB 6 受控脚手架 37 项通过 / 0 项失败(含 6 路并发仅 1 条落库、 审批冲突回滚与无冲突建店回归),清理后 fixture 残留 0;gofmt/go build/go vet 全绿; openspec validate --all 35 项通过、doctor healthy、context-health 通过
366 lines
17 KiB
Go
366 lines
17 KiB
Go
package distributionwithdrawal
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
|
||
type RegistrationResult struct {
|
||
RegistrationID uint
|
||
Status int
|
||
ApprovalInstanceID uint
|
||
ApprovalStatus int
|
||
}
|
||
|
||
// RegistrationService 受理公开扫码注册。
|
||
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
|
||
type RegistrationService struct {
|
||
db *gorm.DB
|
||
verifier VerificationCodeVerifier
|
||
approval approvalapp.Port
|
||
audit AuditWriter
|
||
}
|
||
|
||
// NewRegistrationService 创建公开扫码注册用例。
|
||
func NewRegistrationService(
|
||
db *gorm.DB,
|
||
verifier VerificationCodeVerifier,
|
||
approval approvalapp.Port,
|
||
audit AuditWriter,
|
||
) *RegistrationService {
|
||
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
|
||
}
|
||
|
||
// Register 创建待审批注册记录。
|
||
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
|
||
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
|
||
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
|
||
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
|
||
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
|
||
func (s *RegistrationService) Register(
|
||
ctx context.Context,
|
||
input distributiondomain.RegistrationInput,
|
||
code string,
|
||
) (*RegistrationResult, error) {
|
||
if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
|
||
}
|
||
normalized, err := distributiondomain.ValidateRegistrationInput(input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
code = strings.TrimSpace(code)
|
||
if code == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空")
|
||
}
|
||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||
}
|
||
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
|
||
if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
|
||
return nil, verificationFailure(err)
|
||
}
|
||
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
correlationID := "agent_distribution:registration:" + uuid.NewString()
|
||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||
BusinessType: constants.ApprovalBusinessTypeAgentDistribution,
|
||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
registration := &model.AgentDistributionRegistration{
|
||
DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID,
|
||
Phone: normalized.Phone, PasswordHash: string(passwordHash),
|
||
ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username,
|
||
ContactName: normalized.ContactName, Province: normalized.Province,
|
||
City: normalized.City, District: normalized.District, Address: normalized.Address,
|
||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||
}
|
||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||
registrationApprovalForm(normalized, parent))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result := &RegistrationResult{}
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 关键字段门禁与注册记录插入必须同处一个串行化区间:先取 advisory lock,
|
||
// 再在同一事务内校验并落库,避免并发提交落下两条指向同一手机号/用户名/店铺编号的待审批申请。
|
||
if err := lockRegistrationKeyScopes(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||
return err
|
||
}
|
||
if err := ensureRegistrationKeysAvailable(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||
return err
|
||
}
|
||
if err := ensureNoPendingRegistration(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.WithContext(ctx).Create(registration).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败")
|
||
}
|
||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||
constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID,
|
||
submitterSnapshot, requestSnapshot, correlationID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil {
|
||
return err
|
||
}
|
||
// 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件,
|
||
// 因而没有可信的 actor/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。
|
||
// tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列,
|
||
// 故移除该非必需审计而不是伪造操作者身份。
|
||
result.RegistrationID = registration.ID
|
||
result.Status = registration.Status
|
||
result.ApprovalInstanceID = reference.InstanceID
|
||
result.ApprovalStatus = reference.Status
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
|
||
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
|
||
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
|
||
return result, nil
|
||
}
|
||
|
||
// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。
|
||
const registrationKeyScopePrefix = "agent-distribution-registration:"
|
||
|
||
// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。
|
||
// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」,
|
||
// 因此按 key 字符串升序取事务级 advisory lock;升序保证并发提交不会形成 A→B / B→A 死锁环。
|
||
// 锁随本次事务提交或回滚自动释放。
|
||
func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||
keys := []string{
|
||
registrationKeyScopePrefix + "phone:" + phone,
|
||
registrationKeyScopePrefix + "username:" + username,
|
||
registrationKeyScopePrefix + "shop_code:" + shopCode,
|
||
}
|
||
slices.Sort(keys)
|
||
for _, key := range keys {
|
||
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。
|
||
// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引;
|
||
// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。
|
||
func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败")
|
||
} else if exists {
|
||
return errors.New(errors.CodePhoneExists, "手机号已被使用")
|
||
}
|
||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败")
|
||
} else if exists {
|
||
return errors.New(errors.CodeUsernameExists, "用户名已存在")
|
||
}
|
||
if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败")
|
||
} else if exists {
|
||
return errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。
|
||
func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) {
|
||
var count int64
|
||
if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil {
|
||
return false, err
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。
|
||
// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。
|
||
func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||
var pending model.AgentDistributionRegistration
|
||
err := tx.WithContext(ctx).
|
||
Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)",
|
||
constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode).
|
||
Order("id ASC").First(&pending).Error
|
||
switch {
|
||
case err == nil:
|
||
return pendingKeyConflict(&pending, phone, username)
|
||
case stderrors.Is(err, gorm.ErrRecordNotFound):
|
||
return nil
|
||
default:
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败")
|
||
}
|
||
}
|
||
|
||
// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。
|
||
// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。
|
||
func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error {
|
||
switch {
|
||
case pending.Phone == phone:
|
||
return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果")
|
||
case pending.Username == username:
|
||
return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果")
|
||
default:
|
||
return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果")
|
||
}
|
||
}
|
||
|
||
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
|
||
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
|
||
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
|
||
var parent model.Shop
|
||
if err := s.db.WithContext(ctx).
|
||
Where("distribution_code = ?", distributionCode).
|
||
First(&parent).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
||
}
|
||
if parent.Status != constants.ShopStatusEnabled {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
|
||
}
|
||
return &parent, nil
|
||
}
|
||
|
||
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||
func verificationFailure(err error) error {
|
||
var appErr *errors.AppError
|
||
if stderrors.As(err, &appErr) {
|
||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||
}
|
||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||
}
|
||
|
||
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
|
||
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
|
||
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
|
||
func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) {
|
||
var account model.Account
|
||
if err := db.WithContext(ctx).
|
||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
|
||
First(&account).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
|
||
}
|
||
return &account, nil
|
||
}
|
||
|
||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
|
||
return map[string]any{
|
||
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
|
||
constants.ApprovalFieldDistributionParentShopID: parent.ID,
|
||
constants.ApprovalFieldDistributionParentShopName: parent.ShopName,
|
||
constants.ApprovalFieldDistributionShopName: input.ShopName,
|
||
constants.ApprovalFieldDistributionShopCode: input.ShopCode,
|
||
constants.ApprovalFieldDistributionUsername: input.Username,
|
||
constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone),
|
||
constants.ApprovalFieldDistributionContactName: input.ContactName,
|
||
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
|
||
input.Province + input.City + input.District + input.Address),
|
||
}
|
||
}
|
||
|
||
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
|
||
func attachRegistrationInstance(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
registration *model.AgentDistributionRegistration,
|
||
instanceID uint,
|
||
) error {
|
||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||
Where("id = ? AND approval_instance_id IS NULL", registration.ID).
|
||
Update("approval_instance_id", instanceID)
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败")
|
||
}
|
||
if result.RowsAffected != 1 {
|
||
return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化")
|
||
}
|
||
registration.ApprovalInstanceID = &instanceID
|
||
return nil
|
||
}
|
||
|
||
// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。
|
||
func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any {
|
||
instanceID := uint(0)
|
||
if registration.ApprovalInstanceID != nil {
|
||
instanceID = *registration.ApprovalInstanceID
|
||
}
|
||
return map[string]any{
|
||
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
|
||
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
|
||
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
|
||
"username": registration.Username, "shop_code": registration.ShopCode,
|
||
"status": registration.Status, "approval_instance_id": instanceID,
|
||
}
|
||
}
|
||
|
||
// uintText 将无符号整数转换为审计标识与键的十进制文本。
|
||
func uintText(value uint) string {
|
||
return strconv.FormatUint(uint64(value), 10)
|
||
}
|
||
|
||
// intText 将整数转换为审计标识与键的十进制文本。
|
||
func intText(value int) string {
|
||
return strconv.Itoa(value)
|
||
}
|
||
|
||
// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。
|
||
func composeAuditEventID(parts ...string) (string, error) {
|
||
eventID := strings.Join(parts, ":")
|
||
if len(eventID) > 128 {
|
||
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
|
||
}
|
||
return eventID, nil
|
||
}
|
||
|
||
// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。
|
||
func lockRegistrationForUpdate(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
id uint,
|
||
) (*model.AgentDistributionRegistration, error) {
|
||
var registration model.AgentDistributionRegistration
|
||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
First(®istration, id).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
|
||
}
|
||
return ®istration, nil
|
||
}
|