# 切片 4 — 扩展：按租户添加自定义字段

xly 卖给许多客户。每个客户都想记录框架原本不知道的一些东西：自定义编码、内部备注、分类标签。受支持的做法是向一个*定制*表插入行，并在运行时把该表内容合并到基础表单之上；这样可以**不 fork 代码库，也不修改共享元数据**。

本切片追踪该机制。这是首次覆盖 xly 的*每租户覆盖*模型，即单代码库 / 多客户 SaaS 得以运作的定制模型。

## 三层定制

xly 在每个基础表单之上叠加**三张**定制表。每张表作用域不同，回答的问题也不同。

| 表 | 作用域 | 覆盖层 | dev 行数 |
|---|---|---|---|
| [`gdsconfigformpersonalize`](../auto-catalog/tables/gdsconfigformpersonalize.md) | 每租户（`sBrandsId` + `sSubsidiaryId`） | **form-master**，覆盖整个表单的 `sSqlStr` / `sWhere` / `sOrder` | 18 |
| [`gdsconfigformcustomslave`](../auto-catalog/tables/gdsconfigformcustomslave.md) | 每租户 | **form-slave**，给表单新增、隐藏或替换*单个字段* | 0 |
| [`gdsconfigformuserslave`](../auto-catalog/tables/gdsconfigformuserslave.md) | 每用户 | **form-slave**，每用户字段微调（列顺序、隐藏列） | 9 |

基础表单是 `gdsconfigformmaster` + `gdsconfigformslave`，即*系统默认*。上面三张表是运行时应用并合并的*覆盖层*。

本切片聚焦中间行：`gdsconfigformcustomslave`，也就是“给租户 X 添加自定义字段”的规范通道。

## 示例会是什么样

假设租户“山东星海印务”想在客户列表表单上增加“客户内部编码”字段。客户或实施人员不修改 `gdsconfigformslave`，而是：

1. 打开 BACK 中的 `界面显示内容配置`（`gdsmodule.sId=11`，`/jmnrpz`）。其第三个面板通过 `sId=19211681019715596285250620` 的 form-master 写入 `gdsconfigformcustomslave`，已在线验证。各 panel 映射见下面“待验证项”第 1 项。
2. 新增一行：
   - `sParentId` = 表单 `sId`（与基础 slave 指向同一个表单）。
   - `sName = 'sInternalCode'`（字段列名）。
   - `sChinese = '客户内部编码'`。
   - `sControlName = '文本框'`。
   - `sBrandsId` + `sSubsidiaryId` = 该租户 ID。
3. 可选地给底层物理表添加一列（手工 schema 迁移；框架不会自动 `ALTER TABLE`）。否则该字段只存在于表单上，保存时没有可绑定目标。

下次该租户的任意用户加载表单时，会看到额外列。其他租户仍看到未修改的基础表单。

> **已用 dev DB 确认。** `gdsconfigformcustomslave` 在 `xlyweberp_saas_ai` 中当前**为空**（0 行）。表已接入框架，但当前 dev DB 没有租户注册字段级覆盖。下面的追踪来自代码推导；端到端**观察**需要一个真实填充该表的租户部署。

## 运行时如何合并

前端不会看到三张表，只会看到一个合并表单。框架通过两个数据库**视图**读取基础 slave 和覆盖 slave 与 form-master 的连接：

- [`gdsconfigformslavemasterview`](../auto-catalog/views/gdsconfigformslavemasterview.md)：连接 `gdsconfigformmaster` 与基础 `gdsconfigformslave`，总会加载。
- [`gdsconfigformcustomslavemasterview`](../auto-catalog/views/gdsconfigformcustomslavemasterview.md)：连接 `gdsconfigformmaster` 与自定义 `gdsconfigformcustomslave`，仅对有覆盖行的租户加载。

服务层读取两者，按 `sName` 合并，并向 SPA 返回单一 slave 列表。如果租户的 `gdsconfigformcustomslave` 行与基础 slave 拥有相同 `sName`，则覆盖；新的 `sName` 会追加。隐藏 / 移除情况通过 `bVisible` 处理。

`gdsconfigformpersonalize` 的表单级覆盖以类似方式在 *form-master* 层合并：请求时运行时调用 `configformpersonalizeService.getConfigformpersonalize(sBrandsId, sSubsidiaryId, gdsconfigformmasterId)`；如果返回行，就用其 `sConfigSqlStr` / `sConfigWhere` / `sConfigOrder` 替换基础表单的 `sSqlStr` / `sWhere` / `sOrder`。

合并顺序如下：

```text
gdsconfigformmaster              （系统默认表单）
  ↓ 覆盖
gdsconfigformpersonalize         （每租户整表单覆盖）
  ↓ 然后是基础从表
gdsconfigformslave               （系统默认字段）
  ↓ 覆盖 / 扩展
gdsconfigformcustomslave         （每租户字段）
  ↓ 可选继续微调
gdsconfigformuserslave           （每用户视图偏好）
```

