Files
junhong_cmp_fiber/internal/task/polling_base.go
break aab56a6998
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
新增 000228 成对迁移 tb_polling_priority_item:卡、任务类型、状态、触发类型、来源订单/套餐使用记录、
触发次数与来源集合、尝试次数、失败原因、人工原因与操作者、店铺快照与各时间列;以活动项部分唯一索引
uq_polling_priority_item_active(仅 deleted_at IS NULL AND status IN ('pending','processing') 占键位)
表达「同卡同任务类型至多一条活动项」,另有状态/时间索引与全列注释;down 守卫在存在活动项或未终态行时
拒绝回滚并给出中文原因。

新增优先轮询请求可靠事件 polling.priority.requested(载荷版本 v1、事件键前缀 prio:)与消费者:只在原
业务事务内追加、幂等键稳定;消费者按卡 × 纳入任务类型(realname/carddata/card_status/package)逐条
建项并在提交后下发执行提示,重复投递只合并触发次数、来源集合与最近触发时间,不新建行也不重复调用。
触发点为四类自动场景 purchase_activated / renewal_activated(按同载体更早套餐使用记录判定)/
queue_activated / addon_activated 与「无有效套餐」no_valid_package(仅在普通套餐轮询来源且存在待生效
套餐使用记录时追加;事件通道显式拒绝 manual_trigger);入队对象恒为卡,绑定设备资产在触发事务内冻结
在用卡快照逐卡建项,不使用设备当前卡槽口径。

轮询共享基类新增认领接缝:四个 Handler(realname/carddata/card_status/package)在并发信号量之后、调用
上游之前探测活动项——待执行条件认领、执行中且 90 秒租约未到期则跳过并延后、无活动项时行为与既有完全
等价;超租约允许相邻执行接管,尝试次数只在真正发起执行后累加,未达上限(3)回到活动态按既有间隔重排,
达上限或业务校验类失败进入失败终态并保留可安全展示原因;执行前校验卡自身与绑定设备的轮询开关。未引入
通用卡级锁与 Redis 活动标记,分片队列的出队、入队与移除路径未改动。

提示通道按任务类型独立键(polling:priority:{taskType}),与既有手动触发队列分离;调度器在同一周期内先
排空优先提示、再排空手动触发队列,提示排空不受分片背压跳过影响;未新建调度设施或异步任务类型。

新增人工优先入队与只读查询三条路由 POST /api/admin/polling-priority-items、
GET /api/admin/polling-priority-items、GET /api/admin/polling-priority-items/:id:人工入队复用既有轮询
权限判定(抽取为同包共享函数),原因必填,不受每日 500 次上限与 24 小时去重约束,重复抑制由活动项合并
承担;读侧按店铺快照下推数据范围,越权与不存在不可区分,不提供优先级分级、有效期或人工重触发入口。
新增 7 个审计动作(enqueue/claim/fail/retry/complete/dequeue/manual_denied)与资源
polling_priority_item,并按(操作者类型,来源)注册,人工侧与 Worker 侧均通过来源校验。

同步 OpenAPI 文档装配三处与路由注册;归档 Change 至
openspec/changes/archive/2026-09-17-add-priority-polling-queue/ 并同步主 Spec(新增
priority-polling-queue、polling-operations 追加单次执行互斥 Requirement 与三条路由索引)与上下文健康
证据(requirement-evidence 150 行、入口矩阵 http 403 / async 56)。

本机验证:junhong_cmp_test 与隔离 Redis DB 15,未连生产、未启动 Worker/API、未调用运营商上游;迁移
up/down/up 与 down 守卫实测(含 dirty=true 记账口径与 force 恢复),A–F 批 94 PASS、接缝 63 PASS、
提示通道 12 PASS、清理零残留 20 PASS。成功路径 Complete、真并发互斥、尝试上限第 3 次判定、HTTP 层权限
矩阵、通道阈值持锁复机边界与三类生效触发点生产集成留待测试部署验证(见
docs/verification/add-priority-polling-queue-verification.md 第 4 节)。自动化测试按项目决策为 N/A,
未新增 *_test.go。
2026-09-17 14:29:56 +08:00

