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

View File

@@ -105,6 +105,34 @@ func (h *ShopCommissionHandler) ListCommissionRecords(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetCommissionRecord 佣金明细详情
// GET /api/admin/shops/:shop_id/commission-records/:id
// source 区分原佣金与回溯明细;越权与不存在返回同一结果。
func (h *ShopCommissionHandler) GetCommissionRecord(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
recordID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || recordID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的佣金明细ID")
}
var req dto.ShopCommissionRecordDetailReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
req.ShopID = uint(shopID)
req.ID = uint(recordID)
result, err := h.service.GetShopCommissionRecord(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ResolveCommissionRecord 修正待审佣金记录
// POST /api/admin/commission-records/:id/resolve
func (h *ShopCommissionHandler) ResolveCommissionRecord(c *fiber.Ctx) error {

View File

@@ -118,6 +118,24 @@ func CommissionRecordResource(record *model.CommissionRecord, beforeData, afterD
}
}
// CommissionClawbackResource 构造退款生成的佣金回溯明细资源。
// 金额按分保留负数、余额可为负,与资金事实一致;不含任何凭证或敏感内容。
func CommissionClawbackResource(record *model.CommissionClawbackRecord) ResourceInput {
id := strconv.FormatUint(uint64(record.ID), 10)
return ResourceInput{
Type: constants.AuditResourceCommissionClawback, ID: &id, Key: id, DisplayName: "佣金回溯明细 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundClawback,
IdentitySnapshot: map[string]any{
"id": record.ID, "refund_id": record.RefundID, "refund_no": record.RefundNo,
"original_commission_id": record.OriginalCommissionID,
"order_id": record.OrderID, "order_no": record.OrderNo, "shop_id": record.ShopID,
"commission_source": record.CommissionSource, "amount": record.Amount,
"balance_after": record.BalanceAfter, "withdrawable": record.Withdrawable,
"status": record.Status, "created_at": record.CreatedAt,
},
}
}
func refundStateData(refund *model.RefundRequest) map[string]any {
return map[string]any{
"status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount,

View File

@@ -309,6 +309,9 @@ func NewRegistry() *Registry {
refundReturned := refundAction(constants.AuditActionRefundReturned, "退回退款申请", false)
refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false)
refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金")
// 回溯明细与「无需回溯」都由退款佣金后处理任务在 Worker 内写入,与既有退款系统动作入口一致。
refundClawbackCommission := refundSystemAction(constants.AuditActionRefundClawbackCommission, "生成退款佣金回溯明细")
refundClawbackNotRequired := refundSystemAction(constants.AuditActionRefundClawbackNotRequired, "退款无需回溯佣金")
refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理")
// 退款审批尝试由后台账号提交与重提;终态与异常标记由企业微信审批消费任务写入(复用 refundAction 的 Worker 入口)。
refundAttemptSubmitted := refundAction(constants.AuditActionRefundAttemptSubmitted, "提交退款审批尝试", false)
@@ -638,6 +641,8 @@ func NewRegistry() *Registry {
constants.AuditActionRefundReturned: refundReturned,
constants.AuditActionRefundResubmitted: refundResubmitted,
constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated,
constants.AuditActionRefundClawbackCommission: refundClawbackCommission,
constants.AuditActionRefundClawbackNotRequired: refundClawbackNotRequired,
constants.AuditActionRefundAssetProcessed: refundAssetProcessed,
constants.AuditActionRefundAttemptSubmitted: refundAttemptSubmitted,
constants.AuditActionRefundAttemptApproved: refundAttemptApproved,
@@ -816,6 +821,10 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceRefundChannelRefund, Name: "渠道原路退款事实",
IdentityFields: []string{"id", "refund_no", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag"},
},
constants.AuditResourceCommissionClawback: {
Type: constants.AuditResourceCommissionClawback, Name: "佣金回溯明细",
IdentityFields: []string{"id", "refund_id", "refund_no", "original_commission_id", "order_id", "order_no", "shop_id", "commission_source", "amount", "balance_after", "withdrawable", "status", "created_at"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",
IdentityFields: []string{"id", "enterprise_code", "enterprise_name", "owner_shop_id"},

View File

@@ -111,7 +111,6 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
limit = 100
}
orderStats := RecoveryStats{}
refundStats := RecoveryStats{}
var orders []model.Order
if err := db.WithContext(ctx).Where("payment_status = ? AND commission_status = ?", model.PaymentStatusPaid, model.CommissionStatusPending).Order("id ASC").Limit(limit).Find(&orders).Error; err != nil {
logger.Warn("扫描待计算订单失败", zap.Error(err))
@@ -120,6 +119,19 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
recoverOne(ctx, db, repository, EventCommissionCalculate, order.ID, order.ID, &orderStats, logger, false)
}
}
refundStats := RecoverRefundPostProcessing(ctx, db, repository, limit, logger)
logger.Info("佣金与退款补偿扫描完成",
zap.Int("订单已补发", orderStats.Resent), zap.Int("订单无需补发", orderStats.Unchanged), zap.Int("订单失败", orderStats.Failed),
zap.Int("退款已补发", refundStats.Resent), zap.Int("退款无需补发", refundStats.Unchanged), zap.Int("退款失败", refundStats.Failed))
}
// RecoverRefundPostProcessing 只扫描已通过但后处理未闭合的退款单并按稳定业务键恢复缺失或终态失败事件。
// 返还的统计用于启动日志与周期任务日志;本函数只重投事件,不直接改资金。
func RecoverRefundPostProcessing(ctx context.Context, db *gorm.DB, repository *outbox.Repository, limit int, logger *zap.Logger) RecoveryStats {
if limit <= 0 {
limit = 100
}
refundStats := RecoveryStats{}
var refunds []model.RefundRequest
if err := db.WithContext(ctx).Where("status = ? AND (commission_deducted = ? OR asset_reset = ?)", model.RefundStatusApproved, false, false).Order("id ASC").Limit(limit).Find(&refunds).Error; err != nil {
logger.Warn("扫描退款后处理失败", zap.Error(err))
@@ -133,9 +145,7 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
}
}
}
logger.Info("佣金与退款补偿扫描完成",
zap.Int("订单已补发", orderStats.Resent), zap.Int("订单无需补发", orderStats.Unchanged), zap.Int("订单失败", orderStats.Failed),
zap.Int("退款已补发", refundStats.Resent), zap.Int("退款无需补发", refundStats.Unchanged), zap.Int("退款失败", refundStats.Failed))
return refundStats
}
func recoverOne(ctx context.Context, db *gorm.DB, repository *outbox.Repository, eventType string, aggregateID, orderID uint, stats *RecoveryStats, logger *zap.Logger, retryDelivered bool) {

View File

@@ -0,0 +1,41 @@
package commissiondelivery
import (
"context"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecoverLimit 是单次补偿扫描的退款单上限。
const RecoverLimit = 100
// RefundRecoveryTaskHandler 执行退款佣金回溯后处理的周期性补偿任务。
// 该任务只按稳定业务键重投 Outbox 事件,绝不直接改资金;重复执行由消费端幂等兜底。
type RefundRecoveryTaskHandler struct {
db *gorm.DB
repository *outbox.Repository
logger *zap.Logger
}
// NewRefundRecoveryTaskHandler 创建退款佣金回溯补偿任务 Handler。
func NewRefundRecoveryTaskHandler(db *gorm.DB, repository *outbox.Repository, logger *zap.Logger) *RefundRecoveryTaskHandler {
return &RefundRecoveryTaskHandler{db: db, repository: repository, logger: logger}
}
// Handle 扫描已通过但佣金回溯后处理未闭合的退款单,恢复其唯一后处理请求投递。
func (h *RefundRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
if h == nil || h.db == nil || h.repository == nil {
return errors.New(errors.CodeServiceUnavailable, "退款佣金回溯补偿任务未配置")
}
stats := RecoverRefundPostProcessing(ctx, h.db, h.repository, RecoverLimit, h.logger)
h.logger.Info("退款佣金回溯补偿完成",
zap.String("task_type", constants.TaskTypeRefundCommissionRecovery),
zap.Int("已补发", stats.Resent), zap.Int("无需补发", stats.Unchanged), zap.Int("失败", stats.Failed))
return nil
}

View File

@@ -9,6 +9,7 @@ import (
// CommissionRecord 佣金记录模型
// 记录各级代理的佣金入账情况
// 包含成本价差收入和一次性佣金两种佣金来源
// 退款佣金回溯不改动本表:原佣金保持已发放,回溯事实见 tb_commission_clawback_record。
type CommissionRecord struct {
gorm.Model
BaseModel `gorm:"embedded"`
@@ -19,7 +20,7 @@ type CommissionRecord struct {
CommissionSource string `gorm:"column:commission_source;type:varchar(20);not null;index;comment:佣金来源 cost_diff-成本价差 one_time-一次性佣金" json:"commission_source"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:佣金金额(分)" json:"amount"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;default:0;comment:入账后钱包余额(分)" json:"balance_after"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效 99-待人工修正" json:"status"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效 5-回溯 99-待人工修正" json:"status"`
ReleasedAt *time.Time `gorm:"column:released_at;comment:入账时间" json:"released_at"`
Remark string `gorm:"column:remark;type:varchar(500);comment:备注" json:"remark"`
}

View File

@@ -0,0 +1,29 @@
package model
import "time"
// CommissionClawbackRecord 佣金回溯明细模型。
// 退款完成后的佣金回溯以负数、不可提现明细独立保存,原佣金记录保持已发放不变。
// 记录不可变:生成后不更新、不删除;(refund_id, original_commission_id) 是幂等唯一事实。
type CommissionClawbackRecord struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RefundID uint `gorm:"column:refund_id;not null" json:"refund_id"`
OriginalCommissionID uint `gorm:"column:original_commission_id;not null" json:"original_commission_id"`
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
ShopID uint `gorm:"column:shop_id;not null" json:"shop_id"`
OrderNo string `gorm:"column:order_no;type:varchar(30);not null;default:''" json:"order_no"`
RefundNo string `gorm:"column:refund_no;type:varchar(50);not null;default:''" json:"refund_no"`
CommissionSource string `gorm:"column:commission_source;type:varchar(20);not null" json:"commission_source"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:回溯金额(分),恒为负数" json:"amount"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;default:0;comment:回溯后佣金钱包实际余额(分),允许为负" json:"balance_after"`
Withdrawable bool `gorm:"column:withdrawable;not null;default:false;comment:是否可提现,回溯明细恒为不可提现" json:"withdrawable"`
Status int `gorm:"column:status;type:int;not null;default:5;comment:状态 5-回溯" json:"status"`
// CreatedAt 与 tb_commission_record.created_at 同为不带时区的本地时间,
// 保证两表 UNION ALL 合并分页的统一排序键与时间筛选口径一致。
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;autoCreateTime" json:"created_at"`
}
// TableName 指定表名
func (CommissionClawbackRecord) TableName() string {
return "tb_commission_clawback_record"
}

View File

@@ -4,7 +4,7 @@ import "time"
// CreateExportTaskRequest 创建导出任务请求。
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选)"`
}
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
type ListExportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
@@ -33,7 +33,7 @@ type ExportTaskItem struct {
ID uint `json:"id" description:"任务ID"`
TaskID uint `json:"task_id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"`
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StatusName string `json:"status_name" description:"任务状态名称(中文)"`

View File

@@ -148,24 +148,48 @@ type ShopCommissionRecordListReq struct {
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID模糊查询"`
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=50" maxLength:"50" description:"设备虚拟号(模糊查询)"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=99" minimum:"1" maximum:"99" description:"佣金状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正)"`
}
// ShopCommissionRecordItem 代理商佣金明细
// ShopCommissionClawbackItem 是一条与原佣金关联的佣金回溯明细
type ShopCommissionClawbackItem struct {
ID uint `json:"id" description:"回溯明细ID"`
OriginalCommissionID uint `json:"original_commission_id" description:"被回溯的原佣金记录ID"`
RefundID uint `json:"refund_id" description:"来源退款申请ID"`
RefundNo string `json:"refund_no" description:"来源退款单号"`
Amount int64 `json:"amount" description:"回溯金额(分),恒为负数"`
BalanceAfter int64 `json:"balance_after" description:"回溯后佣金余额(分),可为负数"`
Withdrawable bool `json:"withdrawable" description:"是否可提现,回溯明细恒为不可提现"`
Status int `json:"status" description:"状态 (5:回溯)"`
StatusName string `json:"status_name" description:"状态名称"`
CreatedAt string `json:"created_at" description:"生成时间"`
}
// ShopCommissionRecordItem 代理商佣金明细项。
// 原佣金与回溯明细合并为同一列表source 区分来源,回溯行携带原佣金标识、退款单号与不可提现标识。
type ShopCommissionRecordItem struct {
ID uint `json:"id" description:"佣金记录ID"`
Amount int64 `json:"amount" description:"佣金金额(分"`
BalanceAfter int64 `json:"balance_after" description:"入账后佣金额(分)"`
CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
Status int `json:"status" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效)"`
StatusName string `json:"status_name" description:"状态名称"`
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单"`
VirtualNo string `json:"virtual_no,omitempty" description:"设备虚拟号"`
ICCID string `json:"iccid,omitempty" description:"ICCID"`
OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"`
SellerShopID uint `json:"seller_shop_id" description:"销售来源店铺ID"`
SellerShopName string `json:"seller_shop_name" description:"销售来源店铺名称"`
CreatedAt string `json:"created_at" description:"佣金入账时间"`
Source string `json:"source" description:"记录来源 (original:原佣金, clawback:回溯明细)"`
ID uint `json:"id" description:"记录ID原佣金ID或回溯明细ID"`
Amount int64 `json:"amount" description:"佣金额(分),回溯明细为负数"`
BalanceAfter int64 `json:"balance_after" description:"入账后佣金余额(分),回溯行为回溯后余额,可为负数"`
CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus(已废弃):梯度奖励)"`
Status int `json:"status" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正)"`
StatusName string `json:"status_name" description:"状态名称"`
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
VirtualNo string `json:"virtual_no,omitempty" description:"设备虚拟号"`
ICCID string `json:"iccid,omitempty" description:"ICCID"`
OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"`
SellerShopID uint `json:"seller_shop_id" description:"销售来源店铺ID"`
SellerShopName string `json:"seller_shop_name" description:"销售来源店铺名称"`
ReleasedAt string `json:"released_at,omitempty" description:"佣金入账时间,回溯明细为空"`
CreatedAt string `json:"created_at" description:"佣金入账或回溯明细生成时间"`
OriginalCommissionID uint `json:"original_commission_id,omitempty" description:"被回溯的原佣金记录ID仅回溯明细返回"`
RefundID uint `json:"refund_id,omitempty" description:"来源退款申请ID仅回溯明细返回"`
RefundNo string `json:"refund_no,omitempty" description:"来源退款单号,仅回溯明细返回"`
Withdrawable *bool `json:"withdrawable,omitempty" description:"是否可提现,仅回溯明细返回且恒为不可提现"`
ClawbackRecords []ShopCommissionClawbackItem `json:"clawback_records,omitempty" description:"该原佣金已生成的全部回溯明细,仅原佣金返回"`
ClawbackTotalAmount int64 `json:"clawback_total_amount,omitempty" description:"该原佣金累计回溯金额(分,负值),仅原佣金返回"`
}
// ShopCommissionRecordPageResult 代理商佣金明细分页响应
@@ -176,6 +200,22 @@ type ShopCommissionRecordPageResult struct {
Size int `json:"size" description:"每页数量"`
}
// ShopCommissionRecordDetailReq 代理商佣金明细详情请求
type ShopCommissionRecordDetailReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"记录ID"`
Source string `json:"source" query:"source" validate:"omitempty,oneof=original clawback" description:"记录来源 (original:原佣金, clawback:回溯明细,默认原佣金)"`
}
// ShopCommissionRecordDetailResp 代理商佣金明细详情响应。
// 原佣金详情返回其全部回溯明细;回溯明细详情返回关联的原佣金与来源退款单。
type ShopCommissionRecordDetailResp struct {
Source string `json:"source" description:"记录来源 (original:原佣金, clawback:回溯明细)"`
Record ShopCommissionRecordItem `json:"record" description:"主体记录"`
OriginalCommission *ShopCommissionRecordItem `json:"original_commission,omitempty" description:"回溯明细关联的原佣金记录,仅回溯明细详情返回"`
ClawbackRecords []ShopCommissionClawbackItem `json:"clawback_records,omitempty" description:"该原佣金已生成的全部回溯明细,仅原佣金详情返回"`
}
// ========================================
// 路由参数 DTO
// ========================================

View File

@@ -159,6 +159,15 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/commission-records/:id", handler.GetCommissionRecord, RouteSpec{
Summary: "佣金明细详情",
Description: "返回单条佣金明细source=clawback 时返回回溯明细并附带其原佣金,默认返回原佣金并附带全部回溯明细。仅限有数据范围的后台账号,越权与不存在返回同一结果。",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopCommissionRecordDetailReq),
Output: new(dto.ShopCommissionRecordDetailResp),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/main-wallet/transactions", handler.ListMainWalletTransactions, RouteSpec{
Summary: "代理商预充值钱包流水",
Tags: []string{"代理商资金管理"},

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

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