feat(通道流量阈值): AUG26-011 运营商通道流量阈值达量停机与周期复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m59s

This commit is contained in:
2026-09-16 15:54:49 +08:00
parent 41722760b1
commit 59b3df868a
36 changed files with 2431 additions and 93 deletions

View File

@@ -18,6 +18,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
@@ -29,6 +30,7 @@ import (
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
@@ -89,6 +91,8 @@ type workerRuntime struct {
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
refundChannelService *refundchannelApp.Service
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
carrierThresholdService *carrierThresholdApp.Service
}
func main() {
@@ -161,6 +165,7 @@ func runWorker(cfg *config.Config) {
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
@@ -311,6 +316,9 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.CarrierThreshold == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
runtime := &workerRuntime{
redisAddr: redisAddr,
@@ -328,14 +336,35 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingIotCardStore: pollingIotCardStore,
pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc,
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源重试、Integration Log、统一审计与既有判定
carrierThresholdService: workerResult.Services.CarrierThreshold,
}
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
return runtime
}
// registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。
// 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。
func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
consumer := carrierThresholdApp.NewConsumer(runtime.carrierThresholdService)
for _, eventType := range []string{
carrierThresholdApp.EventCarrierThresholdStop,
carrierThresholdApp.EventCarrierThresholdResume,
} {
if err := runtime.outboxConsumers.Register(eventType, consumer); err != nil {
appLogger.Fatal("注册运营商通道流量阈值 Outbox 消费者失败",
zap.String("event_type", eventType), zap.Error(err))
}
}
}
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
@@ -523,6 +552,20 @@ func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery))
}
// registerCarrierThresholdTasks 注册运营商通道流量阈值的周期处理与结果恢复任务。
// 两个任务共用同一用例实例:周期处理负责跨期解锁与条件复机,恢复扫描只查询状态回填,绝不重复发起停复机。
func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.carrierThresholdService == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
cycleHandler := carrierThresholdInfra.NewCycleTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdCycle, cycleHandler.Handle)
appLogger.Info("注册运营商通道流量阈值周期处理任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdCycle))
recoveryHandler := carrierThresholdInfra.NewRecoveryTaskHandler(runtime.carrierThresholdService)
mux.HandleFunc(constants.TaskTypeCarrierThresholdRecovery, recoveryHandler.Handle)
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -797,6 +840,26 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeCarrierThresholdCycle,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdCycle)),
)); err != nil {
return fmt.Errorf("注册运营商通道流量阈值周期处理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeCarrierThresholdRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdRecovery)),
)); err != nil {
return fmt.Errorf("注册运营商通道流量阈值结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeRefundCommissionRecovery,
nil,

View File

@@ -4287,5 +4287,19 @@
"h5-popup-notification::风险换卡候选与地址提交"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeCarrierThresholdCycle",
"capability": "carrier-channel-traffic-threshold",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeCarrierThresholdRecovery",
"capability": "carrier-channel-traffic-threshold",
"requirements": [],
"classification": "route_index_or_infrastructure"
}
]

View File

@@ -7,6 +7,7 @@ import (
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -42,6 +43,12 @@ type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// ChannelThresholdEvaluator 在卡流量事务内判定运营商通道流量阈值。
// 实现必须在调用方事务内写周期锁与停机事件,使锁事实与流量事实同事务提交。
type ChannelThresholdEvaluator interface {
EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation carrierthresholddomain.Evaluation) error
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
@@ -64,6 +71,8 @@ type Service struct {
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
}
// NewService 创建卡实名观测应用服务。
@@ -71,6 +80,11 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetChannelThresholdEvaluator 注入运营商通道流量阈值达量判定能力。
func (s *Service) SetChannelThresholdEvaluator(evaluator ChannelThresholdEvaluator) {
s.channelThreshold = evaluator
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -80,6 +81,16 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return err
}
}
if decision.ReadingAccepted && s.channelThreshold != nil {
// 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID,
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
}); err != nil {
return err
}
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {

View File

@@ -0,0 +1,258 @@
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// cycleBatchSize 是单次周期处理扫描的锁行上限。
const cycleBatchSize = 200
// CycleStats 是一次周期处理的可观察结果。
//
// Scanned 为扫到的跨期持锁数Unlocked 为本次认领解锁成功的锁数Resumed 为同时写出复机事件的锁数;
// Skipped 为本地事实缺失、被并发推进或判定失败而未改动的锁数。
type CycleStats struct{ Scanned, Unlocked, Resumed, Skipped int }
// ProcessDueLocks 扫描已跨期的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
//
// 过期判断按锁行自身运营商的 data_reset_day支持换运营商后旧锁仍按其归属处理。解锁与复机事件
// 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。
// 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。
func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) {
stats := CycleStats{}
if s == nil || s.db == nil || s.repository == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置")
}
locks, err := s.ScanExpiredLocks(ctx, now, cycleBatchSize)
if err != nil {
return stats, err
}
stats.Scanned = len(locks)
if len(locks) == 0 {
return stats, nil
}
if s.commander == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
var firstErr error
for index := range locks {
lock := locks[index]
if err := s.processDueLock(ctx, &lock, &stats); err != nil {
stats.Skipped++
s.logger.Warn("通道阈值跨期处理单条失败",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// processDueLock 处理单条跨期锁:认领解锁并在条件满足时同事务写复机事件。
func (s *Service) processDueLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, stats *CycleStats) error {
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
stats.Skipped++
s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。
ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID)
if err != nil {
// 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。
return err
}
unlocked := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
if unlockErr != nil {
return unlockErr
}
if !claimed {
return nil
}
unlocked = true
if !ready {
return nil
}
return AppendResumeRequested(ctx, tx, s.repository, lock)
})
if err != nil {
return err
}
if !unlocked {
stats.Skipped++
s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
return nil
}
stats.Unlocked++
if !ready {
s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason))
return nil
}
stats.Resumed++
s.logger.Info("通道阈值新周期条件满足,已写复机事件",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
return nil
}
// recoveryBatchSize 是单次恢复扫描的锁行上限。
const recoveryBatchSize = 200
// RecoveryStats 是一次恢复扫描的可观察结果。
//
// Scanned 为扫到的待确认锁数Confirmed 为本次回填为运营商已确认的锁数Pending 为结果仍未知、
// 等待下次扫描的锁数Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数;
// Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。
type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int }
// RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。
//
// 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但
// 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常
// 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。
func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) {
stats := RecoveryStats{}
if s == nil || s.db == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置")
}
locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize)
if err != nil {
return stats, err
}
stats.Scanned = len(locks)
if len(locks) == 0 {
return stats, nil
}
if s.commander == nil {
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
var firstErr error
for index := range locks {
lock := locks[index]
if err := s.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("通道阈值恢复扫描单条失败",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
if lock.StopStatus != domain.TaskStatusSubmitted && lock.ResumeStatus != domain.TaskStatusSubmitted {
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
status, known := constants.NetworkStatusOffline, false
if card != nil {
// 查询失败按「仍未确认」处理,绝不误判为失败终态。
status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID)
if err != nil {
s.logger.Warn("查询运营商卡状态失败,按仍未确认处理",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
known = false
}
}
confirmed := 0
unconfirmed := 0
if lock.StopStatus == domain.TaskStatusSubmitted {
switch {
case known && status == constants.NetworkStatusOffline:
confirmed++
if err := s.confirmStop(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if lock.ResumeStatus == domain.TaskStatusSubmitted {
switch {
case known && status == constants.NetworkStatusOnline:
confirmed++
if err := s.confirmResume(ctx, lock, card); err != nil {
return err
}
default:
unconfirmed++
}
}
if confirmed == 0 {
stats.Pending++
} else {
stats.Confirmed++
}
if unconfirmed == 0 {
return nil
}
// 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。
if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) {
return nil
}
marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对")
if err != nil {
return err
}
if marked {
stats.Anomaly++
s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工",
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID),
zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)),
zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt)))
}
return nil
}
// confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。
// 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。
func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOffline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID)
return err
}
// confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。
func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
if card != nil && card.NetworkStatus != constants.NetworkStatusOnline {
if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil {
return err
}
}
_, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID)
return err
}
// valueOrZero 在日志中安全展开可空的提交时刻。
func valueOrZero(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}

View File

@@ -0,0 +1,107 @@
package carrierthreshold
import (
"context"
"strconv"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
//
// 判定前提由调用方保证只有「读数被接受」ReadingAccepted的观测才进入本方法
// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeInternalError, "通道流量阈值判定能力未完整配置")
}
if tx == nil {
return errors.New(errors.CodeInvalidStatus, "通道流量阈值判定必须传入事务句柄")
}
if evaluation.CardID == 0 || evaluation.CarrierID == 0 {
return nil
}
var carrier model.Carrier
// 只取判定所需列:周期起点必须来自该运营商的 data_reset_day漏取会让周期判定失去依据。
if err := tx.WithContext(ctx).Select("id", "data_reset_day", "traffic_threshold_enabled", "traffic_threshold_value", "traffic_threshold_unit").
Where("id = ?", evaluation.CarrierID).First(&carrier).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 卡引用的运营商已不存在:不判定、不建锁,保持与既有卡事实不一致的现状。
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商通道流量阈值配置失败")
}
threshold := thresholdOf(carrier)
if !threshold.Enabled {
return nil
}
if !threshold.Valid() {
// 配置半残(启用但无数值/单位,或单位未知)时绝不按 0 判定,跳过并留可观测日志。
s.logger.Warn("运营商通道流量阈值配置不完整,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.String("unit", threshold.Unit), zap.Float64("value", threshold.Value))
return nil
}
reached, err := threshold.Reached(evaluation.ReadingMB)
if err != nil {
return err
}
if !reached {
return nil
}
periodStart, err := domain.PeriodStart(evaluation.ObservedAt, carrier.DataResetDay)
if err != nil {
s.logger.Warn("运营商上游流量重置日非法,跳过达量判定",
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
return nil
}
lock := &model.CarrierTrafficThresholdLock{
CarrierID: evaluation.CarrierID,
CardID: evaluation.CardID,
PeriodStart: periodStart,
Status: domain.LockStatusLocked,
StopStatus: domain.TaskStatusPending,
ResumeStatus: domain.TaskStatusPending,
}
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
created, err := s.createLockInTx(ctx, tx, lock)
if err != nil {
return err
}
if !created {
s.logger.Info("该计费周期已存在通道阈值停机锁,跳过重复判定",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID))
return nil
}
if err := AppendStopRequested(ctx, tx, s.repository, lock); err != nil {
return err
}
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
zap.Time("period_start", periodStart))
return nil
}
// thresholdOf 把运营商持久化列转换为领域阈值配置。
func thresholdOf(carrier model.Carrier) domain.Threshold {
threshold := domain.Threshold{
Enabled: carrier.TrafficThresholdEnabled == 1,
Unit: carrier.TrafficThresholdUnit,
}
if carrier.TrafficThresholdValue != nil {
threshold.Value = *carrier.TrafficThresholdValue
}
return threshold
}
// lockKeyValue 返回周期锁在 Outbox 事件中的稳定字符串标识。
func lockKeyValue(lockID uint) string {
return strconv.FormatUint(uint64(lockID), 10)
}

View File

@@ -0,0 +1,159 @@
package carrierthreshold
import (
"context"
stderrors "errors"
"github.com/bytedance/sonic"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// EventCarrierThresholdStop 是卡流量达到通道阈值后的可靠停机事件。
const EventCarrierThresholdStop = "carrier.threshold.stop.requested"
// EventCarrierThresholdResume 是通道阈值跨期解锁且条件满足后的可靠复机事件。
const EventCarrierThresholdResume = "carrier.threshold.resume.requested"
// carrierThresholdPayloadVersion 是通道阈值事件的载荷版本。
const carrierThresholdPayloadVersion = 1
// periodLockConstraint 是周期锁部分唯一索引名,用于把 23505 精确识别为该周期已处理。
const periodLockConstraint = "uq_carrier_traffic_threshold_lock_key"
// StopPayload 是通道阈值停机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type StopPayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendStopRequested 在达量判定事务内幂等写入通道阈值停机事件。
// 事件 ID 由锁 ID 派生同一周期锁重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendStopRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdStop+":", value),
EventType: EventCarrierThresholdStop,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdStop + ":" + value,
Payload: StopPayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// ResumePayload 是通道阈值复机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
type ResumePayload struct {
LockID uint `json:"lock_id"`
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
}
// AppendResumeRequested 在周期处理事务内幂等写入通道阈值复机事件。
// 事件 ID 由锁 ID 派生解锁认领与复机事件同事务写入重复投递不会创建第二个事件ENG-OUTBOX-001
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if lock == nil || lock.ID == 0 {
return gorm.ErrInvalidData
}
value := lockKeyValue(lock.ID)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventCarrierThresholdResume+":", value),
EventType: EventCarrierThresholdResume,
PayloadVersion: carrierThresholdPayloadVersion,
AggregateType: "carrier_traffic_threshold_lock",
AggregateID: value,
ResourceType: "iot_card",
ResourceID: lockKeyValue(lock.CardID),
BusinessKey: EventCarrierThresholdResume + ":" + value,
Payload: ResumePayload{
LockID: lock.ID,
CardID: lock.CardID,
CarrierID: lock.CarrierID,
},
})
return err
}
// isPeriodLockConflict 判断错误是否为周期锁唯一键冲突,即「该周期已处理」。
// 该冲突必须与流量基线 CAS 冲突CodeConflict区分前者幂等跳过后者由调用方重放重试。
func isPeriodLockConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == periodLockConstraint
}
// Consumer 把通道阈值停复机事件转成一次停复机动作。
type Consumer struct {
service *Service
}
// NewConsumer 创建通道阈值停复机事件消费者。
func NewConsumer(service *Service) *Consumer {
return &Consumer{service: service}
}
// Consume 按事件类型幂等执行停机或复机;重复投递由锁行认领字段兜住。
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行能力未配置")
}
lockID, validationErr := decodeThresholdPayload(envelope)
if validationErr != nil {
return validationErr
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
switch envelope.EventType {
case EventCarrierThresholdStop:
return c.service.ExecuteStop(ctx, lockID)
case EventCarrierThresholdResume:
return c.service.ExecuteResume(ctx, lockID)
default:
return outbox.Permanent(gorm.ErrInvalidData)
}
}
// decodeThresholdPayload 校验事件类型与载荷版本并取出锁 ID。
// 载荷不合法属永久失败:重复投递不会改变结果,必须直接终结而不是重试。
func decodeThresholdPayload(envelope outbox.DeliveryEnvelope) (uint, error) {
if envelope.PayloadVersion != carrierThresholdPayloadVersion {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
var payload struct {
LockID uint `json:"lock_id"`
}
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return 0, outbox.Permanent(err)
}
if payload.LockID == 0 {
return 0, outbox.Permanent(gorm.ErrInvalidData)
}
return payload.LockID, nil
}
// 编译期断言:通道阈值停复机消费者满足公共 Outbox 的消费边界。
var _ outbox.EventConsumer = (*Consumer)(nil)

