瀏覽代碼

feat(进件): 菜单与权限 SQL + review_status 列注释(Task 8,待用户执行)

两份交付 SQL,只写不执行(Global Constraint 6)。

lsy-onboarding-menu.sql:在**已存在**的「收付款」目录下新增进件页与两个权限点。
  · 1 行页面(type=2)「商户进件」 + 2 行按钮(type=3)「建号」「提交入网」。

  ★ 与 lsy-menu.sql 的关键差异:它的 `dir` CTE 是**插入**目录,本脚本是**查**它
  (收付款目录已于 2026-09-22 由 lsy-menu.sql 建好)。照抄它的 INSERT 写法,
  `dir` 会命中空集 ⇒ 一行都不插且不报错 —— 正是 lsy-menu.sql:61-62 警告的那个形态。
  反过来本脚本的幂等更硬:那里 `dir.id` 是刚生成的新 id,它的 NOT EXISTS 是**死条件**
  (lsy-menu.sql:95-97 自陈);这里 dir 是 SELECT 出来的既有 id,那条 NOT EXISTS 是活的。

  ★★ 为什么必须有 type=3 的按钮行:一个 sys_menu 行的 permission 列只能装一个串,
  而进件页有三个端点三个串。授权链是 sys_menu.permission → sys_role_menus →
  MenuMapper.selectPermissionsByUserId → GrantedAuthority → PermissionCheck.hasAny
  (全等匹配),而那条查询 SQL **按 m.type 无过滤**(MenuMapper.java:22-27 实测)
  ⇒ type=3 的行会进入授权集合。
  漏掉按钮行 ⇒ `:create` / `:commit` 永不进入任何角色 ⇒ **普通租户用户必 403**,
  而 PermissionCheck 对 ROLE_SUPERUSER 直接放行 ⇒ **超管测试一个都测不出来**。
  本表自己的父子校验认这个形态:MenuService.java:201-204「菜单下仅允许新增按钮」。

  先例 batch-menu.sql 用 type=3 承载 :authorize / :create / :detail,但**是坏先例**:
  它通篇没有 uuid,按字面执行会违反 uuid NOT NULL(lsy-menu.sql:22-23 已留档)。
  本脚本列清单照 lsy-menu.sql 的 20 列写法。

  三个 permission 逐字取自 LeshouyingOnboardingController.java:107/133/152
  (2026-09-23 从源码现取,未抄文档),已用机械 diff 核对与源码逐字一致。

lsy-review-status-comment.sql:把 §0.9-1 的本地词表 PENDING/APPROVED/REJECTED
  写进 pay_lsy_merchant.review_status 的列注释,并写明该列 NOT NULL DEFAULT 'APPROVED'
  这个危险(任何不显式给值的 INSERT 都会静默拿到 APPROVED)。
  只改注释,不动数据、不动表结构。词表字面值来自 LsyMerchantReviewStatus.java:35/38/41。

两份都自带只读核对语句与**预期读数**(照 receive-code.sql 的先例)。
alphaH 3 周之前
父節點
當前提交
551cea99ba

+ 166 - 0
java/src/main/resources/db/lsy-onboarding-menu.sql

