Просмотр исходного кода

feat(统一支付): 建表/实体/Mapper 与租户隔离登记

alphaH 1 неделя назад
Родитель
Сommit
0fe5119230

+ 5 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -226,7 +226,7 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "pay_har_shop",          // 汇安融门店(开放API创建/查询无登录态)
             "pay_har_order",         // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
                                      //   订单号为我方生成的雪花ID,全局唯一,无跨租户风险)
-            "pay_har_checkout_link"  // 收款链接(顾客页按 token 定位,**免登录**)。与上一段
+            "pay_har_checkout_link", // 收款链接(顾客页按 token 定位,**免登录**)。与上一段
                                      //   pay_lsy_checkout_link 同形,逐条论证不再重抄;
                                      //   ★ 差异只有一处:这里的唯一索引叫 uk_har_checkout_token,
                                      //     同样**单独**建在 token 上(不是 (tenant_id, token))。
@@ -234,6 +234,10 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
                                      //     ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,
                                      //     而且日志里一个字都没有。
                                      //   ★ 定位用 selectOne 是**允许**的:因为走了 (甲)。
+            // 统一支付:通知/轮询/回调重试在无认证上下文里按全局唯一键读,漏登记会恒为空
+            "pay_unified_merchant",
+            "pay_unified_order",
+            "pay_unified_refund"
     );
 
     public TenantInnerInterceptor() {

+ 41 - 0
java/src/main/java/com/payment/platform/module/payment/unified/entity/UnifiedMerchantEntity.java

@@ -0,0 +1,41 @@
+package com.payment.platform.module.payment.unified.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * 租户 ↔ 上游「统一支付平台」商户配置。
+ *
+ * <p>{@code mchNo} 是上游全局唯一商户号 —— 上游通知只带 mchNo,
+ * 无认证上下文必须能靠它唯一定位到租户。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_unified_merchant")
+public class UnifiedMerchantEntity extends PaymentTenantBaseEntity {
+
+    /** 上游商户号,全局唯一 */
+    private String mchNo;
+
+    /** 上游应用 ID */
+    private String appId;
+
+    /** MD5 私钥:永不返回前端(@JsonIgnore)、永不进日志(@ToString.Exclude) */
+    @JsonIgnore
+    @ToString.Exclude
+    private String appSecret;
+
+    /** 显示名 */
+    private String merchantName;
+
+    /** 渠道用户 ID 跳转的 ifCode */
+    private String channelUserIfCode;
+
+    @Version
+    private Integer version;
+}

+ 114 - 0
java/src/main/java/com/payment/platform/module/payment/unified/entity/UnifiedOrderEntity.java

@@ -0,0 +1,114 @@
+package com.payment.platform.module.payment.unified.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 统一支付订单。
+ *
+ * <p>{@code status}(基类)是行状态({@code '0'} 正常);业务状态在 {@code tradeStatus}。
+ * 金额单位一律「分」。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_unified_order")
+public class UnifiedOrderEntity extends PaymentTenantBaseEntity {
+
+    /** pay_unified_merchant.id */
+    private Long merchantId;
+
+    /** 下单时快照,配置变更不影响历史单 */
+    private String mchNo;
+
+    /** 下单时快照 */
+    private String appId;
+
+    /** 我方单号(上游 mchOrderNo),全局唯一 */
+    private String orderNo;
+
+    /** 调用方单号;开放 API 幂等键,收银台单可为空 */
+    private String outTradeNo;
+
+    /** 开放 API 调用方 Key(回调取址用);收银台为空 */
+    private String apiKey;
+
+    /** OPENAPI / CASHIER */
+    private String source;
+
+    /** 见设计 §4.6 */
+    private String wayCode;
+
+    /** 金额,分 */
+    private Long amount;
+
+    /** 已成功退款金额,分,由退款表重算 */
+    private Long refundedAmount;
+
+    private String currency;
+
+    /** 上游 clientIp */
+    private String clientIp;
+
+    /** 商品标题 */
+    private String subject;
+
+    /** 商品描述 */
+    private String body;
+
+    /** 下游回调地址快照(不是上游 notifyUrl) */
+    private String callbackUrl;
+
+    /** 上游扩展参数,原样返回 */
+    private String extParam;
+
+    /** channelExtra 的键名,值一律不落库 */
+    private String channelExtraKeys;
+
+    private String upstreamPayOrderId;
+
+    private String upstreamChannelOrderNo;
+
+    /** payDataType 原值 */
+    private String payDataType;
+
+    /** 二维码 URL / 表单 / 跳转链接原值 */
+    private String payData;
+
+    /** 上游订单状态 0–6 */
+    private String tradeStatus;
+
+    private String errCode;
+
+    private String errMsg;
+
+    /** 上游 successTime */
+    private OffsetDateTime payTime;
+
+    /** 上游 createdAt */
+    private OffsetDateTime upstreamCreatedAt;
+
+    /** 最近一次上游 data / 通知参数的脱敏快照 */
+    private String upstreamDataJson;
+
+    private OffsetDateTime lastQueryTime;
+
+    private OffsetDateTime nextQueryTime;
+
+    private Integer queryCount;
+
+    /** PENDING / SUCCESS / FAILED */
+    private String notifyStatus;
+
+    /** 下游回调尝试次数(含首次) */
+    private Integer notifyCount;
+
+    private OffsetDateTime lastNotifyTime;
+
+    @Version
+    private Integer version;
+}

+ 94 - 0
java/src/main/java/com/payment/platform/module/payment/unified/entity/UnifiedRefundEntity.java

@@ -0,0 +1,94 @@
+package com.payment.platform.module.payment.unified.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.payment.platform.common.base.PaymentTenantBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 统一支付退款单。
+ *
+ * <p>{@code status}(基类)是行状态({@code '0'} 正常);业务状态在 {@code state}(0–4)。
+ * 金额单位一律「分」。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_unified_refund")
+public class UnifiedRefundEntity extends PaymentTenantBaseEntity {
+
+    /** pay_unified_order.id */
+    private Long orderId;
+
+    /** 冗余订单号,便于查询 */
+    private String orderNo;
+
+    /** 我方退款号(上游 mchRefundNo),全局唯一 */
+    private String refundNo;
+
+    /** 调用方退款号;幂等键,可空 */
+    private String outRefundNo;
+
+    /** 订单快照的同一份 */
+    private String mchNo;
+
+    /** 订单快照的同一份 */
+    private String appId;
+
+    /** 开放 API 调用方 Key */
+    private String apiKey;
+
+    /** OPENAPI / ADMIN */
+    private String source;
+
+    /** 退款金额,分 */
+    private Long amount;
+
+    private String currency;
+
+    /** 上游要求非空 */
+    private String refundReason;
+
+    /** 本笔退款专属下游回调地址快照 */
+    private String callbackUrl;
+
+    private String extParam;
+
+    private String upstreamRefundOrderId;
+
+    private String upstreamChannelOrderNo;
+
+    /** 上游退款状态 0–4 */
+    private String state;
+
+    private String errCode;
+
+    private String errMsg;
+
+    /** 上游 successTime */
+    private OffsetDateTime successTime;
+
+    /** 上游 createdAt */
+    private OffsetDateTime upstreamCreatedAt;
+
+    /** 脱敏快照 */
+    private String upstreamDataJson;
+
+    private OffsetDateTime lastQueryTime;
+
+    private OffsetDateTime nextQueryTime;
+
+    private Integer queryCount;
+
+    /** PENDING / SUCCESS / FAILED */
+    private String notifyStatus;
+
+    private Integer notifyCount;
+
+    private OffsetDateTime lastNotifyTime;
+
+    @Version
+    private Integer version;
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/unified/mapper/UnifiedMerchantMapper.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.unified.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface UnifiedMerchantMapper extends BaseMapper<UnifiedMerchantEntity> {
+}

+ 22 - 0
java/src/main/java/com/payment/platform/module/payment/unified/mapper/UnifiedOrderMapper.java

@@ -0,0 +1,22 @@
+package com.payment.platform.module.payment.unified.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface UnifiedOrderMapper extends BaseMapper<UnifiedOrderEntity> {
+
+    /**
+     * 退款事务里按 id 锁单。
+     *
+     * <p>租户条件**按参数有无动态出现**:普通租户传 tenantId 再加一层隔离;
+     * 超管(tenantId 为 null)必须仍能按 id 锁到那一行,否则退款事务在
+     * 「全局视角」下恒锁空。超管本就是跨租户身份,本方法只被内部退款事务调用。
+     */
+    @Select("<script>SELECT * FROM pay_unified_order WHERE id = #{id} "
+            + "<if test='tenantId != null'> AND tenant_id = #{tenantId} </if> FOR UPDATE</script>")
+    UnifiedOrderEntity selectForUpdate(@Param("id") Long id, @Param("tenantId") Long tenantId);
+}

+ 21 - 0
java/src/main/java/com/payment/platform/module/payment/unified/mapper/UnifiedRefundMapper.java

@@ -0,0 +1,21 @@
+package com.payment.platform.module.payment.unified.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface UnifiedRefundMapper extends BaseMapper<UnifiedRefundEntity> {
+
+    /** 已占用金额(分):创建 / 处理中 / 成功 三种状态都算,防止并发超退。 */
+    @Select("SELECT COALESCE(SUM(amount), 0) FROM pay_unified_refund " +
+            "WHERE order_id = #{orderId} AND state IN ('0','1','2')")
+    long sumOccupiedAmount(@Param("orderId") Long orderId);
+
+    /** 已成功退款金额(分):只有 state='2' 才计入订单的 refunded_amount。 */
+    @Select("SELECT COALESCE(SUM(amount), 0) FROM pay_unified_refund " +
+            "WHERE order_id = #{orderId} AND state = '2'")
+    long sumSucceededAmount(@Param("orderId") Long orderId);
+}

+ 126 - 0
java/src/main/resources/db/migration/V1.9__create_pay_unified_tables.sql

@@ -0,0 +1,126 @@
+-- 统一支付平台三张表(2026-10-20)。执行由用户做;本脚本必须可重复执行。
+
+-- 租户 ↔ 上游商户配置
+CREATE TABLE IF NOT EXISTS pay_unified_merchant (
+    id BIGINT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    mch_no VARCHAR(64) NOT NULL,
+    app_id VARCHAR(64) NOT NULL,
+    app_secret VARCHAR(128) NOT NULL,
+    merchant_name VARCHAR(128),
+    channel_user_if_code VARCHAR(16) NOT NULL DEFAULT 'fuioupay',
+    status VARCHAR(10) NOT NULL DEFAULT '0',
+    description TEXT,
+    created_time TIMESTAMP WITH TIME ZONE,
+    updated_time TIMESTAMP WITH TIME ZONE,
+    version INT NOT NULL DEFAULT 0
+);
+-- mch_no 是上游全局唯一的商户号:上游通知只带 mchNo,无认证上下文必须能唯一定位租户。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_merchant_mch_no ON pay_unified_merchant (mch_no);
+CREATE INDEX IF NOT EXISTS idx_unified_merchant_tenant ON pay_unified_merchant (tenant_id);
+
+-- 支付订单
+CREATE TABLE IF NOT EXISTS pay_unified_order (
+    id BIGINT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    merchant_id BIGINT NOT NULL,
+    mch_no VARCHAR(64) NOT NULL,
+    app_id VARCHAR(64) NOT NULL,
+    order_no VARCHAR(64) NOT NULL,
+    out_trade_no VARCHAR(64),
+    api_key VARCHAR(128),
+    source VARCHAR(16) NOT NULL DEFAULT 'OPENAPI',
+    way_code VARCHAR(16) NOT NULL,
+    amount BIGINT NOT NULL,
+    refunded_amount BIGINT NOT NULL DEFAULT 0,
+    currency VARCHAR(3) NOT NULL DEFAULT 'cny',
+    client_ip VARCHAR(64),
+    subject VARCHAR(128) NOT NULL,
+    body VARCHAR(512),
+    callback_url VARCHAR(512),
+    ext_param VARCHAR(512),
+    channel_extra_keys VARCHAR(128),
+    upstream_pay_order_id VARCHAR(64),
+    upstream_channel_order_no VARCHAR(64),
+    pay_data_type VARCHAR(32),
+    pay_data TEXT,
+    trade_status VARCHAR(8) NOT NULL DEFAULT '0',
+    err_code VARCHAR(64),
+    err_msg VARCHAR(512),
+    pay_time TIMESTAMP WITH TIME ZONE,
+    upstream_created_at TIMESTAMP WITH TIME ZONE,
+    upstream_data_json TEXT,
+    last_query_time TIMESTAMP WITH TIME ZONE,
+    next_query_time TIMESTAMP WITH TIME ZONE,
+    query_count INT NOT NULL DEFAULT 0,
+    notify_status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+    notify_count INT NOT NULL DEFAULT 0,
+    last_notify_time TIMESTAMP WITH TIME ZONE,
+    status VARCHAR(10) NOT NULL DEFAULT '0',
+    description TEXT,
+    created_time TIMESTAMP WITH TIME ZONE,
+    updated_time TIMESTAMP WITH TIME ZONE,
+    version INT NOT NULL DEFAULT 0
+);
+-- order_no 是我方单号(上游 mchOrderNo),全局唯一:通知/轮询在无认证上下文里按它点查。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_order_order_no ON pay_unified_order (order_no);
+-- 部分唯一:收银台单没有调用方单号,NULL 不参与唯一。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_order_tenant_out_trade_no
+    ON pay_unified_order (tenant_id, out_trade_no) WHERE out_trade_no IS NOT NULL;
+-- 部分唯一:防两笔本地单抢同一个上游单;同步响应回来前为空。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_order_upstream_pay_order_id
+    ON pay_unified_order (upstream_pay_order_id) WHERE upstream_pay_order_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_unified_order_mch_created
+    ON pay_unified_order (mch_no, created_time DESC);
+CREATE INDEX IF NOT EXISTS idx_unified_order_poll
+    ON pay_unified_order (trade_status, next_query_time);
+CREATE INDEX IF NOT EXISTS idx_unified_order_notify_retry
+    ON pay_unified_order (notify_status, last_notify_time) WHERE notify_status = 'FAILED';
+
+-- 退款单
+CREATE TABLE IF NOT EXISTS pay_unified_refund (
+    id BIGINT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    order_id BIGINT NOT NULL,
+    order_no VARCHAR(64) NOT NULL,
+    refund_no VARCHAR(64) NOT NULL,
+    out_refund_no VARCHAR(64),
+    mch_no VARCHAR(64) NOT NULL,
+    app_id VARCHAR(64) NOT NULL,
+    api_key VARCHAR(128),
+    source VARCHAR(16) NOT NULL DEFAULT 'OPENAPI',
+    amount BIGINT NOT NULL,
+    currency VARCHAR(3) NOT NULL DEFAULT 'cny',
+    refund_reason VARCHAR(64) NOT NULL,
+    callback_url VARCHAR(512),
+    ext_param VARCHAR(512),
+    upstream_refund_order_id VARCHAR(64),
+    upstream_channel_order_no VARCHAR(64),
+    state VARCHAR(8) NOT NULL DEFAULT '0',
+    err_code VARCHAR(64),
+    err_msg VARCHAR(512),
+    success_time TIMESTAMP WITH TIME ZONE,
+    upstream_created_at TIMESTAMP WITH TIME ZONE,
+    upstream_data_json TEXT,
+    last_query_time TIMESTAMP WITH TIME ZONE,
+    next_query_time TIMESTAMP WITH TIME ZONE,
+    query_count INT NOT NULL DEFAULT 0,
+    notify_status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+    notify_count INT NOT NULL DEFAULT 0,
+    last_notify_time TIMESTAMP WITH TIME ZONE,
+    status VARCHAR(10) NOT NULL DEFAULT '0',
+    description TEXT,
+    created_time TIMESTAMP WITH TIME ZONE,
+    updated_time TIMESTAMP WITH TIME ZONE,
+    version INT NOT NULL DEFAULT 0
+);
+-- refund_no 是我方退款号(上游 mchRefundNo),全局唯一:无认证回调按它点查。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_refund_refund_no ON pay_unified_refund (refund_no);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_refund_tenant_out_refund_no
+    ON pay_unified_refund (tenant_id, out_refund_no) WHERE out_refund_no IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_unified_refund_upstream_refund_order_id
+    ON pay_unified_refund (upstream_refund_order_id) WHERE upstream_refund_order_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_unified_refund_order_no ON pay_unified_refund (order_no);
+CREATE INDEX IF NOT EXISTS idx_unified_refund_poll ON pay_unified_refund (state, next_query_time);
+CREATE INDEX IF NOT EXISTS idx_unified_refund_notify_retry
+    ON pay_unified_refund (notify_status, last_notify_time) WHERE notify_status = 'FAILED';

+ 93 - 0
java/src/test/java/com/payment/platform/module/payment/unified/UnifiedMigrationTest.java

@@ -0,0 +1,93 @@
+package com.payment.platform.module.payment.unified;
+
+import com.payment.platform.core.tenant.TenantInnerInterceptor;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * V1.9 迁移与租户隔离登记的机械核对。
+ *
+ * <p>三张表都会在**无认证上下文**里被访问(上游通知、定时轮询、下游回调重试)。
+ * 漏登记进 {@code CONDITIONAL_TABLES} 的后果不是报错,而是被追加
+ * {@code AND tenant_id = 0} ⇒ 按 mch_no / order_no 查恒为空,日志里一个字都没有
+ * ({@code pay_batch_authorize} 踩过同一个坑)。
+ */
+class UnifiedMigrationTest {
+
+    private static final String MIGRATION = "db/migration/V1.9__create_pay_unified_tables.sql";
+
+    @Test
+    void 三张表都以可重复执行的方式建表() throws Exception {
+        String sql = stripComments(readMigration());
+        for (String table : List.of("pay_unified_merchant", "pay_unified_order", "pay_unified_refund")) {
+            assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table),
+                    "缺建表语句(必须 CREATE TABLE IF NOT EXISTS,脚本可重复执行): " + table);
+        }
+    }
+
+    /** 全部唯一索引 —— 无认证点查能安全的**前提**是定位键在 schema 层全局唯一。 */
+    @Test
+    void 全部唯一索引都在且可重复执行() throws Exception {
+        String sql = stripComments(readMigration());
+        for (String index : List.of(
+                "uk_unified_merchant_mch_no",
+                "uk_unified_order_order_no",
+                "uk_unified_order_tenant_out_trade_no",
+                "uk_unified_order_upstream_pay_order_id",
+                "uk_unified_refund_refund_no",
+                "uk_unified_refund_tenant_out_refund_no",
+                "uk_unified_refund_upstream_refund_order_id")) {
+            assertTrue(sql.contains("CREATE UNIQUE INDEX IF NOT EXISTS " + index),
+                    "缺唯一索引(必须 CREATE UNIQUE INDEX IF NOT EXISTS): " + index);
+        }
+    }
+
+    /**
+     * 部分索引的谓词逐字钉住:两个租户侧单号可空(收银台单没有 out_trade_no),
+     * 上游单号在同步响应回来前也可空 —— 退化成全表唯一不会报错,却会把 NULL 当彼此相等
+     * 之外的语义搞错(PG 唯一索引允许多 NULL,但这里要的是「非空才唯一」)。
+     */
+    @Test
+    void 部分索引谓词逐字存在() throws Exception {
+        String sql = stripComments(readMigration()).replaceAll("\\s+", " ");
+        assertTrue(sql.contains("WHERE out_trade_no IS NOT NULL"), sql);
+        assertTrue(sql.contains("WHERE out_refund_no IS NOT NULL"), sql);
+        assertTrue(sql.contains("WHERE upstream_pay_order_id IS NOT NULL"), sql);
+        assertTrue(sql.contains("WHERE upstream_refund_order_id IS NOT NULL"), sql);
+        assertTrue(sql.contains("WHERE notify_status = 'FAILED'"), sql);
+    }
+
+    @Test
+    void 三张表都登记进租户隔离条件表() throws Exception {
+        Field field = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES");
+        field.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Set<String> conditionalTables = (Set<String>) field.get(null);
+        assertNotNull(conditionalTables, "CONDITIONAL_TABLES 读不到");
+
+        for (String table : List.of("pay_unified_merchant", "pay_unified_order", "pay_unified_refund")) {
+            assertTrue(conditionalTables.contains(table),
+                    table + " 未登记进 TenantInnerInterceptor.CONDITIONAL_TABLES —— "
+                            + "无认证的通知/轮询/回调重试会被追加 AND tenant_id = 0,查询恒为空");
+        }
+    }
+
+    private static String readMigration() throws Exception {
+        try (InputStream in = UnifiedMigrationTest.class.getClassLoader().getResourceAsStream(MIGRATION)) {
+            assertNotNull(in, "找不到迁移脚本 " + MIGRATION + "(应位于 src/main/resources 下)");
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    private static String stripComments(String sql) {
+        return sql.replaceAll("(?m)--[^\\n]*", "");
+    }
+}

+ 162 - 0
java/src/test/java/com/payment/platform/module/payment/unified/entity/UnifiedEntityMappingTest.java

@@ -0,0 +1,162 @@
+package com.payment.platform.module.payment.unified.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 实体 ↔ V1.9 迁移的机械对齐。
+ *
+ * <p>为什么不靠人工核对:MyBatis-Plus 按**实体**生成列清单,不是按表。实体上多一个
+ * 表里没有的字段,写的时候会被 NOT_NULL 策略放过(null 字段不进 INSERT 列清单),
+ * 直到第一次**读**才抛 PostgreSQL 42703。HAR/LSY 两个模块都为此留过测试。
+ *
+ * <p>{@code version} 在三个实体里各声明一次;遍历类层次时用 {@link LinkedHashSet}
+ * 去重,每个实体只核对一次。
+ */
+class UnifiedEntityMappingTest {
+
+    private static final String MIGRATION = "db/migration/V1.9__create_pay_unified_tables.sql";
+
+    private static final Class<?>[] ENTITIES = {
+            UnifiedMerchantEntity.class, UnifiedOrderEntity.class, UnifiedRefundEntity.class
+    };
+
+    @Test
+    void 实体声明的表名与迁移一致() {
+        assertEquals("pay_unified_merchant",
+                UnifiedMerchantEntity.class.getAnnotation(TableName.class).value());
+        assertEquals("pay_unified_order",
+                UnifiedOrderEntity.class.getAnnotation(TableName.class).value());
+        assertEquals("pay_unified_refund",
+                UnifiedRefundEntity.class.getAnnotation(TableName.class).value());
+    }
+
+    /**
+     * 实体字段 ⊆ 建表列(含基类带来的 id / tenant_id / status / description / 时间列)。
+     */
+    @Test
+    void 每个实体字段都能在迁移里找到同名列() throws Exception {
+        String sql = readMigration().replaceAll("(?m)--[^\\n]*", "");
+        for (Class<?> entity : ENTITIES) {
+            String table = entity.getAnnotation(TableName.class).value();
+            Set<String> columns = tableColumns(sql, table);
+
+            Set<String> mapped = new LinkedHashSet<>();
+            for (Class<?> c = entity; c != null && c != Object.class; c = c.getSuperclass()) {
+                for (Field f : c.getDeclaredFields()) {
+                    if (f.isSynthetic() || Modifier.isStatic(f.getModifiers())) {
+                        continue;
+                    }
+                    TableField tf = f.getAnnotation(TableField.class);
+                    if (tf != null && !tf.exist()) {
+                        continue;
+                    }
+                    mapped.add(camelToSnake(f.getName()));
+                }
+            }
+
+            assertFalse(mapped.isEmpty(), entity.getSimpleName() + " 一个字段都没读到,量具没在读数");
+            for (String column : mapped) {
+                assertTrue(columns.contains(column),
+                        table + " 缺列 " + column + "。实体字段必须与 V1.9 同步 —— "
+                                + "MP 按实体生成列清单,缺列会在**读**时抛 42703。");
+            }
+        }
+    }
+
+    /**
+     * appSecret 是 MD5 私钥:@JsonIgnore 挡序列化,@ToString.Exclude 挡日志。
+     *
+     * <p>Lombok 的 {@code @ToString.Exclude} 是 SOURCE retention,反射看不到 ——
+     * 所以这里直接调 {@code toString()} 看行为,而不是查注解。
+     */
+    @Test
+    void appSecret不参与序列化也不进toString() throws Exception {
+        Field field = UnifiedMerchantEntity.class.getDeclaredField("appSecret");
+        assertNotNull(field.getAnnotation(JsonIgnore.class),
+                "appSecret 必须标 @JsonIgnore —— MD5 私钥永不返回前端");
+
+        UnifiedMerchantEntity merchant = new UnifiedMerchantEntity();
+        merchant.setAppSecret("SECRET-MUST-NOT-LEAK");
+        assertFalse(merchant.toString().contains("SECRET-MUST-NOT-LEAK"),
+                "appSecret 必须标 @ToString.Exclude —— 私钥永不进日志");
+    }
+
+    @Test
+    void 三个实体都有乐观锁version字段() throws Exception {
+        for (Class<?> entity : ENTITIES) {
+            Field version = entity.getDeclaredField("version");
+            assertNotNull(version.getAnnotation(Version.class),
+                    entity.getSimpleName() + ".version 必须标 @Version(乐观锁)");
+            assertEquals(Integer.class, version.getType(),
+                    entity.getSimpleName() + ".version 必须是 Integer");
+        }
+    }
+
+    private static String readMigration() throws Exception {
+        try (InputStream in = UnifiedEntityMappingTest.class.getClassLoader().getResourceAsStream(MIGRATION)) {
+            assertNotNull(in, "找不到迁移脚本 " + MIGRATION + "(应位于 src/main/resources 下)");
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    /** 抽出一张表的建表语句(到第一个分号为止)。 */
+    private static String tableDdl(String sql, String table) {
+        int at = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " (");
+        assertTrue(at >= 0, "V1.9 里找不到建表语句: " + table);
+        int end = sql.indexOf(';', at);
+        assertTrue(end > at, table + " 的建表语句应以分号结束");
+        return sql.substring(at, end);
+    }
+
+    /** 建表语句里的列名集合(每行第一个标识符;跳过表级约束)。 */
+    private static Set<String> tableColumns(String sql, String table) {
+        String ddl = tableDdl(sql, table);
+        int open = ddl.indexOf('(');
+        int close = ddl.lastIndexOf(')');
+        assertTrue(open > 0 && close > open, "建表语句结构异常: " + table);
+        Set<String> cols = new LinkedHashSet<>();
+        for (String line : ddl.substring(open + 1, close).split(",")) {
+            String t = line.trim();
+            if (t.isEmpty()) {
+                continue;
+            }
+            String head = t.split("\\s+")[0];
+            if (head.equalsIgnoreCase("PRIMARY") || head.equalsIgnoreCase("CONSTRAINT")
+                    || head.equalsIgnoreCase("UNIQUE") || head.equalsIgnoreCase("FOREIGN")
+                    || head.equalsIgnoreCase("CHECK")) {
+                continue;
+            }
+            cols.add(head);
+        }
+        return cols;
+    }
+
+    /** upstreamPayOrderId → upstream_pay_order_id */
+    private static String camelToSnake(String s) {
+        StringBuilder b = new StringBuilder();
+        for (char ch : s.toCharArray()) {
+            if (Character.isUpperCase(ch)) {
+                b.append('_').append(Character.toLowerCase(ch));
+            } else {
+                b.append(ch);
+            }
+        }
+        return b.toString();
+    }
+}

+ 104 - 0
java/src/test/java/com/payment/platform/module/payment/unified/mapper/UnifiedMapperSqlTest.java

@@ -0,0 +1,104 @@
+package com.payment.platform.module.payment.unified.mapper;
+
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.mapping.SqlSource;
+import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 钉住三个注解 SQL 的形状 —— 服务层测试会把 Mapper 整个 mock 掉,
+ * 注解里的 SQL 在那里完全不可见。
+ *
+ * <p>量具照 {@code LsyOrderMapperSqlTest}:MyBatis 自己的注解动态 SQL 解析
+ * ({@link XMLLanguageDriver})求值出 BoundSql,断言只认求值后的 SQL。
+ *
+ * <p>归一化只做排版(空白折叠、逗号后留空格)——`state IN ('0','1','2')`
+ * 写的时候不带空格,断言用带空格的形态,靠这一支对齐;占位符的**个数与顺序**
+ * 仍由断言管。
+ */
+class UnifiedMapperSqlTest {
+
+    @Test
+    void selectForUpdate_带租户时加tenant条件并锁行() {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("id", 1L);
+        params.put("tenantId", 2L);
+
+        String sql = boundSql(UnifiedOrderMapper.class, "selectForUpdate", params);
+
+        assertTrue(sql.contains("FROM pay_unified_order"), sql);
+        assertTrue(sql.contains("id = ?"), sql);
+        assertTrue(sql.contains("tenant_id = ?"), sql);
+        assertTrue(sql.contains("FOR UPDATE"), sql);
+    }
+
+    /**
+     * 超管(tenantId 为 null)也必须能按 id 锁到那一行 —— Task 12 的退款事务
+     * 要求 tenant=null 时仍能取到订单并按订单行取租户。固定 SQL 会在这里恒锁空。
+     */
+    @Test
+    void selectForUpdate_超管无租户时仍能锁单() {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("id", 1L);
+        params.put("tenantId", null);
+
+        String sql = boundSql(UnifiedOrderMapper.class, "selectForUpdate", params);
+
+        assertTrue(sql.contains("FROM pay_unified_order"), sql);
+        assertTrue(sql.contains("FOR UPDATE"), sql);
+        assertFalse(sql.contains("tenant_id"),
+                "tenantId 为 null(超管)时不得加租户条件,否则按 id 锁不到任何行: " + sql);
+    }
+
+    @Test
+    void sumOccupiedAmount_统计占用中的退款() {
+        String sql = boundSql(UnifiedRefundMapper.class, "sumOccupiedAmount", Map.of("orderId", 9L));
+
+        assertTrue(sql.contains("COALESCE(SUM(amount), 0)"), sql);
+        assertTrue(sql.contains("FROM pay_unified_refund"), sql);
+        assertTrue(sql.contains("order_id = ?"), sql);
+        assertTrue(sql.contains("state IN ('0', '1', '2')"),
+                "占用额必须含全部在途/成功状态(0 创建、1 处理中、2 成功): " + sql);
+    }
+
+    @Test
+    void sumSucceededAmount_只统计成功退款() {
+        String sql = boundSql(UnifiedRefundMapper.class, "sumSucceededAmount", Map.of("orderId", 9L));
+
+        assertTrue(sql.contains("COALESCE(SUM(amount), 0)"), sql);
+        assertTrue(sql.contains("FROM pay_unified_refund"), sql);
+        assertTrue(sql.contains("order_id = ?"), sql);
+        assertTrue(sql.contains("state = '2'"), sql);
+    }
+
+    private static String boundSql(Class<?> mapper, String methodName, Map<String, Object> params) {
+        Method method = Arrays.stream(mapper.getDeclaredMethods())
+                .filter(x -> x.getName().equals(methodName))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError(mapper.getSimpleName() + " 里没有 " + methodName));
+        Select select = method.getAnnotation(Select.class);
+        if (select == null) {
+            throw new AssertionError(mapper.getSimpleName() + "." + methodName + " 上不是 @Select");
+        }
+        SqlSource sqlSource = new XMLLanguageDriver()
+                .createSqlSource(new Configuration(), select.value()[0], Object.class);
+        return normalize(sqlSource.getBoundSql(params).getSql());
+    }
+
+    private static String normalize(String sql) {
+        return sql.replaceAll("\\s+", " ")
+                .replaceAll("\\s*,\\s*", ", ")
+                .replaceAll("\\(\\s+", "(")
+                .replaceAll("\\s+\\)", ")")
+                .trim();
+    }
+}