@oj-bin/oj-aarch64-apple-darwin 0.1.13
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/README.md +52 -0
- package/devkit/README.md +29 -0
- package/devkit/SKILL.md +84 -0
- package/devkit/api-manual.md +1628 -0
- package/devkit/global.d.ts +332 -0
- package/devkit/oidc-implementation.md +177 -0
- package/devkit/oidc-integration.md +168 -0
- package/oj +0 -0
- package/package.json +10 -0
- package/plugins/aarch64-apple-darwin/libauth.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libblob-s3.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libbus-kafka.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libbus-rabbitmq.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libdb-mysql.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libdb-postgres.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libes.dylib +0 -0
- package/plugins/aarch64-apple-darwin/libkv-redis.dylib +0 -0
|
@@ -0,0 +1,1628 @@
|
|
|
1
|
+
# oj TS API 开发手册
|
|
2
|
+
|
|
3
|
+
适用版本:API 面 v0.2(二进制版本号见 `oj/Cargo.toml`)
|
|
4
|
+
|
|
5
|
+
本手册面向**用 oj 框架开发业务项目**的开发者与 AI agent:如何组织模块、编写
|
|
6
|
+
`api.ts` / `ws.ts` handler、使用注入的全局对象、写测试、配服务、构建发布与日常运维。
|
|
7
|
+
oj 是把 V8(deno_core)嵌进 Rust 的低代码后端框架:业务逻辑以 TS handler 编写,
|
|
8
|
+
运行时注入 `json` / `db` / `http` / `kv` / `blob` / `bus` / `es` 等全局对象,
|
|
9
|
+
统一以 `{code,msg,data}` 信封写回 HTTP。仓库内部实现见 `docs/dev-guide.md`,
|
|
10
|
+
部署排障细节见 `docs/ops-manual.md`。本手册随版本包 `devkit/` 一同发布;
|
|
11
|
+
API 签名以同目录 `global.d.ts` 为类型权威。
|
|
12
|
+
|
|
13
|
+
目录:1 快速开始 / 2 项目结构与模块约定 / 3 模块数据层 / 4 编写 api.ts /
|
|
14
|
+
5 导入解析 / 6 全局对象 API 参考 / 7 响应信封与错误码 / 8 鉴权与多租户 / 9 测试 /
|
|
15
|
+
10 配置 config.yaml / 11 构建与发布 / 12 运维要点 / 13 安全红线与已知限制
|
|
16
|
+
|
|
17
|
+
## 1. 快速开始
|
|
18
|
+
|
|
19
|
+
> 何时读我:第一次接触 oj,要从零跑通一个请求。
|
|
20
|
+
|
|
21
|
+
### 前置
|
|
22
|
+
|
|
23
|
+
二选一拿到可执行物:
|
|
24
|
+
|
|
25
|
+
- **发行包**:解包 `oj-v<version>.tar.gz`,得到 `oj`(主程序)、`plugins/<平台>/`
|
|
26
|
+
(cdylib 插件)、`devkit/`(本手册)。
|
|
27
|
+
- **自建**(仓库内):`cargo xtask build` —— 以 release 构建并归置
|
|
28
|
+
`bin/oj` + `bin/plugins/<host-triple>/`。注意本项目禁止 debug 构建。
|
|
29
|
+
|
|
30
|
+
### 证书必配(无逃生口)
|
|
31
|
+
|
|
32
|
+
证书校验强制开启:`server.public_key_path` 与 `server.certificate_path` 缺任一路径,
|
|
33
|
+
启动直接报错退出,没有任何 config / CLI 开关可跳过。用 `oj-cert` 工具生成三件:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cargo run -p oj-cert -- gen -o config --days 365
|
|
37
|
+
# → config/private.pem(私钥,仅签发端保管,勿放服务器)
|
|
38
|
+
# config/public.pem(公钥,放服务器)
|
|
39
|
+
# config/cert.jws (JWS 证书,放服务器)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
config 指向其中两件(公钥 + 证书):
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
server:
|
|
46
|
+
public_key_path: "./config/public.pem"
|
|
47
|
+
certificate_path: "./config/cert.jws"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 最小项目
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
myproj/
|
|
54
|
+
├── config.yaml
|
|
55
|
+
├── config/
|
|
56
|
+
│ ├── public.pem
|
|
57
|
+
│ └── cert.jws
|
|
58
|
+
└── src/
|
|
59
|
+
└── hello/
|
|
60
|
+
├── manifest.yaml
|
|
61
|
+
└── api.ts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`config.yaml`(全部字段可省,仅证书两路径必配;生产建议显式写端口):
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
server:
|
|
68
|
+
port: 9778 # 监听端口(代码默认即 9778;<1024 属特权端口)
|
|
69
|
+
public_key_path: "./config/public.pem"
|
|
70
|
+
certificate_path: "./config/cert.jws"
|
|
71
|
+
db:
|
|
72
|
+
default: "sqlite://db.sqlite" # 相对 config 所在目录;缺文件自动建空库
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`src/hello/manifest.yaml`(`name` 必须等于目录名,违反启动失败):
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
name: "hello"
|
|
79
|
+
desc: "第一个模块"
|
|
80
|
+
version: "0.1.0"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`src/hello/api.ts`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
export default {
|
|
87
|
+
get() {
|
|
88
|
+
json.ok({ hello: "oj" });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 启动与验证
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
./bin/oj server -c config.yaml --api-path src
|
|
97
|
+
# 仓库内:先 cargo xtask build 产出 bin/oj,再从仓库根执行同形命令
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`server` 按 `--api-path` 目录**自动判定模式**:目录含 `dist/manifests.yaml`(构建锁)→
|
|
101
|
+
release(跑预构建 `.js`,不转译);否则 dev(服务 `.ts` 源码,按需转译,改文件即生效)。
|
|
102
|
+
启动行会把判定结果(`dev/ts` / `release/js` / `static-only`)与模块清单、路由表写进日志。
|
|
103
|
+
**终端默认静默(`server.console_log` 缺省 false)**——启动时会打一行日志路径的提示,
|
|
104
|
+
随后一切输出只落盘;需要终端同时输出时加 `--console-log` 或配 `server.console_log: true`。
|
|
105
|
+
**例外**:启动失败的最终退出原因总是直写终端(console 关闭也不例外),便于立即调整。
|
|
106
|
+
**准入门(三态,无静默默认)**:`--api-path` 与静态站点(`server.app_path` / `--app-path`)
|
|
107
|
+
至少显式指定其一,否则退出;两者皆指定 → 都必须存在;仅指定其一 → 只启用对应功能。
|
|
108
|
+
|
|
109
|
+
验证信封返回:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl 'http://localhost:9778/v1/api/hello/'
|
|
113
|
+
# → {"code":0,"msg":"ok","data":{"hello":"oj"}}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 交付(release)跑法
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
./bin/oj build -d src -o dist # 生成 dist/<module>-<version>/ + manifests.yaml 锁 + tgz
|
|
120
|
+
./bin/oj server -c config.yaml --api-path dist
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
构建细节见第 11 章;完整可跑样例见仓库 `sample/`(`sample/README.md`)。
|
|
124
|
+
|
|
125
|
+
## 2. 项目结构与模块约定
|
|
126
|
+
|
|
127
|
+
> 何时读我:建新项目或新模块前。
|
|
128
|
+
|
|
129
|
+
### 源码树(dev 服务目录)
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
<project>/
|
|
133
|
+
├── config.yaml # 服务配置(第 10 章)
|
|
134
|
+
├── ext_boot.js # 可选,运行时创建期加载一次(扩展全局对象,§6 末)
|
|
135
|
+
├── src/ # dev 服务目录(release 用 dist/,见第 11 章)
|
|
136
|
+
│ ├── user/ # 首层子目录 = 模块名
|
|
137
|
+
│ │ ├── manifest.yaml # 模块清单(必配)
|
|
138
|
+
│ │ ├── schema.yaml # 声明式表结构(有表模块必配,第 3 章)
|
|
139
|
+
│ │ ├── migrations/ # 手写迁移 {seq:04}__{desc}[.方言].sql
|
|
140
|
+
│ │ ├── fixtures/ # 演示数据,仅 oj test / oj fixture 灌入
|
|
141
|
+
│ │ ├── _shared/validate.ts # 无 api 文件 → 纯工具代码目录,不产生路由
|
|
142
|
+
│ │ ├── account/api.ts # → {base}/user/account/
|
|
143
|
+
│ │ ├── profile/api.ts # → {base}/user/profile/
|
|
144
|
+
│ │ └── profile/detail/api.ts # → {base}/user/profile/detail/(任意深度)
|
|
145
|
+
│ └── order/
|
|
146
|
+
│ ├── manifest.yaml
|
|
147
|
+
│ └── list/api.ts # → {base}/order/list/
|
|
148
|
+
├── tests/ # L1 测试(*.test.ts,第 9 章)
|
|
149
|
+
└── node_modules/ # 裸 specifier 解析起点(第 5 章)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
约束:
|
|
153
|
+
|
|
154
|
+
- **首层子目录 = 模块**,每个必须有 `manifest.yaml`;缺失启动失败。
|
|
155
|
+
- 任意深度的子目录放 `api.ts`(dev)/ `api.js`(release)即成为一条路由;
|
|
156
|
+
没有 `api` 文件的目录不是路由,可作共享工具目录(如 `_shared/`)。
|
|
157
|
+
- 同目录可放 `ws.ts` 产生一条 WebSocket 路由(第 4 章)。
|
|
158
|
+
|
|
159
|
+
### manifest.yaml(模块清单)
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
name: "user" # 必须等于父目录名(强校验,否则启动失败)
|
|
163
|
+
desc: "用户信息相关,记录账号、地址等个人信息"
|
|
164
|
+
version: "0.1.0"
|
|
165
|
+
# tables: [account] # 有表模块必配:拥有的表清单(与 schema.yaml 双向一致 S005)
|
|
166
|
+
# deps: { user: "^0.1.0" } # 跨模块表访问声明(ownership_guard 校验依据)
|
|
167
|
+
# config: {} # 可选,本模块的其他设置
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`name` ≠ 目录名 → 启动报 `manifest name "x" != directory name "y"` 退出(防止模块名与路由脱节)。
|
|
171
|
+
|
|
172
|
+
### seed.sql(可选)
|
|
173
|
+
|
|
174
|
+
- 模块 `seed.sql` 启动时对 `default` 库重放(**三方言**;建库结构演进归
|
|
175
|
+
`migrations/` / `schema.yaml`)。
|
|
176
|
+
- 语句按 `;` 切分 → **语句内不得含分号字面量**。
|
|
177
|
+
- 幂等 INSERT 保证可重复执行(每次启动都重放):写 sqlite 惯用法
|
|
178
|
+
`INSERT OR IGNORE INTO …`,引擎按目标方言自动改写(mysql → `INSERT IGNORE`、
|
|
179
|
+
pg → 句尾 `ON CONFLICT DO NOTHING`);`OR REPLACE` / `ON CONFLICT` /
|
|
180
|
+
`ON DUPLICATE KEY` 不翻译。
|
|
181
|
+
- `oj build` 不执行 seed(构建零磁盘副作用,db 用内存库)。
|
|
182
|
+
|
|
183
|
+
### dist 产物布局(预览)
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
dist/
|
|
187
|
+
├── manifests.yaml # 模块 → 锁定版本(release 按此加载)
|
|
188
|
+
├── user-0.1.0/ # 版本目录 = <module>-<version>
|
|
189
|
+
├── user-0.1.0.tgz # 确定性发布包
|
|
190
|
+
└── … # 多版本目录可共存
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
版本目录内部结构、锁语义与发布流程见第 11 章。
|
|
194
|
+
|
|
195
|
+
## 3. 模块数据层
|
|
196
|
+
|
|
197
|
+
> 何时读我:模块要建表、改表、跨模块取数、接存量库时。
|
|
198
|
+
> 运维视角的完整 runbook 见 `docs/migration.md`;本章只写开发要点。
|
|
199
|
+
|
|
200
|
+
### 心智模型
|
|
201
|
+
|
|
202
|
+
- **声明为源**:每模块可选 `schema.yaml` 声明自己拥有的表。启动 / `oj migrate` 时自动
|
|
203
|
+
收敛到声明(**安全前向**:缺表 CREATE、缺可空列 ALTER ADD、缺索引 CREATE INDEX)。
|
|
204
|
+
- **演进靠迁移**:无法安全推导的变更(NOT NULL 列新增、疑似改名、类型变更、数据回填)
|
|
205
|
+
一律 fail-fast 并打印迁移模板——手写 `migrations/{seq:04}__{desc}[.{sqlite|mysql|postgres}].sql`。
|
|
206
|
+
- **账本记账**:`_oj_migrations(module 列区分模块)` 记录每模块已应用的迁移;带方言后缀的文件只在
|
|
207
|
+
对应方言库执行。
|
|
208
|
+
- **表归属**:schema.yaml 喂归属图(表 → 模块,同表双声明拒启 S002)与 `SchemaRegistry`
|
|
209
|
+
(`db.table()` 列白名单);跨模块表访问须 manifest `deps:` 声明。
|
|
210
|
+
|
|
211
|
+
### schema.yaml
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
tables:
|
|
215
|
+
account:
|
|
216
|
+
pk: id # 可选;主键列须在 columns 声明;联合主键写 pk: [a, b]
|
|
217
|
+
# (联合主键不支持 autoincrement;reconcile 对缺失主键列 fail-fast)
|
|
218
|
+
columns:
|
|
219
|
+
id: { type: integer, autoincrement: true }
|
|
220
|
+
name: { type: text, null: false }
|
|
221
|
+
role: { type: text }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- 列类型最小集:`integer | bigint | text | boolean | double | blob`;`null` 缺省可空。
|
|
225
|
+
- 与 manifest `tables:` 双向一致(S005):声明了表就要进 manifest,反之亦然。
|
|
226
|
+
|
|
227
|
+
### 三层 SQL 文件
|
|
228
|
+
|
|
229
|
+
| 文件 | 时机 | 纪律 |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `migrations/{seq:04}__{desc}[.方言].sql` | 启动(auto)/ `oj migrate` | 只前向;账本 `_oj_migrations(module 列区分模块)`;序列空洞/乱序 S007 报错 |
|
|
232
|
+
| `seed.sql`(模块级) | 每次启动重放 | 幂等 `INSERT OR IGNORE`;按 `;` 切分,语句内不得含分号字面量(S006) |
|
|
233
|
+
| `fixtures/*.sql` | 仅 `oj test` / `oj fixture` 灌入 | 演示/测试数据,不进 release 产物 |
|
|
234
|
+
|
|
235
|
+
### 命令
|
|
236
|
+
|
|
237
|
+
| 命令 | 用途 |
|
|
238
|
+
|---|---|
|
|
239
|
+
| `oj migrate -c config.yaml -d <dir>` | 应用待执行迁移(`--baseline`:存量库接入,≤head 记为已应用不执行) |
|
|
240
|
+
| `oj schema diff -c config.yaml -d <dir>` | 声明 vs 实库对账(D001 漂移 / D002 未声明表),只读,漂移 exit 1 |
|
|
241
|
+
| `oj build --check` | 只跑结构检查 S001–S007 不落盘(CI 门禁) |
|
|
242
|
+
|
|
243
|
+
### 门禁配置(第 10 章 server 表)
|
|
244
|
+
|
|
245
|
+
- `server.migrate_on_start`:`auto`(dev 默认,启动即应用)/ `verify`(release 默认,
|
|
246
|
+
账本落后拒启 M004,报错附 `oj migrate` 命令)/ `off`。
|
|
247
|
+
- `server.ownership_guard`:`warn`(默认,跨模块表访问仅告警)/ `deny`(未声明 deps
|
|
248
|
+
拒绝执行,500 附修复指引)。灰度建议:先 warn 观察日志,声明补齐后切 deny。
|
|
249
|
+
- **无主表语义**:模块未部署时其表无主,运行时不设防(部分部署/灰度属设计语义)。
|
|
250
|
+
|
|
251
|
+
### 跨模块取数(deps)
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
# manifest.yaml
|
|
255
|
+
deps:
|
|
256
|
+
user: "^0.1.0" # 模块名 → 版本范围;裸 SQL join 对方表前必须声明
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
误报逃生门:SQL 注释 `/* oj:allow-table=x,y */`。
|
|
260
|
+
|
|
261
|
+
### 回滚
|
|
262
|
+
|
|
263
|
+
schema 回滚**没有自动机制**(refinery 语义,只前向):破坏性变更前备份数据文件;
|
|
264
|
+
反向变更手写新 seq 迁移前向执行。应用回滚 = `dist/manifests.yaml` 指回旧版本目录 + 重启。
|
|
265
|
+
|
|
266
|
+
## 4. 编写 api.ts
|
|
267
|
+
|
|
268
|
+
> 何时读我:写第一个 handler 时。
|
|
269
|
+
|
|
270
|
+
### 动词 → 方法名映射
|
|
271
|
+
|
|
272
|
+
`api.ts` 导出一个对象,键是 HTTP 动词对应的方法名:
|
|
273
|
+
|
|
274
|
+
| HTTP 动词 | 方法名 |
|
|
275
|
+
|---|---|
|
|
276
|
+
| GET | `get` |
|
|
277
|
+
| POST | `post` |
|
|
278
|
+
| PUT | `put` |
|
|
279
|
+
| DELETE | `del` |
|
|
280
|
+
| PATCH | `patch` |
|
|
281
|
+
| HEAD | `head` |
|
|
282
|
+
| OPTIONS | `options` |
|
|
283
|
+
|
|
284
|
+
**DELETE 的方法名是 `del`,不是 `delete`**——写错时该请求返回 405
|
|
285
|
+
(`method DELETE not allowed`)。
|
|
286
|
+
|
|
287
|
+
### 两种 handler 写法
|
|
288
|
+
|
|
289
|
+
**写法一:同步函数 + `.then().catch()`**。方法体同步返回,异步调用走 Promise 链;
|
|
290
|
+
runtime 会泵 event loop 直到所有 Promise 落定后再写回响应(摘自 `sample/src/user/account/api.ts`):
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
function get(): void {
|
|
294
|
+
const id = Number(http.param("id", 0));
|
|
295
|
+
db.query("select id, name, role from account where id = ?", [id])
|
|
296
|
+
.then((r) => json.ok(r))
|
|
297
|
+
.catch((e) => json.fail(500, String(e)));
|
|
298
|
+
}
|
|
299
|
+
export default { get };
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**写法二:`async` 函数**(driver 会 `await fn()`;摘自 `sample/src/news/api.ts`):
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
export default {
|
|
306
|
+
async post(): Promise<void> {
|
|
307
|
+
const b = http.body as { text?: string };
|
|
308
|
+
await bus.publish("news", { text: b?.text ?? "hello" });
|
|
309
|
+
json.ok({ published: true });
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
两种写法等价可用;同一文件可混用。响应一律经 `json.ok` / `json.fail` 收口(第 7 章)。
|
|
315
|
+
|
|
316
|
+
### 请求体 http.body
|
|
317
|
+
|
|
318
|
+
解析规则:空 body → `null`;能按 JSON 解析 → 对象/数组;否则 → UTF-8 字符串。
|
|
319
|
+
multipart 请求时 `http.body` 是文本字段对象(`{name: value}`),文件走 `http.files`(第 6 章)。
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const b = http.body as { name?: string };
|
|
323
|
+
if (!b.name) { json.fail(400, "name required"); return; }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### 路由:目录镜像与 `.route` 参数路由
|
|
327
|
+
|
|
328
|
+
URL = `{base}/{module}/{...path}/{feature}/` → `<dir>/{module}/{...path}/{feature}/api.ts|js`。
|
|
329
|
+
尾斜杠有无皆可;`{base}` 默认 `/v1/api`(`server.api_prefix` 可配)。
|
|
330
|
+
|
|
331
|
+
handler 函数挂 `.route` 属性即**替换**目录镜像路由,支持 matchit 语法
|
|
332
|
+
(摘自 `sample/src/user/item/api.ts`):
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
function detail(): void {
|
|
336
|
+
const id = Number(http.param("id", 0));
|
|
337
|
+
if (!(id > 0)) { json.fail(400, "id required"); return; }
|
|
338
|
+
db.query("select id, name, role from account where id = ?", [id])
|
|
339
|
+
.then((r) => (r.length ? json.ok(r[0]) : json.fail(404, "no such account")))
|
|
340
|
+
.catch((e) => json.fail(500, String(e)));
|
|
341
|
+
}
|
|
342
|
+
detail.route = "{id}";
|
|
343
|
+
export default { get: detail };
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| 语法 | 匹配 | 示例 |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `{id}` | 单段(不含 `/`) | `/user/item/42` → `http.param("id") === "42"` |
|
|
349
|
+
| `{*path}` | 尾部一段及以上(含 `/`) | `/file/a/b/c` → `http.param("path") === "a/b/c"` |
|
|
350
|
+
| `"/x/{id}"` | 以 `/` 开头挂到 base 根下 | 挂 `/x/{id}` 而非当前目录下 |
|
|
351
|
+
| `""` | 视同未挂载 | 目录镜像路由保留 |
|
|
352
|
+
|
|
353
|
+
`.route` 规则:
|
|
354
|
+
|
|
355
|
+
- 挂载后**目录镜像被替换**:`/user/item`(镜像路径)→ 404,只有参数路由可达。
|
|
356
|
+
- `{*path}` catch-all 至少匹配一段:`/file`(零段)→ 404。
|
|
357
|
+
- **参数段内不得混字面**:`{id}.json`、`v{major}.{minor}` 均属非法 pattern,
|
|
358
|
+
启动时被丢弃并记日志 `InvalidParamSegment`。需要前缀/后缀字面的 URL 拆成静态多段,
|
|
359
|
+
由 handler 自行校验扩展名。
|
|
360
|
+
- TS 项目把 `global.d.ts` 拷进源码根即可获得 `Function.route` 声明,编辑器不报错。
|
|
361
|
+
- `oj build` 会把 `.route` 从产物中剥离——**release 下路由事实唯一来源是构建生成的
|
|
362
|
+
`routes.js`**(第 11 章)。
|
|
363
|
+
|
|
364
|
+
解析顺序:路由表(含 `.route` 参数路由)→ dev 目录镜像兜底(dev 模式)→
|
|
365
|
+
静态站点(`server.app_path`,`server.app_prefix` 前缀内(默认 `/`),仅 GET/HEAD)→ 404。API 永远优先于静态文件。
|
|
366
|
+
目录穿越 / 空段 / 非法段(`..`、`.`、`\`、NUL)→ 404。
|
|
367
|
+
|
|
368
|
+
### ws.ts(WebSocket 生命周期钩子)
|
|
369
|
+
|
|
370
|
+
> 系统学习(心智模型 / 实现走读 / 鉴权现状 / 测试映射)见仓库 `docs/websocket.md`
|
|
371
|
+
>(devkit 包内不含,仓库查看)。
|
|
372
|
+
|
|
373
|
+
目录内放 `ws.ts`(dev)/ `ws.js`(release,约定同 `api.ts`)即产生一条 WebSocket 路由
|
|
374
|
+
`GET {base}/{...path}/ws`:`src/news/ws.ts` → `/v1/api/news/ws`;根级 `ws.ts` → `/v1/api/ws`。
|
|
375
|
+
同目录 `ws.ts` 与 `ws.js` 并存时 `.ts` 优先。
|
|
376
|
+
|
|
377
|
+
**契约(v0.1.9 起,钩子签名不变)**:default 导出生命周期钩子对象,按事件触发:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
export default {
|
|
381
|
+
connection() { /* 连接建立后恰好一次:bus.subscribe 在此 */ },
|
|
382
|
+
message() { /* 每帧一次:http.body 读帧(JSON 自动 parse) */ },
|
|
383
|
+
error(e) { /* 任一钩子抛异常时兜底,之后连接继续 */ },
|
|
384
|
+
close() { /* 收尾恰好一次:客户端断 / socket 断 / ws.close() 三来源统一 */ },
|
|
385
|
+
};
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**执行模型(v0.1.10 帧池)**:每路由 W 个无状态 Worker(`ws.workers_per_route`,默认 2)
|
|
389
|
+
共享执行该路由所有连接的帧。连接状态放 `sess.state`(Rust 会话表持久,按连接隔离,
|
|
390
|
+
**必须可 JSON 序列化**——函数等不可序列化值静默丢失);`sess.id` 为连接 id。
|
|
391
|
+
模块作用域 = Worker 本地只读缓存(W 份副本):可变跨帧状态禁止放模块作用域,
|
|
392
|
+
路由级可变状态用 kv/bus。
|
|
393
|
+
|
|
394
|
+
帧超时断开**该连接**(毒化只影响执行帧的 Worker,池自动补员,其它连接无感)。
|
|
395
|
+
`ws.max_connections`(默认 1000,0=不限)超限 upgrade 返 503。
|
|
396
|
+
|
|
397
|
+
- 四钩子全部可选,但**至少导出一个**(全缺 → 连接建立即断)。
|
|
398
|
+
- 钩子内用既有全局(`json`/`http`/`ws`/`bus`/`sess`/…),与 HTTP handler 一致;**返回值一律忽略**,
|
|
399
|
+
回帧必须显式 `json.ok` / `ws.send`。
|
|
400
|
+
- 可变跨帧状态一律放 `sess.state`(顶层 `const`/`let` 只是 Worker 本地只读缓存,
|
|
401
|
+
不可依赖其跨帧写入生效);跨连接共享走 kv / bus。
|
|
402
|
+
- 帧超时 = 断开该连接(钩子收不到该事件);`error(e)` 是唯一带参钩子(e 为异常对象)。
|
|
403
|
+
|
|
404
|
+
注意:客户端**主动断连**(先发 Close 帧)路径上,`close()` 钩子仍会触发(服务端副作用如
|
|
405
|
+
kv 写入照常生效),但其 `ws.send` 离帧受 RFC 6455 关闭握手限制无法送达客户端——需要离帧
|
|
406
|
+
可见时用服务端 `ws.close()` 收尾。
|
|
407
|
+
|
|
408
|
+
运行案例(摘自 `sample/src/news/ws.ts`):
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
export default {
|
|
412
|
+
connection() {
|
|
413
|
+
sess.state.ready = true; // 会话状态外置:跨帧持久、按连接隔离
|
|
414
|
+
bus.subscribe("news");
|
|
415
|
+
json.ok({ subscribed: true });
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
注意:release 下 root=dist,WS URL 含模块版本段(如 `…/news-0.1.0/ws`)——v0.2 已知限制
|
|
421
|
+
(第 13 章)。bus 的发布/订阅方向约定见第 6 章 bus 小节。
|
|
422
|
+
|
|
423
|
+
### 帧内发布:`ws.ts` 里直接 `bus.publish`
|
|
424
|
+
|
|
425
|
+
`bus.publish` 没有上下文限制(只有 `bus.subscribe` 限 WS 连接),帧处理器可以直接当
|
|
426
|
+
「广播泵」用。可运行案例 `sample/src/news/chat/ws.ts`——聊天室,任意连接发帧,
|
|
427
|
+
所有订阅者收广播:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
// connection = 进房,订阅每连接一次;message = 每帧
|
|
431
|
+
export default {
|
|
432
|
+
connection() {
|
|
433
|
+
bus.subscribe("chat");
|
|
434
|
+
json.ok({ joined: true });
|
|
435
|
+
},
|
|
436
|
+
message() {
|
|
437
|
+
const frame = http.body; // JSON 文本帧已自动 parse 成对象
|
|
438
|
+
if (frame && frame.text) {
|
|
439
|
+
bus.publish("chat", { from: frame.from ?? "anon", text: frame.text });
|
|
440
|
+
json.ok({ sent: true });
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
帧内发布特有的三条语义(系统学习见仓库 `docs/websocket.md`):
|
|
447
|
+
|
|
448
|
+
1. **自回声**:本连接若订阅了同一主题,会收到自己发布的广播帧(fan-out 不排除
|
|
449
|
+
自己)——按 `from` 字段客户端过滤,或发布到别的 topic。
|
|
450
|
+
2. **钩子可直接 `await`**:钩子会被驱动至 Promise 落定才捕获回帧——要拿 `publish`
|
|
451
|
+
返回的「本地接收方数」,直接 `await bus.publish(...)`(kafka/rabbitmq broker 下
|
|
452
|
+
该数恒 0)。
|
|
453
|
+
3. **无状态也够用**:钩子共享该路由的 Worker 池,模块作用域只是只读缓存——可变跨帧
|
|
454
|
+
状态放 `sess.state`(按连接隔离)或 kv/bus(跨连接);聊天室本例全靠 bus 广播,
|
|
455
|
+
无需任何本地状态。
|
|
456
|
+
|
|
457
|
+
## 5. 导入解析
|
|
458
|
+
|
|
459
|
+
> 何时读我:import 报错或想抽公共代码时。
|
|
460
|
+
|
|
461
|
+
### 相对导入
|
|
462
|
+
|
|
463
|
+
`./x`、`../x` 自动补全,顺序:`.ts` → `.js` → `/index.ts` → `/index.js`。
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
import { positiveId, requireRole } from "../_shared/validate"; // → ../_shared/validate.ts
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**跨模块相对导入**(如 `order` 引 `user` 的工具):dev 下直接可跑;build 时改写为指向
|
|
470
|
+
目标模块版本目录的相对路径——目标模块未构建过则报错,先 `oj build user` 再
|
|
471
|
+
`oj build order`(摘自 `sample/src/order/list/api.ts`):
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { requireRole } from "../../user/_shared/validate";
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 裸 specifier(node_modules)
|
|
478
|
+
|
|
479
|
+
`import { escapeHtml } from "escape-goat"` —— 从当前文件目录**逐级向上**找
|
|
480
|
+
`node_modules/<pkg>`(至 project root),按 `package.json` 的 `module` → `main` →
|
|
481
|
+
`index.js` 取入口;支持 `@scope/name` 与子路径 `pkg/lib/util.js`。
|
|
482
|
+
|
|
483
|
+
### CJS 互操作
|
|
484
|
+
|
|
485
|
+
CJS 包自动包装:`module.exports` → `default`;`require("pkg")` 走 `__ojRequire`
|
|
486
|
+
(进程级缓存)。启发式识别,**仅支持裸 specifier**;相对 `require("./x")` v0.2 不支持
|
|
487
|
+
(ESM 相对导入不受影响)。不读 `package.json` 的 `exports`/`conditions`;pnpm 布局不支持。
|
|
488
|
+
|
|
489
|
+
### 安全边界
|
|
490
|
+
|
|
491
|
+
所有解析结果被**钳制在 project root 内**——`..` 逃逸直接报错。
|
|
492
|
+
|
|
493
|
+
## 6. 全局对象 API 参考
|
|
494
|
+
|
|
495
|
+
> 何时读我:写任何 handler 期间,查签名与语义。
|
|
496
|
+
|
|
497
|
+
签名与 `global.d.ts` 一致(类型权威)。SQL 占位符方言:**sqlite / mysql 用 `?`,
|
|
498
|
+
postgres 用 `$1`**;值一律经参数数组绑定。
|
|
499
|
+
|
|
500
|
+
### 总表(20 组)
|
|
501
|
+
|
|
502
|
+
| 全局 | 说明 |
|
|
503
|
+
|---|---|
|
|
504
|
+
| `json.ok(data?)` / `json.fail(code, msg, data?)` / `json.header(name, value)` | 统一响应信封与响应头 |
|
|
505
|
+
| `http.method / query / headers / body / params / param / tenantId / user / files / file(i)` | 当前请求上下文(只读、懒加载、每请求最新) |
|
|
506
|
+
| `db.query / exec / table / tx` | 默认库(`db === DB("default")`) |
|
|
507
|
+
| `DB(name)` | 命名库实例(未配置的名字返回 `undefined`) |
|
|
508
|
+
| `kv.get/set/del/expire/incr` | KV 存储(配 `redis.default` → 真 Redis,否则进程内存 KV) |
|
|
509
|
+
| `redis.get/set/del/expire/incr` | 与 `kv` 同源同面(真连时二者同栈,auth 会话同库) |
|
|
510
|
+
| `blob.put/get/del/url/contentType`(可调用:`blob("name")`) | 对象存储(`blob:` 段启用) |
|
|
511
|
+
| `bus.publish / subscribe / kind` | 主题广播(HTTP 发布、WS 订阅) |
|
|
512
|
+
| `es.search / index / del` | Elasticsearch 薄客户端(`es:` 段启用) |
|
|
513
|
+
| `Kafka(name)` / `RabbitMQ(name)` | 命名 MQ 客户端(`kafkas:`/`rabbits:` 段;未配置的名 → `undefined`;消费方法仅任务上下文,见下「命名 MQ 客户端与长任务」) |
|
|
514
|
+
| `tasks.stopping() / tasks.sleep(ms)` | 长任务上下文:停机信号 + 等待原语(见下「命名 MQ 客户端与长任务」) |
|
|
515
|
+
| `log.debug / info / warn / error` | 结构化日志 |
|
|
516
|
+
| `fetch(url, options?)` | WHATWG fetch(deno 官方实现,v0.1.8;真 `Response`/`Headers`,https 开箱即用) |
|
|
517
|
+
| `ws.send / close` | WebSocket 帧控制(HTTP 路径下 no-op) |
|
|
518
|
+
| `sess.id / sess.state` | WS 连接 id 与会话状态(仅 ws.ts 钩子内;state 须可 JSON 序列化) |
|
|
519
|
+
| `new WebSocket(url)` | WHATWG 出站 WS 客户端(任务与 handler 均可用,见下「WebSocket —— 出站客户端」) |
|
|
520
|
+
| `plugins()` | 已加载插件自省 + 宿主 ABI |
|
|
521
|
+
| `jwt.sign / verify / accessDuration / refreshDuration` | JWT 签发与验签(`auth:` 段注入;未配置调用报错,见第 8 章) |
|
|
522
|
+
| `bcrypt.hash / verify` | 密码哈希与校验(Rust 侧 `spawn_blocking`,不卡 isolate) |
|
|
523
|
+
| `oidc.sign / verify / jwks` + `oidc.issuer / rp / clients` | RS256 JWS 原语与装配期配置(`oidc:` 段启用;私钥留在 Rust,见第 10 章) |
|
|
524
|
+
| `crypto.sha256Hex / randomHex` | sha256 十六进制摘要 / 随机 hex(增补进原生 `crypto`,原生成员保留) |
|
|
525
|
+
| 测试 SDK(`client.*` / `describe / it / expect / beforeEach` / `finish`) | **仅测试文件可用**,见第 9 章 |
|
|
526
|
+
|
|
527
|
+
### json —— 信封与响应头
|
|
528
|
+
|
|
529
|
+
| API | 签名 | 说明 |
|
|
530
|
+
|---|---|---|
|
|
531
|
+
| `json.ok` | `ok(data?: unknown): void` | 成功信封 `{code:0,msg:"ok",data}`,HTTP 200 |
|
|
532
|
+
| `json.fail` | `fail(code: number, msg: string, data?: unknown): void` | 失败信封,HTTP 状态 = `code`(`code<=0` 映射 500) |
|
|
533
|
+
| `json.header` | `header(name: string, value: string): void` | 设置响应头(同名后写覆盖) |
|
|
534
|
+
| `json.raw` | `raw(data: unknown): void` | **裸 JSON 200(无 `{code,msg,data}` 信封)**,content-type 默认 `application/json`(`json.header` 可覆盖)。对外标准协议端点用(OIDC discovery/jwks/token/userinfo);错误仍走 `json.fail` 信封 |
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
json.ok({ created: true });
|
|
538
|
+
json.fail(400, "name required");
|
|
539
|
+
json.fail(404, "no such account", { id });
|
|
540
|
+
json.header("X-Request-Id", "abc");
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### http —— 请求上下文
|
|
544
|
+
|
|
545
|
+
| API | 类型 / 签名 | 说明 |
|
|
546
|
+
|---|---|---|
|
|
547
|
+
| `http.method` | `string` | 请求方法(`GET`/`POST`/…) |
|
|
548
|
+
| `http.query` | `Record<string, string>` | query 参数对象(form-urlencoded 解码:`+`→空格、`%XX`) |
|
|
549
|
+
| `http.headers` | `Record<string, string>` | 请求头对象 |
|
|
550
|
+
| `http.body` | `any` | 请求体(解析规则见第 4 章) |
|
|
551
|
+
| `http.params` | `Record<string, string>` | 路径参数对象(已 percent-decode;目录镜像路由下恒空) |
|
|
552
|
+
| `http.param` | `param(name: string, def?: unknown): any` | **路径参数优先,query 兜底**,均缺失返回 `def` 原值 |
|
|
553
|
+
| `http.tenantId` | `string \| null` | 租户 id(`tenant.enable` 时从租户头提取;未启用为 `null`) |
|
|
554
|
+
| `http.user` | `AuthUser \| null` | 已验签用户 `{id, roles, claims}`(auth 启用且过 Bearer 守卫;否则 `null`) |
|
|
555
|
+
| `http.files` | `UploadedFileMeta[]` | multipart 上传元信息 `[{field, filename, content_type, size}]`;非 multipart 为空数组 |
|
|
556
|
+
| `http.file` | `file(i: number): Promise<Uint8Array>` | 第 i 个上传文件的字节(越界报错 `no such file`) |
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
const id = Number(http.param("id", 0)); // /item/42?id=9 → "42"(路径优先)
|
|
560
|
+
const page = http.param("page", 1); // 无路径参数 → query 兜底 → 默认 1
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### db / DB(name) —— 数据库
|
|
564
|
+
|
|
565
|
+
| API | 签名 | 说明 |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `db.query` | `query(sql: string, params?: unknown[]): Promise<Row[]>` | 参数化查询 → 行数组 |
|
|
568
|
+
| `db.exec` | `exec(sql: string, params?: unknown[]): Promise<number>` | 参数化执行 → 受影响行数 |
|
|
569
|
+
| `db.table` | `table(name: string): QueryBuilder` | 安全查询构造器(标识符白名单 + 参数化值) |
|
|
570
|
+
| `db.tx` | `tx(fn: (tx: DBInstance) => unknown): Promise<unknown>` | 事务(语义见下) |
|
|
571
|
+
| `DB(name)` | `(name: string) => DBInstance \| undefined` | 命名库实例;四方法与 `db` 同签名 |
|
|
572
|
+
|
|
573
|
+
**查询构造器**(流式、结构化;SQL 由服务端按库方言生成):
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
const rows = await db.table("account")
|
|
577
|
+
.select(["id", "name"])
|
|
578
|
+
.where({ field: "role", op: "eq", value: "admin" })
|
|
579
|
+
.where({ field: "id", op: "gt", value: 0 }) // 多个 where 之间 AND
|
|
580
|
+
.orderBy([{ field: "id", dir: "desc" }])
|
|
581
|
+
.limit(10)
|
|
582
|
+
.offset(0)
|
|
583
|
+
.all(); // → Promise<Json[]>
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
- `WhereCond`:`{ field: string; op?: string; value?: unknown; and?; or? }`。
|
|
587
|
+
v0.2 服务端支持的操作符:`eq / ne / gt / gte / lt / lte / in(值须数组)/
|
|
588
|
+
like / isnull`;未知操作符直接报错;`and`/`or` 嵌套字段 v0.2 未展开(多 where 即 AND)。
|
|
589
|
+
- `OrderByItem`:`{ field: string; dir?: "asc" | "desc" | null }`。
|
|
590
|
+
- 表名/列名经 SchemaRegistry 白名单校验(启动内省所得)——未知表/列报错;
|
|
591
|
+
排序列另有可排序白名单。`limit` 缺省 100、硬上限 1000。
|
|
592
|
+
- 构造器自动按库方言出 SQL(sqlite/mysql/postgres),业务无需手写方言差异。
|
|
593
|
+
|
|
594
|
+
**事务 `db.tx`**:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
await db.tx(async (tx) => {
|
|
598
|
+
await tx.exec("update account set balance = balance - ? where id = ?", [50, 1]);
|
|
599
|
+
await tx.exec("update account set balance = balance + ? where id = ?", [50, 2]);
|
|
600
|
+
const rows = await tx.table("account").select(["id", "balance"]).all(); // 同连接读未提交
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
- 回调正常返回 → **提交**;throw / reject → **回滚**并把原错误抛给 handler。
|
|
605
|
+
- `tx` 与 `db` 的 `query / exec / table` **同签名**——事务内自动走同一连接,
|
|
606
|
+
无需改写其余代码。
|
|
607
|
+
- 每请求**至多一个**活跃事务:嵌套 `db.tx` 报错 `transaction already active`;
|
|
608
|
+
事务未完结时访问其它库报错(先结当前事务)。
|
|
609
|
+
- handler 忘记 `await` 或中途崩溃:请求结束时未完结事务**自动回滚**(服务端打 warn 日志)。
|
|
610
|
+
|
|
611
|
+
### kv / redis —— KV 存储
|
|
612
|
+
|
|
613
|
+
| API | 签名 | 说明 |
|
|
614
|
+
|---|---|---|
|
|
615
|
+
| `kv.get` | `get(key: string): Promise<string \| null>` | 取值(缺失 `null`) |
|
|
616
|
+
| `kv.set` | `set(key: string, value: string): Promise<boolean>` | 写值 |
|
|
617
|
+
| `kv.del` | `del(key: string): Promise<boolean>` | 删键 |
|
|
618
|
+
| `kv.expire` | `expire(key: string, ttlSec: number): Promise<boolean>` | 设过期(秒);真 Redis 走 EXPIRE,内存 KV 惰性过期 |
|
|
619
|
+
| `kv.incr` | `incr(key: string): Promise<number>` | 自增返回新值(键不存在从 0 起) |
|
|
620
|
+
|
|
621
|
+
(`redis.get / redis.set / redis.del / redis.expire / redis.incr` 与上表同签名——二者同源同面。)
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
// 读穿缓存(摘自 sample/src/order/detail/api.ts,改编)
|
|
625
|
+
const hit = await kv.get(key);
|
|
626
|
+
if (hit !== null) { json.ok({ cached: true, data: JSON.parse(hit) }); return; }
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
`redis` 全局与 `kv` 同源:`redis.default` 配置即真连(fail-fast),此时两者都走 Redis,
|
|
630
|
+
auth 会话也在同一 Redis(多实例共享的前提);未配置时均为进程内存 KV。
|
|
631
|
+
|
|
632
|
+
### blob —— 对象存储(`blob:` 段启用,未配置调用即报错)
|
|
633
|
+
|
|
634
|
+
| API | 签名 | 说明 |
|
|
635
|
+
|---|---|---|
|
|
636
|
+
| `blob(name?)` | 可调用取命名实例:`blob("media").put(...)`;裸调用 `blob.put(...)` 等价 `blob("default")` | 命名多后端对应 config `blob.backends.<name>` |
|
|
637
|
+
| `blob.put` | `put(key: string, bytes: Uint8Array, contentType?: string): Promise<boolean>` | 写对象(local 落盘 / s3 上传) |
|
|
638
|
+
| `blob.get` | `get(key: string): Promise<Uint8Array>` | 读对象(不存在报错) |
|
|
639
|
+
| `blob.del` | `del(key: string): Promise<boolean>` | 删对象(幂等:不存在视为成功) |
|
|
640
|
+
| `blob.url` | `url(key: string): Promise<string>` | 下载地址:local = `{base}/blob/{key}`;s3 = presigned URL(15min) |
|
|
641
|
+
| `blob.contentType` | `contentType(key: string): Promise<string \| null>` | Content-Type(local 读 sidecar / 按扩展名推断;缺失且无法推断返回空串;s3 无 Content-Type 返回 `null`) |
|
|
642
|
+
|
|
643
|
+
**上传四件套完整例子**(摘自 `sample/src/upload/api.ts`):
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
export default {
|
|
647
|
+
async post() {
|
|
648
|
+
const f = http.files[0]; // 1. 元信息
|
|
649
|
+
if (!f) json.fail(400, "need a file field (multipart)");
|
|
650
|
+
const b = await http.file(0); // 2. 字节
|
|
651
|
+
await blob.put(f.filename, b, f.content_type); // 3. 存储
|
|
652
|
+
json.ok({ key: f.filename, url: await blob.url(f.filename), size: b.length }); // 4. 下载地址
|
|
653
|
+
},
|
|
654
|
+
async del() {
|
|
655
|
+
await blob.del(http.param("k", ""));
|
|
656
|
+
json.ok({ ok: true });
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
下载走内置公开路由 `GET {base}/blob/{key}`(免鉴权、不落业务表;local 直出字节 +
|
|
662
|
+
Content-Type,s3 302 跳 presigned URL)。key 按 `/` 分段白名单校验
|
|
663
|
+
(第 13 章路径安全)。上传体积上限 `server.max_upload_bytes`(超限 413)。
|
|
664
|
+
|
|
665
|
+
### bus —— 订阅发布
|
|
666
|
+
|
|
667
|
+
| API | 签名 | 说明 |
|
|
668
|
+
|---|---|---|
|
|
669
|
+
| `bus.publish` | `publish(topic: string, data?: unknown): Promise<number>` | 广播 JSON 帧 `{"topic":…,"data":…}` 给订阅该 topic 的**全部 WS 会话**,返回接收方数(无订阅返回 0) |
|
|
670
|
+
| `bus.subscribe` | `subscribe(topic: string): Promise<void>` | 当前 WS 会话订阅 topic(**HTTP 路径调用报错**——订阅对象是连接本身) |
|
|
671
|
+
| `bus.kind` | `kind(): Promise<string>` | 活跃 broker 类型:`"local"` / `"kafka"` / `"rabbitmq"`(异步 op,判等须 `await`) |
|
|
672
|
+
|
|
673
|
+
方向约定:**HTTP handler 发布,WS 会话订阅**。订阅在连接断开自动清除;同一会话重复订阅
|
|
674
|
+
幂等去重。`bus.kind()` 为异步,返回 `Promise<string>`,判等须 `await bus.kind()`。
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
// HTTP 发布(任意 api.ts)
|
|
678
|
+
const n = await bus.publish("news", { text: "hi" });
|
|
679
|
+
json.ok({ receivers: n });
|
|
680
|
+
|
|
681
|
+
// WS 订阅(ws.ts 内,见第 4 章 ws.ts 小节)
|
|
682
|
+
bus.subscribe("news");
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
缺省 `broker` 为进程内总线(跨实例不互通);`broker.kind: kafka|rabbitmq` 需对应插件。
|
|
686
|
+
|
|
687
|
+
### es —— Elasticsearch(`es:` 段启用,未配置调用报 `es not configured`)
|
|
688
|
+
|
|
689
|
+
| API | 签名 | 说明 |
|
|
690
|
+
|---|---|---|
|
|
691
|
+
| `es.search` | `search(index: string, dsl?: unknown): Promise<Json>` | `POST {endpoint}/{index}/_search`,直通 ES 响应体 |
|
|
692
|
+
| `es.index` | `index(index: string, id: string, doc?: unknown): Promise<Json>` | `PUT {endpoint}/{index}/_doc/{id}?refresh=true`(写完即可查) |
|
|
693
|
+
| `es.del` | `del(index: string, id: string): Promise<Json>` | `DELETE` 同路径(幂等,缺失返回 404 体) |
|
|
694
|
+
|
|
695
|
+
index / id 限 `[a-zA-Z0-9_-]+`(防路径注入);非 2xx 报错带 ES 返回体。
|
|
696
|
+
|
|
697
|
+
### log —— 结构化日志
|
|
698
|
+
|
|
699
|
+
| API | 签名 |
|
|
700
|
+
|---|---|
|
|
701
|
+
| `log.debug` | `debug(msg: string, ...kv: unknown[]): void` |
|
|
702
|
+
| `log.info` | `info(msg: string, ...kv: unknown[]): void` |
|
|
703
|
+
| `log.warn` | `warn(msg: string, ...kv: unknown[]): void` |
|
|
704
|
+
| `log.error` | `error(msg: string, ...kv: unknown[]): void` |
|
|
705
|
+
|
|
706
|
+
键值对交替传参(zap SugaredLogger 风格):`log.info("order created", "id", id, "amount", amount)`。
|
|
707
|
+
输出经 `tracing-subscriber` 结构化打印(第 12 章日志)。
|
|
708
|
+
|
|
709
|
+
### fetch —— HTTP 客户端(WHATWG,v0.1.8)
|
|
710
|
+
|
|
711
|
+
标准 WHATWG `fetch`(deno 官方 deno_fetch 实现,v0.1.8 起整体替换自研 reqwest 版):
|
|
712
|
+
`fetch(url | Request, init?)` → 真 `Response`——`ok / status / statusText /
|
|
713
|
+
headers: Headers / json() / text() / arrayBuffer() / body(ReadableStream)`。
|
|
714
|
+
`Headers` 大小写不敏感:`r.headers.get("content-type")`。`method / headers /
|
|
715
|
+
body / signal / redirect` 均按标准语义:body 支持 `string | Uint8Array`(不再限于
|
|
716
|
+
字符串),字符串 body 默认 `Content-Type: text/plain;charset=UTF-8`,取消走
|
|
717
|
+
`AbortController`(全局已挂载,如 `AbortSignal.timeout(5000)`)。
|
|
718
|
+
|
|
719
|
+
https/wss 开箱即用:webpki-roots 根集编译进二进制(`bridge::ws_client_extensions`
|
|
720
|
+
注入 `FetchOptions`,https fetch 与 wss 握手共用)。网络错误折叠为 `TypeError`
|
|
721
|
+
(`Invalid URL…` / `error sending request…`),非 2xx 照常返回 `Response`
|
|
722
|
+
(`ok === false`),都不抛 HTTP 状态错。
|
|
723
|
+
|
|
724
|
+
```ts
|
|
725
|
+
const r = await fetch("https://api.example.com/v1/ping", {
|
|
726
|
+
signal: AbortSignal.timeout(5000),
|
|
727
|
+
});
|
|
728
|
+
if (r.ok) {
|
|
729
|
+
json.ok(await r.json());
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### ws —— WebSocket 帧控制
|
|
734
|
+
|
|
735
|
+
| API | 签名 | 说明 |
|
|
736
|
+
|---|---|---|
|
|
737
|
+
| `ws.send` | `send(data: string): void` | 向当前连接发一帧(HTTP 路径下 no-op) |
|
|
738
|
+
| `ws.close` | `close(): void` | 结束当前连接 |
|
|
739
|
+
| `sess.id` | `number`(只读) | 当前连接 id(路由内自 1 递增) |
|
|
740
|
+
| `sess.state` | 读写属性 | 连接会话状态(Rust 会话表持久,按连接隔离);**必须可 JSON 序列化**——函数等不可序列化值静默丢失 |
|
|
741
|
+
|
|
742
|
+
仅在 `ws.ts` 钩子内有意义(第 4 章 §ws.ts——帧池执行模型与 sess 约束)。
|
|
743
|
+
|
|
744
|
+
### WebSocket —— 出站客户端(WHATWG,v0.1.7 起)
|
|
745
|
+
|
|
746
|
+
标准 WHATWG `WebSocket`(deno 官方实现):`new WebSocket(url)`、
|
|
747
|
+
`onopen/onmessage/onclose/onerror`、`send/close`。**任务文件与 HTTP handler 均可用**,
|
|
748
|
+
无 MQ 式 task 门禁(连接无 offset/ack 消费会话语义)。
|
|
749
|
+
|
|
750
|
+
长任务里作消费端取数(`src/tasks/task_*.ts`,写法同「命名 MQ 客户端与长任务」,
|
|
751
|
+
可运行案例 `sample/src/tasks/task_wsclient.ts`):
|
|
752
|
+
|
|
753
|
+
```ts
|
|
754
|
+
const ws = new WebSocket("ws://localhost:9778/v1/api/news/ws");
|
|
755
|
+
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = err; });
|
|
756
|
+
ws.send("{}"); // 连上即已订阅(connection 钩子);此帧触发 message(无则 no-op)
|
|
757
|
+
ws.onmessage = (e) => log.info("frame " + e.data);
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
**重连 = 崩溃监督,不手写 reconnect**:`onerror`/`onclose` 后让任务抛错(或像案例那样
|
|
761
|
+
在下一轮 `if (closed) throw`)→ `Crashed` → 监督器指数退避重启(1s→2s→…cap 60s)→
|
|
762
|
+
新实例重连。停机时在 `stop_grace_secs` 内 `ws.close()` 自然收场(`Stopped`)。
|
|
763
|
+
|
|
764
|
+
三条硬约束(全部来自真实踩坑):
|
|
765
|
+
|
|
766
|
+
1. **鉴权在应用层做,不在管线**:WS 路由(`<dir>/ws.ts`)是 merge 进 Router 的
|
|
767
|
+
**真实路由,不经过** fallback 的 Bearer/租户前置管线——连接天然匿名,
|
|
768
|
+
`anonymous_paths` 对它无效也不需要配(v0.1.8 起 sample 已删除该冗余条目)。
|
|
769
|
+
受保护数据的订阅要自行做首帧 token 握手,或把发布端点(`POST /news`)留在
|
|
770
|
+
鉴权面内。
|
|
771
|
+
2. **URL 主机名要匹配服务端绑定语义**:服务端缺省监听 `[::1]`(IPv6 回环),写
|
|
772
|
+
`127.0.0.1` 会 connection refused——用 `localhost`。
|
|
773
|
+
3. **等帧必须与停机信号竞速**:挂在 `await` 上没人 wake,会拖到看门狗强杀记 `killed`;
|
|
774
|
+
用 `Promise.race([wake, tasks.sleep(250)])`,轮询间隔 ≪ `stop_grace_secs`
|
|
775
|
+
(与 MQ poll 的 `timeoutMs` 纪律同源)。
|
|
776
|
+
|
|
777
|
+
wss 自 v0.1.8 起可用(webpki-roots 根集,见上「fetch」节)。`oj test` 运行时
|
|
778
|
+
同样挂载该全局。出站客户端教学见 [docs/websocket.md](../websocket.md) §7。
|
|
779
|
+
|
|
780
|
+
### plugins —— 插件自省
|
|
781
|
+
|
|
782
|
+
签名:`plugins(): any[]`。返回已加载插件清单
|
|
783
|
+
`[{name, semver, abi_version, fingerprint, description, host_abi_version}]`——用于升级核对窗口
|
|
784
|
+
(第 11 章插件升级);`description` 为插件作者自述。同一清单经内置端点
|
|
785
|
+
`GET {base}/plugins` 公开(不走 Bearer,运维/监控用)。
|
|
786
|
+
|
|
787
|
+
```ts
|
|
788
|
+
json.ok(plugins());
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
### jwt —— JWT 签发与验签(`auth:` 段启用)
|
|
792
|
+
|
|
793
|
+
secret / 算法 / 有效期由 config `auth:` 段在装配期注入,handler 不接触密钥;
|
|
794
|
+
`auth:` 未配置时调用报 `jwt not configured`。`iat` / `exp` 由 Rust 侧补(JS 不可控有效期),
|
|
795
|
+
`exp` 取 access 时长。
|
|
796
|
+
|
|
797
|
+
| API | 签名 | 说明 |
|
|
798
|
+
|---|---|---|
|
|
799
|
+
| `jwt.sign` | `sign(payload: { sub: string; roles?: string[] }): string` | 签发 access token(payload 至少含 `sub`,`roles` 缺省空数组) |
|
|
800
|
+
| `jwt.verify` | `verify(token: string): { sub, roles, iat, exp }` | 验签 + 过期检查(leeway 0);篡改 / 过期 / 算法不符直接抛错 |
|
|
801
|
+
| `jwt.accessDuration` | `readonly number` | 配置的 access 有效期(秒;getter 惰性求值) |
|
|
802
|
+
| `jwt.refreshDuration` | `readonly number` | 配置的 refresh 有效期(秒) |
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
const token = jwt.sign({ sub: user.id, roles: user.roles }); // 登录端点签发
|
|
806
|
+
const claims = jwt.verify(token); // 受守卫保护的端点一般直接读 http.user
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### bcrypt —— 密码哈希(始终可用)
|
|
810
|
+
|
|
811
|
+
哈希与校验在 Rust 侧 `spawn_blocking` 执行,CPU 密集不卡 isolate;**不依赖 `auth:` 段,
|
|
812
|
+
未配置鉴权也可用**(校验不需密钥)。
|
|
813
|
+
|
|
814
|
+
| API | 签名 | 说明 |
|
|
815
|
+
|---|---|---|
|
|
816
|
+
| `bcrypt.hash` | `hash(password: string, cost?: number): Promise<string>` | 生成 bcrypt 哈希(cost 缺省库默认) |
|
|
817
|
+
| `bcrypt.verify` | `verify(password: string, hash: string): Promise<boolean>` | 校验;非法 hash 返回 `false`(不抛错) |
|
|
818
|
+
|
|
819
|
+
```ts
|
|
820
|
+
const ok = await bcrypt.verify(password, row.password_hash); // false = 口令不符或 hash 非法
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### crypto —— 摘要与随机数(增补进原生 crypto)
|
|
824
|
+
|
|
825
|
+
bootstrap 对原生 `crypto` 做 `Object.assign` 合并:原生成员(如 `getRandomValues`)保留,
|
|
826
|
+
仅增补两个方法。**不依赖 `auth:` 段,始终可用。**
|
|
827
|
+
|
|
828
|
+
| API | 签名 | 说明 |
|
|
829
|
+
|---|---|---|
|
|
830
|
+
| `crypto.sha256Hex` | `sha256Hex(s: string): string` | UTF-8 编码后的 sha256 十六进制摘要 |
|
|
831
|
+
| `crypto.randomHex` | `randomHex(nBytes?: number): string` | `nBytes` 字节随机数的 hex(缺省 32 字节) |
|
|
832
|
+
|
|
833
|
+
```ts
|
|
834
|
+
const sessionKey = "AUTH-SESSION:" + crypto.sha256Hex(refreshToken); // 见第 8 章轮换
|
|
835
|
+
const token = crypto.randomHex(); // 64 字符不透明串
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
### ext_boot.js —— 运行时补充全局对象
|
|
839
|
+
|
|
840
|
+
上表全局由 `bootstrap.js` 在**编译期**装配,改它要重编 `oj`。`ext_boot.js` 是运行时补充:
|
|
841
|
+
放在 **config.yaml 同级目录**即被加载,可在已有全局上做组合增补(如 `json.page()`、
|
|
842
|
+
`log.trace`),不重编二进制。
|
|
843
|
+
|
|
844
|
+
```js
|
|
845
|
+
// ext_boot.js —— 每个新建的 JsRuntime 执行一次(不是每个请求)
|
|
846
|
+
export {};
|
|
847
|
+
json.page = (rows, total) => json.ok({ list: rows, total });
|
|
848
|
+
globalThis.APP_ENV = "prod";
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
| 项 | 行为 |
|
|
852
|
+
|---|---|
|
|
853
|
+
| 位置 | `<config_dir>/ext_boot.js`(config.yaml 同目录);不存在即不加载 |
|
|
854
|
+
| 执行时机 | **每新建一个 JsRuntime 执行一次**;请求复用池中的 runtime 不重跑 |
|
|
855
|
+
| 模块形态 | ESM,支持顶层 `await` 与 `import` 项目内模块(解析规则同第 5 章) |
|
|
856
|
+
| 副作用范围 | boot 期写的 `json.ok` 等每请求状态随后即被重置,不会泄漏到请求 |
|
|
857
|
+
| 热重载 | **不做**——改动后须重启进程(启动日志打印已冻结的路径与 `?v=`) |
|
|
858
|
+
| `oj build` / `oj test` | 同样加载,保证 dev / build / release 行为一致 |
|
|
859
|
+
| 失败 | 启动期失败 → 服务拒绝启动;运行期新建 runtime 失败 → 该请求报错、服务存活 |
|
|
860
|
+
|
|
861
|
+
四条硬边界(不是建议):
|
|
862
|
+
|
|
863
|
+
- **必须幂等且无外部副作用**。执行次数 = 模块数 + `pool_size` + WS Worker 数(每路由
|
|
864
|
+
`ws.workers_per_route` 个,与连接数无关),在里面写库 / 发广播 / 打外部接口会被放大同样倍数。
|
|
865
|
+
- **只能组合已有全局,拿不到新能力**。`import "ext:core/ops"` 会被 deno_core 拒绝
|
|
866
|
+
(`ext:` 只允许从 `ext:`/`node:` 模块导入);需要新 op 属于改 bootstrap,不走这条路。
|
|
867
|
+
- **用顶层 `await` 必须带一句 `export {};`**(或有真实 import/export)。否则文件被 CJS
|
|
868
|
+
启发式(§5 CJS 互操作)判定为 CJS、包进非 async 函数,顶层 `await` 直接 SyntaxError。
|
|
869
|
+
- **boot 里不做长等待**。同步死循环 2s 后被熔断(该 runtime 丢弃);
|
|
870
|
+
`await` 永不落定的 Promise 由引擎直接报 `Top-level await promise never resolved`。
|
|
871
|
+
|
|
872
|
+
注入的全局默认可写,handler 覆盖/删除后该 runtime 内后续请求都受影响(boot 不重跑)。
|
|
873
|
+
需要防改就用 `Object.defineProperty(..., {writable: false, configurable: false})`。
|
|
874
|
+
|
|
875
|
+
### 命名 MQ 客户端与长任务(Kafka / RabbitMQ / tasks)
|
|
876
|
+
|
|
877
|
+
> 何时读我:要接 Kafka/RabbitMQ 消费,或写 `src/tasks/` 下的长任务。
|
|
878
|
+
> 教学走读(心智模型 / 实现分层 / 测试地图)见仓库 `docs/mq-tasks.md`
|
|
879
|
+
>(devkit 包内不含,仓库查看)。
|
|
880
|
+
|
|
881
|
+
**命名客户端**:`kafkas:`/`rabbits:` 段(config §10)每个键装配为一个实例,
|
|
882
|
+
`Kafka("default")` / `RabbitMQ("default")` 取用(同名实例进程内同一对象;未配置的
|
|
883
|
+
名返回 `undefined`)。底层经 oj-bus-kafka / oj-bus-rabbitmq 插件的 `mq` 轴。
|
|
884
|
+
|
|
885
|
+
| 客户端 | 生产面 | 消费面(**仅任务上下文**) |
|
|
886
|
+
|---|---|---|
|
|
887
|
+
| `Kafka(name)` | `send(topic, {value, key?, headers?})` | `poll(topics, {max?, timeoutMs?})` → `{messages}`;`commit(m)`(按 m.offset+1 提交) |
|
|
888
|
+
| `RabbitMQ(name)` | `publish(exchange, routingKey, value, {headers?})` | `poll(queues, {max?, timeoutMs?})`(= 循环 basic.get);`ack(m)`;`nack(m, requeue?)` |
|
|
889
|
+
| 两者 | `kind()` / `metadata()` | —— |
|
|
890
|
+
|
|
891
|
+
消息形状(`OjMqMessage`):`{topic, partition?, offset?, key?, value, headers?, ts?}`。
|
|
892
|
+
|
|
893
|
+
**消费门禁(评审 M2)**:`poll/commit/ack/nack` 只在长任务上下文可用——HTTP/WS
|
|
894
|
+
handler 里调用直接报错(消费会话归属任务实例,实例级单 poller,第二个并发 poll
|
|
895
|
+
报 `instance busy`)。任务 = `src/tasks/`(`tasks.dir` 可改)下符合命名约定的文件:
|
|
896
|
+
`task_{name}.ts/.js` 或 `{name}_task.ts/.js`(递归扫描;其余文件是共享库,不执行;
|
|
897
|
+
同名双写、超 `max` 直接拒启)。每任务一条专用线程 + 独立 V8 runtime。
|
|
898
|
+
|
|
899
|
+
**任务骨架**(顺序语义:poll → 处理 → commit;commit 按 offset+1,重复处理至
|
|
900
|
+
少一次):
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
export {};
|
|
904
|
+
const k = Kafka("default");
|
|
905
|
+
while (!tasks.stopping()) {
|
|
906
|
+
const { messages } = await k.poll(["orders"], { max: 100, timeoutMs: 1000 });
|
|
907
|
+
// commit(offset+1) 只推进该消息所在分区——多分区主题必须按分区各提交一次
|
|
908
|
+
// (取该分区最大 offset 的消息提交),只提交最后一条会丢其余分区的进度。
|
|
909
|
+
const deepest = new Map(); // partition → 该分区最深的消息
|
|
910
|
+
for (const m of messages) {
|
|
911
|
+
/* 业务处理(幂等) */
|
|
912
|
+
const cur = deepest.get(m.partition);
|
|
913
|
+
if (!cur || m.offset > cur.offset) deepest.set(m.partition, m);
|
|
914
|
+
}
|
|
915
|
+
for (const m of deepest.values()) await k.commit(m);
|
|
916
|
+
}
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
** RabbitMQ 拉取骨架**:
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
export {};
|
|
923
|
+
const r = RabbitMQ("default");
|
|
924
|
+
while (!tasks.stopping()) {
|
|
925
|
+
const { messages } = await r.poll(["q1"], { max: 10, timeoutMs: 1000 });
|
|
926
|
+
for (const m of messages) { /* 处理 */ await r.ack(m); }
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
硬规则:
|
|
931
|
+
|
|
932
|
+
- **运行时无 timer 全局**:`setTimeout/setInterval` 不可用;任务里等待一律
|
|
933
|
+
`await tasks.sleep(ms)`。
|
|
934
|
+
- **任务文件是 ES 模块**:用顶层 `await` 必须带一句 `export {};`(否则按 CJS 包装
|
|
935
|
+
直接 SyntaxError,见 §5)。
|
|
936
|
+
- **`timeoutMs` 与 `stop_grace_secs` 的互动(评审 N3)**:停机 flag 置位后任务有
|
|
937
|
+
`stop_grace_secs`(默认 30s)宽限自然收场;`timeoutMs` 应显著小于宽限(如 ≤1s),
|
|
938
|
+
否则任务在一次长 poll 中错过退出窗口,到点被看门狗强杀(记 Killed)。
|
|
939
|
+
- **崩溃自动重启**:异常退出按 1s→2s→4s…(cap 60s)退避重启,成功运行 ≥60s 归零;
|
|
940
|
+
启动/重启/停机逐条落日志(`task: <name> … → started / stopped / crashed`)。
|
|
941
|
+
- **热重载无**:改任务文件需重启进程(转译缓存按 mtime 自动失效)。
|
|
942
|
+
- **消费会话 topic 固化**:kafka 首次 `poll` 按当时的 topics 建立订阅,其后换
|
|
943
|
+
topics 被忽略(会话不重建);需换主题就换实例名。
|
|
944
|
+
- 与 `bus.*` 的关系:`bus` 是进程内/分布式**广播**(fire-and-forget),MQ 客户端是
|
|
945
|
+
**持久队列消费**(有 offset/ack 语义)。需要可靠逐条消费用后者。
|
|
946
|
+
|
|
947
|
+
最小无 broker 示例见 `sample/src/tasks/task_demo.ts`(每秒心跳)。
|
|
948
|
+
|
|
949
|
+
## 7. 响应信封与错误码
|
|
950
|
+
|
|
951
|
+
> 何时读我:设计错误返回或排查非 200 时。
|
|
952
|
+
|
|
953
|
+
统一信封 `{code, msg, data}`;**HTTP 状态码 = `code`**(`code=0` → 200;
|
|
954
|
+
`code<=0` 映射 500)。业务错误直接 `json.fail(400, "…")` 返回对应状态码,
|
|
955
|
+
无需另设错误通道。
|
|
956
|
+
|
|
957
|
+
| 场景 | HTTP | 信封示例 |
|
|
958
|
+
|---|---|---|
|
|
959
|
+
| 成功 | 200 | `{"code":0,"msg":"ok","data":…}` |
|
|
960
|
+
| 无路由匹配 / 目录穿越 | 404 | `{"code":404,"msg":"no route matched","data":null}` |
|
|
961
|
+
| 路径命中但动词未注册 | 405 | `{"code":405,"msg":"method DELETE not allowed","data":null}` |
|
|
962
|
+
| 非映射动词(`TRACE` 等) | 405 | `{"code":405,"msg":"method TRACE not allowed","data":null}` |
|
|
963
|
+
| 路由冲突(同 pattern 同方法双声明) | 500 | `{"code":500,"msg":"route conflict: GET /v1/api/user/{id} declared in a/api.ts and b/api.ts","data":null}` |
|
|
964
|
+
| TS 编译错误 / 模块解析失败 | 500 | `{"code":500,"msg":"…/api.ts: 语法错误…","data":null}` |
|
|
965
|
+
| handler 死循环 / 超时 | 408 | `{"code":408,"msg":"handler execution timed out","data":null}` |
|
|
966
|
+
| 上传超 `max_upload_bytes` | 413 | `{"code":413,"msg":"upload too large","data":null}` |
|
|
967
|
+
| blob 不存在 | 404 | `{"code":404,"msg":"blob not found","data":null}` |
|
|
968
|
+
| 证书过期进宽限期(仅 GET) | 403 | `{"code":403,"msg":"certificate expired: service available in grace period, but GET requests are restricted","data":null}` |
|
|
969
|
+
| 证书已过期(Expired,仅 GET,运行中热替换所致) | 403 | `{"code":403,"msg":"certificate expired: service unavailable","data":null}` |
|
|
970
|
+
| 租户头缺失/为空(`tenant.enable`) | 400 | `missing tenant header: X-TENANT-ID` |
|
|
971
|
+
| Bearer 缺失/无效/过期(`auth:` 启用) | 401 | `missing or invalid bearer token` |
|
|
972
|
+
|
|
973
|
+
业务层常用码约定:400 入参不合法、404 资源不存在、401 未认证、403 已认证但无权、
|
|
974
|
+
500 服务器内部错误。`json.fail` 的 `msg` 会原样进入信封,勿把内部细节(堆栈、SQL)
|
|
975
|
+
透给客户端。
|
|
976
|
+
|
|
977
|
+
## 8. 鉴权与多租户
|
|
978
|
+
|
|
979
|
+
> 何时读我:接口要登录态或多租户隔离时。
|
|
980
|
+
|
|
981
|
+
### auth:块存在即启用(oj-auth 守卫 + JS 端点)
|
|
982
|
+
|
|
983
|
+
config `auth:` 段存在即启用两层能力:**Bearer 守卫**(oj-auth 插件在前置管线完成)与
|
|
984
|
+
**`jwt` / `bcrypt` 全局注入**(供 JS 侧实现登录端点,API 细节见第 6 章 jwt / bcrypt / crypto)。
|
|
985
|
+
`jwt_secret` 配了却为空串 → 启动 fail-fast(不静默跳过)。
|
|
986
|
+
|
|
987
|
+
**auth 端点是业务路由,不是内置路由**:`login` / `refresh` / `logout` 由普通 JS 模块实现
|
|
988
|
+
(sample 提供参考实现 `sample/src/auth/`,目录镜像路由 `POST /auth/login|refresh|logout`):
|
|
989
|
+
`db` 查用户表、`bcrypt.verify` 校验密码、`jwt.sign` 签发 access token,refresh 为不透明
|
|
990
|
+
随机串 + KV session(轮换)。框架不内置这些路由——业务可整模块替换或改名。
|
|
991
|
+
|
|
992
|
+
| 端点(sample/src/auth/) | 请求体 | 响应 data |
|
|
993
|
+
|---|---|---|
|
|
994
|
+
| `POST /auth/login` | `{"username","password"}` | `{"access_token","refresh_token","expires_in"(秒),"user":{"id","roles"}}` |
|
|
995
|
+
| `POST /auth/refresh` | `{"refresh_token"}` | 同上(**轮换**:旧 refresh 立即失效) |
|
|
996
|
+
| `POST /auth/logout` | `{"refresh_token"}` | `null`(删 refresh session;access 到期前仍有效) |
|
|
997
|
+
|
|
998
|
+
因为是普通业务路由,这三个路径**必须在 `auth.anonymous_paths` 里显式匿名**,否则会被
|
|
999
|
+
自己的 Bearer 守卫拦成 401。
|
|
1000
|
+
|
|
1001
|
+
**Bearer 守卫**:路由表命中后、进 handler 前,oj-auth 插件对非匿名路径验签
|
|
1002
|
+
`Authorization: Bearer <access_token>`(缺失 / 验签失败 / 过期 → 401)。通过后 handler 里读
|
|
1003
|
+
`http.user`(`{id, roles, claims}`)。登录失败统一报 `invalid credentials`(不区分用户
|
|
1004
|
+
不存在/密码错)。
|
|
1005
|
+
|
|
1006
|
+
**匿名路径** `auth.anonymous_paths`:去 `{base}` 前缀的路径列表,尾 `/*` 为**一层**通配
|
|
1007
|
+
(`/pub/*` 命中 `/pub/x` 但不命中 `/pub`)。`{base}` 之外的路径(静态站点)不设防。
|
|
1008
|
+
|
|
1009
|
+
**用户表是业务约定,没有配置项**:框架不管用户表(不存在 `auth.user_table`),JS 端点
|
|
1010
|
+
按约定查询 `users` 表(`_platform` 伪模块持有归属),最小 schema:
|
|
1011
|
+
|
|
1012
|
+
```sql
|
|
1013
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
1014
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1015
|
+
username TEXT NOT NULL UNIQUE,
|
|
1016
|
+
password_hash TEXT NOT NULL, -- bcrypt
|
|
1017
|
+
roles TEXT NOT NULL DEFAULT '[]' -- JSON 数组串,如 '["admin"]'
|
|
1018
|
+
);
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
auth 段其余字段(`jwt_secret` 生产必改且空串启动 fail-fast、`signing_method`、
|
|
1022
|
+
access/refresh 时长)见第 10 章配置表。
|
|
1023
|
+
|
|
1024
|
+
```ts
|
|
1025
|
+
// 受保护路由:读验签后的用户身份(sample/src/auth_demo/me/api.ts)
|
|
1026
|
+
export default {
|
|
1027
|
+
get() {
|
|
1028
|
+
json.ok({ user: http.user });
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
### tenant:多租户头
|
|
1034
|
+
|
|
1035
|
+
```yaml
|
|
1036
|
+
tenant:
|
|
1037
|
+
enable: true
|
|
1038
|
+
header_key: "X-TENANT-ID" # 默认即此名
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
启用后所有 `{base}` 请求必须带该 header(缺失/空 → 400),值注入 `http.tenantId`
|
|
1042
|
+
供 handler 做数据隔离。`tenant.anonymous_paths` 与 auth 匿名列表同为「去 base 前缀 + 尾
|
|
1043
|
+
`/*`」形式,但 tenant 匹配是**严格一层**通配(更深路径需显式列出,如 `/idp/.well-known/*`;
|
|
1044
|
+
oj-auth 插件实现为多层前缀),豁免缺失 400——给 OIDC 302 跳转腿用(浏览器带不了自定义头);
|
|
1045
|
+
已带的头仍照常注入。
|
|
1046
|
+
**框架不自动改写 SQL**——行级过滤归业务(自行在查询里带
|
|
1047
|
+
tenant 条件)。启用期间测试请求也必须带头(第 9 章两约束)。
|
|
1048
|
+
|
|
1049
|
+
### auth_demo 走读(sample)
|
|
1050
|
+
|
|
1051
|
+
`sample/src/auth_demo/`:`me`(受保护,返回 `http.user`)与 `health`(匿名,
|
|
1052
|
+
在 `anonymous_paths`)两个路由;`sample/seed.sql` 已建 `users` 表并写入 demo 用户
|
|
1053
|
+
(`demo` / `demo1234`,角色 admin)。走读顺序:login 拿 token → 带 Bearer 打
|
|
1054
|
+
`/auth_demo/me/` → refresh 轮换 → logout 失效。sample 同时开了 tenant,
|
|
1055
|
+
curl 需另带 `-H 'X-TENANT-ID: acme'`(完整命令见 `sample/src/auth_demo/README.md`)。
|
|
1056
|
+
|
|
1057
|
+
### OIDC 演示走读(sample:idp + oidc)
|
|
1058
|
+
|
|
1059
|
+
`sample/src/idp/` 是同进程 **OP**(`/.well-known/openid-configuration`、`jwks.json`、
|
|
1060
|
+
`authorize`、`login`、`token`、`userinfo`——标准协议端点用 `json.raw` 出裸 JSON);
|
|
1061
|
+
`sample/src/oidc/` 是 **RP**(`login` → 302 授权 → `callback` 换会话桥接成本地
|
|
1062
|
+
`auth` token → `logout`)。OIDC 的 302 跳转腿带不了自定义头,故 demo 在
|
|
1063
|
+
`tenant.anonymous_paths` 里匿名 `/oidc/*`、`/idp/*`(键见第 10 章 tenant/auth);
|
|
1064
|
+
`oidc.rp`/`oidc.clients` 配置驱动,对接外部 IdP 只改 config。curl 全链路见
|
|
1065
|
+
`sample/README.md` 的「OIDC 演示」;实现走读(含 mermaid 时序图)与接入手册见
|
|
1066
|
+
`oidc-implementation.md` / `oidc-integration.md`(仓库在 `docs/` 下,发行包随 devkit
|
|
1067
|
+
同目录附带)。
|
|
1068
|
+
|
|
1069
|
+
## 9. 测试
|
|
1070
|
+
|
|
1071
|
+
> 何时读我:写模块测试或搭 CI 时。
|
|
1072
|
+
|
|
1073
|
+
两层测试互补:**L1 `oj test`**(进程内真实 v8 + 真实路由/鉴权/租户管线 + 真实后端,
|
|
1074
|
+
零 TCP);**L2 vitest 纯 mock**(Node 直调 handler,mock 全局,毫秒级)。
|
|
1075
|
+
一句话:**L2 测 handler 纯逻辑(快、稳),L1 测端到端行为(真、全)**。
|
|
1076
|
+
|
|
1077
|
+
### L1:`oj test`
|
|
1078
|
+
|
|
1079
|
+
目录约定:测试文件放 `tests/*.test.ts`(目录由 `-t/--tests` 指定,相对 config 所在目录,
|
|
1080
|
+
默认 `tests`)。
|
|
1081
|
+
|
|
1082
|
+
```bash
|
|
1083
|
+
./bin/oj test -c sample/config.yaml -d sample/src # human 摘要
|
|
1084
|
+
./bin/oj test -c config.yaml -d src --format junit --output l1.xml # CI 报告
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
| 旗标 | 说明 |
|
|
1088
|
+
|---|---|
|
|
1089
|
+
| `-c/--config` | 配置文件(默认 `config.yaml`) |
|
|
1090
|
+
| `-b/--base` | API 基础前缀覆盖(默认用 config `server.api_prefix`) |
|
|
1091
|
+
| `-d/--dir` | 源码目录 `src` 或产物 `dist`(默认自动判定) |
|
|
1092
|
+
| `-t/--tests` | 测试目录,相对 config 目录(默认 `tests`) |
|
|
1093
|
+
| `--format` | `human`(默认)/ `tap` / `junit` / `json` |
|
|
1094
|
+
| `--output` | 报告落盘文件;省略打到 stdout(机器格式 stdout 纯净) |
|
|
1095
|
+
|
|
1096
|
+
退出码:**全部通过 = 0,任一失败 = 1**——可直接做 CI 门禁。
|
|
1097
|
+
|
|
1098
|
+
测试文件用注入的全局 `client` 与 `describe/it/expect`(类型见 `global.d.ts`):
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
describe("user account", () => {
|
|
1102
|
+
it("lists accounts (auth + tenant)", async () => {
|
|
1103
|
+
const token = await client.login("demo", "demo1234", { "X-TENANT-ID": "default" });
|
|
1104
|
+
const r = await client.get("/user/account", {
|
|
1105
|
+
headers: { Authorization: "Bearer " + token, "X-TENANT-ID": "default" },
|
|
1106
|
+
});
|
|
1107
|
+
expect(r.status).toBe(200);
|
|
1108
|
+
const body = JSON.parse(r.body); // 信封:{ code, msg, data }
|
|
1109
|
+
expect(body.code).toBe(0);
|
|
1110
|
+
expect(Array.isArray(body.data)).toBeTruthy();
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
注入的全局 API:
|
|
1116
|
+
|
|
1117
|
+
| API | 说明 |
|
|
1118
|
+
|---|---|
|
|
1119
|
+
| `client.get/post/put/del/patch/head/options(path, opts?)` | 进程内派发;`opts = { headers?, body? }`,返回 `ClientResp { status, headers, body, upgrade }`;`path` 相对 base(如 `"/user/account"`) |
|
|
1120
|
+
| `client.login(username, password, headers?)` | POST 业务路由 `/auth/login`(sample/src/auth/)→ 返回 `access_token`(失败抛错;`headers` 透传,如租户头) |
|
|
1121
|
+
| `describe(name, fn)` / `it(name, fn)` / `beforeEach(fn)` | vitest 风格子集 |
|
|
1122
|
+
| `expect(actual)` | `.toBe / .toEqual / .toBeTruthy / .toBeFalsy / .toContain` |
|
|
1123
|
+
| `finish()` | 标记会话结束 |
|
|
1124
|
+
|
|
1125
|
+
**两个必知约束(由 config 决定)**:
|
|
1126
|
+
|
|
1127
|
+
1. 多租户:config 启用 `tenant` 后,每个请求都必须带 `X-TENANT-ID` 头,否则 400。
|
|
1128
|
+
2. 鉴权:config 启用 `auth` 后,除匿名路径外都要带 `Authorization: Bearer <token>`,否则 401。
|
|
1129
|
+
|
|
1130
|
+
> `beforeEach` 注册的是**单一全局钩子**,跨多个 `describe` 会被覆盖;多 describe 文件建议
|
|
1131
|
+
> 在各 `it` 内联准备(如每个用例自己 `client.login`),避免互相干扰。
|
|
1132
|
+
|
|
1133
|
+
适用:API 端到端行为(路由/鉴权/租户/真实 DB/bus 广播/信封)与契约回归;
|
|
1134
|
+
代价是启动开销,适合 CI 与关键链路守护。
|
|
1135
|
+
|
|
1136
|
+
### L2:vitest 纯 mock(`unit/` 独立 npm 包)
|
|
1137
|
+
|
|
1138
|
+
```bash
|
|
1139
|
+
cd sample && npm run test:unit # 统一入口(等价 cd unit && npm ci && npx vitest run)
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
结构:`mocks/oj-globals.ts` 提供 `installGlobals(opts?)`(把 `db/json/http/bus/log`
|
|
1143
|
+
换成可控桩,返回响应捕获 `{code,msg,data}`;`lastPublished()` 取 `bus.publish` 记录,
|
|
1144
|
+
`lastSqlCalls()` 取 `db.query/exec` 的 SQL 与绑定参数——可断言 handler 走了哪个分支);
|
|
1145
|
+
`invoke.ts` 提供 `invoke(handler, method, opts?)`(装桩 → 调 handler → flush 微任务 →
|
|
1146
|
+
返回 `{ ...capture, published }`);`*.spec.ts` 直接 import 真实 `../src/.../api` 的 handler。
|
|
1147
|
+
|
|
1148
|
+
```ts
|
|
1149
|
+
import { describe, it, expect } from "vitest";
|
|
1150
|
+
import account from "../src/user/account/api";
|
|
1151
|
+
import { invoke } from "./invoke";
|
|
1152
|
+
import { lastSqlCalls } from "./mocks/oj-globals";
|
|
1153
|
+
|
|
1154
|
+
describe("user/account (L2 分支 + SQL)", () => {
|
|
1155
|
+
it("post 缺 name → 400,且不落任何 SQL", async () => {
|
|
1156
|
+
const r = await invoke(account, "post", { body: { role: "admin" } });
|
|
1157
|
+
expect(r.code).toBe(400);
|
|
1158
|
+
expect(r.msg).toBe("name required");
|
|
1159
|
+
expect(lastSqlCalls()).toEqual([]);
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
适用:handler **内部分支**与边界(入参校验分支、默认值回落、纯函数如
|
|
1165
|
+
`requireRole`/`positiveId`/`lineLength` 的固定日期期望值)、「发出了什么 SQL」、
|
|
1166
|
+
bus 事件**内容**,以及 TDD 快速回归;
|
|
1167
|
+
局限:mock 不是真实后端,发现不了鉴权/租户/路由装配等集成层问题。
|
|
1168
|
+
|
|
1169
|
+
> **与 L1 分工(避免双写)**:契约类断言(字段结构、分页包装、状态码、camelCase)只放
|
|
1170
|
+
> L1;分支类(输入 X 得 Y、边界、纯函数)只放 L2。判据见 `docs/modules/08-testing.md` §4。
|
|
1171
|
+
|
|
1172
|
+
### 选型与推荐组合
|
|
1173
|
+
|
|
1174
|
+
| 维度 | L1 `oj test` | L2 vitest |
|
|
1175
|
+
|---|---|---|
|
|
1176
|
+
| 运行时 | 真实 deno_core(v8) + axum | Node + vitest(无 v8) |
|
|
1177
|
+
| 后端 | 真实 DB/KV/bus | mock 桩 |
|
|
1178
|
+
| 速度 | 较慢(启动开销) | 极快 |
|
|
1179
|
+
| 覆盖 | 路由/鉴权/租户/DB/总线 | handler 内部分支与纯函数 |
|
|
1180
|
+
| 稳定性 | 受后端/配置影响 | 稳定、无副作用 |
|
|
1181
|
+
|
|
1182
|
+
**推荐组合:开发期 L2 快速验证逻辑,CI 用 L1 守护端到端契约;两层都绿才有信心发布。**
|
|
1183
|
+
|
|
1184
|
+
CI 已内置(`.github/workflows/plugin-matrix.yml` 的 `sample-tests` job):
|
|
1185
|
+
`cargo run --release -p xtask -- build`(workspace 构建,产出 `bin/oj` + 全部第一方插件,
|
|
1186
|
+
勿用 `cargo build -p oj`——会按不同 feature 归一化重编 rusty_v8)→
|
|
1187
|
+
`./bin/oj test -c sample/config.yaml -d sample/src --format junit` → vitest。
|
|
1188
|
+
|
|
1189
|
+
依赖管理:L2 的 vitest 声明在 `unit/package.json` 的 `devDependencies`,与运行时依赖
|
|
1190
|
+
(如 `escape-goat`)隔离——被测物不携带测试工具;统一入口在 `sample/package.json`
|
|
1191
|
+
(`npm run test` = `test:unit` + `test:api`)。
|
|
1192
|
+
|
|
1193
|
+
## 10. 配置 config.yaml
|
|
1194
|
+
|
|
1195
|
+
> 何时读我:起服务前定配置,或排查启动 fail-fast 时。
|
|
1196
|
+
|
|
1197
|
+
全字段可省(均有默认),除证书两路径**必配**。相对路径相对 **config 所在目录**;
|
|
1198
|
+
命令行相对路径相对 CWD。
|
|
1199
|
+
|
|
1200
|
+
### server
|
|
1201
|
+
|
|
1202
|
+
| 字段 | 默认 | 说明 |
|
|
1203
|
+
|---|---|---|
|
|
1204
|
+
| `host` | `"localhost"` | 监听地址 |
|
|
1205
|
+
| `port` | `9778` | 监听端口;<1024 属特权端口(需 root),不要配成 `778` 之类 |
|
|
1206
|
+
| `api_prefix` | `"/v1/api"` | API 基础路由前缀;CLI `-b` 显式给出时覆盖;空前缀(空串/纯斜杠)拒绝启动。旧键名 `base` 仍兼容(并存 → duplicate field 报错) |
|
|
1207
|
+
| `timeout` | `"30s"` | 单请求执行超时(超时熔断 → 408);单位支持 `s/sec/secs/ms/m/min/h/d` |
|
|
1208
|
+
| `pool_size` | `4` | JS 执行线程数 = 并行请求上限 |
|
|
1209
|
+
| `max_upload_bytes` | `10485760`(10MB) | 上传体积上限;axum 层再乘 2 做硬顶(双闸,见第 13 章) |
|
|
1210
|
+
| `app_path` | 无 | 静态站点根目录;**省略 = 不开静态服务**(config 配置相对 config 目录;CLI `--app-path` 相对 CWD)。API 未命中的 GET/HEAD 落此目录(目录 → `index.html`);目录不存在启动即报错;穿越段(含 `%2F`)404;无 SPA 回退/Range/ETag。**准入门**:`--api-path` 与本项至少显式指定其一,两者皆指定则都必须存在 |
|
|
1211
|
+
| `app_prefix` | `"/"` | 静态站点前缀;默认 `/` = 全路径兜底(与旧版一致)。设为如 `/site` 时仅 `/site/*` 的 GET/HEAD 落静态(前缀剥除后解析,`/site` → `index.html`),前缀外 404;API 路由永远优先。必须以 `/` 开头,否则启动报错 |
|
|
1212
|
+
| `logs_dir` | 无(= config 目录下 `./logs`) | 日志目录(终端输出完整镜像落盘;每次启动新建文件 `server-<启动秒>_<pid>.log`,按 `logs_max_m` 滚动、保留 `logs_keep_files` 个);不存在自动创建
|
|
1213
|
+
| `logs_max_m` | `100` | 单个日志文件大小上限(单位 M;**<100 按 100 生效**),超过滚动为 `base.1.log` 依次后移 |
|
|
1214
|
+
| `logs_keep_files` | `10` | 日志文件保留个数(含活动文件,超出删除;最小生效值 2) | |
|
|
1215
|
+
| `console_log` | `false` | 终端输出开关。**默认 false = 只落盘**,终端保持干净(stdout 与 stderr 一起静默,因为 tracing 控制台层写的是 stderr);`true` = 额外回写终端。CLI `--console-log` 亦可打开(与配置取「或」)。**非 unix 平台无落盘,此时强制保留终端输出并告警**。启动失败的最终退出原因无论开关都直写终端(`echo_terminal`,原始 stderr fd 副本) | |
|
|
1216
|
+
| `public_key_path` | **必配** | 证书校验公钥(SPKI PEM;仅验签,私钥不落服务器) |
|
|
1217
|
+
| `certificate_path` | **必配** | JWS 证书(`Base64URL(Header).Payload.Signature`,RS256) |
|
|
1218
|
+
| `grace_days` | `30` | 证书过期后宽限天数(缩窄可加速告警) |
|
|
1219
|
+
| `migrate_on_start` | `auto`(dev)/ `verify`(release) | 迁移门禁:auto 启动即应用;verify 账本落后拒启(M004,先 `oj migrate`);`off` 迁移完全归运维 |
|
|
1220
|
+
| `ownership_guard` | `"warn"` | 表归属守卫:`warn` 跨模块表访问仅告警;`deny` 未声明 deps 拒绝执行(500 附修复指引) |
|
|
1221
|
+
|
|
1222
|
+
### db —— 命名库(多库混用)
|
|
1223
|
+
|
|
1224
|
+
`name → DSN` 映射:`sqlite://<path>`(相对 config 目录,缺文件自动建空库)、
|
|
1225
|
+
`sqlite::memory:`(仅测试,重启即丢)、`mysql://…`、`postgres://…`(原样透传)。
|
|
1226
|
+
|
|
1227
|
+
- 内置只有 sqlite/memory;`mysql`/`postgres` 需对应插件(oj-db-mysql / oj-db-postgres),
|
|
1228
|
+
未装插件 → 启动报 `unknown db scheme`。
|
|
1229
|
+
- `DB("name")` 按名取库;查询构造器自动按库方言出 SQL;裸 SQL 占位符方言归业务
|
|
1230
|
+
(sqlite/mysql `?`,postgres `$1`)。
|
|
1231
|
+
- `seed.sql` 仅对 **default 库且为 sqlite** 时重放。
|
|
1232
|
+
- mysql/pg 连不上启动 fail-fast(连接串错/库未建)。
|
|
1233
|
+
|
|
1234
|
+
### redis
|
|
1235
|
+
|
|
1236
|
+
```yaml
|
|
1237
|
+
redis:
|
|
1238
|
+
default: "redis://127.0.0.1:6379/1"
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
`default` **配置存在即真连**(启动 fail-fast:连不上直接报错退出,不静默退回内存),
|
|
1242
|
+
需 oj-kv-redis 插件;未装插件 → 启动报 `no kv plugin loaded`。段缺失/全注释 →
|
|
1243
|
+
进程内存 KV。真连后 `kv`/`redis` 全局与 auth 会话共享同一 Redis(多实例水平扩展的前提)。
|
|
1244
|
+
仅 `default` 被使用,其余键 warn 忽略。
|
|
1245
|
+
|
|
1246
|
+
### es
|
|
1247
|
+
|
|
1248
|
+
```yaml
|
|
1249
|
+
es:
|
|
1250
|
+
endpoint: "http://127.0.0.1:9200" # 块存在即启用;尾斜杠自动剪除
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
存在即启用 `es.search/index/del`,需 oj-es 插件;缺失时调用报 `es not configured`。
|
|
1254
|
+
config 声明了 `es` 但无 es 插件 → 启动 fail fast。
|
|
1255
|
+
|
|
1256
|
+
### blob —— 对象存储
|
|
1257
|
+
|
|
1258
|
+
```yaml
|
|
1259
|
+
blob:
|
|
1260
|
+
driver: "local" # local | s3(s3 需 oj-blob-s3 插件)
|
|
1261
|
+
root: "uploads" # local 专用:存储根(相对 config 目录绝对化,缺目录自动建,需写权限)
|
|
1262
|
+
# s3:endpoint / access_key / secret_key 可选,bucket / region 必填(缺失 fail-fast)
|
|
1263
|
+
# path_style: true # MinIO/自建 S3 需 true(默认 virtual-hosted)
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
块存在即启用 `blob.*` 全局与 `{base}/blob/{key}` 下载路由。命名多后端:
|
|
1267
|
+
`blob.backends.<name>` 各写一段(与平铺字段互斥,并存且平铺非默认 → 歧义报错),
|
|
1268
|
+
JS 侧 `blob("name")` 取用。
|
|
1269
|
+
|
|
1270
|
+
### broker —— 事件总线(三种 kind)
|
|
1271
|
+
|
|
1272
|
+
```yaml
|
|
1273
|
+
broker:
|
|
1274
|
+
kind: kafka # local(默认,进程内)/ kafka / rabbitmq
|
|
1275
|
+
brokers: ["127.0.0.1:9092"] # kafka 必需;rabbitmq 可省(取 url,再取 brokers[0])
|
|
1276
|
+
# group: "oj-bus" # kafka 消费组默认
|
|
1277
|
+
# topic_prefix: "oj-bus" # kafka 物理 topic 前缀 / rabbitmq 交换名,默认 oj-bus
|
|
1278
|
+
# url: "amqp://…" # rabbitmq 用
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
缺省(无 `broker:` 段)= 进程内 Bus(跨实例不互通)。`kind: kafka`/`rabbitmq` 需对应
|
|
1282
|
+
插件,未装 → 启动报 `unknown broker kind`。
|
|
1283
|
+
|
|
1284
|
+
### ws —— WebSocket 运行时(v0.1.10)
|
|
1285
|
+
|
|
1286
|
+
```yaml
|
|
1287
|
+
ws:
|
|
1288
|
+
# max_connections: 1000 # 全局并发连接闸门:超限 upgrade 返 503;0 = 不限
|
|
1289
|
+
# workers_per_route: 2 # 每路由无状态 Worker 数(共享执行该路由全部连接的帧)
|
|
1290
|
+
# idle_linger_ms: 0 # 路由连接归零后 Worker 池保活毫秒数(0 = 立即退役)
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
段缺省 = 全默认。语义:内存与连接数解耦——每连接只持会话态(sess.state ≈KB 级),
|
|
1294
|
+
V8 runtime 按 Worker 数常驻;单帧超时只断该连接(毒化 Worker 由池补员)。
|
|
1295
|
+
执行模型详见第 4 章 §ws.ts。
|
|
1296
|
+
|
|
1297
|
+
### tenant / auth
|
|
1298
|
+
|
|
1299
|
+
字段与语义见第 8 章(tenant 默认关闭、header 默认 `X-TENANT-ID`、`anonymous_paths`
|
|
1300
|
+
跳转腿豁免;auth 的 `jwt_secret`(空串启动 fail-fast,生产必改)、`signing_method`
|
|
1301
|
+
(HS256|HS384|HS512,默认 HS256)、`access_token_duration`(默认 60s)、
|
|
1302
|
+
`refresh_token_duration`(默认 720h)、`anonymous_paths`——无 `user_table` 配置,
|
|
1303
|
+
用户表是业务约定)。
|
|
1304
|
+
|
|
1305
|
+
### oidc —— 内置 OP/RP 原语(段存在即启用)
|
|
1306
|
+
|
|
1307
|
+
RS256 签名/验签原语与装配期配置(第 6 章 `oidc` 全局);缺 `oidc:` 段调用报
|
|
1308
|
+
`oidc not configured`。
|
|
1309
|
+
|
|
1310
|
+
| 键 | 说明 |
|
|
1311
|
+
|---|---|
|
|
1312
|
+
| `issuer` | OP 标识/发现基址(如 `http://localhost:9778/v1/api/idp`) |
|
|
1313
|
+
| `private_key_path` | RS256 私钥(PKCS#8 PEM,相对 config 目录;demo 用 `sample/config/oidc_rs256.pem`,与示例证书同性质,勿用于生产) |
|
|
1314
|
+
| `rp` | RP 侧 `tenant → IdP` 映射:`issuer` / `client_id` / `client_secret` / `scope` |
|
|
1315
|
+
| `clients` | OP 侧 client 白名单:`secret` / `redirect_uris`(**精确串**,未命中绝不重定向)/ `tenant` |
|
|
1316
|
+
|
|
1317
|
+
私钥只在 Rust 侧解析使用;`client_secret` 经 `oidc.rp`/`oidc.clients` 对 JS 可读——
|
|
1318
|
+
与 `auth.jwt_secret` 同一信任级(能跑 handler 即能拿鉴权密钥)。
|
|
1319
|
+
|
|
1320
|
+
### plugins / plugins_dir —— 插件装配(map 一段三用)
|
|
1321
|
+
|
|
1322
|
+
`plugins:` 为 **map**,一段三用:
|
|
1323
|
+
|
|
1324
|
+
- **键 = 加载清单**:非空 map → 严格模式,只装配列出的插件(缺文件/身份不符/`@semver`
|
|
1325
|
+
pin 不符 fail fast)。
|
|
1326
|
+
- **值 = 插件 cfg**:必须是 YAML 映射(对象)——非空对象**原样透传**为该插件 init cfg
|
|
1327
|
+
(跳过回落,第三方插件正规通道);空对象 `{}` = 不覆盖,回落轴适配器/默认来源;
|
|
1328
|
+
字符串/列表等非对象值视为未提供,静默回落。
|
|
1329
|
+
- **缺省/空 map = 扫描模式**:加载插件目录全部(目录不存在/为空 = 零插件,仅内置后端)。
|
|
1330
|
+
旧 list 写法 `plugins: [a, b]` 已废弃(解析报错)。
|
|
1331
|
+
|
|
1332
|
+
```yaml
|
|
1333
|
+
plugins:
|
|
1334
|
+
kv-redis: {} # 只要清单不要覆盖 → 回落轴适配器(顶层 redis: 段仍生效)
|
|
1335
|
+
auth: { jwt_secret: "..." } # 非空对象 = 原样透传,跳过回落
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
命名契约:插件文件 stem == descriptor name == `plugins:` 键(如 `libauth.dylib` ↔ `auth`);
|
|
1339
|
+
扫描模式以文件 stem 命中配置键。
|
|
1340
|
+
|
|
1341
|
+
`plugins_dir` 目录四级发现(先到先得),最终目录 = `<plugins_dir>/<host-triple>/`:
|
|
1342
|
+
|
|
1343
|
+
1. 环境变量 `OJ_PLUGINS_DIR`
|
|
1344
|
+
2. config `plugins_dir`(相对 config 目录)
|
|
1345
|
+
3. `<exe>/plugins`(`bin/oj` 旁即 `bin/plugins`)
|
|
1346
|
+
4. `<workspace_root>/bin/plugins`
|
|
1347
|
+
|
|
1348
|
+
已装配插件的清单可查:JS `plugins()`(第 6 章)与内置端点 `GET {base}/plugins`
|
|
1349
|
+
(公共端点,不走 Bearer;`plugins` 为保留路径,业务模块勿占用该目录名)。
|
|
1350
|
+
|
|
1351
|
+
### 证书三字段:必配不可绕过
|
|
1352
|
+
|
|
1353
|
+
- `public_key_path` / `certificate_path` 缺任一 → 启动报错退出;**没有任何 config/CLI
|
|
1354
|
+
开关可跳过证书校验**;CLI `--cert-path` / `--key-path` 可覆盖路径,但不豁免校验。
|
|
1355
|
+
- 证书有效期内正常;过期进入宽限期(`grace_days`,默认 30 天)→ **所有 GET 返回 403**
|
|
1356
|
+
(其余方法正常),替换证书即恢复,服务不中断。
|
|
1357
|
+
- 宽限期结束再启动 → 记 ERROR 后进程退出(不服务)。
|
|
1358
|
+
- 证书 / 公钥文件被覆盖即**热加载**(notify 事件驱动,无需重启)。
|
|
1359
|
+
|
|
1360
|
+
证书生成/续期见第 1 章与第 12 章。
|
|
1361
|
+
|
|
1362
|
+
### fail-fast 行为清单(启动即退,不等第一个请求)
|
|
1363
|
+
|
|
1364
|
+
| 触发 | 表现 |
|
|
1365
|
+
|---|---|
|
|
1366
|
+
| 证书两路径缺任一 | `certificate is mandatory but not configured` / `certificate not configured` 退出 |
|
|
1367
|
+
| 证书过期且宽限期尽 | `certificate has expired and grace period elapsed` 退出 |
|
|
1368
|
+
| `redis.default` 配置但连不上 | 报连接错误退出(不静默退回内存) |
|
|
1369
|
+
| 未知 db scheme | `unknown db scheme` 退出 |
|
|
1370
|
+
| db 方案冲突(内置与插件 scheme 交集) | fail fast 退出 |
|
|
1371
|
+
| `broker.kind` 非法或声明但无对应插件 | `unknown broker kind` 退出 |
|
|
1372
|
+
| `blob.driver` 非 local/s3;s3 缺 bucket/region | 启动报错退出 |
|
|
1373
|
+
| `auth.jwt_secret` 为空串 | `auth.jwt_secret must not be empty` 退出 |
|
|
1374
|
+
| `oidc.issuer` / `oidc.private_key_path` 为空,或私钥非 PKCS#8 PEM | `oidc: …` 报错退出(`oidc:` 段存在即校验) |
|
|
1375
|
+
| config 声明 `es:` 但无 es 插件 | fail fast 退出 |
|
|
1376
|
+
| `-d` 目录不存在 | 启动即报错 |
|
|
1377
|
+
| 首层子目录缺 `manifest.yaml` | `missing manifest.yaml` 退出 |
|
|
1378
|
+
| `manifest.yaml` 的 `name` ≠ 目录名 | `manifest name "x" != directory name "y"` 退出 |
|
|
1379
|
+
| 版本目录名碰撞 | `version dir collision` 退出 |
|
|
1380
|
+
| release:`manifests.yaml` 缺失/损坏/指向不存在版本 | 报错提示先 `oj build` |
|
|
1381
|
+
| `neither api path … specified` | 准入门三态:`--api-path` 与静态站点(`server.app_path` / `--app-path`)至少显式指定其一 |
|
|
1382
|
+
| `api path not found: …` / `static site dir not found: …` | 指定了 api / 静态目录但不存在(皆指定时两者都必须存在) |
|
|
1383
|
+
| `server.app_path` 目录不存在 | 启动报错退出 |
|
|
1384
|
+
| 同一张表被两个模块 schema.yaml 声明 | 表归属单射违反(S002),启动拒启 |
|
|
1385
|
+
| release 下迁移账本落后(verify 门禁) | M004 拒启,报错附 `oj migrate` 命令 |
|
|
1386
|
+
| `ext_boot.js` 存在但语法错/导入失败/顶层 await 抛错 | 启动期预热即 `ext_boot: …` 退出(服务不监听) |
|
|
1387
|
+
|
|
1388
|
+
## 11. 构建与发布
|
|
1389
|
+
|
|
1390
|
+
> 何时读我:dev 跑通后要交付时。
|
|
1391
|
+
|
|
1392
|
+
### `oj build [module] [-d src] [-o dist] [--no-minify]`
|
|
1393
|
+
|
|
1394
|
+
```bash
|
|
1395
|
+
./bin/oj build -d src -o dist # 全部模块
|
|
1396
|
+
./bin/oj build user -d src -o dist # 单模块
|
|
1397
|
+
./bin/oj build user --no-minify # 排障:多行可读产物(含内联 sourcemap)
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
`oj build` 内建结构检查 S001–S007(manifest 合法性/表归属/deps/tables 一致/seed 纪律/迁移序列),违规 fail build;`oj build --check` 只查不落盘,作 CI 门禁。
|
|
1401
|
+
|
|
1402
|
+
每个模块构建为版本目录 `dist/<module>-<version>/`(版本读自模块 `manifest.yaml`;
|
|
1403
|
+
同版本重建先清空旧目录)。构建零磁盘副作用(db 用内存库,不执行 seed)。
|
|
1404
|
+
|
|
1405
|
+
| 产物 | 说明 |
|
|
1406
|
+
|---|---|
|
|
1407
|
+
| `<module>-<version>/api.js` 等 | 全部 `.ts` 原路径换 `.js`(api.ts → 同目录 api.js);默认转译 + minify 成单行(剥注释) |
|
|
1408
|
+
| `<module>-<version>/routes.js` | 本模块路由表(pattern 无首斜杠、不含 base、含模块名段);**`.route` 已从 api.js 剥离**——release 下路由事实唯一来源 |
|
|
1409
|
+
| `<module>-<version>/manifest.yaml` | 原样复制 |
|
|
1410
|
+
| `dist/manifests.yaml` | 模块 → 锁定版本(原子写,保留其他模块条目);多版本目录可共存,锁决定 release 加载哪个 |
|
|
1411
|
+
| `dist/<module>-<version>.tgz` | 确定性发布包(同输入字节一致,可校验完整性) |
|
|
1412
|
+
|
|
1413
|
+
跨模块相对导入构建期改写为指向目标模块版本目录的相对路径;目标模块未构建过则报错
|
|
1414
|
+
(先 `oj build user`)。npm 依赖不打包进 tgz(裸 specifier 运行时沿 `node_modules`
|
|
1415
|
+
解析,发布物需自带)。
|
|
1416
|
+
|
|
1417
|
+
### release 加载语义
|
|
1418
|
+
|
|
1419
|
+
`server --api-path dist`(含 `manifests.yaml` → 自动 release)按锁逐模块加载各版本目录的
|
|
1420
|
+
`routes.js` 聚合路由;锁缺失/损坏、指向不存在的版本、任何条目非法 → 直接报错
|
|
1421
|
+
(提示先 `oj build`)。**目录镜像路由在 release 不存在**——路由只认 routes.js,
|
|
1422
|
+
所以发布前必须 `oj build`。
|
|
1423
|
+
|
|
1424
|
+
### 发行包布局与打包脚本
|
|
1425
|
+
|
|
1426
|
+
按平台选用脚本(两者产物布局一致,仅归档格式不同):
|
|
1427
|
+
|
|
1428
|
+
```bash
|
|
1429
|
+
bash scripts/deploy.sh # macOS / Linux → dist/oj-v<version>-<triple>.tar.gz
|
|
1430
|
+
scripts\deploy.bat # Windows (cmd) → dist/oj-v<version>-<triple>.zip
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
`<triple>` 取自 `rustc -vV` 的 host 行(如 `aarch64-apple-darwin`、
|
|
1434
|
+
`x86_64-unknown-linux-gnu`、`x86_64-pc-windows-msvc`),与插件加载器的发现路径
|
|
1435
|
+
`<exe>/plugins/<triple>/` 同形。版本取自 `oj/Cargo.toml`。每个包附带同名
|
|
1436
|
+
`.sha256` 校验文件。
|
|
1437
|
+
|
|
1438
|
+
包内容(解包后,根路径 = 包名):
|
|
1439
|
+
|
|
1440
|
+
```
|
|
1441
|
+
oj-v<version>-<triple>/
|
|
1442
|
+
├── oj # 主程序(Windows 上为 oj.exe;独立二进制,deno_core 内嵌)
|
|
1443
|
+
├── plugins/<triple>/ # 插件 cdylib(oj-es、oj-db-*、oj-kv-redis、oj-blob-s3、oj-bus-*)
|
|
1444
|
+
└── devkit/ # 本手册 + SKILL.md + global.d.ts(agent/编辑器类型提示)
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
目标机部署:解包 → 项目目录放 `config.yaml` + `dist/` + `seed.sql`(可选)+
|
|
1448
|
+
vendored `node_modules/`(不打进 tgz)→ `./oj server -c config.yaml --api-path dist`。
|
|
1449
|
+
启动时把模块清单 + 路由表写入日志,可据此核对发布是否完整(终端默认静默:
|
|
1450
|
+
`tail -f logs/server-*.log`,或启动时加 `--console-log`)。
|
|
1451
|
+
|
|
1452
|
+
## 12. 运维要点
|
|
1453
|
+
|
|
1454
|
+
> 何时读我:服务跑起来之后的证书/日志/排障/升级。
|
|
1455
|
+
|
|
1456
|
+
### 证书生命周期
|
|
1457
|
+
|
|
1458
|
+
```bash
|
|
1459
|
+
# 首次签发(三件:private.pem / public.pem / cert.jws)
|
|
1460
|
+
cargo run -p oj-cert -- gen -o config --days 365
|
|
1461
|
+
# 到期续签:用现有私钥重签(公钥不变,服务器无需换公钥)
|
|
1462
|
+
cargo run -p oj-cert -- renew -k config/private.pem --days 365
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
- 私钥**仅签发端保管**;服务器只放 `public.pem` + `cert.jws`。
|
|
1466
|
+
- CLI `--cert-path` / `--key-path` 仅覆盖路径,**不豁免校验**。
|
|
1467
|
+
- 运行中替换证书文件 → **热加载**即时生效(无需重启);重载失败保留旧状态并记 warn。
|
|
1468
|
+
- 探测:`GET {base}/health` 实时返回证书状态(宽限/过期仍可访问,便于监控):
|
|
1469
|
+
|
|
1470
|
+
```json
|
|
1471
|
+
{ "status": "OK", "certificate_status": "valid|grace|expired",
|
|
1472
|
+
"certificate_expiry": "2027-01-01T00:00:00Z", "grace_remaining_secs": 123456 }
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
- 宽限语义:过期 → GET 全部 403(其余方法正常);宽限尽再启动 → 进程退出。
|
|
1476
|
+
调大 `grace_days` 仅延长宽限、不改 `exp`。
|
|
1477
|
+
|
|
1478
|
+
### 热重载语义
|
|
1479
|
+
|
|
1480
|
+
| 变更 | 是否热生效 |
|
|
1481
|
+
|---|---|
|
|
1482
|
+
| dev 模式改 `api.ts` 及其依赖 | 是(mtime 版本化 specifier 失效缓存,下次请求用新代码) |
|
|
1483
|
+
| release 同版本重建 dist(清场重写同目录) | 是(同样按 mtime 失效) |
|
|
1484
|
+
| release 换版本(改锁指向新版本目录) | **否**——`dist/manifests.yaml` 仅启动时读取,需重启 |
|
|
1485
|
+
| 证书 / 公钥文件被覆盖 | 是(notify 事件驱动,不轮询 mtime) |
|
|
1486
|
+
| `config.yaml` | 否(重启生效) |
|
|
1487
|
+
| `seed.sql` | 否(仅启动重放) |
|
|
1488
|
+
| `manifest.yaml` 新增/删除模块 | 否(重启生效) |
|
|
1489
|
+
| schema.yaml / migrations 变更 | 否(重启或 oj migrate 生效;dev auto 在下次启动收敛) |
|
|
1490
|
+
| `node_modules` 新增包 | 否(已加载包缓存于进程,重启生效) |
|
|
1491
|
+
| `ext_boot.js` | **否**——装配期冻结 `file://…?v=<mtime>`,改了必须重启。启动日志会打印 `ext_boot: loaded <绝对路径> (<spec>)`,这是核对「跑的是不是改过的那份」的唯一依据 |
|
|
1492
|
+
|
|
1493
|
+
### 超时与资源
|
|
1494
|
+
|
|
1495
|
+
- 超过 `server.timeout` 的 handler 被 `terminate_execution` 强杀,HTTP 返回 408;
|
|
1496
|
+
被杀的 JsRuntime **丢弃不回池**,server 不崩、后续请求正常(这是对死循环的唯一熔断手段)。
|
|
1497
|
+
- `RuntimePool` 最大空闲 16,负载后自动收缩;`pool_size` 等于并行请求上限
|
|
1498
|
+
(过高吃内存,过低排队)。
|
|
1499
|
+
|
|
1500
|
+
### 日志
|
|
1501
|
+
|
|
1502
|
+
`tracing-subscriber` 结构化输出:启动横幅(模块/路由表)、请求日志(方法/路径/状态/耗时)、
|
|
1503
|
+
handler 内 `log.debug/info/warn/error(msg, ...kv)`。生产用 `RUST_LOG` 控制级别:
|
|
1504
|
+
|
|
1505
|
+
```bash
|
|
1506
|
+
RUST_LOG=oj=info ./oj server -c config.yaml --api-path dist
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
访问日志目录见第 10 章 `logs_dir`。
|
|
1510
|
+
|
|
1511
|
+
### 排障表(高频条目)
|
|
1512
|
+
|
|
1513
|
+
| 症状 | 原因 | 处置 |
|
|
1514
|
+
|---|---|---|
|
|
1515
|
+
| 启动报 `missing manifest.yaml` | 首层子目录缺清单或残留空目录 | 补齐 / 删除空目录 |
|
|
1516
|
+
| 启动报 `manifest name "x" != directory name "y"` | `name` ≠ 目录名 | 对齐 |
|
|
1517
|
+
| 启动报 `manifests.yaml … run oj build first` | release 锁缺失/损坏/指向不存在版本 | 跑 `oj build <module>` |
|
|
1518
|
+
| 404 | 无对应 api 文件,或穿越/非法段 | 核对路径与 `-b` 前缀;release 确认模块在锁内 |
|
|
1519
|
+
| 405 `method 'del' not exported` | DELETE 请求但没导出 `del`(不是 `delete`) | 改导出名 |
|
|
1520
|
+
| 500 信封含 `api.ts` 字样 | TS 编译/解析错误 | 看 msg 定位行号 |
|
|
1521
|
+
| 408 | handler 死循环/超时 | 查死循环,或调大 `server.timeout` |
|
|
1522
|
+
| 413 | 超 `max_upload_bytes` | 调上限或压缩上传 |
|
|
1523
|
+
| GET 全部 403 `certificate expired` | 证书进入宽限/过期 | 替换证书文件(热加载即时生效);查 `{base}/health` |
|
|
1524
|
+
| 启动报 `certificate …` 系列错误 | 缺路径/密钥不匹配/JWS 格式错 | 见第 10 章证书门禁;`oj-cert` 重签 |
|
|
1525
|
+
| 启动报 `redis 'default': …` | Redis 不可达(fail-fast) | 起 Redis 或核对 URL;不想依赖就注释掉 `redis:` 段 |
|
|
1526
|
+
| 400 `missing tenant header` | `tenant.enable` 且未带租户头 | 客户端补 header |
|
|
1527
|
+
| 401 `missing or invalid bearer token` | auth 启用且路径不匿名 | 走 `/auth/login` 换 token |
|
|
1528
|
+
| 500 `transaction already active` | 嵌套 `db.tx` | 合并为一个事务回调 |
|
|
1529
|
+
| 日志 `open transaction … rolled back at request end` | `db.tx` 漏 await | 修 handler;数据已按未提交丢弃 |
|
|
1530
|
+
| `bus.publish` 收不到广播 | bus 缺省为进程内,跨实例不互通 | 发布与订阅须同实例 |
|
|
1531
|
+
| `GET {base}/…/ws` 404 | release 未重新 build,或 URL 含版本段 | 先 `oj build`;release URL 为 `…/news-0.1.0/ws` |
|
|
1532
|
+
| 改 `api.ts` 不生效 | release 下 dist 未更新 / 换版本未重启 | 同步 dist;必要时重启 |
|
|
1533
|
+
| `blob not configured` / `es not configured` | config 无对应段 | 加 `blob:` / `es.endpoint` |
|
|
1534
|
+
| 启动报 M004 / 迁移账本落后 | release verify 门禁:有迁移未应用 | 先 `oj migrate -c config.yaml -d dist` 再启动 |
|
|
1535
|
+
| 500 报跨模块表访问被拒 | `ownership_guard: deny` 且未声明 deps | manifest 补 `deps:`;或临时 SQL 注释 `/* oj:allow-table=x */` |
|
|
1536
|
+
| `oj schema diff` exit 1 | 声明与实库漂移(D001/D002) | 按输出对齐 schema.yaml 或补迁移;存量库用 `oj migrate --baseline` |
|
|
1537
|
+
| 启动报 `ext_boot: …` 并拒启 | ext_boot.js 语法错/导入失败/顶层 await 抛错 | 看报错定位;改完重启 |
|
|
1538
|
+
| 启动没打印 `ext_boot: loaded` | 文件不在 config.yaml 同目录,或文件名不是 `ext_boot.js` | 挪到 config 同级;核对文件名 |
|
|
1539
|
+
| 改了 `ext_boot.js` 没生效 | 不做热重载,装配期已冻结 spec | 重启进程 |
|
|
1540
|
+
| `ext_boot.js` 里 `await` 报 SyntaxError | 文件无 import/export,被 CJS 启发式包进非 async 函数 | 加一句 `export {};` |
|
|
1541
|
+
| `ext_boot.js` 里 `import "ext:core/ops"` 失败 | deno_core 硬拦,设计如此 | 用已有全局组合;要新 op 属改 bootstrap |
|
|
1542
|
+
| 运行期偶发 500 且 msg 含 `ext_boot` | boot 依赖外部服务抖动,或文件被删/替换 | boot 改为幂等无外部依赖;恢复文件后重启 |
|
|
1543
|
+
|
|
1544
|
+
完整排障表见 `docs/ops-manual.md` §7。
|
|
1545
|
+
|
|
1546
|
+
### 插件升级与回滚
|
|
1547
|
+
|
|
1548
|
+
- 插件替换用 `.new` / `.bak` **原子换名**;升级前 `cargo xtask plugin <name> --check`
|
|
1549
|
+
预检(ABI / 身份 / semver / 符号)。
|
|
1550
|
+
- **ABI bump 部署顺序:先升插件到新 ABI 并验证,再升宿主**(或同版本原子升级)。
|
|
1551
|
+
升级窗口内可用 `plugins()` 自省核对(第 6 章)。
|
|
1552
|
+
- 多版本共存回滚(代码):`dist/` 内旧版本目录不被构建清除,回滚单模块 =
|
|
1553
|
+
把 `dist/manifests.yaml` 该模块指回旧版本 + 重启 server(锁仅启动时读)。
|
|
1554
|
+
- 回滚(二进制):换回上一版打包产物;保持二进制与 `dist/` 同版本发布。
|
|
1555
|
+
- 升级前备份 sqlite 数据文件(`db.default` 路径);配了 Redis/ES 的实例,它们的可用性
|
|
1556
|
+
进入启动契约(fail-fast),发布/巡检时先确认可达。
|
|
1557
|
+
|
|
1558
|
+
## 13. 安全红线与已知限制
|
|
1559
|
+
|
|
1560
|
+
> 何时读我:任何写 SQL / 拼路径 / 处理上传的时刻;发布前自查。
|
|
1561
|
+
|
|
1562
|
+
### SQL 注入红线(置顶,不可违反)
|
|
1563
|
+
|
|
1564
|
+
- **动态标识符(表名/列名)只来自 `db.table()` 查询构造器**(SchemaRegistry 白名单),
|
|
1565
|
+
**绝不来自 JS 字符串**。
|
|
1566
|
+
- **值只通过绑定参数传递**(`db.query("... where id = ?", [id])`),**绝不字符串拼接**。
|
|
1567
|
+
|
|
1568
|
+
```ts
|
|
1569
|
+
// 正确:标识符走构造器,值走绑定参数
|
|
1570
|
+
const rows = await db.table("account").select(["id", "name"])
|
|
1571
|
+
.where({ field: "id", op: "eq", value: id }).all();
|
|
1572
|
+
|
|
1573
|
+
// 正确:裸 SQL 也必须参数化
|
|
1574
|
+
await db.query("select id, name from account where id = ?", [id]);
|
|
1575
|
+
|
|
1576
|
+
// 错误:字符串拼接值(注入入口)
|
|
1577
|
+
await db.query("select id from account where id = " + id, []); // 禁止
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
排序/过滤白名单同理:构造器的表、列、可排序列都经白名单校验,绕过即失守。
|
|
1581
|
+
|
|
1582
|
+
### 路径安全
|
|
1583
|
+
|
|
1584
|
+
- 路由路径里的 `..` / `.` / `\` / NUL / 空段 → 404;静态兜底与 blob key 先 percent-decode
|
|
1585
|
+
再校验——解码出 `/`(`%2F` 走私)、`..`(`%2e%2e` 走私)同样 404。
|
|
1586
|
+
- 路径参数(`http.param` / `http.params`)**已解码**(可含 `/`、`..` 字面)——仅用于
|
|
1587
|
+
参数化查询与类型转换,**勿拼接文件路径 / URL**。
|
|
1588
|
+
- blob key 白名单:按 `/` 分段,`.` / `..` / `\` / NUL / 空段拒绝。下载路由
|
|
1589
|
+
`{base}/blob/{key}` **公开免鉴权**——不要把需鉴权的对象放进去。
|
|
1590
|
+
- import 解析结果钳制在 project root 内,`..` 逃逸报错(第 5 章)。
|
|
1591
|
+
|
|
1592
|
+
### v0.2 已知限制全表
|
|
1593
|
+
|
|
1594
|
+
| 限制 | 说明 / 绕行 |
|
|
1595
|
+
|---|---|
|
|
1596
|
+
| 相对 `require("./x")` 不支持 | ESM 相对导入不受影响;CJS `require` 仅裸 specifier,不读 `exports`/`conditions`,不支持 pnpm 布局 |
|
|
1597
|
+
| build 剥 `.route` 仅识别语句起始的标准赋值写法 | `fn.route = "…"` 顶层标准写法可用;花式写法可能漏剥 |
|
|
1598
|
+
| npm 依赖不打包进 tgz | 发布物需自带 `node_modules/` |
|
|
1599
|
+
| 旧版本目录不自动回收 | 锁不指向即为死数据,手工删 |
|
|
1600
|
+
| 端口 <1024(如 778)属特权端口 | 需 root 才能 bind | 用 ≥1024(默认 `9778`) |
|
|
1601
|
+
| `.tsx` / `.mts` 不转译 | 直通 V8;统一用 `.ts` |
|
|
1602
|
+
| 静态站点无 SPA 回退 / 目录列表 / Range / ETag / 缓存头 | 未知路径不回落 `index.html`;未知扩展名按 `application/octet-stream`;SPA 回退经前置反代补 |
|
|
1603
|
+
| release 下 WS URL 含版本段 | `…/news-0.1.0/ws`;客户端发现 WS 地址时注意拼版本段 |
|
|
1604
|
+
| `db.tx` 每请求至多一个;嵌套报错 | 合并事务回调 |
|
|
1605
|
+
| `bus` 缺省进程内,跨实例不互通 | 需要跨实例广播配 `broker.kind` |
|
|
1606
|
+
| `WhereCond.and/or` 嵌套未展开 | 多个 `where()` 即 AND;复杂条件用 `db.query` 参数化 SQL |
|
|
1607
|
+
| schema 回滚无自动机制 | 迁移只前向;破坏性变更前备份,反向变更写新 seq 迁移 |
|
|
1608
|
+
| fixtures/ 不进 release 产物 | 演示数据走 fixtures(oj test / oj fixture);参考数据走模块 seed.sql |
|
|
1609
|
+
| `ext_boot.js` 用顶层 `await` 须带 `export {};` | 否则被 CJS 启发式包进非 async 函数 → SyntaxError(§6 末) |
|
|
1610
|
+
| `ext_boot.js` 拿不到 `ext:core/ops` | deno_core 拒绝 `file://` → `ext:` 导入;只能在已有全局上做组合,新 op 属改 bootstrap |
|
|
1611
|
+
| `ext_boot.js` 不做热重载 | 装配期冻结 spec,改动必须重启;池内可能新旧混杂 |
|
|
1612
|
+
|
|
1613
|
+
### 常见陷阱清单
|
|
1614
|
+
|
|
1615
|
+
- `del` 不是 `delete`——DELETE 请求映射方法名 `del`,写错返回 405。
|
|
1616
|
+
- `{id}.json` 混字面 pattern 非法——matchit 参数段不得混字面,拆成静态多段由 handler 校验。
|
|
1617
|
+
- `seed.sql` 不得含分号字面量(按 `;` 切分);用 `INSERT OR IGNORE` 保证幂等。
|
|
1618
|
+
- postgres 占位符是 `$1`,sqlite/mysql 才是 `?`。
|
|
1619
|
+
- 上传 413 双闸:`max_upload_bytes`(信封 413)+ axum 层 2x 硬顶(裸 413)——客户端看到
|
|
1620
|
+
的 413 未必带 oj 信封。
|
|
1621
|
+
- 未配置 `blob:` / `es:` 段时调用 `blob.*` / `es.*` 即报错(配置即启用)。
|
|
1622
|
+
- 挂 `.route` 后目录镜像路径 404(替换语义);`{*path}` 至少匹配一段。
|
|
1623
|
+
- `redis.default` 配置即真连且 fail-fast——CI/离线环境注释掉该段即用内存 KV。
|
|
1624
|
+
- 每请求至多一个 `db.tx`;漏 await 会在请求结束时自动回滚并打 warn。
|
|
1625
|
+
- `beforeEach` 是单一全局钩子,跨 `describe` 被覆盖——多 describe 文件在各 `it` 内联准备。
|
|
1626
|
+
- `ext_boot.js` 里别写库/发广播/打外部接口——执行次数是「模块数 + `pool_size` + WS Worker 数
|
|
1627
|
+
(每路由 `ws.workers_per_route`)」,副作用按此放大;boot 只做全局装配。
|
|
1628
|
+
- `ext_boot.js` 顶层 `await` 忘了 `export {};` → 看起来莫名的 SyntaxError(CJS 启发式误判)。
|