fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA

- 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.<domain> 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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:29:29 +08:00
1 parent b7a7fd3958
commit 2b8fe667a5
20 files changed
+298 -42

No files matched your search

@@ -39,6 +39,7 @@ import {
deleteSiteAccount,
overallAccountStatus,
refreshSiteAccount,
secretName,
setSiteCredentials,
userDataHistory,
} from "../service/account.ts";
@@ -405,7 +406,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => 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,关闭"登录失效"待处理,站点健康复位
@@ -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<string>();
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<typeof loginNexusPhp>[0]["login"]; loginSuccess?: string };
const profile = (def.umt as { profile?: Record<string, string> }).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<UserDataOutcome> => {
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<string, unknown>) };
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;
@@ -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<void> {
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<void> {
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<string>();
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 {
@@ -30,6 +30,24 @@ function tmdbFields(m: MetadataCandidate): Partial<Record<EntityMetadataField, u
};
}
/** TMDB 详情写入字段来源表(metadata_field);调用方随后 project() 投影最终值 */
export async function writeTmdbFields(
exec: Exec,
entityId: string,
detail: MetadataCandidate,
sourceRevision: string,
now: Date,
): Promise<void> {
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 })
@@ -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,