@@ -0,0 +1,166 @@
+-- 乐收赢进件 —— 菜单与权限(Task 8 / 三-B)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前请先读下面「★ 与 lsy-menu.sql 的关键差异」一节。
+--
+-- ── 本脚本干什么 ──────────────────────────────────────────────────────
+-- 在**已存在**的「收付款」目录下,新增:
+--   · 1 行页面(type=2)「商户进件」 —— 进件页路由
+--   · 2 行按钮(type=3)「建号」「提交入网」 —— 承载两个写权限点
+-- 并把这三行授权给 role_id = 1(照 005 / lsy-menu.sql 的先例)。
+--
+-- ── ★ 与 lsy-menu.sql 的关键差异(照抄它的写法会出错的地方)──────────────
+-- lsy-menu.sql 的 `dir` CTE 是**插入**收付款目录;本脚本的 `dir` 是**查**它。
+-- 原因:`lsy-menu.sql` 已于 2026-09-22 执行,收付款目录**已在库里**。
+--   ⇒ 若照抄成 INSERT + `WHERE NOT EXISTS(...)`,`dir` 会命中空集、
+--     于是**本脚本一行都不插、且不报错**(就是 lsy-menu.sql 第 61–62 行警告的那个形态)。
+-- ★ 反过来,本脚本的幂等比 lsy-menu.sql **更硬**:那里 `dir.id` 是本语句刚生成的新 id,
+--   它的 `WHERE NOT EXISTS(... parent_id = dir.id ...)` 是**死条件**(lsy-menu.sql:95-97 自陈);
+--   这里 `dir` 是 SELECT 出来的**既有** id ⇒ 那条 NOT EXISTS 是**活的**,重跑不会重复插页面。
+--
+-- ── ★★ 为什么必须有 type=3 的按钮行(**这是本脚本存在的理由**)───────────
+-- 一个 `sys_menu` 行的 `permission` 列只能装**一个**权限串。进件页有三个端点、三个权限串,
+-- 所以页面行只能装 `:list`,另两个必须落在**按钮行**上。
+-- 授权链(缺一不可):
+--   sys_menu.permission → sys_role_menus(授权行)→ MenuMapper.selectPermissionsByUserId
+--   → GrantedAuthority → PermissionCheck.hasAny(`authorities::contains`,**全等匹配**)
+-- `MenuMapper.selectPermissionsByUserId` 的 SQL **按 `m.type` 无过滤**(只要求 permission 非空)
+--   ⇒ type=3 的行**会**进入 GrantedAuthority(实测读源码:MenuMapper.java:22-27)。
+-- ★ **漏掉按钮行会怎样**:`module_payment:leshouying:onboarding:create` 永远不进入任何角色的
+--   授权集合 ⇒ **普通租户用户点「建号」必 403**;而 **超管直通**(`PermissionCheck` 对
+--   `ROLE_SUPERUSER` 直接 `return true`)⇒ **超管测试一个都测不出来**。
+-- ★ 先例:`batch-menu.sql` 用 type=3 行承载 `module_payment:batch:authorize` / `:create` /
+--   `:detail`(它那 4 行全是 type=3)。本表自己的父子校验也认这个形态:
+--   `MenuService.validateParentChildType` —— `pt == 2` 时 `childType != 3` 即抛
+--   「菜单下仅允许新增按钮」(MenuService.java:201-204)。
+--   ★ 但 **batch-menu.sql 是坏先例、不可照抄**:它通篇 **没有 `uuid`**(0 处命中),
+--     按字面执行会违反 `uuid NOT NULL`(lsy-menu.sql:22-23 已为此留档)。
+--     本脚本的列清单照 `lsy-menu.sql` 的 **20 列**写法,`uuid` 用 `gen_random_uuid()`。
+--
+-- ── 列名:本表的真实结构(逐字照 lsy-menu.sql 的警告)──────────────────
+-- `sys_menu` 的真实列(java/all_schema.sql 的 CREATE TABLE public.sys_menu)是:
+--   name, type, "order", permission, icon, route_name, route_path, component_path,
+--   redirect, hidden, keep_alive, always_show, affix, title, params, parent_id,
+--   id, uuid, status, description, created_time, updated_time
+-- 计划里那套 menu_id / menu_name / order_num / path / component / is_frame / is_cache /
+-- menu_type / visible / perms / create_by / create_time / remark 是 **RuoYi 的表结构**,
+-- 在本表上一个都不存在;'M' / 'C' 那种字符枚举也不存在(本表 type 是 integer)。
+-- type 的语义(MenuCreateDTO.java:22 逐字):**1=目录 2=菜单 3=按钮 4=外链**。
+--
+-- ── ★ uuid:NOT NULL 且无任何兜底 ──────────────────────────────────────
+-- `uuid character varying(64) NOT NULL`,**无 DEFAULT**(唯一索引 ix_sys_menu_uuid),
+-- 应用层也不填(AutoFillMetaObjectHandler 只填 createdTime/updatedTime/tenantId)⇒
+-- 每条 INSERT 都必须显式给值。本脚本用 gen_random_uuid()(36 字符,≤64;volatile ⇒ 逐行取值)。
+--
+-- ── ★ id:不要写死 ────────────────────────────────────────────────────
+-- `id` 有默认值 `nextval('public.sys_menu_id_seq'::regclass)` ⇒ 本脚本**省略 id 列**,
+-- 让序列生成(写死数字会与序列撞 PK)。
+--
+-- ── ★ permission 必须与 Task 7 的 @PreAuthorize 逐字相同 ────────────────
+-- 差一个字符就是 403,而且超管测不出来(同上)。
+-- 本脚本三个 permission 逐字取自(**2026-09-23 从源码现取,未抄任何文档**):
+--   LeshouyingOnboardingController.java:107  @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:list')")
+--   LeshouyingOnboardingController.java:133  @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+--   LeshouyingOnboardingController.java:152  @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:commit')")
+-- 类级 `@RequestMapping("/payment/collection/onboarding")`(同文件 :78)。★ 若 Task 7 之后改动,
+-- 本文件必须重取 —— 取值口径是「现取」,不是「抄一次就完」。
+--
+-- ── ★ component_path 的形态 ───────────────────────────────────────────
+-- 必须是**相对 src/views/ 的路径**:不带 `views/` 前缀、不带 `.vue` 后缀。
+-- 依据:frontend/src/store/modules/permission.store.ts
+--   `modules[\`../../views/${component}.vue\`] || modules["../../views/error/404.vue"]`
+-- 带前缀会拼成 `…/views/views/…index.vue.vue` ⇒ 查不到 ⇒ **静默落到 error/404.vue**。
+-- 本脚本的值与 Task 9 的页面文件路径对齐:
+--   frontend/src/views/module_payment/collection/onboarding/index.vue
+-- ★ Task 9 若改了页面路径,这里必须同步改,否则**页面存在但点进去是 404**。
+--
+-- ── 需要人工确认的三处(取值来自先例,不是实测数据)──────────────────
+-- 1) route_name:本脚本用 PascalCase `CollectionOnboarding`,照**紧邻**的三个 lsy 页面
+--    (CollectionOrder / CollectionRefund / CollectionMerchant,见 lsy-menu.sql:88-93)。
+--    但 batch-menu.sql 用的是 kebab('payment-batch')—— 两套先例不一致。
+--    route_name 会成为 vue-router 的 name(菜单高亮与 keep-alive 的 include 都按它匹配),必须唯一。
+-- 2) route_path:本脚本用 `/payment/collection/onboarding`(模块前缀式),照同目录三个 lsy 页面。
+-- 3) role_id = 1:照 005 与 lsy-menu.sql:65,**请按你库里的实际角色 id 调整**。
+--    本脚本只授权给这一个角色;若进件页要给别的角色用,需另外补 sys_role_menus。
+--
+-- ── 其他 ─────────────────────────────────────────────────────────────
+-- · icon 一律留空:图标集是 src/assets/icons/*.svg,经 `i-svg:<name>` 渲染;
+--   缺省时前端回退到 i-svg:menu。要图标请在「菜单管理」界面里选。
+-- · 幂等(两种**相反**的情形都要知道;本脚本不是通用 upsert):
+--   1) 页面行已存在 ⇒ `page` 为空 ⇒ **两个按钮也不会插**(buttons 的 FROM 是 page),且**不报错**。
+--      若出现「菜单在、按钮不在」,请手工补两条 type=3 的 INSERT。
+--   2) 收付款目录不存在 ⇒ `dir` 为空 ⇒ 三行**都不插**,且**不报错**。
+--      执行前请先跑下面第 1 条只读核对确认目录在。
+
+WITH dir AS (
+    SELECT id FROM sys_menu
+     WHERE name = '收付款' AND type = 1 AND status = '0' AND parent_id IS NULL
+),
+page AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT '商户进件', 2,
+           (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id = dir.id),
+           'module_payment:leshouying:onboarding:list', NULL,
+           'CollectionOnboarding', '/payment/collection/onboarding',
+           'module_payment/collection/onboarding/index',
+           NULL, false, true, false, false, '商户进件', NULL,
+           gen_random_uuid(), dir.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM dir
+    WHERE NOT EXISTS (SELECT 1 FROM sys_menu
+                       WHERE name = '商户进件' AND parent_id = dir.id AND status = '0')
+    RETURNING id
+),
+buttons AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT v.name, 3, v.ord, v.perm, NULL, NULL, NULL, NULL,
+           NULL, false, false, false, false, v.name, NULL,
+           gen_random_uuid(), page.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM page,
+         (VALUES
+            ('建号',     1, 'module_payment:leshouying:onboarding:create'),
+            ('提交入网', 2, 'module_payment:leshouying:onboarding:commit')
+         ) AS v(name, ord, perm)
+    RETURNING id
+)
+-- 页面行与两个按钮行都要授权:菜单树按 sys_role_menus 过滤,只授页面会让按钮权限缺席 ⇒ 403。
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 1, id FROM page
+UNION ALL
+SELECT 1, id FROM buttons;
+
+
+-- ── 只读核对(执行后手工跑一遍;前三条**执行前**也建议先跑)────────────────
+--
+-- 1) 收付款目录在不在(**为空则本脚本什么都不会插,且不报错**)
+--   SELECT id, name, type, route_path FROM sys_menu
+--    WHERE name = '收付款' AND type = 1 AND status = '0' AND parent_id IS NULL;
+--   期望:**1 行**。若 0 行 ⇒ 先跑 lsy-menu.sql(或手工建目录)再回来。
+--
+-- 2) 本次插入的页面行
+--   SELECT id, name, type, "order", permission, route_name, route_path, component_path, uuid
+--     FROM sys_menu WHERE name = '商户进件' AND status = '0';
+--   期望:**1 行**,type=2,permission = 'module_payment:leshouying:onboarding:list',
+--         route_path = '/payment/collection/onboarding',
+--         component_path = 'module_payment/collection/onboarding/index',uuid 非空(36 字符)。
+--
+-- 3) 两个按钮行(**漏了这两行 ⇒ 普通租户用户建号/提交必 403,而超管测不出来**)
+--   SELECT m.id, m.name, m.type, m."order", m.permission
+--     FROM sys_menu m JOIN sys_menu p ON m.parent_id = p.id
+--    WHERE p.name = '商户进件' AND m.type = 3 AND m.status = '0'
+--    ORDER BY m."order";
+--   期望:**2 行**,permission 逐字为
+--         'module_payment:leshouying:onboarding:create' 与 'module_payment:leshouying:onboarding:commit'。
+--
+-- 4) 授权行
+--   SELECT rm.role_id, m.name, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE m.name IN ('商户进件', '建号', '提交入网') AND m.status = '0';
+--   期望:**3 行**,role_id 都是 1。
+--
+-- 5) 端到端(**唯一能证明权限串没写错的一条**):用一个**非超管**的租户账号登录,
+--    确认三个`module_payment:leshouying:onboarding:*`都出现在
+--    `MenuMapper.selectPermissionsByUserId` 的返回里。
+--    ★ 超管账号**证明不了任何事** —— 它对 ROLE_SUPERUSER 直接放行。

+ 59 - 0
java/src/main/resources/db/lsy-review-status-comment.sql

@@ -0,0 +1,59 @@
+-- 乐收赢商户映射 —— review_status 本地词表写进列注释(Task 8 / 三-B,计划 §0.9-1)
+--
+-- 【本脚本尚未执行】执行由用户做。**只改注释,不动任何数据、不动表结构** ⇒ 可安全重复执行。
+--
+-- ── 为什么要有这个脚本 ────────────────────────────────────────────────
+-- V1.8 建表时把这一列写成 `review_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED'`,
+-- 但当时**没有定义 B 阶段的词表**:列注释只说「B 阶段由进件写入口与上游审核通知回写」,
+-- 没有任何地方枚举过 B 阶段该写什么值。三-B 把这三个值定下来了(计划 §0.9-1 的裁定),
+-- 本脚本把词表落进**数据库自己的元数据**里 —— 让以后直接看库的人不必回去翻代码。
+--
+-- ★ **本脚本不改变任何行为**。真正的约束在 Java 侧:`LsyMerchantReviewStatus` 的三个常量,
+--   有 `LsyMerchantReviewStatusTest` 钉着字面值。本脚本只是把同一份词表抄进注释。
+--
+-- ── ★ 这一列的真实危险:DEFAULT 'APPROVED' ─────────────────────────────
+-- `NOT NULL DEFAULT 'APPROVED'` 意味着**任何不显式给值的 INSERT 都会静默拿到 APPROVED**,
+-- 也就是「刚进件、还没审核」在管理端被读成「已通过」。
+-- 进件写入口(`LeshouyingOnboardingController.create` → `LsyMerchantService.createOnboardingRow`)
+-- **必须显式写 PENDING**,不得依赖列默认值;也不得复用 `LsyMerchantService.save` 的新增分支
+-- (那一路正是把 review_status 钉成 APPROVED 的地方,计划 §0.9-3)。
+-- 本条**不是**本脚本能保证的,这里写下来是因为「改注释的人」正是最该知道它的人。
+--
+-- ── 词表三个值的来源(每个词都不是新造的)────────────────────────────
+--   PENDING  —— 本批次(三-B)**新引入**:进件写入口用它。
+--   APPROVED —— 沿用**同一份 DDL 的默认值**里的那个词(逐字 'APPROVED',不是新词)。
+--   REJECTED —— 沿用同模块 `pay_lsy_notify_log.process_status` **已在用的**那个词
+--               (该列注释逐字列着 RECEIVED/APPLIED/DUPLICATE/REJECTED/...,
+--                且 `LsyNotifyService` 在验签失败分支上写的就是字面量 "REJECTED")。
+--
+-- ★ 这**不是** spec §6.4 禁的那种「归一化」。那条禁的是把上游**三套** reviewed_status
+--   值域互相猜着合并;这里是给**本地**一列定词表,且只从上游 556 这一套推导。
+--   上游值域 → 本地词表的映射**只此一处**,在 `LsyOnboardingReviewStatus`。
+
+COMMENT ON COLUMN pay_lsy_merchant.review_status IS
+    '进件审核状态(本地词表,VARCHAR(32))。三种取值:'
+    'PENDING=待审核(三-B 新引入,进件写入口写入;新行**不得**落到本列默认值 APPROVED 上);'
+    'APPROVED=审核通过(沿用本列 DDL 默认值里的那个词);'
+    'REJECTED=审核驳回(沿用同模块 pay_lsy_notify_log.process_status 已在用的那个词)。'
+    '字面值由 LsyMerchantReviewStatus 常量钉住;上游审核状态到本词表的映射只在 LsyOnboardingReviewStatus 一处,别的类不许各写一份。';
+
+
+-- ── 只读核对(执行后手工跑一遍)──────────────────────────────────────
+--
+-- 1) 注释是否落上(**逐字**比对上面那段,注意 psql 会把多行字符串拼成一行)
+--   SELECT col_description('pay_lsy_merchant'::regclass,
+--                          (SELECT ordinal_position FROM information_schema.columns
+--                            WHERE table_name = 'pay_lsy_merchant' AND column_name = 'review_status'));
+--   期望:一段含 'PENDING'、'APPROVED'、'REJECTED' 三个词的文本。
+--
+-- 2) 列的默认值**仍然是 APPROVED**(本脚本**故意不改它**)
+--   SELECT column_default, is_nullable, character_maximum_length
+--     FROM information_schema.columns
+--    WHERE table_name = 'pay_lsy_merchant' AND column_name = 'review_status';
+--   期望:`'APPROVED'::character varying | NO | 32`。
+--   ★ 若这里被改成了别的值,说明有人动过 DDL —— 那与 `LsyMerchantReviewStatus` 的
+--     APPROVED 常量、以及 V1.8 的存量行口径都不一致,需要先裁定再继续。
+--
+-- 3) 库里的值**是否只在词表内**(本脚本执行前后都可跑;只读)
+--   SELECT review_status, count(*) FROM pay_lsy_merchant GROUP BY review_status ORDER BY 1;
+--   期望:出现的值都落在 {PENDING, APPROVED, REJECTED} 内。出现别的值 ⇒ 有写入口没走常量。