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
609 lines
28 KiB
Go
609 lines
28 KiB
Go
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)
|
||
}
|