232 lines
8.9 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"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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]: 全部轮询计数KEYS[2]: 分类轮询计数
// ARGV[1]: 总量上限ARGV[2]: 分类上限ARGV[3]: key TTL
// 返回 -1 表示任一上限超额,>0 表示成功获取后的总计数值
var acquireConcurrencyScript = redis.NewScript(`
local total = redis.call('INCR', KEYS[1])
local kind = redis.call('INCR', KEYS[2])
if tonumber(total) > tonumber(ARGV[1]) or tonumber(kind) > tonumber(ARGV[2]) then
redis.call('DECR', KEYS[1])
redis.call('DECR', KEYS[2])
return -1
end
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
return total
`)
var releaseConcurrencyScript = redis.NewScript(`
for _, key in ipairs(KEYS) do
local current = tonumber(redis.call('GET', key) or '0') or 0
if current <= 0 then
if redis.call('EXISTS', key) == 1 then redis.call('SET', key, 0, 'KEEPTTL') end
else
redis.call('DECR', key)
end
end
return 0
`)
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
totalMaxConcurrency int
trafficLock *cardtrafficlock.Lock
db *gorm.DB
priorityStore *postgres.PollingPriorityItemStore
priorityAudit *audit.Writer
scopeChecker *polling.PollingLifecycleService
}
// NewPollingBase 创建轮询共享基类
func NewPollingBase(
redisClient *redis.Client,
queueMgr *polling.PollingQueueManager,
configMgr *polling.PollingConfigManager,
iotCardStore *postgres.IotCardStore,
scopeChecker *polling.PollingLifecycleService,
priorityStore *postgres.PollingPriorityItemStore,
priorityAudit *audit.Writer,
db *gorm.DB,
logger *zap.Logger,
verboseLog bool,
totalMaxConcurrency int,
) *PollingBase {
return &PollingBase{
redis: redisClient,
queueMgr: queueMgr,
configMgr: configMgr,
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
totalMaxConcurrency: totalMaxConcurrency,
trafficLock: cardtrafficlock.New(redisClient),
db: db,
priorityStore: priorityStore,
priorityAudit: priorityAudit,
scopeChecker: scopeChecker,
}
}
// 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)
totalKey := constants.RedisPollingConcurrencyTotalCurrentKey()
maxConcurrency, err := b.redis.Get(ctx, configKey).Int()
if err != nil || maxConcurrency < 1 || maxConcurrency > constants.PollingMaxConcurrencyLimit {
maxConcurrency = constants.PollingDefaultMaxConcurrency
}
totalMax := b.totalMaxConcurrency
if totalMax < 1 || totalMax > constants.PollingMaxConcurrencyLimit {
totalMax = constants.PollingDefaultTotalMaxConcurrency
}
result, err := acquireConcurrencyScript.Run(
ctx, b.redis, []string{totalKey, currentKey},
totalMax, maxConcurrency, constants.PollingConcurrencyKeyTTL,
).Int64()
if err != nil {
b.logger.Warn("获取并发计数失败,放行任务", zap.Error(err))
return true
}
if result == -1 {
b.logger.Info("轮询因并发令牌不足延后", zap.String("task_type", taskType),
zap.Int("max_concurrency", maxConcurrency), zap.Int("total_max_concurrency", totalMax),
zap.String("metric", "polling.deferred.concurrency_limit"))
return false
}
return true
}
// releaseConcurrency 释放并发信号量
func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) {
ctx, cancel := pollingFallbackContext()
defer cancel()
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
if err := releaseConcurrencyScript.Run(ctx, b.redis, []string{constants.RedisPollingConcurrencyTotalCurrentKey(), 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))
}
}
// ensureMissingTask 根据数据库最新卡状态,仅在对应分片队列缺失时补入任务。
func (b *PollingBase) ensureMissingTask(ctx context.Context, cardID uint, taskType string) error {
card, err := b.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
info, ok := b.configMgr.MergedTaskIntervals(card)[taskType]
if !ok || info.Interval <= 0 {
return nil
}
added, err := b.queueMgr.EnsureQueued(ctx, cardID, taskType, time.Now())
if err != nil {
return err
}
if added {
b.logger.Info("卡状态轮询补齐缺失套餐任务",
zap.Uint("card_id", cardID), zap.String("task_type", taskType))
}
return nil
}
// 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)
}