Procházet zdrojové kódy

docs(omg-pay): 同步 spec-kit 文档至代码现状(T055-T061 状态 + 闸1 移除)

代码已变更但 spec-kit 文档未同步,本次回写(按 spec-kit 工作流:变更回写现有 spec/tasks):

tasks.md Phase 10:
- T055 [x] 节流闸(原"三道"→两道;闸1 首查延迟40min 已移除 fc46fed——是对 OMG 节流的误读)
- T058 [x] create 复用预检(000f47f;PaymentCreateGuardService afterCompletion 锁 + isSynchronizationActive 自愈)
- T059 [x] 读侧迁移(OmgPayController 4 处 db043b4 + OrderLifecycleService 3 处 b9a0514)
- T060 [x] 砍掉(双付不现实,退款维持退单条已付行即可)
- T061 [x] 砍掉(用户:不管历史数据)
- Checkpoint + T062 端到端验证描述同步(去掉退款遍历/三道节流闸)

payment-attempt-lifecycle.md:
- §6.4【C】三道节流闸 → 两道频率闸,新增闸1 移除说明
  (OMG 403 是"短时间反复查"频率触发,非"下单后 N 分钟不能查";已付款可立即查;
   "刚创建未付"由扫描窗口 2min 宽限保护,无需 40min 黑名单)
- kill-shot 表 ③ + 散落"三道节流闸"措辞同步为"两道"

Co-Authored-By: Claude <noreply@anthropic.com>
qmj před 2 týdny
rodič
revize
be9b19a455

+ 12 - 10
specs/016-omg-payment/payment-attempt-lifecycle.md

@@ -25,11 +25,11 @@
 **§8 落地顺序调整**(P0-诊断/P0-修B 已完成):
 - ✅ P0-诊断 + P0-修B(bugB 验签)+ parseKvResponse 尾随&。
 - ⏳ collation 待执行。
-- ➡️ **接下来**:P0-补单(reconcile 两段式 + 道节流闸)→ P0-防堆积(create 复用 + DDL is_active/expire_date)→ 读侧(7 处 getLatestByDdId 迁移)→ 退款(listPaidByDdId 遍历)。
+- ➡️ **接下来**:P0-补单(reconcile 两段式 + 道节流闸)→ P0-防堆积(create 复用 + DDL is_active/expire_date)→ 读侧(7 处 getLatestByDdId 迁移)→ 退款(listPaidByDdId 遍历)。
 
 **对抗发现的前置补强**(加进落地):
 - parseKvResponse **重复键放宽**(high):S 依赖 queryTrade 做换号/补单安全网,但重复键仍抛(IllegalArgumentException)→ 大响应(ATM/超商 NeedExtraPaidInfo=Y 47 字段)若含重复键 → queryTrade 返 {0,0} → 安全网失效。落地前补(重复键 last-wins + 同 Map 验签避 kill-shot ⑥)+ 抓 stage 真实 fixture。
-- 道节流闸(防 OMG 403 自残,§6.4【C】)。
+- 道节流闸(防 OMG 403 自残,§6.4【C】)。
 - expire_date 字段(§6.1,paymentInfo 回调落)。
 
 **§9 待拍板项**:freshMin / MySQL 版本 / reuse MerchantTradeDate / 节流参数 / AFTEE 窗口等 → 实现/stage 确认(不阻塞 spec)。
@@ -189,16 +189,18 @@ ORDER BY p.id ASC LIMIT #{batchSize}
 
 **【B. 补单主体】** `reconcileByQuery`(L760)把 L769 `getLatestByDdId` 换成两段:
 1. **先 `selectLatestPaidByDdId`**:若存在 `pay_status=1` 行且 `order.payStatus==0` → `handlePaymentSuccess` 自愈补推(保留原 L774-781,致命伤 ⑤ 消除);若已付且订单已核销 → 幂等返回 `{1,0}`。
-2. **否则 `listUnpaidByDdId` 循环**遍历全量 `pay_status=0` 行(is_active 不限,旧行也在内)。每行 `queryTrade` 前过道节流闸,按 `TradeStatus` 分支:
+2. **否则 `listUnpaidByDdId` 循环**遍历全量 `pay_status=0` 行(is_active 不限,旧行也在内)。每行 `queryTrade` 前过道节流闸,按 `TradeStatus` 分支:
    - `1` 且金额/字段校验过 → `applyPaidResult`;任何已付即 break。
    - `0` → **no-op**(不 markFail 不撤换,直接 continue,日志 info)。
    - `10200095` 且**该行 `trade_no IS NULL`**(从未与 OMG 建立交易)→ `markFail(0→2)`(CAS 允许 2→1 复活,安全)。
    - `10200095` 且 `trade_no` 非空(已取号,可能 OMG 延迟建案或结算滞后)→ no-op + log.warn,绝不 markFail。
 
