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
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1509,6 +1509,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.executeTakeFirst();
|
||||
if (slot && okAny) {
|
||||
const old = slot.current_asset_id;
|
||||
// 需处理的 Slot(如自动整理被拦)经人工整理入库:先回到核验,再由 verify_passed 按实际入库资产定终态
|
||||
if (slot.status === "attention")
|
||||
await applySlotEvent(d, trx, slot.id, {
|
||||
type: "manual_verify",
|
||||
reason: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
|
||||
});
|
||||
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
|
||||
await applySlotEvent(
|
||||
d,
|
||||
@@ -1540,6 +1546,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
}
|
||||
}
|
||||
}
|
||||
// 本下载任务先前被拦的整理事项:已有资产入库即视为处理完毕
|
||||
if (okAny) await closeOrganizeDecisions(d, trx, dt.id, rec);
|
||||
}
|
||||
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
|
||||
if (!dt && frozen.slotId && okAny) {
|
||||
@@ -1590,6 +1598,36 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
|
||||
}
|
||||
|
||||
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
|
||||
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
|
||||
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
|
||||
const rows = await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolved_by: by,
|
||||
resolved_at: d.ports.now(),
|
||||
resolution: jsonb({
|
||||
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
|
||||
optionKey: null,
|
||||
by,
|
||||
note: "整理已成功入库",
|
||||
organizationRecordId: rec.id,
|
||||
}),
|
||||
})
|
||||
.where("dedupe_key", "=", `organize_blocked:${downloadTaskId}`)
|
||||
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||
.returning("id")
|
||||
.execute();
|
||||
for (const r of rows)
|
||||
await d.ctx.outbox.emit(
|
||||
trx,
|
||||
"decision.updated",
|
||||
{ decisionId: r.id, status: "resolved" },
|
||||
{ idempotencyKey: `decision.updated:${r.id}:resolved` },
|
||||
);
|
||||
}
|
||||
|
||||
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
|
||||
async function ensureEpisodeUnits(
|
||||
exec: Exec,
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function setupAdmin(
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
|
||||
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
|
||||
throw new ApiException("unauthorized", "setup token 无效。使用服务端日志里最新打印的那个");
|
||||
throw new ApiException("unauthorized", "初始化口令无效。使用服务端日志里最新打印的那个");
|
||||
}
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin) throw new Error("admin_user 行丢失");
|
||||
|
||||
@@ -104,14 +104,14 @@ export function registerPiRoutes(
|
||||
if (!r.ok) throw new UpstreamError(r.kind as UpstreamError["kind"], r.message);
|
||||
}
|
||||
const t = await clientFor(ctx, row, deps.client).test({ mode, ...(req.model ? { model: req.model } : {}) });
|
||||
out = {
|
||||
ok: true,
|
||||
source: t.source,
|
||||
model: t.model,
|
||||
errorKind: null,
|
||||
message: t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
|
||||
};
|
||||
await setStatus(ctx, row.id, { status: "ok", verified: true });
|
||||
// 自定义 Base URL 的凭证检查不连端点:不据此改写可用状态,提示用最小补全确认可达(QA U2)
|
||||
const unprobed = mode === "auth" && !!row.base_url && row.auth_kind !== "oauth";
|
||||
const notes = [
|
||||
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
|
||||
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
|
||||
].filter(Boolean);
|
||||
out = { ok: true, source: t.source, model: t.model, errorKind: null, message: notes.join(";") || null };
|
||||
if (!unprobed) await setStatus(ctx, row.id, { status: "ok", verified: true });
|
||||
} catch (err) {
|
||||
const e = err instanceof UpstreamError ? err : new UpstreamError("other", (err as Error).message);
|
||||
out = { ok: false, source: null, model: req.model ?? row.default_model, errorKind: e.kind, message: e.message };
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
||||
key: "maintain.backup",
|
||||
defaults: {
|
||||
name: "备份",
|
||||
description: "数据库与数据目录备份;目前由 scripts/backup.sh 在宿主机执行,未接入调度",
|
||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
||||
group: "maintain",
|
||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||
enabled: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,13 +42,13 @@ export function fakeSecrets(db: () => Kysely<DB>): SecretStore & { plain: Map<st
|
||||
if (v === undefined) throw new Error(`unknown secret ${ref}`);
|
||||
return v;
|
||||
},
|
||||
async create({ plaintext, purpose }) {
|
||||
async create({ plaintext, purpose, label }) {
|
||||
const id = randomUUID();
|
||||
plain.set(id, plaintext);
|
||||
// 业务表 secret_ref 有外键:落一行占位(密文不是真的)
|
||||
// 业务表 secret_ref 有外键:落一行占位(密文不是真的);name 与真实实现一致取 label(secret.name 唯一)
|
||||
await db()
|
||||
.insertInto("secret")
|
||||
.values({ id, name: `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
|
||||
.values({ id, name: label ?? `test-${id}`, purpose, ciphertext: Buffer.from("x"), nonce: Buffer.from("x") })
|
||||
.execute();
|
||||
return id;
|
||||
},
|
||||
|
||||
@@ -71,6 +71,10 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
.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<string, { source: string }>).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);
|
||||
|
||||
@@ -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.<IP> 非法 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);
|
||||
});
|
||||
@@ -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:<label>` 撞 `secret.name` 唯一约束(各站点都有 `default`,换凭证时新 secret 与旧 secret 同名)。改为同账号同类凭证(主凭证 / passkey / TOTP)原地 `replace`,需要新建时名字带站点 id 与随机后缀;会话 Cookie、`POST /site-accounts` 同样处理。测试夹具的 fake secret 现在按 label 落 `name`,能复现唯一约束。
|
||||
- 馒头刷新登录 "Invalid URL":用户数据解析器在没有 `config.apiBaseUrl` 时按站点地址推 `https://api.<根域>`,站点地址是 IP / 本地 mock 时拼成非法 URL。改为与适配器一致:站点配置 > 定义里的 `apiBaseUrl`(`https://api.m-team.cc`)。
|
||||
@@ -99,6 +99,8 @@ const EXPECTED_TABLES = [
|
||||
// W7-C 定时任务注册表(0028)
|
||||
"scheduled_job",
|
||||
"scheduled_job_run",
|
||||
// W9-A 站点用户数据(0031)
|
||||
"site_user_data",
|
||||
// W9-C 命名模板版本(0032)
|
||||
"naming_template_version",
|
||||
// W9-D 规则组(0033)
|
||||
@@ -381,5 +383,5 @@ describe.skipIf(skip)("schema migrations (real PG)", () => {
|
||||
const up = await migrator.migrateToLatest();
|
||||
expect(up.error).toBeUndefined();
|
||||
expect(await tables()).toEqual(EXPECTED_TABLES);
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -25,6 +25,8 @@ export type SlotEvent =
|
||||
| { type: "upgrade_found" }
|
||||
| { type: "upgrade_cancelled" }
|
||||
| { type: "attention_resolved"; hasAsset: boolean }
|
||||
/** 需处理的 Slot 经人工整理拿到了新资产:回到核验,由 verify_passed 以实际入库资产定终态 */
|
||||
| { type: "manual_verify"; reason: string }
|
||||
| { type: "asset_missing_confirmed" };
|
||||
|
||||
export class SlotTransitionError extends Error {
|
||||
@@ -80,6 +82,9 @@ export function transition(state: SlotState, event: SlotEvent): SlotState {
|
||||
case "attention_resolved":
|
||||
if (status === "attention") return { status: event.hasAsset ? "satisfied" : "empty", hasAsset: event.hasAsset };
|
||||
return bad();
|
||||
case "manual_verify":
|
||||
if (status === "attention") return { status: "verifying", hasAsset };
|
||||
return bad();
|
||||
case "asset_missing_confirmed":
|
||||
// 只接受"确认缺失"(对账多次观察后);单次观察失败不得触发(失败 ≠ 不存在)
|
||||
if (status === "satisfied" || status === "upgrade_pending") return { status: "attention", hasAsset: false };
|
||||
|
||||
@@ -205,6 +205,13 @@ describe("Slot 状态机", () => {
|
||||
hasAsset: false,
|
||||
});
|
||||
});
|
||||
it("需处理经人工整理回到核验,再按实际资产落终态(manual_verify)", () => {
|
||||
const st = transition(s("attention"), { type: "manual_verify", reason: "manual_organize" });
|
||||
expect(st).toEqual({ status: "verifying", hasAsset: false });
|
||||
expect(transition(st, { type: "verify_passed", complete: true })).toEqual({ status: "satisfied", hasAsset: true });
|
||||
for (const from of ["empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending"] as const)
|
||||
expect(canTransition(s(from), { type: "manual_verify", reason: "x" }), from).toBe(false);
|
||||
});
|
||||
it("部分覆盖回到 empty 但记住已有资产", () => {
|
||||
expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({
|
||||
status: "empty",
|
||||
|
||||
Reference in new issue
Block a user