diff --git a/cmd/api/docs.go b/cmd/api/docs.go index b78e5011..5029b2b2 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -34,6 +34,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { // 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil) handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil) + handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index b2a75b73..abad3806 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -43,6 +43,7 @@ func generateAdminDocs(outputPath string) error { // 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil) handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil) + handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 diff --git a/cmd/worker/main.go b/cmd/worker/main.go index f84a5b09..9185b212 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -196,6 +196,10 @@ func runWorker(cfg *config.Config) { taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger) taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle) + if runtime.workerResult.Services.AssetPolling != nil { + importHandler := task.NewPollingStatusImportHandler(runtime.db, runtime.storageSvc, runtime.workerResult.Services.AssetPolling, appLogger) + taskHandler.GetMux().HandleFunc(constants.TaskTypePollingStatusImport, importHandler.Handle) + } dueRecalculator := polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger) dueRecalculator.SetCarrierScheduling(runtime.carrierScheduling) recalcHandler := polling.NewRecalculateTaskHandler(dueRecalculator, runtime.redisClient, appLogger) @@ -366,6 +370,10 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok { stopResumeSvc.SetPollingCallback(lifecycleSvc) } + if workerResult.Services.IotCardPolling != nil { + // 轮询开关导入与订单自动恢复轮询经该卡服务改开关,开启后按生命周期重新入队。 + workerResult.Services.IotCardPolling.SetPollingCallback(lifecycleSvc) + } if workerResult.Services.CarrierThreshold == nil { appLogger.Fatal("运营商通道流量阈值用例未配置") } diff --git a/internal/application/cardobservation/network.go b/internal/application/cardobservation/network.go index c3e132c5..1789278a 100644 --- a/internal/application/cardobservation/network.go +++ b/internal/application/cardobservation/network.go @@ -2,6 +2,7 @@ package cardobservation import ( "context" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" "strconv" "time" @@ -78,6 +79,12 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新") } + if decision.StopPolling && card.EnablePolling { + // 风险停机或已销户自动关闭轮询:同事务写不轮询清单(只关该卡自身,不级联设备)。 + if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(&card, false), constants.PollingToggleSourceRiskAuto); err != nil { + return err + } + } if decision.StatusChanged { eventID := outboxid.Stable("card-network:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":changed") if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{ diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 2ac6c7bd..d898a96e 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -59,6 +59,7 @@ import ( func initHandlers(svc *services, deps *Dependencies) *Handlers { validate := validator.New() + assetPollingSvc := newAssetPollingService(svc, deps) packageExpiry := packageExpiryQuery.NewQuery(deps.DB) svc.Asset.SetPackageExpiryQuery(packageExpiry) svc.IotCard.SetPackageExpiryQuery(packageExpiry) @@ -337,6 +338,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), GatewayRateLimit: admin.NewGatewayRateLimitHandler(pollingSvcPkg.NewGatewayRateLimitService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), deps.GatewayLimit), deps.Redis, validate), PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), + PollingPause: admin.NewPollingPauseHandler(assetPollingSvc, + pollingSvcPkg.NewPollingStatusImportService(deps.DB, deps.QueueClient, deps.Logger), validate), CarrierScheduler: admin.NewCarrierSchedulerHandler(pollingSvcPkg.NewCarrierSchedulerService(deps.DB, deps.Redis, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), carrierSchedulerConfig()), validate), PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), @@ -347,16 +350,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate, ), Asset: func() *admin.AssetHandler { - pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) - assetPollingSvc := pollingSvcPkg.NewAssetPollingService( - deps.DB, - deviceStore, - deviceSimBindingStore, - svc.IotCard, - pollingQueueMgr, - deps.Logger, - svc.AccessAudit, - ) h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger)) h.SetLifecycleService(svc.AssetLifecycle) h.SetObservationSeriesDispatcher(svc.ObservationSeries) @@ -418,3 +411,20 @@ func carrierSchedulerConfig() config.CarrierSchedulerConfig { } return config.CarrierSchedulerConfig{} } + +// newAssetPollingService 创建资产轮询开关服务,并注入订单套餐生效与改有效期后的自动恢复轮询。 +func newAssetPollingService(svc *services, deps *Dependencies) *pollingSvcPkg.AssetPollingService { + pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) + assetPollingSvc := pollingSvcPkg.NewAssetPollingService( + deps.DB, + postgres.NewDeviceStore(deps.DB, deps.Redis), + postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), + svc.IotCard, + pollingQueueMgr, + deps.Logger, + svc.AccessAudit, + ) + svc.Order.SetPollingRestorer(assetPollingSvc) + svc.Asset.SetPollingRestorer(assetPollingSvc, svc.StopResumeService, deps.Logger) + return assetPollingSvc +} diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 917bfecf..c3386587 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -66,6 +66,7 @@ type Handlers struct { PollingConfig *admin.PollingConfigHandler GatewayRateLimit *admin.GatewayRateLimitHandler CarrierScheduler *admin.CarrierSchedulerHandler + PollingPause *admin.PollingPauseHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler PollingAlert *admin.PollingAlertHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index e9cf6ccf..56592495 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -2,7 +2,9 @@ package bootstrap import ( "context" + pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/config" @@ -172,6 +174,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q orderService.SetEmployeeCollectionBillCreation(employeecollectionApp.NewBillCreationService(auditWriter)) orderService.SetPreActivationSettler(preActivationSettler) orderService.SetLifecycleAudit(auditWriter) + // 资产轮询开关服务:轮询开关文件导入与订单套餐生效后自动恢复轮询(批量下单在 Worker 内执行)。 + assetPollingService := pollingSvc.NewAssetPollingService(deps.DB, stores.Device, stores.DeviceSimBinding, iotCardAuditService, + pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger), deps.Logger, auditWriter) + orderService.SetPollingRestorer(assetPollingService) orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter) @@ -244,5 +250,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q OrderExpirer: orderService, AssetPackageOrderCreator: orderService, DeviceBatchAllocator: deviceBatchAllocator, + AssetPolling: assetPollingService, + IotCardPolling: iotCardAuditService, } } diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index b9bf6bda..7ff7d183 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -15,7 +15,9 @@ const ( deviceExportBaseHeaderCount = 6 deviceExportCardGroupSize = 5 // deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。 - deviceExportTailHeaderCount = 7 + deviceExportTailHeaderCount = 8 + // deviceExportGBTailHeaderCount 是新增「是否轮询」之前、带「设备合计(GB)」的尾部列数,供历史任务表头反解。 + deviceExportGBTailHeaderCount = 7 // deviceExportPhoneTailHeaderCount 是新增「设备合计(GB)」之前、带「关联手机号」的尾部列数,供历史任务表头反解。 deviceExportPhoneTailHeaderCount = 6 // deviceExportLegacyTailHeaderCount 是新增「关联手机号」之前的尾部列数,供历史任务表头反解。 @@ -25,6 +27,8 @@ const ( deviceExportAssociatedPhoneHeader = "关联手机号" // deviceExportTotalGBHeader 是「设备合计(GB)」列的表头,固定位于导出最后一列。 deviceExportTotalGBHeader = "设备合计(GB)" + // deviceExportPollingHeader 是「是否轮询」列的表头,位于导出最后一列。 + deviceExportPollingHeader = "是否轮询" // deviceExportMBPerGB MB 换算 GB 的进制。 deviceExportMBPerGB = 1024 ) @@ -177,6 +181,9 @@ func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *go if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok { query = s.applyHasActivePackageFilter(query, hasActivePackage) } + if enablePolling, ok := filterOptionalBool(params.Filters, "enable_polling"); ok { + query = query.Where("d.enable_polling = ?", enablePolling) + } return query } @@ -265,7 +272,8 @@ func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams d.device_model, COALESCE(sh.shop_name, '') AS shop_name, COALESCE(ps.series_name, '') AS series_name, - COALESCE(aw.balance, 0) AS wallet_balance + COALESCE(aw.balance, 0) AS wallet_balance, + d.enable_polling `). Order("d.id ASC"). Limit(limit). @@ -352,6 +360,7 @@ type deviceExportRow struct { ShopName string `gorm:"column:shop_name"` SeriesName string `gorm:"column:series_name"` WalletBalance int64 `gorm:"column:wallet_balance"` + EnablePolling bool `gorm:"column:enable_polling"` } type deviceExportCardRow struct { @@ -394,6 +403,7 @@ func buildDeviceExportHeaders(cardGroups int) []string { "钱包余额", deviceExportAssociatedPhoneHeader, deviceExportTotalGBHeader, + deviceExportPollingHeader, ) return headers } @@ -444,9 +454,12 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg if tailCount >= deviceExportPhoneTailHeaderCount { row = append(row, associatedPhone) } - if tailCount >= deviceExportTailHeaderCount { + if tailCount >= deviceExportGBTailHeaderCount { row = append(row, formatMB(totalUsage/deviceExportMBPerGB)) } + if tailCount >= deviceExportTailHeaderCount { + row = append(row, yesNo(item.EnablePolling)) + } return row } @@ -462,8 +475,10 @@ func deviceExportTailCount(headers []string) int { return deviceExportTailHeaderCount } switch headers[len(headers)-1] { - case deviceExportTotalGBHeader: + case deviceExportPollingHeader: return deviceExportTailHeaderCount + case deviceExportTotalGBHeader: + return deviceExportGBTailHeaderCount case deviceExportAssociatedPhoneHeader: return deviceExportPhoneTailHeaderCount default: diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 61f7606a..17f768c8 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -40,7 +40,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { // 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader}, nil + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -60,6 +60,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs COALESCE(sh.shop_name, '') AS shop_name, COALESCE(d.device_name, '') AS device_name, c.real_name_status, + c.enable_polling, c.first_realname_at, c.network_status, COALESCE(pkg.package_name, '') AS package_name, @@ -81,7 +82,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if err != nil { return nil, err } - includeExpiry, includeGB := iotCardExportTailColumns(params.ResolvedHeaders) + includeExpiry, includeGB, includePolling := iotCardExportTailColumns(params.ResolvedHeaders) var finalExpiries map[uint]string if includeExpiry { finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) @@ -114,6 +115,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if includeGB { row = append(row, formatMB(float64(item.DataUsageMB)/deviceExportMBPerGB), formatMB(float64(remainingMB)/deviceExportMBPerGB)) } + if includePolling { + row = append(row, yesNo(item.EnablePolling)) + } rows = append(rows, row) } return rows, nil @@ -135,19 +139,21 @@ func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uin return result, nil } -// iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「剩余流量(GB)」输出到期列与 GB 列, -// 末列「套餐到期时间」只输出到期列,更早的表头两者都不输出;无冻结表头按当前版本输出。 -func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB bool) { +// iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「是否轮询」输出到期、GB 与是否轮询列, +// 末列「剩余流量(GB)」输出到期列与 GB 列,末列「套餐到期时间」只输出到期列,更早的表头都不输出;无冻结表头按当前版本输出。 +func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling bool) { if len(headers) == 0 { - return true, true + return true, true, true } switch headers[len(headers)-1] { + case iotCardExportPollingHeader: + return true, true, true case iotCardExportRemainingGBHeader: - return true, true + return true, true, false case iotCardExportFinalExpiryHeader: - return true, false + return true, false, false default: - return false, false + return false, false, false } } @@ -268,6 +274,9 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa if carrierName, ok := filterString(params.Filters, "carrier_name"); ok { query = query.Where("c.carrier_name LIKE ?", "%"+carrierName+"%") } + if enablePolling, ok := filterOptionalBool(params.Filters, "enable_polling"); ok { + query = query.Where("c.enable_polling = ?", enablePolling) + } if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok { subQuery := s.db.WithContext(ctx).Table("tb_package_usage AS pu"). Select("1"). @@ -291,6 +300,8 @@ const iotCardExportFinalExpiryHeader = "套餐到期时间" const ( iotCardExportUsageGBHeader = "使用流量(GB)" iotCardExportRemainingGBHeader = "剩余流量(GB)" + // iotCardExportPollingHeader 是「是否轮询」列的表头,位于 GB 流量列之后、导出最后一列。 + iotCardExportPollingHeader = "是否轮询" ) type iotCardExportRow struct { @@ -307,6 +318,7 @@ type iotCardExportRow struct { PackageName string `gorm:"column:package_name"` DataUsageMB int64 `gorm:"column:data_usage_mb"` DataLimitMB int64 `gorm:"column:data_limit_mb"` + EnablePolling bool `gorm:"column:enable_polling"` } func remainingPackageDataMB(limit, used int64) int64 { diff --git a/internal/exporter/polling_pause_scene.go b/internal/exporter/polling_pause_scene.go new file mode 100644 index 00000000..979dce77 --- /dev/null +++ b/internal/exporter/polling_pause_scene.go @@ -0,0 +1,226 @@ +package exporter + +import ( + "context" + "strconv" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/query/pollingalert" + pausequery "github.com/break/junhong_cmp_fiber/internal/query/pollingpause" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// 不轮询清单与停机被拒告警导出(add-polling-pause-list-and-batch-toggle),仅超级管理员与平台账号。 + +var pollingPauseHeaders = []string{ + "资产类型", "资产ID", "ICCID或IMEI", "运营商账户", "当前是否轮询", "网关扩展状态", "状态", "关闭来源", "关闭原因", + "关闭人", "关闭时间", "导入任务ID", "恢复来源", "恢复原因", "恢复人", "恢复时间", +} + +// PollingPauseDataSource 不轮询清单导出数据源。 +type PollingPauseDataSource struct { + query *pausequery.Query +} + +// NewPollingPauseDataSource 创建不轮询清单导出数据源。 +func NewPollingPauseDataSource(db *gorm.DB) *PollingPauseDataSource { + return &PollingPauseDataSource{query: pausequery.NewQuery(db)} +} + +// Scene 返回导出场景编码。 +func (s *PollingPauseDataSource) Scene() string { return constants.ExportTaskScenePollingPause } + +func (s *PollingPauseDataSource) filter(params ExportParams) (pausequery.Filter, error) { + if err := pausequery.EnsureAllowed(params.UserType); err != nil { + return pausequery.Filter{}, err + } + filter := pausequery.Filter{} + filter.AssetType, _ = filterString(params.Filters, "asset_type") + filter.Source, _ = filterString(params.Filters, "source") + filter.Identifier, _ = filterString(params.Filters, "identifier") + if id, ok := filterUint(params.Filters, "carrier_id"); ok && id > 0 { + filter.CarrierID = &id + } + if id, ok := filterUint(params.Filters, "operator_id"); ok && id > 0 { + filter.OperatorID = &id + } + if status, ok := filterInt(params.Filters, "status"); ok && status > 0 { + filter.Status = &status + } + if t, ok := filterTime(params.Filters, "start_time"); ok { + filter.StartTime = &t + } + if t, ok := filterTime(params.Filters, "end_time"); ok { + filter.EndTime = &t + } + return filter, nil +} + +// Count 统计导出行数。 +func (s *PollingPauseDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + filter, err := s.filter(params) + if err != nil { + return 0, err + } + total, err := s.query.Count(ctx, filter) + return int(total), err +} + +// Headers 返回导出表头。 +func (s *PollingPauseDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { + if err := pausequery.EnsureAllowed(params.UserType); err != nil { + return nil, err + } + return pollingPauseHeaders, nil +} + +// Fetch 按 offset/limit 返回导出行。 +func (s *PollingPauseDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + filter, err := s.filter(params) + if err != nil { + return nil, err + } + rows, err := s.query.Page(ctx, filter, offset, limit) + if err != nil { + return nil, err + } + result := make([][]string, 0, len(rows)) + for _, row := range rows { + assetType := "卡" + if row.AssetType == constants.AssetTypeDevice { + assetType = "设备" + } + importID := "" + if row.ImportTaskID > 0 { + importID = strconv.FormatUint(uint64(row.ImportTaskID), 10) + } + result = append(result, []string{ + assetType, strconv.FormatUint(uint64(row.AssetID), 10), row.Identifier, row.CarrierName, yesNo(row.EnablePolling), row.GatewayExtend, + constants.PollingPauseStatusName(row.Status), constants.PollingToggleSourceName(row.CloseSource), row.CloseReason, + operatorName(row.ClosedBy, row.ClosedByName), formatExportTime(&row.ClosedAt), importID, + constants.PollingToggleSourceName(row.RestoreSource), row.RestoreReason, operatorName(row.RestoredBy, row.RestoredByName), + formatExportTime(row.RestoredAt), + }) + } + return result, nil +} + +var stopRefusedHeaders = []string{ + "告警ID", "ICCID", "运营商账户", "所属设备ID", "网络状态", "有生效套餐", "停机原因", "错误类型", "上游返回信息", + "首次失败时间", "最近失败时间", "失败次数", "是否轮询", "处理状态", "处理人", "处理时间", "处理备注", +} + +// StopRefusedAlertDataSource 停机被拒告警导出数据源。 +type StopRefusedAlertDataSource struct { + query *pollingalert.Query + threshold func(ctx context.Context) int +} + +// NewStopRefusedAlertDataSource 创建停机被拒告警导出数据源;threshold 为空时取默认阈值。 +func NewStopRefusedAlertDataSource(db *gorm.DB, threshold func(ctx context.Context) int) *StopRefusedAlertDataSource { + return &StopRefusedAlertDataSource{query: pollingalert.NewQuery(db), threshold: threshold} +} + +// Scene 返回导出场景编码。 +func (s *StopRefusedAlertDataSource) Scene() string { + return constants.ExportTaskScenePollingStopRefusedAlert +} + +func (s *StopRefusedAlertDataSource) filter(ctx context.Context, params ExportParams) (pollingalert.StopRefusedFilter, int, error) { + if err := pausequery.EnsureAllowed(params.UserType); err != nil { + return pollingalert.StopRefusedFilter{}, 0, err + } + filter := pollingalert.StopRefusedFilter{} + filter.ICCID, _ = filterString(params.Filters, "iccid") + if id, ok := filterUint(params.Filters, "carrier_id"); ok && id > 0 { + filter.CarrierID = &id + } + if status, ok := filterInt(params.Filters, "status"); ok && status > 0 { + filter.Status = &status + } + if t, ok := filterTime(params.Filters, "start_time"); ok { + filter.StartTime = &t + } + if t, ok := filterTime(params.Filters, "end_time"); ok { + filter.EndTime = &t + } + threshold := 0 + if s.threshold != nil { + threshold = s.threshold(ctx) + } + return filter, threshold, nil +} + +// Count 统计导出行数。 +func (s *StopRefusedAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + filter, threshold, err := s.filter(ctx, params) + if err != nil { + return 0, err + } + total, err := s.query.Count(ctx, filter, threshold) + return int(total), err +} + +// Headers 返回导出表头。 +func (s *StopRefusedAlertDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { + if err := pausequery.EnsureAllowed(params.UserType); err != nil { + return nil, err + } + return stopRefusedHeaders, nil +} + +// Fetch 按 offset/limit 返回导出行。 +func (s *StopRefusedAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + filter, threshold, err := s.filter(ctx, params) + if err != nil { + return nil, err + } + rows, err := s.query.Page(ctx, filter, threshold, offset, limit) + if err != nil { + return nil, err + } + result := make([][]string, 0, len(rows)) + for _, row := range rows { + device := "" + if row.DeviceID > 0 { + device = strconv.FormatUint(uint64(row.DeviceID), 10) + } + network := "停机" + if row.NetworkStatus == constants.NetworkStatusOnline { + network = "开机" + } + result = append(result, []string{ + strconv.FormatUint(uint64(row.ID), 10), row.ICCID, row.CarrierName, device, network, yesNo(row.HasPackage), row.StopReason, + constants.GatewayErrorClassName(row.ErrorCategory), row.LastError, formatExportTime(row.FirstFailedAt), formatExportTime(row.LastFailedAt), + strconv.Itoa(row.FailCount), yesNo(row.EnablePolling), constants.PollingAlertStatusName(row.Status), + operatorName(row.HandledBy, row.HandledByName), formatExportTime(row.HandledAt), row.HandleRemark, + }) + } + return result, nil +} + +func yesNo(v bool) string { + if v { + return "是" + } + return "否" +} + +func operatorName(id uint, name string) string { + if id == 0 { + return "系统" + } + if name == "" { + return strconv.FormatUint(uint64(id), 10) + } + return name +} + +func formatExportTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05") +} diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index 965d16a9..854f5d1a 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -1,6 +1,9 @@ package exporter import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue" "sort" "gorm.io/gorm" @@ -42,6 +45,8 @@ func NewDefaultRegistry(db *gorm.DB) *Registry { NewOperationsActivationDataSource(db), NewOperationsRenewalDataSource(db), NewStopResumeRuleDataSource(db), + NewPollingPauseDataSource(db), + NewStopRefusedAlertDataSource(db, stopResumeAlertThreshold(db)), ) } @@ -89,3 +94,9 @@ func IsSupportedScene(scene string) bool { return false } } + +// stopResumeAlertThreshold 读取停复机连续失败告警阈值(停机被拒视图口径)。 +func stopResumeAlertThreshold(db *gorm.DB) func(ctx context.Context) int { + settings := carrierqueue.NewDefaultSettingsProvider(db, nil, nil) + return func(ctx context.Context) int { return settings.Current(ctx).SRAlertFailCount } +} diff --git a/internal/handler/admin/polling_pause.go b/internal/handler/admin/polling_pause.go new file mode 100644 index 00000000..e470a770 --- /dev/null +++ b/internal/handler/admin/polling_pause.go @@ -0,0 +1,138 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// PollingPauseHandler 批量开关轮询、不轮询清单、轮询开关文件导入与告警页关闭轮询。 +type PollingPauseHandler struct { + polling *pollingSvc.AssetPollingService + imports *pollingSvc.PollingStatusImportService + validator *validator.Validate +} + +// NewPollingPauseHandler 创建不轮询清单 Handler。 +func NewPollingPauseHandler(polling *pollingSvc.AssetPollingService, imports *pollingSvc.PollingStatusImportService, validator *validator.Validate) *PollingPauseHandler { + return &PollingPauseHandler{polling: polling, imports: imports, validator: validator} +} + +func (h *PollingPauseHandler) validate(prefix string, req any) error { + if err := h.validator.Struct(req); err != nil { + return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err)) + } + return nil +} + +func (h *PollingPauseHandler) batch(c *fiber.Ctx, assetType string) error { + var req dto.BatchUpdatePollingStatusRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("批量开关轮询参数不合法", &req); err != nil { + return err + } + result, err := h.polling.BatchUpdatePollingStatus(c.UserContext(), assetType, &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// BatchCards 勾选批量开关卡轮询。POST /api/admin/iot-cards/batch-update-polling-status +func (h *PollingPauseHandler) BatchCards(c *fiber.Ctx) error { + return h.batch(c, constants.AssetTypeIotCard) +} + +// BatchDevices 勾选批量开关设备轮询。POST /api/admin/devices/batch-update-polling-status +func (h *PollingPauseHandler) BatchDevices(c *fiber.Ctx) error { + return h.batch(c, constants.AssetTypeDevice) +} + +// Impact 开关轮询影响说明。GET /api/admin/polling-status/impact +func (h *PollingPauseHandler) Impact(c *fiber.Ctx) error { + return response.Success(c, h.polling.Impact()) +} + +// ListPauses 查询不轮询清单。GET /api/admin/card-polling-pauses +func (h *PollingPauseHandler) ListPauses(c *fiber.Ctx) error { + var req dto.CardPollingPauseListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("不轮询清单查询参数不合法", &req); err != nil { + return err + } + result, err := h.polling.ListPauses(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateImport 创建轮询开关文件导入任务。POST /api/admin/polling-status-imports +func (h *PollingPauseHandler) CreateImport(c *fiber.Ctx) error { + var req dto.CreatePollingStatusImportRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("轮询开关导入参数不合法", &req); err != nil { + return err + } + result, err := h.imports.CreateImport(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListImports 查询轮询开关导入任务。GET /api/admin/polling-status-imports +func (h *PollingPauseHandler) ListImports(c *fiber.Ctx) error { + var req dto.PollingStatusImportListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("轮询开关导入任务查询参数不合法", &req); err != nil { + return err + } + result, err := h.imports.ListImports(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetImport 查询轮询开关导入任务详情。GET /api/admin/polling-status-imports/:id +func (h *PollingPauseHandler) GetImport(c *fiber.Ctx) error { + id, err := rateLimitPathID(c, "id", "导入任务 ID") + if err != nil { + return err + } + result, err := h.imports.GetImport(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ClosePollingByAlerts 告警页勾选批量关闭轮询。POST /api/admin/polling-failure-alerts/close-polling +func (h *PollingPauseHandler) ClosePollingByAlerts(c *fiber.Ctx) error { + var req dto.ClosePollingByAlertsRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("告警批量关闭轮询参数不合法", &req); err != nil { + return err + } + result, err := h.polling.ClosePollingByAlerts(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/gatewaylimit/terminal_polling.go b/internal/infrastructure/gatewaylimit/terminal_polling.go index 6777b3b2..d12563a1 100644 --- a/internal/infrastructure/gatewaylimit/terminal_polling.go +++ b/internal/infrastructure/gatewaylimit/terminal_polling.go @@ -2,6 +2,7 @@ package gatewaylimit import ( "context" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" "strconv" "strings" "time" @@ -78,6 +79,9 @@ func (d *TerminalPollingDisabler) disable(ctx context.Context, card *model.IotCa if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil { return err } + if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(card, false), constants.PollingToggleSourceRiskAuto); err != nil { + return err + } if d.audit == nil { return nil } diff --git a/internal/infrastructure/pollingpause/recorder.go b/internal/infrastructure/pollingpause/recorder.go new file mode 100644 index 00000000..abe177ba --- /dev/null +++ b/internal/infrastructure/pollingpause/recorder.go @@ -0,0 +1,128 @@ +// Package pollingpause 在调用方事务内写不轮询清单(tb_card_polling_pause)。 +// 所有改 enable_polling 的入口在同一事务内调用 Record;调度只认 enable_polling,不读本表(add-polling-pause-list-and-batch-toggle)。 +package pollingpause + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// Origin 是一次轮询开关变更的来源信息。 +type Origin struct { + Source string // constants.PollingToggleSource* + Reason string + ImportTaskID uint + OperatorID uint // 0 时取 ctx 中的登录账号 +} + +type originKey struct{} + +// WithOrigin 声明本次轮询开关变更的来源;入口未声明时由 Record 的默认来源决定。 +func WithOrigin(ctx context.Context, origin Origin) context.Context { + return context.WithValue(ctx, originKey{}, origin) +} + +// OriginFrom 返回 ctx 中声明的来源;未声明时返回 fallback 来源。 +func OriginFrom(ctx context.Context, fallback string) Origin { + origin, ok := ctx.Value(originKey{}).(Origin) + if !ok || origin.Source == "" { + origin = Origin{Source: fallback, Reason: origin.Reason, ImportTaskID: origin.ImportTaskID, OperatorID: origin.OperatorID} + } + if origin.OperatorID == 0 { + origin.OperatorID = middleware.GetUserIDFromContext(ctx) + } + return origin +} + +// Change 是一个资产的轮询开关变更。 +type Change struct { + AssetType string // constants.AssetTypeIotCard / AssetTypeDevice + AssetID uint + Identifier string // 卡 ICCID;设备 IMEI(为空时虚拟号) + CarrierID uint + Before bool + After bool +} + +// Record 在调用方事务内写不轮询清单:开启 → 关闭新增"已关闭"记录(同一资产已有已关闭记录时不重复), +// 关闭 → 开启把已关闭记录标为"已恢复"。开关未变化时不写。fallback 为入口默认来源。 +func Record(ctx context.Context, tx *gorm.DB, change Change, fallback string) error { + if tx == nil || change.AssetID == 0 || change.Before == change.After { + return nil + } + origin := OriginFrom(ctx, fallback) + now := time.Now() + if !change.After { + row := &model.CardPollingPause{ + AssetType: change.AssetType, AssetID: change.AssetID, Identifier: truncate(change.Identifier, 100), CarrierID: change.CarrierID, + Status: constants.PollingPauseStatusClosed, CloseSource: origin.Source, CloseReason: truncate(origin.Reason, 500), + ClosedBy: origin.OperatorID, ClosedAt: now, ImportTaskID: origin.ImportTaskID, CreatedAt: now, UpdatedAt: now, + } + // 部分唯一索引 (asset_type, asset_id) WHERE status = 1:已有已关闭记录时保留原记录。 + 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}}}}, + DoNothing: true, + }).Create(row).Error + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入不轮询清单失败") + } + return nil + } + err := tx.WithContext(ctx).Model(&model.CardPollingPause{}). + Where("asset_type = ? AND asset_id = ? AND status = ?", change.AssetType, change.AssetID, constants.PollingPauseStatusClosed). + Updates(map[string]any{ + "status": constants.PollingPauseStatusRestored, "restore_source": origin.Source, "restore_reason": truncate(origin.Reason, 500), + "restored_by": origin.OperatorID, "restored_at": now, "updated_at": now, + }).Error + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新不轮询清单失败") + } + return nil +} + +// CardChange 构造卡的轮询开关变更。 +func CardChange(card *model.IotCard, after bool) Change { + return Change{AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, CarrierID: card.CarrierID, + Before: card.EnablePolling, After: after} +} + +// DeviceChange 构造设备的轮询开关变更。 +func DeviceChange(device *model.Device, after bool) Change { + identifier := device.IMEI + if identifier == "" { + identifier = device.VirtualNo + } + return Change{AssetType: constants.AssetTypeDevice, AssetID: device.ID, Identifier: identifier, Before: device.EnablePolling, After: after} +} + +// IsRiskExtend 判断网关扩展状态是否为风险停机或已销户:这类卡必须关闭轮询,不能开启。 +func IsRiskExtend(extend string) bool { + extend = strings.TrimSpace(extend) + return extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled +} + +// RiskEnableDeniedReason 返回风险停机或已销户卡不能开启轮询的原因。 +func RiskEnableDeniedReason(extend string) string { + if strings.TrimSpace(extend) == constants.GatewayCardExtendCancelled { + return "已销户,不能开启轮询" + } + return "风险停机,不能开启轮询" +} + +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +} diff --git a/internal/model/card_polling_pause.go b/internal/model/card_polling_pause.go new file mode 100644 index 00000000..a30e2253 --- /dev/null +++ b/internal/model/card_polling_pause.go @@ -0,0 +1,96 @@ +package model + +import ( + "database/sql/driver" + "time" + + "github.com/bytedance/sonic" +) + +// CardPollingPause 不轮询清单记录:同一资产只有一条已关闭记录,恢复后保留历史。调度不读本表。 +type CardPollingPause struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;not null" json:"asset_id"` + Identifier string `gorm:"column:identifier;type:varchar(100);not null;default:''" json:"identifier"` + CarrierID uint `gorm:"column:carrier_id;not null;default:0" json:"carrier_id"` + Status int `gorm:"column:status;type:smallint;not null;default:1" json:"status"` + CloseSource string `gorm:"column:close_source;type:varchar(30);not null" json:"close_source"` + CloseReason string `gorm:"column:close_reason;type:varchar(500);not null;default:''" json:"close_reason"` + ClosedBy uint `gorm:"column:closed_by;not null;default:0" json:"closed_by"` + ClosedAt time.Time `gorm:"column:closed_at;not null" json:"closed_at"` + ImportTaskID uint `gorm:"column:import_task_id;not null;default:0" json:"import_task_id"` + RestoreSource string `gorm:"column:restore_source;type:varchar(30);not null;default:''" json:"restore_source"` + RestoreReason string `gorm:"column:restore_reason;type:varchar(500);not null;default:''" json:"restore_reason"` + RestoredBy uint `gorm:"column:restored_by;not null;default:0" json:"restored_by"` + RestoredAt *time.Time `gorm:"column:restored_at" json:"restored_at"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName 返回不轮询清单表名。 +func (CardPollingPause) TableName() string { + return "tb_card_polling_pause" +} + +// PollingStatusImportTask 轮询开关文件导入任务。 +type PollingStatusImportTask struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + TaskNo string `gorm:"column:task_no;type:varchar(50);not null" json:"task_no"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"` + EnablePolling bool `gorm:"column:enable_polling;not null" json:"enable_polling"` + Reason string `gorm:"column:reason;type:varchar(500);not null;default:''" json:"reason"` + FileName string `gorm:"column:file_name;type:varchar(255);not null;default:''" json:"file_name"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);not null" json:"storage_key"` + Status int `gorm:"column:status;not null;default:1" json:"status"` + TotalCount int `gorm:"column:total_count;not null;default:0" json:"total_count"` + SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"` + SkipCount int `gorm:"column:skip_count;not null;default:0" json:"skip_count"` + FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"` + ResultItems PollingStatusImportResults `gorm:"column:result_items;type:jsonb;not null;default:'[]'" json:"result_items"` + ErrorMessage string `gorm:"column:error_message;type:text;not null;default:''" json:"error_message"` + Creator uint `gorm:"column:creator;not null;default:0" json:"creator"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:''" json:"creator_name"` + StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName 返回轮询开关导入任务表名。 +func (PollingStatusImportTask) TableName() string { + return "tb_polling_status_import_task" +} + +// PollingStatusImportResultItem 导入单行结果;行号自数据首行起计。 +type PollingStatusImportResultItem struct { + Line int `json:"line"` + Identifier string `json:"identifier"` + AssetID uint `json:"asset_id,omitempty"` + Status int `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// PollingStatusImportResults 导入逐行结果集合。 +type PollingStatusImportResults []PollingStatusImportResultItem + +// Value 将逐行结果序列化为 JSONB。 +func (items PollingStatusImportResults) Value() (driver.Value, error) { + if items == nil { + return "[]", nil + } + return sonic.Marshal(items) +} + +// Scan 从 JSONB 读取逐行结果。 +func (items *PollingStatusImportResults) Scan(value any) error { + if value == nil { + *items = PollingStatusImportResults{} + return nil + } + data, ok := value.([]byte) + if !ok { + return nil + } + return sonic.Unmarshal(data, items) +} diff --git a/internal/model/dto/carrier_polling_dto.go b/internal/model/dto/carrier_polling_dto.go index e240d2d7..e499a9c3 100644 --- a/internal/model/dto/carrier_polling_dto.go +++ b/internal/model/dto/carrier_polling_dto.go @@ -67,7 +67,8 @@ type PollingFailureAlertListRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty,min=1" description:"运营商账户ID"` - CheckType string `json:"check_type" query:"check_type" validate:"omitempty,oneof=card_status carddata realname package all" description:"检查类型 (card_status:卡状态, carddata:流量, realname:实名, package:套餐, all:账户级)"` + CheckType string `json:"check_type" query:"check_type" validate:"omitempty,oneof=card_status carddata realname package stop_resume all" description:"检查类型 (card_status:卡状态, carddata:流量, realname:实名, package:套餐, stop_resume:停复机, all:账户级)"` + View string `json:"view" query:"view" validate:"omitempty,oneof=stop_refused" description:"视图 (stop_refused:停机被拒:停复机方向为停机,且错误类型为终态或业务错误、或停机失败次数达到阈值)"` ErrorCategory string `json:"error_category" query:"error_category" validate:"omitempty,max=50" description:"错误类别"` Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 3 4" description:"状态 (1:未处理, 2:已恢复, 3:已关闭轮询, 4:已处理)"` AlertLevel string `json:"alert_level" query:"alert_level" validate:"omitempty,oneof=card carrier" description:"告警级别 (card:卡级, carrier:账户级)"` @@ -98,6 +99,14 @@ type PollingFailureAlertItem struct { HandleRemark string `json:"handle_remark" description:"处理备注"` RecoveredAt *time.Time `json:"recovered_at" description:"自动恢复时间"` CreatedAt time.Time `json:"created_at" description:"创建时间"` + + // 以下字段仅在停机被拒视图(view=stop_refused)返回。 + DeviceID *uint `json:"device_id,omitempty" description:"卡绑定的设备ID(停机被拒视图)"` + NetworkStatus *int `json:"network_status,omitempty" description:"卡当前网络状态 (0:停机, 1:开机)(停机被拒视图)"` + StopReason string `json:"stop_reason,omitempty" description:"卡当前停机原因(停机被拒视图)"` + EnablePolling *bool `json:"enable_polling,omitempty" description:"卡当前是否轮询(停机被拒视图)"` + HasPackage *bool `json:"has_package,omitempty" description:"资产是否有生效中主套餐(停机被拒视图)"` + HandledByName string `json:"handled_by_name,omitempty" description:"处理人(停机被拒视图)"` } // PollingFailureAlertPageResult 卡轮询失败告警分页结果。 diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 476b174b..a11d69f5 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -19,6 +19,7 @@ type ListDeviceRequest struct { Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` + EnablePolling *bool `json:"enable_polling" query:"enable_polling" description:"是否轮询 (true:轮询中, false:已关闭轮询, 不传:不过滤)"` HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐)"` Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"` diff --git a/internal/model/dto/export_task_dto.go b/internal/model/dto/export_task_dto.go index 73e785ee..04a73e75 100644 --- a/internal/model/dto/export_task_dto.go +++ b/internal/model/dto/export_task_dto.go @@ -4,7 +4,7 @@ import "time" // CreateExportTaskRequest 创建导出任务请求。 type CreateExportTaskRequest struct { - Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal stop_resume_rule_dry_run" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细)"` + Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal stop_resume_rule_dry_run polling_pause polling_stop_refused_alert" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细, polling_pause:不轮询清单, polling_stop_refused_alert:停机被拒告警)"` Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"` Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选);时间筛选固定使用 filters.start_time 与 filters.end_time,取值必须为带显式时区的 RFC3339 秒级时间"` } @@ -22,7 +22,7 @@ type CreateExportTaskResponse struct { type ListExportTaskRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal stop_resume_rule_dry_run" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细)"` + Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal stop_resume_rule_dry_run polling_pause polling_stop_refused_alert" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细, polling_pause:不轮询清单, polling_stop_refused_alert:停机被拒告警)"` Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"` EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00)"` @@ -33,7 +33,7 @@ type ExportTaskItem struct { ID uint `json:"id" description:"任务ID"` TaskID uint `json:"task_id" description:"任务ID"` TaskNo string `json:"task_no" description:"任务编号"` - Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细)"` + Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表, stop_resume_rule_dry_run:停复机规则演练明细, polling_pause:不轮询清单, polling_stop_refused_alert:停机被拒告警)"` Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StatusName string `json:"status_name" description:"任务状态名称(中文)"` diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 82fbda60..5accde3d 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -23,6 +23,7 @@ type ListStandaloneIotCardRequest struct { ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"` CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"` Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立)"` + EnablePolling *bool `json:"enable_polling" query:"enable_polling" description:"是否轮询 (true:轮询中, false:已关闭轮询, 不传:不过滤)"` NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"` RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"` diff --git a/internal/model/dto/polling_pause_dto.go b/internal/model/dto/polling_pause_dto.go new file mode 100644 index 00000000..9f50463d --- /dev/null +++ b/internal/model/dto/polling_pause_dto.go @@ -0,0 +1,154 @@ +package dto + +import "time" + +// BatchUpdatePollingStatusRequest 勾选批量开关轮询请求(卡或设备)。 +type BatchUpdatePollingStatusRequest struct { + AssetIDs []uint `json:"asset_ids" validate:"required,min=1,max=500,dive,gt=0" minItems:"1" maxItems:"500" description:"资产ID列表(卡ID或设备ID),单次最多500个,不能重复"` + EnablePolling *bool `json:"enable_polling" validate:"required" description:"目标状态 (true:开启轮询, false:关闭轮询)"` + Reason string `json:"reason" validate:"required,max=500" maxLength:"500" description:"原因,写入不轮询清单"` +} + +// PollingToggleItem 批量开关轮询逐项结果。 +type PollingToggleItem struct { + AssetID uint `json:"asset_id" description:"资产ID"` + Identifier string `json:"identifier" description:"资产标识(卡为 ICCID,设备为 IMEI 或虚拟号)"` + Status int `json:"status" description:"结果 (1:成功, 2:跳过, 3:失败, 4:无权限或资产不存在)"` + StatusName string `json:"status_name" description:"结果名称"` + Reason string `json:"reason" description:"跳过或失败原因"` +} + +// BatchUpdatePollingStatusResponse 批量开关轮询结果。 +type BatchUpdatePollingStatusResponse struct { + SuccessCount int `json:"success_count" description:"成功数"` + SkipCount int `json:"skip_count" description:"跳过数(已是目标状态、风险停机或已销户不能开启)"` + FailCount int `json:"fail_count" description:"失败数(含无权限或资产不存在)"` + Items []PollingToggleItem `json:"items" description:"逐项结果"` + Impact []string `json:"impact" description:"本次目标状态的影响说明"` +} + +// PollingToggleImpactResponse 开关轮询影响说明(供前端确认框展示)。 +type PollingToggleImpactResponse struct { + Disable []string `json:"disable" description:"关闭轮询的影响"` + Enable []string `json:"enable" description:"开启轮询的影响"` +} + +// CardPollingPauseListRequest 不轮询清单查询请求。 +type CardPollingPauseListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" description:"资产类型 (iot_card:卡, device:设备)"` + CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty,min=1" description:"卡所属运营商账户ID"` + Source string `json:"source" query:"source" validate:"omitempty,oneof=single batch import risk_auto cascade order expiry_change alert history" description:"关闭来源 (single:单资产接口, batch:勾选批量, import:文件导入, risk_auto:风险停机或已销户自动关闭, cascade:设备级联, order:支付或下单生效, expiry_change:改有效期, alert:停机被拒告警处理, history:历史数据)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:已关闭, 2:已恢复)"` + Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" description:"ICCID 或 IMEI(精确)"` + OperatorID *uint `json:"operator_id" query:"operator_id" validate:"omitempty,min=1" description:"关闭操作人账号ID"` + StartTime string `json:"start_time" query:"start_time" description:"关闭时间起始(带时区的 RFC3339 秒级时间)"` + EndTime string `json:"end_time" query:"end_time" description:"关闭时间结束(带时区的 RFC3339 秒级时间)"` +} + +// CardPollingPauseItem 不轮询清单记录。 +type CardPollingPauseItem struct { + ID uint `json:"id" description:"记录ID"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + Identifier string `json:"identifier" description:"ICCID 或 IMEI"` + CarrierID uint `json:"carrier_id" description:"卡所属运营商账户ID(设备为 0)"` + CarrierName string `json:"carrier_name" description:"运营商账户名称"` + EnablePolling bool `json:"enable_polling" description:"资产当前轮询开关"` + GatewayExtend string `json:"gateway_extend" description:"卡当前网关扩展状态(设备为空)"` + Status int `json:"status" description:"状态 (1:已关闭, 2:已恢复)"` + StatusName string `json:"status_name" description:"状态名称"` + CloseSource string `json:"close_source" description:"关闭来源"` + CloseSourceName string `json:"close_source_name" description:"关闭来源名称"` + CloseReason string `json:"close_reason" description:"关闭原因"` + ClosedBy uint `json:"closed_by" description:"关闭操作人账号ID(系统为 0)"` + ClosedByName string `json:"closed_by_name" description:"关闭操作人"` + ClosedAt time.Time `json:"closed_at" description:"关闭时间"` + ImportTaskID uint `json:"import_task_id" description:"文件导入任务ID(非导入为 0)"` + RestoreSource string `json:"restore_source" description:"恢复来源"` + RestoreSourceName string `json:"restore_source_name" description:"恢复来源名称"` + RestoreReason string `json:"restore_reason" description:"恢复原因"` + RestoredBy uint `json:"restored_by" description:"恢复操作人账号ID(系统为 0)"` + RestoredByName string `json:"restored_by_name" description:"恢复操作人"` + RestoredAt *time.Time `json:"restored_at" description:"恢复时间"` +} + +// CardPollingPausePageResult 不轮询清单分页结果。 +type CardPollingPausePageResult struct { + Items []CardPollingPauseItem `json:"items" description:"清单记录"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` +} + +// CreatePollingStatusImportRequest 创建轮询开关文件导入任务请求。 +type CreatePollingStatusImportRequest struct { + FileKey string `json:"file_key" validate:"required,max=500" maxLength:"500" description:"已上传的 CSV 文件 Key(必须以 polling-status-imports/ 开头);首列为 ICCID(卡)或 IMEI/虚拟号(设备),首行为表头"` + AssetType string `json:"asset_type" validate:"required,oneof=iot_card device" description:"资产类型 (iot_card:按 ICCID, device:按 IMEI 或虚拟号)"` + EnablePolling *bool `json:"enable_polling" validate:"required" description:"目标状态 (true:开启轮询, false:关闭轮询)"` + Reason string `json:"reason" validate:"required,max=500" maxLength:"500" description:"原因,写入不轮询清单"` +} + +// PollingStatusImportListRequest 轮询开关导入任务列表请求。 +type PollingStatusImportListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2 3 4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` +} + +// PollingStatusImportIDPath 导入任务路径参数。 +type PollingStatusImportIDPath struct { + ID uint `json:"-" path:"id" required:"true" description:"导入任务ID"` +} + +// PollingStatusImportTaskResponse 轮询开关导入任务。 +type PollingStatusImportTaskResponse struct { + ID uint `json:"id" description:"任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:卡, device:设备)"` + EnablePolling bool `json:"enable_polling" description:"目标状态"` + Reason string `json:"reason" description:"原因"` + FileName string `json:"file_name" description:"文件名"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusName string `json:"status_name" description:"任务状态名称"` + TotalCount int `json:"total_count" description:"数据行数"` + SuccessCount int `json:"success_count" description:"成功行数"` + SkipCount int `json:"skip_count" description:"跳过行数"` + FailCount int `json:"fail_count" description:"失败行数"` + ErrorMessage string `json:"error_message" description:"任务级失败原因"` + CreatorName string `json:"creator_name" description:"创建人"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + StartedAt *time.Time `json:"started_at" description:"开始处理时间"` + CompletedAt *time.Time `json:"completed_at" description:"完成时间"` +} + +// PollingStatusImportItemResponse 导入逐行结果。 +type PollingStatusImportItemResponse struct { + Line int `json:"line" description:"行号(自数据首行起计)"` + Identifier string `json:"identifier" description:"ICCID 或 IMEI"` + AssetID uint `json:"asset_id" description:"资产ID(未找到为 0)"` + Status int `json:"status" description:"结果 (1:成功, 2:跳过, 3:失败, 4:无权限或资产不存在)"` + StatusName string `json:"status_name" description:"结果名称"` + Reason string `json:"reason" description:"跳过或失败原因"` +} + +// PollingStatusImportTaskDetailResponse 导入任务详情(含逐行结果)。 +type PollingStatusImportTaskDetailResponse struct { + PollingStatusImportTaskResponse + Items []PollingStatusImportItemResponse `json:"items" description:"逐行结果"` +} + +// PollingStatusImportPageResult 导入任务分页结果。 +type PollingStatusImportPageResult struct { + Items []PollingStatusImportTaskResponse `json:"items" description:"任务列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"size" description:"每页数量"` +} + +// ClosePollingByAlertsRequest 告警页勾选批量关闭轮询请求。 +type ClosePollingByAlertsRequest struct { + AlertIDs []uint `json:"alert_ids" validate:"required,min=1,max=500,dive,gt=0" minItems:"1" maxItems:"500" description:"告警ID列表,单次最多500个"` + Reason string `json:"reason" validate:"required,max=500" maxLength:"500" description:"原因,写入不轮询清单"` +} diff --git a/internal/model/dto/storage_dto.go b/internal/model/dto/storage_dto.go index 88269d82..17e6b7cb 100644 --- a/internal/model/dto/storage_dto.go +++ b/internal/model/dto/storage_dto.go @@ -3,7 +3,7 @@ package dto type GetUploadURLRequest struct { FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名(如:cards.csv)"` ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型(如:text/csv),留空则自动推断"` - Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import phone_unbind_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import,phone_unbind_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV, phone_unbind_import:手机号资产解绑导入CSV)"` + Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import phone_unbind_import polling_status_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import,phone_unbind_import,polling_status_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV, phone_unbind_import:手机号资产解绑导入CSV, polling_status_import:轮询开关导入CSV)"` } type GetUploadURLResponse struct { diff --git a/internal/query/pollingalert/stop_refused.go b/internal/query/pollingalert/stop_refused.go new file mode 100644 index 00000000..80ec4963 --- /dev/null +++ b/internal/query/pollingalert/stop_refused.go @@ -0,0 +1,122 @@ +// Package pollingalert 是失败告警"停机被拒"视图的只读查询,供后台列表与导出共用(add-polling-pause-list-and-batch-toggle)。 +package pollingalert + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// StopRefusedFilter 是停机被拒视图的附加筛选。 +type StopRefusedFilter struct { + CarrierID *uint + Status *int + ICCID string + StartTime *time.Time + EndTime *time.Time +} + +// StopRefusedRow 是停机被拒告警与卡当前状态的投影。 +type StopRefusedRow struct { + ID uint + CardID uint + ICCID string + CarrierID uint + CarrierName string + DeviceID uint + NetworkStatus int + StopReason string + EnablePolling bool + HasPackage bool + ErrorCategory string + FailCount int + FirstFailedAt *time.Time + LastFailedAt *time.Time + LastError string + Status int + HandledBy uint + HandledByName string + HandledAt *time.Time + HandleRemark string +} + +// ApplyStopRefused 在告警查询上叠加"停机被拒"视图条件:停复机方向为停机,且错误类型为终态或业务错误、或停机失败次数达到阈值。 +// 停复机方向告警的最近错误以 "stop: " 开头(见停复机指令执行器)。 +func ApplyStopRefused(query *gorm.DB, table string, threshold int) *gorm.DB { + if threshold <= 0 { + threshold = 5 + } + prefix := "" + if table != "" { + prefix = table + "." + } + return query.Where(prefix+"alert_level = ? AND "+prefix+"check_type = ? AND "+prefix+"last_error LIKE ?", + constants.PollingAlertLevelCard, constants.CheckTypeStopResume, constants.StopResumeActionStop+":%"). + Where("("+prefix+"error_category IN ? OR "+prefix+"fail_count >= ?)", + []string{constants.GatewayClassTerminal, constants.GatewayClassBusiness}, threshold) +} + +// Query 停机被拒视图查询。 +type Query struct { + db *gorm.DB +} + +// NewQuery 创建停机被拒视图查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db} +} + +func (q *Query) base(ctx context.Context, filter StopRefusedFilter, threshold int) *gorm.DB { + query := ApplyStopRefused(q.db.WithContext(ctx).Table("tb_polling_failure_alert AS a"), "a", threshold) + if filter.CarrierID != nil { + query = query.Where("a.carrier_id = ?", *filter.CarrierID) + } + if filter.Status != nil { + query = query.Where("a.status = ?", *filter.Status) + } + if filter.ICCID != "" { + query = query.Where("a.iccid = ?", filter.ICCID) + } + if filter.StartTime != nil { + query = query.Where("a.last_failed_at >= ?", *filter.StartTime) + } + if filter.EndTime != nil { + query = query.Where("a.last_failed_at <= ?", *filter.EndTime) + } + return query +} + +// Count 统计停机被拒告警数。 +func (q *Query) Count(ctx context.Context, filter StopRefusedFilter, threshold int) (int64, error) { + var total int64 + if err := q.base(ctx, filter, threshold).Count(&total).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计停机被拒告警失败") + } + return total, nil +} + +// Page 返回一页停机被拒告警,附带所属设备、网络状态、停机原因、轮询开关、是否有生效套餐与处理人。 +func (q *Query) Page(ctx context.Context, filter StopRefusedFilter, threshold, offset, limit int) ([]StopRefusedRow, error) { + var rows []StopRefusedRow + err := q.base(ctx, filter, threshold). + Select(`a.id, a.card_id, a.iccid, a.carrier_id, COALESCE(ca.carrier_name, '') AS carrier_name, + COALESCE(b.device_id, 0) AS device_id, COALESCE(c.network_status, 0) AS network_status, + COALESCE(c.stop_reason, '') AS stop_reason, COALESCE(c.enable_polling, FALSE) AS enable_polling, + EXISTS (SELECT 1 FROM tb_package_usage u WHERE u.status = ? AND u.master_usage_id IS NULL AND u.deleted_at IS NULL + AND ((b.device_id IS NOT NULL AND u.device_id = b.device_id) OR (b.device_id IS NULL AND u.iot_card_id = a.card_id))) AS has_package, + a.error_category, a.fail_count, a.first_failed_at, a.last_failed_at, a.last_error, a.status, + a.handled_by, COALESCE(ac.username, '') AS handled_by_name, a.handled_at, a.handle_remark`, constants.PackageUsageStatusActive). + Joins("LEFT JOIN tb_iot_card c ON c.id = a.card_id"). + Joins("LEFT JOIN tb_device_sim_binding b ON b.iot_card_id = a.card_id AND b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound). + Joins("LEFT JOIN tb_carrier ca ON ca.id = a.carrier_id"). + Joins("LEFT JOIN tb_account ac ON ac.id = a.handled_by"). + Order("a.last_failed_at DESC NULLS LAST, a.id DESC").Offset(offset).Limit(limit).Scan(&rows).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询停机被拒告警失败") + } + return rows, nil +} diff --git a/internal/query/pollingpause/query.go b/internal/query/pollingpause/query.go new file mode 100644 index 00000000..9f441004 --- /dev/null +++ b/internal/query/pollingpause/query.go @@ -0,0 +1,111 @@ +// Package pollingpause 是不轮询清单的只读查询,供后台列表与导出共用(add-polling-pause-list-and-batch-toggle)。 +package pollingpause + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Filter 是不轮询清单筛选条件。 +type Filter struct { + AssetType string + CarrierID *uint + Source string + Status *int + Identifier string + OperatorID *uint + StartTime *time.Time + EndTime *time.Time +} + +// Row 是清单记录与资产当前状态、名称的投影。 +type Row struct { + model.CardPollingPause + CarrierName string + EnablePolling bool + GatewayExtend string + ClosedByName string + RestoredByName string +} + +// EnsureAllowed 不轮询清单仅超级管理员与平台账号可查看与导出。 +func EnsureAllowed(userType int) error { + if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform { + return nil + } + return errors.New(errors.CodeForbidden, "仅平台账号可查看不轮询清单") +} + +// Query 不轮询清单只读查询。 +type Query struct { + db *gorm.DB +} + +// NewQuery 创建不轮询清单查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db} +} + +func (q *Query) base(ctx context.Context, filter Filter) *gorm.DB { + query := q.db.WithContext(ctx).Table("tb_card_polling_pause AS p") + if filter.AssetType != "" { + query = query.Where("p.asset_type = ?", filter.AssetType) + } + if filter.CarrierID != nil { + query = query.Where("p.carrier_id = ?", *filter.CarrierID) + } + if filter.Source != "" { + query = query.Where("p.close_source = ?", filter.Source) + } + if filter.Status != nil { + query = query.Where("p.status = ?", *filter.Status) + } + if filter.Identifier != "" { + query = query.Where("p.identifier = ?", filter.Identifier) + } + if filter.OperatorID != nil { + query = query.Where("p.closed_by = ?", *filter.OperatorID) + } + if filter.StartTime != nil { + query = query.Where("p.closed_at >= ?", *filter.StartTime) + } + if filter.EndTime != nil { + query = query.Where("p.closed_at <= ?", *filter.EndTime) + } + return query +} + +// Count 统计符合条件的记录数。 +func (q *Query) Count(ctx context.Context, filter Filter) (int64, error) { + var total int64 + if err := q.base(ctx, filter).Count(&total).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计不轮询清单失败") + } + return total, nil +} + +// Page 按关闭时间倒序返回一页记录,附带资产当前轮询开关、网关扩展状态、运营商账户与操作人名称。 +func (q *Query) Page(ctx context.Context, filter Filter, offset, limit int) ([]Row, error) { + var rows []Row + err := q.base(ctx, filter). + Select(`p.*, COALESCE(ca.carrier_name, '') AS carrier_name, + COALESCE(c.enable_polling, d.enable_polling, FALSE) AS enable_polling, + COALESCE(c.gateway_extend, '') AS gateway_extend, + COALESCE(a1.username, '') AS closed_by_name, COALESCE(a2.username, '') AS restored_by_name`). + Joins("LEFT JOIN tb_iot_card c ON p.asset_type = 'iot_card' AND c.id = p.asset_id"). + Joins("LEFT JOIN tb_device d ON p.asset_type = 'device' AND d.id = p.asset_id"). + Joins("LEFT JOIN tb_carrier ca ON ca.id = p.carrier_id"). + Joins("LEFT JOIN tb_account a1 ON a1.id = p.closed_by"). + Joins("LEFT JOIN tb_account a2 ON a2.id = p.restored_by"). + Order("p.closed_at DESC, p.id DESC").Offset(offset).Limit(limit).Scan(&rows).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询不轮询清单失败") + } + return rows, nil +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index af702ef1..20a895d1 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -136,6 +136,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PollingConfig != nil { registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath) } + if handlers.PollingPause != nil { + registerPollingPauseRoutes(authGroup, handlers.PollingPause, doc, basePath) + } if handlers.CarrierScheduler != nil { registerCarrierSchedulerRoutes(authGroup, handlers.CarrierScheduler, doc, basePath) } diff --git a/internal/routes/polling_pause.go b/internal/routes/polling_pause.go new file mode 100644 index 00000000..6437fa48 --- /dev/null +++ b/internal/routes/polling_pause.go @@ -0,0 +1,61 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// pollingToggleImpactDoc 开关轮询影响说明,随接口文档提供给前端确认框。 +const pollingToggleImpactDoc = ` + +**关闭轮询的影响**:关闭后不再查询上游卡状态、流量与实名;流量用完、实名不满足时不自动停机,套餐激活、流量重置后不自动复机; +套餐到期、退款后无可用套餐时仍会自动停机;订单套餐生效、后台修改有效期后资产有生效套餐时会自动恢复轮询并按规则复机;人工停复机照常;设备关闭时设备下所有卡同样适用。 + +**开启轮询的影响**:按轮询配置查询上游并按规则自动停复机,人工停机不享有豁免;网关扩展状态为风险停机或已销户的卡不能开启轮询(逐项返回跳过原因,不提供强制参数)。` + +// registerPollingPauseRoutes 注册批量开关轮询、不轮询清单、轮询开关导入与告警页关闭轮询接口。 +func registerPollingPauseRoutes(router fiber.Router, handler *admin.PollingPauseHandler, doc *openapi.Generator, basePath string) { + tags := []string{"不轮询清单"} + Register(router, doc, basePath, "POST", "/iot-cards/batch-update-polling-status", handler.BatchCards, RouteSpec{ + Summary: "勾选批量开关卡轮询", + Description: "单次最多500个;按数据权限逐项处理并返回结果,每项同事务写轮询开关、不轮询清单与审计。" + pollingToggleImpactDoc, + Tags: tags, Input: new(dto.BatchUpdatePollingStatusRequest), Output: new(dto.BatchUpdatePollingStatusResponse), Auth: true, + }) + Register(router, doc, basePath, "POST", "/devices/batch-update-polling-status", handler.BatchDevices, RouteSpec{ + Summary: "勾选批量开关设备轮询", + Description: "单次最多500个;设备沿用级联到设备下各卡(开启时跳过风险停机、已销户的卡)。" + pollingToggleImpactDoc, + Tags: tags, Input: new(dto.BatchUpdatePollingStatusRequest), Output: new(dto.BatchUpdatePollingStatusResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/polling-status/impact", handler.Impact, RouteSpec{ + Summary: "开关轮询影响说明", Description: "供前端关闭与开启轮询的确认框展示。", Tags: tags, + Input: nil, Output: new(dto.PollingToggleImpactResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/card-polling-pauses", handler.ListPauses, RouteSpec{ + Summary: "查询不轮询清单(仅平台账号)", + Description: "每个资产只有一条已关闭记录,恢复后保留历史;导出使用统一导出任务,场景 polling_pause,筛选参数同本接口。", + Tags: tags, Input: new(dto.CardPollingPauseListRequest), Output: new(dto.CardPollingPausePageResult), Auth: true, + }) + imports := router.Group("/polling-status-imports") + importsPath := basePath + "/polling-status-imports" + Register(imports, doc, importsPath, "POST", "", handler.CreateImport, RouteSpec{ + Summary: "创建轮询开关文件导入任务(仅平台账号)", + Description: "先调用 POST /api/admin/storage/upload-url(purpose 传 polling_status_import)上传 CSV;首行为表头,首列为 ICCID(卡)或 IMEI/虚拟号(设备)。" + + "异步逐行处理,规则同勾选批量,部分成功不回滚。" + pollingToggleImpactDoc, + Tags: tags, Input: new(dto.CreatePollingStatusImportRequest), Output: new(dto.PollingStatusImportTaskResponse), Auth: true, + }) + Register(imports, doc, importsPath, "GET", "", handler.ListImports, RouteSpec{ + Summary: "查询轮询开关导入任务", Tags: tags, Input: new(dto.PollingStatusImportListRequest), Output: new(dto.PollingStatusImportPageResult), Auth: true, + }) + Register(imports, doc, importsPath, "GET", "/:id", handler.GetImport, RouteSpec{ + Summary: "查询轮询开关导入任务详情(含逐行结果)", Tags: tags, Input: new(dto.PollingStatusImportIDPath), + Output: new(dto.PollingStatusImportTaskDetailResponse), Auth: true, + }) + Register(router, doc, basePath, "POST", "/polling-failure-alerts/close-polling", handler.ClosePollingByAlerts, RouteSpec{ + Summary: "告警页勾选批量关闭轮询(仅平台账号)", + Description: "单次最多500条;关闭告警对应卡的轮询,清单来源记\"停机被拒告警处理\",告警状态改为已关闭轮询。停机被拒视图:GET /polling-failure-alerts?view=stop_refused,导出场景 polling_stop_refused_alert。", + Tags: tags, Input: new(dto.ClosePollingByAlertsRequest), Output: new(dto.BatchUpdatePollingStatusResponse), Auth: true, + }) +} diff --git a/internal/routes/storage.go b/internal/routes/storage.go index 4e9f2070..680c6bd0 100644 --- a/internal/routes/storage.go +++ b/internal/routes/storage.go @@ -138,6 +138,7 @@ await api.post('/iot-cards/import', { | device_batch_allocation | 设备批量分配、设置套餐系列或回收 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv | | shop_import | 店铺负责人导入 (CSV) | shop-imports/YYYY/MM/DD/uuid.csv | | phone_unbind_import | 手机号资产解绑导入 (CSV) | phone-unbind-imports/YYYY/MM/DD/uuid.csv | +| polling_status_import | 轮询开关导入 (CSV) | polling-status-imports/YYYY/MM/DD/uuid.csv | | export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | | attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 4f8ff88d..97868db6 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -6,6 +6,7 @@ package asset import ( "context" stderrors "errors" + stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume" "strconv" "time" @@ -55,6 +56,9 @@ type Service struct { associationStore *postgres.PhoneAssetAssociationStore auditWriter *infraAudit.Writer packageExpiryQuery PackageExpiryResolver + pollingRestorer PollingRestorer + resumeEvaluator AssetResumeEvaluator + logger *zap.Logger } // SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。 @@ -991,6 +995,7 @@ func (s *Service) UpdatePackageExpiresAt(ctx context.Context, assetType string, s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageExpiresAtUpdated, "修改资产套餐过期时间失败", before, assetType, assetID, assetIdentifier, beforeData, map[string]any{"package_usage_id": packageUsageID, "expires_at": expiresAt}, err) return nil, err } + s.restorePollingAfterExpiryChange(ctx, assetType, assetID) return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil } @@ -1475,3 +1480,47 @@ func packageStatusName(status int) string { return "未知" } } + +// PollingRestorer 是套餐生效类入口自动恢复轮询的能力(改有效期后资产有生效套餐时恢复)。 +type PollingRestorer interface { + RestoreAssetPolling(ctx context.Context, assetType string, assetID uint, source string) bool +} + +// AssetResumeEvaluator 是恢复轮询后按规则评估复机的能力。 +type AssetResumeEvaluator interface { + ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error +} + +// SetPollingRestorer 注入改有效期后的自动恢复轮询与复机评估。 +func (s *Service) SetPollingRestorer(restorer PollingRestorer, evaluator AssetResumeEvaluator, logger *zap.Logger) { + if logger == nil { + logger = zap.NewNop() + } + s.pollingRestorer, s.resumeEvaluator, s.logger = restorer, evaluator, logger +} + +// restorePollingAfterExpiryChange 改有效期保存后,资产有生效中主套餐时恢复关闭轮询的卡或设备(来源"改有效期"),再按规则评估复机。 +func (s *Service) restorePollingAfterExpiryChange(ctx context.Context, assetType string, assetID uint) { + if s.pollingRestorer == nil || assetID == 0 { + return + } + column := "iot_card_id" + if assetType == constants.AssetTypeDevice { + column = "device_id" + } + var active int64 + if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Where(column+" = ? AND status = ? AND master_usage_id IS NULL", assetID, constants.PackageUsageStatusActive). + Count(&active).Error; err != nil || active == 0 { + return + } + if !s.pollingRestorer.RestoreAssetPolling(ctx, assetType, assetID, constants.PollingToggleSourceExpiryChange) || s.resumeEvaluator == nil { + return + } + resumeCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneActivation) + stopresumedomain.RunAfterCommit(resumeCtx, s.resumeEvaluator, func() { + if err := s.resumeEvaluator.ResumeCardIfStopped(resumeCtx, assetType, assetID); err != nil { + s.logger.Warn("改有效期恢复轮询后复机评估失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err)) + } + }) +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 65944061..c3215d7b 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -253,6 +253,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if req.HasActivePackage != nil { filters["has_active_package"] = *req.HasActivePackage } + if req.EnablePolling != nil { + filters["enable_polling"] = *req.EnablePolling + } if req.AuthorizedEnterpriseID != nil { filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index cbabcd18..0390748b 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -3,6 +3,7 @@ package iot_card import ( "context" stderrors "errors" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" "strings" "time" @@ -265,6 +266,9 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if req.RealNameStatus != nil { filters["real_name_status"] = *req.RealNameStatus } + if req.EnablePolling != nil { + filters["enable_polling"] = *req.EnablePolling + } if req.AuthorizedEnterpriseID != nil { filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID } @@ -1717,6 +1721,12 @@ func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePo return appErr } beforePolling := card.EnablePolling + if enablePolling && !beforePolling && pollingpause.IsRiskExtend(card.GatewayExtend) { + // 风险停机、已销户的卡必须关闭轮询,不能开启。 + denyErr := errors.New(errors.CodeForbidden, pollingpause.RiskEnableDeniedReason(card.GatewayExtend)) + s.recordPollingStatusFailure(ctx, constants.AuditActionIotCardPollingStatusUpdated, constants.AuditResultDenied, card, enablePolling, denyErr) + return denyErr + } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if beforePolling != enablePolling { result := tx.Model(&model.IotCard{}).Where("id = ? AND enable_polling = ?", card.ID, beforePolling).Update("enable_polling", enablePolling) @@ -1726,6 +1736,9 @@ func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePo if result.RowsAffected == 0 { return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试") } + if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(card, enablePolling), constants.PollingToggleSourceSingle); err != nil { + return err + } } return s.appendPollingStatusAudit(ctx, tx, card, beforePolling, enablePolling) }) @@ -1758,14 +1771,36 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, return nil } - cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) + // 设备级联:清单来源记"设备级联",原因沿用设备变更的原因。 + origin := pollingpause.OriginFrom(ctx, constants.PollingToggleSourceCascade) + origin.Source = constants.PollingToggleSourceCascade + ctx = pollingpause.WithOrigin(ctx, origin) + loaded, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { return err } + // 开启时跳过风险停机、已销户的卡:这类卡必须保持关闭轮询。 + cards := make([]*model.IotCard, 0, len(loaded)) + cardIDs = make([]uint, 0, len(loaded)) + for _, card := range loaded { + if card == nil || (enablePolling && pollingpause.IsRiskExtend(card.GatewayExtend)) { + continue + } + cards = append(cards, card) + cardIDs = append(cardIDs, card.ID) + } + if len(cardIDs) == 0 { + return nil + } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if updateErr := tx.Model(&model.IotCard{}).Where("id IN ?", cardIDs).Update("enable_polling", enablePolling).Error; updateErr != nil { return errors.Wrap(errors.CodeDatabaseError, updateErr, "批量更新 IoT 卡轮询状态失败") } + for _, card := range cards { + if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(card, enablePolling), constants.PollingToggleSourceCascade); err != nil { + return err + } + } return s.appendBatchPollingStatusAudit(ctx, tx, cards, enablePolling) }) if err != nil { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 5cd2ec4b..38f405e9 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -73,6 +73,8 @@ type Service struct { queueClient *queue.Client logger *zap.Logger resumeCallback packagepkg.ResumeCallback + // pollingRestorer 订单套餐生效后自动恢复关闭轮询的卡或设备;未注入时不恢复。 + pollingRestorer PollingRestorer assetIdentifierStore *postgres.AssetIdentifierStore personalCustomerStore *postgres.PersonalCustomerStore personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore @@ -167,6 +169,45 @@ func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) { s.resumeCallback = callback } +// PollingRestorer 是套餐生效类入口自动恢复轮询的能力。 +type PollingRestorer interface { + RestoreAssetPolling(ctx context.Context, assetType string, assetID uint, source string) bool +} + +// SetPollingRestorer 注入订单套餐生效后自动恢复轮询的能力。 +func (s *Service) SetPollingRestorer(restorer PollingRestorer) { + s.pollingRestorer = restorer +} + +// afterOrderActivated 订单事务提交后:本单直接激活了主套餐时,先把关闭轮询的卡或设备恢复轮询(来源"支付或下单生效", +// 风险停机或已销户不恢复),再按规则评估复机。resume=true 时(支付确认)总是评估;否则只在恢复了轮询时评估。 +func (s *Service) afterOrderActivated(ctx context.Context, order *model.Order, resume bool) { + if order == nil { + return + } + assetType, assetID := constants.AssetTypeIotCard, uint(0) + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + assetID = *order.IotCardID + } else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + assetType, assetID = constants.AssetTypeDevice, *order.DeviceID + } + if assetID == 0 { + return + } + restored := false + if s.pollingRestorer != nil { + var activated int64 + if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Where("order_id = ? AND status = ? AND master_usage_id IS NULL", order.ID, constants.PackageUsageStatusActive). + Count(&activated).Error; err == nil && activated > 0 { + restored = s.pollingRestorer.RestoreAssetPolling(ctx, assetType, assetID, constants.PollingToggleSourceOrder) + } + } + if resume || restored { + s.tryResumeAfterPayment(ctx, order) + } +} + // CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活) // 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活 // POST /api/admin/orders @@ -1233,6 +1274,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model if existingOrderID > 0 { return existingOrderID, nil } + s.afterOrderActivated(ctx, order, false) // 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算 @@ -1245,7 +1287,7 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or } release := s.prepareActivation(ctx, order) defer release() - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") } @@ -1270,6 +1312,10 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or } return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并完成订单", order, nil, orderStateData(order)) }) + if err == nil { + s.afterOrderActivated(ctx, order, false) + } + return err } // CreatePendingOrder 在同一事务中创建待支付订单、冻结个人资产钱包、创建明细和审计事件。 @@ -1928,6 +1974,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, if err != nil { return err } + s.afterOrderActivated(ctx, order, false) return nil } @@ -2043,7 +2090,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay } if shouldResumeAfterPayment { - s.tryResumeAfterPayment(ctx, order) + s.afterOrderActivated(ctx, order, true) } return nil } @@ -2167,7 +2214,7 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str } if shouldResumeAfterPayment { - s.tryResumeAfterPayment(ctx, order) + s.afterOrderActivated(ctx, order, true) } return nil } diff --git a/internal/service/polling/asset_polling_service.go b/internal/service/polling/asset_polling_service.go index e553cd70..e43a82ed 100644 --- a/internal/service/polling/asset_polling_service.go +++ b/internal/service/polling/asset_polling_service.go @@ -3,6 +3,7 @@ package polling import ( "context" stderrors "errors" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" "strconv" "go.uber.org/zap" @@ -125,6 +126,9 @@ func (s *AssetPollingService) updateDevicePollingStatus(ctx context.Context, dev if result.RowsAffected == 0 { return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试") } + if err := pollingpause.Record(ctx, tx, pollingpause.DeviceChange(device, enablePolling), constants.PollingToggleSourceSingle); err != nil { + return err + } } return s.appendDevicePollingAudit(ctx, tx, device, enablePolling, constants.AuditResultSuccess, nil) }) diff --git a/internal/service/polling/carrier_scheduler_service.go b/internal/service/polling/carrier_scheduler_service.go index 58b1c0c9..1d647250 100644 --- a/internal/service/polling/carrier_scheduler_service.go +++ b/internal/service/polling/carrier_scheduler_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" stderrors "errors" + "github.com/break/junhong_cmp_fiber/internal/query/pollingalert" "strconv" "time" @@ -300,6 +301,9 @@ func (s *CarrierSchedulerService) ListAlerts(ctx context.Context, req *dto.Polli return nil, err } page, size := normalizePage(req.Page, req.PageSize) + if req.View == constants.PollingAlertViewStopRefused { + return s.listStopRefused(ctx, req, page, size) + } query := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{}) if req.CarrierID != nil { query = query.Where("carrier_id = ?", *req.CarrierID) @@ -649,3 +653,45 @@ func atoi64(s string) int64 { value, _ := strconv.ParseInt(s, 10, 64) return value } + +// listStopRefused 停机被拒视图:停复机方向为停机,且错误类型为终态或业务错误、或停机失败次数达到阈值。 +func (s *CarrierSchedulerService) listStopRefused(ctx context.Context, req *dto.PollingFailureAlertListRequest, page, size int) (*dto.PollingFailureAlertPageResult, error) { + start, end, err := utils.ParseTimeRange(req.StartTime, req.EndTime) + if err != nil { + return nil, err + } + filter := pollingalert.StopRefusedFilter{CarrierID: req.CarrierID, Status: req.Status, ICCID: req.ICCID, StartTime: start, EndTime: end} + threshold := s.settings.Current(ctx).SRAlertFailCount + query := pollingalert.NewQuery(s.db) + total, err := query.Count(ctx, filter, threshold) + if err != nil { + return nil, err + } + rows, err := query.Page(ctx, filter, threshold, (page-1)*size, size) + if err != nil { + return nil, err + } + result := &dto.PollingFailureAlertPageResult{Total: total, Page: page, PageSize: size, List: make([]dto.PollingFailureAlertItem, 0, len(rows))} + for index := range rows { + result.List = append(result.List, StopRefusedItem(&rows[index])) + } + return result, nil +} + +// StopRefusedItem 把停机被拒查询行转换为告警返回项。 +func StopRefusedItem(row *pollingalert.StopRefusedRow) dto.PollingFailureAlertItem { + network, polling, hasPackage := row.NetworkStatus, row.EnablePolling, row.HasPackage + item := dto.PollingFailureAlertItem{ + ID: row.ID, AlertLevel: constants.PollingAlertLevelCard, CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, + CarrierName: row.CarrierName, CheckType: constants.CheckTypeStopResume, CheckTypeName: "停复机", + ErrorCategory: row.ErrorCategory, FailCount: row.FailCount, FirstFailedAt: row.FirstFailedAt, LastFailedAt: row.LastFailedAt, + LastError: row.LastError, Status: row.Status, StatusName: constants.PollingAlertStatusName(row.Status), + HandledBy: row.HandledBy, HandledAt: row.HandledAt, HandleRemark: row.HandleRemark, + NetworkStatus: &network, StopReason: row.StopReason, EnablePolling: &polling, HasPackage: &hasPackage, HandledByName: row.HandledByName, + } + if row.DeviceID > 0 { + deviceID := row.DeviceID + item.DeviceID = &deviceID + } + return item +} diff --git a/internal/service/polling/polling_pause_service.go b/internal/service/polling/polling_pause_service.go new file mode 100644 index 00000000..b2bea11d --- /dev/null +++ b/internal/service/polling/polling_pause_service.go @@ -0,0 +1,295 @@ +package polling + +import ( + "context" + stderrors "errors" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pausequery "github.com/break/junhong_cmp_fiber/internal/query/pollingpause" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// 勾选批量开关轮询、告警页关闭轮询与不轮询清单查询(add-polling-pause-list-and-batch-toggle)。 +// 每项独立事务(经 UpdatePollingStatus 同事务写 enable_polling、清单与审计,提交后按生命周期移入或移出轮询),部分失败不影响其他项。 + +// BatchUpdatePollingStatus 勾选批量开关轮询:单次最多 500、校验数据权限、逐项处理并返回结果;批量开启跳过风险停机与已销户的卡。 +func (s *AssetPollingService) BatchUpdatePollingStatus(ctx context.Context, assetType string, req *dto.BatchUpdatePollingStatusRequest) (*dto.BatchUpdatePollingStatusResponse, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return nil, errors.New(errors.CodeForbidden, "企业账号无权修改轮询开关") + } + if req == nil || req.EnablePolling == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > constants.PollingBatchMaxSize { + return nil, errors.New(errors.CodeInvalidParam, "资产数量必须为 1~500") + } + seen := make(map[uint]bool, len(req.AssetIDs)) + for _, id := range req.AssetIDs { + if id == 0 || seen[id] { + return nil, errors.New(errors.CodeInvalidParam, "资产ID不能为空或重复") + } + seen[id] = true + } + ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceBatch, Reason: req.Reason}) + resp := &dto.BatchUpdatePollingStatusResponse{Items: make([]dto.PollingToggleItem, 0, len(req.AssetIDs)), Impact: impactFor(*req.EnablePolling)} + for _, id := range req.AssetIDs { + item := s.ToggleAsset(ctx, assetType, id, *req.EnablePolling, true) + countToggle(resp, item) + resp.Items = append(resp.Items, item) + } + return resp, nil +} + +// ToggleAsset 改一个资产的轮询开关并返回逐项结果;scoped 为 true 时按当前账号数据权限查找资产。 +// 来源与原因由 ctx 中的 pollingpause.Origin 决定。 +func (s *AssetPollingService) ToggleAsset(ctx context.Context, assetType string, assetID uint, enable, scoped bool) dto.PollingToggleItem { + item := dto.PollingToggleItem{AssetID: assetID} + current, identifier, extend, err := s.loadToggleTarget(ctx, assetType, assetID, scoped) + item.Identifier = identifier + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return toggleResult(item, constants.PollingToggleItemForbidden, "无权限操作该资产或资产不存在") + } + return toggleResult(item, constants.PollingToggleItemFailed, "查询资产失败") + } + if current == enable { + return toggleResult(item, constants.PollingToggleItemSkipped, "已是目标状态") + } + if enable && assetType == constants.AssetTypeIotCard && pollingpause.IsRiskExtend(extend) { + return toggleResult(item, constants.PollingToggleItemSkipped, pollingpause.RiskEnableDeniedReason(extend)) + } + if err := s.UpdatePollingStatus(ctx, assetType, assetID, enable); err != nil { + message := "修改轮询开关失败" + var appErr *errors.AppError + if stderrors.As(err, &appErr) { + message = appErr.Error() + } + s.logger.Warn("修改资产轮询开关失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err)) + return toggleResult(item, constants.PollingToggleItemFailed, message) + } + return toggleResult(item, constants.PollingToggleItemSuccess, "") +} + +func (s *AssetPollingService) loadToggleTarget(ctx context.Context, assetType string, assetID uint, scoped bool) (bool, string, string, error) { + query := s.db.WithContext(ctx) + switch assetType { + case constants.AssetTypeIotCard: + query = query.Model(&model.IotCard{}) + if scoped { + query = middleware.ApplyStrictShopFilter(ctx, query) + } + var card model.IotCard + if err := query.Select("id", "iccid", "enable_polling", "gateway_extend").Where("id = ?", assetID).First(&card).Error; err != nil { + return false, "", "", err + } + return card.EnablePolling, card.ICCID, card.GatewayExtend, nil + case constants.AssetTypeDevice: + query = query.Model(&model.Device{}) + if scoped { + query = middleware.ApplyStrictShopFilter(ctx, query) + } + var device model.Device + if err := query.Select("id", "imei", "virtual_no", "enable_polling").Where("id = ?", assetID).First(&device).Error; err != nil { + return false, "", "", err + } + identifier := device.IMEI + if identifier == "" { + identifier = device.VirtualNo + } + return device.EnablePolling, identifier, "", nil + default: + return false, "", "", errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +// ClosePollingByAlerts 告警页勾选批量关闭轮询:关闭告警对应卡的轮询(来源"停机被拒告警处理"),成功或已关闭的告警改为"已关闭轮询"。 +func (s *AssetPollingService) ClosePollingByAlerts(ctx context.Context, req *dto.ClosePollingByAlertsRequest) (*dto.BatchUpdatePollingStatusResponse, error) { + if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil { + return nil, err + } + if req == nil || len(req.AlertIDs) == 0 || len(req.AlertIDs) > constants.PollingBatchMaxSize { + return nil, errors.New(errors.CodeInvalidParam, "告警数量必须为 1~500") + } + var alerts []model.PollingFailureAlert + if err := s.db.WithContext(ctx).Where("id IN ? AND alert_level = ? AND card_id > 0", req.AlertIDs, constants.PollingAlertLevelCard). + Find(&alerts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询失败告警失败") + } + operatorID := middleware.GetUserIDFromContext(ctx) + ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceAlert, Reason: req.Reason}) + resp := &dto.BatchUpdatePollingStatusResponse{Items: []dto.PollingToggleItem{}, Impact: impactFor(false)} + done := map[uint]dto.PollingToggleItem{} + found := map[uint]bool{} + for _, alert := range alerts { + found[alert.ID] = true + item, ok := done[alert.CardID] + if !ok { + item = s.ToggleAsset(ctx, constants.AssetTypeIotCard, alert.CardID, false, false) + done[alert.CardID] = item + countToggle(resp, item) + resp.Items = append(resp.Items, item) + } + if item.Status != constants.PollingToggleItemSuccess && item.Status != constants.PollingToggleItemSkipped { + continue + } + now := time.Now() + if err := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{}).Where("id = ?", alert.ID).Updates(map[string]any{ + "status": constants.PollingAlertStatusPollingDisabled, "handled_by": operatorID, "handled_at": now, + "handle_remark": req.Reason, "updated_at": now, + }).Error; err != nil { + s.logger.Warn("更新告警为已关闭轮询失败", zap.Uint("alert_id", alert.ID), zap.Error(err)) + } + } + for _, id := range req.AlertIDs { + if !found[id] { + item := toggleResult(dto.PollingToggleItem{AssetID: 0}, constants.PollingToggleItemForbidden, "告警不存在或不是卡级告警") + countToggle(resp, item) + resp.Items = append(resp.Items, item) + } + } + return resp, nil +} + +// ListPauses 查询不轮询清单(仅超级管理员与平台账号)。 +func (s *AssetPollingService) ListPauses(ctx context.Context, req *dto.CardPollingPauseListRequest) (*dto.CardPollingPausePageResult, error) { + if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil { + return nil, err + } + filter, err := PauseFilter(req.AssetType, req.CarrierID, req.Source, req.Status, req.Identifier, req.OperatorID, req.StartTime, req.EndTime) + if err != nil { + return nil, err + } + page, size := req.Page, req.PageSize + if page <= 0 { + page = constants.DefaultPage + } + if size <= 0 { + size = constants.DefaultPageSize + } + if size > constants.MaxPageSize { + size = constants.MaxPageSize + } + query := pausequery.NewQuery(s.db) + total, err := query.Count(ctx, filter) + if err != nil { + return nil, err + } + rows, err := query.Page(ctx, filter, (page-1)*size, size) + if err != nil { + return nil, err + } + items := make([]dto.CardPollingPauseItem, 0, len(rows)) + for index := range rows { + items = append(items, ToPauseItem(&rows[index])) + } + return &dto.CardPollingPausePageResult{Items: items, Total: total, Page: page, PageSize: size}, nil +} + +// Impact 返回开关轮询的影响说明。 +func (s *AssetPollingService) Impact() *dto.PollingToggleImpactResponse { + return &dto.PollingToggleImpactResponse{Disable: impactFor(false), Enable: impactFor(true)} +} + +// PauseFilter 由请求参数构造不轮询清单筛选条件(列表与导出共用)。 +func PauseFilter(assetType string, carrierID *uint, source string, status *int, identifier string, operatorID *uint, start, end string) (pausequery.Filter, error) { + filter := pausequery.Filter{AssetType: assetType, CarrierID: carrierID, Source: source, Status: status, Identifier: identifier, OperatorID: operatorID} + for _, item := range []struct { + raw string + target **time.Time + }{{start, &filter.StartTime}, {end, &filter.EndTime}} { + if item.raw == "" { + continue + } + parsed, err := time.Parse(time.RFC3339, item.raw) + if err != nil { + return filter, errors.New(errors.CodeInvalidParam, "时间格式必须为带时区的 RFC3339") + } + *item.target = &parsed + } + return filter, nil +} + +// ToPauseItem 把清单查询行转换为接口返回项。 +func ToPauseItem(row *pausequery.Row) dto.CardPollingPauseItem { + return dto.CardPollingPauseItem{ + ID: row.ID, AssetType: row.AssetType, AssetID: row.AssetID, Identifier: row.Identifier, CarrierID: row.CarrierID, + CarrierName: row.CarrierName, EnablePolling: row.EnablePolling, GatewayExtend: row.GatewayExtend, + Status: row.Status, StatusName: constants.PollingPauseStatusName(row.Status), + CloseSource: row.CloseSource, CloseSourceName: constants.PollingToggleSourceName(row.CloseSource), CloseReason: row.CloseReason, + ClosedBy: row.ClosedBy, ClosedByName: row.ClosedByName, ClosedAt: row.ClosedAt, ImportTaskID: row.ImportTaskID, + RestoreSource: row.RestoreSource, RestoreSourceName: constants.PollingToggleSourceName(row.RestoreSource), RestoreReason: row.RestoreReason, + RestoredBy: row.RestoredBy, RestoredByName: row.RestoredByName, RestoredAt: row.RestoredAt, + } +} + +func impactFor(enable bool) []string { + source := constants.PollingToggleImpactDisable + if enable { + source = constants.PollingToggleImpactEnable + } + return append([]string(nil), source...) +} + +func toggleResult(item dto.PollingToggleItem, status int, reason string) dto.PollingToggleItem { + item.Status, item.StatusName, item.Reason = status, constants.PollingToggleItemStatusName(status), reason + return item +} + +func countToggle(resp *dto.BatchUpdatePollingStatusResponse, item dto.PollingToggleItem) { + switch item.Status { + case constants.PollingToggleItemSuccess: + resp.SuccessCount++ + case constants.PollingToggleItemSkipped: + resp.SkipCount++ + default: + resp.FailCount++ + } +} + +// RestoreAssetPolling 套餐生效类入口自动恢复轮询(订单套餐生效、改有效期后有生效套餐): +// 卡关闭轮询且非风险停机、已销户时开启;设备关闭轮询时开启设备(级联开启设备下非风险停机、非已销户的卡), +// 设备已开启时开启设备下单独关闭的卡。返回是否恢复了任何资产。不做数据权限过滤(系统入口)。 +func (s *AssetPollingService) RestoreAssetPolling(ctx context.Context, assetType string, assetID uint, source string) bool { + if s == nil || assetID == 0 { + return false + } + ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: source, Reason: constants.PollingToggleSourceName(source) + ",自动恢复轮询"}) + restored := false + switch assetType { + case constants.AssetTypeIotCard: + restored = s.restoreCard(ctx, assetID) + case constants.AssetTypeDevice: + item := s.ToggleAsset(ctx, constants.AssetTypeDevice, assetID, true, false) + restored = item.Status == constants.PollingToggleItemSuccess + if item.Status == constants.PollingToggleItemSkipped { + bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID) + if err != nil { + s.logger.Warn("自动恢复轮询查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err)) + return false + } + for _, binding := range bindings { + if s.restoreCard(ctx, binding.IotCardID) { + restored = true + } + } + } + } + if restored { + s.logger.Info("套餐生效后自动恢复轮询", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.String("source", source)) + } + return restored +} + +// restoreCard 恢复单张卡的轮询;已开启、风险停机或已销户时不处理。 +func (s *AssetPollingService) restoreCard(ctx context.Context, cardID uint) bool { + item := s.ToggleAsset(ctx, constants.AssetTypeIotCard, cardID, true, false) + if item.Status == constants.PollingToggleItemFailed { + s.logger.Warn("自动恢复卡轮询失败", zap.Uint("card_id", cardID), zap.String("reason", item.Reason)) + } + return item.Status == constants.PollingToggleItemSuccess +} diff --git a/internal/service/polling/polling_status_import_service.go b/internal/service/polling/polling_status_import_service.go new file mode 100644 index 00000000..4c64381b --- /dev/null +++ b/internal/service/polling/polling_status_import_service.go @@ -0,0 +1,142 @@ +package polling + +import ( + "context" + stderrors "errors" + "fmt" + "math/rand" + "path/filepath" + "strings" + "time" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pausequery "github.com/break/junhong_cmp_fiber/internal/query/pollingpause" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// PollingStatusImportPayload 是轮询开关文件导入 Worker 载荷。 +type PollingStatusImportPayload struct { + TaskID uint `json:"task_id"` +} + +// PollingStatusImportService 轮询开关文件导入任务的创建与查询(仅超级管理员与平台账号)。 +// 逐行处理在 Worker 内执行,规则与勾选批量相同;导入只允许平台账号创建,因此 Worker 逐行处理不再按店铺过滤。 +type PollingStatusImportService struct { + db *gorm.DB + queueClient TaskEnqueuer + logger *zap.Logger +} + +// NewPollingStatusImportService 创建轮询开关导入服务。 +func NewPollingStatusImportService(db *gorm.DB, queueClient TaskEnqueuer, logger *zap.Logger) *PollingStatusImportService { + if logger == nil { + logger = zap.NewNop() + } + return &PollingStatusImportService{db: db, queueClient: queueClient, logger: logger} +} + +// CreateImport 创建导入任务并投递到 Worker;投递失败时任务标为失败。 +func (s *PollingStatusImportService) CreateImport(ctx context.Context, req *dto.CreatePollingStatusImportRequest) (*dto.PollingStatusImportTaskResponse, error) { + if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil { + return nil, err + } + if req == nil || req.EnablePolling == nil { + return nil, errors.New(errors.CodeInvalidParam) + } + if !strings.HasPrefix(req.FileKey, constants.PollingStatusImportStoragePrefix+"/") { + return nil, errors.New(errors.CodeInvalidParam, "导入文件Key不属于指定上传目录") + } + if !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") { + return nil, errors.New(errors.CodeInvalidParam, "导入文件必须为CSV格式") + } + userID := middleware.GetUserIDFromContext(ctx) + if userID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + now := time.Now() + task := &model.PollingStatusImportTask{ + TaskNo: fmt.Sprintf("PSI%s%06d", now.Format("20060102150405"), rand.Intn(1000000)), AssetType: req.AssetType, + EnablePolling: *req.EnablePolling, Reason: req.Reason, FileName: filepath.Base(req.FileKey), StorageKey: req.FileKey, + Status: model.ImportTaskStatusPending, ResultItems: model.PollingStatusImportResults{}, Creator: userID, + CreatorName: middleware.GetUsernameFromContext(ctx), CreatedAt: now, UpdatedAt: now, + } + if err := s.db.WithContext(ctx).Create(task).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建轮询开关导入任务失败") + } + var enqueueErr error + if s.queueClient == nil { + enqueueErr = stderrors.New("任务队列未配置") + } else { + enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypePollingStatusImport, PollingStatusImportPayload{TaskID: task.ID}, + asynq.Queue(constants.QueueForTaskType(constants.TaskTypePollingStatusImport)), asynq.Timeout(30*time.Minute), asynq.MaxRetry(0)) + } + if enqueueErr != nil { + s.logger.Error("轮询开关导入任务入队失败", zap.Uint("task_id", task.ID), zap.Error(enqueueErr)) + completed := time.Now() + s.db.WithContext(ctx).Model(&model.PollingStatusImportTask{}).Where("id = ? AND status = ?", task.ID, model.ImportTaskStatusPending). + Updates(map[string]any{"status": model.ImportTaskStatusFailed, "error_message": "导入任务入队失败", "completed_at": completed, "updated_at": completed}) + task.Status, task.ErrorMessage, task.CompletedAt = model.ImportTaskStatusFailed, "导入任务入队失败", &completed + } + return toImportResponse(task), nil +} + +// ListImports 分页查询导入任务。 +func (s *PollingStatusImportService) ListImports(ctx context.Context, req *dto.PollingStatusImportListRequest) (*dto.PollingStatusImportPageResult, error) { + if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil { + return nil, err + } + page, size := normalizePage(req.Page, req.PageSize) + query := s.db.WithContext(ctx).Model(&model.PollingStatusImportTask{}) + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计轮询开关导入任务失败") + } + var rows []model.PollingStatusImportTask + if err := query.Omit("result_items").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询开关导入任务失败") + } + items := make([]dto.PollingStatusImportTaskResponse, 0, len(rows)) + for index := range rows { + items = append(items, *toImportResponse(&rows[index])) + } + return &dto.PollingStatusImportPageResult{Items: items, Total: total, Page: page, PageSize: size}, nil +} + +// GetImport 查询导入任务详情与逐行结果。 +func (s *PollingStatusImportService) GetImport(ctx context.Context, id uint) (*dto.PollingStatusImportTaskDetailResponse, error) { + if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil { + return nil, err + } + var task model.PollingStatusImportTask + if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New(errors.CodeNotFound, "轮询开关导入任务不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询开关导入任务失败") + } + items := make([]dto.PollingStatusImportItemResponse, 0, len(task.ResultItems)) + for _, item := range task.ResultItems { + items = append(items, dto.PollingStatusImportItemResponse{Line: item.Line, Identifier: item.Identifier, AssetID: item.AssetID, + Status: item.Status, StatusName: constants.PollingToggleItemStatusName(item.Status), Reason: item.Reason}) + } + return &dto.PollingStatusImportTaskDetailResponse{PollingStatusImportTaskResponse: *toImportResponse(&task), Items: items}, nil +} + +func toImportResponse(task *model.PollingStatusImportTask) *dto.PollingStatusImportTaskResponse { + return &dto.PollingStatusImportTaskResponse{ + ID: task.ID, TaskNo: task.TaskNo, AssetType: task.AssetType, EnablePolling: task.EnablePolling, Reason: task.Reason, + FileName: task.FileName, Status: task.Status, StatusName: model.ImportTaskStatusName(task.Status), + TotalCount: task.TotalCount, SuccessCount: task.SuccessCount, SkipCount: task.SkipCount, FailCount: task.FailCount, + ErrorMessage: task.ErrorMessage, CreatorName: task.CreatorName, CreatedAt: task.CreatedAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, + } +} diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index f0668d82..10ac19ee 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -221,6 +221,9 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi if hasActive, ok := filters["has_active_package"].(bool); ok { query = s.applyHasActivePackageFilter(query, hasActive) } + if enablePolling, ok := filters["enable_polling"].(bool); ok { + query = query.Where("enable_polling = ?", enablePolling) + } if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 { // 只返回当前有效授权给该企业的设备 query = query.Where("id IN (?)", diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index c0ea5ab9..f412ec7f 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -905,6 +905,9 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D if realNameStatus, ok := filters["real_name_status"].(int); ok { query = query.Where("real_name_status = ?", realNameStatus) } + if enablePolling, ok := filters["enable_polling"].(bool); ok { + query = query.Where("enable_polling = ?", enablePolling) + } if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 { // 只返回有效授权给该企业的卡 query = query.Where("id IN (?)", diff --git a/internal/task/polling_status_import.go b/internal/task/polling_status_import.go new file mode 100644 index 00000000..ed942aa7 --- /dev/null +++ b/internal/task/polling_status_import.go @@ -0,0 +1,195 @@ +package task + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + stderrors "errors" + "io" + "os" + "strings" + "time" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause" + "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" + "github.com/break/junhong_cmp_fiber/pkg/storage" + "github.com/break/junhong_cmp_fiber/pkg/utils" +) + +// PollingToggler 改一个资产的轮询开关并返回逐项结果(由资产轮询服务实现)。 +type PollingToggler interface { + ToggleAsset(ctx context.Context, assetType string, assetID uint, enable, scoped bool) dto.PollingToggleItem +} + +// pollingStatusImportPayload 与 API 侧 PollingStatusImportPayload 同一 JSON 契约。 +type pollingStatusImportPayload struct { + TaskID uint `json:"task_id"` +} + +// PollingStatusImportHandler 轮询开关文件导入:逐行独立处理(规则同勾选批量),部分成功不回滚,逐行记录结果。 +type PollingStatusImportHandler struct { + db *gorm.DB + storage *storage.Service + toggler PollingToggler + logger *zap.Logger +} + +// NewPollingStatusImportHandler 创建轮询开关导入任务处理器。 +func NewPollingStatusImportHandler(db *gorm.DB, storageService *storage.Service, toggler PollingToggler, logger *zap.Logger) *PollingStatusImportHandler { + if logger == nil { + logger = zap.NewNop() + } + return &PollingStatusImportHandler{db: db, storage: storageService, toggler: toggler, logger: logger} +} + +// Handle 处理一个导入任务;任务只会从待处理被领取一次。 +func (h *PollingStatusImportHandler) Handle(ctx context.Context, t *asynq.Task) error { + var payload pollingStatusImportPayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil || payload.TaskID == 0 { + h.logger.Warn("轮询开关导入任务载荷无效", zap.ByteString("payload", t.Payload())) + return nil + } + now := time.Now() + claimed := h.db.WithContext(ctx).Model(&model.PollingStatusImportTask{}). + Where("id = ? AND status = ?", payload.TaskID, model.ImportTaskStatusPending). + Updates(map[string]any{"status": model.ImportTaskStatusProcessing, "started_at": now, "updated_at": now}) + if claimed.Error != nil { + return claimed.Error + } + if claimed.RowsAffected == 0 { + return nil + } + var task model.PollingStatusImportTask + if err := h.db.WithContext(ctx).First(&task, payload.TaskID).Error; err != nil { + return err + } + identifiers, err := h.downloadAndParse(ctx, task.StorageKey) + if err != nil { + h.fail(ctx, task.ID, err.Error()) + return nil + } + rowCtx := pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceImport, Reason: task.Reason, + ImportTaskID: task.ID, OperatorID: task.Creator}) + results := make(model.PollingStatusImportResults, 0, len(identifiers)) + success, skipped, failed := 0, 0, 0 + for index, identifier := range identifiers { + item := h.processRow(rowCtx, task, index+1, identifier) + switch item.Status { + case constants.PollingToggleItemSuccess: + success++ + case constants.PollingToggleItemSkipped: + skipped++ + default: + failed++ + } + results = append(results, item) + } + completed := time.Now() + if err := h.db.WithContext(context.WithoutCancel(ctx)).Model(&model.PollingStatusImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{ + "status": model.ImportTaskStatusCompleted, "total_count": len(identifiers), "success_count": success, "skip_count": skipped, + "fail_count": failed, "result_items": results, "completed_at": completed, "updated_at": completed, + }).Error; err != nil { + h.logger.Error("保存轮询开关导入结果失败", zap.Uint("task_id", task.ID), zap.Error(err)) + return err + } + h.logger.Info("轮询开关导入完成", zap.Uint("task_id", task.ID), zap.Int("total", len(identifiers)), zap.Int("success", success), + zap.Int("skipped", skipped), zap.Int("failed", failed)) + return nil +} + +func (h *PollingStatusImportHandler) processRow(ctx context.Context, task model.PollingStatusImportTask, line int, identifier string) model.PollingStatusImportResultItem { + item := model.PollingStatusImportResultItem{Line: line, Identifier: identifier} + if identifier == "" { + item.Status, item.Reason = constants.PollingToggleItemFailed, "标识为空" + return item + } + assetID, err := h.resolve(ctx, task.AssetType, identifier) + if err != nil { + item.Status, item.Reason = constants.PollingToggleItemForbidden, "资产不存在" + if !stderrors.Is(err, gorm.ErrRecordNotFound) { + item.Status, item.Reason = constants.PollingToggleItemFailed, "查询资产失败" + } + return item + } + item.AssetID = assetID + result := h.toggler.ToggleAsset(ctx, task.AssetType, assetID, task.EnablePolling, false) + item.Status, item.Reason = result.Status, result.Reason + return item +} + +// resolve 按 ICCID(卡)或 IMEI / 虚拟号(设备)查找资产。 +func (h *PollingStatusImportHandler) resolve(ctx context.Context, assetType, identifier string) (uint, error) { + if assetType == constants.AssetTypeDevice { + var device model.Device + err := h.db.WithContext(ctx).Select("id").Where("imei = ? OR virtual_no = ?", identifier, identifier).Order("id").First(&device).Error + return device.ID, err + } + var card model.IotCard + err := h.db.WithContext(ctx).Select("id").Where("iccid = ?", identifier).First(&card).Error + return card.ID, err +} + +// downloadAndParse 下载 CSV 并取每行首列作为资产标识;首行为表头,空行忽略。 +func (h *PollingStatusImportHandler) downloadAndParse(ctx context.Context, key string) ([]string, error) { + if h.storage == nil { + return nil, stderrors.New("对象存储服务未配置") + } + localPath, cleanup, err := h.storage.DownloadToTemp(ctx, key) + if err != nil { + return nil, stderrors.New("下载导入文件失败") + } + defer cleanup() + data, err := os.ReadFile(localPath) + if err != nil { + return nil, stderrors.New("读取导入文件失败") + } + decoded, err := utils.DecodeTextToUTF8(data) + if err != nil { + return nil, stderrors.New("导入文件编码无法识别,请使用 UTF-8 或 GBK") + } + reader := csv.NewReader(bytes.NewReader(decoded)) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + identifiers := make([]string, 0) + header := true + for { + record, readErr := reader.Read() + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, stderrors.New("导入文件不是有效的 CSV") + } + if header { + header = false + continue + } + if len(record) == 0 || strings.TrimSpace(strings.Join(record, "")) == "" { + continue + } + identifiers = append(identifiers, strings.TrimSpace(strings.TrimPrefix(record[0], "\uFEFF"))) + if len(identifiers) > constants.PollingStatusImportMaxRows { + return nil, stderrors.New("导入文件超过 50000 行") + } + } + if len(identifiers) == 0 { + return nil, stderrors.New("导入文件没有数据行") + } + return identifiers, nil +} + +func (h *PollingStatusImportHandler) fail(ctx context.Context, taskID uint, message string) { + now := time.Now() + if err := h.db.WithContext(context.WithoutCancel(ctx)).Model(&model.PollingStatusImportTask{}).Where("id = ?", taskID).Updates(map[string]any{ + "status": model.ImportTaskStatusFailed, "error_message": message, "completed_at": now, "updated_at": now, + }).Error; err != nil { + h.logger.Error("标记轮询开关导入任务失败时出错", zap.Uint("task_id", taskID), zap.Error(err)) + } +} diff --git a/migrations/000256_add_card_polling_pause.down.sql b/migrations/000256_add_card_polling_pause.down.sql new file mode 100644 index 00000000..0c9e5837 --- /dev/null +++ b/migrations/000256_add_card_polling_pause.down.sql @@ -0,0 +1,3 @@ +-- 回滚不轮询清单与轮询开关文件导入任务。 +DROP TABLE IF EXISTS tb_polling_status_import_task; +DROP TABLE IF EXISTS tb_card_polling_pause; diff --git a/migrations/000256_add_card_polling_pause.up.sql b/migrations/000256_add_card_polling_pause.up.sql new file mode 100644 index 00000000..4c92d560 --- /dev/null +++ b/migrations/000256_add_card_polling_pause.up.sql @@ -0,0 +1,104 @@ +-- 不轮询清单与轮询开关文件导入任务(add-polling-pause-list-and-batch-toggle)。 +-- 背景:开关轮询此前只有单资产接口与系统自动规则,没有"谁、何时、为什么关闭"的记录,也没有批量与文件导入。 +-- +-- 设计选择: +-- 1. tb_card_polling_pause 只做记录,调度只认卡与设备的 enable_polling,不读本表。 +-- 2. 每次 enable_polling 由开启变为关闭时新增一条"已关闭"(status=1)记录;由关闭变为开启时把该记录标为"已恢复"(status=2) +-- 并写恢复来源、操作人与时间,记录保留。部分唯一索引保证同一资产只有一条"已关闭"记录。 +-- 3. asset_type:iot_card / device;identifier:卡为 ICCID,设备为 IMEI(为空时取虚拟号);carrier_id 只对卡有值。 +-- 4. close_source / restore_source 取值见代码常量(单资产接口、勾选批量、文件导入、风险停机或已销户自动关闭、设备级联、 +-- 支付或下单生效、改有效期、停机被拒告警处理、历史数据)。 +-- 5. 存量补写:当前 enable_polling = false 的卡与设备各补一条"已关闭";网关扩展状态为风险停机或已销户的卡记 +-- risk_auto(风险停机或已销户自动关闭),其余记 history(历史数据);补写记录的关闭时间取资产 updated_at。 +-- 6. tb_polling_status_import_task:上传 ICCID 或设备 IMEI 的 CSV,选择开启或关闭,异步逐行处理、逐行结果,部分成功不回滚。 +-- 不使用数据库外键(ENG-MODEL-001)。 + +CREATE TABLE tb_card_polling_pause ( + id BIGSERIAL PRIMARY KEY, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + identifier VARCHAR(100) NOT NULL DEFAULT '', + carrier_id BIGINT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + close_source VARCHAR(30) NOT NULL, + close_reason VARCHAR(500) NOT NULL DEFAULT '', + closed_by BIGINT NOT NULL DEFAULT 0, + closed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + import_task_id BIGINT NOT NULL DEFAULT 0, + restore_source VARCHAR(30) NOT NULL DEFAULT '', + restore_reason VARCHAR(500) NOT NULL DEFAULT '', + restored_by BIGINT NOT NULL DEFAULT 0, + restored_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_card_polling_pause_status CHECK (status IN (1, 2)), + CONSTRAINT ck_card_polling_pause_asset_type CHECK (asset_type IN ('iot_card', 'device')) +); + +CREATE UNIQUE INDEX uq_card_polling_pause_open ON tb_card_polling_pause (asset_type, asset_id) WHERE status = 1; +CREATE INDEX idx_card_polling_pause_list ON tb_card_polling_pause (status, closed_at DESC); +CREATE INDEX idx_card_polling_pause_asset ON tb_card_polling_pause (asset_type, asset_id); +CREATE INDEX idx_card_polling_pause_identifier ON tb_card_polling_pause (identifier); + +COMMENT ON TABLE tb_card_polling_pause IS '不轮询清单:记录卡或设备关闭轮询的来源、原因、操作人与恢复历史;调度只认 enable_polling,不读本表'; +COMMENT ON COLUMN tb_card_polling_pause.asset_type IS '资产类型:iot_card / device'; +COMMENT ON COLUMN tb_card_polling_pause.asset_id IS '资产ID(卡ID或设备ID)'; +COMMENT ON COLUMN tb_card_polling_pause.identifier IS '资产标识快照:卡为 ICCID,设备为 IMEI(为空时取虚拟号)'; +COMMENT ON COLUMN tb_card_polling_pause.carrier_id IS '卡所属运营商账户ID(设备为 0)'; +COMMENT ON COLUMN tb_card_polling_pause.status IS '状态:1-已关闭 2-已恢复'; +COMMENT ON COLUMN tb_card_polling_pause.close_source IS '关闭来源'; +COMMENT ON COLUMN tb_card_polling_pause.close_reason IS '关闭原因'; +COMMENT ON COLUMN tb_card_polling_pause.closed_by IS '关闭操作人账号ID(系统为 0)'; +COMMENT ON COLUMN tb_card_polling_pause.closed_at IS '关闭时间'; +COMMENT ON COLUMN tb_card_polling_pause.import_task_id IS '文件导入任务ID(非导入为 0)'; +COMMENT ON COLUMN tb_card_polling_pause.restore_source IS '恢复来源'; +COMMENT ON COLUMN tb_card_polling_pause.restore_reason IS '恢复原因'; +COMMENT ON COLUMN tb_card_polling_pause.restored_by IS '恢复操作人账号ID(系统为 0)'; +COMMENT ON COLUMN tb_card_polling_pause.restored_at IS '恢复时间'; + +INSERT INTO tb_card_polling_pause (asset_type, asset_id, identifier, carrier_id, status, close_source, closed_at) +SELECT 'iot_card', c.id, c.iccid, c.carrier_id, 1, + CASE WHEN c.gateway_extend IN ('风险停机', '已销户') THEN 'risk_auto' ELSE 'history' END, + COALESCE(c.updated_at, NOW()) +FROM tb_iot_card c +WHERE c.enable_polling = FALSE AND c.deleted_at IS NULL; + +INSERT INTO tb_card_polling_pause (asset_type, asset_id, identifier, carrier_id, status, close_source, closed_at) +SELECT 'device', d.id, COALESCE(NULLIF(d.imei, ''), d.virtual_no), 0, 1, 'history', COALESCE(d.updated_at, NOW()) +FROM tb_device d +WHERE d.enable_polling = FALSE AND d.deleted_at IS NULL; + +CREATE TABLE tb_polling_status_import_task ( + id BIGSERIAL PRIMARY KEY, + task_no VARCHAR(50) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + enable_polling BOOLEAN NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + file_name VARCHAR(255) NOT NULL DEFAULT '', + storage_key VARCHAR(500) NOT NULL, + status INTEGER NOT NULL DEFAULT 1, + total_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + skip_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + result_items JSONB NOT NULL DEFAULT '[]', + error_message TEXT NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + creator_name VARCHAR(100) NOT NULL DEFAULT '', + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_polling_status_import_task_status CHECK (status IN (1, 2, 3, 4)), + CONSTRAINT ck_polling_status_import_task_asset_type CHECK (asset_type IN ('iot_card', 'device')) +); + +CREATE UNIQUE INDEX uq_polling_status_import_task_no ON tb_polling_status_import_task (task_no); +CREATE INDEX idx_polling_status_import_task_created ON tb_polling_status_import_task (created_at DESC); + +COMMENT ON TABLE tb_polling_status_import_task IS '轮询开关文件导入任务:逐行独立处理,部分成功不回滚'; +COMMENT ON COLUMN tb_polling_status_import_task.asset_type IS '资产类型:iot_card(按 ICCID)/ device(按 IMEI 或虚拟号)'; +COMMENT ON COLUMN tb_polling_status_import_task.enable_polling IS '目标状态:true 开启 / false 关闭'; +COMMENT ON COLUMN tb_polling_status_import_task.status IS '任务状态:1-待处理 2-处理中 3-已完成 4-失败'; +COMMENT ON COLUMN tb_polling_status_import_task.skip_count IS '跳过行数(已是目标状态、风险停机或已销户不能开启)'; +COMMENT ON COLUMN tb_polling_status_import_task.result_items IS '逐行结果明细'; diff --git a/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md b/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md index 70ce6081..ae4e0dda 100644 --- a/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md +++ b/openspec/changes/add-polling-pause-list-and-batch-toggle/tasks.md @@ -1,43 +1,43 @@ ## 0. 前置确认 -- [ ] 0.1 停机被拒视图实施前确认第 5、6 个 Change 已上线;开关与清单部分可先行。 +- [x] 0.1 停机被拒视图实施前确认第 5、6 个 Change 已上线;开关与清单部分可先行。(第 5 个 Change 已部署测试环境,第 6 个 Change 已实现并部署测试环境) ## 1. 迁移与模型 -- [ ] 1.1 新增成对迁移:建 `tb_card_polling_pause`(资产类型、资产 ID、ICCID 或 IMEI、运营商账户、状态、关闭来源、原因、操作人、关闭时间、导入任务 ID、恢复来源、恢复人、恢复时间;部分唯一索引 `(asset_type, asset_id) WHERE status = 已关闭`);down 删表。 -- [ ] 1.2 同迁移补写存量:`enable_polling = false` 的卡与设备,风险停机或已销户记"风险停机或已销户自动关闭",其余记"历史数据"。 -- [ ] 1.3 新增 model 与 Store。 +- [x] 1.1 新增成对迁移:建 `tb_card_polling_pause`(资产类型、资产 ID、ICCID 或 IMEI、运营商账户、状态、关闭来源、原因、操作人、关闭时间、导入任务 ID、恢复来源、恢复人、恢复时间;部分唯一索引 `(asset_type, asset_id) WHERE status = 已关闭`);down 删表。(000256;同迁移另建 tb_polling_status_import_task) +- [x] 1.2 同迁移补写存量:`enable_polling = false` 的卡与设备,风险停机或已销户记"风险停机或已销户自动关闭",其余记"历史数据"。(测试库补写 4 张卡:risk_auto 2、history 2,与关闭轮询卡数一致) +- [x] 1.3 新增 model 与 Store。(model.CardPollingPause / PollingStatusImportTask;读取走 internal/query/pollingpause) ## 2. 统一的轮询开关写入 -- [ ] 2.1 新增"改轮询开关(资产、目标状态、来源、原因、操作人)":同事务写 `enable_polling`、清单与审计,提交后调用生命周期移入或移出。 -- [ ] 2.2 改造全部入口经此函数:单资产接口、风险停机或已销户自动关闭(`network.go`)、设备级联、自动恢复、告警页关闭。 +- [x] 2.1 新增"改轮询开关(资产、目标状态、来源、原因、操作人)":同事务写 `enable_polling`、清单与审计,提交后调用生命周期移入或移出。(infrastructure/pollingpause.Record 在调用方事务内写清单,来源与原因经 ctx 的 Origin 传入;卡与设备开关、设备级联各自同事务写 enable_polling、清单与审计,提交后经生命周期移入或移出) +- [x] 2.2 改造全部入口经此函数:单资产接口、风险停机或已销户自动关闭(`network.go`)、设备级联、自动恢复、告警页关闭。(单资产接口、设备级联(来源 cascade)、风险停机或已销户自动关闭(network.go 与停复机终态 TerminalPollingDisabler)、批量、导入、自动恢复、告警页关闭均经 Record;开启时风险停机或已销户的卡拒绝或跳过) ## 3. 批量与导入 -- [ ] 3.1 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`:单次 500、数据权限、逐项事务与结果、批量开启跳过风险停机与已销户。 -- [ ] 3.2 文件导入:`POST /polling-status-imports`、`GET /polling-status-imports`、`GET /polling-status-imports/:id`;异步逐行处理与结果。 +- [x] 3.1 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`:单次 500、数据权限、逐项事务与结果、批量开启跳过风险停机与已销户。(逐项结果:成功、跳过(已是目标状态、风险停机或已销户)、失败、无权限或资产不存在;数据权限用 ApplyStrictShopFilter,企业账号拒绝;返回本次影响说明) +- [x] 3.2 文件导入:`POST /polling-status-imports`、`GET /polling-status-imports`、`GET /polling-status-imports/:id`;异步逐行处理与结果。(仅平台账号创建;上传 purpose=polling_status_import;Worker 任务 polling:status_import 逐行处理,每行独立,部分成功不回滚,逐行结果写 result_items) ## 4. 自动恢复轮询 -- [ ] 4.1 `order/service.go` `activatePackage` 套餐生效后:关闭轮询且非风险停机、已销户的卡或设备先恢复轮询(来源"支付或下单生效"),再请求评估(第 6 个 Change 前为现有复机路径)。 -- [ ] 4.2 改有效期接口:保存后资产有生效套餐时同样恢复(来源"改有效期")。 +- [x] 4.1 `order/service.go` `activatePackage` 套餐生效后:关闭轮询且非风险停机、已销户的卡或设备先恢复轮询(来源"支付或下单生效"),再请求评估(第 6 个 Change 前为现有复机路径)。(订单 6 个激活入口事务提交后 afterOrderActivated:本单激活主套餐时恢复轮询(来源 order,风险停机或已销户不恢复),再按规则评估复机;API 与 Worker(批量下单)都注入) +- [x] 4.2 改有效期接口:保存后资产有生效套餐时同样恢复(来源"改有效期")。(UpdatePackageExpiresAt 保存后有生效中主套餐时恢复(来源 expiry_change)并请求复机评估) ## 5. 查询、筛选与导出 -- [ ] 5.1 `GET /card-polling-pauses`(筛选与分页);导出场景 `polling_pause`;清单页勾选批量开启复用 3.1。 -- [ ] 5.2 卡列表、设备列表 Query 增加 `enable_polling` 筛选,导出带该字段。 -- [ ] 5.3 开关轮询影响说明随接口文档与返回提供。 +- [x] 5.1 `GET /card-polling-pauses`(筛选与分页);导出场景 `polling_pause`;清单页勾选批量开启复用 3.1。(GET /card-polling-pauses 仅平台账号;导出场景 polling_pause 与列表同筛选;清单页批量开启复用 3.1) +- [x] 5.2 卡列表、设备列表 Query 增加 `enable_polling` 筛选,导出带该字段。(卡、设备列表与导出 enable_polling 筛选;卡与设备导出最后一列追加「是否轮询」,按冻结表头末列识别版本,历史任务重导出列序不变) +- [x] 5.3 开关轮询影响说明随接口文档与返回提供。(GET /polling-status/impact;批量与导入接口文档附影响说明,批量返回 impact) ## 6. 停机被拒视图 -- [ ] 6.1 `GET /polling-failure-alerts?view=stop_refused` 与展示字段;导出场景 `polling_stop_refused_alert`。 -- [ ] 6.2 `POST /polling-failure-alerts/close-polling`:单次 500,经统一开关写入,来源"停机被拒告警处理",告警状态改为已关闭轮询。 +- [x] 6.1 `GET /polling-failure-alerts?view=stop_refused` 与展示字段;导出场景 `polling_stop_refused_alert`。(view=stop_refused:check_type=stop_resume、停机方向、终态或业务错误或失败数≥停复机告警阈值;附设备、网络状态、停机原因、是否轮询、有无生效套餐、处理人;导出场景 polling_stop_refused_alert;告警检查类型筛选补 stop_resume) +- [x] 6.2 `POST /polling-failure-alerts/close-polling`:单次 500,经统一开关写入,来源"停机被拒告警处理",告警状态改为已关闭轮询。(只关告警对应卡自身;成功或已关闭的告警改为已关闭轮询并记处理人与备注) ## 7. 编译与规范校验 -- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;重新生成接口文档。 -- [ ] 7.2 `openspec validate add-polling-pause-list-and-batch-toggle` 与 `openspec doctor --json` 通过。 +- [x] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;重新生成接口文档。 +- [x] 7.2 `openspec validate add-polling-pause-list-and-batch-toggle` 与 `openspec doctor --json` 通过。(validate 通过;当前 openspec 无 doctor 命令) ## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) diff --git a/pkg/constants/carrier_polling.go b/pkg/constants/carrier_polling.go index bd38c1cb..efb2202a 100644 --- a/pkg/constants/carrier_polling.go +++ b/pkg/constants/carrier_polling.go @@ -64,6 +64,8 @@ func CheckTypeName(checkType string) string { return "实名检查" case CheckTypePackage: return "套餐检查" + case CheckTypeStopResume: + return "停复机" case PollingAlertCheckTypeAll: return "全部" default: diff --git a/pkg/constants/polling_pause.go b/pkg/constants/polling_pause.go new file mode 100644 index 00000000..450899b3 --- /dev/null +++ b/pkg/constants/polling_pause.go @@ -0,0 +1,132 @@ +package constants + +// 不轮询清单(tb_card_polling_pause,add-polling-pause-list-and-batch-toggle)。 + +// 不轮询清单状态。 +const ( + PollingPauseStatusClosed = 1 // 已关闭 + PollingPauseStatusRestored = 2 // 已恢复 +) + +// PollingPauseStatusName 返回不轮询清单状态名称。 +func PollingPauseStatusName(status int) string { + switch status { + case PollingPauseStatusClosed: + return "已关闭" + case PollingPauseStatusRestored: + return "已恢复" + default: + return "" + } +} + +// 轮询开关来源(关闭来源与恢复来源共用)。 +const ( + PollingToggleSourceSingle = "single" // 单资产接口 + PollingToggleSourceBatch = "batch" // 勾选批量 + PollingToggleSourceImport = "import" // 文件导入 + PollingToggleSourceRiskAuto = "risk_auto" // 风险停机或已销户自动关闭 + PollingToggleSourceCascade = "cascade" // 设备级联 + PollingToggleSourceOrder = "order" // 支付或下单生效 + PollingToggleSourceExpiryChange = "expiry_change" // 改有效期 + PollingToggleSourceAlert = "alert" // 停机被拒告警处理 + PollingToggleSourceHistory = "history" // 历史数据(上线时补写) +) + +// PollingToggleSources 返回全部轮询开关来源(筛选校验用)。返回新切片。 +func PollingToggleSources() []string { + return []string{PollingToggleSourceSingle, PollingToggleSourceBatch, PollingToggleSourceImport, PollingToggleSourceRiskAuto, + PollingToggleSourceCascade, PollingToggleSourceOrder, PollingToggleSourceExpiryChange, PollingToggleSourceAlert, PollingToggleSourceHistory} +} + +// PollingToggleSourceName 返回轮询开关来源名称。 +func PollingToggleSourceName(source string) string { + switch source { + case PollingToggleSourceSingle: + return "单资产接口" + case PollingToggleSourceBatch: + return "勾选批量" + case PollingToggleSourceImport: + return "文件导入" + case PollingToggleSourceRiskAuto: + return "风险停机或已销户自动关闭" + case PollingToggleSourceCascade: + return "设备级联" + case PollingToggleSourceOrder: + return "支付或下单生效" + case PollingToggleSourceExpiryChange: + return "改有效期" + case PollingToggleSourceAlert: + return "停机被拒告警处理" + case PollingToggleSourceHistory: + return "历史数据" + default: + return "" + } +} + +// PollingBatchMaxSize 是勾选批量开关轮询的单次上限。 +const PollingBatchMaxSize = 500 + +// 批量与导入逐项结果状态。 +const ( + PollingToggleItemSuccess = 1 // 成功 + PollingToggleItemSkipped = 2 // 跳过(已是目标状态、风险停机或已销户不能开启) + PollingToggleItemFailed = 3 // 失败 + PollingToggleItemForbidden = 4 // 无权限或资产不存在 +) + +// PollingToggleItemStatusName 返回逐项结果状态名称。 +func PollingToggleItemStatusName(status int) string { + switch status { + case PollingToggleItemSuccess: + return "成功" + case PollingToggleItemSkipped: + return "跳过" + case PollingToggleItemFailed: + return "失败" + case PollingToggleItemForbidden: + return "无权限或资产不存在" + default: + return "" + } +} + +// PollingToggleImpactDisable 关闭轮询的影响说明(供前端确认框展示)。 +var PollingToggleImpactDisable = []string{ + "关闭后不再查询上游卡状态、流量与实名", + "流量用完、实名不满足时不自动停机;套餐激活、流量重置后不自动复机", + "套餐到期、退款后无可用套餐时仍会自动停机", + "订单套餐生效、后台修改有效期后资产有生效套餐时,会自动恢复轮询并按规则复机", + "人工停机与人工复机照常可用", + "设备关闭时,设备下所有卡同样适用", +} + +// PollingToggleImpactEnable 开启轮询的影响说明。 +var PollingToggleImpactEnable = []string{ + "开启后按轮询配置查询上游卡状态、流量与实名", + "满足停机或复机条件时按规则自动停复机,人工停机不享有豁免", + "网关扩展状态为风险停机或已销户的卡不能开启轮询", + "设备开启时,设备下非风险停机、非已销户的卡同样开启", +} + +// 轮询开关文件导入。 +const ( + // StoragePurposePollingStatusImport 轮询开关导入 CSV 上传用途。 + StoragePurposePollingStatusImport = "polling_status_import" + // PollingStatusImportStoragePrefix 导入文件对象存储 Key 前缀。 + PollingStatusImportStoragePrefix = "polling-status-imports" + // TaskTypePollingStatusImport 轮询开关文件导入 Worker 任务类型。 + TaskTypePollingStatusImport = "polling:status_import" + // PollingStatusImportMaxRows 单个导入文件最大数据行数。 + PollingStatusImportMaxRows = 50000 +) + +// 导出场景。 +const ( + ExportTaskScenePollingPause = "polling_pause" + ExportTaskScenePollingStopRefusedAlert = "polling_stop_refused_alert" +) + +// PollingAlertViewStopRefused 是失败告警列表的"停机被拒"视图。 +const PollingAlertViewStopRefused = "stop_refused" diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 581acb8f..88e1ad59 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -2,6 +2,7 @@ package queue import ( "context" + iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" assetautorenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" @@ -86,6 +87,10 @@ type WorkerServices struct { OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) AssetPackageOrderCreator task.AssetPackageBatchOrderCreator // 批量订购复用后台单笔订单规则 DeviceBatchAllocator task.DeviceBatchAllocationExecutor // 设备CSV批量分配复用现有业务规则 + // AssetPolling 是资产轮询开关服务:轮询开关文件导入与订单套餐生效后自动恢复轮询共用。 + AssetPolling *pollingSvc.AssetPollingService + // IotCardPolling 是 AssetPolling 使用的卡服务,Worker 启动时注入轮询生命周期回调。 + IotCardPolling *iotCardSvc.Service } // WorkerBootstrapResult Worker Bootstrap 结果 diff --git a/pkg/storage/service.go b/pkg/storage/service.go index 198d1faf..b79d7bab 100644 --- a/pkg/storage/service.go +++ b/pkg/storage/service.go @@ -36,7 +36,7 @@ func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) { if ext == "" { ext = ".bin" } - if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport || purpose == constants.StoragePurposePhoneAssetUnbindImport) && !strings.EqualFold(ext, ".csv") { + if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport || purpose == constants.StoragePurposePhoneAssetUnbindImport || purpose == constants.StoragePurposePollingStatusImport) && !strings.EqualFold(ext, ".csv") { return "", errors.New(errors.CodeInvalidParam, "批量业务文件必须为CSV格式") } diff --git a/pkg/storage/types.go b/pkg/storage/types.go index 472d3a47..256dabf6 100644 --- a/pkg/storage/types.go +++ b/pkg/storage/types.go @@ -25,4 +25,7 @@ var PurposeMappings = map[string]PurposeMapping{ constants.StoragePurposePhoneAssetUnbindImport: { Prefix: constants.PhoneAssetUnbindImportStoragePrefix, ContentType: "text/csv", }, + constants.StoragePurposePollingStatusImport: { + Prefix: constants.PollingStatusImportStoragePrefix, ContentType: "text/csv", + }, }