Prechádzať zdrojové kódy

feat(订单归属): 订单列表对非超管只显示本人创建的 —— pay_lsy_order 新增 created_id

用户报「这些订单有很多都不是用户 q 的」,2026-10-05 裁定:列表只显示本人(登录账号)建的。
此前 pay_lsy_order 没有任何创建人列(user_id 是上游付款人标识,不是建单人)。

- 列:V1.8 建表 + db/lsy-order-created-id.sql(幂等 ALTER + 注释 + 核对;必须先于新 jar 执行)
- 写:只有收银台人工建单腿(三个建单腿里唯一有人类登录态)写当前登录账号;
  开放 API / 免登录收银台两条腿恒 NULL;历史行不回填(归属无从考证)
- 读:/page 非超管恒加 created_id = 本人;超管豁免(与租户筛同一开关 isSuperuser);
  无主体 / 无 userId ⇒ 403 而不是一份空列表(同 requireTenantId 的「显式拒绝」纪律)
- 存量后果(有意):q 的列表在它自己建单之前为空;NULL 行只对超管可见
- TDD:5 条 RED(收银台 1 + 订单 4;mapping 双向防线先红后绿自证在场)→ GREEN;
  4 条既有用例随「/page 要求可识别主体」契约补登录态,断言未动;全量 1087/0/0
alphaH 1 týždeň pred
rodič
commit
6828f3789b

