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 err := validateWithdrawalInvoice(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, } 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_。 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 } // loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。 func loadValidQualification( ctx context.Context, tx *gorm.DB, shopID uint, ) (*model.WithdrawalQualification, error) { var qualification model.WithdrawalQualification err := tx.WithContext(ctx). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). First(&qualification).Error if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批") } if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败") } var shop model.Shop if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil { return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批") } if shop.Status != constants.ShopStatusEnabled { return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效") } return &qualification, nil } // 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)), } 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 }