-**【C. 三道节流闸(防 OMG 403 自残 DoS,致命伤 ③)】**
-1. **首查延迟**:跳过 `create_time < NOW()-INTERVAL 40 MINUTE` 的行。
-2. **per-MerchantID 令牌桶**:Redis key `omg:qt:token:{merchantId}`,1 token / 3s,burst 1——`queryTrade` 前 acquire,空则 skip 本行本轮(不阻塞)。
-3. **per-ddId 扫描间隔**:定时任务 Redis key `omg:reconcile:dd:{ddId}` TTL 180s(每 ddId 最少 3 分钟一轮);`/query` 被动端点 Redis key `omg:query:dd:{ddId}` TTL 60s(窗口内直接返回上次结果或 202,挡用户 2s 轮询放大)。
+**【C. 节流闸(防 OMG 403 自残 DoS,致命伤 ③)——两道,频率制】**
+
+> ⚠ 原"闸1 首查延迟 40min"已于 2026-08-12 移除(commit fc46fed):是对 OMG 节流的**误读**——OMG 的 403 是"短时间内反复查同一笔"(频率)触发,**不是**"下单后 N 分钟内不能查"。已付款交易可立即查状态(付完即查是行业标准)。原闸1 会让已付客户 notify 丢失时等 40min 才看到状态变更,不可接受。"刚创建未付"由定时任务扫描窗口的 2min 宽限(`create_time <= now-2min`)保护,无需 40min 黑名单。
+
+1. **per-MerchantID 令牌桶**:Redis key `omg:qt:token:{merchantId}`,1 token / 3s,burst 1——`queryTrade` 前 acquire,空则 skip 本行本轮(不阻塞)。
+2. **per-ddId 扫描间隔**:定时任务 Redis key `omg:reconcile:dd:{ddId}` TTL 180s(每 ddId 最少 3 分钟一轮);`/query` 被动端点 Redis key `omg:query:dd:{ddId}` TTL 60s(窗口内直接返回上次结果或 202,挡用户 2s 轮询放大)。
 
 **【D. 可观测性 + parseKvResponse(致命伤 ⑥)】**
 - 三个 `{0,0}` 分支(L789 凭证 null / L797 queryTrade 异常 / L830 TradeStatus=0)拆成结构化 `log.error` 带 ddId/mtcn/source/异常类全名/响应前 500 字节;原始响应写 `ipn_log(type=omg_query_error)` 供事后追查 id=26 根因。
@@ -235,7 +237,7 @@ ORDER BY p.id ASC LIMIT #{batchSize}
 |-----------|------|
 | ① 换号塞进 pay_status → CAS 不命中 | 双轴:轮换只翻 is_active,永不碰 pay_status;TradeStatus=0 一律 no-op |
 | ② 复用已占用 MTN | `selectActiveForReuse` 守卫 `trade_no IS NULL` + 新鲜期 |
-| ③ OMG 403 自残 DoS | 三道节流闸(首查≥40min、per-MerchantID 令牌桶 1/3s、per-ddId TTL) |
+| ③ OMG 403 自残 DoS | 两道频率闸(per-MerchantID 令牌桶 1/3s、per-ddId TTL);原"首查≥40min"已移除(误读 OMG 节流,403 是频率触发) |
 | ④ 7 处 getLatestByDdId 读错行 | 全量迁移表(6.4【E】) |
 | ⑤ L774 自愈变死代码 | reconcile 两段式,先 selectLatestPaidByDdId 自愈 |
 | ⑥ parseKvResponse 放宽与验签打架 | 重复键不静默丢;真实 fixture 验签;保留缺 CheckMacValue 即抛 |
@@ -251,7 +253,7 @@ ORDER BY p.id ASC LIMIT #{batchSize}
 
 1. **P0-诊断**:三分支日志 + ipn_log(0 改动),复现定位 id=26。
 2. **P0-修 B**:`parseKvResponse` 放宽(尾随&/空值,不丢重复键)+ `catch(Exception)` 改 log.error+UNKNOWN(3) + `OmgPayTest` 补真实 fixture。
