feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# camofox 真机核验:起临时栈 umtbr → 分阶段跑 verify.ts(阶段间重启 / 杀 camofox)→ 结果写 $OUT_DIR。
# 结束后 `docker compose -p umtbr down -v`(镜像保留)。需要本机已有 camofox-browser:1.17.0 镜像(见 docs/runbook/browser.md §1)。
# 结束后 `docker compose -p umtbr down -v`(镜像保留)。需要本机已有 umt-browser-desktop:1.18.0 镜像(见 docs/runbook/browser.md §1)。
set -eu
cd "$(dirname "$0")/../.."
export POSTGRES_PASSWORD=umtbr-verify SECRET_MASTER_KEY=umtbr-verify-master-key-0123456789
+148 -79
View File
@@ -4,7 +4,7 @@
* Agent 能力)在本进程内装配,与生产同一套代码。分阶段运行,阶段之间由 run.sh 重启 / 杀掉 camofox 容器:
* setup 建 schema、站点、账号;浏览器服务走环境变量登记(camofox-env)
* a 公开静态页导航/截图;mock 站:登录保持(人工接管登录)、截图/快照、列表/详情提取、下载捕获、人与 Agent 同标签页
* b camofox 正常重启(SIGTERM)之后:旧标签页识别为 lost、登录状态是否恢复
* b camofox 正常重启(SIGTERM)之后:自动重连、旧引用被拒、登录状态是否恢复
* idle 会话空闲回收(SESSION_TIMEOUT_MS)之后:同上
* c 账号 2 由 Agent 用凭证注入登录(不关会话),随后 run.sh 用 SIGKILL 杀容器
* d SIGKILL 重启之后:账号 2 的登录是否还在
@@ -46,6 +46,9 @@ const DIRECT = env("MOCK_DIRECT", "http://127.0.0.1:48080");
const CTRL = env("MOCK_CTRL", "http://127.0.0.1:48081");
const PUBLIC_URL = env("PUBLIC_URL", "https://example.com/");
const OUT = env("OUT_DIR", "./browser-verify-out");
const EXPECTED_VERSION = env("VERIFY_CAMOFOX_VERSION", "1.18.0");
// SIGKILL 不执行 profile 落盘;新登录应明确识别为需登录,不能把它当作已恢复。
const KILL_LOGIN_REQUIRED = env("VERIFY_KILL_LOGIN_REQUIRED", "true") === "true";
const SCHEMA = "brverify";
const phase = Deno.args[0] ?? "a";
@@ -206,29 +209,47 @@ async function setup() {
.returning("id")
.executeTakeFirstOrThrow();
// 本地资源索引:下载捕获后按 site_release_id 匹配
for (const r of AUDIENCES_FIXTURE_RELEASES.slice(0, 3))
await db.insertInto("source_release").values({ site_id: site.id, site_release_id: r.id, title: r.title }).execute();
const releaseIds: Record<string, string> = {};
for (const r of AUDIENCES_FIXTURE_RELEASES.slice(0, 3)) {
const release = await db
.insertInto("source_release")
.values({ site_id: site.id, site_release_id: r.id, title: r.title })
.returning("id")
.executeTakeFirstOrThrow();
releaseIds[r.id] = release.id;
}
for (const a of ["browser.navigate", "browser.interact", "browser.download"])
await db.insertInto("automation_grant").values({ action: a, granted_by: "br1-verify" }).execute();
const probe = await req("/browser/providers?probe=true");
const p = probe.body.data.items[0];
record("provider:env 登记 + 探测", p?.name === "camofox-env" && p?.health?.status === "ok", {
name: p?.name,
managedBy: p?.managedBy,
hasAccessKey: p?.hasAccessKey,
health: p?.health,
upstreamVersion: p?.capabilities?.upstreamVersion,
supported: Object.fromEntries(
Object.entries(p?.capabilities ?? {})
.filter(([, v]) => v && typeof v === "object")
.map(([k, v]) => [k, (v as Loose).supported]),
),
});
const supported = Object.fromEntries(
Object.entries(p?.capabilities ?? {})
.filter(([, v]) => v && typeof v === "object")
.map(([k, v]) => [k, (v as Loose).supported]),
);
record(
"provider:env 登记 + 探测",
probe.status === 200 &&
p?.name === "camofox-env" &&
p?.health?.status === "ok" &&
p?.capabilities?.upstreamVersion === EXPECTED_VERSION &&
Object.keys(supported).length === 10 &&
Object.values(supported).every((v) => v === true),
{
name: p?.name,
managedBy: p?.managedBy,
hasAccessKey: p?.hasAccessKey,
health: p?.health,
upstreamVersion: p?.capabilities?.upstreamVersion,
expectedVersion: EXPECTED_VERSION,
supported,
},
);
// 没带凭据时应被拒(确认认证头生效)
const noAuth = await fetch(`${CAMOFOX}/tabs?userId=x`);
record("auth:无 Bearer 被拒", noAuth.status === 401, { status: noAuth.status });
await noAuth.body?.cancel();
await saveState({ siteId: site.id, publicSiteId: pub.id, accountId: acct.id, account2Id: acct2.id });
await saveState({ siteId: site.id, publicSiteId: pub.id, accountId: acct.id, account2Id: acct2.id, releaseIds });
}
async function phaseA() {
@@ -241,15 +262,31 @@ async function phaseA() {
if (pubNav.ok) {
const s = await invoke("browser.snapshot", { sessionId: pubNav.value.sessionId });
const shot = await saveShot(pubNav.value.sessionId, "public");
record("public:导航/快照/截图", s.snapshot.text.length > 0 && shot.width > 0, {
url: pubNav.value.url,
httpStatus: pubNav.value.httpStatus,
firstOpenMs: pubMs,
snapshotHead: s.snapshot.text.slice(0, 300),
refsCount: s.snapshot.refsCount,
shot,
const expectedLoginRequired = /\/login\.php$/.test(new URL(PUBLIC_URL).pathname);
record(
"public:导航/快照/截图",
s.snapshot.text.length > 0 &&
shot.width > 0 &&
new URL(s.snapshot.url).origin === new URL(PUBLIC_URL).origin &&
new URL(s.snapshot.url).pathname === new URL(PUBLIC_URL).pathname &&
s.loginRequired === expectedLoginRequired,
{
url: pubNav.value.url,
httpStatus: pubNav.value.httpStatus,
loginRequired: s.loginRequired,
expectedLoginRequired,
firstOpenMs: pubMs,
snapshotHead: s.snapshot.text.slice(0, 300),
refsCount: s.snapshot.refsCount,
shot,
},
);
const closed = await req(`/browser/sessions/${pubNav.value.sessionId}`, undefined, "DELETE");
const closedView = await req(`/browser/sessions/${pubNav.value.sessionId}`);
record("sessions:关闭公开页会话", closed.status === 200 && closedView.body.data?.controller === "closed", {
status: closed.status,
controller: closedView.body.data?.controller,
});
await req(`/browser/sessions/${pubNav.value.sessionId}`, undefined, "DELETE");
} else record("public:导航/快照/截图", false, pubNav.error);
// 1. Agent 打开 mock 站列表:新 profile 未登录 → 落登录页
@@ -297,7 +334,7 @@ async function phaseA() {
await human("type", { key: "Enter" });
const afterShot = await saveShot(sid, "after-login");
const rel = await req(`/browser/sessions/${sid}/control/release`, { reason: "已登录" });
record("takeover:接管期间自动化被拒", !agentClickWhileHuman.ok, {
record("takeover:接管期间自动化被拒", acq.status === 200 && !agentClickWhileHuman.ok, {
acquire: acq.status,
controller: acq.body.data?.session?.controller,
agentSnapshotWhileHuman: agentWhileHuman.ok ? "允许(只读)" : agentWhileHuman.error,
@@ -308,11 +345,15 @@ async function phaseA() {
steps.every((s) => s.status === 200),
{ coords: { username: u, password: pw }, steps, typedShot, afterShot },
);
record("redact:密码框值不出现在快照", !JSON.stringify(typedSnap.body).includes("mock-password"), {
status: typedSnap.status,
snapshot: typedSnap.body.data?.text?.slice(0, 500),
redactions: typedSnap.body.data?.redactions,
});
record(
"redact:密码框值不出现在快照",
typedSnap.status === 200 && !JSON.stringify(typedSnap.body).includes("mock-password"),
{
status: typedSnap.status,
snapshot: typedSnap.body.data?.text?.slice(0, 500),
redactions: typedSnap.body.data?.redactions,
},
);
// 3. 交还后:旧引用被拒;Agent 重新读取同一标签页,看到人工操作后的状态(已登录列表)
const oldRef = /\[(?:button|textbox)[^\]]*?(e\d+)/.exec(loginSnap.snapshot.text)?.[1] ?? "e1";
@@ -389,13 +430,19 @@ async function phaseA() {
const detEx = await req(`/browser/sessions/${sid}/extract`, {
schema: { type: "object", properties: { download: { type: "string", "x-ref": dlRef } } },
});
record("extract:详情", detSnap.snapshot.text.includes(fx.title) && detEx.status === 200, {
url: det.url,
detShot,
hasSubtitle: fx.subtitle ? detSnap.snapshot.text.includes(fx.subtitle.split(" | ")[0]!) : null,
downloadRef: dlRef,
extract: detEx.status === 200 ? detEx.body.data : detEx.body.error,
});
record(
"extract:详情",
detSnap.snapshot.text.includes(fx.title) &&
detEx.status === 200 &&
detEx.body.data?.data?.download?.includes?.(`${fx.title}.torrent`),
{
url: det.url,
detShot,
hasSubtitle: fx.subtitle ? detSnap.snapshot.text.includes(fx.subtitle.split(" | ")[0]!) : null,
downloadRef: dlRef,
extract: detEx.status === 200 ? detEx.body.data : detEx.body.error,
},
);
// 6. 下载捕获:Agent 点下载链接 → 暂存核验;sha256 与直接 HTTP 下载一致
const t1 = Date.now();
@@ -412,7 +459,11 @@ async function phaseA() {
const infohash = (await (await fetch(`${CTRL}/__mock/infohash?id=${fx.id}`)).json()).infohash;
record(
"download:捕获 + sha256 一致 + 匹配本地资源",
dl.ok && dl.value.sha256 === directSha && dl.value.verification?.infohashV1 === infohash,
dl.ok &&
dl.value.verification?.valid === true &&
dl.value.sha256 === directSha &&
dl.value.verification?.infohashV1 === infohash &&
dl.value.sourceReleaseId === st.releaseIds[fx.id],
dl.ok
? {
filename: dl.value.filename,
@@ -422,6 +473,7 @@ async function phaseA() {
infohashV1: dl.value.verification?.infohashV1,
expectInfohash: infohash,
sourceReleaseId: dl.value.sourceReleaseId,
expectedSourceReleaseId: st.releaseIds[fx.id],
duplicate: dl.value.duplicate,
ms: dlMs,
}
@@ -451,45 +503,51 @@ async function phaseA() {
// 8. 回到列表,留给重启阶段
await invoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/torrents.php` });
await saveState({ ...st, sid, tabId, profileKey });
const finalSnap = await invoke("browser.snapshot", { sessionId: sid });
await saveState({ ...st, sid, tabId, profileKey, oldRef: refOf(finalSnap.snapshot.text, fx.title) ?? "e1" });
}
/** 重启 / 空闲回收之后:旧标签页 → lost(409,不重放);重新打开后登录状态 */
async function afterRestart(label: string, sidKey = "sid") {
/** BR-3:重启 / 回收后自动重连,旧 tab 标 lost,旧引用不能在新页面重放。 */
async function afterRestart(label: string, sidKey = "sid", expectedLoginRequired = false) {
const st = await loadState();
const sid = st[sidKey];
const old = await tryInvoke("browser.snapshot", { sessionId: sid });
const view = (await req(`/browser/sessions/${sid}`)).body.data;
record(`${label}:旧标签页识别为 lost`, !old.ok && view?.tabs?.some((t: Loose) => t.status === "lost"), {
snapshot: old.ok ? "仍可读" : old.error,
tabs: view?.tabs?.map((t: Loose) => ({ upstreamTabId: t.upstreamTabId, status: t.status })),
needsSnapshot: view?.needsSnapshot,
});
// 会话本身仍在(数据库为准):在同一会话里开新标签页读列表
const oldTabId = st[sidKey === "sid" ? "tabId" : "tab2Id"];
const oldRef = st[sidKey === "sid" ? "oldRef" : "oldRef2"];
const t0 = Date.now();
const nav = await tryInvoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/torrents.php` });
const stale = await tryInvoke("browser.interact", { sessionId: sid, action: "click", target: { ref: oldRef } });
const snap = await invoke("browser.snapshot", { sessionId: sid });
const view = (await req(`/browser/sessions/${sid}`)).body.data;
record(
`${label}:自动重连 + 旧标签页 lost + 旧引用不重放`,
!stale.ok &&
/重新读取|快照|snapshot/i.test(stale.error?.message ?? "") &&
view?.currentTabId !== oldTabId &&
view?.tabs?.some((t: Loose) => t.upstreamTabId === oldTabId && t.status === "lost") &&
view?.reconnect?.status === "ok",
{
staleRef: stale.ok ? "未被拒" : stale.error,
oldTabId,
currentTabId: view?.currentTabId,
reconnect: view?.reconnect,
tabs: view?.tabs?.map((t: Loose) => ({ upstreamTabId: t.upstreamTabId, status: t.status })),
needsSnapshot: view?.needsSnapshot,
},
);
const reopenMs = Date.now() - t0;
let snap: Loose = null;
if (nav.ok) snap = await invoke("browser.snapshot", { sessionId: sid });
else {
// 会话无可用标签页:关闭后按站点/账号重开(同一 profileKey = 同一 camofox userId)
await req(`/browser/sessions/${sid}`, undefined, "DELETE");
const re = await invoke("browser.navigate", {
siteId: st.siteId,
accountId: sidKey === "sid" ? st.accountId : st.account2Id,
url: `${ORIGIN}/torrents.php`,
});
st[sidKey] = re.sessionId;
await saveState(st);
snap = await invoke("browser.snapshot", { sessionId: re.sessionId });
}
record(`${label}:登录状态保持`, snap.loginRequired === false, {
navigate: nav.ok ? { url: nav.value.url, loginRequired: nav.value.loginRequired } : nav.error,
reopenMs,
loginRequired: snap.loginRequired,
url: snap.snapshot.url,
mockLoginAttempts: (await (await fetch(`${CTRL}/__mock/stats`)).json()).loginAttempts,
});
record(
`${label}:${expectedLoginRequired ? "未落盘登录丢失被明确识别" : "登录状态保持"}`,
snap.loginRequired === expectedLoginRequired,
{
reopenMs,
loginRequired: snap.loginRequired,
expectedLoginRequired,
url: snap.snapshot.url,
mockLoginAttempts: (await (await fetch(`${CTRL}/__mock/stats`)).json()).loginAttempts,
},
);
st[sidKey === "sid" ? "tabId" : "tab2Id"] = view.currentTabId;
st[sidKey === "sid" ? "oldRef" : "oldRef2"] = snap.snapshot.text.match(/\[(e\d+)\]/)?.[1] ?? "e1";
await saveState(st);
}
/** 账号 2:Agent 用服务端凭证注入登录,登录后不关会话(持久化插件只在会话关闭/服务停止时落盘) */
@@ -528,14 +586,24 @@ async function phaseC() {
submit: true,
});
const after = await invoke("browser.snapshot", { sessionId: sid2 });
record("agent-login:凭证注入登录(账号 2)", r1.ok && r2.ok && after.loginRequired === false, {
refs: { userRef, pwRef },
r1: r1.ok || r1.error,
r2: r2.ok || r2.error,
loginRequired: after.loginRequired,
passwordLeak: JSON.stringify(after).includes("mock-password"),
record(
"agent-login:凭证注入登录(账号 2)",
r1.ok && r2.ok && after.loginRequired === false && !JSON.stringify(after).includes("mock-password"),
{
refs: { userRef, pwRef },
r1: r1.ok || r1.error,
r2: r2.ok || r2.error,
loginRequired: after.loginRequired,
passwordLeak: JSON.stringify(after).includes("mock-password"),
},
);
const view = (await req(`/browser/sessions/${sid2}`)).body.data;
await saveState({
...st,
sid2,
tab2Id: view.currentTabId,
oldRef2: after.snapshot.text.match(/\[(e\d+)\]/)?.[1] ?? "e1",
});
await saveState({ ...st, sid2 });
}
try {
@@ -544,7 +612,7 @@ try {
else if (phase === "b") await afterRestart("restart(SIGTERM)");
else if (phase === "idle") await afterRestart("idle(SESSION_TIMEOUT_MS)");
else if (phase === "c") await phaseC();
else if (phase === "d") await afterRestart("restart(SIGKILL) 账号 2", "sid2");
else if (phase === "d") await afterRestart("restart(SIGKILL) 账号 2", "sid2", KILL_LOGIN_REQUIRED);
else throw new Error(`未知阶段 ${phase}`);
} catch (e) {
record("error", false, { message: (e as Error).message, stack: (e as Error).stack?.split("\n").slice(0, 6) });
@@ -553,3 +621,4 @@ try {
await db.destroy();
await admin.destroy();
}
Deno.exit(results.length > 0 && results.every((r) => r.pass === true) ? 0 : 1);
+2 -2
View File
@@ -49,8 +49,8 @@ if [ "$yes" -ne 1 ]; then
fi
echo "restore: pg_restore ← $src/db.dump"
# 经 psql 回放而不是 pg_restore 直连:镜像里是 17 版客户端,它会输出 16 版服务端不认识的
# SET transaction_timeout;过滤掉这一行即可兼容 16。单事务 + ON_ERROR_STOP:失败整体回滚。
# 经 PG 18 psql 回放;保留 transaction_timeout 过滤,以兼容回滚到 PG 16 的旧部署。
# PG 18 服务端也可接收过滤后的回放。单事务 + ON_ERROR_STOP:失败整体回滚。
pg_restore --clean --if-exists --no-owner --no-privileges --file - "$src/db.dump" \
| sed '/^SET transaction_timeout = /d' \
| psql -X -q -v ON_ERROR_STOP=1 --single-transaction -d "$DATABASE_URL" >/dev/null