Bladeren bron

fix(omg-pay): T056 DDL + T057 实体/mapper 追加式 S(is_active 单活跃轴,账本重构核心)

T056(updatesql/sql.md,待执行):
- pos_order_omg_payment + expire_date(仅 paymentInfo 回调写)+ is_active(1活跃/0历史)
- + 生成列 active_dd_id=IF(is_active=1 AND pay_status=0,dd_id,NULL) + UNIQUE uk_omg_active_dd
  (DB 强制每 ddId 至多一条活跃未付;需 MySQL 5.7.6+,不够退化 idx_omg_dd_active+app锁)
- 历史回填(终态行 is_active=0;每 ddId 最新 pay_status=0 置 1,绝不回填 expire_date)

T057(代码):
- PosOrderOmgPayment + isActive/expireDate 字段
- paymentColumns SQL + is_active AS isActive, expire_date AS expireDate
- Mapper + Service:selectActiveForReuse/getActiveForReuse(ddId,freshMin)(活跃未付未取号+新鲜期,create 复用)
  + markActiveHistorical(ddId)(CAS is_active 1→0 不动 pay_status,保留旧行接迟到 notify)
- 刻意不提供 updateMerchantTradeNo(从能力杜绝换号丢钱)

⚠ T056+T057 必须一起部署(先执行 DDL 加列,再上代码,否则 paymentColumns 查 is_active/expire_date 报错)。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 2 weken geleden
bovenliggende
commit
c67b1367ec

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java

