@oj-module/cli 0.1.16 → 0.1.20

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 (68) hide show
  1. package/devkit/README.md +11 -10
  2. package/devkit/SKILL.md +35 -18
  3. package/devkit/manual.md +359 -131
  4. package/package.json +2 -2
  5. package/shell-dist/assets/contract.js +0 -55
  6. package/shell-dist/assets/runtime.js +238 -213
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +5 -5
  9. package/src/build.ts +1 -0
  10. package/src/contract/__tests__/check-api.test.ts +74 -0
  11. package/src/contract/__tests__/emit-client.test.ts +132 -0
  12. package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
  13. package/src/contract/__tests__/mock.test.ts +56 -0
  14. package/src/contract/__tests__/openapi-ir.test.ts +137 -0
  15. package/src/contract/__tests__/run-api.test.ts +67 -0
  16. package/src/contract/check.ts +67 -294
  17. package/src/contract/emit-client.ts +23 -15
  18. package/src/contract/emit-jsonschema-zod.ts +209 -0
  19. package/src/contract/emit-oj-schema.ts +108 -161
  20. package/src/contract/emit-stub.ts +17 -57
  21. package/src/contract/handler-scan.ts +263 -0
  22. package/src/contract/ir.ts +11 -179
  23. package/src/contract/json-schema-sample.ts +72 -0
  24. package/src/contract/mock.ts +28 -100
  25. package/src/contract/openapi-ir.ts +145 -0
  26. package/src/contract/run.ts +71 -120
  27. package/src/dev.ts +20 -18
  28. package/src/init.ts +18 -3
  29. package/src/oj.ts +6 -2
  30. package/src/vendor.ts +8 -4
  31. package/templates/api/config.yaml +1 -0
  32. package/templates/api/src/auth/api.ts +66 -0
  33. package/templates/api/src/notification/_shared/notify.ts +199 -0
  34. package/templates/api/src/notification/api.ts +231 -0
  35. package/templates/api/src/notification/del/api.ts +47 -0
  36. package/templates/api/src/notification/manifest.yaml +3 -1
  37. package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
  38. package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
  39. package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
  40. package/templates/api/src/notification/schema.yaml +23 -1
  41. package/templates/api/src/notification/seed.sql +6 -4
  42. package/templates/api/src/personal-center/upload/api.ts +12 -1
  43. package/templates/env.d.ts +1 -1
  44. package/templates/openapi.json +471 -0
  45. package/templates/web/src/auth/__client/api.schemas.ts +8 -0
  46. package/templates/web/src/auth/__client/api.ts +110 -0
  47. package/templates/web/src/notification/__client/api.schemas.ts +50 -0
  48. package/templates/web/src/notification/__client/api.ts +186 -0
  49. package/templates/web/src/notification/entry.ts +20 -9
  50. package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
  51. package/templates/web/src/notifications/__client/api.ts +80 -0
  52. package/templates/web/src/{notification/client → personal-center/__client}/api.schemas.ts +2 -14
  53. package/templates/web/src/personal-center/__client/api.ts +88 -0
  54. package/templates/web/src/web/__client/api.schemas.ts +8 -0
  55. package/templates/web/src/{notification/client → web/__client}/api.ts +12 -37
  56. package/vendor/host-versions.json +5 -5
  57. package/src/contract/emit-meta.ts +0 -175
  58. package/src/contract/emit-schema.ts +0 -133
  59. package/src/contract/evaluate.ts +0 -88
  60. package/templates/api/src/auth/login/api.ts +0 -27
  61. package/templates/api/src/auth/logout/api.ts +0 -10
  62. package/templates/api/src/auth/refresh/api.ts +0 -26
  63. package/templates/api/src/notification/contract.ts +0 -52
  64. package/templates/api/src/notification/notifications/api.ts +0 -24
  65. package/templates/api/src/notification/notifications/clear/api.ts +0 -13
  66. package/templates/api/src/notification/notifications/read/api.ts +0 -19
  67. package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
  68. package/templates/api/src/personal-center/contract.ts +0 -16
package/devkit/manual.md CHANGED
@@ -103,7 +103,7 @@ web/src/<模块名>/
103
103
  ├── entry.ts # 元数据唯一来源(没有 package.json / meta.json)
104
104
  ├── pages/ # 页面组件(按需)
105
105
  ├── locales/ # zh-CN.json / en-US.json(按需)
106
- └── client/ # ojm api 生成物(勿手改)
106
+ └── __client/ # ojm api 生成物(勿手改)
107
107
  ```
108
108
 
109
109
  ### 2.2 两步缺一不可
@@ -114,8 +114,8 @@ web/src/<模块名>/
114
114
  > 模板真出过这个 bug:模块文件都在,就是忘了登记,结果模块安静地没加载、
115
115
  > 按钮安静地灰着,**全程没有一个报错**。写完模块第一件事就是检查 `web.config.ts`。
116
116
 
117
- **建档顺序很重要**(有接口的模块):先写 `contract.ts` → 跑 `ojm api` → 再写 `entry.ts`。
118
- 生成前 `import { createXxxClient } from "./client/api"` 必然 ts 报红,
117
+ **建档顺序很重要**(有接口的模块):先写 handler 的 `.schema` → 跑 `oj openapi` + `ojm api` → 再写 `entry.ts`。
118
+ 生成前 `import { createXxxClient } from "./__client/api"` 必然 ts 报红,
119
119
  那不是你写错了,是产物还没生成。完整流水线见 §3.4。
120
120
 
121
121
  ```ts
@@ -143,7 +143,7 @@ import { BookOutlined } from "@ant-design/icons";
143
143
  import { defineModule } from "@oj-module/runtime";
144
144
  import { createElement, lazy } from "react";
145
145
 
146
- import { createBooksClient } from "./client/api";
146
+ import { createBooksClient } from "./__client/api";
147
147
 
148
148
  const Books = lazy(() => import("./pages/index"));
149
149
 
