Files
workspace/code/fms/FMS删除策略重构设计.md
T
2026-09-22 17:29:39 +08:00

19 KiB
Raw Blame History

FMS 删除策略重构设计

版本:v1.2

本文取代此前的删除规则设计和改造清单。本文只确定架构、边界和实施思路,具体代码由 AI 按本文执行。

设计目标:操作简单、执行逻辑简单、业务控制以 SQL 为主;第一期只做物理删除,不做归档。

1. 重构结论

删除能力保留两个来源,但统一放到一个“删除策略”入口下:

删除策略
├── 关联删除处理
│   └── 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_* 审计字段

不保留以下字段:

b_scope_type
b_scope_id
b_hook
b_kind
b_predicate

s_delete_rule 不覆盖无模块的直接表删除。技术表如果需要业务保护,由拥有该表的专用服务在调用删除前执行自己的检查;第一阶段不把表名、服务名和模块编码再抽象成一套通用作用域。

4.2 SQL 契约

模块删除规则 SQL 只表达“哪些记录不允许删除”:

返回至少一行 → 规则不通过
返回零行     → 规则通过

SQL 接收当前模块本次待删除的 ID 集合,统一使用 :ids 作为模板标记。它不是 JDBC 命名参数,执行前由删除引擎根据模块主键类型替换为经过校验和转义的字面量 IN 列表;第一阶段不引入表值参数或 OPENJSON。配置者可以自由使用本模块字段、其他表、聚合、子查询和连接条件。

示例语义:

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. 删除引擎

删除引擎分为三个职责,但对前端暴露一个删除动作:

删除服务
├── 删除计划器:仅对模块目标展开 cascade,形成模块和记录清单
├── 删除检查器:仅对模块目标执行 s_delete_rule 和 restrict 检查
└── 删除执行器:子记录优先,物理删除,提交审计日志

直接表目标不经过前两个组件,经过同一个删除执行器。这样既不漏掉文件、配置表等非模块删除,也不会让直接删除意外触发某个表名猜出来的模块规则。

5.1 删除计划

从用户指定的模块和 ID 开始,沿启用的 cascade 关系递归展开:

  • 同一模块、同一主键只进入清单一次;
  • 关系图出现环路时返回配置错误;
  • 设置最大展开深度,防止错误配置造成无限递归;
  • 记录每个模块将删除的数量,供日志和后续预览使用。

5.2 删除检查

对完整删除清单执行:

  1. 对每个模块运行启用的 s_delete_rule;
  2. 对每条 restrict 入边检查外部引用;
  3. 任何规则或关系失败,整批拒绝;
  4. 错误按“模块 SQL 规则 / 外部引用关系”分类返回。

删除清单内的引用方视为本次一起删除,不触发外部引用保护。这是主单、明细和中间表一起删除时的必要语义。

直接表目标没有模块删除清单,因此不执行上述两类检查;它只返回数据库删除错误或专用服务的清理错误。

5.3 删除执行

第一阶段不做独立的“预览后确认”两阶段操作。前端可以先弹一次普通确认框,后端在同一个事务中完成计划、检查和删除:

开启事务
  → 生成删除清单
  → 执行 SQL 规则
  → 执行 restrict 检查
  → 子记录到主记录物理删除
  → 写审计日志
提交事务

任一步骤失败都回滚整批。以后需要删除预览时增加 dryRun 调用即可,不能改变正式删除流程。

直接表删除省略“生成删除清单 / SQL 规则 / restrict”三步,但仍在同一事务中执行数据库删除和审计。文件服务的外部对象操作不属于数据库事务:对象删除失败时不删数据库行;数据库删除失败时保留可重试或补偿的清理记录。

6. 调用路径

6.1 模块列表删除

列表删除通过统一删除服务处理:

前端传 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 展开的同一记录按解析后的“表 + 主键字段 + 主键值”去重;同一物理记录同时以模块目标和直接目标提交时直接报冲突,不执行两次;
  • 任一检查失败,主表更新、明细删除和其他变更整体回滚。

这样列表删除和表单删行不会形成两套规则。

依赖行删除由调用方在请求里声明,服务端不按表名猜:

{
  "table": "s_power",
  "key_field": "b_object_type,b_object_id",
  "deletes": [{ "b_object_type": "module", "b_object_id": "sea" }],
  "dependent_deletes": [
    { "table": "s_user_power", "key_field": "b_power_id", "source_field": "b_id" }
  ]
}

dependent_deletes 的每一项是 {table, key_field, source_field}:对本表本次要删的每一行,用 source_field 的取值去删 table 中 key_field 等于该值的行;取值由后端按本行的列值条件查出(与物理删除的 WHERE 同口径),调用方不需要先把键值查出来再拼删除行。依赖行先于父行删除,与 cascade 同一执行顺序。

它解决“非模块表之间的引用清理”(例如权限点 s_power → 用户授权行 s_user_power,设计文档 §14.6):这类关系不登记在 s_relation(那是有模块归属的业务关系),也不能由服务端按表名反查——表名只出现在请求里,服务端不认识具体业务表。

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. 前端配置入口

模块管理的业务能力建议调整为:

数据行为
├── 删除策略
│   ├── 关联删除处理
│   └── 删除 SQL 规则
└── 自动编码

“模块关联”可以继续作为关系字段映射的维护面板,但删除行为只能有一个编辑来源。若删除策略页面展示关联行,则模块关联页面中的删除行为列改为只读或跳转。

删除 SQL 规则面板只需要:

  • 新增、编辑、删除、启用/停用;
  • SQL 文本编辑器;
  • 固定提示文案;
  • 执行顺序;
  • 最近修改信息。

删除以下旧概念和前端联动:

勾条件
引用规则
refRules
relationScopeId
b_scope_type
b_scope_id
b_hook

同时必须删除现有实现中的以下联动,否则后端允许配置 cascade 也不会生效:

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 或通用规则字段。