新增 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。
15 KiB
Context
现状(已核实):
- 执行通道:16 分片 ZSet(score=下次检查时间)→ 调度器每 1 秒先排空各任务类型的手动队列(List
polling:manual:{taskType}),再扫描分片 → 既有 Asynq 队列(5 类任务 realname/carddata/package/protect/card_status)→ 既有轮询 Handler。手动队列排空不受分片背压阈值约束,分片批次会被背压跳过。 - 并发:分类型 + 全局两个 Redis Lua 信号量,上限来自
tb_polling_concurrency_config(上限 1000);既有任务MaxRetry(0)、Timeout(60s)。 - 失败:Handler 内部按配置间隔无限重入队,无失败次数上限、无补偿判据。
- 卡级锁只有流量同步锁,仅服务 carddata、人工刷新与观测序列,不能作为通用互斥。
- 触发事实现状:主套餐立即生效、排队主套餐顺延生效、加油包生效三处已在同一业务事务写卡观测序列事件,但载荷不含触发场景;资产无有效套餐只在停复机评估内部判定;普通轮询失败没有任何可判定条件。
- 观测序列通道是另一套执行路径(阶梯尝试),对同一资源有各自的并发保护。
需求权威为 PRD §2.15(优先轮询定位);111.md §23 仅作追溯,其优先级分级、优先级规则配置、有效期与异常记录页均不在本 Change 范围。本设计只解释实现方式,行为契约以 specs 为准。
Goals / Non-Goals
Goals:
- 让命中场景的资产在不改变普通轮询内容的前提下额外获得一次优先执行的既有轮询。
- 加急事实可持久化、可审计、可查询,且人工加急与既有人工触发权限语义一致。
- 复用既有调度、既有任务类型、既有并发与重试保护,不引入第二套轮询逻辑。
Non-Goals:
- 不做 P0/P1/P2 分级与优先级规则配置;不引入有效期字段与过期语义。
- 不做独立异常记录页与人工重触发按钮(重触发走既有人工触发或人工优先入队)。
- 不新增可维护的参数配置项;不改动普通轮询内容、并发上限、失败重试与外部调用保护。
- 不新建调度设施、Asynq 任务类型或队列;不建通用卡级锁;不改分片队列的出队、入队与移除路径。
- 不改人工触发 API 的任务类型白名单、每日次数上限与 24 小时去重;不改通道阈值停机锁的复机拒绝语义。
Decisions
D1 队列载体与执行提示通道分工
持久化事实与认领权威是新增优先项表;执行提示走独立提示通道键(每任务类型一条 List),不复用 polling:manual:{taskType}。调度器在同一调度周期内先排空优先提示、再排空手动触发队列,其余调度路径不变。
- 替代方案一:新建独立调度器/独立 Asynq 队列。放弃,PRD 明确「仅调度顺序优先,不另建参数配置」,且新队列需要新增权重与 Worker 配置。
- 替代方案二:复用人工触发队列。放弃,人工触发的每秒排空额度有限,大批自动触发会把人工触发挤到队列尾部,且两类提示无法区分观测。
- 强度边界(须写进实现与验收):优先只在调度入口领先——跳过分片 ZSet 的定时等待,并在同一周期内先入队;同一任务类型的既有 Asynq 队列内不插队;分片背压跳过不影响优先提示入队。这正是 PRD「仅调度顺序优先」的范围。
D2 唯一活动项粒度 = (card_id, task_type)
同一卡同一轮询任务类型至多一条活动项;一次优先需求对应多个既有任务类型的执行。
- 理由:一次加急天然要对多个任务类型各执行一次,单行 + 多类型 JSON 会同时削弱认领的原子性与逐类型的执行结果审计;按类型分行的互斥语义精确,避免「整卡跳过」饿死其它类型的普通轮询,与 PRD「不移除、暂停或改变普通轮询」一致。
- 合并语义不变:后续触发合并进已存在的活动行,追加触发次数、最近触发时间与来源,不新建行、不重复调用。
- 替代方案:单行 + 类型集合 JSON。放弃,认领需要 JSON 级原子更新,逐类型失败原因与结果无法独立审计。
- 执行集合取
{realname, carddata, package, card_status}:对应 111.md 轮询目标枚举;protect(保护期一致性)不纳入,它是设备维度的一致性修正且会主动发起强制停复机,与五类触发场景无关。
D3 互斥实现:条件认领,而非通用锁或 Redis 活动标记
在轮询共享基类新增接缝,位于取并发信号量之后、调用上游之前,四个纳入集合的 Handler 接入:查询该卡该任务类型的活动项(部分唯一索引探测)。
- 无活动项 → 完全等价既有行为(普通轮询入队、出队、移除路径一行不改)。
- 待执行 → 条件更新(待执行→执行中)命中者即「领取成功」,本轮执行同时就是优先执行;未命中(已被他人领取)→ 跳过该轮次并按既有间隔延后。
- 执行中且租约未到期 → 跳过并延后,不发第二次上游调用。
- 并发安全由数据库条件更新提供,不需要 Redis 活动标记,也不需要通用卡级锁(既有卡级锁只服务流量同步链,语义不匹配)。分片队列的出队、入队与移除路径保持原样。
- 关键澄清:「存在活动项就跳过」会饿死队列——待执行项必须由到达的执行认领并执行,否则提示通道产生的执行也会被自己跳过,优先项永远无人执行。可观察契约因此区分为「待执行=认领并执行」与「执行中=跳过并延后」。
D4 认领租约与崩溃接管
执行中项带认领租约 90 秒(大于既有任务超时 60 秒,留出收尾与重入队余量)。超过租约仍未被更新的执行中项允许相邻执行接管领取。
- 理由:没有租约时,崩溃遗留的「执行中」会让该卡该任务类型被永久跳过,等价于悄悄停掉该卡该类型的轮询——这是不可接受的静默退化。
- 租约接管不违反「不重复调用」:崩溃的执行没有产生有效结果,重新执行是必要恢复;实际并发仍受既有信号量约束。
D5 提示通道丢失的兜底
提示通道不是权威:丢失或缓存服务重启时,活动项仍在库中,后续普通轮询执行按 D3 条件认领仍会执行,只退化为延迟一个普通轮询周期。不引入第二套补偿任务或扫描。
D6 执行前轮询范围校验
执行前校验卡仍在轮询范围内(卡自身与绑定设备的轮询开关均为启用;独立卡命中风险状态会把轮询开关置为关闭)。不满足时终止该项、保留可安全展示的原因,且不发起上游调用。
- 理由:优先通道不得重启已停止轮询的卡;否则加急入口会成为绕过轮询管控的后门。
- 既有人工触发的 3 个入口行为不变,本 Change 不改变其范围语义。
D7 尝试上限常量与出队
优先项使用自身固定的最大尝试次数常量(3),不作为可维护配置项;普通轮询的无限重试策略不变。
- 尝试次数只在真正发起执行后累加;可恢复失败未达上限回到活动态并按既有间隔重排;达上限记录失败终态与安全原因后出队。
- 业务校验类失败(卡不存在、不在轮询范围)直接失败不重试。
- 出队后该卡继续普通轮询且既有无限重入队仍在,不丢卡。
- 不新增有效期字段、不建独立异常记录表。
D8 触发事件类型与抑制开关
新增独立可靠事件类型承载「优先轮询请求」,在原业务事务内追加,由 Outbox 在提交后投递;消费者据载荷逐卡建项并下发提示。
- 不能复用卡观测序列事件类型:同一事件类型不允许注册第二个消费者,且该事件驱动的是另一套观测序列通道。
- 载荷沿用既有形状(资源类型/资源 ID/资源 ID 列表),并在事务内冻结绑定卡快照;另带触发类型与来源订单/套餐使用记录标识。
- 抑制开关:既是观测结果驱动的业务评估,写优先请求事件时必须与既有观测序列写入同样短路,避免「观测→业务→优先轮询→观测」的反向触发环。
- 人工入队不经过事件通道,直接在入队用例中写事实并下发提示。
D9 触发类型枚举与续购判定
稳定枚举:purchase_activated、renewal_activated、queue_activated、addon_activated、no_valid_package、manual_trigger。生产者按自身代码路径写入。
- 新购与续购的判定:在生效事务内对同一载体做一次更早套餐使用记录的存在性查询,存在即
renewal_activated,否则purchase_activated。 - 替代方案:消费者按套餐使用记录现场判定。放弃,消费者无法可靠区分新购与续购(需要历史查询),且会把业务语义判断搬到 Worker。
D10 无有效套餐的前置条件
该场景 MUST 附带前置条件「该载体存在待生效的套餐使用记录」。
- 理由:不加前置条件时,每个普通套餐轮询周期都会重新命中该场景并触发一轮优先,形成自激循环;前置条件让触发在有真实待同步业务时发生,业务恢复后自然停止。
- 不含「待支付订单」:支付结果未确认时不入队,与既有支付成功才生效的隔离一致。
D11 多卡资产口径
按资产解析绑定状态有效的全部在用卡(独立卡为自身),在触发事务内冻结快照,逐卡建项、逐卡记录结果;不使用设备上报的当前卡槽作为入队口径。与既有「业务成功时冻结设备绑定卡快照」的口径一致。
D12 人工入队
复用既有人工触发的权限判定语义(超管/平台全量放行;代理限自身与下级店铺,平台卡不可见;企业拒绝),把该判定抽为同包共享函数,判定语义零变化,不新建角色守卫。入队原因必填并与事实一同保存。
- 不继承每日次数上限与 24 小时去重:它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,且每次人工入队独立审计。
- 人工入队不改调度优先级、不绕过并发上限。
D13 读侧最小接口与数据范围下推
提供列表与详情(筛选、分页)。事实表冗余卡所属店铺快照(平台卡为空),查询按既有数据范围下推判定,代理看不到平台卡与范围外卡,越权与不存在响应不可区分。
- 该接口是最小可读性能力,不是 111.md 的「异常与记录」页:不含优先级分级、有效期、人工重触发入口。
D14 审计归属
- Outbox:触发事件(业务事务内)与既有事件类型不变。
- Audit Event:入队(含合并)、领取、每次失败、重试、终态出队,以及人工入队与被拒绝的人工请求;人工侧复用既有轮询审计输入与失败短事务模式,系统侧使用既有 Worker 审计上下文,并按(操作者类型,来源)补齐注册。
- Integration Log:沿用既有「仅变化才记录外部交互」的规则,本 Change 不新增。
- Domain Ledger:N/A(不涉及资金与库存)。
- 低写入约定保持:优先项生命周期事件低频且属业务事实,不改变「无变化观测不写审计」的既有规则。
D15 与通道阈值停机的边界
通道阈值停机锁只拒绝复机,不使卡退出轮询范围(既有移除路径仅由卡状态变化、禁用、删除与设备级轮询关闭触发)。优先执行复用既有停复机判定,因此持锁拒绝语义天然保留;spec 中以可观察断言固化,实现不得绕过停机锁。
Risks / Trade-offs
- 优先提示不受分片背压约束 → 大批触发时 Asynq 同类型队列会积压。缓解:实际并发仍由既有信号量兜住,积压由既有队列深度监控观测;不新增背压分支(新增分支会在过载时正好压制加急)。
- 每次轮询执行多一次活动项索引探测 → 无匹配时只是索引探测、不产生行锁,成本可控;若日后成为瓶颈,可加「活动卡集合」提示缓存作为纯优化,本版不做(避免第二种可能失真的状态)。
- 优先项存续期间该卡该任务类型的普通执行被延后 → 由尝试上限与认领租约双重兜底,不会无限期;出队后立即恢复普通轮询。
- 多卡资产触发放大(每卡 × 每任务类型)→ 由活动项合并抑制重复,逐卡逐类型记录结果,符合产品口径。
- 无有效套餐场景在「待同步业务持续存在」时每个普通套餐周期触发一轮优先 → 属设计取舍(即加急直到资产恢复);恢复或待同步业务消失后自然停止。此口径须在实施说明中保留,避免被当成缺陷。
- 提示通道与库内事实短期不一致(提示存在但项已终态)→ 到达的执行按事实判定,最多等价一次普通轮询,不产生并发重复调用。
- 观测序列通道与优先轮询可能对同一卡同一时刻发起上游调用(流量同步有卡级锁覆盖;网络/实名观测的间隔保护轮询侧不读取)→ 属既有通道间的既有限制,本 Change 不修改,登记为已知边界。
Migration Plan
- 新增成对迁移:优先项表(活动项部分唯一索引 = 卡 + 任务类型,仅覆盖待执行/执行中且未软删;状态与时间索引)+ down 守卫(存在活动项即拒绝回滚,避免静默丢失加急事实)。
- 模型与存储:关联只存 ID 并显式查询,不新增数据库外键与关联标签。
- 无数据回填;上线后无活动项时系统行为与当前完全一致,可先发布再依赖触发。
- 隔离环境执行 up / down / up 验证;回滚前必须确认无活动项,否则按守卫拒绝并人工确认。
- 发布顺序:先迁移与 Worker(消费者、接缝、提示通道),再开放人工入队与读侧接口。
后续候选
普通轮询异常补偿(本版不做)
- 现状无任何可判定条件:轮询失败按配置间隔无限重入队,无失败上限、无补偿入口,因此当前 spec 中的「既有异常补偿条件」是不可实现、不可验证的空指向,已从 spec 与任务中移除。
- 本版若实现等于自造判据(例如「任一次可恢复失败即入队」);且渠道故障时全量失败卡会涌入优先通道,而提示通道不受分片背压约束,风险不对称。
- 处置:需产品确认后以独立 Change 实施;届时在 PRD §2.15 该场景处解除本版标注,并同时定义判据、上限与过载保护。
其它候选(均需独立 Change)
- 优先级分级与优先级规则配置(111.md §23.6)。
- 优先项有效期与过期出队(111.md §23.4)。
- 独立异常与重试记录页、人工重触发入口、资产/卡详情优先轮询状态展示(111.md §23.2、§23.7、§23.11)。