@@ -240,7 +240,7 @@ export default defineModule({
240
240
  可服务的同源路径)。直接 `import "vditor/dist/..."` 其它深路径会命中前缀 external 但无
241
241
  importmap 键,构建期报错——需要新深路径请联系框架方在共享表登记。
242
242
 
243
- ### 2.7 本版本共享依赖钉版(0.1.16 快照)
243
+ ### 2.7 本版本共享依赖钉版(0.1.20 快照)
244
244
 
245
245
  宿主经 importmap 提供以下单例(模块 `devDependencies` 须对齐,供 `tsc` 用);完整 68 项矩阵以
246
246
  `pnpm exec ojm info` 或 `packages/cli/shell-dist/versions.json`(构建期生成)为准。
@@ -269,8 +269,10 @@ importmap 键,构建期报错——需要新深路径请联系框架方在共
269
269
  | `pinyin-pro` | 3.29.3 |
270
270
  | `vditor` | 4.0.0 |
271
271
 
272
- 框架包版本:`@oj-module/runtime` / `@oj-module/cli` / `@plane/kit` / `@plane/editor` 均为 `0.1.16`
273
- (lockstep;本版实际改动仅 cli——vditor 入共享表);`oj` 二进制最低版本门禁 `MIN_OJ_VERSION = 0.1.50`。
272
+ 框架包版本:`@oj-module/runtime` / `@oj-module/cli` / `@plane/kit` / `@plane/editor` 均为 `0.1.20`
273
+ (lockstep);`oj` 二进制最低版本门禁 `MIN_OJ_VERSION = 0.1.73`——契约流水线要求 oj ≥ 0.1.73:
274
+ 平级 `.response` 出参声明位自 v0.1.72 起、`oj openapi` 的 `del`→`delete` 操作键归一自 v0.1.73 起
275
+ (低版本 oj 生成 `openapi.json` 会丢出参类型或产出非法 `"del"` 键,`ojm api --check` 必漂移)。
274
276
 
275
277
  > **`@oj-module/runtime` 到底导出哪些东西?** 权威清单随包发布在
276
278
  > `node_modules/@oj-module/runtime/dist/index.d.ts`——直接 Read 它,或在编辑器里
@@ -285,94 +287,128 @@ importmap 键,构建期报错——需要新深路径请联系框架方在共
285
287
 
286
288
  ### 3.1 一句话
287
289
 
288
- `ojm api` 读**契约文件**(`contract.ts`),一次性生成前端调用代码、接口元数据、
289
- 后端 handler 骨架,让"前端调用的函数"与"后端暴露的路由"永远同源。
290
+ 新版「契约代码生成」的**唯一权威源是 `oj` handler 上的 `.schema`(入参)+ `.response`(出参)**,
291
+ 不再有 `contract.ts` / `defineApi`(旧 DSL + zod 翻译层已退役删除)。开发者在 handler 上手写
292
+ `.schema`(JSON Schema 严格子集)与平级 `.response`(`json.ok(data)` 的 data 载荷),
293
+ `oj v0.1.73+` 的 `oj openapi` 运行期内省并产出 `openapi.json`(OpenAPI 3.1,落工程根);
294
+ `ojm api` 消费它生成前端 `__client`。整条链路:
290
295
 
291
296
  ```
292
- contract.ts ──evaluate──► IR ──emit──► 四产物 + stub
297
+ oj handler .schema(入参,手写)+ .response(出参,平级)——唯一权威源
298
+ │ oj 内省(含组级 .schema / .response 继承合并)
299
+ ▼
300
+ oj openapi -c config -d api/src -b /api -o openapi.json → OpenAPI 3.1
301
+ │
302
+ ▼
303
+ ojm api → irFromOpenapi → emitZodFromJsonSchema / emitClient
304
+ │
305
+ ▼
306
+ web/<模块>/__client/{api.ts, api.schemas.ts}(+ stub / mock)
293
307
  ```
294
308
 
295
- ### 3.2 契约 discovery 与产物落点
309
+ `openapi.json` 即权威文档源,`ojm api --docs` 直接聚合它渲染文档站。`.schema` / `.response`
310
+ 写法、与 zod 表达力对照等后端侧细节见 `oj-api-dev` skill 的手册(`ojm init` 已装机在
311
+ `.claude/skills/oj-api-dev/`,随 oj 二进制更新)的「入参与出参契约 `.schema`」章节;
312
+ 本文只讲它到前端这一侧的对接。
296
313
 
297
- | 工程形态 | 契约位置 | 产物流向 |
314
+ > 版本要求:平级 `.response` 声明位自 **oj v0.1.72** 起支持;`oj openapi` 把 `del` 动词归一为
315
+ > `delete` 操作键自 **v0.1.73** 起(含 `del` 端点的工程须 ≥ 0.1.73,否则 `--check` 必漂移)。
316
+ > 旧 `.schema.response` 写法继续生效(存量零破坏),新工程一律用平级 `.response`。
317
+
318
+ > 旧 `contract.ts` / `defineApi` 工程:翻译层已删除,`ojm api` **不再兼容读取** `contract.ts`
319
+ > (找不到 `openapi.json` 即报错)。迁移方法见 **§3.10**(本手册自包含迁移指南)。
320
+
321
+ ### 3.2 产物落点(新口径)
322
+
323
+ | 工程形态 | 权威源 | 产物流向 |
298
324
  |---|---|---|
299
- | **uni-dev**(前后端一体,推荐) | `api/src/<模块>/contract.ts` | `api.ts` / `api.schemas.ts` → `web/src/<模块>/client/`;`routes.json` / `openapi.yaml` → 契约旁;stub → `api/src/<模块>/<路径>/api.ts` |
300
- | 纯前端 | `web/src/<模块>/client/contract.ts` | 四产物全部落契约同目录,无 stub |
325
+ | **uni-dev**(前后端一体,推荐) | 后端 `api/src/<模块>/**/api.ts` 上的 `.schema` | `web/src/<模块>/__client/{api.ts, api.schemas.ts}`(由 `ojm api` 消费 `openapi.json` 生成);stub → `api/src/<模块>/<路径>/api.ts`(未实现 handler 时补骨架) |
326
+ | 纯前端 | 仅消费他人 `openapi.json` | `__client/` 全部落前端侧,无 stub |
301
327
 
302
- **硬约束**:uni-dev 形态下每个端点的 `apiPrefix` 必须**字面等于**后端目录名
303
- (`api/src/order/contract.ts` → `/order`),不符即人话报错。
328
+ `openapi.json` 是项目级单文件、含全部模块,由 `oj openapi` 产出,ojm 不再自产 `routes.json` /
329
+ `openapi.yaml`。
330
+
331
+ **硬约束**:模块目录名即 API 前缀来源——`api/src/order/**` → 线上前缀 `/order`(与旧 `apiPrefix`
332
+ 字面相等约束同源,只是现在由目录名推导,不再手写字段)。不符即人话报错。
304
333
 
305
334
  > 工程里有两个 "web":前端 `web/`,与后端 `api/src/web/`(提供 user-info 等内置接口)。
306
335
  > 豁免清单里的 `"modules": ["web"]` 指的是**后者**。
307
336
 
308
- ### 3.3 defineApi 字段
337
+ ### 3.3 handler `.schema`(入参)+ `.response`(出参)写法(JSON Schema 子集)
309
338
 
310
- ```ts
311
- import { defineApi, z } from "@oj-module/runtime/contract";
312
-
313
- const book = z.object({ id: z.number(), title: z.string() });
314
-
315
- export const listBooks = defineApi({
316
- apiPrefix: "/books", // 必填,"/" 开头
317
- route: "/list", // 必填,"/" 开头,相对 apiPrefix
318
- method: "GET", // 缺省 "GET"
319
- query: z.object({ keyword: z.string().optional() }),
320
- data: z.object({ list: z.array(book), total: z.number() }),
321
- ignoreLoading: true, // 该端点不触发全局加载条
322
- description: "图书列表", // 进 OpenAPI
323
- });
324
-
325
- // 另两个按需字段:
326
- // params: z.object({ id: z.number() }) —— 与 route 的 {id} 参数段一一对应
327
- // body: z.object({ title: z.string() }) —— 请求体(JSON)
328
- // response: "raw" —— 二进制/非信封的例外通道,与 data 互斥
329
- ```
339
+ > 旧 `defineApi` / `contract.ts` 已删除。现在契约直接写在 oj handler 的动词上——这是唯一权威源。
330
340
 
331
- **上传文件(multipart/form-data)** —— `form`,与 `body` 互斥:文本字段走
332
- `form.fields`(zod,字段名即表单部件名),文件部件只声明 `name` / `required` /
333
- `multiple`(二进制进不了 JSON Schema,也不进白名单):
341
+ 每个动词(含嵌套组动词、子目录 handler)挂 `.schema` 放**入参**(三通道,对应旧
342
+ `params/query/body`);**出参**走与 `.schema` 平级的 `.response`(oj v0.1.72+,对应旧
343
+ `defineApi` 的 `data`):
334
344
 
335
345
  ```ts
336
- export const uploadAvatar = defineApi({
337
- apiPrefix: "/personal-center",
338
- route: "/upload",
339
- method: "POST",
340
- form: {
341
- fields: z.object({ note: z.string().optional() }),
342
- files: [{ name: "avatar", required: true }, { name: "gallery", multiple: true }],
346
+ // api/src/order/list/api.ts(oj v0.1.72+ 写法,.schema / .response 手写,勿由工具生成)
347
+ function get() {
348
+ // ...业务逻辑,json.ok(...) / json.fail(...)
349
+ }
350
+ get.schema = {
351
+ query: { // 入参 query(旧 defineApi 的 query)
352
+ type: "object",
353
+ required: ["page"],
354
+ properties: { page: { type: "number", minimum: 1 } },
343
355
  },
344
- data: z.string(), // 上传端点照例可以带响应 data
345
- });
356
+ };
357
+ get.response = { // 出参 data 载荷(旧 defineApi 的 data),与 .schema 平级
358
+ type: "object",
359
+ properties: { list: { type: "array", items: { type: "object" } }, total: { type: "number" } },
360
+ };
346
361
 
347
- // 调用形态(生成 client 内部组装 FormData):
348
- // await uploadAvatar({ fields: { note: "合同扫描件" },
349
- // files: { avatar: file, gallery: [f1, f2] } });
362
+ export default { get };
350
363
  ```
351
364
 
352
- **不要手写 `content-type`**:运行时(ky)见到 `FormData` 会自己补
353
- `multipart/form-data; boundary=…`。后端 handler 侧用 `http.files`(元数据)/`http.file(i)`
354
- (字节)读文件,文本字段写入 `http.body.<name>`。
355
-
356
- 文本字段的取值:标量按 `String()` 发;**对象/数组自动 `JSON.stringify`**(多部件文本只能是
357
- 字符串,直接 `String({a:1})` 会静默变 `[object Object]`)。后端从 `http.body.<name>` 拿到的是
358
- JSON 文本,需要结构就自己 `JSON.parse`(或干脆用单独的 JSON 端点发结构化数据)。
359
-
360
- **定义期就会抛错**的写法(早炸好过晚炸):
361
-
362
- - `apiPrefix` / `route` 不以 `/` 开头;`route` 含 `../`、`./`、`\`;
363
- - 参数段混字面:`{id}.json` 非法(matchit 约束——参数段必须**整段**为 `{name}` 或 `{*name}`,要前后缀就拆成静态多段);
364
- - `data` 与 `response: "raw"` 同时出现;
365
- - `response` 取 `"raw"` 以外的值;
366
- - `method: "OPTIONS"`(不在支持范围);
367
- - `method: "HEAD"` 且声明了 `data`(HEAD 无响应体);
368
- - `form` 与 `body` 同时出现;`form` 既无 `fields` 也无 `files`;`form.files` 缺 `name` 或字段名重复(重名要用 `multiple: true`);`form.fields` 不是 `z.object`。
365
+ `.schema` 入参三通道:
369
366
 
370
- `data` 过**类型白名单**:不返回数据的接口**直接省略 `data`**,
371
- 写 `z.null()` 或 transform / refine 会被拒。校验放后端做。
367
+ | 通道 | 含义 | 对应旧字段 |
368
+ |---|---|---|
369
+ | `params` | 路径参数(与 route 的 `{id}` 段对应) | `params` |
370
+ | `query` | URL query | `query` |
371
+ | `body` | JSON 请求体 | `body` |
372
+
373
+ `.response`(平级)语义与旧 `.schema.response` 完全相同:响应 `data` 载荷(信封
374
+ `code/msg/data` 由 oj 自动包裹),对应旧 `data`。**优先级**(高 → 低):动词 `.response` >
375
+ 组 `.response` > 动词 `.schema.response` > 组 `.schema.response`;组级沿嵌套层层继承。
376
+ ⚠️ 保留键:组对象上的 `response` 被当声明读走,不能用来命名嵌套子组(与 `schema`/`route` 相同)。
377
+
378
+ **`.schema` 是 oj JSON Schema 白名单子集**,关键字仅限:
379
+ `type / required / properties / items / additionalProperties / minimum / maximum /
380
+ minLength / maxLength / pattern / enum / nullable / minItems / maxItems / default`。
381
+ 白名单外关键字(`$ref` / `oneOf` / `format` / `transform` 等)**fail-fast**,不静默降级。
382
+
383
+ - **`pattern` 是 Rust regex**:oj 运行期用于校验;ojm 生成前端 zod 时**不进运行时**
384
+ (跨语言语义漂移 + ReDoS 风险),仅发 `// @oj-warn` 注释。需严格校验请下移到 handler。
385
+ - **`default` 不射前端 zod `.default()`**:oj 运行期不为缺参补默认值;前端若补会让可选字段
386
+ 变必填、掩盖契约漂移。仅发 `// @oj-note` 注释。
387
+ - **`nullable` / 类型数组**:`{ type: ["string", "null"] }` → 前端 `z.string().nullable()`;
388
+ 表达 union(如 `z.union([z.string(), z.number()])`)用类型数组 `["string", "number"]`
389
+ (oj 白名单不支持 `oneOf`/`anyOf`)。
390
+ - **`additionalProperties: false`** → 前端 `.strict()`;`true` → `.catchall(z.unknown())`。
391
+ - 所有动态键名 / 枚举值经 `JSON.stringify` 字面量注入,防注入。
392
+
393
+ **端点方法名 / 类型名来自 `operationId`**(oj 由「去 base 子路径 + 方法」派生,非字母数字转 `_`):
394
+ `order/list` + `get` → `order_list_get` → 前端 `orderListGet`、`OrderListGetData`、
395
+ `OrderListGetQuery` 等。这与旧 `defineApi` 的人类命名(`listBooks`)不同,迁移时用「逐字节比对」
396
+ 识别为丢失面(见 §3.10)。
397
+
398
+ **上传文件(multipart/form-data)**:当前 `ojm api` 的 openapi 流水线**不生成** form 通道的
399
+ 前端 client(文件部件是二进制,不进 JSON Schema)。惯例走 antd `Upload` **直连**后端
400
+ (不经 JSON client),handler 用 `http.files` / `http.file(i)` 读字节,仅平级声明
401
+ `.response`(见 `api/src/personal-center/upload/api.ts` 范本)。
402
+
403
+ **不要手写 `content-type`**:运行时(ky)见到 `FormData` 会自己补 `multipart/form-data;
404
+ boundary=…`,手写反而丢 boundary。
405
+
406
+ > 不返回数据的接口:**省略 `.response`** 声明(而非写空对象)。校验一律放后端(`.schema` 在 oj
407
+ > 侧于 handler 调用前校验入参,违反即 `400`,handler 不被调用)。
372
408
 
373
409
  ### 3.4 生成物使用契约
374
410
 
375
- 生成的 `web/src/<模块>/client/api.ts` 导出:
411
+ 生成的 `web/src/<模块>/__client/api.ts` 导出:
376
412
 
377
413
  - 裸请求函数(`fetchNotifications`、`markRead`…)——**直接 import 调用**;
378
414
  - `API_PREFIX` 常量——契约前缀的唯一真源;
@@ -393,12 +429,12 @@ const booksClient = createBooksClient(ctx);
393
429
  - 忘创建就调裸函数 → 人话报错「请求未绑定」,指路 `create<Module>Client(ctx)`。
394
430
  - 重复构造幂等安全;`onInit` 每模块只跑一次(HMR / StrictMode 不重复执行)。
395
431
 
396
- > **生成 ≠ 使用。** `ojm api` 为**每个** `api/src/<模块>/contract.ts` 生成
397
- > `web/src/<模块>/client/`,**不看有没有人 import**。所以「契约在、`client/`
398
- > 目录却不在」只有一种解释:那个模块的 `client/` 从没生成过(模板里的
399
- > personal-center 就是这样——它上传走 antd Upload 直连 `uploadApi.action`,
400
- > 生成出来也没人用,故模板没签回)。你在自己工程跑一次 `ojm api`,它就会出现。
401
- > 判断某模块**要不要用** client 的标准只有一个:代码里有没有人 import `./client/api`。
432
+ > **生成 ≠ 使用。** `ojm api` 为 **openapi.json 里出现的每个模块**(`tags[0]`)生成
433
+ > `web/src/<模块>/__client/`,**不看有没有人 import**。所以「handler 在、`__client/`
434
+ > 目录却不在」只有一种解释:那个模块的 `__client/` 从没生成过(模板里的
435
+ > personal-center 上传走 antd Upload 直连,不经过 JSON client,故没人 import 它的 `__client`)。
436
+ > 你在自己工程跑一次 `ojm api`,它就会出现。
437
+ > 判断某模块**要不要用** client 的标准只有一个:代码里有没有人 import `./__client/api`。
402
438
  > 有就在 onInit 里 `create<Module>Client(ctx)` 一次;没有就什么都不写,
403
439
  > 生成了但没人 import 也不报错。
404
440
 
@@ -409,68 +445,77 @@ const booksClient = createBooksClient(ctx);
409
445
 
410
446
  ### 3.5 生成物勿手改
411
447
 
412
- `client/api.ts`、`client/api.schemas.ts`、`routes.json`、`openapi.yaml`
413
- **重跑即覆盖**(eslint 已忽略这些路径)。改需求 = 改契约,重跑 `ojm api`。
448
+ `__client/api.ts`、`__client/api.schemas.ts` **重跑即覆盖**(eslint 已忽略这些路径)。
449
+ 改需求 = 改 handler 的 `.schema`(或路由),重跑 `oj openapi` + `ojm api`。
414
450
 
415
451
  **唯一例外:stub。** `api/src/**/api.ts` 的 stub 文件头带指纹
416
452
  (`// ojm-api:stub <name> sha256:...`)。人碰过的 stub,工具**永不写永不删**——
417
- 实现 handler 就是在 stub 里填业务逻辑。
453
+ 实现 handler 就是在 stub 里填业务逻辑(`.schema` 由开发者手写,工具不注入)。
418
454
 
419
455
  生成是**幂等**的:内容逐字节比对,无变化不写盘,重复跑 `ojm api` 的 git diff 为空。
420
456
 
421
- 命名规则:生成的函数名 = 契约导出名(`listBooks`);类型名 = 导出名 PascalCase +
422
- `Query` / `Body` / `Form` / `Data`(`ListBooksQuery`、`CreateBookBody`、`UploadAvatarForm`、`ListBooksData`)。
457
+ 命名规则:生成的函数名 = `camelCase(operationId)`(如 `orderListGet`);类型名 =
458
+ PascalCase(operationId) + `Query` / `Params` / `Body` / `Data`
459
+ (`OrderListGetQuery`、`OrderListGetData` 等)。这与旧 `defineApi` 的人类导出名(`listBooks`)
460
+ 不同。
423
461
 
424
462
  ### 3.6 命令
425
463
 
426
464
  ```bash
427
- pnpm exec ojm api # 生成
428
- pnpm exec ojm api --check # 四重对账(只读,永不修文件)
429
- pnpm exec ojm api --docs # 聚合 OpenAPI → redoc 静态站
465
+ # 1) oj 产出 OpenAPI 规范(base 从 config 的 api 挂载前缀读;dev 用 -d api/src 内省)
466
+ oj openapi -c api/config.yaml -d api/src -b /api -o openapi.json
467
+ # 2) ojm 消费生成前端 __client(按 tags[0] 分组到各模块)
468
+ pnpm exec ojm api
469
+ # 3) 校验同步 / route 双向对账(只读)
470
+ pnpm exec ojm api --check
471
+ # 4) 聚合 openapi.json → redoc 静态文档站
472
+ pnpm exec ojm api --docs
430
473
  ```
431
474
 
475
+ > `oj openapi` 是 **oj 透传命令**(ojm 自动补 `-c api/config.yaml`),参数照 oj 原版语义。
476
+ > 找不到 `openapi.json` 时 `ojm api` 直接报错指引先跑 `oj openapi`(旧 `contract.ts` 不兼容读取)。
477
+
432
478
  `--check` 对四件事(只读,绝不写盘、绝不修文件):
433
479
 
434
- 1. **生成物同步**:内存重生成 vs 磁盘逐字节 diff(含 stub 待更新检测);
435
- 2. **route 双向对账**:AST 扫后端 `api.ts`(default 导出方法名 + `.route = "..."`
436
- 赋值)vs 契约路由表——契约未实现 warn、handler 未登记 error、参数段不一致 error;
437
- 3. **`routes.js` diff**:`oj build` 产物路由表 vs `routes.json`;无 dist 给提示不判违规;
480
+ 1. **生成物同步(artifact-stale)**:内存重跑 `ojm api` vs 磁盘逐字节 diff(`__client/{api.ts,api.schemas.ts}`);
481
+ 2. **route 双向对账**:生成物端点 ⊄ handler 已实现表 → `route-not-implemented`;handler 表 ⊄ 生成物 → `route-unregistered`(参数段不一致 error);
482
+ 3. **`routes.js` diff**:`oj build` 产物路由表 vs 生成物;无 dist 给提示不判违规;
438
483
  4. **WS 路由鉴权**:目录里的 `ws.ts` 会产生 `GET {base}/<模块>/<路径>/ws`——oj v0.1.30 起
439
484
  WS 握手也过鉴权守卫,不在 `auth.anonymous_paths` 里的 WS 会让未带凭据的客户端握手 401
440
485
  (表现为「文件在却连不上」)。只 warn(带 Bearer/Cookie 的受保护 WS 是正当用法);
441
486
  读不到 config 就不判。
442
487
 
443
- `ojm dev` 内置契约 watch:契约文件变更 → 自动重跑生成 → 产物写入 `web/` 树
444
- 触发模块重建 + 浏览器刷新。
488
+ `ojm dev` 内置 `openapi.json` 变更 watch:规范变更 → 自动重跑 `ojm api` → 产物写入 `web/` 树
489
+ 触发模块重建 + 浏览器刷新。改完 handler `.schema` 需先重跑 `oj openapi` 更新规范。
445
490
 
446
- > ⚠️ **新模块目录的诞生不在 watch 范围**——新建模块的 `contract.ts` 要先重启
447
- > `ojm dev`(目录注册发生在启动期),之后该模块的契约改动才走热更。
491
+ > ⚠️ **新模块目录的诞生不在 watch 范围**——新建后端模块目录要先重启
492
+ > `ojm dev`(目录注册发生在启动期),之后该模块的 `.schema` 改动才走热更。
448
493
 
449
- ### 3.7 后端入参契约(`.schema`,v0.1.44)
494
+ ### 3.7 后端入参契约(`.schema`,v0.1.44+ 手写)
450
495
 
451
- 契约里声明的 `params` / `query` / `body` 不只生成前端类型与校验——`ojm api` 还会把
452
- 它们落成后端 handler 上的 `.schema`,由 oj 在 **JS 之前**校验:违反即 `400` 信封,
453
- handler 根本不会被调用。于是**一份契约同时给出**:前端 client 与类型、前端 DEV 期 zod
454
- 校验、后端入参校验、OpenAPI 文档。
496
+ `.schema` 是**开发者手写**在 handler 动词上的(见 §3.3),oj 在 **JS 之前**校验入参:违反即
497
+ `400` 信封,handler 根本不会被调用。于是**一份 `.schema` 同时给出**:oj 运行期入参校验、
498
+ 前端 DEV 期 zod 校验、OpenAPI 文档——前端 client 类型则来自 `oj openapi` 产出的 `openapi.json`。
455
499
 
456
500
  ```ts
457
- // api/src/order/list/api.ts(ojm api 生成,勿手改正文外的部分)
501
+ // api/src/order/list/api.ts(.schema 由开发者手写,oj 运行期据此校验入参)
458
502
  function get(): void {
459
503
  json.ok({ /* TODO */ });
460
504
  }
461
505
  get.schema = {
462
- "query": {
463
- "type": "object",
464
- "properties": { "page": { "type": "number", "minimum": 1 } },
465
- "required": ["page"]
466
- }
506
+ query: {
507
+ type: "object",
508
+ properties: { page: { type: "number", minimum: 1 } },
509
+ required: ["page"],
510
+ },
467
511
  };
468
512
  export default { get };
469
513
  ```
470
514
 
471
515
  开关在 `api/config.yaml` 的 `server.schema_validation`(默认 `true`,置 `false` 为逃生门)。
472
- 完整裁剪规则与降级表(白名单无 `format`/`oneOf`、Rust regex 不支持零宽断言、
473
- params/query 只允许扁平标量等)都在生成期拦下或告警,详情见 §3.3 的「定义期就会抛错」清单。
516
+ 白名单裁剪规则与降级(无 `format`/`oneOf`、Rust regex 不支持零宽断言、params/query 只允许扁平
517
+ 标量等)在 `oj openapi` 内省期或 ojm 生成期 fail-fast / 告警,详情见 §3.3 与 `oj-api-dev` skill
518
+ 手册(`.claude/skills/oj-api-dev/`)。
474
519
 
475
520
  ### 3.8 豁免清单 `api/.ojm-api-exempt.json`
476
521
 
@@ -486,6 +531,71 @@ params/query 只允许扁平标量等)都在生成期拦下或告警,详情
486
531
 