@@ -62,6 +62,12 @@ public class PosOrderOmgPayment {
     /** 0 未支付 / 1 已支付 / 2 失败 / 3 已退款 / 4 退款中 */
     private Integer payStatus;
 
+    /** 1=当前活跃尝试 0=已轮换历史(保留接迟到 notify);与 payStatus 正交,轮换只翻 is_active 不动 payStatus */
+    private Integer isActive;
+
+    /** OMG 延期支付真实 ExpireDate,仅由 paymentInfo 回调解析写入;NULL=未知(信用卡/未取号) */
+    private Date expireDate;
+
     /** 授权码(信用卡回调) */
     private String authCode;
 

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java

@@ -26,6 +26,12 @@ public interface PosOrderOmgPaymentMapper extends BaseMapper<PosOrderOmgPayment>
 
     PosOrderOmgPayment selectLatestPaidByDdId(@Param("ddId") String ddId);
 
+    /** 复用候选:活跃未付(is_active=1, pay_status=0, trade_no IS NULL 未取号)且在新鲜期内,供 create 复用 MTN。 */
+    PosOrderOmgPayment selectActiveForReuse(@Param("ddId") String ddId, @Param("freshMin") int freshMin);
+
+    /** 轮换:同订单活跃未付行 is_active 1→0(CAS,不动 pay_status),保留旧行接迟到 notify。 */
+    int markActiveHistorical(@Param("ddId") String ddId, @Param("updateTime") java.util.Date updateTime);
+
     /** 全量未付流水(pay_status=0,含已轮换历史行),reconcile 遍历补单用。 */
     List<PosOrderOmgPayment> listUnpaidByDdId(@Param("ddId") String ddId);
 

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java

@@ -28,6 +28,12 @@ public interface IPosOrderOmgPaymentService {
 
     PosOrderOmgPayment getLatestPaidByDdId(String ddId);
 
+    /** 复用候选(活跃未付未取号 + 新鲜期内),create 复用 MTN 用。freshMin=新鲜期分钟。 */
+    PosOrderOmgPayment getActiveForReuse(String ddId, int freshMin);
+
+    /** 轮换:活跃未付行 is_active 1→0(不动 pay_status),create 换号前调用。 */
+    int markActiveHistorical(String ddId);
+
     /** 全量未付流水(pay_status=0),reconcile 遍历补单用。 */
     List<PosOrderOmgPayment> listUnpaidByDdId(String ddId);
 

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java

@@ -58,6 +58,19 @@ public class PosOrderOmgPaymentServiceImpl implements IPosOrderOmgPaymentService
         return ddId == null || ddId.isEmpty() ? null : posOrderOmgPaymentMapper.selectLatestPaidByDdId(ddId);
     }
 
+    @Override
+    public PosOrderOmgPayment getActiveForReuse(String ddId, int freshMin) {
+        return ddId == null || ddId.isEmpty() ? null : posOrderOmgPaymentMapper.selectActiveForReuse(ddId, freshMin);
+    }
+
+    @Override
+    public int markActiveHistorical(String ddId) {
+        if (ddId == null || ddId.isEmpty()) {
+            return 0;
+        }
+        return posOrderOmgPaymentMapper.markActiveHistorical(ddId, new Date());
+    }
+
     @Override
     public List<PosOrderOmgPayment> listUnpaidByDdId(String ddId) {
         return ddId == null || ddId.isEmpty()

+ 18 - 2
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml

@@ -10,6 +10,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         pay_type AS payType, amount, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
         pay_status AS payStatus, auth_code AS authCode, pay_time AS payTime,
         trade_date AS tradeDate, callback_raw AS callbackRaw,
+        is_active AS isActive, expire_date AS expireDate,
         create_time AS createTime, update_time AS updateTime
     </sql>
 
@@ -90,10 +91,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <!-- 是否存在已付/退款中/已退行(校验用) -->
     <select id="existsPaidByDdId" parameterType="String" resultType="int">
-        SELECT COUNT(*) FROM pos_order_omg_payment
-        WHERE dd_id = #{ddId} AND pay_status IN (1,3,4)
     </select>
 
+    <!-- 复用候选:活跃未付(is_active=1, pay_status=0)且未取号(trade_no IS NULL)且新鲜期内,create 复用 MTN(连点防堆积) -->
+    <select id="selectActiveForReuse" resultType="com.ruoyi.system.domain.PosOrderOmgPayment">
+        SELECT <include refid="paymentColumns"/>
+        FROM pos_order_omg_payment
+        WHERE dd_id = #{ddId} AND is_active = 1 AND pay_status = 0
+          AND trade_no IS NULL
+          AND create_time &gt;= DATE_ADD(NOW(), INTERVAL -#{freshMin} MINUTE)
+        ORDER BY id DESC
+        LIMIT 1
+    </select>
+
+    <!-- 轮换:活跃未付行 is_active 1→0(CAS,不动 pay_status),保留旧行接迟到 notify -->
+    <update id="markActiveHistorical">
+        UPDATE pos_order_omg_payment SET is_active = 0, update_time = #{updateTime}
+        WHERE dd_id = #{ddId} AND is_active = 1 AND pay_status = 0
+    </update>
+
     <update id="markSuccessIfUnpaid">
         UPDATE pos_order_omg_payment
         SET trade_no = #{tradeNo}, pay_status = 1, pay_type = #{payType},

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

@@ -183,8 +183,8 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 - [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 接入。
-- [ ] 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**)。
-- [ ] 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**(从能力杜绝换号丢钱)。
+- [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)` + 运营告警。

+ 34 - 0
updatesql/sql.md

@@ -642,3 +642,37 @@ ALTER TABLE pos_order_omg_payment CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8m
 ALTER TABLE pos_order_omg_refund CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ALTER TABLE pos_store_omg CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ```
+
+## 2026-08-12 OMG 账本重构(追加式 S):is_active 单活跃轴 + expire_date + 生成列唯一索引(016 Phase 10 T056)
+
+```sql
+-- 设计见 specs/016-omg-payment/payment-attempt-lifecycle.md §6.1。双轴:is_active ⊥ pay_status,
+-- 轮换只翻 is_active 1→0 不动 pay_status → 迟到 notify 的 markSuccess CAS IN(0,2) 永命中。
+-- 执行前先确认生产 MySQL 版本:SELECT VERSION();(生成列唯一索引需 5.7.6+)
+
+ALTER TABLE pos_order_omg_payment ADD COLUMN expire_date DATETIME NULL
+  COMMENT 'OMG 延期支付真实 ExpireDate,仅由 paymentInfo 回调解析写入;NULL=未知(信用卡/未取号),绝不由后台任务触发换号';
+ALTER TABLE pos_order_omg_payment ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1
+  COMMENT '1=当前活跃尝试 0=已轮换历史(保留审计+迟到notify落地);与 pay_status 正交,轮换只翻 is_active 不动 pay_status';
+
+-- 生成列唯一索引:每 ddId 同时至多一条活跃未付(is_active=1 AND pay_status=0)行(DB 级强制单活跃)
+-- ⚠ 需 MySQL 5.7.6+;不够则注释掉下两行,退化为普通索引 idx_omg_dd_active(dd_id,is_active,pay_status)+ app 锁 + CAS
+ALTER TABLE pos_order_omg_payment ADD COLUMN active_dd_id BIGINT
+  GENERATED ALWAYS AS (IF(is_active=1 AND pay_status=0, dd_id, NULL)) VIRTUAL
+  COMMENT '单活跃不变量载体';
+ALTER TABLE pos_order_omg_payment ADD UNIQUE KEY uk_omg_active_dd (active_dd_id);
+
+-- 历史回填(绝不回填 expire_date,NULL=未知):
+-- ① 已终态(pay_status<>0)行 is_active=0
+UPDATE pos_order_omg_payment SET is_active=0 WHERE pay_status<>0;
+-- ② pay_status=0 的行:每 ddId 保留最新(id 最大)一条 is_active=1,其余置 0
+UPDATE pos_order_omg_payment p
+INNER JOIN (SELECT dd_id, MAX(id) AS max_id FROM pos_order_omg_payment WHERE pay_status=0 GROUP BY dd_id) m
+  ON p.dd_id=m.dd_id
+SET p.is_active = IF(p.id=m.max_id, 1, 0)
+WHERE p.pay_status=0;
+
+-- 验证:每 ddId 至多一条 is_active=1 AND pay_status=0(应返回 0 行;若 >0 说明回填或唯一索引有问题)
+SELECT dd_id, COUNT(*) AS cnt FROM pos_order_omg_payment WHERE is_active=1 AND pay_status=0
+GROUP BY dd_id HAVING COUNT(*)>1;
+```