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 }