487
532
  文件缺失或损坏时静默按空豁免处理。cli ≥ 0.1.5 的 `ojm init` 已内置该文件。
488
533
 
534
+ ### 3.9 DDD 判据速查(oj v0.1.64 嵌套 handler 组)
535
+
536
+ | 场景 | 形态 |
537
+ |---|---|
538
+ | 同聚合多动词 | 单文件嵌套组(组名 = 路径段,`const login = { async post() {...} }`) |
539
+ | 不同聚合 | 各自目录/文件 |
540
+ | ≤3 动词单一聚合 | 可平铺单文件 |
541
+
542
+ 规则与红线:组名即路径段(`login` → `POST /auth/login`,路由不变);嵌套 ≤4 层;
543
+ `route`/`schema` 是保留键;**`del` 是保留动词键,不能作组名**(删通知这类端点目录
544
+ `del/` 是唯一例外形态);组名可连字符(`"push-broadcast"`),checker 已支持字符串字面量键对账。
545
+
546
+ 反例信号:文件过胖 = 聚合过胖,**拆聚合而不是回退动词目录**;出现动词子目录
547
+ (`read/api.ts` 这类)说明是旧形态,按上表 reshape。
548
+
549
+ ### 3.10 从旧 `contract.ts` / `defineApi` 工程迁移
550
+
551
+ 适用于 0.1.20 之前、用「`api/src/<模块>/contract.ts`(`defineApi` + zod)」声明契约的工程。
552
+ 翻译层已删除,`ojm api` 不再读取 `contract.ts`——不迁移则找不到 `openapi.json` 直接报错。
553
+
554
+ **前置条件**
555
+
556
+ - `oj` ≥ **0.1.73**(`oj openapi` 子命令 + 平级 `.response` + `del`→`delete` 归一;低版本会丢出参类型或产出非法方法键)
557
+ - `@oj-module/cli` / `@oj-module/runtime` ≥ **0.1.20**(lockstep)
558
+ - `api/config.yaml` 站点挂载已是顶层 `mounts:`(oj v0.1.58 起旧键 fail-fast)
559
+
560
+ **字段映射(旧 `defineApi` → 新声明位)**
561
+
562
+ | 旧 `defineApi` 字段 | 新声明位 | 说明 |
563
+ |---|---|---|
564
+ | `route` / `method` | handler 所在目录/文件名 + 动词函数名 | 路由由目录镜像推导,不再手写(AC-D9) |
565
+ | `params` | `.schema.params` | 路径参数,与 route 的 `{id}` 段对应 |
566
+ | `query` | `.schema.query` | URL query |
567
+ | `body` | `.schema.body` | JSON 请求体 |
568
+ | `data` | **平级 `.response`** | 出参 data 载荷(信封 `code/msg/data` 由 oj 自动包裹) |
569
+ | `form` | —(丢失面) | 文件上传,当前流水线不生成前端 client,改 antd `Upload` 直连 |
570
+ | `raw` | —(丢失面) | `openapi.json` 不携带 raw 标记,需人审 |
571
+ | `ignoreLoading` | —(丢失面) | 透传标记,需人审 |
572
+
573
+ **迁移步骤**
574
+
575
+ 1. **盘点**:列出每个 `contract.ts` 的全部端点(route / method / 入参 / data / form / raw / ignoreLoading)。
576
+ 2. **翻译**:对每个端点在对应 handler 动词上写 `.schema`(入参,JSON Schema 白名单子集)+
577
+ 平级 `.response`(出参)。zod → JSON Schema 手工对照:`z.string()` → `{type:"string"}`、
578
+ `z.number()` → `{type:"number"}`、`z.enum([...])` → `{type:"string", enum:[...]}`、
579
+ `z.union([z.string(),z.number()])` → 类型数组 `["string","number"]`、`.nullable()` →
580
+ `["string","null"]`;`refine`/`transform` 必须下移到 handler 校验(丢失面)。
581
+ 3. **生成规范**:`oj openapi -c api/config.yaml -d api/src -b /api -o openapi.json`
582
+ (项目级单文件、含全部模块;组级 `.schema`/`.response` 已由 oj 内省期合并)。
583
+ 4. **生成 client**:`ojm api` → `web/src/<模块>/__client/{api.ts, api.schemas.ts}`。
584
+ 5. **改前端消费点**:函数名从旧人类命名(`fetchNotifications`)改为
585
+ `camelCase(operationId)`(`notificationNotificationsGet`)——用「逐字节比对」识别,
586
+ 逐个改 import 与调用;`tsc --noEmit` 应收敛到 0 错误。
587
+ 6. **删旧文件**:删除各 `contract.ts`,以及旧流水线产物 `api/src/<模块>/routes.json`、
588
+ `api/src/<模块>/openapi.yaml`(ojm 不再自产;`api/docs/openapi.yaml` 仅 `--docs` 时生成,保留)。
589
+ 7. **对账**:`ojm api --check` 应 `0 error / 0 warn`;内置端点(`auth/*`、`web/*`、
590
+ `notifications`)经 `api/.ojm-api-exempt.json` 豁免(init 已内置)。
591
+
592
+ **迁移后自检**
593
+
594
+ - [ ] 工程根有 `openapi.json`,`--check` 零漂移
595
+ - [ ] 每个 `__client` 的 `XxxData` 类型非 `unknown`(出参声明生效)
596
+ - [ ] 无 `contract.ts` / per-module `routes.json` / `openapi.yaml` 残留
597
+ - [ ] `ojm dev` 起服后接口联调通过(登录 / 列表 / 写操作 / 401)
598
+
489
599
  ---
