收口审计治理与套餐任务进展

Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
2026-08-05 14:30:54 +08:00
parent b3499adfca
commit 5e552d99bc
178 changed files with 16797 additions and 5674 deletions

View File

@@ -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, &current, &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))
}
}