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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
831 lines
30 KiB
Go
831 lines
30 KiB
Go
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_<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
|
||
}
|
||
|
||
// 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
|
||
}
|