490
600
 
491
601
  ## §4 接管框架内置功能(provider)
@@ -539,22 +649,34 @@ Provider 就是:「这块别用框架自带的了,换成我的实现。」
539
649
 
540
650
  ### 4.4 案例:接管通知功能(标准四文件)
541
651
 
542
- **① 契约**(`api/src/notification/contract.ts`)——见 §3.3 与 §3.4。
652
+ **① handler `.schema`**(`api/src/notification/api.ts` 上各动词挂的 `.schema`)——见 §3.3 与 §3.4。
543
653
 
544
- **② handler**(`api/src/notification/notifications/read/api.ts`):
654
+ **② handler**(`api/src/notification/api.ts`,模块根嵌套组——组名 = 路径段,oj v0.1.64):
545
655
 
546
656
  ```ts
547
- export default {
548
- async post() {
549
- const body = (http.body ?? {}) as { id?: unknown };
550
- const id = Number(body.id);
551
- if (!Number.isFinite(id)) { json.fail(400, "id is required"); return; }
552
- await db.query("UPDATE notifications SET is_read = 1 WHERE id = ?", [id]);
553
- json.ok(null);
657
+ const notifications = {
658
+ // POST /api/notification/notifications/read —— 单条通知已读。
659
+ // body.id 为命名空间串 "u:<id>"(按人)/"b:<id>"(广播);按人项校验归属,非本人 403。
660
+ read: {
661
+ async post() {
662
+ const uid = http.user?.id;
663
+ if (!uid) { json.fail(401, "unauthorized"); return; }
664
+ const body = (http.body ?? {}) as { id?: unknown };
665
+ const id = String(body.id ?? "");
666
+ if (!id) { json.fail(400, "id is required"); return; }
667
+ const ok = await markRead(id, uid);
668
+ if (!ok) { json.fail(403, "forbidden"); return; }
669
+ json.ok(null);
670
+ },
554
671
  },
672
+ // read-all / clear 子组同理(全部已读 / 清空,按当前用户)
555
673
  };
674
+
675
+ export default { notifications, /* push, "push-broadcast" 等同理 */ };
556
676
  ```