这是“无 FK、语义 FK 现实”在框架自身配置上的应用：没有外键保证 `gdsconfigformcustomslave.sParentId` 真存在于 `gdsconfigformmaster.sId`。孤儿行可能存在，并在合并时被静默忽略。维护人员应增加审计脚本标记此类孤儿。

## 为什么不改代码也能工作

最终客户不用向工程师申请新列。他们打开**后台**构建器，增加一行，字段只在自己的**前台**中出现，其他租户不受影响。这个单代码库属性来自 xly 的数据驱动论点；代价是每次请求合并元数据的运行时成本，以及三张多数表单不会使用的定制表带来的 schema 膨胀。

## 本切片引入的概念

- *定制层级*：上面的三层覆盖模型、合并方式以及各层适用时间。
- *无 schema 扩展限制*：框架会渲染额外字段，但不会也不能 `ALTER TABLE`。基础表没有的自定义字段需要协调的手工 schema 迁移、宽表预留列，或 JSON 列模式。切片 5 覆盖需要结构变化时的每客户 SQL 覆盖通道。

## 本切片使用的参考

- [配置人员：如何定义表单](../reference/builder/define-form.md)：覆盖路径与基础配方相同，只是表不同。
- [维护人员：运行时](../reference/maintainer/runtime.md)：三层合并发生在 `BusinessBaseServiceImpl` 和 `BusinessGdsconfigformsServiceImpl` 中。

## 待验证项

1. ~~**找到实际编辑 `gdsconfigformcustomslave` 的 BACK 页面。**~~ **已关闭：确认为 `界面显示内容配置`**（`gdsmodule.sId=11`，URL `/jmnrpz`）。该页在一个 screen 中渲染三个 form-master panel，分别对应表单定义栈的三层：

   | Panel | `gdsconfigformmaster.sId` | 写入的 `sTbName` |
   |---|---|---|
   | Form-master 编辑器 | `19211681019715574673782610` | `gdsconfigformmaster` |
   | 基础 slave 编辑器 | `19211681019715596207594120` | `gdsconfigformslave` |
   | 每租户覆盖 | `19211681019715596285250620` | `gdsconfigformcustomslave` |

   第三个 panel 就是“给租户 X 添加自定义字段”的规范通道。在线验证：在 BACK（admin/123）点击 `界面显示内容配置` 会触发 `POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715596285250620?sModelsId=11` 加载现有 customslave 行；后续新增 / 修改操作的 `addUpdateDelBusinessData` POST 也会落到同一个 form-master，运行时按标准通用保存路径解析为对 `gdsconfigformcustomslave` 的写入。
2. ~~**追踪合并代码。**~~ **已关闭**：合并发生在 `BusinessBaseServiceImpl.java:246-248` 的 Java 中，先调用 `businessGdsconfigformsService.getFormSlaveData(map)`，再调用 `getFormCustomSlaveData(map)`，并把两者 `addAll` 到同一个 `slaveList`。两个视图（`gdsconfigformslavemasterview`、`gdsconfigformcustomslavemasterview`）提供 master-with-slave 的读取形状；**合并**在 Java，**master-with-slave join** 在 SQL。
3. ~~**`bVisible = false` 语义：隐藏基础字段，还是只抑制覆盖行？**~~ **已关闭：两者都会发生，但在不同层。** `BusinessGdsconfigformsServiceImpl.java:413-433` 中，当 `gdsconfigformcustomslave` 行按 `sControlName` 或 `sName` 匹配到基础 `gdsconfigformslave` 行时，customslave 行会完整替换基础行（`sList.removeAll(_cstlist); sList.addAll(_cList);`），所以 customslave 上的 `bVisible=false` 会按租户隐藏基础字段。用户级覆盖（`gdsconfigformuserslave`，446-468 行）随后叠加：只有用户行的 `bVisible` 为 true 且合并后行的 `bVisible` 也为 true 时，用户的 `iFitWidth` / `iOrder` 才生效；否则 464 行会显式 `cmap.put("bVisible", false)`，仅对该用户隐藏。因此 `bVisible=false` 在两层都能隐藏字段，只是作用域不同（每租户 vs 每用户）。
> **Item 4 — 暂缓（需要填充过的租户部署）。** 已对 dev DB 实证确认：`SELECT COUNT(*) FROM gdsconfigformcustomslave` 返回 0。该 DB 中没有租户注册每租户字段覆盖，因此无法从这里抽取真实 worked example。该项仍是真实缺口，等待有覆盖行的生产租户 DB。

4. **真实示例。** 在生产中找一个租户的实际 `gdsconfigformcustomslave` 行作为贯穿示例。（dev DB 已确认为空，需要有覆盖行的租户部署。）