-3. **P0-补单**:`reconcileByQuery` 两段式 + `selectLeakOrderDdIds` 改 EXISTS + 道节流闸。
+3. **P0-补单**:`reconcileByQuery` 两段式 + `selectLeakOrderDdIds` 改 EXISTS + 道节流闸。
 4. **P0-防堆积**:create 复用预检(`selectActiveForReuse` + `trade_no IS NULL` + 新鲜期)+ afterCommit 锁 + DDL(is_active/expire_date/生成列唯一索引)+ 历史回填(只 is_active)。
 5. **P0-读侧**:7 处 `getLatestByDdId` 全迁移 + `selectLatestPaidByDdId` 扩 `IN(1,3,4)`。
 6. **退款**:`listPaidByDdId` 遍历逐笔退 + 孤立已付告警。
@@ -283,7 +285,7 @@ ORDER BY p.id ASC LIMIT #{batchSize}
 - `domain/PosStoreOmg.java`(无改)
 
 **ruoyi-admin**
-- `app/pay/OmgPayController.java`:create 复用预检 + afterCommit 锁;reconcileByQuery 两段式 + 道节流闸 + 三分支日志;refund 改 listPaidByDdId 遍历;7 处 getLatestByDdId 迁移
+- `app/pay/OmgPayController.java`:create 复用预检 + afterCommit 锁;reconcileByQuery 两段式 + 道节流闸 + 三分支日志;refund 改 listPaidByDdId 遍历;7 处 getLatestByDdId 迁移
 - `app/utils/omg/OmgPay.java`:parseKvResponse 放宽(重复键不丢)
 - `app/task/OmgReconcileTask.java`:节流闸配合(如未在 controller 内全覆盖)
 - 新增:queryTrade 节流组件(Redis 令牌桶,per-MerchantID)

+ 7 - 7
specs/016-omg-payment/tasks.md

@@ -182,16 +182,16 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 - [x] T052 **collation 对齐执行确认**:执行 `updatesql/sql.md` 2026-08-12 节(pos_order_omg_payment/pos_order_omg_refund/pos_store_omg CONVERT 到 utf8mb4_unicode_ci);执行后 `selectLeakOrderDdIds` JOIN 不再报 Illegal mix of collations。
 - [x] T053 **reconcile 扫描改全量未付**(`PosOrderOmgPaymentMapper.xml` + Service):`selectLeakOrderDdIds` 改 `EXISTS(任意 pay_status=0 行)` 去重 ddId(不再 MAX(id) 取一条);新增 `listUnpaidByDdId`(全量 pay_status=0,is_active 不限);`selectLatestPaidByDdId` 扩 `IN(1,3,4)`(修退款中/已退返 null);新增 `selectLatestRefundableByDdId`(pay_status IN(1,3,4))、`existsPaidByDdId`。
 - [x] T054 **reconcileByQuery 两段式**(`OmgPayController.reconcileByQuery`):① 先 selectLatestPaidByDdId(pay_status=1 且 order.payStatus=0 → handlePaymentSuccess 自愈,L774 不能变死代码);② 否则 listUnpaidByDdId 遍历全量未付行 queryTrade:TradeStatus=1 applyPaidResult break / 0 no-op / 10200095 且 trade_no IS NULL markFail / 10200095 且 trade_no 非空 no-op。
