feat: 不轮询清单与批量开关轮询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
- 新增不轮询清单 tb_card_polling_pause(迁移 000256 补写存量),所有改 enable_polling 的入口同事务写清单 - 卡、设备勾选批量开关轮询(单次 500,逐项结果,开启跳过风险停机与已销户)与 CSV 文件导入 - 订单套餐生效、改有效期后自动恢复轮询并按规则评估复机 - 不轮询清单查询与导出;卡、设备列表与导出增加是否轮询 - 失败告警停机被拒视图、导出与批量关闭轮询;开关轮询影响说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
|
||||
@@ -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 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
|
||||
@@ -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("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
226
internal/exporter/polling_pause_scene.go
Normal file
226
internal/exporter/polling_pause_scene.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
138
internal/handler/admin/polling_pause.go
Normal file
138
internal/handler/admin/polling_pause.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
128
internal/infrastructure/pollingpause/recorder.go
Normal file
128
internal/infrastructure/pollingpause/recorder.go
Normal file
@@ -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])
|
||||
}
|
||||
96
internal/model/card_polling_pause.go
Normal file
96
internal/model/card_polling_pause.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 卡轮询失败告警分页结果。
|
||||
|
||||
@@ -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过滤(只返回当前授权给该企业的设备)"`
|
||||
|
||||
@@ -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:"任务状态名称(中文)"`
|
||||
|
||||
@@ -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过滤(只返回当前授权给该企业的卡)"`
|
||||
|
||||
154
internal/model/dto/polling_pause_dto.go
Normal file
154
internal/model/dto/polling_pause_dto.go
Normal file
@@ -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:"原因,写入不轮询清单"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
122
internal/query/pollingalert/stop_refused.go
Normal file
122
internal/query/pollingalert/stop_refused.go
Normal file
@@ -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
|
||||
}
|
||||
111
internal/query/pollingpause/query.go
Normal file
111
internal/query/pollingpause/query.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
61
internal/routes/polling_pause.go
Normal file
61
internal/routes/polling_pause.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
295
internal/service/polling/polling_pause_service.go
Normal file
295
internal/service/polling/polling_pause_service.go
Normal file
@@ -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
|
||||
}
|
||||
142
internal/service/polling/polling_status_import_service.go
Normal file
142
internal/service/polling/polling_status_import_service.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 (?)",
|
||||
|
||||
@@ -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 (?)",
|
||||
|
||||
195
internal/task/polling_status_import.go
Normal file
195
internal/task/polling_status_import.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
3
migrations/000256_add_card_polling_pause.down.sql
Normal file
3
migrations/000256_add_card_polling_pause.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 回滚不轮询清单与轮询开关文件导入任务。
|
||||
DROP TABLE IF EXISTS tb_polling_status_import_task;
|
||||
DROP TABLE IF EXISTS tb_card_polling_pause;
|
||||
104
migrations/000256_add_card_polling_pause.up.sql
Normal file
104
migrations/000256_add_card_polling_pause.up.sql
Normal file
@@ -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 '逐行结果明细';
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ func CheckTypeName(checkType string) string {
|
||||
return "实名检查"
|
||||
case CheckTypePackage:
|
||||
return "套餐检查"
|
||||
case CheckTypeStopResume:
|
||||
return "停复机"
|
||||
case PollingAlertCheckTypeAll:
|
||||
return "全部"
|
||||
default:
|
||||
|
||||
132
pkg/constants/polling_pause.go
Normal file
132
pkg/constants/polling_pause.go
Normal file
@@ -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"
|
||||
@@ -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 结果
|
||||
|
||||
@@ -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格式")
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user