@oj-module/cli 0.2.8 → 0.2.15
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 +69 -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 +262 -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/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +2 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +2 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +70 -1
- package/src/manifest-contract.ts +25 -2
- 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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// 端点(createMarketApi 的 uninstall-impact / uninstall)只做门禁 + 取参 + 调 runUninstall;
|
|
3
3
|
// 编排器负责四处同删(锁/sidecar/sys_solution_route/modules.json)+ 权限级联 uninstallSolution
|
|
4
4
|
// + 版本目录移除 + 审计 + 全用户失效 + market_history 留痕。本规格为验收锚点。
|
|
5
|
-
// 夹具:本仓范式(node:sqlite 真实迁移建表 + 真实 fs 面 g.fs);market_history
|
|
6
|
-
//
|
|
5
|
+
// 夹具:本仓范式(node:sqlite 真实迁移建表 + 真实 fs 面 g.fs);market_history 表由平台迁移
|
|
6
|
+
// 0012(IF NOT EXISTS)随 installDb 重放建立,守卫语义(表不存在静默跳过)单列一例。
|
|
7
7
|
/* eslint-disable test/prefer-lowercase-title -- 端点动词/路径是验收锚点,保留大写(market-api.spec 同约定) */
|
|
8
8
|
import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
|
|
9
9
|
import type { MarketEnv } from "../../src/_platform/market/api";
|
|
@@ -13,10 +13,10 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
14
|
import { readLock, writeLock } from "../../src/_platform/_lifecycle/lock";
|
|
15
15
|
import { createStateStore } from "../../src/_platform/_lifecycle/state";
|
|
16
|
-
import {
|
|
16
|
+
import { collectRetainedTables, moduleTablePrefix } from "../../src/_platform/_lifecycle/uninstall";
|
|
17
17
|
import { invalidateAllUsers } from "../../src/_platform/_shared/service/infra/access";
|
|
18
18
|
import { createMarketApi } from "../../src/_platform/market/api";
|
|
19
|
-
import { installDb
|
|
19
|
+
import { installDb } from "../fixture";
|
|
20
20
|
|
|
21
21
|
const g = globalThis as any;
|
|
22
22
|
|
|
@@ -67,7 +67,6 @@ async function seedInstalled(opts: { withOther?: boolean } = {}): Promise<void>
|
|
|
67
67
|
{ name: "demo-solution", enabled: true, src: "modules/demo-solution/" },
|
|
68
68
|
...(withOther ? [{ name: "other", enabled: true, src: "modules/other/" }] : []),
|
|
69
69
|
]));
|
|
70
|
-
dbo.exec(MARKET_HISTORY_DDL);
|
|
71
70
|
dbo.prepare("INSERT INTO sys_solution_route (path, module, category) VALUES (?, ?, ?)").run("/demo-solution", "demo-solution", "main");
|
|
72
71
|
dbo.prepare("INSERT INTO sys_role (name, module, title, builtin) VALUES (?, ?, ?, 0)").run("demo-solution.admin", "demo-solution", "管理员");
|
|
73
72
|
dbo.prepare("INSERT INTO sys_role (name, module, title, builtin) VALUES (?, ?, ?, 0)").run("demo-solution.viewer", "demo-solution", "访客");
|
|
@@ -295,92 +294,33 @@ describe("P3-4 业务表保留提示(Q4:不删业务数据,retained 回传
|
|
|
295
294
|
});
|
|
296
295
|
});
|
|
297
296
|
|
|
298
|
-
describe("P4-1
|
|
299
|
-
it("
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const pg = catalogSpec("pg");
|
|
303
|
-
expect(sqlite.existing).toBe("SELECT name FROM sqlite_master WHERE type='table'");
|
|
304
|
-
expect(mysql.existing).toContain("information_schema.tables");
|
|
305
|
-
expect(mysql.existing).toContain("DATABASE()");
|
|
306
|
-
expect(pg.existing).toContain("information_schema.tables");
|
|
307
|
-
expect(pg.existing).toContain("table_schema = 'public'");
|
|
297
|
+
describe("P4-1 卸载业务表保留清单(P3-4#2):moduleTablePrefix + db.listTables 方言无关", () => {
|
|
298
|
+
it("moduleTablePrefix 连字符→下划线作表前缀(listTables 内部按字面量前缀匹配,无需手拼 %)", () => {
|
|
299
|
+
expect(moduleTablePrefix("demo-solution")).toBe("demo_solution_");
|
|
300
|
+
expect(moduleTablePrefix("a!b%c_d")).toBe("a!b%c_d_");
|
|
308
301
|
});
|
|
309
302
|
|
|
310
|
-
it("
|
|
311
|
-
const pat = "demo_solution_%";
|
|
312
|
-
const sqlite = catalogSpec("sqlite").like(pat);
|
|
313
|
-
const mysql = catalogSpec("mysql").like(pat);
|
|
314
|
-
const pg = catalogSpec("pg").like(pat);
|
|
315
|
-
// sqlite
|
|
316
|
-
expect(sqlite[0]).toBe("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '!'");
|
|
317
|
-
expect(sqlite[1]).toEqual([pat]);
|
|
318
|
-
// mysql:information_schema + DATABASE() + ESCAPE + 参数化(值不拼字符串,SSRF/注入无关但口径统一)
|
|
319
|
-
expect(mysql[0]).toContain("information_schema.tables");
|
|
320
|
-
expect(mysql[0]).toContain("DATABASE()");
|
|
321
|
-
expect(mysql[0]).toContain("ESCAPE '!'");
|
|
322
|
-
expect(mysql[1]).toEqual([pat]);
|
|
323
|
-
// pg:information_schema + 'public' + ESCAPE + 参数化
|
|
324
|
-
expect(pg[0]).toContain("information_schema.tables");
|
|
325
|
-
expect(pg[0]).toContain("table_schema = 'public'");
|
|
326
|
-
expect(pg[0]).toContain("ESCAPE '!'");
|
|
327
|
-
expect(pg[1]).toEqual([pat]);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it("tablePrefixPattern 转义 !%_ 且连字符→下划线作表前缀", () => {
|
|
331
|
-
// 连字符→下划线后,该下划线亦被转义(!_)= 字面分隔符而非 LIKE 通配,避免前缀扫描越权命中
|
|
332
|
-
expect(tablePrefixPattern("demo-solution")).toBe("demo!_solution_%");
|
|
333
|
-
// 危险字符必须转义,避免 LIKE 通配语义被注入(防前缀扫描越权命中)
|
|
334
|
-
expect(tablePrefixPattern("a!b%c_d")).toBe("a!!b!%c!_d_%");
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
describe("P4-1 多方言卸载矩阵(续):collectRetainedTables 经 db.dialect 走 mysql/pg 信息_schema 路径", () => {
|
|
339
|
-
/** 注入指定方言的 db.query 桩:existing 返回全表清单、like 返回前缀命中,模拟 information_schema 行为 */
|
|
340
|
-
function stubDialect(dialect: "mysql" | "pg", existingNames: string[], likeNames: string[]): void {
|
|
341
|
-
const spec = catalogSpec(dialect);
|
|
342
|
-
(g.db as { dialect: () => string, query: (sql: string, p?: unknown[]) => unknown }).dialect = () => dialect;
|
|
343
|
-
(g.db as { query: (sql: string, p?: unknown[]) => unknown }).query = (sql: string) => {
|
|
344
|
-
if (sql === spec.existing)
|
|
345
|
-
return existingNames.map(name => ({ name }));
|
|
346
|
-
if (sql.includes("AND table_name LIKE"))
|
|
347
|
-
return likeNames.map(name => ({ name }));
|
|
348
|
-
return [];
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
it("mysql 方言:manifest 声明 + 前缀兜底扫描均经 information_schema,返回实际存在的业务表", async () => {
|
|
303
|
+
it("manifest 声明 + 前缀兜底经 db.listTables 合并,仅回传实际存在的业务表", async () => {
|
|
353
304
|
await seedInstalledWithTables();
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
expect(retained).toContain("demo_solution_orphan");
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it("db.dialect 缺省回落 sqlite(不抛、不误导),与既有 sqlite 全量路径一致", async () => {
|
|
371
|
-
await seedInstalledWithTables();
|
|
372
|
-
// 还原为缺省(不提供 dialect 函数)→ currentDialect 回落 "sqlite"
|
|
373
|
-
const spec = catalogSpec("sqlite");
|
|
374
|
-
(g.db as { dialect?: () => string }).dialect = undefined;
|
|
375
|
-
(g.db as { query: (sql: string, p?: unknown[]) => unknown }).query = (sql: string) => {
|
|
376
|
-
if (sql === spec.existing)
|
|
377
|
-
return [{ name: "demo_solution_notes" }, { name: "demo_solution_tags" }];
|
|
378
|
-
if (sql.includes("AND name LIKE"))
|
|
379
|
-
return [{ name: "demo_solution_notes" }];
|
|
380
|
-
return [];
|
|
305
|
+
// manifest 追加「声明了但没建表」的幽灵条目,验证存在性过滤
|
|
306
|
+
const manifestPath = join(sharedRoot, "demo-solution-0.1.0", "manifest.yaml");
|
|
307
|
+
await fsp.writeFile(manifestPath, `${await fsp.readFile(manifestPath, "utf-8")} - demo_solution_ghost\n`);
|
|
308
|
+
// 真实 sqlite 目录:manifest 声明 notes/tags(seed 已建)+ 前缀扫描命中 orphan(未声明)
|
|
309
|
+
dbo.exec("CREATE TABLE demo_solution_orphan (id INTEGER)");
|
|
310
|
+
// L2:listTables 是 oj 运行时能力(纯 Rust 二进制内,vitest 无载体,见 devkit
|
|
311
|
+
// oj-test-guide.md)——桩回放**真实 sqlite 目录**(sqlite_master 实查)并记录
|
|
312
|
+
// 调用形态;被测的是合并/过滤编排,不是引擎的目录自省本身。
|
|
313
|
+
const seen: Array<string | undefined> = [];
|
|
314
|
+
g.db.listTables = async (prefix?: string) => {
|
|
315
|
+
seen.push(prefix);
|
|
316
|
+
const all = (dbo.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>).map(r => r.name);
|
|
317
|
+
return prefix === undefined ? all : all.filter(t => t.startsWith(prefix));
|
|
381
318
|
};
|
|
382
|
-
const retained = await collectRetainedTables("demo-solution",
|
|
383
|
-
expect(
|
|
384
|
-
expect(retained).toContain("
|
|
319
|
+
const retained = await collectRetainedTables("demo-solution", manifestPath);
|
|
320
|
+
expect(seen).toEqual(["demo_solution_", undefined]); // 前缀扫描 + 全量存在性过滤各一次
|
|
321
|
+
expect(retained).toContain("demo_solution_notes"); // manifest 声明
|
|
322
|
+
expect(retained).toContain("demo_solution_tags"); // manifest 声明
|
|
323
|
+
expect(retained).toContain("demo_solution_orphan"); // 前缀兜底命中(未声明)
|
|
324
|
+
expect(retained).not.toContain("demo_solution_ghost"); // 声明但物理不存在 → 不回传
|
|
385
325
|
});
|
|
386
326
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
4
4
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
5
5
|
|
|
6
|
-
import { likePattern, paged } from "../src/_platform/_shared/service/infra/query";
|
|
6
|
+
import { likePattern, paged, pagedFactory } from "../src/_platform/_shared/service/infra/query";
|
|
7
7
|
import { installDb } from "./fixture";
|
|
8
8
|
|
|
9
9
|
let dbo: ReturnType<typeof installDb>;
|
|
@@ -66,3 +66,87 @@ describe("AC1-22:LIKE 模式与 keyword 长度红线", () => {
|
|
|
66
66
|
expect(likePattern("张三")).toBe("%张三%");
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
describe("AC1-24(构造器工厂):pagedFactory 复用同一组条件", () => {
|
|
71
|
+
// L2 记录型假构造器:pagedFactory 是纯编排(count 聚合 + 条件分页 + 截断判定),
|
|
72
|
+
// SQL 渲染/执行在 oj 二进制内,vitest 无真 builder 载体(架构定论:builder 纯 Rust
|
|
73
|
+
// 实现,无 JS 模块可导;SQL 断言走 oj test / oj exec,见 devkit oj-test-guide.md)。
|
|
74
|
+
// 这里只断言编排契约:mk 各跑一次、count 走聚合 select、total=0 不跑 list。
|
|
75
|
+
function fakeBuilder(total: number, rows: any[]) {
|
|
76
|
+
const calls: string[] = [];
|
|
77
|
+
let countPath = false;
|
|
78
|
+
const b: any = {
|
|
79
|
+
select: (cols: any[]) => {
|
|
80
|
+
countPath = cols.some((c: any) => c && typeof c === "object" && c.fn === "count");
|
|
81
|
+
calls.push(`select:${JSON.stringify(cols)}`);
|
|
82
|
+
return b;
|
|
83
|
+
},
|
|
84
|
+
where: (cond: any) => {
|
|
85
|
+
calls.push(`where:${JSON.stringify(cond)}`);
|
|
86
|
+
return b;
|
|
87
|
+
},
|
|
88
|
+
orderBy: (items: any[]) => {
|
|
89
|
+
calls.push(`orderBy:${JSON.stringify(items)}`);
|
|
90
|
+
return b;
|
|
91
|
+
},
|
|
92
|
+
limit: (n: number) => {
|
|
93
|
+
calls.push(`limit:${n}`);
|
|
94
|
+
return b;
|
|
95
|
+
},
|
|
96
|
+
offset: (n: number) => {
|
|
97
|
+
calls.push(`offset:${n}`);
|
|
98
|
+
return b;
|
|
99
|
+
},
|
|
100
|
+
all: async () => (countPath ? [{ c: total }] : rows),
|
|
101
|
+
};
|
|
102
|
+
return { b, calls };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("count + list 各跑一次:count 走聚合 select(不受 LIMIT 钳制),list 带 orderBy/limit/offset", async () => {
|
|
106
|
+
const built: Array<{ calls: string[] }> = [];
|
|
107
|
+
const mk = () => {
|
|
108
|
+
const f = fakeBuilder(301, Array.from({ length: 200 }, (_, i) => ({ id: 300 - i })));
|
|
109
|
+
built.push(f);
|
|
110
|
+
return f.b;
|
|
111
|
+
};
|
|
112
|
+
const r = await pagedFactory<any>(mk, { page: 1, size: 500 });
|
|
113
|
+
expect(built).toHaveLength(2);
|
|
114
|
+
expect(built[0].calls.some(c => c.startsWith("select:") && c.includes("\"count\""))).toBe(true);
|
|
115
|
+
expect(built[1].calls.includes("limit:200")).toBe(true); // 500 被钳 200
|
|
116
|
+
expect(built[1].calls.includes("offset:0")).toBe(true);
|
|
117
|
+
expect(r.total).toBe(301);
|
|
118
|
+
expect(r.size).toBe(200);
|
|
119
|
+
expect(r.list.length).toBe(200);
|
|
120
|
+
expect(r.truncated).toBe(true); // 301 > 1*200
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("条件经 mk 工厂收敛(contains 替代 LIKE ? ESCAPE '!'):两次构造都带上 where", async () => {
|
|
124
|
+
const built: Array<{ calls: string[] }> = [];
|
|
125
|
+
const mk = () => {
|
|
126
|
+
const f = fakeBuilder(1, [{ id: 7, username: "contains_me" }]);
|
|
127
|
+
f.b.where({ field: "username", op: "contains", value: "contains" });
|
|
128
|
+
built.push(f);
|
|
129
|
+
return f.b;
|
|
130
|
+
};
|
|
131
|
+
const r = await pagedFactory<any>(mk, { size: 10 });
|
|
132
|
+
expect(built).toHaveLength(2);
|
|
133
|
+
for (const f of built)
|
|
134
|
+
expect(f.calls.some(c => c.startsWith("where:") && c.includes("contains"))).toBe(true);
|
|
135
|
+
expect(r.total).toBe(1);
|
|
136
|
+
expect(r.truncated).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("total=0 → 不跑 list 查询(省一次往返),缺省 page=1/size=20", async () => {
|
|
140
|
+
const built: Array<{ calls: string[] }> = [];
|
|
141
|
+
const mk = () => {
|
|
142
|
+
const f = fakeBuilder(0, []);
|
|
143
|
+
built.push(f);
|
|
144
|
+
return f.b;
|
|
145
|
+
};
|
|
146
|
+
const r = await pagedFactory<any>(mk, {});
|
|
147
|
+
expect(built).toHaveLength(1); // 只跑了 count
|
|
148
|
+
expect(r.list).toEqual([]);
|
|
149
|
+
expect(r.truncated).toBe(false);
|
|
150
|
+
expect(r.size).toBe(20);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -9,11 +9,6 @@ import { installDb } from "./fixture";
|
|
|
9
9
|
let dbo: ReturnType<typeof installDb>;
|
|
10
10
|
const g = globalThis as any;
|
|
11
11
|
|
|
12
|
-
function mkOplog(username: string, action: string, module: string, level = 6) {
|
|
13
|
-
dbo.prepare("INSERT INTO sys_oplog (create_time, user_id, username, action, module, level) VALUES (?, 1, ?, ?, ?, ?)")
|
|
14
|
-
.run(1000, username, action, module, level);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
beforeAll(() => {
|
|
18
13
|
dbo = installDb();
|
|
19
14
|
});
|
|
@@ -25,22 +20,72 @@ afterEach(() => {
|
|
|
25
20
|
});
|
|
26
21
|
|
|
27
22
|
describe("AC1-29/D13:操作日志查询(admin 可查)", () => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
// L2:searchOplog 的本职是「过滤维度 → whereIf 条件」的映射 + 分页复用(pagedFactory);
|
|
24
|
+
// 构造器执行(SQL 渲染 / 真过滤)在 oj 二进制内——vitest 无真 builder 载体(架构定论:
|
|
25
|
+
// builder 纯 Rust 实现、无 JS 模块可导;SQL 断言走 oj test / oj exec,见 devkit
|
|
26
|
+
// oj-test-guide.md)。这里用记录型假构造器断言条件发射;whereIf 的「假值/空数组跳过」
|
|
27
|
+
// 按 api-manual §1153 契约建模(引擎语义的忠实复刻,非新逻辑)。
|
|
28
|
+
function fakeTable() {
|
|
29
|
+
const conds: Array<{ test: unknown, cond: any }> = [];
|
|
30
|
+
const b: any = {
|
|
31
|
+
whereIf: (test: unknown, cond: any) => {
|
|
32
|
+
if (test !== undefined && test !== null && test !== false && !(Array.isArray(test) && test.length === 0))
|
|
33
|
+
conds.push({ test, cond });
|
|
34
|
+
return b;
|
|
35
|
+
},
|
|
36
|
+
select: () => b,
|
|
37
|
+
orderBy: () => b,
|
|
38
|
+
limit: () => b,
|
|
39
|
+
offset: () => b,
|
|
40
|
+
// 固定非零 total:保证 count/list 两条路径都构造(total=0 跳过 list 的行为
|
|
41
|
+
// 由 query-utils.spec 的 pagedFactory 用例覆盖,这里只关心条件发射)
|
|
42
|
+
all: async () => [{ c: 42 }],
|
|
43
|
+
};
|
|
44
|
+
return { b, conds };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 以假构造器跑 searchOplog,回传 count 构造发射的条件(并校验 count/list 同条件) */
|
|
48
|
+
async function captureConds(filter: Parameters<typeof searchOplog>[0]) {
|
|
49
|
+
const built: Array<ReturnType<typeof fakeTable>> = [];
|
|
50
|
+
const prevDb = g.db;
|
|
51
|
+
g.db = {
|
|
52
|
+
table: () => {
|
|
53
|
+
const t = fakeTable();
|
|
54
|
+
built.push(t);
|
|
55
|
+
return t.b;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
await searchOplog(filter);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
g.db = prevDb;
|
|
63
|
+
}
|
|
64
|
+
expect(built).toHaveLength(2); // count + list 各构造一次
|
|
65
|
+
expect(built[1].conds).toEqual(built[0].conds); // mk 工厂:两组条件一致
|
|
66
|
+
return built[0].conds;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it("无过滤/空串过滤 → 不发射任何条件(归一化空串 → whereIf 跳过,等价旧 if (filter.x))", async () => {
|
|
70
|
+
expect(await captureConds({})).toEqual([]);
|
|
71
|
+
expect(await captureConds({ action: "", username: "", module: "" })).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("action/module 精确 + username 包含:三维度各发射一条,字段与 op 对应", async () => {
|
|
75
|
+
const conds = await captureConds({ action: "user.create", module: "framework", username: "bo" });
|
|
76
|
+
expect(conds).toEqual([
|
|
77
|
+
{ test: "user.create", cond: { field: "action", op: "eq", value: "user.create" } },
|
|
78
|
+
{ test: "framework", cond: { field: "module", op: "eq", value: "framework" } },
|
|
79
|
+
{ test: "bo", cond: { field: "username", op: "contains", value: "bo" } },
|
|
80
|
+
]);
|
|
38
81
|
});
|
|
39
82
|
|
|
40
|
-
it("403 拒绝类(auth.deny
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
it("403 拒绝类(auth.deny)可在日志中检索:按 action 精确命中,且从无 level 条件(读不过滤等级)", async () => {
|
|
84
|
+
const conds = await captureConds({ action: "auth.deny" });
|
|
85
|
+
expect(conds).toEqual([
|
|
86
|
+
{ test: "auth.deny", cond: { field: "action", op: "eq", value: "auth.deny" } },
|
|
87
|
+
]);
|
|
88
|
+
expect(conds.some(c => (c.cond as { field?: string }).field === "level")).toBe(false);
|
|
44
89
|
});
|
|
45
90
|
});
|
|
46
91
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# cdn/ —— 本地模拟市场源
|
|
2
|
+
|
|
3
|
+
本目录是**本地模拟 CDN**:`ojm market publish` 把分发包落在这里,站点通过 `api/config.yaml` 里的挂载
|
|
4
|
+
(`- prefix: /cdn` → `web: ../cdn`)把它直出成分发源,市场页按系统设置 `framework.market_cdn_url`
|
|
5
|
+
指向的地址读取。
|
|
6
|
+
|
|
7
|
+
> **目录缺失会让站点起不来**:`oj serve` 启动即校验挂载目录,缺失就报错退出。
|
|
8
|
+
> 所以本 README 进模板、进发布包、进新工程——它的存在就是目录存在的理由,**不要删**。
|
|
9
|
+
|
|
10
|
+
## 这是本地模拟源,不是生产 CDN
|
|
11
|
+
|
|
12
|
+
- 服务于本地开发与演示:想看市场卡片、想走一遍安装链路,就用它。
|
|
13
|
+
- 目录与挂载都随模板发布,`ojm init` 出的新工程自带可用。
|
|
14
|
+
- 目录里没有包也没关系:空目录只意味着市场页暂时没有卡片。
|
|
15
|
+
|
|
16
|
+
## 进仓边界
|
|
17
|
+
|
|
18
|
+
| 类型 | 是否进仓 |
|
|
19
|
+
|---|---|
|
|
20
|
+
| 本 README | ✅ 唯一进仓的文件 |
|
|
21
|
+
| `<name>-<version>.tgz` 及其 `.tgz.sig` | ❌ 产物 |
|
|
22
|
+
| `bundles.yaml` / `manifests.yaml` 及其 `.sig` | ❌ 产物 |
|
|
23
|
+
|
|
24
|
+
## 生产怎么切出去
|
|
25
|
+
|
|
26
|
+
1. 删掉 `api/config.yaml` 的 `mounts` 里这条(它就是本地挂载的全部):
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- prefix: /cdn
|
|
30
|
+
web: ../cdn
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. 把真 CDN(对象存储 / nginx)的地址写进系统设置 `framework.market_cdn_url`;
|
|
34
|
+
命令行等价写法是 `ojm market setup --url <地址>`。
|
|
35
|
+
3. 索引(`bundles.yaml` / `manifests.yaml`)由 CDN 侧维护:`ojm market build-index` 在本地产出后自行上传。
|
|
36
|
+
|
|
37
|
+
## 签名与校验口径
|
|
38
|
+
|
|
39
|
+
- **包级(信任锚)**:每个 `<name>-<version>.tgz` 配一份 detached `.tgz.sig`,Ed25519,由发布方私钥签署。
|
|
40
|
+
安装时逐字节校验,验不过即拒绝安装(fail-closed)。
|
|
41
|
+
- **索引级(只做完整性)**:`bundles.yaml` / `manifests.yaml` 各自一份 `.sig`,用于发现目录或索引被篡改;
|
|
42
|
+
**它不是信任锚**——陌生发布方的取舍由信任库决定。
|
|
43
|
+
- **时效性**:服务端响应不带缓存头(只有 ETag),因此拉取索引**必须**由请求侧带 `cache-control: no-cache`。
|
|
44
|
+
|
|
45
|
+
## 陌生发布方(TOFU)
|
|
46
|
+
|
|
47
|
+
首次遇到未登记的发布方 key,安装会停在待信任状态,由管理员确认后才写入信任库;
|
|
48
|
+
信任库遵循**首次绑定不可改**(再次绑定同一个 key_id 会被拒绝)。
|
package/templates/global.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ interface WhereCond {
|
|
|
25
25
|
not?: WhereCond
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// db.table(...).join(table, on, ...) 的单个 ON 项(v0.1.81):列=列({left,right})
|
|
29
|
+
// 或列=值({left,op,value}),可混合出现在同一 on 数组;op 限比较类。
|
|
30
|
+
interface JoinOn {
|
|
31
|
+
left: string
|
|
32
|
+
right?: string
|
|
33
|
+
op?: string
|
|
34
|
+
value?: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
// db.table(...).orderBy(items) 的单个排序项。
|
|
29
38
|
interface OrderByItem {
|
|
30
39
|
field: string
|
|
@@ -51,19 +60,37 @@ interface QueryBuilder {
|
|
|
51
60
|
insert: (rows: object | object[]) => QueryBuilder
|
|
52
61
|
update: (sets: object) => QueryBuilder
|
|
53
62
|
delete: () => QueryBuilder
|
|
63
|
+
// 整表清空(v0.1.82):唯一的无 where 写动词,明示使用——渲染 `DELETE FROM t`。
|
|
64
|
+
// 租户守卫照常注入:租户表上只清**当前租户**的行(清整库用 db.asSystem)。
|
|
65
|
+
// 自带 where 报 `truncate does not accept where`;update 携带 truncate 报错。
|
|
66
|
+
truncate: () => QueryBuilder
|
|
54
67
|
returning: (cols?: string[]) => QueryBuilder
|
|
55
|
-
// JOIN
|
|
68
|
+
// JOIN(v0.1.81 支持 ON 值条件与表别名):on = 左右列对 / 列对值条件;kind 缺省 "inner"。
|
|
69
|
+
// 第 4 参数 alias 给被 join 的表起别名;基表别名用 .as(alias)(v0.1.81)。
|
|
56
70
|
join: (
|
|
57
71
|
table: string,
|
|
58
|
-
on:
|
|
72
|
+
on: JoinOn[],
|
|
59
73
|
kind?: string,
|
|
74
|
+
alias?: string,
|
|
60
75
|
) => QueryBuilder
|
|
76
|
+
// 基表别名(v0.1.81):仅作用于列限定与 SQL 渲染;归属/租户守卫按真名生效。
|
|
77
|
+
// 仅 select/join 支持;insert/update/delete 带别名直接报错。
|
|
78
|
+
as: (alias: string) => QueryBuilder
|
|
79
|
+
// 条件化 where(v0.1.81):test 为 undefined/null/false/空数组时整条跳过;0 与 "" 保留。
|
|
80
|
+
whereIf: (test: unknown, cond: WhereCond) => QueryBuilder
|
|
61
81
|
// DISTINCT / GROUP BY / HAVING / UNION / CTE(WITH)。
|
|
62
82
|
distinct: () => QueryBuilder
|
|
63
83
|
groupBy: (cols?: string[]) => QueryBuilder
|
|
64
84
|
having: (cond: WhereCond) => QueryBuilder
|
|
65
85
|
union: (other: QueryBuilder, kind?: string) => QueryBuilder
|
|
66
86
|
with: (name: string, columns: string[], query: QueryBuilder) => QueryBuilder
|
|
87
|
+
// 幂等写入(v0.1.81):撞主键/任意唯一键则跳过(sqlite/pg → ON CONFLICT DO NOTHING;
|
|
88
|
+
// mysql → ON DUPLICATE KEY UPDATE <pk>=<pk>,要求表有主键)。三方言 affected-rows 不一致,
|
|
89
|
+
// 勿用 if (n===1) 判幂等;与 returning 在 mysql 上互斥。
|
|
90
|
+
orIgnore: () => QueryBuilder
|
|
91
|
+
// INSERT ... SELECT(v0.1.81):从另一构造器结果(或其 toJSON 快照)批量写入,
|
|
92
|
+
// cols 与源 select 列按位置对齐。
|
|
93
|
+
fromSelect: (sub: QueryBuilder | object, cols: string[]) => QueryBuilder
|
|
67
94
|
run: () => Promise<number | Row[]>
|
|
68
95
|
// 只构造不执行(与 .all()/.run() 完全相同的校验管线)。
|
|
69
96
|
toSQL: () => { sql: string, params: unknown[] }
|
|
@@ -584,6 +611,13 @@ declare global {
|
|
|
584
611
|
* 连接引擎名(v0.1.61):`"sqlite" | "mysql" | "postgres"`。供 `_shared` 共享模块与
|
|
585
612
|
* 诊断分派方言 SQL;业务 handler 分支方言 = 放弃跨引擎可移植承诺,慎用。
|
|
586
613
|
*/
|
|
614
|
+
/**
|
|
615
|
+
* 目录自省(v0.1.82):当前库全部业务可见表名(**含未登记 registry 的表**——api-only
|
|
616
|
+
* 方案 / 历史遗留表),按名称排序。可选前缀过滤(%/_ 按字面量匹配,引擎转义 + 绑定参数)。
|
|
617
|
+
* sqlite 排除 `sqlite_*` 内部表;mysql/pg 经 information_schema 限当前库/schema。
|
|
618
|
+
* 替代「按方言分支 information_schema / sqlite_master」的裸 db.query。
|
|
619
|
+
*/
|
|
620
|
+
listTables: (prefix?: string) => Promise<string[]>
|
|
587
621
|
dialect(): "sqlite" | "mysql" | "postgres"
|
|
588
622
|
asTenant(id: string): DBInstance
|
|
589
623
|
// 事务:回调 resolve 提交 / throw 回滚再抛;tx.query/exec/table 同签名走同一连接。
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
include:
|
|
27
27
|
# —— 工程级文件 ——
|
|
28
28
|
- README.md
|
|
29
|
+
# 本地模拟 CDN 目录:只带 README(它的存在让挂载目录不缺,站点才起得来)。
|
|
30
|
+
# 产物 *.tgz / *.sig / bundles.yaml / manifests.yaml 一律不拷、不进发布包。
|
|
31
|
+
- cdn/README.md
|
|
29
32
|
- tsconfig.json
|
|
30
33
|
- global.d.ts
|
|
31
34
|
- env.d.ts
|
|
@@ -42,7 +45,6 @@ include:
|
|
|
42
45
|
- api/src/_platform
|
|
43
46
|
- api/src/auth
|
|
44
47
|
- api/src/notification
|
|
45
|
-
- api/src/notifications
|
|
46
48
|
- api/src/personal-center
|
|
47
49
|
- api/src/system
|
|
48
50
|
- api/src/web
|
|
@@ -57,7 +59,6 @@ include:
|
|
|
57
59
|
- web/src/home/pages
|
|
58
60
|
- web/src/login
|
|
59
61
|
- web/src/notification
|
|
60
|
-
- web/src/notifications
|
|
61
62
|
# 解决方案市场:默认第一方能力。逐子路径收录——__tests__ 不进脚手架
|
|
62
63
|
# (用户工程无 web 侧 vitest 基建,拷了是死文件)
|
|
63
64
|
- web/src/oj-market/entry.ts
|
package/templates/openapi.json
CHANGED
|
@@ -1390,17 +1390,65 @@
|
|
|
1390
1390
|
}
|
|
1391
1391
|
}
|
|
1392
1392
|
},
|
|
1393
|
-
"/api/
|
|
1394
|
-
"
|
|
1395
|
-
"operationId": "
|
|
1396
|
-
"summary": "
|
|
1397
|
-
"x-oj-file": "
|
|
1393
|
+
"/api/personal-center/profile": {
|
|
1394
|
+
"post": {
|
|
1395
|
+
"operationId": "personal_center_profile_post",
|
|
1396
|
+
"summary": "POST api/personal-center/profile",
|
|
1397
|
+
"x-oj-file": "personal-center/profile/api.ts",
|
|
1398
1398
|
"tags": [
|
|
1399
|
-
"
|
|
1399
|
+
"personal-center"
|
|
1400
1400
|
],
|
|
1401
|
+
"requestBody": {
|
|
1402
|
+
"required": true,
|
|
1403
|
+
"content": {
|
|
1404
|
+
"application/json": {
|
|
1405
|
+
"schema": {
|
|
1406
|
+
"type": "object",
|
|
1407
|
+
"properties": {
|
|
1408
|
+
"username": {
|
|
1409
|
+
"type": "string"
|
|
1410
|
+
},
|
|
1411
|
+
"email": {
|
|
1412
|
+
"type": "string"
|
|
1413
|
+
},
|
|
1414
|
+
"phoneNumber": {
|
|
1415
|
+
"type": "string"
|
|
1416
|
+
},
|
|
1417
|
+
"description": {
|
|
1418
|
+
"type": "string"
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1401
1425
|
"responses": {
|
|
1402
1426
|
"200": {
|
|
1403
|
-
"description": "OK"
|
|
1427
|
+
"description": "OK",
|
|
1428
|
+
"x-oj-response-enforced": false,
|
|
1429
|
+
"content": {
|
|
1430
|
+
"application/json": {
|
|
1431
|
+
"schema": {
|
|
1432
|
+
"type": "object",
|
|
1433
|
+
"required": [
|
|
1434
|
+
"code",
|
|
1435
|
+
"msg",
|
|
1436
|
+
"data"
|
|
1437
|
+
],
|
|
1438
|
+
"properties": {
|
|
1439
|
+
"code": {
|
|
1440
|
+
"type": "integer"
|
|
1441
|
+
},
|
|
1442
|
+
"msg": {
|
|
1443
|
+
"type": "string"
|
|
1444
|
+
},
|
|
1445
|
+
"data": {
|
|
1446
|
+
"type": "null"
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1404
1452
|
}
|
|
1405
1453
|
}
|
|
1406
1454
|
}
|
|
@@ -2523,9 +2571,6 @@
|
|
|
2523
2571
|
{
|
|
2524
2572
|
"name": "notification"
|
|
2525
2573
|
},
|
|
2526
|
-
{
|
|
2527
|
-
"name": "notifications"
|
|
2528
|
-
},
|
|
2529
2574
|
{
|
|
2530
2575
|
"name": "personal-center"
|
|
2531
2576
|
},
|
|
@@ -1,23 +1,60 @@
|
|
|
1
|
-
import type { NotificationsApiProvider } from "@oj-module/runtime";
|
|
1
|
+
import type { AppRouteRecordRaw, NotificationsApiProvider } from "@oj-module/runtime";
|
|
2
2
|
|
|
3
|
+
import { BellOutlined } from "@ant-design/icons";
|
|
3
4
|
import { defineModule } from "@oj-module/runtime";
|
|
4
5
|
|
|
6
|
+
import { createElement, lazy } from "react";
|
|
7
|
+
|
|
5
8
|
import { createNotificationClient } from "./__client/api";
|
|
6
9
|
|
|
10
|
+
const NotificationCenter = lazy(() => import("./pages/center"));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 通知中心页(N3):全量消息 + 类型 Tab,铃铛弹层「查看全部」跳转至此。
|
|
14
|
+
* hideInMenu——入口在铃铛,不占侧边导航。
|
|
15
|
+
* 布局挂父路由 handle(resolveRouteLayouts 只给「无 Component 有 children」的
|
|
16
|
+
* 父路由注入 layout,页面组件写父级上布局会被忽略、裸渲染无 chrome)。
|
|
17
|
+
*/
|
|
18
|
+
const routes: AppRouteRecordRaw[] = [
|
|
19
|
+
{
|
|
20
|
+
path: "/notification-center",
|
|
21
|
+
handle: {
|
|
22
|
+
layout: "container",
|
|
23
|
+
title: "notification:menu.notificationCenter",
|
|
24
|
+
icon: createElement(BellOutlined),
|
|
25
|
+
hideInMenu: true,
|
|
26
|
+
},
|
|
27
|
+
children: [
|
|
28
|
+
{
|
|
29
|
+
index: true,
|
|
30
|
+
Component: NotificationCenter,
|
|
31
|
+
handle: {
|
|
32
|
+
title: "notification:menu.notificationCenter",
|
|
33
|
+
icon: createElement(BellOutlined),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
7
40
|
/**
|
|
8
|
-
* notification 模块(G5
|
|
41
|
+
* notification 模块(G5 模板形态):通知聚合的唯一前端消费面。
|
|
9
42
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
43
|
+
* onInit 全量接管通知 API(D9 + G4):把通知读/写收敛到本模块 `/notification`
|
|
44
|
+
* 前缀下的 oj 后端,通知铃的互动(单条已读/全部已读/清空)由此生效;发射端
|
|
45
|
+
* 三方法(push/pushBroadcast/del)使本模块同时作为跨模块通知原语的接收端
|
|
46
|
+
* (前端 facade 委托至此)。
|
|
14
47
|
*/
|
|
15
48
|
export default defineModule({
|
|
16
49
|
name: "notification",
|
|
17
|
-
description: "通知模块(D9/G4
|
|
18
|
-
version: "0.0
|
|
50
|
+
description: "通知模块(D9/G4 注入通知读写 + 通知中心页)",
|
|
51
|
+
version: "0.1.0",
|
|
19
52
|
peerRuntime: ">=0.0.0",
|
|
20
|
-
routes
|
|
53
|
+
routes,
|
|
54
|
+
i18n: {
|
|
55
|
+
"zh-CN": () => import("./locales/zh-CN.json"),
|
|
56
|
+
"en-US": () => import("./locales/en-US.json"),
|
|
57
|
+
},
|
|
21
58
|
lifecycle: {
|
|
22
59
|
async onInit(ctx) {
|
|
23
60
|
// 构造即接线:createNotificationClient 登记契约前缀 /notification
|