-- [ ] T055 **三道节流闸(防 OMG 403)**(新建 queryTrade 节流组件 `ruoyi-admin/.../utils/omg/OmgQueryThrottle.java` + Redis):首查跳过 create_time<NOW()-INTERVAL 40 MINUTE;per-MerchantID 令牌桶 `omg:qt:token:{merchantId}`(1token/3s,burst1)acquire 失败 skip 本行本轮;per-ddId TTL(`omg:reconcile:dd:{ddId}` 180s / `omg:query:dd:{ddId}` 60s)。reconcileByQuery 与 /query 接入。
+- [x] T055 **节流闸(防 OMG 403)**(✅ `OmgQueryThrottle.java` + Redis,commit db043b4 + fc46fed):per-MerchantID 令牌桶 `omg:qt:token:{merchantId}`(1token/3s,burst1)acquire 失败 skip 本行本轮;per-ddId TTL(`omg:reconcile:dd:{ddId}` 180s / `omg:query:dd:{ddId}` 60s)。reconcileByQuery 与 /query 接入。⚠ **原"闸1 首查延迟 40min"已移除(fc46fed)**——是对 OMG 节流的误读(403 是频率触发,非"下单 N 分钟内不能查"),会让已付客户 notify 丢失时等 40min;防 403 靠这两道频率闸,"刚创建未付"由扫描窗口 2min 宽限保护。
 - [x] T056 **DDL 追加式 S 字段**(写 `updatesql/sql.md`,不直接执行):`pos_order_omg_payment` + `expire_date DATETIME NULL`(仅 paymentInfo 回调写,create 不猜)+ `is_active TINYINT NOT NULL DEFAULT 1` + 生成列 `active_dd_id=IF(is_active=1 AND pay_status=0,dd_id,NULL) VIRTUAL` + `UNIQUE uk_omg_active_dd(active_dd_id)`(需 MySQL 5.7.6+,**生产版本待确认**;不够则退化 `idx_omg_dd_active(dd_id,is_active,pay_status)`+app 锁+CAS);历史回填(每 ddId 最新 pay_status=0 置 is_active=1,其余 0;终态行 0;**绝不回填 expire_date**)。
 - [x] T057 **实体 + mapper**(`PosOrderOmgPayment.java` + Mapper/XML):+ isActive/expireDate 字段(activeDdId 生成列只读可不映射);加 `selectActiveForReuse(ddId,freshMin)`(WHERE dd_id AND is_active=1 AND pay_status=0 AND trade_no IS NULL AND create_time>=NOW()-INTERVAL freshMin MINUTE ORDER BY id DESC LIMIT 1)、`markActiveHistorical(ddId)`(CAS UPDATE is_active=0 WHERE dd_id AND is_active=1 AND pay_status=0,不动 pay_status)、`markPaymentInfoIfOpen` 扩 expire_date 解析;**刻意不提供 updateMerchantTradeNo/updateTradeNo**(从能力杜绝换号丢钱)。
