Files
junhong_cmp_fiber/internal/application/distributionwithdrawal/withdrawal.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

897 lines
33 KiB
Go
Raw 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"
"crypto/rand"
"math/big"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"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/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。
type WithdrawalPolicy struct {
MinAmount int64
FeeRate int64
DailyWithdrawalLimit int
}
// WithdrawalInput 是提现申请或重提的规范化输入。
type WithdrawalInput struct {
Amount int64
WithdrawalMethod string
AccountName string
AccountNumber string
InvoiceKeys []string
}
// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。
type WithdrawalResult struct {
RequestID uint
WithdrawalNo string
AttemptID uint
AttemptNo int
Amount int64
Fee int64
FeeRate int64
ActualAmount int64
Status int
ApprovalInstanceID uint
ApprovalStatus int
CreatedAt time.Time
}
// WithdrawalService 创建与重提提现申请。
// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成;
// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。
type WithdrawalService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewWithdrawalService 创建提现申请用例。
func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService {
return &WithdrawalService{db: db, approval: approval, audit: audit}
}
// Create 为本人代理店铺创建提现申请。
func (s *WithdrawalService) Create(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return s.submit(ctx, shopID, policy, nil, input)
}
// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。
func (s *WithdrawalService) Resubmit(
ctx context.Context,
requestID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if requestID == 0 {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return s.submit(ctx, 0, policy, &requestID, input)
}
// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *WithdrawalService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置")
}
return nil
}
// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。
func (s *WithdrawalService) submit(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
resubmitRequestID *uint,
input WithdrawalInput,
) (*WithdrawalResult, error) {
submitter, err := currentSubmitter(ctx)
if err != nil {
return nil, err
}
if input.Amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0")
}
if policy.MinAmount > 0 && input.Amount < policy.MinAmount {
return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度")
}
correlationID := "commission_withdrawal:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
result := &WithdrawalResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var request *model.CommissionWithdrawalRequest
if resubmitRequestID != nil {
request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID)
if err != nil {
return err
}
if request.ShopID != submitter.ShopID {
return errors.New(errors.CodeNotFound, "提现申请不存在")
}
if request.Status != constants.WithdrawalStatusRejected {
return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提")
}
if request.ApprovalInstanceID == nil {
return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提")
}
// 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。
if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil {
return err
}
shopID = request.ShopID
}
if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil {
return err
}
qualification, err := loadValidQualification(ctx, tx, shopID)
if err != nil {
return err
}
if !qualification.Passed {
return qualificationCheckError(qualification)
}
if err := validateWithdrawalInvoice(qualification.Qualification, input.InvoiceKeys); err != nil {
return err
}
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
return err
}
wallet, err := lockCommissionWallet(ctx, tx, shopID)
if err != nil {
return err
}
fee := input.Amount * policy.FeeRate / 10000
actualAmount := input.Amount - fee
if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil {
return err
}
accountInfo, err := marshalJSON(map[string]string{
"account_name": input.AccountName, "account_number": input.AccountNumber,
})
if err != nil {
return err
}
invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys))
if err != nil {
return err
}
if request == nil {
request = &model.CommissionWithdrawalRequest{
WithdrawalNo: generateWithdrawalNo(),
ShopID: shopID,
AgentID: submitter.ID,
ApplicantID: submitter.ID,
Amount: input.Amount,
FeeRate: policy.FeeRate,
Fee: fee,
ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod,
AccountInfo: accountInfo,
Status: constants.WithdrawalStatusPending,
}
request.Creator = submitter.ID
request.Updater = submitter.ID
if err := tx.WithContext(ctx).Create(request).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败")
}
} else {
if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil {
return err
}
}
attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID)
if err != nil {
return err
}
attempt := &model.CommissionWithdrawalRequestAttempt{
RequestID: request.ID, AttemptNo: attemptNo,
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
// 当次资料资格校验结果随尝试冻结:资格被替换或失效后,历史尝试仍返回提交时的版本与结果。
QualificationVersionID: qualification.VersionID,
QualificationCheckedAt: &qualification.CheckedAt,
QualificationPassed: qualificationPassedFlag(qualification.Passed),
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID)))
if err != nil {
return err
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil {
return err
}
transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount)
if err != nil {
return err
}
eventID, err := composeAuditEventID(
"commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted,
Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID,
Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction,
AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
}); err != nil {
return err
}
result.RequestID = request.ID
result.WithdrawalNo = request.WithdrawalNo
result.AttemptID = attempt.ID
result.AttemptNo = attempt.AttemptNo
result.Amount = attempt.Amount
result.Fee = attempt.Fee
result.FeeRate = attempt.FeeRate
result.ActualAmount = attempt.ActualAmount
result.Status = request.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
result.CreatedAt = request.CreatedAt
return nil
})
if err != nil {
// 失败审计必须可追溯带上店铺shopID 在手上),使审计恰好有一个主要资源。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID同一店铺的提现可被拒绝多次手工 ID 会让后续拒绝被
// event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_<uuid>。
ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected,
Summary: "提交提现申请被拒绝", CorrelationID: correlationID,
Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID),
}, err)
return nil, err
}
return result, nil
}
// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID
// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。
func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop {
if shopID == 0 && requestID != nil {
var request model.CommissionWithdrawalRequest
if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil {
shopID = request.ShopID
}
}
return failureShop(ctx, db, shopID)
}
// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用,
// 保证拒绝事实仍有可追溯的店铺主资源。
func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
if shopID == 0 {
return nil
}
if shop := loadShopOrNil(ctx, db, shopID); shop != nil {
return shop
}
// gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。
minimal := &model.Shop{}
minimal.ID = shopID
return minimal
}
// withdrawalSubmitter 是发起提现的真实操作者。
type withdrawalSubmitter struct {
ID uint
Username string
ShopID uint
}
// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。
func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息")
}
return withdrawalSubmitter{
ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID,
}, nil
}
// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。
func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error {
if shopID == 0 || shopID != submitter.ShopID {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return nil
}
// qualificationCheck 是本次提现依据的资料资格校验结果。
// 校验结果随当次审批尝试冻结:通过时记录版本标识与校验时间,未通过时给出稳定原因编码,
// 供提现详情查询与审计核对;历史尝试不被改写。
type qualificationCheck struct {
// Qualification 仅在通过时非空。
Qualification *model.WithdrawalQualification
Passed bool
VersionID uint
CheckedAt time.Time
// FailureReason 是未通过时的稳定原因编码(资格不存在/已失效/已停用/资料未通过审批),通过时为空。
FailureReason string
}
// qualificationCheckError 把未通过的稳定原因转成对外的稳定业务错误。
// 提示文案同时携带稳定原因编码与中文名称,使拒绝结果与审计记录都能定位到具体原因(本变更要求
// 拒绝结果与审计记录包含稳定原因)。
func qualificationCheckError(check qualificationCheck) error {
name := constants.GetWithdrawalQualificationFailureReasonName(check.FailureReason)
detail := check.FailureReason + "/" + name
if check.FailureReason == constants.WithdrawalQualificationFailureShopDisabled {
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):店铺已停用,请先恢复店铺后重新提交")
}
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):请先完成资料审批后重新提交")
}
// loadValidQualification 读取当前有效的提现资料资格;缺失、失效、未通过审批或店铺停用时返回未通过结果。
//
// 校验顺序沿用既有实现:先按资料资格判定,再判定店铺启停。稳定原因取值域与迁移
// 000237 的列语义一致(资格不存在、已失效、已停用、资料未通过审批)。
// 仅在数据库访问失败时返回错误,判定不通过一律以结果表达,使调用方能写审计并回传稳定原因。
func loadValidQualification(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (qualificationCheck, error) {
check := qualificationCheck{CheckedAt: time.Now().UTC()}
var latest model.WithdrawalQualification
err := tx.WithContext(ctx).
Where("shop_id = ?", shopID).
Order("id DESC").
First(&latest).Error
if err == gorm.ErrRecordNotFound {
check.FailureReason = constants.WithdrawalQualificationFailureMissing
return check, nil
}
if err != nil {
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
}
check.VersionID = latest.ID
if latest.Status != constants.WithdrawalQualificationStatusApproved {
if latest.Status == constants.WithdrawalQualificationStatusInvalidated {
check.FailureReason = constants.WithdrawalQualificationFailureInvalidated
} else {
check.FailureReason = constants.WithdrawalQualificationFailureNotApproved
}
return check, nil
}
var shop model.Shop
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
}
if shop.Status != constants.ShopStatusEnabled {
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
check.Qualification = &latest
check.Passed = true
return check, nil
}
// qualificationPassedFlag 把校验结果转为尝试表的 0/1 标记,取值与迁移 000237 的 CHECK 一致。
func qualificationPassedFlag(passed bool) int {
if passed {
return constants.WithdrawalQualificationCheckPassed
}
return constants.WithdrawalQualificationCheckNotPassed
}
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error {
keys := normalizeInvoiceKeys(invoiceKeys)
if len(keys) == 0 {
return nil
}
if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise {
return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交")
}
if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" {
return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料")
}
return nil
}
// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。
func normalizeInvoiceKeys(keys []string) []string {
result := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。
func ensureDailyWithdrawalLimit(
ctx context.Context,
tx *gorm.DB,
shopID uint,
limit int,
countNewRequest bool,
) error {
if !countNewRequest || limit <= 0 {
return nil
}
today := time.Now().Format("2006-01-02")
var todayCount int64
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59").
Count(&todayCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败")
}
if int(todayCount) >= limit {
return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
}
return nil
}
// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。
func lockWithdrawalRequest(
ctx context.Context,
tx *gorm.DB,
id uint,
) (*model.CommissionWithdrawalRequest, error) {
var request model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&request, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败")
}
return &request, nil
}
// lockCommissionWallet 以行锁读取店铺佣金钱包。
func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败")
}
return &wallet, nil
}
// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。
func freezeCommissionBalance(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
amount int64,
) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?",
wallet.ID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试")
}
wallet.FrozenBalance += amount
return nil
}
// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。
// 释放金额取尝试记录事实,重复释放不会重复调整余额。
func releaseCommissionBalance(
ctx context.Context,
tx *gorm.DB,
walletID uint,
amount int64,
) (bool, error) {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
walletID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败")
}
return result.RowsAffected == 1, nil
}
// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("request_id = ?", requestID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败")
}
if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount {
return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理")
}
return row.MaxAttemptNo + 1, nil
}
// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。
func updateWithdrawalRequestForResubmit(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
input WithdrawalInput,
policy WithdrawalPolicy,
fee int64,
actualAmount int64,
accountInfo []byte,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected).
Updates(map[string]any{
"amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount,
"withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo,
"status": constants.WithdrawalStatusPending, "processed_at": nil,
"reject_reason": "", "updater": request.ApplicantID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试")
}
request.Amount = input.Amount
request.Fee = fee
request.FeeRate = policy.FeeRate
request.ActualAmount = actualAmount
request.WithdrawalMethod = input.WithdrawalMethod
request.AccountInfo = accountInfo
request.Status = constants.WithdrawalStatusPending
request.ProcessedAt = nil
request.RejectReason = ""
return nil
}
// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。
func attachWithdrawalAttemptInstance(
ctx context.Context,
tx *gorm.DB,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.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, "提现审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。
func updateWithdrawalLatest(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
}
if request.ApprovalInstanceID == nil {
// 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。
updates["approval_instance_id"] = instanceID
}
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ?", request.ID).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化")
}
request.LatestAttemptID = attempt.ID
request.LatestApprovalInstanceID = instanceID
if request.ApprovalInstanceID == nil {
request.ApprovalInstanceID = &instanceID
}
return nil
}
// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。
func recordWithdrawalFreezeTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
operatorID uint,
amount int64,
) (*model.AgentWalletTransaction, error) {
remark := "提现冻结,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID,
TransactionType: constants.AgentTransactionTypeWithdrawal,
Amount: -amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusProcessing,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: operatorID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败")
}
return transaction, nil
}
// shopName 读取店铺名称用于审批表单展示,缺失时留空。
func shopName(ctx context.Context, db *gorm.DB, shopID uint) string {
var shop model.Shop
if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil {
return ""
}
return shop.ShopName
}
// withdrawalApprovalForm 生成企业微信审批表单业务快照。
// 收款账号按原值写入供审批人核验,其他敏感内容不写入。
func withdrawalApprovalForm(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
shopNameValue string,
) map[string]any {
accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo)
return map[string]any{
constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo,
constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo,
constants.ApprovalFieldWithdrawalShopID: request.ShopID,
constants.ApprovalFieldWithdrawalShopName: shopNameValue,
constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount),
constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount,
constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee),
constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount),
constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod,
constants.ApprovalFieldWithdrawalAccountName: accountName,
constants.ApprovalFieldWithdrawalAccountNumber: accountNumber,
constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys),
}
}
// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。
func decodeAccountInfo(payload []byte) (string, string) {
var info map[string]string
if err := sonic.Unmarshal(payload, &info); err != nil {
return "", ""
}
return info["account_name"], info["account_number"]
}
// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。
func decodeInvoiceKeys(payload []byte) []string {
var keys []string
if err := sonic.Unmarshal(payload, &keys); err != nil {
return []string{}
}
return keys
}
// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。
func withdrawalAuditSnapshot(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
wallet *model.AgentWallet,
) map[string]any {
snapshot := map[string]any{
"id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID,
"amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate,
"actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod,
"status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo,
"latest_approval_instance_id": request.LatestApprovalInstanceID,
"anomaly_flag": request.AnomalyFlag,
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
// 当次资料资格校验留痕:版本标识与是否通过,未通过时的稳定原因不在成功快照中重复。
"qualification_version_id": attempt.QualificationVersionID,
"qualification_passed": attempt.QualificationPassed,
}
if wallet != nil {
snapshot["wallet_id"] = wallet.ID
snapshot["wallet_frozen_balance"] = wallet.FrozenBalance
}
return snapshot
}
// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。
func withdrawalSubmitSummary(resubmit bool) string {
if resubmit {
return "重提佣金提现申请,已释放旧未结算冻结"
}
return "提交佣金提现申请并冻结可提现余额"
}
// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。
// 释放金额取 try.amount 事实,释放完成写入 released_at已释放的尝试不会被重复释放。
// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。
func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
if s == nil || s.db == nil || tx == nil || shopID == 0 {
return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效")
}
return releaseUnsettledAttempts(ctx, tx, shopID)
}
// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。
func releaseUnsettledAttemptsForRequest(
ctx context.Context,
tx *gorm.DB,
requestID uint,
) (int64, error) {
return releaseUnsettledForRequests(ctx, tx, []uint{requestID})
}
// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。
// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。
func releaseUnsettledAttempts(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
var requestIDs []uint
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败")
}
return releaseUnsettledForRequests(ctx, tx, requestIDs)
}
// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。
// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。
func releaseUnsettledForRequests(
ctx context.Context,
tx *gorm.DB,
requestIDs []uint,
) (int64, error) {
if len(requestIDs) == 0 {
return 0, nil
}
requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs))
for _, requestID := range requestIDs {
request, err := lockWithdrawalRequest(ctx, tx, requestID)
if err != nil {
return 0, err
}
requests[requestID] = request
}
now := time.Now().UTC()
amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now)
if err != nil {
return 0, err
}
total := int64(0)
for _, requestID := range requestIDs {
amount, exists := amounts[requestID]
if !exists || amount == 0 {
continue
}
request := requests[requestID]
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return 0, err
}
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount)
if err != nil {
return 0, err
}
if !ok {
return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
}
total += amount
}
return total, nil
}
// generateWithdrawalNo 生成提现单号格式W + 时间戳 + 随机数。
func generateWithdrawalNo() string {
return "W" + time.Now().Format("20060102150405") + randomDigits(6)
}
// randomDigits 生成指定位数的数字随机串,用于提现单号。
func randomDigits(length int) string {
const digits = "0123456789"
buf := make([]byte, 0, length)
limit := big.NewInt(int64(len(digits)))
for range length {
value, err := rand.Int(rand.Reader, limit)
if err != nil {
return strings.Repeat("0", length)
}
buf = append(buf, digits[value.Int64()])
}
return string(buf)
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
negative := amount < 0
if negative {
amount = -amount
}
value := distributiondomain.FormatCentYuan(amount)
if negative {
return "-" + value
}
return value
}