feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
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:
2026-09-14 13:40:34 +08:00
parent 67893617fe
commit 1aa4eacee2
30 changed files with 1867 additions and 399 deletions

View File

@@ -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 {

View File

@@ -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,

View 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)
}

View File

@@ -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(&current).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, &current, &wallet, transaction)
})
}
// pendingWithdrawalReject 是一条待审提现的本次拒绝事实。
// releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实,
// 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。

View File

@@ -248,6 +248,7 @@ func (s *Service) buildShopHierarchyPath(ctx context.Context, shop *model.Shop)
// ListShopCommissionRecords 查询代理商佣金明细
// GET /shops/:id/commission-records
// 原佣金与回溯明细按同一分页与排序口径合并返回,筛选与数据范围在合并前各自应用。
func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, req *dto.ShopCommissionRecordListReq) (*dto.ShopCommissionRecordPageResult, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
@@ -262,7 +263,6 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
@@ -270,6 +270,9 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
if opts.PageSize > constants.MaxPageSize {
opts.PageSize = constants.MaxPageSize
}
filters := &postgres.CommissionRecordListFilters{
ShopID: shopID,
@@ -277,56 +280,39 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
ICCID: req.ICCID,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
Status: req.Status,
}
records, total, err := s.commissionRecordStore.ListByShopID(ctx, opts, filters)
rows, total, err := s.commissionRecordStore.ListLedgerByShopID(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细失败")
}
sellerShopIDs := make([]uint, 0)
for _, r := range records {
if r.SellerShopID != nil && *r.SellerShopID > 0 {
sellerShopIDs = append(sellerShopIDs, *r.SellerShopID)
originalIDs := make([]uint, 0, len(rows))
for _, row := range rows {
if row.Source == postgres.CommissionLedgerSourceOriginal {
originalIDs = append(originalIDs, row.ID)
}
}
summaries, err := s.commissionRecordStore.ListClawbackSummaries(ctx, originalIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金回溯摘要失败")
}
shopNameMap := make(map[uint]string)
if len(sellerShopIDs) > 0 {
shops, err := s.shopStore.GetByIDs(ctx, sellerShopIDs)
if err == nil {
for _, sh := range shops {
shopNameMap[sh.ID] = sh.ShopName
shopNameMap, err := s.sellerShopNames(ctx, rows)
if err != nil {
return nil, err
}
items := make([]dto.ShopCommissionRecordItem, 0, len(rows))
for _, row := range rows {
item := buildShopCommissionRecordItem(row, shopNameMap)
if row.Source == postgres.CommissionLedgerSourceOriginal {
item.ClawbackRecords = buildClawbackItems(summaries[row.ID])
for _, clawback := range item.ClawbackRecords {
item.ClawbackTotalAmount += clawback.Amount
}
}
}
items := make([]dto.ShopCommissionRecordItem, 0, len(records))
for _, r := range records {
var orderCreatedAt string
if r.OrderCreatedAt != nil {
orderCreatedAt = r.OrderCreatedAt.Format("2006-01-02 15:04:05")
}
var sellerShopID uint
if r.SellerShopID != nil {
sellerShopID = *r.SellerShopID
}
item := dto.ShopCommissionRecordItem{
ID: r.ID,
Amount: r.Amount,
BalanceAfter: r.BalanceAfter,
CommissionSource: r.CommissionSource,
Status: r.Status,
StatusName: constants.GetCommissionRecordStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: r.OrderNo,
VirtualNo: r.VirtualNo,
ICCID: r.ICCID,
OrderCreatedAt: orderCreatedAt,
SellerShopID: sellerShopID,
SellerShopName: shopNameMap[sellerShopID],
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
}
items = append(items, item)
}
@@ -338,6 +324,150 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
}, nil
}
// GetShopCommissionRecord 查询单条佣金明细详情
// GET /shops/:shop_id/commission-records/:id
// source 区分原佣金与回溯明细;越权与不存在返回同一结果,不泄露存在性。
func (s *Service) GetShopCommissionRecord(ctx context.Context, req *dto.ShopCommissionRecordDetailReq) (*dto.ShopCommissionRecordDetailResp, error) {
if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if _, err := s.shopStore.GetByID(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
}
source := req.Source
if source == "" {
source = postgres.CommissionLedgerSourceOriginal
}
row, err := s.commissionRecordStore.GetLedgerRowByID(ctx, source, req.ID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "佣金明细不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金明细详情失败")
}
// 记录必须属于请求路径中的店铺,避免借已知 ID 跨店铺枚举。
if row.ShopID != req.ShopID {
return nil, errors.New(errors.CodeNotFound, "佣金明细不存在")
}
rows := []*postgres.CommissionLedgerRow{row}
shopNameMap, err := s.sellerShopNames(ctx, rows)
if err != nil {
return nil, err
}
resp := &dto.ShopCommissionRecordDetailResp{
Source: source,
Record: buildShopCommissionRecordItem(row, shopNameMap),
}
if source == postgres.CommissionLedgerSourceClawback {
if row.OriginalCommissionID != nil {
original, err := s.commissionRecordStore.GetLedgerRowByID(ctx, postgres.CommissionLedgerSourceOriginal, *row.OriginalCommissionID)
if err == nil {
originalItem := buildShopCommissionRecordItem(original, shopNameMap)
resp.OriginalCommission = &originalItem
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询回溯明细关联原佣金失败")
}
}
return resp, nil
}
summaries, err := s.commissionRecordStore.ListClawbackSummaries(ctx, []uint{row.ID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询佣金回溯摘要失败")
}
resp.ClawbackRecords = buildClawbackItems(summaries[row.ID])
return resp, nil
}
// sellerShopNames 批量解析合并行涉及的销售来源店铺名称。
func (s *Service) sellerShopNames(ctx context.Context, rows []*postgres.CommissionLedgerRow) (map[uint]string, error) {
sellerShopIDs := make([]uint, 0, len(rows))
for _, row := range rows {
if row.SellerShopID != nil && *row.SellerShopID > 0 {
sellerShopIDs = append(sellerShopIDs, *row.SellerShopID)
}
}
shopNameMap := make(map[uint]string)
if len(sellerShopIDs) == 0 {
return shopNameMap, nil
}
shops, err := s.shopStore.GetByIDs(ctx, sellerShopIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询销售来源店铺失败")
}
for _, shop := range shops {
shopNameMap[shop.ID] = shop.ShopName
}
return shopNameMap, nil
}
// buildShopCommissionRecordItem 把合并行投影为统一明细项。
func buildShopCommissionRecordItem(row *postgres.CommissionLedgerRow, shopNameMap map[uint]string) dto.ShopCommissionRecordItem {
var orderCreatedAt string
if row.OrderCreatedAt != nil {
orderCreatedAt = row.OrderCreatedAt.Format("2006-01-02 15:04:05")
}
var sellerShopID uint
if row.SellerShopID != nil {
sellerShopID = *row.SellerShopID
}
item := dto.ShopCommissionRecordItem{
Source: row.Source,
ID: row.ID,
Amount: row.Amount,
BalanceAfter: row.BalanceAfter,
CommissionSource: row.CommissionSource,
Status: row.Status,
StatusName: constants.GetCommissionRecordStatusName(row.Status),
OrderID: row.OrderID,
OrderNo: row.OrderNo,
VirtualNo: row.VirtualNo,
ICCID: row.ICCID,
OrderCreatedAt: orderCreatedAt,
SellerShopID: sellerShopID,
SellerShopName: shopNameMap[sellerShopID],
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04:05"),
}
if row.ReleasedAt != nil {
item.ReleasedAt = row.ReleasedAt.Format("2006-01-02 15:04:05")
}
if row.OriginalCommissionID != nil {
item.OriginalCommissionID = *row.OriginalCommissionID
}
if row.RefundID != nil {
item.RefundID = *row.RefundID
}
item.RefundNo = row.RefundNo
item.Withdrawable = row.Withdrawable
return item
}
// buildClawbackItems 把回溯摘要投影为明细项。
func buildClawbackItems(summaries []postgres.CommissionClawbackSummary) []dto.ShopCommissionClawbackItem {
if len(summaries) == 0 {
return nil
}
items := make([]dto.ShopCommissionClawbackItem, 0, len(summaries))
for _, summary := range summaries {
items = append(items, dto.ShopCommissionClawbackItem{
ID: summary.ID,
OriginalCommissionID: summary.OriginalCommissionID,
RefundID: summary.RefundID,
RefundNo: summary.RefundNo,
Amount: summary.Amount,
BalanceAfter: summary.BalanceAfter,
Withdrawable: summary.Withdrawable,
Status: summary.Status,
StatusName: constants.GetCommissionRecordStatusName(summary.Status),
CreatedAt: summary.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return items
}
// GetStats 获取佣金统计
// GET /shops/:id/commission-stats
func (s *Service) GetStats(ctx context.Context, shopID uint, req *dto.CommissionStatsRequest) (*dto.CommissionStatsResponse, error) {