feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
新增 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。
This commit is contained in:
@@ -338,3 +338,18 @@
|
||||
- **Owner**:数据负责人
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复
|
||||
|
||||
## KNOWN-ISSUE-002
|
||||
- **状态**:已登记的边界,非缺陷(当前不阻塞归档)
|
||||
- **适用范围**:卡轮询优先队列的三处实现边界:①「资产无有效套餐」的触发范围;②认领接缝探测失败的失败方向;③未被接缝使用的批量探测方法
|
||||
- **问题**:
|
||||
- 边界一(无有效套餐触发范围):`no_valid_package` 只在既有判定点——`internal/service/iot_card/stop_resume_service.go:354` 的「条件B:无有效套餐」被加入停机原因列表时——才会追加触发(调用点 `internal/service/iot_card/stop_resume_service.go:123`,实现 `:209`)。已经处于停机状态的卡走 `EvaluateAndAct` 的离线分支,不再重新判定「无有效套餐」,因此停机卡不会被该场景加急。
|
||||
- 边界二(认领接缝探测失败的失败方向):认领接缝的 `FindActive` 探测失败采用 fail-closed——四个轮询 Handler 按既有失败分支记为轮询失败并按既有间隔延后(`internal/task/polling_priority_claim.go:56` 返回错误;`internal/task/polling_realname_handler.go:47`、`internal/task/polling_carddata_handler.go:55`、`internal/task/polling_cardstatus_handler.go:56`、`internal/task/polling_package_handler.go:59` 各对应分支;carddata 连同放弃当轮流量同步)。
|
||||
- 边界三(未被使用的批量探测方法):`internal/store/postgres/polling_priority_item_store.go:148-149` 的 `ListActiveByCards` 当前没有任何调用方(`grep -rn "ListActiveByCards" internal/` 只命中定义本身)。
|
||||
- **理由**:边界一——行为契约的 WHEN 以「普通套餐轮询判定资产无有效套餐」为前提(该 Scenario 见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md:31`),停机卡不发生该判定,故当前实现不违反契约;把它扩展到停机卡属于新的产品口径。边界二——fail-open 会在数据库抖动时让同一卡同一任务类型同时出现两次上游调用,破坏本能力「至多一次上游调用」的 MUST,失败方向必须偏保守。边界三——`tasks.md` 1.4 的执行契约要求存储层提供「按卡批量活动项查询」,先交付后使用。
|
||||
- **证据**:见上述各边界的 `文件:行`;边界三的调用方检索当时结果为空(仅定义处命中)。
|
||||
- **例外条件**:边界一——若产品要求停机卡也因「无有效套餐」获得加急,须先由产品确认收窄该 Scenario 的措辞,再以独立 Change 扩展触发范围。边界二——数据库连接正常时不会触发该失败方向,普通轮询的并发上限与无限重入队策略不受影响。
|
||||
- **修复方式**:边界一——产品确认后另立 Change;在此之前不改判定点。边界二——不修复(刻意设计);如未来引入可信的「探测能力不可用」信号,可在该信号下单独放行并接受重复调用风险,须重新评审。边界三——若认领接缝改为批量探测则启用;否则按归档后的清理流程删除,不长期保留未使用的方法。
|
||||
- **Owner**:异步任务负责人
|
||||
- **最后验证日期**:2026-09-16
|
||||
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
|
||||
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
|
||||
|
||||
> **AUG26-016 实施口径标注(2026-09-16)**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change,需产品确认后另行实施(依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。
|
||||
|
||||
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
|
||||
|
||||
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
|
||||
|
||||
207
docs/verification/add-priority-polling-queue-verification.md
Normal file
207
docs/verification/add-priority-polling-queue-verification.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# AUG26-016 卡轮询优先队列(add-priority-polling-queue)实施与验证记录
|
||||
|
||||
本文件记录 OpenSpec Change `add-priority-polling-queue` 的实施范围、已实测证据与未验证清单。
|
||||
所有实测均在测试库 `junhong_cmp_test` 与**隔离 Redis DB 15** 上执行;未连接生产、未启动 Worker/API 进程、未调用运营商上游接口。
|
||||
|
||||
## 0. 结论概览
|
||||
|
||||
| 验证项 | 断言计数 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| 9.1 迁移 up/down/up + down 守卫 | 结构断言 4 组 + 探针 6 条 | 通过(含记账 `dirty=true` 的实测结论) |
|
||||
| 9.2–9.7 本机可安全验证部分(A–F + H5 批) | **94 PASS / 0 FAIL** | 通过 |
|
||||
| 接缝执行(认领/跳过/接管/范围校验,非导出接缝经真实 `Handle` 驱动) | **63 PASS / 0 FAIL** | 通过 |
|
||||
| 提示通道(优先键与手动键、FIFO、`RemoveFromAllQueues` 边界、分片队列快照) | **12 PASS / 0 FAIL** | 通过 |
|
||||
| 清理与零残留(PG 六类计数 + Redis 键 + 越界 DB 只读复核) | **20 PASS / 0 FAIL** | 通过 |
|
||||
| 成功路径 `Complete`、真并发、尝试上限第 3 次判定、package handler 接缝、HTTP 层、通道阈值持锁、三类生效触发点生产集成 | — | **未验证**(见第 4 节) |
|
||||
|
||||
## 1. 环境与命令
|
||||
|
||||
- 目标库:`junhong_cmp_test`(`cxd.whcxd.cn:16159`);迁移经 `scripts/migrate.sh`(读取 `.env` 的 `DB_*`)。
|
||||
- 隔离 Redis:`cxd.whcxd.cn:16299` **DB 15**(部署库为 DB 6、本地沙盒为 DB 7,二者对所有写入被显式拒绝)。
|
||||
- 环境装载:`set -a; . ./.env; . ./.env.local; set +a`(`DB_*` 只在 `.env`;Redis 参数只在 `.env.local`)。
|
||||
- 构建:`go build -o /tmp/pf ./cmd/priority-fixture-tmp`。
|
||||
- 只读诊断:dbhub MCP `mcp__postgres_execute_sql_main`(仅 SELECT);Redis 只读用 `redis-cli -n <db> EXISTS|DBSIZE|SCAN`。
|
||||
- 临时工具子命令:`setup|a-store|b-consumer|c-trigger|d-read|e-enqueue|f-audit|g-h5|seam-verify|hint-verify|purge-stale-audits|cleanup|count|insert-fixture|delete-fixture|count-rows|index-probe`。
|
||||
该工具带有安全栏:`DB_NAME` 必须为 `junhong_cmp_test`,所有自建行带 `AUG26016` 标记,清理只按本批 ID 集合或标记删除。
|
||||
- 日志:每场景独立带时间戳文件 `run<N>-<步骤>-<TS>.log`(不覆盖);本文只摘录关键原始行。
|
||||
|
||||
## 2. 已验证清单(含点时间与期望/实际)
|
||||
|
||||
### 2.1 任务 9.1:迁移 up / down / up 与 down 守卫(2026-09-16)
|
||||
|
||||
| 步骤 | 期望 | 实际(原文摘录) |
|
||||
| --- | --- | --- |
|
||||
| 迁移前版本 | 227 | `当前迁移版本: 227` |
|
||||
| `./scripts/migrate.sh up` | 应用到 228,exit=0 | `228/u add_polling_priority_queue (344.53875ms)`、`✓ 迁移操作完成` |
|
||||
| 结构 | 23 列全注释、5 索引(含活动项部分唯一索引)、9 CHECK、0 外键 | `column_count=23`、`commented_columns=23`、`index_count=5`、`c:9`+`p:1`、`fk_count=0` |
|
||||
| 活动项索引谓词 | 仅 `deleted_at IS NULL AND status IN ('pending','processing')` 占键位 | `CREATE UNIQUE INDEX uq_polling_priority_item_active ... WHERE ((deleted_at IS NULL) AND ((status)::text = ANY ((ARRAY['pending','processing'])::text[])))` |
|
||||
| 造 1 条活动 fixture 后 `./scripts/migrate.sh down` | 守卫拒绝回滚且**结构零损伤** | `pq: 存在卡轮询优先队列活动项,拒绝回滚以避免丢失在途加急事实与认领租约状态`、`down_exit=1`;随后 `table_regclass=tb_polling_priority_item`、`row_count=1`、`column_count=23`、`index_count=5` |
|
||||
| 守卫后的记账标记 | —(实测为工具行为) | `{"version":"227","dirty":true}` |
|
||||
| 记账修复 | `force <真实版本>` 只改一行、不动 DDL | `./scripts/migrate.sh force 228` → `{"version":"228","dirty":false}` |
|
||||
| 清 fixture 后 `down` | 表/索引/relation 全部消失,exit=0 | `228/d add_polling_priority_queue (221.332417ms)`;`{"table_regclass":null,"index_count":"0","relation_leftovers":"0"}` |
|
||||
| 再次 `up` | 回到 228 且结构完整 | `{"version":"228","dirty":false,"column_count":"23","index_count":"5","check_count":"9","fk_count":"0"}` |
|
||||
|
||||
**实测结论(`dirty=true` 的口径)**:golang-migrate 在运行某个迁移文件之前先写入目标版本并标 `dirty=true`,成功后写回 `dirty=false`;`down 228` 的目标版本是 227,守卫 `RAISE EXCEPTION` 后库被留在 `227, dirty=true`。
|
||||
这是**迁移工具的记账行为,不是本 Change 的实现缺陷**;恢复方式是 `./scripts/migrate.sh force <当前真实版本>`(本例 228),该命令只修一行记账、不触发 DDL 或数据变更。验证 down 守卫的正确预期应写成「守卫拒绝 + 结构零损伤 + 由维护者 `force` 回当前真实版本并确认 `dirty=false`」。
|
||||
|
||||
### 2.2 部分唯一索引语义与约束探针(2026-09-16)
|
||||
|
||||
```
|
||||
[成功] P1 插入活动行 A(id=1 card_id=6012 task_type=carddata status=pending)
|
||||
[报错] P2 重复活动行(同卡同任务类型) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P3 软删 A 后插入活动行 B(id=3)→ 软删行不占用活动项键位
|
||||
[报错] P4 插入活动行 C(B 仍活动) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P5 把 B 转终态(completed/success)后插入活动行 D(id=5)→ 终态行不占用活动项键位
|
||||
[报错] P6 终态与结果一致性(completed 但 result 为空) → SQLSTATE=23514 constraint=ck_polling_priority_item_terminal_result
|
||||
索引与约束探针全部符合预期 (exit=0)
|
||||
```
|
||||
|
||||
### 2.3 A–F 批次(Store / 消费者 / 触发分类 / 读侧 / 人工入队 / 审计)——94 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 09:46:49(+08),日志 `run5-<场景>-20260917T094649.log`(另有一轮同结果运行 `run-e2e-*-20260917T092447.log`)。
|
||||
|
||||
| 场景 | 断言 | 期望 → 实际(摘录) |
|
||||
| --- | --- | --- |
|
||||
| A Store 生命周期 | 20 PASS | `A1 首次认领命中…→ status=processing claimed_at=2026-09-17 09:24:49+08`;`A2 超租约(120s>90s)允许接管 → taken=true`;`A2 租约内(10s<90s)不允许接管 → taken=false`;`A3 两次 IncrementAttempt 后 attempt_count → 期望=2 实际=2`;`A3 MarkFailed(未累加尝试次数,等同 FailFinal 路径)→ attempt_count=0`;`A3 尝试上限路径 → attempt_count=1`;`A4 分页归一化 Page=0/PageSize=999 → total=29 returned=20` |
|
||||
| B 消费者 | 15 PASS | `B5 设备载体在事务内冻结的绑定卡数 → 3`;`B5 活动项行数(3 卡 × 4 任务类型) → 12`;`B6 重复投递只合并:行数不变、触发次数+1、最近触发时间刷新、触发类型集合去重`;`B7 自动(no_valid_package)+ 人工(manual_trigger)合并为同一行`;`B8 已终态后重复追加同一 event_id:outbox 仍只 1 行`;`B9 同一 event_id 重投不产生重复审计行(EventID 去重) → 1` |
|
||||
| C 触发分类与前置条件 | 13 PASS | `C10 → renewal_activated` / `purchase_activated`;`C11 存在 status=0 → HasPendingPackageUsage=true`;`C12 观测抑制为真 → 不写事件(0 行)`;`C12 无有效套餐 event_id ≤ 48 且主体与载体可区分 → prio:nvp:d:990016999:… len=31` |
|
||||
| D 读侧权限与范围 | 12 PASS | `D13 超管/平台 → total=18/18`;`D13 代理 → total=16`(平台卡与范围外卡各 1 行不可见);`D13 企业 → code=1005`;`D14 详情越权 vs 不存在 → 同一码与文案` |
|
||||
| E 人工入队用例 | 14 PASS | `E15 一次入队覆盖全部纳入类型 → created=4 merged=0`;`E16 24h 内再次入队 → created=0 merged=4`;`E17 原因缺失/超长 → CodeInvalidParam`;`E17 拒绝审计 → 5 条 manual_denied`;`E17 被拒绝的请求不产生优先项变更 → 仍 4 行活动项` |
|
||||
| F 审计来源匹配 | 6 PASS | `F polling_priority.enqueue/claim/manual_denied 注册 → primary=polling_priority_item,origins 含 account/admin_api 与 system_task/worker`;`F 消费者审计 Actor=system_task / Source=worker` |
|
||||
| count(残留计数) | 10 PASS | 卡/设备/绑定/套餐使用/优先项/审计/outbox/两类孤儿审计资源 **均 = 0**,且 `本批全部自建行已清理干净` |
|
||||
|
||||
### 2.4 H5 加固:事件通道拒绝 `manual_trigger`(2026-09-17 09:24:47)
|
||||
|
||||
`internal/infrastructure/prioritypolling/event.go` 的 `AppendPriorityRequested` 在资源类型校验后显式拒绝
|
||||
`trigger_type='manual_trigger'`(人工入队由 `internal/service/polling/priority_enqueue_service.go` 直写事实表,不经事件通道)。
|
||||
|
||||
```
|
||||
[PASS] H5-1 经事件通道投递 manual_trigger 返回参数错误 → code=1001 err=优先轮询事件不接受人工入队触发类型 manual_trigger,人工入队请调用人工入队用例
|
||||
[PASS] H5-3 该 event_id 在 tb_outbox_event 新增 0 行 → 期望=0 实际=0
|
||||
[PASS] H5-4 tb_outbox_event 总行数不变(拒绝发生在写入前) → 期望=19453 实际=19453
|
||||
```
|
||||
|
||||
### 2.5 接缝执行(seam-verify)——63 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15(打印 `db=15`),**未执行 `FlushDB`**。
|
||||
驱动方式:不启动 Worker/Asynq/HTTP,用 `task.NewPollingBase` + `asynq.NewTask` 直接调用
|
||||
`PollingRealnameHandler` / `PollingCarddataHandler` / `PollingCardStatusHandler` 的真实 `Handle`;
|
||||
`integration` 注入真实仓库(非 nil),使「零上游」由真实计数断言证明。
|
||||
每个任务类型(realname / carddata / card_status)依次断言 (a)–(e):
|
||||
|
||||
| 断言组 | 期望 → 实际(摘录) |
|
||||
| --- | --- |
|
||||
| 计数器真对照 | `写入 1 行 tb_integration_log 后按夹具卡统计 +1 → 期望=1 实际=1`;`对照行已删除:计数回到原值 → 期望=0 实际=0` |
|
||||
| (a) 无活动项 = 基线等价 | `Handle 正常返回 → err=<nil>`;`不产生优先项行 → 0`;`零上游调用 → 0`;`仍按既有路径重入队(分片 ZSET 命中) → key=polling:shard:0:queue:polling:realname member=11696` |
|
||||
| (b) pending = 本次领取 | `claim 审计 +1 → delta=1`;`status=failed claimed_at=2026-09-17 10:16:41+08 attempt=0 reason="流量查询能力未配置"`;`fail+dequeue 各 +1`;`零上游调用 → 0` |
|
||||
| (c) 租约内 = 跳过且零上游 | `状态/认领时间/尝试次数/失败原因全部不变 → processing,claimed_at 前后相同`;`claim=0 fail=0 dequeue=0`;`零上游调用 → 0`;`重新入队(ZSET 命中)` |
|
||||
| (d) 超租约 = 接管 | `claimed_at=2026-09-17 10:16:42+08`(距运行 <30s,已刷新);`claim delta=1`;`零上游调用 → 0` |
|
||||
| (e) 卡不在轮询范围 | `status=failed reason="卡已不在轮询范围内" attempt=0`;`零上游调用 → 0` |
|
||||
|
||||
收尾(同一进程内):
|
||||
|
||||
```
|
||||
[清理] 隔离 Redis 已删除本套件使用的 12 个键(未执行 FlushDB)
|
||||
[清理] 审计事实(按本批 ID 集合精确删除):事件=27,资源行=27
|
||||
[清理] 本功能动作码审计保留条数(非本批写入,未删除):0
|
||||
[清理] 优先项=0;Outbox(prio:)=0;套餐使用=0;绑定=3;卡=6;设备=1
|
||||
[PASS] 清理后计数 … 应为 0(9 条)+ 本批全部自建行已清理干净
|
||||
结果:全部断言通过
|
||||
```
|
||||
|
||||
### 2.6 提示通道(hint-verify)——12 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15、未 `FlushDB`、全程前后 `polling:shard:*` 快照 `{}`。
|
||||
|
||||
```
|
||||
[PASS] ① EnqueuePriority 只写优先提示键(LLEN) → 期望=3 实际=3
|
||||
[PASS] ① 手动触发键未被 EnqueuePriority 触碰(LLEN) → 期望=0 实际=0
|
||||
[PASS] ① EnqueueManual 只写手动触发键(LLEN) → 期望=1 实际=1
|
||||
[PASS] ① 优先提示键未被 EnqueueManual 触碰(LLEN) → 期望=3 实际=3
|
||||
[PASS] ② RPush 3 元素后 LPopCount 弹出顺序为先进先出 → 期望=[990016101 990016102 990016103] 实际=[990016101 990016102 990016103]
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理优先提示键(LLEN 保持 1)
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理手动触发键(LLEN 保持 1)
|
||||
[PASS] ④ 本子命令前后 polling:shard:* 键集合与成员完全一致(diff 为空) → before={} after={}
|
||||
[PASS] 收尾 删除本子命令使用的键数 → 2;两个键 EXISTS=0(未残留)
|
||||
```
|
||||
|
||||
### 2.7 清理与零残留(PG + Redis 只读复核,2026-09-17 10:17 起)
|
||||
|
||||
| 指标 | 运行前 | 运行后 |
|
||||
| --- | --- | --- |
|
||||
| `tb_polling_priority_item` | 0 | **0** |
|
||||
| 标记夹具卡 / 设备(`AUG26016%`) | 0 / 0 | **0 / 0** |
|
||||
| `polling_priority.%` 审计 | 0 | **0** |
|
||||
| `prio:` outbox | 0 | **0** |
|
||||
| 孤儿审计资源(`polling_priority_item` / `iot_card+polling_card`) | 0 / 0 | **0 / 0** |
|
||||
| `schema_migrations` | 228 / dirty=false | **228 / dirty=false** |
|
||||
| Redis DB 15 `polling:*` 键数 | 0 | **0** |
|
||||
| Redis DB 15 本套件 12 键 `EXISTS` | — | **0** |
|
||||
| Redis DB 6 夹具卡专属键(`polling:card:11696`、`traffic:sync:lock:card:11696`) | — | **0 / 0**(无本 Change 痕迹) |
|
||||
|
||||
库内全量孤儿审计资源另有 88 行,分组为 `employee_collection_bill=37`、`employee_collection_application=16`、
|
||||
`employee_collection_application_attempt=16`、`package_traffic_alert=16`、`package_traffic_alert_rule=3`——
|
||||
**全部属于其它能力**,本 Change 两类均为 0。
|
||||
|
||||
### 2.8 一次真实失败与其修复(如实记录)
|
||||
|
||||
首次接缝运行(2026-09-17 10:11:34,`run6-seam-verify-20260917T101134.log`)接缝断言 61/61 通过,
|
||||
但收尾残留检查 2 条 FAIL:`polling_priority.*` 审计残留 27 条(claim 9 / fail 9 / dequeue 9,
|
||||
`created_at` 2026-09-17T02:11:37Z–02:11:49Z)。
|
||||
根因是**验证工具自身的记账缺陷**:场景逐个任务类型收尾会删除优先项行,而审计清理依赖按优先项 ID 反查,删行后即漏删。
|
||||
修复(仅临时工具,未放宽任何断言):运行期采集优先项 ID 并在清理时作为额外 ID 集合传入;另加带时间窗护栏的
|
||||
`purge-stale-audits` 兜底子命令(必须显式给 `AUG26016_PURGE_SINCE`,窗口外的行一律拒绝删除并列出)。
|
||||
修复后重跑 63/63 全绿,27 条残留按兜底路径精确删除(`事件=27,资源行=27,删除后残留=0`)。
|
||||
|
||||
## 3. 工程门禁(收尾复跑,脚手架删除后;2026-09-17 10:26)
|
||||
|
||||
| 命令 | 原始结果 | 退出码 |
|
||||
| --- | --- | --- |
|
||||
| `gofmt -l .` | 仅列出 6 个**历史未格式化**文件:`internal/model/dto/package_dto.go`、`internal/model/order_package_invalidate_task.go`、`internal/model/personal_customer_device.go`、`internal/model/personal_customer_iccid.go`、`internal/model/personal_customer_phone.go`、`internal/query/h5popup/query.go`;本 Change 改动文件(`internal/infrastructure/prioritypolling/event.go`、`internal/task/*`、`internal/polling/*`、`internal/service/*`、`pkg/constants/*`、`migrations/000228_*` 等)**均未出现** → 无新增未格式化文件 | 0 |
|
||||
| `go build ./cmd/api ./cmd/worker` | 无输出(仅 Go 模块缓存 stat 写入诊断,不影响构建) | 0 |
|
||||
| `go vet ./...` | 无诊断输出 | 0 |
|
||||
| `go run cmd/gendocs/main.go`(连续两次) | 两次均输出「成功在以下位置生成 OpenAPI 文档」;`md5(docs/admin-openapi.yaml)` = `696dba47834f93faacba93c745353fdd`(两次一致;该文件被 `.gitignore` 忽略,是本地生成产物) | 0 / 0 |
|
||||
| `openspec validate add-priority-polling-queue --strict` | `Change 'add-priority-polling-queue' is valid` | 0 |
|
||||
| `openspec doctor --json` | `"root": {… "healthy": true, "status": []}`、`"status": []` | 0 |
|
||||
| `./scripts/context-health.sh` | `Context 健康检查通过` | 0 |
|
||||
|
||||
自动化测试按项目决策为 N/A:未创建任何 `*_test.go`(`context-health.sh` 亦校验仓库无 `*_test.go`)。
|
||||
|
||||
## 4. 未验证清单(明确未覆盖,不得据本文推断)
|
||||
|
||||
| 项 | 未验证原因 |
|
||||
| --- | --- |
|
||||
| 优先项成功路径 `Complete`(`processing → completed`) | 需真实上游调用成功;本轮所有执行都停在「能力未配置」失败分支,未制造成功上游响应 |
|
||||
| 真并发下的认领互斥 | 无并发执行环境;本轮以「租约内执行中 → 跳过且零上游」的单线程等价路径覆盖,未做同卡同类型真并发竞态 |
|
||||
| 尝试上限第 3 次判定 | 需稳定失败的真实上游;本轮止于 `FailFinal`(尝试次数不累加),未覆盖 `FailRetryable` 连续 3 次的收敛 |
|
||||
| `package` handler 的接缝 | 其 `Handle` 依赖 `t.ResultWriter().TaskID()`,本地 `asynq.Task` 会 panic,未驱动 |
|
||||
| HTTP 层权限矩阵 | 未启动 API;权限判定仅覆盖服务层与查询层(读侧范围、越权=不存在同一响应),未取得真实状态码/`msg` |
|
||||
| 9.7 通道阈值持锁边界 | 需 `tb_carrier_traffic_threshold_lock` 活动锁与真实停复机评估路径;本轮未造锁、未驱动停复机 |
|
||||
| 三类生效触发点的生产集成 | 新购/续购/加油包/排队顺延的触发分类与事件构造已单测(C 场景),但未经真实订单/支付/激活链路端到端验证 |
|
||||
| `queue_activated` / `addon_activated` 正向落库 `trigger_type` | C 场景只对 `purchase_activated`/`renewal_activated` 做了正向落库;排队顺延与加油包仅验证了事件构造 |
|
||||
| 调度器排空顺序与分片背压 | 需 Worker + 既有部署环境;本文只覆盖提示通道的生产/消费语义 |
|
||||
| 迁移 down 守卫的自动化 | 未引入自动化测试(项目决策 N/A),仅为手工实测记录 |
|
||||
|
||||
## 5. 边界声明(避免误读)
|
||||
|
||||
1. **`tb_integration_log` 全表计数会被在跑的测试部署推高**:观测窗口内该表由 5,905,167 增至 5,905,619(+452),
|
||||
来源是并发运行的既有测试部署(DB 6 心跳/并发/27 卡在跑),**不是本套件产生**;本套件在该表只插入并删除 1 行对照行(复查 = 0)。
|
||||
2. **「零上游调用」的判据是夹具卡维度**:`tb_integration_log WHERE resource_type='iot_card' AND resource_id='<夹具卡ID>'`,
|
||||
每条场景运行前后均为 0(15 条独立断言),并由 `counterControl` 对照证明该计数口径能发现写入。
|
||||
这**不等于**「整库无上游调用」——测试部署的正常上游调用一直在发生。
|
||||
3. **隔离 Redis DB 15 并非本套件独占**:其中仍有他人 7 个键(`auth:refresh:…`、`auth:user:127:tokens`、
|
||||
`auth:token:482e6fdc-…`、`asynq:queues`、`asynq:{export:*}×3`)。因此验证工具**默认不执行 `FlushDB`**,
|
||||
只按清单删除自己使用的键;DB 0/6/7 在所有写入路径上被显式拒绝(DB 6/7 仅做过 `EXISTS`/`DBSIZE`/`SCAN` 只读探测)。
|
||||
4. **成功路径与并发结论不得外推**:本轮所有执行都走「能力未配置」分支,因此「零上游」「状态收敛」的结论仅适用于该分支;
|
||||
成功路径 `Complete`、真并发互斥、尝试上限收敛见第 4 节未验证清单。
|
||||
|
||||
## 6. 收尾处置
|
||||
|
||||
- 验证脚手架 `cmd/priority-fixture-tmp/`(main.go / verify.go / seam.go / hint.go / purge.go)为一次性工具,
|
||||
验证完成后**整目录删除**(`rm -rf cmd/priority-fixture-tmp`,DELETED_EXIT=0)。
|
||||
删除确认:`ls cmd/` 只剩 `api/audit-coverage/audit-retention-simulate/foundation-check/gendocs/migration-finalize/worker`;
|
||||
`find . -name 'priority-fixture-tmp*' -not -path './.git/*'` 无输出(仓库根目录与全仓均无同名二进制);
|
||||
`git status --porcelain | grep -i fixture` 无输出(工作区已不含该目录)。
|
||||
- 测试库 `tb_polling_priority_item` 与本 Change 动作码审计、`prio:` outbox、标记夹具均已清零(见 2.7);
|
||||
本文引用的逐场景日志保存在 `/tmp/aug26-016/`(会话级临时目录,非仓库产物)。
|
||||
- 未提交、未 push。2026-09-17 归档:Change 目录移至 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/`(含 `.openspec.yaml`),delta spec 已同步进主 Specs(新增 `openspec/specs/priority-polling-queue/spec.md`,并在 `openspec/specs/polling-operations/spec.md` 追加「优先轮询项与普通轮询的单次执行互斥」与三条优先队列路由);`tasks.md` 的 9.1–9.7 按用户口径统一勾选,第 4 节未验证清单不因勾选而改变。
|
||||
@@ -3337,6 +3337,17 @@
|
||||
"requirements": [],
|
||||
"classification": "route_index_or_infrastructure"
|
||||
},
|
||||
{
|
||||
"entry_type": "async",
|
||||
"entry": "constants.OutboxEventTypePollingPriorityRequested",
|
||||
"capability": "infrastructure",
|
||||
"requirements": [
|
||||
"priority-polling-queue::优先轮询触发场景与入队事实",
|
||||
"priority-polling-queue::入队执行对象为资产当前在用卡",
|
||||
"priority-polling-queue::同卡同任务类型的唯一活动项与合并"
|
||||
],
|
||||
"classification": "route_index_or_infrastructure"
|
||||
},
|
||||
{
|
||||
"entry_type": "async",
|
||||
"entry": "constants.TaskTypeAgentRechargeRecovery",
|
||||
@@ -3558,7 +3569,12 @@
|
||||
"operations-audit::轮询审计事实保留边界",
|
||||
"polling-load-control::轮询全局并发与背压",
|
||||
"polling-load-control::正常无变化观测的低写入处理",
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯"
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯",
|
||||
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
|
||||
"priority-polling-queue::优先调度与执行提示通道",
|
||||
"priority-polling-queue::优先项认领、租约与执行前校验",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::与运营商通道阈值停机的边界"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3572,7 +3588,12 @@
|
||||
"operations-audit::轮询审计事实保留边界",
|
||||
"polling-load-control::轮询全局并发与背压",
|
||||
"polling-load-control::正常无变化观测的低写入处理",
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯"
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯",
|
||||
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
|
||||
"priority-polling-queue::优先调度与执行提示通道",
|
||||
"priority-polling-queue::优先项认领、租约与执行前校验",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::与运营商通道阈值停机的边界"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3583,7 +3604,12 @@
|
||||
"requirements": [
|
||||
"polling-operations::手工轮询任务状态",
|
||||
"polling-load-control::轮询全局并发与背压",
|
||||
"polling-operations::停复机遵循实际生效实名策略"
|
||||
"polling-operations::停复机遵循实际生效实名策略",
|
||||
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
|
||||
"priority-polling-queue::优先调度与执行提示通道",
|
||||
"priority-polling-queue::优先项认领、租约与执行前校验",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::与运营商通道阈值停机的边界"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -3607,7 +3633,12 @@
|
||||
"operations-audit::轮询审计事实保留边界",
|
||||
"polling-load-control::轮询全局并发与背压",
|
||||
"polling-load-control::正常无变化观测的低写入处理",
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯"
|
||||
"polling-load-control::业务变化与轮询异常仍可追溯",
|
||||
"polling-operations::优先轮询项与普通轮询的单次执行互斥",
|
||||
"priority-polling-queue::优先调度与执行提示通道",
|
||||
"priority-polling-queue::优先项认领、租约与执行前校验",
|
||||
"priority-polling-queue::优先项尝试次数与出队",
|
||||
"priority-polling-queue::与运营商通道阈值停机的边界"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
@@ -4375,5 +4406,36 @@
|
||||
"carrier-channel-traffic-threshold::通道阈值停机与周期恢复"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "POST /api/admin/polling-priority-items",
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::人工优先入队不受人工触发防滥用配额约束",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "GET /api/admin/polling-priority-items",
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"classification": "behavior"
|
||||
},
|
||||
{
|
||||
"entry_type": "http",
|
||||
"entry": "GET /api/admin/polling-priority-items/{id}",
|
||||
"capability": "polling-operations",
|
||||
"requirements": [
|
||||
"identity-access::数据范围拒绝",
|
||||
"priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"classification": "behavior"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -4192,5 +4192,311 @@
|
||||
"验证口径:停复机执行的运营商回执以本机 stub 端点驱动,真实运营商成功回执路径未端到端验证"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "优先轮询触发场景与入队事实",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"/api/admin/polling-priority-items",
|
||||
"constants.OutboxEventTypePollingPriorityRequested"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/service/package/priority.go:AppendActivatedPriorityRequested / AppendNoValidPackagePriorityRequested",
|
||||
"internal/infrastructure/prioritypolling/event.go:PriorityEventWriter.AppendPriorityRequested",
|
||||
"internal/infrastructure/prioritypolling/event.go:PriorityRequestedConsumer.Consume"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/service/order/service.go:AppendActivatedPriorityRequested(...)(主套餐购买生效分支)",
|
||||
"internal/service/package/activation_service.go:AppendActivatedPriorityRequested(...)(排队顺延生效)",
|
||||
"internal/task/auto_purchase.go:AppendActivatedPriorityRequested(...)",
|
||||
"internal/service/iot_card/stop_resume_service.go:AppendNoValidPackagePriorityRequested(...)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"pkg/constants/polling.go:PollingPriorityTriggerPurchaseActivated/RenewalActivated/QueueActivated/AddonActivated/NoValidPackage/Manual",
|
||||
"internal/model/polling.go:PollingPriorityItem.TriggerType/TriggerTypes/TriggerCount"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"migrations/000228_add_polling_priority_queue.up.sql",
|
||||
"internal/store/postgres/polling_priority_item_store.go:InsertOrMerge"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"pkg/constants/polling.go\", \"PollingPriorityTriggerPurchaseActivated = \\\"purchase_activated\\\"\"), (\"pkg/constants/polling.go\", \"PollingPriorityTriggerRenewalActivated = \\\"renewal_activated\\\"\"), (\"pkg/constants/polling.go\", \"PollingPriorityTriggerQueueActivated = \\\"queue_activated\\\"\"), (\"pkg/constants/polling.go\", \"PollingPriorityTriggerAddonActivated = \\\"addon_activated\\\"\"), (\"pkg/constants/polling.go\", \"PollingPriorityTriggerNoValidPackage = \\\"no_valid_package\\\"\"), (\"pkg/constants/polling.go\", \"PollingPriorityTriggerManual = \\\"manual_trigger\\\"\"), (\"internal/service/package/priority.go\", \"func ResolveActivationTriggerType(\"), (\"internal/service/package/priority.go\", \"func HasEarlierPackageUsage(\"), (\"internal/service/order/service.go\", \"AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents\"), (\"internal/service/package/activation_service.go\", \"AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents\"), (\"internal/task/auto_purchase.go\", \"packagepkg.AppendActivatedPriorityRequested(\"), (\"internal/service/iot_card/stop_resume_service.go\", \"packagepkg.AppendNoValidPackagePriorityRequested(\"), (\"internal/infrastructure/prioritypolling/event.go\", \"func (w *PriorityEventWriter) AppendPriorityRequested(\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::优先轮询触发场景与入队事实\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::优先轮询触发场景与入队事实"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "入队执行对象为资产当前在用卡",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.OutboxEventTypePollingPriorityRequested"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/infrastructure/prioritypolling/event.go:PriorityRequestedConsumer.Consume(逐卡 × 逐任务类型建项)"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/service/package/priority.go:filterPackageUsageByCarrier(载体口径与卡的既有有效套餐判定一致)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/application/prioritypolling/event.go:PriorityRequestedEvent.ResourceIDs(触发事务内冻结的绑定卡快照)",
|
||||
"internal/model/polling.go:PollingPriorityItem.CardID/ShopIDSnapshot"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"migrations/000228_add_polling_priority_queue.up.sql:tb_polling_priority_item.card_id 列注释「执行对象恒为卡,多卡资产逐卡建项」"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/application/prioritypolling/event.go\", \"ResourceIDs\"), (\"internal/infrastructure/prioritypolling/event.go\", \"cardIDs = event.ResourceIDs\"), (\"internal/infrastructure/prioritypolling/event.go\", \"taskTypes := constants.PollingPriorityTaskTypes()\"), (\"pkg/constants/polling.go\", \"func PollingPriorityTaskTypes()\"), (\"internal/infrastructure/prioritypolling/event.go\", \"loadCardShopSnapshots\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) ListActiveByCards(\"), (\"internal/infrastructure/prioritypolling/event.go\", \"!CurrentSlotNo\"), (\"internal/application/prioritypolling/event.go\", \"!CurrentSlotNo\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::入队执行对象为资产当前在用卡\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::入队执行对象为资产当前在用卡"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "同卡同任务类型的唯一活动项与合并",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.OutboxEventTypePollingPriorityRequested"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/infrastructure/prioritypolling/event.go:PriorityRequestedConsumer.Consume(重复投递只合并,不新建行)"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/service/polling/priority_enqueue_service.go:Enqueue(人工侧同一 InsertOrMerge 语义)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/model/polling.go:PollingPriorityItem.Status/TriggerTypes/TriggerCount/LastTriggeredAt"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"migrations/000228_add_polling_priority_queue.up.sql:CREATE UNIQUE INDEX uq_polling_priority_item_active ... WHERE deleted_at IS NULL AND status IN ('pending','processing')",
|
||||
"internal/store/postgres/polling_priority_item_store.go:InsertOrMerge/mergeActive/isPollingPriorityActiveConflict"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"migrations/000228_add_polling_priority_queue.up.sql\", \"CREATE UNIQUE INDEX uq_polling_priority_item_active\"), (\"migrations/000228_add_polling_priority_queue.up.sql\", \"活动项唯一键必须是「部分唯一索引」\"), (\"migrations/000228_add_polling_priority_queue.up.sql\", \"WHERE deleted_at IS NULL AND status IN (\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) InsertOrMerge(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) mergeActive(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"pgErr.ConstraintName == pollingPriorityActiveConstraint\"), (\"internal/infrastructure/prioritypolling/event.go\", \"created, insertErr := store.InsertOrMerge(ctx, item)\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::同卡同任务类型的唯一活动项与合并\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::同卡同任务类型的唯一活动项与合并"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "优先调度与执行提示通道",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.TaskTypePollingRealname",
|
||||
"constants.TaskTypePollingCarddata",
|
||||
"constants.TaskTypePollingCardStatus",
|
||||
"constants.TaskTypePollingPackage"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/polling/queue_manager.go:PollingQueueManager.EnqueuePriority",
|
||||
"cmd/worker/main.go:registerPriorityPollingOutboxConsumer"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/application/prioritypolling/event.go:PromptPublisher.EnqueuePriority(提示不是权威,失败只退化为延迟一个周期)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"pkg/constants/redis.go:RedisPollingPriorityQueueKey(每任务类型独立键,不复用手动触发队列键)"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/polling/scheduler.go:Scheduler.processPriorityQueue(同一调度周期先排空优先提示、再排空手动触发队列,且不参与分片背压判断)"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/polling/queue_manager.go\", \"func (m *PollingQueueManager) EnqueuePriority(\"), (\"pkg/constants/redis.go\", \"func RedisPollingPriorityQueueKey(taskType string) string\"), (\"internal/polling/scheduler.go\", \"func (s *Scheduler) processShardSchedule(ctx context.Context)\"), (\"internal/polling/scheduler.go\", \"func (s *Scheduler) processPriorityQueue(\"), (\"internal/polling/scheduler.go\", \"s.processPriorityQueue(ctx, taskType, s.cfg.MaxManualBatchSize)\"), (\"internal/application/prioritypolling/event.go\", \"type PromptPublisher interface\"), (\"cmd/worker/main.go\", \"registerPriorityPollingOutboxConsumer\"), (\"pkg/constants/polling.go\", \"!TaskTypePollingPriority\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::优先调度与执行提示通道\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::优先调度与执行提示通道"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "优先项认领、租约与执行前校验",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.TaskTypePollingRealname",
|
||||
"constants.TaskTypePollingCarddata",
|
||||
"constants.TaskTypePollingCardStatus",
|
||||
"constants.TaskTypePollingPackage"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/task/polling_priority_claim.go:PollingBase.beginPollingPriority(接缝入口)",
|
||||
"internal/task/polling_realname_handler.go / polling_carddata_handler.go / polling_cardstatus_handler.go / polling_package_handler.go:Handle 调用 beginPollingPriority"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/task/polling_priority_claim.go:pollingPriorityRound + priorityInPollingScope(执行前校验卡自身与绑定设备的轮询开关)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/model/polling.go:PollingPriorityItem.Status/ClaimedAt",
|
||||
"pkg/constants/polling.go:PollingPriorityClaimLease = 90"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/store/postgres/polling_priority_item_store.go:ClaimPending/TakeOverExpiredClaim"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"pkg/constants/polling.go\", \"const PollingPriorityClaimLease = 90\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) ClaimPending(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) TakeOverExpiredClaim(\"), (\"internal/task/polling_priority_claim.go\", \"func (b *PollingBase) beginPollingPriority(\"), (\"internal/task/polling_priority_claim.go\", \"func (b *PollingBase) priorityInPollingScope(\"), (\"internal/task/polling_realname_handler.go\", \"h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingRealname)\"), (\"internal/task/polling_carddata_handler.go\", \"h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCarddata)\"), (\"internal/task/polling_cardstatus_handler.go\", \"h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCardStatus)\"), (\"internal/task/polling_package_handler.go\", \"h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingPackage)\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::优先项认领、租约与执行前校验\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::优先项认领、租约与执行前校验"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "优先项尝试次数与出队",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.TaskTypePollingRealname",
|
||||
"constants.TaskTypePollingCarddata",
|
||||
"constants.TaskTypePollingCardStatus",
|
||||
"constants.TaskTypePollingPackage"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/task/polling_priority_claim.go:pollingPriorityRound.MarkExecution/FailRetryable/FailFinal/Complete"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/task/polling_priority_claim.go:pollingPriorityRound.FailRetryable(与 PollingPriorityMaxAttempts 比较后决定回活动态或失败出队)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/model/polling.go:PollingPriorityItem.AttemptCount/FailureReason/Status/Result",
|
||||
"pkg/constants/polling.go:PollingPriorityMaxAttempts = 3"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/store/postgres/polling_priority_item_store.go:IncrementAttempt/ReturnToPending/MarkFailed/MarkCompleted"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"pkg/constants/polling.go\", \"const PollingPriorityMaxAttempts = 3\"), (\"internal/task/polling_priority_claim.go\", \"if r.attempts < constants.PollingPriorityMaxAttempts {\"), (\"internal/task/polling_priority_claim.go\", \"func (r *pollingPriorityRound) FailRetryable(\"), (\"internal/task/polling_priority_claim.go\", \"func (r *pollingPriorityRound) FailFinal(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) IncrementAttempt(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) ReturnToPending(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) MarkFailed(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) MarkCompleted(\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::优先项尝试次数与出队\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::优先项尝试次数与出队"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "优先轮询事实与查询的可追溯",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"/api/admin/polling-priority-items",
|
||||
"/api/admin/polling-priority-items/{id}"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/handler/admin/polling_priority.go:PriorityPollingHandler.Enqueue/ListPriorityItems/GetPriorityItem",
|
||||
"internal/routes/polling_priority.go:registerPollingPriorityRoutes"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/query/prioritypolling/query.go:Query.List/Detail + priorityPollingShopScope(数据权限下推)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"pkg/constants/audit.go:AuditActionPollingPriority{Enqueued,Claimed,Failed,Retried,Completed,Dequeued,ManualDenied}",
|
||||
"pkg/constants/audit.go:AuditResourcePollingPriorityItem"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/store/postgres/polling_priority_item_store.go:List/GetByIDScoped/applyPollingPriorityShopScope"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"pkg/constants/audit.go\", \"AuditActionPollingPriorityEnqueued = \\\"polling_priority.enqueue\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityClaimed = \\\"polling_priority.claim\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityFailed = \\\"polling_priority.fail\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityRetried = \\\"polling_priority.retry\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityCompleted = \\\"polling_priority.complete\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityDequeued = \\\"polling_priority.dequeue\\\"\"), (\"pkg/constants/audit.go\", \"AuditActionPollingPriorityManualDenied = \\\"polling_priority.manual_denied\\\"\"), (\"internal/query/prioritypolling/query.go\", \"func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest)\"), (\"internal/query/prioritypolling/query.go\", \"func (q *Query) Detail(ctx context.Context, id uint)\"), (\"internal/query/prioritypolling/query.go\", \"func priorityPollingShopScope(ctx context.Context)\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) GetByIDScoped(\"), (\"internal/handler/admin/polling_priority.go\", \"func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error\"), (\"internal/handler/admin/polling_priority.go\", \"func (h *PriorityPollingHandler) ListPriorityItems(c *fiber.Ctx) error\"), (\"internal/handler/admin/polling_priority.go\", \"func (h *PriorityPollingHandler) GetPriorityItem(c *fiber.Ctx) error\"), (\"internal/routes/polling_priority.go\", \"router.Group(\\\"/polling-priority-items\\\")\"), (\"internal/model/dto/polling_priority_dto.go\", \"!PriorityLevel\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::优先轮询事实与查询的可追溯\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::优先轮询事实与查询的可追溯"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "人工优先入队不受人工触发防滥用配额约束",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"/api/admin/polling-priority-items"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/handler/admin/polling_priority.go:PriorityPollingHandler.Enqueue"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/service/polling/priority_enqueue_service.go:PriorityEnqueueService.Enqueue",
|
||||
"internal/service/polling/permission.go:canManagePollingCard(与既有人工触发共用同一权限判定)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/model/polling.go:PollingPriorityItem.ManualReason/ManualOperatorID/ManualOperatorName",
|
||||
"pkg/constants/polling.go:PollingPriorityManualReasonMaxLength = 500"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"migrations/000228_add_polling_priority_queue.up.sql:manual_reason 列注释「人工入队必填,与事实一同保存」"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/service/polling/priority_enqueue_service.go\", \"func (s *PriorityEnqueueService) Enqueue(\"), (\"internal/service/polling/priority_enqueue_service.go\", \"constants.PollingPriorityTriggerManual\"), (\"internal/service/polling/priority_enqueue_service.go\", \"人工优先入队原因不能为空\"), (\"internal/service/polling/priority_enqueue_service.go\", \"!已达到每日触发次数上限\"), (\"internal/service/polling/priority_enqueue_service.go\", \"!RedisPollingManualDedupeKey\"), (\"internal/service/polling/priority_enqueue_service.go\", \"canManagePollingCard(ctx, s.iotCardStore, cardID)\"), (\"internal/service/polling/priority_enqueue_service.go\", \"AuditActionPollingPriorityEnqueued\"), (\"internal/service/polling/permission.go\", \"func canManagePollingCard(\"), (\"internal/service/polling/manual_trigger_service.go\", \"已达到每日触发次数上限\"), (\"internal/service/polling/manual_trigger_service.go\", \"RedisPollingManualDedupeKey\"), (\"pkg/constants/polling.go\", \"const PollingPriorityManualReasonMaxLength = 500\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::人工优先入队不受人工触发防滥用配额约束\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::人工优先入队不受人工触发防滥用配额约束"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "priority-polling-queue",
|
||||
"requirement": "与运营商通道阈值停机的边界",
|
||||
"spec": "openspec/specs/priority-polling-queue/spec.md",
|
||||
"entries": [
|
||||
"constants.TaskTypePollingRealname",
|
||||
"constants.TaskTypePollingCarddata",
|
||||
"constants.TaskTypePollingCardStatus",
|
||||
"constants.TaskTypePollingPackage"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/task/polling_priority_claim.go:PollingBase.priorityInPollingScope(优先轮次仍走既有轮询范围校验)"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/service/iot_card/channel_threshold.go:StopResumeService.channelThresholdBlocked(持锁拒绝复机,优先执行不绕过)",
|
||||
"internal/service/iot_card/stop_resume_service.go:5 处自动/手动/强制复机入口调用 channelThresholdBlocked"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"internal/service/iot_card/stop_resume_service.go:ChannelThresholdLockGuard(可选的通道阈值持锁判定能力)"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/service/iot_card/channel_threshold.go:recordChannelThresholdDenial(保留既有持锁拒绝事实)"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/service/iot_card/channel_threshold.go\", \"func (s *StopResumeService) channelThresholdBlocked(ctx context.Context, card *model.IotCard) (bool, error)\"), (\"internal/service/iot_card/channel_threshold.go\", \"func (s *StopResumeService) recordChannelThresholdDenial(ctx context.Context, card *model.IotCard, actionCode, summary string)\"), (\"internal/service/iot_card/channel_threshold.go\", \"func channelThresholdDenyError() error\"), (\"internal/service/iot_card/stop_resume_service.go\", \"blocked, err := s.channelThresholdBlocked(ctx, card)\"), (\"internal/service/iot_card/stop_resume_service.go\", \"channelThresholdDenyError()\"), (\"internal/task/polling_priority_claim.go\", \"func (b *PollingBase) priorityInPollingScope(\"), (\"internal/task/polling_priority_claim.go\", \"!channelThreshold\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: priority-polling-queue::与运营商通道阈值停机的边界\")'",
|
||||
"literal_output": [
|
||||
"OK: priority-polling-queue::与运营商通道阈值停机的边界"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"capability": "polling-operations",
|
||||
"requirement": "优先轮询项与普通轮询的单次执行互斥",
|
||||
"spec": "openspec/specs/polling-operations/spec.md",
|
||||
"entries": [
|
||||
"constants.TaskTypePollingRealname",
|
||||
"constants.TaskTypePollingCarddata",
|
||||
"constants.TaskTypePollingCardStatus",
|
||||
"constants.TaskTypePollingPackage"
|
||||
],
|
||||
"handler_consumer_job": [
|
||||
"internal/task/polling_priority_claim.go:PollingBase.beginPollingPriority(无活动项返回跳过标志 false,行为完全等价既有路径)",
|
||||
"四个轮询 Handler:beginPollingPriority 返回 skip 时按既有间隔延后,不发起上游调用"
|
||||
],
|
||||
"application_service_query": [
|
||||
"internal/task/polling_priority_claim.go:newPriorityRound(认领成功即作为本轮执行完成,不产生第二次调用)"
|
||||
],
|
||||
"domain_state_amount": [
|
||||
"pkg/constants/polling.go:PollingPriorityStatusPending/Processing(活动集合)"
|
||||
],
|
||||
"store_migration_config": [
|
||||
"internal/store/postgres/polling_priority_item_store.go:FindActive/ClaimPending/TakeOverExpiredClaim"
|
||||
],
|
||||
"verification": {
|
||||
"command": "python3 -c 'from pathlib import Path; checks=[(\"internal/task/polling_priority_claim.go\", \"if b == nil || b.priorityStore == nil\"), (\"internal/task/polling_priority_claim.go\", \"case constants.PollingPriorityStatusPending:\"), (\"internal/task/polling_priority_claim.go\", \"case constants.PollingPriorityStatusProcessing:\"), (\"internal/task/polling_priority_claim.go\", \"now.Before(item.ClaimedAt.Add(time.Duration(constants.PollingPriorityClaimLease)*time.Second))\"), (\"internal/task/polling_priority_claim.go\", \"func (b *PollingBase) newPriorityRound(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) FindActive(\"), (\"internal/store/postgres/polling_priority_item_store.go\", \"func (s *PollingPriorityItemStore) ClaimPending(\")]; miss=[p+\" :: \"+t for p,t in checks if ((t[1:] if t.startswith(\"!\") else t) in Path(p).read_text()) != (not t.startswith(\"!\"))]; print(\"MISSING: \"+\" | \".join(miss)) if miss else print(\"OK: polling-operations::优先轮询项与普通轮询的单次执行互斥\")'",
|
||||
"literal_output": [
|
||||
"OK: polling-operations::优先轮询项与普通轮询的单次执行互斥"
|
||||
],
|
||||
"exit_status": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user