557
677
 
678
+ > reshape 判据与嵌套组规则(保留键、层级上限)见 §3.9「DDD 判据速查」。
679
+
558
680
  **③ 生成**:`pnpm exec ojm api`
559
681
 
560
682
  **④ 对接**(`entry.ts`):
@@ -562,7 +684,7 @@ export default {
562
684
  ```ts
563
685
  import type { NotificationsApiProvider } from "@oj-module/runtime";
564
686
  import { defineModule } from "@oj-module/runtime";
565
- import { createNotificationClient } from "./client/api";
687
+ import { createNotificationClient } from "./__client/api";
566
688
 
567
689
  export default defineModule({
568
690
  name: "notification",
@@ -584,13 +706,54 @@ export default defineModule({
584
706
  });
585
707
  ```
586
708
 
709
+ **⑤ 跨模块发射原语(任何后端模块都能推通知)**
710
+
711
+ 通知不是「只有通知模块自己用」的功能——下单、审核、@我等其它模块也需要给某个人推一条提醒。
712
+ 把发射逻辑收敛成 `api/src/notification/_shared/notify.ts` 里的纯函数原语,其它模块**相对导入直接调用,
713
+ 无需走 HTTP**:
714
+
715
+ ```ts
716
+ // 在其它模块的 handler 里
717
+ import { pushNotice } from "../../notification/_shared/notify";
718
+
719
+ export default {
720
+ async post() {
721
+ // ...业务逻辑...
722
+ const id = await pushNotice({ userId: 2, title: "你的订单已发货", message: "单号 SO123" });
723
+ json.ok({ id }); // 返回命名空间 id "u:<id>"
724
+ },
725
+ };
726
+ ```
727
+
728
+ 原语清单(`_shared/notify.ts`,设计文档 §4.3 / §6):
729
+
730
+ | 原语 | 作用 | 返回 |
731
+ |---|---|---|
732
+ | `pushNotice({ userId, title, message, link?, avatar?, category? })` | 给指定用户推一条按人通知(一行 `notifications`) | 命名空间 id `"u:<id>"` |
733
+ | `pushBroadcastNotice({ title, message, ... })` | 广播一条(一行 `notifications_broadcast`,全员可见、一行存储) | 命名空间 id `"b:<id>"` |
734
+ | `delNotice(id, userId)` | 删按人项(校验归属,非本人返回 `false`);广播项仅删本人回执 | `boolean`(是否命中) |
735
+ | `noticesFor(userId)` | 合并列表:广播 LEFT JOIN 回执算 `is_read` UNION 按人 `WHERE user_id=?` | `NoticeItem[]` |
736
+ | `markRead(id, userId)` | 按人项校验归属(非本人 `false` → 端点映射 403);广播项 upsert 回执 | `boolean` |
737
+ | `markAllRead(userId)` / `clearNotices(userId)` | 全部已读 / 清空(按当前用户) | `void` |
738
+
739
+ 约定:
740
+ - **原语不做业务判断**(不判空间/角色/可见性),收件人过滤是发送方责任(`pushNotice` 的 `userId` 谁都能传,按人隔离靠存储 `user_id` 保证)。
741
+ - **后端不能 `import '@oj-module/runtime'`**:oj 解析裸 specifier 以 `api/` 为项目根并钳制不越界(越界报
742
+ `escapes project root`);`@oj-module/runtime` 符号链接在 `packages/runtime`,属项目根之外。原语必须落在
743
+ `api/src` 内(相对导入);类型与 runtime 的 `@oj-module/runtime/notify` 保持同形即可,共享契约类型单一真源在 runtime。
744
+ - **`_shared` 必须嵌进所属模块目录**(`api/src/notification/_shared/`),不能放 `api/src/_shared/`——oj 把
745
+ `api/src` 的**顶层**目录都当模块(须有 `manifest.yaml`),顶层 `_shared` 会被当成缺 manifest 的模块,
746
+ `oj serve` 直接退(`module '_shared' missing manifest.yaml`)。
747
+ - **归属校验返回 403**:跨用户 `del` / `read` 他人按人项,原语返回 `false`,端点 `json.fail(403, "forbidden")`——
748
+ 与「按人隔离」对称(A 推给 B 后 A 不可见、B 不可删/读 A 的)。
749
+
587
750
  **验证**:`pnpm dev` → `admin / 123456` 登录 → 右上角铃铛有蓝点 → 点开 →
588
751
  点任意一条蓝点消失 → 「全部已读」全清 → 「清空」列表变空。
589
752
 
590
753
  **加接口的通用流水线**:
591
754
 
592
755
  ```
593
- 写契约 contract.ts → 写 handler api.ts → ojm api 生成 client → entry.ts 里 create client + 注册
756
+ 写 handler .schema → 写 handler 业务逻辑(api.ts) → oj openapi + ojm api 生成 client → entry.ts 里 create client + 注册
594
757
  ```
595
758
 
596
759
  ### 4.5 案例:接管头像上传(不写契约的最简入口)
@@ -932,7 +1095,56 @@ useEffect(() => {
932
1095
 
933
1096
  > `window.$message` 的类型声明在工程根 `env.d.ts`——那里也放着 `ImportMeta.env` 补丁。
934
1097
 
935
- ### 6.4 `pnpm typecheck` 看不到 `api/`
1098
+ ### 6.4 页签与未保存守卫(`beforeClose`,runtime ≥ 0.1.18)
1099
+
1100
+ 页签有未保存内容时,关闭前弹确认框的完整链路:`addTab` 给页签挂 `beforeClose` 守卫,
1101
+ 五个关闭动作(单个/右/左/其他/全部)关闭前**顺序 await** 守卫,任一返回 false 或
1102
+ 抛错则**整批取消**(事务式)。守卫 await 期间页签保留、组件保持挂载,可安全弹 Modal。
1103
+
1104
+ ```tsx
1105
+ const TAB_KEY = "/demo/guard"; // 页签 key = 路由路径
1106
+ const [confirmResolver, setConfirmResolver] = useState<((ok: boolean) => void) | null>(null);
1107
+ const addTab = useTabsStore(s => s.addTab);
1108
+ const dirty = form.isFieldsTouched();
1109
+
1110
+ useEffect(() => {
1111
+ addTab(TAB_KEY, {
1112
+ key: TAB_KEY,
1113
+ label: t("demo:menu.guard"),
1114
+ // 守卫应是稳定引用(useCallback 包裹);去重按函数引用,
1115
+ // keep-alive 重挂载重复注册同一函数不会叠加
1116
+ beforeClose: dirty
1117
+ ? () => new Promise<boolean>((resolve) => {
1118
+ setConfirmResolver(() => resolve);
1119
+ })
1120
+ : null, // null 清除该页签全部守卫(含他人注册的);undefined 不动
1121
+ });
1122
+ }, [dirty, addTab, t]);
1123
+
1124
+ <Modal
1125
+ open={confirmResolver !== null}
1126
+ onOk={() => { confirmResolver?.(true); setConfirmResolver(null); }} // 放行关闭
1127
+ onCancel={() => { confirmResolver?.(false); setConfirmResolver(null); }} // 取消关闭
1128
+ >
1129
+ 有未保存的修改,确定关闭吗?
1130
+ </Modal>
1131
+ ```
1132
+
1133
+ 约定与陷阱:
1134
+
1135
+ - **守卫抛错 = 取消关闭 + console.error**(fail-safe:宁可不关门也不误关丢内容);
1136
+ 页签「怎么点都关不掉」先查控制台 `[tabs] beforeClose` 报错。
1137
+ - **批量关闭串行弹 N 个确认框是语义正确结果**(每个有未保存内容的页签都须用户裁决);
1138
+ 任一否决整批回滚。
1139
+ - home 页(`VITE_BASE_HOME_PATH`)永不可关、不在守卫范围;`resetTabs`(登出/401 下线)
1140
+ **显式绕过守卫**直接清空——业务守卫不得阻塞下线。
1141
+ - **登出/刷新后守卫消失需重挂**:守卫只活在内存 store;重新打开页签时由页面
1142
+ `useEffect` 重新注册(上面的形态天然满足)。
1143
+ - 同一 key 关闭流程进行中再次触发被忽略(per-key in-flight 去重),双击不会弹两个框。
1144
+
1145
+ 可运行示例见 playground-oj `web/src/demo/pages/guard.tsx`(表单 dirty → 关页签弹确认)。
1146
+
1147
+ ### 6.5 `pnpm typecheck` 看不到 `api/`
936
1148
 
937
1149
  工程 `tsconfig.json` 的 `include` 只有 `web`、`web.config.ts`、`global.d.ts`、`env.d.ts`,
938
1150
  **不含 `api/`**。也就是说契约与 handler 的类型错误 `pnpm typecheck` 不报,
@@ -1029,9 +1241,9 @@ export default async (path: string, html: string) => {
1029
1241
  | 在页面/组件里又 create 了一个 client | 创建只该发生在 onInit 一次 | 页面里直接 import 裸函数,不要再创建 |
1030
1242
  | 控制台「模块 A 正在为 /b 路径创建 client(越界)」 | A 创建了 B 的 client | 由 B 创建并通过 provider 提供能力 |
1031
1243
  | 改了契约,前端类型没变 | 没重新生成 | 跑 `ojm api`;生成确定,diff 应只含你改的部分 |
1032
- | `ojm api` 报「schema 超出白名单」 | 契约里写了 `z.null()` / transform / refine | 不返回数据就别写 `data`;校验放后端 |
1244
+ | `ojm api` 报「schema 超出白名单」 | 契约里写了白名单外关键字(`oneOf`/`$ref`/`format` 等) | 改用白名单子集(§3.3);union 用类型数组、nullable 用 `["string","null"]`;校验放后端 |
1033
1245
  | `ojm api` 报「apiPrefix 与目录名不符」 | 契约要求 apiPrefix 字面等于后端目录名 | 改 `apiPrefix` 或移动契约目录 |
1034
- | `ojm api` 报「没有发现契约文件」 | 目录不符发现规则 | 按 §3.2 核对;注意 `modules/`→`web/`、`api/`→`client/` 迁移 |
1246
+ | `ojm api` 报错「未发现 openapi.json」 | 没跑 `oj openapi` 生成规范 | 先 `oj openapi -c api/config.yaml -d api/src -b /api -o openapi.json`,再 `ojm api`;旧 `contract.ts` 工程须先迁移(见 §3.10) |
1035
1247
  | `--check` 报 artifact-stale | 改了契约没重跑 | 重跑 `ojm api` |
1036
1248
  | `--check` 报 handler 未登记 | 内置/手写 handler 无契约 | 补契约,或登记豁免清单 |
1037
1249
  | 豁免写了 `//` 注释后全部失效 | 严格 JSON 解析失败、静默回退 | 注释写进 `_comment` 字段 |
@@ -1067,17 +1279,16 @@ export default async (path: string, html: string) => {
1067
1279
  | 接管通知 provider(四方法全量) | `web/src/notification/entry.ts` |
1068
1280
  | 上传 provider(action + headers 方法) | `web/src/personal-center/entry.ts` |
1069
1281
  | 无页面模块(`routes: []`) | 同 `web/src/notification/entry.ts` |
1070
- | 契约写法(GET + POST、参数段) | `api/src/notification/contract.ts`、`api/src/personal-center/contract.ts` |
1071
- | handler 写法(列表 / 写入 / 上传) | `api/src/notification/notifications/api.ts`、`.../read/api.ts` |
1072
- | 生成物长什么样(含工厂) | `web/src/notification/client/api.ts` |
1282
+ | handler `.schema` 入参 + 平级 `.response` 出参写法(GET + POST、参数段) | `api/src/notification/api.ts`、模板 `packages/cli/templates/api/src/notification/api.ts` |
1283
+ | handler 写法(嵌套组列表 / 写入 / 403 归属) | `api/src/notification/api.ts`(模块根单文件,`read`/`read-all`/`clear` 子组) |
1284
+ | 生成物长什么样(含工厂) | `web/src/notification/__client/api.ts` |
1073
1285
  | 建表三件套 | `api/src/notification/{schema.yaml,manifest.yaml,migrations/}` |
1074
1286
  | 全局类型(`window.$message` 等) | 工程根 `env.d.ts` |
1075
1287
 
1076
1288
  另有:
1077
1289
 
1078
- - **后端 handler 手册**:即 `oj-api-dev` skill 的手册——仓库内已同步到
1079
- [`docs/oj-api-dev/`](../oj-api-dev/README.md)(权威源随 oj 二进制分发在
1080
- `~/.local/share/oj/devkit`,随 oj 版本更新,同步时以之覆盖本目录)。
1290
+ - **后端 handler 手册**:即 `oj-api-dev` skill 的手册——`ojm init` 已装到工程的
1291
+ `.claude/skills/oj-api-dev/`(权威源随 oj 二进制分发在 `bin/devkit/`,随 oj 版本更新)。
1081
1292
  写 handler 遇到 `json` / `db` / `kv` / `jwt` / 租户 / 测试的问题查它;
1082
1293
  站点挂载(`mounts`)、密钥信封(`secrets`)、配置变量(`vars`)、跨域(`cors`)、
1083
1294
  schema 校验(`schema_validation`)、`broker` / `tasks` / `oidc` / `ws` 等 oj 侧能力也都在这套手册里。
@@ -1088,7 +1299,7 @@ export default async (path: string, html: string) => {
1088
1299
  > `app_prefix` / `static_sites` / `app_spa_fallback`)迁到顶层 **`mounts:`** 扁平表
1089
1300
  > (一行=一个 URL 前缀+一个目录,`api`/`web` 恰好其一)。旧键出现即被 `oj serve` fail-fast 拒绝。
1090
1301
  > ojm 模板 `api/config.yaml` 已落到 `mounts:`,`ojm dev/preview` 的 SPA 深链回落由 ojm 自建静态层负责,
1091
- > 不需要 oj 的 spa。详细迁移规则、vars/secrets/cors/schema 用法见 `docs/oj-api-dev/` 手册。
1302
+ > 不需要 oj 的 spa。详细迁移规则、vars/secrets/cors/schema 用法见 `oj-api-dev` skill 手册。
1092
1303
 
1093
1304
  ---
1094
1305
 
@@ -1134,8 +1345,8 @@ export default async (path: string, html: string) => {
1134
1345
  - **终止语义**:`Ctrl+C`(SIGINT)/ `kill`(SIGTERM)/ `q` 退出时,ojm 会先关 SSE 通道、
1135
1346
  再 `SIGTERM` 停 oj 子进程、打印「后端 oj 已随前端一起终止」,不留 oj 孤儿占端口;
1136
1347
  极端情况下(3s 未退)升级 `SIGKILL`,并在进程退出时以 `kill()` 强杀作为最后手段,防止 oj 孤儿化。
1137
- - 内置契约 watch:契约文件变更 → 自动重跑 `ojm api` → 产物写入 `web/` 触发模块重建 + 刷新。
1138
- **新模块目录的诞生不在 watch 范围**——新建模块的 `contract.ts` 要先重启 `ojm dev`。
1348
+ - 内置 `openapi.json` watch:规范变更 → 自动重跑 `ojm api` → 产物写入 `web/` 触发模块重建 + 刷新。
1349
+ **新模块目录的诞生不在 watch 范围**——新建后端模块目录要先重启 `ojm dev`(目录注册发生在启动期)。
1139
1350
 
1140
1351
  #### `ojm build`
1141
1352
 
@@ -1165,7 +1376,7 @@ export default async (path: string, html: string) => {
1165
1376
 
1166
1377
  #### `ojm api [dir] [--check] [--docs] [--exempt <path>]`
1167
1378
 
1168
- 契约代码生成(详见 §3):读 `contract.ts` → 生成前端 client / 后端 stub / `routes.json` / `openapi.yaml`。
1379
+ 契约代码生成(详见 §3):消费 `openapi.json`(oj v0.1.73+ `oj openapi` 产物)生成前端 `__client` / 后端 stub。
1169
1380
 
1170
1381
  - `dir`:项目目录(缺省 `cwd`;显式目录必须先存在,否则报错)。
1171
1382
  - `--check`:**四重对账**(只读、绝不写盘):① 生成物同步 ② route 双向对账
@@ -1220,3 +1431,20 @@ export default async (path: string, html: string) => {
1220
1431
  | `ojm exec "<sql>"` | 在数据库上执行 SQL |
1221
1432
 
1222
1433
  `secret`(`keygen` / `seal` 等)**不在透传集合内**——它不需要 config,需直接 `./bin/oj secret ...`。
1434
+
1435
+ ---
1436
+
1437
+ ## 附录:章节目录
1438
+
1439
+ | 章 | 内容 | 什么时候读 |
1440
+ |---|---|---|
1441
+ | §1 工程结构 | 脚手架产物、两个模块体系、目录镜像路由、重启边界、建表三件套、迁移与 seed | 新人第一遍;改后端目录结构 |
1442
+ | §2 写前端模块 | 目录、两步注册、entry.ts 形状、路由 handle、import 红线、共享依赖钉版 | 加页面 / 加模块 |
1443
+ | §3 契约与代码生成 | `.schema` 入参 + 平级 `.response` 出参、生成物契约、命令、豁免清单、DDD 判据、**§3.10 旧 contract.ts 工程迁移** | 加接口;从旧工程迁移 |
1444
+ | §4 接管框架内置功能 | provider 机制、通知 / 上传 / 菜单三案例 | 接管通知铃、头像上传、动态菜单 |
1445
+ | §5 布局与插槽 | 内置布局、容器外壳、自定义布局、插槽、响应式、偏好 | 改页面外壳 / 适配 |
1446
+ | §6 i18n 与样式 | 命名空间 i18n、样式约定 | 加文案 / 改样式 |
1447
+ | §7 构建与发布 | build / preview、htmlTransform、verify 闸 | 发布前 |
1448
+ | §8 排查 | 五步排查法、症状速查表 | 任何「没生效」 |
1449
+ | §9 参考 | 可抄的工程内文件、oj-api-dev 手册指引 | 找范本 |
1450
+ | §10 CLI 子命令参考 | ojm 原生命令 vs oj 透传命令 | 查命令用法 |