@oj-module/cli 0.2.68 → 0.2.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.1xzahckt10.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/global.d.ts +6 -4
  55. package/templates/init.include.yaml +16 -11
  56. package/templates/openapi.json +3971 -3663
  57. package/templates/src/.ojm-builtin.json +13 -0
  58. package/templates/src/README.md +5 -2
  59. package/templates/src/app/api/_platform/README.md +3 -2
  60. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  61. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  62. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  63. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  64. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  65. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  66. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  67. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  68. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  69. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  70. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  71. package/templates/src/app/api/_platform/_shared/service/domain/users.ts +7 -1
  72. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  73. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  75. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  79. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  81. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  82. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  83. package/templates/src/app/api/_platform/schema.yaml +8 -11
  84. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  85. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  86. package/templates/src/app/api/_platform/seed.sql +1 -1
  87. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  88. package/templates/src/app/api/system/users/api.ts +3 -0
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -113
  90. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  91. package/templates/src/app/web/system/__client/api.schemas.ts +2 -0
  92. package/templates/src/app/web/system/locales/en-US.json +15 -1
  93. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  94. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  95. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  96. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  97. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  98. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  99. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  100. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  101. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  102. package/templates/src/app/web/system/routes.ts +18 -1
  103. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  104. package/templates/src/oj-market/api/backfill.ts +221 -0
  105. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  106. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  107. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  108. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  109. package/templates/src/oj-market/api/manifest.yaml +8 -0
  110. package/templates/src/oj-market/solution.yaml +21 -0
  111. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  112. package/templates/src/oj-market/web/__client/index.ts +509 -0
  113. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  114. package/templates/src/oj-market/web/api/client.ts +6 -6
  115. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  116. package/templates/src/oj-market/web/entry.ts +5 -3
  117. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  118. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  119. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  120. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  121. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  122. package/templates/src/oj-market/web/routes.ts +12 -0
  123. package/templates/src/oj-market/web/types.ts +14 -8
  124. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  125. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  126. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  127. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  128. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  129. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  130. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  131. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  132. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  133. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  134. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  135. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  136. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  137. package/templates/tests/rbac-users.spec.ts +8 -0
  138. package/templates/tests/solutions-entry.spec.ts +55 -0
  139. package/templates/tsconfig.paths.json +1 -0
  140. package/templates/types/stream-probes/stream-handlers.ts +34 -0
  141. package/templates/web.config.ts +6 -6
  142. package/vendor/host-versions.json +3 -3
  143. package/bin/oj +0 -4
  144. package/src/market/__tests__/build-index.test.ts +0 -641
  145. package/src/market/__tests__/sharding.test.ts +0 -90
  146. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  147. package/src/market/build-index.ts +0 -526
  148. package/src/market/index-builder.ts +0 -149
  149. package/src/market/sharding.ts +0 -94
  150. package/src/market/yank-ledger.ts +0 -91
  151. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  152. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  153. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -0,0 +1,13 @@
1
+ {
2
+ "//": "由 ojm init 生成(名单派生自模板白名单)。可手改/删除:删名 = 该模块恢复参与打包与 ojm info 显示;删整个文件 = 退化为无内置概念。",
3
+ "modules": [
4
+ "_platform",
5
+ "app",
6
+ "auth",
7
+ "notification",
8
+ "personal-center",
9
+ "publish",
10
+ "system",
11
+ "web"
12
+ ]
13
+ }
@@ -4,8 +4,11 @@
4
4
 
5
5
  ## 框架自带的模块(不许改)
6
6
 
7
- - `src/app/` —— 框架内置页面与后端(登录、首页、个人中心、通知、系统管理、认证等)
8
- - `src/oj-market/` —— 解决方案市场
7
+ - `src/app/` —— 框架内置页面与后端(登录、首页、个人中心、通知、系统管理、方案安装、认证等)
8
+ - `src/oj-market/` —— 解决方案市场的**编排面**:`/api/oj-market/*` 端点(安装/目录/下线/信任/
9
+ 回填)随脚手架内置;市场 UI 本身是第一方**方案包**(出厂未装)——superadmin 经
10
+ system 域「方案安装」首装,`ojm init --yes` 落位的随站 bundle 由首个市场请求自动收敛
11
+ (上拉不下拉)。这一层只有 api 与生成物 `__client`,没有页面源码。
9
12
 
10
13
  它们由上游统一维护、跟着框架版本走。**请不要修改、也不要删除这里的文件**:
11
14
 
@@ -17,11 +17,12 @@
17
17
  - `domain/`:管理域共享服务(多模块共用的「系统管理」域逻辑,所有权归 `_platform` 的 `sys_*` 表)
18
18
  - `users.ts` / `roles.ts` / `settings.ts` / `solution-perms.ts` / `contacts.ts`
19
19
  2. **`_lifecycle/`** —— 方案生命周期引擎(安装/卸载/注册/验签/信任的声明式状态机,
20
- 与编排端点 `market/api.ts` 职责分离,见 `market/api.ts` 顶部 ISP 注释)。
20
+ 与编排端点(市场域 api 已迁 oj-market 模块,见下)职责分离。
21
21
  3. **编排 API 面** —— 经 oj 扫描 `src/**/api.ts` 暴露,路径前缀即目录名:
22
22
  - `contacts/api.ts` → `/api/_platform/contacts`
23
- - `market/api.ts` → `/api/_platform/market`(方案市场,全 superadmin 门禁,R9)
24
23
  - `solutions/api.ts` → `/api/_platform/solutions`(方案路由/验签数据源)
