Files
break 575d056f54
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
AUG26-008。

- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
  tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
  tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
  不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
  消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
  审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
  重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
  超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
  保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
  通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
  不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
  场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
  以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。

验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
  退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
  openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。

运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
2026-09-14 09:45:13 +08:00

241 lines
9.9 KiB
Go
Raw Permalink 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 distributionwithdrawal
import (
"context"
"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 创建待审批注册记录。
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
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, "密码哈希失败")
}
var parent *model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
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 {
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/sourceAppend 会以「审计操作者或入口不符合动作注册规则」失败)。
// 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
}
return result, nil
}
// 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.CodeInvalidParam, "分销码不可用")
}
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(&registration, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
}
return &registration, nil
}