All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码, 客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。 - 验证码校验与消费拆分为 CheckCode 与 ConsumeCode,注册记录与审批实例落库成功后才原子消费; 校验不消费、消费一次性,落库前失败时同一验证码可直接重试 - 分销码无效、所属店铺已停用、上级店铺缺少启用的主账号、验证码错误分别返回各自错误码与提示 - 注册必填字段缺失与请求参数校验失败提示定位到具体字段 - 同步公开接口描述与 agent-distribution-withdrawal 主 Spec 行为契约
200 lines
8.3 KiB
Go
200 lines
8.3 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 == "" {
|
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
|
|
}
|
|
if input.Phone == "" {
|
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
|
|
}
|
|
if input.Username == "" {
|
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
|
|
}
|
|
if input.ShopName == "" {
|
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
|
|
}
|
|
if 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
|
|
}
|