@oj-module/cli 0.2.66 → 0.2.67
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/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +30 -1
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +38 -12
- package/devkit/publish-walkthrough.md +110 -37
- package/devkit/wecom-internal-app.md +7 -2
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +17 -5
- package/src/dev.ts +15 -10
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +5 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/site.ts +2 -0
- package/src/usage.ts +8 -4
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +8 -3
- package/templates/openapi.json +460 -1
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -23,6 +23,22 @@ export interface InstallContext {
|
|
|
23
23
|
hostVersions?: Record<string, string>
|
|
24
24
|
/** 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;镜像 GET /api/_platform/market/meta 的 apiVersion,即 market/api.ts 的 API_VERSION 常量,与 _platform/manifest.yaml 版本手工同步) */
|
|
25
25
|
hostApiVersion?: string
|
|
26
|
+
/**
|
|
27
|
+
* 宿主能力集(P0 §3.9:ojInfo().capabilities,装配期冻结、任何模块可读)。由调用方从 ojInfo 注入;
|
|
28
|
+
* 与 manifest.requiresCapabilities 配对做写锁前门禁。缺省 undefined = 宿主未上报,
|
|
29
|
+
* 此时若模板声明了 requiresCapabilities 则 fail-closed(视为能力缺失)。
|
|
30
|
+
*/
|
|
31
|
+
capabilities?: string[]
|
|
32
|
+
/**
|
|
33
|
+
* 站点 config.yaml 已存在的 auth.anonymous_paths(P0 §3.2 质辩1 / E8:装前残留匿名检测真源)。
|
|
34
|
+
* 由调用方在 preflight 前读 config 注入(preflight 保持只读 ctx 的纯校验段纪律);缺省 [] 视为无残留。
|
|
35
|
+
*/
|
|
36
|
+
existingAnonymousPaths?: string[]
|
|
37
|
+
/**
|
|
38
|
+
* 本次热更返回的待重启段(reloading 段捕获自 reloadAndAssert;非空 = 部分生效、需宿主重启后完全生效)。
|
|
39
|
+
* installer 终态判定据其标 partial + restartRequired(无守护形态,P0 §3.6 / 验收 15)。瞬态,不落盘。
|
|
40
|
+
*/
|
|
41
|
+
restartPending?: string[]
|
|
26
42
|
/** 目标站点数据库句柄(本段不消费;P1-8 register 段起使用) */
|
|
27
43
|
db?: unknown
|
|
28
44
|
/**
|
|
@@ -31,6 +47,12 @@ export interface InstallContext {
|
|
|
31
47
|
* 跨方案碰撞仍由 apiSeg/route_prefix 首段占用兜住;缺省 false 保持 P1-5「同名拒装」钉面。
|
|
32
48
|
*/
|
|
33
49
|
reinstall?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* 强制热更(oj 0.1.123+ reloadConfig(force)):绕过「unchanged=已是最新装配」幂等短路,
|
|
52
|
+
* 强制触发一次重装配——运维显式重装配用,不依赖锁 diff / 文档 sha 变化。缺省 false。
|
|
53
|
+
* 仅供 install 路径消费(reloading 段透传给 reload 面);uninstall 恒严格(忽略 force)。
|
|
54
|
+
*/
|
|
55
|
+
force?: boolean
|
|
34
56
|
}
|
|
35
57
|
|
|
36
58
|
/** 阶段结果(全 stage 通用结构形状;stage 取值为各 step 自己的字面量,编排层以 StageDef.name 为准) */
|
|
@@ -87,8 +109,8 @@ export interface TaskRecord {
|
|
|
87
109
|
/** 0-100 整体进度(按 STAGES 表完成数折算) */
|
|
88
110
|
progress: number
|
|
89
111
|
message: string
|
|
90
|
-
/** 各 stage 明细日志(UI
|
|
91
|
-
stages: { stage:
|
|
112
|
+
/** 各 stage 明细日志(UI 详情日志区数据源;补偿子步形如 `compensate.<step>`,故 stage 为 string 而非窄类型 InstallStage) */
|
|
113
|
+
stages: { stage: string, ok: boolean, message: string }[]
|
|
92
114
|
/** 失败/partial 终态:reason + retryable + 指引文案 key(契约附录 A) */
|
|
93
115
|
error?: { reason: string, retryable: boolean, hintKey?: string }
|
|
94
116
|
/** needs_trust 终态:待确认的发布方 key(POST /market/trust {task_id} 消费;指纹供人确认) */
|
|
@@ -99,4 +121,6 @@ export interface TaskRecord {
|
|
|
99
121
|
appliedSeq?: number
|
|
100
122
|
/** 取消安装标记(U5,P3-8):用户中止触发的终态(status 仍取 failed/partial——D11 共用失败终态,此标记供 UI 区分「取消」与「失败」) */
|
|
101
123
|
cancelled?: boolean
|
|
124
|
+
/** 无守护形态部分生效标记(P0 §3.6 / 验收 15):热更返回待重启段非空、且无 supervisor 自动换进程 → 装完仍须宿主重启才完全生效。UI 据其展示「重启提示」而非「成功」 */
|
|
125
|
+
restartRequired?: boolean
|
|
102
126
|
}
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import type { OpEntry } from "../_shared/service/infra/audit";
|
|
11
11
|
import type { StateStore } from "./state";
|
|
12
|
+
import type { ReloadFace, ReloadResult } from "./steps/reload";
|
|
12
13
|
import { uninstallSolution } from "../_shared/service/domain/solution-perms";
|
|
13
14
|
import { invalidateAllUsers } from "../_shared/service/infra/access";
|
|
14
15
|
import { auditOp } from "../_shared/service/infra/audit";
|
|
15
16
|
import { atomicWriteText, fsFace } from "./atomic";
|
|
16
17
|
import { recordHistory } from "./history";
|
|
17
18
|
import { readLock, writeLock } from "./lock";
|
|
19
|
+
import { assertReloadApplied } from "./steps/reload";
|
|
18
20
|
|
|
19
21
|
// db 为 oj isolate 注入全局(同模板 service 层惯例,installer.ts 同款;测试由夹具装 globalThis.db)
|
|
20
22
|
declare const db: {
|
|
@@ -65,6 +67,21 @@ export function moduleTablePrefix(name: string): string {
|
|
|
65
67
|
return `${name.replace(/-/g, "_")}_`;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 卸载期判断方案是否含 api 模块(决定 oj 是否产出 -name@ver 装配条目):读版本目录内 C2 manifest.yaml
|
|
72
|
+
* 的顶层 `api:` 段。reload 断言时版本目录仍在(删除在 reload 之后),故可读。读失败/无 api 段 → false
|
|
73
|
+
* (web-only 方案 oj 不产模块条目,符号不强制,仅校验 reload 已探测锁变更)。
|
|
74
|
+
*/
|
|
75
|
+
async function uninstallHasApi(name: string, version: string, sharedRoot: string): Promise<boolean> {
|
|
76
|
+
try {
|
|
77
|
+
const text = new TextDecoder().decode(await fsFace().readFile(`${sharedRoot}/${name}-${version}/manifest.yaml`));
|
|
78
|
+
return /^api\s*:/m.test(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
/** 收集需保留的业务表(Q4):声明 + 前缀兜底;仅返回实际存在的表名 */
|
|
69
86
|
export async function collectRetainedTables(name: string, manifestPath: string): Promise<string[]> {
|
|
70
87
|
const candidates = new Set<string>();
|
|
@@ -91,6 +108,18 @@ export async function collectRetainedTables(name: string, manifestPath: string):
|
|
|
91
108
|
return [...candidates].filter(t => existing.has(t));
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 回退某方案的 DB 注册(P0 补偿清单 §3.5 复用):单一事务内删 sys_solution_route + 级联清
|
|
113
|
+
* 角色/权限(uninstallSolution 双维度级联)。与 runUninstall 的 DB 步同原语,避免两套 DB 回滚逻辑。
|
|
114
|
+
* 行已不存在 = no-op(幂等安全)。
|
|
115
|
+
*/
|
|
116
|
+
export async function revertRegistrations(name: string): Promise<void> {
|
|
117
|
+
await db.tx(async (t) => {
|
|
118
|
+
await t.table("sys_solution_route").where({ field: "module", op: "eq", value: name }).delete().run();
|
|
119
|
+
await uninstallSolution(name, t);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
/** 卸载影响面预检(GET uninstall-impact 消费面):返回前端二次确认所需的影响计数 */
|
|
95
124
|
export interface UninstallImpact {
|
|
96
125
|
name: string
|
|
@@ -111,6 +140,8 @@ export interface UninstallInput {
|
|
|
111
140
|
now?: number
|
|
112
141
|
/** 审计出口(缺省 auditOp;测试注入记录器) */
|
|
113
142
|
audit?: (entry: OpEntry) => void
|
|
143
|
+
/** 热更面(P0 §3.4 第5步:卸载摘锁后 reload 触达装配以真正摘除模块;缺省不 reload——依赖 US-1 上游 manifests 摘要驱动才实际掉挂载)。reload 失败 → 锁写回 + 再 reload 回旧代(模块仍挂载、可重试) */
|
|
144
|
+
reload?: ReloadFace
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
export interface UninstallResult {
|
|
@@ -184,16 +215,73 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
|
|
|
184
215
|
return { ok: false, message: `卸载失败:权限级联清理失败(${String(e)})——事务已回滚,锁/路由/角色/权限未改动(fail-closed)` };
|
|
185
216
|
}
|
|
186
217
|
|
|
187
|
-
//
|
|
218
|
+
// ① 锁摘除(先失可达性:模块停止装配;在此之前任一失败锁均保留 = fail-closed)
|
|
219
|
+
try {
|
|
220
|
+
await writeLock(input.lockPath, remaining);
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
audit(false, `锁写失败:${String(e)}`);
|
|
224
|
+
return { ok: false, message: `卸载失败:锁写失败(${String(e)})——DB 级联已回滚、sidecar/modules 未动(fail-closed)` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ② reload 触达装配以真正摘除模块(P0 §3.4 第5步:先失可达性);缺省无 reload 面则跳过(依赖
|
|
228
|
+
// US-1 上游 manifests 摘要驱动才实际掉挂载)。reload 失败 / 返回 unchanged / 未真正摘除模块 →
|
|
229
|
+
// 锁写回 + 再 reload 回旧代——模块仍挂载,且此时 sidecar/modules.json/版本目录均未删(下面才删),
|
|
230
|
+
// 故锁写回即**一致回滚**,不残留半卸状态,返回失败可重试(fail-closed)。
|
|
231
|
+
if (input.reload !== undefined) {
|
|
232
|
+
let r: ReloadResult;
|
|
233
|
+
try {
|
|
234
|
+
r = await input.reload.reloadConfig();
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
try {
|
|
238
|
+
await writeLock(input.lockPath, lock);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// 锁写回失败不掩盖 reload 失败终态
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await input.reload.reloadConfig();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// 回滚 reload 也失败:模块仍按原锁挂载,下次卸载重试
|
|
248
|
+
}
|
|
249
|
+
audit(false, `reload 失败,已回写锁并恢复旧代:${String(e)}`);
|
|
250
|
+
return { ok: false, message: `卸载失败:reload 失败(${String(e)})——已回写锁恢复旧代(模块仍挂载、元数据未删),请稍后重试` };
|
|
251
|
+
}
|
|
252
|
+
// 卸载空转回归守卫(§2.7):reload 须真实摘除模块——unchanged 即「锁摘了但装配没掉挂载」,
|
|
253
|
+
// 正是本 PR 修复的回归;api 模块还须 applied 含 -name@ver(web-only 无 api 段,oj 不产模块条目,仅校验 unchanged)。
|
|
254
|
+
const hasApi = await uninstallHasApi(name, version, input.sharedRoot);
|
|
255
|
+
const a = assertReloadApplied(r, { hasApi, kind: "uninstall", name, version });
|
|
256
|
+
if (!a.ok) {
|
|
257
|
+
try {
|
|
258
|
+
await writeLock(input.lockPath, lock);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// 锁写回失败不掩盖 reload 断言失败终态
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
await input.reload.reloadConfig();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// 回滚 reload 也失败:模块仍按原锁挂载,下次卸载重试
|
|
268
|
+
}
|
|
269
|
+
audit(false, `reload 未真正摘除模块,已回写锁并恢复旧代:${a.message}`);
|
|
270
|
+
return { ok: false, message: `卸载失败:${a.message}——已回写锁恢复旧代(模块仍挂载、元数据未删),请稍后重试` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ③ 再失文件前先清元数据:sidecar 删条目 / modules.json 删条目(reload 成功才走到这里,回滚已不可达)
|
|
275
|
+
// sidecar 删条目(patch 值 null = 删除该方案键)
|
|
188
276
|
try {
|
|
189
277
|
await input.state.write({ solutions: { [name]: null } } as unknown as Parameters<typeof input.state.write>[0]);
|
|
190
278
|
}
|
|
191
279
|
catch (e) {
|
|
192
280
|
audit(false, `sidecar 写失败:${String(e)}`);
|
|
193
|
-
return { ok: false, message: `卸载失败:sidecar 写失败(${String(e)}
|
|
281
|
+
return { ok: false, message: `卸载失败:sidecar 写失败(${String(e)})——锁已摘、reload 已生效,模块已停装配;sidecar 残留待重装自愈` };
|
|
194
282
|
}
|
|
195
283
|
|
|
196
|
-
//
|
|
284
|
+
// modules.json 删条目(条目不存在 = api-only 方案,天然一致)
|
|
197
285
|
try {
|
|
198
286
|
const list = JSON.parse(new TextDecoder().decode(await f.readFile(input.modulesJsonPath))) as unknown[];
|
|
199
287
|
if (!Array.isArray(list)) {
|
|
@@ -204,19 +292,10 @@ export async function runUninstall(input: UninstallInput): Promise<UninstallResu
|
|
|
204
292
|
}
|
|
205
293
|
catch (e) {
|
|
206
294
|
audit(false, `modules.json 写失败:${String(e)}`);
|
|
207
|
-
return { ok: false, message: `卸载失败:modules.json 写失败(${String(e)}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ① 锁摘除(最后执行:模块停止装配;在此之前任一失败锁均保留 = fail-closed)
|
|
211
|
-
try {
|
|
212
|
-
await writeLock(input.lockPath, remaining);
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
audit(false, `锁写失败:${String(e)}`);
|
|
216
|
-
return { ok: false, message: `卸载失败:锁写失败(${String(e)})——DB 级联/sidecar/modules 已更新,锁残留` };
|
|
295
|
+
return { ok: false, message: `卸载失败:modules.json 写失败(${String(e)})——锁已摘、reload 已生效、sidecar 已删,modules.json 残留待重装自愈` };
|
|
217
296
|
}
|
|
218
297
|
|
|
219
|
-
//
|
|
298
|
+
// ④ 版本目录移除(缺失不报错,幂等);再失文件(P0 §3.4:先失可达性再失文件)
|
|
220
299
|
// —— 但 Q4 业务表清单须在版本目录移除前从 manifest 取得(manifest 在版本目录内)——
|
|
221
300
|
let retained: string[] = [];
|
|
222
301
|
try {
|
|
@@ -68,11 +68,15 @@ export async function updateSetting(actorId: number, key: string, value: string)
|
|
|
68
68
|
await recordOp({ action: "setting.update", module: String(row.module), target: key });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
/**
|
|
72
|
+
* C5/D2:登录方式发现的数据源——local 恒在,ldap/sso/wecom 由设置项开关。
|
|
73
|
+
* 同时回传站点标题(framework.site_title,非 secret),供登录页匿名设置浏览器标签。
|
|
74
|
+
*/
|
|
75
|
+
export async function listLoginMethods(): Promise<{ methods: string[], site_title: string }> {
|
|
76
|
+
const rows = await db.table("sys_setting").select(["key", "value"]).where({ field: "key", op: "in", value: ["ldap.enabled", "sso.enabled", "wecom.enabled", "framework.site_title"] }).all() as Array<{ key: string, value: string | null }>;
|
|
74
77
|
const enabled = (k: string) => rows.find(r => r.key === k)?.value === "1";
|
|
78
|
+
const siteTitle = rows.find(r => r.key === "framework.site_title")?.value ?? "";
|
|
75
79
|
// wecom 的凭据在部署常量(config vars),这里只报开关:开关开了但凭据没配,
|
|
76
80
|
// 点进企微登录会拿到 400「企业微信未配置」——比登录页凭空少一个 tab 更容易排查。
|
|
77
|
-
return { methods: ["local", ...(enabled("ldap.enabled") ? ["ldap" as const] : []), ...(enabled("sso.enabled") ? ["sso" as const] : []), ...(enabled("wecom.enabled") ? ["wecom" as const] : [])] };
|
|
81
|
+
return { methods: ["local", ...(enabled("ldap.enabled") ? ["ldap" as const] : []), ...(enabled("sso.enabled") ? ["sso" as const] : []), ...(enabled("wecom.enabled") ? ["wecom" as const] : [])], site_title: siteTitle };
|
|
78
82
|
}
|
|
@@ -33,6 +33,7 @@ import { createTrustStore } from "../_lifecycle/trust";
|
|
|
33
33
|
import { computeUninstallImpact, runUninstall } from "../_lifecycle/uninstall";
|
|
34
34
|
import { respond } from "../_shared/respond";
|
|
35
35
|
import { assertSuperAdmin } from "../_shared/service/infra/access";
|
|
36
|
+
import { pageArgs } from "../_shared/service/infra/query";
|
|
36
37
|
import { journalHeadOf, readCanonicalOrigin, siteOrigin, withSiteOrigin } from "../_shared/site-facade";
|
|
37
38
|
import { BOOTSTRAP_KEY, seedBootstrapKey } from "./bootstrap";
|
|
38
39
|
import { httpIndexSource, indexPathOf } from "./index-source";
|
|
@@ -64,7 +65,7 @@ export interface MarketEnv {
|
|
|
64
65
|
modulesJsonPath: string
|
|
65
66
|
/** API 前缀(smoke 探针 URL 前缀) */
|
|
66
67
|
apiPrefix: string
|
|
67
|
-
/** 站点 config.yaml 路径(reloading 段
|
|
68
|
+
/** 站点 config.yaml 路径(reloading 段 anonymous_paths 接线落盘处;缺省 config.yaml) */
|
|
68
69
|
configPath?: string
|
|
69
70
|
/** canonical origin(REG-CS6:相对 URL 补全用,取自 config.yaml vars.canonical_origin,绝不读请求头 Host) */
|
|
70
71
|
canonicalOrigin?: string
|
|
@@ -236,7 +237,14 @@ function execOf(env: MarketEnv): ExecFace {
|
|
|
236
237
|
|
|
237
238
|
function reloadOf(env: MarketEnv): ReloadFace {
|
|
238
239
|
const g = globalThis as { server?: { reloadConfig: ReloadFace["reloadConfig"] } };
|
|
239
|
-
|
|
240
|
+
// env 显式注入优先;其次平台 server.reloadConfig(生产 oj 提供,oj 0.1.123+ 支持 force 透传);
|
|
241
|
+
// 均缺失(dev/test 无 server)回落 no-op——等价于「不 reload」,保持卸载旧语义(reload 实际掉
|
|
242
|
+
// 挂载依赖 US-1 上游 manifests 摘要驱动,未到位前回落 no-op 不阻断卸载主流程);force 透传给宿主。
|
|
243
|
+
if (env.reload !== undefined)
|
|
244
|
+
return env.reload;
|
|
245
|
+
if (g.server?.reloadConfig !== undefined)
|
|
246
|
+
return { reloadConfig: (force?: boolean) => g.server!.reloadConfig(force) };
|
|
247
|
+
return { reloadConfig: async () => ({ unchanged: true, applied: [], restart_pending: [] }) };
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
function sha384Of(env: MarketEnv): (data: Uint8Array) => string {
|
|
@@ -249,6 +257,23 @@ function sha256Of(env: MarketEnv): (data: Uint8Array) => string {
|
|
|
249
257
|
return env.sha256Hex ?? ((data: Uint8Array) => g.crypto!.sha256Hex!(data));
|
|
250
258
|
}
|
|
251
259
|
|
|
260
|
+
/**
|
|
261
|
+
* 宿主能力集(P0 §3.9 / §2.3):从 ojInfo().build.capabilities 读取(v0.1.122 增量能力位闭集:
|
|
262
|
+
* reload.assemble_modules / reload.anon_per_reload / reload.cron_survives)。注入安装上下文后,
|
|
263
|
+
* preflight 据 manifest.requiresCapabilities 做写锁前门禁——宿主缺能力即 fail-closed(零痕迹)。
|
|
264
|
+
* ojInfo 不可用时回落空数组(等同未上报 → 若模板声明了 requiresCapabilities 则拒装)。
|
|
265
|
+
*/
|
|
266
|
+
function capabilitiesOf(): string[] {
|
|
267
|
+
try {
|
|
268
|
+
const info = (globalThis as { ojInfo?: () => any }).ojInfo?.();
|
|
269
|
+
const caps = info?.build?.capabilities;
|
|
270
|
+
return Array.isArray(caps) ? caps : [];
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
252
277
|
/** CDN 基址:注入面优先,缺省读设置表(值/缺省值俱空 = 未配置;表异常视同未配置,消费端给可读错误) */
|
|
253
278
|
async function cdnBaseOf(env: MarketEnv): Promise<string | null> {
|
|
254
279
|
if (env.cdnBase) {
|
|
@@ -694,6 +719,8 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
694
719
|
if (name === "")
|
|
695
720
|
fail(400, "name is required");
|
|
696
721
|
const reqVersion = body.version == null || body.version === "" ? null : String(body.version);
|
|
722
|
+
// force 透传(oj 0.1.123+ reloadConfig(force)):body.force 或 query.force=1 任一为真即强制重装配
|
|
723
|
+
const force = body.force === true || String((http as { query?: Record<string, unknown> }).query?.force ?? "") === "1";
|
|
697
724
|
|
|
698
725
|
let manifest: unknown;
|
|
699
726
|
let version: string;
|
|
@@ -786,7 +813,10 @@ async function installBody(env: MarketEnv): Promise<{ task_id: string }> {
|
|
|
786
813
|
hostVersions: await hostVersionsOf(env),
|
|
787
814
|
// 宿主平台编排面 API 版本(C2-11 peerPlatform 比对真源;与 meta 端点 apiVersion、_platform/manifest.yaml 版本同源,手工同步)
|
|
788
815
|
hostApiVersion: API_VERSION,
|
|
816
|
+
// 宿主能力集(P0 §3.9):ojInfo().build.capabilities,preflight 的 requiresCapabilities 门禁据此生效
|
|
817
|
+
capabilities: capabilitiesOf(),
|
|
789
818
|
trustPath: env.trustPath,
|
|
819
|
+
force,
|
|
790
820
|
registryId: env.registryId,
|
|
791
821
|
cancel: () => {
|
|
792
822
|
if (!cancelled) {
|
|
@@ -818,10 +848,16 @@ interface IndexRow {
|
|
|
818
848
|
available: string[]
|
|
819
849
|
/** 最新版 changelog(C2-08,R27 卡片版本对比;分片条目缺 changelog → null) */
|
|
820
850
|
changelog: unknown
|
|
821
|
-
/** 最新版发布方(manifest author,C2-07;分片条目缺 author → null
|
|
851
|
+
/** 最新版发布方(manifest author,C2-07;分片条目缺 author → null,自报字段) */
|
|
822
852
|
publisher: string | null
|
|
853
|
+
/** 最新版包级签名 key_id(Form B archive.signature.key_id,经 Ed25519 验签的发布身份;缺签名 → null) */
|
|
854
|
+
publisher_key: string | null
|
|
823
855
|
/** 最新版许可(manifest license,C2-07 SPDX 简化式;分片条目缺/非字符串 → null) */
|
|
824
856
|
license: string | null
|
|
857
|
+
/** 方案简介(manifest summary,C2-04 单行 1..200;分片条目缺/非字符串 → null) */
|
|
858
|
+
summary: string | null
|
|
859
|
+
/** 方案详细描述(manifest description,C2-05 ≤2000;分片条目缺/非字符串 → null) */
|
|
860
|
+
description: string | null
|
|
825
861
|
/** 本地安装的发布方 key 状态(P2-6 sidecar;未装 → null) */
|
|
826
862
|
trust_state: "trusted" | "untrusted" | "absent" | null
|
|
827
863
|
/** 本地安装记录的签名 key id(sidecar archive.key_id;未装/P1 时代条目 → null) */
|
|
@@ -853,19 +889,32 @@ async function lastInstallOperatorOf(name: string): Promise<string | null> {
|
|
|
853
889
|
*
|
|
854
890
|
* 三级化后市场页的拉取形态:1 catalog + catalog 指向的全部分片(去重)——`available`
|
|
855
891
|
* 回滚候选要版本全集,故页面比安装多拉几片;安装侧仍 ≤ 1 catalog + 1 shard(resolveCdn)。
|
|
856
|
-
* 版本条目即 Form B manifest:latest 的 changelog/publisher
|
|
857
|
-
*
|
|
892
|
+
* 版本条目即 Form B manifest:latest 的 changelog/publisher/summary/description 直接从
|
|
893
|
+
* 分片条目取,不再有独立的聚合清单拉取。分片拉取/验签失败即 502(REG-CS1:可用性不再
|
|
894
|
+
* 压过完整性)。
|
|
858
895
|
*/
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
896
|
+
interface IndexSnapshot {
|
|
897
|
+
base: string
|
|
898
|
+
/** catalog 的签名时效(发布时间兜底;索引缺时效 → null) */
|
|
899
|
+
issuedAt: number | null
|
|
900
|
+
solutions: Array<{ name: string, shards: number[] }>
|
|
901
|
+
shardDocs: Map<number, ShardSolution[]>
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* 读整份签名索引(catalog + 它指向的全部分片)。
|
|
906
|
+
*
|
|
907
|
+
* 市场卡片(indexBody)与发布历史(syncReleases)都要「全量版本视图」,读取与验签只有这一份:
|
|
908
|
+
* 两处各写一遍 `fetchIndex` 循环,迟早漂移成「卡片看得见、历史看不见」。
|
|
909
|
+
*/
|
|
910
|
+
async function readIndexSnapshot(env: MarketEnv): Promise<IndexSnapshot> {
|
|
863
911
|
const base = await cdnBaseOf(env);
|
|
864
912
|
if (base === null)
|
|
865
913
|
fail(503, "未配置市场地址(framework.market_cdn_url)——卡片区显示「未配置市场地址」(PRD §5.3)");
|
|
866
914
|
await assertOriginForRelativeBase(env, base);
|
|
867
915
|
const source = await indexSourceOf(env, base);
|
|
868
916
|
const catalog = await fetchIndex(env, source, base, "catalog");
|
|
917
|
+
const times = await indexTimesOfText(catalog.text);
|
|
869
918
|
const { pins, solutions } = await parseCatalog(catalog.text, base);
|
|
870
919
|
// 拉取 catalog 指向的全部分片(去重、升序确定性);每片都过签名 + 钉值同代校验
|
|
871
920
|
const shardDocs = new Map<number, ShardSolution[]>();
|
|
@@ -873,6 +922,14 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
873
922
|
const shard = await fetchIndex(env, source, base, "shard", no, pins.get(no)!);
|
|
874
923
|
shardDocs.set(no, await parseShard(shard.text, `shards/${no}.yaml`));
|
|
875
924
|
}
|
|
925
|
+
return { base, issuedAt: times?.issued_at ?? null, solutions, shardDocs };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
929
|
+
// 对账即服务(P1-10 接线③):市场页刷新即对账时点——锁 ↔ 账本 head 自愈 + enabled 不一致
|
|
930
|
+
// 自动修复先行(失败只记日志,不阻断索引),失败站点的市场页每次打开都在尝试自愈
|
|
931
|
+
await reconcileOf(env);
|
|
932
|
+
const { solutions, shardDocs } = await readIndexSnapshot(env);
|
|
876
933
|
const local = (await createStateStore(env.statePath)).read().solutions;
|
|
877
934
|
const rows: IndexRow[] = [];
|
|
878
935
|
const seen = new Set<string>();
|
|
@@ -898,6 +955,11 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
898
955
|
const manifestOf = (version: string | null): Record<string, unknown> | undefined =>
|
|
899
956
|
version === null ? undefined : merged.find(v => String(v.version) === version);
|
|
900
957
|
const m = manifestOf(latest) ?? manifestOf(side?.version ?? null);
|
|
958
|
+
// 发布者:取最新版 Form B archive.signature.key_id(Ed25519 验签过的签名身份,离线可验);
|
|
959
|
+
// manifest.author 是自报字段,二者不同。缺签名/畸形 → null(不拿自报值冒充)。
|
|
960
|
+
const rawArchive: unknown = m?.archive;
|
|
961
|
+
const rawSig: unknown = isObj(rawArchive) ? rawArchive.signature : undefined;
|
|
962
|
+
const publisherKey = isObj(rawSig) && typeof rawSig.key_id === "string" ? rawSig.key_id : null;
|
|
901
963
|
rows.push({
|
|
902
964
|
name,
|
|
903
965
|
latest,
|
|
@@ -907,7 +969,10 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
907
969
|
available,
|
|
908
970
|
changelog: m?.changelog ?? null,
|
|
909
971
|
publisher: typeof m?.author === "string" ? m.author : null,
|
|
972
|
+
publisher_key: publisherKey,
|
|
910
973
|
license: typeof m?.license === "string" ? m.license : null,
|
|
974
|
+
summary: typeof m?.summary === "string" ? m.summary : null,
|
|
975
|
+
description: typeof m?.description === "string" ? m.description : null,
|
|
911
976
|
trust_state: side?.trust_state ?? null,
|
|
912
977
|
key_id: side?.archive?.key_id ?? null,
|
|
913
978
|
operator: await lastInstallOperatorOf(name),
|
|
@@ -924,7 +989,10 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
924
989
|
available: [],
|
|
925
990
|
changelog: null,
|
|
926
991
|
publisher: null,
|
|
992
|
+
publisher_key: null,
|
|
927
993
|
license: null,
|
|
994
|
+
summary: null,
|
|
995
|
+
description: null,
|
|
928
996
|
trust_state: side.trust_state ?? null,
|
|
929
997
|
key_id: side.archive?.key_id ?? null,
|
|
930
998
|
operator: await lastInstallOperatorOf(name),
|
|
@@ -934,6 +1002,106 @@ async function indexBody(env: MarketEnv): Promise<IndexRow[]> {
|
|
|
934
1002
|
return rows;
|
|
935
1003
|
}
|
|
936
1004
|
|
|
1005
|
+
/** 发布历史行(市场索引里出现过的每个 product@version) */
|
|
1006
|
+
interface ReleaseRow {
|
|
1007
|
+
id: number
|
|
1008
|
+
product: string
|
|
1009
|
+
version: string
|
|
1010
|
+
key_id: string | null
|
|
1011
|
+
sha256: string | null
|
|
1012
|
+
size: number | null
|
|
1013
|
+
published_at: number
|
|
1014
|
+
/** 时间来源:file = tgz 的 Last-Modified;index = 索引生成时间兜底 */
|
|
1015
|
+
published_at_source: "file" | "index"
|
|
1016
|
+
yanked: number
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* 取单版本的「发布时间」。
|
|
1021
|
+
*
|
|
1022
|
+
* 优先 HEAD 分发包读 `Last-Modified`——那是包落进 CDN 的时刻,也就是真正的发版时间。
|
|
1023
|
+
* 读不到(源不吐该头 / 网络失败 / file 名缺失)才回落到索引生成时间:`ojm market publish`
|
|
1024
|
+
* 与 `build-index` 在推荐流程里紧邻执行,两者差值只有重建索引的耗时。
|
|
1025
|
+
*
|
|
1026
|
+
* 只对**首次入库**的版本调一次(已入库的 published_at 不再改写)——历史一旦记录就是事实,
|
|
1027
|
+
* 不该被后续重新发布/重新建索引刷新。
|
|
1028
|
+
*/
|
|
1029
|
+
async function archivePublishedAt(env: MarketEnv, base: string, file: unknown, fallback: number): Promise<{ at: number, source: "file" | "index" }> {
|
|
1030
|
+
if (typeof file !== "string" || file === "")
|
|
1031
|
+
return { at: fallback, source: "index" };
|
|
1032
|
+
try {
|
|
1033
|
+
// HEAD 不在 FetchInitFace 的最小形状里(安装面只需要 GET),但运行时 fetch 原生支持:
|
|
1034
|
+
// 这里显式窄化一次,避免为「读一个头」再造第二种下载实现。
|
|
1035
|
+
const head = await fetchOf(env) as unknown as (url: string, init?: { method?: string }) => Promise<{ headers?: { get: (n: string) => string | null } }>;
|
|
1036
|
+
const res = await head(`${base}/${encodeURIComponent(file)}`, { method: "HEAD" });
|
|
1037
|
+
const raw = res.headers?.get?.("last-modified") ?? res.headers?.get?.("Last-Modified") ?? null;
|
|
1038
|
+
if (raw !== null && raw !== "") {
|
|
1039
|
+
const at = Math.floor(Date.parse(raw) / 1000);
|
|
1040
|
+
if (Number.isFinite(at) && at > 0)
|
|
1041
|
+
return { at, source: "file" };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch {}
|
|
1045
|
+
return { at: fallback, source: "index" };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* 把索引里出现过的版本同步进 `market_release`(幂等:只补新行)。
|
|
1050
|
+
*
|
|
1051
|
+
* 为什么不做成「发版时由 CLI 上报」:`ojm market publish` 是纯本地操作(不登录、可离线),
|
|
1052
|
+
* 加一条上报链路就得再造一套写端授权的信任面;而**签名索引本身就是发版事实**——
|
|
1053
|
+
* 站点已经在读它,历史直接从这里落库,既不用新协议,也不会出现「上报成功但包里没有」的分叉。
|
|
1054
|
+
*
|
|
1055
|
+
* 索引拉取/验签失败由调用方兜住(历史是观测面,不阻断页面:返回已入库行 + syncError)。
|
|
1056
|
+
*/
|
|
1057
|
+
async function syncReleases(env: MarketEnv): Promise<void> {
|
|
1058
|
+
const snapshot = await readIndexSnapshot(env);
|
|
1059
|
+
const fallbackAt = snapshot.issuedAt ?? (env.now?.() ?? Math.floor(Date.now() / 1000));
|
|
1060
|
+
const now = env.now?.() ?? Math.floor(Date.now() / 1000);
|
|
1061
|
+
|
|
1062
|
+
const existingRows = await db.table("market_release").select(["id", "product", "version", "yanked"]).all() as Array<Record<string, unknown>>;
|
|
1063
|
+
const existing = new Map(existingRows.map(r => [`${String(r.product)}@${String(r.version)}`, { id: Number(r.id), yanked: Number(r.yanked) }]));
|
|
1064
|
+
|
|
1065
|
+
for (const sol of snapshot.shardDocs.values()) {
|
|
1066
|
+
for (const entry of sol) {
|
|
1067
|
+
for (const v of entry.versions) {
|
|
1068
|
+
const version = typeof v.version === "string" ? v.version : "";
|
|
1069
|
+
if (version === "")
|
|
1070
|
+
continue;
|
|
1071
|
+
const dedupeKey = `${entry.name}@${version}`;
|
|
1072
|
+
const yanked = v.yanked === true ? 1 : 0;
|
|
1073
|
+
const prior = existing.get(dedupeKey);
|
|
1074
|
+
if (prior !== undefined) {
|
|
1075
|
+
// 已入库的 published_at 是事实、不再改写;但 yank 状态是「当前是否在架」,跟随索引
|
|
1076
|
+
if (prior.yanked !== yanked)
|
|
1077
|
+
await db.table("market_release").update({ yanked }).where({ field: "id", op: "eq", value: prior.id }).run();
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const archive = isObj(v.archive) ? v.archive : {};
|
|
1081
|
+
const sig = isObj(archive.signature) ? archive.signature : {};
|
|
1082
|
+
const { at, source } = await archivePublishedAt(env, snapshot.base, archive.file, fallbackAt);
|
|
1083
|
+
try {
|
|
1084
|
+
await db.table("market_release").insert({
|
|
1085
|
+
product: entry.name,
|
|
1086
|
+
version,
|
|
1087
|
+
key_id: typeof sig.key_id === "string" ? sig.key_id : null,
|
|
1088
|
+
sha256: typeof archive.sha256 === "string" ? archive.sha256 : null,
|
|
1089
|
+
size: typeof archive.size === "number" ? archive.size : null,
|
|
1090
|
+
published_at: at,
|
|
1091
|
+
published_at_source: source,
|
|
1092
|
+
yanked,
|
|
1093
|
+
first_seen_at: now,
|
|
1094
|
+
}).run();
|
|
1095
|
+
existing.set(dedupeKey, { id: -1, yanked });
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
// 并发同步(两个页面同时打开)撞唯一键:另一路已写入,跳过即可
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
937
1105
|
/** 四端点组(oj v0.1.64 嵌套组:组名 = 路径段);全业务体经 respond 收口(status 语义不落 500) */
|
|
938
1106
|
export function createMarketApi(env: MarketEnv) {
|
|
939
1107
|
return {
|
|
@@ -1131,6 +1299,7 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1131
1299
|
state: await createStateStore(env.statePath),
|
|
1132
1300
|
modulesJsonPath: env.modulesJsonPath,
|
|
1133
1301
|
operator: (await operatorOf()) ?? undefined,
|
|
1302
|
+
reload: reloadOf(env),
|
|
1134
1303
|
});
|
|
1135
1304
|
// P3-11 宿主打磨:方案已不存在 = 资源缺失语义 → 404(非 200 {ok:false});
|
|
1136
1305
|
// 其余领域拒绝(空锁拒启/级联失败等)维持 200 {ok:false} 人话在 message。
|
|
@@ -1156,6 +1325,42 @@ export function createMarketApi(env: MarketEnv) {
|
|
|
1156
1325
|
});
|
|
1157
1326
|
},
|
|
1158
1327
|
},
|
|
1328
|
+
|
|
1329
|
+
// GET /api/_platform/market/releases —— 发布历史(登录即可读;与市场卡片同口径的「浏览开放」)。
|
|
1330
|
+
//
|
|
1331
|
+
// 数据源是签名索引本身:每次读取先把索引里出现过的 product@version 补进 market_release
|
|
1332
|
+
// (只补新行,published_at 一次写定),再按发布时间倒序分页返回。索引拉不到/验签失败
|
|
1333
|
+
// 不阻断查询:返回已入库行 + syncError,页面照常显示历史并提示「暂时读不到索引」。
|
|
1334
|
+
"releases": {
|
|
1335
|
+
async get() {
|
|
1336
|
+
await respond(async () => {
|
|
1337
|
+
const uid = (http as { user?: { id?: unknown } }).user?.id;
|
|
1338
|
+
if (uid == null)
|
|
1339
|
+
fail(401, "unauthorized");
|
|
1340
|
+
let syncError: string | null = null;
|
|
1341
|
+
try {
|
|
1342
|
+
await syncReleases(env);
|
|
1343
|
+
}
|
|
1344
|
+
catch (e) {
|
|
1345
|
+
syncError = e instanceof Error ? e.message : String(e);
|
|
1346
|
+
}
|
|
1347
|
+
const q = (http.query ?? {}) as Record<string, unknown>;
|
|
1348
|
+
const { page, size, offset } = pageArgs({ page: q.page, size: q.limit });
|
|
1349
|
+
const total = Number(
|
|
1350
|
+
(await db.table("market_release").select([{ fn: "count", as: "c" }]).all() as Array<Record<string, unknown>>)[0]?.c ?? 0,
|
|
1351
|
+
);
|
|
1352
|
+
const list = total === 0
|
|
1353
|
+
? []
|
|
1354
|
+
: await db.table("market_release")
|
|
1355
|
+
.select(["id", "product", "version", "key_id", "sha256", "size", "published_at", "published_at_source", "yanked"])
|
|
1356
|
+
.orderBy([{ field: "published_at", dir: "desc" }, { field: "id", dir: "desc" }])
|
|
1357
|
+
.limit(size)
|
|
1358
|
+
.offset(offset)
|
|
1359
|
+
.all() as unknown as ReleaseRow[];
|
|
1360
|
+
return { list, total, page, size, syncError };
|
|
1361
|
+
});
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1159
1364
|
};
|
|
1160
1365
|
}
|
|
1161
1366
|
|
|
@@ -1169,7 +1374,7 @@ marketApi.install.post.schema = {
|
|
|
1169
1374
|
body: {
|
|
1170
1375
|
type: "object",
|
|
1171
1376
|
required: ["source", "name"],
|
|
1172
|
-
properties: { source: { type: "string", enum: ["cdn", "file"] }, name: { type: "string" }, version: { type: ["string", "null"] } },
|
|
1377
|
+
properties: { source: { type: "string", enum: ["cdn", "file"] }, name: { type: "string" }, version: { type: ["string", "null"] }, force: { type: "boolean" } },
|
|
1173
1378
|
},
|
|
1174
1379
|
};
|
|
1175
1380
|
marketApi.install.post.response = { type: "object", properties: { task_id: { type: "string" } }, additionalProperties: true };
|
|
@@ -1193,5 +1398,25 @@ marketApi["uninstall-impact"].get.response = { type: "object", additionalPropert
|
|
|
1193
1398
|
marketApi.uninstall.post.schema = { body: { type: "object", required: ["name"], properties: { name: { type: "string" } } } };
|
|
1194
1399
|
marketApi.uninstall.post.response = { type: "object", additionalProperties: true };
|
|
1195
1400
|
marketApi.history.get.response = { type: "array", items: { type: "object", additionalProperties: true } };
|
|
1401
|
+
marketApi.releases.get.schema = {
|
|
1402
|
+
query: {
|
|
1403
|
+
type: "object",
|
|
1404
|
+
properties: {
|
|
1405
|
+
page: { type: "number", minimum: 1 },
|
|
1406
|
+
limit: { type: "number", minimum: 1, maximum: 200 },
|
|
1407
|
+
},
|
|
1408
|
+
},
|
|
1409
|
+
};
|
|
1410
|
+
marketApi.releases.get.response = {
|
|
1411
|
+
type: "object",
|
|
1412
|
+
required: ["list", "total", "page", "size"],
|
|
1413
|
+
properties: {
|
|
1414
|
+
list: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1415
|
+
total: { type: "number" },
|
|
1416
|
+
page: { type: "number" },
|
|
1417
|
+
size: { type: "number" },
|
|
1418
|
+
syncError: { type: ["string", "null"] },
|
|
1419
|
+
},
|
|
1420
|
+
};
|
|
1196
1421
|
|
|
1197
1422
|
export default marketApi;
|