style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow the new full-width summaries. check:copy now runs with --strict in ci. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0fccfe257d
commit
fb84a71f80
76 files changed
+582
-570
No files matched your search
@@ -60,7 +60,7 @@ export async function queryMediaServer(
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`timeout: 媒体服务器 ${limits.timeoutMs / 1000}s 内未响应`)),
|
||||
() => reject(new Error(`timeout:媒体服务器 ${limits.timeoutMs / 1000}s 内未响应`)),
|
||||
limits.timeoutMs,
|
||||
);
|
||||
});
|
||||
@@ -131,7 +131,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "get",
|
||||
path: "/library",
|
||||
scope: "read",
|
||||
summary: "媒体库列表(已登记资产或正在追踪的作品)",
|
||||
summary: "媒体库列表(已登记资产或正在追踪的作品)",
|
||||
query: LibraryListQuery,
|
||||
response: cursorPage(LibraryItemSchema),
|
||||
},
|
||||
@@ -232,7 +232,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "get",
|
||||
path: "/library/{id}",
|
||||
scope: "read",
|
||||
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
|
||||
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
|
||||
params: IdParam,
|
||||
response: LibraryDetail,
|
||||
},
|
||||
@@ -349,7 +349,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "get",
|
||||
path: "/inventory",
|
||||
scope: "read",
|
||||
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
|
||||
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
|
||||
query: InventoryQuery,
|
||||
response: InventoryResponse,
|
||||
},
|
||||
@@ -469,7 +469,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
method: "get",
|
||||
path: "/inventory/media-servers",
|
||||
scope: "read",
|
||||
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
|
||||
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
|
||||
query: MediaServerInventoryQuery,
|
||||
response: MediaServerInventory,
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
|
||||
.where("active", "=", true)
|
||||
.executeTakeFirst();
|
||||
if (!sup) return;
|
||||
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
|
||||
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
|
||||
await liftSuppressions(trx, fingerprint, by);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly stri
|
||||
.execute();
|
||||
const have = new Set(rows.map((r) => r.id));
|
||||
const missing = ids.filter((x) => !have.has(x));
|
||||
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
||||
@@ -253,7 +253,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
Api.ruleAtoms.create,
|
||||
async (_c, { body }) => {
|
||||
const key = String(body.key).toUpperCase();
|
||||
if (BUILTIN_ATOM_MAP.has(key)) conflict(`${key} 是内置关键字,不能覆盖`);
|
||||
if (BUILTIN_ATOM_MAP.has(key)) conflict(`${key} 是内置关键字,不能覆盖`);
|
||||
checkAtomPatterns(body.include);
|
||||
checkAtomPatterns(body.exclude);
|
||||
const r = await db()
|
||||
@@ -318,7 +318,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
(e) => atomsOf(e).has(a!.key),
|
||||
),
|
||||
);
|
||||
if (users.length) conflict(`自定义条件 ${a!.key} 被规则组引用:${users.map((u) => u.name).join("、")}`);
|
||||
if (users.length) conflict(`自定义条件 ${a!.key} 被规则组引用:${users.map((u) => u.name).join("、")}`);
|
||||
await db().deleteFrom("rule_atom").where("id", "=", params.id).execute();
|
||||
return { ok: true as const };
|
||||
},
|
||||
@@ -434,7 +434,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.executeTakeFirst();
|
||||
if (!cur) notFound("规则组");
|
||||
if (b.expectedRevision !== undefined && b.expectedRevision !== cur!.revision)
|
||||
conflict(`revision 不匹配(当前 ${cur!.revision})`);
|
||||
conflict(`revision 不匹配(当前 ${cur!.revision})`);
|
||||
if (b.name !== undefined && b.name !== cur!.name) {
|
||||
const dupName = await trx
|
||||
.selectFrom("rule_group")
|
||||
@@ -458,7 +458,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
ladderRequired: b.ladderRequired ?? cur!.ladder_required,
|
||||
};
|
||||
if (b.kind !== undefined && b.kind !== cur!.kind && cur!.default_order !== null)
|
||||
conflict("该规则组在默认链里,先从默认值移出再改类型");
|
||||
conflict("该规则组在默认链里,先从默认值移出再改类型");
|
||||
// 偏好标签迁来的组沿用旧指纹口径,规则没变就不重算
|
||||
const semanticChanged =
|
||||
b.kind !== undefined ||
|
||||
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.executeTakeFirst();
|
||||
if (!cur) notFound("规则组");
|
||||
const refs = await referenceCount(trx, cur!.id);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
|
||||
const row = await trx
|
||||
.updateTable("rule_group")
|
||||
.set({
|
||||
|
||||
@@ -103,7 +103,7 @@ async function assertNoDuplicate(
|
||||
q = input.seasonUnitId ? q.where("season_unit_id", "=", input.seasonUnitId) : q.where("season_unit_id", "is", null);
|
||||
if (input.excludeId) q = q.where("id", "!=", input.excludeId);
|
||||
const hit = await q.executeTakeFirst();
|
||||
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
||||
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
||||
}
|
||||
|
||||
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
|
||||
@@ -497,12 +497,12 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const pause = async (sub: SubscriptionRow) => {
|
||||
if (sub.status === "paused") return sub;
|
||||
if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status))
|
||||
conflict(`订阅状态为 ${sub.status},不能暂停`);
|
||||
conflict(`订阅状态为 ${sub.status},不能暂停`);
|
||||
return setStatus(sub, "paused", "user_paused");
|
||||
};
|
||||
const resume = async (sub: SubscriptionRow) => {
|
||||
if ((TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) return sub;
|
||||
if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成/已取消的订阅用"重新订阅"`);
|
||||
if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成/已取消的订阅用"重新订阅"`);
|
||||
const ready = await tmdbReady(db(), sub.media_entity_id, sub.media_kind);
|
||||
const r = await setStatus(sub, ready ? "active" : "pending", ready ? "resumed" : "metadata_pending", {
|
||||
nextSearchNow: true,
|
||||
@@ -511,11 +511,11 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
return r;
|
||||
};
|
||||
const search = async (sub: SubscriptionRow) => {
|
||||
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) {
|
||||
throw new ApiException("upstream_unavailable", `搜索任务入队失败:${errMessage(err)}`);
|
||||
throw new ApiException("upstream_unavailable", `搜索任务入队失败:${errMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -623,7 +623,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
async (_c, { params, body }) => {
|
||||
const sub = await load(params.id);
|
||||
if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status))
|
||||
conflict("订阅不在追踪中(暂停/已完成/已取消),先恢复或重新订阅");
|
||||
conflict("订阅不在追踪中(暂停/已完成/已取消),先恢复或重新订阅");
|
||||
const slots = sub.media_entity_id
|
||||
? await db()
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -641,7 +641,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
{ idempotencyKey: `fill:${params.slotId}:${d.ports.now().getTime()}`, traceId: params.slotId },
|
||||
);
|
||||
} catch (err) {
|
||||
throw new ApiException("upstream_unavailable", `补全任务入队失败:${errMessage(err)}`);
|
||||
throw new ApiException("upstream_unavailable", `补全任务入队失败:${errMessage(err)}`);
|
||||
}
|
||||
},
|
||||
{ tag: TAG, status: 202 },
|
||||
|
||||
@@ -801,7 +801,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
}).then(
|
||||
({ taskId }) => db.updateTable("acquisition_plan").set({ task_id: taskId }).where("id", "=", planId).execute(),
|
||||
(err) =>
|
||||
d.ctx.logger.warn({ err: { message: (err as Error).message }, planId }, "submitDownload 入队失败,留给巡检补投"),
|
||||
d.ctx.logger.warn({ err: { message: (err as Error).message }, planId }, "submitDownload 入队失败,留给巡检补投"),
|
||||
);
|
||||
return { planId, deferred: false };
|
||||
}
|
||||
@@ -932,7 +932,7 @@ async function gates(
|
||||
reasons: [b.decision === "unknown" ? "budget_unknown_size" : "budget_exceeded"],
|
||||
rejected: b.violations.map((v) => ({
|
||||
code: `budget.${v.kind}`,
|
||||
message: `${v.kind}: 上限 ${v.limit},当前 ${v.current},请求 ${v.requested}`,
|
||||
message: `${v.kind}:上限 ${v.limit},当前 ${v.current},请求 ${v.requested}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -949,7 +949,7 @@ async function gates(
|
||||
rejected: [
|
||||
{
|
||||
code: `cache.${dv.decision}`,
|
||||
message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`,
|
||||
message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function applySlotEvent(
|
||||
return false;
|
||||
}
|
||||
if (!next) {
|
||||
d.ctx.logger.warn({ slotId, event: event.type, from: slot.status }, "Slot 状态迁移被拒绝(已被人工处理?)");
|
||||
d.ctx.logger.warn({ slotId, event: event.type, from: slot.status }, "Slot 状态迁移被拒绝(已被人工处理?)");
|
||||
return false;
|
||||
}
|
||||
const ended = PLAN_EVENTS.has(event.type) && stageAfter(event.type) === null;
|
||||
@@ -288,7 +288,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
|
||||
}
|
||||
case "accepted_unconfirmed":
|
||||
if (!out.hash) throw new Error("qB 已收单但未能确认任务(无 hash),稍后按探针标签对账");
|
||||
if (!out.hash) throw new Error("qB 已收单但未能确认任务(无 hash),稍后按探针标签对账");
|
||||
return {
|
||||
kind: "accepted_unconfirmed",
|
||||
hash: out.hash,
|
||||
@@ -351,7 +351,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
// 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果
|
||||
if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) {
|
||||
await selectPackFiles(d, client, hash, plan, rel).catch((err) =>
|
||||
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
|
||||
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
|
||||
);
|
||||
}
|
||||
if (!dtId) {
|
||||
@@ -754,7 +754,7 @@ async function observeLocked(
|
||||
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_stalled",
|
||||
title: `下载无进展:${t.name}`,
|
||||
title: `下载无进展:${t.name}`,
|
||||
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
@@ -771,7 +771,7 @@ async function observeLocked(
|
||||
if ((merged.status === "failed" || merged.status === "missing") && changed) {
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_failed",
|
||||
title: `下载异常:${t.name}`,
|
||||
title: `下载异常:${t.name}`,
|
||||
summary: `qB 状态 ${t.rawState}`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
@@ -1133,7 +1133,7 @@ export async function cleanupDownloadCache(
|
||||
kind: "space_insufficient",
|
||||
severity: "critical",
|
||||
title: `下载缓存卷 ${v.volumeKey} 空间不足`,
|
||||
summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`,
|
||||
summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`,
|
||||
subjectType: "cache_volume",
|
||||
dedupeKey: `space_insufficient:${v.volumeKey}`,
|
||||
evidence: { usedPercent: pct, locked: entry.skipped },
|
||||
@@ -1146,7 +1146,7 @@ export async function cleanupDownloadCache(
|
||||
/** qB 删种 + 删数据:必经 assertSafeToDeleteData(本机视图与下载器视图各查一次) */
|
||||
async function removeWithData(d: AoDeps, cand: CleanupCandidate, libraryRootsLocal: string[]): Promise<boolean> {
|
||||
const { row, topo } = cand;
|
||||
if (!row.content_path) throw new Error("content_path 未知,不能删数据");
|
||||
if (!row.content_path) throw new Error("content_path 未知,不能删数据");
|
||||
const localContent = hostPathOf(topo, row.content_path);
|
||||
if (!localContent) throw new Error("content_path 无法映射到本机路径");
|
||||
assertSafeToDeleteData(
|
||||
|
||||
@@ -217,14 +217,14 @@ export function ruleFingerprint(g: {
|
||||
}
|
||||
|
||||
const fmtErrors = (errs: readonly RuleParseError[]) =>
|
||||
errs.map((e) => `${e.message}(第 ${e.layer + 1} 层,位置 ${e.position})`).join(";");
|
||||
errs.map((e) => `${e.message}(第 ${e.layer + 1} 层,位置 ${e.position})`).join(";");
|
||||
|
||||
/** 表达式里的原子都要存在(内置且支持,或自定义) */
|
||||
function checkAtoms(e: RuleExpr, custom: ReadonlySet<string>): string[] {
|
||||
const bad: string[] = [];
|
||||
for (const id of atomsOf(e)) {
|
||||
const b = BUILTIN_ATOM_MAP.get(id);
|
||||
if (b ? !b.supported : !custom.has(id)) bad.push(b ? `${id}(不支持)` : `${id}(不存在)`);
|
||||
if (b ? !b.supported : !custom.has(id)) bad.push(b ? `${id}(不支持)` : `${id}(不存在)`);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export function normalizeGroupInput(
|
||||
if (body.ruleString.trim() === "") out.layers = [];
|
||||
else {
|
||||
const p = parseRuleString(body.ruleString, { customAtoms: custom });
|
||||
if (p.errors.length) throw new ApiException("validation_failed", `规则串解析失败:${fmtErrors(p.errors)}`);
|
||||
if (p.errors.length) throw new ApiException("validation_failed", `规则串解析失败:${fmtErrors(p.errors)}`);
|
||||
out.layers = p.layers;
|
||||
}
|
||||
} else if (body.layers !== undefined) {
|
||||
@@ -252,16 +252,16 @@ export function normalizeGroupInput(
|
||||
if (body.hardExcludes !== undefined) {
|
||||
out.hardExcludes = body.hardExcludes.map((x: string | ApiRuleExpr, i: number) => {
|
||||
if (typeof x !== "string") return normalizeExpr(x as RuleExpr);
|
||||
if (x.includes(">")) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条不能分层(去掉 >)`);
|
||||
if (x.includes(">")) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条不能分层(去掉 >)`);
|
||||
const p = parseExpr(x, { customAtoms: custom });
|
||||
if (!p.expr) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条解析失败:${fmtErrors(p.errors)}`);
|
||||
if (!p.expr) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条解析失败:${fmtErrors(p.errors)}`);
|
||||
return p.expr;
|
||||
});
|
||||
}
|
||||
const bad = [...(out.layers ?? []).map((l) => l.expr), ...(out.hardExcludes ?? [])].flatMap((e) =>
|
||||
checkAtoms(e, custom),
|
||||
);
|
||||
if (bad.length) throw new ApiException("validation_failed", `条件无效:${[...new Set(bad)].join("、")}`);
|
||||
if (bad.length) throw new ApiException("validation_failed", `条件无效:${[...new Set(bad)].join("、")}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ async function idsByKey(
|
||||
.execute();
|
||||
return values.map((v) => {
|
||||
const hit = rows.find((r) => r.id === v || r.k === v);
|
||||
if (!hit) bad(`${label}不存在:${v}`);
|
||||
if (!hit) bad(`${label}不存在:${v}`);
|
||||
return hit!.id;
|
||||
});
|
||||
}
|
||||
@@ -454,14 +454,14 @@ export async function normalizeSubscriptionParams(
|
||||
const parsed = SubscriptionParams.safeParse(src);
|
||||
if (!parsed.success) {
|
||||
const i = parsed.error.issues[0];
|
||||
bad(`params.${i?.path.join(".") ?? ""} 无效:${i?.message ?? ""}`);
|
||||
bad(`params.${i?.path.join(".") ?? ""} 无效:${i?.message ?? ""}`);
|
||||
}
|
||||
const p = parsed.data as SubscriptionParams;
|
||||
|
||||
// ── 库根存在性与正则白名单 ──
|
||||
if (p.libraryRootId) {
|
||||
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
|
||||
if (!r) bad(`库根不存在:${p.libraryRootId}`);
|
||||
if (!r) bad(`库根不存在:${p.libraryRootId}`);
|
||||
}
|
||||
for (const k of ["include", "exclude"] as const)
|
||||
for (const pat of p[k] ?? []) {
|
||||
@@ -470,7 +470,7 @@ export async function normalizeSubscriptionParams(
|
||||
try {
|
||||
new RegExp(pat, "i");
|
||||
} catch {
|
||||
bad(`${k} 正则语法错误:${pat}`);
|
||||
bad(`${k} 正则语法错误:${pat}`);
|
||||
}
|
||||
}
|
||||
if (p.stopAfter && Number.isNaN(Date.parse(p.stopAfter))) bad("stopAfter 不是有效时间");
|
||||
|
||||
@@ -474,7 +474,7 @@ export async function afterSlotVerified(
|
||||
kind: "other",
|
||||
severity: "info",
|
||||
title: "疑似缺集",
|
||||
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
|
||||
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
|
||||
subjectType: "acquisition_slot",
|
||||
subjectId: slot.id,
|
||||
dedupeKey: `suspected_missing:${slot.id}`,
|
||||
@@ -549,7 +549,7 @@ export async function completeSubscription(
|
||||
completed_at: d.ports.now(),
|
||||
})
|
||||
.execute();
|
||||
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
|
||||
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ 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.enabled) return { allowed: false, reason: "自动化总开关已关闭,不自动执行" };
|
||||
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式下不自动执行" };
|
||||
}
|
||||
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
|
||||
@@ -154,9 +154,9 @@ export function createAuthorizer(deps: {
|
||||
if (a.status !== "approved") return { allowed: false, reason: `审批 ${approvalId} 状态为 ${a.status}` };
|
||||
if (a.plan_type !== ap.planType || a.plan_id !== planId)
|
||||
return { allowed: false, reason: "审批绑定的计划与本次调用不一致" };
|
||||
if (a.payload_hash !== target.payloadHash) return { allowed: false, reason: "计划内容已变化,旧审批失效" };
|
||||
if (a.payload_hash !== target.payloadHash) return { allowed: false, reason: "计划内容已变化,旧审批失效" };
|
||||
if (a.expected_revision !== target.revision)
|
||||
return { allowed: false, reason: "计划 revision 已变化,旧审批失效" };
|
||||
return { allowed: false, reason: "计划 revision 已变化,旧审批失效" };
|
||||
if (a.expires_at && a.expires_at.getTime() <= now().getTime()) return { allowed: false, reason: "审批已过期" };
|
||||
return { allowed: true, approvalId: a.id };
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export function createAuthorizer(deps: {
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`,
|
||||
reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function checkGrantScope(ctx: ServerContext, grantId: string, planId: stri
|
||||
.where("p.id", "=", planId)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
|
||||
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
|
||||
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
|
||||
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
|
||||
if (scope.mediaKinds?.length) {
|
||||
@@ -87,7 +87,7 @@ export function registerDownloadRecoveryCapabilities(
|
||||
if (input.action === "recheck") await client.recheck([dt.torrent_hash]);
|
||||
else await client.resume([dt.torrent_hash]);
|
||||
} catch (err) {
|
||||
throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`);
|
||||
throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`);
|
||||
}
|
||||
return {
|
||||
downloadTaskId: dt.id,
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function taskTimeline(ctx: ServerContext, t: TaskRow) {
|
||||
out.push({
|
||||
at: t.finished_at.toISOString(),
|
||||
type: `task.${t.status}`,
|
||||
summary: t.status === "failed" ? `失败:${JSON.stringify(t.last_error ?? {}).slice(0, 200)}` : t.status,
|
||||
summary: t.status === "failed" ? `失败:${JSON.stringify(t.last_error ?? {}).slice(0, 200)}` : t.status,
|
||||
});
|
||||
if (t.subject_type === "acquisition_plan" && t.subject_id) {
|
||||
const p = await db
|
||||
@@ -82,7 +82,7 @@ export async function taskTimeline(ctx: ServerContext, t: TaskRow) {
|
||||
out.push({
|
||||
at: dt.updated_at.toISOString(),
|
||||
type: "download.status",
|
||||
summary: `下载任务 ${dt.status}(进度 ${Math.round(Number(dt.progress ?? 0) * 100)}%)`,
|
||||
summary: `下载任务 ${dt.status}(进度 ${Math.round(Number(dt.progress ?? 0) * 100)}%)`,
|
||||
data: { downloadTaskId: dt.id, status: dt.status },
|
||||
});
|
||||
const recs = dts.length
|
||||
@@ -125,7 +125,7 @@ export async function retryTask(ctx: ServerContext, taskId: string, by: string,
|
||||
const t = await ctx.db.selectFrom("task").selectAll().where("id", "=", taskId).executeTakeFirst();
|
||||
if (!t) throw new CapabilityError("not_found", "任务不存在");
|
||||
if (!["failed", "dead", "cancelled"].includes(t.status))
|
||||
throw new CapabilityError("precondition", `只能重试失败/死信/已取消的任务(当前 ${t.status})`);
|
||||
throw new CapabilityError("precondition", `只能重试失败/死信/已取消的任务(当前 ${t.status})`);
|
||||
const input =
|
||||
t.kind === "agentRun" ? { agentRunId: t.subject_id } : { ...((t.input ?? {}) as Record<string, unknown>), taskId };
|
||||
const { runId } = await ctx.engine.enqueue(engineTaskName(t.kind), input, {
|
||||
|
||||
@@ -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} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
|
||||
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -289,7 +289,7 @@ async function planSummary(db: Kysely<DB>, planType: string, planId: string): Pr
|
||||
}
|
||||
if (planType === "organize_plan") {
|
||||
const r = await db.selectFrom("artifact").select("title").where("id", "=", planId).executeTakeFirst();
|
||||
if (r) return safe(`整理:${r.title}`, 300);
|
||||
if (r) return safe(`整理:${r.title}`, 300);
|
||||
}
|
||||
if (planType === "policy_revision") {
|
||||
const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst();
|
||||
@@ -353,8 +353,8 @@ export async function inlineApproval(
|
||||
return { allowed: false, reason: "审批绑定的计划与本次调用不一致" };
|
||||
}
|
||||
if (existing.payload_hash !== b.payloadHash || existing.expected_revision !== b.revision) {
|
||||
await resolved("invalid", "计划在审批期间变化,旧审批失效");
|
||||
return { allowed: false, reason: "计划在审批期间变化,旧审批失效;重新生成计划后再提交" };
|
||||
await resolved("invalid", "计划在审批期间变化,旧审批失效");
|
||||
return { allowed: false, reason: "计划在审批期间变化,旧审批失效;重新生成计划后再提交" };
|
||||
}
|
||||
if (existing.expires_at && existing.expires_at.getTime() <= now.getTime()) {
|
||||
await resolved("expired", null);
|
||||
@@ -383,7 +383,7 @@ export async function inlineApproval(
|
||||
if (existing.status === "expired" || (existing.expires_at && existing.expires_at.getTime() <= now.getTime())) {
|
||||
await expireApproval(sctx, existing.id);
|
||||
await resolved("expired", null);
|
||||
return { allowed: false, reason: "审批已过期,没有执行" };
|
||||
return { allowed: false, reason: "审批已过期,没有执行" };
|
||||
}
|
||||
// 仍在等待:重新挂起(崩溃重放 / 兜底轮询提前醒来)
|
||||
if (live?.suspend && !inv.startsWith("reconcile:"))
|
||||
@@ -405,7 +405,7 @@ export async function inlineApproval(
|
||||
const summary = await planSummary(db, plan.planType, plan.planId);
|
||||
const decision = await insertDecision(sctx, {
|
||||
kind: "approval_required",
|
||||
title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
|
||||
title: `Agent 请求审批:${CAPABILITY_CATALOG[d.id as KnownCapabilityId]?.title ?? d.id}`,
|
||||
summary,
|
||||
runId,
|
||||
dedupeKey: `${APPROVAL_KEY}:${runId}:${inv}`,
|
||||
@@ -450,7 +450,7 @@ export async function inlineApproval(
|
||||
deadline: expiresAt.toISOString(),
|
||||
approvalId: a.id,
|
||||
});
|
||||
return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" };
|
||||
return { allowed: false, reason: "已在步骤处发起审批,等待管理员批准" };
|
||||
}
|
||||
|
||||
async function expireApproval(ctx: ServerContext, approvalId: string) {
|
||||
@@ -524,7 +524,7 @@ export async function proposeFirstBlock(db: Kysely<DB>, runId: string): Promise<
|
||||
.where("dedupe_key", "like", `${OPTIONS_KEY}:%`)
|
||||
.where("status", "=", "resolved")
|
||||
.executeTakeFirst();
|
||||
return chosen ? null : "本会话要求先看方案再执行:先用 agent.propose_options 给出方案,用户选定后才能执行";
|
||||
return chosen ? null : "本会话要求先看方案再执行:先用 agent.propose_options 给出方案,用户选定后才能执行";
|
||||
}
|
||||
|
||||
// ── 挂起唤醒条件(runAgent 的 waitForFact) ──
|
||||
@@ -569,7 +569,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
registry.register(capabilitySpec("agent.plan"), async (input, c) => {
|
||||
const run = await runOf(ctx, c);
|
||||
const live = liveRun(run.id);
|
||||
if (!live) throw new CapabilityError("precondition", "会话不在运行中,不能更新步骤清单");
|
||||
if (!live) throw new CapabilityError("precondition", "会话不在运行中,不能更新步骤清单");
|
||||
const cur = await currentPlan(ctx, live, run.id);
|
||||
const byId = new Map(input.replace ? [] : cur.steps.map((s) => [s.id, s] as const));
|
||||
const order = input.replace ? [] : cur.steps.map((s) => s.id);
|
||||
@@ -583,7 +583,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
});
|
||||
if (!order.includes(s.id)) order.push(s.id);
|
||||
}
|
||||
if (order.length > 30) throw new CapabilityError("invalid_input", "步骤最多 30 个;合并细碎步骤后再更新");
|
||||
if (order.length > 30) throw new CapabilityError("invalid_input", "步骤最多 30 个;合并细碎步骤后再更新");
|
||||
const next = { steps: order.map((id) => byId.get(id)!), revision: cur.revision + 1 };
|
||||
live.plan = next;
|
||||
live.handle.appendEvent({ type: "plan_updated", steps: next.steps, revision: next.revision });
|
||||
@@ -594,7 +594,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
registry.register({ ...capabilitySpec("agent.propose_options"), replay: "safe" }, async (input, c) => {
|
||||
const run = await runOf(ctx, c);
|
||||
if (!canAskUser(run))
|
||||
throw new CapabilityError("precondition", "子会话和后台会话不能让用户选方案:自己按证据决定,在汇报里说明取舍");
|
||||
throw new CapabilityError("precondition", "子会话和后台会话不能让用户选方案:自己按证据决定,在汇报里说明取舍");
|
||||
const live = liveRun(run.id);
|
||||
const inv = c.invocationId;
|
||||
if (!live?.suspend || !inv) throw new CapabilityError("precondition", "会话不在运行中");
|
||||
@@ -625,7 +625,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
const stepId = await currentStepId(ctx, live, run.id);
|
||||
d = await insertDecision(ctx, {
|
||||
kind: "other",
|
||||
title: `Agent 等你选方案:${clip(input.question, 80)}`,
|
||||
title: `Agent 等你选方案:${clip(input.question, 80)}`,
|
||||
summary: input.options.map((o) => `${o.id}. ${o.title}`).join(";"),
|
||||
runId: run.id,
|
||||
dedupeKey,
|
||||
@@ -660,8 +660,8 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct
|
||||
const pick = ev.recommendedId ?? ev.options[0]?.id ?? null;
|
||||
const res: OptionsResolution =
|
||||
ev.timeoutAction === "recommended" && pick
|
||||
? { optionKey: pick, outcome: "chosen", reason: "超时未选,按设置自动选推荐项", timeout: true }
|
||||
: { optionKey: null, outcome: "stop", reason: "超时未选,按设置停止", timeout: true };
|
||||
? { optionKey: pick, outcome: "chosen", reason: "超时未选,按设置自动选推荐项", timeout: true }
|
||||
: { optionKey: null, outcome: "stop", reason: "超时未选,按设置停止", timeout: true };
|
||||
await closeDecision(ctx, d.id, res.outcome === "chosen" ? "resolved" : "expired", { ...res }, "system:timeout");
|
||||
result = optionsResult(ev, res.outcome === "chosen" ? "resolved" : "expired", res, "timeout");
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
|
||||
|
||||
function suggestionFor(d: DecisionRow, reason: string): string {
|
||||
if (d.kind === "identity_ambiguous")
|
||||
return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`;
|
||||
return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`;
|
||||
return (
|
||||
`${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
|
||||
`${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
|
||||
`换源、删除需人工决定。`
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ async function addRecheckOption(ctx: ServerContext, d: DecisionRow) {
|
||||
{
|
||||
key: "recheck",
|
||||
label: "强制重新校验",
|
||||
description: "让下载器重新校验已有文件(不换源、不删除)",
|
||||
description: "让下载器重新校验已有文件(不换源、不删除)",
|
||||
capabilityId: "download.recheck",
|
||||
input: { downloadTaskId: d.subject_id, action: "recheck" },
|
||||
},
|
||||
@@ -184,7 +184,7 @@ export async function autoInvestigate(
|
||||
and (evidence->'autoRecover'->>'recoverAttempted')::boolean is true`.execute(ctx.db);
|
||||
attempt = (prior.rows[0]?.n ?? 0) + 1;
|
||||
if (attempt > s.investigateMaxAttempts) {
|
||||
const reason = `同一根因已自动恢复 ${attempt - 1} 次(上限 ${s.investigateMaxAttempts}),不再自动处理`;
|
||||
const reason = `同一根因已自动恢复 ${attempt - 1} 次(上限 ${s.investigateMaxAttempts}),不再自动处理`;
|
||||
await mergeEvidence(ctx, d.id, {
|
||||
mode: "investigate",
|
||||
requestedMode: requested,
|
||||
@@ -229,14 +229,14 @@ export async function autoInvestigate(
|
||||
const taskId = await relatedTask(ctx, d);
|
||||
const goal = [
|
||||
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
|
||||
d.summary ? `摘要:${d.summary}。` : "",
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
|
||||
d.summary ? `摘要:${d.summary}。` : "",
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
|
||||
taskId ? `关联任务 ${taskId}。` : "",
|
||||
mode === "recover"
|
||||
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
|
||||
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
|
||||
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
].join("");
|
||||
const { run, created } = await startRun(ctx, {
|
||||
goal,
|
||||
@@ -380,16 +380,16 @@ export async function applyRecoveryOutcome(
|
||||
const what = done.map((a) => a.capabilityId).join(" + ");
|
||||
const summary =
|
||||
outcome === "recovered"
|
||||
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
|
||||
? `已恢复(系统核实):执行 ${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,7 +31,7 @@ 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" : "站点"}并发已满,稍后重试`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,11 +102,11 @@ 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" ? "已提交≠已入库:等待下载完成并核验入库。" : "",
|
||||
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
|
||||
`已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`,
|
||||
state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "",
|
||||
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
@@ -224,7 +224,7 @@ export function registerAgentRoutes(
|
||||
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!run) throw new ApiException("not_found", "Agent 运行不存在");
|
||||
if (body.optionId === null && !body.reason?.trim())
|
||||
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
|
||||
throw new ApiException("validation_failed", "都不要时写一句原因,Agent 按它调整方案");
|
||||
const r = await answerOptions(ctx, run.id, body, actorId);
|
||||
if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
|
||||
if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
|
||||
@@ -287,7 +287,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({
|
||||
@@ -362,7 +362,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
|
||||
@@ -388,7 +388,7 @@ export function registerAgentRoutes(
|
||||
}));
|
||||
route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => {
|
||||
const entry = ctx.capabilities.get(params.capabilityId);
|
||||
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
|
||||
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
|
||||
const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[];
|
||||
if (!hasScope(scopes, entry.descriptor.scope))
|
||||
throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`);
|
||||
@@ -431,7 +431,8 @@ export function registerAgentRoutes(
|
||||
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")
|
||||
@@ -443,7 +444,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: "这个作品没有版本目标,先在订阅里新建" });
|
||||
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
|
||||
const invokeCtx = {
|
||||
actorId,
|
||||
taskId: `http:${c.get("requestId") ?? "req"}`,
|
||||
|
||||
@@ -86,7 +86,7 @@ export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly str
|
||||
const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
|
||||
return !d || !PLAN_ONLY_KINDS.includes(d.kind);
|
||||
});
|
||||
if (bad.length) throw new Error(`plan_only 模式只允许 read/preview/propose 类能力,不允许:${bad.join(", ")}`);
|
||||
if (bad.length) throw new Error(`plan_only 模式只允许 read/preview/propose 类能力,不允许:${bad.join(", ")}`);
|
||||
return {
|
||||
capabilities: allowlist?.length
|
||||
? [...allowlist]
|
||||
@@ -252,23 +252,24 @@ function systemPromptFor(
|
||||
sub: { canSpawn: boolean; maxConcurrent: number; maxDepth: number },
|
||||
): string {
|
||||
const constraints: string[] = [
|
||||
`可用能力:${scope.capabilities.join(", ")}`,
|
||||
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
|
||||
`可用能力:${scope.capabilities.join(", ")}`,
|
||||
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
|
||||
];
|
||||
if (run.mode === "plan_only") constraints.push("本次只读:只调查与规划,不提交下载、不改文件、不发布策略。");
|
||||
if (run.mode === "plan_only") constraints.push("本次只读:只调查与规划,不提交下载、不改文件、不发布策略。");
|
||||
if (sub.canSpawn)
|
||||
constraints.push(
|
||||
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
|
||||
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
|
||||
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
|
||||
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
|
||||
);
|
||||
if (run.parent_run_id) constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。");
|
||||
if (run.parent_run_id)
|
||||
constraints.push("你是子会话:只完成本目标,完成后简要汇报结论与证据(对象 ID),由父会话汇总。");
|
||||
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("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
|
||||
const opts = sessionOptions(run);
|
||||
return buildSystemPrompt({
|
||||
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
|
||||
@@ -340,7 +341,7 @@ export async function drive(
|
||||
let settings: AgentSettings;
|
||||
try {
|
||||
const resolved = await resolveRunUpstream(ctx, run.upstream_id);
|
||||
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
|
||||
if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
|
||||
upstream = resolved;
|
||||
fake = upstream.row.provider === "fake";
|
||||
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
|
||||
@@ -373,7 +374,7 @@ export async function drive(
|
||||
status: "failed",
|
||||
reason: "error",
|
||||
budget: null,
|
||||
message: `fakeScript 不合法:${(err as Error).message}`,
|
||||
message: `fakeScript 不合法:${(err as Error).message}`,
|
||||
finalText: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ async function enqueueRun(ctx: ServerContext, run: AgentRunRow, key: string) {
|
||||
} catch (err) {
|
||||
await ctx.db
|
||||
.updateTable("agent_run")
|
||||
.set({ status: "failed", error: JSON.stringify({ message: `入队失败:${(err as Error).message}` }) })
|
||||
.set({ status: "failed", error: JSON.stringify({ message: `入队失败:${(err as Error).message}` }) })
|
||||
.where("id", "=", run.id)
|
||||
.execute();
|
||||
throw err;
|
||||
@@ -237,13 +237,13 @@ export async function finalize(
|
||||
let summary: string;
|
||||
if (res.status === "cancelled" || run.cancel_requested_at) {
|
||||
status = "cancelled";
|
||||
summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`;
|
||||
summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`;
|
||||
} else if (res.reason === "budget") {
|
||||
status = "budget_exceeded";
|
||||
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`;
|
||||
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`;
|
||||
} else if (res.status === "failed") {
|
||||
status = "failed";
|
||||
summary = `运行失败:${res.message ?? ""}`;
|
||||
summary = `运行失败:${res.message ?? ""}`;
|
||||
} else if (report.state === "waiting") {
|
||||
status = "waiting";
|
||||
summary = report.summary;
|
||||
@@ -252,7 +252,7 @@ export async function finalize(
|
||||
summary = report.summary;
|
||||
}
|
||||
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
|
||||
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`;
|
||||
if (res.finalText && res.status !== "failed") summary += `\n 模型汇报(未经核查):${res.finalText}`;
|
||||
// 子代理:父会话总预算含子会话用量
|
||||
const kids = await descendants(ctx, runId);
|
||||
if (kids.length) {
|
||||
@@ -264,7 +264,7 @@ export async function finalize(
|
||||
.executeTakeFirst();
|
||||
const ownTokens = ((fresh?.usage ?? own) as { totalTokens?: number }).totalTokens ?? 0;
|
||||
const ext = await descendantUsage(ctx, runId);
|
||||
summary += `\n子会话 ${kids.length} 个;会话树合计工具调用 ${(fresh?.tool_call_count ?? run.tool_call_count) + ext.toolCalls} 次、token ${ownTokens + ext.totalTokens}。`;
|
||||
summary += `\n 子会话 ${kids.length} 个;会话树合计工具调用 ${(fresh?.tool_call_count ?? run.tool_call_count) + ext.toolCalls} 次、token ${ownTokens + ext.totalTokens}。`;
|
||||
}
|
||||
// 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本
|
||||
if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`;
|
||||
@@ -391,9 +391,9 @@ export async function autoInvestigate(
|
||||
if (!s.autoInvestigate) return { runId: null, created: false };
|
||||
const goal = [
|
||||
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
|
||||
d.summary ? `摘要:${d.summary}。` : "",
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
"只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
d.summary ? `摘要:${d.summary}。` : "",
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
"只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
].join("");
|
||||
const { run, created } = await startRun(ctx, {
|
||||
goal,
|
||||
|
||||
@@ -75,7 +75,7 @@ const safe = (s: string | null | undefined, n = 1500) => clip(redactString(s ??
|
||||
*/
|
||||
export class AwaitSuspendedError extends CapabilityError {
|
||||
constructor() {
|
||||
super("precondition", "会话已挂起等待子会话结束,恢复后重放本调用");
|
||||
super("precondition", "会话已挂起等待子会话结束,恢复后重放本调用");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export async function cascadeCancel(ctx: ServerContext, runId: string, reason: s
|
||||
.updateTable("agent_run")
|
||||
.set({
|
||||
cancel_requested_at: now,
|
||||
error: JSON.stringify({ abortReason: `父会话已取消:${reason}`, by: `agent:${runId}` }),
|
||||
error: JSON.stringify({ abortReason: `父会话已取消:${reason}`, by: `agent:${runId}` }),
|
||||
})
|
||||
.where("id", "in", ids)
|
||||
.where("cancel_requested_at", "is", null)
|
||||
@@ -330,7 +330,7 @@ export async function cascadeCancel(ctx: ServerContext, runId: string, reason: s
|
||||
if (taskIds.length)
|
||||
await ctx.db
|
||||
.updateTable("task")
|
||||
.set({ cancel_requested_at: now, cancel_reason: `父会话已取消:${reason}` })
|
||||
.set({ cancel_requested_at: now, cancel_reason: `父会话已取消:${reason}` })
|
||||
.where("id", "in", taskIds)
|
||||
.where("cancel_requested_at", "is", null)
|
||||
.execute();
|
||||
@@ -489,7 +489,7 @@ export async function allocateBudget(
|
||||
if (want !== undefined) out[k] = want;
|
||||
return;
|
||||
}
|
||||
if (left < min) throw new CapabilityError("budget", `本会话剩余${label}不足,不能再派生子会话(剩余 ${left})`);
|
||||
if (left < min) throw new CapabilityError("budget", `本会话剩余${label}不足,不能再派生子会话(剩余 ${left})`);
|
||||
if (want !== undefined && want > left)
|
||||
throw new CapabilityError("budget", `子会话${label} ${want} 超过本会话剩余预算 ${left}`);
|
||||
const v = want ?? (k === "maxCostUsd" ? left / 2 : Math.max(min, Math.floor(left / 2)));
|
||||
@@ -498,7 +498,7 @@ export async function allocateBudget(
|
||||
pick("maxToolCalls", remain.maxToolCalls, "工具调用次数", 1);
|
||||
pick("maxTokens", remain.maxTokens, " token", 1);
|
||||
pick("maxCostUsd", remain.maxCostUsd, "费用", 0.0001);
|
||||
pick("maxDurationSec", remain.maxDurationSec, "时长(秒)", 5);
|
||||
pick("maxDurationSec", remain.maxDurationSec, "时长(秒)", 5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -528,10 +528,10 @@ export function spawnScope(
|
||||
const kindOf = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind;
|
||||
if (requested?.length) {
|
||||
const outside = requested.filter((id) => !parentCaps.includes(id));
|
||||
if (outside.length) throw new CapabilityError("unauthorized", `子会话能力超出本会话白名单:${outside.join(", ")}`);
|
||||
if (outside.length) throw new CapabilityError("unauthorized", `子会话能力超出本会话白名单:${outside.join(", ")}`);
|
||||
const tooStrong = requested.filter((id) => !kinds.includes(kindOf(id) as CapabilityKind));
|
||||
if (tooStrong.length)
|
||||
throw new CapabilityError("unauthorized", `子会话模式/授权上限不允许这些能力:${tooStrong.join(", ")}`);
|
||||
throw new CapabilityError("unauthorized", `子会话模式/授权上限不允许这些能力:${tooStrong.join(", ")}`);
|
||||
}
|
||||
const caps = (requested?.length ? [...new Set(requested)] : parentCaps).filter((id) =>
|
||||
kinds.includes(kindOf(id) as CapabilityKind),
|
||||
@@ -587,7 +587,7 @@ export async function awaitChildren(
|
||||
const rows = await q.orderBy("created_at").execute();
|
||||
if (ids?.length && rows.length !== new Set(ids).size) {
|
||||
const missing = ids.filter((id) => !rows.some((r) => r.id === id));
|
||||
throw new CapabilityError("not_found", `不是本会话派生的子会话:${missing.join(", ")}`);
|
||||
throw new CapabilityError("not_found", `不是本会话派生的子会话:${missing.join(", ")}`);
|
||||
}
|
||||
await liveRun(parentId)?.pump();
|
||||
const allDone = rows.every((r) => SETTLED.includes(r.status));
|
||||
@@ -644,7 +644,7 @@ export async function wakeParent(ctx: ServerContext, childRunId: string): Promis
|
||||
await ctx.engine.signal(w.engineRunId, w.fact, { childRunId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ childRunId, err: { message: (err as Error).message } }, "唤醒父会话失败(父会话会兜底轮询)");
|
||||
ctx.logger.warn({ childRunId, err: { message: (err as Error).message } }, "唤醒父会话失败(父会话会兜底轮询)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -662,7 +662,7 @@ export async function wakeRun(
|
||||
await ctx.engine.signal(w.engineRunId, w.fact, payload);
|
||||
return true;
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ runId, err: { message: (err as Error).message } }, "唤醒会话失败(会话会兜底轮询)");
|
||||
ctx.logger.warn({ runId, err: { message: (err as Error).message } }, "唤醒会话失败(会话会兜底轮询)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -719,9 +719,9 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
|
||||
const parent = await parentOf(ctx, c);
|
||||
const s = await ctx.settings.all();
|
||||
const cfg = subagentSettings(s);
|
||||
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
|
||||
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
|
||||
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
|
||||
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
|
||||
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
|
||||
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
|
||||
const existing = await ctx.db
|
||||
.selectFrom("agent_run")
|
||||
@@ -731,7 +731,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
|
||||
let child = existing;
|
||||
if (!child) {
|
||||
if (parent.depth + 1 > cfg.maxDepth)
|
||||
throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`);
|
||||
throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`);
|
||||
const mode: AgentRunMode = input.mode ?? parent.mode;
|
||||
if (parent.mode === "plan_only" && mode === "execute")
|
||||
throw new CapabilityError("unauthorized", "只给方案的会话只能派生只给方案的子会话");
|
||||
@@ -812,7 +812,7 @@ export async function exceedsAncestors(
|
||||
for (const a of chain) {
|
||||
const sc = (a.scope ?? {}) as Partial<RunScope>;
|
||||
if (sc.capabilities?.length && !sc.capabilities.includes(capabilityId))
|
||||
return `超出父会话 ${a.id} 的能力白名单:${capabilityId}`;
|
||||
return `超出父会话 ${a.id} 的能力白名单:${capabilityId}`;
|
||||
if (sc.allowedKinds && !sc.allowedKinds.includes(kind)) return `父会话 ${a.id} 不允许 ${kind} 类能力`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function ensureSetupToken(deps: Pick<AuthDeps, "db" | "logger">): P
|
||||
returning id`.execute(deps.db);
|
||||
if (!r.rows.length) return null;
|
||||
// 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里
|
||||
deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`);
|
||||
deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,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 分钟后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ function otpauthUri(secret: string, account: string): string {
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
const secret = base32Encode(randomBytes(20));
|
||||
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
|
||||
const old = admin.totp_pending_secret_ref;
|
||||
@@ -375,7 +375,7 @@ export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSe
|
||||
|
||||
/** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */
|
||||
export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
if (admin.totp_pending_secret_ref) {
|
||||
try {
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
|
||||
@@ -25,7 +25,7 @@ const D = {
|
||||
method: "get",
|
||||
path: "/engine/runs",
|
||||
scope: "admin",
|
||||
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
|
||||
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
|
||||
query: EngineRunQuery,
|
||||
response: cursorPage(EngineRun),
|
||||
tags,
|
||||
@@ -61,7 +61,7 @@ const D = {
|
||||
method: "post",
|
||||
path: "/engine/runs/{id}/retry",
|
||||
scope: "admin",
|
||||
summary: "按原任务名与输入重新入队(新幂等键 retry:<id>:<序号>)",
|
||||
summary: "按原任务名与输入重新入队(新幂等键 retry:<id>:<序号>)",
|
||||
params: RunIdParam,
|
||||
response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }),
|
||||
tags,
|
||||
@@ -316,7 +316,7 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
|
||||
const run = r.rows[0];
|
||||
if (!run) throw new ApiException("not_found", `run ${params.id} 不存在`);
|
||||
if (!RETRYABLE.has(run.status))
|
||||
throw new ApiException("conflict", `run 状态为 ${run.status},只能重跑已结束的 run(可先取消)`);
|
||||
throw new ApiException("conflict", `run 状态为 ${run.status},只能重跑已结束的 run(可先取消)`);
|
||||
const env = (run.input ?? {}) as { input?: unknown; traceId?: string; priority?: number };
|
||||
const prefix = `retry:${params.id}:`;
|
||||
const prev = await sql<{ n: number }>`
|
||||
@@ -330,7 +330,7 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
|
||||
...(typeof env.priority === "number" ? { priority: env.priority } : {}),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw new ApiException("precondition_failed", `无法重新入队:${(err as Error).message}`);
|
||||
throw new ApiException("precondition_failed", `无法重新入队:${(err as Error).message}`);
|
||||
}
|
||||
await audit(
|
||||
ctx,
|
||||
|
||||
@@ -130,7 +130,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
.deleteFrom(table)
|
||||
.where("id", "=", id)
|
||||
.execute()
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}仍被引用,不能删除`));
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}仍被引用,不能删除`));
|
||||
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
await audit(ctx, actor, action, { type: table, id });
|
||||
return { ok: true as const };
|
||||
@@ -142,7 +142,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
.values(values)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record<
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
@@ -157,7 +157,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirst()
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`));
|
||||
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`));
|
||||
if (!r) throw new ApiException("not_found", `${NOUN[table]}不存在`);
|
||||
return r as Record<string, unknown> & { id: string };
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
} catch (e) {
|
||||
throw new ApiException(
|
||||
"precondition_failed",
|
||||
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
||||
`库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
|
||||
@@ -35,7 +35,7 @@ const D = {
|
||||
method: "delete",
|
||||
path: "/secrets/{id}",
|
||||
scope: "admin",
|
||||
summary: "删除凭证(被引用时 409)",
|
||||
summary: "删除凭证(被引用时 409)",
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
tags: ["secrets"],
|
||||
@@ -44,7 +44,7 @@ const D = {
|
||||
method: "get",
|
||||
path: "/audit-events",
|
||||
scope: "admin",
|
||||
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
|
||||
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
|
||||
query: AuditEventQuery,
|
||||
response: cursorPage(AuditEvent),
|
||||
tags: ["audit"],
|
||||
@@ -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 };
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function verifyRegistration(
|
||||
const response = input.response as unknown as RegistrationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "register", admin.id);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
|
||||
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
|
||||
let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
v = await verifyRegistrationResponse({
|
||||
@@ -191,7 +191,7 @@ export async function verifyRegistration(
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
|
||||
@@ -220,7 +220,7 @@ export async function verifyRegistration(
|
||||
|
||||
export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const creds = await listPasskeys(db, admin.id);
|
||||
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
|
||||
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: "preferred",
|
||||
@@ -240,7 +240,7 @@ export async function verifyAuthentication(
|
||||
const response = input.response as unknown as AuthenticationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "login", null);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
|
||||
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
|
||||
const cred = await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
@@ -264,7 +264,7 @@ export async function verifyAuthentication(
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
// 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行)
|
||||
@@ -281,6 +281,6 @@ export async function verifyAuthentication(
|
||||
.where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
|
||||
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
|
||||
return u;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export class SecretError extends Error {
|
||||
readonly secretRef: string,
|
||||
) {
|
||||
super(
|
||||
kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`,
|
||||
kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`,
|
||||
);
|
||||
this.name = "SecretError";
|
||||
}
|
||||
@@ -221,7 +221,7 @@ export async function rotateMasterKey(
|
||||
newKey: string,
|
||||
opts: { progressEvery?: number } = {},
|
||||
): Promise<RotateMasterKeyResult> {
|
||||
if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换");
|
||||
if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换");
|
||||
const { db, logger } = deps;
|
||||
const newKid = masterKeyId(newKey);
|
||||
const every = opts.progressEvery ?? 50;
|
||||
@@ -266,7 +266,7 @@ export async function rotateMasterKey(
|
||||
else if (r === "skipped") out.skipped++;
|
||||
else if (r === "failed") {
|
||||
out.failed.push(id);
|
||||
logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)");
|
||||
logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)");
|
||||
}
|
||||
done++;
|
||||
if (done % every === 0 || done === ids.length)
|
||||
@@ -295,7 +295,7 @@ export async function rotateMasterKey(
|
||||
.catch((err) => logger.warn({ err: { message: (err as Error).message } }, "主密钥轮换审计写入失败"));
|
||||
logger.info(
|
||||
{ total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length },
|
||||
out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成",
|
||||
out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -337,7 +337,7 @@ export function createSettingsReader(deps: {
|
||||
const v = stored.get(key);
|
||||
const merged = v && typeof v === "object" && !Array.isArray(v) ? { ...def, ...(v as object) } : def;
|
||||
const parsed = GlobalSettings.shape[key].safeParse(merged);
|
||||
if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省");
|
||||
if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省");
|
||||
out[key] = parsed.success ? parsed.data : def;
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
|
||||
@@ -33,7 +33,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
const resolveSession = async (input: { sessionId?: string | undefined; siteId?: string | undefined }) => {
|
||||
if (input.sessionId) return input.sessionId;
|
||||
const row = input.siteId ? await svc().liveSessionFor(input.siteId) : undefined;
|
||||
if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面");
|
||||
if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面");
|
||||
return row.id;
|
||||
};
|
||||
|
||||
@@ -176,12 +176,12 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
duplicate: !!v.duplicate,
|
||||
note: v.duplicate
|
||||
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
|
||||
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
|
||||
: !v.verification.valid
|
||||
? "不是有效的种子文件"
|
||||
: v.sourceReleaseId
|
||||
? "已暂存核验;用 download.plan 提交这个 sourceReleaseId"
|
||||
: "资源不在本地索引;先 release.search 该站点后再提交",
|
||||
? "已暂存核验;用 download.plan 提交这个 sourceReleaseId"
|
||||
: "资源不在本地索引;先 release.search 该站点后再提交",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -244,20 +244,20 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
|
||||
const v = await svc().verifyDownload(params.id, artifact);
|
||||
let submission: ManualAcquisitionResult | null = null;
|
||||
let note: string | null = null;
|
||||
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
|
||||
else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
|
||||
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交";
|
||||
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
|
||||
else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
|
||||
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交";
|
||||
else if (body.submit) {
|
||||
try {
|
||||
submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
|
||||
...body.submit,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
});
|
||||
if (!submission.submitted) note = "提交未生成下载计划,见 rejected";
|
||||
if (!submission.submitted) note = "提交未生成下载计划,见 rejected";
|
||||
} catch (e) {
|
||||
// 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回
|
||||
if (!(e instanceof ApiException) || e.code === "internal") throw e;
|
||||
note = `提交未通过:${e.message}`;
|
||||
note = `提交未通过:${e.message}`;
|
||||
}
|
||||
}
|
||||
await svc().recordDownload(params.id, {
|
||||
|
||||
@@ -86,7 +86,7 @@ export function fromBrowserError(e: unknown): unknown {
|
||||
case "rate_limited":
|
||||
return new BrowserServiceError("rate_limited", e.message);
|
||||
default:
|
||||
return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`);
|
||||
return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,8 @@ export class BrowserService {
|
||||
.where("kind", "<>", "none");
|
||||
if (id) q = q.where("id", "=", id);
|
||||
const row = await q.orderBy("created_at").executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md");
|
||||
if (!row)
|
||||
throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md");
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -329,13 +330,13 @@ export class BrowserService {
|
||||
.executeTakeFirst();
|
||||
if (!prow?.enabled) {
|
||||
await this.closeRow(row, "provider_disabled");
|
||||
throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭");
|
||||
throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭");
|
||||
}
|
||||
const site = await this.liveSite(row.site_id).catch(() => null);
|
||||
if (!site?.enabled) {
|
||||
// 站点关闭即停止其网络访问(REQ §4.6、§4.7.3)
|
||||
await this.closeRow(row, "site_disabled", prow);
|
||||
throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭");
|
||||
throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭");
|
||||
}
|
||||
const provider = this.providerFor(prow);
|
||||
if (!provider.sessionInfo(row.id)) {
|
||||
@@ -611,8 +612,8 @@ export class BrowserService {
|
||||
kind: "other",
|
||||
status: "awaiting_user",
|
||||
severity: "warning",
|
||||
title: `${site?.name ?? "站点"}:原站需要人工处理`,
|
||||
summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`,
|
||||
title: `${site?.name ?? "站点"}:原站需要人工处理`,
|
||||
summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`,
|
||||
subject_type: "browser_session",
|
||||
subject_id: row.id,
|
||||
dedupe_key: `browser.waiting:${row.id}`,
|
||||
@@ -783,8 +784,8 @@ export class BrowserService {
|
||||
throw new BrowserServiceError(
|
||||
"conflict",
|
||||
row.controller === "human"
|
||||
? "会话正由人工接管,自动化动作已停止"
|
||||
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`,
|
||||
? "会话正由人工接管,自动化动作已停止"
|
||||
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -803,7 +804,7 @@ export class BrowserService {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
|
||||
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, loaded.provider, { needsSnapshot: true });
|
||||
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
||||
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
||||
}
|
||||
await this.persist(id, loaded.provider).catch(() => {});
|
||||
throw fromBrowserError(e);
|
||||
|
||||
@@ -60,12 +60,12 @@ export function readableLoginError(provider: string, err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
if (/EADDRINUSE|address already in use/i.test(raw)) {
|
||||
const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1];
|
||||
return `服务端回调端口${port ? ` ${port}` : ""} 被占用。结束占用该端口的进程(如另一个登录流程、Claude Code / Codex CLI)后重试`;
|
||||
return `服务端回调端口${port ? ` ${port}` : ""} 被占用。结束占用该端口的进程(如另一个登录流程、Claude Code / Codex CLI)后重试`;
|
||||
}
|
||||
if (/cancel|abort/i.test(raw)) return "登录已取消";
|
||||
if (/state mismatch/i.test(raw)) return "粘贴的地址不属于本次登录(state 不一致)。用本次打开的授权链接重新登录";
|
||||
if (/state mismatch/i.test(raw)) return "粘贴的地址不属于本次登录(state 不一致)。用本次打开的授权链接重新登录";
|
||||
if (/\b404\b/.test(raw) && provider === "openai-codex")
|
||||
return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录";
|
||||
return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录";
|
||||
return classifyUpstreamError(err).message;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export class OAuthSessions {
|
||||
if (!info.oauth.modes.includes(mode))
|
||||
throw new ApiException(
|
||||
"validation_failed",
|
||||
`${info.name} 不支持 ${mode} 登录,可用:${info.oauth.modes.join(" / ")}`,
|
||||
`${info.name} 不支持 ${mode} 登录,可用:${info.oauth.modes.join(" / ")}`,
|
||||
);
|
||||
|
||||
const busy = this.active(row.provider);
|
||||
@@ -119,7 +119,7 @@ export class OAuthSessions {
|
||||
if (busy.upstreamId !== row.id)
|
||||
throw new ApiException(
|
||||
"conflict",
|
||||
`${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`,
|
||||
`${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`,
|
||||
);
|
||||
this.cancel(busy, "cancelled", "已被新的登录请求取代");
|
||||
await busy.done.catch(() => undefined);
|
||||
@@ -144,7 +144,7 @@ export class OAuthSessions {
|
||||
pasteWaiters: [],
|
||||
firstEvent: Promise.withResolvers<void>(),
|
||||
done: Promise.resolve(),
|
||||
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl),
|
||||
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl),
|
||||
};
|
||||
(s.timer as { unref?: () => void }).unref?.();
|
||||
this.sessions.set(s.id, s);
|
||||
@@ -192,7 +192,7 @@ export class OAuthSessions {
|
||||
for (const w of s.pasteWaiters.splice(0)) w();
|
||||
});
|
||||
}
|
||||
throw new Error("这个登录流程需要额外输入(secret),服务端不支持");
|
||||
throw new Error("这个登录流程需要额外输入(secret),服务端不支持");
|
||||
}
|
||||
|
||||
private notify(s: Session, e: UpstreamAuthEvent) {
|
||||
@@ -238,11 +238,11 @@ export class OAuthSessions {
|
||||
return {
|
||||
sessionId: req.sessionId,
|
||||
state: "expired",
|
||||
message: "登录会话不存在或已过期,重新开始登录",
|
||||
message: "登录会话不存在或已过期,重新开始登录",
|
||||
progress: [],
|
||||
};
|
||||
if (req.pastedUrlOrCode && s.state === "pending") {
|
||||
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成");
|
||||
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成");
|
||||
if (!s.paste) {
|
||||
// manual_code 提示可能还没发出:短暂等待
|
||||
await Promise.race([
|
||||
|
||||
@@ -342,7 +342,7 @@ export async function refreshSiteAccount(
|
||||
decisionId: null,
|
||||
});
|
||||
const def = getSiteDefinition(site.key);
|
||||
if (!def || site.deleted_at) return unchanged("unsupported", "站点没有适配器定义,无法校验登录");
|
||||
if (!def || site.deleted_at) return unchanged("unsupported", "站点没有适配器定义,无法校验登录");
|
||||
const defaults = await rt.siteDefaults();
|
||||
const ua = acc.user_agent ?? defaults.userAgent;
|
||||
let limiter: ReturnType<SourcesRuntime["limiterOf"]> = null;
|
||||
@@ -402,7 +402,7 @@ export async function refreshSiteAccount(
|
||||
const succeed = (info: SiteUserInfo | null, note?: string) => markSuccess(rt, acc, site, info, actor, note);
|
||||
|
||||
try {
|
||||
if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不参与登录");
|
||||
if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不参与登录");
|
||||
if (!acc.secret_ref) return unchanged("unsupported", "账号没有凭证");
|
||||
if (acc.auth_kind === "password") {
|
||||
if (umt.kind !== "nexusphp" || !umt.login) {
|
||||
@@ -434,14 +434,14 @@ export async function refreshSiteAccount(
|
||||
if (r.kind === "ok") return await succeed(r.info);
|
||||
return await succeed(
|
||||
null,
|
||||
`登录成功;用户数据未取到:${r.kind === "no_parser" ? "站点没有用户数据解析器" : r.message}`,
|
||||
`登录成功;用户数据未取到:${r.kind === "no_parser" ? "站点没有用户数据解析器" : r.message}`,
|
||||
);
|
||||
}
|
||||
const plain = await secrets.resolve(acc.secret_ref);
|
||||
let r = await fetchUserData(acc.auth_kind === "cookie" ? { cookie: plain } : { apikey: plain });
|
||||
if (r.kind === "no_parser") r = await viaHealth();
|
||||
if (r.kind === "ok") return await succeed(r.info);
|
||||
if (r.kind === "no_parser") return await succeed(null, "站点没有用户数据解析器,只校验了登录态");
|
||||
if (r.kind === "no_parser") return await succeed(null, "站点没有用户数据解析器,只校验了登录态");
|
||||
if (r.kind === "network") return await fail("network", r.message);
|
||||
if (r.kind === "challenge") return await fail("challenge", r.message);
|
||||
return await fail(
|
||||
@@ -580,11 +580,11 @@ async function upsertUserDataDay(trx: Exec, accountId: string, now: Date, info:
|
||||
const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = {
|
||||
challenge: {
|
||||
title: (s) => `站点「${s}」登录需要人工验证`,
|
||||
summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试",
|
||||
summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试",
|
||||
},
|
||||
invalid: {
|
||||
title: (s) => `站点「${s}」登录失败`,
|
||||
summary: "用户名或密码错误,或账号被封禁 / 未确认。更新凭证后重新刷新登录",
|
||||
summary: "用户名或密码错误,或账号被封禁 / 未确认。更新凭证后重新刷新登录",
|
||||
},
|
||||
expired: {
|
||||
title: (s) => `站点「${s}」登录失效`,
|
||||
@@ -619,7 +619,7 @@ async function markFailure(
|
||||
const text = DECISION_TEXT[status]!;
|
||||
const summary =
|
||||
reason === "challenge"
|
||||
? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录"
|
||||
? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录"
|
||||
: status === "expired" ||
|
||||
reason === "account_blocked" ||
|
||||
reason === "bad_credentials" ||
|
||||
@@ -719,7 +719,7 @@ export async function checkSiteHealth(
|
||||
let message: string | null = null;
|
||||
const def = getSiteDefinition(site.key);
|
||||
if (!def) {
|
||||
message = "站点没有适配器定义,无法检测";
|
||||
message = "站点没有适配器定义,无法检测";
|
||||
status = "unsupported";
|
||||
} else {
|
||||
await rt.siteDefaults();
|
||||
|
||||
Reference in new issue
Block a user