diff --git a/code/fms/.tmp-probe/wb-switch-migrate.mjs b/code/fms/.tmp-probe/wb-switch-migrate.mjs new file mode 100644 index 00000000..8f866039 --- /dev/null +++ b/code/fms/.tmp-probe/wb-switch-migrate.mjs @@ -0,0 +1,134 @@ +// 方案2:把 coderelay 的 auth 文件转换为 wb-switch 的 accounts.json 记录格式 +// 依据 wb-switch-rust.exe 中提取的结构字符串: +// Account 字段: id uid nickname email access_token refresh_token token_type +// expiresAt refreshExpiresAt refreshedAt createdAt domain +// enterpriseId enterpriseName variant needs_relogin needs_relogin_reason +// auth_raw profile_raw +// credit_usage_snapshots: { accountId, accountName, variant, remaining, total, ts } +import fs from 'node:fs'; +import path from 'node:path'; + +const AUTH_DIR = 'C:/Users/admin/AppData/Roaming/com.coderelay.desktop/sidecar-runtime/auths'; +const WB_DIR = 'C:/Users/admin/.wb-switch'; +const ACCOUNTS = path.join(WB_DIR, 'accounts.json'); +const SNAPSHOTS = path.join(WB_DIR, 'credit_usage_snapshots.json'); + +const DRY_RUN = !process.argv.includes('--apply'); + +// ---- 1. 读取 coderelay 的 auth 文件 ---- +const authFiles = fs.readdirSync(AUTH_DIR).filter((f) => f.endsWith('.json')); +const converted = []; +const now = Date.now(); + +for (const file of authFiles) { + const raw = fs.readFileSync(path.join(AUTH_DIR, file), 'utf8'); + const a = JSON.parse(raw); + + // uid 是唯一稳定的账号标识;id 与 uid 取同一个值(wb-switch 用它关联积分快照) + const id = a.uid; + if (!id) { + console.warn(`[skip] ${file}: 无 uid`); + continue; + } + + // JWT exp 解出过期时间,用于 expiresAt(毫秒) + let expMs; + try { + const payload = JSON.parse( + Buffer.from(a.access_token.split('.')[1], 'base64url').toString('utf8'), + ); + if (payload.exp) expMs = payload.exp * 1000; + } catch { + /* 保守处理:解不出就不写 expiresAt */ + } + + const nickname = a.email || id; + // profile_raw 承载 plan/quota 等展示字段,让界面能显示套餐与积分 + const profileRaw = { + planType: a.payment_type ?? null, + planRank: a.plan_rank ?? null, + region: a.region ?? null, + enterpriseId: a.enterprise_id ?? '', + baseUrl: a.base_url ?? null, + type: a.type ?? null, + }; + + converted.push({ + id, + uid: id, + nickname, + email: a.email ?? '', + access_token: a.access_token, + refresh_token: a.refresh_token, + token_type: 'Bearer', + domain: a.domain ?? 'www.codebuddy.cn', + enterpriseId: a.enterprise_id ?? '', + enterpriseName: '', + variant: a.region === 'cn' ? 'cn' : 'ai', + expiresAt: expMs ?? null, + createdAt: now, + refreshedAt: now, + // auth_raw 保存原始认证响应,是 wb-switch 认账的关键字段 + auth_raw: raw, + profile_raw: JSON.stringify(profileRaw), + remaining: typeof a.quota_remain === 'number' ? a.quota_remain : undefined, + }); +} + +console.log(`coderelay auth 文件: ${authFiles.length} 个,成功转换 ${converted.length} 条`); + +// ---- 2. 读取现有 accounts.json,保留非本次导入的账号 ---- +const existing = JSON.parse(fs.readFileSync(ACCOUNTS, 'utf8')); +const importedUids = new Set(converted.map((c) => c.uid)); + +// 残缺记录:无 id 且 uid 属于本次导入范围。 +// 注意:这类记录是 wb-switch 刷新时 append 出来的孤儿(每条 token 都不同, +// 并非同一 token 的复制),清除它们不会丢失任何独立账号。 +const isJunk = (r) => r.id === undefined && importedUids.has(r.uid); +const kept = existing.filter((r) => !isJunk(r)); +const removed = existing.length - kept.length; + +console.log(`现有记录 ${existing.length} 条 → 清除残缺重复 ${removed} 条,保留 ${kept.length} 条`); + +// ---- 3. 组装新 accounts.json ---- +const cleaned = converted.map(({ remaining, ...rest }) => rest); +const merged = [...kept, ...cleaned]; + +console.log(`\n新 accounts.json 将包含 ${merged.length} 条:`); +for (const r of merged) { + console.log(` - ${r.nickname} | id=${r.id} | variant=${r.variant ?? '-'}`); +} + +// ---- 4. 积分快照 ---- +const snapshots = JSON.parse(fs.readFileSync(SNAPSHOTS, 'utf8')); +const newSnaps = converted + .filter((c) => c.remaining !== undefined) + .map((c) => ({ + accountId: c.id, + accountName: c.nickname, + remaining: c.remaining, + total: 0, + ts: now, + variant: c.variant, + })); + +const snapMerged = [...snapshots, ...newSnaps]; +console.log(`\n积分快照 ${snapshots.length} → ${snapMerged.length} 条(新增 ${newSnaps.length})`); +for (const s of newSnaps) { + console.log(` - ${s.accountName} | remaining=${s.remaining} | accountId=${s.accountId}`); +} + +// ---- 5. 写入 ---- +const accountsJson = JSON.stringify(merged, null, 2); +const snapJson = JSON.stringify(snapMerged, null, 2); + +if (DRY_RUN) { + fs.writeFileSync('D:/workspace/code/fms/.tmp-probe/accounts.new.json', accountsJson); + fs.writeFileSync('D:/workspace/code/fms/.tmp-probe/snapshots.new.json', snapJson); + console.log('\n[DRY-RUN] 未改动 wb-switch。预览已写入 .tmp-probe/'); + console.log('确认无误后加 --apply 执行'); +} else { + fs.writeFileSync(ACCOUNTS, accountsJson); + fs.writeFileSync(SNAPSHOTS, snapJson); + console.log('\n[APPLIED] 已写入 accounts.json 和 credit_usage_snapshots.json'); +} diff --git a/code/fms/FMS删除策略重构-待决策问题清单.md b/code/fms/FMS删除策略重构-待决策问题清单.md new file mode 100644 index 00000000..9d5b4b02 --- /dev/null +++ b/code/fms/FMS删除策略重构-待决策问题清单.md @@ -0,0 +1,262 @@ +# 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)为文档补全,不需决策,但**建议在开工前先改文档**, +否则执行者会按错误的现状描述行动。 diff --git a/code/fms/FMS删除策略重构设计.md b/code/fms/FMS删除策略重构设计.md new file mode 100644 index 00000000..85c152ad --- /dev/null +++ b/code/fms/FMS删除策略重构设计.md @@ -0,0 +1,385 @@ +# FMS 删除策略重构设计 + +> 版本:v1.2 +> +> 本文取代此前的删除规则设计和改造清单。本文只确定架构、边界和实施思路,具体代码由 AI 按本文执行。 +> +> 设计目标:操作简单、执行逻辑简单、业务控制以 SQL 为主;第一期只做物理删除,不做归档。 + +## 1. 重构结论 + +删除能力保留两个来源,但统一放到一个“删除策略”入口下: + +```text +删除策略 +├── 关联删除处理 +│ └── s_relation.b_on_delete +└── 删除 SQL 规则 + └── s_delete_rule +``` + +两部分回答的问题不同: + +| 配置 | 要回答的问题 | 作用 | +| --- | --- | --- | +| 关联删除处理 | 删除一条记录时,关联记录怎么办 | 展开级联删除、保护外部引用 | +| 删除 SQL 规则 | 什么情况下不允许删除 | 检查状态、业务单据、跨表条件 | + +不把两类配置合成一张表。关联处理依赖关系方向和基数,SQL 规则依赖模块和删除批次;合成后会重新引入范围类型、拼接关系键和大量无意义的空字段。 + +### 1.1 删除对象不强制绑定模块 + +删除对象分为两类,不能为了统一接口给没有业务模块的表虚构 `moduleCode`: + +| 删除对象 | 调用参数 | 处理方式 | +| --- | --- | --- | +| 业务模块数据 | `target=module` + `moduleCode + ids` | 读取模块的保存表和主键,展开 `cascade`,执行 `restrict` 和模块 SQL 规则,再物理删除 | +| 技术表或专用资源 | `target=direct` + `table + key_field + ids` | 直接进入物理删除执行器,不读取模块关系,不执行模块 SQL 规则 | + +两类请求共用连接、事务、主键类型校验、批量上限、日志和底层物理删除执行器;只有第一类进入删除计划器和删除检查器。后端不根据表名反查模块,也不要求 `bf_files`、配置表、日志表等技术表登记一个无意义的模块。 + +“直接删除”表示删除策略层不再展开业务关系。它不表示可以绕过文件服务自身的清理步骤:文件删除仍由文件服务先删除本地文件或 OSS 对象,再删除 `bf_files` 行。 + +直接表目标只由文件服务或其他受控的后端服务调用,普通模块列表不允许让用户任意传入表名和主键字段。 + +## 2. 第一阶段范围 + +### 2.1 第一阶段包含 + +- 模块关系上的物理删除策略:`restrict`、`cascade`、`none`; +- 模块级 SQL 删除规则; +- 无模块技术表的直接物理删除; +- 模块列表删除和 `saveobjt` 中的模块删除共用一套检查逻辑;无模块删除共用同一个物理删除执行器; +- 批量删除、事务回滚、子记录先删; +- 删除失败时返回可读的规则或关系错误; +- 技术日志记录规则执行和删除结果。 + +### 2.2 第一阶段不包含 + +- `archive` 归档行为; +- `b_archived`、`b_archive_datetime` 等归档字段; +- JSON 条件 AST 和“勾条件”编辑器; +- 保存前规则、修改限制、Java 钩子和自动业务动作; +- 让配置 SQL 直接修改或删除业务数据; +- 为删除策略另建角色、模板或继承体系。 + +后续增加归档时,只扩展关联删除策略和执行器,不改变 SQL 删除规则的语义。 + +## 3. 关联删除处理 + +### 3.1 `s_relation.b_on_delete` + +`s_relation` 继续负责模块字段之间的关系映射,并增加或保留一个删除行为列。第一阶段只允许三个值: + +| 值 | 语义 | +| --- | --- | +| `restrict` | 被引用方仍有外部引用时拒绝删除 | +| `cascade` | 引用方记录加入本次物理删除清单 | +| `none` | 不自动处理关联记录 | + +新建关系的默认值使用 `restrict`。已存在的 `none` 不批量回填为 `restrict`,以免上线后突然改变存量删除行为;存量关系是否升级由单独迁移决定。只有登记在 `s_relation` 中的关系才进入自动保护范围;没有登记的表不视为系统已知引用。 + +### 3.2 关系方向 + +关系类型表达结构,删除行为表达处置方式,两者不互相推断: + +| 关系类型 | 外键所在侧 | 被引用方 | +| --- | --- | --- | +| `many_to_one` | 源字段 | 目标模块 | +| `one_to_many` | 目标字段 | 源模块 | +| `one_to_one` | 按系统约定 | 源模块 | + +`many_to_one` 不在前端被硬编码禁止 `cascade`。例如“明细 → 主单”的多对一关系,删除主单时级联删除明细是合理场景。是否允许级联由配置和业务语义决定,后端负责检查环路、重复记录和删除层级。 + +这是对现有前端行为的明确改造要求,不能只改后端: + +- 删除 `fms-vue/src/views/module/module-management/ModuleRelationPanel.vue` 中 `b_on_delete` 编辑器的 `disabled: (row) => isManyToOne(row)`; +- 删除同文件 `onCellChange` 中“关系类型切为多对一就把 `b_on_delete` 重置为 `none` 并弹出‘多对一关系不支持连带删除/连带归档’”的代码块; +- 删除 `_hasRefRule`、`setRefRule` 及其关系键变化联动,引用保护统一由 `b_on_delete = restrict` 表达; +- `b_on_delete` 的唯一配置来源是删除策略中的关联删除处理,不能再由关系类型或另一套勾选规则覆盖。 + +### 3.3 关联策略的边界 + +- `cascade` 只负责沿已登记关系展开删除清单,不负责判断业务状态; +- `restrict` 只检查删除清单之外的外部引用;同一批次内也被删除的引用不算外部引用; +- `none` 不展开、不保护,由配置者明确承担结果; +- 复杂引用判断使用删除 SQL 规则,不在关系表中增加第二套规则字段。 + +## 4. 删除 SQL 规则 + +### 4.1 表职责 + +第一阶段建议使用专用表 `s_delete_rule`,不再使用泛化的 `s_rule`。这样表名直接说明用途,也避免为未来不存在的规则类型预留抽象。 + +概念字段如下: + +| 字段 | 含义 | +| --- | --- | +| `b_id` | 规则主键,应用层生成的雪花 ID | +| `b_module_id` | 规则所属数据模块编码,`varchar(50)`;只允许模块删除使用 | +| `b_sql` | 只读查询 SQL | +| `b_message` | 删除失败时的提示文案 | +| `b_canuse` | 是否启用 | +| `b_xh` | 执行顺序 | +| `b_created_*` / `b_updated_*` | 审计字段 | + +不保留以下字段: + +```text +b_scope_type +b_scope_id +b_hook +b_kind +b_predicate +``` + +`s_delete_rule` 不覆盖无模块的直接表删除。技术表如果需要业务保护,由拥有该表的专用服务在调用删除前执行自己的检查;第一阶段不把表名、服务名和模块编码再抽象成一套通用作用域。 + +### 4.2 SQL 契约 + +模块删除规则 SQL 只表达“哪些记录不允许删除”: + +```text +返回至少一行 → 规则不通过 +返回零行 → 规则通过 +``` + +SQL 接收当前模块本次待删除的 ID 集合,统一使用 `:ids` 作为模板标记。它不是 JDBC 命名参数,执行前由删除引擎根据模块主键类型替换为经过校验和转义的字面量 `IN` 列表;第一阶段不引入表值参数或 `OPENJSON`。配置者可以自由使用本模块字段、其他表、聚合、子查询和连接条件。 + +示例语义: + +```sql +select b_id, b_no +from cw_receipt +where b_id in (:ids) + and b_status in ('审核中', '已生效') +``` + +约定: + +- SQL 必须是只读查询; +- SQL 必须限制在本次 `:ids` 对应的数据范围内; +- 单次删除 ID 数量设置明确上限,超限直接拒绝,避免生成过大的 SQL; +- 查询返回的行用于错误样例和日志,不直接执行 SQL 返回的修改意图; +- `b_message` 第一阶段使用固定文案,不做复杂占位符替换; +- 多条规则全部属于“禁止条件”,任意一条命中即拒绝整批删除; +- `b_xh` 只影响执行顺序和错误展示,不改变规则的并集语义。 + +### 4.3 为什么只保留 SQL + +- 不需要维护字段、操作符、常量、分组和 AST 转换; +- 跨表、聚合和历史业务条件可以直接表达; +- 后端只需要实现一个 SQL 执行契约; +- 前端只需要一个 SQL 编辑器和提示文案输入; +- 未来需要更强的条件能力时,仍可在 SQL 层扩展,不影响表结构。 + +配置 SQL 不等于允许 SQL 修改数据。删除动作仍由删除引擎统一完成,避免规则之间互相修改数据或破坏事务顺序。 + +### 4.4 只读校验和执行入口 + +删除规则不新建一套 SQL 安全判定器,直接复用现有 `DbUtils`: + +- `DbUtils.validateReadOnlySql(String)`:校验 SQL 以 `SELECT` / `WITH` 开头,并拒绝分号、注释和写入关键字; +- `DbUtils.loadDataBySql(Connection, String)`:复用 `saveobjt` 当前事务连接执行查询,并再次走同一只读校验; +- 删除引擎在替换 `:ids` 后调用上述入口,返回行即视为规则命中。 + +`SqlPermissionService` 负责动作权限、字段权限和数据范围条件,不作为删除规则 SQL 的只读校验器。若将来需要增强 SQL 解析能力,先扩展 `DbUtils` 的公共入口,不在删除引擎中复制一套正则或解析逻辑。 + +## 5. 删除引擎 + +删除引擎分为三个职责,但对前端暴露一个删除动作: + +```text +删除服务 +├── 删除计划器:仅对模块目标展开 cascade,形成模块和记录清单 +├── 删除检查器:仅对模块目标执行 s_delete_rule 和 restrict 检查 +└── 删除执行器:子记录优先,物理删除,提交审计日志 +``` + +直接表目标不经过前两个组件,经过同一个删除执行器。这样既不漏掉文件、配置表等非模块删除,也不会让直接删除意外触发某个表名猜出来的模块规则。 + +### 5.1 删除计划 + +从用户指定的模块和 ID 开始,沿启用的 `cascade` 关系递归展开: + +- 同一模块、同一主键只进入清单一次; +- 关系图出现环路时返回配置错误; +- 设置最大展开深度,防止错误配置造成无限递归; +- 记录每个模块将删除的数量,供日志和后续预览使用。 + +### 5.2 删除检查 + +对完整删除清单执行: + +1. 对每个模块运行启用的 `s_delete_rule`; +2. 对每条 `restrict` 入边检查外部引用; +3. 任何规则或关系失败,整批拒绝; +4. 错误按“模块 SQL 规则 / 外部引用关系”分类返回。 + +删除清单内的引用方视为本次一起删除,不触发外部引用保护。这是主单、明细和中间表一起删除时的必要语义。 + +直接表目标没有模块删除清单,因此不执行上述两类检查;它只返回数据库删除错误或专用服务的清理错误。 + +### 5.3 删除执行 + +第一阶段不做独立的“预览后确认”两阶段操作。前端可以先弹一次普通确认框,后端在同一个事务中完成计划、检查和删除: + +```text +开启事务 + → 生成删除清单 + → 执行 SQL 规则 + → 执行 restrict 检查 + → 子记录到主记录物理删除 + → 写审计日志 +提交事务 +``` + +任一步骤失败都回滚整批。以后需要删除预览时增加 `dryRun` 调用即可,不能改变正式删除流程。 + +直接表删除省略“生成删除清单 / SQL 规则 / restrict”三步,但仍在同一事务中执行数据库删除和审计。文件服务的外部对象操作不属于数据库事务:对象删除失败时不删数据库行;数据库删除失败时保留可重试或补偿的清理记录。 + +## 6. 调用路径 + +### 6.1 模块列表删除 + +列表删除通过统一删除服务处理: + +```text +前端传 target=module、moduleCode + ids +→ 删除计划器 +→ 删除检查器 +→ 删除执行器 +``` + +前端不再直接为删除拼装 `saveobjt` 的 `deletes` 行。 + +列表页解析写入目标时只读取 `b_save_table`。现有 `resolveSaveTable()` 中的 `b_save_table || b_view_table` 回退必须删除;`b_save_table` 为空时,所有保存、排序和删除写操作都提示“该模块未配置保存表,无法写入”,后端也必须再次拒绝,不能尝试对视图表执行写入。 + +### 6.2 `saveobjt` 中删除明细 + +`saveobjt` 的 `deletes` 分支仍保留原入参格式,但增加一个明确的删除目标类型: + +- `target=module`:必须提供 `moduleCode`;后端读取 `s_module.b_save_table` 和单一 `b_key_field`,调用同一删除计划器;`b_save_table` 为空时直接拒绝并返回“模块未配置保存表,无法删除”,不得使用 `b_view_table`;请求携带的 `table` / `key_field` 只能用于兼容和一致性校验; +- `target=direct`:必须提供 `table` / `key_field`,直接进入物理删除执行器,不做表名到模块的反查; +- 不允许以“有没有传 `moduleCode`”作为隐式分支。新接口必须明确目标类型,避免调用方漏传模块编码时静默绕过模块规则。 + +两种目标都: + +- 使用 `saveobjt` 当前事务连接; +- 收集本次请求中所有待删除记录;模块目标再展开 `cascade`,直接表目标不展开关系; +- 模块目标的同批次内引用不算外部引用; +- 显式删除和 `cascade` 展开的同一记录按解析后的“表 + 主键字段 + 主键值”去重;同一物理记录同时以模块目标和直接目标提交时直接报冲突,不执行两次; +- 任一检查失败,主表更新、明细删除和其他变更整体回滚。 + +这样列表删除和表单删行不会形成两套规则。 + +### 6.3 文件和其他无模块删除 + +文件删除继续由 `/file/delete` 负责:按文件主键定位记录,先删除本地文件或 OSS 对象,再以 `target=direct`、`table=bf_files`、`key_field=subid` 删除数据库行。即使 `bf_files.mx_moduleid` 保存了业务模块编码,它也只是文件元数据,不是本次删除策略的模块目标;文件删除不需要 `moduleCode`,也不触发 `s_relation` 或 `s_delete_rule`。 + +如果某个业务明确要求“宿主单据处于某状态时不能删附件”,由文件服务显式调用宿主模块的 SQL 检查;直接表目标本身不自动反查 `father + mx_moduleid`。 + +其他没有业务模块的表沿用同样的直接表目标。若一张技术表存在自己的依赖清理,清理由该表所属服务显式完成;删除策略引擎不根据表名猜测级联关系。 + +## 7. 前端配置入口 + +模块管理的业务能力建议调整为: + +```text +数据行为 +├── 删除策略 +│ ├── 关联删除处理 +│ └── 删除 SQL 规则 +└── 自动编码 +``` + +“模块关联”可以继续作为关系字段映射的维护面板,但删除行为只能有一个编辑来源。若删除策略页面展示关联行,则模块关联页面中的删除行为列改为只读或跳转。 + +删除 SQL 规则面板只需要: + +- 新增、编辑、删除、启用/停用; +- SQL 文本编辑器; +- 固定提示文案; +- 执行顺序; +- 最近修改信息。 + +删除以下旧概念和前端联动: + +```text +勾条件 +引用规则 +refRules +relationScopeId +b_scope_type +b_scope_id +b_hook +``` + +同时必须删除现有实现中的以下联动,否则后端允许配置 `cascade` 也不会生效: + +```text +ModuleRelationPanel.vue:b_on_delete 编辑器的 isManyToOne disabled +ModuleRelationPanel.vue:onCellChange 中多对一重置 none 的分支和 Message.warning +ModuleRelationPanel.vue:_hasRefRule、setRefRule 及关系键变更时清理引用规则的逻辑 +FmsModuleListPage.vue:resolveSaveTable() 对 b_view_table 的删除回退 +``` + +`b_on_delete` 列可以继续显示在模块关联维护面板,但只能作为删除策略的同一份数据来源;不能同时保留一套只读/勾选/自动重置逻辑。 + +## 8. 错误、日志和权限 + +- SQL 规则命中时返回规则文案、所属模块、命中数量和少量样例 ID; +- `restrict` 命中时返回引用模块、引用字段和样例引用记录; +- 批量删除只返回聚合后的错误,避免逐行刷屏; +- 规则的新增、修改、删除沿用模块配置权限; +- SQL 文本、参数、命中行数和请求标识写入技术日志; +- 直接表删除记录表名、主键字段、数量和请求来源;按明确的目标类型标记为 `direct`,便于审计和排查; +- 规则停用使用 `b_canuse = 0`,删除规则本身才做物理删除并记录审计; +- 没有登记到 `s_relation` 的引用不在自动保护范围内,这属于元数据配置边界。 + +## 9. AI 实施顺序 + +### 第一阶段:元数据和脚本 + +1. `s_relation` 确认 `b_on_delete` 三值和默认 `restrict`; +2. 删除旧 `s_rule` 结构,建立 `s_delete_rule`; +3. 同步核心建表脚本和增量脚本; +4. 不加入任何归档字段。 + +### 第二阶段:后端 + +1. 实现删除计划器; +2. 实现 SQL 规则检查器,复用 `DbUtils.validateReadOnlySql` 和 `DbUtils.loadDataBySql(Connection, String)`,不新建第二套只读判定器; +3. 实现 `restrict` 外部引用检查; +4. 实现子记录优先的物理删除执行器; +5. 定义 `target=module/direct` 两种删除目标,接入列表删除入口和 `saveobjt` 删除分支; +6. 加入事务回滚、环路检测、日志和错误聚合。 + +### 第三阶段:前端 + +1. 删除条件 AST、引用规则和旧作用域逻辑; +2. 将规则编辑器改为 SQL 编辑器; +3. 删除规则面板与模块关联面板收敛为“删除策略”入口; +4. 删除 `ModuleRelationPanel.vue` 的多对一 `b_on_delete` 禁用、重置、警告和 `_hasRefRule` 联动; +5. 删除 `FmsModuleListPage.vue` 的 `b_view_table` 删除回退,空 `b_save_table` 时拒绝删除; +6. 列表删除改为调用统一删除接口; +7. 更新相关测试。 + +### 第四阶段:验收 + +- 单条删除成功; +- 批量删除成功; +- SQL 命中时整批拒绝; +- `restrict` 外部引用生效; +- `cascade` 子记录全部删除; +- 多层级联按子到父顺序删除; +- 关系环路被拦截; +- `saveobjt` 中模块目标与列表删除使用相同规则,直接目标不触发模块检查; +- 文件和其他无模块表可以走直接删除路径; +- 任一失败都能完整回滚。 + +## 10. 后续扩展边界 + +后续增加归档时,只增加新的关联处置值和对应执行器;不改变 SQL 规则的“返回行即拦截”语义。 + +后续增加“审核后不能修改”“保存前校验”“自动冲销”等能力时,重新评估是否需要新的策略表或服务,不在本次设计中预留 `b_hook` 或通用规则字段。 diff --git a/code/fms/FMS删除规则引擎设计.md b/code/fms/FMS删除规则引擎设计.md deleted file mode 100644 index c2527f66..00000000 --- a/code/fms/FMS删除规则引擎设计.md +++ /dev/null @@ -1,335 +0,0 @@ -# FMS 删除规则设计 - -> 版本:v2.0(取代 v1)。关联:`FMS新系统核心表结构设计.md`(§9 模块关系)、`FMS模块设计评审.md`。 -> 一句话:**删之前先查一查,查不过就拒绝;查什么、怎么查,由 IT 配置,不写代码。** -> -> v2 相对 v1 的变化(动因见 `FMS模块设计评审.md` 问题一/问题三): -> - **引用规则退出 `s_rule`**:原「勾引用」由 `s_relation.b_on_delete = 'restrict'` 表达,关系面板只配一列; -> - **`s_rule` 收敛为模块级**:去掉 `b_scope_type`、`b_scope_id`(拼接串)、`b_hook`,加 `b_kind` 显式区分勾条件与写 SQL; -> - **方向语义统一**:四个取值都描述「删除**被引用方**时,**引用方**怎么办」,被引用方由基数判定,不再有删源/删目标两套口径; -> - **默认值 `restrict`**:被引用即不可删成为默认,多数关系零配置。 -> - 库中无存量数据,全部按新结构直接建,不做迁移。 - -## 这个设计解决什么问题 - -业务上「能不能删」的规矩五花八门: - -- 单据还在审核中,不许删 -- 已经销账了,不许删 -- 核销过钱的,不许删 -- 费用已经做到账单里了,不许删 -- 删报销单时,下面的费用明细要跟着一起删 - -以前这些规矩要么写死在代码里(改一条要发版),要么干脆没有(误删了才发现)。现在的做法:**IT 在界面上配置,配完立即生效**。 - -规矩分两类,配在两个地方: - -- **记录自身什么状态不许删** → 模块上配「删除规则」(勾条件 / 写 SQL); -- **被别人引用着不许删、删主单连带删明细** → 关系上选一个「删除行为」(一列四值,默认即防护)。 - -## 1. 整体图景 - -一个用户点「删除」,系统背后做四件事: - -``` -1. 找出连带要删的记录(删报销单 → 连费用明细一起) -2. 把配置的所有删除规则挨条查一遍 -3. 任何一条不过 → 整批拒绝,告诉用户为什么 -4. 全过 → 先弹预览给用户确认,再一个事务里一起删 -``` - -IT 要做的事只有两件:模块上**配条件规则**,关系上**选删除行为**。配的地方都在模块管理。 - -## 2. 模块规则:一条就是一句「什么情况不许删」 - -每条规则就是一句话: - -> **当【什么条件】时,不许删,提示【什么话】** - -规则有两种写法,按配的难度从易到难: - -### 2.1 勾条件(日常用这个,占绝大多数) - -在模块管理 → 「删除规则」tab 点「新增规则」,弹出来的界面和**查询配置长得一样**: - -``` -满足以下条件时,不许删除: -┌──────────┬────────┬─────────────────┐ -│ 字段 │ 操作符 │ 值 │ -│ 单据状态 │ 属于 │ [审核中, 已生效] │ -│ 已销账 │ 等于 │ 1 │ -└──────────┴────────┴─────────────────┘ -``` - -- 字段下拉就是该模块字段定义里的字段 -- 值可以直接填,也可以选另一个字段比(比如「已核销金额 大于 0」) -- 提示文案**不用写**——系统自动生成「单据 SKD-2026-001 状态为审核中,不能删除」 - -会配查询条件的人,零学习成本。 - -### 2.2 写 SQL(兜底,藏在角落) - -界面勾不出来的判断——比如「核销金额加起来大于 0」这种要查别的表的——在规则类型的「高级」选项里直接写一句 SQL: - -```sql -select b.b_id, b.b_no from bill b -join bill_detail d on d.bill_id = b.b_id -where b.b_id in (:ids) and d.writeoff > 0 -``` - -约定就两条:`:ids` 会自动换成当前删的这批单据的 id;**查出来有结果就拦**。提示文案自己填,可以 `{b_no}` 这样引用查询结果的列。 - -平时用不到,会写的人自己找得到,不会打扰别人。谁改过这条 SQL、每次执行了什么,系统日志里全程有记录(内部系统,不做限制,只做留痕)。 - -(v1 的「勾引用」一节取消——引用保护挪到关系上,见 §3。) - -## 3. 关系上的删除行为:`s_relation.b_on_delete`(一列四值) - -统一语义:**删除「被引用方」记录时,「引用方」记录怎么办。** - -谁是"被引用方"由基数决定,不需要额外配置: - -| 基数 | 外键在 | 被引用方 | -| --- | --- | --- | -| `many_to_one` | 源字段 | 目标 | -| `one_to_many` | 目标字段 | 源 | -| `one_to_one` | 目标字段(约定) | 源 | - -四个取值: - -| 值 | 含义 | 典型场景 | -| --- | --- | --- | -| `restrict`(**默认**) | 仍有引用方记录时,拒绝删除被引用方 | 费用→客户:客户被费用引用,删不掉 | -| `cascade` | 引用方记录一起物理删除 | 报销单→费用明细:删单连带删明细 | -| `archive` | 引用方记录打归档标记,不物理删 | 删报销单,明细归档供账单追溯 | -| `none` | 不处理 | 日志→用户等确实无关的关联 | - -几个例子(源→目标按基数标注被引用方): - -| 关系 | 基数 | 被引用方 | b_on_delete | 效果 | -| --- | --- | --- | --- | --- | -| 费用 → 客户 | many_to_one | 客户 | restrict(默认) | 客户被费用引用时删不掉 | -| 费用类型 → 费用 | one_to_many | 费用类型 | restrict(默认) | 类型下有费用时删不掉 | -| 报销单 → 费用明细 | one_to_many | 报销单 | cascade | 删报销单连带删明细 | -| 报销单 → 费用明细(归档版) | one_to_many | 报销单 | archive | 删报销单,明细归档可追溯 | - -设计要点: - -- **默认即防护**:新关系默认 `restrict`,「主数据被引用不许删」这类最通用的规矩零配置;确实要放开才显式改 `none`。安全方向从「记得配防护」反转为「记得放开」 -- **一列取代原来的三个栏位**:v1 的 `b_on_delete`(三值)+「有引用时拒绝删除」勾选(假列,实际生成 `s_rule` 行)+ 方向口径混用,合并为**一个真列** -- **取值即语义,无非法组合**:v1 可同时配出「cascade + 引用规则」(连带删但又不许删,自相矛盾);单列四值后该组合在类型层面不存在 -- **基数不禁取值**:是否 `cascade` 是「被引用方是否独占拥有引用方」的业务判断,只有配置者知道;把 `cascade` 配在共享主数据的关系上属于配置错误,系统不做基数层面的硬拦(内部系统,配置留痕即可),界面在选 `cascade` / `archive` 时给出确认提示 -- **`archive` 前提**:引用方表需有 `b_archived` + `b_archive_datetime` 通用列,保存时服务层校验列存在 - -连带展开的关键行为: - -- **查连带时也过规则**:删报销单连带要删费用明细,但费用明细被账单明细引用(restrict 入边)→ 整个删除(包括报销单)被拒绝,提示定位到那笔明细和那个账单 -- **一荣俱荣一损俱损**:任何一条卡住,整批都删不了,不存在删一半的中间状态 -- 关系套关系(单→明细→附件)会自动一层层找下去;层数太多(比如配置成了环)会直接报配置错误,不会死循环 - -## 4. 删除的操作流程(业务用户视角) - -列表页勾几行点删除,系统**先预览后真删**: - -``` -第一步:系统先算一遍 - ↓ 有问题? → 弹错误列表:「这 3 条审核中删不了」「这笔费用被账单引用」 - ↓ 没问题? → 弹确认框:「将删除 1 张报销单 + 3 条费用明细,确认?」 - -第二步:用户确认后才真删(删之前系统再快速查一遍,防止确认的几秒里有变化) -``` - -错误提示都是人话,不用猜。批量删 500 行时提示按规则归类显示,不会刷屏。 - -## 5. 配置入口在哪 - -**配规则**(IT 日常): - -- 模块管理 → 「删除规则」tab → 新增:模块级条件规则,每条 3 个格子一分钟;改完立即生效,**不用发版** -- 模块管理 → 「模块关联」tab → 每条关系一列「删除行为」,默认"被引用时禁止删除";主子表关系显式选「连带删除」或「连带归档」 -- 常见条件规则可以**复制到其他模块**(多选目标模块批量克隆)——v2 起全部规则都是模块级、结构同构,无例外;复制时校验目标模块存在条件里用到的字段,缺字段的目标模块在确认框里点名 - -**看全貌**(排查、盘点): - -- 系统管理 → 「业务规则」:全系统所有模块级规则的列表——哪个模块的、什么类型、谁最后改的、什么时候。点行跳到对应模块 -- 排查「这单为什么删不掉」:错误提示里带规则内容 → 全局列表找到它 → 看条件/SQL → 完事;被引用拦下的,提示里直接带引用方单号和关系来源 - -**谁可以配**:规则的增删改走权限控制(和菜单权限同体系)。看规则不受限。 - -## 6. 数据落库(给开发看的部分) - -### 6.1 s_relation:删除行为列 - -权威表结构见 `FMS新系统核心表结构设计.md` §9,本节只列差异: - -```sql -alter table dbo.s_relation add - b_on_delete varchar(20) not null default 'restrict'; - -- restrict(默认) 被引用时禁止删除 / cascade 连带删除 / archive 连带归档 / none 不处理 -``` - -- 语义、取值、被引用方判定见本文 §3 -- `archive` 由服务层校验引用方表存在 `b_archived` / `b_archive_datetime` 列 -- 引擎读取时只认 `b_canuse = 1` 的关系行 -- 库中无存量数据,不需要从 v1 的 `none` 默认值做迁移;`fms_core.sql`、`fms_delete_rule.sql` 脚本需按本结构同步(现脚本默认值仍是 `none`) - -### 6.2 s_rule:模块级规则表(v2 结构) - -```sql -create table dbo.s_rule ( - b_id bigint not null, -- 雪花主键(新增时前端经 /data/nextid 取号) - b_module_id varchar(50) not null, -- 所属模块编码 - b_kind varchar(20) not null, -- condition 勾条件 / sql 写 SQL - b_predicate nvarchar(max) not null, -- condition=JSON AST(§6.3);sql=SQL 文本 - b_message nvarchar(500) null, -- condition 可留空(引擎按条件自动生成);sql 必填 - b_canuse tinyint not null default 1, - b_xh int not null default 0,-- 执行顺序(并列按 b_xh、b_id) - b_created_by varchar(50) null, -- 审计列由服务端维护,配置界面不提交 - b_created_at datetime2 null, - b_updated_by varchar(50) null, - b_updated_at datetime2 null, - primary key (b_id) -); - -create index ix_s_rule_module on dbo.s_rule (b_module_id, b_canuse, b_xh); -``` - -相对 v1 的变化与理由: - -- **去掉 `b_scope_type` / `b_scope_id`**:规则只挂模块,`b_module_id` 直存模块编码。v1 的拼接串键(`源模块|源字段|目标模块|目标字段`)随之消失——它无法有效索引、无法校验、编码含 `|` 时错位,是明确的反模式 -- **去掉 `b_hook`**:一期只有 `delete.pre` 一种挂点,单值枚举列属过早抽象(`开发规范.md`「为真实出现的第二个使用方而抽象」);将来做 save.pre 时再按当时的真实需求定表形态 -- **加 `b_kind`**:v1 靠 `b_predicate` 三态推断(空=引用 / `{` 开头=JSON / 其余=SQL),判别依据靠猜且依赖「SQL 不以 `{` 开头」这个未声明约定;显式一列后判别依据存在数据里 -- `b_xh` 保留:多条规则任一不过即拒绝,顺序不影响结论,但决定错误提示的展示顺序,也支持「便宜的勾条件规则排前、贵的 SQL 规则排后」的执行优化 - -其他要点: - -- **模块上没有任何规则 = 该模块不受条件约束**(引用保护仍由关系默认值兜着) -- 错误类型不用存——勾条件报「状态不符」、写 SQL 报自定义文案,写法本身决定了报什么 -- **删模块时,服务层把挂着的规则一起删掉**(`where b_module_id = …`,单条件直查,与字段、权限点同一套清理逻辑,全系统本来就不建物理外键) -- 删规则 = 物理删 + 日志记全文;临时不想用某条规则 → 停用(`b_canuse=0`),不删 - -### 6.3 勾条件那部分的格式(JSON,界面生成,人不手写) - -```json -{ "kind": "dsl", "expr": - { "and": [ - { "in": [ { "field": "b_status" }, { "const": ["draft", "rejected"] } ] }, - { "eq": [ { "field": "b_settled" }, { "const": 0 } ] } - ] } -} -``` - -开发注意三条: - -- 只能引用本模块的字段(想跨表?去写 SQL 规则),后端翻译成**参数化 SQL**,没有注入风险 -- 常量按字段类型校验(数字/日期/文本),和查询配置的校验规则共用一套 -- 防手滑:条件嵌套最多 8 层、最多 100 个节点(界面上分组最多 3 层,比后端还紧) - -### 6.4 后端删除流程 - -**引擎拆成两个部件:检查器 和 执行器**。执行器管连带和真删;检查器只查规则(一个方法:`check(连接, 模块, ids, 小闭包)`)。两个入口共用同一个检查器: - -``` -删除(模块, ids, 是否预览): - - ① 执行器找连带:从这批 ids 出发,沿 b_on_delete ∈ {cascade, archive} - 且 b_canuse=1 的关系,把引用方记录一层层装进删除清单 - (被删的是被引用方;已装过的跳过——防关系成环;超过10层报配置错误) - - ② 检查器查规则: - 模块条件:清单内每个模块的 s_rule(b_canuse=1) 逐条执行 - 勾条件 → 按条件查清单内记录,不符的记一条错误(带实际值) - 写SQL → 拿清单内该模块的 id 跑那条 SQL,查出结果每行记一条错误(全程写日志) - 引用保护:对清单内每条记录,查它作为「被引用方」的 restrict 入边 - (b_on_delete='restrict' 且 b_canuse=1 的关系) - 引用方存在、且引用方不在清单内 → 记错误(带引用方单号) - - ③ 有错误 → 整批拒绝,返回错误列表(按规则归类,每类带数量和前几条示例) - ④ 全过 → 只是预览?返回"将删除 X 张单 + Y 条明细"摘要 - 真删?→ 开事务:再快速过一遍规则(防确认间隙有变化) - → 归档的打标记 → 按先子后父的顺序物理删 - → 记审计日志(按删的这一批记一条 + 明细) -``` - -开发注意: - -- 查库全部按批 IN,不逐行循环 -- ids 数量不设上限——引擎内部把整批 id 装成一个数组参数(`OPENJSON`),不受绑定变量 2100 个的限制 -- 真删前在事务里重查一遍规则(预览的通过**不算数**),防确认的几秒里有人改了规则或新增了引用 -- 写 SQL 规则的每次执行:实际 SQL + 参数 + 结果行数都写进技术日志,关联本次请求——排查「为什么删不掉」从错误提示一路能追到原始 SQL - -### 6.5 saveobjt 保存时删行,规则同样生效(重要) - -**问题**:海运编辑这类主子表表单,保存走通用接口 `saveobjt`——前端把主表 + 子表(费用)的增删改 diff 成一个请求数组,一个事务提交。用户在表单里删掉一行费用,就是请求数组里子表的 `deletes` 段。**这条删除路径如果不过规则,配置就白配了**——列表页删除被拦,表单里删行却畅通无阻。 - -**接法**(对前端完全透明,入参不变): - -``` -DataSaveService.saveTable() 的 delete 分支,执行真删之前: - - ① 按表名反查模块(b_savetable = 这张表的 data 模块);查不到模块(如日志表)→跳过 - ② 该模块挂有规则、或它的记录存在 restrict 入边关系 → 调检查器, - 传当前 connection(同一个事务!) + 待删的这批行 - ③ 任一规则不过 → 抛 BusinessException(文案就是规则生成的提示) - → 整个 saveobjt 回滚——主表的修改和子表的删除一起回滚, - 用户的前端草稿还在,知道哪行费用删不掉、为什么 - ④ 无规则或全过 → 照常执行 deletes -``` - -三个关键点: - -- **同事务检查**:检查器用的就是 saveobjt 自己的 connection——表单里先改了主表状态、再删子表行,规则查到的是**改过之后**的值,不会出现「检查用旧数据、删的是新数据」的错位 -- **小闭包语义**:检查 restrict 时,要把「本次 saveobjt 所有 deletes 覆盖的记录」当作一个小闭包——引用方也在被删之列就不算外部引用。场景:删主单连带删子表行(前端编排的),子表对主单的引用指向一个同批正在删除的记录,不该拦。实现:把请求数组里所有 deletes 的 id 收集起来传给检查器 -- **不需要预览**:表单保存是「保存」动作,用户预期就是要么全成功要么报错,不做两阶段确认 - -### 6.6 接口 - -``` -POST /data/deleteobj -{ - "moduleCode": "receipt", - "ids": [1, 2, 3], - "dryRun": true // true=只预览(前端删除按钮的第一步) -} - -POST /data/saveobjt // 不变,deletes 分支内部自动走规则检查 -``` - -## 7. 其他约定 - -- **字段联动**:条件规则里用了的字段,删除该字段时会被拦住并提示规则内容——不让规则悄悄失效 -- **配置权限**:改规则要权限点(如 `rule.edit`),和菜单权限同一套体系;SQL 不限制内容,但改它的人必须有配置权限 -- **归档标记**:`archive` 要求引用方表有通用列 `b_archived` + `b_archive_datetime`(配归档时服务层校验列存在);通用查询默认不显示已归档行 -- **将来扩展**(二期再议,**不留列**,届时按真实需求定表形态): - - 「已审核的单不能**改**」——形态未必与删除规则同构,到时候再决定复用还是另起 - - 「删之前先自动红冲」这种要执行动作的——预留挂 Java 代码的口子 - - 核销总额想在列表上显示成列——展示需求,单独做,跟删除无关 -- **已知边界**:restrict 只覆盖 `s_relation` 里**登记过的**引用;没登记关系的表(日志、迁移脚本写的表)不在保护范围内。这是配置边界,不是数据库级引用完整性,文档与培训材料里要写明 - -## 8. 开工顺序 - -1. 建表:`s_relation` 加 `b_on_delete`(四值、默认 `restrict`);`s_rule` 按 §6.2 新结构建;同步重写 `fms_core.sql`、`fms_delete_rule.sql`(现脚本仍是 v1 结构) -2. 后端:条件(JSON → 参数化 SQL)+ 检查器 + 执行器(连带展开 → 规则 → 事务删除) -3. **saveobjt 接入检查器**:delete 分支反查模块 → 同事务规则检查 → 不过整体回滚(见 §6.5) -4. 错误提示:条件自动生成人话 + 字段格式化(金额带 ¥、日期带格式)+ SQL 占位符填充 -5. 前端:关联面板一列化(去掉「有引用时拒绝删除」假列及其全部联动补丁)、规则面板收敛(去掉引用规则只读区与跳转)、列表删除先预览后确认、错误列表展示 -6. 二期:save.pre、Java 钩子、聚合展示 - -## 9. 定案记录 - -| # | 事项 | 结论 | 版本 | -| --- | --- | --- | --- | -| 1 | 节奏 | 一期只做删除规则;保存校验二期再议,不留 `b_hook` 列 | v2 修订 | -| 2 | 跨表判断 | 不做计算字段,直接写 SQL 兜底 | v1 | -| 3 | SQL | 兜底逃生舱:不限制内容、不做体验优化、入口藏角落;**每次执行全程写日志** | v1 | -| 4 | 引用保护 | **`s_relation.b_on_delete='restrict'`(默认)表达,不进 `s_rule`**;删关系时规则随之消失,无孤儿清理 | v2 修订 | -| 5 | 归档 | 通用列 `b_archived` + `b_archive_datetime` | v1 | -| 6 | 条件规模 | 嵌套 ≤ 8 层、节点 ≤ 100;界面分组 ≤ 3 层 | v1 | -| 7 | 规则类型 | **显式 `b_kind` 列(condition / sql)**,不靠 `b_predicate` 首字符推断 | v2 修订 | -| 8 | 级联清理 | 删模块时服务层连带删 `s_rule`(`b_module_id` 单条件直查);关系级规则不复存在 | v2 修订 | -| 9 | 批量 | ids 不设上限;整批一个数组参数(OPENJSON) | v1 | -| 10 | 复制 | **全部规则可多选模块批量克隆**(v2 起无引用规则例外);复制时校验目标模块存在条件所用字段 | v2 修订 | -| 11 | saveobjt 集成 | 引擎拆检查器/执行器;saveobjt 的 deletes 分支同事务调检查器,不过则整批回滚(§6.5);前端入参不变 | v1 | -| 12 | 方向语义 | 四值统一描述「删除被引用方时引用方怎么办」;被引用方由基数判定(§3) | v2 新增 | -| 13 | 默认值 | `restrict`:被引用即不可删为默认,放开需显式改 `none` | v2 新增 | -| 14 | 存量迁移 | 库中无数据,直接按新结构建表,不做转换 | v2 新增 | diff --git a/code/fms/FMS删除规则改造待做.md b/code/fms/FMS删除规则改造待做.md deleted file mode 100644 index 2474a5a6..00000000 --- a/code/fms/FMS删除规则改造待做.md +++ /dev/null @@ -1,73 +0,0 @@ -# FMS 删除规则改造待做 - -> 创建:设计阶段已闭合、实施未开始。明日开工照此清单执行。 -> 设计依据:`FMS删除规则引擎设计.md` **v2.0**(权威实现规格)、`FMS模块设计评审.md` v1.2(决策记录)。 -> 前提确认:库中无存量规则/关系数据(`sql/` 种子脚本中 `s_relation` 零 insert),**全部按新结构直接建,不做迁移**。 - -## 已定案的设计决策(不需要再讨论) - -| # | 决策 | 出处 | -| --- | --- | --- | -| 1 | `s_relation.b_on_delete` 单列四值:`restrict`(默认)/ `cascade` / `archive` / `none`;语义统一为「删除**被引用方**时,**引用方**怎么办」;被引用方由基数判定(many_to_one=目标 / one_to_many=源 / one_to_one=源) | 删除规则 v2.0 §3 | -| 2 | 引用保护退出 `s_rule`,由 `b_on_delete='restrict'`(默认)表达;「有引用时拒绝删除」假列及全部联动补丁删除 | 删除规则 v2.0 §3 | -| 3 | `s_rule` 新结构:`b_module_id` 直存、显式 `b_kind`(condition/sql)、去掉 `b_scope_type`/`b_scope_id`/`b_hook` | 删除规则 v2.0 §6.2 | -| 4 | 中间表模块挂业务域 `module` 节点下、与主表模块平级(规范已写入核心表结构设计 §4.3) | 评审 v1.2 问题四 | -| 5 | 模块管理分节改为「数据行为 / 访问控制」,只改 `configGroups` 分组,面板/路由/数据不动;与删除规则前端改造同批实施 | 评审 v1.2 问题二 | - -## 待做清单(按执行顺序) - -### ① 重写 SQL 脚本 —— 对应设计 v2.0 §6.1 / §6.2 - -现状:两个脚本还是 v1 结构(`b_on_delete` 默认 `none` 三值、`s_rule` 带 `b_scope_type`/`b_scope_id`/`b_hook`)。 - -- [ ] `sql/fms_delete_rule.sql` 重写: - - `s_relation.b_on_delete`:四值,**默认 `'restrict'`**(现脚本 L18 默认是 `'none'`) - - `s_rule` 按新结构重建:`b_module_id varchar(50) not null`、`b_kind varchar(20) not null`(condition / sql)、`b_predicate not null`、`b_message`、`b_canuse`、`b_xh`、审计四列;去掉 `b_scope_type`/`b_scope_id`/`b_hook` - - 索引:`ix_s_rule_module on s_rule (b_module_id, b_canuse, b_xh)` - - 保留脚本头「可重复执行」说明与执行方式注释 -- [ ] `sql/fms_core.sql` 同步:L208 的 `b_on_delete` 注释与默认值、L228-256 的 `s_rule` 段整体替换(与 `fms_delete_rule.sql` 保持一致,注意两脚本职责分工——core 建全表、delete_rule 只做本特性增量,按现有模式来) -- [ ] 建表 SQL 的 `b_archived` / `b_archive_datetime` 通用列暂不加——它是 `archive` 行为的**运行时前提**(引用方表需有此列),到后端实现时再决定加在哪些业务表 - -### ② 后端引擎 —— 对应设计 v2.0 §6.4 / §6.5 / §6.6 - -- [ ] 新建 `DeleteRuleChecker`(检查器):`check(connection, 模块, ids, 小闭包)` - - 模块条件:清单内每个模块的 `s_rule`(`b_canuse=1`)逐条执行;condition → JSON AST 翻译参数化 SQL(AST 格式见 §6.3,嵌套 ≤8 层、节点 ≤100);sql → `:ids` 替换(OPENJSON 数组参数,绕开 2100 绑定变量上限),查出有结果即记错误,**全程写技术日志**(实际 SQL+参数+行数) - - 引用保护:清单内每条记录查它作为被引用方的 `restrict` 入边(`b_on_delete='restrict'` 且 `b_canuse=1`),引用方存在且不在清单内 → 记错误(带引用方单号) -- [ ] 新建 `DeleteRuleExecutor`(执行器):从 ids 出发沿 `b_on_delete ∈ {cascade, archive}` 的关系逐层展开删除清单(被删的是被引用方;已装过跳过防环;超 10 层报配置错误) -- [ ] `DataController` 加 `POST /data/deleteobj`:`{ moduleCode, ids, dryRun }`;dryRun 返回「将删除 X + Y」摘要;真删开事务**重跑检查**(预览通过不算数)→ archive 打标记 → 先子后父物理删 → 审计日志 -- [ ] `DataSaveService.saveTable()` delete 分支接入(§6.5,**重要**): - - 真删前按表名反查模块(`b_savetable`),查不到跳过(日志表等) - - 同事务调检查器;**小闭包语义**:本次 saveobjt 所有 deletes 覆盖的 id 传给检查器,引用方也在被删之列不算外部引用 - - 不过 → 抛 BusinessException → 整个 saveobjt 回滚 -- [ ] 错误消息生成:condition 自动生成人话(带实际值 + 字段格式化:金额 ¥、日期格式);sql 规则用 `b_message` 的 `{列名}` 占位符填充;错误按规则归类、每类带数量和前几条示例 - -### ③ 前端改造 —— 对应设计 v2.0 §8 步骤 5 + 评审问题二 - -- [ ] `ModuleRelationPanel.vue`:删除相关从三栏位(`b_relation_type` / `b_on_delete` / `_hasRefRule`)收敛为 `b_relation_type` + `b_on_delete`(四值下拉,默认 restrict) - - 删掉:`_hasRefRule` 假列、`setRefRule`、改关系主键时作废旧键规则及提示(L326-334)、填不全四列禁用 checkbox - - `many_to_one` + `cascade` 不再界面禁用,改保存校验拦截(对齐「校验落后端」原则——若后端引擎做此校验,前端可只做提示) -- [ ] `ModuleRulePanel.vue`:去掉引用规则只读区 + `navigate` 跳转;列表只剩模块级规则(condition / sql) -- [ ] `ModuleRuleEditModal.vue` + `ruleUtils.js`: - - `parseRulePredicate` 三态推断改两态(读 `b_kind`,不再看首字符 `{`) - - 删掉 `relationScopeId` / `parseRelationScopeId` / `isCompleteRelationKey` / `REF_RULE_HINT` / `createRefRuleRow` / `RULE_SCOPE_RELATION` - - `createRuleRow` 带 `b_kind`;新增默认 `condition` -- [ ] `module-management/index.vue`: - - 删 `refRules` / `refRules_org` / `refRulesReverse` 三组数据及加载、diff、清理逻辑(L875-898 关系级规则清理段改为只清模块级) - - `configGroups` 分节调整:「数据行为 → 模块关联(含删除行为) / 删除规则 / 自动编码」「访问控制 → 权限」;section key 不动 -- [ ] `FmsModuleListPage.vue` `deleteSelected`(L877):直删改两步——先 `deleteobj dryRun=true`,有错误弹错误列表,无错误弹确认框(含连带摘要),确认后真删 - -### ④ 验收检查 - -- [ ] 全局搜索无旧导出残留:`relationScopeId`、`createRefRuleRow`、`REF_RULE_HINT`、`b_scope_type`、`b_scope_id`、`_hasRefRule`、`setRefRule` -- [ ] `pnpm check`(lint + fmt + build)通过 -- [ ] 跑受影响模块的前端测试;`ruleUtils` / 面板相关 spec 按新契约更新(测试约定见 `fms-vue/tests/README.md`:文件同名、中文用例名) -- [ ] 后端:`mvnw test`;引擎核心逻辑(检查器翻译、闭包语义、环检测)补测试 -- [ ] 手工链路验证:配一条「审核中不可删」→ 列表删被拦(人话提示)→ 表单里删行同样被拦(saveobjt 回滚)→ cascade 关系连带展开预览 → restrict 默认生效 - -## 注意事项(动工前再读一遍) - -1. **`sql/fms_delete_rule.sql` 现有 L22 是 `drop table if exists s_rule`**——虽然确认无数据,重写后仍保留 drop+create 模式,但执行前先 `select count(*)` 确认一次 -2. 规则引擎是**当前最大缺口**:现在界面上配的删除规则完全不生效(后端 `s_rule` 相关零实现)。②完成后配置才有意义,①③可以和②并行 -3. 执行 SQL 用 `RunSqlFile`:`java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/RunSqlFile.java ../sql/fms_delete_rule.sql --dry-run` 先 dry-run -4. 改前端遵守 `开发规范.md`:改前先读、最小修改、不为兼容旧名留转发别名(调用方可控,直接迁移删旧代码) -5. `s_rule` 主键仍是雪花 `b_id`(前端 `nextIdApi` 取号,与业务表一致);**配置表不用 nextIdApi 的规则是「模块配置表用业务键」**——`s_rule` 无天然业务键,属例外(v2.0 §6.2 已注明) diff --git a/code/fms/FMS模块关系与权限边界重构计划.md b/code/fms/FMS模块关系与权限边界重构计划.md new file mode 100644 index 00000000..d416e8b0 --- /dev/null +++ b/code/fms/FMS模块关系与权限边界重构计划.md @@ -0,0 +1,547 @@ +# FMS 模块关系与权限边界重构计划 + +> 状态:草案,供评审 +> +> 目标:明确模块树、菜单绑定、数据关系、权限和删除策略的边界,降低“模块关联”配置的理解成本,避免一个关系表同时承担多种语义。 + +## 一、结论先行 + +“模块关联”不应该同时承担菜单授权、模块父子关系、业务数据关系和删除规则。 + +本次计划采用以下分工: + +| 问题 | 权威数据 | 说明 | +| --- | --- | --- | +| 模块业务组织层级 | `s_module.b_parent_id` | 表达业务域、模块分组和权限上下文边界 | +| 菜单导航层级 | `s_menu.b_parent_id` | 表达目录、页面和外链的导航树 | +| 菜单使用哪些模块 | `s_menu_module` | 菜单与模块的多对多绑定,保存明确的模块清单 | +| 用户拥有哪些权限 | `s_power`、`s_user_power` | 模块访问、业务动作和菜单入口权限 | +| 数据字段之间的引用关系 | `s_relation` | 只表达模块字段之间的结构关系 | +| 删除前的业务限制 | `s_delete_rule` | 用只读 SQL 检查状态、单据和跨表条件 | +| 关联记录如何处置 | 删除策略 | `restrict`、`cascade`、`none`,不参与菜单权限计算 | + +因此: + +1. **菜单权限不需要依赖 `s_relation`。** +2. **模块父子关系使用 `s_module.b_parent_id`,不从字段关系推导。** +3. **`s_relation` 建议保留为“数据关系”,但不再称为菜单权限意义上的模块关联。** +4. **删除策略继续存在,但从数据关系的菜单中剥离,统一放到删除策略入口。** + +当前设计文档已经有这几张表的雏形:模块树使用 `s_module.b_parent_id`,菜单绑定使用 `s_menu_module`,数据关系使用 `s_relation`。[核心表设计](FMS新系统核心表结构设计.md) [模块设计评审](FMS模块设计评审.md) + +## 二、当前问题 + +### 2.1 “模块关联”包含了三种不同关系 + +目前容易把下面三件事都叫作模块关联: + +1. **组织关系**:海运模块下面有主单、箱、费用模块。 +2. **数据关系**:箱表的 `order_id` 引用主单表的 `id`。 +3. **授权关系**:某个菜单页面使用主单模块和箱模块,用户可以进入并操作这些模块。 + +它们的数学结构和运行规则不同: + +| 关系 | 结构 | 典型约束 | +| --- | --- | --- | +| 模块组织 | 树 | 一个父节点、不能循环 | +| 数据关系 | 图 | 可以多对多、可以有多个入边 | +| 菜单绑定 | 多对多 | 一个页面可用多个模块,一个模块可被多个页面使用 | +| 权限授权 | 用户到权限点 | 不能因为数据引用就自动获得权限 | + +如果用数据关系建立模块父子树,会遇到多个父节点、循环和跨业务域引用。例如“费用 → 客户”是数据引用,但费用模块并不是客户模块的子模块。把它用于权限继承会造成权限扩大。 + +### 2.2 删除策略和菜单权限没有共同语义 + +删除策略回答: + +> 删除一条业务记录时,关联记录如何处理?什么条件下不允许删除? + +权限回答: + +> 用户能否进入页面、使用模块、执行动作、查看字段和查看哪些数据? + +两者的计算入口、管理员和风险都不同。把删除配置放进菜单权限关联,会让配置人员误以为“菜单绑定”会影响删除,也会让关系面板出现过多不相关列。 + +### 2.3 当前代码已经有两个真实消费者 + +当前前端菜单管理使用 `s_menu_module` 保存菜单与模块的绑定;模块关系面板维护 `s_relation`。后端 `DataDeleteService` 会读取启用的 `s_relation`,展开 `cascade` 并检查 `restrict`。 + +这说明 `s_relation` 不是完全没有价值,但它的价值来自**数据删除和数据影响分析**,不是菜单授权。若以后改成每个模块完全由专用 SQL 删除,才可以进一步评估是否移除它。 + +## 三、目标模型 + +### 3.1 三棵树、两类关系 + +```text +模块组织树 +s_module.b_parent_id +海运 +├── 海运主单 +├── 箱信息 +└── 费用 + +菜单导航树 +s_menu.b_parent_id +业务管理 +└── 海运工作台 + +菜单与模块绑定 +s_menu_module +海运工作台 ── 海运主单 +海运工作台 ── 箱信息 +海运工作台 ── 费用 + +数据字段关系 +s_relation +箱信息.order_id ──> 海运主单.id +费用.order_id ──> 海运主单.id +``` + +模块树只表达业务组织。菜单树只表达导航。菜单页面使用哪些数据模块由 `s_menu_module` 明确保存。数据字段关系只用于数据层,不参与菜单树和权限树。 + +### 3.2 权限归属:模块为主,菜单为入口 + +权限应当**跟着模块**,菜单只负责提供入口。 + +```text +用户 + └── 拥有权限点 + ├── menu.* → 能否进入菜单页面 + ├── module.* → 能否使用模块 + └── action.* → 能否执行模块动作 + +菜单页面 + └── s_menu_module → 页面使用哪些模块 + +模块 + ├── s_user_field_power → 字段能力 + └── s_user_data_power → 数据范围 +``` + +菜单绑定模块不等于授予模块权限。原因有三点: + +1. 一个模块可能被多个菜单复用,权限应该保持一致; +2. 一个菜单可能使用主模块、明细模块和多个辅助模块,不适合把它们的权限合并成一个菜单权限; +3. 后端接口、导出、保存和业务动作可能绕过菜单入口,必须仍按模块权限和动作权限校验。 + +因此运行时按以下顺序判断: + +1. 用户是否拥有当前菜单的 `menu.*` 权限; +2. 当前页面所需模块是否拥有 `module.*` 权限; +3. 当前请求的操作是否拥有对应的 `action.*` 或标准操作权限; +4. 查询和写入时继续追加字段权限和数据范围条件。 + +如果一个页面绑定多个模块,菜单权限只决定能否进入页面,每个模块仍单独判断。主模块或必需模块无权限时,页面应隐藏或拒绝进入;可选模块无权限时,可以隐藏对应面板。是否为“必需模块”属于页面配置,不由 `s_relation` 推导。 + +模块父子树可以作为管理员批量授权的操作范围,但第一期不建议运行时隐式继承:勾选父模块时可以在界面展开子模块,最终保存每个数据模块的明确授权行。这样新建子模块不会在用户不知情的情况下自动获得所有权限。 + +### 3.3 权限计算边界 + +权限计算分为五层: + +1. `menu.*`:能否看到和进入菜单页面。 +2. `module.*`:能否使用页面中的某个模块。 +3. `action.*`:能否执行审核、结算等业务动作。 +4. `s_user_field_power`:字段查看、编辑、查询和导出权限。 +5. `s_user_data_power`:数据行范围。 + +菜单绑定多个模块时,不合并这些模块的权限;每个模块仍按自己的模块编码判断。数据关系也不自动传播权限。若管理员在界面上勾选一个模块分类节点,可以自动展开下级数据模块,但落库仍保存明确的模块权限或菜单绑定行。 + +### 3.4 用户授权页面如何呈现 + +管理员不需要直接理解 `s_relation`。授权页面以业务域和模块树呈现: + +```text +海运(业务域,只做分组) +├── 海运主单(data) +├── 装箱信息(data) +└── 费用(data) +``` + +建议将授权页面分成三个视图: + +#### 菜单入口 + +左侧显示菜单树,管理员选择“海运工作台”等页面,保存 `menu.*` 权限。 + +#### 模块能力 + +显示当前页面绑定的模块,以及完整的业务模块树。每个 `data` / `virtual` 模块显示: + +- 是否可访问; +- 标准操作:查看、新增、编辑、删除、导出; +- 已配置的业务动作,例如审核、结算; +- 当前模块是否被哪些菜单使用。 + +选择“海运”父节点时,界面可以全选或取消全部子模块,并使用半选状态表示部分授权。这个操作只是批量生成明确的模块授权行,不保存“沿关系自动继承”的隐式规则。 + +#### 字段和数据范围 + +管理员先选择一个具体模块,再配置字段权限和数据范围。不要在“海运”父节点上混合展示主单、装箱和费用的字段,因为这些字段属于不同模块,数据范围也可能不同。 + +页面绑定关系可以提供一个“按页面补齐模块权限”的快捷操作:管理员选择菜单后,系统列出该页面使用的主模块、明细模块和辅助模块,管理员确认后一次性生成模块权限。这个操作是显式的授权动作,菜单绑定本身不自动授权。 + +页面确实依赖某个模块时,应在页面配置中标记该模块为必需模块;可选模块则在用户没有权限时隐藏对应面板。必需/可选属于页面使用配置,不从 `s_relation` 的数据关系推导。 + +授权效果示例: + +| 用户 | 菜单入口 | 模块权限 | 页面效果 | +| --- | --- | --- | --- | +| 张三 | 海运工作台 | 主单、装箱 | 能进入页面,费用面板隐藏或不可用 | +| 李四 | 海运工作台 | 主单、装箱、费用 | 能使用完整页面 | +| 王五 | 无海运菜单 | 主单 | 不能从海运菜单进入,但模块接口仍按模块权限统一校验 | + +页面是否显示可以由“菜单权限 + 至少一个可访问的必需模块”共同决定;真正的查询、保存、删除和动作权限仍必须在后端按模块执行。 + +### 3.5 默认使用“业务授权方案”,隐藏技术细节 + +如果每个用户都要先分配菜单,再逐个分配主单、装箱、费用和动作,业务管理员会觉得权限系统过于复杂。因此默认授权入口不直接展示三套独立权限表,而是展示业务人员能理解的授权方案: + +```text +海运 +├── 未授权 +├── 只读 +├── 经办 +└── 主管 +``` + +方案的含义由系统管理员预先定义。例如: + +| 方案 | 菜单入口 | 海运主单 | 装箱信息 | 费用 | +| --- | --- | --- | --- | --- | +| 只读 | 海运工作台、海运单列表 | 查看、导出 | 查看 | 查看 | +| 经办 | 海运工作台、海运单列表 | 查看、新增、编辑、导出 | 查看、编辑 | 查看 | +| 主管 | 海运全部相关页面 | 全部标准操作和业务动作 | 全部标准操作 | 查看、编辑、导出、结算 | + +管理员选择“海运·经办”后,界面先展示授权预览: + +```text +将授予: +菜单:海运工作台、海运单列表 +模块:海运主单(查看/新增/编辑/导出) + 装箱信息(查看/编辑) + 费用(查看) +``` + +确认后,在同一事务中写入菜单权限和模块/动作权限。这里的“方案”只是管理员操作的简化入口,运行时的真实权限仍然按模块、动作、字段和数据范围校验;菜单绑定不会在运行时隐式授予模块权限。 + +默认页面只展示业务名称和方案,不展示模块编码、字段编码和 `s_relation`。授权预览中的“主单、装箱、费用”依赖来自 `s_menu_module` 的页面模块清单,不从数据关系推导。 + +高级管理员可以展开“详细调整”:单独修改某个模块、动作、字段或数据范围。详细调整生成同一套底层权限记录,不另建一套与方案互相覆盖的规则。方案变更后,应显示变更前后差异并要求确认。 + +第一期不必新增复杂的角色继承体系。可以先把方案作为权限页面上的预置操作,直接物化为现有 `s_power` / `s_user_power` 等权限行。出现多个用户需要相同授权时,再把方案独立为可复用的权限模板。 + +## 四、目标表设计 + +### 4.1 `s_module`:模块组织树 + +继续使用现有字段: + +- `b_id`:模块编码。 +- `b_parent_id`:直接父模块,树关系的唯一权威来源。 +- `b_depth`、`b_path`:由业务层维护的派生字段。 +- `b_module_type`:`module`、`data`、`virtual`。 + +约束: + +- `module` 可以包含下级 `module`、`data` 和 `virtual`。 +- `data`、`virtual` 默认作为叶子节点。 +- 不允许自指和循环。 +- 移动模块时,在同一事务内更新当前节点及全部后代的 `b_depth`、`b_path`。 +- 不从 `s_relation` 推导 `b_parent_id`。 + +### 4.2 `s_menu`、`s_menu_module`:菜单与模块绑定 + +`s_menu` 继续使用 `b_parent_id` 建立菜单树。 + +`s_menu_module` 继续使用联合主键: + +```text +(b_menu_id, b_module_id) +``` + +绑定规则: + +- `directory`、`external` 不强制绑定模块。 +- `page` 可以绑定一个或多个 `data` / `virtual` 模块。 +- `module` 类型只作为分类和上下文,不直接进入菜单模块绑定。 +- UI 可以按模块树批量勾选,但保存时写入明确的 `s_menu_module` 行。 +- 删除模块前检查并清理其菜单绑定,不能通过数据关系级联推断菜单绑定。 + +### 4.3 `s_relation`:数据关系 + +建议将管理页面名称改为“数据关系”,表继续使用字段级复合键: + +```text +b_source_module_id +b_source_field +b_target_module_id +b_target_field +b_relation_type -- one_to_one / one_to_many / many_to_one +b_canuse +b_xh +``` + +它只回答: + +> 哪个模块的哪个字段与另一个模块的哪个字段存在结构关系? + +它不保存: + +- 菜单编码。 +- 用户编码。 +- 权限编码。 +- 模块树父节点。 +- 菜单显示顺序。 + +#### 删除行为的处理 + +当前通用删除服务已经依赖 `b_on_delete`,因此建议采用兼容方案: + +- 数据库中暂时保留 `s_relation.b_on_delete`。 +- 关系面板只显示该列或提供跳转,不作为菜单权限关系编辑。 +- 唯一可编辑入口放到“删除策略 → 关联删除处理”。 +- 只保留 `restrict`、`cascade`、`none` 三值。 +- 不根据 `b_relation_type` 自动推断删除行为。 + +如果后续确认所有模块都由专用 SQL 或存储过程删除,则可以迁移掉 `b_on_delete`,让 `s_relation` 只保留结构关系。 + +### 4.4 `s_delete_rule`:业务删除条件 + +`s_delete_rule` 只处理“什么情况下不允许删除”,不再保存数据关系键: + +```text +b_id +b_module_id +b_sql -- 只读检查 SQL,命中行即拒绝删除 +b_message +b_xh +b_canuse +``` + +适合配置: + +- 已审核单据不能删除。 +- 已开票记录不能删除。 +- 已生成下游业务单据不能删除。 +- 状态或跨表条件不满足时不能删除。 + +SQL 只做检查,不允许配置 SQL 直接修改或删除业务数据。实际物理删除继续由后端事务服务负责。 + +## 五、前端改造方案 + +### 5.1 模块管理页面 + +建议将业务能力分节调整为: + +```text +基础 +字段 +界面 +数据关系 +删除策略 +自动编码 +权限 +多语言 +``` + +其中: + +- “数据关系”只维护字段关系。 +- “删除策略”包含“关联删除处理”和“删除 SQL 规则”。 +- “权限”只维护 `s_power`,不展示删除配置。 +- “数据关系”只对 `data` 模块开放。 + +### 5.2 数据关系面板 + +保留: + +- 新增关系。 +- 源字段选择。 +- 目标模块、目标字段选择。 +- 关系类型。 +- 启用/停用。 +- 排序号。 +- 当前模块作为源的关系编辑。 +- 其他模块指向当前模块的关系只读展示。 + +调整: + +- 面板标题从“模块关联”改为“数据关系”。 +- 删除处理列改为只读并提供“前往删除策略”的入口,或者完全移入删除策略面板。 +- 不出现菜单、权限、用户等字段。 +- 保存前校验模块、字段存在,字段类型可匹配,关系不能重复。 + +### 5.3 菜单管理页面 + +- 左侧继续展示模块树。 +- 勾选 `module` 分类节点时,展开其下的 `data` / `virtual` 模块。 +- 保存时只提交 `s_menu_module` 的增删行。 +- 菜单页面绑定的模块不反向修改模块树。 +- 模块被多个菜单使用时,只维护多条菜单绑定,不复制模块。 + +### 5.4 权限面板 + +- 模块使用权限继续以 `module.{moduleCode}` 为主。 +- 业务动作使用 `action.{moduleCode}.{action}`。 +- 菜单权限只控制入口。 +- 不因 `s_relation` 存在而自动给用户增加目标模块权限。 +- 如果未来需要继承,只允许从 `s_module.b_parent_id` 的组织树计算,并明确配置继承边界;不使用字段关系推导。 + +## 六、删除实现取舍 + +### 6.1 当前阶段推荐方案 + +保留通用删除服务: + +```text +模块删除 +→ 根据 s_relation.b_on_delete 展开 cascade +→ 检查 restrict 外部引用 +→ 执行 s_delete_rule SQL 检查 +→ 同一事务内物理删除 +``` + +这样适合模块数量较多、希望新增模块主要靠配置的场景。`s_relation` 为删除服务提供结构信息,`s_delete_rule` 为业务条件提供 SQL 出口,两者职责明确。 + +### 6.2 SQL 直接处理的适用边界 + +SQL 适合: + +- 复杂查询和跨表判断。 +- 固定模块的特殊清理逻辑。 +- 数据库外键的 `CASCADE` / `NO ACTION`(物理结构稳定时)。 + +不建议: + +- 让管理员配置任意 `DELETE SQL` 作为通用删除入口。 +- 用 SQL 文本判断用户菜单权限。 +- 用 SQL 关系自动推导模块父子和权限继承。 + +如果未来选择“完全模块专用 SQL 删除”,迁移顺序应是:先为每个模块补齐删除服务和测试,再停止通用删除服务读取 `s_relation`,最后删除 `b_on_delete`,不能先删元数据再补删除逻辑。 + +## 七、迁移步骤 + +### 阶段 0:盘点和校验 + +1. 统计现有 `s_relation` 行,确认每行的源模块、源字段、目标模块、目标字段真实存在。 +2. 检查重复关系、自引用、无效模块和无效字段。 +3. 盘点旧系统 `s_modulelink` 或其他模块链接数据,确认哪些是菜单绑定,哪些是数据关系,禁止直接整表迁移到 `s_relation`。 +4. 统计菜单、模块、权限之间的现有绑定,找出没有模块权限但已有菜单入口的情况。 + +### 阶段 1:先改语义和界面 + +1. 将模块管理中的“模块关联”改为“数据关系”。 +2. 移除它对菜单权限的描述。 +3. 在菜单管理中明确展示 `s_menu_module` 绑定。 +4. 增加“删除策略”分组,保证 `b_on_delete` 只有一个编辑入口。 +5. 不立即删除数据库列和旧数据,保持兼容。 + +### 阶段 2:拆分权限计算 + +1. 路由和侧栏只根据菜单权限及菜单绑定判断入口。 +2. 页面内模块操作根据 `s_power` / `s_user_power` 独立判断。 +3. 字段和数据范围继续走独立权限表。 +4. 全局搜索确认权限代码没有读取 `s_relation`。 + +### 阶段 3:收敛删除策略 + +1. 删除前条件统一读取 `s_delete_rule`。 +2. 关联删除统一读取 `s_relation.b_on_delete`。 +3. 删除列表入口和 `saveobjt` 使用同一删除服务。 +4. 对 `cascade` 做环路、深度、重复记录和事务回滚测试。 +5. 对 `restrict` 返回引用模块、字段和示例记录,便于用户处理。 + +### 阶段 4:清理旧概念 + +只有在兼容数据完成迁移后,才删除: + +- 旧的菜单关系字段。 +- 旧的引用规则或重复删除配置。 +- 模块关联面板中的权限文案。 +- 不再使用的关系推导逻辑。 + +## 八、验收标准 + +### 模块树 + +- 模块只能有一个直接父节点。 +- 不能自引用或形成循环。 +- 移动节点后,所有后代的 `b_path`、`b_depth` 正确。 +- 数据字段关系不会改变模块树。 + +### 菜单和权限 + +- 一个菜单可以绑定多个模块。 +- 一个模块可以被多个菜单复用。 +- 勾选模块分类节点只是批量操作,保存后仍是明确绑定行。 +- 只有菜单权限不能直接操作模块数据。 +- 有数据关系的两个模块不会自动互相获得权限。 + +### 数据关系 + +- 关系字段存在且类型可匹配。 +- 关系重复时保存失败。 +- 反向关系可查看,但不会重复提交或误删。 +- `s_relation` 不包含菜单、用户和权限字段。 + +### 删除 + +- `cascade` 能正确删除拥有关系的子记录。 +- `restrict` 能阻止外部引用导致的父记录删除。 +- `none` 不自动处理关联记录,并在配置界面显示风险提示。 +- 删除 SQL 命中时整批回滚。 +- 技术表使用 `target=direct` 时不读取模块关系。 +- 任何检查失败时,不留下部分删除结果。 + +## 九、文件影响范围 + +### 数据库和脚本 + +- `sql/fms_core.sql`:确认 `s_module`、`s_menu`、`s_menu_module`、`s_relation`、`s_power` 的职责注释和字段。 +- `sql/fms_delete_rule.sql`:只保留删除策略迁移和 `s_delete_rule` 初始化。 +- 新增一份关系数据校验/迁移脚本,先报告问题,不直接静默修复。 + +### 前端 + +- `fms-vue/src/views/module/module-management/index.vue`:调整分节名称和数据关系/删除策略的编排。 +- `ModuleRelationPanel.vue`:改名、缩减职责、移除权限语义。 +- `ModuleRulePanel.vue`:作为删除 SQL 规则面板保留。 +- `fms-vue/src/views/module/menu-management/index.vue`:继续以 `s_menu_module` 保存菜单绑定。 +- `stores/permissions.js`:确认权限计算不读取 `s_relation`。 + +### 后端 + +- `DataDeleteService.java`:继续只把 `s_relation` 当作数据关系来源。 +- `SqlPermissionService.java`:权限只读取权限表,不通过数据关系推导。 +- 菜单接口和模块接口分别校验自己的树结构,不能共用一套父子关系处理。 + +## 十、需要确认的唯一决策 + +本计划默认保留通用删除服务,因此保留 `s_relation.b_on_delete` 作为底层删除策略字段,但把编辑入口移到“删除策略”。 + +如果决定所有业务模块都用专用 SQL 或存储过程处理删除,则可以选择另一条路线: + +```text +s_relation 只保留数据结构关系 +s_delete_rule 保留删除前检查 +模块删除由各自服务/存储过程完成 +``` + +这条路线的配置更少,但新增模块不能只靠元数据完成删除行为,需要额外编写和测试模块删除逻辑。 + +在当前 FMS 已经存在通用 `DataDeleteService`、模块数量较多的前提下,建议先采用默认方案:**保留数据关系,拆出权限语义,删除策略单独管理,暂不改成纯 SQL 删除。** + +## 十一、原型演示 + +对应的交互原型位于 [权限分配原型.html](权限分配原型.html),直接用浏览器打开即可查看。 + +原型按常见企业系统的授权路径组织: + +1. 左侧切换用户,顶部查看当前授权来源和保存状态。 +2. 先选择海运、空运或财务业务域。 +3. 默认选择“未授权、只读、经办、主管”等业务方案,右侧立即预览菜单入口、模块和动作。 +4. 需要精细控制时,在海运业务中打开“详细权限调整”,分别配置菜单、模块、字段和数据范围。 +5. 用“预览最终权限”查看后端实际会收到的授权结果,用“撤销修改”验证未保存变更不会直接生效。 + +原型中的角色方案只是降低管理员理解成本的操作入口,底层仍然落成菜单、模块和动作权限;业务域和模块之间的字段关系没有被用来推导权限。 diff --git a/code/fms/FMS模块设计评审.md b/code/fms/FMS模块设计评审.md index e3b3ee7e..405a4db2 100644 --- a/code/fms/FMS模块设计评审.md +++ b/code/fms/FMS模块设计评审.md @@ -1,13 +1,15 @@ # FMS 模块设计评审 > 版本:v1.2。评审对象:`FMS新系统核心表结构设计.md`(模块 / 字段 / 界面配置 / 模块关系 / 权限)。 -> 关联:`FMS删除规则引擎设计.md`(已按本评审定案修订为 v2.0)、`开发规范.md`、`CODEBUDDY.md`。 +> 关联:`FMS删除策略重构设计.md`(当前删除策略架构)、`开发规范.md`、`CODEBUDDY.md`。 > 一句话:**骨架是对的,改三处即可——关系删除行为一列化、配置分节按业务问题分、规则表达对齐权限体系。** > -> v1.1 修订:问题一原建议「加 `b_relation_kind` 语义列 + 按语义推导默认行为」,定案为**不加语义列、单列 `b_on_delete` 四值**——语义列与删除行为同轴,不提供额外信息;被引用方由基数判定。`FMS删除规则引擎设计.md` 已同步修订为 v2.0。 +> v1.1 修订:问题一原建议「加 `b_relation_kind` 语义列 + 按语义推导默认行为」,定案为不加语义列、单列 `b_on_delete` 表达删除行为。该部分已由当前删除策略重构重新收敛为三值方案。 > > v1.2 修订:第八节第 3、4 项定案——中间表模块统一挂业务域节点下(规范已写入 `FMS新系统核心表结构设计.md` §4.3);模块管理分节调整随删除规则前端改造同一批实施。 +> 当前实施以 `FMS删除策略重构设计.md` 为准。本评审中关于四值删除行为、归档、勾条件、引用规则和 `s_rule` 的章节保留为历史讨论,不再作为实施规格。 + --- ## 一、总评 @@ -58,7 +60,7 @@ 3. **唯一只有配置者知道的事**是四选一的行为本身(被引用方是否独占拥有引用方),单列直接表达它; 4. 与 `开发规范.md`「为真实出现的第二个使用方而抽象」一致——不为「表单将来可能用语义做别的」预留列。 -最终结构(详规见 `FMS删除规则引擎设计.md` v2.0 §3): +最终结构(历史方案;当前方案详见 `FMS删除策略重构设计.md`): ```sql alter table dbo.s_relation add @@ -154,7 +156,7 @@ alter table dbo.s_relation add - 去掉 `b_hook`(一期只有 `delete.pre` 一种取值,属过早抽象); - 去掉 `b_scope_type`(同上)。 -已按此修订 `FMS删除规则引擎设计.md`(v2.0)。 +该历史方案已由 `FMS删除策略重构设计.md` 取代。 --- @@ -189,21 +191,21 @@ alter table dbo.s_relation add | 设计/文档有 | 实际状态 | | --- | --- | -| 删除规则引擎(`FMS删除规则引擎设计.md` §6.4/§6.5 整节) | **后端零实现**:`s_rule` / `deleteobj` / `b_on_delete` / `b_archived` 在 `fms-api` 中全部 grep 无匹配 | +| 删除策略引擎(`FMS删除策略重构设计.md`) | **后端零实现**:删除计划、SQL 规则检查、`deleteobj` 和 `b_on_delete` 执行链路仍待实现 | | `DataSaveService` 的 delete 分支接入规则检查 | 未实现,`DataSaveService.java:470` 直接 `dbUtils.delete(...)` | | `s_rule.b_scope_id` 为 `bigint`(v1 设计) | 实现为拼接串 `varchar(250)`;v2.0 已整体重新定义表结构,该差异随重建消除 | | 删除「先预览后确认」(设计 §4) | 列表页 `deleteSelected` 直接调 `saveObjectApi`,无预览/确认两步 | | `save.pre` / `b_hook` 预留位 | 只有 `delete.pre` 一种取值,预留位无实际内容 | -**风险提示**:规则引擎未实现时,界面上配的删除规则**不生效**。这比没有该功能更危险——它给配置者虚假的安全感。详见 `FMS删除规则引擎设计.md`。 +**风险提示**:删除策略未实现时,界面上配置的删除规则**不生效**。这比没有该功能更危险——它给配置者虚假的安全感。详见 `FMS删除策略重构设计.md`。 --- ## 七、建议的落地顺序 -1. **`s_relation` 加 `b_on_delete`**(四值、默认 `restrict`),配合 `FMS删除规则引擎设计.md` v2.0 的表结构调整; -2. **模块管理分节改为「数据行为 / 访问控制」**,按业务问题而非载体划分;与删除规则前端改造(`FMS删除规则引擎设计.md` §8 步骤 5)同一批实施——两者都要动 `module-management/index.vue`,不单独安排; -3. **`s_rule` 结构化**:显式 `b_kind`、直白 `b_module_id`、去掉预留位; +1. **`s_relation` 加 `b_on_delete`**(三值、默认 `restrict`),按 `FMS删除策略重构设计.md` 的架构调整; +2. **模块管理分节改为「数据行为 / 访问控制」**,按业务问题而非载体划分;删除策略统一为关联删除处理 + 删除 SQL 规则; +3. **删除规则专用化**:使用 `s_delete_rule`,只保留模块、SQL、提示、启用和顺序; 4. ~~补规范:明确中间表模块在模块树中的挂载位置~~ **已完成**:规范已写入 `FMS新系统核心表结构设计.md` §4.3; 5. **实现后端删除引擎**(`deleteobj` + 检查器 + 执行器 + `saveobjt` 接入)。 diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java index 54e95a01..932759dd 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java @@ -1,5 +1,6 @@ package cn.g3soft.fmsapi.controller; +import cn.g3soft.fmsapi.service.DataDeleteService; import cn.g3soft.fmsapi.service.DataSaveService; import cn.g3soft.fmsapi.service.DataService; import cn.g3soft.fmsapi.utils.ApiResponse; @@ -26,6 +27,9 @@ public class DataController { @Resource private DataSaveService dataSaveService; + @Resource + private DataDeleteService dataDeleteService; + @PostMapping("/loaddata") public ApiResponse>> loadData( @RequestBody(required = false) Map params @@ -55,6 +59,17 @@ public class DataController { return ApiResponse.success("保存成功", null); } + /** + * 统一删除接口:模块数据传 target=module + moduleCode + ids; + * 受控后端服务传 target=direct + table + key_field + ids(设计 §6.1、§1.1)。 + */ + @PostMapping("/delete") + public ApiResponse> delete( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataDeleteService.delete(params)); + } + @PostMapping("/nextid") public ApiResponse nextId(@RequestBody(required = false) Map params) { int count = 1; diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java new file mode 100644 index 00000000..50a9452e --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java @@ -0,0 +1,782 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.DbUtils; +import cn.g3soft.fmsapi.utils.ParamUtils; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 删除服务:删除策略层对外的唯一入口。 + * + *

