style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9b017906cf
commit
52402864d6
143 files changed
+985
-606
No files matched your search
@@ -63,7 +63,7 @@ export function loadConfig(env: Record<string, string | undefined> = Deno.env.to
|
||||
const parsed = ConfigSchema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
|
||||
throw new Error(`配置无效:\n${lines.join("\n")}`);
|
||||
throw new Error(`配置无效:\n${lines.join("\n")}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class DeferredTaskEngine implements TaskEngine {
|
||||
}
|
||||
|
||||
private get e(): TaskEngine {
|
||||
if (!this.inner) throw new Error("任务引擎尚未就绪(模块不要在 register* 阶段调用引擎)");
|
||||
if (!this.inner) throw new Error("任务引擎尚未就绪(模块不要在 register* 阶段调用引擎)");
|
||||
return this.inner;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const route = createRoute({
|
||||
method: "get",
|
||||
path: "/health",
|
||||
tags: ["system"],
|
||||
summary: "服务健康状态(免鉴权)",
|
||||
summary: "服务健康状态(免鉴权)",
|
||||
responses: {
|
||||
200: {
|
||||
description: "服务状态",
|
||||
|
||||
@@ -89,7 +89,7 @@ export class PgEventBus implements EventBusPort {
|
||||
client.removeAllListeners("notification");
|
||||
client.end().catch(() => {});
|
||||
if (!this.stopped) {
|
||||
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
|
||||
this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连");
|
||||
this.reconnect(500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
|
||||
summary: def.summary,
|
||||
responses: {
|
||||
200: {
|
||||
description: "JSON 补读页(带 limit 或 Accept: application/json);否则为 text/event-stream",
|
||||
description: "JSON 补读页(带 limit 或 Accept: application/json);否则为 text/event-stream",
|
||||
content: {
|
||||
"application/json": { schema: apiResponse(def.response) },
|
||||
"text/event-stream": { schema: { type: "string" } },
|
||||
|
||||
@@ -124,14 +124,14 @@ async function runOneShot(r: OneShotRole): Promise<number> {
|
||||
const storage = await checkStorage({ ...paths, subdirs: DATA_SUBDIRS, mounts });
|
||||
problems.push(...storage.problems);
|
||||
for (const w of storage.warnings) {
|
||||
logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用: ${w.name} ${w.reason}`);
|
||||
logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用:${w.name} ${w.reason}`);
|
||||
}
|
||||
await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败"));
|
||||
// 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效
|
||||
if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES"))
|
||||
logger.warn(
|
||||
{ active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" },
|
||||
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
|
||||
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
|
||||
);
|
||||
for (const p of problems) logger.error({ check: p.check }, p.message);
|
||||
if (problems.length) return 1;
|
||||
|
||||
@@ -147,7 +147,7 @@ describe.skipIf(!hasPg)("整理执行:onExisting / partial 清理 / TIMEOUT 重
|
||||
for (const attempt of [1, 2]) {
|
||||
const err = await executeRecord(h.d, plan.recordId!).catch((x) => x);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
|
||||
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
|
||||
const fr = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["status"])
|
||||
|
||||
@@ -111,7 +111,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] };
|
||||
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
|
||||
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
|
||||
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
|
||||
if (rejected.length === 0)
|
||||
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
|
||||
}
|
||||
const fresh = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -148,7 +149,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
.where("id", "=", plan.slot_id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
|
||||
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
|
||||
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
|
||||
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
|
||||
@@ -177,7 +178,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
});
|
||||
if (ok) throw new CapabilityError("conflict", ok);
|
||||
} else if (!["reserved", "submitted", "completed"].includes(plan.status)) {
|
||||
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
|
||||
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
|
||||
}
|
||||
// 与自动路径同一幂等键:同一计划只会有一个 submitDownload 任务
|
||||
const { taskId } = await enqueueTracked(d, {
|
||||
@@ -231,7 +232,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
if (!frozen) {
|
||||
throw new CapabilityError(
|
||||
"precondition",
|
||||
res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`,
|
||||
res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`,
|
||||
);
|
||||
}
|
||||
const entity = await d.ctx.db
|
||||
@@ -313,7 +314,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
dryRun: true,
|
||||
});
|
||||
if (!again.plan || planHash(req, again.plan) !== art.content_hash)
|
||||
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划");
|
||||
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划");
|
||||
const taskId = await ensureTaskRow(db, {
|
||||
kind: "verifyAndImport",
|
||||
idempotencyKey: taskKey,
|
||||
@@ -389,8 +390,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
|
||||
if (cur.status === "published") return cur; // 幂等:已发布
|
||||
if (cur.revision !== input.expectedRevision)
|
||||
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
|
||||
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
|
||||
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
|
||||
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
|
||||
return publishRevision(trx, cur.id, cur.policy_key, ctx.actorId, "superseded");
|
||||
});
|
||||
return { policy: toApi(row) as never };
|
||||
|
||||
@@ -106,19 +106,19 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "downloads.sync",
|
||||
defaults: {
|
||||
name: "qB 同步",
|
||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
||||
group: "download",
|
||||
schedule: every(syncMs),
|
||||
},
|
||||
run: async () => {
|
||||
const st = await syncDownloads(d);
|
||||
if (st.instances > 0 && st.unreachable.length === st.instances) {
|
||||
throw new Error(`下载器都连不上:${st.unreachable.join("、")}`);
|
||||
throw new Error(`下载器都连不上:${st.unreachable.join("、")}`);
|
||||
}
|
||||
return {
|
||||
summary:
|
||||
`${st.instances} 个下载器,更新 ${st.updated},完成 ${st.completed.length}` +
|
||||
(st.unreachable.length ? `,连不上 ${st.unreachable.length}` : ""),
|
||||
`${st.instances} 个下载器,更新 ${st.updated},完成 ${st.completed.length}` +
|
||||
(st.unreachable.length ? `,连不上 ${st.unreachable.length}` : ""),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -148,14 +148,14 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
run: guarded(d, async (d) => {
|
||||
const submitted = await sweepReservedPlans(d);
|
||||
const imports = await sweepPendingImports(d);
|
||||
return `补提交 ${submitted},补入库 ${imports}`;
|
||||
return `补提交 ${submitted},补入库 ${imports}`;
|
||||
}),
|
||||
});
|
||||
registerScheduledJob(ctx, {
|
||||
key: "slots.reevaluate",
|
||||
defaults: {
|
||||
name: "到期重评",
|
||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
||||
group: "download",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ export function createPorts(
|
||||
return client;
|
||||
},
|
||||
async torrentSource({ release, site }) {
|
||||
if (!site) throw new Error("站点不存在,无法获取种子");
|
||||
if (!site) throw new Error("站点不存在,无法获取种子");
|
||||
const raw = (release.raw ?? {}) as Record<string, unknown>;
|
||||
const siteMeta = (raw._site ?? {}) as Record<string, unknown>;
|
||||
const defId = [str(siteMeta.adapterSiteId), site.key].find((k) => k && getSiteDefinition(k));
|
||||
|
||||
@@ -18,7 +18,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "get",
|
||||
path: "/dashboard",
|
||||
scope: "read",
|
||||
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
|
||||
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
|
||||
response: Dashboard,
|
||||
},
|
||||
async () => {
|
||||
@@ -141,9 +141,9 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const dv = decideVolume(v, cache);
|
||||
const cands = await cleanupCandidates(d, v);
|
||||
const notes: string[] = [];
|
||||
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
|
||||
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
|
||||
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
|
||||
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
|
||||
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
|
||||
const pct = usedPercent(v);
|
||||
volumes.push({
|
||||
volumeKey: v.volumeKey,
|
||||
|
||||
@@ -75,7 +75,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
|
||||
if (!exists) notFound("下载任务");
|
||||
const r = await userResume(d, params.id);
|
||||
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复");
|
||||
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复");
|
||||
return toApi<DownloadTask>(r);
|
||||
},
|
||||
{ tag: TAG },
|
||||
@@ -91,7 +91,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "post",
|
||||
path: "/download-cache/cleanup",
|
||||
scope: "admin",
|
||||
summary: "入队一轮缓存清理(只清已验证入库且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页",
|
||||
summary: "入队一轮缓存清理(只清已验证入库且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页",
|
||||
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
|
||||
response: accepted,
|
||||
},
|
||||
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
scope: "admin",
|
||||
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
||||
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
|
||||
body: z.object({ libraryRootId: z.uuid().optional() }),
|
||||
response: accepted,
|
||||
},
|
||||
|
||||
@@ -137,7 +137,7 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
|
||||
if (block) {
|
||||
throw new ApiException(
|
||||
"conflict",
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
|
||||
`待处理事项 ${block.decisionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!exists) notFound("版本目标");
|
||||
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
||||
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
||||
}
|
||||
await kickEvaluate(d, { acquisitionSlotId: params.id });
|
||||
return toApi<AcquisitionSlot>(r!);
|
||||
@@ -246,7 +246,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
if (!r.success)
|
||||
throw new ApiException(
|
||||
"validation_failed",
|
||||
`rules 不符合收录策略结构: ${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
|
||||
`rules 不符合收录策略结构:${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
|
||||
);
|
||||
};
|
||||
mount(
|
||||
@@ -293,7 +293,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
async (_c, { params, body }) => {
|
||||
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!cur) notFound("策略版本");
|
||||
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
|
||||
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
|
||||
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
|
||||
const r = await db()
|
||||
.updateTable("policy_revision")
|
||||
@@ -344,8 +344,8 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!cur) notFound("策略版本");
|
||||
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
|
||||
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
|
||||
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
|
||||
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
|
||||
checkRules(cur!.kind, cur!.rules);
|
||||
return publishRevision(trx, cur!.id, cur!.policy_key, actorOf(c), "superseded");
|
||||
});
|
||||
|
||||
@@ -58,16 +58,16 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
|
||||
return {
|
||||
roots,
|
||||
async check(p) {
|
||||
if (!isAbsolute(p)) throw new PathGuardError(`源路径必须是绝对路径: ${p}`, p);
|
||||
if (!isAbsolute(p)) throw new PathGuardError(`源路径必须是绝对路径:${p}`, p);
|
||||
if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p);
|
||||
let r: string;
|
||||
try {
|
||||
r = await realpath(p);
|
||||
} catch {
|
||||
throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p);
|
||||
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
|
||||
}
|
||||
if (!roots.some((root) => within(r, root)))
|
||||
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下: ${p}`, p);
|
||||
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
@@ -280,7 +280,7 @@ export async function authorizeMoveEntities(
|
||||
unauthorized.push({
|
||||
label: s.label,
|
||||
entityId: null,
|
||||
reason: "无法确定所属作品,只能用一次性审批",
|
||||
reason: "无法确定所属作品,只能用一次性审批",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -306,8 +306,8 @@ export async function authorizeMoveEntities(
|
||||
unauthorized,
|
||||
reason:
|
||||
detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null)
|
||||
? `移动需要授权:${detail.join(";")}`
|
||||
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
|
||||
? `移动需要授权:${detail.join(";")}`
|
||||
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,11 +339,11 @@ export async function hnrBlockForMove(
|
||||
if (hnr === true) continue;
|
||||
const decisionId = await upsertDecision(d, db, {
|
||||
kind: "approval_required",
|
||||
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
|
||||
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
|
||||
summary:
|
||||
hnr === false
|
||||
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
|
||||
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
|
||||
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
|
||||
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
|
||||
subjectType: "download_task",
|
||||
subjectId: dt.id,
|
||||
dedupeKey: `manual_move_hnr:${dt.id}`,
|
||||
@@ -357,7 +357,7 @@ export async function hnrBlockForMove(
|
||||
},
|
||||
options: [
|
||||
{ key: "wait", label: "做种满足后再移动" },
|
||||
{ key: "copy", label: "改用复制(需重新发起)" },
|
||||
{ key: "copy", label: "改用复制(需重新发起)" },
|
||||
],
|
||||
});
|
||||
return { downloadTaskId: dt.id, hnr, decisionId };
|
||||
|
||||
@@ -55,7 +55,7 @@ async function walkTree(
|
||||
if (out.files.length >= limit) return true;
|
||||
const p = join(dir, n);
|
||||
const st = await statSafe(p);
|
||||
if (st.kind !== "ok") throw new Error(`源不可访问: ${p} (${st.kind})`);
|
||||
if (st.kind !== "ok") throw new Error(`源不可访问:${p} (${st.kind})`);
|
||||
if (st.facts.isSymbolicLink) out.links.push(p);
|
||||
else if (st.facts.isDirectory) {
|
||||
out.dirs.push(p);
|
||||
@@ -93,7 +93,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
|
||||
const add = async (src: string, dst: string) => {
|
||||
if (t.guard) await t.guard(src);
|
||||
const s = await statSafe(src);
|
||||
if (s.kind !== "ok") throw new Error(`源不可访问: ${src} (${s.kind})`);
|
||||
if (s.kind !== "ok") throw new Error(`源不可访问:${src} (${s.kind})`);
|
||||
files.push({
|
||||
src,
|
||||
dst,
|
||||
@@ -205,7 +205,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
|
||||
target_path: rel(f.targetPath),
|
||||
status: held ? "failed" : f.status,
|
||||
skip_reason: held ? null : f.skipReason,
|
||||
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
|
||||
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ async function walk(
|
||||
guard?: ((p: string) => Promise<string>) | undefined,
|
||||
): Promise<void> {
|
||||
const st = await statSafe(p);
|
||||
if (st.kind !== "ok") throw new FsError("ENOENT", `源路径不可访问: ${p} (${st.kind})`, { path: p });
|
||||
if (st.kind !== "ok") throw new FsError("ENOENT", `源路径不可访问:${p} (${st.kind})`, { path: p });
|
||||
if (st.facts.isFile) {
|
||||
// 目录里的符号链接可能指向根外:逐个文件复核 realpath
|
||||
if (guard) await guard(p);
|
||||
@@ -389,7 +389,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const rid = await fail("identity_unknown");
|
||||
await decide_({
|
||||
kind: "identity_ambiguous",
|
||||
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||
summary: "下载内容未关联作品。识别或指定作品后再整理",
|
||||
subjectType: "organization_record",
|
||||
subjectId: rid.recordId,
|
||||
@@ -450,8 +450,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
await decide_({
|
||||
kind: "other",
|
||||
severity: "critical",
|
||||
title: `库根离线:${root.name}`,
|
||||
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
|
||||
title: `库根离线:${root.name}`,
|
||||
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
|
||||
subjectType: "library_root",
|
||||
subjectId: root.id,
|
||||
dedupeKey: `library_root_offline:${root.id}`,
|
||||
@@ -508,7 +508,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
if (dec.kind === "conflict") {
|
||||
await decide_({
|
||||
kind: "path_conflict",
|
||||
title: `作品存在多个历史目录:${entity.title}`,
|
||||
title: `作品存在多个历史目录:${entity.title}`,
|
||||
summary: "不会按最新标题自动合并。指定要保留的目录",
|
||||
subjectType: "media_entity",
|
||||
subjectId: entity.id,
|
||||
@@ -700,7 +700,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const hnrConflict = blocking.find((c) => c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown");
|
||||
await upsertDecision(d, trx, {
|
||||
kind: hnrConflict ? "approval_required" : "path_conflict",
|
||||
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
|
||||
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
|
||||
summary: blocking
|
||||
.map((c) => c.detail ?? c.kind)
|
||||
.join(";")
|
||||
@@ -850,7 +850,7 @@ export async function executeRecord(
|
||||
const fsr = fsRuntime(d);
|
||||
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
||||
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
||||
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
|
||||
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
|
||||
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
||||
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
||||
if (fsr.partialMaxAgeMs > 0)
|
||||
@@ -987,7 +987,7 @@ export async function executeRecord(
|
||||
error: r.error
|
||||
? jsonb(r.error)
|
||||
: r.status === "attention"
|
||||
? jsonb({ code: "source_not_removed", message: "目标已就绪,源文件未能删除" })
|
||||
? jsonb({ code: "source_not_removed", message: "目标已就绪,源文件未能删除" })
|
||||
: null,
|
||||
finished_at: now,
|
||||
})
|
||||
@@ -1010,8 +1010,8 @@ export async function executeRecord(
|
||||
if (outcome.status === "failed" && outcome.error && fsErrorClass(outcome.error.code) === "retry") {
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
|
||||
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`,
|
||||
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
|
||||
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
dedupeKey: `fs_retry_exhausted:${opId}`,
|
||||
@@ -1027,8 +1027,8 @@ export async function executeRecord(
|
||||
if (outcome.replaced?.disposition === "left") {
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: "新版本已入库,旧文件未能处置",
|
||||
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
|
||||
title: "新版本已入库,旧文件未能处置",
|
||||
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
dedupeKey: `replace_old_left:${opId}`,
|
||||
@@ -1039,7 +1039,7 @@ export async function executeRecord(
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: "跨卷移动后删除源文件失败",
|
||||
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
|
||||
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
dedupeKey: `move_source_left:${opId}`,
|
||||
@@ -1050,7 +1050,7 @@ export async function executeRecord(
|
||||
if (deferred.length > 0) {
|
||||
// 交给任务引擎退避重试(executeRecord 幂等:已完成文件直接跳过,账本无结果的文件重跑并先对账)
|
||||
throw new Error(
|
||||
`文件操作可重试失败,等待重试:${deferred.map((x) => `${x.code}@${x.src}(第 ${x.attempt} 次)`).join(";")}`,
|
||||
`文件操作可重试失败,等待重试:${deferred.map((x) => `${x.code}@${x.src}(第 ${x.attempt} 次)`).join(";")}`,
|
||||
);
|
||||
}
|
||||
return finalizeRecord(d, recordId);
|
||||
@@ -1149,9 +1149,9 @@ function statFailure(st: StatResult, path: string, role: "源" | "目标"): { co
|
||||
case "ok":
|
||||
return null;
|
||||
case "missing":
|
||||
return role === "源" ? { code: "SOURCE_MISSING", message: `源文件不存在: ${path}` } : null;
|
||||
return role === "源" ? { code: "SOURCE_MISSING", message: `源文件不存在:${path}` } : null;
|
||||
case "permission_denied":
|
||||
return { code: "EACCES", message: `${role}无权限访问: ${path}` };
|
||||
return { code: "EACCES", message: `${role}无权限访问:${path}` };
|
||||
case "root_offline":
|
||||
return { code: "ROOT_OFFLINE", message: `${role}所在根离线(${st.reason}): ${path}` };
|
||||
case "error":
|
||||
@@ -1217,7 +1217,7 @@ async function transferFile(
|
||||
const err = fsErr(e);
|
||||
return {
|
||||
status: "failed",
|
||||
error: { ...err, message: `对账比对失败:${err.message}` },
|
||||
error: { ...err, message: `对账比对失败:${err.message}` },
|
||||
evidence: { phase: "reconcile", src, dst },
|
||||
};
|
||||
}
|
||||
@@ -1234,7 +1234,7 @@ async function transferFile(
|
||||
};
|
||||
}
|
||||
} else if (x.onExisting !== "replace")
|
||||
return { status: "failed", error: { code: "EEXIST", message: `目标已存在且内容不同: ${dst}` } };
|
||||
return { status: "failed", error: { code: "EEXIST", message: `目标已存在且内容不同:${dst}` } };
|
||||
}
|
||||
}
|
||||
const sFail = statFailure(s, src, "源");
|
||||
@@ -1262,7 +1262,7 @@ async function transferFile(
|
||||
if (crossDevice !== true) {
|
||||
const m = await px.moveSafe(src, dst, call);
|
||||
if (m.status === "moved") return { status: "succeeded", bytes: size };
|
||||
if (m.status === "exists") return { status: "failed", error: { code: "EEXIST", message: `目标已存在: ${dst}` } };
|
||||
if (m.status === "exists") return { status: "failed", error: { code: "EEXIST", message: `目标已存在:${dst}` } };
|
||||
// EXDEV:不静默降级;按设置显式走可恢复三步
|
||||
}
|
||||
if (!x.allowCrossVolume)
|
||||
@@ -1575,7 +1575,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
kind: "other",
|
||||
severity: "info",
|
||||
title: "旧版本待处置",
|
||||
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
|
||||
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
|
||||
subjectType: "media_asset",
|
||||
subjectId: old,
|
||||
dedupeKey: `old_version:${old}`,
|
||||
@@ -1636,7 +1636,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
} catch (err) {
|
||||
d.ctx.logger.warn(
|
||||
{ downloadTaskId: result.dt.id, err: { message: errMessage(err) } },
|
||||
"Move 后移除 qB 任务失败,下次同步重试",
|
||||
"Move 后移除 qB 任务失败,下次同步重试",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
|
||||
const code = errnoOf(e);
|
||||
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
|
||||
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线:${msg}`);
|
||||
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
|
||||
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
|
||||
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
|
||||
@@ -189,7 +189,7 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
|
||||
|
||||
export async function rootDef(exec: Exec, id: string): Promise<RootDef> {
|
||||
const r = (await loadRootDefs(exec)).find((x) => x.id === id);
|
||||
if (!r) throw new FilesError("not_found", `根不存在或未启用: ${id}`);
|
||||
if (!r) throw new FilesError("not_found", `根不存在或未启用:${id}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export interface Resolved {
|
||||
}
|
||||
|
||||
function checkRel(rel: string) {
|
||||
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径: ${rel}`);
|
||||
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径:${rel}`);
|
||||
const segs = rel.split("/").filter((s) => s.length > 0);
|
||||
if (segs.some((s) => s === ".." || s === ".")) throw new FilesError("path_escape", `路径不允许包含 . 或 ..: ${rel}`);
|
||||
return segs.join("/");
|
||||
@@ -290,9 +290,9 @@ export async function resolveIn(
|
||||
parentReal = await realpath(dirname(joined));
|
||||
} catch (e) {
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
|
||||
throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`);
|
||||
throw new FilesError("not_found", `父目录不存在:${dirname(rel) || "/"}`);
|
||||
}
|
||||
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外: ${rel}`);
|
||||
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外:${rel}`);
|
||||
const abs = join(parentReal, basename(joined));
|
||||
let lst: Awaited<ReturnType<typeof lstat>> | null = null;
|
||||
try {
|
||||
@@ -301,12 +301,12 @@ export async function resolveIn(
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
|
||||
if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`);
|
||||
}
|
||||
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在: ${rel}`);
|
||||
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在:${rel}`);
|
||||
const isSymlink = !!lst?.isSymbolicLink();
|
||||
if (isSymlink) {
|
||||
const target = await realpath(abs).catch(() => null);
|
||||
if (!target || !within(target, rootReal))
|
||||
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作: ${rel}`);
|
||||
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作:${rel}`);
|
||||
}
|
||||
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
|
||||
}
|
||||
@@ -318,9 +318,9 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
|
||||
r = await realpath(abs);
|
||||
} catch (e) {
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
|
||||
throw new FilesError("plan_stale", `源已不存在: ${abs}`);
|
||||
throw new FilesError("plan_stale", `源已不存在:${abs}`);
|
||||
}
|
||||
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外: ${abs}`);
|
||||
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ export async function downloadFile(
|
||||
): Promise<Response> {
|
||||
// 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御)
|
||||
if (!isAdminSession(principal))
|
||||
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
|
||||
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
|
||||
const r = await resolveIn(d, q.root, q.path);
|
||||
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
|
||||
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
|
||||
const name = basename(r.abs);
|
||||
const kind = kindOfName(name);
|
||||
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
|
||||
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
|
||||
const real = await recheck(r.abs, r.rootReal);
|
||||
const file = await Deno.open(real, { read: true });
|
||||
let size: number;
|
||||
@@ -59,7 +59,7 @@ export async function downloadFile(
|
||||
if (size > d.options.downloadMaxBytes)
|
||||
throw new FilesError(
|
||||
"too_large",
|
||||
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
|
||||
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
|
||||
);
|
||||
} catch (e) {
|
||||
file.close();
|
||||
|
||||
@@ -16,7 +16,7 @@ const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
|
||||
|
||||
function assertCleanName(name: string) {
|
||||
if (sanitizeSegment(name) !== name)
|
||||
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
|
||||
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
|
||||
}
|
||||
|
||||
export async function mkdirIn(
|
||||
@@ -26,15 +26,15 @@ export async function mkdirIn(
|
||||
): Promise<FilesMkdirResult> {
|
||||
const r = await resolveIn(d, body.root, body.path);
|
||||
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`);
|
||||
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
|
||||
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
|
||||
assertCleanName(body.name);
|
||||
await recheck(r.abs, r.rootReal);
|
||||
const abs = join(r.abs, body.name);
|
||||
try {
|
||||
await mkdir(abs);
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在: ${body.name}`);
|
||||
throw new FilesError("bad_request", `创建目录失败: ${errMessage(e)}`);
|
||||
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
|
||||
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
|
||||
}
|
||||
const rel = relOf(r.rootReal, abs);
|
||||
await audit(d, principal, "mkdir", { root: body.root, path: rel });
|
||||
@@ -57,7 +57,7 @@ export async function renameEntry(
|
||||
): Promise<FilesRenameResult> {
|
||||
const r = await resolveIn(d, body.root, body.path);
|
||||
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名");
|
||||
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
|
||||
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
|
||||
assertCleanName(body.newName);
|
||||
const oldName = basename(r.abs);
|
||||
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
|
||||
@@ -68,7 +68,7 @@ export async function renameEntry(
|
||||
if (hits.length > 0)
|
||||
throw new FilesError(
|
||||
"download_active",
|
||||
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
|
||||
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
|
||||
);
|
||||
|
||||
const dirAbs = dirname(r.abs);
|
||||
@@ -91,7 +91,7 @@ export async function renameEntry(
|
||||
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
|
||||
if (st) taken.push(p.to);
|
||||
}
|
||||
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖: ${taken.join(", ")}`);
|
||||
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
|
||||
|
||||
const lib = r.def.libraryRootId;
|
||||
let locCount = 0;
|
||||
@@ -123,11 +123,11 @@ export async function renameEntry(
|
||||
await recheck(p.fromAbs, r.rootReal);
|
||||
const before = await lstat(p.fromAbs);
|
||||
const res = await moveSafe(p.fromAbs, p.toAbs);
|
||||
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
|
||||
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
|
||||
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
|
||||
const after = await lstat(p.toAbs);
|
||||
if (after.ino !== before.ino || after.dev !== before.dev)
|
||||
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止: ${p.to}`);
|
||||
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
|
||||
done.push(p);
|
||||
} catch (e) {
|
||||
await rollback();
|
||||
|
||||
@@ -213,10 +213,10 @@ export async function organizeFiles(
|
||||
for (const it of body.items) {
|
||||
const r = await resolveIn(d, it.root, it.path);
|
||||
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
|
||||
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
|
||||
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
|
||||
const action = it.operation ?? body.action;
|
||||
if (action === "move" && !r.def.modifiable)
|
||||
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
|
||||
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
|
||||
roots.add(it.root);
|
||||
const isDir = !!r.lst?.isDirectory();
|
||||
const entityId = entityOf(it);
|
||||
@@ -231,7 +231,7 @@ export async function organizeFiles(
|
||||
if (it.targetDir) {
|
||||
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
||||
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
|
||||
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录: ${it.targetDir.path || "/"}`);
|
||||
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录:${it.targetDir.path || "/"}`);
|
||||
if (!(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
|
||||
targetDir = join(t.def.hostPath, t.rel);
|
||||
@@ -239,13 +239,13 @@ export async function organizeFiles(
|
||||
}
|
||||
// 季/集
|
||||
if (it.season !== undefined || it.episodes !== undefined) {
|
||||
if (!isTv) warnings.push(`${r.rel}: 电影不适用季/集覆写,已忽略`);
|
||||
if (!isTv) warnings.push(`${r.rel}:电影不适用季/集覆写,已忽略`);
|
||||
else {
|
||||
if (it.season !== undefined) ov.season = it.season;
|
||||
if (it.episodes?.length) {
|
||||
const range = episodeRange(it.episodes);
|
||||
if (!range) warnings.push(`${r.rel}: 集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
|
||||
else if (isDir) warnings.push(`${r.rel}: 目录项不能指定集号(会套到其下所有视频),未应用`);
|
||||
if (!range) warnings.push(`${r.rel}:集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
|
||||
else if (isDir) warnings.push(`${r.rel}:目录项不能指定集号(会套到其下所有视频),未应用`);
|
||||
else ov.episodes = range;
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export async function organizeFiles(
|
||||
if (unit) {
|
||||
const u = units.get(unit);
|
||||
if (!u || u.media_entity_id !== entityId) {
|
||||
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
|
||||
warnings.push(`${r.rel}:内容单元 ${unit} 不属于该作品,未应用`);
|
||||
unit = null;
|
||||
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
|
||||
}
|
||||
@@ -264,10 +264,10 @@ export async function organizeFiles(
|
||||
if (slotId) {
|
||||
const s = slots.get(slotId);
|
||||
if (!s || s.media_entity_id !== entityId) {
|
||||
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
|
||||
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
|
||||
slotId = null;
|
||||
} else if (BUSY_SLOT.has(s.status)) {
|
||||
warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`);
|
||||
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
|
||||
slotId = null;
|
||||
}
|
||||
}
|
||||
@@ -416,12 +416,12 @@ export async function organizeFiles(
|
||||
const frozen = (await loadPlan<Frozen>(d, planId, "organize", reqHash)).content;
|
||||
const now = await factsOf(frozen.facts.map((f) => f.path));
|
||||
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
|
||||
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化: ${changed.path}`);
|
||||
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`);
|
||||
const again = await planImport(d.ao, importReq(true));
|
||||
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
|
||||
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
|
||||
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
|
||||
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
|
||||
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
|
||||
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
|
||||
|
||||
if (g.action === "move") {
|
||||
const managed = await managedTasksUnder(db, req.sourcePaths);
|
||||
@@ -436,7 +436,7 @@ export async function organizeFiles(
|
||||
if (block)
|
||||
throw new FilesError(
|
||||
"download_active",
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
|
||||
);
|
||||
}
|
||||
await retirePlan(d, planId);
|
||||
@@ -551,6 +551,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<
|
||||
.where("title", "=", "files_plan")
|
||||
.execute();
|
||||
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
|
||||
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
|
||||
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
|
||||
return hit.id;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export async function scrape(
|
||||
.where("media_entity_id", "=", e.id)
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
|
||||
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
|
||||
const { runId } = await d.ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: e.id },
|
||||
@@ -166,7 +166,7 @@ export async function scrapeBatch(
|
||||
for (const id of found) ids.add(id);
|
||||
}
|
||||
if (ids.size === 0 && skipped.length === 0) throw new FilesError("bad_request", "需要 items 或 mediaEntityIds");
|
||||
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
|
||||
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
|
||||
const list = [...ids];
|
||||
const ents = list.length
|
||||
? await db.selectFrom("media_entity").select(["id", "title", "field_locks"]).where("id", "in", list).execute()
|
||||
|
||||
@@ -95,20 +95,20 @@ export async function transfer(
|
||||
const t = await resolveIn(d, body.target.root, body.target.path);
|
||||
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
|
||||
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
|
||||
|
||||
const sources: Resolved[] = [];
|
||||
for (const it of body.items) {
|
||||
const s = await resolveIn(d, it.root, it.path);
|
||||
if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制");
|
||||
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
|
||||
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
|
||||
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
|
||||
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`);
|
||||
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
|
||||
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`);
|
||||
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`);
|
||||
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`);
|
||||
sources.push(s);
|
||||
}
|
||||
const names = sources.map((s) => basename(s.abs));
|
||||
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
|
||||
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
|
||||
|
||||
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
|
||||
const downloads = await managedDownloads(d);
|
||||
@@ -148,7 +148,7 @@ export async function transfer(
|
||||
const reason = planned.reason ?? "plan_failed";
|
||||
throw new FilesError(
|
||||
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
|
||||
`无法生成计划:${reason}`,
|
||||
`无法生成计划:${reason}`,
|
||||
);
|
||||
}
|
||||
const planFiles = planned.plan.organize.files;
|
||||
@@ -189,9 +189,9 @@ export async function transfer(
|
||||
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
|
||||
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
|
||||
const warnings: string[] = [];
|
||||
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
|
||||
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
|
||||
if (truncated) warnings.push("源文件数超过上限,分批操作");
|
||||
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
|
||||
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
|
||||
if (truncated) warnings.push("源文件数超过上限,分批操作");
|
||||
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
|
||||
|
||||
if (body.dryRun) {
|
||||
@@ -215,22 +215,22 @@ export async function transfer(
|
||||
for (const [i, s] of snaps.entries()) {
|
||||
const frozen = plan.content.sources[i];
|
||||
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
|
||||
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
|
||||
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单):${s.rel}`);
|
||||
}
|
||||
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
|
||||
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
|
||||
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
|
||||
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
|
||||
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
|
||||
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
|
||||
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
|
||||
if (crossBlocked)
|
||||
throw new FilesError(
|
||||
"bad_request",
|
||||
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
|
||||
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
|
||||
);
|
||||
if (mode === "move") {
|
||||
if (hnrBlocks.length > 0)
|
||||
throw new FilesError(
|
||||
"download_active",
|
||||
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
|
||||
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
|
||||
);
|
||||
if (!isAdmin(principal)) {
|
||||
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
|
||||
@@ -272,7 +272,7 @@ export async function transfer(
|
||||
}
|
||||
if (!rec.recordId || rec.status === "failed") {
|
||||
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
|
||||
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
|
||||
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
|
||||
}
|
||||
const recordId = rec.recordId;
|
||||
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
|
||||
@@ -316,7 +316,7 @@ export async function transfer(
|
||||
failed.push({
|
||||
path: s.rel,
|
||||
code: err.code === "EEXIST" ? "exists" : (err.code ?? bad.status),
|
||||
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
|
||||
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +157,8 @@ const BLOCK_ERR: Record<
|
||||
> = {
|
||||
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
||||
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
||||
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
|
||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
|
||||
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
|
||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
|
||||
root_unavailable: ["root_unavailable", "根不可用"],
|
||||
not_found: ["not_found", "不存在"],
|
||||
escape: ["path_escape", "路径指向根外"],
|
||||
@@ -276,8 +276,8 @@ export async function deleteFiles(
|
||||
for (const s of slots)
|
||||
await upsertDecision(d.ao, trx, {
|
||||
kind: "other",
|
||||
title: `版本目标的文件已删除:${s.name}`,
|
||||
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
|
||||
title: `版本目标的文件已删除:${s.name}`,
|
||||
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
|
||||
subjectType: "acquisition_slot",
|
||||
subjectId: s.id,
|
||||
dedupeKey: `files_delete_slot:${s.id}`,
|
||||
@@ -384,7 +384,7 @@ async function moveToTrash(
|
||||
try {
|
||||
await mkdir(dirname(trashPath), { recursive: true });
|
||||
const r = await moveSafe(abs, trashPath);
|
||||
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在: ${trashPath}`);
|
||||
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在:${trashPath}`);
|
||||
moved = r.status === "moved";
|
||||
} catch (e) {
|
||||
if (e instanceof FilesError) throw e;
|
||||
@@ -396,7 +396,7 @@ async function moveToTrash(
|
||||
if (check.size > d.options.trashCrossMaxBytes)
|
||||
throw new FilesError(
|
||||
"bad_request",
|
||||
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
|
||||
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
|
||||
);
|
||||
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
|
||||
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
|
||||
@@ -487,10 +487,10 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
|
||||
throw e;
|
||||
});
|
||||
if (st?.isSymbolicLink())
|
||||
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
|
||||
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
|
||||
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
|
||||
if (!st) await mkdir(cur);
|
||||
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外: ${rel}`);
|
||||
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`);
|
||||
}
|
||||
}
|
||||
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
|
||||
@@ -509,12 +509,12 @@ export async function restoreTrash(
|
||||
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
|
||||
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
|
||||
if (target && !(await isWritableTarget(target.def, target.rootReal)))
|
||||
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
|
||||
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
|
||||
for (const id of body.ids) {
|
||||
try {
|
||||
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new FilesError("not_found", "回收站条目不存在");
|
||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
|
||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
|
||||
let rootId = row.root_id;
|
||||
let dst: string;
|
||||
let rootReal: string;
|
||||
@@ -537,7 +537,7 @@ export async function restoreTrash(
|
||||
);
|
||||
dst = parent.abs;
|
||||
}
|
||||
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择“还原到…”");
|
||||
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
|
||||
const r = await moveSafe(row.trash_path, dst);
|
||||
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
|
||||
if (r.status === "exdev") {
|
||||
@@ -547,7 +547,7 @@ export async function restoreTrash(
|
||||
if (bytes > d.options.trashCrossMaxBytes)
|
||||
throw new FilesError(
|
||||
"too_large",
|
||||
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
|
||||
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
|
||||
);
|
||||
await copyTree(d, row.trash_path, dst, files);
|
||||
await rm(row.trash_path, { recursive: true });
|
||||
@@ -597,7 +597,7 @@ export async function purgeTrash(
|
||||
const failed: FilesTrashPurgeResult["failed"] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
|
||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
|
||||
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
|
||||
if (!gone) continue;
|
||||
await rm(row.trash_path, { recursive: true, force: true });
|
||||
|
||||
@@ -19,7 +19,7 @@ const hiddenName = (n: string) => n.startsWith(".") || isTempName(n);
|
||||
|
||||
export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> {
|
||||
const r = await resolveIn(d, q.root, q.path);
|
||||
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
|
||||
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
|
||||
const names = await readdir(r.abs, { withFileTypes: true });
|
||||
const subdirs = names
|
||||
.filter((n) => n.isDirectory() && !hiddenName(n.name))
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createPiModule(
|
||||
key: "pi.token_refresh",
|
||||
defaults: {
|
||||
name: "上游登录刷新",
|
||||
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
|
||||
description: "订阅登录即将过期的 Agent 上游,提前刷新 token",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: interval },
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
|
||||
|
||||
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
|
||||
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
|
||||
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
|
||||
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
|
||||
await ctx.db.transaction().execute(async (trx) => {
|
||||
const r = await sql<{ id: string; inserted: boolean }>`
|
||||
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
|
||||
|
||||
@@ -107,8 +107,8 @@ export function registerPiRoutes(
|
||||
// 自定义 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,
|
||||
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 });
|
||||
|
||||
@@ -63,12 +63,12 @@ function validateShape(input: {
|
||||
if (!info.authKinds.includes(input.authKind as never))
|
||||
throw new ApiException(
|
||||
"validation_failed",
|
||||
`${info.name} 不支持认证方式 ${input.authKind},可用:${info.authKinds.join(" / ")}`,
|
||||
`${info.name} 不支持认证方式 ${input.authKind},可用:${info.authKinds.join(" / ")}`,
|
||||
);
|
||||
if (input.baseUrl && (!info.baseUrlOverridable || input.authKind === "oauth"))
|
||||
throw new ApiException(
|
||||
"validation_failed",
|
||||
`${info.name}${input.authKind === "oauth" ? "(订阅登录)" : ""} 不能覆盖 baseUrl`,
|
||||
`${info.name}${input.authKind === "oauth" ? "(订阅登录)" : ""} 不能覆盖 baseUrl`,
|
||||
);
|
||||
if (input.provider === "openai-compatible" && (!input.baseUrl || !input.defaultModel))
|
||||
throw new ApiException("validation_failed", "自定义兼容端点必须填 baseUrl 与模型 id");
|
||||
@@ -213,9 +213,9 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
|
||||
const id = upstreamId ?? (await defaultUpstreamId(ctx));
|
||||
if (!id) return null;
|
||||
const row = await getRow(ctx, id);
|
||||
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
|
||||
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
|
||||
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
|
||||
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
|
||||
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
|
||||
if (row.status === "auth_required")
|
||||
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "未登录" : "未配置 API Key"}`);
|
||||
return { row, spec: { ...specOf(row), extra: parseExtra(row.extra) }, credentials: dbCredentialStore(ctx, row.id) };
|
||||
|
||||
@@ -29,12 +29,12 @@ export const BUILTIN_AGENT_CHECKS: {
|
||||
{
|
||||
key: "check.pending",
|
||||
name: "待处理巡检",
|
||||
description: "检查待处理事项并给出建议(只读)",
|
||||
description: "检查待处理事项并给出建议(只读)",
|
||||
everyMs: 6 * HOUR,
|
||||
config: {
|
||||
goalTemplate:
|
||||
"巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
|
||||
"给出处理建议和理由。只读,不做任何变更。",
|
||||
"巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
|
||||
"给出处理建议和理由。只读,不做任何变更。",
|
||||
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
|
||||
budget: { maxToolCalls: 20, maxDurationSec: 300 },
|
||||
triggerCondition: "open_decisions",
|
||||
@@ -43,12 +43,12 @@ export const BUILTIN_AGENT_CHECKS: {
|
||||
{
|
||||
key: "check.downloads",
|
||||
name: "下载诊断",
|
||||
description: "检查无进展或失败的下载并诊断;开启恢复后在授权内重试或重新校验",
|
||||
description: "检查无进展或失败的下载并诊断;开启恢复后在授权内重试或重新校验",
|
||||
everyMs: 2 * HOUR,
|
||||
config: {
|
||||
goalTemplate:
|
||||
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
|
||||
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
|
||||
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
|
||||
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
|
||||
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
|
||||
budget: { maxToolCalls: 24, maxDurationSec: 420 },
|
||||
triggerCondition: "stalled_downloads",
|
||||
@@ -58,12 +58,12 @@ export const BUILTIN_AGENT_CHECKS: {
|
||||
{
|
||||
key: "check.weekly",
|
||||
name: "订阅与站点周报",
|
||||
description: "检查订阅缺集与站点健康,汇总写入待处理(只读)",
|
||||
description: "检查订阅缺集与站点健康,汇总写入待处理(只读)",
|
||||
everyMs: 7 * 24 * HOUR,
|
||||
config: {
|
||||
goalTemplate:
|
||||
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
|
||||
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
|
||||
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
|
||||
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
|
||||
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
|
||||
budget: { maxToolCalls: 30, maxDurationSec: 600 },
|
||||
triggerCondition: "subscriptions_active",
|
||||
@@ -106,7 +106,7 @@ const isPlanOnly = (id: string) => {
|
||||
/** agent_check 的执行体(内置与自定义共用) */
|
||||
export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promise<JobRunResult> {
|
||||
const parsed = AgentCheckConfig.safeParse(c.config);
|
||||
if (!parsed.success) throw new Error(`巡检参数无效:${parsed.error.issues[0]?.message ?? "未知"}`);
|
||||
if (!parsed.success) throw new Error(`巡检参数无效:${parsed.error.issues[0]?.message ?? "未知"}`);
|
||||
const cfg = parsed.data;
|
||||
const f = await facts(ctx);
|
||||
const unmet =
|
||||
@@ -114,7 +114,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
|
||||
(cfg.triggerCondition === "stalled_downloads" && f.stalledDownloads === 0) ||
|
||||
(cfg.triggerCondition === "subscriptions_active" && f.activeSubscriptions === 0);
|
||||
// 手动执行不看触发条件
|
||||
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
|
||||
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
|
||||
|
||||
let mode: "execute" | "plan_only" = "plan_only";
|
||||
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
|
||||
@@ -124,7 +124,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
|
||||
if (grants.length > 0) {
|
||||
mode = "execute";
|
||||
allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])];
|
||||
} else note = "(没有覆盖恢复动作的有效授权,改为只读)";
|
||||
} else note = "(没有覆盖恢复动作的有效授权,改为只读)";
|
||||
}
|
||||
const goal = renderGoal(cfg.goalTemplate, c.now, f);
|
||||
const dedupeKey = c.trigger === "manual" ? `schedule:${c.jobId}:manual:${c.runId}` : `schedule:${c.jobId}:${c.slot}`;
|
||||
|
||||
@@ -23,7 +23,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
|
||||
const out = new Set<number>();
|
||||
for (const part of src.split(",")) {
|
||||
const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
|
||||
if (!m) throw new Error(`cron ${label} 段无效:${part}`);
|
||||
if (!m) throw new Error(`cron ${label} 段无效:${part}`);
|
||||
let a = lo;
|
||||
let b = hi;
|
||||
if (m[1] !== "*") {
|
||||
@@ -31,7 +31,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
|
||||
b = m[3] !== undefined ? Number(m[3]) : m[4] !== undefined ? hi : a;
|
||||
}
|
||||
const step = m[4] !== undefined ? Number(m[4]) : 1;
|
||||
if (a < lo || b > hi || a > b || step < 1) throw new Error(`cron ${label} 段越界:${part}`);
|
||||
if (a < lo || b > hi || a > b || step < 1) throw new Error(`cron ${label} 段越界:${part}`);
|
||||
for (let v = a; v <= b; v += step) out.add(v);
|
||||
}
|
||||
return out;
|
||||
@@ -39,7 +39,7 @@ function parseField(src: string, [lo, hi]: [number, number], label: string): Set
|
||||
|
||||
export function parseCron(expr: string): CronFields {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) throw new Error("cron 需要 5 段:分 时 日 月 周");
|
||||
if (parts.length !== 5) throw new Error("cron 需要 5 段:分 时 日 月 周");
|
||||
const [mi, h, d, mo, w] = parts as [string, string, string, string, string];
|
||||
const weekday = parseField(w, RANGES[4]!, "周");
|
||||
if (weekday.has(7)) weekday.add(0);
|
||||
@@ -85,5 +85,5 @@ export function nextCronAfter(expr: string | CronFields, after: Date): Date {
|
||||
}
|
||||
return t;
|
||||
}
|
||||
throw new Error(`cron 在 4 年内没有触发时间:${typeof expr === "string" ? expr : "(parsed)"}`);
|
||||
throw new Error(`cron 在 4 年内没有触发时间:${typeof expr === "string" ? expr : "(parsed)"}`);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
||||
key: "maintain.backup",
|
||||
defaults: {
|
||||
name: "备份",
|
||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
||||
group: "maintain",
|
||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||
enabled: false,
|
||||
|
||||
@@ -71,7 +71,7 @@ class MemoryRegistry implements ScheduleRegistry {
|
||||
return [...this.defs.values()];
|
||||
}
|
||||
register(def: ScheduledJobDef) {
|
||||
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
|
||||
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
|
||||
this.defs.set(def.key, def);
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class Scheduler {
|
||||
});
|
||||
} catch (err) {
|
||||
this.leader = false;
|
||||
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
|
||||
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
|
||||
if (!this.stopped) await this.sleep(retry);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export class Scheduler {
|
||||
|
||||
/** 上一个执行者中断时留下的 running 状态:标为失败 */
|
||||
private async recoverInterrupted(): Promise<void> {
|
||||
const msg = "调度器重启,上次运行中断";
|
||||
const msg = "调度器重启,上次运行中断";
|
||||
await this.db
|
||||
.updateTable("scheduled_job_run")
|
||||
.set({ status: "failed", error: msg, finished_at: this.now() })
|
||||
@@ -296,7 +296,7 @@ export class Scheduler {
|
||||
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
|
||||
limit 50`.execute(this.db);
|
||||
for (const r of rows.rows) {
|
||||
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
|
||||
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
|
||||
await this.db
|
||||
.updateTable("scheduled_job_run")
|
||||
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
|
||||
@@ -305,7 +305,7 @@ export class Scheduler {
|
||||
if (r.config?.reportAsDecision === true && r.status === "succeeded") {
|
||||
const ins = await sql<{ id: string }>`
|
||||
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, agent_run_id)
|
||||
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
|
||||
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
|
||||
${`schedule-report:${r.id}`}, ${JSON.stringify({ scheduledJobId: r.job_id, scheduledRunId: r.id })}::jsonb,
|
||||
${r.agent_run_id})
|
||||
on conflict do nothing
|
||||
@@ -315,7 +315,7 @@ export class Scheduler {
|
||||
await this.ctx.outbox.emit(
|
||||
this.db,
|
||||
"decision.created",
|
||||
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
|
||||
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
|
||||
{ idempotencyKey: `decision.created:${d.id}` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function syncDefinitions(db: Kysely<DB>, defs: readonly ScheduledJo
|
||||
|
||||
export async function getJob(db: Kysely<DB>, id: string): Promise<ScheduledJobRow> {
|
||||
const row = await db.selectFrom("scheduled_job").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", `定时任务不存在:${id}`);
|
||||
if (!row) throw new ApiException("not_found", `定时任务不存在:${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function updateJob(
|
||||
});
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
|
||||
if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
|
||||
set.enabled = patch.enabled;
|
||||
set.customized = true;
|
||||
if (patch.enabled && !row.enabled && !set.next_run_at) {
|
||||
@@ -228,7 +228,7 @@ export async function updateJob(
|
||||
/** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */
|
||||
export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> {
|
||||
const row = await getJob(db, id);
|
||||
if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
|
||||
if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
|
||||
const upd = await db
|
||||
.updateTable("scheduled_job")
|
||||
.set({ run_requested_at: sql`now()` })
|
||||
@@ -262,7 +262,7 @@ export async function skipNext(db: Kysely<DB>, id: string, now: Date): Promise<S
|
||||
started_at: now,
|
||||
finished_at: now,
|
||||
duration_ms: 0,
|
||||
summary: `跳过 ${base.toISOString()},下次 ${next.toISOString()}`,
|
||||
summary: `跳过 ${base.toISOString()},下次 ${next.toISOString()}`,
|
||||
})
|
||||
.execute();
|
||||
await trimRuns(trx, id);
|
||||
@@ -317,6 +317,6 @@ export async function createAgentCheck(db: Kysely<DB>, body: AgentCheckCreate, n
|
||||
|
||||
export async function deleteJob(db: Kysely<DB>, id: string): Promise<void> {
|
||||
const row = await getJob(db, id);
|
||||
if (row.builtin || row.kind !== "agent_check") throw new ApiException("conflict", "内置任务不能删除,可以停用");
|
||||
if (row.builtin || row.kind !== "agent_check") throw new ApiException("conflict", "内置任务不能删除,可以停用");
|
||||
await db.deleteFrom("scheduled_job").where("id", "=", id).execute();
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export class InternalDispatcher {
|
||||
return c.body(null, 500);
|
||||
});
|
||||
inner.notFound((c) => {
|
||||
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
|
||||
this.errors.set(c.req.raw, new ApiException("internal", `内部端点不存在:${c.req.method} ${c.req.path}`));
|
||||
return c.body(null, 404);
|
||||
});
|
||||
this.inner = inner;
|
||||
|
||||
@@ -208,7 +208,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
row = await find();
|
||||
}
|
||||
}
|
||||
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
|
||||
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
|
||||
return dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
|
||||
...(body as SearchReleasePlanRequest),
|
||||
sourceReleaseId: row.id,
|
||||
|
||||
@@ -102,7 +102,7 @@ export const D = {
|
||||
method: "get" as const,
|
||||
path: "/sites",
|
||||
scope: "read" as const,
|
||||
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
|
||||
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
|
||||
tags: TS,
|
||||
query: SiteListQuery,
|
||||
response: cursorPage(SiteListItem),
|
||||
@@ -117,7 +117,7 @@ export const D = {
|
||||
body: SiteCreate,
|
||||
response: Site,
|
||||
},
|
||||
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
|
||||
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
|
||||
},
|
||||
siteAccounts: {
|
||||
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
|
||||
@@ -126,7 +126,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/site-accounts",
|
||||
scope: "admin" as const,
|
||||
summary: "新建站点账号(凭证只写,存为 secret)",
|
||||
summary: "新建站点账号(凭证只写,存为 secret)",
|
||||
tags: TS,
|
||||
body: SiteAccountCreate,
|
||||
response: SiteAccount,
|
||||
@@ -148,7 +148,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/feed-sources",
|
||||
scope: "admin" as const,
|
||||
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
|
||||
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
|
||||
tags: TF,
|
||||
body: FeedSourceCreate,
|
||||
response: FeedSource,
|
||||
@@ -158,7 +158,7 @@ export const D = {
|
||||
"post",
|
||||
"/feed-sources/{id}/fetch",
|
||||
"admin",
|
||||
"立即抓取(仍受站点限流)",
|
||||
"立即抓取(仍受站点限流)",
|
||||
undefined,
|
||||
z.object({ taskId: Id }),
|
||||
TF,
|
||||
@@ -202,7 +202,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/media-entities",
|
||||
scope: "organize.execute" as const,
|
||||
summary: "手动建本地作品(TMDB 无记录时可先建档)",
|
||||
summary: "手动建本地作品(TMDB 无记录时可先建档)",
|
||||
tags: TE,
|
||||
body: MediaEntityCreate,
|
||||
response: MediaEntity,
|
||||
@@ -211,7 +211,7 @@ export const D = {
|
||||
"patch",
|
||||
"/media-entities/{id}",
|
||||
"organize.execute",
|
||||
"修改作品(标题/原名/年份按字段覆盖并锁定)",
|
||||
"修改作品(标题/原名/年份按字段覆盖并锁定)",
|
||||
MediaEntityUpdate,
|
||||
MediaEntity,
|
||||
TE,
|
||||
@@ -220,7 +220,7 @@ export const D = {
|
||||
"post",
|
||||
"/media-entities/{id}/merge",
|
||||
"organize.execute",
|
||||
"合并重复作品(保留旧 ID 跳转)",
|
||||
"合并重复作品(保留旧 ID 跳转)",
|
||||
MediaEntityMerge,
|
||||
MediaEntity,
|
||||
TE,
|
||||
@@ -239,7 +239,7 @@ export const D = {
|
||||
"patch",
|
||||
"/content-units/{id}",
|
||||
"organize.execute",
|
||||
"修改内容单元(改编号即确认并锁定)",
|
||||
"修改内容单元(改编号即确认并锁定)",
|
||||
ContentUnitUpdate,
|
||||
ContentUnit,
|
||||
TE,
|
||||
@@ -270,7 +270,7 @@ export const D = {
|
||||
"post",
|
||||
"/external-identities",
|
||||
"organize.execute",
|
||||
"新增外部身份(accepted 等同人工纠错)",
|
||||
"新增外部身份(accepted 等同人工纠错)",
|
||||
ExternalIdentityCreate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
@@ -280,7 +280,7 @@ export const D = {
|
||||
"patch",
|
||||
"/external-identities/{id}",
|
||||
"organize.execute",
|
||||
"修订外部身份(乐观并发)",
|
||||
"修订外部身份(乐观并发)",
|
||||
ExternalIdentityUpdate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
@@ -295,7 +295,7 @@ export const D = {
|
||||
decisions: {
|
||||
list: list(
|
||||
"/decisions",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
Decision,
|
||||
{ status: DecisionStatus.optional(), kind: DecisionKind.optional() },
|
||||
TD,
|
||||
|
||||
@@ -58,7 +58,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
return { summary: `${await fn(rt)} 项` };
|
||||
},
|
||||
});
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
|
||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||
@@ -66,7 +66,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.user_data",
|
||||
defaults: {
|
||||
name: "站点数据刷新",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -80,7 +80,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.health_check",
|
||||
defaults: {
|
||||
name: "站点健康检测",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
|
||||
@@ -154,7 +154,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.where("source_release_id", "=", d.subject_id)
|
||||
.executeTakeFirst();
|
||||
if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) {
|
||||
throw new ApiException("conflict", "候选已变化,刷新后重试");
|
||||
throw new ApiException("conflict", "候选已变化,刷新后重试");
|
||||
}
|
||||
await assignReleaseIdentity(
|
||||
rt(),
|
||||
|
||||
@@ -112,7 +112,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/detail",
|
||||
scope: "read",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: MediaEntityDetail,
|
||||
@@ -202,7 +202,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/identities",
|
||||
scope: "organize.execute",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: EntityIdentitySet,
|
||||
@@ -221,7 +221,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
@@ -248,7 +248,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
tags: T_ENT,
|
||||
params: FieldParam,
|
||||
body: FieldOverrideInput,
|
||||
@@ -501,7 +501,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/identity-pool",
|
||||
scope: "read",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
tags: T_ID,
|
||||
query: IdentityPoolQuery,
|
||||
response: cursorPage(IdentityPoolItem),
|
||||
@@ -600,7 +600,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/dismiss",
|
||||
scope: "organize.execute",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: z.object({ note: z.string().max(2000).optional() }),
|
||||
|
||||
@@ -75,7 +75,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/site-definitions",
|
||||
scope: "read",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
tags: T_SITES,
|
||||
response: z.object({ items: z.array(SiteDefinitionView) }),
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
|
||||
}
|
||||
if (!def && body.adapter !== "generic_rss") {
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
}
|
||||
try {
|
||||
const row = await db()
|
||||
@@ -199,7 +199,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: `/sites/{id}/${enabled ? "enable" : "disable"}`,
|
||||
scope: "admin",
|
||||
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: Site,
|
||||
@@ -213,7 +213,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/snapshot",
|
||||
scope: "admin",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
|
||||
@@ -236,7 +236,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/snapshots",
|
||||
scope: "read",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
query: CursorQuery,
|
||||
@@ -264,7 +264,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "delete",
|
||||
path: "/sites/{id}",
|
||||
scope: "admin",
|
||||
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
|
||||
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
@@ -281,7 +281,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/health-check",
|
||||
scope: "admin",
|
||||
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
|
||||
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: SiteHealthResult,
|
||||
@@ -294,7 +294,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/refresh-login",
|
||||
scope: "admin",
|
||||
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
|
||||
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: SiteRefreshLoginInput,
|
||||
@@ -356,7 +356,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "put",
|
||||
path: "/sites/{id}/credentials",
|
||||
scope: "admin",
|
||||
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
|
||||
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: SiteCredentialsPut,
|
||||
@@ -625,7 +625,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/reset",
|
||||
scope: "admin",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
tags: T_FEEDS,
|
||||
params: IdParam,
|
||||
response: FeedSource,
|
||||
@@ -713,7 +713,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/identity",
|
||||
scope: "organize.execute",
|
||||
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
tags: T_RELEASES,
|
||||
params: IdParam,
|
||||
body: ReleaseIdentityAssign,
|
||||
|
||||
@@ -17,7 +17,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases",
|
||||
scope: "read",
|
||||
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id }),
|
||||
query: WorkbenchReleaseQuery,
|
||||
@@ -31,7 +31,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases/{rid}",
|
||||
scope: "read",
|
||||
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id, rid: Id }),
|
||||
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
|
||||
|
||||
@@ -86,7 +86,7 @@ export class SourcesRuntime {
|
||||
if (/^[0-9a-f]{32}$/i.test(secret)) apiKey = secret;
|
||||
else accessToken = secret;
|
||||
} catch (err) {
|
||||
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
|
||||
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
|
||||
}
|
||||
} else {
|
||||
apiKey = env("TMDB_API_KEY");
|
||||
|
||||
@@ -41,14 +41,14 @@ export async function createEntityFromTmdbCandidate(
|
||||
if (existing) return describe(db, existing, false, 0);
|
||||
|
||||
const client = await tmdb();
|
||||
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
|
||||
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
|
||||
let detail: Awaited<ReturnType<TmdbClient["details"]>>;
|
||||
try {
|
||||
detail = await client.details(ref.kind, ref.externalId);
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`);
|
||||
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
|
||||
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function ingestFeed(
|
||||
}
|
||||
|
||||
const lastError = auth
|
||||
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
|
||||
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
|
||||
: (state.lastError ?? null);
|
||||
// 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态
|
||||
const gap: StoredCursor["gap"] =
|
||||
@@ -209,13 +209,13 @@ export async function ingestFeed(
|
||||
kind: "source_unreachable",
|
||||
severity: "warning",
|
||||
title: `Feed「${feed.name}」凭证失效`,
|
||||
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
|
||||
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
|
||||
subjectType: "feed_source",
|
||||
subjectId: feed.id,
|
||||
dedupeKey: `feed-auth:${feed.id}`,
|
||||
evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError },
|
||||
options: [
|
||||
{ key: "reset", label: "已更新凭证,恢复抓取" },
|
||||
{ key: "reset", label: "已更新凭证,恢复抓取" },
|
||||
{ key: "disable", label: "停用 Feed" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
|
||||
const hit = await entityByExternal(exec, ref);
|
||||
if (hit) return hit;
|
||||
if (ref.provider !== "tmdb")
|
||||
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
|
||||
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
|
||||
let detail: Awaited<ReturnType<typeof tmdb.details>>;
|
||||
try {
|
||||
detail = await tmdb.details(ref.entityType, ref.externalId);
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new ApiException("not_found", `TMDB 无此条目 ${ref.entityType}/${ref.externalId}`);
|
||||
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
|
||||
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
const r = await createEntityFromTmdb(exec, detail, {
|
||||
@@ -241,7 +241,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const other = await entityByExternal(trx, input);
|
||||
if (other && other !== id) {
|
||||
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
|
||||
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
|
||||
}
|
||||
if (other === id) return { mediaEntityId: id, changed: false };
|
||||
const olds = await trx
|
||||
@@ -290,7 +290,7 @@ export async function updateExternalIdentity(
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "外部身份不存在");
|
||||
if (row.revision !== input.expectedRevision) {
|
||||
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
|
||||
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
|
||||
}
|
||||
const rev = Math.max(row.revision + 1, await nextRevision(trx, id));
|
||||
const patch: Record<string, unknown> = { revision: rev };
|
||||
@@ -365,7 +365,7 @@ export async function mergeEntities(
|
||||
if (!from) throw new ApiException("not_found", "源作品不存在");
|
||||
if (!intoResolved) throw new ApiException("not_found", "目标作品不存在");
|
||||
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
|
||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
|
||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
|
||||
|
||||
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
|
||||
const idents = await trx
|
||||
|
||||
@@ -511,13 +511,13 @@ function manualIdentityOptions(sourceReleaseId: string) {
|
||||
{
|
||||
key: "manual_tmdb",
|
||||
label: "手动指定 TMDB ID",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
input: { sourceReleaseId, requires: "tmdb" },
|
||||
},
|
||||
{
|
||||
key: "local_entity",
|
||||
label: "选本地作品",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
input: { sourceReleaseId, requires: "mediaEntityId" },
|
||||
},
|
||||
];
|
||||
@@ -592,7 +592,7 @@ async function toPool(
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_ambiguous",
|
||||
severity: "info",
|
||||
title: `待确认身份:${release.title}`,
|
||||
title: `待确认身份:${release.title}`,
|
||||
summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
@@ -615,10 +615,10 @@ async function toPool(
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_unresolved",
|
||||
severity: "info",
|
||||
title: `待识别:${release.title}`,
|
||||
title: `待识别:${release.title}`,
|
||||
summary:
|
||||
reason === "lookup_failed"
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||
: `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
|
||||
@@ -91,7 +91,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
|
||||
subjectId: site.id,
|
||||
dedupeKey: `site-auth:${site.id}`,
|
||||
evidence: { siteId: site.id, kind: e.kind, httpStatus: e.httpStatus ?? null },
|
||||
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
|
||||
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
|
||||
});
|
||||
// 有密码的账号:自动重登一次(同账号 10 分钟内最多一次),成功后关闭本待处理并复位站点健康
|
||||
await enqueueRelogin(rt, site.id);
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function liveSearch(
|
||||
const st = liveState(rt);
|
||||
const key = `${site.id}|${normalizeTitle(q) || q.toLowerCase()}`;
|
||||
const failed = st.failedUntil.get(key);
|
||||
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
|
||||
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
|
||||
const running = st.inflight.get(key);
|
||||
if (running) return { ...(await running), coalesced: true };
|
||||
const p = (async () => {
|
||||
@@ -149,7 +149,7 @@ export async function listSiteReleases(
|
||||
let coalesced = false;
|
||||
const wantLive = q.live === "true" || (q.live !== "false" && offset === 0 && !!q.q && (rows.length === 0 || stale));
|
||||
if (wantLive && q.q) {
|
||||
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
|
||||
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
|
||||
else {
|
||||
const r = await liveSearch(rt, site, q.q);
|
||||
live = r.error === null;
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
|
||||
const prod = config.NODE_ENV === "production";
|
||||
|
||||
if (prod && PLACEHOLDER.test(config.SECRET_MASTER_KEY)) {
|
||||
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
|
||||
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
|
||||
}
|
||||
if (prod && uid === 0) {
|
||||
problems.push({ check: "user", message: "生产环境不允许以 root 运行" });
|
||||
@@ -32,7 +32,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
|
||||
try {
|
||||
await sql`select 1`.execute(db);
|
||||
} catch (err) {
|
||||
problems.push({ check: "db", message: `数据库不可达: ${(err as Error).message}` });
|
||||
problems.push({ check: "db", message: `数据库不可达:${(err as Error).message}` });
|
||||
}
|
||||
if (config.WEB_ROOT) {
|
||||
try {
|
||||
@@ -109,7 +109,7 @@ export async function checkStorage(opts: {
|
||||
await Deno.mkdir(dir, { recursive: true });
|
||||
await probeWrite(dir);
|
||||
} catch (e) {
|
||||
problems.push({ check: "data_dir", message: `数据目录不可写: ${dir}(${errText(e)});检查卷挂载与 PUID/PGID` });
|
||||
problems.push({ check: "data_dir", message: `数据目录不可写:${dir}(${errText(e)});检查卷挂载与 PUID/PGID` });
|
||||
}
|
||||
}
|
||||
for (const m of opts.mounts) {
|
||||
@@ -118,13 +118,13 @@ export async function checkStorage(opts: {
|
||||
if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" });
|
||||
for await (const _ of Deno.readDir(m.path)) break;
|
||||
} catch (e) {
|
||||
warnings.push({ ...m, reason: `不可读: ${errText(e)}` });
|
||||
warnings.push({ ...m, reason: `不可读:${errText(e)}` });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await probeWrite(m.path);
|
||||
} catch (e) {
|
||||
warnings.push({ ...m, reason: `不可写: ${errText(e)}` });
|
||||
warnings.push({ ...m, reason: `不可写:${errText(e)}` });
|
||||
}
|
||||
}
|
||||
return { problems, warnings };
|
||||
|
||||
Reference in new issue
Block a user