feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变, 回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。 - 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束 (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。 - 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常 标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、 分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差; 终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。 - 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影 并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、 审批异常转人工。 - 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点, refund.invalidate_commission 仅保留常量与注册供历史审计读取。 - 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作 末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。 - 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource 与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。 - 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) / Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。 Refs: AUG26-012
This commit is contained in:
@@ -261,16 +261,11 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter
|
||||
return err
|
||||
}
|
||||
|
||||
// ProcessCommissionDeduction 处理退款佣金回溯后处理。
|
||||
// 返回非 nil 表示后处理尚未闭合(准入未满足、冻结实收非正或订单佣金未终态),
|
||||
// 由 Outbox 与周期性补偿任务重试;返回 nil 表示已闭合并已落库全部应有事实。
|
||||
func (s *Service) ProcessCommissionDeduction(ctx context.Context, refundID uint) error {
|
||||
s.deductAllCommission(ctx, refundID)
|
||||
var refund model.RefundRequest
|
||||
if err := s.db.WithContext(ctx).Select("commission_deducted").First(&refund, refundID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核退款佣金回扣状态失败")
|
||||
}
|
||||
if !refund.CommissionDeducted {
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款佣金回扣尚未完成")
|
||||
}
|
||||
return nil
|
||||
return s.clawbackCommission(ctx, refundID)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessAssetPostProcessing(ctx context.Context, refundID uint) error {
|
||||
|
||||
@@ -257,43 +257,6 @@ func assetWalletRefundTransactionResource(transaction *model.AssetWalletTransact
|
||||
}
|
||||
}
|
||||
|
||||
// appendCommissionAudit 将佣金失效、钱包扣减和回扣流水绑定在同一事务。
|
||||
func (s *Service) appendCommissionAudit(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, commission *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
|
||||
}
|
||||
primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "退款佣金已处理"
|
||||
commissionResource := audit.CommissionRecordResource(commission,
|
||||
map[string]any{"status": constants.CommissionStatusReleased}, map[string]any{"status": constants.CommissionStatusInvalid})
|
||||
commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
transactionResource := agentWalletRefundTransactionResource(transaction, refund.ID)
|
||||
transactionResource.Relation = constants.AuditResourceRelationAffected
|
||||
transactionResource.Role = constants.AuditResourceRoleRefundTransaction
|
||||
resources := []audit.ResourceInput{primary, commissionResource, agentWalletRefundResource(wallet, transaction), transactionResource}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单审计快照失败")
|
||||
}
|
||||
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder)
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, orderResource)
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, commission.ShopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联店铺审计快照失败")
|
||||
}
|
||||
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundCommission)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, shopResource)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":commission:" + strconv.FormatUint(uint64(commission.ID), 10) + ":invalidated",
|
||||
ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: refund.RefundNo, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
// recordRefundFailure 在原业务事务回滚后记录已定位退款的失败或拒绝。
|
||||
func (s *Service) recordRefundFailure(ctx context.Context, actionCode, summary string, refund *model.RefundRequest, order *model.Order, businessErr error) {
|
||||
if businessErr == nil || refund == nil || refund.RefundNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
@@ -314,22 +277,6 @@ func (s *Service) recordRefundFailure(ctx context.Context, actionCode, summary s
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
// recordCommissionFailure 在单条佣金回扣事务回滚后记录失败事实。
|
||||
func (s *Service) recordCommissionFailure(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord, businessErr error) {
|
||||
if businessErr == nil || refund == nil || commission == nil || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
commissionResource := audit.CommissionRecordResource(commission, map[string]any{"status": commission.Status}, nil)
|
||||
commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金失败",
|
||||
ScopeType: constants.AuditScopePlatform, CorrelationID: refund.RefundNo,
|
||||
Resources: []audit.ResourceInput{primary, commissionResource},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func refundAuditState(refund *model.RefundRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount,
|
||||
|
||||
608
internal/service/refund/clawback.go
Normal file
608
internal/service/refund/clawback.go
Normal file
@@ -0,0 +1,608 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// clawbackAllocation 是一条原佣金的回溯分摊结果。
|
||||
// Amount 为正的应回溯金额;写入回溯明细时取负数,原佣金记录本身不变。
|
||||
type clawbackAllocation struct {
|
||||
Commission model.CommissionRecord
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// clawbackCommission 处理一次退款的佣金回溯后处理。
|
||||
//
|
||||
// 返回非 nil 表示后处理尚未闭合(准入未满足或订单佣金未终态),由既有 Outbox / 补偿任务重试;
|
||||
// 返回 nil 表示已闭合:已生成回溯明细、确认无需回溯或审批异常转人工。
|
||||
//
|
||||
// 准入由退款申请状态、审批异常标记与退款方式唯一决定:
|
||||
// 仅退款申请已通过(原路退款还须渠道明确成功)且无审批异常时生成回溯明细。
|
||||
// 失败分类标记面向「尝试是否终结」,与「退款是否真正完成」正交,因此不参与准入判定。
|
||||
func (s *Service) clawbackCommission(ctx context.Context, refundID uint) error {
|
||||
var refund model.RefundRequest
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款单失败")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing,
|
||||
ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo,
|
||||
})
|
||||
// commission_deducted 只是投影:已闭合即无需重复处理,本函数不依赖它做逐条幂等判定。
|
||||
if refund.CommissionDeducted {
|
||||
return nil
|
||||
}
|
||||
// 审批异常(企业微信通过后撤销)不生成任何回溯事实,但必须闭合后处理并转人工,
|
||||
// 否则补偿扫描会在每轮重复处理同一批退款单。
|
||||
if refund.AnomalyFlag != 0 {
|
||||
return s.settleClawback(ctx, &refund, constants.AuditActionRefundAnomalyFlagged,
|
||||
"退款审批异常,佣金回溯转人工处理", clawbackEventID(refund.ID, "clawback-anomaly"),
|
||||
map[string]any{
|
||||
"anomaly_flag": refund.AnomalyFlag, "anomaly_reason": refund.AnomalyReason,
|
||||
"order_id": refund.OrderID, "clawback_required": false,
|
||||
})
|
||||
}
|
||||
// 准入:只有已通过的退款才可能完成退款,待审批、原路处理中与渠道明确失败都必须等待。
|
||||
if refund.Status != model.RefundStatusApproved {
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款申请尚未通过,暂不生成佣金回溯")
|
||||
}
|
||||
if refund.Method == constants.RefundMethodOriginalRoute && refund.ChannelRefundStatus != constants.RefundChannelStatusSucceeded {
|
||||
return errors.New(errors.CodeServiceUnavailable, "原路退款渠道结果尚未明确成功,暂不生成佣金回溯")
|
||||
}
|
||||
|
||||
// 订单佣金未终态时等待:此时「无已发放佣金」只代表尚未计算完成或尚待人工修正,
|
||||
// 提前判定为无需回溯会让后续入账的佣金永久失去回溯机会,违反「MUST NOT 提前判定为无需回溯」。
|
||||
//
|
||||
// 终态由两件事共同决定,缺一不可:
|
||||
// 1. 订单佣金流程已离开「待计算」;
|
||||
// 2. 该订单不存在任何未终态佣金记录(已冻结 1、解冻中 2、待人工修正 99)。
|
||||
// 不能用 order.commission_status = 3 判定终态:该值只表示「存在 99 记录」,
|
||||
// 而人工处置(shop_commission.ResolveCommissionRecord)把 99 改成已发放/已失效后并不回写订单状态,
|
||||
// 订单会长期停在 3;只看订单状态会把「待人工修正」误判为终态并提前闭合。
|
||||
//
|
||||
// 收敛条件(依据全仓 tb_commission_record.status 写点实测):该表状态只由
|
||||
// commission_calculation(创建置 3 或 99、入账置 3)、recharge_order(创建置 3)与
|
||||
// shop_commission.ResolveCommissionRecord(人工处置置 3 或 4)写入,从不写入 1/2
|
||||
// ——1/2 是仅供历史数据读取的遗留枚举,纳入未终态集合属防御性判断。
|
||||
// 因此现实中的等待只可能由 99 引起,且处置后必然收敛:置 3 → 生成回溯;置 4 → 确认无佣金可回溯并闭合。
|
||||
var order model.Order
|
||||
if err := s.db.WithContext(ctx).Select("id, commission_status").First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单失败")
|
||||
}
|
||||
if order.CommissionStatus == model.CommissionStatusPending {
|
||||
return errors.New(errors.CodeServiceUnavailable, "订单佣金尚未计算完成,等待终态后再回溯")
|
||||
}
|
||||
var openCommissionCount int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
|
||||
Where("order_id = ? AND status IN ?", refund.OrderID, []int{
|
||||
constants.CommissionStatusFrozen,
|
||||
constants.CommissionStatusUnfreezing,
|
||||
constants.CommissionStatusPendingReview,
|
||||
}).Count(&openCommissionCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "统计订单未终态佣金记录失败")
|
||||
}
|
||||
if openCommissionCount > 0 {
|
||||
return errors.New(errors.CodeServiceUnavailable, "订单存在未终态佣金记录,等待终态后再回溯")
|
||||
}
|
||||
|
||||
// 可回溯原佣金固定为已发放记录,稳定顺序取 original_commission_id 升序:
|
||||
// 该顺序决定舍入补差落点,必须固定,否则同一输入可能得出不同分摊结果。
|
||||
var commissions []model.CommissionRecord
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND status = ?", refund.OrderID, constants.CommissionStatusReleased).
|
||||
Order("id ASC").Find(&commissions).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款原佣金记录失败")
|
||||
}
|
||||
if len(commissions) == 0 {
|
||||
// 终态确无佣金:不产生任何钱包变动。
|
||||
return s.settleClawback(ctx, &refund, constants.AuditActionRefundClawbackNotRequired,
|
||||
"退款无需回溯佣金", clawbackEventID(refund.ID, "clawback-not-required"),
|
||||
map[string]any{
|
||||
"order_id": refund.OrderID, "commission_status": order.CommissionStatus,
|
||||
"released_commission_count": 0, "clawback_required": false,
|
||||
})
|
||||
}
|
||||
|
||||
denominator, err := s.resolveClawbackDenominator(ctx, &refund)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
numerator := s.resolveClawbackNumerator(&refund)
|
||||
if denominator <= 0 {
|
||||
// 冻结实收缺失或非正:记录可恢复失败,不落库、不改余额、不置位,
|
||||
// 禁止用订单标价或申请金额替代。
|
||||
s.logger.Error("佣金回溯:本次退款冻结实收金额非正,等待权威金额补齐",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("refund_no", refund.RefundNo),
|
||||
zap.Int64("frozen_actual_received_amount", refund.FrozenActualReceivedAmount))
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款冻结实收金额缺失或非正,无法计算佣金回溯")
|
||||
}
|
||||
if numerator <= 0 {
|
||||
// 已通过的退款必然携带权威成功退款金额;缺失时同样按可恢复失败处理,不生成零金额回溯。
|
||||
s.logger.Error("佣金回溯:本次退款成功金额非正,等待权威金额补齐",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("refund_no", refund.RefundNo),
|
||||
zap.String("method", refund.Method), zap.Int64("channel_refund_amount", refund.ChannelRefundAmount))
|
||||
return errors.New(errors.CodeServiceUnavailable, "退款成功金额缺失或非正,无法计算佣金回溯")
|
||||
}
|
||||
|
||||
// 剩余可回溯余额按明细聚合:同一原佣金可被同订单的多次退款累计回溯,累计不得超过原佣金金额。
|
||||
clawed, err := s.loadClawedAmounts(ctx, commissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amounts := make([]int64, len(commissions))
|
||||
clawedAmounts := make([]int64, len(commissions))
|
||||
for i := range commissions {
|
||||
amounts[i] = commissions[i].Amount
|
||||
clawedAmounts[i] = clawed[commissions[i].ID]
|
||||
}
|
||||
amountsAllocated := allocateClawbackAmounts(amounts, clawedAmounts, numerator, denominator)
|
||||
|
||||
allocations := make([]clawbackAllocation, 0, len(commissions))
|
||||
total := int64(0)
|
||||
for i := range commissions {
|
||||
allocations = append(allocations, clawbackAllocation{Commission: commissions[i], Amount: amountsAllocated[i]})
|
||||
total += amountsAllocated[i]
|
||||
}
|
||||
if total <= 0 {
|
||||
// 比例向下取整后为 0,或全部原佣金的剩余可回溯余额已用尽:本次退款不再产生资金事实。
|
||||
return s.settleClawback(ctx, &refund, constants.AuditActionRefundClawbackNotRequired,
|
||||
"退款无需回溯佣金", clawbackEventID(refund.ID, "clawback-not-required"),
|
||||
map[string]any{
|
||||
"order_id": refund.OrderID, "commission_status": order.CommissionStatus,
|
||||
"released_commission_count": len(commissions), "clawback_required": false,
|
||||
"numerator": numerator, "denominator": denominator,
|
||||
})
|
||||
}
|
||||
|
||||
return s.applyClawback(ctx, &refund, allocations)
|
||||
}
|
||||
|
||||
// clawbackEventID 生成回溯审计事件的稳定键,重复执行返回同一事件,审计写入因此天然幂等。
|
||||
func clawbackEventID(refundID uint, suffix string) string {
|
||||
return "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":" + suffix
|
||||
}
|
||||
|
||||
// resolveClawbackDenominator 取本次退款的冻结实收金额作为回溯比例分母。
|
||||
// 退款行缺失权威金额时回落本次审批尝试记录的冻结事实;两者都没有时返回 0,由调用方按可恢复失败处理。
|
||||
func (s *Service) resolveClawbackDenominator(ctx context.Context, refund *model.RefundRequest) (int64, error) {
|
||||
if refund.FrozenActualReceivedAmount > 0 {
|
||||
return refund.FrozenActualReceivedAmount, nil
|
||||
}
|
||||
if refund.LatestAttemptID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := s.db.WithContext(ctx).Select("id, frozen_actual_received_amount").
|
||||
First(&attempt, refund.LatestAttemptID).Error
|
||||
if err == nil {
|
||||
return attempt.FrozenActualReceivedAmount, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试冻结实收金额失败")
|
||||
}
|
||||
|
||||
// resolveClawbackNumerator 取本次退款成功金额作为回溯比例分子。
|
||||
// 原路退款取渠道明确成功金额,其余方式取审批退款金额(含退回原钱包与客户收款信息方式)。
|
||||
func (s *Service) resolveClawbackNumerator(refund *model.RefundRequest) int64 {
|
||||
if refund.Method == constants.RefundMethodOriginalRoute {
|
||||
return refund.ChannelRefundAmount
|
||||
}
|
||||
if refund.ApprovedRefundAmount != nil {
|
||||
return *refund.ApprovedRefundAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// loadClawedAmounts 聚合各原佣金已生成的回溯累计金额(正数视图)。
|
||||
// 累计口径覆盖全部退款单:同一原佣金的累计回溯不得超过其金额,与具体退款无关。
|
||||
func (s *Service) loadClawedAmounts(ctx context.Context, commissions []model.CommissionRecord) (map[uint]int64, error) {
|
||||
clawed := make(map[uint]int64, len(commissions))
|
||||
if len(commissions) == 0 {
|
||||
return clawed, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(commissions))
|
||||
for i := range commissions {
|
||||
ids = append(ids, commissions[i].ID)
|
||||
}
|
||||
type clawedRow struct {
|
||||
OriginalCommissionID uint
|
||||
Total int64
|
||||
}
|
||||
var rows []clawedRow
|
||||
if err := s.db.WithContext(ctx).Model(&model.CommissionClawbackRecord{}).
|
||||
Select("original_commission_id, COALESCE(SUM(amount), 0) AS total").
|
||||
Where("original_commission_id IN ?", ids).
|
||||
Group("original_commission_id").Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合原佣金已回溯金额失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
// 明细金额为负数,转为正数视图供分摊计算使用。
|
||||
clawed[row.OriginalCommissionID] = -row.Total
|
||||
}
|
||||
return clawed, nil
|
||||
}
|
||||
|
||||
// allocateClawbackAmounts 按分计算各原佣金的应回溯金额,入参与返回均按 original_commission_id 升序。
|
||||
//
|
||||
// 计算规则:
|
||||
// - 逐条基准 base_i = min(floor(amount_i × N / D), 剩余_i);
|
||||
// - 应回溯总额 T = min(floor(Σamount_i × N / D), Σ剩余_i);
|
||||
// - 舍入差 T − Σbase_i 自稳定顺序末条起向前分摊,每条不超过该条剩余_i。
|
||||
//
|
||||
// 乘法使用 math/big 中间量,全程整数,禁止浮点与溢出;余额不足由调用方允许负余额,不在此处裁剪。
|
||||
func allocateClawbackAmounts(amounts []int64, clawed []int64, numerator int64, denominator int64) []int64 {
|
||||
result := make([]int64, len(amounts))
|
||||
if len(amounts) == 0 || numerator <= 0 || denominator <= 0 {
|
||||
return result
|
||||
}
|
||||
remaining := make([]int64, len(amounts))
|
||||
totalAmount := new(big.Int)
|
||||
totalRemaining := new(big.Int)
|
||||
for i := range amounts {
|
||||
left := amounts[i] - clawed[i]
|
||||
if amounts[i] <= 0 {
|
||||
left = 0
|
||||
}
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
remaining[i] = left
|
||||
totalAmount.Add(totalAmount, big.NewInt(amounts[i]))
|
||||
totalRemaining.Add(totalRemaining, big.NewInt(left))
|
||||
}
|
||||
|
||||
totalTarget := mulDivFloor(totalAmount, numerator, denominator)
|
||||
totalTarget = minBig(totalTarget, totalRemaining)
|
||||
|
||||
totalBase := new(big.Int)
|
||||
for i := range amounts {
|
||||
base := mulDivFloorInt64(amounts[i], numerator, denominator)
|
||||
if base > remaining[i] {
|
||||
base = remaining[i]
|
||||
}
|
||||
result[i] = base
|
||||
totalBase.Add(totalBase, big.NewInt(base))
|
||||
}
|
||||
|
||||
// 补差自末条起向前分摊;最末条不足以吸收全部余差时继续向前一条分摊。
|
||||
gap := new(big.Int).Sub(totalTarget, totalBase)
|
||||
for i := len(amounts) - 1; i >= 0 && gap.Sign() > 0; i-- {
|
||||
room := remaining[i] - result[i]
|
||||
if room <= 0 {
|
||||
continue
|
||||
}
|
||||
add := minBig(gap, big.NewInt(room))
|
||||
result[i] += add.Int64()
|
||||
gap.Sub(gap, add)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mulDivFloor 计算 floor(value × numerator / denominator),使用大整数中间量避免溢出。
|
||||
func mulDivFloor(value *big.Int, numerator int64, denominator int64) *big.Int {
|
||||
if value.Sign() <= 0 || numerator <= 0 || denominator <= 0 {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
product := new(big.Int).Mul(value, big.NewInt(numerator))
|
||||
return product.Div(product, big.NewInt(denominator))
|
||||
}
|
||||
|
||||
// mulDivFloorInt64 计算 floor(value × numerator / denominator) 并收敛到 int64。
|
||||
// 结果不可能为负;超过 int64 上限时按上限收敛,由后续剩余额度裁剪兜底。
|
||||
func mulDivFloorInt64(value int64, numerator int64, denominator int64) int64 {
|
||||
result := mulDivFloor(big.NewInt(value), numerator, denominator)
|
||||
if !result.IsInt64() {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return result.Int64()
|
||||
}
|
||||
|
||||
// minBig 返回两个非负大整数中的较小值。
|
||||
func minBig(left *big.Int, right *big.Int) *big.Int {
|
||||
if left.Cmp(right) <= 0 {
|
||||
return new(big.Int).Set(left)
|
||||
}
|
||||
return new(big.Int).Set(right)
|
||||
}
|
||||
|
||||
// settleClawback 在没有资金动作时闭合退款佣金后处理:条件置位完成后处理事实并写入同一事务的审计。
|
||||
// 置位带 commission_deducted = false 保护,重复执行不会二次闭合。
|
||||
func (s *Service) settleClawback(
|
||||
ctx context.Context,
|
||||
refund *model.RefundRequest,
|
||||
actionCode string,
|
||||
summary string,
|
||||
eventID string,
|
||||
metadata map[string]any,
|
||||
) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
|
||||
}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND commission_deducted = ?", refund.ID, false).
|
||||
Update("commission_deducted", true)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款佣金后处理已闭合失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.BeforeData = map[string]any{"commission_deducted": false}
|
||||
primary.AfterData = map[string]any{"commission_deducted": true}
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = summary
|
||||
resources := []audit.ResourceInput{primary}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单审计快照失败")
|
||||
}
|
||||
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder)
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, orderResource)
|
||||
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
EventID: eventID, ActionCode: actionCode, Summary: summary,
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: refund.RefundNo, Metadata: metadata, Resources: resources,
|
||||
}); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款佣金后处理闭合审计失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("退款佣金回溯后处理已闭合",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("refund_no", refund.RefundNo), zap.String("action", actionCode))
|
||||
return nil
|
||||
}
|
||||
|
||||
// clawbackShopState 记录一个店铺在本次回溯中的钱包与资金事实,用于同事务审计。
|
||||
type clawbackShopState struct {
|
||||
shop model.Shop
|
||||
wallet model.AgentWallet
|
||||
balanceFrom int64
|
||||
balanceTo int64
|
||||
}
|
||||
|
||||
// applyClawback 在同一事务内释放待审提现冻结、生成回溯明细、扣减佣金钱包并写入审计。
|
||||
//
|
||||
// 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 balance(允许为负)
|
||||
// → 写负数流水 → 审计。任一步失败整体回滚,不释放提现、不写部分明细或部分流水。
|
||||
func (s *Service) applyClawback(ctx context.Context, refund *model.RefundRequest, allocations []clawbackAllocation) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
|
||||
}
|
||||
// generatedCount 记录本次实际落库的明细条数,用于日志与审计的真实性(不含命中唯一键跳过的条目)。
|
||||
generatedCount := 0
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 第 2 层幂等:锁定原佣金行并复核状态,状态被并发改写即整体回滚等待重试。
|
||||
ids := make([]uint, 0, len(allocations))
|
||||
for i := range allocations {
|
||||
ids = append(ids, allocations[i].Commission.ID)
|
||||
}
|
||||
var locked []model.CommissionRecord
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", ids).Order("id ASC").Find(&locked).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款原佣金记录失败")
|
||||
}
|
||||
statusByID := make(map[uint]int, len(locked))
|
||||
for i := range locked {
|
||||
statusByID[locked[i].ID] = locked[i].Status
|
||||
}
|
||||
for i := range allocations {
|
||||
if statusByID[allocations[i].Commission.ID] != constants.CommissionStatusReleased {
|
||||
return errors.New(errors.CodeConflict, "退款原佣金状态已变化")
|
||||
}
|
||||
}
|
||||
|
||||
// 第 1 层幂等的事实来源是 (refund_id, original_commission_id) 唯一约束;
|
||||
// 这里按已有明细跳过已生成的条目,避免唯一冲突回滚整次后处理的其余资金事实。
|
||||
var existing []model.CommissionClawbackRecord
|
||||
if err := tx.WithContext(ctx).Where("refund_id = ?", refund.ID).Find(&existing).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款已有佣金回溯明细失败")
|
||||
}
|
||||
generated := make(map[uint]struct{}, len(existing))
|
||||
for i := range existing {
|
||||
generated[existing[i].OriginalCommissionID] = struct{}{}
|
||||
}
|
||||
|
||||
// 按店铺分组并按店铺升序加锁,避免与并发回溯或提现终态消费者形成死锁环。
|
||||
shopOrder := make([]uint, 0, len(allocations))
|
||||
shopIndex := make(map[uint][]int, len(allocations))
|
||||
for i := range allocations {
|
||||
shopID := allocations[i].Commission.ShopID
|
||||
if _, ok := shopIndex[shopID]; !ok {
|
||||
shopOrder = append(shopOrder, shopID)
|
||||
}
|
||||
shopIndex[shopID] = append(shopIndex[shopID], i)
|
||||
}
|
||||
sortUintAsc(shopOrder)
|
||||
|
||||
var (
|
||||
records []*model.CommissionClawbackRecord
|
||||
transactions []*model.AgentWalletTransaction
|
||||
shops []clawbackShopState
|
||||
)
|
||||
remark := "退款佣金回溯"
|
||||
for _, shopID := range shopOrder {
|
||||
// 加锁顺序全库统一:申请行 → 尝试行 → 钱包行(钱包永远最后加锁)。
|
||||
rejects, err := s.collectPendingWithdrawalRejects(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败")
|
||||
}
|
||||
// 先释放待审核提现冻结,再扣减余额:既有约束要求冻结额不高于非负余额。
|
||||
if err := s.applyPendingWithdrawalRejects(ctx, tx, &wallet, refund, rejects); err != nil {
|
||||
return err
|
||||
}
|
||||
balanceFrom := wallet.Balance
|
||||
for _, index := range shopIndex[shopID] {
|
||||
allocation := allocations[index]
|
||||
if allocation.Amount <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := generated[allocation.Commission.ID]; ok {
|
||||
continue
|
||||
}
|
||||
commissionID := allocation.Commission.ID
|
||||
record := &model.CommissionClawbackRecord{
|
||||
RefundID: refund.ID, OriginalCommissionID: commissionID,
|
||||
OrderID: allocation.Commission.OrderID, ShopID: allocation.Commission.ShopID,
|
||||
OrderNo: refund.OrderNo, RefundNo: refund.RefundNo,
|
||||
CommissionSource: allocation.Commission.CommissionSource,
|
||||
Amount: -allocation.Amount, BalanceAfter: wallet.Balance - allocation.Amount,
|
||||
Withdrawable: false, Status: constants.CommissionStatusClawback,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金回溯明细失败")
|
||||
}
|
||||
// 佣金钱包允许余额为负:余额不足不跳过、不拒绝回溯。
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||
Updates(map[string]any{"balance": gorm.Expr("balance - ?", allocation.Amount), "version": gorm.Expr("version + 1")})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减退款佣金钱包失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款佣金钱包版本已变化")
|
||||
}
|
||||
balanceBefore := wallet.Balance
|
||||
wallet.Balance -= allocation.Amount
|
||||
wallet.Version++
|
||||
refType, refID := constants.ReferenceTypeCommission, commissionID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, UserID: refund.Creator,
|
||||
TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -allocation.Amount,
|
||||
BalanceBefore: balanceBefore, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID,
|
||||
Remark: &remark, Creator: refund.Creator, ShopIDTag: wallet.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款佣金回溯流水失败")
|
||||
}
|
||||
records = append(records, record)
|
||||
transactions = append(transactions, transaction)
|
||||
generatedCount++
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, shopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联店铺审计快照失败")
|
||||
}
|
||||
shops = append(shops, clawbackShopState{shop: shop, wallet: wallet, balanceFrom: balanceFrom, balanceTo: wallet.Balance})
|
||||
}
|
||||
|
||||
// 本次未生成任何明细时(全部命中已存在的唯一键,仅投影陈旧时可达成)不写
|
||||
// refund.clawback_commission 审计:该动作语义是「生成回溯明细」,
|
||||
// 记录数 0 且 clawback_required=true 的审计是误导性事实。置位逻辑保持不变。
|
||||
if len(records) > 0 {
|
||||
if err := s.appendClawbackAudit(ctx, tx, refund, records, transactions, shops); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 全部回溯明细与钱包流水完成后才置位;带条件保护,重复执行不会二次闭合。
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND commission_deducted = ?", refund.ID, false).
|
||||
Update("commission_deducted", true)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款佣金回溯完成失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款佣金后处理已由其他执行闭合")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("退款佣金回溯完成",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("refund_no", refund.RefundNo),
|
||||
zap.Int("clawback_records", generatedCount))
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendClawbackAudit 将回溯明细、佣金钱包与负数流水绑定在同一事务。
|
||||
func (s *Service) appendClawbackAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
refund *model.RefundRequest,
|
||||
records []*model.CommissionClawbackRecord,
|
||||
transactions []*model.AgentWalletTransaction,
|
||||
shops []clawbackShopState,
|
||||
) error {
|
||||
primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "退款佣金已回溯"
|
||||
resources := []audit.ResourceInput{primary}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单审计快照失败")
|
||||
}
|
||||
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder)
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, orderResource)
|
||||
|
||||
totalClawback := int64(0)
|
||||
for _, record := range records {
|
||||
totalClawback += record.Amount
|
||||
resource := audit.CommissionClawbackResource(record)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
for i := range transactions {
|
||||
resource := audit.AgentWalletTransactionResource(transactions[i], constants.AuditResourceRelationAffected, constants.AuditResourceRoleRefundTransaction)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
for i := range shops {
|
||||
walletResource := audit.AgentWalletResource(&shops[i].wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleRefundWallet,
|
||||
map[string]any{"balance": shops[i].balanceFrom}, map[string]any{"balance": shops[i].balanceTo})
|
||||
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, walletResource)
|
||||
shopResource := audit.ShopResource(&shops[i].shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundCommission)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, shopResource)
|
||||
}
|
||||
|
||||
_, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
EventID: clawbackEventID(refund.ID, "clawback"), ActionCode: constants.AuditActionRefundClawbackCommission,
|
||||
Summary: "生成退款佣金回溯明细", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: refund.RefundNo,
|
||||
Metadata: map[string]any{
|
||||
"order_id": refund.OrderID, "clawback_record_count": len(records),
|
||||
"clawback_total_amount": totalClawback, "clawback_required": true,
|
||||
},
|
||||
Resources: resources,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款佣金回溯审计失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortUintAsc 就地对店铺 ID 升序排序,保证多店铺加锁顺序稳定。
|
||||
func sortUintAsc(values []uint) {
|
||||
slices.Sort(values)
|
||||
}
|
||||
@@ -882,155 +882,6 @@ func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error {
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
|
||||
// deductAllCommission 幂等回扣该订单所有已入账佣金。
|
||||
// 每条佣金在独立事务中锁定并失效;全部完成后才设置退款单完成标记。
|
||||
func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
|
||||
logger := s.logger
|
||||
|
||||
// 查询退款单
|
||||
var refund model.RefundRequest
|
||||
if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||||
logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing,
|
||||
ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo,
|
||||
})
|
||||
if refund.CommissionDeducted {
|
||||
return
|
||||
}
|
||||
|
||||
// 先确认订单佣金已计算完成;否则可能与异步佣金计算竞态:
|
||||
// “无已入账记录”会被误判为“无需回扣”并提前置 commission_deducted,随后佣金才入账且不再回溯。
|
||||
var order model.Order
|
||||
if err := s.db.Select("commission_status").First(&order, refund.OrderID).Error; err != nil {
|
||||
logger.Error("佣金回扣:查询订单佣金状态失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if order.CommissionStatus == model.CommissionStatusPending {
|
||||
logger.Info("佣金回扣:订单佣金尚未计算完成,等待重试", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID))
|
||||
return
|
||||
}
|
||||
|
||||
// 查询该订单所有已入账佣金记录
|
||||
var commissions []model.CommissionRecord
|
||||
if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, constants.CommissionStatusReleased).Find(&commissions).Error; err != nil {
|
||||
logger.Error("佣金回扣:查询佣金记录失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(commissions) == 0 {
|
||||
logger.Info("佣金回扣:无已入账佣金记录", zap.Uint("refund_id", refundID))
|
||||
// 无佣金需要回扣,直接标记完成
|
||||
s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true)
|
||||
return
|
||||
}
|
||||
|
||||
allSucceeded := true
|
||||
// 对每条佣金记录执行扣减
|
||||
for _, commission := range commissions {
|
||||
if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil {
|
||||
allSucceeded = false
|
||||
s.recordCommissionFailure(ctx, &refund, &commission, err)
|
||||
logger.Error("佣金回扣:单条佣金扣减失败",
|
||||
zap.Uint("refund_id", refundID),
|
||||
zap.Uint("commission_id", commission.ID),
|
||||
zap.Uint("shop_id", commission.ShopID),
|
||||
zap.Int64("amount", commission.Amount),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 继续处理下一条,不中断
|
||||
}
|
||||
}
|
||||
if !allSucceeded {
|
||||
return
|
||||
}
|
||||
|
||||
// 全部完成后标记退款单佣金已回扣
|
||||
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil {
|
||||
logger.Error("佣金回扣:更新回扣标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// deductSingleCommission 回扣单条佣金记录:先拒绝该店铺待审核提现释放冻结余额,
|
||||
// 再扣减佣金钱包(允许余额为负),最后失效佣金记录并创建流水。
|
||||
func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.CommissionRecord
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", commission.ID).First(¤t).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金记录失败")
|
||||
}
|
||||
if current.Status == constants.CommissionStatusInvalid {
|
||||
return nil
|
||||
}
|
||||
if current.Status != constants.CommissionStatusReleased {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款佣金状态不允许回扣")
|
||||
}
|
||||
var existingCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.AgentWalletTransaction{}).
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeCommission, current.ID, constants.AgentTransactionTypeCommissionDeduct, constants.TransactionStatusSuccess).
|
||||
Count(&existingCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核退款佣金回扣流水失败")
|
||||
}
|
||||
if existingCount > 0 {
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionRecord{}).Where("id = ?", current.ID).
|
||||
Update("status", constants.CommissionStatusInvalid).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核后失效退款佣金记录失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
|
||||
// 待审提现的加锁与释放额计算必须发生在钱包加锁之前,否则与企微终态消费者
|
||||
// (申请 → 尝试 → 钱包)形成 A→B、B→A 的死锁环。
|
||||
rejects, err := s.collectPendingWithdrawalRejects(ctx, tx, current.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", current.ShopID, constants.AgentWalletTypeCommission).
|
||||
First(&wallet).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败")
|
||||
}
|
||||
if err := s.applyPendingWithdrawalRejects(ctx, tx, &wallet, refund, rejects); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||
Updates(map[string]any{"balance": gorm.Expr("balance - ?", current.Amount), "version": gorm.Expr("version + 1")})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减退款佣金钱包失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款佣金钱包版本已变化")
|
||||
}
|
||||
refType, refID := constants.ReferenceTypeCommission, current.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: current.ShopID, UserID: refund.Creator,
|
||||
TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -current.Amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - current.Amount,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID,
|
||||
Creator: refund.Creator, ShopIDTag: current.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款佣金回扣流水失败")
|
||||
}
|
||||
updated := tx.WithContext(ctx).Model(&model.CommissionRecord{}).
|
||||
Where("id = ? AND status = ?", current.ID, constants.CommissionStatusReleased).
|
||||
Update("status", constants.CommissionStatusInvalid)
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "失效退款佣金记录失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款佣金状态已变化")
|
||||
}
|
||||
current.Status = constants.CommissionStatusInvalid
|
||||
return s.appendCommissionAudit(ctx, tx, refund, ¤t, &wallet, transaction)
|
||||
})
|
||||
}
|
||||
|
||||
// pendingWithdrawalReject 是一条待审提现的本次拒绝事实。
|
||||
// releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实,
|
||||
// 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。
|
||||
|
||||
Reference in New Issue
Block a user