style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9b017906cf
commit
52402864d6
143 files changed
+985
-606
No files matched your search
@@ -102,7 +102,7 @@ export const D = {
|
||||
method: "get" as const,
|
||||
path: "/sites",
|
||||
scope: "read" as const,
|
||||
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
|
||||
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
|
||||
tags: TS,
|
||||
query: SiteListQuery,
|
||||
response: cursorPage(SiteListItem),
|
||||
@@ -117,7 +117,7 @@ export const D = {
|
||||
body: SiteCreate,
|
||||
response: Site,
|
||||
},
|
||||
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
|
||||
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
|
||||
},
|
||||
siteAccounts: {
|
||||
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
|
||||
@@ -126,7 +126,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/site-accounts",
|
||||
scope: "admin" as const,
|
||||
summary: "新建站点账号(凭证只写,存为 secret)",
|
||||
summary: "新建站点账号(凭证只写,存为 secret)",
|
||||
tags: TS,
|
||||
body: SiteAccountCreate,
|
||||
response: SiteAccount,
|
||||
@@ -148,7 +148,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/feed-sources",
|
||||
scope: "admin" as const,
|
||||
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
|
||||
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
|
||||
tags: TF,
|
||||
body: FeedSourceCreate,
|
||||
response: FeedSource,
|
||||
@@ -158,7 +158,7 @@ export const D = {
|
||||
"post",
|
||||
"/feed-sources/{id}/fetch",
|
||||
"admin",
|
||||
"立即抓取(仍受站点限流)",
|
||||
"立即抓取(仍受站点限流)",
|
||||
undefined,
|
||||
z.object({ taskId: Id }),
|
||||
TF,
|
||||
@@ -202,7 +202,7 @@ export const D = {
|
||||
method: "post" as const,
|
||||
path: "/media-entities",
|
||||
scope: "organize.execute" as const,
|
||||
summary: "手动建本地作品(TMDB 无记录时可先建档)",
|
||||
summary: "手动建本地作品(TMDB 无记录时可先建档)",
|
||||
tags: TE,
|
||||
body: MediaEntityCreate,
|
||||
response: MediaEntity,
|
||||
@@ -211,7 +211,7 @@ export const D = {
|
||||
"patch",
|
||||
"/media-entities/{id}",
|
||||
"organize.execute",
|
||||
"修改作品(标题/原名/年份按字段覆盖并锁定)",
|
||||
"修改作品(标题/原名/年份按字段覆盖并锁定)",
|
||||
MediaEntityUpdate,
|
||||
MediaEntity,
|
||||
TE,
|
||||
@@ -220,7 +220,7 @@ export const D = {
|
||||
"post",
|
||||
"/media-entities/{id}/merge",
|
||||
"organize.execute",
|
||||
"合并重复作品(保留旧 ID 跳转)",
|
||||
"合并重复作品(保留旧 ID 跳转)",
|
||||
MediaEntityMerge,
|
||||
MediaEntity,
|
||||
TE,
|
||||
@@ -239,7 +239,7 @@ export const D = {
|
||||
"patch",
|
||||
"/content-units/{id}",
|
||||
"organize.execute",
|
||||
"修改内容单元(改编号即确认并锁定)",
|
||||
"修改内容单元(改编号即确认并锁定)",
|
||||
ContentUnitUpdate,
|
||||
ContentUnit,
|
||||
TE,
|
||||
@@ -270,7 +270,7 @@ export const D = {
|
||||
"post",
|
||||
"/external-identities",
|
||||
"organize.execute",
|
||||
"新增外部身份(accepted 等同人工纠错)",
|
||||
"新增外部身份(accepted 等同人工纠错)",
|
||||
ExternalIdentityCreate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
@@ -280,7 +280,7 @@ export const D = {
|
||||
"patch",
|
||||
"/external-identities/{id}",
|
||||
"organize.execute",
|
||||
"修订外部身份(乐观并发)",
|
||||
"修订外部身份(乐观并发)",
|
||||
ExternalIdentityUpdate,
|
||||
ExternalIdentity,
|
||||
TI,
|
||||
@@ -295,7 +295,7 @@ export const D = {
|
||||
decisions: {
|
||||
list: list(
|
||||
"/decisions",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
Decision,
|
||||
{ status: DecisionStatus.optional(), kind: DecisionKind.optional() },
|
||||
TD,
|
||||
|
||||
@@ -58,7 +58,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
return { summary: `${await fn(rt)} 项` };
|
||||
},
|
||||
});
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
|
||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||
@@ -66,7 +66,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.user_data",
|
||||
defaults: {
|
||||
name: "站点数据刷新",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -80,7 +80,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.health_check",
|
||||
defaults: {
|
||||
name: "站点健康检测",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
|
||||
@@ -154,7 +154,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.where("source_release_id", "=", d.subject_id)
|
||||
.executeTakeFirst();
|
||||
if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) {
|
||||
throw new ApiException("conflict", "候选已变化,刷新后重试");
|
||||
throw new ApiException("conflict", "候选已变化,刷新后重试");
|
||||
}
|
||||
await assignReleaseIdentity(
|
||||
rt(),
|
||||
|
||||
@@ -112,7 +112,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/media-entities/{id}/detail",
|
||||
scope: "read",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: MediaEntityDetail,
|
||||
@@ -202,7 +202,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/identities",
|
||||
scope: "organize.execute",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: EntityIdentitySet,
|
||||
@@ -221,7 +221,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
response: Accepted,
|
||||
@@ -248,7 +248,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "put",
|
||||
path: "/media-entities/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
|
||||
tags: T_ENT,
|
||||
params: FieldParam,
|
||||
body: FieldOverrideInput,
|
||||
@@ -501,7 +501,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/identity-pool",
|
||||
scope: "read",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
|
||||
tags: T_ID,
|
||||
query: IdentityPoolQuery,
|
||||
response: cursorPage(IdentityPoolItem),
|
||||
@@ -600,7 +600,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/identity-pool/{id}/dismiss",
|
||||
scope: "organize.execute",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
summary: "忽略待识别资源(不再自动复查)",
|
||||
tags: T_ID,
|
||||
params: IdParam,
|
||||
body: z.object({ note: z.string().max(2000).optional() }),
|
||||
|
||||
@@ -75,7 +75,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/site-definitions",
|
||||
scope: "read",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
summary: "随产品发布的站点适配器定义(含是否已登记)",
|
||||
tags: T_SITES,
|
||||
response: z.object({ items: z.array(SiteDefinitionView) }),
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
|
||||
}
|
||||
if (!def && body.adapter !== "generic_rss") {
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
}
|
||||
try {
|
||||
const row = await db()
|
||||
@@ -199,7 +199,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: `/sites/{id}/${enabled ? "enable" : "disable"}`,
|
||||
scope: "admin",
|
||||
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: Site,
|
||||
@@ -213,7 +213,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/snapshot",
|
||||
scope: "admin",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
summary: "立即同步站点快照(受站点限流)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
|
||||
@@ -236,7 +236,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/snapshots",
|
||||
scope: "read",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
query: CursorQuery,
|
||||
@@ -264,7 +264,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "delete",
|
||||
path: "/sites/{id}",
|
||||
scope: "admin",
|
||||
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
|
||||
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: OkResult,
|
||||
@@ -281,7 +281,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/health-check",
|
||||
scope: "admin",
|
||||
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
|
||||
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
response: SiteHealthResult,
|
||||
@@ -294,7 +294,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/sites/{id}/refresh-login",
|
||||
scope: "admin",
|
||||
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
|
||||
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: SiteRefreshLoginInput,
|
||||
@@ -356,7 +356,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "put",
|
||||
path: "/sites/{id}/credentials",
|
||||
scope: "admin",
|
||||
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
|
||||
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
|
||||
tags: T_SITES,
|
||||
params: IdParam,
|
||||
body: SiteCredentialsPut,
|
||||
@@ -625,7 +625,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/feed-sources/{id}/reset",
|
||||
scope: "admin",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
|
||||
tags: T_FEEDS,
|
||||
params: IdParam,
|
||||
response: FeedSource,
|
||||
@@ -713,7 +713,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/source-releases/{id}/identity",
|
||||
scope: "organize.execute",
|
||||
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
|
||||
tags: T_RELEASES,
|
||||
params: IdParam,
|
||||
body: ReleaseIdentityAssign,
|
||||
|
||||
@@ -17,7 +17,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases",
|
||||
scope: "read",
|
||||
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id }),
|
||||
query: WorkbenchReleaseQuery,
|
||||
@@ -31,7 +31,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "get",
|
||||
path: "/sites/{id}/releases/{rid}",
|
||||
scope: "read",
|
||||
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
|
||||
tags: T,
|
||||
params: z.object({ id: Id, rid: Id }),
|
||||
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
|
||||
|
||||
@@ -86,7 +86,7 @@ export class SourcesRuntime {
|
||||
if (/^[0-9a-f]{32}$/i.test(secret)) apiKey = secret;
|
||||
else accessToken = secret;
|
||||
} catch (err) {
|
||||
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
|
||||
this.ctx.logger.warn({ err: { message: (err as Error).message } }, "TMDB 密钥解密失败,按未配置处理");
|
||||
}
|
||||
} else {
|
||||
apiKey = env("TMDB_API_KEY");
|
||||
|
||||
@@ -41,14 +41,14 @@ export async function createEntityFromTmdbCandidate(
|
||||
if (existing) return describe(db, existing, false, 0);
|
||||
|
||||
const client = await tmdb();
|
||||
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
|
||||
if (!client) throw new CandidateError("not_configured", "未配置 TMDB,无法按候选建作品");
|
||||
let detail: Awaited<ReturnType<TmdbClient["details"]>>;
|
||||
try {
|
||||
detail = await client.details(ref.kind, ref.externalId);
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`);
|
||||
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
|
||||
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function ingestFeed(
|
||||
}
|
||||
|
||||
const lastError = auth
|
||||
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
|
||||
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" }
|
||||
: (state.lastError ?? null);
|
||||
// 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态
|
||||
const gap: StoredCursor["gap"] =
|
||||
@@ -209,13 +209,13 @@ export async function ingestFeed(
|
||||
kind: "source_unreachable",
|
||||
severity: "warning",
|
||||
title: `Feed「${feed.name}」凭证失效`,
|
||||
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
|
||||
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
|
||||
subjectType: "feed_source",
|
||||
subjectId: feed.id,
|
||||
dedupeKey: `feed-auth:${feed.id}`,
|
||||
evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError },
|
||||
options: [
|
||||
{ key: "reset", label: "已更新凭证,恢复抓取" },
|
||||
{ key: "reset", label: "已更新凭证,恢复抓取" },
|
||||
{ key: "disable", label: "停用 Feed" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
|
||||
const hit = await entityByExternal(exec, ref);
|
||||
if (hit) return hit;
|
||||
if (ref.provider !== "tmdb")
|
||||
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
|
||||
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
|
||||
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部身份建档");
|
||||
let detail: Awaited<ReturnType<typeof tmdb.details>>;
|
||||
try {
|
||||
detail = await tmdb.details(ref.entityType, ref.externalId);
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new ApiException("not_found", `TMDB 无此条目 ${ref.entityType}/${ref.externalId}`);
|
||||
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
|
||||
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
|
||||
throw e;
|
||||
}
|
||||
const r = await createEntityFromTmdb(exec, detail, {
|
||||
@@ -241,7 +241,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const other = await entityByExternal(trx, input);
|
||||
if (other && other !== id) {
|
||||
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
|
||||
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
|
||||
}
|
||||
if (other === id) return { mediaEntityId: id, changed: false };
|
||||
const olds = await trx
|
||||
@@ -290,7 +290,7 @@ export async function updateExternalIdentity(
|
||||
.executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "外部身份不存在");
|
||||
if (row.revision !== input.expectedRevision) {
|
||||
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
|
||||
throw new ApiException("precondition_failed", `修订号已变化(当前 ${row.revision})`);
|
||||
}
|
||||
const rev = Math.max(row.revision + 1, await nextRevision(trx, id));
|
||||
const patch: Record<string, unknown> = { revision: rev };
|
||||
@@ -365,7 +365,7 @@ export async function mergeEntities(
|
||||
if (!from) throw new ApiException("not_found", "源作品不存在");
|
||||
if (!intoResolved) throw new ApiException("not_found", "目标作品不存在");
|
||||
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
|
||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
|
||||
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环");
|
||||
|
||||
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
|
||||
const idents = await trx
|
||||
|
||||
@@ -511,13 +511,13 @@ function manualIdentityOptions(sourceReleaseId: string) {
|
||||
{
|
||||
key: "manual_tmdb",
|
||||
label: "手动指定 TMDB ID",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
input: { sourceReleaseId, requires: "tmdb" },
|
||||
},
|
||||
{
|
||||
key: "local_entity",
|
||||
label: "选本地作品",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
input: { sourceReleaseId, requires: "mediaEntityId" },
|
||||
},
|
||||
];
|
||||
@@ -592,7 +592,7 @@ async function toPool(
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_ambiguous",
|
||||
severity: "info",
|
||||
title: `待确认身份:${release.title}`,
|
||||
title: `待确认身份:${release.title}`,
|
||||
summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
@@ -615,10 +615,10 @@ async function toPool(
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_unresolved",
|
||||
severity: "info",
|
||||
title: `待识别:${release.title}`,
|
||||
title: `待识别:${release.title}`,
|
||||
summary:
|
||||
reason === "lookup_failed"
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||
: `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
|
||||
@@ -91,7 +91,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
|
||||
subjectId: site.id,
|
||||
dedupeKey: `site-auth:${site.id}`,
|
||||
evidence: { siteId: site.id, kind: e.kind, httpStatus: e.httpStatus ?? null },
|
||||
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
|
||||
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
|
||||
});
|
||||
// 有密码的账号:自动重登一次(同账号 10 分钟内最多一次),成功后关闭本待处理并复位站点健康
|
||||
await enqueueRelogin(rt, site.id);
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function liveSearch(
|
||||
const st = liveState(rt);
|
||||
const key = `${site.id}|${normalizeTitle(q) || q.toLowerCase()}`;
|
||||
const failed = st.failedUntil.get(key);
|
||||
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
|
||||
if (failed && failed.until > rt.now().getTime()) return { error: `${failed.error}(退避中)`, coalesced: false };
|
||||
const running = st.inflight.get(key);
|
||||
if (running) return { ...(await running), coalesced: true };
|
||||
const p = (async () => {
|
||||
@@ -149,7 +149,7 @@ export async function listSiteReleases(
|
||||
let coalesced = false;
|
||||
const wantLive = q.live === "true" || (q.live !== "false" && offset === 0 && !!q.q && (rows.length === 0 || stale));
|
||||
if (wantLive && q.q) {
|
||||
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
|
||||
if (!site.enabled) liveError = "站点已关闭,只展示历史数据";
|
||||
else {
|
||||
const r = await liveSearch(rt, site, q.q);
|
||||
live = r.error === null;
|
||||
|
||||
Reference in new issue
Block a user