View File

@@ -0,0 +1,199 @@
package carrierthreshold
import (
"context"
"go.uber.org/zap"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 无法从运营商确认结果时的可安全展示原因(不含内部细节与渠道报文)。
const (
failedStopReason = "运营商停机调用失败,等待状态查询确认"
unknownStopReason = "运营商停机结果未知,等待状态查询确认"
failedResumeReason = "运营商复机调用失败,等待状态查询确认"
unknownResumeReason = "运营商复机结果未知,等待状态查询确认"
)
// ExecuteStop 执行一次通道阈值达量停机,保证至多一次外部调用。
//
// 流程提交认领stop_submitted_at IS NULL 且仍持锁)→ 卡已停机则直接确认成功,不调运营商 →
// 否则复用既有停机重试、Integration Log 与统一审计执行停机。认领失败表示该锁已提交过
// (事件重复投递或人工重放),本次只结束,绝不重复调用;结果由恢复扫描查询收敛。
// 任何分支都不删除锁行、不解锁。
func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值停机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimStopSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值停机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("stop_status", lock.StopStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
// 卡事实不存在时无法调运营商,保留 submitted 由恢复扫描按窗口标记异常转人工。
s.logger.Warn("通道阈值停机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOffline {
// 其他停因已先行停机:不重复调用运营商,直接确认本次停机目标已达成。
if _, err := s.markTaskConfirmed(ctx, lockID, stopTask, ""); err != nil {
return err
}
s.logger.Info("卡已停机,通道阈值停机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.String("stop_reason", card.StopReason))
return nil
}
outcome, callErr := s.commander.StopCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
// 基础设施故障(读卡、写审计或写卡状态失败)导致结果无法判定:保留 submitted
// 交由恢复扫描查询确认,本次不判定终态、不重复调用。
s.logger.Error("通道阈值停机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值停机运营商调用未成功,已按结果分类回填",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.Error(callErr))
}
_, err = s.markTaskOutcome(ctx, lockID, stopTask, domain.TaskStatusSubmitted,
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值停机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// ExecuteResume 执行一次通道阈值新周期复机,保证至多一次外部调用。
//
// 流程提交认领resume_submitted_at IS NULL→ 卡已在线则直接确认成功,不调运营商 →
// 否则复用既有复机重试、Integration Log 与统一审计执行复机(成功时同一事务写回卡状态,
// 且只在停因为通道阈值时清除停因)。认领失败表示已提交过,绝不重复调用。
func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
if err := s.requireExecution(); err != nil {
return err
}
lock, err := s.loadLock(ctx, lockID)
if err != nil {
return err
}
if lock == nil {
s.logger.Info("通道阈值复机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
return nil
}
claimed, err := s.ClaimResumeSubmission(ctx, lockID, s.now())
if err != nil {
return err
}
if !claimed {
s.logger.Info("通道阈值复机已提交过,只等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.String("resume_status", lock.ResumeStatus))
return nil
}
card, err := s.loadCard(ctx, lock.CardID)
if err != nil {
return err
}
if card == nil {
s.logger.Warn("通道阈值复机锁对应卡不存在,等待恢复扫描处理",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOnline {
if _, err := s.markTaskConfirmed(ctx, lockID, resumeTask, ""); err != nil {
return err
}
s.logger.Info("卡已在线,通道阈值复机直接确认成功",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
return nil
}
outcome, callErr := s.commander.ResumeCardForThreshold(ctx, lock.CardID)
if callErr != nil && outcome.Result == "" {
s.logger.Error("通道阈值复机执行失败,等待恢复扫描确认",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
return nil
}
if callErr != nil {
s.logger.Warn("通道阈值复机运营商调用未成功,已按结果分类回填",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.Error(callErr))
}
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, domain.TaskStatusSubmitted,
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
if err != nil {
return err
}
s.logger.Info("通道阈值复机任务已回填结果",
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
return nil
}
// requireExecution 校验消费者与周期处理所需的端口已配置。
func (s *Service) requireExecution() error {
if s == nil || s.db == nil || s.repository == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值执行能力未完整配置")
}
if s.commander == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
}
return nil
}
// taskResultOf 把运营商调用结果映射为子任务终态。
func taskResultOf(outcome domain.CommandOutcome) string {
switch outcome.Result {
case constants.AuditResultSuccess:
return domain.TaskStatusConfirmed
case constants.AuditResultUnknown:
return domain.TaskStatusUnknown
default:
return domain.TaskStatusFailed
}
}
// stopFailureReason 生成停机子任务的可安全失败原因,成功时为空。
func stopFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownStopReason
}
return failedStopReason
}
// resumeFailureReason 生成复机子任务的可安全失败原因,成功时为空。
func resumeFailureReason(outcome domain.CommandOutcome) string {
if outcome.Confirmed() {
return ""
}
if outcome.Unresolved() {
return unknownResumeReason
}
return failedResumeReason
}

View File

@@ -0,0 +1,317 @@
package carrierthreshold
import (
"context"
"strings"
"time"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// activeLockScanLimit 是「按卡取当前周期锁」的候选扫描上限。
// 一张卡正常情况下只有当前周期的锁与历史周期锁,取最近若干条足以覆盖换运营商后的场景。
const activeLockScanLimit = 8
// createLockInTx 在调用方事务内插入周期锁,返回 created=false 表示该周期已处理(唯一键冲突)。
func (s *Service) createLockInTx(ctx context.Context, tx *gorm.DB, lock *model.CarrierTrafficThresholdLock) (bool, error) {
// 唯一冲突在 PostgreSQL 中会中止整个事务,因此插入必须隔离在保存点内:
// GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT冲突只回滚本次插入。
insertErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return inner.Create(lock).Error
})
if insertErr == nil {
return true, nil
}
if isPeriodLockConflict(insertErr) {
return false, nil
}
return false, errors.Wrap(errors.CodeDatabaseError, insertErr, "写入通道流量阈值周期锁失败")
}
// FindLock 按唯一键读取某卡在某运营商某计费周期内的锁;不存在返回 nil。
func (s *Service) FindLock(ctx context.Context, carrierID, cardID uint, periodStart time.Time) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || carrierID == 0 || cardID == 0 {
return nil, nil
}
var lock model.CarrierTrafficThresholdLock
err := s.db.WithContext(ctx).
Where("carrier_id = ? AND card_id = ? AND period_start = ?", carrierID, cardID, periodStart).
First(&lock).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道流量阈值周期锁失败")
}
return &lock, nil
}
// ActiveLock 返回该卡在 now 所属计费周期内仍然生效的通道阈值锁;无锁返回 nil。
//
// 周期归属按锁行自身运营商的 data_reset_day 判断,因此换运营商后旧周期锁不会误判为当前周期。
// 锁行引用的运营商已不存在时无法计算周期归属,此时按「仍可能属于当前周期」处理并返回该锁:
// 持有通道阈值锁的卡在周期内必须拒绝一切复机,不能因为配置缺失放开复机,只能由周期处理/人工核销。
func (s *Service) ActiveLock(ctx context.Context, cardID uint, now time.Time) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || cardID == 0 {
return nil, nil
}
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("card_id = ? AND status = ?", cardID, domain.LockStatusLocked).
Order("period_start DESC").Limit(activeLockScanLimit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前周期通道流量阈值锁失败")
}
if len(locks) == 0 {
return nil, nil
}
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
if err != nil {
return nil, err
}
for index := range locks {
resetDay, ok := resetDays[locks[index].CarrierID]
if !ok {
return &locks[index], nil
}
periodStart, err := domain.PeriodStart(now, resetDay)
if err != nil {
// 重置日非法时同样无法判定周期归属,按仍生效处理,避免放开复机。
return &locks[index], nil
}
if locks[index].PeriodStart.Equal(periodStart) {
return &locks[index], nil
}
}
return nil, nil
}
// ClaimStopSubmission 以 stop_submitted_at IS NULL 条件更新认领停机提交权。
//
// 返回 true 表示调用方获得提交权、可以调用停机接口false 表示该锁已提交过(事件重复投递或
// 人工重放),调用方只能查询结果。谓词同时要求锁仍处于 locked已跨期解锁的锁不再停机。
func (s *Service) ClaimStopSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND stop_submitted_at IS NULL AND status = ?", lockID, domain.LockStatusLocked).
Updates(map[string]any{"stop_submitted_at": now, "stop_status": domain.TaskStatusSubmitted})
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值停机提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// ClaimResumeSubmission 以 resume_submitted_at IS NULL 条件更新认领复机提交权。
//
// 复机发生在周期处理解锁之后,此时锁已是 unlocked因此谓词只要求未提交过。
func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND resume_submitted_at IS NULL", lockID).
Updates(map[string]any{"resume_submitted_at": now, "resume_status": domain.TaskStatusSubmitted})
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值复机提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// ScanExpiredLocks 扫描已跨期但仍持锁的锁行,供周期处理解锁与条件复机。
//
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在时无法计算周期,
// 本次不返回该锁(不猜测),交由周期处理标记异常转人工。
func (s *Service) ScanExpiredLocks(ctx context.Context, now time.Time, limit int) ([]model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("status = ?", domain.LockStatusLocked).
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描跨期通道流量阈值锁失败")
}
if len(locks) == 0 {
return nil, nil
}
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
if err != nil {
return nil, err
}
expired := make([]model.CarrierTrafficThresholdLock, 0, len(locks))
for index := range locks {
resetDay, ok := resetDays[locks[index].CarrierID]
if !ok {
continue
}
periodStart, err := domain.PeriodStart(now, resetDay)
if err != nil {
continue
}
if locks[index].PeriodStart.Before(periodStart) {
expired = append(expired, locks[index])
}
}
return expired, nil
}
// ScanSubmittedLocks 扫描存在已提交子任务的锁行,供恢复扫描查询运营商状态回填。
//
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil {
return nil, nil
}
var locks []model.CarrierTrafficThresholdLock
if err := s.db.WithContext(ctx).
Where("anomaly_flag = ? AND (stop_status = ? OR resume_status = ?)",
domain.AnomalyFlagNone, domain.TaskStatusSubmitted, domain.TaskStatusSubmitted).
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描待确认通道流量阈值任务失败")
}
return locks, nil
}
// lockTask 标识锁行上的停复机子任务。
type lockTask struct {
// statusColumn 是子任务状态列名。
statusColumn string
// integrationColumn 是该子任务对应的 Integration Log 标识列名。
integrationColumn string
}
var (
// stopTask 是停机子任务。
stopTask = lockTask{statusColumn: "stop_status", integrationColumn: "stop_integration_id"}
// resumeTask 是复机子任务。
resumeTask = lockTask{statusColumn: "resume_status", integrationColumn: "resume_integration_id"}
)
// failureReasonMaxRunes 与 tb_carrier_traffic_threshold_lock.failure_reason 的长度上限一致。
const failureReasonMaxRunes = 500
// safeFailureReason 裁剪可安全展示的失败原因,超长截断,绝不写入内部细节。
func safeFailureReason(reason string) string {
trimmed := strings.TrimSpace(reason)
runes := []rune(trimmed)
if len(runes) <= failureReasonMaxRunes {
return trimmed
}
return string(runes[:failureReasonMaxRunes])
}
// loadLock 按 ID 读取锁行;不存在返回 nil软删行不可见
func (s *Service) loadLock(ctx context.Context, lockID uint) (*model.CarrierTrafficThresholdLock, error) {
if s == nil || s.db == nil || lockID == 0 {
return nil, nil
}
var lock model.CarrierTrafficThresholdLock
err := s.db.WithContext(ctx).Where("id = ?", lockID).First(&lock).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道流量阈值周期锁失败")
}
return &lock, nil
}
// loadCard 按 ID 读取卡事实;不存在返回 nil。
func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, error) {
if s == nil || s.db == nil || cardID == 0 {
return nil, nil
}
var card model.IotCard
err := s.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值卡事实失败")
}
return &card, nil
}
// markTaskOutcome 按 expected 状态条件更新把子任务推进到终态ENG-CONC-001
// 返回 false 表示记录已被并发推进,调用方必须按幂等处理,不再重复执行外部动作。
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected, result, integrationID, failureReason string) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
updates := map[string]any{task.statusColumn: result}
if integrationID != "" {
updates[task.integrationColumn] = integrationID
}
if failureReason != "" {
updates["failure_reason"] = safeFailureReason(failureReason)
}
result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND "+task.statusColumn+" = ?", lockID, expected).
Updates(updates)
if result_.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
}
return result_.RowsAffected == 1, nil
}
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
func (s *Service) markTaskConfirmed(ctx context.Context, lockID uint, task lockTask, integrationID string) (bool, error) {
return s.markTaskOutcome(ctx, lockID, task, domain.TaskStatusSubmitted, domain.TaskStatusConfirmed, integrationID, "")
}
// markAnomaly 把锁标记为需人工核对并退出自动扫描。
// 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。
func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) {
if s == nil || s.db == nil || lockID == 0 {
return false, nil
}
result := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND anomaly_flag = ?", lockID, domain.AnomalyFlagNone).
Updates(map[string]any{
"anomaly_flag": domain.AnomalyFlagManual,
"failure_reason": safeFailureReason(reason),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记通道阈值锁异常失败")
}
return result.RowsAffected == 1, nil
}
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
// 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) {
if tx == nil {
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
}
updates := map[string]any{"status": domain.LockStatusUnlocked}
if failureReason != "" {
updates["failure_reason"] = safeFailureReason(failureReason)
}
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
Where("id = ? AND status = ?", lockID, domain.LockStatusLocked).
Updates(updates)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "解除通道流量阈值周期锁失败")
}
return result.RowsAffected == 1, nil
}
// lockCarrierIDs 收集锁行引用的运营商 ID 去重集合用于显式批量查询ENG-MODEL-001
func lockCarrierIDs(locks []model.CarrierTrafficThresholdLock) []uint {
seen := make(map[uint]struct{}, len(locks))
ids := make([]uint, 0, len(locks))
for index := range locks {
if _, ok := seen[locks[index].CarrierID]; ok {
continue
}
seen[locks[index].CarrierID] = struct{}{}
ids = append(ids, locks[index].CarrierID)
}
return ids
}

