@oj-module/cli 0.2.50 → 0.2.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/devkit/SKILL.md +2 -2
  2. package/devkit/manual.md +32 -25
  3. package/devkit/wecom-internal-app.md +111 -4
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
  6. package/shell-dist/assets/runtime.js +29 -5
  7. package/shell-dist/index.html +9 -12
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/templates-tenant-scope.test.ts +3 -2
  10. package/src/build.ts +19 -2
  11. package/src/config.ts +9 -0
  12. package/src/contract/__tests__/client-path.test.ts +30 -0
  13. package/src/contract/__tests__/emit-client.test.ts +18 -0
  14. package/src/contract/__tests__/handler-scan.test.ts +23 -0
  15. package/src/contract/check.ts +5 -5
  16. package/src/contract/client-path.ts +30 -0
  17. package/src/contract/emit-client.ts +18 -4
  18. package/src/contract/handler-scan.ts +40 -3
  19. package/src/contract/run.ts +5 -3
  20. package/src/init.ts +19 -1
  21. package/src/shared-deps.ts +27 -9
  22. package/src/site-base.ts +1 -1
  23. package/templates/README.md +40 -44
  24. package/templates/init.include.yaml +15 -14
  25. package/templates/openapi.json +3422 -1955
  26. package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
  27. package/templates/src/_platform/api/contacts/api.ts +29 -1
  28. package/templates/src/_platform/api/contacts/detail/api.ts +25 -1
  29. package/templates/src/_platform/api/solutions/api.ts +60 -1
  30. package/templates/src/_platform/web/__client/api.schemas.ts +99 -35
  31. package/templates/src/_platform/web/__client/api.ts +103 -59
  32. package/templates/src/app/web/entry.ts +77 -0
  33. package/templates/src/{home/web → app/web/home}/__client/api.ts +5 -2
  34. package/templates/src/app/web/home/routes.ts +41 -0
  35. package/templates/src/{login/web → app/web/login}/pages/login.tsx +54 -24
  36. package/templates/src/app/web/login/routes.ts +71 -0
  37. package/templates/src/{notification/web → app/web/notification}/__client/api.schemas.ts +14 -0
  38. package/templates/src/{notification/web → app/web/notification}/__client/api.ts +67 -20
  39. package/templates/src/app/web/notification/routes.ts +70 -0
  40. package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.ts +5 -2
  41. package/templates/src/app/web/personal-center/routes.ts +49 -0
  42. package/templates/src/{system/web → app/web/system}/__client/api.schemas.ts +28 -22
  43. package/templates/src/{system/web → app/web/system}/__client/api.ts +103 -58
  44. package/templates/src/{system/web → app/web/system}/locales/en-US.json +14 -0
  45. package/templates/src/{system/web → app/web/system}/locales/zh-CN.json +14 -0
  46. package/templates/src/{system/web → app/web/system}/pages/users/index.tsx +5 -2
  47. package/templates/src/{system/web → app/web/system}/pages/users/roles.tsx +14 -3
  48. package/templates/src/{system/web → app/web/system}/pages/wecom/index.tsx +139 -1
  49. package/templates/src/app/web/system/routes.ts +97 -0
  50. package/templates/src/auth/api/_shared/wecom/admin.ts +4 -0
  51. package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
  52. package/templates/src/auth/api/_shared/wecom/events.ts +3 -0
  53. package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
  54. package/templates/src/auth/api/api.ts +59 -0
  55. package/templates/src/auth/api/ldap-login/api.ts +37 -1
  56. package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
  57. package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
  58. package/templates/src/auth/api/wecom/api.ts +23 -0
  59. package/templates/src/auth/web/__client/api.schemas.ts +133 -13
  60. package/templates/src/auth/web/__client/api.ts +129 -31
  61. package/templates/src/notification/api/api.ts +17 -0
  62. package/templates/src/notification/api/del/api.ts +2 -0
  63. package/templates/src/system/api/roles/api.ts +3 -0
  64. package/templates/src/system/api/roles/bindings/api.ts +3 -0
  65. package/templates/src/system/api/settings/api.ts +6 -3
  66. package/templates/src/system/api/users/forbidden/api.ts +3 -0
  67. package/templates/src/system/api/users/password/api.ts +3 -0
  68. package/templates/src/system/api/users/roles/api.ts +3 -0
  69. package/templates/src/web/api/get-async-routes/api.ts +16 -1
  70. package/templates/src/web/api/hello/api.ts +13 -1
  71. package/templates/src/web/api/user-info/api.ts +21 -1
  72. package/templates/src/web/web/__client/api.schemas.ts +28 -0
  73. package/templates/src/web/web/__client/api.ts +33 -9
  74. package/templates/tsconfig.paths.json +0 -1
  75. package/templates/web.config.ts +22 -17
  76. package/vendor/host-versions.json +3 -3
  77. package/shell-dist/assets/echarts-charts.js +0 -58571
  78. package/shell-dist/assets/echarts-features.js +0 -24057
  79. package/shell-dist/assets/echarts-for-react.js +0 -704
  80. package/shell-dist/assets/echarts.js +0 -82916
  81. package/shell-dist/assets/editor.js +0 -161457
  82. package/shell-dist/assets/vditor-index-css.js +0 -7
  83. package/shell-dist/assets/vditor.js +0 -15223
  84. package/templates/src/demo/api/manifest.yaml +0 -5
  85. package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
  86. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
  87. package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
  88. package/templates/src/demo/api/schema.yaml +0 -12
  89. package/templates/src/demo/api/seed.sql +0 -5
  90. package/templates/src/demo/api/todos/api.ts +0 -59
  91. package/templates/src/demo/web/__client/api.schemas.ts +0 -20
  92. package/templates/src/demo/web/__client/api.ts +0 -89
  93. package/templates/src/demo/web/entry.ts +0 -42
  94. package/templates/src/demo/web/locales/en-US.json +0 -10
  95. package/templates/src/demo/web/locales/zh-CN.json +0 -10
  96. package/templates/src/demo/web/pages/index.tsx +0 -45
  97. package/templates/src/home/web/entry.ts +0 -49
  98. package/templates/src/login/web/entry.ts +0 -79
  99. package/templates/src/notification/web/entry.ts +0 -84
  100. package/templates/src/personal-center/web/entry.ts +0 -60
  101. package/templates/src/system/web/entry.ts +0 -108
  102. /package/templates/src/{home/web → app/web/home}/__client/api.schemas.ts +0 -0
  103. /package/templates/src/{home/web → app/web/home}/locales/en-US.json +0 -0
  104. /package/templates/src/{home/web → app/web/home}/locales/zh-CN.json +0 -0
  105. /package/templates/src/{home/web → app/web/home}/pages/components/bar-chart.tsx +0 -0
  106. /package/templates/src/{home/web → app/web/home}/pages/components/card-list.tsx +0 -0
  107. /package/templates/src/{home/web → app/web/home}/pages/components/line-chart.tsx +0 -0
  108. /package/templates/src/{home/web → app/web/home}/pages/components/pie-chart.tsx +0 -0
  109. /package/templates/src/{home/web → app/web/home}/pages/index.tsx +0 -0
  110. /package/templates/src/{login/web → app/web/login}/auth-provider.ts +0 -0
  111. /package/templates/src/{login/web → app/web/login}/locales/en-US.json +0 -0
  112. /package/templates/src/{login/web → app/web/login}/locales/zh-CN.json +0 -0
  113. /package/templates/src/{login/web → app/web/login}/pages/change-password.tsx +0 -0
  114. /package/templates/src/{login/web → app/web/login}/wecom.ts +0 -0
  115. /package/templates/src/{notification/web → app/web/notification}/locales/en-US.json +0 -0
  116. /package/templates/src/{notification/web → app/web/notification}/locales/zh-CN.json +0 -0
  117. /package/templates/src/{notification/web → app/web/notification}/pages/center/index.tsx +0 -0
  118. /package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.schemas.ts +0 -0
  119. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/en-US.json +0 -0
  120. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/zh-CN.json +0 -0
  121. /package/templates/src/{personal-center/web → app/web/personal-center}/pages/my-profile/index.tsx +0 -0
  122. /package/templates/src/{system/web → app/web/system}/pages/departments/index.tsx +0 -0
  123. /package/templates/src/{system/web → app/web/system}/pages/departments/sync-bar.tsx +0 -0
  124. /package/templates/src/{system/web → app/web/system}/pages/departments/tree.ts +0 -0
  125. /package/templates/src/{system/web → app/web/system}/pages/errors.ts +0 -0
  126. /package/templates/src/{system/web → app/web/system}/pages/forbidden.tsx +0 -0
  127. /package/templates/src/{system/web → app/web/system}/pages/login-methods/index.tsx +0 -0
  128. /package/templates/src/{system/web → app/web/system}/pages/oplog/index.tsx +0 -0
  129. /package/templates/src/{system/web → app/web/system}/pages/roles/index.tsx +0 -0
  130. /package/templates/src/{system/web → app/web/system}/pages/settings/index.tsx +0 -0
