@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
package/src/versions.ts
CHANGED
|
@@ -100,9 +100,37 @@ export function validateSharedVersions(
|
|
|
100
100
|
return errors;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 源码形态守卫(R5 prepack 断言的本地版):cli 源码树内 shell-dist 与
|
|
105
|
+
* packages/runtime 同源于一次宿主构建,升 runtime 后忘跑 build:shell 会让
|
|
106
|
+
* versions.json 停在旧版本,门禁误报「请对齐版本后重新安装」。此处先对比
|
|
107
|
+
* 两者版本,过期直接报真因。发布形态(pack 后)无同级 runtime 目录,跳过。
|
|
108
|
+
*/
|
|
109
|
+
export function hostArtifactFreshnessError(
|
|
110
|
+
hostRuntime: string | undefined,
|
|
111
|
+
workspaceRuntime: string | undefined,
|
|
112
|
+
): string | undefined {
|
|
113
|
+
if (!hostRuntime || !workspaceRuntime || hostRuntime === workspaceRuntime)
|
|
114
|
+
return undefined;
|
|
115
|
+
return `内置宿主产物(shell-dist/versions.json)过期:其中 runtime 版本(${hostRuntime})≠ 本仓 packages/runtime 版本(${workspaceRuntime})。
|
|
116
|
+
这是升 runtime 后未重建宿主导致的漂移,请执行:pnpm --filter @oj-module/cli build:shell`;
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
/** 校验模块工程的共享依赖版本矩阵,违规直接抛错并打印期望值 */
|
|
104
120
|
export function checkSharedVersions(projectRoot: string, shellDist: string): void {
|
|
105
121
|
const versions = readHostVersions(shellDist);
|
|
122
|
+
|
|
123
|
+
let workspaceRuntime: string | undefined;
|
|
124
|
+
try {
|
|
125
|
+
workspaceRuntime = JSON.parse(fs.readFileSync(path.join(CLI_ROOT, "..", "runtime", "package.json"), "utf-8")).version;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* 发布形态:cli 包旁无 runtime 源码目录,跳过守卫 */
|
|
129
|
+
}
|
|
130
|
+
const stale = hostArtifactFreshnessError(versions["@oj-module/runtime"], workspaceRuntime);
|
|
131
|
+
if (stale)
|
|
132
|
+
throw new Error(stale);
|
|
133
|
+
|
|
106
134
|
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8"));
|
|
107
135
|
const errors = validateSharedVersions(pkg, versions, spec => readInstalledVersion(projectRoot, spec));
|
|
108
136
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "ojm api --check 豁免清单(D12/F19):把契约与 oj 路由对账中「确认无害」的差异降级为 skip。modules=整模块跳过双向对账(值是 oj 后端模块名,即 api/src/<模块> 的目录名);paths=按路径跳过,'/xxx/*' 为一层通配(只盖 /xxx/<单段>),其余为精确前缀匹配。仅降级 error→skip,绝不引入新错误;文件缺失或 JSON 损坏时静默按空豁免处理。注意:本文件是严格 JSON,写 // 注释会导致解析失败、豁免静默全失效——说明请写在本 _comment 字段。",
|
|
3
|
-
"modules": [
|
|
4
|
-
|
|
3
|
+
"modules": [
|
|
4
|
+
"web"
|
|
5
|
+
],
|
|
6
|
+
"paths": [
|
|
7
|
+
"/auth/*"
|
|
8
|
+
]
|
|
5
9
|
}
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
mounts:
|
|
30
30
|
- prefix: /api # api 挂载:/api/<module>/...(目录相对 config 所在目录 → api/src)
|
|
31
31
|
api: src
|
|
32
|
+
# 本地模拟 CDN:直出工程根 cdn/(内容见 cdn/README.md)。
|
|
33
|
+
# 红线:生产请删掉这一条挂载,并把 system 设置 framework.market_cdn_url 指向真 CDN。
|
|
34
|
+
# 不得给此挂载设 spa——缺文件必须返回 404,回落 index.html 会让市场解析崩。
|
|
35
|
+
- prefix: /cdn
|
|
36
|
+
web: ../cdn
|
|
32
37
|
# 生产若由 oj 直出前端(前后端同进程,即 ojm preview 的生产形态),
|
|
33
38
|
# 取消下一行注释并先 build 出 web/dist(preview 会自动注入等价挂载,无需手改):
|
|
34
39
|
# - prefix: "/"
|
|
@@ -76,6 +81,12 @@ vars:
|
|
|
76
81
|
# ---- 平台 exec 白名单(ojm install 的解压/迁移两处消费;模板逐字对齐 lifecycle steps 命令构造,
|
|
77
82
|
# 不得增删——多一个 token 都会 NotCapable)----
|
|
78
83
|
platform:
|
|
84
|
+
# 平台特权模块名单:方案安装器的 reloading 段调 server.reloadConfig 触发热更。
|
|
85
|
+
# 缺这条 → 装在最后一步报 NotCapable: module '_platform' is not platform-privileged。
|
|
86
|
+
modules: [_platform]
|
|
87
|
+
# 受控挂载根 = api 挂载目录(dev 形态 src)。ojm preview 派生 config 时会改写为 dist,
|
|
88
|
+
# 保持与 `--api-path` 同源;手写部署请按实际模块根填写。
|
|
89
|
+
root: src
|
|
79
90
|
# ---- U6 信任库加固(P3-1;P2-2 红线 spike 结论)----
|
|
80
91
|
# 威胁:devkit 0.1.75 语义下 fs: 段对全部模块生效(jail = fs.root),且 trusted_paths 只增不减——
|
|
81
92
|
# 信任库落在第三方方案模块可写的 fs.root 内即可被其伪造信任锚。加固须两端配合:
|
|
@@ -92,6 +103,15 @@ platform:
|
|
|
92
103
|
- 'tar -xzf ${path:archive} -C ${path:dest}'
|
|
93
104
|
- 'oj migrate --format json -d ${path:dir} ${raw:module}'
|
|
94
105
|
|
|
106
|
+
# ---- 方案安装器的落盘面(fs.* 全局)----
|
|
107
|
+
# 安装器要解包到 modules/.staging、写 dist/modules、备份 db.sqlite——全在 config 目录内。
|
|
108
|
+
# 缺本段 = fs.* 抛 NotCapable,且失败发生在任务文件落盘之前:表现为「POST 安装返回 200、
|
|
109
|
+
# 但 task 查询恒 404」,排查成本极高。生产收紧口径见上面 platform 段的 U6 说明
|
|
110
|
+
# (fs.root 收窄到安装子树 + third-party 模块 jail 隔离属部署清单项)。
|
|
111
|
+
fs:
|
|
112
|
+
root: .
|
|
113
|
+
readonly: false
|
|
114
|
+
|
|
95
115
|
# ---- LDAP 连接段(D1/PRD §4.5 两段式之连接段;oj 插件严格解析本段,未知键启动 fail-fast——只写下列键)----
|
|
96
116
|
# 取消注释并填值即接通目录服务;应用映射段在 DB/设置页(ldap.enabled、ldap_base_dn、
|
|
97
117
|
# ldap_uid_attr、ldap_email_attr,group=ldap,即时生效)。本段改动属 restart_pending,需重启。
|
|
@@ -122,10 +122,13 @@ const NATIVE_KEYS = new Set(["name", "desc", "version", "config", "db", "tables"
|
|
|
122
122
|
/**
|
|
123
123
|
* C2-31 安装期复核纯函数(双端 DRY 于夹具,preflight 同纪律;构建侧同形实现在
|
|
124
124
|
* packages/cli/src/bundle.ts checkModuleManifest):包内 oj 原生清单 ↔ C2 Form A 逐项钉住——
|
|
125
|
-
* name/version
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
125
|
+
* name/version 三方一致、七键白名单(白名单外键 = OJ-X 红线,oj 不拒但契约拒)。
|
|
126
|
+
* 只验不写:安装期改写原生清单等于掩盖上游不一致。
|
|
127
|
+
*
|
|
128
|
+
* 变更(2026-10-21):**desc === summary 的等值校验已移除**。该等值要求把「构建期映射」
|
|
129
|
+
* 当成安装期契约,于是 fixture/手写包只要 desc 与 summary 有一字之差就整包装不上——
|
|
130
|
+
* 校验成本远高于它拦下的风险(desc 只是模块描述文本,非安全边界)。现只保留
|
|
131
|
+
* 「summary 必填(C2-04)」与「desc 必填非空(oj 启动装配面)」两项。
|
|
129
132
|
*/
|
|
130
133
|
export function checkModuleManifest(
|
|
131
134
|
c2: unknown,
|
|
@@ -138,15 +141,12 @@ export function checkModuleManifest(
|
|
|
138
141
|
}
|
|
139
142
|
const summary = isObj(c2) && typeof c2.summary === "string" ? c2.summary : undefined;
|
|
140
143
|
if (summary === undefined || summary.trim() === "") {
|
|
141
|
-
return { ok: false, message: "C2 manifest summary
|
|
144
|
+
return { ok: false, message: "C2 manifest summary 缺省或空白(C2-04 必填字段)——C2-31" };
|
|
142
145
|
}
|
|
143
146
|
const desc = native.desc;
|
|
144
147
|
if (typeof desc !== "string" || desc.trim() === "") {
|
|
145
148
|
return { ok: false, message: "包内原生清单 desc 缺失或空白(oj Manifest 必填字段,serve 装配即失败)——C2-31" };
|
|
146
149
|
}
|
|
147
|
-
if (desc !== summary) {
|
|
148
|
-
return { ok: false, message: "包内原生清单 desc 与 C2 summary 不一致(构建期映射被篡改或上游不一致,拒装)——C2-31" };
|
|
149
|
-
}
|
|
150
150
|
if (native.name !== name) {
|
|
151
151
|
return { ok: false, message: `包内原生清单 name「${String(native.name)}」与安装身份「${name}」不一致——C2-31` };
|
|
152
152
|
}
|
|
@@ -18,14 +18,12 @@ import { readLock, writeLock } from "./lock";
|
|
|
18
18
|
|
|
19
19
|
// db 为 oj isolate 注入全局(同模板 service 层惯例,installer.ts 同款;测试由夹具装 globalThis.db)
|
|
20
20
|
declare const db: {
|
|
21
|
-
|
|
22
|
-
query: (sql: string, params?: unknown[]) => Promise<unknown>
|
|
23
|
-
/** 安全查询构造器(迁移目标;与 query 同返回行数组) */
|
|
21
|
+
/** 安全查询构造器(与 db.query 同返回行数组) */
|
|
24
22
|
table: (name: string) => any
|
|
25
|
-
/**
|
|
26
|
-
|
|
23
|
+
/** 目录自省(v0.1.82):当前库全部/前缀匹配的表名(方言无关;前缀 %/_ 由引擎转义) */
|
|
24
|
+
listTables: (prefix?: string) => Promise<string[]>
|
|
27
25
|
/** 单一 DB 事务包裹(P3-3):回调内语句共享一个 BEGIN/COMMIT/ROLLBACK 边界,异常即整段回滚 */
|
|
28
|
-
tx: <T>(fn: (t: {
|
|
26
|
+
tx: <T>(fn: (t: { table: (name: string) => any }) => Promise<T>) => Promise<T>
|
|
29
27
|
};
|
|
30
28
|
|
|
31
29
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
@@ -62,45 +60,13 @@ function manifestTables(yamlText: string): string[] {
|
|
|
62
60
|
return out;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
/**
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
return typeof d === "function" ? d() : "sqlite";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 表目录查询片段(方言无关:sqlite=sqlite_master,mysql/pg=information_schema) */
|
|
72
|
-
export function catalogSpec(dialect: string): { existing: string, like: (pat: string) => [string, unknown[]] } {
|
|
73
|
-
if (dialect === "sqlite") {
|
|
74
|
-
return {
|
|
75
|
-
existing: "SELECT name FROM sqlite_master WHERE type='table'",
|
|
76
|
-
like: pat => ["SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '!'", [pat]],
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
const schemaExpr = dialect === "mysql" ? "DATABASE()" : "'public'";
|
|
80
|
-
return {
|
|
81
|
-
existing: `SELECT table_name AS name FROM information_schema.tables WHERE table_schema = ${schemaExpr}`,
|
|
82
|
-
like: pat => [`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = ${schemaExpr} AND table_name LIKE ? ESCAPE '!'`, [pat]],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** 当前库实际存在的表名集合 */
|
|
87
|
-
async function listExistingTables(dialect: string): Promise<Set<string>> {
|
|
88
|
-
// 保留 db.query:方言相关表目录查询(sqlite_master / information_schema),构造器无法表达
|
|
89
|
-
const rows = (await db.query(catalogSpec(dialect).existing)) as Array<{ name: string }>;
|
|
90
|
-
return new Set(rows.map(r => r.name));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** 模块 slug 规范化 + LIKE 转义(沿用 RBAC 权限命名空间约定:!%_ 转义,连字符→下划线作表前缀) */
|
|
94
|
-
export function tablePrefixPattern(name: string): string {
|
|
95
|
-
const slug = name.replace(/-/g, "_").replace(/[!%_]/g, c => `!${c}`);
|
|
96
|
-
// 表名用下划线分隔(非权限码的 `.`):slug_ + 通配符 %(末尾 % 不转义,保持通配语义)
|
|
97
|
-
return `${slug}_%`;
|
|
63
|
+
/** 模块 slug 规范化(连字符→下划线)作表前缀;db.listTables 内部按字面量前缀匹配(自动转义 %/_),无需手拼通配 */
|
|
64
|
+
export function moduleTablePrefix(name: string): string {
|
|
65
|
+
return `${name.replace(/-/g, "_")}_`;
|
|
98
66
|
}
|
|
99
67
|
|
|
100
68
|
/** 收集需保留的业务表(Q4):声明 + 前缀兜底;仅返回实际存在的表名 */
|
|
101
69
|
export async function collectRetainedTables(name: string, manifestPath: string): Promise<string[]> {
|
|
102
|
-
const dialect = currentDialect();
|
|
103
|
-
const existing = await listExistingTables(dialect);
|
|
104
70
|
const candidates = new Set<string>();
|
|
105
71
|
// ① 声明式权威源:manifest.tables(通知等模块已在 manifest 声明业务表归属)
|
|
106
72
|
try {
|
|
@@ -111,17 +77,17 @@ export async function collectRetainedTables(name: string, manifestPath: string):
|
|
|
111
77
|
catch {
|
|
112
78
|
// manifest 缺失(api-only 方案)→ 仅前缀兜底
|
|
113
79
|
}
|
|
114
|
-
// ②
|
|
80
|
+
// ② 前缀兜底:db.listTables(v0.1.82,方言无关,前缀转义引擎做)枚举当前库匹配模块前缀的
|
|
81
|
+
// 实际表,覆盖未声明场景。listTables 已只返回存在的表,故前缀命中的候选天然存在。
|
|
115
82
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const rows = (await db.query(sql, params)) as Array<{ name: string }>;
|
|
119
|
-
for (const r of rows)
|
|
120
|
-
candidates.add(r.name);
|
|
83
|
+
for (const t of await db.listTables(moduleTablePrefix(name)))
|
|
84
|
+
candidates.add(t);
|
|
121
85
|
}
|
|
122
86
|
catch {
|
|
123
87
|
// 兜底扫描失败不阻断卸载
|
|
124
88
|
}
|
|
89
|
+
// 仅回传实际存在的表(manifest 声明的表若物理不存在则不回传)
|
|
90
|
+
const existing = new Set(await db.listTables());
|
|
125
91
|
return [...candidates].filter(t => existing.has(t));
|
|
126
92
|
}
|
|
127
93
|
|
|
@@ -163,12 +129,14 @@ export async function computeUninstallImpact(input: UninstallInput): Promise<Uni
|
|
|
163
129
|
const roleNames = roles.map(r => r.name);
|
|
164
130
|
let affectedUsers = 0;
|
|
165
131
|
if (roleNames.length > 0) {
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
132
|
+
// 构造器(v0.1.82):count(distinct) 聚合一行返回,不受 LIMIT 钳制——
|
|
133
|
+
// 旧 distinct().all().length 因顶层隐式 LIMIT(默认 100)/ 显式 clamp(max_limit 默认 1000)
|
|
134
|
+
// 在受影响用户 >1000 时静默漏算(今天就有正确性风险,须尽快迁移)。
|
|
135
|
+
const rows = await db.table("sys_user_role")
|
|
136
|
+
.select([{ fn: "count", distinct: true, field: "user_id", as: "c" }])
|
|
137
|
+
.where({ field: "role_name", op: "in", value: roleNames })
|
|
138
|
+
.all() as Array<{ c: number }>;
|
|
139
|
+
affectedUsers = rows[0]?.c ?? 0;
|
|
172
140
|
}
|
|
173
141
|
return { name, affectedRoles: roleNames.length, affectedUsers };
|
|
174
142
|
}
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// 目录红线:只出 id/username/display_name——不含 email/avatar/roles(防低权账号拖走全员邮箱喂钓鱼,安全评审 P2);
|
|
3
3
|
// 详情端点才给 email/avatar。默认排除 is_forbidden=1(AC1-23:管理域仍可见,目录与管理分离)。
|
|
4
4
|
// keyword≥2、LIKE 转义复用 query.ts;solutions 同 isolate import searchContacts 直调,无 HTTP 往返(AC1-25)。
|
|
5
|
-
import { likePattern } from "../infra/query";
|
|
6
|
-
|
|
7
5
|
export interface ContactRow {
|
|
8
6
|
id: number
|
|
9
7
|
username: string
|
|
@@ -14,16 +12,23 @@ const DEFAULT_LIMIT = 200;
|
|
|
14
12
|
const MAX_LIMIT = 200;
|
|
15
13
|
|
|
16
14
|
export async function searchContacts(keyword: unknown, opts: { limit?: unknown } = {}): Promise<ContactRow[]> {
|
|
17
|
-
|
|
15
|
+
// 长度红线(默认 2~64,越界 400)——与 query.ts likePattern 同口径;contains 自动转义 %/_/!,无需手写 ESCAPE。
|
|
16
|
+
const kw = String(keyword ?? "").trim();
|
|
17
|
+
if (kw.length < 2 || kw.length > 64)
|
|
18
|
+
throw Object.assign(new Error(kw.length < 2 ? "keyword too short (min 2)" : "keyword too long (max 64)"), { status: 400 });
|
|
18
19
|
const rawLimit = Number.parseInt(String(opts.limit ?? ""), 10);
|
|
19
20
|
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(MAX_LIMIT, rawLimit) : DEFAULT_LIMIT;
|
|
20
|
-
//
|
|
21
|
-
return await db.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// 构造器(v0.1.81):模糊搜索走语义 op contains(自动转义 + 包 %),OR 用条件树 or,方言无关。
|
|
22
|
+
return await db.table("users")
|
|
23
|
+
.select(["id", "username", "display_name"])
|
|
24
|
+
.where({ field: "is_forbidden", op: "eq", value: 0 })
|
|
25
|
+
.where({ or: [
|
|
26
|
+
{ field: "username", op: "contains", value: kw },
|
|
27
|
+
{ field: "display_name", op: "contains", value: kw },
|
|
28
|
+
] })
|
|
29
|
+
.orderBy([{ field: "username", dir: "asc" }])
|
|
30
|
+
.limit(limit)
|
|
31
|
+
.all() as unknown as ContactRow[];
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export async function contactDetail(id: number): Promise<Record<string, unknown>> {
|
|
@@ -73,10 +73,12 @@ export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertRe
|
|
|
73
73
|
return { deprecated };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* 事务/非事务统一的 db 面(P3-3:可传入 db.tx 的事务句柄以纳入单一事务)。
|
|
78
|
+
* 只收 table——全构造器化后(v0.1.81 startswith 补齐最后缺口)本出口不再走裸 SQL,
|
|
79
|
+
* 契约随实现收窄:tx 句柄({table})与全局 db 均可传入。
|
|
80
|
+
*/
|
|
77
81
|
interface DbLike {
|
|
78
|
-
query: (sql: string, params?: unknown[]) => Promise<unknown>
|
|
79
|
-
exec: (sql: string, params?: unknown[]) => Promise<unknown>
|
|
80
82
|
table: DBInstance["table"]
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -95,8 +97,8 @@ export async function uninstallSolution(module: string, d: DbLike = db): Promise
|
|
|
95
97
|
await d.table("sys_user_role").where({ field: "role_name", op: "eq", value: r.name }).delete().run();
|
|
96
98
|
}
|
|
97
99
|
await d.table("sys_role").where({ field: "module", op: "eq", value: module }).delete().run();
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
await d.
|
|
100
|
+
// startswith(v0.1.81):语义化前缀匹配(⇒ `v%`),引擎自动转义 %/_/!——
|
|
101
|
+
// 替代旧「手工 escape 拼接 + 裸 SQL like ? escape '!'」(三方言 ESCAPE 同义纪律见 query.ts)
|
|
102
|
+
await d.table("sys_role_permission").where({ field: "permission_code", op: "startswith", value: `${module}.` }).delete().run();
|
|
101
103
|
await d.table("sys_permission").where({ field: "module", op: "eq", value: module }).delete().run();
|
|
102
104
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 操作日志查询(AC1-29/D13:admin 可查;读操作不记审计的红线不适用——本表本身就是审计)。
|
|
2
|
-
// 过滤维度:action 精确 / username
|
|
3
|
-
import {
|
|
2
|
+
// 过滤维度:action 精确 / username 包含 / module 精确;分页复用 query.ts 的 pagedFactory(构造器工厂)。
|
|
3
|
+
import { pagedFactory } from "./query";
|
|
4
4
|
|
|
5
5
|
export interface OplogRow {
|
|
6
6
|
id: number
|
|
@@ -22,26 +22,19 @@ export interface OplogPage {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function searchOplog(filter: { action?: unknown, username?: unknown, module?: unknown, limit?: unknown }): Promise<OplogPage> {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
const where = conds.length ? `where ${conds.join(" and ")}` : "";
|
|
40
|
-
const r = await paged<OplogRow>(
|
|
41
|
-
`select * from sys_oplog ${where} order by id desc`,
|
|
42
|
-
`select count(*) as c from sys_oplog ${where}`,
|
|
43
|
-
params,
|
|
44
|
-
{ size: filter.limit },
|
|
45
|
-
);
|
|
25
|
+
// 构造器(v0.1.82):可选过滤走 whereIf,条件写一份(mk 工厂),count + list 复用;
|
|
26
|
+
// username 走语义 op contains(引擎转义 %/_/!,等价旧 LIKE ? ESCAPE '!' 的 %kw%)。
|
|
27
|
+
// 照抄口径:devkit scenarios 场景 42 ④。
|
|
28
|
+
// 注意 whereIf 的跳过集是 undefined/null/false/[]——**"" 是合法值会保留**(api-manual
|
|
29
|
+
// §1153),故缺失值必须归一化为 undefined(不能图省事用 "",否则会发射 `action = ''`
|
|
30
|
+
// 把结果过滤空——与旧 if (filter.x) 的「假值整条跳过」语义相悖)。
|
|
31
|
+
const action = filter.action ? String(filter.action) : undefined;
|
|
32
|
+
const module = filter.module ? String(filter.module) : undefined;
|
|
33
|
+
const username = filter.username ? String(filter.username) : undefined;
|
|
34
|
+
const mk = () => db.table("sys_oplog")
|
|
35
|
+
.whereIf(action, { field: "action", op: "eq", value: action })
|
|
36
|
+
.whereIf(module, { field: "module", op: "eq", value: module })
|
|
37
|
+
.whereIf(username, { field: "username", op: "contains", value: username });
|
|
38
|
+
const r = await pagedFactory<OplogRow>(mk, { size: filter.limit });
|
|
46
39
|
return { list: r.list, total: r.total, truncated: r.truncated };
|
|
47
40
|
}
|
|
@@ -37,6 +37,24 @@ export async function paged<T>(
|
|
|
37
37
|
return { list, total, page, size, truncated: offset + list.length < total };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// 构造器分页工厂(v0.1.82):条件写一份(mk 工厂),count + list 各跑一次(count 用聚合,
|
|
41
|
+
// 不受 LIMIT 钳制);替代「接受任意 SQL 片段」的旧 paged(裸 SQL 包装器)。条件收敛到构造器
|
|
42
|
+
// (contains 替代 LIKE ? ESCAPE '!'),残留任意 SQL 的场景才继续走旧 paged。
|
|
43
|
+
type Builder = ReturnType<typeof db.table>;
|
|
44
|
+
export async function pagedFactory<T>(
|
|
45
|
+
mk: () => Builder,
|
|
46
|
+
input: { page?: unknown, size?: unknown } = {},
|
|
47
|
+
): Promise<PageResult<T>> {
|
|
48
|
+
const { page, size, offset } = pageArgs(input);
|
|
49
|
+
const total = Number(
|
|
50
|
+
(await mk().select([{ fn: "count", as: "c" }]).all() as Array<Record<string, unknown>>)[0]?.c ?? 0,
|
|
51
|
+
);
|
|
52
|
+
const list = total === 0
|
|
53
|
+
? []
|
|
54
|
+
: await mk().orderBy([{ field: "id", dir: "desc" }]).limit(size).offset(offset).all() as T[];
|
|
55
|
+
return { list, total, page, size, truncated: total > page * size };
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* LIKE 模式:修剪后校验长度(默认 2~64),元字符 % _ ! 加 ! 转义,两侧包 %。
|
|
42
60
|
* SQL 侧必须写 `like ? escape '!'`(三方言同语义;sqlite 默认无转义符,mysql/pg 默认不是 !)。
|
|
@@ -75,5 +75,8 @@ export async function loginSucceeded(key: string): Promise<void> {
|
|
|
75
75
|
|
|
76
76
|
/** 清登录限速状态(测试夹具/运维排障用)。 */
|
|
77
77
|
export async function resetLoginThrottle(): Promise<void> {
|
|
78
|
-
|
|
78
|
+
// 构造器(v0.1.82):truncate() 是唯一的「无 where 清空」写动词,命名式、可 grep,
|
|
79
|
+
// 取代裸 db.exec("delete from …")(审查上像漏写 where 的 delete)。渲染 DELETE FROM t,
|
|
80
|
+
// 租户守卫照常注入(本表非租户表=整表清空)。
|
|
81
|
+
await db.table("sys_login_throttle").truncate().run();
|
|
79
82
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- L1 测试账号(仅 `oj test` 经 fixtures 灌入,不进 release 产物)。
|
|
2
|
+
--
|
|
3
|
+
-- 为什么不能用 seed 的 admin 跑 L1 登录:
|
|
4
|
+
-- ① seed 的 admin 是 must_change=1 首登改密态(D12 生产语义),且初始口令 123456
|
|
5
|
+
-- 只是开发便利——用户在 dev 库改过口令后(产品主动引导),admin 口令即漂移,
|
|
6
|
+
-- L1 若绑 admin/123456 会随机 401(本地实测踩过:dev 库口令被改成 12345678)。
|
|
7
|
+
-- ② 判权类端点(currentAccess/assertPermission)对 must_change=1 恒 403。
|
|
8
|
+
-- 故 additive 建一个专用账号:INSERT OR IGNORE 幂等,反复重放终态一致,
|
|
9
|
+
-- 不碰 admin 既有行(用户的口令/状态不受 `oj test` 影响)。
|
|
10
|
+
-- 按分号朴素切分(S006):语句内不得含分号字面量。
|
|
11
|
+
INSERT OR IGNORE INTO users (username, password_hash, avatar_base64, source, display_name, must_change, is_forbidden) VALUES ('l1tester', '$2b$10$/0DuEHsBLYp6.5ZLJCFafOOvksLbruMvs9aMhUqRSpbdY52D9RaXS', '', 'local', 'L1 测试账号', 0, 0);
|
|
12
|
+
-- 绑 superadmin:L1 若打判权类端点可直通(账号生命周期 B6 建号默认 must_change=1,这里显式 0)
|
|
13
|
+
INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'l1tester';
|
|
@@ -2,6 +2,7 @@ name: _platform
|
|
|
2
2
|
desc: 平台内核(非业务模块、无自身业务路由前缀)。含三类:① 跨模块服务 _shared/service(infra 基础设施 + domain 管理域);② 生命周期引擎 _lifecycle(方案安装/卸载/注册/验签/信任状态机);③ 平台编排 API 面 /api/_platform/{contacts,market,solutions}(由 market/api.ts、contacts/api.ts、solutions/api.ts 暴露,全 superadmin 门禁)。运行时安装的方案包不在此树。
|
|
3
3
|
version: 0.1.0
|
|
4
4
|
tables:
|
|
5
|
+
- _oj_migrations
|
|
5
6
|
- users
|
|
6
7
|
- sys_setting
|
|
7
8
|
- sys_oplog
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# 框架级共有表上收 _platform 伪模块(§3-D2):归属图单源,杜绝「散在模块 seed」。
|
|
2
2
|
# 与 manifest.tables 双向一致(S005);auth 模块跨模块读 users 经 deps 声明。
|
|
3
3
|
# D10:users.roles JSON 列已移除——角色唯一权威源为 sys_user_role 关系表。
|
|
4
|
+
# _oj_migrations 为 oj 迁移 journal 内建表(oj 自建自管 DDL);此处声明只为让 db.table
|
|
5
|
+
# 元数据校验放行(journalHeadOf:深检 ledger 复核 + P3-11 预锁 guard 的真源),列与 oj 真表逐列对齐。
|
|
4
6
|
tables:
|
|
7
|
+
_oj_migrations:
|
|
8
|
+
pk: [module, version]
|
|
9
|
+
columns:
|
|
10
|
+
module: {type: text, null: false}
|
|
11
|
+
version: {type: integer, null: false}
|
|
12
|
+
name: {type: text}
|
|
13
|
+
applied_on: {type: text}
|
|
14
|
+
checksum: {type: text}
|
|
5
15
|
users:
|
|
6
16
|
pk: id
|
|
7
17
|
columns:
|
|
@@ -14,6 +24,8 @@ tables:
|
|
|
14
24
|
must_change: {type: integer, null: false}
|
|
15
25
|
is_forbidden: {type: integer, null: false}
|
|
16
26
|
email: {type: text}
|
|
27
|
+
phone_number: {type: text}
|
|
28
|
+
description: {type: text}
|
|
17
29
|
sys_setting:
|
|
18
30
|
pk: key
|
|
19
31
|
columns:
|
|
@@ -111,8 +111,8 @@ export function createSession(deps: SessionDeps = runtimeDeps()) {
|
|
|
111
111
|
|
|
112
112
|
/** B3:刷新——轮换作旧;reuse 检测(已吊销串二次出现)全族吊销 + 审计。 */
|
|
113
113
|
async refresh(token: string): Promise<TokenBundle> {
|
|
114
|
-
//
|
|
115
|
-
const rows = await db.
|
|
114
|
+
// 构造器(v0.1.81):select(["*"]) 显式拉全列,无已知列清单也能用,方言无关。
|
|
115
|
+
const rows = await db.table("sys_refresh_token").select(["*"]).where({ field: "token_hash", op: "eq", value: hashOf(String(token ?? "")) }).all() as Array<Record<string, unknown>>;
|
|
116
116
|
const row = rows[0];
|
|
117
117
|
if (!row)
|
|
118
118
|
fail(401, "invalid or expired refresh token");
|
|
@@ -6,16 +6,15 @@
|
|
|
6
6
|
const todos = {
|
|
7
7
|
async get() {
|
|
8
8
|
const kw = http.query.keyword;
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const hasKw = typeof kw === "string" && kw.length > 0;
|
|
10
|
+
// 构造器:模糊过滤走语义 op(contains 自动转义 %/_/!),count 走聚合,方言无关。
|
|
11
|
+
const build = () => db.table("todos").whereIf(hasKw, { field: "title", op: "contains", value: kw });
|
|
11
12
|
try {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
params,
|
|
18
|
-
);
|
|
13
|
+
const total = Number((await build().select([{ fn: "count", as: "c" }]).all())[0]?.c ?? 0);
|
|
14
|
+
const rows = await build()
|
|
15
|
+
.select(["id", "title", "done", "create_time", "update_time"])
|
|
16
|
+
.orderBy([{ field: "id", dir: "asc" }])
|
|
17
|
+
.all();
|
|
19
18
|
const list = rows.map(r => ({
|
|
20
19
|
id: Number(r.id),
|
|
21
20
|
title: String(r.title),
|