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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
280 lines
10 KiB
Go
280 lines
10 KiB
Go
// Package distributionwithdrawal 提供分销注册、提现资格与提现审批的只读投影查询。
|
|
// 查询不得修改任何状态;可见性由调用方在业务边界先行校验。
|
|
package distributionwithdrawal
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
)
|
|
|
|
// Query 提供提现资料资格与提现申请详情的只读投影。
|
|
type Query struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewQuery 创建分销与提现审批只读查询。
|
|
func NewQuery(db *gorm.DB) *Query {
|
|
return &Query{db: db}
|
|
}
|
|
|
|
// ListQualifications 按数据范围分页查询提现资料资格版本,证件号按脱敏值返回。
|
|
func (q *Query) ListQualifications(
|
|
ctx context.Context,
|
|
shopIDs []uint,
|
|
req *dto.WithdrawalQualificationListReq,
|
|
) (*dto.WithdrawalQualificationPageResult, error) {
|
|
if q == nil || q.db == nil {
|
|
return nil, errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
|
|
}
|
|
page := req.Page
|
|
if page <= 0 {
|
|
page = constants.DefaultPage
|
|
}
|
|
pageSize := req.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = constants.DefaultPageSize
|
|
}
|
|
if pageSize > constants.MaxPageSize {
|
|
pageSize = constants.MaxPageSize
|
|
}
|
|
query := q.db.WithContext(ctx).Model(&model.WithdrawalQualification{})
|
|
if len(shopIDs) == 1 {
|
|
query = query.Where("shop_id = ?", shopIDs[0])
|
|
} else if len(shopIDs) > 1 {
|
|
query = query.Where("shop_id IN ?", shopIDs)
|
|
}
|
|
if req.Status != nil {
|
|
query = query.Where("status = ?", *req.Status)
|
|
}
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计提现资料资格失败")
|
|
}
|
|
var versions []model.WithdrawalQualification
|
|
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).
|
|
Find(&versions).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
|
|
}
|
|
shopNames, err := q.shopNames(ctx, versions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeWithdrawalQualification, instanceIDsOfQualifications(versions))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]dto.WithdrawalQualificationItem, 0, len(versions))
|
|
for index := range versions {
|
|
version := &versions[index]
|
|
instanceID := uint(0)
|
|
if version.ApprovalInstanceID != nil {
|
|
instanceID = *version.ApprovalInstanceID
|
|
}
|
|
item := dto.WithdrawalQualificationItem{
|
|
ID: version.ID, ShopID: version.ShopID, ShopName: shopNames[version.ShopID],
|
|
SubjectType: version.SubjectType,
|
|
SubjectTypeName: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
|
|
SubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
|
|
LegalPersonIDCardMask: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
|
|
ContractFileKey: version.ContractFileKey,
|
|
IDCardFrontFileKey: version.IDCardFrontFileKey,
|
|
IDCardBackFileKey: version.IDCardBackFileKey,
|
|
BusinessLicenseFileKey: version.BusinessLicenseFileKey,
|
|
ShopFrontFileKey: version.ShopFrontFileKey,
|
|
InvoiceFileKey: version.InvoiceFileKey, InvoiceTitle: version.InvoiceTitle,
|
|
Status: version.Status, StatusName: constants.GetWithdrawalQualificationStatusName(version.Status),
|
|
InvalidReason: version.InvalidReason, ApprovalInstanceID: instanceID,
|
|
CreatedAt: version.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
UpdatedAt: version.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
if version.InvalidatedAt != nil {
|
|
item.InvalidatedAt = version.InvalidatedAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
if status, ok := instanceStatuses[instanceID]; ok {
|
|
item.ApprovalStatus = status
|
|
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
|
|
} else {
|
|
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return &dto.WithdrawalQualificationPageResult{
|
|
Items: items, Total: total, Page: page, Size: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// WithdrawalDetail 查询单笔提现申请详情与其全部审批尝试记录。
|
|
func (q *Query) WithdrawalDetail(
|
|
ctx context.Context,
|
|
requestID uint,
|
|
) (*dto.ShopWithdrawalRequestDetailResp, error) {
|
|
if q == nil || q.db == nil {
|
|
return nil, errors.New(errors.CodeInternalError, "提现详情查询能力未配置")
|
|
}
|
|
var request model.CommissionWithdrawalRequest
|
|
if err := q.db.WithContext(ctx).First(&request, requestID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现申请失败")
|
|
}
|
|
var attempts []model.CommissionWithdrawalRequestAttempt
|
|
if err := q.db.WithContext(ctx).
|
|
Where("request_id = ?", request.ID).Order("attempt_no DESC").Find(&attempts).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
|
|
}
|
|
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeCommissionWithdrawal, instanceIDsOfAttempts(attempts))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shopNames, err := q.shopNames(ctx, []model.WithdrawalQualification{{ShopID: request.ShopID}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accountName, accountNumber := decodeAttemptAccount(request.AccountInfo)
|
|
processedAt := ""
|
|
if request.ProcessedAt != nil {
|
|
processedAt = request.ProcessedAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
paidAt := ""
|
|
if request.PaidAt != nil {
|
|
paidAt = request.PaidAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
detail := &dto.ShopWithdrawalRequestDetailResp{
|
|
ShopWithdrawalRequestItem: dto.ShopWithdrawalRequestItem{
|
|
ID: request.ID, WithdrawalNo: request.WithdrawalNo, Amount: request.Amount,
|
|
FeeRate: request.FeeRate, Fee: request.Fee, ActualAmount: request.ActualAmount,
|
|
Status: request.Status, StatusName: constants.GetWithdrawalStatusName(request.Status),
|
|
ShopID: request.ShopID, ShopName: shopNames[request.ShopID],
|
|
ApplicantID: request.ApplicantID, WithdrawalMethod: request.WithdrawalMethod,
|
|
PaymentType: request.PaymentType, AccountName: accountName, AccountNumber: accountNumber,
|
|
RejectReason: request.RejectReason, Remark: request.Remark,
|
|
CreatedAt: request.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
ProcessedAt: processedAt, PaidAt: paidAt,
|
|
},
|
|
LatestAttemptID: request.LatestAttemptID,
|
|
LatestApprovalInstanceID: request.LatestApprovalInstanceID,
|
|
AnomalyFlag: request.AnomalyFlag,
|
|
AnomalyName: constants.GetWithdrawalAnomalyName(request.AnomalyFlag),
|
|
AnomalyReason: request.AnomalyReason,
|
|
Attempts: make([]dto.WithdrawalRequestAttemptItem, 0, len(attempts)),
|
|
}
|
|
if request.ApprovalInstanceID != nil {
|
|
detail.ApprovalInstanceID = *request.ApprovalInstanceID
|
|
}
|
|
for index := range attempts {
|
|
attempt := &attempts[index]
|
|
instanceID := uint(0)
|
|
if attempt.ApprovalInstanceID != nil {
|
|
instanceID = *attempt.ApprovalInstanceID
|
|
}
|
|
item := dto.WithdrawalRequestAttemptItem{
|
|
ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount,
|
|
Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount,
|
|
WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID,
|
|
ApprovalInstanceID: instanceID,
|
|
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
if status, ok := instanceStatuses[instanceID]; ok {
|
|
item.ApprovalStatus = status
|
|
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
|
|
} else {
|
|
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
|
|
}
|
|
if attempt.ReleasedAt != nil {
|
|
item.ReleasedAt = attempt.ReleasedAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
detail.Attempts = append(detail.Attempts, item)
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
// shopNames 批量读取店铺名称,缺失店铺留空。
|
|
func (q *Query) shopNames(ctx context.Context, versions []model.WithdrawalQualification) (map[uint]string, error) {
|
|
shopIDs := make([]uint, 0, len(versions))
|
|
seen := make(map[uint]struct{}, len(versions))
|
|
for _, version := range versions {
|
|
if version.ShopID == 0 {
|
|
continue
|
|
}
|
|
if _, exists := seen[version.ShopID]; exists {
|
|
continue
|
|
}
|
|
seen[version.ShopID] = struct{}{}
|
|
shopIDs = append(shopIDs, version.ShopID)
|
|
}
|
|
names := make(map[uint]string, len(shopIDs))
|
|
if len(shopIDs) == 0 {
|
|
return names, nil
|
|
}
|
|
var shops []model.Shop
|
|
if err := q.db.WithContext(ctx).Select("id", "shop_name").Where("id IN ?", shopIDs).Find(&shops).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺名称失败")
|
|
}
|
|
for _, shop := range shops {
|
|
names[shop.ID] = shop.ShopName
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// approvalStatuses 批量读取通用审批实例状态,缺失实例不进入结果。
|
|
func (q *Query) approvalStatuses(
|
|
ctx context.Context,
|
|
businessType string,
|
|
instanceIDs []uint,
|
|
) (map[uint]int, error) {
|
|
statuses := make(map[uint]int, len(instanceIDs))
|
|
if len(instanceIDs) == 0 {
|
|
return statuses, nil
|
|
}
|
|
var instances []model.ApprovalInstance
|
|
if err := q.db.WithContext(ctx).Select("id", "status").
|
|
Where("business_type = ? AND id IN ?", businessType, instanceIDs).
|
|
Find(&instances).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例状态失败")
|
|
}
|
|
for _, instance := range instances {
|
|
statuses[instance.ID] = instance.Status
|
|
}
|
|
return statuses, nil
|
|
}
|
|
|
|
// instanceIDsOfQualifications 提取资料版本关联的审批实例 ID。
|
|
func instanceIDsOfQualifications(versions []model.WithdrawalQualification) []uint {
|
|
ids := make([]uint, 0, len(versions))
|
|
for index := range versions {
|
|
if versions[index].ApprovalInstanceID != nil {
|
|
ids = append(ids, *versions[index].ApprovalInstanceID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// instanceIDsOfAttempts 提取提现审批尝试关联的审批实例 ID。
|
|
func instanceIDsOfAttempts(attempts []model.CommissionWithdrawalRequestAttempt) []uint {
|
|
ids := make([]uint, 0, len(attempts))
|
|
for index := range attempts {
|
|
if attempts[index].ApprovalInstanceID != nil {
|
|
ids = append(ids, *attempts[index].ApprovalInstanceID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// decodeAttemptAccount 解析收款账户快照,解析失败时留空。
|
|
func decodeAttemptAccount(payload []byte) (string, string) {
|
|
var info map[string]string
|
|
if err := json.Unmarshal(payload, &info); err != nil {
|
|
return "", ""
|
|
}
|
|
return info["account_name"], info["account_number"]
|
|
}
|