收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -2,6 +2,7 @@ package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"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"
|
||||
)
|
||||
@@ -32,8 +34,11 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision
|
||||
}
|
||||
|
||||
func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
var refund model.RefundRequest
|
||||
var order model.Order
|
||||
changed := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
@@ -46,15 +51,17 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过")
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款关联订单失败")
|
||||
}
|
||||
beforeRefund := refundAuditState(&refund)
|
||||
beforeOrder := map[string]any{"payment_status": order.PaymentStatus}
|
||||
approvedAmount := refund.RequestedRefundAmount
|
||||
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil {
|
||||
return err
|
||||
}
|
||||
if refund.Status == model.RefundStatusPending {
|
||||
changed = true
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
@@ -71,6 +78,7 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
}
|
||||
switch order.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
changed = true
|
||||
result := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
|
||||
@@ -88,22 +96,32 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
|
||||
if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendCompletedNotification(ctx, tx, &refund)
|
||||
if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil {
|
||||
return err
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批",
|
||||
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", &refund, &order, err)
|
||||
return err
|
||||
}
|
||||
return s.ensureApprovedPostProcessing(ctx, event.BusinessID)
|
||||
}
|
||||
|
||||
func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
|
||||
reason := map[string]string{
|
||||
constants.ApprovalDecisionRejected: "企业微信审批已拒绝",
|
||||
constants.ApprovalDecisionCancelled: "企业微信审批已撤销",
|
||||
constants.ApprovalDecisionDeleted: "企业微信审批已删除",
|
||||
}[event.Decision]
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
var refund model.RefundRequest
|
||||
var order model.Order
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
@@ -116,6 +134,10 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter
|
||||
if refund.Status != model.RefundStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许结束审批")
|
||||
}
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
beforeRefund := refundAuditState(&refund)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
@@ -128,8 +150,13 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
return nil
|
||||
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundRejected, "拒绝退款审批",
|
||||
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":rejected", beforeRefund, nil, "退款已拒绝")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ensureApprovedPostProcessing(ctx context.Context, refundID uint) error {
|
||||
|
||||
357
internal/service/refund/audit.go
Normal file
357
internal/service/refund/audit.go
Normal file
@@ -0,0 +1,357 @@
|
||||
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(¬ification).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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
"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, "退款资产处理已完成")
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,11 +18,13 @@ import (
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -50,6 +53,7 @@ type Service struct {
|
||||
agentWalletRefundService *walletapp.RefundService
|
||||
refundApprovalCreation *refundapprovalapp.CreationService
|
||||
notificationOutbox *outbox.Repository
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -101,6 +105,11 @@ func (s *Service) SetNotificationOutbox(repository *outbox.Repository) {
|
||||
s.notificationOutbox = repository
|
||||
}
|
||||
|
||||
// SetLifecycleAudit 注入退款完整业务链统一审计 Writer。
|
||||
func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// Create 创建退款申请
|
||||
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
|
||||
@@ -159,9 +168,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
|
||||
Refund: refund, SubmitterAccountID: userID,
|
||||
Refund: refund, Order: order, SubmitterAccountID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
failedRefund := *refund
|
||||
failedRefund.ID = 0
|
||||
failedRefund.ApprovalInstanceID = nil
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundCreated, "提交退款申请失败", &failedRefund, order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -258,11 +271,16 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
|
||||
if refund.Status != model.RefundStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
if refund.ApprovalInstanceID != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批")
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -272,14 +290,19 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
}
|
||||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
beforeRefund := refundAuditState(refund)
|
||||
beforeOrder := map[string]any{"payment_status": order.PaymentStatus}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
@@ -314,19 +337,32 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendCompletedNotification(ctx, tx, refund)
|
||||
}); err != nil {
|
||||
if err := s.appendCompletedNotification(ctx, tx, refund); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批",
|
||||
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 事务提交成功后,异步执行佣金回扣和退款后资产处理(失败不影响审批结果)
|
||||
go func() {
|
||||
asyncCtx := context.Background()
|
||||
asyncCtx := auditcontext.With(context.Background(), auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing,
|
||||
ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker,
|
||||
CorrelationID: refund.RefundNo,
|
||||
})
|
||||
s.deductAllCommission(asyncCtx, id)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
asyncCtx := context.Background()
|
||||
asyncCtx := auditcontext.With(context.Background(), auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing,
|
||||
ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker,
|
||||
})
|
||||
s.handleRefundAssetProcessing(asyncCtx, id)
|
||||
}()
|
||||
|
||||
@@ -561,25 +597,40 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ
|
||||
return err
|
||||
}
|
||||
|
||||
var refund model.RefundRequest
|
||||
var order model.Order
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusRejected,
|
||||
"processor_id": userID,
|
||||
"processed_at": now,
|
||||
"reject_reason": req.RejectReason,
|
||||
"updater": userID,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败")
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
beforeRefund := refundAuditState(&refund)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusRejected,
|
||||
"processor_id": userID,
|
||||
"processed_at": now,
|
||||
"reject_reason": req.RejectReason,
|
||||
"updater": userID,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundRejected, "拒绝退款审批",
|
||||
"refund:"+strconv.FormatUint(uint64(id), 10)+":rejected", beforeRefund, nil, "退款已拒绝")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func legacyRefundManualEnabled() bool {
|
||||
@@ -598,25 +649,39 @@ func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequ
|
||||
return err
|
||||
}
|
||||
|
||||
var refund model.RefundRequest
|
||||
var order model.Order
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusReturned,
|
||||
"processor_id": userID,
|
||||
"processed_at": now,
|
||||
"remark": req.Remark,
|
||||
"updater": userID,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败")
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
beforeRefund := refundAuditState(&refund)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusReturned,
|
||||
"processor_id": userID,
|
||||
"processed_at": now,
|
||||
"remark": req.Remark,
|
||||
"updater": userID,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundReturned, "退回退款申请", "", beforeRefund, nil, "退款申请已退回")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundReturned, "退回退款申请失败", &refund, &order, err)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureRefundProcessor 确保只有平台侧账号可以处理审批类动作。
|
||||
@@ -640,8 +705,11 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
|
||||
if refund.Status != model.RefundStatusReturned {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
|
||||
requestedRefundAmount := refund.RequestedRefundAmount
|
||||
@@ -652,6 +720,7 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
if req.RefundVoucherKey != nil {
|
||||
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
|
||||
if normErr != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, normErr)
|
||||
return normErr
|
||||
}
|
||||
refundVoucherKey = normalized
|
||||
@@ -659,9 +728,12 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
|
||||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -684,17 +756,23 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
updates["refund_reason"] = *req.RefundReason
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败")
|
||||
beforeRefund := refundAuditState(refund)
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
}
|
||||
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// deductAllCommission 幂等回扣该订单所有已入账佣金。
|
||||
@@ -708,6 +786,10 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
|
||||
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
|
||||
}
|
||||
@@ -731,6 +813,7 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
|
||||
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),
|
||||
@@ -814,7 +897,8 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款佣金状态已变化")
|
||||
}
|
||||
return nil
|
||||
current.Status = constants.CommissionStatusInvalid
|
||||
return s.appendCommissionAudit(ctx, tx, refund, ¤t, &wallet, transaction)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -832,13 +916,21 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
if refund.AssetReset {
|
||||
return
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing,
|
||||
ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo,
|
||||
})
|
||||
|
||||
// 查询关联订单
|
||||
var order model.Order
|
||||
if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, nil, err)
|
||||
logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
recordFailure := func(err error) {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, &order, err)
|
||||
}
|
||||
|
||||
// 确定资产类型和 ID
|
||||
var assetType string
|
||||
@@ -846,6 +938,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
switch order.OrderType {
|
||||
case model.OrderTypeSingleCard:
|
||||
if order.IotCardID == nil {
|
||||
recordFailure(errors.New(errors.CodeInternalError, "退款单卡订单缺少资产ID"))
|
||||
logger.Error("退款资产处理:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID))
|
||||
return
|
||||
}
|
||||
@@ -853,24 +946,29 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
assetID = *order.IotCardID
|
||||
case model.OrderTypeDevice:
|
||||
if order.DeviceID == nil {
|
||||
recordFailure(errors.New(errors.CodeInternalError, "退款设备订单缺少资产ID"))
|
||||
logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID))
|
||||
return
|
||||
}
|
||||
assetType = "device"
|
||||
assetID = *order.DeviceID
|
||||
default:
|
||||
recordFailure(errors.New(errors.CodeInvalidParam, "退款订单资产类型无效"))
|
||||
logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType))
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐)
|
||||
if s.packageActivationService == nil {
|
||||
businessErr := errors.New(errors.CodeServiceUnavailable, "退款套餐处理能力未配置")
|
||||
recordFailure(businessErr)
|
||||
logger.Error("退款资产处理:套餐激活服务未注入",
|
||||
zap.Uint("refund_id", refund.ID),
|
||||
zap.Uint("order_id", order.ID))
|
||||
return
|
||||
}
|
||||
if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil {
|
||||
recordFailure(err)
|
||||
fields := []zap.Field{
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
@@ -888,6 +986,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
|
||||
// 2. 尝试按购买顺序接续待生效主套餐
|
||||
if _, err := s.packageActivationService.ActivateNextPendingMainPackage(ctx, assetType, assetID); err != nil {
|
||||
recordFailure(err)
|
||||
logger.Error("退款资产处理:接续激活待生效套餐失败",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
@@ -898,6 +997,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
|
||||
hasActiveMain, err := s.packageActivationService.HasActiveMainPackage(ctx, assetType, assetID)
|
||||
if err != nil {
|
||||
recordFailure(err)
|
||||
logger.Error("退款资产处理:查询生效主套餐失败",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
@@ -908,12 +1008,14 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
|
||||
if !hasActiveMain {
|
||||
// 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。
|
||||
if !s.stopAsset(ctx, assetType, assetID) {
|
||||
recordFailure(errors.New(errors.CodeServiceUnavailable, "退款资产停机处理失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 标记退款后资产处理已完成
|
||||
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil {
|
||||
if err := s.markRefundAssetProcessed(ctx, refundID); err != nil {
|
||||
recordFailure(err)
|
||||
logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user