From 1aa4eacee2e65ecca728efb6cc2795da83f57a61 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 14 Sep 2026 13:40:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=80=80=E6=AC=BE=E5=88=86=E4=BD=A3):=20?= =?UTF-8?q?=E4=BD=A3=E9=87=91=E5=9B=9E=E6=BA=AF=E6=98=8E=E7=BB=86=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E5=85=A8=E9=A2=9D=E5=A4=B1=E6=95=88=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E8=AF=BB=E4=BE=A7=E4=B8=8E=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 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 --- cmd/worker/main.go | 22 + .../entry-capability-requirement-matrix.json | 11 + internal/exporter/commission_record_scene.go | 213 ++++++ internal/exporter/registry.go | 4 +- internal/handler/admin/shop_commission.go | 28 + internal/infrastructure/audit/refund.go | 18 + internal/infrastructure/audit/registry.go | 9 + .../commissiondelivery/event.go | 18 +- .../commissiondelivery/recovery_task.go | 41 ++ internal/model/commission.go | 3 +- internal/model/commission_clawback.go | 29 + internal/model/dto/export_task_dto.go | 6 +- internal/model/dto/shop_commission_dto.go | 70 +- internal/routes/shop.go | 9 + internal/service/refund/approval_decision.go | 13 +- internal/service/refund/audit.go | 53 -- internal/service/refund/clawback.go | 608 ++++++++++++++++++ internal/service/refund/service.go | 149 ----- internal/service/shop_commission/service.go | 210 ++++-- .../store/postgres/commission_record_store.go | 263 +++++--- ...20_add_commission_clawback_record.down.sql | 16 + ...0220_add_commission_clawback_record.up.sql | 56 ++ .../add-commission-clawback-records/design.md | 161 ++++- .../proposal.md | 24 +- .../specs/agent-funds-commission/spec.md | 123 +++- .../specs/order-refund-exchange/spec.md | 27 + .../add-commission-clawback-records/tasks.md | 41 +- pkg/constants/audit.go | 9 + pkg/constants/constants.go | 27 +- pkg/constants/iot.go | 5 +- 30 files changed, 1867 insertions(+), 399 deletions(-) create mode 100644 internal/exporter/commission_record_scene.go create mode 100644 internal/infrastructure/commissiondelivery/recovery_task.go create mode 100644 internal/model/commission_clawback.go create mode 100644 internal/service/refund/clawback.go create mode 100644 migrations/000220_add_commission_clawback_record.down.sql create mode 100644 migrations/000220_add_commission_clawback_record.up.sql create mode 100644 openspec/changes/add-commission-clawback-records/specs/order-refund-exchange/spec.md diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 2983bf3..c9f3b71 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -161,6 +161,7 @@ func runWorker(cfg *config.Config) { registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger) registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger) + registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) @@ -499,6 +500,17 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery)) } +// registerRefundCommissionRecoveryTask 注册退款佣金回溯后处理的周期性补偿任务。 +// 该任务只重投稳定的退款后处理 Outbox 事件,绝不直接改动资金;重复执行由消费端幂等兜底。 +func registerRefundCommissionRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) { + if runtime == nil || runtime.db == nil { + appLogger.Fatal("退款佣金回溯补偿任务缺少数据库依赖") + } + handler := commissionDelivery.NewRefundRecoveryTaskHandler(runtime.db, outbox.NewRepository(), appLogger) + mux.HandleFunc(constants.TaskTypeRefundCommissionRecovery, handler.Handle) + appLogger.Info("注册退款佣金回溯补偿任务处理器", zap.String("task_type", constants.TaskTypeRefundCommissionRecovery)) +} + // registerRefundChannelRecoveryTask 注册渠道原路退款结果恢复任务。 // 该任务只查询渠道并回填结果,绝不重复发起资金动作。 // 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。 @@ -785,6 +797,16 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna )); err != nil { return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( + constants.TaskTypeRefundCommissionRecovery, + nil, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + asynq.Unique(10*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundCommissionRecovery)), + )); err != nil { + return fmt.Errorf("注册退款佣金回溯补偿定时任务失败: %w", err) + } if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( constants.TaskTypeOrderExpire, nil, diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 0640714..1cb52ba 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -3578,6 +3578,17 @@ ], "classification": "behavior" }, + { + "entry_type": "async", + "entry": "constants.TaskTypeRefundCommissionRecovery", + "capability": "agent-funds-commission", + "requirements": [ + "agent-funds-commission::退款佣金回扣可靠完成", + "agent-funds-commission::退款后处理可补偿", + "order-refund-exchange::退款终态事实与失败分类" + ], + "classification": "route_index_or_infrastructure" + }, { "entry_type": "async", "entry": "constants.TaskTypeWeComApprovalRecovery", diff --git a/internal/exporter/commission_record_scene.go b/internal/exporter/commission_record_scene.go new file mode 100644 index 0000000..6359c21 --- /dev/null +++ b/internal/exporter/commission_record_scene.go @@ -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 "不可提现" +} diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index fac6704..394b894 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -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 diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index 4147206..3cfca1d 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -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 { diff --git a/internal/infrastructure/audit/refund.go b/internal/infrastructure/audit/refund.go index 957791e..b8d7dd5 100644 --- a/internal/infrastructure/audit/refund.go +++ b/internal/infrastructure/audit/refund.go @@ -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, diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index b885c69..a28298f 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -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"}, diff --git a/internal/infrastructure/commissiondelivery/event.go b/internal/infrastructure/commissiondelivery/event.go index d95a27b..7cab136 100644 --- a/internal/infrastructure/commissiondelivery/event.go +++ b/internal/infrastructure/commissiondelivery/event.go @@ -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) { diff --git a/internal/infrastructure/commissiondelivery/recovery_task.go b/internal/infrastructure/commissiondelivery/recovery_task.go new file mode 100644 index 0000000..985edba --- /dev/null +++ b/internal/infrastructure/commissiondelivery/recovery_task.go @@ -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 +} diff --git a/internal/model/commission.go b/internal/model/commission.go index 69e2f57..3a66dfc 100644 --- a/internal/model/commission.go +++ b/internal/model/commission.go @@ -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"` } diff --git a/internal/model/commission_clawback.go b/internal/model/commission_clawback.go new file mode 100644 index 0000000..20c99fb --- /dev/null +++ b/internal/model/commission_clawback.go @@ -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" +} diff --git a/internal/model/dto/export_task_dto.go b/internal/model/dto/export_task_dto.go index e6f63f9..fd7b665 100644 --- a/internal/model/dto/export_task_dto.go +++ b/internal/model/dto/export_task_dto.go @@ -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:"任务状态名称(中文)"` diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index 73c2bb1..62bfd76 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -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 // ======================================== diff --git a/internal/routes/shop.go b/internal/routes/shop.go index 678c4e8..b748ab0 100644 --- a/internal/routes/shop.go +++ b/internal/routes/shop.go @@ -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{"代理商资金管理"}, diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 3ba370c..5fb4867 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -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 { diff --git a/internal/service/refund/audit.go b/internal/service/refund/audit.go index 55fdfb6..0c947d3 100644 --- a/internal/service/refund/audit.go +++ b/internal/service/refund/audit.go @@ -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, diff --git a/internal/service/refund/clawback.go b/internal/service/refund/clawback.go new file mode 100644 index 0000000..1a51ab2 --- /dev/null +++ b/internal/service/refund/clawback.go @@ -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) +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 67ceb3d..ee9eeb3 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -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(¤t).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, ¤t, &wallet, transaction) - }) -} - // pendingWithdrawalReject 是一条待审提现的本次拒绝事实。 // releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实, // 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。 diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 1f242a1..6ea9bab 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -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) { diff --git a/internal/store/postgres/commission_record_store.go b/internal/store/postgres/commission_record_store.go index 6e47fb5..0cb88ba 100644 --- a/internal/store/postgres/commission_record_store.go +++ b/internal/store/postgres/commission_record_store.go @@ -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 diff --git a/migrations/000220_add_commission_clawback_record.down.sql b/migrations/000220_add_commission_clawback_record.down.sql new file mode 100644 index 0000000..124c462 --- /dev/null +++ b/migrations/000220_add_commission_clawback_record.down.sql @@ -0,0 +1,16 @@ +-- 回滚佣金回溯明细表。 +-- 回溯明细是资金事实,无法由回滚重建;存在记录时禁止破坏性回滚,需人工先核对资金。 +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_commission_clawback_record) THEN + RAISE EXCEPTION '存在佣金回溯明细,禁止回滚佣金回溯明细表'; + END IF; +END $$; + +DROP TABLE IF EXISTS tb_commission_clawback_record; + +-- 还原列注释为 000220 up 之前的真实值:最后一次写该注释的是 +-- migrations/archive/000029_add_one_time_commission.up.sql:94, +-- 值即 '状态 1-已入账 2-已失效'(000113 只迁移状态值,未改注释)。 +-- 只有还原为该字面值,down 才是 up 的严格逆操作(ENG-MIG-001)。 +COMMENT ON COLUMN tb_commission_record.status IS '状态 1-已入账 2-已失效'; diff --git a/migrations/000220_add_commission_clawback_record.up.sql b/migrations/000220_add_commission_clawback_record.up.sql new file mode 100644 index 0000000..537cb6e --- /dev/null +++ b/migrations/000220_add_commission_clawback_record.up.sql @@ -0,0 +1,56 @@ +-- 佣金回溯明细表。 +-- 退款完成后的佣金回溯以独立负数、不可提现明细保存,原佣金记录保持已发放不变, +-- 因此不复用 tb_commission_record,避免负数语义污染既有终态判定、统计与提现资格。 +-- 幂等权威为 (refund_id, original_commission_id) 唯一约束:一次退款对一条原佣金至多一条回溯明细。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_commission_clawback_record ( + id BIGSERIAL PRIMARY KEY, + refund_id BIGINT NOT NULL, + original_commission_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + shop_id BIGINT NOT NULL, + order_no VARCHAR(30) NOT NULL DEFAULT '', + refund_no VARCHAR(50) NOT NULL DEFAULT '', + commission_source VARCHAR(20) NOT NULL, + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL DEFAULT 0, + withdrawable BOOLEAN NOT NULL DEFAULT FALSE, + status INTEGER NOT NULL DEFAULT 5, + -- 列类型与合并分页的另一支 tb_commission_record.created_at 保持一致(均不带时区), + -- 否则 UNION ALL 会按会话时区改写其中一支,破坏统一排序键与时间筛选口径。 + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uk_commission_clawback_refund_original UNIQUE (refund_id, original_commission_id), + CONSTRAINT chk_commission_clawback_ids CHECK (refund_id > 0 AND original_commission_id > 0 AND order_id > 0 AND shop_id > 0), + CONSTRAINT chk_commission_clawback_amount CHECK (amount < 0), + CONSTRAINT chk_commission_clawback_withdrawable CHECK (withdrawable = FALSE), + CONSTRAINT chk_commission_clawback_status CHECK (status = 5) +); + +-- 列表按店铺 + 生成时间倒序合并分页;id 为次序键,避免同秒记录翻页抖动。 +CREATE INDEX idx_commission_clawback_shop_created + ON tb_commission_clawback_record (shop_id, created_at DESC, id DESC); +-- 反向关联原佣金:剩余可回溯余额按原佣金聚合已生成的回溯累计。 +CREATE INDEX idx_commission_clawback_original + ON tb_commission_clawback_record (original_commission_id); +-- 订单维度核对一次订单退款产生的全部回溯事实。 +CREATE INDEX idx_commission_clawback_order + ON tb_commission_clawback_record (order_id); + +COMMENT ON TABLE tb_commission_clawback_record IS '佣金回溯明细,退款完成后生成的负数、不可提现佣金事实,原佣金记录保持不变'; +COMMENT ON COLUMN tb_commission_clawback_record.id IS '主键'; +COMMENT ON COLUMN tb_commission_clawback_record.refund_id IS '来源退款申请ID,与原始佣金ID共同构成幂等唯一键'; +COMMENT ON COLUMN tb_commission_clawback_record.original_commission_id IS '被回溯的原佣金记录ID(tb_commission_record.id),原记录状态与金额不变'; +COMMENT ON COLUMN tb_commission_clawback_record.order_id IS '原佣金关联订单ID快照'; +COMMENT ON COLUMN tb_commission_clawback_record.shop_id IS '佣金归属店铺ID,列表与导出按此应用数据范围'; +COMMENT ON COLUMN tb_commission_clawback_record.order_no IS '原订单号快照'; +COMMENT ON COLUMN tb_commission_clawback_record.refund_no IS '来源退款单号快照'; +COMMENT ON COLUMN tb_commission_clawback_record.commission_source IS '原佣金来源快照:cost_diff 成本价差、one_time 一次性佣金'; +COMMENT ON COLUMN tb_commission_clawback_record.amount IS '回溯金额(分),恒为负数'; +COMMENT ON COLUMN tb_commission_clawback_record.balance_after IS '回溯后佣金钱包实际余额(分),允许为负'; +COMMENT ON COLUMN tb_commission_clawback_record.withdrawable IS '是否可提现,回溯明细恒为不可提现'; +COMMENT ON COLUMN tb_commission_clawback_record.status IS '状态 5-回溯,与 pkg/constants.CommissionStatusClawback 一致'; +COMMENT ON COLUMN tb_commission_clawback_record.created_at IS '生成时间'; + +-- 原佣金记录状态新增 5-回溯枚举(回溯明细不写本表,仅同步列注释)。 +COMMENT ON COLUMN tb_commission_record.status IS '状态 1-已冻结 2-解冻中 3-已发放 4-已失效 5-回溯 99-待人工修正'; diff --git a/openspec/changes/add-commission-clawback-records/design.md b/openspec/changes/add-commission-clawback-records/design.md index 9b04a41..f93be31 100644 --- a/openspec/changes/add-commission-clawback-records/design.md +++ b/openspec/changes/add-commission-clawback-records/design.md @@ -1,30 +1,161 @@ ## Context -退款和佣金终态可能异步到达,回溯必须等待原佣金事实且不能修改其历史记录。 +现有退款佣金回扣是「整单全额失效」:退款 Outbox 事件 `refund.commission.deduct.requested` 触发 `ProcessCommissionDeduction`,由 `deductAllCommission` 对每条已发放佣金调用 `deductSingleCommission`,把原佣金记录状态由 3 改为 4、扣减佣金钱包并写 `commission_deduct` 负数流水,审计动作 `refund.invalidate_commission`。该路径已正确实现两件本 Change 要复用的事实:加锁顺序「申请行 → 尝试行 → 钱包行」,以及「先释放待审提现冻结、再扣减余额」。动机见 `proposal.md` - Why。 + +约束: + +- 佣金钱包 CHECK `chk_agent_wallet_available_balance` 的 commission 分支为 `frozen_balance <= GREATEST(balance, 0)`(迁移 000209)。扣减前若仍有未释放冻结,`GREATEST` 随余额下降而塌陷,先扣款即违约。 +- 提现尝试表 `tb_commission_withdrawal_request_attempt` 无 status 列;未结算的唯一事实是 `released_at IS NULL`,释放金额事实是 `attempt.amount`。 +- 退款状态:`1 待审批 / 2 已通过 / 3 已拒绝 / 4 已退回 / 5 原路处理中 / 6 渠道明确失败`。仅 `2` 是稳定成功终态;`3/4/6` 可重提,重提复用同一退款行且可修改退款金额与冻结实收金额。 +- 企业微信通过后撤销**不改变** `status`,只置 `anomaly_flag = 1`、`failure_reason = 'revoked_after_approved'`,因此「已通过但存在审批异常」与「正常的已通过」在状态上不可区分。 +- 原路渠道明确成功路径不产生任何 Outbox 事件,只回写退款行与订单。 + +## Goals / Non-Goals + +**Goals:** + +- 用 PRD 2.14 语义整体替换全额失效实现,原佣金记录保持不变。 +- 退款回溯准入由退款申请状态与方式唯一决定,不依赖失败分类标记或新增事件。 +- 金额可按分精确复现:比例、向下取整、末条起向前补差、累计上限。 +- 幂等有数据库权威兜底,`commission_deducted` 明确降级为投影。 +- 回溯在提现冻结约束下可安全落库,允许佣金余额为负。 +- 回溯事实可在佣金明细列表、详情与导出中按既有数据范围核对。 + +**Non-Goals:** + +- 不修改既有迁移;新增 Schema 变化一律使用新的成对迁移。 +- 不重构 `internal/service/refund` 的模块结构,也不改动人工处置链路(`ResolveCommissionRecord` 的 99 → 4 失效)与提现资格、审批语义。 +- 不新增退款完成类独立事件,不新增运行时开关。 +- 不修改 `GetStats` / `GetDailyStats` 与首页统计口径:回溯不计入净佣金,本期只保证回溯明细在列表、详情与导出中可见。 +- 不回填存量:已按旧语义处理过(`commission_deducted = true`)的退款单不重新回溯,其原佣金保持既有的已失效状态,不补建回溯明细。 +- 换货回溯、统一时间筛选、员工代收款账单、代理自充与商户池不在范围。 ## Decisions -- 回溯表以退款+原佣金唯一,保存负数快照;退款消费者等待佣金终态再可靠重试。 -- 在钱包事务内先处理待审提现释放,再插入回溯和扣款流水;唯一约束保证重放安全。 +### 1. 回溯准入由退款申请状态与方式决定 -## 生成、资金与读侧契约 +仅当 `refund.status = 2`(已通过)**且** `anomaly_flag = 0` **且**(非原路方式 **或** `channel_refund_status = 2`)时生成回溯。其余状态返回可重试错误,不写明细、不置完成事实。 -### 退款事件消费 +- **`anomaly_flag = 0` 是必需条件,不是可选加固**:`applyRevokedAfterApproved` 只置异常标记、不改状态,因此「企业微信通过后撤销」的退款单仍是 `status = 2`;原路场景下渠道后续成功仍会把 `status` 由 5 推到 2(`applyResult` 的谓词只含 `status = 5`)。仅用 `status = 2` 会把这两种情况误判为可回溯,直接违反「撤销不得回溯」。 +- **不含失败分类**:`RefundFailureReasonIsDefinitive` 的取值面向「尝试是否终结」,与「退款是否真正完成」正交;成功退款的 `failure_reason` 为空,按分类判定会把成功退款判为不可回溯。该函数保留但 MUST NOT 进入回溯准入。 +- **不依赖独立完成事件键**:原路与客户收款信息两种完成的区别已由 `status` 与 `channel_refund_status` 表达,`status = 2` 是唯一稳定终态。 +- 备选(否决):在渠道明确失败时回溯。被否决,因为 `3/4/6` 可重提并修改金额,按 `(refund_id, original_commission_id)` 幂等会使第二次成功退款无法补回溯,金额必然错误。 +- 渠道明确失败后重提并最终成功:`status` 由 `5/6` 重新走到 `2`,此时才生成一次回溯,金额取最终成功金额。 -- 退款完成可靠事件以 `refund_id`、`order_id` 进入回溯用例;锁定退款、订单和佣金终态。原订单佣金未终态时不写“无需回溯”,仅保留可重试事件;终态无佣金时写退款已处理且无需回溯的审计;换货事件不进入本用例。 -- 查询原订单全部可回溯佣金,按稳定顺序计算。全额退款回溯每条剩余可回溯金额;部分退款以 `refund_amount / frozen_actual_paid_amount` 计算,每条向下取整,最后一条仅补足总额舍入差且不得超过该条剩余可回溯余额。冻结实收金额缺失或非正时记录可恢复失败,不以订单标价替代。 -- 新表以 `(refund_id, original_commission_id)` 唯一,保存负数金额、原佣金/订单/退款快照、不可提现标识、生成时间;唯一冲突视为已生成,禁止第二次扣款。 +### 2. 消费门禁与周期性补偿 -### 钱包与提现原子边界 +- 消费端在准入不满足时返回可重试错误,交由既有 Outbox 重试;订单佣金未终态时同样返回可重试错误,不写明细、不置位。 +- 既有补偿扫描 `commissionDelivery.Recover` 保留启动时执行,并新增周期性任务:`@every 1m`、`asynq.MaxRetry(3)`、`asynq.Timeout(10m)`、`asynq.Unique(10m)`、独立队列,形态与 `TaskTypeRefundChannelRecovery` 一致;新增任务类型常量与 `QueueForTaskType` 映射及其 Handler。 +- 补偿扫描判据保持 `status = 2 AND commission_deducted = false`(不新增列条件),因为「撤销」与「无需回溯」都在第 3 条被收敛为已闭合并置位,扫描不会对它们空转。 +- 备选(否决):让扫描排除 `anomaly_flag = 1`。被否决——那样异常的退款单永远没有闭合事实,扫描与投递在每轮都会重复处理同一批记录。 -- 在同一钱包事务内,先锁定代理佣金钱包和所有待审核/审批中的提现申请;拒绝这些申请、释放其冻结余额并保存“退款回溯优先”原因,然后插入所有回溯明细和负数佣金钱包流水。允许钱包余额低于零。 -- 原佣金记录、历史发放金额和已提现完成事实不更新、不删除;回溯是独立负数事实。事务任一步失败时不释放提现、不写部分回溯或部分流水,可靠事件保留重试。 +### 3. 后处理闭合的三种结果 -### 查询与导出 +`commission_deducted` 的语义统一为「该退款单的佣金回溯后处理是否已闭合(含无需回溯与转人工)」,只在以下三种结果之一成立时置位,且置位使用 `WHERE commission_deducted = false` 条件保护: -- 扩展佣金明细列表/详情:原佣金返回 `clawback_records` 摘要,回溯明细返回 `original_commission_id`、退款单号、负数金额、不可提现、回溯后实际钱包余额和生成时间。关联查询先应用既有佣金数据范围,再按关联 ID 查询;越权不泄露存在性。 -- 导出每条原佣金和回溯明细各一行,冻结筛选、操作者、可见范围和生成时间;金额保持分,展示层转换元不得改变负数或余额事实。 +| 结果 | 条件 | 资金 | 审计 | +| --- | --- | --- | --- | +| 已生成回溯 | 准入满足且存在可回溯佣金 | 插入明细、扣钱包、写负数流水 | 新动作码 `refund.clawback_commission`,资源含回溯明细 | +| 无需回溯 | 准入满足、原订单佣金已终态且确认无佣金 | 无 | 新动作码 `refund.clawback_not_required` | +| 转人工不回溯 | 准入不满足且 `anomaly_flag = 1` | 无 | 复用既有 `refund.anomaly_flagged` 语义 | + +- 准入不满足且**非**异常(待审批、原路处理中、渠道明确失败)时返回可重试错误,不置位、不写审计,等待退款推进。 +- 撤销发生在回溯已生成之后时,不自动冲销已落库的回溯事实(资金已实际发生),按异常转人工处理;回收走人工更正,不新增自动反向流水。 +- **「原订单佣金已终态」的判据(两个条件同时成立)**: + 1. 订单佣金流程已离开「待计算」(`order.commission_status != 1`); + 2. 该订单不存在任何未终态佣金记录(`tb_commission_record.status IN (1 已冻结, 2 解冻中, 99 待人工修正)`)。 +- 仅用条件 1 是**错误**判据:`order.commission_status = 3`(待人工处理)只表示「存在 99 记录」,而人工处置(`shop_commission.ResolveCommissionRecord`)把 99 改成已发放/已失效后**不回写订单状态**,订单会长期停留在 3。只看订单状态会把「待人工修正」误判为终态,进而在「无已发放佣金」时提前闭合为「无需回溯」并置位;此后投影早退 + 补偿扫描只扫 `commission_deducted = false`,该订单后续入账的佣金将**永久不再回溯**,违反本 Change 的 Spec「MUST NOT 提前判定为无需回溯」。 +- 收敛条件(保证不会永久等待,依据全仓 `tb_commission_record.status` 写点实测):该表的状态写点只有两类—— + - **创建**:`internal/service/commission_calculation/service.go:181/:241/:554` 写入已发放 3,`:220/:581` 写入待人工修正 99;`internal/service/recharge_order/service.go:418` 写入已发放 3; + - **更新**:`internal/service/commission_calculation/service.go:689-693`(入账置 3)与 `internal/service/shop_commission/service.go:751/:781`(人工处置置 4 或置 3)。 + + 即当前代码**从不写入 1(已冻结)或 2(解冻中)**,这两个值是仅供历史数据读取与名称映射的遗留枚举;把它们纳入未终态集合是防御性判断(历史遗留的 1/2 行确实不是已结算事实),而非新的等待来源。因此现实中的等待只可能由 99 引起,且 99 一旦被人工处置即收敛:处置为已发放(3) → 生成回溯;处置为已失效(4) → 确认无佣金可回溯并闭合。 +- 该判据不满足时返回可重试错误(`CodeServiceUnavailable`),不写明细、不置位。 + +### 4. 金额计算:分母、分子、分摊与舍入 + +全程 `int64` 分,乘法使用 `math/big` 或等价的 128 位中间量,禁止浮点与溢出。 + +- 分母 `D` = 本次退款的冻结实收金额(退款行,缺失时回落该次审批尝试记录)。`D <= 0` 时记录可恢复失败:不落库、不改余额、不置位,MUST NOT 用订单标价或申请金额替代。 +- 分子 `N` = 原路成功时取渠道明确成功金额,其余方式取审批退款金额。 +- 稳定顺序固定为 `original_commission_id ASC`;该顺序决定补差落点,必须固定,否则同一输入可能得出不同分摊结果。 +- 逐条基准 `base_i = min( floor(amount_i × N / D), 剩余_i )`;`剩余_i = amount_i − 该原佣金已生成的回溯累计`。 +- 应回溯总额 `T = min( floor(Σamount_i × N / D), Σ剩余_i )`。 +- 补差 `T − Σbase_i` 自稳定顺序**末条起向前**分摊,每条不超过该条 `剩余_i`;若最末条不足以吸收全部余差则继续向前一条分摊,直至余差用尽。 +- 全额退款(`N = D`)时 `base_i = min(amount_i, 剩余_i)`,自然退化为按剩余可回溯余额回溯。 +- 备选(否决):按比例算出总额后平均分配。被否决——会产生与各原佣金金额不成比例的扣减,且无法在分制下复现。 + +### 5. 新表 `tb_commission_clawback_record` + +不复用 `tb_commission_record`:负数语义与本表的可提现判定、结算与统计查询完全隔离,避免污染既有 `status = 3 AND amount > 0` 一类终态判定。 + +字段:`refund_id`、`original_commission_id`、`order_id`、`shop_id`、`amount`(负数)、`withdrawable`(固定不可提现)、`status`(= 5)、`balance_after`(回溯后佣金钱包实际余额,可负)、原佣金与退款关键快照(佣金来源、订单号、退款单号)、`created_at`。 + +- 唯一约束 `(refund_id, original_commission_id)` 是唯一权威幂等键。 +- 索引:店铺 + 时间(列表与数据范围)、`original_commission_id`(反向关联)、`order_id`(订单维度核对)。 +- 备选(否决):复用 `tb_commission_record` 并加 `original_commission_id` / `refund_id` 列。被否决——列表、统计、终态判定与导出全部要重新审计负数的语义,且「已发放」「回溯」会被读侧到处区分,回归面远大于两表合并。 +- 代价:读侧必须两表 `UNION ALL` 合并分页(见第 8 条)。 + +### 6. 幂等三层与投影 + +1. **第 1 层(权威、DB 兜底)**:回溯明细唯一约束 `(refund_id, original_commission_id)`。唯一冲突即视为已生成,不重复扣款。 +2. **第 2 层(事务内互斥)**:佣金行 `FOR UPDATE` + 佣金钱包 `version` 乐观锁(沿用既有)。 +3. **第 3 层(投影、非幂等键)**:`commission_deducted` 仅供 Outbox 消费结果判据与补偿扫描使用,MUST NOT 参与逐条幂等判定;置位加 `WHERE commission_deducted = false` 保护(现有实现缺此保护)。 + +### 7. 加锁顺序、释放顺序与原子边界 + +同一事务内顺序固定: + +``` +锁 提现申请行 (shop_id, status = 待审核, ORDER BY id ASC) FOR UPDATE +锁 尝试行 FOR UPDATE, released_at IS NULL + → 幂等置 released_at;释放额 = Σ attempt.amount(无尝试记录的存量申请取申请金额) +锁 佣金钱包行 FOR UPDATE + → 解冻 frozen_balance(先于扣款,约束要求) + → 置申请为已拒绝 + processed_at + 拒绝原因 + → 释放额 > 0 时写正向流水与拒绝审计 +插入 回溯明细(唯一约束兜底) +扣减 balance(允许为负) +写 commission_deduct 负数流水 +写回溯审计 +``` + +- 加锁顺序全库统一为**申请行 → 尝试行 → 钱包行**,钱包永远最后加锁,避免与提现终态消费者形成死锁环。 +- 保留既有 `collectPendingWithdrawalRejects` / `applyPendingWithdrawalRejects` / `appendWithdrawalRejectAudit`,不重写。 +- 任一步失败整体回滚:不释放提现、不写部分明细或部分流水,可靠事件保留重试。事务内 MUST NOT 持有不可回滚的外部 I/O(ENG-TX-001)。 + +### 8. 读侧最小面 + +- 列表支持 `status` 筛选透传(现有 DTO 与 service 均未透传,store 已支持)。 +- 两表 `UNION ALL` 合并分页,筛选与数据范围在合并前各自应用。 +- 排序与次序键:`created_at DESC` → `id DESC` → `source ASC`。两表自增 ID 空间相互独立,同一 `created_at` 下不同表的 `id` 只保证各自表内有序,因此 `(created_at DESC, id DESC)` **不是全序**;必须再以 `source ASC` 作为末位次序键,才能在 `created_at` 与 `id` 完全相同的跨表记录上保证翻页不重不漏。 +- 原佣金行返回 `clawback_records` 摘要;回溯行返回原始佣金标识、退款单号、负数金额、不可提现标识、回溯后余额、生成时间。 +- 新增佣金明细详情接口,并同步可执行路由、`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 装配;DTO 中文 description 与枚举名与 `pkg/constants` 一致(ENG-DTO-001)。 +- 越权一律按既有佣金数据范围返回不存在或空结果,不泄露存在性。 + +### 9. 导出归属与粒度 + +- 本 Change 落地佣金明细导出场景:场景白名单、`internal/exporter` 注册、DTO `oneof`、DataSource 与列定义。 +- 粒度为佣金记录:原佣金与回溯记录各占一行;入账后金额与回溯后余额取对应钱包变动提交后的实际余额,可为负;金额保持分,展示层转元不得改变负数或余额事实。 +- 边界:佣金明细导出的粒度与列定义由本 Change 确定;`add-export-time-filter-standards` 只负责统一 `start_time`/`end_time` 与快照冻结。 + +### 10. 常量与审计 + +- 新增 `CommissionStatusClawback = 5`,`GetCommissionRecordStatusName` 增加「回溯」映射,并同步状态常量注释;`tb_commission_record.status` 无 CHECK 白名单,新增枚举值不需要改列约束。 +- 新增回溯审计动作码 `refund.clawback_commission` 与 `refund.clawback_not_required`,主资源为退款单,并在 `internal/infrastructure/audit/registry.go` 注册。 +- 旧 `refund.invalidate_commission` 保留常量与注册(历史审计仍可读),代码中不再有新调用点。 + +## Risks / Trade-offs + +- [两表合并分页复杂,易漏筛选或重复计数] → 合并前各自应用数据范围与筛选,统一排序键并显式以 `source ASC` 打破跨表 `(created_at, id)` 并列;在隔离库对翻页边界与总数做核对。 +- [误用 `status = 2` 单条件会导致撤销退款被回溯] → 准入显式包含 `anomaly_flag = 0`,并以「撤销后不回溯」「原路在途不回溯」两个场景固定。 +- [周期性补偿放大重复处理] → 幂等第 1 层由唯一约束兜底,第 3 层置位加条件保护;补偿只重投事件,不直接改资金。 +- [`math/big` 中间量引入新代码路径] → 仅用于比例乘法,除法与求和保持 `int64`;用溢出边界用例固定。 +- [存量已按旧语义处理的退款单与新语义并存] → 明确不回填;`commission_deducted = true` 的存量不进入补偿扫描,原佣金保持已失效是历史事实。 +- [新导出场景未覆盖 AUG26-014 的时间筛选] → 列定义与粒度归本 Change,时间参数与快照冻结在 AUG26-014 落地;两者叠加不互相改变语义。 ## Migration Plan -新增成对迁移;隔离库验证全额/部分、舍入、佣金延迟、重复事件、提现释放、负余额及 up/down/up。 \ No newline at end of file +新增成对迁移建表、唯一约束与索引;编号按 `migrations/` 目录当前最大编号顺延,不预占、不修改既有迁移。回滚按 `down` 删除本 Change 新增的表与约束,不影响既有表与数据。 + +隔离库验证(ENG-TEST-001):在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 上,以本地显式 `DB_*` 参数执行 `./scripts/migrate.sh` 完成 up/down/up;仅创建与删除本 Change 自己的 fixture,不重置整库。验证覆盖全额与部分回溯、比例与补差、溢出行边界、累计不超过剩余、冻结实收非正可恢复失败、佣金未终态等待、终态确无佣金、原路在途不回溯、撤销不回溯、渠道失败重提后按最终金额回溯、重复消费、释放先于扣款与负余额不违约、原佣金不变、读侧合并分页与越权、导出负数余额。 diff --git a/openspec/changes/add-commission-clawback-records/proposal.md b/openspec/changes/add-commission-clawback-records/proposal.md index faaa3a7..4f5a938 100644 --- a/openspec/changes/add-commission-clawback-records/proposal.md +++ b/openspec/changes/add-commission-clawback-records/proposal.md @@ -4,13 +4,17 @@ ## Why -套餐退款后佣金需以独立负数事实回溯,并与提现冻结和钱包余额一致。 +现有退款佣金回扣把原佣金记录整单置为已失效,与 PRD 2.14「原佣金不变 + 独立负数回溯明细 + 可按分核对与导出」不一致;该路径在退款真正完成前即可触发,金额与幂等都不可核对。 ## What Changes -- 新增不可提现负数回溯明细及原佣金/退款关联。 -- 按冻结实收比例、舍入和幂等规则扣回佣金。 -- 回溯前释放待审提现,允许佣金钱包负余额。 +- **BREAKING**(行为替换,非新增):用 PRD 2.14 语义替换现有全额失效实现。原佣金记录保持已发放,不改为已失效、不改金额与发放时间;回溯事实落在新表 `tb_commission_clawback_record` 的负数、不可提现明细上。不保留旧的全额失效路径,不新增运行时开关。 +- 回溯只在退款申请已通过(`status = 2`)时生成;原路方式还须渠道明确成功(`channel_refund_status = 2`)。待审批、原路处理中、渠道明确失败(可重提)、企业微信通过后撤销均不生成;渠道失败重提后按最终成功金额回溯一次。 +- 消费端未满足触发条件时返回可重试错误,不写明细、不置完成标记;既有补偿扫描注册为周期性任务,并保留启动时执行。 +- 金额全程按分整数计算:分母取本次退款的冻结实收金额,分子按方式取渠道成功金额或审批退款金额;每条向下取整,自稳定顺序 (`original_commission_id` 升序) 末条起向前补差,累计不超过各原佣金剩余可回溯余额;乘法使用大整数中间量,禁止溢出与浮点。 +- 新增佣金状态 `5 = 回溯` 与名称映射,新增回溯审计动作码并在 `registry.go` 注册;旧 `refund.invalidate_commission` 仅保留常量与注册,使历史审计仍可读,代码中不再有新调用点。 +- 在退款佣金回扣用例中落地佣金明细导出场景,粒度为佣金记录:原佣金与回溯记录各一行;扩展佣金明细列表 `status` 筛选与详情接口。 +- 生成回溯前,在同一事务内先拒绝并释放待审核提现的冻结余额,再扣减佣金钱包;佣金钱包允许余额为负。 ## Capabilities @@ -18,8 +22,16 @@ - 无。 ### Modified Capabilities -- `agent-funds-commission`: 退款佣金回溯。 +- `agent-funds-commission`: 退款佣金回溯明细、原佣金保持已发放、佣金钱包负余额、回溯读侧与导出。 +- `order-refund-exchange`: 回溯准入改为按退款申请状态与方式判定,取消「明确失败才可回溯」语义与独立完成事件键要求。 ## Impact -影响退款事件、佣金明细、钱包、提现和 Schema。 \ No newline at end of file +影响退款终态判定与回溯准入、退款后处理补偿任务、佣金记录状态常量、佣金明细读侧与导出场景注册、佣金钱包与提现冻结释放、统一审计动作注册、OpenAPI 与数据库 Schema。 + +## Non-Goals + +- 换货回溯不在本期范围(PRD §3 列为后续独立需求)。 +- 统一 `start_time`/`end_time` 解析、区间校验与筛选、权限快照冻结由 `add-export-time-filter-standards` 负责;本 Change 只负责佣金导出的粒度与列定义,两者边界不得互相改变。 +- 本期不修改 `GetStats` / `GetDailyStats` 与首页统计口径,回溯不计入净佣金;如需净佣金口径另立需求。 +- 提现资格与审批语义、员工代收款账单、代理自充与商户池不在本期范围。 diff --git a/openspec/changes/add-commission-clawback-records/specs/agent-funds-commission/spec.md b/openspec/changes/add-commission-clawback-records/specs/agent-funds-commission/spec.md index de9bc04..2dcff53 100644 --- a/openspec/changes/add-commission-clawback-records/specs/agent-funds-commission/spec.md +++ b/openspec/changes/add-commission-clawback-records/specs/agent-funds-commission/spec.md @@ -1,23 +1,130 @@ +## MODIFIED Requirements + +### Requirement: 佣金异常状态可见 + +系统 SHALL 将佣金记录保持为已冻结、解冻中、已发放、已失效、回溯或待人工修正;链路断裂的记录进入待人工修正而不是静默计入可提现余额。回溯记录 MUST 为负数且不可提现,MUST NOT 计入可提现余额或提高可提现额度。 + +#### Scenario: 佣金链路断裂 + +- **GIVEN** 佣金记录无法关联完成后续发放所需事实 +- **WHEN** 系统处理该记录 +- **THEN** 记录保持待人工修正状态且不增加可提现余额 + +#### Scenario: 回溯记录不计入可提现余额 + +- **GIVEN** 代理店铺存在已发放佣金及其回溯记录 +- **WHEN** 查询佣金明细并按可提现余额判定提现资格 +- **THEN** 回溯记录以「回溯」状态与负数金额可见,且不增加该店铺的可提现余额 + +### Requirement: 退款佣金回扣可靠完成 + +系统 SHALL 在退款申请已通过时持久化佣金回溯请求;回溯请求的投递或处理异常不得静默遗留,且退款单在全部应有回溯明细生成并完成对应钱包流水前不得标记为已回溯。原佣金记录 MUST NOT 因回溯改变状态、金额、佣金来源或发放时间。 + +#### Scenario: 已退款订单佣金回扣失败后恢复 + +- **WHEN** 已退款订单的佣金回溯首次处理失败或进程中断 +- **THEN** 退款单保持回溯未完成状态并保留可重试事实,后续成功处理后原佣金仍为已发放、回溯明细与佣金钱包负数流水均已生成且退款单标记为已完成 + +#### Scenario: 订单佣金未终态 + +- **WHEN** 退款申请已通过但原订单佣金仍未进入终态 +- **THEN** 系统不写回溯明细、不标记回溯完成,并保留可重试事实等待终态 + +#### Scenario: 终态确无佣金 + +- **WHEN** 原订单佣金已进入终态且确认无佣金 +- **THEN** 系统标记该退款单无需回溯并写入「无需回溯」审计,且不产生任何钱包变动 + +#### Scenario: 审批异常转人工不回溯 + +- **WHEN** 退款申请存在审批异常标记(企业微信通过后撤销) +- **THEN** 系统不生成回溯明细与钱包变动,标记该退款单的回溯后处理已闭合并写入转人工审计 + +### Requirement: 退款后处理可补偿 + +系统 SHALL 对已通过但回溯未完成或资产未完成后处理的退款单提供幂等补偿;重复补偿不得重复生成回溯明细、重复扣减佣金钱包、重复写负数流水或重复处理资产。 + +#### Scenario: 遗留退款单补偿 + +- **WHEN** 补偿流程发现已通过且回溯完成事实缺失的退款单 +- **THEN** 系统恢复该退款单的唯一后处理请求,并在全部应有回溯明细生成后更新其完成事实 + +#### Scenario: 周期性补偿 + +- **WHEN** 补偿扫描按既有周期任务形态执行且存在回溯后处理未闭合的退款单 +- **THEN** 系统按固定周期重复补偿直至完成事实落库或该退款单转人工,且不重复产生任何资金事实 + ## ADDED Requirements ### Requirement: 套餐退款佣金回溯 -系统 SHALL 在套餐退款后保留原佣金不变,并创建关联原佣金记录和退款单的负数、不可提现回溯明细,冻结原订单号及原佣金关键字段。同一退款业务必须幂等;若佣金计算未终态则等待终态后生成,确认无佣金才标记无需回溯。换货不在本期范围。 -部分退款按本次退款金额与订单冻结实收金额比例,对每条原佣金按分向下取整;最后一条补足舍入差,累计回溯不得超过原佣金。生成前系统 MUST 拒绝并释放待审核提现,再生成回溯明细和钱包扣款流水;佣金钱包允许负余额。 +系统 SHALL 在退款申请已通过后生成关联原佣金记录与退款单的负数、不可提现回溯明细,并保留原佣金记录不变。回溯明细 MUST 冻结原订单号、原佣金标识、负数金额、不可提现标识、回溯后佣金钱包实际余额与生成时间。换货不在本期范围。 + +回溯准入 MUST 由退款申请状态与方式得出:仅退款申请已通过时可生成,原路退款还须渠道明确成功。待审批、原路处理中、渠道明确失败与企业微信通过后撤销 MUST NOT 生成回溯明细;渠道明确失败可修改材料后重提,重提后按最终成功金额生成一次。同一退款业务 MUST 幂等,幂等依据为「一次退款对应一次原佣金」的唯一事实,MUST NOT 依赖退款单上的佣金回扣标记。 + +原订单佣金未进入终态时系统 MUST 等待终态后再生成,MUST NOT 提前判定为无需回溯;确认无佣金时才标记无需回溯。 + +部分退款按本次成功退款金额与本次退款冻结实收金额的比例对每条原佣金等比例回溯。金额 MUST 以分整数精确计算,MUST NOT 溢出或引入浮点误差;每条按分向下取整,舍入差自稳定顺序(原佣金标识升序)末条起向前补足,每条不超过该条剩余可回溯余额;累计回溯 MUST NOT 超过各原佣金的可回溯余额。冻结实收金额缺失或非正时系统 MUST 记录可恢复失败且不落库、不改变余额,MUST NOT 以订单标价或申请金额替代。 + +生成回溯前系统 MUST 先拒绝并释放待审核提现的冻结余额,再生成回溯明细与钱包扣款流水;佣金钱包余额允许为负,MUST NOT 因余额不足而跳过或拒绝回溯。 #### Scenario: 部分退款舍入 + - **WHEN** 一笔部分退款关联多条原佣金且比例计算产生分级舍入差 -- **THEN** 系统按各条向下取整并仅在最后一条补差,回溯总额等于应回溯额且不超过各原佣金可回溯余额 +- **THEN** 系统按各条向下取整并自末条起向前补差,回溯总额等于应回溯额且不超过各原佣金可回溯余额 + +#### Scenario: 全额回溯 + +- **WHEN** 退款金额与冻结实收金额相等 +- **THEN** 系统按各原佣金的剩余可回溯金额回溯,回溯总额等于各原佣金金额之和 + +#### Scenario: 冻结实收金额非正 + +- **WHEN** 本次退款冻结实收金额缺失或非正 +- **THEN** 系统记录可恢复失败,不写回溯明细、不改变佣金钱包余额,且不以订单标价替代计算 + +#### Scenario: 原路渠道失败重提后按最终金额回溯 + +- **GIVEN** 一笔原路退款曾在渠道明确失败并可重提 +- **WHEN** 该退款重提后最终渠道明确成功 +- **THEN** 系统仅在该退款申请已通过时按其最终成功金额生成一次回溯明细,失败阶段不产生任何回溯事实 + +#### Scenario: 重复退款消费 + +- **WHEN** 同一退款完成事件被重复消费 +- **THEN** 系统不重复生成回溯明细、钱包扣款或提现释放事实 + +#### Scenario: 回溯后佣金钱包负余额 + +- **GIVEN** 店铺佣金钱包余额不足以覆盖本次回溯金额 +- **WHEN** 系统生成回溯明细 +- **THEN** 钱包余额允许为负并记录回溯后实际余额,且不因余额不足而跳过或拒绝回溯 + +#### Scenario: 原佣金保持不变 + +- **WHEN** 一笔已发放佣金被回溯 +- **THEN** 该原佣金记录的状态、金额、佣金来源与发放时间均不变,可提现余额按回溯金额减少 ### Requirement: 回溯明细关联查询与导出 -系统 SHALL 在佣金明细中分别展示原发放佣金和回溯扣款记录,并允许从任一记录查询其关联的退款单、原佣金或全部回溯明细。回溯记录必须显示负数金额、不可提现标识、来源退款单号、原佣金记录号、生成时间和回溯后佣金钱包实际余额。佣金明细及导出 MUST 使用既有佣金数据范围:代理仅可读取自身及其既有可见范围内的事实,平台/超级管理员遵循既有范围;无权记录不得通过关联 ID、汇总或导出泄露。 -导出应冻结筛选条件、操作者和可见范围;原佣金与回溯记录均作为独立行导出,回溯后余额为对应钱包变动提交后的实际余额,可为负数。 +系统 SHALL 在佣金明细中分别展示原发放佣金和回溯扣款记录,并允许从任一记录查询其关联的退款单、原佣金或全部回溯明细。回溯记录必须显示负数金额、不可提现标识、来源退款单号、原佣金记录号、生成时间和回溯后佣金钱包实际余额。原佣金与回溯记录 MUST 合并为同一列表的同一分页与同一排序口径,MUST NOT 因来源表不同而丢失或重复任一条事实。 + +佣金明细及导出 MUST 使用既有佣金数据范围:代理仅可读取自身及其既有可见范围内的事实,平台与超级管理员遵循既有范围;无权记录不得通过关联 ID、汇总或导出泄露存在性。 + +佣金明细导出 MUST 使用佣金记录粒度,原佣金与回溯记录各占一行,并冻结创建时筛选条件、操作者与可见范围。回溯后余额为对应钱包变动提交后的实际余额,可为负数;金额保持分,展示层转元 MUST NOT 改变负数或余额事实。 #### Scenario: 代理查询越权回溯记录 + - **WHEN** 代理使用回溯记录 ID、原佣金 ID 或退款单号查询其数据范围外的回溯关系 - **THEN** 系统按既有数据范围返回不存在或空结果,不泄露关联事实 -#### Scenario: 重复退款消费 -- **WHEN** 同一退款完成事件被重复消费 -- **THEN** 系统不重复生成回溯明细、钱包扣款或提现释放事实 +#### Scenario: 原佣金与回溯合并分页 + +- **GIVEN** 同一店铺同时存在原佣金记录与回溯记录 +- **WHEN** 查询佣金明细列表并翻页 +- **THEN** 两类记录按同一排序口径出现在同一结果集内,任一条不缺失也不重复 + +#### Scenario: 回溯记录导出 + +- **WHEN** 导出含回溯记录的佣金明细 +- **THEN** 原佣金与回溯记录各占一行,回溯行金额为负数、可提现标识为不可提现、含关联佣金明细与退款单标识,且回溯后余额为负数时原样导出 diff --git a/openspec/changes/add-commission-clawback-records/specs/order-refund-exchange/spec.md b/openspec/changes/add-commission-clawback-records/specs/order-refund-exchange/spec.md new file mode 100644 index 0000000..782c951 --- /dev/null +++ b/openspec/changes/add-commission-clawback-records/specs/order-refund-exchange/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: 退款终态事实与失败分类 + +退款申请 SHALL 保存结构化失败分类、渠道退款状态、渠道退款流水与渠道退款请求号,并在列表、详情和导出中返回冻结实收金额、方式、申请状态、渠道退款状态、失败安全摘要、审批尝试历史和渠道流水,按既有订单数据范围过滤。失败分类 MUST 为稳定枚举,至少覆盖:渠道明确拒绝、渠道凭证失效、渠道余额不足、超时或结果未知、企业微信驳回或关闭、企业微信通过后撤销,以及本地原支付事实不可用。渠道凭证失效与本地原支付事实不可用 MUST 为两个并列分类、语义不得合并:前者指该商户退款必需凭证缺失或失效,后者指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。每个分类 MUST 显式标记其是否属于「明确失败」:明确失败表示该次退款尝试已终结且不可自动恢复,非明确失败表示仍在途、可自动恢复或需人工处理。该标记 MUST 仅用于判定尝试终结性与人工处置,MUST NOT 作为佣金回溯的准入条件。审计 SHALL 记录申请、重提、审批终态、权益处理、渠道调用与恢复,且不得记录凭证内容、完整收款文本或商户密钥。 + +为后续佣金回溯能力提供稳定事实,退款终态 SHALL 可按退款单与订单定位,并提供:成功退款金额、冻结实收金额与终态时点。佣金回溯准入 MUST 由退款申请状态、审批异常标记与退款方式得出,MUST NOT 依据失败分类标记、退款原因文本或新增的独立完成事件键:仅退款申请已通过且不存在审批异常标记时可进入回溯判定,原路退款还须渠道明确成功;待审批、原路处理中、渠道明确失败(可修改材料后重提)与企业微信通过后撤销均不得回溯。系统 MUST 保留既有退款佣金回扣事件键的兼容语义;佣金回溯的幂等键为一次退款一次回溯,不依赖退款单上的佣金回扣标记。 + +#### Scenario: 渠道失败分类可查询 + +- **WHEN** 原路退款因渠道余额不足失败 +- **THEN** 退款详情返回该失败分类与安全摘要,且不返回任何凭证内容或完整收款文本 + +#### Scenario: 审计不含敏感内容 + +- **WHEN** 渠道退款调用或恢复完成后写入审计 +- **THEN** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文 + +#### Scenario: 回溯准入仅取决于退款申请状态 + +- **WHEN** 退款申请处于待审批、原路处理中、渠道明确失败或企业微信通过后撤销 +- **THEN** 系统不生成任何佣金回溯事实;仅当退款申请已通过(原路退款还须渠道明确成功)时才生成 + +#### Scenario: 失败分类不决定回溯准入 + +- **WHEN** 一次退款尝试带有明确失败分类,但该退款申请尚未处于已通过状态 +- **THEN** 系统不生成佣金回溯事实,该分类只用于判定尝试终结性与人工处置 diff --git a/openspec/changes/add-commission-clawback-records/tasks.md b/openspec/changes/add-commission-clawback-records/tasks.md index 4b615ce..e5abb10 100644 --- a/openspec/changes/add-commission-clawback-records/tasks.md +++ b/openspec/changes/add-commission-clawback-records/tasks.md @@ -1,8 +1,35 @@ -## 1. 回溯实现 -- [ ] 1.1 追踪退款完成、佣金计算终态、提现冻结和佣金钱包链路。 -- [ ] 1.2 新增回溯明细、退款/原佣金唯一约束、状态/索引的成对迁移和 DTO。 -- [ ] 1.3 实现比例分摊、最后一条舍入补差、终态等待、待审提现释放、负余额扣款及幂等消费者。 +## 1. 事实与常量 -## 2. 验证 -- [ ] 2.1 隔离库验证全额/部分、重复消费、佣金延迟、负余额和 up/down/up。 -- [ ] 2.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-commission-clawback-records --strict` 与 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file +- [x] 1.1 新增 `CommissionStatusClawback = 5` 与 `GetCommissionRecordStatusName` 的「回溯」映射,并同步佣金记录状态常量注释与 `internal/model/commission.go` 的说明。 证据:`pkg/constants/iot.go:214` 新增 `CommissionStatusClawback = 5`,`:345` 注释同步为 `…4=已失效, 5=回溯, 99=待人工修正`,`:356-357` 新增 `case CommissionStatusClawback: return "回溯"`;`internal/model/commission.go:23` 列注释同步含 `5-回溯`,`:12` 补说明「退款佣金回溯不改动本表」;迁移 000220 同步 `COMMENT ON COLUMN tb_commission_record.status`。 +- [x] 1.2 新增回溯审计动作码 `refund.clawback_commission`(生成回溯明细)与 `refund.clawback_not_required`(终态确无佣金),在 `internal/infrastructure/audit/registry.go` 注册;保留 `refund.invalidate_commission` 常量与注册以维持历史审计可读,并删除其全部业务调用点。 证据:`pkg/constants/audit.go:363-366` 新增 `AuditActionRefundClawbackCommission = "refund.clawback_commission"` 与 `AuditActionRefundClawbackNotRequired = "refund.clawback_not_required"`;`:360-362` 旧常量补注释「保留常量与注册仅供历史审计读取,代码中已无调用点」;`internal/infrastructure/audit/registry.go:313-314` 用 `refundSystemAction` 定义、`:644-645` 登记、`:311`/`:643` 保留旧动作注册。旧调用点 `appendCommissionAudit`、`recordCommissionFailure` 已整体删除(`internal/service/refund/audit.go` 现无该常量引用),全仓 grep 仅剩常量定义与注册两处。 +- [x] 1.3 核对回溯用例 Audit Event、Domain Ledger、Integration Log 与 Outbox 四类事实的使用决定或 N/A 理由,并在仓库外记录证据(ENG-AUDIT-001)。 证据:四类事实决定记录于仓库外 `/tmp/aug26012-evidence/07-audit-facts.md`:Audit Event 使用、Domain Ledger 使用、Integration Log N/A(本用例不调用外部系统,理由已写明)、Outbox 复用既有事件。落地实现见 `internal/service/refund/clawback.go` 的 `applyClawback`/`settleClawback`/`appendClawbackAudit`(审计与资金同事务、`AppendAndGet` 要求成功必达)。 + +## 2. Schema 与模型 + +- [x] 2.1 新增成对迁移建 `tb_commission_clawback_record`:`refund_id`、`original_commission_id`、`order_id`、`shop_id`、负数 `amount`、不可提现标识、`status`、`balance_after`、原佣金与退款关键快照、`created_at`;迁移编号按 `migrations/` 目录当前最大编号顺延,不预占编号,不修改任何既有迁移。 证据:新增成对迁移 `migrations/000220_add_commission_clawback_record.up.sql`/`.down.sql`;编号按 `migrations/` 当前最大 000219 顺延,未修改任何既有迁移(`git status` 中 migrations 仅新增这两个文件)。建表含 `refund_id`、`original_commission_id`、`order_id`、`shop_id`、`order_no`、`refund_no`、`commission_source`、负数 `amount`、`balance_after`、`withdrawable`、`status`、`created_at`,及 `chk_commission_clawback_amount CHECK (amount < 0)`、`chk_commission_clawback_withdrawable CHECK (withdrawable = FALSE)`、`chk_commission_clawback_status CHECK (status = 5)`。 `created_at` 采用 `TIMESTAMP`(不带时区),与另一支 `tb_commission_record.created_at`(实测 `timestamp without time zone`)保持一致:若用 `timestamptz`,`UNION ALL` 会按会话时区改写其中一支,破坏统一排序键与时间筛选口径;S-4 同时把两个回溯分支的占位列由 `NULL::timestamptz` 改为 `NULL::timestamp`,消除该耦合(实测 `【S-4】released_at 跨会话时区稳定:Shanghai/NewYork/UTC 三会话同值`)。 +- [x] 2.2 迁移内建唯一约束 `(refund_id, original_commission_id)`,以及店铺 + 时间、`original_commission_id`、`order_id` 索引;`down` 仅删除本 Change 新增对象。 证据:迁移内建 `CONSTRAINT uk_commission_clawback_refund_original UNIQUE (refund_id, original_commission_id)` 与三个索引 `idx_commission_clawback_shop_created (shop_id, created_at DESC, id DESC)`、`idx_commission_clawback_original (original_commission_id)`、`idx_commission_clawback_order (order_id)`;`down` 在存在回溯明细时 `RAISE EXCEPTION` 阻断后 `DROP TABLE`,并还原 `tb_commission_record.status` 列注释,不触碰既有表与数据。 D-2 修复:`down` 还原的 `tb_commission_record.status` 列注释改为 000220 up 之前的**真实值** `状态 1-已入账 2-已失效`(最后一次写该注释的是 `migrations/archive/000029_add_one_time_commission.up.sql:94`;000113 只迁移状态值未改注释),使 down 成为 up 的严格逆操作(ENG-MIG-001)。实测:down 后 dbhub 只读核对该注释为 `状态 1-已入账 2-已失效`,up 后为 `状态 1-已冻结 2-解冻中 3-已发放 4-已失效 5-回溯 99-待人工修正`(见 `01-migration.txt`)。 +- [x] 2.3 新增回溯明细模型与 DTO(含中文 description 与状态枚举名,ENG-DTO-001)。 证据:新增 `internal/model/commission_clawback.go` 的 `CommissionClawbackRecord`(表名 `tb_commission_clawback_record`);DTO 见 `internal/model/dto/shop_commission_dto.go` 的 `ShopCommissionClawbackItem`、`ShopCommissionRecordItem`(status 描述为 `1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正`,与 `pkg/constants` 一致)、`ShopCommissionRecordDetailReq/Resp`,均为中文 description。 + +## 3. 写路径替换 + +- [x] 3.1 实现回溯准入:仅 `refund.status = 2` 且 `anomaly_flag = 0` 且(非原路方式或 `channel_refund_status = 2`)时生成;其余状态返回可重试错误,不写明细、不置完成事实;不使用失败分类标记判定准入。 证据:`internal/service/refund/clawback.go:clawbackCommission` —— `refund.AnomalyFlag != 0` 先走转人工闭合;`refund.Status != model.RefundStatusApproved` 返回 `CodeServiceUnavailable`;`refund.Method == original_route && ChannelRefundStatus != 2` 返回可重试错误。未引用 `RefundFailureReasonIsDefinitive`(`grep -rn "RefundFailureReasonIsDefinitive" internal/service/refund/` 为空)。实测见 `04-write-path.txt`:「原路在途不回溯」「渠道失败不回溯」「待审批等待」通过。 准入与终态判据见 `internal/service/refund/clawback.go:67-97`;终态判据除「订单佣金非待计算」外,还要求该订单不存在 `status IN (1,2,99)` 的未终态佣金记录(D-1 修复,理由与收敛条件见 design 第 3 条)。收敛性依据实测的全仓状态写点:`tb_commission_record.status` 仅被 `commission_calculation/service.go:181/:241/:554`(置 3)、`:220/:581`(置 99)、`:689-693`(入账置 3)、`recharge_order/service.go:418`(置 3)与 `shop_commission/service.go:751/:781`(人工处置置 4 或 3)写入,**从不写入 1/2**(遗留枚举,仅历史读取与名称映射用),故现实等待只可能由 99 引起且处置后必然收敛。。 +- [x] 3.2 实现金额计算:分母取本次退款冻结实收金额(缺失时回落审批尝试记录),`<= 0` 时记录可恢复失败且不落库、不改余额;分子原路取渠道明确成功金额、其余取审批退款金额;乘法使用 `math/big` 或等价大整数中间量,全程 `int64` 分,禁止浮点与溢出。 证据:`resolveClawbackDenominator`(退款行缺失时回落 `LatestAttemptID` 对应尝试的 `frozen_actual_received_amount`)、`resolveClawbackNumerator`(原路取 `ChannelRefundAmount`,其余取 `ApprovedRefundAmount`);`D <= 0` 记录可恢复失败并返回错误,不落库、不改余额、不置位;乘法用 `math/big` 中间量(`mulDivFloor`/`mulDivFloorInt64`),全程 int64 分。实测见 `04-write-path.txt`:「冻结实收非正 D=0」「分母回落+原路分子」「乘积极端值 6148914691236517204 / [6148914691236517204 6148914691236517205]」通过。 +- [x] 3.3 实现分摊:稳定顺序 `original_commission_id ASC`;`剩余_i`、逐条 `base_i`、应回溯总额上限,逐条向下取整,舍入差自末条起向前分摊且每条不超过该条剩余。 证据:`allocateClawbackAmounts` —— 输入按 `original_commission_id ASC` 固定;`剩余_i = amount_i − 已生成回溯累计`(`loadClawedAmounts` 从明细聚合);`base_i = min(floor(amount_i×N/D), 剩余_i)`;`T = min(floor(Σamount_i×N/D), Σ剩余_i)`;补差自末条向前逐条受 `剩余_i − base_i` 限制。实测见 `04-write-path.txt`:「比例与补差 -66/-66/-68 总额-200」「补差跨条向前,末两条剩余=0 被跳过 → 各 -1000 总额-2000」「Σ剩余封顶 总额-2100」通过。 +- [x] 3.4 实现后处理闭合的三种结果与置位保护:已生成回溯、终态无需回溯、审批异常转人工;置位 `commission_deducted` 加 `WHERE commission_deducted = false` 保护;订单佣金未终态时不写明细、不置位并返回可重试错误。 证据:`clawbackCommission` 三种闭合——已生成回溯(`applyClawback`,置位 `WHERE id = ? AND commission_deducted = false` 并判 `RowsAffected != 1` → `CodeConflict`)、终态无需回溯(`settleClawback` + `refund.clawback_not_required`)、审批异常转人工(`settleClawback` 复用 `refund.anomaly_flag`);订单 `CommissionStatusPending` 时返回可重试错误、不写明细、不置位。旧实现两处无保护的 `Update("commission_deducted", true)` 已随 `deductAllCommission` 整体删除。实测见 `04-write-path.txt`:「未终态等待」「终态确无佣金」「撤销不回溯」「置位保护」通过。 另实测 D-1 修复后的闭合边界(`04-write-path.txt`):「order=3 + 仅 99 记录」返回可重试错误(err=订单存在未终态佣金记录,等待终态后再回溯)且无明细/未置位/无审计;99 处置为已发放 5000 后生成 -5000 明细并闭合;99 处置为已失效后写「无需回溯」审计并闭合(不永久等待);order=2 确无佣金保持原行为。 +- [x] 3.5 删除旧全额失效写入(原佣金置已失效、旧审计前后态与调用点),替换为插入回溯明细;保留 `collectPendingWithdrawalRejects`、`applyPendingWithdrawalRejects`、`appendWithdrawalRejectAudit`,事务内顺序固定为:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 → 扣 `balance`(允许为负)→ 写负数流水 → 审计。 证据:`deductAllCommission`/`deductSingleCommission`/`appendCommissionAudit`/`recordCommissionFailure` 已整体删除(原佣金置已失效写入、旧审计前后态与全部调用点消失)。`applyClawback` 事务内顺序为:锁原佣金行 → 按店铺升序 `collectPendingWithdrawalRejects`(申请行 → 尝试行)→ 锁佣金钱包行 → `applyPendingWithdrawalRejects`(解冻 + 置驳回 + 正向流水 + 审计)→ 插回溯明细 → 扣 `balance`(允许为负)→ 写 `commission_deduct` 负数流水 → 审计 → 条件置位。`collectPendingWithdrawalRejects`/`applyPendingWithdrawalRejects`/`appendWithdrawalRejectAudit` 未重写。实测见 `04-write-path.txt`:「释放先于扣款 frozen 800→0、balance 1000→-4000、提现已拒绝、尝试已释放、原佣金不变」通过。 S-1 修复:本次未生成任何明细时(仅投影陈旧可达成)不写 `refund.clawback_commission` 审计,避免 `clawback_record_count=0 且 clawback_required=true` 的误导性事实;置位逻辑不变,正常路径(确有明细)审计与 metadata 计数不变(实测 `【S-1】` 与 `【S-1 对照】`:正常路径 metadata={"order_id": 540, "clawback_required": true, "clawback_record_count": 2, "clawback_total_amount": -5000})。 +- [x] 3.6 新增周期性补偿任务:`@every 1m`、`MaxRetry(3)`、`Timeout(10m)`、`Unique(10m)`、独立队列,配套任务类型常量、`QueueForTaskType` 映射与 Handler;保留既有启动时补偿扫描,判据保持 `status = 2 且 commission_deducted = false`。 证据:`pkg/constants/constants.go:95` 新增 `TaskTypeRefundCommissionRecovery = "refund:commission:recovery"`,`:311` 纳入 `QueueForTaskType`;`internal/infrastructure/commissiondelivery/recovery_task.go` 新增 `RefundRecoveryTaskHandler`(只重投稳定事件,不直接改资金);`cmd/worker/main.go:502-511` 注册 Handler、`:164` 调用,Scheduler 以 `@every 1m` + `MaxRetry(3)` + `Timeout(10m)` + `Unique(10m)` + `Queue(QueueForTaskType(...))` 注册(与 `TaskTypeRefundChannelRecovery` 同形态)。补偿扫描判据**与 HEAD 一致、未新增任何列条件**:仍是 `status = 2 AND (commission_deducted = false OR asset_reset = false)`(`internal/infrastructure/commissiondelivery/event.go` 的 `RecoverRefundPostProcessing`,同时覆盖资产后处理),仅将退款部分抽出为独立函数供启动扫描(`cmd/worker/main.go:413` 的 `commissionDelivery.Recover` 保留不变)与周期任务共用。 + +## 4. 读侧与导出 + +- [x] 4.1 列表支持 `status` 筛选透传(DTO 与 service);读侧两表 `UNION ALL` 合并分页,统一排序键 `created_at DESC, id DESC`,筛选与数据范围在合并前各自应用。 证据:DTO `internal/model/dto/shop_commission_dto.go:151` 新增 `Status *int`;Service `internal/service/shop_commission/service.go:283` 赋值 `Status: req.Status` 并补 `PageSize > MaxPageSize` 归一化;Store 新增 `ListLedgerByShopID`,两支先各自 `Count` 与各自应用数据范围/筛选,再 `UNION ALL` 合并后分页。**排序与次序键为 `created_at DESC` → `id DESC` → `source ASC`**:两表自增 ID 空间独立,同一 `created_at` 下 `(created_at DESC, id DESC)` 不是全序,必须再以 `source ASC` 作末位次序键才能在 `created_at` 与 `id` 完全相同的跨表记录上保证翻页不重不漏(design 第 8 条已同步该理由)。实测见 `05-read-path.txt`:「合并分页 total=3 逐页 size=1 无重复无缺失」「status=5→1 条回溯 / status=3→1 条原佣金」「同秒排序稳定」通过;**次序键反例已实测**:强制两表插入 `created_at` 与 `id` 完全相同的记录(id=987654321)后按 size=1 翻页,两行仍不重不漏且次序稳定为 `[clawback original]`(`【次序键复验】`)。 +- [x] 4.2 原佣金返回 `clawback_records` 摘要;回溯明细返回原佣金标识、退款单号、负数金额、不可提现标识、回溯后余额与生成时间;越权按既有数据范围返回不存在或空结果。 证据:原佣金行返回 `clawback_records` 摘要(`ListClawbackSummaries`)与 `clawback_total_amount`;回溯行返回 `original_commission_id`、`refund_id`、`refund_no`、负数 `amount`、`withdrawable`、`balance_after`、`status_name=回溯`。越权在 Service 用 `middleware.CanManageShop` + 详情内 `row.ShopID != req.ShopID` 双重拦截,错误文本与「不存在」一致。实测见 `05-read-path.txt`:「越权不可枚举」(越权列表/详情失败、不存在返回「佣金明细不存在」、范围内可见 2 条、详情双向定位)通过。 +- [x] 4.3 新增佣金明细详情接口,并同步可执行路由、`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 装配。 证据:`internal/handler/admin/shop_commission.go:108-134` 新增 `GetCommissionRecord`;`internal/routes/shop.go:162-169` 新增 `GET /:shop_id/commission-records/:id` RouteSpec(含中文 Description);`pkg/openapi/handlers.go:32` 的 `BuildDocHandlers` 已含 `ShopCommission`,`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 同构调用该入口(未新增 Handler 类型,故无需改这两处的显式赋值)。实测:`go run cmd/gendocs/main.go` 退出码 0(`08-gendocs.txt`),`docs/admin-openapi.yaml` 出现 `/api/admin/shops/{shop_id}/commission-records/{id}`。 +- [x] 4.4 落地佣金明细导出场景:场景白名单、`internal/exporter` 注册、DTO `oneof`、DataSource 与列定义;粒度为佣金记录,原佣金与回溯记录各一行,金额保持分且可为负,冻结创建时筛选条件、操作者与可见范围。 证据:`pkg/constants/constants.go:370` 新增 `ExportTaskSceneCommissionRecord = "commission_record"`;`internal/exporter/registry.go:39` 注册 `NewCommissionRecordDataSource`、`:76` 纳入 `IsSupportedScene` 白名单;`internal/model/dto/export_task_dto.go` 三处 `oneof` 与描述补齐;新增 `internal/exporter/commission_record_scene.go`(列:记录来源/记录ID/代理店铺名称/关联订单号/资产标识/佣金来源/金额(元)/是否可提现/状态/回溯后佣金余额(元)/原佣金记录ID/来源退款单号/佣金入账时间/生成时间)。实测见 `06-export.txt`:「原佣金与回溯各一行,回溯金额 -90.00、余额 -40.00 原样导出,数据范围生效」通过。 + +## 5. 验证 + +- [x] 5.1 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 上,以本地显式 `DB_*` 参数执行 `./scripts/migrate.sh` 完成 up/down/up;仅创建与删除本 Change 自己的 fixture,禁止重置整个测试库。 证据:见 `/tmp/aug26012-evidence/01-migration.txt`(含 D-2 修正后的 down 注释还原核对):显式 `DB_*`(`junhong_cmp_test`)执行 `./scripts/migrate.sh up`(220 应用成功)→ `version` 返回 220 → `down 1` 回滚 220 → `version` 返回 219 → `up` 重新应用到 220 → `version` 返回 220。全部 fixture 建在回滚事务内,收尾核查 `tb_commission_clawback_record` 行数为 0、验证用店铺数为 0、验证用退款单数为 0(dbhub 只读查询,见 `10-fixture-cleanup.txt`),未重置整库。 +- [x] 5.2 验证金额:全额与部分回溯、比例与补差、乘法溢出行边界、累计不超过各原佣金剩余、冻结实收非正可恢复失败。 证据:见 `04-write-path.txt`(17 项全部 PASS):全额回溯(-10000/-25000/-555,余额 64445)、比例与补差(-66/-66/-68 总额-200)、补差跨条向前(末两条剩余=0 被跳过 → 各 -1000 总额-2000)、Σ剩余封顶(-2100)、乘积极端值(单条/多条 int64 max 无溢出)、冻结实收非正(D=0 与 D<0 均不落库不改余额不置位)、分母回落 + 原路分子(-5000)。 +- [x] 5.3 验证时序与幂等:佣金未终态等待、终态确无佣金、原路在途不回溯、企业微信通过后撤销不回溯、渠道失败重提后按最终金额回溯一次、重复消费不重复。 证据:同上 `04-write-path.txt`:佣金未终态等待、终态确无佣金(置位 + `refund.clawback_not_required`)、原路在途不回溯、撤销不回溯(`anomaly_flag=1` 转人工且闭合)、渠道失败重提后按最终金额回溯一次(-6000)、重复消费不重复(3 次 → 1 明细 1 流水)、唯一约束权威(重复插入被数据库拒绝)、并发消费(已有明细时跳过重复扣款并闭合)。 +- [x] 5.4 验证资金与读侧:释放先于扣款且负余额不违约、原佣金状态与金额不变、读侧合并分页与越权、导出负数余额与原样导出。 证据:见 `04-write-path.txt` 的「释放先于扣款且负余额不违约」(frozen 800→0、balance 1000→-4000、提现已拒绝、尝试已释放、原佣金 status/amount/source/released_at 全部不变);`05-read-path.txt`(4 项 PASS)的合并分页、status 筛选、越权不可枚举、同秒稳定排序;`06-export.txt` 的回溯行金额 `-90.00` 与回溯后余额 `-40.00` 原样导出、粒度两行、数据范围生效。 +- [x] 5.5 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-commission-clawback-records --strict`、`openspec doctor --json` 与 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 证据:见 `02-build.txt`(退出码逐一显式记录,不经管道过滤):`gofmt -l` 对全部 22 个变更/新增 Go 文件**无输出**;`go build ./cmd/api ./cmd/worker` **BUILD_EXIT=0**(构建输出仅含 go 模块缓存写权限噪音,无编译错误);`go vet ./internal/... ./pkg/...` **VET_EXIT=0** 且无输出;`go run cmd/gendocs/main.go` **GENDOCS_EXIT=0**。`openspec validate add-commission-clawback-records --strict` → `Change is valid`(VALIDATE_EXIT=0);`openspec doctor --json` → `"healthy": true`;`./scripts/context-health.sh` → `Context 健康检查通过`(CTX_EXIT=0,见 `03-context-health.txt`)。自动化测试按项目决策为 N/A:临时验证脚手架已全部删除,收尾后 `find . -name '*_test.go' | wc -l` 为 0、`find . -name 'zz_*' | wc -l` 为 0,仓库未新增测试入口。 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index e8f68aa..d4ad33f 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -358,7 +358,12 @@ const ( // AuditActionRefundResubmitted 表示重新提交已退回的退款申请。 AuditActionRefundResubmitted = "refund.resubmit" // AuditActionRefundCommissionInvalidated 表示退款导致单条已入账佣金失效并回扣。 + // 保留常量与注册仅供历史审计读取,代码中已无调用点;现行语义见 AuditActionRefundClawbackCommission。 AuditActionRefundCommissionInvalidated = "refund.invalidate_commission" + // AuditActionRefundClawbackCommission 表示退款完成后生成负数、不可提现的佣金回溯明细并扣减佣金钱包。 + AuditActionRefundClawbackCommission = "refund.clawback_commission" + // AuditActionRefundClawbackNotRequired 表示退款后处理确认原订单佣金已终态且无佣金可回溯。 + AuditActionRefundClawbackNotRequired = "refund.clawback_not_required" // AuditActionRefundAssetProcessed 表示退款后的套餐与资产处理已完成。 AuditActionRefundAssetProcessed = "refund.process_asset" // AuditActionRefundAttemptSubmitted 表示提交或重提退款审批尝试并创建独立审批实例。 @@ -712,6 +717,8 @@ const ( AuditResourceApprovalInstance = "approval_instance" // AuditResourceCommissionRecord 表示佣金记录资源。 AuditResourceCommissionRecord = "commission_record" + // AuditResourceCommissionClawback 表示佣金回溯明细资源。 + AuditResourceCommissionClawback = "commission_clawback_record" // AuditResourceCommissionWithdrawal 表示佣金提现单资源。 AuditResourceCommissionWithdrawal = "commission_withdrawal" // AuditResourceCommissionWithdrawalAttempt 表示提现审批尝试记录资源。 @@ -948,6 +955,8 @@ const ( AuditResourceRoleRefundTransaction = "refund_transaction" // AuditResourceRoleRefundCommission 表示退款导致失效的佣金记录。 AuditResourceRoleRefundCommission = "refund_commission" + // AuditResourceRoleRefundClawback 表示退款生成的回溯明细。 + AuditResourceRoleRefundClawback = "refund_clawback" // AuditResourceRoleRefundPackageUsage 表示退款后处理涉及的套餐权益。 AuditResourceRoleRefundPackageUsage = "refund_package_usage" // AuditResourceRoleRefundNotification 表示退款完成通知的可靠 Outbox 事实。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 070a4bf..3579804 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -81,17 +81,18 @@ const ( TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包 // 定时任务类型(由 Asynq Scheduler 调度) - TaskTypeAlertCheck = "alert:check" // 告警检查 - TaskTypeDataCleanup = "data:cleanup" // 数据清理 - TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 - TaskTypePackageExpiryReminder = "package:expiry:reminder" // 每日套餐临期提醒扫描 - TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 - TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 - TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 - TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 - TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 - TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单 - TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询 + TaskTypeAlertCheck = "alert:check" // 告警检查 + TaskTypeDataCleanup = "data:cleanup" // 数据清理 + TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 + TaskTypePackageExpiryReminder = "package:expiry:reminder" // 每日套餐临期提醒扫描 + TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 + TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 + TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 + TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 + TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 + TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单 + TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询 + TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿 ) // 用户状态常量 @@ -307,7 +308,7 @@ func QueueForTaskType(taskType string) string { return QueueCardObservationSeries case TaskTypeWeComApprovalSync, TaskTypeWeComApprovalRecovery: return QueueWeComApproval - case TaskTypeAgentRechargeRecovery, TaskTypeRefundChannelRecovery: + case TaskTypeAgentRechargeRecovery, TaskTypeRefundChannelRecovery, TaskTypeRefundCommissionRecovery: return QueueDefault default: return QueueDefault @@ -365,6 +366,8 @@ const ( ExportTaskSceneRefund = "refund" // ExportTaskSceneExchange 表示换货记录导出场景。 ExportTaskSceneExchange = "exchange" + // ExportTaskSceneCommissionRecord 表示佣金明细导出场景,原佣金与回溯明细各占一行。 + ExportTaskSceneCommissionRecord = "commission_record" ) // 导出文件格式常量 diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index f5f2a01..69b03b9 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -211,6 +211,7 @@ const ( CommissionStatusUnfreezing = 2 // 解冻中 CommissionStatusReleased = 3 // 已发放 CommissionStatusInvalid = 4 // 已失效 + CommissionStatusClawback = 5 // 回溯(退款完成后的负数、不可提现回溯明细) CommissionStatusPendingReview = 99 // 待人工修正(链路断裂,需平台处理) ) @@ -341,7 +342,7 @@ func GetNetworkStatusName(status int) string { } // GetCommissionRecordStatusName 获取佣金记录状态名称 -// 对应 CommissionStatus* 常量:1=已冻结, 2=解冻中, 3=已发放, 4=已失效, 99=待人工修正 +// 对应 CommissionStatus* 常量:1=已冻结, 2=解冻中, 3=已发放, 4=已失效, 5=回溯, 99=待人工修正 func GetCommissionRecordStatusName(status int) string { switch status { case CommissionStatusFrozen: @@ -352,6 +353,8 @@ func GetCommissionRecordStatusName(status int) string { return "已发放" case CommissionStatusInvalid: return "已失效" + case CommissionStatusClawback: + return "回溯" case CommissionStatusPendingReview: return "待人工修正" default: