Files
junhong_cmp_fiber/internal/task/polling_base.go
break 5c4d17e9fc
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
收口七月卡状态回调与系列授权兼容契约
完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
2026-07-24 19:59:24 +08:00

166 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package task
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// acquireConcurrencyScript 原子获取并发信号量的 Lua 脚本
// INCR + EXPIRE 合并为单个服务端操作,消除二者之间的崩溃窗口:
// 若 Worker 在 INCR 后、EXPIRE 前崩溃key 将永久留在 Redis 导致计数器卡死。
// KEYS[1]: 当前并发计数 key
// ARGV[1]: 最大并发数ARGV[2]: key TTL
// 返回 -1 表示超额拒绝,>0 表示成功获取后的计数值
var acquireConcurrencyScript = redis.NewScript(`
local current = redis.call('INCR', KEYS[1])
if tonumber(current) > tonumber(ARGV[1]) then
redis.call('DECR', KEYS[1])
return -1
end
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
return current
`)
const pollingFallbackOperationTimeout = 5 * time.Second
// pollingFallbackContext 创建轮询兜底操作使用的独立短超时上下文。
// Asynq 任务 ctx 超时后,释放并发计数、释放锁、重入队仍必须尽量完成,
// 否则会造成并发计数短期卡住,甚至卡已出队但未重新入队。
func pollingFallbackContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), pollingFallbackOperationTimeout)
}
// PollingBase 轮询共享基类
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,所有 Handler 共享
type PollingBase struct {
redis *redis.Client
queueMgr *polling.PollingQueueManager
configMgr *polling.PollingConfigManager
iotCardStore *postgres.IotCardStore
logger *zap.Logger
verboseLog bool
trafficLock *cardtrafficlock.Lock
}
// NewPollingBase 创建轮询共享基类
func NewPollingBase(
redisClient *redis.Client,
queueMgr *polling.PollingQueueManager,
configMgr *polling.PollingConfigManager,
iotCardStore *postgres.IotCardStore,
logger *zap.Logger,
verboseLog bool,
) *PollingBase {
return &PollingBase{
redis: redisClient,
queueMgr: queueMgr,
configMgr: configMgr,
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
trafficLock: cardtrafficlock.New(redisClient),
}
}
// acquireConcurrency 原子获取并发信号量
// 使用 Lua 脚本将 INCR 与 EXPIRE 合并为单个服务端操作,消除非原子窗口:
// 旧实现中若 Worker 在 INCR 后、EXPIRE 前崩溃key 永久留在 Redis计数器卡死。
func (b *PollingBase) acquireConcurrency(ctx context.Context, taskType string) bool {
shortType := shortTaskType(taskType)
configKey := constants.RedisPollingConcurrencyConfigKey(shortType)
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
maxConcurrency, err := b.redis.Get(ctx, configKey).Int()
if err != nil {
maxConcurrency = constants.PollingDefaultMaxConcurrency
}
result, err := acquireConcurrencyScript.Run(
ctx, b.redis, []string{currentKey},
maxConcurrency, constants.PollingConcurrencyKeyTTL,
).Int64()
if err != nil {
b.logger.Warn("获取并发计数失败,放行任务", zap.Error(err))
return true
}
return result != -1
}
// releaseConcurrency 释放并发信号量
func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) {
ctx, cancel := pollingFallbackContext()
defer cancel()
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
if err := b.redis.Decr(ctx, currentKey).Err(); err != nil {
b.logger.Warn("释放并发计数失败", zap.String("task_type", taskType), zap.Error(err))
}
}
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免轮询与手动刷新重复统计同一上游读数。
func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
return b.trafficLock.Acquire(ctx, cardID)
}
// releaseCardTrafficSyncLock 释放卡流量同步锁。
func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint, token string) {
ctx, cancel := pollingFallbackContext()
defer cancel()
if err := b.trafficLock.Release(ctx, cardID, token); err != nil {
b.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// requeueCardAt 使用独立短超时上下文执行真正的 ZADD 重入队。
func (b *PollingBase) requeueCardAt(cardID uint, taskType string, nextCheckAt time.Time) error {
ctx, cancel := pollingFallbackContext()
defer cancel()
if err := b.queueMgr.Requeue(ctx, cardID, taskType, nextCheckAt); err != nil {
b.logger.Error("重入队失败,卡可能暂停轮询",
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(err))
return err
}
return nil
}
// requeueCard 将卡按匹配配置间隔重新入队分片 Sorted Set
// ⚠️ 关键Lua 脚本原子出队后卡已从队列移除,若并发满时直接 return 会导致卡永久丢失
// 调用方必须在 acquireConcurrency 返回 false 时调用此方法入队后再返回
func (b *PollingBase) requeueCard(_ context.Context, cardID uint, taskType string) error {
ctx, cancel := pollingFallbackContext()
card, err := b.getCardWithCache(ctx, cardID)
cancel()
if err != nil {
b.logger.Warn("重入队获取卡信息失败使用30秒兜底间隔",
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(err))
// 获取卡失败时立即重入队(下次仍然尝试处理)
return b.requeueCardAt(cardID, taskType, time.Now().Add(30*time.Second))
}
intervals := b.configMgr.MergedTaskIntervals(card)
if len(intervals) == 0 {
// 兜底:配置未加载时延迟 30 秒重入队,防止卡从轮询永久消失
b.logger.Warn("无匹配轮询配置30秒后重入队配置可能未加载",
zap.Uint("card_id", cardID), zap.String("task_type", taskType))
return b.requeueCardAt(cardID, taskType, time.Now().Add(30*time.Second))
}
info, ok := intervals[taskType]
if !ok || info.Interval <= 0 {
b.logger.Debug("轮询间隔为 NULL不入队", zap.Uint("card_id", cardID), zap.String("task_type", taskType))
return nil
}
nextCheckAt := time.Now().Add(time.Duration(info.Interval) * time.Second)
return b.requeueCardAt(cardID, taskType, nextCheckAt)
}