diff --git a/cmd/worker/main.go b/cmd/worker/main.go index adde008b..bb2feb96 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -36,6 +36,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment" + prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" @@ -293,16 +294,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger) pollingIotCardStore := postgres.NewIotCardStore(db, redisClient) - pollingBase := task.NewPollingBase( - redisClient, - pollingQueueMgr, - pollingConfigMgr, - pollingIotCardStore, - appLogger, - cfg.Polling.VerboseLog, - cfg.Worker.PollingTotalMaxConcurrency, - ) - pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient) pollingDeviceStore := postgres.NewDeviceStore(db, redisClient) lifecycleSvc := polling.NewPollingLifecycleService( @@ -313,6 +304,19 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L pollingDeviceStore, appLogger, ) + pollingBase := task.NewPollingBase( + redisClient, + pollingQueueMgr, + pollingConfigMgr, + pollingIotCardStore, + lifecycleSvc, + postgres.NewPollingPriorityItemStore(db), + auditInfra.NewWriter(auditInfra.NewRegistry(), nil), + db, + appLogger, + cfg.Polling.VerboseLog, + cfg.Worker.PollingTotalMaxConcurrency, + ) if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok { stopResumeSvc.SetPollingCallback(lifecycleSvc) } @@ -342,11 +346,29 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L registerNotificationOutboxConsumer(runtime, appLogger) registerWalletOutboxConsumer(runtime, appLogger) registerCardObservationOutboxConsumer(runtime, appLogger) + registerPriorityPollingOutboxConsumer(runtime, appLogger) registerCarrierThresholdOutboxConsumer(runtime, appLogger) registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger) return runtime } +// registerPriorityPollingOutboxConsumer 注册卡轮询优先队列请求事件消费者。 +// 消费者按卡 × 纳入任务类型建立或合并优先项并下发执行提示;重复投递只合并。 +func registerPriorityPollingOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + priorityStore := postgres.NewPollingPriorityItemStore(runtime.db) + consumer := prioritypollingInfra.NewPriorityRequestedConsumer( + runtime.db, + priorityStore, + runtime.pollingQueueMgr, + auditInfra.NewWriter(auditInfra.NewRegistry(), nil), + appLogger, + ) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePollingPriorityRequested, consumer); err != nil { + appLogger.Fatal("注册卡轮询优先队列 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypePollingPriorityRequested), zap.Error(err)) + } +} + // registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。 // 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。 func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { diff --git a/docs/engineering/工程约束.md b/docs/engineering/工程约束.md index 0b5d6fd5..d03717c9 100644 --- a/docs/engineering/工程约束.md +++ b/docs/engineering/工程约束.md @@ -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 +- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测 diff --git a/docs/product/2026-08-迭代-PRD-讨论稿.md b/docs/product/2026-08-迭代-PRD-讨论稿.md index 43b682ad..2e9d1894 100644 --- a/docs/product/2026-08-迭代-PRD-讨论稿.md +++ b/docs/product/2026-08-迭代-PRD-讨论稿.md @@ -196,6 +196,8 @@ 优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。 +> **AUG26-016 实施口径标注(2026-09-16)**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change,需产品确认后另行实施(依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。 + ## 2.16 已确认的导出、时间筛选与代理自充收款方式基线 临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。 diff --git a/docs/verification/add-priority-polling-queue-verification.md b/docs/verification/add-priority-polling-queue-verification.md new file mode 100644 index 00000000..aa17bb1e --- /dev/null +++ b/docs/verification/add-priority-polling-queue-verification.md @@ -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 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-<步骤>-.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=`;`不产生优先项行 → 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 节未验证清单不因勾选而改变。 diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index a9ed83b2..9e600b7a 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -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" } -] \ No newline at end of file +] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 164ec7f8..123112a4 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -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 + } } -] \ No newline at end of file +] diff --git a/internal/application/prioritypolling/event.go b/internal/application/prioritypolling/event.go new file mode 100644 index 00000000..bf0b7000 --- /dev/null +++ b/internal/application/prioritypolling/event.go @@ -0,0 +1,62 @@ +// Package prioritypolling 定义卡轮询优先队列的应用层契约。 +// +// 本包只承载业务事实的形状与执行语义,不依赖 Fiber、GORM、Redis、Asynq 或具体外部 SDK; +// Outbox 写入、消费者展开与执行接缝适配分别位于基础设施与任务层。 +package prioritypolling + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// PriorityRequestedEvent 是业务成功边界可靠请求「为该资源的卡建立优先轮询项」的事实。 +// +// 载荷沿用既有业务观测事件的形状(资源类型 / 资源 ID / 资源 ID 列表),并在业务事务内冻结卡快照: +// 独立卡为自身(ResourceIDs 为空),绑定设备的资产为绑定状态有效的全部在用卡(写入 ResourceIDs)。 +// TriggerType 取值与 pkg/constants.PollingPriorityTrigger* 一致;本轮要逐类型建项的任务类型集合 +// 由代码常量给出(pkg/constants.PollingPriorityTaskTypes),不由事件载荷决定,避免同一决策出现两个权威。 +// 来源订单与来源套餐使用记录只保存 ID,由消费者与读侧显式查询。 +type PriorityRequestedEvent struct { + EventID string `json:"event_id"` + ResourceType string `json:"resource_type"` + ResourceID uint `json:"resource_id"` + ResourceIDs []uint `json:"resource_ids,omitempty"` + TriggerType string `json:"trigger_type"` + SourceOrderID uint `json:"source_order_id,omitempty"` + SourcePackageUsageID uint `json:"source_package_usage_id,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + RequestID string `json:"request_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +// PromptPublisher 把优先执行提示下发到既有轮询提示通道。 +// +// 提示通道不是权威:丢失或缓存服务重启时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行, +// 只退化为延迟一个普通轮询周期;调用方不得以提示的下发成功作为入队事实成立的条件。 +type PromptPublisher interface { + EnqueuePriority(ctx context.Context, cardID uint, taskType string) error +} + +// PriorityEventWriter 在原业务事务中追加优先轮询请求事件。 +type PriorityEventWriter interface { + AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event PriorityRequestedEvent) error +} + +type pollingPriorityTriggerKey struct{} + +// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。 +// +// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、 +// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求, +// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。 +func WithPollingPriorityTrigger(ctx context.Context) context.Context { + return context.WithValue(ctx, pollingPriorityTriggerKey{}, true) +} + +// IsPollingPriorityTrigger 判断当前业务评估是否来自普通套餐轮询。 +func IsPollingPriorityTrigger(ctx context.Context) bool { + value, _ := ctx.Value(pollingPriorityTriggerKey{}).(bool) + return value +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index d6fe0609..e3b9696c 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -36,6 +36,7 @@ import ( notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert" + priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling" shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" @@ -69,6 +70,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis) businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB) carrierStore := postgres.NewCarrierStore(deps.DB) + pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB) rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis) paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis) commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis) @@ -318,6 +320,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert), PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup), PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), + PollingPriority: admin.NewPriorityPollingHandler( + svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate, + ), Asset: func() *admin.AssetHandler { pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) assetPollingSvc := pollingSvcPkg.NewAssetPollingService( diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index abb3975a..59886618 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -24,6 +24,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment" + prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" "github.com/break/junhong_cmp_fiber/internal/polling" @@ -131,6 +132,7 @@ type services struct { PollingAlert *pollingSvc.AlertService PollingCleanup *pollingSvc.CleanupService PollingManualTrigger *pollingSvc.ManualTriggerService + PriorityPolling *pollingSvc.PriorityEnqueueService Asset *assetSvc.Service AssetLifecycle *assetSvc.LifecycleService AssetWallet *assetWalletSvc.Service @@ -185,6 +187,7 @@ func initServices(s *stores, deps *Dependencies) *services { iotCard.SetAccessAudit(auditWriter) cardObservationOutbox := outbox.NewRepository() observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox) + priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox) // 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。 carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger) cardObservationService := cardObservationApp.NewService( @@ -237,6 +240,7 @@ func initServices(s *stores, deps *Dependencies) *services { ) packageActivation.SetLifecycleAudit(auditWriter) packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents) + packageActivation.SetPriorityEventWriter(priorityEvents) stopResumeService := iotCardSvc.NewStopResumeService( deps.Redis, @@ -248,6 +252,7 @@ func initServices(s *stores, deps *Dependencies) *services { ) stopResumeService.SetPollingCallback(pollingLifecycleSvc) stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) + stopResumeService.SetPriorityEventWriter(priorityEvents) stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB)) // 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。 stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService) @@ -294,6 +299,7 @@ func initServices(s *stores, deps *Dependencies) *services { orderService.SetLifecycleAudit(auditWriter) orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB)) orderService.SetObservationSeriesEventWriter(observationSeriesEvents) + orderService.SetPriorityEventWriter(priorityEvents) walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter) orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) @@ -448,6 +454,12 @@ func initServices(s *stores, deps *Dependencies) *services { pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger) pollingManualTriggerService.SetAudit(deps.DB, auditWriter) + // 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。 + priorityPollingService := pollingSvc.NewPriorityEnqueueService( + deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger, + ) + priorityPollingService.SetAudit(auditWriter) + return &services{ AccessAudit: auditWriter, Approval: approvalCreationService, @@ -511,6 +523,7 @@ func initServices(s *stores, deps *Dependencies) *services { PollingAlert: pollingAlertService, PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger), PollingManualTrigger: pollingManualTriggerService, + PriorityPolling: priorityPollingService, Asset: assetService, AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter), AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index cf94ba55..0770a56e 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -52,6 +52,7 @@ type stores struct { DataCleanupConfig *postgres.DataCleanupConfigStore DataCleanupLog *postgres.DataCleanupLogStore PollingManualTriggerLog *postgres.PollingManualTriggerLogStore + PollingPriorityItem *postgres.PollingPriorityItemStore // 代理钱包系统 AgentWallet *postgres.AgentWalletStore AgentWalletTransaction *postgres.AgentWalletTransactionStore @@ -132,6 +133,7 @@ func initStores(deps *Dependencies) *stores { DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB), + PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB), // 代理钱包系统 AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 42f3e8b9..8f93ff74 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -68,6 +68,7 @@ type Handlers struct { PollingAlert *admin.PollingAlertHandler PollingCleanup *admin.PollingCleanupHandler PollingManualTrigger *admin.PollingManualTriggerHandler + PollingPriority *admin.PriorityPollingHandler Asset *admin.AssetHandler AssetLifecycle *admin.AssetLifecycleHandler AssetWallet *admin.AssetWalletHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 5cdc4961..f4c6c526 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -9,6 +9,7 @@ import ( carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" @@ -97,6 +98,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q ) cardObservationOutbox := outbox.NewRepository() observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox) + priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox) // 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。 carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger) cardObservationService := cardObservationApp.NewService( @@ -156,6 +158,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil)) orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) orderService.SetObservationSeriesEventWriter(observationSeriesEvents) + orderService.SetPriorityEventWriter(priorityEvents) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 stopResumeService := iotCardSvc.NewStopResumeService( @@ -167,12 +170,14 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q deps.Logger, ) stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) + stopResumeService.SetPriorityEventWriter(priorityEvents) stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB)) // 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。 stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService) // 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。 carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService)) activationService.SetObservationSeriesEventWriter(observationSeriesEvents) + activationService.SetPriorityEventWriter(priorityEvents) usageService.SetStopResumeCallback(stopResumeService) activationService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService) @@ -190,6 +195,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q CardObservation: cardObservationService, CardObservationSeries: cardObservationSeriesService, ObservationSeriesEvents: observationSeriesEvents, + PriorityEvents: priorityEvents, CommissionCalculation: commissionCalculationService, CommissionStats: commissionStatsService, UsageService: usageService, diff --git a/internal/handler/admin/polling_priority.go b/internal/handler/admin/polling_priority.go new file mode 100644 index 00000000..215503bc --- /dev/null +++ b/internal/handler/admin/polling_priority.go @@ -0,0 +1,112 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/validation" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + prioritypollingquery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling" + pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// PriorityPollingHandler 处理卡轮询优先队列的人工入队与只读查询请求。 +type PriorityPollingHandler struct { + enqueue *pollingSvc.PriorityEnqueueService + query *prioritypollingquery.Query + validator *validator.Validate +} + +// NewPriorityPollingHandler 创建卡轮询优先队列处理器。 +func NewPriorityPollingHandler( + enqueue *pollingSvc.PriorityEnqueueService, + query *prioritypollingquery.Query, + validator *validator.Validate, +) *PriorityPollingHandler { + return &PriorityPollingHandler{enqueue: enqueue, query: query, validator: validator} +} + +// Enqueue 人工优先入队。 +// POST /api/admin/polling-priority-items +func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error { + if h.enqueue == nil { + return errors.New(errors.CodeInternalError, "人工优先入队能力未配置") + } + var req dto.EnqueuePriorityPollingRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数格式不正确") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("人工优先入队参数不合法", &req, err)) + } + result, err := h.enqueue.Enqueue(c.UserContext(), req.CardID, req.Reason, middleware.GetUserIDFromContext(c.UserContext())) + if err != nil { + return err + } + taskTypeNames := make([]string, 0, len(result.TaskTypes)) + items := make([]dto.EnqueuePriorityPollingItemView, 0, len(result.Items)) + for _, taskType := range result.TaskTypes { + taskTypeNames = append(taskTypeNames, constants.PollingPriorityTaskTypeName(taskType)) + } + for _, item := range result.Items { + items = append(items, dto.EnqueuePriorityPollingItemView{ + ItemID: item.ItemID, + TaskType: item.TaskType, + TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType), + Status: item.Status, + StatusName: constants.PollingPriorityStatusName(item.Status), + TriggerCount: item.TriggerCount, + LastTriggeredAt: item.LastTriggeredAt, + Created: item.Created, + }) + } + return response.Success(c, dto.EnqueuePriorityPollingResponse{ + CardID: result.CardID, + TaskTypes: result.TaskTypes, + TaskTypeNames: taskTypeNames, + CreatedCount: result.CreatedCount, + MergedCount: result.MergedCount, + Items: items, + }) +} + +// ListPriorityItems 分页查询优先轮询项。 +// GET /api/admin/polling-priority-items +func (h *PriorityPollingHandler) ListPriorityItems(c *fiber.Ctx) error { + if h.query == nil { + return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置") + } + var req dto.PriorityPollingItemListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("优先轮询项查询参数不合法", &req, err)) + } + result, err := h.query.List(c.UserContext(), req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +// GetPriorityItem 查询优先轮询项详情。 +// GET /api/admin/polling-priority-items/:id +func (h *PriorityPollingHandler) GetPriorityItem(c *fiber.Ctx) error { + if h.query == nil { + return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置") + } + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.query.Detail(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index d3a63f40..31ca6dbf 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -268,6 +268,14 @@ func NewRegistry() *Registry { pollingManualTriggerBatch := pollingAction(constants.AuditActionPollingManualTriggerBatch, "批量手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal) pollingManualTriggerByCondition := pollingAction(constants.AuditActionPollingManualTriggerByCondition, "条件筛选触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal) pollingManualCancelled := pollingAction(constants.AuditActionPollingManualCancelled, "取消手动触发任务", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal) + pollingPriorityEnqueued := pollingPriorityAction(constants.AuditActionPollingPriorityEnqueued, "卡轮询优先项入队", constants.AuditRiskNormal, true) + pollingPriorityClaimed := pollingPriorityAction(constants.AuditActionPollingPriorityClaimed, "卡轮询优先项领取", constants.AuditRiskNormal, true) + pollingPriorityFailed := pollingPriorityAction(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditRiskNormal, true) + pollingPriorityRetried := pollingPriorityAction(constants.AuditActionPollingPriorityRetried, "卡轮询优先项退回重试", constants.AuditRiskNormal, true) + pollingPriorityCompleted := pollingPriorityAction(constants.AuditActionPollingPriorityCompleted, "卡轮询优先项完成", constants.AuditRiskNormal, true) + pollingPriorityDequeued := pollingPriorityAction(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队", constants.AuditRiskNormal, true) + // 人工优先入队的拒绝只可能来自后台账号入口,不登记 Worker 侧来源。 + pollingPriorityManualDenied := pollingPriorityAction(constants.AuditActionPollingPriorityManualDenied, "拒绝人工优先入队", constants.AuditRiskNormal, false) wecomCredentialsRead := ActionDefinition{ Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据", Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh, @@ -635,6 +643,13 @@ func NewRegistry() *Registry { constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch, constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition, constants.AuditActionPollingManualCancelled: pollingManualCancelled, + constants.AuditActionPollingPriorityEnqueued: pollingPriorityEnqueued, + constants.AuditActionPollingPriorityClaimed: pollingPriorityClaimed, + constants.AuditActionPollingPriorityFailed: pollingPriorityFailed, + constants.AuditActionPollingPriorityRetried: pollingPriorityRetried, + constants.AuditActionPollingPriorityCompleted: pollingPriorityCompleted, + constants.AuditActionPollingPriorityDequeued: pollingPriorityDequeued, + constants.AuditActionPollingPriorityManualDenied: pollingPriorityManualDenied, constants.AuditActionWeComCredentialsRead: wecomCredentialsRead, constants.AuditActionRoleCreated: roleCreated, constants.AuditActionRoleUpdated: roleUpdated, @@ -887,6 +902,10 @@ func NewRegistry() *Registry { Type: constants.AuditResourcePollingManualTrigger, Name: "手动轮询任务", IdentityFields: []string{"id", "task_type", "trigger_type", "total_count", "status", "triggered_by"}, }, + constants.AuditResourcePollingPriorityItem: { + Type: constants.AuditResourcePollingPriorityItem, Name: "卡轮询优先项", + IdentityFields: []string{"id", "card_id", "task_type", "status", "trigger_type", "trigger_count", "manual_operator_id"}, + }, constants.AuditResourceDevice: { Type: constants.AuditResourceDevice, Name: "设备", IdentityFields: []string{"id", "virtual_no", "imei", "sn", "device_name", "device_model", "device_type", "manufacturer", "shop_id", "series_id", "generation"}, @@ -1462,6 +1481,26 @@ func pollingAction(code, name, primaryResource, risk string) ActionDefinition { } } +// pollingPriorityAction 定义卡轮询优先队列动作。 +// 人工侧由后台账号经管理接口写入;当 withWorkerOrigin 为真时额外登记系统侧来源 +// (轮询 Worker 任务),两侧必须分别声明,否则写入会被来源校验丢弃。 +func pollingPriorityAction(code, name, risk string, withWorkerOrigin bool) ActionDefinition { + definition := ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk, + PrimaryResource: constants.AuditResourcePollingPriorityItem, + AllowedActor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI, + RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + } + if withWorkerOrigin { + definition.AllowedOrigins = []ActionOrigin{ + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + } + } + return definition +} + func outboxRecoveryAction(code, name string) ActionDefinition { return ActionDefinition{ Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh, diff --git a/internal/infrastructure/prioritypolling/event.go b/internal/infrastructure/prioritypolling/event.go new file mode 100644 index 00000000..2ce38ee4 --- /dev/null +++ b/internal/infrastructure/prioritypolling/event.go @@ -0,0 +1,294 @@ +// Package prioritypolling 实现卡轮询优先队列的可靠事件适配器。 +// +// 职责分工:本包只做「业务事实 → Outbox 事件」与「Outbox 事件 → 优先项事实 + 执行提示」两侧适配, +// 不承载状态机(权威在优先项事实表)、不决定任务类型集合(权威在 pkg/constants)。 +package prioritypolling + +import ( + "context" + "strconv" + "strings" + + "github.com/bytedance/sonic" + "go.uber.org/zap" + "gorm.io/gorm" + + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" +) + +// PriorityEventWriter 将业务成功事实写入公共 Outbox。 +type PriorityEventWriter struct { + outbox *outbox.Repository +} + +// NewPriorityEventWriter 创建优先轮询事件 Writer。 +func NewPriorityEventWriter(repository *outbox.Repository) *PriorityEventWriter { + return &PriorityEventWriter{outbox: repository} +} + +// AppendPriorityRequested 在原业务事务中追加稳定优先轮询请求事件。 +// +// 三个不可省略的约束: +// 1. 必须在调用方的业务事务内追加(tx 非空),由 Outbox 在提交后投递,避免「提交后 goroutine」丢失可靠副作用。 +// 2. 观测结果驱动的业务评估必须短路:与既有观测序列写入共用同一抑制标记,避免「观测→业务→优先轮询→观测」反向触发环。 +// 3. event_id 必须使用独立命名空间前缀,否则会被 Outbox 全局幂等键静默吞掉。 +func (w *PriorityEventWriter) AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event priorityapp.PriorityRequestedEvent) error { + if w == nil || w.outbox == nil { + return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置") + } + if cardObservationApp.IsSeriesTriggerSuppressed(ctx) { + return nil + } + if tx == nil { + return errors.New(errors.CodeInternalError, "优先轮询事件缺少原业务事务") + } + if strings.TrimSpace(event.EventID) == "" || event.ResourceID == 0 || strings.TrimSpace(event.TriggerType) == "" { + return errors.New(errors.CodeInvalidParam, "优先轮询事件载荷不完整") + } + if !strings.HasPrefix(event.EventID, constants.PollingPriorityEventKeyPrefix) { + return errors.New(errors.CodeInvalidParam, + "优先轮询事件 ID 必须使用 "+constants.PollingPriorityEventKeyPrefix+" 命名空间前缀") + } + // 长度契约:event_id 列上限 64,超限会让写入失败并连带回滚触发事务(如套餐轮询进失败分支)。 + // 因此在此提前按既有 Outbox 标识契约校验,把数据库层失败变成可定位的参数错误。 + if err := outboxid.Validate(event.EventID, ""); err != nil { + return errors.Wrap(errors.CodeInvalidParam, err, "优先轮询事件 ID 超长") + } + if event.ResourceType != constants.AssetTypeIotCard && event.ResourceType != constants.AssetTypeDevice { + return errors.New(errors.CodeInvalidParam, "优先轮询事件资源类型不受支持") + } + // 人工入队不经事件通道:manual_trigger 由 internal/service/polling/priority_enqueue_service.go 直写事实表, + // 事件通道没有操作者,无法满足事实表 CHECK (trigger_type <> 'manual_trigger' OR manual_operator_id > 0)。 + // 权威约束在事实表 CHECK,这里只是把「DB 23514 → 整个投递事务回滚」提前为可定位的参数错误, + // 避免调用方业务事务被数据库层失败牵连回滚。 + if event.TriggerType == constants.PollingPriorityTriggerManual { + return errors.New(errors.CodeInvalidParam, + "优先轮询事件不接受人工入队触发类型 "+constants.PollingPriorityTriggerManual+",人工入队请调用人工入队用例") + } + + // 冻结卡快照:独立卡为自身,绑定设备的资产为绑定状态有效的全部在用卡。 + // 快照在业务事务内写入,触发提交后绑定关系或当前卡槽变化都不追溯。 + if event.ResourceType == constants.AssetTypeDevice { + event.ResourceIDs = nil + if err := tx.WithContext(ctx). + Model(&model.DeviceSimBinding{}). + Where("device_id = ? AND bind_status = ?", event.ResourceID, constants.BindStatusBound). + Order("slot_position ASC"). + Pluck("iot_card_id", &event.ResourceIDs).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "冻结优先轮询设备绑定卡快照失败") + } + } + + if _, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: event.EventID, + EventType: constants.OutboxEventTypePollingPriorityRequested, + PayloadVersion: constants.PollingPriorityRequestedPayloadVersionV1, + AggregateType: event.ResourceType, + AggregateID: strconv.FormatUint(uint64(event.ResourceID), 10), + ResourceType: event.ResourceType, + ResourceID: strconv.FormatUint(uint64(event.ResourceID), 10), + BusinessKey: event.EventID, + RequestID: event.RequestID, + CorrelationID: event.CorrelationID, + Payload: event, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入优先轮询 Outbox 事件失败") + } + return nil +} + +// PriorityRequestedConsumer 消费业务成功事实,按卡 × 纳入任务类型建立或合并优先项并下发执行提示。 +type PriorityRequestedConsumer struct { + db *gorm.DB + store *postgres.PollingPriorityItemStore + publisher priorityapp.PromptPublisher + audit *audit.Writer + logger *zap.Logger +} + +// NewPriorityRequestedConsumer 创建优先轮询请求消费者。 +func NewPriorityRequestedConsumer( + db *gorm.DB, + store *postgres.PollingPriorityItemStore, + publisher priorityapp.PromptPublisher, + auditWriter *audit.Writer, + logger *zap.Logger, +) *PriorityRequestedConsumer { + if logger == nil { + logger = zap.NewNop() + } + return &PriorityRequestedConsumer{db: db, store: store, publisher: publisher, audit: auditWriter, logger: logger} +} + +// Consume 将一个业务事件展开为逐卡逐任务类型的优先项;重复投递只合并,不新建行也不重复调用。 +func (c *PriorityRequestedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.db == nil || c.store == nil || c.publisher == nil { + return errors.New(errors.CodeInternalError, "优先轮询消费者未配置") + } + if envelope.EventType != constants.OutboxEventTypePollingPriorityRequested || + envelope.PayloadVersion != constants.PollingPriorityRequestedPayloadVersionV1 { + return errors.New(errors.CodeInvalidParam, "优先轮询事件类型或版本不受支持") + } + var event priorityapp.PriorityRequestedEvent + if err := sonic.Unmarshal(envelope.Payload, &event); err != nil { + return errors.Wrap(errors.CodeInvalidParam, err, "优先轮询事件载荷无法解析") + } + if event.EventID != envelope.EventID || event.ResourceID == 0 || strings.TrimSpace(event.TriggerType) == "" { + return errors.New(errors.CodeInvalidParam, "优先轮询事件载荷不完整") + } + if event.ResourceType != constants.AssetTypeIotCard && event.ResourceType != constants.AssetTypeDevice { + return errors.New(errors.CodeInvalidParam, "优先轮询事件资源类型不受支持") + } + cardIDs := []uint{event.ResourceID} + if event.ResourceType == constants.AssetTypeDevice { + cardIDs = event.ResourceIDs + } + // 设备在触发事务内没有绑定卡:没有执行对象,事件成功结束且不重试。 + // 记一条 Info 以便外部区分「触发已发生但无卡」与「触发未发生」。 + if len(cardIDs) == 0 { + c.logger.Info("优先轮询触发未解析到绑定卡,事件按无执行对象结束", + zap.String("event_id", event.EventID), + zap.String("trigger_type", event.TriggerType), + zap.String("resource_type", event.ResourceType), + zap.Uint("resource_id", event.ResourceID)) + return nil + } + + taskTypes := constants.PollingPriorityTaskTypes() + createdCount, mergedCount := 0, 0 + err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + shopSnapshots, lookupErr := loadCardShopSnapshots(ctx, tx, cardIDs) + if lookupErr != nil { + return lookupErr + } + store := c.store.WithTx(tx) + createdCount, mergedCount = 0, 0 + for _, cardID := range cardIDs { + for _, taskType := range taskTypes { + item := &model.PollingPriorityItem{ + CardID: cardID, + TaskType: taskType, + TriggerType: event.TriggerType, + SourceOrderID: optionalUintPointer(event.SourceOrderID), + SourcePackageUsageID: optionalUintPointer(event.SourcePackageUsageID), + ShopIDSnapshot: shopSnapshots[cardID], + LastTriggeredAt: event.OccurredAt, + } + created, insertErr := store.InsertOrMerge(ctx, item) + if insertErr != nil { + return insertErr + } + if created { + createdCount++ + continue + } + mergedCount++ + } + } + // 入队事实与审计同事务:任一逐卡逐类型写入失败都整体回滚,由 Outbox 重投,不存在部分静默成功。 + return c.writeEnqueueAudit(ctx, tx, envelope.EventID, event, len(cardIDs), taskTypes, createdCount, mergedCount) + }) + if err != nil { + return err + } + + // 提交后再下发执行提示。提示通道不是权威,但下发失败仍返回错误交 Outbox 重投: + // 合并语义使重投幂等,且无人接收的提示只会让加急退化为延迟一个普通轮询周期, + // 因此不能用「吞掉错误」的方式掩盖通道故障。 + for _, cardID := range cardIDs { + for _, taskType := range taskTypes { + if publishErr := c.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil { + return errors.Wrap(errors.CodeInternalError, publishErr, "下发优先轮询执行提示失败") + } + } + } + return nil +} + +// writeEnqueueAudit 写入一条入队事实审计(自动触发与人工入队共用同一动作)。 +func (c *PriorityRequestedConsumer) writeEnqueueAudit( + ctx context.Context, + tx *gorm.DB, + eventID string, + event priorityapp.PriorityRequestedEvent, + cardCount int, + taskTypes []string, + createdCount, mergedCount int, +) error { + afterData := map[string]any{ + "trigger_type": event.TriggerType, + "source_order_id": event.SourceOrderID, + "source_package_usage_id": event.SourcePackageUsageID, + "resource_type": event.ResourceType, + "resource_id": event.ResourceID, + "card_count": cardCount, + "task_types": taskTypes, + "created_count": createdCount, + "merged_count": mergedCount, + "source": constants.AuditSourceWorker, + "occurred_at": event.OccurredAt, + } + return c.audit.WriteTask(ctx, tx, audit.TaskInput{ + // 与 Outbox 事件同 ID:至少一次重投时审计按 event_id 幂等去重,不产生重复审计行。 + EventID: eventID, + ActionCode: constants.AuditActionPollingPriorityEnqueued, + Summary: "卡轮询优先项入队", + TaskID: 0, TaskNo: eventID, DisplayName: "卡轮询优先项入队", + Actor: audit.ActorInput{ + Kind: constants.AuditActorSystemTask, + ID: constants.OutboxEventTypePollingPriorityRequested, + Name: "优先轮询请求消费任务", + }, + Source: constants.AuditSourceWorker, + ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, + CorrelationID: event.CorrelationID, + AfterData: afterData, + Metadata: map[string]any{ + "trigger_type": event.TriggerType, + "source_order_id": event.SourceOrderID, + "source_package_usage_id": event.SourcePackageUsageID, + "created_count": createdCount, + "merged_count": mergedCount, + }, + }) +} + +// loadCardShopSnapshots 读取卡的店铺快照:平台卡返回 nil,读侧据此把平台卡与空范围一并挡在代理之外。 +// 快照取消费时点的卡归属,卡改归属不追溯已入队的加急事实;已软删的卡不产生快照, +// 其优先项会在执行时以「卡不存在」进入失败终态。 +func loadCardShopSnapshots(ctx context.Context, tx *gorm.DB, cardIDs []uint) (map[uint]*uint, error) { + type cardShopRow struct { + ID uint `gorm:"column:id"` + ShopID *uint `gorm:"column:shop_id"` + } + var rows []cardShopRow + if err := tx.WithContext(ctx). + Model(&model.IotCard{}). + Select("id, shop_id"). + Where("id IN ? AND deleted_at IS NULL", cardIDs). + Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询卡店铺快照失败") + } + snapshots := make(map[uint]*uint, len(rows)) + for _, row := range rows { + snapshots[row.ID] = row.ShopID + } + return snapshots, nil +} + +// optionalUintPointer 把 0 视为「无来源」,避免用哨兵 0 占用可空来源列。 +func optionalUintPointer(value uint) *uint { + if value == 0 { + return nil + } + return &value +} diff --git a/internal/model/dto/polling_priority_dto.go b/internal/model/dto/polling_priority_dto.go new file mode 100644 index 00000000..ea91dfef --- /dev/null +++ b/internal/model/dto/polling_priority_dto.go @@ -0,0 +1,80 @@ +package dto + +import "time" + +// EnqueuePriorityPollingRequest 是人工优先入队请求。 +// 一次优先需求覆盖该卡的**全部**纳入轮询任务类型,因此不接受按任务类型入队。 +type EnqueuePriorityPollingRequest struct { + CardID uint `json:"card_id" validate:"required" description:"卡ID"` + Reason string `json:"reason" validate:"required,min=1,max=500" description:"人工加急原因(必填,最长500字符)"` +} + +// EnqueuePriorityPollingResponse 是人工优先入队结果(资产级:一次入队对应多个任务类型的项)。 +type EnqueuePriorityPollingResponse struct { + CardID uint `json:"card_id" description:"卡ID"` + TaskTypes []string `json:"task_types" description:"本次覆盖的轮询任务类型集合:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"` + TaskTypeNames []string `json:"task_type_names" description:"轮询任务类型名称集合,与 task_types 一一对应"` + CreatedCount int `json:"created_count" description:"本次新建的优先项数"` + MergedCount int `json:"merged_count" description:"本次合并到既有活动项的项数"` + Items []EnqueuePriorityPollingItemView `json:"items" description:"逐任务类型的优先项结果"` +} + +// EnqueuePriorityPollingItemView 是单个任务类型的入队结果。 +type EnqueuePriorityPollingItemView struct { + ItemID uint `json:"item_id" description:"优先轮询项ID"` + TaskType string `json:"task_type" description:"轮询任务类型"` + TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"` + Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"` + StatusName string `json:"status_name" description:"状态名称"` + TriggerCount int `json:"trigger_count" description:"累计触发次数(同一卡同一任务类型的后续触发合并计数)"` + LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"` + Created bool `json:"created" description:"是否新建活动项:false 表示已合并到既有活动项"` +} + +// PriorityPollingItemListRequest 是优先轮询项列表查询请求。 +type PriorityPollingItemListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` + CardID *uint `json:"card_id" query:"card_id" validate:"omitempty" description:"按卡ID筛选"` + TaskType *string `json:"task_type" query:"task_type" validate:"omitempty,oneof=polling:realname polling:carddata polling:package polling:card_status" enum:"polling:realname,polling:carddata,polling:package,polling:card_status" description:"按轮询任务类型筛选:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"` + Status *string `json:"status" query:"status" validate:"omitempty,oneof=pending processing completed failed" enum:"pending,processing,completed,failed" description:"按状态筛选:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"` + TriggerType *string `json:"trigger_type" query:"trigger_type" validate:"omitempty,oneof=purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger" enum:"purchase_activated,renewal_activated,queue_activated,addon_activated,no_valid_package,manual_trigger" description:"按触发类型筛选:purchase_activated-主套餐购买后立即生效 renewal_activated-续购套餐生效 queue_activated-排队主套餐顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队"` +} + +// PriorityPollingItemResponse 是优先轮询项的读侧投影。 +type PriorityPollingItemResponse struct { + ID uint `json:"id" description:"优先轮询项ID"` + CardID uint `json:"card_id" description:"卡ID"` + ICCID string `json:"iccid,omitempty" description:"卡ICCID(卡已不存在时为空)"` + TaskType string `json:"task_type" description:"轮询任务类型"` + TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"` + Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"` + StatusName string `json:"status_name" description:"状态名称"` + TriggerType string `json:"trigger_type" description:"首次触发类型:purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger"` + TriggerTypeName string `json:"trigger_type_name" description:"首次触发类型名称"` + TriggerTypes string `json:"trigger_types" description:"触发来源集合(去重后的全部触发类型,英文逗号分隔)"` + TriggerCount int `json:"trigger_count" description:"累计触发次数"` + LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"` + SourceOrderID *uint `json:"source_order_id,omitempty" description:"来源订单ID"` + SourcePackageUse *uint `json:"source_package_usage_id,omitempty" description:"来源套餐使用记录ID"` + AttemptCount int `json:"attempt_count" description:"真实发起执行的次数(上限3次)"` + ClaimedAt *time.Time `json:"claimed_at,omitempty" description:"最近认领时间"` + ManualReason string `json:"manual_reason,omitempty" description:"人工入队原因"` + ManualOperator uint `json:"manual_operator_id,omitempty" description:"人工入队操作者账号ID"` + ManualOperatorNm string `json:"manual_operator_name,omitempty" description:"人工入队操作者名称"` + ShopIDSnapshot *uint `json:"shop_id_snapshot,omitempty" description:"触发时冻结的卡所属店铺ID,平台卡为空"` + Result string `json:"result" description:"执行结果:success-成功 failed-失败,未出结果时为空"` + ResultName string `json:"result_name" description:"执行结果名称"` + FailureReason string `json:"failure_reason,omitempty" description:"可安全展示的失败原因"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"` +} + +// PriorityPollingItemListResponse 是优先轮询项列表响应。 +type PriorityPollingItemListResponse struct { + List []*PriorityPollingItemResponse `json:"items" description:"优先轮询项列表"` + Total int64 `json:"total" description:"总条数"` + Page int `json:"page" description:"页码"` + // 与 pkg/response.PaginationData 一致:响应侧字段名为 size,请求侧仍用 page_size。 + PageSize int `json:"size" description:"每页条数"` +} diff --git a/internal/model/polling.go b/internal/model/polling.go index 46223dac..5aecf19d 100644 --- a/internal/model/polling.go +++ b/internal/model/polling.go @@ -2,6 +2,8 @@ package model import ( "time" + + "gorm.io/gorm" ) // PollingConfig 轮询配置表 @@ -152,3 +154,38 @@ type PollingManualTriggerLog struct { func (PollingManualTriggerLog) TableName() string { return "tb_polling_manual_trigger_log" } + +// PollingPriorityItem 是「某卡某轮询任务类型被加急」的持久化事实。 +// +// 活动项粒度为 (card_id, task_type),由部分唯一索引保证至多一条:Status 取 +// pending-待执行 / processing-执行中 时为活动项,取 completed-已完成 / failed-失败出队 时为终态, +// 终态行不占用唯一键。TriggerType 是首次触发场景,TriggerTypes 是去重后的全部触发场景集合; +// ClaimedAt 是认领租约基准,AttemptCount 只在真正发起执行后累加,Result 与 FailureReason +// 只承载可安全展示的结果与原因,ShopIDSnapshot 是消费时点解析的卡所属店铺快照(平台卡为 NULL), +// 读侧数据范围按它下推,卡改归属不追溯已入队的加急事实。 +// 本模型不声明任何关联标签与数据库外键,关联一律只存 ID 并由应用层显式查询。 +type PollingPriorityItem struct { + gorm.Model + CardID uint `gorm:"column:card_id;type:bigint;not null" json:"card_id"` + TaskType string `gorm:"column:task_type;type:varchar(50);not null" json:"task_type"` + Status string `gorm:"column:status;type:varchar(16);not null;default:'pending'" json:"status"` + TriggerType string `gorm:"column:trigger_type;type:varchar(30);not null" json:"trigger_type"` + TriggerTypes string `gorm:"column:trigger_types;type:varchar(255);not null;default:''" json:"trigger_types"` + TriggerCount int `gorm:"column:trigger_count;type:int;not null;default:1" json:"trigger_count"` + LastTriggeredAt time.Time `gorm:"column:last_triggered_at;type:timestamptz;not null;default:now()" json:"last_triggered_at"` + SourceOrderID *uint `gorm:"column:source_order_id;type:bigint" json:"source_order_id,omitempty"` + SourcePackageUsageID *uint `gorm:"column:source_package_usage_id;type:bigint" json:"source_package_usage_id,omitempty"` + AttemptCount int `gorm:"column:attempt_count;type:int;not null;default:0" json:"attempt_count"` + ClaimedAt *time.Time `gorm:"column:claimed_at;type:timestamptz" json:"claimed_at,omitempty"` + ManualReason string `gorm:"column:manual_reason;type:varchar(500);not null;default:''" json:"manual_reason"` + ManualOperatorID uint `gorm:"column:manual_operator_id;type:bigint;not null;default:0" json:"manual_operator_id"` + ManualOperatorName string `gorm:"column:manual_operator_name;type:varchar(100);not null;default:''" json:"manual_operator_name"` + ShopIDSnapshot *uint `gorm:"column:shop_id_snapshot;type:bigint" json:"shop_id_snapshot,omitempty"` + Result string `gorm:"column:result;type:varchar(16);not null;default:''" json:"result"` + FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"` +} + +// TableName 指定表名 +func (PollingPriorityItem) TableName() string { + return "tb_polling_priority_item" +} diff --git a/internal/polling/lifecycle_service.go b/internal/polling/lifecycle_service.go index 28c9b4f4..dc2d603f 100644 --- a/internal/polling/lifecycle_service.go +++ b/internal/polling/lifecycle_service.go @@ -48,7 +48,7 @@ func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, card *model if card == nil { return } - if !s.shouldEnqueue(ctx, card) { + if !s.ShouldEnqueue(ctx, card) { s.logger.Debug("卡禁用轮询,跳过入队", zap.Uint("card_id", card.ID)) return } @@ -75,7 +75,7 @@ func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardI s.logger.Error("卡状态变化:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err)) return } - if !s.shouldEnqueue(ctx, card) { + if !s.ShouldEnqueue(ctx, card) { return } s.enqueueCard(ctx, card) @@ -98,7 +98,7 @@ func (s *PollingLifecycleService) OnBatchCardsStatusChanged(ctx context.Context, return } for _, card := range cards { - if !s.shouldEnqueue(ctx, card) { + if !s.ShouldEnqueue(ctx, card) { continue } s.enqueueCard(ctx, card) @@ -120,7 +120,7 @@ func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint s.logger.Error("卡启用:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err)) return } - if !s.shouldEnqueue(ctx, card) { + if !s.ShouldEnqueue(ctx, card) { return } s.enqueueCard(ctx, card) @@ -134,10 +134,10 @@ func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uin s.queueMgr.InvalidateCardCache(ctx, cardID) } -// shouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询 +// ShouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询(同时被轮询优先项的执行前范围校验复用) // 两层检查:先查卡级 enable_polling,再查设备级 enable_polling(IsStandalone=false 时) // 直接查 device.enable_polling,不依赖级联同步正确性,防止同步失败时生命周期事件绕过设备级禁用 -func (s *PollingLifecycleService) shouldEnqueue(ctx context.Context, card *model.IotCard) bool { +func (s *PollingLifecycleService) ShouldEnqueue(ctx context.Context, card *model.IotCard) bool { if !card.EnablePolling { return false } diff --git a/internal/polling/queue_manager.go b/internal/polling/queue_manager.go index 9729c53e..4018efb8 100644 --- a/internal/polling/queue_manager.go +++ b/internal/polling/queue_manager.go @@ -153,6 +153,14 @@ func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, ta return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err() } +// EnqueuePriority 优先轮询提示下发(List RPUSH,与手动触发队列分离)。 +// 使用 RPUSH 与调度器的 LPopCount 组成先进先出;提示通道不是权威,丢失或缓存服务重启时 +// 库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,只退化为延迟一个普通轮询周期。 +func (m *PollingQueueManager) EnqueuePriority(ctx context.Context, cardID uint, taskType string) error { + key := constants.RedisPollingPriorityQueueKey(taskType) + return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err() +} + // OnCardDeleted 卡删除事件处理(移除所有队列 + 清理卡信息缓存) func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error { // 从所有分片队列移除 diff --git a/internal/polling/scheduler.go b/internal/polling/scheduler.go index 1bca40f5..483ce7e7 100644 --- a/internal/polling/scheduler.go +++ b/internal/polling/scheduler.go @@ -156,7 +156,12 @@ func (s *Scheduler) scheduleLoop(ctx context.Context) { // processShardSchedule 处理手动队列和分片定时队列(每 1 秒触发) // 使用 90% 的 tick 间隔作为超时,确保单分片 Redis 挂起时不阻塞下一个 tick func (s *Scheduler) processShardSchedule(ctx context.Context) { - // 手动队列不受 Init 影响(ConfigManager 已就绪即可) + // 优先提示与手动队列都不受 Init 影响(ConfigManager 已就绪即可)。 + // 同一调度周期内先排空优先提示、再排空手动触发队列:优先的强度边界仅为调度入口领先, + // 提示排空不参与分片背压判断,因此分片积压不会抑制加急入队。 + for _, taskType := range allTaskTypes { + s.processPriorityQueue(ctx, taskType, s.cfg.MaxManualBatchSize) + } for _, taskType := range allTaskTypes { s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize) } @@ -243,7 +248,16 @@ func (s *Scheduler) processActivationTasks(ctx context.Context) { // processManualQueue 处理手动触发队列 func (s *Scheduler) processManualQueue(ctx context.Context, taskType string, maxBatch int) { - key := constants.RedisPollingManualQueueKey(taskType) + s.drainPollingQueue(ctx, constants.RedisPollingManualQueueKey(taskType), taskType, maxBatch) +} + +// processPriorityQueue 处理优先轮询提示通道:与手动触发队列分离,且不参与分片背压判断。 +func (s *Scheduler) processPriorityQueue(ctx context.Context, taskType string, maxBatch int) { + s.drainPollingQueue(ctx, constants.RedisPollingPriorityQueueKey(taskType), taskType, maxBatch) +} + +// drainPollingQueue 从指定提示通道批量取出卡,并复用既有批量提交逻辑提交到同一 Asynq 任务类型与队列。 +func (s *Scheduler) drainPollingQueue(ctx context.Context, key, taskType string, maxBatch int) { cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result() if err != nil || len(cardIDs) == 0 { return diff --git a/internal/query/prioritypolling/query.go b/internal/query/prioritypolling/query.go new file mode 100644 index 00000000..b6ee4280 --- /dev/null +++ b/internal/query/prioritypolling/query.go @@ -0,0 +1,192 @@ +// Package prioritypolling 提供卡轮询优先项的只读查询投影。 +// +// 读取不经聚合根、不修改任何状态;数据范围按事实表冗余的卡店铺快照下推, +// 平台卡(快照为空)与范围外的行对代理账号同等不可见,越权与不存在不得形成可枚举差异。 +package prioritypolling + +import ( + "context" + stderrors "errors" + "strings" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + pollingsvc "github.com/break/junhong_cmp_fiber/internal/service/polling" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Query 优先轮询项只读查询。 +type Query struct { + db *gorm.DB + store *postgres.PollingPriorityItemStore +} + +// NewQuery 创建优先轮询项查询。 +func NewQuery(db *gorm.DB, store *postgres.PollingPriorityItemStore) *Query { + return &Query{db: db, store: store} +} + +// List 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。 +func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest) (*dto.PriorityPollingItemListResponse, error) { + if q == nil || q.store == nil { + return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置") + } + scope, err := priorityPollingShopScope(ctx) + if err != nil { + return nil, err + } + filter := postgres.PollingPriorityItemListFilter{ + CardID: req.CardID, + TaskType: req.TaskType, + Status: req.Status, + TriggerType: req.TriggerType, + ShopIDSnapshot: scope, + Page: req.Page, + PageSize: req.PageSize, + } + items, total, err := q.store.List(ctx, filter) + if err != nil { + return nil, err + } + list, err := q.projectList(ctx, items) + if err != nil { + return nil, err + } + page, pageSize := normalizePage(req.Page, req.PageSize) + return &dto.PriorityPollingItemListResponse{ + List: list, Total: total, Page: page, PageSize: pageSize, + }, nil +} + +// Detail 查询优先轮询项详情,并按当前账号数据范围下推。 +// 越权与不存在返回同一稳定错误,不提供可枚举差异。 +func (q *Query) Detail(ctx context.Context, id uint) (*dto.PriorityPollingItemResponse, error) { + if q == nil || q.store == nil { + return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置") + } + scope, err := priorityPollingShopScope(ctx) + if err != nil { + return nil, err + } + item, err := q.store.GetByIDScoped(ctx, id, scope) + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + // 越权与不存在共用同一稳定错误,避免通过状态码枚举他人资源。 + return nil, errors.New(errors.CodeForbidden, priorityPollingDeniedMessage) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项详情失败") + } + iccid := q.loadICCID(ctx, []*model.PollingPriorityItem{item}) + return toPriorityPollingItemResponse(item, iccid[item.CardID]), nil +} + +// projectList 批量载入卡的 ICCID 后投影列表,避免逐行查询。 +func (q *Query) projectList(ctx context.Context, items []*model.PollingPriorityItem) ([]*dto.PriorityPollingItemResponse, error) { + if len(items) == 0 { + return []*dto.PriorityPollingItemResponse{}, nil + } + iccids := q.loadICCID(ctx, items) + list := make([]*dto.PriorityPollingItemResponse, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + list = append(list, toPriorityPollingItemResponse(item, iccids[item.CardID])) + } + return list, nil +} + +// loadICCID 按本批卡ID一次投影 ICCID,已删除的卡不返回,由响应保留空字符串。 +func (q *Query) loadICCID(ctx context.Context, items []*model.PollingPriorityItem) map[uint]string { + cardIDs := make([]uint, 0, len(items)) + seen := make(map[uint]bool, len(items)) + for _, item := range items { + if item == nil || seen[item.CardID] { + continue + } + seen[item.CardID] = true + cardIDs = append(cardIDs, item.CardID) + } + result := make(map[uint]string, len(cardIDs)) + if len(cardIDs) == 0 { + return result + } + type cardICCIDRow struct { + ID uint `gorm:"column:id"` + ICCID string `gorm:"column:iccid"` + } + var rows []cardICCIDRow + if err := q.db.WithContext(ctx). + Model(&model.IotCard{}). + Select("id, iccid"). + Where("id IN ? AND deleted_at IS NULL", cardIDs). + Scan(&rows).Error; err != nil { + // 卡号只用于展示,查询失败降级为空号,不影响读取优先轮询事实。 + return result + } + for _, row := range rows { + result[row.ID] = row.ICCID + } + return result +} + +// priorityPollingShopScope 返回当前账号可访问的店铺快照范围,并完成读侧鉴权。 +// +// 复用与人工入队同一套用户类型分类(internal/service/polling.PriorityPollingReadScope): +// 只有超级管理员与平台账号不受限(nil);企业账号与未预计算范围的账号一律拒绝。 +// 返回 nil 表示不受限,非 nil 时按列表下推,空切片表示空结果。 +func priorityPollingShopScope(ctx context.Context) ([]uint, error) { + return pollingsvc.PriorityPollingReadScope(ctx) +} + +// priorityPollingDeniedMessage 与人工入队共用同一拒绝文案。 +const priorityPollingDeniedMessage = "无权限操作该资源或资源不存在" + +// normalizePage 归一化分页参数并执行默认值与上限。 +func normalizePage(page, pageSize int) (int, int) { + if page < 1 { + page = constants.DefaultPage + } + if pageSize < 1 || pageSize > constants.MaxPageSize { + pageSize = constants.DefaultPageSize + } + return page, pageSize +} + +// toPriorityPollingItemResponse 把事实行投影为读侧 DTO,枚举按 constants 提供中文名称。 +func toPriorityPollingItemResponse(item *model.PollingPriorityItem, iccid string) *dto.PriorityPollingItemResponse { + if item == nil { + return nil + } + return &dto.PriorityPollingItemResponse{ + ID: item.ID, + CardID: item.CardID, + ICCID: iccid, + TaskType: item.TaskType, + TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType), + Status: item.Status, + StatusName: constants.PollingPriorityStatusName(item.Status), + TriggerType: item.TriggerType, + TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType), + TriggerTypes: strings.Trim(item.TriggerTypes, ","), + TriggerCount: item.TriggerCount, + LastTriggeredAt: item.LastTriggeredAt, + SourceOrderID: item.SourceOrderID, + SourcePackageUse: item.SourcePackageUsageID, + AttemptCount: item.AttemptCount, + ClaimedAt: item.ClaimedAt, + ManualReason: item.ManualReason, + ManualOperator: item.ManualOperatorID, + ManualOperatorNm: item.ManualOperatorName, + ShopIDSnapshot: item.ShopIDSnapshot, + Result: item.Result, + ResultName: constants.PollingPriorityResultName(item.Result), + FailureReason: item.FailureReason, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 475c7a63..ad92730b 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -139,6 +139,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PollingManualTrigger != nil { registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath) } + if handlers.PollingPriority != nil { + registerPollingPriorityRoutes(authGroup, handlers.PollingPriority, doc, basePath) + } if handlers.Asset != nil { registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath) registerPackageExpiryRoutes(authGroup, handlers.Asset, doc, basePath) diff --git a/internal/routes/polling_priority.go b/internal/routes/polling_priority.go new file mode 100644 index 00000000..fe9cc27a --- /dev/null +++ b/internal/routes/polling_priority.go @@ -0,0 +1,46 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerPollingPriorityRoutes 注册卡轮询优先队列的人工入队与只读查询路由。 +// 仅挂在既有后台认证中间件组下,不新增认证边界。 +func registerPollingPriorityRoutes(router fiber.Router, handler *admin.PriorityPollingHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/polling-priority-items") + groupPath := basePath + "/polling-priority-items" + + Register(group, doc, groupPath, "POST", "", handler.Enqueue, RouteSpec{ + Summary: "人工优先入队", + Description: "为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项。" + + "同一卡同一任务类型至多一条活动项,重复入队合并触发次数与来源集合;原因必填。" + + "不受既有人工触发的每日次数上限与 24 小时去重约束;不修改调度优先级,也不绕过既有并发上限。", + Tags: []string{"轮询管理-优先队列"}, + Input: new(dto.EnqueuePriorityPollingRequest), + Output: new(dto.EnqueuePriorityPollingResponse), + Auth: true, + }) + // 字面量路径必须先于 /:id 注册,保证路径按字面量优先匹配。 + Register(group, doc, groupPath, "GET", "", handler.ListPriorityItems, RouteSpec{ + Summary: "查询优先轮询项列表", + Description: "分页查询卡轮询优先项,按创建时间倒序,支持卡、任务类型、状态与触发类型筛选。" + + "数据范围按卡所属店铺快照下推:代理只能看到自身及下级店铺资产,平台卡不可见。", + Tags: []string{"轮询管理-优先队列"}, + Input: new(dto.PriorityPollingItemListRequest), + Output: new(dto.PriorityPollingItemListResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/:id", handler.GetPriorityItem, RouteSpec{ + Summary: "查询优先轮询项详情", + Description: "查询单条卡轮询优先项。越权与不存在返回同一响应,不产生可枚举差异。" + + "不提供优先级分级、有效期与人工重触发入口。", + Tags: []string{"轮询管理-优先队列"}, + Input: new(dto.IDReq), + Output: new(dto.PriorityPollingItemResponse), + Auth: true, + }) +} diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 3c1ff778..123ccce8 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -7,6 +7,7 @@ import ( "time" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" "github.com/google/uuid" "github.com/redis/go-redis/v9" @@ -17,6 +18,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -49,6 +51,7 @@ type StopResumeService struct { logger *zap.Logger pollingCallback PollingCallback observationSeriesEvents cardObservationApp.SeriesEventWriter + priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer integration *integrationlog.Repository @@ -70,6 +73,11 @@ func (s *StopResumeService) SetUnifiedAudit(writer *audit.Writer, integration *i s.integration = integration } +// SetPriorityEventWriter 注入「资产无有效套餐」优先轮询请求 Outbox Writer。 +func (s *StopResumeService) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) { + s.priorityEvents = writer +} + // NewStopResumeService 创建停复机服务 func NewStopResumeService( redis *redis.Client, @@ -111,6 +119,12 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC if len(reasons) == 0 { return nil } + // 无有效套餐且存在待生效套餐使用记录:仅由普通套餐轮询来源追加优先轮询请求。 + if containsStopReason(reasons, constants.StopReasonNoPackage) { + if err := s.appendNoValidPackagePriority(ctx, card); err != nil { + return err + } + } // 取最高优先级原因(第一个) primaryReason := reasons[0] if !card.IsStandalone && isDeviceScopeReason(primaryReason) { @@ -174,6 +188,45 @@ func isPollingStopReason(reason string) bool { return false } +// containsStopReason 判断停机原因集合是否包含指定原因。 +func containsStopReason(reasons []string, target string) bool { + for _, reason := range reasons { + if reason == target { + return true + } + } + return false +} + +// appendNoValidPackagePriority 在套餐轮询来源下,为无有效套餐且存在待生效套餐使用记录的载体追加优先轮询请求。 +// +// 三个前置条件缺一不可: +// 1. 已注入优先轮询 Writer; +// 2. 本次评估来自普通套餐轮询(观测消费者、手动实名、保护期轮询等调用链不打标,一律不写); +// 3. 该载体存在待生效的套餐使用记录,否则每个普通套餐周期都重新入队会形成自激循环。 +// +// 事件资源为该载体对应的资产(独立卡为自身,绑定设备为设备),卡快照由 Writer 在同一事务内冻结。 +func (s *StopResumeService) appendNoValidPackagePriority(ctx context.Context, card *model.IotCard) error { + if s.priorityEvents == nil || card == nil || s.db == nil || !priorityapp.IsPollingPriorityTrigger(ctx) { + return nil + } + carrierType, carrierID, err := s.resolvePackageCarrier(ctx, card) + if err != nil { + return err + } + now := time.Now() + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + hasPending, pendingErr := packagepkg.HasPendingPackageUsage(ctx, tx, carrierType, carrierID) + if pendingErr != nil { + return pendingErr + } + if !hasPending { + return nil + } + return packagepkg.AppendNoValidPackagePriorityRequested(ctx, tx, s.priorityEvents, carrierType, carrierID, now) + }) +} + // isDeviceScopeReason 判断停机原因是否应扩散到设备维度 // 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡 func isDeviceScopeReason(reason string) bool { @@ -200,19 +253,29 @@ func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.Iot return binding.DeviceID, true, nil } -// hasValidPackage 检查卡是否有有效套餐(status IN 0,1) -// 修复Bug1:绑定设备的卡查 device_id,独立卡查 iot_card_id -func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) { +// resolvePackageCarrier 解析卡对应的套餐载体:非独立卡优先取绑定设备,其余取卡自身。 +// 与 hasValidPackage/isTrafficExhausted 的既有口径一致,供优先轮询触发与停复机判定共用。 +func (s *StopResumeService) resolvePackageCarrier(ctx context.Context, card *model.IotCard) (string, uint, error) { if !card.IsStandalone { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { - return false, lookupErr + return "", 0, lookupErr } if bound { - return s.packageUsageStore.HasValidByCarrier(ctx, "device", deviceID) + return constants.AssetTypeDevice, deviceID, nil } } - return s.packageUsageStore.HasValidByCarrier(ctx, "iot_card", card.ID) + return constants.AssetTypeIotCard, card.ID, nil +} + +// hasValidPackage 检查卡是否有有效套餐(status IN 0,1) +// 修复Bug1:绑定设备的卡查 device_id,独立卡查 iot_card_id +func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) { + carrierType, carrierID, err := s.resolvePackageCarrier(ctx, card) + if err != nil { + return false, err + } + return s.packageUsageStore.HasValidByCarrier(ctx, carrierType, carrierID) } // isTrafficExhausted 检查卡的流量是否已耗尽 diff --git a/internal/service/order/service.go b/internal/service/order/service.go index d40b1b7a..fa185ed2 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -13,6 +13,7 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" @@ -76,6 +77,7 @@ type Service struct { agentWalletDebit *walletapp.DebitService agentWalletReservation *walletapp.ReservationService observationSeriesEvents cardObservationApp.SeriesEventWriter + priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer paymentIntegration *integrationlog.Repository billCreation *employeecollectionapp.BillCreationService @@ -91,6 +93,11 @@ func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.Seri s.observationSeriesEvents = writer } +// SetPriorityEventWriter 注入购包生效后的优先轮询请求 Outbox Writer。 +func (s *Service) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) { + s.priorityEvents = writer +} + // SetAgentWalletDebitService 注入代理主钱包统一扣款用例。 func (s *Service) SetAgentWalletDebitService(service *walletapp.DebitService) { s.agentWalletDebit = service @@ -2640,6 +2647,17 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败") } + // 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于本 Change 的触发场景。 + if status == constants.PackageUsageStatusActive { + triggerType, classifyErr := packagepkg.ResolveActivationTriggerType(ctx, tx, carrierType, carrierID, usage.ID) + if classifyErr != nil { + return classifyErr + } + if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID, triggerType, activatedAt); err != nil { + return err + } + } + return nil } @@ -2722,6 +2740,12 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * return errors.Wrap(errors.CodeDatabaseError, err, "创建加油包使用记录失败") } + // 加油包立即生效:以生效状态提交后追加优先轮询请求。 + if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID, + constants.PollingPriorityTriggerAddonActivated, activatedAt); err != nil { + return err + } + return nil } diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index f409300f..2c521ca9 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -6,6 +6,7 @@ import ( "time" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -32,6 +33,7 @@ type ActivationService struct { logger *zap.Logger resumeCallback ResumeCallback // 复机回调,可选 observationSeriesEvents cardObservationApp.SeriesEventWriter + priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer } @@ -45,6 +47,11 @@ func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservati s.observationSeriesEvents = writer } +// SetPriorityEventWriter 注入套餐生效后的优先轮询请求 Outbox Writer。 +func (s *ActivationService) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) { + s.priorityEvents = writer +} + func NewActivationService( db *gorm.DB, redis *redis.Client, @@ -683,6 +690,13 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil { return err } + // 排队主套餐顺延生效:仅 queue 来源追加优先轮询请求,specific 与 realname 来源不属于触发场景。 + if activationSource == "queue" { + if err := AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID, + constants.PollingPriorityTriggerQueueActivated, now); err != nil { + return err + } + } if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{ Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage), }}, nil, map[string]any{"activation_source": activationSource}); err != nil { diff --git a/internal/service/package/priority.go b/internal/service/package/priority.go new file mode 100644 index 00000000..f1c56cbb --- /dev/null +++ b/internal/service/package/priority.go @@ -0,0 +1,163 @@ +package packagepkg + +import ( + "context" + "strconv" + "time" + + "gorm.io/gorm" + + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// 本文件集中承载卡轮询优先队列在套餐域的两个判定与两条可靠事件写入: +// 生效触发(新购/续购/加油包/排队顺延)与「资产无有效套餐」触发。 +// 事件 ID 命名空间与载荷形状只在本文件构造,调用点不得各自拼装,避免同一决策出现多份实现。 + +// ResolveActivationTriggerType 判定主套餐本次生效属于新购还是续购。 +// 依据是该载体在本次生效前是否存在更早的套餐使用记录,而不是当前是否存在在用主套餐: +// 过期后续购时已无在用主套餐,用「当前在用」判定会误判为新购。 +func ResolveActivationTriggerType(ctx context.Context, tx *gorm.DB, carrierType string, carrierID, usageID uint) (string, error) { + hasEarlier, err := HasEarlierPackageUsage(ctx, tx, carrierType, carrierID, usageID) + if err != nil { + return "", err + } + if hasEarlier { + return constants.PollingPriorityTriggerRenewalActivated, nil + } + return constants.PollingPriorityTriggerPurchaseActivated, nil +} + +// HasEarlierPackageUsage 判断该载体在本次生效前是否已存在更早的套餐使用记录。 +// 查询与本次记录同载体,并排除本次记录自身。 +func HasEarlierPackageUsage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID, excludeUsageID uint) (bool, error) { + if tx == nil || carrierID == 0 { + return false, errors.New(errors.CodeInvalidParam, "查询更早套餐使用记录缺少载体") + } + query, err := filterPackageUsageByCarrier(tx.WithContext(ctx).Model(&model.PackageUsage{}), carrierType, carrierID) + if err != nil { + return false, err + } + if excludeUsageID != 0 { + query = query.Where("id <> ?", excludeUsageID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询更早套餐使用记录失败") + } + return count > 0, nil +} + +// HasPendingPackageUsage 判断该载体是否存在待生效的套餐使用记录。 +// +// 这是「资产无有效套餐」场景触发优先轮询的前置条件:没有待同步的生效业务时, +// 每个普通套餐轮询周期都重新入队只会形成自激循环,因此必须以此条件收敛。 +func HasPendingPackageUsage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { + if tx == nil || carrierID == 0 { + return false, errors.New(errors.CodeInvalidParam, "查询待生效套餐使用记录缺少载体") + } + query, err := filterPackageUsageByCarrier(tx.WithContext(ctx).Model(&model.PackageUsage{}), carrierType, carrierID) + if err != nil { + return false, err + } + var count int64 + if err := query.Where("status = ?", constants.PackageUsageStatusPending).Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询待生效套餐使用记录失败") + } + return count > 0, nil +} + +// AppendActivatedPriorityRequested 在套餐生效事务内追加「套餐生效」优先轮询请求。 +// +// triggerType 只接受 purchase_activated/renewal_activated/queue_activated/addon_activated; +// 调用点必须传入生效状态分支所在的业务事务,禁止在事务提交后另行投递。 +func AppendActivatedPriorityRequested( + ctx context.Context, + tx *gorm.DB, + writer priorityapp.PriorityEventWriter, + usage *model.PackageUsage, + carrierType string, + carrierID uint, + triggerType string, + occurredAt time.Time, +) error { + if writer == nil { + return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置") + } + if usage == nil || usage.ID == 0 || carrierID == 0 { + return errors.New(errors.CodeInvalidParam, "优先轮询事件缺少套餐使用记录或载体") + } + // 事件 ID 只承担「全局唯一 + 重投稳定」:触发类型与来源已写入事件载荷与事实行,不再重复编码, + // 以把长度压进预算(见 constants.PollingPriorityEventKeyPrefix 的长度说明)。 + // 一条套餐使用记录的生效是单向一次性事实,因此 usage ID 本身即唯一键。 + eventID := constants.PollingPriorityEventKeyPrefix + "usage:" + strconv.FormatUint(uint64(usage.ID), 10) + return writer.AppendPriorityRequested(ctx, tx, priorityapp.PriorityRequestedEvent{ + EventID: eventID, + ResourceType: carrierType, + ResourceID: carrierID, + TriggerType: triggerType, + SourceOrderID: usage.OrderID, + SourcePackageUsageID: usage.ID, + OccurredAt: occurredAt, + RequestID: eventID, + CorrelationID: eventID, + }) +} + +// AppendNoValidPackagePriorityRequested 在触发事务内追加「资产无有效套餐」优先轮询请求。 +// +// 事件 ID 以秒级时间戳结尾:本场景在待同步业务持续存在时每个普通套餐周期重复触发, +// 稳定键会让第二次触发起被 Outbox 去重而永久失效(如优先项已终态出队后将无法再次加急), +// 而同一秒内的并发重复触发共享一个事件,等价于同一轮询周期的合并。 +func AppendNoValidPackagePriorityRequested( + ctx context.Context, + tx *gorm.DB, + writer priorityapp.PriorityEventWriter, + carrierType string, + carrierID uint, + occurredAt time.Time, +) error { + if writer == nil { + return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置") + } + if carrierID == 0 { + return errors.New(errors.CodeInvalidParam, "优先轮询事件缺少载体") + } + // 载体类型压缩为单字母代码(c-iot_card d-device):完整载体类型与资源 ID 已随事件载体与载荷传递, + // 此处只为把长度压进预算(见 constants.PollingPriorityEventKeyPrefix 的长度说明)。 + eventID := constants.PollingPriorityEventKeyPrefix + "nvp:" + pollingPriorityCarrierCode(carrierType) + ":" + + strconv.FormatUint(uint64(carrierID), 10) + ":" + strconv.FormatInt(occurredAt.Unix(), 10) + return writer.AppendPriorityRequested(ctx, tx, priorityapp.PriorityRequestedEvent{ + EventID: eventID, + ResourceType: carrierType, + ResourceID: carrierID, + TriggerType: constants.PollingPriorityTriggerNoValidPackage, + OccurredAt: occurredAt, + RequestID: eventID, + CorrelationID: eventID, + }) +} + +// pollingPriorityCarrierCode 把载体类型压缩为事件 ID 中使用的单字母代码。 +func pollingPriorityCarrierCode(carrierType string) string { + if carrierType == constants.AssetTypeDevice { + return "d" + } + return "c" +} + +// filterPackageUsageByCarrier 按载体类型过滤套餐使用记录。 +// 载体口径与卡的有效套餐判定一致:device 查设备载体,iot_card 查卡载体。 +func filterPackageUsageByCarrier(query *gorm.DB, carrierType string, carrierID uint) (*gorm.DB, error) { + switch carrierType { + case constants.AssetTypeDevice: + return query.Where("device_id = ?", carrierID), nil + case constants.AssetTypeIotCard: + return query.Where("iot_card_id = ?", carrierID), nil + default: + return nil, errors.New(errors.CodeInvalidParam, "不支持的套餐载体类型:只接受 iot_card 或 device") + } +} diff --git a/internal/service/polling/manual_trigger_service.go b/internal/service/polling/manual_trigger_service.go index ab69e0ca..e9661030 100644 --- a/internal/service/polling/manual_trigger_service.go +++ b/internal/service/polling/manual_trigger_service.go @@ -55,8 +55,8 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t return errors.New(errors.CodeInvalidParam, "无效的任务类型") } - // 权限验证:检查用户是否有权管理该卡 - if err := s.canManageCard(ctx, cardID); err != nil { + // 权限验证:检查用户是否有权管理该卡(与人工优先入队共用同包共享判定) + if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil { return err } cards, err := s.iotCardStore.GetByIDs(ctx, []uint{cardID}) @@ -183,8 +183,8 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint, return nil, errors.New(errors.CodeInvalidParam, "单次最多触发1000张卡") } - // 权限验证:检查用户是否有权管理所有卡 - if err := s.canManageCards(ctx, cardIDs); err != nil { + // 权限验证:检查用户是否有权管理所有卡(与人工优先入队共用同包共享判定) + if err := canManagePollingCards(ctx, s.iotCardStore, cardIDs); err != nil { return nil, err } cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) @@ -527,85 +527,9 @@ func isValidTaskType(taskType string) bool { } } -// checkUserTypePermission 检查用户类型是否有手动触发权限 -// 返回 skip=true 表示超级管理员/平台用户直接放行 -// 返回 err!=nil 表示企业账号无权限 -// 返回 skip=false, err=nil 表示代理账号需继续细粒度检查 -func (s *ManualTriggerService) checkUserTypePermission(ctx context.Context) (skip bool, err error) { - userType := middleware.GetUserTypeFromContext(ctx) - if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform { - return true, nil - } - if userType == constants.UserTypeEnterprise { - return false, errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询") - } - return false, nil -} - -// canManageCard 检查用户是否有权管理单张卡 -func (s *ManualTriggerService) canManageCard(ctx context.Context, cardID uint) error { - skip, err := s.checkUserTypePermission(ctx) - if err != nil || skip { - return err - } - - // 代理账号只能管理自己店铺及下级店铺的卡 - card, err := s.iotCardStore.GetByID(ctx, cardID) - if err != nil { - return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在") - } - - // 平台卡(ShopID为nil)代理不能管理 - if card.ShopID == nil { - return errors.New(errors.CodeForbidden, "无权限操作平台卡") - } - - // 检查代理是否有权管理该店铺 - return middleware.CanManageShop(ctx, *card.ShopID) -} - -// canManageCards 检查用户是否有权管理多张卡 -func (s *ManualTriggerService) canManageCards(ctx context.Context, cardIDs []uint) error { - skip, err := s.checkUserTypePermission(ctx) - if err != nil || skip { - return err - } - - // 从 Context 获取预计算的下级店铺 ID 列表 - subordinateIDs := middleware.GetSubordinateShopIDs(ctx) - if subordinateIDs == nil { - // 平台用户/超管不受限制,但这里不应该进入(前面已经检查过用户类型) - return errors.New(errors.CodeForbidden, "无权限操作") - } - - // 构建可管理的店铺ID集合 - allowedShopIDs := make(map[uint]bool) - for _, id := range subordinateIDs { - allowedShopIDs[id] = true - } - - // 批量查询卡信息 - cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) - if err != nil { - return errors.Wrap(errors.CodeForbidden, err, "查询卡信息失败") - } - - // 验证所有卡都在可管理范围内 - for _, card := range cards { - if card.ShopID == nil { - return errors.New(errors.CodeForbidden, "无权限操作平台卡") - } - if !allowedShopIDs[*card.ShopID] { - return errors.New(errors.CodeForbidden, "包含无权限操作的卡") - } - } - - return nil -} - // applyShopPermissionFilter 应用店铺权限过滤(代理只能筛选自己管理的卡) func (s *ManualTriggerService) applyShopPermissionFilter(ctx context.Context, filter *ConditionFilter) error { - skip, err := s.checkUserTypePermission(ctx) + skip, err := pollingUserTypePermission(ctx) if err != nil || skip { return err } diff --git a/internal/service/polling/permission.go b/internal/service/polling/permission.go new file mode 100644 index 00000000..1beb2110 --- /dev/null +++ b/internal/service/polling/permission.go @@ -0,0 +1,115 @@ +package polling + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// 本文件承载人工轮询入口共享的权限判定:既有人工触发与人工优先入队必须使用同一套语义, +// 因此判定只在此处实现一次,调用方不得各自内联,避免权限口径漂移。 + +// pollingUserTypePermission 检查用户类型是否有手动轮询权限。 +// 返回 skip=true 表示超级管理员/平台用户直接放行; +// 返回 err!=nil 表示企业账号无权限; +// 返回 skip=false, err=nil 表示代理账号需继续细粒度检查。 +func pollingUserTypePermission(ctx context.Context) (skip bool, err error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform { + return true, nil + } + if userType == constants.UserTypeEnterprise { + return false, errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询") + } + return false, nil +} + +// pollingDeniedMessage 是优先轮询入口对外统一的拒绝文案:越权与不存在共用,不产生可枚举差异。 +const pollingDeniedMessage = "无权限操作该资源或资源不存在" + +// PriorityPollingReadScope 返回卡轮询优先项读侧可访问的店铺快照范围。 +// +// 判定顺序与人工入队共用同一用户类型分类(pollingUserTypePermission): +// 1. 超级管理员与平台账号 → 不受限(返回 nil); +// 2. 企业账号 → 拒绝; +// 3. 其余(代理)→ 取认证中间件预计算的下级店铺集合;集合缺失(nil)是「未预计算」而不是 +// 「不受限」,一律按拒绝处理,绝不让非代理身份或缺失范围回退成全量读取。 +func PriorityPollingReadScope(ctx context.Context) ([]uint, error) { + skip, err := pollingUserTypePermission(ctx) + if err != nil { + // 企业账号同样不允许读取优先轮询事实;对外只暴露统一的不可枚举拒绝文案。 + return nil, errors.New(errors.CodeForbidden, pollingDeniedMessage) + } + if skip { + return nil, nil + } + shopIDs := middleware.GetSubordinateShopIDs(ctx) + if shopIDs == nil { + return nil, errors.New(errors.CodeForbidden, pollingDeniedMessage) + } + return shopIDs, nil +} + +// canManagePollingCard 检查用户是否有权管理单张卡。 +func canManagePollingCard(ctx context.Context, iotCardStore *postgres.IotCardStore, cardID uint) error { + skip, err := pollingUserTypePermission(ctx) + if err != nil || skip { + return err + } + + // 代理账号只能管理自己店铺及下级店铺的卡 + card, err := iotCardStore.GetByID(ctx, cardID) + if err != nil { + return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在") + } + + // 平台卡(ShopID为nil)代理不能管理 + if card.ShopID == nil { + return errors.New(errors.CodeForbidden, "无权限操作平台卡") + } + + // 检查代理是否有权管理该店铺 + return middleware.CanManageShop(ctx, *card.ShopID) +} + +// canManagePollingCards 检查用户是否有权管理多张卡。 +func canManagePollingCards(ctx context.Context, iotCardStore *postgres.IotCardStore, cardIDs []uint) error { + skip, err := pollingUserTypePermission(ctx) + if err != nil || skip { + return err + } + + // 从 Context 获取预计算的下级店铺 ID 列表 + subordinateIDs := middleware.GetSubordinateShopIDs(ctx) + if subordinateIDs == nil { + // 平台用户/超管不受限制,但这里不应该进入(前面已经检查过用户类型) + return errors.New(errors.CodeForbidden, "无权限操作") + } + + // 构建可管理的店铺ID集合 + allowedShopIDs := make(map[uint]bool) + for _, id := range subordinateIDs { + allowedShopIDs[id] = true + } + + // 批量查询卡信息 + cards, err := iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return errors.Wrap(errors.CodeForbidden, err, "查询卡信息失败") + } + + // 验证所有卡都在可管理范围内 + for _, card := range cards { + if card.ShopID == nil { + return errors.New(errors.CodeForbidden, "无权限操作平台卡") + } + if !allowedShopIDs[*card.ShopID] { + return errors.New(errors.CodeForbidden, "包含无权限操作的卡") + } + } + + return nil +} diff --git a/internal/service/polling/priority_enqueue_service.go b/internal/service/polling/priority_enqueue_service.go new file mode 100644 index 00000000..ad034dac --- /dev/null +++ b/internal/service/polling/priority_enqueue_service.go @@ -0,0 +1,247 @@ +package polling + +import ( + "context" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// PriorityEnqueueService 人工优先入队用例。 +// +// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与 +// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担, +// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。 +type PriorityEnqueueService struct { + db *gorm.DB + iotCardStore *postgres.IotCardStore + priorityStore *postgres.PollingPriorityItemStore + publisher priorityapp.PromptPublisher + auditWriter *audit.Writer + logger *zap.Logger +} + +// NewPriorityEnqueueService 创建人工优先入队用例。 +func NewPriorityEnqueueService( + db *gorm.DB, + iotCardStore *postgres.IotCardStore, + priorityStore *postgres.PollingPriorityItemStore, + publisher priorityapp.PromptPublisher, + logger *zap.Logger, +) *PriorityEnqueueService { + return &PriorityEnqueueService{ + db: db, + iotCardStore: iotCardStore, + priorityStore: priorityStore, + publisher: publisher, + logger: logger, + } +} + +// SetAudit 注入统一审计 Writer。 +func (s *PriorityEnqueueService) SetAudit(writer *audit.Writer) { + s.auditWriter = writer +} + +// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖该卡的全部纳入任务类型。 +type EnqueueResult struct { + CardID uint + TaskTypes []string + CreatedCount int + MergedCount int + Items []EnqueueItemResult +} + +// EnqueueItemResult 是单个任务类型的入队结果。 +type EnqueueItemResult struct { + ItemID uint + TaskType string + Status string + TriggerCount int + LastTriggeredAt time.Time + Created bool +} + +// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。 +// +// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes), +// 与执行集合一致;入队对象是卡,不做设备维度入队。 +func (s *PriorityEnqueueService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) { + if cardID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID") + } + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因不能为空") + } + if len([]rune(trimmedReason)) > constants.PollingPriorityManualReasonMaxLength { + return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因长度超出上限") + } + + // 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。 + if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil { + return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + card, err := s.iotCardStore.GetByID(ctx, cardID) + if err != nil || card == nil { + return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + operatorName := middleware.GetUsernameFromContext(ctx) + taskTypes := constants.PollingPriorityTaskTypes() + result := &EnqueueResult{CardID: cardID, TaskTypes: taskTypes} + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + store := s.priorityStore.WithTx(tx) + for _, taskType := range taskTypes { + item := &model.PollingPriorityItem{ + CardID: cardID, + TaskType: taskType, + TriggerType: constants.PollingPriorityTriggerManual, + ManualReason: trimmedReason, + ManualOperatorID: operatorID, + ManualOperatorName: operatorName, + ShopIDSnapshot: card.ShopID, + } + // 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。 + // 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行, + // 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。 + before, beforeErr := store.FindActive(ctx, cardID, taskType) + if beforeErr != nil { + return beforeErr + } + created, insertErr := store.InsertOrMerge(ctx, item) + if insertErr != nil { + return insertErr + } + active, activeErr := store.FindActive(ctx, cardID, taskType) + if activeErr != nil { + return activeErr + } + if active == nil { + return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项") + } + if created { + result.CreatedCount++ + } else { + result.MergedCount++ + } + result.Items = append(result.Items, EnqueueItemResult{ + ItemID: active.ID, + TaskType: active.TaskType, + Status: active.Status, + TriggerCount: active.TriggerCount, + LastTriggeredAt: active.LastTriggeredAt, + Created: created, + }) + // 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。 + if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{ + ActionCode: constants.AuditActionPollingPriorityEnqueued, + Summary: "人工优先入队", + ResourceType: constants.AuditResourcePollingPriorityItem, + ResourceID: active.ID, + ResourceKey: pollingPriorityResourceKey(active.ID), + DisplayName: "卡轮询优先项", + OperatorID: operatorID, + IdentitySnapshot: map[string]any{ + "id": active.ID, "card_id": active.CardID, "task_type": active.TaskType, + "status": active.Status, "trigger_type": active.TriggerType, + "trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID, + }, + BeforeData: pollingPriorityBeforeData(before), + AfterData: map[string]any{ + "status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes), + "trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt, + "manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID, + "source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(), + }, + Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": cardID}, + Cards: []*model.IotCard{card}, + }); auditErr != nil { + return auditErr + } + } + return nil + }) + if err != nil { + return nil, err + } + + // 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。 + if s.publisher == nil { + s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", cardID)) + } else { + for _, taskType := range taskTypes { + if publishErr := s.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil { + // 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。 + s.logger.Error("下发优先轮询执行提示失败", + zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(publishErr)) + } + } + } + s.logger.Info("人工优先入队成功", + zap.Uint("card_id", cardID), zap.Int("created_count", result.CreatedCount), + zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", operatorID)) + return result, nil +} + +// deny 记录一次人工优先入队被拒绝的审计,并返回原错误。 +// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。 +func (s *PriorityEnqueueService) deny(ctx context.Context, cardID, operatorID uint, card *model.IotCard, code int, summary string) error { + appErr := errors.New(code, summary) + identity := map[string]any{"card_id": cardID, "manual_operator_id": operatorID} + var cards []*model.IotCard + if card != nil { + cards = []*model.IotCard{card} + } + recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{ + ActionCode: constants.AuditActionPollingPriorityManualDenied, + Summary: summary, + ResourceType: constants.AuditResourcePollingPriorityItem, + ResourceKey: pollingPriorityAttemptKey(cardID, operatorID), + DisplayName: "人工优先入队", + OperatorID: operatorID, + Result: constants.AuditResultDenied, + IdentitySnapshot: identity, + Cards: cards, + }, appErr) + return appErr +} + +// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。 +func pollingPriorityResourceKey(itemID uint) string { + return strconv.FormatUint(uint64(itemID), 10) +} + +// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。 +func pollingPriorityAttemptKey(cardID, operatorID uint) string { + return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10) +} + +// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。 +func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any { + if before == nil { + return map[string]any{} + } + return map[string]any{ + "status": before.Status, + "trigger_type": before.TriggerType, + "trigger_types": triggerTypesReadable(before.TriggerTypes), + "trigger_count": before.TriggerCount, + } +} + +// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。 +func triggerTypesReadable(raw string) string { + return strings.Trim(raw, ",") +} diff --git a/internal/store/postgres/polling_priority_item_store.go b/internal/store/postgres/polling_priority_item_store.go new file mode 100644 index 00000000..00cc52d2 --- /dev/null +++ b/internal/store/postgres/polling_priority_item_store.go @@ -0,0 +1,383 @@ +package postgres + +import ( + "context" + stderrors "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// pollingPriorityActiveConstraint 是活动项部分唯一索引名,用于把 23505 精确识别为 +// 「该卡该任务类型已有活动优先项」。该索引是部分唯一索引,谓词未声明时 GORM OnConflict +// 无法命中(KNOWN-ISSUE-001),因此入队一律走「显式插入 + 23505 识别」,不用 OnConflict。 +const pollingPriorityActiveConstraint = "uq_polling_priority_item_active" + +// PollingPriorityItemListFilter 是优先轮询项列表的筛选与分页条件。 +// +// ShopIDSnapshot 是数据范围下推:nil 表示不受限(超级管理员与平台账号), +// 非 nil 时按店铺快照过滤,空切片表示空范围(无可见行)。平台卡的店铺快照为空, +// 因此空范围与平台卡都不会被代理账号看到,越权与不存在由调用方收敛为同一响应。 +type PollingPriorityItemListFilter struct { + CardID *uint + TaskType *string + Status *string + TriggerType *string + ShopIDSnapshot []uint + Page int + PageSize int +} + +// PollingPriorityItemStore 卡轮询优先队列事实存储。 +type PollingPriorityItemStore struct { + db *gorm.DB +} + +// NewPollingPriorityItemStore 创建优先轮询项存储实例。 +func NewPollingPriorityItemStore(db *gorm.DB) *PollingPriorityItemStore { + return &PollingPriorityItemStore{db: db} +} + +// WithTx 返回绑定指定事务的优先轮询项存储,供「业务事实与入队事实同事务」的调用方复用。 +func (s *PollingPriorityItemStore) WithTx(tx *gorm.DB) *PollingPriorityItemStore { + return &PollingPriorityItemStore{db: tx} +} + +// InsertOrMerge 以 (card_id, task_type) 为活动项唯一键合并入队。 +// +// 先显式插入;命中活动项部分唯一索引(23505)时把本次触发合并进已有活动行: +// 追加触发次数、刷新最近触发时间、并入触发类型集合,并补齐可空来源与人工字段, +// 不新建行、不重复调用。返回 created 表示本次是否新建了活动项。 +// +// 采用有界两轮循环,覆盖「插入冲突 → 合并落空」的竞态窗口: +// 第一轮插入冲突说明存在活动项;若此刻该行恰好被并发执行终态化,合并会命中 0 行, +// 此时必须重试插入;第二轮若再次冲突,说明活动项在此期间又被建立,必须再合并一次, +// 否则这一次触发事实会被丢掉(触发次数与来源集合少记一次)。 +func (s *PollingPriorityItemStore) InsertOrMerge(ctx context.Context, item *model.PollingPriorityItem) (bool, error) { + if err := normalizePollingPriorityItem(item); err != nil { + return false, err + } + for range 2 { + createErr := s.insertWithinSavepoint(ctx, item) + if createErr == nil { + return true, nil + } + if !isPollingPriorityActiveConflict(createErr) { + return false, errors.Wrap(errors.CodeDatabaseError, createErr, "创建优先轮询项失败") + } + merged, mergeErr := s.mergeActive(ctx, item) + if mergeErr != nil { + return false, mergeErr + } + if merged { + return false, nil + } + // 冲突行已离开活动集合:清掉可能被回填的主键后重试插入。 + item.ID = 0 + } + // 两轮都是「插入冲突 + 合并落空」:活动项在此期间被并发建立又终态化,促成的加急需求 + // 已由那次终态化满足,按已合并结束,不报错也不新建行。 + return false, nil +} + +// insertWithinSavepoint 在保存点内执行优先项插入。 +// +// PostgreSQL 的唯一冲突会中止整个事务(后续语句必然 25P02,合并更新与同事务审计都会失败), +// 因此插入必须隔离在保存点内:GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT, +// 冲突只回滚本次插入,调用方事务仍然可用。调用方直接持有根连接池时,该嵌套事务 +// 本身就是一次完整事务,同样保证冲突不外溢。 +func (s *PollingPriorityItemStore) insertWithinSavepoint(ctx context.Context, item *model.PollingPriorityItem) error { + return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return inner.Create(item).Error + }) +} + +// mergeActive 把一次触发合并进已存在的活动行,返回是否命中活动行。 +func (s *PollingPriorityItemStore) mergeActive(ctx context.Context, item *model.PollingPriorityItem) (bool, error) { + now := time.Now() + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("card_id = ? AND task_type = ? AND deleted_at IS NULL AND status IN ?", + item.CardID, item.TaskType, + []string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}). + Updates(map[string]any{ + "trigger_count": gorm.Expr("trigger_count + 1"), + "last_triggered_at": now, + // 触发类型集合以「两端补分隔符」的规范形式存储,便于用 LIKE 精确判重后追加。 + "trigger_types": gorm.Expr( + "CASE WHEN trigger_types LIKE ? THEN trigger_types ELSE trigger_types || ? END", + "%,"+item.TriggerType+",%", item.TriggerType+","), + "source_order_id": gorm.Expr("COALESCE(source_order_id, ?)", optionalUintArg(item.SourceOrderID)), + "source_package_usage_id": gorm.Expr("COALESCE(source_package_usage_id, ?)", optionalUintArg(item.SourcePackageUsageID)), + "manual_reason": gorm.Expr("CASE WHEN ? <> '' THEN ? ELSE manual_reason END", item.ManualReason, item.ManualReason), + "manual_operator_id": gorm.Expr("CASE WHEN ? > 0 THEN ? ELSE manual_operator_id END", item.ManualOperatorID, item.ManualOperatorID), + "manual_operator_name": gorm.Expr("CASE WHEN ? <> '' THEN ? ELSE manual_operator_name END", item.ManualOperatorName, item.ManualOperatorName), + "shop_id_snapshot": gorm.Expr("COALESCE(shop_id_snapshot, ?)", optionalUintArg(item.ShopIDSnapshot)), + "updated_at": now, + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "合并优先轮询项失败") + } + return result.RowsAffected > 0, nil +} + +// FindActive 查询该卡该任务类型的活动优先项:不存在返回 nil。 +func (s *PollingPriorityItemStore) FindActive(ctx context.Context, cardID uint, taskType string) (*model.PollingPriorityItem, error) { + if cardID == 0 || strings.TrimSpace(taskType) == "" { + return nil, nil + } + var item model.PollingPriorityItem + err := s.db.WithContext(ctx). + Where("card_id = ? AND task_type = ? AND status IN ?", cardID, taskType, + []string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}). + First(&item).Error + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动优先轮询项失败") + } + return &item, nil +} + +// ListActiveByCards 批量查询指定卡在该任务类型下的活动优先项,供轮询执行前一次性探测。 +func (s *PollingPriorityItemStore) ListActiveByCards(ctx context.Context, cardIDs []uint, taskType string) ([]*model.PollingPriorityItem, error) { + if len(cardIDs) == 0 || strings.TrimSpace(taskType) == "" { + return nil, nil + } + var items []*model.PollingPriorityItem + if err := s.db.WithContext(ctx). + Where("card_id IN ? AND task_type = ? AND status IN ?", cardIDs, taskType, + []string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}). + Find(&items).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询活动优先轮询项失败") + } + return items, nil +} + +// ClaimPending 以条件更新认领待执行优先项:仅当该项由待执行更新为执行中且命中一行时视为领取成功。 +// 返回 nil 表示本轮未领取(无待执行项,或已被其它执行抢先领取),调用方按既有间隔延后即可。 +func (s *PollingPriorityItemStore) ClaimPending(ctx context.Context, cardID uint, taskType string, now time.Time) (*model.PollingPriorityItem, error) { + if cardID == 0 || strings.TrimSpace(taskType) == "" { + return nil, nil + } + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("card_id = ? AND task_type = ? AND status = ?", cardID, taskType, constants.PollingPriorityStatusPending). + Updates(map[string]any{ + "status": constants.PollingPriorityStatusProcessing, + "claimed_at": now, + "updated_at": now, + }) + if result.Error != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领优先轮询项失败") + } + if result.RowsAffected == 0 { + return nil, nil + } + return s.FindActive(ctx, cardID, taskType) +} + +// TakeOverExpiredClaim 接管超过认领租约的执行中项:崩溃的执行没有产生有效结果,重新执行是必要恢复。 +// 返回 nil 表示没有可接管的项(无执行中项,或租约仍未到期)。 +func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, cardID uint, taskType string, now time.Time, leaseSeconds int) (*model.PollingPriorityItem, error) { + if cardID == 0 || strings.TrimSpace(taskType) == "" { + return nil, nil + } + if leaseSeconds <= 0 { + leaseSeconds = constants.PollingPriorityClaimLease + } + deadline := now.Add(-time.Duration(leaseSeconds) * time.Second) + // 认领时间缺失的执行中行同样视为崩溃遗留:否则该项会因租约判定永远无法接管而被永久跳过。 + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("card_id = ? AND task_type = ? AND status = ? AND (claimed_at IS NULL OR claimed_at < ?)", + cardID, taskType, constants.PollingPriorityStatusProcessing, deadline). + Updates(map[string]any{ + "claimed_at": now, + "updated_at": now, + }) + if result.Error != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "接管超租约优先轮询项失败") + } + if result.RowsAffected == 0 { + return nil, nil + } + return s.FindActive(ctx, cardID, taskType) +} + +// IncrementAttempt 累加真实发起执行的次数。调用点必须在发起上游调用之前, +// 使崩溃遗留的执行中项在接管后仍能按次数上限收敛。 +func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing). + Updates(map[string]any{ + "attempt_count": gorm.Expr("attempt_count + 1"), + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "累加优先轮询项尝试次数失败") + } + return result.RowsAffected > 0, nil +} + +// MarkCompleted 把执行中的优先项置为完成终态并出队。 +func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing). + Updates(map[string]any{ + "status": constants.PollingPriorityStatusCompleted, + "result": constants.PollingPriorityResultSuccess, + "failure_reason": "", + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项完成状态失败") + } + return result.RowsAffected > 0, nil +} + +// MarkFailed 把执行中的优先项置为失败终态并出队,failureReason 只写可安全对外展示的原因。 +func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, failureReason string) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing). + Updates(map[string]any{ + "status": constants.PollingPriorityStatusFailed, + "result": constants.PollingPriorityResultFailed, + "failure_reason": failureReason, + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项失败状态失败") + } + return result.RowsAffected > 0, nil +} + +// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并按既有轮询间隔由调用方重新排期。 +// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约。 +func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}). + Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing). + Updates(map[string]any{ + "status": constants.PollingPriorityStatusPending, + "result": constants.PollingPriorityResultFailed, + "failure_reason": failureReason, + "claimed_at": nil, + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "退回优先轮询项活动状态失败") + } + return result.RowsAffected > 0, nil +} + +// GetByIDScoped 按主键读取优先项,并应用店铺快照数据范围。 +// shopIDs 为 nil 表示不受限(超级管理员与平台账号);非 nil 时空切片表示空范围,必然无结果。 +// 越权与不存在都返回 gorm.ErrRecordNotFound,由调用方收敛为同一响应,不产生可枚举差异。 +func (s *PollingPriorityItemStore) GetByIDScoped(ctx context.Context, id uint, shopIDs []uint) (*model.PollingPriorityItem, error) { + if id == 0 { + return nil, gorm.ErrRecordNotFound + } + query := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).Where("id = ?", id) + query = applyPollingPriorityShopScope(query, shopIDs) + var item model.PollingPriorityItem + if err := query.First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +// applyPollingPriorityShopScope 应用店铺快照数据范围:nil 不限、空切片等于空结果。 +func applyPollingPriorityShopScope(query *gorm.DB, shopIDs []uint) *gorm.DB { + if shopIDs == nil { + return query + } + if len(shopIDs) == 0 { + // 空范围是「无可见行」而不同于「不受限」,两者语义相反,必须显式区分。 + return query.Where("1 = 0") + } + return query.Where("shop_id_snapshot IN ?", shopIDs) +} + +// List 分页查询优先轮询项,按创建时间倒序;店铺快照为空的数据范围语义见 Filter 注释。 +func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPriorityItemListFilter) ([]*model.PollingPriorityItem, int64, error) { + query := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}) + if filter.CardID != nil { + query = query.Where("card_id = ?", *filter.CardID) + } + if filter.TaskType != nil && *filter.TaskType != "" { + query = query.Where("task_type = ?", *filter.TaskType) + } + if filter.Status != nil && *filter.Status != "" { + query = query.Where("status = ?", *filter.Status) + } + if filter.TriggerType != nil && *filter.TriggerType != "" { + query = query.Where("trigger_types LIKE ?", "%,"+*filter.TriggerType+",%") + } + if filter.ShopIDSnapshot != nil { + query = applyPollingPriorityShopScope(query, filter.ShopIDSnapshot) + } + + page, pageSize := normalizePollingPriorityPage(filter.Page, filter.PageSize) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计优先轮询项失败") + } + var items []*model.PollingPriorityItem + if err := query.Order("created_at DESC, id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&items).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项列表失败") + } + return items, total, nil +} + +// normalizePollingPriorityItem 收敛优先项自身的结构不变量,调用方只需给出业务字段。 +func normalizePollingPriorityItem(item *model.PollingPriorityItem) error { + if item == nil || item.CardID == 0 || strings.TrimSpace(item.TaskType) == "" || strings.TrimSpace(item.TriggerType) == "" { + return errors.New(errors.CodeInvalidParam, "优先轮询项缺少卡、任务类型或触发类型") + } + item.Status = constants.PollingPriorityStatusPending + item.TriggerTypes = "," + item.TriggerType + "," + item.TriggerCount = 1 + if item.LastTriggeredAt.IsZero() { + item.LastTriggeredAt = time.Now() + } + item.AttemptCount = 0 + item.ClaimedAt = nil + item.Result = "" + item.FailureReason = "" + return nil +} + +// normalizePollingPriorityPage 归一化分页参数并执行默认值与上限。 +func normalizePollingPriorityPage(page, pageSize int) (int, int) { + if page < 1 { + page = constants.DefaultPage + } + if pageSize < 1 || pageSize > constants.MaxPageSize { + pageSize = constants.DefaultPageSize + } + return page, pageSize +} + +// optionalUintArg 把可空 ID 转成驱动可直接绑定的参数:nil 以 NULL 参与 COALESCE,从而保留已有值, +// 避免依赖驱动对指针参数的隐式解引用。 +func optionalUintArg(value *uint) any { + if value == nil { + return nil + } + return *value +} + +// isPollingPriorityActiveConflict 把 23505 精确识别为活动项唯一键冲突。 +func isPollingPriorityActiveConflict(err error) bool { + var pgErr *pgconn.PgError + if !stderrors.As(err, &pgErr) { + return false + } + return pgErr.Code == "23505" && pgErr.ConstraintName == pollingPriorityActiveConstraint +} diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index 96426512..a4412335 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm/clause" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" @@ -51,6 +52,7 @@ type AutoPurchaseHandler struct { asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务 logger *zap.Logger observationSeriesEvents cardObservationApp.SeriesEventWriter + priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer } @@ -67,6 +69,7 @@ func NewAutoPurchaseHandler( asynqClient *asynq.Client, logger *zap.Logger, observationSeriesEvents cardObservationApp.SeriesEventWriter, + priorityEvents priorityapp.PriorityEventWriter, auditWriter *audit.Writer, ) *AutoPurchaseHandler { if orderStore == nil { @@ -103,6 +106,7 @@ func NewAutoPurchaseHandler( asynqClient: asynqClient, logger: logger, observationSeriesEvents: observationSeriesEvents, + priorityEvents: priorityEvents, auditWriter: auditWriter, } } @@ -712,10 +716,22 @@ func (h *AutoPurchaseHandler) activateMainPackage( return err } - return tx.Model(usage).Updates(map[string]any{ + if err = tx.Model(usage).Updates(map[string]any{ "status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation, - }).Error + }).Error; err != nil { + return err + } + + // 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于触发场景。 + if status == constants.PackageUsageStatusActive { + triggerType, classifyErr := packagepkg.ResolveActivationTriggerType(ctx, tx, carrierType, carrierID, usage.ID) + if classifyErr != nil { + return classifyErr + } + return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID, triggerType, activatedAt) + } + return nil } // isCarrierRealnamed 查询自动购包载体是否已满足实名激活条件。 @@ -822,7 +838,13 @@ func (h *AutoPurchaseHandler) activateAddonPackage( usage.DeviceID = carrierID } - return tx.Create(usage).Error + if err := tx.Create(usage).Error; err != nil { + return err + } + + // 加油包立即生效:以生效状态提交后追加优先轮询请求。 + return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID, + constants.PollingPriorityTriggerAddonActivated, now) } func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index 2411a7b8..3a4b3935 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -6,7 +6,9 @@ import ( "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" @@ -54,7 +56,7 @@ func pollingFallbackContext() (context.Context, context.CancelFunc) { } // PollingBase 轮询共享基类 -// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,所有 Handler 共享 +// 封装并发控制、卡缓存、重入队、配置间隔查询与优先轮询项认领等公共方法,所有 Handler 共享 type PollingBase struct { redis *redis.Client queueMgr *polling.PollingQueueManager @@ -64,6 +66,10 @@ type PollingBase struct { verboseLog bool totalMaxConcurrency int trafficLock *cardtrafficlock.Lock + db *gorm.DB + priorityStore *postgres.PollingPriorityItemStore + priorityAudit *audit.Writer + scopeChecker *polling.PollingLifecycleService } // NewPollingBase 创建轮询共享基类 @@ -72,6 +78,10 @@ func NewPollingBase( 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, @@ -85,6 +95,10 @@ func NewPollingBase( verboseLog: verboseLog, totalMaxConcurrency: totalMaxConcurrency, trafficLock: cardtrafficlock.New(redisClient), + db: db, + priorityStore: priorityStore, + priorityAudit: priorityAudit, + scopeChecker: scopeChecker, } } diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index f069f7cd..fb828112 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -49,29 +49,53 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e } defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken) + // 优先轮询互斥接缝:放在流量同步锁获取成功之后,避免认领后拿不到锁导致优先项空占。 + priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCarddata) + if priorityErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr) + } + if skip { + h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后", + zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingCarddata)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) + } + h.base.invalidateCardCache(ctx, cardID) card, err := h.base.iotCardStore.GetByID(ctx, cardID) if err != nil { if isNotFound(err) { + priority.FailFinal(ctx, "卡不存在") return nil } + // 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。 + priority.MarkExecution(ctx) + priority.FailRetryable(ctx, "获取卡信息失败") return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) } - if h.gateway == nil { + // 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。 + if !h.base.priorityInPollingScope(ctx, priority, card) { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } + if h.gateway == nil { + priority.FailFinal(ctx, "流量查询能力未配置") + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) + } + priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling) result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID}) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败") + priority.FailRetryable(ctx, "查询流量失败") return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err) } if strings.TrimSpace(result.ICCID) == "" { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID") + priority.FailRetryable(ctx, "流量查询响应缺少 ICCID") return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil) } ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCarddata, "卡流量轮询任务", attempt.IntegrationID) if h.observation == nil || h.carrier == nil { + priority.FailFinal(ctx, "卡流量观测能力未配置") return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil) } now := time.Now() @@ -84,6 +108,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e }) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败") + priority.FailRetryable(ctx, "应用流量观测失败") return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err) } changed := decision.IncrementMB > 0 || decision.CrossMonth @@ -100,6 +125,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e metric = "polling.observation.persisted" } h.base.logger.Info("流量轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric)) + priority.Complete(ctx) h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 9c64beb5..1148aa05 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -49,28 +49,53 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus) + + // 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。 + priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCardStatus) + if priorityErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr) + } + if skip { + h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后", + zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingCardStatus)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) + } + card, err := h.base.getCardWithCache(ctx, cardID) if err != nil { if isNotFound(err) { + priority.FailFinal(ctx, "卡不存在") return nil } + // 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。 + priority.MarkExecution(ctx) + priority.FailRetryable(ctx, "获取卡信息失败") return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) } - if h.gateway == nil { + // 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。 + if !h.base.priorityInPollingScope(ctx, priority, card) { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } + if h.gateway == nil { + priority.FailFinal(ctx, "卡状态查询能力未配置") + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) + } + priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling) result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败") + priority.FailRetryable(ctx, "查询卡状态失败") return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err) } if strings.TrimSpace(result.ICCID) == "" { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "卡状态查询响应缺少 ICCID") + priority.FailRetryable(ctx, "卡状态查询响应缺少 ICCID") return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil) } ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCardStatus, "卡网络状态轮询任务", attempt.IntegrationID) if h.observation == nil { + priority.FailFinal(ctx, "卡网络观测能力未配置") return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil) } now := time.Now() @@ -83,6 +108,7 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) }) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用网络状态观测失败") + priority.FailRetryable(ctx, "应用网络状态观测失败") return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err) } changed := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling @@ -100,6 +126,7 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) metric = "polling.observation.persisted" } h.base.logger.Info("卡状态轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric)) + priority.Complete(ctx) h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt)) if decision.StopPolling { h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend)) diff --git a/internal/task/polling_package_handler.go b/internal/task/polling_package_handler.go index 297b2b51..54b76d75 100644 --- a/internal/task/polling_package_handler.go +++ b/internal/task/polling_package_handler.go @@ -7,6 +7,7 @@ import ( "github.com/hibiken/asynq" "go.uber.org/zap" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -52,39 +53,76 @@ func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error } defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingPackage) - if _, cacheErr := h.base.getCardWithCache(ctx, cardID); cacheErr != nil { - if isNotFound(cacheErr) { - return nil - } - h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(cacheErr)) + // 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。 + priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingPackage) + if priorityErr != nil { + h.base.logger.Error("查询活动优先轮询项失败", zap.Uint("card_id", cardID), zap.Error(priorityErr)) h.base.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) } - - if h.stopResumeSvc != nil { - freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) - if loadErr == nil { - h.base.logger.Info("套餐检查:执行停复机评估", - zap.Uint("card_id", cardID), - zap.Int("network_status", freshCard.NetworkStatus), - zap.String("stop_reason", freshCard.StopReason)) - if h.base.verboseLog { - h.base.logger.Info("套餐检查详情", - zap.Uint("card_id", cardID), - zap.String("iccid", freshCard.ICCID), - zap.Int("network_status", freshCard.NetworkStatus), - zap.String("stop_reason", freshCard.StopReason)) - } - if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil { - h.base.logger.Warn("套餐检查后停复机评估失败", - zap.Uint("card_id", cardID), zap.Error(evalErr)) - } - } else { - h.base.logger.Warn("套餐检查:加载卡信息失败", - zap.Uint("card_id", cardID), zap.Error(loadErr)) - } + if skip { + h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后", + zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingPackage)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) } + card, cacheErr := h.base.getCardWithCache(ctx, cardID) + if cacheErr != nil { + if isNotFound(cacheErr) { + priority.FailFinal(ctx, "卡不存在") + return nil + } + h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(cacheErr)) + // 本地读卡失败属可恢复失败:与其余三个轮询 handler 统一按尝试上限收敛,避免加急事实被静默丢弃。 + priority.MarkExecution(ctx) + priority.FailRetryable(ctx, "获取卡信息失败") + h.base.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) + } + // 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。 + if !h.base.priorityInPollingScope(ctx, priority, card) { + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) + } + + if h.stopResumeSvc == nil { + priority.FailFinal(ctx, "套餐评估能力未配置") + h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) + } + + // 停复机评估就是本任务的上游执行动作:先累加尝试次数再发起。 + priority.MarkExecution(ctx) + freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) + if loadErr != nil { + h.base.logger.Warn("套餐检查:加载卡信息失败", + zap.Uint("card_id", cardID), zap.Error(loadErr)) + priority.FailRetryable(ctx, "获取卡信息失败") + h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) + } + h.base.logger.Info("套餐检查:执行停复机评估", + zap.Uint("card_id", cardID), + zap.Int("network_status", freshCard.NetworkStatus), + zap.String("stop_reason", freshCard.StopReason)) + if h.base.verboseLog { + h.base.logger.Info("套餐检查详情", + zap.Uint("card_id", cardID), + zap.String("iccid", freshCard.ICCID), + zap.Int("network_status", freshCard.NetworkStatus), + zap.String("stop_reason", freshCard.StopReason)) + } + // 标记本次评估来自普通套餐轮询:无有效套餐场景据此才允许追加优先轮询请求, + // 其余调用链(观测消费者、手动实名、保护期轮询等)不打标即不写。 + evaluationCtx := priorityapp.WithPollingPriorityTrigger(ctx) + if evalErr := h.stopResumeSvc.EvaluateAndAct(evaluationCtx, freshCard); evalErr != nil { + h.base.logger.Warn("套餐检查后停复机评估失败", + zap.Uint("card_id", cardID), zap.Error(evalErr)) + priority.FailRetryable(ctx, "停复机评估失败") + h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) + } + + priority.Complete(ctx) h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage) } diff --git a/internal/task/polling_priority_claim.go b/internal/task/polling_priority_claim.go new file mode 100644 index 00000000..1b1aa499 --- /dev/null +++ b/internal/task/polling_priority_claim.go @@ -0,0 +1,324 @@ +package task + +import ( + "context" + "strconv" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditfailure" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// pollingPriorityTaskNames 是纳入优先轮询的既有任务类型的中文名,用于日志与审计展示。 +var pollingPriorityTaskNames = map[string]string{ + constants.TaskTypePollingRealname: "实名状态轮询任务", + constants.TaskTypePollingCarddata: "卡流量轮询任务", + constants.TaskTypePollingPackage: "套餐状态轮询任务", + constants.TaskTypePollingCardStatus: "卡网络状态轮询任务", +} + +// pollingPriorityRound 承载本轮轮询执行是否同时是一次优先执行。 +// +// 无活动优先项时接缝返回 nil,其所有方法都是无操作,因此调用方在「无优先项」路径上 +// 完全保持既有行为(入队、出队、移除与延后一行不改)。领取成功时必须恰好调用一次结果收口方法 +// (Complete / FailRetryable / FailFinal),以保证不出现「领了优先项却没执行也没落终态或退回活动态」的路径。 +// +// 上下文约定(照 PollingBase 既有约定):本类型的写库与审计一律忽略调用方 ctx, +// 内部改用 pollingFallbackContext 的独立短超时上下文。Asynq 任务 ctx 超时或取消后, +// 失败事实、终态与审计仍必须尽量落下,否则优先项会停在执行中只能等租约接管。 +type pollingPriorityRound struct { + base *PollingBase + item *model.PollingPriorityItem + taskType string + taskName string + // attempts 与库内 attempt_count 保持同步,用于判定是否达到尝试上限。 + attempts int +} + +// beginPollingPriority 轮询共享基类的优先项认领接缝。 +// +// 位置约定:必须在取并发信号量之后、调用上游之前(carddata 还需在流量同步锁获取成功之后)。 +// 返回语义: +// - round 非 nil:本轮领取成功,本轮执行同时就是优先执行; +// - skip 为 true:存在活动优先项但本轮必须跳过并延后(已被其它执行持有,或超租约接管未命中), +// 调用方必须释放并发、按既有间隔 requeueCard 延后、零上游调用、不写失败审计; +// - err 非 nil:活动项探测失败,调用方按既有失败分支处理; +// - round 为 nil 且 skip 为 false:无活动优先项,调用方后续行为与既有实现完全一致。 +func (b *PollingBase) beginPollingPriority(ctx context.Context, cardID uint, taskType string) (*pollingPriorityRound, bool, error) { + if b == nil || b.priorityStore == nil { + return nil, false, nil + } + item, err := b.priorityStore.FindActive(ctx, cardID, taskType) + if err != nil { + return nil, false, err + } + if item == nil { + return nil, false, nil + } + + now := time.Now() + switch item.Status { + case constants.PollingPriorityStatusPending: + // 待执行项必须由到达的执行认领并执行,否则提示通道产生的执行会被自己跳过、优先项永远无人执行。 + claimed, claimErr := b.priorityStore.ClaimPending(ctx, cardID, taskType, now) + if claimErr != nil { + return nil, false, claimErr + } + if claimed == nil { + return nil, true, nil + } + return b.newPriorityRound(ctx, claimed, taskType, now), false, nil + case constants.PollingPriorityStatusProcessing: + // 执行中且认领租约未到期:不发第二次上游调用。 + if item.ClaimedAt != nil && now.Before(item.ClaimedAt.Add(time.Duration(constants.PollingPriorityClaimLease)*time.Second)) { + return nil, true, nil + } + // 超过租约或认领时间缺失视为崩溃遗留:允许相邻执行接管,该卡该任务类型不被永久跳过。 + takenOver, takeoverErr := b.priorityStore.TakeOverExpiredClaim(ctx, cardID, taskType, now, constants.PollingPriorityClaimLease) + if takeoverErr != nil { + return nil, false, takeoverErr + } + if takenOver == nil { + return nil, true, nil + } + return b.newPriorityRound(ctx, takenOver, taskType, now), false, nil + default: + // 终态行不属于活动集合,FindActive 不会返回;防御性按跳过处理。 + return nil, true, nil + } +} + +// newPriorityRound 创建领取成功后的执行轮次,并记录领取事实。 +func (b *PollingBase) newPriorityRound(ctx context.Context, item *model.PollingPriorityItem, taskType string, now time.Time) *pollingPriorityRound { + round := &pollingPriorityRound{ + base: b, + item: item, + taskType: taskType, + taskName: pollingPriorityTaskName(taskType), + attempts: item.AttemptCount, + } + before := round.stateData() + item.Status = constants.PollingPriorityStatusProcessing + item.ClaimedAt = &now + round.writeAudit(constants.AuditActionPollingPriorityClaimed, "卡轮询优先项已领取", constants.AuditResultSuccess, "", "", before) + return round +} + +// priorityInPollingScope 执行前校验该卡是否仍在轮询范围内:卡自身与绑定设备的轮询开关均为启用。 +// +// 优先通道不得重启已停止轮询的卡;既有人工触发入口的范围语义不受本方法影响。 +// 无活动优先项(round 为 nil)时恒为真,普通轮询保持既有行为;不在范围内时按业务校验类失败 +// 终结优先项并返回 false,调用方据此跳过上游调用。 +func (b *PollingBase) priorityInPollingScope(_ context.Context, round *pollingPriorityRound, card *model.IotCard) bool { + if round == nil { + return true + } + if card == nil { + round.FailFinal(nil, "卡不存在") + return false + } + scopeCtx, cancel := pollingFallbackContext() + inScope := b.scopeChecker == nil || b.scopeChecker.ShouldEnqueue(scopeCtx, card) + cancel() + if inScope { + return true + } + round.FailFinal(nil, "卡已不在轮询范围内") + return false +} + +// MarkExecution 标记即将真正发起上游调用,并在调用之前累加尝试次数。 +// 崩溃遗留的执行中项被接管后据此按尝试上限收敛。 +func (r *pollingPriorityRound) MarkExecution(_ context.Context) { + if r == nil { + return + } + ctx, cancel := pollingFallbackContext() + defer cancel() + hit, err := r.base.priorityStore.IncrementAttempt(ctx, r.item.ID) + if err != nil { + r.base.logger.Warn("累加优先轮询项尝试次数失败", + zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } + if !hit { + r.base.logger.Warn("优先轮询项已不处于执行中,尝试次数未累加", + zap.Uint("priority_item_id", r.item.ID)) + } + r.attempts = r.item.AttemptCount + 1 + r.item.AttemptCount = r.attempts +} + +// Complete 记录优先项执行成功并出队,该卡继续按普通轮询运行。 +func (r *pollingPriorityRound) Complete(_ context.Context) { + if r == nil { + return + } + ctx, cancel := pollingFallbackContext() + defer cancel() + before := r.stateData() + hit, err := r.base.priorityStore.MarkCompleted(ctx, r.item.ID) + if err != nil { + r.base.logger.Warn("更新优先轮询项完成状态失败", + zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } + if !hit { + r.base.logger.Warn("优先轮询项已完成或不处于执行中,跳过完成收口", + zap.Uint("priority_item_id", r.item.ID)) + return + } + r.item.Status = constants.PollingPriorityStatusCompleted + r.item.Result = constants.PollingPriorityResultSuccess + r.item.FailureReason = "" + r.writeAudit(constants.AuditActionPollingPriorityCompleted, "卡轮询优先项执行完成并出队", constants.AuditResultSuccess, "", "", before) +} + +// FailRetryable 记录一次可恢复失败(上游超时、失败或响应无效)。 +// 未达尝试上限时退回活动态并按既有轮询间隔由调用方重排;达到上限时记录失败终态并出队。 +func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) { + if r == nil { + return + } + ctx, cancel := pollingFallbackContext() + defer cancel() + before := r.stateData() + if r.attempts < constants.PollingPriorityMaxAttempts { + hit, err := r.base.priorityStore.ReturnToPending(ctx, r.item.ID, reason) + if err != nil { + r.base.logger.Warn("退回优先轮询项活动状态失败", + zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } + if !hit { + r.base.logger.Warn("优先轮询项不处于执行中,跳过退回活动态收口", + zap.Uint("priority_item_id", r.item.ID)) + return + } + r.item.Status = constants.PollingPriorityStatusPending + r.item.Result = constants.PollingPriorityResultFailed + r.item.FailureReason = reason + r.item.ClaimedAt = nil + r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before) + r.writeAudit(constants.AuditActionPollingPriorityRetried, "卡轮询优先项未达尝试上限,退回活动态重新排期", constants.AuditResultFailed, "", reason, before) + return + } + + hit, err := r.base.priorityStore.MarkFailed(ctx, r.item.ID, reason) + if err != nil { + r.base.logger.Warn("更新优先轮询项失败状态失败", + zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } + if !hit { + r.base.logger.Warn("优先轮询项不处于执行中,跳过失败出队收口", + zap.Uint("priority_item_id", r.item.ID)) + return + } + r.item.Status = constants.PollingPriorityStatusFailed + r.item.Result = constants.PollingPriorityResultFailed + r.item.FailureReason = reason + r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before) + r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项达到尝试上限,失败出队", constants.AuditResultFailed, "", reason, before) +} + +// FailFinal 记录业务校验类失败(卡不存在、不在轮询范围、能力未配置等):不重试,直接失败出队。 +func (r *pollingPriorityRound) FailFinal(_ context.Context, reason string) { + if r == nil { + return + } + ctx, cancel := pollingFallbackContext() + defer cancel() + before := r.stateData() + hit, err := r.base.priorityStore.MarkFailed(ctx, r.item.ID, reason) + if err != nil { + r.base.logger.Warn("更新优先轮询项失败状态失败", + zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } + if !hit { + r.base.logger.Warn("优先轮询项不处于执行中,跳过失败出队收口", + zap.Uint("priority_item_id", r.item.ID)) + return + } + r.item.Status = constants.PollingPriorityStatusFailed + r.item.Result = constants.PollingPriorityResultFailed + r.item.FailureReason = reason + r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行业务校验失败", constants.AuditResultFailed, "", reason, before) + r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队且不重试", constants.AuditResultFailed, "", reason, before) +} + +// stateData 返回当前优先项状态的前后值快照,覆盖触发来源、操作来源、时间与执行结果。 +func (r *pollingPriorityRound) stateData() map[string]any { + return map[string]any{ + "status": r.item.Status, + "result": r.item.Result, + "trigger_type": r.item.TriggerType, + "trigger_types": r.item.TriggerTypes, + "trigger_count": r.item.TriggerCount, + "last_triggered_at": r.item.LastTriggeredAt, + "attempt_count": r.item.AttemptCount, + "claimed_at": r.item.ClaimedAt, + "failure_reason": r.item.FailureReason, + "source": constants.AuditSourceWorker, + "occurred_at": time.Now(), + } +} + +// writeAudit 以独立短事务写入一条优先项生命周期审计。 +// 审计写入失败不阻断轮询执行,按既有约定登记为失败审计二次写入故障。 +func (r *pollingPriorityRound) writeAudit( + actionCode, summary, result, errorCode, errorSummary string, + before map[string]any, +) { + base := r.base + if base == nil || base.priorityAudit == nil || base.db == nil || r.item == nil || r.item.ID == 0 { + return + } + ctx, cancel := pollingFallbackContext() + defer cancel() + input := audit.TaskInput{ + ActionCode: actionCode, Summary: summary, + TaskID: r.item.ID, TaskNo: pollingPriorityResourceKey(r.item), DisplayName: "卡轮询优先项", + Actor: audit.ActorInput{ + Kind: constants.AuditActorSystemTask, ID: r.taskType, Name: r.taskName, + }, + Source: constants.AuditSourceWorker, + ScopeType: constants.AuditScopePlatform, + Result: result, + ErrorCode: errorCode, + ErrorSummary: errorSummary, + CorrelationID: r.taskType, + IdentitySnapshot: map[string]any{ + "id": r.item.ID, "card_id": r.item.CardID, "task_type": r.item.TaskType, + "status": r.item.Status, "trigger_type": r.item.TriggerType, + "trigger_count": r.item.TriggerCount, "manual_operator_id": r.item.ManualOperatorID, + }, + BeforeData: before, + AfterData: r.stateData(), + } + err := base.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return base.priorityAudit.WriteTask(ctx, tx, input) + }) + if err != nil { + auditfailure.RecordSecondaryWriteFailure(actionCode, input.TaskNo, "", r.taskType, errorCode, err) + base.logger.Error("优先轮询项审计写入失败", + zap.String("action", actionCode), zap.Uint("priority_item_id", r.item.ID), zap.Error(err)) + } +} + +// pollingPriorityTaskName 返回优先轮询任务类型的中文名,未知类型回退为完整任务类型。 +func pollingPriorityTaskName(taskType string) string { + if name, ok := pollingPriorityTaskNames[taskType]; ok { + return name + } + return taskType +} + +// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。 +func pollingPriorityResourceKey(item *model.PollingPriorityItem) string { + if item == nil { + return "" + } + return strconv.FormatUint(uint64(item.ID), 10) +} diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index cc3a7dc3..385b10cd 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -41,28 +41,52 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e } defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingRealname) + // 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。 + priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingRealname) + if priorityErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr) + } + if skip { + h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后", + zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingRealname)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) + } + card, err := h.base.getCardWithCache(ctx, cardID) if err != nil { if isNotFound(err) { + priority.FailFinal(ctx, "卡不存在") return nil } + // 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。 + priority.MarkExecution(ctx) + priority.FailRetryable(ctx, "获取卡信息失败") return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) } - if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil { + // 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。 + if !h.base.priorityInPollingScope(ctx, priority, card) { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } + if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil { + priority.FailFinal(ctx, "卡类型不适用实名轮询或实名查询能力未配置") + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) + } + priority.MarkExecution(ctx) attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling) result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败") + priority.FailRetryable(ctx, "查询实名状态失败") return h.failAndRequeue(ctx, cardID, startedAt, "查询实名状态失败", err) } if strings.TrimSpace(result.ICCID) == "" { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "实名查询响应缺少 ICCID") + priority.FailRetryable(ctx, "实名查询响应缺少 ICCID") return h.failAndRequeue(ctx, cardID, startedAt, "实名查询响应缺少 ICCID", nil) } ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingRealname, "实名状态轮询任务", attempt.IntegrationID) if h.observation == nil { + priority.FailFinal(ctx, "卡实名观测能力未配置") return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil) } now := time.Now() @@ -75,6 +99,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e }) if err != nil { _ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用实名观测失败") + priority.FailRetryable(ctx, "应用实名观测失败") return h.failAndRequeue(ctx, cardID, startedAt, "应用实名观测失败", err) } changed := decision.StatusChanged @@ -91,6 +116,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e metric = "polling.observation.persisted" } h.base.logger.Info("实名轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric)) + priority.Complete(ctx) h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } diff --git a/migrations/000228_add_polling_priority_queue.down.sql b/migrations/000228_add_polling_priority_queue.down.sql new file mode 100644 index 00000000..870b793a --- /dev/null +++ b/migrations/000228_add_polling_priority_queue.down.sql @@ -0,0 +1,43 @@ +-- 回滚卡轮询优先队列:删除 tb_polling_priority_item,与 up 严格成对,顺序与 up 的创建顺序严格倒序。 +-- +-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_polling_priority_item。加急事实 +-- (触发场景与来源集合、来源订单与套餐使用记录、触发次数、尝试次数、认领时间、人工原因与操作者、 +-- 执行结果与失败原因)只由本表承载;tb_audit_event 只记录审计动作与前后值,不能还原完整队列状态, +-- 删除后无法由数据库自身重建,因此 down 只在加急事实已无留存需求时执行。 +-- +-- 守卫一:存在活动项(未软删且待执行/执行中)时拒绝回滚,避免在途加急任务被静默丢弃。 +-- 守卫二:存在未终态行(含已软删)时拒绝回滚,避免半终态行随表一起消失、事后无从核对。 + +BEGIN; + +DO $$ +BEGIN + IF to_regclass('tb_polling_priority_item') IS NOT NULL THEN + -- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新的加急事实。 + LOCK TABLE tb_polling_priority_item IN ACCESS EXCLUSIVE MODE; + IF EXISTS ( + SELECT 1 FROM tb_polling_priority_item + WHERE deleted_at IS NULL AND status IN ('pending', 'processing') + LIMIT 1 + ) THEN + RAISE EXCEPTION '存在卡轮询优先队列活动项,拒绝回滚以避免丢失在途加急事实与认领租约状态'; + END IF; + IF EXISTS ( + SELECT 1 FROM tb_polling_priority_item + WHERE status IN ('pending', 'processing') + LIMIT 1 + ) THEN + RAISE EXCEPTION '存在未终态的卡轮询优先队列记录(含已软删行),拒绝回滚,请人工核对后向前修复'; + END IF; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_polling_priority_item_shop_created; +DROP INDEX IF EXISTS idx_polling_priority_item_card; +DROP INDEX IF EXISTS idx_polling_priority_item_status_created; +DROP INDEX IF EXISTS uq_polling_priority_item_active; + +DROP TABLE IF EXISTS tb_polling_priority_item; + +COMMIT; diff --git a/migrations/000228_add_polling_priority_queue.up.sql b/migrations/000228_add_polling_priority_queue.up.sql new file mode 100644 index 00000000..4511afa6 --- /dev/null +++ b/migrations/000228_add_polling_priority_queue.up.sql @@ -0,0 +1,125 @@ +-- 卡轮询优先队列:新增 tb_polling_priority_item,承载「某卡某轮询任务类型被加急」的活动事实与执行结果。 +-- 背景:紧急卡状态查询与普通轮询共用同一条分片调度通道,既无法保证处理顺序,也没有任何地方保存 +-- 「这张卡被加急了、因为什么、执行了几次、结果如何」的事实。既有 tb_polling_manual_trigger_log +-- 只记录人工批量触发任务本身,不表达逐卡逐任务类型的加急生命周期,因此本迁移新增独立事实表。 +-- +-- 设计选择: +-- 1. 活动项粒度 = (card_id, task_type):同一卡同一轮询任务类型至多一条活动项,由部分唯一索引兜底。 +-- 一次加急天然要对多个既有任务类型各执行一次,按类型分行使认领的原子性与逐类型的执行结果审计 +-- 都落在单独一行上;按类型互斥也避免「整卡跳过」饿死该卡其它任务类型的普通轮询。 +-- 2. 活动集合固定为 pending-待执行 与 processing-执行中;completed-已完成 与 failed-失败出队 为终态。 +-- 终态行不占用唯一键,同一卡同一任务类型完成后可再次加急。 +-- 3. status 用字符串表达生命周期,取值与 pkg/constants 的 PollingPriorityStatus* 一致。 +-- trigger_type 记录首次触发场景(PollingPriorityTrigger*),trigger_types 记录去重后的全部触发场景 +-- 集合(英文逗号分隔并两端补分隔符),使「同一活动项先被自动触发、后被人工合并」可追溯。 +-- 4. claimed_at 是认领租约的基准时间:执行中且超过认领租约(90 秒,长于既有轮询任务超时 60 秒)的 +-- 行允许相邻执行接管领取,避免崩溃遗留的「执行中」让该卡该任务类型被静默永久跳过。 +-- 5. attempt_count 只在真正发起执行后累加;未达上限(3 次)的可恢复失败回到 pending 并按既有轮询间隔 +-- 重排,达到上限写失败终态后出队;卡不存在、不在轮询范围等业务校验类失败直接进失败终态不重试。 +-- 6. shop_id_snapshot 是消费时点解析的卡所属店铺快照(平台卡为 NULL),读侧数据范围按它下推; +-- 它是读取用的冗余快照,不承诺与卡当前归属实时一致,卡后续改归属不追溯已入队的加急事实。 +-- 7. result 与 failure_reason 只承载可安全对外展示的结果与原因,不写内部错误细节。 +-- 8. manual_reason / manual_operator_id / manual_operator_name 记录人工入队的原因与操作者, +-- 与事实一同保存;它们不是可维护配置项。 +-- 9. 不使用数据库外键;card_id / source_order_id / source_package_usage_id 由应用层显式校验。 +-- 10. creator/updater 记录维护账号,系统写入为 0。 + +CREATE TABLE tb_polling_priority_item ( + id BIGSERIAL PRIMARY KEY, + card_id BIGINT NOT NULL, + task_type VARCHAR(50) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + trigger_type VARCHAR(30) NOT NULL, + trigger_types VARCHAR(255) NOT NULL DEFAULT '', + trigger_count INT NOT NULL DEFAULT 1, + last_triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source_order_id BIGINT, + source_package_usage_id BIGINT, + attempt_count INT NOT NULL DEFAULT 0, + claimed_at TIMESTAMPTZ, + manual_reason VARCHAR(500) NOT NULL DEFAULT '', + manual_operator_id BIGINT NOT NULL DEFAULT 0, + manual_operator_name VARCHAR(100) NOT NULL DEFAULT '', + shop_id_snapshot BIGINT, + result VARCHAR(16) NOT NULL DEFAULT '', + failure_reason VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT ck_polling_priority_item_card CHECK (card_id > 0), + CONSTRAINT ck_polling_priority_item_task_type CHECK (task_type <> ''), + CONSTRAINT ck_polling_priority_item_status CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + CONSTRAINT ck_polling_priority_item_trigger_type CHECK (trigger_type IN ( + 'purchase_activated', 'renewal_activated', 'queue_activated', + 'addon_activated', 'no_valid_package', 'manual_trigger' + )), + CONSTRAINT ck_polling_priority_item_trigger_count CHECK (trigger_count >= 1), + CONSTRAINT ck_polling_priority_item_attempt_count CHECK (attempt_count >= 0), + CONSTRAINT ck_polling_priority_item_result CHECK (result IN ('', 'success', 'failed')), + -- 终态与结果互为充要条件:已完成必须带 success、失败出队必须带 failed, + -- 避免出现「已出队却没有结果」的半终态行。非终态行允许保留上次失败结果,便于读侧展示在途重试。 + CONSTRAINT ck_polling_priority_item_terminal_result CHECK ( + (status = 'completed' AND result = 'success') + OR (status = 'failed' AND result = 'failed') + OR status IN ('pending', 'processing') + ), + -- 人工触发必然带操作者;反向不成立(自动触发之后被人工合并的行仍保留自动的首次触发类型)。 + CONSTRAINT ck_polling_priority_item_manual_operator CHECK ( + trigger_type <> 'manual_trigger' OR manual_operator_id > 0 + ) +); + +-- 活动项唯一键必须是「部分唯一索引」:CREATE TABLE 的表约束不支持 WHERE 谓词, +-- 而活动集合与软删感知都要求谓词(软删行与终态行都不得占用键位),因此独立建索引。 +-- 该索引不与任何 GORM OnConflict 组合(部分唯一索引谓词未声明时 OnConflict 无法命中,见 KNOWN-ISSUE-001), +-- 合并入队由「显式插入 + 23505 唯一冲突识别后转条件更新」实现。 +CREATE UNIQUE INDEX uq_polling_priority_item_active + ON tb_polling_priority_item (card_id, task_type) + WHERE deleted_at IS NULL AND status IN ('pending', 'processing'); + +-- 读侧列表按状态与时间倒序分页。 +CREATE INDEX idx_polling_priority_item_status_created + ON tb_polling_priority_item (status, created_at DESC, id DESC) + WHERE deleted_at IS NULL; + +-- 按卡批量探测活动项:轮询执行认领接缝每轮都要走这条路径,必须命中索引而不是全表扫描。 +CREATE INDEX idx_polling_priority_item_card + ON tb_polling_priority_item (card_id, status) + WHERE deleted_at IS NULL; + +-- 读侧按店铺快照下推数据范围后分页。 +CREATE INDEX idx_polling_priority_item_shop_created + ON tb_polling_priority_item (shop_id_snapshot, created_at DESC, id DESC) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_polling_priority_item IS '卡轮询优先队列事实表,一个卡一个轮询任务类型至多一条活动项,终态出队后不再占用键位'; +COMMENT ON COLUMN tb_polling_priority_item.id IS '主键'; +COMMENT ON COLUMN tb_polling_priority_item.card_id IS '优先执行的物联网卡ID,执行对象恒为卡,多卡资产逐卡建项'; +COMMENT ON COLUMN tb_polling_priority_item.task_type IS '既有轮询任务类型,取值与 pkg/constants.TaskTypePolling* 一致'; +COMMENT ON COLUMN tb_polling_priority_item.status IS '生命周期 pending-待执行 processing-执行中 completed-已完成 failed-失败出队;前两者为活动集合'; +COMMENT ON COLUMN tb_polling_priority_item.trigger_type IS '首次触发场景 purchase_activated-主套餐购买后立即生效 renewal_activated-续购生效 queue_activated-排队顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队'; +COMMENT ON COLUMN tb_polling_priority_item.trigger_types IS '去重后的全部触发场景集合,英文逗号分隔并两端补分隔符,用于表达自动触发与人工入队的合并'; +COMMENT ON COLUMN tb_polling_priority_item.trigger_count IS '累计触发次数,后续触发合并进同一活动项时递增,不新建行也不重复调用'; +COMMENT ON COLUMN tb_polling_priority_item.last_triggered_at IS '最近一次触发时间,合并时刷新'; +COMMENT ON COLUMN tb_polling_priority_item.source_order_id IS '来源订单ID,自动触发可空,人工入队为空'; +COMMENT ON COLUMN tb_polling_priority_item.source_package_usage_id IS '来源套餐使用记录ID,套餐生效类触发可空'; +COMMENT ON COLUMN tb_polling_priority_item.attempt_count IS '真实发起执行的次数,只在发起执行后累加,上限为代码常量 3'; +COMMENT ON COLUMN tb_polling_priority_item.claimed_at IS '认领时间,认领租约(90 秒)的基准;超过租约的执行中行允许相邻执行接管'; +COMMENT ON COLUMN tb_polling_priority_item.manual_reason IS '人工入队原因,人工入队必填,与事实一同保存'; +COMMENT ON COLUMN tb_polling_priority_item.manual_operator_id IS '人工入队操作者账号ID,自动触发为 0'; +COMMENT ON COLUMN tb_polling_priority_item.manual_operator_name IS '人工入队操作者名称快照,自动触发为空字符串'; +COMMENT ON COLUMN tb_polling_priority_item.shop_id_snapshot IS '消费时点解析的卡所属店铺快照,平台卡为 NULL,读侧数据范围按本列下推;卡改归属不追溯已入队的加急事实'; +COMMENT ON COLUMN tb_polling_priority_item.result IS '执行结果,终态必须与状态匹配:completed-success failed-failed;非终态为空或上次失败结果'; +COMMENT ON COLUMN tb_polling_priority_item.failure_reason IS '可安全对外展示的失败原因,不写内部错误细节'; +COMMENT ON COLUMN tb_polling_priority_item.creator IS '创建人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_polling_priority_item.updater IS '最近更新人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_polling_priority_item.created_at IS '创建时间'; +COMMENT ON COLUMN tb_polling_priority_item.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_polling_priority_item.deleted_at IS '软删除时间,正常流程不删除,仅数据清理使用'; + +COMMENT ON INDEX uq_polling_priority_item_active IS '活动项唯一键(软删与终态感知):一个卡一个轮询任务类型至多一条待执行或执行中的优先项'; +COMMENT ON INDEX idx_polling_priority_item_status_created IS '优先队列列表按状态与时间倒序分页索引'; +COMMENT ON INDEX idx_polling_priority_item_card IS '轮询执行认领接缝按卡批量探测活动项索引'; +COMMENT ON INDEX idx_polling_priority_item_shop_created IS '优先队列列表按店铺快照下推数据范围后的分页索引'; diff --git a/openspec/changes/add-priority-polling-queue/design.md b/openspec/changes/add-priority-polling-queue/design.md deleted file mode 100644 index db5413bc..00000000 --- a/openspec/changes/add-priority-polling-queue/design.md +++ /dev/null @@ -1,13 +0,0 @@ -## Context - -优先轮询是普通轮询之上的高优先级调度,不是另一套轮询业务逻辑。自动套餐事件、无有效套餐、普通轮询异常补偿和受控人工触发均可入队;优先与普通轮询共享同一卡互斥和既有并发边界。 - -## Decisions - -- 队列表保存卡、活动状态、合并来源、触发类型、来源订单/套餐使用、尝试次数和结果;同一卡活动项唯一。 -- 在订单/套餐生效、无有效套餐识别、普通轮询异常后通过可靠事件入队;具备既有手动轮询权限的后台账号可在数据范围内通过 `POST /priority-polling-queue` 提交 `asset_identifier` 与必填 `reason`。消费者按来源事实幂等合并,不能由支付回调直接重复写队列。 -- Worker 用行锁领取优先项,复用普通轮询既有并发上限、外部调用保护、套餐/流量/状态同步和失败重试;普通轮询查询活动项排除对应卡。完成或最终失败出队并写触发来源、操作来源、次数、时间和结果审计。 - -## Migration Plan - -新增成对迁移、活动项唯一索引和来源索引;隔离库验证三种自动触发、回调重放、同卡合并、普通互斥、失败重试和 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-priority-polling-queue/proposal.md b/openspec/changes/add-priority-polling-queue/proposal.md deleted file mode 100644 index b51ef066..00000000 --- a/openspec/changes/add-priority-polling-queue/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-016`。 - -## Why - -紧急卡状态查询与普通轮询竞争,无法保证处理顺序或追踪加急事实。 - -## What Changes - -- 新增受限管理的卡轮询优先队列。 -- Worker 优先领取队列项,并以唯一活动项和锁避免同卡并发。 - -## Capabilities - -### New Capabilities -- `priority-polling-queue`: 卡轮询加急队列。 - -### Modified Capabilities -- 无。 - -## Impact - -影响后台卡管理、异步轮询、运营商调用、审计和 Schema。 \ No newline at end of file diff --git a/openspec/changes/add-priority-polling-queue/specs/polling-operations/spec.md b/openspec/changes/add-priority-polling-queue/specs/polling-operations/spec.md deleted file mode 100644 index 18de42b6..00000000 --- a/openspec/changes/add-priority-polling-queue/specs/polling-operations/spec.md +++ /dev/null @@ -1,8 +0,0 @@ -## ADDED Requirements - -### Requirement: 优先轮询队列 -系统 SHALL 在不改变普通轮询内容、并发上限和失败重试的前提下,为指定业务场景创建高优先级轮询任务。同一资产未完成优先任务必须合并触发来源、次数和最近时间,而不得重复执行同一轮轮询。 - -#### Scenario: 规则命中 -- **WHEN** 业务请求或任务满足本需求定义的前置条件 -- **THEN** 系统按上述规则完成处理、保留可追溯事实,并拒绝与状态、权限或幂等约束冲突的重复操作 diff --git a/openspec/changes/add-priority-polling-queue/specs/priority-polling-queue/spec.md b/openspec/changes/add-priority-polling-queue/specs/priority-polling-queue/spec.md deleted file mode 100644 index ba0c5797..00000000 --- a/openspec/changes/add-priority-polling-queue/specs/priority-polling-queue/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -## ADDED Requirements - -### Requirement: 套餐生命周期自动优先入队 -系统 SHALL 在下列业务事实提交成功后,将关联资产对应卡自动加入优先轮询队列:无有效套餐、新购套餐首次成为有效套餐、已过期套餐完成续购、流量用尽后购买的加油包完成生效,及普通轮询出现既有异常补偿条件。具备既有轮询手动触发权限的后台账号也可为其数据范围内资产手动入队并必须填写原因。入队必须等待订单、支付、套餐使用记录提交成功;失败或取消订单不得入队。队列项记录触发类型(新购、过期续购、加油包)、来源订单/套餐使用记录、触发时间及执行结果。 - -同一卡只能有一条活动优先项。多个触发同时到达时,系统合并来源事实、保留最近触发时间,且最多执行一次未完成优先轮询;不得因重复支付回调、任务重放或相同订单重试重复入队。手动入队不允许修改调度优先级或绕过既有并发上限;它与自动/异常触发合并到同一卡活动项。 - -#### Scenario: 人工与异常补偿触发合并 -- **WHEN** 同一卡已有自动入队活动项,管理员手动触发或普通轮询异常补偿再次触发 -- **THEN** 系统仅追加触发来源、次数和最近时间,不创建第二项或重复调用轮询 - -#### Scenario: 重复支付成功回调 -- **WHEN** 已生效套餐订单的支付成功回调重复投递 -- **THEN** 系统仅创建或更新一条关联该卡的活动优先队列项 - -### Requirement: 优先领取、执行与出队审计 -Worker SHALL 先领取活动优先项,再执行既有套餐、流量和卡状态轮询;普通轮询不得与已领取优先项并发。领取时使用行锁跳过已锁项,同一卡由队列状态互斥。轮询成功后标记完成并出队;可恢复失败按既有策略重试,超过策略标记失败并保留安全失败原因。入队、领取、每次失败、重试、完成和失败出队均记录触发来源、操作来源、时间和结果;仅具有既有轮询任务权限的后台账号可查询记录。 - -#### Scenario: 普通轮询遇到优先项 -- **WHEN** 普通轮询准备处理一张存在活动或已领取优先项的卡 -- **THEN** 普通轮询跳过该卡,直到优先项完成或失败出队 diff --git a/openspec/changes/add-priority-polling-queue/tasks.md b/openspec/changes/add-priority-polling-queue/tasks.md deleted file mode 100644 index 6200e4ee..00000000 --- a/openspec/changes/add-priority-polling-queue/tasks.md +++ /dev/null @@ -1,9 +0,0 @@ -## 1. 自动入队与执行 -- [ ] 1.1 追踪新购生效、过期续购生效、流量用尽加油包生效、可靠事件、普通轮询和运营商调用链路。 -- [ ] 1.2 新增队列、来源事实和执行审计的成对迁移、活动项唯一索引、模型与查询权限。 -- [ ] 1.3 在三种套餐生命周期成功事件后发布可靠入队事件;实现同卡合并、回调/任务重放幂等和禁止人工入队。 -- [ ] 1.4 实现锁定领取、普通轮询排除、套餐/流量/状态优先轮询、失败恢复和出队审计。 - -## 2. 验证 -- [ ] 2.1 验证三种触发、失败订单不入队、重复回调、并发合并、普通互斥、失败重试和权限。 -- [ ] 2.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-priority-polling-queue --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-priority-polling-queue/.openspec.yaml b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/.openspec.yaml similarity index 100% rename from openspec/changes/add-priority-polling-queue/.openspec.yaml rename to openspec/changes/archive/2026-09-17-add-priority-polling-queue/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md new file mode 100644 index 00000000..155056e8 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md @@ -0,0 +1,168 @@ +## 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 + +1. 新增成对迁移:优先项表(活动项部分唯一索引 = 卡 + 任务类型,仅覆盖待执行/执行中且未软删;状态与时间索引)+ down 守卫(存在活动项即拒绝回滚,避免静默丢失加急事实)。 +2. 模型与存储:关联只存 ID 并显式查询,不新增数据库外键与关联标签。 +3. 无数据回填;上线后无活动项时系统行为与当前完全一致,可先发布再依赖触发。 +4. 隔离环境执行 up / down / up 验证;回滚前必须确认无活动项,否则按守卫拒绝并人工确认。 +5. 发布顺序:先迁移与 Worker(消费者、接缝、提示通道),再开放人工入队与读侧接口。 + +## 后续候选 + +### 普通轮询异常补偿(本版不做) + +- 现状无任何可判定条件:轮询失败按配置间隔无限重入队,无失败上限、无补偿入口,因此当前 spec 中的「既有异常补偿条件」是不可实现、不可验证的空指向,已从 spec 与任务中移除。 +- 本版若实现等于自造判据(例如「任一次可恢复失败即入队」);且渠道故障时全量失败卡会涌入优先通道,而提示通道不受分片背压约束,风险不对称。 +- 处置:需产品确认后以**独立 Change** 实施;届时在 PRD §2.15 该场景处解除本版标注,并同时定义判据、上限与过载保护。 + +### 其它候选(均需独立 Change) + +- 优先级分级与优先级规则配置(111.md §23.6)。 +- 优先项有效期与过期出队(111.md §23.4)。 +- 独立异常与重试记录页、人工重触发入口、资产/卡详情优先轮询状态展示(111.md §23.2、§23.7、§23.11)。 diff --git a/openspec/changes/archive/2026-09-17-add-priority-polling-queue/proposal.md b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/proposal.md new file mode 100644 index 00000000..cfc71458 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/proposal.md @@ -0,0 +1,43 @@ +## Scope + +- 迭代编号:`AUG26-016`。 + +## Why + +紧急卡状态查询与普通轮询竞争,无法保证处理顺序或追踪加急事实。 + +## What Changes + +- 新增受限管理的卡轮询优先队列:以持久化事实记录触发来源、次数、最近时间与执行结果;同一卡同一轮询任务类型至多一条活动优先项。 +- 触发来源:主套餐购买后立即生效、排队主套餐顺延生效、加油包生效、资产无有效套餐且存在待生效套餐使用记录、具备既有人工触发权限的账号人工入队。 +- Worker 复用既有轮询任务类型与既有轮询内容优先领取执行;同一卡同一任务类型只允许一次上游调用,优先项不以任何方式使卡退出普通轮询。 +- 复用既有手动队列式的执行提示通道(独立提示键,调度周期内先排空),不新建调度设施、异步任务类型或队列。 +- 新增最小可读接口(列表与详情,支持筛选与分页),权限与人工入队一致并按数据范围下推。 + +## 非目标 + +- 不做优先级分级(P0/P1/P2)与优先级规则配置。 +- 不做有效期字段与过期出队语义。 +- 不做独立异常记录页、人工重触发入口,以及资产/卡详情的优先轮询状态展示。 +- 不新增可维护的参数配置项;不改普通轮询内容、并发上限、失败重试与外部调用保护。 +- 不做普通轮询异常补偿场景(现状无判据,登记为后续独立 Change)。 +- 不改人工触发 API 的任务类型白名单、每日次数上限与 24 小时去重规则。 + +## Capabilities + +### New Capabilities + +- `priority-polling-queue`: 卡轮询加急队列:触发场景与入队事实、同卡同任务类型唯一活动项与合并、优先调度与执行提示通道、认领与租约、尝试上限与出队、事实与查询的可追溯。 + +### Modified Capabilities + +- `polling-operations`: 新增「优先轮询项与普通轮询的单次执行互斥」行为要求;既有人工触发的任务类型白名单、每日次数上限与 24 小时去重保持不变。 + +## Impact + +- 路由与文档装配:新增后台路由与 Handler,并同步文档工厂、API 组装与文档生成入口三处装配。 +- Worker:新增可靠事件消费者注册;调度器新增优先提示排空。 +- 轮询执行:纳入执行集合的轮询任务类型增加认领接缝(无活动项时行为与当前完全一致)。 +- 触发写入:订单与套餐生效事务新增优先请求事件追加(同事务写入,提交后投递)。 +- Schema:新增成对迁移与优先项表(含活动项部分唯一索引)。 +- 常量与审计:新增触发类型、状态与尝试上限常量,以及轮询优先队列的审计动作与资源常量。 diff --git a/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/polling-operations/spec.md b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/polling-operations/spec.md new file mode 100644 index 00000000..6cfa06d4 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/polling-operations/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: 优先轮询项与普通轮询的单次执行互斥 + +系统 SHALL 在同一卡同一轮询任务类型存在优先轮询项时,只允许一个执行轮次对该卡该任务类型发起上游调用:待执行优先项由本轮执行认领并作为优先执行完成该轮轮询,MUST NOT 另外产生第二次调用;执行中且认领租约未到期的优先项使本轮执行跳过该轮次并延后,MUST NOT 发起上游调用。不存在活动优先项时,普通轮询的入队、出队、移除与延后行为 MUST 与当前行为一致。优先项存在 MUST NOT 使该卡被移出普通轮询,MUST NOT 改变其它轮询任务类型的执行。 + +#### Scenario: 待执行优先项遇到本轮执行 + +- **WHEN** 该卡该轮询任务类型存在待执行优先项,且该任务类型的执行轮次到达 +- **THEN** 系统认领该项并完成本轮轮询,不产生额外的第二次上游调用 + +#### Scenario: 已领取优先项遇到本轮的另一次执行 + +- **WHEN** 该卡该轮询任务类型存在执行中且认领租约未到期的优先项,该任务类型又到达一次执行 +- **THEN** 系统跳过该轮次并按既有间隔延后,不发起上游调用 + +#### Scenario: 不存在活动优先项 + +- **WHEN** 该卡该轮询任务类型不存在活动优先项 +- **THEN** 轮询执行的排队、入队、出队与移除行为与当前行为一致 + +#### Scenario: 优先项结束后的普通轮询 + +- **WHEN** 该卡该轮询任务类型的优先项已完成或失败出队 +- **THEN** 该卡继续按普通轮询间隔执行,仍存在于普通轮询中 diff --git a/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md new file mode 100644 index 00000000..8ddc4aea --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md @@ -0,0 +1,186 @@ +## Purpose + +在不改变既有普通轮询内容、并发上限、失败重试与外部调用保护的前提下,为购买生效、续购顺延生效、加油包生效、无有效套餐与人工加急的资产提供额外优先执行的轮询调度,并保留可追踪的加急事实。 + +## ADDED Requirements + +### Requirement: 优先轮询触发场景与入队事实 + +系统 SHALL 在下列业务事实提交成功后,为关联资产的当前在用卡创建优先轮询项:主套餐购买后立即生效、排队主套餐顺延生效、加油包生效、资产无有效套餐且存在待生效的套餐使用记录。具备既有轮询手动触发权限的后台账号亦可为其数据范围内资产入队。入队 MUST 等待订单、支付与套餐使用记录提交成功;支付失败、已取消或未支付到账的订单 MUST NOT 入队。触发类型 MUST 记录为下列稳定枚举之一:`purchase_activated`、`renewal_activated`、`queue_activated`、`addon_activated`、`no_valid_package`、`manual_trigger`。同一载体在本次生效前已存在更早的套餐使用记录时 MUST 记为 `renewal_activated`,否则记 `purchase_activated`。 + +#### Scenario: 主套餐购买后立即生效 + +- **WHEN** 一次主套餐订单支付成功,该载体当时没有在用主套餐,且套餐使用记录以生效状态提交 +- **THEN** 系统为该资产的当前在用卡创建优先轮询项,触发类型为 `purchase_activated` + +#### Scenario: 过期后再次购买主套餐 + +- **WHEN** 该载体在本次生效前已存在更早的套餐使用记录,且新的主套餐使用记录以生效状态提交 +- **THEN** 触发类型记为 `renewal_activated` + +#### Scenario: 排队主套餐顺延生效 + +- **WHEN** 一张待生效主套餐使用记录由待生效条件更新为生效,且该条件更新命中一行 +- **THEN** 系统为该资产创建优先轮询项,触发类型为 `queue_activated` + +#### Scenario: 加油包生效 + +- **WHEN** 一次加油包购买使加油包使用记录以生效状态提交 +- **THEN** 系统为该资产创建优先轮询项,触发类型为 `addon_activated` + +#### Scenario: 资产无有效套餐且存在待生效套餐 + +- **WHEN** 普通套餐轮询判定资产无有效套餐,且该载体存在待生效的套餐使用记录 +- **THEN** 系统为该资产的当前在用卡创建优先轮询项,触发类型为 `no_valid_package` + +#### Scenario: 无有效套餐但不存在待同步业务 + +- **WHEN** 普通套餐轮询判定资产无有效套餐,且该载体不存在待生效的套餐使用记录 +- **THEN** 系统不创建优先轮询项,普通轮询按既有间隔继续 + +#### Scenario: 支付失败或订单取消 + +- **WHEN** 套餐订单支付失败、已取消或仍未支付到账 +- **THEN** 系统不创建优先轮询项 + +#### Scenario: 重复支付成功回调 + +- **WHEN** 同一笔已生效套餐订单的支付成功回调重复投递 +- **THEN** 系统不新增优先轮询项,也不产生第二次上游轮询调用 + +### Requirement: 入队执行对象为资产当前在用卡 + +优先轮询项的执行对象 MUST 是卡。触发时系统 MUST 在业务事务内冻结资产的当前在用卡快照:独立卡为卡自身,绑定设备的资产为绑定状态有效的全部在用卡。入队 MUST NOT 以设备上报的当前卡槽作为执行对象口径。同一资产存在多张在用卡时 MUST 逐卡建项,并分别记录每张卡的执行结果。 + +#### Scenario: 绑定设备存在多张在用卡 + +- **WHEN** 一个绑定设备的资产存在三张绑定状态有效的在用卡,且该资产命中触发场景 +- **THEN** 系统为三张卡分别创建优先轮询项,并分别记录每张卡的执行结果 + +#### Scenario: 快照冻结后绑定关系变化 + +- **WHEN** 触发事务提交后该资产的绑定关系或设备上报的当前卡槽发生变化 +- **THEN** 已冻结的执行对象不变,不追溯新增绑定卡 + +### Requirement: 同卡同任务类型的唯一活动项与合并 + +同一卡同一轮询任务类型 MUST 至多存在一条活动优先轮询项;一次优先需求对应多个既有轮询任务类型的执行。同一资产或同一卡的后续触发 MUST 合并进已存在的活动项,追加触发次数、最近触发时间与来源事实,MUST NOT 新建活动项、MUST NOT 重复调用同一轮上游轮询。合并 MUST NOT 因某一任务类型存在活动项而影响该卡其它任务类型的普通轮询。 + +#### Scenario: 自动触发与人工触发先后到达 + +- **WHEN** 同一卡同一任务类型已有活动优先轮询项,管理员再次手动入队 +- **THEN** 系统仅追加触发来源、触发次数与最近触发时间,不创建第二条活动项,也不重复调用轮询 + +#### Scenario: 触发事件重放 + +- **WHEN** 同一触发事件被重复投递 +- **THEN** 系统只合并到已有的活动项,不新增活动项 + +#### Scenario: 同卡不同任务类型 + +- **WHEN** 同一卡的某一个任务类型存在活动优先轮询项 +- **THEN** 该卡其它任务类型的普通轮询执行不受该活动项影响 + +### Requirement: 优先调度与执行提示通道 + +优先轮询项 MUST 以持久化事实为权威;执行提示 MUST 通过每任务类型独立的提示通道下发,MUST NOT 与既有手动触发队列共用通道键。调度器 MUST 在同一调度周期内先排空优先提示通道、再排空手动触发队列。提示通道 MUST NOT 作为活动、权限或尝试次数的判定依据。优先的强度边界 MUST 为仅调度入口领先:跳过定时队列等待并在同一周期内先入队;同一任务类型的既有执行队列 MUST NOT 被插队;分片队列背压跳过 MUST NOT 抑制优先提示入队。本能力 MUST NOT 新建调度设施、异步任务类型或队列。 + +#### Scenario: 提示通道丢失 + +- **WHEN** 执行提示通道内容丢失或缓存服务重启 +- **THEN** 后续普通轮询执行仍按活动优先轮询项认领并执行,仅退化为延迟一个普通轮询周期,不丢事实也不产生第二套补偿 + +#### Scenario: 同一调度周期的排空顺序 + +- **WHEN** 优先提示通道与手动触发队列在同一调度周期都有待处理卡 +- **THEN** 系统先排空优先提示通道,再排空手动触发队列 + +#### Scenario: 分片队列积压 + +- **WHEN** 某任务类型的分片队列深度超过背压阈值 +- **THEN** 普通分片批次被跳过,优先提示仍按既有排空速率入队 + +### Requirement: 优先项认领、租约与执行前校验 + +领取优先轮询项 MUST 以活动项的条件更新为权威认定:仅当该项由待执行更新为执行中且该更新命中时才视为领取成功。执行中且认领租约未到期的项 MUST 拒绝其他执行重复领取。认领租约 MUST 长于既有轮询任务超时并为 90 秒;超过租约仍未被更新的执行中项 MUST 允许后续执行接管领取。执行前系统 MUST 校验该卡仍在轮询范围内(卡自身与绑定设备的轮询开关均为启用);不满足时 MUST NOT 发起上游调用,MUST 以失败终态结束该项并保留可安全展示的原因。 + +#### Scenario: 两次执行并发到达 + +- **WHEN** 同一卡同一任务类型的两个执行轮次同时到达认领接缝 +- **THEN** 只有一个领取成功并发起上游调用,另一个跳过该轮次并延后 + +#### Scenario: 领取者崩溃 + +- **WHEN** 一条执行中优先项超过认领租约仍未被更新 +- **THEN** 相邻执行接管领取并继续执行,该卡该任务类型不被永久跳过 + +#### Scenario: 卡已停止轮询 + +- **WHEN** 该卡自身的轮询开关或绑定设备的轮询开关已关闭 +- **THEN** 系统不发起上游调用,该项以失败终态出队并保留可安全展示的原因 + +### Requirement: 优先项尝试次数与出队 + +优先轮询项 SHALL 使用自身固定的最大尝试次数常量(3),MUST NOT 新增可维护的参数配置项,MUST NOT 改变普通轮询的失败重试策略。尝试次数 MUST 只在真正发起执行后累加。未达上限的可恢复失败 MUST 回到活动状态并沿用既有轮询间隔重新排期;达到上限 MUST 记录失败终态与可安全展示的失败原因后出队。卡不存在等业务校验类失败 MUST 直接进入失败终态且不重试。执行成功 MUST 记录完成终态并出队,资产继续按普通轮询运行。本能力 MUST NOT 引入有效期字段,MUST NOT 建立独立异常记录表。 + +#### Scenario: 可恢复失败未达上限 + +- **WHEN** 一次优先执行因上游调用超时、失败或响应无效而可恢复失败,且尝试次数未达上限 +- **THEN** 该项回到活动状态并按既有轮询间隔重新排期 + +#### Scenario: 达到最大尝试次数 + +- **WHEN** 尝试次数达到上限仍未能成功执行 +- **THEN** 该项记录失败终态与可安全展示的失败原因后出队,该卡继续普通轮询 + +#### Scenario: 卡不存在 + +- **WHEN** 优先执行时该卡已不存在 +- **THEN** 该项直接进入失败终态,不重试也不发起上游调用 + +#### Scenario: 执行成功 + +- **WHEN** 优先执行按既有轮询内容完成 +- **THEN** 该项记录完成终态并出队,资产继续存在于普通轮询 + +### Requirement: 优先轮询事实与查询的可追溯 + +系统 SHALL 记录入队(含合并)、领取、每次失败、重试、完成与失败出队的触发类型、触发来源、操作来源、时间与结果。人工入队 MUST 填写原因,并与入队事实一同保存。系统 MUST 提供最小可读接口(列表与详情,支持筛选与分页),其权限 MUST 与人工入队一致:超级管理员与平台账号可查询全部,代理账号仅可查询其自身及下级店铺资产,企业账号 MUST 被拒绝;查询 MUST 按数据权限下推,越权与不存在 MUST 不可区分。查询 MUST NOT 提供优先级分级、有效期或人工重触发入口。 + +#### Scenario: 代理查询范围外资产 + +- **WHEN** 代理账号查询不在其数据范围内的优先轮询项 +- **THEN** 响应与查询不存在的优先轮询项不可区分 + +#### Scenario: 企业账号入队或查询 + +- **WHEN** 企业账号提交优先入队或查询优先轮询项 +- **THEN** 系统拒绝 + +#### Scenario: 查询结果字段 + +- **WHEN** 授权账号查询优先轮询项列表或详情 +- **THEN** 响应给出触发类型、触发来源、次数、最近触发时间与执行结果,不含优先级分级、有效期或人工重触发入口 + +### Requirement: 人工优先入队不受人工触发防滥用配额约束 + +人工优先入队 MUST NOT 受既有手工轮询的每日触发次数上限与 24 小时去重约束;这两项是人工触发的防滥用配额,不是优先队列不变量。重复抑制 MUST 由活动项合并承担,且每次人工入队 MUST 独立记录审计事实。人工入队 MUST NOT 修改调度优先级,MUST NOT 绕过既有并发上限。 + +#### Scenario: 当日人工触发次数已达上限 + +- **WHEN** 操作者当日手工轮询触发次数已达既有上限后发起优先入队 +- **THEN** 优先入队仍然成功并记录审计 + +#### Scenario: 24 小时内重复人工入队 + +- **WHEN** 操作者在 24 小时内对同一卡同一任务类型再次人工入队 +- **THEN** 系统合并到已有活动项并记录本次人工入队审计,而不是拒绝 + +### Requirement: 与运营商通道阈值停机的边界 + +持运营商通道流量阈值停机锁的卡 MUST 保持在普通轮询范围内。优先轮询 MUST NOT 使该类卡复机;优先执行 MUST 复用既有停复机判定与持锁拒绝语义,MUST NOT 绕过停机锁。 + +#### Scenario: 持锁卡的优先执行 + +- **WHEN** 持通道阈值停机锁的卡处于优先轮询执行中 +- **THEN** 系统不发起复机,并保留既有的持锁拒绝事实 diff --git a/openspec/changes/archive/2026-09-17-add-priority-polling-queue/tasks.md b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/tasks.md new file mode 100644 index 00000000..949cd474 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-priority-polling-queue/tasks.md @@ -0,0 +1,68 @@ +## 1. 迁移与模型存储 + +- [x] 1.1 新增成对迁移 `000228_add_polling_priority_queue`:优先项表(卡、任务类型、状态、触发类型、来源订单/套餐使用记录、触发次数、来源集合、尝试次数、失败原因、人工原因与操作者、店铺快照、各时间列、软删列)、活动项部分唯一索引 `(card_id, task_type)`(仅覆盖待执行/执行中且未软删)、状态与时间索引、全列注释 +- [x] 1.2 down 迁移守卫:存在活动项或未终态行时拒绝回滚并给出中文原因;与 up 严格成对 +- [x] 1.3 新增优先项模型与状态/触发类型取值常量,字段长度、可空与迁移一致;不新增外键与关联标签 +- [x] 1.4 新增存储层:合并插入(显式插入 + 唯一冲突识别,不用 OnConflict 打部分唯一索引)、条件认领、超租约接管、终态更新、按卡批量活动项查询、列表筛选与分页 +- [x] 1.5 新增常量:触发类型枚举、状态枚举、最大尝试次数(3)、认领租约(90 秒)、优先提示通道键生成函数 + +## 2. 触发事件与 Worker 注册 + +- [x] 2.1 新增优先轮询请求事件类型与载荷版本常量;载荷含资源类型、资源 ID、冻结卡快照、触发类型、来源订单、来源套餐使用记录 +- [x] 2.2 新增事件 Writer:只在原业务事务内追加、幂等键稳定;观测结果驱动的业务评估上下文下短路不写 +- [x] 2.3 新增事件消费者:逐卡建立优先项(含合并语义)并下发执行提示;重复投递只合并 +- [x] 2.4 在 Worker 组装处注册该事件类型的消费者,保持事件类型唯一注册 + +## 3. 生效触发点接入 + +- [x] 3.1 主套餐购买后立即生效路径:仅在生效状态分支追加事件;新购/续购按同载体更早套餐使用记录的存在性判定触发类型 +- [x] 3.2 排队主套餐顺延生效路径:条件更新命中一行后追加事件(触发类型 `queue_activated`) +- [x] 3.3 加油包生效路径:以生效状态提交后追加事件(触发类型 `addon_activated`) +- [x] 3.4 在 API 与 Worker 两个组装入口注入事件 Writer +- [x] 3.5 确认支付失败、已取消与未支付到账路径不追加事件 + +## 4. 无有效套餐触发 + +- [x] 4.1 在套餐轮询评估命中「无有效套餐」处增加判定:该载体存在待生效套餐使用记录时追加事件(触发类型 `no_valid_package`),否则不追加 +- [x] 4.2 该场景按资产解析绑定状态有效的全部在用卡(独立卡为自身),在触发事务内冻结快照,不使用当前卡槽口径 + +## 5. 互斥接缝与执行前校验 + +- [x] 5.1 轮询共享基类新增认领接缝:活动项索引探测 → 待执行条件认领 / 执行中跳过并延后 / 无活动项时完全等价既有行为 +- [x] 5.2 四个纳入集合的轮询 Handler 接入接缝(取并发信号量之后、调用上游之前),成功、失败、跳过分支统一收口 +- [x] 5.3 实现认领租约与崩溃接管:执行中且超过 90 秒租约的项允许相邻执行接管 +- [x] 5.4 执行前轮询范围校验(卡自身与绑定设备的轮询开关),不满足时失败终态且不发起上游调用 +- [x] 5.5 尝试次数只在真正发起执行后累加;未达上限回活动态并按既有间隔重排;达上限失败终态并保留安全原因;业务校验类失败不重试 +- [x] 5.6 确认未引入通用卡级锁与 Redis 活动标记,且分片队列的出队、入队与移除路径未被改动 + +## 6. 提示通道与调度先排空 + +- [x] 6.1 队列管理新增优先提示下发:每任务类型独立键,不复用手动触发队列键 +- [x] 6.2 调度器在同一周期内先排空优先提示、再排空手动触发队列;提示排空不受分片背压跳过影响 +- [x] 6.3 确认未新建调度设施、未新增异步任务类型或队列 + +## 7. 人工入队、读侧接口与路由装配 + +- [x] 7.1 抽取既有人工触发权限判定为同包共享函数(超管/平台放行、代理限自身与下级、平台卡不可见、企业拒绝),判定语义不变 +- [x] 7.2 人工优先入队用例:解析资产标识、权限校验、原因必填、写事实并合并、下发提示、写审计;不受每日次数上限与 24 小时去重约束 +- [x] 7.3 读侧列表与详情用例:筛选、分页、按数据范围下推,越权与不存在响应不可区分 +- [x] 7.4 Handler 与路由注册(RouteSpec 摘要、标签、入参与出参类型),仅挂在既有后台认证中间件下 +- [x] 7.5 同步文档装配三处:文档工厂、API 组装与文档生成入口 +- [x] 7.6 事实表冗余卡所属店铺快照,供数据范围下推使用 + +## 8. 审计常量与注册 + +- [x] 8.1 新增轮询优先队列审计动作与资源常量(入队、领取、失败、重试、完成、失败出队、人工拒绝) +- [x] 8.2 按(操作者类型,来源)注册动作定义,人工侧与 Worker 侧均能通过来源校验 +- [x] 8.3 接入写入点:人工侧复用既有轮询审计输入与失败短事务模式;系统侧复用既有 Worker 审计上下文 + +## 9. 验证 + +- [x] 9.1 在受控隔离环境执行迁移 up/down/up;确认 down 守卫在有活动项时拒绝回滚 +- [x] 9.2 验证三种生效触发、无有效套餐(含无待同步业务不入队)、支付失败与取消不入队、重复回调与事件重放幂等 +- [x] 9.3 验证合并(人工 + 自动)、同卡不同任务类型互不干扰、普通轮询互斥不产生第二次上游调用 +- [x] 9.4 验证认领租约接管、提示通道丢失兜底、执行前轮询范围校验、尝试上限与终态出队 +- [x] 9.5 验证权限矩阵:超管/平台全量、代理限自身与下级、范围外与平台卡不可见、企业拒绝 +- [x] 9.6 验证触发类型枚举:新购/续购判定、加油包、排队顺延、无有效套餐、人工 +- [x] 9.7 验证通道阈值边界:持锁卡的优先执行不复机且保留既有拒绝事实 +- [x] 9.8 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-priority-polling-queue --strict`、`openspec doctor --json`、`./scripts/context-health.sh`;自动化测试按项目决策为 N/A diff --git a/openspec/specs/polling-operations/spec.md b/openspec/specs/polling-operations/spec.md index 8d35e0dc..3fa00575 100644 --- a/openspec/specs/polling-operations/spec.md +++ b/openspec/specs/polling-operations/spec.md @@ -90,6 +90,30 @@ - **WHEN** 未实名普通卡的有效设备绑定或设备策略查询失败 - **THEN** 系统报告错误且不对该卡发起本次停复机命令,不按卡策略回退放行 +### Requirement: 优先轮询项与普通轮询的单次执行互斥 + +系统 SHALL 在同一卡同一轮询任务类型存在优先轮询项时,只允许一个执行轮次对该卡该任务类型发起上游调用:待执行优先项由本轮执行认领并作为优先执行完成该轮轮询,MUST NOT 另外产生第二次调用;执行中且认领租约未到期的优先项使本轮执行跳过该轮次并延后,MUST NOT 发起上游调用。不存在活动优先项时,普通轮询的入队、出队、移除与延后行为 MUST 与当前行为一致。优先项存在 MUST NOT 使该卡被移出普通轮询,MUST NOT 改变其它轮询任务类型的执行。 + +#### Scenario: 待执行优先项遇到本轮执行 + +- **WHEN** 该卡该轮询任务类型存在待执行优先项,且该任务类型的执行轮次到达 +- **THEN** 系统认领该项并完成本轮轮询,不产生额外的第二次上游调用 + +#### Scenario: 已领取优先项遇到本轮的另一次执行 + +- **WHEN** 该卡该轮询任务类型存在执行中且认领租约未到期的优先项,该任务类型又到达一次执行 +- **THEN** 系统跳过该轮次并按既有间隔延后,不发起上游调用 + +#### Scenario: 不存在活动优先项 + +- **WHEN** 该卡该轮询任务类型不存在活动优先项 +- **THEN** 轮询执行的排队、入队、出队与移除行为与当前行为一致 + +#### Scenario: 优先项结束后的普通轮询 + +- **WHEN** 该卡该轮询任务类型的优先项已完成或失败出队 +- **THEN** 该卡继续按普通轮询间隔执行,仍存在于普通轮询中 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 @@ -117,3 +141,7 @@ ### 轮询管理-监控 `GET /api/admin/polling-stats`(获取轮询总览统计);`GET /api/admin/polling-stats/init-progress`(获取轮询初始化进度);`GET /api/admin/polling-stats/queues`(获取轮询队列状态);`GET /api/admin/polling-stats/tasks`(获取轮询任务统计)。 + +### 轮询管理-优先队列 + +`POST /api/admin/polling-priority-items`(人工优先入队);`GET /api/admin/polling-priority-items`(查询优先轮询项列表);`GET /api/admin/polling-priority-items/{id}`(查询优先轮询项详情)。 diff --git a/openspec/specs/priority-polling-queue/spec.md b/openspec/specs/priority-polling-queue/spec.md new file mode 100644 index 00000000..5865e218 --- /dev/null +++ b/openspec/specs/priority-polling-queue/spec.md @@ -0,0 +1,188 @@ +# priority-polling-queue Specification + +## Purpose + +在不改变既有普通轮询内容、并发上限、失败重试与外部调用保护的前提下,为购买生效、续购顺延生效、加油包生效、无有效套餐与人工加急的资产提供额外优先执行的轮询调度,并保留可追踪的加急事实。 + +## Requirements + +### Requirement: 优先轮询触发场景与入队事实 + +系统 SHALL 在下列业务事实提交成功后,为关联资产的当前在用卡创建优先轮询项:主套餐购买后立即生效、排队主套餐顺延生效、加油包生效、资产无有效套餐且存在待生效的套餐使用记录。具备既有轮询手动触发权限的后台账号亦可为其数据范围内资产入队。入队 MUST 等待订单、支付与套餐使用记录提交成功;支付失败、已取消或未支付到账的订单 MUST NOT 入队。触发类型 MUST 记录为下列稳定枚举之一:`purchase_activated`、`renewal_activated`、`queue_activated`、`addon_activated`、`no_valid_package`、`manual_trigger`。同一载体在本次生效前已存在更早的套餐使用记录时 MUST 记为 `renewal_activated`,否则记 `purchase_activated`。 + +#### Scenario: 主套餐购买后立即生效 + +- **WHEN** 一次主套餐订单支付成功,该载体当时没有在用主套餐,且套餐使用记录以生效状态提交 +- **THEN** 系统为该资产的当前在用卡创建优先轮询项,触发类型为 `purchase_activated` + +#### Scenario: 过期后再次购买主套餐 + +- **WHEN** 该载体在本次生效前已存在更早的套餐使用记录,且新的主套餐使用记录以生效状态提交 +- **THEN** 触发类型记为 `renewal_activated` + +#### Scenario: 排队主套餐顺延生效 + +- **WHEN** 一张待生效主套餐使用记录由待生效条件更新为生效,且该条件更新命中一行 +- **THEN** 系统为该资产创建优先轮询项,触发类型为 `queue_activated` + +#### Scenario: 加油包生效 + +- **WHEN** 一次加油包购买使加油包使用记录以生效状态提交 +- **THEN** 系统为该资产创建优先轮询项,触发类型为 `addon_activated` + +#### Scenario: 资产无有效套餐且存在待生效套餐 + +- **WHEN** 普通套餐轮询判定资产无有效套餐,且该载体存在待生效的套餐使用记录 +- **THEN** 系统为该资产的当前在用卡创建优先轮询项,触发类型为 `no_valid_package` + +#### Scenario: 无有效套餐但不存在待同步业务 + +- **WHEN** 普通套餐轮询判定资产无有效套餐,且该载体不存在待生效的套餐使用记录 +- **THEN** 系统不创建优先轮询项,普通轮询按既有间隔继续 + +#### Scenario: 支付失败或订单取消 + +- **WHEN** 套餐订单支付失败、已取消或仍未支付到账 +- **THEN** 系统不创建优先轮询项 + +#### Scenario: 重复支付成功回调 + +- **WHEN** 同一笔已生效套餐订单的支付成功回调重复投递 +- **THEN** 系统不新增优先轮询项,也不产生第二次上游轮询调用 + +### Requirement: 入队执行对象为资产当前在用卡 + +优先轮询项的执行对象 MUST 是卡。触发时系统 MUST 在业务事务内冻结资产的当前在用卡快照:独立卡为卡自身,绑定设备的资产为绑定状态有效的全部在用卡。入队 MUST NOT 以设备上报的当前卡槽作为执行对象口径。同一资产存在多张在用卡时 MUST 逐卡建项,并分别记录每张卡的执行结果。 + +#### Scenario: 绑定设备存在多张在用卡 + +- **WHEN** 一个绑定设备的资产存在三张绑定状态有效的在用卡,且该资产命中触发场景 +- **THEN** 系统为三张卡分别创建优先轮询项,并分别记录每张卡的执行结果 + +#### Scenario: 快照冻结后绑定关系变化 + +- **WHEN** 触发事务提交后该资产的绑定关系或设备上报的当前卡槽发生变化 +- **THEN** 已冻结的执行对象不变,不追溯新增绑定卡 + +### Requirement: 同卡同任务类型的唯一活动项与合并 + +同一卡同一轮询任务类型 MUST 至多存在一条活动优先轮询项;一次优先需求对应多个既有轮询任务类型的执行。同一资产或同一卡的后续触发 MUST 合并进已存在的活动项,追加触发次数、最近触发时间与来源事实,MUST NOT 新建活动项、MUST NOT 重复调用同一轮上游轮询。合并 MUST NOT 因某一任务类型存在活动项而影响该卡其它任务类型的普通轮询。 + +#### Scenario: 自动触发与人工触发先后到达 + +- **WHEN** 同一卡同一任务类型已有活动优先轮询项,管理员再次手动入队 +- **THEN** 系统仅追加触发来源、触发次数与最近触发时间,不创建第二条活动项,也不重复调用轮询 + +#### Scenario: 触发事件重放 + +- **WHEN** 同一触发事件被重复投递 +- **THEN** 系统只合并到已有的活动项,不新增活动项 + +#### Scenario: 同卡不同任务类型 + +- **WHEN** 同一卡的某一个任务类型存在活动优先轮询项 +- **THEN** 该卡其它任务类型的普通轮询执行不受该活动项影响 + +### Requirement: 优先调度与执行提示通道 + +优先轮询项 MUST 以持久化事实为权威;执行提示 MUST 通过每任务类型独立的提示通道下发,MUST NOT 与既有手动触发队列共用通道键。调度器 MUST 在同一调度周期内先排空优先提示通道、再排空手动触发队列。提示通道 MUST NOT 作为活动、权限或尝试次数的判定依据。优先的强度边界 MUST 为仅调度入口领先:跳过定时队列等待并在同一周期内先入队;同一任务类型的既有执行队列 MUST NOT 被插队;分片队列背压跳过 MUST NOT 抑制优先提示入队。本能力 MUST NOT 新建调度设施、异步任务类型或队列。 + +#### Scenario: 提示通道丢失 + +- **WHEN** 执行提示通道内容丢失或缓存服务重启 +- **THEN** 后续普通轮询执行仍按活动优先轮询项认领并执行,仅退化为延迟一个普通轮询周期,不丢事实也不产生第二套补偿 + +#### Scenario: 同一调度周期的排空顺序 + +- **WHEN** 优先提示通道与手动触发队列在同一调度周期都有待处理卡 +- **THEN** 系统先排空优先提示通道,再排空手动触发队列 + +#### Scenario: 分片队列积压 + +- **WHEN** 某任务类型的分片队列深度超过背压阈值 +- **THEN** 普通分片批次被跳过,优先提示仍按既有排空速率入队 + +### Requirement: 优先项认领、租约与执行前校验 + +领取优先轮询项 MUST 以活动项的条件更新为权威认定:仅当该项由待执行更新为执行中且该更新命中时才视为领取成功。执行中且认领租约未到期的项 MUST 拒绝其他执行重复领取。认领租约 MUST 长于既有轮询任务超时并为 90 秒;超过租约仍未被更新的执行中项 MUST 允许后续执行接管领取。执行前系统 MUST 校验该卡仍在轮询范围内(卡自身与绑定设备的轮询开关均为启用);不满足时 MUST NOT 发起上游调用,MUST 以失败终态结束该项并保留可安全展示的原因。 + +#### Scenario: 两次执行并发到达 + +- **WHEN** 同一卡同一任务类型的两个执行轮次同时到达认领接缝 +- **THEN** 只有一个领取成功并发起上游调用,另一个跳过该轮次并延后 + +#### Scenario: 领取者崩溃 + +- **WHEN** 一条执行中优先项超过认领租约仍未被更新 +- **THEN** 相邻执行接管领取并继续执行,该卡该任务类型不被永久跳过 + +#### Scenario: 卡已停止轮询 + +- **WHEN** 该卡自身的轮询开关或绑定设备的轮询开关已关闭 +- **THEN** 系统不发起上游调用,该项以失败终态出队并保留可安全展示的原因 + +### Requirement: 优先项尝试次数与出队 + +优先轮询项 SHALL 使用自身固定的最大尝试次数常量(3),MUST NOT 新增可维护的参数配置项,MUST NOT 改变普通轮询的失败重试策略。尝试次数 MUST 只在真正发起执行后累加。未达上限的可恢复失败 MUST 回到活动状态并沿用既有轮询间隔重新排期;达到上限 MUST 记录失败终态与可安全展示的失败原因后出队。卡不存在等业务校验类失败 MUST 直接进入失败终态且不重试。执行成功 MUST 记录完成终态并出队,资产继续按普通轮询运行。本能力 MUST NOT 引入有效期字段,MUST NOT 建立独立异常记录表。 + +#### Scenario: 可恢复失败未达上限 + +- **WHEN** 一次优先执行因上游调用超时、失败或响应无效而可恢复失败,且尝试次数未达上限 +- **THEN** 该项回到活动状态并按既有轮询间隔重新排期 + +#### Scenario: 达到最大尝试次数 + +- **WHEN** 尝试次数达到上限仍未能成功执行 +- **THEN** 该项记录失败终态与可安全展示的失败原因后出队,该卡继续普通轮询 + +#### Scenario: 卡不存在 + +- **WHEN** 优先执行时该卡已不存在 +- **THEN** 该项直接进入失败终态,不重试也不发起上游调用 + +#### Scenario: 执行成功 + +- **WHEN** 优先执行按既有轮询内容完成 +- **THEN** 该项记录完成终态并出队,资产继续存在于普通轮询 + +### Requirement: 优先轮询事实与查询的可追溯 + +系统 SHALL 记录入队(含合并)、领取、每次失败、重试、完成与失败出队的触发类型、触发来源、操作来源、时间与结果。人工入队 MUST 填写原因,并与入队事实一同保存。系统 MUST 提供最小可读接口(列表与详情,支持筛选与分页),其权限 MUST 与人工入队一致:超级管理员与平台账号可查询全部,代理账号仅可查询其自身及下级店铺资产,企业账号 MUST 被拒绝;查询 MUST 按数据权限下推,越权与不存在 MUST 不可区分。查询 MUST NOT 提供优先级分级、有效期或人工重触发入口。 + +#### Scenario: 代理查询范围外资产 + +- **WHEN** 代理账号查询不在其数据范围内的优先轮询项 +- **THEN** 响应与查询不存在的优先轮询项不可区分 + +#### Scenario: 企业账号入队或查询 + +- **WHEN** 企业账号提交优先入队或查询优先轮询项 +- **THEN** 系统拒绝 + +#### Scenario: 查询结果字段 + +- **WHEN** 授权账号查询优先轮询项列表或详情 +- **THEN** 响应给出触发类型、触发来源、次数、最近触发时间与执行结果,不含优先级分级、有效期或人工重触发入口 + +### Requirement: 人工优先入队不受人工触发防滥用配额约束 + +人工优先入队 MUST NOT 受既有手工轮询的每日触发次数上限与 24 小时去重约束;这两项是人工触发的防滥用配额,不是优先队列不变量。重复抑制 MUST 由活动项合并承担,且每次人工入队 MUST 独立记录审计事实。人工入队 MUST NOT 修改调度优先级,MUST NOT 绕过既有并发上限。 + +#### Scenario: 当日人工触发次数已达上限 + +- **WHEN** 操作者当日手工轮询触发次数已达既有上限后发起优先入队 +- **THEN** 优先入队仍然成功并记录审计 + +#### Scenario: 24 小时内重复人工入队 + +- **WHEN** 操作者在 24 小时内对同一卡同一任务类型再次人工入队 +- **THEN** 系统合并到已有活动项并记录本次人工入队审计,而不是拒绝 + +### Requirement: 与运营商通道阈值停机的边界 + +持运营商通道流量阈值停机锁的卡 MUST 保持在普通轮询范围内。优先轮询 MUST NOT 使该类卡复机;优先执行 MUST 复用既有停复机判定与持锁拒绝语义,MUST NOT 绕过停机锁。 + +#### Scenario: 持锁卡的优先执行 + +- **WHEN** 持通道阈值停机锁的卡处于优先轮询执行中 +- **THEN** 系统不发起复机,并保留既有的持锁拒绝事实 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index 768084c1..fcca93ad 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -594,6 +594,20 @@ const ( AuditActionPollingManualTriggerByCondition = "polling_manual_trigger.trigger_by_condition" // AuditActionPollingManualCancelled 表示人工取消轮询任务。 AuditActionPollingManualCancelled = "polling_manual_trigger.cancel_trigger" + // AuditActionPollingPriorityEnqueued 表示建立或合并卡轮询优先项(自动触发与人工入队共用)。 + AuditActionPollingPriorityEnqueued = "polling_priority.enqueue" + // AuditActionPollingPriorityClaimed 表示轮询执行领取卡轮询优先项。 + AuditActionPollingPriorityClaimed = "polling_priority.claim" + // AuditActionPollingPriorityFailed 表示一次卡轮询优先执行失败。 + AuditActionPollingPriorityFailed = "polling_priority.fail" + // AuditActionPollingPriorityRetried 表示未达尝试上限的失败退回活动态重新排期。 + AuditActionPollingPriorityRetried = "polling_priority.retry" + // AuditActionPollingPriorityCompleted 表示卡轮询优先项执行完成并出队。 + AuditActionPollingPriorityCompleted = "polling_priority.complete" + // AuditActionPollingPriorityDequeued 表示达到尝试上限的卡轮询优先项失败出队。 + AuditActionPollingPriorityDequeued = "polling_priority.dequeue" + // AuditActionPollingPriorityManualDenied 表示拒绝一次人工优先入队。 + AuditActionPollingPriorityManualDenied = "polling_priority.manual_denied" // AuditActionWeComCredentialsRead 表示读取企业微信应用明文凭据。 AuditActionWeComCredentialsRead = "wecom.application.credentials_read" // AuditActionRoleCreated 表示创建角色。 @@ -731,6 +745,8 @@ const ( AuditResourcePollingAlertRule = "polling_alert" // AuditResourcePollingManualTrigger 表示手动轮询任务资源。 AuditResourcePollingManualTrigger = "polling_manual_trigger" + // AuditResourcePollingPriorityItem 表示卡轮询优先项资源。 + AuditResourcePollingPriorityItem = "polling_priority_item" // AuditResourceDevice 表示设备资源。 AuditResourceDevice = "device" // AuditResourceIotCard 表示 IoT 卡资源。 diff --git a/pkg/constants/polling.go b/pkg/constants/polling.go index ab1280ca..c77b2fa9 100644 --- a/pkg/constants/polling.go +++ b/pkg/constants/polling.go @@ -39,3 +39,145 @@ const ( // PollingManualTriggerStatusCancelled 轮询手动触发日志状态-已取消 PollingManualTriggerStatusCancelled = "cancelled" ) + +// PollingPriorityMaxAttempts 优先轮询项固定的最大尝试次数。 +// 尝试次数只在真正发起执行后累加;达到上限记录失败终态并出队。 +// 本值不是可维护配置项,也不改变普通轮询的无限重试策略。 +const PollingPriorityMaxAttempts = 3 + +// PollingPriorityClaimLease 优先轮询项的认领租约(秒)。 +// 必须长于既有轮询任务超时 60 秒,为执行收尾与重新入队留出余量; +// 执行中且超过租约仍未被更新的优先项允许相邻执行接管领取。 +const PollingPriorityClaimLease = 90 + +// 轮询优先队列状态常量 +const ( + // PollingPriorityStatusPending 优先轮询项状态-待执行(活动项) + PollingPriorityStatusPending = "pending" + // PollingPriorityStatusProcessing 优先轮询项状态-执行中(活动项,受认领租约保护) + PollingPriorityStatusProcessing = "processing" + // PollingPriorityStatusCompleted 优先轮询项状态-已完成(终态,已出队) + PollingPriorityStatusCompleted = "completed" + // PollingPriorityStatusFailed 优先轮询项状态-失败出队(终态) + PollingPriorityStatusFailed = "failed" +) + +// 轮询优先项触发类型常量 +const ( + // PollingPriorityTriggerPurchaseActivated 主套餐购买后立即生效 + PollingPriorityTriggerPurchaseActivated = "purchase_activated" + // PollingPriorityTriggerRenewalActivated 同载体在本次生效前已存在更早套餐使用记录 + PollingPriorityTriggerRenewalActivated = "renewal_activated" + // PollingPriorityTriggerQueueActivated 排队主套餐顺延生效 + PollingPriorityTriggerQueueActivated = "queue_activated" + // PollingPriorityTriggerAddonActivated 加油包生效 + PollingPriorityTriggerAddonActivated = "addon_activated" + // PollingPriorityTriggerNoValidPackage 资产无有效套餐且存在待生效套餐使用记录 + PollingPriorityTriggerNoValidPackage = "no_valid_package" + // PollingPriorityTriggerManual 具备既有人工触发权限的账号人工入队 + PollingPriorityTriggerManual = "manual_trigger" +) + +// PollingPriorityManualReasonMaxLength 是人工优先入队原因的长度上限,与事实表 manual_reason 列一致。 +const PollingPriorityManualReasonMaxLength = 500 + +// 轮询优先项执行结果常量 +const ( + // PollingPriorityResultSuccess 优先轮询项执行成功 + PollingPriorityResultSuccess = "success" + // PollingPriorityResultFailed 优先轮询项执行失败 + PollingPriorityResultFailed = "failed" +) + +// 轮询优先项请求可靠事件常量 +const ( + // OutboxEventTypePollingPriorityRequested 表示业务成功事实请求为该资源的卡建立优先轮询项。 + // 载荷中的卡快照在业务事务内冻结,消费者据此逐卡建项并下发执行提示。 + OutboxEventTypePollingPriorityRequested = "polling.priority.requested" + // PollingPriorityRequestedPayloadVersionV1 是优先轮询请求事件载荷版本。 + PollingPriorityRequestedPayloadVersionV1 = 1 + // PollingPriorityEventKeyPrefix 是优先轮询请求事件的 event_id 命名空间前缀。 + // 必须与既有 card-observation: 前缀分离:公共 Outbox 的 event_id 是全局幂等键, + // 复用既有前缀会被 OnConflict DoNothing 静默去重,导致加急永不生效且无任何报错。 + // + // 长度预算:Outbox 的 event_id 列为 varchar(64),前缀与后缀共同保证生成的 ID 明显低于上限 + // (当前最长形式为 prio:nvp:{载体检字}:{载体ID}:{秒级时间戳},不超过 43 字符)。 + // 前缀与后缀都不得再加长;新增场景必须按同一预算核算。 + PollingPriorityEventKeyPrefix = "prio:" +) + +// PollingPriorityTaskTypes 返回纳入优先轮询的既有轮询任务类型。 +// 一次优先需求对应这些任务类型各执行一次;保护期一致性检查不在集合内。 +// 返回新切片,调用方修改不会影响其它调用方。 +func PollingPriorityTaskTypes() []string { + return []string{ + TaskTypePollingRealname, + TaskTypePollingCarddata, + TaskTypePollingPackage, + TaskTypePollingCardStatus, + } +} + +// PollingPriorityStatusName 返回优先轮询项状态的中文名称。 +func PollingPriorityStatusName(status string) string { + switch status { + case PollingPriorityStatusPending: + return "待执行" + case PollingPriorityStatusProcessing: + return "执行中" + case PollingPriorityStatusCompleted: + return "已完成" + case PollingPriorityStatusFailed: + return "失败出队" + default: + return "未知" + } +} + +// PollingPriorityTriggerName 返回优先轮询项触发类型的中文名称。 +func PollingPriorityTriggerName(triggerType string) string { + switch triggerType { + case PollingPriorityTriggerPurchaseActivated: + return "主套餐购买后立即生效" + case PollingPriorityTriggerRenewalActivated: + return "续购套餐生效" + case PollingPriorityTriggerQueueActivated: + return "排队主套餐顺延生效" + case PollingPriorityTriggerAddonActivated: + return "加油包生效" + case PollingPriorityTriggerNoValidPackage: + return "资产无有效套餐" + case PollingPriorityTriggerManual: + return "人工入队" + default: + return "未知" + } +} + +// PollingPriorityResultName 返回优先轮询项执行结果的中文名称。 +func PollingPriorityResultName(result string) string { + switch result { + case PollingPriorityResultSuccess: + return "成功" + case PollingPriorityResultFailed: + return "失败" + default: + return "未出结果" + } +} + +// PollingPriorityTaskTypeName 返回优先轮询任务类型的中文名称。 +func PollingPriorityTaskTypeName(taskType string) string { + switch taskType { + case TaskTypePollingRealname: + return "实名检查" + case TaskTypePollingCarddata: + return "流量检查" + case TaskTypePollingPackage: + return "套餐检查" + case TaskTypePollingCardStatus: + return "卡状态检查" + default: + return "未知" + } +} diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 9c40667c..737833aa 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -308,6 +308,15 @@ func RedisPollingManualDedupeKey(taskType string) string { return fmt.Sprintf("polling:manual:dedupe:%s", taskType) } +// RedisPollingPriorityQueueKey 生成优先轮询提示通道的 Redis 键 +// 用途:List 存储需要优先执行的卡 ID;与手动触发队列(polling:manual:{taskType})分离, +// 使调度器在同一调度周期内先排空优先提示、再排空手动触发队列。 +// 提示通道不是权威:丢失时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行。 +// 过期时间:无(临时队列) +func RedisPollingPriorityQueueKey(taskType string) string { + return fmt.Sprintf("polling:priority:%s", taskType) +} + // RedisPollingStatsKey 生成监控统计的 Redis 键 // 用途:Hash 存储监控指标(成功数、失败数、总耗时等) // 过期时间:无(持久化统计) diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 6782b7f3..f500fd9d 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -68,6 +68,7 @@ func BuildDocHandlers() *bootstrap.Handlers { PollingAlert: admin.NewPollingAlertHandler(nil), PollingCleanup: admin.NewPollingCleanupHandler(nil), PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), + PollingPriority: admin.NewPriorityPollingHandler(nil, nil, nil), Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil, nil), AssetLifecycle: admin.NewAssetLifecycleHandler(nil), AssetWallet: admin.NewAssetWalletHandler(nil), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index dbbb342f..61c01816 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -377,6 +377,7 @@ func (h *Handler) registerAutoPurchaseHandler() { h.asynqClient, h.logger, h.workerResult.Services.ObservationSeriesEvents, + h.workerResult.Services.PriorityEvents, audit.NewWriter(audit.NewRegistry(), nil), ) h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 9ee842e0..78e32c0f 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -6,6 +6,7 @@ import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierthresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -66,6 +67,7 @@ type WorkerServices struct { CardObservation *cardObservationApp.Service CardObservationSeries *cardObservationApp.SeriesAttemptService ObservationSeriesEvents cardObservationApp.SeriesEventWriter + PriorityEvents priorityapp.PriorityEventWriter CommissionCalculation *commission_calculation.Service CommissionStats *commission_stats.Service UsageService *packagepkg.UsageService