Files
junhong_cmp_fiber/internal/service/refund/clawback.go
break 1aa4eacee2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
用 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
2026-09-14 13:40:34 +08:00

609 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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)
}