package/devkit/SKILL.md CHANGED
@@ -54,7 +54,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
54
54
  - **注册时机**:一切 `ctx.register.*` 必须在 `lifecycle.onInit` 内、且在 onInit 返回前完成。
55
55
  拖到 `onActivate` 或某个异步回调里注册不保证生效。`onInit` 签名是 **async**(返回
56
56
  `Promise<void>`)——漏写 `async` 会被 `tsc` 以 TS2322 拦下,范本见模板
57
- `src/notification/web/entry.ts`。
57
+ `src/app/web/notification/entry.ts`。
58
58
  - **一模块一 client**:`create<Module>Client(ctx)` 只在 onInit 里创建一次。
59
59
  页面/组件/事件回调里**不要**再创建——会覆盖 request 单槽。页面里直接 import 裸函数调用。
60
60
  - **只创建自己模块的 client**:A 模块的 onInit 里创建 B 模块的 client 会 DEV 告警。要别家接口,
@@ -187,7 +187,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
187
187
  | 登录 401 且 msg 不是 `invalid credentials` | `/auth/*` 不在 `anonymous_paths` | 加进工程根 `config.yaml`,重启 |
188
188
  | WS 连接 401(「文件在却连不上」) | 模块写了 `ws.ts`,但其 `/ws` 路由不在 `anonymous_paths`(oj v0.1.30+ WS 握手也过鉴权) | 匿名 WS 加进 `anonymous_paths`;受保护 WS 带 Bearer/Cookie 是正当用法。`ojm api --check` 会对此 warn |
189
189
  | 登出/回登录页落空,或登录后落错误边界 | 工程缺 `login` / `home` 模块 | 在 `web.config.ts` 保留两者 |
190
- | 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底;脚手架不再提供 root 级 `/api/notifications` | 保留 `src/notification/web/entry.ts` 的登记 + onInit 注册四方法;后端由 `src/notification/api` 出 `/api/notification/*` |
190
+ | 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底;脚手架不再提供 root 级 `/api/notifications` | 保留 `src/app/web/notification/entry.ts` 的登记 + onInit 注册四方法;后端由 `src/notification/api` 出 `/api/notification/*` |
191
191
  | `oj serve` 启动即退,报 `config uses removed server.* site keys (v0.1.58): api_prefix` | 用了 oj v0.1.58 起删除的旧站点键(`api_prefix`/`app_path`/`app_prefix`/`static_sites`/`app_spa_fallback`) | 改 `server.api_prefix: /api` 为顶层 `mounts:`(module-root 写 `{prefix: "/api", api: "api/dist"}`;历史布局写 `api: "src"`、源码交给 `-d`);迁移完整 YAML 由报错给出 |
192
192
  | 改完 `mounts:` 仍全 404 | `api:` 指向目录不对(相对 config 所在目录) | module-root 指向产物目录 `"api/dist"`、历史布局指向源码树 `"src"`;`-c` 指向工程根 `config.yaml`(ojm dev/preview 已内置)。module-root 模板**故意不写 api 挂载**(源码交给 `--module-root src`、产物交给 `--api-path api/dist`,同时出现会报 module declared in multiple places) |
193
193
  | `oj serve` 启动即退,报 `module '_shared' missing manifest.yaml` | 把共享后端代码放到了扫描根顶层(`src/_shared/`)——oj 把扫描根下的顶层目录都当模块;历史布局是 `api/src/_shared/` | 嵌进所属模块:`src/<模块>/api/_shared/`(历史布局 `api/src/<模块>/_shared/`),用 `#<本模块>/...` 别名 |
package/devkit/manual.md CHANGED
@@ -45,13 +45,21 @@
45
45
  │ ├── _platform/ # 框架级:users 表 / rbac / 市场编排 / 生命周期
46
46
  │ │ ├── api/ # + _shared/(respond、service/、site-facade)
47
47
  │ │ └── web/ # 仅 __client 生成物(无页面)
48
+ │ ├── app/web/ # 框架内置页面**多根模块**(一个 entry 多个根):
49
+ │ │ ├── entry.ts # routes 聚合 + i18nNamespaces + 生命周期
50
+ │ │ ├── home/ # /home (routes.ts + pages + locales + __client)
51
+ │ │ ├── login/ # /login、/change-password
52
+ │ │ ├── personal-center/ # /personal-center/my-profile
53
+ │ │ ├── notification/ # /notification-center
54
+ │ │ └── system/ # /system/*(根上 handle.roles = 系统管理员)
55
+ │ ├── oj-market/web/ # 解决方案市场(独立模块,删目录即下线)
48
56
  │ ├── auth/api/ # login / refresh / logout / ldap-login
