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

@@ -0,0 +1,213 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// CommissionRecordDataSource 佣金明细导出数据源。
// 粒度为佣金记录:原佣金与回溯明细各占一行,金额保持分并在展示层转元,
// 负数金额与可为负的余额原样导出,不因符号或余额不足被裁剪。
type CommissionRecordDataSource struct {
db *gorm.DB
}
// NewCommissionRecordDataSource 创建佣金明细导出数据源。
func NewCommissionRecordDataSource(db *gorm.DB) *CommissionRecordDataSource {
return &CommissionRecordDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *CommissionRecordDataSource) Scene() string {
return constants.ExportTaskSceneCommissionRecord
}
// Count 统计原佣金与回溯明细的合并行数。
func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var originalTotal int64
if err := s.originalBranch(ctx, params).Count(&originalTotal).Error; err != nil {
return 0, err
}
var clawbackTotal int64
if err := s.clawbackBranch(ctx, params).Count(&clawbackTotal).Error; err != nil {
return 0, err
}
return int(originalTotal + clawbackTotal), nil
}
// Headers 返回佣金明细导出表头。
func (s *CommissionRecordDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"记录来源", "记录ID", "代理店铺名称", "关联订单号", "资产标识", "佣金来源",
"金额(元)", "是否可提现", "状态", "回溯后佣金余额(元)",
"原佣金记录ID", "来源退款单号", "佣金入账时间", "生成时间",
}, nil
}
// Fetch 按 offset/limit 查询合并后的佣金明细导出数据。
func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
union := s.db.WithContext(ctx).
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback",
s.originalBranch(ctx, params), s.clawbackBranch(ctx, params))
var items []commissionRecordExportRow
query := s.db.WithContext(ctx).Table("(?) AS ledger", union).
Select(`
ledger.source,
ledger.id,
COALESCE(sh.shop_name, '') AS shop_name,
ledger.order_no,
COALESCE(NULLIF(ledger.iccid, ''), ledger.virtual_no, '') AS asset_identifier,
ledger.commission_source,
ledger.amount,
ledger.withdrawable,
ledger.status,
ledger.balance_after,
ledger.original_commission_id,
ledger.refund_no,
ledger.released_at,
ledger.created_at
`).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = ledger.shop_id").
// 合并后统一排序并分页,保证两类记录落在同一结果集,任一条不缺失也不重复。
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
Limit(limit).Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
formatCommissionLedgerSource(item.Source),
strconv.FormatUint(uint64(item.ID), 10),
item.ShopName,
item.OrderNo,
item.AssetIdentifier,
formatCommissionSource(item.CommissionSource),
formatMoneyYuan(item.Amount),
formatCommissionWithdrawable(item.Source, item.Withdrawable),
constants.GetCommissionRecordStatusName(item.Status),
formatMoneyYuan(item.BalanceAfter),
formatOptionalUint(item.OriginalCommissionID),
item.RefundNo,
formatOptionalTime(item.ReleasedAt),
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
// originalBranch 构造原佣金导出分支:自带场景筛选与数据范围。
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
Where("c.deleted_at IS NULL").
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_iot_card ic ON c.iot_card_id = ic.id AND ic.deleted_at IS NULL").
Joins("LEFT JOIN tb_device d ON c.device_id = d.id AND d.deleted_at IS NULL").
Select(`'` + sourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, o.order_no, ` +
`ic.iccid, d.virtual_no, c.commission_source, c.amount, c.balance_after, c.status, ` +
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
`NULL::boolean AS withdrawable`)
query = applyExportShopScope(query, params, "c.shop_id")
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.status", "o.order_no")
}
// clawbackBranch 构造回溯明细导出分支:资产维度取原佣金关联的卡或设备,保持与原佣金同一口径。
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_iot_card ic ON oc.iot_card_id = ic.id AND ic.deleted_at IS NULL").
Joins("LEFT JOIN tb_device d ON oc.device_id = d.id AND oc.deleted_at IS NULL").
Select(`'` + sourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, ic.iccid, d.virtual_no, ` +
`g.commission_source, g.amount, g.balance_after, g.status, ` +
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
query = applyExportShopScope(query, params, "g.shop_id")
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.status", "g.order_no")
}
// 导出分支来源标识与后台列表保持一致,便于导出结果与列表逐行核对。
const (
sourceOriginal = "original"
sourceClawback = "clawback"
)
// applyCommissionExportFilters 把佣金明细导出的筛选条件应用到单个分支。
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, statusColumn, orderNoColumn string) *gorm.DB {
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where(shopColumn+" = ?", shopID)
}
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where(statusColumn+" = ?", status)
}
if source, ok := filterString(params.Filters, "commission_source"); ok {
query = query.Where(sourceColumn+" = ?", source)
}
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
query = query.Where(orderNoColumn+" = ?", orderNo)
}
return query
}
// commissionRecordExportRow 是佣金明细导出的合并行投影,金额一律保持分。
type commissionRecordExportRow struct {
Source string `gorm:"column:source"`
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
AssetIdentifier string `gorm:"column:asset_identifier"`
CommissionSource string `gorm:"column:commission_source"`
Amount int64 `gorm:"column:amount"`
Withdrawable *bool `gorm:"column:withdrawable"`
Status int `gorm:"column:status"`
BalanceAfter int64 `gorm:"column:balance_after"`
OriginalCommissionID *uint `gorm:"column:original_commission_id"`
RefundNo string `gorm:"column:refund_no"`
ReleasedAt *time.Time `gorm:"column:released_at"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// formatCommissionLedgerSource 把记录来源转为导出用中文描述。
func formatCommissionLedgerSource(source string) string {
if source == sourceClawback {
return "回溯明细"
}
return "原佣金"
}
// formatCommissionSource 把佣金来源转为导出用中文描述。
func formatCommissionSource(source string) string {
switch source {
case model.CommissionSourceCostDiff:
return "成本价差"
case model.CommissionSourceOneTime:
return "一次性佣金"
case "":
return ""
default:
return source
}
}
// formatCommissionWithdrawable 把可提现标识转为导出用中文描述。
// 原佣金不参与可提现判定,留空;回溯明细恒为不可提现。
func formatCommissionWithdrawable(source string, withdrawable *bool) string {
if source != sourceClawback || withdrawable == nil {
return ""
}
if *withdrawable {
return "可提现"
}
return "不可提现"
}

View File

@@ -36,6 +36,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewAgentRechargeDataSource(db),
NewRefundDataSource(db),
NewExchangeDataSource(db),
NewCommissionRecordDataSource(db),
)
}
@@ -71,7 +72,8 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange:
constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord:
return true
default:
return false