Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
189 lines
7.9 KiB
Go
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
|
|
}
|