Files
junhong_cmp_fiber/internal/service/refund/audit.go
break 1aa4eacee2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
用 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
2026-09-14 13:40:34 +08:00

305 lines
16 KiB
Go

package refund
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// appendRefundAudit 在调用方事务内追加退款状态及完整关联资源。
func (s *Service) appendRefundAudit(
ctx context.Context,
tx *gorm.DB,
refundID uint,
actionCode string,
summary string,
eventID string,
beforeRefund map[string]any,
beforeOrder map[string]any,
subjectSummary string,
) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
}
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, refundID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审计快照失败")
}
var order model.Order
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单审计快照失败")
}
primary := audit.RefundResource(&refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
primary.BeforeData = beforeRefund
primary.AfterData = refundAuditState(&refund)
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = subjectSummary
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder)
orderResource.BeforeData = beforeOrder
orderResource.AfterData = map[string]any{"payment_status": order.PaymentStatus}
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary, orderResource}
if refund.ApprovalInstanceID != nil {
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, *refund.ApprovalInstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
resource := audit.ApprovalInstanceResource(&approval, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundApproval, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
asset, err := audit.RefundAssetResource(ctx, tx, &order, subjectSummary)
if err != nil {
return err
}
if asset != nil {
resources = append(resources, *asset)
}
if actionCode == constants.AuditActionRefundApproved || actionCode == constants.AuditActionRefundAssetProcessed {
chain, err := refundChainAuditResources(ctx, tx, &refund, &order)
if err != nil {
return err
}
resources = append(resources, chain...)
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: eventID, ActionCode: actionCode, Summary: summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: refund.RefundNo,
Metadata: map[string]any{
"requested_refund_amount": refund.RequestedRefundAmount,
"approved_refund_amount": refund.ApprovedRefundAmount,
},
Resources: resources,
})
}
// refundChainAuditResources 汇总退款已形成的资金、佣金、套餐和通知事实引用。
func refundChainAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) {
resources, err := refundFinanceAuditResources(ctx, tx, refund, order)
if err != nil {
return nil, err
}
var commissions []model.CommissionRecord
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&commissions).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金审计快照失败")
}
for i := range commissions {
resource := audit.CommissionRecordResource(&commissions[i], nil, nil)
resource.Relation = constants.AuditResourceRelationReference
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
var usages []model.PackageUsage
if err := tx.WithContext(ctx).Where("refund_id = ?", refund.ID).Order("id ASC").Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款套餐权益审计快照失败")
}
for i := range usages {
resource := audit.PackageUsageResource(&usages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundPackageUsage, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
var notification model.OutboxEvent
notificationEventID := "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":completed"
if err := tx.WithContext(ctx).Where("event_id = ?", notificationEventID).First(&notification).Error; err == nil {
id := strconv.FormatUint(uint64(notification.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceOutboxEvent, ID: &id, Key: notification.EventID, DisplayName: notification.EventID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundNotification,
IdentitySnapshot: map[string]any{
"event_id": notification.EventID, "event_type": notification.EventType,
"aggregate_type": notification.AggregateType, "aggregate_id": notification.AggregateID,
"resource_type": notification.ResourceType, "resource_id": notification.ResourceID,
"business_key": notification.BusinessKey,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款通知审计快照失败")
}
return resources, nil
}
// refundFinanceAuditResources 关联原扣款与退款流水,但不替代钱包流水权威事实。
func refundFinanceAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) {
var agentTransactions []model.AgentWalletTransaction
if err := tx.WithContext(ctx).Unscoped().
Where("(reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?)",
constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess,
constants.ReferenceTypeRefund, refund.ID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
Order("id DESC").Find(&agentTransactions).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包流水审计快照失败")
}
resources := make([]audit.ResourceInput, 0, len(agentTransactions)*2+2)
wallets := make(map[uint]struct{}, len(agentTransactions))
for i := range agentTransactions {
transaction := &agentTransactions[i]
if _, exists := wallets[transaction.AgentWalletID]; !exists {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包审计快照失败")
}
resources = append(resources, agentWalletRefundResource(&wallet, transaction))
wallets[transaction.AgentWalletID] = struct{}{}
}
resources = append(resources, agentWalletRefundTransactionResource(transaction, refund.ID))
}
var assetTransactions []model.AssetWalletTransaction
if err := tx.WithContext(ctx).Unscoped().
Where("(reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?)",
constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct, constants.TransactionStatusSuccess,
constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess).
Order("id DESC").Find(&assetTransactions).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包流水审计快照失败")
}
assetWallets := make(map[uint]struct{}, len(assetTransactions))
for i := range assetTransactions {
transaction := &assetTransactions[i]
if _, exists := assetWallets[transaction.AssetWalletID]; !exists {
var wallet model.AssetWallet
if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包审计快照失败")
}
resources = append(resources, assetWalletRefundResource(&wallet, transaction))
assetWallets[transaction.AssetWalletID] = struct{}{}
}
resources = append(resources, assetWalletRefundTransactionResource(transaction, refund.RefundNo))
}
return resources, nil
}
// agentWalletRefundResource 构造代理钱包退款余额变化资源。
func agentWalletRefundResource(wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) audit.ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return audit.ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理钱包 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
},
BeforeData: map[string]any{"balance": transaction.BalanceBefore},
AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// agentWalletRefundTransactionResource 区分原扣款流水和退款回充流水。
func agentWalletRefundTransactionResource(transaction *model.AgentWalletTransaction, refundID uint) audit.ResourceInput {
id := strconv.FormatUint(uint64(transaction.ID), 10)
role := constants.AuditResourceRoleRefundOriginalTransaction
relation := constants.AuditResourceRelationReference
if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund &&
transaction.ReferenceID != nil && *transaction.ReferenceID == refundID {
role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected
}
return audit.ResourceInput{
Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": transaction.ID, "agent_wallet_id": transaction.AgentWalletID, "shop_id": transaction.ShopID,
"transaction_type": transaction.TransactionType, "transaction_subtype": transaction.TransactionSubtype,
"reference_type": transaction.ReferenceType, "reference_id": transaction.ReferenceID, "status": transaction.Status,
},
AfterData: map[string]any{
"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// assetWalletRefundResource 构造资产钱包退款余额变化资源。
func assetWalletRefundResource(wallet *model.AssetWallet, transaction *model.AssetWalletTransaction) audit.ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
"currency": wallet.Currency, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag,
},
BeforeData: map[string]any{"balance": transaction.BalanceBefore},
AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// assetWalletRefundTransactionResource 区分资产钱包原扣款流水和退款回充流水。
func assetWalletRefundTransactionResource(transaction *model.AssetWalletTransaction, refundNo string) audit.ResourceInput {
id := strconv.FormatUint(uint64(transaction.ID), 10)
role := constants.AuditResourceRoleRefundOriginalTransaction
relation := constants.AuditResourceRelationReference
if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund &&
transaction.ReferenceNo != nil && *transaction.ReferenceNo == refundNo {
role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected
}
return audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID,
"resource_type": transaction.ResourceType, "resource_id": transaction.ResourceID,
"transaction_type": transaction.TransactionType, "reference_type": transaction.ReferenceType,
"reference_no": transaction.ReferenceNo, "status": transaction.Status,
},
AfterData: map[string]any{
"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// 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 {
return
}
primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
primary.BeforeData = refundAuditState(refund)
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary}
if order != nil && (order.ID > 0 || order.OrderNo != "") {
resource := audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
CorrelationID: refund.RefundNo, Resources: resources,
}, businessErr)
}
func refundAuditState(refund *model.RefundRequest) map[string]any {
return map[string]any{
"status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount,
"approval_instance_id": refund.ApprovalInstanceID, "processor_id": refund.ProcessorID,
"processed_at": refund.ProcessedAt, "commission_deducted": refund.CommissionDeducted,
"asset_reset": refund.AssetReset, "reject_reason": refund.RejectReason, "remark": refund.Remark,
}
}
// markRefundAssetProcessed 仅在首次完成后处理时同事务写入完成标记和审计事件。
func (s *Service) markRefundAssetProcessed(ctx context.Context, refundID uint) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND asset_reset = ?", refundID, false).Update("asset_reset", true)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款资产处理标记失败")
}
if result.RowsAffected == 0 {
return nil
}
return s.appendRefundAudit(ctx, tx, refundID, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理",
"refund:"+strconv.FormatUint(uint64(refundID), 10)+":asset-processed",
map[string]any{"asset_reset": false}, nil, "退款资产处理已完成")
})
}