From 2b8fe667a5eff566af60e0d831b0c8276f4db26d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 15:59:11 +0800 Subject: [PATCH] fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slot: new manual_verify event (attention -> verifying, with reason); a successful organize of a download whose slot is in attention goes back through verifying and lands by verify_passed on the actually imported assets; the download's organize_blocked decision is resolved with resolution {key: manual_organize | organize_retry, organizationRecordId} (U1) - acquisition: plan purpose is upgrade only when the slot already has a current asset; a movie slot gap is a fill (U7) - identity: entities created from TMDB details get metadata fields projected in the same transaction; TMDB aliases deduped by normalized text, site-title aliases skipped when the text already exists (U9) - pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and says to use the minimal completion to probe the endpoint (U2) - sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and names new secrets with site id + random suffix (secret.name is unique; was a 500 on update); M-Team user data uses config/definition apiBaseUrl instead of deriving api. from the site URL (Invalid URL on IP/local bases) - copy: setup error says 初始化口令; backup job points at umt backup - tests: schema expected tables add site_user_data; migration round-trip timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/ site-center tests for the above - docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are never mirrored to SSE) Co-Authored-By: Claude Opus 5.5 --- .../__tests__/vertical.test.ts | 42 +++++++++++++++++ .../services/acquisition.ts | 3 +- .../services/downloads.ts | 2 +- .../acquisition-organize/services/organize.ts | 38 +++++++++++++++ .../src/modules/auth-settings/auth-service.ts | 2 +- apps/server/src/modules/pi/routes.ts | 16 +++---- apps/server/src/modules/schedules/index.ts | 2 +- .../sources-identity/routes/sources.ts | 5 +- .../sources-identity/service/account.ts | 43 +++++++++++++---- .../sources-identity/service/entities.ts | 42 +++++++++++++++-- .../sources-identity/service/metadata.ts | 28 +++++++---- .../sources-identity/service/resolve.ts | 6 +-- apps/server/test/pi/upstreams.test.ts | 5 ++ apps/server/test/sources-identity/harness.ts | 6 +-- .../test/sources-identity/resolve.test.ts | 4 ++ .../test/sources-identity/site-center.test.ts | 47 +++++++++++++++++++ docs/plan/09-visual-qa.md | 33 +++++++++++++ packages/db/test/schema.test.ts | 4 +- packages/domain/src/slot/state.ts | 5 ++ packages/domain/test/slot.test.ts | 7 +++ 20 files changed, 298 insertions(+), 42 deletions(-) diff --git a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts index b177bc35..45090d35 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts @@ -459,4 +459,46 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临 const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`); expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [] }); }); + + it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => { + await h.setSettings("organize", { defaultMode: "move" }); + const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 }); + const slot = await seedSlot(h.db, { entityId: e }); + const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true }); + // 新作品首次下载:计划用途是补缺,不是升级(QA U7) + expect( + (await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow()) + .purpose, + ).toBe("fill"); + const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" }); + expect(auto.status).toBe("failed"); + const slotRow = () => + h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow(); + expect((await slotRow()).status).toBe("attention"); + const dec = await h.db + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", `organize_blocked:${dtId}`) + .executeTakeFirstOrThrow(); + expect(dec.status).toBe("open"); + + await h.setSettings("organize", { defaultMode: "copy" }); + const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null }); + expect(manual.status).toBe("running"); + const done = await executeRecord(h.d, manual.recordId!); + expect(done.status).toBe("succeeded"); + const after = await slotRow(); + expect(after.status).toBe("satisfied"); + expect(after.current_asset_id).not.toBeNull(); + const asset = await h.db + .selectFrom("media_asset") + .select("media_entity_id") + .where("id", "=", after.current_asset_id!) + .executeTakeFirstOrThrow(); + expect(asset.media_entity_id).toBe(e); + const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow(); + expect(closed.status).toBe("resolved"); + expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId }); + expect(closed.resolved_by).toBe("user"); + }, 30_000); }); diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index e6bf0e64..55ba8800 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -967,7 +967,8 @@ async function reserve( savePath: p.savePath, ...(p.evaluationExtra ?? {}), }); - const purpose = p.mode === "upgrade" ? ("upgrade" as const) : ("fill" as const); + // 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺 + const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const); if (c.draft) { // 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1 const key = `draft:${p.slot.id}:${p.rel.id}`; diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index f6aa3a0f..b0d1c42e 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -168,7 +168,7 @@ export async function applySlotEvent( ? completion?.complete ? "verified" : "verified_partial" - : (extra.reason ?? event.type), + : (extra.reason ?? (event.type === "manual_verify" ? event.reason : event.type)), ...(ended ? { next_evaluate_at: d.ports.now() } : {}), }) .where("id", "=", slotId) diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index 1123bc3b..f578f2bb 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -1509,6 +1509,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise SourcesRuntime) { const ref = await rt().ctx.secrets.create({ purpose: SECRET_PURPOSE[body.authKind]!, plaintext: body.credential, - label: `site_account:${body.label}`, + label: secretName({ site_id: body.siteId, label: body.label }), }); try { const row = await db() @@ -442,7 +443,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { patch.secret_ref = await rt().ctx.secrets.create({ purpose: SECRET_PURPOSE[body.authKind ?? acc.auth_kind]!, plaintext: body.credential, - label: `site_account:${acc.label}`, + label: secretName(acc), }); } // 换了凭证:状态回到未验证,清会话 Cookie,关闭"登录失效"待处理,站点健康复位 diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index 06a5c7dc..32060e8d 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { getSiteDefinition, loginNexusPhp, + type MteamDefinition, + mteamApiBase, refreshSiteUserData, SiteLoginError, type SiteLoginFailure, @@ -138,15 +140,30 @@ export async function setSiteCredentials( const label = body.label; const created: string[] = []; const mk = async (purpose: SecretPurpose, plaintext: string, suffix = "") => { - const ref = await secrets.create({ purpose, plaintext, label: `site_account:${label}${suffix}` }); + const ref = await secrets.create({ purpose, plaintext, label: secretName({ site_id: site.id, label }, suffix) }); created.push(ref); return ref; }; + // 已有账号的同类凭证原地覆盖(replace),不新建 secret + const pre = await db + .selectFrom("site_account") + .select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"]) + .where("site_id", "=", site.id) + .where("label", "=", label) + .executeTakeFirst(); + const reused = new Set(); + const put = async (existing: string | null | undefined, purpose: SecretPurpose, plaintext: string, suffix = "") => { + if (!existing) return mk(purpose, plaintext, suffix); + await secrets.replace(existing, plaintext); + reused.add(existing); + return existing; + }; const obsolete: string[] = []; let result: SiteCredentialsResult; try { if (body.kind === "passkey") { - const ref = await mk("site_passkey", body.passkey, ":passkey"); + const oldPasskey = pre?.auth_kind === "passkey" ? pre.secret_ref : pre?.passkey_secret_ref; + const ref = await put(oldPasskey, "site_passkey", body.passkey, ":passkey"); result = await db.transaction().execute(async (trx) => { const cur = await trx .selectFrom("site_account") @@ -195,9 +212,11 @@ export async function setSiteCredentials( }); } else { const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey; - const ref = await mk(PURPOSE[body.kind]!, plaintext); + const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext); const totpRef = - body.kind === "password" && body.totpSecret ? await mk("site_password", body.totpSecret, ":totp") : null; + body.kind === "password" && body.totpSecret + ? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp") + : null; result = await db.transaction().execute(async (trx) => { const cur = await trx .selectFrom("site_account") @@ -265,7 +284,7 @@ export async function setSiteCredentials( for (const r of created) await secrets.remove(r).catch(() => {}); throw e; } - for (const r of obsolete) await secrets.remove(r).catch(() => {}); + for (const r of obsolete) if (!reused.has(r)) await secrets.remove(r).catch(() => {}); await audit( db, actor, @@ -337,7 +356,11 @@ export async function refreshSiteAccount( const umt = def.umt as { kind: string; login?: Parameters[0]["login"]; loginSuccess?: string }; const profile = (def.umt as { profile?: Record }).profile; const vipLevels = (def.umt as { vipLevels?: string[] }).vipLevels; - const apiUrl = def.umt.kind === "mteam" ? ((site.config as { apiBaseUrl?: string })?.apiBaseUrl ?? null) : null; + // 馒头 API 基址与适配器一致:站点配置 > 定义里的 apiBaseUrl;不再由站点地址推 api.<根域>(IP / 本地地址会拼成非法 URL) + const apiUrl = + def.umt.kind === "mteam" + ? (site.config as { apiBaseUrl?: string })?.apiBaseUrl || mteamApiBase(def as MteamDefinition) + : null; const fetchUserData = async (cred: { cookie?: string | null; apikey?: string | null }): Promise => { const info = await refreshSiteUserData({ @@ -433,12 +456,16 @@ export async function refreshSiteAccount( } } +/** secret.name 全局唯一:带站点 id 与随机后缀(各站点都有同名账号 default) */ +export const secretName = (acc: { site_id: string; label: string }, suffix = "") => + `site_account:${acc.site_id}:${acc.label}${suffix}:${randomUUID().slice(0, 8)}`; + /** 账密登录成功:会话 Cookie 存 secret;"只换 Cookie"时凭证类型落为 cookie,密码与 TOTP 丢弃 */ async function storeSession(rt: SourcesRuntime, acc: SiteAccountRow, cookie: string) { const { db, secrets } = rt.ctx; const cookieOnly = (acc.user_info as { saveCookieOnly?: boolean })?.saveCookieOnly === true; if (cookieOnly) { - const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, label: `site_account:${acc.label}` }); + const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, label: secretName(acc) }); const info = { ...(acc.user_info as Record) }; delete info.saveCookieOnly; await db @@ -468,7 +495,7 @@ async function storeSession(rt: SourcesRuntime, acc: SiteAccountRow, cookie: str const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, - label: `site_account:${acc.label}:session`, + label: secretName(acc, ":session"), }); await db.updateTable("site_account").set({ session_secret_ref: ref }).where("id", "=", acc.id).execute(); acc.session_secret_ref = ref; diff --git a/apps/server/src/modules/sources-identity/service/entities.ts b/apps/server/src/modules/sources-identity/service/entities.ts index 928d0ec3..c6126e6e 100644 --- a/apps/server/src/modules/sources-identity/service/entities.ts +++ b/apps/server/src/modules/sources-identity/service/entities.ts @@ -5,6 +5,7 @@ import { normalizeTitle, type ParsedRelease } from "@umt/domain"; import { toContractMediaKind } from "@umt/mapping"; import { jsonb } from "../rows.ts"; import type { Actor, Exec } from "./common.ts"; +import { project, writeTmdbFields } from "./metadata.ts"; // 本地作品 / 内容单元 / 外部身份 / 别名的写入辅助。所有写入在调用方事务内完成。 @@ -57,6 +58,27 @@ export async function addAlias( on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(exec); } +/** 作品下已有同文本别名(任意类别)时不再登记,用于站点标题这类只为召回的补充别名 */ +export async function addAliasIfAbsent( + exec: Exec, + entityId: string, + alias: string, + kind: AliasKind, + source: AliasSource, + language: string | null = null, +): Promise { + const normalized = normalizeTitle(alias); + if (!normalized) return; + const hit = await exec + .selectFrom("title_alias") + .select("id") + .where("media_entity_id", "=", entityId) + .where("content_unit_id", "is", null) + .where("normalized", "=", normalized) + .executeTakeFirst(); + if (!hit) await addAlias(exec, entityId, alias, kind, source, language); +} + /** 写一条外部身份(accepted)+ 修订 created;同一外部身份已被其它作品接受时返回 null(交给调用方处理冲突) */ export async function acceptIdentity( exec: Exec, @@ -141,16 +163,28 @@ export async function createEntityFromTmdb( ); } await seedAliases(exec, entity.id, m); + // 建档即带上 TMDB 详情里的字段(简介/首映/语言等);不记 last_metadata_refresh_at,季总集数仍由首次刷新补 + const now = new Date(); + await writeTmdbFields(exec, entity.id, m, `tmdb:${m.entityType}/${m.externalId}`, now); + await project(exec, entity.id, now); return { entityId: entity.id, created: true }; } export async function seedAliases(exec: Exec, entityId: string, m: MetadataCandidate): Promise { - await addAlias(exec, entityId, m.title, "translated", "tmdb", null); - if (m.originalTitle) await addAlias(exec, entityId, m.originalTitle, "original", "tmdb", m.originalLanguage); + // 同一文本只登记一次(按首个出现的类别):TMDB 的译名/别名列表常重复主标题与原名 + const seen = new Set(); + const add = async (text: string, kind: AliasKind, lang: string | null) => { + const n = normalizeTitle(text); + if (!n || seen.has(n)) return; + seen.add(n); + await addAlias(exec, entityId, text, kind, "tmdb", lang); + }; + await add(m.title, "translated", null); + if (m.originalTitle) await add(m.originalTitle, "original", m.originalLanguage); for (const t of m.titles) { - if (t.kind === "localized") await addAlias(exec, entityId, t.text, "translated", "tmdb", t.lang); + if (t.kind === "localized") await add(t.text, "translated", t.lang); } - for (const a of m.aliases.slice(0, 50)) await addAlias(exec, entityId, a, "other", "tmdb", null); + for (const a of m.aliases.slice(0, 50)) await add(a, "other", null); } export class IdentityConflictError extends Error { diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 43c89044..42e64c3e 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -30,6 +30,24 @@ function tmdbFields(m: MetadataCandidate): Partial { + for (const [field, value] of Object.entries(tmdbFields(detail))) { + await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at) + values (${entityId}, null, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${sourceRevision}, ${now}) + on conflict (media_entity_id, content_unit_id, field, source, language) do update set + value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute( + exec, + ); + } +} + export interface RefreshResult { status: "refreshed" | "skipped" | "failed"; reason?: string; @@ -80,15 +98,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string) } const now = rt.now(); return ctx.db.transaction().execute(async (trx) => { - const fields = tmdbFields(detail); - for (const [field, value] of Object.entries(fields)) { - await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at) - values (${id}, null, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${`tmdb:${ident.entity_type}/${ident.external_id}`}, ${now}) - on conflict (media_entity_id, content_unit_id, field, source, language) do update set - value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute( - trx, - ); - } + await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now); await trx .updateTable("external_identity") .set({ verify_status: "ok", verified_at: now }) diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 35cfd611..1cb3f986 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -24,7 +24,7 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts"; import { - addAlias, + addAliasIfAbsent, createEntityFromTmdb, ensureUnits, entityByExternal, @@ -417,8 +417,8 @@ async function bindAuto( .executeTakeFirstOrThrow(); const units = await ensureUnits(trx, entityId, entity.kind, parsed); // 站点标题作为别名:下次同名资源可直接本地召回 - if (parsed.titleCn) await addAlias(trx, entityId, parsed.titleCn, "site_title", "site", "zh"); - if (parsed.titleEn) await addAlias(trx, entityId, parsed.titleEn, "site_title", "site", "en"); + if (parsed.titleCn) await addAliasIfAbsent(trx, entityId, parsed.titleCn, "site_title", "site", "zh"); + if (parsed.titleEn) await addAliasIfAbsent(trx, entityId, parsed.titleEn, "site_title", "site", "en"); const evidence = { level, diff --git a/apps/server/test/pi/upstreams.test.ts b/apps/server/test/pi/upstreams.test.ts index 50d61efe..5b88e400 100644 --- a/apps/server/test/pi/upstreams.test.ts +++ b/apps/server/test/pi/upstreams.test.ts @@ -158,6 +158,11 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => { const t2 = (await req("POST", `/pi/upstreams/${a.id}/test`, {})).body.data; expect(t2).toMatchObject({ ok: true, mode: "auth", source: "stored credential", model: "gpt-5" }); expect(t2.upstream.lastVerifiedAt).toBeTruthy(); + // 自定义 Base URL:凭证检查不连端点,不改写可用状态,提示用最小补全(QA U2) + await h.db.updateTable("pi_upstream").set({ status: "error" }).where("id", "=", b.id).execute(); + const tb = (await req("POST", `/pi/upstreams/${b.id}/test`, {})).body.data; + expect(tb).toMatchObject({ ok: true, mode: "auth", upstream: { status: "error", lastVerifiedAt: null } }); + expect(tb.message).toMatch(/最小补全/); const models = (await req("GET", `/pi/upstreams/${a.id}/models`)).body.data; expect(models.items.length).toBeGreaterThan(10); expect(models.refreshed).toBe(false); diff --git a/apps/server/test/sources-identity/harness.ts b/apps/server/test/sources-identity/harness.ts index c83a35e4..a3699558 100644 --- a/apps/server/test/sources-identity/harness.ts +++ b/apps/server/test/sources-identity/harness.ts @@ -42,13 +42,13 @@ export function fakeSecrets(db: () => Kysely): SecretStore & { plain: Map { .where("media_entity_id", "=", oppId) .execute(); expect(aliases.map((a) => a.alias)).toEqual(expect.arrayContaining(["奥本海默", "Oppenheimer"])); + // 同一文本只登记一次;建档即带 TMDB 字段(QA U9) + expect(new Set(aliases.map((a) => a.alias)).size, JSON.stringify(aliases)).toBe(aliases.length); + const created = await h.db.selectFrom("media_entity").selectAll().where("id", "=", oppId).executeTakeFirstOrThrow(); + expect((created.metadata as Record).overview?.source).toBe("tmdb"); const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow(); expect(rel).toMatchObject({ resolution_status: "resolved", media_entity_id: oppId, parser_version: "1" }); expect((rel.parsed as { year: number }).year).toBe(2023); diff --git a/apps/server/test/sources-identity/site-center.test.ts b/apps/server/test/sources-identity/site-center.test.ts index ad1e5911..23772b93 100644 --- a/apps/server/test/sources-identity/site-center.test.ts +++ b/apps/server/test/sources-identity/site-center.test.ts @@ -119,6 +119,10 @@ describe.skipIf(!up)("站点中心(mock NexusPHP)", () => { expect(ds).toHaveLength(1); expect((ds[0]!.options as { key: string }[]).map((o) => o.key)).toEqual(["use_cookie", "dismiss"]); await json(await put({ kind: "password", label: "bad", username: "alice", password: "s3cret-pw" })); + // 同类凭证原地覆盖:secret 引用不变、明文已换(不新建 secret,不撞 secret.name 唯一) + const again = await account("bad"); + expect(again.secret_ref).toBe(acc.secret_ref); + expect(h.secrets.plain.get(again.secret_ref!)).toBe("s3cret-pw"); expect((await account("bad")).status).toBe("unknown"); expect(await openDecisions(acc.id)).toHaveLength(0); await json(await h.req(`/site-accounts/${acc.id}`, { method: "DELETE" })); @@ -315,6 +319,21 @@ describe.skipIf(!up)("站点中心(mock M-Team API Key)", () => { expect(pw).toMatchObject({ ok: false, reason: "unsupported", status: "unknown" }); }); + it("换凭证类型再换回、同类覆盖:不撞 secret 名(联调:更新凭证 500)", async () => { + const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body }); + await json(await put({ kind: "cookie", cookie: "a=1" })); + await json(await put({ kind: "cookie", cookie: "a=2" })); + await json(await put({ kind: "api_key", apiKey: api.apiKey })); + const acc = await h.db + .selectFrom("site_account") + .selectAll() + .where("site_id", "=", siteId) + .where("label", "=", "default") + .executeTakeFirstOrThrow(); + expect(acc.auth_kind).toBe("api_key"); + expect(h.secrets.plain.get(acc.secret_ref!)).toBe(api.apiKey); + }); + it("旧格式凭证 {authKind, credential} 仍可用", async () => { const r = await json( await h.req(`/sites/${siteId}/credentials`, { @@ -326,3 +345,31 @@ describe.skipIf(!up)("站点中心(mock M-Team API Key)", () => { expect(JSON.stringify(r)).not.toContain("pk-123"); }); }); + +describe.skipIf(!up)("站点中心(馒头 API 基址)", () => { + it("站点地址是 IP / 本地、未配 apiBaseUrl:用户数据走定义里的 API 基址,不拼出 api. 非法 URL(联调:Invalid URL)", async () => { + const seen: string[] = []; + const fetch = (async (input: RequestInfo | URL) => { + seen.push(input instanceof Request ? input.url : String(input)); + return new Response(JSON.stringify({ code: "0", data: { id: 7, username: "mt", memberCount: {} } }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof globalThis.fetch; + const h = await createHarness("sitecenter_mtbase", { fetch }); + try { + const site = await json( + await h.req("/sites", { + body: { key: "mteam", name: "馒头", adapter: "mteam_api", baseUrl: "http://127.0.0.1:9/" }, + }), + ); + await json( + await h.req(`/sites/${site.id}/credentials`, { method: "PUT", body: { kind: "api_key", apiKey: "k-1" } }), + ); + const r = (await json(await h.req(`/sites/${site.id}/refresh-login`, { body: { wait: true } }))).result; + expect(r, JSON.stringify(r)).toMatchObject({ ok: true, status: "active" }); + expect(seen[0]).toBe("https://api.m-team.cc/api/member/profile"); + } finally { + await h.close(); + } + }, 60_000); +}); diff --git a/docs/plan/09-visual-qa.md b/docs/plan/09-visual-qa.md index 21157aef..5ade527f 100644 --- a/docs/plan/09-visual-qa.md +++ b/docs/plan/09-visual-qa.md @@ -144,3 +144,36 @@ 3. `apps/web/test/screenshots/qa-v02/66-organize-wizard-1-d.png`:批量整理向导识别步骤(整季包 + 电影 + 待选作品) 4. `apps/web/test/screenshots/qa-v02/45-dl-organize-exec-d.png`:预览失败仍能执行产生的失败记录(#2,已修) 5. `apps/web/test/screenshots/qa-v02/V11-pi-test-network-hint-d.png`:不存在的端点「测试」通过、状态变「可用」(U2) + +## W10-pre 后端复核(未修项状态) + +2026-09-28。只改后端;前端项留给前端波。验证:HEAD + 本轮改动的独立 worktree 跑 `deno task ci`。 + +| # | 归属 | 状态 | 说明 | +| --- | --- | --- | --- | +| U1 | 后端 | 已修 | 协调者拍板:状态机新增 `manual_verify`(attention → verifying,带原因)。下载任务的整理成功入库时,Slot 若是 attention 先回核验,再由 `verify_passed` 按实际入库资产落 satisfied / empty(部分覆盖);同一下载任务的「整理冲突 / 做种义务阻止移动」事项(`organize_blocked:<下载任务>`)自动 resolved,`resolution = { key: "manual_organize", organizationRecordId, by }`,自动重试成功记 `organize_retry`。纵切测试覆盖 | +| U2 | 后端 | 已修 | 自定义 Base URL(非 OAuth)的「测试」只核对凭证和模型,不再把状态改成「可用」、不记核验时间,返回提示「没有连接 Base URL;用「最小补全」确认端点可达」。前端直接显示 `message` 即可 | +| U3 | 后端 + 前端 | 未修,需定 | 根因比报告写的大:`ctx.outbox.emit` 写的事件(`feed.fetched`、`decision.*`、`download.status_changed`、`organization.changed` 等)只进 outbox 路由成任务,**没有任何一条镜像到 SSE 总线**;浏览器只收得到直接 `ctx.events.publish` 的事件(agent、settings、files、schedule、heartbeat)。首页订阅的这些类型实际从未到达。要补一个 outbox → SSE 的桥(提交后可见、顺序与裁剪策略),属于设计改动,超出本轮。桥接好后前端来源健康 / 站点详情订阅 `feed.fetched` 失效列表 | +| U4 | 前端 | 未修 | 表单级错误定位 | +| U5 | 前端 | 未修 | `FormField` 间距 | +| U6 | 前端 | 未修 | FAB 定位 | +| U7 | 后端 | 已修 | 电影 Slot 缺口的 mode 仍记 `upgrade`(`determineMode` 的 slot_gap),但计划用途只在 Slot 已有当前资产时才是「升级」,否则「补缺」;时间线随之显示「创建下载计划(补缺)」 | +| U8 | 后端 | 不改 | 观众站定义(照搬 MP 的 `audiences.json`)没有 `hr` 选择器,H&R 三态按设计为 unknown;mock 页面上的 `hitandrun` 标记不会被读取。要补选择器需先在真实站点核对 | +| U9 | 后端 | 已修 | 按 TMDB 建档(自动识别 / 手动指定 / 按候选建)时同事务写入 TMDB 字段并投影,不再等下一次刷新(不记 `last_metadata_refresh_at`,剧集季总集数仍由首次刷新补);别名按规范化文本去重(TMDB 译名/别名列表与主标题、原名重复的不再登记,站点标题与已有别名同文本时也不登记)。已有数据里的重复别名不回溯清理 | +| U10 | 后端 | 不改 | fake 仅用于演示,行为与 runbook 一致 | +| U11 | 前端 / 文案 | 未修 | 全站标点规范 | +| U12 | 前端 | 未修 | 文件管理单击进入 | +| U13 | 前端 | 未修 | 移动目标树先拦「目标 = 源所在目录」 | +| U14 | 前端 | 未修 | 浮层材质 | +| U15 | 前端 | 未修 | 指标卡文案缩短 | +| U16 | 前端 | 未修 | 身份指定弹窗 select / 标签 | +| U17 | 后端 | 已核 | `/health` 与 `/system/health` 都读 `apps/server/deno.json`,当前为 0.2.0;rc 镜像构建早于版本号提交,下次构建复核 | + +文案清单里的后端项已改:服务端初始化报错改为「初始化口令无效…」(与页面字段同词);定时任务「备份」说明改为「目前用 umt backup(宿主机 cron 调用)」。其余文案(TMDB 空格、API Key 重复小节、「每 7 天」、「最高级别:提示」、「延后:已有计划在途」、失败记录「未生成目标路径」、「默认模型」)在前端。 + +其他:`packages/db/test/schema.test.ts` 补 `site_user_data`(0031),迁移往返用例超时放宽到 60s(35 个迁移 down/up 在共用开发库上超过默认 5s);`db-sync` 在 HEAD 上已齐。迁移 0031–0035 连续无冲突(0017 历史上空缺,迁移器按文件名排序,不影响);0035 仍是 W9-E 未提交改动,`types.ts` 已含其列。 + +前端联调追加的两条站点中心后端 bug(同一提交修): + +- `PUT /sites/{id}/credentials` 更新已有账号凭证 500:secret 名 `site_account: