Files
dsh_ai1net_server/src/db/adapter.ts
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

170 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The unified async DB interface. Routes depend only on this — never on
* `better-sqlite3` or `pg` directly — so the backend can switch between SQLite
* (`deployMode=local`) and Postgres (`deployMode=k8s`) without touching callers.
* @module dshs/db/adapter
*/
import type {
BusinessPlugin,
ClaimResult,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
DshHost,
DshHostStatus,
DshInstance,
DshInstanceRole,
DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser,
RecordEmailCodeInput,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
UpsertDshHostInput,
UpsertDshInstanceInput,
User,
UserRole,
Workspace,
} from './types.js'
export interface DbAdapter {
// users
createUser(input: CreateUserInput): Promise<User>
findUserByUsername(username: string): Promise<User | undefined>
findUserBySlug(slug: string): Promise<User | undefined>
findUserById(id: string): Promise<User | undefined>
listPublicUsers(): Promise<PublicUser[]>
countAdmins(): Promise<number>
setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean>
/** Assign a Linux uid to a user (used by the legacy backfill). */
setUserUid(userId: string, uid: number): Promise<void>
/** Ids of users whose uid column is still null (legacy rows awaiting backfill). */
listUsersWithoutUid(): Promise<string[]>
/**
* Permanently delete a user and every owned row (credential_vault / domains /
* sessions / dsh_instances / audit_log / folder_plugins / workspaces), inside
* one transaction. Returns false when no such user exists.
*/
deleteUser(userId: string): Promise<boolean>
// sessions
createSession(input: CreateSessionInput): Promise<void>
findSession(tokenHash: string): Promise<SessionRow | undefined>
deleteSession(tokenHash: string): Promise<void>
deleteUserSessions(userId: string): Promise<void>
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
/** Whether the user has any session that has not yet expired (idle reap). */
hasActiveSession(userId: string): Promise<boolean>
/** Registration e-mail lookup (v10, case-insensitive). */
findUserByEmail(email: string): Promise<User | undefined>
// e-mail verification codes (v10) — 注册页「邮箱验证码」的存储 + 防爆破计数来源
/** Append one `email_codes` row (sent / throttled / failed). */
recordEmailCode(input: RecordEmailCodeInput): Promise<void>
/** Rolling counters for one (email, ip) pair — the brute-force guard's only input. */
emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts>
/** The newest **deliverable** code for (email, purpose), or undefined. */
latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined>
/**
* 记一次校验失败:`attempts + 1`;`consume` 为真时同时打上 `consumed_at`(= 该码作废)。
* 返回 false 表示该行已不存在(例如被并发守卫先行作废)。
*/
bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean>
/** 校验通过:打上 `consumed_at`(**单次使用**)。返回 false = 在写入前已被别人用掉。 */
consumeEmailCode(id: string, now: number): Promise<boolean>
/** 删除早于 `before` 的 `email_codes` 行(自维护,避免表无限增长)。 */
purgeEmailCodes(before: number): Promise<number>
// audit
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
// workspaces / plugins
findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined>
getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace>
setFolderPlugins(workspaceId: string, selections: ReadonlyArray<{ id: string; enabled: boolean }>): Promise<void>
getEnabledPluginIds(workspaceId: string): Promise<string[]>
// business plugins (系统外插件候选池)
listBusinessPlugins(): Promise<BusinessPlugin[]>
findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined>
upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin>
deleteBusinessPlugin(id: string): Promise<boolean>
// domains
findDomainByUser(userId: string): Promise<Domain | undefined>
findDomainById(id: string): Promise<Domain | undefined>
listDomains(): Promise<Domain[]>
upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain>
setDomainVerified(id: string, verified: boolean): Promise<boolean>
// credential vault
listCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 86:该用户**全部已启用**的条目(spawn 时按它们写实例配置)。 */
listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 87:同上 + **encrypted ref** —— **仅供 `server.ts` 的落地层**,绝不经 API 返回。 */
listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]>
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey>
selectCredentialKey(userId: string, id: string): Promise<boolean>
/** 档案 86:开/关**单个**条目(不动其它条目 —— 用户口径:可同时启用多个)。 */
toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean>
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
getSharedModelEnabled(userId: string): Promise<boolean>
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
deleteCredentialKey(userId: string, id: string): Promise<boolean>
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
findInstance(id: string): Promise<DshInstance | undefined>
findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined>
listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]>
/** Record a state transition; `exitCode`/`lastError` also stamp `last_exit`. */
setInstanceStatus(
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): Promise<boolean>
deleteInstance(id: string): Promise<boolean>
deleteUserInstances(userId: string): Promise<void>
// ── 集群化:worker 注册表 + 实例归属/租约(v7;T08 S2 / 设计 §3.1–§3.2)──
// ⚠️ local 模式**不写**这些表(`LocalSpawner` 靠进程内 Map + 单机互斥),
// 所以这些方法在单机路径上恒为"空/未认领",不影响现有行为。
/** 注册/更新一台 worker(join 幂等:同 id 重复执行 = 更新)。 */
upsertDshHost(input: UpsertDshHostInput): Promise<DshHost>
findDshHost(id: string): Promise<DshHost | undefined>
listDshHosts(): Promise<DshHost[]>
/** 心跳/状态上报:可只改状态,或同时带上容量水位与心跳时间。 */
setDshHostStatus(
id: string,
status: DshHostStatus,
usedMb?: number,
heartbeatAt?: number,
): Promise<boolean>
/**
* **原子抢占**某用户的 main 实例归属(承重墙,见设计 §3.2)。
* 仅当"无人持有 **或** 租约已过期"才成功;成功时 `epoch` +1(fencing)。
* 返回 `ok:false` = 有人在管 ⇒ 调用方**退让**(不是接管)。
*/
claimInstance(
userId: string,
hostId: string,
ttlMs: number,
meta?: { folder?: string; patch?: string },
): Promise<ClaimResult>
/** 续租。**必须带 epoch**:不匹配说明已被他人抢占 ⇒ 本次续租失败(fencing)。 */
renewInstanceLease(userId: string, hostId: string, epoch: number, ttlMs: number): Promise<boolean>
/** 主动释放(停实例时)。同样带 epoch 校验,避免误清他人的归属。 */
releaseInstanceLease(userId: string, hostId: string, epoch: number): Promise<boolean>
/** 钉住归属(首次触达工作区时用):只写 `host_id`,不动 epoch/租约。 */
pinInstanceHost(userId: string, hostId: string): Promise<void>
/** 租约已过期、但仍标着归属的实例 —— 供巡检/自愈(**不代表可以立即接管**,见 R9)。 */
listExpiredInstanceLeases(now: number): Promise<DshInstance[]>
/** 某 worker 上的全部实例 —— 对账用**一次拿回整机**(替代逐用户查询)。 */
listInstancesByHost(hostId: string): Promise<DshInstance[]>
// lifecycle
close(): Promise<void>
}