24
+ - 市场域 api(方案市场/发布推送/生命周期编排/信任库)自 v4 起迁 `src/oj-market/api/`
25
+ → `/api/oj-market/*`(模块 manifest 声明 `deps: [_platform]`;表与引擎仍归本内核)
25
26
 
26
27
  ## 边界约定
27
28
 
@@ -75,7 +75,8 @@ export function fsFace(): FsFace {
75
75
 
76
76
  const encoder = new TextEncoder();
77
77
 
78
- export async function atomicWriteText(path: string, content: string): Promise<void> {
78
+ /** 字节原子写(上传端点落 tgz/.sig 用):同目录 tmp + rename,语义同 atomicWriteText */
79
+ export async function atomicWriteBytes(path: string, data: Uint8Array): Promise<void> {
79
80
  const f = fsFace();
80
81
  // tmp 与目标同目录:保证 rename 不跨文件系统;随机后缀防并发写者互踩同一 tmp
81
82
  const slash = path.lastIndexOf("/");
@@ -86,7 +87,7 @@ export async function atomicWriteText(path: string, content: string): Promise<vo
86
87
  await f.mkdir(dir, { recursive: true });
87
88
  }
88
89
  try {
89
- await f.writeFile(tmp, encoder.encode(content));
90
+ await f.writeFile(tmp, data);
90
91
  await f.rename(tmp, path);
91
92
  }
92
93
  catch (e) {
@@ -99,3 +100,7 @@ export async function atomicWriteText(path: string, content: string): Promise<vo
99
100
  throw e;
100
101
  }
101
102
  }
103
+
104
+ export async function atomicWriteText(path: string, content: string): Promise<void> {
105
+ await atomicWriteBytes(path, encoder.encode(content));
106
+ }
@@ -0,0 +1,158 @@
1
+ // 随站 bundle 启动收敛(Phase B T3b,方案 §5「上拉不下拉」):
2
+ // 未装 → install;bundle > 已装 → upgrade;bundle ≤ 已装 → skip。
3
+ // bundle < 已装 是市场轨(用户经方案市场升过更高版本)的正常漂移态——不降级,
4
+ // 对齐 rollbackGuard 禁降版红线 OJ-9(降级唯一入口 = 站点回滚)。
5
+ //
6
+ // 挂点裁决(T3b):oj 启动段 ext_boot 有 2s 看门狗(oj-bin runtime.rs BOOT_TIMEOUT,
7
+ // terminate 的是正被借出跑请求的 isolate),安装全程(解压/迁移/重装配)不可在 boot
8
+ // 内跑——收敛编排由首个编排 API 请求触发(index 端点「对账即服务」先例),每次调用
9
+ // 幂等:已对齐即全 skip(只剩 marker/状态两次读)。单条目失败记 failed 续跑,不抛。
10
+
11
+ import { atomicWriteText, fsFace } from "./atomic";
12
+ import { compareVersions } from "./semver";
13
+ import { createStateStore } from "./state";
14
+
15
+ export type BundleConvergePlan = "install" | "upgrade" | "skip";
16
+
17
+ export function bundleConvergePlan(installed: string | null, bundle: string): BundleConvergePlan {
18
+ if (installed === null)
19
+ return "install";
20
+ return compareVersions(bundle, installed) > 0 ? "upgrade" : "skip";
21
+ }
22
+
23
+ /** 意图标记条目(init --yes 写 cdn/bundles.json;file = cdn/ 内 tgz 文件名) */
24
+ export interface BundledEntry {
25
+ name: string
26
+ version: string
27
+ file: string
28
+ }
29
+
30
+ /** 安装闭包注入面:字节 + 旁车签名的本地归档安装(manifest 组装 + runInstall 在调用方) */
31
+ export interface BundleConvergeEnv {
32
+ /** 意图标记路径(缺省 <cdnDir>/bundles.json,调用方可显式传) */
33
+ markerPath: string
34
+ /** 本地字节仓库目录 */
35
+ cdnDir: string
36
+ /** sidecar 状态文件(读已装版本) */
37
+ statePath: string
38
+ installLocal: (entry: BundledEntry, archive: { bytes: Uint8Array, sig: unknown | null }) => Promise<void>
39
+ /** 失败冷却文件(可选;调用方启用才有冷却,缺省每次请求重试——测试与轻量场景) */
40
+ cooldownPath?: string
41
+ /** 当前时刻 epoch ms(测试注入;缺省 Date.now) */
42
+ now?: () => number
43
+ }
44
+
45
+ export interface BundleConvergeResult {
46
+ installed: string[]
47
+ upgraded: string[]
48
+ skipped: string[]
49
+ failed: string[]
50
+ /** 冷却窗内跳过(上次失败未满 10 分钟;调用方据此可观测连续失败) */
51
+ cooling: string[]
52
+ }
53
+
54
+ /** 失败冷却窗:持久失败条目每次 index 请求全量重装是重试风暴——窗内不再重试(终审 Important-2) */
55
+ const COOLDOWN_MS = 10 * 60 * 1000;
56
+
57
+ /** 读冷却表(缺省 {});文件损坏按空表对待(冷却失效 = 保守重试,不阻断收敛) */
58
+ async function readCooldown(cooldownPath: string): Promise<Record<string, number>> {
59
+ try {
60
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(await fsFace().readFile(cooldownPath)));
61
+ if (parsed !== null && typeof parsed === "object")
62
+ return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter(([, v]) => typeof v === "number")) as Record<string, number>;
63
+ }
64
+ catch {}
65
+ return {};
66
+ }
67
+
68
+ /**
69
+ * 卸载墓碑(终审 Important-1):方案被 superadmin 卸载时,调用方摘除其在意图标记里
70
+ * 的条目——否则「未装→装」收敛会把故意卸载当漂移纠正,下次 index 请求全自动重装,
71
+ * 计划特性 2「卸载后仅可经 system 域入口重装」落空。marker 缺失/损坏安静 no-op。
72
+ */
73
+ export async function pruneBundledMarker(markerPath: string, name: string): Promise<void> {
74
+ const entries = await readMarker(markerPath);
75
+ if (entries === null)
76
+ return;
77
+ const rest = entries.filter(e => e.name !== name);
78
+ if (rest.length === entries.length)
79
+ return;
80
+ await atomicWriteText(markerPath, `${JSON.stringify({ solutions: rest }, null, "\t")}\n`);
81
+ }
82
+
83
+ /** 读 marker:缺失/损坏/空清单 → null(零动作,旧工程与无 bundle 站点的安静态) */
84
+ async function readMarker(markerPath: string): Promise<BundledEntry[] | null> {
85
+ let text: string;
86
+ try {
87
+ text = new TextDecoder().decode(await fsFace().readFile(markerPath));
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ try {
93
+ const parsed: unknown = JSON.parse(text);
94
+ const list = (parsed as { solutions?: unknown }).solutions;
95
+ if (!Array.isArray(list) || list.length === 0)
96
+ return null;
97
+ const out: BundledEntry[] = [];
98
+ for (const raw of list) {
99
+ const e = raw as Partial<BundledEntry>;
100
+ if (typeof e?.name === "string" && typeof e.version === "string" && typeof e.file === "string")
101
+ out.push({ name: e.name, version: e.version, file: e.file });
102
+ }
103
+ return out.length === 0 ? null : out;
104
+ }
105
+ catch {
106
+ return null; // 损坏 marker 不当场修(下次 init --yes 重写),收敛静默跳过
107
+ }
108
+ }
109
+
110
+ /** 跑一遍对账(幂等;由编排 API 请求面调用,如 oj-market index 端点) */
111
+ export async function convergeBundledSolutions(env: BundleConvergeEnv): Promise<BundleConvergeResult> {
112
+ const result: BundleConvergeResult = { installed: [], upgraded: [], skipped: [], failed: [], cooling: [] };
113
+ const entries = await readMarker(env.markerPath);
114
+ if (entries === null)
115
+ return result;
116
+ const state = (await createStateStore(env.statePath)).read();
117
+ const now = env.now ?? (() => Date.now());
118
+ let cooldown: Record<string, number> | null = null;
119
+ if (env.cooldownPath !== undefined)
120
+ cooldown = await readCooldown(env.cooldownPath);
121
+ for (const entry of entries) {
122
+ const label = `${entry.name}@${entry.version}`;
123
+ const plan = bundleConvergePlan(state.solutions[entry.name]?.version ?? null, entry.version);
124
+ if (plan === "skip") {
125
+ result.skipped.push(label);
126
+ continue;
127
+ }
128
+ if (cooldown !== null && now() - (cooldown[entry.name] ?? 0) < COOLDOWN_MS) {
129
+ result.cooling.push(label);
130
+ continue;
131
+ }
132
+ try {
133
+ const bytes = new Uint8Array(await fsFace().readFile(`${env.cdnDir}/${entry.file}`));
134
+ let sig: unknown | null = null;
135
+ try {
136
+ sig = JSON.parse(new TextDecoder().decode(await fsFace().readFile(`${env.cdnDir}/${entry.file}.sig`)));
137
+ }
138
+ catch {
139
+ sig = null; // 无旁车 → null,验签 fail-closed 由安装器 verify 段裁决(六码分流)
140
+ }
141
+ await env.installLocal(entry, { bytes, sig });
142
+ result[plan === "install" ? "installed" : "upgraded"].push(label);
143
+ if (cooldown !== null && cooldown[entry.name] !== undefined) {
144
+ delete cooldown[entry.name];
145
+ await atomicWriteText(env.cooldownPath!, JSON.stringify(cooldown));
146
+ }
147
+ }
148
+ catch (e) {
149
+ // 降级不阻断:记 failed 续跑;冷却窗内(若启用)不再重试
150
+ result.failed.push(`${label}(${e instanceof Error ? e.message : String(e)})`);
151
+ if (cooldown !== null) {
152
+ cooldown[entry.name] = now();
153
+ await atomicWriteText(env.cooldownPath!, JSON.stringify(cooldown));
154
+ }
155
+ }
156
+ }
157
+ return result;
158
+ }
@@ -1,5 +1,6 @@
1
1
  // market_history 留痕(P1-15a 缺口 A,P4 上收 _platform):install / enable / disable / uninstall
2
- // 四类动作共用的平台级留痕出口(前端 HistoryDrawer 消费面)。表由 _platform 自带迁移创建
2
+ // 四类动作共用的平台级留痕出口(前端 HistoryDrawer 消费面);v4 起 publish(签名推送)/
3
+ // yank(目录下线)同通道留痕(成败俱记)。表由 _platform 自带迁移创建
3
4
  // (0012__market_history),归平台核心所有;守卫式 insert 仍兜底表缺失/写失败,静默跳过,
4
5
  // 不阻断主流程(历史是观测面,不是业务正确性依赖;与终态审计附录 B-5 同语义)。
5
6
  // 参数化 insert,与 audit.ts 同纪律(值不拼字符串)。
@@ -10,7 +11,7 @@ declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown>,
10
11
  export interface HistoryRow {
11
12
  solution: string
12
13
  version: string
13
- action: "install" | "enable" | "disable" | "uninstall"
14
+ action: "install" | "enable" | "disable" | "uninstall" | "publish" | "yank" | "unyank"
14
15
  status: string
15
16
  /** 操作人(用户名;编排层可得则传,缺省 null) */
16
17
  operator: string | null
@@ -234,12 +234,13 @@ async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
234
234
  // 留在 staging(deploying_web 段消费)。无 api 段(web-only 包,extracting C2-31 已放行)
235
235
  // 回退整体 rename——版本目录 = 包内容(无 serve 装配面的残留形态)。
236
236
  let src = req.staging;
237
+ let hasApiModule = true;
237
238
  try {
238
239
  await f.readdir(`${req.staging}/api/${req.name}-${req.version}`);
239
240
  src = `${req.staging}/api/${req.name}-${req.version}`;
240
241
  }
241
242
  catch {
242
- // 无 api 段:整体 rename
243
+ hasApiModule = false; // 无 api 段:整体 rename
243
244
  }
244
245
  try {
245
246
  await f.rename(src, versionDir);
@@ -247,6 +248,14 @@ async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
247
248
  catch (e) {
248
249
  return { stage: "lock_commit", ok: false, retryable: true, message: `生效切换失败:${src} rename 失败(${String(e)})——附录 A lock_commit:请稍后重试` };
249
250
  }
251
+ if (!hasApiModule) {
252
+ // web-only 包 api 锁零变更(2026-11-07 市场线演练实锤):锁是 oj 装配真源(按原生七键
253
+ // 清单 parse_one),写锁会让 oj 拿 C2 方案清单当模块清单装配(reload 400),且双轨同名
254
+ // (第一方 oj-market 方案 vs 平台内建 oj-market 编排模块)锁条目会遮蔽内建模块。
255
+ // 生效面 = modules.json upsert + 路由/角色注册 + sidecar;版本目录归位作回滚素材/审计形态
256
+ // (web-only 降级不走市场回滚——OJ-9:降级唯一入口 = 站点回滚,双轨裁决)。
257
+ return { stage: "lock_commit", ok: true, message: `生效切换完成:${src} → ${versionDir} 就位(web-only 包:api 锁零变更,不遮蔽同名内建模块)` };
258
+ }
250
259
  const c = await commitLockEntry(`${req.sharedRoot}/manifests.yaml`, req.name, req.version);
251
260
  if (!c.ok) {
252
261
  // 锁写失败回退 rename(锁未变则多余版本目录撤回,由锁前清场统一删;回退失败由 reconcile 兜底,D11)
@@ -337,15 +346,26 @@ async function deployWebStage(req: InstallRequest): Promise<StageResult> {
337
346
  const web = isObj(m.web) ? m.web : undefined;
338
347
  const entry = web !== undefined && typeof web.entry === "string" ? web.entry : undefined;
339
348
  let integrity: string | undefined;
349
+ // web-only 包(无 api 段):lock_commit 回退「staging 整体 rename → 版本目录」(版本目录 = 包内容
350
+ // 形态,B-11 注释面),web 树随之归位版本目录、staging 已不存在——部署段须从版本目录消费
351
+ // (2026-11-07 市场线演练实锤:整体 rename 后 staging/web 读空致 deploying_web 必败)。
352
+ let stagingDir = req.staging;
340
353
  if (entry !== undefined) {
341
354
  try {
342
- integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${req.staging}/web/${entry}`))}`;
355
+ integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${stagingDir}/web/${entry}`))}`;
343
356
  }
344
- catch (e) {
345
- return { stage: "deploying_web", ok: false, retryable: false, message: `部署失败:web.entry「${entry}」在 staging 不可读(${String(e)})——manifest 与产物错配(R10)` };
357
+ catch {
358
+ const versionDir = `${req.sharedRoot}/${req.name}-${req.version}`;
359
+ try {
360
+ integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${versionDir}/web/${entry}`))}`;
361
+ stagingDir = versionDir;
362
+ }
363
+ catch (e) {
364
+ return { stage: "deploying_web", ok: false, retryable: false, message: `部署失败:web.entry「${entry}」在 staging 与版本目录均不可读(${String(e)})——manifest 与产物错配(R10)` };
365
+ }
346
366
  }
347
367
  }
348
- return deployWeb({ ...req, stagingDir: req.staging, integrity });
368
+ return deployWeb({ ...req, stagingDir, integrity });
349
369
  }
350
370
 
351
371
  /** `ojm preview` 派生的 config 文件名(与 CLI 侧 preview-config.ts 的 PREVIEW_CONFIG_NAME 逐字一致) */
@@ -0,0 +1,61 @@
1
+ // 本地归档 manifest 补全(Phase B T3b 抽取:installBody file 源与 bundle 收敛共用)。
2
+ // ojm build --bundle 产物的包内 manifest 是 Form A(archive 仅 file,sha256/size 由
3
+ // publish 填充于 Form B),而安装器 verify 段对 archive.sha256 fail-closed——字节在手
4
+ // 的本地安装(multipart 上传 / 随站 bundle)须在安装时点补摘要成可校验清单
5
+ // (verify 复核同批字节,R10 语义保持;CDN 源不受影响,Form B 真源自带摘要)。
6
+ // 正名(P2-11 索引投毒加固):archive.file 逐字 <name>-<version>.tgz——name(C2-01)/
7
+ // version(无路径分隔符)拼出的单文件名安全时即正名;否则退回常量名(此刻尚未过预检,
8
+ // 恶意命名不提前拒也不给穿越面——预检稍后照拒)。
9
+ // detached 侧车签名注入(P2-3,G4 补裁):与 Form B archive.signature 同结构注入,
10
+ // verify 段统一验签(六码分流);sig 为 null(无旁车)时不注入,验签 fail-closed
11
+ // 由 verify 段按 sig_missing 裁决,本层不预判。
12
+
13
+ import type { ExecFace } from "./steps/extract";
14
+ import { manifestOfUpload } from "./manifest-of-upload";
15
+
16
+ export interface LocalArchivePrep {
17
+ /** 暂存目录(与安装器 staging 同纪律) */
18
+ stagingDir: string
19
+ /** exec 面(tar 列表/解包) */
20
+ exec: ExecFace
21
+ /** sha256 摘要面(archive 补摘要) */
22
+ sha256Hex: (data: Uint8Array) => string
23
+ }
24
+
25
+ export interface LocalArchiveInput {
26
+ /** 身份权威名(body/标记权威,包内 manifest 兜底;冲突即拒) */
27
+ name: string
28
+ /** 期望版本(null = 以包内 manifest.version 为准) */
29
+ reqVersion: string | null
30
+ /** tgz 字节 */
31
+ bytes: Uint8Array
32
+ /** detached 侧车签名(已解析的 SidecarSig 形状;null = 无旁车) */
33
+ sig: unknown | null
34
+ }
35
+
36
+ export interface LocalArchiveManifest {
37
+ manifest: Record<string, unknown>
38
+ version: string
39
+ }
40
+
41
+ export async function localArchiveManifestOf(prep: LocalArchivePrep, input: LocalArchiveInput): Promise<LocalArchiveManifest> {
42
+ const manifest: unknown = await manifestOfUpload({ stagingDir: prep.stagingDir, bytes: input.bytes, exec: prep.exec });
43
+ const m = (manifest !== null && typeof manifest === "object" ? manifest : {}) as Record<string, unknown>;
44
+ // 身份:入参权威、manifest 兜底;两者俱在且冲突即拒(防锁名/注册名分叉的坏装)
45
+ const mVersion = typeof m.version === "string" ? m.version : "";
46
+ if (input.reqVersion !== null && mVersion !== "" && mVersion !== input.reqVersion)
47
+ throw Object.assign(new Error(`期望版本「${input.reqVersion}」与包内 manifest.version「${mVersion}」不一致——请核对分发包`), { status: 400 });
48
+ const mName = typeof m.name === "string" ? m.name : "";
49
+ if (mName !== "" && mName !== input.name)
50
+ throw Object.assign(new Error(`期望 name「${input.name}」与包内 manifest.name「${mName}」不一致——请核对分发包`), { status: 400 });
51
+ if (input.reqVersion === null && mVersion === "")
52
+ throw Object.assign(new Error("version 不可得(期望版本与包内 manifest.version 均缺失)"), { status: 400 });
53
+ const version = input.reqVersion ?? mVersion;
54
+ const namedFile = /^[a-z][a-z0-9-]{0,63}$/.test(input.name) && /^[0-9A-Z.-]+$/i.test(version) && !version.includes("..")
55
+ ? `${input.name}-${version}.tgz`
56
+ : "upload.tgz";
57
+ m.archive = { file: namedFile, sha256: prep.sha256Hex(input.bytes), size: input.bytes.byteLength };
58
+ if (input.sig !== null)
59
+ m.archive = { ...(m.archive as Record<string, unknown>), signature: input.sig };
60
+ return { manifest: m, version };
61
+ }
@@ -0,0 +1,62 @@
1
+ // 上传包 manifest 抽取共享管线(v4 §二:上传端点与回填命令同一抽取实现,勿各写一遍)。
2
+ // R1 三步范式(list→validate→extract)+ REG-CS8 体积闸(validate 与 extract 之间,
3
+ // 零解压零落盘拒解压炸弹)+ 解包扁平化 + manifest.yaml 解析。
4
+ // 原实现位于 market/api.ts manifestOfUpload(file 源安装用);上传端点落地时上收此处,
5
+ // 两侧共用。错误口径:带 status 的可读失败(respond 收口成 json.fail,D14/B5 同通道)。
6
+
7
+ import type { ExecFace } from "./steps/extract";
8
+ import { fsFace } from "./atomic";
9
+ import { flattenBundleRoot } from "./steps/extract";
10
+ import { checkTarVolumeLimits, validateTarListing } from "./tgz-guard";
11
+
12
+ /** 带 status 的可读失败(respond 收口:err.status → json.fail) */
13
+ function fail(status: number, msg: string): never {
14
+ throw Object.assign(new Error(msg), { status });
15
+ }
16
+
17
+ export interface ManifestOfUploadInput {
18
+ /** 暂存目录(与安装器 staging 同纪律:包体落 upload.tgz 后解包,文件名常量无用户输入) */
19
+ stagingDir: string
20
+ /** 上传字节(tgz 包体) */
21
+ bytes: Uint8Array
22
+ /** exec 面(tar 列表/解包;注入 stub 不真跑) */
23
+ exec: ExecFace
24
+ }
25
+
26
+ /**
27
+ * 抽取包内嵌 manifest 对象。校验序:
28
+ * ① tar -tvzf 列表(损坏包 400)→ ② validateTarListing(zip-slip 拒收 400)→
29
+ * ③ checkTarVolumeLimits(体积闸 413,零解压零落盘)→ ④ tar -xzf 解包(失败 400)→
30
+ * ⑤ flattenBundleRoot(单根目录包扁平化)→ ⑥ manifest.yaml 解析(不可解析 400)。
31
+ */
32
+ export async function manifestOfUpload(input: ManifestOfUploadInput): Promise<unknown> {
33
+ const f = fsFace();
34
+ const staged = `${input.stagingDir}/upload.tgz`; // 常量名不含用户输入,无穿越面
35
+ await f.mkdir(input.stagingDir, { recursive: true });
36
+ await f.writeFile(staged, input.bytes);
37
+ const listed = await input.exec.run(["tar", "-tvzf", staged]);
38
+ if (listed.code !== 0)
39
+ fail(400, `上传包列表失败(tar -tvzf):${(listed.stderr || listed.stdout).trim()}——包体损坏?`);
40
+ // 铁律②:tar 输出恒带尾随换行,先 trim 尾部空行再 split(extract.ts 同款)
41
+ const lines = listed.stdout.replace(/\n+$/, "").split("\n");
42
+ const v = validateTarListing(lines);
43
+ if (!v.ok)
44
+ fail(400, `上传包含非法成员「${v.offender}」——R1 zip-slip 防护拒收(list→validate→extract 三步范式)`);
45
+ // REG-CS8 体积闸:清单在手即拒解压炸弹,先于一切解压/落盘动作
46
+ const vol = checkTarVolumeLimits(lines);
47
+ if (!vol.ok) {
48
+ const label = vol.code === "too_many_entries" ? "条目数超限" : vol.code === "unpacked_too_large" ? "解压后总体积超限" : "单成员体积超限";
49
+ fail(413, `上传包${label}(${vol.offender})——REG-CS8 体积闸拒收(解压炸弹防护),请缩减包体后重发`);
50
+ }
51
+ const extracted = await input.exec.run(["tar", "-xzf", staged, "-C", input.stagingDir]);
52
+ if (extracted.code !== 0)
53
+ fail(400, `上传包解包失败(tar -xzf):${(extracted.stderr || extracted.stdout).trim()}`);
54
+ await flattenBundleRoot(input.stagingDir);
55
+ const g = globalThis as { yaml?: { parse: (text: string) => Promise<unknown> } };
56
+ try {
57
+ return await g.yaml!.parse(new TextDecoder().decode(await f.readFile(`${input.stagingDir}/manifest.yaml`)));
58
+ }
59
+ catch (e) {
60
+ return fail(400, `上传包 manifest.yaml 不可解析:${String(e)}`);
61
+ }
62
+ }
@@ -55,6 +55,14 @@ const ANONYMOUS_RE = /^[a-z0-9][a-z0-9\-_.]*(?:\/[a-z0-9][a-z0-9\-_.]*)*$/;
55
55
  /** P0 §3.2 匿名保留段黑名单(显式拒装清单;与 C2-26 段内收敛互补——C2-26 挡「跨段」,本清单挡「保留段本身」) */
56
56
  const ANON_RESERVED_PREFIXES = ["_platform", "_system", "auth", "_lifecycle"];
57
57
 
58
+ /**
59
+ * 平台内建编排段(Phase B):市场编排面 api 随脚手架内置,config 出厂即匿名
60
+ * /oj-market/catalog|cdn/upload|yank(方案 §2「市场未装时端点仍在」)。这些条目与
61
+ * /auth/* 同属宿主内建而非方案注入——第一方纯 web 包(无 anonymous 声明)收敛/首装
62
+ * 时不得在 anonymous_residual 被误判为「同 prefix 换装残留」(2026-11-07 市场线演练实锤)。
63
+ */
64
+ const BUILTIN_ORCH_SEGMENTS = new Set(["oj-market"]);
65
+
58
66
  /** C2-17:路由 path 语法(首段首字符为字母;无参数段/通配符/尾斜杠) */
59
67
  const ROUTE_PATH_RE = /^\/[a-z][a-z0-9\-_]*(?:\/[a-z0-9\-_]+)*$/;
60
68
 
@@ -286,7 +294,7 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
286
294
  else {
287
295
  const host = ctx.hostApiVersion;
288
296
  if (host === undefined) {
289
- v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/_platform/market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
297
+ v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/oj-market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
290
298
  }
291
299
  else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
292
300
  const advice = caretUpgradeAdvice(host, parsed[1], parsed[2]);
@@ -399,7 +407,9 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
399
407
  // **保留段条目不在此查**:config 里 /auth/*、/_platform/* 等属宿主内建(如 /auth/login),方案永不
400
408
  // 能注入(C2-26 段内收敛已挡住方案声明保留段匿名),故保留段 config 条目绝不会是方案残留——
401
409
  // 纳入比对只会误伤宿主内建匿名端点(F4 假阳性)。
402
- {
410
+ // **平台内建编排段同样不在此查**(BUILTIN_ORCH_SEGMENTS):编排面匿名条目出厂即在 config,
411
+ // 非任何方案注入;第一方纯 web 包(oj-market,无 anonymous 声明)依赖此豁免完成收敛/首装。
412
+ if (!BUILTIN_ORCH_SEGMENTS.has(apiSeg)) {
403
413
  const existing = ctx.existingAnonymousPaths ?? [];
404
414
  const residual: string[] = [];
405
415
  // config 条目带前导斜杠(appendAnonymousPaths 统一补 /),归一去斜杠再取首段,
@@ -79,7 +79,7 @@ export function moduleSymbolOf(name: string, version: string, kind: "install" |
79
79
  * - 附加段键(ctx.expected,如 mounts/tenant/tasks)逐一 ∈ applied(覆盖项保留)。
80
80
  * 返回 {ok, message?} 供调用方生成人话。
81
81
  */
82
- export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, kind: "install" | "uninstall", name: string, version: string, additional?: string[], /** 强制热更(oj 0.1.123+ reloadConfig(force)):unchanged 时须失败(强制装配未生效) */ force?: boolean }): { ok: boolean, message?: string } {
82
+ export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, kind: "install" | "uninstall", name: string, version: string, additional?: string[], /** 强制热更(oj 0.1.123+ reloadConfig(force)):unchanged 时须失败(强制装配未生效) */ force?: boolean, /** 卸载侧:锁条目是否真随卸载摘除——双轨同名(内建模块锁条目保留,lockRemoved=false)时 oj 装配面无锁 diff,unchanged 属预期(方案摘除发生在 modules.json/sidecar/route 面) */ lockRemoved?: boolean }): { ok: boolean, message?: string } {
83
83
  if (typeof r !== "object" || r === null || !Array.isArray(r.applied) || typeof r.unchanged !== "boolean") {
84
84
  return {
85
85
  ok: false,
@@ -93,6 +93,9 @@ export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, ki
93
93
  }
94
94
  if (r.unchanged) {
95
95
  if (opts.kind === "uninstall") {
96
+ // 双轨同名(2026-11-07 演练实锤):内建模块锁条目保留 → 装配面无锁 diff,unchanged 属预期。
97
+ if (opts.lockRemoved === false)
98
+ return { ok: true };
96
99
  return {
97
100
  ok: false,
98
101
  message: "reloadConfig 返回 unchanged——卸载后锁未变化,疑似卸载空转(锁未摘除);请确认宿主已开启 reload_api 且 oj ≥ v0.1.122",
@@ -124,11 +124,17 @@ export async function smoke(ctx: SmokeContext): Promise<StageResult> {
124
124
  const m = isObj(ctx.manifest) ? ctx.manifest : {};
125
125
  const web = isObj(m.web) ? m.web : undefined;
126
126
 
127
- // ① 探活:health 优先(2xx),缺省首个非匿名路由(连通即可)
127
+ // ① 探活:health 优先(2xx),缺省首个非匿名路由(连通即可)。
128
+ // 探活只对有 api 面的方案成立:纯 web 包的 routes[] 是页面路由(如 /oj-market),
129
+ // 塞进 api 前缀必 404——2026-11-07 oj-market 重装实锤(误探 /api/oj-market → smoke 失败 +
130
+ // 全量补偿回滚含 modules.json → 重装死循环)。无 api 段 = 无 api 面可探,无事可验视为过。
128
131
  const notes: string[] = [];
129
132
  try {
133
+ const api = isObj(m.api) ? m.api : undefined;
130
134
  const health = typeof m.health === "string" && m.health !== "" ? m.health : undefined;
131
- const target = health ?? firstNonAnonymousRoute(m);
135
+ const target = api === undefined ? undefined : (health ?? firstNonAnonymousRoute(m));
136
+ if (target === undefined && api === undefined)
137
+ notes.push("纯 web 方案无 api 面,探活跳过");
132
138
  if (target !== undefined) {
133
139
  const url = probeUrlOf(ctx.apiPrefix, target);
134
140
  const res = await ctx.fetch(url, { headers: { [PROBE_TENANT_HEADER]: PROBE_TENANT } });
@@ -21,7 +21,7 @@ export interface InstallContext {
21
21
  state: StateStore
22
22
  /** 宿主 versions.json(C2-09 peerRuntime 比对真源;键 = 包名,runtime 取 @oj-module/runtime) */
23
23
  hostVersions?: Record<string, string>
24
- /** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/_platform/market/meta 的 apiVersion,即 market/api.ts 的 API_VERSION 常量,与 _platform/manifest.yaml 版本手工同步) */
24
+ /** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/oj-market/meta 的 apiVersion,即 market/api.ts 的 API_VERSION 常量,与 _platform/manifest.yaml 版本手工同步) */
25
25
  hostApiVersion?: string
26
26
  /**
27
27
  * 宿主能力集(P0 §3.9:ojInfo().capabilities,装配期冻结、任何模块可读)。由调用方从 ojInfo 注入;
@@ -187,14 +187,23 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
187
187
  if (lock[name] === undefined && side === undefined) {
188
188
  return { ok: false, reason: "not-installed", message: `卸载失败:「${name}」未安装(锁与 sidecar 均无记录)` };
189
189
  }
190
- const version = input.version ?? lock[name] ?? side?.version;
190
+ // 安装在哪:锁与 sidecar 均无记录 = 未安装(P3-11:细分 reason,端点据此回 404 资源缺失语义)
191
+ if (lock[name] === undefined && side === undefined) {
192
+ return { ok: false, reason: "not-installed", message: `卸载失败:「${name}」未安装(锁与 sidecar 均无记录)` };
193
+ }
194
+ // 版本解析 sidecar 优先(2026-11-07 市场线演练实锤:双轨同名——第一方 web-only 方案的锁
195
+ // 编排面)可能正占着锁的另一版本;「锁优先」会把内建版本当方案删,内建目录/锁条目被毁、
196
+ // 编排端点全灭。sidecar 是方案轨唯一真相;锁仅作 sidecar 缺失(旧工程/半残态)的兜底)。
197
+ const version = input.version ?? side?.version ?? lock[name];
191
198
  if (version === undefined) {
192
199
  return { ok: false, message: `卸载失败:「${name}」版本不可得(锁与 sidecar 均无版本)` };
193
200
  }
194
-
195
- // 空锁拒启(S1-1.2):摘除后锁为空 → 拒绝卸载最后一个模块
201
+ // 锁条目归属裁决:sidecar 在 → 锁条目仅当与 sidecar 同版本才属本方案(api 轨锁随装写);
202
+ // sidecar 缺失(旧形态/半残)→ 锁条目按旧语义归属方案。同名不同轨的锁条目(内建模块)不摘。
203
+ const lockEntryIsSolution = side !== undefined ? lock[name] === side.version : lock[name] !== undefined;
196
204
  const remaining = { ...lock };
197
- delete remaining[name];
205
+ if (lockEntryIsSolution)
206
+ delete remaining[name];
198
207
  if (Object.keys(remaining).length === 0) {
199
208
  return { ok: false, message: "卸载失败:摘除后锁为空(空锁拒启,S1-1.2)——站点至少保留一个已锁模块,不能卸载最后一个" };
200
209
  }
@@ -252,7 +261,7 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
252
261
  // 卸载空转回归守卫(§2.7):reload 须真实摘除模块——unchanged 即「锁摘了但装配没掉挂载」,
253
262
  // 正是本 PR 修复的回归;api 模块还须 applied 含 -name@ver(web-only 无 api 段,oj 不产模块条目,仅校验 unchanged)。
254
263
  const hasApi = await uninstallHasApi(name, version, input.sharedRoot);
255
- const a = assertReloadApplied(r, { hasApi, kind: "uninstall", name, version });
264
+ const a = assertReloadApplied(r, { hasApi, kind: "uninstall", name, version, lockRemoved: lockEntryIsSolution });
256
265
  if (!a.ok) {
257
266
  try {
258
267
  await writeLock(input.lockPath, lock);
@@ -25,6 +25,10 @@ export interface UserRow {
25
25
  display_name: string | null
26
26
  email: string | null
27
27
  position: string | null
28
+ /** 工号(tenant 内唯一,未设置即 null)——U3:列表接口透出 users 表既有列 */
29
+ employee_no: string | null
30
+ /** 手机号(未设置即 null)——U3:列表接口透出 users 表既有列 */
31
+ phone_number: string | null
28
32
  /** 所属部门(主部门在前) */
29
33
  departments: UserDeptRef[]
30
34
  is_forbidden: boolean
@@ -108,7 +112,7 @@ async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Pr
108
112
  }
109
113
 
110
114
  export async function listUsers(): Promise<UserRow[]> {
111
- const rows = await db.table("users").select(["id", "username", "display_name", "email", "position", "is_forbidden", "must_change"]).orderBy([{ field: "username" }]).all() as Array<Record<string, unknown>>;
115
+ const rows = await db.table("users").select(["id", "username", "display_name", "email", "position", "employee_no", "phone_number", "is_forbidden", "must_change"]).orderBy([{ field: "username" }]).all() as Array<Record<string, unknown>>;
112
116
  const roleRows = await db.table("sys_user_role").select(["user_id", "role_name"]).all() as Array<{ user_id: number, role_name: string }>;
113
117
  const roleMap = new Map<number, string[]>();
114
118
  for (const r of roleRows)
@@ -120,6 +124,8 @@ export async function listUsers(): Promise<UserRow[]> {
120
124
  display_name: r.display_name == null ? null : String(r.display_name),
121
125
  email: r.email == null ? null : String(r.email),
122
126
  position: r.position == null ? null : String(r.position),
127
+ employee_no: r.employee_no == null ? null : String(r.employee_no),
128
+ phone_number: r.phone_number == null ? null : String(r.phone_number),
123
129
  departments: depts.get(Number(r.id)) ?? [],
124
130
  is_forbidden: Number(r.is_forbidden) === 1,
125
131
  must_change: Number(r.must_change) === 1,
@@ -82,6 +82,37 @@ export function withSiteOrigin(fetch: FetchInitFace, canonicalOrigin?: string |
82
82
  return (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => fetch(/^https?:\/\//i.test(url) ? url : `${o.proto}://${o.host}${url}`, init) as ReturnType<FetchFace>;
83
83
  }
84
84
 
85
+ /**
86
+ * 读站点 config.yaml `vars.<key>`(缺文件/缺字段/解析失败 → null)。
87
+ * 与 readCanonicalOrigin 同机制(fsFace + yaml 异步面 + 按 configPath 进程级缓存):
88
+ * vars 下部署期常量皆走此口,勿各写一份读盘。
89
+ */
90
+ const siteVarCache = new Map<string, Promise<string | null>>();
91
+
92
+ export function readSiteVar(configPath: string | undefined, key: string): Promise<string | null> {
93
+ if (!configPath) {
94
+ return Promise.resolve(null);
95
+ }
96
+ const cacheKey = `${configPath}::${key}`;
97
+ const cached = siteVarCache.get(cacheKey);
98
+ if (cached !== undefined) {
99
+ return cached;
100
+ }
101
+ const p = (async () => {
102
+ try {
103
+ const bytes = await fsFace().readFile(configPath);
104
+ const doc = await (globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } }).yaml?.parse(new TextDecoder().decode(bytes)) as { vars?: Record<string, unknown> } | undefined;
105
+ const v = doc?.vars?.[key];
106
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ })();
112
+ siteVarCache.set(cacheKey, p);
113
+ return p;
114
+ }
115
+
85
116
  /** 站点迁移账本 head(oj journal 真源 `_oj_migrations`;表缺失/查询失败 → null = 跳过禁降版判定,不猜) */
86
117
  export async function journalHeadOf(name: string): Promise<number | bigint | null> {
87
118
  try {