@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.
- package/devkit/README.md +11 -10
- package/devkit/SKILL.md +32 -18
- package/devkit/manual.md +318 -131
- package/package.json +2 -2
- package/shell-dist/assets/contract.js +0 -55
- package/shell-dist/assets/runtime.js +206 -212
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +5 -5
- package/src/build.ts +1 -0
- package/src/contract/__tests__/check-api.test.ts +74 -0
- package/src/contract/__tests__/emit-client.test.ts +132 -0
- package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
- package/src/contract/__tests__/mock.test.ts +56 -0
- package/src/contract/__tests__/openapi-ir.test.ts +137 -0
- package/src/contract/__tests__/run-api.test.ts +67 -0
- package/src/contract/check.ts +67 -294
- package/src/contract/emit-client.ts +23 -15
- package/src/contract/emit-jsonschema-zod.ts +209 -0
- package/src/contract/emit-oj-schema.ts +108 -161
- package/src/contract/emit-stub.ts +17 -57
- package/src/contract/handler-scan.ts +263 -0
- package/src/contract/ir.ts +11 -179
- package/src/contract/json-schema-sample.ts +72 -0
- package/src/contract/mock.ts +28 -100
- package/src/contract/openapi-ir.ts +145 -0
- package/src/contract/run.ts +71 -120
- package/src/dev.ts +20 -18
- package/src/init.ts +18 -3
- package/src/oj.ts +6 -2
- package/src/vendor.ts +8 -4
- package/templates/api/config.yaml +1 -0
- package/templates/api/src/auth/api.ts +66 -0
- package/templates/api/src/notification/_shared/notify.ts +199 -0
- package/templates/api/src/notification/api.ts +231 -0
- package/templates/api/src/notification/del/api.ts +47 -0
- package/templates/api/src/notification/manifest.yaml +3 -1
- package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
- package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
- package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
- package/templates/api/src/notification/schema.yaml +23 -1
- package/templates/api/src/notification/seed.sql +6 -4
- package/templates/api/src/personal-center/upload/api.ts +12 -1
- package/templates/env.d.ts +1 -1
- package/templates/openapi.json +471 -0
- package/templates/web/src/auth/__client/api.schemas.ts +8 -0
- package/templates/web/src/auth/__client/api.ts +110 -0
- package/templates/web/src/notification/__client/api.schemas.ts +50 -0
- package/templates/web/src/notification/{client → __client}/api.ts +29 -29
- package/templates/web/src/notification/entry.ts +11 -9
- package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
- package/templates/web/src/notifications/__client/api.ts +80 -0
- package/templates/web/src/personal-center/__client/api.schemas.ts +10 -0
- package/templates/web/src/personal-center/__client/api.ts +88 -0
- package/templates/web/src/web/__client/api.schemas.ts +8 -0
- package/templates/web/src/web/__client/api.ts +110 -0
- package/vendor/host-versions.json +5 -5
- package/src/contract/emit-meta.ts +0 -175
- package/src/contract/emit-schema.ts +0 -133
- package/src/contract/evaluate.ts +0 -88
- package/templates/api/src/auth/login/api.ts +0 -27
- package/templates/api/src/auth/logout/api.ts +0 -10
- package/templates/api/src/auth/refresh/api.ts +0 -26
- package/templates/api/src/notification/contract.ts +0 -98
- package/templates/api/src/notification/notifications/api.ts +0 -24
- package/templates/api/src/notification/notifications/clear/api.ts +0 -13
- package/templates/api/src/notification/notifications/read/api.ts +0 -19
- package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
- package/templates/api/src/personal-center/contract.ts +0 -16
- 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
|
|
310
|
-
// `@oj-module/runtime
|
|
311
|
-
//
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
|
31
|
-
* 0.1.
|
|
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.
|
|
37
|
+
export const MIN_OJ_VERSION = "0.1.73";
|
|
34
38
|
|
|
35
|
-
/** 按段数值比较(字符串比较会把 0.1.9 判成大于 0.1.
|
|
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);
|
|
@@ -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;
|
|
@@ -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);
|