Files
workspace/code/fms/FMS删除策略重构-待决策问题清单.md
T
2026-09-19 21:17:02 +08:00

263 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FMS 删除策略重构 — 待决策问题清单(已全部关闭)
> **状态:本文所列问题均已决策并实施完毕,保留作为决策过程的记录。**
> 结论已回写进 `FMS删除策略重构设计.md`(v1.1 / v1.2),代码已按结论落地。
> 阅读本文时请以设计文档为准;本文只说明"为什么设计长成现在这样"。
>
> 最终决策对照:
>
> | # | 问题 | 结论 |
> | --- | --- | --- |
> | 1 | `b_on_delete` 默认值 | 新建关系默认 `restrict`;**存量 `none` 不回填**,避免上线后突然改变存量删除行为(设计 §3.1) |
> | 2 | SQL 只读校验 | 复用 `DbUtils.validateReadOnlySql` + `DbUtils.loadDataBySql(Connection, String)`,不新建第二套判定器(设计 §4.4) |
> | 3 | `:ids` 参数形式 | 非 JDBC 命名参数;引擎按主键类型替换为**经过校验和转义**的字面量 `IN` 列表(设计 §4.2) |
> | 4 | 两条路径清单合并 | 显式删除与 cascade 展开按「表 + 主键字段 + 主键值」**去重合并**;同一记录同时以两种目标提交直接报冲突(设计 §6.2) |
> | 5 | `b_module_id` 类型 | `varchar(50)` 模块编码,与 `s_relation` 等配置表一致(设计 §4.1) |
> | 6 | 前端多对一硬编码 | `ModuleRelationPanel.vue` 的 `isManyToOne` 禁用/重置/警告与 `_hasRefRule` 联动全部删除(设计 §3.2、§9 第三阶段) |
> | 7 | 视图表回退 | 删除操作只允许 `b_save_table`;为空时前后端都拒绝,绝不回退 `b_view_table`(设计 §6.1) |
>
> 除此之外,设计文档还新增了两处本文未提出的必要补充:
> `target=module/direct` 二分(§1.1,避免给 `bf_files` 等技术表虚构模块)和文件删除路径(§6.3)。
---
> 用途(原始):本文把《FMS删除策略重构设计.md》落地前必须先拍板的问题集中列出,供多方(人工 + 其他 AI)评审。
> 阅读者可能无法访问本代码库,因此每个问题都附带了**必要的事实依据**(文件路径 + 行号 + 现有代码片段)。
>
> 背景:`FMS删除策略重构设计.md` v1.0 是一份"架构定稿、由 AI 执行"的文档。本文**不否定其架构方向**,
> 只列出执行前存在的现状误判、行为反转风险与设计空白。
>
> 已确认的前提:**当前数据库无配置数据,`s_rule` 可直接删表重建**(该前提消除了"数据迁移"类问题)。
---
## 一、先说结论
文档的**架构判断是正确的**,值得保留:
1. 两类配置不合并(关联处置 vs SQL 规则,依赖维度不同);
2. SQL 规则只保留"返回至少一行 → 拒绝"这一条契约,废弃 `b_scope_type` / `b_scope_id` / `b_hook` / `b_kind` / `b_predicate`;
3. 删除引擎三职责(计划器 / 检查器 / 执行器),对前端只暴露一个动作;
4. 列表删除与 `saveobjt` 删行共用同一套检查逻辑。
但文档 §9「AI 实施顺序」建立在**对代码现状的错误假设**上,直接照做会出错。
以下 4 个问题的性质不同,其中 **问题 1 和问题 3 必须人工拍板**,问题 2、4 属于文档补全。
---
## 二、问题 1(必须决策):`b_on_delete` 默认值 `none → restrict` 是行为反转
### 事实依据
现状建表语句 `sql/fms_core.sql:208`:
```sql
b_on_delete varchar(20) not null default 'none', -- 删除连带:none不连带 / cascade连带删除 / archive连带归档
```
文档 §3.1 要求:**默认值使用 `restrict`**,且只允许 `restrict` / `cascade` / `none` 三值。
### 为什么这是问题
`none` 与 `restrict` 的语义是相反的:
| 取值 | 语义 | 新增一条关系后的默认行为 |
| --- | --- | --- |
| `none`(现状默认) | 不展开、**不保护**,由配置者承担结果 | 删主单时明细不受任何检查 |
| `restrict`(文档要求) | 被引用方仍有外部引用时**拒绝删除** | 删主单时会被明细挡住 |
文档 §8 已明确"没有登记到 `s_relation` 的引用不在自动保护范围内",这没问题;
但**已登记关系且取值为 `none`** 的那些,默认值一改,行为立即翻转。
### 与现有前端代码的直接冲突
`fms-vue/src/views/module/module-management/ModuleRelationPanel.vue:317-318`:
```js
if (dataIndex === 'b_relation_type' && isManyToOne(row) && row.b_on_delete !== 'none') {
row.b_on_delete = 'none'
}
```
前端现在把 `many_to_one` 关系的 `b_on_delete` **强制重置为 `none`**。
而文档 §3.2 明确反对这一做法:"`many_to_one` 不在前端被硬编码禁止 `cascade`……是否允许级联由配置和业务语义决定"。
也就是说,**文档 §3.2 那一整节实际上是在推翻一段已存在的代码**,但文档没有点名,
执行者可能只改后端、漏掉这段前端硬编码,导致新配置在界面上被静默改回 `none`。
### 待决策
`s_relation.b_on_delete` 的默认值如何处理?可选:
- **方案 A(推荐)**:列默认值改为 `restrict`(新增关系默认受保护),同时写一条回填语句把**存量关系**置为 `none`,保证现有删除行为不变。
- **方案 B**:存量关系也一并改为 `restrict`,一致性最好,但上线后可能突然挡住一批原本能删的操作。
- **方案 C**:保持 `none` 为默认,仅补充 `cascade` / `restrict` 的显式配置能力(与文档 §3.1 不一致,需回改文档)。
**并且需要明确**:文档 §3.1 说的"默认值",指的是**数据库列默认值**,还是**配置界面新增行时的默认值**?两者可以不同。
---
## 三、问题 2(文档补全):SQL 规则的"只读"边界存在现成手段,但文档未引用
文档 §4.2 约定"SQL 必须是只读查询",§4.3 重申"配置 SQL 不等于允许 SQL 修改数据",
但**没有指定用什么机制保证**,也没说复用现有能力。
### 事实依据
- 项目规范《开发规范.md》第 8 条已把"只执行查询语句"定为系统级边界:
> **原生 SQL 仅限查询** — `b_query_sql`、查询 SQL 扩展和调用方传入的 SQL 可以是任意合法的 `SELECT` 语句……
> 禁止执行 `INSERT`、`UPDATE`、`DELETE`、DDL、存储过程调用及其他非查询语句。
- 现有实现样板:`fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java`
(模块级 SQL 条件拼装与服务端数据范围过滤,可直接参照其连接管理与查询执行方式)。
### 建议
在 §4.2 补一句:**复用现有 `SELECT` 校验入口与连接管理,不新写一套 SQL 判定器**
(符合《开发规范.md》第 3、4 条"复用现有模式 / 修改范围最小化")。
---
## 四、问题 3(必须决策):`:ids` 的参数形式未定义,而它决定契约能否落地
### 事实依据
文档 §4.2 给出的 SQL 契约示例:
```sql
select b_id, b_no
from cw_receipt
where b_id in (:ids)
and b_status in ('审核中', '已生效')
```
文档只写"统一使用 `:ids` 作为批量参数",**没有说明这个占位符在实现层是什么**。
而现有数据访问层的能力是**字面量拼接**,不是命名参数:
`DbUtils` 提供 `toSqlStringLiteral(...)`;`DataSaveService.executeRows(...)`
(`fms-api/.../service/DataSaveService.java:460-479`)逐行调用 `dbUtils.delete/update/insert`,
`SqlPermissionService` 拼数据范围条件时同样是 `dbUtils.toSqlStringLiteral(userId)` 直接拼进 SQL 文本。
### 因此 `:ids` 只能有三种落地方式,必须选一个
- **方案 A**:引擎把 `:ids` 替换为拼接好的 `IN (...)` 列表(雪花 bigint 或业务键 varchar,按类型加引号/校验)。与现有代码风格一致。
- **方案 B**:改造 `DbUtils` 支持真正的参数化(`?` / 命名参数),改动面大,触及所有保存/查询路径。
- **方案 C**:把待删 ID 集先写入临时表,SQL 用 `join` 临时表(避免超长 `IN` 列表,但引入临时表生命周期管理)。
**相关约束**:《开发规范.md》第 5 条"当前系统按内部 ERP 处理……不额外引入复杂的安全防护层",
第 6 条"SQL 驱动优先"。按此基调,**方案 A 更契合**,但需要文档显式写明,
并要求删除引擎统一对 `ids` 做**类型校验**(不能把用户输入直接拼进 SQL)。
此外还需明确:`:ids` 是**当前模块本次待删 ID**,还是**整个删除清单所有模块的 ID**?
文档 §4.2 写"SQL 接收当前模块本次待删除的 ID 集合",暗示是前者,
但多条规则跨模块级联时,配置者可能期望看到子表 ID —— 这一点容易产生歧义。
---
## 五、问题 4(必须是设计决策,不是实现细节):两条路径的待删清单如何合并
文档 §6 说列表删除和 `saveobjt` 删行"共用一套检查逻辑",方向正确,但**合并规则是空白的**。
### 事实依据
`saveobjt` 的实际执行顺序(`fms-api/.../service/DataSaveService.java:417-437`):
```java
executeRows(connection, table, keyColumns, "delete", rows(request, "deletes"));
executeRows(connection, table, keyColumns, "update", rows(request, "updates"));
executeRows(connection, table, keyColumns, "insert", rows(request, "inserts"));
```
即 **先 delete → 再 update → 最后 insert**,且逐行循环。
前端列表删除现状(`fms-vue/src/components/fms-module-list/FmsModuleListPage.vue:877-933`):
`deleteSelected()` 取 `resolveSaveTable()`(`:754`,即 `b_save_table || b_view_table`),
然后调用 `saveObjectApi([{ table, key_field, deletes }])` —— **传的是裸表名,不是模块编码**。
### 由此产生的三个未答问题
1. **`moduleCode + ids` 如何反查表和主键?**
文档 §6.1 要求列表删除改为传 `moduleCode + ids`,但现有链路是 `table + key_field`。
后端需要新增"模块 → 保存表 + 主键字段"的反查,且需考虑一个模块可能对应多张表的情况。
`saveobjt` 路径则保留 `table` 入参 —— **两条路径的入参格式不一致,如何落到同一个服务?**
2. **cascade 展开的子记录与请求自带的 `deletes` 行如何合并去重?**
例如 `saveobjt` 请求本身带了明细行的 `deletes`(表单删行),而 cascade 又从主单展开出同一批明细。
文档 §5.2 只说"删除清单内的引用方视为本次一起删除,不触发外部引用保护",
但**没说这份清单怎么构建、两处来源怎么去重**。若不去重,可能出现同一记录删两次(第二次影响 0 行,或触发异常)。
3. **cascade 展开的记录插入 `delete → update → insert` 的哪个位置?**
若模块级删除规则或外键约束要求"子先父后",展开出的多层级记录必须排在请求自带 `deletes` 的**合适位置**。
文档 §5.3 给的流程是"生成清单 → 检查 → 子记录到主记录物理删除",但**没有说明它与 `saveobjt` 既有顺序约束的关系**。
### 待决策
- **方案 A(推荐)**:cascade 展开结果与 `saveobjt` 自带 `deletes` 按「模块 + 主键」**去重合并成一份清单**,再统一检查、按子先父后排序后删除。
- **方案 B**:`saveobjt` 路径不做 cascade,只对请求自带的 `deletes` 跑规则检查(简单,但两条路径语义不再等价,与文档 §6 目标冲突)。
---
## 六、问题 5(文档补全):`s_delete_rule.b_module_id` 的类型与取号方式
文档 §4.1 概念字段表列出 `b_id`(雪花主键)与 `b_module_id`(规则所属数据模块),
但**没有给出 `b_module_id` 的类型**,也没有说明新增规则时是否走前端取号。
### 事实依据
《开发规范.md》「面板式配置页(左树右面板)设计」第 6 条:
> **模块配置不使用雪花临时 ID** — 模块管理中的 `s_module` 使用业务编码,
> `s_field`、`s_module_schema`、`s_autocode`、`s_relation` 等配置表使用业务键或联合主键;
> 新增配置行直接使用业务字段组成的临时唯一键,不调用 `nextIdApi`,保存时也不做"临时 ID → 雪花 ID"转换。
即模块配置类的表普遍用**业务编码 varchar(50)** 作为模块引用(`s_relation.b_source_module_id` 即如此)。
而 `s_delete_rule` 与 `s_rule` 同族,旧表用的是 `b_scope_id varchar(250)`。
### 待决策
- `b_module_id` 用 `varchar(50)` 业务编码(与 `s_relation` 一致,前端不做 ID 换号),还是 `bigint` 雪花 ID?
- 若用 `varchar(50)`:`b_id` 是规则主键(雪花),新增规则仍需前端取号 —— 这与"配置表不做 ID 转换"是否冲突?
旧 `s_rule` 的做法是"前端经 `/data/nextid` 取号"(见 `sql/fms_delete_rule.sql:29` 注释),文档未表态。
---
## 七、问题 6(次要):文档对"现状"的描述性错误
这类问题不影响架构,但会让执行者误判起点,建议一并修正:
| 文档位置 | 文档表述 | 实际情况 |
| --- | --- | --- |
| §1 | `s_relation.b_on_delete` | **已存在**(`sql/fms_core.sql:208`),无需"增加" |
| §3.1 | "并增加或保留一个删除行为列" | 列已存在,只需改默认值语义 |
| §9 第一阶段 2 | "删除旧 `s_rule` 结构,建立 `s_delete_rule`" | 旧设计**已完整落地**:`sql/fms_delete_rule.sql` 建了 `s_rule`,且前端整套已实现 |
| §7 | 待删除的旧概念 `勾条件` / `引用规则` / `refRules` / `relationScopeId` / `b_scope_type` … | **全部真实存在**,不是待设计的抽象概念(见下方清单) |
### 旧设计在前端的实际落点(§7 要求删除的对象)
- `fms-vue/src/views/module/module-management/ruleUtils.js`(195 行,规则类型三态推断、关系业务键拼装)
- `fms-vue/src/views/module/module-management/ModuleRulePanel.vue`
- `fms-vue/src/views/module/module-management/ModuleRuleEditModal.vue`
- `fms-vue/src/views/module/module-management/ModuleRelationPanel.vue`
- `fms-vue/src/views/module/module-management/index.vue`(`rules` / `refRules` / `refRulesReverse` / `refRules_org` 等状态与读写逻辑)
- `sql/fms_core.sql:237-256`(`s_rule` 建表 + 索引)
- `sql/fms_delete_rule.sql`(旧迁移脚本)
值得注意:`ruleUtils.js:62-74` 的 `parseRulePredicate()` 用
"是不是 `{` 开头 / 能不能 `JSON.parse` / `kind === 'dsl'`"来**推断规则类型**(引用 / 勾条件 / SQL 三态)。
这种靠内容猜测类型的做法脆弱且难维护,正是新版"只保留 SQL + 关系处置"的改进理由 ——
**建议把这条写进文档 §4.3,作为废弃 `s_rule` 的具体论据**,比现在的抽象论证更有说服力。
---
## 八、汇总:需要人工拍板的决策点
| # | 决策点 | 影响 |
| --- | --- | --- |
| 1 | `b_on_delete` 默认值:存量是否升级 `restrict`;"默认"指列还是界面 | 决定上线后现有删除行为是否变化 |
| 2 | `:ids` 落地方式:拼接 IN / 参数化 / 临时表;以及 `ids` 范围 | 决定 SQL 契约能否实现及引擎改造量 |
| 3 | 两条路径清单合并:是否去重合并;cascade 是否参与 `saveobjt` | 决定两条路径语义是否真正等价 |
| 4 | `b_module_id` 类型与规则取号方式 | 决定建表脚本与前端取号逻辑 |
| 5 | `ModuleRelationPanel.vue:317-318` 的 `many_to_one → none` 硬编码是否删除 | 不删则新配置在界面被静默改回 |
其余(问题 2、6)为文档补全,不需决策,但**建议在开工前先改文档**,
否则执行者会按错误的现状描述行动。