docs(plan): v0.2 adds Pi subagents and scheduled-jobs board
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
b839ed4056
commit
16ab2a6c84
97 files changed
+592
-648
No files matched your search
@@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
|
||||
for (const dryRun of [true, false]) {
|
||||
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.error.message).toContain("不在已登记的媒体库根或下载器路径映射之下");
|
||||
expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下");
|
||||
}
|
||||
// `..` 即使规范化后仍在根内也拒绝
|
||||
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
|
||||
@@ -240,7 +240,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
|
||||
// 按下载任务 ID(管理员也一样被做种义务拦下)
|
||||
const byId = await post({ downloadTaskId: dtId, action: "move" });
|
||||
expect(byId.status).toBe(409);
|
||||
expect(byId.body.error.message).toContain("未改为 Copy");
|
||||
expect(byId.body.error.message).toContain("未改为复制");
|
||||
// 按路径命中纳管任务的内容
|
||||
const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv");
|
||||
expect(existsSync(content)).toBe(true);
|
||||
|
||||
@@ -80,13 +80,13 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
|
||||
if (!slot) throw new CapabilityError("not_found", "收录目标(Slot)不存在");
|
||||
if (!slot) throw new CapabilityError("not_found", "版本目标不存在");
|
||||
const rel = await db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "media_entity_id"])
|
||||
.where("id", "=", input.sourceReleaseId)
|
||||
.executeTakeFirst();
|
||||
if (!rel) throw new CapabilityError("not_found", "来源资源不存在");
|
||||
if (!rel) throw new CapabilityError("not_found", "资源不存在");
|
||||
const out = await evaluateAcquisition(d, {
|
||||
sourceReleaseId: input.sourceReleaseId,
|
||||
acquisitionSlotId: input.slotId,
|
||||
@@ -111,7 +111,7 @@ 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: "未生成计划(Slot 忙或条件不满足)" });
|
||||
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
|
||||
}
|
||||
const fresh = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -148,8 +148,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
.where("id", "=", plan.slot_id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "Slot 已变化,请重新生成计划";
|
||||
if (BUSY.includes(slot.status)) return `Slot 正忙(${slot.status})`;
|
||||
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" });
|
||||
st = transition(st, { type: "reserve" });
|
||||
@@ -172,7 +172,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
if ((err as { code?: string }).code === "23505") return "Slot 已有其它有效预留";
|
||||
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
|
||||
throw err;
|
||||
});
|
||||
if (ok) throw new CapabilityError("conflict", ok);
|
||||
@@ -205,7 +205,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
registry.register(capabilitySpec("organize.plan"), async (input, ctx) => {
|
||||
const d = deps();
|
||||
if (!input.downloadTaskId)
|
||||
throw new CapabilityError("precondition", "v0.1 只支持按下载任务(downloadTaskId)生成整理计划");
|
||||
throw new CapabilityError("precondition", "只支持按下载任务(downloadTaskId)生成整理计划");
|
||||
const dt = await d.ctx.db
|
||||
.selectFrom("download_task")
|
||||
.select(["status"])
|
||||
@@ -231,9 +231,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
|
||||
@@ -315,7 +313,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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -121,9 +121,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,8 +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 },
|
||||
|
||||
@@ -137,8 +137,8 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
|
||||
if (block) {
|
||||
throw new ApiException(
|
||||
"conflict",
|
||||
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},` +
|
||||
`拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
|
||||
`待处理事项 ${block.decisionId}`,
|
||||
);
|
||||
}
|
||||
await audit(d, actorOf(c), "organize.move_authorized", {
|
||||
|
||||
@@ -249,7 +249,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!sub) notFound("订阅");
|
||||
if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅尚未关联作品,无法按标题搜索");
|
||||
if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅未关联作品,无法搜索");
|
||||
try {
|
||||
return await enqueueSubscriptionSearch(d, sub!.id, "manual");
|
||||
} catch (err) {
|
||||
@@ -371,7 +371,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
Api.acquisitionSlots.get,
|
||||
async (_c, { params }) => {
|
||||
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
return toApi<AcquisitionSlot>(r ?? notFound("收录目标"));
|
||||
return toApi<AcquisitionSlot>(r ?? notFound("版本目标"));
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
@@ -404,7 +404,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!r) conflict("同一作品/内容单元下已有同名 Slot");
|
||||
if (!r) conflict("该作品或内容单元下已有同名版本目标");
|
||||
await kickEvaluate(d, { acquisitionSlotId: r!.id });
|
||||
return toApi<AcquisitionSlot>(r!);
|
||||
},
|
||||
@@ -439,7 +439,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.select("revision")
|
||||
.where("id", "=", params.id)
|
||||
.executeTakeFirst();
|
||||
if (!exists) notFound("收录目标");
|
||||
if (!exists) notFound("版本目标");
|
||||
conflict(`revision 不匹配(当前 ${exists!.revision})`);
|
||||
}
|
||||
await kickEvaluate(d, { acquisitionSlotId: params.id });
|
||||
@@ -523,7 +523,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")
|
||||
@@ -670,7 +670,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.executeTakeFirst();
|
||||
if (sup) {
|
||||
// Agent 不得据旧证据自动加回;用户手动(或对话确认)重新添加即解除抑制
|
||||
if (origin === "agent_inferred") conflict("该偏好已被用户取消(存在抑制记录),Agent 不能自动加回");
|
||||
if (origin === "agent_inferred") conflict("用户已取消该偏好,Agent 不能自动加回");
|
||||
await trx
|
||||
.updateTable("preference_suppression")
|
||||
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
|
||||
throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p);
|
||||
}
|
||||
if (!roots.some((root) => within(r, root)))
|
||||
throw new PathGuardError(`源路径不在已登记的媒体库根或下载器路径映射之下: ${p}`, p);
|
||||
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下: ${p}`, p);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
@@ -205,7 +205,7 @@ export async function authorizeMoveEntities(
|
||||
const req = ((a.content ?? {}) as { request?: Parameters<typeof requestKey>[0] }).request;
|
||||
if (!req || requestKey(req) !== key) continue;
|
||||
if (a.payload_hash !== a.content_hash) {
|
||||
reasons.push(`审批 ${a.id} 绑定的计划内容已变化`);
|
||||
reasons.push(`审批 ${a.id} 对应的计划内容已变化`);
|
||||
continue;
|
||||
}
|
||||
// 预检(不消费):执行阶段会再调一次并原子消费
|
||||
@@ -249,7 +249,7 @@ export async function authorizeMoveEntities(
|
||||
else
|
||||
for (const g of grants) {
|
||||
if (g.policy_revision_id && g.policy_status !== "published") {
|
||||
why.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
|
||||
why.push(`授权 ${g.id} 的策略版本已不是发布状态`);
|
||||
continue;
|
||||
}
|
||||
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
|
||||
@@ -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)
|
||||
? `Move 需要授权:${detail.join(";")}`
|
||||
: "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
|
||||
? `移动需要授权:${detail.join(";")}`
|
||||
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,11 +339,11 @@ export async function hnrBlockForMove(
|
||||
if (hnr === true) continue;
|
||||
const decisionId = await upsertDecision(d, db, {
|
||||
kind: "approval_required",
|
||||
title: `手动 Move 被做种义务阻止:${dt.name ?? dt.torrent_hash}`,
|
||||
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
|
||||
summary:
|
||||
hnr === false
|
||||
? "来源是纳管下载任务,H&R / 做种要求尚未满足;Move 会删源并移除任务,已拒绝(未改为 Copy)"
|
||||
: "来源是纳管下载任务,H&R 要求未知,按未满足处理;Move 已拒绝(未改为 Copy)",
|
||||
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
|
||||
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
|
||||
subjectType: "download_task",
|
||||
subjectId: dt.id,
|
||||
dedupeKey: `manual_move_hnr:${dt.id}`,
|
||||
@@ -356,8 +356,8 @@ export async function hnrBlockForMove(
|
||||
requestedBy: ctx.actor,
|
||||
},
|
||||
options: [
|
||||
{ key: "wait", label: "等待做种义务满足后再 Move" },
|
||||
{ key: "copy", label: "改用 Copy 入库(需重新发起)" },
|
||||
{ key: "wait", label: "做种满足后再移动" },
|
||||
{ key: "copy", label: "改用复制(需重新发起)" },
|
||||
],
|
||||
});
|
||||
return { downloadTaskId: dt.id, hnr, decisionId };
|
||||
|
||||
@@ -327,7 +327,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
await decide_({
|
||||
kind: "identity_ambiguous",
|
||||
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
|
||||
summary: "下载内容没有关联本地作品,需要先识别/指定作品再整理",
|
||||
summary: "下载内容未关联作品。识别或指定作品后再整理",
|
||||
subjectType: "organization_record",
|
||||
subjectId: rid.recordId,
|
||||
dedupeKey: `organize_identity:${dt?.id ?? rid.recordId ?? "x"}`,
|
||||
@@ -387,8 +387,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}`,
|
||||
@@ -440,7 +440,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
await decide_({
|
||||
kind: "path_conflict",
|
||||
title: `作品存在多个历史目录:${entity.title}`,
|
||||
summary: "不按最新标题自动合并,请指定保留的目录",
|
||||
summary: "不会按最新标题自动合并。指定要保留的目录",
|
||||
subjectType: "media_entity",
|
||||
subjectId: entity.id,
|
||||
dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`,
|
||||
@@ -622,7 +622,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 ? `Move 入库被做种义务阻止:${entity.title}` : `整理冲突:${entity.title}`,
|
||||
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
|
||||
summary: blocking
|
||||
.map((c) => c.detail ?? c.kind)
|
||||
.join(";")
|
||||
@@ -633,10 +633,10 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
evidence: { conflicts: blocking, action },
|
||||
options: hnrConflict
|
||||
? [
|
||||
{ key: "wait", label: "等待做种义务满足后再 Move" },
|
||||
{ key: "copy", label: "授权改用 Copy 入库" },
|
||||
{ key: "wait", label: "做种满足后再移动" },
|
||||
{ key: "copy", label: "改用复制" },
|
||||
]
|
||||
: [{ key: "review", label: "查看冲突并指定处理方式" }],
|
||||
: [{ key: "review", label: "指定处理方式" }],
|
||||
});
|
||||
if (slotId) await applySlotEvent(d, trx, slotId, { type: "verify_failed" }, { reason: reason ?? "blocked" });
|
||||
}
|
||||
@@ -750,7 +750,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)
|
||||
@@ -910,13 +910,13 @@ 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}`,
|
||||
evidence: { src: pf.src, dst: pf.dst, error: outcome.error, attempts: outcome.attempt ?? null },
|
||||
options: [{ key: "retry", label: "检查挂载后重新执行整理" }],
|
||||
options: [{ key: "retry", label: "检查挂载后重试" }],
|
||||
});
|
||||
}
|
||||
// 被替换的旧文件登记进文件管理回收站(可在回收站视图还原/清除);登记失败只告警,旧文件留在 .umt-trash
|
||||
@@ -927,7 +927,7 @@ export async function executeRecord(
|
||||
if (outcome.replaced?.disposition === "left") {
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: "替换入库后旧文件未能处置",
|
||||
title: "新版本已入库,旧文件未能处置",
|
||||
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
@@ -938,7 +938,7 @@ export async function executeRecord(
|
||||
if (outcome.status === "attention") {
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: "跨卷搬运后删源失败",
|
||||
title: "跨卷移动后删除源文件失败",
|
||||
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
@@ -1168,7 +1168,7 @@ async function transferFile(
|
||||
if (!x.allowCrossVolume)
|
||||
return {
|
||||
status: "failed",
|
||||
error: { code: "EXDEV", message: "跨卷 Move 未启用(settings.organize.allowCrossVolumeMove)" },
|
||||
error: { code: "EXDEV", message: "未启用跨卷移动(settings.organize.allowCrossVolumeMove)" },
|
||||
};
|
||||
const r = await px.crossDeviceMove(src, dst, { hash: d.ports.options.verifyHash }, call);
|
||||
const failedStep = r.steps.find((st) => st.status === "failed");
|
||||
@@ -1469,7 +1469,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}`,
|
||||
|
||||
@@ -464,8 +464,8 @@ export async function afterSlotVerified(
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "other",
|
||||
severity: "info",
|
||||
title: "疑似缺集(总集数未知,继续追踪)",
|
||||
summary: `已入库的最高集以下仍缺:${desc}`.slice(0, 500),
|
||||
title: "疑似缺集",
|
||||
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
|
||||
subjectType: "acquisition_slot",
|
||||
subjectId: slot.id,
|
||||
dedupeKey: `suspected_missing:${slot.id}`,
|
||||
|
||||
@@ -109,8 +109,8 @@ export function createAuthorizer(deps: {
|
||||
// 自动化总开关 / 只读对账:自动来源(agent / 后台任务)一律不执行写操作
|
||||
if (ctx.source !== "web" && d.kind === "execute") {
|
||||
const a = await deps.settings.get("automation");
|
||||
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,自动执行被拒绝" };
|
||||
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式,自动执行被拒绝" };
|
||||
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,不自动执行" };
|
||||
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式下不自动执行" };
|
||||
}
|
||||
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const DEMO_SCRIPTS: FakeScript[] = [
|
||||
finalText: (results) => {
|
||||
const r = results.find((x) => x.capabilityId === "inventory.query");
|
||||
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
|
||||
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不会提交下载;需要真实执行请在设置里配置 Pi 上游。`;
|
||||
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -126,11 +126,10 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
|
||||
}
|
||||
|
||||
function suggestionFor(d: DecisionRow, reason: string): string {
|
||||
if (d.kind === "identity_ambiguous")
|
||||
return `${reason}。建议在待处理中心人工选择身份,或发布识别策略授权 identity.resolve。`;
|
||||
if (d.kind === "identity_ambiguous") return `${reason}。建议在待处理中人工选择身份,或为 identity.resolve 发放授权。`;
|
||||
return (
|
||||
`${reason}。建议:在待处理中心选择"强制重新校验"(download.recheck),或为自动恢复发放持续授权` +
|
||||
`(action=download.recheck / task.retry,可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
|
||||
`${reason}。建议在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权` +
|
||||
`(可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,9 +233,9 @@ export async function autoInvestigate(
|
||||
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
|
||||
taskId ? `关联任务 ${taskId}。` : "",
|
||||
mode === "recover"
|
||||
? "在已授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
|
||||
: "只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。",
|
||||
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
].join("");
|
||||
const { run, created } = await startRun(ctx, {
|
||||
goal,
|
||||
@@ -382,14 +381,14 @@ export async function applyRecoveryOutcome(
|
||||
outcome === "recovered"
|
||||
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
|
||||
: outcome === "failed"
|
||||
? `未恢复:执行 ${what} 后业务状态仍为失败,已交还待处理中心。`
|
||||
? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。`
|
||||
: outcome === "unverified"
|
||||
? `未恢复:执行 ${what} 后在核实时限内未见业务状态恢复,已交还待处理中心。`
|
||||
? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。`
|
||||
: outcome === "budget_exceeded"
|
||||
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已交还待处理中心。`
|
||||
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。`
|
||||
: outcome === "cancelled"
|
||||
? "未恢复:运行已取消。"
|
||||
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已交还待处理中心。`;
|
||||
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`;
|
||||
const evidence: Partial<AutoRecoverEvidence> = {
|
||||
runId,
|
||||
outcome,
|
||||
|
||||
@@ -31,13 +31,13 @@ export function externalUpstream(capabilityId: string, input: unknown): Upstream
|
||||
|
||||
export class UpstreamBusyError extends Error {
|
||||
constructor(readonly upstream: Upstream) {
|
||||
super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,请稍后重试`);
|
||||
super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,稍后重试`);
|
||||
}
|
||||
}
|
||||
|
||||
export class UpstreamTimeoutError extends Error {
|
||||
constructor(readonly upstream: Upstream) {
|
||||
super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s),已放弃等待`);
|
||||
super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ export async function buildGoalReport(
|
||||
const done = records.filter((r) => r.status === "succeeded" || r.status === "partial");
|
||||
const summary =
|
||||
state === "no_actions"
|
||||
? "本次运行没有提交下载或整理;结论见模型汇报(未产生可核查的业务变更)。"
|
||||
? "本次没有提交下载或整理,结论见模型汇报。"
|
||||
: [
|
||||
`已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`,
|
||||
state === "waiting" ? "已提交≠已入库:仍在等待下载完成与核验入库。" : "",
|
||||
state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "",
|
||||
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -227,7 +227,7 @@ export function registerAgentRoutes(
|
||||
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!cur) throw new ApiException("not_found", "授权不存在");
|
||||
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
|
||||
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围请新建授权");
|
||||
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权");
|
||||
const r = await db()
|
||||
.updateTable("automation_grant")
|
||||
.set({
|
||||
@@ -302,7 +302,7 @@ export function registerAgentRoutes(
|
||||
.executeTakeFirst();
|
||||
if (!cur) throw new ApiException("not_found", "审批不存在");
|
||||
if (cur.payload_hash !== body.payloadHash)
|
||||
throw new ApiException("conflict", "payloadHash 不一致:计划已变化,请重新申请审批");
|
||||
throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批");
|
||||
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
|
||||
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
|
||||
return trx
|
||||
@@ -361,16 +361,15 @@ export function registerAgentRoutes(
|
||||
.select(["id", "media_entity_id"])
|
||||
.where("id", "=", body.sourceReleaseId)
|
||||
.executeTakeFirst();
|
||||
if (!rel) throw new ApiException("not_found", "来源资源不存在");
|
||||
if (!rel) throw new ApiException("not_found", "资源不存在");
|
||||
let slotIds: string[];
|
||||
if (body.slotId) {
|
||||
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
|
||||
if (!s) throw new ApiException("not_found", "收录目标(Slot)不存在");
|
||||
if (!s) throw new ApiException("not_found", "版本目标不存在");
|
||||
slotIds = [s.id];
|
||||
} else {
|
||||
const entityId = body.entityId ?? rel.media_entity_id;
|
||||
if (!entityId)
|
||||
throw new ApiException("precondition_failed", "来源资源尚未识别到本地作品,请先在“身份与元数据”指定作品");
|
||||
if (!entityId) throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在“身份与元数据”指定作品");
|
||||
slotIds = (
|
||||
await db()
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -382,7 +381,7 @@ export function registerAgentRoutes(
|
||||
}
|
||||
const rejected: { slotId: string | null; code: string; message: string }[] = [];
|
||||
if (slotIds.length === 0)
|
||||
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标(Slot),请先在订阅里建目标" });
|
||||
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标,先在订阅里新建" });
|
||||
const invokeCtx = {
|
||||
actorId,
|
||||
taskId: `http:${c.get("requestId") ?? "req"}`,
|
||||
|
||||
@@ -123,7 +123,7 @@ async function agentSettings(
|
||||
const pi = s.pi;
|
||||
let apiKey = "fake-key-not-used";
|
||||
if (!fake) {
|
||||
if (!pi.apiKeySecretRef) throw new Error("未配置 Pi 上游密钥(设置 → pi.apiKey)");
|
||||
if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游密钥(设置 → pi.apiKey)");
|
||||
apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef);
|
||||
if (!pi.model) throw new Error("未配置模型(设置 → pi.model)");
|
||||
}
|
||||
@@ -250,17 +250,16 @@ export async function reconcilePending(
|
||||
function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
|
||||
const constraints: string[] = [
|
||||
`可用能力:${scope.capabilities.join(", ")}`,
|
||||
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒绝时如实说明缺少哪项授权。",
|
||||
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
|
||||
];
|
||||
if (run.mode === "plan_only")
|
||||
constraints.push("本次为“先给方案”:只调查与规划,不提交下载、不做文件变更、不发布策略。");
|
||||
if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。");
|
||||
if (run.trigger === "event" && run.mode === "execute")
|
||||
constraints.push(
|
||||
"本任务由后台事件触发,是有界的授权内恢复:先查证据,再只在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
|
||||
"每种恢复动作最多一次;不换源、不删除、不清理;恢复是否成功由系统按业务状态核实,不要自行宣称已恢复。",
|
||||
"本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
|
||||
"每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。",
|
||||
);
|
||||
else if (run.trigger === "event")
|
||||
constraints.push("本任务由后台事件触发,是有界的只读调查:收集证据、给出结论与建议,不要反复重试同一操作。");
|
||||
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
|
||||
return buildSystemPrompt({
|
||||
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手",
|
||||
goal: run.goal,
|
||||
|
||||
@@ -333,7 +333,7 @@ export async function autoInvestigate(
|
||||
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
|
||||
d.summary ? `摘要:${d.summary}。` : "",
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
"只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。",
|
||||
"只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
].join("");
|
||||
const { run, created } = await startRun(ctx, {
|
||||
goal,
|
||||
|
||||
@@ -136,7 +136,7 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
|
||||
endpoint(app, D.password, async ({ body, c, actor }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
|
||||
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
|
||||
const revoked = await changePassword(deps(), body, {
|
||||
adminUserId: session.adminUserId,
|
||||
sessionId: session.id,
|
||||
|
||||
@@ -103,7 +103,7 @@ async function recentFailures(db: Kysely<DB>, col: "ip" | "username", value: str
|
||||
export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> {
|
||||
const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]);
|
||||
if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) {
|
||||
throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试");
|
||||
throw new ApiException("rate_limited", "失败次数过多,15 分钟后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function setupAdmin(
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
|
||||
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
|
||||
throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)");
|
||||
throw new ApiException("unauthorized", "setup token 无效。使用服务端日志里最新打印的那个");
|
||||
}
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin) throw new Error("admin_user 行丢失");
|
||||
@@ -206,7 +206,7 @@ export async function verifyLogin(
|
||||
): Promise<LoginOutcome> {
|
||||
await assertNotRateLimited(deps.db, ip, input.username);
|
||||
const admin = await loadAdmin(deps.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化");
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
const userOk = admin.username === input.username;
|
||||
// 用户名不对也跑一次 argon2,避免用时差探测账号
|
||||
let pwOk = false;
|
||||
@@ -284,7 +284,7 @@ export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri:
|
||||
}
|
||||
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
|
||||
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥");
|
||||
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥");
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const counter = verifyTotp(secret, code, null);
|
||||
if (counter === null) throw new ApiException("unauthorized", "验证码错误");
|
||||
|
||||
@@ -68,7 +68,7 @@ const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"]
|
||||
|
||||
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
|
||||
|
||||
const LR = crudDefs("/library-roots", "媒体库根", {
|
||||
const LR = crudDefs("/library-roots", "库根", {
|
||||
row: LibraryRoot,
|
||||
create: LibraryRootCreate,
|
||||
update: LibraryRootUpdate,
|
||||
|
||||
@@ -174,7 +174,7 @@ export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
.where("id", "=", params.id)
|
||||
.returning("id")
|
||||
.executeTakeFirst()
|
||||
.catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用"));
|
||||
.catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用"));
|
||||
if (!r) throw new ApiException("not_found", "凭证不存在");
|
||||
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
|
||||
return { ok: true as const };
|
||||
|
||||
@@ -122,7 +122,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
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})`);
|
||||
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
|
||||
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHe
|
||||
export async function onlineRoot(d: FmDeps, def: RootDef): Promise<string> {
|
||||
const h = await probeRoot(def, d.options.healthTimeoutMs);
|
||||
if (h.health !== "healthy" || !h.real)
|
||||
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health}),不显示为空目录`);
|
||||
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health})`);
|
||||
return h.real;
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ export async function resolveIn(
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
|
||||
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 {
|
||||
@@ -302,7 +302,7 @@ export async function resolveIn(
|
||||
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 };
|
||||
}
|
||||
@@ -316,7 +316,7 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
|
||||
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
|
||||
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,13 +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", "只能下载文件,不能下载目录");
|
||||
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;
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function identify(
|
||||
if (remoteQueries >= REMOTE_QUERY_MAX) {
|
||||
res = {
|
||||
list: [],
|
||||
warning: `本次识别的远端查询已达上限 ${REMOTE_QUERY_MAX} 个,该项仅本地召回;可缩小范围后再识别`,
|
||||
warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`,
|
||||
};
|
||||
remoteCache.set(key, res);
|
||||
return res;
|
||||
@@ -193,7 +193,7 @@ export async function identify(
|
||||
}));
|
||||
res = { list, warning: null };
|
||||
} catch (e) {
|
||||
res = { list: [], warning: `远端候选不可用(仅本地召回):${errMessage(e).slice(0, 200)}` };
|
||||
res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` };
|
||||
}
|
||||
remoteCache.set(key, res);
|
||||
return res;
|
||||
|
||||
@@ -44,13 +44,12 @@ export async function loadPlan<C>(d: FmDeps, planId: string, op: string, request
|
||||
.select(["id", "content", "content_hash", "expires_at", "title", "kind"])
|
||||
.where("id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
if (row?.kind !== "plan" || row.title !== "files_plan")
|
||||
throw new FilesError("plan_stale", "planId 不存在,请重新预览");
|
||||
if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览");
|
||||
const content = row.content as StoredPlan<C>["content"];
|
||||
if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`);
|
||||
if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime())
|
||||
throw new FilesError("plan_stale", "计划已过期,请重新预览");
|
||||
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求内容与预览时不一致,请重新预览");
|
||||
throw new FilesError("plan_stale", "计划已过期,重新预览");
|
||||
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览");
|
||||
return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at };
|
||||
}
|
||||
|
||||
@@ -101,7 +100,7 @@ export async function idempotent<T>(
|
||||
if (!row) return undefined;
|
||||
const c = row.content as { reqHash: string; state: string; result?: T };
|
||||
if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求");
|
||||
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
|
||||
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
return c.result as T;
|
||||
};
|
||||
const prev = await replay();
|
||||
@@ -116,7 +115,7 @@ export async function idempotent<T>(
|
||||
if (!id) {
|
||||
const again = await replay();
|
||||
if (again !== undefined) return again;
|
||||
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
|
||||
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
|
||||
}
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
@@ -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(
|
||||
@@ -56,8 +56,8 @@ export async function renameEntry(
|
||||
body: FilesRenameRequest,
|
||||
): 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} 为只读登记(register_only)`);
|
||||
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名");
|
||||
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);
|
||||
@@ -124,10 +124,10 @@ export async function renameEntry(
|
||||
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 !== "moved") throw new FilesError("bad_request", `改名失败(${res.status}): ${p.from}`);
|
||||
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();
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function organizeFiles(
|
||||
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} 为只读登记,不能 Move`);
|
||||
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
|
||||
roots.add(it.root);
|
||||
const isDir = !!r.lst?.isDirectory();
|
||||
const kind = entities.get(it.mediaEntityId);
|
||||
@@ -173,10 +173,10 @@ export async function organizeFiles(
|
||||
if (slotId) {
|
||||
const s = slots.get(slotId);
|
||||
if (!s || s.media_entity_id !== it.mediaEntityId) {
|
||||
warnings.push(`${r.rel}: Slot ${slotId} 不属于该作品,未应用`);
|
||||
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
|
||||
slotId = null;
|
||||
} else if (BUSY_SLOT.has(s.status)) {
|
||||
warnings.push(`${r.rel}: Slot 正在获取中(${s.status}),不改其状态`);
|
||||
warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`);
|
||||
slotId = null;
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ export async function organizeFiles(
|
||||
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", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览");
|
||||
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
|
||||
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
|
||||
|
||||
if (g.action === "move") {
|
||||
@@ -329,7 +329,7 @@ export async function organizeFiles(
|
||||
if (block)
|
||||
throw new FilesError(
|
||||
"download_active",
|
||||
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
|
||||
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
|
||||
);
|
||||
}
|
||||
await retirePlan(d, planId);
|
||||
@@ -415,6 +415,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;
|
||||
}
|
||||
@@ -117,7 +117,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 },
|
||||
|
||||
@@ -95,17 +95,16 @@ 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 (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 (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`);
|
||||
sources.push(s);
|
||||
}
|
||||
const names = sources.map((s) => basename(s.abs));
|
||||
@@ -149,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;
|
||||
@@ -190,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("跨卷移动未开启(设置 → 整理 → 允许跨卷 Move),执行会被拒绝;可改用复制");
|
||||
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
|
||||
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
|
||||
if (truncated) warnings.push("源文件数超过上限,请分批操作");
|
||||
if (truncated) warnings.push("源文件数超过上限,分批操作");
|
||||
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
|
||||
|
||||
if (body.dryRun) {
|
||||
@@ -219,19 +218,19 @@ export async function transfer(
|
||||
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", "源目录文件数超过上限,请分批操作");
|
||||
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(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`,
|
||||
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
|
||||
);
|
||||
if (!isAdmin(principal)) {
|
||||
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
|
||||
@@ -273,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();
|
||||
|
||||
@@ -155,13 +155,13 @@ const BLOCK_ERR: Record<
|
||||
NonNullable<FileDeleteCheck["blocked"]>,
|
||||
[ConstructorParameters<typeof FilesError>[0], string]
|
||||
> = {
|
||||
download_active: ["download_active", "属于纳管下载任务(未完成或做种义务未满足)"],
|
||||
is_root: ["bad_request", "不能删除根本身或只读登记根下的内容"],
|
||||
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
|
||||
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
|
||||
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
|
||||
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
|
||||
root_unavailable: ["root_unavailable", "根不可用,不当作已删除"],
|
||||
root_unavailable: ["root_unavailable", "根不可用"],
|
||||
not_found: ["not_found", "不存在"],
|
||||
escape: ["path_escape", "路径逃出根"],
|
||||
escape: ["path_escape", "路径指向根外"],
|
||||
};
|
||||
|
||||
export async function deleteFiles(
|
||||
@@ -171,7 +171,7 @@ export async function deleteFiles(
|
||||
): Promise<FilesDeleteResult> {
|
||||
if (body.mode === "permanent") {
|
||||
if (!d.options.allowPermanentDelete)
|
||||
throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站");
|
||||
throw new FilesError("permanent_disabled", "未开启永久删除(FILES_ALLOW_PERMANENT_DELETE)。改用回收站");
|
||||
if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
|
||||
}
|
||||
const downloads = await managedDownloads(d);
|
||||
@@ -276,8 +276,8 @@ export async function deleteFiles(
|
||||
for (const s of slots)
|
||||
await upsertDecision(d.ao, trx, {
|
||||
kind: "other",
|
||||
title: `Slot 满足资产已被删除:${s.name}`,
|
||||
summary: "文件管理删除了满足该 Slot 的资产(进回收站或永久删除),Slot 退回 attention,请确认是否重新获取",
|
||||
title: `版本目标的文件已删除:${s.name}`,
|
||||
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
|
||||
subjectType: "acquisition_slot",
|
||||
subjectId: s.id,
|
||||
dedupeKey: `files_delete_slot:${s.id}`,
|
||||
@@ -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,7 +509,7 @@ 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();
|
||||
@@ -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 });
|
||||
|
||||
@@ -107,7 +107,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
.selectAll()
|
||||
.where("id", "=", input.sourceReleaseId)
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new Error("来源资源不存在");
|
||||
if (!row) throw new Error("资源不存在");
|
||||
// 库存(media_asset)归 W2-C,这里只按作品只读查询
|
||||
const inventory = row.media_entity_id
|
||||
? await r.ctx.db
|
||||
@@ -130,7 +130,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
.executeTakeFirst();
|
||||
srId = o?.source_release_id ?? null;
|
||||
}
|
||||
if (!srId) throw new Error("找不到来源资源");
|
||||
if (!srId) throw new Error("资源不存在");
|
||||
const actor = { type: "agent" as const, id: ctx.actorId };
|
||||
const c = input.candidate;
|
||||
if (c?.mediaEntityId || (c?.provider && c.entityType && c.externalId)) {
|
||||
|
||||
@@ -157,7 +157,7 @@ export const D = {
|
||||
sourceReleases: {
|
||||
list: list(
|
||||
"/source-releases",
|
||||
"站点资源列表",
|
||||
"资源列表",
|
||||
SourceRelease,
|
||||
{
|
||||
siteId: Id.optional(),
|
||||
@@ -167,7 +167,7 @@ export const D = {
|
||||
},
|
||||
TR,
|
||||
),
|
||||
get: get("/source-releases/{id}", "站点资源详情", SourceRelease, TR),
|
||||
get: get("/source-releases/{id}", "资源详情", SourceRelease, TR),
|
||||
},
|
||||
observations: {
|
||||
list: list(
|
||||
|
||||
@@ -129,7 +129,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(),
|
||||
|
||||
@@ -521,7 +521,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
});
|
||||
route(app, D.sourceReleases.get, async ({ params }) => {
|
||||
const row = await db().selectFrom("source_release").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "来源资源不存在");
|
||||
if (!row) throw new ApiException("not_found", "资源不存在");
|
||||
return toApi(row);
|
||||
});
|
||||
route(
|
||||
@@ -530,7 +530,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,
|
||||
@@ -544,7 +544,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/unlock",
|
||||
scope: "organize.execute",
|
||||
summary: "解除来源资源的人工锁并重新识别",
|
||||
summary: "解除资源的人工锁并重新识别",
|
||||
tags: T_RELEASES,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
@@ -556,7 +556,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.where("id", "=", params.id)
|
||||
.returning(["id", "site_id"])
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "来源资源不存在");
|
||||
if (!row) throw new ApiException("not_found", "资源不存在");
|
||||
await audit(db(), actor, "identity.release_unlocked", { type: "source_release", id: params.id });
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -209,14 +209,14 @@ 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: "disable", label: "停用该 Feed" },
|
||||
{ key: "disable", label: "停用 Feed" },
|
||||
],
|
||||
});
|
||||
out = { ...out, status: "auth_failed", reason: "auth" };
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function assignReleaseIdentity(
|
||||
): Promise<{ mediaEntityId: string; contentUnitIds: string[] }> {
|
||||
const { ctx } = rt;
|
||||
const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst();
|
||||
if (!release) throw new ApiException("not_found", "来源资源不存在");
|
||||
if (!release) throw new ApiException("not_found", "资源不存在");
|
||||
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
|
||||
const site = ((release.raw as Record<string, unknown>)?._site ?? {}) as { categoryName?: string };
|
||||
const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw);
|
||||
@@ -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
|
||||
|
||||
@@ -557,7 +557,7 @@ async function toPool(
|
||||
kind: "identity_ambiguous",
|
||||
severity: "info",
|
||||
title: `待确认身份:${release.title}`,
|
||||
summary: `${candidates.length} 个候选证据等级相近(${detail}),请选择或忽略`,
|
||||
summary: `${candidates.length} 个候选证据等级相近(${detail})。选择一个或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
dedupeKey: `identity:${release.id}`,
|
||||
|
||||
@@ -85,7 +85,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
|
||||
kind: "source_unreachable",
|
||||
severity: "warning",
|
||||
title: `站点「${site.name}」登录失效`,
|
||||
summary: `${e.kind}:${e.message}。已停止该站自动访问,请更新账号凭证后恢复。`,
|
||||
summary: `${e.kind}:${e.message}。已停止自动访问该站点。更新账号凭证后恢复访问。`,
|
||||
subjectType: "site",
|
||||
subjectId: site.id,
|
||||
dedupeKey: `site-auth:${site.id}`,
|
||||
|
||||
Reference in new issue
Block a user