feat: 实现 IoT 卡轮询系统(支持千万级卡规模)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m35s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m35s
实现功能: - 实名状态检查轮询(可配置间隔) - 卡流量检查轮询(支持跨月流量追踪) - 套餐检查与超额自动停机 - 分布式并发控制(Redis 信号量) - 手动触发轮询(单卡/批量/条件筛选) - 数据清理配置与执行 - 告警规则与历史记录 - 实时监控统计(队列/性能/并发) 性能优化: - Redis 缓存卡信息,减少 DB 查询 - Pipeline 批量写入 Redis - 异步流量记录写入 - 渐进式初始化(10万卡/批) 压测工具(scripts/benchmark/): - Mock Gateway 模拟上游服务 - 测试卡生成器 - 配置初始化脚本 - 实时监控脚本 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,11 @@ const (
|
||||
TaskTypeCommissionStatsUpdate = "commission:stats:update" // 佣金统计更新
|
||||
TaskTypeCommissionStatsSync = "commission:stats:sync" // 佣金统计同步
|
||||
TaskTypeCommissionStatsArchive = "commission:stats:archive" // 佣金统计归档
|
||||
|
||||
// 轮询任务类型
|
||||
TaskTypePollingRealname = "polling:realname" // 实名状态检查
|
||||
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
|
||||
TaskTypePollingPackage = "polling:package" // 套餐流量检查
|
||||
)
|
||||
|
||||
// 用户状态常量
|
||||
|
||||
@@ -157,3 +157,91 @@ func RedisCommissionStatsLockKey() string {
|
||||
func RedisShopSeriesAllocationKey(shopID, seriesID uint) string {
|
||||
return fmt.Sprintf("shop_series_alloc:%d:%d", shopID, seriesID)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 轮询系统相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisPollingQueueRealnameKey 生成实名检查轮询队列的 Redis 键
|
||||
// 用途:Sorted Set 存储待检查实名状态的卡,Score 为下次检查的 Unix 时间戳
|
||||
// 过期时间:无(持久化数据)
|
||||
func RedisPollingQueueRealnameKey() string {
|
||||
return "polling:queue:realname"
|
||||
}
|
||||
|
||||
// RedisPollingQueueCarddataKey 生成卡流量检查轮询队列的 Redis 键
|
||||
// 用途:Sorted Set 存储待检查流量的卡,Score 为下次检查的 Unix 时间戳
|
||||
// 过期时间:无(持久化数据)
|
||||
func RedisPollingQueueCarddataKey() string {
|
||||
return "polling:queue:carddata"
|
||||
}
|
||||
|
||||
// RedisPollingQueuePackageKey 生成套餐检查轮询队列的 Redis 键
|
||||
// 用途:Sorted Set 存储待检查套餐的卡,Score 为下次检查的 Unix 时间戳
|
||||
// 过期时间:无(持久化数据)
|
||||
func RedisPollingQueuePackageKey() string {
|
||||
return "polling:queue:package"
|
||||
}
|
||||
|
||||
// RedisPollingCardInfoKey 生成卡信息缓存的 Redis 键
|
||||
// 用途:Hash 存储卡的基本信息和轮询状态
|
||||
// 过期时间:7 天
|
||||
func RedisPollingCardInfoKey(cardID uint) string {
|
||||
return fmt.Sprintf("polling:card:%d", cardID)
|
||||
}
|
||||
|
||||
// RedisPollingConfigsCacheKey 生成轮询配置缓存的 Redis 键
|
||||
// 用途:String 存储所有轮询配置(JSON 格式)
|
||||
// 过期时间:10 分钟
|
||||
func RedisPollingConfigsCacheKey() string {
|
||||
return "polling:configs"
|
||||
}
|
||||
|
||||
// RedisPollingConfigCardsKey 生成配置匹配索引的 Redis 键
|
||||
// 用途:Set 存储匹配该配置的所有卡 ID
|
||||
// 过期时间:1 小时
|
||||
func RedisPollingConfigCardsKey(configID uint) string {
|
||||
return fmt.Sprintf("polling:config:cards:%d", configID)
|
||||
}
|
||||
|
||||
// RedisPollingConcurrencyConfigKey 生成并发控制配置的 Redis 键
|
||||
// 用途:String 存储任务类型的最大并发数
|
||||
// 过期时间:无(持久化配置)
|
||||
func RedisPollingConcurrencyConfigKey(taskType string) string {
|
||||
return fmt.Sprintf("polling:concurrency:config:%s", taskType)
|
||||
}
|
||||
|
||||
// RedisPollingConcurrencyCurrentKey 生成并发控制当前值的 Redis 键
|
||||
// 用途:String 存储任务类型的当前并发数(计数器)
|
||||
// 过期时间:无(实时数据)
|
||||
func RedisPollingConcurrencyCurrentKey(taskType string) string {
|
||||
return fmt.Sprintf("polling:concurrency:current:%s", taskType)
|
||||
}
|
||||
|
||||
// RedisPollingManualQueueKey 生成手动触发队列的 Redis 键
|
||||
// 用途:List 存储手动触发的卡 ID(FIFO 队列)
|
||||
// 过期时间:无(临时队列)
|
||||
func RedisPollingManualQueueKey(taskType string) string {
|
||||
return fmt.Sprintf("polling:manual:%s", taskType)
|
||||
}
|
||||
|
||||
// RedisPollingManualDedupeKey 生成手动触发去重的 Redis 键
|
||||
// 用途:Set 存储已加入手动触发队列的卡 ID(用于去重)
|
||||
// 过期时间:1小时
|
||||
func RedisPollingManualDedupeKey(taskType string) string {
|
||||
return fmt.Sprintf("polling:manual:dedupe:%s", taskType)
|
||||
}
|
||||
|
||||
// RedisPollingStatsKey 生成监控统计的 Redis 键
|
||||
// 用途:Hash 存储监控指标(成功数、失败数、总耗时等)
|
||||
// 过期时间:无(持久化统计)
|
||||
func RedisPollingStatsKey(taskType string) string {
|
||||
return fmt.Sprintf("polling:stats:%s", taskType)
|
||||
}
|
||||
|
||||
// RedisPollingInitProgressKey 生成初始化进度的 Redis 键
|
||||
// 用途:Hash 存储初始化进度信息(总数、已处理数、状态)
|
||||
// 过期时间:无(初始化完成后自动删除)
|
||||
func RedisPollingInitProgressKey() string {
|
||||
return "polling:init:progress"
|
||||
}
|
||||
|
||||
@@ -116,6 +116,15 @@ const (
|
||||
CodeForceRechargeRequired = 1140 // 必须充值指定金额
|
||||
CodeForceRechargeAmountMismatch = 1141 // 强充金额不匹配
|
||||
|
||||
// 轮询系统相关错误 (1150-1169)
|
||||
CodePollingConfigNotFound = 1150 // 轮询配置不存在
|
||||
CodePollingConfigNameExists = 1151 // 轮询配置名称已存在
|
||||
CodePollingQueueFull = 1152 // 轮询队列已满
|
||||
CodePollingConcurrencyLimit = 1153 // 并发数已达上限
|
||||
CodePollingAlertRuleNotFound = 1154 // 告警规则不存在
|
||||
CodePollingCleanupConfigNotFound = 1155 // 数据清理配置不存在
|
||||
CodePollingManualTriggerLimit = 1156 // 手动触发次数已达上限
|
||||
|
||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||
CodeInternalError = 2001 // 内部服务器错误
|
||||
CodeDatabaseError = 2002 // 数据库错误
|
||||
@@ -214,6 +223,13 @@ var allErrorCodes = []int{
|
||||
CodePurchaseOnBehalfInvalidTarget,
|
||||
CodeForceRechargeRequired,
|
||||
CodeForceRechargeAmountMismatch,
|
||||
CodePollingConfigNotFound,
|
||||
CodePollingConfigNameExists,
|
||||
CodePollingQueueFull,
|
||||
CodePollingConcurrencyLimit,
|
||||
CodePollingAlertRuleNotFound,
|
||||
CodePollingCleanupConfigNotFound,
|
||||
CodePollingManualTriggerLimit,
|
||||
CodeInternalError,
|
||||
CodeDatabaseError,
|
||||
CodeRedisError,
|
||||
@@ -310,6 +326,13 @@ var errorMessages = map[int]string{
|
||||
CodePurchaseOnBehalfInvalidTarget: "代购目标无效",
|
||||
CodeForceRechargeRequired: "必须充值指定金额",
|
||||
CodeForceRechargeAmountMismatch: "强充金额不匹配",
|
||||
CodePollingConfigNotFound: "轮询配置不存在",
|
||||
CodePollingConfigNameExists: "轮询配置名称已存在",
|
||||
CodePollingQueueFull: "轮询队列已满",
|
||||
CodePollingConcurrencyLimit: "并发数已达上限",
|
||||
CodePollingAlertRuleNotFound: "告警规则不存在",
|
||||
CodePollingCleanupConfigNotFound: "数据清理配置不存在",
|
||||
CodePollingManualTriggerLimit: "手动触发次数已达上限",
|
||||
CodeInvalidCredentials: "用户名或密码错误",
|
||||
CodeAccountLocked: "账号已锁定",
|
||||
CodePasswordExpired: "密码已过期",
|
||||
|
||||
@@ -45,5 +45,11 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
H5Order: h5.NewOrderHandler(nil),
|
||||
H5Recharge: h5.NewRechargeHandler(nil),
|
||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),
|
||||
PollingConfig: admin.NewPollingConfigHandler(nil),
|
||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||
PollingMonitoring: admin.NewPollingMonitoringHandler(nil),
|
||||
PollingAlert: admin.NewPollingAlertHandler(nil),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(nil),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -15,20 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
mux *asynq.ServeMux
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
storage *storage.Service
|
||||
mux *asynq.ServeMux
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
storage *storage.Service
|
||||
gatewayClient *gateway.Client
|
||||
pollingCallback task.PollingCallback
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, logger *zap.Logger) *Handler {
|
||||
func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, gatewayClient *gateway.Client, pollingCallback task.PollingCallback, logger *zap.Logger) *Handler {
|
||||
return &Handler{
|
||||
mux: asynq.NewServeMux(),
|
||||
logger: logger,
|
||||
db: db,
|
||||
redis: redis,
|
||||
storage: storageSvc,
|
||||
mux: asynq.NewServeMux(),
|
||||
logger: logger,
|
||||
db: db,
|
||||
redis: redis,
|
||||
storage: storageSvc,
|
||||
gatewayClient: gatewayClient,
|
||||
pollingCallback: pollingCallback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +55,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
||||
h.registerDeviceImportHandler()
|
||||
h.registerCommissionStatsHandlers()
|
||||
h.registerCommissionCalculationHandler()
|
||||
h.registerPollingHandlers()
|
||||
|
||||
h.logger.Info("所有任务处理器注册完成")
|
||||
return h.mux
|
||||
@@ -58,7 +64,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
||||
func (h *Handler) registerIotCardImportHandler() {
|
||||
importTaskStore := postgres.NewIotCardImportTaskStore(h.db, h.redis)
|
||||
iotCardStore := postgres.NewIotCardStore(h.db, h.redis)
|
||||
iotCardImportHandler := task.NewIotCardImportHandler(h.db, h.redis, importTaskStore, iotCardStore, h.storage, h.logger)
|
||||
iotCardImportHandler := task.NewIotCardImportHandler(h.db, h.redis, importTaskStore, iotCardStore, h.storage, h.pollingCallback, h.logger)
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypeIotCardImport, iotCardImportHandler.HandleIotCardImport)
|
||||
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
|
||||
@@ -138,6 +144,20 @@ func (h *Handler) registerCommissionCalculationHandler() {
|
||||
h.logger.Info("注册佣金计算任务处理器", zap.String("task_type", constants.TaskTypeCommission))
|
||||
}
|
||||
|
||||
// registerPollingHandlers 注册轮询任务处理器
|
||||
func (h *Handler) registerPollingHandlers() {
|
||||
pollingHandler := task.NewPollingHandler(h.db, h.redis, h.gatewayClient, h.logger)
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingRealname, pollingHandler.HandleRealnameCheck)
|
||||
h.logger.Info("注册实名检查任务处理器", zap.String("task_type", constants.TaskTypePollingRealname))
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingCarddata, pollingHandler.HandleCarddataCheck)
|
||||
h.logger.Info("注册流量检查任务处理器", zap.String("task_type", constants.TaskTypePollingCarddata))
|
||||
|
||||
h.mux.HandleFunc(constants.TaskTypePollingPackage, pollingHandler.HandlePackageCheck)
|
||||
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
|
||||
}
|
||||
|
||||
// GetMux 获取 ServeMux(用于启动 Worker 服务器)
|
||||
func (h *Handler) GetMux() *asynq.ServeMux {
|
||||
return h.mux
|
||||
|
||||
Reference in New Issue
Block a user