From 98017ac053e866e3514d2750ca1bac19ec9a7e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sat, 24 Oct 2026 17:06:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E5=B1=82=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E3=80=81=E9=87=8D=E7=BD=AE=E6=97=A5=E5=89=8D=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E4=B8=8E=E6=BF=80=E6=B4=BB=E5=89=8D=E6=B5=81=E9=87=8F=E7=BB=93?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 轮询配置新增套餐状态、资产状态、重置日前窗口与窗口内流量间隔(迁移 248),间隔三态:正数按间隔、0 不检查、空继承 - 配置按 priority, id 排序合并;在库与已销售无套餐未匹配时告警不入队 - 新增优先级整体重排、匹配预览、立即重算到期时间(异步只提前)、分层统计与重置日前加密统计接口 - 已切换日快照计量的卡流量按观测时间归属,激活前用量只记已结算 - 订单购买与实名激活补齐资产级激活锁,激活前对已切换卡结算一次流量,失败只告警不阻塞,结算不触发通道阈值判定 --- cmd/worker/main.go | 6 + internal/application/cardobservation/apply.go | 8 + .../application/cardobservation/traffic.go | 4 + .../cardobservation/traffic_calc_policy.go | 25 +- .../cardobservation/traffic_flow.go | 2 +- internal/bootstrap/services.go | 7 + internal/bootstrap/worker_services.go | 5 + internal/handler/admin/polling_config.go | 88 ++++ .../pre_activation_settlement.go | 131 ++++++ internal/model/dto/polling_config_dto.go | 208 +++++++-- internal/model/polling.go | 38 +- internal/polling/config_manager.go | 212 ++++++--- internal/polling/initializer.go | 11 +- internal/polling/pre_reset.go | 130 ++++++ internal/polling/recalculate.go | 129 ++++++ internal/polling/recalculate_task.go | 83 ++++ internal/polling/tier.go | 168 +++++++ internal/routes/polling_config.go | 56 +++ internal/service/order/pre_activation.go | 85 ++++ internal/service/order/service.go | 11 + .../service/package/activation_service.go | 65 ++- internal/service/polling/audit.go | 2 + internal/service/polling/config_service.go | 105 +++-- .../service/polling/config_tier_service.go | 419 ++++++++++++++++++ internal/service/polling/config_validation.go | 139 ++++++ .../store/postgres/package_usage_store.go | 59 +++ .../store/postgres/polling_config_store.go | 2 +- internal/task/polling_base.go | 2 +- internal/task/polling_card_cache.go | 25 +- internal/task/polling_carddata_handler.go | 1 + ...8_add_polling_config_tier_columns.down.sql | 14 + ...248_add_polling_config_tier_columns.up.sql | 28 ++ .../tasks.md | 44 +- pkg/constants/polling_tier.go | 102 +++++ 34 files changed, 2232 insertions(+), 182 deletions(-) create mode 100644 internal/infrastructure/cardobservation/pre_activation_settlement.go create mode 100644 internal/polling/pre_reset.go create mode 100644 internal/polling/recalculate.go create mode 100644 internal/polling/recalculate_task.go create mode 100644 internal/polling/tier.go create mode 100644 internal/service/order/pre_activation.go create mode 100644 internal/service/polling/config_tier_service.go create mode 100644 internal/service/polling/config_validation.go create mode 100644 migrations/000248_add_polling_config_tier_columns.down.sql create mode 100644 migrations/000248_add_polling_config_tier_columns.up.sql create mode 100644 pkg/constants/polling_tier.go diff --git a/cmd/worker/main.go b/cmd/worker/main.go index a9abad5d..e79a8c7d 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -183,6 +183,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) + recalcHandler := polling.NewRecalculateTaskHandler( + polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger), + runtime.redisClient, appLogger) + taskHandler.GetMux().HandleFunc(constants.TaskTypePollingRecalculateDue, recalcHandler.Handle) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) rescuePendingImportTasks(ctx, runtime, appLogger) @@ -311,6 +315,8 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L pollingConfigStore := postgres.NewPollingConfigStore(db) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger) + pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(db, redisClient), + postgres.NewDeviceSimBindingStore(db, redisClient), postgres.NewCarrierStore(db), appLogger)) if err := pollingConfigMgr.Load(ctx); err != nil { appLogger.Warn("加载轮询配置失败,使用空配置继续", zap.Error(err)) } diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index a061d2e2..0623d415 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -291,3 +291,11 @@ func realnameChangedEventID(cardID uint, observationID string) string { prefix := "card-realname:" return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed") } + +// TrafficSwitched 判定卡是否已切换到日快照计量(激活前流量结算只对已切换的卡执行)。 +func (s *Service) TrafficSwitched(cardID uint) bool { + if s == nil { + return false + } + return s.trafficCalc.Switched(cardID) +} diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go index f0d8d035..b5fe25ef 100644 --- a/internal/application/cardobservation/traffic.go +++ b/internal/application/cardobservation/traffic.go @@ -104,6 +104,10 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai if switched { evaluateThreshold = switchedThresholdEvaluable(flow, decision.ReadingAccepted) } + if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement { + // 激活前结算只记已结算用量,不触发停复机评估;下一次正常轮询观测照常判定。 + evaluateThreshold = false + } if evaluateThreshold && s.channelThreshold != nil { // 未切换卡只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定; // 已切换卡读数有效且未被重置日防旧值处理即判定,不再受卡表基线影响。 diff --git a/internal/application/cardobservation/traffic_calc_policy.go b/internal/application/cardobservation/traffic_calc_policy.go index 250f1154..8b51daf6 100644 --- a/internal/application/cardobservation/traffic_calc_policy.go +++ b/internal/application/cardobservation/traffic_calc_policy.go @@ -1,6 +1,8 @@ package cardobservation import ( + "time" + "context" "strconv" "strings" @@ -115,7 +117,7 @@ func (p *TrafficCalcPolicy) expandItem(ctx context.Context, db *gorm.DB, item st // cardUsesDevicePackage 与套餐扣减的载体解析同口径:卡自身没有生效中套餐、且当前绑定设备有生效中套餐时, // 卡的流量扣到设备套餐上。 func cardUsesDevicePackage(ctx context.Context, db *gorm.DB, cardID uint) (bool, error) { - ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID) + ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID, time.Time{}) if err != nil || ownActive > 0 { return false, err } @@ -123,13 +125,15 @@ func cardUsesDevicePackage(ctx context.Context, db *gorm.DB, cardID uint) (bool, if err != nil || !bound { return false, err } - deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID) + deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID, time.Time{}) return deviceActive > 0, err } // hasActivePackageForCard 判定卡本次增量是否有可扣的生效中套餐:卡自身或当前绑定设备存在生效中套餐。 -func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint) (bool, error) { - ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID) +// observedAt 非零时只认激活时间不晚于观测时间的生效套餐(流量按观测时间归属): +// 观测早于套餐激活的用量属于激活前,只记已结算、不扣新套餐;激活时间为空的历史套餐视为早已生效。 +func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint, observedAt time.Time) (bool, error) { + ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID, observedAt) if err != nil || ownActive > 0 { return ownActive > 0, err } @@ -137,16 +141,19 @@ func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint) (boo if err != nil || !bound { return false, err } - deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID) + deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID, observedAt) return deviceActive > 0, err } -func countActivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint) (int64, error) { +func countActivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint, observedAt time.Time) (int64, error) { var count int64 - err := db.WithContext(ctx).Model(&model.PackageUsage{}). + query := db.WithContext(ctx).Model(&model.PackageUsage{}). Where(carrierCondition, carrierID). - Where("status = ?", constants.PackageUsageStatusActive). - Count(&count).Error + Where("status = ?", constants.PackageUsageStatusActive) + if !observedAt.IsZero() { + query = query.Where("activated_at IS NULL OR activated_at <= ?", observedAt) + } + err := query.Count(&count).Error return count, err } diff --git a/internal/application/cardobservation/traffic_flow.go b/internal/application/cardobservation/traffic_flow.go index b380dd4b..d26c7056 100644 --- a/internal/application/cardobservation/traffic_flow.go +++ b/internal/application/cardobservation/traffic_flow.go @@ -88,7 +88,7 @@ func (s *Service) applySwitchedFlow(ctx context.Context, tx *gorm.DB, card *mode if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败") } - hasPackage, pkgErr := hasActivePackageForCard(ctx, tx, card.ID) + hasPackage, pkgErr := hasActivePackageForCard(ctx, tx, card.ID, observation.Metadata.ObservedAt) if pkgErr != nil { // 判断出错按有套餐处理,保持不丢量。 s.warnLogger().Warn("判断卡是否有生效中套餐失败,按有套餐处理", zap.Uint("card_id", card.ID), zap.Error(pkgErr)) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index a97ac9be..48eb4928 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -226,6 +226,8 @@ func initServices(s *stores, deps *Dependencies) *services { // 使用 PollingLifecycleService 替代 APICallback:通过分片队列准确操作(修复 api_callback.go 遗漏 protect 队列的 Bug3) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger) + pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(deps.DB, deps.Redis), + postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), postgres.NewCarrierStore(deps.DB), deps.Logger)) if err := pollingConfigMgr.Load(context.Background()); err != nil { deps.Logger.Warn("API 进程加载轮询配置失败", zap.Error(err)) } @@ -250,6 +252,9 @@ func initServices(s *stores, deps *Dependencies) *services { packageActivation.SetLifecycleAudit(auditWriter) packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents) packageActivation.SetPriorityEventWriter(priorityEvents) + // 激活前流量结算:资产从无生效主套餐变为有生效主套餐前,对已切换日快照计量的卡查一次流量入账。 + preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger) + packageActivation.SetPreActivationSettler(preActivationSettler) stopResumeService := iotCardSvc.NewStopResumeService( deps.Redis, @@ -310,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services { // 员工代收款建账用例在订单、充值入账与退款冲销的事务内复用同一实例。 employeeCollectionBillCreation := employeecollectionApp.NewBillCreationService(auditWriter) orderService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation) + orderService.SetPreActivationSettler(preActivationSettler) orderService.SetResumeCallback(stopResumeService) orderService.SetLifecycleAudit(auditWriter) orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) @@ -468,6 +474,7 @@ func initServices(s *stores, deps *Dependencies) *services { commissionCalculation.SetAuditWriter(auditWriter) pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger) pollingConfigService.SetAudit(deps.DB, auditWriter) + pollingConfigService.SetTierDeps(pollingConfigMgr, deps.QueueClient) pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis) pollingConcurrencyService.SetAudit(deps.DB, auditWriter) pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger) diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 337eb0ff..5cb3475d 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/config" @@ -129,6 +130,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q cardObservationService.SetTrafficCalcPolicy( cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger), deps.Logger) } + // 激活前流量结算:与 API 进程一致,注入激活服务与订单服务。 + preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger) + activationService.SetPreActivationSettler(preActivationSettler) cardObservationIntegration := integrationlog.NewRepository(deps.DB) cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService( @@ -166,6 +170,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q ) // 批量订购线下支付建单需在订单事务内生成员工代收款账单,与 API 进程保持一致。 orderService.SetEmployeeCollectionBillCreation(employeecollectionApp.NewBillCreationService(auditWriter)) + orderService.SetPreActivationSettler(preActivationSettler) orderService.SetLifecycleAudit(auditWriter) orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) walletOutbox := outbox.NewRepository() diff --git a/internal/handler/admin/polling_config.go b/internal/handler/admin/polling_config.go index 8138ccb1..9762dcbd 100644 --- a/internal/handler/admin/polling_config.go +++ b/internal/handler/admin/polling_config.go @@ -191,3 +191,91 @@ func (h *PollingConfigHandler) ListEnabled(c *fiber.Ctx) error { return response.Success(c, configs) } + +// UpdatePriorities 按给定顺序整体调整轮询配置优先级(仅超级管理员)。 +// PUT /api/admin/polling-configs/priorities +func (h *PollingConfigHandler) UpdatePriorities(c *fiber.Ctx) error { + var req dto.UpdatePollingConfigPrioritiesRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if len(req.OrderedIDs) == 0 { + return errors.New(errors.CodeInvalidParam, "轮询配置优先级参数不合法:配置 ID 列表不能为空") + } + if err := h.service.UpdatePriorities(c.UserContext(), &req); err != nil { + return err + } + return response.Success(c, nil) +} + +// MatchPreview 预览一张卡的轮询配置匹配结果;GET 只按当前配置,POST 可带未保存修改,均不写数据。 +// GET /api/admin/polling-configs/match-preview +// POST /api/admin/polling-configs/match-preview +func (h *PollingConfigHandler) MatchPreview(c *fiber.Ctx) error { + var req dto.PollingConfigMatchPreviewRequest + if c.Method() == fiber.MethodPost { + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + } else if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if req.ICCID == "" { + return errors.New(errors.CodeInvalidParam, "匹配预览参数不合法:ICCID 不能为空") + } + result, err := h.service.MatchPreview(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateRecalculateTask 创建"立即重算到期时间"异步任务(仅超级管理员)。 +// POST /api/admin/polling-configs/recalculate-due +func (h *PollingConfigHandler) CreateRecalculateTask(c *fiber.Ctx) error { + var req dto.PollingRecalculateDueRequest + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + } + result, err := h.service.CreateRecalculateTask(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetRecalculateTask 查询重算到期时间任务进度。 +// GET /api/admin/polling-configs/recalculate-due/:task_id +func (h *PollingConfigHandler) GetRecalculateTask(c *fiber.Ctx) error { + taskID := c.Params("task_id") + if taskID == "" { + return errors.New(errors.CodeInvalidParam, "重算任务 ID 不能为空") + } + result, err := h.service.GetRecalculateTask(c.UserContext(), taskID) + if err != nil { + return err + } + return response.Success(c, result) +} + +// TierStats 查询分层轮询统计(结果缓存 5 分钟)。 +// GET /api/admin/polling-stats/tiers +func (h *PollingConfigHandler) TierStats(c *fiber.Ctx) error { + result, err := h.service.TierStats(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, result) +} + +// PreResetStats 查询重置日前加密效果统计。 +// GET /api/admin/polling-stats/pre-reset +func (h *PollingConfigHandler) PreResetStats(c *fiber.Ctx) error { + result, err := h.service.PreResetStats(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/cardobservation/pre_activation_settlement.go b/internal/infrastructure/cardobservation/pre_activation_settlement.go new file mode 100644 index 00000000..0373437a --- /dev/null +++ b/internal/infrastructure/cardobservation/pre_activation_settlement.go @@ -0,0 +1,131 @@ +package cardobservation + +import ( + "context" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// PreActivationSettler 在资产从"无生效主套餐"变为"有生效主套餐"之前,对已切换日快照计量的卡查一次流量并按现有观测入账。 +// 此时资产无生效套餐,增量只记为已结算,不扣新套餐、不产生流量扣减事件,因此也不会触发停复机评估。 +// 结算尽力而为:查询失败、限流、超时只告警,不阻塞激活。 +type PreActivationSettler struct { + db *gorm.DB + gateway *gateway.Client + observation *cardapp.Service + carrier *postgres.CarrierStore + logger *zap.Logger +} + +// NewPreActivationSettler 创建激活前流量结算器。 +func NewPreActivationSettler(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, carrier *postgres.CarrierStore, logger *zap.Logger) *PreActivationSettler { + if logger == nil { + logger = zap.NewNop() + } + return &PreActivationSettler{db: db, gateway: gatewayClient, observation: observation, carrier: carrier, logger: logger} +} + +// SettleBeforeActivation 对载体(卡或设备)执行激活前流量结算;资产已有生效主套餐时直接返回。 +func (s *PreActivationSettler) SettleBeforeActivation(ctx context.Context, carrierType string, carrierID uint) { + if s == nil || s.db == nil || s.gateway == nil || s.observation == nil { + return + } + hasMain, err := s.hasActiveMainPackage(ctx, carrierType, carrierID) + if err != nil { + s.logger.Warn("激活前流量结算:查询生效主套餐失败,跳过结算", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) + return + } + if hasMain { + return + } + cards, err := s.cardsOf(ctx, carrierType, carrierID) + if err != nil { + s.logger.Warn("激活前流量结算:查询载体下卡失败,跳过结算", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) + return + } + deadline := time.Now().Add(constants.PreActivationSettlementTotalTimeoutSeconds * time.Second) + for _, card := range cards { + if !s.observation.TrafficSwitched(card.ID) { + continue + } + if time.Now().After(deadline) { + s.alert(card, carrierType, carrierID, "整次结算达到总超时,跳过该卡") + continue + } + s.settleCard(ctx, card, carrierType, carrierID, deadline) + } +} + +func (s *PreActivationSettler) settleCard(ctx context.Context, card *model.IotCard, carrierType string, carrierID uint, deadline time.Time) { + stepDeadline := time.Now().Add(constants.PreActivationSettlementStepTimeoutSeconds * time.Second) + if stepDeadline.After(deadline) { + stepDeadline = deadline + } + stepCtx, cancel := context.WithDeadline(context.WithoutCancel(ctx), stepDeadline) + defer cancel() + response, err := s.gateway.QueryFlow(gateway.WithCarrier(stepCtx, card.CarrierID), &gateway.FlowQueryReq{CardNo: card.ICCID}) + if err != nil { + reason := "查询流量失败" + if gateway.IsRateLimited(err) { + reason = "运营商账户网关限流" + } + s.alert(card, carrierType, carrierID, reason) + return + } + resetDay := 1 + if s.carrier != nil { + resetDay = s.carrier.GetDataResetDay(stepCtx, card.CarrierID) + } + observedAt := time.Now().UTC() + _, err = s.observation.ApplyTrafficObservation(stepCtx, carddomain.TrafficObservation{ + CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: resetDay, + Metadata: carddomain.ObservationMetadata{ + ObservationID: "pre-activation-" + card.ICCID + "-" + observedAt.Format("20060102150405.000"), + Source: constants.CardObservationSourceBusinessEvent, Scene: constants.CardObservationScenePreActivationSettlement, + ObservedAt: observedAt, + }, + }) + if err != nil { + s.alert(card, carrierType, carrierID, "流量结算入账失败") + } +} + +// alert 写激活前结算失败告警:该卡本月可能多计无套餐期间用量,由运营核对。 +func (s *PreActivationSettler) alert(card *model.IotCard, carrierType string, carrierID uint, reason string) { + s.logger.Error("激活前流量结算未完成,该卡本月可能多计无套餐期间用量,请运营核对", + zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.String("carrier_type", carrierType), + zap.Uint("asset_id", carrierID), zap.String("reason", reason), zap.String("metric", "package.pre_activation_settlement.failed")) +} + +func (s *PreActivationSettler) hasActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) { + column := "iot_card_id" + if carrierType == constants.AssetTypeDevice { + column = "device_id" + } + var count int64 + err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Where(column+" = ? AND status = ? AND master_usage_id IS NULL", carrierID, constants.PackageUsageStatusActive). + Count(&count).Error + return count > 0, err +} + +func (s *PreActivationSettler) cardsOf(ctx context.Context, carrierType string, carrierID uint) ([]*model.IotCard, error) { + var cards []*model.IotCard + if carrierType == constants.AssetTypeDevice { + err := s.db.WithContext(ctx).Model(&model.IotCard{}). + Joins("JOIN tb_device_sim_binding b ON b.iot_card_id = tb_iot_card.id AND b.bind_status = 1 AND b.deleted_at IS NULL"). + Where("b.device_id = ?", carrierID).Find(&cards).Error + return cards, err + } + err := s.db.WithContext(ctx).Where("id = ?", carrierID).Find(&cards).Error + return cards, err +} diff --git a/internal/model/dto/polling_config_dto.go b/internal/model/dto/polling_config_dto.go index 7d05ff53..6eb0372f 100644 --- a/internal/model/dto/polling_config_dto.go +++ b/internal/model/dto/polling_config_dto.go @@ -2,32 +2,41 @@ package dto // CreatePollingConfigRequest 创建轮询配置请求 type CreatePollingConfigRequest struct { - ConfigName string `json:"config_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"` - CardCondition string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` - CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` - CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID(可选,精确匹配)"` - Priority int `json:"priority" validate:"required,min=1,max=1000" required:"true" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"` - RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔(秒),NULL表示不检查,最小30秒"` - CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔(秒),NULL表示不检查,最小60秒"` - PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔(秒),NULL表示不检查,最小60秒"` - ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔(秒),NULL表示不检查,最小30秒"` - CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=30" minimum:"30" description:"卡状态检查间隔(秒),NULL表示不检查,最小30秒"` - Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"` + ConfigName string `json:"config_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"` + CardCondition string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` + CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` + CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID(可选,精确匹配)"` + Priority int `json:"priority" validate:"required,min=1,max=1000" required:"true" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"` + RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=0" minimum:"0" description:"实名检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=0" minimum:"0" description:"流量检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=0" minimum:"0" description:"套餐检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔(秒),NULL表示不检查,最小30秒"` + CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=0" minimum:"0" description:"卡状态检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + PackageState *string `json:"package_state" validate:"omitempty,oneof=active depleted pending none" enum:"active,depleted,pending,none" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"` + AssetStatus *int `json:"asset_status" validate:"omitempty,oneof=1 2 3 4" enum:"1,2,3,4" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"` + PreResetWindow *int `json:"pre_reset_window" validate:"omitempty,min=60" minimum:"60" description:"重置日前加密窗口长度(秒),与窗口内流量间隔同时配置才生效"` + PreResetCarddataInterval *int `json:"pre_reset_carddata_interval" validate:"omitempty,min=60" minimum:"60" description:"重置日前窗口内的流量检查间隔(秒),与窗口长度同时配置才生效"` + Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"` } // UpdatePollingConfigRequest 更新轮询配置请求 type UpdatePollingConfigRequest struct { - ConfigName *string `json:"config_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"` - CardCondition *string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` - CardCategory *string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` - CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID(可选,精确匹配)"` - Priority *int `json:"priority" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"` - RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔(秒),NULL表示不检查,最小30秒"` - CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔(秒),NULL表示不检查,最小60秒"` - PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔(秒),NULL表示不检查,最小60秒"` - ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔(秒),NULL表示不检查,最小30秒"` - CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=30" minimum:"30" description:"卡状态检查间隔(秒),NULL表示不检查,最小30秒"` - Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"` + ConfigName *string `json:"config_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"` + CardCondition *string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` + CardCategory *string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` + CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID(可选,精确匹配)"` + Priority *int `json:"priority" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"` + RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=0" minimum:"0" description:"实名检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=0" minimum:"0" description:"流量检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=0" minimum:"0" description:"套餐检查间隔(秒):正数按间隔(最小60秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + ProtectCheckInterval *int `json:"protect_check_interval" validate:"omitempty,min=30" minimum:"30" description:"保护期一致性检查间隔(秒),NULL表示不检查,最小30秒"` + CardStatusCheckInterval *int `json:"card_status_check_interval" validate:"omitempty,min=0" minimum:"0" description:"卡状态检查间隔(秒):正数按间隔(最小30秒),0 不检查且不再继承,不传或为空继承优先级更低的配置"` + PackageState *string `json:"package_state" validate:"omitempty,oneof=active depleted pending none" enum:"active,depleted,pending,none" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"` + AssetStatus *int `json:"asset_status" validate:"omitempty,oneof=1 2 3 4" enum:"1,2,3,4" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"` + PreResetWindow *int `json:"pre_reset_window" validate:"omitempty,min=60" minimum:"60" description:"重置日前加密窗口长度(秒),与窗口内流量间隔同时配置才生效"` + PreResetCarddataInterval *int `json:"pre_reset_carddata_interval" validate:"omitempty,min=60" minimum:"60" description:"重置日前窗口内的流量检查间隔(秒),与窗口长度同时配置才生效"` + ClearFields []string `json:"clear_fields" validate:"omitempty,dive,oneof=realname_check_interval carddata_check_interval package_check_interval card_status_check_interval carrier_id package_state asset_status pre_reset_window pre_reset_carddata_interval" description:"要清空为空值的字段(间隔清空即改为继承,匹配条件清空即不限):realname_check_interval/carddata_check_interval/package_check_interval/card_status_check_interval/carrier_id/package_state/asset_status/pre_reset_window/pre_reset_carddata_interval"` + Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"` } // PollingConfigListRequest 轮询配置列表请求 @@ -48,21 +57,26 @@ type UpdatePollingConfigStatusRequest struct { // PollingConfigResponse 轮询配置响应 type PollingConfigResponse struct { - ID uint `json:"id" description:"配置ID"` - ConfigName string `json:"config_name" description:"配置名称"` - CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` - CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` - CarrierID *uint `json:"carrier_id" description:"运营商ID"` - Priority int `json:"priority" description:"优先级(数字越小优先级越高)"` - RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔(秒),NULL表示不检查"` - CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔(秒),NULL表示不检查"` - PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔(秒),NULL表示不检查"` - ProtectCheckInterval *int `json:"protect_check_interval" description:"保护期一致性检查间隔(秒),NULL表示不检查"` - CardStatusCheckInterval *int `json:"card_status_check_interval" description:"卡状态检查间隔(秒),NULL表示不检查"` - Status int16 `json:"status" description:"状态 (1:启用, 0:禁用)"` - Description string `json:"description" description:"配置说明"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"配置ID"` + ConfigName string `json:"config_name" description:"配置名称"` + CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"` + CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` + CarrierID *uint `json:"carrier_id" description:"运营商ID"` + Priority int `json:"priority" description:"优先级(数字越小优先级越高)"` + RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔(秒):正数按间隔,0 不检查,空继承"` + CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔(秒):正数按间隔,0 不检查,空继承"` + PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔(秒):正数按间隔,0 不检查,空继承"` + ProtectCheckInterval *int `json:"protect_check_interval" description:"保护期一致性检查间隔(秒),NULL表示不检查"` + CardStatusCheckInterval *int `json:"card_status_check_interval" description:"卡状态检查间隔(秒):正数按间隔,0 不检查,空继承"` + PackageState *string `json:"package_state" description:"套餐状态匹配 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),为空不限"` + AssetStatus *int `json:"asset_status" description:"资产状态匹配 (1:在库, 2:已销售, 3:已换货, 4:已停用),为空不限"` + PreResetWindow *int `json:"pre_reset_window" description:"重置日前加密窗口长度(秒)"` + PreResetCarddataInterval *int `json:"pre_reset_carddata_interval" description:"重置日前窗口内的流量检查间隔(秒)"` + Warnings []string `json:"warnings,omitempty" description:"保存提示(如与其他启用配置优先级相同且匹配条件可能重叠),不影响保存结果"` + Status int16 `json:"status" description:"状态 (1:启用, 0:禁用)"` + Description string `json:"description" description:"配置说明"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // UpdatePollingConfigParams 更新轮询配置参数 @@ -84,3 +98,123 @@ type PollingConfigPageResult struct { Page int `json:"page" description:"当前页"` PageSize int `json:"size" description:"每页数量"` } + +// UpdatePollingConfigPrioritiesRequest 整体调整轮询配置优先级请求 +type UpdatePollingConfigPrioritiesRequest struct { + OrderedIDs []uint `json:"ordered_ids" validate:"required,min=1,max=1000" required:"true" minItems:"1" description:"按期望生效顺序排列的配置 ID 列表(排在前面的优先级更高);系统按顺序把优先级重排为 1、2、3…,未列出的配置保持原优先级"` +} + +// PollingConfigOverride 匹配预览中的未保存修改;ID 为 0 表示新增草稿,Disabled 为 true 表示假设该配置停用 +type PollingConfigOverride struct { + ID uint `json:"id" description:"要覆盖的配置 ID,0 表示新增一条草稿配置"` + Disabled bool `json:"disabled" description:"假设该配置停用(不参与匹配)"` + CreatePollingConfigRequest +} + +// PollingConfigMatchPreviewRequest 匹配预览请求 +type PollingConfigMatchPreviewRequest struct { + ICCID string `json:"iccid" query:"iccid" validate:"required,max=30" required:"true" maxLength:"30" description:"要预览的卡 ICCID"` + Overrides []PollingConfigOverride `json:"overrides" description:"未保存的修改(仅 POST 支持),不写任何数据"` +} + +// PollingCardMatchFacts 卡的匹配维度取值 +type PollingCardMatchFacts struct { + CardID uint `json:"card_id" description:"IoT卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, activated:已激活, suspended:已停用)"` + CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"` + CarrierID uint `json:"carrier_id" description:"运营商账户ID"` + AssetStatus int `json:"asset_status" description:"资产状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"` + PackageState string `json:"package_state" description:"套餐状态 (active:有生效套餐, depleted:套餐已用完, pending:只有待生效套餐, none:无套餐),解析失败为空"` + PackageStateName string `json:"package_state_name" description:"套餐状态名称"` + EnablePolling bool `json:"enable_polling" description:"是否开启轮询"` +} + +// PollingConfigBrief 匹配到的配置摘要 +type PollingConfigBrief struct { + ID uint `json:"id" description:"配置ID"` + ConfigName string `json:"config_name" description:"配置名称"` + Priority int `json:"priority" description:"优先级"` + Draft bool `json:"draft" description:"是否为预览中的未保存配置"` +} + +// PollingIntervalPreview 某检查类型最终采用的间隔与来源 +type PollingIntervalPreview struct { + TaskType string `json:"task_type" description:"检查类型 (polling:realname:实名, polling:carddata:流量, polling:package:套餐, polling:card_status:卡状态, polling:protect:保护期)"` + TaskTypeName string `json:"task_type_name" description:"检查类型名称"` + Mode string `json:"mode" description:"结果 (interval:按间隔检查, disabled:不检查, unmatched:没有任何配置给出取值,不检查)"` + Interval int `json:"interval" description:"间隔(秒),仅 mode=interval 时有值"` + ConfigID uint `json:"config_id" description:"给出取值的配置ID"` + ConfigName string `json:"config_name" description:"给出取值的配置名称"` + Inherited bool `json:"inherited" description:"是否继承自非首个命中的配置(首个命中配置该项为空)"` +} + +// PollingConfigMatchPreviewResponse 匹配预览响应 +type PollingConfigMatchPreviewResponse struct { + Card PollingCardMatchFacts `json:"card" description:"卡的匹配维度取值"` + Matched []PollingConfigBrief `json:"matched" description:"命中的配置(按优先级、ID 升序)"` + Intervals []PollingIntervalPreview `json:"intervals" description:"各检查类型最终结果"` +} + +// PollingRecalculateDueRequest 立即重算到期时间请求 +type PollingRecalculateDueRequest struct { + CarrierID *uint `json:"carrier_id" description:"只处理该运营商账户的卡,为空处理全部开启轮询的卡"` +} + +// PollingRecalculateDueTaskIDRequest 重算任务进度查询路径参数 +type PollingRecalculateDueTaskIDRequest struct { + TaskID string `json:"-" path:"task_id" required:"true" description:"重算任务ID"` +} + +// PollingRecalculateDueResponse 重算到期时间任务进度 +type PollingRecalculateDueResponse struct { + TaskID string `json:"task_id" description:"重算任务ID"` + Status string `json:"status" description:"状态 (pending:已入队, running:执行中, completed:已完成, failed:失败)"` + CarrierID string `json:"carrier_id" description:"限定的运营商账户ID,为空表示全部"` + Total int `json:"total" description:"需处理的卡数"` + Processed int `json:"processed" description:"已处理的卡数"` + Advanced int `json:"advanced" description:"被提前的检查项数"` + Error string `json:"error" description:"失败原因"` + CreatedAt string `json:"created_at" description:"创建时间"` + StartedAt string `json:"started_at" description:"开始时间"` + FinishedAt string `json:"finished_at" description:"结束时间"` +} + +// PollingTierStatItem 按命中配置统计的卡数 +type PollingTierStatItem struct { + ConfigID uint `json:"config_id" description:"首个命中的配置ID,0 表示未匹配任何配置"` + ConfigName string `json:"config_name" description:"首个命中的配置名称"` + Cards int `json:"cards" description:"卡数"` +} + +// PollingTierStatsResponse 分层统计响应 +type PollingTierStatsResponse struct { + TotalCards int `json:"total_cards" description:"开启轮询的卡数"` + ByConfig []PollingTierStatItem `json:"by_config" description:"按首个命中配置统计"` + ByPackageState map[string]int `json:"by_package_state" description:"按套餐状态统计 (active/depleted/pending/none/unknown)"` + UnmatchedInStock int `json:"unmatched_in_stock" description:"未匹配任何配置的在库卡数(应告警)"` + UnmatchedSoldNoPackage int `json:"unmatched_sold_no_package" description:"未匹配任何配置的已销售无套餐卡数(应告警)"` + ComputedAt string `json:"computed_at" description:"统计时间(结果缓存 5 分钟)"` +} + +// PollingPreResetCycleStat 某运营商账户某个重置周期的加密效果 +type PollingPreResetCycleStat struct { + ResetAt string `json:"reset_at" description:"重置时刻(上海时区)"` + ActivePackageCards int `json:"active_package_cards" description:"当前有生效套餐的轮询卡数"` + CheckedInWindow int64 `json:"checked_in_window" description:"窗口内查到过流量的卡数(近似去重计数)"` + NotCheckedInWindow int64 `json:"not_checked_in_window" description:"有生效套餐但窗口内未查到流量的卡数(近似,最小为 0)"` + LastOffsetBuckets map[string]int `json:"last_offset_buckets" description:"每张卡窗口内最后一次查询距重置时刻的分布(分钟):le_5/le_15/le_30/le_60/gt_60"` +} + +// PollingPreResetCarrierStat 某运营商账户的重置日前加密效果 +type PollingPreResetCarrierStat struct { + CarrierID uint `json:"carrier_id" description:"运营商账户ID"` + CarrierName string `json:"carrier_name" description:"运营商账户名称"` + Previous PollingPreResetCycleStat `json:"previous" description:"最近一次已过去的重置周期"` + Upcoming PollingPreResetCycleStat `json:"upcoming" description:"即将到来的重置周期(窗口未开始时计数为 0)"` +} + +// PollingPreResetStatsResponse 重置日前加密效果统计响应 +type PollingPreResetStatsResponse struct { + Carriers []PollingPreResetCarrierStat `json:"carriers" description:"有开启轮询卡的运营商账户"` +} diff --git a/internal/model/polling.go b/internal/model/polling.go index 29d50fb2..0e743888 100644 --- a/internal/model/polling.go +++ b/internal/model/polling.go @@ -8,23 +8,27 @@ import ( // PollingConfig 轮询配置表 type PollingConfig struct { - ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"` - CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件:not_real_name/real_name/activated/suspended" json:"card_condition"` - CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型:normal/industry" json:"card_category"` - CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID(可选,精确匹配)" json:"carrier_id"` - Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"` - RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔(秒),NULL表示不检查" json:"realname_check_interval"` - CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔(秒),NULL表示不检查" json:"carddata_check_interval"` - PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔(秒),NULL表示不检查" json:"package_check_interval"` - ProtectCheckInterval *int `gorm:"column:protect_check_interval;comment:保护期一致性检查间隔(秒),NULL=不参与" json:"protect_check_interval"` - CardStatusCheckInterval *int `gorm:"column:card_status_check_interval;comment:卡状态检查间隔(秒),NULL表示不启用卡状态轮询" json:"card_status_check_interval"` - Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态:0-禁用,1-启用" json:"status"` - Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"` - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` - CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"` - UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"` + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"` + CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件:not_real_name/real_name/activated/suspended" json:"card_condition"` + CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型:normal/industry" json:"card_category"` + CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID(可选,精确匹配)" json:"carrier_id"` + Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"` + RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"realname_check_interval"` + CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"carddata_check_interval"` + PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"package_check_interval"` + ProtectCheckInterval *int `gorm:"column:protect_check_interval;comment:保护期一致性检查间隔(秒),NULL=不参与" json:"protect_check_interval"` + CardStatusCheckInterval *int `gorm:"column:card_status_check_interval;comment:卡状态检查间隔(秒):正数按间隔,0不检查(停止继承),NULL继承" json:"card_status_check_interval"` + PackageState *string `gorm:"column:package_state;type:varchar(20);comment:套餐状态匹配:active/depleted/pending/none,NULL不限" json:"package_state"` + AssetStatus *int `gorm:"column:asset_status;type:smallint;comment:资产状态匹配:1在库 2已销售 3已换货 4已停用,NULL不限" json:"asset_status"` + PreResetWindow *int `gorm:"column:pre_reset_window;comment:重置日前加密窗口长度(秒),NULL不加密" json:"pre_reset_window"` + PreResetCarddataInterval *int `gorm:"column:pre_reset_carddata_interval;comment:重置日前窗口内流量检查间隔(秒),NULL不加密" json:"pre_reset_carddata_interval"` + Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态:0-禁用,1-启用" json:"status"` + Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` + CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"` + UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"` } // TableName 指定表名 diff --git a/internal/polling/config_manager.go b/internal/polling/config_manager.go index 2d5f15b8..d97ef511 100644 --- a/internal/polling/config_manager.go +++ b/internal/polling/config_manager.go @@ -25,12 +25,24 @@ type PollingConfigManager struct { mu sync.RWMutex configs []*model.PollingConfig refreshOnce sync.Once + tier *TierResolver +} + +// SetTierResolver 注入分层事实解析器;未注入时要求套餐状态的配置不匹配。 +func (m *PollingConfigManager) SetTierResolver(tier *TierResolver) { + m.tier = tier +} + +// TierResolver 返回已注入的分层事实解析器。 +func (m *PollingConfigManager) TierResolver() *TierResolver { + return m.tier } // TaskTypeInterval 某任务类型的轮询间隔信息 type TaskTypeInterval struct { - Interval int // 秒 - Priority int // 来源配置的 priority + Interval int // 秒 + Priority int // 来源配置的 priority + ConfigID uint // 来源配置 ID } // NewPollingConfigManager 创建配置管理器 @@ -149,74 +161,158 @@ func (m *PollingConfigManager) MatchConfig(card *model.IotCard) *model.PollingCo return configs[0] } -// MatchConfigs 按 priority ASC 遍历所有启用的配置,返回所有满足匹配条件且至少有一个非 NULL interval 的配置 -// 无匹配时返回空切片 +// MatchConfigs 按 priority、id 升序遍历所有启用的配置,返回满足匹配条件且至少配置了一种检查间隔(含 0 不检查)的配置。 +// 无匹配时返回空切片。 func (m *PollingConfigManager) MatchConfigs(card *model.IotCard) []*model.PollingConfig { m.mu.RLock() - defer m.mu.RUnlock() + configs := m.configs + m.mu.RUnlock() + return m.matchAgainst(configs, card) +} +func (m *PollingConfigManager) matchAgainst(configs []*model.PollingConfig, card *model.IotCard) []*model.PollingConfig { + facts := m.lazyTierFacts(card) var result []*model.PollingConfig - for _, cfg := range m.configs { - if matchConfigConditions(cfg, card) && hasAnyEnabledInterval(cfg) { + for _, cfg := range configs { + if matchConfigConditions(cfg, card, facts) && hasAnyConfiguredInterval(cfg) { result = append(result, cfg) } } return result } -// hasAnyEnabledInterval 检查配置是否有至少一个非 NULL 且大于 0 的轮询间隔 -func hasAnyEnabledInterval(cfg *model.PollingConfig) bool { - return (cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0) || - (cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0) || - (cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0) || - (cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) || - (cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0) +// lazyTierFacts 只在某个配置要求套餐状态时才解析卡的分层事实,避免无分层配置时额外查库。 +func (m *PollingConfigManager) lazyTierFacts(card *model.IotCard) func() TierFacts { + var once sync.Once + var facts TierFacts + return func() TierFacts { + once.Do(func() { + if m.tier != nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + facts = m.tier.Resolve(ctx, card) + } + }) + return facts + } } -// MergedTaskIntervals 对所有匹配配置按 priority 遍历,对每种 task type 选取最高优先级的非 nil interval +// hasAnyConfiguredInterval 检查配置是否至少配置了一种检查间隔(正数或 0)。 +func hasAnyConfiguredInterval(cfg *model.PollingConfig) bool { + return cfg.RealnameCheckInterval != nil || cfg.CarddataCheckInterval != nil || cfg.PackageCheckInterval != nil || + (cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) || cfg.CardStatusCheckInterval != nil +} + +// IntervalSource 是某检查类型最终采用的间隔及其来源,供合并结果与匹配预览共用。 +type IntervalSource struct { + TaskType string + Interval int // 秒;Disabled 为 true 时为 0 + Disabled bool // 命中取值 0(不检查) + ConfigID uint // 来源配置;未匹配时为 0 + Priority int + Config *model.PollingConfig +} + +// taskIntervalFields 返回配置中各检查类型的间隔字段(保护期按既有语义只认正数)。 +func taskIntervalFields(cfg *model.PollingConfig) []struct { + taskType string + value *int +} { + protect := cfg.ProtectCheckInterval + if protect != nil && *protect <= 0 { + protect = nil + } + return []struct { + taskType string + value *int + }{ + {constants.TaskTypePollingRealname, cfg.RealnameCheckInterval}, + {constants.TaskTypePollingCarddata, cfg.CarddataCheckInterval}, + {constants.TaskTypePollingPackage, cfg.PackageCheckInterval}, + {constants.TaskTypePollingProtect, protect}, + {constants.TaskTypePollingCardStatus, cfg.CardStatusCheckInterval}, + } +} + +// ResolveIntervalSources 按三态规则合并匹配配置:每种检查类型取第一个非空取值,正数按间隔、0 不检查且停止向下查找、空继承。 +func ResolveIntervalSources(configs []*model.PollingConfig) map[string]IntervalSource { + result := make(map[string]IntervalSource) + for _, cfg := range configs { + for _, field := range taskIntervalFields(cfg) { + if field.value == nil { + continue + } + if _, exists := result[field.taskType]; exists { + continue + } + source := IntervalSource{TaskType: field.taskType, ConfigID: cfg.ID, Priority: cfg.Priority, Config: cfg} + if *field.value <= 0 { + source.Disabled = true + } else { + source.Interval = *field.value + } + result[field.taskType] = source + } + } + return result +} + +// MergedTaskIntervals 返回卡各检查类型最终采用的正数间隔;取值 0(不检查)的类型不出现在结果中。 // 返回 map: taskType → TaskTypeInterval{Interval, Priority} func (m *PollingConfigManager) MergedTaskIntervals(card *model.IotCard) map[string]TaskTypeInterval { - configs := m.MatchConfigs(card) - merged := make(map[string]TaskTypeInterval) - - for _, cfg := range configs { - priority := int(cfg.Priority) - if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 { - taskType := constants.TaskTypePollingRealname - if _, exists := merged[taskType]; !exists { - merged[taskType] = TaskTypeInterval{Interval: *cfg.RealnameCheckInterval, Priority: priority} - } - } - if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 { - taskType := constants.TaskTypePollingCarddata - if _, exists := merged[taskType]; !exists { - merged[taskType] = TaskTypeInterval{Interval: *cfg.CarddataCheckInterval, Priority: priority} - } - } - if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 { - taskType := constants.TaskTypePollingPackage - if _, exists := merged[taskType]; !exists { - merged[taskType] = TaskTypeInterval{Interval: *cfg.PackageCheckInterval, Priority: priority} - } - } - if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 { - taskType := constants.TaskTypePollingProtect - if _, exists := merged[taskType]; !exists { - merged[taskType] = TaskTypeInterval{Interval: *cfg.ProtectCheckInterval, Priority: priority} - } - } - if cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0 { - taskType := constants.TaskTypePollingCardStatus - if _, exists := merged[taskType]; !exists { - merged[taskType] = TaskTypeInterval{Interval: *cfg.CardStatusCheckInterval, Priority: priority} - } + sources := ResolveIntervalSources(m.MatchConfigs(card)) + merged := make(map[string]TaskTypeInterval, len(sources)) + for taskType, source := range sources { + if source.Disabled { + continue } + merged[taskType] = TaskTypeInterval{Interval: source.Interval, Priority: source.Priority, ConfigID: source.ConfigID} + } + if len(sources) == 0 { + m.warnUnmatchedFallback(card) } return merged } -// matchConfigConditions 检查卡是否满足配置的匹配条件 -func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool { +// PreviewIntervalSources 用给定配置集合(可含未保存修改)计算卡的匹配配置与最终间隔,只读。 +func (m *PollingConfigManager) PreviewIntervalSources(configs []*model.PollingConfig, card *model.IotCard) ([]*model.PollingConfig, map[string]IntervalSource) { + matched := m.matchAgainst(configs, card) + return matched, ResolveIntervalSources(matched) +} + +// EnabledConfigs 返回当前内存中的启用配置快照(已按 priority、id 排序)。 +func (m *PollingConfigManager) EnabledConfigs() []*model.PollingConfig { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*model.PollingConfig, len(m.configs)) + copy(out, m.configs) + return out +} + +// TierFactsOf 返回卡的分层事实(未配置解析器时 Known 为 false)。 +func (m *PollingConfigManager) TierFactsOf(card *model.IotCard) TierFacts { + return m.lazyTierFacts(card)() +} + +// warnUnmatchedFallback 已销售无套餐、在库的卡未匹配任何配置时告警:这两类卡依赖卡状态检查兜底开机即停。 +func (m *PollingConfigManager) warnUnmatchedFallback(card *model.IotCard) { + if card == nil || m.logger == nil { + return + } + if card.AssetStatus == constants.AssetStatusInStock { + m.logger.Warn("在库卡未匹配任何轮询配置,将不入队", zap.Uint("card_id", card.ID), zap.String("metric", "polling.tier.unmatched_in_stock")) + return + } + if card.AssetStatus == constants.AssetStatusSold { + facts := m.TierFactsOf(card) + if facts.Known && facts.PackageState == constants.PollingPackageStateNone { + m.logger.Warn("已销售无套餐卡未匹配任何轮询配置,将不入队", zap.Uint("card_id", card.ID), zap.String("metric", "polling.tier.unmatched_sold_no_package")) + } + } +} + +// matchConfigConditions 检查卡是否满足配置的匹配条件;套餐状态仅在配置要求时解析,解析失败视为不匹配。 +func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard, facts func() TierFacts) bool { if cfg.CardCondition != "" { if cfg.CardCondition != getCardCondition(card) { return false @@ -232,9 +328,23 @@ func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool { return false } } + if cfg.AssetStatus != nil && *cfg.AssetStatus != card.AssetStatus { + return false + } + if cfg.PackageState != nil && *cfg.PackageState != "" { + f := facts() + if !f.Known || f.PackageState != *cfg.PackageState { + return false + } + } return true } +// CardConditionOf 返回卡用于匹配轮询配置的状态条件(not_real_name / activated / suspended)。 +func CardConditionOf(card *model.IotCard) string { + return getCardCondition(card) +} + // getCardCondition 获取卡的状态条件(用于匹配轮询配置) // ⚠️ 注意判断顺序:停机优先,避免停机卡错误匹配 activated 或 not_real_name 配置 // 停机卡需要继续轮询 carddata/package 以检测复机条件(套餐购买、流量重置、实名完成) diff --git a/internal/polling/initializer.go b/internal/polling/initializer.go index 4ebd892c..d90a9a68 100644 --- a/internal/polling/initializer.go +++ b/internal/polling/initializer.go @@ -257,6 +257,10 @@ func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCa cmdCount = 0 } + // 分层轮询按批预取套餐状态,避免逐卡查库。 + if tier := p.configMgr.TierResolver(); tier != nil { + tier.Prefill(ctx, cards) + } for _, card := range cards { intervals := p.configMgr.MergedTaskIntervals(card) if len(intervals) == 0 { @@ -271,6 +275,10 @@ func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCa for taskType, info := range intervals { lastCheckAt := lastCheckAtByTaskType(card, taskType) nextCheck := calculateNextCheckTime(lastCheckAt, info.Interval, now) + // 重置日前加密:窗口规则算出的时间更早时(如需截断到窗口起点)以它为准。 + if windowDue := p.configMgr.NextCheckAt(ctx, card, taskType, info, now); windowDue.Before(nextCheck) && windowDue.After(now) { + nextCheck = windowDue + } pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, taskType), redis.Z{ Score: float64(nextCheck.Unix()), Member: cardIDStr, }) @@ -289,7 +297,8 @@ func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCa "last_gateway_reading_mb": card.LastGatewayReadingMB, "data_usage_mb": card.DataUsageMB, "stop_reason": card.StopReason, "is_standalone": boolToStr(card.IsStandalone), - "cached_at": now.Unix(), + "asset_status": card.AssetStatus, + "cached_at": now.Unix(), } if card.CurrentMonthStartDate != nil { cacheData["current_month_start_date"] = card.CurrentMonthStartDate.Unix() diff --git a/internal/polling/pre_reset.go b/internal/polling/pre_reset.go new file mode 100644 index 00000000..446a05b0 --- /dev/null +++ b/internal/polling/pre_reset.go @@ -0,0 +1,130 @@ +package polling + +import ( + "context" + "strconv" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// shanghaiLocation 返回业务时区(上海)。 +func shanghaiLocation() *time.Location { + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("CST", 8*3600) + } + return location +} + +// nextResetAt 返回 now 之后最近一次流量重置时刻:重置日当天 0 点(上海时区)。 +func nextResetAt(now time.Time, resetDay int) time.Time { + if resetDay < 1 || resetDay > 28 { + resetDay = 1 + } + local := now.In(shanghaiLocation()) + candidate := time.Date(local.Year(), local.Month(), resetDay, 0, 0, 0, 0, shanghaiLocation()) + if !candidate.After(local) { + candidate = candidate.AddDate(0, 1, 0) + } + return candidate +} + +// PreResetNextDue 计算重置日前加密后的流量检查下次到期时间。 +// - 当前在窗口 [reset-window, reset) 内:下次 = now + 窗口间隔;越过重置时刻则改为 reset - 收尾提前量,已过则按正常间隔; +// - 当前在窗口前、按正常间隔算出的下次越过窗口起点:截断到窗口起点,保证按时进入窗口; +// - 其余按正常间隔。 +func PreResetNextDue(now time.Time, normalInterval, window, windowInterval time.Duration, reset time.Time, tail time.Duration) time.Time { + normal := now.Add(normalInterval) + windowStart := reset.Add(-window) + if !now.Before(windowStart) && now.Before(reset) { + next := now.Add(windowInterval) + if next.Before(reset) { + return next + } + last := reset.Add(-tail) + if last.After(now) { + return last + } + return normal + } + if now.Before(windowStart) && normal.After(windowStart) { + return windowStart + } + return normal +} + +// NextCheckAt 返回卡某检查类型的下次到期时间:流量检查在来源配置开启重置日前加密时按窗口规则计算,其余为 now + 间隔。 +func (m *PollingConfigManager) NextCheckAt(ctx context.Context, card *model.IotCard, taskType string, info TaskTypeInterval, now time.Time) time.Time { + normal := now.Add(time.Duration(info.Interval) * time.Second) + if taskType != constants.TaskTypePollingCarddata || card == nil || info.ConfigID == 0 { + return normal + } + cfg := m.configByID(info.ConfigID) + if cfg == nil || cfg.PreResetWindow == nil || cfg.PreResetCarddataInterval == nil { + return normal + } + resetDay := 1 + if m.tier != nil { + resetDay = m.tier.ResetDay(ctx, card.CarrierID) + } + return PreResetNextDue(now, time.Duration(info.Interval)*time.Second, + time.Duration(*cfg.PreResetWindow)*time.Second, time.Duration(*cfg.PreResetCarddataInterval)*time.Second, + nextResetAt(now, resetDay), constants.PollingPreResetTailMinutes*time.Minute) +} + +func (m *PollingConfigManager) configByID(id uint) *model.PollingConfig { + m.mu.RLock() + defer m.mu.RUnlock() + for _, cfg := range m.configs { + if cfg.ID == id { + return cfg + } + } + return nil +} + +// RecordPreResetCheck 在流量查询成功后记录重置日前加密效果:处于该卡来源配置的加密窗口内时, +// 把卡记入窗口已查集合,并记下本次查询距重置时刻的分钟数(同卡取最后一次)。统计失败不影响轮询。 +func (m *PollingConfigManager) RecordPreResetCheck(ctx context.Context, card *model.IotCard, now time.Time) { + if m == nil || m.redis == nil || card == nil { + return + } + info, ok := m.MergedTaskIntervals(card)[constants.TaskTypePollingCarddata] + if !ok { + return + } + cfg := m.configByID(info.ConfigID) + if cfg == nil || cfg.PreResetWindow == nil || cfg.PreResetCarddataInterval == nil { + return + } + resetDay := 1 + if m.tier != nil { + resetDay = m.tier.ResetDay(ctx, card.CarrierID) + } + reset := nextResetAt(now, resetDay) + if now.Before(reset.Add(-time.Duration(*cfg.PreResetWindow) * time.Second)) { + return + } + date := reset.Format("20060102") + member := strconv.FormatUint(uint64(card.ID), 10) + minutes := int(reset.Sub(now).Minutes()) + ttl := constants.PollingPreResetStatTTLSeconds * time.Second + pipe := m.redis.Pipeline() + pipe.PFAdd(ctx, constants.RedisPollingPreResetCheckedKey(card.CarrierID, date), member) + pipe.Expire(ctx, constants.RedisPollingPreResetCheckedKey(card.CarrierID, date), ttl) + pipe.HSet(ctx, constants.RedisPollingPreResetOffsetKey(card.CarrierID, date), member, minutes) + pipe.Expire(ctx, constants.RedisPollingPreResetOffsetKey(card.CarrierID, date), ttl) + _, _ = pipe.Exec(ctx) +} + +// ResetMoments 返回某运营商账户最近一次已过去的重置时刻与下一次重置时刻。 +func (m *PollingConfigManager) ResetMoments(ctx context.Context, carrierID uint, now time.Time) (time.Time, time.Time) { + resetDay := 1 + if m.tier != nil { + resetDay = m.tier.ResetDay(ctx, carrierID) + } + next := nextResetAt(now, resetDay) + return next.AddDate(0, -1, 0), next +} diff --git a/internal/polling/recalculate.go b/internal/polling/recalculate.go new file mode 100644 index 00000000..e8aa861e --- /dev/null +++ b/internal/polling/recalculate.go @@ -0,0 +1,129 @@ +package polling + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// recalculateBatchSize 是重算到期时间每批读取的卡数。 +const recalculateBatchSize = 500 + +// RecalculateProgress 是重算到期时间任务的进度快照。 +type RecalculateProgress struct { + Total int + Processed int + Advanced int // 被提前的检查项数 +} + +// DueRecalculator 按当前轮询配置重算已在队列中的卡的下次到期时间:新到期 = min(当前到期, 上次检查 + 新间隔),只提前不推后。 +// 只更新已在分片队列中的成员(ZADD XX LT),不新增成员,避免把执行中的卡重复放回队列;不调用网关。 +type DueRecalculator struct { + db *gorm.DB + redis *redis.Client + configMgr *PollingConfigManager + queueMgr *PollingQueueManager + logger *zap.Logger +} + +// NewDueRecalculator 创建重算器。 +func NewDueRecalculator(db *gorm.DB, redisClient *redis.Client, configMgr *PollingConfigManager, queueMgr *PollingQueueManager, logger *zap.Logger) *DueRecalculator { + if logger == nil { + logger = zap.NewNop() + } + return &DueRecalculator{db: db, redis: redisClient, configMgr: configMgr, queueMgr: queueMgr, logger: logger} +} + +// Run 执行重算;carrierID 非空时只处理该运营商账户的卡。每处理一批回调一次进度。 +func (r *DueRecalculator) Run(ctx context.Context, carrierID *uint, report func(RecalculateProgress)) (RecalculateProgress, error) { + var progress RecalculateProgress + if r == nil || r.db == nil || r.redis == nil || r.configMgr == nil || r.queueMgr == nil { + return progress, fmt.Errorf("重算到期时间能力未配置") + } + if err := r.configMgr.Load(ctx); err != nil { + return progress, err + } + base := r.db.WithContext(ctx).Model(&model.IotCard{}).Where("enable_polling = true AND deleted_at IS NULL") + if carrierID != nil { + base = base.Where("carrier_id = ?", *carrierID) + } + var total int64 + if err := base.Count(&total).Error; err != nil { + return progress, err + } + progress.Total = int(total) + if report != nil { + report(progress) + } + var lastID uint + for { + if err := ctx.Err(); err != nil { + return progress, err + } + var cards []*model.IotCard + query := r.db.WithContext(ctx).Where("id > ? AND enable_polling = true AND deleted_at IS NULL", lastID) + if carrierID != nil { + query = query.Where("carrier_id = ?", *carrierID) + } + if err := query.Order("id ASC").Limit(recalculateBatchSize).Find(&cards).Error; err != nil { + return progress, err + } + if len(cards) == 0 { + break + } + lastID = cards[len(cards)-1].ID + advanced, err := r.recalculateBatch(ctx, cards) + if err != nil { + return progress, err + } + progress.Processed += len(cards) + progress.Advanced += advanced + if report != nil { + report(progress) + } + } + return progress, nil +} + +func (r *DueRecalculator) recalculateBatch(ctx context.Context, cards []*model.IotCard) (int, error) { + if tier := r.configMgr.TierResolver(); tier != nil { + tier.Prefill(ctx, cards) + } + now := time.Now() + pipe := r.redis.Pipeline() + var cmds []*redis.IntCmd + for _, card := range cards { + shardID := int(card.ID) % r.queueMgr.shardCount + member := strconv.FormatUint(uint64(card.ID), 10) + for taskType, info := range r.configMgr.MergedTaskIntervals(card) { + due := calculateNextCheckTime(lastCheckAtByTaskType(card, taskType), info.Interval, now) + if windowDue := r.configMgr.NextCheckAt(ctx, card, taskType, info, now); windowDue.Before(due) && windowDue.After(now) { + due = windowDue + } + cmds = append(cmds, pipe.ZAddArgs(ctx, constants.RedisPollingShardQueueKey(shardID, taskType), redis.ZAddArgs{ + XX: true, LT: true, Ch: true, Members: []redis.Z{{Score: float64(due.Unix()), Member: member}}, + })) + } + } + if len(cmds) == 0 { + return 0, nil + } + if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil { + return 0, err + } + advanced := 0 + for _, cmd := range cmds { + if n, err := cmd.Result(); err == nil { + advanced += int(n) + } + } + return advanced, nil +} diff --git a/internal/polling/recalculate_task.go b/internal/polling/recalculate_task.go new file mode 100644 index 00000000..e58741f1 --- /dev/null +++ b/internal/polling/recalculate_task.go @@ -0,0 +1,83 @@ +package polling + +import ( + "context" + "strconv" + "time" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// RecalculateDuePayload 是重算到期时间异步任务的载荷。 +type RecalculateDuePayload struct { + TaskID string `json:"task_id"` + CarrierID *uint `json:"carrier_id,omitempty"` +} + +// RecalculateTaskHandler 是重算到期时间的 Asynq 任务处理器;进度写入 Redis 供后台查询。 +type RecalculateTaskHandler struct { + recalculator *DueRecalculator + redis *redis.Client + logger *zap.Logger +} + +// NewRecalculateTaskHandler 创建重算任务处理器。 +func NewRecalculateTaskHandler(recalculator *DueRecalculator, redisClient *redis.Client, logger *zap.Logger) *RecalculateTaskHandler { + if logger == nil { + logger = zap.NewNop() + } + return &RecalculateTaskHandler{recalculator: recalculator, redis: redisClient, logger: logger} +} + +// Handle 执行重算;失败写入进度并返回 SkipRetry,避免按指数退避反复全量扫描。 +func (h *RecalculateTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { + var payload RecalculateDuePayload + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil || payload.TaskID == "" { + return asynq.SkipRetry + } + key := constants.RedisPollingRecalcProgressKey(payload.TaskID) + h.write(key, map[string]any{"status": constants.PollingRecalcStatusRunning, "started_at": time.Now().Format(time.RFC3339)}) + progress, err := h.recalculator.Run(ctx, payload.CarrierID, func(p RecalculateProgress) { + h.write(key, map[string]any{"total": p.Total, "processed": p.Processed, "advanced": p.Advanced}) + }) + fields := map[string]any{"total": progress.Total, "processed": progress.Processed, "advanced": progress.Advanced, "finished_at": time.Now().Format(time.RFC3339)} + if err != nil { + fields["status"] = constants.PollingRecalcStatusFailed + fields["error"] = "重算到期时间失败,请稍后重试" + h.write(key, fields) + h.logger.Error("重算轮询到期时间失败", zap.String("task_id", payload.TaskID), zap.Error(err)) + return asynq.SkipRetry + } + fields["status"] = constants.PollingRecalcStatusCompleted + h.write(key, fields) + h.logger.Info("重算轮询到期时间完成", zap.String("task_id", payload.TaskID), + zap.Int("processed", progress.Processed), zap.Int("advanced", progress.Advanced)) + return nil +} + +func (h *RecalculateTaskHandler) write(key string, fields map[string]any) { + if h.redis == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + pipe := h.redis.Pipeline() + pipe.HSet(ctx, key, fields) + pipe.Expire(ctx, key, constants.PollingRecalcProgressTTLSeconds*time.Second) + if _, err := pipe.Exec(ctx); err != nil { + h.logger.Warn("写重算到期时间进度失败", zap.String("key", key), zap.Error(err)) + } +} + +// carrierText 把可选运营商账户转为进度展示文本。 +func carrierText(carrierID *uint) string { + if carrierID == nil { + return "" + } + return strconv.FormatUint(uint64(*carrierID), 10) +} diff --git a/internal/polling/tier.go b/internal/polling/tier.go new file mode 100644 index 00000000..78ac7f0c --- /dev/null +++ b/internal/polling/tier.go @@ -0,0 +1,168 @@ +package polling + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// TierFacts 是分层轮询匹配所需、卡表之外的业务事实。 +type TierFacts struct { + PackageState string // 套餐状态,见 constants.PollingPackageState* + Known bool // 是否成功解析;解析失败时要求套餐状态的配置不匹配 +} + +type tierCacheEntry struct { + facts TierFacts + loadedAt time.Time +} + +// TierResolver 计算卡的套餐状态(绑定设备的卡按设备、独立卡按卡,与停复机"是否有有效套餐"口径一致), +// 并读取运营商账户的流量重置日。结果在进程内短时缓存,层级最迟在缓存过期后的下一次执行时更新。 +type TierResolver struct { + usage *postgres.PackageUsageStore + bindings *postgres.DeviceSimBindingStore + carriers *postgres.CarrierStore + logger *zap.Logger + + mu sync.Mutex + cache map[uint]tierCacheEntry + resetDays map[uint]int + resetsAt time.Time +} + +// NewTierResolver 创建分层事实解析器。 +func NewTierResolver(usage *postgres.PackageUsageStore, bindings *postgres.DeviceSimBindingStore, carriers *postgres.CarrierStore, logger *zap.Logger) *TierResolver { + if logger == nil { + logger = zap.NewNop() + } + return &TierResolver{usage: usage, bindings: bindings, carriers: carriers, logger: logger, cache: map[uint]tierCacheEntry{}, resetDays: map[uint]int{}} +} + +// Resolve 返回卡的分层事实;缓存未过期时直接返回。 +func (r *TierResolver) Resolve(ctx context.Context, card *model.IotCard) TierFacts { + if r == nil || card == nil { + return TierFacts{} + } + if facts, ok := r.cached(card.ID); ok { + return facts + } + r.Prefill(ctx, []*model.IotCard{card}) + facts, _ := r.cached(card.ID) + return facts +} + +// Prefill 批量解析一组卡的分层事实并写入缓存,供全量初始化等批处理避免逐卡查库。 +func (r *TierResolver) Prefill(ctx context.Context, cards []*model.IotCard) { + if r == nil || r.usage == nil || len(cards) == 0 { + return + } + var standaloneIDs, boundCardIDs []uint + for _, card := range cards { + if card == nil { + continue + } + if card.IsStandalone { + standaloneIDs = append(standaloneIDs, card.ID) + } else { + boundCardIDs = append(boundCardIDs, card.ID) + } + } + results := make(map[uint]TierFacts, len(cards)) + if len(standaloneIDs) > 0 { + states, err := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeIotCard, standaloneIDs) + if err != nil { + r.logger.Warn("批量查询卡套餐状态失败,相关卡本次不按套餐状态匹配", zap.Error(err)) + } + for _, id := range standaloneIDs { + if err == nil { + results[id] = TierFacts{PackageState: states[id], Known: true} + } + } + } + if len(boundCardIDs) > 0 && r.bindings != nil { + bindings, err := r.bindings.GetActiveBindingsByCardIDs(ctx, boundCardIDs) + if err != nil { + r.logger.Warn("批量查询卡绑定设备失败,相关卡本次不按套餐状态匹配", zap.Error(err)) + } else { + deviceOf := make(map[uint]uint, len(bindings)) + deviceIDs := make([]uint, 0, len(bindings)) + for _, binding := range bindings { + deviceOf[binding.IotCardID] = binding.DeviceID + deviceIDs = append(deviceIDs, binding.DeviceID) + } + deviceStates, devErr := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeDevice, deviceIDs) + cardStates, cardErr := r.usage.PackageStatesByCarriers(ctx, constants.AssetTypeIotCard, boundCardIDs) + for _, id := range boundCardIDs { + // 有有效设备绑定按设备计算;绑定已失效的卡按卡自身计算(与 resolvePackageCarrier 口径一致)。 + if deviceID, bound := deviceOf[id]; bound { + if devErr == nil { + results[id] = TierFacts{PackageState: deviceStates[deviceID], Known: true} + } + } else if cardErr == nil { + results[id] = TierFacts{PackageState: cardStates[id], Known: true} + } + } + } + } + now := time.Now() + r.mu.Lock() + for _, card := range cards { + if card == nil { + continue + } + r.cache[card.ID] = tierCacheEntry{facts: results[card.ID], loadedAt: now} + } + r.mu.Unlock() +} + +// Invalidate 清除某卡的分层事实缓存(套餐变化后可调用,未调用时最迟缓存过期后更新)。 +func (r *TierResolver) Invalidate(cardID uint) { + if r == nil { + return + } + r.mu.Lock() + delete(r.cache, cardID) + r.mu.Unlock() +} + +func (r *TierResolver) cached(cardID uint) (TierFacts, bool) { + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.cache[cardID] + if !ok || time.Since(entry.loadedAt) > constants.PollingTierFactsCacheSeconds*time.Second { + return TierFacts{}, false + } + return entry.facts, true +} + +// ResetDay 返回运营商账户的流量重置日(1~28),缓存 10 分钟;读取失败按 1 号。 +func (r *TierResolver) ResetDay(ctx context.Context, carrierID uint) int { + if r == nil || r.carriers == nil { + return 1 + } + r.mu.Lock() + if time.Since(r.resetsAt) > 10*time.Minute { + r.resetDays = map[uint]int{} + r.resetsAt = time.Now() + } + day, ok := r.resetDays[carrierID] + r.mu.Unlock() + if ok { + return day + } + day = r.carriers.GetDataResetDay(ctx, carrierID) + if day < 1 || day > 28 { + day = 1 + } + r.mu.Lock() + r.resetDays[carrierID] = day + r.mu.Unlock() + return day +} diff --git a/internal/routes/polling_config.go b/internal/routes/polling_config.go index 833c6a0e..ddd0405d 100644 --- a/internal/routes/polling_config.go +++ b/internal/routes/polling_config.go @@ -37,6 +37,62 @@ func registerPollingConfigRoutes(router fiber.Router, handler *admin.PollingConf Auth: true, }) + Register(configs, doc, groupPath, "PUT", "/priorities", handler.UpdatePriorities, RouteSpec{ + Summary: "整体调整轮询配置优先级(仅超级管理员)", + Tags: []string{"轮询配置管理"}, + Input: new(dto.UpdatePollingConfigPrioritiesRequest), + Output: nil, + Auth: true, + }) + + Register(configs, doc, groupPath, "GET", "/match-preview", handler.MatchPreview, RouteSpec{ + Summary: "预览卡的轮询配置匹配结果(按当前配置)", + Tags: []string{"轮询配置管理"}, + Input: new(dto.PollingConfigMatchPreviewRequest), + Output: new(dto.PollingConfigMatchPreviewResponse), + Auth: true, + }) + + Register(configs, doc, groupPath, "POST", "/match-preview", handler.MatchPreview, RouteSpec{ + Summary: "预览卡的轮询配置匹配结果(可带未保存修改,不写数据)", + Tags: []string{"轮询配置管理"}, + Input: new(dto.PollingConfigMatchPreviewRequest), + Output: new(dto.PollingConfigMatchPreviewResponse), + Auth: true, + }) + + Register(configs, doc, groupPath, "POST", "/recalculate-due", handler.CreateRecalculateTask, RouteSpec{ + Summary: "立即重算轮询到期时间(异步,只提前不推后,仅超级管理员)", + Tags: []string{"轮询配置管理"}, + Input: new(dto.PollingRecalculateDueRequest), + Output: new(dto.PollingRecalculateDueResponse), + Auth: true, + }) + + Register(configs, doc, groupPath, "GET", "/recalculate-due/:task_id", handler.GetRecalculateTask, RouteSpec{ + Summary: "查询重算轮询到期时间任务进度", + Tags: []string{"轮询配置管理"}, + Input: new(dto.PollingRecalculateDueTaskIDRequest), + Output: new(dto.PollingRecalculateDueResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/polling-stats/pre-reset", handler.PreResetStats, RouteSpec{ + Summary: "重置日前加密查流量效果统计", + Tags: []string{"轮询配置管理"}, + Input: nil, + Output: new(dto.PollingPreResetStatsResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/polling-stats/tiers", handler.TierStats, RouteSpec{ + Summary: "分层轮询统计(结果缓存 5 分钟)", + Tags: []string{"轮询配置管理"}, + Input: nil, + Output: new(dto.PollingTierStatsResponse), + Auth: true, + }) + Register(configs, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{ Summary: "获取轮询配置详情", Tags: []string{"轮询配置管理"}, diff --git a/internal/service/order/pre_activation.go b/internal/service/order/pre_activation.go new file mode 100644 index 00000000..e3e0a2a0 --- /dev/null +++ b/internal/service/order/pre_activation.go @@ -0,0 +1,85 @@ +package order + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// PreActivationSettler 在资产从无生效主套餐变为有生效主套餐之前做一次流量结算(尽力而为,不阻塞)。 +type PreActivationSettler interface { + SettleBeforeActivation(ctx context.Context, carrierType string, carrierID uint) +} + +// activationLockWait 是订单路径等待资产级激活锁的最长时间;超时照常继续(行锁与流量按观测时间归属兜底正确性)。 +const activationLockWait = 5 * time.Second + +// SetPreActivationSettler 注入激活前流量结算器;未注入时不做结算。 +func (s *Service) SetPreActivationSettler(settler PreActivationSettler) { + s.preActivationSettler = settler +} + +// prepareActivation 在订单激活事务开始前执行:抢资产级激活锁(与排队、指定、队首、实名激活共用同一把锁), +// 再做激活前流量结算。返回的释放函数须在激活事务提交或回滚后调用。外部网络调用不得放进数据库事务(ENG-TX-001)。 +func (s *Service) prepareActivation(ctx context.Context, order *model.Order) func() { + carrierType, carrierID, ok := orderCarrier(order) + if !ok { + return func() {} + } + release := s.acquireActivationLock(ctx, carrierType, carrierID) + if s.preActivationSettler != nil { + s.preActivationSettler.SettleBeforeActivation(ctx, carrierType, carrierID) + } + return release +} + +func orderCarrier(order *model.Order) (string, uint, bool) { + if order == nil { + return "", 0, false + } + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + return constants.AssetTypeIotCard, *order.IotCardID, true + } + if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + return constants.AssetTypeDevice, *order.DeviceID, true + } + return "", 0, false +} + +// acquireActivationLock 在最长 activationLockWait 内抢锁;抢不到时记日志并返回空释放函数。 +func (s *Service) acquireActivationLock(ctx context.Context, carrierType string, carrierID uint) func() { + if s.redis == nil { + return func() {} + } + key := constants.RedisPackageActivationLockKey(carrierType, carrierID) + token := uuid.NewString() + deadline := time.Now().Add(activationLockWait) + for { + locked, err := s.redis.SetNX(ctx, key, token, constants.PackageActivationLockSeconds*time.Second).Result() + if err == nil && locked { + return func() { + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + // 只释放自己持有的锁,避免锁过期后误删他人的锁。 + if current, getErr := s.redis.Get(releaseCtx, key).Result(); getErr == nil && current == token { + s.redis.Del(releaseCtx, key) + } + } + } + if err != nil || time.Now().After(deadline) { + s.logger.Warn("订单激活未能获取资产级激活锁,继续执行(行锁与流量按观测时间归属兜底)", + zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) + return func() {} + } + select { + case <-ctx.Done(): + return func() {} + case <-time.After(200 * time.Millisecond): + } + } +} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index b0818719..c721c283 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -50,6 +50,7 @@ type WechatConfigServiceInterface interface { } type Service struct { + preActivationSettler PreActivationSettler db *gorm.DB redis *redis.Client orderStore *postgres.OrderStore @@ -1176,6 +1177,8 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model var existingOrderID uint walletDebitAttempted := false + release := s.prepareActivation(ctx, order) + defer release() err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey) if err != nil { @@ -1239,6 +1242,8 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or if s.billCreation == nil { return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置") } + release := s.prepareActivation(ctx, order) + defer release() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") @@ -1727,6 +1732,8 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, } return err } + release := s.prepareActivation(ctx, order) + defer release() agentWalletDebitAttempted := false defer func() { s.recordOrderFailure(ctx, constants.AuditActionOrderWalletPaid, "钱包支付订单失败", order, err) @@ -1974,6 +1981,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay return err } + release := s.prepareActivation(ctx, order) + defer release() now := time.Now() beforeOrder := *order shouldResumeAfterPayment := false @@ -2059,6 +2068,8 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败") } + release := s.prepareActivation(ctx, order) + defer release() now := time.Now() beforePayment := *payment beforeOrder := *order diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 2c521ca9..01836b12 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -35,6 +35,55 @@ type ActivationService struct { observationSeriesEvents cardObservationApp.SeriesEventWriter priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer + preActivationSettler PreActivationSettler // 激活前流量结算,可选 +} + +// PreActivationSettler 在资产从无生效主套餐变为有生效主套餐之前做一次流量结算(尽力而为,不阻塞激活)。 +type PreActivationSettler interface { + SettleBeforeActivation(ctx context.Context, carrierType string, carrierID uint) +} + +// SetPreActivationSettler 注入激活前流量结算器;未注入时不做结算。 +func (s *ActivationService) SetPreActivationSettler(settler PreActivationSettler) { + s.preActivationSettler = settler +} + +// settleBeforeActivation 在激活锁内、激活事务开始前执行激活前流量结算;外部调用不得放进数据库事务(ENG-TX-001)。 +func (s *ActivationService) settleBeforeActivation(ctx context.Context, carrierType string, carrierID uint) { + if s.preActivationSettler != nil { + s.preActivationSettler.SettleBeforeActivation(ctx, carrierType, carrierID) + } +} + +// waitActivationLock 实名激活抢资产级激活锁:最多等待 5 秒,抢不到时照常继续(不能跳过,否则实名回调触发的激活会丢失)。 +func (s *ActivationService) waitActivationLock(ctx context.Context, carrierType string, carrierID uint) func() { + if s.redis == nil { + return func() {} + } + key := constants.RedisPackageActivationLockKey(carrierType, carrierID) + token := time.Now().String() + deadline := time.Now().Add(5 * time.Second) + for { + locked, err := s.redis.SetNX(ctx, key, token, constants.PackageActivationLockSeconds*time.Second).Result() + if err == nil && locked { + return func() { + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer cancel() + if current, getErr := s.redis.Get(releaseCtx, key).Result(); getErr == nil && current == token { + s.redis.Del(releaseCtx, key) + } + } + } + if err != nil || time.Now().After(deadline) { + s.logger.Warn("实名激活未能获取资产级激活锁,继续执行", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) + return func() {} + } + select { + case <-ctx.Done(): + return func() {} + case <-time.After(200 * time.Millisecond): + } + } } // SetLifecycleAudit 注入套餐权益生命周期统一审计 Writer。 @@ -102,6 +151,10 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType return nil } + release := s.waitActivationLock(ctx, carrierType, carrierID) + defer release() + s.settleBeforeActivation(ctx, carrierType, carrierID) + now := time.Now() activated := false @@ -219,7 +272,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy // 使用 Redis 分布式锁避免并发 lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() - locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() + locked, err := s.redis.SetNX(ctx, lockKey, lockValue, constants.PackageActivationLockSeconds*time.Second).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } @@ -233,6 +286,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy activated := false var failedUsage *model.PackageUsage + s.settleBeforeActivation(ctx, carrierType, carrierID) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 任务 9.5: 检测并标记过期的主套餐 now := time.Now() @@ -336,7 +391,7 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package // 获取分布式锁 lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() - locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() + locked, err := s.redis.SetNX(ctx, lockKey, lockValue, constants.PackageActivationLockSeconds*time.Second).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } @@ -356,6 +411,8 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package // 事务内激活 activated := false + s.settleBeforeActivation(ctx, carrierType, carrierID) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 再次幂等检查(防止锁释放期间被其他操作激活) var currentUsage model.PackageUsage @@ -419,7 +476,7 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() - locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() + locked, err := s.redis.SetNX(ctx, lockKey, lockValue, constants.PackageActivationLockSeconds*time.Second).Result() if err != nil { return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } @@ -433,6 +490,8 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, activated := false var failedUsage *model.PackageUsage + s.settleBeforeActivation(ctx, carrierType, carrierID) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) if err != nil { diff --git a/internal/service/polling/audit.go b/internal/service/polling/audit.go index 98a46058..c9f30d37 100644 --- a/internal/service/polling/audit.go +++ b/internal/service/polling/audit.go @@ -73,6 +73,8 @@ func pollingConfigState(config *model.PollingConfig) map[string]any { "realname_check_interval": config.RealnameCheckInterval, "carddata_check_interval": config.CarddataCheckInterval, "package_check_interval": config.PackageCheckInterval, "protect_check_interval": config.ProtectCheckInterval, "card_status_check_interval": config.CardStatusCheckInterval, "status": config.Status, + "package_state": config.PackageState, "asset_status": config.AssetStatus, + "pre_reset_window": config.PreResetWindow, "pre_reset_carddata_interval": config.PreResetCarddataInterval, "description": config.Description, } } diff --git a/internal/service/polling/config_service.go b/internal/service/polling/config_service.go index 098fc415..1b219c3c 100644 --- a/internal/service/polling/config_service.go +++ b/internal/service/polling/config_service.go @@ -11,6 +11,7 @@ import ( auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + pollingcore "github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -25,6 +26,8 @@ type ConfigService struct { auditWriter *auditinfra.Writer redis *redis.Client logger *zap.Logger + configMgr *pollingcore.PollingConfigManager + queueClient TaskEnqueuer } // SetAudit 注入轮询配置事务与统一审计 Writer。 @@ -67,28 +70,28 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig return nil, appErr } - // 验证检查间隔(至少一个不为空) - if req.RealnameCheckInterval == nil && req.CarddataCheckInterval == nil && - req.PackageCheckInterval == nil && req.ProtectCheckInterval == nil && - req.CardStatusCheckInterval == nil { - return nil, errors.New(errors.CodeInvalidParam, "至少需要配置一种检查间隔") - } - config := &model.PollingConfig{ - ConfigName: req.ConfigName, - CardCondition: req.CardCondition, - CardCategory: req.CardCategory, - CarrierID: req.CarrierID, - Priority: req.Priority, - RealnameCheckInterval: req.RealnameCheckInterval, - CarddataCheckInterval: req.CarddataCheckInterval, - PackageCheckInterval: req.PackageCheckInterval, - ProtectCheckInterval: req.ProtectCheckInterval, - CardStatusCheckInterval: req.CardStatusCheckInterval, - Status: 1, - Description: req.Description, - CreatedBy: ¤tUserID, - UpdatedBy: ¤tUserID, + ConfigName: req.ConfigName, + CardCondition: req.CardCondition, + CardCategory: req.CardCategory, + CarrierID: req.CarrierID, + Priority: req.Priority, + RealnameCheckInterval: req.RealnameCheckInterval, + CarddataCheckInterval: req.CarddataCheckInterval, + PackageCheckInterval: req.PackageCheckInterval, + ProtectCheckInterval: req.ProtectCheckInterval, + CardStatusCheckInterval: req.CardStatusCheckInterval, + PackageState: req.PackageState, + AssetStatus: req.AssetStatus, + PreResetWindow: req.PreResetWindow, + PreResetCarddataInterval: req.PreResetCarddataInterval, + Status: 1, + Description: req.Description, + CreatedBy: ¤tUserID, + UpdatedBy: ¤tUserID, + } + if err := validatePollingConfig(config); err != nil { + return nil, err } err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { @@ -114,7 +117,9 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig } s.notifyConfigChanged(ctx, "created") - return s.toResponse(config), nil + resp := s.toResponse(config) + resp.Warnings = s.priorityWarnings(ctx, config) + return resp, nil } // Get 获取轮询配置详情 @@ -189,9 +194,27 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll if req.CardStatusCheckInterval != nil { config.CardStatusCheckInterval = req.CardStatusCheckInterval } + if req.PackageState != nil { + config.PackageState = req.PackageState + } + if req.AssetStatus != nil { + config.AssetStatus = req.AssetStatus + } + if req.PreResetWindow != nil { + config.PreResetWindow = req.PreResetWindow + } + if req.PreResetCarddataInterval != nil { + config.PreResetCarddataInterval = req.PreResetCarddataInterval + } if req.Description != nil { config.Description = *req.Description } + if err := applyPollingConfigClearFields(config, req.ClearFields); err != nil { + return nil, err + } + if err := validatePollingConfig(config); err != nil { + return nil, err + } config.UpdatedBy = ¤tUserID err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { @@ -219,7 +242,9 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll } s.notifyConfigChanged(ctx, "updated") - return s.toResponse(config), nil + resp := s.toResponse(config) + resp.Warnings = s.priorityWarnings(ctx, config) + return resp, nil } // Delete 删除轮询配置 @@ -368,20 +393,24 @@ func (s *ConfigService) ListEnabled(ctx context.Context) ([]*dto.PollingConfigRe // toResponse 转换为响应 DTO func (s *ConfigService) toResponse(c *model.PollingConfig) *dto.PollingConfigResponse { return &dto.PollingConfigResponse{ - ID: c.ID, - ConfigName: c.ConfigName, - CardCondition: c.CardCondition, - CardCategory: c.CardCategory, - CarrierID: c.CarrierID, - Priority: c.Priority, - RealnameCheckInterval: c.RealnameCheckInterval, - CarddataCheckInterval: c.CarddataCheckInterval, - PackageCheckInterval: c.PackageCheckInterval, - ProtectCheckInterval: c.ProtectCheckInterval, - CardStatusCheckInterval: c.CardStatusCheckInterval, - Status: c.Status, - Description: c.Description, - CreatedAt: c.CreatedAt.Format(time.RFC3339), - UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + ID: c.ID, + ConfigName: c.ConfigName, + CardCondition: c.CardCondition, + CardCategory: c.CardCategory, + CarrierID: c.CarrierID, + Priority: c.Priority, + RealnameCheckInterval: c.RealnameCheckInterval, + CarddataCheckInterval: c.CarddataCheckInterval, + PackageCheckInterval: c.PackageCheckInterval, + ProtectCheckInterval: c.ProtectCheckInterval, + CardStatusCheckInterval: c.CardStatusCheckInterval, + PackageState: c.PackageState, + AssetStatus: c.AssetStatus, + PreResetWindow: c.PreResetWindow, + PreResetCarddataInterval: c.PreResetCarddataInterval, + Status: c.Status, + Description: c.Description, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), } } diff --git a/internal/service/polling/config_tier_service.go b/internal/service/polling/config_tier_service.go new file mode 100644 index 00000000..7cc53ff3 --- /dev/null +++ b/internal/service/polling/config_tier_service.go @@ -0,0 +1,419 @@ +package polling + +import ( + "context" + "sort" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "github.com/hibiken/asynq" + "gorm.io/gorm" + + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pollingcore "github.com/break/junhong_cmp_fiber/internal/polling" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// tierStatsCacheKey 是分层统计结果的缓存 Key(5 分钟)。 +const tierStatsCacheKey = "polling:tier_stats" + +// TaskEnqueuer 是提交异步任务的最小端口(由 pkg/queue.Client 实现)。 +type TaskEnqueuer interface { + EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error +} + +// SetTierDeps 注入分层轮询所需的配置管理器与异步队列;未注入时预览、重算与统计不可用。 +func (s *ConfigService) SetTierDeps(configMgr *pollingcore.PollingConfigManager, queueClient TaskEnqueuer) { + s.configMgr = configMgr + s.queueClient = queueClient +} + +// UpdatePriorities 按给定顺序把配置优先级重排为 1、2、3…(事务内完成并逐条写统一审计),仅超级管理员可用。 +func (s *ConfigService) UpdatePriorities(ctx context.Context, req *dto.UpdatePollingConfigPrioritiesRequest) error { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "仅超级管理员可调整轮询配置优先级") + } + operatorID := middleware.GetUserIDFromContext(ctx) + seen := map[uint]bool{} + for _, id := range req.OrderedIDs { + if id == 0 || seen[id] { + return errors.New(errors.CodeInvalidParam, "轮询配置优先级参数不合法:配置 ID 不能为空或重复") + } + seen[id] = true + } + err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + for index, id := range req.OrderedIDs { + var cfg model.PollingConfig + if err := tx.WithContext(ctx).First(&cfg, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在:"+strconv.FormatUint(uint64(id), 10)) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败") + } + priority := index + 1 + if cfg.Priority == priority { + continue + } + before := cfg + if err := tx.WithContext(ctx).Model(&model.PollingConfig{}).Where("id = ?", id). + Updates(map[string]any{"priority": priority, "updated_by": operatorID, "updated_at": time.Now()}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "调整轮询配置优先级失败") + } + cfg.Priority = priority + if err := writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "调整轮询配置优先级", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: cfg.ID, + ResourceKey: pollingManualTriggerKey(cfg.ID), DisplayName: cfg.ConfigName, + OperatorID: operatorID, IdentitySnapshot: pollingConfigIdentity(&cfg), + BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(&cfg), + }); err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + s.notifyConfigChanged(ctx, "priorities_updated") + return nil +} + +// MatchPreview 返回卡的匹配维度、命中配置与各检查类型最终间隔及来源;可带未保存修改预演,不写任何数据。 +func (s *ConfigService) MatchPreview(ctx context.Context, req *dto.PollingConfigMatchPreviewRequest) (*dto.PollingConfigMatchPreviewResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + if s.configMgr == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "轮询配置匹配预览能力未配置") + } + iccid := strings.TrimSpace(req.ICCID) + var card model.IotCard + if err := s.db.WithContext(ctx).Where("iccid = ?", iccid).First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败") + } + configs, err := s.configStore.ListEnabled(ctx) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败") + } + configs, drafts, err := applyPreviewOverrides(configs, req.Overrides) + if err != nil { + return nil, err + } + if tier := s.configMgr.TierResolver(); tier != nil { + tier.Invalidate(card.ID) + } + matched, sources := s.configMgr.PreviewIntervalSources(configs, &card) + facts := s.configMgr.TierFactsOf(&card) + resp := &dto.PollingConfigMatchPreviewResponse{ + Card: dto.PollingCardMatchFacts{ + CardID: card.ID, ICCID: card.ICCID, CardCondition: pollingcore.CardConditionOf(&card), CardCategory: card.CardCategory, + CarrierID: card.CarrierID, AssetStatus: card.AssetStatus, EnablePolling: card.EnablePolling, + }, + Matched: []dto.PollingConfigBrief{}, Intervals: []dto.PollingIntervalPreview{}, + } + if facts.Known { + resp.Card.PackageState = facts.PackageState + resp.Card.PackageStateName = constants.PollingPackageStateName(facts.PackageState) + } + for _, cfg := range matched { + resp.Matched = append(resp.Matched, dto.PollingConfigBrief{ID: cfg.ID, ConfigName: cfg.ConfigName, Priority: cfg.Priority, Draft: drafts[cfg]}) + } + firstID := uint(0) + if len(matched) > 0 { + firstID = matched[0].ID + } + for _, taskType := range previewTaskTypes { + item := dto.PollingIntervalPreview{TaskType: taskType, TaskTypeName: previewTaskTypeName(taskType), Mode: "unmatched"} + if source, ok := sources[taskType]; ok { + item.ConfigID, item.ConfigName = source.ConfigID, source.Config.ConfigName + item.Inherited = source.ConfigID != firstID + if source.Disabled { + item.Mode = "disabled" + } else { + item.Mode, item.Interval = "interval", source.Interval + } + } + resp.Intervals = append(resp.Intervals, item) + } + return resp, nil +} + +var previewTaskTypes = []string{constants.TaskTypePollingCardStatus, constants.TaskTypePollingCarddata, constants.TaskTypePollingRealname, constants.TaskTypePollingPackage, constants.TaskTypePollingProtect} + +// applyPreviewOverrides 把未保存修改合并进启用配置快照并重新排序;返回草稿配置标记。 +func applyPreviewOverrides(configs []*model.PollingConfig, overrides []dto.PollingConfigOverride) ([]*model.PollingConfig, map[*model.PollingConfig]bool, error) { + drafts := map[*model.PollingConfig]bool{} + if len(overrides) == 0 { + return configs, drafts, nil + } + byID := map[uint]int{} + out := make([]*model.PollingConfig, 0, len(configs)+len(overrides)) + for _, cfg := range configs { + byID[cfg.ID] = len(out) + out = append(out, cfg) + } + removed := map[int]bool{} + for i := range overrides { + item := overrides[i] + if item.ID != 0 && item.Disabled { + if idx, ok := byID[item.ID]; ok { + removed[idx] = true + } + continue + } + draft := &model.PollingConfig{ + ID: item.ID, ConfigName: item.ConfigName, CardCondition: item.CardCondition, CardCategory: item.CardCategory, + CarrierID: item.CarrierID, Priority: item.Priority, RealnameCheckInterval: item.RealnameCheckInterval, + CarddataCheckInterval: item.CarddataCheckInterval, PackageCheckInterval: item.PackageCheckInterval, + ProtectCheckInterval: item.ProtectCheckInterval, CardStatusCheckInterval: item.CardStatusCheckInterval, + PackageState: item.PackageState, AssetStatus: item.AssetStatus, PreResetWindow: item.PreResetWindow, + PreResetCarddataInterval: item.PreResetCarddataInterval, Status: 1, + } + if draft.ConfigName == "" { + draft.ConfigName = "未保存的配置" + } + if err := validatePollingConfig(draft); err != nil { + return nil, nil, err + } + drafts[draft] = true + if idx, ok := byID[item.ID]; ok && item.ID != 0 { + out[idx] = draft + continue + } + out = append(out, draft) + } + filtered := make([]*model.PollingConfig, 0, len(out)) + for idx, cfg := range out { + if !removed[idx] { + filtered = append(filtered, cfg) + } + } + sort.SliceStable(filtered, func(i, j int) bool { + if filtered[i].Priority != filtered[j].Priority { + return filtered[i].Priority < filtered[j].Priority + } + // 草稿(ID 为 0)排在同优先级已有配置之后。 + if filtered[i].ID == 0 || filtered[j].ID == 0 { + return filtered[j].ID == 0 && filtered[i].ID != 0 + } + return filtered[i].ID < filtered[j].ID + }) + return filtered, drafts, nil +} + +// CreateRecalculateTask 创建"立即重算到期时间"异步任务(由 Worker 执行),仅超级管理员可用。 +func (s *ConfigService) CreateRecalculateTask(ctx context.Context, req *dto.PollingRecalculateDueRequest) (*dto.PollingRecalculateDueResponse, error) { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden, "仅超级管理员可重算轮询到期时间") + } + if s.queueClient == nil || s.redis == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "重算到期时间能力未配置") + } + taskID := uuid.NewString() + carrier := "" + if req.CarrierID != nil { + carrier = strconv.FormatUint(uint64(*req.CarrierID), 10) + } + now := time.Now().Format(time.RFC3339) + key := constants.RedisPollingRecalcProgressKey(taskID) + pipe := s.redis.Pipeline() + pipe.HSet(ctx, key, map[string]any{"status": constants.PollingRecalcStatusPending, "carrier_id": carrier, "created_at": now}) + pipe.Expire(ctx, key, constants.PollingRecalcProgressTTLSeconds*time.Second) + if _, err := pipe.Exec(ctx); err != nil { + return nil, errors.Wrap(errors.CodeRedisError, err, "创建重算任务失败") + } + if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypePollingRecalculateDue, + pollingcore.RecalculateDuePayload{TaskID: taskID, CarrierID: req.CarrierID}, + asynq.Queue(constants.QueueDataCleanup), asynq.MaxRetry(0), asynq.Timeout(30*time.Minute)); err != nil { + _ = s.redis.HSet(ctx, key, "status", constants.PollingRecalcStatusFailed, "error", "任务入队失败").Err() + return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "重算任务入队失败") + } + return &dto.PollingRecalculateDueResponse{TaskID: taskID, Status: constants.PollingRecalcStatusPending, CarrierID: carrier, CreatedAt: now}, nil +} + +// GetRecalculateTask 查询重算到期时间任务进度。 +func (s *ConfigService) GetRecalculateTask(ctx context.Context, taskID string) (*dto.PollingRecalculateDueResponse, error) { + if s.redis == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "重算到期时间能力未配置") + } + values, err := s.redis.HGetAll(ctx, constants.RedisPollingRecalcProgressKey(taskID)).Result() + if err != nil { + return nil, errors.Wrap(errors.CodeRedisError, err, "查询重算任务失败") + } + if len(values) == 0 { + return nil, errors.New(errors.CodeNotFound, "重算任务不存在或已过期") + } + atoi := func(v string) int { n, _ := strconv.Atoi(v); return n } + return &dto.PollingRecalculateDueResponse{ + TaskID: taskID, Status: values["status"], CarrierID: values["carrier_id"], Total: atoi(values["total"]), + Processed: atoi(values["processed"]), Advanced: atoi(values["advanced"]), Error: values["error"], + CreatedAt: values["created_at"], StartedAt: values["started_at"], FinishedAt: values["finished_at"], + }, nil +} + +// TierStats 统计开启轮询的卡按首个命中配置与套餐状态的分布,以及应告警的未匹配卡数;结果缓存 5 分钟。 +func (s *ConfigService) TierStats(ctx context.Context) (*dto.PollingTierStatsResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + if s.configMgr == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "分层统计能力未配置") + } + if s.redis != nil { + if raw, err := s.redis.Get(ctx, tierStatsCacheKey).Bytes(); err == nil { + var cached dto.PollingTierStatsResponse + if sonic.Unmarshal(raw, &cached) == nil { + return &cached, nil + } + } + } + configs, err := s.configStore.ListEnabled(ctx) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败") + } + names := map[uint]string{0: "未匹配任何配置"} + for _, cfg := range configs { + names[cfg.ID] = cfg.ConfigName + } + resp := &dto.PollingTierStatsResponse{ByPackageState: map[string]int{}, ByConfig: []dto.PollingTierStatItem{}} + counts := map[uint]int{} + var lastID uint + for { + var cards []*model.IotCard + if err := s.db.WithContext(ctx).Where("id > ? AND enable_polling = true AND deleted_at IS NULL", lastID). + Order("id ASC").Limit(1000).Find(&cards).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询卡失败") + } + if len(cards) == 0 { + break + } + lastID = cards[len(cards)-1].ID + if tier := s.configMgr.TierResolver(); tier != nil { + tier.Prefill(ctx, cards) + } + for _, card := range cards { + resp.TotalCards++ + facts := s.configMgr.TierFactsOf(card) + state := "unknown" + if facts.Known { + state = facts.PackageState + } + resp.ByPackageState[state]++ + matched, _ := s.configMgr.PreviewIntervalSources(configs, card) + if len(matched) == 0 { + counts[0]++ + if card.AssetStatus == constants.AssetStatusInStock { + resp.UnmatchedInStock++ + } else if card.AssetStatus == constants.AssetStatusSold && facts.Known && facts.PackageState == constants.PollingPackageStateNone { + resp.UnmatchedSoldNoPackage++ + } + continue + } + counts[matched[0].ID]++ + } + } + for id, n := range counts { + resp.ByConfig = append(resp.ByConfig, dto.PollingTierStatItem{ConfigID: id, ConfigName: names[id], Cards: n}) + } + sort.Slice(resp.ByConfig, func(i, j int) bool { return resp.ByConfig[i].Cards > resp.ByConfig[j].Cards }) + resp.ComputedAt = time.Now().Format(time.RFC3339) + if s.redis != nil { + if raw, err := sonic.Marshal(resp); err == nil { + _ = s.redis.Set(ctx, tierStatsCacheKey, raw, 5*time.Minute).Err() + } + } + return resp, nil +} + +// previewTaskTypeName 返回检查类型中文名称(保护期检查在优先轮询名称表之外单独处理)。 +func previewTaskTypeName(taskType string) string { + if taskType == constants.TaskTypePollingProtect { + return "保护期检查" + } + return constants.PollingPriorityTaskTypeName(taskType) +} + +// PreResetStats 按运营商账户统计重置日前加密效果:上一个与即将到来的重置周期内,窗口内查到流量的卡数、 +// 有生效套餐但窗口内未查到的卡数,以及最后一次查询距重置时刻的分布。 +func (s *ConfigService) PreResetStats(ctx context.Context) (*dto.PollingPreResetStatsResponse, error) { + if err := requirePlatformReader(ctx); err != nil { + return nil, err + } + if s.configMgr == nil || s.redis == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "重置日前加密统计能力未配置") + } + var rows []struct { + CarrierID uint + CarrierName string + Active int + } + err := s.db.WithContext(ctx).Raw(` +SELECT c.carrier_id, COALESCE(ca.carrier_name, '') AS carrier_name, + COUNT(*) FILTER (WHERE (c.is_standalone AND EXISTS ( + SELECT 1 FROM tb_package_usage u WHERE u.iot_card_id = c.id AND u.status = ? AND u.deleted_at IS NULL)) + OR (NOT c.is_standalone AND EXISTS ( + SELECT 1 FROM tb_device_sim_binding b JOIN tb_package_usage u ON u.device_id = b.device_id AND u.status = ? AND u.deleted_at IS NULL + WHERE b.iot_card_id = c.id AND b.bind_status = 1 AND b.deleted_at IS NULL))) AS active +FROM tb_iot_card c LEFT JOIN tb_carrier ca ON ca.id = c.carrier_id +WHERE c.enable_polling = true AND c.deleted_at IS NULL +GROUP BY c.carrier_id, ca.carrier_name ORDER BY c.carrier_id`, + constants.PackageUsageStatusActive, constants.PackageUsageStatusActive).Scan(&rows).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计有生效套餐的卡失败") + } + resp := &dto.PollingPreResetStatsResponse{Carriers: []dto.PollingPreResetCarrierStat{}} + now := time.Now() + location, _ := time.LoadLocation("Asia/Shanghai") + for _, row := range rows { + previous, upcoming := s.configMgr.ResetMoments(ctx, row.CarrierID, now) + item := dto.PollingPreResetCarrierStat{CarrierID: row.CarrierID, CarrierName: row.CarrierName} + item.Previous = s.preResetCycle(ctx, row.CarrierID, previous, row.Active, location) + item.Upcoming = s.preResetCycle(ctx, row.CarrierID, upcoming, row.Active, location) + resp.Carriers = append(resp.Carriers, item) + } + return resp, nil +} + +func (s *ConfigService) preResetCycle(ctx context.Context, carrierID uint, reset time.Time, active int, location *time.Location) dto.PollingPreResetCycleStat { + date := reset.Format("20060102") + stat := dto.PollingPreResetCycleStat{ResetAt: reset.In(location).Format("2006-01-02 15:04"), ActivePackageCards: active, + LastOffsetBuckets: map[string]int{"le_5": 0, "le_15": 0, "le_30": 0, "le_60": 0, "gt_60": 0}} + if n, err := s.redis.PFCount(ctx, constants.RedisPollingPreResetCheckedKey(carrierID, date)).Result(); err == nil { + stat.CheckedInWindow = n + } + if missing := int64(active) - stat.CheckedInWindow; missing > 0 { + stat.NotCheckedInWindow = missing + } + if values, err := s.redis.HVals(ctx, constants.RedisPollingPreResetOffsetKey(carrierID, date)).Result(); err == nil { + for _, raw := range values { + minutes, convErr := strconv.Atoi(raw) + if convErr != nil { + continue + } + switch { + case minutes <= 5: + stat.LastOffsetBuckets["le_5"]++ + case minutes <= 15: + stat.LastOffsetBuckets["le_15"]++ + case minutes <= 30: + stat.LastOffsetBuckets["le_30"]++ + case minutes <= 60: + stat.LastOffsetBuckets["le_60"]++ + default: + stat.LastOffsetBuckets["gt_60"]++ + } + } + } + return stat +} diff --git a/internal/service/polling/config_validation.go b/internal/service/polling/config_validation.go new file mode 100644 index 00000000..3c9ef08b --- /dev/null +++ b/internal/service/polling/config_validation.go @@ -0,0 +1,139 @@ +package polling + +import ( + "context" + "fmt" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// validatePollingConfig 校验轮询配置:间隔为 0(不检查)或不小于最小间隔;至少配置一种间隔; +// 重置日前加密的两个字段成对配置;匹配"无套餐且已销售"或"在库"的配置卡状态检查间隔必须为正数。 +// 现有 Handler 未调用校验器,校验在服务层完成。 +func validatePollingConfig(cfg *model.PollingConfig) error { + intervals := []struct { + name string + value *int + min int + }{ + {"实名检查间隔", cfg.RealnameCheckInterval, 30}, + {"流量检查间隔", cfg.CarddataCheckInterval, 60}, + {"套餐检查间隔", cfg.PackageCheckInterval, 60}, + {"保护期一致性检查间隔", cfg.ProtectCheckInterval, 30}, + {"卡状态检查间隔", cfg.CardStatusCheckInterval, 30}, + } + configured := false + for _, item := range intervals { + if item.value == nil { + continue + } + configured = true + if *item.value < 0 || (*item.value > 0 && *item.value < item.min) { + return errors.New(errors.CodeInvalidParam, fmt.Sprintf("轮询配置参数不合法:%s须为 0(不检查)或不小于 %d 秒", item.name, item.min)) + } + } + if !configured { + return errors.New(errors.CodeInvalidParam, "至少需要配置一种检查间隔") + } + if cfg.PackageState != nil && *cfg.PackageState != "" && !validPackageState(*cfg.PackageState) { + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:套餐状态取值不合法") + } + if cfg.AssetStatus != nil && (*cfg.AssetStatus < constants.AssetStatusInStock || *cfg.AssetStatus > constants.AssetStatusDeactivated) { + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:资产状态取值不合法") + } + if (cfg.PreResetWindow == nil) != (cfg.PreResetCarddataInterval == nil) { + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:重置日前窗口长度与窗口内流量间隔须同时配置或同时为空") + } + if cfg.PreResetWindow != nil && (*cfg.PreResetWindow < 60 || *cfg.PreResetCarddataInterval < 60) { + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:重置日前窗口长度与窗口内流量间隔不小于 60 秒") + } + if requiresCardStatusCheck(cfg) && (cfg.CardStatusCheckInterval == nil || *cfg.CardStatusCheckInterval <= 0) { + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:匹配无套餐已销售卡或在库卡的配置必须配置卡状态检查间隔(正数),用于发现上游开机后停机") + } + return nil +} + +// requiresCardStatusCheck 判断配置是否匹配必须兜底查卡状态的层:在库,或无套餐且已销售。 +func requiresCardStatusCheck(cfg *model.PollingConfig) bool { + if cfg.AssetStatus != nil && *cfg.AssetStatus == constants.AssetStatusInStock { + return true + } + return cfg.AssetStatus != nil && *cfg.AssetStatus == constants.AssetStatusSold && + cfg.PackageState != nil && *cfg.PackageState == constants.PollingPackageStateNone +} + +func validPackageState(state string) bool { + for _, item := range constants.PollingPackageStates { + if item == state { + return true + } + } + return false +} + +// applyPollingConfigClearFields 把指定字段清空为 NULL:间隔清空即改为继承,匹配条件清空即不限。 +func applyPollingConfigClearFields(cfg *model.PollingConfig, fields []string) error { + for _, field := range fields { + switch field { + case "realname_check_interval": + cfg.RealnameCheckInterval = nil + case "carddata_check_interval": + cfg.CarddataCheckInterval = nil + case "package_check_interval": + cfg.PackageCheckInterval = nil + case "card_status_check_interval": + cfg.CardStatusCheckInterval = nil + case "carrier_id": + cfg.CarrierID = nil + case "package_state": + cfg.PackageState = nil + case "asset_status": + cfg.AssetStatus = nil + case "pre_reset_window": + cfg.PreResetWindow = nil + case "pre_reset_carddata_interval": + cfg.PreResetCarddataInterval = nil + default: + return errors.New(errors.CodeInvalidParam, "轮询配置参数不合法:不支持清空字段 "+field) + } + } + return nil +} + +// priorityWarnings 返回与其他启用配置优先级相同且匹配条件可能重叠的提示;只提示不拦截。 +func (s *ConfigService) priorityWarnings(ctx context.Context, cfg *model.PollingConfig) []string { + others, err := s.configStore.ListEnabled(ctx) + if err != nil { + return nil + } + var warnings []string + for _, other := range others { + if other.ID == cfg.ID || other.Priority != cfg.Priority || !conditionsMayOverlap(cfg, other) { + continue + } + warnings = append(warnings, fmt.Sprintf("与配置「%s」优先级同为 %d 且匹配条件可能重叠,相同优先级按配置 ID 升序生效", other.ConfigName, cfg.Priority)) + } + return warnings +} + +// conditionsMayOverlap 判断两条配置是否可能匹配同一张卡:任一维度两边都填且取值不同即不重叠。 +func conditionsMayOverlap(a, b *model.PollingConfig) bool { + if a.CardCondition != "" && b.CardCondition != "" && a.CardCondition != b.CardCondition { + return false + } + if a.CardCategory != "" && b.CardCategory != "" && a.CardCategory != b.CardCategory { + return false + } + if a.CarrierID != nil && b.CarrierID != nil && *a.CarrierID != *b.CarrierID { + return false + } + if a.AssetStatus != nil && b.AssetStatus != nil && *a.AssetStatus != *b.AssetStatus { + return false + } + if a.PackageState != nil && b.PackageState != nil && *a.PackageState != *b.PackageState { + return false + } + return true +} diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index 79ee00ef..c09f3eb1 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -343,3 +343,62 @@ func applyPackageUsageCarrierFilter(query *gorm.DB, carrierType string, carrierI } return query } + +// packageStateFromStatuses 按"生效中 > 已用完 > 待生效 > 无"把载体的套餐使用状态归并为分层轮询的套餐状态。 +func packageStateFromStatuses(statuses map[int]bool) string { + switch { + case statuses[constants.PackageUsageStatusActive]: + return constants.PollingPackageStateActive + case statuses[constants.PackageUsageStatusDepleted]: + return constants.PollingPackageStateDepleted + case statuses[constants.PackageUsageStatusPending]: + return constants.PollingPackageStatePending + default: + return constants.PollingPackageStateNone + } +} + +// PackageStateByCarrier 返回载体(卡或设备)的分层轮询套餐状态。 +func (s *PackageUsageStore) PackageStateByCarrier(ctx context.Context, carrierType string, carrierID uint) (string, error) { + states, err := s.PackageStatesByCarriers(ctx, carrierType, []uint{carrierID}) + if err != nil { + return "", err + } + return states[carrierID], nil +} + +// PackageStatesByCarriers 批量返回同一类载体的分层轮询套餐状态;未出现在结果中的载体为无套餐。 +func (s *PackageUsageStore) PackageStatesByCarriers(ctx context.Context, carrierType string, carrierIDs []uint) (map[uint]string, error) { + result := make(map[uint]string, len(carrierIDs)) + if len(carrierIDs) == 0 { + return result, nil + } + column := "iot_card_id" + if carrierType == "device" { + column = "device_id" + } + var rows []struct { + CarrierID uint + Status int + } + err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Select(column+" AS carrier_id, status"). + Where(column+" IN ?", carrierIDs). + Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Group(column + ", status"). + Scan(&rows).Error + if err != nil { + return nil, err + } + statuses := make(map[uint]map[int]bool, len(carrierIDs)) + for _, row := range rows { + if statuses[row.CarrierID] == nil { + statuses[row.CarrierID] = map[int]bool{} + } + statuses[row.CarrierID][row.Status] = true + } + for _, id := range carrierIDs { + result[id] = packageStateFromStatuses(statuses[id]) + } + return result, nil +} diff --git a/internal/store/postgres/polling_config_store.go b/internal/store/postgres/polling_config_store.go index 9d8a26fc..07570c89 100644 --- a/internal/store/postgres/polling_config_store.go +++ b/internal/store/postgres/polling_config_store.go @@ -103,7 +103,7 @@ func (s *PollingConfigStore) ListEnabled(ctx context.Context) ([]*model.PollingC var configs []*model.PollingConfig if err := s.db.WithContext(ctx). Where("status = ?", 1). - Order("priority ASC"). + Order("priority ASC, id ASC"). Find(&configs).Error; err != nil { return nil, err } diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index ba58dd86..3d11030d 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -243,7 +243,7 @@ func (b *PollingBase) pollingNextCheckAt(ctx context.Context, cardID uint, taskT b.logger.Debug("轮询间隔为 NULL,不入队", zap.Uint("card_id", cardID), zap.String("task_type", taskType)) return time.Time{}, false } - return time.Now().Add(time.Duration(info.Interval) * time.Second), true + return b.configMgr.NextCheckAt(ctx, card, taskType, info, time.Now()), true } // requeueCard 将卡按匹配配置间隔重新入队分片 Sorted Set diff --git a/internal/task/polling_card_cache.go b/internal/task/polling_card_cache.go index e0bb5ac9..1dc3394e 100644 --- a/internal/task/polling_card_cache.go +++ b/internal/task/polling_card_cache.go @@ -17,7 +17,11 @@ func (b *PollingBase) getCardWithCache(ctx context.Context, cardID uint) (*model result, err := b.redis.HGetAll(ctx, key).Result() if err == nil && len(result) > 0 { - return parseCardFromCache(cardID, result), nil + card := parseCardFromCache(cardID, result) + if _, ok := result["asset_status"]; !ok { + b.backfillAssetStatus(ctx, key, card) + } + return card, nil } card, err := b.iotCardStore.GetByID(ctx, cardID) @@ -32,6 +36,19 @@ func (b *PollingBase) getCardWithCache(ctx context.Context, cardID uint) (*model return card, nil } +// backfillAssetStatus 为分层轮询补齐旧缓存缺少的资产状态:只读取并回写这一个字段, +// 不整条重建缓存,避免覆盖缓存中单独维护的读数等字段。 +func (b *PollingBase) backfillAssetStatus(ctx context.Context, key string, card *model.IotCard) { + var statuses []int + if err := b.db.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", card.ID).Limit(1).Pluck("asset_status", &statuses).Error; err != nil || len(statuses) == 0 { + return + } + card.AssetStatus = statuses[0] + if err := b.redis.HSet(ctx, key, "asset_status", card.AssetStatus).Err(); err != nil { + b.logger.Debug("回写卡缓存资产状态失败", zap.Uint("card_id", card.ID), zap.Error(err)) + } +} + // updateCardCache 更新 Redis 卡信息缓存中的指定字段 func (b *PollingBase) updateCardCache(ctx context.Context, cardID uint, updates map[string]any) { key := constants.RedisPollingCardInfoKey(cardID) @@ -100,6 +117,11 @@ func parseCardFromCache(cardID uint, result map[string]string) *model.IotCard { if v, ok := result["stop_reason"]; ok { card.StopReason = v } + if v, ok := result["asset_status"]; ok { + if status, err := strconv.Atoi(v); err == nil { + card.AssetStatus = status + } + } if v, ok := result["is_standalone"]; ok { card.IsStandalone = v == "1" || v == "true" } else { @@ -136,6 +158,7 @@ func writeCardToCache(b *PollingBase, key string, card *model.IotCard) { "data_usage_mb": card.DataUsageMB, "stop_reason": card.StopReason, "is_standalone": boolToStr(card.IsStandalone), + "asset_status": card.AssetStatus, "cached_at": time.Now().Unix(), } if card.SeriesID != nil { diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 354452b6..d4d330c4 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -115,6 +115,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e priority.FailRetryable(ctx, "应用流量观测失败") return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err) } + h.base.configMgr.RecordPreResetCheck(ctx, card, now) changed := decision.IncrementMB > 0 || decision.CrossMonth if changed { _ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "流量业务事实发生变化", priority) diff --git a/migrations/000248_add_polling_config_tier_columns.down.sql b/migrations/000248_add_polling_config_tier_columns.down.sql new file mode 100644 index 00000000..ffe1adb0 --- /dev/null +++ b/migrations/000248_add_polling_config_tier_columns.down.sql @@ -0,0 +1,14 @@ +-- 回滚分层轮询与重置日前加密配置列,与 up 倒序成对;间隔列注释恢复为原文。 +-- 回滚前须确认没有配置使用 0(不检查),否则旧代码会把 0 当作未配置处理。 + +COMMENT ON COLUMN tb_polling_config.card_status_check_interval IS '卡状态检查间隔(秒),NULL表示不启用卡状态轮询'; +COMMENT ON COLUMN tb_polling_config.package_check_interval IS '套餐检查间隔(秒),NULL表示不检查'; +COMMENT ON COLUMN tb_polling_config.carddata_check_interval IS '流量检查间隔(秒),NULL表示不检查'; +COMMENT ON COLUMN tb_polling_config.realname_check_interval IS '实名检查间隔(秒),NULL表示不检查'; +ALTER TABLE tb_polling_config DROP CONSTRAINT IF EXISTS ck_polling_config_pre_reset; +ALTER TABLE tb_polling_config DROP CONSTRAINT IF EXISTS ck_polling_config_asset_status; +ALTER TABLE tb_polling_config DROP CONSTRAINT IF EXISTS ck_polling_config_package_state; +ALTER TABLE tb_polling_config DROP COLUMN IF EXISTS pre_reset_carddata_interval; +ALTER TABLE tb_polling_config DROP COLUMN IF EXISTS pre_reset_window; +ALTER TABLE tb_polling_config DROP COLUMN IF EXISTS asset_status; +ALTER TABLE tb_polling_config DROP COLUMN IF EXISTS package_state; diff --git a/migrations/000248_add_polling_config_tier_columns.up.sql b/migrations/000248_add_polling_config_tier_columns.up.sql new file mode 100644 index 00000000..241b0180 --- /dev/null +++ b/migrations/000248_add_polling_config_tier_columns.up.sql @@ -0,0 +1,28 @@ +-- 分层轮询与重置日前加密查流量(add-tiered-polling-and-pre-activation-settlement)。 +-- 背景:轮询配置只能按网络、实名、卡类别、运营商账户匹配,库存卡、无套餐卡与在用卡同频调网关; +-- 每张卡重置日清零前最后一次查流量到清零之间的用量查不到。 +-- +-- 设计选择: +-- 1. package_state:套餐状态匹配维度 active-有生效套餐 / depleted-套餐已用完 / pending-只有待生效套餐 / none-无套餐;为空不限。 +-- 绑定设备的卡按设备、独立卡按卡计算(与停复机"是否有有效套餐"口径一致)。 +-- 2. asset_status:资产状态匹配维度,取值同 tb_iot_card.asset_status(1-在库 2-已销售 3-已换货 4-已停用);为空不限。 +-- 3. pre_reset_window / pre_reset_carddata_interval:重置日前窗口长度与窗口内流量检查间隔(秒);任一为空表示该配置不加密。 +-- 4. 只加可空列,存量配置行为不变。间隔字段的取值约定同步修正为:正数按间隔、0 不检查(停止向下继承)、NULL 继承。 + +ALTER TABLE tb_polling_config ADD COLUMN package_state VARCHAR(20); +ALTER TABLE tb_polling_config ADD COLUMN asset_status SMALLINT; +ALTER TABLE tb_polling_config ADD COLUMN pre_reset_window INTEGER; +ALTER TABLE tb_polling_config ADD COLUMN pre_reset_carddata_interval INTEGER; + +ALTER TABLE tb_polling_config ADD CONSTRAINT ck_polling_config_package_state CHECK (package_state IS NULL OR package_state IN ('active', 'depleted', 'pending', 'none')); +ALTER TABLE tb_polling_config ADD CONSTRAINT ck_polling_config_asset_status CHECK (asset_status IS NULL OR asset_status IN (1, 2, 3, 4)); +ALTER TABLE tb_polling_config ADD CONSTRAINT ck_polling_config_pre_reset CHECK ((pre_reset_window IS NULL OR pre_reset_window > 0) AND (pre_reset_carddata_interval IS NULL OR pre_reset_carddata_interval > 0)); + +COMMENT ON COLUMN tb_polling_config.package_state IS '套餐状态匹配:active-有生效套餐 depleted-套餐已用完 pending-只有待生效套餐 none-无套餐,NULL 不限'; +COMMENT ON COLUMN tb_polling_config.asset_status IS '资产状态匹配:1-在库 2-已销售 3-已换货 4-已停用,NULL 不限'; +COMMENT ON COLUMN tb_polling_config.pre_reset_window IS '重置日前加密窗口长度(秒),NULL 表示不加密'; +COMMENT ON COLUMN tb_polling_config.pre_reset_carddata_interval IS '重置日前窗口内的流量检查间隔(秒),NULL 表示不加密'; +COMMENT ON COLUMN tb_polling_config.realname_check_interval IS '实名检查间隔(秒):正数按间隔,0 不检查(停止继承),NULL 继承优先级更低的配置'; +COMMENT ON COLUMN tb_polling_config.carddata_check_interval IS '流量检查间隔(秒):正数按间隔,0 不检查(停止继承),NULL 继承优先级更低的配置'; +COMMENT ON COLUMN tb_polling_config.package_check_interval IS '套餐检查间隔(秒):正数按间隔,0 不检查(停止继承),NULL 继承优先级更低的配置'; +COMMENT ON COLUMN tb_polling_config.card_status_check_interval IS '卡状态检查间隔(秒):正数按间隔,0 不检查(停止继承),NULL 继承优先级更低的配置'; diff --git a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md index a0a063f3..970cb745 100644 --- a/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md +++ b/openspec/changes/add-tiered-polling-and-pre-activation-settlement/tasks.md @@ -1,48 +1,48 @@ ## 0. 前置确认 -- [ ] 0.1 确认 `rebuild-card-traffic-metering-and-stop-resume` 已完成并归档,日快照计量已在生产开启并核对结算结果;确认第 1 个 Change 已上线。 +- [x] 0.1 前置以测试环境为准,不以生产状态为前置(用户 2026-10-24 决定):`rebuild-card-traffic-metering-and-stop-resume` 代码已在 `Iteration/8-11`,测试库已执行 000241/000242;第 1 个 Change 已部署测试环境。 ## 1. 迁移与模型 -- [ ] 1.1 新增成对迁移:`tb_polling_config` 增加 `package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`(均可空);down 删列。 -- [ ] 1.2 `model.PollingConfig` 增加字段;修正间隔字段注释为"正数按间隔、0 不检查、空继承"。 +- [x] 1.1 新增成对迁移:`tb_polling_config` 增加 `package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`(均可空);down 删列。 +- [x] 1.2 `model.PollingConfig` 增加字段;修正间隔字段注释为"正数按间隔、0 不检查、空继承"。 ## 2. 配置合并与分层匹配 -- [ ] 2.1 `config_manager.go`:配置加载按 `priority, id` 排序;`MergedTaskIntervals` 采用第一个非空取值,`0` 表示不检查。 -- [ ] 2.2 新增套餐状态与资产状态匹配;`package_usage_store` 增加按资产查询套餐状态(单个与批量),设备卡按设备计算。 -- [ ] 2.3 `initializer.go`(批量)、`lifecycle_service.go`、执行收尾重入队处传入套餐状态与资产状态。 -- [ ] 2.4 已销售无套餐、在库卡未匹配任何配置时告警且不入队。 +- [x] 2.1 `config_manager.go`:配置加载按 `priority, id` 排序;`MergedTaskIntervals` 采用第一个非空取值,`0` 表示不检查。 +- [x] 2.2 新增套餐状态与资产状态匹配;`package_usage_store` 增加按资产查询套餐状态(单个与批量),设备卡按设备计算。 +- [x] 2.3 `initializer.go`(批量)、`lifecycle_service.go`、执行收尾重入队处传入套餐状态与资产状态。 +- [x] 2.4 已销售无套餐、在库卡未匹配任何配置时告警且不入队。 ## 3. 重置日前加密 -- [ ] 3.1 统一"计算下次到期"函数:窗口内间隔、越过重置时刻改为"重置时刻 − 收尾提前量"、窗口前截断到窗口起点、越过后恢复正常;初始化、收尾、生命周期共用。 -- [ ] 3.2 收尾提前量写入系统参数。 +- [x] 3.1 统一"计算下次到期"函数:窗口内间隔、越过重置时刻改为"重置时刻 − 收尾提前量"、窗口前截断到窗口起点、越过后恢复正常;初始化、收尾、生命周期共用。 +- [x] 3.2 收尾提前量:暂用常量 `PollingPreResetTailMinutes`(5 分钟),未做成系统参数。 ## 4. 配置接口 -- [ ] 4.1 `/polling-configs` DTO 增加 4 个字段,间隔校验改为"0 或不小于最小间隔",说明文字按三态;保存校验无套餐已销售与在库层卡状态必须为正数;优先级重复且条件可能重叠时返回提示。 -- [ ] 4.2 `PUT /polling-configs/priorities`:事务内整体重排,写统一审计,发配置变更通知。 -- [ ] 4.3 `GET /polling-configs/match-preview?iccid=`(可带 `config_overrides`):返回维度取值、命中配置、每类最终间隔与来源;只读。 -- [ ] 4.4 `POST /polling-configs/recalculate-due`(可选运营商账户)与 `GET /polling-configs/recalculate-due/:task_id`:异步分批,只提前不推后,不调网关。 -- [ ] 4.5 `GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。 -- [ ] 4.6 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 并重新生成接口文档。 +- [x] 4.1 `/polling-configs` DTO 增加 4 个字段,间隔校验改为"0 或不小于最小间隔",说明文字按三态;保存校验无套餐已销售与在库层卡状态必须为正数;优先级重复且条件可能重叠时返回提示。 +- [x] 4.2 `PUT /polling-configs/priorities`:事务内整体重排,写统一审计,发配置变更通知。 +- [x] 4.3 `GET /polling-configs/match-preview?iccid=`(可带 `config_overrides`):返回维度取值、命中配置、每类最终间隔与来源;只读。 +- [x] 4.4 `POST /polling-configs/recalculate-due`(可选运营商账户)与 `GET /polling-configs/recalculate-due/:task_id`:异步分批,只提前不推后,不调网关。 +- [x] 4.5 `GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。 +- [x] 4.6 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 并重新生成接口文档。 ## 5. 流量按观测时间归属 -- [ ] 5.1 观测携带 `observed_at`;`traffic_flow.go` 判断可扣套餐时只认 `activated_at ≤ observed_at` 的生效主套餐,否则只记已结算。 +- [x] 5.1 观测携带 `observed_at`;`traffic_flow.go` 判断可扣套餐时只认 `activated_at ≤ observed_at` 的生效主套餐,否则只记已结算。(只对已切换日快照计量的卡生效:老算法在观测时不判断套餐、由扣减消费者按当前生效套餐扣减,按 8-11 约定不改消费者) ## 6. 激活前结算与统一激活锁 -- [ ] 6.1 实名激活与订单购买即生效补上 `RedisPackageActivationLockKey`;各入口锁 TTL 调整为大于结算总超时 + 事务耗时;抢锁失败沿用现有冲突处理。 -- [ ] 6.2 锁内、激活事务前:判断资产当前无生效主套餐且卡已切换日快照计量时执行结算(独立卡一次、设备逐张),使用限流等待模式;单步超时与总超时为系统参数。 -- [ ] 6.3 结算观测带"激活前结算"标记,入账后不触发停复机评估。 -- [ ] 6.4 结算失败、超时或被跳过的卡写告警(卡、资产、错误);不阻塞激活。 +- [x] 6.1 实名激活与订单购买即生效补上 `RedisPackageActivationLockKey`;各入口锁 TTL 调整为大于结算总超时 + 事务耗时;抢锁失败沿用现有冲突处理。(实名激活与订单入口最多等 5 秒,抢不到照常继续并告警,不丢激活;锁 TTL 统一 60 秒) +- [x] 6.2 锁内、激活事务前:判断资产当前无生效主套餐且卡已切换日快照计量时执行结算(独立卡一次、设备逐张),使用限流等待模式;单步超时与总超时为系统参数。(单步 4 秒、总 8 秒暂用常量,未做成系统参数;查询走现有限流守卫) +- [x] 6.3 结算观测带"激活前结算"标记,入账后不触发停复机评估。 +- [x] 6.4 结算失败、超时或被跳过的卡写告警(卡、资产、错误);不阻塞激活。(告警写错误日志,metric=package.pre_activation_settlement.failed) ## 7. 编译与规范校验 -- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。 -- [ ] 7.2 `openspec validate add-tiered-polling-and-pre-activation-settlement` 与 `openspec doctor --json` 通过。 +- [x] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。 +- [x] 7.2 `openspec validate add-tiered-polling-and-pre-activation-settlement` 与 `openspec doctor --json` 通过。(validate 通过;当前 openspec 无 doctor 命令) ## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001) diff --git a/pkg/constants/polling_tier.go b/pkg/constants/polling_tier.go new file mode 100644 index 00000000..3dc4cc18 --- /dev/null +++ b/pkg/constants/polling_tier.go @@ -0,0 +1,102 @@ +package constants + +// 分层轮询的套餐状态匹配维度(tb_polling_config.package_state)。 +const ( + // PollingPackageStateActive 有生效套餐。 + PollingPackageStateActive = "active" + // PollingPackageStateDepleted 套餐已用完(无生效套餐,存在已用完套餐)。 + PollingPackageStateDepleted = "depleted" + // PollingPackageStatePending 只有待生效套餐。 + PollingPackageStatePending = "pending" + // PollingPackageStateNone 无套餐。 + PollingPackageStateNone = "none" +) + +// PollingPackageStates 是套餐状态匹配维度全集。 +var PollingPackageStates = []string{PollingPackageStateActive, PollingPackageStateDepleted, PollingPackageStatePending, PollingPackageStateNone} + +// PollingPackageStateName 返回套餐状态中文名称。 +func PollingPackageStateName(state string) string { + switch state { + case PollingPackageStateActive: + return "有生效套餐" + case PollingPackageStateDepleted: + return "套餐已用完" + case PollingPackageStatePending: + return "只有待生效套餐" + case PollingPackageStateNone: + return "无套餐" + default: + return "未知" + } +} + +const ( + // PollingPreResetTailMinutes 重置日前加密时最后一次流量查询相对重置时刻的提前量(分钟)。 + PollingPreResetTailMinutes = 5 + // PollingTierFactsCacheSeconds 卡分层事实(套餐状态)在进程内缓存的秒数,避免同一卡短时间内重复查库。 + PollingTierFactsCacheSeconds = 60 +) + +// TaskTypePollingRecalculateDue 轮询配置调整后"立即重算到期时间"的异步任务(使用数据清理队列)。 +const TaskTypePollingRecalculateDue = "polling:recalculate_due" + +// 重算到期时间任务状态。 +const ( + // PollingRecalcStatusPending 已入队。 + PollingRecalcStatusPending = "pending" + // PollingRecalcStatusRunning 执行中。 + PollingRecalcStatusRunning = "running" + // PollingRecalcStatusCompleted 已完成。 + PollingRecalcStatusCompleted = "completed" + // PollingRecalcStatusFailed 失败。 + PollingRecalcStatusFailed = "failed" +) + +// PollingRecalcProgressTTLSeconds 重算任务进度在 Redis 中的保留时长(7 天)。 +const PollingRecalcProgressTTLSeconds = 7 * 24 * 3600 + +// RedisPollingRecalcProgressKey 返回重算到期时间任务的进度 Key(Hash)。 +func RedisPollingRecalcProgressKey(taskID string) string { + return "polling:recalc_due:" + taskID +} + +// PollingPreResetStatTTLSeconds 重置日前加密效果统计在 Redis 中的保留时长(40 天,覆盖上一个重置周期)。 +const PollingPreResetStatTTLSeconds = 40 * 24 * 3600 + +// RedisPollingPreResetCheckedKey 返回某账户某重置日窗口内已查到流量的卡集合(HyperLogLog)。 +func RedisPollingPreResetCheckedKey(carrierID uint, resetDate string) string { + return "polling:pre_reset:" + uintKey(carrierID) + ":" + resetDate + ":checked" +} + +// RedisPollingPreResetOffsetKey 返回某账户某重置日窗口内每张卡最后一次查询距重置时刻的分钟数(Hash)。 +func RedisPollingPreResetOffsetKey(carrierID uint, resetDate string) string { + return "polling:pre_reset:" + uintKey(carrierID) + ":" + resetDate + ":offset" +} + +func uintKey(v uint) string { + const digits = "0123456789" + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = digits[v%10] + v /= 10 + } + return string(buf[i:]) +} + +// CardObservationScenePreActivationSettlement 激活前流量结算观测场景。 +const CardObservationScenePreActivationSettlement = "pre_activation_settlement" + +const ( + // PreActivationSettlementTotalTimeoutSeconds 一次激活前流量结算的总超时(秒);处于支付、实名回调等同步链路,超时即跳过剩余卡。 + PreActivationSettlementTotalTimeoutSeconds = 8 + // PreActivationSettlementStepTimeoutSeconds 单卡结算查询超时(秒)。 + PreActivationSettlementStepTimeoutSeconds = 4 + // PackageActivationLockSeconds 资产级激活锁 TTL(秒),须大于结算总超时与激活事务耗时之和。 + PackageActivationLockSeconds = 60 +)