View File

@@ -0,0 +1,107 @@
// Package carrierthreshold 编排运营商通道流量阈值的达量判定与周期锁事实。
//
// 本包拥有 tb_carrier_traffic_threshold_lock 的全部读写:达量判定在流量观测事务内写入周期锁
// 与可靠停机事件,消费者以提交认领字段取得至多一次的外部调用权,周期处理与恢复扫描只查询锁行。
// 本包不调用任何运营商接口,也不在数据库事务内发起外部 I/O停复机执行通过 CardCommander
// 端口复用既有停复机单一事实源。
package carrierthreshold
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardCommander 是通道阈值停复机对卡与运营商接口的能力边界。
//
// 实现必须复用既有停复机单一事实源重试、Integration Log、统一审计、卡状态观测序列
// 既有套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
type CardCommander interface {
// StopCardForThreshold 执行通道阈值达量停机,成功时写回卡停机状态与停因。
StopCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeCardForThreshold 执行新周期复机,成功时写回卡在线状态(只清除通道阈值停因)。
ResumeCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
// ResumeReady 判断解锁后的卡是否满足自动复机条件;不满足时返回可安全记录的原因。
ResumeReady(ctx context.Context, cardID uint) (ready bool, reason string, err error)
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态known 为 false 表示状态不可判定。
CardNetworkStatus(ctx context.Context, cardID uint) (status int, known bool, integrationID string, err error)
// ConfirmCardState 按已确认的运营商结果补写卡状态,覆盖 Gateway 成功但 DB 更新失败的场景。
ConfirmCardState(ctx context.Context, cardID uint, offline bool) error
}
// Service 执行通道阈值达量判定并维护周期锁事实。
type Service struct {
db *gorm.DB
// repository 是公共 Outbox 仓储达量停机事件与周期复机事件必须与锁事实在同一事务写入ENG-OUTBOX-001
repository *outbox.Repository
logger *zap.Logger
// commander 是停复机执行端口;未注入时消费者与 cron 拒绝执行外部调用。
commander CardCommander
now func() time.Time
}
// NewService 创建通道阈值用例repository 决定达量判定能否写出可靠停复机事件。
func NewService(db *gorm.DB, repository *outbox.Repository) *Service {
return &Service{db: db, repository: repository, logger: zap.NewNop(), now: time.Now}
}
// SetLogger 注入通道阈值运行日志。
func (s *Service) SetLogger(logger *zap.Logger) *Service {
if s == nil {
return s
}
if logger == nil {
s.logger = zap.NewNop()
return s
}
s.logger = logger
return s
}
// SetCommander 注入停复机执行端口(消费者与周期处理必需)。
func (s *Service) SetCommander(commander CardCommander) *Service {
if s == nil {
return s
}
s.commander = commander
return s
}
// ChannelThresholdLocked 判断该卡当前计费周期是否持有通道阈值停机锁。
//
// 供复机入口前置拒绝复用:持锁即拒绝,判定口径与锁生效口径完全一致(按锁行自身运营商的
// data_reset_day 判断周期归属),不另立一套判定。
func (s *Service) ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error) {
lock, err := s.ActiveLock(ctx, cardID, s.now())
if err != nil {
return false, err
}
return lock != nil, nil
}
// carrierResetDays 按运营商 ID 集合显式查询上游流量重置日ENG-MODEL-001不使用关联标签
// 返回结果只包含仍然存在的运营商,缺失 ID 由调用方按各自语义处理。
func (s *Service) carrierResetDays(ctx context.Context, carrierIDs []uint) (map[uint]int, error) {
resetDays := make(map[uint]int, len(carrierIDs))
if len(carrierIDs) == 0 {
return resetDays, nil
}
var carriers []model.Carrier
if err := s.db.WithContext(ctx).
Select("id", "data_reset_day").
Where("id IN ?", carrierIDs).
Find(&carriers).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商上游流量重置日失败")
}
for index := range carriers {
resetDays[carriers[index].ID] = carriers[index].DataResetDay
}
return resetDays, nil
}

View File

@@ -8,6 +8,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
@@ -182,12 +183,16 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -242,6 +247,8 @@ func initServices(s *stores, deps *Dependencies) *services {
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)

View File

@@ -2,9 +2,11 @@ package bootstrap
import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
@@ -95,6 +97,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
@@ -108,6 +112,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
iotCardAuditService.SetAccessAudit(auditWriter)
iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
@@ -162,6 +168,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
@@ -188,6 +198,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
AlertService: alertService,
CleanupService: cleanupService,
StopResumeService: stopResumeService,
CarrierThreshold: carrierThresholdService,
OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,

View File

@@ -0,0 +1,45 @@
package carrierthreshold
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// SubmissionQueryWindow 是停/复机子任务自提交起允许自动查询确认的窗口。
//
// 语义:子任务进入 submitted 后,恢复扫描只查询运营商状态回填;超过该窗口仍无法确认结果的
// 锁会标记异常并转人工处理,不再每分钟重复查询同一笔无法收敛的结果,也不自动删除锁。
const SubmissionQueryWindow = 30 * time.Minute
// SubmissionExpired 判断子任务自提交起是否已超过自动查询窗口。
// 未提交submittedAt 为空)不算超期。
func SubmissionExpired(submittedAt *time.Time, now time.Time) bool {
if submittedAt == nil {
return false
}
return now.Sub(*submittedAt) >= SubmissionQueryWindow
}
// CommandOutcome 是一次运营商停复机调用可安全记录的结果摘要。
//
// 它只承载回填可靠任务状态所需的事实Integration Log 标识、结果分类与可安全展示的原因,
// 不携带渠道报文原文、凭证或内部错误细节。
type CommandOutcome struct {
// IntegrationID 是本次 Gateway 调用的 Integration Log 标识,失败重试时取最后一次尝试。
IntegrationID string
// Result 取值 constants.AuditResultSuccess / AuditResultFailed / AuditResultUnknown。
Result string
// SafeReason 是可安全对外展示的失败原因,成功时为空。
SafeReason string
}
// Confirmed 判断本次运营商调用是否已明确成功。
func (o CommandOutcome) Confirmed() bool {
return o.Result == constants.AuditResultSuccess
}
// Unresolved 判断本次运营商调用结果是否未知(必须由恢复扫描查询收敛)。
func (o CommandOutcome) Unresolved() bool {
return o.Result == constants.AuditResultUnknown
}

View File

@@ -0,0 +1,18 @@
package carrierthreshold
import "time"
// Evaluation 是一次通道阈值达量判定所需的卡与读数事实。
//
// 读数只来自运营商回传的网关累计读数IoT 卡的 last_gateway_reading_mb
// 不使用本地用量统计、当月用量或套餐真流量ObservedAt 决定该读数所属的计费周期。
type Evaluation struct {
// CardID 是触发判定的物联网卡 ID。
CardID uint
// CarrierID 是卡当前所属运营商 ID决定阈值配置与周期起点。
CarrierID uint
// ReadingMB 是本次已接受的运营商网关累计读数。
ReadingMB float64
// ObservedAt 是本次读数的观测时刻。
ObservedAt time.Time
}

View File

@@ -0,0 +1,31 @@
package carrierthreshold
// 周期锁整行生命周期取值,与 tb_carrier_traffic_threshold_lock.status 的 CHECK 一致。
const (
// LockStatusLocked 表示该卡在该计费周期内持有通道阈值停机锁,周期内拒绝一切复机。
LockStatusLocked = "locked"
// LockStatusUnlocked 表示已跨期解除通道阈值锁,锁行保留为历史事实。
LockStatusUnlocked = "unlocked"
)
// 停复机子任务状态取值,与 stop_status / resume_status 的 CHECK 一致。
const (
// TaskStatusPending 表示子任务待提交,达量判定写锁时停机子任务处于该状态。
TaskStatusPending = "pending"
// TaskStatusSubmitted 表示子任务已提交待确认,认领成功后处于该状态。
TaskStatusSubmitted = "submitted"
// TaskStatusConfirmed 表示运营商调用或状态查询已明确成功。
TaskStatusConfirmed = "confirmed"
// TaskStatusFailed 表示运营商明确失败。
TaskStatusFailed = "failed"
// TaskStatusUnknown 表示结果未知,交由恢复扫描查询收敛。
TaskStatusUnknown = "unknown"
)
// 异常标记取值,与 anomaly_flag 的 CHECK 一致。
const (
// AnomalyFlagNone 表示无需人工核对。
AnomalyFlagNone = 0
// AnomalyFlagManual 表示查询窗口超期或失败无法自动确认,已转人工核对并退出自动扫描。
AnomalyFlagManual = 1
)

View File

@@ -0,0 +1,29 @@
package carrierthreshold
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// shanghaiLocation 是计费周期边界的固定时区。
// 中国自 1991 年起不实行夏令时,使用固定偏移可避免依赖宿主机 tzdata。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// PeriodStart 计算 now 所属计费周期的起点(上海时区零点)。
//
// 周期起点 = 本月重置日 0 点,若 now 早于本月重置日 0 点则取上月重置日 0 点。
// resetDay 由该卡所属运营商(锁行自身的 carrier的 data_reset_day 提供,合法范围 1-28
// 因此每个月都有定义。该口径与 isTrafficResetWindow判断读数回落是否为合法清零的观测窗口
// 是两个独立口径,互不修改。
func PeriodStart(now time.Time, resetDay int) (time.Time, error) {
if resetDay < MinResetDay || resetDay > MaxResetDay {
return time.Time{}, errors.New(errors.CodeInvalidParam, "运营商上游流量重置日必须在 1-28 之间")
}
local := now.In(shanghaiLocation)
current := time.Date(local.Year(), local.Month(), resetDay, 0, 0, 0, 0, shanghaiLocation)
if !local.Before(current) {
return current, nil
}
return current.AddDate(0, -1, 0), nil
}

View File

@@ -0,0 +1,69 @@
// Package carrierthreshold 定义运营商通道流量阈值的领域规则。
//
// 本包只表达与传输、持久化无关的纯规则:阈值单位与 GB→MB 换算、按上游流量重置日计算的
// 计费周期起点、以及周期锁与停复机子任务的状态取值。运营商通道即既有 Carrier
// 计费周期与网关计数器清零周期是同一事实,因此周期起点只由 carrier.data_reset_day 决定。
package carrierthreshold
import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 阈值单位枚举:只允许 MB 与 GB换算后统一以 MB 与网关累计读数比较。
const (
UnitMB = "MB"
UnitGB = "GB"
)
// MBPerGB 是 GB→MB 的固定换算系数1 GB = 1024 MB
const MBPerGB = 1024
// 计费周期起点的合法重置日范围1-28 保证每个月都有定义,不存在 2 月 30 日问题。
const (
MinResetDay = 1
MaxResetDay = 28
)
// Threshold 是通道阈值配置的领域值。
type Threshold struct {
// Enabled 为 true 表示该通道参与达量停机判定。
Enabled bool
// Value 是阈值数值,启用时必须为正数。
Value float64
// Unit 是阈值单位,取值 UnitMB 或 UnitGB。
Unit string
}
// Valid 判断阈值配置是否为可用于判定的完整配置。
// 未启用、数值非正、单位未知都视为不可判定;不可判定必须跳过判定而不是按 0 停机。
func (t Threshold) Valid() bool {
if !t.Enabled || t.Value <= 0 {
return false
}
return t.Unit == UnitMB || t.Unit == UnitGB
}
// LimitMB 返回换算为 MB 的阈值上限;单位未知返回稳定参数错误。
func (t Threshold) LimitMB() (float64, error) {
switch t.Unit {
case UnitMB:
return t.Value, nil
case UnitGB:
return t.Value * MBPerGB, nil
default:
return 0, errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 与 GB")
}
}
// Reached 判断运营商回传的当前周期累计读数是否达到或超过阈值。
// readingMB 只来自网关累计读数last_gateway_reading_mb不使用本地用量或套餐真流量。
func (t Threshold) Reached(readingMB float64) (bool, error) {
if !t.Valid() {
return false, nil
}
limitMB, err := t.LimitMB()
if err != nil {
return false, err
}
return readingMB >= limitMB, nil
}

View File

