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:
1 parent
d6fca25679
commit
05a7706dde
15 files changed
+1602
-179
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user