@oj-module/cli 0.2.8 → 0.2.14
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 +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +9 -6
- package/devkit/module-dev-and-cdn-playground.md +197 -0
- package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +130 -45
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +64 -0
- package/src/__tests__/versions.test.ts +26 -0
- package/src/build.ts +9 -0
- package/src/bundle.ts +8 -5
- package/src/contract/__tests__/market-client.integration.test.ts +13 -10
- package/src/index.ts +6 -0
- package/src/init.ts +22 -0
- package/src/lifecycle/__tests__/bundle.test.ts +3 -3
- package/src/market/__tests__/bootstrap.test.ts +204 -0
- package/src/market/__tests__/build-index.test.ts +154 -0
- package/src/market/__tests__/cdn-layout.test.ts +96 -0
- package/src/market/__tests__/failure-playbook.test.ts +33 -0
- package/src/market/__tests__/fixture.test.ts +48 -0
- package/src/market/__tests__/publish.test.ts +109 -0
- package/src/market/__tests__/report.test.ts +51 -0
- package/src/market/__tests__/setup.test.ts +114 -0
- package/src/market/__tests__/verify.test.ts +111 -0
- package/src/market/bootstrap.ts +255 -0
- package/src/market/build-index.ts +159 -0
- package/src/market/cdn-layout.ts +122 -0
- package/src/market/command.ts +139 -0
- package/src/market/failure-playbook.ts +57 -0
- package/src/market/fixture.ts +138 -0
- package/src/market/index-builder.ts +76 -0
- package/src/market/publish.ts +156 -0
- package/src/market/report.ts +67 -0
- package/src/market/scan.ts +99 -0
- package/src/market/setup.ts +144 -0
- package/src/market/trial.ts +121 -0
- package/src/market/verify.ts +298 -0
- package/src/market/walkthrough.ts +506 -0
- package/src/oj.ts +3 -1
- package/src/preview-config.ts +14 -0
- package/src/preview.ts +4 -3
- package/src/sign.ts +21 -6
- package/src/usage.ts +19 -0
- package/src/vendor.ts +4 -2
- package/src/versions.ts +28 -0
- package/templates/api/.ojm-api-exempt.json +6 -2
- package/templates/api/config.yaml +20 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
- package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
- package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
- package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
- package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
- package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
- package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
- package/templates/api/src/_platform/schema.yaml +12 -0
- package/templates/api/src/auth/_shared/session.ts +2 -2
- package/templates/api/src/demo/todos/api.ts +8 -9
- package/templates/api/src/notification/_shared/notify.ts +64 -26
- package/templates/api/src/notification/api.ts +1 -1
- package/templates/api/src/notification/manifest.yaml +1 -0
- package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
- package/templates/api/src/notification/schema.yaml +7 -0
- package/templates/api/src/notification/seed.sql +5 -4
- package/templates/api/src/personal-center/profile/api.ts +63 -0
- package/templates/api/src/web/user-info/api.ts +3 -1
- package/templates/api/tests/fixture.ts +8 -16
- package/templates/api/tests/l1-builder.test.ts +91 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
- package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
- package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
- package/templates/api/tests/query-utils.spec.ts +85 -1
- package/templates/api/tests/rbac-settings.spec.ts +64 -19
- package/templates/cdn/README.md +48 -0
- package/templates/global.d.ts +36 -2
- package/templates/init.include.yaml +3 -2
- package/templates/openapi.json +55 -10
- package/templates/web/src/notification/entry.ts +46 -9
- package/templates/web/src/notification/locales/en-US.json +19 -0
- package/templates/web/src/notification/locales/zh-CN.json +19 -0
- package/templates/web/src/notification/pages/center/index.tsx +131 -0
- package/templates/web/src/oj-market/entry.ts +8 -2
- package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
- package/templates/web/src/personal-center/__client/api.ts +25 -1
- package/templates/web/src/personal-center/entry.ts +5 -0
- package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
- package/templates/web/src/system/entry.ts +2 -1
- package/templates/web.config.ts +10 -7
- package/vendor/host-versions.json +3 -3
- package/templates/api/src/notifications/api.ts +0 -29
- package/templates/api/src/notifications/manifest.yaml +0 -5
- package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
- package/templates/web/src/notifications/__client/api.ts +0 -80
|
@@ -69,12 +69,17 @@ function splitId(id: string): [scope: "u" | "b", raw: string] {
|
|
|
69
69
|
function mapItem(r: Row): NoticeItem {
|
|
70
70
|
const scope = String(r.scope);
|
|
71
71
|
const isBroadcast = scope === "broadcast";
|
|
72
|
+
// UNION 结果列名取首分支(userQ)=> 位置 4 名为 is_read。按人项此处即存储整数列;
|
|
73
|
+
// 广播项此处实际承载回执 read_time(非空即已读,与原始 SQL 的 (read_time IS NOT NULL AND <> '') 等价)。
|
|
74
|
+
const isRead = isBroadcast
|
|
75
|
+
? (r.is_read != null && String(r.is_read) !== "")
|
|
76
|
+
: Number(r.is_read) === 1;
|
|
72
77
|
return {
|
|
73
78
|
id: (isBroadcast ? "b:" : "u:") + String(r.id),
|
|
74
79
|
scope: isBroadcast ? "broadcast" : "user",
|
|
75
80
|
avatar: String(r.avatar ?? ""),
|
|
76
81
|
date: String(r.date ?? ""),
|
|
77
|
-
isRead
|
|
82
|
+
isRead,
|
|
78
83
|
message: String(r.message ?? ""),
|
|
79
84
|
title: String(r.title ?? ""),
|
|
80
85
|
link: r.link == null ? undefined : String(r.link),
|
|
@@ -112,17 +117,17 @@ export async function pushBroadcastNotice(input: PushBroadcastInput): Promise<st
|
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
/**
|
|
115
|
-
* 删除通知。按人项校验归属:非本人返回 false
|
|
120
|
+
* 删除通知。按人项校验归属:非本人返回 false;广播项是共享行,物理删除会影响
|
|
121
|
+
* 其他收件人——落「对我隐藏」标记(幂等,返回 true),列表查询过滤 hidden 命中项。
|
|
116
122
|
* 返回是否成功操作到目标(调用方据 false 判 403)。
|
|
117
123
|
*/
|
|
118
124
|
export async function delNotice(id: string, userId: string | number): Promise<boolean> {
|
|
119
125
|
const [scope, raw] = splitId(id);
|
|
120
126
|
const uid = Number(userId);
|
|
121
127
|
if (scope === "b") {
|
|
122
|
-
await db.table("
|
|
123
|
-
.
|
|
124
|
-
.
|
|
125
|
-
.delete()
|
|
128
|
+
await db.table("notifications_broadcast_hidden")
|
|
129
|
+
.insert({ broadcast_id: Number(raw), user_id: uid, create_time: nowStr() })
|
|
130
|
+
.orIgnore()
|
|
126
131
|
.run();
|
|
127
132
|
return true;
|
|
128
133
|
}
|
|
@@ -142,18 +147,43 @@ export async function noticesFor(
|
|
|
142
147
|
const limit = opts.limit ?? 50;
|
|
143
148
|
const offset = opts.offset ?? 0;
|
|
144
149
|
const uid = Number(userId);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
// 构造器(v0.1.81):UNION ALL 合并「按人」与「广播(LEFT JOIN 回执)」;lit 打固定 scope 标记列,
|
|
151
|
+
// 顶层 orderBy/limit/offset 跨方言一致。
|
|
152
|
+
// 注意:UNION 结果列名取首分支(userQ),故两分支位置对齐即可——广播第 4 列取回执 read_time,
|
|
153
|
+
// 经 union 落到 is_read 列名下,是否「已读」由 mapItem 据非空推导(与原始 SQL 同源)。
|
|
154
|
+
const userQ = db.table("notifications").select([
|
|
155
|
+
"id",
|
|
156
|
+
"avatar",
|
|
157
|
+
"date",
|
|
158
|
+
"is_read",
|
|
159
|
+
"message",
|
|
160
|
+
"title",
|
|
161
|
+
"link",
|
|
162
|
+
"category",
|
|
163
|
+
{ lit: "user", as: "scope" },
|
|
164
|
+
]).where({ field: "user_id", op: "eq", value: uid });
|
|
165
|
+
const broadcastQ = db.table("notifications_broadcast").as("b").join("notifications_broadcast_read", [
|
|
166
|
+
{ left: "b.id", right: "r.broadcast_id" },
|
|
167
|
+
{ left: "r.user_id", op: "eq", value: uid },
|
|
168
|
+
], "left", "r").join("notifications_broadcast_hidden", [
|
|
169
|
+
{ left: "b.id", right: "h.broadcast_id" },
|
|
170
|
+
{ left: "h.user_id", op: "eq", value: uid },
|
|
171
|
+
], "left", "h").where({ field: "h.id", op: "isnull" }).select([
|
|
172
|
+
"b.id",
|
|
173
|
+
"b.avatar",
|
|
174
|
+
"b.date",
|
|
175
|
+
"r.read_time",
|
|
176
|
+
"b.message",
|
|
177
|
+
"b.title",
|
|
178
|
+
"b.link",
|
|
179
|
+
"b.category",
|
|
180
|
+
{ lit: "broadcast", as: "scope" },
|
|
181
|
+
]);
|
|
182
|
+
const rows = await userQ.union(broadcastQ, "all")
|
|
183
|
+
.orderBy([{ field: "date", dir: "desc" }])
|
|
184
|
+
.limit(limit)
|
|
185
|
+
.offset(offset)
|
|
186
|
+
.all();
|
|
157
187
|
return rows.map(mapItem);
|
|
158
188
|
}
|
|
159
189
|
|
|
@@ -166,11 +196,11 @@ export async function markRead(id: string, userId: string | number): Promise<boo
|
|
|
166
196
|
const uid = Number(userId);
|
|
167
197
|
const now = nowStr();
|
|
168
198
|
if (scope === "b") {
|
|
169
|
-
//
|
|
170
|
-
await db.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
// 构造器(v0.1.81):orIgnore 幂等写入(sqlite/pg → ON CONFLICT DO NOTHING;mysql → ON DUPLICATE KEY UPDATE pk=pk)。
|
|
200
|
+
await db.table("notifications_broadcast_read")
|
|
201
|
+
.insert({ broadcast_id: Number(raw), user_id: uid, read_time: now })
|
|
202
|
+
.orIgnore()
|
|
203
|
+
.run();
|
|
174
204
|
return true;
|
|
175
205
|
}
|
|
176
206
|
const n = await db.table("notifications")
|
|
@@ -186,16 +216,24 @@ export async function markAllRead(userId: string | number): Promise<void> {
|
|
|
186
216
|
const uid = Number(userId);
|
|
187
217
|
const now = nowStr();
|
|
188
218
|
await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).update({ is_read: 1 }).run();
|
|
189
|
-
// 保留 db.exec:INSERT OR IGNORE ... SELECT
|
|
219
|
+
// 保留 db.exec:INSERT OR IGNORE ... SELECT 在 sqlite 上构造器无法表达——
|
|
220
|
+
// oj 0.1.81 的 orIgnore 仅跟随 VALUES 的 upsert 子句,不与 fromSelect(SELECT 源) 组合
|
|
221
|
+
// (实测:code 500 "orIgnore with fromSelect is not supported on sqlite")。原 SQL 三方言通用
|
|
222
|
+
// (sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql ON DUPLICATE KEY),退回裸 SQL。
|
|
190
223
|
await db.exec(
|
|
191
224
|
"INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
|
|
192
225
|
[uid, now],
|
|
193
226
|
);
|
|
194
227
|
}
|
|
195
228
|
|
|
196
|
-
/** 清空:删按人项 +
|
|
229
|
+
/** 清空:删按人项 + 广播逐条落「对我隐藏」标记(共享行不可物理删,他人不受影响) */
|
|
197
230
|
export async function clearNotices(userId: string | number): Promise<void> {
|
|
198
231
|
const uid = Number(userId);
|
|
199
232
|
await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).delete().run();
|
|
200
|
-
|
|
233
|
+
// 保留 db.exec:INSERT OR IGNORE ... SELECT 同 markAllRead(构造器 orIgnore 不与
|
|
234
|
+
// fromSelect 组合,实测 500),原 SQL 三方言通用。
|
|
235
|
+
await db.exec(
|
|
236
|
+
"INSERT OR IGNORE INTO notifications_broadcast_hidden (broadcast_id, user_id, create_time) SELECT id, ?, ? FROM notifications_broadcast",
|
|
237
|
+
[uid, nowStr()],
|
|
238
|
+
);
|
|
201
239
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 广播隐藏标记表(按人):清空/单条删除广播项时落标记——广播本体是共享行,
|
|
2
|
+
-- 任何人不得物理删除(会影响其他收件人),只能「对我隐藏」。
|
|
3
|
+
-- 清空 = 按人项物理删 + 广播逐条落 hidden 标记;列表查询过滤 hidden 命中项。
|
|
4
|
+
-- 三方言可移植:建表 + 索引均为标准 DDL。
|
|
5
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast_hidden (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
broadcast_id INTEGER NOT NULL,
|
|
8
|
+
user_id INTEGER NOT NULL,
|
|
9
|
+
create_time TEXT NOT NULL DEFAULT ''
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_broadcast_hidden ON notifications_broadcast_hidden (user_id, broadcast_id);
|
|
@@ -31,3 +31,10 @@ tables:
|
|
|
31
31
|
broadcast_id: {type: integer, null: false}
|
|
32
32
|
user_id: {type: integer, null: false}
|
|
33
33
|
read_time: {type: text, null: false}
|
|
34
|
+
notifications_broadcast_hidden:
|
|
35
|
+
pk: id
|
|
36
|
+
columns:
|
|
37
|
+
id: {type: integer, autoincrement: true}
|
|
38
|
+
broadcast_id: {type: integer, null: false}
|
|
39
|
+
user_id: {type: integer, null: false}
|
|
40
|
+
create_time: {type: text, null: false}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
-- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
|
|
2
|
+
-- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
|
|
2
3
|
-- 按分号朴素切分(S006):语句内不得含分号字面量。
|
|
3
|
-
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, '', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / 123456 登录开始', '欢迎', NULL, 'notice');
|
|
4
|
-
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, '', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
|
|
5
|
-
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, '', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
|
|
6
|
-
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, '', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
|
|
4
|
+
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / 123456 登录开始', '欢迎', NULL, 'notice');
|
|
5
|
+
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, 'https://avatar.vercel.sh/seed-bell.svg?text=B', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
|
|
6
|
+
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, 'https://avatar.vercel.sh/seed-contract.svg?text=C', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
|
|
7
|
+
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, 'https://avatar.vercel.sh/seed-dev.svg?text=D', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// POST /api/personal-center/profile —— 保存「我的资料」(个人中心表单入库端点)。
|
|
2
|
+
// 只允许改本人行(http.user.id 权威,不接受 body 传 id);username 全局唯一,
|
|
3
|
+
// 重名 400;avatar 走 upload 端点(multipart),不在此通道。
|
|
4
|
+
const handler = {
|
|
5
|
+
async post() {
|
|
6
|
+
const uid = http.user?.id;
|
|
7
|
+
if (!uid) {
|
|
8
|
+
json.fail(401, "unauthorized");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const b = (http.body ?? {}) as {
|
|
12
|
+
username?: unknown
|
|
13
|
+
email?: unknown
|
|
14
|
+
phoneNumber?: unknown
|
|
15
|
+
description?: unknown
|
|
16
|
+
};
|
|
17
|
+
const patch: Record<string, string> = {};
|
|
18
|
+
if (b.username != null && String(b.username).trim() !== "") {
|
|
19
|
+
const username = String(b.username).trim();
|
|
20
|
+
const dup = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
|
|
21
|
+
if (dup.some(r => Number(r.id) !== Number(uid))) {
|
|
22
|
+
json.fail(400, "username already taken");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
patch.username = username;
|
|
26
|
+
}
|
|
27
|
+
if (b.email != null)
|
|
28
|
+
patch.email = String(b.email);
|
|
29
|
+
if (b.phoneNumber != null)
|
|
30
|
+
patch.phone_number = String(b.phoneNumber);
|
|
31
|
+
if (b.description != null)
|
|
32
|
+
patch.description = String(b.description);
|
|
33
|
+
if (Object.keys(patch).length === 0) {
|
|
34
|
+
json.fail(400, "nothing to update");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await db.table("users").update(patch).where({ field: "id", op: "eq", value: Number(uid) }).run();
|
|
39
|
+
json.ok(null);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
json.fail(500, String(e));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// 入参:四字段均可选(部分保存允许),类型即字符串。
|
|
48
|
+
handler.post.schema = {
|
|
49
|
+
body: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
username: { type: "string" },
|
|
53
|
+
email: { type: "string" },
|
|
54
|
+
phoneNumber: { type: "string" },
|
|
55
|
+
description: { type: "string" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// 出参:无业务载荷(前端保存后经 user-info 重拉刷新 store)。
|
|
61
|
+
handler.post.response = { type: "null" };
|
|
62
|
+
|
|
63
|
+
export default handler;
|
|
@@ -10,7 +10,7 @@ export default {
|
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
try {
|
|
13
|
-
const rows = await db.table("users").select(["id", "username", "display_name", "avatar_base64", "email", "must_change"]).where({ field: "id", op: "eq", value: Number(uid) }).all();
|
|
13
|
+
const rows = await db.table("users").select(["id", "username", "display_name", "avatar_base64", "email", "phone_number", "description", "must_change"]).where({ field: "id", op: "eq", value: Number(uid) }).all();
|
|
14
14
|
const row = rows[0];
|
|
15
15
|
if (!row) {
|
|
16
16
|
json.fail(404, "user not found");
|
|
@@ -23,6 +23,8 @@ export default {
|
|
|
23
23
|
username: String(row.username),
|
|
24
24
|
displayName: row.display_name == null ? "" : String(row.display_name),
|
|
25
25
|
email: String(row.email ?? ""),
|
|
26
|
+
phoneNumber: row.phone_number == null ? "" : String(row.phone_number),
|
|
27
|
+
description: row.description == null ? "" : String(row.description),
|
|
26
28
|
roles: roleRows.map(r => r.role_name),
|
|
27
29
|
mustChange: Number(row.must_change) === 1,
|
|
28
30
|
permissions: [],
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// 后端 BDD 夹具:node:sqlite 内存库 + **真实迁移文件**建表,装到 globalThis.db——
|
|
2
2
|
// 与 oj 运行时注入的全局同名同面(query/exec/tx/dialect),service 代码不改一行即可被测。
|
|
3
3
|
// 夹具直接读 api/src/<模块>/migrations/*.sql,与迁移不可能漂移(沿用 oj-wiki 先例)。
|
|
4
|
+
//
|
|
5
|
+
// **构造器边界(架构定论,勿在此夹具里补 table())**:db.table()/listTables() 等构造器是
|
|
6
|
+
// oj 二进制内的纯 Rust 实现,无 JS 模块可导出,Node 侧垫不出来——本夹具刻意只给
|
|
7
|
+
// {query, exec, tx, dialect}。两层测试的分工因此定型:
|
|
8
|
+
// · L2(本目录 *.spec.ts):构造器面用**记录型假 builder**(记 where/orderBy/limit 调用
|
|
9
|
+
// 序列做编排断言);真 SQL 执行断言走 db.query 直查。
|
|
10
|
+
// · L1(同目录 *.test.ts,`oj test` 收集):真 v8 + 真构造器闭环,守 SQL 渲染/过滤/转义
|
|
11
|
+
// 的真执行面(样板见 tests/l1-builder.test.ts;方法论见 devkit oj-test-guide.md)。
|
|
4
12
|
import { createRequire } from "node:module";
|
|
5
13
|
|
|
6
14
|
const req = createRequire(import.meta.url);
|
|
@@ -13,22 +21,6 @@ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
|
13
21
|
|
|
14
22
|
export const ADMIN = 1;
|
|
15
23
|
|
|
16
|
-
/**
|
|
17
|
-
* market_history 同构 DDL:表真源 = 市场方案包自带迁移(oj-market 0002__market_history),
|
|
18
|
-
* 不属平台迁移(installDb 不回放)——需要断言留痕的用例显式执行;守卫语义(表不存在静默跳过)
|
|
19
|
-
* 另有专项用例钉住。
|
|
20
|
-
*/
|
|
21
|
-
export const MARKET_HISTORY_DDL = `CREATE TABLE market_history (
|
|
22
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
-
solution TEXT NOT NULL,
|
|
24
|
-
version TEXT NOT NULL,
|
|
25
|
-
action TEXT NOT NULL,
|
|
26
|
-
status TEXT NOT NULL,
|
|
27
|
-
operator TEXT,
|
|
28
|
-
message TEXT,
|
|
29
|
-
created_at INTEGER NOT NULL
|
|
30
|
-
)`;
|
|
31
|
-
|
|
32
24
|
/** 建内存库 + 注入全局;返回 DatabaseSync 供用例直插数据/断言。 */
|
|
33
25
|
export function installDb(): any {
|
|
34
26
|
const dbo = new DatabaseSync(":memory:");
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// L1 回归(`oj test` 收集,*.test.ts;与 vitest 的 *.spec.ts 互不干扰)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么有这类文件:SQL 构造器是 oj 二进制内的纯 Rust 实现,vitest 夹具够不着真
|
|
4
|
+
// builder(架构定论:无 JS 模块可导、不写运行时垫片)——模板 tests/*.spec.ts 只能做
|
|
5
|
+
// L2 记录型断言。**真执行面**(SQL 渲染 / 真过滤 / 真转义)在这里经 oj test 闭环:
|
|
6
|
+
// 真 v8 + 真路由/鉴权 + 真迁移/seed + 真 db + 真构造器(方法论见 devkit oj-test-guide.md)。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖 0.1.81/0.1.82 构造器迁移的关键行为:
|
|
9
|
+
// ① settings 真构造器(IN + LIMIT)——vitest 侧同源码 5 个用例红海的对照面;
|
|
10
|
+
// ② demo/todos:whereIf 真跳过(无 keyword 不过滤)+ contains 真渲染(%kw% + 元字符转义);
|
|
11
|
+
// ③ notification:join ON 值条件 + UNION ALL + 字面量列 {lit,as} 的真渲染(场景 41 同款)。
|
|
12
|
+
//
|
|
13
|
+
// 登录账号:fixtures 的 l1tester(src/_platform/fixtures/l1-test-user.sql,仅 oj test 灌入)。
|
|
14
|
+
// 不用 seed 的 admin:其 must_change=1(判权端点恒 403)且初始口令 123456 会随用户
|
|
15
|
+
// 首登改密漂移——L1 绑 admin 曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
|
|
16
|
+
//
|
|
17
|
+
// 运行:cd packages/cli/templates/api && ~/.local/share/oj/oj test -c config.yaml -d src -t tests
|
|
18
|
+
|
|
19
|
+
const L1_USER = { username: "l1tester", password: "l1test1234" };
|
|
20
|
+
const asL1 = async () => ({ Authorization: `Bearer ${await client.login(L1_USER.username, L1_USER.password)}` });
|
|
21
|
+
|
|
22
|
+
describe("l1:0.1.81/0.1.82 构造器迁移真执行面", () => {
|
|
23
|
+
it("匿名 login-methods:真构造器(IN + LIMIT 渲染)返回 200 + 方法数组", async () => {
|
|
24
|
+
const r = await client.get("/system/login-methods");
|
|
25
|
+
expect(r.status).toBe(200);
|
|
26
|
+
const body = JSON.parse(r.body);
|
|
27
|
+
expect(body.code).toBe(0);
|
|
28
|
+
expect(Array.isArray(body.data.methods)).toBeTruthy();
|
|
29
|
+
expect(body.data.methods).toContain("local");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("demo/todos 无 keyword:whereIf 真跳过(不发射条件),全量 4 条", async () => {
|
|
33
|
+
const h = await asL1();
|
|
34
|
+
const r = await client.get("/demo/todos", { headers: h });
|
|
35
|
+
expect(r.status).toBe(200);
|
|
36
|
+
const body = JSON.parse(r.body);
|
|
37
|
+
expect(body.data.total).toBe(4);
|
|
38
|
+
expect(body.data.list.length).toBe(4); // L1 expect 无 toHaveLength(契约见 api-manual §9)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("demo/todos keyword=契约:contains 真渲染(%kw%)精确命中 1 条", async () => {
|
|
42
|
+
const h = await asL1();
|
|
43
|
+
const r = await client.get(`/demo/todos?keyword=${encodeURIComponent("契约")}`, { headers: h });
|
|
44
|
+
expect(r.status).toBe(200);
|
|
45
|
+
const body = JSON.parse(r.body);
|
|
46
|
+
expect(body.data.total).toBe(1);
|
|
47
|
+
expect(String(body.data.list[0].title)).toContain("契约");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("demo/todos keyword=%:LIKE 元字符经引擎转义不泛化(0 命中且不炸)", async () => {
|
|
51
|
+
const h = await asL1();
|
|
52
|
+
const r = await client.get(`/demo/todos?keyword=${encodeURIComponent("%")}`, { headers: h });
|
|
53
|
+
expect(r.status).toBe(200);
|
|
54
|
+
expect(JSON.parse(r.body).data.total).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("notification 列表:join ON 值条件 + UNION + 字面量 scope 列真渲染,按人/广播分流", async () => {
|
|
58
|
+
const h = await asL1();
|
|
59
|
+
// 自给自足(oj-test-guide §6 纪律:按自己推的行精确断言,不依赖 seed 的 user_id=1):
|
|
60
|
+
// 先清空本人视图,再经真 push 端点各发一条按人 + 一条广播,回来过 list 断言。
|
|
61
|
+
const clear = await client.post("/notification/notifications/clear", { headers: h });
|
|
62
|
+
expect(clear.status).toBe(200);
|
|
63
|
+
|
|
64
|
+
const pushUser = await client.post("/notification/push", {
|
|
65
|
+
headers: h,
|
|
66
|
+
body: JSON.stringify({ title: "L1 按人通知", message: "join ON 值条件 + UNION 真渲染" }),
|
|
67
|
+
});
|
|
68
|
+
expect(pushUser.status).toBe(200);
|
|
69
|
+
const userId = String(JSON.parse(pushUser.body).data.id);
|
|
70
|
+
expect(userId.startsWith("u:")).toBeTruthy(); // 命名空间 id:按人
|
|
71
|
+
|
|
72
|
+
const pushBcast = await client.post("/notification/push-broadcast", {
|
|
73
|
+
headers: h,
|
|
74
|
+
body: JSON.stringify({ title: "L1 广播通知", message: "LEFT JOIN 回执 + 字面量 scope 列" }),
|
|
75
|
+
});
|
|
76
|
+
expect(pushBcast.status).toBe(200);
|
|
77
|
+
const bcastId = String(JSON.parse(pushBcast.body).data.id);
|
|
78
|
+
expect(bcastId.startsWith("b:")).toBeTruthy(); // 命名空间 id:广播
|
|
79
|
+
|
|
80
|
+
const r = await client.get("/notification/notifications", { headers: h });
|
|
81
|
+
expect(r.status).toBe(200);
|
|
82
|
+
const list = JSON.parse(r.body).data as Array<Record<string, unknown>>;
|
|
83
|
+
expect(Array.isArray(list)).toBeTruthy();
|
|
84
|
+
const byId = new Map(list.map(n => [String(n.id), n]));
|
|
85
|
+
// {lit:"user",as:"scope"} / {lit:"broadcast",...} 经 UNION 落到首列名——两条各带正确作用域
|
|
86
|
+
expect(byId.get(userId)?.scope).toBe("user");
|
|
87
|
+
expect(byId.get(bcastId)?.scope).toBe("broadcast");
|
|
88
|
+
expect(String(byId.get(userId)?.title)).toBe("L1 按人通知");
|
|
89
|
+
expect(String(byId.get(bcastId)?.title)).toBe("L1 广播通知");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -28,7 +28,7 @@ import { acquireTaskLock, commitLockEntry, parseLock, readLock, serializeLock, w
|
|
|
28
28
|
import { createStateStore } from "../../src/_platform/_lifecycle/state";
|
|
29
29
|
import { getTask, readTasks, redact, sweepTimedOut, writeTask } from "../../src/_platform/_lifecycle/tasks";
|
|
30
30
|
import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
|
|
31
|
-
import { installDb
|
|
31
|
+
import { installDb } from "../fixture";
|
|
32
32
|
|
|
33
33
|
const g = globalThis as any;
|
|
34
34
|
|
|
@@ -979,7 +979,6 @@ describe("market_history 留痕(P1-15a 缺口 A):install/enable/disable/un
|
|
|
979
979
|
}
|
|
980
980
|
|
|
981
981
|
it("runInstall 终态(done)落 install 历史(守卫式:随主流程写,失败不阻断)", async () => {
|
|
982
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
983
982
|
const t = await runInstall(makeReq());
|
|
984
983
|
expect(t.status).toBe("done");
|
|
985
984
|
const hist = rows("SELECT solution, version, action, status, operator, message FROM market_history");
|
|
@@ -989,7 +988,6 @@ describe("market_history 留痕(P1-15a 缺口 A):install/enable/disable/un
|
|
|
989
988
|
});
|
|
990
989
|
|
|
991
990
|
it("setEnabled 落 disable/enable 历史(status=success,message 与返回人话一致)", async () => {
|
|
992
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
993
991
|
await seedHistory();
|
|
994
992
|
const off = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
|
|
995
993
|
const on = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath });
|
|
@@ -1185,7 +1183,6 @@ describe("P1-16 接线:reconcile 不一致自动修复(重跑 setEnabled)+
|
|
|
1185
1183
|
});
|
|
1186
1184
|
|
|
1187
1185
|
it("operator 通道:runInstall / setEnabled 的 operator 落 market_history(缺省 null 语义不变)", async () => {
|
|
1188
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
1189
1186
|
await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
|
|
1190
1187
|
await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
|
|
1191
1188
|
await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
|
|
@@ -28,7 +28,7 @@ import { getTask, writeTask } from "../../src/_platform/_lifecycle/tasks";
|
|
|
28
28
|
import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
|
|
29
29
|
import { invalidateAllUsers } from "../../src/_platform/_shared/service/infra/access";
|
|
30
30
|
import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/_platform/market/api";
|
|
31
|
-
import { installDb
|
|
31
|
+
import { installDb } from "../fixture";
|
|
32
32
|
|
|
33
33
|
function hexToBytes(hex: string): Uint8Array {
|
|
34
34
|
const out = new Uint8Array(hex.length / 2);
|
|
@@ -680,7 +680,6 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
|
|
|
680
680
|
});
|
|
681
681
|
|
|
682
682
|
it("operator 回落 history 行(P2-11:最近一次 install 动作的 operator;表缺失/无行 → null 不炸)", async () => {
|
|
683
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
684
683
|
dbo.prepare("insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)")
|
|
685
684
|
.run("demo-solution", "0.1.0", "install", "done", "alice", "", 1700000000);
|
|
686
685
|
const api = createMarketApi(makeEnv());
|
|
@@ -917,7 +916,6 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
917
916
|
});
|
|
918
917
|
|
|
919
918
|
it("operator 通道:install/enable 端点按 http.user.id 反查 username 落 market_history(uninstall 同款反查)", async () => {
|
|
920
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
921
919
|
// 预置「已装」最小形态(enable 用例同款:单条目禁用会触发空锁拒启,S1-1.2)
|
|
922
920
|
await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0", "other": "1.0.0" });
|
|
923
921
|
const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
|
|
@@ -514,7 +514,7 @@ describe("checkModuleManifest(C2-31 安装期复核纯函数:C2 manifest ↔
|
|
|
514
514
|
const c2 = { name: "demo-solution", version: "0.1.0", summary: "演示方案 Demo — émoji 🎉" };
|
|
515
515
|
const native = { name: "demo-solution", desc: "演示方案 Demo — émoji 🎉", version: "0.1.0" };
|
|
516
516
|
|
|
517
|
-
it("合法:
|
|
517
|
+
it("合法:name/version 三方一致、desc 非空、七键之内 → ok", () => {
|
|
518
518
|
expect(checkModuleManifest(c2, native, "demo-solution", "0.1.0")).toEqual({ ok: true });
|
|
519
519
|
});
|
|
520
520
|
|
|
@@ -535,10 +535,9 @@ describe("checkModuleManifest(C2-31 安装期复核纯函数:C2 manifest ↔
|
|
|
535
535
|
expect(r.ok).toBe(false);
|
|
536
536
|
});
|
|
537
537
|
|
|
538
|
-
it("desc !== summary →
|
|
538
|
+
it("desc !== summary → 放行(等值要求已于 2026-10-21 移除:desc 只是模块描述,非安全边界)", () => {
|
|
539
539
|
const r = checkModuleManifest(c2, { ...native, desc: "另一个描述" }, "demo-solution", "0.1.0");
|
|
540
|
-
expect(r.ok).toBe(
|
|
541
|
-
expect(!r.ok && r.message).toContain("desc");
|
|
540
|
+
expect(r.ok).toBe(true);
|
|
542
541
|
});
|
|
543
542
|
|
|
544
543
|
it("name 或 version 与包身份不一致 → 拒", () => {
|
|
@@ -613,11 +612,9 @@ describe("extract C2-31 安装期复核(B-11:api 段声明与包内原生清
|
|
|
613
612
|
expect(r.message).toContain("C2-31");
|
|
614
613
|
});
|
|
615
614
|
|
|
616
|
-
it("包内原生清单 desc !== summary →
|
|
615
|
+
it("包内原生清单 desc !== summary → 放行(等值校验已移除)", async () => {
|
|
617
616
|
await seedBundle({ nativeManifest: { name: "demo-solution", desc: "另一个描述", version: "0.1.0" } });
|
|
618
617
|
const r = await extract(exCtx({ manifest: c2Manifest(), exec: realTarExec() }));
|
|
619
|
-
expect(r.ok).toBe(
|
|
620
|
-
expect(r.retryable).toBe(false);
|
|
621
|
-
expect(r.message).toContain("C2-31");
|
|
618
|
+
expect(r.ok).toBe(true);
|
|
622
619
|
});
|
|
623
620
|
});
|