diff --git a/en/docs/api-reference/external.md b/en/docs/api-reference/external.md
index 806431c..b4a1110 100644
--- a/en/docs/api-reference/external.md
+++ b/en/docs/api-reference/external.md
@@ -22,7 +22,10 @@ Content-Type: application/json
```
Handler: `ApiController.invoke()` at
-`xlyApi/src/main/java/com/xly/api/web/ApiController.java`.
+`xlyApi/src/main/java/com/xly/api/web/ApiController.java:223`. The mapping
+is `@RequestMapping` (method-agnostic at the framework level); the per-API
+`sysapi.sMethod` column declares the expected verb for callers, and
+`ApiCheckUtil` enforces it at dispatch.
The flow:
diff --git a/en/docs/api-reference/index.md b/en/docs/api-reference/index.md
index 3d917d6..5f83b5a 100644
--- a/en/docs/api-reference/index.md
+++ b/en/docs/api-reference/index.md
@@ -10,7 +10,7 @@ overview lives in [concepts/api-surface](../concepts/api-surface.md).
| [External API](external.md) | `xlyApi` | `/xlyApi` | You are integrating an outside system that calls xly. |
| [Webhooks](webhooks.md) | `xlyInterface` | `/xlyInterface` | A third-party system needs to push events into xly. |
| [Messaging](messaging.md) | `xlyEntry` + `xlyErpJms*` | n/a (ActiveMQ / RocketMQ) | An asynchronous, fan-out integration is more appropriate than a synchronous HTTP call. |
-| [Notifications](notifications.md) | `xlyMsg` (consumed as a library by `xlyEntry`, `xlyBusinessService`, `xlyInterface`) | n/a (DingTalk / WeChat APIs) | A business event needs to push a chat-platform message to a user. |
+| [Notifications](notifications.md) | `xlyMsg` (consumed as a library by `xlyEntry`, `xlyBusinessService`, `xlyInterface`, `xlyFlow`) | n/a (DingTalk / WeChat / email APIs) | A business event needs to push a chat-platform message or email to a user. |
## Reading order
diff --git a/en/docs/api-reference/internal.md b/en/docs/api-reference/internal.md
index 2e5936a..b48b038 100644
--- a/en/docs/api-reference/internal.md
+++ b/en/docs/api-reference/internal.md
@@ -18,8 +18,8 @@ page is the catalog of HTTP entry points.
| Endpoint | Method | Purpose |
|---|---|---|
-| `/business/getModelBysId/{moduleId}` | GET | Returns the form layout for a module — the five-key composite (`formData`, `gdsformconst`, `gdsjurisdiction`, `billnosetting`, `report`). |
-| `/business/getBusinessDataByFormcustomId/{formId}` | POST | Returns rows of business data for a form, paginated. Branches to `getBusinessDataByGroup` when `sGroupList` is set. |
+| `/business/getModelBysId/{sModelsId}` | GET | Returns the form layout for a module — the five-key composite (`formData`, `gdsformconst`, `gdsjurisdiction`, `billnosetting`, `report`). |
+| `/business/getBusinessDataByFormcustomId/{gdsconfigformmasterId}` | POST | Returns rows of business data for a form, paginated. Branches to `getBusinessDataByGroup` when `sGroupList` is set. |
| `/business/getBusinessDataByIndex` | POST | First / last / next / previous-record navigation. |
| `/business/addBusinessData` | POST | Single insert. |
| `/business/addUpdateDelBusinessData` | POST | Bundled add+update+delete in one transactional call. The frontend names the target table directly via `sTable`. |
@@ -55,7 +55,7 @@ virtual tables) there is a parallel surface in
| `/treegrid/*` | `BusinessTreeGridController` | Tree-grid endpoints (the proc-backed path is implemented in this branch). |
| `/procedureCall/*` | `GenericProcedureCallController` | Generic stored-procedure invocation by name + parameters — see [generic procedure dispatch](../reference/maintainer/proc-dispatch.md). |
| `/panel/*` | `ConfigformPanelController` | Panel-layout persistence in `gdsconfigformpanel`. |
-| `/checkflow/*` | `CheckFlowController` | **Empty shell — returns 404.** The class declares the prefix but has zero handler methods. The actual workflow approve/reject/complete URLs come from xlyFlow's `CurrencyFlowController` (served at xlyEntry's context-path because xlyFlow is a library dep): `/complete/{taskId}/{sBrandsId}/{sSubsidiaryId}/{sUserId}`, `/completeerp/...`, plus `/modeler/*` for the BPMN modeler. See [Activiti integration](../reference/maintainer/activiti.md). |
+| `/checkflow/*` | `CheckFlowController` | **Empty shell — returns 404.** The class declares the prefix but has zero handler methods. The actual workflow approve/reject/complete URLs come from xlyFlow's `CurrencyFlowController` (served at xlyEntry's context-path because xlyFlow is a library dep): `/currencyFlow/complete/{taskId}/{sBrandsId}/{sSubsidiaryId}/{sUserId}`, `/currencyFlow/completeerp/{sBrandsId}/{sSubsidiaryId}/{sUserName}`, plus `/modeler/*` (root-mapped) for the BPMN modeler. See [Activiti integration](../reference/maintainer/activiti.md). |
| `/modelCenter/getModelCenter`, `/modelCenter/getModelCenterCalculation` | `BusinessModelCenterController` | The FROUNT home-page **KPI Work Center** card (titled `KPI监控`). Aggregates open tasks across modules tagged `gdsmodule.bUnTask=1`, partitioned by role and business flow. **Not Activiti-driven.** See [The KPI Work Center](../reference/maintainer/runtime.md#the-kpi-work-center-front-end-home-dashboard). |
## Reporting and printing
@@ -115,24 +115,144 @@ driven. **`图表配置`** is fully metadata-driven via two
`gdsconfigcharslave`; chart definitions there are consumed by xly's
dashboard rendering elsewhere in the SPA.
-## Coverage policy — what this catalog includes
-
-`xlyEntry` hosts **~71 controllers** in total. This page enumerates
-the ~19 that are part of the framework's universal runtime:
-`/business/*`, `/configform/*`, `/treegrid/*`, `/procedureCall/*`,
-`/panel/*`, `/checkflow/*`, `/gdsmodule/*`, `/gdsconfigform/*`,
-`/gdsconfigtb/*`, plus the print surface. Every form in the system
-flows through these.
-
-The remaining ~52 controllers are **business-domain modules**
-(`/sysworkorder`, `/salesorder`, `/productionPlan`, `/oee`,
-`/eleMaterialsStock`, etc.) — they implement specific industry-tier
-flows on top of the framework primitives above. The wiki treats those
-as *illustrations of the framework at work*, not as catalogued surface
-of their own. Maintainers who need to find a specific business
-controller should grep `xlyEntry/src/main/java/com/xly/web/` for the
-URL prefix; the framework primitives on this page are what's worth
-reading first.
+## Beyond the framework primitives — the rest of xlyEntry's surface
+
+`xlyEntry` hosts **70 controllers** in total. The 18 framework-side
+controllers — the universal-runtime ones enumerated explicitly above
+plus the seven `systemweb/` admin controllers backing the
+[BACK builder sidebar](#back-builder-sidebar-admin-surface)
+(`GdsformconstController`, `GdsjurisdictionController`,
+`GdslogininfoController`, `GdsparameterController`, `LicenseController`,
+`LoginController`, `SysbrandsController`) — are where every
+metadata-driven form's lifecycle lives. The remaining 52 controllers
+exist because the framework's universal CRUD + procedure-dispatch
+path **wasn't enough** for the use case. Each one is, in effect, a
+marker for where the data-driven thesis stopped scaling.
+
+That makes them worth enumerating even though they are out of the
+framework's catalogued surface: they show what xly hardcodes in Java
+versus what it leaves to metadata. A maintainer reading them gets a
+direct read on the framework's escape-hatch shape.
+
+> **Namespace overlap.** `BusinessBaseController` (`/business/*`) and
+> `QuoquotationController` (also `@RequestMapping("/business")`) share
+> the URL prefix. Spring resolves by method-level path, so
+> `/business/addQuotationsheet` and `/business/getQuoquotationProgress`
+> live on `QuoquotationController` while every other `/business/*`
+> endpoint is on `BusinessBaseController`. The collision is benign
+> (no method-path overlap) but the convention is a foot-gun: a
+> future contributor adding `@PostMapping("/addQuotationsheet")` to
+> `BusinessBaseController` would silently shadow the quotation path.
+
+### Form-helper and SPA-extension controllers (22)
+
+Framework-adjacent endpoints — extensions to the universal CRUD path
+that didn't fit the form-master / form-slave shape. Most are called
+*alongside* `/business/*` from the same SPA screen.
+
+| Endpoint root | Controller | Role |
+|---|---|---|
+| `/bill/*` | `BillController` | Copy-a-document operations (`billCopyToCheck`, `billCopyToCheckWork`) — clone a master+slaves row-set into a new document. Not in `/business/*` because the SPA needs the new `sId` set returned in a single round-trip, which the universal save endpoint doesn't shape. |
+| `/change/*` | `ChangeController` | Generic field-change recompute (`changeParam`) — the SPA fires this when a field's value triggers a derived recomputation that requires hitting a proc. |
+| `/parameter/*` | `BusinessParameterController` | Per-module parameter reads. |
+| `/treeclassify/*` | `BusinessTreeClassifyController` | Tree-grouped variant of the form data load — `/treeclassify/getTreeClassify/{gdsconfigformmasterId}`. Lives outside `/business/*` because the response shape is a nested tree, not a flat row-set. |
+| `/calcprocedure/*` | `CalcProcedureController` | The runtime side of the [calculation-formula](../reference/builder/define-vtable.md) feature — `/calc` invokes a named calc proc. |
+| `/calculationFormula/*` | `CalculationFormulaController` | Builder-side metadata for calculation formulas. |
+| `/calculationStd/*` | `CalculationStdController` | Standard-calc lookup catalog. |
+| `/char/*` | `CharController` | Chart-config CRUD for the BACK 图表配置 page — wraps `gdsconfigcharmaster`/`slave`. |
+| `/checkModel/*` | `CheckmodelController` | Approval-model membership reads (`getUserListByModelId/{sCheckModeId}`). Used by the lightweight (non-Activiti) approval flow. |
+| `/comparatorTree/*` | `ComparatorTreeController` | Comparator-tree reads for filterable hierarchical pickers. |
+| `/excel/*` | `ExcelController` | Excel **export** of a grid — `/export/{gdsconfigformmasterId}`. Sibling to print, but data instead of report layout. |
+| `/import/*` | `ImportExcelController` | Excel **import** (`/checkExcel`, then commit). Validates against the form's slaves before inserting. |
+| `/filterTree/*` | `FilterTreeController` | Tree-shaped filter dropdown for grids. |
+| `/notClear/*` | `NotClearController` | Barcode-scan "not yet cleared" save path (`doNotClearSave`, `getNotClearScanData/{sProcName}/{sId}`). Specific to scanner-driven warehouse flows. |
+| `/notice/*` | `NoticeController` | In-app notice fetch / mark-read. |
+| `/replaceField/*` | `ReplaceFieldController` | Bulk field-replace across rows. |
+| `/searchgroupby/*` | `SearchgroupbyController` | Saved-search definitions with group-by. |
+| `/searchupdown/*` | `SearchUpDownController` | Previous-/next-record-by-search navigation (variant of `/business/getBusinessDataByIndex`). |
+| `/syssearch/*` | `SyssearchController` | Saved-search definitions CRUD. |
+| `/syssystem/*` | `SyssystemController` | Variant of `getBusinessDataByFormcustomId` for system-table reads (`/getSyssystemDataByFormcustomId/{gdsconfigformmasterId}`) — bypasses tenant scoping for global metadata reads. |
+| `/sqlfile/*` | `SqlFileController` | SQL-file load/save backing the "Mysql脚本配置" admin screen. |
+| `/instruct/*` | `InstructController` | Direct SQL execution endpoints (`/exesql`, `/opensql`) — the admin-side query console. |
+
+### User and permission management (4)
+
+Multiple overlapping controllers for the same concern. The `New`
+suffix and the presence of `sftlogininfo` *and* `userinfo` *and*
+`gdslogininfo` (in `systemweb/`) suggest a refactor in progress —
+the older path coexists with the new one until callers migrate.
+
+| Endpoint root | Controller | Role |
+|---|---|---|
+| `/userinfo/*` | `UserInfoController` | Current-user profile + session info. |
+| `/sftlogininfo/*` | `SftlogininfoController` | User-account CRUD (newer of two paths). |
+| `/sysjurisdiction/*` | `SysjurisdictionController` | Group/user permission reads (`getGroupData`, `getUserData`). |
+| `/sysjurisdictionNew/*` | `SysjurisdictionNewController` | Newer parallel path (`getGroupDataNew`, `getGroupUserIdNew/{sUserId}`). Same concern, different shape. |
+
+### Manufacturing / MES (7)
+
+Industry-tier flows whose state machines, multi-table joins, or
+hardware integration could not be expressed as `gdsconfigformmaster`
+SQL. These are the framework's largest single concentration of
+hardcoded business logic.
+
+| Endpoint root | Controller | Role |
+|---|---|---|
+| `/sysworkorder/*` | `WorkOrderController` | Work-order CRUD with side effects (`/add`, `/update/{sId}`) — a printing-industry work order spans many slaves and proc invocations the universal save can't chain atomically. |
+| `/workOrderFlow/*` | `WorkOrderFlowController` | Work-order routing / process-flow reads (`getWorkOrderFlow`, `getSplitWorkOrderData/{sId}`). |
+| `/workOrderPlan/*` | `WorkOrderPlanController` | Production-plan reads linking work orders to plan rows (`getControlProcess/{sProductionPlanId}`, `getProductionPlanInfo`). |
+| `/splitWorkOrder/*` | `SplitWorkOrderController` | Splitting a master work order into multiple sub-orders (`getSplitWorkOrderData`). |
+| `/productionPlan/*` | `ProductionPlanController` | Plan-tree reads (`getProductionPlanTree`). |
+| `/process/*` | `ProcessController` | Manufacturing-process catalog reads. |
+| `/oee/*` | `OeeController` | Overall Equipment Effectiveness — barcode-scan and MES status callbacks (`updateBarcode/{sBarCodeId}/{sBarCode}`, `doSysMesMsg/{sStatus}/{sMachineId}`). |
+
+### Sales, inventory, accounting, procurement, HR (9)
+
+| Endpoint root | Controller | Role |
+|---|---|---|
+| `/salesorder/*` | `SalesOrderController` | Sales-order specifics beyond the universal CRUD. |
+| `/business/addQuotationsheet`, `/business/getQuoquotationProgress` | `QuoquotationController` | Quotation-sheet creation (long-running, hence the progress endpoint). **Note:** shares the `/business/*` prefix with `BusinessBaseController` — see the namespace-overlap note above. |
+| `/eleMaterialsStock/*` | `EleMaterialsStockController` | Raw-material stock reads (`getEleMaterialsStock`, `getEleMaterialsStoreCurrQty`). |
+| `/eleProductStock/*` | `EleProductStockController` | Finished-product stock reads. |
+| `/costCenter/*` | `CostCenterController` | Cost-center data + voucher-import (`getCostCenterData`, `getCosvoucherImportData`). |
+| `/sysAccountPeriod/*` | `SysAccountPeriodController` | Accounting-period open/close logic. |
+| `/erpOrderProcurement/*` | `ErpOrderProcurementController` | Procurement-order specifics. |
+| `/sisproductclassify/*` | `SisproductclassifyController` | Product-classification tree. |
+| `/eleteamemployee/*` | `EleteamemployeeController` | Team/employee assignment for shop-floor flows. |
+
+### Integration and hardware (5)
+
+| Endpoint root | Controller | Role |
+|---|---|---|
+| `/file/*` | `FileController` | File upload (incl. WeChat mobile variant `mobileuploadwechat`). |
+| `/plc/*` | `PlcController` | PLC-bridge entry points (`getplcMachine/{iOrder}/{sParentId}`) — see [Slice 6](../slices/06-hardware.md). |
+| `/mobilephone/*` | `MobliePhoneController` | Mobile-app endpoints. (Typo `Moblie` is in the class name; the URL is `/mobilephone`.) |
+| `/sysWebsocket/*` | `SysWebSocketController` | WebSocket setup/teardown for push notifications. |
+| `/wechat/*` | `WechatController` | WeChat integration (in-app QR, OAuth callback). |
+
+### Out-of-scope per [the index](../index.md) (5)
+
+Listed for completeness — these are not part of the framework wiki's
+in-scope surface, but they exist in the WAR.
+
+| Endpoint root | Controller | Status |
+|---|---|---|
+| `/ai/*` | `AiController` | AI assistant. Out of scope (index.md). |
+| `/robot/*` | `ChatGptController` | ChatGPT integration. Out of scope (index.md). |
+| `/test/*` | `TestController` | Dev scaffolding (`/file`, `/getDinkToken`). |
+| (root paths) | `TestProcessController` | Dev scaffolding; no class-level `@RequestMapping`. |
+| (commented out) | `XsController` | Dead file — `@RestController` and `@RequestMapping` are commented out; class exists but registers nothing. |
+
+### Reading these as a diagnostic
+
+Three patterns stand out when you scan the list:
+
+1. **Long-running or multi-step transactions** (`QuoquotationController.getQuoquotationProgress`, `BillController.billCopyToCheck`, `SplitWorkOrderController`) — the universal save is single-shot; any flow that needs a progress endpoint or a "clone then redirect" semantic needs its own controller.
+2. **Industry-specific state machines** (`OeeController`, work-order family, `SysAccountPeriodController`) — when "the next legal state" can't be derived from a single column, the proc-dispatch path isn't enough and the controller wires up the orchestration in Java.
+3. **Hardware or external systems** (`PlcController`, `WechatController`, `SysWebSocketController`, `FileController`) — anything that isn't "MySQL + HTTP" needs Java glue; metadata can't describe an inbound websocket or a serial-port handshake.
+
+The framework's universal runtime is what's *missing* from these
+controllers' jobs.
## What this API is *not*
diff --git a/en/docs/api-reference/messaging.md b/en/docs/api-reference/messaging.md
index 9c1a8dc..ab2d965 100644
--- a/en/docs/api-reference/messaging.md
+++ b/en/docs/api-reference/messaging.md
@@ -83,10 +83,12 @@ express well). RocketMQ topics are configured per environment.
## Manual cache-invalidation poke
-If a metadata change happens via raw SQL (no JMS event), the cache
-across nodes will not bust automatically. The supported override is
-`BusinessCleanRedisDataImpl` in `xlyBusinessService/.../service/impl/` —
-it can publish an invalidation event directly. See
+If a metadata change happens via raw SQL, the cache across nodes will not
+bust automatically because no BACK save path calls `@CacheEvict`. The
+supported override is to invoke the appropriate
+`BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(...)` cleaner
+from inside the application once; it evicts the shared Redis-backed Spring
+cache directly. See
[cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md)
for the broader troubleshooting path.
@@ -95,6 +97,6 @@ for the broader troubleshooting path.
- **Not a public integration channel.** External integrators do not
publish or subscribe to these brokers. They are *internal* fan-out
for the cluster.
-- **Not the only way to invalidate caches.** The HTTP write paths in
- `xlyEntry` already publish JMS events when they should; the manual
- poke is for edge cases.
+- **Not a cache-invalidation channel.** The HTTP write paths in
+ `xlyEntry` already evict Redis synchronously when they should; the
+ manual cleaner call is for raw-SQL or bypass paths.
diff --git a/en/docs/api-reference/notifications.md b/en/docs/api-reference/notifications.md
index e8261a6..09caff4 100644
--- a/en/docs/api-reference/notifications.md
+++ b/en/docs/api-reference/notifications.md
@@ -1,9 +1,9 @@
# Notifications (xlyMsg)
-Outbound notifications — DingTalk and WeChat — go through the `xlyMsg`
-module. This is *not* an HTTP surface that callers hit; it's an
-internal SDK that in-scope services call when a business event should
-push a message out to a chat platform.
+Outbound notifications — DingTalk, WeChat, and email — go through the
+`xlyMsg` module. This is *not* an HTTP surface that callers hit; it's
+an internal SDK that in-scope services call when a business event
+should push a message out to a chat platform or mailbox.
## What's inside
@@ -13,6 +13,7 @@ push a message out to a chat platform.
|---|---|
| `dingtalk/service/DingTalkService` + `dingtalk/util/SendDingTalkUtil`, `DingTalkMsgContentUtil`, `LocalCacheClient` | DingTalk corp-message dispatch. Wraps `com.aliyun:dingtalk:2.1.14` and `com.aliyun:alibaba-dingtalk-service-sdk:2.0.0`. |
| `wechat/service/WechatService` + `wechat/util/SendWxUtil`, `Wx_SignatureUtil`, `JedisMsgUtil`, `MsgContentUtil`, `Xml2JsonUtil` | WeChat work-platform dispatch — signature + send, including a Redis-backed access-token cache. |
+| `emial/service/SendEmailService` + impl | Email dispatch (note the package typo `emial`). Used by `xlyFlow`'s `QuartzTask` for scheduled-job mail; `xlyEntry` also has its own `com.xly.web.email.SendEmailService` for `ScheduledTasks`-driven mail — same interface name, parallel implementation, kept for historical reasons. |
| `notice/service/NoticeService` | Provider-agnostic notice abstraction; routes a logical "notify user X about event Y" to the right backend. |
`xlyMsg/build.gradle` — sole framework dependency is `xlyPersist`. The
diff --git a/en/docs/api-reference/webhooks.md b/en/docs/api-reference/webhooks.md
index 4789630..a2ce257 100644
--- a/en/docs/api-reference/webhooks.md
+++ b/en/docs/api-reference/webhooks.md
@@ -37,7 +37,7 @@ for inbound calls:
| Endpoint | Method | Purpose |
|---|---|---|
| `/interfaceDefine/invoke/{interfaceInvoke}` | POST | Dispatch an inbound payload to the handler that `{interfaceInvoke}` names. |
-| `/interfaceDefine/callthirdparty/{interfaceInvoke}` | POST | Forward to a configured outbound endpoint. (Mirrors the `/interfaceDefine/callthirdparty/...` endpoint that also exists on `xlyApi`; the inbound side here is paired with the data-driven inbound dispatcher.) |
+| `/interfaceDefine/callthirdparty/{interfaceInvoke}` | POST | Forward to a configured outbound endpoint. The same URL also exists on `xlyApi`'s `InterfaceController` — duplication is intentional, the two services share the data-driven dispatcher pattern. |
Handler: `xlyInterface/src/main/java/com/xly/web/InterfaceController.java`.
diff --git a/en/docs/concepts/customization-layers.md b/en/docs/concepts/customization-layers.md
index a8d37ac..739ed18 100644
--- a/en/docs/concepts/customization-layers.md
+++ b/en/docs/concepts/customization-layers.md
@@ -57,10 +57,13 @@ links are FK-enforced — see [no-FK reality](semantic-fk.md).
The framework reads each layer in order and merges by `sName` (the field
name). For a custom slave row with the same `sName` as a base slave:
override. For a new `sName`: append. For a base slave with no
-corresponding custom row: pass through unchanged. The merge happens
-inside `BusinessBaseServiceImpl.getModelBysId` (line 181) and the
-helpers it calls — `BusinessGdsconfigformsServiceImpl.getFormSlaveData`
-+ `getFormCustomSlaveData`.
+corresponding custom row: pass through unchanged. Entry point is
+`BusinessBaseServiceImpl.getModelBysId` (line 181), which calls
+`BaseServiceImpl.getModelConfigByModleId` (line 55); the actual
+slave-+-customslave merge runs in
+`BusinessGdsconfigformsServiceImpl.getGdsconfigformslaveShow` (line 392),
+combining `getFormSlaveData` (line 87) and `getFormCustomSlaveData`
+(line 121), then optionally layering `getUserFormSlaveData` (line 156).
Two database **views** support the merge by joining the form-master with
the relevant slave table:
diff --git a/en/docs/concepts/index.md b/en/docs/concepts/index.md
index 95a93a9..361b925 100644
--- a/en/docs/concepts/index.md
+++ b/en/docs/concepts/index.md
@@ -87,6 +87,6 @@ a sign the content wants to be a slice instead.
- [No-FK, semantic-FK reality](semantic-fk.md) — how relations actually work.
- [Two customization channels](customization-channels.md) — metadata edits vs. SQL scripts.
- [Customization layers](customization-layers.md) — within Channel 1, how base / per-tenant / per-user overlays merge.
-- [Multi-tenancy and product editions](multi-tenancy.md) — the three scoping axes (`sBrandsId`, `sSubsidiaryId`, `sVersionFlowId`).
+- [Multi-tenancy and product editions](multi-tenancy.md) — row scoping (`sBrandsId`, `sSubsidiaryId`) plus licence-gated module discovery.
- [The metadata-driven request lifecycle](request-lifecycle.md) — the diagram you'll come back to.
- [The three API tiers](api-surface.md) — internal (`xlyEntry`), external (`xlyApi`), inbound webhooks (`xlyInterface`).
diff --git a/en/docs/concepts/master-slave.md b/en/docs/concepts/master-slave.md
index e412b0f..a98ddd7 100644
--- a/en/docs/concepts/master-slave.md
+++ b/en/docs/concepts/master-slave.md
@@ -4,9 +4,10 @@
> This page is about the **document-row** pattern: one header row plus N
> detail rows for a quotation / sales order / work order. The
> **DataSource** master / slave (write-vs-read connection routing via
-> `MasterDataSourceConfig` / `SlaveDataSourceConfig` in `xlyApi`, paired
-> with `MasterBaseMapper.xml` / `SlaveBaseMapper.xml` in `xlyPersist`) is
-> a different concept covered in the [Tech-stack HikariCP row](../reference/maintainer/tech-stack.md#3-cache-in-memory)
+> `MasterDataSourceConfig` / `SlaveDataSourceConfig` in `xlyApi` and
+> `xlyInterface`, paired with `mastermapper/MasterBaseMapper.xml` /
+> `slavemapper/SlaveBaseMapper.xml` co-located in those services) is
+> a different concept covered in the [Tech-stack HikariCP row](../reference/maintainer/tech-stack.md#2-persistence)
> and indirectly in the runtime page. The two senses overlap in name only.
Almost every business document in xly — a quotation, a sales order, a work
diff --git a/en/docs/concepts/modules-forms-vtables.md b/en/docs/concepts/modules-forms-vtables.md
index 02a46f8..07e9566 100644
--- a/en/docs/concepts/modules-forms-vtables.md
+++ b/en/docs/concepts/modules-forms-vtables.md
@@ -92,8 +92,8 @@ module / form / virtual-table combination through one universal
dispatch path. There is no per-module Java; PMs creating new modules
are creating new rows.
-The flip side: that one engine has accumulated 3,500+ lines in
-`BusinessBaseServiceImpl` alone, plus another 800+ in
+The flip side: that one engine has accumulated 3,900+ lines in
+`BusinessBaseServiceImpl` alone, plus another 600+ in
`BusinessGdsconfigformsServiceImpl`. Edge cases, special-case
table handling (e.g., the `mftproductionplanslave` hardcode at
`BusinessBaseServiceImpl.java:1768`), per-tenant overlay merge
@@ -112,17 +112,17 @@ any business-data table to its domain by the three-letter prefix:
| Prefix | Domain | Sample tables (live count) |
|---|---|---|
| `gds` | Framework metadata (modules, forms, fields, permissions, parameters, charts) | `gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`, `gdsjurisdiction`, `gdsroute`, `gdsformconst`, `gdsparameter`, `gdsconfigcharmaster`/`slave` (chart definitions used by the BACK 图表配置 screen), … |
-| `sys` | Framework system (numbering, jurisdiction grants, reports, search, billing settings) — distinct from `gds*` "definition" tier | `sysjurisdiction`, `sysbillnosettings`, `sysreport`, `syssearch`, `sysapi`, `SysSystemSettings`, … (66 tables) |
-| `sis` | Shared lookup tables / classifiers backing dropdowns | `sisbank`, `siscolor`, `sisversionflow`, `sisjurisdictionclassify`, … (78 tables) |
-| `sft` | Login-session / group-permission link tables | `sftlogininfo*`, `sftlogininfojurisdictiongroup`, … (8 tables) |
-| `ele` | Master data ("element"): customer, employee, machine, materials, product, process, semigoods, costframe | `elecustomer*`, `eleemployee*`, `elemachine*`, `elematerials*`, `eleproduct*`, … (88 tables) |
-| `mft` | Manufacturing: work-order, production-plan, production-report | `mftworkordermaster`, `mftproductionplan*`, `mftproductionreport*`, … (72 tables) |
-| `sal` | Sales | `salsalesordermaster`, `salsalesorderslave`, `salsalesorderprocess`, … (65 tables) |
-| `quo` | Quotation | `quoquotationmaster`, `quoquotationslave`, `quoquotationcalc_tmp`, … (12 tables) |
+| `sys` | Framework system (numbering, jurisdiction grants, reports, search, billing settings) — distinct from `gds*` "definition" tier | `sysjurisdiction`, `sysbillnosettings`, `sysreport`, `syssearch`, `sysapi`, `syssystemsettings`, … (68 tables) |
+| `sis` | Shared lookup tables / classifiers backing dropdowns | `sisbank`, `siscolor`, `sisversionflow`, `sisjurisdictionclassify`, … (80 tables) |
+| `sft` | Login-session / group-permission link tables | `sftlogininfo`, `sftlogininfojurisdictiongroup`, … (8 tables) |
+| `ele` | Master data ("element"): customer, employee, machine, materials, product, process, semigoods, costframe | `elecustomer*`, `eleemployee*`, `elemachine*`, `elematerials*`, `eleproduct*`, … (89 tables) |
+| `mft` | Manufacturing: work-order, production-plan, production-report | `mftworkordermaster`, `mftproductionplan*`, `mftproductionreport*`, … (82 tables) |
+| `sal` | Sales | `salsalesordermaster`, `salsalesorderslave`, `salsalesorderprocess`, … (67 tables) |
+| `quo` | Quotation | `quoquotationmaster`, `quoquotationslave`, `quoquotationcalc_tmp`, … (23 tables) |
| `acc` | Accounting | `accordercostanalysis`, `accordercostanalysisoperation`, … (31 tables) |
| `pur` | Purchasing | `purpurchaseapply`, `purpurchasearrive`, `purpurchasechecking`, … (28 tables) |
| `ops` | Outside-processing / outsourcing | `opsoutsidearrive`, `opsoutsidechecking`, `opsoutsideinstore`, … (23 tables) |
-| `cah` | Cashier / financial | `cahcashierinit`, `cahcostchange`, `cahpayment`, `cahreceipt`, … (22 tables) |
+| `cah` | Cashier / financial | `cahcashierinit`, `cahcostchangemaster`, `cahpaymentmaster`, `cahreceiptmaster`, … (22 tables) |
| `sgd` | Semi-goods (半成品) | `sgdsemigoodscheck`, `sgdsemigoodsinstore`, `sgdsemigoodsmatchbill`, … (21 tables) |
| `ept` | Equipment / machine fixed assets | `eptmachinefixedborrow`, `eptmachinefixedchange`, `eptmachinefixedinstore`, … (21 tables) |
| `mit` | Materials inventory transactions | `mitmaterialsadjust`, `mitmaterialscheck`, `mitmaterialsinstore`, … (19 tables) |
diff --git a/en/docs/concepts/multi-tenancy.md b/en/docs/concepts/multi-tenancy.md
index 25633a2..e7d446f 100644
--- a/en/docs/concepts/multi-tenancy.md
+++ b/en/docs/concepts/multi-tenancy.md
@@ -14,7 +14,7 @@ two-paragraph summary you can link from anywhere.
|---|---|---|---|
| **`sBrandsId`** (加工商ID) | almost every business row | per-row | the user's session (`UserInfo.getsBrandsId()`) |
| **`sSubsidiaryId`** (子公司ID) | almost every business row | per-row | the user's session |
-| **`sVersionFlowId`** (版本流程ID) | `gdsmodule` only | per-module | the user's edition (against `sisversionflow`) |
+| **`sVersionFlowId` / `sVersionFlowCode`** (版本流程ID / code) | `gdsmodule` only | per-module tag | edition catalogue metadata; the runtime menu gate uses the licence-derived `sVerifyLicense` module list |
Per-row scoping is universal across business-data tables: both
`sBrandsId` and `sSubsidiaryId` appear on essentially every one. Most
@@ -27,9 +27,11 @@ tables. In practice they hold a single sentinel tenant value shared
across all customers. Convention: "if a row represents tenant-owned
state, both columns are present *and populated from the session*."
-Per-module gating (`sVersionFlowId`) is the opposite — it lives on
-`gdsmodule` only. So edition gating is a one-time filter at module-
-discovery time, not a per-row check.
+Per-module edition metadata is the opposite — it lives on `gdsmodule`
+only. The live runtime does not filter directly on `sVersionFlowId`;
+module discovery is gated by the licence-derived `sVerifyLicense` list
+of permitted `gdsmodule.sId` values. So edition gating is a one-time
+module-discovery filter, not a per-row check.
## How it's enforced
diff --git a/en/docs/concepts/request-lifecycle.md b/en/docs/concepts/request-lifecycle.md
index d9c5d9e..e931dc0 100644
--- a/en/docs/concepts/request-lifecycle.md
+++ b/en/docs/concepts/request-lifecycle.md
@@ -107,9 +107,10 @@ sequenceDiagram
Note over CTRL: AuthorizationInterceptor.preHandle
resolves UserInfo from Redis
RequestAddParamUtil.addParams (16 keys)
CTRL->>SVC: getModelBysId(map)
- SVC->>FORMS: getModelConfigByModleId
(form-master + slaves + overlays)
+ Note over SVC: getModelConfigByModleId (inherited from BaseServiceImpl)
orchestrates the per-master form-master + slave loads
+ SVC->>FORMS: getFormmasterData / getGdsconfigformslaveShow
(form-master + slaves + overlays)
REDIS-->>FORMS: cache hit?
- FORMS->>DB: SELECT ... gdsconfigformmaster ⋈ personalize ⋈ slave ⋈ customslave
+ FORMS->>DB: SELECT ... gdsconfigformmaster ⋈ personalize; per master row, gdsconfigformslave + gdsconfigformcustomslave
DB-->>FORMS: rows
FORMS-->>SVC: formData
@@ -172,12 +173,15 @@ A few things readers expect to find here but don't:
body — the handler branches on whether the body asks for one row or
many.
- **Workflow steps.** When a module has an active approval workflow
- (`bCheck = 1`, populated `sVersionFlowId`, deployed Activiti process),
- additional steps interleave. None of those tables are populated in
- this dev DB; see [Slice 7 (deferred)](../slices/07-workflow.md).
-- **Cache invalidation.** When BACK changes a metadata row, a JMS message
- invalidates cached copies on every running node — `ConsumerChangeGdsModuleThread`
- in `xlyErpJmsConsumer`. Outside the request flow but adjacent to it.
+ (`bCheck = 1`, `gdsmoduleflow` configured, deployed Activiti process,
+ and `ConstantUtils.bCheckflowCheck = true`), additional steps
+ interleave. None of those tables are populated in this dev DB; see
+ [Slice 7 (deferred)](../slices/07-workflow.md).
+- **Cache invalidation.** When BACK changes a metadata row, the save path
+ synchronously calls `BusinessCleanRedisData` / `CleanRedisServiceImpl`,
+ which evicts Spring cache regions from shared Redis. The JMS
+ `ConsumerChangeGdsModuleThread` path is a separate base-data merge
+ channel, not cache invalidation.
## Variations covered by other slices
diff --git a/en/docs/concepts/thesis.md b/en/docs/concepts/thesis.md
index ba89ebe..eacd318 100644
--- a/en/docs/concepts/thesis.md
+++ b/en/docs/concepts/thesis.md
@@ -66,7 +66,7 @@ Three costs are baked into this design and worth being explicit about:
- **Customizations are layered "cleanly"** ([Slice 4](../slices/04-custom-field.md)):
per-tenant overrides sit *on top of* the shared base without forking.
— *Limit:* the cleanliness is a Java-side property. The runtime
- merge logic in `BusinessBaseServiceImpl` is non-trivial (3,500+
+ merge logic in `BusinessBaseServiceImpl` is non-trivial (3,900+
lines), debugging "why does this tenant see field X but not Y"
involves chasing through `gdsconfigformpersonalize` +
`gdsconfigformcustomslave` + `gdsconfigformuserslave` interactions.
diff --git a/en/docs/contributing/index.md b/en/docs/contributing/index.md
index d939905..cc07e36 100644
--- a/en/docs/contributing/index.md
+++ b/en/docs/contributing/index.md
@@ -41,10 +41,10 @@ on each run. Hand-edits in those directories will be lost.
## Pre-commit hook (optional but recommended for local edits)
-Install once:
+Install once (run from `xly-wiki/en/`):
```bash
-ln -s ../../scripts/precommit.sh .git/hooks/pre-commit
+ln -s ../../en/scripts/precommit.sh ../.git/hooks/pre-commit
chmod +x scripts/precommit.sh
```
diff --git a/en/docs/glossary/index.md b/en/docs/glossary/index.md
index d1449d2..4b8e525 100644
--- a/en/docs/glossary/index.md
+++ b/en/docs/glossary/index.md
@@ -23,3 +23,7 @@ preserves Chinese terms in the body text and translates only here.
| 流水号 | flow number / sequence | Document numbering — `sysbillnosettings`. |
| 抄送 | cc (carbon copy) | `biz_todo_copyto`. |
| 待办 | todo | `biz_todo_item`. |
+| 审核 | audit / approve | The "approve" click in BACK that flips a document's `bCheck` flag and runs `Sp_
_check*` transition procs. The canonical workflow trigger. |
+| 驳回 | reject | The counterpart to 审核 — reject a document; in customer overrides like `领班驳回.sql` it walks a multi-level rejection chain. |
+| — | BACK | The admin / builder web app (`http://:8597`) where PMs configure modules, forms, and permissions. Spelled uppercase to match the deployment artefact. |
+| — | FROUNT | The end-user web app (`http://:8598`) where day-to-day work happens. Misspelling of "Front" preserved from the deployment artefact. |
diff --git a/en/docs/index.md b/en/docs/index.md
index de4b21f..6a56f42 100644
--- a/en/docs/index.md
+++ b/en/docs/index.md
@@ -40,7 +40,7 @@ which Reference chapter you go deep on.
- Scheduler modules (`xlyErpTask`, `xlyPlatTask`) — commented out in `settings.gradle`; cron / Quartz wiring is not part of the wiki's framework runtime.
- Test scaffolding modules (`xlyTestService`, `xlyTestController`) — historical, not part of the framework runtime.
- Per-tenant schema drift between `xlyweberp_*` databases — wiki targets one schema.
-- Backup tables (`*_bak`, `*0302`, `*_copy1`, `*_history`, `*YYYYMMDD[HHMMSS]`-suffixed snapshots, etc.) — the auto-catalog generates a page for each because they exist on disk; the prose pages don't cover them as a family. ~56 such tables in the live schema.
+- Backup tables (`*_bak`, `*0302`, `*_copy1`, `*_history`, `*YYYYMMDD[HHMMSS]`-suffixed snapshots, etc.) — the auto-catalog generates a page for each because they exist on disk; the prose pages don't cover them as a family. ~44 such tables in the live schema.
- The MongoDB document store (`spring.data.mongodb.uri` in the yaml profiles, document classes under `xlyEntity/.../mongo/`). Of 22 `@Document` classes there, 20 are `PLAT_*`-named — the only outliers are two `DIKE_TEST*` scratch classes. The single `MongoTemplate` caller is `xlyPersist/.../dao/platmongo/BaseMongoDao` (the `dao/platmongo/` package gives away its plat-tier intent), which has no in-tree consumers on the cleanup branch — the `xlyPlat*` modules that used to extend it are all commented out of `settings.gradle`. The framework layer this wiki covers is MySQL-only; the Mongo wiring stays compiled but dormant.
> **Note on `xlyPlatConstant`.** It carries the `xlyPlat*` prefix but is in scope: `xlyPersist` imports two utility classes from it (`com.xly.xlyplatconstant.contant.thread.MultiThreadServer`, `com.xly.xlyplatconstant.contant.TimeContant`). Treat it as a misnamed shared-utility module, not a platform-tier module.
diff --git a/en/docs/reference/builder/attach-workflow.md b/en/docs/reference/builder/attach-workflow.md
index 8e5a5d9..516059a 100644
--- a/en/docs/reference/builder/attach-workflow.md
+++ b/en/docs/reference/builder/attach-workflow.md
@@ -5,7 +5,7 @@
> returns 0; `gdsmoduleflow = 0`; `gdsmodule WHERE bCheck = 1` matches
> 0 rows. The dispatch path itself is hard-disabled by
> `ConstantUtils.bCheckflowCheck = false` (see
-> [Activiti integration](../../reference/maintainer/activiti.md)). The
+> [Activiti integration](../maintainer/activiti.md)). The
> recipe below is the **code-derived hypothesis** — it has not been
> exercised against a live deployment.
@@ -19,8 +19,10 @@
>
> 1. Set `gdsmodule.bCheck = 1` to flag the module as workflow-enabled.
> 2. Populate `gdsmoduleflow` with the flow's window configuration.
-> 3. Set `gdsmodule.sVersionFlowId` and `sVersionFlowCode` to the
-> Activiti process definition's ID and key.
+> 3. Link the module/button to the process in `gdsmoduleflow` using
+> the fields expected by `CheckExamineFlowServiceImpl`.
+> `gdsmodule.sVersionFlowId` / `sVersionFlowCode` are edition tags,
+> not the live Activiti binding.
> 4. Deploy the BPMN process definition through the Activiti REST API
> or whichever workflow deployment surface is enabled in the target
> environment.
diff --git a/en/docs/reference/builder/define-form.md b/en/docs/reference/builder/define-form.md
index 4e19598..11dc6f4 100644
--- a/en/docs/reference/builder/define-form.md
+++ b/en/docs/reference/builder/define-form.md
@@ -19,7 +19,7 @@ One row registers the module's existence. Required columns:
| `sChinese` / `sEnglish` / `sBig5` | display name in three languages |
| `sParentId` | parent module's `sId` — places this module in the menu tree |
| `sBrandsId` / `sSubsidiaryId` | tenant scope — should be your tenant's IDs (or `'1111111111'` if standard / system-level) |
-| `sVersionFlowId` | the product edition this module belongs to (look up in `sisversionflow`) |
+| `sVersionFlowId` / `sVersionFlowCode` | product-edition catalogue tags (look up in `sisversionflow` where populated); live menu visibility is still gated by the licence-derived module list |
| `bVisible` | `1` to show in the menu |
| `bInvalid` | `0` for active |
diff --git a/en/docs/reference/builder/define-vtable.md b/en/docs/reference/builder/define-vtable.md
index 2b943bf..a40be9a 100644
--- a/en/docs/reference/builder/define-vtable.md
+++ b/en/docs/reference/builder/define-vtable.md
@@ -98,7 +98,7 @@ A representative real row from the dev DB:
sId = 192116810113315231587698560
sChinese = 包装方式 (Packing method)
sTbName = SisPacking
-sParentId = (root)
+sParentId = 192116810113315231564967560 (parent classification row)
```
**Slave columns** (`gdsconfigtbslave`, 10 rows under that `sParentId`)
diff --git a/en/docs/reference/builder/index.md b/en/docs/reference/builder/index.md
index dbafe42..58771c6 100644
--- a/en/docs/reference/builder/index.md
+++ b/en/docs/reference/builder/index.md
@@ -12,5 +12,3 @@ metadata-table column names and a worked example.
- [How to set permissions](permissions.md) — `gdsjurisdiction` recipe.
If you need details on a specific table or proc, see the [Auto-Catalog](../../auto-catalog/index.md).
-
-> **STUB.** Recipes will be filled in as their concepts are exercised by slices.
diff --git a/en/docs/reference/builder/permissions.md b/en/docs/reference/builder/permissions.md
index b937e4f..29e5a28 100644
--- a/en/docs/reference/builder/permissions.md
+++ b/en/docs/reference/builder/permissions.md
@@ -8,7 +8,7 @@ correctly here matters because the names look superficially similar.
| Table | Granularity | What it stores | Loaded when |
|---|---|---|---|
-| [`gdsjurisdiction`](../../auto-catalog/tables/gdsjurisdiction.md) | per **module** | the **catalog** of actions/buttons that exist on each module (`BtnAdd`, `BtnUpd`, `BtnDel`, …) | every `getModelBysId` call (skipped for ADMIN — see [Slice 1](../../slices/01-hello-world.md)) |
+| [`gdsjurisdiction`](../../auto-catalog/tables/gdsjurisdiction.md) | per **module** | the **catalog** of actions/buttons that exist on each module (`BtnAdd`, `BtnUpd`, `BtnDel`, `BtnPrint`, …) | every `getModelBysId` call (skipped for ADMIN — see [Slice 1](../../slices/01-hello-world.md)) |
| [`sysjurisdiction`](../../auto-catalog/tables/sysjurisdiction.md) | per **role** (and optionally per user) | the **grants** — which role (or user) can perform which action on which module | resolved on `getModelBysId` for the user's role; `BusinessGdsconfigformsServiceImpl.getJurisdictionData()` |
The mental model: `gdsjurisdiction` is the *menu* of permission items
@@ -22,7 +22,7 @@ A tree of permission items keyed by module:
| Column | Meaning |
|---|---|
| `sParentId` | the `gdsmodule.sId` this permission item belongs to |
-| `sName` | the action key — e.g., `BtnAdd`, `BtnUpd`, `BtnDel`, `BtnExport` |
+| `sName` | the action key — e.g., `BtnAdd`, `BtnUpd`, `BtnDel`, `BtnPrint` |
| `sChinese` / `sEnglish` / `sBig5` | display label (e.g., `新增`) |
| `iOrder` | sort order in the permission UI |
| `sBrandsId` / `sSubsidiaryId` | tenant scope |
@@ -89,7 +89,7 @@ For a new module, the typical path is:
1. Define the module + form ([recipe](define-form.md)).
2. Insert `gdsjurisdiction` rows for each button/action the module
- exposes (`BtnAdd`, `BtnUpd`, `BtnExport`, …). One row per
+ exposes (`BtnAdd`, `BtnUpd`, `BtnPrint`, …). One row per
action; `sParentId = your module's sId`.
3. Decide which roles get which actions, and insert `sysjurisdiction`
rows: `sParentId = module sId`, `sJurisdictionClassifyId = role sId`,
diff --git a/en/docs/reference/maintainer/activiti.md b/en/docs/reference/maintainer/activiti.md
index b4cddd6..24fd422 100644
--- a/en/docs/reference/maintainer/activiti.md
+++ b/en/docs/reference/maintainer/activiti.md
@@ -158,7 +158,7 @@ the instance.
### A real Path-1 customisation example
-[Slice 5](../../slices/05-customer-sql-override.md#worked-example-2-builds-a-multi-level-approval-workflow)
+[Slice 5](../../slices/05-customer-sql-override.md#worked-example-2-万昌-builds-a-multi-level-approval-workflow)
walks through 万昌's `领班驳回.sql` — a customer-side multi-level
approval rejection. It's the canonical example of how customers
extend Path 1 when the single-`bCheck` flag isn't enough: they
@@ -222,9 +222,10 @@ Despite the dev DB being idle, the engine boots with `xlyEntry`:
a `SpringProcessEngineConfiguration` bean.
- `xlyEntry/build.gradle` includes `xlyFlow` as `api project(':xlyFlow')`,
so the starter is on the runtime classpath of the `xlyEntry` WAR.
-- `xlyEntry/.../EntryApplicationBoot.java:23-24` excludes only
+- `xlyEntry/.../EntryApplicationBoot.java:23-24` excludes
+ `DataSourceAutoConfiguration`,
`org.activiti.spring.boot.SecurityAutoConfiguration` (the
- REST-endpoint security adapter) and Spring's own
+ REST-endpoint security adapter), and Spring's own
`SecurityAutoConfiguration`. **Activiti's main engine
auto-config is NOT excluded** → the engine starts.
- `xlyFlow/.../activiti/config/ActivitiConfig.java` is a
@@ -267,7 +268,7 @@ plus the modeler subpackage are real call sites. Selected anchors:
| Save a model in the modeler | `ModelerController.create()` :122 | `repositoryService.saveModel()` + `addModelEditorSource()` |
| Deploy a BPMN at runtime | `ModelerController.deploy()` :147 | `repositoryService.createDeployment().addString(name, bpmnXml).deploy()` |
| List process definitions | `ProcessDefinitionController` :135 | `repositoryService.createProcessDefinitionQuery()` |
-| Read engine config | `ProcessActController` :281 | `ProcessEngines.getDefaultProcessEngine()` |
+| Read engine config | `BizTodoItemServiceImpl` :126 | `ProcessEngines.getDefaultProcessEngine()` |
| Bridge xly users into Activiti identity | `act_id_user` / `act_id_group` / `act_id_membership` are **views** projecting xly's `sftlogininfo*` schema | xly does not write to Activiti's identity tables; the views fake them |
## URLs the modeler exposes (xlyFlow controllers, on xlyEntry's port)
@@ -291,7 +292,7 @@ authoritative.
This is a wiki-internal correction worth flagging: the class file
exists at `xlyEntry/src/main/java/com/xly/web/businessweb/CheckFlowController.java`
-but its body is **22 lines, zero handler methods** — just a
+but its body is **25 lines, zero handler methods** — just a
`@RestController @RequestMapping(value="/checkflow")` shell with no
content. Earlier versions of this wiki described `/checkflow/*` as
"Activiti workflow surface (approve / reject / view)"; that is not
diff --git a/en/docs/reference/maintainer/cache-invalidation.md b/en/docs/reference/maintainer/cache-invalidation.md
index 91926c7..82ddc3d 100644
--- a/en/docs/reference/maintainer/cache-invalidation.md
+++ b/en/docs/reference/maintainer/cache-invalidation.md
@@ -17,7 +17,7 @@ flowchart TB
PM[PM clicks 保存 in BACK]:::ok
SAVE["BusinessBaseServiceImpl
add/update/deleteBusinessData"]
- EVICT["BusinessCleanRedisData.delCleanRedisData
→ CleanRedisServiceImpl
17 @CacheEvict methods"]:::ok
+ EVICT["BusinessCleanRedisData.delCleanRedisData
→ CleanRedisServiceImpl
18 cache regions for gdsmodule"]:::ok
REDIS[("Redis
(shared across nodes)")]:::ok
DB[("MySQL
row written")]:::ok
@@ -72,7 +72,7 @@ Next /business/getModelBysId call re-reads from DB and re-populates
The cleaner methods are in
`xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java`.
A representative one — invoked when `gdsmodule` rows change — evicts
-17 cache regions in a single call:
+18 cache regions in a single call:
```
@CacheEvict(value = {
@@ -130,7 +130,9 @@ not Spring Cache), Spring Boot 2.2.5 auto-configures
**Empirically verified** against the live dev Redis at
`118.178.19.35:16379` (database 0): 233 of 267 keys use Spring Cache's
default `::` separator. Sample key shape matching the
-`@Cacheable` SpEL spec from `BusinessGdsconfigformsServiceImpl.java:209-211`:
+`@Cacheable` annotation on `getFormconstData` at
+`BusinessGdsconfigformsServiceImpl.java:189-190` (default key derived
+from all params):
```
businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111}
diff --git a/en/docs/reference/maintainer/deployment.md b/en/docs/reference/maintainer/deployment.md
index d5f3a34..35e240d 100644
--- a/en/docs/reference/maintainer/deployment.md
+++ b/en/docs/reference/maintainer/deployment.md
@@ -117,7 +117,7 @@ files. The active profile is selected at startup via
## Disabled in `settings.gradle`
-The cleanup branch comments out 12 `include` lines. Three are non-Plat
+The cleanup branch comments out 17 `include` lines. Three are non-Plat
modules present on disk:
- `xlyErpTask` — long-running background tasks.
@@ -127,7 +127,7 @@ modules present on disk:
- `xlyFile` — older file-management module, superseded by
`xlyPlatFileUpload` (also commented out).
-The remaining nine commented-out includes are `xlyTestService`,
+The remaining 14 commented-out includes are `xlyTestService`,
`xlyTestController`, and the full `xlyPlat*` family except
`xlyPlatConstant` — i.e. `xlyPlatTask`, `xlyPlatJmsProductor`,
`xlyPlatJmsConsumer`, `xlyPlatReportForm`, `xlyPlatFileUpload`,
@@ -161,8 +161,9 @@ Three communication channels:
schema. Most cross-service "communication" is implicit through
shared tables.
2. **Messaging** — both ActiveMQ/JMS and RocketMQ exist in the codebase.
- Cache invalidation ([cache invalidation on metadata change](cache-invalidation.md))
- uses the ActiveMQ/JMS path.
+ ActiveMQ/JMS carries base-data merge and document fan-out jobs; Redis
+ cache invalidation is synchronous `@CacheEvict` in the BACK save path
+ (see [cache invalidation on metadata change](cache-invalidation.md)).
3. **HTTP REST** — for synchronous calls (e.g., xlyApi calling xlyEntry's
`/business/*` endpoints).
@@ -185,7 +186,7 @@ Profiles split by service:
`bgj` (lowercase). `dev` is the in-repo default.
- **xlyApi**: `local` (default in repo), `dev`, `linux`, `win`.
- **xlyInterface**: `dev` only.
-- **xlyFlow**: `dev` (empty file).
+- **xlyFlow**: `dev` (datasource-only).
- **xlyFace**: `win` (default), `dev`, `linux`, `local`.
- **xlyPlc**: `dev` (default) plus 7 press-model profiles
(`15S`, `S10`, `T0`, `T1`, `CT`, `yt`, `pro` — uppercase / mixed-case,
diff --git a/en/docs/reference/maintainer/index.md b/en/docs/reference/maintainer/index.md
index 92329c9..c362978 100644
--- a/en/docs/reference/maintainer/index.md
+++ b/en/docs/reference/maintainer/index.md
@@ -9,10 +9,9 @@ metadata table to the `gds*` family, or wire in a new third-party integration.
- [Tech stack](tech-stack.md) — library inventory by category (versions, where each is used, and why).
- [The runtime: BusinessBaseController & friends](runtime.md) — the metadata-driven dispatch loop.
- [Generic procedure dispatch](proc-dispatch.md) — `GenericProcedureCallController` deep dive.
-- [Cache invalidation on metadata change](cache-invalidation.md) — `ConsumerChangeGdsModuleThread` and friends.
-- [SQL templates (`xlyEntry/templesql/`)](sql-templates.md) — runtime SQL generation.
+- [Cache invalidation on metadata change](cache-invalidation.md) — synchronous `@CacheEvict`, and why similarly named JMS consumers are not cache-bust.
+- [SQL templates (`xlyEntry/templesql/`)](sql-templates.md) — proc scaffolds engineers fill in.
- [Multi-service deployment](deployment.md) — `xlyApi` vs `xlyEntry` vs `xlyInterface`.
+- [Metadata-management services (`xlyManage`)](management-services.md) — the `Gds*ServiceImpl` family behind the BACK builder.
+- [BI / KPI / Charts engine](bi-engine.md) — the homebrewed dashboard layer.
- [Activiti integration](activiti.md) — version skew, schemas, custom delegates.
-
-> **STUB.** Each sub-page will be filled with a real code-trace once the matching
-> slice exercises it.
diff --git a/en/docs/reference/maintainer/management-services.md b/en/docs/reference/maintainer/management-services.md
index 9e25bfd..52023c8 100644
--- a/en/docs/reference/maintainer/management-services.md
+++ b/en/docs/reference/maintainer/management-services.md
@@ -16,7 +16,7 @@ the read/write logic for every `gds*` table. The runtime
| Service | Lines | Owns | BACK page |
|---|---:|---|---|
-| `GdsmoduleServiceImpl` | 729 | `gdsmodule` (modules), `gdsroute` (URL whitelist), module-tree CRUD, edition gating | 系统模块配置 |
+| `GdsmoduleServiceImpl` | 729 | `gdsmodule` (modules), `gdsroute` (URL whitelist), module-tree CRUD | 系统模块配置 |
| `GdsconfigformServiceImpl` | 878 | `gdsconfigformmaster`, `gdsconfigformslave`, `gdsconfigformcustomslave`, `gdsconfigformpersonalize` (form definitions + per-tenant overlays) | 界面显示内容配置 |
| `GdsconfigtbServiceImpl` | 555 | `gdsconfigtbmaster`, `gdsconfigtbslave` (virtual-table definitions) | 数据表内容配置 |
| `SqlScriptsServiceImpl` | 489 | DDL / proc / view scripts authored in BACK; uses templates from [`templesql/`](sql-templates.md) | Mysql脚本配置 |
@@ -63,12 +63,11 @@ own dedicated controller+service pair.
end-to-end usable: the BACK builder can also generate the
schema-migration SQL the overlay implies.
- **`GdsmoduleServiceImpl` includes `getModuleTreePro`** — the
- per-edition / per-tenant module-tree resolution called by the SPA at
- login (the first `/gdsmodule/getModuleTreePro` request you see in
- the live trace). Edition gating
- ([Slice 2](../../slices/02-multi-tenancy.md)) happens here, as a
- filter on `gdsmodule.sVersionFlowId` against the user's
- `sisversionflow` row.
+ module-tree resolution called by the SPA at login (the first
+ `/gdsmodule/getModuleTreePro` request you see in the live trace).
+ Edition visibility ([Slice 2](../../slices/02-multi-tenancy.md)) is
+ ultimately enforced by the licence-derived `sVerifyLicense` module-id
+ list used by the menu SQL, not by a direct `sVersionFlowId` predicate.
- **`SqlScriptsServiceImpl`** glues the
[`templesql/`](sql-templates.md) scaffolds into the BACK script
authoring screen. Engineers fill in the placeholder spec; the
@@ -85,14 +84,18 @@ own dedicated controller+service pair.
## Cache-invalidation hookpoints
-Every write through these services synchronously calls
-`BusinessCleanRedisData.delCleanRedisData*` on commit. This is why
+Every write through these services carries its own `@CacheEvict`
+annotations directly on the method (e.g., `GdsmoduleServiceImpl.java:96`
+evicts nine cache regions on `addGdsmodule`). This is why
metadata edits in BACK take effect immediately on every node — the
shared Redis cache (RedisCacheManager, see
[cache-invalidation.md](cache-invalidation.md)) gets the relevant
regions evicted in the same transaction the write commits. There is
**no JMS fan-out here for cache-bust** — that's a common
misconception, addressed in detail on the cache-invalidation page.
+(Business-data writes through `BusinessBaseServiceImpl` use the
+separate `BusinessCleanRedisData.delCleanRedisData*` dispatcher; see
+[cache-invalidation.md](cache-invalidation.md) for that path.)
## What's *not* in `xlyManage`
@@ -109,7 +112,7 @@ misconception, addressed in detail on the cache-invalidation page.
| Symptom | First place to look |
|---|---|
| BACK 修改/新增 against `gdsconfigform*` returns "操作失败" | `GdsconfigformServiceImpl` — check field validation + the matching DDL-script generation path |
-| Edition gating shows wrong modules | `GdsmoduleServiceImpl.getModuleTreePro` — verify the user's `sVersionFlowId` resolution |
+| Edition gating shows wrong modules | menu/module-tree SQL plus `VerifyLicense.getModelAllList()` / `sVerifyLicense` — verify the permitted module-id list |
| BACK script-authoring screen produces broken SQL | `SqlScriptsServiceImpl` + the [templesql scaffolds](sql-templates.md) |
| Permission catalogue (BtnAdd / BtnUpd / …) missing for a module | `GdsjurisdictionServiceImpl` — check the rows under that `sParentId` |
| User can log in to BACK but FROUNT is empty | `GdslogininfoServiceImpl` — check the `sftlogininfo*` link tables |
diff --git a/en/docs/reference/maintainer/proc-dispatch.md b/en/docs/reference/maintainer/proc-dispatch.md
index ec6d767..beff792 100644
--- a/en/docs/reference/maintainer/proc-dispatch.md
+++ b/en/docs/reference/maintainer/proc-dispatch.md
@@ -119,15 +119,15 @@ templates](sql-templates.md) for the loader and the placeholder syntax.
The 1687 procedures in the live DB cluster around a few naming molds
beyond the bare `Sp_*` family:
-| Mold | Approx count | Role |
+| Mold | Live count | Role |
|---|---:|---|
| `Sp_*` | most | The dominant family, dispatched by `sSaveProName` / `sCalcProName` / `sProcName` etc. |
-| `Sp_*_BeforeSave` | ~62 | Pre-save hooks. Pair with `sSaveProNameBefore`. |
-| `Sp_*_AfterSave` / `Sp_*_SaveReturn` | ~62 / ~54 | Post-save hooks; `_SaveReturn` writes back into the parent transaction. |
-| `Sp_*_Calc` | ~178 | Calculation procs invoked by button-press flow (`sCalcProName` / `sButtonParam`). |
-| `sp_btn_*` | ~65 | Button-event sub-family — typically `sp_btn_calc*` / `sp_btn_validate*` (lowercase by convention). |
-| `PRO_ERPMERGE*` | ~11 | Data-migration utilities. **Not dispatched by the runtime** — engineer-only. |
-| `PRO_*` (other) | ~12 | Other one-off utilities. |
+| `Sp_beforeSave*` | 78 | Pre-save hooks. Pair with `sSaveProNameBefore`. |
+| `Sp_afterSave*` / `Sp_saveReturn*` | 62 / 54 | Post-save hooks; `Sp_saveReturn*` writes back into the parent transaction. |
+| `Sp_Calc*` | 184 | Calculation procs invoked by button-press flow (`sCalcProName` / `sButtonParam`). |
+| `Sp_Btn*` | 65 | Button-event sub-family — typically button calculations / validators. |
+| `PRO_ERPMERGE*` | 22 | Data-merge utilities. **Not dispatched by the runtime** — engineer / JMS-consumer paths. |
+| `PRO_*` (all) | 23 | `PRO_ERPMERGE*` plus one-off utilities. |
| `Get_*`, `del_*`, `Cal*`, `Tj_*` | small handfuls | Legacy / domain-specific helpers. Not part of the generic-dispatch contract. |
A typo in any of the dispatched columns gets an `Sp_*`-shaped target,
diff --git a/en/docs/reference/maintainer/running-locally.md b/en/docs/reference/maintainer/running-locally.md
index 339768e..e956727 100644
--- a/en/docs/reference/maintainer/running-locally.md
+++ b/en/docs/reference/maintainer/running-locally.md
@@ -69,7 +69,7 @@ boot:
- `xlyApi` — start it as a second JVM if you need the external API surface.
- `xlyInterface` — same.
- `xlyPlc`, `xlyFlow`, `xlyFace` — same; each has its own application class and profile.
-- The `xlyErpJms*` consumers — they are standalone Spring Boot apps. Without them, JMS-driven cache invalidation will not happen across nodes (fine for a single-node dev box).
+- The `xlyErpJms*` consumers — they are standalone Spring Boot apps. Without them, ActiveMQ base-data merge and document fan-out jobs will not run. Redis cache invalidation for BACK saves still works through synchronous `@CacheEvict`.
For multi-service local development, see
[Multi-service deployment](deployment.md).
diff --git a/en/docs/reference/maintainer/runtime.md b/en/docs/reference/maintainer/runtime.md
index db8005d..7125f12 100644
--- a/en/docs/reference/maintainer/runtime.md
+++ b/en/docs/reference/maintainer/runtime.md
@@ -16,7 +16,7 @@ controllers and services that carry most of the generic form runtime.
| `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 22 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) |
+| `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/`
@@ -226,7 +226,7 @@ Two flagged in slices that belong here permanently:
The "one controller writes any row in any table" pattern is the
core data-driven move. It also concentrates risk:
-- **`BusinessBaseServiceImpl` is ~3,500 lines** of tightly
+- **`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
diff --git a/en/docs/reference/maintainer/tech-stack.md b/en/docs/reference/maintainer/tech-stack.md
index 30e9a69..9e366b2 100644
--- a/en/docs/reference/maintainer/tech-stack.md
+++ b/en/docs/reference/maintainer/tech-stack.md
@@ -48,7 +48,7 @@ page records facts only.
| Library | Version | Where | In-scope source references |
|---|---|---|---|
-| MyBatis | 2.1.2 (`mybatis-spring-boot-starter`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 102 Java files import `org.apache.ibatis.*` or `org.mybatis.*` — 76 in `xlyPersist`, the rest spread across xlyApi, xlyFlow, xlyInterface. Mapper XMLs live at `xlyPersist/src/main/resources/mapper/{erptable,business,test}/`. |
+| MyBatis | 2.1.2 (`mybatis-spring-boot-starter`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 98 Java files import `org.apache.ibatis.*` or `org.mybatis.*`, concentrated in `xlyPersist` with the rest spread across xlyApi, xlyFlow, xlyInterface. Mapper XMLs live at `xlyPersist/src/main/resources/mapper/{erptable,business,test}/`. |
| MyBatis-Plus | 3.3.0 | `xlyApi/build.gradle` | 2 files: `xlyApi/src/main/java/com/xly/api/util/SqlUtil.java`, `xlyApi/src/main/java/com/xly/api/web/BaseController.java`. Not used outside xlyApi. |
| MySQL Connector/J | 8.0.13 | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | yaml `spring.datasource.driverClassName: com.mysql.cj.jdbc.Driver` (e.g., `xlyEntry/.../application-local.yml:127`). |
| MSSQL JDBC | sqljdbc4 3.0 (Maven) + `mssql-jdbc-6.2.2.jre8.jar` (local jar in `xlyFlow/`, `xlyInterface/`) | `xlyApi/build.gradle`, `xlyInterface/build.gradle`, `xlyFlow/build.gradle` | 5 files: 3 in `xlyFlow/src/`, 2 in `xlyInterface/src/`. |
@@ -143,7 +143,7 @@ third-party code.
| Library | Version | Where | In-scope source references |
|---|---|---|---|
-| FastJson | 1.2.15 (`xlyPersist`, `xlyApi`) / 1.2.60 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 83 files import `com.alibaba.fastjson.*` across in-scope modules (xlyBusinessService=39, xlyEntry=11, xlyInterface=9, xlyPersist=9, xlyFlow=6, xlyMsg=5, xlyApi=4). |
+| FastJson | 1.2.15 (`xlyPersist`, `xlyApi`) / 1.2.60 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 84 files import `com.alibaba.fastjson.*` across in-scope modules (xlyBusinessService=39, xlyEntry=11, xlyInterface=10, xlyPersist=9, xlyFlow=6, xlyMsg=5, xlyApi=4). |
| Jackson | `jackson-databind` 2.9.7 (`xlyFlow` explicit) + transitive via Spring | `xlyFlow/build.gradle` | 22 files import `com.fasterxml.jackson.*` (xlyFlow=8, xlyInterface=9, xlyApi=2, xlyPersist=2, xlyEntry=1). |
| Hutool | `hutool-all` 5.6.5 (`xlyPersist`) / 5.8.5 (`xlyApi`, `xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 271 files import `cn.hutool.*` across every in-scope module (xlyBusinessService=93, xlyFlow=47, xlyApi=37, xlyPersist=33, xlyEntry=25, xlyInterface=23, xlyMsg=10, xlyManage=2, xlyPlc=1). |
| commons-lang3 | 3.6 (`xlyPersist`) / 3.8.1 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyFlow/build.gradle` | 39 files import `org.apache.commons.lang3.*` (xlyFlow=23, xlyPersist=7, xlyEntry=3, xlyApi=2, xlyBusinessService=3, xlyMsg=1). |
diff --git a/en/docs/slices/01-hello-world.md b/en/docs/slices/01-hello-world.md
index c3e1619..04ffc4b 100644
--- a/en/docs/slices/01-hello-world.md
+++ b/en/docs/slices/01-hello-world.md
@@ -227,9 +227,11 @@ POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
### 5. Cache invalidation
A modified `gds*` row in any of the four metadata tables invalidates cached
-copies on every running node. xly does this through a JMS message:
-`xlyErpJmsConsumer/.../ConsumerChangeGdsModuleThread.java` listens for the
-"module changed" event and clears the relevant Redis keys. See
+copies on every running node through the shared Redis store. The save path
+calls `BusinessCleanRedisData.delCleanRedisData*`, which fires Spring
+`@CacheEvict` synchronously in the BACK process. The similarly named JMS
+`CHANGE_GDS_MODULE` path runs base-data merge procedures; it does not clear
+Redis. See
[Cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md).
### 6. Browser confirms
diff --git a/en/docs/slices/02-multi-tenancy.md b/en/docs/slices/02-multi-tenancy.md
index 1674b1c..1505e80 100644
--- a/en/docs/slices/02-multi-tenancy.md
+++ b/en/docs/slices/02-multi-tenancy.md
@@ -17,7 +17,7 @@ xly's tenancy has **three** dimensions, applied at different layers:
|---|---|---|---|
| **`sBrandsId`** (加工商ID) | Almost every business row | Per-row | "Which manufacturer/company owns this row?" |
| **`sSubsidiaryId`** (子公司ID) | Almost every business row | Per-row | "Which subsidiary within the company?" |
-| **`sVersionFlowId`** (版本流程ID) | `gdsmodule` only | Per-module | "Which product edition is this module part of?" |
+| **`sVersionFlowId` / `sVersionFlowCode`** (版本流程ID / code) | `gdsmodule` only | Per-module tag | "Which product edition is this module tagged for?" Runtime gating uses the licence-derived module list. |
The first two are **per-row** scoping. The third is **per-module** filtering
applied at module-list load time. Different mechanisms, different layers.
@@ -174,9 +174,10 @@ Within `gdsmodule` (1358 rows in the dev DB), three tagging patterns coexist:
- *Multi-tenant scoping* (new concept page) — `sBrandsId`/`sSubsidiaryId`
as the per-row tenant boundary; the framework's universal injector
(`RequestAddParamUtil`).
-- *Product editions* (new concept page) — `sVersionFlowId` against
- `sisversionflow` as the per-module visibility filter; the difference
- between scoping (per-row) and gating (per-module).
+- *Product editions* (concept page) — `sVersionFlowId` /
+ `sVersionFlowCode` as catalogue tags, with actual visibility gated
+ by the licence-derived module list; the difference between scoping
+ (per-row) and gating (per-module).
These will be added to Concepts as part of the next backfill pass.
@@ -186,8 +187,9 @@ These will be added to Concepts as part of the next backfill pass.
- [The runtime](../reference/maintainer/runtime.md) — `RequestAddParamUtil`
belongs in the runtime chapter as the universal tenant-context injector.
-- New page: *Multi-tenant query patterns* — the conventions every MyBatis
- mapper and every stored procedure must follow to stay tenant-safe.
+- [Multi-tenancy and product editions](../concepts/multi-tenancy.md) —
+ the conventions every MyBatis mapper and stored procedure must follow
+ to stay tenant-safe.
## Open verification items
diff --git a/en/docs/slices/04-custom-field.md b/en/docs/slices/04-custom-field.md
index a85d527..34f3667 100644
--- a/en/docs/slices/04-custom-field.md
+++ b/en/docs/slices/04-custom-field.md
@@ -109,8 +109,8 @@ configuration: there are no foreign keys enforcing that
`gdsconfigformcustomslave.sParentId` actually exists in
`gdsconfigformmaster.sId`. Orphan rows are possible and would be silently
ignored at merge time. A maintainer audit script that flags such orphans
-is on the [Maintainer Reference](../reference/maintainer/runtime.md)'s
-TODO list.
+belongs in the maintainer toolkit; the current runtime does not enforce
+that relationship.
## Why it works without code changes — and what that costs
diff --git a/en/docs/slices/05-customer-sql-override.md b/en/docs/slices/05-customer-sql-override.md
index ec5e86c..eda3145 100644
--- a/en/docs/slices/05-customer-sql-override.md
+++ b/en/docs/slices/05-customer-sql-override.md
@@ -248,7 +248,7 @@ Verified against the dev DB recon target (`xlyweberp_saas_ai`):
So 万昌's "Foreman Rejection" workflow is **a customer-built
state-machine atop xly's button primitive**: schema extension +
custom procs + custom audit log. The framework provides only the
-button-press dispatch (via `/business/genericProcedureCall*` or the
+button-press dispatch (via `/procedureCall/doGenericProcedureCall` or the
button-param hook on the form-slave). Everything else — what state
the document is in, what flags toggle, what audit text gets logged —
is customer-side.
diff --git a/en/docs/slices/index.md b/en/docs/slices/index.md
index 7709a8b..81771d5 100644
--- a/en/docs/slices/index.md
+++ b/en/docs/slices/index.md
@@ -14,9 +14,9 @@ Re-read earlier slices once you've read later ones — the cross-refs back-fill.
| # | Slice | Concept(s) introduced |
|---|---|---|
| 1 | [a CRUD module (Hello World)](01-hello-world.md) | modules, forms, master/slave, jurisdiction |
-| 2 | [multi-tenancy and product editions](02-multi-tenancy.md) | sBrandsId/sSubsidiaryId scoping, sVersionFlowId editions |
+| 2 | [multi-tenancy and product editions](02-multi-tenancy.md) | sBrandsId/sSubsidiaryId scoping, licence-gated editions |
| 3 | [a module with a report](03-report.md) | views, report templates, jxls |
-| 4 | [extending — a custom field](04-custom-field.md) | gdsconfigformuserslave, schema-less extension |
+| 4 | [extending — a custom field](04-custom-field.md) | gdsconfigformcustomslave, schema-less extension |
| 5 | [extending — a per-customer SQL override](05-customer-sql-override.md) | script/客户/, override channel |
| 6 | [a hardware-integrated module](06-hardware.md) | xlyPlc, serial, RPC into the press |
| 7 | [a module with workflow](07-workflow.md) (deferred) | Activiti, biz_flow, approval — needs a deployment with active flows |
diff --git a/zh/docs/api-reference/external.md b/zh/docs/api-reference/external.md
index 970bc89..e933daa 100644
--- a/zh/docs/api-reference/external.md
+++ b/zh/docs/api-reference/external.md
@@ -15,13 +15,13 @@ Content-Type: application/json
{ ...request body, passed through as `sBody` to the handler... }
```
-处理器:`xlyApi/src/main/java/com/xly/api/web/ApiController.java` 中的 `ApiController.invoke()`。
+处理器:`xlyApi/src/main/java/com/xly/api/web/ApiController.java:223` 中的 `ApiController.invoke()`。该 mapping 是 `@RequestMapping`,框架层不限定 HTTP 方法;每个 API 行的 `sysapi.sMethod` 列声明调用方应使用的动词,并由 `ApiCheckUtil` 在分发时校验。
流程:
1. 读取 `Authorization` header(回退:`authorizationt` 查询参数)。
2. 查找以 `sApiCode` 为键的 `sysapi` 行(通过 `ApiServiceImpl.invoke` → `SELECT … FROM sysapi WHERE sApiCode = #{sApiCode}`)。
-3. 如果该行的 `bHasToken` 标志已设置,就用 `sysapithirdtoken`(或该行指向的等价 token 存储)验证 token。
+3. 如果该行的 `bHasToken` 标志已设置,就 AES 解密 bearer token 取回 `corpid`,再通过 `BrandServiceImpl.selectByCorpid` 到 `sysapibrand` 校验该 `corpid`。如果品牌行的 `iLossTime` 非 0,还会检查 token 内嵌时间戳是否过期。(`sysapithirdtoken` 用于**出站** token,也就是 xly 调第三方 API;这里不用于校验入站 bearer token。)
4. 使用请求 body 合并出的参数 map,执行 `sysapi.sDataSql` 中存放的 SQL 模板。
5. 将调用写入 `sysapilog`。
6. 用 `AjaxResult` 包装结果并返回。
@@ -32,7 +32,7 @@ Content-Type: application/json
| 列 | 含义 |
|---|---|
-| `sApiCode` | 消费方发送的 path variable。每个租户内必须唯一。 |
+| `sApiCode` | 消费方发送的路径变量。每个租户内必须唯一。 |
| `sApiName` | 人类可读标签。 |
| `sApiUrl` / `sApiUrlRef` | 计算后的 URL,即 `sApiUrlRef + sApiCode`,用于出站分发。 |
| `sMethod` | 此 API 期望的 HTTP 方法(`GET`、`POST` 等)。 |
@@ -47,9 +47,9 @@ Content-Type: application/json
## Token 端点:`/token/*`
-| Endpoint | Method | 用途 |
+| 端点 | 方法 | 用途 |
|---|---|---|
-| `/token/getToken?corpid=&corpsecret=` | POST | 为集成方的 `(corpid, corpsecret)` 组合签发 bearer token。 |
+| `/token/getToken?corpid=&corpsecret=` | GET / POST | 为集成方的 `(corpid, corpsecret)` 组合签发 bearer token。(mapping 不限定方法。) |
返回的 token 就是 `/api/invoke/{sApiCode}` 期望在 `Authorization` 中收到的值。完整实现位于 `xlyApi/src/main/java/com/xly/api/web/TokenController.java` 及其 service。
@@ -59,15 +59,17 @@ Content-Type: application/json
这些是同一个 WAR 中托管的较小专用 API:
-| Endpoint root | Controller | 用途 |
+| 端点前缀 | Controller | 用途 |
|---|---|---|
-| `/online/api/{sApiCode}` | `OnlineController` | 只读执行某个 `sysapi` 行(不写入)。适合公开数据端点。 |
-| `/online/onlineword/{sApiCode}` | `OnlineController` | 返回“word”风格模板载荷的变体。 |
-| `/online/onlinelist`, `/online/getToken` | `OnlineController` | 在线 API 列表及其 token 签发。 |
-| `/pro/get/{sProName}` | `ProContentController` | 按名称获取存储过程元数据。 |
-| `/pro/getData/{sProName}` | `ProContentController` | 执行指定存储过程并返回其结果集。 |
-| `/pro/alert/{redisId}`, `/pro/getAlertValue/{redisId}` | `ProContentController` | 按 Redis id 读取预警 / 通知值。 |
-| `/pro/executeSql` | `ProContentController` | 执行参数化 SQL 模板(管理级)。 |
+| `/online/api/{sApiCode}` | `OnlineController` | 为给定 `sysapi` 行渲染 BACK 浏览器内 API 调试 / 控制台页面(返回 Thymeleaf view,不执行 API)。 |
+| `/online/onlineword/{sApiCode}` | `OnlineController` | 渲染“word”风格 API 文档页面。 |
+| `/online/onlinelist` | `OnlineController` | 渲染在线 API 列表页面。 |
+| `/online/getToken` | `OnlineController` | 渲染浏览器内 token 获取辅助页面。 |
+| `/pro/get/{sProName}` | `ProContentController` | 渲染 BACK 页面,用于展示某个存储过程源码。 |
+| `/pro/getData/{sProName}` | `ProContentController` | 返回存储过程源码文本(不是结果集)。 |
+| `/pro/alert/{redisId}` | `ProContentController` | 为给定 Redis key 渲染预警 / 通知展示页面。 |
+| `/pro/getAlertValue/{redisId}` | `ProContentController` | 返回 Redis 中 `redisId` 对应的缓存值。 |
+| `/pro/executeSql` | `ProContentController` | 直接执行 `sSql` 载荷(管理级开发工具)。 |
| `/thirdparty/*` | `ThirdPartyController` | 第三方 API 定义 CRUD,以及 `checkPartyApi` 校验器。由 `sysapithirdparty` 支撑。 |
| `/thirdtoken/*` | `ThirdTokenController` | 出站 token 配置 CRUD。由 `sysapithirdtoken` 支撑。 |
| `/brand/*` | `BrandController` | 伙伴 / 供应商列表(`sysapibrand`)CRUD。 |
@@ -86,8 +88,8 @@ Content-Type: application/json
| `sysapibrand` | 伙伴 / 供应商目录。 |
| `sysapithirdparty` | 出站第三方端点定义。 |
| `sysapithirdtoken` | 出站第三方 token 配置。 |
-| `sysapidbtodb` | DB-to-DB 同步 API 定义。 |
-| `sysapidbtodblog` | DB-to-DB 同步运行日志。 |
+| `sysapidbtodb` | DB-to-DB 同步 API 定义。**由 `xlyFlow` 的 `DbToDbController` 拥有,不由 xlyApi 拥有**;列在这里是因为该表位于 xlyApi 的 `sysapi.sql` 中。 |
+| `sysapidbtodblog` | DB-to-DB 同步运行日志。同上,由 xlyFlow 写入。 |
## 集成方如何使用
diff --git a/zh/docs/api-reference/index.md b/zh/docs/api-reference/index.md
index 0927053..ccd0424 100644
--- a/zh/docs/api-reference/index.md
+++ b/zh/docs/api-reference/index.md
@@ -8,7 +8,7 @@ xly 暴露了三个不同的 HTTP 接口面,分别由三个独立的 Spring Bo
| [外部 API](external.md) | `xlyApi` | `/xlyApi` | 你在接入会从外部调用 xly 的系统。 |
| [Webhook](webhooks.md) | `xlyInterface` | `/xlyInterface` | 第三方系统需要把事件推送进 xly。 |
| [消息](messaging.md) | `xlyEntry` + `xlyErpJms*` | 不适用(ActiveMQ / RocketMQ) | 异步、扇出式集成比同步 HTTP 调用更合适。 |
-| [通知](notifications.md) | `xlyMsg`(作为库被 `xlyEntry`、`xlyBusinessService`、`xlyInterface` 使用) | 不适用(钉钉 / 微信 API) | 业务事件需要向用户推送聊天平台消息。 |
+| [通知](notifications.md) | `xlyMsg`(作为库被 `xlyEntry`、`xlyBusinessService`、`xlyInterface`、`xlyFlow` 使用) | 不适用(钉钉 / 微信 / 邮件 API) | 业务事件需要向用户推送聊天平台消息或邮件。 |
## 阅读顺序
diff --git a/zh/docs/api-reference/internal.md b/zh/docs/api-reference/internal.md
index 4493b6f..64c66d2 100644
--- a/zh/docs/api-reference/internal.md
+++ b/zh/docs/api-reference/internal.md
@@ -8,15 +8,18 @@
## 通用 CRUD 接口面:`/business/*`
-| Endpoint | Method | 用途 |
+| 端点 | 方法 | 用途 |
|---|---|---|
-| `/business/getModelBysId/{moduleId}` | GET | 返回某个模块的表单布局,也就是五键组合(`formData`、`gdsformconst`、`gdsjurisdiction`、`billnosetting`、`report`)。 |
-| `/business/getBusinessDataByFormcustomId/{formId}` | POST | 返回某个表单的业务数据行,带分页。设置 `sGroupList` 时会分支到 `getBusinessDataByGroup`。 |
+| `/business/getModelBysId/{sModelsId}` | GET | 返回某个模块的表单布局,也就是五键组合(`formData`、`gdsformconst`、`gdsjurisdiction`、`billnosetting`、`report`)。 |
+| `/business/getBusinessDataByFormcustomId/{gdsconfigformmasterId}` | POST | 返回某个表单的业务数据行,带分页。设置 `sGroupList` 时会分支到 `getBusinessDataByGroup`。 |
| `/business/getBusinessDataByIndex` | POST | 首条 / 末条 / 下一条 / 上一条记录导航。 |
| `/business/addBusinessData` | POST | 单行新增。 |
| `/business/addUpdateDelBusinessData` | POST | 在一个事务调用里组合新增 + 修改 + 删除。前端通过 `sTable` 直接指定目标表。 |
| `/business/getSelectDataBysControlId/{sId}` | POST | 按控件 `sId` 为单个控件加载下拉选项。 |
| `/business/getSelectLimit/{sId}` | POST | 下拉加载调用的分页变体。 |
+| `/business/addSysLocking` | POST | 用户开始编辑单据时获取乐观锁,在系统锁表中按 `(sFormGuid, sUserId)` 插入一行。SPA 进入编辑模式时会触发它,使并发编辑者收到冲突提示。处理器:`BusinessBaseController.java:400-407`。 |
+| `/business/doExamine` | POST | 简单“审核”:通过 SQL 把指定行的 `bCheck` 翻为 `1`。**不会调用 Activiti**;这是不需要多步工作流的模块使用的 xly 轻量审批路径。处理器:`BusinessBaseController.java:384-391` → `BusinessBaseServiceImpl.doExamine` → `ExamineServiceImpl`。何时改用 Activiti,见 [xly 如何在不使用 Activiti 的情况下处理工作流](../reference/maintainer/activiti.md#xly-如何在不使用-activiti-的情况下处理工作流)。 |
+| `/business/getProData` | POST | 面向模块的通用存储过程调用,是 `/procedureCall/doGenericProcedureCall` 的另一条路径。处理器:`BusinessBaseController.java:350-358` → `BusinessBaseServiceImpl.getProData`。FROUNT 会用它做模块级 proc 读取,例如首页看板的 `/getProData?sModelsId=...&sName=` 模式。 |
这些端点在[切片 1](../slices/01-hello-world.md)(`getModelBysId` + 网格加载 + 保存)和[切片 3](../slices/03-report.md)(基于视图的读取变体)中有更详细说明。处理类位于 `xlyEntry/src/main/java/com/xly/web/businessweb/`。
@@ -24,7 +27,7 @@
配置侧动作(创建模块、定义表单、声明虚拟表)在 `xlyEntry/src/main/java/com/xly/web/systemweb/` 下有一套并行接口面:
-| Endpoint root | Controller | 用途 |
+| 端点前缀 | Controller | 用途 |
|---|---|---|
| `/gdsmodule/*` | `GdsmoduleController` | 模块树 CRUD,包括 `getModuleTreePro`、`addGdsmodule`、`updateGdsmodule`。 |
| `/gdsconfigform/*` | `GdsconfigformController` | 表单主表和表单明细元数据 CRUD。 |
@@ -32,20 +35,21 @@
## 专用运行时端点
-| Endpoint root | Controller | 用途 |
+| 端点前缀 | Controller | 用途 |
|---|---|---|
| `/configform/*` | `BusinessConfigformController` | 用户 / 用户组级显示定制。 |
| `/treegrid/*` | `BusinessTreeGridController` | 树表端点(当前分支实现的是存储过程支撑路径)。 |
| `/procedureCall/*` | `GenericProcedureCallController` | 按名称 + 参数通用调用存储过程;见[通用存储过程分发](../reference/maintainer/proc-dispatch.md)。 |
| `/panel/*` | `ConfigformPanelController` | `gdsconfigformpanel` 中的面板布局持久化。 |
-| `/checkFlow/*` | `CheckFlowController` | Activiti 工作流接口面(审批 / 驳回 / 查看),只在运行流程引擎的部署中有意义。 |
+| `/checkflow/*` | `CheckFlowController` | **空壳,返回 404。** 该类只声明前缀,没有任何 handler 方法。真正的工作流审核 / 驳回 / 完成 URL 来自 xlyFlow 的 `CurrencyFlowController`(因为 xlyFlow 作为库依赖被编进 xlyEntry,所以仍挂在 xlyEntry context-path 下):`/currencyFlow/complete/{taskId}/{sBrandsId}/{sSubsidiaryId}/{sUserId}`、`/currencyFlow/completeerp/{sBrandsId}/{sSubsidiaryId}/{sUserName}`,以及根路径映射的 BPMN modeler `/modeler/*`。见 [Activiti 集成](../reference/maintainer/activiti.md)。 |
+| `/modelCenter/getModelCenter`、`/modelCenter/getModelCenterCalculation` | `BusinessModelCenterController` | FROUNT 首页的 **KPI 工作中心**卡片(标题为 `KPI监控`)。它聚合标记为 `gdsmodule.bUnTask=1` 的模块上的未清任务,按角色和业务流程分组。**不是 Activiti 驱动。** 见 [KPI 工作中心](../reference/maintainer/runtime.md#kpi-工作中心front-端首页-dashboard)。 |
## 报表与打印
打印接口面位于 `xlyEntry/src/main/java/com/xly/web/report/`:
- `PrintReportController` — 当前 jxls / iText 打印路径。
-- `PrintReportControllerOld` — 为旧模板保留的历史打印路径。
+- `PrintReportControllerOld.java` — 文件存在,但类体已全部注释掉(而且注释内类名是 `PrintReportController`,不是 `*Old`)。这是保留作参考的死代码,不是活跃 controller。
前端的“打印” / “导出”按钮会调用这些控制器,控制器从 `sysreport` 加载模板,执行匹配的视图查询,并把二进制文件流回前端。流程见[切片 3](../slices/03-report.md)。
@@ -55,6 +59,135 @@
如果某个请求未认证却进入了控制器,正常情况下会被 `@Authorization` 拦下;如果没有被拦下(例如某个方法未加注解),这个方法也会绕过 `RequestAddParamUtil` 中的通用租户注入,因此就是多租户 bug。
+## BACK 配置侧边栏(管理接口面) {#back-builder-sidebar-admin-surface}
+
+BACK 管理侧边栏的 10 个顶层项(登录 `admin`/`123`,版本 `基础版/8s`)都通过上面的框架原语接线成元数据驱动页面:
+
+| 侧边栏 | URL 片段 | 支撑 form-master `sTbName` | 所属 service |
+|---|---|---|---|
+| 系统模块配置 | `/xtmkpz` | `gdsmodule` | `GdsmoduleServiceImpl` |
+| 数据表内容配置 | n/a | `gdsconfigtbmaster`/`slave` | `GdsconfigtbServiceImpl` |
+| 界面显示内容配置 | n/a | `gdsconfigformmaster`/`slave`/`customslave`/`personalize` | `GdsconfigformServiceImpl` |
+| 接口自定义配置 | `/sjbnrpz` | `sysapi` 表族 | xlyApi 侧管理接口 |
+| 系统常量配置 | `/xtclpz` | `gdsformconst` | `GdsformconstServiceImpl`,切片 1 锚点 |
+| 系统权限配置 | n/a | `gdsjurisdiction` | `GdsjurisdictionServiceImpl` |
+| 常用操作配置 | n/a | 当前 dev DB 中没有对应 `gdsconfigformmaster` 行;页面是 SPA 中直接接线的管理特例。若通过元数据扩展,数据位于用户自定义按钮组层。 | n/a |
+| 用户信息配置 | n/a | `sftlogininfo` 表族 | `GdslogininfoServiceImpl` |
+| Mysql脚本配置 | n/a | BACK 对 [`templesql/` 脚手架](../reference/maintainer/sql-templates.md)的编辑器 | `SqlScriptsServiceImpl` |
+| 图表配置 | 无 `gdsroute` 项;通过 SPA state 导航 | `gdsconfigcharmaster`/`slave` | `GdsconfigformServiceImpl`(图表子集) |
+
+10 项里有 8 项是本目录和[维护人员参考](../reference/maintainer/management-services.md)覆盖的框架原语。**`常用操作配置` 是 SPA 侧管理特例**:它出现在侧边栏,但 dev DB 中没有对应的 `gdsconfigformmaster` 行,说明该页在 BACK 中硬编码,而不是元数据驱动。**`图表配置`** 则完全由元数据驱动:两条 `gdsconfigformmaster` 行分别指向 `gdsconfigcharmaster` 和 `gdsconfigcharslave`,其中的图表定义由 SPA 其他位置的看板渲染消费。
+
+## 框架原语之外:xlyEntry 的其余接口面
+
+`xlyEntry` 总共托管 **70 个 controller**。其中 18 个属于框架侧:上面明确列出的通用运行时 controller,加上支撑 [BACK 配置侧边栏](#back-builder-sidebar-admin-surface) 的 7 个 `systemweb/` 管理 controller(`GdsformconstController`、`GdsjurisdictionController`、`GdslogininfoController`、`GdsparameterController`、`LicenseController`、`LoginController`、`SysbrandsController`)。每个元数据驱动表单的生命周期都在这些接口面里。
+
+剩余 52 个 controller 存在,是因为框架的通用 CRUD + 存储过程分发路径**不足以表达对应用例**。换句话说,每一个都是数据驱动论点停止扩展的位置标记。
+
+即使它们不属于框架目录化接口面,也值得枚举:它们展示了 xly 哪些内容硬编码在 Java 里,哪些内容留给元数据。维护人员读这些 controller,能直接看到框架逃生口的形状。
+
+> **命名空间重叠。** `BusinessBaseController`(`/business/*`)和 `QuoquotationController`(同样 `@RequestMapping("/business")`)共享 URL 前缀。Spring 会按方法级路径解析,所以 `/business/addQuotationsheet` 和 `/business/getQuoquotationProgress` 落在 `QuoquotationController`,其他 `/business/*` 端点落在 `BusinessBaseController`。当前没有方法路径冲突,因此能正常工作;但这个约定很容易踩坑:未来如果有人在 `BusinessBaseController` 里新增 `@PostMapping("/addQuotationsheet")`,就会悄悄遮蔽报价路径。
+
+### 表单辅助与 SPA 扩展 controller(22)
+
+这些是贴近框架的端点,是通用 CRUD 路径的扩展,但不适合放进 form-master / form-slave 的固定形状里。多数会在同一个 SPA 页面里和 `/business/*` 一起被调用。
+
+| 端点前缀 | Controller | 角色 |
+|---|---|---|
+| `/bill/*` | `BillController` | 复制单据操作(`billCopyToCheck`、`billCopyToCheckWork`):把一组 master + slave 行克隆成新单据。它不放在 `/business/*` 下,是因为 SPA 需要在一次请求里拿到新 `sId` 集合,而通用保存端点没有这种响应形状。 |
+| `/change/*` | `ChangeController` | 通用字段变更重算(`changeParam`):字段值触发派生重算且需要调用存储过程时,SPA 会调用它。 |
+| `/parameter/*` | `BusinessParameterController` | 每模块参数读取。 |
+| `/treeclassify/*` | `BusinessTreeClassifyController` | 表单数据加载的树分组变体:`/treeclassify/getTreeClassify/{gdsconfigformmasterId}`。它位于 `/business/*` 之外,是因为响应形状是嵌套树,不是平铺行集。 |
+| `/calcprocedure/*` | `CalcProcedureController` | [计算公式](../reference/builder/define-vtable.md) 功能的运行时侧:`/calc` 调用命名计算过程。 |
+| `/calculationFormula/*` | `CalculationFormulaController` | 计算公式的构建侧元数据。 |
+| `/calculationStd/*` | `CalculationStdController` | 标准计算查找目录。 |
+| `/char/*` | `CharController` | BACK 图表配置页面的图表配置 CRUD,包装 `gdsconfigcharmaster` / `slave`。 |
+| `/checkModel/*` | `CheckmodelController` | 审批模型成员读取(`getUserListByModelId/{sCheckModeId}`),供轻量级(非 Activiti)审批流使用。 |
+| `/comparatorTree/*` | `ComparatorTreeController` | 可过滤层级选择器使用的比较树读取。 |
+| `/excel/*` | `ExcelController` | 网格 **Excel 导出**:`/export/{gdsconfigformmasterId}`。它是打印的兄弟路径,但输出数据而不是报表版式。 |
+| `/import/*` | `ImportExcelController` | Excel **导入**:先 `/checkExcel` 校验,再提交。插入前会按表单 slave 定义验证。 |
+| `/filterTree/*` | `FilterTreeController` | 网格过滤用树形下拉。 |
+| `/notClear/*` | `NotClearController` | 条码扫描“未清”保存路径(`doNotClearSave`、`getNotClearScanData/{sProcName}/{sId}`),特定于扫描驱动的仓库流程。 |
+| `/notice/*` | `NoticeController` | 站内通知获取 / 标记已读。 |
+| `/replaceField/*` | `ReplaceFieldController` | 跨行批量字段替换。 |
+| `/searchgroupby/*` | `SearchgroupbyController` | 带 group-by 的保存搜索定义。 |
+| `/searchupdown/*` | `SearchUpDownController` | 按搜索结果上一条 / 下一条导航,是 `/business/getBusinessDataByIndex` 的变体。 |
+| `/syssearch/*` | `SyssearchController` | 保存搜索定义 CRUD。 |
+| `/syssystem/*` | `SyssystemController` | 系统表读取专用的 `getBusinessDataByFormcustomId` 变体(`/getSyssystemDataByFormcustomId/{gdsconfigformmasterId}`),用于全局元数据读取时绕过租户作用域。 |
+| `/sqlfile/*` | `SqlFileController` | “Mysql脚本配置”管理页面背后的 SQL 文件加载 / 保存。 |
+| `/instruct/*` | `InstructController` | 直接执行 SQL 的端点(`/exesql`、`/opensql`):管理侧查询控制台。 |
+
+### 用户与权限管理(4)
+
+同一关注点下有多个重叠 controller。`New` 后缀,以及 `sftlogininfo`、`userinfo`、`gdslogininfo`(在 `systemweb/` 中)同时存在,说明这里有一次迁移中的重构:旧路径和新路径会并存,直到调用方迁移完成。
+
+| 端点前缀 | Controller | 角色 |
+|---|---|---|
+| `/userinfo/*` | `UserInfoController` | 当前用户资料与 session 信息。 |
+| `/sftlogininfo/*` | `SftlogininfoController` | 用户账号 CRUD(两条路径中较新的一个)。 |
+| `/sysjurisdiction/*` | `SysjurisdictionController` | 用户组 / 用户权限读取(`getGroupData`、`getUserData`)。 |
+| `/sysjurisdictionNew/*` | `SysjurisdictionNewController` | 较新的并行路径(`getGroupDataNew`、`getGroupUserIdNew/{sUserId}`)。关注点相同,形状不同。 |
+
+### 生产 / MES(7)
+
+这些是行业层流程;它们的状态机、多表连接或硬件集成无法只靠 `gdsconfigformmaster` SQL 表达。这是框架中硬编码业务逻辑最集中的一组。
+
+| 端点前缀 | Controller | 角色 |
+|---|---|---|
+| `/sysworkorder/*` | `WorkOrderController` | 带副作用的工单 CRUD(`/add`、`/update/{sId}`):印刷行业工单会跨很多 slave 和存储过程调用,通用保存端点无法原子串起这些操作。 |
+| `/workOrderFlow/*` | `WorkOrderFlowController` | 工单工艺路线 / 流程读取(`getWorkOrderFlow`、`getSplitWorkOrderData/{sId}`)。 |
+| `/workOrderPlan/*` | `WorkOrderPlanController` | 连接工单与计划行的生产计划读取(`getControlProcess/{sProductionPlanId}`、`getProductionPlanInfo`)。 |
+| `/splitWorkOrder/*` | `SplitWorkOrderController` | 把一个主工单拆成多个子工单(`getSplitWorkOrderData`)。 |
+| `/productionPlan/*` | `ProductionPlanController` | 计划树读取(`getProductionPlanTree`)。 |
+| `/process/*` | `ProcessController` | 生产工序目录读取。 |
+| `/oee/*` | `OeeController` | OEE(Overall Equipment Effectiveness,设备综合效率):条码扫描和 MES 状态回调(`updateBarcode/{sBarCodeId}/{sBarCode}`、`doSysMesMsg/{sStatus}/{sMachineId}`)。 |
+
+### 销售、库存、财务、采购、人事(9)
+
+| 端点前缀 | Controller | 角色 |
+|---|---|---|
+| `/salesorder/*` | `SalesOrderController` | 超出通用 CRUD 的销售订单专用逻辑。 |
+| `/business/addQuotationsheet`、`/business/getQuoquotationProgress` | `QuoquotationController` | 报价单创建(长运行,因此有进度端点)。**注意:**它和 `BusinessBaseController` 共享 `/business/*` 前缀,见上面的命名空间重叠说明。 |
+| `/eleMaterialsStock/*` | `EleMaterialsStockController` | 原材料库存读取(`getEleMaterialsStock`、`getEleMaterialsStoreCurrQty`)。 |
+| `/eleProductStock/*` | `EleProductStockController` | 成品库存读取。 |
+| `/costCenter/*` | `CostCenterController` | 成本中心数据与凭证导入(`getCostCenterData`、`getCosvoucherImportData`)。 |
+| `/sysAccountPeriod/*` | `SysAccountPeriodController` | 会计期间启用 / 关闭逻辑。 |
+| `/erpOrderProcurement/*` | `ErpOrderProcurementController` | 采购订单专用逻辑。 |
+| `/sisproductclassify/*` | `SisproductclassifyController` | 产品分类树。 |
+| `/eleteamemployee/*` | `EleteamemployeeController` | 车间流程中的班组 / 员工分配。 |
+
+### 集成与硬件(5)
+
+| 端点前缀 | Controller | 角色 |
+|---|---|---|
+| `/file/*` | `FileController` | 文件上传,包括微信移动端变体 `mobileuploadwechat`。 |
+| `/plc/*` | `PlcController` | PLC 桥接入口(`getplcMachine/{iOrder}/{sParentId}`),见[切片 6](../slices/06-hardware.md)。 |
+| `/mobilephone/*` | `MobliePhoneController` | 移动 App 端点。(类名中的 `Moblie` 拼写错误来自源码;URL 是 `/mobilephone`。) |
+| `/sysWebsocket/*` | `SysWebSocketController` | 推送通知的 WebSocket 建立 / 关闭。 |
+| `/wechat/*` | `WechatController` | 微信集成(站内二维码、OAuth 回调)。 |
+
+### 按[首页范围说明](../index.md)排除(5)
+
+为完整性列出;它们不属于本框架 Wiki 的范围内接口面,但确实存在于 WAR 中。
+
+| 端点前缀 | Controller | 状态 |
+|---|---|---|
+| `/ai/*` | `AiController` | AI assistant。范围外(见 index.md)。 |
+| `/robot/*` | `ChatGptController` | ChatGPT 集成。范围外(见 index.md)。 |
+| `/test/*` | `TestController` | 开发脚手架(`/file`、`/getDinkToken`)。 |
+| (根路径) | `TestProcessController` | 开发脚手架;没有类级 `@RequestMapping`。 |
+| (已注释) | `XsController` | 死文件:`@RestController` 和 `@RequestMapping` 都被注释掉;类存在,但不会注册任何端点。 |
+
+### 把这些 controller 当作诊断线索
+
+扫完整个列表后,三个模式很明显:
+
+1. **长运行或多步骤事务**(`QuoquotationController.getQuoquotationProgress`、`BillController.billCopyToCheck`、`SplitWorkOrderController`):通用保存是一次性请求;任何需要进度端点,或“克隆后跳转”语义的流程,都需要自己的 controller。
+2. **行业专用状态机**(`OeeController`、工单家族、`SysAccountPeriodController`):当“下一个合法状态”不能从单个列推出时,存储过程分发路径就不够用了,controller 会在 Java 中串起编排逻辑。
+3. **硬件或外部系统**(`PlcController`、`WechatController`、`SysWebSocketController`、`FileController`):凡是不只是“MySQL + HTTP”的能力,都需要 Java 胶水;元数据无法描述入站 websocket 或串口握手。
+
+这些 controller 的职责,正好就是框架通用运行时没有覆盖的部分。
+
## 这个 API 不是什么
- **不是稳定接口。** 端点形状会随框架变化。
diff --git a/zh/docs/api-reference/messaging.md b/zh/docs/api-reference/messaging.md
index 39e05b5..977c61c 100644
--- a/zh/docs/api-reference/messaging.md
+++ b/zh/docs/api-reference/messaging.md
@@ -4,25 +4,60 @@ xly 中不是所有集成都适合同步 HTTP 调用。框架运行两个消息
| Broker | 用途 | Producer | Consumer |
|---|---|---|---|
-| **ActiveMQ / JMS** | 缓存失效、集群内扇出事件。元数据变更链路([缓存失效](../reference/maintainer/cache-invalidation.md))依赖它。 | `xlyErpJmsProductor` | `xlyErpJmsConsumer` |
+| **ActiveMQ / JMS** | 集群内扇出事件:基础数据合并作业(把每租户行合并成扁平查询表)以及单据更新 / 单据删除通知。**尽管其中一个队列有历史命名,这条通道不用于 Redis 缓存失效**;真实缓存清理路径见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。 | `xlyErpJmsProductor` | `xlyErpJmsConsumer` |
| **RocketMQ** | 其他不适合 ActiveMQ 假设的集成流程。 | `RocketMQServiceImpl`(位于 `xlyBusinessService`) | 因服务而异。 |
本页只是指针而不是深入说明;准确的队列名和载荷在 `xlyErpJmsConsumer/src/main/java/com/xly/xlyerpjmsconsumer/` 下按 consumer thread 级别记录。
-## ActiveMQ / JMS:缓存失效通道
+## ActiveMQ / JMS:基础数据合并 + 扇出通道
-Producer 侧队列声明位于 `xlyErpJmsProductor/src/main/java/com/xly/xlyerpjmsproductor/config/P2pQueue.java`。当前框架使用的主要 destination(完整列表请读该文件):
+Producer 侧队列声明位于 `xlyErpJmsProductor/src/main/java/com/xly/xlyerpjmsproductor/config/P2pQueue.java`。完整集合是 **24 个 destination**,可按意图分组:
+
+### 模块 / 控制(2)
+
+| Constant | 用途 |
+|---|---|
+| `ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` | “模块元数据已变更”,`ConsumerChangeGdsModuleThread` 会运行存储过程 `PRO_ERPMERGEBASEGDSMODULE`,把每租户 `gdsmodule` 行合并到扁平基础查询表中。**尽管名字如此,它不清 Redis 缓存**;Redis 缓存清理由 BACK 保存时通过 `@CacheEvict` 同步完成。见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。 |
+| `ERP_JMS_ACTIVEMQ_CHANGE_WORK_ORDER_CONTROL` | 工单控制状态变更(状态 / 汇总标志),用于下游重算扇出。 |
+
+### 单据操作(6)
| Constant | 用途 |
|---|---|
-| `ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` | “模块元数据已变更”,触发 `ConsumerChangeGdsModuleThread` 清理各节点相关 Redis 缓存。见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。 |
-| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_CUSTOMER` | 客户主数据变更扇出。 |
-| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_EMPLOYEE` | 员工主数据变更扇出。 |
-| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_MACHINE` | 车间机台主数据变更扇出。 |
-| `ERP_JMS_ACTIVEMQ_UPD_SALE_ORDER`、`ERP_JMS_ACTIVEMQ_UPD_WORK_ORDER`、`ERP_JMS_ACTIVEMQ_UPD_PRODUCTION_REPORT` | “单据已更新”通知,由后台 worker 消费(合计重算、下游失效等)。 |
-| `ERP_JMS_ACTIVEMQ_DEL_SALE_ORDER`、`ERP_JMS_ACTIVEMQ_DEL_WORK_ORDER`、`ERP_JMS_ACTIVEMQ_DEL_PRODUCTION_REPORT` | 单据删除通知。 |
+| `ERP_JMS_ACTIVEMQ_UPD_SALE_ORDER` / `_UPD_WORK_ORDER` / `_UPD_PRODUCTION_REPORT` | “单据已更新”通知,由后台消费者处理(合计重算、下游失效等)。 |
+| `ERP_JMS_ACTIVEMQ_DEL_SALE_ORDER` / `_DEL_WORK_ORDER` / `_DEL_PRODUCTION_REPORT` | 单据删除通知。 |
+
+### 主数据变更扇出(7):`CHANGE_ELE_*`
+
+| Constant | 用途 |
+|---|---|
+| `_CHANGE_ELE_CUSTOMER` | 客户主数据变更。 |
+| `_CHANGE_ELE_EMPLOYEE` | 员工主数据变更。 |
+| `_CHANGE_ELE_MACHINE` | 车间机台主数据变更。 |
+| `_CHANGE_ELE_MATERIALS` | 材料主数据变更。 |
+| `_CHANGE_ELE_PRODUCT` | 产品主数据变更。 |
+| `_CHANGE_ELE_PROCESS` | 工序主数据变更。 |
+| `_CHANGE_ELE_TEAM` | 班组主数据变更。 |
+
+### 系统信息 / 字典表变更扇出(9):`CHANGE_SIS_*`
+
+| Constant | 用途 |
+|---|---|
+| `_CHANGE_SIS_CUSTOMER_CLASSIFY` | 客户分类变更。 |
+| `_CHANGE_SIS_DELIVER` | 送货方式变更。 |
+| `_CHANGE_SIS_FORMULA` | 计算公式变更。 |
+| `_CHANGE_SIS_PAYMENT` | 付款方式变更。 |
+| `_CHANGE_SIS_PROCESS_CLASSIFY` | 工序分类变更。 |
+| `_CHANGE_SIS_PRODUCT_CLASSIFY` | 产品分类变更。 |
+| `_CHANGE_SIS_SALES_MAN` | 销售人员变更。 |
+| `_CHANGE_SIS_TAX` | 税率变更。 |
+| `_CHANGE_SIS_WORK_CENTER` | 工作中心变更。 |
+
+(第一张表之后省略 constant 前缀为 `_…`;完整字面量是 `ERP_JMS_ACTIVEMQ_…`。)
+
+### Listener 侧
-每个 destination 在 `xlyErpJmsConsumer/.../thread/` 下都有对应的 `Consumer*Thread` 类异步处理消息。
+`xlyErpJmsConsumer/.../consumer/Consumer.java` 是承载**全部 24 个 `@JmsListener` 方法**的单一类,每个 destination 一个方法。每个方法把载荷分发给 `xlyErpJmsConsumer/.../thread/` 下对应的 `Consumer*Thread` 类,由它异步执行领域工作(通常是调用一个 `PRO_ERPMERGEBASE*` 存储过程,把每租户行合并成扁平基础查询表)。这里是一个 listener 类有 24 个方法,**不是** 24 个 listener 类。consumer thread 中没有调用 `@CacheEvict` 或 `cleanRedis*`;Redis 缓存失效在 BACK 保存时同步完成,见 [cache-invalidation.md](../reference/maintainer/cache-invalidation.md)。
## RocketMQ:其他流程
@@ -30,9 +65,9 @@ Producer 侧队列声明位于 `xlyErpJmsProductor/src/main/java/com/xly/xlyerpj
## 手动触发缓存失效
-如果元数据变更是通过原始 SQL 完成的(没有 JMS 事件),各节点缓存不会自动清理。支持的覆盖路径是 `xlyBusinessService/.../service/impl/` 中的 `BusinessCleanRedisDataImpl`,它可以直接发布失效事件。更完整的排查路径见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。
+如果元数据变更是通过原始 SQL 完成的(没有 BACK 保存路径),各节点缓存不会自动清理。支持的覆盖路径是 `xlyBusinessService/.../service/impl/` 中的 `BusinessCleanRedisDataImpl`,它可以直接调用清理方法。更完整的排查路径见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。
## 这个机制不是什么
- **不是公开集成通道。** 外部集成方不应向这些 broker 发布消息,也不应订阅它们。它们是集群内部的扇出机制。
-- **不是失效缓存的唯一方式。** `xlyEntry` 的 HTTP 写路径已经会在需要时发布 JMS 事件;手动触发只用于边界情况。
+- **不是缓存失效通道。** `xlyEntry` 的 HTTP 写路径会在需要时同步触发 `@CacheEvict`;JMS 队列负责基础数据合并和异步工作项。
diff --git a/zh/docs/api-reference/notifications.md b/zh/docs/api-reference/notifications.md
index a0a34af..83608c6 100644
--- a/zh/docs/api-reference/notifications.md
+++ b/zh/docs/api-reference/notifications.md
@@ -1,15 +1,16 @@
# 通知(xlyMsg)
-出站通知(钉钉和微信)通过 `xlyMsg` 模块完成。它**不是**调用方直接访问的 HTTP 接口面,而是范围内服务在业务事件需要向聊天平台推送消息时调用的内部 SDK。
+出站通知(钉钉、微信和邮件)通过 `xlyMsg` 模块完成。它**不是**调用方直接访问的 HTTP 接口面,而是范围内服务在业务事件需要向聊天平台或邮箱推送消息时调用的内部 SDK。
## 模块内容
`xlyMsg/src/main/java/com/xly/`:
-| Package | 角色 |
+| 包 | 角色 |
|---|---|
| `dingtalk/service/DingTalkService` + `dingtalk/util/SendDingTalkUtil`、`DingTalkMsgContentUtil`、`LocalCacheClient` | 钉钉企业消息分发。封装 `com.aliyun:dingtalk:2.1.14` 和 `com.aliyun:alibaba-dingtalk-service-sdk:2.0.0`。 |
| `wechat/service/WechatService` + `wechat/util/SendWxUtil`、`Wx_SignatureUtil`、`JedisMsgUtil`、`MsgContentUtil`、`Xml2JsonUtil` | 微信工作平台分发,包含签名和发送,以及 Redis 支撑的 access-token 缓存。 |
+| `emial/service/SendEmailService` + impl | 邮件分发(包名拼成了 `emial`)。`xlyFlow` 的 `QuartzTask` 会用它发送定时任务邮件;`xlyEntry` 也有自己的 `com.xly.web.email.SendEmailService`,供 `ScheduledTasks` 驱动的邮件路径使用。两个接口名相同、实现并行保留,属于历史原因。 |
| `notice/service/NoticeService` | 与供应商无关的通知抽象;把“通知用户 X 某事件 Y”的逻辑路由到正确后端。 |
`xlyMsg/build.gradle` 中唯一的框架依赖是 `xlyPersist`。该模块作为库被消费,不会作为自己的服务部署。
@@ -18,12 +19,12 @@
这些调用让 `xlyMsg` 成为框架相关内容,而不是 plat 层内容:
-| Caller | 作用 |
+| 调用方 | 作用 |
|---|---|
| `xlyEntry/.../web/businessweb/TestController.java` | 诊断端点,用于发送测试钉钉消息。 |
-| `xlyBusinessService/.../thread/UpdateDingTalkThread.java` | 单据变更后推送钉钉更新的异步 worker。 |
+| `xlyBusinessService/.../thread/UpdateDingTalkThread.java` | 单据变更后推送钉钉更新的异步线程。 |
| `xlyBusinessService/.../service/impl/CheckExamineFlowServiceImpl.java` | 工作流审批通知;Activiti 任务被重新分配或完成时,待办人会收到聊天消息。 |
-| `xlyBusinessService/.../service/impl/GenericProcedureCallServiceImpl.java` | 通用存储过程 hook:任何选择接入的 `gdsmodule` 存储过程都可以通过这条路径发布通知。 |
+| `xlyBusinessService/.../service/impl/GenericProcedureCallServiceImpl.java` | 通用存储过程钩子:任何选择接入的 `gdsmodule` 存储过程都可以通过这条路径发布通知。 |
| `xlyInterface/.../util/DingTalkUtil.java` + `scheduler/ScheduledTasks.java`、`ErpJobRunStatus.java` | 集成侧的定时任务心跳 / 失败告警。 |
## 配置
diff --git a/zh/docs/api-reference/webhooks.md b/zh/docs/api-reference/webhooks.md
index 8b8df72..00d2b26 100644
--- a/zh/docs/api-reference/webhooks.md
+++ b/zh/docs/api-reference/webhooks.md
@@ -10,30 +10,32 @@ http:///xlyInterface/swagger-ui.html
等价的 JSON 描述位于 `http:///xlyInterface/v2/api-docs`。
+> **注意:** 项目拉取了 SpringFox jar,但没有注册 `Docket` bean(`xly-src` 中没有 `@EnableSwagger2`,也没有 `@Bean Docket api()`)。`swagger-ui.html` shell 会从 jar 的静态资源中被服务出来,但 `/v2/api-docs` 返回的描述基本为空。这里的 UI 是“依赖自带页面”,不是“已填充的 try-it-out 控制台”。如果维护人员希望列出真实 API,需要补一个 `Docket` bean。
+
## 数据驱动接收器:`/interfaceDefine/*`
它和[外部 API 的 `/api/invoke`](external.md) 模式对应,但用于入站调用:
-| Endpoint | Method | 用途 |
+| 端点 | 方法 | 用途 |
|---|---|---|
| `/interfaceDefine/invoke/{interfaceInvoke}` | POST | 将入站载荷分发给 `{interfaceInvoke}` 命名的处理器。 |
-| `/interfaceDefine/callthirdparty/{interfaceInvoke}` | POST | 转发到已配置的出站端点。(对应 `xlyApi` 上同样存在的 `/interfaceDefine/callthirdparty/...` 端点;这里的入站侧和数据驱动入站分发器配套。) |
+| `/interfaceDefine/callthirdparty/{interfaceInvoke}` | POST | 转发到已配置的出站端点。同一个 URL 在 `xlyApi` 的 `InterfaceController` 上也存在;这种重复是有意的,两个服务共享数据驱动分发器模式。 |
处理器:`xlyInterface/src/main/java/com/xly/web/InterfaceController.java`。
-`{interfaceInvoke}` path variable 会查找一条元数据行,该行声明要运行的 SQL 或存储过程、参数映射和响应形状。它和 xly 其他部分一样遵循数据驱动思路:新增入站端点靠插入数据行,而不是写 Java。
+`{interfaceInvoke}` 路径变量会查找一条元数据行,该行声明要运行的 SQL 或存储过程、参数映射和响应形状。它和 xly 其他部分一样遵循数据驱动思路:新增入站端点靠插入数据行,而不是写 Java。
## 硬编码的供应商接收器
少量接收器不会经过 `/interfaceDefine`,因为它们必须匹配合作方固定的 URL 规格。
-| Endpoint | Method | 用途 |
+| 端点 | 方法 | 用途 |
|---|---|---|
| `/Push` | POST | 供应商(微信 / 类似系统)推送接收器。 |
| `/Pull` | POST | 供应商拉取模式接收器。 |
| `/getKey/{key}` | GET | 按伙伴命名的 `key` 获取公钥。 |
| `/getKeyTest` | GET | `/getKey` 的测试模式变体。 |
-| `/send/sendQw` | POST | 企业微信出站消息。 |
+| `/send/sendQw` | POST | 企业微信出站消息。**当前分支是空实现**:`SendQwController` 中方法体只是 `return "ok";`;已搭好 token 获取脚手架,但未完成。 |
处理器:`xlyInterface/src/main/java/com/xly/web/WX_VendorWeb.java` 和 `xlyInterface/src/main/java/com/xly/wechat/test/SendQwController.java`。
diff --git a/zh/docs/concepts/api-surface.md b/zh/docs/concepts/api-surface.md
index a56ac98..d472eda 100644
--- a/zh/docs/concepts/api-surface.md
+++ b/zh/docs/concepts/api-surface.md
@@ -10,17 +10,26 @@ xly 不是只有一个 API,而是有**三层**,分别由三个独立的 Spri
每个服务都会构建成自己的 WAR,并在自己的 JVM 中运行。它们不共享进程内状态;它们共享的是**数据库**。正是这个共享数据库让服务拆分成立:内部 API 写入的数据会自动被外部 API 读到,因为两者都连接同一个 schema。
-## 为什么是三层,而不是一层
+## 为什么是三层,以及拆分的代价
-每一层回答的问题不同,合在一起会牺牲清晰度:
+每一层最初都是为了解答不同的问题:
- **内部层**很大(对所有元数据驱动模块做通用 CRUD)、易变(随框架变化)、且有意保持弱类型(SPA 决定要什么,服务端照元数据执行)。
-- **外部层**是收敛后的接口(只暴露允许集成方使用的端点),按 `sApiCode` 做版本化,并用 bearer token 认证。它能跨框架变化保持稳定,正是因为它小而明确。
+- **外部层**是收敛后的接口(只暴露允许集成方使用的端点),按 `sApiCode` 做版本化,并用 bearer token 认证。
- **入站 webhook 层**接收来自第三方系统的不可信 body,并路由到 xly 处理器。Swagger UI 放在这里,因为这个受众最需要交互式文档。
+这个拆分有真实成本,Wiki 不应该略过:
+
+- **需要部署、监控、锁定版本的 WAR 有三个。** 一次发布必须协调 `xlyEntry`、`xlyApi`、`xlyInterface` 的构建。版本不匹配时(例如 `xlyEntry` 引入了 schema 变化,但 `xlyApi` 还没跟上),问题通常会静默存在,直到某条调用路径撞上它。
+- **存在重复代码。** `RequestAddParamUtil` 在 `xlyPersist`(供 `xlyEntry` 使用)和 `xlyApi` 中各有一份,几乎是 56 行 / 57 行的拷贝。`InterfaceController` 在 `xlyApi` 和 `xlyInterface` 中也都存在,并且有重叠的 `/interfaceDefine/callthirdparty/*` 端点。让两边保持同步依赖运维纪律,不是编译期保证。
+- **没有共享 session。** 在 BACK 登录的用户,在 `xlyApi` 中没有 session;外部调用方需要单独获取 bearer token。这对外部集成是正确的,但也意味着内部跨 WAR 调用如果发生,必须走公开 token 流程。
+- **三个 context path 就意味着三条反向代理规则。** `BACK=:8597`、`FROUNT=:8598` 到具体 WAR 的映射在 nginx 配置中,不在本仓库里。代理配置错误是代码库本身捕捉不到的常见故障模式。
+
+这些层当然也可以做成一个部署物,只在内部用包边界隔离;Spring Boot 支持这种做法。那样的好处是:一次构建、一套依赖、一套 session 逻辑,也不会有重复工具类。代价是:各层更难独立扩容,也更难只限制外部调用方而不影响 SPA。xly 选择了部署期隔离;Wiki 的职责是把这个选择牺牲了什么写清楚。
+
## 每一层在运行时长什么样
-- **内部层** — 见[四表读取](request-lifecycle.md)。一个端点(`/business/getModelBysId`)返回完整表单布局;另一个端点(`/business/addUpdateDelBusinessData`)写入元数据命名的任意表中的任意行。端点少,形状通用。
+- **内部层** — 见[五键读取](../reference/maintainer/runtime.md#five-key-read)。一个端点(`/business/getModelBysId`)返回完整表单布局;另一个端点(`/business/addUpdateDelBusinessData`)写入元数据命名的任意表中的任意行。端点少,形状通用。
- **外部层** — 大多数调用走 `/api/invoke/{sApiCode}`。`sApiCode` 是 `sysapi` 元数据表中的一行,定义 SQL 模板、参数、认证要求和目标。新的外部 API 是**注册成数据**,不是写成代码;这和框架对自身表单采用的数据驱动基本论点一致。
- **入站 webhook 层** — `/interfaceDefine/invoke/{interfaceInvoke}` 接收载荷,查找元数据中的匹配处理器,运行配置好的 SQL 或存储过程。此外还有少量为特定伙伴准备的硬编码接收器(`/Push`、`/Pull`、`/send/sendQw`)。
diff --git a/zh/docs/concepts/customization-channels.md b/zh/docs/concepts/customization-channels.md
index 2ff9e73..50bbe45 100644
--- a/zh/docs/concepts/customization-channels.md
+++ b/zh/docs/concepts/customization-channels.md
@@ -15,7 +15,7 @@ xly 客户通过**两条不同路径**定制系统。理解区别很关键:它
这些修改是**数据**。它们跟随客户数据库。它们在**后台**界面中可见,PM 可以审计。框架运行时在每次请求中读取它们(带缓存)。Java 代码不变;应用行为由这些行决定。
-这是默认路径。**90% 以上客户定制都应该放在这里。**
+这是架构希望客户优先使用的路径。至于真实比例是不是 90/10 偏向通道 1,代码库里没有统计;经验信号是 `script/客户/` 下已经有 18 个客户目录,说明有相当一部分客户需要通道 1 表达不了的东西。因此,“90% 以上应该在这里”更像目标,而不是实测事实。
## 通道 2 — 每客户 SQL 覆盖
@@ -40,7 +40,7 @@ xly 客户通过**两条不同路径**定制系统。理解区别很关键:它
- 客户需要框架 Add/Update/Calc 过程无法表达的过程逻辑。
- 需要替换存储过程主体,而不是只在周围注入 SQL 片段。
-- 维护人员审查客户运行时时,需要能在源码控制的 SQL 文件中看到差异。
+- 运行时差异应放在源码控制的 `.sql` 文件中(`script/客户//` 下),这样维护人员审查客户运行时时能一眼看到每客户变更,而不是只有连接到实时 DB 后才能发现。
通道 2 *几乎总是最后手段*。只有确认通道 1 做不到时才使用。
diff --git a/zh/docs/concepts/customization-layers.md b/zh/docs/concepts/customization-layers.md
index 2345d28..d94f877 100644
--- a/zh/docs/concepts/customization-layers.md
+++ b/zh/docs/concepts/customization-layers.md
@@ -6,21 +6,33 @@
## 各层
-```text
-gdsconfigformmaster ← 系统默认(表单)
- ↓ 被覆盖
-gdsconfigformpersonalize ← 每租户整表单覆盖
- (替换 sSqlStr/sWhere/sOrder)
- ↓ 然后是基础从表
-gdsconfigformslave ← 系统默认字段
- ↓ 被覆盖 / 扩展
-gdsconfigformcustomslave ← 每租户字段(新增、隐藏、覆盖)
- ↓ 可选地继续微调
-gdsconfigformuserslave ← 每用户视图偏好
- (列顺序、隐藏列)
+```mermaid
+flowchart TB
+ classDef sys fill:#e8f0fe,stroke:#4285f4
+ classDef tenant fill:#fef7e0,stroke:#fbbc04
+ classDef user fill:#f3e8fd,stroke:#a142f4
+
+ M["gdsconfigformmaster
系统默认:表单
(sSqlStr · sWhere · sOrder)"]:::sys
+ P["gdsconfigformpersonalize
每租户整表单覆盖
(替换 sSqlStr / sWhere / sOrder)"]:::tenant
+ S["gdsconfigformslave
系统默认字段"]:::sys
+ CS["gdsconfigformcustomslave
每租户字段
(按 sName 新增 · 隐藏 · 覆盖)"]:::tenant
+ US["gdsconfigformuserslave
每用户视图微调
(列顺序 · 隐藏列)"]:::user
+
+ OUT["合并后的表单
返回给 SPA"]
+
+ M --> P
+ P --> S
+ S --> CS
+ CS --> US
+ US --> OUT
+
+ M -. "总是加载" .-> OUT
+ P -. "租户有覆盖时加载" .-> OUT
+ CS -. "租户有覆盖时加载" .-> OUT
+ US -. "用户有偏好时加载" .-> OUT
```
-每一层都通过 `sParentId` 连接到上一层。没有任何连接由 FK 强制;见[无 FK 现实](semantic-fk.md)。
+自上而下读这条链:**系统 → 租户 → 用户**。每一层都通过 `sParentId` 连接到上一层。没有任何连接由 FK 强制;见[无物理外键、语义外键的现实](semantic-fk.md)。
## 每层回答的问题
@@ -34,7 +46,7 @@ gdsconfigformuserslave ← 每用户视图偏好
## 合并如何发生
-框架按顺序读取每层,并按 `sName`(字段名)合并。自定义从表行与基础从表拥有相同 `sName` 时:覆盖。新的 `sName`:追加。没有对应自定义行的基础从表:原样透传。合并发生在 `BusinessBaseServiceImpl.getModelBysId`(第 181 行)及其调用的 helper 中:`BusinessGdsconfigformsServiceImpl.getFormSlaveData` + `getFormCustomSlaveData`。
+框架按顺序读取每层,并按 `sName`(字段名)合并。自定义从表行与基础从表拥有相同 `sName` 时:覆盖。新的 `sName`:追加。没有对应自定义行的基础从表:原样透传。入口是 `BusinessBaseServiceImpl.getModelBysId`(第 181 行),它会调用 `BaseServiceImpl.getModelConfigByModleId`(第 55 行);真正的 slave + customslave 合并发生在 `BusinessGdsconfigformsServiceImpl.getGdsconfigformslaveShow`(第 392 行),组合 `getFormSlaveData`(第 87 行)和 `getFormCustomSlaveData`(第 121 行),随后可选叠加 `getUserFormSlaveData`(第 156 行)。
两个数据库**视图**通过连接 form-master 和相关 slave 表来支持合并:
diff --git a/zh/docs/concepts/index.md b/zh/docs/concepts/index.md
index 2d96189..5be8d5c 100644
--- a/zh/docs/concepts/index.md
+++ b/zh/docs/concepts/index.md
@@ -24,7 +24,7 @@ flowchart TB
XMSG[/"xlyMsg
库"/]
end
- DB[("MySQL
xlyweberp")]
+ DB[("MySQL
xlyweberp_*")]
REDIS[(Redis)]
AMQ([ActiveMQ])
XEJMSC[xlyErpJmsConsumer]
@@ -42,10 +42,13 @@ flowchart TB
XFLOW --> DB
XPLC --> DB
- XENTRY <--> REDIS
- XENTRY -- "元数据变更" --> AMQ
+ XENTRY -- "保存时 @CacheEvict
同步执行" --> REDIS
+ XENTRY <-- "缓存读取
+ Shiro session" --> REDIS
+ XAPI <--> REDIS
+
+ XENTRY -- "领域事件
(不是缓存失效)" --> AMQ
AMQ --> XEJMSC
- XEJMSC --> REDIS
+ XEJMSC -- "PRO_ERPMERGEBASE*
基础数据合并" --> DB
XENTRY -. 使用 .-> XMSG
XIF -. 使用 .-> XMSG
@@ -55,6 +58,10 @@ flowchart TB
虚线簇(`xlyPlat*` + MongoDB)是 B2B 印刷平台层。它存在于构建中,但在本 Wiki 中[不属于覆盖范围](../index.md)。
+注意运行时到 Redis / ActiveMQ 有两条不同路径:**`@CacheEvict` 在保存流程中同步执行,直接清理共享 Redis 存储**(跨节点一致性依赖共享存储)。**JMS 路径是另一条基础数据合并通道**,不是缓存失效;`ConsumerChangeGdsModuleThread` 会运行 `PRO_ERPMERGEBASEGDSMODULE` 等过程。这两条路径在[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)中有完整说明。
+
+每个框背后的类库清单见[技术栈](../reference/maintainer/tech-stack.md)。
+
## 概念页
这些页面刻意保持简短:每页解释一个概念,并链接到实际使用该概念的[垂直切片](../slices/index.md)。概念页应该短;如果一页长到超过一屏,通常说明它应该变成一个切片。
@@ -65,6 +72,6 @@ flowchart TB
- [无物理外键、语义外键的现实](semantic-fk.md) — 关系实际如何工作。
- [两条定制通道](customization-channels.md) — 元数据编辑 vs. SQL 脚本。
- [定制层级](customization-layers.md) — 通道 1 内,基础 / 租户 / 用户覆盖如何合并。
-- [多租户与产品版本](multi-tenancy.md) — 三条作用域轴(`sBrandsId`、`sSubsidiaryId`、`sVersionFlowId`)。
+- [多租户与产品版本](multi-tenancy.md) — 行作用域(`sBrandsId`、`sSubsidiaryId`)加上许可证控制的模块发现。
- [元数据驱动的请求生命周期](request-lifecycle.md) — 后续会反复回到这张图。
- [三层 API](api-surface.md) — 内部(`xlyEntry`)、外部(`xlyApi`)、入站 webhook(`xlyInterface`)。
diff --git a/zh/docs/concepts/master-slave.md b/zh/docs/concepts/master-slave.md
index cca22ca..7e5313b 100644
--- a/zh/docs/concepts/master-slave.md
+++ b/zh/docs/concepts/master-slave.md
@@ -1,5 +1,9 @@
# 主从单据模式
+> **这个代码库里有两个互不相关的“主 / 从”概念。**
+> 本页讨论的是**单据行**模式:报价、销售订单、生产工单等业务单据由 1 行表头 + N 行明细组成。
+> **DataSource** 层的 master / slave(`xlyApi` 和 `xlyInterface` 中通过 `MasterDataSourceConfig` / `SlaveDataSourceConfig` 做写库 / 读库连接路由,并配套这些服务内的 `mastermapper/MasterBaseMapper.xml` / `slavemapper/SlaveBaseMapper.xml`)是另一个概念,见[技术栈](../reference/maintainer/tech-stack.md#persistence)中 HikariCP / 数据源相关说明,也会在运行时页面间接涉及。两者只是名字重叠。
+
xly 中几乎所有业务单据,例如报价单、销售订单、工单、付款凭证,都是以**一行表头 + N 行明细**存储。xly 对此的术语是 **master / slave**。master 保存单据身份和汇总;每个 slave 行是一条明细、一段阶段、一项材料、一个产品或一笔费用。
这个模式无处不在:
@@ -29,9 +33,6 @@ xly 中几乎所有业务单据,例如报价单、销售订单、工单、付
## “Slave” 命名说明
-这个词在英文中有额外含义,而中文“主表 / 从表”没有。Wiki 保留 “slave”,原因是:
-
-1. 改名会破坏代码库、schema 和自动目录中的全部交叉引用(14k+ 标识符)。
-2. 把每次出现都映射成 “detail” 或 “child” 会损害可搜索性,并使 Wiki 文本偏离开发者实际 grep 到的内容。
+这个词在英文中带有中文“主表 / 从表”没有的额外含义。Wiki 保留 “slave” 原词,是因为代码库、schema 和自动目录中有 14k+ 标识符逐字使用它;翻译成其他词会让文档偏离开发者实际 grep 到的内容。
-未来 xly 版本可能改名为 “detail” / “header”;在此之前,Wiki 使用代码库中的原词,并在此处一次性说明。
+保留这个词也有代价。最初命名本身并不好:`主表 / 从表` 完全可以翻成 `master / detail` 或 `header / line`,既符合英文开发习惯,也更贴近关系语义。继续保留 “slave” 的成本,会由每个需要阅读或输入这个词的英文维护者承担,也会由未来任何一次全 schema 改名承担。Wiki 在这里说明一次,并不能消除成本,只是把它明确写出来。
diff --git a/zh/docs/concepts/modules-forms-vtables.md b/zh/docs/concepts/modules-forms-vtables.md
index e239b79..42dbe3c 100644
--- a/zh/docs/concepts/modules-forms-vtables.md
+++ b/zh/docs/concepts/modules-forms-vtables.md
@@ -54,3 +54,35 @@ gdsmodule.sFormId → (大多为空,历史字段)
## 三个名词,一个引擎
运行时(`BusinessBaseController` 和 `BusinessBaseServiceImpl`,见[切片 1](../slices/01-hello-world.md))知道如何渲染任意模块 / 表单 / 虚拟表组合。不存在每模块专用 Java 代码。PM 创建新模块是在创建新行,不是在创建新代码路径。
+
+## 业务数据表前缀
+
+本 Wiki 把业务模块当作示例,而不是章节主体;但 schema 的命名有规律。维护人员可以通过三字母前缀判断业务数据表所属领域:
+
+| 前缀 | 领域 | 示例表(实时数量) |
+|---|---|---|
+| `gds` | 框架元数据(模块、表单、字段、权限、参数、图表) | `gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`、`gdsjurisdiction`、`gdsroute`、`gdsformconst`、`gdsparameter`、`gdsconfigcharmaster`/`slave`(BACK 图表配置使用的图表定义)等 |
+| `sys` | 框架系统层(编号、授权、报表、搜索、账单设置),区别于 `gds*` 定义层 | `sysjurisdiction`、`sysbillnosettings`、`sysreport`、`syssearch`、`sysapi`、`syssystemsettings` 等(68 张表) |
+| `sis` | 支撑下拉项的共享字典 / 分类表 | `sisbank`、`siscolor`、`sisversionflow`、`sisjurisdictionclassify` 等(80 张表) |
+| `sft` | 登录 session / 用户组权限连接表 | `sftlogininfo`、`sftlogininfojurisdictiongroup` 等(8 张表) |
+| `ele` | 主数据(element):客户、员工、机台、材料、产品、工序、半成品、成本框架 | `elecustomer*`、`eleemployee*`、`elemachine*`、`elematerials*`、`eleproduct*` 等(89 张表) |
+| `mft` | 制造:工单、生产计划、生产报工 | `mftworkordermaster`、`mftproductionplan*`、`mftproductionreport*` 等(82 张表) |
+| `sal` | 销售 | `salsalesordermaster`、`salsalesorderslave`、`salsalesorderprocess` 等(67 张表) |
+| `quo` | 报价 | `quoquotationmaster`、`quoquotationslave`、`quoquotationcalc_tmp` 等(23 张表) |
+| `acc` | 会计 / 成本 | `accordercostanalysis`、`accordercostanalysisoperation` 等(31 张表) |
+| `pur` | 采购 | `purpurchaseapply`、`purpurchasearrive`、`purpurchasechecking` 等(28 张表) |
+| `ops` | 外协 / 外发加工 | `opsoutsidearrive`、`opsoutsidechecking`、`opsoutsideinstore` 等(23 张表) |
+| `cah` | 出纳 / 财务 | `cahcashierinit`、`cahcostchangemaster`、`cahpaymentmaster`、`cahreceiptmaster` 等(22 张表) |
+| `sgd` | 半成品 | `sgdsemigoodscheck`、`sgdsemigoodsinstore`、`sgdsemigoodsmatchbill` 等(21 张表) |
+| `ept` | 设备 / 机台固定资产 | `eptmachinefixedborrow`、`eptmachinefixedchange`、`eptmachinefixedinstore` 等(21 张表) |
+| `mit` | 材料库存事务 | `mitmaterialsadjust`、`mitmaterialscheck`、`mitmaterialsinstore` 等(19 张表) |
+| `pit` | 产品库存事务 | `pitproductadjust`、`pitproductbarcode`、`pitproductcheck`、`pitproductinstore` 等(18 张表) |
+| `qly` | 质量检测 | `qlycomematerialstest`、`qlyproducttest`、`qlyprocesstest` 等(8 张表) |
+| `kpi` | KPI 跟踪 | `kpimaster`、`kpidetail`、`kpimoduleuserday` 等(7 张表) |
+| `udf` | 自定义 / 通用凭证框架 | `udfaccountno`、`udfvouchermaster`、`udfvouchertemplatemaster` 等(5 张表) |
+| `viw_` / `Viw_` | 数据库**视图**(schema 中大小写不一致) | `viw_mftworkorderprocess`、`viw_corebusinessreport`、`viw_accordercostanalysisnew` 等(共 311 个视图) |
+| `plat_` | B2B 印刷平台层(按[首页](../index.md)说明属于范围外) | 92 张表;本 Wiki 不展开 |
+| `ai_` | AI / LLM 功能(范围外) | 7 张表;本 Wiki 不展开 |
+| `act_`、`qrtz_` | 第三方 schema(Activiti 工作流、Quartz 调度) | 在 [Activiti](../reference/maintainer/activiti.md) 和[技术栈 Quartz](../reference/maintainer/tech-stack.md)中间接覆盖 |
+
+业务领域前缀(`ele`、`mft`、`sal`、`quo`、`acc`、`pur`、`ops`、`cah`、`sgd`、`ept`、`mit`、`pit`、`qly`、`kpi`、`udf`)及其从表都走同一套元数据驱动运行时:没有按前缀区分的 Java 代码,只有 `gdsconfigformmaster` / `gdsconfigformslave` 中指向各支撑表或视图的行。
diff --git a/zh/docs/concepts/multi-tenancy.md b/zh/docs/concepts/multi-tenancy.md
index 83b7385..10bbad8 100644
--- a/zh/docs/concepts/multi-tenancy.md
+++ b/zh/docs/concepts/multi-tenancy.md
@@ -10,11 +10,11 @@ xly 是多租户 SaaS。同一套代码库、同一套数据库 schema、同一
|---|---|---|---|
| **`sBrandsId`**(加工商ID) | 几乎每条业务行 | 每行 | 用户 session(`UserInfo.getsBrandsId()`) |
| **`sSubsidiaryId`**(子公司ID) | 几乎每条业务行 | 每行 | 用户 session |
-| **`sVersionFlowId`**(版本流程ID) | 仅 `gdsmodule` | 每模块 | 用户所属产品版本(对应 `sisversionflow`) |
+| **`sVersionFlowId` / `sVersionFlowCode`**(版本流程ID / code) | 仅 `gdsmodule` | 每模块标签 | 版本目录元数据;运行时菜单可见性使用许可证产出的 `sVerifyLicense` 模块列表 |
-实时 DB 中每行作用域非常普遍:`sBrandsId` 出现在 1,009 张表 / 视图上,`sSubsidiaryId` 出现在 1,008 张表 / 视图上。几乎所有业务数据表和框架元数据表都带有它们。
+逐行作用域在业务数据表中非常普遍:几乎每张业务数据表和视图都带有 `sBrandsId` 与 `sSubsidiaryId`。大多数框架元数据表也带有这两个列,但四张表(`gdsformconst`、`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`)是明确例外:`BusinessBaseServiceImpl.sTableNameList`(162-169 行)把它们列为“不需要公司子公司的表”,1078-1084 行会从这些表的写入载荷中剥掉 `sBrandsId` / `sSubsidiaryId`。实际使用中它们保存的是所有客户共享的一组哨兵租户值。惯例应理解为:如果一行代表租户拥有的状态,就有这两个列,且它们由 session 填充。
-每模块 gating(`sVersionFlowId`)只出现在 **3** 张表上,并且只有 `gdsmodule` 是实时表,另外两张是备份。因此版本 gating 是模块发现阶段的一次性过滤,不是每行检查。
+每模块版本元数据则相反:它只存在于 `gdsmodule` 上。实时运行时并不直接按 `sVersionFlowId` 过滤;模块发现由许可证产出的 `sVerifyLicense` 允许模块列表控制。因此版本控制发生在模块发现阶段,是一次性过滤,不是每行检查。
## 如何强制执行
@@ -34,6 +34,13 @@ RequestAddParamUtil.me().addParams(params, userInfo);
2. **数据库视图。** 几乎所有 `viw_*` 视图都会从主基础表带出租户列,但手写视图如果漏掉这些列,就可能造成按行泄漏。维护审计脚本应标记此类视图。
3. **保存端点**(`addUpdateDelBusinessData`),它允许前端在 payload 中直接提供 `sTable`。如果运行时不校验该表是否属于表单授权范围,这就是权限提升面。见[切片 1](../slices/01-hello-world.md#4-user-edits-a-row-clicks-save)。
-## 为什么这个实时 DB 看起来很小
+## 这个设计如何扩展,以及哪里扩展不了
-`xlyweberp_saas_ai` 只有**一个**品牌(`sBrandsId = '1111111111'`)、**一个**子公司,以及**一个**有数据的版本(`8S_001 / 基础版`)。多租户机制已经接好,但这里没有充分使用。生产租户会有几十个品牌、每个品牌下几十个子公司,以及多个版本;设计通过行数扩展,而不是通过代码分支扩展。
+框架的多租户设计靠**行数**扩展,而不是靠代码分支扩展。只有一个品牌、一个子公司、一种版本的小型 SaaS 部署,和拥有几十个品牌、几十个子公司、多个版本的部署,使用的是同一套 Java、MyBatis mapper 和存储过程;差异只体现在 `gdsmodule`、`sisversionflow` 和业务数据表中的行分布。
+
+按行数扩展运维上简单,但限制也很明确:
+
+- **共享物理 schema 意味着共享资源竞争。** 所有租户的查询都打到同一个 MySQL 实例、同一批表和同一批索引上。租户 A 的重报表会和租户 B 的录单竞争 buffer pool 和 CPU。这里没有按租户隔离资源。
+- **每个 WHERE 都要带租户过滤。** 每条读查询都要携带 `sBrandsId = ? AND sSubsidiaryId = ?`。索引通常也要以这些列开头才有用;xly 大多数表按约定这样做,但维护人员新增索引时必须记得这一点。漏掉后,查询计划会扫过所有租户的行,并在表变大后悄悄变慢。
+- **没有物理硬删除边界。** 租户下线不会 drop 一个数据库;行会留在原处,有的标记 `bInvalid`,有的被删除,有的完全不动。永久移除需要按租户写清理脚本。从 GDPR / 数据驻留角度看,“这个租户已经彻底消失”很难证明。
+- **`sBrandsId` / `sSubsidiaryId` everywhere 固化了租户单位。** “租户”精确定义为 `(sBrandsId, sSubsidiaryId)` 这个二元组。其他切法(例如按区域授权、按部门授权但不拆子公司)不适合这个模型,需要并行的作用域列。这个模型假定该形状永远适合所有客户;目前实践上基本成立,但这是一个硬承诺。
diff --git a/zh/docs/concepts/request-lifecycle.md b/zh/docs/concepts/request-lifecycle.md
index b5ce5f1..caa0d7e 100644
--- a/zh/docs/concepts/request-lifecycle.md
+++ b/zh/docs/concepts/request-lifecycle.md
@@ -7,27 +7,37 @@
```text
Browser
1. 任意 URL 加载 SPA shell(服务端对每个路径返回同一 shell;
- gdsroute 是客户端侧边栏 / deep-link 白名单,不是服务端 404 gate)
+ gdsroute 是客户端侧边栏 / deep-link 白名单,不是服务端 404 闸门)
2. 用户点击侧边栏项或在 SPA 内导航
3. SPA 决定加载哪个模块 → 调用 /business/...
-GET /xlyEntry/business/getModelBysId/{moduleId}?sModelsId={moduleId}
+GET /xlyEntry/business/getModelBysId/{sModelsId}?sModelsId={sModelsId}
+(模块 id 同时出现在 path 和 query 中;controller 绑定路径变量,
+但 service 从 @RequestParam map 读取 sModelsId,所以 SPA 也必须在 query string 中传它)
xlyEntry — BusinessBaseController.getModelBysId()
RequestAddParamUtil.addParams(params, userInfo)
→ sBrandsId、sSubsidiaryId、sUserId、sLanguage 等
- → 租户作用域进入所有下游查询
+ → 共 16 个 key(详见 runtime.md)
+ → 租户作用域变成下游查询可用的参数。框架元数据读取按 form-id 过滤;
+ 每租户覆盖与业务数据读取才按 sBrandsId / sSubsidiaryId 过滤。
BusinessBaseService.getModelBysId(map)
- ├── 加载 gdsmodule 行(模块)
- ├── 加载 gdsconfigformmaster 行(通过 sParentId 连接模块)
- ├── 加载 gdsconfigformslave 行(通过 sParentId 连接 form-master)
- ├── 合并 gdsconfigformpersonalize(每租户)
- ├── 合并 gdsconfigformcustomslave(每租户)
- ├── 加载 gdsjurisdiction(ADMIN 跳过)
- ├── 加载 gdsformconst(表单级常量)
- ├── 加载 sysbillnosettings(单据编号)
- └── 加载关联到该表单的 sysreport 行
+ ├── 1. formData
+ │ └── gdsconfigformmaster(按 sParentId = sModelsId 过滤;
+ │ gdsmodule 本身不被 SELECT,只通过 id 引用)
+ │ + LEFT JOIN gdsconfigformpersonalize(每租户覆盖)
+ │ + 每个 master 的 gdsconfigformslave
+ │ + 每个 master 的 gdsconfigformcustomslave(每租户覆盖)
+ ├── 2. gdsformconst(仅按 sParentId 过滤;不按租户过滤;
+ │ sLanguage 决定返回哪一列标签)
+ ├── 3. sysjurisdiction(按用户 / 用户组授权,JOIN
+ │ sftlogininfojurisdictiongroup + sisjurisdictionclassify;
+ │ ADMIN 跳过。返回 map key 仍叫 `gdsjurisdiction`,
+ │ 这个名字有误导性:gdsjurisdiction 表是配置侧动作目录,
+ │ 不是这里读取的授权表)
+ ├── 4. sysbillnosettings(每租户、每表单)
+ └── 5. sysreport(每租户、每表单)
返回一个复合 map:
{ formData, gdsformconst, gdsjurisdiction, billnosetting, report }
@@ -44,24 +54,83 @@ xlyEntry — BusinessBaseController.getBusinessDataByFormcustomId()
用户看到表格
```
+## 同一流程的时序图
+
+上面的 ASCII 图强调执行顺序;下面的时序图强调**谁调用谁**。排查真实请求时,后者通常更有用。
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant SPA as Browser SPA
+ participant CTRL as BusinessBaseController
+ participant SVC as BusinessBaseServiceImpl
+ participant FORMS as BusinessGdsconfigformsServiceImpl
+ participant DB as MySQL
+ participant REDIS as Redis (RedisCacheManager)
+
+ SPA->>CTRL: GET /business/getModelBysId/{sModelsId}
?sModelsId=...&Authorization=
+ Note over CTRL: AuthorizationInterceptor.preHandle
从 Redis 解析 UserInfo
RequestAddParamUtil.addParams(16 个 key)
+
+ CTRL->>SVC: getModelBysId(map)
+ Note over SVC: getModelConfigByModleId(继承自 BaseServiceImpl)
编排每个 form-master 的主表单 + 从表加载
+ SVC->>FORMS: getFormmasterData / getGdsconfigformslaveShow
(form-master + slaves + overlays)
+ REDIS-->>FORMS: cache hit?
+ FORMS->>DB: SELECT ... gdsconfigformmaster ⋈ personalize;每个 master 再读 gdsconfigformslave + gdsconfigformcustomslave
+ DB-->>FORMS: rows
+ FORMS-->>SVC: formData
+
+ SVC->>FORMS: getFormconstData(只按 form-id,不按租户)
+ FORMS->>DB: SELECT ... gdsformconst WHERE sParentId=...
+ DB-->>FORMS: rows
+ FORMS-->>SVC: gdsformconst
+
+ alt sUserType != ADMIN
+ SVC->>FORMS: getJurisdictionData(每用户授权)
+ FORMS->>DB: SELECT ... sysjurisdiction ⋈ sftlogininfojurisdictiongroup
+ DB-->>FORMS: rows
+ FORMS-->>SVC: gdsjurisdiction(map key;源表是 sysjurisdiction)
+ else ADMIN
+ Note over SVC: 跳过权限加载
+ end
+
+ SVC->>FORMS: getBillnosettingData
+ FORMS->>DB: SELECT ... sysbillnosettings WHERE sFormId=... AND tenant
+ DB-->>FORMS: row
+ FORMS-->>SVC: billnosetting
+
+ SVC->>DB: SELECT ... sysreport WHERE sFormId=... AND tenant
+ DB-->>SVC: report rows
+
+ SVC-->>CTRL: composite Map(5 个 key)
+ CTRL-->>SPA: AjaxResult{code:1, dataset:{...}}
+
+ SPA->>CTRL: POST /business/getBusinessDataByFormcustomId/{formId}
?sModelsId=...
+ Note over CTRL,SVC: 同一次 RequestAddParamUtil 注入
随后使用每表单 sSqlStr / sWhere / sOrder
+ CTRL->>DB: 对表单支撑 table/view/proc 执行参数化 SELECT
+ DB-->>CTRL: rows
+ CTRL-->>SPA: dataset
+```
+
+图中第 1 行和第 22 行是两个 HTTP 往返。中间全部是服务端工作,SPA 看不到。
+
## 五键复合结果
`getModelBysId` 返回一个 Java `Map`,按顺序包含这些 key:
| Key | 来源 | SPA 用途 |
|---|---|---|
-| `formData` | `gdsmodule` ⋈ `gdsconfigformmaster` ⋈ `gdsconfigformslave`(+ 覆盖) | 表单布局本身:每个字段、控件、标签、校验规则 |
-| `gdsformconst` | 按租户 + 语言作用域过滤的 `gdsformconst` 行 | 表单级常量:标签、默认值、下拉文本 |
-| `gdsjurisdiction` | 用户角色的 `gdsjurisdiction` 行 | 按钮和数据权限 |
-| `billnosetting` | 该模块的 `sysbillnosettings` 行 | 单据编号规则(工单号、报价单号) |
-| `report` | 关联到该表单的 `sysreport` 行 | 打印模板(jxls Excel、iText PDF) |
+| `formData` | `gdsconfigformmaster`(按 `sParentId = sModelsId` 过滤)⋈ `gdsconfigformpersonalize`(每租户覆盖);每个 master 行再加载 `gdsconfigformslave` + `gdsconfigformcustomslave` 覆盖。`gdsmodule` 只通过 id 引用。 | 表单布局本身:每个字段、控件、标签、校验规则 |
+| `gdsformconst` | 仅按 `sParentId` 过滤的 `gdsformconst` 行;不按租户过滤;`sLanguage` 决定返回哪一列标签 | 表单级常量:标签、默认值、下拉文本 |
+| `gdsjurisdiction` | 用户的 `sysjurisdiction` 行,或通过 `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify` 读取用户组授权;ADMIN 跳过。map key 名称 `gdsjurisdiction` 有误导性:那张表是配置侧动作目录,不是这里读取的授权表。 | 按钮和数据权限 |
+| `billnosetting` | 该模块的 `sysbillnosettings` 行(每租户) | 单据编号规则(工单号、报价单号) |
+| `report` | 关联到该表单的 `sysreport` 行(每租户) | 打印模板(jxls Excel、iText PDF) |
## 不在这个生命周期中的内容
- **保存路径。** 保存有自己的端点 `POST /business/addUpdateDelBusinessData`,把新增 / 更新 / 删除打包进一个请求。见[切片 1](../slices/01-hello-world.md#4-user-edits-a-row-clicks-save)。
- **打开已有行编辑的读取。** 与表格加载使用同一端点 `getBusinessDataByFormcustomId`,但 body 中带行 `sId`,handler 按请求一行还是多行分支。
-- **工作流步骤。** 如果模块有活动审批流(`bCheck = 1`、填充 `sVersionFlowId`、已部署 Activiti 流程),会插入额外步骤。当前 dev DB 没有这些数据;见[切片 7(暂缓)](../slices/07-workflow.md)。
-- **缓存失效。** **后台**修改元数据行时,JMS 消息会让所有运行节点失效缓存副本,即 `xlyErpJmsConsumer` 中的 `ConsumerChangeGdsModuleThread`。它在请求流之外,但紧邻请求流。
+- **工作流步骤。** 如果模块有活动审批流(`bCheck = 1`、`gdsmoduleflow` 已配置、Activiti 流程已部署,并且 `ConstantUtils.bCheckflowCheck = true`),会插入额外步骤。当前 dev DB 没有这些数据;见[切片 7(暂缓)](../slices/07-workflow.md)。
+- **缓存失效。** **后台**修改元数据行时,保存服务会同步调用 `BusinessCleanRedisData` / `CleanRedisServiceImpl`,从共享 Redis 中驱逐 Spring cache region。JMS 的 `ConsumerChangeGdsModuleThread` 是另一条基础数据合并通道,不是缓存失效。
## 其他切片覆盖的变体
diff --git a/zh/docs/concepts/semantic-fk.md b/zh/docs/concepts/semantic-fk.md
index 6be72bd..53deae1 100644
--- a/zh/docs/concepts/semantic-fk.md
+++ b/zh/docs/concepts/semantic-fk.md
@@ -1,15 +1,22 @@
# 无物理外键、语义外键的现实
-`xlyweberp_saas_ai` schema 在任何 xly 自研表(`gds*`、`ele*`、`mft*`、`quo*`、`sal*`、`acc*` 等表族)上都有 **0 个触发器**和 **0 个外键约束**。确实存在的 41 个 FK 约束都在捆绑的第三方 schema 上:Activiti 表(`act_*`)36 个、Quartz 调度表(`qrtz_*`)5 个;框架自身运行时不会通过这些表做核心连接。在 901 张基础表中,框架依赖的元数据和业务数据连接全部只是约定。
+`xlyweberp_saas_ai` schema 在任何 xly 自研表(`gds*`、`ele*`、`mft*`、`quo*`、`sal*`、`acc*` 等表族)上都有 **0 个触发器**和 **0 个外键约束**。确实存在的 FK 约束都在捆绑的第三方 schema 上:Activiti 的 `act_*` 表和 Quartz 的 `qrtz_*` 表;框架自身运行时不会通过这些表做核心连接。框架依赖的元数据和业务数据连接全部只是约定。
这是一个有意的设计选择,继续阅读前必须理解。
## 为什么 xly 禁用 FK
-架构上给出的两个原因都很务实:
+架构上给出的两个原因是:
1. **批量写入性能。** 大量插入(工单计算、月结、批量导入)一次会写入几十万行。启用 FK 后,MySQL 会在插入时验证每一行引用;以 xly 的数据量,这会成为限制因素。
-2. **schema 迁移敏捷性。** xly 演进很快:新模块、新字段、新表。启用 FK 时,每次 schema 变更都必须考虑约束图;没有 FK 时,`CREATE TABLE` 或 `ALTER TABLE` 是局部操作。代价由运行时应用代码承担。
+2. **schema 迁移敏捷性。** xly 演进很快:新模块、新字段、新表。启用 FK 时,每次 schema 变更都必须考虑约束图;没有 FK 时,`CREATE TABLE` 或 `ALTER TABLE` 是局部操作。
+
+这两个考虑都真实存在,但都不足以推出“整个 schema 零 FK”:
+
+- **批量写入性能**可以更精细地处理:批处理期间临时关闭约束(`SET FOREIGN_KEY_CHECKS = 0`),写完后重新打开并校验。xly 选择的是完全不建 FK,这意味着每次读取也都要相信临时过程校验,而不是数据库强制的完整性。
+- **schema 迁移敏捷性**确实会因为没有 FK 而提高,但代价是每个引用检查都要挪到应用代码或存储过程里,甚至可能被遗漏。实践中,原本 FK 自动完成的完整性工作被复制到数百个存储过程中,而且没有编译期保证某个过程真的做了检查(见下面的失效模式)。
+
+更准确的说法是:系统用 **DB 强制完整性**换取了**写入时和 DDL 时的运维便利**。这笔交易带来的 bug 面(孤儿行、未发现的跨租户引用、几周后才浮现的完整性错误)会在系统每天运行时持续付费。
## 什么是“语义 FK”
diff --git a/zh/docs/concepts/thesis.md b/zh/docs/concepts/thesis.md
index a2c0642..75ed051 100644
--- a/zh/docs/concepts/thesis.md
+++ b/zh/docs/concepts/thesis.md
@@ -10,22 +10,24 @@ xly 的方案相反:**单一代码库、单一部署,每客户行为用数
这个设计有三个内置成本,值得明确写出来:
-1. **每次请求都要读取元数据。** 每次页面加载至少读取四张表(`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`、`gdsjurisdiction`)以及租户过滤条件。运行时会积极缓存,但缓存未命中时这些读取不可避免。
-2. **schema 会持续膨胀。** 新模块 = `gdsmodule` 中一行 + `gdsconfigformslave` 中 1 到 50 行 + 一个支撑它的物理表(通常按单据类型)。当前实时 DB 有 901 张基础表;生产租户更多。
+1. **每次请求都要读取元数据。** 每次页面加载在缓存未命中时会跑五类查询:`gdsconfigformmaster`(并为匹配的明细行叠加 personalize / customslave 覆盖)、`gdsformconst`、`sysjurisdiction`(用户授权;返回 map key 叫 `gdsjurisdiction`,但实际读取的是 `sysjurisdiction`;ADMIN 会跳过)、`sysbillnosettings`、`sysreport`。运行时会积极缓存,但缓存未命中时这些读取不可避免。
+2. **schema 会持续膨胀。** 新模块 = `gdsmodule` 中一行 + `gdsconfigformslave` 中 1 到 50 行 + 一个支撑它的物理表(通常按单据类型)。随着业务模块增加,基础表数量会继续增长;生产租户通常比干净的 dev schema 带更多表,因为客户定制模块会长期留在共享 schema 中。
3. **关系是约定,不是约束。** 为了性能和迁移灵活性禁用外键后,从 `gdsconfigformmaster.sParentId` 到 `gdsmodule.sId` 的连接,以及上百个类似连接,都只是[语义外键](semantic-fk.md)。孤儿行是可能存在的。
-## 收益
+## 这个设计带来的能力,以及每种能力的代价
-作为交换,xly 得到:
+- **一个代码库服务几十个客户。** 每个客户租户拥有自己的元数据行;Java 完全相同。——*限制:*它并不能覆盖所有客户。`script/客户/` 下的 18 个目录(见[切片 5](../slices/05-customer-sql-override.md))就是数据驱动设计撞墙的位置:当客户需要不同的过程逻辑时,“单一代码库”就变成了“单一 Java 代码库 + 一批由数据库静默承载的客户专属 SQL”。
+- **PM 不占用开发时间就能演进应用。** 他们打开 BACK、添加模块、定义表单、设置权限,下一个用户加载时即可看到变化。——*限制:*PM 能表达的词汇,取决于 `gdsconfigformmaster` / `gdsconfigformslave` 已经暴露的列。真正新的能力(自定义计算、非标准校验、不同保存路径)仍然需要存储过程,也就重新需要工程师,只是开发位置从 Java 换成了 SQL。没有 DB 访问权的 PM 也很难判断一次元数据改动为什么产生了错误输出,因为过程侧逻辑在 BACK 中不可见。
+- **定制可以“干净地”分层**([切片 4](../slices/04-custom-field.md)):每租户覆盖叠加在共享基础之上,不需要 fork。——*限制:*这种干净主要是 Java 运行时视角下的。`BusinessBaseServiceImpl` 中的合并逻辑本身并不简单(3,900 多行);排查“为什么这个租户能看到字段 X、看不到字段 Y”时,需要追 `gdsconfigformpersonalize`、`gdsconfigformcustomslave`、`gdsconfigformuserslave` 的组合关系。而且覆盖层不能 `ALTER TABLE`;真正新增物理列仍然需要协调 schema 迁移。
-- **一个代码库服务几十个客户。** 每个客户租户拥有自己的元数据行;Java 完全相同。
-- **PM 不占用开发时间就能演进应用。** 他们打开**后台**、添加模块、定义表单、设置权限,下一个用户加载时即可看到变化。
-- **定制可以干净地分层**([切片 4](../slices/04-custom-field.md)):每租户覆盖叠加在共享基础之上,不需要 fork。
+更直白地说:数据驱动设计把复杂度从 Java 挪到了数据库和 PM 构建的元数据里。系统总复杂度没有消失,只是转移到了框架无法编译检查的人和工具上。
## 何时失效
-数据驱动适用于客户需求能用元数据表达的情况。一旦客户需要元数据表达不了的行为,比如不同 SQL、不同存储过程主体、框架词汇无法描述的聚合规则,就会触及边界。xly 的逃生口是[每客户 SQL 覆盖通道](../slices/05-customer-sql-override.md):把手写 SQL 提交到 `script/客户//`,并直接应用到该客户 schema,完全绕过框架。这个通道真实存在且正在使用,但也是维护成本最高的定制方式。
+数据驱动适用于客户需求能用元数据表达的情况。一旦客户需要元数据表达不了的行为,比如不同 SQL、不同存储过程主体、框架词汇无法描述的聚合规则,就会触及边界。xly 的应对方式是[每客户 SQL 覆盖通道](../slices/05-customer-sql-override.md):把手写 SQL 提交到 `script/客户//`,并直接应用到该客户 schema,完全绕过框架。
+
+这里需要说清楚:“绕过框架”意味着数据驱动论点只在系统的一部分成立。对 `script/客户/` 下的 18 个客户来说,运行时已经不再是真正的单一代码库;Java 是共享的,但每个客户 DB 里实际执行的过程体会分叉,而且没有自动机制发现漂移。审查者在源码中读到的 `Sp_SalSalesCheck`,并不保证就是某个生产客户实际运行的版本。把它称为“逃生口”已经偏温和;实践中,覆盖通道已经变成处理重大业务逻辑差异的标准答案,而这正是数据驱动设计原本想避免的失效模式。
## 这对阅读 Wiki 意味着什么
-本 Wiki 中的每个切片都记录这个论点的一次*应用*。切片 1 是 CRUD 模块上的四表读取。切片 2 是贯穿每一层的多租户作用域。切片 3 是只读 / 视图支撑的变体。切片 4 是定制覆盖。切片 5 是覆盖不够用时的逃生口。它们合起来从中心到边界覆盖数据驱动设计。
+本 Wiki 中的每个切片都记录这个论点的一次*应用*。切片 1 是 CRUD 模块上的元数据读取,也是标准实例。切片 2 是贯穿每一层的多租户作用域。切片 3 是只读 / 视图支撑的变体。切片 4 是定制覆盖。切片 5 是覆盖不够用时的逃生口。它们合起来从中心到边界覆盖数据驱动设计。
diff --git a/zh/docs/contributing/index.md b/zh/docs/contributing/index.md
index daa411e..77fd3d9 100644
--- a/zh/docs/contributing/index.md
+++ b/zh/docs/contributing/index.md
@@ -32,7 +32,7 @@ DB_NAME=xlyweberp_some_other python scripts/gen_catalog.py
生成器每次运行都会清空并重写 `docs/auto-catalog/{tables,views,procedures,functions}/` 下的全部文件。这些目录中的手工修改会丢失。
-## Pre-commit hook(可选,建议本地编辑时使用)
+## Pre-commit 钩子(可选,建议本地编辑时使用)
安装一次:
@@ -41,7 +41,7 @@ ln -s ../../scripts/precommit.sh .git/hooks/pre-commit
chmod +x scripts/precommit.sh
```
-hook 会在每次提交时运行 `mkdocs build --strict`,以便在提交前发现断开的交叉链接。
+钩子会在每次提交时运行 `mkdocs build --strict`,以便在提交前发现断开的交叉链接。
## 风格
diff --git a/zh/docs/index.md b/zh/docs/index.md
index 76068c9..882d56f 100644
--- a/zh/docs/index.md
+++ b/zh/docs/index.md
@@ -21,13 +21,20 @@
## 不覆盖的范围
-- B2B 印刷平台层(`plat_*` 表、`xlyPlat*` 模块)。
+- B2B 印刷平台层(`plat_*` 表、除 `xlyPlatConstant` 外的所有 `xlyPlat*` 模块;见下方说明)。
- AI / LLM 功能(`ai_*` 表、`AiController`),太新且仍在变化。
-- 人脸识别(`xlyFace`),范围较窄。
+- 人脸识别(`xlyFace`),范围较窄;它仍在 `settings.gradle` 中启用(会构建和部署),但本 Wiki 有意不展开。
+- 文件管理模块(`xlyFile`)和串口模块(`xlyRxtx`),范围较窄 / 偏硬件。
+- 调度模块(`xlyErpTask`、`xlyPlatTask`),在 `settings.gradle` 中已注释;cron / Quartz 接线不属于本 Wiki 覆盖的框架运行时。
+- 测试脚手架模块(`xlyTestService`、`xlyTestController`),历史遗留,不属于框架运行时。
- `xlyweberp_*` 数据库之间的租户级 schema 漂移;本 Wiki 针对一个 schema。
-- 备份表(`*_bak`、`*0302` 等)。
-- MongoDB 文档存储(yaml profile 中的 `spring.data.mongodb.uri`,以及 `xlyEntity/.../mongo/` 下的文档类)。每个 `@Document` 类都是 `PLAT_*` 命名,每个 `MongoTemplate` 调用方都位于 `xlyPlat*` 模块中,因此 MongoDB 属于上面的 plat 层。本 Wiki 覆盖的框架层只讨论 MySQL。
+- 备份表(`*_bak`、`*0302`、`*_copy1`、`*_history`、`*YYYYMMDD[HHMMSS]` 后缀快照等)。自动目录会为它们生成页面,因为它们真实存在;正文页面不会把它们作为一个家族展开。当前 schema 约有 56 张此类表。
+- MongoDB 文档存储(yaml profile 中的 `spring.data.mongodb.uri`,以及 `xlyEntity/.../mongo/` 下的文档类)。其中 22 个 `@Document` 类里有 20 个是 `PLAT_*` 命名;仅有两个例外是 `DIKE_TEST*` 临时测试类。唯一的 `MongoTemplate` 调用方是 `xlyPersist/.../dao/platmongo/BaseMongoDao`(`dao/platmongo/` 包名已经表明其 plat 层意图),在 cleanup 分支没有树内消费者;曾经继承它的 `xlyPlat*` 模块都已从 `settings.gradle` 注释掉。本 Wiki 覆盖的框架层只讨论 MySQL;Mongo 接线仍可编译,但处于休眠状态。
+
+> **关于 `xlyPlatConstant`。** 它带有 `xlyPlat*` 前缀,但属于本 Wiki 范围:`xlyPersist` 从中导入了两个工具类(`com.xly.xlyplatconstant.contant.thread.MultiThreadServer`、`com.xly.xlyplatconstant.contant.TimeContant`)。把它视为命名不准的共享工具模块,而不是平台层模块。
+
+> **关于 `xlyPlc`。** PLC / 硬件桥接插件属于本 Wiki 范围,是非核心模块如何挂入框架的标准示例。见[切片 06:硬件](slices/06-hardware.md)。
## 如何修正这个 Wiki
-编辑 Markdown 文件即可,这些文件就是 Wiki 的源。MkDocs Material 会从这些 `.md` 文件生成静态 HTML。重新生成命令和 pre-commit hook 见 [参与维护](contributing/index.md)。
+编辑 Markdown 文件即可,这些文件就是 Wiki 的源。MkDocs Material 会从这些 `.md` 文件生成静态 HTML。重新生成命令和 pre-commit 钩子见 [参与维护](contributing/index.md)。
diff --git a/zh/docs/reference/builder/attach-workflow.md b/zh/docs/reference/builder/attach-workflow.md
index 0984f9e..e487726 100644
--- a/zh/docs/reference/builder/attach-workflow.md
+++ b/zh/docs/reference/builder/attach-workflow.md
@@ -1,5 +1,7 @@
# 如何挂接工作流
+> **暂缓:需要一个已部署 BPMN 的环境。** 已用 dev DB 实证确认:`SELECT COUNT(*) FROM act_re_procdef` 返回 0;`gdsmoduleflow = 0`;`gdsmodule WHERE bCheck = 1` 也为 0 行。分发路径本身还被 `ConstantUtils.bCheckflowCheck = false` 硬禁用(见 [Activiti 集成](../../reference/maintainer/activiti.md))。下面的配方是**从代码推导出的假设**,尚未在实时部署上跑通过。
+
> **暂缓。** Activiti 已接入代码库,但当前实时 DB 中没有部署工作流。相同原因见[切片 7(暂缓)](../../slices/07-workflow.md)。
>
> 当有带活动流程的 DB 可用时,配方大致如下:
diff --git a/zh/docs/reference/builder/define-form.md b/zh/docs/reference/builder/define-form.md
index 936db24..5125106 100644
--- a/zh/docs/reference/builder/define-form.md
+++ b/zh/docs/reference/builder/define-form.md
@@ -88,4 +88,4 @@
## 缓存失效
-插入后,运行时缓存仍可能持有*之前*的空状态,直到 JMS 消息触发。**后台**构建器保存变更时,xly 的缓存失效监听器(`ConsumerChangeGdsModuleThread`)会处理;如果你通过原始 SQL 插入,可能需要重启运行服务或等待 TTL 过期。见[元数据变更后的缓存失效](../maintainer/cache-invalidation.md)。
+插入后,运行时缓存仍可能持有*之前*的空状态,直到某条路径清理它。BACK 保存变更时,保存 service 会同步调用 `BusinessCleanRedisData.delCleanRedisData*`,进而触发 `CleanRedisServiceImpl` 中相关 cache region 的 `@CacheEvict`。如果你通过原始 SQL 插入,**不会自动清理缓存**;需要从应用内部调用 `BusinessCleanRedisDataImpl` 的方法、重启运行服务,或等待 TTL 过期。(名字相近的 `ConsumerChangeGdsModuleThread` JMS 路径做的是存储过程基础数据合并,不是缓存失效;详见[元数据变更后的缓存失效](../maintainer/cache-invalidation.md)。)
diff --git a/zh/docs/reference/builder/define-vtable.md b/zh/docs/reference/builder/define-vtable.md
index 09f4002..7fe9b1c 100644
--- a/zh/docs/reference/builder/define-vtable.md
+++ b/zh/docs/reference/builder/define-vtable.md
@@ -10,7 +10,7 @@ xly 中的*虚拟表*是作为元数据声明的“表”,不是 DDL 创建的
- 定义下游表单可以叠加使用的数据形状。
- 集中管理租户感知的列定义,使多个读取同一形状的表单共享默认值。
-当前实时 DB 中,`gdsconfigtbmaster` 有 307 个虚拟表 master 行,`gdsconfigtbslave` 有 14,385 个虚拟列行。它们覆盖 lookup 表、分类树和可配置参数集。
+虚拟表覆盖 lookup 表、分类树和可配置参数集。随着 PM 增加新形状,目录会自由增长。
## 配方
@@ -21,19 +21,42 @@ xly 中的*虚拟表*是作为元数据声明的“表”,不是 DDL 创建的
| 列 | 值 |
|---|---|
| `sId` | 唯一虚拟表 ID |
-| `sName` | 虚拟表逻辑名 |
| `sChinese` / `sEnglish` / `sBig5` | 显示名 |
| `sBrandsId` / `sSubsidiaryId` | 租户作用域 |
-| `sTbName` | 底层物理表名(如果有支撑表) |
-| 其他配置列 | 描述存储和索引 |
+| `sTbName` | 底层物理名称。**实践中它可以指向表、视图或存储过程**;该列有唯一键,但没有更严格约束。运行时把它当成通用 SQL 标识符解析。 |
+| `sParentId` | 树形分类的父虚拟表;平铺表为空 |
+| `iOrder` | BACK 列表中的排序 |
### 2. 列 — `gdsconfigtbslave`
每列一行。每行携带列名、类型、默认值、显示标签、校验规则,以及它是否属于主键。
-## 未决:数据由什么支撑
+## `sTbName` 实际指向什么,以及漂移
-当前实时 DB 中,307 个 `gdsconfigtbmaster` 行都有非空 `sTbName`,但其中 11 个名称在 `information_schema.tables` 中找不到当前对象。因此安全表述是:元数据期望存在底层 SQL 对象,但实时 schema 并非对每个虚拟表行都完全对齐。
+每个 `gdsconfigtbmaster` 行都有非空 `sTbName`,但该列只是带唯一键的字符串;框架不会强制它解析为基础表。已对实时 dev DB 验证:
+
+- `gdsconfigtbmaster` 总计 307 行。
+- **296 行(96.4%)解析到 `information_schema.tables` 中真实存在的 `BASE TABLE`**。
+- **11 行(3.6%)无法解析为基础表**,而它们的分布本身很有信息量:
+
+| 未解析的 `sTbName` 实际指向 | 数量 | 示例 |
+|---|---:|---|
+| 视图(`viw_*`),不是表 | 4 | `viw_mftproductionreport`、`viw_mftproductionreportEmployee1` |
+| 存储过程(`Sp_*`) | 3 | `Sp_Cashier_BankJournal`、`Sp_Cashier_SumJournal`、`Sp_Sales_NotDeliverGoodNotifyList` |
+| 大小写折叠后存在的真实表,或已重命名 / 删除对象 | 4 | `QlyProcessTestResult`(大小写漂移)等 |
+
+所以 `sTbName` **不严格等于“物理表名”**;它是运行时会替换进读取查询里的通用 SQL 标识符,也可能指向视图或可调用过程。早先把它写成“底层物理表名”的说法过窄。
+
+可用于暴露漂移的审计 SQL:
+
+```sql
+SELECT sId, sChinese, sTbName
+FROM gdsconfigtbmaster
+WHERE sTbName NOT IN (
+ SELECT TABLE_NAME FROM information_schema.tables
+ WHERE TABLE_SCHEMA = DATABASE()
+);
+```
## 何时选择虚拟表、视图或真实表
@@ -45,6 +68,34 @@ xly 中的*虚拟表*是作为元数据声明的“表”,不是 DDL 创建的
虚拟表通道是框架对数据驱动形状的“类型系统”;物理 schema 才是真正存储行的地方。两者有意解耦。
-## 示例
+## 示例:`包装方式` lookup
-本页需要一个具体示例:从 `gdsconfigtbmaster` 选一张真实虚拟表,逐行解释它的 master 行和 slave 行。后续版本应补上。
+dev DB 中的一条代表性真实记录:
+
+**Master**(`gdsconfigtbmaster`):
+
+```text
+sId = 192116810113315231587698560
+sChinese = 包装方式 (Packing method)
+sTbName = SisPacking
+sParentId = (root)
+```
+
+**Slave 列**(`gdsconfigtbslave`,该 `sParentId` 下 10 行)声明的是*逻辑*形状:名称、显示标签、校验。*物理*形状位于真实的 `SisPacking` 表中:
+
+| Slave 行 | `SisPacking` 上的物理列 |
+|---|---|
+| `iIncrement`(自增列) | `iIncrement int auto_increment PK` |
+| `sId`(标准ID) | `sId varchar(100) UNIQUE` |
+| `sBrandsId`(加工商Id) | `sBrandsId varchar(100)` |
+| `sSubsidiaryId`(子公司Id) | `sSubsidiaryId varchar(100)` |
+| `tCreateDate`(制单日期) | `tCreateDate datetime DEFAULT CURRENT_TIMESTAMP` |
+| `sMakePerson`(制单人) | `sMakePerson varchar(255)` |
+| `iOrder`(排序号) | `iOrder int DEFAULT 0` |
+| `sName`(名称) | `sName varchar(255)` |
+| `sNo`(编号) | `sNo varchar(255)` |
+| `bInvalid`(作废) | `bInvalid bit(1) DEFAULT b'0'` |
+
+`gdsconfigtbslave` 中的 10 行与物理 `SisPacking` 表上的 10 列逐一对应。PM 随后可以让某条 `gdsconfigformmaster` 指向 `sTbName='SisPacking'`,form-slave 行再按名称引用同一批列。运行时把这两层粘合起来,路径与切片 1 中的元数据驱动读取相同。
+
+本页之前把“补一个 worked example”列为 TODO;这里就是补上的示例。
diff --git a/zh/docs/reference/builder/permissions.md b/zh/docs/reference/builder/permissions.md
index adf7260..0350863 100644
--- a/zh/docs/reference/builder/permissions.md
+++ b/zh/docs/reference/builder/permissions.md
@@ -87,4 +87,4 @@ returnMap.put("gdsjurisdiction", jList);
## 关于 `plat_base_authority_*`
-schema 中有三张 `plat_base_authority` 下的表(`plat_base_authority`、`plat_base_authority_button_type`、`plat_base_authority_data_type`),看起来像按钮类型和数据权限类型 lookup。**三张表在实时 DB 中都为空**;它们属于不在本 Wiki 范围内的 `xlyPlat*` B2B 平台层,不属于框架自身权限流。记录框架权限时不要引用它们;上面的 jurisdiction surface 是自包含的。
+schema 中有三张 `plat_base_authority` 下的表(`plat_base_authority`、`plat_base_authority_button_type`、`plat_base_authority_data_type`),看起来像按钮类型和数据权限类型 lookup。**三张表在实时 DB 中都为空**;它们属于不在本 Wiki 范围内的 `xlyPlat*` B2B 平台层,不属于框架自身权限流。记录框架权限时不要引用它们;上面的权限接口面是自包含的。
diff --git a/zh/docs/reference/maintainer/activiti.md b/zh/docs/reference/maintainer/activiti.md
index 62983ba..bdda1df 100644
--- a/zh/docs/reference/maintainer/activiti.md
+++ b/zh/docs/reference/maintainer/activiti.md
@@ -1,66 +1,198 @@
# Activiti 集成
-> **完整覆盖暂缓**:Activiti 已接入代码库,但当前实时 DB 中没有部署流程。见[切片 7(暂缓)](../../slices/07-workflow.md)。
+> **TL;DR:Activiti 已接线,流程引擎会启动,但没有流量经过它。** 引擎已完成 bootstrap,`act_*` schema 已按 6.0.0.4 版本建好,BPMN modeler 可访问。但所有工作流表都是 0 行:没有 BPMN、没有 procdef、没有流程实例、没有任务;没有 `gdsmodule.bCheck=1`,也没有 `gdsmoduleflow` 关联。用户看到的审批按钮(`/business/doExamine`)**完全绕过 Activiti**,只是通过 SQL 把 `bCheck` 更新为 `1`。细节见下面的 [xly 如何在不使用 Activiti 的情况下处理工作流](#xly-如何在不使用-activiti-的情况下处理工作流)。
-本页记录代码库中与 Activiti 相关的事实,避免未来维护人员在工作流真正启用时从零开始。
+本页记录实际已接线的内容(具体类、URL、流程引擎状态),以及要让它真正工作需要满足什么条件。
-## 两个 Activiti 版本
+## xly 如何在不使用 Activiti 的情况下处理工作流 {#xly-如何在不使用-activiti-的情况下处理工作流}
-依赖树携带**两个** Activiti 版本:
+xly 有**三种类似工作流的机制**,按实际使用程度从高到低排序如下。
+
+### 路径 1:存储过程 + `bCheck` 标志的一步审批(主流模式)
+
+这是 xly 中 99% 审批的样子。这里**没有流程引擎,也没有状态机**;工作流就是那次过程调用本身。
+
+机制:
+
+1. 每张业务表携带同一组三个审计列:`bCheck`(审批布尔值)、`sCheckPerson`、`tCheckDate`。在当前 dev DB 中,`bCheck` 出现在 **426** 张表上,`tCheckDate` 出现在 400 张表上,`sCheckPerson` 出现在 398 张表上。也就是说,几乎每个业务单据都把审批审计轨迹放在自己行里。
+2. 每个模块在 `gdsmodule.sProcName` 中声明一个**单一**审批过程名(列注释是“存储过程(审核)名称”)。例如报价模块的 `Sp_Quo_QuotationCheck`、销售订单的 `Sp_SalSalesCheck`。
+3. 用户点击“审核”按钮时:
+ - `POST /business/doExamine` → `BusinessBaseController.java:384-391` → `BusinessBaseServiceImpl.doExamine()` → `ExamineServiceImpl.doExcuExamine()`。
+ - service 获取一个基于行 id 的 Redis 锁(`ws_update_*_{sGuid}_*`),避免两个用户并发审批。
+ - 它通过通用过程调用机制分发到 `sProcName` 命名的过程,见[通用存储过程分发](proc-dispatch.md)。
+ - **过程本身拥有业务逻辑**:校验必填字段、子行和主表合计是否平衡、相关单据是否锁定等。如果全部通过,过程更新该行:`bCheck = 1`、`sCheckPerson = `、`tCheckDate = NOW()`。
+ - 过程返回 `OUT sCode INT`(1 表示成功,≤0 表示错误)和 `OUT sReturn LONGTEXT`(错误消息)。
+4. 反审核是同一调用,只是 `iFlag = 0` 而不是 `1`;过程同时处理两个方向。
+5. `Sp_System_CheckSave` 是每个带 `bCheck` 行保存后的通用钩子,由 `BusinessBaseServiceImpl.java:1828` 调用。它写入 `sFormId` 审计字段,并保留跨单据校验的占位逻辑(当前大多已注释)。
+
+这条路径没有“下一个审批人”或“审批队列”的概念。一个有权限的用户点击审核且过程成功后,该行就审批完成。
+
+### 路径 2:单据串联形成的隐式多步工作流
+
+多单据业务流程(报价 → 客户确认 → 销售订单 → 发货 → 发票)不是靠一个单据在状态之间推进,而是靠**多个独立模块和表单**实现。用户视角是:
+
+1. 模块 A(例如 `quoQuotationMaster`):填单,点击审核,行变成 `bCheck = 1`。
+2. 模块 A 上有一个按钮(通过 `gdsconfigformslave.sButtonParam` 指向 `Sp_Quo_ToSalesOrder` 或类似过程)。点击后,过程创建模块 B(例如 `salSalesOrderMaster`)中的一行,并用模块 A 的数据预填。
+3. 用户进入模块 B,补充字段,再在那里审核,依次推进。
+
+FROUNT [KPI 工作中心](runtime.md#kpi-工作中心front-端首页-dashboard) 上的 “01/04、02/04、03/04、04/04” 步骤编号反映的就是这个模型:每个“流程”是一个父模块,下面有 N 个有序子模块;步骤只是父 `gdsmodule` 条目下的子模块。没有流程引擎在跟踪“你处于四步中的第二步”;用户只是操作当前打开的单据,框架用父模块下子模块的 `iOrder` 提供上下文。
+
+因此,多步“工作流”来自:
+
+- 一个按主题分组步骤的父 `gdsmodule`(例如 `估价管理流程`,包含 4 个子模块)。
+- 每个子模块自己的 `sProcName`(一步审批)。
+- 每个子模块自己的 `sButtonParam` 过程,点击后创建下一张单据。
+- 每张业务单据自己的 `bCheck` 标志。
+
+没有状态机,也没有 FSM 库;只是**通过表单按钮把一串存储过程接起来**。这是当前 dev DB 中能看到的机制。
+
+### 路径 3:Activiti BPMN 工作流(有闸门,目前代码中禁用) {#路径-3activiti-bpmn-工作流有闸门目前代码中禁用}
+
+路径 3 作为第三条通道存在于代码库中:启用后会经由 Activiti。但它没有在当前 dev DB 中运行,而且目前不重新编译无法启用。
+
+闸门硬编码在 `xlyPersist/.../utils/ConstantUtils.java`:
+
+```java
+public static Boolean bCheckflowCheck = false;
+```
+
+`ExamineServiceImpl.doExcuExamine()` 内部分发逻辑是:
+
+```java
+if (ConstantUtils.bCheckflowCheck) {
+ Map reMap = checkExamineFlowService
+ .doSendCheckFolw(sGuid, sUserName, sBrandsId, sSubsidiaryId,
+ sFormId, map, searMap, sBtnName, request);
+ if (MapUtil.isNotEmpty(reMap)) { return reMap; }
+}
+```
+
+所以即使租户部署了 BPMN,并通过 `gdsmoduleflow` 关联了模块,这个 `if` 也会因为 `bCheckflowCheck` 是 Java 常量 `false` 而短路。要启用路径 3,需要:
+
+1. 把源码中的 `ConstantUtils.bCheckflowCheck` 改为 `true` 并重新构建 WAR,或在运行时 patch 该常量。
+2. 插入一行以 `(sFormId, sBtnName)` 为 key 的 `gdsmoduleflow`,把表单上的审批按钮映射到已部署 BPMN。
+3. 通过 modeler 部署 BPMN,让 `act_re_procdef` 有数据。
+4. 确认相关模块的 `bCheck` 语义与 BPMN 起止事件对齐。
+
+启用后,`CheckExamineFlowServiceImpl.doSendCheckFolw` 会读取 `gdsmoduleflow` 行,调用 `checkExamineFlowDataService.doSendCheckFolwData` 预置数据,再经 `doSendFlowUrl` 跳到 xlyFlow controller。随后 `ProcessServiceImpl.submitApply()` 调用 `runtimeService.startProcessInstanceByKey(...)`,Activiti 接管流程;`biz_flow` + `biz_todo_item` 填充,审批人在收件箱中看到任务,`CurrencyFlowController.complete(...)` 推进流程实例。
+
+### 对比
+
+| 方面 | 路径 1(proc + bCheck) | 路径 2(单据链) | 路径 3(Activiti) |
+|---|---|---|---|
+| 状态存储 | 单据行上的 `bCheck` 列 | 无;状态等于用户打开哪张单据 | `act_ru_task`、`act_hi_*`、`biz_flow`、`biz_todo_item` |
+| 步骤转换 | 每张单据一步 | 每个链路按钮触发“转下一张单据”的过程 | 流程引擎按 BPMN 图驱动转换 |
+| 转派 / 委托 | 不支持 | 不支持 | Activiti 支持 |
+| 并行分支 | 不支持 | 不支持 | BPMN 网关支持 |
+| 当前是否活跃 | 是,每次“审核”点击都会用 | 是,多单据业务流都在用 | 否,代码中 `bCheckflowCheck = false` |
+| 工具 | 只有存储过程 | 存储过程 + 模块树配置 | `/modeler/*` 下的 BPMN modeler |
+
+### 真实路径 1 定制示例
+
+[切片 5](../../slices/05-customer-sql-override.md#示例-2万昌构建多级审批工作流) 追踪了万昌的 `领班驳回.sql`:这是客户侧多级审批驳回的典型例子。它展示了客户在单个 `bCheck` 不够时如何扩展路径 1:`ALTER TABLE` 增加多个审批标志(`bManager`、`bIPQC`、`bDeputy` 等),按 `Sp__check_` 约定编写状态转换过程,并通过自定义 `sp_add_flow_log` 写审计。这是代码库中实证可见的定制通道;`script/客户/` 下没有任何目录部署 BPMN。
+
+### 为什么这个设计适合 xly 的用户
+
+印刷行业 ERP 客户的业务流程通常是规则驱动的(报价 → 订单 → 生产 → 发货 → 开票 → 收款),每一步按惯例都是**自己的单据和自己的表单**。用户预期的是“现在打开下一张表单继续填”,而不是“系统告诉我有一个 task 等我处理”。对这类用户:
+
+- 路径 1 + 路径 2 覆盖了当前 dev DB 中观察到的所有场景。
+- 路径 3 的价值(BPMN 建模、转派、并行网关)留给极少数审批图确实需要它的租户。
+
+代价是:工作流逻辑**分散在存储过程中**,而不是集中声明在一个地方。给流程增加新步骤意味着写或改一个或多个过程,而不是编辑 BPMN 图。对复杂且频繁变化的流程,这会很脆弱;但对印刷厂现实中的 quote-to-cash 链条(每客户不常变化)来说,这是务实选择。
+
+## Activiti 已接线:流程引擎已开启
+
+尽管 dev DB 处于空转状态,流程引擎会随 `xlyEntry` 启动:
+
+- `xlyFlow/build.gradle:15` 引入 `org.activiti:activiti-spring-boot-starter-rest-api:6.0.0`。该 starter 传递引入 `activiti-spring-boot-starter`,触发 Spring Boot 的 `ProcessEngineAutoConfiguration` 创建 `SpringProcessEngineConfiguration` bean。
+- `xlyEntry/build.gradle` 通过 `api project(':xlyFlow')` 引入 xlyFlow,因此 starter 在 `xlyEntry` WAR 的运行时 classpath 上。
+- `xlyEntry/.../EntryApplicationBoot.java:23-24` 只排除了 `org.activiti.spring.boot.SecurityAutoConfiguration`(REST 端点安全适配器)和 Spring 自己的 `SecurityAutoConfiguration`。**Activiti 主流程引擎 auto-config 没有被排除**,所以引擎会启动。
+- `xlyFlow/.../activiti/config/ActivitiConfig.java` 是一个 `@Configuration implements ProcessEngineConfigurationConfigurer`,只做两件事:把图生成器字体设为中文友好的 `宋体`(activity / annotation / label fonts),并安装自定义 `ICustomProcessDiagramGenerator`。
+- `xlyApi` 的 `ApiApplicationBoot` 也没有排除 Activiti,但 xlyApi 不依赖 xlyFlow。因此 xlyApi classpath 上有流程引擎里的 `org.activiti.engine.identity.User` 类(仅由 `IdGen.java` 加密工具使用),但不会触发 Activiti auto-config。
+
+实时 schema 中看到的 24 张基础 `act_*` 表,是流程引擎首次启动时通过 auto-DDL 创建的。
+
+## classpath 上的两个 Activiti 版本
| 模块 | 版本 | 说明 |
|---|---|---|
-| `xlyPersist` | `org.activiti:activiti-engine:5.17.0` | 较老的 5.x 线 |
-| `xlyFlow` | `org.activiti:activiti-spring-boot-starter-rest-api:6.0.0`、`activiti-json-converter:6.0.0` | 较新的 6.0 线 |
+| `xlyPersist`、`xlyApi` | `org.activiti:activiti-engine:5.17.0` | 较老的 5.x 线,两个模块都有声明。**遗留依赖**;`xlyEntry` WAR 中实际运行的流程引擎是 xlyFlow starter 拉入的 6.0。5.17 声明只是 classpath 上的包袱。 |
+| `xlyFlow` | `org.activiti:activiti-spring-boot-starter-rest-api:6.0.0`、`activiti-json-converter:6.0.0` | 较新的 6.0 线,这才是会运行的版本。 |
-这是实际版本不匹配。Activiti 5.x 和 6.x schema 有重叠,但在部分 `act_*` 表和迁移路径上分叉。可能性包括:
+做清理的维护人员应从 `xlyPersist` 和 `xlyApi` 的 `build.gradle` 中移除 `5.17.0`。已验证:这两个模块只有 `IdGen.java` 会触碰 `org.activiti.engine.identity.User`,而该类型签名由 6.0 流程引擎也能满足,因此移除是安全的。
+
+## 代码中实际调用了什么
+
+`xlyFlow/src/main/java/com/xly/activiti/` 下 154 个 Java 文件加 modeler 子包是真实调用点。选取锚点如下:
+
+| 活动 | 类 : 行 | 使用的 Activiti API |
+|---|---|---|
+| 启动流程实例 | `ProcessServiceImpl.submitApply()` :107 | `runtimeService.startProcessInstanceByKey(module, businessKey, variables)` |
+| 完成任务 | `CurrencyFlowController.complete(...)` :167 / :200;`WechatFlowPostThread` :132 | `processService.complete(taskId, ...)` → `taskService.complete()` |
+| 查询活跃任务 | `CurrencyFlowController` :409、:480 | `taskService.createTaskQuery().active().list()` |
+| 查询运行中实例 | `CurrencyFlowController` :485、:659 | `runtimeService.createProcessInstanceQuery()` |
+| 在 modeler 中保存模型 | `ModelerController.create()` :122 | `repositoryService.saveModel()` + `addModelEditorSource()` |
+| 运行时部署 BPMN | `ModelerController.deploy()` :147 | `repositoryService.createDeployment().addString(name, bpmnXml).deploy()` |
+| 列出流程定义 | `ProcessDefinitionController` :135 | `repositoryService.createProcessDefinitionQuery()` |
+| 读取流程引擎配置 | `ProcessActController` :281 | `ProcessEngines.getDefaultProcessEngine()` |
+| 把 xly 用户桥接到 Activiti identity | `act_id_user` / `act_id_group` / `act_id_membership` 是投影 xly `sftlogininfo*` schema 的**视图** | xly 不写 Activiti identity 表;这些视图伪装成 identity 表 |
-1. 框架运行 Activiti 6.0(由 xlyFlow 驱动),而 xlyPersist 的 5.17 依赖是早期遗留。
-2. 不同服务因历史原因使用不同版本。
-3. 两者都在 classpath 中,但运行时只初始化一个。
+## modeler 暴露的 URL(xlyFlow controller 挂在 xlyEntry 端口上) {#modeler-暴露的-urlxlyflow-controller-挂在-xlyentry-端口上}
-未来维护人员应:(a) 移除未使用版本避免混淆,(b) 记录实时 schema 使用哪个版本,(c) 验证 `act_*` 表布局与该版本精确匹配。
+xlyFlow 被 xlyEntry 作为库消费(`api project(':xlyFlow')`),因此 xlyFlow controller 会编译进 xlyEntry WAR,并在 xlyEntry context-path(`/xlyEntry`)下服务。重要 URL:
-额外事实:`xlyFlow/build.gradle` 引入 Activiti 6 starter,但 `xlyFlow/src/main/java/com/xly/XlyFlowApplicationBoot.java` 被完全注释。因此代码存在,但本仓库当前并没有把 `xlyFlow` 呈现为明确可独立运行的 app。
+- `POST /xlyEntry/modeler/model/{modelId}/save`:保存 BPMN modeler XML。
+- `GET /xlyEntry/modeler/model/{modelId}/json`:为编辑器加载模型。
+- `GET /xlyEntry/modeler/editor/stencilset`:modeler stencil 定义。
+- `GET /xlyEntry/modeler/create` / `/modeler/deploy/{modelId}`:创建和部署。
+- `POST /xlyEntry/currencyFlow/complete/{taskId}/{sBrandsId}/{sSubsidiaryId}/{sUserId}`:完成任务(`CurrencyFlowController`)。
+- `POST /xlyEntry/currencyFlow/completeerp/{sBrandsId}/{sSubsidiaryId}/{sUserName}`:ERP 侧完成任务变体。
-## `act_*` schema
+这些 URL 不在[内部 API 页](../../api-reference/internal.md)中完整编目,因为它们是很少触碰的工作流接口面;维护时以源码为准。
-实时 DB 当前有预期的 `act_*` 表,但本轮检查的关键表都是空的:
+## `CheckFlowController.java` 实际包含什么
-- `act_re_deployment` = 0
-- `act_re_procdef` = 0
-- `act_ru_task` = 0
-- `act_hi_procinst` = 0
+这是一个需要明确标出的 wiki 内部纠正:`xlyEntry/src/main/java/com/xly/web/businessweb/CheckFlowController.java` 文件存在,但类体**只有 22 行、0 个 handler 方法**,只是一个带 `@RestController @RequestMapping(value="/checkflow")` 的空壳。早期 wiki 把 `/checkflow/*` 描述为“Activiti 工作流接口面(审批 / 驳回 / 查看)”,这不符合当前文件。**实时系统上任何 `/checkflow/*` 子路径都会返回 404。** 真正的审批 / 驳回 / 查看 URL 来自上面列出的 `CurrencyFlowController` 等 controller。
-## xly 的包装层
+## `act_*` schema 状态(当前 dev DB)
-三个 xly 表包装 Activiti 集成:
+| 表 | 行数 | 有数据时的含义 |
+|---|---:|---|
+| `act_re_model` | 0 | modeler 中保存的 BPMN 模型 |
+| `act_re_procdef` | 0 | 已部署流程定义 |
+| `act_ru_task` | 0 | 活跃等待任务 |
+| `act_hi_procinst` | 0 | 历史流程实例 |
+| `act_id_user` / `act_id_group` / `act_id_membership` | 视图 | 把 xly 的 `sftlogininfo*` 用户投影成 Activiti identity 形状 |
+| `gdsmoduleflow` | 0 | xly 从 `gdsmodule` 到流程定义的关联 |
+| `biz_flow` | 0 | xly 的每单据流程状态 |
+| `biz_todo_item` | 0 | 待审批任务(xly wrapper,不是 Activiti 的 `act_ru_task`) |
+| `biz_todo_copyto` | 0 | 流程抄送方 |
-- [`biz_flow`](../../auto-catalog/tables/biz_flow.md):xly 每单据流程状态。
-- [`biz_todo_item`](../../auto-catalog/tables/biz_todo_item.md):待审批任务。
-- [`biz_todo_copyto`](../../auto-catalog/tables/biz_todo_copyto.md):流程抄送方。
-- [`gdsmoduleflow`](../../auto-catalog/tables/gdsmoduleflow.md) + `gdsmoduleflowslave`:模块流程窗口配置。
+所以 Activiti 当前是**彻底空转**:流程引擎在运行,schema 已就绪,没有流量。
-包装表在实时 DB 中也为空:`gdsmoduleflow = 0`、`biz_flow = 0`、`biz_todo_item = 0`、`biz_todo_copyto = 0`。
+## 什么会让它动起来
-启用时的模式:单据提交写入 `biz_flow` 行,同时启动 Activiti 流程实例;待审批人在 `biz_todo_item` 中看到任务;审批后流程实例推进并最终完成。
+要让一个流程真正运行,大致顺序如下:
-## 代码中接入位置
+1. 工程师或 PM 打开 **modeler UI**(静态资源位于 `xlyFlow/src/main/resources/static/modeler/`,通过 `/modeler/*` 端点服务)。他们绘制 BPMN 并保存,`act_re_model` 填充。
+2. 在 modeler 中点击 *Deploy* → `ModelerController.deploy()` 调用 `repositoryService.createDeployment().addString(name, bpmnXml).deploy()` → `act_re_procdef` 填充。
+3. 某个 `gdsmodule` 行标记 `bCheck = 1`,并在 `gdsmoduleflow` 中插入一行,把该模块关联到已部署的 `act_re_procdef.KEY_`。
+4. 用户在该模块保存一行时,保存 service 检测 `bCheck = 1`,调用 `ProcessServiceImpl.submitApply(applyUserId, businessKey, itemName, itemContent, module, variables)`。该方法执行 `runtimeService.startProcessInstanceByKey(module, businessKey, variables)`,于是 `act_ru_*` 表填充,`biz_flow` + `biz_todo_item` 也得到 xly 侧行。
+5. 审批人在 FROUNT 收件箱中看到待办(很可能是“审批”tab,独立于 KPI 工作中心)。点击通过 / 驳回 → `CurrencyFlowController.complete()` → `taskService.complete()`。
+6. 当流程实例到达 `endEvent`,行的 `bCheck` 发生转换;下游过滤 `bCheck = 1` 的查询开始看到它。
-`xlyFlow/` 是专用模块。后续补全时应关注:
+## xly 为什么要接 Activiti
-- `xlyFlow` 的 Gradle build 引入 Activiti 6.0。
-- Activiti process engine 的 Spring Boot 配置。
-- `xlyEntry/com/xly/web/businessweb/` 中的 `CheckFlowController` 是 SPA 驱动工作流(审批 / 驳回 / 查看)的一个入口。
-- BPMN 流程定义若存在,应位于 `xlyFlow/src/main/resources/processes/` 或类似位置;当前代码库为空。
+代码库已有自己的 `biz_flow` / `biz_todo_item` 表,理论上可以手写审批系统。把 Activiti 放在后面的收益是:
-## 让 Activiti 工作需要什么
+- 标准 BPMN 建模(JS modeler 使用 Activiti Explorer 同源的 stencilset)。
+- 免费的状态机语义:流程引擎处理“task A 完成 → task B 可用”,xly 不需要用 SQL 维护 FSM。
+- 图渲染能力(`ProcessActController` 中的页面转 PNG)。
-使用工作流的部署需要:
+成本是:JVM 中多一个流程引擎,DB 中多一套可能发生 DDL 漂移的 schema,以及一个额外认证接口面(xly 通过 `act_id_*` 视图把它遮住)。
-1. 已部署 BPMN 流程定义(`act_re_procdef` 有数据)。
-2. 模块标记 `bCheck = 1`,并通过 `gdsmoduleflow` 关联到正确流程。
-3. 通过 `act_id_*` 或 xly 包装层分配审批用户。
-4. 保存端点在 `bCheck = 1` 模块上分支,启动流程实例,而不是(或除了)标准 add/update/delete。
+## 本页不是什么
-当有已部署流程的环境可用时,本页会成为正式参考;在此之前,把它视为“这里应该有什么”的清单。
+- 不是切片 7 的替代品。切片 7(暂缓)应基于一个真实运行流程的部署做端到端追踪。
+- 不是 modeler 教程。modeler 来自 Activiti 项目;xly 只是把它作为静态资源嵌入,没有修改。
+- 不是从 Activiti 迁移到其他方案的计划。那会是更大的架构决策,不是 wiki finding。
diff --git a/zh/docs/reference/maintainer/bi-engine.md b/zh/docs/reference/maintainer/bi-engine.md
new file mode 100644
index 0000000..11e6461
--- /dev/null
+++ b/zh/docs/reference/maintainer/bi-engine.md
@@ -0,0 +1,140 @@
+# BI / KPI / 图表引擎
+
+xly **没有**内置通用 OLAP / cube 引擎(没有 Mondrian、Saiku、MDX;随包里的 `olap4j-1.2.0.jar` 没有任何 Java import,属于 classpath 上的历史包袱,见[技术栈](tech-stack.md)里的 OLAP4J 说明)。
+
+xly 真正提供的是一套**自研的元数据驱动看板 + KPI 层**。它和框架其他部分使用同一组原语:`gds*` 元数据行指向 `Sp_*` 存储过程,再由通用 Java service 渲染。本页按端到端路径说明它。
+
+## 三个部分
+
+| 部分 | 入口 | 支撑表 | Service |
+|---|---|---|---|
+| **图表**(卡片、柱线饼仪表盘组件、看板) | FROUNT 的 `/indexPage/commonChar` 模块;BACK 的图表配置管理页 | `gdsconfigcharmaster`(3,006 行)、`gdsconfigcharslave`(1,951 行) | `CharServiceImpl`(2,219 行,是 xlyBusinessService 里最重的类之一) |
+| **KPI**(按员工 / 模块做绩效评分) | BACK / FROUNT 的 KPI 页面,例如 `行为KPI项目设置`、`5.经营KPI分析`、`异常清除KPI任务表` | `kpimaster`(124,524 行)、`kpidetail`(1,308)、`kpimodule`(44)、`kpimoduleuser`、`kpimoduleuserday`、`kpislavel` | `KpiServiceImpl`(833 行,位于 `xlyBusinessService/.../KPIService/`)+ `BusinessModelKpiServiceImpl`(901 行)+ `FlushModleKpiThread`(后台刷新) |
+| **预置聚合过程** | 由图表元数据行调用 | n/a | 20 个 `Sp_chart_*` 过程 + 2 个 `Sp_KPI_*` 过程 + `spKPImodule` |
+
+## 图表:看板如何渲染
+
+xly 中的一个图表就是 `gdsconfigcharmaster` 中的一行,关键列如下:
+
+| 列 | 作用 |
+|---|---|
+| `sId` | 图表 ID |
+| `sParentId` | 所属模块(`gdsmodule.sId`) |
+| `sChinese` / `sEnglish` / `sBig5` | 图表标题 |
+| `sCharType` | 组件类型,见下方分布 |
+| `sProcedureName` | 产出图表数据的存储过程 |
+| `sProcedureParam` | 存储过程参数 JSON 规格 |
+| `iWidth` | 布局跨度(24 列栅格) |
+
+实时 dev DB 中的 `sCharType` 分布:
+
+| `sCharType` | 数量 | 渲染内容 |
+|---|---:|---|
+| `Div` | 1558 | 容器 / 纯布局块 |
+| `sLabel` | 1143 | 单值文本卡片,例如“今日销售额:¥X” |
+| `Progress` | 137 | 进度条 |
+| `sPie` | 52 | 饼图 |
+| `commonList` | 45 | 内嵌数据表格(复用通用 grid) |
+| `sColumnarGroup` | 30 | 分组柱状图 |
+| `sColumnar` | 28 | 单系列柱状图 |
+| `sBrokenLine` | 5 | 折线图 |
+| `sBar` | 3 | 横向条形图 |
+| `ColorBlock` | 3 | 类热力块的彩色块 |
+| `sGauge` | 2 | 仪表盘组件 |
+
+从表行(`gdsconfigcharslave`)保存多系列 / 多列图表所需的系列或列拆分。
+
+运行时路径:
+
+```text
+SPA 打开 /indexPage/commonChar?sModelsId=<看板模块 id>
+ │
+ ▼
+GET /xlyEntry/business/getModelBysId/
+ → 返回看板的元数据复合结果
+ (formData 中包含来自 gdsconfigcharmaster + slave 行的图表布局)
+ │
+ ▼
+对每个图表:
+ POST /xlyEntry/business/getXxx(CharServiceImpl 方法)
+ 携带图表的 sProcedureName + sProcedureParam
+ → CharServiceImpl 通过通用存储过程分发调用对应的 Sp_chart_* 过程
+ │
+ ▼
+SPA 用前端 ECharts 渲染每张卡片;一行图表元数据对应一张卡片
+```
+
+## 20 个 `Sp_chart_*` 过程
+
+| 过程 | 计算内容 |
+|---|---|
+| `Sp_chart_home_11`、`Sp_chart_home_13` | 首页看板卡片 |
+| `Sp_chart_TodayOrder`、`Sp_chart_TodayOrder_hm`、`Sp_chart_ThisMonthQty`、`Sp_chart_MonthOrder`、`Sp_chart_MonthTeamQty` | 当天 / 当月订单数、班组产量 |
+| `Sp_chart_TodayProfit`、`Sp_chart_MonthProfit`、`Sp_chart_TodayReceivables`、`Sp_chart_TodayReceive`、`Sp_chart_expenses` | 财务:利润、应收、收款、费用汇总 |
+| `Sp_chart_EquipmentLoad`、`Sp_chart_EquipmentLoad1`、`Sp_chart_EquipmentLod1`、`Sp_chart_EquipmentLast`、`Sp_chart_sMachine_speed`、`Sp_chart_Bottleneck` | 车间:设备负载、最后运行状态、当前瓶颈 |
+| `Sp_chart_OrderProcess`、`Sp_chart_WorkOrderProcess` | 订单 / 工单进度时间线 |
+
+每个过程都遵循标准的 `(IN sLoginId, IN sBrId, IN sSuId, ...) → result-set` 形状,因此通用分发器可以直接调用。[多租户作用域](../../concepts/multi-tenancy.md)也会自然传入:每张图表都会自动按租户过滤。
+
+## dev DB 中的预置看板模块
+
+`/indexPage/commonChar` 是共享路由。dev DB 中有 6 个模块映射到它:
+
+| Module sId | 中文名称 |
+|---|---|
+| `19211681019715464089035510` | 销售图表分析 |
+| `19211681019715481435115760` | 财务图表分析 |
+| `19211681019715481435298200` | 生产图表分析 |
+| `19211681019715708435449190` | 销售大数据分析 |
+| `19211681019715708471874620` | 采购大数据分析 |
+| `101251240115015889205266000` | 采购价格分析查询 |
+
+六个模块全部由 `gdsconfigcharmaster` 行驱动;新增或修改它们不需要改 Java 代码。
+
+## KPI 子系统
+
+> **先消歧。** FROUNT 首页也有一个标题为“**KPI监控**”的卡片,但它**不是**本页记录的 KPI。首页卡片是 `BusinessModelCenterController.getModelCenter` 提供的未清任务计数器,读取 `gdsmodule.bUnTask` / `sUnType`,没有目标值、评分和图表,只是名字容易误导。见运行时页的 [KPI 工作中心](runtime.md#kpi-工作中心front-端首页-dashboard)。下面的 `kpi*` 表族才是真正的员工绩效评分层。
+
+`kpi*` 是独立于图表渲染的**按员工绩效评分**层。形状如下:
+
+| 表 | 作用 | 实时行数 |
+|---|---|---:|
+| `kpimaster` | 按员工、按期间的 KPI 汇总。它是容量最大的表:每个员工的每次评分事件一行。 | 124,524 |
+| `kpidetail` | 支撑每条 `kpimaster` 汇总的明细行。 | 1,308 |
+| `kpimodule` | KPI 定义:哪些模块 / 指标参与评分。 | 44 |
+| `kpimoduleuser` | 每用户 KPI 分配。 | 0(dev DB 未分配) |
+| `kpimoduleuserday` | 每用户每日 KPI 桶。 | 1 |
+| `kpislavel` | KPI 等级 / 档位定义。 | 0 |
+
+Java 侧:
+
+- `KpiServiceImpl.java`(833 行,位于 `xlyBusinessService/.../KPIService/`):KPI 事件的读写 API。
+- `BusinessModelKpiServiceImpl.java`(901 行):把业务事件数据计算成 KPI 行的计算层。
+- `FlushModleKpiThread.java`:后台重算线程。
+- `KpimasterCloum.java` enum(xlyPersist):列名常量。
+
+存储过程:
+
+- `Sp_KPI_DetailByEmployee`:按员工的明细报表。
+- `Sp_KPI_SumByEmployee`:按员工的汇总报表。
+- `spKPImodule`:按模块重算 KPI。
+
+`script/客户/` 下也有较大的客户覆盖,例如 `script/标版/30100101/spKPImodule.sql` 以及多个 `Sp_SalesOrder_Kpi*` 过程。这与[每客户 SQL 覆盖通道](../../slices/05-customer-sql-override.md)一致:需要不同 KPI 规则的客户会交付自己的过程。
+
+## 自研方案的代价
+
+元数据 + 每图表一个过程的设计与 xly 的数据驱动论点一致,也避免了携带重型 OLAP 引擎。但代价很明确:
+
+1. **每张新图表都需要 SQL 作者。** “PM 添加一行元数据”只在工程师已经写好配套 `Sp_chart_*` 过程之后才成立。这里没有聚合构建器、字段选择器或自动生成查询;每个指标都是工程团队手写、评审和维护的存储过程。20 个过程和 11 种图表类型就是当前系统能渲染的全部形状。
+2. **图表在 OLTP DB 上跑重 SQL。** 没有数仓、没有预聚合、没有增量汇总。“今日利润”图表就是在实时交易 schema 上做 SELECT。大客户加载图表时会和录单负载竞争同一个 MySQL 实例。缓存有帮助,但只对命中有效;元数据变更后的第一次加载仍要付完整成本。
+3. **图表之间没有语义一致性保证。** 每个 `Sp_chart_*` 过程自行决定如何计算“月利润”“今日销售额”等指标。两个看似展示同一指标的图表可能因为过程体不同而悄悄不一致。真正的语义层可以避免这个问题,自研模型不能。
+4. **不能钻取,也不能自由切片分析。** 每张图表都是固定查询形状。用户不能自由切换维度,也不能从汇总卡片钻取到底层交易,除非工程师为每条路径再写一个过程。
+5. **KPI 逻辑会按客户分歧。** `script/客户/` 下的客户会交付自己的 `spKPImodule` 和 `Sp_SalesOrder_Kpi*` 覆盖;不同客户的 KPI 算法不同,而且代码只存在于该客户 DB 中。这会让“这个 KPI 到底是什么意思”取决于当前连接的是哪个 schema。
+
+这种简单设计足够支撑“展示 xly 一直展示的那 20 张卡片”。如果目标是即席分析或自助报表,它就不够了;那需要一套 xly 当前没有的独立语义层 / 数仓层。
+
+## 它不是什么
+
+- **不是自助 BI 工具。** 客户不能随便指向一张表并拖拽生成图表;新图表需要一个 SQL 存储过程,以及懂得注册元数据行的管理员。
+- **不是实时分析基础设施。** 图表在缓存 miss 时会直接在 OLTP MySQL schema 上运行过程。没有独立数仓、没有增量聚合管道、没有流式层。大客户的大图表会在实时 DB 上执行重 SQL。
+- **不是列存 / OLAP 引擎支撑的分析。** `xlyPersist/build.gradle` 中的 `olap4j` jar 没有任何 Java import,只是 classpath 上的历史包袱。xly 通过 MyBatis 和通用存储过程分发使用 MySQL 的普通行存。
diff --git a/zh/docs/reference/maintainer/cache-invalidation.md b/zh/docs/reference/maintainer/cache-invalidation.md
index ab22d80..1af36dc 100644
--- a/zh/docs/reference/maintainer/cache-invalidation.md
+++ b/zh/docs/reference/maintainer/cache-invalidation.md
@@ -1,61 +1,126 @@
# 元数据变更后的缓存失效
-当 PM 在**后台**保存变更(给表单加列、更新权限、注册新模块)时,每个运行节点都必须丢弃对旧元数据的缓存解释。xly 通过 JMS 完成,而不是轮询。
+当 PM 在 BACK 保存变更(给表单加列、更新权限、注册新模块)时,框架必须丢弃对旧元数据的缓存解释。**缓存清理由 BACK 进程内的 Spring `@CacheEvict` 同步完成**,不是 JMS 扇出。代码里另有一条名字相近的 JMS 路径,但用途不同(基础数据合并);两者很容易混淆,本页专门拆开说明。
-## 路径
+## 一个触发点,两条路径:差异在哪里
+
+```mermaid
+flowchart TB
+ classDef ok fill:#e6f4ea,stroke:#34a853
+ classDef notcache fill:#fce8e6,stroke:#ea4335
+
+ PM[PM 在 BACK 点击保存]:::ok
+ SAVE["BusinessBaseServiceImpl
add/update/deleteBusinessData"]
+ EVICT["BusinessCleanRedisData.delCleanRedisData
→ CleanRedisServiceImpl
gdsmodule 相关 18 个 cache region"]:::ok
+ REDIS[("Redis
(跨节点共享)")]:::ok
+ DB[("MySQL
写入行")]:::ok
+
+ PM --> SAVE
+ SAVE --> DB
+ SAVE -- "同步执行,
同一事务路径" --> EVICT
+ EVICT --> REDIS
+ REDIS -. "任意节点下次读取
都能看到新值" .-> ANY[其他节点]:::ok
+
+ SAVE -. "发布 'gds module changed'" .-> AMQ([ActiveMQ])
+ AMQ --> CGM["ConsumerChangeGdsModuleThread
(xlyErpJmsConsumer)"]:::notcache
+ CGM -- "调用
PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL
基础数据合并
不是缓存")]:::notcache
+
+ classDef title font-weight:bold
+```
+
+**绿色路径**才是每次“修改元数据后刷新页面”实际依赖的路径。**红色路径**因为队列名(`CHANGE_GDS_MODULE`)和 consumer thread 名字,很容易被误认为缓存失效;但它不是。它通过存储过程做每租户 → base 数据合并。**两条路径互不依赖。**
+
+## 真实缓存失效路径(同步、进程内)
```text
-PM 在**后台**保存
- ↓
-**后台**控制器写入变更的 gds_* 行
- ↓
-Controller 发布 JMS “module changed” 消息
- ↓
-每个节点的 xlyErpJmsConsumer 收到消息
- ↓
-ConsumerChangeGdsModuleThread.run() 清除相关 Redis key
- ↓
-任意节点下一次 /business/getModelBysId 调用重新读表,
-并用新值重新填充缓存
+PM 在 BACK 保存
+ │
+ ▼
+BACK controller(如 /business/addUpdateDelBusinessData)调用
+BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData
+ │
+ ▼
+保存 service 调用 businessCleanRedisData.delCleanRedisData(...)
+(如 BusinessBaseServiceImpl.java:1122、1224、1375、1441、1597、1677)
+ │
+ ▼
+BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, ...)
+按表名分发到 CleanRedisServiceImpl 上的某个清理方法
+ │
+ ▼
+CleanRedisServiceImpl.cleanRedisByTableNameGdsModle()(或类似方法)
+对固定的一组 cache region 触发 @CacheEvict
+ │
+ ▼
+Spring CacheManager 清理命名条目
+ │
+ ▼
+下一次 /business/getModelBysId 调用重新从 DB 读取并回填缓存
```
-处理器位于 `xlyErpJmsConsumer/src/main/java/com/xly/xlyerpjmsconsumer/thread/ConsumerChangeGdsModuleThread.java`。
+清理方法位于 `xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java`。一个代表性方法(`gdsmodule` 行变更时调用)会一次性清理 18 个 cache region:
+
+```java
+@CacheEvict(value = {
+ "getGdsmoduleTree", "getGdsmoduleList", "getModuleTreePro",
+ "getSysjurisdictionTreePro", "getsDisplayTypeAll",
+ "businessBaseServiceGetMenuList", "getBuMenu", "getMenu",
+ "getsAuthsId", "businessCommonServicegetModulelistAll",
+ "gdsmoduleById", "getSaveProName", "businessParameterGetParameter",
+ "getPrcName", "getKpiModelByUser", "getUserByFromId",
+ "getUserByActionId", "getModuleTreeProAll"
+}, allEntries = true)
+public void cleanRedisByTableNameGdsModle() { … }
+```
+
+同一个类上还有其他按表命名的 cleaner,分别清理与 `gdsconfigformmaster`、`gdsconfigformslave`、`gdsconfigtbmaster`、`gdsformconst`、`gdsjurisdiction`、`gdsconfigcharmaster`、登录信息、billnosetting、kpimaster、`SysSystemSettings` 等相关的缓存区域。
+
+## JMS 的 `CHANGE_GDS_MODULE` 实际做什么(不是清缓存)
+
+框架中确实有 `P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` 队列和 `ConsumerChangeGdsModuleThread` consumer thread,名字看上去像是做缓存失效,但事实不是。
-## 为什么是 JMS,不是轮询后清除
+`ConsumerChangeGdsModuleThread.run()` 解析 `changeGdsModuleService` bean(`ChangeGdsModuleServiceImpl`),调用 `changeTableData(sGdsModuleId, sJobId)`,进而调用存储过程 `PRO_ERPMERGEBASEGDSMODULE`(通过 `proDao.proErpMergeBaseGdsModule`,映射在 `ProMapper.xml`)。该存储过程把每租户 `gdsmodule` 行合并进扁平“base”查询表。这是基础数据合并作业,不是缓存清理。对 `xlyErpJmsConsumer/` grep `@CacheEvict` 或 `cleanRedis*` 结果为 0;consumer 侧不会清 Redis。
-xly 经常跨多个节点运行(xlyEntry、xlyApi、xlyInterface 各自 JVM,有时水平扩展)。轮询“元数据是否变了”要么很慢(直到下一次轮询才可见),要么很吵(持续心跳)。JMS 可以在毫秒级把失效广播到每个节点。
+[`P2pQueue.java`](../../api-reference/messaging.md) 中另外 23 个 `ERP_JMS_ACTIVEMQ_*` 队列也一样:每个队列驱动一个领域特定的基础数据合并或扇出工作项,而不是缓存失效。
-代码库同时使用 **ActiveMQ** 和 **RocketMQ**,但这里记录的元数据变更路径是 **ActiveMQ / JMS**:`xlyErpJmsConsumer` 用 `@JmsListener` 监听 `P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE`,`ConsumerChangeGdsModuleThread` 负责清缓存。`RocketMQServiceImpl` 用于其他集成流。
+## 跨节点缓存一致性:Redis 支撑,已确认
-## 会清哪些 key
+`EntryApplicationBoot.java:22` 和 `ApiApplicationBoot.java:24` 上有 `@EnableCaching`。范围内源码中**没有自定义 `CacheManager` bean**(没有 `RedisCacheManager`、没有返回 `CacheManager` 的 `@Bean` 方法、没有 `implements CacheManager`、任何 `application*.yml` 中也没有 `spring.cache.*` 属性)。在存在 `spring-boot-starter-data-redis` 且 classpath 中没有其他缓存提供方(无 Caffeine、EhCache、Hazelcast、JCache;`xlyFlow` 中的 `shiro-ehcache` jar 是 Shiro 自己的 session cache,不是 Spring Cache)的情况下,Spring Boot 2.2.5 会自动配置 **`RedisCacheManager`**。
-Redis 缓存包含:
+已在实时 dev Redis `118.178.19.35:16379`(database 0)验证:267 个 key 中有 233 个使用 Spring Cache 默认的 `::` 分隔符。与 `BusinessGdsconfigformsServiceImpl.java:189-190` 中 `getFormconstData` 的 `@Cacheable` 注解(默认 key 来自所有参数)匹配的 key 形状示例:
-- 按 `sId` 的模块元数据。
-- 按 `sId` 的表单元数据。
-- 按表单 `sId` 的字段从表列表。
-- 每租户覆盖合并结果(派生缓存)。
-- 每(模块、角色)的权限规则。
+```text
+businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111}
+gdsmoduleById::gdsmoduleById___
+```
-consumer thread 收到变更行 ID 后,会清掉每个可能包含它的缓存 key 家族。**这里过度失效是安全选择**:下一次请求多读一次 DB 的成本,远小于继续服务陈旧元数据的成本。
+因此,任一节点上的 `@CacheEvict` 会清理共享 Redis 存储,其他节点下一次读取时也会看到失效。跨节点一致性来自 Redis,不来自 JMS。
## 直接用 SQL 修改元数据时
-通过 MyBatis 或通过**后台**做的 insert / update 会*触发* JMS 事件。工程师直接在生产 DB 执行 `UPDATE gdsmodule SET ...` 不会触发。缓存会持续提供旧元数据,直到:
+通过 MyBatis 或 BACK 完成的 insert / update 会触发 `businessCleanRedisData.delCleanRedisData*`。直接在 DB 执行 `UPDATE gdsmodule SET …` 不会触发任何 cleaner。缓存会继续提供旧元数据,直到:
1. 缓存 TTL 过期(实际 TTL 看缓存配置)。
-2. 重启应用服务器。
-3. 手工发送 JMS 消息(见 `xlyBusinessService` 中的 `BusinessCleanRedisDataImpl`)。
+2. 重启应用服务器(由于缓存由 Redis 支撑且共享,重启一次即可,见上文)。
+3. 从应用内部调用某个 `BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, …)` 方法;任意节点调用一次即可,因为它清的是共享 Redis 存储。
+
+## 这个设计的代价
+
+保存时同步 `@CacheEvict` 的模型运维上简单;在 Redis 支撑下,它也确实具备跨节点一致性。但它仍有几个脆弱点需要明确:
-第三种是受支持的 workaround;第二种是粗暴 fallback。
+- **两套名字很像的系统容易混淆。** JMS 路径 `CHANGE_GDS_MODULE` + `ConsumerChangeGdsModuleThread` 听起来像缓存失效,实际不是。这页存在的一部分原因,就是这种混淆反复造成 bug 和阅读误判。如果能把队列和过程重命名为类似 `MERGE_BASE_GDS_MODULE`,会更清楚,但改名成本不低。
+- **驱逐和写入在同一条事务路径上。** 如果保存期间 Redis 调用失败,数据库行可能已经提交,但缓存仍旧是旧值。框架不会检测或自动恢复这种情况;保存时 Redis 短暂故障会让受影响行在 TTL 到期前一直读到旧缓存。
+- **驱逐粒度是按 cache region 全量清。** `CleanRedisServiceImpl` 上大多数 `@CacheEvict` 都使用 `allEntries=true`,清掉整个 cache region,而不是只清受影响 key。元数据保存吞吐较高时,后续会出现一波 cache miss;小元数据缓存可以接受,但如果 region 有几千项就会变贵。
+- **没有驱逐预算或批处理。** 批量元数据变更(例如一次改 100 个字段)会触发 100 次 `@CacheEvict`,每次都往返 Redis。没有把多次驱逐合并成一批的机制。
+- **直接 DB 写入会绕过全部机制。** 任何绕开 `BusinessBaseServiceImpl` 的工具都会留下陈旧缓存,包括 DBA 脚本、通过 `mysql` 命令应用的 `script/客户/` 覆盖、以及通道 2 的 SQL 替换。对 xly 实际采用的部署模式来说,这是实打实的运维风险。
## 常见 bug:问题其实是缓存
当现象像是“我改了但页面还是旧值”时,按顺序检查:
1. 变更是否实际提交?(对 DB 执行 `SELECT` 确认。)
-2. **后台**节点能否访问 JMS broker?(不能访问时,失效事件不会发布。)
-3. 所有 consumer 节点是否在运行?(暂停的节点会继续服务旧元数据直到重启。)
-4. 变更是否通过原始 SQL 完成?(那就没有 JMS 事件,需要手工触发。)
+2. 变更是否经过会调用 `BusinessCleanRedisData` 的路径?(直接 DB 写入或绕过 `BusinessBaseServiceImpl` 的 controller 不会。)
+3. 保存提交时 Redis 是否可达?驱逐失败不会回滚保存。
+4. 这个变更所在表是否映射到了会被清理的 cache region?`CleanRedisServiceImpl` 按写入表映射到具体 region;未映射的表不会让对应读取缓存失效。
-[切片 1](../../slices/01-hello-world.md) 的“五表读取”会从缓存重新运行;理解哪一层陈旧是定位 bug 的关键。
+[`getModelBysId` 在切片 1](../../slices/01-hello-world.md) 中返回的五键复合体会从缓存重跑;定位 bug 的关键是理解哪一层陈旧。
diff --git a/zh/docs/reference/maintainer/deployment.md b/zh/docs/reference/maintainer/deployment.md
index 54d865d..dcfb616 100644
--- a/zh/docs/reference/maintainer/deployment.md
+++ b/zh/docs/reference/maintainer/deployment.md
@@ -1,65 +1,162 @@
# 多服务部署
-xly 不是单个 Spring Boot WAR。仓库包含多个可部署模块,以及一些也被 `xlyEntry` 作为依赖使用的类库型 WAR 模块。
+xly 不是单个 Spring Boot WAR。仓库包含多个可部署模块,以及一些也被 `xlyEntry` 作为依赖使用的类库型模块。
+
+## 拓扑总览
+
+```mermaid
+flowchart LR
+ classDef library fill:#f5f5f5,stroke:#999,stroke-dasharray:3 3
+ classDef boot fill:#e8f0fe,stroke:#4285f4
+
+ subgraph clients [面向操作人员]
+ BACK["BACK SPA
http://<host>:8597"]
+ FROUNT["FROUNT SPA
http://<host>:8598"]
+ end
+ EXT([外部集成方])
+ HOOKS([第三方 webhook])
+
+ subgraph boots [可部署 Spring Boot 应用]
+ direction TB
+ XENTRY["xlyEntry
:8080 /xlyEntry
EntryApplicationBoot"]:::boot
+ XAPI["xlyApi
:8090 (local) / :8080
/xlyApi · ApiApplicationBoot"]:::boot
+ XIF["xlyInterface
:8080 /xlyInterface
InterfaceApplicationBoot"]:::boot
+ XPLC["xlyPlc
:8000 (dev) / :8080
/xlyEntry · PlcApplicationBoot"]:::boot
+ XFACE["xlyFace
:8091 (local) / :8080
/xlyFace(文档范围外)"]:::boot
+ XEJMSC["xlyErpJmsConsumer
(无端口;继承配置)
JmsConsumerApplicationBoot"]:::boot
+ end
+
+ subgraph libs [类库模块:不独立运行]
+ direction TB
+ XMANAGE[xlyManage]:::library
+ XBSERVICE[xlyBusinessService]:::library
+ XPERSIST[xlyPersist]:::library
+ XENTITY[xlyEntity]:::library
+ XFLOW[xlyFlow]:::library
+ XMSG[xlyMsg]:::library
+ XEJMSP[xlyErpJmsProductor]:::library
+ XPLATC[xlyPlatConstant]:::library
+ end
+
+ subgraph infra [共享基础设施]
+ DB[("MySQL
xlyweberp_*")]
+ REDIS[(Redis :16379
共享缓存 + session)]
+ AMQ([ActiveMQ :61616])
+ MONGO[("MongoDB
已接线但未使用")]
+ end
+
+ BACK -->|nginx| XENTRY
+ FROUNT -->|nginx| XENTRY
+ FROUNT -->|nginx| XAPI
+ EXT --> XAPI
+ HOOKS --> XIF
+
+ XENTRY --- XMANAGE
+ XENTRY --- XBSERVICE
+ XENTRY --- XFLOW
+ XBSERVICE --- XPERSIST
+ XAPI --- XPERSIST
+ XIF --- XPERSIST
+ XPERSIST --- XPLATC
+ XPERSIST --- XENTITY
+ XBSERVICE --- XMSG
+ XIF --- XMSG
+ XBSERVICE --- XEJMSP
+
+ XENTRY --> DB
+ XAPI --> DB
+ XIF --> DB
+ XPLC --> DB
+ XFLOW --> DB
+
+ XENTRY --> REDIS
+ XAPI --> REDIS
+ XEJMSC --> AMQ
+ XEJMSP -. 发布 .-> AMQ
+ XENTRY -. 发布 .-> AMQ
+```
+
+反向代理把面向操作人员的端口(8597 / 8598)映射到内部 Spring Boot 应用(大多是 `:8080`,再通过不同 context path 区分)。类库模块不会独立运行,而是作为依赖打进可部署 WAR。`xlyFlow` 和 `xlyPlc` 都共享 xlyEntry 的 context-path `/xlyEntry`,所以真实部署需要按 host 或 upstream 路由,而不能只靠 path 区分。
## 主要模块
-| 服务 / 模块 | 角色 | 代码事实 |
-|---|---|---|
-| **xlyEntry** | 主运行时与 builder/admin surface。承载 `/business/*`、`/gdsmodule/*`、`/gdsconfigform/*`、`/gdsconfigtb/*`、报表、登录和其他框架 controller。 | 依赖 `xlyManage`、`xlyBusinessService`、`xlyFlow`。 |
-| **xlyApi** | 面向 `/api/*`、`/online/*`、`/pro/*`、`/thirdparty/*` 等端点的 API 模块。 | 独立 Spring Boot 应用类 `ApiApplicationBoot`。 |
-| **xlyInterface** | 外部集成模块,带 Swagger 依赖和第三方集成代码。 | 独立 Spring Boot 应用类 `InterfaceApplicationBoot`。 |
-| **xlyPlc** | 车间 PLC 桥接([切片 6](../../slices/06-hardware.md))。 | 独立 Spring Boot 应用类 `PlcApplicationBoot`。 |
-| **xlyFace** | 可选人脸识别模块。 | 独立模块,仍存在于 Gradle build 中。 |
-| **xlyFlow** | 工作流 / Activiti 代码和 controller。 | 作为模块存在,但当前分支中 `XlyFlowApplicationBoot` 被注释,因此应视为通过 `xlyEntry` 消费的代码,而不是明确可独立运行的 app。 |
+### 可部署的 Spring Boot 应用
+
+| 服务 / 模块 | 角色 | 默认 profile / 端口 | Boot 类 |
+|---|---|---|---|
+| **xlyEntry** | 主运行时与配置 / 管理接口面。承载 `/business/*`、`/gdsmodule/*`、`/gdsconfigform/*`、`/gdsconfigtb/*`、报表、登录和其他框架 controller。 | `dev` → 8080,context `/xlyEntry` | `EntryApplicationBoot` |
+| **xlyApi** | 面向 `/api/*`、`/online/*`、`/pro/*`、`/thirdparty/*` 等端点的 API 模块。 | repo 默认 `local` → 8090,dev/win/linux → 8080,context `/xlyApi` | `ApiApplicationBoot` |
+| **xlyInterface** | 外部集成模块,带 Swagger 依赖和第三方集成代码。 | `dev` → 8080,context `/xlyInterface` | `InterfaceApplicationBoot` |
+| **xlyPlc** | 车间 PLC 桥接([切片 6](../../slices/06-hardware.md))。 | `dev` → 8000;命名 profile(15S、S10、T0、T1、CT、yt、pro)→ 8080,context `/xlyEntry`(与 xlyEntry 共享 context-path) | `PlcApplicationBoot` |
+| **xlyFace** | 人脸识别模块。在 build 中(`settings.gradle` 仍按用户设置启用),但**不属于本 Wiki 文档范围**。 | repo 默认 `win` → 8080,local → 8091,context `/xlyFace` | `XlyFaceApplicationBoot` |
+| **xlyErpJmsConsumer** | JMS consumer 后台服务。有 Boot main,但没有自己的 `application*.yml`;运行配置从同类服务继承。 | n/a(继承) | `JmsConsumerApplicationBoot` |
+
+### 类库模块(在 `settings.gradle` 中启用,但不独立运行)
+
+| 模块 | 角色 |
+|---|---|
+| **xlyManage** | 后台元数据管理服务(`Gds*ServiceImpl` 家族),被 xlyEntry 拉入。 |
+| **xlyBusinessService** | 业务逻辑 service 层(`BusinessBaseServiceImpl` 和约 100 个兄弟 `*ServiceImpl` 类),被 xlyEntry 拉入。 |
+| **xlyFlow** | 工作流 / Activiti 代码。当前分支 `XlyFlowApplicationBoot.java` 已完全注释掉;作为类库被 xlyEntry 使用。也共享 context-path `/xlyEntry`。 |
+| **xlyEntity** | 共享实体 / DTO 类(约 83 个 Java 文件,包括 22 个 Mongo `@Document` 类)。 |
+| **xlyPersist** | 持久化 helper(DAO、MyBatis mapper XML、`RequestAddParamUtil` 等)。 |
+| **xlyMsg** | 通知 helper(钉钉、微信、邮件);没有 Boot main。 |
+| **xlyErpJmsProductor** | JMS producer 代码(队列声明在 `P2pQueue.java`);没有 Boot main。 |
+| **xlyPlatConstant** | 共享工具常量(`MultiThreadServer`、`TimeContant`),被 `xlyPersist` 消费。唯一仍启用的 Plat* 模块。 |
-每个模块有自己的 `application.yml` 和多个 `application-.yml`。启动时通过 `-Dspring.profiles.active=...` 选择 profile。
+每个可运行模块有自己的 `application.yml` 和多个 `application-.yml`。启动时通过 `-Dspring.profiles.active=...` 选择 profile。
## `settings.gradle` 中禁用的模块
-```text
-//include 'xlyErpTask'
-//include 'xlyRxtx'
-//include 'xlyFile'
-```
+cleanup 分支注释掉了 17 条 `include`。其中三个是磁盘上存在的非 Plat 模块:
-三者在磁盘上存在,但被排除在当前 Gradle build 之外:
+- `xlyErpTask`:长运行后台任务。
+- `xlyRxtx`:原生串口库。xlyPlc 需要直接串口访问时可能重新启用;部分机型使用 TCP / Ethernet。
+- `xlyFile`:旧文件管理模块,已被 `xlyPlatFileUpload`(也被注释)取代。
-- `xlyErpTask`:长运行**后台**任务。
-- `xlyRxtx`:原生串口库。xlyPlc 不需要直接串口访问时会禁用;部分机型使用 TCP / Ethernet。
-- `xlyFile`:旧文件管理模块,已被 `xlyPlatFileUpload` 中的阿里云 OSS 集成取代。
+其余 14 条被注释的 include 是 `xlyTestService`、`xlyTestController`,以及除 `xlyPlatConstant` 外的完整 `xlyPlat*` 家族:`xlyPlatTask`、`xlyPlatJmsProductor`、`xlyPlatJmsConsumer`、`xlyPlatReportForm`、`xlyPlatFileUpload`、`xlyPlatMarketingService`、`xlyPlatUserService`、`xlyPlatSmsService`、`xlyPlatMerchantController`、`xlyPlatWebsocket`、`xlyPlatPayService`、`xlyPlatCainiaoWaybillSevice`。(`xlyTestService` / `xlyTestController` 目录不在磁盘上;只有 `xlyEntry/.../businessweb/` 里存在一个空壳 `TestController.java`。)
-维护人员清理代码库时应判断删除还是保留为历史参考。它们占用磁盘空间,但不影响 build。
+维护人员清理代码库时应判断是否删除磁盘上但已排除的 `xlyErpTask` / `xlyRxtx` / `xlyFile` 目录,或保留为历史参考。它们占用磁盘空间,但不影响 build。
-## Plat* 表族
+## Plat* 家族
-`xlyPlat*` 模块(`xlyPlatMerchantController`、`xlyPlatUserService`、`xlyPlatPayService`、`xlyPlatMarketingService`、`xlyPlatCainiaoWaybillSevice`、`xlyPlatSmsService`、`xlyPlatReportForm`、`xlyPlatFileUpload`、`xlyPlatJmsConsumer` / `Productor`、`xlyPlatTask`、`xlyPlatWebsocket`、`xlyPlatConstant`)属于 **B2B 印刷平台层**,不在本 Wiki 范围内。
+`xlyPlat*` 模块(`xlyPlatMerchantController`、`xlyPlatUserService`、`xlyPlatPayService`、`xlyPlatMarketingService`、`xlyPlatCainiaoWaybillSevice`、`xlyPlatSmsService`、`xlyPlatReportForm`、`xlyPlatFileUpload`、`xlyPlatJmsConsumer` / `Productor`、`xlyPlatTask`、`xlyPlatWebsocket`)属于 **B2B 印刷平台层**,不在本 Wiki 范围内。唯一例外是 `xlyPlatConstant`,它仍在 `settings.gradle` 中启用,并被 `xlyPersist` 当作共享常量工具使用(`MultiThreadServer`、`TimeContant`)。
## 服务如何互相发现
三种通信通道:
1. **共享数据库**:每个服务读写同一个 MySQL schema。大多数跨服务“通信”是通过共享表隐式发生。
-2. **消息**:代码库中同时存在 ActiveMQ / JMS 和 RocketMQ。缓存失效([元数据变更后的缓存失效](cache-invalidation.md))使用 ActiveMQ / JMS 路径。
+2. **消息**:代码库中同时存在 ActiveMQ / JMS 和 RocketMQ。ActiveMQ / JMS 路径用于基础数据合并和异步扇出;缓存失效不走 JMS,见[元数据变更后的缓存失效](cache-invalidation.md)。
3. **HTTP REST**:同步调用,例如 xlyApi 调用 xlyEntry 的 `/business/*` 端点。
除 `application-.yml` 中按环境硬编码的 peer URL 外,没有服务注册 / 发现机制。
## 部署环境中的 URL 路由
-工作区 `.env.local` 在实时环境中把 **后台** 指向端口 `8597`,**前台** 指向端口 `8598`。这足以识别两个面向操作人员的 surface;反向代理和 context-path 映射属于部署细节,不是本仓库中有代码依据的事实。
+工作区 `.env.local` 在实时环境中把 **后台** 指向端口 `8597`,**前台** 指向端口 `8598`。这足以识别两个面向操作人员的接口面;反向代理和 context-path 映射属于部署细节,不是本仓库中有代码依据的事实。
## Profile 组合
-`application-saas.yml`、`application-linux.yml`、`application-win.yml`、`application-15S.yml`、`application-S10.yml`、`application-pro.yml`、`application-T0.yml`、`application-T1.yml` 等覆盖以下组合:
+Profile 按服务拆分:
+
+- **xlyEntry**:`dev`、`local`、`win`、`linux`、`15s`、`s10`、`saas`、`bgj`(小写)。`dev` 是 repo 内默认。
+- **xlyApi**:`local`(repo 默认)、`dev`、`linux`、`win`。
+- **xlyInterface**:仅 `dev`。
+- **xlyFlow**:`dev`(空文件)。
+- **xlyFace**:`win`(默认)、`dev`、`linux`、`local`。
+- **xlyPlc**:`dev`(默认)加 7 个机型 profile(`15S`、`S10`、`T0`、`T1`、`CT`、`yt`、`pro`,大小写混合;不同于 xlyEntry 的小写 `15s` / `s10`)。
-- 操作系统(linux / win)。
-- 客户类别(saas、15S、S10 等)。
-- 环境(dev、pro)。
-- 印刷机机型(xlyPlc)。
+机型 profile(`T0`、`T1`、`CT`、`yt`、`pro`、`15S`、`S10`)是 **xlyPlc 专用**,其他服务没有这些 profile。跨服务 profile 覆盖以下组合:
+
+- 操作系统(`linux` / `win`)。
+- 环境(`dev`、`local`、`saas`、`bgj`)。
+- 客户 / 版本(xlyEntry 的 `15s`、`s10`)。
每个部署为每个服务选择一个 profile。“某客户 → 哪些 profile”的映射位于运维部署文档,不在代码库中。
## 未决:生产 URL 路由
+> **暂缓(超出仓库可验证范围)。** 将公网 `:8597` / `:8598` 映射到内部 Spring Boot context-path 的 nginx / 反向代理配置位于部署运维基础设施中,不在本代码库里。Wiki 无法仅靠 src / DB / web 验证它;本节只是占位,等待部署侧配置被链接或纳入仓库。
+
`8597` / `8598` 的精确 nginx / 反向代理配置不在仓库中。只有拿到部署侧配置后再补入本页。
diff --git a/zh/docs/reference/maintainer/index.md b/zh/docs/reference/maintainer/index.md
index e178b23..83e17f0 100644
--- a/zh/docs/reference/maintainer/index.md
+++ b/zh/docs/reference/maintainer/index.md
@@ -5,11 +5,13 @@
本章服务于会修改 `BusinessBaseController`、向 `gds*` 表族添加元数据表,或接入新第三方集成的人。
- [本地运行 xlyEntry](running-locally.md) — 开发者首次启动配置(前置条件、profile、DB 覆盖、冒烟检查)。
+- [技术栈](tech-stack.md) — 按类别整理的库清单(版本、声明位置、使用位置和原因)。
- [运行时:BusinessBaseController 及相关组件](runtime.md) — 元数据驱动分发循环。
- [通用存储过程分发](proc-dispatch.md) — `GenericProcedureCallController` 深入说明。
-- [元数据变更后的缓存失效](cache-invalidation.md) — `ConsumerChangeGdsModuleThread` 及相关组件。
+- [元数据变更后的缓存失效](cache-invalidation.md) — 同步 `@CacheEvict` 路径,以及名字相近但用途不同的 JMS 基础数据合并路径。
- [SQL 模板(`xlyEntry/templesql/`)](sql-templates.md) — 运行时 SQL 生成相关脚手架。
- [多服务部署](deployment.md) — `xlyApi` vs `xlyEntry` vs `xlyInterface`。
-- [Activiti 集成](activiti.md) — 版本偏差、schema、自定义 delegate。
+- [元数据管理服务(xlyManage)](management-services.md) — BACK 配置侧背后的 `Gds*ServiceImpl` 写入路径。
+- [Activiti 集成](activiti.md) — 版本偏差、schema、自定义委托类。
> **占位。** 每个子页会在相应切片实际覆盖后补充真实代码追踪。
diff --git a/zh/docs/reference/maintainer/management-services.md b/zh/docs/reference/maintainer/management-services.md
new file mode 100644
index 0000000..0dbd6a9
--- /dev/null
+++ b/zh/docs/reference/maintainer/management-services.md
@@ -0,0 +1,64 @@
+# 元数据管理服务(`xlyManage`)
+
+`xlyManage` 模块是 **BACK 配置器背后的 service 层**。PM 在系统模块配置、界面显示内容配置、数据表内容配置、系统权限配置、系统常量配置、用户信息配置、Mysql脚本配置等页面点击修改 / 新增 / 删除时,请求会先落到 `xlyEntry/.../web/systemweb/` 下的某个 `Gds*Controller`,再委托给 `xlyManage/src/main/java/com/xly/service/systeminfo/impl/` 下的 `Gds*ServiceImpl`。
+
+这些 service 是框架的**元数据 CRUD 主干**:它们拥有每张 `gds*` 表的读写逻辑。运行时([切片 1](../../slices/01-hello-world.md)、[runtime.md](runtime.md))负责**读取**元数据;`xlyManage` 负责**写入**元数据。
+
+## 服务总览
+
+| Service | 行数 | 拥有内容 | BACK 页面 |
+|---|---:|---|---|
+| `GdsmoduleServiceImpl` | 729 | `gdsmodule`(模块)、`gdsroute`(URL 白名单)、模块树 CRUD | 系统模块配置 |
+| `GdsconfigformServiceImpl` | 878 | `gdsconfigformmaster`、`gdsconfigformslave`、`gdsconfigformcustomslave`、`gdsconfigformpersonalize`(表单定义 + 每租户覆盖) | 界面显示内容配置 |
+| `GdsconfigtbServiceImpl` | 555 | `gdsconfigtbmaster`、`gdsconfigtbslave`(虚拟表定义) | 数据表内容配置 |
+| `SqlScriptsServiceImpl` | 489 | BACK 中编写的 DDL / proc / view 脚本;使用 [`templesql/`](sql-templates.md) 模板 | Mysql脚本配置 |
+| `GdsjurisdictionServiceImpl` | 362 | `gdsjurisdiction`(每模块动作**目录**;见[权限](../builder/permissions.md)) | 系统权限配置 + 配置流程的一部分 |
+| `GdsparameterServiceImpl` | 319 | `gdsparameter`(系统级参数) | 参数页面 |
+| `GdsformconstServiceImpl` | 243 | `gdsformconst`(每表单常量:标签、默认文本)。切片 1 锚点表。 | 系统常量配置 |
+| `GdslogininfoServiceImpl` | 221 | `sftlogininfo*` 表族(用户 / 登录 / 用户组目录) | 用户信息配置 |
+| `SysbrandsServiceImpl` | 125 | `sysbrands`(加工商 / 租户主数据) | 租户管理 |
+| `CommonServiceImpl` | 56 | 其他 service 共用 helper | n/a |
+
+合计约 4000 行元数据 CRUD 逻辑,这是框架运行时里相当大的部分,之前 Wiki 没有覆盖。
+
+## 方法形状约定
+
+每个 `Gds*Service` 基本都遵循同一个五方法形状:
+
+```java
+Feedback