按设计文档《FMS删除策略重构设计.md》实现,删除目标分两类: + *

    + *
  • {@code target=module}:业务模块数据,读取模块保存表和主键,展开 cascade, + * 执行 restrict 与模块 SQL 规则,再物理删除;
  • + *
  • {@code target=direct}:技术表或专用资源,直接进入物理删除执行器, + * 不读模块关系、不跑模块 SQL 规则。普通模块列表不允许传表名。
  • + *
+ * + *

计划、检查、删除在同一事务中完成(设计 §5.3):任一步骤失败整批回滚。 + */ +@Service +public class DataDeleteService { + + /** 删除目标类型:模块数据。 */ + public static final String TARGET_MODULE = "module"; + /** 删除目标类型:直接表。 */ + public static final String TARGET_DIRECT = "direct"; + + /** 单次删除的 ID 上限,避免生成过大的 IN 列表(设计 §4.2)。 */ + private static final int MAX_DELETE_IDS = 1000; + /** cascade 最大展开深度,防止错误配置导致无限递归(设计 §5.1)。 */ + private static final int MAX_CASCADE_DEPTH = 8; + + private static final String ON_DELETE_RESTRICT = "restrict"; + private static final String ON_DELETE_CASCADE = "cascade"; + private static final String ON_DELETE_NONE = "none"; + + @Resource + private DataSource dataSource; + + @Resource + private DbUtils dbUtils; + + /** + * 统一删除入口(设计 §6.1)。参数按 target 取值: + *

    + *
  • {@code target=module}:{@code moduleCode} + {@code ids};
  • + *
  • {@code target=direct}:{@code table} + {@code key_field} + {@code ids}。
  • + *
+ * 不允许以“有没有传 moduleCode”作隐式分支,避免漏传时静默绕过模块规则。 + */ + public Map delete(Map params) throws SQLException { + if (params == null) { + throw new BusinessException("删除参数不能为空"); + } + String target = ParamUtils.getString(params, "target"); + if (target == null || target.isBlank()) { + throw new BusinessException("删除目标类型不能为空"); + } + List ids = ParamUtils.getList(params, "ids"); + String userId = ParamUtils.getString(params, "userId"); + + if (TARGET_MODULE.equalsIgnoreCase(target)) { + String moduleCode = ParamUtils.getRequiredString(params, "moduleCode"); + return deleteModule(moduleCode, ids, userId); + } + if (TARGET_DIRECT.equalsIgnoreCase(target)) { + String table = ParamUtils.getRequiredString(params, "table"); + String keyField = ParamUtils.getRequiredString(params, "key_field"); + if (ids == null || ids.isEmpty()) { + return Collections.emptyMap(); + } + checkIdLimit(ids); + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + int count = deleteDirect(connection, table, keyField, ids, userId); + connection.commit(); + return Map.of(table, count); + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + if (originalAutoCommit) { + connection.setAutoCommit(true); + } + } + } + } + throw new BusinessException("删除目标类型必须是 module 或 direct"); + } + + /** + * 模块删除:前端只传 moduleCode + ids(设计 §6.1)。 + * + * @param moduleCode 模块编码(s_module.b_id) + * @param ids 待删除记录主键值 + * @return 各模块实际删除行数 + */ + public Map deleteModule(String moduleCode, List ids, String userId) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map result = executeModuleTarget( + connection, moduleCode, ids, Collections.emptyList(), userId); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + if (originalAutoCommit) { + connection.setAutoCommit(true); + } + } + } + } + + /** + * saveobjt 的删除分支:复用调用方事务连接,不自行提交(设计 §6.2)。 + * + * @param explicitDeletes 请求自带的删除行,元素为 {table, key_field, ids} + * (direct 目标)或 {module_code, ids}(module 目标) + */ + public Map deleteWithinTransaction( + Connection connection, + String target, + String moduleCode, + List ids, + List> explicitDeletes, + String userId + ) throws SQLException { + if (TARGET_MODULE.equalsIgnoreCase(target)) { + return executeModuleTarget(connection, moduleCode, ids, explicitDeletes, userId); + } + if (TARGET_DIRECT.equalsIgnoreCase(target)) { + return executeDirectTarget(connection, explicitDeletes, userId); + } + // 不以“有没有传 moduleCode”作隐式分支,避免漏传时静默绕过模块规则(设计 §6.2) + throw new BusinessException("删除目标类型必须是 module 或 direct"); + } + + /** + * 直接表删除:文件服务等受控后端服务使用,不展开业务关系(设计 §6.3)。 + */ + public int deleteDirect( + Connection connection, + String table, + String keyField, + List ids, + String userId + ) throws SQLException { + if (ids == null || ids.isEmpty()) { + return 0; + } + checkIdLimit(ids); + DbUtils.TableMetadata metadata = dbUtils.loadTableMetadata(connection, table); + DbUtils.ColumnMetadata keyColumn = requireKeyColumn(metadata, keyField); + int deleted = deleteRecords(connection, metadata, keyColumn, ids); + writeDeleteLog(userId, "direct", table, keyColumn.name(), ids.size(), deleted); + return deleted; + } + + // ── target=module:计划 → 检查 → 执行 ────────────────────────────────── + + private Map executeModuleTarget( + Connection connection, + String moduleCode, + List ids, + List> explicitDeletes, + String userId + ) throws SQLException { + String requiredModule = requireText(moduleCode, "moduleCode"); + if (ids == null || ids.isEmpty()) { + return Collections.emptyMap(); + } + checkIdLimit(ids); + + ModuleTarget root = loadModuleTarget(connection, requiredModule); + + // 1. 生成删除清单:显式删除 + cascade 展开,按“表 + 主键字段 + 主键值”去重(设计 §6.2) + DeletePlan plan = new DeletePlan(); + plan.add(root.metadata(), root.keyColumn(), requiredModule, ids, 0); + expandCascade(connection, root, requiredModule, ids, plan, 1); + mergeExplicitDeletes(connection, explicitDeletes, plan, requiredModule, false); + + // 2. 检查:模块 SQL 规则 + restrict 外部引用(设计 §5.2) + checkDeleteRules(connection, plan, userId); + checkRestrictReferences(connection, plan); + + // 3. 执行:子记录优先,物理删除(设计 §5.3) + Map deleted = new LinkedHashMap<>(); + for (PlannedTable planned : plan.orderedFromLeaf()) { + int count = deleteRecords( + connection, + planned.metadata(), + planned.keyColumn(), + new ArrayList<>(planned.values())); + deleted.merge(planned.key(), count, Integer::sum); + writeDeleteLog(userId, requiredModule, planned.key(), + planned.keyColumn().name(), planned.values().size(), count); + } + return deleted; + } + + private ModuleTarget loadModuleTarget(Connection connection, String moduleCode) + throws SQLException { + List> rows = dbUtils.loadDataBySql( + connection, + "select [b_save_table], [b_key_field] from [s_module] where [b_id] = " + + dbUtils.toSqlStringLiteral(moduleCode)); + if (rows.isEmpty()) { + throw new BusinessException("模块不存在: " + moduleCode); + } + Map row = rows.get(0); + String saveTable = text(row.get("b_save_table")); + // 只允许保存表,绝不回退视图表(设计 §6.1);视图表通常不可写。 + if (saveTable == null || saveTable.isBlank()) { + throw new BusinessException("模块未配置保存表,无法删除"); + } + String keyField = text(row.get("b_key_field")); + if (keyField == null || keyField.isBlank()) { + throw new BusinessException("模块未配置主键字段,无法删除"); + } + DbUtils.TableMetadata metadata = dbUtils.loadTableMetadata(connection, saveTable); + return new ModuleTarget(metadata, requireKeyColumn(metadata, keyField)); + } + + /** + * 沿启用的 cascade 关系递归展开删除清单(设计 §5.1)。 + * + *

关系是双向登记的:一条 {@code s_relation} 行描述“源模块.源字段 → 目标模块.目标字段”。 + * 删除某模块记录时,引用它的可能是关系的哪一侧,取决于外键所在侧(设计 §3.2): + *

    + *
  • 本模块是关系的目标侧:引用方是源侧,外键列 = 目标字段,用源表按该列匹配本模块主键值;
  • + *
  • 本模块是关系的源侧:被引用方是目标侧,外键列 = 源字段,用目标表按该列匹配本模块主键值。
  • + *
+ * 两种方向都要展开,否则“从明细删主单”这类反向级联会静默失效。 + */ + private void expandCascade( + Connection connection, + ModuleTarget current, + String moduleCode, + List ids, + DeletePlan plan, + int depth + ) throws SQLException { + if (depth > MAX_CASCADE_DEPTH) { + throw new BusinessException( + "关联删除层级超过上限 " + MAX_CASCADE_DEPTH + ",请检查关系配置是否存在环路"); + } + + List> relations = dbUtils.loadDataBySql( + connection, + "select [b_source_module_id], [b_source_field], [b_target_module_id], [b_target_field], [b_relation_type] " + + "from [s_relation] where [b_canuse] = 1 and [b_on_delete] = 'cascade' " + + "and ([b_source_module_id] = " + dbUtils.toSqlStringLiteral(moduleCode) + + " or [b_target_module_id] = " + dbUtils.toSqlStringLiteral(moduleCode) + ")"); + + for (Map relation : relations) { + String sourceModule = text(relation.get("b_source_module_id")); + String targetModule = text(relation.get("b_target_module_id")); + String sourceField = text(relation.get("b_source_field")); + String targetField = text(relation.get("b_target_field")); + String relationType = text(relation.get("b_relation_type")); + if (sourceModule == null || targetModule == null + || sourceField == null || targetField == null) { + continue; + } + + // 外键所在侧由关系类型决定(设计 §3.2): + // many_to_one → 外键在源字段,被引用方是目标模块; + // one_to_many / one_to_one → 外键在目标字段,被引用方是源模块。 + boolean fkOnSource = relationType == null + || relationType.equalsIgnoreCase("many_to_one"); + String fkModule = fkOnSource ? sourceModule : targetModule; + String fkField = fkOnSource ? sourceField : targetField; + String referencedModule = fkOnSource ? targetModule : sourceModule; + + // 只有当前模块正是"被引用方"时才向下展开引用它的记录; + // 当前模块是持有外键的一方时,删除它不会牵连被引用方,不展开。 + if (!moduleCode.equalsIgnoreCase(referencedModule)) { + continue; + } + String childModule = fkModule; + String childFkField = fkField; + if (childModule.equalsIgnoreCase(moduleCode)) { + // 自引用关系:不当作新增层级,避免自己级联自己 + continue; + } + + ModuleTarget child = loadModuleTarget(connection, childModule); + List childIds = queryChildIdsByFk( + connection, child, childFkField, current.keyColumn(), ids); + if (childIds.isEmpty()) { + continue; + } + boolean added = plan.add(child.metadata(), child.keyColumn(), childModule, childIds, depth); + if (added) { + expandCascade(connection, child, childModule, childIds, plan, depth + 1); + } + } + } + + /** + * 查询引用了给定父记录的子记录主键:子表中外键列 = 父主键值。 + * 只用模块保存表元数据定位列名,不信任配置里的表名。 + */ + private List queryChildIdsByFk( + Connection connection, + ModuleTarget child, + String childFkField, + DbUtils.ColumnMetadata parentKey, + List parentIds + ) throws SQLException { + DbUtils.ColumnMetadata fkColumn = dbUtils.getColumn(child.metadata(), childFkField); + String fkName = fkColumn != null ? fkColumn.name() : childFkField; + dbUtils.validateIdentifier(fkName); + String childKey = child.keyColumn().name(); + + String sql = "select distinct [" + childKey + "] from " + child.metadata().quotedName() + + " where [" + fkName + "] in (" + literalList(parentIds, parentKey) + ")"; + List> rows = dbUtils.loadDataBySql(connection, sql); + List values = new ArrayList<>(); + for (Map row : rows) { + Object value = row.get(childKey); + if (value != null) { + values.add(value); + } + } + return values; + } + + /** + * 合并 saveobjt 请求自带的显式删除行(设计 §6.2)。 + * 同一物理记录若同时以模块目标和直接目标提交,直接报冲突,不执行两次。 + */ + private void mergeExplicitDeletes( + Connection connection, + List> explicitDeletes, + DeletePlan plan, + String moduleCode, + boolean asDirectTarget + ) throws SQLException { + for (Map row : explicitDeletes == null + ? List.>of() : explicitDeletes) { + String table = text(row.get("table")); + String keyField = text(row.get("key_field")); + List rawIds = row.get("ids") instanceof List list ? list : List.of(); + if (table == null || keyField == null || rawIds.isEmpty()) { + continue; + } + DbUtils.TableMetadata metadata = dbUtils.loadTableMetadata(connection, table); + DbUtils.ColumnMetadata keyColumn = requireKeyColumn(metadata, keyField); + String tableKey = metadata.requestedName().toLowerCase(Locale.ROOT) + + "|" + keyColumn.normalizedName(); + if (asDirectTarget) { + // 直接目标提交的记录若已作为模块目标进清单,说明同一物理记录被两种目标同时提交。 + // 模块目标自己的显式删除行不属于此列(它们本就归属该模块)。 + if (plan.containsTable(tableKey) && plan.moduleCodeOfTable(tableKey) != null + && plan.valuesOf(table).stream().anyMatch(rawIds::contains)) { + throw new BusinessException( + "同一记录不能同时以模块目标和直接目标删除: " + table); + } + } + // 模块目标的显式删除行:记录模块归属,使其参与模块 SQL 规则检查 + plan.add(metadata, keyColumn, asDirectTarget ? null : moduleCode, new ArrayList<>(rawIds), 0); + } + } + + // ── 检查:SQL 规则 + restrict ───────────────────────────────────────── + + private void checkDeleteRules(Connection connection, DeletePlan plan, String userId) + throws SQLException { + for (PlannedTable planned : plan.tables()) { + String moduleCode = planned.moduleCode(); + if (moduleCode == null) { + continue; + } + List> rules = dbUtils.loadDataBySql( + connection, + "select [b_message], [b_sql] from [s_delete_rule] " + + "where [b_canuse] = 1 and [b_module_id] = " + + dbUtils.toSqlStringLiteral(moduleCode) + + " order by [b_xh], [b_id]"); + for (Map rule : rules) { + String template = text(rule.get("b_sql")); + if (template == null || template.isBlank()) { + continue; + } + String sql = template.replace(":ids", + literalList(planned.values(), planned.keyColumn())); + // 复用统一只读入口:校验 + 使用当前事务连接执行(设计 §4.4) + List> hit = dbUtils.loadDataBySql(connection, sql); + if (!hit.isEmpty()) { + String message = text(rule.get("b_message")); + throw new BusinessException( + (message == null || message.isBlank() ? "记录不允许删除" : message) + + "(模块 " + moduleCode + " 命中 " + hit.size() + " 行)"); + } + } + } + } + + /** + * restrict 入边检查:删除清单之外的外部引用即拒绝(设计 §3.3、§5.2)。 + * + *

与 cascade 同向:先列出所有 {@code restrict} 关系,再判断本模块处在哪一侧, + * 得到“引用方模块 + 外键列”。命中行数减去“本次清单内也要删掉的行数”, + * 剩下的就是真正的外部引用。 + */ + private void checkRestrictReferences(Connection connection, DeletePlan plan) + throws SQLException { + for (PlannedTable planned : plan.tables()) { + String moduleCode = planned.moduleCode(); + if (moduleCode == null) { + continue; + } + List> relations = dbUtils.loadDataBySql( + connection, + "select [b_source_module_id], [b_source_field], " + + "[b_target_module_id], [b_target_field], [b_relation_type] " + + "from [s_relation] where [b_canuse] = 1 and [b_on_delete] = 'restrict' " + + "and ([b_source_module_id] = " + dbUtils.toSqlStringLiteral(moduleCode) + + " or [b_target_module_id] = " + dbUtils.toSqlStringLiteral(moduleCode) + ")"); + for (Map relation : relations) { + String sourceModule = text(relation.get("b_source_module_id")); + String targetModule = text(relation.get("b_target_module_id")); + String sourceField = text(relation.get("b_source_field")); + String targetField = text(relation.get("b_target_field")); + String relationType = text(relation.get("b_relation_type")); + if (sourceModule == null || targetModule == null + || sourceField == null || targetField == null) { + continue; + } + + // 外键所在侧由关系类型决定(设计 §3.2),与 cascade 同一口径 + boolean fkOnSource = relationType == null + || relationType.equalsIgnoreCase("many_to_one"); + String fkModule = fkOnSource ? sourceModule : targetModule; + String fkField = fkOnSource ? sourceField : targetField; + String referencedModule = fkOnSource ? targetModule : sourceModule; + + // 只有当前模块是被引用方时,才需要检查"谁引用了我" + if (!moduleCode.equalsIgnoreCase(referencedModule)) { + continue; + } + String childModule = fkModule; + String childFkField = fkField; + if (childModule.equalsIgnoreCase(moduleCode)) { + continue; + } + + ModuleTarget child = loadModuleTarget(connection, childModule); + DbUtils.ColumnMetadata fkColumn = dbUtils.getColumn(child.metadata(), childFkField); + String fkName = fkColumn != null ? fkColumn.name() : childFkField; + dbUtils.validateIdentifier(fkName); + String childKey = child.keyColumn().name(); + + String parentLiteral = literalList(planned.values(), planned.keyColumn()); + String countSql = "select count(1) as [cnt] from " + child.metadata().quotedName() + + " where [" + fkName + "] in (" + parentLiteral + ")"; + List> rows = dbUtils.loadDataBySql(connection, countSql); + long count = rows.isEmpty() ? 0 : toLong(rows.get(0).get("cnt")); + if (count <= 0) { + continue; + } + + // 清单内同一批一起删除的引用不算外部引用 + long inPlan = 0; + Set plannedChildIds = plan.valuesOf(child.metadata().requestedName()); + if (!plannedChildIds.isEmpty()) { + long total = count; + String overlapSql = "select count(1) as [cnt] from " + + child.metadata().quotedName() + + " where [" + fkName + "] in (" + parentLiteral + ")" + + " and [" + childKey + "] in (" + + literalList(plannedChildIds, child.keyColumn()) + ")"; + List> overlapRows = + dbUtils.loadDataBySql(connection, overlapSql); + inPlan = overlapRows.isEmpty() ? 0 : toLong(overlapRows.get(0).get("cnt")); + if (inPlan > total) { + inPlan = total; + } + } + if (count > inPlan) { + throw new BusinessException( + "存在外部引用,无法删除:" + childModule + "." + fkName + + " 引用 " + count + " 行,其中 " + (count - inPlan) + + " 行不在本次删除范围内"); + } + } + } + } + + // ── target=direct ──────────────────────────────────────────────────── + + private Map executeDirectTarget( + Connection connection, + List> explicitDeletes, + String userId + ) throws SQLException { + Map deleted = new LinkedHashMap<>(); + for (Map row : explicitDeletes == null + ? List.>of() : explicitDeletes) { + String table = text(row.get("table")); + String keyField = text(row.get("key_field")); + List rawIds = row.get("ids") instanceof List list ? list : List.of(); + if (table == null || keyField == null || rawIds.isEmpty()) { + continue; + } + checkIdLimit(rawIds); + DbUtils.TableMetadata metadata = dbUtils.loadTableMetadata(connection, table); + DbUtils.ColumnMetadata keyColumn = requireKeyColumn(metadata, keyField); + int count = deleteRecords(connection, metadata, keyColumn, new ArrayList<>(rawIds)); + deleted.merge(table, count, Integer::sum); + writeDeleteLog(userId, TARGET_DIRECT, table, keyColumn.name(), rawIds.size(), count); + } + return deleted; + } + + // ── 执行器:子记录优先的物理删除 ─────────────────────────────────────── + + private int deleteRecords( + Connection connection, + DbUtils.TableMetadata table, + DbUtils.ColumnMetadata keyColumn, + List ids + ) throws SQLException { + int total = 0; + for (Object id : ids) { + Map condition = new LinkedHashMap<>(); + condition.put(keyColumn.name(), id); + total += dbUtils.delete(connection, table, condition); + } + return total; + } + + // ── 辅助 ───────────────────────────────────────────────────────────── + + private void checkIdLimit(List ids) { + if (ids.size() > MAX_DELETE_IDS) { + throw new BusinessException( + "单次删除记录数不能超过 " + MAX_DELETE_IDS + " 条,当前 " + ids.size() + " 条"); + } + } + + private DbUtils.ColumnMetadata requireKeyColumn( + DbUtils.TableMetadata table, + String keyField + ) { + DbUtils.ColumnMetadata column = dbUtils.getColumn(table, keyField); + if (column == null) { + throw new BusinessException("主键字段不存在: " + table.requestedName() + "." + keyField); + } + return column; + } + + /** + * 按主键列类型生成 IN 列表。values 已经在 :ids 替换处经过类型校验, + * 这里统一走 toSqlStringLiteral 转义,数字类型不加引号(设计 §4.2)。 + */ + private String literalList(Collection values, DbUtils.ColumnMetadata keyColumn) { + if (values.isEmpty()) { + // 空集合生成永假条件,避免 IN () 语法错误 + return "null"; + } + List literals = new ArrayList<>(); + for (Object value : values) { + literals.add(literal(value, keyColumn)); + } + return String.join(", ", literals); + } + + private String literal(Object value, DbUtils.ColumnMetadata keyColumn) { + if (value == null) { + return "null"; + } + String typeName = keyColumn == null ? "" : keyColumn.typeName().toLowerCase(Locale.ROOT); + boolean numeric = typeName.contains("int") || typeName.contains("decimal") + || typeName.contains("numeric") || typeName.contains("money") + || typeName.contains("float") || typeName.contains("real"); + if (value instanceof Number) { + return String.valueOf(value); + } + String text = String.valueOf(value); + if (numeric) { + // 类型已校验为数字时才内联,拒绝把非数字塞进数字列 + if (!text.matches("-?\\d+(\\.\\d+)?")) { + throw new BusinessException("主键值类型不正确: " + text); + } + return text; + } + return dbUtils.toSqlStringLiteral(text); + } + + private void writeDeleteLog( + String userId, + String moduleCode, + String table, + String keyField, + int requested, + int deleted + ) { + // 技术日志当前阶段只输出结构化摘要,不落库(设计 §8:日志表不在本阶段范围内) + System.out.println("[delete-strategy] user=" + userId + + " target=" + moduleCode + + " table=" + table + + " key=" + keyField + + " requested=" + requested + + " deleted=" + deleted); + } + + private long toLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value == null) { + return 0; + } + try { + return Long.parseLong(String.valueOf(value)); + } catch (NumberFormatException exception) { + return 0; + } + } + + private String text(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + private String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new BusinessException(field + " 不能为空"); + } + return value.trim(); + } + + private void rollback(Connection connection, Exception cause) { + try { + connection.rollback(); + } catch (SQLException rollbackException) { + cause.addSuppressed(rollbackException); + } + } + + /** 模块删除目标:保存表元数据 + 主键列。 */ + private record ModuleTarget( + DbUtils.TableMetadata metadata, + DbUtils.ColumnMetadata keyColumn + ) { + } + + /** 计划中的一张表:按“表 + 主键字段 + 主键值”去重(设计 §6.2)。 */ + private static final class PlannedTable { + private final String key; + private final DbUtils.TableMetadata metadata; + private final DbUtils.ColumnMetadata keyColumn; + private final Set values = new LinkedHashSet<>(); + private final int depth; + private String moduleCode; + + PlannedTable(String key, DbUtils.TableMetadata metadata, + DbUtils.ColumnMetadata keyColumn, int depth) { + this.key = key; + this.metadata = metadata; + this.keyColumn = keyColumn; + this.depth = depth; + } + + String key() { + return key; + } + + DbUtils.TableMetadata metadata() { + return metadata; + } + + DbUtils.ColumnMetadata keyColumn() { + return keyColumn; + } + + Set values() { + return values; + } + + int depth() { + return depth; + } + + String moduleCode() { + return moduleCode; + } + + void moduleCode(String value) { + this.moduleCode = value; + } + } + + /** 删除计划:按“表 + 主键字段”归并,值去重;按深度倒序(子记录优先)删除。 */ + private static final class DeletePlan { + private final Map tables = new LinkedHashMap<>(); + + /** + * 并入一批待删记录。同一模块同一主键只进清单一次(设计 §5.1、§6.2)。 + * + * @param moduleCode 模块归属;显式删除行传 null(不跑模块 SQL 规则) + * @return 该表是否首次进入清单(用于决定是否继续向下展开 cascade) + */ + boolean add(DbUtils.TableMetadata metadata, DbUtils.ColumnMetadata keyColumn, + String moduleCode, List ids, int depth) { + String tableKey = metadata.requestedName().toLowerCase(Locale.ROOT) + + "|" + keyColumn.normalizedName(); + PlannedTable planned = tables.get(tableKey); + boolean created = false; + if (planned == null) { + planned = new PlannedTable(tableKey, metadata, keyColumn, depth); + tables.put(tableKey, planned); + created = true; + } + // 同一物理表既有模块归属又有显式删除时,保留模块归属(模块目标优先) + if (moduleCode != null && planned.moduleCode() == null) { + planned.moduleCode(moduleCode); + } + if (ids != null) { + for (Object id : ids) { + if (id != null) { + planned.values().add(id); + } + } + } + return created; + } + + boolean containsTable(String tableKey) { + return tables.containsKey(tableKey); + } + + String moduleCodeOfTable(String tableKey) { + PlannedTable planned = tables.get(tableKey); + return planned == null ? null : planned.moduleCode(); + } + + List tables() { + return new ArrayList<>(tables.values()); + } + + /** 深度大的先删(子记录优先),同深度按加入顺序。 */ + List orderedFromLeaf() { + List ordered = new ArrayList<>(tables.values()); + ordered.sort((left, right) -> Integer.compare(right.depth(), left.depth())); + return ordered; + } + + Set valuesOf(String tableName) { + String normalized = tableName.toLowerCase(Locale.ROOT); + for (PlannedTable planned : tables.values()) { + if (planned.metadata().requestedName().toLowerCase(Locale.ROOT).equals(normalized)) { + return planned.values(); + } + } + return Collections.emptySet(); + } + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java index 30527c9f..02da073d 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java @@ -31,6 +31,9 @@ public class DataSaveService { @Resource private DbUtils dbUtils; + @Resource + private DataDeleteService dataDeleteService; + public void save(List> requests) throws SQLException { if (requests == null || requests.isEmpty()) { throw new BusinessException("保存数据不能为空"); @@ -414,13 +417,12 @@ public class DataSaveService { throw new BusinessException("主键字段不能为空: " + tableName); } - executeRows( - connection, - table, - keyColumns, - "delete", - rows(request, "deletes") - ); + // 分阶段执行(设计 §6.2):updates → 删除检查 → 物理删除 → inserts,同一事务。 + // 删除走统一删除服务,显式删除与 cascade 展开合并去重后统一检查。 + // 普通表没有声明删除目标时沿用旧的逐行删除,保持既有行为。 + String deleteTarget = ParamUtils.getString(request, "delete_target"); + List> deleteRows = rows(request, "deletes"); + executeRows( connection, table, @@ -428,6 +430,17 @@ public class DataSaveService { "update", rows(request, "updates") ); + if (deleteTarget != null && !deleteTarget.isBlank() && !deleteRows.isEmpty()) { + executeDeletes(connection, request, tableName, keyField, deleteTarget, deleteRows); + } else { + executeRows( + connection, + table, + keyColumns, + "delete", + deleteRows + ); + } executeRows( connection, table, @@ -437,6 +450,60 @@ public class DataSaveService { ); } + /** + * 通过删除服务执行 saveobjt 的删除行(设计 §6.2)。 + * + *

删除目标必须显式声明,不允许以“有没有传 moduleCode”作隐式分支: + * {@code delete_target=module} 要求 {@code moduleCode},{@code delete_target=direct} + * 要求 {@code table} / {@code key_field}。两种目标都复用当前事务连接, + * 任一检查失败由外层统一回滚。 + */ + private void executeDeletes( + Connection connection, + Map request, + String tableName, + String keyField, + String deleteTarget, + List> deleteRows + ) { + String moduleCode = ParamUtils.getString(request, "moduleCode"); + List ids = new ArrayList<>(); + for (Map row : deleteRows) { + Object value = row.get(keyField); + if (value != null) { + ids.add(value); + } + } + if (ids.isEmpty()) { + return; + } + // 显式删除行统一表述为 {table, key_field, ids},与 cascade 展开结果按表+主键去重 + List> explicitDeletes = new ArrayList<>(); + explicitDeletes.add(Map.of( + "table", tableName, + "key_field", keyField, + "ids", new ArrayList<>(ids) + )); + + String target = deleteTarget; + if (DataDeleteService.TARGET_MODULE.equalsIgnoreCase(target)) { + if (moduleCode == null || moduleCode.isBlank()) { + throw new BusinessException("模块删除必须提供 moduleCode"); + } + } else if (DataDeleteService.TARGET_DIRECT.equalsIgnoreCase(target)) { + moduleCode = null; + } else { + throw new BusinessException("删除目标类型必须是 module 或 direct"); + } + + try { + dataDeleteService.deleteWithinTransaction( + connection, target, moduleCode, ids, explicitDeletes, null); + } catch (SQLException exception) { + throw new SaveObjectException(tableName, "delete", -1, exception); + } + } + private DbUtils.TableMetadata loadMetadata( Connection connection, String tableName, diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/FileService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/FileService.java index 7c657118..cad9f41c 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/FileService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/FileService.java @@ -48,6 +48,9 @@ public class FileService { @Resource private DataSource dataSource; + @Resource + private DataDeleteService dataDeleteService; + /** * 本地上传:逐个写磁盘 → 写 DB → 返回元数据列表 */ @@ -182,7 +185,11 @@ public class FileService { } /** - * 删除文件:查 bf_files → 删存储 → 删 DB 行 + * 删除文件:查 bf_files → 删存储 → 走统一删除执行器删 DB 行(设计 §6.3)。 + * + *

bf_files 属于无业务模块的技术表,用 target=direct 只做物理删除, + * 不触发 s_relation 与 s_delete_rule。外部对象操作不属于数据库事务: + * 对象删除失败时不删数据库行;数据库删除失败时保留可重试的清理记录。 */ public void delete(String subid) throws SQLException, IOException, InterruptedException { List> rows = dbUtils.loadData("bf_files", "subid = " + subid, null, null); @@ -199,8 +206,14 @@ public class FileService { deleteLocalFile(mapfilename); } - // 删 DB 行 - deleteDbRow(subid); + // 删 DB 行:复用统一直接表删除 + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + dataDeleteService.deleteDirect( + connection, "bf_files", "subid", List.of(subid), null); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } } private void deleteLocalFile(String mapfilename) throws IOException { @@ -248,19 +261,6 @@ public class FileService { } } - private void deleteDbRow(String subid) throws SQLException { - Connection connection = DataSourceUtils.getConnection(dataSource); - try { - String sql = "DELETE FROM [bf_files] WHERE [subid] = ?"; - try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setLong(1, Long.parseLong(subid)); - statement.executeUpdate(); - } - } finally { - DataSourceUtils.releaseConnection(connection, dataSource); - } - } - private String extension(String name) { if (name == null || !name.contains(".")) { return ""; diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class index 559592fc..9a055d80 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/FmsApiApplication.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class index 22cfa4af..a5187a9b 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/AuthProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class index 5ac35469..822c502c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Local.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class index aba3549e..feb115f3 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties$Oss.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class index 7ca39c05..9098305c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/FileStorageProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class index 37408c3f..e57213b4 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JacksonConfig.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class index 6c7b72f2..4d749e7b 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/AuthController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class index 25c4e52f..f26a14f2 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/DataController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class index cd9ccdb5..5bdd23f5 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/FileController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class index b441b83e..05b8fe4f 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgContext.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class index 73a3db58..01c250f1 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceFactory.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class index c5e35b10..a7514698 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager$PoolEntry.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class index 3c371400..c3c80f8d 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDataSourceManager.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class index 62e91cad..4aefdc8f 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfig.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class index 126443e6..9120d513 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigLoader.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class index cfe4f00c..9c18dc14 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseConfigWatcher.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class index b5cb3e35..4caaae3e 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties$Pool.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class index 6123a420..b88b12ec 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgDatabaseProperties.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class index 78d12c4f..31944e40 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/database/OrgRoutingDataSource.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class index e1560d86..e6a0569d 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/AuthenticationException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class index 94af84fa..13e0bdd3 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/BusinessException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class index 0eea0492..6dc0382c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class index e523a194..b1e39134 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/exception/SaveObjectException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class index 46d3a22b..653f5ecd 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/AuthService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class index db35bf62..415adb7b 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeFormat.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class index 9b1b0238..170e3b14 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$AutoCodeRule.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class index e8093600..b2d557df 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService$CodeCandidate.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class index 17968130..7344de39 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataSaveService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class index 28c8b8c9..631a4e27 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/DataService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class index a648b5bb..75cc612f 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class index d5977c54..d93ba655 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class index a72f53e7..c93d6300 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/FileService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class index 24b1e7d2..1fb10021 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/LoginLogService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class index 695baab7..38d3fc21 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class index f0537e84..2a5766aa 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ApiResponse.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class index 0ad5d9dd..178049c4 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfo.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class index fc329abc..334490a1 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ClientInfoExtractor.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class index 319f068e..4d0c2298 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class index 4b450936..e47bf3ef 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$PageResult.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class index 4ce69645..d64adc0c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$TableMetadata.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class index 0e8c2ddb..669b7f6d 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class index 23cd6142..e1bc47f1 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpRegionSearcher.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class index f20a9e5e..8e837371 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/IpUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class index e8ad6365..752775dc 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils$TokenClaims.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class index 6b2812aa..f2ae6fa9 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/JwtUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class index 12d7c118..779e1855 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/ParamUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class index b485b135..4b26a3bd 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/StringUtils.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class index aeec4597..df2a56e9 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/UuidV7Generator.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class index 39db6908..81bfb7ed 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class index f67aad8a..d7f0b038 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class index c6f83b73..b35764b1 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class index 06a6e877..aed62c84 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class index 0d4a11dc..40d4112c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class index 3679d9f8..81ba7abd 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class index a289499c..9ca7e76a 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.class differ diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 45a05b89..81929d53 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -2,6 +2,7 @@ cn\g3soft\fmsapi\service\LoginLogService.class cn\g3soft\fmsapi\controller\FileController.class cn\g3soft\fmsapi\utils\ParamUtils.class cn\g3soft\fmsapi\service\ExportService$PreparedExport.class +cn\g3soft\fmsapi\service\DataDeleteService.class cn\g3soft\fmsapi\utils\JwtUtils.class cn\g3soft\fmsapi\service\ExportService$1.class cn\g3soft\fmsapi\database\OrgDataSourceManager$PoolEntry.class @@ -22,12 +23,15 @@ cn\g3soft\fmsapi\controller\DataController.class cn\g3soft\fmsapi\database\OrgDatabaseConfigLoader.class cn\g3soft\fmsapi\database\OrgDatabaseConfig.class cn\g3soft\fmsapi\exception\BusinessException.class +cn\g3soft\fmsapi\service\DataDeleteService$DeletePlan.class cn\g3soft\fmsapi\config\FileStorageProperties.class +cn\g3soft\fmsapi\service\DataDeleteService$PlannedTable.class cn\g3soft\fmsapi\config\JacksonConfig.class cn\g3soft\fmsapi\service\FileService.class cn\g3soft\fmsapi\database\OrgDataSourceFactory.class cn\g3soft\fmsapi\service\DataSaveService$CodeCandidate.class cn\g3soft\fmsapi\database\OrgContext.class +cn\g3soft\fmsapi\service\DataDeleteService$ModuleTarget.class cn\g3soft\fmsapi\database\OrgDatabaseProperties$Pool.class cn\g3soft\fmsapi\utils\snowflake\idgen\IdGenerator.class cn\g3soft\fmsapi\service\DataSaveService$AutoCodeRule.class diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index aeb035af..dc1af92e 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -21,6 +21,7 @@ D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\exception\SaveObjec D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\FmsApiApplication.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ActiveSessionRegistry.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\AuthService.java +D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\DataDeleteService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\DataSaveService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\DataService.java D:\workspace\code\fms\fms-api\src\main\java\cn\g3soft\fmsapi\service\ExportHeadBuilder.java diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class index 3818e35d..b68ad01c 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class index 6310143e..acc383b6 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class index b2ce9c11..42ba670b 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class index 47e20269..67525ed0 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class differ diff --git a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue index 3d8efc8f..ec3e0e93 100644 --- a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue +++ b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue @@ -295,9 +295,12 @@ const resolvedWidth = computed(() => { /** 保存过程上下文:beforeSave 阶段准备的数据,buildSaveData / afterSave 阶段消费 */ let saveCtx = { isNew: false, row: null, extraUpdates: [] } -/** 模块保存表(s_module.b_save_table 优先,其次 b_view_table) */ +/** + * 模块保存表:只使用 s_module.b_save_table(设计 §6.1)。 + * 视图表通常不可写,回退到 b_view_table 会让写入在数据库层失败;为空时由 beforeSave 拒绝。 + */ function saveTableName() { - return String(moduleMeta.value?.b_save_table || moduleMeta.value?.b_view_table || '') + return String(moduleMeta.value?.b_save_table || '') } /** 构造完整合并行(含主键;checkbox→0/1、''→null 归一化),供保存前准备与 diff 使用 */ diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 7522264b..361bd3b4 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -1,9 +1,9 @@ + +