diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index d9edf508..5b54ba28 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -395,6 +395,41 @@ export const Api = { // ── 来源 ── sites: { ...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R), + list: ep({ + method: "get", + path: "/sites", + scope: "read", + summary: "站点列表(include=accounts 附带账号与整体账号状态)", + query: sources.SiteListQuery, + response: cursorPage(sources.SiteListItem), + }), + remove: del("/sites/{id}", "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)"), + healthCheck: ep({ + method: "post", + path: "/sites/{id}/health-check", + scope: "admin", + summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列", + params: IdParam, + response: sources.SiteHealthResult, + }), + refreshLogin: ep({ + method: "post", + path: "/sites/{id}/refresh-login", + scope: "admin", + summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据", + params: IdParam, + body: sources.SiteRefreshLoginInput, + response: sources.SiteRefreshLoginAccepted, + }), + userData: ep({ + method: "get", + path: "/sites/{id}/user-data", + scope: "read", + summary: "站点账号用户数据按天历史", + params: IdParam, + query: sources.SiteUserDataQuery, + response: z.object({ items: z.array(sources.SiteUserDataPoint) }), + }), enable: ep({ method: "post", path: "/sites/{id}/enable", @@ -429,13 +464,11 @@ export const Api = { query: CursorQuery, response: cursorPage(sources.SiteSnapshot), }), - /** 兼容别名:规范接口是 /site-accounts 资源 CRUD;这里按 (site,label) 新建或更新 site_account 的凭证 */ credentials: ep({ method: "put", path: "/sites/{id}/credentials", scope: "admin", - summary: - "兼容别名:按 (站点, label) 新建或更新 site_account 凭证(→ secret_ref,不回显);新客户端请用 POST/PATCH /site-accounts", + summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)", params: IdParam, body: instances.SiteCredentialsPut, response: instances.SiteCredentialsResult, @@ -471,17 +504,20 @@ export const Api = { response: sources.WorkbenchReleaseDetail, }), }, - siteAccounts: crud( - "/site-accounts", - "站点账号", - { - row: sources.SiteAccount, - create: sources.SiteAccountCreate, - update: sources.SiteAccountUpdate, - filters: { siteId: Id.optional() }, - }, - R, - ), + siteAccounts: { + ...crud( + "/site-accounts", + "站点账号", + { + row: sources.SiteAccount, + create: sources.SiteAccountCreate, + update: sources.SiteAccountUpdate, + filters: { siteId: Id.optional() }, + }, + R, + ), + remove: del("/site-accounts/{id}", "删除站点账号与其凭证"), + }, feedSources: { ...crud( "/feed-sources", diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts index 9ab3680e..192eb75c 100644 --- a/packages/contracts/src/instances.ts +++ b/packages/contracts/src/instances.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts"; +import { SiteCredentialsBody } from "./sources.ts"; // 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5) // 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。 @@ -170,24 +171,28 @@ export const InstanceTestResult = z.object({ export type InstanceTestResult = z.infer; /** - * PUT /sites/{id}/credentials:站点账号凭证录入(cookie / passkey / apiKey / 密码)。 - * 按 (siteId, label) 建立或更新 site_account;明文加密为 secret 后丢弃,响应不回显。 + * @deprecated 旧格式 {authKind, credential, username?};新客户端用 SiteCredentialsBody({kind, ...})。 + * 服务端两种都接受,旧格式按 kind 转换。 */ -export const SiteCredentialsPut = z.object({ +export const SiteCredentialsLegacyPut = z.object({ label: z.string().min(1).max(80).default("default"), authKind: z.enum(["cookie", "passkey", "api_key", "password"]), /** 只写明文 */ credential: Credential, - /** authKind=password 时的登录名(存入 user_info.username) */ + /** authKind=password 时的登录名 */ username: z.string().max(200).optional(), enabled: z.boolean().optional(), }); +export type SiteCredentialsLegacyPut = z.infer; +/** PUT /sites/{id}/credentials 请求体:新格式(按 kind 判别)或旧格式 */ +export const SiteCredentialsPut = z.union([SiteCredentialsBody, SiteCredentialsLegacyPut]); export type SiteCredentialsPut = z.infer; export const SiteCredentialsResult = z.object({ siteAccountId: Id, siteId: Id, label: z.string(), authKind: z.string(), + /** 本次写入的 secret 引用(passkey 请求时为 passkey 的引用) */ secretRef: Id, /** true:新建账号;false:替换已有凭证 */ created: z.boolean(), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3ebda3ab..5e1cb7e0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { Id, Timestamp } from "./common.ts"; +import { SiteRateLimit } from "./sources.ts"; // 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。 // schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值); @@ -152,6 +153,29 @@ export const MetadataSettings = z.object({ }); export type MetadataSettings = z.infer; +/** 固定桌面 Chrome UA(账号未设 UA 时使用) */ +export const DEFAULT_SITE_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"; + +/** 站点默认(site-center §8):站点行的限流 / 快照周期为空时继承;站点和凭证在站点页管理 */ +export const SitesSettings = z.object({ + /** 站点 rate_limit 为空时使用 */ + defaultRateLimit: SiteRateLimit, + /** 站点未设快照周期时使用(秒) */ + defaultSnapshotIntervalSec: z + .number() + .int() + .min(300) + .max(7 * 24 * 3600), + /** 账号未设 UA 时使用 */ + userAgent: z.string().min(1).max(500), + /** 站点开"使用代理"时生效;null = 不用代理 */ + proxyUrl: z.url().nullable(), + /** 用户数据做种分页上限 */ + userDataMaxSeedingPages: z.number().int().min(1).max(50), +}); +export type SitesSettings = z.infer; + /** 每日预算与并发(REQ §6.4、§10.5) */ export const BudgetSettings = z.object({ maxNewTitlesPerDay: z.number().int().min(0), @@ -171,6 +195,7 @@ export const GlobalSettings = z.object({ budget: BudgetSettings, agent: AgentRuntimeSettings, metadata: MetadataSettings, + sites: SitesSettings, }); export type GlobalSettings = z.infer; @@ -219,6 +244,13 @@ export const DEFAULT_SETTINGS: GlobalSettings = { sessionDir: null, }, metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null }, + sites: { + defaultRateLimit: { requestsPerMinute: 20, concurrency: 1 }, + defaultSnapshotIntervalSec: 3600, + userAgent: DEFAULT_SITE_USER_AGENT, + proxyUrl: null, + userDataMaxSeedingPages: 10, + }, }; /** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */ @@ -254,6 +286,7 @@ export const GlobalSettingsUpdate = z.object({ tmdbApiKey: z.string().min(1).max(4096).nullable().optional(), }) .optional(), + sites: SitesSettings.partial().optional(), }); export type GlobalSettingsUpdate = z.infer; @@ -270,6 +303,7 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda budget: { ...current.budget, ...patch.budget }, agent: { ...current.agent, ...patch.agent }, metadata: { ...current.metadata, ...metadata }, + sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites }, }; } @@ -368,15 +402,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ }, { id: "sites", - label: "站点与账号", - description: "站点启停、快照周期、限流、账号凭证", + label: "站点默认", + description: "新站点的限流、快照周期、UA 和代理;站点和凭证在站点页管理", legacyTabs: [{ tab: "site-credentials" }], - anchors: [ - { id: "sites", label: "站点" }, - { id: "accounts", label: "站点账号" }, + anchors: [{ id: "defaults", label: "站点默认" }], + fields: [ + "sites.defaultRateLimit", + "sites.defaultSnapshotIntervalSec", + "sites.userAgent", + "sites.proxyUrl", + "sites.userDataMaxSeedingPages", ], - fields: [], - resources: ["sites", "site-accounts"], + resources: [], }, { id: "downloaders", diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 49a8dab6..a3beda8c 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; +import { CursorQuery, DateOnly, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; // 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4) // 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。 @@ -20,6 +20,16 @@ export const SiteRateLimit = z.object({ }); export type SiteRateLimit = z.infer; +/** 站点行 last_health:最近一次健康检测结果 */ +export const SiteLastHealth = z.object({ + ok: z.boolean(), + /** ok / auth_missing / auth_expired / auth_invalid / rate_limited / unreachable / site_maintenance / parse_error */ + status: z.string(), + latencyMs: z.number().int().nonnegative().nullable(), + message: z.string().nullable(), +}); +export type SiteLastHealth = z.infer; + export const Site = EntityBase.extend({ key: z.string().regex(/^[a-z][a-z0-9_-]*$/), name: z.string().min(1), @@ -35,6 +45,11 @@ export const Site = EntityBase.extend({ snapshotIntervalSec: z.number().int().positive().nullable(), lastSnapshotAt: Timestamp.nullable(), snapshotCursor: JsonObject, + /** 最近一次健康检测(手动或定时 sites.healthCheck);从未检测为 null */ + lastHealth: SiteLastHealth.nullable(), + lastHealthAt: Timestamp.nullable(), + /** 使用全局代理(settings.sites.proxyUrl) */ + proxy: z.boolean(), }); export type Site = z.infer; export const SiteCreate = Site.pick({ @@ -47,34 +62,235 @@ export const SiteCreate = Site.pick({ config: true, snapshotEnabled: true, snapshotIntervalSec: true, -}).partial({ enabled: true, rateLimit: true, config: true, snapshotEnabled: true, snapshotIntervalSec: true }); + proxy: true, +}).partial({ + enabled: true, + rateLimit: true, + config: true, + snapshotEnabled: true, + snapshotIntervalSec: true, + proxy: true, +}); export type SiteCreate = z.infer; export const SiteUpdate = SiteCreate.omit({ key: true }).partial(); export type SiteUpdate = z.infer; export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]); export type SiteAuthKind = z.infer; -export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]); +/** + * 账号登录状态(site-center §4.6):unknown 未验证 → active 正常;expired 失效(自动重登也失败); + * invalid 密码错 / 封禁;challenge 需人工验证(验证码 / 二次验证 / Cloudflare)。 + */ +export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired", "challenge"]); export type SiteAccountStatus = z.infer; +/** 登录 / 校验失败的分类(NexusPHP takelogin、Cookie / API Key 校验) */ +export const SiteLoginFailureReason = z.enum([ + "captcha", + "two_factor", + "bad_credentials", + "account_blocked", + "challenge", + "auth_expired", + "network", + "unsupported", + "unknown", +]); +export type SiteLoginFailureReason = z.infer; + +export const SiteAccountError = z.object({ + reason: SiteLoginFailureReason, + /** 中文一句;站点原文截 200 字 */ + message: z.string(), + at: Timestamp, +}); +export type SiteAccountError = z.infer; + +/** + * 站点用户数据(refreshSiteUserData 的 SiteUserInfo 去掉 raw / 消息正文 / 做种明细)。 + * 字节数为 number;ratio null = 站点未给且无法计算;download=0 时前端显示 ∞。 + * bonus null = 站点不提供魔力;vip null = 无法判定(不显示)。 + */ +export const SiteAccountUserData = z.object({ + schema: z.string().nullable(), + userId: z.string().nullable(), + username: z.string().nullable(), + userLevel: z.string().nullable(), + vip: z.boolean().nullable(), + joinAt: z.string().nullable(), + upload: z.number().nonnegative(), + download: z.number().nonnegative(), + ratio: z.number().nullable(), + bonus: z.number().nullable(), + seeding: NonNegInt, + seedingSize: z.number().nonnegative(), + leeching: NonNegInt, + leechingSize: z.number().nonnegative(), + messageUnread: NonNegInt, +}); +export type SiteAccountUserData = z.infer; + export const SiteAccount = EntityBase.extend({ siteId: Id, label: z.string().min(1), + /** 主凭证类型;passkey 另见 hasPasskey */ authKind: SiteAuthKind, secretRef: Id.nullable(), enabled: z.boolean(), status: SiteAccountStatus, lastVerifiedAt: Timestamp.nullable(), + /** @deprecated 只读兼容一版;登录名见 loginName,用户数据见 userData */ userInfo: JsonObject, + /** 账密登录名 */ + loginName: z.string().nullable(), + /** 空 = 全局默认 UA(settings.sites.userAgent) */ + userAgent: z.string().nullable(), + /** 最近一次登录 / 校验成功(Cookie、API Key 校验成功也算) */ + lastLoginAt: Timestamp.nullable(), + lastError: SiteAccountError.nullable(), + /** 最近一次成功的用户数据;从未成功为 null */ + userData: SiteAccountUserData.nullable(), + userDataAt: Timestamp.nullable(), + hasTotp: z.boolean(), + /** 账密登录得到的会话 Cookie 已保存 */ + hasSession: z.boolean(), + hasPasskey: z.boolean(), }); export type SiteAccount = z.infer; +/** @deprecated 新客户端用 PUT /sites/{id}/credentials(SiteCredentialsBody) */ export const SiteAccountCreate = SiteAccount.pick({ siteId: true, label: true, authKind: true, enabled: true }) .partial({ enabled: true }) .extend({ credential: z.string().min(1).max(16384) }); export type SiteAccountCreate = z.infer; +/** @deprecated 凭证部分改走 PUT /sites/{id}/credentials;label / enabled 仍可用 */ export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial(); export type SiteAccountUpdate = z.infer; +// ── W9-A 站点中心(docs/design/site-center.md) ── + +/** POST /sites/{id}/health-check:首页连通 + 登录态 */ +export const SiteHealthResult = SiteLastHealth.extend({ + siteId: Id, + checkedAt: Timestamp, + /** 登录态:true 已登录;false 未登录 / 失效;null 未能判定(网络失败、无凭证) */ + loggedIn: z.boolean().nullable(), + /** 写回后的站点健康 */ + healthStatus: SiteHealth, +}); +export type SiteHealthResult = z.infer; + +const CredLabel = z.string().min(1).max(80).default("default"); +const CredSecret = z.string().min(1).max(16384); +const CredUa = z.string().min(1).max(500); +/** + * PUT /sites/{id}/credentials 新格式(按 kind 判别)。按 (siteId, label) 建立或更新账号;明文加密为 secret,不回显。 + * 更新凭证:账号回到 unknown,关闭该站 site-auth / site-login 待处理。 + */ +export const SiteCredentialsBody = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("password"), + label: CredLabel, + username: z.string().min(1).max(200), + password: CredSecret, + /** TOTP 密钥(base32);表单有二次验证字段时自动填当前 6 位码 */ + totpSecret: z.string().min(1).max(200).optional(), + /** 只换 Cookie:登录成功后凭证类型落为 cookie,密码与 TOTP 丢弃 */ + saveCookieOnly: z.boolean().optional(), + userAgent: CredUa.optional(), + enabled: z.boolean().optional(), + }), + z.object({ + kind: z.literal("cookie"), + label: CredLabel, + cookie: CredSecret, + userAgent: CredUa.optional(), + enabled: z.boolean().optional(), + }), + z.object({ kind: z.literal("api_key"), label: CredLabel, apiKey: CredSecret, enabled: z.boolean().optional() }), + /** passkey 附加到同账号(RSS / 下载用,不参与登录);账号不存在时新建 passkey 型账号 */ + z.object({ kind: z.literal("passkey"), label: CredLabel, passkey: CredSecret, enabled: z.boolean().optional() }), +]); +export type SiteCredentialsBody = z.infer; + +/** POST /sites/{id}/refresh-login */ +export const SiteRefreshLoginInput = z.object({ + /** 缺省:该站第一个启用账号 */ + accountId: Id.optional(), + /** true:同步执行并返回结果(≤ 30 s,添加站点第 ③ 步用);缺省异步入队返回 taskId */ + wait: z.boolean().optional(), +}); +export type SiteRefreshLoginInput = z.infer; + +export const SiteLoginResult = z.object({ + siteId: Id, + accountId: Id, + ok: z.boolean(), + /** 处理后的账号状态;网络失败时不变 */ + status: SiteAccountStatus, + reason: SiteLoginFailureReason.nullable(), + message: z.string().nullable(), + /** 成功时的用户数据(拉取失败为 null,不影响登录结果) */ + userData: SiteAccountUserData.nullable(), + /** 失败时开出的待处理事项 */ + decisionId: Id.nullable(), +}); +export type SiteLoginResult = z.infer; +export const SiteRefreshLoginAccepted = z.object({ + /** 异步时的引擎任务 ID;wait=true 为 null */ + taskId: Id.nullable(), + /** wait=true 时的结果 */ + result: SiteLoginResult.nullable(), +}); +export type SiteRefreshLoginAccepted = z.infer; + +/** site_user_data 表行:用户数据按天一行(同日 upsert 取最新),随账号删除 */ +export const SiteUserData = EntityBase.extend({ + siteAccountId: Id, + day: DateOnly, + upload: z.number().nonnegative(), + download: z.number().nonnegative(), + ratio: z.number().nullable(), + bonus: z.number().nullable(), + seeding: NonNegInt, + seedingSize: z.number().nonnegative(), + leeching: NonNegInt, + userLevel: z.string().nullable(), + vip: z.boolean().nullable(), + messageUnread: NonNegInt, + joinAt: z.string().nullable(), + /** SiteUserInfo 其余字段(不含凭据、消息正文、做种明细) */ + raw: JsonObject, + capturedAt: Timestamp, +}); +export type SiteUserData = z.infer; +/** GET /sites/{id}/user-data 的一点 */ +export const SiteUserDataPoint = SiteUserData.omit({ + id: true, + createdAt: true, + updatedAt: true, + raw: true, + joinAt: true, +}); +export type SiteUserDataPoint = z.infer; +export const SiteUserDataQuery = z.object({ + accountId: Id.optional(), + days: z.coerce.number().int().min(1).max(400).default(30), +}); +export type SiteUserDataQuery = z.infer; + +/** 站点整体账号状态:多账号取最差;无启用账号 = unconfigured */ +export const SiteAccountOverall = z.enum(["unconfigured", ...SiteAccountStatus.options]); +export type SiteAccountOverall = z.infer; + +/** GET /sites?include=accounts 的条目(不带 include 时两个附加字段缺省) */ +export const SiteListItem = Site.extend({ + accounts: z.array(SiteAccount).optional(), + accountStatus: SiteAccountOverall.optional(), +}); +export type SiteListItem = z.infer; +export const SiteListQuery = CursorQuery.extend({ include: z.enum(["accounts"]).optional() }); +export type SiteListQuery = z.infer; + export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]); export type FeedPurpose = z.infer; export const FeedKind = z.enum(["rss", "atom", "list", "query"]); diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index c7a1ec66..f2d3889d 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -76,8 +76,15 @@ const ROWS: Record Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] }, ServerMapping: { row: C.ServerMapping }, Settings: { row: C.SettingEntry }, - Site: { row: C.Site }, - SiteAccount: { row: C.SiteAccount }, + // deleted_at:软删除只在服务端过滤(已删站点不出 API) + Site: { row: C.Site, dbOnly: ["deletedAt"] }, + // secret 引用不外露,只给布尔 + SiteUserData: { row: C.SiteUserData }, + SiteAccount: { + row: C.SiteAccount, + dbOnly: ["totpSecretRef", "sessionSecretRef", "passkeySecretRef"], + apiOnly: ["hasTotp", "hasSession", "hasPasskey"], + }, SourceRelease: { row: C.SourceRelease }, Subscription: { row: C.Subscription, apiOnly: ["mediaEntityTitle"] }, Task: { row: C.Task }, diff --git a/packages/db/migrations/0031_site_center.ts b/packages/db/migrations/0031_site_center.ts new file mode 100644 index 00000000..8a6570ca --- /dev/null +++ b/packages/db/migrations/0031_site_center.ts @@ -0,0 +1,88 @@ +import type { Kysely } from "kysely"; +import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts"; + +// W9-A 站点中心(docs/design/site-center.md §5): +// - site_account:登录状态加 challenge;账密登录名、TOTP / 会话 Cookie / passkey 的 secret 引用、UA、 +// 上次登录、最近错误、最近一次用户数据;user_info.username 迁到 login_name。 +// - site_user_data:用户数据按天一行(同日 upsert 取最新),随账号删除。 +// - site:最近健康检测(结果 + 时间)、使用代理;deleted_at 软删除(source_release 外键 restrict,资源保留)。 + +const OLD_STATUS = ["unknown", "active", "invalid", "expired"]; +const NEW_STATUS = [...OLD_STATUS, "challenge"]; + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +alter table site_account drop constraint site_account_status_check; +alter table site_account add constraint site_account_status_check ${oneOf("status", NEW_STATUS)}; +alter table site_account + add column login_name text, + add column totp_secret_ref uuid references secret (id) on delete set null, + add column session_secret_ref uuid references secret (id) on delete set null, + add column passkey_secret_ref uuid references secret (id) on delete set null, + add column user_agent text, + add column last_login_at timestamptz, + add column last_error jsonb, + add column user_data jsonb, + add column user_data_at timestamptz; +update site_account set login_name = user_info ->> 'username' where user_info ? 'username'; + +alter table site + add column last_health jsonb, + add column last_health_at timestamptz, + add column proxy boolean not null default false, + add column deleted_at timestamptz; + +${entityTable( + "site_user_data", + ` + site_account_id uuid not null references site_account (id) on delete cascade, + day date not null, + upload bigint not null default 0, + download bigint not null default 0, + ratio double precision, + bonus double precision, + seeding integer not null default 0, + seeding_size bigint not null default 0, + leeching integer not null default 0, + user_level text, + vip boolean, + message_unread integer not null default 0, + join_at text, + raw jsonb not null default '{}'::jsonb, + captured_at timestamptz not null default now(), + constraint site_user_data_day_key unique (site_account_id, day)`, +)} +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + ` +drop table if exists site_user_data; +alter table site + drop column last_health, + drop column last_health_at, + drop column proxy, + drop column deleted_at; +alter table site_account + drop column login_name, + drop column totp_secret_ref, + drop column session_secret_ref, + drop column passkey_secret_ref, + drop column user_agent, + drop column last_login_at, + drop column last_error, + drop column user_data, + drop column user_data_at; +update site_account set status = 'expired' where status = 'challenge'; +alter table site_account drop constraint site_account_status_check; +alter table site_account add constraint site_account_status_check ${oneOf("status", OLD_STATUS)}; +`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 04bcaa86..418ff71c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -228,7 +228,7 @@ export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss"; export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled"; export type SiteCircuitState = "closed" | "open" | "half_open"; export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password"; -export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired"; +export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired" | "challenge"; export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual"; export type SiteSnapshotStatus = "running" | "ok" | "partial" | "failed"; export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected"; @@ -1208,6 +1208,10 @@ export interface SiteTable { snapshot_cursor: Generated; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + last_health: Json | null; + last_health_at: Timestamptz | null; + proxy: Generated; + deleted_at: Timestamptz | null; } export interface SiteAccountTable { @@ -1222,6 +1226,15 @@ export interface SiteAccountTable { user_info: Generated; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + login_name: string | null; + totp_secret_ref: string | null; + session_secret_ref: string | null; + passkey_secret_ref: string | null; + user_agent: string | null; + last_login_at: Timestamptz | null; + last_error: Json | null; + user_data: Json | null; + user_data_at: Timestamptz | null; } export interface SiteSnapshotTable { @@ -1242,6 +1255,27 @@ export interface SiteSnapshotTable { updated_at: GeneratedTimestamptz; } +export interface SiteUserDataTable { + id: Generated; + site_account_id: string; + day: string; + upload: Generated; + download: Generated; + ratio: number | null; + bonus: number | null; + seeding: Generated; + seeding_size: Generated; + leeching: Generated; + user_level: string | null; + vip: boolean | null; + message_unread: Generated; + join_at: string | null; + raw: Generated; + captured_at: GeneratedTimestamptz; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface SlotDeferralTable { id: Generated; slot_id: string; @@ -1489,6 +1523,7 @@ export interface DB { site: SiteTable; site_account: SiteAccountTable; site_snapshot: SiteSnapshotTable; + site_user_data: SiteUserDataTable; slot_deferral: SlotDeferralTable; slot_unit: SlotUnitTable; source_release: SourceReleaseTable; @@ -1671,6 +1706,9 @@ export type SiteAccountUpdate = Updateable; export type SiteSnapshotRow = Selectable; export type NewSiteSnapshot = Insertable; export type SiteSnapshotUpdate = Updateable; +export type SiteUserDataRow = Selectable; +export type NewSiteUserData = Insertable; +export type SiteUserDataUpdate = Updateable; export type SlotDeferralRow = Selectable; export type NewSlotDeferral = Insertable; export type SlotDeferralUpdate = Updateable;