49
- │ ├── system/{api,web}/ # 用户 / 角色 / 权限 / 设置 / oplog / login-methods
50
- │ ├── notification/{api,web}/ # 通知互动(表 + push + del)
51
- │ ├── personal-center/{api,web}/ # 头像上传
57
+ │ ├── system/api/ # 用户 / 角色 / 权限 / 设置 / oplog / login-methods(后端)
58
+ │ ├── notification/api/ # 通知互动(表 + push + del)
59
+ │ ├── personal-center/api/ # 头像上传
52
60
  │ └── web/{api,web}/ # root 级 /api/web(hello / user-info / get-async-routes 在 api 侧)
53
- ├── api/dist/ # 后端产物(release 根:<name>-<ver>/ + manifests.yaml + versions.json)—构建后才有
54
- ├── web/dist/ # 站点产物(shell + modules/ + modules.json)—构建后才有
61
+ ├── dist/api/ # 后端产物(release 根:<name>-<ver>/ + manifests.yaml + versions.json)—构建后才有
62
+ ├── dist/web/ # 站点产物(shell + modules/ + modules.json)—构建后才有
55
63
  ├── bin/ # oj 二进制 + plugins/ + devkit/(不入库)
56
64
  └── .claude/skills/ # ojm-module-dev(本 devkit)+ oj-api-dev(随 oj 二进制)
