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

@@ -12,15 +12,6 @@ import (
"gorm.io/gorm"
)
type CommissionRecordWithRelations struct {
model.CommissionRecord
OrderNo string `gorm:"column:order_no"`
OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
ICCID string `gorm:"column:iccid"`
VirtualNo string `gorm:"column:virtual_no"`
SellerShopID *uint `gorm:"column:seller_shop_id"`
}
type CommissionRecordStore struct {
db *gorm.DB
redis *redis.Client
@@ -56,6 +47,194 @@ func (s *CommissionRecordStore) GetByID(ctx context.Context, id uint) (*model.Co
return &record, nil
}
// CommissionLedgerSourceOriginal 表示合并行来自原佣金记录。
const CommissionLedgerSourceOriginal = "original"
// CommissionLedgerSourceClawback 表示合并行来自佣金回溯明细。
const CommissionLedgerSourceClawback = "clawback"
// CommissionLedgerRow 是佣金明细的合并行:原佣金与回溯明细统一投影到同一结构。
// 回溯行只填 ref/refund/withdrawable 与负数金额;原佣金行只填 released_at 与 clawback 摘要。
type CommissionLedgerRow struct {
Source string `gorm:"column:source"`
ID uint `gorm:"column:id"`
ShopID uint `gorm:"column:shop_id"`
OrderID uint `gorm:"column:order_id"`
OrderNo string `gorm:"column:order_no"`
OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
ICCID string `gorm:"column:iccid"`
VirtualNo string `gorm:"column:virtual_no"`
SellerShopID *uint `gorm:"column:seller_shop_id"`
CommissionSource string `gorm:"column:commission_source"`
Amount int64 `gorm:"column:amount"`
BalanceAfter int64 `gorm:"column:balance_after"`
Status int `gorm:"column:status"`
ReleasedAt *time.Time `gorm:"column:released_at"`
Remark string `gorm:"column:remark"`
CreatedAt time.Time `gorm:"column:created_at"`
OriginalCommissionID *uint `gorm:"column:original_commission_id"`
RefundID *uint `gorm:"column:refund_id"`
RefundNo string `gorm:"column:refund_no"`
Withdrawable *bool `gorm:"column:withdrawable"`
}
// CommissionClawbackSummary 是一条原佣金已生成的回溯摘要,供列表与详情展示关联事实。
type CommissionClawbackSummary struct {
ID uint `gorm:"column:id"`
OriginalCommissionID uint `gorm:"column:original_commission_id"`
RefundID uint `gorm:"column:refund_id"`
RefundNo string `gorm:"column:refund_no"`
Amount int64 `gorm:"column:amount"`
BalanceAfter int64 `gorm:"column:balance_after"`
Withdrawable bool `gorm:"column:withdrawable"`
Status int `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// ListLedgerByShopID 合并原佣金与回溯明细分页返回佣金明细。
//
// 两表以 UNION ALL 合并,统一排序键 created_at DESC, id DESC
// 由于两张表的自增 ID 空间独立,再以 source 作为末位次序键,保证同秒同 ID 的记录翻页不抖动、
// 任一条不缺失也不重复。筛选与数据范围在合并前分别应用到各自的分支。
func (s *CommissionRecordStore) ListLedgerByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*CommissionLedgerRow, int64, error) {
if opts == nil {
opts = &store.QueryOptions{Page: 1, PageSize: constants.DefaultPageSize}
}
original := s.ledgerOriginalBranch(ctx, filters)
clawback := s.ledgerClawbackBranch(ctx, filters)
var totalOriginal int64
if err := original.Session(&gorm.Session{}).Count(&totalOriginal).Error; err != nil {
return nil, 0, err
}
var totalClawback int64
if err := clawback.Session(&gorm.Session{}).Count(&totalClawback).Error; err != nil {
return nil, 0, err
}
offset := (opts.Page - 1) * opts.PageSize
var rows []*CommissionLedgerRow
union := s.db.WithContext(ctx).
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback", original, clawback)
// 合并后再排序与分页,保证两类记录落在同一结果集与同一分页口径。
ledger := s.db.WithContext(ctx).Table("(?) AS ledger", union).
Order("ledger.created_at DESC").Order("ledger.id DESC").Order("ledger.source ASC").
Limit(opts.PageSize).Offset(offset)
if err := ledger.Scan(&rows).Error; err != nil {
return nil, 0, err
}
return rows, totalOriginal + totalClawback, nil
}
// ledgerOriginalBranch 构造原佣金分支:自带店铺数据范围与全部筛选。
func (s *CommissionRecordStore) ledgerOriginalBranch(ctx context.Context, filters *CommissionRecordListFilters) *gorm.DB {
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
Where("c.deleted_at IS NULL")
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
query = query.Where("c.shop_id IN ?", shopIDs)
}
query = query.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(`'` + CommissionLedgerSourceOriginal + `' AS source, c.id, c.shop_id, c.order_id, ` +
`o.order_no, o.created_at AS order_created_at, ic.iccid, d.virtual_no, o.seller_shop_id, ` +
`c.commission_source, c.amount, c.balance_after, c.status, c.released_at, c.remark, c.created_at, ` +
`NULL::bigint AS original_commission_id, NULL::bigint AS refund_id, ''::varchar AS refund_no, NULL::boolean AS withdrawable`)
return s.applyLedgerFilters(query, filters, "c.shop_id", "c.commission_source", "c.created_at", "c.status")
}
// ledgerClawbackBranch 构造回溯明细分支:资产与订单维度通过原佣金与订单关联,
// 使同一卡片/设备筛选同时命中其回溯事实,不因来源表不同而丢失记录。
func (s *CommissionRecordStore) ledgerClawbackBranch(ctx context.Context, filters *CommissionRecordListFilters) *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(`'` + CommissionLedgerSourceClawback + `' AS source, g.id, g.shop_id, g.order_id, ` +
`COALESCE(NULLIF(g.order_no, ''), o.order_no) AS order_no, o.created_at AS order_created_at, ` +
`ic.iccid, d.virtual_no, o.seller_shop_id, g.commission_source, g.amount, g.balance_after, ` +
`g.status, NULL::timestamp AS released_at, ''::varchar AS remark, g.created_at, ` +
`g.original_commission_id, g.refund_id, g.refund_no, g.withdrawable`)
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
query = query.Where("g.shop_id IN ?", shopIDs)
}
return s.applyLedgerFilters(query, filters, "g.shop_id", "g.commission_source", "g.created_at", "g.status")
}
// applyLedgerFilters 把列表筛选条件应用到单个分支;两支使用同一套语义与列别名。
func (s *CommissionRecordStore) applyLedgerFilters(query *gorm.DB, filters *CommissionRecordListFilters, shopColumn, sourceColumn, timeColumn, statusColumn string) *gorm.DB {
if filters == nil {
return query
}
if filters.ShopID > 0 {
query = query.Where(shopColumn+" = ?", filters.ShopID)
}
if filters.CommissionSource != "" {
query = query.Where(sourceColumn+" = ?", filters.CommissionSource)
}
if filters.StartTime != nil && *filters.StartTime != "" {
query = query.Where(timeColumn+" >= ?", *filters.StartTime)
}
if filters.EndTime != nil && *filters.EndTime != "" {
query = query.Where(timeColumn+" <= ?", *filters.EndTime)
}
if filters.Status != nil {
query = query.Where(statusColumn+" = ?", *filters.Status)
}
if filters.ICCID != "" {
query = query.Where("ic.iccid LIKE ?", "%"+filters.ICCID+"%")
}
if filters.OrderNo != "" {
query = query.Where("o.order_no = ?", filters.OrderNo)
}
if filters.DeviceNo != "" {
query = query.Where("d.virtual_no LIKE ?", "%"+filters.DeviceNo+"%")
}
return query
}
// ListClawbackSummaries 返回给定原佣金的全部回溯摘要,按生成顺序稳定排列。
func (s *CommissionRecordStore) ListClawbackSummaries(ctx context.Context, originalCommissionIDs []uint) (map[uint][]CommissionClawbackSummary, error) {
result := make(map[uint][]CommissionClawbackSummary, len(originalCommissionIDs))
if len(originalCommissionIDs) == 0 {
return result, nil
}
query := s.db.WithContext(ctx).Model(&model.CommissionClawbackRecord{}).
Where("original_commission_id IN ?", originalCommissionIDs).
Order("id ASC")
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
query = query.Where("shop_id IN ?", shopIDs)
}
var rows []CommissionClawbackSummary
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.OriginalCommissionID] = append(result[row.OriginalCommissionID], row)
}
return result, nil
}
// GetLedgerRowByID 按来源读取单条合并行,并在读取时应用店铺数据范围。
// 越权与不存在返回同一错误,不泄露存在性。
func (s *CommissionRecordStore) GetLedgerRowByID(ctx context.Context, source string, id uint) (*CommissionLedgerRow, error) {
var query *gorm.DB
if source == CommissionLedgerSourceClawback {
query = s.ledgerClawbackBranch(ctx, nil).Where("g.id = ?", id)
} else {
query = s.ledgerOriginalBranch(ctx, nil).Where("c.id = ?", id)
}
var rows []*CommissionLedgerRow
if err := query.Limit(1).Scan(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, gorm.ErrRecordNotFound
}
return rows[0], nil
}
type CommissionRecordListFilters struct {
ShopID uint
CommissionSource string
@@ -67,72 +246,6 @@ type CommissionRecordListFilters struct {
Status *int
}
func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*CommissionRecordWithRelations, int64, error) {
var total int64
query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
Joins("LEFT JOIN tb_order o ON tb_commission_record.order_id = o.id AND o.deleted_at IS NULL").
Joins("LEFT JOIN tb_iot_card ic ON tb_commission_record.iot_card_id = ic.id AND ic.deleted_at IS NULL").
Joins("LEFT JOIN tb_device d ON tb_commission_record.device_id = d.id AND d.deleted_at IS NULL")
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
query = query.Where("tb_commission_record.shop_id IN ?", shopIDs)
}
if filters != nil {
if filters.ShopID > 0 {
query = query.Where("tb_commission_record.shop_id = ?", filters.ShopID)
}
if filters.CommissionSource != "" {
query = query.Where("tb_commission_record.commission_source = ?", filters.CommissionSource)
}
if filters.StartTime != nil && *filters.StartTime != "" {
query = query.Where("tb_commission_record.created_at >= ?", *filters.StartTime)
}
if filters.EndTime != nil && *filters.EndTime != "" {
query = query.Where("tb_commission_record.created_at <= ?", *filters.EndTime)
}
if filters.Status != nil {
query = query.Where("tb_commission_record.status = ?", *filters.Status)
}
if filters.ICCID != "" {
query = query.Where("ic.iccid LIKE ?", "%"+filters.ICCID+"%")
}
if filters.OrderNo != "" {
query = query.Where("o.order_no = ?", filters.OrderNo)
}
if filters.DeviceNo != "" {
query = query.Where("d.virtual_no LIKE ?", "%"+filters.DeviceNo+"%")
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Select(`tb_commission_record.*, o.order_no, o.created_at as order_created_at, o.seller_shop_id, ic.iccid, d.virtual_no`)
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("tb_commission_record.created_at DESC")
}
var records []*CommissionRecordWithRelations
if err := query.Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
type CommissionStats struct {
TotalAmount int64
CostDiffAmount int64