@oj-module/cli 0.1.17 → 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 (69) hide show
  1. package/devkit/README.md +11 -10
  2. package/devkit/SKILL.md +32 -18
  3. package/devkit/manual.md +318 -131
  4. package/package.json +2 -2
  5. package/shell-dist/assets/contract.js +0 -55
  6. package/shell-dist/assets/runtime.js +206 -212
  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 → __client}/api.ts +29 -29
  49. package/templates/web/src/notification/entry.ts +11 -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/personal-center/__client/api.schemas.ts +10 -0
  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/web/__client/api.ts +110 -0
  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 -98
  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
  69. package/templates/web/src/notification/client/api.schemas.ts +0 -50
package/src/init.ts CHANGED
@@ -209,6 +209,10 @@ function copyTemplates(
209
209
  const src = path.join(srcDir, entry.name);
210
210
  const dest = path.join(destDir, entry.name);
211
211
  if (entry.isDirectory()) {
212
+ // 空目录不进工程:git 不跟踪空目录,模板里残留的空目录(如误建的
213
+ // api/src/_shared)会被 oj build 当作缺 manifest.yaml 的模块 fail-fast
214
+ if (!dirHasFiles(src))
215
+ continue;
212
216
  fs.mkdirSync(dest, { recursive: true });
213
217
  copyTemplates(src, dest, placeholders, report);
214
218
  continue;
@@ -227,6 +231,17 @@ function copyTemplates(
227
231
  }
228
232
  }
229
233
 
234
+ /** 目录(递归)是否含至少一个文件——空目录判定供 copyTemplates 跳过用 */
235
+ function dirHasFiles(dir: string): boolean {
236
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
237
+ if (entry.isFile())
238
+ return true;
239
+ if (entry.isDirectory() && dirHasFiles(path.join(dir, entry.name)))
240
+ return true;
241
+ }
242
+ return false;
243
+ }
244
+
230
245
  function copyIfAbsent(src: string, dest: string, report: InitReport): void {
231
246
  if (fs.existsSync(dest)) {
232
247
  report.skipped.push(path.basename(dest));
@@ -306,9 +321,9 @@ function generatePackageJson(cliRoot: string, projectName: string) {
306
321
 
307
322
  const devDeps: Record<string, string> = {
308
323
  "@oj-module/cli": cliPkg.version,
309
- // runtime 必须显式声明:uni-dev 工程的 contract.ts 与生成 client 直接 import
310
- // `@oj-module/runtime/contract[/errors]`,`ojm api` 在 Node 侧求值契约时
311
- // 需从工程 node_modules 解析真实现(evaluateContract 把裸说明符 external)。
324
+ // runtime 必须显式声明:生成的 __client 与 web 模块直接 import
325
+ // `@oj-module/runtime`(契约层在 oj handler 的 .schema/.response 上,
326
+ // 经 openapi.json 进 client,不再经过 runtime/contract 的 Node 侧求值)。
312
327
  "@oj-module/runtime": pin("@oj-module/runtime"),
313
328
  "@ant-design/icons": pin("@ant-design/icons"),
314
329
  // personal-center 的 ProForm 用到——仅取**类型**(运行时由宿主 importmap 提供单例)
package/src/oj.ts CHANGED
@@ -27,8 +27,12 @@ export interface OjProcess {
27
27
  ready: Promise<void>
28
28
  /** SIGTERM 回收,3s 不退升级 SIGKILL;已退出则立即返回 */
29
29
  stop: () => Promise<void>
30
- /** 同步强杀(SIGKILL):仅作进程退出兜底,防 oj 孤儿化 */
31
- kill: () => void
30
+ /**
31
+ * 同步强杀(SIGKILL):仅作进程退出兜底,防 oj 孤儿化。
32
+ * 可选:唯一消费点(dev 的 process exit 兜底)按 `kill?.()` 宽容调用;
33
+ * 未 spawn 真实进程的实现(测试桩)可不提供。
34
+ */
35
+ kill?: () => void
32
36
  }
33
37
 
34
38
  export interface StartOjPoll {
package/src/vendor.ts CHANGED
@@ -27,12 +27,16 @@ export const NPM_PKG = "@oj-bin/oj";
27
27
  * 0.1.13 / 0.1.16 / 0.1.19 / 0.1.24 / 0.1.26 / 0.1.28 → 启动子命令 `server`
28
28
  * 0.1.32 起 → `serve`
29
29
  * 即 `server` → `serve` 的改名落在 (0.1.28, 0.1.32]。改名刚发生时 npm 通道只到
30
- * 0.1.32,门禁曾暂定 0.1.32(否则 vendor/init 没有可用来源);现 npm 已发布
31
- * 0.1.50,门禁即抬到当前最新。
30
+ * 0.1.32,门禁曾暂定 0.1.32(否则 vendor/init 没有可用来源);后随发布进度抬到
31
+ * 0.1.69(含嵌套 handler 组 v0.1.64 等,迁移指引见
32
+ * docs/superpowers/specs/2026-10-14-ojm-0.1.18-design.md);**0.1.20 抬到 0.1.73**:
33
+ * 平级 `.response` 出参声明位自 oj v0.1.72 起、`oj openapi` 的 `del`→`delete`
34
+ * 操作键归一自 v0.1.73 起,`ojm api` 的 openapi 流水线以这两者为前提
35
+ * (见 CHANGELOG 0.1.20 / docs/guides/api-codegen-openapi.md)。
32
36
  */
33
- export const MIN_OJ_VERSION = "0.1.50";
37
+ export const MIN_OJ_VERSION = "0.1.73";
34
38
 
35
- /** 按段数值比较(字符串比较会把 0.1.9 判成大于 0.1.50);非数字段按 0 处理 */
39
+ /** 按段数值比较(字符串比较会把 0.1.9 判成大于 0.1.69);非数字段按 0 处理 */
36
40
  export function isSupportedOjVersion(version: string): boolean {
37
41
  const parse = (v: string) => v.replace(/^v/, "").split(".").map(n => Number.parseInt(n, 10) || 0);
38
42
  const [major = 0, minor = 0, patch = 0] = parse(version);
@@ -12,6 +12,7 @@ mounts:
12
12
  # - prefix: "/"
13
13
  # web: "../web/dist" # 相对 api/config.yaml → 项目根/web/dist
14
14
  # spa: true
15
+ # 嵌套 handler 组(oj v0.1.64)无需配置开关,组织方式见 api.ts 顶部注释。
15
16
 
16
17
  server:
17
18
  host: 127.0.0.1 # 显式钉死:oj 默认绑 [::1],localhost 解析序在反代下有坑
@@ -0,0 +1,66 @@
1
+ // oj v0.1.64 嵌套 handler 组:组名 = 路径段(login → POST /auth/login,路由不变)。
2
+ // DDD 判据:同聚合(会话生命周期)多动词收进单文件;
3
+ // 未来 register/reset/oauth 属新聚合、各自开目录。共享签发逻辑仍在 _shared/session.ts。
4
+ import { issueTokens, nowSecs, sessionKey } from "./_shared/session";
5
+
6
+ const login = {
7
+ // POST /api/auth/login —— 登录(devkit 手册 §8:auth 端点是业务路由)。
8
+ // 用户不存在与密码错同报 invalid credentials(不泄露用户存在性)。
9
+ async post() {
10
+ const body = http.body || {};
11
+ const rows = await db.query(
12
+ "select id, password_hash, roles from users where username = ?",
13
+ [String(body.username ?? "")],
14
+ );
15
+ const row = rows[0];
16
+ if (!row || !(await bcrypt.verify(String(body.password ?? ""), row.password_hash || ""))) {
17
+ json.fail(401, "invalid credentials");
18
+ return;
19
+ }
20
+ // roles 列按 JSON 数组串解析,失败回落空。
21
+ let roles: string[] = [];
22
+ try {
23
+ roles = JSON.parse(row.roles || "[]");
24
+ }
25
+ catch {
26
+ roles = [];
27
+ }
28
+ json.ok(await issueTokens(String(row.id), roles));
29
+ },
30
+ };
31
+
32
+ const logout = {
33
+ // POST /api/auth/logout —— 注销(删 refresh session;access 到期前仍有效)。
34
+ async post() {
35
+ const token = String((http.body || {}).refresh_token ?? "");
36
+ await kv.del(sessionKey(token));
37
+ json.ok(null);
38
+ },
39
+ };
40
+
41
+ const refresh = {
42
+ // POST /api/auth/refresh —— 刷新(轮换:旧 refresh 立即失效,一次一用)。
43
+ async post() {
44
+ const token = String((http.body || {}).refresh_token ?? "");
45
+ const key = sessionKey(token);
46
+ const raw = await kv.get(key);
47
+ const sess = raw ? JSON.parse(raw) : null;
48
+ if (!sess || !(sess.exp > nowSecs())) {
49
+ json.fail(401, "invalid or expired refresh token");
50
+ return;
51
+ }
52
+ // session 只存 uid——roles 重查库取最新。
53
+ const rows = await db.query("select roles from users where id = ?", [sess.uid]);
54
+ let roles: string[] = [];
55
+ try {
56
+ roles = JSON.parse((rows[0] || {}).roles || "[]");
57
+ }
58
+ catch {
59
+ roles = [];
60
+ }
61
+ await kv.del(key);
62
+ json.ok(await issueTokens(String(sess.uid), roles));
63
+ },
64
+ };
65
+
66
+ export default { login, logout, refresh };
@@ -0,0 +1,199 @@
1
+ // 跨模块通知发射原语(sample 后端实现,本地模块)。
2
+ //
3
+ // 设计文档 §4.3:「Sample 后端(实现):api/src/_shared/notify.ts 用 db 直写两表,
4
+ // 是后端模块真正 import 的原语」。oj 只把 api/src 的【顶层】目录当作模块(须有 manifest.yaml),
5
+ // 故共享原语不能放 api/src/_shared(会被当成缺 manifest 的模块),须嵌进所属模块目录
6
+ // (此处嵌套在 notification/ 下)。后端解析裸 specifier 以 api/ 为项目根并钳制模块不越界
7
+ // (api-manual 第 5 章),原语用相对导入、不能 import 来自 @oj-module/runtime 的包
8
+ // (符号链接指向 packages/runtime,越出项目根)。
9
+ //
10
+ // 语义:原语不做任何业务判断(不判空间/角色/可见性),收件人过滤是发送方责任。
11
+ // 按人通知落 notifications(user_id 必填);广播落 notifications_broadcast(一行),
12
+ // 已读状态走 notifications_broadcast_read 回执表,避免广播按人 fan-out 的信息爆炸。
13
+ //
14
+ // 类型与 runtime 的 @oj-module/runtime/notify 保持同形(前端契约面复用 runtime 类型);
15
+ // 此处本地定义以避免后端 import 越界。两处形状变更需同步。
16
+
17
+ export type NoticeCategory = "notice" | "pm" | "mention";
18
+
19
+ export interface NoticeItem {
20
+ id: string
21
+ scope: "user" | "broadcast"
22
+ avatar: string
23
+ date: string
24
+ isRead?: boolean
25
+ message: string
26
+ title: string
27
+ link?: string
28
+ category?: NoticeCategory
29
+ }
30
+
31
+ export interface PushInput {
32
+ userId: string | number
33
+ title: string
34
+ message: string
35
+ link?: string
36
+ avatar?: string
37
+ category?: NoticeCategory
38
+ }
39
+
40
+ export interface PushBroadcastInput {
41
+ title: string
42
+ message: string
43
+ link?: string
44
+ avatar?: string
45
+ category?: NoticeCategory
46
+ }
47
+
48
+ interface Row {
49
+ [k: string]: unknown
50
+ }
51
+ interface NotifyDb {
52
+ query: (sql: string, params?: unknown[]) => Promise<Row[]>
53
+ exec: (sql: string, params?: unknown[]) => Promise<number>
54
+ }
55
+ declare const db: NotifyDb;
56
+
57
+ function nowStr(): string {
58
+ return new Date().toISOString().slice(0, 16).replace("T", " ");
59
+ }
60
+
61
+ function splitId(id: string): [scope: "u" | "b", raw: string] {
62
+ const i = id.indexOf(":");
63
+ if (i < 0)
64
+ return ["u", id];
65
+ return [id.slice(0, i) === "b" ? "b" : "u", id.slice(i + 1)];
66
+ }
67
+
68
+ function mapItem(r: Row): NoticeItem {
69
+ const scope = String(r.scope);
70
+ const isBroadcast = scope === "broadcast";
71
+ return {
72
+ id: (isBroadcast ? "b:" : "u:") + String(r.id),
73
+ scope: isBroadcast ? "broadcast" : "user",
74
+ avatar: String(r.avatar ?? ""),
75
+ date: String(r.date ?? ""),
76
+ isRead: Number(r.is_read) === 1,
77
+ message: String(r.message ?? ""),
78
+ title: String(r.title ?? ""),
79
+ link: r.link == null ? undefined : String(r.link),
80
+ category: (r.category as NoticeCategory) ?? "notice",
81
+ };
82
+ }
83
+
84
+ /** 给指定用户推一条按人通知,返回命名空间 id "u:<id>" */
85
+ export async function pushNotice(input: PushInput): Promise<string> {
86
+ const ins = await db.query(
87
+ `INSERT INTO notifications (user_id, avatar, date, is_read, message, title, link, category)
88
+ VALUES (?, ?, ?, 0, ?, ?, ?, ?) RETURNING id`,
89
+ [
90
+ Number(input.userId),
91
+ input.avatar ?? "",
92
+ nowStr(),
93
+ input.message,
94
+ input.title,
95
+ input.link ?? null,
96
+ input.category ?? "notice",
97
+ ],
98
+ );
99
+ return `u:${Number(ins[0]!.id)}`;
100
+ }
101
+
102
+ /** 广播一条通知(全员可见,一行存储),返回命名空间 id "b:<id>" */
103
+ export async function pushBroadcastNotice(input: PushBroadcastInput): Promise<string> {
104
+ const ins = await db.query(
105
+ `INSERT INTO notifications_broadcast (avatar, date, message, title, link, category, create_time)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
107
+ [
108
+ input.avatar ?? "",
109
+ nowStr(),
110
+ input.message,
111
+ input.title,
112
+ input.link ?? null,
113
+ input.category ?? "notice",
114
+ nowStr(),
115
+ ],
116
+ );
117
+ return `b:${Number(ins[0]!.id)}`;
118
+ }
119
+
120
+ /**
121
+ * 删除通知。按人项校验归属:非本人返回 false;广播项仅删本人回执(幂等,返回 true)。
122
+ * 返回是否成功操作到目标(调用方据 false 判 403)。
123
+ */
124
+ export async function delNotice(id: string, userId: string | number): Promise<boolean> {
125
+ const [scope, raw] = splitId(id);
126
+ const uid = Number(userId);
127
+ if (scope === "b") {
128
+ await db.exec(
129
+ "DELETE FROM notifications_broadcast_read WHERE broadcast_id = ? AND user_id = ?",
130
+ [Number(raw), uid],
131
+ );
132
+ return true;
133
+ }
134
+ const n = await db.exec(
135
+ "DELETE FROM notifications WHERE id = ? AND user_id = ?",
136
+ [Number(raw), uid],
137
+ );
138
+ return n > 0;
139
+ }
140
+
141
+ /** 取某用户的合并通知列表(广播 LEFT JOIN 回执算 is_read UNION 按人),按时间倒序 */
142
+ export async function noticesFor(
143
+ userId: string | number,
144
+ opts: { offset?: number, limit?: number } = {},
145
+ ): Promise<NoticeItem[]> {
146
+ const limit = opts.limit ?? 50;
147
+ const offset = opts.offset ?? 0;
148
+ const uid = Number(userId);
149
+ const rows = await db.query(
150
+ `SELECT id, avatar, date, is_read, message, title, link, category, 'user' as scope
151
+ FROM notifications WHERE user_id = ?
152
+ UNION ALL
153
+ SELECT b.id, b.avatar, b.date, (r.read_time IS NOT NULL AND r.read_time <> '') as is_read,
154
+ b.message, b.title, b.link, b.category, 'broadcast' as scope
155
+ FROM notifications_broadcast b
156
+ LEFT JOIN notifications_broadcast_read r ON r.broadcast_id = b.id AND r.user_id = ?
157
+ ORDER BY date DESC
158
+ LIMIT ? OFFSET ?`,
159
+ [uid, uid, limit, offset],
160
+ );
161
+ return rows.map(mapItem);
162
+ }
163
+
164
+ /**
165
+ * 标记已读:按人项校验归属(非本人返回 false);广播项 upsert 回执(始终 true)。
166
+ * 返回是否命中目标(调用方据 false 判 403,与 delNotice 对称)。
167
+ */
168
+ export async function markRead(id: string, userId: string | number): Promise<boolean> {
169
+ const [scope, raw] = splitId(id);
170
+ const uid = Number(userId);
171
+ const now = nowStr();
172
+ if (scope === "b") {
173
+ await db.exec(
174
+ "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) VALUES (?, ?, ?)",
175
+ [Number(raw), uid, now],
176
+ );
177
+ return true;
178
+ }
179
+ const n = await db.exec("UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?", [Number(raw), uid]);
180
+ return n > 0;
181
+ }
182
+
183
+ /** 全部已读:按人项全置位 + 广播回执全 upsert */
184
+ export async function markAllRead(userId: string | number): Promise<void> {
185
+ const uid = Number(userId);
186
+ const now = nowStr();
187
+ await db.exec("UPDATE notifications SET is_read = 1 WHERE user_id = ?", [uid]);
188
+ await db.exec(
189
+ "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
190
+ [uid, now],
191
+ );
192
+ }
193
+
194
+ /** 清空:删按人项 + 删本人广播回执(列表消失) */
195
+ export async function clearNotices(userId: string | number): Promise<void> {
196
+ const uid = Number(userId);
197
+ await db.exec("DELETE FROM notifications WHERE user_id = ?", [uid]);
198
+ await db.exec("DELETE FROM notifications_broadcast_read WHERE user_id = ?", [uid]);
199
+ }
@@ -0,0 +1,231 @@
1
+ // oj v0.1.64 嵌套 handler 组:组名 = 路径段(路由不变)。del 是 oj 保留动词键
2
+ // (动词键下不允许挂子组),del/ 保留独立目录——唯一例外。
3
+ // DDD 判据:notifications 表 = 一致性边界 = 一个聚合,读/已读/清空/推送/删除都是
4
+ // 该聚合上的操作;发射三端点是薄 HTTP 壳,真逻辑在 ./_shared/notify。
5
+ //
6
+ // 契约(oj v0.1.72「最新方式」):入参挂 `.schema`(params/query/body 三通道),
7
+ // 出参挂平级 `.response`(动词级优先于组级,组级沿嵌套继承)——oj 运行期据此校验入参、
8
+ // 并供 `oj openapi` 生成 openapi.json → 前端 `ojm api` 生成 __client。
9
+ // `.schema` 是 JSON Schema 严格子集(关键字白名单),union 用类型数组表达(见 userId)。
10
+ import { clearNotices, markAllRead, markRead, noticesFor, pushBroadcastNotice, pushNotice } from "./_shared/notify";
11
+
12
+ const notifications = {
13
+ // GET /api/notification/notifications —— 通知列表(按人真实通知)。
14
+ // 合并:广播(LEFT JOIN 回执算 is_read) UNION 按人(WHERE user_id = ?),命名空间 id。
15
+ // Bearer 守卫保护。直接复用 _shared/notify 原语 noticesFor 保证与发射端单一数据路径。
16
+ async get() {
17
+ const uid = http.user?.id;
18
+ if (!uid) {
19
+ json.fail(401, "unauthorized");
20
+ return;
21
+ }
22
+ try {
23
+ json.ok(await noticesFor(uid));
24
+ }
25
+ catch (e) {
26
+ json.fail(500, String(e));
27
+ }
28
+ },
29
+ // POST /api/notification/notifications/read —— 单条通知已读。
30
+ // body.id 为命名空间串 "u:<id>"(按人)/"b:<id>"(广播);按人项校验归属,非本人 403。
31
+ "read": {
32
+ async post() {
33
+ const uid = http.user?.id;
34
+ if (!uid) {
35
+ json.fail(401, "unauthorized");
36
+ return;
37
+ }
38
+ const body = (http.body ?? {}) as { id?: unknown };
39
+ const id = String(body.id ?? "");
40
+ if (!id) {
41
+ json.fail(400, "id is required");
42
+ return;
43
+ }
44
+ try {
45
+ const ok = await markRead(id, uid);
46
+ if (!ok) {
47
+ json.fail(403, "forbidden");
48
+ return;
49
+ }
50
+ json.ok(null);
51
+ }
52
+ catch (e) {
53
+ json.fail(500, String(e));
54
+ }
55
+ },
56
+ },
57
+ // POST /api/notification/notifications/read-all —— 全部通知已读(按当前用户)。
58
+ "read-all": {
59
+ async post() {
60
+ const uid = http.user?.id;
61
+ if (!uid) {
62
+ json.fail(401, "unauthorized");
63
+ return;
64
+ }
65
+ try {
66
+ await markAllRead(uid);
67
+ json.ok(null);
68
+ }
69
+ catch (e) {
70
+ json.fail(500, String(e));
71
+ }
72
+ },
73
+ },
74
+ // POST /api/notification/notifications/clear —— 清空通知(按当前用户)。
75
+ // 删按人项 + 删本人广播回执(列表消失)。
76
+ "clear": {
77
+ async post() {
78
+ const uid = http.user?.id;
79
+ if (!uid) {
80
+ json.fail(401, "unauthorized");
81
+ return;
82
+ }
83
+ try {
84
+ await clearNotices(uid);
85
+ json.ok(null);
86
+ }
87
+ catch (e) {
88
+ json.fail(500, String(e));
89
+ }
90
+ },
91
+ },
92
+ };
93
+
94
+ // 通知列表出参(data payload):命名空间 id + 按人/广播 scope + 已读态 + 分类。
95
+ // oj v0.1.72 起出参走平级 .response(.schema 只放 body 等入参)。
96
+ notifications.get.response = {
97
+ type: "array",
98
+ items: {
99
+ type: "object",
100
+ required: ["id", "scope", "avatar", "date", "message", "title"],
101
+ properties: {
102
+ id: { type: "string" },
103
+ scope: { type: "string", enum: ["user", "broadcast"] },
104
+ avatar: { type: "string" },
105
+ date: { type: "string" },
106
+ isRead: { type: "boolean" },
107
+ message: { type: "string" },
108
+ title: { type: "string" },
109
+ link: { type: ["string", "null"] },
110
+ category: { type: "string", enum: ["notice", "pm", "mention"] },
111
+ },
112
+ },
113
+ };
114
+
115
+ // POST /api/notification/notifications/read —— body 仅需 id。
116
+ notifications.read.post.schema = {
117
+ body: {
118
+ type: "object",
119
+ required: ["id"],
120
+ properties: { id: { type: "string" } },
121
+ },
122
+ };
123
+
124
+ // POST /api/notification/push —— 给指定用户推一条按人通知(发射原语 HTTP 入口)。
125
+ // 真正逻辑在 _shared/notify.pushNotice(任一后端模块也可直接 import 调用,无需走 HTTP)。
126
+ // Bearer 守卫保护;收件人过滤是发送方责任(原语不做业务判断)。
127
+ // userId 类型数组 ["string","number"] 表达 z.union(oj 白名单不支持 union 关键字,
128
+ // 用类型数组表达;handler 内 String() 归一,缺省回落当前用户)。
129
+ const push = {
130
+ async post() {
131
+ const uid = http.user?.id;
132
+ if (!uid) {
133
+ json.fail(401, "unauthorized");
134
+ return;
135
+ }
136
+ const b = (http.body ?? {}) as {
137
+ userId?: unknown
138
+ title?: unknown
139
+ message?: unknown
140
+ link?: unknown
141
+ avatar?: unknown
142
+ category?: unknown
143
+ };
144
+ const target = b.userId ?? uid;
145
+ if (!b.title || !b.message) {
146
+ json.fail(400, "title and message are required");
147
+ return;
148
+ }
149
+ try {
150
+ const id = await pushNotice({
151
+ userId: target as string | number,
152
+ title: String(b.title),
153
+ message: String(b.message),
154
+ link: b.link != null ? String(b.link) : undefined,
155
+ avatar: b.avatar != null ? String(b.avatar) : undefined,
156
+ category: b.category as never,
157
+ });
158
+ json.ok({ id });
159
+ }
160
+ catch (e) {
161
+ json.fail(500, String(e));
162
+ }
163
+ },
164
+ };
165
+
166
+ push.post.schema = {
167
+ body: {
168
+ type: "object",
169
+ required: ["title", "message"],
170
+ properties: {
171
+ userId: { type: ["string", "number"] },
172
+ title: { type: "string" },
173
+ message: { type: "string" },
174
+ link: { type: "string" },
175
+ avatar: { type: "string" },
176
+ category: { type: "string", enum: ["notice", "pm", "mention"] },
177
+ },
178
+ },
179
+ };
180
+
181
+ // POST /api/notification/push-broadcast —— 广播一条通知(全员可见,一行存储)。
182
+ // 真正逻辑在 _shared/notify.pushBroadcastNotice。Bearer 守卫保护。
183
+ const broadcast = {
184
+ async post() {
185
+ const uid = http.user?.id;
186
+ if (!uid) {
187
+ json.fail(401, "unauthorized");
188
+ return;
189
+ }
190
+ const b = (http.body ?? {}) as {
191
+ title?: unknown
192
+ message?: unknown
193
+ link?: unknown
194
+ avatar?: unknown
195
+ category?: unknown
196
+ };
197
+ if (!b.title || !b.message) {
198
+ json.fail(400, "title and message are required");
199
+ return;
200
+ }
201
+ try {
202
+ const id = await pushBroadcastNotice({
203
+ title: String(b.title),
204
+ message: String(b.message),
205
+ link: b.link != null ? String(b.link) : undefined,
206
+ avatar: b.avatar != null ? String(b.avatar) : undefined,
207
+ category: b.category as never,
208
+ });
209
+ json.ok({ id });
210
+ }
211
+ catch (e) {
212
+ json.fail(500, String(e));
213
+ }
214
+ },
215
+ };
216
+
217
+ broadcast.post.schema = {
218
+ body: {
219
+ type: "object",
220
+ required: ["title", "message"],
221
+ properties: {
222
+ title: { type: "string" },
223
+ message: { type: "string" },
224
+ link: { type: "string" },
225
+ avatar: { type: "string" },
226
+ category: { type: "string", enum: ["notice", "pm", "mention"] },
227
+ },
228
+ },
229
+ };
230
+
231
+ export default { notifications, push, "push-broadcast": broadcast };
@@ -0,0 +1,47 @@
1
+ // POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
2
+ // 按人项校验归属,非本人返回 403;广播项仅删本人回执。Bearer 守卫保护。
3
+ import { delNotice } from "../_shared/notify";
4
+
5
+ // POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
6
+ // 按人项校验归属,非本人返回 403;广播项仅删本人回执。Bearer 守卫保护。
7
+ // userId 类型数组表达 z.union(同 push);缺省回落当前用户。
8
+ const handler = {
9
+ async post() {
10
+ const uid = http.user?.id;
11
+ if (!uid) {
12
+ json.fail(401, "unauthorized");
13
+ return;
14
+ }
15
+ const b = (http.body ?? {}) as { id?: unknown, userId?: unknown };
16
+ const id = String(b.id ?? "");
17
+ if (!id) {
18
+ json.fail(400, "id is required");
19
+ return;
20
+ }
21
+ const target = b.userId ?? uid;
22
+ try {
23
+ const ok = await delNotice(id, target as string | number);
24
+ if (!ok) {
25
+ json.fail(403, "forbidden");
26
+ return;
27
+ }
28
+ json.ok(null);
29
+ }
30
+ catch (e) {
31
+ json.fail(500, String(e));
32
+ }
33
+ },
34
+ };
35
+
36
+ handler.post.schema = {
37
+ body: {
38
+ type: "object",
39
+ required: ["id"],
40
+ properties: {
41
+ id: { type: "string" },
42
+ userId: { type: ["string", "number"] },
43
+ },
44
+ },
45
+ };
46
+
47
+ export default handler;
@@ -1,5 +1,7 @@
1
1
  name: notification
2
- desc: 通知模块(契约化通知读写:读 + 单条已读 + 全部已读 + 清空;表与种子归属本模块)
2
+ desc: 通知模块(契约化通知读写:读 + 单条已读 + 全部已读 + 清空 + 广播;表与种子归属本模块)
3
3
  version: 0.1.0
4
4
  tables:
5
5
  - notifications
6
+ - notifications_broadcast
7
+ - notifications_broadcast_read
@@ -1,4 +1,4 @@
1
- -- notifications 模块建表:DDL 只进 migrations(S006)。is_read 用 INTEGER(0/1)。
1
+ -- notification 模块建表(P4-2)。notifications 通知表,is_read 用 INTEGER(0/1)。
2
2
  CREATE TABLE IF NOT EXISTS notifications (
3
3
  id INTEGER PRIMARY KEY AUTOINCREMENT,
4
4
  avatar TEXT NOT NULL DEFAULT '',
@@ -0,0 +1,9 @@
1
+ -- notification 模块表演进(升级为按人真实通知):可空 ADD COLUMN + 索引 + 既有行归主。
2
+ -- 三方言可移植:sqlite/mysql/pg 均支持 ALTER TABLE ADD COLUMN(含 DEFAULT)。
3
+ ALTER TABLE notifications ADD COLUMN user_id INTEGER;
4
+ ALTER TABLE notifications ADD COLUMN link TEXT;
5
+ -- 类别:notice=通知/广播,pm=私信,mention=@我(占位)。NOT NULL + DEFAULT 保证既有行与新增行合规。
6
+ ALTER TABLE notifications ADD COLUMN category TEXT NOT NULL DEFAULT 'notice';
7
+ CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id, is_read);
8
+ -- 既有演示行(user_id 为 NULL)归主到用户 1:不丢数据、不再全员可见。
9
+ UPDATE notifications SET user_id = 1, category = 'notice' WHERE user_id IS NULL;
@@ -0,0 +1,22 @@
1
+ -- 广播/公告表(全局一行,避免按人 fan-out 造成的信息爆炸)+ 已读回执表(按人记录读状态)。
2
+ -- 铃列表 = 广播(LEFT JOIN 回执算 is_read) UNION 按人(WHERE user_id = ?)。
3
+ -- 三方言可移植:建表 + 索引均为标准 DDL。
4
+ CREATE TABLE IF NOT EXISTS notifications_broadcast (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ avatar TEXT NOT NULL DEFAULT '',
7
+ date TEXT NOT NULL DEFAULT '',
8
+ message TEXT NOT NULL DEFAULT '',
9
+ title TEXT NOT NULL DEFAULT '',
10
+ link TEXT,
11
+ category TEXT NOT NULL DEFAULT 'notice',
12
+ create_time TEXT NOT NULL DEFAULT ''
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS notifications_broadcast_read (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ broadcast_id INTEGER NOT NULL,
18
+ user_id INTEGER NOT NULL,
19
+ read_time TEXT NOT NULL DEFAULT ''
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_broadcast_read ON notifications_broadcast_read (user_id, broadcast_id);