# 运行时：BusinessBaseController 及相关组件

`xlyEntry/src/main/java/com/xly/web/businessweb/` 是元数据驱动运行时所在位置。本页是维护人员理解大部分通用表单运行时 controller 和 service 的地图。

## 承重 controller

| 类 | 包 | 角色 | 最常引用端点 |
|---|---|---|---|
| `BusinessBaseController` | `web/businessweb/` | 元数据驱动模块的通用 CRUD。每个表单的默认 API 接口面。 | `/business/getModelBysId/{moduleId}`、`/business/getBusinessDataByFormcustomId/{formId}`、`/business/addUpdateDelBusinessData`、`/business/getSelectDataBysControlId/{controlId}` |
| `BusinessConfigformController` | `web/businessweb/` | 已有表单的每用户 / 每组显示定制，不是基础表单定义 CRUD。 | `/configform/getConfigformData/{moduleId}`、`/configform/sHandleConfigform`、`/configform/sCopyConfigform` |
| `GdsmoduleController` | `web/systemweb/` | builder 侧使用的模块树和模块定义 CRUD。 | `/gdsmodule/getModuleTreePro`、`/gdsmodule/addGdsmodule`、`/gdsmodule/updateGdsmodule` |
| `GdsconfigformController` | `web/systemweb/` | form-master 和 form-slave 元数据 CRUD。 | `/gdsconfigform/*` 下端点 |
| `GdsconfigtbController` | `web/systemweb/` | 虚拟表 master / slave 元数据 CRUD。 | `/gdsconfigtb/*` 下端点 |
| `BusinessTreeGridController` | `web/businessweb/` | 树表格端点。当前分支实现了存储过程支撑路径，普通 `getTreeGrid` service 方法仍是空实现。 | `/treegrid/getTreeGridByPro/{formId}` |
| `GenericProcedureCallController` | `web/businessweb/` | 按名称 + 参数通用调用存储过程。 | `/procedureCall/doGenericProcedureCall` |
| `ConfigformPanelController` | `web/businessweb/` | `gdsconfigformpanel` 中的面板布局持久化。 | `/panel/get/{sFormId}`、`/panel/save/{sFormId}` |
| `CheckFlowController` | `web/businessweb/` | **空壳。** 类文件只有 22 行：一个 `@RestController @RequestMapping(value="/checkflow")`，没有 handler 方法。`/checkflow/*` 返回 404。真正的工作流审核 / 驳回 / 完成 URL 在 `CurrencyFlowController`（位于 `xlyFlow`，经 xlyEntry context-path 暴露）中；见 [Activiti 集成](activiti.md#modeler-暴露的-urlxlyflow-controller-挂在-xlyentry-端口上)。 | 无，空类 |
| `BusinessModelCenterController` | `web/businessweb/` | FROUNT 首页的“KPI 工作中心”看板：聚合标记为 `gdsmodule.bUnTask=1` 的模块上的未清任务。尽管 UI 像工作流，**它不是 Activiti 驱动**；它读取 `gdsmodule` 行，按 `sUnType` ∈ {`Pending`, `PendingCheck`, `MyWarning`} 分桶，并按用户缓存。见下面的 [KPI 工作中心](#kpi-工作中心front-端首页-dashboard)。 | `/modelCenter/getModelCenter`、`/modelCenter/getModelCenterCalculation` |

注意 controller 分布在**两个包**中：`businessweb/` 承载运行时端点，`systemweb/` 承载 builder 侧元数据 CRUD 端点。两者都编译进同一个 `xlyEntry` WAR。

## KPI 工作中心（FROUNT 首页看板） {#kpi-工作中心front-端首页-dashboard}

用户进入 FROUNT（`http://<host>:8598/indexPage`）时，首页会显示一个标题为 **`KPI监控`** 的卡片。尽管名字叫 KPI，它不是分析意义上的 KPI 看板：没有目标、没有图表、没有指标度量。它是一个**未清任务聚合器**：把“需要你处理的事”按角色和业务流程汇总成统一入口。

处理链路：

- `POST /xlyEntry/modelCenter/getModelCenter` → `BusinessModelCenterController.java:44-48` → `BusinessModelCenterServiceImpl.getKPIModelList`（很薄的一层 wrapper）→ `xlyBusinessService` 中的 `BusinessModelKpiServiceImpl.getKPIModelList`。
- `POST /xlyEntry/modelCenter/getModelCenterCalculation` 会重新计算数量，并清掉 `getKpiModelByUser` 缓存区域。

`getModelCenter` 的 Javadoc 称它为**“初次获取KPI工作中心”**，这是框架内部对该接口面的命名。

### 数据来源：`gdsmodule` 未清任务标记

KPI 工作中心读取的是 `gdsmodule`，不是 Activiti 的 `act_*` 表。参与看板的每个模块都会在自己的 `gdsmodule` 行上设置四个列：

| 列 | 注释 | 含义 |
|---|---|---|
| `bUnTask` | `是否增加到未清` | `1` 表示纳入看板，`0` 表示忽略 |
| `sUnType` | `未清类型` | 分桶：`Pending`、`PendingCheck`、`MyWarning` 之一 |
| `sChineseUnMemo`、`sEnglishUnMemo`、`sBig5UnMemo` | `未清描述` | 各语言显示标签 |

实时 dev DB 中，`bUnTask = 1` 的模块有 **92** 行，分布如下：

| `sUnType` | 数量 | 前端分桶标签 |
|---|---:|---|
| `Pending` | 79 | 待处理事务 |
| `PendingCheck` | 1 | 发起新事务 |
| `MyWarning` | 3 | 我的预警报表 |
| NULL | 9 | 未标记，排除 |

对每个已标记模块，`BusinessModelKpiServiceImpl` 会执行该模块的每用户数量查询，并按两种维度聚合：

- **角色**（UI 中“按角色”）：JOIN `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify`，把当前用户映射到角色，再按角色拆分数量。
- **流程**（UI 中“按流程”）：`估价管理流程` / `订单生产流程` 这类标签来自 `gdsmodule` 树中的父模块。每个业务流程是一个父模块，下面包含 1 到 N 个有序子模块。UI 中 “01/04、02/04 …” 的步骤编号，就是子模块在父流程下的 `iOrder`。

### 缓存

`@Cacheable(value="getKpiModelByUser", key=...)` 会缓存每用户结果；`getModelCenterCalculation` 以及任何修改 `gdsmodule` 的路径都会通过 [`CleanRedisServiceImpl`](cache-invalidation.md) 失效这个区域。由于 `bUnTask` / `sUnType` 可通过 BACK 的“系统模块配置”页面编辑，维护人员要给看板加一个新的“未清任务”，改的是元数据，不是 Java。

### 为什么不是 Activiti

Activiti 的职责是**审批工作流**：当某行需要经过 N 步 `act_re_procdef` 图签核时，`act_ru_task` / `biz_todo_item` 表会按处理人保存待办任务。它和 KPI 工作中心是不同接口面，虽然两者都会向用户展示“需要处理的事”。

当前 dev DB 中，`biz_todo_item` 和 `biz_flow` 都是空表（0 行），但 KPI 工作中心仍能显示非零数量。这是两套系统相互独立的实证。启用 Activiti 流程的部署会通过工作流 controller 和单独的流程面板暴露这些任务，而不是通过 KPI 工作中心。

## 五键读取 {#five-key-read}

对任何元数据驱动模块，请求生命周期（见[概念 → 请求生命周期](../../concepts/request-lifecycle.md)）可归结为：

```java
public Map<String, Object> getModelBysId(Map<String, Object> map) {
    // 1. formData：gdsconfigformmaster 按 sParentId=sModelsId 过滤，
    //    LEFT JOIN gdsconfigformpersonalize（每租户），再为每个 master 行
    //    加载 gdsconfigformslave + gdsconfigformcustomslave 覆盖。
    //    gdsmodule 本身只通过 id 引用，不被 SELECT。
    List<Map<String, Object>> formList = this.getModelConfigByModleId(map);
    // 2. gdsformconst：仅按 sParentId；sLanguage 决定返回哪列标签；不按租户过滤。
    List<Map<String, Object>> fList = businessGdsconfigformsService.getFormconstData(qMap);
    // 3. sysjurisdiction：每用户授权，JOIN sftlogininfojurisdictiongroup
    //    + sisjurisdictionclassify；ADMIN 跳过。
    //    虽然返回 map key 叫 `gdsjurisdiction`，实际源表是 sysjurisdiction。
    List<Map<String, Object>> jList = businessGdsconfigformsService.getJurisdictionData(qMap);
    // 4. sysbillnosettings（每租户、每表单）。
    Map<String, Object> billnosettingMap = businessGdsconfigformsService.getBillnosettingData(param);
    // 5. sysreport（每租户、每表单）。
    List<Map<String, Object>> reportList = printReportService.getReportData(qMap);
    return composite(formList, fList, jList, billnosettingMap, reportList);
}
```

先读 `BusinessBaseServiceImpl.java` 中这个方法；运行时其余部分都是它的变体。

## 返回的五键复合体

| Key | 来源 | 前端用途 |
|---|---|---|
| `formData` | `gdsconfigformmaster`（按 `sParentId = sModelsId` 过滤）⋈ `gdsconfigformpersonalize` 覆盖；每个 master 行再加载 `gdsconfigformslave` + `gdsconfigformcustomslave`。`gdsmodule` 只作为 id 来源被引用，不参与 join。 | 表单布局 |
| `gdsformconst` | `gdsformconst`（仅按 `sParentId` 过滤；`sLanguage` 决定返回哪列标签；不按租户过滤） | 表单级常量、下拉标签 |
| `gdsjurisdiction` | `sysjurisdiction`（JOIN `sftlogininfojurisdictiongroup` + `sisjurisdictionclassify` 得到每用户 / 用户组授权）；ADMIN 跳过。**注意：** map key 名称 `gdsjurisdiction` 有误导性，`gdsjurisdiction` 是配置侧动作目录表；这里读取的每用户授权实际来自 `sysjurisdiction`。 | 按钮 / 数据权限 |
| `billnosetting` | `sysbillnosettings`（每租户、每表单） | 单据编号规则 |
| `report` | `sysreport`（每租户、每表单） | 打印模板 |

## 保存端点

`POST /business/addUpdateDelBusinessData` 把新增 + 更新 + 删除打包为一个事务批次。前端为每行提供 `sTable` 和 `column` map：

```json
{
  "addData":    [{"sTable": "<table>", "column": {"sId": "", "...": "..."}}],
  "updateData": [{"sTable": "<table>", "column": {"sId": "", "...": "..."}}],
  "delData":    [{"sTable": "<table>", "column": {"sId": "", "...": "..."}}]
}
```

基础新增 / 更新路径总是通过 `BusinessBaseServiceImpl.addBusinessData` / `updateBusinessData`（`xlyBusinessService/.../BusinessBaseServiceImpl.java:1014` 和 `:1250`），再委托 `businessBaseDao.add(map)` / `businessBaseDao.update(map)`，对 `sTable` 命名的表执行操作。

`gdsmodule.sSaveProName`（及其兄弟列 `sSaveProNameBefore`）**不是**替换基础路径的二选一分支；它命名的是叠加在基础路径之上的额外存储过程钩子：保存后 / 保存前由 `BusinessBaseServiceImpl.java:1824` 的 `checkUpdate(...,"sSaveProName")` 和 `CheckSaveServiceImpl.java` 分发。`AddDelUpdCommonServiceImpl`（`@Service("addDelUpdCommonService")`）是另一套可复用 `insertByMap` / `updateByMap` / `delByMap` / `addBatch` helper，被工单计划、OEE、多报价、订单采购等领域 service 使用；它**不是** `addUpdateDelBusinessData` 的默认新增 / 更新路径。

## 多租户边界

每个 controller 方法的第一条非平凡语句都是：

```java
RequestAddParamUtil.me().addParams(params, userInfo);
```

这是把用户 session 身份（`sBrandsId`、`sSubsidiaryId`、`sBrId`、`sSuId`、`sLoginId`、`sLanguage`、`sTeamId`、`sMachineId` 及其他 key）注入下游 `params` map 的单点。任何引用 `#{sBrandsId}` / `#{sSubsidiaryId}` 的 MyBatis 查询或存储过程调用都会从这里自动获得作用域。

新的 controller 方法如果不调用 `RequestAddParamUtil`，就是**多租户 bug**。

## 需要审计的安全关注点

1. **`addUpdateDelBusinessData` 中的 `sTable` 校验：已确认缺失。** 前端直接命名目标表，而运行时**不会**交叉检查传入表是否属于该表单授权的支撑表。`BusinessBaseServiceImpl.sTableNameList`（162-169 行）是多租户作用域绕过列表（四张全局框架元数据表，写入时剥掉 `sBrandsId` / `sSubsidiaryId`；见 165 行 `//不需要公司子公司的表` 注释），不是支撑表白名单。整个流程里唯一的模块 / 表交叉检查是 1768 行的硬编码特例（`mftproductionplanslave`）。确实存在一些缓解措施（`RequestAddParamUtil` 的租户作用域、可选的保存前 / 后存储过程校验），但它们都不是支撑表白名单。完整追踪见[切片 1 follow-up](../../slices/01-hello-world.md#open-verification-items)。
2. **ADMIN 绕过权限。** `BusinessBaseServiceImpl` 对 `UserType.ADMIN` 完全跳过 `gdsjurisdiction` 加载。ADMIN 账号治理必须来自应用外部。

## “通用 CRUD”在实践中意味着什么

“一个 controller 写任意表中的任意行”是 xly 数据驱动设计的核心动作，也会把风险集中到少数路径上：

- **`BusinessBaseServiceImpl` 已经约 3,900 行**，其中缠在一起的逻辑包括每租户作用域绕过列表、特定表硬编码（第 1768 行的 `mftproductionplanslave`）、保存前 / 保存后钩子分发、以及由 `sTable` 驱动的写入路由。每个 bug fix 都必须穿过这个大类。
- **它是整个业务运行时的单点故障。** `addUpdateDelBusinessData` 中的回归会同时破坏所有租户的所有表单保存。模块专用 controller 可以把爆炸半径限制在一个模块内；通用 controller 做不到。
- **`Map<String, Object>` 没有类型系统。** 前端传来一袋 key/value。运行时相信 key 与列名匹配、value 能转换成列类型。不匹配时通常在 DAO 层抛 `BadSqlGrammarException`，离错误来源已经很远。这里没有 schema-aware 的请求校验。
- **可发现性差。** “哪些端点会写 `mftproductionplanslave`？”不能靠 IDE find-usages 回答。真实答案是：任何调用 `BusinessBaseServiceImpl.addBusinessData` 且把 `sTable` 设为 `mftproductionplanslave` 的 controller，也就是几乎所有通用保存入口。

通用模式让数据驱动论点成立；它也是新增模块几乎免费的原因，**同时**也是修改运行时几乎从不免费的原因。

## 缓存失效

BACK 保存元数据变更时，保存 service 会同步调用 `BusinessCleanRedisData.delCleanRedisData*`，进而触发 `CleanRedisServiceImpl` 上相关缓存区域的 `@CacheEvict`。另有一个名字相近的 JMS 路径（`ConsumerChangeGdsModuleThread`），但它通过存储过程做基础数据合并，不做缓存失效。完整说明见[元数据变更后的缓存失效](cache-invalidation.md)，包括跨节点一致性问题。
