收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -53,6 +53,7 @@ type Service struct {
|
||||
operationPasswordService OperationPasswordServiceInterface
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
rechargeAudit agentrechargeapp.RechargeAuditWriter
|
||||
}
|
||||
|
||||
// New 创建代理预充值服务实例
|
||||
@@ -90,6 +91,11 @@ func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCre
|
||||
s.offlineCreation = service
|
||||
}
|
||||
|
||||
// SetRechargeAudit 注入代理充值统一审计 Writer。
|
||||
func (s *Service) SetRechargeAudit(writer agentrechargeapp.RechargeAuditWriter) {
|
||||
s.rechargeAudit = writer
|
||||
}
|
||||
|
||||
// Create 创建代理充值订单
|
||||
// POST /api/admin/agent-recharges
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
|
||||
@@ -215,33 +221,22 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
|
||||
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
|
||||
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
|
||||
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
|
||||
TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID,
|
||||
Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "线下充值确认已入账")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 异步记录审计日志
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: userID,
|
||||
OperatorType: userType,
|
||||
OperationType: "offline_recharge_confirm",
|
||||
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s,金额: %d分", record.RechargeNo, record.Amount),
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
|
||||
shopName := ""
|
||||
if shop != nil {
|
||||
@@ -325,16 +320,16 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
|
||||
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
|
||||
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
|
||||
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
|
||||
TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID,
|
||||
Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "代理充值支付回调已入账")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -391,11 +386,30 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
|
||||
return errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工驳回")
|
||||
}
|
||||
|
||||
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if s.rechargeAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
|
||||
Updates(map[string]any{"status": constants.RechargeStatusRejected, "rejection_reason": strings.TrimSpace(rejectionReason)})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "驳回充值订单失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "驳回充值订单失败")
|
||||
after := *record
|
||||
after.Status = constants.RechargeStatusRejected
|
||||
reason := strings.TrimSpace(rejectionReason)
|
||||
after.RejectionReason = &reason
|
||||
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
|
||||
ActionCode: constants.AuditActionAgentRechargeClosed, Summary: "驳回代理充值申请",
|
||||
Record: &after, BeforeData: map[string]any{"status": record.Status},
|
||||
AfterData: map[string]any{"status": after.Status, "rejection_reason": reason},
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("代理充值订单驳回成功",
|
||||
@@ -405,6 +419,29 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) appendCreditedAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, payment *model.Payment, status int, summary string) error {
|
||||
if s.rechargeAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
|
||||
}
|
||||
var transaction model.AgentWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
|
||||
}
|
||||
after := *record
|
||||
after.Status = status
|
||||
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
|
||||
ActionCode: constants.AuditActionAgentRechargeCredited, Summary: summary,
|
||||
Record: &after, Payment: payment, Wallet: &wallet, Transaction: &transaction,
|
||||
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID 根据ID查询充值订单详情
|
||||
// GET /api/admin/agent-recharges/:id
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
|
||||
|
||||
47
internal/service/asset_package_batch_order/audit.go
Normal file
47
internal/service/asset_package_batch_order/audit.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package asset_package_batch_order
|
||||
|
||||
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/middleware"
|
||||
)
|
||||
|
||||
func (s *Service) writeBatchOrderTaskAudit(ctx context.Context, tx *gorm.DB, task *model.AssetPackageBatchOrderTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if task.CreatorShopID != 0 {
|
||||
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(task.CreatorShopID), 10)
|
||||
}
|
||||
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, task.ID, phase),
|
||||
ActionCode: constants.AuditActionAssetPackageBatchOrderTaskCreated,
|
||||
Summary: "创建资产套餐批量订购任务", TaskID: task.ID, TaskNo: task.TaskNo,
|
||||
Actor: audit.ActorInput{
|
||||
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
|
||||
Name: middleware.GetUsernameFromContext(ctx),
|
||||
},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
|
||||
"package_id": task.PackageID, "package_code": task.PackageCode,
|
||||
"package_name": task.PackageName, "payment_method": task.PaymentMethod,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
})
|
||||
}
|
||||
|
||||
func batchOrderTaskState(task *model.AssetPackageBatchOrderTask) map[string]any {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"status": task.Status, "total_count": task.TotalCount,
|
||||
"success_count": task.SuccessCount, "fail_count": task.FailCount,
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,20 @@ package asset_package_batch_order
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"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/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/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -26,11 +29,16 @@ type Service struct {
|
||||
taskStore *postgres.AssetPackageBatchOrderTaskStore
|
||||
packageStore *postgres.PackageStore
|
||||
queueClient *queue.Client
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// New 创建资产套餐批量订购任务服务。
|
||||
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client) *Service {
|
||||
return &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
|
||||
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client, auditWriters ...*audit.Writer) *Service {
|
||||
service := &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// TaskPayload 批量订购 Worker 结构化载荷。
|
||||
@@ -69,7 +77,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
|
||||
CreatorUserType: middleware.GetUserTypeFromContext(ctx), CreatorShopID: middleware.GetShopIDFromContext(ctx),
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
}
|
||||
if err := s.taskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "资产套餐批量订购统一审计接缝未配置")
|
||||
}
|
||||
if err := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeBatchOrderTaskAudit(ctx, tx, task, nil, batchOrderTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建批量订购任务失败")
|
||||
}
|
||||
var enqueueErr error
|
||||
@@ -82,10 +98,19 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
|
||||
}
|
||||
if enqueueErr != nil {
|
||||
message := "批量订购任务入队失败"
|
||||
_ = s.taskStore.MarkFailed(ctx, task.ID, message)
|
||||
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
|
||||
now := time.Now()
|
||||
task.CompletedAt = &now
|
||||
secondaryErr := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
before := batchOrderTaskState(task)
|
||||
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, message); err != nil {
|
||||
return err
|
||||
}
|
||||
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
|
||||
now := time.Now()
|
||||
task.CompletedAt = &now
|
||||
return s.writeBatchOrderTaskAudit(ctx, tx, task, before, batchOrderTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), message)
|
||||
})
|
||||
if secondaryErr != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAssetPackageBatchOrderTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
|
||||
}
|
||||
}
|
||||
return toResponse(task), nil
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ package carrier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -17,10 +20,11 @@ import (
|
||||
|
||||
type Service struct {
|
||||
carrierStore *postgres.CarrierStore
|
||||
audit systemconfigapp.AuditWriter
|
||||
}
|
||||
|
||||
func New(carrierStore *postgres.CarrierStore) *Service {
|
||||
return &Service{carrierStore: carrierStore}
|
||||
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
|
||||
return &Service{carrierStore: carrierStore, audit: audit}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
|
||||
@@ -31,8 +35,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
||||
|
||||
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
|
||||
if existing != nil {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
|
||||
return nil, errors.New(errors.CodeCarrierCodeExists, "运营商编码已存在")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
|
||||
}
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: req.CarrierCode,
|
||||
@@ -53,7 +61,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
||||
}
|
||||
carrier.Creator = currentUserID
|
||||
|
||||
if err := s.carrierStore.Create(ctx, carrier); err != nil {
|
||||
err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.carrierStore.WithTx(tx).Create(ctx, carrier); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierCreate, "创建运营商配置", nil, carrier)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditOperationCarrierCreate, "创建运营商配置失败", carrier)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
|
||||
}
|
||||
|
||||
@@ -68,6 +83,10 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
|
||||
}
|
||||
before := *carrier
|
||||
return s.toResponse(carrier), nil
|
||||
}
|
||||
|
||||
@@ -101,11 +120,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
|
||||
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
|
||||
}
|
||||
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
|
||||
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商实名链接配置", &before, errors.CodeInvalidParam)
|
||||
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
|
||||
}
|
||||
carrier.Updater = currentUserID
|
||||
|
||||
if err := s.carrierStore.Update(ctx, carrier); err != nil {
|
||||
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierUpdate, "更新运营商配置", &before, carrier)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditOperationCarrierUpdate, "更新运营商配置失败", carrier)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
|
||||
}
|
||||
|
||||
@@ -113,15 +140,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
_, err := s.carrierStore.GetByID(ctx, id)
|
||||
carrier, err := s.carrierStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeCarrierNotFound, "运营商不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
|
||||
}
|
||||
|
||||
if err := s.carrierStore.Delete(ctx, id); err != nil {
|
||||
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.carrierStore.WithTx(tx).Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierDelete, "删除运营商配置", carrier, nil)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditOperationCarrierDelete, "删除运营商配置失败", carrier)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除运营商失败")
|
||||
}
|
||||
|
||||
@@ -178,17 +215,100 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
|
||||
}
|
||||
before := *carrier
|
||||
|
||||
carrier.Status = status
|
||||
carrier.Updater = currentUserID
|
||||
|
||||
if err := s.carrierStore.Update(ctx, carrier); err != nil {
|
||||
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态", &before, carrier)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailure(ctx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态失败", carrier)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新运营商状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.Carrier) error {
|
||||
carrier := after
|
||||
if carrier == nil {
|
||||
carrier = before
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
|
||||
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
|
||||
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier),
|
||||
BeforeData: carrierAuditSnapshot(before), AfterData: carrierAuditSnapshot(after),
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDenied(ctx context.Context, operation, description string, carrier *model.Carrier, code int) {
|
||||
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultDenied, code)
|
||||
}
|
||||
|
||||
func (s *Service) recordFailure(ctx context.Context, operation, description string, carrier *model.Carrier) {
|
||||
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultFailed, errors.CodeDatabaseError)
|
||||
}
|
||||
|
||||
func (s *Service) recordAuditResult(ctx context.Context, operation, description string, carrier *model.Carrier, result string, code int) {
|
||||
if s.audit == nil || carrier == nil || s.carrierStore == nil || s.carrierStore.DB() == nil {
|
||||
return
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
audit := systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
|
||||
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
|
||||
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier), BeforeData: carrierAuditSnapshot(carrier),
|
||||
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}
|
||||
if err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteConfigChange(ctx, tx, audit)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(operation, audit.ConfigKey, requestID, requestID, audit.ErrorCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func carrierIdentity(carrier *model.Carrier) map[string]any {
|
||||
if carrier == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
|
||||
"carrier_type": carrier.CarrierType, "status": carrier.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
|
||||
if carrier == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
|
||||
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
|
||||
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
|
||||
"data_reset_day": carrier.DataResetDay,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
||||
return &dto.CarrierResponse{
|
||||
ID: c.ID,
|
||||
|
||||
103
internal/service/client_order/payment_audit.go
Normal file
103
internal/service/client_order/payment_audit.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package client_order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *Service) appendPaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, recharge *model.RechargeOrder) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置")
|
||||
}
|
||||
resources := []audit.ResourceInput{audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status})}
|
||||
if order != nil {
|
||||
resources = append(resources, audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRolePaymentBusinessOrder))
|
||||
}
|
||||
if recharge != nil {
|
||||
id := strconv.FormatUint(uint64(recharge.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceRechargeOrder, ID: &id, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePaymentBusinessOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
|
||||
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
|
||||
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建第三方支付记录",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: payment.PaymentNo, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) startPaymentAttempt(ctx context.Context, payment *model.Payment, provider, scene string) (*model.IntegrationLog, time.Time, error) {
|
||||
if s.paymentIntegration == nil {
|
||||
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "支付 Integration Log 接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(payment.ID), 10)
|
||||
resourceKey, series, correlationID := payment.PaymentNo, "payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, payment.PaymentNo
|
||||
triggerSource, triggerScene := auditcontext.From(ctx).Source, scene
|
||||
log, err := s.paymentIntegration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationPaymentPreCreate,
|
||||
ResourceType: constants.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
|
||||
ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene,
|
||||
TriggerSeries: &series, CorrelationID: &correlationID,
|
||||
RequestSummary: map[string]any{"payment_config_id": payment.PaymentConfigID, "amount": payment.Amount},
|
||||
})
|
||||
return log, time.Now(), err
|
||||
}
|
||||
|
||||
func (s *Service) completePaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
|
||||
completion := integrationlog.Completion{
|
||||
Result: result, ProviderCode: providerCode, SafeProviderMessage: safeMessage,
|
||||
ResponseSummary: map[string]any{"success": result == constants.IntegrationResultSuccess},
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
if result == constants.IntegrationResultUnknown {
|
||||
completion.RecoveryStrategy = "使用原支付单号主动查单,确认结果后再推进本地支付状态"
|
||||
}
|
||||
_, err := s.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
func paymentIntegrationProvider(config *model.WechatConfig) string {
|
||||
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
|
||||
return constants.IntegrationProviderFuiou
|
||||
}
|
||||
return constants.IntegrationProviderWechatPay
|
||||
}
|
||||
|
||||
func (s *Service) markPaymentFailed(ctx context.Context, payment *model.Payment, summary string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
|
||||
Update("status", model.PaymentRecordStatusFailed)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新失败支付记录失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
after := *payment
|
||||
after.Status = model.PaymentRecordStatusFailed
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionPaymentFailed, Summary: summary,
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: payment.PaymentNo,
|
||||
Resources: []audit.ResourceInput{audit.PaymentResource(&after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
|
||||
map[string]any{"status": payment.Status}, map[string]any{"status": after.Status})},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
@@ -40,6 +42,8 @@ type WechatConfigServiceInterface interface {
|
||||
// 用于将钱包扣款、套餐激活、佣金计算等核心逻辑委托给 B 端 order.Service 处理。
|
||||
type OrderWalletPayServiceInterface interface {
|
||||
WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error
|
||||
CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error
|
||||
RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error)
|
||||
}
|
||||
|
||||
// PaymentMethodPolicy 提供按资产类型校验支付方式的能力。
|
||||
@@ -76,6 +80,8 @@ type Service struct {
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
paymentMethodPolicy PaymentMethodPolicy
|
||||
auditWriter *audit.Writer
|
||||
paymentIntegration *integrationlog.Repository
|
||||
}
|
||||
|
||||
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
|
||||
@@ -83,6 +89,12 @@ func (s *Service) SetPaymentMethodPolicy(policy PaymentMethodPolicy) {
|
||||
s.paymentMethodPolicy = policy
|
||||
}
|
||||
|
||||
// SetPaymentAudit 注入支付审计与外部交互日志接缝。
|
||||
func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
|
||||
s.auditWriter = writer
|
||||
s.paymentIntegration = integration
|
||||
}
|
||||
|
||||
// New 创建客户端订单服务。
|
||||
func New(
|
||||
assetService *asset.Service,
|
||||
@@ -131,7 +143,7 @@ func New(
|
||||
// CreateOrder 创建客户端订单。
|
||||
// 普通套餐下单:仅创建待支付订单,不发起支付,需后续调用 POST /orders/:id/pay 支付。
|
||||
// 强充场景:检测到需要强充时,直接创建充值单并发起微信支付(一步完成),此时 app_type 必传。
|
||||
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
|
||||
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (resp *dto.ClientCreateOrderResponse, err error) {
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
@@ -144,6 +156,23 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auditOrder := &model.Order{
|
||||
OrderNo: "create:client:" + strings.TrimSpace(req.Identifier), BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: customerID, AssetIdentifier: strings.TrimSpace(req.Identifier), PaymentMethod: req.PaymentMethod,
|
||||
}
|
||||
if assetInfo.AssetType == "card" || assetInfo.AssetType == constants.AssetTypeIotCard {
|
||||
auditOrder.OrderType = model.OrderTypeSingleCard
|
||||
auditOrder.IotCardID = &assetInfo.AssetID
|
||||
} else {
|
||||
auditOrder.OrderType = model.OrderTypeDevice
|
||||
auditOrder.DeviceID = &assetInfo.AssetID
|
||||
}
|
||||
orderFlow := true
|
||||
defer func() {
|
||||
if orderFlow && s.orderPaymentService != nil {
|
||||
s.orderPaymentService.RecordCreateFailure(skipCtx, auditOrder, err)
|
||||
}
|
||||
}()
|
||||
if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
|
||||
} else if !owned {
|
||||
@@ -249,6 +278,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
}()
|
||||
|
||||
if forceRecharge.NeedForceRecharge {
|
||||
orderFlow = false
|
||||
if s.paymentMethodPolicy == nil {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig)
|
||||
}
|
||||
@@ -405,8 +435,11 @@ func (s *Service) createPackageOrder(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
||||
if s.orderPaymentService == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "订单创建能力未配置")
|
||||
}
|
||||
if err := s.orderPaymentService.CreatePendingOrder(ctx, order, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
|
||||
@@ -484,10 +517,6 @@ func (s *Service) createForceRechargeOrder(
|
||||
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
|
||||
}
|
||||
|
||||
if err := s.rechargeOrderStore.Create(ctx, rechargeOrder); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
|
||||
paymentNo := generateClientPaymentNo()
|
||||
payment := &model.Payment{
|
||||
PaymentNo: paymentNo,
|
||||
@@ -499,12 +528,34 @@ func (s *Service) createForceRechargeOrder(
|
||||
PaymentConfigID: &activeConfig.ID,
|
||||
}
|
||||
|
||||
if err := s.paymentStore.Create(ctx, payment); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
payment.OrderID = rechargeOrder.ID
|
||||
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||||
}
|
||||
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startPaymentAttempt(ctx, payment, paymentIntegrationProvider(activeConfig), "client_force_recharge")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, paymentNo, "余额充值", openID, int(rechargeOrder.Amount))
|
||||
if err != nil {
|
||||
if completeErr := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
if updateErr := s.markPaymentFailed(ctx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -937,7 +988,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
|
||||
} else {
|
||||
// 过期或不存在,标记旧单 failed 并新建
|
||||
if existing != nil {
|
||||
if updateErr := s.paymentStore.UpdateStatus(ctx, existing.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil {
|
||||
s.logger.Warn("标记过期支付宝支付单 failed 失败",
|
||||
zap.Uint("payment_id", existing.ID),
|
||||
zap.Error(updateErr),
|
||||
@@ -959,8 +1010,13 @@ func (s *Service) getOrBuildAlipayPaymentLink(
|
||||
PaymentConfigID: &activeConfig.ID,
|
||||
ExpireAt: &expireAt,
|
||||
}
|
||||
if err := s.paymentStore.Create(ctx, newPayment); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
|
||||
}
|
||||
return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payment = newPayment
|
||||
s.logger.Info("创建支付宝支付单",
|
||||
@@ -977,7 +1033,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
|
||||
if err != nil {
|
||||
// 新建的 payment 生成链接失败,标记 failed
|
||||
if existing == nil || payment.ID != existing.ID {
|
||||
_ = s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed)
|
||||
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -1074,14 +1130,17 @@ func (s *Service) createAlipayForceRechargeOrder(
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
payment.OrderID = rechargeOrder.ID
|
||||
return s.paymentStore.CreateWithTx(ctx, tx, payment)
|
||||
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, "余额充值")
|
||||
if err != nil {
|
||||
if updateErr := s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
if updateErr := s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录"); updateErr != nil {
|
||||
s.logger.Warn("标记支付宝支付单 failed 失败",
|
||||
zap.String("payment_no", paymentNo),
|
||||
zap.Error(updateErr),
|
||||
@@ -1407,14 +1466,21 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
||||
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||||
}
|
||||
return nil
|
||||
return s.appendPaymentCreatedAudit(skipCtx, tx, payment, order, nil)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startPaymentAttempt(skipCtx, payment, paymentIntegrationProvider(activeConfig), "client_order")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentResult, err := paymentProvider.CreateJSAPIPayment(skipCtx, paymentNo, "套餐购买", openID, int(order.TotalAmount))
|
||||
if err != nil {
|
||||
if updateErr := s.paymentStore.UpdateStatus(skipCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
|
||||
if completeErr := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
if updateErr := s.markPaymentFailed(skipCtx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
|
||||
s.logger.Warn("标记支付记录失败状态失败",
|
||||
zap.Uint("payment_id", payment.ID),
|
||||
zap.String("payment_no", paymentNo),
|
||||
@@ -1423,6 +1489,9 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ClientPayOrderResponse{
|
||||
PaymentMethod: paymentMethod,
|
||||
PayConfig: buildClientPayConfigFromResult(paymentResult),
|
||||
|
||||
148
internal/service/commission_calculation/audit.go
Normal file
148
internal/service/commission_calculation/audit.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package commission_calculation
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (s *Service) appendCommissionCalculationAudit(ctx context.Context, tx *gorm.DB, order *model.Order, status, result int) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
|
||||
}
|
||||
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
|
||||
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
|
||||
primary.AfterData = map[string]any{"commission_status": status, "commission_result": result}
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources := []audit.ResourceInput{primary}
|
||||
|
||||
var records []model.CommissionRecord
|
||||
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单佣金审计快照失败")
|
||||
}
|
||||
shopIDs := make([]uint, 0, len(records))
|
||||
seenShops := make(map[uint]struct{}, len(records))
|
||||
for i := range records {
|
||||
resource := audit.CommissionRecordResource(&records[i], nil, map[string]any{
|
||||
"amount": records[i].Amount, "status": records[i].Status, "balance_after": records[i].BalanceAfter,
|
||||
})
|
||||
resource.Relation = constants.AuditResourceRelationAffected
|
||||
resource.Role = constants.AuditResourceRoleCommissionRecord
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
if _, ok := seenShops[records[i].ShopID]; !ok {
|
||||
seenShops[records[i].ShopID] = struct{}{}
|
||||
shopIDs = append(shopIDs, records[i].ShopID)
|
||||
}
|
||||
}
|
||||
if len(shopIDs) > 0 {
|
||||
var shops []model.Shop
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
|
||||
}
|
||||
for i := range shops {
|
||||
resource := audit.ShopResource(&shops[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
}
|
||||
seriesResource, err := commissionSeriesResource(ctx, tx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seriesResource != nil {
|
||||
resources = append(resources, *seriesResource)
|
||||
}
|
||||
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission:order:" + strconv.FormatUint(uint64(order.ID), 10) + ":calculated",
|
||||
ActionCode: constants.AuditActionCommissionCalculated, Summary: "完成订单佣金计算",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: order.OrderNo, Resources: resources,
|
||||
Metadata: map[string]any{"commission_status": status, "commission_result": result, "record_count": len(records)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) appendCommissionCreditAudit(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, balanceBefore int64) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
|
||||
}
|
||||
var saved model.CommissionRecord
|
||||
if err := tx.WithContext(ctx).First(&saved, record.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金入账审计快照失败")
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, saved.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联订单审计快照失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
|
||||
}
|
||||
|
||||
primary := audit.CommissionRecordResource(&saved,
|
||||
map[string]any{"amount": record.Amount, "status": record.Status, "balance_after": record.BalanceAfter},
|
||||
map[string]any{"amount": saved.Amount, "status": saved.Status, "balance_after": saved.BalanceAfter, "released_at": saved.ReleasedAt})
|
||||
primary.Relation = constants.AuditResourceRelationPrimary
|
||||
primary.Role = constants.AuditResourceRoleCommissionRecord
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionWallet,
|
||||
map[string]any{"balance": balanceBefore, "frozen_balance": wallet.FrozenBalance},
|
||||
map[string]any{"balance": balanceBefore + saved.Amount, "frozen_balance": wallet.FrozenBalance})
|
||||
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionTransaction)
|
||||
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionOrder)
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources := []audit.ResourceInput{primary, walletResource, transactionResource, orderResource, shopResource}
|
||||
seriesResource, err := commissionSeriesResource(ctx, tx, &order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seriesResource != nil {
|
||||
resources = append(resources, *seriesResource)
|
||||
}
|
||||
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission:record:" + strconv.FormatUint(uint64(saved.ID), 10) + ":credited",
|
||||
ActionCode: constants.AuditActionCommissionCredited, Summary: "佣金已入账",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: order.OrderNo, Resources: resources,
|
||||
Metadata: map[string]any{"amount": saved.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter},
|
||||
})
|
||||
}
|
||||
|
||||
func commissionSeriesResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
|
||||
if order.SeriesID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var series model.PackageSeries
|
||||
if err := tx.WithContext(ctx).First(&series, *order.SeriesID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联套餐系列审计快照失败")
|
||||
}
|
||||
resource := audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionSeries, nil, nil)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordCommissionCalculationFailure(ctx context.Context, order *model.Order, businessErr error) {
|
||||
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
|
||||
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionCommissionCalculated, Summary: "订单佣金计算失败",
|
||||
ScopeType: constants.AuditScopePlatform, CorrelationID: order.OrderNo,
|
||||
Resources: []audit.ResourceInput{primary},
|
||||
}, businessErr)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -30,6 +31,7 @@ type Service struct {
|
||||
packageStore *postgres.PackageStore
|
||||
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
|
||||
commissionStatsService *commission_stats.Service
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -71,12 +73,19 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuditWriter 注入佣金计算与入账统一审计 Writer。
|
||||
func (s *Service) SetAuditWriter(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||||
var order *model.Order
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
loadedOrder, err := s.orderStore.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败")
|
||||
}
|
||||
order = loadedOrder
|
||||
|
||||
if order.CommissionStatus == model.CommissionStatusCompleted || order.CommissionStatus == model.CommissionStatusPendingReview {
|
||||
s.logger.Warn("订单佣金流程已结束,跳过",
|
||||
@@ -120,8 +129,12 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金结果失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendCommissionCalculationAudit(ctx, tx, order, commissionStatus, commissionResult)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordCommissionCalculationFailure(ctx, order, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error) {
|
||||
@@ -702,7 +715,7 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendCommissionCreditAudit(ctx, tx, record, &wallet, transaction, balanceBefore)
|
||||
}
|
||||
|
||||
func (s *Service) persistCommissionRecordsInTx(ctx context.Context, tx *gorm.DB, records []*model.CommissionRecord) error {
|
||||
|
||||
86
internal/service/commission_withdrawal/audit.go
Normal file
86
internal/service/commission_withdrawal/audit.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package commission_withdrawal
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (s *Service) appendWithdrawalDecisionAudit(ctx context.Context, tx *gorm.DB, before *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, actionCode, summary string, amount int64) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "佣金提现统一审计接缝未配置")
|
||||
}
|
||||
var saved model.CommissionWithdrawalRequest
|
||||
if err := tx.WithContext(ctx).First(&saved, before.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批审计快照失败")
|
||||
}
|
||||
expectedStatus := constants.WithdrawalStatusApproved
|
||||
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
|
||||
expectedStatus = constants.WithdrawalStatusRejected
|
||||
}
|
||||
if saved.Status != expectedStatus {
|
||||
return errors.New(errors.CodeInvalidStatus, "提现申请终态更新未生效")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批店铺审计快照失败")
|
||||
}
|
||||
primary := audit.CommissionWithdrawalResource(&saved, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
|
||||
withdrawalDecisionState(before), withdrawalDecisionState(&saved))
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = summary
|
||||
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance - amount})
|
||||
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
|
||||
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
suffix := "approved"
|
||||
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
|
||||
suffix = "rejected"
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(saved.ID), 10) + ":" + suffix,
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, CorrelationID: saved.WithdrawalNo,
|
||||
Metadata: map[string]any{"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount, "status": saved.Status},
|
||||
Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordWithdrawalDecisionFailure(ctx context.Context, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, actionCode, summary string, businessErr error) {
|
||||
if businessErr == nil || withdrawal == nil || withdrawal.WithdrawalNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
|
||||
withdrawalDecisionState(withdrawal), nil)
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources := []audit.ResourceInput{primary}
|
||||
if wallet != nil {
|
||||
resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalWallet, nil, nil)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
CorrelationID: withdrawal.WithdrawalNo, Resources: resources,
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func withdrawalDecisionState(withdrawal *model.CommissionWithdrawalRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "actual_amount": withdrawal.ActualAmount,
|
||||
"withdrawal_method": withdrawal.WithdrawalMethod, "payment_type": withdrawal.PaymentType,
|
||||
"status": withdrawal.Status, "processor_id": withdrawal.ProcessorID,
|
||||
"processed_at": withdrawal.ProcessedAt, "paid_at": withdrawal.PaidAt,
|
||||
"reject_reason": withdrawal.RejectReason, "remark": withdrawal.Remark,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
@@ -22,6 +23,12 @@ type Service struct {
|
||||
agentWalletStore *postgres.AgentWalletStore
|
||||
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
|
||||
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// SetAuditWriter 注入佣金提现审批统一审计 Writer。
|
||||
func (s *Service) SetAuditWriter(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -154,13 +161,17 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
|
||||
}
|
||||
|
||||
if withdrawal.Status != constants.WithdrawalStatusPending {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
|
||||
return nil, businessErr
|
||||
}
|
||||
|
||||
// 获取店铺分佣钱包
|
||||
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
|
||||
return nil, businessErr
|
||||
}
|
||||
|
||||
amount := withdrawal.Amount
|
||||
@@ -169,7 +180,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
|
||||
}
|
||||
|
||||
if wallet.FrozenBalance < amount {
|
||||
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
|
||||
businessErr := errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
|
||||
return nil, businessErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -239,10 +252,11 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalApproved, "佣金提现申请已通过", amount)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -267,12 +281,16 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
|
||||
}
|
||||
|
||||
if withdrawal.Status != constants.WithdrawalStatusPending {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
||||
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
|
||||
return nil, businessErr
|
||||
}
|
||||
|
||||
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
|
||||
return nil, businessErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -312,10 +330,11 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalRejected, "佣金提现申请已驳回", withdrawal.Amount)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
200
internal/service/customer_binding/audit.go
Normal file
200
internal/service/customer_binding/audit.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package customer_binding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// SetAccessAudit 注入个人客户资产关系的统一审计接缝。
|
||||
func (s *Service) SetAccessAudit(writer accessauditapp.Writer) {
|
||||
s.accessAudit = writer
|
||||
}
|
||||
|
||||
func (s *Service) writeBindingAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary string,
|
||||
customerID uint,
|
||||
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
|
||||
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
|
||||
cards []accessauditapp.IotCardChange,
|
||||
devices []accessauditapp.DeviceChange,
|
||||
) error {
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
|
||||
}
|
||||
var customer model.PersonalCustomer
|
||||
if err := tx.WithContext(ctx).First(&customer, customerID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户审计快照失败")
|
||||
}
|
||||
|
||||
value := auditcontext.From(ctx)
|
||||
operatorID := customerID
|
||||
actorKind := constants.AuditActorPersonalCustomer
|
||||
actorName := customer.Nickname
|
||||
source := constants.AuditSourcePersonalAPI
|
||||
scopeType := constants.AuditScopePersonalCustomer
|
||||
visibility := constants.AuditSubjectDetail
|
||||
subjectData := map[string]any(nil)
|
||||
if actionCode == constants.AuditActionPersonalCustomerAssetBound {
|
||||
assetType, assetID := bindingAssetReference(cards, devices)
|
||||
subjectData = map[string]any{"asset_type": assetType, "asset_id": assetID}
|
||||
} else {
|
||||
operatorID = middleware.GetUserIDFromContext(ctx)
|
||||
if parsed, err := strconv.ParseUint(value.ActorID, 10, 64); err == nil && parsed > 0 {
|
||||
operatorID = uint(parsed)
|
||||
}
|
||||
actorKind = value.ActorKind
|
||||
actorName = value.ActorName
|
||||
source = value.Source
|
||||
scopeType = constants.AuditScopePlatform
|
||||
visibility = constants.AuditSubjectResult
|
||||
}
|
||||
if operatorID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计操作者不完整")
|
||||
}
|
||||
|
||||
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||||
ActionCode: actionCode, Summary: summary, Result: constants.AuditResultSuccess,
|
||||
OperatorID: operatorID, ActorKind: actorKind, ActorName: actorName, Source: source, ScopeType: scopeType,
|
||||
PersonalCustomer: &customer, PersonalDevices: personalDevices, PersonalICCIDs: personalICCIDs,
|
||||
Cards: cards, Devices: devices,
|
||||
SubjectVisibility: visibility, SubjectSummary: summary, SubjectData: subjectData,
|
||||
})
|
||||
}
|
||||
|
||||
func bindingAssetReference(cards []accessauditapp.IotCardChange, devices []accessauditapp.DeviceChange) (string, uint) {
|
||||
if len(cards) > 0 && cards[0].Card != nil {
|
||||
return constants.AuditResourceIotCard, cards[0].Card.ID
|
||||
}
|
||||
if len(devices) > 0 && devices[0].Device != nil {
|
||||
return constants.AuditResourceDevice, devices[0].Device.ID
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
func cardAuditChange(card *model.IotCard, relation, role string, beforeData, afterData map[string]any) accessauditapp.IotCardChange {
|
||||
return accessauditapp.IotCardChange{
|
||||
Card: card, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectSummary: "个人客户资产关系已更新",
|
||||
}
|
||||
}
|
||||
|
||||
func deviceAuditChange(device *model.Device, relation, role string, beforeData, afterData map[string]any) accessauditapp.DeviceChange {
|
||||
return accessauditapp.DeviceChange{
|
||||
Device: device, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectSummary: "个人客户资产关系已更新",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadAuditAssets(ctx context.Context, tx *gorm.DB, oldType string, oldID uint, newType string, newID uint) ([]accessauditapp.IotCardChange, []accessauditapp.DeviceChange, error) {
|
||||
cards := make([]accessauditapp.IotCardChange, 0, 2)
|
||||
devices := make([]accessauditapp.DeviceChange, 0, 2)
|
||||
appendAsset := func(assetType string, assetID uint, role string) error {
|
||||
switch normalizeAssetType(assetType) {
|
||||
case assetTypeIotCard:
|
||||
card, err := s.readCard(ctx, tx, assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, role, nil, nil))
|
||||
case assetTypeDevice:
|
||||
device, err := s.readDevice(ctx, tx, assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, role, nil, nil))
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := appendAsset(oldType, oldID, constants.AuditResourceRolePersonalCustomerOldAsset); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := appendAsset(newType, newID, constants.AuditResourceRolePersonalCustomerNewAsset); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cards, devices, nil
|
||||
}
|
||||
|
||||
func (s *Service) writeMigrationAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
customerID uint,
|
||||
oldType string,
|
||||
oldID uint,
|
||||
newType string,
|
||||
newID uint,
|
||||
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
|
||||
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
|
||||
) error {
|
||||
cards, devices, err := s.loadAuditAssets(ctx, tx, oldType, oldID, newType, newID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeBindingAudit(
|
||||
ctx, tx, constants.AuditActionPersonalCustomerAssetBindingMigrated, "换货迁移个人客户资产绑定",
|
||||
customerID, personalDevices, personalICCIDs, cards, devices,
|
||||
)
|
||||
}
|
||||
|
||||
// UnbindByVirtualNo 按现有换货重置语义删除设备号绑定,并在同一事务记录实际删除关系。
|
||||
func (s *Service) UnbindByVirtualNo(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, virtualNo string) error {
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
|
||||
}
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
records, err := s.makePCD(tx).GetByDeviceNo(ctx, virtualNo)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户资产绑定失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Where("virtual_no = ?", virtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
|
||||
}
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
cards := []accessauditapp.IotCardChange(nil)
|
||||
devices := []accessauditapp.DeviceChange(nil)
|
||||
switch normalizeAssetType(assetType) {
|
||||
case assetTypeIotCard:
|
||||
card, loadErr := s.readCard(ctx, tx, assetID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
|
||||
case assetTypeDevice:
|
||||
device, loadErr := s.readDevice(ctx, tx, assetID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
}
|
||||
if err := s.writeBindingAudit(
|
||||
ctx, tx, constants.AuditActionPersonalCustomerAssetUnbound, "解除个人客户资产绑定", record.CustomerID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
|
||||
BeforeData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
|
||||
AfterData: map[string]any{"deleted": true},
|
||||
}}, nil, cards, devices,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -67,8 +68,9 @@ type Service struct {
|
||||
makePCI func(*gorm.DB) pciOps
|
||||
markAsSold func(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error
|
||||
// readCard/readDevice 用于 Bind/Migrate 内部,接收事务 db 以保持读写在同一事务内
|
||||
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
|
||||
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
|
||||
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
|
||||
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
|
||||
accessAudit accessauditapp.Writer
|
||||
}
|
||||
|
||||
// New 创建客户绑定服务实例
|
||||
@@ -216,6 +218,9 @@ func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.Per
|
||||
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
|
||||
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccid(Issue 02)
|
||||
func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
|
||||
}
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
@@ -229,10 +234,10 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
|
||||
return err
|
||||
}
|
||||
if card.VirtualNo != "" {
|
||||
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID)
|
||||
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID, card, nil)
|
||||
}
|
||||
// 无虚拟号路径(Issue 02)
|
||||
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID)
|
||||
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID, card)
|
||||
|
||||
case assetTypeDevice:
|
||||
device, err := s.readDevice(ctx, tx, assetID)
|
||||
@@ -243,14 +248,14 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
|
||||
if key == "" {
|
||||
key = device.IMEI
|
||||
}
|
||||
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID)
|
||||
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID, nil, device)
|
||||
}
|
||||
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
// bindViaPCD 通过 tb_personal_customer_device 创建绑定
|
||||
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint) error {
|
||||
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint, card *model.IotCard, device *model.Device) error {
|
||||
count, err := pcd.CountByVirtualNo(ctx, virtualNo)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定数量失败")
|
||||
@@ -262,8 +267,9 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
|
||||
}
|
||||
|
||||
var record *model.PersonalCustomerDevice
|
||||
if !exists {
|
||||
record := &model.PersonalCustomerDevice{
|
||||
record = &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
VirtualNo: virtualNo,
|
||||
Status: 1,
|
||||
@@ -274,14 +280,30 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
|
||||
}
|
||||
|
||||
if firstEverBind {
|
||||
return s.markAsSold(ctx, tx, assetType, assetID)
|
||||
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
cards := []accessauditapp.IotCardChange(nil)
|
||||
devices := []accessauditapp.DeviceChange(nil)
|
||||
if card != nil {
|
||||
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
|
||||
}
|
||||
if device != nil {
|
||||
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
|
||||
}
|
||||
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
|
||||
AfterData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
|
||||
}}, nil, cards, devices)
|
||||
}
|
||||
|
||||
// bindViaPCI 通过 tb_personal_customer_iccid 创建绑定(无虚拟号卡专用,Issue 02)
|
||||
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint) error {
|
||||
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint, card *model.IotCard) error {
|
||||
count, err := pci.CountByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询 ICCID 绑定数量失败")
|
||||
@@ -293,12 +315,13 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询客户 ICCID 绑定关系失败")
|
||||
}
|
||||
|
||||
var record *model.PersonalCustomerICCID
|
||||
if !exists {
|
||||
iccid19 := iccid
|
||||
if len(iccid) == 20 {
|
||||
iccid19 = iccid[:19]
|
||||
}
|
||||
record := &model.PersonalCustomerICCID{
|
||||
record = &model.PersonalCustomerICCID{
|
||||
CustomerID: customerID,
|
||||
ICCID: iccid,
|
||||
ICCID19: iccid19,
|
||||
@@ -310,15 +333,28 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
|
||||
}
|
||||
|
||||
if firstEverBind {
|
||||
return s.markAsSold(ctx, tx, assetType, assetID)
|
||||
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
|
||||
nil, []accessauditapp.PersonalCustomerICCIDChange{{
|
||||
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
|
||||
AfterData: map[string]any{"iccid": record.ICCID, "status": record.Status},
|
||||
}}, []accessauditapp.IotCardChange{
|
||||
cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用)
|
||||
// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci
|
||||
func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
|
||||
if s.accessAudit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
|
||||
}
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
@@ -332,9 +368,9 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
|
||||
return err
|
||||
}
|
||||
if oldCard.VirtualNo != "" {
|
||||
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, newAssetType, newAssetID)
|
||||
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, oldAssetType, oldAssetID, newAssetType, newAssetID)
|
||||
}
|
||||
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, newAssetType, newAssetID)
|
||||
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, oldAssetType, oldAssetID, newAssetType, newAssetID)
|
||||
|
||||
case assetTypeDevice:
|
||||
oldDevice, err := s.readDevice(ctx, tx, oldAssetID)
|
||||
@@ -345,14 +381,14 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
|
||||
if key == "" {
|
||||
key = oldDevice.IMEI
|
||||
}
|
||||
return s.migrateFromPCD(ctx, tx, pcd, pci, key, newAssetType, newAssetID)
|
||||
return s.migrateFromPCD(ctx, tx, pcd, pci, key, oldAssetType, oldAssetID, newAssetType, newAssetID)
|
||||
}
|
||||
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产
|
||||
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, newAssetType string, newAssetID uint) error {
|
||||
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
|
||||
records, err := pcd.GetByDeviceNo(ctx, oldKey)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询旧资产绑定记录失败")
|
||||
@@ -381,6 +417,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newCard.VirtualNo); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "迁移客户绑定关系失败")
|
||||
}
|
||||
after := *rec
|
||||
after.VirtualNo = newCard.VirtualNo
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
|
||||
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
|
||||
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
|
||||
}}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 新卡无虚拟号:禁用旧 pcd + 创建新 pci
|
||||
@@ -401,6 +447,17 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pci.Create(ctx, newPCI); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
|
||||
}
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
|
||||
AfterData: map[string]any{"virtual_no": rec.VirtualNo, "status": 0},
|
||||
}}, []accessauditapp.PersonalCustomerICCIDChange{{
|
||||
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +474,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newKey); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "迁移设备客户绑定关系失败")
|
||||
}
|
||||
after := *rec
|
||||
after.VirtualNo = newKey
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
|
||||
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
|
||||
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
|
||||
}}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -427,7 +494,7 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
}
|
||||
|
||||
// migrateFromPCI 将 tb_personal_customer_iccid 中 oldICCID 的所有有效绑定迁移到新资产
|
||||
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, newAssetType string, newAssetID uint) error {
|
||||
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
|
||||
records, err := pci.GetByICCID(ctx, oldICCID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 绑定记录失败")
|
||||
@@ -463,6 +530,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pcd.Create(ctx, newPCD); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
|
||||
}
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
|
||||
}}, []accessauditapp.PersonalCustomerICCIDChange{{
|
||||
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
|
||||
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 新卡也无虚拟号:禁用旧 pci + 创建新 pci(新 ICCID)
|
||||
@@ -483,6 +561,20 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pci.Create(ctx, newPCI); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建新 ICCID 绑定关系失败")
|
||||
}
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
nil, []accessauditapp.PersonalCustomerICCIDChange{
|
||||
{
|
||||
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
|
||||
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
|
||||
},
|
||||
{
|
||||
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +599,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
|
||||
if err := pcd.Create(ctx, newPCD); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
|
||||
}
|
||||
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
|
||||
[]accessauditapp.PersonalCustomerDeviceChange{{
|
||||
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
|
||||
}}, []accessauditapp.PersonalCustomerICCIDChange{{
|
||||
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
|
||||
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
|
||||
}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func (s *Service) appendCSVBatchAllocationAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
devices []*model.Device,
|
||||
succeededIDs []uint,
|
||||
failedItems []dto.AllocationDeviceFailedItem,
|
||||
targetShopID uint,
|
||||
) error {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
|
||||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
|
||||
linkage.CorrelationID == "" {
|
||||
return nil
|
||||
}
|
||||
devicesByID := make(map[uint]*model.Device, len(devices))
|
||||
for _, device := range devices {
|
||||
if device != nil {
|
||||
devicesByID[device.ID] = device
|
||||
}
|
||||
}
|
||||
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
|
||||
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
|
||||
for _, deviceID := range succeededIDs {
|
||||
if device := devicesByID[deviceID]; device != nil {
|
||||
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
|
||||
}
|
||||
}
|
||||
for _, item := range failedItems {
|
||||
if device := devicesByID[item.DeviceID]; device != nil {
|
||||
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
|
||||
}
|
||||
}
|
||||
result := constants.AuditResultSuccess
|
||||
if len(succeededIDs) > 0 && len(failedItems) > 0 {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
|
||||
Summary: "设备CSV批量分配完成", Result: result,
|
||||
CorrelationID: linkage.CorrelationID,
|
||||
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
|
||||
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
|
||||
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func deviceBatchChild(
|
||||
device *model.Device,
|
||||
parentEventID string,
|
||||
correlationID string,
|
||||
targetShopID uint,
|
||||
succeeded bool,
|
||||
reason string,
|
||||
) audit.AppendInput {
|
||||
resourceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
|
||||
after := before
|
||||
result := constants.AuditResultFailed
|
||||
summary := "设备批量分配失败"
|
||||
if succeeded {
|
||||
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
|
||||
result = constants.AuditResultSuccess
|
||||
summary = "设备批量分配成功"
|
||||
}
|
||||
return audit.AppendInput{
|
||||
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
|
||||
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
|
||||
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
|
||||
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func stableBatchEventID(kind, key string) string {
|
||||
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
|
||||
}
|
||||
|
||||
func deviceAuditKey(device *model.Device) string {
|
||||
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(device.ID), 10)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"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/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
@@ -80,105 +81,29 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeNotFound, "设备不存在")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"device_id": deviceID,
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"device_id": deviceID,
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
metadata := map[string]any{"iot_card_id": req.IotCardID, "slot_position": req.SlotPosition}
|
||||
|
||||
if req.SlotPosition > device.MaxSimSlots {
|
||||
appErr := errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
|
||||
device, nil, nil, metadata, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, nil, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
if existingBinding != nil {
|
||||
appErr := errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
|
||||
device, nil, nil, metadata, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
@@ -186,88 +111,30 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeIotCardNotFound)
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, nil, metadata, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, nil, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
item := deviceBindingAuditItem{
|
||||
Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard,
|
||||
CardBefore: map[string]any{"device_id": nil, "slot_position": nil},
|
||||
CardAfter: map[string]any{"device_id": device.ID, "slot_position": req.SlotPosition},
|
||||
}
|
||||
|
||||
activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, []deviceBindingAuditItem{item}, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
if activeBinding != nil {
|
||||
appErr := errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{
|
||||
"device": deviceSnapshot(device),
|
||||
"card": map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"status": card.Status,
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"iot_card_id": req.IotCardID,
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
|
||||
device, nil, []deviceBindingAuditItem{item}, metadata, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
@@ -278,29 +145,26 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
BindStatus: 1,
|
||||
}
|
||||
|
||||
if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewDeviceSimBindingStore(tx, nil).Create(ctx, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
item.Binding = binding
|
||||
item.BindingRole = constants.AuditResourceRoleDeviceCreatedBinding
|
||||
item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, false)
|
||||
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditResultSuccess,
|
||||
device,
|
||||
map[string]any{
|
||||
"device": deviceSnapshot(device),
|
||||
"card": map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"status": card.Status,
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"slot_position": req.SlotPosition,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": nil},
|
||||
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": card.ID},
|
||||
[]deviceBindingAuditItem{item}, metadata, nil)
|
||||
})
|
||||
if err != nil {
|
||||
result := constants.AuditResultFailed
|
||||
if appErr, ok := err.(*errors.AppError); ok && (appErr.Code == errors.CodeConflict || appErr.Code == errors.CodeIotCardBoundToDevice) {
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", result,
|
||||
device, nil, []deviceBindingAuditItem{{Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard}}, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -313,32 +177,6 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
)
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceBindCard,
|
||||
"设备绑卡",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{
|
||||
"device": deviceSnapshot(device),
|
||||
"card": map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"status": card.Status,
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"binding_id": binding.ID,
|
||||
"slot_position": req.SlotPosition,
|
||||
"iot_card_id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
},
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
|
||||
return &dto.BindCardToDeviceResponse{
|
||||
BindingID: binding.ID,
|
||||
Message: "绑定成功",
|
||||
@@ -349,111 +187,54 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeNotFound, "设备不存在")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"device_id": deviceID,
|
||||
"iot_card_id": cardID,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"device_id": deviceID,
|
||||
"iot_card_id": cardID,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
metadata := map[string]any{"iot_card_id": cardID}
|
||||
|
||||
binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeNotFound, "该卡未绑定到此设备")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"iot_card_id": cardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡被拒绝", constants.AuditResultDenied,
|
||||
device, nil, nil, metadata, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"iot_card_id": cardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, nil, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cardAudit map[string]any
|
||||
if card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID); cardErr == nil {
|
||||
cardAudit = map[string]any{
|
||||
"id": card.ID,
|
||||
"iccid": card.ICCID,
|
||||
"status": card.Status,
|
||||
card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID)
|
||||
if cardErr != nil {
|
||||
card = &model.IotCard{}
|
||||
card.ID = binding.IotCardID
|
||||
}
|
||||
item := deviceBindingAuditItem{
|
||||
Card: card, Binding: binding,
|
||||
CardRole: constants.AuditResourceRoleDeviceBindingTargetCard, BindingRole: constants.AuditResourceRoleDeviceRemovedBinding,
|
||||
CardBefore: map[string]any{"device_id": device.ID, "slot_position": binding.SlotPosition},
|
||||
CardAfter: map[string]any{"device_id": nil, "slot_position": nil},
|
||||
BindingBefore: bindingStateData(binding, constants.BindStatusBound, binding.IsCurrent),
|
||||
BindingAfter: bindingStateData(binding, constants.BindStatusUnbound, false),
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewDeviceSimBindingStore(tx, nil).Unbind(ctx, binding.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
beforeAuditData := map[string]any{
|
||||
"device": deviceSnapshot(device),
|
||||
"binding_id": binding.ID,
|
||||
"iot_card_id": binding.IotCardID,
|
||||
}
|
||||
if cardAudit != nil {
|
||||
beforeAuditData["card"] = cardAudit
|
||||
}
|
||||
|
||||
if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", binding.ID).Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditResultSuccess,
|
||||
device,
|
||||
beforeAuditData,
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID},
|
||||
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": nil},
|
||||
[]deviceBindingAuditItem{item}, metadata, nil)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
|
||||
device, nil, []deviceBindingAuditItem{item}, metadata, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -466,28 +247,6 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
|
||||
)
|
||||
}
|
||||
|
||||
afterAuditData := map[string]any{
|
||||
"iot_card_id": cardID,
|
||||
"unbind": true,
|
||||
}
|
||||
if cardAudit != nil {
|
||||
afterAuditData["card"] = cardAudit
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceUnbindCard,
|
||||
"设备解绑卡",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
beforeAuditData,
|
||||
afterAuditData,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
|
||||
return &dto.UnbindCardFromDeviceResponse{
|
||||
Message: "解绑成功",
|
||||
}, nil
|
||||
|
||||
236
internal/service/device/binding_audit.go
Normal file
236
internal/service/device/binding_audit.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
type deviceBindingAuditItem struct {
|
||||
Card *model.IotCard
|
||||
Binding *model.DeviceSimBinding
|
||||
CardRole string
|
||||
BindingRole string
|
||||
CardBefore map[string]any
|
||||
CardAfter map[string]any
|
||||
BindingBefore map[string]any
|
||||
BindingAfter map[string]any
|
||||
}
|
||||
|
||||
type deviceBindingState struct {
|
||||
bindings []*model.DeviceSimBinding
|
||||
cards map[uint]*model.IotCard
|
||||
target *model.DeviceSimBinding
|
||||
current *model.DeviceSimBinding
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceBindingAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
device *model.Device,
|
||||
deviceBefore, deviceAfter map[string]any,
|
||||
items []deviceBindingAuditItem,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
deviceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &deviceID,
|
||||
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: deviceBefore, AfterData: deviceAfter,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
for index, item := range items {
|
||||
if item.Card != nil && item.Card.ID > 0 {
|
||||
cardID := strconv.FormatUint(uint64(item.Card.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID,
|
||||
Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: item.CardRole,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), BeforeData: item.CardBefore, AfterData: item.CardAfter,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, SortOrder: index*2 + 1,
|
||||
})
|
||||
}
|
||||
if item.Binding != nil && item.Binding.ID > 0 {
|
||||
bindingID := strconv.FormatUint(uint64(item.Binding.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
|
||||
Key: bindingID, DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: item.BindingRole,
|
||||
IdentitySnapshot: deviceBindingIdentity(device, item.Card, item.Binding),
|
||||
BeforeData: item.BindingBefore, AfterData: item.BindingAfter,
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index*2 + 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Metadata: metadata, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceBindingAuditFailure(
|
||||
ctx context.Context,
|
||||
actionCode, summary, result string,
|
||||
device *model.Device,
|
||||
deviceBefore map[string]any,
|
||||
items []deviceBindingAuditItem,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
deviceID := uint(0)
|
||||
if device != nil {
|
||||
deviceID = device.ID
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil || deviceID == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceBindingAudit(ctx, tx, actionCode, summary, result, device, deviceBefore, nil, items, metadata, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceBindingIdentity(device *model.Device, card *model.IotCard, binding *model.DeviceSimBinding) map[string]any {
|
||||
identity := map[string]any{
|
||||
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
|
||||
"iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent,
|
||||
}
|
||||
if device != nil {
|
||||
identity["device_virtual_no"] = device.VirtualNo
|
||||
}
|
||||
if card != nil {
|
||||
identity["iccid"] = card.ICCID
|
||||
identity["virtual_no"] = card.VirtualNo
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func bindingStateData(binding *model.DeviceSimBinding, bindStatus int, isCurrent bool) map[string]any {
|
||||
return map[string]any{
|
||||
"slot_position": binding.SlotPosition,
|
||||
"bind_status": bindStatus,
|
||||
"is_current": isCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
func loadDeviceBindingState(ctx context.Context, db *gorm.DB, deviceID uint, targetICCID string, lock bool) (*deviceBindingState, error) {
|
||||
query := db.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).Order("slot_position ASC")
|
||||
if lock {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
state := &deviceBindingState{cards: make(map[uint]*model.IotCard)}
|
||||
if err := query.Find(&state.bindings).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡槽关系失败")
|
||||
}
|
||||
cardIDs := make([]uint, 0, len(state.bindings))
|
||||
for _, binding := range state.bindings {
|
||||
cardIDs = append(cardIDs, binding.IotCardID)
|
||||
if binding.IsCurrent {
|
||||
state.current = binding
|
||||
}
|
||||
}
|
||||
if len(cardIDs) > 0 {
|
||||
var cards []*model.IotCard
|
||||
if err := db.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
for _, card := range cards {
|
||||
state.cards[card.ID] = card
|
||||
}
|
||||
}
|
||||
targetICCID = strings.TrimSpace(targetICCID)
|
||||
for _, binding := range state.bindings {
|
||||
if card := state.cards[binding.IotCardID]; card != nil && cardMatchesICCID(card, targetICCID) {
|
||||
state.target = binding
|
||||
break
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func switchCardAuditItems(state *deviceBindingState) []deviceBindingAuditItem {
|
||||
items := make([]deviceBindingAuditItem, 0, 2)
|
||||
if state.current != nil {
|
||||
oldCurrentAfter := false
|
||||
if state.target != nil && state.current.ID == state.target.ID {
|
||||
oldCurrentAfter = true
|
||||
}
|
||||
items = append(items, deviceBindingAuditItem{
|
||||
Card: state.cards[state.current.IotCardID], Binding: state.current,
|
||||
CardRole: constants.AuditResourceRoleDeviceOldCurrentCard, BindingRole: constants.AuditResourceRoleDeviceOldCurrentBinding,
|
||||
CardBefore: map[string]any{"is_current": true}, CardAfter: map[string]any{"is_current": oldCurrentAfter},
|
||||
BindingBefore: bindingStateData(state.current, constants.BindStatusBound, true),
|
||||
BindingAfter: bindingStateData(state.current, constants.BindStatusBound, oldCurrentAfter),
|
||||
})
|
||||
}
|
||||
if state.target != nil {
|
||||
wasCurrent := state.target.IsCurrent
|
||||
items = append(items, deviceBindingAuditItem{
|
||||
Card: state.cards[state.target.IotCardID], Binding: state.target,
|
||||
CardRole: constants.AuditResourceRoleDeviceNewCurrentCard, BindingRole: constants.AuditResourceRoleDeviceNewCurrentBinding,
|
||||
CardBefore: map[string]any{"is_current": wasCurrent}, CardAfter: map[string]any{"is_current": true},
|
||||
BindingBefore: bindingStateData(state.target, constants.BindStatusBound, wasCurrent),
|
||||
BindingAfter: bindingStateData(state.target, constants.BindStatusBound, true),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func currentCardID(state *deviceBindingState) uint {
|
||||
if state == nil || state.current == nil {
|
||||
return 0
|
||||
}
|
||||
return state.current.IotCardID
|
||||
}
|
||||
|
||||
func loadDeviceUnbindAuditReferences(ctx context.Context, tx *gorm.DB, device *model.Device) ([]audit.ResourceInput, error) {
|
||||
referencesByDevice, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
references := referencesByDevice[device.ID]
|
||||
for index := range references {
|
||||
resource := &references[index]
|
||||
resource.Relation = constants.AuditResourceRelationAffected
|
||||
switch resource.Type {
|
||||
case constants.AuditResourceIotCard:
|
||||
resource.Role = constants.AuditResourceRoleDeviceBindingTargetCard
|
||||
resource.BeforeData = map[string]any{"device_id": device.ID}
|
||||
resource.AfterData = map[string]any{"device_id": nil}
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = "设备删除并解绑 IoT 卡"
|
||||
case constants.AuditResourceDeviceSIMBinding:
|
||||
resource.Role = constants.AuditResourceRoleDeviceRemovedBinding
|
||||
resource.BeforeData = map[string]any{
|
||||
"slot_position": resource.IdentitySnapshot["slot_position"],
|
||||
"bind_status": constants.BindStatusBound,
|
||||
"is_current": resource.IdentitySnapshot["is_current"],
|
||||
}
|
||||
resource.AfterData = map[string]any{
|
||||
"slot_position": resource.IdentitySnapshot["slot_position"],
|
||||
"bind_status": constants.BindStatusUnbound,
|
||||
"is_current": false,
|
||||
}
|
||||
}
|
||||
}
|
||||
return references, nil
|
||||
}
|
||||
294
internal/service/device/gateway_audit.go
Normal file
294
internal/service/device/gateway_audit.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
type deviceGatewayIntegrationLog interface {
|
||||
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
|
||||
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
|
||||
}
|
||||
|
||||
// SetGatewayIntegrationLog 注入设备外部命令的 Integration Log 接缝。
|
||||
func (s *Service) SetGatewayIntegrationLog(integration deviceGatewayIntegrationLog) {
|
||||
s.gatewayIntegration = integration
|
||||
}
|
||||
|
||||
type deviceGatewayResource struct {
|
||||
Type string
|
||||
ID string
|
||||
Key string
|
||||
ExternalID string
|
||||
RequestSummary map[string]any
|
||||
}
|
||||
|
||||
type deviceGatewayAttempt struct {
|
||||
log *model.IntegrationLog
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
type deviceGatewayAttemptObserver struct {
|
||||
service *Service
|
||||
operation string
|
||||
scene string
|
||||
seriesKey string
|
||||
resource deviceGatewayResource
|
||||
current *deviceGatewayAttempt
|
||||
successful *deviceGatewayAttempt
|
||||
integration string
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (o *deviceGatewayAttemptObserver) BeforeAttempt(ctx context.Context, attempt int) error {
|
||||
started, err := o.service.startDeviceGatewayAttempt(ctx, o.operation, o.scene, o.seriesKey, attempt, o.resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.current = started
|
||||
o.integration = started.log.IntegrationID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *deviceGatewayAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
|
||||
if isDeviceGatewayTimeout(callErr) {
|
||||
o.unknown = true
|
||||
}
|
||||
if callErr == nil {
|
||||
o.successful = o.current
|
||||
o.current = nil
|
||||
return nil
|
||||
}
|
||||
err := o.service.completeDeviceGatewayAttempt(ctx, o.current, callErr, false)
|
||||
o.current = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (o *deviceGatewayAttemptObserver) completeSuccess(ctx context.Context, stateChanged bool) error {
|
||||
return o.service.completeDeviceGatewayAttempt(ctx, o.successful, nil, stateChanged)
|
||||
}
|
||||
|
||||
func (s *Service) startDeviceGatewayAttempt(
|
||||
ctx context.Context,
|
||||
operation, scene, seriesKey string,
|
||||
attempt int,
|
||||
resource deviceGatewayResource,
|
||||
) (*deviceGatewayAttempt, error) {
|
||||
if s == nil || s.gatewayIntegration == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "设备 Gateway Integration Log 接缝未配置")
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
triggerSource := linkage.Source
|
||||
if triggerSource == "" {
|
||||
triggerSource = "service"
|
||||
}
|
||||
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-device-command:"+seriesKey+":"+operation+":"+resource.Type+":"+resource.ID)).String()
|
||||
var requestID, correlationID *string
|
||||
if linkage.RequestID != "" {
|
||||
requestID = &linkage.RequestID
|
||||
}
|
||||
if linkage.CorrelationID != "" {
|
||||
correlationID = &linkage.CorrelationID
|
||||
} else {
|
||||
correlationID = requestID
|
||||
}
|
||||
log, err := s.gatewayIntegration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operation, ExternalID: &resource.ExternalID,
|
||||
ResourceType: resource.Type, ResourceID: &resource.ID, ResourceKey: &resource.Key,
|
||||
TriggerSource: &triggerSource, TriggerScene: &scene, TriggerSeries: &triggerSeries,
|
||||
Attempt: attempt, RequestID: requestID, CorrelationID: correlationID,
|
||||
RequestSummary: resource.RequestSummary,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &deviceGatewayAttempt{log: log, startedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) completeDeviceGatewayAttempt(ctx context.Context, attempt *deviceGatewayAttempt, callErr error, stateChanged bool) error {
|
||||
if attempt == nil || attempt.log == nil {
|
||||
return nil
|
||||
}
|
||||
completion := integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
|
||||
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
|
||||
}
|
||||
if callErr != nil {
|
||||
completion.Result = constants.IntegrationResultFailed
|
||||
completion.SafeProviderMessage = "Gateway 设备命令失败"
|
||||
completion.ResponseSummary = map[string]any{"result": "failed"}
|
||||
if isDeviceGatewayTimeout(callErr) {
|
||||
completion.Result = constants.IntegrationResultUnknown
|
||||
completion.SafeProviderMessage = "Gateway 设备命令结果未知"
|
||||
completion.ResponseSummary = map[string]any{"result": "unknown"}
|
||||
completion.RecoveryStrategy = constants.GatewayDeviceCommandUnknownRecoveryStrategy
|
||||
}
|
||||
}
|
||||
_, err := s.gatewayIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
type deviceGatewayCommand struct {
|
||||
ActionCode string
|
||||
Summary string
|
||||
Operation string
|
||||
Scene string
|
||||
RequestSummary map[string]any
|
||||
Metadata map[string]any
|
||||
TargetCard *model.IotCard
|
||||
Call func(context.Context) error
|
||||
}
|
||||
|
||||
func (s *Service) executeDeviceGatewayCommand(ctx context.Context, device *model.Device, command deviceGatewayCommand) error {
|
||||
deviceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
seriesKey := deviceCommandSeriesKey(ctx)
|
||||
observer := &deviceGatewayAttemptObserver{
|
||||
service: s, operation: command.Operation, scene: command.Scene, seriesKey: seriesKey,
|
||||
resource: deviceGatewayResource{
|
||||
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device),
|
||||
ExternalID: device.IMEI, RequestSummary: command.RequestSummary,
|
||||
},
|
||||
}
|
||||
callErr := command.Call(gateway.WithAttemptObserver(ctx, observer))
|
||||
metadata := cloneDeviceCommandMetadata(command.Metadata)
|
||||
metadata["integration_id"] = observer.integration
|
||||
if callErr != nil {
|
||||
result := constants.AuditResultFailed
|
||||
summary := command.Summary + "失败"
|
||||
if observer.unknown {
|
||||
result = constants.AuditResultUnknown
|
||||
summary = command.Summary + "结果未知"
|
||||
}
|
||||
s.recordDeviceCommandAudit(ctx, command.ActionCode, summary, result, device, command.TargetCard, nil, nil, metadata, callErr)
|
||||
return callErr
|
||||
}
|
||||
if err := observer.completeSuccess(ctx, false); err != nil {
|
||||
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary+"结果未知", constants.AuditResultUnknown,
|
||||
device, command.TargetCard, nil, nil, metadata, err)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "终结设备 Gateway Integration Log 失败")
|
||||
}
|
||||
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary, constants.AuditResultSuccess,
|
||||
device, command.TargetCard, nil, nil, metadata, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneDeviceCommandMetadata(source map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(source)+1)
|
||||
for key, value := range source {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func deviceCommandSeriesKey(ctx context.Context) string {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if linkage.CorrelationID != "" {
|
||||
return linkage.CorrelationID
|
||||
}
|
||||
if linkage.RequestID != "" {
|
||||
return linkage.RequestID
|
||||
}
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func isDeviceGatewayTimeout(err error) bool {
|
||||
var appErr *errors.AppError
|
||||
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == errors.CodeGatewayTimeout
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceCommandAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
device *model.Device,
|
||||
targetCard *model.IotCard,
|
||||
cardBefore, cardAfter, metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if targetCard != nil && targetCard.ID > 0 {
|
||||
targetID := strconv.FormatUint(uint64(targetCard.ID), 10)
|
||||
found := false
|
||||
for i := range cardReferences[device.ID] {
|
||||
resource := &cardReferences[device.ID][i]
|
||||
if resource.Type == constants.AuditResourceIotCard && resource.ID != nil && *resource.ID == targetID {
|
||||
found = true
|
||||
if cardBefore != nil || cardAfter != nil {
|
||||
resource.Relation = constants.AuditResourceRelationAffected
|
||||
resource.BeforeData = cardBefore
|
||||
resource.AfterData = cardAfter
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = summary
|
||||
} else {
|
||||
resource.Role = constants.AuditResourceRoleDeviceCommandTargetCard
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cardReferences[device.ID] = append(cardReferences[device.ID], audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &targetID,
|
||||
Key: audit.IotCardResourceKey(targetCard), DisplayName: targetCard.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCommandTargetCard,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(targetCard),
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
}
|
||||
deviceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &deviceID,
|
||||
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
resources = append(resources, cardReferences[device.ID]...)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Metadata: metadata, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceCommandAudit(
|
||||
ctx context.Context,
|
||||
actionCode, summary, result string,
|
||||
device *model.Device,
|
||||
targetCard *model.IotCard,
|
||||
cardBefore, cardAfter, metadata map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceCommandAudit(ctx, tx, actionCode, summary, result, device, targetCard, cardBefore, cardAfter, metadata, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ deviceGatewayIntegrationLog = (*integrationlog.Repository)(nil)
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -51,73 +52,31 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g
|
||||
func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error {
|
||||
device, imei, err := s.getGatewayDevice(ctx, identifier)
|
||||
if err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSetWiFi,
|
||||
"设备设置WiFi失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"identifier": identifier,
|
||||
"ssid": req.SSID,
|
||||
"enabled": req.Enabled,
|
||||
"password": req.Password,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
|
||||
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
|
||||
CardNo: imei,
|
||||
Params: gateway.WiFiParams{
|
||||
SSIDName: req.SSID,
|
||||
SSIDPassword: req.Password,
|
||||
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
|
||||
ActionCode: constants.AuditActionDeviceWiFiSet,
|
||||
Summary: "设置设备 Wi-Fi",
|
||||
Operation: constants.IntegrationOperationGatewaySetWiFi,
|
||||
Scene: constants.CardObservationSceneDeviceSetWiFi,
|
||||
RequestSummary: map[string]any{
|
||||
"device_id": device.ID, "imei": imei, "ssid": req.SSID,
|
||||
"enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
|
||||
},
|
||||
}); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSetWiFi,
|
||||
"设备设置WiFi失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"imei": imei,
|
||||
"ssid": req.SSID,
|
||||
"enabled": req.Enabled,
|
||||
"password": req.Password,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
Metadata: map[string]any{
|
||||
"ssid": req.SSID, "enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
|
||||
},
|
||||
Call: func(callCtx context.Context) error {
|
||||
return s.gatewayClient.SetWiFi(callCtx, &gateway.WiFiReq{
|
||||
CardNo: imei,
|
||||
Params: gateway.WiFiParams{SSIDName: req.SSID, SSIDPassword: req.Password},
|
||||
})
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSetWiFi,
|
||||
"设备设置WiFi",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"imei": imei,
|
||||
"ssid": req.SSID,
|
||||
"enabled": req.Enabled,
|
||||
"password": req.Password,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
|
||||
return nil
|
||||
}
|
||||
@@ -126,58 +85,92 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
|
||||
func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req *dto.SwitchCardRequest) error {
|
||||
device, imei, err := s.getGatewayDevice(ctx, identifier)
|
||||
if err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchCard,
|
||||
"设备切卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"identifier": identifier,
|
||||
"target_iccid": req.TargetICCID,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
state, err := loadDeviceBindingState(ctx, s.db, device.ID, req.TargetICCID, false)
|
||||
metadata := map[string]any{"target_iccid": req.TargetICCID}
|
||||
if err != nil {
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
|
||||
device, nil, nil, metadata, err)
|
||||
return err
|
||||
}
|
||||
if state.target == nil {
|
||||
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备")
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡被拒绝", constants.AuditResultDenied,
|
||||
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
|
||||
return appErr
|
||||
}
|
||||
targetCard := state.cards[state.target.IotCardID]
|
||||
if targetCard == nil {
|
||||
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
|
||||
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
|
||||
return appErr
|
||||
}
|
||||
metadata["target_iot_card_id"] = targetCard.ID
|
||||
metadata["target_slot_position"] = state.target.SlotPosition
|
||||
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
|
||||
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
|
||||
deviceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
observer := &deviceGatewayAttemptObserver{
|
||||
service: s, operation: constants.IntegrationOperationGatewaySwitchCard,
|
||||
scene: constants.CardObservationSceneDeviceSwitchCard, seriesKey: deviceCommandSeriesKey(ctx),
|
||||
resource: deviceGatewayResource{
|
||||
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device), ExternalID: imei,
|
||||
RequestSummary: map[string]any{
|
||||
"device_id": device.ID, "imei": imei, "target_iot_card_id": targetCard.ID,
|
||||
"target_iccid": targetCard.ICCID, "target_slot_position": state.target.SlotPosition,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err = s.gatewayClient.SwitchCard(gateway.WithAttemptObserver(ctx, observer), &gateway.SwitchCardReq{
|
||||
CardNo: imei,
|
||||
ICCID: req.TargetICCID,
|
||||
}); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchCard,
|
||||
"设备切卡失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"target_iccid": req.TargetICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
result, summary := constants.AuditResultFailed, "设备切卡失败"
|
||||
if observer.unknown {
|
||||
result, summary = constants.AuditResultUnknown, "设备切卡结果未知"
|
||||
}
|
||||
metadata["integration_id"] = observer.integration
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, summary, result,
|
||||
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
|
||||
return err
|
||||
}
|
||||
metadata["integration_id"] = observer.integration
|
||||
if err := observer.completeSuccess(ctx, false); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "终结设备切卡 Integration Log 失败")
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
|
||||
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
|
||||
return appErr
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedState, err := loadDeviceBindingState(ctx, tx, device.ID, targetCard.ICCID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lockedState.target == nil {
|
||||
return errors.New(errors.CodeConflict, "切卡期间目标卡绑定关系已变化")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
|
||||
Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Where("id = ? AND bind_status = ?", lockedState.target.ID, constants.BindStatusBound).
|
||||
Update("is_current", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", constants.AuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"current_iot_card_id": currentCardID(lockedState)},
|
||||
map[string]any{"current_iot_card_id": lockedState.target.IotCardID},
|
||||
switchCardAuditItems(lockedState), metadata, nil)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
|
||||
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchCard,
|
||||
"设备切卡",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"target_iccid": req.TargetICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
|
||||
return nil
|
||||
}
|
||||
@@ -186,54 +179,21 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
|
||||
func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) error {
|
||||
device, imei, err := s.getGatewayDevice(ctx, identifier)
|
||||
if err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReboot,
|
||||
"设备重启失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{"identifier": identifier},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
|
||||
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
|
||||
DeviceID: imei,
|
||||
}); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReboot,
|
||||
"设备重启失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
|
||||
ActionCode: constants.AuditActionDeviceRebooted, Summary: "重启设备",
|
||||
Operation: constants.IntegrationOperationGatewayReboot, Scene: constants.CardObservationSceneDeviceReboot,
|
||||
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
|
||||
Metadata: map[string]any{"requested_action": "reboot"},
|
||||
Call: func(callCtx context.Context) error {
|
||||
return s.gatewayClient.RebootDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReboot,
|
||||
"设备重启",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
|
||||
return nil
|
||||
}
|
||||
@@ -242,54 +202,21 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
|
||||
func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) error {
|
||||
device, imei, err := s.getGatewayDevice(ctx, identifier)
|
||||
if err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReset,
|
||||
"设备恢复出厂失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{"identifier": identifier},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
|
||||
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
|
||||
DeviceID: imei,
|
||||
}); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReset,
|
||||
"设备恢复出厂失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
|
||||
ActionCode: constants.AuditActionDeviceReset, Summary: "恢复设备出厂设置",
|
||||
Operation: constants.IntegrationOperationGatewayReset, Scene: constants.CardObservationSceneDeviceReset,
|
||||
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
|
||||
Metadata: map[string]any{"requested_action": "factory_reset"},
|
||||
Call: func(callCtx context.Context) error {
|
||||
return s.gatewayClient.ResetDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceReset,
|
||||
"设备恢复出厂",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
|
||||
return nil
|
||||
}
|
||||
@@ -303,244 +230,77 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
|
||||
|
||||
device, imei, err := s.getGatewayDevice(ctx, identifier)
|
||||
if err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{
|
||||
"identifier": identifier,
|
||||
"switch_mode": switchMode,
|
||||
"iot_card_id": req.IotCardID,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
recordRejected := func(summary, result string, businessErr error, targetCard *model.IotCard) error {
|
||||
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceSwitchModeSet, summary, result,
|
||||
device, targetCard, nil, nil,
|
||||
map[string]any{"requested_switch_mode": switchMode, "iot_card_id": req.IotCardID}, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
if req.SwitchMode == nil {
|
||||
appErr := errors.New(errors.CodeInvalidParam, "切卡模式不能为空")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
|
||||
}
|
||||
if switchMode != 0 && switchMode != 1 {
|
||||
appErr := errors.New(errors.CodeInvalidParam, "切卡模式仅支持0或1")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
|
||||
}
|
||||
if req.IotCardID == 0 {
|
||||
appErr := errors.New(errors.CodeInvalidParam, "目标卡资产ID不能为空")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
|
||||
}
|
||||
|
||||
targetCard, err := s.iotCardStore.GetByID(ctx, req.IotCardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询目标卡资产失败")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, nil)
|
||||
}
|
||||
if _, err = s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, targetCard.ID); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备,禁止设置切卡模式")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡绑定关系失败")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, targetCard)
|
||||
}
|
||||
if targetCard.ICCID == "" {
|
||||
appErr := errors.New(errors.CodeConflict, "目标卡资产缺少ICCID,无法设置切卡模式")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
|
||||
}
|
||||
if targetCard.NetworkStatus != constants.NetworkStatusOnline {
|
||||
appErr := errors.New(errors.CodeForbidden, "目标卡状态异常,仅正常状态的卡允许设置切卡模式")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"switch_mode": switchMode,
|
||||
"iot_card_id": req.IotCardID,
|
||||
"iccid": targetCard.ICCID,
|
||||
"network_status": targetCard.NetworkStatus,
|
||||
"real_name_status": targetCard.RealNameStatus,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
|
||||
}
|
||||
if targetCard.RealNameStatus != constants.RealNameStatusVerified {
|
||||
appErr := errors.New(errors.CodeForbidden, "目标卡未实名,禁止设置切卡模式")
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换被拒绝",
|
||||
constants.AssetAuditResultDenied,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{
|
||||
"switch_mode": switchMode,
|
||||
"iot_card_id": req.IotCardID,
|
||||
"iccid": targetCard.ICCID,
|
||||
"network_status": targetCard.NetworkStatus,
|
||||
"real_name_status": targetCard.RealNameStatus,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
appErr,
|
||||
)
|
||||
return appErr
|
||||
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
|
||||
}
|
||||
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
|
||||
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
|
||||
CardNo: imei,
|
||||
SwitchMode: strconv.Itoa(switchMode),
|
||||
ICCID: targetCard.ICCID,
|
||||
}); err != nil {
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换失败",
|
||||
constants.AssetAuditResultFailed,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
err,
|
||||
)
|
||||
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
|
||||
ActionCode: constants.AuditActionDeviceSwitchModeSet, Summary: "设置设备切卡模式",
|
||||
Operation: constants.IntegrationOperationGatewaySwitchMode, Scene: constants.CardObservationSceneDeviceSwitchMode,
|
||||
RequestSummary: map[string]any{
|
||||
"device_id": device.ID, "imei": imei, "switch_mode": switchMode,
|
||||
"iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"requested_switch_mode": switchMode, "iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
|
||||
},
|
||||
TargetCard: targetCard,
|
||||
Call: func(callCtx context.Context) error {
|
||||
return s.gatewayClient.SwitchMode(callCtx, &gateway.SwitchModeReq{
|
||||
CardNo: imei, SwitchMode: strconv.Itoa(switchMode), ICCID: targetCard.ICCID,
|
||||
})
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpDeviceSwitchMode,
|
||||
"设备切卡模式切换",
|
||||
constants.AssetAuditResultSuccess,
|
||||
device,
|
||||
map[string]any{"device": deviceSnapshot(device)},
|
||||
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var devices []*model.Device
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var devices []model.Device
|
||||
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.Device{})).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
if err := query.Where("id IN ?", ids).Find(&devices).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量设备资产失败")
|
||||
@@ -30,31 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
|
||||
if len(devices) != len(ids) {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
result := tx.Model(&model.Device{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
|
||||
changedIDs := make([]uint, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device != nil && device.RealnamePolicy != req.RealnamePolicy {
|
||||
changedIDs = append(changedIDs, device.ID)
|
||||
}
|
||||
}
|
||||
if result.RowsAffected != int64(len(ids)) {
|
||||
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
|
||||
if len(changedIDs) > 0 {
|
||||
result := tx.Model(&model.Device{}).Where("id IN ?", changedIDs).Update("realname_policy", req.RealnamePolicy)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(changedIDs)) {
|
||||
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return s.appendDeviceRealnamePolicyBatchAudit(ctx, tx, devices, req.RealnamePolicy)
|
||||
})
|
||||
if err != nil {
|
||||
result := constants.AuditResultFailed
|
||||
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden {
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordDeviceRealnamePolicyBatchFailure(ctx, devices, req.RealnamePolicy, result, err)
|
||||
return nil, err
|
||||
}
|
||||
s.logDeviceOperation(
|
||||
ctx,
|
||||
constants.AssetAuditOpAssetRealnamePolicy,
|
||||
"批量更新设备实名认证策略",
|
||||
constants.AssetAuditResultSuccess,
|
||||
nil,
|
||||
nil,
|
||||
map[string]any{"asset_ids": ids, "realname_policy": req.RealnamePolicy},
|
||||
len(ids),
|
||||
len(ids),
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
673
internal/service/device/unified_audit.go
Normal file
673
internal/service/device/unified_audit.go
Normal file
@@ -0,0 +1,673 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/internal/model/dto"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SetAccessAudit 注入设备身份生命周期的统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceLifecycleAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
device *model.Device,
|
||||
beforeData, afterData map[string]any,
|
||||
references []audit.ResourceInput,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &resourceID,
|
||||
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
resources = append(resources, references...)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceLifecycleFailure(ctx context.Context, actionCode, summary, result string, device *model.Device, deviceID uint, businessErr error) {
|
||||
if device == nil {
|
||||
device = &model.Device{}
|
||||
device.ID = deviceID
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil || device.ID == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceLifecycleAudit(ctx, tx, actionCode, summary, result, device, nil, nil, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordDeviceAuditSecondaryFailure(ctx context.Context, actionCode string, deviceID uint, businessErr, auditErr error) {
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
linkage := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
actionCode, strconv.FormatUint(uint64(deviceID), 10),
|
||||
linkage.RequestID, linkage.CorrelationID, errorCode, auditErr,
|
||||
)
|
||||
}
|
||||
|
||||
type deviceAuditOutcome struct {
|
||||
Result string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type deviceBatchAuditItem struct {
|
||||
Device *model.Device
|
||||
PrimaryRole string
|
||||
Result string
|
||||
Summary string
|
||||
ErrorSummary string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
References []audit.ResourceInput
|
||||
}
|
||||
|
||||
type deviceCardAuditChange struct {
|
||||
ShopID *uint
|
||||
Status int
|
||||
}
|
||||
|
||||
func deviceAuditOutcomes(devices []*model.Device, result, summary string) map[uint]deviceAuditOutcome {
|
||||
outcomes := make(map[uint]deviceAuditOutcome, len(devices))
|
||||
for _, device := range devices {
|
||||
if device != nil && device.ID > 0 {
|
||||
outcomes[device.ID] = deviceAuditOutcome{Result: result, Summary: summary}
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func setDeviceAuditOutcomes(outcomes map[uint]deviceAuditOutcome, ids []uint, result, summary string) {
|
||||
for _, id := range ids {
|
||||
if _, ok := outcomes[id]; ok {
|
||||
outcomes[id] = deviceAuditOutcome{Result: result, Summary: summary}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceAuditFailedItems(outcomes map[uint]deviceAuditOutcome, items []dto.AllocationDeviceFailedItem) {
|
||||
for _, item := range items {
|
||||
if _, ok := outcomes[item.DeviceID]; ok {
|
||||
outcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceModelsByIDs(devices []*model.Device, ids []uint) []*model.Device {
|
||||
wanted := make(map[uint]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
result := make([]*model.Device, 0, len(ids))
|
||||
for _, device := range devices {
|
||||
if device != nil {
|
||||
if _, ok := wanted[device.ID]; ok {
|
||||
result = append(result, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceTransferAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
devices []*model.Device,
|
||||
outcomes map[uint]deviceAuditOutcome,
|
||||
records []*model.AssetAllocationRecord,
|
||||
targetShopID *uint,
|
||||
newStatus, batchTotal, successCount, failCount int,
|
||||
cardReferences map[uint][]audit.ResourceInput,
|
||||
businessErr error,
|
||||
) error {
|
||||
if cardReferences == nil {
|
||||
var err error
|
||||
cardReferences, _, err = loadDeviceCardAuditReferences(ctx, tx, devices, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
shops, err := loadDeviceTransferAuditShops(ctx, tx, devices, targetShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordByDeviceID := make(map[uint]*model.AssetAllocationRecord, len(records))
|
||||
for _, record := range records {
|
||||
if record != nil {
|
||||
recordByDeviceID[record.AssetID] = record
|
||||
}
|
||||
}
|
||||
items := make([]deviceBatchAuditItem, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device == nil || device.ID == 0 {
|
||||
continue
|
||||
}
|
||||
outcome, ok := outcomes[device.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var afterData map[string]any
|
||||
if outcome.Result == constants.AuditResultSuccess {
|
||||
afterData = map[string]any{"shop_id": targetShopID, "status": newStatus}
|
||||
}
|
||||
references := deviceTransferAuditReferences(device, recordByDeviceID[device.ID], targetShopID, shops)
|
||||
references = append(references, cardReferences[device.ID]...)
|
||||
items = append(items, deviceBatchAuditItem{
|
||||
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTransferTarget,
|
||||
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
|
||||
BeforeData: map[string]any{"shop_id": device.ShopID, "status": device.Status}, AfterData: afterData,
|
||||
References: references,
|
||||
})
|
||||
}
|
||||
allocationNo := ""
|
||||
if len(records) > 0 && records[0] != nil {
|
||||
allocationNo = records[0].AllocationNo
|
||||
}
|
||||
return s.appendDeviceBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
|
||||
batchTotal, successCount, failCount, items,
|
||||
map[string]any{"allocation_no": allocationNo, "to_shop_id": targetShopID, "new_status": newStatus}, businessErr)
|
||||
}
|
||||
|
||||
func loadDeviceTransferAuditShops(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetShopID *uint) (map[uint]*model.Shop, error) {
|
||||
shopIDs := make(map[uint]struct{})
|
||||
if targetShopID != nil && *targetShopID > 0 {
|
||||
shopIDs[*targetShopID] = struct{}{}
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device != nil && device.ShopID != nil && *device.ShopID > 0 {
|
||||
shopIDs[*device.ShopID] = struct{}{}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(shopIDs))
|
||||
for id := range shopIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var rows []*model.Shop
|
||||
if len(ids) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
shops := make(map[uint]*model.Shop, len(rows))
|
||||
for _, shop := range rows {
|
||||
shops[shop.ID] = shop
|
||||
}
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
func deviceTransferAuditReferences(device *model.Device, record *model.AssetAllocationRecord, targetShopID *uint, shops map[uint]*model.Shop) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, 3)
|
||||
if record != nil && record.ID > 0 {
|
||||
recordID := strconv.FormatUint(uint64(record.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetAllocationRecord, ID: &recordID,
|
||||
Key: recordID, DisplayName: record.AllocationNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAllocationRecord,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": record.ID, "allocation_no": record.AllocationNo, "asset_type": record.AssetType,
|
||||
"asset_id": record.AssetID, "asset_identifier": record.AssetIdentifier,
|
||||
"from_owner_type": record.FromOwnerType, "from_owner_id": record.FromOwnerID,
|
||||
"to_owner_type": record.ToOwnerType, "to_owner_id": record.ToOwnerID,
|
||||
},
|
||||
AfterData: map[string]any{"created": true}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
if device.ShopID != nil && *device.ShopID > 0 {
|
||||
resources = appendDeviceShopAuditReference(resources, shops[*device.ShopID], *device.ShopID, constants.AuditResourceRoleTransferSourceShop)
|
||||
}
|
||||
if targetShopID != nil && *targetShopID > 0 {
|
||||
resources = appendDeviceShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func appendDeviceShopAuditReference(resources []audit.ResourceInput, shop *model.Shop, shopID uint, role string) []audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(shopID), 10)
|
||||
name := id
|
||||
identity := map[string]any{"id": shopID}
|
||||
if shop != nil {
|
||||
name = shop.ShopName
|
||||
identity = map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level}
|
||||
}
|
||||
return append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: role,
|
||||
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceBatchAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
items []deviceBatchAuditItem,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
linkage := auditcontext.From(ctx)
|
||||
batchKey := linkage.RequestID
|
||||
if batchKey == "" {
|
||||
batchKey = linkage.CorrelationID
|
||||
}
|
||||
if s.auditWriter == nil || batchKey == "" {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备批量审计上下文不完整")
|
||||
}
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
children := make([]audit.AppendInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Device == nil || item.Device.ID == 0 {
|
||||
continue
|
||||
}
|
||||
deviceID := strconv.FormatUint(uint64(item.Device.ID), 10)
|
||||
primaryRole := item.PrimaryRole
|
||||
if primaryRole == "" {
|
||||
primaryRole = constants.AuditResourceRoleDeviceTarget
|
||||
}
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &deviceID,
|
||||
Key: audit.DeviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: primaryRole,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(item.Device), BeforeData: item.BeforeData, AfterData: item.AfterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: item.Summary,
|
||||
}}
|
||||
resources = append(resources, item.References...)
|
||||
childErrorCode, childErrorSummary := "", ""
|
||||
if item.Result == constants.AuditResultFailed || item.Result == constants.AuditResultDenied {
|
||||
childErrorCode = errorCode
|
||||
childErrorSummary = item.ErrorSummary
|
||||
if childErrorSummary == "" {
|
||||
childErrorSummary = errorSummary
|
||||
}
|
||||
}
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: stableDeviceBatchEventID(kind+"-"+item.Result+"-device", batchKey+":"+deviceID),
|
||||
ActionCode: itemAction, Summary: item.Summary, ScopeType: constants.AuditScopePlatform, Result: item.Result,
|
||||
ErrorCode: childErrorCode, ErrorSummary: childErrorSummary, Resources: resources,
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: stableDeviceBatchEventID(kind+"-"+result, batchKey),
|
||||
ActionCode: rootAction, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: result,
|
||||
ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, Metadata: metadata,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDeviceBatch, Key: batchKey, DisplayName: batchKey,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceBatch,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"request_id": linkage.RequestID, "correlation_id": linkage.CorrelationID,
|
||||
"device_count": len(items), "operation_type": kind,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func loadDeviceCardAuditReferences(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
devices []*model.Device,
|
||||
change *deviceCardAuditChange,
|
||||
) (map[uint][]audit.ResourceInput, []uint, error) {
|
||||
deviceByID := make(map[uint]*model.Device, len(devices))
|
||||
deviceIDs := make([]uint, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device != nil && device.ID > 0 {
|
||||
deviceByID[device.ID] = device
|
||||
deviceIDs = append(deviceIDs, device.ID)
|
||||
}
|
||||
}
|
||||
result := make(map[uint][]audit.ResourceInput)
|
||||
if len(deviceIDs) == 0 {
|
||||
return result, nil, nil
|
||||
}
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if err := tx.WithContext(ctx).Where("device_id IN ? AND bind_status = ?", deviceIDs, 1).Find(&bindings).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
seenCards := make(map[uint]struct{}, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
if _, exists := seenCards[binding.IotCardID]; !exists {
|
||||
seenCards[binding.IotCardID] = struct{}{}
|
||||
cardIDs = append(cardIDs, binding.IotCardID)
|
||||
}
|
||||
}
|
||||
var cards []*model.IotCard
|
||||
if len(cardIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
cardByID := make(map[uint]*model.IotCard, len(cards))
|
||||
for _, card := range cards {
|
||||
cardByID[card.ID] = card
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
device := deviceByID[binding.DeviceID]
|
||||
card := cardByID[binding.IotCardID]
|
||||
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
|
||||
cardID := strconv.FormatUint(uint64(binding.IotCardID), 10)
|
||||
deviceVirtualNo, cardICCID, cardVirtualNo := "", "", ""
|
||||
if device != nil {
|
||||
deviceVirtualNo = device.VirtualNo
|
||||
}
|
||||
cardIdentity := map[string]any{"id": binding.IotCardID}
|
||||
cardKey, cardName := cardID, cardID
|
||||
if card != nil {
|
||||
cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo
|
||||
cardKey, cardName = audit.IotCardResourceKey(card), card.ICCID
|
||||
cardIdentity = audit.IotCardIdentitySnapshot(card)
|
||||
}
|
||||
cardRelation := constants.AuditResourceRelationReference
|
||||
var beforeData, afterData map[string]any
|
||||
if change != nil {
|
||||
cardRelation = constants.AuditResourceRelationAffected
|
||||
if card != nil {
|
||||
beforeData = map[string]any{"shop_id": card.ShopID, "status": card.Status}
|
||||
}
|
||||
afterData = map[string]any{"shop_id": change.ShopID, "status": change.Status}
|
||||
}
|
||||
result[binding.DeviceID] = append(result[binding.DeviceID],
|
||||
audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID, Key: cardKey, DisplayName: cardName,
|
||||
Relation: cardRelation, Role: constants.AuditResourceRoleDeviceBoundCard,
|
||||
IdentitySnapshot: cardIdentity, BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
},
|
||||
audit.ResourceInput{
|
||||
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
|
||||
Key: bindingID, DisplayName: deviceVirtualNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCardBinding,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": deviceVirtualNo,
|
||||
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
|
||||
"iccid": cardICCID, "virtual_no": cardVirtualNo, "is_current": binding.IsCurrent,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
},
|
||||
)
|
||||
}
|
||||
return result, cardIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceTransferAuditFailure(
|
||||
ctx context.Context,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
devices []*model.Device,
|
||||
outcomes map[uint]deviceAuditOutcome,
|
||||
targetShopID *uint,
|
||||
newStatus, batchTotal, successCount, failCount int,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备批量审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
|
||||
devices, outcomes, nil, targetShopID, newStatus, batchTotal, successCount, failCount, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func stableDeviceBatchEventID(kind, key string) string {
|
||||
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device:"+kind+":"+key)).String()
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceSeriesBindingAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
devices []*model.Device,
|
||||
outcomes map[uint]deviceAuditOutcome,
|
||||
seriesID *uint,
|
||||
result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
series, err := loadDeviceSeriesAuditResources(ctx, tx, devices, seriesID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]deviceBatchAuditItem, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device == nil || device.ID == 0 {
|
||||
continue
|
||||
}
|
||||
outcome, ok := outcomes[device.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var afterData map[string]any
|
||||
if outcome.Result == constants.AuditResultSuccess {
|
||||
afterData = map[string]any{"series_id": seriesID}
|
||||
}
|
||||
references := deviceSeriesAuditReferences(device.SeriesID, seriesID, series)
|
||||
references = append(references, cardReferences[device.ID]...)
|
||||
items = append(items, deviceBatchAuditItem{
|
||||
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceSeriesTarget,
|
||||
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
|
||||
BeforeData: map[string]any{"series_id": device.SeriesID}, AfterData: afterData,
|
||||
References: references,
|
||||
})
|
||||
}
|
||||
return s.appendDeviceBatchAudit(ctx, tx,
|
||||
constants.AuditActionDeviceSeriesBindingBatch,
|
||||
constants.AuditActionDeviceSeriesBound,
|
||||
"series-binding", "批量设置设备系列绑定", result,
|
||||
batchTotal, successCount, failCount, items, metadata, businessErr)
|
||||
}
|
||||
|
||||
func loadDeviceSeriesAuditResources(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) {
|
||||
seriesIDs := make(map[uint]struct{})
|
||||
if targetSeriesID != nil && *targetSeriesID > 0 {
|
||||
seriesIDs[*targetSeriesID] = struct{}{}
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device != nil && device.SeriesID != nil && *device.SeriesID > 0 {
|
||||
seriesIDs[*device.SeriesID] = struct{}{}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(seriesIDs))
|
||||
for id := range seriesIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var rows []*model.PackageSeries
|
||||
if len(ids) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
series := make(map[uint]*model.PackageSeries, len(rows))
|
||||
for _, item := range rows {
|
||||
series[item.ID] = item
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func deviceSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, 2)
|
||||
if previousID != nil && *previousID > 0 {
|
||||
resources = appendDevicePackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries)
|
||||
}
|
||||
if targetID != nil && *targetID > 0 {
|
||||
resources = appendDevicePackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func appendDevicePackageSeriesAuditReference(resources []audit.ResourceInput, series *model.PackageSeries, seriesID uint, role string) []audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(seriesID), 10)
|
||||
name := id
|
||||
identity := map[string]any{"id": seriesID}
|
||||
if series != nil {
|
||||
name = series.SeriesName
|
||||
identity = map[string]any{"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName, "status": series.Status}
|
||||
}
|
||||
return append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePackageSeries, ID: &id, Key: id, DisplayName: name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: role,
|
||||
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceSeriesBindingAuditFailure(
|
||||
ctx context.Context,
|
||||
devices []*model.Device,
|
||||
outcomes map[uint]deviceAuditOutcome,
|
||||
seriesID *uint,
|
||||
result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备系列绑定审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceSeriesBindingAudit(ctx, tx, devices, outcomes, seriesID, result,
|
||||
batchTotal, successCount, failCount, metadata, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, devices []*model.Device, policy string) error {
|
||||
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]deviceBatchAuditItem, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device == nil || device.ID == 0 || device.RealnamePolicy == policy {
|
||||
continue
|
||||
}
|
||||
items = append(items, deviceBatchAuditItem{
|
||||
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
|
||||
Result: constants.AuditResultSuccess, Summary: "更新设备实名策略",
|
||||
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
|
||||
AfterData: map[string]any{"realname_policy": policy},
|
||||
References: cardReferences[device.ID],
|
||||
})
|
||||
}
|
||||
return s.appendDeviceBatchAudit(ctx, tx,
|
||||
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
|
||||
constants.AuditActionDeviceRealnamePolicyUpdated,
|
||||
"realname-policy", "批量更新设备实名策略", constants.AuditResultSuccess,
|
||||
len(items), len(items), 0, items,
|
||||
map[string]any{"realname_policy": policy, "requested_count": len(devices)}, nil)
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceRealnamePolicyBatchFailure(ctx context.Context, devices []*model.Device, policy, result string, businessErr error) {
|
||||
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略批量审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
items := make([]deviceBatchAuditItem, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device == nil || device.ID == 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, deviceBatchAuditItem{
|
||||
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
|
||||
Result: result, Summary: "更新设备实名策略未完成",
|
||||
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
|
||||
AfterData: map[string]any{"requested_realname_policy": policy},
|
||||
})
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceBatchAudit(ctx, tx,
|
||||
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
|
||||
constants.AuditActionDeviceRealnamePolicyUpdated,
|
||||
"realname-policy", "批量更新设备实名策略未完成", result,
|
||||
len(devices), 0, len(devices), items, map[string]any{"realname_policy": policy}, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceRealnamePolicyAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
summary, result string,
|
||||
device *model.Device,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || device == nil || device.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整")
|
||||
}
|
||||
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceID := strconv.FormatUint(uint64(device.ID), 10)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceDevice, ID: &deviceID,
|
||||
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
resources = append(resources, cardReferences[device.ID]...)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionDeviceRealnamePolicyUpdated, Summary: summary,
|
||||
ScopeType: constants.AuditScopePlatform, Result: result,
|
||||
ErrorCode: errorCode, ErrorSummary: errorSummary, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceRealnamePolicyFailure(ctx context.Context, device *model.Device, deviceID uint, businessErr error) {
|
||||
if device == nil || device.ID == 0 || s.db == nil || s.auditWriter == nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceRealnamePolicyAudit(ctx, tx, "更新设备实名策略失败", constants.AuditResultFailed,
|
||||
device, map[string]any{"realname_policy": device.RealnamePolicy}, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, err)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,17 @@ package device_import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
@@ -14,62 +20,62 @@ type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) logDeviceImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAudit == nil {
|
||||
func (s *Service) writeDeviceImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.DeviceImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if task.OperatorShopID != nil {
|
||||
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.OperatorShopID), 10)
|
||||
}
|
||||
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
|
||||
EventID: infraAudit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, phase),
|
||||
ActionCode: constants.AuditActionDeviceImportTaskCreated, Summary: "创建设备导入任务",
|
||||
TaskID: task.ID, TaskNo: task.TaskNo,
|
||||
Actor: infraAudit.ActorInput{
|
||||
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
|
||||
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.OperatorShopID,
|
||||
},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
|
||||
"operation_type": task.OperationType, "target_id": task.TargetID,
|
||||
"batch_no": task.BatchNo, "realname_policy": task.RealnamePolicy,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordDeviceImportTaskAudit(ctx context.Context, task *model.DeviceImportTask, before, after map[string]any, result, phase string, errorCode int, summary string) {
|
||||
if s == nil || s.db == nil || s.auditWriter == nil || task == nil || task.TaskNo == "" {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.OperationType == "" {
|
||||
p.OperationType = constants.AssetAuditOpDeviceImportTaskCreate
|
||||
}
|
||||
if p.AssetType == "" {
|
||||
p.AssetType = constants.AssetTypeDevice
|
||||
}
|
||||
p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil)
|
||||
s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
}
|
||||
|
||||
func newDeviceImportAuditParams(
|
||||
taskID uint,
|
||||
taskNo string,
|
||||
req *dto.ImportDeviceRequest,
|
||||
resultStatus string,
|
||||
err error,
|
||||
) assetAuditSvc.BuildLogParams {
|
||||
afterData := map[string]any{}
|
||||
if req != nil {
|
||||
afterData["batch_no"] = req.BatchNo
|
||||
afterData["file_key"] = req.FileKey
|
||||
afterData["realname_policy"] = req.RealnamePolicy
|
||||
}
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
return assetAuditSvc.BuildLogParams{
|
||||
AssetID: taskID,
|
||||
AssetIdentifier: taskNo,
|
||||
OperationDesc: "创建设备导入任务",
|
||||
ResultStatus: resultStatus,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AfterData: afterData,
|
||||
code := strconv.Itoa(errorCode)
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, after, result, phase, code, summary)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionDeviceImportTaskCreated, task.TaskNo, "", task.TaskNo, code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.CreateDeviceBatchAllocationRequest, resultStatus string, err error) assetAuditSvc.BuildLogParams {
|
||||
afterData := map[string]any{}
|
||||
if req != nil {
|
||||
afterData["file_key"] = req.FileKey
|
||||
afterData["operation_type"] = req.OperationType
|
||||
if req.OperationType != constants.DeviceImportOperationRecall {
|
||||
afterData["target_id"] = req.TargetID
|
||||
func (s *Service) failEnqueueWithAudit(ctx context.Context, task *model.DeviceImportTask, summary string) error {
|
||||
before := deviceImportTaskState(task)
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
if err := tx.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
|
||||
"status": model.ImportTaskStatusFailed, "error_message": summary, "completed_at": now, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, summary
|
||||
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), summary)
|
||||
})
|
||||
}
|
||||
|
||||
func deviceImportTaskState(task *model.DeviceImportTask) map[string]any {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
return assetAuditSvc.BuildLogParams{
|
||||
AssetID: taskID, AssetIdentifier: taskNo,
|
||||
OperationType: constants.AssetAuditOpDeviceBatchTaskCreate,
|
||||
OperationDesc: "创建设备CSV批量操作任务", ResultStatus: resultStatus,
|
||||
ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData,
|
||||
return map[string]any{
|
||||
"status": task.Status, "total_count": task.TotalCount, "success_count": task.SuccessCount,
|
||||
"skip_count": task.SkipCount, "fail_count": task.FailCount, "warning_count": task.WarningCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
@@ -23,6 +24,7 @@ type Service struct {
|
||||
importTaskStore *postgres.DeviceImportTaskStore
|
||||
queueClient *queue.Client
|
||||
assetAudit AssetAuditService
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
type DeviceImportPayload struct {
|
||||
@@ -34,21 +36,24 @@ func New(
|
||||
importTaskStore *postgres.DeviceImportTaskStore,
|
||||
queueClient *queue.Client,
|
||||
assetAudit AssetAuditService,
|
||||
auditWriters ...*audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
service := &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
queueClient: queueClient,
|
||||
assetAudit: assetAudit,
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
appErr := errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
@@ -67,9 +72,17 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "设备导入任务统一审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败")
|
||||
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备导入任务失败")
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
@@ -81,14 +94,13 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
|
||||
)
|
||||
if err != nil {
|
||||
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备导入任务入队失败"); secondaryErr != nil {
|
||||
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备导入任务入队失败")
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败")
|
||||
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
|
||||
|
||||
return &dto.ImportDeviceResponse{
|
||||
TaskID: task.ID,
|
||||
TaskNo: taskNo,
|
||||
@@ -101,9 +113,7 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
|
||||
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
|
||||
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
|
||||
}
|
||||
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务参数不合法")
|
||||
@@ -136,20 +146,28 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
}
|
||||
task.Creator, task.Updater = userID, userID
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "设备批量任务统一审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备CSV批量任务失败")
|
||||
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备 CSV 批量任务失败")
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, DeviceImportPayload{TaskID: task.ID},
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
|
||||
asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil {
|
||||
_ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败")
|
||||
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备 CSV 批量任务入队失败"); secondaryErr != nil {
|
||||
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备 CSV 批量任务入队失败")
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "设备CSV批量任务入队失败")
|
||||
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
return nil, appErr
|
||||
}
|
||||
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
|
||||
return &dto.CreateDeviceBatchAllocationResponse{
|
||||
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备CSV批量任务已创建,Worker 将异步处理CSV文件",
|
||||
}, nil
|
||||
|
||||
494
internal/service/exchange/audit.go
Normal file
494
internal/service/exchange/audit.go
Normal file
@@ -0,0 +1,494 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
type cardExchangeAuditBefore struct {
|
||||
Wallets map[uint]model.AssetWallet
|
||||
DeviceBindings []*model.PersonalCustomerDevice
|
||||
ICCIDBindings []*model.PersonalCustomerICCID
|
||||
}
|
||||
|
||||
// SetAccessAudit 注入卡与设备换货完整用例的统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendCardExchangeAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
order *model.ExchangeOrder,
|
||||
oldCard, newCard *model.IotCard,
|
||||
orderBefore, orderAfter map[string]any,
|
||||
oldCardBefore, oldCardAfter map[string]any,
|
||||
newCardBefore, newCardAfter map[string]any,
|
||||
extra []audit.ResourceInput,
|
||||
businessErr error,
|
||||
) error {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
|
||||
return nil
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置")
|
||||
}
|
||||
internalOnly := actionCode == constants.AuditActionCardExchangeRenewed
|
||||
resources := []audit.ResourceInput{cardExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
|
||||
if oldCard != nil {
|
||||
resources = append(resources, cardExchangeCardAuditResource(oldCard, constants.AuditResourceRoleCardExchangeOldCard, summary, internalOnly, oldCardBefore, oldCardAfter))
|
||||
}
|
||||
if newCard != nil {
|
||||
resources = append(resources, cardExchangeCardAuditResource(newCard, constants.AuditResourceRoleCardExchangeNewCard, summary, internalOnly, newCardBefore, newCardAfter))
|
||||
}
|
||||
shopResource, err := loadCardExchangeShopAuditResource(ctx, tx, order.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shopResource != nil {
|
||||
resources = append(resources, *shopResource)
|
||||
}
|
||||
resources = append(resources, extra...)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if actionCode == constants.AuditActionCardExchangeShippingInfoSubmitted {
|
||||
scopeType = constants.AuditScopePersonalCustomer
|
||||
scopeID = auditcontext.From(ctx).ActorID
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func cardExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
|
||||
key := order.ExchangeNo
|
||||
if key == "" {
|
||||
key = "iot_card:" + strconv.FormatUint(uint64(order.OldAssetID), 10) + ":exchange"
|
||||
}
|
||||
var id *string
|
||||
if order.ID > 0 {
|
||||
value := strconv.FormatUint(uint64(order.ID), 10)
|
||||
id = &value
|
||||
}
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceExchangeOrder, ID: id, Key: key, DisplayName: key,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": effectiveExchangeFlowType(order.FlowType),
|
||||
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID, "old_asset_identifier": order.OldAssetIdentifier,
|
||||
"new_asset_type": order.NewAssetType, "new_asset_id": order.NewAssetID, "new_asset_identifier": order.NewAssetIdentifier,
|
||||
"shop_id": order.ShopID, "status": order.Status,
|
||||
},
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
}
|
||||
if internalOnly {
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
} else {
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = summary
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
func cardExchangeCardAuditResource(card *model.IotCard, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(card.ID), 10)
|
||||
relation := constants.AuditResourceRelationReference
|
||||
if len(beforeData) > 0 || len(afterData) > 0 {
|
||||
relation = constants.AuditResourceRelationAffected
|
||||
}
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: relation, Role: role, IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
}
|
||||
if internalOnly {
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
} else {
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = summary
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
func loadCardExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
|
||||
if shopID == nil || *shopID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", *shopID).First(&shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货所属店铺失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(shop.ID), 10)
|
||||
return &audit.ResourceInput{
|
||||
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: shop.ShopName,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeShop,
|
||||
IdentitySnapshot: map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) captureCardExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldCard, newCard *model.IotCard) (*cardExchangeAuditBefore, error) {
|
||||
state := &cardExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
|
||||
cardIDs := make([]uint, 0, 2)
|
||||
if oldCard != nil {
|
||||
cardIDs = append(cardIDs, oldCard.ID)
|
||||
}
|
||||
if newCard != nil {
|
||||
cardIDs = append(cardIDs, newCard.ID)
|
||||
}
|
||||
if len(cardIDs) > 0 {
|
||||
var wallets []model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeIotCard, cardIDs).Find(&wallets).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货钱包审计快照失败")
|
||||
}
|
||||
for _, wallet := range wallets {
|
||||
state.Wallets[wallet.ResourceID] = wallet
|
||||
}
|
||||
}
|
||||
devices, iccids, err := loadCardExchangeBindings(ctx, tx, oldCard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.DeviceBindings, state.ICCIDBindings = devices, iccids
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func loadCardExchangeBindings(ctx context.Context, tx *gorm.DB, card *model.IotCard) ([]*model.PersonalCustomerDevice, []*model.PersonalCustomerICCID, error) {
|
||||
if card == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if card.VirtualNo != "" {
|
||||
var rows []*model.PersonalCustomerDevice
|
||||
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", card.VirtualNo, constants.StatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货客户绑定失败")
|
||||
}
|
||||
return rows, nil, nil
|
||||
}
|
||||
var rows []*model.PersonalCustomerICCID
|
||||
if err := tx.WithContext(ctx).Where("iccid = ? AND status = ?", card.ICCID, constants.StatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货 ICCID 绑定失败")
|
||||
}
|
||||
return nil, rows, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildCardExchangeCompletionResources(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
order *model.ExchangeOrder,
|
||||
oldCard, newCard *model.IotCard,
|
||||
before *cardExchangeAuditBefore,
|
||||
migration *exchangeMigrationResult,
|
||||
) ([]audit.ResourceInput, error) {
|
||||
resources := cardExchangeOldBindingResources(before)
|
||||
devices, iccids, err := loadCardExchangeBindings(ctx, tx, newCard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, cardExchangeNewBindingResources(devices, iccids)...)
|
||||
beforeWallets := map[uint]model.AssetWallet(nil)
|
||||
if before != nil {
|
||||
beforeWallets = before.Wallets
|
||||
}
|
||||
walletResources, err := loadCardExchangeWalletResources(ctx, tx, oldCard.ID, newCard.ID, beforeWallets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, walletResources...)
|
||||
if migration == nil {
|
||||
return resources, nil
|
||||
}
|
||||
transactionResources, err := loadCardExchangeTransactionResources(ctx, tx, order.ExchangeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, transactionResources...)
|
||||
usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldCard.ID, newCard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resources, usageResources...), nil
|
||||
}
|
||||
|
||||
func cardExchangeOldBindingResources(before *cardExchangeAuditBefore) []audit.ResourceInput {
|
||||
if before == nil {
|
||||
return nil
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(before.DeviceBindings)+len(before.ICCIDBindings))
|
||||
for _, row := range before.DeviceBindings {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
|
||||
BeforeData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
for _, row := range before.ICCIDBindings {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
|
||||
BeforeData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func cardExchangeNewBindingResources(devices []*model.PersonalCustomerDevice, iccids []*model.PersonalCustomerICCID) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, len(devices)+len(iccids))
|
||||
for _, row := range devices {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
|
||||
AfterData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
for _, row := range iccids {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
|
||||
AfterData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func loadCardExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldCardID, newCardID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
|
||||
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeIotCard, oldCardID, newCardID, before,
|
||||
constants.AuditResourceRoleCardExchangeOldWallet, constants.AuditResourceRoleCardExchangeNewWallet, "卡")
|
||||
}
|
||||
|
||||
func loadExchangeWalletResources(ctx context.Context, tx *gorm.DB, assetType string, oldAssetID, newAssetID uint, before map[uint]model.AssetWallet, oldRole, newRole, assetName string) ([]audit.ResourceInput, error) {
|
||||
var wallets []model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", assetType, []uint{oldAssetID, newAssetID}).Find(&wallets).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货迁移后钱包失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(wallets))
|
||||
for _, wallet := range wallets {
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
role := newRole
|
||||
if wallet.ResourceID == oldAssetID {
|
||||
role = oldRole
|
||||
}
|
||||
relation := constants.AuditResourceRelationAffected
|
||||
beforeData, afterData := map[string]any{"exists": false}, cardExchangeWalletData(wallet)
|
||||
if previous, ok := before[wallet.ResourceID]; ok {
|
||||
if !cardExchangeWalletChanged(previous, wallet) {
|
||||
relation, beforeData, afterData = constants.AuditResourceRelationReference, nil, nil
|
||||
} else {
|
||||
beforeData = cardExchangeWalletData(previous)
|
||||
}
|
||||
}
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
|
||||
Relation: relation, Role: role,
|
||||
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: beforeData, AfterData: afterData, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func cardExchangeWalletData(wallet model.AssetWallet) map[string]any {
|
||||
return map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance, "status": wallet.Status, "version": wallet.Version, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag}
|
||||
}
|
||||
|
||||
func cardExchangeWalletChanged(before, after model.AssetWallet) bool {
|
||||
return before.Balance != after.Balance || before.FrozenBalance != after.FrozenBalance || before.Status != after.Status ||
|
||||
before.Version != after.Version || before.ShopIDTag != after.ShopIDTag || !sameOptionalUint(before.EnterpriseIDTag, after.EnterpriseIDTag)
|
||||
}
|
||||
|
||||
func sameOptionalUint(left, right *uint) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func loadCardExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, cardID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
|
||||
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeIotCard, cardID, before, constants.AuditResourceRoleCardExchangeOldWallet, "卡")
|
||||
}
|
||||
|
||||
func loadExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, before map[uint]model.AssetWallet, role, assetName string) (*audit.ResourceInput, error) {
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", assetType, assetID).First(&wallet).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询旧"+assetName+"转新钱包失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
beforeData := map[string]any{"exists": false}
|
||||
if previous, ok := before[assetID]; ok {
|
||||
beforeData = cardExchangeWalletData(previous)
|
||||
}
|
||||
return &audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: role,
|
||||
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: beforeData, AfterData: cardExchangeWalletData(wallet), SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCardExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
|
||||
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleCardExchangeWalletTransaction, "卡")
|
||||
}
|
||||
|
||||
func loadExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo, role, assetName string) ([]audit.ResourceInput, error) {
|
||||
var rows []model.AssetWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("transaction_type = ? AND reference_type = ? AND reference_no = ?", constants.AssetTransactionTypeExchange, constants.ReferenceTypeExchange, exchangeNo).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货钱包流水失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: exchangeNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: role,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "asset_wallet_id": row.AssetWalletID, "resource_type": row.ResourceType, "resource_id": row.ResourceID, "transaction_type": row.TransactionType, "reference_type": row.ReferenceType, "reference_no": row.ReferenceNo, "status": row.Status},
|
||||
AfterData: map[string]any{"amount": row.Amount, "balance_before": row.BalanceBefore, "balance_after": row.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldCardID, newCardID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡")
|
||||
}
|
||||
|
||||
func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []model.PackageUsage
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益失败")
|
||||
}
|
||||
orderIDs := make(map[uint]struct{}, len(rows))
|
||||
packageIDs := make(map[uint]struct{}, len(rows))
|
||||
resources := make([]audit.ResourceInput, 0, len(rows)*3)
|
||||
for _, row := range rows {
|
||||
resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role,
|
||||
map[string]any{assetIDField: oldAssetID}, map[string]any{assetIDField: newAssetID})
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
orderIDs[row.OrderID] = struct{}{}
|
||||
packageIDs[row.PackageID] = struct{}{}
|
||||
}
|
||||
var orders []model.Order
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(orderIDs)).Order("id ASC").Find(&orders).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联订单失败")
|
||||
}
|
||||
for i := range orders {
|
||||
resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder))
|
||||
}
|
||||
var packages []model.Package
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(packageIDs)).Order("id ASC").Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联套餐失败")
|
||||
}
|
||||
for i := range packages {
|
||||
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil))
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func exchangeUintKeys(values map[uint]struct{}) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
for value := range values {
|
||||
if value > 0 {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) recordCardExchangeFailure(ctx context.Context, actionCode, summary, result string, order *model.ExchangeOrder, oldCard, newCard *model.IotCard, businessErr error) {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
|
||||
return
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardExchangeAudit(ctx, tx, actionCode, summary, result, order, oldCard, newCard,
|
||||
nil, nil, nil, nil, nil, nil, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordCardExchangeAuditSecondaryFailure(ctx context.Context, actionCode, exchangeNo string, businessErr, auditErr error) {
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
linkage := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, exchangeNo, linkage.RequestID, linkage.CorrelationID, errorCode, auditErr)
|
||||
}
|
||||
|
||||
func (s *Service) recordCardExchangeOrderFailure(ctx context.Context, actionCode, summary string, order *model.ExchangeOrder, businessErr error) {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
|
||||
return
|
||||
}
|
||||
oldCard, newCard := s.loadCardExchangeAuditCards(ctx, order)
|
||||
s.recordCardExchangeFailure(ctx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldCard, newCard, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) loadCardExchangeAuditCards(ctx context.Context, order *model.ExchangeOrder) (*model.IotCard, *model.IotCard) {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
|
||||
return nil, nil
|
||||
}
|
||||
var oldCard *model.IotCard
|
||||
if s.iotCardStore != nil {
|
||||
oldCard, _ = s.iotCardStore.GetByID(ctx, order.OldAssetID)
|
||||
}
|
||||
if oldCard == nil {
|
||||
oldCard = &model.IotCard{Model: gorm.Model{ID: order.OldAssetID}, ICCID: order.OldAssetIdentifier, ShopID: order.ShopID}
|
||||
}
|
||||
var newCard *model.IotCard
|
||||
if order.NewAssetID != nil && *order.NewAssetID > 0 {
|
||||
if s.iotCardStore != nil {
|
||||
newCard, _ = s.iotCardStore.GetByID(ctx, *order.NewAssetID)
|
||||
}
|
||||
if newCard == nil {
|
||||
newCard = &model.IotCard{Model: gorm.Model{ID: *order.NewAssetID}, ICCID: order.NewAssetIdentifier, ShopID: order.ShopID}
|
||||
}
|
||||
}
|
||||
return oldCard, newCard
|
||||
}
|
||||
|
||||
func cardExchangeFailureResult(err error) string {
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if !ok {
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
switch appErr.Code {
|
||||
case errors.CodeDatabaseError, errors.CodeInternalError, errors.CodeExchangeMigrationFailed:
|
||||
return constants.AuditResultFailed
|
||||
default:
|
||||
return constants.AuditResultDenied
|
||||
}
|
||||
}
|
||||
393
internal/service/exchange/device_audit.go
Normal file
393
internal/service/exchange/device_audit.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
type deviceExchangeAuditBefore struct {
|
||||
Wallets map[uint]model.AssetWallet
|
||||
CustomerBindings []*model.PersonalCustomerDevice
|
||||
}
|
||||
|
||||
func (s *Service) appendExchangeAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
cardActionCode, summary, result string,
|
||||
order *model.ExchangeOrder,
|
||||
oldAsset, newAsset *resolvedExchangeAsset,
|
||||
orderBefore, orderAfter map[string]any,
|
||||
oldAssetBefore, oldAssetAfter map[string]any,
|
||||
newAssetBefore, newAssetAfter map[string]any,
|
||||
extra []audit.ResourceInput,
|
||||
businessErr error,
|
||||
) error {
|
||||
if order != nil && order.OldAssetType == constants.ExchangeAssetTypeDevice {
|
||||
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"), result,
|
||||
order, resolvedDevice(oldAsset), resolvedDevice(newAsset), orderBefore, orderAfter,
|
||||
oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
|
||||
}
|
||||
return s.appendCardExchangeAudit(ctx, tx, cardActionCode, summary, result, order, resolvedCard(oldAsset), resolvedCard(newAsset),
|
||||
orderBefore, orderAfter, oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
|
||||
}
|
||||
|
||||
func resolvedCard(asset *resolvedExchangeAsset) *model.IotCard {
|
||||
if asset == nil {
|
||||
return nil
|
||||
}
|
||||
return asset.Card
|
||||
}
|
||||
|
||||
func resolvedDevice(asset *resolvedExchangeAsset) *model.Device {
|
||||
if asset == nil {
|
||||
return nil
|
||||
}
|
||||
return asset.Device
|
||||
}
|
||||
|
||||
func deviceExchangeActionCode(cardActionCode string) string {
|
||||
switch cardActionCode {
|
||||
case constants.AuditActionCardExchangeCreated:
|
||||
return constants.AuditActionDeviceExchangeCreated
|
||||
case constants.AuditActionCardExchangeShippingInfoSubmitted:
|
||||
return constants.AuditActionDeviceExchangeShippingInfoSubmitted
|
||||
case constants.AuditActionCardExchangeShipped:
|
||||
return constants.AuditActionDeviceExchangeShipped
|
||||
case constants.AuditActionCardExchangeCompleted:
|
||||
return constants.AuditActionDeviceExchangeCompleted
|
||||
case constants.AuditActionCardExchangeCancelled:
|
||||
return constants.AuditActionDeviceExchangeCancelled
|
||||
case constants.AuditActionCardExchangeRenewed:
|
||||
return constants.AuditActionDeviceExchangeRenewed
|
||||
default:
|
||||
return cardActionCode
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendDeviceExchangeAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
order *model.ExchangeOrder,
|
||||
oldDevice, newDevice *model.Device,
|
||||
orderBefore, orderAfter map[string]any,
|
||||
oldDeviceBefore, oldDeviceAfter map[string]any,
|
||||
newDeviceBefore, newDeviceAfter map[string]any,
|
||||
extra []audit.ResourceInput,
|
||||
businessErr error,
|
||||
) error {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
|
||||
return nil
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置")
|
||||
}
|
||||
internalOnly := actionCode == constants.AuditActionDeviceExchangeRenewed
|
||||
resources := []audit.ResourceInput{deviceExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
|
||||
if oldDevice != nil {
|
||||
resources = append(resources, deviceExchangeDeviceAuditResource(oldDevice, constants.AuditResourceRoleDeviceExchangeOldDevice, summary, internalOnly, oldDeviceBefore, oldDeviceAfter))
|
||||
}
|
||||
if newDevice != nil {
|
||||
resources = append(resources, deviceExchangeDeviceAuditResource(newDevice, constants.AuditResourceRoleDeviceExchangeNewDevice, summary, internalOnly, newDeviceBefore, newDeviceAfter))
|
||||
}
|
||||
shopResource, err := loadDeviceExchangeShopAuditResource(ctx, tx, order.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shopResource != nil {
|
||||
resources = append(resources, *shopResource)
|
||||
}
|
||||
resources = append(resources, extra...)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if actionCode == constants.AuditActionDeviceExchangeShippingInfoSubmitted {
|
||||
scopeType = constants.AuditScopePersonalCustomer
|
||||
scopeID = auditcontext.From(ctx).ActorID
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func deviceExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
|
||||
resource := cardExchangeOrderAuditResource(order, summary, internalOnly, beforeData, afterData)
|
||||
resource.Role = constants.AuditResourceRoleDeviceExchangeOrder
|
||||
return resource
|
||||
}
|
||||
|
||||
func deviceExchangeDeviceAuditResource(device *model.Device, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(device.ID), 10)
|
||||
relation := constants.AuditResourceRelationReference
|
||||
if len(beforeData) > 0 || len(afterData) > 0 {
|
||||
relation = constants.AuditResourceRelationAffected
|
||||
}
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(device), DisplayName: preferredDeviceIdentifier(device),
|
||||
Relation: relation, Role: role, IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
}
|
||||
if internalOnly {
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
} else {
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = summary
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
func loadDeviceExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
|
||||
resource, err := loadCardExchangeShopAuditResource(ctx, tx, shopID)
|
||||
if resource != nil {
|
||||
resource.Role = constants.AuditResourceRoleDeviceExchangeShop
|
||||
}
|
||||
return resource, err
|
||||
}
|
||||
|
||||
func (s *Service) captureDeviceExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) (*deviceExchangeAuditBefore, error) {
|
||||
state := &deviceExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
|
||||
deviceIDs := make([]uint, 0, 2)
|
||||
if oldDevice != nil {
|
||||
deviceIDs = append(deviceIDs, oldDevice.ID)
|
||||
}
|
||||
if newDevice != nil {
|
||||
deviceIDs = append(deviceIDs, newDevice.ID)
|
||||
}
|
||||
if len(deviceIDs) > 0 {
|
||||
var wallets []model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeDevice, deviceIDs).Find(&wallets).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货钱包审计快照失败")
|
||||
}
|
||||
for _, wallet := range wallets {
|
||||
state.Wallets[wallet.ResourceID] = wallet
|
||||
}
|
||||
}
|
||||
rows, err := loadDeviceExchangeCustomerBindings(ctx, tx, oldDevice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.CustomerBindings = rows
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func loadDeviceExchangeCustomerBindings(ctx context.Context, tx *gorm.DB, device *model.Device) ([]*model.PersonalCustomerDevice, error) {
|
||||
if device == nil {
|
||||
return nil, nil
|
||||
}
|
||||
key := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: device, VirtualNo: device.VirtualNo})
|
||||
if key == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*model.PersonalCustomerDevice
|
||||
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", key, constants.StatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货客户绑定失败")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildDeviceExchangeCompletionResources(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
order *model.ExchangeOrder,
|
||||
oldDevice, newDevice *model.Device,
|
||||
before *deviceExchangeAuditBefore,
|
||||
migration *exchangeMigrationResult,
|
||||
) ([]audit.ResourceInput, error) {
|
||||
resources := deviceExchangeOldCustomerBindingResources(before)
|
||||
newBindings, err := loadDeviceExchangeCustomerBindings(ctx, tx, newDevice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, deviceExchangeNewCustomerBindingResources(newBindings)...)
|
||||
simResources, err := loadDeviceExchangeSIMResources(ctx, tx, oldDevice, newDevice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, simResources...)
|
||||
beforeWallets := map[uint]model.AssetWallet(nil)
|
||||
if before != nil {
|
||||
beforeWallets = before.Wallets
|
||||
}
|
||||
walletResources, err := loadDeviceExchangeWalletResources(ctx, tx, oldDevice.ID, newDevice.ID, beforeWallets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, walletResources...)
|
||||
if migration == nil {
|
||||
return resources, nil
|
||||
}
|
||||
transactions, err := loadDeviceExchangeTransactionResources(ctx, tx, order.ExchangeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, transactions...)
|
||||
usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldDevice.ID, newDevice.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resources, usages...), nil
|
||||
}
|
||||
|
||||
func deviceExchangeOldCustomerBindingResources(before *deviceExchangeAuditBefore) []audit.ResourceInput {
|
||||
if before == nil {
|
||||
return nil
|
||||
}
|
||||
return deviceExchangeCustomerBindingResources(before.CustomerBindings, constants.AuditResourceRoleDeviceExchangeOldCustomerBinding, true)
|
||||
}
|
||||
|
||||
func deviceExchangeNewCustomerBindingResources(rows []*model.PersonalCustomerDevice) []audit.ResourceInput {
|
||||
return deviceExchangeCustomerBindingResources(rows, constants.AuditResourceRoleDeviceExchangeNewCustomerBinding, false)
|
||||
}
|
||||
|
||||
func deviceExchangeCustomerBindingResources(rows []*model.PersonalCustomerDevice, role string, before bool) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
id := strconv.FormatUint(uint64(row.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: role,
|
||||
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}
|
||||
if before {
|
||||
resource.BeforeData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
|
||||
} else {
|
||||
resource.AfterData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func loadDeviceExchangeSIMResources(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) ([]audit.ResourceInput, error) {
|
||||
resources := make([]audit.ResourceInput, 0)
|
||||
for _, item := range []struct {
|
||||
device *model.Device
|
||||
cardRole string
|
||||
bindingRole string
|
||||
}{
|
||||
{oldDevice, constants.AuditResourceRoleDeviceExchangeOldBoundCard, constants.AuditResourceRoleDeviceExchangeOldSIMBinding},
|
||||
{newDevice, constants.AuditResourceRoleDeviceExchangeNewBoundCard, constants.AuditResourceRoleDeviceExchangeNewSIMBinding},
|
||||
} {
|
||||
if item.device == nil {
|
||||
continue
|
||||
}
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", item.device.ID, constants.BindStatusBound).
|
||||
Order("slot_position ASC, id ASC").Find(&bindings).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货卡槽绑定失败")
|
||||
}
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
cardIDs = append(cardIDs, binding.IotCardID)
|
||||
}
|
||||
cards := make(map[uint]*model.IotCard, len(cardIDs))
|
||||
if len(cardIDs) > 0 {
|
||||
var rows []*model.IotCard
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货绑定卡失败")
|
||||
}
|
||||
for _, card := range rows {
|
||||
cards[card.ID] = card
|
||||
}
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
card := cards[binding.IotCardID]
|
||||
if card == nil {
|
||||
return nil, errors.New(errors.CodeAssetNotFound, "设备换货绑定卡不存在")
|
||||
}
|
||||
cardID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: item.cardRole,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID, Key: bindingID, DisplayName: preferredDeviceIdentifier(item.device),
|
||||
Relation: constants.AuditResourceRelationReference, Role: item.bindingRole,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": item.device.VirtualNo,
|
||||
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
|
||||
"iccid": card.ICCID, "virtual_no": card.VirtualNo, "is_current": binding.IsCurrent,
|
||||
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func loadDeviceExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldDeviceID, newDeviceID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
|
||||
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeDevice, oldDeviceID, newDeviceID, before,
|
||||
constants.AuditResourceRoleDeviceExchangeOldWallet, constants.AuditResourceRoleDeviceExchangeNewWallet, "设备")
|
||||
}
|
||||
|
||||
func loadDeviceExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, deviceID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
|
||||
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeDevice, deviceID, before, constants.AuditResourceRoleDeviceExchangeOldWallet, "设备")
|
||||
}
|
||||
|
||||
func loadDeviceExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
|
||||
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleDeviceExchangeWalletTransaction, "设备")
|
||||
}
|
||||
|
||||
func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) {
|
||||
return loadExchangePackageUsageResources(ctx, tx, ids, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备")
|
||||
}
|
||||
|
||||
func (s *Service) recordExchangeOrderFailure(ctx context.Context, cardActionCode, summary string, order *model.ExchangeOrder, businessErr error) {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
|
||||
s.recordCardExchangeOrderFailure(ctx, cardActionCode, summary, order, businessErr)
|
||||
return
|
||||
}
|
||||
oldDevice, newDevice := s.loadDeviceExchangeAuditDevices(ctx, order)
|
||||
if s.db == nil || s.auditWriter == nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr,
|
||||
errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"),
|
||||
cardExchangeFailureResult(businessErr), order, oldDevice, newDevice,
|
||||
nil, nil, nil, nil, nil, nil, nil, businessErr)
|
||||
}); err != nil {
|
||||
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadDeviceExchangeAuditDevices(ctx context.Context, order *model.ExchangeOrder) (*model.Device, *model.Device) {
|
||||
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
|
||||
return nil, nil
|
||||
}
|
||||
var oldDevice *model.Device
|
||||
if s.deviceStore != nil {
|
||||
oldDevice, _ = s.deviceStore.GetByID(ctx, order.OldAssetID)
|
||||
}
|
||||
if oldDevice == nil {
|
||||
oldDevice = &model.Device{Model: gorm.Model{ID: order.OldAssetID}, ShopID: order.ShopID}
|
||||
}
|
||||
var newDevice *model.Device
|
||||
if order.NewAssetID != nil && *order.NewAssetID > 0 {
|
||||
if s.deviceStore != nil {
|
||||
newDevice, _ = s.deviceStore.GetByID(ctx, *order.NewAssetID)
|
||||
}
|
||||
if newDevice == nil {
|
||||
newDevice = &model.Device{Model: gorm.Model{ID: *order.NewAssetID}, ShopID: order.ShopID}
|
||||
}
|
||||
}
|
||||
return oldDevice, newDevice
|
||||
}
|
||||
@@ -12,21 +12,27 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
|
||||
type exchangeMigrationResult struct {
|
||||
Balance int64
|
||||
PackageUsageIDs []uint
|
||||
}
|
||||
|
||||
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (*exchangeMigrationResult, error) {
|
||||
migrationBalance, err := s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
|
||||
}
|
||||
if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
|
||||
usageIDs, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
|
||||
}
|
||||
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
|
||||
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
|
||||
}
|
||||
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
|
||||
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
|
||||
}
|
||||
return migrationBalance, nil
|
||||
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil
|
||||
}
|
||||
|
||||
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
|
||||
@@ -95,7 +101,7 @@ func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB,
|
||||
return migrationBalance, nil
|
||||
}
|
||||
|
||||
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, error) {
|
||||
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
|
||||
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
|
||||
@@ -105,11 +111,11 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
|
||||
|
||||
var usageIDs []uint
|
||||
if err := query.Pluck("id", &usageIDs).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
if len(usageIDs) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
updates := map[string]any{"updated_at": time.Now()}
|
||||
@@ -120,14 +126,14 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return usageIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
@@ -31,6 +32,7 @@ type Service struct {
|
||||
resourceTagStore *postgres.ResourceTagStore
|
||||
customerBinding *customerBindingSvc.Service
|
||||
shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -81,31 +83,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isExchangeableAssetStatus(asset.AssetStatus) {
|
||||
return nil, oldAssetStatusError(asset.AssetStatus)
|
||||
migrateData := false
|
||||
if req.MigrateData != nil {
|
||||
migrateData = *req.MigrateData
|
||||
}
|
||||
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
|
||||
}
|
||||
if hasUnfinishedRefund {
|
||||
return nil, errors.New(errors.CodeExchangeActiveRefund)
|
||||
}
|
||||
|
||||
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
|
||||
return nil, errors.New(errors.CodeExchangeInProgress)
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
|
||||
}
|
||||
|
||||
if flowType == constants.ExchangeFlowTypeDirect {
|
||||
return s.createDirectExchange(ctx, req, asset)
|
||||
}
|
||||
|
||||
creator := middleware.GetUserIDFromContext(ctx)
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: model.GenerateExchangeNo(),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
FlowType: flowType,
|
||||
OldAssetType: asset.AssetType,
|
||||
OldAssetID: asset.AssetID,
|
||||
OldAssetIdentifier: asset.Identifier,
|
||||
@@ -114,15 +99,57 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
|
||||
Status: constants.ExchangeStatusPendingInfo,
|
||||
MigrationCompleted: false,
|
||||
MigrationBalance: 0,
|
||||
MigrateData: false,
|
||||
MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData,
|
||||
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
|
||||
}
|
||||
if asset.ShopID != nil {
|
||||
order.ShopID = asset.ShopID
|
||||
}
|
||||
if !isExchangeableAssetStatus(asset.AssetStatus) {
|
||||
err = oldAssetStatusError(asset.AssetStatus)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
|
||||
return nil, err
|
||||
}
|
||||
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
|
||||
if err != nil {
|
||||
err = errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
|
||||
return nil, err
|
||||
}
|
||||
if hasUnfinishedRefund {
|
||||
err = errors.New(errors.CodeExchangeActiveRefund)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
|
||||
err = errors.New(errors.CodeExchangeInProgress)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
|
||||
return nil, err
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
err = errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if flowType == constants.ExchangeFlowTypeDirect {
|
||||
orderID, directErr := s.createDirectExchange(ctx, req, asset, order)
|
||||
if directErr != nil {
|
||||
order.ID = 0
|
||||
order.Status = constants.ExchangeStatusPendingInfo
|
||||
order.MigrationCompleted = false
|
||||
order.MigrationBalance = 0
|
||||
order.CompletedAt = nil
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡直接换货失败", order, directErr)
|
||||
return nil, directErr
|
||||
}
|
||||
return s.Get(ctx, orderID)
|
||||
}
|
||||
|
||||
if s.shippingCreatedNotifier == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
|
||||
err = errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
|
||||
return nil, err
|
||||
}
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
@@ -145,9 +172,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
|
||||
return notifyErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCreated, "已创建卡换货单", constants.AuditResultSuccess,
|
||||
order, asset, nil,
|
||||
map[string]any{"exists": false}, map[string]any{"status": constants.ExchangeStatusPendingInfo},
|
||||
nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
order.ID = 0
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -178,13 +210,18 @@ func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipReques
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||
}
|
||||
if order.Status != constants.ExchangeStatusPendingShip {
|
||||
return nil, errors.New(errors.CodeExchangeStatusInvalid)
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
|
||||
return nil, err
|
||||
}
|
||||
if !isShippingExchangeFlow(order.FlowType) {
|
||||
return nil, errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = s.shipWithTx(ctx, order, req); err != nil {
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货失败", order, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -200,13 +237,17 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||
}
|
||||
if order.Status != constants.ExchangeStatusShipped {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
if !isShippingExchangeFlow(order.FlowType) {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
@@ -219,6 +260,10 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
|
||||
}
|
||||
return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
|
||||
@@ -230,10 +275,14 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||
}
|
||||
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
if !isShippingExchangeFlow(order.FlowType) {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
@@ -243,13 +292,36 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
|
||||
if req != nil {
|
||||
updates["remark"] = req.Remark
|
||||
}
|
||||
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
oldAsset, resolveErr := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
|
||||
if resolveErr != nil {
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, resolveErr)
|
||||
return resolveErr
|
||||
}
|
||||
fromStatus := order.Status
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
values := make(map[string]any, len(updates)+1)
|
||||
for key, value := range updates {
|
||||
values[key] = value
|
||||
}
|
||||
values["status"] = constants.ExchangeStatusCancelled
|
||||
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).Where("id = ? AND status = ?", id, fromStatus).Updates(values)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "取消换货失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
|
||||
order.Status = constants.ExchangeStatusCancelled
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCancelled, "已取消卡换货单", constants.AuditResultSuccess,
|
||||
order, oldAsset, nil,
|
||||
map[string]any{"status": fromStatus}, map[string]any{"status": constants.ExchangeStatusCancelled},
|
||||
nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
order.Status = fromStatus
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, err)
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||
@@ -261,52 +333,84 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||
}
|
||||
if order.Status != constants.ExchangeStatusCompleted {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
|
||||
var card model.IotCard
|
||||
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if queryErr := tx.WithContext(ctx).Where("id = ?", order.OldAssetID).First(&card).Error; queryErr != nil {
|
||||
if queryErr == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
|
||||
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡失败")
|
||||
}
|
||||
if card.AssetStatus != constants.AssetStatusExchanged {
|
||||
return errors.New(errors.CodeExchangeAssetNotExchanged)
|
||||
}
|
||||
var newCard *model.IotCard
|
||||
if order.NewAssetID != nil && *order.NewAssetID > 0 {
|
||||
var value model.IotCard
|
||||
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
|
||||
if queryErr == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新卡失败")
|
||||
}
|
||||
newCard = &value
|
||||
}
|
||||
auditBefore, auditErr := s.captureCardExchangeAuditBefore(ctx, tx, &card, newCard)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
cardBefore := map[string]any{"generation": card.Generation, "asset_status": card.AssetStatus}
|
||||
|
||||
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
if updateErr := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"generation": card.Generation + 1,
|
||||
"asset_status": constants.AssetStatusInStock,
|
||||
"accumulated_recharge_by_series": "{}",
|
||||
"first_recharge_triggered_by_series": "{}",
|
||||
"updater": middleware.GetUserIDFromContext(ctx),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
|
||||
}).Error; updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败")
|
||||
}
|
||||
|
||||
cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo})
|
||||
if cardKey != "" {
|
||||
if err = tx.Where("virtual_no = ?", cardKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
|
||||
if unbindErr := s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, cardKey); unbindErr != nil {
|
||||
return unbindErr
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
|
||||
if deleteErr := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; deleteErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "清理旧钱包失败")
|
||||
}
|
||||
|
||||
shopTag := uint(0)
|
||||
if card.ShopID != nil {
|
||||
shopTag = *card.ShopID
|
||||
}
|
||||
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
|
||||
if createErr := tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; createErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建新钱包失败")
|
||||
}
|
||||
return nil
|
||||
var renewedCard model.IotCard
|
||||
if queryErr := tx.WithContext(ctx).Where("id = ?", card.ID).First(&renewedCard).Error; queryErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡转新结果失败")
|
||||
}
|
||||
walletResource, resourceErr := loadCardExchangeRenewWalletResource(ctx, tx, card.ID, auditBefore.Wallets)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
extra := cardExchangeOldBindingResources(auditBefore)
|
||||
extra = append(extra, *walletResource)
|
||||
return s.appendCardExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
|
||||
order, &renewedCard, newCard,
|
||||
nil, nil,
|
||||
cardBefore, map[string]any{"generation": renewedCard.Generation, "asset_status": renewedCard.AssetStatus},
|
||||
nil, nil, extra, nil)
|
||||
}
|
||||
|
||||
var device model.Device
|
||||
@@ -319,6 +423,22 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||
if device.AssetStatus != constants.AssetStatusExchanged {
|
||||
return errors.New(errors.CodeExchangeAssetNotExchanged)
|
||||
}
|
||||
var newDevice *model.Device
|
||||
if order.NewAssetID != nil && *order.NewAssetID > 0 {
|
||||
var value model.Device
|
||||
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
|
||||
if queryErr == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新设备失败")
|
||||
}
|
||||
newDevice = &value
|
||||
}
|
||||
deviceAuditBefore, auditErr := s.captureDeviceExchangeAuditBefore(ctx, tx, &device, newDevice)
|
||||
if auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
deviceBefore := map[string]any{"generation": device.Generation, "asset_status": device.AssetStatus}
|
||||
|
||||
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
|
||||
"generation": device.Generation + 1,
|
||||
@@ -333,8 +453,8 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||
|
||||
deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo})
|
||||
if deviceKey != "" {
|
||||
if err = tx.Where("virtual_no = ?", deviceKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
|
||||
if err = s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, deviceKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +469,31 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
|
||||
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
|
||||
}
|
||||
return nil
|
||||
var renewedDevice model.Device
|
||||
if queryErr := tx.WithContext(ctx).Where("id = ?", device.ID).First(&renewedDevice).Error; queryErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧设备转新结果失败")
|
||||
}
|
||||
walletResource, resourceErr := loadDeviceExchangeRenewWalletResource(ctx, tx, device.ID, deviceAuditBefore.Wallets)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
extra := deviceExchangeOldCustomerBindingResources(deviceAuditBefore)
|
||||
simResources, resourceErr := loadDeviceExchangeSIMResources(ctx, tx, &renewedDevice, newDevice)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
extra = append(extra, simResources...)
|
||||
extra = append(extra, *walletResource)
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
|
||||
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &renewedDevice}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: newDevice},
|
||||
nil, nil,
|
||||
deviceBefore, map[string]any{"generation": renewedDevice.Generation, "asset_status": renewedDevice.AssetStatus},
|
||||
nil, nil, extra, nil)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新失败", order, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
|
||||
@@ -392,17 +535,24 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
|
||||
}
|
||||
if !isShippingExchangeFlow(order.FlowType) {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
if order.Status != constants.ExchangeStatusPendingInfo {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
err = errors.New(errors.CodeExchangeStatusInvalid)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
|
||||
if err != nil {
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
|
||||
return err
|
||||
}
|
||||
if !s.customerOwnsAsset(ctx, oldAsset) {
|
||||
return errors.New(errors.CodeExchangeOrderNotFound)
|
||||
err = errors.New(errors.CodeExchangeOrderNotFound)
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
@@ -411,13 +561,32 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
|
||||
"recipient_address": req.RecipientAddress,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
values := make(map[string]any, len(updates)+1)
|
||||
for key, value := range updates {
|
||||
values[key] = value
|
||||
}
|
||||
values["status"] = constants.ExchangeStatusPendingShip
|
||||
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
|
||||
Where("id = ? AND status = ?", id, constants.ExchangeStatusPendingInfo).
|
||||
Updates(values)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "提交收货信息失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
|
||||
order.Status = constants.ExchangeStatusPendingShip
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShippingInfoSubmitted, "已提交卡换货收货信息", constants.AuditResultSuccess,
|
||||
order, oldAsset, nil,
|
||||
map[string]any{"status": constants.ExchangeStatusPendingInfo}, map[string]any{"status": constants.ExchangeStatusPendingShip},
|
||||
nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
order.Status = constants.ExchangeStatusPendingInfo
|
||||
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
type resolvedExchangeAsset struct {
|
||||
@@ -497,12 +666,8 @@ func isShippingExchangeFlow(flowType string) bool {
|
||||
return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping
|
||||
}
|
||||
|
||||
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset) (*dto.ExchangeOrderResponse, error) {
|
||||
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset, order *model.ExchangeOrder) (uint, error) {
|
||||
var orderID uint
|
||||
migrateData := false
|
||||
if req.MigrateData != nil {
|
||||
migrateData = *req.MigrateData
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID)
|
||||
@@ -524,27 +689,13 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
|
||||
return errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||
}
|
||||
|
||||
creator := middleware.GetUserIDFromContext(ctx)
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: model.GenerateExchangeNo(),
|
||||
FlowType: constants.ExchangeFlowTypeDirect,
|
||||
OldAssetType: lockedOldAsset.AssetType,
|
||||
OldAssetID: lockedOldAsset.AssetID,
|
||||
OldAssetIdentifier: lockedOldAsset.Identifier,
|
||||
NewAssetType: newAsset.AssetType,
|
||||
NewAssetID: &newAsset.AssetID,
|
||||
NewAssetIdentifier: newAsset.Identifier,
|
||||
ExchangeReason: req.ExchangeReason,
|
||||
Remark: req.Remark,
|
||||
Status: constants.ExchangeStatusPendingInfo,
|
||||
MigrationCompleted: false,
|
||||
MigrationBalance: 0,
|
||||
MigrateData: migrateData,
|
||||
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
|
||||
}
|
||||
if lockedOldAsset.ShopID != nil {
|
||||
order.ShopID = lockedOldAsset.ShopID
|
||||
}
|
||||
order.OldAssetType = lockedOldAsset.AssetType
|
||||
order.OldAssetID = lockedOldAsset.AssetID
|
||||
order.OldAssetIdentifier = lockedOldAsset.Identifier
|
||||
order.NewAssetType = newAsset.AssetType
|
||||
order.NewAssetID = &newAsset.AssetID
|
||||
order.NewAssetIdentifier = newAsset.Identifier
|
||||
order.ShopID = cloneShopID(lockedOldAsset.ShopID)
|
||||
if err = tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败")
|
||||
}
|
||||
@@ -555,9 +706,9 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, err
|
||||
}
|
||||
return s.Get(ctx, orderID)
|
||||
return orderID, nil
|
||||
}
|
||||
|
||||
func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error {
|
||||
@@ -609,7 +760,16 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
}
|
||||
return nil
|
||||
lockedOrder.NewAssetType = newAsset.AssetType
|
||||
lockedOrder.NewAssetID = &newAsset.AssetID
|
||||
lockedOrder.NewAssetIdentifier = newAsset.Identifier
|
||||
lockedOrder.MigrateData = req.MigrateData
|
||||
lockedOrder.ShippedAt = &now
|
||||
lockedOrder.Status = constants.ExchangeStatusShipped
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess,
|
||||
lockedOrder, oldAsset, newAsset,
|
||||
map[string]any{"status": constants.ExchangeStatusPendingShip}, map[string]any{"status": constants.ExchangeStatusShipped},
|
||||
nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -629,6 +789,19 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil {
|
||||
return err
|
||||
}
|
||||
var auditBefore *cardExchangeAuditBefore
|
||||
var deviceAuditBefore *deviceExchangeAuditBefore
|
||||
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
|
||||
auditBefore, err = s.captureCardExchangeAuditBefore(ctx, tx, oldAsset.Card, newAsset.Card)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
deviceAuditBefore, err = s.captureDeviceExchangeAuditBefore(ctx, tx, oldAsset.Device, newAsset.Device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = s.syncNewAssetOwnershipWithTx(ctx, tx, oldAsset, newAsset); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -639,9 +812,9 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
return err
|
||||
}
|
||||
|
||||
var migrationBalance int64
|
||||
var migration *exchangeMigrationResult
|
||||
if order.MigrateData {
|
||||
migrationBalance, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
|
||||
migration, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -656,7 +829,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
}
|
||||
if order.MigrateData {
|
||||
updates["migration_completed"] = true
|
||||
updates["migration_balance"] = migrationBalance
|
||||
updates["migration_balance"] = migration.Balance
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
|
||||
Where("id = ? AND status = ?", order.ID, fromStatus).
|
||||
@@ -667,7 +840,49 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeExchangeStatusInvalid)
|
||||
}
|
||||
return nil
|
||||
orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}
|
||||
order.Status = constants.ExchangeStatusCompleted
|
||||
order.CompletedAt = &now
|
||||
if migration != nil {
|
||||
order.MigrationCompleted = true
|
||||
order.MigrationBalance = migration.Balance
|
||||
}
|
||||
if order.OldAssetType == constants.ExchangeAssetTypeDevice {
|
||||
var oldDeviceAfter, newDeviceAfter model.Device
|
||||
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldDeviceAfter).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货旧设备结果失败")
|
||||
}
|
||||
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newDeviceAfter).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货新设备结果失败")
|
||||
}
|
||||
extra, resourceErr := s.buildDeviceExchangeCompletionResources(ctx, tx, order, &oldDeviceAfter, &newDeviceAfter, deviceAuditBefore, migration)
|
||||
if resourceErr != nil {
|
||||
return resourceErr
|
||||
}
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
|
||||
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter},
|
||||
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
|
||||
map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID},
|
||||
map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID},
|
||||
extra, nil)
|
||||
}
|
||||
var oldCardAfter, newCardAfter model.IotCard
|
||||
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldCardAfter).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货旧卡结果失败")
|
||||
}
|
||||
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newCardAfter).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货新卡结果失败")
|
||||
}
|
||||
extra, err := s.buildCardExchangeCompletionResources(ctx, tx, order, &oldCardAfter, &newCardAfter, auditBefore, migration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
|
||||
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter},
|
||||
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
|
||||
map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID},
|
||||
map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID},
|
||||
extra, nil)
|
||||
}
|
||||
|
||||
func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) {
|
||||
|
||||
60
internal/service/export_task/audit.go
Normal file
60
internal/service/export_task/audit.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package export_task
|
||||
|
||||
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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
scopeType, scopeID := constants.AuditScopePlatform, ""
|
||||
if task.CreatorShopID != nil {
|
||||
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.CreatorShopID), 10)
|
||||
}
|
||||
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceExportTask, task.ID, phase),
|
||||
ActionCode: actionCode, Summary: summary, TaskID: task.ID, TaskNo: task.TaskNo,
|
||||
Actor: audit.ActorInput{
|
||||
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
|
||||
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.CreatorShopID, EnterpriseID: task.CreatorEnterpriseID,
|
||||
},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": task.ID, "task_no": task.TaskNo, "scene": task.Scene, "format": task.Format,
|
||||
"creator_user_id": task.CreatorUserID, "creator_user_type": task.CreatorUserType,
|
||||
"creator_shop_id": task.CreatorShopID, "creator_enterprise_id": task.CreatorEnterpriseID,
|
||||
"scope_shop_ids": task.ScopeShopIDs,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordTaskAudit(ctx context.Context, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase string, errorCode int) {
|
||||
if s == nil || s.auditWriter == nil || s.db == nil || task == nil || task.TaskNo == "" {
|
||||
return
|
||||
}
|
||||
code := strconv.Itoa(errorCode)
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.writeTaskAudit(ctx, tx, actionCode, summary, task, before, after, result, phase, code, summary)
|
||||
})
|
||||
if err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(actionCode, task.TaskNo, "", task.TaskNo, code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func exportTaskState(task *model.ExportTask) map[string]any {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"status": task.Status, "cancel_requested": task.CancelRequested, "progress": task.Progress,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package export_task
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
@@ -10,10 +12,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -28,6 +32,7 @@ type Service struct {
|
||||
queueClient *queue.Client
|
||||
storageSvc *storage.Service
|
||||
sceneRegistry *exporter.Registry
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
type dispatchPayload struct {
|
||||
@@ -35,14 +40,18 @@ type dispatchPayload struct {
|
||||
}
|
||||
|
||||
// New 创建导出任务服务。
|
||||
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service) *Service {
|
||||
return &Service{
|
||||
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service, auditWriters ...*audit.Writer) *Service {
|
||||
service := &Service{
|
||||
db: db,
|
||||
taskStore: taskStore,
|
||||
queueClient: queueClient,
|
||||
storageSvc: storageSvc,
|
||||
sceneRegistry: exporter.NewDefaultRegistry(db),
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// CreateTask 创建导出任务并入队 dispatch。
|
||||
@@ -118,7 +127,16 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.taskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "创建业务导出任务", task, nil, exportTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCreated, "创建业务导出任务失败", task, nil, exportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError)
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建导出任务失败")
|
||||
}
|
||||
|
||||
@@ -130,7 +148,18 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
|
||||
asynq.Timeout(constants.ExportDispatchTaskTimeout),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeExportDispatch)),
|
||||
); err != nil {
|
||||
_ = s.taskStore.MarkFailed(ctx, task.ID, userID, "导出任务入队失败")
|
||||
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, userID, "导出任务入队失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
before := exportTaskState(task)
|
||||
task.Status = constants.ExportTaskStatusFailed
|
||||
task.ErrorMessage = "导出任务入队失败"
|
||||
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "导出任务入队失败", task, before, exportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "导出任务入队失败")
|
||||
})
|
||||
if secondaryErr != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionExportTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeTaskQueueError, err, "导出任务入队失败")
|
||||
}
|
||||
|
||||
@@ -233,45 +262,68 @@ func (s *Service) CancelTask(ctx context.Context, id uint) (*dto.CancelExportTas
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询导出任务失败")
|
||||
}
|
||||
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
|
||||
}
|
||||
message := "取消请求已提交"
|
||||
switch task.Status {
|
||||
case constants.ExportTaskStatusPending:
|
||||
ok, err := s.taskStore.CancelPendingTask(ctx, id, userID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
}
|
||||
message = "任务已取消"
|
||||
case constants.ExportTaskStatusProcessing:
|
||||
if !task.CancelRequested {
|
||||
ok, err := s.taskStore.SetCancelRequested(ctx, id, userID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "提交取消请求失败")
|
||||
before := exportTaskState(task)
|
||||
changed := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txStore := s.taskStore.WithTx(tx)
|
||||
switch task.Status {
|
||||
case constants.ExportTaskStatusPending:
|
||||
ok, updateErr := txStore.CancelPendingTask(ctx, id, userID)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
}
|
||||
} else {
|
||||
message = "取消请求已提交,请稍后刷新状态"
|
||||
task.Status, task.CancelRequested, task.Progress = constants.ExportTaskStatusCancelled, true, 100
|
||||
message, changed = "任务已取消", true
|
||||
case constants.ExportTaskStatusProcessing:
|
||||
if task.CancelRequested {
|
||||
message = "取消请求已提交,请稍后刷新状态"
|
||||
return nil
|
||||
}
|
||||
ok, updateErr := txStore.SetCancelRequested(ctx, id, userID)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
if !ok {
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
}
|
||||
task.CancelRequested, changed = true, true
|
||||
case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled:
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
}
|
||||
case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled:
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
|
||||
}
|
||||
|
||||
latestTask, err := s.taskStore.GetByID(ctx, id)
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCancelled, message, task, before, exportTaskState(task), constants.AuditResultSuccess, "cancelled", "", "")
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询最新任务状态失败")
|
||||
result := constants.AuditResultFailed
|
||||
errorCode := errors.CodeDatabaseError
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidStatus {
|
||||
result = constants.AuditResultDenied
|
||||
errorCode = appErr.Code
|
||||
}
|
||||
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCancelled, "取消业务导出任务失败", task, before, exportTaskState(task), result, "", errorCode)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
|
||||
}
|
||||
|
||||
return &dto.CancelExportTaskResponse{
|
||||
TaskID: latestTask.ID,
|
||||
Status: latestTask.Status,
|
||||
StatusName: constants.GetExportTaskStatusName(latestTask.Status),
|
||||
CancelRequested: latestTask.CancelRequested,
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
StatusName: constants.GetExportTaskStatusName(task.Status),
|
||||
CancelRequested: task.CancelRequested,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
117
internal/service/iot_card/gateway_integration.go
Normal file
117
internal/service/iot_card/gateway_integration.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
type gatewayAttempt struct {
|
||||
log *model.IntegrationLog
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) startGatewayCardAttempt(ctx context.Context, card *model.IotCard, operation, scene, seriesKey string, attempt int) (*gatewayAttempt, error) {
|
||||
if s == nil || s.speedTierIntegration == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Gateway Integration Log 接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
triggerSource := auditcontext.From(ctx).Source
|
||||
if triggerSource == "" {
|
||||
triggerSource = "service"
|
||||
}
|
||||
triggerScene := scene
|
||||
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card:"+seriesKey+":"+operation)).String()
|
||||
requestID := requestIDFromContext(ctx)
|
||||
var requestIDPtr *string
|
||||
if requestID != "" {
|
||||
requestIDPtr = &requestID
|
||||
}
|
||||
log, err := s.speedTierIntegration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operation, ExternalID: &card.ICCID,
|
||||
ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, ResourceKey: &card.ICCID,
|
||||
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &triggerSeries,
|
||||
Attempt: attempt, RequestID: requestIDPtr, CorrelationID: requestIDPtr,
|
||||
RequestSummary: map[string]any{"iot_card_id": card.ID, "iccid": card.ICCID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayAttempt{log: log, startedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) completeGatewayCardAttempt(ctx context.Context, attempt *gatewayAttempt, callErr error, stateChanged bool) error {
|
||||
if attempt == nil || attempt.log == nil {
|
||||
return nil
|
||||
}
|
||||
completion := integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
|
||||
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
|
||||
}
|
||||
if callErr != nil {
|
||||
completion.Result = constants.IntegrationResultFailed
|
||||
completion.SafeProviderMessage = "Gateway 请求失败"
|
||||
completion.ResponseSummary = map[string]any{"result": "failed"}
|
||||
if isGatewayTimeout(callErr) {
|
||||
completion.Result = constants.IntegrationResultUnknown
|
||||
completion.SafeProviderMessage = "Gateway 请求结果未知"
|
||||
completion.ResponseSummary = map[string]any{"result": "unknown"}
|
||||
completion.RecoveryStrategy = constants.GatewayQueryUnknownRecoveryStrategy
|
||||
}
|
||||
}
|
||||
_, err := s.speedTierIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
type gatewayCardAttemptObserver struct {
|
||||
service *Service
|
||||
card *model.IotCard
|
||||
operation string
|
||||
scene string
|
||||
seriesKey string
|
||||
nextAttempt int
|
||||
current *gatewayAttempt
|
||||
successful *gatewayAttempt
|
||||
lastCallErr error
|
||||
recordingErr error
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (o *gatewayCardAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
|
||||
o.nextAttempt++
|
||||
o.lastCallErr = nil
|
||||
attempt, err := o.service.startGatewayCardAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
|
||||
if err != nil {
|
||||
o.recordingErr = err
|
||||
return err
|
||||
}
|
||||
o.current = attempt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *gatewayCardAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
|
||||
o.lastCallErr = callErr
|
||||
if isGatewayTimeout(callErr) {
|
||||
o.unknown = true
|
||||
}
|
||||
if callErr == nil {
|
||||
o.successful = o.current
|
||||
o.current = nil
|
||||
return nil
|
||||
}
|
||||
err := o.service.completeGatewayCardAttempt(ctx, o.current, callErr, false)
|
||||
o.current = nil
|
||||
if err != nil {
|
||||
o.recordingErr = err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -22,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cards []*model.IotCard
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var cards []model.IotCard
|
||||
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.IotCard{})).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
if err := query.Where("id IN ?", ids).Find(&cards).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量卡资产失败")
|
||||
@@ -31,29 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
|
||||
if len(cards) != len(ids) {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
result := tx.Model(&model.IotCard{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败")
|
||||
changedIDs := make([]uint, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil && card.RealnamePolicy != req.RealnamePolicy {
|
||||
changedIDs = append(changedIDs, card.ID)
|
||||
}
|
||||
}
|
||||
if result.RowsAffected != int64(len(ids)) {
|
||||
return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试")
|
||||
if len(changedIDs) > 0 {
|
||||
result := tx.Model(&model.IotCard{}).Where("id IN ?", changedIDs).Update("realname_policy", req.RealnamePolicy)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(changedIDs)) {
|
||||
return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return s.appendCardRealnamePolicyBatchAudit(ctx, tx, cards, req.RealnamePolicy)
|
||||
})
|
||||
if err != nil {
|
||||
result := constants.AuditResultFailed
|
||||
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden {
|
||||
result = constants.AuditResultDenied
|
||||
}
|
||||
s.recordCardRealnamePolicyBatchFailure(ctx, cards, req.RealnamePolicy, result, err)
|
||||
return nil, err
|
||||
}
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardRealnamePolicy,
|
||||
OperationDesc: "批量更新卡实名认证策略",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
BatchTotal: len(ids),
|
||||
SuccessCount: len(ids),
|
||||
AfterData: map[string]any{
|
||||
"asset_ids": ids,
|
||||
"realname_policy": req.RealnamePolicy,
|
||||
},
|
||||
})
|
||||
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type speedTierIntegrationLog interface {
|
||||
@@ -30,7 +30,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
|
||||
if code == nil || !constants.IsGatewaySpeedTier(*code) {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "固定限速档位不合法")
|
||||
}
|
||||
if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil {
|
||||
if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil || s.db == nil || s.auditWriter == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeServiceUnavailable, "卡限速服务未完整配置")
|
||||
}
|
||||
|
||||
@@ -58,11 +58,10 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
|
||||
"iccid": card.ICCID,
|
||||
"tier_code": *code,
|
||||
"tier_name": tierName,
|
||||
"operator_id": middleware.GetUserIDFromContext(ctx),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.logSpeedTierAudit(ctx, card, *code, "", constants.AssetAuditResultFailed, err)
|
||||
s.recordSpeedTierAudit(ctx, card, *code, "", false, constants.AuditResultFailed, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -87,18 +86,22 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
|
||||
zap.Error(completeErr),
|
||||
)
|
||||
}
|
||||
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, completeErr)
|
||||
auditErr := gatewayErr
|
||||
if auditErr == nil {
|
||||
auditErr = completeErr
|
||||
}
|
||||
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, false, speedTierAuditResult(gatewayErr), auditErr)
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, completeErr, "终结卡限速外部交互记录失败")
|
||||
}
|
||||
if gatewayErr != nil {
|
||||
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, gatewayErr)
|
||||
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, speedTierAuditResult(gatewayErr), gatewayErr)
|
||||
if isGatewayTimeout(gatewayErr) {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeGatewayTimeout, "Gateway 卡限速请求结果未知,请核对实际档位后再操作")
|
||||
}
|
||||
return nil, gatewayErr
|
||||
}
|
||||
|
||||
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultSuccess, nil)
|
||||
s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, constants.AuditResultSuccess, nil)
|
||||
return &dto.SetIotCardSpeedTierResponse{
|
||||
IotCardID: card.ID, ICCID: card.ICCID, Code: *code,
|
||||
SpeedTierName: tierName, IntegrationID: attempt.IntegrationID,
|
||||
@@ -118,7 +121,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
|
||||
completion := integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess,
|
||||
DurationMS: duration.Milliseconds(),
|
||||
StateChanged: true,
|
||||
StateChanged: false,
|
||||
ResponseSummary: map[string]any{
|
||||
"result": "success",
|
||||
},
|
||||
@@ -132,6 +135,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
|
||||
completion.ResponseSummary = map[string]any{"result": "failed"}
|
||||
if isGatewayTimeout(err) {
|
||||
completion.Result = constants.IntegrationResultUnknown
|
||||
completion.SafeProviderMessage = "Gateway 卡限速请求结果未知"
|
||||
completion.ResponseSummary = map[string]any{"result": "unknown"}
|
||||
completion.RecoveryStrategy = constants.GatewaySpeedTierUnknownRecoveryStrategy
|
||||
}
|
||||
@@ -143,24 +147,34 @@ func isGatewayTimeout(err error) bool {
|
||||
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == pkgerrors.CodeGatewayTimeout
|
||||
}
|
||||
|
||||
func (s *Service) logSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID, result string, err error) {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
OperationType: constants.AssetAuditOpCardSpeedTier,
|
||||
OperationDesc: "设置 IoT 卡固定限速档位",
|
||||
BeforeData: map[string]any{"card": cardSnapshot(card)},
|
||||
AfterData: map[string]any{
|
||||
"tier_code": code,
|
||||
"tier_name": constants.GetGatewaySpeedTierName(code),
|
||||
"integration_id": integrationID,
|
||||
},
|
||||
ResultStatus: result,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
})
|
||||
func speedTierAuditResult(err error) string {
|
||||
if err == nil {
|
||||
return constants.AuditResultSuccess
|
||||
}
|
||||
if isGatewayTimeout(err) {
|
||||
return constants.AuditResultUnknown
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func (s *Service) recordSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID string, integrationLogCompleted bool, result string, businessErr error) {
|
||||
afterData := map[string]any{
|
||||
"requested_tier_code": code,
|
||||
"requested_tier_name": constants.GetGatewaySpeedTierName(code),
|
||||
"integration_id": integrationID,
|
||||
"integration_log_completed": integrationLogCompleted,
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr,
|
||||
pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardLifecycleAudit(ctx, tx, constants.AuditActionIotCardSpeedTierSet,
|
||||
"设置 IoT 卡固定限速档位为"+constants.GetGatewaySpeedTierName(code), result, card, nil, afterData, businessErr)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ speedTierIntegrationLog = (*integrationlog.Repository)(nil)
|
||||
|
||||
239
internal/service/iot_card/stop_resume_audit.go
Normal file
239
internal/service/iot_card/stop_resume_audit.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *StopResumeService) appendCardCommandAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
card *model.IotCard,
|
||||
actionCode, summary, result, integrationID string,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
if s.auditWriter == nil || card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
resourcesByCard, err := loadCardDeviceAuditReferences(ctx, tx, []*model.IotCard{card})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cardID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID,
|
||||
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
resources = append(resources, resourcesByCard[card.ID]...)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
input := audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Metadata: map[string]any{"integration_id": integrationID}, Resources: resources,
|
||||
}
|
||||
if actionCode == constants.AuditActionIotCardAutoStopped || actionCode == constants.AuditActionIotCardAutoStarted ||
|
||||
actionCode == constants.AuditActionIotCardAutoStopReasonUpdated {
|
||||
input.Actor = audit.ActorInput{Kind: constants.AuditActorSystemTask, ID: "iot-card-stop-resume", Name: "IoT 卡停复机服务"}
|
||||
input.Source = constants.AuditSourceWorker
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, input)
|
||||
}
|
||||
|
||||
func (s *StopResumeService) recordCardCommandAudit(
|
||||
ctx context.Context,
|
||||
card *model.IotCard,
|
||||
actionCode, summary, result, integrationID string,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
|
||||
recordCardAuditSecondaryFailure(ctx, actionCode, cardID(card), businessErr,
|
||||
errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, result, integrationID, beforeData, afterData, businessErr)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func cardID(card *model.IotCard) uint {
|
||||
if card == nil {
|
||||
return 0
|
||||
}
|
||||
return card.ID
|
||||
}
|
||||
|
||||
func stopAuditAction(ctx context.Context, stopReason string) (string, string) {
|
||||
if stopReason == constants.StopReasonManual && auditcontext.From(ctx).ActorKind == constants.AuditActorAccount {
|
||||
return constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络"
|
||||
}
|
||||
return constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络"
|
||||
}
|
||||
|
||||
func cardCommandSeriesKey(ctx context.Context) string {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if linkage.CorrelationID != "" {
|
||||
return linkage.CorrelationID
|
||||
}
|
||||
if linkage.RequestID != "" {
|
||||
return linkage.RequestID
|
||||
}
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func cardCommandAuditResult(err error) string {
|
||||
if err == nil {
|
||||
return constants.AuditResultSuccess
|
||||
}
|
||||
if isGatewayTimeout(err) {
|
||||
return constants.AuditResultUnknown
|
||||
}
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
func (s *StopResumeService) updateCardStopReasonWithAudit(ctx context.Context, card *model.IotCard, stopReason string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("stop_reason", stopReason).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停机原因失败")
|
||||
}
|
||||
return s.appendCardCommandAudit(ctx, tx, card, constants.AuditActionIotCardAutoStopReasonUpdated,
|
||||
"自动更新 IoT 卡停机原因", constants.AuditResultSuccess, "",
|
||||
map[string]any{"stop_reason": card.StopReason}, map[string]any{"stop_reason": stopReason}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StopResumeService) startCardCommandAttempt(
|
||||
ctx context.Context,
|
||||
card *model.IotCard,
|
||||
operation, scene, seriesKey string,
|
||||
attempt int,
|
||||
) (*gatewayAttempt, error) {
|
||||
if s.integration == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "停复机 Integration Log 接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
triggerSource := auditcontext.From(ctx).Source
|
||||
if triggerSource == "" {
|
||||
triggerSource = constants.AuditSourceWorker
|
||||
}
|
||||
triggerScene := scene
|
||||
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card-command:"+seriesKey+":"+operation)).String()
|
||||
requestID := requestIDFromContext(ctx)
|
||||
correlationID := auditcontext.From(ctx).CorrelationID
|
||||
if correlationID == "" {
|
||||
correlationID = requestID
|
||||
}
|
||||
var requestIDPtr, correlationIDPtr *string
|
||||
if requestID != "" {
|
||||
requestIDPtr = &requestID
|
||||
}
|
||||
if correlationID != "" {
|
||||
correlationIDPtr = &correlationID
|
||||
}
|
||||
log, err := s.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: operation, ExternalID: &card.ICCID,
|
||||
ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, ResourceKey: &card.ICCID,
|
||||
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &triggerSeries,
|
||||
Attempt: attempt, RequestID: requestIDPtr, CorrelationID: correlationIDPtr,
|
||||
RequestSummary: map[string]any{"iot_card_id": card.ID, "iccid": card.ICCID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayAttempt{log: log, startedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *StopResumeService) completeCardCommandAttempt(ctx context.Context, attempt *gatewayAttempt, callErr error, stateChanged bool) error {
|
||||
if attempt == nil || attempt.log == nil {
|
||||
return nil
|
||||
}
|
||||
completion := integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
|
||||
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
|
||||
}
|
||||
if callErr != nil {
|
||||
completion.Result = constants.IntegrationResultFailed
|
||||
completion.SafeProviderMessage = "Gateway 停复机请求失败"
|
||||
completion.ResponseSummary = map[string]any{"result": "failed"}
|
||||
if isGatewayTimeout(callErr) {
|
||||
completion.Result = constants.IntegrationResultUnknown
|
||||
completion.SafeProviderMessage = "Gateway 停复机请求结果未知"
|
||||
completion.ResponseSummary = map[string]any{"result": "unknown"}
|
||||
completion.RecoveryStrategy = constants.GatewayCardCommandUnknownRecoveryStrategy
|
||||
}
|
||||
}
|
||||
_, err := s.integration.Complete(ctx, attempt.log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
type cardCommandAttemptObserver struct {
|
||||
service *StopResumeService
|
||||
card *model.IotCard
|
||||
operation string
|
||||
scene string
|
||||
seriesKey string
|
||||
nextAttempt int
|
||||
current *gatewayAttempt
|
||||
successful *gatewayAttempt
|
||||
lastIntegrationID string
|
||||
lastCallErr error
|
||||
recordingErr error
|
||||
unknown bool
|
||||
}
|
||||
|
||||
func (o *cardCommandAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
|
||||
o.nextAttempt++
|
||||
o.lastCallErr = nil
|
||||
attempt, err := o.service.startCardCommandAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
|
||||
if err != nil {
|
||||
o.recordingErr = err
|
||||
return err
|
||||
}
|
||||
o.current = attempt
|
||||
o.lastIntegrationID = attempt.log.IntegrationID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *cardCommandAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
|
||||
o.lastCallErr = callErr
|
||||
if isGatewayTimeout(callErr) {
|
||||
o.unknown = true
|
||||
}
|
||||
if callErr == nil {
|
||||
o.successful = o.current
|
||||
o.current = nil
|
||||
return nil
|
||||
}
|
||||
err := o.service.completeCardCommandAttempt(ctx, o.current, callErr, false)
|
||||
o.current = nil
|
||||
if err != nil {
|
||||
o.recordingErr = err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (o *cardCommandAttemptObserver) auditResult(lastErr error) string {
|
||||
if o.unknown {
|
||||
return constants.AuditResultUnknown
|
||||
}
|
||||
return cardCommandAuditResult(lastErr)
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -25,6 +26,10 @@ import (
|
||||
type StopResumeServiceInterface interface {
|
||||
// EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机
|
||||
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
|
||||
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
|
||||
ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error
|
||||
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
|
||||
ForceStartCard(ctx context.Context, card *model.IotCard) error
|
||||
}
|
||||
|
||||
// 编译时验证 StopResumeService 实现了 StopResumeServiceInterface
|
||||
@@ -43,6 +48,8 @@ type StopResumeService struct {
|
||||
assetAuditService AssetAuditService
|
||||
pollingCallback PollingCallback
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
auditWriter *audit.Writer
|
||||
integration *integrationlog.Repository
|
||||
|
||||
maxRetries int
|
||||
retryInterval time.Duration
|
||||
@@ -54,6 +61,12 @@ func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer
|
||||
s.observationSeriesEvents = writer
|
||||
}
|
||||
|
||||
// SetUnifiedAudit 注入停复机统一审计和外部交互日志接缝。
|
||||
func (s *StopResumeService) SetUnifiedAudit(writer *audit.Writer, integration *integrationlog.Repository) {
|
||||
s.auditWriter = writer
|
||||
s.integration = integration
|
||||
}
|
||||
|
||||
// NewStopResumeService 创建停复机服务
|
||||
func NewStopResumeService(
|
||||
redis *redis.Client,
|
||||
@@ -364,7 +377,7 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
|
||||
var cardErrors []error
|
||||
for _, card := range cards {
|
||||
if !s.isRealnameOK(card) {
|
||||
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
|
||||
if updateErr := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); updateErr != nil {
|
||||
cardErrors = append(cardErrors, updateErr)
|
||||
s.logger.Warn("更新未实名卡停机原因失败",
|
||||
zap.Uint("card_id", card.ID), zap.Error(updateErr))
|
||||
@@ -408,21 +421,48 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
|
||||
}
|
||||
}
|
||||
|
||||
// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。
|
||||
func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error {
|
||||
if card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return s.stopCardWithRetry(ctx, card, stopReason)
|
||||
}
|
||||
|
||||
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
|
||||
func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error {
|
||||
if card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
|
||||
return err
|
||||
}
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
if err := s.completeCardCommandAttempt(ctx, attempt, nil, true); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "终结保护期复机 Integration Log 失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeSingleCard 对单张卡执行复机逻辑
|
||||
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: assetAuditSvc.SystemOperator("系统任务"),
|
||||
OperationType: constants.AssetAuditOpCardAutoStart,
|
||||
OperationDesc: "自动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: cardID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -462,112 +502,54 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
s.logger.Error("调用运营商复机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: assetAuditSvc.SystemOperator("系统任务"),
|
||||
OperationType: constants.AssetAuditOpCardAutoStart,
|
||||
OperationDesc: "自动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: assetAuditSvc.SystemOperator("系统任务"),
|
||||
OperationType: constants.AssetAuditOpCardAutoStart,
|
||||
OperationDesc: "自动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
attempt.log.IntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结复机 Integration Log 失败")
|
||||
}
|
||||
|
||||
s.logger.Info("卡已自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: assetAuditSvc.SystemOperator("系统任务"),
|
||||
OperationType: constants.AssetAuditOpCardAutoStart,
|
||||
OperationDesc: "自动复机",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
|
||||
operator := assetAuditSvc.SystemOperator("系统任务")
|
||||
operationType := constants.AssetAuditOpCardAutoStop
|
||||
operationDesc := "自动停卡"
|
||||
if stopReason == constants.StopReasonManual {
|
||||
operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
operationType = constants.AssetAuditOpCardManualStop
|
||||
operationDesc = "手动停卡"
|
||||
}
|
||||
|
||||
actionCode, summary := stopAuditAction(ctx, stopReason)
|
||||
if s.gatewayClient == nil {
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(failErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: operator,
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc + "执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "",
|
||||
cardSnapshot(card), nil, failErr)
|
||||
return failErr
|
||||
}
|
||||
|
||||
@@ -576,7 +558,14 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.String("stop_reason", stopReason))
|
||||
|
||||
seriesKey := cardCommandSeriesKey(ctx)
|
||||
attemptObserver := &cardCommandAttemptObserver{
|
||||
service: s, card: card, operation: constants.IntegrationOperationGatewayStopCard,
|
||||
scene: constants.CardObservationSceneBusinessStop, seriesKey: seriesKey,
|
||||
}
|
||||
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
|
||||
var lastErr error
|
||||
lastIntegrationID := ""
|
||||
for i := 0; i < s.maxRetries; i++ {
|
||||
if i > 0 {
|
||||
s.logger.Debug("重试调用停机接口",
|
||||
@@ -585,107 +574,83 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
|
||||
err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
|
||||
CardNo: card.ICCID,
|
||||
})
|
||||
if err == nil {
|
||||
callErr := s.gatewayClient.StopCard(gatewayCtx, &gateway.CardOperationReq{CardNo: card.ICCID})
|
||||
lastIntegrationID = attemptObserver.lastIntegrationID
|
||||
if attemptObserver.recordingErr != nil {
|
||||
s.logger.Error("记录停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))
|
||||
lastErr = attemptObserver.lastCallErr
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
break
|
||||
}
|
||||
if callErr == nil {
|
||||
attempt := attemptObserver.successful
|
||||
s.logger.Info("网关停机成功",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
now := time.Now()
|
||||
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
|
||||
if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": stopReason,
|
||||
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil {
|
||||
}, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString(), actionCode, summary, lastIntegrationID); updateErr != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(logErr))
|
||||
}
|
||||
s.logger.Error("停机 Gateway 成功但 DB 更新失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(updateErr))
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(updateErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: operator,
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc + "执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stop_reason": stopReason,
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, updateErr)
|
||||
return updateErr
|
||||
}
|
||||
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: operator,
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc,
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stop_reason": stopReason,
|
||||
},
|
||||
})
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
lastErr = callErr
|
||||
s.logger.Warn("调用停机接口失败,准备重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
zap.Error(callErr))
|
||||
}
|
||||
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(lastErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
Operator: operator,
|
||||
OperationType: operationType,
|
||||
OperationDesc: operationDesc + "执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stop_reason": stopReason,
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr)
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// resumeCardWithRetry 调用运营商复机接口(带重试机制)
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error {
|
||||
// resumeCardWithRetry 调用运营商复机接口(带重试机制)。
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, error) {
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
|
||||
return nil, failErr
|
||||
}
|
||||
|
||||
s.logger.Info("调用网关复机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
seriesKey := cardCommandSeriesKey(ctx)
|
||||
attemptObserver := &cardCommandAttemptObserver{
|
||||
service: s, card: card, operation: constants.IntegrationOperationGatewayStartCard,
|
||||
scene: constants.CardObservationSceneBusinessResume, seriesKey: seriesKey,
|
||||
}
|
||||
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
|
||||
var lastErr error
|
||||
lastIntegrationID := ""
|
||||
for i := 0; i < s.maxRetries; i++ {
|
||||
if i > 0 {
|
||||
s.logger.Debug("重试调用复机接口",
|
||||
@@ -698,22 +663,34 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
|
||||
if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendMachineSeparated {
|
||||
req.Extend = constants.GatewayCardStartExtendMachineSeparated
|
||||
}
|
||||
err := s.gatewayClient.StartCard(ctx, req)
|
||||
if err == nil {
|
||||
callErr := s.gatewayClient.StartCard(gatewayCtx, req)
|
||||
lastIntegrationID = attemptObserver.lastIntegrationID
|
||||
if attemptObserver.recordingErr != nil {
|
||||
s.logger.Error("记录复机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))
|
||||
lastErr = attemptObserver.lastCallErr
|
||||
if lastErr == nil {
|
||||
lastErr = attemptObserver.recordingErr
|
||||
}
|
||||
break
|
||||
}
|
||||
if callErr == nil {
|
||||
s.logger.Info("网关复机成功",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
return nil
|
||||
return attemptObserver.successful, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
lastErr = callErr
|
||||
s.logger.Warn("调用复机接口失败,准备重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
zap.Error(callErr))
|
||||
}
|
||||
|
||||
return lastErr
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
|
||||
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr)
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// StartMachineSeparatedCard 对机卡分离停机卡执行复机
|
||||
@@ -722,8 +699,11 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
if card == nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络"
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
|
||||
return failErr
|
||||
}
|
||||
|
||||
gatewayExtend := strings.TrimSpace(card.GatewayExtend)
|
||||
@@ -737,108 +717,43 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机被拒绝(风险状态)",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if gatewayExtend != constants.GatewayCardExtendMachineSeparated {
|
||||
denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
AfterData: map[string]any{
|
||||
"gateway_extend": gatewayExtend,
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
|
||||
cardSnapshot(card), map[string]any{"gateway_extend": gatewayExtend}, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
AfterData: map[string]any{
|
||||
"gateway_extend": gatewayExtend,
|
||||
},
|
||||
})
|
||||
return wrapErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
"gateway_extend": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
"gateway_extend": gatewayExtend,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
"gateway_extend": "",
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr)
|
||||
return wrapErr
|
||||
}
|
||||
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
"gateway_extend": gatewayExtend,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
"gateway_extend": "",
|
||||
},
|
||||
})
|
||||
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结 OpenAPI 复机 Integration Log 失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -846,32 +761,13 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error {
|
||||
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
denyErr := errors.New(errors.CodeNotFound, "卡不存在")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStop,
|
||||
OperationDesc: "手动停卡被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetIdentifier: iccid,
|
||||
})
|
||||
return denyErr
|
||||
return errors.New(errors.CodeNotFound, "卡不存在")
|
||||
}
|
||||
actionCode, summary := stopAuditAction(ctx, constants.StopReasonManual)
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStop,
|
||||
OperationDesc: "手动停卡被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
|
||||
@@ -882,41 +778,19 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
|
||||
if exists > 0 {
|
||||
denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStop,
|
||||
OperationDesc: "手动停卡被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
AfterData: map[string]any{
|
||||
"device_id": binding.DeviceID,
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
|
||||
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
|
||||
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStop,
|
||||
OperationDesc: "手动停卡执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
|
||||
return wrapErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
|
||||
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -926,18 +800,9 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
|
||||
func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error {
|
||||
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
|
||||
if err != nil {
|
||||
denyErr := errors.New(errors.CodeNotFound, "卡不存在")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetIdentifier: iccid,
|
||||
})
|
||||
return denyErr
|
||||
return errors.New(errors.CodeNotFound, "卡不存在")
|
||||
}
|
||||
actionCode, summary := constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络"
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,拒绝复机
|
||||
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
|
||||
@@ -948,33 +813,13 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
|
||||
@@ -985,106 +830,52 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
|
||||
if exists > 0 {
|
||||
denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
AfterData: map[string]any{
|
||||
"device_id": binding.DeviceID,
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
|
||||
cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr)
|
||||
return denyErr
|
||||
}
|
||||
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
|
||||
wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr)
|
||||
return wrapErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
|
||||
if err != nil {
|
||||
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
return wrapErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
|
||||
if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
|
||||
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
|
||||
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
|
||||
}
|
||||
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机执行失败",
|
||||
ResultStatus: constants.AssetAuditResultFailed,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
},
|
||||
})
|
||||
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
|
||||
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr)
|
||||
return wrapErr
|
||||
}
|
||||
|
||||
s.reschedulePolling(ctx, card.ID)
|
||||
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机",
|
||||
ResultStatus: constants.AssetAuditResultSuccess,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: map[string]any{
|
||||
"network_status": card.NetworkStatus,
|
||||
"stop_reason": card.StopReason,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"stop_reason": "",
|
||||
},
|
||||
})
|
||||
|
||||
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结人工复机 Integration Log 失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error {
|
||||
if s.db == nil || s.observationSeriesEvents == nil {
|
||||
func (s *StopResumeService) updateCardAndAppendNetworkSeries(
|
||||
ctx context.Context,
|
||||
card *model.IotCard,
|
||||
fields map[string]any,
|
||||
scene, expected, operationID, actionCode, summary, integrationID string,
|
||||
) error {
|
||||
if s.db == nil || s.observationSeriesEvents == nil || card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置")
|
||||
}
|
||||
requestID := requestIDFromContext(ctx)
|
||||
@@ -1092,15 +883,21 @@ func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context
|
||||
requestID = operationID
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(fields).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败")
|
||||
}
|
||||
if err := s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, constants.AuditResultSuccess,
|
||||
integrationID,
|
||||
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
|
||||
fields, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
|
||||
return nil
|
||||
}
|
||||
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
|
||||
EventID: "card-observation:network-command:" + operationID,
|
||||
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
|
||||
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID,
|
||||
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
|
||||
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
|
||||
751
internal/service/iot_card/unified_audit.go
Normal file
751
internal/service/iot_card/unified_audit.go
Normal file
@@ -0,0 +1,751 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// SetAccessAudit 注入 IoT 卡身份生命周期的统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// WriteCardStateAudit 将卡观测事务中的人工状态操作写入统一 Audit Event。
|
||||
func (s *Service) WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input cardapp.StateAudit) error {
|
||||
extraResources := make([]audit.ResourceInput, 0, 1)
|
||||
if input.IntegrationID != "" {
|
||||
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID))
|
||||
}
|
||||
return s.appendCardLifecycleAudit(ctx, tx, input.ActionCode, input.Summary, constants.AuditResultSuccess,
|
||||
input.Card, input.BeforeData, input.AfterData, nil, extraResources...)
|
||||
}
|
||||
|
||||
// WriteCardStateFailure 使用独立短事务记录已解析卡资源后的回调失败。
|
||||
func (s *Service) WriteCardStateFailure(ctx context.Context, input cardapp.StateAudit, businessErr error) {
|
||||
extraResources := make([]audit.ResourceInput, 0, 1)
|
||||
if input.IntegrationID != "" {
|
||||
extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID))
|
||||
}
|
||||
s.recordCardLifecycleFailure(ctx, input.ActionCode, input.Summary, constants.AuditResultFailed,
|
||||
input.Card, input.Card.ID, businessErr, extraResources...)
|
||||
}
|
||||
|
||||
func callbackIntegrationAuditResource(ctx context.Context, integrationID string) audit.ResourceInput {
|
||||
linkage := auditcontext.From(ctx)
|
||||
correlationID := linkage.CorrelationID
|
||||
if correlationID == "" {
|
||||
correlationID = linkage.RequestID
|
||||
}
|
||||
return audit.ResourceInput{
|
||||
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCallbackIntegration,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"integration_id": integrationID, "provider": linkage.ActorID,
|
||||
"direction": constants.IntegrationDirectionInbound, "correlation_id": correlationID,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}
|
||||
}
|
||||
|
||||
func cardRefreshAuditAction(ctx context.Context) (string, bool) {
|
||||
switch auditcontext.From(ctx).ActorKind {
|
||||
case constants.AuditActorAccount:
|
||||
return constants.AuditActionIotCardManualRefreshed, true
|
||||
case constants.AuditActorPersonalCustomer:
|
||||
return constants.AuditActionIotCardPersonalRefreshed, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateCardRefreshCompletion(ctx context.Context, card *model.IotCard, syncTime time.Time, result, summary string) error {
|
||||
actionCode, audited := cardRefreshAuditAction(ctx)
|
||||
if !audited {
|
||||
return s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{"last_sync_time": syncTime})
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("last_sync_time", syncTime).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡刷新时间失败")
|
||||
}
|
||||
return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result,
|
||||
card, map[string]any{"last_sync_time": card.LastSyncTime}, map[string]any{"last_sync_time": syncTime}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordCardRefreshFailure(ctx context.Context, card *model.IotCard, result string, businessErr error) {
|
||||
actionCode, audited := cardRefreshAuditAction(ctx)
|
||||
if !audited || card == nil {
|
||||
return
|
||||
}
|
||||
s.recordCardLifecycleFailure(ctx, actionCode, "人工刷新 IoT 卡未完成", result, card, card.ID, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) completeCardRefreshAttempt(
|
||||
ctx context.Context,
|
||||
card *model.IotCard,
|
||||
attempt *gatewayAttempt,
|
||||
callErr error,
|
||||
stateChanged bool,
|
||||
message string,
|
||||
) error {
|
||||
if err := s.completeGatewayCardAttempt(ctx, attempt, callErr, stateChanged); err != nil {
|
||||
wrapped := errors.Wrap(errors.CodeDatabaseError, err, message)
|
||||
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapped)
|
||||
return wrapped
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) appendCardLifecycleAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary, result string,
|
||||
card *model.IotCard,
|
||||
beforeData, afterData map[string]any,
|
||||
businessErr error,
|
||||
extraResources ...audit.ResourceInput,
|
||||
) error {
|
||||
if s.auditWriter == nil || card == nil || card.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置或资源不完整")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &resourceID,
|
||||
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
}}
|
||||
resources = append(resources, extraResources...)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordCardLifecycleFailure(ctx context.Context, actionCode, summary, result string, card *model.IotCard, cardID uint, businessErr error, extraResources ...audit.ResourceInput) {
|
||||
if card == nil {
|
||||
card = &model.IotCard{}
|
||||
card.ID = cardID
|
||||
}
|
||||
if s.db == nil || s.auditWriter == nil || card.ID == 0 {
|
||||
recordCardAuditSecondaryFailure(ctx, actionCode, cardID, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result, card, nil, nil, businessErr, extraResources...)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendBatchDeleteAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, batchTotal int) error {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if s.auditWriter == nil || linkage.RequestID == "" {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计上下文不完整")
|
||||
}
|
||||
rootEventID := stableCardBatchEventID("delete", linkage.RequestID)
|
||||
result := constants.AuditResultSuccess
|
||||
if len(cards) < batchTotal {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
children := make([]audit.AppendInput, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: stableCardBatchEventID("delete-card", linkage.RequestID+":"+resourceID),
|
||||
ActionCode: constants.AuditActionIotCardDeleted, Summary: "批量删除 IoT 卡", Result: constants.AuditResultSuccess,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &resourceID,
|
||||
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: cardSnapshot(card),
|
||||
AfterData: map[string]any{"deleted": true},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡已删除",
|
||||
}},
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootEventID, ActionCode: constants.AuditActionIotCardBatchDeleted,
|
||||
Summary: "批量删除 IoT 卡", Result: result,
|
||||
BatchTotal: batchTotal, SuccessCount: len(cards), FailCount: batchTotal - len(cards),
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
|
||||
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cards)},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordBatchDeleteFailure(ctx context.Context, cardIDs []uint, businessErr error) {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if s.db == nil || s.auditWriter == nil || linkage.RequestID == "" {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: stableCardBatchEventID("delete-failed", linkage.RequestID),
|
||||
ActionCode: constants.AuditActionIotCardBatchDeleted, Summary: "批量删除 IoT 卡失败",
|
||||
Result: constants.AuditResultFailed, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
BatchTotal: len(cardIDs), FailCount: len(cardIDs),
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
|
||||
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cardIDs)},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
type cardAuditOutcome struct {
|
||||
Result string
|
||||
Summary string
|
||||
}
|
||||
|
||||
func cardAuditOutcomes(cards []*model.IotCard, result, summary string) map[uint]cardAuditOutcome {
|
||||
outcomes := make(map[uint]cardAuditOutcome, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil && card.ID > 0 {
|
||||
outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary}
|
||||
}
|
||||
}
|
||||
return outcomes
|
||||
}
|
||||
|
||||
func setCardAuditOutcomes(outcomes map[uint]cardAuditOutcome, cardIDs []uint, result, summary string) {
|
||||
for _, cardID := range cardIDs {
|
||||
outcomes[cardID] = cardAuditOutcome{Result: result, Summary: summary}
|
||||
}
|
||||
}
|
||||
|
||||
func setCardAuditOutcomeByICCID(outcomes map[uint]cardAuditOutcome, cards []*model.IotCard, iccid, result, summary string) {
|
||||
for _, card := range cards {
|
||||
if card != nil && (card.ICCID == iccid || card.ICCID19 == iccid || card.ICCID20 != nil && *card.ICCID20 == iccid) {
|
||||
outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendCardTransferAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
cards []*model.IotCard,
|
||||
outcomes map[uint]cardAuditOutcome,
|
||||
records []*model.AssetAllocationRecord,
|
||||
newShopID *uint,
|
||||
newStatus, batchTotal, successCount, failCount int,
|
||||
businessErr error,
|
||||
) error {
|
||||
shops, err := loadCardTransferAuditShops(ctx, tx, cards, records, newShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordByCardID := make(map[uint]*model.AssetAllocationRecord, len(records))
|
||||
for _, record := range records {
|
||||
if record != nil {
|
||||
recordByCardID[record.AssetID] = record
|
||||
}
|
||||
}
|
||||
items := make([]cardBatchAuditItem, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
outcome, ok := outcomes[card.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
beforeData := map[string]any{"shop_id": card.ShopID, "status": card.Status}
|
||||
var afterData map[string]any
|
||||
if outcome.Result == constants.AuditResultSuccess {
|
||||
afterData = map[string]any{"shop_id": newShopID, "status": newStatus}
|
||||
}
|
||||
references := cardTransferAuditReferences(card, recordByCardID[card.ID], newShopID, shops)
|
||||
references = append(references, deviceReferences[card.ID]...)
|
||||
items = append(items, cardBatchAuditItem{
|
||||
Card: card, Result: outcome.Result, Summary: outcome.Summary,
|
||||
BeforeData: beforeData, AfterData: afterData,
|
||||
References: references,
|
||||
})
|
||||
}
|
||||
allocationNo := ""
|
||||
if len(records) > 0 && records[0] != nil {
|
||||
allocationNo = records[0].AllocationNo
|
||||
}
|
||||
return s.appendCardBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
|
||||
batchTotal, successCount, failCount, items,
|
||||
map[string]any{"allocation_no": allocationNo, "to_shop_id": newShopID, "new_status": newStatus}, businessErr)
|
||||
}
|
||||
|
||||
func loadCardTransferAuditShops(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, records []*model.AssetAllocationRecord, newShopID *uint) (map[uint]*model.Shop, error) {
|
||||
shopIDs := make(map[uint]struct{})
|
||||
if newShopID != nil && *newShopID > 0 {
|
||||
shopIDs[*newShopID] = struct{}{}
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card != nil && card.ShopID != nil && *card.ShopID > 0 {
|
||||
shopIDs[*card.ShopID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
if record.FromOwnerType == constants.OwnerTypeShop && record.FromOwnerID != nil {
|
||||
shopIDs[*record.FromOwnerID] = struct{}{}
|
||||
}
|
||||
if record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 {
|
||||
shopIDs[record.ToOwnerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(shopIDs))
|
||||
for id := range shopIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var rows []*model.Shop
|
||||
if len(ids) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
shops := make(map[uint]*model.Shop, len(rows))
|
||||
for _, shop := range rows {
|
||||
shops[shop.ID] = shop
|
||||
}
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
func cardTransferAuditReferences(card *model.IotCard, record *model.AssetAllocationRecord, targetShopID *uint, shops map[uint]*model.Shop) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, 3)
|
||||
if record != nil && record.ID > 0 {
|
||||
recordID := strconv.FormatUint(uint64(record.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetAllocationRecord, ID: &recordID,
|
||||
Key: recordID, DisplayName: record.AllocationNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAllocationRecord,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": record.ID, "allocation_no": record.AllocationNo, "asset_type": record.AssetType,
|
||||
"asset_id": record.AssetID, "asset_identifier": record.AssetIdentifier,
|
||||
"from_owner_type": record.FromOwnerType, "from_owner_id": record.FromOwnerID,
|
||||
"to_owner_type": record.ToOwnerType, "to_owner_id": record.ToOwnerID,
|
||||
},
|
||||
AfterData: map[string]any{"created": true}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
sourceShopID := card.ShopID
|
||||
if record != nil && record.FromOwnerType == constants.OwnerTypeShop {
|
||||
sourceShopID = record.FromOwnerID
|
||||
}
|
||||
if sourceShopID != nil && *sourceShopID > 0 {
|
||||
resources = appendShopAuditReference(resources, shops[*sourceShopID], *sourceShopID, constants.AuditResourceRoleTransferSourceShop)
|
||||
}
|
||||
if record != nil && record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 {
|
||||
resources = appendShopAuditReference(resources, shops[record.ToOwnerID], record.ToOwnerID, constants.AuditResourceRoleTransferTargetShop)
|
||||
} else if targetShopID != nil && *targetShopID > 0 {
|
||||
resources = appendShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func appendShopAuditReference(resources []audit.ResourceInput, shop *model.Shop, shopID uint, role string) []audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(shopID), 10)
|
||||
name := id
|
||||
identity := map[string]any{"id": shopID}
|
||||
if shop != nil {
|
||||
name = shop.ShopName
|
||||
identity = map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level}
|
||||
}
|
||||
return append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: role,
|
||||
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
|
||||
type cardBatchAuditItem struct {
|
||||
Card *model.IotCard
|
||||
PrimaryRole string
|
||||
Result string
|
||||
Summary string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
References []audit.ResourceInput
|
||||
}
|
||||
|
||||
func (s *Service) appendCardBatchAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
items []cardBatchAuditItem,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
linkage := auditcontext.From(ctx)
|
||||
if s.auditWriter == nil || linkage.RequestID == "" {
|
||||
return errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计上下文不完整")
|
||||
}
|
||||
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
children := make([]audit.AppendInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Card == nil || item.Card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
cardID := strconv.FormatUint(uint64(item.Card.ID), 10)
|
||||
primaryRole := item.PrimaryRole
|
||||
if primaryRole == "" {
|
||||
primaryRole = constants.AuditResourceRoleIotCardTransferTarget
|
||||
}
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID,
|
||||
Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: primaryRole,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), BeforeData: item.BeforeData, AfterData: item.AfterData,
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: item.Summary,
|
||||
}}
|
||||
resources = append(resources, item.References...)
|
||||
childErrorCode, childErrorSummary := "", ""
|
||||
if item.Result == constants.AuditResultFailed || item.Result == constants.AuditResultDenied {
|
||||
childErrorCode, childErrorSummary = errorCode, errorSummary
|
||||
}
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: stableCardBatchEventID(kind+"-"+item.Result+"-card", linkage.RequestID+":"+cardID),
|
||||
ActionCode: itemAction, Summary: item.Summary, ScopeType: constants.AuditScopePlatform, Result: item.Result,
|
||||
ErrorCode: childErrorCode, ErrorSummary: childErrorSummary, Resources: resources,
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: stableCardBatchEventID(kind+"-"+result, linkage.RequestID),
|
||||
ActionCode: rootAction, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: result,
|
||||
ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, Metadata: metadata,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch,
|
||||
IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(items)},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordCardTransferAuditFailure(
|
||||
ctx context.Context,
|
||||
rootAction, itemAction, kind, summary, result string,
|
||||
cards []*model.IotCard,
|
||||
outcomes map[uint]cardAuditOutcome,
|
||||
newShopID *uint,
|
||||
newStatus, batchTotal, successCount, failCount int,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
|
||||
recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
|
||||
cards, outcomes, nil, newShopID, newStatus, batchTotal, successCount, failCount, businessErr)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendCardSeriesBindingAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
cards []*model.IotCard,
|
||||
outcomes map[uint]cardAuditOutcome,
|
||||
seriesID *uint,
|
||||
result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) error {
|
||||
series, err := loadCardSeriesAuditResources(ctx, tx, cards, seriesID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]cardBatchAuditItem, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
outcome, ok := outcomes[card.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var afterData map[string]any
|
||||
if outcome.Result == constants.AuditResultSuccess {
|
||||
afterData = map[string]any{"series_id": seriesID}
|
||||
}
|
||||
references := cardSeriesAuditReferences(card.SeriesID, seriesID, series)
|
||||
references = append(references, deviceReferences[card.ID]...)
|
||||
items = append(items, cardBatchAuditItem{
|
||||
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardSeriesTarget,
|
||||
Result: outcome.Result, Summary: outcome.Summary,
|
||||
BeforeData: map[string]any{"series_id": card.SeriesID}, AfterData: afterData,
|
||||
References: references,
|
||||
})
|
||||
}
|
||||
return s.appendCardBatchAudit(ctx, tx,
|
||||
constants.AuditActionIotCardSeriesBindingBatch,
|
||||
constants.AuditActionIotCardSeriesBound,
|
||||
"series-binding", "批量设置 IoT 卡系列绑定", result,
|
||||
batchTotal, successCount, failCount, items, metadata, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) appendCardRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, policy string) error {
|
||||
deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]cardBatchAuditItem, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 || card.RealnamePolicy == policy {
|
||||
continue
|
||||
}
|
||||
items = append(items, cardBatchAuditItem{
|
||||
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget,
|
||||
Result: constants.AuditResultSuccess, Summary: "更新 IoT 卡实名策略",
|
||||
BeforeData: map[string]any{"realname_policy": card.RealnamePolicy},
|
||||
AfterData: map[string]any{"realname_policy": policy},
|
||||
References: deviceReferences[card.ID],
|
||||
})
|
||||
}
|
||||
return s.appendCardBatchAudit(ctx, tx,
|
||||
constants.AuditActionIotCardRealnamePolicyBatchUpdated,
|
||||
constants.AuditActionIotCardRealnamePolicyUpdated,
|
||||
"realname-policy", "批量更新 IoT 卡实名策略", constants.AuditResultSuccess,
|
||||
len(items), len(items), 0, items, map[string]any{"realname_policy": policy, "requested_count": len(cards)}, nil)
|
||||
}
|
||||
|
||||
func (s *Service) recordCardRealnamePolicyBatchFailure(ctx context.Context, cards []*model.IotCard, policy, result string, businessErr error) {
|
||||
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
items := make([]cardBatchAuditItem, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card == nil || card.ID == 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, cardBatchAuditItem{
|
||||
Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget,
|
||||
Result: result, Summary: "更新 IoT 卡实名策略未完成",
|
||||
BeforeData: map[string]any{"realname_policy": card.RealnamePolicy},
|
||||
AfterData: map[string]any{"requested_realname_policy": policy},
|
||||
})
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardBatchAudit(ctx, tx,
|
||||
constants.AuditActionIotCardRealnamePolicyBatchUpdated,
|
||||
constants.AuditActionIotCardRealnamePolicyUpdated,
|
||||
"realname-policy", "批量更新 IoT 卡实名策略未完成", result,
|
||||
len(cards), 0, len(cards), items, map[string]any{"realname_policy": policy}, businessErr)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardRealnamePolicyBatchUpdated, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCardSeriesAuditResources(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) {
|
||||
seriesIDs := make(map[uint]struct{})
|
||||
if targetSeriesID != nil && *targetSeriesID > 0 {
|
||||
seriesIDs[*targetSeriesID] = struct{}{}
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card != nil && card.SeriesID != nil && *card.SeriesID > 0 {
|
||||
seriesIDs[*card.SeriesID] = struct{}{}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(seriesIDs))
|
||||
for id := range seriesIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var rows []*model.PackageSeries
|
||||
if len(ids) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
series := make(map[uint]*model.PackageSeries, len(rows))
|
||||
for _, item := range rows {
|
||||
series[item.ID] = item
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func cardSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput {
|
||||
resources := make([]audit.ResourceInput, 0, 2)
|
||||
if previousID != nil && *previousID > 0 {
|
||||
resources = appendPackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries)
|
||||
}
|
||||
if targetID != nil && *targetID > 0 {
|
||||
resources = appendPackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func appendPackageSeriesAuditReference(resources []audit.ResourceInput, series *model.PackageSeries, seriesID uint, role string) []audit.ResourceInput {
|
||||
id := strconv.FormatUint(uint64(seriesID), 10)
|
||||
name := id
|
||||
identity := map[string]any{"id": seriesID}
|
||||
if series != nil {
|
||||
name = series.SeriesName
|
||||
identity = map[string]any{"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName, "status": series.Status}
|
||||
}
|
||||
return append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePackageSeries, ID: &id, Key: id, DisplayName: name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: role,
|
||||
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
})
|
||||
}
|
||||
|
||||
func loadCardDeviceAuditReferences(ctx context.Context, tx *gorm.DB, cards []*model.IotCard) (map[uint][]audit.ResourceInput, error) {
|
||||
cardByID := make(map[uint]*model.IotCard, len(cards))
|
||||
cardIDs := make([]uint, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil && card.ID > 0 {
|
||||
cardByID[card.ID] = card
|
||||
cardIDs = append(cardIDs, card.ID)
|
||||
}
|
||||
}
|
||||
result := make(map[uint][]audit.ResourceInput)
|
||||
if len(cardIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if err := tx.WithContext(ctx).Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1).Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceIDs := make([]uint, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
deviceIDs = append(deviceIDs, binding.DeviceID)
|
||||
}
|
||||
var devices []*model.Device
|
||||
if len(deviceIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
deviceByID := make(map[uint]*model.Device, len(devices))
|
||||
for _, device := range devices {
|
||||
deviceByID[device.ID] = device
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
card := cardByID[binding.IotCardID]
|
||||
device := deviceByID[binding.DeviceID]
|
||||
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
|
||||
deviceID := strconv.FormatUint(uint64(binding.DeviceID), 10)
|
||||
deviceKey, deviceName := deviceID, deviceID
|
||||
deviceIdentity := map[string]any{"id": binding.DeviceID}
|
||||
deviceVirtualNo := ""
|
||||
if device != nil {
|
||||
deviceVirtualNo = device.VirtualNo
|
||||
if device.VirtualNo != "" {
|
||||
deviceKey = device.VirtualNo
|
||||
}
|
||||
deviceName = device.DeviceName
|
||||
if deviceName == "" {
|
||||
deviceName = device.VirtualNo
|
||||
}
|
||||
deviceIdentity = map[string]any{"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN, "generation": device.Generation}
|
||||
}
|
||||
cardICCID, cardVirtualNo := "", ""
|
||||
if card != nil {
|
||||
cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo
|
||||
}
|
||||
result[binding.IotCardID] = append(result[binding.IotCardID],
|
||||
audit.ResourceInput{
|
||||
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
|
||||
Key: bindingID, DisplayName: deviceVirtualNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardDeviceBinding,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": deviceVirtualNo,
|
||||
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
|
||||
"iccid": cardICCID, "virtual_no": cardVirtualNo, "is_current": binding.IsCurrent,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
},
|
||||
audit.ResourceInput{
|
||||
Type: constants.AuditResourceDevice, ID: &deviceID,
|
||||
Key: deviceKey, DisplayName: deviceName,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardRelatedDevice,
|
||||
IdentitySnapshot: deviceIdentity, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
},
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordCardSeriesBindingAuditFailure(
|
||||
ctx context.Context,
|
||||
cards []*model.IotCard,
|
||||
outcomes map[uint]cardAuditOutcome,
|
||||
seriesID *uint,
|
||||
result string,
|
||||
batchTotal, successCount, failCount int,
|
||||
metadata map[string]any,
|
||||
businessErr error,
|
||||
) {
|
||||
if s.db == nil || s.auditWriter == nil || len(cards) == 0 {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡系列绑定审计接缝未配置"))
|
||||
return
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.appendCardSeriesBindingAudit(ctx, tx, cards, outcomes, seriesID, result,
|
||||
batchTotal, successCount, failCount, metadata, businessErr)
|
||||
}); err != nil {
|
||||
recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func stableCardBatchEventID(kind, key string) string {
|
||||
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("iot-card:"+kind+":"+key)).String()
|
||||
}
|
||||
|
||||
func recordCardAuditSecondaryFailure(ctx context.Context, actionCode string, cardID uint, businessErr, auditErr error) {
|
||||
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
|
||||
linkage := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
actionCode, strconv.FormatUint(uint64(cardID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, auditErr,
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,15 @@ package iot_card_import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AssetAuditService 资产审计服务接口。
|
||||
@@ -14,46 +18,44 @@ type AssetAuditService interface {
|
||||
LogOperation(ctx context.Context, log *model.AssetOperationLog)
|
||||
}
|
||||
|
||||
func (s *Service) logIotCardImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
|
||||
if s == nil || s.assetAudit == nil {
|
||||
return
|
||||
}
|
||||
if p.Operator.Type == "" {
|
||||
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
|
||||
}
|
||||
if p.OperationType == "" {
|
||||
p.OperationType = constants.AssetAuditOpIotCardImportTaskCreate
|
||||
}
|
||||
if p.AssetType == "" {
|
||||
p.AssetType = constants.AssetTypeIotCard
|
||||
}
|
||||
p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil)
|
||||
s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
|
||||
func (s *Service) writeImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.IotCardImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
|
||||
EventID: infraAudit.TaskEventID(constants.AuditResourceIotCardImportTask, task.ID, phase),
|
||||
ActionCode: constants.AuditActionIotCardImportTaskCreated, Summary: "创建 IoT 卡导入任务",
|
||||
TaskID: task.ID, TaskNo: task.TaskNo,
|
||||
Actor: infraAudit.ActorInput{
|
||||
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
|
||||
Name: middleware.GetUsernameFromContext(ctx),
|
||||
},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
|
||||
"carrier_id": task.CarrierID, "carrier_name": task.CarrierName, "batch_no": task.BatchNo,
|
||||
"card_category": task.CardCategory, "realname_policy": task.RealnamePolicy,
|
||||
},
|
||||
BeforeData: before, AfterData: after,
|
||||
})
|
||||
}
|
||||
|
||||
func newIotCardImportAuditParams(
|
||||
taskID uint,
|
||||
taskNo string,
|
||||
req *dto.ImportIotCardRequest,
|
||||
resultStatus string,
|
||||
err error,
|
||||
) assetAuditSvc.BuildLogParams {
|
||||
afterData := map[string]any{}
|
||||
if req != nil {
|
||||
afterData["carrier_id"] = req.CarrierID
|
||||
afterData["batch_no"] = req.BatchNo
|
||||
afterData["file_key"] = req.FileKey
|
||||
afterData["card_category"] = req.CardCategory
|
||||
afterData["realname_policy"] = req.RealnamePolicy
|
||||
func (s *Service) recordImportTaskAudit(ctx context.Context, task *model.IotCardImportTask, before, after map[string]any, result, phase string, errorCode int, summary string) {
|
||||
if s == nil || s.db == nil || s.auditWriter == nil || task == nil || task.TaskNo == "" {
|
||||
return
|
||||
}
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
|
||||
return assetAuditSvc.BuildLogParams{
|
||||
AssetID: taskID,
|
||||
AssetIdentifier: taskNo,
|
||||
OperationDesc: "创建IoT卡导入任务",
|
||||
ResultStatus: resultStatus,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AfterData: afterData,
|
||||
code := strconv.Itoa(errorCode)
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.writeImportTaskAudit(ctx, tx, task, before, after, result, phase, code, summary)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionIotCardImportTaskCreated, task.TaskNo, "", task.TaskNo, code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func importTaskState(task *model.IotCardImportTask) map[string]any {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"status": task.Status, "total_count": task.TotalCount, "success_count": task.SuccessCount,
|
||||
"skip_count": task.SkipCount, "fail_count": task.FailCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package iot_card_import
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
@@ -23,6 +25,7 @@ type Service struct {
|
||||
carrierStore carrierGetter
|
||||
queueClient *queue.Client
|
||||
assetAudit AssetAuditService
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
type carrierGetter interface {
|
||||
@@ -50,14 +53,19 @@ func New(
|
||||
importTaskStore *postgres.IotCardImportTaskStore,
|
||||
queueClient *queue.Client,
|
||||
assetAudit AssetAuditService,
|
||||
auditWriters ...*audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
service := &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
carrierStore: NewCarrierStore(db),
|
||||
queueClient: queueClient,
|
||||
assetAudit: assetAudit,
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
type IotCardImportPayload struct {
|
||||
@@ -67,16 +75,12 @@ type IotCardImportPayload struct {
|
||||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest) (*dto.ImportIotCardResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
appErr := errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID)
|
||||
if err != nil {
|
||||
appErr := errors.New(errors.CodeInvalidParam, "运营商不存在")
|
||||
s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
|
||||
return nil, appErr
|
||||
return nil, errors.New(errors.CodeInvalidParam, "运营商不存在")
|
||||
}
|
||||
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
@@ -103,9 +107,17 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "IoT 卡导入任务统一审计接缝未配置")
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeImportTaskAudit(ctx, tx, task, nil, importTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败")
|
||||
s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建 IoT 卡导入任务失败")
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
@@ -117,14 +129,23 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIotCardImport)),
|
||||
)
|
||||
if err != nil {
|
||||
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
before := importTaskState(task)
|
||||
if updateErr := tx.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
|
||||
"status": model.ImportTaskStatusFailed, "error_message": "任务入队失败", "completed_at": time.Now(), "updated_at": time.Now(),
|
||||
}).Error; updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, "任务入队失败"
|
||||
return s.writeImportTaskAudit(ctx, tx, task, before, importTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "IoT 卡导入任务入队失败")
|
||||
})
|
||||
if secondaryErr != nil {
|
||||
s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "IoT 卡导入任务入队失败")
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败")
|
||||
s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
|
||||
|
||||
return &dto.ImportIotCardResponse{
|
||||
TaskID: task.ID,
|
||||
TaskNo: taskNo,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAssetWalletOrderReservationLifecycle 验证个人钱包订单冻结、超额拦截、支付核销和历史订单兼容。
|
||||
func TestAssetWalletOrderReservationLifecycle(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
resourceID := uint(time.Now().UnixNano() & testIDMask)
|
||||
wallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeIotCard,
|
||||
ResourceID: resourceID,
|
||||
Balance: 1000,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(wallet).Error; err != nil {
|
||||
t.Fatalf("创建测试资产钱包失败:%v", err)
|
||||
}
|
||||
service := &Service{}
|
||||
order := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 700, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, order); err != nil {
|
||||
t.Fatalf("冻结订单金额失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
if order.AssetWalletReservationWalletID == nil || *order.AssetWalletReservationWalletID != wallet.ID || order.AssetWalletReservedAmount != 700 {
|
||||
t.Fatalf("订单预占快照错误:wallet_id=%v amount=%d", order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount)
|
||||
}
|
||||
|
||||
overdrawOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 400, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.freezeAssetWalletForOrder(context.Background(), tx, overdrawOrder); err == nil {
|
||||
t.Fatal("可用余额不足时必须拒绝第二笔冻结")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700)
|
||||
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, order, constants.AssetWalletResourceTypeIotCard, resourceID); err != nil {
|
||||
t.Fatalf("核销订单预占失败:%v", err)
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 0)
|
||||
|
||||
if err := tx.Model(&model.AssetWallet{}).Where("id = ?", wallet.ID).
|
||||
Updates(map[string]any{"balance": 300, "frozen_balance": 200, "version": 10}).Error; err != nil {
|
||||
t.Fatalf("准备历史订单场景失败:%v", err)
|
||||
}
|
||||
legacyOrder := &model.Order{
|
||||
OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal,
|
||||
PaymentMethod: model.PaymentMethodWallet, TotalAmount: 150, IotCardID: &resourceID,
|
||||
}
|
||||
if err := service.releaseAssetWalletReservation(context.Background(), tx, legacyOrder); err != nil {
|
||||
t.Fatalf("历史订单取消不应释放其他订单冻结额:%v", err)
|
||||
}
|
||||
if _, err := service.deductAssetWalletForOrder(context.Background(), tx, legacyOrder, constants.AssetWalletResourceTypeIotCard, resourceID); err == nil {
|
||||
t.Fatal("历史订单不得占用其他订单的冻结余额")
|
||||
}
|
||||
assertAssetWalletFunds(t, tx, wallet.ID, 300, 200)
|
||||
}
|
||||
|
||||
func assertAssetWalletFunds(t *testing.T, tx *gorm.DB, walletID uint, balance, frozenBalance int64) {
|
||||
t.Helper()
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.First(&wallet, walletID).Error; err != nil {
|
||||
t.Fatalf("查询资产钱包失败:%v", err)
|
||||
}
|
||||
if wallet.Balance != balance || wallet.FrozenBalance != frozenBalance {
|
||||
t.Fatalf("资产钱包金额错误:balance=%d frozen=%d,期望 balance=%d frozen=%d", wallet.Balance, wallet.FrozenBalance, balance, frozenBalance)
|
||||
}
|
||||
}
|
||||
363
internal/service/order/audit.go
Normal file
363
internal/service/order/audit.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package order
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// SetLifecycleAudit 注入订单生命周期统一审计 Writer。
|
||||
func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendOrderAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, order *model.Order, beforeData, afterData map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单统一审计接缝未配置")
|
||||
}
|
||||
resources, err := orderAuditResources(ctx, tx, order, beforeData, afterData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo,
|
||||
Metadata: map[string]any{
|
||||
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
|
||||
"purchase_role": order.PurchaseRole, "payment_method": order.PaymentMethod,
|
||||
},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, businessErr error) {
|
||||
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
resource := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
resource.BeforeData = orderStateData(order)
|
||||
correlationID := order.OrderNo
|
||||
if order.ID == 0 {
|
||||
correlationID = ""
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
CorrelationID: correlationID, Resources: []audit.ResourceInput{resource},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) recordAgentWalletOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, shopID uint, businessErr error) {
|
||||
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
orderSnapshot := *order
|
||||
if orderSnapshot.ID > 0 {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Unscoped().Model(&model.Order{}).Where("id = ?", orderSnapshot.ID).Count(&count).Error; err == nil && count == 0 {
|
||||
orderSnapshot.ID = 0
|
||||
}
|
||||
}
|
||||
primary := audit.OrderResource(&orderSnapshot, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
primary.BeforeData = orderStateData(order)
|
||||
resources := []audit.ResourceInput{primary}
|
||||
|
||||
var reservation model.AgentWalletReservation
|
||||
if order.ID > 0 {
|
||||
if err := s.db.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, order.ID).First(&reservation).Error; err == nil {
|
||||
reservationID := strconv.FormatUint(uint64(reservation.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWalletReservation, ID: &reservationID,
|
||||
Key: reservation.ReferenceType + ":" + strconv.FormatUint(uint64(reservation.ReferenceID), 10), DisplayName: "订单钱包预占 " + reservationID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletReservation,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": reservation.ID, "agent_wallet_id": reservation.AgentWalletID, "shop_id": reservation.ShopID,
|
||||
"amount": reservation.Amount, "status": reservation.Status,
|
||||
"reference_type": reservation.ReferenceType, "reference_id": reservation.ReferenceID,
|
||||
},
|
||||
BeforeData: map[string]any{"status": reservation.Status},
|
||||
})
|
||||
shopID = reservation.ShopID
|
||||
}
|
||||
}
|
||||
if shopID > 0 {
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err == nil {
|
||||
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderWallet,
|
||||
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": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
})
|
||||
}
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
CorrelationID: order.OrderNo, Metadata: map[string]any{"amount": order.TotalAmount}, Resources: resources,
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
// RecordCreateFailure 在订单创建调用方已识别资产后记录失败或拒绝事实。
|
||||
func (s *Service) RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error) {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", order, businessErr)
|
||||
}
|
||||
|
||||
func orderAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order, beforeData, afterData map[string]any) ([]audit.ResourceInput, error) {
|
||||
if order == nil || order.ID == 0 || order.OrderNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "订单审计资源不完整")
|
||||
}
|
||||
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
|
||||
primary.BeforeData, primary.AfterData = beforeData, afterData
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "订单状态已更新"
|
||||
resources := []audit.ResourceInput{primary}
|
||||
|
||||
buyer, err := orderBuyerAuditResource(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if buyer != nil {
|
||||
resources = append(resources, *buyer)
|
||||
}
|
||||
asset, err := orderAssetAuditResource(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asset != nil {
|
||||
resources = append(resources, *asset)
|
||||
}
|
||||
|
||||
packages, err := orderPackageAuditResources(ctx, tx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, packages...)
|
||||
finance, err := orderFinanceAuditResources(ctx, tx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(resources, finance...), nil
|
||||
}
|
||||
|
||||
func orderBuyerAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
|
||||
if order.BuyerID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
switch order.BuyerType {
|
||||
case model.BuyerTypeAgent:
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, order.BuyerID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家店铺审计快照失败")
|
||||
}
|
||||
resource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderBuyer)
|
||||
return &resource, nil
|
||||
case model.BuyerTypePersonal:
|
||||
var customer model.PersonalCustomer
|
||||
if err := tx.WithContext(ctx).First(&customer, order.BuyerID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(customer.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourcePersonalCustomer, ID: &id, Key: id, DisplayName: customer.Nickname,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderBuyer,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
|
||||
"wx_union_id": customer.WxUnionID, "status": customer.Status,
|
||||
},
|
||||
}
|
||||
return &resource, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func orderAssetAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
|
||||
if order.IotCardID != nil {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).First(&card, *order.IotCardID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联卡审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(card.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
|
||||
SubjectSummary: "关联订单状态已更新",
|
||||
}
|
||||
return &resource, nil
|
||||
}
|
||||
if order.DeviceID != nil {
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).First(&device, *order.DeviceID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联设备审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(device.ID), 10)
|
||||
resource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&device), DisplayName: device.VirtualNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
|
||||
SubjectSummary: "关联订单状态已更新",
|
||||
}
|
||||
return &resource, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func orderPackageAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) {
|
||||
var packages []model.Package
|
||||
if err := tx.WithContext(ctx).Model(&model.Package{}).Distinct("tb_package.*").
|
||||
Joins("JOIN tb_order_item item ON item.package_id = tb_package.id AND item.deleted_at IS NULL").
|
||||
Where("item.order_id = ?", orderID).Order("tb_package.id ASC").Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联套餐审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(packages))
|
||||
for i := range packages {
|
||||
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPackage, nil, nil))
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func orderFinanceAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order) ([]audit.ResourceInput, error) {
|
||||
resources := make([]audit.ResourceInput, 0, 5)
|
||||
agentResources, err := agentWalletOrderAuditResources(ctx, tx, order.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, agentResources...)
|
||||
assetResources, err := assetWalletOrderAuditResources(ctx, tx, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(resources, assetResources...)
|
||||
|
||||
var payments []model.Payment
|
||||
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&payments).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录审计快照失败")
|
||||
}
|
||||
for i := range payments {
|
||||
id := strconv.FormatUint(uint64(payments[i].ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourcePayment, ID: &id, Key: payments[i].PaymentNo, DisplayName: payments[i].PaymentNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderPayment,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": payments[i].ID, "payment_no": payments[i].PaymentNo, "order_id": payments[i].OrderID,
|
||||
"order_type": payments[i].OrderType, "payment_method": payments[i].PaymentMethod,
|
||||
"amount": payments[i].Amount, "status": payments[i].Status,
|
||||
"third_party_trade_no": payments[i].ThirdPartyTradeNo, "payment_config_id": payments[i].PaymentConfigID,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func agentWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) {
|
||||
var transactions []model.AgentWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, orderID).
|
||||
Order("id ASC").Find(&transactions).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包流水审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(transactions)*2)
|
||||
wallets := make(map[uint]struct{}, len(transactions))
|
||||
for i := range transactions {
|
||||
transaction := &transactions[i]
|
||||
if _, ok := wallets[transaction.AgentWalletID]; !ok {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理主钱包 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet,
|
||||
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},
|
||||
})
|
||||
wallets[transaction.AgentWalletID] = struct{}{}
|
||||
}
|
||||
id := strconv.FormatUint(uint64(transaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderWalletTransaction,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func assetWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderNo string) ([]audit.ResourceInput, error) {
|
||||
var transactions []model.AssetWalletTransaction
|
||||
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_no = ?", constants.ReferenceTypeOrder, orderNo).
|
||||
Order("id ASC").Find(&transactions).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包流水审计快照失败")
|
||||
}
|
||||
resources := make([]audit.ResourceInput, 0, len(transactions)*2)
|
||||
wallets := make(map[uint]struct{}, len(transactions))
|
||||
for i := range transactions {
|
||||
transaction := &transactions[i]
|
||||
if _, ok := wallets[transaction.AssetWalletID]; !ok {
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包审计快照失败")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet,
|
||||
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},
|
||||
})
|
||||
wallets[transaction.AssetWalletID] = struct{}{}
|
||||
}
|
||||
id := strconv.FormatUint(uint64(transaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletTransaction,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func orderStateData(order *model.Order) map[string]any {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"payment_status": order.PaymentStatus, "payment_method": order.PaymentMethod,
|
||||
"total_amount": order.TotalAmount, "actual_paid_amount": order.ActualPaidAmount,
|
||||
"paid_at": order.PaidAt, "expires_at": order.ExpiresAt,
|
||||
"purchase_role": order.PurchaseRole,
|
||||
}
|
||||
}
|
||||
78
internal/service/order/payment_audit.go
Normal file
78
internal/service/order/payment_audit.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"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"
|
||||
)
|
||||
|
||||
// SetPaymentIntegrationLog 注入支付渠道 Integration Log。
|
||||
func (s *Service) SetPaymentIntegrationLog(repository *integrationlog.Repository) {
|
||||
s.paymentIntegration = repository
|
||||
}
|
||||
|
||||
func (s *Service) startOrderPaymentAttempt(ctx context.Context, order *model.Order, provider, scene string) (*model.IntegrationLog, time.Time, error) {
|
||||
if s.paymentIntegration == nil {
|
||||
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "支付 Integration Log 接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(order.ID), 10)
|
||||
resourceKey, series, correlationID := order.OrderNo, "order-payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, order.OrderNo
|
||||
triggerSource, triggerScene := auditcontext.From(ctx).Source, scene
|
||||
log, err := s.paymentIntegration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationPaymentPreCreate,
|
||||
ResourceType: constants.AuditResourceOrder, ResourceID: &resourceID, ResourceKey: &resourceKey,
|
||||
ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene,
|
||||
TriggerSeries: &series, CorrelationID: &correlationID,
|
||||
RequestSummary: map[string]any{"payment_config_id": order.PaymentConfigID, "amount": order.TotalAmount},
|
||||
})
|
||||
return log, time.Now(), err
|
||||
}
|
||||
|
||||
func (s *Service) completeOrderPaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
|
||||
if log == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付 Integration Log 尝试不存在")
|
||||
}
|
||||
completion := integrationlog.Completion{
|
||||
Result: result, ProviderCode: providerCode, SafeProviderMessage: safeMessage,
|
||||
ResponseSummary: map[string]any{"success": result == constants.IntegrationResultSuccess},
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
}
|
||||
if result == constants.IntegrationResultUnknown {
|
||||
completion.RecoveryStrategy = "使用原业务单号向支付渠道查单,确认结果后再推进本地支付状态"
|
||||
}
|
||||
_, err := s.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) appendPaymentConfirmedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, beforePayment, afterPayment, beforeOrder, afterOrder map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置")
|
||||
}
|
||||
paymentResource := audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, beforePayment, afterPayment)
|
||||
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRolePaymentBusinessOrder)
|
||||
orderResource.BeforeData, orderResource.AfterData = beforeOrder, afterOrder
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectResult
|
||||
orderResource.SubjectSummary = "订单支付已确认"
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "第三方支付确认订单已支付",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: payment.PaymentNo, Resources: []audit.ResourceInput{paymentResource, orderResource},
|
||||
})
|
||||
}
|
||||
|
||||
func paymentStateData(payment *model.Payment) map[string]any {
|
||||
return map[string]any{
|
||||
"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo,
|
||||
"paid_at": payment.PaidAt,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -68,6 +71,8 @@ type Service struct {
|
||||
agentWalletDebit *walletapp.DebitService
|
||||
agentWalletReservation *walletapp.ReservationService
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
auditWriter *audit.Writer
|
||||
paymentIntegration *integrationlog.Repository
|
||||
}
|
||||
|
||||
// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。
|
||||
@@ -145,7 +150,7 @@ func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) {
|
||||
// CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活)
|
||||
// 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活
|
||||
// POST /api/admin/orders
|
||||
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) {
|
||||
resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
@@ -164,9 +169,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
deviceID = &resolvedDevice.ID
|
||||
resourceShopID = resolvedDevice.ShopID
|
||||
}
|
||||
auditOrder := &model.Order{
|
||||
OrderNo: "create:" + orderType + ":" + req.Identifier, OrderType: orderType,
|
||||
BuyerType: buyerType, BuyerID: buyerID, IotCardID: iotCardID, DeviceID: deviceID,
|
||||
AssetIdentifier: req.Identifier, PaymentMethod: req.PaymentMethod,
|
||||
}
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err)
|
||||
}()
|
||||
|
||||
var validationResult *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
operatorUserType := middleware.GetUserTypeFromContext(ctx)
|
||||
validationCtx := ctx
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && (operatorUserType == constants.UserTypeSuperAdmin || operatorUserType == constants.UserTypePlatform) {
|
||||
@@ -477,6 +489,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
PurchaseRole: purchaseRole,
|
||||
PaymentConfigID: paymentConfigID,
|
||||
}
|
||||
auditOrder = order
|
||||
|
||||
// 线下支付订单写入支付凭证 file_key 列表
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
@@ -549,9 +562,16 @@ func rewriteAdminAgentPackageOffShelfError(err error, resourceShopID *uint, shou
|
||||
// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态)
|
||||
// 保留原 Create() 方法的完整逻辑,H5 端行为不变
|
||||
// POST /api/h5/orders
|
||||
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) {
|
||||
var validationResult *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
auditOrder := &model.Order{
|
||||
OrderNo: "create:" + req.OrderType + ":" + strconv.FormatUint(uint64(buyerID), 10),
|
||||
OrderType: req.OrderType, BuyerType: buyerType, BuyerID: buyerID,
|
||||
IotCardID: req.IotCardID, DeviceID: req.DeviceID, PaymentMethod: req.PaymentMethod,
|
||||
}
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err)
|
||||
}()
|
||||
|
||||
if req.OrderType == model.OrderTypeSingleCard {
|
||||
if req.IotCardID == nil {
|
||||
@@ -776,6 +796,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
ExpiresAt: expiresAt,
|
||||
PaymentConfigID: h5PaymentConfigID,
|
||||
}
|
||||
auditOrder = order
|
||||
|
||||
items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap)
|
||||
|
||||
@@ -816,7 +837,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
// 待支付订单设置过期时间,超过 30 分钟未支付则自动取消
|
||||
expireTime := now.Add(constants.OrderExpireTimeout)
|
||||
order.ExpiresAt = &expireTime
|
||||
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||||
if err := s.CreatePendingOrder(ctx, order, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.markOrderCreated(ctx, idempotencyKey, order.ID)
|
||||
@@ -1134,6 +1155,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
}
|
||||
actualAmount := *order.ActualPaidAmount
|
||||
var existingOrderID uint
|
||||
walletDebitAttempted := false
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey)
|
||||
@@ -1159,6 +1181,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
|
||||
relatedShopID = &buyerShopID
|
||||
}
|
||||
walletDebitAttempted = true
|
||||
if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1168,11 +1191,17 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并使用钱包支付订单", order, nil, orderStateData(order)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if walletDebitAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, operatorShopID, err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if existingOrderID > 0 {
|
||||
@@ -1200,7 +1229,29 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并完成订单", order, nil, orderStateData(order))
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePendingOrder 在同一事务中创建待支付订单、冻结个人资产钱包、创建明细和审计事件。
|
||||
func (s *Service) CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.freezeAssetWalletForOrder(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
||||
}
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||||
}
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建待支付订单", order, nil, orderStateData(order))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1394,14 +1445,19 @@ func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID
|
||||
}
|
||||
|
||||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
err = errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if order.PaymentStatus != model.PaymentStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
|
||||
err = errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.cancelOrder(ctx, order)
|
||||
_, err = s.cancelOrder(ctx, order, constants.AuditActionOrderCancelled)
|
||||
return err
|
||||
}
|
||||
|
||||
// CancelExpiredOrders 批量取消已超时的待支付订单
|
||||
@@ -1420,7 +1476,8 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
|
||||
|
||||
cancelledCount := 0
|
||||
for _, order := range orders {
|
||||
if err := s.cancelOrder(ctx, order); err != nil {
|
||||
orderCtx := auditcontext.With(ctx, auditcontext.Context{CorrelationID: order.OrderNo})
|
||||
if _, err := s.cancelOrder(orderCtx, order, constants.AuditActionOrderExpiredClosed); err != nil {
|
||||
s.logger.Error("自动取消超时订单失败",
|
||||
zap.Uint("order_id", order.ID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -1443,8 +1500,10 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
|
||||
|
||||
// cancelOrder 内部取消订单逻辑(共用于手动取消和自动超时取消)
|
||||
// 在事务中执行:更新订单状态为已取消、清除过期时间、解冻钱包余额(如有)
|
||||
func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
func (s *Service) cancelOrder(ctx context.Context, order *model.Order, actionCode string) (bool, error) {
|
||||
changed := false
|
||||
walletReleaseAttempted := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 使用条件更新确保幂等性:只有待支付的订单才能取消
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
|
||||
@@ -1459,11 +1518,11 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
// 订单已被处理(幂等),直接返回
|
||||
return nil
|
||||
}
|
||||
changed = true
|
||||
|
||||
// 检查是否需要解冻钱包余额(混合支付场景)
|
||||
// 当前系统中钱包支付订单是立即支付的,不会进入待支付状态
|
||||
// 此处为预留逻辑,支持未来混合支付场景的钱包解冻
|
||||
// 待支付钱包订单取消时释放该订单创建时记录的资金预占。
|
||||
if order.PaymentMethod == model.PaymentMethodWallet {
|
||||
walletReleaseAttempted = order.BuyerType == model.BuyerTypeAgent
|
||||
if err := s.unfreezeWalletForCancel(ctx, tx, order); err != nil {
|
||||
s.logger.Error("取消订单时解冻钱包失败",
|
||||
zap.Uint("order_id", order.ID),
|
||||
@@ -1473,8 +1532,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusCancelled
|
||||
after.ExpiresAt = nil
|
||||
summary := "取消待支付订单"
|
||||
if actionCode == constants.AuditActionOrderExpiredClosed {
|
||||
summary = "关闭过期待支付订单"
|
||||
}
|
||||
return s.appendOrderAudit(ctx, tx, actionCode, summary, &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
if err != nil {
|
||||
s.recordOrderFailure(ctx, actionCode, "订单关闭失败", order, err)
|
||||
if walletReleaseAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderReleased, "释放代理主钱包订单预占失败", order, 0, err)
|
||||
}
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
// unfreezeWalletForCancel 取消订单时解冻钱包余额。
|
||||
@@ -1494,39 +1567,83 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde
|
||||
RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金",
|
||||
})
|
||||
} else if order.BuyerType == model.BuyerTypePersonal {
|
||||
// 个人客户钱包(卡/设备钱包)
|
||||
var resourceType string
|
||||
var resourceID uint
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
resourceType = "iot_card"
|
||||
resourceID = *order.IotCardID
|
||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
resourceType = "device"
|
||||
resourceID = *order.DeviceID
|
||||
} else {
|
||||
return errors.New(errors.CodeInternalError, "无法确定钱包归属")
|
||||
}
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeWalletNotFound, err, "查询资产钱包失败")
|
||||
}
|
||||
// 资产钱包解冻:直接减少冻结余额
|
||||
result := tx.Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "冻结余额不足,无法解冻")
|
||||
}
|
||||
return nil
|
||||
return s.releaseAssetWalletReservation(ctx, tx, order)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeAssetWalletForOrder 为个人待支付钱包订单冻结精确金额,并把钱包与金额写入订单快照。
|
||||
func (s *Service) freezeAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.BuyerType != model.BuyerTypePersonal || order.PaymentMethod != model.PaymentMethodWallet || order.TotalAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
if order.TotalAmount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "订单金额无效")
|
||||
}
|
||||
resourceType, resourceID, err := resolveOrderAssetWalletReference(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var wallet model.AssetWallet
|
||||
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND balance - frozen_balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance + ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结资产钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突")
|
||||
}
|
||||
order.AssetWalletReservationWalletID = &wallet.ID
|
||||
order.AssetWalletReservedAmount = order.TotalAmount
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAssetWalletReservation 释放订单快照指向的个人资产钱包预占。
|
||||
// 历史订单没有预占快照,取消时只更新订单状态,避免误释放其他订单的冻结资金。
|
||||
func (s *Service) releaseAssetWalletReservation(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
if order.AssetWalletReservationWalletID == nil && order.AssetWalletReservedAmount == 0 {
|
||||
return nil
|
||||
}
|
||||
if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount <= 0 {
|
||||
return errors.New(errors.CodeInternalError, "资产钱包预占快照不完整")
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND frozen_balance >= ?", *order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放资产钱包预占失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConflict, "资产钱包预占状态不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveOrderAssetWalletReference(order *model.Order) (string, uint, error) {
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil
|
||||
}
|
||||
if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil
|
||||
}
|
||||
return "", 0, errors.New(errors.CodeInternalError, "无法确定资产钱包归属")
|
||||
}
|
||||
|
||||
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error {
|
||||
paidAt := order.PaidAt
|
||||
if paidAt == nil {
|
||||
@@ -1548,7 +1665,7 @@ func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, pay
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
|
||||
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) {
|
||||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -1556,6 +1673,13 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
}
|
||||
return err
|
||||
}
|
||||
agentWalletDebitAttempted := false
|
||||
defer func() {
|
||||
s.recordOrderFailure(ctx, constants.AuditActionOrderWalletPaid, "钱包支付订单失败", order, err)
|
||||
if agentWalletDebitAttempted {
|
||||
s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, buyerID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
@@ -1636,6 +1760,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
order.PaidAt = &now
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
agentWalletDebitAttempted = true
|
||||
if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1644,22 +1769,18 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return err
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = model.PaymentMethodWallet
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用代理钱包支付订单", &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
} else {
|
||||
// 资产钱包系统(iot_card 或 device)
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if wallet.Balance < order.TotalAmount {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足")
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||
@@ -1695,22 +1816,12 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
actualPaidAmountSnapshot := actualPaidAmount
|
||||
order.ActualPaidAmount = &actualPaidAmountSnapshot
|
||||
|
||||
// 扣款前记录余额快照,用于写入流水
|
||||
wallet, err := s.deductAssetWalletForOrder(ctx, tx, order, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
balanceBefore := wallet.Balance
|
||||
|
||||
walletResult := tx.Model(&model.AssetWallet{}).
|
||||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if walletResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
|
||||
}
|
||||
if walletResult.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||
}
|
||||
|
||||
// 扣款成功后补写扣款流水,填补流水表中扣款记录缺失的问题
|
||||
deductTx := &model.AssetWalletTransaction{
|
||||
AssetWalletID: wallet.ID,
|
||||
@@ -1736,7 +1847,15 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return err
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = model.PaymentMethodWallet
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用资产钱包支付订单", &after, orderStateData(order), orderStateData(&after))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1750,6 +1869,47 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// deductAssetWalletForOrder 完成个人钱包订单扣款;新订单同时核销冻结额,历史订单只使用可用余额。
|
||||
func (s *Service) deductAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order, resourceType string, resourceID uint) (*model.AssetWallet, error) {
|
||||
var wallet model.AssetWallet
|
||||
query := tx.WithContext(ctx)
|
||||
if order.AssetWalletReservationWalletID != nil || order.AssetWalletReservedAmount != 0 {
|
||||
if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount != order.TotalAmount || order.AssetWalletReservedAmount <= 0 {
|
||||
return nil, errors.New(errors.CodeConflict, "资产钱包预占快照与订单金额不一致")
|
||||
}
|
||||
query = query.Where("id = ? AND resource_type = ? AND resource_id = ?", *order.AssetWalletReservationWalletID, resourceType, resourceID)
|
||||
} else {
|
||||
query = query.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID)
|
||||
}
|
||||
if err := query.First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
updateQuery := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ? AND version = ?", wallet.ID, wallet.Version)
|
||||
if order.AssetWalletReservedAmount > 0 {
|
||||
updateQuery = updateQuery.Where("balance >= ? AND frozen_balance >= ?", order.TotalAmount, order.AssetWalletReservedAmount)
|
||||
updates["frozen_balance"] = gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount)
|
||||
} else {
|
||||
updateQuery = updateQuery.Where("balance - frozen_balance >= ?", order.TotalAmount)
|
||||
}
|
||||
result := updateQuery.Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减资产钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突")
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string, actualPaidAmount int64) error {
|
||||
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
@@ -1760,6 +1920,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
beforeOrder := *order
|
||||
shouldResumeAfterPayment := false
|
||||
shouldEnqueueCommission := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -1800,7 +1961,15 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
shouldResumeAfterPayment = true
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
after := *order
|
||||
after.PaymentStatus = model.PaymentStatusPaid
|
||||
after.PaymentMethod = paymentMethod
|
||||
after.PaidAt = &now
|
||||
after.ExpiresAt = nil
|
||||
return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderOnlinePaid, "第三方支付确认订单已支付", &after, orderStateData(&beforeOrder), orderStateData(&after))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -1838,6 +2007,8 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
beforePayment := *payment
|
||||
beforeOrder := *order
|
||||
shouldResumeAfterPayment := false
|
||||
shouldEnqueueCommission := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -1900,7 +2071,23 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
|
||||
shouldResumeAfterPayment = true
|
||||
shouldEnqueueCommission = true
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
if err := s.activatePackage(ctx, tx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
afterPayment := beforePayment
|
||||
afterPayment.Status = model.PaymentRecordStatusPaid
|
||||
afterPayment.PaidAt = &now
|
||||
if thirdPartyTradeNo != "" {
|
||||
afterPayment.ThirdPartyTradeNo = thirdPartyTradeNo
|
||||
}
|
||||
afterOrder := beforeOrder
|
||||
afterOrder.PaymentStatus = model.PaymentStatusPaid
|
||||
afterOrder.PaymentMethod = paymentMethod
|
||||
afterOrder.PaidAt = &now
|
||||
afterOrder.ExpiresAt = nil
|
||||
afterOrder.ActualPaidAmount = &actualPaidAmountSnapshot
|
||||
return s.appendPaymentConfirmedAudit(ctx, tx, &afterPayment, &afterOrder,
|
||||
paymentStateData(&beforePayment), paymentStateData(&afterPayment), orderStateData(&beforeOrder), orderStateData(&afterOrder))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -2765,8 +2952,15 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
|
||||
description = items[0].PackageName
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_jsapi")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := paymentSvc.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("创建 JSAPI 支付失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -2774,6 +2968,9 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("创建 JSAPI 支付成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
@@ -2831,8 +3028,15 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.
|
||||
H5Type: sceneInfo.H5Info.Type,
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_h5")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := paymentSvc.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo)
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("创建 H5 支付失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -2840,6 +3044,9 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("创建 H5 支付成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
@@ -3075,6 +3282,10 @@ func (s *Service) fuiouPreCreate(
|
||||
termIP = *ip
|
||||
}
|
||||
|
||||
attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderFuiou, "order_"+strings.ToLower(tradeType))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.WxPreCreate(
|
||||
order.OrderNo,
|
||||
strconv.FormatInt(order.TotalAmount, 10),
|
||||
@@ -3085,6 +3296,9 @@ func (s *Service) fuiouPreCreate(
|
||||
openID,
|
||||
)
|
||||
if err != nil {
|
||||
if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "富友支付预下单结果未知"); completeErr != nil {
|
||||
return nil, completeErr
|
||||
}
|
||||
s.logger.Error("富友预下单失败",
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.String("order_no", order.OrderNo),
|
||||
@@ -3093,6 +3307,9 @@ func (s *Service) fuiouPreCreate(
|
||||
)
|
||||
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
|
||||
}
|
||||
if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "000000", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("富友预下单成功",
|
||||
zap.Uint("order_id", orderID),
|
||||
|
||||
39
internal/service/order_package_invalidate/audit.go
Normal file
39
internal/service/order_package_invalidate/audit.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package order_package_invalidate
|
||||
|
||||
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/middleware"
|
||||
)
|
||||
|
||||
func (s *Service) writeInvalidateTaskAudit(ctx context.Context, tx *gorm.DB, task *model.OrderPackageInvalidateTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
|
||||
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, phase),
|
||||
ActionCode: constants.AuditActionOrderPackageInvalidateTaskCreated,
|
||||
Summary: "创建订单套餐批量失效任务", TaskID: task.ID, TaskNo: task.TaskNo,
|
||||
Actor: audit.ActorInput{
|
||||
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
|
||||
Name: middleware.GetUsernameFromContext(ctx),
|
||||
},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
|
||||
IdentitySnapshot: map[string]any{"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName},
|
||||
BeforeData: before, AfterData: after,
|
||||
})
|
||||
}
|
||||
|
||||
func invalidateTaskState(task *model.OrderPackageInvalidateTask) map[string]any {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"status": task.Status, "total_count": task.TotalCount,
|
||||
"success_count": task.SuccessCount, "fail_count": task.FailCount,
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@ package order_package_invalidate
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"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/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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -21,17 +25,23 @@ import (
|
||||
type Service struct {
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore
|
||||
queueClient *queue.Client
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// New 创建 Service 实例
|
||||
func New(
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore,
|
||||
queueClient *queue.Client,
|
||||
auditWriters ...*audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
service := &Service{
|
||||
taskStore: taskStore,
|
||||
queueClient: queueClient,
|
||||
}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// InvalidateTaskPayload Worker 任务载荷
|
||||
@@ -59,7 +69,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalid
|
||||
Updater: userID,
|
||||
}
|
||||
|
||||
if err := s.taskStore.Create(ctx, task); err != nil {
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "订单套餐失效任务统一审计接缝未配置")
|
||||
}
|
||||
if err := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeInvalidateTaskAudit(ctx, tx, task, nil, invalidateTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建任务失败")
|
||||
}
|
||||
|
||||
@@ -71,7 +89,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalid
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderPackageInvalidate)),
|
||||
)
|
||||
if err != nil {
|
||||
s.taskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
secondaryErr := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
before := invalidateTaskState(task)
|
||||
if updateErr := s.taskStore.WithTx(tx).UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败"); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, "任务入队失败"
|
||||
return s.writeInvalidateTaskAudit(ctx, tx, task, before, invalidateTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "订单套餐失效任务入队失败")
|
||||
})
|
||||
if secondaryErr != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionOrderPackageInvalidateTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
|
||||
}
|
||||
}
|
||||
|
||||
return s.toResponse(task), nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -30,6 +31,12 @@ type ActivationService struct {
|
||||
logger *zap.Logger
|
||||
resumeCallback ResumeCallback // 复机回调,可选
|
||||
observationSeriesEvents cardObservationApp.SeriesEventWriter
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// SetLifecycleAudit 注入套餐权益生命周期统一审计 Writer。
|
||||
func (s *ActivationService) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
|
||||
@@ -90,9 +97,11 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
now := time.Now()
|
||||
|
||||
activated := false
|
||||
var failedUsage *model.PackageUsage
|
||||
// 在事务中激活套餐
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, usage := range pendingUsages {
|
||||
failedUsage = usage
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||||
@@ -150,12 +159,28 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
updates["next_reset_at"] = *nextResetAt
|
||||
}
|
||||
|
||||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
|
||||
beforeData := packageUsageStateData(usage)
|
||||
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活套餐失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
activatedUsage := *usage
|
||||
activatedUsage.Status = constants.PackageUsageStatusActive
|
||||
activatedUsage.PendingRealnameActivation = false
|
||||
activatedUsage.ActivatedAt = &activatedAt
|
||||
activatedUsage.ExpiresAt = &expiresAt
|
||||
activatedUsage.NextResetAt = nextResetAt
|
||||
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益", []packageUsageAuditChange{{
|
||||
Usage: &activatedUsage, BeforeData: beforeData, AfterData: packageUsageStateData(&activatedUsage),
|
||||
}}, nil, map[string]any{"activation_source": "realname"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
|
||||
|
||||
@@ -171,6 +196,7 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益失败", failedUsage, err)
|
||||
return err
|
||||
}
|
||||
if activated {
|
||||
@@ -198,6 +224,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
activated := false
|
||||
var failedUsage *model.PackageUsage
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 9.5: 检测并标记过期的主套餐
|
||||
now := time.Now()
|
||||
@@ -213,9 +240,17 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
}
|
||||
|
||||
for _, expiredMain := range expiredMainUsages {
|
||||
failedUsage = expiredMain
|
||||
// 更新主套餐状态为已过期
|
||||
if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败")
|
||||
mainBeforeData := packageUsageStateData(expiredMain)
|
||||
result := tx.Model(expiredMain).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Update("status", constants.PackageUsageStatusExpired)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新过期主套餐状态失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
expiresAt := now
|
||||
@@ -227,7 +262,13 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
// 任务 9.7: 加油包级联失效
|
||||
if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil {
|
||||
addons, err := s.invalidateAddons(ctx, tx, expiredMain.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiredUsage := *expiredMain
|
||||
expiredUsage.Status = constants.PackageUsageStatusExpired
|
||||
if err := s.appendExpirationAudit(ctx, tx, &expiredUsage, mainBeforeData, addons); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -242,6 +283,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", failedUsage, err)
|
||||
return err
|
||||
}
|
||||
if activated {
|
||||
@@ -341,13 +383,14 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "specific", "指定套餐已激活"); err != nil {
|
||||
return err
|
||||
}
|
||||
activated = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活指定套餐权益失败", &usage, err)
|
||||
return err
|
||||
}
|
||||
if activated {
|
||||
@@ -381,22 +424,30 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
activated := false
|
||||
var failedUsage *model.PackageUsage
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActive {
|
||||
s.logger.Info("载体已有占位主套餐,本轮不接续",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
|
||||
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
s.logger.Info("载体没有待生效主套餐,本轮不接续",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
failedUsage = nextMain
|
||||
|
||||
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
|
||||
if err != nil {
|
||||
@@ -416,13 +467,14 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil {
|
||||
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "queue", "队首待生效套餐已激活"); err != nil {
|
||||
return err
|
||||
}
|
||||
activated = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活排队套餐权益失败", failedUsage, err)
|
||||
return false, err
|
||||
}
|
||||
if activated {
|
||||
@@ -438,16 +490,16 @@ func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierTyp
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 9.7: 加油包级联失效
|
||||
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||||
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]packageUsageAuditChange, error) {
|
||||
var addons []*model.PackageUsage
|
||||
if err := tx.Where("master_usage_id = ?", masterUsageID).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}).
|
||||
Find(&addons).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
|
||||
}
|
||||
|
||||
if len(addons) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
addonIDs := make([]uint, len(addons))
|
||||
@@ -459,14 +511,20 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", addonIDs).
|
||||
Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
|
||||
}
|
||||
|
||||
s.logger.Info("加油包已级联失效",
|
||||
zap.Uint("master_usage_id", masterUsageID),
|
||||
zap.Int("addon_count", len(addons)))
|
||||
|
||||
return nil
|
||||
changes := make([]packageUsageAuditChange, 0, len(addons))
|
||||
for _, addon := range addons {
|
||||
beforeData := packageUsageStateData(addon)
|
||||
addon.Status = constants.PackageUsageStatusInvalidated
|
||||
changes = append(changes, packageUsageAuditChange{Usage: addon, BeforeData: beforeData, AfterData: packageUsageStateData(addon)})
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
|
||||
@@ -502,7 +560,7 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil {
|
||||
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "queue", "排队主套餐已激活"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
@@ -577,7 +635,7 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
|
||||
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, activationSource, logMessage string) error {
|
||||
terms, err := ResolveUsageTerms(usage, pkg, s.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -608,12 +666,27 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
|
||||
updates["next_reset_at"] = *nextResetAt
|
||||
}
|
||||
|
||||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
|
||||
beforeData := packageUsageStateData(usage)
|
||||
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活排队主套餐失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
usage.Status = constants.PackageUsageStatusActive
|
||||
usage.PendingRealnameActivation = false
|
||||
usage.ActivatedAt = &activatedAt
|
||||
usage.ExpiresAt = &expiresAt
|
||||
usage.NextResetAt = nextResetAt
|
||||
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{
|
||||
Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage),
|
||||
}}, nil, map[string]any{"activation_source": activationSource}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
|
||||
|
||||
@@ -711,137 +784,139 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
|
||||
// 换货会把套餐使用记录迁移到新资产,但原订单与套餐使用记录的关联保持不变。
|
||||
// 因此退款必须以订单和套餐使用记录为权威定位键,不能再用旧资产 ID 缩小查询范围。
|
||||
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
|
||||
var targets []model.PackageUsage
|
||||
if packageUsageID != nil && *packageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
err := baseQuery.
|
||||
Where("id = ? AND order_id = ?", *packageUsageID, orderID).
|
||||
Where("status IN ?", validStatuses).
|
||||
First(&usage).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
s.logger.Info("退款精准失效:未命中可失效套餐",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.Uint("package_usage_id", *packageUsageID),
|
||||
)
|
||||
return nil
|
||||
var failedUsage *model.PackageUsage
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 换货后权益仍保留原订单关系,退款必须按订单定位并使用权益当前资产快照。
|
||||
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
|
||||
var targets []model.PackageUsage
|
||||
if packageUsageID != nil && *packageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
if err := query.Where("id = ? AND order_id = ?", *packageUsageID, orderID).First(&usage).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
|
||||
}
|
||||
targets = append(targets, usage)
|
||||
} else {
|
||||
if err := baseQuery.
|
||||
Where("order_id = ?", orderID).
|
||||
Where("status IN ?", validStatuses).
|
||||
Find(&targets).Error; err != nil {
|
||||
targets = append(targets, usage)
|
||||
} else if err := query.Where("order_id = ?", orderID).Find(&targets).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败")
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
s.logger.Info("退款精准失效:订单无可失效套餐",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
zap.Uint("order_id", orderID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
targetIDSet := make(map[uint]struct{}, len(targets))
|
||||
mainUsageIDs := make([]uint, 0, len(targets))
|
||||
for _, usage := range targets {
|
||||
targetIDSet[usage.ID] = struct{}{}
|
||||
if usage.MasterUsageID == nil {
|
||||
mainUsageIDs = append(mainUsageIDs, usage.ID)
|
||||
mainUsageIDs := make([]uint, 0, len(targets))
|
||||
for i := range targets {
|
||||
if targets[i].MasterUsageID == nil {
|
||||
mainUsageIDs = append(mainUsageIDs, targets[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mainUsageIDs) > 0 {
|
||||
var addons []model.PackageUsage
|
||||
if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("master_usage_id IN ?", mainUsageIDs).
|
||||
Where("status IN ?", validStatuses).
|
||||
Find(&addons).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
|
||||
if len(mainUsageIDs) > 0 {
|
||||
var addons []model.PackageUsage
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("master_usage_id IN ? AND status IN ?", mainUsageIDs, validStatuses).Find(&addons).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
|
||||
}
|
||||
targets = append(targets, addons...)
|
||||
}
|
||||
for _, addon := range addons {
|
||||
targetIDSet[addon.ID] = struct{}{}
|
||||
failedUsage = &targets[0]
|
||||
targetIDs := make([]uint, 0, len(targets))
|
||||
changes := make([]packageUsageAuditChange, 0, len(targets))
|
||||
for i := range targets {
|
||||
targetIDs = append(targetIDs, targets[i].ID)
|
||||
beforeData := packageUsageStateData(&targets[i])
|
||||
targets[i].Status = constants.PackageUsageStatusInvalidated
|
||||
if refundID > 0 {
|
||||
targets[i].RefundID = &refundID
|
||||
}
|
||||
targets[i].RefundNo = refundNo
|
||||
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
|
||||
}
|
||||
updates := map[string]any{"status": constants.PackageUsageStatusInvalidated}
|
||||
if refundID > 0 {
|
||||
updates["refund_id"] = refundID
|
||||
}
|
||||
if refundNo != "" {
|
||||
updates["refund_no"] = refundNo
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("id IN ? AND status IN ?", targetIDs, validStatuses).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(targetIDs)) {
|
||||
return errors.New(errors.CodeConflict, "退款套餐权益状态已变化")
|
||||
}
|
||||
var refund *model.RefundRequest
|
||||
if refundID > 0 {
|
||||
refund = &model.RefundRequest{}
|
||||
if err := tx.WithContext(ctx).Where("id = ?", refundID).First(refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联退款单审计快照失败")
|
||||
}
|
||||
}
|
||||
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益", changes, refund, map[string]any{
|
||||
"asset_type": assetType, "asset_id": assetID, "order_id": orderID, "refund_id": refundID, "refund_no": refundNo,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益失败", failedUsage, err)
|
||||
return err
|
||||
}
|
||||
|
||||
targetIDs := make([]uint, 0, len(targetIDSet))
|
||||
for id := range targetIDSet {
|
||||
targetIDs = append(targetIDs, id)
|
||||
}
|
||||
if len(targetIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": constants.PackageUsageStatusInvalidated,
|
||||
}
|
||||
if refundID > 0 {
|
||||
updates["refund_id"] = refundID
|
||||
}
|
||||
if refundNo != "" {
|
||||
updates["refund_no"] = refundNo
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", targetIDs).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
|
||||
}
|
||||
|
||||
s.logger.Info("退款精准失效套餐完成",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
zap.Uint("order_id", orderID),
|
||||
zap.Uint("refund_id", refundID),
|
||||
zap.String("refund_no", refundNo),
|
||||
zap.Int("target_count", len(targetIDs)),
|
||||
zap.Int64("affected", result.RowsAffected),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐
|
||||
// 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4)
|
||||
func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error {
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusPending,
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
})
|
||||
|
||||
switch assetType {
|
||||
case "iot_card":
|
||||
query = query.Where("iot_card_id = ?", assetID)
|
||||
case "device":
|
||||
query = query.Where("device_id = ?", assetID)
|
||||
default:
|
||||
validStatuses := []int{
|
||||
constants.PackageUsageStatusPending,
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
}
|
||||
|
||||
result := query.Update("status", constants.PackageUsageStatusInvalidated)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
|
||||
var failedUsage *model.PackageUsage
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
|
||||
query = query.Where(assetType+"_id = ?", assetID)
|
||||
var targets []model.PackageUsage
|
||||
if err := query.Find(&targets).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联套餐权益失败")
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
failedUsage = &targets[0]
|
||||
ids := make([]uint, 0, len(targets))
|
||||
changes := make([]packageUsageAuditChange, 0, len(targets))
|
||||
for i := range targets {
|
||||
ids = append(ids, targets[i].ID)
|
||||
beforeData := packageUsageStateData(&targets[i])
|
||||
targets[i].Status = constants.PackageUsageStatusInvalidated
|
||||
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("id IN ? AND status IN ?", ids, validStatuses).
|
||||
Update("status", constants.PackageUsageStatusInvalidated)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(ids)) {
|
||||
return errors.New(errors.CodeConflict, "资产套餐权益状态已变化")
|
||||
}
|
||||
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益", changes, nil, map[string]any{
|
||||
"asset_type": assetType, "asset_id": assetID,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益失败", failedUsage, err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("批量失效套餐完成",
|
||||
zap.String("asset_type", assetType),
|
||||
zap.Uint("asset_id", assetID),
|
||||
zap.Int64("affected", result.RowsAffected),
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
111
internal/service/package/audit.go
Normal file
111
internal/service/package/audit.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package packagepkg
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// SetAccessAudit 注入套餐商品统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(db *gorm.DB, writer *audit.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendPackageAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, pkg *model.Package, beforeData, afterData map[string]any) error {
|
||||
if s.auditWriter == nil || s.db == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐商品统一审计接缝未配置")
|
||||
}
|
||||
resources := []audit.ResourceInput{
|
||||
audit.PackageResource(pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, afterData),
|
||||
}
|
||||
if pkg.SeriesID > 0 {
|
||||
var series model.PackageSeries
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", pkg.SeriesID).First(&series).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列审计快照失败")
|
||||
}
|
||||
resources = append(resources, audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil))
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) appendAllocationAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData, afterData map[string]any) error {
|
||||
if s.auditWriter == nil || s.db == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置")
|
||||
}
|
||||
resources := []audit.ResourceInput{
|
||||
audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, afterData),
|
||||
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", allocation.ShopID).First(&shop).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺套餐审计快照失败")
|
||||
}
|
||||
resources = append(resources, audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop))
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: allocationShopID(allocation), Result: constants.AuditResultSuccess, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordPackageFailure(ctx context.Context, actionCode, summary string, pkg *model.Package, beforeData map[string]any, businessErr error) {
|
||||
if pkg == nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{audit.PackageResource(
|
||||
pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, nil,
|
||||
)},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) recordAllocationFailure(ctx context.Context, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData map[string]any, businessErr error) {
|
||||
if allocation == nil || pkg == nil {
|
||||
return
|
||||
}
|
||||
shop := &model.Shop{Model: gorm.Model{ID: allocation.ShopID}}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: allocationShopID(allocation), Resources: []audit.ResourceInput{
|
||||
audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, nil),
|
||||
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func allocationShopID(allocation *model.ShopPackageAllocation) string {
|
||||
if allocation == nil {
|
||||
return ""
|
||||
}
|
||||
return uintString(allocation.ShopID)
|
||||
}
|
||||
|
||||
func uintString(value uint) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
|
||||
func packageData(pkg *model.Package) map[string]any {
|
||||
if pkg == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"package_name": pkg.PackageName, "series_id": pkg.SeriesID, "package_type": pkg.PackageType,
|
||||
"duration_months": pkg.DurationMonths, "duration_days": pkg.DurationDays,
|
||||
"real_data_mb": pkg.RealDataMB, "virtual_data_mb": pkg.VirtualDataMB,
|
||||
"enable_virtual_data": pkg.EnableVirtualData, "cost_price": pkg.CostPrice,
|
||||
"suggested_retail_price": pkg.SuggestedRetailPrice, "price_config_status": pkg.PriceConfigStatus,
|
||||
"is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus,
|
||||
"calendar_type": pkg.CalendarType, "data_reset_cycle": pkg.DataResetCycle, "expiry_base": pkg.ExpiryBase,
|
||||
}
|
||||
}
|
||||
221
internal/service/package/lifecycle_audit.go
Normal file
221
internal/service/package/lifecycle_audit.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
type packageUsageAuditChange struct {
|
||||
Usage *model.PackageUsage
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
func appendPackageUsageAudit(ctx context.Context, tx *gorm.DB, writer *audit.Writer, actionCode, summary string, changes []packageUsageAuditChange, refund *model.RefundRequest, metadata map[string]any) error {
|
||||
if writer == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐权益统一审计接缝未配置")
|
||||
}
|
||||
resources, err := packageUsageAuditResources(ctx, tx, changes, refund, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, Metadata: metadata, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ActivationService) appendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, mainBeforeData map[string]any, addons []packageUsageAuditChange) error {
|
||||
changes := make([]packageUsageAuditChange, 0, 1+len(addons))
|
||||
changes = append(changes, packageUsageAuditChange{Usage: main, BeforeData: mainBeforeData, AfterData: packageUsageStateData(main)})
|
||||
changes = append(changes, addons...)
|
||||
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期并处理关联加油包", changes, nil, map[string]any{
|
||||
"invalidated_addon_count": len(addons),
|
||||
})
|
||||
}
|
||||
|
||||
// AppendExpirationAudit 在调度器的既有过期事务内记录实际权益变化。
|
||||
func (s *ActivationService) AppendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, addons []*model.PackageUsage) error {
|
||||
if main == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "过期套餐权益审计资源不完整")
|
||||
}
|
||||
mainAfter := *main
|
||||
mainAfter.Status = constants.PackageUsageStatusExpired
|
||||
addonChanges := make([]packageUsageAuditChange, 0, len(addons))
|
||||
for _, addon := range addons {
|
||||
if addon == nil {
|
||||
continue
|
||||
}
|
||||
beforeData := packageUsageStateData(addon)
|
||||
after := *addon
|
||||
after.Status = constants.PackageUsageStatusInvalidated
|
||||
addonChanges = append(addonChanges, packageUsageAuditChange{Usage: &after, BeforeData: beforeData, AfterData: packageUsageStateData(&after)})
|
||||
}
|
||||
return s.appendExpirationAudit(ctx, tx, &mainAfter, packageUsageStateData(main), addonChanges)
|
||||
}
|
||||
|
||||
// RecordUsageFailure 在权益已定位且业务事务回滚后记录失败事实。
|
||||
func (s *ActivationService) RecordUsageFailure(ctx context.Context, actionCode, summary string, usage *model.PackageUsage, businessErr error) {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, actionCode, summary, usage, businessErr)
|
||||
}
|
||||
|
||||
func packageUsageAuditResources(ctx context.Context, tx *gorm.DB, changes []packageUsageAuditChange, refund *model.RefundRequest, subjectSummary string) ([]audit.ResourceInput, error) {
|
||||
if len(changes) == 0 || changes[0].Usage == nil || changes[0].Usage.ID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐权益审计资源不完整")
|
||||
}
|
||||
orderIDs, packageIDs, cardIDs, deviceIDs := packageUsageReferenceIDs(changes)
|
||||
orders, packages, cards, devices, err := loadPackageUsageReferences(ctx, tx, orderIDs, packageIDs, cardIDs, deviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := make([]audit.ResourceInput, 0, len(changes)+len(orders)+len(packages)+len(cards)+len(devices)+1)
|
||||
for index, change := range changes {
|
||||
if change.Usage == nil || change.Usage.ID == 0 {
|
||||
continue
|
||||
}
|
||||
relation := constants.AuditResourceRelationAffected
|
||||
if index == 0 {
|
||||
relation = constants.AuditResourceRelationPrimary
|
||||
}
|
||||
resource := audit.PackageUsageResource(change.Usage, relation, constants.AuditResourceRolePackageUsageTarget, change.BeforeData, change.AfterData)
|
||||
resource.SubjectVisibility = constants.AuditSubjectResult
|
||||
resource.SubjectSummary = subjectSummary
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
for i := range orders {
|
||||
resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder))
|
||||
}
|
||||
for i := range packages {
|
||||
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil))
|
||||
}
|
||||
for i := range cards {
|
||||
id := strconv.FormatUint(uint64(cards[i].ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&cards[i]), DisplayName: cards[i].ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(&cards[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary,
|
||||
})
|
||||
}
|
||||
for i := range devices {
|
||||
id := strconv.FormatUint(uint64(devices[i].ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&devices[i]), DisplayName: devices[i].VirtualNo,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset,
|
||||
IdentitySnapshot: audit.DeviceIdentitySnapshot(&devices[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary,
|
||||
})
|
||||
}
|
||||
if refund != nil && refund.ID > 0 {
|
||||
resources = append(resources, audit.RefundResource(refund, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageRefund))
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func packageUsageReferenceIDs(changes []packageUsageAuditChange) ([]uint, []uint, []uint, []uint) {
|
||||
orders, packages, cards, devices := map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{}
|
||||
for _, change := range changes {
|
||||
if change.Usage == nil {
|
||||
continue
|
||||
}
|
||||
orders[change.Usage.OrderID] = struct{}{}
|
||||
packages[change.Usage.PackageID] = struct{}{}
|
||||
if change.Usage.IotCardID > 0 {
|
||||
cards[change.Usage.IotCardID] = struct{}{}
|
||||
}
|
||||
if change.Usage.DeviceID > 0 {
|
||||
devices[change.Usage.DeviceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return mapUintKeys(orders), mapUintKeys(packages), mapUintKeys(cards), mapUintKeys(devices)
|
||||
}
|
||||
|
||||
func loadPackageUsageReferences(ctx context.Context, tx *gorm.DB, orderIDs, packageIDs, cardIDs, deviceIDs []uint) ([]model.Order, []model.Package, []model.IotCard, []model.Device, error) {
|
||||
var orders []model.Order
|
||||
if len(orderIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", orderIDs).Order("id ASC").Find(&orders).Error; err != nil {
|
||||
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联订单审计快照失败")
|
||||
}
|
||||
}
|
||||
var packages []model.Package
|
||||
if len(packageIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", packageIDs).Order("id ASC").Find(&packages).Error; err != nil {
|
||||
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联套餐审计快照失败")
|
||||
}
|
||||
}
|
||||
var cards []model.IotCard
|
||||
if len(cardIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Order("id ASC").Find(&cards).Error; err != nil {
|
||||
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联卡审计快照失败")
|
||||
}
|
||||
}
|
||||
var devices []model.Device
|
||||
if len(deviceIDs) > 0 {
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", deviceIDs).Order("id ASC").Find(&devices).Error; err != nil {
|
||||
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联设备审计快照失败")
|
||||
}
|
||||
}
|
||||
return orders, packages, cards, devices, nil
|
||||
}
|
||||
|
||||
func mapUintKeys(values map[uint]struct{}) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
for value := range values {
|
||||
if value > 0 {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
|
||||
func packageUsageStateData(usage *model.PackageUsage) map[string]any {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"status": usage.Status, "data_usage_mb": usage.DataUsageMB,
|
||||
"pending_realname_activation": usage.PendingRealnameActivation,
|
||||
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
|
||||
"last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt,
|
||||
"refund_id": usage.RefundID, "refund_no": usage.RefundNo,
|
||||
"iot_card_id": usage.IotCardID, "device_id": usage.DeviceID,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePackageUsageAuditChanges(changes []packageUsageAuditChange) []packageUsageAuditChange {
|
||||
result := make([]packageUsageAuditChange, 0, len(changes))
|
||||
positions := make(map[uint]int, len(changes))
|
||||
for _, change := range changes {
|
||||
if change.Usage == nil || change.Usage.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if position, ok := positions[change.Usage.ID]; ok {
|
||||
result[position].Usage = change.Usage
|
||||
result[position].AfterData = change.AfterData
|
||||
continue
|
||||
}
|
||||
positions[change.Usage.ID] = len(result)
|
||||
result = append(result, change)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func recordPackageUsageFailure(ctx context.Context, db *gorm.DB, writer *audit.Writer, actionCode, summary string, usage *model.PackageUsage, businessErr error) {
|
||||
if usage == nil || usage.ID == 0 || writer == nil || db == nil {
|
||||
return
|
||||
}
|
||||
writer.RecordFailure(ctx, db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{audit.PackageUsageResource(
|
||||
usage, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageUsageTarget, packageUsageStateData(usage), nil,
|
||||
)},
|
||||
}, businessErr)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ResetService struct {
|
||||
@@ -19,6 +21,12 @@ type ResetService struct {
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
resumeCallback ResumeCallback
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// SetLifecycleAudit 注入套餐权益流量重置统一审计 Writer。
|
||||
func (s *ResetService) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func NewResetService(
|
||||
@@ -55,6 +63,7 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetDaily).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -80,21 +89,40 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置日流量失败")
|
||||
result := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置日流量失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(packages)) {
|
||||
return errors.New(errors.CodeConflict, "日流量重置目标状态已变化")
|
||||
}
|
||||
changes := make([]packageUsageAuditChange, 0, len(packages))
|
||||
for _, usage := range packages {
|
||||
beforeData := packageUsageStateData(usage)
|
||||
usage.DataUsageMB = 0
|
||||
usage.LastResetAt = &now
|
||||
usage.NextResetAt = &nextReset
|
||||
usage.Status = constants.PackageUsageStatusActive
|
||||
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
|
||||
}
|
||||
resetPackages = packages
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetDaily}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("日流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
resetPackages = packages
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if len(resetPackages) > 0 {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量失败", resetPackages[0], err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -117,6 +145,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetMonthly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -128,6 +157,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
return nil
|
||||
}
|
||||
|
||||
changes := make([]packageUsageAuditChange, 0, len(packages))
|
||||
for _, usage := range packages {
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||||
@@ -156,9 +186,22 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
|
||||
beforeData := packageUsageStateData(usage)
|
||||
result := tx.Model(usage).
|
||||
Where("next_reset_at <= ? AND status IN ?", now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "重置月流量失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
usage.DataUsageMB = 0
|
||||
usage.LastResetAt = &now
|
||||
usage.NextResetAt = nextResetAt
|
||||
usage.Status = constants.PackageUsageStatusActive
|
||||
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
|
||||
resetPackages = append(resetPackages, usage)
|
||||
|
||||
s.logger.Info("月流量已重置",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
@@ -166,11 +209,18 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
zap.Time("next_reset_at", *nextResetAt))
|
||||
}
|
||||
|
||||
resetPackages = packages
|
||||
if len(changes) > 0 {
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetMonthly}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if len(resetPackages) > 0 {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量失败", resetPackages[0], err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -193,6 +243,7 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetYearly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -218,21 +269,40 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置年流量失败")
|
||||
result := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置年流量失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(packages)) {
|
||||
return errors.New(errors.CodeConflict, "年流量重置目标状态已变化")
|
||||
}
|
||||
changes := make([]packageUsageAuditChange, 0, len(packages))
|
||||
for _, usage := range packages {
|
||||
beforeData := packageUsageStateData(usage)
|
||||
usage.DataUsageMB = 0
|
||||
usage.LastResetAt = &now
|
||||
usage.NextResetAt = &nextReset
|
||||
usage.Status = constants.PackageUsageStatusActive
|
||||
changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)})
|
||||
}
|
||||
resetPackages = packages
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetYearly}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("年流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
resetPackages = packages
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if len(resetPackages) > 0 {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量失败", resetPackages[0], err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
@@ -18,10 +19,12 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -38,7 +41,7 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) {
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (_ *dto.PackageResponse, retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -114,6 +117,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
failedPackage := *pkg
|
||||
failedPackage.ID = 0
|
||||
s.recordPackageFailure(ctx, constants.AuditActionPackageCreated, "创建套餐商品失败 "+pkg.PackageCode, &failedPackage, nil, retErr)
|
||||
}
|
||||
}()
|
||||
if req.SeriesID != nil {
|
||||
pkg.SeriesID = *req.SeriesID
|
||||
}
|
||||
@@ -140,8 +150,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
|
||||
pkg.Creator = currentUserID
|
||||
|
||||
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageStore(tx).Create(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
|
||||
}
|
||||
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageCreated, "创建套餐商品 "+pkg.PackageCode, pkg, nil, packageData(pkg))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
@@ -157,7 +172,6 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
// 查询系列名称
|
||||
if pkg.SeriesID > 0 {
|
||||
@@ -191,7 +205,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) {
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (_ *dto.PackageResponse, retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -204,6 +218,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
before := *pkg
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordPackageFailure(ctx, constants.AuditActionPackageUpdated, "更新套餐商品失败 "+before.PackageCode, &before, packageData(&before), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
@@ -305,8 +325,13 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
|
||||
pkg.Updater = currentUserID
|
||||
|
||||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
|
||||
}
|
||||
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageUpdated, "更新套餐商品 "+pkg.PackageCode, pkg, packageData(&before), packageData(pkg))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := s.toResponse(ctx, pkg)
|
||||
@@ -314,20 +339,26 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
_, err := s.packageStore.GetByID(ctx, id)
|
||||
func (s *Service) Delete(ctx context.Context, id uint) (retErr error) {
|
||||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordPackageFailure(ctx, constants.AuditActionPackageDeleted, "删除套餐商品失败 "+pkg.PackageCode, pkg, packageData(pkg), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := s.packageStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageStore(tx).Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败")
|
||||
}
|
||||
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageDeleted, "删除套餐商品 "+pkg.PackageCode, pkg, packageData(pkg), map[string]any{"deleted": true})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, int64, error) {
|
||||
@@ -444,7 +475,7 @@ func (s *Service) batchGetSeriesAllocationsForShop(ctx context.Context, shopID u
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) (retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -457,6 +488,12 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
before := *pkg
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordPackageFailure(ctx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态失败 "+before.PackageCode, &before, map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
pkg.Status = status
|
||||
pkg.Updater = currentUserID
|
||||
@@ -465,14 +502,16 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
pkg.ShelfStatus = 2
|
||||
}
|
||||
|
||||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败")
|
||||
}
|
||||
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态 "+pkg.PackageCode, pkg,
|
||||
map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, map[string]any{"status": pkg.Status, "shelf_status": pkg.ShelfStatus})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
|
||||
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) (retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -493,6 +532,12 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
before := *pkg
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordPackageFailure(ctx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态失败 "+before.PackageCode, &before, map[string]any{"shelf_status": before.ShelfStatus}, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if shelfStatus == constants.ShelfStatusOn && pkg.Status == constants.StatusDisabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用")
|
||||
@@ -501,15 +546,17 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
|
||||
pkg.ShelfStatus = shelfStatus
|
||||
pkg.Updater = currentUserID
|
||||
|
||||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败")
|
||||
}
|
||||
return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态 "+pkg.PackageCode, pkg,
|
||||
map[string]any{"shelf_status": before.ShelfStatus}, map[string]any{"shelf_status": pkg.ShelfStatus})
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRetailPrice 代理修改自己店铺的套餐零售价
|
||||
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error {
|
||||
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) (retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -540,6 +587,14 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
beforePrice, beforeStatus := allocation.RetailPrice, allocation.RetailPriceConfigStatus
|
||||
beforeAllocation := *allocation
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordAllocationFailure(ctx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价失败 "+pkg.PackageCode, &beforeAllocation, pkg,
|
||||
map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus}, retErr)
|
||||
}
|
||||
}()
|
||||
if pkg.IsGift {
|
||||
return errors.New(errors.CodeForbidden, "赠送套餐不允许代理修改零售价")
|
||||
}
|
||||
@@ -550,16 +605,19 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP
|
||||
if retailPrice < allocation.CostPrice {
|
||||
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
|
||||
}
|
||||
|
||||
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewShopPackageAllocationStore(tx).UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
|
||||
}
|
||||
allocation.RetailPrice, allocation.RetailPriceConfigStatus = storedRetailPrice, priceConfigStatus
|
||||
return s.appendAllocationAudit(ctx, tx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价 "+pkg.PackageCode, allocation, pkg,
|
||||
map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus},
|
||||
map[string]any{"retail_price": storedRetailPrice, "retail_price_config_status": priceConfigStatus})
|
||||
})
|
||||
}
|
||||
|
||||
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
|
||||
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
|
||||
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) (retErr error) {
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if shopID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
|
||||
@@ -573,26 +631,34 @@ func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, sh
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
beforeShelfStatus := allocation.ShelfStatus
|
||||
beforeAllocation := *allocation
|
||||
pkg, err := s.packageStore.GetByID(ctx, packageID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordAllocationFailure(ctx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态失败 "+pkg.PackageCode, &beforeAllocation, pkg,
|
||||
map[string]any{"shelf_status": beforeShelfStatus}, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// 上架时检查套餐全局禁用状态
|
||||
if shelfStatus == constants.ShelfStatusOn {
|
||||
pkg, err := s.packageStore.GetByID(ctx, packageID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
if pkg.Status == constants.StatusDisabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐已禁用,无法上架")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.packageAllocationStore.UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewShopPackageAllocationStore(tx).UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败")
|
||||
}
|
||||
allocation.ShelfStatus = shelfStatus
|
||||
return s.appendAllocationAudit(ctx, tx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态 "+pkg.PackageCode, allocation, pkg,
|
||||
map[string]any{"shelf_status": beforeShelfStatus}, map[string]any{"shelf_status": shelfStatus})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse {
|
||||
@@ -607,29 +673,29 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
@@ -686,29 +752,29 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -38,6 +39,12 @@ type UsageService struct {
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
logger *zap.Logger
|
||||
stopResumeCallback StopResumeCallback // 停复机回调,可选
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// SetLifecycleAudit 注入套餐权益流量扣减统一审计 Writer。
|
||||
func (s *UsageService) SetLifecycleAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func NewUsageService(
|
||||
@@ -81,6 +88,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
shouldSuspend := false
|
||||
suspendCarrierType := ""
|
||||
var suspendCarrierID uint
|
||||
var auditChanges []packageUsageAuditChange
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
|
||||
@@ -116,9 +124,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
isLastPackage := index == len(packages)-1
|
||||
if remainingQuota <= 0 && !isLastPackage {
|
||||
// 套餐已用完,标记为已用完
|
||||
beforeData := packageUsageStateData(pkg)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
|
||||
}
|
||||
pkg.Status = constants.PackageUsageStatusDepleted
|
||||
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -132,9 +143,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
deductFromPkg = remainingQuota
|
||||
}
|
||||
if deductFromPkg <= 0 {
|
||||
beforeData := packageUsageStateData(pkg)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
|
||||
}
|
||||
pkg.Status = constants.PackageUsageStatusDepleted
|
||||
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -149,14 +163,20 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
updates["status"] = constants.PackageUsageStatusDepleted
|
||||
}
|
||||
|
||||
beforeData := packageUsageStateData(pkg)
|
||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败")
|
||||
}
|
||||
pkg.DataUsageMB = newUsage
|
||||
if status, ok := updates["status"].(int); ok {
|
||||
pkg.Status = status
|
||||
}
|
||||
|
||||
// 任务 10.6: 写入日记录
|
||||
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil {
|
||||
return err
|
||||
}
|
||||
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||||
|
||||
remainingUsage -= deductFromPkg
|
||||
|
||||
@@ -171,17 +191,28 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
}
|
||||
|
||||
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
|
||||
shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
|
||||
shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditChanges = append(auditChanges, suspensionChanges...)
|
||||
shouldSuspend = shouldSuspendCurrent
|
||||
suspendCarrierType = targetCarrierType
|
||||
suspendCarrierID = targetCarrierID
|
||||
if len(auditChanges) > 0 {
|
||||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量", normalizePackageUsageAuditChanges(auditChanges), nil, map[string]any{
|
||||
"carrier_type": targetCarrierType, "carrier_id": targetCarrierID, "usage_mb": deductUsageMB,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if len(auditChanges) > 0 {
|
||||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量失败", auditChanges[0].Usage, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -360,7 +391,7 @@ func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packa
|
||||
}
|
||||
|
||||
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, []packageUsageAuditChange, error) {
|
||||
query := tx.Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
|
||||
@@ -369,24 +400,28 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
|
||||
} else if carrierType == constants.AssetTypeDevice {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
return false, nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
var packages []*model.PackageUsage
|
||||
if err := query.Find(&packages).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
|
||||
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
|
||||
}
|
||||
|
||||
hasAvailablePackage := false
|
||||
var changes []packageUsageAuditChange
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
if pkg.IsTrafficDepleted() {
|
||||
if pkg.Status != constants.PackageUsageStatusDepleted {
|
||||
beforeData := packageUsageStateData(pkg)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
|
||||
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
|
||||
}
|
||||
pkg.Status = constants.PackageUsageStatusDepleted
|
||||
changes = append(changes, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -398,10 +433,10 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
|
||||
s.logger.Warn("所有套餐已用完,触发停机",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return true, nil
|
||||
return true, changes, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return false, changes, nil
|
||||
}
|
||||
|
||||
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
|
||||
|
||||
54
internal/service/package_series/audit.go
Normal file
54
internal/service/package_series/audit.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package package_series
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// SetAccessAudit 注入套餐系列统一审计 Writer。
|
||||
func (s *Service) SetAccessAudit(db *gorm.DB, writer *audit.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func (s *Service) appendAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, series *model.PackageSeries, beforeData, afterData map[string]any) error {
|
||||
if s.auditWriter == nil || s.db == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐系列统一审计接缝未配置")
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Resources: []audit.ResourceInput{audit.PackageSeriesResource(
|
||||
series, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageSeriesTarget, beforeData, afterData,
|
||||
)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordAuditFailure(ctx context.Context, actionCode, summary string, series *model.PackageSeries, beforeData map[string]any, businessErr error) {
|
||||
if series == nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{audit.PackageSeriesResource(
|
||||
series, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageSeriesTarget, beforeData, nil,
|
||||
)},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func packageSeriesData(series *model.PackageSeries) map[string]any {
|
||||
if series == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"series_name": series.SeriesName, "description": series.Description, "status": series.Status,
|
||||
"enable_one_time_commission": series.EnableOneTimeCommission,
|
||||
"one_time_commission_config": series.OneTimeCommissionConfigJSON,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
@@ -18,9 +19,11 @@ import (
|
||||
|
||||
// Service 套餐系列业务服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageStore *postgres.PackageStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// New 创建套餐系列服务实例
|
||||
@@ -32,7 +35,7 @@ func New(packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationSt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) {
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesRequest) (_ *dto.PackageSeriesResponse, retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -50,6 +53,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesReques
|
||||
Status: constants.StatusEnabled,
|
||||
OneTimeCommissionConfigJSON: "{}",
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
failedSeries := *series
|
||||
failedSeries.ID = 0
|
||||
s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesCreated, "创建套餐系列失败 "+series.SeriesCode, &failedSeries, nil, retErr)
|
||||
}
|
||||
}()
|
||||
series.Creator = currentUserID
|
||||
|
||||
if req.EnableOneTimeCommission != nil {
|
||||
@@ -69,8 +79,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesReques
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.packageSeriesStore.Create(ctx, series); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐系列失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageSeriesStore(tx).Create(ctx, series); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建套餐系列失败")
|
||||
}
|
||||
return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesCreated, "创建套餐系列 "+series.SeriesCode, series, nil, packageSeriesData(series))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toResponse(series), nil
|
||||
@@ -87,7 +102,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageSeriesResponse,
|
||||
return s.toResponse(series), nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) {
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSeriesRequest) (_ *dto.PackageSeriesResponse, retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -100,6 +115,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSer
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||||
}
|
||||
before := *series
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesUpdated, "更新套餐系列失败 "+before.SeriesCode, &before, packageSeriesData(&before), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if req.SeriesName != nil {
|
||||
series.SeriesName = *req.SeriesName
|
||||
@@ -128,21 +149,31 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSer
|
||||
}
|
||||
series.Updater = currentUserID
|
||||
|
||||
if err := s.packageSeriesStore.Update(ctx, series); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐系列失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageSeriesStore(tx).Update(ctx, series); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列失败")
|
||||
}
|
||||
return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesUpdated, "更新套餐系列 "+series.SeriesCode, series, packageSeriesData(&before), packageSeriesData(series))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toResponse(series), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
_, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||
func (s *Service) Delete(ctx context.Context, id uint) (retErr error) {
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesDeleted, "删除套餐系列失败 "+series.SeriesCode, series, packageSeriesData(series), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
count, err := s.packageStore.CountBySeriesID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -152,11 +183,12 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count))
|
||||
}
|
||||
|
||||
if err := s.packageSeriesStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐系列失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageSeriesStore(tx).Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐系列失败")
|
||||
}
|
||||
return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesDeleted, "删除套餐系列 "+series.SeriesCode, series, packageSeriesData(series), map[string]any{"deleted": true})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) ([]*dto.PackageSeriesResponse, int64, error) {
|
||||
@@ -223,7 +255,7 @@ func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) (
|
||||
return responses, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) (retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -236,15 +268,22 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||||
}
|
||||
before := *series
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态失败 "+before.SeriesCode, &before, map[string]any{"status": before.Status}, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
series.Status = status
|
||||
series.Updater = currentUserID
|
||||
|
||||
if err := s.packageSeriesStore.Update(ctx, series); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewPackageSeriesStore(tx).Update(ctx, series); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列状态失败")
|
||||
}
|
||||
return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态 "+series.SeriesCode, series, map[string]any{"status": before.Status}, map[string]any{"status": series.Status})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) toResponse(series *model.PackageSeries) *dto.PackageSeriesResponse {
|
||||
|
||||
@@ -12,21 +12,32 @@ import (
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// AlertService 告警服务
|
||||
type AlertService struct {
|
||||
ruleStore *postgres.PollingAlertRuleStore
|
||||
historyStore *postgres.PollingAlertHistoryStore
|
||||
db *gorm.DB
|
||||
auditWriter *auditinfra.Writer
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SetAudit 注入轮询告警规则事务与统一审计 Writer。
|
||||
func (s *AlertService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// NewAlertService 创建告警服务实例
|
||||
func NewAlertService(
|
||||
ruleStore *postgres.PollingAlertRuleStore,
|
||||
@@ -44,6 +55,10 @@ func NewAlertService(
|
||||
|
||||
// CreateRule 创建告警规则
|
||||
func (s *AlertService) CreateRule(ctx context.Context, rule *model.PollingAlertRule) error {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
// 验证参数
|
||||
if rule.RuleName == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "规则名称不能为空")
|
||||
@@ -62,7 +77,28 @@ func (s *AlertService) CreateRule(ctx context.Context, rule *model.PollingAlertR
|
||||
if rule.Operator == "" {
|
||||
rule.Operator = ">" // 默认大于
|
||||
}
|
||||
return s.ruleStore.Create(ctx, rule)
|
||||
rule.CreatedBy = &operatorID
|
||||
rule.UpdatedBy = &operatorID
|
||||
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.ruleStore.WithTx(tx).Create(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleCreated, Summary: "创建轮询告警规则",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID,
|
||||
ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule), AfterData: pollingAlertRuleState(rule),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleCreated, Summary: "创建轮询告警规则失败",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceKey: rule.RuleName,
|
||||
DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule), AfterData: pollingAlertRuleState(rule),
|
||||
}, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRule 获取告警规则
|
||||
@@ -81,10 +117,15 @@ func (s *AlertService) ListRules(ctx context.Context) ([]*model.PollingAlertRule
|
||||
|
||||
// UpdateRule 更新告警规则
|
||||
func (s *AlertService) UpdateRule(ctx context.Context, id uint, updates map[string]interface{}) error {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
rule, err := s.ruleStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在")
|
||||
}
|
||||
before := *rule
|
||||
|
||||
if name, ok := updates["rule_name"].(string); ok && name != "" {
|
||||
rule.RuleName = name
|
||||
@@ -104,17 +145,60 @@ func (s *AlertService) UpdateRule(ctx context.Context, id uint, updates map[stri
|
||||
if channels, ok := updates["notification_channels"].(string); ok {
|
||||
rule.NotificationChannels = channels
|
||||
}
|
||||
|
||||
return s.ruleStore.Update(ctx, rule)
|
||||
rule.UpdatedBy = &operatorID
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.ruleStore.WithTx(tx).Update(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleUpdated, Summary: "更新轮询告警规则",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID,
|
||||
ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule),
|
||||
BeforeData: pollingAlertRuleState(&before), AfterData: pollingAlertRuleState(rule),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleUpdated, Summary: "更新轮询告警规则失败",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID,
|
||||
ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(&before),
|
||||
}, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRule 删除告警规则
|
||||
func (s *AlertService) DeleteRule(ctx context.Context, id uint) error {
|
||||
_, err := s.ruleStore.GetByID(ctx, id)
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
rule, err := s.ruleStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在")
|
||||
}
|
||||
return s.ruleStore.Delete(ctx, id)
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.ruleStore.WithTx(tx).Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleDeleted, Summary: "删除轮询告警规则",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID,
|
||||
ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(rule),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingAlertRuleDeleted, Summary: "删除轮询告警规则失败",
|
||||
ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID,
|
||||
ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(rule),
|
||||
}, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListHistory 获取告警历史
|
||||
|
||||
121
internal/service/polling/audit.go
Normal file
121
internal/service/polling/audit.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
func runPollingTransaction(ctx context.Context, db *gorm.DB, writer *auditinfra.Writer, fn func(*gorm.DB) error) error {
|
||||
if db == nil || writer == nil {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一轮询审计接缝未配置")
|
||||
}
|
||||
return db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
func writePollingAudit(ctx context.Context, tx *gorm.DB, writer *auditinfra.Writer, input auditinfra.PollingInput) error {
|
||||
return writer.WritePolling(ctx, tx, input)
|
||||
}
|
||||
|
||||
func recordPollingFailure(ctx context.Context, db *gorm.DB, writer *auditinfra.Writer, input auditinfra.PollingInput, originalErr error) {
|
||||
if input.OperatorID == 0 || input.ResourceType == "" || input.ResourceKey == "" {
|
||||
return
|
||||
}
|
||||
var appErr *pkgerrors.AppError
|
||||
if !stderrors.As(originalErr, &appErr) {
|
||||
appErr = pkgerrors.New(pkgerrors.CodeInternalError, "轮询操作失败")
|
||||
}
|
||||
if input.Result == "" {
|
||||
input.Result = constants.AuditResultFailed
|
||||
}
|
||||
if input.ErrorCode == "" {
|
||||
input.ErrorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
if input.ErrorSummary == "" {
|
||||
input.ErrorSummary = appErr.Message
|
||||
}
|
||||
if db != nil && writer != nil {
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return writePollingAudit(ctx, tx, writer, input)
|
||||
}); err == nil {
|
||||
return
|
||||
} else {
|
||||
originalErr = err
|
||||
}
|
||||
}
|
||||
linkage := auditcontext.From(ctx)
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
input.ActionCode, input.ResourceKey, linkage.RequestID, linkage.CorrelationID, input.ErrorCode, originalErr,
|
||||
)
|
||||
}
|
||||
|
||||
func pollingConfigIdentity(config *model.PollingConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": config.ID, "config_name": config.ConfigName, "card_condition": config.CardCondition,
|
||||
"card_category": config.CardCategory, "carrier_id": config.CarrierID, "priority": config.Priority,
|
||||
"status": config.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func pollingConfigState(config *model.PollingConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"config_name": config.ConfigName, "card_condition": config.CardCondition,
|
||||
"card_category": config.CardCategory, "carrier_id": config.CarrierID, "priority": config.Priority,
|
||||
"realname_check_interval": config.RealnameCheckInterval, "carddata_check_interval": config.CarddataCheckInterval,
|
||||
"package_check_interval": config.PackageCheckInterval, "protect_check_interval": config.ProtectCheckInterval,
|
||||
"card_status_check_interval": config.CardStatusCheckInterval, "status": config.Status,
|
||||
"description": config.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func pollingConcurrencyIdentity(config *model.PollingConcurrencyConfig) map[string]any {
|
||||
return map[string]any{"id": config.ID, "task_type": config.TaskType, "max_concurrency": config.MaxConcurrency}
|
||||
}
|
||||
|
||||
func pollingAlertRuleIdentity(rule *model.PollingAlertRule) map[string]any {
|
||||
return map[string]any{
|
||||
"id": rule.ID, "rule_name": rule.RuleName, "task_type": rule.TaskType,
|
||||
"metric_type": rule.MetricType, "operator": rule.Operator, "threshold": rule.Threshold,
|
||||
"alert_level": rule.AlertLevel, "status": rule.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func pollingAlertRuleState(rule *model.PollingAlertRule) map[string]any {
|
||||
return map[string]any{
|
||||
"rule_name": rule.RuleName, "task_type": rule.TaskType, "metric_type": rule.MetricType,
|
||||
"operator": rule.Operator, "threshold": rule.Threshold, "duration_minutes": rule.DurationMinutes,
|
||||
"alert_level": rule.AlertLevel, "status": rule.Status, "cooldown_minutes": rule.CooldownMinutes,
|
||||
"notification_channels_configured": rule.NotificationChannels != "", "description": rule.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func pollingManualTriggerIdentity(log *model.PollingManualTriggerLog) map[string]any {
|
||||
return map[string]any{
|
||||
"id": log.ID, "task_type": log.TaskType, "trigger_type": log.TriggerType,
|
||||
"total_count": log.TotalCount, "status": log.Status, "triggered_by": log.TriggeredBy,
|
||||
}
|
||||
}
|
||||
|
||||
func pollingManualTriggerKey(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func pollingManualAttemptKey(taskType, triggerType string, operatorID uint) string {
|
||||
return triggerType + ":" + taskType + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||||
}
|
||||
|
||||
func pollingManualAttemptIdentity(taskType, triggerType string, totalCount int, operatorID uint) map[string]any {
|
||||
return map[string]any{
|
||||
"task_type": taskType, "trigger_type": triggerType,
|
||||
"total_count": totalCount, "triggered_by": operatorID,
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,28 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// ConcurrencyService 并发控制服务
|
||||
type ConcurrencyService struct {
|
||||
store *postgres.PollingConcurrencyConfigStore
|
||||
redis *redis.Client
|
||||
store *postgres.PollingConcurrencyConfigStore
|
||||
db *gorm.DB
|
||||
auditWriter *auditinfra.Writer
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
// SetAudit 注入轮询并发配置事务与统一审计 Writer。
|
||||
func (s *ConcurrencyService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// NewConcurrencyService 创建并发控制服务实例
|
||||
@@ -113,14 +124,34 @@ func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType
|
||||
}
|
||||
|
||||
// 验证任务类型存在
|
||||
_, err := s.store.GetByTaskType(ctx, taskType)
|
||||
config, err := s.store.GetByTaskType(ctx, taskType)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在")
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
if err := s.store.UpdateMaxConcurrency(ctx, taskType, maxConcurrency, updatedBy); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新并发配置失败")
|
||||
before := config.MaxConcurrency
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.store.WithTx(tx).UpdateMaxConcurrency(ctx, taskType, maxConcurrency, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
config.MaxConcurrency = maxConcurrency
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyUpdated, Summary: "更新轮询并发配置",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: updatedBy,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config),
|
||||
BeforeData: map[string]any{"max_concurrency": before}, AfterData: map[string]any{"max_concurrency": maxConcurrency},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "更新并发配置失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyUpdated, Summary: "更新轮询并发配置失败",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: updatedBy,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"max_concurrency": before},
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// 同步更新 Redis 配置缓存
|
||||
@@ -134,19 +165,72 @@ func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType
|
||||
|
||||
// ResetConcurrency 重置并发计数(用于信号量修复)
|
||||
func (s *ConcurrencyService) ResetConcurrency(ctx context.Context, taskType string) error {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
// 验证任务类型存在
|
||||
_, err := s.store.GetByTaskType(ctx, taskType)
|
||||
config, err := s.store.GetByTaskType(ctx, taskType)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在")
|
||||
}
|
||||
|
||||
// 重置 Redis 当前计数为 0
|
||||
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
|
||||
if err := s.redis.Set(ctx, currentKey, 0, 24*time.Hour).Err(); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "重置并发计数失败")
|
||||
before, getErr := s.redis.Get(ctx, currentKey).Int64()
|
||||
beforeExists := getErr == nil
|
||||
if getErr != nil && getErr != redis.Nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, getErr, "读取并发计数失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config),
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
beforeTTL := time.Duration(0)
|
||||
if beforeExists {
|
||||
beforeTTL, _ = s.redis.PTTL(ctx, currentKey).Result()
|
||||
if beforeTTL < 0 {
|
||||
beforeTTL = 0
|
||||
}
|
||||
}
|
||||
if err := s.redis.Set(ctx, currentKey, 0, 24*time.Hour).Err(); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "重置并发计数失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"current": before},
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config),
|
||||
BeforeData: map[string]any{"current": before}, AfterData: map[string]any{"current": int64(0)},
|
||||
})
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if beforeExists {
|
||||
_ = s.redis.Set(ctx, currentKey, before, beforeTTL).Err()
|
||||
} else {
|
||||
_ = s.redis.Del(ctx, currentKey).Err()
|
||||
}
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "记录重置并发计数审计失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败",
|
||||
ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID,
|
||||
ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID,
|
||||
IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"current": before},
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// InitFromDB 从数据库初始化 Redis 并发配置
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
@@ -20,10 +21,18 @@ import (
|
||||
// ConfigService 轮询配置服务
|
||||
type ConfigService struct {
|
||||
configStore *postgres.PollingConfigStore
|
||||
db *gorm.DB
|
||||
auditWriter *auditinfra.Writer
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SetAudit 注入轮询配置事务与统一审计 Writer。
|
||||
func (s *ConfigService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// NewConfigService 创建轮询配置服务实例
|
||||
func NewConfigService(configStore *postgres.PollingConfigStore, redisClient *redis.Client, logger *zap.Logger) *ConfigService {
|
||||
return &ConfigService{configStore: configStore, redis: redisClient, logger: logger}
|
||||
@@ -48,7 +57,14 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig
|
||||
// 验证配置名称唯一性
|
||||
existing, _ := s.configStore.GetByName(ctx, req.ConfigName)
|
||||
if existing != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "配置名称已存在")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigCreated, Summary: "拒绝创建重复轮询配置",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceKey: req.ConfigName,
|
||||
DisplayName: req.ConfigName, OperatorID: currentUserID, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: map[string]any{"config_name": req.ConfigName},
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 验证检查间隔(至少一个不为空)
|
||||
@@ -75,8 +91,26 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig
|
||||
UpdatedBy: ¤tUserID,
|
||||
}
|
||||
|
||||
if err := s.configStore.Create(ctx, config); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建轮询配置失败")
|
||||
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.configStore.WithTx(tx).Create(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigCreated, Summary: "创建轮询配置",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), AfterData: pollingConfigState(config),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "创建轮询配置失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigCreated, Summary: "创建轮询配置失败",
|
||||
ResourceType: constants.AuditResourcePollingConfig,
|
||||
ResourceKey: config.ConfigName, DisplayName: config.ConfigName, OperatorID: currentUserID,
|
||||
IdentitySnapshot: pollingConfigIdentity(config), AfterData: pollingConfigState(config),
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.notifyConfigChanged(ctx, "created")
|
||||
@@ -109,13 +143,22 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
|
||||
}
|
||||
before := *config
|
||||
|
||||
// 更新字段
|
||||
if req.ConfigName != nil {
|
||||
// 检查名称唯一性
|
||||
existing, _ := s.configStore.GetByName(ctx, *req.ConfigName)
|
||||
if existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "配置名称已存在")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "拒绝更新为重复轮询配置名称",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config),
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
config.ConfigName = *req.ConfigName
|
||||
}
|
||||
@@ -151,8 +194,28 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll
|
||||
}
|
||||
config.UpdatedBy = ¤tUserID
|
||||
|
||||
if err := s.configStore.Update(ctx, config); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新轮询配置失败")
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.configStore.WithTx(tx).Update(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "更新轮询配置",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config),
|
||||
BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(config),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "更新轮询配置失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "更新轮询配置失败",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config),
|
||||
BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(config),
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
s.notifyConfigChanged(ctx, "updated")
|
||||
@@ -161,7 +224,11 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll
|
||||
|
||||
// Delete 删除轮询配置
|
||||
func (s *ConfigService) Delete(ctx context.Context, id uint) error {
|
||||
_, err := s.configStore.GetByID(ctx, id)
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
config, err := s.configStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
|
||||
@@ -169,8 +236,26 @@ func (s *ConfigService) Delete(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
|
||||
}
|
||||
|
||||
if err := s.configStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除轮询配置失败")
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.configStore.WithTx(tx).Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigDeleted, Summary: "删除轮询配置",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "删除轮询配置失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigDeleted, Summary: "删除轮询配置失败",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config),
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
s.notifyConfigChanged(ctx, "deleted")
|
||||
@@ -228,7 +313,7 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16)
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
_, err := s.configStore.GetByID(ctx, id)
|
||||
config, err := s.configStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
|
||||
@@ -236,8 +321,29 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
|
||||
}
|
||||
|
||||
if err := s.configStore.UpdateStatus(ctx, id, status, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新轮询配置状态失败")
|
||||
before := pollingConfigState(config)
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.configStore.WithTx(tx).UpdateStatus(ctx, id, status, currentUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
config.Status = status
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigStatusUpdated, Summary: "更新轮询配置状态",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config),
|
||||
BeforeData: before, AfterData: pollingConfigState(config),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
appErr := errors.Wrap(errors.CodeInternalError, err, "更新轮询配置状态失败")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingConfigStatusUpdated, Summary: "更新轮询配置状态失败",
|
||||
ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID,
|
||||
ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName,
|
||||
OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: before,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
s.notifyConfigChanged(ctx, "updated")
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -19,10 +21,18 @@ import (
|
||||
type ManualTriggerService struct {
|
||||
logStore *postgres.PollingManualTriggerLogStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
db *gorm.DB
|
||||
auditWriter *auditinfra.Writer
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SetAudit 注入手动轮询任务事务与统一审计 Writer。
|
||||
func (s *ManualTriggerService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) {
|
||||
s.db = db
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// NewManualTriggerService 创建手动触发服务实例
|
||||
func NewManualTriggerService(
|
||||
logStore *postgres.PollingManualTriggerLogStore,
|
||||
@@ -49,6 +59,10 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
|
||||
if err := s.canManageCard(ctx, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, []uint{cardID})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败")
|
||||
}
|
||||
|
||||
// 检查每日触发限制
|
||||
todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy)
|
||||
@@ -60,7 +74,15 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
|
||||
return err
|
||||
}
|
||||
if todayCount >= 500 { // 每日最多触发500次
|
||||
return errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "拒绝超过每日上限的单卡手动触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// 检查去重
|
||||
@@ -74,7 +96,15 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
|
||||
return err
|
||||
}
|
||||
if added == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该卡已在手动触发队列中")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "该卡已在手动触发队列中")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "拒绝重复加入手动触发队列",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
// 设置去重 key 过期时间(24小时,与日限制周期对齐)
|
||||
s.redis.Expire(ctx, dedupeKey, 24*time.Hour)
|
||||
@@ -90,7 +120,27 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
|
||||
TriggeredBy: triggeredBy,
|
||||
TriggeredAt: time.Now(),
|
||||
}
|
||||
if err := s.logStore.Create(ctx, triggerLog); err != nil {
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "单卡手动触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID,
|
||||
ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog),
|
||||
AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType},
|
||||
Cards: cards,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.redis.SRem(ctx, dedupeKey, cardID).Err()
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "单卡手动触发失败",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards,
|
||||
}, err)
|
||||
s.logger.Error("创建触发日志失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Uint("triggered_by", triggeredBy),
|
||||
@@ -101,6 +151,7 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
|
||||
// 加入手动触发队列(使用 List,优先级高于定时轮询)
|
||||
queueKey := constants.RedisPollingManualQueueKey(taskType)
|
||||
if err := s.redis.LPush(ctx, queueKey, cardID).Err(); err != nil {
|
||||
_ = s.redis.SRem(ctx, dedupeKey, cardID).Err()
|
||||
s.logger.Error("写入手动触发队列失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("task_type", taskType),
|
||||
@@ -136,6 +187,10 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint,
|
||||
if err := s.canManageCards(ctx, cardIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败")
|
||||
}
|
||||
|
||||
// 检查每日触发限制
|
||||
todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy)
|
||||
@@ -143,7 +198,15 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint,
|
||||
return nil, err
|
||||
}
|
||||
if todayCount >= 500 { // 每日最多触发500次
|
||||
return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "拒绝超过每日上限的批量手动触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "batch", triggeredBy), DisplayName: "批量手动触发",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: pollingManualAttemptIdentity(taskType, "batch", len(cardIDs), triggeredBy), Cards: cards,
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 创建触发日志
|
||||
@@ -157,7 +220,26 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint,
|
||||
TriggeredBy: triggeredBy,
|
||||
TriggeredAt: time.Now(),
|
||||
}
|
||||
if err := s.logStore.Create(ctx, triggerLog); err != nil {
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "批量手动触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID,
|
||||
ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog),
|
||||
AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType},
|
||||
Cards: cards,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "批量手动触发失败",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "batch", triggeredBy), DisplayName: "批量手动触发",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "batch", len(cardIDs), triggeredBy), Cards: cards,
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -252,7 +334,16 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C
|
||||
return nil, err
|
||||
}
|
||||
if todayCount >= 500 { // 每日最多触发500次
|
||||
return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "拒绝超过每日上限的条件筛选触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "by_condition", triggeredBy), DisplayName: "条件筛选触发",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: pollingManualAttemptIdentity(taskType, "by_condition", 0, triggeredBy),
|
||||
Metadata: map[string]any{"condition_filter_configured": true},
|
||||
}, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
// 查询符合条件的卡(已应用权限过滤)
|
||||
@@ -264,6 +355,10 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C
|
||||
if len(cardIDs) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "没有符合条件的卡")
|
||||
}
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败")
|
||||
}
|
||||
|
||||
// 创建触发日志
|
||||
filterJSON, _ := json.Marshal(filter)
|
||||
@@ -278,7 +373,27 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C
|
||||
TriggeredBy: triggeredBy,
|
||||
TriggeredAt: time.Now(),
|
||||
}
|
||||
if err := s.logStore.Create(ctx, triggerLog); err != nil {
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "条件筛选触发",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID,
|
||||
ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog),
|
||||
AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType},
|
||||
Metadata: map[string]any{"condition_filter_configured": true}, Cards: cards,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "条件筛选触发失败",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger,
|
||||
ResourceKey: pollingManualAttemptKey(taskType, "by_condition", triggeredBy), DisplayName: "条件筛选触发",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "by_condition", len(cardIDs), triggeredBy),
|
||||
Metadata: map[string]any{"condition_filter_configured": true}, Cards: cards,
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -341,14 +456,53 @@ func (s *ManualTriggerService) CancelTrigger(ctx context.Context, logID uint, tr
|
||||
}
|
||||
|
||||
if log.TriggeredBy != triggeredBy {
|
||||
return errors.New(errors.CodeForbidden, "无权限取消该任务")
|
||||
appErr := errors.New(errors.CodeForbidden, "无权限取消该任务")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualCancelled, Summary: "拒绝取消其他账号的手动触发任务",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID,
|
||||
ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied, IdentitySnapshot: pollingManualTriggerIdentity(log),
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
if log.Status != constants.PollingManualTriggerStatusPending && log.Status != constants.PollingManualTriggerStatusProcessing {
|
||||
return errors.New(errors.CodeInvalidParam, "任务已完成或已取消")
|
||||
appErr := errors.New(errors.CodeInvalidParam, "任务已完成或已取消")
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualCancelled, Summary: "拒绝取消已结束的手动触发任务",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID,
|
||||
ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, Result: constants.AuditResultDenied, IdentitySnapshot: pollingManualTriggerIdentity(log),
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
return s.logStore.UpdateStatus(ctx, logID, constants.PollingManualTriggerStatusCancelled)
|
||||
var cardIDs []uint
|
||||
_ = json.Unmarshal([]byte(log.CardIDs), &cardIDs)
|
||||
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
if err := s.logStore.WithTx(tx).UpdateStatus(ctx, logID, constants.PollingManualTriggerStatusCancelled); err != nil {
|
||||
return err
|
||||
}
|
||||
before := log.Status
|
||||
log.Status = constants.PollingManualTriggerStatusCancelled
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualCancelled, Summary: "人工取消轮询任务",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID,
|
||||
ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(log),
|
||||
BeforeData: map[string]any{"status": before}, AfterData: map[string]any{"status": log.Status}, Cards: cards,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingManualCancelled, Summary: "取消手动触发任务失败",
|
||||
ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID,
|
||||
ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务",
|
||||
OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(log), Cards: cards,
|
||||
}, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRunningTasks 获取正在运行的任务
|
||||
|
||||
@@ -2,8 +2,10 @@ package recharge_order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
@@ -28,6 +30,12 @@ type Service struct {
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// SetPaymentAudit 注入充值支付统一审计 Writer。
|
||||
func (s *Service) SetPaymentAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -157,8 +165,60 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
|
||||
if err := s.triggerOneTimeCommissionIfNeededInTx(ctx, tx, rechargeOrder, rechargeOrder.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "充值支付统一审计接缝未配置")
|
||||
}
|
||||
afterPayment := *payment
|
||||
afterPayment.Status = model.PaymentRecordStatusPaid
|
||||
afterPayment.ThirdPartyTradeNo = transactionID
|
||||
afterPayment.PaidAt = &now
|
||||
paymentResource := audit.PaymentResource(&afterPayment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
|
||||
map[string]any{"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, "paid_at": payment.PaidAt},
|
||||
map[string]any{"status": afterPayment.Status, "third_party_trade_no": afterPayment.ThirdPartyTradeNo, "paid_at": afterPayment.PaidAt})
|
||||
rechargeID := strconv.FormatUint(uint64(rechargeOrder.ID), 10)
|
||||
rechargeResource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: rechargeOrder.RechargeOrderNo, DisplayName: rechargeOrder.RechargeOrderNo,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePaymentBusinessOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": rechargeOrder.ID, "recharge_order_no": rechargeOrder.RechargeOrderNo, "user_id": rechargeOrder.UserID,
|
||||
"asset_wallet_id": rechargeOrder.AssetWalletID, "resource_type": rechargeOrder.ResourceType,
|
||||
"resource_id": rechargeOrder.ResourceID, "amount": rechargeOrder.Amount, "status": model.RechargeOrderStatusPaid,
|
||||
},
|
||||
BeforeData: map[string]any{"status": rechargeOrder.Status}, AfterData: map[string]any{"status": model.RechargeOrderStatusPaid},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值支付已到账",
|
||||
}
|
||||
transactionIDValue := strconv.FormatUint(uint64(transaction.ID), 10)
|
||||
transactionResource := audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &transactionIDValue, Key: transactionIDValue, DisplayName: "资产钱包流水 " + transactionIDValue,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePaymentWalletTransaction,
|
||||
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},
|
||||
}
|
||||
resources := []audit.ResourceInput{paymentResource, rechargeResource, transactionResource}
|
||||
references, err := audit.AssetRechargeReferences(ctx, tx, rechargeOrder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range references {
|
||||
if references[i].Type != constants.AuditResourceAssetWallet {
|
||||
continue
|
||||
}
|
||||
references[i].Relation = constants.AuditResourceRelationAffected
|
||||
references[i].BeforeData = map[string]any{"balance": balanceBefore}
|
||||
references[i].AfterData = map[string]any{"balance": balanceBefore + rechargeOrder.Amount}
|
||||
references[i].SubjectVisibility = constants.AuditSubjectResult
|
||||
references[i].SubjectSummary = "充值支付已到账"
|
||||
}
|
||||
resources = append(resources, references...)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "第三方支付确认资产充值已到账",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: payment.PaymentNo, Resources: resources,
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
148
internal/service/shop_commission/audit.go
Normal file
148
internal/service/shop_commission/audit.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package shop_commission
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (s *Service) appendWithdrawalRequestAudit(ctx context.Context, tx *gorm.DB, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "佣金提现统一审计接缝未配置")
|
||||
}
|
||||
var saved model.CommissionWithdrawalRequest
|
||||
if err := tx.WithContext(ctx).First(&saved, withdrawal.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现申请审计快照失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺审计快照失败")
|
||||
}
|
||||
primary := audit.CommissionWithdrawalResource(&saved, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
|
||||
nil, withdrawalAuditState(&saved))
|
||||
primary.SubjectVisibility = constants.AuditSubjectDetail
|
||||
primary.SubjectSummary = "提现申请已提交"
|
||||
primary.SubjectData = map[string]any{
|
||||
"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount,
|
||||
"withdrawal_method": saved.WithdrawalMethod, "status": saved.Status,
|
||||
}
|
||||
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance + saved.Amount})
|
||||
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
|
||||
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(saved.ID), 10) + ":requested",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalRequested, Summary: "提交佣金提现申请",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: saved.WithdrawalNo,
|
||||
Metadata: map[string]any{"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount},
|
||||
Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordWithdrawalRequestFailure(ctx context.Context, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, businessErr error) {
|
||||
if businessErr == nil || withdrawal == nil || withdrawal.WithdrawalNo == "" || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget, nil, nil)
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources := []audit.ResourceInput{primary}
|
||||
if wallet != nil {
|
||||
resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalWallet, nil, nil)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalRequested, Summary: "提交佣金提现申请失败",
|
||||
ScopeType: constants.AuditScopePlatform, CorrelationID: withdrawal.WithdrawalNo, Resources: resources,
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) appendCommissionResolutionAudit(ctx context.Context, tx *gorm.DB, before *model.CommissionRecord, wallet *model.AgentWallet, actionCode, summary string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
|
||||
}
|
||||
var saved model.CommissionRecord
|
||||
if err := tx.WithContext(ctx).First(&saved, before.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金修正审计快照失败")
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, saved.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金修正关联订单审计快照失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金修正关联店铺审计快照失败")
|
||||
}
|
||||
primary := audit.CommissionRecordResource(&saved,
|
||||
map[string]any{"amount": before.Amount, "status": before.Status, "balance_after": before.BalanceAfter, "remark": before.Remark},
|
||||
map[string]any{"amount": saved.Amount, "status": saved.Status, "balance_after": saved.BalanceAfter, "released_at": saved.ReleasedAt, "remark": saved.Remark})
|
||||
primary.Relation = constants.AuditResourceRelationPrimary
|
||||
primary.Role = constants.AuditResourceRoleCommissionRecord
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionOrder)
|
||||
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources := []audit.ResourceInput{primary, orderResource, shopResource}
|
||||
if wallet != nil {
|
||||
resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionWallet,
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
map[string]any{"balance": wallet.Balance + saved.Amount, "frozen_balance": wallet.FrozenBalance})
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
if order.SeriesID != nil {
|
||||
var series model.PackageSeries
|
||||
if err := tx.WithContext(ctx).First(&series, *order.SeriesID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金修正关联系列审计快照失败")
|
||||
}
|
||||
resource := audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionSeries, nil, nil)
|
||||
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
suffix := "invalidated"
|
||||
if actionCode == constants.AuditActionCommissionCredited {
|
||||
suffix = "credited"
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission:record:" + strconv.FormatUint(uint64(saved.ID), 10) + ":" + suffix,
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo,
|
||||
Metadata: map[string]any{"amount": saved.Amount, "status": saved.Status}, Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordCommissionResolutionFailure(ctx context.Context, record *model.CommissionRecord, actionCode, summary string, businessErr error) {
|
||||
if businessErr == nil || record == nil || record.ID == 0 || s.auditWriter == nil || s.db == nil {
|
||||
return
|
||||
}
|
||||
primary := audit.CommissionRecordResource(record, map[string]any{"amount": record.Amount, "status": record.Status}, nil)
|
||||
primary.Relation = constants.AuditResourceRelationPrimary
|
||||
primary.Role = constants.AuditResourceRoleCommissionRecord
|
||||
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{primary},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func withdrawalAuditState(withdrawal *model.CommissionWithdrawalRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "actual_amount": withdrawal.ActualAmount,
|
||||
"withdrawal_method": withdrawal.WithdrawalMethod, "payment_type": withdrawal.PaymentType,
|
||||
"status": withdrawal.Status, "processor_id": withdrawal.ProcessorID,
|
||||
"processed_at": withdrawal.ProcessedAt, "paid_at": withdrawal.PaidAt,
|
||||
"reject_reason": withdrawal.RejectReason, "remark": withdrawal.Remark,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
@@ -28,9 +29,15 @@ type Service struct {
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
|
||||
db *gorm.DB
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// SetAuditWriter 注入佣金与提现统一审计 Writer。
|
||||
func (s *Service) SetAuditWriter(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// New 创建代理商资金管理服务
|
||||
func New(
|
||||
shopStore *postgres.ShopStore,
|
||||
@@ -460,7 +467,20 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req
|
||||
}
|
||||
accountInfoJSON, _ := json.Marshal(accountInfo)
|
||||
|
||||
var withdrawalRequest *model.CommissionWithdrawalRequest
|
||||
withdrawalRequest := &model.CommissionWithdrawalRequest{
|
||||
WithdrawalNo: withdrawalNo,
|
||||
ShopID: shopID,
|
||||
ApplicantID: currentUserID,
|
||||
Amount: req.Amount,
|
||||
FeeRate: setting.FeeRate,
|
||||
Fee: fee,
|
||||
ActualAmount: actualAmount,
|
||||
WithdrawalMethod: req.WithdrawalMethod,
|
||||
AccountInfo: accountInfoJSON,
|
||||
Status: constants.WithdrawalStatusPending,
|
||||
}
|
||||
withdrawalRequest.Creator = currentUserID
|
||||
withdrawalRequest.Updater = currentUserID
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 使用条件更新防并发
|
||||
@@ -476,21 +496,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试")
|
||||
}
|
||||
|
||||
withdrawalRequest = &model.CommissionWithdrawalRequest{
|
||||
WithdrawalNo: withdrawalNo,
|
||||
ShopID: shopID,
|
||||
ApplicantID: currentUserID,
|
||||
Amount: req.Amount,
|
||||
FeeRate: setting.FeeRate,
|
||||
Fee: fee,
|
||||
ActualAmount: actualAmount,
|
||||
WithdrawalMethod: req.WithdrawalMethod,
|
||||
AccountInfo: accountInfoJSON,
|
||||
Status: constants.WithdrawalStatusPending, // 待审核
|
||||
}
|
||||
withdrawalRequest.Creator = currentUserID
|
||||
withdrawalRequest.Updater = currentUserID
|
||||
|
||||
if err := tx.WithContext(ctx).Create(withdrawalRequest).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建提现申请失败")
|
||||
}
|
||||
@@ -517,9 +522,10 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建钱包流水失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendWithdrawalRequestAudit(ctx, tx, withdrawalRequest, wallet, transaction)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordWithdrawalRequestFailure(ctx, withdrawalRequest, wallet, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -625,7 +631,13 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re
|
||||
}
|
||||
|
||||
if record.Status != constants.CommissionStatusPendingReview {
|
||||
return errors.New(errors.CodeInvalidParam, "该记录不是待修正状态")
|
||||
actionCode := constants.AuditActionCommissionInvalidated
|
||||
if req.Action == "release" {
|
||||
actionCode = constants.AuditActionCommissionCredited
|
||||
}
|
||||
businessErr := errors.New(errors.CodeInvalidParam, "该记录不是待修正状态")
|
||||
s.recordCommissionResolutionFailure(ctx, record, actionCode, "修正待审佣金失败", businessErr)
|
||||
return businessErr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -635,24 +647,37 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re
|
||||
}
|
||||
|
||||
if req.Action == "invalidate" {
|
||||
return s.commissionRecordStore.UpdateByID(ctx, nil, recordID, map[string]any{
|
||||
"status": constants.CommissionStatusInvalid,
|
||||
"remark": resolveRemark,
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
|
||||
"status": constants.CommissionStatusInvalid,
|
||||
"remark": resolveRemark,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendCommissionResolutionAudit(ctx, tx, record, nil, constants.AuditActionCommissionInvalidated, "待审佣金已失效")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionInvalidated, "失效待审佣金失败", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// release 入账
|
||||
if req.Amount == nil || *req.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "入账操作必须指定金额")
|
||||
businessErr := errors.New(errors.CodeInvalidParam, "入账操作必须指定金额")
|
||||
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr)
|
||||
return businessErr
|
||||
}
|
||||
amount := *req.Amount
|
||||
|
||||
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, record.ShopID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在")
|
||||
businessErr := errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在")
|
||||
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr)
|
||||
return businessErr
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{
|
||||
"status": constants.CommissionStatusReleased,
|
||||
"amount": amount,
|
||||
@@ -682,8 +707,12 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新入账后余额失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendCommissionResolutionAudit(ctx, tx, record, wallet, constants.AuditActionCommissionCredited, "待审佣金已入账")
|
||||
})
|
||||
if err != nil {
|
||||
s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// generateWithdrawalNo 生成提现单号
|
||||
|
||||
109
internal/service/shop_package_batch_allocation/audit.go
Normal file
109
internal/service/shop_package_batch_allocation/audit.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package shop_package_batch_allocation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *Service) appendExpiryBaseAudit(ctx context.Context, tx *gorm.DB, allocation *model.ShopPackageAllocation, pkg *model.Package, before, after *string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置")
|
||||
}
|
||||
resources, err := s.allocationResources(ctx, tx, allocation, pkg, constants.AuditResourceRelationPrimary,
|
||||
map[string]any{"expiry_base_override": before}, map[string]any{"expiry_base_override": after})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionShopPackageExpiryBaseUpdated,
|
||||
Summary: "更新店铺套餐生效条件", ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(allocation.ShopID), 10), Result: constants.AuditResultSuccess,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) appendBatchAllocationAudit(ctx context.Context, tx *gorm.DB, batchKey string, shop *model.Shop, series *model.PackageSeries, allocations []*model.ShopPackageAllocation, packages map[uint]*model.Package, total, skipped int) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置")
|
||||
}
|
||||
rootEventID := "evt_" + uuid.NewString()
|
||||
children := make([]audit.AppendInput, 0, len(allocations))
|
||||
for _, allocation := range allocations {
|
||||
pkg := packages[allocation.PackageID]
|
||||
resources, err := s.allocationResources(ctx, tx, allocation, pkg, constants.AuditResourceRelationPrimary, nil,
|
||||
map[string]any{"cost_price": allocation.CostPrice, "retail_price": allocation.RetailPrice, "expiry_base_override": allocation.ExpiryBaseOverride, "status": allocation.Status})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: "evt_" + uuid.NewString(), ActionCode: constants.AuditActionShopPackageAllocated,
|
||||
Summary: "分配店铺套餐 " + pkg.PackageCode, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess, Resources: resources,
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootEventID, ActionCode: constants.AuditActionShopPackageBatchAllocated,
|
||||
Summary: "批量分配店铺套餐", ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess,
|
||||
BatchTotal: total, SuccessCount: len(allocations), FailCount: 0,
|
||||
Metadata: map[string]any{"skipped_count": skipped},
|
||||
Resources: []audit.ResourceInput{
|
||||
audit.PackageConfigBatchResource(batchKey, "batch_allocate", shop.ID, series.ID),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil),
|
||||
},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordExpiryBaseFailure(ctx context.Context, allocation *model.ShopPackageAllocation, pkg *model.Package, before *string, businessErr error) {
|
||||
if allocation == nil || pkg == nil {
|
||||
return
|
||||
}
|
||||
shop := &model.Shop{Model: gorm.Model{ID: allocation.ShopID}}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionShopPackageExpiryBaseUpdated, Summary: "更新店铺套餐生效条件失败",
|
||||
ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(allocation.ShopID), 10),
|
||||
Resources: []audit.ResourceInput{
|
||||
audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, map[string]any{"expiry_base_override": before}, nil),
|
||||
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) recordBatchAllocationFailure(ctx context.Context, batchKey string, shop *model.Shop, seriesID uint, total int, businessErr error) {
|
||||
if shop == nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionShopPackageBatchAllocated, Summary: "批量分配店铺套餐失败",
|
||||
ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(shop.ID), 10),
|
||||
BatchTotal: total, FailCount: total, Resources: []audit.ResourceInput{
|
||||
audit.PackageConfigBatchResource(batchKey, "batch_allocate", shop.ID, seriesID),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func (s *Service) allocationResources(ctx context.Context, tx *gorm.DB, allocation *model.ShopPackageAllocation, pkg *model.Package, relation string, beforeData, afterData map[string]any) ([]audit.ResourceInput, error) {
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", allocation.ShopID).First(&shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺套餐审计快照失败")
|
||||
}
|
||||
return []audit.ResourceInput{
|
||||
audit.ShopPackageAllocationResource(allocation, relation, constants.AuditResourceRoleShopPackageAllocation, beforeData, afterData),
|
||||
audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
|
||||
audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
}, nil
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package shop_package_batch_allocation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
@@ -20,12 +22,7 @@ type Service struct {
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
shopStore *postgres.ShopStore
|
||||
auditService AuditLogger
|
||||
}
|
||||
|
||||
// AuditLogger 敏感配置变更审计能力。
|
||||
type AuditLogger interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -34,7 +31,7 @@ func New(
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
auditService AuditLogger,
|
||||
auditWriter *audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -42,12 +39,12 @@ func New(
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
shopStore: shopStore,
|
||||
auditService: auditService,
|
||||
auditWriter: auditWriter,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateExpiryBase 修改单条套餐分配的生效条件覆盖。
|
||||
func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (*dto.ShopPackageAllocationTermsResponse, error) {
|
||||
func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (_ *dto.ShopPackageAllocationTermsResponse, retErr error) {
|
||||
override, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -61,13 +58,23 @@ func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.Update
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
before := allocation.ExpiryBaseOverride
|
||||
if !sameNullableString(before, override) {
|
||||
allocation.ExpiryBaseOverride = override
|
||||
allocation.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if err := s.packageAllocationStore.Update(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败")
|
||||
beforeAllocation := *allocation
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordExpiryBaseFailure(ctx, &beforeAllocation, pkg, before, retErr)
|
||||
}
|
||||
}()
|
||||
if !sameNullableString(before, override) {
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
allocation.ExpiryBaseOverride = override
|
||||
allocation.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if err := postgres.NewShopPackageAllocationStore(tx).Update(ctx, allocation); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败")
|
||||
}
|
||||
return s.appendExpiryBaseAudit(ctx, tx, allocation, pkg, before, override)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logExpiryBaseAudit(ctx, allocation, before, override)
|
||||
}
|
||||
return buildTermsResponse(pkg, allocation), nil
|
||||
}
|
||||
@@ -87,21 +94,7 @@ func sameNullableString(left, right *string) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func (s *Service) logExpiryBaseAudit(ctx context.Context, allocation *model.ShopPackageAllocation, before, after *string) {
|
||||
if s.auditService == nil {
|
||||
return
|
||||
}
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx), OperationType: "update_package_expiry_base",
|
||||
OperationDesc: "修改套餐分配生效条件覆盖: " + strconv.FormatUint(uint64(allocation.ID), 10),
|
||||
BeforeData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": before},
|
||||
AfterData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": after},
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (*dto.BatchAllocatePackagesResponse, error) {
|
||||
func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (_ *dto.BatchAllocatePackagesResponse, retErr error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +118,13 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败")
|
||||
}
|
||||
batchKey := "package-allocation-" + uuid.NewString()
|
||||
batchTotal := 0
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordBatchAllocationFailure(ctx, batchKey, targetShop, req.SeriesID, batchTotal, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
|
||||
@@ -151,6 +151,12 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
}
|
||||
|
||||
result := &dto.BatchAllocatePackagesResponse{TotalPackages: len(packages), Allocations: []dto.ShopPackageAllocationTermsResponse{}}
|
||||
createdAllocations := make([]*model.ShopPackageAllocation, 0, len(packages))
|
||||
packageMap := make(map[uint]*model.Package, len(packages))
|
||||
for _, pkg := range packages {
|
||||
packageMap[pkg.ID] = pkg
|
||||
}
|
||||
batchTotal = len(packages)
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx)
|
||||
|
||||
@@ -182,10 +188,15 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败")
|
||||
}
|
||||
createdAllocations = append(createdAllocations, allocation)
|
||||
result.Allocations = append(result.Allocations, *buildTermsResponse(pkg, allocation))
|
||||
}
|
||||
|
||||
return nil
|
||||
var series model.PackageSeries
|
||||
if err := tx.WithContext(ctx).Where("id = ?", req.SeriesID).First(&series).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列审计快照失败")
|
||||
}
|
||||
return s.appendBatchAllocationAudit(ctx, tx, batchKey, targetShop, &series, createdAllocations, packageMap, batchTotal, result.SkippedCount)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
98
internal/service/shop_package_batch_pricing/audit.go
Normal file
98
internal/service/shop_package_batch_pricing/audit.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package shop_package_batch_pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
type pricingAuditItem struct {
|
||||
allocation *model.ShopPackageAllocation
|
||||
history *model.ShopPackageAllocationPriceHistory
|
||||
oldPrice int64
|
||||
result string
|
||||
errorCode string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (s *Service) appendBatchPricingAudit(ctx context.Context, tx *gorm.DB, batchKey string, shop *model.Shop, seriesID uint, items []pricingAuditItem) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "批量套餐调价统一审计接缝未配置")
|
||||
}
|
||||
rootEventID := "evt_" + uuid.NewString()
|
||||
children := make([]audit.AppendInput, 0, len(items))
|
||||
successCount, failCount := 0, 0
|
||||
for _, item := range items {
|
||||
var pkg model.Package
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", item.allocation.PackageID).First(&pkg).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量调价套餐审计快照失败")
|
||||
}
|
||||
resources := []audit.ResourceInput{
|
||||
audit.ShopPackageAllocationResource(item.allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation,
|
||||
map[string]any{"cost_price": item.oldPrice}, pricingAfterData(item)),
|
||||
audit.PackageResource(&pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
}
|
||||
if item.history != nil {
|
||||
resources = append(resources, audit.ShopPackagePriceHistoryResource(item.history))
|
||||
}
|
||||
if item.result == constants.AuditResultSuccess {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: "evt_" + uuid.NewString(), ActionCode: constants.AuditActionShopPackagePricingItemUpdated,
|
||||
Summary: "更新店铺套餐成本价 " + pkg.PackageCode, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: item.result,
|
||||
ErrorCode: item.errorCode, ErrorSummary: item.reason, Resources: resources,
|
||||
})
|
||||
}
|
||||
result := constants.AuditResultSuccess
|
||||
if successCount == 0 && failCount > 0 {
|
||||
result = constants.AuditResultDenied
|
||||
} else if failCount > 0 {
|
||||
result = constants.AuditResultPartial
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootEventID, ActionCode: constants.AuditActionShopPackageBatchPricingUpdated,
|
||||
Summary: "批量更新店铺套餐成本价", ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: result,
|
||||
BatchTotal: len(items), SuccessCount: successCount, FailCount: failCount,
|
||||
Resources: []audit.ResourceInput{
|
||||
audit.PackageConfigBatchResource(batchKey, "batch_update_pricing", shop.ID, seriesID),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordBatchPricingFailure(ctx context.Context, batchKey string, shop *model.Shop, seriesID uint, total int, businessErr error) {
|
||||
if shop == nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionShopPackageBatchPricingUpdated, Summary: "批量更新店铺套餐成本价失败",
|
||||
ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(shop.ID), 10),
|
||||
BatchTotal: total, FailCount: total, Resources: []audit.ResourceInput{
|
||||
audit.PackageConfigBatchResource(batchKey, "batch_update_pricing", shop.ID, seriesID),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func pricingAfterData(item pricingAuditItem) map[string]any {
|
||||
if item.result != constants.AuditResultSuccess {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"cost_price": item.allocation.CostPrice}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package shop_package_batch_pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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/postgres"
|
||||
@@ -18,6 +22,7 @@ type Service struct {
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore
|
||||
shopStore *postgres.ShopStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -25,16 +30,18 @@ func New(
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
auditWriter *audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
priceHistoryStore: priceHistoryStore,
|
||||
shopStore: shopStore,
|
||||
auditWriter: auditWriter,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCostPriceRequest) (*dto.BatchUpdateCostPriceResponse, error) {
|
||||
func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCostPriceRequest) (_ *dto.BatchUpdateCostPriceResponse, retErr error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -46,6 +53,21 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
||||
if userType == constants.UserTypeAgent && shopID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
|
||||
}
|
||||
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
batchKey := "package-pricing-" + uuid.NewString()
|
||||
seriesID := uint(0)
|
||||
if req.SeriesID != nil {
|
||||
seriesID = *req.SeriesID
|
||||
}
|
||||
batchTotal := 0
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordBatchPricingFailure(ctx, batchKey, targetShop, seriesID, batchTotal, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
filters := map[string]interface{}{
|
||||
"shop_id": req.ShopID,
|
||||
@@ -64,11 +86,13 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
||||
if len(allocations) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
|
||||
}
|
||||
batchTotal = len(allocations)
|
||||
|
||||
updatedCount := 0
|
||||
now := time.Now()
|
||||
affectedIDs := make([]uint, 0)
|
||||
skipped := make([]dto.BatchPricingSkipped, 0)
|
||||
auditItems := make([]pricingAuditItem, 0, len(allocations))
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, allocation := range allocations {
|
||||
@@ -84,6 +108,10 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
||||
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID).
|
||||
Count(&subCount)
|
||||
if subCount > 0 {
|
||||
auditItems = append(auditItems, pricingAuditItem{
|
||||
allocation: allocation, oldPrice: oldPrice, result: constants.AuditResultDenied,
|
||||
errorCode: strconv.Itoa(errors.CodeForbidden), reason: "存在下级分配记录,请先回收后再修改成本价",
|
||||
})
|
||||
skipped = append(skipped, dto.BatchPricingSkipped{
|
||||
AllocationID: allocation.ID,
|
||||
Reason: "存在下级分配记录,请先回收后再修改成本价",
|
||||
@@ -110,10 +138,14 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
||||
}
|
||||
|
||||
affectedIDs = append(affectedIDs, allocation.ID)
|
||||
auditItems = append(auditItems, pricingAuditItem{allocation: allocation, history: history, oldPrice: oldPrice, result: constants.AuditResultSuccess})
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
return nil
|
||||
if len(auditItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.appendBatchPricingAudit(ctx, tx, batchKey, targetShop, seriesID, auditItems)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
93
internal/service/shop_series_grant/audit.go
Normal file
93
internal/service/shop_series_grant/audit.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package shop_series_grant
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type allocationAuditChange struct {
|
||||
before map[string]any
|
||||
after map[string]any
|
||||
}
|
||||
|
||||
func (s *Service) appendGrantAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
actionCode, summary string,
|
||||
allocation *model.ShopSeriesAllocation,
|
||||
series *model.PackageSeries,
|
||||
shop *model.Shop,
|
||||
beforeData, afterData map[string]any,
|
||||
packageAllocations []*model.ShopPackageAllocation,
|
||||
priceHistories []*model.ShopPackageAllocationPriceHistory,
|
||||
packageChanges map[uint]allocationAuditChange,
|
||||
) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "店铺系列授权统一审计接缝未配置")
|
||||
}
|
||||
resources := []audit.ResourceInput{
|
||||
audit.ShopSeriesAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopSeriesAllocation, beforeData, afterData),
|
||||
audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
}
|
||||
for _, packageAllocation := range packageAllocations {
|
||||
change, ok := packageChanges[packageAllocation.ID]
|
||||
if !ok {
|
||||
change.after = map[string]any{
|
||||
"cost_price": packageAllocation.CostPrice, "retail_price": packageAllocation.RetailPrice,
|
||||
"expiry_base_override": packageAllocation.ExpiryBaseOverride, "status": packageAllocation.Status,
|
||||
}
|
||||
}
|
||||
resources = append(resources, audit.ShopPackageAllocationResource(
|
||||
packageAllocation, constants.AuditResourceRelationAffected, constants.AuditResourceRoleShopPackageAllocation, change.before, change.after,
|
||||
))
|
||||
var pkg model.Package
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", packageAllocation.PackageID).First(&pkg).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询系列授权套餐审计快照失败")
|
||||
}
|
||||
resources = append(resources, audit.PackageResource(&pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil))
|
||||
}
|
||||
for _, history := range priceHistories {
|
||||
resources = append(resources, audit.ShopPackagePriceHistoryResource(history))
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordGrantFailure(ctx context.Context, actionCode, summary string, allocation *model.ShopSeriesAllocation, series *model.PackageSeries, shop *model.Shop, beforeData map[string]any, businessErr error) {
|
||||
if allocation == nil || series == nil || shop == nil {
|
||||
return
|
||||
}
|
||||
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
|
||||
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop,
|
||||
ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Resources: []audit.ResourceInput{
|
||||
audit.ShopSeriesAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopSeriesAllocation, beforeData, nil),
|
||||
audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil),
|
||||
audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop),
|
||||
},
|
||||
}, businessErr)
|
||||
}
|
||||
|
||||
func grantData(allocation *model.ShopSeriesAllocation) map[string]any {
|
||||
if allocation == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"one_time_commission_amount": allocation.OneTimeCommissionAmount,
|
||||
"commission_tiers": allocation.CommissionTiersJSON,
|
||||
"enable_force_recharge": allocation.EnableForceRecharge,
|
||||
"force_recharge_amount": allocation.ForceRechargeAmount,
|
||||
"force_recharge_trigger_type": allocation.ForceRechargeTriggerType,
|
||||
"status": allocation.Status,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
@@ -29,6 +30,7 @@ type Service struct {
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
logger *zap.Logger
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。
|
||||
@@ -47,6 +49,7 @@ func New(
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
logger *zap.Logger,
|
||||
auditWriter *audit.Writer,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -57,6 +60,7 @@ func New(
|
||||
packageStore: packageStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
logger: logger,
|
||||
auditWriter: auditWriter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +303,7 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
||||
|
||||
// Create 创建系列授权
|
||||
// POST /api/admin/shop-series-grants
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -324,6 +328,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: req.ShopID, SeriesID: req.SeriesID, Status: constants.StatusEnabled, CommissionTiersJSON: "[]",
|
||||
}
|
||||
businessCommitted := false
|
||||
defer func() {
|
||||
if retErr != nil && !businessCommitted {
|
||||
failedAllocation := *allocation
|
||||
failedAllocation.ID = 0
|
||||
s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权失败", &failedAllocation, series, targetShop, nil, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 检查重复授权
|
||||
exists, err := s.shopSeriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID)
|
||||
@@ -354,13 +369,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
}
|
||||
|
||||
// 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: req.ShopID,
|
||||
SeriesID: req.SeriesID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
Status: constants.StatusEnabled,
|
||||
CommissionTiersJSON: "[]",
|
||||
}
|
||||
allocation.AllocatorShopID = allocatorShopID
|
||||
allocation.Creator = operatorID
|
||||
allocation.Updater = operatorID
|
||||
|
||||
@@ -423,6 +432,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
}
|
||||
|
||||
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
|
||||
createdPackageAllocations := make([]*model.ShopPackageAllocation, 0, len(req.Packages))
|
||||
createdPriceHistories := make([]*model.ShopPackageAllocationPriceHistory, 0, len(req.Packages))
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedTargetShop model.Shop
|
||||
if lockErr := tx.WithContext(ctx).
|
||||
@@ -476,23 +487,28 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
|
||||
}
|
||||
if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
history := &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: pkgAlloc.ID,
|
||||
OldCostPrice: 0,
|
||||
NewCostPrice: *item.CostPrice,
|
||||
ChangeReason: "初始授权",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
}); err != nil {
|
||||
}
|
||||
if err := txHistoryStore.Create(ctx, history); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败")
|
||||
}
|
||||
createdPackageAllocations = append(createdPackageAllocations, pkgAlloc)
|
||||
createdPriceHistories = append(createdPriceHistories, history)
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权", allocation, series, targetShop,
|
||||
nil, grantData(allocation), createdPackageAllocations, createdPriceHistories, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
businessCommitted = true
|
||||
|
||||
// 事务提交后构建完整响应(此时 packages 已可查询到)
|
||||
return s.buildGrantResponse(ctx, allocation, series, config)
|
||||
@@ -638,7 +654,7 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest)
|
||||
|
||||
// Update 更新系列授权
|
||||
// PUT /api/admin/shop-series-grants/:id
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
@@ -649,16 +665,27 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
|
||||
}
|
||||
before := *allocation
|
||||
series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
|
||||
if seriesErr != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败")
|
||||
}
|
||||
shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID)
|
||||
if shopErr != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败")
|
||||
}
|
||||
businessCommitted := false
|
||||
defer func() {
|
||||
if retErr != nil && !businessCommitted {
|
||||
s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权失败", &before, series, shop, grantData(&before), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// 代理只能修改自己分配出去的授权
|
||||
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录")
|
||||
}
|
||||
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败")
|
||||
}
|
||||
config, err := series.GetOneTimeCommissionConfig()
|
||||
if err != nil || config == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "获取系列佣金配置失败")
|
||||
@@ -713,16 +740,23 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
}
|
||||
|
||||
allocation.Updater = operatorID
|
||||
if err := s.shopSeriesAllocationStore.Update(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败")
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := postgres.NewShopSeriesAllocationStore(tx).Update(ctx, allocation); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败")
|
||||
}
|
||||
return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权", allocation, series, shop,
|
||||
grantData(&before), grantData(allocation), nil, nil, nil)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
businessCommitted = true
|
||||
|
||||
return s.buildGrantResponse(ctx, allocation, series, config)
|
||||
}
|
||||
|
||||
// ManagePackages 管理授权套餐(新增/更新/删除)
|
||||
// PUT /api/admin/shop-series-grants/:id/packages
|
||||
func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) {
|
||||
func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -737,12 +771,29 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
|
||||
}
|
||||
series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
|
||||
if seriesErr != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败")
|
||||
}
|
||||
shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID)
|
||||
if shopErr != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败")
|
||||
}
|
||||
businessCommitted := false
|
||||
defer func() {
|
||||
if retErr != nil && !businessCommitted {
|
||||
s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权失败", allocation, series, shop, nil, retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// 代理只能操作自己分配的授权
|
||||
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录")
|
||||
}
|
||||
|
||||
affectedAllocations := make([]*model.ShopPackageAllocation, 0, len(req.Packages))
|
||||
priceHistories := make([]*model.ShopPackageAllocationPriceHistory, 0, len(req.Packages))
|
||||
packageChanges := make(map[uint]allocationAuditChange, len(req.Packages))
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
|
||||
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
|
||||
@@ -758,6 +809,11 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败")
|
||||
}
|
||||
affectedAllocations = append(affectedAllocations, existing)
|
||||
packageChanges[existing.ID] = allocationAuditChange{
|
||||
before: map[string]any{"cost_price": existing.CostPrice, "status": existing.Status},
|
||||
after: map[string]any{"deleted": true},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -789,16 +845,22 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败")
|
||||
}
|
||||
if oldPrice != costPrice {
|
||||
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
history := &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: existing.ID,
|
||||
OldCostPrice: oldPrice,
|
||||
NewCostPrice: costPrice,
|
||||
ChangeReason: "手动调价",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
}); historyErr != nil {
|
||||
}
|
||||
if historyErr := txHistoryStore.Create(ctx, history); historyErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
|
||||
}
|
||||
priceHistories = append(priceHistories, history)
|
||||
affectedAllocations = append(affectedAllocations, existing)
|
||||
packageChanges[existing.ID] = allocationAuditChange{
|
||||
before: map[string]any{"cost_price": oldPrice}, after: map[string]any{"cost_price": costPrice},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
|
||||
@@ -831,23 +893,35 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
|
||||
}
|
||||
if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||
history := &model.ShopPackageAllocationPriceHistory{
|
||||
AllocationID: pkgAlloc.ID,
|
||||
OldCostPrice: 0,
|
||||
NewCostPrice: costPrice,
|
||||
ChangeReason: "新增授权",
|
||||
ChangedBy: operatorID,
|
||||
EffectiveFrom: time.Now(),
|
||||
}); historyErr != nil {
|
||||
}
|
||||
if historyErr := txHistoryStore.Create(ctx, history); historyErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
|
||||
}
|
||||
affectedAllocations = append(affectedAllocations, pkgAlloc)
|
||||
priceHistories = append(priceHistories, history)
|
||||
packageChanges[pkgAlloc.ID] = allocationAuditChange{after: map[string]any{
|
||||
"cost_price": pkgAlloc.CostPrice, "retail_price": pkgAlloc.RetailPrice,
|
||||
"expiry_base_override": pkgAlloc.ExpiryBaseOverride, "status": pkgAlloc.Status,
|
||||
}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if len(affectedAllocations) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权", allocation, series, shop,
|
||||
nil, nil, affectedAllocations, priceHistories, packageChanges)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
businessCommitted = true
|
||||
|
||||
// 重新查询最新状态
|
||||
return s.Get(ctx, id)
|
||||
@@ -855,7 +929,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
|
||||
// Delete 删除系列授权(软删除)
|
||||
// DELETE /api/admin/shop-series-grants/:id
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
func (s *Service) Delete(ctx context.Context, id uint) (retErr error) {
|
||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id)
|
||||
@@ -865,6 +939,19 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
|
||||
}
|
||||
series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID)
|
||||
if seriesErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败")
|
||||
}
|
||||
shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID)
|
||||
if shopErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败")
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权失败", allocation, series, shop, grantData(allocation), retErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// 代理只能删除自己分配的授权
|
||||
if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID {
|
||||
@@ -886,7 +973,12 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
txPkgStore := postgres.NewShopPackageAllocationStore(tx)
|
||||
|
||||
pkgAllocations, _ := txPkgStore.GetBySeriesAllocationID(ctx, id)
|
||||
packageChanges := make(map[uint]allocationAuditChange, len(pkgAllocations))
|
||||
for _, pa := range pkgAllocations {
|
||||
packageChanges[pa.ID] = allocationAuditChange{
|
||||
before: map[string]any{"cost_price": pa.CostPrice, "retail_price": pa.RetailPrice, "status": pa.Status},
|
||||
after: map[string]any{"deleted": true},
|
||||
}
|
||||
if delErr := txPkgStore.Delete(ctx, pa.ID); delErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, delErr, "删除套餐分配失败")
|
||||
}
|
||||
@@ -895,6 +987,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
if delErr := txSeriesStore.Delete(ctx, id); delErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, delErr, "删除系列授权失败")
|
||||
}
|
||||
return nil
|
||||
return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权", allocation, series, shop,
|
||||
grantData(allocation), map[string]any{"deleted": true}, pkgAllocations, nil, packageChanges)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package wechat_config
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
@@ -12,10 +13,12 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"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/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
@@ -24,11 +27,6 @@ import (
|
||||
// Redis 缓存键
|
||||
const redisActiveConfigKey = "wechat:config:active"
|
||||
|
||||
// AuditServiceInterface 审计日志服务接口
|
||||
type AuditServiceInterface interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
}
|
||||
|
||||
// Service 微信参数配置业务服务
|
||||
type Service struct {
|
||||
store *postgres.WechatConfigStore
|
||||
@@ -36,7 +34,7 @@ type Service struct {
|
||||
rechargeOrderStore *postgres.RechargeOrderStore
|
||||
agentRechargeStore *postgres.AgentRechargeStore
|
||||
paymentStore *postgres.PaymentStore
|
||||
auditService AuditServiceInterface
|
||||
audit systemconfigapp.AuditWriter
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
@@ -48,7 +46,7 @@ func New(
|
||||
rechargeOrderStore *postgres.RechargeOrderStore,
|
||||
agentRechargeStore *postgres.AgentRechargeStore,
|
||||
paymentStore *postgres.PaymentStore,
|
||||
auditService AuditServiceInterface,
|
||||
audit systemconfigapp.AuditWriter,
|
||||
rdb *redis.Client,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
@@ -58,7 +56,7 @@ func New(
|
||||
rechargeOrderStore: rechargeOrderStore,
|
||||
agentRechargeStore: agentRechargeStore,
|
||||
paymentStore: paymentStore,
|
||||
auditService: auditService,
|
||||
audit: audit,
|
||||
redis: rdb,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -69,8 +67,17 @@ func New(
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest) (*dto.WechatConfigResponse, error) {
|
||||
// 根据 provider_type 校验必填字段
|
||||
if err := s.validateProviderFields(req); err != nil {
|
||||
s.recordAuditFailure(ctx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationPaymentConfigCreate,
|
||||
Description: "拒绝创建非法支付连接配置", ConfigKey: "payment_config.new:" + req.Name,
|
||||
DisplayName: req.Name, Identity: map[string]any{"name": req.Name, "provider_type": req.ProviderType, "credentials_configured": paymentRequestCredentialsConfigured(req)},
|
||||
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam), ErrorSummary: "支付连接配置字段校验失败",
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
|
||||
}
|
||||
|
||||
var desc *string
|
||||
if req.Description != "" {
|
||||
@@ -118,28 +125,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest
|
||||
}
|
||||
config.Creator = middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
if err := s.store.Create(ctx, config); err != nil {
|
||||
err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.store.WithTx(tx).Create(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置", nil, config)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置失败", config, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建微信支付配置失败")
|
||||
}
|
||||
|
||||
// 审计日志
|
||||
afterData := model.JSONB{
|
||||
"id": config.ID,
|
||||
"name": config.Name,
|
||||
"provider_type": config.ProviderType,
|
||||
}
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: "",
|
||||
OperationType: "create",
|
||||
OperationDesc: fmt.Sprintf("创建微信支付配置:%s", config.Name),
|
||||
AfterData: afterData,
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return dto.FromWechatConfigModel(config), nil
|
||||
}
|
||||
|
||||
@@ -200,6 +196,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
|
||||
}
|
||||
before := *config
|
||||
|
||||
// 合并字段:指针非 nil 时更新,敏感字段空字符串表示保持原值
|
||||
if req.Name != nil {
|
||||
@@ -256,7 +256,14 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf
|
||||
|
||||
config.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
if err := s.store.Update(ctx, config); err != nil {
|
||||
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.store.WithTx(tx).Update(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置", &before, config)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置失败", config, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新微信支付配置失败")
|
||||
}
|
||||
|
||||
@@ -265,24 +272,6 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf
|
||||
s.clearActiveConfigCache(ctx)
|
||||
}
|
||||
|
||||
afterData := model.JSONB{
|
||||
"id": config.ID,
|
||||
"name": config.Name,
|
||||
"provider_type": config.ProviderType,
|
||||
"is_active": config.IsActive,
|
||||
}
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: "",
|
||||
OperationType: "update",
|
||||
OperationDesc: fmt.Sprintf("更新微信支付配置:%s", config.Name),
|
||||
AfterData: afterData,
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return dto.FromWechatConfigModel(config), nil
|
||||
}
|
||||
|
||||
@@ -296,9 +285,13 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
|
||||
}
|
||||
|
||||
// 不允许删除正在激活的配置
|
||||
if config.IsActive {
|
||||
s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除生效中的支付连接配置", config, errors.CodeWechatConfigActive)
|
||||
return errors.New(errors.CodeWechatConfigActive)
|
||||
}
|
||||
|
||||
@@ -314,32 +307,23 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
}
|
||||
|
||||
if pendingOrders > 0 || pendingRecharges > 0 {
|
||||
s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除存在在途业务的支付连接配置", config, errors.CodeWechatConfigHasPendingOrders)
|
||||
return errors.New(errors.CodeWechatConfigHasPendingOrders)
|
||||
}
|
||||
|
||||
if err := s.store.SoftDelete(ctx, id); err != nil {
|
||||
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.store.WithTx(tx).SoftDelete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置", config, nil)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置失败", config, err)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除微信支付配置失败")
|
||||
}
|
||||
|
||||
s.clearActiveConfigCache(ctx)
|
||||
|
||||
beforeData := model.JSONB{
|
||||
"id": config.ID,
|
||||
"name": config.Name,
|
||||
"provider_type": config.ProviderType,
|
||||
}
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: "",
|
||||
OperationType: "delete",
|
||||
OperationDesc: fmt.Sprintf("删除微信支付配置:%s", config.Name),
|
||||
BeforeData: beforeData,
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -353,19 +337,32 @@ func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigRespo
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||
}
|
||||
|
||||
// 记录旧的激活配置名称
|
||||
oldActiveName := ""
|
||||
oldActive, oldErr := s.store.GetActive(ctx)
|
||||
if oldErr == nil && oldActive != nil {
|
||||
oldActiveName = oldActive.Name
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
|
||||
}
|
||||
before := *config
|
||||
|
||||
// 保留原激活配置快照,确保自动停用也进入该配置自身时间线。
|
||||
oldActive, oldErr := s.store.GetActive(ctx)
|
||||
|
||||
// 事务内激活
|
||||
db := s.store.DB()
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.store.ActivateInTx(ctx, tx, id)
|
||||
if err := s.store.ActivateInTx(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
after := before
|
||||
after.IsActive = true
|
||||
if oldErr == nil && oldActive != nil && oldActive.ID != id {
|
||||
oldAfter := *oldActive
|
||||
oldAfter.IsActive = false
|
||||
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "激活其他配置时停用原支付连接配置", oldActive, &oldAfter); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置", &before, &after)
|
||||
}); err != nil {
|
||||
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置失败", config, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "激活微信支付配置失败")
|
||||
}
|
||||
|
||||
@@ -374,22 +371,6 @@ func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigRespo
|
||||
// 重新查询最新状态
|
||||
config, _ = s.store.GetByID(ctx, id)
|
||||
|
||||
desc := fmt.Sprintf("激活微信支付配置:%s", config.Name)
|
||||
if oldActiveName != "" && oldActiveName != config.Name {
|
||||
desc = fmt.Sprintf("激活微信支付配置:%s(原激活配置:%s)", config.Name, oldActiveName)
|
||||
}
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: "",
|
||||
OperationType: "activate",
|
||||
OperationDesc: desc,
|
||||
AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": true},
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return dto.FromWechatConfigModel(config), nil
|
||||
}
|
||||
|
||||
@@ -403,8 +384,21 @@ func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigRes
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
|
||||
}
|
||||
if s.audit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
|
||||
}
|
||||
before := *config
|
||||
after := before
|
||||
after.IsActive = false
|
||||
|
||||
if err := s.store.Deactivate(ctx, id); err != nil {
|
||||
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.store.WithTx(tx).Deactivate(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置", &before, &after)
|
||||
})
|
||||
if err != nil {
|
||||
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置失败", config, err)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "停用微信支付配置失败")
|
||||
}
|
||||
|
||||
@@ -413,21 +407,109 @@ func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigRes
|
||||
// 重新查询最新状态
|
||||
config, _ = s.store.GetByID(ctx, id)
|
||||
|
||||
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||
OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: "",
|
||||
OperationType: "deactivate",
|
||||
OperationDesc: fmt.Sprintf("停用微信支付配置:%s", config.Name),
|
||||
AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": false},
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||
IPAddress: middleware.GetIPFromContext(ctx),
|
||||
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
|
||||
return dto.FromWechatConfigModel(config), nil
|
||||
}
|
||||
|
||||
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.WechatConfig) error {
|
||||
config := after
|
||||
if config == nil {
|
||||
config = before
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(config.ID), 10)
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
|
||||
ConfigKey: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID,
|
||||
DisplayName: config.Name, Identity: paymentConfigIdentity(config),
|
||||
BeforeData: paymentConfigAuditSnapshot(before), AfterData: paymentConfigAuditSnapshot(after),
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recordPaymentDenied(ctx context.Context, operation, description string, config *model.WechatConfig, code int) {
|
||||
s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultDenied, code))
|
||||
}
|
||||
|
||||
func (s *Service) recordPaymentFailure(ctx context.Context, operation, description string, config *model.WechatConfig, _ error) {
|
||||
s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultFailed, errors.CodeDatabaseError))
|
||||
}
|
||||
|
||||
func (s *Service) recordAuditFailure(ctx context.Context, audit systemconfigapp.ChangeAudit) {
|
||||
if s.audit == nil || s.store == nil || s.store.DB() == nil || audit.OperatorID == 0 || audit.ConfigKey == "" {
|
||||
return
|
||||
}
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
audit.RequestID = *value
|
||||
audit.CorrelationID = *value
|
||||
}
|
||||
if err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.audit.WriteConfigChange(ctx, tx, audit)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func paymentFailureAudit(ctx context.Context, operation, description string, config *model.WechatConfig, result string, code int) systemconfigapp.ChangeAudit {
|
||||
resourceID := strconv.FormatUint(uint64(config.ID), 10)
|
||||
return systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
|
||||
ConfigKey: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID,
|
||||
DisplayName: config.Name, Identity: paymentConfigIdentity(config), BeforeData: paymentConfigAuditSnapshot(config),
|
||||
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
|
||||
}
|
||||
}
|
||||
|
||||
func paymentConfigIdentity(config *model.WechatConfig) map[string]any {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": config.ID, "name": config.Name, "provider_type": config.ProviderType,
|
||||
"is_active": config.IsActive, "credentials_configured": paymentConfigCredentialsConfigured(config),
|
||||
}
|
||||
}
|
||||
|
||||
func paymentConfigAuditSnapshot(config *model.WechatConfig) map[string]any {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": config.ID, "name": config.Name, "description": config.Description,
|
||||
"provider_type": config.ProviderType, "is_active": config.IsActive,
|
||||
"oa_app_id": config.OaAppID, "oa_oauth_redirect_url": config.OaOAuthRedirectURL,
|
||||
"miniapp_app_id": config.MiniappAppID, "wx_mch_id": config.WxMchID,
|
||||
"wx_serial_no": config.WxSerialNo, "wx_notify_url": config.WxNotifyURL,
|
||||
"fy_ins_cd": config.FyInsCd, "fy_mchnt_cd": config.FyMchntCd, "fy_term_id": config.FyTermID,
|
||||
"fy_api_url": config.FyAPIURL, "fy_notify_url": config.FyNotifyURL,
|
||||
"ali_app_id": config.AliAppID, "ali_notify_url": config.AliNotifyURL,
|
||||
"ali_return_url": config.AliReturnURL, "ali_production": config.AliProduction,
|
||||
"ali_pay_expire_minutes": config.AliPayExpireMinutes,
|
||||
"credentials_configured": paymentConfigCredentialsConfigured(config),
|
||||
"oauth_configured": config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "",
|
||||
"wechat_payment_configured": config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "",
|
||||
"fuiou_configured": config.FyPrivateKey != "" || config.FyPublicKey != "",
|
||||
"alipay_configured": config.AliPrivateKey != "" || config.AliPublicKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
func paymentConfigCredentialsConfigured(config *model.WechatConfig) bool {
|
||||
if config == nil {
|
||||
return false
|
||||
}
|
||||
return config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "" ||
|
||||
config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "" ||
|
||||
config.FyPrivateKey != "" || config.FyPublicKey != "" || config.AliPrivateKey != "" || config.AliPublicKey != ""
|
||||
}
|
||||
|
||||
func paymentRequestCredentialsConfigured(request *dto.CreateWechatConfigRequest) bool {
|
||||
return request != nil && (request.OaAppSecret != "" || request.OaToken != "" || request.OaAesKey != "" || request.MiniappAppSecret != "" ||
|
||||
request.WxAPIV3Key != "" || request.WxAPIV2Key != "" || request.WxCertContent != "" || request.WxKeyContent != "" ||
|
||||
request.FyPrivateKey != "" || request.FyPublicKey != "" || request.AliPrivateKey != "" || request.AliPublicKey != "")
|
||||
}
|
||||
|
||||
// GetActiveConfig 获取当前生效的支付配置(带 Redis 缓存)
|
||||
// 缓存策略:命中直接返回,未命中查 DB 后缓存 5 分钟,无记录缓存 "none" 1 分钟
|
||||
func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) {
|
||||
|
||||
Reference in New Issue
Block a user