feat: 卡网关按运营商账户限流并记录停复机调用
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m33s

- 新增网关限流:按本系统运营商账户(卡 carrier_id)→ 限流组 → 操作限制每秒请求数,每次 HTTP 尝试计量,默认限流、仅后台人工点击/通道阈值/自动续费/手动刷新豁免;支持只统计与拦截两种模式,默认只统计
- Asynq 遇限流不计失败、固定短延迟重试;轮询任务与停复机业务重试遇限流短延迟重排
- 停机、复机不再使用网关内部重试,只发一次请求
- 网关返回结构化错误;新增上游错误分类规则表与测试接口
- 新增按月分区的停复机记录表:系统自动停复机只写记录,不再写统一审计与集成日志;普通轮询查询不再写集成日志
- 新增限流组、错误分类、停复机记录、按账户调用统计与枚举后台接口
- 迁移 000243~000246;同步 OpenSpec 提案(含后续 6 个 change)与技术方案、工程约束
This commit is contained in:
邹进
2026-10-24 15:06:41 +09:00
parent 9e45097aa2
commit 42e881576d
106 changed files with 7173 additions and 48 deletions

View File

@@ -31,6 +31,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。

View File

@@ -22,6 +22,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
@@ -71,6 +72,11 @@ func main() {
// 9. 初始化 Gateway 客户端(可选)
gatewayClient := initGateway(cfg, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
// 10. 初始化所有业务组件(通过 Bootstrap)
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
@@ -83,6 +89,7 @@ func main() {
QueueClient: queueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
})
if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err))

View File

@@ -40,6 +40,8 @@ func generateAdminDocs(outputPath string) error {
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。

View File

@@ -34,6 +34,7 @@ import (
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/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
@@ -99,6 +100,8 @@ type workerRuntime struct {
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
// gatewayLimit 是卡网关运营商账户限流组件(限流、统计、停复机记录)。
gatewayLimit *gatewaylimit.Runtime
}
func main() {
@@ -178,6 +181,8 @@ func runWorker(cfg *config.Config) {
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
rescuePendingImportTasks(ctx, runtime, appLogger)
@@ -266,6 +271,11 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
storageSvc := initStorage(cfg, appLogger)
gatewayClient := initGateway(cfg, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
Addr: redisAddr,
@@ -282,6 +292,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
QueueClient: workerQueueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
}
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
@@ -289,7 +300,13 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
}
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger, queue.WithRateLimitedRetry(queue.RateLimitedRetry{
Match: gateway.IsRateLimited,
Delay: func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
},
OnDeferred: gatewayLimit.TrackDeferral,
}))
outboxQueueClient := workerQueueClient
pollingConfigStore := postgres.NewPollingConfigStore(db)
@@ -324,6 +341,9 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingBase.SetRateLimitedDelay(func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
})
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
@@ -337,6 +357,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
db: db,
storageSvc: storageSvc,
gatewayClient: gatewayClient,
gatewayLimit: gatewayLimit,
asynqClient: asynqClient,
workerResult: workerResult,
workerServer: workerServer,
@@ -996,6 +1017,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 2 * * *", asynq.NewTask(
constants.TaskTypeStopResumeRecordPartition,
nil,
asynq.MaxRetry(3),
asynq.Queue(constants.QueueDataCleanup),
)); err != nil {
return fmt.Errorf("注册停复机记录分区维护定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask(
constants.TaskTypeNotificationCleanup,
nil,

View File

@@ -283,9 +283,9 @@
- **最小正例**:外部失败写脱敏 Integration Log,HTTP 请求写 Access Log。
- **最小反例**:把完整凭证写 app.log。
- **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。卡网关调用(add-carrier-gateway-rate-limit):普通轮询查询不写 Integration Log,失败原因与调用量由 Redis 按运营商账户的分钟计数与卡最近错误承载(同时是优先执行的轮次仍写);系统自动停复机不写 Integration Log 与 Audit Event,每次上游调用写停复机记录表 `tb_card_stop_resume_record`;后台人工点击、通道阈值、自动续费及账户、个人客户、OpenAPI 发起的停复机保持原写法。
- **Owner**:审计与安全负责人
- **最后验证日期**:2026-08-07
- **最后验证日期**:2026-10-24
- **更新触发条件**:日志字段、保留策略或外部集成变化
## ENG-SECRET-001

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package bootstrap
import (
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
@@ -26,6 +27,7 @@ type Dependencies struct {
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选)
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer

View File

@@ -21,6 +21,7 @@ import (
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
@@ -120,6 +121,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger)
gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil {
@@ -329,6 +331,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
),
WeComApprovalCallback: wecomApprovalCallback,
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
GatewayRateLimit: admin.NewGatewayRateLimitHandler(pollingSvcPkg.NewGatewayRateLimitService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), deps.GatewayLimit), deps.Redis, validate),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),

View File

@@ -263,6 +263,9 @@ func initServices(s *stores, deps *Dependencies) *services {
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
iotCard.SetRealnameActivator(packageActivation)
@@ -293,6 +296,9 @@ func initServices(s *stores, deps *Dependencies) *services {
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
if deps.GatewayLimit != nil {
device.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
}
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
shopCommission.SetAuditWriter(auditWriter)

View File

@@ -64,6 +64,7 @@ type Handlers struct {
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
GatewayRateLimit *admin.GatewayRateLimitHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
@@ -16,10 +17,11 @@ type WorkerDependencies struct {
DB *gorm.DB
Redis *redis.Client
Logger *zap.Logger
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选)
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选)
GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选)
}
// WorkerBootstrapResult Worker Bootstrap 初始化结果

View File

@@ -187,6 +187,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。

View File

@@ -0,0 +1,142 @@
package gateway
import (
"context"
stderrors "errors"
"time"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// GatewayError 是网关调用失败的结构化错误。
// 它包裹原有的 *errors.AppError:Error() 文本与错误码保持不变,调用方仍可 errors.As 取 AppError;
// 需要类别、上游业务码、上游信息与耗时的调用方用 errors.As 取 *GatewayError。
type GatewayError struct {
Category string // 错误类别,见 constants.GatewayError*
ProviderCode int // 网关业务码(仅上游业务错误时非 0)
ProviderMessage string // 网关返回信息(已截断,仅上游业务错误时非空)
HTTPStatus int // HTTP 状态码(仅 HTTP 异常时非 0)
Duration time.Duration // 本次 HTTP 尝试耗时
app *errors.AppError
}
// Error 返回与原 AppError 一致的文本。
func (e *GatewayError) Error() string {
if e == nil || e.app == nil {
return "Gateway 调用失败"
}
return e.app.Error()
}
// Unwrap 返回原 AppError,保持错误码判断兼容。
func (e *GatewayError) Unwrap() error {
if e == nil || e.app == nil {
return nil
}
return e.app
}
func newGatewayError(category string, app *errors.AppError) *GatewayError {
return &GatewayError{Category: category, app: app}
}
// AsGatewayError 从错误链中取出结构化网关错误。
func AsGatewayError(err error) (*GatewayError, bool) {
var gwErr *GatewayError
if stderrors.As(err, &gwErr) && gwErr != nil {
return gwErr, true
}
return nil, false
}
// IsRateLimited 判断错误是否为本系统运营商账户限流拒绝(未发出请求)。
func IsRateLimited(err error) bool {
gwErr, ok := AsGatewayError(err)
return ok && gwErr.Category == constants.GatewayErrorRateLimited
}
// NewRateLimitedError 创建限流拒绝错误;由限流守卫在拿不到名额时返回。
func NewRateLimitedError() error {
return newGatewayError(constants.GatewayErrorRateLimited,
errors.New(errors.CodeTooManyRequests, "运营商账户网关调用已达限流上限,请稍后重试"))
}
// truncateProviderMessage 按字符截断上游信息,避免超长内容落库或展示。
func truncateProviderMessage(msg string) string {
if utf8.RuneCountInString(msg) <= constants.GatewayProviderMessageMaxLen {
return msg
}
runes := []rune(msg)
return string(runes[:constants.GatewayProviderMessageMaxLen])
}
// CallInfo 描述一次卡网关 HTTP 尝试,供调用守卫限流与统计。
type CallInfo struct {
Operation string // 卡网关操作,见 constants.GatewayOp*
CardNo string // 卡号(ICCID)
CarrierID uint // 调用方已知的运营商账户 ID(本系统 tb_carrier.id),0 表示由守卫按卡号解析
ExemptSource string // 豁免限流来源,空表示受限流约束
Attempt int // 本次方法调用内的尝试序号,从 1 开始
}
// CallGuard 是卡网关调用守卫:每次 HTTP 尝试前取限流名额,尝试结束后记录统计。
// BeforeAttempt 返回错误时本次方法调用立即结束,不再发起后续尝试。
type CallGuard interface {
BeforeAttempt(ctx context.Context, call CallInfo) error
AfterAttempt(ctx context.Context, call CallInfo, callErr error, duration time.Duration)
}
type cardCallKey struct{}
type carrierKey struct{}
type exemptKey struct{}
type cardCall struct {
operation string
cardNo string
}
// WithCarrier 声明本次调用目标卡所属的运营商账户(本系统 tb_carrier.id),避免守卫按卡号再查一次。
func WithCarrier(ctx context.Context, carrierID uint) context.Context {
if carrierID == 0 {
return ctx
}
return context.WithValue(ctx, carrierKey{}, carrierID)
}
// WithRateLimitExempt 声明本次调用豁免运营商账户限流,只允许后台人工点击、通道阈值、自动续费、手动刷新使用。
func WithRateLimitExempt(ctx context.Context, source string) context.Context {
if source == "" {
return ctx
}
return context.WithValue(ctx, exemptKey{}, source)
}
// RateLimitExemptSource 返回 ctx 中声明的豁免来源,未声明返回空串。
func RateLimitExemptSource(ctx context.Context) string {
source, _ := ctx.Value(exemptKey{}).(string)
return source
}
func withCardCall(ctx context.Context, operation, cardNo string) context.Context {
return context.WithValue(ctx, cardCallKey{}, cardCall{operation: operation, cardNo: cardNo})
}
// cardCallInfo 从 ctx 取出本次卡网关调用信息;非卡操作返回 false。
func cardCallInfo(ctx context.Context) (CallInfo, bool) {
call, ok := ctx.Value(cardCallKey{}).(cardCall)
if !ok {
return CallInfo{}, false
}
carrierID, _ := ctx.Value(carrierKey{}).(uint)
return CallInfo{
Operation: call.operation, CardNo: call.cardNo, CarrierID: carrierID,
ExemptSource: RateLimitExemptSource(ctx),
}, true
}
// SetCallGuard 设置卡网关调用守卫;为 nil 时不限流、不统计。
func (c *Client) SetCallGuard(guard CallGuard) {
c.guard = guard
}

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
"go.uber.org/zap"
@@ -36,6 +37,7 @@ type Client struct {
timeout time.Duration
logger *zap.Logger
maxRetries int
guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效
}
type attemptObserverKey struct{}
@@ -138,6 +140,7 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
call, isCardCall := cardCallInfo(ctx)
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// 检查用户 Context 是否已取消
@@ -158,12 +161,30 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
}
attemptNumber := attempt + 1
// 卡网关操作每次 HTTP 尝试前各取一次限流名额,内部重试同样计量;拿不到名额时本次调用立即结束。
if isCardCall && c.guard != nil {
call.Attempt = attemptNumber
if err := c.guard.BeforeAttempt(ctx, call); err != nil {
if attempt > 0 {
c.logger.Warn("Gateway 重试被限流中止", zap.String("path", path), zap.Int("attempt", attemptNumber), zap.NamedError("last_error", lastErr))
}
return nil, err
}
}
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
attemptStartedAt := time.Now()
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
attemptDuration := time.Since(attemptStartedAt)
if gwErr, ok := AsGatewayError(err); ok {
gwErr.Duration = attemptDuration
}
if isCardCall && c.guard != nil {
c.guard.AfterAttempt(ctx, call, err, attemptDuration)
}
if observer != nil {
if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil {
return nil, observeErr
@@ -238,30 +259,32 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD
if err != nil {
// 用户 Context 已取消 — 不可重试
if ctx.Err() != nil {
return nil, false, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
return nil, false, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消"))
}
// Client 超时 — 可重试
if reqCtx.Err() == context.DeadlineExceeded {
return nil, true, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
return nil, true, newGatewayError(constants.GatewayErrorTimeout, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时"))
}
// 其他网络错误(连接失败、DNS 解析等)— 可重试
return nil, true, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
return nil, true, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败"))
}
defer resp.Body.Close()
// HTTP 状态码错误 — 不可重试
if resp.StatusCode != http.StatusOK {
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
gwErr := newGatewayError(constants.GatewayErrorHTTP, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode)))
gwErr.HTTPStatus = resp.StatusCode
return nil, false, gwErr
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败"))
}
var gatewayResp GatewayResponse
if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败"))
}
// Gateway 业务错误 — 不可重试
@@ -271,7 +294,10 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD
zap.Int("gateway_code", gatewayResp.Code),
zap.String("gateway_msg", gatewayResp.Msg),
)
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
gwErr := newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg)))
gwErr.ProviderCode = gatewayResp.Code
gwErr.ProviderMessage = truncateProviderMessage(gatewayResp.Msg)
return nil, false, gwErr
}
return gatewayResp.Data, false, nil

View File

@@ -4,41 +4,74 @@ package gateway
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// QueryCardStatus 查询流量卡状态
// POST /flow-card/status
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryStatus, cardNoOf(req))
return doRequestWithResponse[CardStatusResp](c, ctx, "/flow-card/status", req)
}
// QueryFlow 查询流量使用情况
// POST /flow-card/flow
func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryFlow, flowCardNoOf(req))
return doRequestWithResponse[FlowUsageResp](c, ctx, "/flow-card/flow", req)
}
// QueryRealnameStatus 查询实名认证状态
// POST /flow-card/realName
func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryRealname, cardNoOf(req))
return doRequestWithResponse[RealnameStatusResp](c, ctx, "/flow-card/realName", req)
}
// StopCard 流量卡停机
// POST /flow-card/cardStop
// 停机是外部状态写入:超时或连接失败时上游可能已受理,内部重试会对同一张卡连发并撞上游单卡频率限制,故只发一次请求。
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
_, err := c.doRequest(ctx, "/flow-card/cardStop", req)
ctx = withCardCall(ctx, constants.GatewayOpStop, operationCardNoOf(req))
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/cardStop", req)
return err
}
// StartCard 流量卡复机
// POST /flow-card/cardStart
// 复机同停机,只发一次请求,不使用内部重试。
func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error {
_, err := c.doRequest(ctx, "/flow-card/cardStart", req)
ctx = withCardCall(ctx, constants.GatewayOpStart, operationCardNoOf(req))
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/cardStart", req)
return err
}
func cardNoOf(req *CardStatusReq) string {
if req == nil {
return ""
}
return req.CardNo
}
func flowCardNoOf(req *FlowQueryReq) string {
if req == nil {
return ""
}
return req.CardNo
}
func operationCardNoOf(req *CardOperationReq) string {
if req == nil {
return ""
}
return req.CardNo
}
// GetRealnameLink 获取实名认证跳转链接
// POST /flow-card/RealNameVerification
func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -447,7 +448,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error {
switch asset.AssetType {
case "device":
result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID)
result, devErr := h.deviceService.StopDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID)
if devErr != nil {
return devErr
}
@@ -456,7 +457,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error {
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机")
}
if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil {
if cardErr := h.iotCardStopResume.ManualStopCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)
@@ -475,7 +476,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error {
switch asset.AssetType {
case "device":
if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil {
if devErr := h.deviceService.StartDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID); devErr != nil {
return devErr
}
return response.Success(c, nil)
@@ -483,7 +484,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error {
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机")
}
if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil {
if cardErr := h.iotCardStopResume.ManualStartCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)

View File

@@ -0,0 +1,343 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingService "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// GatewayRateLimitHandler 运营商账户网关限流、上游错误分类、停复机记录与调用统计 Handler。
type GatewayRateLimitHandler struct {
service *pollingService.GatewayRateLimitService
redis *redis.Client
validator *validator.Validate
}
// NewGatewayRateLimitHandler 创建网关限流 Handler。
func NewGatewayRateLimitHandler(service *pollingService.GatewayRateLimitService, redisClient *redis.Client, validate *validator.Validate) *GatewayRateLimitHandler {
if validate == nil {
validate = validator.New()
}
return &GatewayRateLimitHandler{service: service, redis: redisClient, validator: validate}
}
func (h *GatewayRateLimitHandler) validate(prefix string, req any) error {
if err := h.validator.Struct(req); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err))
}
return nil
}
func rateLimitPathID(c *fiber.Ctx, name, label string) (uint, error) {
value, err := strconv.ParseUint(c.Params(name), 10, 64)
if err != nil || value == 0 {
return 0, errors.New(errors.CodeInvalidParam, "无效的"+label)
}
return uint(value), nil
}
// ListGroups 查询网关限流组列表。
// GET /api/admin/polling-rate-limit-groups
func (h *GatewayRateLimitHandler) ListGroups(c *fiber.Ctx) error {
var req dto.RateLimitGroupListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关限流组查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListGroups(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetGroup 查询网关限流组详情。
// GET /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) GetGroup(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
result, err := h.service.GetGroup(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateGroup 新建网关限流组。
// POST /api/admin/polling-rate-limit-groups
func (h *GatewayRateLimitHandler) CreateGroup(c *fiber.Ctx) error {
var req dto.SaveRateLimitGroupRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关限流组参数不合法", &req); err != nil {
return err
}
result, err := h.service.CreateGroup(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateGroup 修改网关限流组。
// PUT /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) UpdateGroup(c *fiber.Ctx) error {
var req dto.SaveRateLimitGroupRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("网关限流组参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateGroup(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateGroupStatus 启停网关限流组。
// PUT /api/admin/polling-rate-limit-groups/:id/status
func (h *GatewayRateLimitHandler) UpdateGroupStatus(c *fiber.Ctx) error {
var req dto.UpdateRateLimitGroupStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("网关限流组状态参数不合法", &req); err != nil {
return err
}
if err := h.service.UpdateGroupStatus(c.UserContext(), req.ID, *req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteGroup 删除网关限流组。
// DELETE /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) DeleteGroup(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
if err := h.service.DeleteGroup(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// ListRules 查询上游错误分类规则列表。
// GET /api/admin/gateway-error-classifications
func (h *GatewayRateLimitHandler) ListRules(c *fiber.Ctx) error {
var req dto.GatewayErrorRuleListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类规则查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListRules(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// CreateRule 新建上游错误分类规则。
// POST /api/admin/gateway-error-classifications
func (h *GatewayRateLimitHandler) CreateRule(c *fiber.Ctx) error {
var req dto.SaveGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类规则参数不合法", &req); err != nil {
return err
}
result, err := h.service.CreateRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRule 修改上游错误分类规则。
// PUT /api/admin/gateway-error-classifications/:id
func (h *GatewayRateLimitHandler) UpdateRule(c *fiber.Ctx) error {
var req dto.SaveGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("上游错误分类规则参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRuleStatus 启停上游错误分类规则。
// PUT /api/admin/gateway-error-classifications/:id/status
func (h *GatewayRateLimitHandler) UpdateRuleStatus(c *fiber.Ctx) error {
var req dto.UpdateGatewayErrorRuleStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("上游错误分类规则状态参数不合法", &req); err != nil {
return err
}
if err := h.service.UpdateRuleStatus(c.UserContext(), req.ID, *req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteRule 删除上游错误分类规则。
// DELETE /api/admin/gateway-error-classifications/:id
func (h *GatewayRateLimitHandler) DeleteRule(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
if err := h.service.DeleteRule(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// TestRule 用已启用规则(可附带草稿)对一段上游返回信息分类,不写任何数据。
// POST /api/admin/gateway-error-classifications/test
func (h *GatewayRateLimitHandler) TestRule(c *fiber.Ctx) error {
var req dto.TestGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类测试参数不合法", &req); err != nil {
return err
}
result, err := h.service.TestRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListStopResumeRecords 查询停复机记录。
// GET /api/admin/card-stop-resume-records
func (h *GatewayRateLimitHandler) ListStopResumeRecords(c *fiber.Ctx) error {
var req dto.StopResumeRecordListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式")
}
if err := h.validate("停复机记录查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListStopResumeRecords(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListCardStopResumeRecords 查询单卡停复机历史。
// GET /api/admin/iot-cards/:id/stop-resume-records
func (h *GatewayRateLimitHandler) ListCardStopResumeRecords(c *fiber.Ctx) error {
var req dto.CardStopResumeRecordListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式")
}
id, err := rateLimitPathID(c, "id", "IoT 卡 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("单卡停复机记录查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListCardStopResumeRecords(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// CarrierCallStats 按运营商账户汇总最近若干分钟的网关调用。
// GET /api/admin/polling-stats/carriers
func (h *GatewayRateLimitHandler) CarrierCallStats(c *fiber.Ctx) error {
var req dto.GatewayCallStatsRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关调用统计参数不合法", &req); err != nil {
return err
}
result, err := h.service.CarrierCallStats(c.UserContext(), h.redis, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// CarrierCallTrend 查询单个运营商账户的网关调用趋势。
// GET /api/admin/polling-stats/carriers/:carrier_id/trend
func (h *GatewayRateLimitHandler) CarrierCallTrend(c *fiber.Ctx) error {
var req dto.GatewayCallTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
carrierID, err := strconv.ParseUint(c.Params("carrier_id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的运营商账户 ID")
}
req.CarrierID = uint(carrierID)
if err := h.validate("网关调用趋势参数不合法", &req); err != nil {
return err
}
result, err := h.service.CarrierCallTrend(c.UserContext(), h.redis, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// PollingOptions 返回轮询与网关限流相关枚举。
// GET /api/admin/polling-options
func (h *GatewayRateLimitHandler) PollingOptions(c *fiber.Ctx) error {
result, err := h.service.PollingOptions(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -5,7 +5,9 @@ import (
"context"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
"github.com/break/junhong_cmp_fiber/internal/gateway"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -35,7 +37,7 @@ func (c *CardCommander) ResumeAssetForAutoRenewal(ctx context.Context, assetType
if c == nil || c.service == nil {
return assetAutoRenewalApp.ResumeOutcome{}, errCommanderUnavailable()
}
outcome, err := c.service.ResumeAssetForAutoRenewal(ctx, assetType, assetID)
outcome, err := c.service.ResumeAssetForAutoRenewal(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptAutoRenewal), assetType, assetID)
return assetAutoRenewalApp.ResumeOutcome{
Applied: outcome.Applied, IntegrationID: outcome.IntegrationID,
Result: outcome.Result, SafeReason: outcome.SafeReason,
@@ -47,7 +49,7 @@ func (c *CardCommander) QueryAutoRenewalResumeState(ctx context.Context, assetTy
if c == nil || c.service == nil {
return false, false, "", errCommanderUnavailable()
}
return c.service.QueryAutoRenewalResumeState(ctx, assetType, assetID)
return c.service.QueryAutoRenewalResumeState(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptAutoRenewal), assetType, assetID)
}
// errCommanderUnavailable 返回复机执行端口未配置的稳定错误。

View File

@@ -259,6 +259,14 @@ func NewRegistry() *Registry {
pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigStatusUpdated := pollingAction(constants.AuditActionPollingConfigStatusUpdated, "更新轮询配置状态", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
rateLimitGroupCreated := pollingAction(constants.AuditActionRateLimitGroupCreated, "创建网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh)
rateLimitGroupUpdated := pollingAction(constants.AuditActionRateLimitGroupUpdated, "更新网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh)
rateLimitGroupStatusUpdated := pollingAction(constants.AuditActionRateLimitGroupStatusUpdated, "更新网关限流组状态", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh)
rateLimitGroupDeleted := pollingAction(constants.AuditActionRateLimitGroupDeleted, "删除网关限流组", constants.AuditResourceRateLimitGroup, constants.AuditRiskHigh)
gatewayErrorRuleCreated := pollingAction(constants.AuditActionGatewayErrorRuleCreated, "创建上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
gatewayErrorRuleUpdated := pollingAction(constants.AuditActionGatewayErrorRuleUpdated, "更新上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
gatewayErrorRuleStatusUpdated := pollingAction(constants.AuditActionGatewayErrorRuleStatusUpdated, "更新上游错误分类规则状态", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
gatewayErrorRuleDeleted := pollingAction(constants.AuditActionGatewayErrorRuleDeleted, "删除上游错误分类规则", constants.AuditResourceGatewayErrorRule, constants.AuditRiskNormal)
pollingConcurrencyUpdated := pollingAction(constants.AuditActionPollingConcurrencyUpdated, "更新轮询并发配置", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingConcurrencyReset := pollingAction(constants.AuditActionPollingConcurrencyReset, "重置轮询并发计数", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingAlertRuleCreated := pollingAction(constants.AuditActionPollingAlertRuleCreated, "创建轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
@@ -650,6 +658,14 @@ func NewRegistry() *Registry {
constants.AuditActionPollingConfigUpdated: pollingConfigUpdated,
constants.AuditActionPollingConfigDeleted: pollingConfigDeleted,
constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated,
constants.AuditActionRateLimitGroupCreated: rateLimitGroupCreated,
constants.AuditActionRateLimitGroupUpdated: rateLimitGroupUpdated,
constants.AuditActionRateLimitGroupStatusUpdated: rateLimitGroupStatusUpdated,
constants.AuditActionRateLimitGroupDeleted: rateLimitGroupDeleted,
constants.AuditActionGatewayErrorRuleCreated: gatewayErrorRuleCreated,
constants.AuditActionGatewayErrorRuleUpdated: gatewayErrorRuleUpdated,
constants.AuditActionGatewayErrorRuleStatusUpdated: gatewayErrorRuleStatusUpdated,
constants.AuditActionGatewayErrorRuleDeleted: gatewayErrorRuleDeleted,
constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated,
constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset,
constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated,
@@ -930,6 +946,14 @@ func NewRegistry() *Registry {
Type: constants.AuditResourcePollingConcurrencyConfig, Name: "轮询并发配置",
IdentityFields: []string{"id", "task_type", "max_concurrency"},
},
constants.AuditResourceRateLimitGroup: {
Type: constants.AuditResourceRateLimitGroup, Name: "网关限流组",
IdentityFields: []string{"id", "carrier_id", "group_code", "group_name", "max_qps", "reserved_qps", "status"},
},
constants.AuditResourceGatewayErrorRule: {
Type: constants.AuditResourceGatewayErrorRule, Name: "上游错误分类规则",
IdentityFields: []string{"id", "keyword", "match_type", "category", "priority", "status"},
},
constants.AuditResourcePollingAlertRule: {
Type: constants.AuditResourcePollingAlertRule, Name: "轮询告警规则",
IdentityFields: []string{"id", "rule_name", "task_type", "metric_type", "operator", "threshold", "alert_level", "status"},

View File

@@ -9,7 +9,9 @@ import (
carrierthresholdapp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/gateway"
iotcardsvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -34,7 +36,7 @@ func (c *CardCommander) StopCardForThreshold(ctx context.Context, cardID uint) (
if c == nil || c.service == nil {
return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停机执行能力未配置")
}
return c.service.StopCardForChannelThreshold(ctx, cardID)
return c.service.StopCardForChannelThreshold(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID)
}
// ResumeCardForThreshold 执行通道阈值新周期复机。
@@ -42,7 +44,7 @@ func (c *CardCommander) ResumeCardForThreshold(ctx context.Context, cardID uint)
if c == nil || c.service == nil {
return carrierthresholddomain.CommandOutcome{}, errors.New(errors.CodeServiceUnavailable, "通道流量阈值复机执行能力未配置")
}
return c.service.ResumeCardForChannelThreshold(ctx, cardID)
return c.service.ResumeCardForChannelThreshold(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID)
}
// ResumeReady 判断解锁后的卡是否满足自动复机条件。
@@ -58,7 +60,7 @@ func (c *CardCommander) CardNetworkStatus(ctx context.Context, cardID uint) (int
if c == nil || c.service == nil {
return 0, false, "", errors.New(errors.CodeServiceUnavailable, "通道流量阈值状态查询能力未配置")
}
return c.service.QueryChannelThresholdCardStatus(ctx, cardID)
return c.service.QueryChannelThresholdCardStatus(gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptChannelThreshold), cardID)
}
// ConfirmCardState 按已确认的运营商结果补写卡状态。

View File

@@ -0,0 +1,139 @@
package gatewaylimit
import (
"context"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ClassificationLoader 读取全部启用的错误分类规则(按优先级排序)。
type ClassificationLoader interface {
ListEnabled(ctx context.Context) ([]model.GatewayErrorClassification, error)
}
// Classification 是一次上游错误的分类结果。
type Classification struct {
Category string // 见 constants.GatewayClass*
RuleID uint // 命中的规则 ID,未命中为 0
Keyword string // 命中的关键字
MaybeAccepted bool // 上游可能已受理
}
type compiledRule struct {
row model.GatewayErrorClassification
ops map[string]bool
regex *regexp.Regexp
}
// Classifier 按后台维护的关键字规则对上游错误分类;规则在进程内缓存,超过 TTL 重新加载。
type Classifier struct {
loader ClassificationLoader
logger *zap.Logger
mu sync.Mutex
rules []compiledRule
loaded time.Time
}
// NewClassifier 创建错误分类器;loader 为 nil 时一律归为业务错误。
func NewClassifier(loader ClassificationLoader, logger *zap.Logger) *Classifier {
if logger == nil {
logger = zap.NewNop()
}
return &Classifier{loader: loader, logger: logger}
}
// Invalidate 使规则缓存立即失效,后台保存规则后调用。
func (c *Classifier) Invalidate() {
if c == nil {
return
}
c.mu.Lock()
c.loaded = time.Time{}
c.mu.Unlock()
}
// Classify 对某操作的上游返回信息分类;未命中任何启用规则时归为业务错误。
func (c *Classifier) Classify(ctx context.Context, op, message string) Classification {
if c == nil {
return Classification{Category: constants.GatewayClassBusiness}
}
return MatchRules(c.current(ctx), op, message)
}
// ClassifyWith 用给定规则列表分类,供测试接口在不写数据的前提下验证规则。
func ClassifyWith(rows []model.GatewayErrorClassification, op, message string) Classification {
return MatchRules(compileRules(rows, zap.NewNop()), op, message)
}
// MatchRules 按优先级顺序返回第一个命中的规则分类。
func MatchRules(rules []compiledRule, op, message string) Classification {
for _, rule := range rules {
if len(rule.ops) > 0 && !rule.ops[op] {
continue
}
if !ruleMatches(rule, message) {
continue
}
return Classification{Category: rule.row.Category, RuleID: rule.row.ID, Keyword: rule.row.Keyword, MaybeAccepted: rule.row.MaybeAccepted}
}
return Classification{Category: constants.GatewayClassBusiness}
}
func ruleMatches(rule compiledRule, message string) bool {
switch rule.row.MatchType {
case constants.GatewayMatchPrefix:
return strings.HasPrefix(message, rule.row.Keyword)
case constants.GatewayMatchRegex:
return rule.regex != nil && rule.regex.MatchString(message)
default:
return strings.Contains(message, rule.row.Keyword)
}
}
func (c *Classifier) current(ctx context.Context) []compiledRule {
c.mu.Lock()
defer c.mu.Unlock()
if c.loader == nil || time.Since(c.loaded) < constants.GatewayRateLimitConfigTTL {
return c.rules
}
rows, err := c.loader.ListEnabled(ctx)
c.loaded = time.Now()
if err != nil {
c.logger.Warn("加载上游错误分类规则失败,沿用上一次规则", zap.Error(err))
return c.rules
}
c.rules = compileRules(rows, c.logger)
return c.rules
}
func compileRules(rows []model.GatewayErrorClassification, logger *zap.Logger) []compiledRule {
rules := make([]compiledRule, 0, len(rows))
for _, row := range rows {
rule := compiledRule{row: row, ops: map[string]bool{}}
ops, err := DecodeOperations(row.Operations)
if err != nil {
logger.Warn("错误分类规则适用操作非法,已跳过", zap.Uint("rule_id", row.ID))
continue
}
for _, op := range ops {
rule.ops[op] = true
}
if row.MatchType == constants.GatewayMatchRegex {
re, err := regexp.Compile(row.Keyword)
if err != nil {
logger.Warn("错误分类规则正则非法,已跳过", zap.Uint("rule_id", row.ID))
continue
}
rule.regex = re
}
rules = append(rules, rule)
}
return rules
}

View File

@@ -0,0 +1,130 @@
package gatewaylimit
import (
"context"
"sync"
"time"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// defaultGroupCode 是未被任何启用限流组覆盖时使用的默认组标识(每个账户每个操作独立窗口)。
const defaultGroupCode = "default"
// GroupRule 是一个限流组对某个操作生效的规则快照。
type GroupRule struct {
GroupCode string
MaxQPS int
ReservedQPS int
HighPriority bool // 该操作在组内是否为高优先级
}
// groupIndex 是启用限流组按"账户 → 操作"建立的索引。
type groupIndex struct {
byCarrier map[uint]map[string]GroupRule
global map[string]GroupRule
}
// GroupLoader 读取全部启用的限流组。
type GroupLoader interface {
ListEnabled(ctx context.Context) ([]model.CarrierRateLimitGroup, error)
}
// GroupProvider 在进程内缓存启用限流组,超过 TTL 重新加载,后台修改数秒内生效。
type GroupProvider struct {
loader GroupLoader
logger *zap.Logger
mu sync.Mutex
index groupIndex
loaded time.Time
}
// NewGroupProvider 创建限流组提供者;loader 为 nil 时所有操作使用默认 QPS。
func NewGroupProvider(loader GroupLoader, logger *zap.Logger) *GroupProvider {
if logger == nil {
logger = zap.NewNop()
}
return &GroupProvider{loader: loader, logger: logger, index: groupIndex{byCarrier: map[uint]map[string]GroupRule{}, global: map[string]GroupRule{}}}
}
// Invalidate 使缓存立即失效,后台保存限流组后调用。
func (p *GroupProvider) Invalidate() {
if p == nil {
return
}
p.mu.Lock()
p.loaded = time.Time{}
p.mu.Unlock()
}
// Resolve 返回某账户某操作的账户组规则(未覆盖时 ok=false)与全局组规则(未配置时 ok=false)。
func (p *GroupProvider) Resolve(ctx context.Context, carrierID uint, op string) (account GroupRule, accountOK bool, global GroupRule, globalOK bool) {
index := p.current(ctx)
if rules, ok := index.byCarrier[carrierID]; ok {
account, accountOK = rules[op]
}
global, globalOK = index.global[op]
return
}
func (p *GroupProvider) current(ctx context.Context) groupIndex {
if p == nil {
return groupIndex{}
}
p.mu.Lock()
defer p.mu.Unlock()
if p.loader == nil || time.Since(p.loaded) < constants.GatewayRateLimitConfigTTL {
return p.index
}
rows, err := p.loader.ListEnabled(ctx)
p.loaded = time.Now()
if err != nil {
p.logger.Warn("加载网关限流组失败,沿用上一次配置", zap.Error(err))
return p.index
}
p.index = buildGroupIndex(rows, p.logger)
return p.index
}
func buildGroupIndex(rows []model.CarrierRateLimitGroup, logger *zap.Logger) groupIndex {
index := groupIndex{byCarrier: map[uint]map[string]GroupRule{}, global: map[string]GroupRule{}}
for _, row := range rows {
ops, err := DecodeOperations(row.Operations)
if err != nil {
logger.Warn("限流组操作列表非法,已跳过该组", zap.Uint("group_id", row.ID), zap.Error(err))
continue
}
highOps, _ := DecodeOperations(row.HighPriorityOperations)
high := map[string]bool{}
for _, op := range highOps {
high[op] = true
}
target := index.global
if row.CarrierID != nil {
if index.byCarrier[*row.CarrierID] == nil {
index.byCarrier[*row.CarrierID] = map[string]GroupRule{}
}
target = index.byCarrier[*row.CarrierID]
}
for _, op := range ops {
target[op] = GroupRule{GroupCode: row.GroupCode, MaxQPS: row.MaxQPS, ReservedQPS: row.ReservedQPS, HighPriority: high[op]}
}
}
return index
}
// DecodeOperations 解析 JSON 字符串数组形式的操作列表。
func DecodeOperations(raw []byte) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
var ops []string
if err := sonic.Unmarshal(raw, &ops); err != nil {
return nil, err
}
return ops, nil
}

View File

@@ -0,0 +1,169 @@
package gatewaylimit
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 分钟计数 Hash 的字段名。
const (
statFieldWouldLimit = "would_limit"
statFieldDurationSum = "duration_ms_sum"
statFieldAttempts = "attempts"
statPrefixExempt = "exempt:"
statPrefixUpstream = "upstream:"
statPrefixDuration = "duration_le:"
)
// durationBuckets 是耗时分桶上界(毫秒),用于近似计算 p95。
var durationBuckets = []int64{100, 300, 500, 1000, 3000, 10000, 60000}
// Guard 实现 gateway.CallGuard:每次卡网关 HTTP 尝试前按运营商账户限流,结束后写分钟计数与卡最近错误。
type Guard struct {
limiter *Limiter
resolver *CarrierResolver
classifier *Classifier
redis *redis.Client
logger *zap.Logger
}
// NewGuard 创建卡网关调用守卫。
func NewGuard(limiter *Limiter, resolver *CarrierResolver, classifier *Classifier, redisClient *redis.Client, logger *zap.Logger) *Guard {
if logger == nil {
logger = zap.NewNop()
}
return &Guard{limiter: limiter, resolver: resolver, classifier: classifier, redis: redisClient, logger: logger}
}
var _ gateway.CallGuard = (*Guard)(nil)
// BeforeAttempt 取限流名额;豁免调用直接放行。拦截模式下拿不到名额返回限流错误,本次方法调用随即结束。
func (g *Guard) BeforeAttempt(ctx context.Context, call gateway.CallInfo) error {
if g == nil || g.limiter == nil || call.ExemptSource != "" {
return nil
}
carrierID := g.carrierOf(ctx, call)
decision, err := g.limiter.Check(ctx, carrierID, call.Operation)
if err != nil {
// 限流器故障时失败开放,避免 Redis 抖动阻断全部网关调用。
g.logger.Warn("网关限流判断失败,本次放行", zap.Uint("carrier_id", carrierID), zap.String("operation", call.Operation), zap.Error(err))
return nil
}
if decision.Exceed {
g.incr(ctx, carrierID, call.Operation, statFieldWouldLimit)
}
if !decision.Allowed {
g.incr(ctx, carrierID, call.Operation, constants.GatewayErrorRateLimited)
return gateway.NewRateLimitedError()
}
return nil
}
// AfterAttempt 写分钟计数(按结果类别与耗时分桶);查询失败时写卡该操作最近一次错误。
func (g *Guard) AfterAttempt(ctx context.Context, call gateway.CallInfo, callErr error, duration time.Duration) {
if g == nil || g.redis == nil {
return
}
ctx = context.WithoutCancel(ctx)
carrierID := g.carrierOf(ctx, call)
field := constants.GatewayResultSuccess
var classification Classification
gwErr, isGatewayErr := gateway.AsGatewayError(callErr)
if callErr != nil {
field = constants.GatewayErrorNetwork
if isGatewayErr {
field = gwErr.Category
if gwErr.Category == constants.GatewayErrorUpstream {
classification = g.classifier.Classify(ctx, call.Operation, gwErr.ProviderMessage)
field = statPrefixUpstream + classification.Category
}
}
}
key := constants.RedisGatewayCallStatKey(carrierID, call.Operation, statMinute(time.Now()))
durationMS := duration.Milliseconds()
pipe := g.redis.Pipeline()
pipe.HIncrBy(ctx, key, field, 1)
pipe.HIncrBy(ctx, key, statFieldAttempts, 1)
pipe.HIncrBy(ctx, key, statFieldDurationSum, durationMS)
pipe.HIncrBy(ctx, key, statPrefixDuration+durationBucket(durationMS), 1)
if call.ExemptSource != "" {
pipe.HIncrBy(ctx, key, statPrefixExempt+call.ExemptSource, 1)
}
pipe.Expire(ctx, key, constants.GatewayCallStatTTL)
if callErr != nil && isQueryOperation(call.Operation) && call.CardNo != "" {
errKey := constants.RedisGatewayCardLastErrorKey(call.CardNo, call.Operation)
values := map[string]any{"category": field, "at": time.Now().Format(time.RFC3339)}
if isGatewayErr {
values["provider_code"] = strconv.Itoa(gwErr.ProviderCode)
values["provider_message"] = gwErr.ProviderMessage
}
pipe.HSet(ctx, errKey, values)
pipe.Expire(ctx, errKey, constants.GatewayCardLastErrorTTL)
}
if _, err := pipe.Exec(ctx); err != nil {
g.logger.Debug("写网关调用统计失败", zap.Error(err))
}
}
// Classify 暴露分类器供停复机记录等调用方对上游错误分类。
func (g *Guard) Classify(ctx context.Context, op, message string) Classification {
if g == nil {
return Classification{Category: constants.GatewayClassBusiness}
}
return g.classifier.Classify(ctx, op, message)
}
func (g *Guard) carrierOf(ctx context.Context, call gateway.CallInfo) uint {
if call.CarrierID != 0 || g.resolver == nil {
return call.CarrierID
}
carrierID, err := g.resolver.Resolve(ctx, call.CardNo)
if err != nil {
g.logger.Debug("解析卡所属运营商账户失败,按未知账户计量", zap.String("iccid", call.CardNo), zap.Error(err))
}
return carrierID
}
func (g *Guard) incr(ctx context.Context, carrierID uint, op, field string) {
if g.redis == nil {
return
}
key := constants.RedisGatewayCallStatKey(carrierID, op, statMinute(time.Now()))
pipe := g.redis.Pipeline()
pipe.HIncrBy(ctx, key, field, 1)
pipe.Expire(ctx, key, constants.GatewayCallStatTTL)
_, _ = pipe.Exec(context.WithoutCancel(ctx))
}
func isQueryOperation(op string) bool {
return op == constants.GatewayOpQueryFlow || op == constants.GatewayOpQueryStatus || op == constants.GatewayOpQueryRealname
}
// statMinute 返回上海时区的分钟标识(yyyyMMddHHmm)。
func statMinute(t time.Time) string {
return t.In(shanghai()).Format("200601021504")
}
func durationBucket(ms int64) string {
for _, upper := range durationBuckets {
if ms <= upper {
return strconv.FormatInt(upper, 10)
}
}
return "inf"
}
func shanghai() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return location
}

View File

@@ -0,0 +1,191 @@
package gatewaylimit
import (
"context"
"crypto/rand"
"encoding/hex"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// slidingWindowScript 在一次原子执行内判断账户组窗口与可选的全局组窗口。
//
// KEYS[1] 账户组窗口 ZSET;KEYS[2] 账户组高优先级等待计数;KEYS[3] 全局组窗口 ZSET(无全局组时传空串占位)。
// ARGV:now_ms、window_ms、max_qps、reserved_qps、is_high(0/1)、global_max(0 表示无全局组)、member、dry_run(0/1)。
// 返回 {allowed(1/0), wait_ms}:dry_run=1(只统计)时总是记录本次请求,allowed=0 表示"本应被限流"。
const slidingWindowLua = `
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local maxQPS = tonumber(ARGV[3])
local reserved = tonumber(ARGV[4])
local isHigh = tonumber(ARGV[5])
local globalMax = tonumber(ARGV[6])
local member = ARGV[7]
local dryRun = tonumber(ARGV[8])
local useGlobal = globalMax > 0 and KEYS[3] ~= ''
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
if useGlobal then
redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now - window)
end
local limit = maxQPS
if isHigh == 0 then
limit = maxQPS - reserved
if reserved > 0 then
local waiting = tonumber(redis.call('GET', KEYS[2]) or '0')
if waiting > 0 then
limit = 0
end
end
end
local allowed = 1
local wait = 0
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
allowed = 0
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
if oldest[2] then
wait = tonumber(oldest[2]) + window - now
else
wait = window
end
end
if allowed == 1 and useGlobal then
local gcount = redis.call('ZCARD', KEYS[3])
if gcount >= globalMax then
allowed = 0
local goldest = redis.call('ZRANGE', KEYS[3], 0, 0, 'WITHSCORES')
if goldest[2] then
wait = tonumber(goldest[2]) + window - now
else
wait = window
end
end
end
if allowed == 1 or dryRun == 1 then
redis.call('ZADD', KEYS[1], now, member)
redis.call('PEXPIRE', KEYS[1], window * 2)
if useGlobal then
redis.call('ZADD', KEYS[3], now, member)
redis.call('PEXPIRE', KEYS[3], window * 2)
end
end
if wait < 1 and allowed == 0 then
wait = 1
end
return {allowed, wait}
`
var slidingWindowScript = redis.NewScript(slidingWindowLua)
// Decision 是一次限流判断的结果。
type Decision struct {
Allowed bool // 是否放行(只统计模式下恒为 true)
Exceed bool // 是否超过上限(只统计模式下表示"本应被限流")
Wait time.Duration // 拦截时建议的等待时长
}
// Limiter 是基于 Redis 滑动窗口的运营商账户限流器,跨全部进程共享。
type Limiter struct {
redis *redis.Client
groups *GroupProvider
settings *SettingsProvider
}
// NewLimiter 创建限流器。
func NewLimiter(redisClient *redis.Client, groups *GroupProvider, settings *SettingsProvider) *Limiter {
return &Limiter{redis: redisClient, groups: groups, settings: settings}
}
// Check 立即判断一次请求能否放行,不等待。拦截模式下超过上限返回 Allowed=false;
// 只统计模式下总是放行,Exceed 表示本应被限流。Redis 故障时放行(失败开放),避免限流故障阻断业务。
func (l *Limiter) Check(ctx context.Context, carrierID uint, op string) (Decision, error) {
settings := l.settings.Current(ctx)
account, accountOK, global, globalOK := l.groups.Resolve(ctx, carrierID, op)
if !accountOK {
account = GroupRule{GroupCode: defaultGroupCode + ":" + op, MaxQPS: settings.DefaultQPS}
}
globalKey, globalMax := "", 0
if globalOK {
globalKey, globalMax = constants.RedisGatewayGlobalQPSKey(global.GroupCode), global.MaxQPS
}
isHigh, dryRun := 0, 1
if account.HighPriority {
isHigh = 1
}
if settings.Enforce() {
dryRun = 0
}
now := time.Now().UnixMilli()
result, err := slidingWindowScript.Run(ctx, l.redis,
[]string{constants.RedisGatewayQPSKey(carrierID, account.GroupCode), constants.RedisGatewayQPSHighWaitingKey(carrierID, account.GroupCode), globalKey},
now, constants.GatewayRateLimitWindow.Milliseconds(), account.MaxQPS, account.ReservedQPS, isHigh, globalMax, windowMember(now), dryRun,
).Int64Slice()
if err != nil || len(result) != 2 {
return Decision{Allowed: true}, err
}
exceed := result[0] == 0
decision := Decision{Allowed: !exceed || dryRun == 1, Exceed: exceed}
if exceed && dryRun == 0 {
decision.Wait = time.Duration(result[1]) * time.Millisecond
}
return decision, nil
}
// Wait 在拦截模式下按建议时长等待名额,总等待不超过 maxWait;超时返回 false。
// 高优先级操作等待期间登记等待计数,使同组普通操作拿不到新名额。本 Change 中 Asynq 任务不使用该方法。
func (l *Limiter) Wait(ctx context.Context, carrierID uint, op string, maxWait time.Duration) (bool, error) {
deadline := time.Now().Add(maxWait)
registered := false
var waitingKey string
defer func() {
if registered {
l.redis.Decr(context.WithoutCancel(ctx), waitingKey)
}
}()
for {
decision, err := l.Check(ctx, carrierID, op)
if err != nil || decision.Allowed {
return true, err
}
if !registered {
account, accountOK, _, _ := l.groups.Resolve(ctx, carrierID, op)
if accountOK && account.HighPriority && account.ReservedQPS > 0 {
waitingKey = constants.RedisGatewayQPSHighWaitingKey(carrierID, account.GroupCode)
pipe := l.redis.TxPipeline()
pipe.Incr(ctx, waitingKey)
pipe.Expire(ctx, waitingKey, 10*time.Second)
if _, err := pipe.Exec(ctx); err == nil {
registered = true
}
}
}
sleep := decision.Wait
if remaining := time.Until(deadline); remaining <= 0 {
return false, nil
} else if sleep > remaining {
sleep = remaining
}
timer := time.NewTimer(sleep)
select {
case <-ctx.Done():
timer.Stop()
return false, ctx.Err()
case <-timer.C:
}
}
}
// windowMember 生成窗口内唯一成员,避免同毫秒请求互相覆盖。
func windowMember(nowMS int64) string {
buf := make([]byte, 6)
_, _ = rand.Read(buf)
return strconv.FormatInt(nowMS, 10) + "-" + hex.EncodeToString(buf)
}

View File

@@ -0,0 +1,61 @@
package gatewaylimit
import (
"context"
stderrors "errors"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
)
// partitionDropLookbackMonths 是每次清理向前检查的过期月数,覆盖维护任务停摆一段时间后的补删。
const partitionDropLookbackMonths = 12
// PartitionMaintainer 维护停复机记录按月分区:提前创建未来分区,按保留期整区删除过期分区。
type PartitionMaintainer struct {
store *postgres.CardStopResumeRecordStore
settings *SettingsProvider
logger *zap.Logger
}
// NewPartitionMaintainer 创建分区维护任务。
func NewPartitionMaintainer(store *postgres.CardStopResumeRecordStore, settings *SettingsProvider, logger *zap.Logger) *PartitionMaintainer {
if logger == nil {
logger = zap.NewNop()
}
return &PartitionMaintainer{store: store, settings: settings, logger: logger}
}
// Handle 是 Asynq 定时任务入口;任一分区操作失败时写错误告警并返回错误,由 Asynq 按既有规则重试。
func (m *PartitionMaintainer) Handle(ctx context.Context, _ *asynq.Task) error {
if m == nil || m.store == nil {
return stderrors.New("停复机记录分区维护未配置")
}
settings := m.settings.Current(ctx)
monthStart := currentMonthStart(time.Now())
var failed error
for i := 0; i <= settings.RecordPremakeMonth; i++ {
month := monthStart.AddDate(0, i, 0)
if err := m.store.EnsureMonthPartition(ctx, month); err != nil {
m.logger.Error("创建停复机记录分区失败,记录将落入默认分区", zap.String("month", month.Format("200601")), zap.Error(err))
failed = err
}
}
for k := 0; k < partitionDropLookbackMonths; k++ {
month := monthStart.AddDate(0, -(settings.RecordRetention + k), 0)
if err := m.store.DropMonthPartition(ctx, month); err != nil {
m.logger.Error("删除过期停复机记录分区失败", zap.String("month", month.Format("200601")), zap.Error(err))
failed = err
}
}
return failed
}
// currentMonthStart 返回上海时区当月 1 日 0 点。
func currentMonthStart(now time.Time) time.Time {
local := now.In(shanghai())
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, shanghai())
}

View File

@@ -0,0 +1,53 @@
package gatewaylimit
import (
"context"
"strconv"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// CarrierResolver 按 ICCID 解析卡所属的本系统运营商账户(tb_iot_card.carrier_id),结果缓存在 Redis。
type CarrierResolver struct {
db *gorm.DB
redis *redis.Client
}
// NewCarrierResolver 创建运营商账户解析器。
func NewCarrierResolver(db *gorm.DB, redisClient *redis.Client) *CarrierResolver {
return &CarrierResolver{db: db, redis: redisClient}
}
// Resolve 返回 ICCID 对应卡的运营商账户 ID;卡不存在时返回 0、nil,由调用方按未知账户处理。
func (r *CarrierResolver) Resolve(ctx context.Context, iccid string) (uint, error) {
if r == nil || iccid == "" {
return 0, nil
}
cacheKey := constants.RedisGatewayCardCarrierKey(iccid)
if r.redis != nil {
if raw, err := r.redis.Get(ctx, cacheKey).Result(); err == nil {
if value, parseErr := strconv.ParseUint(raw, 10, 64); parseErr == nil {
return uint(value), nil
}
}
}
if r.db == nil {
return 0, nil
}
var carrierIDs []uint
err := r.db.WithContext(ctx).Table("tb_iot_card").
Where("iccid = ? AND deleted_at IS NULL", iccid).Limit(1).Pluck("carrier_id", &carrierIDs).Error
if err != nil {
return 0, err
}
if len(carrierIDs) == 0 {
return 0, nil
}
if r.redis != nil {
_ = r.redis.Set(ctx, cacheKey, strconv.FormatUint(uint64(carrierIDs[0]), 10), constants.GatewayCardCarrierCacheTTL).Err()
}
return carrierIDs[0], nil
}

View File

@@ -0,0 +1,79 @@
package gatewaylimit
import (
"context"
"crypto/sha1"
"encoding/hex"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Runtime 汇总一个进程内的网关限流组件,API 与 Worker 各自创建一份,限流窗口经 Redis 跨进程共享。
type Runtime struct {
Settings *SettingsProvider
Groups *GroupProvider
Classifier *Classifier
Limiter *Limiter
Guard *Guard
Recorder *StopResumeRecorder
redisClient *redis.Client
logger *zap.Logger
}
// NewRuntime 创建网关限流运行组件;配置读取使用独立注册表,与后台系统配置共享同一份数据库与缓存数据。
func NewRuntime(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Runtime {
if logger == nil {
logger = zap.NewNop()
}
registry := systemconfig.NewRegistry()
RegisterConfigDefinitions(registry, logger)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
settings := NewSettingsProvider(reader, logger)
groups := NewGroupProvider(postgres.NewCarrierRateLimitGroupStore(db), logger)
classifier := NewClassifier(postgres.NewGatewayErrorClassificationStore(db), logger)
limiter := NewLimiter(redisClient, groups, settings)
guard := NewGuard(limiter, NewCarrierResolver(db, redisClient), classifier, redisClient, logger)
recorder := NewStopResumeRecorder(postgres.NewCardStopResumeRecordStore(db), classifier, logger)
return &Runtime{Settings: settings, Groups: groups, Classifier: classifier, Limiter: limiter, Guard: guard, Recorder: recorder, redisClient: redisClient, logger: logger}
}
// TrackDeferral 记录 Asynq 任务因限流被推迟;同一任务首次被限流起累计推迟超过阈值时写一次错误告警,任务继续重试。
// 任务以"类型 + 载荷摘要"标识,记录 1 天后自动过期。
func (r *Runtime) TrackDeferral(task *asynq.Task) {
if r == nil || r.redisClient == nil || task == nil {
return
}
ctx := context.Background()
sum := sha1.Sum(task.Payload())
taskKey := task.Type() + ":" + hex.EncodeToString(sum[:])
key := constants.RedisRateLimitDeferStartKey(taskKey)
now := time.Now()
if ok, err := r.redisClient.SetNX(ctx, key, now.Unix(), 24*time.Hour).Result(); err != nil || ok {
return
}
startedUnix, err := r.redisClient.Get(ctx, key).Int64()
if err != nil {
return
}
deferred := now.Sub(time.Unix(startedUnix, 0))
if deferred < r.Settings.Current(ctx).DeferAlert {
return
}
if alerted, _ := r.redisClient.SetNX(ctx, key+":alerted", 1, 24*time.Hour).Result(); alerted {
r.logger.Error("异步任务因运营商账户网关限流累计推迟超过告警阈值,任务继续重试",
zap.String("task_type", task.Type()), zap.String("task_key", taskKey), zap.Duration("deferred", deferred))
}
}

View File

@@ -0,0 +1,136 @@
// Package gatewaylimit 实现按运营商账户(本系统 tb_carrier)的卡网关调用限流、错误分类与调用统计。
//
// 账户口径:限流单位是本系统的运营商账户(卡上的 carrier_id),与网关客户端自身的 appId 无关。
// 调用方已知卡所属账户时用 gateway.WithCarrier 传入;否则守卫按 ICCID 查本系统卡表解析。
package gatewaylimit
import (
"context"
stderrors "errors"
"strconv"
"sync"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// RegisterConfigDefinitions 向受控系统配置注册表注册网关限流相关配置;已注册的 Key 跳过。
func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
minOne, minZero := int64(1), int64(0)
maxQPS, maxDelay, maxAlert, maxMonths := int64(10000), int64(3600), int64(86400*7), int64(120)
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigPollingRateLimitMode, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.RateLimitModeObserve, Description: "网关限流模式:observe 只统计不拦截,enforce 拦截", EnumValues: []string{constants.RateLimitModeObserve, constants.RateLimitModeEnforce}},
{Key: constants.SystemConfigPollingGatewayDefaultQPS, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "10", Description: "未被启用限流组覆盖的卡网关操作的默认每秒请求上限", Min: &minOne, Max: &maxQPS},
{Key: constants.SystemConfigPollingRateLimitedRetryDelay, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "Asynq 任务遇限流后的固定重试延迟(秒)", Min: &minOne, Max: &maxDelay},
{Key: constants.SystemConfigPollingRateLimitDeferAlert, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "600", Description: "同一任务因限流累计推迟超过该时长(秒)时写告警", Min: &minOne, Max: &maxAlert},
{Key: constants.SystemConfigPollingStopResumeRecordRetention, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "6", Description: "停复机记录保留月数,到期整区删除", Min: &minOne, Max: &maxMonths},
{Key: constants.SystemConfigPollingStopResumeRecordPremake, Module: constants.SystemConfigModulePolling, ValueType: constants.SystemConfigTypeInt, DefaultValue: "3", Description: "停复机记录提前创建未来分区的月数", Min: &minZero, Max: &maxMonths},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册网关限流系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
// ConfigReader 读取已注册的受控系统配置。
type ConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// Settings 是网关限流运行参数快照。
type Settings struct {
Mode string
DefaultQPS int
RetryDelay time.Duration
DeferAlert time.Duration
RecordRetention int
RecordPremakeMonth int
}
// Enforce 判断当前是否为拦截模式。
func (s Settings) Enforce() bool {
return s.Mode == constants.RateLimitModeEnforce
}
// defaultSettings 是配置读取失败时的安全默认值:只统计、不拦截。
func defaultSettings() Settings {
return Settings{Mode: constants.RateLimitModeObserve, DefaultQPS: 10, RetryDelay: 5 * time.Second, DeferAlert: 10 * time.Minute, RecordRetention: 6, RecordPremakeMonth: 3}
}
// SettingsProvider 在进程内缓存限流参数,超过 TTL 重新读取,后台修改数秒内生效。
type SettingsProvider struct {
reader ConfigReader
logger *zap.Logger
mu sync.Mutex
cached Settings
loaded time.Time
}
// NewSettingsProvider 创建限流参数提供者;reader 为 nil 时始终返回安全默认值。
func NewSettingsProvider(reader ConfigReader, logger *zap.Logger) *SettingsProvider {
if logger == nil {
logger = zap.NewNop()
}
return &SettingsProvider{reader: reader, logger: logger, cached: defaultSettings()}
}
// Current 返回当前限流参数;读取失败时保留上一次成功值。
func (p *SettingsProvider) Current(ctx context.Context) Settings {
if p == nil {
return defaultSettings()
}
p.mu.Lock()
defer p.mu.Unlock()
if p.reader == nil || time.Since(p.loaded) < constants.GatewayRateLimitConfigTTL {
return p.cached
}
next, err := p.load(ctx)
p.loaded = time.Now()
if err != nil {
p.logger.Warn("读取网关限流配置失败,沿用上一次配置", zap.Error(err))
return p.cached
}
p.cached = next
return next
}
func (p *SettingsProvider) load(ctx context.Context) (Settings, error) {
settings := defaultSettings()
mode, err := p.reader.Get(ctx, constants.SystemConfigPollingRateLimitMode)
if err != nil {
return settings, err
}
settings.Mode = mode
ints := []struct {
key string
set func(int)
}{
{constants.SystemConfigPollingGatewayDefaultQPS, func(v int) { settings.DefaultQPS = v }},
{constants.SystemConfigPollingRateLimitedRetryDelay, func(v int) { settings.RetryDelay = time.Duration(v) * time.Second }},
{constants.SystemConfigPollingRateLimitDeferAlert, func(v int) { settings.DeferAlert = time.Duration(v) * time.Second }},
{constants.SystemConfigPollingStopResumeRecordRetention, func(v int) { settings.RecordRetention = v }},
{constants.SystemConfigPollingStopResumeRecordPremake, func(v int) { settings.RecordPremakeMonth = v }},
}
for _, item := range ints {
raw, err := p.reader.Get(ctx, item.key)
if err != nil {
return settings, err
}
value, err := strconv.Atoi(raw)
if err != nil {
return settings, stderrors.New("网关限流整数配置非法")
}
item.set(value)
}
return settings, nil
}

View File

@@ -0,0 +1,86 @@
package gatewaylimit
import (
"context"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeRecordInput 描述一次实际调用上游停机或复机的结果。
type StopResumeRecordInput struct {
CardID uint
ICCID string
CarrierID uint
Action string // constants.StopResumeActionStop / StopResumeActionStart
Source string // 来源:豁免来源或审计来源
Scene string
Reason string // 停机原因
CommandID string // 同一次停复机命令的多次尝试共用
Attempt int
CallErr error
Duration time.Duration
}
// StopResumeRecorder 把每次停复机上游调用写入停复机记录表;写入失败只记日志与计数,不影响业务回写。
type StopResumeRecorder struct {
store *postgres.CardStopResumeRecordStore
classifier *Classifier
logger *zap.Logger
}
// NewStopResumeRecorder 创建停复机记录写入器。
func NewStopResumeRecorder(store *postgres.CardStopResumeRecordStore, classifier *Classifier, logger *zap.Logger) *StopResumeRecorder {
if logger == nil {
logger = zap.NewNop()
}
return &StopResumeRecorder{store: store, classifier: classifier, logger: logger}
}
// Record 写入一条停复机记录;限流拒绝(请求未发出)不写。
func (r *StopResumeRecorder) Record(ctx context.Context, input StopResumeRecordInput) {
if r == nil || r.store == nil || gateway.IsRateLimited(input.CallErr) {
return
}
row := &model.CardStopResumeRecord{
CardID: input.CardID, ICCID: input.ICCID, CarrierID: input.CarrierID, Action: input.Action,
Source: input.Source, Scene: input.Scene, Reason: input.Reason, CommandID: input.CommandID,
Attempt: input.Attempt, Result: constants.StopResumeRecordSuccess,
DurationMS: int(input.Duration.Milliseconds()), CreatedAt: time.Now(),
}
if input.CallErr != nil {
row.Result = constants.StopResumeRecordFailed
if gwErr, ok := gateway.AsGatewayError(input.CallErr); ok {
row.ErrorCategory = gwErr.Category
switch gwErr.Category {
case constants.GatewayErrorTimeout, constants.GatewayErrorNetwork:
// 超时或连接失败时上游可能已受理,按结果未知记录。
row.Result = constants.StopResumeRecordUnknown
case constants.GatewayErrorUpstream:
op := constants.GatewayOpStop
if input.Action == constants.StopResumeActionStart {
op = constants.GatewayOpStart
}
classification := r.classifier.Classify(ctx, op, gwErr.ProviderMessage)
row.ErrorCategory = classification.Category
if classification.Category == constants.GatewayClassAlreadyTarget {
row.Result = constants.StopResumeRecordAlreadyTarget
}
row.ProviderCode = strconv.Itoa(gwErr.ProviderCode)
row.ProviderMessage = gwErr.ProviderMessage
}
}
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
defer cancel()
if err := r.store.Create(writeCtx, row); err != nil {
r.logger.Warn("写停复机记录失败,不影响停复机结果回写",
zap.Uint("card_id", input.CardID), zap.String("action", input.Action), zap.String("metric", "stop_resume_record.write_failed"), zap.Error(err))
}
}

View File

@@ -0,0 +1,262 @@
package dto
import "time"
// RateLimitGroupListRequest 网关限流组列表请求
type RateLimitGroupListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty" description:"运营商账户ID筛选"`
OnlyGlobal bool `json:"only_global" query:"only_global" description:"只看全局组"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用)"`
}
// SaveRateLimitGroupRequest 新建或修改网关限流组请求
type SaveRateLimitGroupRequest struct {
ID uint `json:"-" description:"限流组ID"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商账户ID,为空表示全局组"`
GroupCode string `json:"group_code" validate:"required,max=50" required:"true" maxLength:"50" description:"组标识,同一账户内唯一"`
GroupName string `json:"group_name" validate:"required,max=100" required:"true" maxLength:"100" description:"组名称"`
MaxQPS int `json:"max_qps" validate:"required,min=1,max=10000" required:"true" minimum:"1" maximum:"10000" description:"每秒请求上限"`
Operations []string `json:"operations" validate:"required,min=1,dive,oneof=query_flow query_status query_realname stop start" required:"true" description:"组内操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"`
HighPriorityOperations []string `json:"high_priority_operations" validate:"omitempty,dive,oneof=query_flow query_status query_realname stop start" description:"高优先级操作,必须属于组内操作"`
ReservedQPS int `json:"reserved_qps" validate:"min=0" minimum:"0" description:"预留给高优先级操作的额度,须小于每秒请求上限"`
Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用),新建默认启用"`
}
// UpdateRateLimitGroupStatusRequest 启停网关限流组请求
type UpdateRateLimitGroupStatusRequest struct {
ID uint `json:"-" path:"id" description:"限流组ID"`
Status *int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:停用, 1:启用);停用后组内操作回落为默认每秒请求上限"`
}
// RateLimitGroupResponse 网关限流组响应
type RateLimitGroupResponse struct {
ID uint `json:"id" description:"限流组ID"`
CarrierID *uint `json:"carrier_id" description:"运营商账户ID,为空表示全局组"`
CarrierName string `json:"carrier_name" description:"运营商账户名称,全局组为空"`
GroupCode string `json:"group_code" description:"组标识"`
GroupName string `json:"group_name" description:"组名称"`
MaxQPS int `json:"max_qps" description:"每秒请求上限"`
Operations []string `json:"operations" description:"组内操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"`
HighPriorityOperations []string `json:"high_priority_operations" description:"高优先级操作"`
ReservedQPS int `json:"reserved_qps" description:"预留给高优先级操作的额度"`
Status int `json:"status" description:"状态 (0:停用, 1:启用)"`
StatusName string `json:"status_name" description:"状态名称"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// RateLimitGroupPageResult 网关限流组分页结果
type RateLimitGroupPageResult struct {
List []RateLimitGroupResponse `json:"items" description:"限流组列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"size" description:"每页数量"`
}
// GatewayErrorRuleListRequest 上游错误分类规则列表请求
type GatewayErrorRuleListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Category string `json:"category" query:"category" validate:"omitempty,oneof=rate_limited_upstream terminal already_target transient business account_config" description:"类别筛选 (rate_limited_upstream:上游频率限制, terminal:终态不可操作, already_target:已是目标状态, transient:临时故障, business:业务错误, account_config:账户配置错误)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用)"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字模糊搜索"`
}
// SaveGatewayErrorRuleRequest 新建或修改上游错误分类规则请求
type SaveGatewayErrorRuleRequest struct {
ID uint `json:"-" description:"规则ID"`
Keyword string `json:"keyword" validate:"required,max=200" required:"true" maxLength:"200" description:"匹配关键字或正则"`
MatchType string `json:"match_type" validate:"required,oneof=contains prefix regex" required:"true" description:"匹配方式 (contains:包含, prefix:前缀, regex:正则)"`
Category string `json:"category" validate:"required,oneof=rate_limited_upstream terminal already_target transient business account_config" required:"true" description:"类别 (rate_limited_upstream:上游频率限制, terminal:终态不可操作, already_target:已是目标状态, transient:临时故障, business:业务错误, account_config:账户配置错误)"`
Operations []string `json:"operations" validate:"omitempty,dive,oneof=query_flow query_status query_realname stop start" description:"适用操作,为空表示全部"`
Priority int `json:"priority" validate:"min=1,max=10000" minimum:"1" maximum:"10000" description:"匹配优先级,越小越先匹配"`
MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理(如返回含调用成功、操作进行中、请求被合并)"`
Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:停用, 1:启用),新建默认启用"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
// UpdateGatewayErrorRuleStatusRequest 启停上游错误分类规则请求
type UpdateGatewayErrorRuleStatusRequest struct {
ID uint `json:"-" path:"id" description:"规则ID"`
Status *int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:停用, 1:启用)"`
}
// GatewayErrorRuleResponse 上游错误分类规则响应
type GatewayErrorRuleResponse struct {
ID uint `json:"id" description:"规则ID"`
Keyword string `json:"keyword" description:"匹配关键字或正则"`
MatchType string `json:"match_type" description:"匹配方式 (contains:包含, prefix:前缀, regex:正则)"`
Category string `json:"category" description:"类别"`
CategoryName string `json:"category_name" description:"类别名称"`
Operations []string `json:"operations" description:"适用操作,为空表示全部"`
Priority int `json:"priority" description:"匹配优先级"`
MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理"`
Status int `json:"status" description:"状态 (0:停用, 1:启用)"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// GatewayErrorRulePageResult 上游错误分类规则分页结果
type GatewayErrorRulePageResult struct {
List []GatewayErrorRuleResponse `json:"items" description:"规则列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"size" description:"每页数量"`
}
// TestGatewayErrorRuleRequest 测试上游错误分类请求
type TestGatewayErrorRuleRequest struct {
Operation string `json:"operation" validate:"required,oneof=query_flow query_status query_realname stop start" required:"true" description:"操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"`
Message string `json:"message" validate:"required,max=1000" required:"true" maxLength:"1000" description:"上游返回信息"`
Draft *SaveGatewayErrorRuleRequest `json:"draft" description:"未保存的规则草稿,传入时与已启用规则一起参与匹配(草稿按自身优先级排序)"`
}
// TestGatewayErrorRuleResponse 测试上游错误分类响应
type TestGatewayErrorRuleResponse struct {
Category string `json:"category" description:"命中类别"`
CategoryName string `json:"category_name" description:"命中类别名称"`
RuleID uint `json:"rule_id" description:"命中规则ID,未命中或命中草稿为 0"`
Keyword string `json:"keyword" description:"命中关键字,未命中为空"`
MaybeAccepted bool `json:"maybe_accepted" description:"上游可能已受理"`
Matched bool `json:"matched" description:"是否命中规则;未命中时按业务错误处理"`
}
// StopResumeRecordListRequest 停复机记录列表请求
type StopResumeRecordListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"ICCID"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty" description:"运营商账户ID"`
Action string `json:"action" query:"action" validate:"omitempty,oneof=stop start" description:"动作 (stop:停机, start:复机)"`
Source string `json:"source" query:"source" validate:"omitempty,max=50" maxLength:"50" description:"来源"`
Result string `json:"result" query:"result" validate:"omitempty,oneof=success failed unknown already_target skipped" description:"结果 (success:成功, failed:失败, unknown:结果未知, already_target:已是目标状态, skipped:跳过)"`
ErrorCategory string `json:"error_category" query:"error_category" validate:"omitempty,max=30" maxLength:"30" description:"错误类别"`
StartTime time.Time `json:"start_time" query:"start_time" validate:"required" required:"true" description:"开始时间(必填,RFC3339)"`
EndTime time.Time `json:"end_time" query:"end_time" validate:"required" required:"true" description:"结束时间(必填,RFC3339,跨度不超过 31 天)"`
}
// CardStopResumeRecordListRequest 单卡停复机历史请求
type CardStopResumeRecordListRequest struct {
ID uint `json:"-" path:"id" description:"IoT卡ID"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
StartTime time.Time `json:"start_time" query:"start_time" description:"开始时间(RFC3339),为空取最近 31 天"`
EndTime time.Time `json:"end_time" query:"end_time" description:"结束时间(RFC3339),为空取当前"`
}
// StopResumeRecordResponse 停复机记录响应
type StopResumeRecordResponse struct {
ID uint `json:"id" description:"记录ID"`
CardID uint `json:"card_id" description:"IoT卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
Action string `json:"action" description:"动作 (stop:停机, start:复机)"`
ActionName string `json:"action_name" description:"动作名称"`
Source string `json:"source" description:"来源"`
Scene string `json:"scene" description:"场景"`
Reason string `json:"reason" description:"停机原因"`
CommandID string `json:"command_id" description:"停复机命令标识,同一命令的多次尝试相同"`
Attempt int `json:"attempt" description:"尝试序号"`
Result string `json:"result" description:"结果 (success:成功, failed:失败, unknown:结果未知, already_target:已是目标状态, skipped:跳过)"`
ErrorCategory string `json:"error_category" description:"错误类别"`
ErrorCategoryName string `json:"error_category_name" description:"错误类别名称"`
ProviderCode string `json:"provider_code" description:"上游返回码"`
ProviderMessage string `json:"provider_message" description:"上游返回信息(已截断)"`
DurationMS int `json:"duration_ms" description:"耗时(毫秒)"`
CreatedAt time.Time `json:"created_at" description:"记录时间"`
}
// StopResumeRecordPageResult 停复机记录分页结果
type StopResumeRecordPageResult struct {
List []StopResumeRecordResponse `json:"items" description:"记录列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"size" description:"每页数量"`
}
// GatewayCallStatsRequest 按运营商账户的网关调用统计请求
type GatewayCallStatsRequest struct {
Minutes int `json:"minutes" query:"minutes" validate:"omitempty,min=1,max=60" minimum:"1" maximum:"60" description:"统计最近多少分钟,默认 15,最大 60"`
}
// GatewayCallTrendRequest 单个运营商账户的网关调用趋势请求
type GatewayCallTrendRequest struct {
CarrierID uint `json:"-" path:"carrier_id" description:"运营商账户ID(0 表示无法解析账户的卡)"`
Minutes int `json:"minutes" query:"minutes" validate:"omitempty,min=1,max=10080" minimum:"1" maximum:"10080" description:"统计最近多少分钟,默认 60,最大 10080(7 天)"`
BucketMinutes int `json:"bucket_minutes" query:"bucket_minutes" validate:"omitempty,min=1,max=1440" minimum:"1" maximum:"1440" description:"聚合粒度(分钟),默认 1"`
}
// GatewayOperationStat 某操作在统计窗口内的调用汇总
type GatewayOperationStat struct {
Operation string `json:"operation" description:"操作 (query_flow:查流量, query_status:查状态, query_realname:查实名, stop:停机, start:复机)"`
OperationName string `json:"operation_name" description:"操作名称"`
Attempts int64 `json:"attempts" description:"实际发出的请求数"`
Success int64 `json:"success" description:"成功数"`
SuccessRate float64 `json:"success_rate" description:"成功率(0~1)"`
AvgQPS float64 `json:"avg_qps" description:"平均每秒请求数"`
PeakMinuteQPS float64 `json:"peak_minute_qps" description:"峰值分钟的平均每秒请求数"`
WouldLimit int64 `json:"would_limit" description:"超过限流组上限的次数(只统计模式下为本应被限流次数)"`
RateLimited int64 `json:"rate_limited" description:"被限流拒绝的次数(拦截模式)"`
Failures map[string]int64 `json:"failures" description:"按错误类别的失败数(network/timeout/http/invalid_response/upstream:<类别>)"`
Exempt map[string]int64 `json:"exempt" description:"按豁免来源的调用量 (manual_click/channel_threshold/auto_renewal/manual_refresh)"`
AvgDurationMS float64 `json:"avg_duration_ms" description:"平均耗时(毫秒)"`
P95DurationMS int64 `json:"p95_duration_ms" description:"p95 耗时上界(毫秒,按分桶近似),-1 表示超过最大分桶"`
}
// GatewayCarrierStat 某运营商账户的网关调用汇总
type GatewayCarrierStat struct {
CarrierID uint `json:"carrier_id" description:"运营商账户ID(0 表示无法解析账户的卡)"`
CarrierName string `json:"carrier_name" description:"运营商账户名称"`
Operations []GatewayOperationStat `json:"operations" description:"各操作汇总"`
}
// GatewayCallStatsResponse 按运营商账户的网关调用统计响应
type GatewayCallStatsResponse struct {
Mode string `json:"mode" description:"当前限流模式 (observe:只统计, enforce:拦截)"`
Minutes int `json:"minutes" description:"统计窗口(分钟)"`
Carriers []GatewayCarrierStat `json:"carriers" description:"有调用的运营商账户汇总"`
}
// GatewayCallTrendPoint 趋势中的一个时间桶
type GatewayCallTrendPoint struct {
Time string `json:"time" description:"时间桶起点(上海时区,yyyy-MM-dd HH:mm)"`
Operations []GatewayOperationStat `json:"operations" description:"各操作在该时间桶的汇总"`
}
// GatewayCallTrendResponse 单个运营商账户的网关调用趋势响应
type GatewayCallTrendResponse struct {
CarrierID uint `json:"carrier_id" description:"运营商账户ID"`
Points []GatewayCallTrendPoint `json:"points" description:"时间桶列表"`
}
// PollingOptionItem 枚举选项
type PollingOptionItem struct {
Value string `json:"value" description:"取值"`
Label string `json:"label" description:"中文名称"`
}
// PollingOptionsResponse 轮询与网关限流相关枚举
type PollingOptionsResponse struct {
GatewayOperations []PollingOptionItem `json:"gateway_operations" description:"卡网关操作"`
ErrorCategories []PollingOptionItem `json:"error_categories" description:"上游错误分类类别"`
MatchTypes []PollingOptionItem `json:"match_types" description:"错误分类匹配方式"`
RateLimitModes []PollingOptionItem `json:"rate_limit_modes" description:"限流模式"`
ExemptSources []PollingOptionItem `json:"exempt_sources" description:"限流豁免来源"`
StopResumeResults []PollingOptionItem `json:"stop_resume_results" description:"停复机记录结果"`
StopResumeActions []PollingOptionItem `json:"stop_resume_actions" description:"停复机动作"`
}
// UpdateRateLimitGroupParams 修改网关限流组参数(路径 + 请求体,供接口文档使用)
type UpdateRateLimitGroupParams struct {
IDReq
SaveRateLimitGroupRequest
}
// UpdateGatewayErrorRuleParams 修改上游错误分类规则参数(路径 + 请求体,供接口文档使用)
type UpdateGatewayErrorRuleParams struct {
IDReq
SaveGatewayErrorRuleRequest
}

View File

@@ -4,7 +4,7 @@ import "time"
// SystemConfigListRequest 是系统配置分页查询参数。
type SystemConfigListRequest struct {
Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment" enum:"carrier_callback,c2b.payment" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"`
Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment polling" enum:"carrier_callback,c2b.payment,polling" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置, polling:轮询与网关限流配置)"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
}
@@ -14,7 +14,7 @@ type SystemConfigItem struct {
ConfigKey string `json:"config_key" description:"稳定配置 Key"`
Value string `json:"value" description:"配置值;敏感值按注册策略脱敏"`
ValueType string `json:"value_type" description:"值类型 (string:字符串, int:整数, bool:布尔, json:JSON)"`
Module string `json:"module" enum:"carrier_callback,c2b.payment" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"`
Module string `json:"module" enum:"carrier_callback,c2b.payment,polling" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置, polling:轮询与网关限流配置)"`
Description string `json:"description" description:"中文说明"`
Readonly bool `json:"readonly" description:"是否只读"`
Sensitive bool `json:"sensitive" description:"是否敏感"`

View File

@@ -0,0 +1,72 @@
package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// CarrierRateLimitGroup 运营商账户网关限流组。
// CarrierID 为 nil 表示全局组;Operations、HighPriorityOperations 为卡网关操作字符串数组(JSON)。
type CarrierRateLimitGroup struct {
gorm.Model
BaseModel `gorm:"embedded"`
CarrierID *uint `gorm:"column:carrier_id;comment:运营商账户ID,为空表示全局组" json:"carrier_id"`
GroupCode string `gorm:"column:group_code;type:varchar(50);not null;comment:组标识" json:"group_code"`
GroupName string `gorm:"column:group_name;type:varchar(100);not null;comment:组名称" json:"group_name"`
MaxQPS int `gorm:"column:max_qps;not null;comment:每秒请求上限" json:"max_qps"`
Operations datatypes.JSON `gorm:"column:operations;type:jsonb;not null;comment:组内操作" json:"operations"`
HighPriorityOperations datatypes.JSON `gorm:"column:high_priority_operations;type:jsonb;not null;comment:高优先级操作" json:"high_priority_operations"`
ReservedQPS int `gorm:"column:reserved_qps;not null;default:0;comment:预留给高优先级操作的额度" json:"reserved_qps"`
Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-停用 1-启用" json:"status"`
}
// TableName 返回限流组表名。
func (CarrierRateLimitGroup) TableName() string {
return "tb_carrier_rate_limit_group"
}
// GatewayErrorClassification 上游错误分类规则。
type GatewayErrorClassification struct {
gorm.Model
BaseModel `gorm:"embedded"`
Keyword string `gorm:"column:keyword;type:varchar(200);not null;comment:匹配关键字或正则" json:"keyword"`
MatchType string `gorm:"column:match_type;type:varchar(20);not null;default:contains;comment:匹配方式" json:"match_type"`
Category string `gorm:"column:category;type:varchar(30);not null;comment:类别" json:"category"`
Operations datatypes.JSON `gorm:"column:operations;type:jsonb;not null;comment:适用操作,空数组表示全部" json:"operations"`
Priority int `gorm:"column:priority;not null;default:100;comment:匹配优先级" json:"priority"`
MaybeAccepted bool `gorm:"column:maybe_accepted;not null;default:false;comment:上游可能已受理" json:"maybe_accepted"`
Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-停用 1-启用" json:"status"`
Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"`
}
// TableName 返回错误分类规则表名。
func (GatewayErrorClassification) TableName() string {
return "tb_gateway_error_classification"
}
// CardStopResumeRecord 停复机调用记录(按月分区表),每次实际调用上游停机或复机一条。
type CardStopResumeRecord struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
CardID uint `gorm:"column:card_id;not null" json:"card_id"`
ICCID string `gorm:"column:iccid;type:varchar(30);not null;default:''" json:"iccid"`
CarrierID uint `gorm:"column:carrier_id;not null;default:0" json:"carrier_id"`
Action string `gorm:"column:action;type:varchar(10);not null" json:"action"`
Source string `gorm:"column:source;type:varchar(50);not null;default:''" json:"source"`
Scene string `gorm:"column:scene;type:varchar(50);not null;default:''" json:"scene"`
Reason string `gorm:"column:reason;type:varchar(50);not null;default:''" json:"reason"`
CommandID string `gorm:"column:command_id;type:varchar(64);not null;default:''" json:"command_id"`
Attempt int `gorm:"column:attempt;not null;default:1" json:"attempt"`
Result string `gorm:"column:result;type:varchar(20);not null" json:"result"`
ErrorCategory string `gorm:"column:error_category;type:varchar(30);not null;default:''" json:"error_category"`
ProviderCode string `gorm:"column:provider_code;type:varchar(30);not null;default:''" json:"provider_code"`
ProviderMessage string `gorm:"column:provider_message;type:varchar(500);not null;default:''" json:"provider_message"`
DurationMS int `gorm:"column:duration_ms;not null;default:0" json:"duration_ms"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;primaryKey;not null" json:"created_at"`
}
// TableName 返回停复机记录表名。
func (CardStopResumeRecord) TableName() string {
return "tb_card_stop_resume_record"
}

View File

@@ -133,6 +133,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PollingConfig != nil {
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
}
if handlers.GatewayRateLimit != nil {
registerGatewayRateLimitRoutes(authGroup, handlers.GatewayRateLimit, doc, basePath)
}
if handlers.PollingConcurrency != nil {
registerPollingConcurrencyRoutes(authGroup, handlers.PollingConcurrency, doc, basePath)
}

View File

@@ -0,0 +1,72 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerGatewayRateLimitRoutes 注册运营商账户网关限流、错误分类、停复机记录与调用统计路由。
func registerGatewayRateLimitRoutes(router fiber.Router, handler *admin.GatewayRateLimitHandler, doc *openapi.Generator, basePath string) {
tags := []string{"网关限流"}
groups := router.Group("/polling-rate-limit-groups")
groupsPath := basePath + "/polling-rate-limit-groups"
Register(groups, doc, groupsPath, "GET", "", handler.ListGroups, RouteSpec{
Summary: "查询网关限流组列表", Tags: tags, Input: new(dto.RateLimitGroupListRequest), Output: new(dto.RateLimitGroupPageResult), Auth: true,
})
Register(groups, doc, groupsPath, "POST", "", handler.CreateGroup, RouteSpec{
Summary: "新建网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.SaveRateLimitGroupRequest), Output: new(dto.RateLimitGroupResponse), Auth: true,
})
Register(groups, doc, groupsPath, "GET", "/:id", handler.GetGroup, RouteSpec{
Summary: "查询网关限流组详情", Tags: tags, Input: new(dto.IDReq), Output: new(dto.RateLimitGroupResponse), Auth: true,
})
Register(groups, doc, groupsPath, "PUT", "/:id", handler.UpdateGroup, RouteSpec{
Summary: "修改网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.UpdateRateLimitGroupParams), Output: new(dto.RateLimitGroupResponse), Auth: true,
})
Register(groups, doc, groupsPath, "PUT", "/:id/status", handler.UpdateGroupStatus, RouteSpec{
Summary: "启停网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.UpdateRateLimitGroupStatusRequest), Output: nil, Auth: true,
})
Register(groups, doc, groupsPath, "DELETE", "/:id", handler.DeleteGroup, RouteSpec{
Summary: "删除网关限流组(仅超级管理员)", Tags: tags, Input: new(dto.IDReq), Output: nil, Auth: true,
})
rules := router.Group("/gateway-error-classifications")
rulesPath := basePath + "/gateway-error-classifications"
Register(rules, doc, rulesPath, "GET", "", handler.ListRules, RouteSpec{
Summary: "查询上游错误分类规则列表", Tags: tags, Input: new(dto.GatewayErrorRuleListRequest), Output: new(dto.GatewayErrorRulePageResult), Auth: true,
})
Register(rules, doc, rulesPath, "POST", "", handler.CreateRule, RouteSpec{
Summary: "新建上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.SaveGatewayErrorRuleRequest), Output: new(dto.GatewayErrorRuleResponse), Auth: true,
})
Register(rules, doc, rulesPath, "POST", "/test", handler.TestRule, RouteSpec{
Summary: "测试上游错误分类(不写数据)", Tags: tags, Input: new(dto.TestGatewayErrorRuleRequest), Output: new(dto.TestGatewayErrorRuleResponse), Auth: true,
})
Register(rules, doc, rulesPath, "PUT", "/:id", handler.UpdateRule, RouteSpec{
Summary: "修改上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.UpdateGatewayErrorRuleParams), Output: new(dto.GatewayErrorRuleResponse), Auth: true,
})
Register(rules, doc, rulesPath, "PUT", "/:id/status", handler.UpdateRuleStatus, RouteSpec{
Summary: "启停上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.UpdateGatewayErrorRuleStatusRequest), Output: nil, Auth: true,
})
Register(rules, doc, rulesPath, "DELETE", "/:id", handler.DeleteRule, RouteSpec{
Summary: "删除上游错误分类规则(仅超级管理员)", Tags: tags, Input: new(dto.IDReq), Output: nil, Auth: true,
})
Register(router, doc, basePath, "GET", "/card-stop-resume-records", handler.ListStopResumeRecords, RouteSpec{
Summary: "查询停复机记录(时间范围必填,跨度不超过 31 天)", Tags: tags, Input: new(dto.StopResumeRecordListRequest), Output: new(dto.StopResumeRecordPageResult), Auth: true,
})
Register(router, doc, basePath, "GET", "/iot-cards/:id/stop-resume-records", handler.ListCardStopResumeRecords, RouteSpec{
Summary: "查询单卡停复机历史", Tags: tags, Input: new(dto.CardStopResumeRecordListRequest), Output: new(dto.StopResumeRecordPageResult), Auth: true,
})
Register(router, doc, basePath, "GET", "/polling-stats/carriers", handler.CarrierCallStats, RouteSpec{
Summary: "按运营商账户汇总网关调用", Tags: tags, Input: new(dto.GatewayCallStatsRequest), Output: new(dto.GatewayCallStatsResponse), Auth: true,
})
Register(router, doc, basePath, "GET", "/polling-stats/carriers/:carrier_id/trend", handler.CarrierCallTrend, RouteSpec{
Summary: "查询单个运营商账户的网关调用趋势", Tags: tags, Input: new(dto.GatewayCallTrendRequest), Output: new(dto.GatewayCallTrendResponse), Auth: true,
})
Register(router, doc, basePath, "GET", "/polling-options", handler.PollingOptions, RouteSpec{
Summary: "查询轮询与网关限流枚举", Tags: tags, Input: nil, Output: new(dto.PollingOptionsResponse), Auth: true,
})
}

View File

@@ -653,6 +653,8 @@ func flexIntPtr(fi gateway.FlexInt) *int {
// Refresh 刷新资产数据(调网关同步)
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
// 手动刷新是用户在页面等待结果的操作,豁免运营商账户网关限流(限流只约束自动触发的调用)。
ctx = gateway.WithRateLimitExempt(ctx, constants.RateLimitExemptManualRefresh)
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, id)

View File

@@ -15,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -28,6 +29,7 @@ import (
)
type Service struct {
stopResumeRecorder *gatewaylimit.StopResumeRecorder
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
@@ -1440,7 +1442,9 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
log.Info("调用网关停机(设备)",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID))
gwErr := s.gatewayClient.StopCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID})
callStartedAt := time.Now()
gwErr := s.gatewayClient.StopCard(gateway.WithCarrier(gateway.WithAttemptObserver(ctx, observer), card.CarrierID), &gateway.CardOperationReq{CardNo: card.ICCID})
s.recordCardCommand(ctx, card, constants.StopResumeActionStop, constants.StopReasonManual, gwErr, time.Since(callStartedAt))
integrationID = observer.integration
if gwErr != nil {
log.Error("设备停机-调网关停机失败",
@@ -1601,7 +1605,9 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
log.Info("调用网关复机(设备)",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID))
gwErr := s.gatewayClient.StartCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID})
callStartedAt := time.Now()
gwErr := s.gatewayClient.StartCard(gateway.WithCarrier(gateway.WithAttemptObserver(ctx, observer), card.CarrierID), &gateway.CardOperationReq{CardNo: card.ICCID})
s.recordCardCommand(ctx, card, constants.StopResumeActionStart, "", gwErr, time.Since(callStartedAt))
integrationID = observer.integration
if gwErr != nil {
log.Error("设备复机-调网关复机失败",
@@ -1757,3 +1763,24 @@ func (s *Service) updateCardAndAppendNetworkSeries(
})
})
}
// SetStopResumeRecorder 注入停复机记录写入器;设备停复机每次上游调用写一条记录。
func (s *Service) SetStopResumeRecorder(recorder *gatewaylimit.StopResumeRecorder) {
s.stopResumeRecorder = recorder
}
// recordCardCommand 写设备下单卡的停复机记录;停复机只发一次请求,尝试序号恒为 1。
func (s *Service) recordCardCommand(ctx context.Context, card *model.IotCard, action, reason string, callErr error, duration time.Duration) {
if s.stopResumeRecorder == nil || card == nil {
return
}
source := gateway.RateLimitExemptSource(ctx)
if source == "" {
source = constants.AuditSourceWorker
}
s.stopResumeRecorder.Record(ctx, gatewaylimit.StopResumeRecordInput{
CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID, Action: action,
Source: source, Scene: "device_command", Reason: reason, CommandID: deviceCommandSeriesKey(ctx),
Attempt: 1, CallErr: callErr, Duration: duration,
})
}

View File

@@ -8,7 +8,9 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
@@ -25,6 +27,9 @@ func (s *StopResumeService) appendCardCommandAudit(
beforeData, afterData map[string]any,
businessErr error,
) error {
if !commandAuditRequired(ctx, actionCode, result) {
return nil
}
if s.auditWriter == nil || card == nil || card.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置或资源不完整")
}
@@ -62,6 +67,9 @@ func (s *StopResumeService) recordCardCommandAudit(
beforeData, afterData map[string]any,
businessErr error,
) {
if !commandAuditRequired(ctx, actionCode, result) {
return
}
if s.db == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
recordCardAuditSecondaryFailure(ctx, actionCode, cardID(card), businessErr,
errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置"))
@@ -74,6 +82,49 @@ func (s *StopResumeService) recordCardCommandAudit(
}
}
// legacyCommandLogging 判断本次停复机是否沿用统一审计与集成日志:
// 带限流豁免标记的入口(后台人工点击、通道阈值、自动续费)与账户、个人客户、OpenAPI 发起的操作沿用;
// 系统自动停复机(轮询、Outbox、激活、重置、退款等系统任务)只写停复机记录(add-carrier-gateway-rate-limit)。
// 通道阈值与自动续费的恢复扫描依赖集成日志标识,因此必须沿用。
func legacyCommandLogging(ctx context.Context) bool {
if gateway.RateLimitExemptSource(ctx) != "" {
return true
}
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount, constants.AuditActorPersonalCustomer, constants.AuditActorOpenAPI:
return true
default:
return false
}
}
// commandAuditRequired 判断停复机命令结果是否写统一审计:系统自动停复机的成功、失败与结果未知改由停复机记录承载;
// 被拒绝(未调用上游)与非停复机命令的审计保持不变。
func commandAuditRequired(ctx context.Context, actionCode, result string) bool {
if legacyCommandLogging(ctx) || result == constants.AuditResultDenied {
return true
}
switch actionCode {
case constants.AuditActionIotCardAutoStopped, constants.AuditActionIotCardAutoStarted,
constants.AuditActionIotCardManualStopped, constants.AuditActionIotCardManualStarted,
constants.AuditActionIotCardOpenAPIStarted:
return false
default:
return true
}
}
// commandSource 返回停复机记录的来源:豁免来源优先,其次审计来源。
func commandSource(ctx context.Context) string {
if source := gateway.RateLimitExemptSource(ctx); source != "" {
return source
}
if source := auditcontext.From(ctx).Source; source != "" {
return source
}
return constants.AuditSourceWorker
}
func cardID(card *model.IotCard) uint {
if card == nil {
return 0
@@ -191,6 +242,10 @@ type cardCommandAttemptObserver struct {
operation string
scene string
seriesKey string
action string // constants.StopResumeActionStop / StopResumeActionStart
reason string // 停机原因(复机为空)
legacy bool // 是否沿用集成日志(见 legacyCommandLogging)
startedAt time.Time
nextAttempt int
current *gatewayAttempt
successful *gatewayAttempt
@@ -203,6 +258,13 @@ type cardCommandAttemptObserver struct {
func (o *cardCommandAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
o.nextAttempt++
o.lastCallErr = nil
o.startedAt = time.Now()
if !o.legacy {
// 系统自动停复机不再写集成日志,只在尝试结束后写停复机记录。
o.current = nil
o.lastIntegrationID = ""
return nil
}
attempt, err := o.service.startCardCommandAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
if err != nil {
o.recordingErr = err
@@ -218,6 +280,10 @@ func (o *cardCommandAttemptObserver) AfterAttempt(ctx context.Context, _ int, ca
if isGatewayTimeout(callErr) {
o.unknown = true
}
o.service.recordStopResumeAttempt(ctx, o, callErr)
if !o.legacy {
return nil
}
if callErr == nil {
o.successful = o.current
o.current = nil
@@ -237,3 +303,15 @@ func (o *cardCommandAttemptObserver) auditResult(lastErr error) string {
}
return cardCommandAuditResult(lastErr)
}
// recordStopResumeAttempt 每次实际调用上游停机或复机后写一条停复机记录,写入失败不影响业务回写。
func (s *StopResumeService) recordStopResumeAttempt(ctx context.Context, o *cardCommandAttemptObserver, callErr error) {
if s.stopResumeRecorder == nil || o == nil || o.card == nil {
return
}
s.stopResumeRecorder.Record(ctx, gatewaylimit.StopResumeRecordInput{
CardID: o.card.ID, ICCID: o.card.ICCID, CarrierID: o.card.CarrierID, Action: o.action,
Source: commandSource(ctx), Scene: o.scene, Reason: o.reason, CommandID: o.seriesKey,
Attempt: o.nextAttempt, CallErr: callErr, Duration: time.Since(o.startedAt),
})
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -61,6 +62,13 @@ type StopResumeService struct {
retryInterval time.Duration
// channelThresholdGuard 是可选的通道阈值持锁判定能力;未注入时不做持锁拒绝。
channelThresholdGuard ChannelThresholdLockGuard
// stopResumeRecorder 写每次上游停复机调用的停复机记录;未注入时不写。
stopResumeRecorder *gatewaylimit.StopResumeRecorder
}
// SetStopResumeRecorder 注入停复机记录写入器。
func (s *StopResumeService) SetStopResumeRecorder(recorder *gatewaylimit.StopResumeRecorder) {
s.stopResumeRecorder = recorder
}
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
@@ -717,8 +725,9 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
attemptObserver := &cardCommandAttemptObserver{
service: s, card: card, operation: constants.IntegrationOperationGatewayStopCard,
scene: constants.CardObservationSceneBusinessStop, seriesKey: seriesKey,
action: constants.StopResumeActionStop, reason: stopReason, legacy: legacyCommandLogging(ctx),
}
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
@@ -730,6 +739,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
}
callErr := s.gatewayClient.StopCard(gatewayCtx, &gateway.CardOperationReq{CardNo: card.ICCID})
if gateway.IsRateLimited(callErr) {
// 被运营商账户限流拒绝时请求并未发出:不重试、不写失败审计,由调用方的既有重试或兜底路径稍后处理。
s.logger.Warn("停机被运营商账户网关限流,本次不调用上游", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID))
return carrierthresholddomain.CommandOutcome{Result: constants.AuditResultFailed}, callErr
}
lastIntegrationID = attemptObserver.lastIntegrationID
if attemptObserver.recordingErr != nil {
s.logger.Error("记录停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))
@@ -818,8 +832,9 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
attemptObserver := &cardCommandAttemptObserver{
service: s, card: card, operation: constants.IntegrationOperationGatewayStartCard,
scene: constants.CardObservationSceneBusinessResume, seriesKey: seriesKey,
action: constants.StopResumeActionStart, legacy: legacyCommandLogging(ctx),
}
gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver)
gatewayCtx := gateway.WithCarrier(gateway.WithAttemptObserver(ctx, attemptObserver), card.CarrierID)
var lastErr error
lastIntegrationID := ""
for i := 0; i < s.maxRetries; i++ {
@@ -835,6 +850,11 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
req.Extend = constants.GatewayCardStartExtendMachineSeparated
}
callErr := s.gatewayClient.StartCard(gatewayCtx, req)
if gateway.IsRateLimited(callErr) {
// 被运营商账户限流拒绝时请求并未发出:不重试、不写失败审计,由调用方的既有重试或兜底路径稍后处理。
s.logger.Warn("复机被运营商账户网关限流,本次不调用上游", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID))
return nil, "", callErr
}
lastIntegrationID = attemptObserver.lastIntegrationID
if attemptObserver.recordingErr != nil {
s.logger.Error("记录复机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr))

View File

@@ -0,0 +1,366 @@
package polling
import (
"context"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"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/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 分钟计数 Hash 字段(与 gatewaylimit.Guard 写入口径一致)。
const (
statAttempts = "attempts"
statDurationSum = "duration_ms_sum"
statWouldLimit = "would_limit"
statPrefixDur = "duration_le:"
statPrefixExem = "exempt:"
statPrefixUp = "upstream:"
)
// statDurationBuckets 与写入端分桶上界一致(毫秒)。
var statDurationBuckets = []string{"100", "300", "500", "1000", "3000", "10000", "60000", "inf"}
// ListStopResumeRecords 按条件查询停复机记录;时间范围必填且跨度不超过 31 天。
func (s *GatewayRateLimitService) ListStopResumeRecords(ctx context.Context, req *dto.StopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
if req.StartTime.IsZero() || req.EndTime.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:开始时间与结束时间必填")
}
if !req.EndTime.After(req.StartTime) || req.EndTime.Sub(req.StartTime) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天")
}
page, size := normalizePage(req.Page, req.PageSize)
return s.listRecords(ctx, postgres.StopResumeRecordFilter{
ICCID: strings.TrimSpace(req.ICCID), CarrierID: req.CarrierID, Action: req.Action, Source: req.Source,
Result: req.Result, ErrorCategory: req.ErrorCategory, StartTime: req.StartTime, EndTime: req.EndTime,
Page: page, PageSize: size,
})
}
// ListCardStopResumeRecords 查询单卡停复机历史;未传时间默认最近 31 天。
func (s *GatewayRateLimitService) ListCardStopResumeRecords(ctx context.Context, req *dto.CardStopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
var count int64
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", req.ID).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败")
}
if count == 0 {
return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在")
}
end := req.EndTime
if end.IsZero() {
end = time.Now()
}
start := req.StartTime
if start.IsZero() {
start = end.AddDate(0, 0, -constants.StopResumeRecordQueryMaxDays)
}
if !end.After(start) || end.Sub(start) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天")
}
page, size := normalizePage(req.Page, req.PageSize)
cardID := req.ID
return s.listRecords(ctx, postgres.StopResumeRecordFilter{CardID: &cardID, StartTime: start, EndTime: end, Page: page, PageSize: size})
}
func (s *GatewayRateLimitService) listRecords(ctx context.Context, filter postgres.StopResumeRecordFilter) (*dto.StopResumeRecordPageResult, error) {
rows, total, err := postgres.NewCardStopResumeRecordStore(s.db).List(ctx, filter)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询停复机记录失败")
}
items := make([]dto.StopResumeRecordResponse, 0, len(rows))
for _, row := range rows {
items = append(items, dto.StopResumeRecordResponse{
ID: row.ID, CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, Action: row.Action,
ActionName: stopResumeActionName(row.Action), Source: row.Source, Scene: row.Scene, Reason: row.Reason,
CommandID: row.CommandID, Attempt: row.Attempt, Result: row.Result, ErrorCategory: row.ErrorCategory,
ErrorCategoryName: errorCategoryName(row.ErrorCategory), ProviderCode: row.ProviderCode,
ProviderMessage: row.ProviderMessage, DurationMS: row.DurationMS, CreatedAt: row.CreatedAt,
})
}
return &dto.StopResumeRecordPageResult{List: items, Total: total, Page: filter.Page, PageSize: filter.PageSize}, nil
}
// CarrierCallStats 汇总最近若干分钟各运营商账户的网关调用(只返回有调用的账户)。
func (s *GatewayRateLimitService) CarrierCallStats(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallStatsRequest) (*dto.GatewayCallStatsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
minutes := req.Minutes
if minutes <= 0 {
minutes = 15
}
names, err := s.carrierNames(ctx)
if err != nil {
return nil, err
}
carrierIDs := []uint{0}
for id := range names {
carrierIDs = append(carrierIDs, id)
}
sort.Slice(carrierIDs, func(i, j int) bool { return carrierIDs[i] < carrierIDs[j] })
stamps := minuteStamps(time.Now(), minutes)
data, err := loadStatHashes(ctx, redisClient, carrierIDs, stamps)
if err != nil {
return nil, err
}
resp := &dto.GatewayCallStatsResponse{Mode: s.currentMode(ctx), Minutes: minutes, Carriers: []dto.GatewayCarrierStat{}}
for _, carrierID := range carrierIDs {
var ops []dto.GatewayOperationStat
for _, op := range constants.GatewayCardOperations {
stat := aggregateOperation(op, data[statKey{carrierID, op}], len(stamps))
if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 {
ops = append(ops, stat)
}
}
if len(ops) == 0 {
continue
}
name := names[carrierID]
if carrierID == 0 {
name = "未知账户"
}
resp.Carriers = append(resp.Carriers, dto.GatewayCarrierStat{CarrierID: carrierID, CarrierName: name, Operations: ops})
}
return resp, nil
}
// CarrierCallTrend 返回单个运营商账户按时间桶聚合的网关调用趋势。
func (s *GatewayRateLimitService) CarrierCallTrend(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallTrendRequest) (*dto.GatewayCallTrendResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
minutes, bucket := req.Minutes, req.BucketMinutes
if minutes <= 0 {
minutes = 60
}
if bucket <= 0 {
bucket = 1
}
stamps := minuteStamps(time.Now(), minutes)
data, err := loadStatHashes(ctx, redisClient, []uint{req.CarrierID}, stamps)
if err != nil {
return nil, err
}
resp := &dto.GatewayCallTrendResponse{CarrierID: req.CarrierID, Points: []dto.GatewayCallTrendPoint{}}
for start := 0; start < len(stamps); start += bucket {
end := start + bucket
if end > len(stamps) {
end = len(stamps)
}
point := dto.GatewayCallTrendPoint{Time: formatStamp(stamps[start]), Operations: []dto.GatewayOperationStat{}}
for _, op := range constants.GatewayCardOperations {
series := data[statKey{req.CarrierID, op}]
if series == nil {
continue
}
stat := aggregateOperation(op, series[start:end], end-start)
if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 {
point.Operations = append(point.Operations, stat)
}
}
resp.Points = append(resp.Points, point)
}
return resp, nil
}
func (s *GatewayRateLimitService) currentMode(ctx context.Context) string {
if s.runtime == nil {
return constants.RateLimitModeObserve
}
return s.runtime.Settings.Current(ctx).Mode
}
type statKey struct {
carrierID uint
op string
}
// minuteStamps 返回从最早到最近的分钟标识(上海时区 yyyyMMddHHmm)。
func minuteStamps(now time.Time, minutes int) []string {
location := shanghaiLocation()
current := now.In(location).Truncate(time.Minute)
stamps := make([]string, minutes)
for i := 0; i < minutes; i++ {
stamps[minutes-1-i] = current.Add(-time.Duration(i) * time.Minute).Format("200601021504")
}
return stamps
}
func formatStamp(stamp string) string {
t, err := time.ParseInLocation("200601021504", stamp, shanghaiLocation())
if err != nil {
return stamp
}
return t.Format("2006-01-02 15:04")
}
// loadStatHashes 用管道批量读取分钟计数;返回每个账户 × 操作按分钟顺序的 Hash 列表。
func loadStatHashes(ctx context.Context, redisClient *redis.Client, carrierIDs []uint, stamps []string) (map[statKey][]map[string]string, error) {
result := map[statKey][]map[string]string{}
if redisClient == nil {
return result, nil
}
type pending struct {
key statKey
idx int
cmd *redis.MapStringStringCmd
}
pipe := redisClient.Pipeline()
cmds := make([]pending, 0, len(carrierIDs)*len(constants.GatewayCardOperations)*len(stamps))
for _, carrierID := range carrierIDs {
for _, op := range constants.GatewayCardOperations {
key := statKey{carrierID, op}
result[key] = make([]map[string]string, len(stamps))
for idx, stamp := range stamps {
cmds = append(cmds, pending{key: key, idx: idx, cmd: pipe.HGetAll(ctx, constants.RedisGatewayCallStatKey(carrierID, op, stamp))})
}
}
}
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "读取网关调用统计失败")
}
for _, item := range cmds {
values, err := item.cmd.Result()
if err == nil && len(values) > 0 {
result[item.key][item.idx] = values
}
}
return result, nil
}
// aggregateOperation 汇总一个操作在若干分钟内的计数。
func aggregateOperation(op string, minutes []map[string]string, minuteCount int) dto.GatewayOperationStat {
stat := dto.GatewayOperationStat{Operation: op, OperationName: constants.GatewayOperationName(op), Failures: map[string]int64{}, Exempt: map[string]int64{}}
buckets := map[string]int64{}
var durationSum int64
for _, values := range minutes {
var minuteAttempts int64
for field, raw := range values {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
continue
}
switch {
case field == statAttempts:
stat.Attempts += value
minuteAttempts = value
case field == statDurationSum:
durationSum += value
case field == statWouldLimit:
stat.WouldLimit += value
case field == constants.GatewayErrorRateLimited:
stat.RateLimited += value
case field == constants.GatewayResultSuccess:
stat.Success += value
case strings.HasPrefix(field, statPrefixDur):
buckets[strings.TrimPrefix(field, statPrefixDur)] += value
case strings.HasPrefix(field, statPrefixExem):
stat.Exempt[strings.TrimPrefix(field, statPrefixExem)] += value
default:
stat.Failures[field] += value
}
}
if qps := float64(minuteAttempts) / 60; qps > stat.PeakMinuteQPS {
stat.PeakMinuteQPS = qps
}
}
if minuteCount > 0 {
stat.AvgQPS = float64(stat.Attempts) / float64(minuteCount*60)
}
if stat.Attempts > 0 {
stat.SuccessRate = float64(stat.Success) / float64(stat.Attempts)
stat.AvgDurationMS = float64(durationSum) / float64(stat.Attempts)
stat.P95DurationMS = percentileBucket(buckets, stat.Attempts, 0.95)
}
return stat
}
// percentileBucket 返回累计占比达到 p 的分桶上界;落在最后一个分桶时返回 -1。
func percentileBucket(buckets map[string]int64, total int64, p float64) int64 {
threshold := int64(float64(total)*p + 0.5)
var cumulative int64
for _, upper := range statDurationBuckets {
cumulative += buckets[upper]
if cumulative >= threshold {
if upper == "inf" {
return -1
}
value, _ := strconv.ParseInt(upper, 10, 64)
return value
}
}
return -1
}
func shanghaiLocation() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return location
}
func stopResumeActionName(action string) string {
if action == constants.StopResumeActionStart {
return "复机"
}
return "停机"
}
func errorCategoryName(category string) string {
switch category {
case "":
return ""
case constants.GatewayErrorNetwork:
return "网络错误"
case constants.GatewayErrorTimeout:
return "请求超时"
case constants.GatewayErrorHTTP:
return "HTTP 异常"
case constants.GatewayErrorInvalidResponse:
return "响应无效"
default:
return constants.GatewayErrorClassName(category)
}
}
// PollingOptions 返回轮询与网关限流相关的前端枚举,前端不写死取值。
func (s *GatewayRateLimitService) PollingOptions(ctx context.Context) (*dto.PollingOptionsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
resp := &dto.PollingOptionsResponse{}
for _, op := range constants.GatewayCardOperations {
resp.GatewayOperations = append(resp.GatewayOperations, dto.PollingOptionItem{Value: op, Label: constants.GatewayOperationName(op)})
}
for _, class := range constants.GatewayErrorClasses {
resp.ErrorCategories = append(resp.ErrorCategories, dto.PollingOptionItem{Value: class, Label: constants.GatewayErrorClassName(class)})
}
resp.MatchTypes = []dto.PollingOptionItem{{Value: constants.GatewayMatchContains, Label: "包含"}, {Value: constants.GatewayMatchPrefix, Label: "前缀"}, {Value: constants.GatewayMatchRegex, Label: "正则"}}
resp.RateLimitModes = []dto.PollingOptionItem{{Value: constants.RateLimitModeObserve, Label: "只统计不拦截"}, {Value: constants.RateLimitModeEnforce, Label: "拦截"}}
resp.ExemptSources = []dto.PollingOptionItem{
{Value: constants.RateLimitExemptManualClick, Label: "后台人工点击"}, {Value: constants.RateLimitExemptChannelThreshold, Label: "通道阈值"},
{Value: constants.RateLimitExemptAutoRenewal, Label: "自动续费"}, {Value: constants.RateLimitExemptManualRefresh, Label: "手动刷新"},
}
resp.StopResumeResults = []dto.PollingOptionItem{
{Value: constants.StopResumeRecordSuccess, Label: "成功"}, {Value: constants.StopResumeRecordFailed, Label: "失败"},
{Value: constants.StopResumeRecordUnknown, Label: "结果未知"}, {Value: constants.StopResumeRecordAlreadyTarget, Label: "已是目标状态"},
{Value: constants.StopResumeRecordSkipped, Label: "跳过"},
}
resp.StopResumeActions = []dto.PollingOptionItem{{Value: constants.StopResumeActionStop, Label: "停机"}, {Value: constants.StopResumeActionStart, Label: "复机"}}
return resp, nil
}

View File

@@ -0,0 +1,638 @@
package polling
import (
"context"
"regexp"
"sort"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"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/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// GatewayRateLimitService 维护运营商账户网关限流组与上游错误分类规则(后台配置,简单写事务脚本)。
// 读取仅超级管理员与平台用户可用;修改仅超级管理员可用,并在同一事务写统一审计。
type GatewayRateLimitService struct {
db *gorm.DB
auditWriter *auditinfra.Writer
groups *postgres.CarrierRateLimitGroupStore
rules *postgres.GatewayErrorClassificationStore
runtime *gatewaylimit.Runtime
}
// NewGatewayRateLimitService 创建网关限流配置服务。
func NewGatewayRateLimitService(db *gorm.DB, auditWriter *auditinfra.Writer, runtime *gatewaylimit.Runtime) *GatewayRateLimitService {
return &GatewayRateLimitService{
db: db, auditWriter: auditWriter, runtime: runtime,
groups: postgres.NewCarrierRateLimitGroupStore(db), rules: postgres.NewGatewayErrorClassificationStore(db),
}
}
// requirePlatformReader 校验读取权限:超级管理员或平台用户。
func requirePlatformReader(ctx context.Context) error {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return nil
default:
return errors.New(errors.CodeForbidden, "仅平台账号可查看网关限流配置")
}
}
// requireSuperAdminWriter 校验修改权限:仅超级管理员,返回操作者 ID。
func requireSuperAdminWriter(ctx context.Context) (uint, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return 0, errors.New(errors.CodeForbidden, "仅超级管理员可修改网关限流配置")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized, "未授权访问")
}
return operatorID, nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
// ListGroups 分页查询限流组。
func (s *GatewayRateLimitService) ListGroups(ctx context.Context, req *dto.RateLimitGroupListRequest) (*dto.RateLimitGroupPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
page, size := normalizePage(req.Page, req.PageSize)
rows, total, err := s.groups.List(ctx, postgres.RateLimitGroupFilter{CarrierID: req.CarrierID, OnlyGlobal: req.OnlyGlobal, Status: req.Status, Page: page, PageSize: size})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败")
}
names, err := s.carrierNames(ctx)
if err != nil {
return nil, err
}
items := make([]dto.RateLimitGroupResponse, 0, len(rows))
for i := range rows {
items = append(items, toRateLimitGroupResponse(&rows[i], names))
}
return &dto.RateLimitGroupPageResult{List: items, Total: total, Page: page, PageSize: size}, nil
}
// GetGroup 查询限流组详情。
func (s *GatewayRateLimitService) GetGroup(ctx context.Context, id uint) (*dto.RateLimitGroupResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
row, err := s.loadGroup(ctx, id)
if err != nil {
return nil, err
}
names, err := s.carrierNames(ctx)
if err != nil {
return nil, err
}
resp := toRateLimitGroupResponse(row, names)
return &resp, nil
}
// CreateGroup 新建限流组。
func (s *GatewayRateLimitService) CreateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest) (*dto.RateLimitGroupResponse, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
if err := s.validateGroup(ctx, req, 0); err != nil {
return nil, err
}
row := &model.CarrierRateLimitGroup{CarrierID: req.CarrierID, Status: constants.RateLimitGroupStatusEnabled}
applyGroupRequest(row, req)
row.Creator, row.Updater = operatorID, operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(row).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "新建网关限流组失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupCreated, "创建网关限流组", row, operatorID, nil))
})
if err != nil {
return nil, err
}
s.invalidateGroups()
return s.GetGroup(ctx, row.ID)
}
// UpdateGroup 修改限流组。
func (s *GatewayRateLimitService) UpdateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest) (*dto.RateLimitGroupResponse, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
row, err := s.loadGroup(ctx, req.ID)
if err != nil {
return nil, err
}
if !sameCarrier(row.CarrierID, req.CarrierID) {
return nil, errors.New(errors.CodeInvalidParam, "限流组所属运营商账户不可修改,请删除后重新创建")
}
if err := s.validateGroup(ctx, req, row.ID); err != nil {
return nil, err
}
before := groupState(row)
applyGroupRequest(row, req)
row.Updater = operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Save(row).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "修改网关限流组失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupUpdated, "更新网关限流组", row, operatorID, before))
})
if err != nil {
return nil, err
}
s.invalidateGroups()
return s.GetGroup(ctx, row.ID)
}
// UpdateGroupStatus 启停限流组;停用后组内操作回落为默认每秒请求上限。
func (s *GatewayRateLimitService) UpdateGroupStatus(ctx context.Context, id uint, status int) error {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return err
}
row, err := s.loadGroup(ctx, id)
if err != nil {
return err
}
if status == constants.RateLimitGroupStatusEnabled {
ops, _ := gatewaylimit.DecodeOperations(row.Operations)
if err := s.checkOperationsFree(ctx, row.CarrierID, ops, row.ID); err != nil {
return err
}
}
before := groupState(row)
row.Status, row.Updater = status, operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&model.CarrierRateLimitGroup{}).Where("id = ?", id).
Updates(map[string]any{"status": status, "updater": operatorID}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新网关限流组状态失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupStatusUpdated, "更新网关限流组状态", row, operatorID, before))
})
if err != nil {
return err
}
s.invalidateGroups()
return nil
}
// DeleteGroup 删除限流组;组内操作回落为默认每秒请求上限。
func (s *GatewayRateLimitService) DeleteGroup(ctx context.Context, id uint) error {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return err
}
row, err := s.loadGroup(ctx, id)
if err != nil {
return err
}
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Delete(&model.CarrierRateLimitGroup{}, id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除网关限流组失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, groupAudit(constants.AuditActionRateLimitGroupDeleted, "删除网关限流组", row, operatorID, groupState(row)))
})
if err != nil {
return err
}
s.invalidateGroups()
return nil
}
func (s *GatewayRateLimitService) loadGroup(ctx context.Context, id uint) (*model.CarrierRateLimitGroup, error) {
row, err := s.groups.Get(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败")
}
if row == nil {
return nil, errors.New(errors.CodeNotFound, "网关限流组不存在")
}
return row, nil
}
// validateGroup 校验预留额度、高优先级操作归属、同账户同操作只属于一个启用组、组标识唯一。
func (s *GatewayRateLimitService) validateGroup(ctx context.Context, req *dto.SaveRateLimitGroupRequest, selfID uint) error {
if req.ReservedQPS >= req.MaxQPS {
return errors.New(errors.CodeInvalidParam, "预留额度必须小于每秒请求上限")
}
ops := uniqueStrings(req.Operations)
opSet := map[string]bool{}
for _, op := range ops {
opSet[op] = true
}
for _, op := range req.HighPriorityOperations {
if !opSet[op] {
return errors.New(errors.CodeInvalidParam, "高优先级操作必须属于组内操作:"+constants.GatewayOperationName(op))
}
}
if req.ReservedQPS > 0 && len(req.HighPriorityOperations) == 0 {
return errors.New(errors.CodeInvalidParam, "设置预留额度时必须指定高优先级操作")
}
if req.CarrierID != nil {
var count int64
if err := s.db.WithContext(ctx).Model(&model.Carrier{}).Where("id = ?", *req.CarrierID).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败")
}
if count == 0 {
return errors.New(errors.CodeInvalidParam, "运营商账户不存在")
}
}
existing, err := s.groups.ListByCarrier(ctx, req.CarrierID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败")
}
for _, row := range existing {
if row.ID != selfID && row.GroupCode == req.GroupCode {
return errors.New(errors.CodeInvalidParam, "同一运营商账户内组标识已存在")
}
}
if req.Status == nil || *req.Status == constants.RateLimitGroupStatusEnabled {
return s.checkOperationsFree(ctx, req.CarrierID, ops, selfID)
}
return nil
}
// checkOperationsFree 校验操作未被同账户其他启用组占用。
func (s *GatewayRateLimitService) checkOperationsFree(ctx context.Context, carrierID *uint, ops []string, selfID uint) error {
existing, err := s.groups.ListByCarrier(ctx, carrierID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询网关限流组失败")
}
for _, row := range existing {
if row.ID == selfID || row.Status != constants.RateLimitGroupStatusEnabled {
continue
}
used, _ := gatewaylimit.DecodeOperations(row.Operations)
for _, op := range used {
for _, want := range ops {
if op == want {
return errors.New(errors.CodeInvalidParam, "操作「"+constants.GatewayOperationName(op)+"」已属于限流组「"+row.GroupName+"」,同一账户下每个操作只能属于一个启用的组")
}
}
}
}
return nil
}
func applyGroupRequest(row *model.CarrierRateLimitGroup, req *dto.SaveRateLimitGroupRequest) {
row.GroupCode = strings.TrimSpace(req.GroupCode)
row.GroupName = strings.TrimSpace(req.GroupName)
row.MaxQPS = req.MaxQPS
row.ReservedQPS = req.ReservedQPS
row.Operations = encodeStrings(uniqueStrings(req.Operations))
row.HighPriorityOperations = encodeStrings(uniqueStrings(req.HighPriorityOperations))
if req.Status != nil {
row.Status = *req.Status
}
}
func groupState(row *model.CarrierRateLimitGroup) map[string]any {
ops, _ := gatewaylimit.DecodeOperations(row.Operations)
high, _ := gatewaylimit.DecodeOperations(row.HighPriorityOperations)
return map[string]any{"group_code": row.GroupCode, "group_name": row.GroupName, "max_qps": row.MaxQPS,
"reserved_qps": row.ReservedQPS, "operations": ops, "high_priority_operations": high, "status": row.Status}
}
func groupAudit(action, summary string, row *model.CarrierRateLimitGroup, operatorID uint, before map[string]any) auditinfra.PollingInput {
var carrierID any
if row.CarrierID != nil {
carrierID = *row.CarrierID
}
return auditinfra.PollingInput{
ActionCode: action, Summary: summary, ResourceType: constants.AuditResourceRateLimitGroup,
ResourceID: row.ID, ResourceKey: "rate_limit_group:" + uintString(row.ID), DisplayName: row.GroupName,
OperatorID: operatorID,
IdentitySnapshot: map[string]any{"id": row.ID, "carrier_id": carrierID, "group_code": row.GroupCode,
"group_name": row.GroupName, "max_qps": row.MaxQPS, "reserved_qps": row.ReservedQPS, "status": row.Status},
BeforeData: before, AfterData: groupState(row),
}
}
func toRateLimitGroupResponse(row *model.CarrierRateLimitGroup, names map[uint]string) dto.RateLimitGroupResponse {
ops, _ := gatewaylimit.DecodeOperations(row.Operations)
high, _ := gatewaylimit.DecodeOperations(row.HighPriorityOperations)
resp := dto.RateLimitGroupResponse{
ID: row.ID, CarrierID: row.CarrierID, GroupCode: row.GroupCode, GroupName: row.GroupName, MaxQPS: row.MaxQPS,
Operations: nonNil(ops), HighPriorityOperations: nonNil(high), ReservedQPS: row.ReservedQPS,
Status: row.Status, StatusName: enabledName(row.Status), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
if row.CarrierID != nil {
resp.CarrierName = names[*row.CarrierID]
}
return resp
}
// ListRules 分页查询上游错误分类规则。
func (s *GatewayRateLimitService) ListRules(ctx context.Context, req *dto.GatewayErrorRuleListRequest) (*dto.GatewayErrorRulePageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
page, size := normalizePage(req.Page, req.PageSize)
rows, total, err := s.rules.List(ctx, postgres.ErrorClassificationFilter{Category: req.Category, Status: req.Status, Keyword: strings.TrimSpace(req.Keyword), Page: page, PageSize: size})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败")
}
items := make([]dto.GatewayErrorRuleResponse, 0, len(rows))
for i := range rows {
items = append(items, toRuleResponse(&rows[i]))
}
return &dto.GatewayErrorRulePageResult{List: items, Total: total, Page: page, PageSize: size}, nil
}
// CreateRule 新建上游错误分类规则。
func (s *GatewayRateLimitService) CreateRule(ctx context.Context, req *dto.SaveGatewayErrorRuleRequest) (*dto.GatewayErrorRuleResponse, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
if err := validateRule(req); err != nil {
return nil, err
}
row := &model.GatewayErrorClassification{Status: constants.RateLimitGroupStatusEnabled}
applyRuleRequest(row, req)
row.Creator, row.Updater = operatorID, operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(row).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "新建上游错误分类规则失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleCreated, "创建上游错误分类规则", row, operatorID, nil))
})
if err != nil {
return nil, err
}
s.invalidateRules()
resp := toRuleResponse(row)
return &resp, nil
}
// UpdateRule 修改上游错误分类规则。
func (s *GatewayRateLimitService) UpdateRule(ctx context.Context, req *dto.SaveGatewayErrorRuleRequest) (*dto.GatewayErrorRuleResponse, error) {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return nil, err
}
if err := validateRule(req); err != nil {
return nil, err
}
row, err := s.loadRule(ctx, req.ID)
if err != nil {
return nil, err
}
before := ruleState(row)
applyRuleRequest(row, req)
row.Updater = operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Save(row).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "修改上游错误分类规则失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleUpdated, "更新上游错误分类规则", row, operatorID, before))
})
if err != nil {
return nil, err
}
s.invalidateRules()
resp := toRuleResponse(row)
return &resp, nil
}
// UpdateRuleStatus 启停上游错误分类规则。
func (s *GatewayRateLimitService) UpdateRuleStatus(ctx context.Context, id uint, status int) error {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return err
}
row, err := s.loadRule(ctx, id)
if err != nil {
return err
}
before := ruleState(row)
row.Status, row.Updater = status, operatorID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Model(&model.GatewayErrorClassification{}).Where("id = ?", id).
Updates(map[string]any{"status": status, "updater": operatorID}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新上游错误分类规则状态失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleStatusUpdated, "更新上游错误分类规则状态", row, operatorID, before))
})
if err != nil {
return err
}
s.invalidateRules()
return nil
}
// DeleteRule 删除上游错误分类规则。
func (s *GatewayRateLimitService) DeleteRule(ctx context.Context, id uint) error {
operatorID, err := requireSuperAdminWriter(ctx)
if err != nil {
return err
}
row, err := s.loadRule(ctx, id)
if err != nil {
return err
}
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Delete(&model.GatewayErrorClassification{}, id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除上游错误分类规则失败")
}
return writePollingAudit(ctx, tx, s.auditWriter, ruleAudit(constants.AuditActionGatewayErrorRuleDeleted, "删除上游错误分类规则", row, operatorID, ruleState(row)))
})
if err != nil {
return err
}
s.invalidateRules()
return nil
}
// TestRule 用已启用规则(可附带一条未保存的草稿)对上游返回信息分类,不写任何数据。
func (s *GatewayRateLimitService) TestRule(ctx context.Context, req *dto.TestGatewayErrorRuleRequest) (*dto.TestGatewayErrorRuleResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
rows, err := s.rules.ListEnabled(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败")
}
if req.Draft != nil {
if err := validateRule(req.Draft); err != nil {
return nil, err
}
draft := model.GatewayErrorClassification{}
applyRuleRequest(&draft, req.Draft)
rows = append(rows, draft)
sort.SliceStable(rows, func(i, j int) bool { return rows[i].Priority < rows[j].Priority })
}
result := gatewaylimit.ClassifyWith(rows, req.Operation, req.Message)
matched := result.Keyword != ""
return &dto.TestGatewayErrorRuleResponse{
Category: result.Category, CategoryName: constants.GatewayErrorClassName(result.Category),
RuleID: result.RuleID, Keyword: result.Keyword, MaybeAccepted: result.MaybeAccepted, Matched: matched,
}, nil
}
func (s *GatewayRateLimitService) loadRule(ctx context.Context, id uint) (*model.GatewayErrorClassification, error) {
row, err := s.rules.Get(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上游错误分类规则失败")
}
if row == nil {
return nil, errors.New(errors.CodeNotFound, "上游错误分类规则不存在")
}
return row, nil
}
func validateRule(req *dto.SaveGatewayErrorRuleRequest) error {
if strings.TrimSpace(req.Keyword) == "" {
return errors.New(errors.CodeInvalidParam, "匹配关键字不能为空")
}
if req.MatchType == constants.GatewayMatchRegex {
if _, err := regexp.Compile(req.Keyword); err != nil {
return errors.New(errors.CodeInvalidParam, "正则表达式不合法")
}
}
return nil
}
func applyRuleRequest(row *model.GatewayErrorClassification, req *dto.SaveGatewayErrorRuleRequest) {
row.Keyword = strings.TrimSpace(req.Keyword)
row.MatchType = req.MatchType
row.Category = req.Category
row.Operations = encodeStrings(uniqueStrings(req.Operations))
row.Priority = req.Priority
if row.Priority == 0 {
row.Priority = 100
}
row.MaybeAccepted = req.MaybeAccepted
row.Remark = strings.TrimSpace(req.Remark)
if req.Status != nil {
row.Status = *req.Status
}
}
func ruleState(row *model.GatewayErrorClassification) map[string]any {
ops, _ := gatewaylimit.DecodeOperations(row.Operations)
return map[string]any{"keyword": row.Keyword, "match_type": row.MatchType, "category": row.Category,
"operations": ops, "priority": row.Priority, "maybe_accepted": row.MaybeAccepted, "status": row.Status}
}
func ruleAudit(action, summary string, row *model.GatewayErrorClassification, operatorID uint, before map[string]any) auditinfra.PollingInput {
return auditinfra.PollingInput{
ActionCode: action, Summary: summary, ResourceType: constants.AuditResourceGatewayErrorRule,
ResourceID: row.ID, ResourceKey: "gateway_error_rule:" + uintString(row.ID), DisplayName: row.Keyword,
OperatorID: operatorID,
IdentitySnapshot: map[string]any{"id": row.ID, "keyword": row.Keyword, "match_type": row.MatchType,
"category": row.Category, "priority": row.Priority, "status": row.Status},
BeforeData: before, AfterData: ruleState(row),
}
}
func toRuleResponse(row *model.GatewayErrorClassification) dto.GatewayErrorRuleResponse {
ops, _ := gatewaylimit.DecodeOperations(row.Operations)
return dto.GatewayErrorRuleResponse{
ID: row.ID, Keyword: row.Keyword, MatchType: row.MatchType, Category: row.Category,
CategoryName: constants.GatewayErrorClassName(row.Category), Operations: nonNil(ops), Priority: row.Priority,
MaybeAccepted: row.MaybeAccepted, Status: row.Status, Remark: row.Remark, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
}
}
// carrierNames 返回运营商账户 ID → 名称映射(含已删除账户,便于展示历史配置)。
func (s *GatewayRateLimitService) carrierNames(ctx context.Context) (map[uint]string, error) {
var carriers []model.Carrier
if err := s.db.WithContext(ctx).Unscoped().Select("id", "carrier_name").Find(&carriers).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败")
}
names := make(map[uint]string, len(carriers))
for _, carrier := range carriers {
names[carrier.ID] = carrier.CarrierName
}
return names, nil
}
func sameCarrier(a, b *uint) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func encodeStrings(values []string) datatypes.JSON {
if values == nil {
values = []string{}
}
raw, err := sonic.Marshal(values)
if err != nil {
return datatypes.JSON("[]")
}
return datatypes.JSON(raw)
}
func nonNil(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func enabledName(status int) string {
if status == constants.RateLimitGroupStatusEnabled {
return "启用"
}
return "停用"
}
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// invalidateGroups 让本进程限流组缓存立即失效;其他进程在缓存时长(数秒)内自动刷新。
func (s *GatewayRateLimitService) invalidateGroups() {
if s.runtime != nil {
s.invalidateGroups()
}
}
// invalidateRules 让本进程错误分类规则缓存立即失效。
func (s *GatewayRateLimitService) invalidateRules() {
if s.runtime != nil {
s.invalidateRules()
}
}

View File

@@ -0,0 +1,258 @@
package postgres
import (
"context"
stderrors "errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// CarrierRateLimitGroupStore 运营商账户网关限流组存储。
type CarrierRateLimitGroupStore struct {
db *gorm.DB
}
// NewCarrierRateLimitGroupStore 创建限流组存储。
func NewCarrierRateLimitGroupStore(db *gorm.DB) *CarrierRateLimitGroupStore {
return &CarrierRateLimitGroupStore{db: db}
}
// RateLimitGroupFilter 是限流组列表筛选条件;CarrierID 为 nil 不限,OnlyGlobal 只返回全局组。
type RateLimitGroupFilter struct {
CarrierID *uint
OnlyGlobal bool
Status *int
Page int
PageSize int
}
// List 分页查询限流组。
func (s *CarrierRateLimitGroupStore) List(ctx context.Context, filter RateLimitGroupFilter) ([]model.CarrierRateLimitGroup, int64, error) {
query := s.db.WithContext(ctx).Model(&model.CarrierRateLimitGroup{})
if filter.OnlyGlobal {
query = query.Where("carrier_id IS NULL")
} else if filter.CarrierID != nil {
query = query.Where("carrier_id = ?", *filter.CarrierID)
}
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.CarrierRateLimitGroup
err := query.Order("carrier_id NULLS FIRST, id").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error
return rows, total, err
}
// ListEnabled 返回全部启用的限流组,供限流器加载。
func (s *CarrierRateLimitGroupStore) ListEnabled(ctx context.Context) ([]model.CarrierRateLimitGroup, error) {
var rows []model.CarrierRateLimitGroup
err := s.db.WithContext(ctx).Where("status = ?", 1).Order("id").Find(&rows).Error
return rows, err
}
// ListByCarrier 返回同一账户(nil 为全局组)下的全部未删除组,供保存时校验操作归属。
func (s *CarrierRateLimitGroupStore) ListByCarrier(ctx context.Context, carrierID *uint) ([]model.CarrierRateLimitGroup, error) {
query := s.db.WithContext(ctx)
if carrierID == nil {
query = query.Where("carrier_id IS NULL")
} else {
query = query.Where("carrier_id = ?", *carrierID)
}
var rows []model.CarrierRateLimitGroup
err := query.Find(&rows).Error
return rows, err
}
// Get 按 ID 查询限流组;不存在返回 nil、nil。
func (s *CarrierRateLimitGroupStore) Get(ctx context.Context, id uint) (*model.CarrierRateLimitGroup, error) {
var row model.CarrierRateLimitGroup
err := s.db.WithContext(ctx).First(&row, id).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Create 新建限流组。
func (s *CarrierRateLimitGroupStore) Create(ctx context.Context, row *model.CarrierRateLimitGroup) error {
return s.db.WithContext(ctx).Create(row).Error
}
// Save 保存限流组全部字段。
func (s *CarrierRateLimitGroupStore) Save(ctx context.Context, row *model.CarrierRateLimitGroup) error {
return s.db.WithContext(ctx).Save(row).Error
}
// Delete 软删除限流组。
func (s *CarrierRateLimitGroupStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.CarrierRateLimitGroup{}, id).Error
}
// GatewayErrorClassificationStore 上游错误分类规则存储。
type GatewayErrorClassificationStore struct {
db *gorm.DB
}
// NewGatewayErrorClassificationStore 创建错误分类规则存储。
func NewGatewayErrorClassificationStore(db *gorm.DB) *GatewayErrorClassificationStore {
return &GatewayErrorClassificationStore{db: db}
}
// ErrorClassificationFilter 是错误分类规则列表筛选条件。
type ErrorClassificationFilter struct {
Category string
Status *int
Keyword string
Page int
PageSize int
}
// List 分页查询错误分类规则。
func (s *GatewayErrorClassificationStore) List(ctx context.Context, filter ErrorClassificationFilter) ([]model.GatewayErrorClassification, int64, error) {
query := s.db.WithContext(ctx).Model(&model.GatewayErrorClassification{})
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
if filter.Keyword != "" {
query = query.Where("keyword LIKE ?", "%"+filter.Keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.GatewayErrorClassification
err := query.Order("priority, id").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error
return rows, total, err
}
// ListEnabled 返回全部启用规则(按优先级),供分类器加载。
func (s *GatewayErrorClassificationStore) ListEnabled(ctx context.Context) ([]model.GatewayErrorClassification, error) {
var rows []model.GatewayErrorClassification
err := s.db.WithContext(ctx).Where("status = ?", 1).Order("priority, id").Find(&rows).Error
return rows, err
}
// Get 按 ID 查询规则;不存在返回 nil、nil。
func (s *GatewayErrorClassificationStore) Get(ctx context.Context, id uint) (*model.GatewayErrorClassification, error) {
var row model.GatewayErrorClassification
err := s.db.WithContext(ctx).First(&row, id).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Create 新建规则。
func (s *GatewayErrorClassificationStore) Create(ctx context.Context, row *model.GatewayErrorClassification) error {
return s.db.WithContext(ctx).Create(row).Error
}
// Save 保存规则全部字段。
func (s *GatewayErrorClassificationStore) Save(ctx context.Context, row *model.GatewayErrorClassification) error {
return s.db.WithContext(ctx).Save(row).Error
}
// Delete 软删除规则。
func (s *GatewayErrorClassificationStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.GatewayErrorClassification{}, id).Error
}
// CardStopResumeRecordStore 停复机调用记录存储(分区表)。
type CardStopResumeRecordStore struct {
db *gorm.DB
}
// NewCardStopResumeRecordStore 创建停复机记录存储。
func NewCardStopResumeRecordStore(db *gorm.DB) *CardStopResumeRecordStore {
return &CardStopResumeRecordStore{db: db}
}
// StopResumeRecordFilter 是停复机记录列表筛选条件;时间范围由调用方保证必填且有上限。
type StopResumeRecordFilter struct {
CardID *uint
ICCID string
CarrierID *uint
Action string
Source string
Result string
ErrorCategory string
StartTime time.Time
EndTime time.Time
Page int
PageSize int
}
// Create 写入一条停复机记录。
func (s *CardStopResumeRecordStore) Create(ctx context.Context, row *model.CardStopResumeRecord) error {
return s.db.WithContext(ctx).Create(row).Error
}
// List 按条件分页查询停复机记录,按时间倒序。
func (s *CardStopResumeRecordStore) List(ctx context.Context, filter StopResumeRecordFilter) ([]model.CardStopResumeRecord, int64, error) {
query := s.db.WithContext(ctx).Model(&model.CardStopResumeRecord{}).
Where("created_at >= ? AND created_at < ?", filter.StartTime, filter.EndTime)
if filter.CardID != nil {
query = query.Where("card_id = ?", *filter.CardID)
}
if filter.ICCID != "" {
query = query.Where("iccid = ?", filter.ICCID)
}
if filter.CarrierID != nil {
query = query.Where("carrier_id = ?", *filter.CarrierID)
}
if filter.Action != "" {
query = query.Where("action = ?", filter.Action)
}
if filter.Source != "" {
query = query.Where("source = ?", filter.Source)
}
if filter.Result != "" {
query = query.Where("result = ?", filter.Result)
}
if filter.ErrorCategory != "" {
query = query.Where("error_category = ?", filter.ErrorCategory)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.CardStopResumeRecord
err := query.Order("created_at DESC, id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error
return rows, total, err
}
// stopResumeRecordPartitionName 返回某月分区表名(上海时区月份)。
func stopResumeRecordPartitionName(month time.Time) string {
return "tb_card_stop_resume_record_" + month.Format("200601")
}
// EnsureMonthPartition 创建 month 所在月的分区(已存在则跳过);month 须为上海时区月初。
func (s *CardStopResumeRecordStore) EnsureMonthPartition(ctx context.Context, month time.Time) error {
next := month.AddDate(0, 1, 0)
sql := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s PARTITION OF tb_card_stop_resume_record FOR VALUES FROM ('%s') TO ('%s')",
stopResumeRecordPartitionName(month), month.Format(time.RFC3339), next.Format(time.RFC3339))
return s.db.WithContext(ctx).Exec(sql).Error
}
// DropMonthPartition 删除 month 所在月的分区(不存在则跳过),用于按保留期清理。
func (s *CardStopResumeRecordStore) DropMonthPartition(ctx context.Context, month time.Time) error {
sql := fmt.Sprintf("DROP TABLE IF EXISTS %s", stopResumeRecordPartitionName(month))
return s.db.WithContext(ctx).Exec(sql).Error
}

View File

@@ -70,6 +70,24 @@ type PollingBase struct {
priorityStore *postgres.PollingPriorityItemStore
priorityAudit *audit.Writer
scopeChecker *polling.PollingLifecycleService
rateLimitedDelay func() time.Duration // 卡网关调用被运营商账户限流后的固定重入队延迟
}
// SetRateLimitedDelay 设置被运营商账户限流后的重入队延迟来源(受控系统配置,即时生效)。
func (b *PollingBase) SetRateLimitedDelay(delay func() time.Duration) {
b.rateLimitedDelay = delay
}
// requeueRateLimited 卡网关调用被运营商账户限流拒绝时请求并未发出:不计失败、不写失败统计与失败审计,
// 按固定短延迟重入队,不在任务内等待名额(避免占满与其他业务共用的 Asynq 协程)。
func (b *PollingBase) requeueRateLimited(cardID uint, taskType string) error {
delay := 5 * time.Second
if b.rateLimitedDelay != nil {
if configured := b.rateLimitedDelay(); configured > 0 {
delay = configured
}
}
return b.requeueCardAt(cardID, taskType, time.Now().Add(delay))
}
// NewPollingBase 创建轮询共享基类

View File

@@ -82,7 +82,11 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
result, err := h.gateway.QueryFlow(gateway.WithCarrier(ctx, card.CarrierID), &gateway.FlowQueryReq{CardNo: card.ICCID})
if gateway.IsRateLimited(err) {
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return h.base.requeueRateLimited(cardID, constants.TaskTypePollingCarddata)
}
if err != nil {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority)
priority.FailRetryable(ctx, "查询流量失败")

View File

@@ -82,7 +82,11 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
result, err := h.gateway.QueryCardStatus(gateway.WithCarrier(ctx, card.CarrierID), &gateway.CardStatusReq{CardNo: card.ICCID})
if gateway.IsRateLimited(err) {
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return h.base.requeueRateLimited(cardID, constants.TaskTypePollingCardStatus)
}
if err != nil {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败", priority)
priority.FailRetryable(ctx, "查询卡状态失败")

View File

@@ -39,9 +39,9 @@ func recordGatewayAttempt(ctx context.Context, repository *integrationlog.Reposi
return err
}
// recordPriorityGatewayAttempt 记录外部交互日志,并把真正落库的日志标识回写优先项。
// 成功且无变化的轮询按既有语义不落库,因此只有落库成功才回写标识,避免悬挂引用;
// 其余调用方(无优先项在途)继续使用 recordGatewayAttempt。
// recordPriorityGatewayAttempt 只在本轮同时是一次优先执行时写外部交互日志,并把日志标识回写优先项。
// 普通轮询查询(无优先项在途)不再写集成日志:失败原因与调用量改由网关调用分钟计数与卡最近错误保留
// (add-carrier-gateway-rate-limit);优先执行仍按优先轮询可追溯要求保留日志引用。
func recordPriorityGatewayAttempt(
ctx context.Context,
repository *integrationlog.Repository,
@@ -51,6 +51,9 @@ func recordPriorityGatewayAttempt(
summary string,
round *pollingPriorityRound,
) error {
if round == nil {
return nil
}
if err := recordGatewayAttempt(ctx, repository, attempt, result, changed, summary); err != nil {
return err
}

View File

@@ -73,7 +73,11 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
result, err := h.gateway.QueryRealnameStatus(gateway.WithCarrier(ctx, card.CarrierID), &gateway.CardStatusReq{CardNo: card.ICCID})
if gateway.IsRateLimited(err) {
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return h.base.requeueRateLimited(cardID, constants.TaskTypePollingRealname)
}
if err != nil {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败", priority)
priority.FailRetryable(ctx, "查询实名状态失败")

View File

@@ -0,0 +1,6 @@
-- 回滚运营商账户网关限流组:删除 tb_carrier_rate_limit_group,与 up 严格倒序成对。
-- 删除后限流器对全部账户回落默认 QPS(只统计模式下无行为影响)。
DROP INDEX IF EXISTS idx_carrier_rate_limit_group_carrier;
DROP INDEX IF EXISTS uk_carrier_rate_limit_group_code;
DROP TABLE IF EXISTS tb_carrier_rate_limit_group;

View File

@@ -0,0 +1,44 @@
-- 运营商账户网关限流组(add-carrier-gateway-rate-limit)。
-- 背景:上游按运营商账户(tb_carrier 一行即一个上游账户)限频,现有轮询并发计数不以账户为单位且数值无依据。
-- 本表按账户配置卡的 5 个网关操作(查流量 / 查状态 / 查实名 / 停机 / 复机)的每秒请求上限。
--
-- 设计选择:
-- 1. carrier_id 为空表示全局组(可选,用于聚合网关总限流);同一账户下同一操作只能属于一个组,由应用层保存时校验。
-- 2. operations / high_priority_operations 为 JSON 字符串数组,取值见 pkg/constants 网关操作常量。
-- 3. 停用(status=0)的组不参与判断,组内操作回落为默认 QPS,不存在不限流的自动调用。
-- 4. 不使用数据库外键(ENG-MODEL-001);软删除,后台维护。
CREATE TABLE tb_carrier_rate_limit_group (
id BIGSERIAL PRIMARY KEY,
carrier_id BIGINT,
group_code VARCHAR(50) NOT NULL,
group_name VARCHAR(100) NOT NULL,
max_qps INTEGER NOT NULL,
operations JSONB NOT NULL DEFAULT '[]'::jsonb,
high_priority_operations JSONB NOT NULL DEFAULT '[]'::jsonb,
reserved_qps INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
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_rate_limit_group_qps CHECK (max_qps > 0 AND reserved_qps >= 0 AND reserved_qps < max_qps),
CONSTRAINT ck_carrier_rate_limit_group_status CHECK (status IN (0, 1))
);
CREATE UNIQUE INDEX uk_carrier_rate_limit_group_code
ON tb_carrier_rate_limit_group (COALESCE(carrier_id, 0), group_code) WHERE deleted_at IS NULL;
CREATE INDEX idx_carrier_rate_limit_group_carrier ON tb_carrier_rate_limit_group (carrier_id) WHERE deleted_at IS NULL;
COMMENT ON TABLE tb_carrier_rate_limit_group IS '运营商账户网关限流组:按账户配置卡网关操作的每秒请求上限';
COMMENT ON COLUMN tb_carrier_rate_limit_group.carrier_id IS '运营商账户ID(tb_carrier.id),为空表示全局组';
COMMENT ON COLUMN tb_carrier_rate_limit_group.group_code IS '组标识,同一账户内唯一';
COMMENT ON COLUMN tb_carrier_rate_limit_group.group_name IS '组名称';
COMMENT ON COLUMN tb_carrier_rate_limit_group.max_qps IS '该组每秒请求上限';
COMMENT ON COLUMN tb_carrier_rate_limit_group.operations IS '组内操作:query_flow/query_status/query_realname/stop/start';
COMMENT ON COLUMN tb_carrier_rate_limit_group.high_priority_operations IS '组内高优先级操作,可用满 max_qps';
COMMENT ON COLUMN tb_carrier_rate_limit_group.reserved_qps IS '预留给高优先级操作的额度,普通操作最多使用 max_qps - reserved_qps';
COMMENT ON COLUMN tb_carrier_rate_limit_group.status IS '状态 0-停用 1-启用;停用后组内操作回落为默认 QPS';
COMMENT ON COLUMN tb_carrier_rate_limit_group.creator IS '创建人ID';
COMMENT ON COLUMN tb_carrier_rate_limit_group.updater IS '更新人ID';

View File

@@ -0,0 +1,5 @@
-- 回滚上游错误分类规则:删除 tb_gateway_error_classification(含初始规则),与 up 严格倒序成对。
-- 删除后分类器回落为"未命中规则即业务错误",后台新增的规则一并丢失。
DROP INDEX IF EXISTS idx_gateway_error_classification_active;
DROP TABLE IF EXISTS tb_gateway_error_classification;

View File

@@ -0,0 +1,61 @@
-- 上游错误分类规则(add-carrier-gateway-rate-limit)。
-- 背景:网关对各类上游错误统一返回 code=400,失败原因只拼在错误字符串里,无法区分频率限制、终态、临时故障与业务错误。
-- 本表把上游返回信息按关键字归入类别,供停复机与轮询的失败处理、告警与统计使用;后台可逐条增补。
--
-- 设计选择:
-- 1. match_type:contains(包含)/ prefix(前缀)/ regex(正则);operations 为空数组表示适用全部操作。
-- 2. priority 越小越先匹配;未命中任何规则的上游业务错误归为业务错误(business)。
-- 3. maybe_accepted 标记"上游可能已受理"(如"调用成功-5分钟内最多重复调用3次"),停复机下次执行前先查卡状态确认。
-- 4. 只有明确表示卡已处于目标状态的返回才可归入 already_target;老系统结束停机任务的关键字("已存在""密码错误"等)
-- 不代表停机成功,初始数据不据此归类为 already_target。
-- 5. 初始规则来自《轮询按运营商账户限流技术方案》第三部分 6.2 节(生产 2026-10-22 实测返回)。
CREATE TABLE tb_gateway_error_classification (
id BIGSERIAL PRIMARY KEY,
keyword VARCHAR(200) NOT NULL,
match_type VARCHAR(20) NOT NULL DEFAULT 'contains',
category VARCHAR(30) NOT NULL,
operations JSONB NOT NULL DEFAULT '[]'::jsonb,
priority INTEGER NOT NULL DEFAULT 100,
maybe_accepted BOOLEAN NOT NULL DEFAULT FALSE,
status SMALLINT NOT NULL DEFAULT 1,
remark 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_gateway_error_classification_match CHECK (match_type IN ('contains', 'prefix', 'regex')),
CONSTRAINT ck_gateway_error_classification_category CHECK (category IN ('rate_limited_upstream', 'terminal', 'already_target', 'transient', 'business', 'account_config')),
CONSTRAINT ck_gateway_error_classification_status CHECK (status IN (0, 1))
);
CREATE INDEX idx_gateway_error_classification_active ON tb_gateway_error_classification (status, priority) WHERE deleted_at IS NULL;
COMMENT ON TABLE tb_gateway_error_classification IS '上游错误分类规则:按关键字把网关返回信息归入失败类别';
COMMENT ON COLUMN tb_gateway_error_classification.keyword IS '匹配关键字或正则';
COMMENT ON COLUMN tb_gateway_error_classification.match_type IS '匹配方式 contains-包含 prefix-前缀 regex-正则';
COMMENT ON COLUMN tb_gateway_error_classification.category IS '类别 rate_limited_upstream-上游频率限制 terminal-终态不可操作 already_target-已是目标状态 transient-临时故障 business-业务错误 account_config-账户配置错误';
COMMENT ON COLUMN tb_gateway_error_classification.operations IS '适用操作,空数组表示全部:query_flow/query_status/query_realname/stop/start';
COMMENT ON COLUMN tb_gateway_error_classification.priority IS '匹配优先级,越小越先匹配';
COMMENT ON COLUMN tb_gateway_error_classification.maybe_accepted IS '上游可能已受理(如返回含调用成功、操作进行中、请求被合并)';
COMMENT ON COLUMN tb_gateway_error_classification.status IS '状态 0-停用 1-启用';
COMMENT ON COLUMN tb_gateway_error_classification.remark IS '备注';
INSERT INTO tb_gateway_error_classification (keyword, match_type, category, priority, maybe_accepted, remark) VALUES
('仅允许一次', 'contains', 'rate_limited_upstream', 10, TRUE, '3分钟内同一张卡相同操作仅允许一次'),
('最多重复调用', 'contains', 'rate_limited_upstream', 10, TRUE, '调用成功-5分钟内最多重复调用3次'),
('只允许调用', 'contains', 'rate_limited_upstream', 10, TRUE, '调用成功-卡状态变更每十分钟只允许调用1次'),
('请求被合并', 'contains', 'rate_limited_upstream', 10, TRUE, '请求被合并,以最新一次为准'),
('操作进行中', 'contains', 'rate_limited_upstream', 10, TRUE, '操作进行中'),
('超频访问', 'contains', 'rate_limited_upstream', 10, FALSE, '超频访问'),
('已销户', 'contains', 'terminal', 20, FALSE, '当前用户已销户,无法操作'),
('仅支持风险复机', 'contains', 'terminal', 20, FALSE, '当前卡为风险停机状态,仅支持风险复机'),
('号码封存', 'contains', 'terminal', 20, FALSE, '用户状态是物联网号码封存,禁止办理物联网停机'),
('密码错误', 'contains', 'account_config', 30, FALSE, '账户凭证问题,影响该账户全部卡'),
('参数缺失', 'contains', 'account_config', 30, FALSE, '账户接入参数问题'),
('token 为空', 'contains', 'transient', 40, FALSE, '湖北省网获取到的 token 为空'),
('token为空', 'contains', 'transient', 40, FALSE, '获取到的 token 为空'),
('当前用户不允许办理', 'contains', 'business', 50, FALSE, '不视为已是目标状态'),
('防范实名', 'contains', 'business', 50, FALSE, '不视为已是目标状态'),
('请求失败', 'contains', 'transient', 90, FALSE, '上游通用失败');

View File

@@ -0,0 +1,7 @@
-- 回滚停复机调用记录:删除分区表 tb_card_stop_resume_record(含全部分区),与 up 严格倒序成对。
-- 不可逆说明(ENG-MIG-001 例外条件):记录删除后无法重建;只在代码已退回写统一审计、且无需追溯停复机调用时执行。
DROP INDEX IF EXISTS idx_card_stop_resume_record_result;
DROP INDEX IF EXISTS idx_card_stop_resume_record_carrier;
DROP INDEX IF EXISTS idx_card_stop_resume_record_card;
DROP TABLE IF EXISTS tb_card_stop_resume_record CASCADE;

View File

@@ -0,0 +1,71 @@
-- 停复机调用记录(add-carrier-gateway-rate-limit)。
-- 背景:系统自动停复机的上游调用此前写统一审计 tb_audit_event 与集成日志 tb_integration_log(生产约 1,500 万行、积压严重),
-- 且错误原因只在审计 error_summary 中可见。改为每次实际调用上游停机或复机写一条本表记录。
--
-- 设计选择:
-- 1. 按 created_at 月分区(声明式 RANGE 分区),主键 (id, created_at);建 DEFAULT 分区兜底,任何月份插入都不因分区缺失失败。
-- 2. 本迁移创建当前月起 4 个月的分区;之后由 Worker 定时任务提前创建未来分区,并按保留期整区删除过期分区。
-- 3. 记录写入与卡状态回写不在同一事务,写入失败只记日志与计数。
-- 4. provider_message 为脱敏截断后的上游信息;不使用数据库外键(ENG-MODEL-001)。
CREATE TABLE tb_card_stop_resume_record (
id BIGSERIAL,
card_id BIGINT NOT NULL,
iccid VARCHAR(30) NOT NULL DEFAULT '',
carrier_id BIGINT NOT NULL DEFAULT 0,
action VARCHAR(10) NOT NULL,
source VARCHAR(50) NOT NULL DEFAULT '',
scene VARCHAR(50) NOT NULL DEFAULT '',
reason VARCHAR(50) NOT NULL DEFAULT '',
command_id VARCHAR(64) NOT NULL DEFAULT '',
attempt INTEGER NOT NULL DEFAULT 1,
result VARCHAR(20) NOT NULL,
error_category VARCHAR(30) NOT NULL DEFAULT '',
provider_code VARCHAR(30) NOT NULL DEFAULT '',
provider_message VARCHAR(500) NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at),
CONSTRAINT ck_card_stop_resume_record_action CHECK (action IN ('stop', 'start')),
CONSTRAINT ck_card_stop_resume_record_result CHECK (result IN ('success', 'failed', 'unknown', 'already_target', 'skipped'))
) PARTITION BY RANGE (created_at);
CREATE TABLE tb_card_stop_resume_record_default PARTITION OF tb_card_stop_resume_record DEFAULT;
DO $$
DECLARE
month_start DATE := date_trunc('month', NOW() AT TIME ZONE 'Asia/Shanghai')::date;
i INTEGER;
from_date DATE;
to_date DATE;
BEGIN
FOR i IN 0..3 LOOP
from_date := (month_start + make_interval(months => i))::date;
to_date := (month_start + make_interval(months => i + 1))::date;
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF tb_card_stop_resume_record FOR VALUES FROM (%L) TO (%L)',
'tb_card_stop_resume_record_' || to_char(from_date, 'YYYYMM'),
(from_date::timestamp AT TIME ZONE 'Asia/Shanghai'),
(to_date::timestamp AT TIME ZONE 'Asia/Shanghai'));
END LOOP;
END $$;
CREATE INDEX idx_card_stop_resume_record_card ON tb_card_stop_resume_record (card_id, created_at);
CREATE INDEX idx_card_stop_resume_record_carrier ON tb_card_stop_resume_record (carrier_id, created_at);
CREATE INDEX idx_card_stop_resume_record_result ON tb_card_stop_resume_record (result, created_at);
COMMENT ON TABLE tb_card_stop_resume_record IS '停复机调用记录:每次实际调用上游停机或复机一条,按月分区';
COMMENT ON COLUMN tb_card_stop_resume_record.card_id IS 'IoT卡ID';
COMMENT ON COLUMN tb_card_stop_resume_record.iccid IS 'ICCID';
COMMENT ON COLUMN tb_card_stop_resume_record.carrier_id IS '卡所属运营商账户ID';
COMMENT ON COLUMN tb_card_stop_resume_record.action IS '动作 stop-停机 start-复机';
COMMENT ON COLUMN tb_card_stop_resume_record.source IS '来源:轮询、业务事件、退款、激活、重置、订单、后台人工点击、通道阈值等';
COMMENT ON COLUMN tb_card_stop_resume_record.scene IS '场景';
COMMENT ON COLUMN tb_card_stop_resume_record.reason IS '停机原因';
COMMENT ON COLUMN tb_card_stop_resume_record.command_id IS '停复机指令标识,同一指令的多次尝试归为一组';
COMMENT ON COLUMN tb_card_stop_resume_record.attempt IS '尝试序号';
COMMENT ON COLUMN tb_card_stop_resume_record.result IS '结果 success-成功 failed-失败 unknown-结果未知 already_target-已是目标状态 skipped-跳过';
COMMENT ON COLUMN tb_card_stop_resume_record.error_category IS '错误类别';
COMMENT ON COLUMN tb_card_stop_resume_record.provider_code IS '上游返回码';
COMMENT ON COLUMN tb_card_stop_resume_record.provider_message IS '上游返回信息(脱敏截断)';
COMMENT ON COLUMN tb_card_stop_resume_record.duration_ms IS '耗时(毫秒)';

View File

@@ -0,0 +1,11 @@
-- 回滚网关限流相关的受控系统配置:删除 up 插入的 6 个 Key,与 up 成对。
-- 删除后代码按注册表默认值运行(限流模式回到 observe)。
DELETE FROM tb_system_config WHERE config_key IN (
'polling.rate_limit_mode',
'polling.gateway_default_qps',
'polling.rate_limited_retry_delay_seconds',
'polling.rate_limit_defer_alert_seconds',
'polling.stop_resume_record_retention_months',
'polling.stop_resume_record_premake_months'
);

View File

@@ -0,0 +1,18 @@
-- 网关限流相关的受控系统配置(add-carrier-gateway-rate-limit),module = polling。
-- 配置的权威定义(类型、取值范围、默认值)在代码注册表中;本迁移只落库初始值,便于后台列表展示与审计。
-- 已存在同名 Key 时不覆盖(ON CONFLICT DO NOTHING),避免覆盖运营已修改的值。
--
-- 1. polling.rate_limit_mode:observe 只统计不拦截(默认)/ enforce 拦截;全局生效,不按账户覆盖。
-- 2. polling.gateway_default_qps:未被任何启用限流组覆盖的操作使用的默认每秒请求上限(暂定,上线前由维护者按 observe 数据确认)。
-- 3. polling.rate_limited_retry_delay_seconds:Asynq 任务遇限流后的固定短延迟。
-- 4. polling.rate_limit_defer_alert_seconds:同一任务因限流累计推迟超过该时长时写告警。
-- 5. polling.stop_resume_record_retention_months / polling.stop_resume_record_premake_months:停复机记录保留月数与提前建分区月数。
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description, is_readonly, is_sensitive, creator, updater, created_at, updated_at) VALUES
('polling.rate_limit_mode', 'observe', 'string', 'polling', '网关限流模式:observe 只统计不拦截,enforce 拦截', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.gateway_default_qps', '10', 'int', 'polling', '未被启用限流组覆盖的卡网关操作的默认每秒请求上限', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.rate_limited_retry_delay_seconds', '5', 'int', 'polling', 'Asynq 任务遇限流后的固定重试延迟(秒)', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.rate_limit_defer_alert_seconds', '600', 'int', 'polling', '同一任务因限流累计推迟超过该时长(秒)时写告警', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.stop_resume_record_retention_months', '6', 'int', 'polling', '停复机记录保留月数,到期整区删除', FALSE, FALSE, 0, 0, NOW(), NOW()),
('polling.stop_resume_record_premake_months', '3', 'int', 'polling', '停复机记录提前创建未来分区的月数', FALSE, FALSE, 0, 0, NOW(), NOW())
ON CONFLICT (config_key) DO NOTHING;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,73 @@
## Context
完整推导、生产数据与评审记录见 `docs/engineering/轮询按运营商账户限流技术方案.md`(第三部分第 4、6.2、7.1、7.4 节,第五部分第 3 节第 1 步)。本文只记录本 Change 的实现决策。
现状:
- `internal/gateway/client.go:141` `executeWithRetry`:一次方法调用最多 `maxRetries + 1` 次 HTTP 请求(默认 2 次重试),仅对超时、连接失败重试;每次尝试前后调用 `AttemptObserver`(`stop_resume_audit.go`、`gateway_integration.go` 已用于审计与集成日志)。
- `internal/gateway/flow_card.go`:`StopCard` / `StartCard` 用默认重试;`SetCardSpeedTier` 已设 `maxRetries = 0`。
- 业务错误只拼在错误字符串(`client.go:274`),集成日志 `provider_code`、`provider_message` 为空。
- 轮询并发:`internal/task/polling_base.go` 的全局 + 任务类型计数(本 Change 不动,第 5 个 Change 下线)。
- Asynq:`pkg/queue/server.go` 显式使用 `asynq.DefaultRetryDelayFunc`,未设 `IsFailure`;`outbox:deliver` 入队未设 `MaxRetry`(默认 25 次)。`queue.concurrency` 默认 10,轮询与 Outbox、导出、佣金共用。
- Redis 单节点;自动化测试为 N/A,验证在维护者指定的测试环境进行。
## Goals / Non-Goals
**Goals:**
- 所有自动触发的卡网关调用受运营商账户限流约束,口径为每次实际 HTTP 请求。
- 上线不造成整体变慢:先只统计,配好后整体拦截。
- 限流不导致"流量用完停机"被推迟数小时或丢失。
- 失败原因与调用量可见,停复机逐次可查。
**Non-Goals:**
- 不改轮询调度、分片、并发计数(第 5 个 Change)。
- 不改停复机业务规则与触发路径(第 4、6 个 Change);现有 `stopCardWithRetry` 的业务层 sleep 重试保留到第 6 个 Change。
- 不做批量查询;不对后台人工点击、通道阈值、自动续费、手动刷新限流。
## Decisions
1. **限流在每次 HTTP 尝试前执行**,而不是方法入口。方法入口只能扣一次,内部重试会漏计。实现为网关客户端的调用守卫 `gateway.CallGuard`(`BeforeAttempt` / `AfterAttempt`),在 `executeWithRetry` 每次尝试前、现有 `AttemptObserver` 之前调用;卡操作类型由 5 个卡方法写入 ctx。没有复用 ctx 里的 `AttemptObserver`,因为 ctx 只能放一个观察器,会与停复机、设备的审计观察器互相覆盖。限流拒绝发生在审计观察器之前,因此被拒绝的尝试不写集成日志。
2. **滑动窗口 Lua(ZSET 毫秒时间戳)**,key `gateway:qps:{cid}:{group}`;一次脚本同时校验账户组与全局组、普通操作与预留额度、`high_waiting`。备选令牌桶:突发更平滑但"本秒剩余"口径与后续分发器领取不一致,放弃。
3. **默认限流、显式豁免**:ctx 中只有豁免标记(带来源),没有"受限流"标记。备选"带标记才限流":新入口忘记打标记就漏网,与"不存在不限流的自动调用"冲突,放弃。
4. **两种模式全局开关**:`polling.rate_limit_mode` 放受控系统配置(`tb_system_config`,`module = polling`),不按账户覆盖(不灰度,见方案第三部分第 9 节)。API 与 Worker 各自用独立注册表读取同一份配置数据,进程内缓存 5 秒,后台修改数秒内生效。
- 限流组与错误分类规则同样在进程内缓存 5 秒(`GatewayRateLimitConfigTTL`),后台保存后本进程立即失效、其他进程 5 秒内刷新;未另建 Redis 配置缓存。
- 运营商账户口径为本系统 `tb_carrier`(卡上的 `carrier_id`),与网关客户端自身的 appId 无关:已知卡的调用方用 `gateway.WithCarrier` 传入,否则守卫按 ICCID 查本系统卡表并缓存 10 分钟;查不到时按账户 0(未知账户)单独计量。
- Redis 故障时限流失败开放(放行并记日志),避免限流组件故障阻断全部网关调用。
5. **Asynq 内不等待**:限流器提供"立即返回"与"等待至超时"两种模式,本 Change 只用立即返回。等待会占住共用的 10 个协程、饿死 Outbox 等业务。等待模式留给第 5 个 Change 的 NSQ 消费者。
6. **限流错误不计失败**:在 `pkg/queue/server.go` 设置 `IsFailure`(`errors.Is(err, ErrRateLimited)` 时返回 false)与 `RetryDelayFunc`(限流错误返回 `rate_limited_retry_delay`,其余回落 `DefaultRetryDelayFunc`)。只改服务配置与错误类型,各消费者业务代码不改。轮询 handler 另外自行短延迟 Requeue(它们是 `MaxRetry(0)`)。累计推迟告警:任务载荷或 Redis 记首次限流时间,超过上限写告警。
7. **停复机 `maxRetries = 0`**:与 `SetCardSpeedTier` 同一做法(复制 Client 并置 0)。超时返回 `CodeGatewayTimeout`,现有调用方按失败或结果未知处理。
8. **结构化错误**:新增 `GatewayError{Category, ProviderCode, ProviderMessage, Duration}` 实现 `error` 并保留现有错误码(`CodeGatewayError` 等),调用方用 `errors.As` 取结构,既有 `errors.Is`/错误码判断不受影响。
9. **错误分类**:表 `tb_gateway_error_classification` + Redis 缓存;分类器按"适用操作 + 启用 + 优先级"匹配关键字(包含 / 前缀 / 正则)。初始数据按方案第 6.2 节表格写入迁移。
10. **停复机记录表**:PostgreSQL 声明式分区(`PARTITION BY RANGE (created_at)`),主键 `(id, created_at)`,建 DEFAULT 分区;Worker 定时任务提前创建未来 N 个月分区(N 为系统参数)并按保留期删除过期分区。记录写入在业务事务之外,失败只记日志与计数。
11. **审计替换**:以"是否带限流豁免标记或由账户、个人客户、OpenAPI 发起"区分(`legacyCommandLogging`):
- 系统自动停复机(系统任务发起、无豁免标记)只写停复机记录,不写 `tb_audit_event` 与集成日志;未调用上游的拒绝类审计保留。
- 后台人工点击、通道阈值、自动续费,以及账户、个人客户、OpenAPI 发起的停复机保持原审计与集成日志并额外写记录。通道阈值与自动续费的可靠任务用集成日志标识做结果未知恢复,不能去掉。
- 设备停复机(后台点击)在设备服务内同样写记录。
- 普通轮询查询不再写集成日志(含有变化时的成功记录);同时是优先执行的轮次仍写,以满足优先轮询"返回集成交互日志标识"的现行要求。
12. **分钟计数**:key `gateway:stat:{cid}:{op}:{yyyyMMddHHmm}` Hash(按结果类别计数、耗时分桶),TTL 8 天,覆盖 `observe` 一周的评估需要。
13. **观测序列遇限流**:观测序列任务为 `MaxRetry(0)`,现有逻辑在限流时记一次"被限流"并结束本次尝试(不归档、不报错),后续两次尝试与普通轮询兜底;本 Change 不做顺延重入队(需改序列幂等认领),观测序列在第 5 个 Change 下线。
14. **业务层停复机重试**:`stopCardWithRetry` / `resumeCardWithRetry` 遇限流立即返回,不再 sleep 重试、不写失败审计,由调用方既有重试或兜底路径处理。
## Risks / Trade-offs
- [默认 QPS 设得不合理] → 先 `observe` 至少一周,按 `/polling-stats/carriers` 数据为全部账户配置限流组后再整体 `enforce`;`enforce` 后异常可即时改回 `observe`。
- [停复机不再内部重试,人工点击网络抖动直接失败] → 已接受;页面提示重试。
- [移除系统自动停复机审计后,原审计查询看不到] → 停复机记录提供同等字段与查询;后台人工点击仍有统一审计。
- [`IsFailure` 对全部 Asynq 任务生效] → 只对 `ErrRateLimited` 返回 false,其余错误行为不变;限流错误只由限流器产生。
- [分区表维护任务失败] → DEFAULT 分区兜底插入不失败;监控分区创建结果。
- [Redis 计数 key 量] → 账户 37 × 操作 5 × 分钟,8 天约 210 万个小 Hash;上线前按生产 Redis 余量评估,必要时改为 5 分钟粒度。
## Migration Plan
1. 测试环境:执行迁移(三张新表、`tb_system_config` 配置项与错误分类初始数据),部署 API 与 Worker,默认 `observe`。
2. 按 tasks 第 9 节核对清单逐项验证,包括 `enforce` 下 Outbox 限流不计失败。
3. 生产:维护者按生产运行说明手工执行迁移与发布,保持 `observe` 至少一周。
4. 运营按统计为全部账户配置限流组,确认后改 `enforce`。
5. 回退:改回 `observe` 即停止拦截;代码回退时新表保留不删。
## Open Questions
- 默认 QPS 初值:在测试环境与 `observe` 数据确认前,暂定为系统参数默认值,由维护者上线前填写。

View File

@@ -0,0 +1,37 @@
## Why
上游按运营商账户限频,本系统却只有"全局 + 任务类型"的并发计数,数值没有依据,且自动停复机、业务事件观测完全不受约束。生产 2026-10-22:退款触发的停机 24 小时失败约 26.8 万次、单卡每 1~2 秒调用一次;轮询失败约 37 万次但失败原因全部为空,无法区分限频、卡错误与超时。本 Change 是《轮询按运营商账户限流技术方案》(`docs/engineering/轮询按运营商账户限流技术方案.md`)第五部分第 3 节的第 1 步:不动现有调度,先给网关调用加上按运营商账户的限流,并让失败原因与调用量可见,作为后续各 Change 和各账户 QPS 配置的依据。
## What Changes
- 新增网关限流器:按"运营商账户 → 限流组 → 操作"对卡的 5 个网关操作(查流量、查状态、查实名、停机、复机)做每秒请求数限制,滑动窗口,跨 Worker 共享;**按每次实际 HTTP 尝试计量**(挂在 `AttemptObserver.BeforeAttempt`),网关内部重试同样计入。
- 默认限流、显式豁免:只有后台人工点击停复机、通道阈值、自动续费、手动刷新 4 个入口打豁免标记,其余卡操作一律受限。
- 限流组配置(新表 `tb_carrier_rate_limit_group`):组内操作、`max_qps`、高优先级操作与预留额度、可选全局组;未覆盖或组已停用的操作按默认 QPS,不存在不限流的自动调用。
- 两种模式:受控系统配置 `polling.rate_limit_mode`,`observe`(只统计、不拦截,默认)与 `enforce`(拦截),全局生效、不按账户覆盖。上线先 `observe` 采集至少一周,配好全部账户限流组后整体切到 `enforce`。
- 拦截时在 Asynq 任务内**不等待**:拿不到名额立即返回"限流"错误;轮询任务短延迟重入队且不计失败;Asynq 服务配置 `IsFailure` 对"限流"错误返回 false、`RetryDelayFunc` 返回固定短延迟,Outbox 消费者与观测序列因此不会因限流消耗重试次数、推迟数小时或归档。
- **BREAKING** `StopCard` / `StartCard` 不再使用网关内部重试(`maxRetries = 0`),对全部停复机入口生效(含后台人工点击):避免超时后几百毫秒内对同一张卡连发 2~3 次撞上游单卡频率限制。
- 网关返回结构化错误(错误类别、网关业务码与信息、耗时),不再只拼在错误字符串里。
- 上游错误分类:新表 `tb_gateway_error_classification`(关键字 → 类别:频率限制 / 终态不可操作 / 已是目标状态 / 临时故障 / 业务错误 / 账户配置错误),后台可增补,提供测试接口。只有明确返回已是目标状态才视为成功。
- 停复机记录:新表 `tb_card_stop_resume_record`(按月分区 + DEFAULT 分区,提前建未来分区),每次实际调用上游停机或复机写一条;**BREAKING** 系统自动停复机不再写统一审计 `tb_audit_event` 与集成日志 `tb_integration_log`(后台人工点击仍写统一审计);轮询查询不再写集成日志。
- 查询类调用不逐条落库:Redis 按"运营商账户 × 操作 × 结果类别"做分钟级计数与耗时分布。
- 后台接口:限流组增删改查、错误分类增删改查与测试、停复机记录查询、按账户调用量统计、`polling` 模块系统配置注册(含 JSON 结构校验)、枚举选项。
## Capabilities
### New Capabilities
- `carrier-gateway-rate-limit`:按运营商账户的网关限流、计量口径、豁免、只统计与拦截模式、限流组配置、Asynq 任务遇限流的重试语义、停复机不做网关内部重试。
- `gateway-call-observability`:网关结构化错误、上游错误分类、停复机记录表、查询调用分钟计数、按账户调用量与失败查询接口。
### Modified Capabilities
- `polling-load-control`:"业务变化与轮询异常仍可追溯"改为:系统自动停复机写停复机记录表而非统一审计与集成日志;查询失败以分钟计数与卡上最近错误保留可调查信息,不逐条写集成日志。
## Impact
- 依赖:无前置 Change;不改现有轮询调度、并发计数与停复机业务规则。
- 数据库:新增 `tb_carrier_rate_limit_group`、`tb_gateway_error_classification`、`tb_card_stop_resume_record`(分区表);`tb_system_config` 插入 `module = polling` 配置项。均为新的成对迁移。
- 代码:`internal/gateway/client.go`、`internal/gateway/flow_card.go`;新增限流器与错误分类器;`pkg/queue/server.go`(`IsFailure`、`RetryDelayFunc`);`internal/task/polling_*_handler.go`(限流重入队);`internal/service/iot_card/stop_resume_service.go` 与 `stop_resume_audit.go`(记录表替代审计);`internal/infrastructure/systemconfig` 注册;新增 Handler / 路由 / DTO 并同步 `cmd/api/docs.go`、`cmd/gendocs/main.go`。
- 接口:新增 `/polling-rate-limit-groups`、`/gateway-error-classifications`、`/card-stop-resume-records`、`/iot-cards/:id/stop-resume-records`、`/polling-stats/carriers`、`/polling-options`;`/system-configs` 的 `module` 枚举增加 `polling`。前端需新增限流组、错误分类、停复机记录页面,系统配置页增加 `polling` 模块。
- 行为:后台人工点击停复机遇网络超时不再自动重试,页面直接显示失败;系统自动停复机的审计查询改到停复机记录。
- 运营:`observe` 期间按调用量统计为全部运营商账户配置限流组,确认后切 `enforce`。

View File

@@ -0,0 +1,108 @@
## ADDED Requirements
### Requirement: 卡网关操作按运营商账户限流
系统 SHALL 对卡的 5 个网关操作(查流量、查状态、查实名、停机、复机)按目标卡所属运营商账户与操作类型找到所属限流组,并在每秒请求数达到该组 `max_qps` 时拒绝或延后本次请求。限流窗口 MUST 跨全部 API 与 Worker 进程共享。设备停复机拆分到卡后,每张卡 MUST 各自按其所属运营商账户查找限流组。未被任何启用的限流组覆盖的操作(含所在组已停用)MUST 按默认 QPS 限流,不得出现不限流的自动调用。配置了全局组时,请求 MUST 同时满足账户组与全局组才放行。
#### Scenario: 账户组 QPS 用满
- **GIVEN** 运营商账户 A 的查询组 `max_qps = 20`,当前 1 秒窗口内已放行 20 次查询
- **WHEN** 第 21 次查询 A 账户下卡的流量
- **THEN** 系统在拦截模式下不发出该 HTTP 请求,返回"限流"结果
#### Scenario: 不同账户互不影响
- **GIVEN** 账户 A 的查询组本秒已用满
- **WHEN** 查询账户 B 下卡的状态
- **THEN** 系统按账户 B 的限流组判断,不受账户 A 影响
#### Scenario: 限流组停用
- **WHEN** 账户 A 的停复机组被停用后发起 A 账户下卡的停机
- **THEN** 系统按默认 QPS 限流该次停机,不得不限流放行
#### Scenario: 全局组
- **GIVEN** 配置了覆盖查询操作的全局组且其本秒已用满,账户 A 查询组仍有余量
- **WHEN** 查询账户 A 下卡的流量
- **THEN** 系统按全局组拒绝本次请求
### Requirement: 按每次实际 HTTP 请求计量
系统 SHALL 在每次向网关发出 HTTP 请求之前各取一次限流名额,网关客户端一次方法调用内部的重试请求 MUST 各自计量。拿不到名额时,本次方法调用 MUST 结束且不再发起后续尝试。
#### Scenario: 查询内部重试
- **GIVEN** 查流量第一次请求超时,网关客户端准备进行第二次尝试
- **WHEN** 第二次尝试发出前
- **THEN** 系统为第二次尝试再取一次名额,名额不足时不发出第二次请求并返回"限流"结果
### Requirement: 默认限流与显式豁免
系统 SHALL 对卡的 5 个网关操作默认限流;只有后台人工点击的停复机、通道阈值、自动续费、手动刷新 4 个入口 MAY 携带豁免标记绕过限流,豁免调用 MUST 按豁免来源计数以便监控。未携带豁免标记的调用(含今后新增的入口)MUST 受限流约束。
#### Scenario: 新增自动入口未处理标记
- **WHEN** 任一未声明豁免的入口调用停机
- **THEN** 该调用受所属限流组约束
#### Scenario: 后台人工点击停机
- **WHEN** 运营在后台对单卡点击停机
- **THEN** 该调用不受限流约束,并计入"人工点击"豁免调用量
### Requirement: 高优先级操作预留额度
当同一限流组内包含停机、复机与其他操作且配置了高优先级操作与 `reserved_qps` 时,系统 SHALL 让普通操作最多使用 `max_qps − reserved_qps`,高优先级操作可用满 `max_qps`;高优先级请求等待名额期间,普通操作 MUST 拿不到新名额。保存限流组时,系统 MUST 拒绝 `reserved_qps ≥ max_qps`、高优先级操作不属于本组操作、同一运营商账户下同一操作属于多个组的配置。
#### Scenario: 共享组内普通操作受预留约束
- **GIVEN** 广电账户一个组含全部 5 个操作,`max_qps = 10`,停机与复机为高优先级,`reserved_qps = 3`
- **WHEN** 本秒已放行 7 次查询后再发起查询
- **THEN** 系统拒绝该查询;同一秒内的停机仍可放行至第 10 次
#### Scenario: 同一操作重复归组
- **WHEN** 维护者为账户 A 新建组并包含已属于另一组的"查流量"
- **THEN** 系统拒绝保存并提示该操作已属于其他组
### Requirement: 只统计与拦截两种模式
系统 SHALL 按受控系统配置 `polling.rate_limit_mode` 全局决定限流模式:`observe` 时照常计数(实际 QPS、本应被拦截的次数)但不拦截、不等待;`enforce` 时按限流组拦截。默认值 MUST 为 `observe`。模式修改 MUST 即时生效,不需要重新部署。模式不得按运营商账户覆盖。
#### Scenario: 只统计模式下超出上限
- **GIVEN** 模式为 `observe`,账户 A 查询组本秒已达上限
- **WHEN** 再查询 A 账户下一张卡
- **THEN** 系统照常发出请求,并把该次计入"本应被限流"次数
#### Scenario: 切换为拦截
- **WHEN** 超级管理员把 `polling.rate_limit_mode` 改为 `enforce`
- **THEN** 下一次请求起按限流组拦截,无需重启进程
### Requirement: Asynq 任务内遇限流不等待且不计失败
在拦截模式下,系统 SHALL 让 Asynq 任务内发起的受限调用拿不到名额时立即返回"限流"错误,不在任务内等待名额。轮询任务遇限流 MUST 按 `rate_limited_retry_delay` 短延迟重新入队且不计为失败。其余 Asynq 任务(含 Outbox 消费者、观测序列)返回"限流"错误时,Asynq MUST 不将其计为失败、不增加重试计数,并按固定短延迟重试;其他错误的重试行为 MUST 不变。同一任务因限流累计推迟超过上限时,系统 MUST 写告警且任务继续重试。
#### Scenario: 流量用完停机遇限流
- **GIVEN** 流量入账后的 Outbox 消费者判断需要停机,停复机组本秒已用满
- **WHEN** 消费者调用停机拿不到名额
- **THEN** 任务以"限流"错误结束,Asynq 不增加重试计数,几秒后重试,不会因连续限流推迟数小时或被归档
#### Scenario: 非限流错误
- **WHEN** Outbox 消费者因数据库错误失败
- **THEN** Asynq 按原有重试次数与递增间隔处理
### Requirement: 停复机不使用网关内部重试
系统 SHALL 对停机、复机网关调用只发出一次 HTTP 请求,不使用网关客户端的内部重试,对全部停复机入口生效(含后台人工点击、通道阈值、自动续费)。超时或连接失败 MUST 按结果未知返回给调用方。
#### Scenario: 停机超时
- **WHEN** 停机请求超时
- **THEN** 系统不在同一次调用内再发停机请求,向调用方返回结果未知
#### Scenario: 后台人工点击停机网络抖动
- **WHEN** 运营点击停机时连接失败
- **THEN** 页面返回失败,由运营重新操作,系统不自动重试
### Requirement: 限流组与限流参数可后台维护
系统 SHALL 提供限流组的列表(按运营商账户筛选,含全局组)、新建、详情、修改、启停、删除接口;修改类接口仅超级管理员可用并写统一审计。保存后 MUST 同步到共享缓存,下一次请求起生效。限流模式、限流短延迟、限流累计推迟告警上限等标量参数 SHALL 通过受控系统配置 `module = polling` 维护,列表 MUST 支持按 `polling` 模块筛选。
#### Scenario: 修改 QPS 即时生效
- **WHEN** 超级管理员把账户 A 查询组 `max_qps` 从 20 改为 30
- **THEN** 下一次请求起按 30 判断,无需重启
#### Scenario: 非超级管理员修改
- **WHEN** 非超级管理员提交限流组修改
- **THEN** 系统拒绝并保留原配置

View File

@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: 网关结构化错误
系统 SHALL 让网关客户端在失败时返回结构化错误,包含错误类别(网络、超时、HTTP 异常、响应无效、上游业务错误、限流)、网关业务码与信息、耗时。上游信息 MUST 脱敏截断后才可落库或展示。
#### Scenario: 上游业务错误
- **WHEN** 网关返回业务码 400 与信息"当前用户已销户,无法操作"
- **THEN** 调用方拿到类别"上游业务错误"、业务码 400、该信息与本次耗时
### Requirement: 上游错误分类
系统 SHALL 按后台维护的关键字规则把上游错误归入频率限制、终态不可操作、已是目标状态、临时故障、业务错误、账户配置错误之一;规则包含关键字、匹配方式、类别、适用操作、启用状态。只有明确表示卡已处于目标状态的返回 MAY 归入"已是目标状态";"密码错误""参数缺失"MUST 归入账户配置错误;"当前用户不允许办理""防范实名"MUST NOT 归入已是目标状态。未命中任何规则的上游业务错误 MUST 归入业务错误。系统 SHALL 提供测试接口:输入一段上游返回信息与操作,返回命中的规则与类别。
#### Scenario: 已停机返回
- **WHEN** 停机返回信息命中"已是目标状态"规则
- **THEN** 该次结果归为已是目标状态
#### Scenario: 密码错误
- **WHEN** 停机返回"密码错误"
- **THEN** 该次结果归为账户配置错误,不视为停机成功
#### Scenario: 测试规则
- **WHEN** 运营在新增规则前用测试接口输入"3分钟内同一张卡相同操作仅允许一次"
- **THEN** 系统返回命中的规则与"频率限制"类别,不写任何业务数据
### Requirement: 停复机调用逐次记录
系统 SHALL 在每次实际调用上游停机或复机后写一条停复机记录:卡、ICCID、运营商账户、动作、来源与场景、停机原因、尝试序号、结果(成功、失败、结果未知、已是目标状态)、错误类别、上游返回码与信息(脱敏截断)、耗时、记录时间。系统自动停复机(轮询、Outbox、激活、重置、退款等系统任务发起,且未带限流豁免标记)MUST 不再写统一审计与集成日志;后台人工点击、通道阈值、自动续费(带豁免标记),以及账户、个人客户、OpenAPI 发起的停复机 MUST 仍写统一审计与集成日志并同时写停复机记录(通道阈值与自动续费的结果未知恢复依赖集成日志标识)。未调用上游的拒绝类审计(如通道阈值锁拒绝复机)保持不变。被本系统限流拒绝、未发出请求的停复机 MUST NOT 写记录。记录写入失败 MUST 只记日志与计数,不影响卡状态与停机原因回写。记录按月分区,系统 MUST 提前创建未来若干个月的分区并保留默认分区,使任何月份的插入都不因分区缺失失败;保留期可配置,到期整区删除。
#### Scenario: 流量用完自动停机
- **WHEN** 轮询判定流量用完并成功停机
- **THEN** 系统写一条结果为成功的停复机记录,不写统一审计事件与集成日志
#### Scenario: 后台人工点击复机失败
- **WHEN** 运营点击复机且上游返回业务错误
- **THEN** 系统写统一审计,并写一条结果为失败、带错误类别与上游信息的停复机记录
#### Scenario: 未来月份分区缺失
- **WHEN** 某月分区尚未创建时写入记录
- **THEN** 记录写入默认分区,插入成功
### Requirement: 查询调用不逐条落库
系统 SHALL 不为普通轮询查询(查流量、查状态、查实名)逐条写集成日志,包括"数据有变化"的成功调用;本轮同时是一次优先执行时,按优先轮询可追溯要求仍写集成日志并回写优先项;改为在共享缓存中按"运营商账户 × 操作 × 结果类别"做分钟级计数与耗时分布,并在卡上保留各检查类型最近一次错误(类别、码、信息、时间)。业务事实变化(网络、实名、套餐、有效流量)MUST 仍按现有规则持久化业务记录。
#### Scenario: 查流量失败
- **WHEN** 轮询查流量超时
- **THEN** 系统不写集成日志,对应分钟计数的"超时"加一,卡上流量类型的最近错误更新为超时
### Requirement: 按账户调用量与停复机记录可查询
系统 SHALL 提供:按运营商账户的调用量统计(各操作实际 QPS、成功率、按类别的失败数、p95 耗时、本应被限流次数、豁免调用量,分钟级趋势);停复机记录列表(按 ICCID、运营商账户、动作、来源、结果、错误类别、时间范围筛选,时间范围必填且有上限)与单卡停复机历史;前端下拉所需的操作类型、错误类别等枚举。查询接口 MUST 需要登录并遵守现有数据权限。
#### Scenario: 无时间范围查询记录
- **WHEN** 请求停复机记录列表未带时间范围
- **THEN** 系统拒绝请求并提示时间范围必填
#### Scenario: 只统计期间评估 QPS
- **WHEN** 运营在 `observe` 模式下查看账户 A 近一周统计
- **THEN** 系统返回各操作实际 QPS 峰值与本应被限流次数,作为配置限流组的依据

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: 业务变化与轮询异常仍可追溯
系统 SHALL 在轮询产生网络、实名、套餐或有效流量等业务事实变化时持久化必要的业务事实与审计记录。外部调用失败、响应无效或结果未知时,系统 MUST 保留可调查的失败或恢复信息,且不得把失败静默降级为正常无变化观测。系统自动停复机的每次上游调用 MUST 以停复机记录保留结果与失败原因,不再写统一审计与集成日志;查询类调用失败 MUST 以按运营商账户的分钟计数与卡上各检查类型最近一次错误保留可调查信息,普通轮询不逐条写集成日志;同时是优先执行的轮次仍写集成日志供优先项追溯。
#### Scenario: 轮询产生网络状态变化
- **GIVEN** 渠道返回的网络状态与当前业务事实不同
- **WHEN** 系统成功应用该状态变化
- **THEN** 系统持久化业务状态变化及相应审计事实
#### Scenario: 轮询渠道调用失败
- **WHEN** 轮询调用渠道超时、失败或返回无效响应
- **THEN** 系统更新对应分钟计数与该卡该检查类型的最近错误,并按既有策略安排后续处理
#### Scenario: 自动停机失败
- **WHEN** 系统自动停机时上游返回业务错误
- **THEN** 系统写一条带错误类别与上游信息的停复机记录,不写统一审计与集成日志

View File

@@ -0,0 +1,66 @@
## 1. 迁移与模型
- [x] 1.1 新增成对迁移:建 `tb_carrier_rate_limit_group`(`carrier_id` 可空表示全局组、`group_code`、`group_name`、`max_qps`、`operations`、`high_priority_operations`、`reserved_qps`、`status`、审计字段);down 删表。
- [x] 1.2 新增成对迁移:建 `tb_gateway_error_classification`(关键字、匹配方式、类别、适用操作、优先级、启用状态),写入方案第 6.2 节的初始规则;down 删表。
- [x] 1.3 新增成对迁移:建分区表 `tb_card_stop_resume_record`(按 `created_at` 月分区,主键 `(id, created_at)`,DEFAULT 分区,当前月起若干个月分区,索引 `(card_id, created_at)`、`(carrier_id, created_at)`、`(result, created_at)`);down 删表。
- [x] 1.4 新增成对迁移:`tb_system_config` 插入 `module = polling` 配置项(`polling.rate_limit_mode` 默认 `observe`、`rate_limited_retry_delay`、限流累计推迟告警上限、默认 QPS、停复机记录保留月数与提前建分区月数);down 删除这些键。
- [x] 1.5 新增对应 model 与 Store;`internal/infrastructure/systemconfig` 注册上述键(类型、取值范围、枚举),`SystemConfigListRequest.Module` 枚举增加 `polling`;为 JSON 类型注册项增加结构校验接缝。
## 2. 网关结构化错误与停复机不重试
- [x] 2.1 `internal/gateway`:新增 `GatewayError`(类别、业务码、信息、耗时),`executeHTTPRequest` 与业务错误分支返回它并保留现有错误码;上游信息脱敏截断。
- [x] 2.2 `flow_card.go`:`StopCard` / `StartCard` 改为 `maxRetries = 0`(同 `SetCardSpeedTier` 写法)。
- [x] 2.3 全仓检查对网关错误字符串的解析(如按 `Gateway 业务错误: code=` 匹配),改为读取结构化字段,行为保持一致。
## 3. 限流器
- [x] 3.1 新增限流器:滑动窗口 Lua(账户组 + 全局组 + 预留额度 + `high_waiting`),返回放行或需等待毫秒数;提供立即返回与等待至超时两种调用方式。
- [x] 3.2 限流组配置读取:数据库 → Redis 缓存,变更后即时刷新;按目标卡 `carrier_id` + 操作查组,未覆盖或停用回落默认 QPS。
- [x] 3.3 实现限流 `AttemptObserver`,在 `BeforeAttempt` 取名额;与现有审计观察器链式组合;拿不到名额返回 `ErrRateLimited`(结构化错误类别"限流")。
- [x] 3.4 豁免标记:ctx 携带豁免来源;在后台人工点击停复机、通道阈值、自动续费、手动刷新 4 处入口打标记,其余不改。
- [x] 3.5 `observe` / `enforce` 模式:`observe` 只计数(含本应被限流次数)不拦截;模式读取受控系统配置并即时生效。
## 4. Asynq 遇限流的重试语义
- [x] 4.1 `pkg/queue/server.go`:`IsFailure` 对 `ErrRateLimited` 返回 false;`RetryDelayFunc` 对其返回 `rate_limited_retry_delay`,其余回落 `DefaultRetryDelayFunc`。
- [x] 4.2 现有轮询 handler(`internal/task/polling_*_handler.go`)遇 `ErrRateLimited` 按短延迟 Requeue、不计失败、不写失败审计。
- [x] 4.3 限流累计推迟告警:记录任务首次被限流时间,超过上限写告警,任务继续重试。
## 5. 错误分类与调用计数
- [x] 5.1 错误分类器:按适用操作、启用状态、优先级匹配关键字;规则缓存于 Redis 并在变更后刷新;未命中归业务错误。
- [x] 5.2 网关调用分钟计数:每次尝试结束按"账户 × 操作 × 结果类别"计数并记录耗时分桶,含豁免调用量与本应被限流次数。
- [x] 5.3 卡上最近错误:查询失败时写该卡该检查类型的最近错误(类别、码、信息、时间)。
## 6. 停复机记录替代审计
- [x] 6.1 每次实际调用上游停机或复机后写停复机记录(来源、场景、尝试序号、结果、错误类别、上游信息、耗时);写入失败只记日志与计数。
- [x] 6.2 系统自动停复机不再写 `tb_audit_event` 与集成日志;后台人工点击停复机保留统一审计并同时写记录。
- [x] 6.3 轮询查询删除集成日志写入(含有变化时的成功记录),业务事实变化的持久化不变。
- [x] 6.4 Worker 定时任务:提前创建未来分区、按保留期删除过期分区,失败写告警。
## 7. 后台接口
- [x] 7.1 限流组:`GET/POST /polling-rate-limit-groups`、`GET/PUT/DELETE /polling-rate-limit-groups/:id`、`PUT /:id/status`;保存校验(`reserved_qps < max_qps`、高优先级属于本组、同账户同操作只属一组);修改仅超级管理员并写统一审计。
- [x] 7.2 错误分类:`GET/POST /gateway-error-classifications`、`PUT/DELETE /:id`、`PUT /:id/status`、`POST /gateway-error-classifications/test`。
- [x] 7.3 停复机记录:`GET /card-stop-resume-records`(时间范围必填且有上限)、`GET /iot-cards/:id/stop-resume-records`。
- [x] 7.4 调用量统计:`GET /polling-stats/carriers`(实际 QPS、成功率、按类别失败数、p95、本应被限流次数、豁免调用量)与 `GET /polling-stats/carriers/:carrier_id/trend`。
- [x] 7.5 枚举:`GET /polling-options`(操作类型、错误类别、匹配方式等)。
- [x] 7.6 同步可执行路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,运行 `go run cmd/gendocs/main.go` 重新生成接口文档。
## 8. 编译与规范校验
- [x] 8.1 `gofmt -w` 变更文件;`go build ./cmd/api ./cmd/worker` 通过。
- [x] 8.2 `openspec validate add-carrier-gateway-rate-limit` 与 `openspec doctor --json` 通过。
## 9. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001 在维护者指定测试环境逐项核对)
- [x] 9.1 迁移在测试库执行成功;未来分区与 DEFAULT 分区存在。
- [ ] 9.2 `observe`:超过限流组上限的请求照常发出,统计接口显示本应被限流次数。
- [ ] 9.3 `enforce`:账户组用满时请求被拒;不同账户互不影响;停用组回落默认 QPS;全局组生效;预留额度下停机仍可放行。
- [ ] 9.4 内部重试计量:构造查询超时,确认第二次尝试单独取名额。
- [ ] 9.5 停复机超时只发一次请求,返回结果未知;后台人工点击不受限流。
- [ ] 9.6 Outbox 消费者遇限流:Asynq 重试计数不增加、按短延迟重试;非限流错误按原规则重试;累计推迟超限写告警。
- [ ] 9.7 错误分类:测试接口与实际调用的分类一致;"密码错误"归账户配置错误,"已存在"不归已是目标状态。
- [ ] 9.8 系统自动停复机只写停复机记录、不写统一审计与集成日志;人工点击两者都写;记录写入失败不影响卡状态回写。
- [ ] 9.9 生成的接口文档包含新增接口;`/system-configs?module=polling` 返回新增配置项且 JSON 结构校验生效。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,42 @@
## Context
完整设计见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 1~5.1、7.2~7.6、8、9、14 节。本文记录实现决策。
现状:`internal/polling/scheduler.go` 每秒对 16 个分片 × 5 类型 Lua 出队后逐条 `Enqueue` 到 Asynq(`MaxRetry(0)`、无 TaskID);按 ZCARD 背压(50 万);`polling_base.go` 全局 + 类型并发令牌;优先提示 List `polling:priority:{taskType}` 与手动触发 List;`OnCardStatusChanged` 重新入队全部类型;业务事件经 Outbox `AppendSeriesRequested` 创建"立即、3 分钟、5 分钟"观测序列,读入口经 `BestEffortSeriesDispatcher`(C 端资产详情、后台资产实时状态、C 端与后台实名链接;OpenAPI 不触发)。Redis 单节点;仓库无 NSQ 依赖;自动化测试为 N/A。生产 98,430 张卡、89,962 张开启轮询、37 个运营商账户。
## Goals / Non-Goals
**Goals:** 按账户隔离、不丢卡、同卡串行、去重;业务事件检查合并且受限流;整体切换可回退。
**Non-Goals:** 停复机指令队列与设备维度判断(第 6 个 Change,本 Change 中套餐步骤与 Outbox 消费者仍按现有方式同步停复机);批量查询;手动刷新行为。
## Decisions
1. **Redis 结构**(前缀新,不与旧 key 冲突):
- 账户:`polling:carrier:{cid}:priority|queue`(ZSET,member=card_id)、`:running`(ZSET,member=token,score=到期,仅计数)、`:dispatch_lock`。`stop_resume`、`sr_running` 在第 6 个 Change 使用,本 Change 预留领取顺序。
- 卡:`polling:card:{id}:due`(Hash:各类型到期、优先标记、连续失败、首次失败、最近错误、最近成功时间)、`:claim`(值=token)、`:exec`(值=token+实例)。
2. **Lua 脚本**:领取(清理过期 running → 计算上限 → 优先/普通按占比 → 预计调用数对比剩余 QPS → `SET claim NX PX` → 推后 score → 写 running)、续期(校验 token → PEXPIRE claim/exec → 推后 score 与 running)、收尾(校验 token → 删 claim/exec → 移出 running → 写 due → 按优先标记选队列设 score)、请求检查(设 due[type]=now、冷却判断、放入 priority 或打标记)。全部单节点 Lua,不跨 slot 问题。
3. **NSQ 而非 Asynq**(已定保留 NSQ):每账户独立 topic `polling.card.{cid}`,channel `worker`;消费者 `max_in_flight` 按账户参数;`msg_timeout` 大于续期间隔,执行中 `Touch`;租约 > `msg_timeout` × 允许重投次数。动态订阅随运营商账户与配置刷新。
4. **执行权 key**:NSQ 在消费者假死时会重投同一 token,仅核对 claim 无法区分;`SET exec token+实例 NX PX 续期间隔×2` 抢不到即 FIN。
5. **步骤函数**:原 4 个 handler 拆为 `step(ctx, StepInput{Card, Result}) (nextDue, err)`;`Result` 预留批量查询与推送结果。套餐步骤本轮流量已触发评估时可跳过(配置)。沿用 `polling_priority_claim.go` 的优先项认领与收尾。卡流量同步锁保留与手动刷新互斥。
6. **限流等待模式**:NSQ 消费者使用第 1 个 Change 限流器的"等待至超时"模式(不占 Asynq 协程),等待超过单步超时返回限流超时,按短延迟重排不计失败。
7. **请求检查取代观测序列**:5 处 `AppendSeriesRequested` 与 3 处读入口分发改为调用"请求检查(卡、类型、场景)";场景表 `tb_polling_check_scene` 预置 scene 行(订单支付、套餐激活、停复机成功、设备操作、自动续费购包、C 端资产详情、后台资产实时状态、实名链接),读入口默认配置冷却。观测序列中与卡检查无关的部分(设备信息同步 `query_device_info`)保持不变。
8. **失败告警**:`tb_polling_failure_alert` 部分唯一索引(卡、检查类型、未关闭),告警写入走异步、失败只记日志;通知走现有 `alert_service.go`。停复机方向的告警在第 6 个 Change 接入同一张表。
9. **整体切换**:`polling.scheduler_mode` 放受控系统配置;切换由 Worker 监听配置变更执行:停旧调度 → 初始化新结构(按批读库,进度复用 `/polling-stats/init-progress`)→ 开启分发。回退对称。旧 handler 执行前检查模式。
10. **监控**:队列与执行数读 Redis;QPS、耗时读第 1 个 Change 的分钟计数;告警读告警表。
## Risks / Trade-offs
- [不灰度,新调度出问题影响全部 9 万张卡] → 测试环境按生产规模压测(造卡或回放生产卡 ID),覆盖 tasks 第 11 节全部场景;上线首日重点监控,异常用 `scheduler_mode` 回退。
- [初始化 10 万张卡耗时] → 测试环境实测并写入上线步骤;初始化期间不领取,切换窗口内轮询暂停。
- [新增 NSQ 组件] → nsqd 与 nsqlookupd 部署、监控与告警纳入运维;NSQ 消息丢失由租约到期兜底。
- [Redis 内存增加] → 每卡一个 `due` Hash(约 10 字段),上线前按生产卡量估算并确认 `maxmemory` 与淘汰策略不淘汰轮询 key。
- [读入口冷却期内页面数据不新] → 已接受;页面照常展示数据库数据。
## Migration Plan
1. 测试环境部署 nsqd + nsqlookupd;执行迁移(三张新表、系统配置项、并发配置表注释)。
2. 部署 API 与 Worker,`scheduler_mode = legacy`;运营配置账户调度参数与场景。
3. 切 `carrier`,按 tasks 第 11 节核对;演练回退与再次切换。
4. 生产:维护者部署 NSQ、执行迁移与发布,选择低峰期切 `carrier`,首日监控。
5. 稳定运行后另行删除旧分片 key 与旧调度代码。

View File

@@ -0,0 +1,37 @@
## Why
现有轮询按固定 16 个分片 × 5 种类型调度,与运营商账户无关:慢账户占满共用的 Asynq 协程,影响其他账户与其他业务;出队后到投递前崩溃会丢卡;按集合大小背压,单分片超过 50 万后永久停止出队;投递无去重,同卡同类型可能重复执行,同卡不同类型并行执行。业务事件触发的卡观测序列固定"立即、3 分钟、5 分钟"三连查且不受限流,生产 2026-10-22 批量下单 70 张卡形成单秒 177 次的波峰,并与优先轮询重复查询。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 5 步。
## What Changes
- 新调度:每个运营商账户 3 个延时队列(停复机预留、优先、普通);卡始终留在队列中,领取时写带租约的处理中标记并推后 score,收尾按下次到期设置 score;崩溃后标记到期自动重新领取,不丢卡。
- 同卡串行:一张卡一个卡任务,按配置的步骤顺序(默认卡状态 → 流量 → 实名 → 套餐)串行执行已到期的检查类型;每步单独超时,某步失败不影响后续步骤。
- 分发器每秒按账户领取:账户分发锁保证每轮只有一个实例领取;按执行上限、优先占比、预计网关调用数与本秒剩余 QPS 领取。
- **BREAKING** 卡轮询从 Asynq 改为 NSQ 分发:每个账户独立 topic,`max_in_flight` 控制每进程并发;Redis 是唯一权威状态,NSQ 只做传输。消费前核对派发 token 并抢执行权;执行中续期租约;收尾 Lua 校验 token 写回。
- 失败处理:按"卡 × 检查类型"记连续失败次数与最近错误,按配置的退避序列重试;限流等待超时不计失败;达到阈值写失败告警(新表 `tb_polling_failure_alert`),首次成功自动恢复;同账户告警超阈值产生账户级告警。
- **BREAKING** 业务事件与读入口改为"请求检查":订单支付、套餐激活、停复机成功、设备操作、自动续费购包,以及 C 端资产详情、后台资产实时状态、实名链接,不再起观测序列;改为把对应检查设为立即到期放入优先队列,同卡多次请求合并,按场景配置延迟确认与冷却时长(新表 `tb_polling_check_scene`)。OpenAPI 查询保持不触发观测。
- 优先轮询提示、手动触发队列改为写入账户队列;初始化、生命周期改为操作账户队列与卡到期信息。
- 运营商账户调度参数(新表 `tb_carrier_polling_param`):执行上限、每进程 `max_in_flight`、优先占比等;步骤顺序、单步超时、退避、告警阈值放受控系统配置。
- 整体切换:受控系统配置 `polling.scheduler_mode`(`legacy` / `carrier`),不按账户灰度;切换时旧任务直接结束、从数据库初始化全部账户新结构后开始领取;可整体回退。
- 监控改为按运营商账户;下线按 ZCARD 背压、旧并发计数与并发配置接口、旧提示通道。
## Capabilities
### New Capabilities
- `carrier-polling-scheduler`:按账户队列与领取、租约与续期、执行权、同卡串行步骤、NSQ 分发与消费、执行上限与优先占比、失败退避与告警、请求检查与场景配置、整体切换与回退、按账户监控。
### Modified Capabilities
- `polling-load-control`:删除"轮询全局并发与背压"(由按账户执行上限与限流取代)。
- `polling-operations`:删除"轮询并发计数可观测""轮询并发配置覆盖实际任务""轮询任务类型名称可读"(并发配置页下线)。
- `priority-polling-queue`:"优先调度与执行提示通道"改为写入运营商账户优先队列。
## Impact
- 依赖:第 1 个 Change(限流器,消费者使用等待模式)、第 3 个 Change(去掉保护期)已上线。
- 部署:测试环境与生产新增 nsqd + nsqlookupd;`go.mod` 新增 NSQ 客户端依赖;yaml 增加 NSQ 地址、`msg_timeout`、租约、续期间隔、分发间隔、卡任务总超时、优雅退出宽限期。
- 数据库:新增 `tb_carrier_polling_param`、`tb_polling_check_scene`(预置场景)、`tb_polling_failure_alert`;`tb_system_config` 插入步骤与告警参数、`polling.scheduler_mode`;`tb_polling_concurrency_config` 保留不删、表注释标记废弃。
- 代码:重写 `internal/polling/queue_manager.go`、`scheduler.go`、`initializer.go`、`internal/task/polling_*_handler.go`(拆为步骤函数)、`polling_base.go`、`internal/service/polling/monitoring_service.go`;修改 `lifecycle_service.go`、`priority_manual_service.go`、`manual_trigger_service.go`、`polling_priority_claim.go`;业务事件 5 处与读入口 3 处改为请求检查;`cmd/worker/main.go`、`internal/bootstrap/*` 装配分发器与 NSQ 消费者;删除 `concurrency_service.go` 及其接口。
- 接口:新增 `/polling-carrier-params`、`/polling-check-scenes`、`/polling-failure-alerts`(列表、汇总、详情、处理)、`/iot-cards/:id/polling-state`;`/polling-stats` 按账户;下线 `/polling-concurrency`。前端新增调度参数、场景、失败告警页,监控页按账户,下线并发配置页。
- 行为:业务事件后的检查不再固定三连查,按场景配置;读入口受冷却与限流约束。

View File

@@ -0,0 +1,124 @@
## ADDED Requirements
### Requirement: 按运营商账户队列调度且不丢卡
系统 SHALL 在 `polling.scheduler_mode = carrier` 时,为每个运营商账户维护优先队列与普通队列,每张参与轮询的卡在其所属账户中 MUST 只占一个成员(优先标记决定在哪个队列),score 为该卡各检查类型中最早的到期时间。领取时系统 MUST 写带租约的处理中标记(写成功才算领取到)并把 score 推后到租约到期时间,卡不移出队列;处理结束(成功或失败)由收尾按派发 token 校验后删除处理中标记、写回各检查类型到期时间与失败计数,并把 score 设为下次最早到期时间。处理中崩溃时,租约到期后该卡 MUST 能被重新领取。优先提示、手动触发、生命周期事件 MUST 只修改到期时间与 score,不得新增重复成员。
#### Scenario: 执行中进程崩溃
- **WHEN** 卡任务执行到一半 Worker 崩溃
- **THEN** 租约到期后该卡被重新领取执行,不需要全量初始化
#### Scenario: 重复优先提示
- **WHEN** 同一张卡 1 秒内收到 3 次优先提示
- **THEN** 该卡在优先队列中仍只有一个成员,只执行一次
### Requirement: 分发器按账户领取
系统 SHALL 每秒对每个运营商账户执行一次领取,同一账户每轮 MUST 只由一个分发器实例领取。卡任务领取上限 = 账户执行上限 − 执行中数,且不超过订阅该账户的各进程 `max_in_flight` 之和;先领优先队列,优先领取数不超过本轮上限 × `priority_max_ratio`(为空不限),剩余名额给普通队列,普通队列不足时优先可补满;按卡累计本次预计网关调用数(未开实名管控的账户不计实名),任一限流组累计达到本秒剩余 QPS 即停止领取。只领取已到期的卡。
#### Scenario: 多实例同时运行
- **WHEN** 3 个 Worker 实例同时运行分发器
- **THEN** 同一账户每秒只被一个实例领取,执行中数不超过该账户执行上限
#### Scenario: 读入口刷屏
- **GIVEN** 账户 A `priority_max_ratio = 0.5`,优先队列积压 1 万张,普通队列有已到期卡
- **WHEN** 分发器领取账户 A
- **THEN** 本轮至少一半名额给普通队列
### Requirement: 卡任务串行执行检查步骤
系统 SHALL 对一张卡的一个卡任务按配置的步骤顺序(默认卡状态 → 流量 → 实名 → 套餐)串行执行本次已到期的检查类型。每步 MUST 有单独超时;某步失败只影响该步,后续步骤照常执行;限流等待超时时该步按短延迟重排且不计失败。卡任务总时长 MUST 不超过各步超时之和加余量,到达后取消剩余步骤并按失败收尾。同一张卡的不同检查类型 MUST NOT 并行执行。
#### Scenario: 流量步骤失败
- **WHEN** 卡任务中流量查询超时
- **THEN** 实名、套餐步骤照常执行,流量按退避间隔重试
#### Scenario: 同卡不同类型
- **WHEN** 卡的状态检查与流量检查同时到期
- **THEN** 系统在同一个卡任务中先查状态再查流量,不并行发起
### Requirement: 租约续期与执行权
系统 SHALL 在卡任务执行期间定期续期处理中标记(按 token 校验后延长租约,并同步推后 score 与执行中计数的到期时间)。续期发现 token 已不一致时,系统 MUST 立即停止后续步骤,不再发起新的网关调用,只写入已产生的业务结果。消费者执行前 MUST 核对处理中标记的值等于消息中的派发 token,并抢该 token 的执行权;任一不满足即丢弃消息,保证同一 token 不会被两个消费者同时执行。
#### Scenario: 长任务超过单个租约
- **WHEN** 卡任务因限流等待累计超过一个租约时长
- **THEN** 续期成功,期间该卡不被重新领取
#### Scenario: 消息重投时原消费者仍在执行
- **WHEN** NSQ 重投同一消息给另一个消费者,原消费者尚未结束
- **THEN** 后者抢不到执行权,直接丢弃消息
### Requirement: 卡轮询经 NSQ 分发
系统 SHALL 将领取结果发布到对应运营商账户的 NSQ topic,由订阅该 topic 的消费者执行;每个账户独立 topic,消费者按账户配置的 `max_in_flight` 控制每进程并发。重试与退避 MUST 由 Redis 中的 score 表达,MUST NOT 用 NSQ 重新入队做业务重试。发布失败时,系统 MUST 按 token 删除处理中标记并把 score 恢复为当前时间。卡轮询 MUST NOT 再经过 Asynq;其他业务的 Asynq 队列不受影响。
#### Scenario: 慢账户积压
- **WHEN** 账户 A 上游变慢导致其 topic 积压
- **THEN** 账户 B 的卡任务照常执行,Outbox 等 Asynq 业务不受影响
#### Scenario: 发布失败
- **WHEN** 领取后发布 NSQ 失败
- **THEN** 该卡处理中标记被删除、score 恢复为当前时间,下一轮重新领取
### Requirement: 检查失败退避与失败告警
系统 SHALL 按"卡 × 检查类型"记录连续失败次数、首次失败时间与最近错误;失败后下次到期 = 当前 + 退避间隔,退避随连续失败次数按配置序列拉长,最长不超过该类型正常检查间隔;成功一次即清零。限流等待超时与上游频率限制 MUST NOT 计入连续失败。连续失败次数或持续失败时长达到阈值、或命中"首次即告警"的错误类别时,系统 MUST 写失败告警;同一卡同一检查类型只保留一条未关闭告警,后续失败更新计数;告警后继续按最长退避轮询,首次成功自动转为已恢复。同一运营商账户在时间窗口内的告警数超过阈值时,系统 MUST 产生账户级告警。
#### Scenario: 持续超时
- **WHEN** 某卡流量查询连续失败达到阈值
- **THEN** 系统写一条失败告警,之后按最长退避继续查询
#### Scenario: 上游恢复
- **WHEN** 已告警的卡下一次查询成功
- **THEN** 连续失败清零,告警状态变为已恢复
#### Scenario: 账户级问题
- **WHEN** 账户 A 10 分钟内新增失败告警超过阈值
- **THEN** 系统额外产生一条账户级告警,提示联系上游
### Requirement: 业务事件与读入口请求检查
系统 SHALL 在订单支付、套餐激活、停复机成功、设备操作(按设备下各卡)、自动续费购包事务提交后,以及 C 端资产详情、后台资产实时状态、获取实名链接时,按场景配置把卡的对应检查类型设为立即到期并放入所属账户优先队列;卡正在执行时打优先标记,执行结束后立即再执行一次。同一张卡多次请求 MUST 合并为一次。场景配置了冷却时长时,该检查类型最近一次成功检查未超过冷却时长 MUST NOT 请求。场景配置了延迟确认序列时,本次检查完成后追加延迟检查(下次到期 = min(正常间隔, 当前 + 确认延迟)),观测结果已满足预期时取消后续确认。系统 MUST NOT 再为卡检查创建"立即、3 分钟、5 分钟"观测序列。OpenAPI 查询 MUST 保持不触发任何检查。场景由代码预置,后台只可修改检查类型、确认延迟与冷却时长。
#### Scenario: 批量下单
- **WHEN** 代理 10 秒内线下支付 27 笔订单,涉及 70 张卡
- **THEN** 系统只产生约 70 次立即检查(加按场景配置的确认检查),按各账户 QPS 平滑执行
#### Scenario: C 端反复刷新
- **GIVEN** C 端资产详情场景冷却 5 分钟
- **WHEN** 用户 2 分钟内打开同一资产详情 10 次
- **THEN** 只在第一次请求检查,其余不请求,页面照常展示数据库数据
#### Scenario: 停机后确认
- **GIVEN** 停复机成功场景配置确认延迟 3 分钟
- **WHEN** 停机成功后立即检查显示仍在线
- **THEN** 3 分钟后再检查一次;若立即检查已显示停机,则取消确认
### Requirement: 调度参数可后台维护
系统 SHALL 提供运营商账户调度参数(执行上限、卡任务与停复机每进程 `max_in_flight`、停复机执行上限、优先占比、单卡停复机最小重试间隔、结果未知确认延迟)的列表与修改,未配置的账户返回全局默认值并标记"默认";保存时 MUST 校验执行上限不大于当前订阅进程 `max_in_flight` 之和并提示。步骤顺序、单步超时、限流短延迟、退避序列、告警阈值通过受控系统配置维护,JSON 值 MUST 做结构校验(步骤只能是 4 种类型且不重复,退避序列递增)。修改仅超级管理员可用并写统一审计,下一轮调度生效。
#### Scenario: 步骤重复
- **WHEN** 超级管理员提交步骤顺序 `[card_status, carddata, carddata]`
- **THEN** 系统拒绝保存
### Requirement: 整体切换与回退
系统 SHALL 由受控系统配置 `polling.scheduler_mode`(`legacy` / `carrier`)全局决定卡轮询使用旧分片调度还是新调度,MUST NOT 按运营商账户灰度,任一时刻只有一套调度在领取与执行。切到 `carrier` 时:旧调度停止出队,旧 Asynq 轮询任务执行前检查模式并直接结束;系统从数据库按批初始化全部账户新结构,初始化完成前新分发器不领取。回到 `legacy` 时:新分发器停止领取,执行中任务按收尾规则完成,系统重新初始化旧分片队列后旧调度恢复。所有写入轮询结构的入口按当前模式写新或旧结构。
#### Scenario: 切换期间旧任务
- **WHEN** 切到 `carrier` 时 Asynq 中仍有排队的旧轮询任务
- **THEN** 这些任务执行时直接结束,不调网关、不重入旧队列
#### Scenario: 回退
- **WHEN** 新调度异常,超级管理员把模式改回 `legacy`
- **THEN** 新分发器停止领取,旧分片队列按数据库重建后恢复轮询,无卡丢失
### Requirement: 按账户监控与卡轮询状态查询
系统 SHALL 按运营商账户展示各队列长度、已到期数、执行中数、实际 QPS、限流等待与超时次数、处理中标记到期次数、失败告警数(按错误类型);提供单卡轮询状态查询(各检查类型下次到期、连续失败与最近错误、是否执行中),只读。系统 SHALL 提供失败告警列表(按运营商账户、检查类型、错误类型、状态、时间筛选)、按账户 × 错误类型汇总、详情与标记已处理。
#### Scenario: 排查单卡
- **WHEN** 运营查询某卡轮询状态
- **THEN** 系统返回各检查类型下次到期时间、最近错误与是否执行中,不改变任何状态

View File

@@ -0,0 +1,5 @@
## REMOVED Requirements
### Requirement: 轮询全局并发与背压
**Reason**: 并发计数不以上游运营商账户为单位、数值没有依据,按集合大小背压会在单分片积压超过阈值后永久停止出队。由 `carrier-polling-scheduler` 的按账户执行上限、领取上限与 `carrier-gateway-rate-limit` 的按账户限流取代。
**Migration**: 运营按账户配置调度参数与限流组;`tb_polling_concurrency_config` 保留不删、表注释标记废弃,代码不再读写。

View File

@@ -0,0 +1,13 @@
## REMOVED Requirements
### Requirement: 轮询并发计数可观测
**Reason**: 按任务类型的并发计数随新调度下线,执行情况改为按运营商账户的执行中数与监控展示。
**Migration**: 使用 `/polling-stats` 按账户监控与 `/polling-carrier-params` 调度参数;`/polling-concurrency` 下线,前端去掉并发配置页。
### Requirement: 轮询并发配置覆盖实际任务
**Reason**: 并发配置由运营商账户调度参数(执行上限、每进程 `max_in_flight`)取代。
**Migration**: 同上;`tb_polling_concurrency_config` 保留不删、表注释标记废弃。
### Requirement: 轮询任务类型名称可读
**Reason**: 该名称仅用于并发状态接口,接口随并发配置一起下线。
**Migration**: 检查类型的中文名称由 `/polling-options` 枚举接口提供。

View File

@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: 优先调度与执行提示通道
优先轮询项 MUST 以持久化事实为权威;执行提示 MUST 通过把该卡对应检查类型设为立即到期、并把卡放入其所属运营商账户的优先队列下发;卡正在执行时只打优先标记,本次执行结束后立即再执行一次。同一张卡在一个账户中只占一个队列成员,多次提示 MUST 合并。手动触发队列取出的卡 MUST 设为立即到期并放入所属账户的普通队列。分发器 MUST 在每轮领取中先领优先队列、再领普通队列,优先领取数受账户 `priority_max_ratio` 约束。提示 MUST NOT 作为活动、权限或尝试次数的判定依据。优先 MUST NOT 绕过运营商账户限流与执行上限。
#### Scenario: 提示丢失
- **WHEN** 优先提示因缓存服务重启丢失
- **THEN** 后续普通轮询执行仍按活动优先轮询项认领并执行,仅退化为延迟到该卡下一次到期,不丢事实也不产生第二套补偿
#### Scenario: 同一轮的领取顺序
- **WHEN** 同一账户的优先队列与普通队列都有已到期的卡
- **THEN** 分发器先领优先队列,在优先占比内领取后再领普通队列
#### Scenario: 执行中收到优先提示
- **WHEN** 卡正在执行卡任务时收到优先提示
- **THEN** 系统打优先标记,本次执行结束后该卡立即再执行一次,不并行执行

View File

@@ -0,0 +1,86 @@
## 0. 前置确认
- [ ] 0.1 确认第 1 个 Change(限流器)与第 3 个 Change(去掉保护期)已上线;维护者提供测试环境 nsqd + nsqlookupd 地址。
## 1. 迁移与配置
- [ ] 1.1 新增成对迁移:`tb_carrier_polling_param`(`carrier_id` 唯一、`inflight_limit`、`card_max_in_flight`、`stop_resume_max_in_flight`、`sr_inflight_limit`、`priority_max_ratio`、`stop_resume_min_interval`、`stop_resume_confirm_delay`、审计字段)。
- [ ] 1.2 新增成对迁移:`tb_polling_check_scene`(`scene` 唯一、`check_types`、`confirm_delays`、`cooldown`),预置 8 个场景,读入口场景带默认冷却。
- [ ] 1.3 新增成对迁移:`tb_polling_failure_alert`(卡、运营商账户、检查类型、连续失败、首次与最近失败、最近错误、状态、处理人与时间;部分唯一索引 `(card_id, check_type) WHERE status = 未处理`)。
- [ ] 1.4 新增成对迁移:`tb_system_config` 插入 `polling.scheduler_mode`(默认 `legacy`)、步骤顺序、单步超时、卡任务总超时余量、退避序列、告警阈值、账户级告警窗口与阈值;`tb_polling_concurrency_config` 表注释标记废弃。
- [ ] 1.5 `pkg/config` 与 `defaults/config.yaml`:NSQ 地址、`msg_timeout`、租约、续期间隔、分发间隔、优雅退出宽限期、默认执行上限与 `max_in_flight`;`go.mod` 引入 NSQ 客户端。
- [ ] 1.6 注册受控系统配置项并实现 JSON 结构校验(步骤类型合法且不重复、退避序列递增)。
## 2. Redis 结构与 Lua
- [ ] 2.1 常量:账户队列、running、dispatch_lock,卡 due、claim、exec 的 key。
- [ ] 2.2 领取 Lua:清理过期 running、执行上限、优先占比、预计调用数对比本秒剩余 QPS、`SET claim NX PX`、推后 score、写 running;已有 claim 时推后到剩余 TTL 后。
- [ ] 2.3 续期 Lua:校验 token,延长 claim、exec,推后 score 与 running。
- [ ] 2.4 收尾 Lua:校验 token,删 claim、exec,移出 running,写 due 与失败计数,按优先标记设置 score;token 不一致时只写业务结果。
- [ ] 2.5 请求检查 Lua:设 due[type]=now、冷却判断、放入 priority 或打优先标记,合并重复请求。
## 3. 初始化与生命周期
- [ ] 3.1 `initializer.go`:按批读库计算各类型 due(复用第 2 个 Change 的分层与重置日前到期计算),写 due 与账户 queue;进度写入 init-progress。
- [ ] 3.2 `lifecycle_service.go`、`asset_polling_service.go`:增删卡、状态变化、启停轮询改为操作账户队列与 due;恢复轮询时 due 设为当前加抖动。
- [ ] 3.3 优先提示(`priority_manual_service.go`)与手动触发(`manual_trigger_service.go`)改为写 due 与账户队列。
## 4. 分发器
- [ ] 4.1 Worker 内每秒对各账户抢分发锁并执行领取 Lua,发布到 `polling.card.{cid}`;发布失败按 token 删 claim、score 恢复为当前时间。
- [ ] 4.2 读取账户调度参数(缺省用默认),执行上限不超过订阅进程 `max_in_flight` 之和,不满足时收紧并上报。
- [ ] 4.3 定时动作:统计各账户处理中标记到期次数、写分发器心跳。
## 5. NSQ 消费者与卡任务
- [ ] 5.1 NSQ 生产者与消费者封装:按账户动态订阅与取消订阅、`max_in_flight`、`Touch`、优雅退出(停止分发与订阅、宽限期内完成后 FIN)。
- [ ] 5.2 消费入口:核对 claim token、抢 exec 执行权,任一失败即 FIN。
- [ ] 5.3 将实名、流量、卡状态、套餐 4 个 handler 拆为步骤函数(`StepInput` 含可选外部结果);按配置顺序串行执行到期类型;单步超时、总超时、panic 恢复。
- [ ] 5.4 执行期间按续期间隔续期;续期失败立即停止后续步骤。
- [ ] 5.5 网关调用使用限流器等待模式;限流等待超时按短延迟重排不计失败。
- [ ] 5.6 沿用 `polling_priority_claim.go` 的认领与收尾;保留卡流量同步锁。
## 6. 失败退避与告警
- [ ] 6.1 收尾按"卡 × 类型"更新连续失败、首次失败、最近错误;按退避序列计算下次到期,频率限制与限流超时不计失败;成功清零。
- [ ] 6.2 达到阈值或首次即告警类别时写或更新 `tb_polling_failure_alert`;成功后自动转已恢复;通知走现有告警通道。
- [ ] 6.3 账户级告警:窗口内同账户告警数超过阈值时产生账户级告警。
## 7. 业务事件与读入口请求检查
- [ ] 7.1 订单支付、套餐激活、停复机成功、设备操作(按设备下各卡)、自动续费购包 5 处 `AppendSeriesRequested` 改为事务提交后请求检查。
- [ ] 7.2 C 端资产详情、后台资产实时状态、C 端与后台实名链接改为请求检查;OpenAPI 查询保持不触发。
- [ ] 7.3 延迟确认:按场景在检查完成后追加确认到期;结果已满足预期时取消。
- [ ] 7.4 卡检查不再创建观测序列;`query_device_info` 等非运营商上游部分保持不变。
## 8. 整体切换
- [ ] 8.1 Worker 监听 `polling.scheduler_mode`:切 `carrier` 时停旧调度出队、初始化新结构后开启分发;切 `legacy` 时停止领取、等待执行中任务收尾、重建旧分片队列后恢复旧调度。
- [ ] 8.2 旧轮询 handler 执行前检查模式,为 `carrier` 时直接结束。
- [ ] 8.3 所有写入轮询结构的入口按当前模式写新或旧结构。
## 9. 接口与下线
- [ ] 9.1 `/polling-carrier-params` 列表(含默认标记)、详情、修改、删除;`/polling-check-scenes` 列表与修改;`/polling-failure-alerts` 列表、`/summary`、详情、`PUT /:id/status`;`GET /iot-cards/:id/polling-state`。
- [ ] 9.2 `/polling-stats` 改为按账户(队列长度、到期数、执行中、实际 QPS、限流等待与超时、处理中标记到期、告警数)。
- [ ] 9.3 下线 `/polling-concurrency`、`concurrency_service.go`、`acquireConcurrency`/`releaseConcurrency`、按 ZCARD 背压;旧提示通道 List 不再写入。
- [ ] 9.4 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,重新生成接口文档。
## 10. 编译与规范校验
- [ ] 10.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。
- [ ] 10.2 `openspec validate add-carrier-polling-scheduler` 与 `openspec doctor --json` 通过。
## 11. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001;按生产规模造卡或回放生产卡 ID)
- [ ] 11.1 多实例同时领取:同卡只被领取一次;同一账户每轮只领一次;执行中数不超过执行上限。
- [ ] 11.2 处理中崩溃:杀掉执行中的 Worker,租约到期后卡被重新领取,不丢卡。
- [ ] 11.3 长任务续期:执行超过单个租约时续期成功;构造 token 不一致时续期失败并停止后续步骤。
- [ ] 11.4 NSQ 重投:同一 token 两个消费者只有一个拿到执行权。
- [ ] 11.5 同卡串行:状态与流量同时到期时在一个卡任务中先后执行;某步失败后续步骤照常。
- [ ] 11.6 慢账户隔离:人为让账户 A 变慢,账户 B 与 Outbox 等 Asynq 业务不受影响。
- [ ] 11.7 请求检查:多次请求合并;冷却时长内不请求;执行中请求打优先标记;批量下单不形成单秒波峰;OpenAPI 查询不触发。
- [ ] 11.8 失败退避与告警:连续失败按序列退避并在阈值写告警,成功后自动恢复;账户级告警触发。
- [ ] 11.9 整体切换:切 `carrier` 时旧 Asynq 排队任务直接结束、初始化完成前不领取;实测约 10 万张卡初始化耗时并写入上线步骤;回退后旧分片队列完整、无丢卡;再次切换正常。
- [ ] 11.10 跨账户设备:设备下各卡分属不同账户时各自按账户执行,无重复执行。
- [ ] 11.11 按生产卡量评估 Redis 内存占用,确认轮询 key 不会被淘汰。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,31 @@
## Context
依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 12、13 节与 14.5 节。
现状:单资产接口 `PATCH /assets/:identifier/polling-status`(`internal/routes/asset.go:140`);`iot_card.Service.BatchUpdatePollingStatus` 仅设备级联内部使用(`asset_polling_service.go:96`);风险停机或已销户自动关闭(`internal/application/cardobservation/network.go:69`);支付成功自动恢复已有部分逻辑。导入任务可参照 `internal/routes/phone_asset_association.go` 的 `/unbind-imports`。生产 2026-10-24:8,420 张风险停机或已销户卡,均关闭轮询。
## Goals / Non-Goals
**Goals:** 人工批量开关轮询;清单可查、可导出、有历史;停机被拒集中处理;套餐生效类入口自动恢复与老系统一致。
**Non-Goals:** 导卡时直接设为不轮询(导卡后用文件导入关闭);调度读取清单表;激活状态计算口径。
## Decisions
1. **清单只做记录,调度只认 `enable_polling`**:避免为列表扫描卡大表,也避免两处状态不一致;所有写 `enable_polling` 的入口统一经一个"改轮询开关"函数,同事务写清单与审计。
2. **部分唯一索引**:`(asset_type, asset_id) WHERE status = 已关闭`,保证同一资产只有一条已关闭记录;恢复时更新该记录。
3. **批量逐项事务**:每项独立事务,部分失败不影响其他项;返回逐项结果。参照 `batch-update-realname-policy` 的请求与返回格式。
4. **导入任务**:沿用手机号资产解绑导入的上传与任务模型(文件 key 前缀、CSV、逐行结果)。
5. **自动恢复轮询的位置**:订单 `activatePackage`(覆盖支付、后台下单、批量下单、OpenAPI 钱包下单)与改有效期接口;恢复后调用"请求评估"(第 6 个 Change 上线前为现有复机路径)。
6. **存量补写**:迁移中按 `enable_polling = false` 一次性插入,来源按 `gateway_extend` 区分。
7. **停机被拒视图**:基于 `tb_polling_failure_alert` 的筛选视图,不新建表。
## Risks / Trade-offs
- [批量关闭误操作] → 确认框展示影响说明;清单保留操作人与原因,可在清单页批量恢复。
- [存量补写数据量] → 约 8,500 行,一次性迁移可接受。
- [自动恢复与人工关闭意图冲突] → 已定对齐老系统:客户购买或改有效期即恢复;需要长期停用的卡运营须另行处理。
## Migration Plan
1. 迁移建表并补写存量。2. 测试环境按 tasks 第 8 节核对。3. 维护者发布生产。4. 回退:代码回退;清单表保留。

View File

@@ -0,0 +1,33 @@
## Why
开关轮询目前只有单资产接口 `PATCH /assets/:identifier/polling-status` 与系统自动规则,没有人工批量操作,卡列表也不能按是否轮询筛选,也没有"谁、何时、为什么关闭"的记录;生产约 8,456 张关闭轮询的卡中约 8,420 张是风险停机或已销户自动关闭,人工关闭的卡无从查起。规则优先后,人工停机不享有豁免,需要保持停机的卡必须关闭轮询,运营需要批量处理与清单;停机被拒的卡也需要单独列出、导出与批量关闭。老系统"不轮询网卡"页面提供了对应能力。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 7 步。
## What Changes
- 卡、设备勾选批量开关轮询:单次最多 500,带目标状态与原因;数据权限校验;同一事务写 `enable_polling`、不轮询清单与审计;提交后按生命周期移入或移出轮询(设备沿用级联到卡)。
- 文件导入:上传 ICCID 或设备 IMEI 文件选择开启或关闭,异步任务逐行处理、逐行返回结果,部分成功不回滚。
- 批量开启跳过网关扩展状态为风险停机或已销户的卡,逐项返回跳过原因,不提供强制开启参数。
- 不轮询清单(新表 `tb_card_polling_pause`):记录关闭来源、原因、操作人、时间与恢复来源;每个资产只保留一条"已关闭"记录,恢复时标记"已恢复"并保留历史;所有改 `enable_polling` 的入口同事务写入(单资产接口、批量、导入、风险停机或已销户自动关闭、设备级联、自动恢复、告警页关闭)。调度不读本表。
- 存量补写:上线时为当前 `enable_polling = false` 的卡与设备补写清单,风险停机或已销户记为对应自动关闭来源,其余记为历史数据。
- **BREAKING** 套餐生效类入口自动恢复轮询:订单套餐生效(客户支付成功、后台下单、批量下单、OpenAPI 钱包下单)、后台修改套餐有效期后资产有生效套餐时,先把卡或设备恢复轮询(写清单与审计)再按规则评估复机;风险停机或已销户的卡不恢复。
- 不轮询清单页面:按运营商账户、来源、状态、ICCID、操作人、时间筛选,导出复用统一导出任务(场景 `polling_pause`),列表勾选可批量开启。
- 卡列表、设备列表增加"是否轮询"筛选,导出带上该字段。
- 失败告警页增加"停机被拒"视图与导出(场景 `polling_stop_refused_alert`),可勾选批量关闭轮询(来源"停机被拒告警处理")。
- 关闭与开启轮询的确认框须提示影响(关闭后不查上游、流量用完与实名不满足不自动停机、激活与重置不自动复机;套餐到期与退款后无套餐仍停机;订单生效与改有效期会自动恢复)。
## Capabilities
### New Capabilities
- `polling-pause-list`:批量与文件导入开关轮询、不轮询清单与历史、所有开关入口同事务写清单、存量补写、套餐生效类自动恢复轮询、清单查询与导出、卡与设备列表是否轮询筛选。
### Modified Capabilities
- `carrier-polling-scheduler`:失败告警增加"停机被拒"视图、导出与批量关闭轮询(该能力由第 5 个 Change 引入)。
## Impact
- 依赖:开关与清单部分无前置;停机被拒视图依赖第 5 个 Change 的失败告警表与第 6 个 Change 的停复机方向告警;自动恢复轮询后的"按规则评估"在第 6 个 Change 上线前沿用现有复机路径。
- 数据库:新增 `tb_card_polling_pause`(部分唯一索引:同一资产仅一条已关闭)与存量补写迁移;新的成对迁移。
- 代码:`internal/service/iot_card` 与 `internal/service/device` 批量接口;`internal/service/polling/asset_polling_service.go`;导入任务(参照 `phone_asset_association` 的 `/unbind-imports`);`internal/application/cardobservation/network.go` 自动关闭写清单;`internal/service/order/service.go` `activatePackage` 与改有效期接口自动恢复;卡、设备列表 Query 与导出;导出场景注册。
- 接口:新增 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`、`/polling-status-imports`(创建、列表、详情)、`GET /card-polling-pauses`、`POST /polling-failure-alerts/close-polling`、`GET /polling-failure-alerts?view=stop_refused`;卡与设备列表增加 `enable_polling` 筛选。前端新增不轮询清单页、导入页,卡与设备列表增加筛选与批量操作,告警页增加停机被拒视图。

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 停机被拒视图与批量关闭轮询
系统 SHALL 在失败告警中提供"停机被拒"视图:停复机方向为停机,且错误类型为终态或业务错误、或停机失败次数达到阈值的告警;展示 ICCID、运营商账户、所属资产、网络状态、套餐状态、停机原因、上游返回码与信息、首次与最近失败时间、失败次数、是否已关闭轮询、处理状态与处理人。视图 SHALL 可通过统一导出任务导出(场景 `polling_stop_refused_alert`)。系统 SHALL 支持在告警列表勾选批量关闭对应卡或设备的轮询(单次最多 500,规则同批量开关轮询),清单来源记为"停机被拒告警处理",对应告警状态变为"已关闭轮询"。
#### Scenario: 导出停机被拒
- **WHEN** 运营导出停机被拒视图
- **THEN** 系统创建导出任务,文件包含上述字段
#### Scenario: 告警页批量关闭
- **WHEN** 运营勾选 50 条停机被拒告警关闭轮询
- **THEN** 对应卡关闭轮询、写清单来源"停机被拒告警处理",告警状态变为已关闭轮询

View File

@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: 勾选批量开关轮询
系统 SHALL 提供卡与设备的批量开关轮询:请求包含资产 ID 列表(单次最多 500)、目标状态与原因;系统 MUST 校验数据权限,逐项处理并返回每项结果。每项在同一事务内写 `enable_polling`、不轮询清单与审计,事务提交后按生命周期移入或移出轮询;设备沿用现有级联到设备下各卡。批量开启 MUST 跳过网关扩展状态为风险停机或已销户的卡并返回跳过原因,不提供强制开启参数。
#### Scenario: 批量关闭
- **WHEN** 运营勾选 200 张卡关闭轮询并填写原因
- **THEN** 200 张卡的 `enable_polling` 变为关闭,各写一条"已关闭"清单记录与审计,并移出轮询
#### Scenario: 批量开启含已销户卡
- **WHEN** 勾选的卡中有 3 张已销户
- **THEN** 这 3 张返回"已销户,不能开启轮询",其余卡照常开启
#### Scenario: 超过单次上限
- **WHEN** 请求包含 501 个资产
- **THEN** 系统拒绝请求
#### Scenario: 越权资产
- **WHEN** 列表中包含操作者无数据权限的资产
- **THEN** 该项返回无权限,其他项照常处理
### Requirement: 文件导入开关轮询
系统 SHALL 支持上传 ICCID(或设备 IMEI)文件并选择开启或关闭,以异步任务逐行处理;每行独立成功或失败并记录原因,部分成功不回滚;提供任务创建、列表与详情(含逐行结果)。逐行处理规则与勾选批量相同。
#### Scenario: 文件中有不存在的 ICCID
- **WHEN** 导入 1000 行,其中 5 行 ICCID 不存在
- **THEN** 995 行按目标状态处理,5 行记"资产不存在",任务结束状态为部分成功
### Requirement: 不轮询清单与历史
系统 SHALL 在每次 `enable_polling` 由开启变为关闭时新增一条"已关闭"清单记录(资产类型、资产 ID、ICCID 或 IMEI、卡所属运营商账户、关闭来源、原因、操作人、时间、导入任务),同一资产 MUST 只有一条"已关闭"记录;由关闭变为开启时把该记录标为"已恢复"并写恢复来源、操作人与时间,记录保留。所有改 `enable_polling` 的入口 MUST 在同一事务内写清单:单资产接口、勾选批量、文件导入、风险停机或已销户自动关闭、设备级联、套餐生效类自动恢复、告警页关闭轮询。调度判断 MUST 只以卡与设备的 `enable_polling` 为准,不读本表。上线时系统 MUST 为当前关闭轮询的卡与设备补写清单:网关扩展状态为风险停机或已销户的记为"风险停机或已销户自动关闭",其余记为"历史数据"。
#### Scenario: 再次关闭
- **WHEN** 已恢复的卡再次被关闭
- **THEN** 系统新增一条"已关闭"记录,历史记录保留
#### Scenario: 自动关闭
- **WHEN** 查卡状态返回已销户而自动关闭轮询
- **THEN** 同一事务写一条来源为"风险停机或已销户自动关闭"的清单记录
### Requirement: 套餐生效类入口自动恢复轮询
系统 SHALL 在订单套餐生效(客户支付成功、后台下单、批量下单、OpenAPI 钱包下单)以及后台修改套餐有效期后资产有生效套餐时,先把关闭轮询的卡或设备恢复轮询(写清单"已恢复"与审计),再按停复机规则评估复机。网关扩展状态为风险停机或已销户的卡 MUST NOT 恢复。排队激活、实名激活、流量重置、运营商账户实名管控修改 MUST NOT 自动恢复轮询。
#### Scenario: 关闭轮询的卡客户续费
- **WHEN** 被人工关闭轮询的停机卡客户支付购买套餐并生效
- **THEN** 系统恢复该卡轮询,随后按规则复机
#### Scenario: 已销户卡下单
- **WHEN** 已销户卡后台下单套餐生效
- **THEN** 系统不恢复该卡轮询
#### Scenario: 排队激活
- **WHEN** 关闭轮询的卡排队套餐自动激活
- **THEN** 系统不恢复轮询,不自动复机
### Requirement: 清单查询、导出与是否轮询筛选
系统 SHALL 提供不轮询清单列表(按运营商账户、来源、状态、ICCID、操作人、时间筛选)与导出(统一导出任务,场景 `polling_pause`),列表勾选可批量开启。卡列表与设备列表 SHALL 支持按是否轮询筛选,导出 MUST 带上是否轮询字段。查询与导出 MUST 遵守现有数据权限。
#### Scenario: 筛选人工关闭
- **WHEN** 运营按来源"勾选批量"筛选不轮询清单
- **THEN** 返回人工批量关闭的记录,不被系统自动关闭的记录淹没
### Requirement: 开关轮询的影响提示
系统 SHALL 在关闭与开启轮询的接口文档与返回中提供影响说明,供前端确认框展示:关闭后不查上游,流量用完、实名不满足不自动停机,激活、流量重置不自动复机;套餐到期、退款后无套餐仍自动停机;订单套餐生效、改有效期后有生效套餐会自动恢复轮询并按规则复机;人工停复机照常。设备关闭时设备下所有卡同样适用。
#### Scenario: 关闭确认
- **WHEN** 前端请求关闭轮询前获取影响说明
- **THEN** 系统返回上述影响条目

View File

@@ -0,0 +1,49 @@
## 0. 前置确认
- [ ] 0.1 停机被拒视图实施前确认第 5、6 个 Change 已上线;开关与清单部分可先行。
## 1. 迁移与模型
- [ ] 1.1 新增成对迁移:建 `tb_card_polling_pause`(资产类型、资产 ID、ICCID 或 IMEI、运营商账户、状态、关闭来源、原因、操作人、关闭时间、导入任务 ID、恢复来源、恢复人、恢复时间;部分唯一索引 `(asset_type, asset_id) WHERE status = 已关闭`);down 删表。
- [ ] 1.2 同迁移补写存量:`enable_polling = false` 的卡与设备,风险停机或已销户记"风险停机或已销户自动关闭",其余记"历史数据"。
- [ ] 1.3 新增 model 与 Store。
## 2. 统一的轮询开关写入
- [ ] 2.1 新增"改轮询开关(资产、目标状态、来源、原因、操作人)":同事务写 `enable_polling`、清单与审计,提交后调用生命周期移入或移出。
- [ ] 2.2 改造全部入口经此函数:单资产接口、风险停机或已销户自动关闭(`network.go`)、设备级联、自动恢复、告警页关闭。
## 3. 批量与导入
- [ ] 3.1 `POST /iot-cards/batch-update-polling-status`、`POST /devices/batch-update-polling-status`:单次 500、数据权限、逐项事务与结果、批量开启跳过风险停机与已销户。
- [ ] 3.2 文件导入:`POST /polling-status-imports`、`GET /polling-status-imports`、`GET /polling-status-imports/:id`;异步逐行处理与结果。
## 4. 自动恢复轮询
- [ ] 4.1 `order/service.go` `activatePackage` 套餐生效后:关闭轮询且非风险停机、已销户的卡或设备先恢复轮询(来源"支付或下单生效"),再请求评估(第 6 个 Change 前为现有复机路径)。
- [ ] 4.2 改有效期接口:保存后资产有生效套餐时同样恢复(来源"改有效期")。
## 5. 查询、筛选与导出
- [ ] 5.1 `GET /card-polling-pauses`(筛选与分页);导出场景 `polling_pause`;清单页勾选批量开启复用 3.1。
- [ ] 5.2 卡列表、设备列表 Query 增加 `enable_polling` 筛选,导出带该字段。
- [ ] 5.3 开关轮询影响说明随接口文档与返回提供。
## 6. 停机被拒视图
- [ ] 6.1 `GET /polling-failure-alerts?view=stop_refused` 与展示字段;导出场景 `polling_stop_refused_alert`。
- [ ] 6.2 `POST /polling-failure-alerts/close-polling`:单次 500,经统一开关写入,来源"停机被拒告警处理",告警状态改为已关闭轮询。
## 7. 编译与规范校验
- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;重新生成接口文档。
- [ ] 7.2 `openspec validate add-polling-pause-list-and-batch-toggle` 与 `openspec doctor --json` 通过。
## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001)
- [ ] 8.1 批量关闭与开启:逐项结果正确;风险停机与已销户跳过;超过 500 被拒;越权项返回无权限。
- [ ] 8.2 文件导入部分成功不回滚,逐行原因正确。
- [ ] 8.3 清单:每个入口改开关都写清单;同一资产只有一条已关闭;恢复后保留历史;存量补写数量与生产关闭轮询的卡设备数一致。
- [ ] 8.4 自动恢复:支付、后台下单、批量下单、OpenAPI 下单、改有效期后恢复轮询并按规则复机;已销户不恢复;排队激活不恢复。
- [ ] 8.5 卡与设备列表是否轮询筛选与导出字段正确;清单导出与停机被拒导出可用。
- [ ] 8.6 告警页批量关闭轮询后清单来源与告警状态正确。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,37 @@
## Context
完整设计见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 6、6.1、6.2、11.5、12 节,第 9 节停复机出口开关。
现状:`StopResumeService.EvaluateAndAct`(`stop_resume_service.go:113`)判断后当场调网关,`stopCardWithRetry` / `resumeCardWithRetry` 3 次、每次 sleep 2 秒;设备维度原因抢 `RedisPollingDeviceOpLockKey`(SetNX 30 秒,抢不到跳过)逐张停;`resumeAfterActivation`(4 处)、`reset_service.go:400`、`order/service.go:2196` 起 goroutine 调 `ResumeCardIfStopped`;退款 `refund/service.go:1258` 调 `ManualStopCard`,失败由 Outbox 与 `RecoverRefundPostProcessing` 反复补发;套餐到期由套餐轮询发现。第 5 个 Change 已提供账户队列、分发器、NSQ、失败告警表;第 1 个 Change 已提供限流、错误分类与停复机记录。
## Goals / Non-Goals
**Goals:** 自动停复机一个出口、不风暴、不翻转、不丢;设备下各卡一致;暂停轮询的卡套餐失效照常停。
**Non-Goals:** 不改停复机业务规则(第 4 个 Change 已定口径);不改后台人工点击、通道阈值、自动续费的停复机;不做提前停机、停机前续费等老系统规则。
## Decisions
1. **Redis 结构**:账户 `polling:carrier:{cid}:stop_resume`(ZSET)与 `:sr_running`;卡 `polling:card:{id}:stop_resume_cmd`(Hash:动作、原因、场景、来源、版本、失败计数、首次失败、最近错误、待确认)、`:sr_claim`、`:sr_exec`、`:sr_last_call`;设备 `polling:device_eval:queue`(ZSET)、`polling:device:{id}:eval_ver`、`:eval_scenes`(Set)、`:eval_claim`、`:eval_lock`;分发锁 `polling:device_eval:dispatch_lock`。
2. **统一判断函数**:从 `EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 抽出"输入卡与资产数据 + 场景 → 目标状态与原因",入口与执行器共用;执行部分抽出供执行器调用一次网关。
3. **版本号**:覆盖指令 `HINCRBY ver`;领取时随消息带版本;收尾 Lua 比较版本决定移除或保留。
4. **单卡最小重试间隔按卡合计**:上游"卡状态变更每十分钟只允许调用 1 次"对停机与复机共同计数;领取 Lua 校验 `sr_last_call`,执行器调用前写。
5. **结果未知在队列内闭环**:不复用现有人工核对策略(`GatewayCardCommandUnknownRecoveryStrategy` 只有人工核对);确认查询属于查状态,计入停复机所在限流组,不受单卡最小间隔约束。
6. **设备判断**:`ZADD LT` 合并;`SADD eval_scenes`;领取带版本与场景快照;收尾 `SREM` 已处理场景并比较版本。锁内只读与写指令,毫秒级;与旧路径的 `RedisPollingDeviceOpLockKey` 互斥。
7. **全局开关**:`polling.stop_resume_queue_enabled` 受控系统配置;所有入口通过一个"请求评估"门面按开关决定入队还是旧路径当场执行,避免分散判断。停复机与设备判断的分发不受 `scheduler_mode` 限制:开关开启后对全部账户领取(`scheduler_mode = legacy` 时套餐步骤仍在旧 handler 内,同样改为请求评估)。
8. **退款完成语义**:请求写入成功即标记 `asset_reset`,补偿扫描只覆盖"请求未写入"的情况,不再因停机失败重放。
9. **到期即停入口**:`package_activation_handler.go` 到期失效处理处判断无其他有效主套餐后请求评估(场景"套餐到期");接续激活路径不变。
## Risks / Trade-offs
- [停复机比现在晚 1~2 秒] → 已接受;停复机先于卡任务领取并可预留 QPS。
- [请求评估写 Redis 失败] → 记日志,由该卡下一次套餐步骤兜底;暂停轮询的卡套餐失效停机按已定口径不补偿。
- [流量重置日集中复机] → 按账户停复机 QPS 与执行上限平滑执行,监控队列长度。
- [开关切换中旧路径与新路径并存] → 设备锁互斥;开关关闭时队列指令保留,重新开启时执行前重新判断。
## Migration Plan
1. 迁移插入开关(默认关闭)与告警阈值。
2. 测试环境部署,开关关闭时行为与现状一致;开启后按 tasks 第 10 节核对;演练开、关、再开。
3. 生产发布后开启开关,首日监控停复机成功率、队列长度与失败告警。
4. 回退:关闭开关。

View File

@@ -0,0 +1,35 @@
## Why
自动停复机没有独立队列,在哪个任务里触发就在哪里同步调网关并 sleep 重试;激活、重置、订单后的复机在裸 goroutine 里执行,失败只打日志;退款走人工停机接口,失败后 Outbox 重投与补偿扫描反复补发(生产 2026-10-22:24 小时失败约 26.8 万次);设备下多张卡(生产约 58% 设备跨运营商账户)可能被并发的判断互相覆盖,造成翻转。另外暂停轮询的卡套餐到期、退款后无套餐也应停机,但现有设计会漏停。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 6 步。
## What Changes
- 新增停复机指令队列:所有自动入口(套餐步骤、Outbox 三个卡观测消费者、套餐激活、流量重置、订单支付后、退款后资产处理、套餐到期、运营商账户实名管控修改)只"请求评估",由统一判断函数生成指令,进入卡所属运营商账户的停复机队列,优先级最高、先于卡任务领取。
- 同卡只保留一条指令,新覆盖旧并递增版本号;收尾按版本号判断,执行期间生成的新指令不会被删除。
- 执行器:执行前按规则重新判断;只调用一次上游、不 sleep 重试;单卡最小重试间隔(停机与复机合计,按账户配置);结果未知按"确认延迟 → 查状态 → 已是目标状态结束 / 否则按间隔重发"处理,不沿用现有人工核对恢复;失败按错误分类退避、告警,指令不丢弃;终态不重试。
- 设备维度判断队列:按设备合并请求、保存并合并请求场景、版本号再判断、设备锁内只判断不调网关,为设备下各卡生成指令进各自账户队列。
- 到期即停:套餐到期且资产无其他有效套餐时,以"套餐到期"场景直接请求评估(不经过套餐步骤)。
- **BREAKING** 暂停轮询口径:暂停轮询的卡或设备,套餐到期、退款后无可用主套餐照常停机(只触发一次,不做补偿扫描);其他自动停复机仍不做。
- **BREAKING** 退款后资产处理不再调用 `ManualStopCard`:改为请求评估,请求写入成功即视为资产后处理完成,停机失败由指令队列退避与告警处理,不再触发 Outbox 重投与补偿扫描。
- **BREAKING** 激活、重置、订单后的复机去掉裸 goroutine,改为事务提交后请求评估。
- 运营商账户实名管控修改后对受影响的卡批量请求评估(0 → 1 在线未实名,1 → 0 停机未实名),按账户 QPS 平滑执行。
- 全局开关 `polling.stop_resume_queue_enabled`(受控系统配置,默认关闭):开启后所有账户的自动停复机经指令队列,不按账户灰度;关闭即回退为各入口当场执行。
- 停复机方向的失败接入失败告警表;监控增加停复机成功、失败、重试与指令队列长度。
## Capabilities
### New Capabilities
- `stop-resume-command-queue`:请求评估与统一判断、指令覆盖与版本、执行器与单卡最小重试间隔、结果未知确认、失败退避与告警、设备维度判断与场景合并、自动入口统一、全局开关与回退。
### Modified Capabilities
- `card-stop-resume`:"暂停轮询的卡不自动停复机"增加套餐到期与退款后无套餐照常停机;新增"套餐到期即停"。
## Impact
- 依赖:第 1 个(限流、错误分类、停复机记录)、第 4 个(新规则转正)、第 5 个(账户调度、NSQ、失败告警表)Change 已上线。
- 数据库:`tb_system_config` 插入 `polling.stop_resume_queue_enabled` 与结果未知告警阈值;新的成对迁移。
- 代码:`internal/service/iot_card/stop_resume_service.go`(`EvaluateAndAct` 只判断、生成指令;抽出执行部分)、`stop_resume_unified.go`;`internal/infrastructure/cardobservation/event.go`;`internal/service/package/activation_service.go`、`reset_service.go`、`internal/service/order/service.go`;`internal/service/refund/service.go` `stopAsset` 与 `internal/infrastructure/commissiondelivery/event.go` 补偿扫描;`internal/task/package_activation_handler.go`(到期即停);`internal/service/carrier/service.go`;分发器与 NSQ 消费者新增停复机与设备判断;`RedisPollingDeviceOpLockKey` 轮询路径替换。
- 接口:调度参数中的停复机参数生效;`/polling-failure-alerts` 包含停复机方向;`/iot-cards/:id/polling-state` 返回当前指令;监控增加停复机指标。
- 行为:自动停复机比现在晚约 1~2 秒;后台人工点击、通道阈值、自动续费的停复机仍直接执行。

View File

@@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: 暂停轮询的卡不自动停复机
卡或其绑定设备暂停轮询的,系统 SHALL NOT 对该卡发起由检查结果或周期评估触发的自动停复机(流量用完、实名不满足、卡状态检查后的停复机、套餐步骤评估),也 SHALL NOT 发起套餐激活、套餐重置、充值、排队激活、实名激活、运营商账户实名管控修改等入口触发的自动复机;人工停机与人工复机照常执行。套餐失效类停机 MUST 照常执行,不看暂停轮询:套餐到期且资产无其他有效套餐、退款后资产无可用主套餐时,系统按"套餐到期"或"退款"场景请求评估并停机;该停机对暂停轮询的卡只触发一次,之后由停复机指令按失败处理重试,请求写入失败或缓存数据丢失时不做补偿扫描。请求评估与停复机指令 MUST 携带场景,执行前重新判断沿用同一场景。网关扩展状态为风险停机或已销户的卡 MUST 关闭该卡自身轮询,不论是否绑定设备:查卡状态返回该扩展状态时关闭;停复机调用直接返回命中"自动关闭轮询"规则的终态时,同样关闭并回写网关扩展状态。关闭只作用于该卡,MUST NOT 级联关闭设备与设备下其他卡。机卡分离停机等其他运营商异常状态 MUST NOT 自动关闭轮询,由人工判断。未暂停轮询的卡,不论运营商扩展状态如何,均按停机与复机条件处理,上游拒绝复机时按现有失败处理。
#### Scenario: 暂停轮询的卡充值后
- **WHEN** 已被人工暂停轮询的停机卡充值并激活套餐
- **THEN** 系统不自动复机;人工复机照常可用
#### Scenario: 暂停轮询的卡套餐到期
- **WHEN** 暂停轮询的开机卡套餐到期且无其他有效套餐
- **THEN** 系统以"套餐到期"场景生成停机指令并执行
#### Scenario: 暂停轮询的卡退款后
- **WHEN** 暂停轮询的开机卡退款后资产无可用主套餐
- **THEN** 系统以"退款"场景生成停机指令并执行,只触发一次
#### Scenario: 暂停轮询的卡流量用完
- **WHEN** 暂停轮询的卡流量用完
- **THEN** 系统不自动停机
#### Scenario: 恢复轮询后
- **WHEN** 维护者为该卡恢复轮询
- **THEN** 下一次评估起系统按停机与复机条件正常处理
#### Scenario: 设备卡出现机卡分离停机
- **WHEN** 设备下的卡运营商扩展状态为机卡分离停机且未被暂停轮询
- **THEN** 系统不自动暂停轮询,按停机与复机条件处理
#### Scenario: 设备下的卡被销户
- **WHEN** 查卡状态返回绑定设备的某张卡扩展状态为已销户
- **THEN** 系统关闭该卡轮询,设备与设备下其他卡的轮询不变
#### Scenario: 复机返回风险停机
- **WHEN** 复机调用返回"当前卡为风险停机状态,仅支持风险复机"且该规则标记自动关闭轮询
- **THEN** 系统不重试,关闭该卡轮询并回写扩展状态为风险停机
## ADDED Requirements
### Requirement: 套餐到期即停
系统 SHALL 在套餐到期失效处理时,若该资产已无其他有效主套餐,直接以"套餐到期"场景请求停复机评估:独立卡当场判断并生成停机指令,设备请求设备维度判断并记录该场景;MUST NOT 依赖卡任务的套餐步骤发现过期。存在待生效的后续主套餐时,按现有到期接续流程先激活后续套餐,不发起停机。
#### Scenario: 独立卡到期无后续套餐
- **WHEN** 独立卡主套餐到期且无后续主套餐
- **THEN** 系统立即生成 `no_package` 停机指令,不等待下一次套餐检查
#### Scenario: 到期有后续套餐
- **WHEN** 主套餐到期且存在待生效的后续主套餐
- **THEN** 系统按接续流程激活后续套餐,不生成停机指令

View File

@@ -0,0 +1,81 @@
## ADDED Requirements
### Requirement: 自动停复机统一经指令队列
在 `polling.stop_resume_queue_enabled` 开启时,系统 SHALL 让套餐步骤、Outbox 卡观测消费者(流量增加、网络状态变化、实名变化)、套餐激活、流量重置、订单支付后、退款后资产处理、套餐到期、运营商账户实名管控修改这些自动入口只请求评估:按统一判断函数(暂停轮询 → 停机条件 → 复机条件 → 通道阈值锁)得出目标状态,需要动作时生成停复机指令并放入卡所属运营商账户的停复机队列,MUST NOT 在入口当场调用网关。请求评估本身只写共享缓存;写入失败 MUST 记日志,并由该卡下一次套餐步骤兜底。后台人工点击、通道阈值、自动续费发起的停复机 MUST 照旧直接执行。开关关闭时各入口 MUST 恢复当场执行;队列中未执行的指令保留,再次开启后继续执行,执行前重新判断。开关 MUST 全局生效,不按运营商账户灰度。
#### Scenario: 订单支付后复机
- **WHEN** 客户支付后套餐生效,卡处于停机且满足复机条件
- **THEN** 订单事务提交后系统生成复机指令,由指令执行器调用上游,支付流程不等待也不起 goroutine
#### Scenario: 开关关闭
- **WHEN** 超级管理员关闭 `polling.stop_resume_queue_enabled`
- **THEN** 各自动入口恢复当场执行,已在队列中的指令保留不删
### Requirement: 同卡一条指令与版本号
系统 SHALL 对同一张卡只保留一条停复机指令,新指令覆盖旧指令,方向相反时以最新判断为准、失败计数清零;每次生成或覆盖 MUST 递增指令版本号。执行器领取时记下版本号;收尾时版本号与领取时一致才移除指令,不一致 MUST 保留新指令,并把执行时间设为 max(当前时间, 本卡最近一次停复机调用时间 + 单卡最小重试间隔)。
#### Scenario: 停机执行中客户付费
- **GIVEN** 卡 A 停机指令(版本 1)正在执行
- **WHEN** 执行期间客户付费生成复机指令(版本 2),随后停机成功
- **THEN** 收尾保留复机指令,在单卡最小重试间隔后执行复机
### Requirement: 指令执行与单卡最小重试间隔
系统 SHALL 优先于卡任务领取停复机指令,领取数不超过停复机所在限流组本秒剩余 QPS 与账户停复机执行上限减执行中数。同一张卡两次停复机网关调用(停机与复机合计)间隔 MUST 不小于账户配置的单卡最小重试间隔,未到间隔时推后执行时间、不发起调用。执行前系统 MUST 按指令记录的场景重新判断,已是目标状态或条件不再满足则结束指令。网关只调用一次,不 sleep 重试;成功后按现有回写更新卡状态、停机原因与网络状态序列,并写停复机记录。
#### Scenario: 停机后几分钟内购买套餐
- **GIVEN** 卡 A 5 分钟前被停机,单卡最小重试间隔 10 分钟
- **WHEN** 客户购买套餐生成复机指令
- **THEN** 系统在停机后满 10 分钟时才调用复机,期间不调用上游
#### Scenario: 执行前已是目标状态
- **WHEN** 停机指令执行前重新判断发现卡已停机
- **THEN** 系统结束指令,不调用上游停机
### Requirement: 停复机结果未知的确认
停复机调用超时、连接失败或上游返回"调用成功""操作进行中""请求被合并"等已受理类频率限制时,系统 SHALL 置待确认标记并保留指令,在账户配置的确认延迟后再次领取:先按规则重新判断,条件不再满足则结束;否则查一次卡状态(受限流、不受单卡最小重试间隔约束),已是目标状态则按成功回写并结束;不是目标状态则清除待确认,满足单卡最小重试间隔时重新调用停复机,否则推后;查状态失败 MUST 保持待确认并按退避重查,不发起停复机。连续结果未知达到阈值时写失败告警,指令不丢弃。
#### Scenario: 超时后确认已停机
- **WHEN** 停机超时,确认延迟后查状态显示已停机
- **THEN** 系统按成功回写,结束指令,停复机记录结果为确认成功
#### Scenario: 查状态也失败
- **WHEN** 确认查询失败
- **THEN** 系统不发起停机,按退避再次查询
### Requirement: 停复机失败分类处理
系统 SHALL 按错误分类处理停复机失败:频率限制不计连续失败,按单卡最小重试间隔与上游窗口退避;终态不可操作不重试、结束指令、写告警(标记自动关闭轮询的终态按 `card-stop-resume` 关闭该卡轮询),停机方向且上游已不可用时按目标状态视为已停;已是目标状态按成功处理;账户配置错误首次即产生账户级告警;临时故障、网络超时、业务错误计入连续失败按退避重试。达到告警阈值后写失败告警,指令 MUST NOT 丢弃,按最长退避继续重试。
#### Scenario: 停机持续失败
- **WHEN** 停机连续失败达到阈值
- **THEN** 系统写停复机方向的失败告警,指令保留并按最长退避继续重试
### Requirement: 设备维度判断合并
需要设备维度判断时,系统 SHALL 不在调用方当场判断,而是请求一次设备判断:递增设备判断版本号、把请求场景加入设备的场景集合、设备加入设备判断队列(同一设备只有一个成员)。设备判断执行时 MUST 抢设备判断锁(锁内不调网关),读取最新数据按统一判断函数判断(各卡按所属运营商账户判断实名,暂停轮询按场景集合判断),需要动作时为设备下每张卡生成指令(记录触发场景)进入各卡所属账户的停复机队列。收尾时只移除本次已处理的场景;版本号变大时 MUST 用最新数据再判断一次。同一设备同一时刻只有一个判断在执行。旧路径正在对该设备执行停复机时(持现有设备锁),设备判断 MUST 推后、不并行执行。
#### Scenario: 跨账户设备流量用完
- **GIVEN** 设备 D 有 3 张卡分属联通、移动、广电账户
- **WHEN** 卡 1 流量入账使设备流量用完,同时卡 2 的消费者读到扣减前数据
- **THEN** 两次请求合并为一次设备判断,按最新数据为 3 张卡各生成停机指令,进入各自账户队列
#### Scenario: 到期与普通请求合并
- **GIVEN** 设备 D 已暂停轮询
- **WHEN** 设备套餐到期请求与一次普通轮询请求在判断前合并
- **THEN** 场景集合含"套餐到期",系统按套餐失效放行停机
### Requirement: 自动入口的改造语义
系统 SHALL 按以下语义改造自动入口:套餐激活、流量重置、订单支付后在业务事务提交后请求评估,不起 goroutine 当场复机;退款后资产处理在资产无可用主套餐时请求评估(设备按设备维度判断),请求写入成功即标记资产后处理完成,MUST NOT 调用人工停机接口,MUST NOT 因停机失败触发 Outbox 重投或补偿扫描;运营商账户实名管控由 0 改为 1 时对该账户在线且未实名的卡、由 1 改为 0 时对停机且未实名的卡批量请求评估(设备卡按设备),按账户 QPS 平滑执行;卡或设备恢复轮询时不另加触发,由套餐步骤随即评估。
#### Scenario: 退款后上游终态
- **WHEN** 退款后资产无可用主套餐,卡在上游已销户
- **THEN** 退款资产后处理标记完成,停机指令按终态结束并告警,不再反复补发
#### Scenario: 开启实名管控
- **WHEN** 超级管理员把账户 A 实名管控由 0 改为 1
- **THEN** 系统对 A 下在线未实名的卡请求评估,停机按 A 的停复机 QPS 逐步执行

View File

@@ -0,0 +1,61 @@
## 0. 前置确认
- [ ] 0.1 确认第 1、4、5 个 Change 已上线。
## 1. 迁移与配置
- [ ] 1.1 新增成对迁移:`tb_system_config` 插入 `polling.stop_resume_queue_enabled`(默认 false)、结果未知告警阈值、停复机退避序列与告警阈值;注册受控系统配置项。
## 2. 统一判断与请求评估
- [ ] 2.1 抽出统一判断函数(暂停轮询按场景 → 停机条件 → 复机条件 → 通道阈值锁),`EvaluateAndAct`、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 改为调用它。
- [ ] 2.2 新增"请求评估(卡或设备、场景、来源)"门面:开关开启时单卡生成指令、设备请求设备判断;关闭时走旧路径当场执行。
- [ ] 2.3 指令写入 Lua:覆盖并 `HINCRBY ver`、方向相反清零失败计数、放入卡所属账户 `stop_resume`。
## 3. 指令领取与执行
- [ ] 3.1 分发器在卡任务之前领取 `stop_resume`:清理过期 `sr_running`,领取数 = min(剩余 QPS, `sr_inflight_limit` − 执行中),校验 `sr_last_call` 推后,写 `sr_claim` 并记版本;开关开启后对全部账户领取。
- [ ] 3.2 NSQ `polling.stop_resume.{cid}` 专用消费者:核对 token、抢 `sr_exec`、续期。
- [ ] 3.3 执行器:按场景重新判断 → 写 `sr_last_call` → 调用一次网关(限流等待模式)→ 成功沿用现有回写与停复机记录;收尾按版本移除或保留。
- [ ] 3.4 结果未知:置待确认,确认延迟后先判断再查状态,按规则结束、重发或推后;查询失败不重发;连续未知达阈值告警。
- [ ] 3.5 失败分类处理:频率限制、终态(含自动关闭轮询)、已是目标状态、账户配置错误、临时故障与业务错误;停复机方向写入 `tb_polling_failure_alert`;指令不丢弃。
## 4. 设备维度判断
- [ ] 4.1 请求设备判断 Lua:`INCR eval_ver`、`SADD eval_scenes`、`ZADD LT device_eval:queue`。
- [ ] 4.2 分发与消费:抢 `device_eval:dispatch_lock` 领取,NSQ `polling.device_eval`;消费者抢 `eval_lock`(检查旧路径设备锁),读最新数据判断,为各卡写指令(记录场景)。
- [ ] 4.3 收尾:`SREM` 已处理场景,比较版本,变大则 score 设为当前时间再判断。
- [ ] 4.4 轮询路径不再使用 `RedisPollingDeviceOpLockKey`;人工设备停复机保持不变。
## 5. 自动入口改造
- [ ] 5.1 套餐步骤与 Outbox 三个卡观测消费者改为请求评估,不再同步调网关、不 sleep 重试。
- [ ] 5.2 `resumeAfterActivation`(4 处)、`reset_service.go:400`、`order/service.go:2196` 去掉 goroutine,事务提交后请求评估。
- [ ] 5.3 `refund/service.go` `stopAsset` 改为请求评估,写入成功即标记 `asset_reset`;`RecoverRefundPostProcessing` 只补偿"请求未写入"。
- [ ] 5.4 `package_activation_handler.go` 到期失效处:无其他有效主套餐时以"套餐到期"场景请求评估;接续激活路径不变。
- [ ] 5.5 `carrier/service.go` 实名管控修改后按 0 → 1、1 → 0 筛选受影响卡批量请求评估(设备卡按设备)。
## 6. 监控与接口
- [ ] 6.1 `/polling-stats` 按账户增加停复机队列长度、执行中、成功、失败、重试、结果未知数。
- [ ] 6.2 `/iot-cards/:id/polling-state` 返回当前指令(动作、原因、场景、版本、失败次数、待确认)。
- [ ] 6.3 重新生成接口文档。
## 7. 编译与规范校验
- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。
- [ ] 7.2 `openspec validate add-stop-resume-command-queue` 与 `openspec doctor --json` 通过。
## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001)
- [ ] 8.1 开关关闭:各入口行为与上线前一致。
- [ ] 8.2 指令覆盖:停机执行中写入复机指令,收尾后复机保留并在最小间隔后执行;无覆盖时指令被移除。
- [ ] 8.3 单卡最小重试间隔:间隔内不调用上游,score 推后到可调用时间。
- [ ] 8.4 结果未知:超时后确认延迟查状态,已是目标状态结束、否则按间隔重发、查询失败不重发。
- [ ] 8.5 失败分类:频率限制不计失败、终态不重试并按标记关闭轮询、已是目标状态按成功、持续失败告警且指令不丢。
- [ ] 8.6 设备判断:判断期间新请求使版本变化后再判断;到期与普通请求合并后场景保留;跨账户设备下各卡最终状态一致。
- [ ] 8.7 暂停轮询的卡:套餐到期与退款后停机执行一次;流量用完、激活、重置不自动停复机。
- [ ] 8.8 退款:上游终态卡退款后不再反复补发停机;资产后处理标记完成。
- [ ] 8.9 激活、重置、订单后复机经指令队列;流量重置日集中复机按账户 QPS 平滑。
- [ ] 8.10 实名管控 0 → 1、1 → 0 后受影响卡被批量评估且平滑执行。
- [ ] 8.11 开关开、关、再开:无重复停复机与漏停;旧路径设备锁持有时设备判断推后。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,52 @@
## Context
完整推导见 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 11 节。
现状:
- `internal/polling/config_manager.go`:`MatchConfigs` 按 priority 升序遍历启用配置;`MergedTaskIntervals`(第 176 行)对每种类型取最高优先级的非 NULL 间隔,即 NULL = 继承。字段注释(`internal/model/polling.go:17-21`)与 DTO 说明(`polling_config_dto.go:10-14`)写的是"NULL 表示不检查",与实际相反;DTO 校验 `min=30/60`,不允许 0。配置变更经 Redis 通知即时刷新,另每 5 分钟定时刷新。
- 生产 2026-10-24:`tb_polling_config` 7 条,无 0 值,3 条优先级同为 1,数据库无优先级唯一约束。
- 卡的下次到期时间在执行收尾时按当时配置算好写入分片 ZSET,配置修改不会改变已排程时间。
- 流量(`internal/application/cardobservation/traffic_flow.go`,8-11 已切换分支):增量 = 本月最大快照 − 本月已结算;入账时按 `hasActivePackageForCard` 判断有无套餐,无套餐只记已结算。
- 激活入口:排队、指定、队首激活用 Redis 锁 `RedisPackageActivationLockKey`(30 秒);订单购买用卡或设备行锁 `FOR UPDATE`(`order/service.go:2300`);实名激活 `ActivateByRealname` 无锁。
- 自动化测试为 N/A。
## Goals / Non-Goals
**Goals:**
- 按套餐状态与资产状态分层,直接减少网关调用量;无套餐与库存层兜底开机即停。
- 间隔含义无歧义;调整配置后可预览、可立即生效。
- 无套餐期间与上一用户用量不计入新套餐;减少重置日前尾部用量丢失。
**Non-Goals:**
- 到期即停(依赖第 6 个 Change 的停复机指令队列与场景合并);本 Change 期间沿用现有套餐轮询发现过期后停机。
- 激活后复机改为请求评估(第 6 个 Change)。
- 调度结构改造(第 5 个 Change);本 Change 在现有分片调度上实现,第 5 个 Change 沿用同一套间隔计算函数。
## Decisions
1. **保留 NULL = 继承,新增 0 = 不检查**。备选"把 NULL 改成不检查":会改变生产现有配置的行为,放弃。合并函数改为:遇到非 NULL 即采用(0 表示该类型不入队)。
2. **稳定排序**:查询 `ORDER BY priority, id`;不加唯一约束(生产已有重复),只在保存时提示。
3. **套餐状态按使用时计算**:套餐状态修改点约 15 个文件 45 处,不逐处挂钩子;在收尾、初始化、生命周期重新匹配时查询(新增按资产查询套餐状态的单个与批量方法),初始化按批查询避免 N+1。
4. **重算到期时间**:异步任务按运营商账户分批读卡,读取各类型上次检查时间,按 min 规则只提前;写分片 ZSET 用 `ZADD LT`,天然只提前。
5. **重置日前加密**:在统一的"计算下次到期"函数中处理窗口截断与收尾提前量,初始化、收尾、生命周期共用;重置时刻取 `tb_carrier.data_reset_day`,与流量计量重置日口径一致。
6. **观测时间归属**:观测携带 `observed_at`(网关返回时刻);入账判断"有无可扣套餐"时改为"是否存在 `activated_at ≤ observed_at` 的生效主套餐"。这是正确性的根本保证,锁只减少并发与重复结算。
7. **结算不触发评估**:观测元数据带"激活前结算"标记,`traffic_flow` 入账后不写触发停复机评估的 Outbox 事件(或消费者识别标记跳过);避免先停机、复机又被单卡最小重试间隔挡住。
8. **统一激活锁**:沿用 `RedisPackageActivationLockKey(carrierType, carrierID)`,实名激活与订单购买即生效补上;锁内顺序为"结算 → 激活事务",TTL 提为大于结算总超时 + 事务耗时。订单路径的行锁保留在事务内。
9. **结算的限流与超时**:结算是自动触发的查询,受第 1 个 Change 的限流约束,使用"等待至超时"模式(处于同步回调链路,单步超时与总超时均为系统参数)。
## Risks / Trade-offs
- [支付、实名回调链路变长] → 总超时兜底,超时照常激活并告警。
- [锁 TTL 变长导致激活冲突返回增多] → 各入口沿用现有冲突处理;监控抢锁失败次数。
- [运营把某层间隔设为 0 后漏查] → 无套餐与库存层强制卡状态正数;匹配预览可在保存前确认。
- [重置日窗口内查询集中] → 仍受账户 QPS 约束;按监控调整窗口与间隔。
- [依赖 8-11 未上线生产] → 本 Change 实施前确认 `rebuild-card-traffic-metering-and-stop-resume` 已归档、日快照计量生产开启。
## Migration Plan
1. 迁移:`tb_polling_config` 加 4 个可空列;现有数据不变。
2. 测试环境部署并按 tasks 第 8 节核对;运营按新维度新增分层配置(先用预览确认),再执行重算到期时间。
3. 生产由维护者手工迁移与发布。回退:删除新增分层配置即恢复原匹配;代码回退时新列保留。

View File

@@ -0,0 +1,35 @@
## Why
开启轮询的卡全部按配置轮询,配置只能区分网络、实名、卡类别与运营商账户:库存卡、无套餐卡与在用卡同频调网关,"无套餐仍在跑流量""库存卡在上游开机"没有专门兜底。流量计量方面,无套餐期间若不查流量,按月对账会把这段用量(以及二次销售前上一用户本月的用量)算进新激活的套餐;流量查询稀疏时,每张卡在重置日清零前最后一次查询到清零之间的用量永远查不到。另外现有配置合并把间隔为空当"继承",字段说明却写"空表示不检查",分层后会让"无套餐层不查流量"失效。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 2 步。
## What Changes
- 轮询配置新增两个匹配维度:套餐状态(有生效套餐 / 套餐已用完 / 只有待生效套餐 / 无套餐)、资产状态(在库 / 已销售 / 已换货 / 已停用),为空表示不限;层级在卡任务收尾、全量初始化与生命周期重新匹配时计算,不在套餐状态修改点挂钩子。
- 间隔三态:正数按间隔、`0` 不检查(停止向下继承)、空继承;保持"空 = 继承"不改变现有配置行为;修正字段注释与接口文档的错误说明。
- 保存校验:匹配"无套餐、已销售"或"在库"的配置,卡状态检查间隔必须为正数。已销售无套餐、在库卡未匹配任何配置时告警。
- 优先级可后台调整:单条修改与整体排序接口;相同优先级按 ID 稳定排序;提供匹配预览(输入 ICCID,返回命中配置与每种检查最终间隔及来源,可带未保存修改预演);提供"立即重算到期时间"(只提前不推后,异步任务,可按运营商账户限定)。
- 重置日前加密查流量:配置新增 `pre_reset_window`、`pre_reset_carddata_interval`,在运营商账户重置日前窗口内缩短流量检查间隔,最后一次查询贴近清零时刻。
- 激活前流量结算:资产从"无生效主套餐"变为"有生效主套餐"之前,先查一次流量入账(只记已结算),再激活;结算观测**不触发停复机评估**;结算失败或超时不阻塞激活并告警;整次结算设总超时;结算受运营商账户限流约束。
- 流量按观测时间归属:观测时间早于套餐激活时间的用量只记已结算,不扣新套餐(对所有流量观测生效)。
- 统一资产级激活锁:实名激活与订单购买即生效补上现有 `RedisPackageActivationLockKey`,结算与激活在锁内执行。
- 激活后复机沿用现有路径,第 6 个 Change 上线后改为请求评估。
- 按层统计、漏配告警、重置日前加密效果统计接口。
## Capabilities
### New Capabilities
- `tiered-polling`:轮询配置的套餐状态与资产状态维度、间隔三态与合并规则、优先级调整与稳定排序、匹配预览、重算到期时间、重置日前加密、无套餐与库存层兜底校验、按层统计。
### Modified Capabilities
- `card-traffic-metering`:新增激活前流量结算与"按观测时间归属"规则(该能力由 `rebuild-card-traffic-metering-and-stop-resume` 引入,本 Change 要求其先归档)。
- `package-lifecycle`:无生效主套餐到有生效主套餐的各激活入口统一资产级激活锁,激活前先做流量结算。
## Impact
- 依赖:`rebuild-card-traffic-metering-and-stop-resume` 已完成并归档、日快照计量已在生产开启并核对;第 1 个 Change(限流器)已上线,结算查询受限流约束。
- 数据库:`tb_polling_config` 新增 4 个可空列(`package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`);新的成对迁移。
- 代码:`internal/polling/config_manager.go`(三态合并、稳定排序、新维度、重置日前到期计算)、`internal/service/polling/config_service.go` 与 DTO、`internal/store/postgres/package_usage_store.go`(按资产查询套餐状态,单个与批量)、`internal/polling/initializer.go`、`lifecycle_service.go` 与重入队处传入套餐状态;`internal/service/package/activation_service.go`、`internal/service/order/service.go`(激活锁与结算);`internal/application/cardobservation/traffic_flow.go`(观测时间归属、结算标记不触发评估)。
- 接口:`/polling-configs` 增加 4 个字段、间隔支持 `0`;新增 `PUT /polling-configs/priorities`、`GET /polling-configs/match-preview`、`POST /polling-configs/recalculate-due`、`GET /polling-configs/recalculate-due/:task_id`、`GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。前端轮询配置页改为"按间隔 / 不检查 / 继承"三态并支持拖拽排序。
- 行为:激活链路多一次流量查询,有总超时;生产现有 7 条轮询配置无 `0` 值,行为不变。

View File

@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: 流量按观测时间归属
系统 SHALL 为每次流量观测记录网关返回时的观测时间;入账时若资产已有生效主套餐但观测时间早于该套餐的激活时间,本次增量 MUST 只记为已结算、不扣减该套餐。该规则 MUST 对全部流量观测生效(轮询、激活前结算、手动刷新)。
#### Scenario: 慢返回的结算晚于另一入口激活
- **GIVEN** 资产无生效套餐,激活前结算 10:00:00 发出查询,另一入口 10:00:02 激活了套餐
- **WHEN** 结算结果 10:00:05 返回并入账,观测时间早于激活时间
- **THEN** 本次增量只记已结算,不扣新套餐
#### Scenario: 激活后的观测正常扣减
- **WHEN** 观测时间晚于套餐激活时间
- **THEN** 增量按现有规则扣减套餐
### Requirement: 激活前流量结算
对已切换日快照计量的卡,系统 SHALL 在资产从"无生效主套餐"变为"有生效主套餐"之前(实名激活、排队激活、指定激活、队首激活、订单购买即生效),对独立卡查一次流量、对设备逐张查设备下各卡,并按现有观测入账(此时无套餐,增量只记已结算),然后再执行激活。已有生效主套餐(续费、排队衔接)与流量重置 MUST NOT 执行结算。结算观测 MUST 携带"激活前结算"标记,入账照常但 MUST NOT 触发停复机评估;激活提交后的评估是激活链路唯一一次评估。结算查询 MUST 受运营商账户限流约束;单卡限流等待超过单步超时、查询失败或整次结算达到总超时时,系统 MUST 跳过剩余结算、照常激活,并按卡写告警。未切换日快照计量的卡 MUST 不做结算。
#### Scenario: 二次销售激活
- **GIVEN** 卡上一用户本月已用 3 GB,当前无生效套餐
- **WHEN** 新用户购买套餐即生效
- **THEN** 系统先查流量并把 3 GB 记为已结算,再激活,新套餐不承担这 3 GB
#### Scenario: 结算不触发停机
- **WHEN** 结算观测入账时资产无套餐
- **THEN** 系统不生成"无有效套餐"停机,激活提交后再评估一次停复机
#### Scenario: 结算超时
- **WHEN** 设备下第 3 张卡结算时整次结算达到总超时
- **THEN** 系统跳过剩余卡、照常激活,并为未结算的卡写告警

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 无套餐到有套餐的激活入口共用资产级激活锁
系统 SHALL 让所有使资产从"无生效主套餐"变为"有生效主套餐"的入口(实名激活、排队激活、指定激活、队首激活、订单购买即生效)在同一把资产级激活锁内执行"激活前流量结算 → 激活事务"。锁 TTL MUST 大于结算总超时与激活事务耗时之和;抢锁失败的入口 MUST 按各自现有的冲突处理返回或重试,不得在锁外执行激活。订单购买路径原有的卡或设备行锁 MUST 保留。
#### Scenario: 实名激活与订单购买并发
- **WHEN** 同一资产的实名回调激活与客户购买即生效同时到达
- **THEN** 两者串行执行,后执行者看到已有生效主套餐,不再做结算并按排队规则处理
#### Scenario: 续费不加结算
- **WHEN** 资产已有生效主套餐时续费
- **THEN** 系统不做激活前结算,按现有排队衔接处理

View File

@@ -0,0 +1,79 @@
## ADDED Requirements
### Requirement: 按套餐状态与资产状态分层匹配
系统 SHALL 在现有匹配维度(网络与实名状态、卡类别、运营商账户)之外,按套餐状态(有生效套餐、套餐已用完、只有待生效套餐、无套餐)与资产状态(在库、已销售、已换货、已停用)匹配轮询配置;维度为空表示不限。绑定设备的卡 MUST 按设备计算套餐状态,独立卡按卡计算,口径与现有"是否有有效套餐"判断一致。系统 MUST 在卡任务执行完收尾、全量初始化、生命周期重新匹配时计算层级,卡的层级最迟在下一次执行时更新。
#### Scenario: 套餐到期后降层
- **GIVEN** 卡原匹配"有生效套餐"层,套餐已过期
- **WHEN** 该卡下一次轮询执行完收尾
- **THEN** 系统按"无套餐"层的间隔计算各检查类型下次到期时间
#### Scenario: 设备卡同层
- **WHEN** 设备下 2 张卡分属不同运营商账户,设备有生效套餐
- **THEN** 两张卡都按"有生效套餐"匹配
### Requirement: 检查间隔三态与合并
系统 SHALL 区分每种检查类型间隔的三种取值:正数(秒)表示按此间隔检查并停止向下查找;`0` 表示不检查并停止向下查找;空表示继承,继续查找优先级更低的配置。系统 MUST 按优先级升序、优先级相同按配置 ID 升序遍历启用的匹配配置,对每种检查类型取第一个非空取值。接口 MUST 接受 `0` 或不小于该类型最小间隔的正数,拒绝其他值。
#### Scenario: 无套餐层不查流量
- **GIVEN** "无套餐"层流量间隔为 `0`,优先级更低的兜底配置流量间隔为 3600
- **WHEN** 无套餐卡匹配这两条配置
- **THEN** 该卡不做流量检查
#### Scenario: 空值继承
- **GIVEN** 高优先级配置实名间隔为空,低优先级配置实名间隔为 7200
- **WHEN** 卡同时匹配两条配置
- **THEN** 该卡实名检查间隔为 7200
#### Scenario: 相同优先级
- **WHEN** 两条启用配置优先级都为 1 且同时匹配一张卡
- **THEN** 系统按配置 ID 较小者优先,结果在多次计算间保持一致
### Requirement: 无套餐层与库存层必须检查卡状态
系统 SHALL 拒绝保存匹配"无套餐且已销售"或"在库"的配置中卡状态检查间隔为空或 `0` 的情况。已销售无套餐、在库的卡未匹配到任何配置时,系统 MUST 告警且不入队。
#### Scenario: 库存层未配卡状态
- **WHEN** 维护者保存资产状态为"在库"、卡状态间隔为 `0` 的配置
- **THEN** 系统拒绝保存并提示该层必须配置卡状态检查
### Requirement: 优先级调整、匹配预览与重算到期时间
系统 SHALL 允许超级管理员单条修改配置优先级,以及一次提交全部配置顺序整体重排(事务内完成)。保存时若与其他启用配置优先级相同且匹配条件可能重叠,系统 MUST 提示但不拦截。系统 SHALL 提供匹配预览:输入 ICCID(可附带未保存的修改),返回该卡各匹配维度取值、命中配置列表,以及每种检查类型最终采用的间隔与来源(按间隔、不检查、继承自某配置、未匹配),预览 MUST 不写任何数据。系统 SHALL 提供"立即重算到期时间"异步任务(可按运营商账户限定),新到期时间 = min(当前到期, 上次检查时间 + 新间隔),只提前不推后,只更新调度状态、不调网关,返回任务 ID 与进度。
#### Scenario: 整体排序
- **WHEN** 超级管理员提交新的配置顺序
- **THEN** 系统按顺序重排优先级,各进程在配置刷新后按新顺序匹配
#### Scenario: 预览未保存修改
- **WHEN** 运营带上"把某配置流量间隔改为 0"的未保存修改预览一张卡
- **THEN** 系统返回修改后的最终间隔与来源,配置与调度状态不变
#### Scenario: 缩短间隔后重算
- **GIVEN** 某层流量间隔从 86400 改为 3600,卡 A 上次查流量在 2 小时前、当前到期在 20 小时后
- **WHEN** 执行重算到期时间
- **THEN** 卡 A 流量检查到期时间改为上次检查后 1 小时(即立即到期),且仍受运营商账户限流约束执行
### Requirement: 重置日前加密查流量
系统 SHALL 在配置了 `pre_reset_window` 与 `pre_reset_carddata_interval` 的层内,于卡所属运营商账户重置时刻(重置日当天 0 点,业务时区)前的窗口内,按窗口间隔安排流量检查:窗口内下次到期 = 当前 + 窗口间隔,若越过重置时刻则改为"重置时刻 − 收尾提前量"(已过则不再加查);窗口前按正常间隔算出的到期越过窗口起点时截断到窗口起点;越过重置时刻后恢复正常间隔。两列为空表示该配置不加密。
#### Scenario: 窗口内最后一次查询
- **GIVEN** 重置日为 1 号,窗口 6 小时,窗口间隔 30 分钟,收尾提前量 5 分钟,当前为 31 号 23:40
- **WHEN** 计算流量下次到期
- **THEN** 下次到期为 31 号 23:55,而不是 1 号 00:10
#### Scenario: 截断到窗口起点
- **GIVEN** 正常间隔 1 天,当前为 31 号 10:00,窗口起点 31 号 18:00
- **WHEN** 计算流量下次到期
- **THEN** 下次到期为 31 号 18:00
### Requirement: 分层与加密效果可观测
系统 SHALL 提供按层的卡数与漏配告警卡数统计,以及按运营商账户、按重置周期的"有生效套餐但窗口内未查到流量"卡数与最后一次查询距重置时刻的分布。
#### Scenario: 查看分层统计
- **WHEN** 运营查询分层统计
- **THEN** 系统返回每层卡数与未匹配任何配置的已销售无套餐、在库卡数

View File

@@ -0,0 +1,56 @@
## 0. 前置确认
- [ ] 0.1 确认 `rebuild-card-traffic-metering-and-stop-resume` 已完成并归档,日快照计量已在生产开启并核对结算结果;确认第 1 个 Change 已上线。
## 1. 迁移与模型
- [ ] 1.1 新增成对迁移:`tb_polling_config` 增加 `package_state`、`asset_status`、`pre_reset_window`、`pre_reset_carddata_interval`(均可空);down 删列。
- [ ] 1.2 `model.PollingConfig` 增加字段;修正间隔字段注释为"正数按间隔、0 不检查、空继承"。
## 2. 配置合并与分层匹配
- [ ] 2.1 `config_manager.go`:配置加载按 `priority, id` 排序;`MergedTaskIntervals` 采用第一个非空取值,`0` 表示不检查。
- [ ] 2.2 新增套餐状态与资产状态匹配;`package_usage_store` 增加按资产查询套餐状态(单个与批量),设备卡按设备计算。
- [ ] 2.3 `initializer.go`(批量)、`lifecycle_service.go`、执行收尾重入队处传入套餐状态与资产状态。
- [ ] 2.4 已销售无套餐、在库卡未匹配任何配置时告警且不入队。
## 3. 重置日前加密
- [ ] 3.1 统一"计算下次到期"函数:窗口内间隔、越过重置时刻改为"重置时刻 − 收尾提前量"、窗口前截断到窗口起点、越过后恢复正常;初始化、收尾、生命周期共用。
- [ ] 3.2 收尾提前量写入系统参数。
## 4. 配置接口
- [ ] 4.1 `/polling-configs` DTO 增加 4 个字段,间隔校验改为"0 或不小于最小间隔",说明文字按三态;保存校验无套餐已销售与在库层卡状态必须为正数;优先级重复且条件可能重叠时返回提示。
- [ ] 4.2 `PUT /polling-configs/priorities`:事务内整体重排,写统一审计,发配置变更通知。
- [ ] 4.3 `GET /polling-configs/match-preview?iccid=`(可带 `config_overrides`):返回维度取值、命中配置、每类最终间隔与来源;只读。
- [ ] 4.4 `POST /polling-configs/recalculate-due`(可选运营商账户)与 `GET /polling-configs/recalculate-due/:task_id`:异步分批,只提前不推后,不调网关。
- [ ] 4.5 `GET /polling-stats/tiers`、`GET /polling-stats/pre-reset`。
- [ ] 4.6 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 并重新生成接口文档。
## 5. 流量按观测时间归属
- [ ] 5.1 观测携带 `observed_at`;`traffic_flow.go` 判断可扣套餐时只认 `activated_at ≤ observed_at` 的生效主套餐,否则只记已结算。
## 6. 激活前结算与统一激活锁
- [ ] 6.1 实名激活与订单购买即生效补上 `RedisPackageActivationLockKey`;各入口锁 TTL 调整为大于结算总超时 + 事务耗时;抢锁失败沿用现有冲突处理。
- [ ] 6.2 锁内、激活事务前:判断资产当前无生效主套餐且卡已切换日快照计量时执行结算(独立卡一次、设备逐张),使用限流等待模式;单步超时与总超时为系统参数。
- [ ] 6.3 结算观测带"激活前结算"标记,入账后不触发停复机评估。
- [ ] 6.4 结算失败、超时或被跳过的卡写告警(卡、资产、错误);不阻塞激活。
## 7. 编译与规范校验
- [ ] 7.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过。
- [ ] 7.2 `openspec validate add-tiered-polling-and-pre-activation-settlement` 与 `openspec doctor --json` 通过。
## 8. 测试环境验证(自动化测试为 N/A,按 ENG-TEST-001)
- [ ] 8.1 现有配置在升级后各卡最终间隔不变(用匹配预览抽查)。
- [ ] 8.2 间隔 `0` 停止继承、空值继承、相同优先级按 ID 稳定。
- [ ] 8.3 无套餐已销售、在库层卡状态为空或 0 时保存被拒;未匹配告警出现。
- [ ] 8.4 整体排序后匹配结果按新顺序;重算到期时间只提前不推后。
- [ ] 8.5 重置日前窗口:最后一次查询落在"重置时刻 − 收尾提前量",越过后恢复正常间隔。
- [ ] 8.6 二次销售卡购买即生效:先结算再激活,新套餐不承担上一用户本月用量;结算不产生停机。
- [ ] 8.7 构造结算慢返回且另一入口先激活:激活前用量只记已结算。
- [ ] 8.8 实名激活与订单购买并发:串行执行,无重复结算;结算超时照常激活并告警。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,40 @@
## Context
依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第三部分第 12 节、第五部分第 3 节前置条件与"上线前演练"。
现状:
- `internal/service/iot_card/stop_resume_unified.go`:`unifiedStopResumeEnabled()` 读 `cfg.StopResume.UnifiedRuleEnabled`;`autoResumableStopReason`、`autoControlSuspended`、`unifiedRealnameOK` 在开关开启时生效,关闭时走 `isRealnameOK` 旧分支与 `isPollingStopReason`。
- 迁移 242:`tb_carrier.realname_control_enabled` 默认 0;生产库仍为迁移 240(未上线)。
- `internal/task/polling_realname_handler.go:70`:行业卡跳过实名查询。
- `internal/domain/cardobservation/network.go:88` `ShouldStopPollingForRisk` 要求 `isStandalone`;生产 8,420 张风险停机或已销户卡均为独立卡且已关闭轮询。
- 停复机调用返回终态时只按失败处理,不关闭轮询。
## Goals / Non-Goals
**Goals:** 停复机只有一套口径;实名轮询与实名管控口径一致;风险停机与已销户无论如何发现都关闭轮询;开启前可预演影响。
**Non-Goals:** 不改停复机触发路径与执行方式(第 6 个 Change);不改暂停轮询的套餐失效停机与自动恢复轮询(第 6、7 个 Change);不改激活状态计算口径。
## Decisions
1. **先演练开启、再删除开关**:保留开关会长期双口径,但演练必须早于开启;故第一次发布只加演练与补齐、不改口径,开启并稳定后第二次发布删除开关。
2. **复机含 `carrier_stopped`**:用户已定"只看本系统规则"。上游终态卡由关闭轮询兜底,不进入自动复机;未识别的终态由错误分类在复机失败时识别并关闭轮询。
3. **关闭轮询只作用于卡自身**:设备下其他卡仍可能正常使用;`autoControlSuspended` 按卡判断,该卡不再参与自动停复机,设备维度判断对其余卡照常。
4. **终态关闭轮询由规则标记驱动**:在错误分类表加 `auto_disable_polling`,而不是在代码里写死关键字,符合"数值与分组由配置决定"。关闭时同事务回写 `gateway_extend`,并写不轮询清单(第 7 个 Change 上线后;之前只改 `enable_polling` 并写审计)。
5. **演练用读侧 Query**:复用统一判断函数,输入卡与资产当前数据和可选的假设实名管控配置,输出目标状态;按批读取、只读,不经聚合根写状态。明细导出复用统一导出任务。
## Risks / Trade-offs
- [开启实名管控后行业卡被停] → 已知口径变化;演练可按假设配置预演,运营按结果决定是否开启。
- [演练全量扫描较慢] → 按账户分批、只读;导出走异步任务。
- [删除开关后无法快速回到旧口径] → 前置要求新规则已在生产稳定运行;必要时代码回退。
## Migration Plan
演练必须在新规则开启之前可用,因此分两次发布:
1. 第一次发布:迁移(错误分类表加列)、演练接口、风险停机与已销户关闭轮询补齐;总开关仍关闭,停复机行为不变。演练始终按新规则判断,不受总开关影响。
2. 运营:设置需要实名的账户;生产执行演练并导出核对;需保持停机的卡关闭轮询。维护者开启总开关并重启全部进程,首日监控。
3. 第二次发布(新规则稳定后):删除总开关、旧口径与部署环境变量,实名轮询改按账户实名管控跳过。
4. 回退:第一次发布后可直接关闭总开关回到旧口径;第二次发布后需代码回退。

View File

@@ -0,0 +1,35 @@
## Why
`Iteration/8-11` 已实现停复机新规则,但靠总开关与旧口径并存:代码里同时保留两套实名判断与复机条件,实名轮询仍按行业卡跳过,与"按运营商账户实名管控"的新口径不一致。后续停复机指令队列(第 6 个 Change)需要唯一口径。另一方面,生产 2026-10-24 实查:停机、开启轮询且有生效套餐的卡中,`not_realname` 15,059 张、`carrier_stopped` 998 张、停因为空 83 张、`manual` 22 张;所有账户实名管控默认 0,新规则开启即可能批量复机,必须先演练。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 4 步。
## What Changes
- **BREAKING** 新规则转正:删除总开关 `stop_resume.unified_rule_enabled`(`JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`)及其配置、部署变量与全部分支;删除旧口径(行业卡放行、卡或设备 `realname_policy` 放行、只对轮询停因自动复机、`ListByDeviceIDAndPollingStopReasons`)。
- 复机不看停机原因,包括运营商侧主动停机 `carrier_stopped`:是否复机只由本系统规则决定。
- **BREAKING** 实名轮询改为:卡所属运营商账户未开实名管控时跳过查实名;不再按行业卡跳过。
- 风险停机、已销户必须关闭轮询:保留现有查状态自动关闭;补齐绑定设备的卡(只关该卡自身,不级联设备)与停复机调用直接返回终态两种情况;错误分类规则增加"自动关闭轮询"标记,只允许终态类别勾选。
- 新增停复机规则演练接口:对全部停机与开机卡按规则只判断不执行,按运营商账户 × 停机原因 × 判断结果输出汇总与明细,可传入假设的实名管控配置预演,明细可导出。
- 通道阈值新周期解锁后的复机条件随转正去掉总开关表述;同时去掉已下线的"保护期强制复机"表述。
- 上线前演练作为发布前置步骤写入任务。
## Capabilities
### New Capabilities
(无)
### Modified Capabilities
- `card-stop-resume`:删除总开关要求;停机、复机、实名管控要求去掉"总开关开启时"前提;复机明确包含 `carrier_stopped`;新增实名轮询按账户实名管控跳过、风险停机与已销户关闭轮询补齐、规则演练。(该能力由 `rebuild-card-traffic-metering-and-stop-resume` 引入,本 Change 要求其先归档。)
- `polling-operations`:删除"停复机遵循实际生效实名策略"(旧口径)。
- `carrier-channel-traffic-threshold`:"通道阈值停机与周期恢复"去掉总开关与保护期表述,复机条件固定为 `card-stop-resume` 的复机条件。
- `gateway-call-observability`:错误分类规则增加"自动关闭轮询"标记(该能力由第 1 个 Change 引入)。
## Impact
- 依赖:`rebuild-card-traffic-metering-and-stop-resume` 已归档并上线生产(总开关可仍关闭);第 1 个 Change(错误分类)与第 3 个 Change(去掉保护期)已上线。
- **分两次发布**:第一次发布演练接口与关闭轮询补齐,总开关仍关闭、停复机行为不变;运营为需要实名的账户设置 `realname_control_enabled = 1`,用演练核对"将被复机 / 将被停机"的卡并导出确认,需保持停机的卡先关闭轮询,然后维护者开启总开关;新规则稳定运行后第二次发布删除总开关与旧口径、实名轮询改口径。
- 数据库:新增成对迁移,`tb_gateway_error_classification` 增加 `auto_disable_polling` 列并为"已销户""风险停机"类规则置位。
- 代码:`internal/service/iot_card/stop_resume_unified.go` 并入主逻辑;`stop_resume_service.go` 删除旧分支;`internal/task/polling_realname_handler.go:70`;`internal/domain/cardobservation/network.go` `ShouldStopPollingForRisk` 去掉独立卡限制;停复机失败处理接入"自动关闭轮询";`pkg/config/config.go`、`loader.go`、`defaults/config.yaml`、`.gitea/workflows/deploy.yaml`、`docker-compose.prod.yml` 删除总开关;新增演练 Query 与接口。
- 接口:新增 `GET /stop-resume-rule/dry-run` 与导出场景 `stop_resume_rule_dry_run`;错误分类接口增加 `auto_disable_polling` 字段。
- 行为:开启实名管控的账户下,行业卡未实名也会被停机;未开实名管控的账户不再查实名。

View File

@@ -0,0 +1,109 @@
## REMOVED Requirements
### Requirement: 停复机新规则总开关
**Reason**: 新规则转正为唯一口径,不再保留旧口径与总开关。
**Migration**: 删除配置项与部署变量 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`;发布前完成实名管控配置与规则演练。
## MODIFIED Requirements
### Requirement: 自动停机条件
系统 SHALL 对开机的卡在满足任一条件时自动停机:卡(绑定设备时按设备)没有套餐;流量已用完;卡所属通道开启实名管控且卡未实名。停机原因按命中条件分别记为 `no_package`、`traffic_exhausted`、`not_realname`。无套餐与流量用完扩散到设备下全部开机卡,未实名只停该卡。系统 MUST NOT 因卡当前开机是由外部、人工或其他原因造成而改变判断。
#### Scenario: 套餐到期
- **WHEN** 开机卡绑定的设备没有套餐
- **THEN** 系统停机设备下全部开机卡,停因为 `no_package`
#### Scenario: 通道开启实名管控的未实名卡
- **WHEN** 卡所属通道开启实名管控,卡未实名且开机
- **THEN** 系统只停这张卡,停因为 `not_realname`
#### Scenario: 通道未开启实名管控的未实名卡
- **WHEN** 卡所属通道未开启实名管控,卡未实名、有套餐、流量未用完且开机
- **THEN** 系统不停机
### Requirement: 自动复机条件不看停机原因
系统 SHALL 对停机的卡在同时满足以下条件时自动复机:卡(绑定设备时按设备)有套餐;流量未用完;卡已实名或卡所属通道未开启实名管控。系统 MUST NOT 以停机原因决定是否复机:运营商侧主动停机(`carrier_stopped`)、卡管人工停机(`manual`)、历史保护期停机(`protect_period`)、未实名、无套餐、流量耗尽及停因为空的卡满足条件都复机,是否复机只由本系统规则决定。持有本周期通道阈值锁的卡 MUST 拒绝复机。设备复机时系统 SHALL 逐张判断设备下全部停机卡,不满足实名条件的卡跳过并记停因 `not_realname`,其他卡照常复机。外部反复停机时,系统每次评估均按上述条件复机,不设次数上限。停机原因只用于展示与审计。上游对复机返回终态时,按"风险停机与已销户关闭轮询"处理,不重试。
#### Scenario: 外部停机后满足条件
- **WHEN** 卡被外部停机后记为 `carrier_stopped`,有套餐、流量未用完、实名条件满足
- **THEN** 下一次评估时系统自动复机并清空停因
#### Scenario: 人工停机后满足条件
- **WHEN** 管理员手动停机的卡有套餐、流量未用完、实名条件满足
- **THEN** 下一次评估时系统自动复机
#### Scenario: 开启实名管控的通道中未实名的停机卡
- **WHEN** 卡所属通道开启实名管控,卡未实名、停机、有套餐
- **THEN** 系统不复机;卡实名后下一次评估复机
#### Scenario: 持有通道阈值锁
- **WHEN** 停机卡在本周期持有通道阈值锁,其他条件均满足
- **THEN** 系统拒绝复机并保留锁
### Requirement: 通道实名管控配置、权限与审计
系统 SHALL 在运营商通道上提供独立的实名管控开关 `realname_control_enabled`(0 不管控、1 管控),默认 0,MUST NOT 复用通道现有的实名链接类型等字段。开启时,通道下所有卡停复机只按卡是否已实名判断,MUST NOT 以行业卡身份或卡、设备上的实名策略放行;实名策略仍只用于购买与首次实名激活流程。仅超级管理员和平台用户 SHALL 在通道新建或编辑时配置该开关,非平台账号提交该字段 MUST 被拒绝;修改并入通道更新审计。从 0 改为 1 时,管理端 MUST 二次确认并提示该通道未实名的开机卡数。查询卡所属通道的实名管控失败时,系统本次不对该卡发起停复机并记录日志。人工复机同样按本要求判断实名。
#### Scenario: 默认不管控
- **WHEN** 通道未修改实名管控,非行业卡未实名、卡实名策略为 `after_order`、有套餐且流量未用完
- **THEN** 系统不因未实名停机,人工复机不因未实名被拒
#### Scenario: 开启后行业卡不再放行
- **WHEN** 通道开启实名管控,行业卡未实名且开机
- **THEN** 系统停机该卡,停因为 `not_realname`
#### Scenario: 越权修改实名管控
- **WHEN** 非超级管理员、非平台用户提交通道实名管控字段
- **THEN** 系统拒绝请求且不修改配置
#### Scenario: 查询通道失败
- **WHEN** 停复机评估时卡所属通道的实名管控查询失败
- **THEN** 系统本次不对该卡发起停复机命令并记录日志
### Requirement: 暂停轮询的卡不自动停复机
卡或其绑定设备暂停轮询的,系统 SHALL NOT 对该卡发起自动停机或自动复机,包括套餐激活、套餐重置、充值等非轮询触发的复机;人工停机与人工复机照常执行。网关扩展状态为风险停机或已销户的卡 MUST 关闭该卡自身轮询,不论是否绑定设备:查卡状态返回该扩展状态时关闭;停复机调用直接返回命中"自动关闭轮询"规则的终态时,同样关闭并回写网关扩展状态。关闭只作用于该卡,MUST NOT 级联关闭设备与设备下其他卡。机卡分离停机等其他运营商异常状态 MUST NOT 自动关闭轮询,由人工判断。未暂停轮询的卡,不论运营商扩展状态如何,均按停机与复机条件处理,上游拒绝复机时按现有失败处理。
#### Scenario: 暂停轮询的卡充值后
- **WHEN** 已被人工暂停轮询的停机卡充值并激活套餐
- **THEN** 系统不自动复机;人工复机照常可用
#### Scenario: 恢复轮询后
- **WHEN** 维护者为该卡恢复轮询
- **THEN** 下一次评估起系统按停机与复机条件正常处理
#### Scenario: 设备卡出现机卡分离停机
- **WHEN** 设备下的卡运营商扩展状态为机卡分离停机且未被暂停轮询
- **THEN** 系统不自动暂停轮询,按停机与复机条件处理
#### Scenario: 设备下的卡被销户
- **WHEN** 查卡状态返回绑定设备的某张卡扩展状态为已销户
- **THEN** 系统关闭该卡轮询,设备与设备下其他卡的轮询不变
#### Scenario: 复机返回风险停机
- **WHEN** 复机调用返回"当前卡为风险停机状态,仅支持风险复机"且该规则标记自动关闭轮询
- **THEN** 系统不重试,关闭该卡轮询并回写扩展状态为风险停机
## ADDED Requirements
### Requirement: 实名轮询按通道实名管控跳过
系统 SHALL 在卡所属运营商账户未开启实名管控时跳过该卡的实名查询(结果不影响停复机),开启时照常查询;MUST NOT 再按卡类别(行业卡)跳过实名查询。
#### Scenario: 未开管控账户的普通卡
- **WHEN** 卡所属账户 `realname_control_enabled = 0`,实名检查到期
- **THEN** 系统不调用网关查实名,按正常间隔安排下次检查
#### Scenario: 开启管控账户的行业卡
- **WHEN** 卡所属账户开启实名管控,行业卡实名检查到期
- **THEN** 系统调用网关查实名
### Requirement: 停复机规则演练
系统 SHALL 提供只判断不执行的停复机规则演练:对停机卡与开机卡按本能力规则判断目标状态,按运营商账户 × 停机原因 × 判断结果(将复机、将停机、不动作及不动作原因)返回汇总与明细,可按运营商账户与停机原因筛选,可传入假设的通道实名管控配置预演。演练 MUST NOT 调用网关、MUST NOT 写任何卡、指令或调度状态。明细 SHALL 可通过统一导出任务导出。接口仅超级管理员与平台用户可用。
#### Scenario: 开启前预演
- **WHEN** 运营在所有账户实名管控为 0 时执行演练
- **THEN** 系统返回各账户将被复机的 `not_realname` 卡数与明细,卡状态不变
#### Scenario: 假设开启管控
- **WHEN** 运营假设账户 A 开启实名管控执行演练
- **THEN** 系统按假设配置返回账户 A 将停机的未实名开机卡数,实际配置不变

View File

@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: 通道阈值停机与周期恢复
系统 SHALL 为每个已启用运营商通道按通道既有的上游流量重置日(与网关计数器清零同一事实,上海时区周期边界)判断运营商回传的每张卡当前周期累计流量,累计流量仅取网关累计读数换算,不使用本地用量统计或套餐真流量;每张卡独立判断,不扩散到设备维度,不进入既有停复机的设备扩散路径。达到或超过阈值时,系统为当前周期创建通道阈值停机锁并创建可靠停机任务;同一周期重复判定、并发或任务重放不得重复创建锁或重复发起停机。改阈值不生效于当前周期已持锁卡:当前周期维持拒绝复机,新周期按新阈值判断。
持锁卡在当前周期内 MUST 拒绝一切复机,包括自动复机、手动复机和机卡分离复机;拒绝时保留锁并记录拒绝事实。停机调用失败或结果未知时保留锁与任务状态,由本能力新增的恢复扫描处理:只查询运营商状态回填、确认成功后补写卡状态;自提交起超过约定窗口仍无法确认结果的,标记异常并转人工处理,不自动删除锁。本能力新增停因 `channel_threshold`,不参与既有自动复机判定,复机仅由周期处理发起。
新周期开始时,系统 SHALL 对仍持锁卡解除通道锁;复机条件为 `card-stop-resume` 的复机条件:有套餐、流量未用完、卡已实名或卡所属通道未开启实名管控,且卡与其绑定设备未暂停轮询;不以其他停机原因或网关扩展状态阻止复机,条件满足时创建自动复机任务,不满足时只解锁不调用运营商。复机失败或结果未知同样由恢复扫描处理。卡更换运营商后,旧周期锁保留,新周期按新运营商的重置日计算。
#### Scenario: 达量后人工复机
- **WHEN** 当前计费周期内持有通道阈值停机锁的卡请求复机
- **THEN** 系统拒绝复机且保留该锁
#### Scenario: 达量后机卡分离复机被拒
- **WHEN** 当前计费周期内的持锁卡触发机卡分离复机
- **THEN** 系统拒绝复机、保留该锁并记录拒绝事实
#### Scenario: 停机结果未知恢复确认
- **WHEN** 通道阈值停机调用后结果未知,恢复扫描向运营商查询确认卡已停机
- **THEN** 系统补写卡停机状态并将任务结果记为已确认
#### Scenario: 停机结果未知超期转人工
- **WHEN** 通道阈值停机任务自提交起超过约定窗口仍无法确认结果
- **THEN** 系统标记异常并留存安全失败原因转人工处理,不自动删除锁
#### Scenario: 换运营商后周期归属
- **WHEN** 持锁卡更换运营商后到达新计费周期
- **THEN** 旧周期锁保留,新周期按新运营商的上游流量重置日计算并处理
#### Scenario: 新周期存在其他停因
- **WHEN** 新周期开始的持锁卡停因记录为人工停机,有套餐、流量未用完、实名条件满足且未暂停轮询
- **THEN** 系统解除通道阈值锁并创建自动复机任务
#### Scenario: 新周期卡已暂停轮询
- **WHEN** 新周期开始的持锁卡已被暂停轮询
- **THEN** 系统解除通道阈值锁但不调用运营商复机

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 终态规则可标记自动关闭轮询
系统 SHALL 允许错误分类规则标记"自动关闭轮询",且只允许类别为"终态不可操作"的规则勾选;保存其他类别的规则并勾选时 MUST 拒绝。初始数据中"已销户""风险停机"类终态规则 MUST 置位,"物联网号码封存"等其他终态不置位。
#### Scenario: 非终态规则勾选
- **WHEN** 维护者为"频率限制"类规则勾选自动关闭轮询
- **THEN** 系统拒绝保存
#### Scenario: 号码封存
- **WHEN** 停机返回"用户状态是物联网号码封存,禁止办理物联网停机"
- **THEN** 该次归为终态不可操作,不自动关闭轮询,按失败告警处理

View File

@@ -0,0 +1,5 @@
## REMOVED Requirements
### Requirement: 停复机遵循实际生效实名策略
**Reason**: 停复机新规则转正,实名门槛唯一由 `card-stop-resume` 的通道实名管控决定,行业卡与卡、设备实名策略不再参与停复机。
**Migration**: 需要实名的运营商账户设置 `realname_control_enabled = 1`;发布前用停复机规则演练核对受影响卡。

View File

@@ -0,0 +1,51 @@
## 0. 前置确认
- [ ] 0.1 确认 `rebuild-card-traffic-metering-and-stop-resume` 已归档并上线生产(总开关可仍为关闭);第 1、3 个 Change 已上线。
## 第一次发布:演练与口径补齐(总开关仍关闭,行为不变)
## 1. 迁移
- [ ] 1.1 新增成对迁移:`tb_gateway_error_classification` 增加 `auto_disable_polling boolean not null default false`,为"已销户""风险停机"类终态规则置位;down 删列。
## 2. 规则演练
- [ ] 2.1 新增演练 Query:按批读取卡与资产数据,复用新规则判断函数(不受总开关影响,始终按新规则判断),支持假设的实名管控配置;只读。
- [ ] 2.2 `GET /stop-resume-rule/dry-run`(运营商账户、停机原因筛选,汇总 + 分页明细),仅超级管理员与平台用户;导出场景 `stop_resume_rule_dry_run`。
- [ ] 2.3 同步路由与文档生成入口,重新生成接口文档。
## 3. 风险停机与已销户关闭轮询
- [ ] 3.1 `ShouldStopPollingForRisk` 去掉独立卡限制,只关闭该卡自身 `enable_polling`,不级联设备。
- [ ] 3.2 停复机调用失败时按错误分类查 `auto_disable_polling`:置位则不重试、关闭该卡轮询、回写 `gateway_extend`、写审计。
- [ ] 3.3 错误分类接口增加 `auto_disable_polling` 字段,非终态类别勾选时拒绝。
## 4. 第一次发布的编译与验证
- [ ] 4.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;`openspec validate finalize-unified-stop-resume-rule` 通过。
- [ ] 4.2 测试环境:演练不改任何状态;开启总开关后抽取 `not_realname`、`carrier_stopped`、`manual`、停因为空各若干张,实际评估结果与演练一致。
- [ ] 4.3 测试环境:绑定设备的卡返回已销户时只关该卡轮询;复机返回风险停机时不重试并关闭轮询;号码封存不关闭轮询。
## 5. 开启新规则(运营与维护者,第一次发布上线后)
- [ ] 5.1 为需要实名的运营商账户设置 `realname_control_enabled = 1`。
- [ ] 5.2 生产执行演练并导出明细,运营确认"将被复机 / 将被停机"清单;需保持停机的卡先关闭轮询。
- [ ] 5.3 维护者在生产开启 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` 并重启全部进程;首日监控复机成功率与终态错误数,确认稳定运行。
## 第二次发布:转正(新规则在生产稳定运行后)
## 6. 删除总开关与旧口径
- [ ] 6.1 `stop_resume_unified.go` 并入主逻辑:删除 `unifiedStopResumeEnabled` 与各分支,`autoResumableStopReason` 恒为真,`autoControlSuspended`、`unifiedRealnameOK` 无条件生效。
- [ ] 6.2 `stop_resume_service.go` 删除旧口径:`isRealnameOK` 行业卡与 `realname_policy` 分支、`isPollingStopReason` 限定、`ListByDeviceIDAndPollingStopReasons` 的调用。
- [ ] 6.3 删除 `pkg/config/config.go`、`loader.go`、`defaults/config.yaml` 的总开关,`.gitea/workflows/deploy.yaml` 与 `docker-compose.prod.yml` 中的 `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED`。
- [ ] 6.4 通道阈值新周期复机条件去掉开关分支,固定为新规则复机条件。
## 7. 实名轮询
- [ ] 7.1 `polling_realname_handler.go`:删除按行业卡跳过;卡所属账户未开实名管控时跳过查询并按正常间隔重入队;账户查询失败按可恢复失败处理。
## 8. 第二次发布的编译与验证
- [ ] 8.1 `gofmt -w`;`go build ./cmd/api ./cmd/worker` 通过;全仓检索无 `UnifiedRuleEnabled` 残留;`openspec validate` 与 `openspec doctor --json` 通过。
- [ ] 8.2 测试环境:未开管控账户不查实名;开启管控账户的行业卡查实名并按未实名停机;通道阈值新周期解锁后按新规则复机或只解锁;停复机行为与第一次发布开启总开关后一致。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@@ -0,0 +1,27 @@
## Context
依据 `docs/engineering/轮询按运营商账户限流技术方案.md` 第一部分第 6 节、第四部分"保护期整套机制"。
现状:后台设备停复机设置 `protect:device:{id}:stop|start`(1 小时,互斥,`internal/service/device/service.go:1506,1663`);保护期内拒绝相反人工操作;`polling:protect` 轮询(`internal/task/polling_protect_handler.go`)方向不一致时 `ForceStopCard` / `ForceStartCard`;自动停复机 `EvaluateAndAct` 不看保护期,造成反复翻转。`ProtectCheckSkipsForRealname`(`stop_resume_unified.go`)仅保护期轮询使用。资产 DTO 有 `device_protect_status`(`asset_dto.go:38,144`、`client_asset_dto.go:75`)。
## Goals / Non-Goals
**Goals:** 删除保护期的设置、检查、轮询与展示逻辑;不破坏现有客户端;数据库只加不删。
**Non-Goals:** 不改停复机业务规则(第 4、6 个 Change);不删 `protect_period` 常量与历史数据;不删数据库列。
## Decisions
1. **字段保留、固定返回 `none`**:直接删字段会让读取该字段的老版本小程序出错;固定值兼容成本最低。备选"字段 omitempty 后不返回":老客户端读不到同样可能出错,放弃。
2. **数据库列只改注释**:遵循"数据库只加不删";`protect_check_interval`、`last_protect_check_at` 列注释标记已废弃,代码中去掉读写。
3. **存量 Redis 保护期 key 不清理**:TTL 只有 1 小时,上线后自然过期;代码不再读取,即使残留也无影响。
4. **Asynq 中已排队的 `polling:protect` 任务**:保留一个空处理函数直接返回成功一个发布周期,避免"未注册任务类型"错误堆积,下一次发布再移除;或上线前确认队列为空。选择保留空处理函数,更稳妥。
## Risks / Trade-offs
- [运营依赖保护期防止误操作] → 已定"规则优先、人工停机无豁免",需要保持停机的关闭轮询(第 7 个 Change 提供批量操作)。
- [前端仍展示保护期] → 固定 `none` 时前端显示"无",不报错;前端随后去掉展示。
## Migration Plan
1. 迁移只改列注释。2. 测试环境验证后由维护者发布生产。3. 回退:代码回退即可恢复保护期逻辑,数据库无需回退。

View File

@@ -0,0 +1,32 @@
## Why
后台对设备停机或复机时会设置 1 小时保护期,保护期内阻止相反的人工操作,`protect` 轮询发现方向不一致时强制停复机。但自动停复机不检查保护期,保护期内会出现"自动停机 → 保护期强制复机 → 再自动停机"的反复翻转,且与"只要符合规则就自动停复机"的原则冲突。本 Change 是《轮询按运营商账户限流技术方案》第五部分第 3 节第 3 步:整体下线保护期,为后续调度与停复机改造减负。
## What Changes
- **BREAKING** 后台设备停机、复机不再设置保护期,也不再因保护期拒绝相反操作;单卡手动停复机去掉保护期检查。
- **BREAKING** 下线 `polling:protect` 轮询任务类型:不再注册、调度、统计与监控;轮询并发配置与任务类型名称不再包含保护期检查。
- 轮询配置的 `protect_check_interval` 不再读写,接口不再接受与返回该字段;数据库列保留,列注释标记已废弃。
- 卡字段 `last_protect_check_at` 不再读写;数据库列保留,列注释标记已废弃。
- 资产接口(后台与 C 端)的 `device_protect_status` **字段保留**,固定返回"无保护期"并在接口文档标记废弃,待前端与老版本小程序不再读取后另行删除。
- 停机原因常量 `protect_period` 保留(历史数据与对外开放接口映射仍需要),不再产生新值;删除 `RedisDeviceProtectKey`、`DeviceProtectPeriodDuration`。
- 存量保护期 Redis key 上线后自然过期(TTL 1 小时),不做迁移。
## Capabilities
### New Capabilities
(无)
### Modified Capabilities
- `polling-operations`:"轮询并发配置覆盖实际任务"去掉 `protect`;"轮询任务类型名称可读"去掉"保护期检查"。
- `asset-device`:新增"设备停复机不设保护期"要求,并约定 `device_protect_status` 字段的兼容返回。
## Impact
- 依赖:无前置 Change。第 4 个 Change 改写通道阈值规范时一并去掉"保护期强制复机"表述。
- 数据库:新增成对迁移,只更新 `tb_polling_config.protect_check_interval`、`tb_iot_card.last_protect_check_at` 的列注释(标记废弃);不删列、不改数据。
- 代码:删除 `internal/task/polling_protect_handler.go` 与 `pkg/queue/handler.go` 中的注册;`internal/service/device/service.go`(不设、不查保护期);`internal/service/iot_card/stop_resume_service.go`(手动停复机去掉保护期检查);`stop_resume_unified.go` 删除 `ProtectCheckSkipsForRealname`;`internal/polling/config_manager.go`、`initializer.go`、调度器去掉 `protect` 类型;`internal/model/dto/polling_config_dto.go`、`asset_dto.go`、`client_asset_dto.go`、`internal/service/asset/service.go`;`pkg/constants`。
- 接口:`/polling-configs` 不再含 `protect_check_interval`;`/polling-concurrency` 不再含 `protect`;资产详情 `device_protect_status` 固定为无保护期并标记废弃。重新生成接口文档。
- 前端:轮询配置页去掉保护期间隔;资产详情(后台与 C 端)不再展示保护期状态。

View File

@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: 设备停复机不设保护期
系统 SHALL 在后台对设备停机或复机后不设置保护期,MUST NOT 因此前的设备停机或复机拒绝相反的人工操作;单卡手动停复机 MUST NOT 检查保护期。系统 MUST NOT 调度保护期一致性检查,也 MUST NOT 产生停机原因 `protect_period` 的新记录;历史记录中的 `protect_period` 保留并照常展示,对外开放接口的停机原因映射保持不变。
#### Scenario: 设备停机后立即复机
- **WHEN** 运营对设备停机后 10 分钟内点击复机
- **THEN** 系统按复机规则执行,不因保护期拒绝
#### Scenario: 停机后自动规则复机
- **WHEN** 设备被人工停机后满足自动复机条件
- **THEN** 系统按规则自动复机,不存在保护期内的强制停机或强制复机
### Requirement: 保护期状态字段兼容返回
系统 SHALL 在后台资产详情与 C 端资产详情中继续返回 `device_protect_status` 字段,其值 MUST 固定为无保护期(`none`),接口文档 MUST 标记该字段已废弃。字段删除另行变更,前提是后台前端与 C 端(含老版本小程序)均不再读取。
#### Scenario: 老版本客户端读取
- **WHEN** 老版本小程序读取设备资产详情
- **THEN** 响应中仍有 `device_protect_status`,值为 `none`

Some files were not shown because too many files have changed in this diff Show More