+ 32 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -175,11 +175,13 @@ public class LeshouyingCashierController {
     @PostMapping("/qrcode")
     public Result<LsyCashierOrderVO> createQr(@Valid @RequestBody LsyCashierCreateDTO dto) {
         Long tenantId = requireTenantId();
+        // ★ 归属(created_id)只在**这条腿**能落 —— 三个建单腿里只有收银台有人类登录态。
+        Long creatorId = requireUserId();
 
         // ★ 本地闸在**任何上游调用之前**。没有商户号 ⇒ 404,一行都不落、一次上游都不发。
         LsyMerchantEntity merchant = merchantService.requireByTenant(tenantId);
 
-        LsyOrderEntity order = newOrder(tenantId, merchant, dto);
+        LsyOrderEntity order = newOrder(tenantId, creatorId, merchant, dto);
         orderMapper.insert(order);
         publisher.changed(OrderEventPublisher.CHANNEL_LSY_ORDER, order.getTenantId());
 
@@ -304,15 +306,22 @@ public class LeshouyingCashierController {
      *       <td>同一列,两个端点各自的名字</td></tr>
      *   <tr><td>{@code attach}</td><td>{@code dto.getAttach()}</td><td>不设(null)</td>
      *       <td>收银台没有「回传字段」这个概念</td></tr>
+     *   <tr><td>{@code createdId}</td><td><b>不设(null)</b></td><td>当前登录账号</td>
+     *       <td>列表归属列(用户 2026-10-05 裁定「只显示本人建的」)。开放 API 腿是 api key 主体、
+     *           免登录收银台腿是合成登录态,两条腿**构造上没有登录账号** ⇒ 恒 NULL;
+     *           历史行同样不回填。列由 {@code db/lsy-order-created-id.sql} 建。</td></tr>
      * </table>
      *
      * <p>★ <b>{@code insert} 必须发生在 {@code mchNo}/{@code shopNo} 装配之后</b>:
      * {@code pay_lsy_order.mch_no} 是 NOT NULL 且无默认值。
      */
-    private LsyOrderEntity newOrder(Long tenantId, LsyMerchantEntity merchant, LsyCashierCreateDTO dto) {
+    private LsyOrderEntity newOrder(Long tenantId, Long creatorId, LsyMerchantEntity merchant,
+                                    LsyCashierCreateDTO dto) {
         OffsetDateTime now = OffsetDateTime.now();
         LsyOrderEntity o = new LsyOrderEntity();
         o.setTenantId(tenantId);
+        // ★ 归属 = 当前登录账号(三个建单腿里只有这里有人类登录态;见上方逐字段差异表)
+        o.setCreatedId(creatorId);
 
         o.setOutTradeNo(OTN_PREFIX + IdUtil.getSnowflakeNextIdStr());
         o.setMchNo(merchant.getMchNo());
@@ -467,6 +476,27 @@ public class LeshouyingCashierController {
         return tenantId;
     }
 
+    /**
+     * 取当前主体的**账号 ID**(订单归属 {@code created_id} 的唯一来源);取不到就**显式拒绝**。
+     *
+     * <p>与 {@link #requireTenantId} 同一条纪律,理由再重一档:{@code created_id} 写错的后果
+     * **静默** —— 落成 null 的行从此对任何非超管不可见(列表谓词是「本人建的」),
+     * 却不会有任何报错。
+     *
+     * <p>理论不可达:本端点前置 {@code @PreAuthorize},能进来的只有 JWT 登录态
+     * ({@code UserDetailsServiceImpl:52-53} 从 {@code sys_user.id} 装配 userId)。
+     * 与 {@code requireTenantId} 一样,这是**防御**,不是流程。
+     */
+    private Long requireUserId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        Long userId = user == null ? null : user.getUserId();
+        if (userId == null) {
+            throw new BusinessException(403, "当前登录身份没有账号标识,无法发起收款,请重新登录");
+        }
+        return userId;
+    }
+
     /**
      * 请求体里有不认识的字段 ⇒ 400 + 一句人话,**不是** HTTP 500「服务器内部错误」。
      *

+ 36 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -83,6 +83,8 @@ public class LeshouyingOrderController {
         Page<LsyOrderEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyOrderEntity> w = new LambdaQueryWrapper<>();
         applyTenantFilter(w, tenantId);
+        // ★ 2026-10-05:非超管恒定只查本人创建的(超管豁免)—— 判据与理由见 applyCreatorFilter
+        applyCreatorFilter(w);
         if (StrUtil.isNotBlank(status)) {
             w.eq(LsyOrderEntity::getStatus, status);
         }
@@ -177,6 +179,40 @@ public class LeshouyingOrderController {
         w.eq(LsyOrderEntity::getTenantId, tenantId);
     }
 
+    /**
+     * 只显示**本人(登录账号)创建**的订单 —— 非超管恒定加 {@code created_id = 自己}。
+     *
+     * <p>【为什么需要它】{@code pay_lsy_order} 原先没有任何创建人列,租户下几个入口
+     * (开放 API / 免登录收银台 / 收银台)建的单在列表上混在一起 —— 用户 2026-10-05 报的
+     * 「这些订单有很多都不是用户 q 的」就是这个。裁定(用户拍板):列表只显示本人建的。
+     *
+     * <p>【为什么不用平台现成的 data_scope 机制】{@code DataScopeInnerInterceptor} 存在但
+     * **未接入拦截器链**({@code MyBatisPlusConfig} 只注册了租户/分页/乐观锁三个),
+     * 且它的表清单本来也不含 {@code pay_lsy_order}。动那条全局链会波及所有模块 ——
+     * 本处按它的判据形状(scope=1 即 {@code created_id = 当前用户})在本地实现。
+     *
+     * <p>【为什么超管豁免】与 {@link #applyTenantFilter} 用**同一个开关**
+     * ({@code LoginUser#getIsSuperuser}):超管的定位是跨租户运维,锁到「本人建的」会让它
+     * 在生产上连自己没建过的单都看不到 —— 而它本来就能看全租户。
+     *
+     * <p>【为什么取不到主体 / 主体没有 userId 时**拒绝**而不是给空列表】同
+     * {@code LeshouyingCashierController#requireTenantId} 的纪律:{@code eq(列, null)}
+     * 恒不成立,表现是一个「看起来正常的空页面」,比一句 403 难查得多。本处**没有**拦截器兜底,
+     * 取不到「本人」就必须显式拒绝。
+     */
+    private void applyCreatorFilter(LambdaQueryWrapper<LsyOrderEntity> w) {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        if (user != null && Boolean.TRUE.equals(user.getIsSuperuser())) {
+            return;
+        }
+        Long uid = user == null ? null : user.getUserId();
+        if (uid == null) {
+            throw new BusinessException(403, "当前登录身份没有账号标识,无法查看订单列表,请重新登录");
+        }
+        w.eq(LsyOrderEntity::getCreatedId, uid);
+    }
+
     /** 详情含回调审计轨迹,便于对账排查 */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/{outTradeNo}")

+ 17 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyOrderEntity.java

@@ -72,6 +72,23 @@ public class LsyOrderEntity extends PaymentTenantBaseEntity {
 
     private OffsetDateTime finishTime;
 
+    /**
+     * 创建人:**本平台登录账号**({@code sys_user.id}),管理端列表的归属列。
+     *
+     * <p>【谁写】只有收银台人工建单腿({@code LeshouyingCashierController.createQr})落库时写入;
+     * 开放 API 那条腿(api key 主体)与免登录收银台那条腿(合成登录态,没有 userId)
+     * **构造上没有登录账号** ⇒ 恒 NULL。历史行同样恒 NULL —— **不回填**(归属无从考证)。
+     *
+     * <p>【谁读】{@code LeshouyingOrderController#page}:非超管的列表恒加
+     * {@code created_id = 当前账号}(用户 2026-10-05 裁定「只显示本人建的」;
+     * 建列脚本见 {@code db/lsy-order-created-id.sql})。超管不加谓词 ⇒ NULL 行只对超管可见。
+     *
+     * <p>【为什么是显式赋值、不是自动填充】本模块实体不继承 {@code UserBaseEntity},
+     * 且 {@code AutoFillMetaObjectHandler} 只填 createdTime/updatedTime/tenantId ——
+     * {@code createdId} 在本仓的既有写法就是显式 set(同 {@code OperationLogInterceptor:55})。
+     */
+    private Long createdId;
+
     /**
      * 本单**是否有退款正在途中** —— 只读标记,**不是库里的列**({@code exist = false})。
      *

+ 41 - 0
java/src/main/resources/db/lsy-order-created-id.sql

@@ -0,0 +1,41 @@
+-- 订单归属列:给 pay_lsy_order 加 created_id(创建人 = 本平台登录账号 ID)
+--
+-- 【为什么需要单独一个文件】
+-- V1.8 用的是 `CREATE TABLE IF NOT EXISTS` —— 它的判据是**关系名**,不是定义。
+-- 建表语句执行过之后,对 V1.8 里 `pay_lsy_order` 的**任何修改都静默失效**
+-- (只发一条 NOTICE,什么都不做)。而 V1.8 已经执行过,所以新增列必须在**这里**重发一次,
+-- 否则文件与库分叉(同款先例:lsy-refund-reconcile-cursor.sql 对 pay_lsy_refund 的处理)。
+--
+-- 【改的是什么 / 为什么要这一列】
+-- 用户 2026-10-05 报「这些订单有很多都不是用户 q 的」—— pay_lsy_order 此前**没有任何创建人列**
+-- (user_id 是**上游付款人**标识,不是建单人)⇒ 租户下各入口(开放 API / 免登录收银台 / 收银台)
+-- 建的单在管理端列表上混在一起,分不出「谁建的」。裁定(用户拍板):列表**只显示本人建的**。
+--
+-- 【写入方】只有收银台人工建单腿(LeshouyingCashierController.createQr)写入;
+-- 开放 API(api key 主体)与免登录收银台(合成登录态,没有 userId)两条腿**构造上没有登录账号**
+-- ⇒ 恒 NULL。
+--
+-- 【不回填】历史行一律保持 NULL,**不做任何猜测式回填**:归属无从考证,猜一个比 NULL 更坏。
+-- 后果(有意):非超管看不到这些历史行(列表谓词是 created_id = 本人);超管不受限。
+--
+-- 【执行顺序:本文件必须在部署新代码**之前**跑完】
+-- 新代码的 selectPage / insert / updateById 的列清单里都含 created_id,
+-- 列不存在时会抛 PostgreSQL 42703(与 lsy-refund-reconcile-cursor.sql 同一个执行顺序闸)。
+--
+-- 【安全性】纯加列 + 注释,不改任何已有列、不动任何已有数据;重复执行是空操作。
+--
+-- 【执行方式】psql -f 本文件,或用任意客户端逐条跑。跑完用文件末尾两句核对。
+
+ALTER TABLE pay_lsy_order
+    ADD COLUMN IF NOT EXISTS created_id BIGINT;
+
+COMMENT ON COLUMN pay_lsy_order.created_id IS '创建人(本平台登录账号ID;仅收银台人工建单腿写入,其余腿恒NULL)';
+
+-- ===== 核对(两句都跑一遍)=====
+--
+-- 1) 列存在且是 bigint —— 期望一行:created_id | bigint
+-- SELECT column_name, data_type FROM information_schema.columns
+--  WHERE table_name = 'pay_lsy_order' AND column_name = 'created_id';
+--
+-- 2) 存量行读数(预期:全部 NULL —— 历史归属不猜测)—— 期望一行两列,attributed 为 0
+-- SELECT count(*) AS total, count(created_id) AS attributed FROM pay_lsy_order;

+ 5 - 0
java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql

@@ -84,6 +84,10 @@ CREATE TABLE IF NOT EXISTS pay_lsy_order (
 
     finish_time             TIMESTAMP WITH TIME ZONE,
     description             VARCHAR(255),
+    -- 创建人:**本平台登录账号**(sys_user.id)。三个建单腿里只有收银台有人类登录态(由它写入),
+    -- 开放 API / 免登录收银台两条腿构造上没有登录账号 ⇒ 恒 NULL;历史行同样不回填(归属无从考证)。
+    -- 管理端列表对非超管恒筛 created_id = 本人(2026-10-05 用户裁定);建列/核对见 db/lsy-order-created-id.sql。
+    created_id              BIGINT,
     created_time            TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
     updated_time            TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
 );
@@ -139,6 +143,7 @@ COMMENT ON COLUMN pay_lsy_order.notify_count         IS '下游通知次数';
 COMMENT ON COLUMN pay_lsy_order.last_notify_time     IS '上次通知时间';
 COMMENT ON COLUMN pay_lsy_order.finish_time          IS '完成时间(终态)';
 COMMENT ON COLUMN pay_lsy_order.description          IS '备注/描述';
+COMMENT ON COLUMN pay_lsy_order.created_id           IS '创建人(本平台登录账号ID;仅收银台人工建单腿写入,其余腿恒NULL)';
 COMMENT ON COLUMN pay_lsy_order.created_time         IS '创建时间';
 COMMENT ON COLUMN pay_lsy_order.updated_time         IS '更新时间';
 

+ 24 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java

@@ -278,6 +278,30 @@ class LeshouyingCashierControllerTest {
         assertNull(o.getNotifyUrl(), "有意不设 —— 见本方法 javadoc");
     }
 
+    /**
+     * 建单行必须带上**当前登录账号**作为创建人({@code created_id})。
+     *
+     * <p>「列表只显示本人建的」(用户 2026-10-05 裁定)全靠这一笔:三个建单腿里只有收银台
+     * 有人类登录态,开放 API / 免登录收银台两条腿构造上没有登录账号(有意恒 NULL)。
+     * 漏掉这笔的后果是**静默**的:这单建完立刻从操作员自己的列表里消失
+     * (非超管的列表恒筛 {@code created_id = 本人}),却不会有任何报错。
+     */
+    @Test
+    void create_stampsTheLoggedInAccount_asCreator() throws Exception {
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + "}"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        // 期望值用 Long.valueOf:写成原始类型 1L 会让 null 走到 NPE(错误)而不是断言失败(红)
+        assertEquals(Long.valueOf(1L), insertedOrder().getCreatedId(),
+                "创建人必须是当前登录账号(login() 设的 userId=1)—— 不是租户、不是别的任何值");
+    }
+
     @Test
     void createQr_publishesLsyOrderSignal() throws Exception {
         // 夹具与 create_assemblesTheOrder_rowShapeAndServerDecidedFields 逐行一致

+ 75 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java

@@ -342,6 +342,8 @@ class LeshouyingOrderControllerTest {
      */
     @Test
     void page_不传租户_不加租户谓词_由拦截器兜底() {
+        // 2026-10-05 起 /page 还要求可识别的主体(创建人闸);无主体的用例在下方归属组
+        login(3L, false);
         when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
 
         controller.page(1, 20, null, null, null, null);
@@ -390,6 +392,76 @@ class LeshouyingOrderControllerTest {
         verify(orderMapper, never()).selectPage(any(), any());
     }
 
+    // ───────────────── 订单归属:非超管只看本人创建的(2026-10-05) ─────────────────
+    //
+    // 用户报「这些订单有很多都不是用户 q 的」—— pay_lsy_order 此前**没有任何创建人列**
+    // (user_id 是上游付款人,不是建单人)。裁定(用户拍板):列表只显示本人(登录账号)建的。
+    // created_id 由收银台那条腿写入(见 LeshouyingCashierControllerTest),
+    // 开放 API / 免登录收银台两条腿与全部历史行恒 NULL ⇒ 只对超管可见。
+
+    @Test
+    void page_非超管_只查本人创建的订单() {
+        login(3L, false);   // 普通账号(login 统一设 userId=1);租户谓词由拦截器追加,不在这里
+        when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
+
+        controller.page(1, 20, null, null, null, null);
+
+        AbstractWrapper<?, ?, ?> w = capturedPageQuery();
+        assertTrue(w.getSqlSegment().contains("created_id"),
+                "非超管的列表必须带创建人谓词;实测: " + w.getSqlSegment());
+        assertEquals(List.of(1L), boundValues(w),
+                "绑的必须是**当前登录账号**(login() 设的 userId=1),多一个少一个都不对");
+    }
+
+    /** 负对照:同一个量具读同一类 wrapper —— 超管这一个开关关掉创建人谓词。 */
+    @Test
+    void page_超管_不加创建人谓词() {
+        login(null, true);
+        when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
+
+        controller.page(1, 20, null, null, null, null);
+
+        AbstractWrapper<?, ?, ?> w = capturedPageQuery();
+        assertFalse(w.getSqlSegment().contains("created_id"),
+                "超管豁免(锁到「本人建」会让跨租户运维连自己没建过的单都看不到);"
+                        + "实测: " + w.getSqlSegment());
+        assertTrue(boundValues(w).isEmpty(),
+                "超管 + 不传租户 ⇒ 一个谓词都不加;实测绑定值: " + boundValues(w));
+    }
+
+    @Test
+    void page_无登录主体_403_而不是一份空列表() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.page(1, 20, null, null, null, null));
+
+        assertEquals(403, e.getCode(),
+                "取不到主体 ⇒「本人」无从谈起,必须显式拒绝(本处没有拦截器兜底:data_scope 未接入);"
+                        + "给一份空列表则是「看起来正常的空」");
+        verify(orderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    void page_主体没有账号标识_403() {
+        // 有主体、没 userId(如免登录收银台的合成登录态)—— 同样无法确定「本人」是谁
+        LoginUser u = new LoginUser();
+        u.setUserId(null);
+        u.setUsername("synthetic");
+        u.setStatus("0");
+        u.setTenantId(3L);
+        u.setIsSuperuser(false);
+        u.setAuthorities(List.of());
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(u, null, List.of()));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.page(1, 20, null, null, null, null));
+
+        assertEquals(403, e.getCode());
+        verify(orderMapper, never()).selectPage(any(), any());
+    }
+
     // ─────────────── 退款在途标记:列表/详情出口处补的那个派生量 ───────────────
     //
     // 用户 2026-09-30 报的:「我点击退款后,lsy 回调送达前,页面上还有退款按钮,难道还能重复点吗?」
@@ -427,6 +499,7 @@ class LeshouyingOrderControllerTest {
     @Test
     void page_只有退款在途的那一行才标记为退款中() {
         Page<LsyOrderEntity> p = pageOfTwoOrders();
+        login(3L, false);   // /page 要求可识别的主体(创建人闸)
 
         controller.page(1, 20, null, null, null, null);
 
@@ -439,6 +512,7 @@ class LeshouyingOrderControllerTest {
     @Test
     void 退款在途的判据_与后端闸门同一集合() {
         pageOfTwoOrders();
+        login(3L, false);   // /page 要求可识别的主体(创建人闸)
 
         controller.page(1, 20, null, null, null, null);
 
@@ -474,6 +548,7 @@ class LeshouyingOrderControllerTest {
 
     @Test
     void page_空页_一次退款表都不查() {
+        login(3L, false);   // /page 要求可识别的主体(创建人闸)
         when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
 
         controller.page(1, 20, null, null, null, null);