Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
405 lines
19 KiB
Go
405 lines
19 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, s.parentBusinessOwnerName(ctx, 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) {
|
||
if appErr.Code == errors.CodeTooManyRequests {
|
||
return 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 生成企业微信审批表单业务快照。
|
||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||
//
|
||
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
|
||
// MUST NOT 以提交人、上级店铺主账号或其它账号填充。该字段是只读快照,审批通过时写入新店铺的
|
||
// 初始业务员仍按既有规则取上级业务员当时值,不因本快照与通过时值不同而改写通过结果。
|
||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop, businessOwnerName string) map[string]any {
|
||
owner := strings.TrimSpace(businessOwnerName)
|
||
absenceMark := ""
|
||
if owner == "" {
|
||
absenceMark = agentDistributionBusinessOwnerAbsenceMark
|
||
}
|
||
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),
|
||
constants.ApprovalFieldDistributionBusinessOwner: owner,
|
||
constants.ApprovalFieldDistributionBusinessOwnerAbsence: absenceMark,
|
||
}
|
||
}
|
||
|
||
// agentDistributionBusinessOwnerAbsenceMark 是注册审批材料中「上级店铺无业务员」的显式标记。
|
||
// 与「字段未填」区分开,使审批人看到的是确认无业务员而非漏填。
|
||
const agentDistributionBusinessOwnerAbsenceMark = "无"
|
||
|
||
// parentBusinessOwnerName 读取上级店铺当前业务员的名称快照。
|
||
// 判定与店铺业务员投影的可用口径一致:账号存在、为平台账号、启用且未删除;
|
||
// 任一条件不满足时返回空串,由审批材料显式标记为无,MUST NOT 用其它账号填充。
|
||
func (s *RegistrationService) parentBusinessOwnerName(ctx context.Context, parent *model.Shop) string {
|
||
if parent == nil || parent.BusinessOwnerAccountID == nil || *parent.BusinessOwnerAccountID == 0 {
|
||
return ""
|
||
}
|
||
var account model.Account
|
||
if err := s.db.WithContext(ctx).
|
||
Select("id", "username", "user_type", "status").
|
||
First(&account, *parent.BusinessOwnerAccountID).Error; err != nil {
|
||
return ""
|
||
}
|
||
if account.UserType != constants.UserTypePlatform || account.Status != constants.StatusEnabled || account.DeletedAt.Valid {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(account.Username)
|
||
}
|
||
|
||
// 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
|
||
}
|