Files
junhong_cmp_fiber/internal/domain/distribution/distribution.go
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

189 lines
7.9 KiB
Go

// Package distribution 收口代理分销注册、提现资格与提现审批的领域不变量。
// 本包不依赖 Fiber、GORM、Redis、Asynq 或具体第三方 SDK。
package distribution
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
distributionCodeBytes = 16
phoneMaskedKeepPrefix = 3
phoneMaskedKeepSuffix = 4
codeMaskedKeepPrefix = 4
codeMaskedKeepSuffix = 4
)
// GenerateDistributionCode 生成 32 位十六进制随机分销码。
// 唯一性由数据库条件唯一索引兜底,调用方在冲突时重新生成。
func GenerateDistributionCode() (string, error) {
buf := make([]byte, distributionCodeBytes)
if _, err := rand.Read(buf); err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "生成分销码失败")
}
return hex.EncodeToString(buf), nil
}
// ValidateRegistrationInput 规范化并校验扫码注册输入。
// 手机号、用户名、店铺编号与店铺名称由公开接口必填;密码长度沿用账号体系既有下限。
func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, error) {
input.DistributionCode = strings.TrimSpace(input.DistributionCode)
input.Phone = strings.TrimSpace(input.Phone)
input.Username = strings.TrimSpace(input.Username)
input.ShopName = strings.TrimSpace(input.ShopName)
input.ShopCode = strings.TrimSpace(input.ShopCode)
input.ContactName = strings.TrimSpace(input.ContactName)
input.Province = strings.TrimSpace(input.Province)
input.City = strings.TrimSpace(input.City)
input.District = strings.TrimSpace(input.District)
input.Address = strings.TrimSpace(input.Address)
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" ||
input.ShopName == "" || input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
if len(input.Phone) != 11 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
}
if len(input.Username) < 3 || len(input.Username) > 50 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符")
}
if len(input.Password) < 6 || len(input.Password) > 64 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符")
}
return input, nil
}
// ValidateQualificationInput 规范化并校验提现资料资格输入。
// 企业主体必须填写统一社会信用代码,个人主体必须填写法人身份证号;
// 发票仅企业可选,且抬头与统一社会信用代码必须与签约主体一致。
func ValidateQualificationInput(input QualificationInput) (QualificationInput, error) {
input.SubjectCode = strings.TrimSpace(input.SubjectCode)
input.LegalPersonIDCard = strings.TrimSpace(input.LegalPersonIDCard)
input.ContractFileKey = strings.TrimSpace(input.ContractFileKey)
input.IDCardFrontFileKey = strings.TrimSpace(input.IDCardFrontFileKey)
input.IDCardBackFileKey = strings.TrimSpace(input.IDCardBackFileKey)
input.BusinessLicenseFileKey = strings.TrimSpace(input.BusinessLicenseFileKey)
input.ShopFrontFileKey = strings.TrimSpace(input.ShopFrontFileKey)
input.InvoiceFileKey = strings.TrimSpace(input.InvoiceFileKey)
input.InvoiceTitle = strings.TrimSpace(input.InvoiceTitle)
input.InvoiceSubjectCode = strings.TrimSpace(input.InvoiceSubjectCode)
switch input.SubjectType {
case constants.WithdrawalQualificationSubjectTypeEnterprise:
if input.SubjectCode == "" || input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "企业主体必须填写统一社会信用代码与法人身份证号")
}
case constants.WithdrawalQualificationSubjectTypePersonal:
if input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体必须填写法人身份证号")
}
if input.SubjectCode == "" {
// 个人主体的签约主体代码即法人身份证号。
input.SubjectCode = input.LegalPersonIDCard
}
default:
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "签约主体类型无效")
}
if input.SubjectCode != input.LegalPersonIDCard && input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体的签约主体代码必须与法人身份证号一致")
}
if input.ContractFileKey == "" || input.IDCardFrontFileKey == "" || input.IDCardBackFileKey == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "合同与法人身份证正反面附件必须填写")
}
if input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal &&
(input.InvoiceFileKey != "" || input.InvoiceTitle != "" || input.InvoiceSubjectCode != "") {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票资料仅企业主体可提交")
}
if input.InvoiceFileKey != "" {
if input.InvoiceTitle == "" || input.InvoiceSubjectCode == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "提交发票时必须填写抬头与统一社会信用代码")
}
if input.InvoiceSubjectCode != input.SubjectCode {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票统一社会信用代码必须与合同主体一致")
}
}
// 附件上限由结构保证:资格只有合同、法人身份证正反面、营业执照、门头照、发票共 6 个
// 单对象键字段,天然不超过企业微信单张审批单 6 个附件上限,无需运行时计数校验。
return input, nil
}
// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。
// 日志与审计不得记录完整手机号。
func MaskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix {
return ""
}
return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:]
}
// MaskSubjectCode 生成脱敏证件号或统一社会信用代码,仅保留前 4 位与后 4 位。
// 日志与审计不得记录完整证件号。
func MaskSubjectCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "**********" + code[len(code)-codeMaskedKeepSuffix:]
}
// MaskDistributionCode 生成脱敏分销码,仅保留首尾片段。
// 分销码是可枚举的公开入口标识,日志与审计只记录脱敏值。
func MaskDistributionCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "****" + code[len(code)-codeMaskedKeepSuffix:]
}
// FormatCentYuan 将分金额格式化为两位小数的元字符串,仅用于审批表单与展示。
func FormatCentYuan(amount int64) string {
return strconv.FormatInt(amount/100, 10) + "." +
pad2(strconv.FormatInt(amount%100, 10))
}
// pad2 将 0 至 99 的十进制文本左补零到两位。
func pad2(value string) string {
if len(value) >= 2 {
return value
}
return "0" + value
}
// RegistrationInput 是公开扫码注册的规范化输入。
type RegistrationInput struct {
DistributionCode string
Phone string
Username string
Password string
ShopName string
ShopCode string
ContactName string
Province string
City string
District string
Address string
}
// QualificationInput 是提现资料资格的规范化输入。
type QualificationInput struct {
SubjectType string
SubjectCode string
LegalPersonIDCard string
ContractFileKey string
IDCardFrontFileKey string
IDCardBackFileKey string
BusinessLicenseFileKey string
ShopFrontFileKey string
InvoiceFileKey string
InvoiceTitle string
InvoiceSubjectCode string
}