@@ -0,0 +1,70 @@
// Package carrierthreshold 提供运营商通道流量阈值的停复机执行适配与计划任务处理。
//
// 本包只做两件事:把通道阈值的执行需求转发到既有停复机服务(不新增任何网关调用层实现、
// 不复制既有判定规则),以及把两个计划任务入口转换成对通道阈值用例的调用。
package carrierthreshold
import (
"context"
carrierthresholdapp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
iotcardsvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardCommander 把通道阈值的停复机执行需求转发到既有停复机服务。
//
// 复用点:停机/复机的 3 次重试、Integration Log、统一审计、卡状态观测序列以及有效主套餐、
// 流量耗尽、实名、风险网关扩展等判定;本适配器不包含任何业务规则。
type CardCommander struct {
service *iotcardsvc.StopResumeService
}
// 编译期断言:适配器满足通道阈值停复机执行端口。
var _ carrierthresholdapp.CardCommander = (*CardCommander)(nil)
// NewCardCommander 创建通道阈值停复机执行适配器。
func NewCardCommander(service *iotcardsvc.StopResumeService) *CardCommander {
return &CardCommander{service: service}
}
// StopCardForThreshold 执行通道阈值达量停机。
func (c *CardCommander) StopCardForThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
if c == nil || c.service == nil {
return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停机执行能力未配置")
}
return c.service.StopCardForChannelThreshold(ctx, cardID)
}
// ResumeCardForThreshold 执行通道阈值新周期复机。
func (c *CardCommander) ResumeCardForThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
if c == nil || c.service == nil {
return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值复机执行能力未配置")
}
return c.service.ResumeCardForChannelThreshold(ctx, cardID)
}
// ResumeReady 判断解锁后的卡是否满足自动复机条件。
func (c *CardCommander) ResumeReady(ctx context.Context, cardID uint) (bool, string, error) {
if c == nil || c.service == nil {
return false, "", errors.New(errors.CodeServiceUnavailable, "通道流量阈值复机判定能力未配置")
}
return c.service.ChannelThresholdResumeReady(ctx, cardID)
}
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态。
func (c *CardCommander) CardNetworkStatus(ctx context.Context, cardID uint) (int, bool, string, error) {
if c == nil || c.service == nil {
return 0, false, "", errors.New(errors.CodeServiceUnavailable, "通道流量阈值状态查询能力未配置")
}
return c.service.QueryChannelThresholdCardStatus(ctx, cardID)
}
// ConfirmCardState 按已确认的运营商结果补写卡状态。
func (c *CardCommander) ConfirmCardState(ctx context.Context, cardID uint, offline bool) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值卡状态确认能力未配置")
}
return c.service.ConfirmChannelThresholdCardState(ctx, cardID, offline)
}

View File

@@ -0,0 +1,68 @@
package carrierthreshold
import (
"context"
"time"
"github.com/hibiken/asynq"
carrierthresholdapp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CycleTaskHandler 执行运营商通道流量阈值周期处理任务。
type CycleTaskHandler struct {
service *carrierthresholdapp.Service
}
// NewCycleTaskHandler 创建通道阈值周期处理任务 Handler。
func NewCycleTaskHandler(service *carrierthresholdapp.Service) *CycleTaskHandler {
return &CycleTaskHandler{service: service}
}
// Handle 扫描已跨期的持锁,认领解锁并在条件满足时写复机事件。
// 审计上下文固定为计划任务来源,使解锁与复机事件都可按计划任务维度追溯。
func (h *CycleTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理任务未配置")
}
taskType := constants.TaskTypeCarrierThresholdCycle
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "运营商通道流量阈值周期处理计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.ProcessDueLocks(ctx, time.Now().UTC())
return err
}
// RecoveryTaskHandler 执行运营商通道流量阈值停复机结果恢复任务。
type RecoveryTaskHandler struct {
service *carrierthresholdapp.Service
}
// NewRecoveryTaskHandler 创建通道阈值停复机结果恢复任务 Handler。
func NewRecoveryTaskHandler(service *carrierthresholdapp.Service) *RecoveryTaskHandler {
return &RecoveryTaskHandler{service: service}
}
// Handle 扫描已提交的停复机子任务并只查询运营商状态回填,绝不重复发起停复机。
func (h *RecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描任务未配置")
}
taskType := constants.TaskTypeCarrierThresholdRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "运营商通道流量阈值结果恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.RecoverSubmitted(ctx, time.Now().UTC())
return err
}

View File

@@ -15,6 +15,13 @@ type Carrier struct {
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
DataResetDay int `gorm:"column:data_reset_day;type:int;not null;default:1;comment:上游流量重置日(1-28) 运营商每月清零网关计数器的日期" json:"data_reset_day"`
// TrafficThresholdEnabled 是通道流量阈值开关,取值 0-未启用 1-已启用ENG-STATE-001 启停语义)。
// 未启用时达量判定直接跳过;停用只停止后续新锁创建,不删除既有锁与历史任务结果。
TrafficThresholdEnabled int `gorm:"column:traffic_threshold_enabled;type:smallint;not null;default:0;comment:通道流量阈值是否启用 0-未启用 1-已启用" json:"traffic_threshold_enabled"`
// TrafficThresholdValue 是阈值数值,单位由 TrafficThresholdUnit 决定必须为正数NULL 表示未配置。
TrafficThresholdValue *float64 `gorm:"column:traffic_threshold_value;type:numeric(18,2);comment:通道流量阈值数值,单位由 traffic_threshold_unit 决定" json:"traffic_threshold_value"`
// TrafficThresholdUnit 是阈值单位,取值 MB/GB1 GB = 1024 MB换算后与网关累计读数同单位比较空字符串表示未配置。
TrafficThresholdUnit string `gorm:"column:traffic_threshold_unit;type:varchar(8);not null;default:'';comment:通道流量阈值单位 MB/GB空字符串-未配置" json:"traffic_threshold_unit"`
}
// TableName 指定表名

View File

@@ -0,0 +1,38 @@
package model
import (
"time"
"gorm.io/gorm"
)
// CarrierTrafficThresholdLock 是「卡在某运营商计费周期内已触发通道流量阈值停机」的持久化事实。
//
// 一行表达一个 (carrier_id, card_id, period_start) 组合,由部分唯一索引保证至多一条:
// Status 表达整行生命周期locked 进行中 / unlocked 已跨期解锁StopStatus 与 ResumeStatus
// 分别表达停机与复机两个子任务StopSubmittedAt/ResumeSubmittedAt 是提交认领字段,
// 由消费者以「字段为 NULL」条件更新取得至多一次的外部调用权AnomalyFlag 与 FailureReason
// 承载查询窗口超期等需人工核对的结果。周期起点按触发时该运营商的 data_reset_day 计算,
// 换运营商后旧周期锁保留、新周期按新运营商的重置日另建一行。
type CarrierTrafficThresholdLock struct {
gorm.Model
CarrierID uint `gorm:"column:carrier_id;type:bigint;not null" json:"carrier_id"`
CardID uint `gorm:"column:card_id;type:bigint;not null" json:"card_id"`
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"`
Status string `gorm:"column:status;type:varchar(16);not null;default:'locked'" json:"status"`
StopStatus string `gorm:"column:stop_status;type:varchar(16);not null;default:'pending'" json:"stop_status"`
ResumeStatus string `gorm:"column:resume_status;type:varchar(16);not null;default:'pending'" json:"resume_status"`
StopSubmittedAt *time.Time `gorm:"column:stop_submitted_at;type:timestamptz" json:"stop_submitted_at,omitempty"`
ResumeSubmittedAt *time.Time `gorm:"column:resume_submitted_at;type:timestamptz" json:"resume_submitted_at,omitempty"`
StopIntegrationID string `gorm:"column:stop_integration_id;type:varchar(64);not null;default:''" json:"stop_integration_id"`
// ResumeIntegrationID 记录复机 Gateway 调用的 Integration Log 标识,便于人工核对。
ResumeIntegrationID string `gorm:"column:resume_integration_id;type:varchar(64);not null;default:''" json:"resume_integration_id"`
// AnomalyFlag 为 1 表示查询窗口超期或失败无法自动确认,必须退出自动扫描转人工核对。
AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0" json:"anomaly_flag"`
FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"`
}
// TableName 指定运营商通道流量阈值周期锁表名。
func (CarrierTrafficThresholdLock) TableName() string {
return "tb_carrier_traffic_threshold_lock"
}

View File

