# The runtime: BusinessBaseController & friends

`xlyEntry/src/main/java/com/xly/web/businessweb/` is where the
metadata-driven runtime lives. This page is the maintainer's map of the
controllers and services that carry most of the generic form runtime.

## The load-bearing controllers

| Class | Package | Role | Most-cited endpoints |
|---|---|---|---|
| `BusinessBaseController` | `web/businessweb/` | Universal CRUD for metadata-driven modules. The default API surface for every form. | `/business/getModelBysId/{moduleId}`, `/business/getBusinessDataByFormcustomId/{formId}`, `/business/addUpdateDelBusinessData`, `/business/getSelectDataBysControlId/{controlId}` |
| `BusinessConfigformController` | `web/businessweb/` | Per-user / per-group display customization for an existing form, not the base form-definition CRUD. | `/configform/getConfigformData/{moduleId}`, `/configform/sHandleConfigform`, `/configform/sCopyConfigform` |
| `GdsmoduleController` | `web/systemweb/` | Module-tree and module-definition CRUD used by the builder side. | `/gdsmodule/getModuleTreePro`, `/gdsmodule/addGdsmodule`, `/gdsmodule/updateGdsmodule` |
| `GdsconfigformController` | `web/systemweb/` | Form-master and form-slave metadata CRUD. | endpoints under `/gdsconfigform/*` |
| `GdsconfigtbController` | `web/systemweb/` | Virtual-table master/slave metadata CRUD. | endpoints under `/gdsconfigtb/*` |
| `BusinessTreeGridController` | `web/businessweb/` | Tree-grid endpoints. In this branch, the proc-backed path is implemented; the plain `getTreeGrid` service method is still a stub. | `/treegrid/getTreeGridByPro/{formId}` |
| `GenericProcedureCallController` | `web/businessweb/` | Generic stored-procedure invocation by name + parameters. | `/procedureCall/doGenericProcedureCall` |
| `ConfigformPanelController` | `web/businessweb/` | Panel-layout persistence in `gdsconfigformpanel`. | `/panel/get/{sFormId}`, `/panel/save/{sFormId}` |
| `CheckFlowController` | `web/businessweb/` | **Empty shell.** The class file is 25 lines: just a `@RestController @RequestMapping(value="/checkflow")` with no handler methods. `/checkflow/*` returns 404. The actual workflow approve/reject/complete URLs live in `CurrencyFlowController` (in `xlyFlow`, served via xlyEntry's context-path) — see [Activiti integration](activiti.md#urls-the-modeler-exposes-xlyflow-controllers-on-xlyentrys-port). | (none — empty class) |
| `BusinessModelCenterController` | `web/businessweb/` | The "KPI Work Center" home dashboard on FROUNT — open-task aggregation across modules tagged with `gdsmodule.bUnTask=1`. **Not Activiti-driven** despite the workflow-flavoured UI; reads from `gdsmodule` rows partitioned by `sUnType` ∈ {`Pending`, `PendingCheck`, `MyWarning`}. Cached per user. See [The KPI Work Center](#the-kpi-work-center-front-end-home-dashboard) below. | `/modelCenter/getModelCenter`, `/modelCenter/getModelCenterCalculation` |

Note that the controllers split across **two packages**: `businessweb/`
hosts the runtime-facing endpoints, while `systemweb/` hosts the
metadata-CRUD endpoints used by the builder side. Both compile into the
same `xlyEntry` WAR.

## The KPI Work Center (front-end home dashboard)

When a user lands on FROUNT (`http://<host>:8598/indexPage`), the home
page shows a card titled **`KPI监控`** ("KPI Monitor"). Despite the
name, this is *not* a KPI dashboard in the analytics sense — there
are no targets, no charts, no measurements. It's an **open-task
aggregator**: a unified inbox of "things you owe action on", grouped
by role and by business flow.

The handler:

- `POST /xlyEntry/modelCenter/getModelCenter` →
  `BusinessModelCenterController.java:44-48` →
  `BusinessModelCenterServiceImpl.getKPIModelList` (a thin wrapper) →
  `BusinessModelKpiServiceImpl.getKPIModelList` in `xlyBusinessService`.
- `POST /xlyEntry/modelCenter/getModelCenterCalculation` recomputes
  the counts and evicts the cache region `getKpiModelByUser`.

The Javadoc on `getModelCenter` calls it **"初次获取KPI工作中心"**
("initial fetch of KPI Work Center") — that's the framework's
internal name for this surface.

### Data source — `gdsmodule` open-task tagging

The KPI Work Center reads from `gdsmodule`, not from
Activiti's `act_*` tables. Every module that participates in the
dashboard sets four columns on its `gdsmodule` row:

| Column | Comment | Meaning |
|---|---|---|
| `bUnTask` | `是否增加到未清` ("add to open list?") | `1` = include in the dashboard, `0` = ignore |
| `sUnType` | `未清类型` ("open type") | Bucket: one of `Pending`, `PendingCheck`, `MyWarning` |
| `sChineseUnMemo`, `sEnglishUnMemo`, `sBig5UnMemo` | `未清描述` ("open description") | Display label per language |

In the live dev DB, `bUnTask = 1` is set on **92** module rows, split:

| `sUnType` | Count | Frontend bucket label |
|---|---:|---|
| `Pending` | 79 | 待处理事务 (pending tasks) |
| `PendingCheck` | 1 | 发起新事务 (initiate new) |
| `MyWarning` | 3 | 我的预警报表 (my warnings) |
| (NULL) | 9 | (untagged — excluded) |

For each tagged module, `BusinessModelKpiServiceImpl` runs the
module's per-user count query and aggregates results by:

- **Role** (按角色 in the UI): joins `sftlogininfojurisdictiongroup`
  ⋈ `sisjurisdictionclassify` to map the calling user to roles, then
  partitions counts by role.
- **Flow** (按流程 in the UI): the labels like `估价管理流程` /
  `订单生产流程` come from the module's parent in the `gdsmodule`
  tree — each business flow is a parent module containing 1-N
  ordered child modules. The "01/04, 02/04…" step numbering in the
  UI is the child module's `iOrder` within its parent flow.

### Cache

`@Cacheable(value="getKpiModelByUser", key=…)` caches the per-user
result; `getModelCenterCalculation` and any
`gdsmodule`-changing path invalidate this region via
[`CleanRedisServiceImpl`](cache-invalidation.md). With `bUnTask` /
`sUnType` editable through BACK's 系统模块配置 screen, a maintainer
adding a new "open task" to the dashboard does so by editing
metadata, not Java.

### Why not Activiti?

Activiti's job is **approval workflow** — when a row needs sign-off
through an N-step `act_re_procdef` graph, the `act_ru_task` /
`biz_todo_item` tables hold pending tasks per assignee. That is a
*different* surface from the KPI Work Center, even though both
present "things to do" to the user.

In this dev DB, `biz_todo_item` and `biz_flow` are both empty
(0 rows) — yet the KPI Work Center still shows non-zero counts.
That's the empirical proof that the two systems are independent.
A deployment with active Activiti flows would surface those tasks
through `CheckFlowController` (`/checkflow/*`) and a separate flow
panel — not through the KPI Work Center.

## The five-key read

For any metadata-driven module, the request lifecycle (see
[Concepts → request lifecycle](../../concepts/request-lifecycle.md))
boils down to:

```java
public Map<String, Object> getModelBysId(Map<String, Object> map) {
    // 1. formData — gdsconfigformmaster filtered by sParentId=sModelsId,
    //    LEFT JOIN gdsconfigformpersonalize (per-tenant), then for each
    //    master row load its gdsconfigformslave + gdsconfigformcustomslave
    //    overlays. gdsmodule itself is referenced only by id, not SELECT-ed.
    List<Map<String, Object>> formList = this.getModelConfigByModleId(map);
    // 2. gdsformconst — by sParentId only; sLanguage selects which label
    //    column to return; NOT tenant-scoped.
    List<Map<String, Object>> fList = businessGdsconfigformsService.getFormconstData(qMap);
    // 3. sysjurisdiction (per-user grants joined to sftlogininfojurisdictiongroup
    //    + sisjurisdictionclassify); skipped for ADMIN.
    //    Returned under map key `gdsjurisdiction` despite the source table
    //    being sysjurisdiction.
    List<Map<String, Object>> jList = businessGdsconfigformsService.getJurisdictionData(qMap);
    // 4. sysbillnosettings (per-tenant, per-form).
    Map<String, Object> billnosettingMap = businessGdsconfigformsService.getBillnosettingData(param);
    // 5. sysreport (per-tenant, per-form).
    List<Map<String, Object>> reportList = printReportService.getReportData(qMap);
    return composite(formList, fList, jList, billnosettingMap, reportList);
}
```

— `BusinessBaseServiceImpl.java`. Read this method first; the
rest of the runtime is variations on it.

## The five-key composite returned

| Key | Source | Frontend uses for |
|---|---|---|
| `formData` | `gdsconfigformmaster` (filtered by `sParentId = sModelsId`) ⋈ `gdsconfigformpersonalize` overlay; per master row, `gdsconfigformslave` + `gdsconfigformcustomslave`. `gdsmodule` is referenced only as the id source, not joined. | Form layout |
| `gdsformconst` | `gdsformconst` (filtered by `sParentId` only; `sLanguage` selects which label column to return; NOT tenant-scoped) | Form-level constants, dropdown labels |
| `gdsjurisdiction` | `sysjurisdiction` (joined to `sftlogininfojurisdictiongroup` + `sisjurisdictionclassify` for per-user/group grants); skipped for ADMIN. **Note:** the map-key name `gdsjurisdiction` is misleading — `gdsjurisdiction` is the builder-side action *catalogue* table; the per-user *grant* read here actually queries `sysjurisdiction`. | Per-button / per-data permissions |
| `billnosetting` | `sysbillnosettings` (per-tenant, per-form) | Document numbering rules |
| `report` | `sysreport` (per-tenant, per-form) | Print templates |

## The save endpoint

`POST /business/addUpdateDelBusinessData` bundles add+update+delete in
one transactional batch. The frontend supplies `sTable` and a `column`
map per row, in this shape:

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

The base add/update path always runs through
`BusinessBaseServiceImpl.addBusinessData` / `updateBusinessData`
(`xlyBusinessService/.../BusinessBaseServiceImpl.java:1014` and
`:1250`), which delegate to `businessBaseDao.add(map)` /
`businessBaseDao.update(map)` against the table named by `sTable`.
`gdsmodule.sSaveProName` (and its sibling `sSaveProNameBefore`) is
**not** an either/or branch that swaps the path; it names an extra
stored proc that runs as a post-save (or pre-save) hook on top of
the base path, dispatched by `checkUpdate(...,"sSaveProName")` at
`BusinessBaseServiceImpl.java:1824` and `CheckSaveServiceImpl.java`.
`AddDelUpdCommonServiceImpl` (`@Service("addDelUpdCommonService")`)
is a separate utility of reusable `insertByMap`/`updateByMap`/
`delByMap`/`addBatch` helpers used by domain services
(work-order-plan, oee, many-quo, order-procurement, etc.); it is
**not** the runtime's default add/update path for
`addUpdateDelBusinessData`.

## Multi-tenant boundary

Every controller method's first non-trivial line is:

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

— `xlyPersist/.../RequestAddParamUtil.java`, 56 lines (xlyApi has its
own near-identical 57-line copy at `xlyApi/.../api/util/RequestAddParamUtil.java`).
This is the single point where the user's session identity (`sBrandsId`,
`sSubsidiaryId`, `sBrId`, `sSuId`, `sLoginId`, `sLanguage`, `sTeamId`,
`sMachineId`, plus eight more — sixteen keys total) is injected into the
downstream `params` map. Every MyBatis query and stored-procedure call
that references `#{sBrandsId}`/`#{sSubsidiaryId}` is automatically scoped
from there.

A new controller method that doesn't call `RequestAddParamUtil` is a
**multi-tenant bug**.

## Security concerns to audit

Two flagged in slices that belong here permanently:

1. **`sTable` validation in `addUpdateDelBusinessData` — CONFIRMED MISSING.**
   The frontend names the target table directly and the runtime does
   **not** cross-check that the supplied table is one of the form's
   authorised backing tables. `BusinessBaseServiceImpl.sTableNameList`
   (lines 162-169) is a multi-tenant scope-bypass list (the four
   framework-metadata tables that are global, so `sBrandsId` /
   `sSubsidiaryId` are stripped from writes against them — see the
   `//不需要公司子公司的表` comment at line 165), not a backing-table
   whitelist. The hardcoded special case at line 1768
   (`mftproductionplanslave`) is the only module/table cross-check
   in the whole flow. Mitigations exist (tenant scoping via
   `RequestAddParamUtil`, optional pre/post-save proc validation),
   but none of them is a backing-table whitelist. See
   [Slice 1 follow-up](../../slices/01-hello-world.md#open-verification-items)
   for the full trace.
2. **ADMIN bypasses permissions.**
   `BusinessBaseServiceImpl` skips the `gdsjurisdiction`
   load entirely for `UserType.ADMIN`. ADMIN account governance must
   come from outside the app.

## What "universal CRUD" means in practice

The "one controller writes any row in any table" pattern is the
core data-driven move. It also concentrates risk:

- **`BusinessBaseServiceImpl` is ~3,900 lines** of tightly
  intertwined logic: per-tenant scope-bypass list, special-case
  table hardcodes (`mftproductionplanslave` at line 1768),
  pre/post-save hook dispatch, sTable-driven write routing. Every
  bug fix has to navigate the whole class.
- **The class is the single point of failure for the entire
  business runtime.** A regression in `addUpdateDelBusinessData`
  breaks save for every form in every tenant simultaneously.
  Module-specific controllers would localise the blast radius;
  the universal one cannot.
- **No type system on `Map<String, Object>`.** The frontend ships
  a bag of (key, value) pairs. The runtime trusts the keys
  match column names and the values cast to the column types.
  Mismatches surface as `BadSqlGrammarException` at the DAO layer
  — far from where the wrong value originated. There is no
  schema-aware request validation.
- **Discoverability is poor.** "What endpoints write to
  `mftproductionplanslave`?" can't be answered by IDE find-usages
  — the answer is "any controller that calls
  `BusinessBaseServiceImpl.addBusinessData` with `sTable` set to
  `mftproductionplanslave`", which is everything.

The universal pattern is what makes the data-driven thesis work.
It is also the reason adding a new module is essentially free
*and* the reason that touching the runtime is essentially never
free.

## Cache invalidation

When BACK saves a metadata change, the save service synchronously
calls `BusinessCleanRedisData.delCleanRedisData*`, which fires
`@CacheEvict` on the relevant cache regions in `CleanRedisServiceImpl`.
A separate JMS path (`ConsumerChangeGdsModuleThread`) exists with a
similar name but does base-data merging via stored proc, not cache
invalidation. See [cache invalidation on metadata change](cache-invalidation.md)
for the full story (cross-node coherence is empirically Redis-backed,
no longer an open question).
