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:
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,
|
||||
|
||||
Reference in new issue
Block a user