-- [ ] T058 **create 复用预检**(`OmgPayController.create`,保留 @Transactional):Redisson `RLock lock:omg:create:{ddId}` tryLock(等 3s),**锁释放在 TransactionSynchronizationManager.registerSynchronization 的 afterCommit**(仿 WalletService.returnPoints,**禁 try/finally unlock**——否则 TX 提交前泄锁致重复活跃行);selectActiveForReuse 命中复用 MTN(form 用当前时刻重算 CheckMacValue);未命中同 TX:markActiveHistorical(旧 is_active→0)+ createPayment 新行(is_active=1, expire_date=NULL);DuplicateKeyException 重试 3 次(uk_omg_active_dd DB 兜底)。
-- [ ] T059 **读侧 7 处 getLatestByDdId 全迁移**:`getPaymentInfo`/`refundOrderOutcome`/L569 早退/`markRefunding` 并发/`confirmManualRefundOutcome`/`reconcileByQuery`(T054)/`OrderLifecycleService.buildContext`/`validateOmgReconcile`/`validateOmgRefund` → `selectLatestRefundableByDdId`(pay_status IN(1,3,4))或 `listPaidByDdId`;修"订单未支付"早退为 listPaidByDdId 为空才 FAILED。
-- [ ] T060 **退款遍历**(`refundOrderOutcome`):改 `listPaidByDdId` 遍历所有 pay_status=1 且 trade_no 非空行;每行先查 `pos_order_omg_refund` 是否 action=R 且 rtnCode=1 → 跳过;否则 `markRefundingIfPaid`(1→4 CAS)→ `DoAction(Action=R)` 逐笔退(每笔独立 tradeNo);ATM/超商无退款 API 走 manual pending;孤立已付(双付第二笔)写 `ipn_log(type=omg_orphan_paid)` + 运营告警。
-- [ ] T061 **历史 9 行迁移**(orders 如 991786433092835 id=18~26):迁移前对每行 queryTrade 确认(已可靠);TradeStatus=1 的行无论本地 pay_status 一律保留并回填 pay_status=1(如 id=26 OMGFE0494FB7AD1474A8);仅 TradeStatus=10200095 或(0 且过 ExpireDate 且非信用卡)才允许清理;优先 is_active 软标记而非物理删除(lifecycle §9 Q10)。
-- [ ] T062 **端到端验证**(stage):create 连点复用(单活跃 is_active=1)、reconcile 两段式补单(含 id=26)、过期延期支付轮换、退款遍历多笔、三道节流闸不触发 403;抓 stage 真实 QueryTradeInfo 大响应(ATM/超商 NeedExtraPaidInfo=Y)fixture 补 OmgPayTest(重复键验证)。
+- [x] T058 **create 复用预检** ✅(commit 000f47f;实际:PaymentCreateGuardService afterCompletion 锁 + isSynchronizationActive 自愈,共享 OMG/LINE Pay)(`OmgPayController.create`,保留 @Transactional):Redisson `RLock lock:omg:create:{ddId}` tryLock(等 3s),**锁释放在 TransactionSynchronizationManager.registerSynchronization 的 afterCommit**(仿 WalletService.returnPoints,**禁 try/finally unlock**——否则 TX 提交前泄锁致重复活跃行);selectActiveForReuse 命中复用 MTN(form 用当前时刻重算 CheckMacValue);未命中同 TX:markActiveHistorical(旧 is_active→0)+ createPayment 新行(is_active=1, expire_date=NULL);DuplicateKeyException 重试 3 次(uk_omg_active_dd DB 兜底)。
+- [x] T059 **读侧 getLatestByDdId 迁移** ✅(OmgPayController 4 处 db043b4 + OrderLifecycleService 3 处 b9a0514;buildContext canReconcileOmg 重构为 hasOmgActivity):`getPaymentInfo`/`refundOrderOutcome`/L569 早退/`markRefunding` 并发/`confirmManualRefundOutcome`/`reconcileByQuery`(T054)/`OrderLifecycleService.buildContext`/`validateOmgReconcile`/`validateOmgRefund` → `selectLatestRefundableByDdId`(pay_status IN(1,3,4))或 `listPaidByDdId`;修"订单未支付"早退为 listPaidByDdId 为空才 FAILED。
+- [x] T060 **砍掉** ~~退款遍历~~(2026-08-12 用户拍板:双付不现实——用户不会对同一订单付两笔,实测 bugA 9 行也只 1 笔 paid。退款维持退单条已付行即可,无需遍历多笔)(`refundOrderOutcome`):改 `listPaidByDdId` 遍历所有 pay_status=1 且 trade_no 非空行;每行先查 `pos_order_omg_refund` 是否 action=R 且 rtnCode=1 → 跳过;否则 `markRefundingIfPaid`(1→4 CAS)→ `DoAction(Action=R)` 逐笔退(每笔独立 tradeNo);ATM/超商无退款 API 走 manual pending;孤立已付(双付第二笔)写 `ipn_log(type=omg_orphan_paid)` + 运营告警。
+- [x] T061 **砍掉** ~~历史 9 行迁移~~(2026-08-12 用户:不管历史数据。新读路径对历史行仍兼容——可退款行优先)(orders 如 991786433092835 id=18~26):迁移前对每行 queryTrade 确认(已可靠);TradeStatus=1 的行无论本地 pay_status 一律保留并回填 pay_status=1(如 id=26 OMGFE0494FB7AD1474A8);仅 TradeStatus=10200095 或(0 且过 ExpireDate 且非信用卡)才允许清理;优先 is_active 软标记而非物理删除(lifecycle §9 Q10)。
+- [ ] T062 **端到端验证**(stage):create 连点复用(单活跃 is_active=1)、reconcile 两段式补单(含 id=26)、过期延期支付轮换、道节流闸不触发 403;抓 stage 真实 QueryTradeInfo 大响应(ATM/超商 NeedExtraPaidInfo=Y)fixture 补 OmgPayTest(重复键验证)。(T060 退款遍历/T061 历史迁移已砍,不纳入验证)
 
-**Checkpoint**: 问题 A 收敛(连点单活跃)、问题 B 闭环(reconcile 全量补单 + 节流)、追加式 S 落地(is_active 双轴 + mapper 杜绝换号 + 7 处读侧迁移 + 退款遍历);id=26 类历史漏单可补
+**Checkpoint**: 问题 A 收敛(连点单活跃 T058)、问题 B 闭环(reconcile 全量补单 T054 + 两道频率节流闸 T055)、追加式 S 落地(is_active 双轴 + mapper 杜绝换号 + 读侧迁移 T059)。**T060 退款遍历砍掉**(双付不现实,退款维持退单条已付行)、**T061 历史 9 行迁移砍掉**(不管历史数据)。T062 stage 端到端待测试环境
 
 **§9 待拍板(实现/stage 确认,不阻塞)**:freshMin(建议 3min,stage 实测同 MTN 重 POST)、生产 MySQL 版本(决定生成列唯一索引)、reuse 保留原 MerchantTradeDate、节流精确参数、AFTEE 窗口(现 168h 不够,建议 720h 或按 expire_date 动态)、/query 轮询 cadence(配合前端 30-60s)。