@@ -1,21 +1,45 @@
package dto
type CreateCarrierRequest struct {
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28)运营商每月清零网关计数器的日期默认1"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28)运营商每月清零网关计数器的日期默认1"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
TrafficThresholdEnabled *int `json:"traffic_threshold_enabled" validate:"omitempty,oneof=0 1" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可配置"`
TrafficThresholdValue *float64 `json:"traffic_threshold_value" validate:"omitempty,gt=0" description:"通道流量阈值数值,必须为正数,单位由 traffic_threshold_unit 决定,仅超级管理员与平台账号可配置"`
TrafficThresholdUnit *string `json:"traffic_threshold_unit" validate:"omitempty,oneof=MB GB" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节1 GB = 1024 MB),仅超级管理员与平台账号可配置"`
}
// HasTrafficThresholdFields 判断请求是否携带通道流量阈值字段。
// 非超管、非平台账号提交携带阈值字段的请求必须被整体拒绝。
func (r *CreateCarrierRequest) HasTrafficThresholdFields() bool {
if r == nil {
return false
}
return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil
}
type UpdateCarrierRequest struct {
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28),运营商每月清零网关计数器的日期"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28),运营商每月清零网关计数器的日期"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
TrafficThresholdEnabled *int `json:"traffic_threshold_enabled" validate:"omitempty,oneof=0 1" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可配置"`
TrafficThresholdValue *float64 `json:"traffic_threshold_value" validate:"omitempty,gt=0" description:"通道流量阈值数值,必须为正数,单位由 traffic_threshold_unit 决定,仅超级管理员与平台账号可配置"`
TrafficThresholdUnit *string `json:"traffic_threshold_unit" validate:"omitempty,oneof=MB GB" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节1 GB = 1024 MB),仅超级管理员与平台账号可配置"`
}
// HasTrafficThresholdFields 判断请求是否携带通道流量阈值字段。
// 非超管、非平台账号提交携带阈值字段的请求必须被整体拒绝。
func (r *UpdateCarrierRequest) HasTrafficThresholdFields() bool {
if r == nil {
return false
}
return r.TrafficThresholdEnabled != nil || r.TrafficThresholdValue != nil || r.TrafficThresholdUnit != nil
}
type CarrierListRequest struct {
@@ -40,8 +64,12 @@ type CarrierResponse struct {
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
// 阈值字段仅对超级管理员与平台账号返回;其他账号响应中不出现这三个字段。
TrafficThresholdEnabled *int `json:"traffic_threshold_enabled,omitempty" description:"通道流量阈值是否启用 (0:禁用, 1:启用),仅超级管理员与平台账号可见"`
TrafficThresholdValue *float64 `json:"traffic_threshold_value,omitempty" description:"通道流量阈值数值,单位由 traffic_threshold_unit 决定,未配置时不返回,仅超级管理员与平台账号可见"`
TrafficThresholdUnit *string `json:"traffic_threshold_unit,omitempty" description:"通道流量阈值单位 (MB:兆字节, GB:吉字节1 GB = 1024 MB),未配置时不返回,仅超级管理员与平台账号可见"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
type UpdateCarrierParams struct {

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
carrierthreshold "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -33,6 +34,25 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 阈值字段只由超级管理员与平台账号配置:非平台账号提交即整体拒绝,不落任何配置。
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝非平台账号配置运营商通道流量阈值",
thresholdCreateProbe(req), errors.CodeForbidden)
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
thresholdEnabled, thresholdValue, thresholdUnit := 0, req.TrafficThresholdValue, ""
if req.TrafficThresholdEnabled != nil {
thresholdEnabled = *req.TrafficThresholdEnabled
}
if req.TrafficThresholdUnit != nil {
thresholdUnit = *req.TrafficThresholdUnit
}
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝保存非法运营商通道流量阈值配置",
thresholdCreateProbe(req), errors.CodeInvalidParam)
return nil, err
}
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
if existing != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
@@ -49,6 +69,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
Description: req.Description,
Status: constants.StatusEnabled,
DataResetDay: 1,
TrafficThresholdEnabled: thresholdEnabled,
TrafficThresholdValue: thresholdValue,
TrafficThresholdUnit: thresholdUnit,
}
if req.DataResetDay != nil {
carrier.DataResetDay = *req.DataResetDay
@@ -72,7 +96,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
}
return s.toResponse(carrier), nil
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error) {
@@ -83,7 +107,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
return s.toResponse(carrier), nil
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierRequest) (*dto.CarrierResponse, error) {
@@ -101,6 +125,32 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
}
before := *carrier
// 阈值字段只由超级管理员与平台账号修改:非平台账号提交即整体拒绝。
if req.HasTrafficThresholdFields() && !canManageThreshold(ctx) {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝非平台账号配置运营商通道流量阈值",
&before, errors.CodeForbidden)
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// 阈值按「请求覆盖既有配置」后的结果校验,避免保存出启用但缺数值/单位的半配置。
thresholdEnabled, thresholdValue, thresholdUnit := carrier.TrafficThresholdEnabled, carrier.TrafficThresholdValue, carrier.TrafficThresholdUnit
if req.TrafficThresholdEnabled != nil {
thresholdEnabled = *req.TrafficThresholdEnabled
}
if req.TrafficThresholdValue != nil {
thresholdValue = req.TrafficThresholdValue
}
if req.TrafficThresholdUnit != nil {
thresholdUnit = *req.TrafficThresholdUnit
}
if err := validateThresholdConfig(thresholdEnabled, thresholdValue, thresholdUnit); err != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商通道流量阈值配置",
&before, errors.CodeInvalidParam)
return nil, err
}
carrier.TrafficThresholdEnabled = thresholdEnabled
carrier.TrafficThresholdValue = thresholdValue
carrier.TrafficThresholdUnit = thresholdUnit
if req.CarrierName != nil {
carrier.CarrierName = *req.CarrierName
}
@@ -133,7 +183,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
}
return s.toResponse(carrier), nil
return s.toResponse(ctx, carrier), nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
@@ -193,7 +243,7 @@ func (s *Service) List(ctx context.Context, req *dto.CarrierListRequest) ([]*dto
responses := make([]*dto.CarrierResponse, len(carriers))
for i, c := range carriers {
responses[i] = s.toResponse(c)
responses[i] = s.toResponse(ctx, c)
}
return responses, total, nil
@@ -303,11 +353,56 @@ func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
"data_reset_day": carrier.DataResetDay,
// 通道流量阈值的新增、修改、启用与停用都并入既有运营商更新审计的前后值快照,不另建审计动作。
"traffic_threshold_enabled": carrier.TrafficThresholdEnabled,
"traffic_threshold_value": carrier.TrafficThresholdValue,
"traffic_threshold_unit": carrier.TrafficThresholdUnit,
}
}
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
return &dto.CarrierResponse{
// canManageThreshold 判断当前账号是否具备通道流量阈值字段的读写权限。
// 只有超级管理员与平台账号可见可写,与 requirePlatformManagement 的判定一致(引用同一用户类型枚举)。
func canManageThreshold(ctx context.Context) bool {
userType := middleware.GetUserTypeFromContext(ctx)
return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform
}
// validateThresholdConfig 校验通道流量阈值配置:启停仅 0/1、数值必须为正、单位仅 MB/GB、配置齐备。
// 「启用但无数值/单位」与「只给数值不给单位」都会让达量判定失去唯一口径;
// 非 0/1 的启停取值会被判定端按「未启用」静默处理,因此必须显式拒绝。
func validateThresholdConfig(enabled int, value *float64, unit string) error {
if enabled != constants.StatusDisabled && enabled != constants.StatusEnabled {
return errors.New(errors.CodeInvalidParam, "通道流量阈值是否启用仅支持 0 或 1")
}
if value != nil && *value <= 0 {
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值必须为正数")
}
if unit != "" && unit != carrierthreshold.UnitMB && unit != carrierthreshold.UnitGB {
return errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 或 GB")
}
configured := value != nil || unit != ""
if configured && (value == nil || unit == "") {
return errors.New(errors.CodeInvalidParam, "通道流量阈值数值与流量单位必须同时提供")
}
if enabled == constants.StatusEnabled && !configured {
return errors.New(errors.CodeInvalidParam, "启用通道流量阈值必须同时提供正的阈值数值与流量单位")
}
return nil
}
// thresholdCreateProbe 构造仅供拒绝审计使用的运营商身份,不写库。
func thresholdCreateProbe(req *dto.CreateCarrierRequest) *model.Carrier {
if req == nil {
return nil
}
return &model.Carrier{
CarrierCode: req.CarrierCode, CarrierName: req.CarrierName, CarrierType: req.CarrierType,
}
}
// toResponse 把运营商事实投影为响应;阈值字段只对超级管理员与平台账号返回。
func (s *Service) toResponse(ctx context.Context, c *model.Carrier) *dto.CarrierResponse {
result := &dto.CarrierResponse{
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
@@ -320,4 +415,15 @@ func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
if !canManageThreshold(ctx) {
return result
}
enabled := c.TrafficThresholdEnabled
result.TrafficThresholdEnabled = &enabled
result.TrafficThresholdValue = c.TrafficThresholdValue
if c.TrafficThresholdUnit != "" {
unit := c.TrafficThresholdUnit
result.TrafficThresholdUnit = &unit
}
return result
}

View File

@@ -0,0 +1,222 @@
package iot_card
import (
"time"
"context"
"go.uber.org/zap"
"github.com/google/uuid"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ChannelThresholdLockGuard 判断卡在当前计费周期是否持有运营商通道流量阈值停机锁。
// 由通道阈值能力实现并注入,停复机服务只做前置拒绝判定,不复制周期与锁的规则。
type ChannelThresholdLockGuard interface {
ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error)
}
// ChannelThresholdResumeDeniedMessage 是持通道阈值锁复机被拒的统一可读文案。
const ChannelThresholdResumeDeniedMessage = "该卡当前计费周期已达到运营商通道流量阈值并被锁定,禁止复机"
// ChannelThresholdDenialSnapshotKey 是拒绝审计中标记「持通道阈值锁」的快照键。
const ChannelThresholdDenialSnapshotKey = "channel_threshold_lock"
// SetChannelThresholdLockGuard 注入通道阈值持锁判定能力。
func (s *StopResumeService) SetChannelThresholdLockGuard(guard ChannelThresholdLockGuard) {
if s == nil {
return
}
s.channelThresholdGuard = guard
}
// StopCardForChannelThreshold 执行通道阈值达量停机。
//
// 复用既有停机重试、Integration Log 与统一审计:成功后同一事务写回 network_status=offline、
// stop_reason=channel_threshold、stopped_at。返回的 outcome 始终携带可回填的结果分类
// (成功/失败/未知error 只表示执行过程本身的基础设施故障(读卡、写审计或写卡失败),
// 此时调用方不得据此判定终态,应保留 submitted 等待恢复扫描确认。
func (s *StopResumeService) StopCardForChannelThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return carrierthresholddomain.CommandOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值停机卡事实失败")
}
return s.stopCardWithRetry(ctx, card, constants.StopReasonChannelThreshold)
}
// ResumeCardForChannelThreshold 执行通道阈值新周期复机。
//
// 复用既有复机重试、Integration Log 与统一审计;成功时在同一事务写回 network_status=online、
// resumed_at并且只在该卡停因正是通道阈值时清除停因绝不覆盖其他停因。
func (s *StopResumeService) ResumeCardForChannelThreshold(ctx context.Context, cardID uint) (carrierthresholddomain.CommandOutcome, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return carrierthresholddomain.CommandOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机卡事实失败")
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
return carrierthresholddomain.CommandOutcome{
IntegrationID: integrationID, Result: cardCommandAuditResult(err),
}, err
}
fields := map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
}
if card.StopReason == constants.StopReasonChannelThreshold {
fields["stop_reason"] = ""
}
if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields,
constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结通道阈值复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown,
attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err)
// 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认。
return carrierthresholddomain.CommandOutcome{
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown,
}, err
}
s.reschedulePolling(ctx, card.ID)
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return carrierthresholddomain.CommandOutcome{
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
}, errors.Wrap(errors.CodeDatabaseError, logErr, "终结通道阈值复机 Integration Log 失败")
}
s.logger.Info("通道阈值新周期复机成功",
zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID))
return carrierthresholddomain.CommandOutcome{
IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess,
}, nil
}
// ChannelThresholdResumeReady 判断通道阈值跨期解锁后的卡是否满足自动复机条件。
//
// 条件全部复用既有单一事实源:有效主套餐、流量未耗尽、实名满足、非风险网关扩展,且不存在其他停机锁
// (停因为空或正是通道阈值本身)。返回的 reason 是不满足原因(可安全记录),满足时为空。
func (s *StopResumeService) ChannelThresholdResumeReady(ctx context.Context, cardID uint) (bool, string, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值复机判定卡事实失败")
}
// 其他停机锁:风险网关扩展(风险停机/销户)或非通道阈值的停因。
if isRiskGatewayExtend(card.GatewayExtend) {
return false, "网关扩展状态为风险停机或已销户,不自动复机", nil
}
if card.StopReason != "" && card.StopReason != constants.StopReasonChannelThreshold {
return false, "存在其他停机原因,不自动复机", nil
}
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return false, "", err
}
if !hasPackage {
return false, "无有效主套餐,不自动复机", nil
}
exhausted, err := s.isTrafficExhausted(ctx, card)
if err != nil {
return false, "", err
}
if exhausted {
return false, "套餐流量已耗尽,不自动复机", nil
}
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil {
return false, "", err
}
if !realnameOK {
return false, "实名要求未满足,不自动复机", nil
}
return true, "", nil
}
// QueryChannelThresholdCardStatus 只查询运营商卡状态并映射为本地网络状态,供恢复扫描回填。
//
// 不发起任何停复机调用:只做状态查询,并按既有网关状态映射规则给出本地网络状态。
// 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。
func (s *StopResumeService) QueryChannelThresholdCardStatus(ctx context.Context, cardID uint) (int, bool, string, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return 0, false, "", errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值状态查询卡事实失败")
}
if s.gatewayClient == nil {
return 0, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态")
}
attempt, err := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork,
constants.CardObservationSceneCarrierThresholdRecovery, cardCommandSeriesKey(ctx), 1)
if err != nil {
return 0, false, "", err
}
response, callErr := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if callErr != nil {
if completeErr := s.completeCardCommandAttempt(ctx, attempt, callErr, false); completeErr != nil {
s.logger.Error("终结通道阈值状态查询 Integration Log 失败",
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
}
return 0, false, attempt.log.IntegrationID, callErr
}
if completeErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); completeErr != nil {
s.logger.Error("终结通道阈值状态查询 Integration Log 失败",
zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr))
return 0, false, attempt.log.IntegrationID, completeErr
}
status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend)
return status, known, attempt.log.IntegrationID, nil
}
// ConfirmChannelThresholdCardState 按已确认的运营商结果补写卡状态,覆盖运营商调用成功但本地回写失败的场景。
//
// offline=true 写回停机事实停因为通道阈值offline=false 写回在线事实,并且只在该卡停因
// 正是通道阈值时清除停因,绝不覆盖其他停因。卡状态与统一审计、观测序列在同一事务写入。
func (s *StopResumeService) ConfirmChannelThresholdCardState(ctx context.Context, cardID uint, offline bool) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值确认卡事实失败")
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
scene, expected := constants.CardObservationSceneBusinessResume, "online"
fields := map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
}
if offline {
actionCode, summary = constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络"
scene, expected = constants.CardObservationSceneBusinessStop, "offline"
fields = map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": time.Now(),
"stop_reason": constants.StopReasonChannelThreshold,
}
} else if card.StopReason == constants.StopReasonChannelThreshold {
fields["stop_reason"] = ""
}
return s.updateCardAndAppendNetworkSeries(ctx, card, fields, scene, expected, uuid.NewString(), actionCode, summary, "")
}
// channelThresholdBlocked 判断复机入口是否必须拒绝:卡在当前周期持有通道阈值停机锁。
// 判定失败直接返回错误,绝不在事实不可读时放开复机。
func (s *StopResumeService) channelThresholdBlocked(ctx context.Context, card *model.IotCard) (bool, error) {
if s == nil || s.channelThresholdGuard == nil || card == nil || card.ID == 0 {
return false, nil
}
return s.channelThresholdGuard.ChannelThresholdLocked(ctx, card.ID)
}
// recordChannelThresholdDenial 记录持锁复机被拒的事实:不改卡状态、不解除锁、不调用运营商。
func (s *StopResumeService) recordChannelThresholdDenial(ctx context.Context, card *model.IotCard, actionCode, summary string) {
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "",
cardSnapshot(card), map[string]any{ChannelThresholdDenialSnapshotKey: true}, channelThresholdDenyError())
}
// channelThresholdDenyError 返回持锁复机被拒的稳定业务错误(中文可读)。
func channelThresholdDenyError() error {
return errors.New(errors.CodeForbidden, ChannelThresholdResumeDeniedMessage)
}

View File

@@ -7,6 +7,7 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@@ -53,6 +54,8 @@ type StopResumeService struct {
maxRetries int
retryInterval time.Duration
// channelThresholdGuard 是可选的通道阈值持锁判定能力;未注入时不做持锁拒绝。
channelThresholdGuard ChannelThresholdLockGuard
}
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
@@ -120,7 +123,8 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
return s.stopDeviceCards(ctx, deviceID, primaryReason)
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
_, stopErr := s.stopCardWithRetry(ctx, card, primaryReason)
return stopErr
case constants.NetworkStatusOffline:
// 卡停机,检查是否可以复机
@@ -353,7 +357,7 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
var cardErrors []error
for _, card := range cards {
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
if _, stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
cardErrors = append(cardErrors, stopErr)
s.logger.Warn("设备卡停机失败,继续处理其他卡",
zap.Uint("device_id", deviceID),
@@ -450,16 +454,26 @@ func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCa
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
return s.stopCardWithRetry(ctx, card, stopReason)
_, err := s.stopCardWithRetry(ctx, card, stopReason)
return err
}
// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。
// 持通道阈值锁的卡必须先拒绝,再走既有保护期一致性检查,避免强行复机锁定期内的卡。
func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error {
if card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
blocked, err := s.channelThresholdBlocked(ctx, card)
if err != nil {
return err
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
return channelThresholdDenyError()
}
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
return err
}
@@ -467,7 +481,7 @@ func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotC
"network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(),
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
@@ -483,13 +497,29 @@ func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotC
}
// resumeSingleCard 对单张卡执行复机逻辑
// 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
// 依次检查:持通道阈值锁则拒绝 → 已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
// 该入口被轮询评估、套餐激活/重置与设备维度复机复用,返回 nil 表示本次不动作,
// 避免让自动链路按失败重试;用户可见入口由 ManualStartCard/ForceStartCard 返回拒绝错误。
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return blockErr
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
s.logger.Info("卡持通道阈值锁,跳过自动复机",
zap.Uint("card_id", cardID), zap.String("stop_reason", card.StopReason))
return nil
}
if card.NetworkStatus == constants.NetworkStatusOnline {
return nil
}
@@ -526,8 +556,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
}
defer s.redis.Del(ctx, lockKey)
actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络"
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
s.logger.Error("调用运营商复机接口失败",
zap.Uint("card_id", cardID),
@@ -541,7 +570,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
@@ -567,14 +596,17 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
return nil
}
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error {
// stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因
//
// 返回的 outcome 始终携带可回填可靠任务的结果分类(成功/失败/未知)与最后一次尝试的
// Integration Log 标识error 与既有语义一致,表示本次停机未成功。
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) (carrierthresholddomain.CommandOutcome, error) {
actionCode, summary := stopAuditAction(ctx, stopReason)
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "",
cardSnapshot(card), nil, failErr)
return failErr
return carrierthresholddomain.CommandOutcome{Result: constants.AuditResultFailed}, failErr
}
s.logger.Info("调用网关停机",
@@ -606,7 +638,9 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
if lastErr == nil {
lastErr = attemptObserver.recordingErr
}
break
return carrierthresholddomain.CommandOutcome{
IntegrationID: lastIntegrationID, Result: attemptObserver.auditResult(lastErr),
}, lastErr
}
if callErr == nil {
attempt := attemptObserver.successful
@@ -631,14 +665,21 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
lastIntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, updateErr)
return updateErr
// 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认。
return carrierthresholddomain.CommandOutcome{
IntegrationID: lastIntegrationID, Result: constants.AuditResultUnknown,
}, updateErr
}
s.reschedulePolling(ctx, card.ID)
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil {
return errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
return carrierthresholddomain.CommandOutcome{
IntegrationID: lastIntegrationID, Result: constants.AuditResultSuccess,
}, errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败")
}
return nil
return carrierthresholddomain.CommandOutcome{
IntegrationID: lastIntegrationID, Result: constants.AuditResultSuccess,
}, nil
}
lastErr = callErr
@@ -652,15 +693,18 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr)
return lastErr
return carrierthresholddomain.CommandOutcome{
IntegrationID: lastIntegrationID, Result: attemptObserver.auditResult(lastErr),
}, lastErr
}
// resumeCardWithRetry 调用运营商复机接口(带重试机制)。
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, error) {
// 第二个返回值是最后一次尝试的 Integration Log 标识(无论成功或失败),供调用方回填可靠任务状态。
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, string, error) {
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
return nil, failErr
return nil, "", failErr
}
s.logger.Info("调用网关复机",
@@ -695,13 +739,13 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
if lastErr == nil {
lastErr = attemptObserver.recordingErr
}
break
return nil, lastIntegrationID, lastErr
}
if callErr == nil {
s.logger.Info("网关复机成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID))
return attemptObserver.successful, nil
return attemptObserver.successful, lastIntegrationID, nil
}
lastErr = callErr
@@ -714,7 +758,7 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend},
map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr)
return nil, lastErr
return nil, lastIntegrationID, lastErr
}
// StartMachineSeparatedCard 对机卡分离停机卡执行复机
@@ -724,6 +768,17 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
return errors.New(errors.CodeInvalidParam)
}
actionCode, summary := constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络"
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return blockErr
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
return channelThresholdDenyError()
}
if s.gatewayClient == nil {
failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr)
@@ -752,7 +807,7 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
return denyErr
}
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
return wrapErr
@@ -764,7 +819,7 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
"resumed_at": now,
"stop_reason": "",
"gateway_extend": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}
@@ -818,7 +873,7 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
}
}
if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
if _, err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil {
return err
}
@@ -833,6 +888,16 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
actionCode, summary := constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络"
// 持通道阈值锁:周期内拒绝一切复机,记录拒绝事实并保留锁。
blocked, blockErr := s.channelThresholdBlocked(ctx, card)
if blockErr != nil {
return blockErr
}
if blocked {
s.recordChannelThresholdDenial(ctx, card, actionCode, summary)
return channelThresholdDenyError()
}
// 独立卡处于风险停机或已销户状态时,拒绝复机
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
var denyMsg string
@@ -875,7 +940,7 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
}
attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary)
if err != nil {
wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
return wrapErr
@@ -886,7 +951,7 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil {
}, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, integrationID); err != nil {
if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil {
s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr))
}

View File

@@ -0,0 +1,36 @@
-- 回滚运营商通道流量阈值停机:删除周期阈值停机锁表与 tb_carrier 的阈值三列,与 up 严格成对。
-- 先删锁表索引与锁表,再删除 tb_carrier 的三个阈值列,顺序与 up 的创建顺序严格倒序。
--
-- 不可逆说明ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_carrier_traffic_threshold_lock 与
-- tb_carrier 的 traffic_threshold_enabled/traffic_threshold_value/traffic_threshold_unit 三列。
-- 已触发的阈值停机事实(锁行)与运营商阈值配置都无法由数据库自身重建,只能依据
-- tb_audit_event 中 carrier_create/carrier_update 的前后值快照与 Integration Log 人工复核,
-- 因此 down 只在上述事实已无留存需求时执行。
--
-- 守卫一:锁表存在任何未软删行时直接阻断回滚,避免静默丢失阈值停机/复机事实与恢复扫描状态。
-- 守卫二:存在任一已配置阈值(启用或已填数值)的运营商时直接阻断回滚,避免静默丢失阈值配置。
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_carrier_traffic_threshold_lock WHERE deleted_at IS NULL LIMIT 1) THEN
RAISE EXCEPTION '存在通道流量阈值周期锁事实,拒绝回滚以避免丢失停机/复机事实与恢复扫描状态';
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_carrier WHERE traffic_threshold_enabled = 1 OR traffic_threshold_value IS NOT NULL LIMIT 1) THEN
RAISE EXCEPTION '存在已配置通道流量阈值的运营商,拒绝回滚以避免丢失阈值配置';
END IF;
END $$;
DROP INDEX IF EXISTS idx_carrier_traffic_threshold_lock_submitted;
DROP INDEX IF EXISTS idx_carrier_traffic_threshold_lock_card;
DROP INDEX IF EXISTS idx_carrier_traffic_threshold_lock_status_period;
DROP TABLE IF EXISTS tb_carrier_traffic_threshold_lock;
ALTER TABLE tb_carrier
DROP COLUMN IF EXISTS traffic_threshold_enabled,
DROP COLUMN IF EXISTS traffic_threshold_value,
DROP COLUMN IF EXISTS traffic_threshold_unit;

View File

@@ -0,0 +1,112 @@
-- 运营商通道流量阈值停机tb_carrier 增加阈值配置列,并新增「按周期锁」表承载阈值停机/复机事实。
-- 背景:运营需要按运营商配置的计费周期流量阈值自动停复机。既有 tb_carrier 没有任何承载
-- 「通道流量阈值」的列,也没有任何表承载「某卡在某计费周期内是否已触发阈值停机」的事实,
-- 因此本迁移扩展 tb_carrier 三个列并新增 tb_carrier_traffic_threshold_lock 表。
--
-- 设计选择:
-- 1. 阈值三列全部可空/默认禁用enabled 默认 0未启用value 与 unit 仅在启用时有意义,
-- 既有运营商行零变更unit 仅允许 MB/GB由应用层换算为 MB 后判定1GB=1024MB
-- 2. 锁表以 (carrier_id, card_id, period_start) 为唯一键:一个卡在一个计费周期内至多一条锁,
-- 重复达量观测由唯一冲突视为「该周期已处理」,不重复停机、不重放事件。
-- 唯一键为部分唯一索引WHERE deleted_at IS NULL避免软删行占用键位。
-- 注意:该唯一键不与任何 GORM OnConflict 组合(应用层用显式插入 + 23505 识别),
-- 因此不涉及部分唯一索引谓词未声明导致 OnConflict 失败的问题KNOWN-ISSUE-001
-- 3. period_start 为 timestamptz存「该卡所属运营商计费周期的起点」UTC+8 口径的零点),
-- 由应用层按 data_reset_day 计算后写入;恢复扫描与周期处理据此判断是否跨期。
-- 4. status 表达整行生命周期locked 进行中 / unlocked 已跨期解锁stop_status 与
-- resume_status 分别表达停机与复机两个子任务pending 待提交 / submitted 已提交待确认 /
-- confirmed 已确认 / failed 已失败 / unknown 结果未知anomaly_flag 标记需人工核对的异常,
-- failure_reason 只写可安全对外展示的原因,不写内部细节。
-- 5. *_submitted_at 记录子任务进入 submitted 的时刻,恢复扫描据此判断查询窗口是否超期。
-- 6. *_integration_id 记录对应停/复机 Gateway 调用的 Integration Log 标识,便于人工核对。
-- 7. 不使用数据库外键carrier_id 与 card_id 由应用层显式校验。
-- 8. creator/updater 记录维护账号,系统写入为 0。
ALTER TABLE tb_carrier
ADD COLUMN traffic_threshold_enabled SMALLINT NOT NULL DEFAULT 0,
ADD COLUMN traffic_threshold_value NUMERIC(18,2),
ADD COLUMN traffic_threshold_unit VARCHAR(8) NOT NULL DEFAULT '';
COMMENT ON COLUMN tb_carrier.traffic_threshold_enabled IS '通道流量阈值是否启用 0-未启用 1-已启用;未启用时不参与达量停机判定';
COMMENT ON COLUMN tb_carrier.traffic_threshold_value IS '通道流量阈值数值(正数),单位由 traffic_threshold_unit 决定NULL-未配置,启用时必填';
COMMENT ON COLUMN tb_carrier.traffic_threshold_unit IS '通道流量阈值单位 MB-兆字节 GB-吉字节,空字符串-未配置,启用时必填';
-- 阈值配置在数据库层就不允许出现「启用但无数值/单位」或「非正数阈值/未知单位」的半配置状态:
-- 半配置会被达量判定解释为阈值 0 而立即停机,属于必须由 Schema 兜住的风险。
-- 未启用时允许保留数值与单位(停用只停止新锁创建,不丢配置);未配置表示为 value IS NULL + unit = ''。
-- 启停列必须是严格的 0/1判定端按 enabled = 1 识别启用,其他取值会被静默视为未启用,
-- 因此非 0/1 的半残取值必须在 Schema 与业务边界一起拒绝。
ALTER TABLE tb_carrier
ADD CONSTRAINT ck_carrier_traffic_threshold_config CHECK (
traffic_threshold_enabled IN (0, 1)
AND (traffic_threshold_value IS NULL OR traffic_threshold_value > 0)
AND traffic_threshold_unit IN ('', 'MB', 'GB')
AND (traffic_threshold_enabled = 0
OR (traffic_threshold_value IS NOT NULL AND traffic_threshold_unit IN ('MB', 'GB')))
);
CREATE TABLE tb_carrier_traffic_threshold_lock (
id BIGSERIAL PRIMARY KEY,
carrier_id BIGINT NOT NULL,
card_id BIGINT NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'locked',
stop_status VARCHAR(16) NOT NULL DEFAULT 'pending',
resume_status VARCHAR(16) NOT NULL DEFAULT 'pending',
stop_submitted_at TIMESTAMPTZ,
resume_submitted_at TIMESTAMPTZ,
stop_integration_id VARCHAR(64) NOT NULL DEFAULT '',
resume_integration_id VARCHAR(64) NOT NULL DEFAULT '',
anomaly_flag SMALLINT NOT NULL DEFAULT 0,
failure_reason VARCHAR(500) NOT NULL DEFAULT '',
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT ck_carrier_traffic_threshold_lock_status CHECK (status IN ('locked', 'unlocked')),
CONSTRAINT ck_carrier_traffic_threshold_lock_stop_status CHECK (stop_status IN ('pending', 'submitted', 'confirmed', 'failed', 'unknown')),
CONSTRAINT ck_carrier_traffic_threshold_lock_resume_status CHECK (resume_status IN ('pending', 'submitted', 'confirmed', 'failed', 'unknown')),
CONSTRAINT ck_carrier_traffic_threshold_lock_anomaly CHECK (anomaly_flag IN (0, 1))
);
-- 唯一键必须是「部分唯一索引」CREATE TABLE 的表约束不支持 WHERE 谓词(实测 pq: syntax error at or near "WHERE"
-- 而软删感知要求谓词 deleted_at IS NULL 使软删行不占用键位,因此独立建索引。
-- 该索引不与任何 GORM OnConflict 组合(部分唯一索引谓词未声明时 OnConflict 无法命中,见 KNOWN-ISSUE-001
-- 应用层以显式插入 + 23505 识别表达「该周期已处理」。
CREATE UNIQUE INDEX uq_carrier_traffic_threshold_lock_key
ON tb_carrier_traffic_threshold_lock (carrier_id, card_id, period_start)
WHERE deleted_at IS NULL;
COMMENT ON TABLE tb_carrier_traffic_threshold_lock IS '运营商通道流量阈值按周期锁,一个卡一个计费周期至多一条,承载阈值停机/复机事实与恢复扫描状态';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.id IS '主键';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.carrier_id IS '触发阈值时的运营商ID周期起点与恢复扫描均按该运营商的 data_reset_day 计算';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.card_id IS '触发阈值的物联网卡ID';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.period_start IS '触发时的计费周期起点UTC+8 零点口径),与唯一键共同表达「哪个周期」';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.status IS '整行生命周期 locked-进行中 unlocked-已跨期解锁';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.stop_status IS '停机子任务 pending-待提交 submitted-已提交待确认 confirmed-已确认 failed-已失败 unknown-结果未知';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.resume_status IS '复机子任务 pending-待提交 submitted-已提交待确认 confirmed-已确认 failed-已失败 unknown-结果未知';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.stop_submitted_at IS '停机进入 submitted 的时刻,恢复扫描据此判断查询窗口';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.resume_submitted_at IS '复机进入 submitted 的时刻,恢复扫描据此判断查询窗口';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.stop_integration_id IS '停机 Gateway 调用的 Integration Log 标识,便于人工核对';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.resume_integration_id IS '复机 Gateway 调用的 Integration Log 标识,便于人工核对';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.anomaly_flag IS '异常标记 0-正常 1-需人工核对(查询窗口超期或失败无法自动确认)';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.failure_reason IS '可安全对外展示的原因,不写内部细节';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.creator IS '创建人账号ID系统写入为 0';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.updater IS '最近更新人账号ID系统写入为 0';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.created_at IS '创建时间';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_carrier_traffic_threshold_lock.deleted_at IS '软删除时间,正常流程不删除,仅数据清理使用';
-- 周期处理扫描进行中锁:按 (status, period_start) 取「当前周期之前」的 locked 行。
CREATE INDEX idx_carrier_traffic_threshold_lock_status_period ON tb_carrier_traffic_threshold_lock (status, period_start);
-- 四入口拒绝与周期处理按卡查当前周期锁。
CREATE INDEX idx_carrier_traffic_threshold_lock_card ON tb_carrier_traffic_threshold_lock (card_id, period_start);
-- 恢复扫描只看 submitted 子任务。
CREATE INDEX idx_carrier_traffic_threshold_lock_submitted ON tb_carrier_traffic_threshold_lock (stop_status, resume_status)
WHERE deleted_at IS NULL AND (stop_status = 'submitted' OR resume_status = 'submitted');
COMMENT ON INDEX idx_carrier_traffic_threshold_lock_status_period IS '通道阈值周期处理扫描的进行中锁索引';
COMMENT ON INDEX idx_carrier_traffic_threshold_lock_card IS '通道阈值按卡查当前周期锁索引';
COMMENT ON INDEX idx_carrier_traffic_threshold_lock_submitted IS '通道阈值恢复扫描的已提交子任务部分索引';
COMMENT ON INDEX uq_carrier_traffic_threshold_lock_key IS '通道阈值周期锁唯一键(软删感知):一个卡在一个运营商计费周期内至多一条,冲突即该周期已处理';

View File

@@ -1,29 +1,86 @@
## Context
通道累计流量和停复机已有外部调用链;本能力增加本地停机锁及可靠任务,不改变套餐预警
「运营商通道」即既有 `Carrier``tb_carrier`),系统无独立通道实体;卡归属通道 = `IotCard.CarrierID`,下文 `carrier_id` 均指该外键。`Carrier.DataResetDay` 是上游流量重置日128运营商每月清零网关计数器的日期已有 CRUD 管理接口。卡上 `last_gateway_reading_mb` 是运营商当前周期累计流量读数,由流量轮询经卡观测 `ApplyTrafficObservation` 事务写入。停复机统一入口 `StopResumeService.EvaluateAndAct`,现有停因 3 种轮询停因可自动复机,风险网关扩展态(风险停机/销户禁止复机。资金类外部调用已有「DB 事实表 + 每分钟恢复扫描 + 至多一次提交认领」范式refundchannel。套餐真流量预警第 12 项,已归档)只通知不停机,本能力与其零耦合
## Goals / Non-Goals
**Goals:**
- 通道阈值配置(扩列 `tb_carrier`+ 字段级平台守卫 + 审计并入既有 carrier 审计。
- 达量停机:锁 + 可靠停机事件,周期内拒绝一切复机。
- 新周期解锁 + 条件复机;停机/复机失败未知由恢复扫描确认或超期转人工。
**Non-Goals:**
- 不改套餐真流量预警代码与表。
- 不改既有 3 种轮询停因语义、不改网关调用层、不改 `isTrafficResetWindow`
- 不扩散设备维度;不建独立通道实体;不为阈值配置建附属表。
## Decisions
- 通道配置和卡周期锁分表;锁以通道、卡、周期唯一。
- 达量事务写锁和 Outbox 停机任务;新周期扫描按锁、主套餐和其他锁决定仅解锁或复机。
### 1. 阈值配置扩列 tb_carrier
## 配置、锁与任务契约
- 新增列:`traffic_threshold_enabled`bool默认 false`traffic_threshold_value`正数numeric`traffic_threshold_unit``MB`/`GB`)。
- **周期起始日复用既有 `data_reset_day`,不新增列。** 通道计费周期 = 网关计数器清零周期,是同一事实(列注释即此语义),两套并存必然口径漂移;因此不引入 `traffic_period_type``traffic_period_start_day` 字段统计周期只有一个月内起始日128上海时区
- **GB 换算系数固定 1 GB = 1024 MB**,以常量定义;换算后与 `last_gateway_reading_mb`MB同单位比较。
### 通道配置
### 2. period_start 公式(上海时区)
- 扩展既有运营商通道创建/编辑 DTO`traffic_threshold_enabled``traffic_threshold_value``traffic_threshold_unit``traffic_period_type``traffic_period_start_day``traffic_threshold_status`。仅超级管理员、平台用户可读写;阈值必须为正数,单位只能是系统支持的 MB/GB周期起始日为 128所有时间边界按上海时区计算。
- 启用时完整校验字段;停用时停止后续达量判断但不删除当前周期锁或历史任务结果。新增、更新、启用、停用均写通道 ID、前后字段、操作者和时间审计不向代理、企业、个人客户暴露配置字段。
`resetDay` 取该卡 carrier 的 `data_reset_day`
### 达量停机
```
M = time.Date(y, m, resetDay, 0, 0, 0, 0, time.FixedZone("", 8*3600)) // 本月重置日 0 点
period_start = M if now >= M
period_start = M.AddDate(0, -1, 0) if now < M // 上月重置日 0 点
```
- 运营商流量同步/周期扫描按卡当前所属通道及上海时区周期边界读取运营商回传累计流量,换算至配置单位;不使用套餐真流量预警数据。达到或超过阈值时,事务中以 `(channel_id, card_id, period_start)` 唯一键创建通道阈值停机锁和 Outbox 停机任务
- 唯一冲突表示该周期已处理;重复同步、并发扫描或任务重放不得创建第二把锁或重复发起停机。停机调用失败/未知保留锁、任务结果与安全失败原因,复用既有外部调用恢复;锁存在时所有人工或自动复机入口先拒绝。
`resetDay` 128 保证每月有定义(无 2 月 30 日问题)。与 `isTrafficResetWindow``internal/domain/cardobservation/traffic.go`,重置日当天+前一日)**是两个口径**:前者是网关计数器清零的观测窗口(判断读数回落是否为合法清零),后者是逻辑周期起点(锁唯一键);二者互不修改
### 新周期解锁与复
### 3. 达量停
- 周期转换任务锁定上周期仍有效的通道锁,解除其通道阈值限制;随后重新检查当前有效主套餐、风险停机、销户和其他停机锁。仅全部条件允许时写可靠复机任务;任一条件不满足时只解锁,不调用运营商
- 复机任务成功记录结果;失败/未知保留可恢复执行结果,不重建通道锁或改变套餐状态。通道停用、卡换通道或删除配置均不得使历史锁/任务失去审计关联
- **判定落点:`ApplyTrafficObservation` 既有事务内**(行锁 + CAS 已持有,数据最新,并发天然安全;人工刷新同路径行为一致)。条件:`decision.ReadingAccepted == true` 且卡所属 carrier 阈值启用;异常下降保护(`ReadingAccepted == false`)不判定。只用 `last_gateway_reading_mb`,不用 `data_usage_mb``current_month_usage_mb` 或套餐真流量
- 事务内写锁 + Outbox 停机事件ENG-TX-001事件 ID 用 `outboxid.Stable`
- 锁唯一键 `(carrier_id, card_id, period_start)`**postgres 23505 单独捕获为「该周期已处理」并跳过**,不得与既有流量基线 CAS 冲突(`CodeConflict`)混流——两类冲突语义不同,前者幂等跳过,后者重放重试。
- **改阈值不生效于当前周期已持锁卡**:唯一键已占位 = 该周期已处理,当前周期维持拒绝复机;新周期按新阈值判断。
- **停机消费者**worker消费停机 Outbox 事件):
1. 提交认领:锁行 `stop_submitted_at IS NULL` 条件更新refundchannel `claimChannelSubmission` 范式),至多一次执行;认领失败 = 已提交过 → 只走恢复查询,绝不重复调用。
2. 卡已 `offline`(其他停因先行)→ 不调 Gateway直接确认 success。
3. 否则复用 `stopCardWithRetry`(内部 3 次重试、Integration Log、统一审计成功后写 `network_status=offline``stop_reason=channel_threshold``stopped_at`
4. 失败/未知保留锁与任务状态、记安全失败原因unknown 的 Integration Log 必带 `RecoveryStrategy`
## Migration Plan
### 4. 停因与复机拒绝
新增成对迁移;隔离库验证周期边界、重复达量、停机失败、复机条件和 up/down/up。
- 新增 `StopReasonChannelThreshold = "channel_threshold"``pkg/constants/iot.go` 停因组)。**不纳入 `isPollingStopReason`**(否则 `EvaluateAndAct` 离线分支会绕过周期逻辑直接自动复机),**不纳入 `isDeviceScopeReason`**(不扩散设备)。复机只由周期处理发起。
- **持锁拒绝覆盖四个入口**,入口前置检查(锁存在 → 拒绝 + 审计 `denied` + 锁保留):
| 入口 | 覆盖路径 |
|---|---|
| `resumeSingleCard` | `EvaluateAndAct` 自动复机、`ResumeCardIfStopped``resumeDeviceCards` 遍历 |
| `ManualStartCard` | 手动复机 |
| `ForceStartCard` | 保护期强制复机(不加此拒绝,保护期一致性检查会强行复机持锁卡) |
| `StartMachineSeparatedCard` | 机卡分离复机 |
- **「其他停机锁」定义**`stop_reason` 非空且非 `channel_threshold`arrears/manual/carrier_stopped/protect_period 等),或 `gateway_extend` 为风险停机/销户(`isRiskGatewayExtend`)。
### 5. 周期处理与恢复(两个独立 cron
`@every 1m` + `asynq.Unique(10m)` + 无 payload`TaskTypeRefundChannelRecovery` 形态注册(`cmd/worker/main.go`);独立 cron 而非挂流量轮询同路径,是因为不依赖「停机卡是否继续被轮询」,新周期后最迟 1 分钟处理。
1. **周期处理**:扫描 `period_start` 已过期的持锁锁行(按**锁行自身 carrier** 的 `data_reset_day` 判断过期,支持换运营商后旧锁归属);条件更新认领解锁(`status=locked → unlocked`,防并发重复);逐卡评估:有效主套餐(`hasValidPackage`+ 流量未耗尽(`isTrafficExhausted`+ 实名 OK`isRealnameOK`+ 非风险 extend + 无其他停因;全满足写复机 Outbox 事件,任一不满足只解锁。
2. **恢复扫描**:扫描 `stop_status`/`resume_status``submitted` 的锁;**只查询网关状态回填,绝不重复发起停复机**;确认成功 → 回填任务状态并补写卡状态覆盖「Gateway 成功但 DB 更新失败」场景);自提交起超过 **30 分钟**(常量定义)仍不可查 → `anomaly_flag=1` + 安全失败原因,退出扫描转人工,不自动删除锁。
- **复机消费者**:结构同停机消费者,认领字段 `resume_submitted_at`,复用 `resumeCardWithRetry`,成功后写 `network_status=online``stop_reason=""``resumed_at`
- 周期处理与恢复 Handler 审计上下文固定 `ActorKind=AuditActorScheduledJob``Source=AuditSourceScheduler`
### 6. 权限与审计
- 现有 carrier CRUD 路由无角色守卫(仅 `Auth: true`,代理后端账号可达 admin 组)。**字段级守卫**Create/Update 中阈值字段仅 `SuperAdmin`/`Platform` 可写(对齐 `requirePlatformManagement` 模式非平台账号提交含阈值字段的请求即拒绝List/Get 响应对非平台账号不返回阈值字段。**不把整个 carrier CRUD 改为平台专属**,避免影响既有非阈值字段用途。
- 审计并入既有 carrier 更新审计(`writeAudit` 整体快照前后值),不新建独立审计动作;启停即 Update 的一部分,前后值自然覆盖。
- 通道停用:`enabled=false` 只停止新锁创建;已持锁卡当前周期继续拒绝复机,新周期解锁/复机照常;锁与历史保留。
### 7. 锁表结构
`carrier_id``card_id``period_start`timestamptz唯一键三列组合软删感知`status``locked`/`unlocked`)、任务状态组(`stop_status`/`resume_status``pending`/`submitted`/`confirmed`/`failed`/`unknown`)、提交认领字段(`stop_submitted_at`/`resume_submitted_at`)、`anomaly_flag``failure_reason`、时间戳。成对迁移up/down
## Risks / Trade-offs
- 达量判定嵌入 `ApplyTrafficObservation` 事务:该事务变重(多一次 carrier 查询 + 锁插入)。收益是并发安全与数据最新;风险是事务失败回滚会连同流量事实一起回滚——既有 CAS 冲突已按此语义处理,行为一致。
- 停机成功写 `stop_reason=channel_threshold` 会覆盖卡上既有停因字段;仅当停机消费者确认 Gateway 成功后写入,且持锁期本就该拒绝其他路径,覆盖可接受。
- 恢复扫描「只查询回填」依赖网关状态查询接口可用;持续不可用 → 30 分钟超期转人工,锁保留,无数据丢失。

View File

@@ -21,4 +21,11 @@
## Impact
影响通道、卡状态、可靠任务、外部运营商调用和 Schema。
影响通道、卡状态、可靠任务、外部运营商调用和 Schema。具体落点:
- `tb_carrier` 扩列:`traffic_threshold_enabled``traffic_threshold_value``traffic_threshold_unit`(周期起始日复用既有 `data_reset_day`,不新增列)。
- 新增周期阈值停机锁表:`(carrier_id, card_id, period_start)` 唯一键、任务状态组、提交认领字段、`anomaly_flag`;成对迁移。
- 新增 2 个 Outbox 事件类型停机、复机各一worker 消费。
- 新增 2 个 cron 与 worker 装配点:周期处理(解锁/条件复机)、恢复扫描(只查询回填),均 `@every 1m` + `asynq.Unique(10m)`,照 `TaskTypeRefundChannelRecovery` 形态注册。
- 新增停因常量 `channel_threshold`4 个复机入口新增持锁拒绝。
- carrier 管理接口字段级守卫与响应过滤(阈值字段仅平台账号可见可写)。

View File

@@ -5,16 +5,22 @@
## ADDED Requirements
### Requirement: 通道阈值配置、权限与审计
仅超级管理员和平台用户 SHALL 在运营商通道新建或编辑时配置流量阈值开关、阈值数值流量单位、统计周期和生效状态;阈值数值必须为正,单位必须为系统支持单位,统计周期必须能换算为明确起止边界。新增、修改、启用、停用均必须记录操作者、修改前后字段时间。代理、企业和个人客户不得读取或修改通道阈值配置
仅超级管理员和平台用户 SHALL 在运营商通道新建或编辑时配置流量阈值开关、阈值数值流量单位;阈值数值必须为正,流量单位必须为系统支持的 MB 或 GB统计周期为通道既有的上游流量重置日128上海时区不另行配置周期。阈值字段仅对平台账号可见列表与详情响应对非平台账号不返回阈值字段非平台账号提交包含阈值字段的创建或编辑请求必须被拒绝不改变通道其余字段的既有可见性与可编辑范围。新增、修改、启用、停用均必须并入通道更新审计的操作者、修改前后字段时间记录,不另建独立审计动作。停用仅停止后续新锁创建:已持锁卡当前周期继续拒绝复机,新周期解锁与条件复机照常;停用不得删除当前周期锁或历史任务结果
#### Scenario: 越权修改通道阈值
- **WHEN** 非超级管理员、非平台用户请求创建、编辑或启停通道阈值
- **THEN** 系统拒绝请求,不修改配置且不产生审计成功事实
### Requirement: 通道阈值停机与周期恢复
系统 SHALL 为每个已启用运营商通道按其配置统计周期判断运营商回传的每张卡当前周期累计流量;达量即写通道阈值停机锁、创建可靠停机任务并调用运营商停机。持锁卡在当前周期内 MUST 拒绝复机;调用失败或未知保留任务结果并按既有恢复机制处理。
#### Scenario: 停用后已持锁卡进入新周期
- **WHEN** 通道阈值停用后,曾达量持锁的卡进入新计费周期
- **THEN** 系统不再创建新锁,且该卡按新周期规则正常解锁并按条件复机,历史锁与任务结果保留
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐且不存在风险停机、销户或其他停机锁时调用自动复机,不符合条件不得调用复机。复机失败记录结果并可靠处理。
### Requirement: 通道阈值停机与周期恢复
系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。
持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机、保护期强制复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;仅存在有效主套餐、流量未耗尽、实名满足、不存在风险停机、销户或其他停机锁时创建自动复机任务,任一条件不满足时只解锁不调用运营商。其他停机锁指卡停机原因为非空且非通道阈值的其他停因,或网关扩展状态为风险停机、销户。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。
#### Scenario: 达量后人工复机
- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机
@@ -23,3 +29,19 @@
#### Scenario: 新周期仍有其他停机锁
- **WHEN** 新周期开始的持锁卡没有风险停机但存在其他停机锁
- **THEN** 系统解除通道阈值锁但不调用运营商复机
#### Scenario: 达量后强制复机被拒
- **WHEN** 当前计费周期内的持锁卡触发保护期强制复机或机卡分离复机
- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实
#### Scenario: 停机结果未知恢复确认
- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机
- **THEN** 系统补写卡停机状态并将任务结果记为已确认
#### Scenario: 停机结果未知超期转人工
- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果
- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁
#### Scenario: 换运营商后周期归属
- **WHEN** 持锁卡更换运营商后到达新计费周期
- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理

View File

@@ -1,8 +1,30 @@
## 1. 阈值控制
- [ ] 1.1 追踪通道流量、卡状态、停复机锁、运营商任务与恢复链路
- [ ] 1.2 新增通道配置、周期停机锁、任务结果的成对迁移、模型、索引和管理接口
- [ ] 1.3 实现达量写锁/可靠停机、周期扫描解锁/条件复机及幂等恢复。
## 1. 迁移与模型
- [x] 1.1 成对迁移:`tb_carrier` 扩列 `traffic_threshold_enabled`(默认 false`traffic_threshold_value`(正数 numeric`traffic_threshold_unit`MB/GB新增周期阈值停机锁表`(carrier_id, card_id, period_start)` 唯一键软删感知、任务状态组stop/resumepending/submitted/confirmed/failed/unknown、提交认领字段`stop_submitted_at`/`resume_submitted_at`)、`anomaly_flag``failure_reason`
- [x] 1.2 锁表模型与 Store含唯一键冲突 23505 识别、条件更新认领方法)
## 2. 验证
- [ ] 2.1 隔离库验证周期、达量、拒绝复机、其他锁、失败重试和 up/down/up
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-carrier-channel-traffic-thresholds --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。
## 2. 阈值配置与权限
- [x] 2.1 carrier 创建/更新 DTO 增阈值字段与校验(正数、单位 MB/GB、周期复用 `data_reset_day`
- [x] 2.2 字段级平台守卫Create/Update 仅 SuperAdmin/Platform 可写阈值字段非平台提交即拒绝List/Get 对非平台账号不返回阈值字段;不改 carrier 其余字段既有可见性。
- [x] 2.3 阈值新增、修改、启用、停用并入既有 carrier 更新审计前后值快照;同步 `cmd/gendocs` 文档。
## 3. 达量判定
- [x] 3.1 period_start 计算(显式 Asia/ShanghairesetDay 取卡 carrier 的 `data_reset_day`)与 GB→MB 换算常量1 GB = 1024 MB
- [x] 3.2 `ApplyTrafficObservation` 事务内达量判定:`ReadingAccepted == true` 且 carrier 阈值启用时,以 `last_gateway_reading_mb` 换算比较;达量写锁 + 停机 Outbox 事件同事务。
- [x] 3.3 锁唯一键 23505 单独捕获为「该周期已处理」跳过,与流量基线 CAS 冲突分离。
## 4. 可靠停复机消费者
- [x] 4.1 停机消费者:提交认领(`stop_submitted_at IS NULL` 条件更新);卡已 offline 跳过 Gateway 直接确认;否则复用 `stopCardWithRetry`;成功后写 `network_status=offline``stop_reason=channel_threshold``stopped_at`;失败/未知保留锁与任务状态unknown 的 Integration Log 必带 RecoveryStrategy。
- [x] 4.2 复机消费者:结构同停机(`resume_submitted_at` 认领、复用 `resumeCardWithRetry`、成功后写 online/清停因/resumed_at新增 2 个 Outbox 事件类型与 worker 消费者注册、装配。
## 5. 周期处理与恢复 cron
- [x] 5.1 周期处理 cron`@every 1m` + `asynq.Unique(10m)` + 无 payload扫描 period_start 过期持锁锁行(按锁行自身 carrier 的 `data_reset_day` 判断),条件更新认领解锁,逐卡评估(有效主套餐 + 流量未耗尽 + 实名 OK + 非风险 extend + 无其他停因),全满足写复机事件,否则只解锁。
- [x] 5.2 恢复扫描 cron同形态扫描 stop/resume 为 submitted 的锁,只查询网关状态回填、绝不重复发起停复机;确认成功补写卡状态;自提交起超 30 分钟(常量)仍不可查 → `anomaly_flag` + 安全失败原因转人工,不自动删除锁。
- [x] 5.3 worker 注册两个 cron 与装配;周期处理与恢复 Handler 审计上下文固定 `ActorKind=AuditActorScheduledJob``Source=AuditSourceScheduler`
## 6. 停因与复机拒绝
- [x] 6.1 新增 `StopReasonChannelThreshold = "channel_threshold"` 常量;确认不纳入 `isPollingStopReason``isDeviceScopeReason`
- [x] 6.2 持锁拒绝覆盖四个入口(`resumeSingleCard``ManualStartCard``ForceStartCard``StartMachineSeparatedCard`):拒绝 + 审计 denied + 锁保留。
## 7. 验证
- [x] 7.1 隔离库验证:平台配置阈值(含 GB 换算)成功 + 审计前后值,代理/企业写被拒且读不到字段;达量触发停机、同周期重复判定唯一冲突不重复锁/不重复停机;持锁期四入口复机拒绝且锁保留;停机 unknown 保留 + RecoveryStrategy + 恢复扫描确认补写卡状态、超期 anomaly 转人工新周期解锁、条件满足才复机、其他停因stop_reason 非空非本停因或风险 extend只解锁停用后新卡不再锁、已持锁卡新周期照常解锁复机换运营商旧锁保留、新周期按新 carrier 重置日;第 12 项套餐预警与既有 3 种停因停复机回归不变;迁移 up/down/up。
- [x] 7.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-carrier-channel-traffic-thresholds --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

@@ -70,6 +70,8 @@ func AgentOpenAPIStopReasonName(reason string) string {
return "未完成实名认证"
case StopReasonCarrierStopped:
return "运营商侧停机"
case StopReasonChannelThreshold:
return "通道流量阈值停机"
case "":
return ""
default:

View File

@@ -155,12 +155,14 @@ const (
CardObservationSceneAdminRealnameLink = "admin_realname_link" // 后台获取实名链接
CardObservationSceneBusinessStop = "business_stop" // 业务停机成功
CardObservationSceneBusinessResume = "business_resume" // 业务复机成功
CardObservationScenePackageChanged = "package_changed" // 购包或套餐激活成功
CardObservationSceneDeviceSwitchCard = "device_switch_card" // 设备切卡成功
CardObservationSceneDeviceSwitchMode = "device_switch_mode" // 设备切卡模式成功
CardObservationSceneDeviceReboot = "device_reboot" // 设备重启成功
CardObservationSceneDeviceReset = "device_reset" // 设备恢复出厂成功
CardObservationSceneDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置成功
// CardObservationSceneCarrierThresholdRecovery 是通道阈值恢复扫描的只读状态查询场景。
CardObservationSceneCarrierThresholdRecovery = "carrier_threshold_recovery"
CardObservationScenePackageChanged = "package_changed" // 购包或套餐激活成功
CardObservationSceneDeviceSwitchCard = "device_switch_card" // 设备切卡成功
CardObservationSceneDeviceSwitchMode = "device_switch_mode" // 设备切卡模式成功
CardObservationSceneDeviceReboot = "device_reboot" // 设备重启成功
CardObservationSceneDeviceReset = "device_reset" // 设备恢复出厂成功
CardObservationSceneDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置成功
)
// 卡实名状态变更 Outbox 事件

View File

@@ -96,6 +96,10 @@ const (
TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单
TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询
TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿
// 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度)
TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机
TaskTypeCarrierThresholdRecovery = "carrier_threshold:recovery" // 通道阈值停复机结果恢复扫描
)
// 用户状态常量
@@ -319,6 +323,9 @@ func QueueForTaskType(taskType string) string {
return QueueWeComApproval
case TaskTypeAgentRechargeRecovery, TaskTypeRefundChannelRecovery, TaskTypeRefundCommissionRecovery:
return QueueDefault
case TaskTypeCarrierThresholdCycle, TaskTypeCarrierThresholdRecovery:
// 与退款恢复同队列:每分钟轻量扫描,复用既有已监听队列,不新增队列与权重。
return QueueDefault
default:
return QueueDefault
}

View File

@@ -76,6 +76,10 @@ const (
StopReasonNoPackage = "no_package" // 停机原因:无有效套餐
StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证
StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机
// StopReasonChannelThreshold 是运营商通道流量阈值达量停机的停因。
// 它既不属于轮询停因isPollingStopReason也不参与设备维度扩散isDeviceScopeReason
// 周期内必须拒绝一切复机,复机只由通道阈值的周期处理发起。
StopReasonChannelThreshold = "channel_threshold"
)
// 套餐流量类型

View File

@@ -5,6 +5,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierthresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -60,19 +61,22 @@ type WorkerStores struct {
// WorkerServices Worker 侧所有 Service 的集合
type WorkerServices struct {
PaymentAudit agentrechargeApp.PaymentAuditWriter
RechargeAudit agentrechargeApp.RechargeAuditWriter
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
ObservationSeriesEvents cardObservationApp.SeriesEventWriter
CommissionCalculation *commission_calculation.Service
CommissionStats *commission_stats.Service
UsageService *packagepkg.UsageService
ActivationService *packagepkg.ActivationService
ResetService *packagepkg.ResetService
AlertService *pollingSvc.AlertService
CleanupService *pollingSvc.CleanupService
StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler
PaymentAudit agentrechargeApp.PaymentAuditWriter
RechargeAudit agentrechargeApp.RechargeAuditWriter
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
ObservationSeriesEvents cardObservationApp.SeriesEventWriter
CommissionCalculation *commission_calculation.Service
CommissionStats *commission_stats.Service
UsageService *packagepkg.UsageService
ActivationService *packagepkg.ActivationService
ResetService *packagepkg.ResetService
AlertService *pollingSvc.AlertService
CleanupService *pollingSvc.CleanupService
StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler
// CarrierThreshold 是运营商通道流量阈值的唯一用例实例:达量判定、持锁拒绝、停复机消费者与
// 两个计划任务共用它,使直接执行的成功与恢复确认的成功走同一回写路径。
CarrierThreshold *carrierthresholdApp.Service
OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖)
AssetPackageOrderCreator task.AssetPackageBatchOrderCreator // 批量订购复用后台单笔订单规则
DeviceBatchAllocator task.DeviceBatchAllocationExecutor // 设备CSV批量分配复用现有业务规则