diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 171d144f..e6e8dc97 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -98,7 +98,12 @@ func NewRegistry() *Registry { {Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}, {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, } + // 轮询开关可由后台人工、文件导入 Worker、订单套餐生效(C 端、开放接口、支付回调)与改有效期触发,均需留下审计事实。 + iotCardPollingStatusUpdated.AllowedActor, iotCardPollingStatusUpdated.Source = "", "" + iotCardPollingStatusUpdated.AllowedOrigins = pollingToggleOrigins() iotCardPollingStatusBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardPollingStatusBatchUpdated, "批量更新 IoT 卡轮询开关") + iotCardPollingStatusBatchUpdated.AllowedActor, iotCardPollingStatusBatchUpdated.Source = "", "" + iotCardPollingStatusBatchUpdated.AllowedOrigins = pollingToggleOrigins() iotCardBatchDeleted := ActionDefinition{ Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡", Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh, @@ -135,6 +140,8 @@ func NewRegistry() *Registry { deviceDeactivated := deviceAction(constants.AuditActionDeviceDeactivated, "停用设备资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI) deviceDeactivated.Risk = constants.AuditRiskHigh devicePollingStatusUpdated := deviceAction(constants.AuditActionDevicePollingStatusUpdated, "更新设备轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI) + devicePollingStatusUpdated.AllowedActor, devicePollingStatusUpdated.Source = "", "" + devicePollingStatusUpdated.AllowedOrigins = pollingToggleOrigins() deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备") deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备") deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备") @@ -1707,3 +1714,15 @@ func (r *Registry) Resource(resourceType string) (ResourceDefinition, bool) { resource, ok := r.resources[resourceType] return resource, ok } + +// pollingToggleOrigins 是轮询开关变更允许的审计来源(后台、Worker、开放接口、C 端、支付回调)。 +func pollingToggleOrigins() []ActionOrigin { + return []ActionOrigin{ + {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, + {Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI}, + {Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}, + {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, + } +} diff --git a/internal/infrastructure/pollingpause/recorder.go b/internal/infrastructure/pollingpause/recorder.go index abe177ba..d5b408a5 100644 --- a/internal/infrastructure/pollingpause/recorder.go +++ b/internal/infrastructure/pollingpause/recorder.go @@ -4,6 +4,7 @@ package pollingpause import ( "context" + "strconv" "strings" "time" @@ -68,9 +69,10 @@ func Record(ctx context.Context, tx *gorm.DB, change Change, fallback string) er ClosedBy: origin.OperatorID, ClosedAt: now, ImportTaskID: origin.ImportTaskID, CreatedAt: now, UpdatedAt: now, } // 部分唯一索引 (asset_type, asset_id) WHERE status = 1:已有已关闭记录时保留原记录。 + // 谓词必须写成字面量:启用预编译语句时,参数化的 WHERE 在通用执行计划下无法匹配部分唯一索引。 err := tx.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}}, - TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "status = ?", Vars: []any{constants.PollingPauseStatusClosed}}}}, + TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: closedStatusPredicate}}}, DoNothing: true, }).Create(row).Error if err != nil { @@ -119,6 +121,9 @@ func RiskEnableDeniedReason(extend string) string { return "风险停机,不能开启轮询" } +// closedStatusPredicate 是部分唯一索引 uq_card_polling_pause_open 的谓词字面量。 +var closedStatusPredicate = "status = " + strconv.Itoa(constants.PollingPauseStatusClosed) + func truncate(s string, max int) string { runes := []rune(s) if len(runes) <= max { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 38f405e9..9da04c1c 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -1772,6 +1772,7 @@ func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, pay } func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) { + alreadyPaid := false order, err := s.orderStore.GetByID(ctx, orderID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -1852,6 +1853,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, switch currentOrder.PaymentStatus { case model.PaymentStatusPaid: + alreadyPaid = true return nil case model.PaymentStatusCancelled: return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") @@ -1912,6 +1914,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, switch currentOrder.PaymentStatus { case model.PaymentStatusPaid: + alreadyPaid = true return nil case model.PaymentStatusCancelled: return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") @@ -1974,7 +1977,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, if err != nil { return err } - s.afterOrderActivated(ctx, order, false) + if !alreadyPaid { + // 重复支付请求不再恢复轮询,避免把运营在两次请求之间刚关闭的轮询重新打开。 + s.afterOrderActivated(ctx, order, false) + } return nil } diff --git a/internal/service/polling/polling_pause_service.go b/internal/service/polling/polling_pause_service.go index b2bea11d..17c8a8a9 100644 --- a/internal/service/polling/polling_pause_service.go +++ b/internal/service/polling/polling_pause_service.go @@ -3,6 +3,7 @@ package polling import ( "context" stderrors "errors" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "time" "go.uber.org/zap" @@ -259,6 +260,10 @@ func (s *AssetPollingService) RestoreAssetPolling(ctx context.Context, assetType return false } ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: source, Reason: constants.PollingToggleSourceName(source) + ",自动恢复轮询"}) + if auditcontext.From(ctx).ActorKind == "" { + ctx = auditcontext.With(ctx, auditcontext.Context{ActorKind: constants.AuditActorSystemTask, ActorID: "polling_auto_restore", + ActorName: "套餐生效自动恢复轮询", Source: constants.AuditSourceWorker}) + } restored := false switch assetType { case constants.AssetTypeIotCard: diff --git a/internal/task/polling_status_import.go b/internal/task/polling_status_import.go index ed942aa7..fae86202 100644 --- a/internal/task/polling_status_import.go +++ b/internal/task/polling_status_import.go @@ -6,6 +6,7 @@ import ( "encoding/csv" "encoding/json" stderrors "errors" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "io" "os" "strings" @@ -77,6 +78,8 @@ func (h *PollingStatusImportHandler) Handle(ctx context.Context, t *asynq.Task) } rowCtx := pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceImport, Reason: task.Reason, ImportTaskID: task.ID, OperatorID: task.Creator}) + rowCtx = auditcontext.With(rowCtx, auditcontext.Context{ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePollingStatusImport, + ActorName: "轮询开关导入任务", Source: constants.AuditSourceWorker, CorrelationID: task.TaskNo}) results := make(model.PollingStatusImportResults, 0, len(identifiers)) success, skipped, failed := 0, 0, 0 for index, identifier := range identifiers { @@ -131,8 +134,16 @@ func (h *PollingStatusImportHandler) resolve(ctx context.Context, assetType, ide err := h.db.WithContext(ctx).Select("id").Where("imei = ? OR virtual_no = ?", identifier, identifier).Order("id").First(&device).Error return device.ID, err } + // 与 IotCardStore.GetByICCID 同口径:19 位按 iccid_19、20 位按 iccid_20 匹配(联通卡两种位数都可识别)。 + column := "iccid" + switch len(identifier) { + case 19: + column = "iccid_19" + case 20: + column = "iccid_20" + } var card model.IotCard - err := h.db.WithContext(ctx).Select("id").Where("iccid = ?", identifier).First(&card).Error + err := h.db.WithContext(ctx).Select("id").Where(column+" = ?", identifier).First(&card).Error return card.ID, err }