@oj-module/cli 0.2.67 → 0.2.68

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.
package/devkit/SKILL.md CHANGED
@@ -51,7 +51,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
51
51
  历史布局漏登记 = 模块安静不加载;module-root 下登记是可选的,但**排序仍靠它**。
52
52
  - **`home` / `login` 模块必须保留**:shell 预构建把 `/` 重定向到 `/home`,且宿主只消费
53
53
  模块路由、不挂内置登录页。删掉它们 → 登录回跳落错误边界、`/login` 无路由可跳。
54
- - **布局级路由 handle 必写 `layout: "container"`**:不写页面会裸渲染(无侧边栏/页签),
54
+ - **布局级路由 handle 必写 `layout: "container"`**(`category: "admin"` 除外,未声明时框架默认 `container`):不写页面会裸渲染(无侧边栏/页签),
55
55
  且 `keepAlive` 失效。`"container" | "parent" | "none" | 自定义名`。
56
56
  - **注册时机**:一切 `ctx.register.*` 必须在 `lifecycle.onInit` 内、且在 onInit 返回前完成。
57
57
  拖到 `onActivate` 或某个异步回调里注册不保证生效。`onInit` 签名是 **async**(返回
@@ -171,8 +171,12 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
171
171
  - [ ] `web.config.ts` 的 `modules` 里已登记(`enabled` 缺省即启用;module-root 下扫描已能捡到,登记用于排序/覆盖)
172
172
  - [ ] 路由 `handle.layout` 选对了(有页面用 `"container"`;无页面模块 `routes: []`)
173
173
  - [ ] 菜单标题用命名空间语法 `"<模块名>:menu.<key>"`,且 `locales/<lang>.json` 里有这个 key
174
- - [ ] manifest 声明了 `roles[]` → 每个 `title_key` 用 `"<模块名>:role.<slug>"` 形状(ns 必须 = 模块名),
175
- 且双语 locale 里都有对应嵌套 key `{"role":{…}}`——安装时解析成 `sys_role.title`,缺失则存裸 key(不失败)
174
+ - [ ] 角色/权限声明写在方案契约 `solution.yaml`,**不是** `api/manifest.yaml`(oj 原生七键白名单,
175
+ 摆错位置安装直接拒「白名单外键」);细则见 manual.md §11.5
176
+ - [ ] 声明了 `roles[]` → 每个 `title_key` 用 `"<模块名>:role.<slug>"` 形状(ns 必须 = 模块名);
177
+ 声明了 `permissions[]` → 每个 `title_key` 用 `"<模块名>:perm.<slug>"`(必填)。
178
+ 双语 locale 里都要有对应嵌套 key(`{"role":{…}}` / `{"perm":{…}}`)——安装时按站点语言
179
+ 解析落库(`sys_role.title` / `sys_permission.title_key`),缺失则存裸 key(不失败)
176
180
  - [ ] 有接口 → handler 动词挂了 `.schema`(入参)+ 平级 `.response`(出参,oj v0.1.72+)→ `oj openapi` + `ojm api` 跑过 → onInit 里 `create<Module>Client(ctx)`
177
181
  - [ ] 接管了某功能 → provider 方法数与类型定义**一个不差**
178
182
  - [ ] 有后端 → handler 四红线过一遍(manual §1.7:respond 收口 / import 深度 / 无模块级状态 / 无模块级运行时全局)
@@ -228,6 +232,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
228
232
  | dev 日志 route error(Cannot find module)/ 端点 404 | handler 相对 import 多一层——按**目录**解析 | 改用 `#/<模块名>/…` 别名(与深度无关);存量代码按 manual §1.7 深度表改 |
229
233
  | 限速/节流/计数真实请求下永不累积(测试全绿) | 模块级 `Map`/`let` 不跨请求存活(每请求重建模块) | 状态落库(模板迁移 0009 先例)或 oj 持久面 |
230
234
  | `ojm build` 报 `introspect api.ts: jwt not configured` | 模块级语句碰运行时全局,introspect 空沙箱炸 | 移进 handler 函数体内(manual §1.7-4) |
235
+ | `ojm build` 报「框架默认模块与上游模板不一致」并列出文件 | 内置模块自治门禁:`src/app`、`src/oj-market` 被本地改过/删过/多出文件(CLI 升级后旧版默认模块也算漂移) | 需求提交上游;确认放弃本地修改则 `ojm init --yes` 恢复(会丢弃这两个目录的本地改动,先备份)。无绕过开关(manual §1.9) |
231
236
  | `ojm build` 报「密封值解不开」/「本机没有能解开的私钥」 | 出包预检 fail-closed:合并产物兜底 + tracked 非 jwt 红线(manual §10.1) | jwt 坏了删 `config.local.yaml` 的 `auth.jwt_secret` 行重跑 `ojm dev`;非 jwt 取配对私钥或 local 覆盖该字段 |
232
237
  | 页签怎么点都关不掉、无报错提示 | `beforeClose` 守卫 fail-safe 否决(守卫抛错按取消处理) | 先查控制台 `[tabs] beforeClose` 报错;确认弹窗逻辑与 `resolve(true/false)` 收口(manual §6.4) |
233
238
  | 组织架构读面 403「该部门不在可见范围内」 | `scope` 默认 `self`,只看得到自己那条部门路径 | 换 `scope=all`(需权限点 `platform.org.read.all`),或改问自己所在部门(manual §14.2) |
package/devkit/manual.md CHANGED
@@ -12,7 +12,8 @@
12
12
 
13
13
  ### 1.1 脚手架产物
14
14
 
15
- `ojm init <目录>` 生成(幂等补缺,重跑只补缺失文件,**永不覆盖已有文件**)。
15
+ `ojm init <目录>` 生成(幂等补缺,重跑只补缺失文件,**已有文件永不覆盖**;`--yes` 是唯一
16
+ 例外,见 §1.9 恢复语义)。
16
17
  模板拷贝按框架内置的 **`init.include.yaml` 白名单**执行——未收录的路径一律不拷
17
18
  (demo-only 演示模块、模板自运行产物都在清单外)。该清单是**框架侧文件**,`ojm init`
18
19
  从 cli 包内读取,**不在你的工程里**——你在工程里找不到它,也改不到它。
@@ -39,7 +40,8 @@
39
40
  ├── tests/ # 后端 BDD 测试(node:sqlite 内存库 + 真实迁移建表)
40
41
  ├── src/
41
42
  │ ├── tsconfig.json # 后端 typecheck 工程(extends 上两者,exclude */web)
42
- │ ├── .ojm-builtin.json # 框架自带方案名单(init 派生,可手改/删除;打包排除 + info 折叠,见 §11)
43
+ │ ├── README.md # src 分区说明(内置模块自治契约的人话版,动手前必读)
44
+ │ ├── .ojm-builtin.json # 框架自带方案名单(init 派生,可手改/删除;打包排除 + info 折叠 + build 门禁开关,见 §11)
43
45
  │ ├── styles.css
44
46
  │ ├── app/ # 整个应用(后端模块 + 前端聚合模块)
45
47
  │ │ ├── api/ # 后端模块(模块名 = URL 前缀,各自仍是独立 oj 模块)
@@ -59,7 +61,7 @@
59
61
  │ │ ├── auth/ # 仅 __client(无页面)
60
62
  │ │ ├── _platform/ # 仅 __client(oj-market 引用)
61
63
  │ │ └── web/ # 仅 __client
62
- │ ├── oj-market/web/ # 解决方案市场(独立模块,删目录即下线;单根 /oj-market 挂三个子页)
64
+ │ ├── oj-market/web/ # 解决方案市场(独立模块,enabled: false 下线;单根 /oj-market 挂三个子页)
63
65
  │ │ ├── entry.ts # routes 聚合:/oj-market 根(方案清单 index)+ 发布管理/发布历史子路由
64
66
  │ │ └── publish/ # 发布管理 + 发布历史(/oj-market/publish、/oj-market/history:routes + pages + locales + __client)
65
67
  ├── dist/api/ # 后端产物(release 根:<name>-<ver>/ + manifests.yaml + versions.json)—构建后才有
@@ -283,6 +285,35 @@ oj 的 `#` 导入与目录深度无关(后端解析规则见 `oj-api-dev` 手
283
285
  - `#` 与 Node `package.json#imports` 共用命名空间:工程里若出现 `#` 开头的 imports 键,
284
286
  `oj build` 直接失败(避免两套解析器分叉)。
285
287
 
288
+ ### 1.9 内置模块自治契约(build 门禁)
289
+
290
+ **`src/app/`(框架内置页面与后端)和 `src/oj-market/` 是上游自治的,下游不许改。**
291
+ `ojm build`(含 `--bundle`)与 `ojm market publish --real` 在任何产物写入前,把这两个目录
292
+ 逐文件与**当前 cli 包内模板**比对(上游模板即基线;比较前行尾归一,git autocrlf 不误报):
293
+
294
+ | 漂移 | 判定 |
295
+ | --- | --- |
296
+ | 改了内容 | `modified` — 拦 |
297
+ | 删了白名单文件 | `missing` — 拦 |
298
+ | 目录里多出文件 | `extra` — 拦 |
299
+
300
+ 被拦时构建直接失败、**零产物**,报错点名全部差异文件,并给出唯一正路:
301
+
302
+ - 功能需求/缺陷 → **提交上游 issue**,等新版本下发;
303
+ - 确认放弃本地修改 → `ojm init --yes` 恢复上游版本(见下)。
304
+
305
+ 没有绕过开关。这不是安全边界——删掉 `src/.ojm-builtin.json`(门禁开关)会整体退回旧行为,
306
+ 但没人替你兜底升级时的功能漂移;`enabled: false` 是下线某个自带能力的正确姿势(不是删目录)。
307
+
308
+ **`ojm init --yes`(恢复/升级模式)**:日常重跑 init 永不覆盖;`--yes` 额外把 `src/app`、
309
+ `src/oj-market` 恢复成当前 CLI 携带的上游版本(覆盖白名单文件、删除目录内多出文件、重写
310
+ `.ojm-builtin.json` 为上游名单)。**会丢弃这两个目录里的本地修改,执行前先备份**;下游自定义
311
+ 模块(`src/<你的模块>/`)与工程配置(config.yaml / web.config.ts / package.json 等)不受影响。
312
+
313
+ **CLI 升级后必须先同步**:新版 CLI 的模板就是新基线,旧版默认模块(即使一行没改)与新模板
314
+ 不一致即被拦——升级 cli 后先跑一次 `ojm init --yes` 再 build。dev 不拦,本地随便试验;
315
+ 发布产物必须过门禁。
316
+
286
317
  ---
287
318
 
288
319
  ## §2 写前端模块
@@ -394,7 +425,7 @@ export default defineModule({
394
425
  | `icon` | `createElement(XxxOutlined)` |
395
426
  | `keepAlive` | 页面缓存(多 tab 开启时有效;与 layout 解绑;相对/绝对声明均可,key 取路由绝对路径) |
396
427
  | `hideInMenu` | 不在菜单显示 |
397
- | `roles` / `permissions` | 路由级权限(页面级 / 按钮级) |
428
+ | `roles` / `permissions` | 路由级权限(页面级 / 按钮级);可绑定的角色/权限点目录由方案契约声明供给(§11.5) |
398
429
  | `iframeLink` | 内嵌 iframe 的外链地址 |
399
430
 
400
431
  > 另有**模块级** `config.requiredRoles` / `config.requiredPermissions`——它在路由
@@ -1150,13 +1181,11 @@ export default {
1150
1181
  | `container` | `ContainerLayout` | **有页面的业务路由必写**。完整外壳:顶栏 + 侧边/顶部导航 + 页签(keepAlive)+ 主内容 + 可选页脚 + 回到顶部 + 水印 |
1151
1182
  | `parent` | `ParentLayout` | 嵌套父级,自身只渲染 `<Outlet/>`(不挂任何 chrome),用于「菜单分组 / 多级路由」的父节点 |
1152
1183
  | `fullscreen` | `FullscreenLayout` | 全屏外壳:占满视口 + 品牌区(logo + 应用标题)+ 右上角工具(布局预览/主题/语言)+ 横幅 + 页脚,**无侧边栏/页签**。登录页即用它 |
1153
- | `none` | 裸 `<Outlet/>` | 不挂任何外壳,页面裸渲染。**不写 `layout` 时运行时回落为此值** |
1184
+ | `none` | 裸 `<Outlet/>` | 不挂任何外壳,页面裸渲染。普通路由不写 `layout` 时回落为此值;`category: "admin"` 未声明时默认 `container` |
1154
1185
 
1155
1186
  > 红线(见 SKILL.md):有页面的路由 handle 忘写 `layout: "container"`,页面会**裸奔**
1156
- > (无侧边栏/页签/顶栏),且 `keepAlive` 失效。
1157
- >
1158
- > 迁移说明:类型定义里 `handle.layout` 当前标了默认 `"container"` 仅为向后兼容,
1159
- > **目标态是改为 `"none"`**——不要依赖默认值,每个路由都显式写 `layout`。
1187
+ > (无侧边栏/页签/顶栏),且 `keepAlive` 失效。唯一例外是 `category: "admin"`:未声明时默认
1188
+ > `container`,需要其他布局时仍可显式覆盖。
1160
1189
 
1161
1190
  ### 5.2 路由如何选布局
1162
1191
 
@@ -2036,6 +2065,14 @@ flowchart TD
2036
2065
  ```
2037
2066
 
2038
2067
  - **needs_trust 确认后重试同一安装**即可续跑;一切消费面须把它当**非成功态**渲染。
2068
+ - **`partial` 不都是失败**:oj 0.1.122+ 首装 11 段全 `ok` 也可以终态 `partial`(`restart_pending`
2069
+ 正常语义——新模块的路由/模块注册要宿主重启,message 明示待重启段);重启宿主后 retry 同一条
2070
+ install → `done`,冒烟三项(探活 / integrity / 注册一致性)全过。partial 仅在 `reloading` 段
2071
+ 报 `unchanged` 时才是真失败(对着 dev 装的典型症状)。
2072
+ - **preflight 含匿名面检查(`anonymous_residual`)**:实际匿名可达的端点必须在方案契约
2073
+ `api.anonymous` 里声明(`ojm new --api` 骨架已自带 ping 的声明);漏声明的包拒装且不可自动重试。
2074
+ - **preflight 含 RBAC 声明校验**:`permissions[]` / `roles[]` 的命名前缀、`title_key`
2075
+ 必填、悬空引用双端拒绝——形状细则与注册/管理面见 §11.5。
2039
2076
 
2040
2077
  ### 11.4 生命周期命令与站点寻址约定
2041
2078
 
@@ -2057,6 +2094,61 @@ flowchart TD
2057
2094
  > `enable`/`disable`/`uninstall` 失败多半是「站点地址/凭据没给对」或「站点没这个方案」——
2058
2095
  > 先 `ojm info` 贴出版本矩阵与模块清单,确认目标站点与方案名无误再重试。
2059
2096
 
2097
+ ### 11.5 方案自管理角色/权限(声明 → 安装注册 → 管理面 → 多语言)
2098
+
2099
+ 方案可以自带角色与权限点目录,装进站点后由管理员在「系统管理 → 角色权限」页分配——
2100
+ **声明属方案契约(`solution.yaml`),不是 `api/manifest.yaml`**(后者是 oj 原生七键清单,
2101
+ 写方案级字段安装期被白名单拒装;见 §1.2)。
2102
+
2103
+ **声明面(`solution.yaml`,两段都可选)**:
2104
+
2105
+ ```yaml
2106
+ permissions: # code 强制 <name>. 前缀;title_key 必填 <name>:perm.*;包内唯一
2107
+ - code: wiki.doc.create
2108
+ title_key: wiki:perm.doc_create
2109
+ - code: wiki.doc.read # 无角色引用也合法(系统预置点,管理员在管理面自行分配)
2110
+ title_key: wiki:perm.doc_read
2111
+ roles: # name 强制 <name>.<role>;title_key 必填 <name>:role.*;
2112
+ - name: wiki.editor # permissions ⊆ permissions[].code(悬空引用双端拒绝)
2113
+ title_key: wiki:role.editor
2114
+ permissions: [wiki.doc.create, wiki.doc.read]
2115
+ ```
2116
+
2117
+ **安装注册(registering 段,唯一出口 `upsertSolutionPerms`)**:
2118
+
2119
+ - **title 安装期解析**:`title` 由 `title_key` 经包内
2120
+ `web/modules/<name>/locales/<站点默认语言>.json` 解析落库(站点默认语言读 `sys_setting`
2121
+ `site.language`,缺省 zh-CN);ns ≠ 本方案名不越界读;任何解析失败保留原文、**绝不拒装**。
2122
+ - **title_key 随行落库(展示锚)**:`sys_role.title_key` / `sys_permission.title_key` 存原始 key,
2123
+ 展示页按当前语言实时翻译、`title`/`code` 兜底。
2124
+ - **升级即重解析**:新版本 locale 文案改进随升级覆写元数据(title/title_key);
2125
+ 存量站点的权限点补 title_key 也是同一条路——**重装同版本方案即触发覆写**。
2126
+ - **只加法**:声明绑定 insert-if-absent(管理员撤过的绑定不复活);声明消失 →
2127
+ 行保留标 `deprecated`(装机告警清单);**重声明复位 `deprecated=0`**;
2128
+ 卸载双维级联清理。除该出口外不得直写 `sys_role`/`sys_permission`。
2129
+ - **落库不产生任何授权**:声明权限点只建目录行,判权仍走角色绑定——别以为把 code 写进
2130
+ manifest 就等于拿到权限。
2131
+
2132
+ **管理面(`/api/system/*`,需系统管理员——superadmin 或 admin,其余角色 403;`custom`/`wiki.*` 命名空间互斥)**:
2133
+
2134
+ | 端点 | 作用 | 红线 |
2135
+ |---|---|---|
2136
+ | `GET /api/system/roles` | 角色清单(module 归属 / title+title_key / holders / 已绑权限) | `superadmin` 行前端展示层隐藏(后端原样返回) |
2137
+ | `POST /api/system/roles` | 建自定义角色 | 机器名强制 `custom.<slug>`;未知权限码 400 |
2138
+ | `PUT /api/system/roles/bindings?name=` | 整集替换角色↔权限绑定 | builtin 角色硬拒 403;写后按持有者失效判权缓存(撤销即强退) |
2139
+ | `DELETE /api/system/roles?name=` | 删角色 | builtin 硬拒 403;有持有者 409(`holders` 明细) |
2140
+ | `GET /api/system/permissions` | 权限点目录(含 deprecated 标记,UI 标灰) | 判权链不过滤 deprecated |
2141
+ | `PUT /api/system/users/roles?uid=` | 整集替换用户角色(`sys_user_role` 唯一权威源) | 写后失效该用户判权缓存 |
2142
+
2143
+ **展示与多语言(「系统管理 → 角色权限」页,`/system/roles`)**:方案角色按当前语言实时渲染
2144
+ `t(title_key, { defaultValue: title })`;builtin 角色回退框架词典 `system:rolesPage.roleTitle.<name>`;
2145
+ custom 角色原样显示 title;权限点 tab 同理,title_key 不命中回退裸 code。
2146
+ `title_key` 引用的键须在方案 `web/locales/{zh-CN,en-US}.json` 双语落齐(打包校验项)。
2147
+
2148
+ > 与前端路由权限的关系:路由级 `handle.roles` / `permissions` 与模块级
2149
+ > `config.requiredRoles` / `requiredPermissions`(§2.4)是**消费端**过滤;本节是**供给端**
2150
+ > 目录与管理面。判权本身在服务端按 `sys_user_role` → `sys_role_permission` 链路裁决。
2151
+
2060
2152
  ---
2061
2153
 
2062
2154
  ## §12 签名与信任
@@ -2174,7 +2266,7 @@ export default defineModule({
2174
2266
  handle: { title: "wiki:menu.main", layout: "container" },
2175
2267
  children: [
2176
2268
  { path: "space", handle: { title: "wiki:menu.space", layout: "parent" } },
2177
- { path: "admin", handle: { title: "wiki:menu.admin", category: "admin", layout: "container" } },
2269
+ { path: "admin", handle: { title: "wiki:menu.admin", category: "admin" } },
2178
2270
  { path: "status/detail", handle: { title: "wiki:menu.statusDetail", category: "hidden" } },
2179
2271
  ],
2180
2272
  }],
@@ -2226,7 +2318,11 @@ const paths = createWikiPaths(getModuleRoutePrefix("wiki"));
2226
2318
 
2227
2319
  - 省略 / `""` / 旧 `"main"` → 普通可见菜单项。**`"main"` 不再产生合成父菜单**(扁平 main 归组已取消),新代码直接省略。
2228
2320
  - `"hidden"` → 有效路由但**不进菜单**(等价于 `hideInMenu: true`),用于详情页等。
2229
- - `"admin"` → 路由层被 `rewriteAdminPaths` 真重写为 `/system/<模块 root path>/*`(运行时 URL 真正归到系统设置),菜单层再被 `groupSolutionMenus` 收进 `/system` 子组 `solgroup:admin:<模块>`。
2321
+ - `"admin"` → 路由层被 `rewriteAdminPaths` 真重写为 `/system/<模块 root path>/*`(运行时 URL 真正归到系统设置),菜单层再被 `groupSolutionMenus` 收进 `/system` 子组 `solgroup:admin:<模块>`;未声明 `layout` 时默认 `container`,显式配置优先。
2322
+
2323
+ 下游主动导航时调用公开纯函数 `resolveRoutePath(route)` 获取最终 path,不复制重写规则。相对 admin path 会从 `getRoutes()` 生成的绝对 `id` 补全模块根;普通路由 path 原样返回,已重写 path 不重复添加 `/system`。
2324
+
2325
+ admin 子树内的后代路由自身不声明 `category`(继承自父级),调用时追加祖先链 `resolveRoutePath(child, moduleRoot, ..., parent)`(链从模块根到直接父级):重写结果为 `/system + 原绝对路径`(与 admin 嵌套深度无关),原绝对路径优先取已生成的 `id`(index 路由含尾斜杠),无 id 按链拼接;链上无 admin 祖先时仅拼接绝对路径,已重写 id 幂等。
2230
2326
 
2231
2327
  > `/system` 是框架侧断言,方案只标 `category: "admin"`,挂载点交给框架;宿主缺 `/system` 时告警一次。
2232
2328
 
@@ -2242,7 +2338,7 @@ const paths = createWikiPaths(getModuleRoutePrefix("wiki"));
2242
2338
 
2243
2339
  - 在 `entry.ts` 路由 handle 直接声明归组/菜单元数据(**真源**):
2244
2340
  `{ title: "wiki:menu.space", layout: "parent" }`、
2245
- `{ title: "wiki:menu.admin", category: "admin", layout: "container" }`
2341
+ `{ title: "wiki:menu.admin", category: "admin" }`
2246
2342
  - 模块只设一个根路由,根 `title` 用 `<模块>:menu.main`;`manifest.yaml` 的 `routes[]` 不必手写(构建期从 handle 派生覆盖)。
2247
2343
  - 在 `locales/{zh-CN,en-US}.json` 里补嵌套的 `menu.main`。
2248
2344
  - `admin` 路由统一 `category: "admin"`;模块内路径走 `paths.ts` 助手,`currentActiveMenu` 写绝对路径且从助手派生。
@@ -2466,7 +2562,7 @@ LDAP 侧改名 / 删人后,`origin='ldap'` 的行跟着变,而 `origin='weco
2466
2562
  | §8 排查 | 五步排查法、症状速查表 | 任何「没生效」 |
2467
2563
  | §9 参考 | 可抄的工程内文件、oj-api-dev 手册指引 | 找范本 |
2468
2564
  | §10 CLI 子命令参考 | ojm 原生命令 vs oj 透传命令;`market` 子命令(本地模拟 CDN) | 查命令用法;`ojm <cmd> --help` 看单命令帮助 |
2469
- | §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范、引用式方案(api 段省略 tables/deps,引用包内原生 manifest) | 发布方案包 |
2565
+ | §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范、引用式方案(api 段省略 tables/deps,引用包内原生 manifest)、**方案自管理角色/权限(声明/注册/管理面/多语言 §11.5)** | 发布方案包;方案要自带角色/权限目录 |
2470
2566
  | §12 签名与信任 | keygen/sign/install 发布流程、信任库、TOFU、bootstrap key | 首次签名发布;管理信任 |
2471
2567
  | §13 方案菜单归组规则 | 单根模块约定(根路由=第一级菜单)、自定义根路径 `web.route_prefix`(构建写 modules.json、运行时只改根段、getModuleRoutePrefix 单点消费)、category 三值(普通/hidden/admin)、admin 真重写 `/system/<模块 root>/*` + 视觉归组、menu.main 统一、模块内路径助手、开发者 DO/DON'T | 写方案要让菜单按模块分组、admin 归系统设置 |
2472
2568
  | §14 组织架构与通讯录 | 内置底座(两张租户表 + `users` 三列)、共享读面 7 端点与两级 `scope` 门禁、前端怎么「借壳」出自己端点、归属权四条规则与管理端 CRUD、源适配器接点与三条禁令(企微为范本,LDAP 前置)、通讯录与组织架构的分工 | 页面要选人/选部门、通知要按部门算收件人、要接新的组织数据来源 |
@@ -53,8 +53,8 @@ export default defineModule({
53
53
  { path: "space", handle: { title: "wiki:menu.space", layout: "parent" } },
54
54
  { path: "status", handle: { title: "wiki:menu.status", layout: "parent" } },
55
55
  { path: "collections", handle: { title: "wiki:menu.collections", layout: "parent" } },
56
- // admin 页面:标 category:"admin",路径会被真重写为 /system/wiki/...(§2)
57
- { path: "admin", handle: { title: "wiki:menu.admin", category: "admin", layout: "container" } },
56
+ // admin 页面:标 category:"admin",默认 container,路径真重写为 /system/wiki/...(§2)
57
+ { path: "admin", handle: { title: "wiki:menu.admin", category: "admin" } },
58
58
  // 仅可达、不进菜单的页面:category:"hidden"
59
59
  { path: "status/detail", handle: { title: "wiki:menu.statusDetail", category: "hidden" } },
60
60
  ],
@@ -67,7 +67,7 @@ export default defineModule({
67
67
 
68
68
  - 模块**只有一个根路由**,它渲染成侧栏第一级菜单项。根路由下的页面是其 `children`,按 `category` 决定是否在菜单出现(见 §1)。
69
69
  - 根路由的 `handle.title` 用 `<模块名>:menu.main`(这是「模块标题」与「该模块在 /system 下的管理子组标题」共用的同一个 key,见 §4)。
70
- - `layout` 语义:`container` = 全站外壳(顶栏/侧栏/页签/页脚);`parent` = 仅 Outlet 包裹(分组路由,无额外外壳);深嵌套分组路由必须用 `parent`,否则会叠加出双层外壳(双击 chrome)。根路由与 admin 路由保留 `container`。
70
+ - `layout` 语义:`container` = 全站外壳(顶栏/侧栏/页签/页脚);`parent` = 仅 Outlet 包裹(分组路由,无额外外壳);深嵌套分组路由必须用 `parent`,否则会叠加出双层外壳(双击 chrome)。根路由保留 `container`;admin 路由未声明 `layout` 时默认 `container`,显式配置仍优先。
71
71
  - `module` 由 module-loader 在 `getRoutes()` 注册时按 `definition.name` 自动注入,**你不用写**;旧 bundle 也带标。
72
72
  - 不要为了归组父菜单去手写一个额外的「分组根路由」——第一级菜单就是模块根路由本身。
73
73
 
@@ -113,6 +113,21 @@ export default defineModule({
113
113
  "/wiki/admin" (category:"admin") ──► hoist + 重写 ──► "/system/wiki/admin"
114
114
  ```
115
115
 
116
+ 下游需要主动导航时,从公开入口调用 `resolveRoutePath(route)`,不要复制这套映射规则。它接收单条 route 并返回最终 path:admin 添加 `/system`,普通路由原样返回,已重写 path 幂等;相对 admin path 使用 `getRoutes()` 生成的绝对 `id` 补全模块根。
117
+
118
+ admin 子树内的后代路由(自身不声明 `category`,继承自父级)追加祖先链参数,链从模块根到直接父级:
119
+
120
+ ```ts
121
+ import { resolveRoutePath } from "@oj-module/runtime";
122
+
123
+ const target = resolveRoutePath(route);
124
+ if (target)
125
+ navigate(target);
126
+
127
+ // admin 子树后代
128
+ const childTarget = resolveRoutePath(childRoute, moduleRootRoute, adminParentRoute);
129
+ ```
130
+
116
131
  > 真重写 vs 旧视觉归组:旧版只在菜单层把 admin 挪到 /system,路由 URL 仍是 `/wiki/admin`,
117
132
  > 深链与 SEO 都错位。新版在路由层就改掉路径,URL 与菜单一致。
118
133
 
@@ -341,7 +356,7 @@ headless chromium 核对「`/` 重定向到 `/oj/<首个模块路由>`」与「
341
356
 
342
357
  - 在 `entry.ts` 路由 handle 直接声明归组/菜单元数据(**真源**):
343
358
  `{ title: "wiki:menu.space", layout: "parent" }`、
344
- `{ title: "wiki:menu.admin", category: "admin", layout: "container" }`
359
+ `{ title: "wiki:menu.admin", category: "admin" }`
345
360
  - 模块只设**一个根路由**,其余页面作其 `children`;根路由 `title` 用 `<模块>:menu.main`。
346
361
  - `manifest.yaml` 的 `routes[]` **不必手写**:构建期从 entry.ts handle 自动派生并覆盖;漂移时警告一次并以 entry.ts 为准。
347
362
  - 在 `locales/{zh-CN,en-US}.json` 里补嵌套的 `menu.main`(根菜单标题 + 系统设置管理子组标题共用)。
@@ -57,6 +57,10 @@
57
57
  > 解析数据源是包内 `web/modules/<name>/locales/`——`build --bundle` 自动从源码复制双语,
58
58
  > 作者零动作。key 缺失/文件缺失 → title 存裸 key,**安装永不因此失败**;改 locale 文案后重新
59
59
  > 打包升级,角色名即随升级更新。
60
+ >
61
+ > **权限点标题同理落库(0.2.68 起)**:`permissions[].title_key`(契约必填)随行落库
62
+ > `sys_permission.title_key`(此前只校验不落库、展示页回退裸 code);升级元数据覆写与
63
+ > deprecated 重声明复位与角色同纪律。声明面/管理面/多语言全景见 devkit 手册 §11.5。
60
64
 
61
65
  ### 3.2 但同名再装会被直接拒
62
66
 
@@ -24,7 +24,13 @@ flowchart LR
24
24
  1. 方案契约在位:`src/<模块>/solution.yaml`(`name` / `version` / `summary`;纯前端可省 `api` 段,纯后端可省 `web` 段);
25
25
  2. 前端入口在位:`src/<模块>/web/entry.ts`。module-root 布局(0.2.20+)以**扫描**为准,
26
26
  `web.config.ts` 的 `modules` 登记只影响加载顺序,漏登记**不会**漏构建(登记了不存在的 entry 才会报错);
27
- 3. 双语 locale 在位:`src/<模块>/web/locales/{zh-CN,en-US}.json`(key 必须嵌套,如 `{"menu":{"main":"…"}}`)。
27
+ 3. 双语 locale 在位:`src/<模块>/web/locales/{zh-CN,en-US}.json`(key 必须嵌套,如 `{"menu":{"main":"…"}}`);
28
+ 4. **匿名端点已在契约声明**:凡不带 token 可访问的端点(`ojm new` 骨架的 `ping` 就是),必须在
29
+ `solution.yaml` 的 `api.anonymous` 里列出(如 `anonymous: ["<模块>/ping"]`,模块相对路径)——
30
+ 漏声明安装预检 `anonymous_residual` 直接拒装(0.2.68 起 `ojm new --api` 骨架已自带这行;
31
+ 自己加端点时记得同步维护这份清单)。
32
+ 5. **别把方案建进保护根**:`src/app`、`src/oj-market` 受完整性门禁保护(0.2.68+,见 `src/README.md`),
33
+ `ojm build` 会先比对上游模板、有漂移即拒;用户方案放 `src/<你的名字>/`。
28
34
 
29
35
  ---
30
36
 
@@ -151,6 +157,12 @@ ojm market publish --real . \
151
157
  `cdn/` 是**三级索引**布局:`catalog.yaml`(+`.sig`)、`shards/<n>.yaml`(+`.sig`)、
152
158
  `<name>-<version>.tgz`(+`.tgz.sig`);分片数随方案数增长,不要按固定文件数核对。
153
159
 
160
+ > **`--real` 仅限单方案工程**(`src/` 下只有一份 `solution.yaml`);`ojm new` 生成的方案长在宿主工程里
161
+ > (宿主还自带 `app`/`oj-market`),多方案工程走 §4.2 显式四步。
162
+ > **`--fixture` 不是发布通道**:`ojm market publish --fixture <name>@<ver>` / `trial` / `walkthrough`
163
+ > 里的 fixture 是**现场合成的最小合法测试包**(固定 ping 形状),专供演练与隔离验证——
164
+ > 它不会打包你的真实代码,别拿它发真方案。
165
+
154
166
  > 引用式方案(契约 `api` 段省略 `tables`/`deps`)会打印「引用式方案…非漏配」提示——那是合法形态,不是错误。
155
167
 
156
168
  ### 4.2 多方案工程(显式四步)
@@ -207,7 +219,9 @@ ojm build
207
219
  oj serve -c config.yaml -b /api --api-path dist/api
208
220
 
209
221
  # ② 市场地址:本地自供 /cdn 是模板默认,**不需要 setup**;
210
- # 只有指向外部真 CDN 才要执行(首跑可能强制改密,故下一步的 --pass 用新口令):
222
+ # 只有指向外部真 CDN 才要执行。注意 setup 不一定改密:账号不在「必须改密」悬挂期时
223
+ # (当前 seed 的 superadmin 就是),--new-pass 不生效、原口令继续可用——
224
+ # 2026-11-06 演练实踩:setup 后拿新口令登录得 401。真触发了改密闭环才用新口令:
211
225
  # ojm market setup --site-url http://127.0.0.1:9778 --user superadmin --pass <当前口令> --new-pass <新口令>
212
226
 
213
227
  # ③ 安装(从 CDN 拉:name[@version];直传文件则给 tgz 路径,.sig 同目录自动随包)
@@ -221,10 +235,14 @@ ojm trust confirm --task <task-id> --site-url http://127.0.0.1:9778 --user super
221
235
  ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass <口令> # 重跑续跑
222
236
  ```
223
237
 
224
- `<口令>` 就是登录站点用的那个:没跑过 `market setup` 时是脚手架种子口令(`superadmin / aA123456`),
225
- 跑过则用 `--new-pass` 设的新口令。
238
+ `<口令>` 就是登录站点用的那个:没跑过 `market setup` 时是脚手架种子口令(`superadmin / aA123456`);
239
+ 跑过 setup 也不一定变——只在真触发了「必须改密」闭环时才用 `--new-pass` 的新口令(见 ②)。
240
+
241
+ **首装终态两段式(oj 0.1.122+ 正常语义)**:install 走完 11 段全 ok 但 `status=partial`——
242
+ message 明示 `restart_pending`(新模块的路由/模块注册要宿主重启才生效,如「待重启段 [auth]」)。
243
+ **重启一次 `oj serve`** 后再 retry 同一条 install → `status=done`、冒烟三项(探活/完整性/注册一致性)全过。
226
244
 
227
- 通过判据:`dist/api/manifests.yaml` 出现 `<name>: <version>`;**重启一次 `oj serve`**(新方案路由要重启才注册——reload 由 manifests 锁 diff 驱动装配,但新模块路由/模块注册属 restart_pending,热改不即时生效)后
245
+ 通过判据:`dist/api/manifests.yaml` 出现 `<name>: <version>`;重启后
228
246
  `curl -H 'X-TENANT-ID: default' /api/<name>/ping` 返回 `code:0`——**不带租户头会 400 `missing tenant header`**,
229
247
  那是缺参数,不是装失败。
230
248
 
@@ -245,12 +263,16 @@ TOFU 没有免检通道:**只有模板内置的 oj-market(第一方包)用
245
263
  | 申请凭证 403 `password change required` | 新建账号首次登录还在「必须先改密」悬挂期 | 先改密(页面或 `PUT /api/auth/password`),再申请 |
246
264
  | 安装 503 `安装需要站点 canonical origin` | `config.yaml` 的 `vars.canonical_origin` 没配,而市场基址是相对路径 `/cdn` | 配成站点对外地址(本地演练 `http://127.0.0.1:9778`)后重启 |
247
265
  | 安装 partial(stage `reloading`,`reloadConfig 返回 unchanged`) | 对着 `ojm dev` 装:dev 的文件监听先吃掉了 config 改动 | 换成 `oj serve` release 形态再装;别在 dev 上验证安装 |
266
+ | 安装 partial(11 段全 ok,message 含 `restart_pending` /「请重启 oj serve」) | **正常语义**(oj 0.1.122+):新模块的路由/模块注册要宿主重启 | 重启 `oj serve` 后 retry 同一条 install → `done`(§6 首装终态两段式) |
267
+ | 安装拒 `anonymous_residual`(preflight,不可自动重试) | 有端点实际匿名可达,但 `solution.yaml` 的 `api.anonymous` 没声明(换包时含上一版声明了、新包漏声明的残留比对) | 把该端点补进 `anonymous: ["<模块>/<路由>"]`(模块相对路径),重打包重签重发;`ojm new --api` 骨架 0.2.68 起自带 ping 声明 |
268
+ | 安装拒「包内原生清单含白名单外键 `permissions、roles`…」(extracting 段,不可自动重试) | 角色/权限声明写进了 `api/manifest.yaml`(oj 原生七键清单) | 声明挪到方案契约 `solution.yaml`(上级目录那一份);`api/manifest.yaml` 只留 oj 原生七键(2026-11-06 演练实踩) |
269
+ | 安装 preflight 拒 RBAC 声明 | 声明形状不符:权限 code / 角色 name 没带 `<name>.` 前缀、`title_key` 缺失或 ns 不符、角色引用了未声明的权限(悬空) | 按报错条款改 `solution.yaml` 的 `permissions[]` / `roles[]`(字段细则见 devkit 手册 §11.5) |
248
270
  | 装完 `curl /api/<name>/ping` 返回 400 `missing tenant header` | 少了 `X-TENANT-ID` 头 | 加 `-H 'X-TENANT-ID: default'` 再探 |
249
271
  | `sig_alg` / `sig_format` | 签名算法/.sig 结构不符 | 用当前 ojm 重签(旧版本工具产物) |
250
272
  | `needs_trust`(第五终态,非成功) | 站点第一次见这个 key | 与发布方逐字核对指纹 → `trust confirm --task <id>` → 重跑 install |
251
273
  | publish 报「包冲突:…字节不一致」 | 同 name@version 已有字节不同的包 | **升 version 重发**;确需覆盖先删 CDN 旧 tgz |
252
274
  | publish 报「一次只发布一份方案」 | 多方案工程走了自动链路 | §4.2 显式四步 |
253
- | 打包报「缺少本方案的站点产物」 | 没先 `ojm build`,或前端入口不在 `src/<模块>/web/entry.ts`(module-root 扫描不到;`web.config.ts` 漏登记**不是**原因) | 补 entry / 先构建再打包 |
275
+ | 打包报「缺少本方案的站点产物」 | 没先 `ojm build`;或**卸载方案时把 `dist/api/<name>-<ver>` 一并移除了**(2026-11-06 实测:卸载四同删含版本目录,重发同版本前必须重建);或前端入口不在 `src/<模块>/web/entry.ts` | 先 `ojm build` 再 `--bundle`;补 entry |
254
276
  | 打包报「缺少 bundle 级契约」 | 没有 `solution.yaml` | 补契约(§0 前提 1) |
255
277
  | 装不到刚发的包 | 索引没重建 | `ojm market build-index`(§4.3 纪律 2),确认 `cdn/` 的 catalog + 分片 + tgz(+sig) 已同步 |
256
278
  | 装成后 404 | 方案路由没注册 | 重启 `oj serve`(§6 通过判据) |
@@ -615,7 +615,7 @@ superadmin 是平台级的、只留一个;admin 是租户级的、每租户一
615
615
 
616
616
  | 表 | 是否带 `tenant_id` | 含义 |
617
617
  |---|---|---|
618
- | `sys_role` / `sys_permission` / `sys_role_permission` | **无** | 全站只有一套角色定义:superadmin / admin / editor / viewer(`framework` 内置) |
618
+ | `sys_role` / `sys_permission` / `sys_role_permission` | **无** | 角色定义全站一套,不按租户分:内置四角色(superadmin / admin / editor / viewer)+ 各方案声明安装注册的角色(`solution.yaml` 的 `roles[]`),同池共存 |
619
619
  | `sys_user_role` | **有** | 授予是**按租户**的 |
620
620
 
621
621
  也就是说:不可能「每租户定义一套角色」,但可以「每租户各授一个 superadmin」——**但不要这么做**,理由见下。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.67",
4
+ "version": "0.2.68",
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.67",
52
+ "@oj-module/runtime": "0.2.68",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",