57
65
  ```
@@ -62,13 +70,12 @@
62
70
  所在目录**,所有 `fs.*` 相对路径、`db.default`、`platform.root`、`mounts[].web`
63
71
  都相对它解析;证书与密钥落在同级的 `config/`(`ojm` 侧由 `resolveConfigPath()` /
64
72
  `resolveCertDir()` 单点派生,你不需要自己拼路径)。
65
- - **产物有两个根**:后端 `api/dist`(oj 扁平 release,`<name>-<ver>/` 与
66
- `manifests.yaml` 同层),站点 `web/dist`(`modules/` + `modules.json`)。
67
- `config.yaml` 上移到工程根后,两者不再同名 `dist`。
73
+ - **产物有两个根**:后端 `dist/api`(oj 扁平 release,`<name>-<ver>/` 与
74
+ `manifests.yaml` 同层),站点 `dist/web`(`modules/` + `modules.json`)。
68
75
  - **`src/` 下每个目录都得是一个模块**:脚本 / 共享库请放在 `src/` 之外,否则
69
76
  `ojm dev/build` 会以「既无 `api/manifest.yaml` 也无 `web/entry.ts`」直接报错。
70
77
 
71
- > 模板里还有一批 demo-only 模块(`src/{demo,home}/api`、`src/{about,access,exception,
78
+ > 模板里还有一批 demo-only 模块(`src/home/api`、`src/{about,access,exception,
72
79
  > outside,route-nest}/web`)仅供模板自演示,**不进脚手架**;其 `web.config.ts` 条目带
73
80
  > `// #region demo-only` 标记,init 拷贝时整段剥离。新布局的清单是「扫描 + 覆盖」并集,
74
81
  > 注释掉不会让模块消失——要「不构建」必须写 `enabled: false`。
@@ -289,14 +296,14 @@ export default {
289
296
  baseUrl: "", // 模块产物 URL 前缀(可填 CDN 绝对域);与站点部署位置无关
290
297
  // siteBase: "/oj", // 站点挂在 https://host/oj/ 下时才需要(默认根路径),见 §13 的路径空间一节
291
298
  modules: [
292
- // home 必须保留且尽量靠前:shell 预构建把 "/" → VITE_BASE_HOME_PATH(=/home)
293
- // 重定向,缺 /home 路由会让登录回跳 / 点 logo 落错误边界。
294
- { name: "home", entry: "src/home/web/entry.ts", enabled: true },
295
- // login 必须保留:shell 宿主只消费模块路由、不挂 runtime 内置 baseRoutes,
296
- // 缺它 `/login` 无路由可跳(登出会落空)。
297
- { name: "login", entry: "src/login/web/entry.ts", enabled: true },
299
+ // 框架内置页面聚合为一个**多根模块**(/home /login /personal-center
300
+ // /notification /system):一个 entry 多个根,各根按 handle.order 排菜单。
301
+ // 缺 /home 会让登录回跳 / 点 logo 落错误边界;缺 /login 则登出无路由可跳。
302
+ { name: "app", entry: "src/app/web/entry.ts", enabled: true },
298
303
  { name: "books", entry: "src/books/web/entry.ts", enabled: true },
299
304
  ],
305
+ // 多根聚合模块的 client 归属(生成物落 src/app/web/<api 前缀>/__client):
306
+ // clientOwners: { home: "app", "personal-center": "app", notification: "app", system: "app" },
300
307
  };
301
308
  ```
302
309
 
@@ -351,7 +358,7 @@ export default defineModule({
351
358
  `defineModule` 字段:`name`(= 目录名)、`description`、`version`、
352
359
  `peerRuntime`、`routes`、`i18n?`、`lifecycle?`。
353
360
 
354
- > 页面组件用**静态 import 或 `lazy()` 都行**:模板自带模块(`src/home/web/entry.ts` 等)
361
+ > 页面组件用**静态 import 或 `lazy()` 都行**:模板自带模块(`src/app/web/home/entry.ts` 等)
355
362
  > 用静态 import,多页面模块可对非首屏页面用 `lazy(() => import("./pages/x"))` 做代码分割。
356
363
  > 单个页面就用静态 import,少一层间接。
357
364
 
@@ -1349,7 +1356,7 @@ useEffect(() => {
1349
1356
  复用展示不同内容(列表页/`index` 路由),离开时必须 `resetTableTitle(tabKey)`,否则再进来看到
1350
1357
  上一个内容的标题。
1351
1358
 
1352
- 推荐形态(照 `src/home/web/pages/`、`src/personal-center/web/pages/my-profile/` 抄):
1359
+ 推荐形态(照 `src/app/web/home/pages/`、`src/app/web/personal-center/pages/my-profile/` 抄):
1353
1360
  `BasicContent` 包一层 → 搜索区 + `BasicTable` → 新增/编辑用 antd `Modal` + `Form`
1354
1361
  → 提交后重新拉列表。数据来自 §3.4 的生成函数,**页面里直接 import 裸函数调用**。
1355
1362
 
@@ -1402,7 +1409,7 @@ useEffect(() => {
1402
1409
  `useEffect` 重新注册(上面的形态天然满足)。
1403
1410
  - 同一 key 关闭流程进行中再次触发被忽略(per-key in-flight 去重),双击不会弹两个框。
1404
1411
 
1405
- 可运行示例见 playground-oj `web/src/demo/pages/guard.tsx`(本仓 playground 仍是历史布局;
1412
+ 可运行示例见模板工程 `src/app/web/*/pages/` 下的页面(layouts/权限门同源);
1406
1413
  模板侧对应 `src/<模块>/web/pages/`,表单 dirty → 关页签弹确认)。
1407
1414
 
1408
1415
  ### 6.5 两份 tsconfig:`typecheck` 与 `typecheck:api`
@@ -1556,7 +1563,7 @@ export default async (path: string, html: string) => {
1556
1563
  | 登录 401 且 msg 不是 `invalid credentials` | `/auth/*` 不在 `anonymous_paths` | 加进工程根 `config.yaml`,重启 |
1557
1564
  | 登出/回登录页空白或 404 | 工程缺 `login` 模块(shell 无内置登录回落) | 在 `web.config.ts` 保留 `login` |
1558
1565
  | 登录后 / 点 logo 落 React Router 错误边界 | 工程缺 `home` 模块 | 在 `web.config.ts` 保留 `home`(尽量靠前) |
1559
- | 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底——而脚手架不再提供 root 级 `/api/notifications` 模块 | 保留 `web.config.ts` 的 `notification` 登记,onInit 里注册四方法(模板 `src/notification/web/entry.ts`);后端由 `src/notification/api` 提供 `/api/notification/*` |
1566
+ | 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底——而脚手架不再提供 root 级 `/api/notifications` 模块 | 保留 `web.config.ts` 的 `notification` 登记,onInit 里注册四方法(模板 `src/app/web/notification/entry.ts`);后端由 `src/notification/api` 提供 `/api/notification/*` |
1560
1567
  | `@types/react` / `typescript` 装成 `*` | 宿主 versions.json 未列 | 安装后按实际版本钉死(`*` 不可复现) |
1561
1568
  | 迁移账本落后、服务拒绝启动 | release `verify` 闸 | 先 `ojm migrate -d api/dist`(透传自动补 `-c`) |
1562
1569
  | `seed.sql` 没生效 / 语法错 | 按 `;` 朴素切分,注释里有分号 | 去掉注释里的分号字面量 |
@@ -1579,14 +1586,14 @@ export default async (path: string, html: string) => {
1579
1586
 
1580
1587
  | 想抄什么 | 抄这里 |
1581
1588
  |---|---|
1582
- | 带页面的模块(路由 / i18n / order) | `src/home/web/entry.ts`、`src/demo/web/entry.ts` |
1583
- | 接管通知 provider(四方法全量) | `src/notification/web/entry.ts` |
1584
- | 上传 provider(action + headers 方法) | `src/personal-center/web/entry.ts` |
1585
- | 无页面模块(`routes: []`) | 同 `src/notification/web/entry.ts` |
1589
+ | 带页面的模块(路由 / i18n / order) | `src/app/web/home/entry.ts`、`src/app/web/entry.ts` |
1590
+ | 接管通知 provider(四方法全量) | `src/app/web/notification/entry.ts` |
1591
+ | 上传 provider(action + headers 方法) | `src/app/web/personal-center/entry.ts` |
1592
+ | 无页面模块(`routes: []`) | 同 `src/app/web/notification/entry.ts` |
1586
1593
  | handler `.schema` 入参 + 平级 `.response` 出参写法(GET + POST、参数段) | `src/notification/api/api.ts` |
1587
1594
  | handler 写法(嵌套组列表 / 写入 / 403 归属) | `src/notification/api/api.ts`(模块根单文件,`read`/`read-all`/`clear` 子组) |
1588
1595
  | 统一异常出口 `respond()`(service 抛 `Error & {status}` → 信封 `json.fail`) | 模板 `src/_platform/api/_shared/respond.ts` + 各 handler 用法(§1.7-1) |
1589
- | 生成物长什么样(含工厂) | `src/notification/web/__client/api.ts` |
1596
+ | 生成物长什么样(含工厂) | `src/app/web/notification/__client/api.ts` |
1590
1597
  | 建表三件套 | `src/notification/api/{schema.yaml,manifest.yaml,migrations/}` |
1591
1598
  | 全局类型(`window.$message` 等) | 工程根 `env.d.ts` |
1592
1599
 
@@ -2229,7 +2236,7 @@ async function post() {
2229
2236
  3. 页面用本模块 client 调。
2230
2237
 
2231
2238
  多一层 handler 换来三件事:接口形状由**你自己的契约**决定(只出页面要的字段)、框架内部实现不进你的
2232
- bundle、契约变更走你自己的版本号。仓库里 `src/system/web/pages/departments` 的组织架构页就是这条路的
2239
+ bundle、契约变更走你自己的版本号。仓库里 `src/app/web/system/pages/departments` 的组织架构页就是这条路的
2233
2240
  另一个方向(它消费的是管理端 `/api/system/departments`,见 §14.4)。
2234
2241
 
2235
2242
  ### 14.4 管理端 CRUD:只有「管组织架构」的模块才该碰
@@ -13,9 +13,9 @@
13
13
 
14
14
  | 你是 | 读 | 大约要做什么 |
15
15
  |---|---|---|
16
- | 部署 / 运维 | §2 §3 §4 §7 §8 | 备凭据、填 `config.yaml`、在后台点「验证并接入」、日常启停与轮换;真机验收照 **§7.5** 打勾 |
16
+ | 部署 / 运维 | §2 §3 §4 §7 §8 **§9** | 备凭据、填 `config.yaml`、在后台点「验证并接入」、日常启停与轮换;真机验收照 **§7.5** 打勾 |
17
17
  | 企业管理员(客户 IT) | §2.3 §3 §4.3 §7.5 | 在自家企微后台建自建应用、交出三个值、配可信 IP / 可信域名、把可见范围设成全公司、可选配通讯录事件回调 |
18
- | 本工程开发者 | §1 §6 §8 | 本地联调、加第二套凭据、写没有真实企业的自动化测试 |
18
+ | 本工程开发者 | §1 §6 §8 **§9** | 本地联调、加第二套凭据、写没有真实企业的自动化测试 |
19
19
  | 企业员工(用户) | §5 | 怎么进、要不要注册、进不去怎么办 |
20
20
 
21
21
  ---
@@ -589,7 +589,114 @@ wecom_allow_coexist: 'true' # 取值 1 / true / yes
589
589
 
590
590
  ---
591
591
 
592
- ## 9. 故障速查
592
+ ## 9. 租户与管理员模型(任命口径)
593
+
594
+ 这一节回答「接进来之后,**谁来管这家企业的数据**」。一句话:**租户键 = 企业 ID;
595
+ superadmin 是平台级的、只留一个;admin 是租户级的、每租户一个。**
596
+
597
+ ### 9.1 租户键就是企业 ID
598
+
599
+ - `tenantOfCorp(corpId)` 直接返回 `corpId`(`_shared/wecom/store.ts`)。
600
+ - 同一企业的**多个应用**(测试 / 正式、迁移期代开发与自建并存)**共用同一租户**——这是刻意
601
+ 的:企微的 `userid` 是**企业级唯一**,同一个人从不同应用进来就是同一个人(`wecom:<userid>`
602
+ 同一个账号),组织架构也是企业级的同一份。
603
+ - 需要按应用隔离的东西,在**应用这一维**已经隔离了:`wecom_app_profile` 主键
604
+ `(suite_id, corp_id, agent_id)`;同步任务 / 锁按 `corp_id + agent_id`(`jobScope`);
605
+ 登录链接带 `corp` + `agent`。
606
+ - 所以「同企业多应用」不需要、也**不该**拆成多个租户(拆了会把同一个人变成两个账号、
607
+ 组织树存两份)。
608
+
609
+ ### 9.2 角色定义全局唯一,角色授予按租户
610
+
611
+ | 表 | 是否带 `tenant_id` | 含义 |
612
+ |---|---|---|
613
+ | `sys_role` / `sys_permission` / `sys_role_permission` | **无** | 全站只有一套角色定义:superadmin / admin / editor / viewer(`framework` 内置) |
614
+ | `sys_user_role` | **有** | 授予是**按租户**的 |
615
+
616
+ 也就是说:不可能「每租户定义一套角色」,但可以「每租户各授一个 superadmin」——**但不要这么做**,理由见下。
617
+
618
+ ### 9.3 superadmin:平台级,建议只留一个(在 `default` 租户)
619
+
620
+ - `assertSuperAdmin()` 只判「当前用户在本租户有没有 superadmin」(`_platform/.../access.ts`)。
621
+ - 而 `/system/wecom` 每个端点都过这道闸,且它是**跨租户**的:`listApps()` 列出**所有企业**
622
+ 的档案。
623
+ - **推论**:任何租户里只要有人握着 superadmin,他带自己租户的头访问 `/system/wecom` 就能
624
+ 看到别家企业的档案 → 跨租户越权。
625
+ - 所以:superadmin = 平台级、唯一、只存在于 `default`(平台)租户。代码**没有强制**这条
626
+ (是运维纪律,不是代码约束)——别给企业租户的人授 superadmin。
627
+
628
+ ### 9.4 admin:租户级,每租户一个(自动开通)
629
+
630
+ - 企业租户是企微登录时由 `ensureTenant()` **隐式具象化**的(没有「建租户」这个动作),
631
+ 里面只有 JIT 出来的 `wecom:<userid>` 员工号,**默认一个管理员都没有** → 没人能做组织架构
632
+ 同步,也没人能在该租户内授权。
633
+ - 开通时机:自建「验证并接入」(`connectCorp`)与代开发授权(`create_auth`)各挂一次
634
+ `provisionTenantAdmin()`,幂等。
635
+ - **尽力而为**(失败只告警,不打断调用方):`create_auth` 有约 1 秒响应窗口,bcrypt 不该
636
+ 有机会拖垮回调;`connectCorp` 走到那步档案已落库,抛错只会让界面显示「接入失败」。
637
+ - **补开通道**:superadmin 在 `/system/wecom` → 「租户管理员」→ 重设口令(内部会先
638
+ `ensureTenantAdmin`)。老租户(本功能上线前就接入的)走这条路补开。
639
+ - 账号名固定 `admin`;初始口令**随机且无人知晓**(只存 bcrypt),`must_change=1`。
640
+ - **口令不存储**:superadmin 重设时**自己输入**新口令 → 他知道 → 线下告知对方。系统没有
641
+ 任何可逆副本,所以「回显已有口令」物理上做不到(bcrypt 单向)。
642
+ - 实现:`auth/api/_shared/wecom/tenant-admin.ts`,是**全仓唯一使用 `db.asSystem()` 的地方**
643
+ (平台请求绑在 `default`,而 `db.asTenant` 是「请求级、只能设一次」,没有任何正常通道能写
644
+ 企业租户)。框架注释明写「业务 handler 不得使用」,此处是有意识且被审计的**单一例外**,
645
+ **不得复制到别处**。
646
+
647
+ ### 9.5 任命 admin 已下放到租户内
648
+
649
+ `assertMembershipAllowed()` 按「被授予的角色」分层,不再一刀切:
650
+
651
+ | 授予的角色 | 谁能授 |
652
+ |---|---|
653
+ | `superadmin` | **仅 superadmin**(平台级、跨租户) |
654
+ | `admin` | 本租户内握有 `admin` **或** `superadmin` 的人(已下放) |
655
+ | `editor` / `viewer` 等 | 不校验(原行为) |
656
+
657
+ - **为什么必须下放**:平台 superadmin 因租户隔离**查不到**企业租户的用户(实测
658
+ `PUT /system/users/roles?uid=` 返回 `404 user not found`)。若 admin 任命也只许
659
+ superadmin,企业租户就**永远配不出管理员**——两头都够不着。
660
+ - **为什么这样是安全的**:判定侧 `rolesOf()` 读 `sys_user_role` 走请求租户注入,写入侧同样
661
+ 带 `tenant_id`,所以租户 admin 只能影响**自己租户**,越不到别家;而 `superadmin` 握跨租户
662
+ 权限,仍只许 superadmin 授。
663
+ - 前端:成员资格入口对 `admin` 也可见;非 superadmin 时 `superadmin` 选项**置灰**(提交后吃
664
+ 403 太晚也太含糊)。
665
+
666
+ > ⚠️ **首次登录必须先改密**:`must_change=1` 期间 `currentAccess()` 直接抛 `403 must_change`,
667
+ > **任何**走 `assertSystemAdmin()` 的动作(包括任命角色)都会被拦。管理员登录后会拿到
668
+ > `restricted` 会话,先改密才能做管理操作——这是预期行为,不是故障。
669
+
670
+ ### 9.6 审计:任命留痕带租户维度
671
+
672
+ `user.roles.update` 的 `detail`:
673
+
674
+ | 字段 | 说明 |
675
+ |---|---|
676
+ | `added` / `removed` | 本次增 / 删的角色 |
677
+ | `tenant` | **租户维度**(原本只有隐式的 `sys_oplog.tenant_id` 列) |
678
+ | `actorUid` / `targetUid` | 双方 uid |
679
+ | `elevated` | 是否牵涉 `admin` / `superadmin`(授予**或撤销**都算),便于直接筛「任命」类条目 |
680
+
681
+ 严重级:牵涉 `admin` / `superadmin` → `level=4`(warning,与「重设租户管理员口令」同级);
682
+ 普通角色变更 → `6`(info),避免审计被噪声淹没。
683
+
684
+ > 为什么要显式记 `tenant` + 双方 uid:各租户的管理员**用户名都叫 `admin`**,撞车。审计里
685
+ > `username='admin'` 既可能是 `default` 租户的平台 admin,也可能是某个企业租户的管理员,
686
+ > 只记 username 分不清「哪个租户的 admin 任命了谁」。
687
+
688
+ ### 9.7 登录时要选租户
689
+
690
+ - 登录页「企业代码」字段默认收起;**开了企业微信登录就默认展开**(判据:
691
+ `GET /system/login-methods` 的 `methods` 含 `wecom`)。
692
+ - 判据为什么不是「数租户」:匿名请求没有租户上下文,统计租户要动用 `db.asSystem`
693
+ (不为此破 §9.4 的「仅此一例」);而「开了企微登录 ⇒ 存在企业租户」语义成立。
694
+ - 运维填 `corp_id` + `admin` + 口令即可登录到该租户做管理;员工走企微登录(`?tenant=`
695
+ 自动回填,无需手填)。
696
+
697
+ ---
698
+
699
+ ## 10. 故障速查
593
700
 
594
701
  | 现象 / 文案 | 病根 | 处置 |
595
702
  |---|---|---|
@@ -628,7 +735,7 @@ wecom_allow_coexist: 'true' # 取值 1 / true / yes
628
735
 
629
736
  ---
630
737
 
631
- ## 10. 一分钟自检
738
+ ## 11. 一分钟自检
632
739
 
633
740
  ```text
634
741
  [ ] 五项 wecom_corp_* + wecom_enc_key + wecom_base_url 非空;代开发那几项留空(或显式开共存)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.50",
4
+ "version": "0.2.54",
5
5
  "description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.50",
52
+ "@oj-module/runtime": "0.2.54",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -1,5 +1,5 @@
1
1
  import { StyleProvider } from "@ant-design/cssinjs";
2
- import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getHostRoutes, getRoutes, loadAll, setupI18n, useAccessStore, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
2
+ import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getHostRoutes, getRoutes, loadAll, resolveApiBase, setupI18n, useAccessStore, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
3
3
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { ConfigProvider, theme } from "antd";
5
5
  import { useEffect, useState } from "react";
@@ -312,6 +312,10 @@ function Boot() {
312
312
  let cancelled = false;
313
313
  (async () => {
314
314
  try {
315
+ if (typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent) && location.pathname.endsWith("/login") && !new URLSearchParams(location.search).get("wecom_ticket")) {
316
+ window.location.assign(resolveApiBase() + "/auth/wecom/login");
317
+ return;
318
+ }
315
319
  ensureI18n();
316
320
  const base = resolveSiteBase();
317
321
  const [res, versionsRes] = await Promise.all([fetch(`${base}modules.json`), fetch(`${base}versions.json`).catch(() => null)]);
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.50",
307
+ "version": "0.2.54",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-11-01 22:25:12"
310
+ "lastBuildTime": "2026-11-02 18:04:24"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -7500,11 +7500,14 @@ function topologicalSort(entries, definitions) {
7500
7500
  return sorted;
7501
7501
  }
7502
7502
  async function mergeI18nResources(definition) {
7503
- if (!definition.i18n) return;
7504
- for (const [locale, loader] of Object.entries(definition.i18n)) {
7503
+ if (definition.i18n) for (const [locale, loader] of Object.entries(definition.i18n)) {
7505
7504
  const resources = await loader();
7506
7505
  i18next.addResourceBundle(locale, definition.name, resources.default || resources);
7507
7506
  }
7507
+ for (const [namespace, bundle] of Object.entries(definition.i18nNamespaces ?? {})) for (const [locale, loader] of Object.entries(bundle)) {
7508
+ const resources = await loader();
7509
+ i18next.addResourceBundle(locale, namespace, resources.default || resources);
7510
+ }
7508
7511
  }
7509
7512
  async function loadAll(manifest) {
7510
7513
  const enabledEntries = manifest.modules.filter((m) => m.enabled !== false);
@@ -7687,6 +7690,27 @@ function buildInstance(entry, definition, status, error) {
7687
7690
  error
7688
7691
  };
7689
7692
  }
7693
+ /**
7694
+ * 按 `handle.roles` 递归过滤路由(菜单与路由表同源)。
7695
+ *
7696
+ * 模块级 `config.requiredRoles` 只能整包放行/拦截;一个模块里既有全员可见页、
7697
+ * 又有仅超管可见页(/system/settings、/system/wecom)时,需要路由级粒度。
7698
+ * 语义与前端静态路由(generateRoutesByFrontend)一致:未声明 = 不限制;
7699
+ * 声明了则命中任一角色才保留,否则连同子树一起剔除。
7700
+ */
7701
+ function filterRoutesByRoles(routes, roles) {
7702
+ const out = [];
7703
+ for (const route of routes) {
7704
+ const required = route.handle?.roles;
7705
+ if (required?.length && !required.some((role) => roles.includes(role))) continue;
7706
+ const children = "children" in route && Array.isArray(route.children) ? filterRoutesByRoles(route.children, roles) : void 0;
7707
+ out.push(children ? {
7708
+ ...route,
7709
+ children
7710
+ } : route);
7711
+ }
7712
+ return out;
7713
+ }
7690
7714
  function getRoutes() {
7691
7715
  const { roles, permissions = [] } = useUserStore.getState();
7692
7716
  const routes = [];
@@ -7696,7 +7720,7 @@ function getRoutes() {
7696
7720
  if (requiredRoles?.length && !requiredRoles.some((role) => roles.includes(role))) continue;
7697
7721
  const requiredPermissions = instance.definition.config?.requiredPermissions;
7698
7722
  if (requiredPermissions?.length && !requiredPermissions.every((perm) => permissions.includes(perm))) continue;
7699
- if (instance.definition.routes.length > 0) routes.push(...resolveRouteLayouts(tagModuleRoutes(instance.definition.routes, instance.definition.name)));
7723
+ if (instance.definition.routes.length > 0) routes.push(...filterRoutesByRoles(resolveRouteLayouts(tagModuleRoutes(instance.definition.routes, instance.definition.name)), roles));
7700
7724
  }
7701
7725
  return addRouteIdByPath(routes);
7702
7726
  }
@@ -5,9 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-milCmNXuFsJqtDXLlLdu/g=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
-
10
- <script type="importmap" nonce="milCmNXuFsJqtDXLlLdu/g==">{
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-Php0JD1/JaEf2vFUnNnXaQ=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
+ <script type="importmap" nonce="Php0JD1/JaEf2vFUnNnXaQ==">{
11
10
  "imports": {
12
11
  "react": "/assets/react.js",
13
12
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -21,7 +20,6 @@
21
20
  "@oj-module/runtime/contract": "/assets/contract.js",
22
21
  "@oj-module/runtime/contract/errors": "/assets/contract-errors.js",
23
22
  "@plane/kit": "/assets/kit.js",
24
- "@plane/editor": "/assets/editor.js",
25
23
  "antd": "/assets/antd.js",
26
24
  "antd/locale/en_US": "/assets/antd-locale-en_US.js",
27
25
  "antd/locale/zh_CN": "/assets/antd-locale-zh_CN.js",
@@ -50,10 +48,6 @@
50
48
  "dayjs/plugin/weekday": "/assets/dayjs-plugin-weekday.js",
51
49
  "dayjs/plugin/weekOfYear": "/assets/dayjs-plugin-weekOfYear.js",
52
50
  "dayjs/plugin/weekYear": "/assets/dayjs-plugin-weekYear.js",
53
- "echarts": "/assets/echarts.js",
54
- "echarts/charts": "/assets/echarts-charts.js",
55
- "echarts/features.js": "/assets/echarts-features.js",
56
- "echarts-for-react": "/assets/echarts-for-react.js",
57
51
  "motion": "/assets/motion.js",
58
52
  "motion/react": "/assets/motion-react.js",
59
53
  "@dnd-kit/core": "/assets/dnd-kit-core.js",
@@ -73,8 +67,6 @@
73
67
  "simplebar-react": "/assets/simplebar-react.js",
74
68
  "spin-delay": "/assets/spin-delay.js",
75
69
  "tailwind-merge": "/assets/tailwind-merge.js",
76
- "vditor": "/assets/vditor.js",
77
- "vditor/dist/index.css": "/assets/vditor-index-css.js",
78
70
  "antd/es/modal": "/assets/ojm-antd-es-modal.js",
79
71
  "antd/es/upload": "/assets/ojm-antd-es-upload.js",
80
72
  "antd/es/button": "/assets/ojm-antd-es-button.js",
@@ -133,9 +125,14 @@
133
125
  "dayjs/locale/zh-cn": "/assets/ojm-dayjs-locale-zh-cn.js"
134
126
  }
135
127
  }</script>
136
- <script type="module" crossorigin src="/assets/index-FYt9CjFd.js"></script>
128
+ <style>
129
+ .boot-skeleton{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#999;font-size:14px;background:#fff;z-index:9999}
130
+ .boot-skeleton:after{content:"";width:18px;height:18px;margin-left:8px;border:2px solid #d9d9d9;border-top-color:#1677ff;border-radius:50%;animation:bs-spin .8s linear infinite}
131
+ @keyframes bs-spin{to{transform:rotate(360deg)}}
132
+ </style>
133
+ <script nonce="Php0JD1/JaEf2vFUnNnXaQ==" data-ojm-entry="/assets/index-e_Mp2mb5.js">(function(){try{var ua=(navigator.userAgent||"").toLowerCase(),p=location.pathname;while(p.length>1&&p.charAt(p.length-1)==="/")p=p.slice(0,-1);if(ua.indexOf("wxwork")>=0&&p.slice(-6)==="/login"&&!new URLSearchParams(location.search).get("wecom_ticket")){var a=(typeof window.__OJ_API_BASE__==="string"&&window.__OJ_API_BASE__.trim())||"/api";while(a.length>1&&a.charAt(a.length-1)==="/")a=a.slice(0,-1);location.replace(a+"/auth/wecom/login");return}}catch(e){}var s=document.createElement("script");s.type="module";s.src=document.currentScript.getAttribute("data-ojm-entry");document.head.appendChild(s);})();</script>
137
134
  </head>
138
135
  <body>
139
- <div id="root"></div>
136
+ <div id="root"><div class="boot-skeleton">加载中…</div></div>
140
137
  </body>
141
138
  </html>
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.50",
11
- "@oj-module/runtime/contract": "0.2.50",
12
- "@oj-module/runtime/contract/errors": "0.2.50",
10
+ "@oj-module/runtime": "0.2.54",
11
+ "@oj-module/runtime/contract": "0.2.54",
12
+ "@oj-module/runtime/contract/errors": "0.2.54",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -115,7 +115,7 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
115
115
  expect(unknown).toEqual([]);
116
116
  });
117
117
 
118
- it("t-4 28 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
118
+ it("t-4 27 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
119
119
  const { tables } = loadSchemaTables();
120
120
  const shared = [...tables].filter(([, d]) => d.tenant === false).map(([n]) => n).sort();
121
121
  // Phase 0 落地 21 张(11 A + 10 B);Phase 1 加企微三表,全部 B 组(D9:查它们时必然还没有租户上下文)
@@ -124,7 +124,8 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
124
124
  // auth/0002 加 wecom_sync_job / wecom_sync_lock 两张,同属 A 组:同步只在调用方自己的租户内跑
125
125
  // (D7 不接受 corpId 参数),故不进 shared_allow——跨租户读元数据那条另走显式 tenant_id 的裸 SQL。
126
126
  // 改这张表清单 = 改租户隔离边界,必须同步 config.yaml 的 tenant.shared_allow(t-2/t-3 会把关)。
127
- expect(tables.size).toBe(28);
127
+ // 2026-11-02 demo 模块下线(ecc7b35e)删掉 demo/api/schema.yaml 的 todos 表,28 → 27。
128
+ expect(tables.size).toBe(27);
128
129
  expect(shared).toEqual([
129
130
  "_oj_migrations",
130
131
  "market_history",
package/src/build.ts CHANGED
@@ -16,7 +16,7 @@ import { ALIAS_PATHS_FILE, syncAliasPaths } from "./alias-paths";
16
16
  import { assertModuleList, loadSiteConfig, mergeModuleList, resolveModuleEntry } from "./config";
17
17
  import { moduleDir, resolveConfigPath, resolveLayout, scanModules, tryResolveLayout } from "./layout";
18
18
  import { requireModuleRootCapability } from "./oj-caps";
19
- import { isSharedDep, SHARED_DEPS } from "./shared-deps";
19
+ import { isPrebuiltDep, isSharedDep, SHARED_DEPS } from "./shared-deps";
20
20
  import { normalizeSiteBase, rewriteIndexHtml } from "./site-base";
21
21
  import { resolveOjBin } from "./vendor";
22
22
  import { checkSharedVersions, resolveShellDist } from "./versions";
@@ -790,7 +790,7 @@ export async function buildModules(
790
790
  rolldownOptions: {
791
791
  // 只把共享依赖留给宿主 importmap;
792
792
  // 相对路径 / 绝对路径是模块自身代码,需要打包
793
- external: (id: string) => !id.startsWith(".") && !path.isAbsolute(id) && isSharedDep(id),
793
+ external: (id: string) => !id.startsWith(".") && !path.isAbsolute(id) && isPrebuiltDep(id),
794
794
  output: {
795
795
  // P7.x / e2e:模块产物内联动态 import(lazy 页面、i18n 等),
796
796
  // 输出为**单文件**。原因:
@@ -807,6 +807,23 @@ export async function buildModules(
807
807
  },
808
808
  });
809
809
 
810
+ // 运行期 require 门禁:CJS 依赖 require 了 external 共享包时,rolldown 只留一个浏览器
811
+ // 不可用的 require 桩("Calling `require` for ..."),表现为模块加载即抛错、页面空白。
812
+ // 这类依赖必须移出 bundle 走宿主 importmap(shell 侧有 CJS→ESM 垫片)。
813
+ const builtEntry = path.join(moduleOutDir, "entry.js");
814
+ if (fs.existsSync(builtEntry)) {
815
+ const code = fs.readFileSync(builtEntry, "utf-8");
816
+ if (code.includes("doesn't expose the `require` function")) {
817
+ const specs = [...new Set([...code.matchAll(/ne\("([^"]+)"\)/g)].map(m => m[1]))];
818
+ throw new Error(
819
+ `[ojm] ${definition.name}@${definition.version} 产物含运行期 require(${specs.join(", ") || "?"})`
820
+ + "——浏览器没有 require,模块加载即抛错。"
821
+ + "根因:该 CJS 依赖被标为 bundle(自带副本),但它 require 的共享包被 external 了。"
822
+ + "修复:把它从 SHARED_DEPS 的 bundle 列表移回共享资产,或改用该依赖的 ESM 入口。",
823
+ );
824
+ }
825
+ }
826
+
810
827
  const prefix = `${baseUrl}/modules/${definition.name}/${definition.version}/`;
811
828
  const chunks: ChunkEntry[] = [];
812
829
  const css: string[] = [];
package/src/config.ts CHANGED
@@ -48,6 +48,15 @@ export interface ModulesConfig {
48
48
  * `(path, html) => string | { html, cacheControl? } | Promise<…>`)。
49
49
  */
50
50
  htmlTransform?: string
51
+ /**
52
+ * 生成 client 的归属模块:`api 前缀 → 承载其 __client 的 web 模块`。
53
+ *
54
+ * 缺省(不配)= 同名模块,即 `src/<api 前缀>/web/__client`(历史行为,零漂移)。
55
+ * 多根聚合模块把多个业务域的页面/client 收进同一个 web 模块时配它:生成物落
56
+ * `src/<归属模块>/web/<api 前缀>/__client`,页面相对导入 `../__client` 不变。
57
+ * 只影响 `ojm api` 的落盘位置与生成物里的认领护栏,不影响路由与后端。
58
+ */
59
+ clientOwners?: Record<string, string>
51
60
  }
52
61
 
53
62
  const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl" | "siteBase">> = {
@@ -0,0 +1,30 @@
1
+ import type { ProjectLayout } from "../../layout";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { clientDirOf } from "../client-path";
5
+
6
+ const layout: ProjectLayout = {
7
+ kind: "module-root",
8
+ projectRoot: "/proj",
9
+ modulesSrc: "/proj/src",
10
+ distDir: "/proj/dist/web",
11
+ apiDist: "/proj/dist/api",
12
+ watchTarget: "/proj/src",
13
+ moduleRoot: "/proj/src",
14
+ };
15
+
16
+ describe("clientDirOf(client 落盘目录)", () => {
17
+ it("缺省 = 同名模块 src/<模块>/web/__client(历史口径零漂移)", () => {
18
+ expect(clientDirOf(layout, "/proj", "system")).toBe(join("/proj/src/system/web", "__client"));
19
+ });
20
+
21
+ it("clientOwners 指定归属 → src/<归属>/web/<api 前缀>/__client", () => {
22
+ expect(clientDirOf(layout, "/proj", "system", { system: "app" }))
23
+ .toBe(join("/proj/src/app/web/system", "__client"));
24
+ });
25
+
26
+ it("历史布局(layout=null)同样支持归属", () => {
27
+ expect(clientDirOf(null, "/proj", "system", { system: "app" }))
28
+ .toBe(join("/proj/web/src/app/system", "__client"));
29
+ });
30
+ });
@@ -125,6 +125,24 @@ describe("emitClient 注入安全:模块名含特殊字符不破坏源码", ()
125
125
  });
126
126
  });
127
127
 
128
+ describe("emitClient 多根聚合归属(owner)", () => {
129
+ it("owner 与 module 不同 → 落 MODULE_OWNER,护栏对比归属模块;MODULE_PREFIX 仍是 api 前缀", () => {
130
+ const ir = irFromOpenapi(DOC, { base: "/v1/api" });
131
+ const { "api.ts": apiTs } = emitClient(ir, { target: "module", module: "system", owner: "app" });
132
+ expect(apiTs).toContain("export const MODULE_PREFIX = \"/system\";");
133
+ expect(apiTs).toContain("export const MODULE_OWNER = \"/app\";");
134
+ expect(apiTs).toContain("ctx.module.name !== MODULE_OWNER.slice(1)");
135
+ expect(apiTs).not.toContain("ctx.module.name !== MODULE_PREFIX.slice(1)");
136
+ });
137
+
138
+ it("owner 缺省 = module → 生成物与历史逐字一致(不出现 MODULE_OWNER)", () => {
139
+ const ir = irFromOpenapi(DOC, { base: "/v1/api" });
140
+ const { "api.ts": apiTs } = emitClient(ir, { target: "module", module: "system", owner: "system" });
141
+ expect(apiTs).not.toContain("MODULE_OWNER");
142
+ expect(apiTs).toContain("ctx.module.name !== MODULE_PREFIX.slice(1)");
143
+ });
144
+ });
145
+
128
146
  describe("emitClient 空 IR 报错", () => {
129
147
  it("iR 为空抛错(无端点无需生成)", () => {
130
148
  expect(() => emitClient([], { target: "module", module: "x" })).toThrow();