20260922172938

This commit is contained in:
oneao committed 2026-09-22 17:29:39 +08:00
1 parent 583984c24f
commit c8dad8d4a6
46 files changed
+3366 -1246

No files matched your search

@@ -30,6 +30,9 @@ import java.util.Set;
* 不读模块关系、不跑模块 SQL 规则。普通模块列表不允许传表名。</li>
* </ul>
*
* <p>另外,{@code saveobjt} 的删除行可以带 {@code dependent_deletes} 声明,把引用这些行的
* 依赖行一并删除(见 {@link #deleteDependents}):依赖关系由调用方声明,服务端不按表名猜。
*
* <p>计划、检查、删除在同一事务中完成(设计 §5.3):任一步骤失败整批回滚。
*/
@Service
@@ -178,6 +181,110 @@ public class DataDeleteService {
return deleted;
}
/**
* 依赖行删除:本表被删的行被别的表引用时,连引用行一起删(《FMS删除策略重构设计》§6.2)。
*
* <p>例如删除权限点({@code s_power})要连引用它的用户授权行({@code s_user_power})
* 一起删:授权行只记录“拥有”,没有外键约束,权限点没了、授权行留着就是幽灵授权 ——
* 权限判定会读到它,授权界面也无从解释。这类关系不登记在 {@code s_relation} 里
* (那是有模块归属的业务关系),也不由服务端按表名反查:调用方在请求里声明依赖表、
* 键列和取值列,这里按声明执行。表名只出现在请求里,服务端不认识具体业务表。
*
* <p>声明格式 {@code {table, key_field, source_field}}:对本表本次要删的每一行,
* 用取值列的值得出要删的键值,再删依赖表里键列等于该值的行。取值用本表这一行的列值
* 条件查出(与物理删除的 WHERE 同口径),所以调用方不需要先查一遍再拼删除行。
*
* @param parentTable 本表(正在删除的表)的元数据
* @param parentRows 本次要删除的行,列值即删除条件
* @param dependents 依赖声明列表
* @return 实际删除的依赖行数
*/
public int deleteDependents(
Connection connection,
DbUtils.TableMetadata parentTable,
List<Map<String, Object>> parentRows,
List<Map<String, Object>> dependents
) throws SQLException {
int total = 0;
for (Map<String, Object> dependent : dependents) {
String table = text(dependent.get("table"));
String keyField = text(dependent.get("key_field"));
String sourceField = text(dependent.get("source_field"));
// 声明不完整时直接拒绝:静默跳过等于把该清的依赖行留下,成了没人解释得了的孤儿行
if (table == null || keyField == null || sourceField == null) {
throw new BusinessException(
"依赖删除声明不完整,需要 table / key_field / source_field");
}
DbUtils.TableMetadata dependentTable = dbUtils.loadTableMetadata(connection, table);
DbUtils.ColumnMetadata keyColumn = requireKeyColumn(dependentTable, keyField);
DbUtils.ColumnMetadata sourceColumn = dbUtils.getColumn(parentTable, sourceField);
if (sourceColumn == null) {
throw new BusinessException("依赖删除的取值列不存在: "
+ parentTable.requestedName() + "." + sourceField);
}
List<Object> values = querySourceValues(
connection, parentTable, parentRows, sourceColumn);
if (values.isEmpty()) {
continue;
}
int deleted = deleteRecords(connection, dependentTable, keyColumn, values);
total += deleted;
writeDeleteLog(null, parentTable.requestedName(), table,
keyColumn.name(), values.size(), deleted);
}
return total;
}
/**
* 取出本表待删行在取值列上的值(去重)。
*
* <p>匹配条件与物理删除同口径:行内有本表可写列的列值做 AND;一行里没有任何本表列时
* 拒绝执行,和 {@code DbUtils.delete} 一样不允许从这条路径形成无条件匹配。
*/
private List<Object> querySourceValues(
Connection connection,
DbUtils.TableMetadata table,
List<Map<String, Object>> rows,
DbUtils.ColumnMetadata sourceColumn
) throws SQLException {
Set<Object> values = new LinkedHashSet<>();
for (Map<String, Object> row : rows) {
List<String> conditions = new ArrayList<>();
for (Map.Entry<String, Object> entry : row.entrySet()) {
DbUtils.ColumnMetadata column = columnOf(table, entry.getKey());
if (column == null || !column.writable()) {
continue;
}
conditions.add(dbUtils.quoteQualifiedIdentifier(column.name())
+ " = " + literal(entry.getValue(), column));
}
if (conditions.isEmpty()) {
throw new BusinessException("删除条件不能为空: " + table.requestedName());
}
String sql = "select distinct " + dbUtils.quoteQualifiedIdentifier(sourceColumn.name())
+ " from " + table.quotedName()
+ " where " + String.join(" and ", conditions);
for (Map<String, Object> found : dbUtils.loadDataBySql(connection, sql)) {
Object value = found.get(sourceColumn.name());
if (value != null) {
values.add(value);
}
}
}
return new ArrayList<>(values);
}
/** 按列名取元数据(大小写不敏感);不是本表列时返回 null,与物理删除忽略未知列同口径 */
private DbUtils.ColumnMetadata columnOf(DbUtils.TableMetadata table, String fieldName) {
if (fieldName == null) {
return null;
}
return table.columnsByName().get(fieldName.trim().toLowerCase(Locale.ROOT));
}
// ── target=module:计划 → 检查 → 执行 ──────────────────────────────────
private Map<String, Integer> executeModuleTarget(
@@ -25,6 +25,13 @@ import java.util.StringJoiner;
@Service
public class DataSaveService {
/**
* 依赖行删除声明(请求可选):本表本次要删的行被别的表引用时,连引用行一起删。
* 声明为 {@code [{table, key_field, source_field}]},由删除服务按声明执行
* (《FMS删除策略重构设计》§6.2)。表名只出现在请求里,服务端不按表名猜关系。
*/
private static final String DEPENDENT_DELETES = "dependent_deletes";
@Resource
private DataSource dataSource;
@@ -430,6 +437,15 @@ public class DataSaveService {
"update",
rows(request, "updates")
);
// 依赖行随本表删除:请求显式声明依赖表(如删权限点连引用它的授权行一起删),
// 由删除服务按声明查出引用行的键值再删(设计《删除策略》§6.2)。声明通用 ——
// 服务端不认识具体表名,依赖关系由调用方声明,不做“按表名猜关系”的特殊处理。
// 依赖行先于父行删除(设计 §5.3),所以放在物理删除之前;本表没有删除行时
// 没有依赖可清,直接跳过。与本次删除同事务,任一步失败整体回滚。
List<Map<String, Object>> dependentDeletes = rows(request, DEPENDENT_DELETES);
if (!deleteRows.isEmpty() && !dependentDeletes.isEmpty()) {
executeDependentDeletes(connection, table, deleteRows, dependentDeletes);
}
if (deleteTarget != null && !deleteTarget.isBlank() && !deleteRows.isEmpty()) {
executeDeletes(connection, request, tableName, keyField, deleteTarget, deleteRows);
} else {
@@ -450,6 +466,25 @@ public class DataSaveService {
);
}
/**
* 执行请求声明的依赖行删除(设计《删除策略》§6.2)。
*
* <p>具体匹配与删除走删除服务:那里有物理删除执行器、主键类型校验和删除日志,
* 与其它删除路径同一套实现,不在这里再写一份。
*/
private void executeDependentDeletes(
Connection connection,
DbUtils.TableMetadata table,
List<Map<String, Object>> deleteRows,
List<Map<String, Object>> dependentDeletes
) {
try {
dataDeleteService.deleteDependents(connection, table, deleteRows, dependentDeletes);
} catch (SQLException exception) {
throw new SaveObjectException(table.requestedName(), "delete", -1, exception);
}
}
/**
* 通过删除服务执行 saveobjt 的删除行(设计 §6.2)。
*
@@ -29,7 +29,9 @@ import java.util.regex.Pattern;
* 三层,与《FMS新系统核心表结构设计》第 14 节一一对应:
* 1. 动作权限(14.1 / 14.2):s_power(action.{模块}.{动作})+ s_user_power,白名单语义;
* 2. 字段权限(14.3):s_user_field_power 的 b_view / b_export,无记录按模块默认(可看可导出);
* 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND。
* 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND;
* 按范围判的规则所用的字段取自模块配置 s_module.b_scope_field(记录归属人字段),
* self 直接比该字段,dept / dept_tree 由当前用户反查部门后再比。
*/
@Service
public class SqlPermissionService {
@@ -107,7 +109,7 @@ public class SqlPermissionService {
* 无生效规则返回 null,表示该模块无数据范围限制。
*/
public String buildDataScopeCondition(String moduleId, String userId, String operation) {
String sql = "SELECT [b_operation], [b_scope_type], [b_scope_field], [b_condition_sql] "
String sql = "SELECT [b_operation], [b_scope_type], [b_condition_sql] "
+ "FROM [s_user_data_power] "
+ "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1 "
+ "AND ([b_operation] = ? OR [b_operation] = '*') "
@@ -128,20 +130,29 @@ public class SqlPermissionService {
List<Map<String, Object>> effective = exact.isEmpty() ? wildcard : exact;
String deptId = null;
// 判断字段只来自模块配置(s_module.b_scope_field,记录归属人字段):同一个模块的
// 所有用户必须用同一字段判范围,所以授权行上的同名列不参与判定。按需读一次,
// 只有按范围判的规则(self / dept / dept_tree)才需要它
String moduleScopeField = null;
List<String> fragments = new ArrayList<>();
for (Map<String, Object> row : effective) {
String scopeType = text(row.get("b_scope_type")).toLowerCase(Locale.ROOT);
String scopeField = text(row.get("b_scope_field"));
switch (scopeType) {
case "all", "" -> {
// 全部数据:不追加条件
}
case "self" -> {
String field = requireScopeField(scopeField);
if (moduleScopeField == null) {
moduleScopeField = loadModuleScopeField(connection, moduleId);
}
String field = requireScopeField(moduleScopeField);
fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(userId));
}
case "dept", "dept_tree" -> {
String field = requireScopeField(scopeField);
if (moduleScopeField == null) {
moduleScopeField = loadModuleScopeField(connection, moduleId);
}
String field = requireScopeField(moduleScopeField);
if (deptId == null) {
deptId = loadDeptId(connection, userId);
}
@@ -197,6 +208,20 @@ public class SqlPermissionService {
return rows.isEmpty() ? null : text(rows.get(0).get("b_dept_id"));
}
/**
* 模块的数据范围字段(s_module.b_scope_field,记录归属人字段如 b_inputuser_id)。
* 配在模块上而不是授权行上:同一个模块的所有用户必须用同一字段判范围。
* 未配置时返回空串,由 requireScopeField 明确报错,不静默放行。
*/
private String loadModuleScopeField(Connection connection, String moduleId) throws SQLException {
List<Map<String, Object>> rows = query(
connection,
"SELECT [b_scope_field] FROM [s_module] WHERE [b_id] = ?",
moduleId
);
return rows.isEmpty() ? "" : text(rows.get(0).get("b_scope_field"));
}
private String loadDeptPath(Connection connection, String deptId) throws SQLException {
List<Map<String, Object>> rows = query(
connection,