feat(contracts): site center schemas, endpoints and migration 0031

Site health/proxy, SiteAccount login state (challenge, lastLoginAt, lastError,
userData), SiteUserData history table, SiteCredentialsBody by kind,
refresh-login/health-check/user-data/delete endpoints, SitesSettings defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 14:40:07 +08:00
1 parent 381791cd64
commit e2d1e21968
7 files changed
+458 -31

No files matched your search

+50 -14
View File
@@ -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",
+9 -4
View File
@@ -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<typeof InstanceTestResult>;
/**
* 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<typeof SiteCredentialsLegacyPut>;
/** PUT /sites/{id}/credentials 请求体:新格式(按 kind 判别)或旧格式 */
export const SiteCredentialsPut = z.union([SiteCredentialsBody, SiteCredentialsLegacyPut]);
export type SiteCredentialsPut = z.infer<typeof SiteCredentialsPut>;
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(),
+44 -7
View File
@@ -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<typeof MetadataSettings>;
/** 固定桌面 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<typeof SitesSettings>;
/** 每日预算与并发(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<typeof GlobalSettings>;
@@ -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<typeof GlobalSettingsUpdate>;
@@ -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",
+219 -3
View File
@@ -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<typeof SiteRateLimit>;
/** 站点行 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<typeof SiteLastHealth>;
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<typeof Site>;
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<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
export type SiteUpdate = z.infer<typeof SiteUpdate>;
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
export type SiteAuthKind = z.infer<typeof SiteAuthKind>;
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<typeof SiteAccountStatus>;
/** 登录 / 校验失败的分类(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<typeof SiteLoginFailureReason>;
export const SiteAccountError = z.object({
reason: SiteLoginFailureReason,
/** 中文一句;站点原文截 200 字 */
message: z.string(),
at: Timestamp,
});
export type SiteAccountError = z.infer<typeof SiteAccountError>;
/**
* 站点用户数据(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<typeof SiteAccountUserData>;
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<typeof SiteAccount>;
/** @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<typeof SiteAccountCreate>;
/** @deprecated 凭证部分改走 PUT /sites/{id}/credentials;label / enabled 仍可用 */
export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial();
export type SiteAccountUpdate = z.infer<typeof SiteAccountUpdate>;
// ── 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<typeof SiteHealthResult>;
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<typeof SiteCredentialsBody>;
/** 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<typeof SiteRefreshLoginInput>;
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<typeof SiteLoginResult>;
export const SiteRefreshLoginAccepted = z.object({
/** 异步时的引擎任务 ID;wait=true 为 null */
taskId: Id.nullable(),
/** wait=true 时的结果 */
result: SiteLoginResult.nullable(),
});
export type SiteRefreshLoginAccepted = z.infer<typeof SiteRefreshLoginAccepted>;
/** 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<typeof SiteUserData>;
/** 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<typeof SiteUserDataPoint>;
export const SiteUserDataQuery = z.object({
accountId: Id.optional(),
days: z.coerce.number().int().min(1).max(400).default(30),
});
export type SiteUserDataQuery = z.infer<typeof SiteUserDataQuery>;
/** 站点整体账号状态:多账号取最差;无启用账号 = unconfigured */
export const SiteAccountOverall = z.enum(["unconfigured", ...SiteAccountStatus.options]);
export type SiteAccountOverall = z.infer<typeof SiteAccountOverall>;
/** GET /sites?include=accounts 的条目(不带 include 时两个附加字段缺省) */
export const SiteListItem = Site.extend({
accounts: z.array(SiteAccount).optional(),
accountStatus: SiteAccountOverall.optional(),
});
export type SiteListItem = z.infer<typeof SiteListItem>;
export const SiteListQuery = CursorQuery.extend({ include: z.enum(["accounts"]).optional() });
export type SiteListQuery = z.infer<typeof SiteListQuery>;
export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]);
export type FeedPurpose = z.infer<typeof FeedPurpose>;
export const FeedKind = z.enum(["rss", "atom", "list", "query"]);
+9 -2
View File
@@ -76,8 +76,15 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
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 },
@@ -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<any>): Promise<void> {
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<any>): Promise<void> {
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)};
`,
);
}
+39 -1
View File
@@ -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<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
last_health: Json | null;
last_health_at: Timestamptz | null;
proxy: Generated<boolean>;
deleted_at: Timestamptz | null;
}
export interface SiteAccountTable {
@@ -1222,6 +1226,15 @@ export interface SiteAccountTable {
user_info: Generated<Json>;
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<string>;
site_account_id: string;
day: string;
upload: Generated<number>;
download: Generated<number>;
ratio: number | null;
bonus: number | null;
seeding: Generated<number>;
seeding_size: Generated<number>;
leeching: Generated<number>;
user_level: string | null;
vip: boolean | null;
message_unread: Generated<number>;
join_at: string | null;
raw: Generated<Json>;
captured_at: GeneratedTimestamptz;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface SlotDeferralTable {
id: Generated<string>;
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<SiteAccountTable>;
export type SiteSnapshotRow = Selectable<SiteSnapshotTable>;
export type NewSiteSnapshot = Insertable<SiteSnapshotTable>;
export type SiteSnapshotUpdate = Updateable<SiteSnapshotTable>;
export type SiteUserDataRow = Selectable<SiteUserDataTable>;
export type NewSiteUserData = Insertable<SiteUserDataTable>;
export type SiteUserDataUpdate = Updateable<SiteUserDataTable>;
export type SlotDeferralRow = Selectable<SlotDeferralTable>;
export type NewSlotDeferral = Insertable<SlotDeferralTable>;
export type SlotDeferralUpdate = Updateable<SlotDeferralTable>;