feat(sources): site center backend (credentials, refresh login, user data, health check, delete)

PUT /sites/{id}/credentials by kind (moved from auth-settings, legacy body
accepted), POST refresh-login (async task or wait), POST health-check,
GET user-data history, DELETE /sites/{id} (soft delete, releases kept),
DELETE /site-accounts/{id}, GET /sites?include=accounts; refreshSiteAccount
task with login-failure decisions and auto re-login on auth_expired;
scheduled jobs sites.user_data (6h) and sites.health_check (1h);
settings.sites defaults for rate limit, snapshot interval, UA and proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:18:40 +08:00
1 parent d6fca25679
commit 05a7706dde
15 files changed
+1602 -179

No files matched your search

@@ -18,10 +18,7 @@ import {
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
SiteCredentialsPut,
SiteCredentialsResult,
} from "@umt/contracts";
import type { DB } from "@umt/db";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
import { redact, redactString } from "../../logger.ts";
@@ -41,7 +38,7 @@ import {
} from "./endpoint.ts";
// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试,
// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。
// 站点账号凭证录入已迁到 sources-identity(PUT /sites/{id}/credentials)。
const OkResult = z.object({ ok: z.literal(true) });
const del = (path: string, summary: string, tag: string) => ({
@@ -470,97 +467,5 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok });
return toApi(updated);
});
// ── 站点账号凭证 ──
const PURPOSE: Record<SiteCredentialsPut["authKind"], SecretPurpose> = {
cookie: "site_cookie",
passkey: "site_passkey",
api_key: "site_api_key",
password: "site_password",
};
endpoint(
app,
{
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)",
params: IdParam,
body: SiteCredentialsPut,
response: SiteCredentialsResult,
tags: ["sites"],
},
async ({ params, body, actor }) => {
const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential });
let oldRef: string | null = null;
let created = false;
let accountId: string;
try {
accountId = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
.select(["id", "secret_ref", "user_info"])
.where("site_id", "=", params.id)
.where("label", "=", body.label)
.forUpdate()
.executeTakeFirst();
const userInfo = {
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
...(body.username ? { username: body.username } : {}),
};
const common: Partial<Record<keyof DB["site_account"], unknown>> = {
auth_kind: body.authKind,
secret_ref: newRef,
status: "unknown",
user_info: JSON.stringify(userInfo),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
};
if (cur) {
oldRef = cur.secret_ref;
await trx
.updateTable("site_account")
.set(common as never)
.where("id", "=", cur.id)
.execute();
return cur.id;
}
created = true;
const r = await trx
.insertInto("site_account")
.values({ site_id: params.id, label: body.label, ...common } as never)
.returning("id")
.executeTakeFirstOrThrow();
return r.id;
});
} catch (e) {
await ctx.secrets.remove(newRef).catch(() => {});
throw e;
}
if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {});
await audit(
ctx,
actor,
"site_account.credentials_set",
{ type: "site_account", id: accountId },
{
siteId: params.id,
label: body.label,
authKind: body.authKind,
created,
},
);
return {
siteAccountId: accountId,
siteId: params.id,
label: body.label,
authKind: body.authKind,
secretRef: newRef,
created,
};
},
);
// 站点账号凭证 PUT /sites/{id}/credentials 已迁到 sources-identity(W9-A 站点中心,按 kind 判别)
}
@@ -33,6 +33,8 @@ import {
SiteAccountCreate,
SiteAccountUpdate,
SiteCreate,
SiteListItem,
SiteListQuery,
SiteUpdate,
SourceRelease,
TitleAlias,
@@ -96,7 +98,15 @@ const TD = ["decisions"];
export const D = {
sites: {
list: list("/sites", "站点列表", Site, {}, TS),
list: {
method: "get" as const,
path: "/sites",
scope: "read" as const,
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
tags: TS,
query: SiteListQuery,
response: cursorPage(SiteListItem),
},
get: get("/sites/{id}", "站点详情", Site, TS),
create: {
method: "post" as const,
@@ -107,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),
@@ -7,6 +7,7 @@ import { sourcesRoutes } from "./routes/sources.ts";
import { workbenchRoutes } from "./routes/workbench.ts";
import { type RuntimeOverrides, SourcesRuntime } from "./runtime.ts";
import { readOnlyReconcile, tickFeeds, tickReviews, tickSnapshots } from "./scheduler.ts";
import { tickSiteHealth, tickSiteUserData } from "./service/account.ts";
import { registerSourcesTasks, sourcesOutboxRoutes } from "./tasks.ts";
/**
@@ -60,6 +61,32 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
registerScheduledJob(ctx, {
key: "sites.user_data",
defaults: {
name: "站点数据刷新",
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
group: "collect",
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
firstRun: "after_interval",
},
run: async ({ slot }) => {
if (await readOnlyReconcile(rt)) return { status: "skipped", summary: "只读对账模式" };
return { summary: `${await tickSiteUserData(rt, slot)} 个账号` };
},
});
registerScheduledJob(ctx, {
key: "sites.health_check",
defaults: {
name: "站点健康检测",
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
group: "collect",
schedule: { type: "interval", everyMs: 3_600_000 },
firstRun: "after_interval",
},
run: async () => ({ summary: `${await tickSiteHealth(rt)} 个站点` }),
});
},
};
}
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db";
import { z } from "zod";
@@ -163,6 +164,15 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
);
}
}
// 站点登录需人工验证:"重试登录" = 入队刷新登录(结果另行更新账号状态与待处理)
if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") {
const accountId = d.dedupe_key.slice("site-login:".length);
await rt().ctx.engine.enqueue(
"refreshSiteAccount",
{ accountId, mode: "login" },
{ idempotencyKey: `refreshSiteAccount:decision:${d.id}:${Date.now()}`, traceId: randomUUID() },
);
}
// 上面的执行路径已按 dedupeKey 关闭;其余类型在这里记录处理结果
const row = await db()
.transaction()
@@ -7,13 +7,21 @@ import {
Id,
IdParam,
type Observation,
OkResult,
ReleaseIdentityAssign,
type SecretPurpose,
Site,
type SiteAccount,
type SiteAdapter,
SiteCredentialsPut,
SiteCredentialsResult,
SiteDefinitionView,
SiteHealthResult,
SiteRefreshLoginAccepted,
SiteRefreshLoginInput,
SiteSnapshot,
SiteUserDataPoint,
SiteUserDataQuery,
type SourceRelease,
} from "@umt/contracts";
import { sql } from "@umt/db";
@@ -24,6 +32,16 @@ import { D } from "../defs.ts";
import { route } from "../http.ts";
import { decodeCursor, feedToApi, jsonb, page, toApi, toApiList } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import {
accountToApi,
checkSiteHealth,
deleteSite,
deleteSiteAccount,
overallAccountStatus,
refreshSiteAccount,
setSiteCredentials,
userDataHistory,
} from "../service/account.ts";
import { audit, closeDecisions } from "../service/common.ts";
import { assignReleaseIdentity } from "../service/identity-admin.ts";
@@ -61,7 +79,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
response: z.object({ items: z.array(SiteDefinitionView) }),
},
async () => {
const rows = await db().selectFrom("site").select(["id", "key"]).execute();
const rows = await db().selectFrom("site").select(["id", "key"]).where("deleted_at", "is", null).execute();
const byKey = new Map(rows.map((r) => [r.key, r.id]));
return {
items: listSiteDefinitions().map((d) => ({
@@ -86,12 +104,31 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const rows = await db()
.selectFrom("site")
.selectAll()
.where("deleted_at", "is", null)
.orderBy("key")
.offset(off)
.limit(query.limit + 1)
.execute();
const p = page(rows, off, query.limit);
return { items: toApiList<Site>(p.items), nextCursor: p.nextCursor };
const items = toApiList<Record<string, unknown>>(p.items);
if (query.include === "accounts" && p.items.length) {
const accounts = await db()
.selectFrom("site_account")
.selectAll()
.where(
"site_id",
"in",
p.items.map((s) => s.id),
)
.orderBy("created_at")
.execute();
for (const it of items) {
const mine = accounts.filter((a) => a.site_id === it.id);
it.accounts = mine.map(accountToApi);
it.accountStatus = overallAccountStatus(mine);
}
}
return { items, nextCursor: p.nextCursor };
});
route(app, D.sites.get, async ({ params }) => toApi(await getSite(params.id)));
route(app, D.sites.create, async ({ body, actor }) => {
@@ -115,6 +152,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
config: jsonb(body.config ?? {}),
snapshot_enabled: body.snapshotEnabled ?? false,
snapshot_interval_sec: body.snapshotIntervalSec ?? null,
proxy: body.proxy ?? false,
})
.returningAll()
.executeTakeFirstOrThrow();
@@ -135,7 +173,15 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
if (body.config !== undefined) patch.config = jsonb(body.config);
if (body.snapshotEnabled !== undefined) patch.snapshot_enabled = body.snapshotEnabled;
if (body.snapshotIntervalSec !== undefined) patch.snapshot_interval_sec = body.snapshotIntervalSec;
if (body.enabled !== undefined) return toApi(await setSiteEnabled(params.id, body.enabled, actor));
if (body.proxy !== undefined) patch.proxy = body.proxy;
if (body.enabled !== undefined) {
if (Object.keys(patch).length) {
await db().updateTable("site").set(patch).where("id", "=", params.id).execute();
await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) });
}
return toApi(await setSiteEnabled(params.id, body.enabled, actor));
}
if (!Object.keys(patch).length) return toApi(await getSite(params.id));
const row = await db()
.updateTable("site")
.set(patch)
@@ -210,6 +256,132 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
},
);
// ── W9-A 站点中心:删除、健康检测、刷新登录、用户数据、凭证 ──
route(
app,
{
method: "delete",
path: "/sites/{id}",
scope: "admin",
summary: "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)",
tags: T_SITES,
params: IdParam,
response: OkResult,
},
async ({ params, actor }) => {
const s = await getSite(params.id);
await deleteSite(rt(), s, actor);
return { ok: true as const };
},
);
route(
app,
{
method: "post",
path: "/sites/{id}/health-check",
scope: "admin",
summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列",
tags: T_SITES,
params: IdParam,
response: SiteHealthResult,
},
async ({ params, actor }) => checkSiteHealth(rt(), await getSite(params.id), actor),
);
route(
app,
{
method: "post",
path: "/sites/{id}/refresh-login",
scope: "admin",
summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据",
tags: T_SITES,
params: IdParam,
body: SiteRefreshLoginInput,
response: SiteRefreshLoginAccepted,
},
async ({ params, body, actor }) => {
const s = await getSite(params.id);
let q = db().selectFrom("site_account").select(["id", "enabled", "auth_kind"]).where("site_id", "=", s.id);
q = body.accountId
? q.where("id", "=", body.accountId)
: q.where("enabled", "=", true).where("auth_kind", "!=", "passkey");
const acc = await q.orderBy("created_at").executeTakeFirst();
if (!acc) {
throw new ApiException(
body.accountId ? "not_found" : "precondition_failed",
body.accountId ? "站点账号不存在" : "站点还没有凭证",
);
}
if (body.wait) {
const result = await refreshSiteAccount(rt(), acc.id, {
mode: "login",
actor,
signal: AbortSignal.timeout(30_000),
});
return { taskId: null, result };
}
const r = await rt().ctx.engine.enqueue(
"refreshSiteAccount",
{ accountId: acc.id, mode: "login" },
{
idempotencyKey: `refreshSiteAccount:manual:${acc.id}:${Math.floor(Date.now() / 10_000)}`,
traceId: randomUUID(),
},
);
await audit(db(), actor, "site_account.login_refresh_requested", { type: "site_account", id: acc.id });
return { taskId: r.runId, result: null };
},
);
route(
app,
{
method: "get",
path: "/sites/{id}/user-data",
scope: "read",
summary: "站点账号用户数据按天历史",
tags: T_SITES,
params: IdParam,
query: SiteUserDataQuery,
response: z.object({ items: z.array(SiteUserDataPoint) }),
},
async ({ params, query }) => {
await getSite(params.id);
return { items: await userDataHistory(rt(), params.id, query) };
},
);
route(
app,
{
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)",
tags: T_SITES,
params: IdParam,
body: SiteCredentialsPut,
response: SiteCredentialsResult,
},
async ({ params, body, actor }) => setSiteCredentials(rt(), await getSite(params.id), body, actor),
);
route(
app,
{
method: "delete",
path: "/site-accounts/{id}",
scope: "admin",
summary: "删除站点账号与其凭证",
tags: T_SITES,
params: IdParam,
response: OkResult,
},
async ({ params, actor }) => {
const acc = await db().selectFrom("site_account").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!acc) throw new ApiException("not_found", "站点账号不存在");
await deleteSiteAccount(rt(), acc, actor);
return { ok: true as const };
},
);
// ── 站点账号 ──
route(app, D.siteAccounts.list, async ({ query }) => {
const off = decodeCursor(query.cursor);
@@ -221,12 +393,12 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.limit(query.limit + 1)
.execute();
const p = page(rows, off, query.limit);
return { items: toApiList<SiteAccount>(p.items), nextCursor: p.nextCursor };
return { items: p.items.map(accountToApi) as SiteAccount[], nextCursor: p.nextCursor };
});
route(app, D.siteAccounts.get, async ({ params }) => {
const row = await db().selectFrom("site_account").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!row) throw new ApiException("not_found", "站点账号不存在");
return toApi(row);
return accountToApi(row);
});
route(app, D.siteAccounts.create, async ({ body, actor }) => {
await getSite(body.siteId);
@@ -248,7 +420,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.returningAll()
.executeTakeFirstOrThrow();
await audit(db(), actor, "site_account.created", { type: "site_account", id: row.id }, { siteId: body.siteId });
return toApi(row);
return accountToApi(row);
} catch (e) {
await rt()
.ctx.secrets.remove(ref)
@@ -273,8 +445,10 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
label: `site_account:${acc.label}`,
});
}
// 换了凭证:状态回到未验证,关闭"登录失效"待处理,站点健康复位
// 换了凭证:状态回到未验证,清会话 Cookie,关闭"登录失效"待处理,站点健康复位
patch.status = "unknown";
patch.last_error = null;
patch.session_secret_ref = null;
}
const row = await db()
.transaction()
@@ -300,6 +474,14 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
{ by: "credential_updated" },
`${actor.type}:${actor.id}`,
);
await closeDecisions(
rt().ctx,
trx,
`site-login:${acc.id}`,
"resolved",
{ by: "credential_updated" },
`${actor.type}:${actor.id}`,
);
}
await audit(
trx,
@@ -313,7 +495,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
);
return r;
});
return toApi(row);
return accountToApi(row);
});
// ── Feed ──
@@ -588,7 +770,12 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
// ── 内部辅助 ──
async function getSite(id: string) {
const row = await db().selectFrom("site").selectAll().where("id", "=", id).executeTakeFirst();
const row = await db()
.selectFrom("site")
.selectAll()
.where("id", "=", id)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!row) throw new ApiException("not_found", "站点不存在");
return row;
}
@@ -11,6 +11,7 @@ import {
type SiteRateLimiter,
type TmdbClient,
} from "@umt/adapters";
import { DEFAULT_SETTINGS, type SitesSettings } from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
@@ -40,6 +41,9 @@ export class SourcesRuntime {
readonly rss: RssFetcher;
private readonly adapters = new Map<string, CachedAdapter>();
private tmdbClient: TmdbClient | null | undefined;
/** 站点默认(settings.sites)缓存:siteAdapter 是同步的,读设置的地方顺手刷新 */
private siteDefaultsCache: SitesSettings = DEFAULT_SETTINGS.sites;
private siteDefaultsAt = 0;
private tmdbSig: string | null = null;
constructor(
@@ -101,17 +105,60 @@ export class SourcesRuntime {
return this.tmdbClient;
}
/** 站点默认(settings.sites),读库并刷新缓存;读失败用上次值 */
async siteDefaults(): Promise<SitesSettings> {
try {
this.siteDefaultsCache = { ...DEFAULT_SETTINGS.sites, ...(await this.ctx.settings.get("sites")) };
} catch {
// 保留缓存
}
return this.siteDefaultsCache;
}
/**
* 站点请求用的 fetch:测试注入优先;站点开了"使用代理"且设置了 proxyUrl 时走代理(Deno.createHttpClient)。
*/
siteFetch(site: Pick<SiteRow, "proxy">, defaults: SitesSettings = this.siteDefaultsCache): typeof globalThis.fetch {
if (this.overrides.fetch) return this.overrides.fetch;
if (!site.proxy || !defaults.proxyUrl) return globalThis.fetch;
const key = defaults.proxyUrl;
let client = this.proxyClients.get(key);
if (!client) {
client = Deno.createHttpClient({ proxy: { url: key } });
this.proxyClients.set(key, client);
}
const c = client;
return ((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, { ...init, client: c } as RequestInit)) as typeof globalThis.fetch;
}
private readonly proxyClients = new Map<string, Deno.HttpClient>();
/**
* 站点适配器:每站一个实例,列表/搜索/详情/下载共享同一个限流器。
* 站点限流参数或基址变化时重建(签名比较)。
* 站点限流参数、基址、代理或站点默认变化时重建(签名比较)。
*/
siteAdapter(site: Pick<SiteRow, "id" | "key" | "base_url" | "rate_limit" | "config">): SiteAdapter {
siteAdapter(
site: Pick<SiteRow, "id" | "key" | "base_url" | "rate_limit" | "config"> & Partial<Pick<SiteRow, "proxy">>,
): SiteAdapter {
const def = getSiteDefinition(site.key);
if (!def) throw new Error(`站点 ${site.key} 没有随产品发布的适配器定义`);
const sig = JSON.stringify([site.base_url, site.rate_limit, site.config]);
if (Date.now() - this.siteDefaultsAt > 30_000) {
this.siteDefaultsAt = Date.now();
void this.siteDefaults();
}
const defaults = this.siteDefaultsCache;
const proxy = site.proxy ?? false;
const sig = JSON.stringify([
site.base_url,
site.rate_limit,
site.config,
proxy,
proxy ? defaults.proxyUrl : null,
defaults.defaultRateLimit,
]);
const hit = this.adapters.get(site.id);
if (hit && hit.sig === sig) return hit.adapter;
const limiter = createSiteRateLimiter(limiterOptions(def, site.rate_limit));
const limiter = createSiteRateLimiter(limiterOptions(def, site.rate_limit, defaults.defaultRateLimit));
const cfg = (site.config ?? {}) as { apiBaseUrl?: string };
const baseUrl =
def.umt.kind === "mteam" ? cfg.apiBaseUrl : sameUrl(site.base_url, def.domain) ? undefined : site.base_url;
@@ -119,7 +166,7 @@ export class SourcesRuntime {
secrets: this.siteSecrets(site.id),
limiter,
now: this.now,
...(this.overrides.fetch ? { fetch: this.overrides.fetch } : {}),
...(this.overrides.fetch || proxy ? { fetch: this.siteFetch({ proxy }, defaults) } : {}),
...(baseUrl ? { baseUrl } : {}),
});
this.adapters.set(site.id, { sig, limiter, adapter });
@@ -131,24 +178,39 @@ export class SourcesRuntime {
return this.adapters.get(siteId)?.limiter ?? null;
}
/** 站点凭证:取该站第一个启用且未失效的账号,按 auth_kind 对应适配器要求的凭证类型;明文不缓存 */
/**
* 站点凭证:取该站第一个启用且未失效的账号;明文不缓存。
* cookie:Cookie 型账号的 secret,或账密账号登录得到的会话 Cookie;apiKey:API Key 型账号;
* passkey:账号附挂的 passkey 或 passkey 型账号(RSS / 下载用,不看登录状态)。
*/
private siteSecrets(siteId: string): SecretResolver {
const { db, secrets } = this.ctx;
return {
async resolve({ kind }) {
const want = kind === "cookie" ? ["cookie"] : kind === "apiKey" ? ["api_key"] : ["passkey"];
const acc = await db
let q = db
.selectFrom("site_account")
.select(["secret_ref"])
.select(["auth_kind", "secret_ref", "session_secret_ref", "passkey_secret_ref"])
.where("site_id", "=", siteId)
.where("enabled", "=", true)
.where("status", "in", ["unknown", "active"])
.where("auth_kind", "in", want as never)
.where("secret_ref", "is not", null)
.orderBy("created_at")
.executeTakeFirst();
if (!acc?.secret_ref) return null;
return secrets.resolve(acc.secret_ref);
.orderBy("created_at");
if (kind !== "passkey") q = q.where("status", "in", ["unknown", "active"]);
const rows = await q.execute();
for (const r of rows) {
const ref =
kind === "cookie"
? r.auth_kind === "cookie"
? r.secret_ref
: r.auth_kind === "password"
? r.session_secret_ref
: null
: kind === "apiKey"
? r.auth_kind === "api_key"
? r.secret_ref
: null
: (r.passkey_secret_ref ?? (r.auth_kind === "passkey" ? r.secret_ref : null));
if (ref) return secrets.resolve(ref);
}
return null;
},
};
}
@@ -158,11 +220,32 @@ function sameUrl(a: string, b: string): boolean {
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
}
/** 站点行 rate_limit 优先,缺省用定义里的 umt.rateLimit */
export function limiterOptions(def: SiteDefinition, rateLimit: unknown): { minIntervalMs: number; burst: number } {
const rl = (rateLimit ?? {}) as { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
const minIntervalMs =
rl.minIntervalMs ??
(rl.requestsPerMinute ? Math.ceil(60_000 / rl.requestsPerMinute) : def.umt.rateLimit.minIntervalMs);
return { minIntervalMs, burst: rl.concurrency ?? def.umt.rateLimit.burst };
type RateLimitLike = { requestsPerMinute?: number; minIntervalMs?: number; concurrency?: number };
/**
* 站点行 rate_limit 优先;为空时用全局默认(settings.sites.defaultRateLimit),但不比定义里的 umt.rateLimit 更激进
* (定义值是按站点实测调的下限);都没有时用定义值。
*/
export function limiterOptions(
def: SiteDefinition,
rateLimit: unknown,
globalDefault?: RateLimitLike | null,
): { minIntervalMs: number; burst: number } {
const own = (rateLimit ?? {}) as RateLimitLike;
const hasOwn =
own.minIntervalMs !== undefined || own.requestsPerMinute !== undefined || own.concurrency !== undefined;
const interval = (rl: RateLimitLike) =>
rl.minIntervalMs ?? (rl.requestsPerMinute ? Math.ceil(60_000 / rl.requestsPerMinute) : undefined);
if (hasOwn) {
return {
minIntervalMs: interval(own) ?? def.umt.rateLimit.minIntervalMs,
burst: own.concurrency ?? def.umt.rateLimit.burst,
};
}
const g = globalDefault ?? {};
const gi = interval(g);
return {
minIntervalMs: gi === undefined ? def.umt.rateLimit.minIntervalMs : Math.max(gi, def.umt.rateLimit.minIntervalMs),
burst: g.concurrency === undefined ? def.umt.rateLimit.burst : Math.min(g.concurrency, def.umt.rateLimit.burst),
};
}
@@ -66,10 +66,13 @@ export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt;
const now = rt.now();
// 站点未设周期时用全局默认(settings.sites.defaultSnapshotIntervalSec)
const fallback = (await rt.siteDefaults()).defaultSnapshotIntervalSec;
const sites = await sql<{ id: string; snapshot_interval_sec: number }>`
select id, snapshot_interval_sec from site
where enabled and snapshot_enabled and snapshot_interval_sec is not null
and (last_snapshot_at is null or last_snapshot_at + make_interval(secs => snapshot_interval_sec) <= ${now})`.execute(
select id, coalesce(snapshot_interval_sec, ${fallback}::int) as snapshot_interval_sec from site
where enabled and snapshot_enabled and deleted_at is null
and (last_snapshot_at is null
or last_snapshot_at + make_interval(secs => coalesce(snapshot_interval_sec, ${fallback}::int)) <= ${now})`.execute(
ctx.db,
);
for (const s of sites.rows) {
@@ -0,0 +1,884 @@
import { randomUUID } from "node:crypto";
import {
getSiteDefinition,
loginNexusPhp,
refreshSiteUserData,
SiteLoginError,
type SiteLoginFailure,
type SiteUserInfo,
} from "@umt/adapters";
import type {
DecisionOption,
SecretPurpose,
SiteAccountStatus,
SiteAccountUserData,
SiteCredentialsPut,
SiteCredentialsResult,
SiteHealth,
SiteHealthResult,
SiteLoginResult,
SiteUserDataPoint,
} from "@umt/contracts";
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
import { toApi } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
// W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、
// 用户数据与按天历史、健康检测、删除。凭证明文只在内存里用,不进日志、审计与错误信息。
const jsonb = (v: unknown) => JSON.stringify(v ?? null);
/** site_account 行 → SiteAccount(secret 引用只给布尔) */
export function accountToApi(row: SiteAccountRow): Record<string, unknown> {
const { totp_secret_ref, session_secret_ref, passkey_secret_ref, ...rest } = row;
return {
...toApi<Record<string, unknown>>(rest),
hasTotp: !!totp_secret_ref,
hasSession: !!session_secret_ref,
hasPasskey: !!passkey_secret_ref || (row.auth_kind === "passkey" && !!row.secret_ref),
};
}
/** 多账号取最差;无启用账号 = unconfigured */
const STATUS_RANK: Record<SiteAccountStatus, number> = { active: 0, unknown: 1, expired: 2, invalid: 3, challenge: 4 };
export function overallAccountStatus(rows: Pick<SiteAccountRow, "enabled" | "status">[]) {
const enabled = rows.filter((r) => r.enabled);
if (!enabled.length) return "unconfigured" as const;
return enabled.reduce<SiteAccountStatus>(
(worst, r) => (STATUS_RANK[r.status] > STATUS_RANK[worst] ? r.status : worst),
"active",
);
}
/** 本地日期(按进程时区)YYYY-MM-DD */
function localDay(d: Date): string {
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
}
export function userDataOf(info: SiteUserInfo): SiteAccountUserData {
const upload = Math.max(0, info.upload || 0);
const download = Math.max(0, info.download || 0);
const ratio = info.ratio ? info.ratio : download > 0 ? Math.round((upload / download) * 1000) / 1000 : null;
const int = (n: number) => Math.max(0, Math.round(n || 0));
return {
schema: info.schema ?? null,
userId: info.userId ?? null,
username: info.username ?? null,
userLevel: info.userLevel ?? null,
vip: info.vip ?? null,
joinAt: info.joinAt ?? null,
upload,
download,
ratio,
bonus: info.bonus ? info.bonus : null,
seeding: int(info.seeding),
seedingSize: Math.max(0, info.seedingSize || 0),
leeching: int(info.leeching),
leechingSize: Math.max(0, info.leechingSize || 0),
messageUnread: int(info.messageUnread),
};
}
// ── 凭证写入 ──
const PURPOSE: Record<string, SecretPurpose> = {
cookie: "site_cookie",
passkey: "site_passkey",
api_key: "site_api_key",
password: "site_password",
};
type NormalizedCredentials =
| {
kind: "password";
label: string;
username: string;
password: string;
totpSecret?: string;
saveCookieOnly?: boolean;
userAgent?: string;
enabled?: boolean;
}
| { kind: "cookie"; label: string; cookie: string; userAgent?: string; enabled?: boolean }
| { kind: "api_key"; label: string; apiKey: string; enabled?: boolean }
| { kind: "passkey"; label: string; passkey: string; enabled?: boolean };
/** 旧格式 {authKind, credential, username?} → 新格式 */
export function normalizeCredentials(body: SiteCredentialsPut): NormalizedCredentials {
if ("kind" in body) return body as NormalizedCredentials;
const label = body.label;
const enabled = body.enabled;
const opt = enabled === undefined ? {} : { enabled };
switch (body.authKind) {
case "password":
return { kind: "password", label, username: body.username || label, password: body.credential, ...opt };
case "cookie":
return { kind: "cookie", label, cookie: body.credential, ...opt };
case "api_key":
return { kind: "api_key", label, apiKey: body.credential, ...opt };
default:
return { kind: "passkey", label, passkey: body.credential, ...opt };
}
}
/**
* PUT /sites/{id}/credentials:按 (site, label) 建立或更新账号。
* 主凭证(password / cookie / api_key)换掉旧 secret、状态回 unknown、清会话 Cookie、关闭 site-auth / site-login 待处理;
* passkey 只附挂到账号(不改登录状态)。
*/
export async function setSiteCredentials(
rt: SourcesRuntime,
site: Pick<SiteRow, "id" | "health_status">,
input: SiteCredentialsPut,
actor: Actor,
): Promise<SiteCredentialsResult> {
const { db, secrets } = rt.ctx;
const body = normalizeCredentials(input);
const label = body.label;
const created: string[] = [];
const mk = async (purpose: SecretPurpose, plaintext: string, suffix = "") => {
const ref = await secrets.create({ purpose, plaintext, label: `site_account:${label}${suffix}` });
created.push(ref);
return ref;
};
const obsolete: string[] = [];
let result: SiteCredentialsResult;
try {
if (body.kind === "passkey") {
const ref = await mk("site_passkey", body.passkey, ":passkey");
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
.selectAll()
.where("site_id", "=", site.id)
.where("label", "=", label)
.forUpdate()
.executeTakeFirst();
if (cur) {
if (cur.auth_kind === "passkey") {
if (cur.secret_ref) obsolete.push(cur.secret_ref);
await trx
.updateTable("site_account")
.set({ secret_ref: ref, ...(body.enabled !== undefined ? { enabled: body.enabled } : {}) })
.where("id", "=", cur.id)
.execute();
} else {
if (cur.passkey_secret_ref) obsolete.push(cur.passkey_secret_ref);
await trx
.updateTable("site_account")
.set({ passkey_secret_ref: ref, ...(body.enabled !== undefined ? { enabled: body.enabled } : {}) })
.where("id", "=", cur.id)
.execute();
}
return {
siteAccountId: cur.id,
siteId: site.id,
label,
authKind: cur.auth_kind,
secretRef: ref,
created: false,
};
}
const r = await trx
.insertInto("site_account")
.values({
site_id: site.id,
label,
auth_kind: "passkey",
secret_ref: ref,
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
})
.returning("id")
.executeTakeFirstOrThrow();
return { siteAccountId: r.id, siteId: site.id, label, authKind: "passkey", secretRef: ref, created: true };
});
} else {
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
const ref = await mk(PURPOSE[body.kind]!, plaintext);
const totpRef =
body.kind === "password" && body.totpSecret ? await mk("site_password", body.totpSecret, ":totp") : null;
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
.selectAll()
.where("site_id", "=", site.id)
.where("label", "=", label)
.forUpdate()
.executeTakeFirst();
const userInfo = {
...((cur?.user_info as Record<string, unknown> | undefined) ?? {}),
...(body.kind === "password" ? { username: body.username, saveCookieOnly: !!body.saveCookieOnly } : {}),
};
if (body.kind !== "password") delete userInfo.saveCookieOnly;
const values = {
auth_kind: body.kind,
secret_ref: ref,
status: "unknown" as const,
last_error: null,
session_secret_ref: null,
totp_secret_ref: totpRef,
login_name: body.kind === "password" ? body.username : null,
user_agent: "userAgent" in body ? (body.userAgent ?? null) : null,
user_info: jsonb(userInfo),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
};
let id: string;
let isNew = false;
if (cur) {
for (const r of [
cur.auth_kind === "passkey" ? null : cur.secret_ref,
cur.session_secret_ref,
cur.totp_secret_ref,
])
if (r) obsolete.push(r);
// 原来是 passkey 型账号:passkey 转为附挂
const passkey = cur.auth_kind === "passkey" && cur.secret_ref ? { passkey_secret_ref: cur.secret_ref } : {};
await trx
.updateTable("site_account")
.set({ ...values, ...passkey })
.where("id", "=", cur.id)
.execute();
id = cur.id;
} else {
const r = await trx
.insertInto("site_account")
.values({ site_id: site.id, label, ...values })
.returning("id")
.executeTakeFirstOrThrow();
id = r.id;
isNew = true;
}
await trx
.updateTable("site")
.set({ health_status: "unknown" })
.where("id", "=", site.id)
.where("health_status", "=", "failing")
.execute();
const by = `${actor.type}:${actor.id ?? ""}`;
await closeDecisions(rt.ctx, trx, `site-auth:${site.id}`, "resolved", { by: "credential_updated" }, by);
await closeDecisions(rt.ctx, trx, `site-login:${id}`, "resolved", { by: "credential_updated" }, by);
return { siteAccountId: id, siteId: site.id, label, authKind: body.kind, secretRef: ref, created: isNew };
});
}
} catch (e) {
for (const r of created) await secrets.remove(r).catch(() => {});
throw e;
}
for (const r of obsolete) await secrets.remove(r).catch(() => {});
await audit(
db,
actor,
"site_account.credentials_set",
{ type: "site_account", id: result.siteAccountId },
{ siteId: site.id, label, authKind: body.kind, created: result.created },
);
return result;
}
// ── 刷新登录 / 用户数据 ──
type UserDataOutcome =
| { kind: "ok"; info: SiteUserInfo }
| { kind: "auth"; message: string }
| { kind: "network"; message: string }
| { kind: "challenge"; message: string }
| { kind: "no_parser" };
const FAIL_STATUS: Partial<Record<SiteLoginFailure, SiteAccountStatus>> = {
captcha: "challenge",
two_factor: "challenge",
challenge: "challenge",
bad_credentials: "invalid",
account_blocked: "invalid",
auth_expired: "expired",
unknown: "expired",
};
export interface RefreshOptions {
/** login:重新登录 / 校验(手动刷新登录);userData:定时刷新用户数据,会话失效时账密账号自动重登 */
mode?: "login" | "userData";
actor?: Actor;
signal?: AbortSignal;
}
export async function refreshSiteAccount(
rt: SourcesRuntime,
accountId: string,
opts: RefreshOptions = {},
): Promise<SiteLoginResult> {
const { db, secrets } = rt.ctx;
const actor = opts.actor ?? SYSTEM;
const acc = await db.selectFrom("site_account").selectAll().where("id", "=", accountId).executeTakeFirst();
if (!acc) throw new Error("站点账号不存在");
const site = await db.selectFrom("site").selectAll().where("id", "=", acc.site_id).executeTakeFirstOrThrow();
const base = { siteId: site.id, accountId: acc.id };
const unchanged = (reason: SiteLoginFailure, message: string): SiteLoginResult => ({
...base,
ok: false,
status: acc.status,
reason,
message,
userData: null,
decisionId: null,
});
const def = getSiteDefinition(site.key);
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;
try {
rt.siteAdapter(site);
limiter = rt.limiterOf(site.id);
} catch {
// 无适配器:不限流(仅 generic_rss,前面已返回)
}
const fetch = rt.siteFetch(site, defaults);
const umt = def.umt as { kind: string; login?: Parameters<typeof loginNexusPhp>[0]["login"]; loginSuccess?: string };
const profile = (def.umt as { profile?: Record<string, string> }).profile;
const vipLevels = (def.umt as { vipLevels?: string[] }).vipLevels;
const apiUrl = def.umt.kind === "mteam" ? ((site.config as { apiBaseUrl?: string })?.apiBaseUrl ?? null) : null;
const fetchUserData = async (cred: { cookie?: string | null; apikey?: string | null }): Promise<UserDataOutcome> => {
const info = await refreshSiteUserData({
name: site.name,
url: site.base_url,
schema: (def as { schema?: string }).schema ?? null,
public: true,
cookie: cred.cookie ?? null,
apikey: cred.apikey ?? null,
ua,
fetch,
...(limiter ? { limiter } : {}),
...(apiUrl ? { apiUrl } : {}),
fetchMessages: false,
maxSeedingPages: defaults.userDataMaxSeedingPages,
vipCss: profile?.vip_css ?? null,
vipLevels: vipLevels ?? null,
timeoutMs: 20_000,
...(opts.signal ? { signal: opts.signal } : {}),
});
if (!info) return { kind: "no_parser" };
if (info.userId && !info.errMsg) return { kind: "ok", info };
const errs = ((info.raw as { http_errors?: string[] }).http_errors ?? []).filter(Boolean);
if (errs.some((e) => /Cloudflare/i.test(e))) return { kind: "challenge", message: "站点返回 Cloudflare 挑战页" };
// 连接失败 / 超时 / 5xx 视为网络问题;4xx 与解析失败按登录态失效处理
const net = errs.find((e) => !/HTTP 4\d\d|JSON 解析失败/.test(e));
if (net) return { kind: "network", message: net.slice(0, 200) };
return { kind: "auth", message: info.errMsg ?? "未检测到登录态" };
};
const viaHealth = async (): Promise<UserDataOutcome> => {
const h = await rt.siteAdapter(site).healthCheck(opts.signal ? { signal: opts.signal } : {});
if (h.ok) return { kind: "no_parser" };
if (h.status.startsWith("auth_")) return { kind: "auth", message: h.message ?? "未检测到登录态" };
return { kind: "network", message: h.message ?? h.status };
};
const fail = (reason: SiteLoginFailure, message: string) => markFailure(rt, acc, site, reason, message, actor);
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.secret_ref) return unchanged("unsupported", "账号没有凭证");
if (acc.auth_kind === "password") {
if (umt.kind !== "nexusphp" || !umt.login) {
return unchanged("unsupported", "该站点不支持账号密码登录。改用 Cookie 或 API Key");
}
if (opts.mode === "userData" && acc.session_secret_ref) {
const r = await fetchUserData({ cookie: await secrets.resolve(acc.session_secret_ref) });
if (r.kind === "ok") return await succeed(r.info);
if (r.kind === "network") return await fail("network", r.message);
// 会话失效:下面重登一次
}
const password = await secrets.resolve(acc.secret_ref);
const totpSecret = acc.totp_secret_ref ? await secrets.resolve(acc.totp_secret_ref) : null;
const login = await loginNexusPhp({
baseUrl: site.base_url,
login: umt.login,
...(umt.loginSuccess ? { loginSuccess: umt.loginSuccess } : {}),
username: acc.login_name ?? (acc.user_info as { username?: string })?.username ?? acc.label,
password,
totpSecret,
userAgent: ua,
fetch,
...(limiter ? { limiter } : {}),
...(opts.signal ? { signal: opts.signal } : {}),
now: rt.now,
});
await storeSession(rt, acc, login.cookie);
const r = await fetchUserData({ cookie: login.cookie });
if (r.kind === "ok") return await succeed(r.info);
return await succeed(
null,
`登录成功;用户数据未取到:${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 === "network") return await fail("network", r.message);
if (r.kind === "challenge") return await fail("challenge", r.message);
return await fail(
"auth_expired",
acc.auth_kind === "api_key"
? "API Key 无效。更新 API Key 后重新刷新登录"
: "Cookie 已失效。更新 Cookie 后重新刷新登录",
);
} catch (e) {
if (e instanceof SiteLoginError) return await fail(e.reason, e.message);
throw e;
}
}
/** 账密登录成功:会话 Cookie 存 secret;"只换 Cookie"时凭证类型落为 cookie,密码与 TOTP 丢弃 */
async function storeSession(rt: SourcesRuntime, acc: SiteAccountRow, cookie: string) {
const { db, secrets } = rt.ctx;
const cookieOnly = (acc.user_info as { saveCookieOnly?: boolean })?.saveCookieOnly === true;
if (cookieOnly) {
const ref = await secrets.create({ purpose: "site_cookie", plaintext: cookie, label: `site_account:${acc.label}` });
const info = { ...(acc.user_info as Record<string, unknown>) };
delete info.saveCookieOnly;
await db
.updateTable("site_account")
.set({
auth_kind: "cookie",
secret_ref: ref,
session_secret_ref: null,
totp_secret_ref: null,
user_info: jsonb(info),
})
.where("id", "=", acc.id)
.execute();
for (const r of [acc.secret_ref, acc.session_secret_ref, acc.totp_secret_ref]) {
if (r) await secrets.remove(r).catch(() => {});
}
acc.auth_kind = "cookie";
acc.secret_ref = ref;
acc.session_secret_ref = null;
acc.totp_secret_ref = null;
return;
}
if (acc.session_secret_ref) {
await secrets.replace(acc.session_secret_ref, cookie);
return;
}
const ref = await secrets.create({
purpose: "site_cookie",
plaintext: cookie,
label: `site_account:${acc.label}:session`,
});
await db.updateTable("site_account").set({ session_secret_ref: ref }).where("id", "=", acc.id).execute();
acc.session_secret_ref = ref;
}
async function markSuccess(
rt: SourcesRuntime,
acc: SiteAccountRow,
site: SiteRow,
info: SiteUserInfo | null,
actor: Actor,
note?: string,
): Promise<SiteLoginResult> {
const now = rt.now();
const data = info ? userDataOf(info) : null;
await rt.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("site_account")
.set({
status: "active",
last_login_at: now,
last_verified_at: now,
last_error: null,
...(data ? { user_data: jsonb(data), user_data_at: now } : {}),
})
.where("id", "=", acc.id)
.execute();
if (info && data) await upsertUserDataDay(trx, acc.id, now, info, data);
await trx
.updateTable("site")
.set({ health_status: "unknown" })
.where("id", "=", site.id)
.where("health_status", "=", "failing")
.execute();
const by = `${actor.type}:${actor.id ?? ""}`;
await closeDecisions(rt.ctx, trx, `site-login:${acc.id}`, "resolved", { by: "login_refreshed" }, by);
await closeDecisions(rt.ctx, trx, `site-auth:${site.id}`, "resolved", { by: "login_refreshed" }, by);
await audit(
trx,
actor,
"site_account.login_refreshed",
{ type: "site_account", id: acc.id },
{
siteId: site.id,
ok: true,
userData: !!data,
},
);
});
return {
siteId: site.id,
accountId: acc.id,
ok: true,
status: "active",
reason: null,
message: note ?? null,
userData: data,
decisionId: null,
};
}
async function upsertUserDataDay(trx: Exec, accountId: string, now: Date, info: SiteUserInfo, d: SiteAccountUserData) {
const {
messageUnreadContents: _m,
seedingInfo: _s,
raw: _r,
...rest
} = info as SiteUserInfo & { messageUnreadContents?: unknown; seedingInfo?: unknown };
await sql`
insert into site_user_data (site_account_id, day, upload, download, ratio, bonus, seeding, seeding_size, leeching,
user_level, vip, message_unread, join_at, raw, captured_at)
values (${accountId}, ${localDay(now)}, ${Math.round(d.upload)}, ${Math.round(d.download)}, ${d.ratio}, ${d.bonus},
${d.seeding}, ${Math.round(d.seedingSize)}, ${d.leeching}, ${d.userLevel}, ${d.vip}, ${d.messageUnread},
${d.joinAt}, ${jsonb(rest)}::jsonb, ${now})
on conflict (site_account_id, day) do update set
upload = excluded.upload, download = excluded.download, ratio = excluded.ratio, bonus = excluded.bonus,
seeding = excluded.seeding, seeding_size = excluded.seeding_size, leeching = excluded.leeching,
user_level = excluded.user_level, vip = excluded.vip, message_unread = excluded.message_unread,
join_at = excluded.join_at, raw = excluded.raw, captured_at = excluded.captured_at`.execute(trx);
}
const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = {
challenge: {
title: (s) => `站点「${s}」登录需要人工验证`,
summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试",
},
invalid: {
title: (s) => `站点「${s}」登录失败`,
summary: "用户名或密码错误,或账号被封禁 / 未确认。更新凭证后重新刷新登录",
},
expired: {
title: (s) => `站点「${s}」登录失效`,
summary: "凭证已失效。更新凭证后重新刷新登录",
},
};
async function markFailure(
rt: SourcesRuntime,
acc: SiteAccountRow,
site: SiteRow,
reason: SiteLoginFailure,
message: string,
actor: Actor,
): Promise<SiteLoginResult> {
const now = rt.now();
const lastError = { reason, message: message.slice(0, 300), at: now.toISOString() };
const status =
reason === "network" || reason === "unsupported"
? null
: reason === "auth_expired" && acc.auth_kind === "api_key"
? "invalid"
: (FAIL_STATUS[reason] ?? "expired");
let decisionId: string | null = null;
await rt.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("site_account")
.set({ last_error: jsonb(lastError), ...(status ? { status, last_verified_at: now } : {}) })
.where("id", "=", acc.id)
.execute();
if (status) {
const text = DECISION_TEXT[status]!;
const summary =
reason === "challenge"
? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录"
: status === "expired" ||
reason === "account_blocked" ||
reason === "bad_credentials" ||
reason === "auth_expired"
? message
: text.summary;
const options: DecisionOption[] = [
{
key: "use_cookie",
label: "改用 Cookie",
input: { siteId: site.id, accountId: acc.id, credential: "cookie" },
},
...(status === "challenge" ? [{ key: "retry", label: "重试登录", input: { accountId: acc.id } }] : []),
{ key: "dismiss", label: "忽略" },
];
const d = await openDecision(rt.ctx, trx, {
kind: "source_unreachable",
severity: "warning",
title: text.title(site.name),
summary,
subjectType: "site_account",
subjectId: acc.id,
dedupeKey: `site-login:${acc.id}`,
evidence: { siteId: site.id, accountId: acc.id, reason, authKind: acc.auth_kind },
options,
});
decisionId = d.id;
}
await audit(
trx,
actor,
"site_account.login_refreshed",
{ type: "site_account", id: acc.id },
{
siteId: site.id,
ok: false,
reason,
},
);
});
return {
siteId: site.id,
accountId: acc.id,
ok: false,
status: status ?? acc.status,
reason,
message,
userData: null,
decisionId,
};
}
/** 请求遇 auth_expired:有密码的账号 10 分钟内最多自动重登一次 */
export async function enqueueRelogin(rt: SourcesRuntime, siteId: string): Promise<number> {
const rows = await rt.ctx.db
.selectFrom("site_account")
.select(["id"])
.where("site_id", "=", siteId)
.where("enabled", "=", true)
.where("auth_kind", "=", "password")
.where("secret_ref", "is not", null)
.execute();
const slot = Math.floor(rt.now().getTime() / 600_000);
for (const r of rows) {
await rt.ctx.engine.enqueue(
"refreshSiteAccount",
{ accountId: r.id, mode: "login" },
{ idempotencyKey: `refreshSiteAccount:relogin:${r.id}:${slot}`, traceId: randomUUID() },
);
}
return rows.length;
}
// ── 健康检测 ──
const HEALTH_OF: Record<string, SiteHealth> = {
ok: "healthy",
auth_missing: "failing",
auth_expired: "failing",
auth_invalid: "failing",
rate_limited: "degraded",
unreachable: "degraded",
site_maintenance: "degraded",
parse_error: "degraded",
};
/** 首页连通 + 登录态(≤ 20 s,走站点限流);写 site.last_health* 与 health_status(已关闭的站点保持 disabled) */
export async function checkSiteHealth(
rt: SourcesRuntime,
site: SiteRow,
actor: Actor = SYSTEM,
): Promise<SiteHealthResult> {
const now = rt.now();
let ok = false;
let status = "unreachable";
let latencyMs: number | null = null;
let message: string | null = null;
const def = getSiteDefinition(site.key);
if (!def) {
message = "站点没有适配器定义,无法检测";
status = "unsupported";
} else {
await rt.siteDefaults();
const h = await rt.siteAdapter(site).healthCheck({ signal: AbortSignal.timeout(20_000) });
ok = h.ok;
status = h.status;
latencyMs = h.latencyMs;
message = h.message ?? null;
}
const loggedIn = ok ? true : status.startsWith("auth_") ? false : null;
const healthStatus: SiteHealth = !site.enabled
? "disabled"
: def
? (HEALTH_OF[status] ?? "degraded")
: site.health_status;
const last = { ok, status, latencyMs, message };
await rt.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("site")
.set({ last_health: jsonb(last), last_health_at: now, health_status: healthStatus })
.where("id", "=", site.id)
.execute();
// 首页显示已登录:顺手记上次登录
if (ok) {
await trx
.updateTable("site_account")
.set({ last_login_at: now })
.where("site_id", "=", site.id)
.where("enabled", "=", true)
.where("status", "=", "active")
.execute();
}
if (actor.type !== "system") {
await audit(trx, actor, "site.health_checked", { type: "site", id: site.id }, { ok, status });
}
});
return { siteId: site.id, checkedAt: now.toISOString(), loggedIn, healthStatus, ...last };
}
// ── 用户数据历史 ──
export async function userDataHistory(
rt: SourcesRuntime,
siteId: string,
q: { accountId?: string | undefined; days: number },
): Promise<SiteUserDataPoint[]> {
const since = localDay(new Date(rt.now().getTime() - (q.days - 1) * 86_400_000));
let query = rt.ctx.db
.selectFrom("site_user_data as d")
.innerJoin("site_account as a", "a.id", "d.site_account_id")
.selectAll("d")
.where("a.site_id", "=", siteId)
.where("d.day", ">=", since);
if (q.accountId) query = query.where("d.site_account_id", "=", q.accountId);
const rows = await query.orderBy("d.day").orderBy("d.site_account_id").execute();
return rows.map((r) => ({
siteAccountId: r.site_account_id,
day: r.day,
upload: r.upload,
download: r.download,
ratio: r.ratio,
bonus: r.bonus,
seeding: r.seeding,
seedingSize: r.seeding_size,
leeching: r.leeching,
userLevel: r.user_level,
vip: r.vip,
messageUnread: r.message_unread,
capturedAt: r.captured_at.toISOString(),
}));
}
// ── 删除 ──
/**
* 删除站点:停用(资源标 site_disabled、Feed 停用)→ 删账号与全部凭证 → 软删除站点行(key 改名,可再次登记同一定义)。
* source_release 外键 restrict,资源和下载任务都保留。
*/
export async function deleteSite(rt: SourcesRuntime, site: SiteRow, actor: Actor): Promise<void> {
const { db, secrets } = rt.ctx;
const refs: string[] = [];
await db.transaction().execute(async (trx) => {
const accounts = await trx.selectFrom("site_account").selectAll().where("site_id", "=", site.id).execute();
for (const a of accounts) {
for (const r of [a.secret_ref, a.session_secret_ref, a.totp_secret_ref, a.passkey_secret_ref])
if (r) refs.push(r);
}
await trx
.updateTable("source_release")
.set({ availability: "site_disabled" })
.where("site_id", "=", site.id)
.where("availability", "=", "active")
.execute();
await trx
.updateTable("feed_source")
.set({ enabled: false, health_status: "disabled", site_account_id: null })
.where("site_id", "=", site.id)
.execute();
const by = `${actor.type}:${actor.id ?? ""}`;
for (const a of accounts) {
await closeDecisions(rt.ctx, trx, `site-login:${a.id}`, "dismissed", { by: "site_deleted" }, by);
}
await closeDecisions(rt.ctx, trx, `site-auth:${site.id}`, "dismissed", { by: "site_deleted" }, by);
await trx.deleteFrom("site_account").where("site_id", "=", site.id).execute();
await trx
.updateTable("site")
.set({
enabled: false,
health_status: "disabled",
snapshot_enabled: false,
deleted_at: rt.now(),
key: `${site.key}-deleted-${Date.now().toString(36)}`,
})
.where("id", "=", site.id)
.execute();
await audit(
trx,
actor,
"site.deleted",
{ type: "site", id: site.id },
{ key: site.key, accounts: accounts.length },
);
});
for (const r of refs) await secrets.remove(r).catch(() => {});
}
export async function deleteSiteAccount(rt: SourcesRuntime, acc: SiteAccountRow, actor: Actor): Promise<void> {
const { db, secrets } = rt.ctx;
await db.transaction().execute(async (trx) => {
await closeDecisions(
rt.ctx,
trx,
`site-login:${acc.id}`,
"dismissed",
{ by: "account_deleted" },
`${actor.type}:${actor.id ?? ""}`,
);
await trx.deleteFrom("site_account").where("id", "=", acc.id).execute();
await audit(trx, actor, "site_account.deleted", { type: "site_account", id: acc.id }, { siteId: acc.site_id });
});
for (const r of [acc.secret_ref, acc.session_secret_ref, acc.totp_secret_ref, acc.passkey_secret_ref]) {
if (r) await secrets.remove(r).catch(() => {});
}
}
// ── 定时任务 ──
/** sites.userData:启用站点里登录状态正常的账号逐个入队刷新用户数据 */
export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promise<number> {
const rows = await rt.ctx.db
.selectFrom("site_account as a")
.innerJoin("site as s", "s.id", "a.site_id")
.select(["a.id"])
.where("s.enabled", "=", true)
.where("s.deleted_at", "is", null)
.where("a.enabled", "=", true)
.where("a.status", "=", "active")
.where("a.auth_kind", "!=", "passkey")
.execute();
for (const r of rows) {
await rt.ctx.engine.enqueue(
"refreshSiteAccount",
{ accountId: r.id, mode: "userData" },
{ idempotencyKey: `refreshSiteAccount:userData:${r.id}:${slot}`, traceId: randomUUID() },
);
}
return rows.length;
}
/** sites.healthCheck:启用站点逐个检测(同步,走站点限流) */
export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
const sites = await rt.ctx.db
.selectFrom("site")
.selectAll()
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.where("adapter", "!=", "generic_rss")
.execute();
let n = 0;
for (const s of sites) {
try {
await checkSiteHealth(rt, s);
n++;
} catch (err) {
rt.ctx.logger.warn({ siteId: s.id, err: { message: (err as Error).message } }, "站点健康检测失败");
}
}
return n;
}
@@ -5,6 +5,7 @@ import { parseRelease } from "@umt/domain";
import { siteReleaseToSourceRelease } from "@umt/mapping";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { enqueueRelogin } from "./account.ts";
import { type Exec, openDecision } from "./common.ts";
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
@@ -92,6 +93,8 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
evidence: { siteId: site.id, kind: e.kind, httpStatus: e.httpStatus ?? null },
options: [{ key: "reset", label: "已更新凭证,恢复访问" }],
});
// 有密码的账号:自动重登一次(同账号 10 分钟内最多一次),成功后关闭本待处理并复位站点健康
await enqueueRelogin(rt, site.id);
}
export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Promise<SnapshotResult> {
@@ -1,6 +1,7 @@
import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import type { SourcesRuntime } from "./runtime.ts";
import { refreshSiteAccount } from "./service/account.ts";
import { ingestFeed } from "./service/feeds.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { resolveRelease } from "./service/resolve.ts";
@@ -76,6 +77,23 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
snapshotSite(rt(), { siteId: t.input.siteId, kind: t.input.kind, maxPages: t.input.maxPages }),
),
}),
defineTask({
name: "refreshSiteAccount",
version: 1,
inputSchema: z.object({ accountId: id, mode: z.enum(["login", "userData"]).default("login") }),
// 网络失败只记 last_error 不抛错;不自动重试(站点登录频繁重试有封号风险)
retryPolicy: { maximumAttempts: 1 },
handler: (t) =>
t.step.run("refresh", async () => {
const exists = await rt()
.ctx.db.selectFrom("site_account")
.select("id")
.where("id", "=", t.input.accountId)
.executeTakeFirst();
if (!exists) return { skipped: "not_found" };
return refreshSiteAccount(rt(), t.input.accountId, { mode: t.input.mode });
}),
}),
);
}