# Readest Lite — 迭代提示词(v8.23.2,分道扬镳版) > 这是 Readest Lite 的「持续迭代提示词」。每次新对话开始时把它丢给助手,能让 > 助手快速进入「Lite 维护者」上下文,避免每次都重复解释 Lite 与上游 Readest > 的区别、为什么某个文件不能改、为什么某个 URL 必须是相对路径,等等。本文档 > 涵盖从 v8.0 到 v8.23.2 的所有设计决策、迁移、API 端点和 Lite 自定义文件。 ## 🔀 与上游分道扬镳声明(v8.23.2 起,最重要) **自 v8.23.2 起,Readest Lite 不再与上游 [readest/readest](https://github.com/readest/readest) 同步。** ### 历史背景 - v8.0 → v8.23.2:以「持续合并上游 + Lite 自定义改造」方式迭代,每次上游发版(v0.11.x → v0.12.6)都做三方合并。 - v8.24.0 尝试合并上游 v0.12.10 时遇到严重回归(多次合并失败、功能丢失、CI 反复 fail),消耗大量精力后决定放弃。 ### 新策略(维护者必读) - **不再跟踪上游**:上游 v0.12.7+ 的新功能(iOS CarPlay / Android Auto / BookOrbit / KOReader / RSS / OPDS / localsend 等)不会进入 Lite。 - **长期基线**:v8.23.2 + 上游 v0.12.6,稳定可靠,作为 Lite 的长期版本。 - **只在以下情况更新**: 1. 用户在 NodeByte 社区或 GitHub Issues 提交 bug 报告 2. 用户提交 PR 增加自己想要的特色功能 3. 安全漏洞修复 - **不再做上游合并**:不要尝试 `git fetch upstream` + 三方合并。即使上游发了 v0.13、v0.14 也不合并。 - **Lite 自定义文件清单(§下文)仍然有效**:这些文件不能被任何上游改动覆盖(虽然现在不会再有上游改动了)。 ### 版本号策略 - 后续版本号从 v8.23.3 开始递增(v8.23.3、v8.23.4...),仅用于标记 Lite 自己的修复/特色功能更新。 - **不再使用 v8.24+** 以避免与上游版本号混淆。 - 已删除 v8.24.0 tag(曾短暂存在过,已 reset 回 v8.23.2)。 ### 助手行为约束 当用户要求「合并上游」「同步上游」「更新到上游最新版」时: 1. **拒绝**,并引用本章节说明 Lite 已分道扬镳。 2. 提醒用户:想要上游最新功能请用官方 [readest/readest](https://github.com/readest/readest)。 3. 如果用户坚持要某个上游特定功能,建议从上游 cherry-pick **那一个 PR**,不要整版合并。 当用户提交 bug 报告或 PR 时: 1. 基于 v8.23.2 当前代码分析问题。 2. 不要假设上游已经修复了某个 bug — 上游的修复可能依赖被 Lite 跳过的功能。 3. 修复后版本号 bump 到 v8.23.x+1。 ## 项目定位 **Readest Lite** = 上游 [Readest](https://github.com/readest/readest) 的自托管 单容器分支。原版依赖 Supabase + R2 + Stripe,Lite 把这些全部替换成自托管组件: SQLite via Prisma + 本地文件系统 + JWT 邮箱密码 + 移除支付(所有用户视为 Pro)。 ### Lite 与上游的核心差异(绝不改回上游) - **存储**:`local` 文件系统(不是 R2/S3)。`utils/object.ts` 重新导出 `localStorage.ts`,签名 URL 走 HMAC-SHA256,TTL 1800 秒。 - **数据库**:SQLite via Prisma(不是 Postgres + Supabase)。容器启动时 `prisma db push` 自动建表 + 跑 `docker/volumes/db/migrations/*.sql`。 - **认证**:本地 JWT + 邮箱密码(不是 Supabase Auth/OAuth)。 `utils/localAuth.ts` 提供 `signAccessToken` / `verifyAccessToken` / `hashPass` / `comparePass` / `validateUserAndToken` / `validateAdmin`。 - **支付**:移除 — 所有用户视为 `Pro` 计划(无限配额)。 `utils/access.ts` 中所有 `getXxxPlanData` 恒返回 100TB,`EMAIL_IN_PLANS` 恒 true。 - **Cloud sync**:WebDAV / S3 / GoogleDrive / OneDrive / iCloud 都是 stub。 用户在「Integrations → Cloud Sync」面板里看到的是只读说明,不能配置。 `services/sync/cloudSyncActivation.ts` 是空 stub,`providers/*` 是空类。 - **Native-only 功能**:ABS / LocalSend / Yomitan / Wordlens / Nix / Audiobook pairing 都是 stub。所有 stub 在 v8.19.4 之后必须**非阻塞** — 返回 null / 空 / 原值,**不抛 Error**。 - **品牌**:所有用户可见字符串必须是「Readest Lite」,不是「Readest」。 搜索 `apps/readest-app/src` 里残留的 `"Readest"` 字面量,逐个改成 `"Readest Lite"`(除非是上游未改的依赖字符串,例如 `process.env['READEST_*']`)。 ## Lite 自定义文件清单(绝对不能被上游同步覆盖) 下面这些文件被 Lite 重写或新建,**绝对不能**用上游版本覆盖。维护者每次合上游 PR 时要逐个对比,遇到上游改了其中之一就要把 Lite 的差异手动 backport 进来 (通常是几行 import 改动 + 一个 if 分支)。 ``` # App service 与运行时配置 apps/readest-app/src/services/appService.ts # BaseAppService apps/readest-app/src/services/constants.ts # DEFAULT_SYSTEM_SETTINGS (Lite 默认值) apps/readest-app/src/services/environment.ts # getBaseUrl / getAPIBaseUrl 用相对路径 apps/readest-app/src/services/runtimeConfig.ts # 相对 URL 而非烤死的 localhost apps/readest-app/src/types/{book,settings,system}.ts # 类型扩展(含 Lite 字段) # Store apps/readest-app/src/store/{libraryStore,readerStore}.ts # Context apps/readest-app/src/context/{AuthContext,VaultContext,PHContext}.tsx # Library UI apps/readest-app/src/app/library/page.tsx apps/readest-app/src/app/library/components/{LibraryHeader,ImportMenu}.tsx # 公共组件 apps/readest-app/src/components/{AboutWindow,Quota,Providers,Landing}.tsx # 翻译 / 词典 / RSS apps/readest-app/src/services/translators/providers/google.ts # 走 /api/translate/google apps/readest-app/src/services/dictionaries/providers/{wikipedia,wiktionary}Provider.ts apps/readest-app/src/services/dictionaries/chineseDict.ts # 走 fetchViaWikiProxy apps/readest-app/src/services/rss/favicon.ts # v8.18.3 favicon 自动识别 apps/readest-app/src/services/rss/feedBook.ts # v8.18.3 favicon 嵌入封面 # Utils apps/readest-app/src/utils/{access,localStorage,db,localAuth,supabase,vaultState,proxy}.ts apps/readest-app/src/utils/book.ts # getRemoteBookFilename (local 分支) apps/readest-app/src/libs/storage.ts # 相对 URL + requestOrigin apps/readest-app/src/libs/shareServer.ts # feed:// 书籍分享 apps/readest-app/src/libs/errors.ts # isWrongPassphraseError apps/readest-app/src/libs/crypto/session.ts # invalidatePassphrase # Cloud sync + Replica apps/readest-app/src/services/cloudService.ts # feed:// 上传/下载 short-circuit apps/readest-app/src/services/sync/cloudSyncActivation.ts # stub apps/readest-app/src/services/sync/providers/{gdrive,onedrive}/...Connect.ts apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts # 上游版本 apps/readest-app/src/services/sync/passphraseGate.ts # 上游版本 apps/readest-app/src/services/sync/adapters/absServer.ts # ReplicaAdapter 实现 apps/readest-app/src/services/sync/encryptedSettingsSync.ts # v8.18.4 加密设置同步 apps/readest-app/src/services/sync/statsSync.ts # v8.19.4 阅读统计加密同步 # Native stubs(v8.19.4 全部改为非阻塞) apps/readest-app/src/services/audiobook/* # 全部 stub apps/readest-app/src/services/localsend/* # 全部 stub apps/readest-app/src/services/dictionaries/plugins/* # 全部 stub apps/readest-app/src/store/{absServerStore,localsendStore}.ts # 类型扩展(含 Lite 专用字段) apps/readest-app/src/types/{audiobookshelf,bookorbit,payment,webSource}.ts # Settings 集成面板(stub) apps/readest-app/src/components/localsend/LocalSendManager.tsx # 渲染 null apps/readest-app/src/components/settings/integrations/{ABSForm,LocalSendForm,cloudSync}.tsx apps/readest-app/src/app/reader/components/audiobook/AudiobookPairingDialog.tsx # 渲染 null # 用户管理(v8.19.0;v8.23.3 移除 UserDetailModal) apps/readest-app/src/app/user/components/UserManagement.tsx # 含 AllUsersModal(v8.23.3 移除 UserDetailModal) # Pages Router API(Lite 专用,App Router `/api/admin/*` 由路由组而非文件构成) apps/readest-app/src/pages/api/storage/delete.ts # v8.18.4 自动 revoke shares apps/readest-app/src/pages/api/settings/{index,save}.ts # v8.18.4 加密设置同步 apps/readest-app/src/pages/api/recycle-bin/{index,restore,clear}.ts # v8.19.0 回收站 apps/readest-app/src/pages/api/avatar/[id].ts # v8.19.2 隐藏头像 URL # Library / Remote download UI apps/readest-app/src/app/library/components/RemoteDownloadDialog.tsx # v8.18.3 移除 Advanced Options apps/readest-app/src/app/library/components/ShareBookDialog.tsx # v8.18.4 永久+日历 ``` ## 关键设计决策 ### 1. 签名 URL 必须用相对 URL(CORS / URL 修复 #1) `utils/localStorage.ts` 的 `getStorageBase()` 默认返回 `''`,让 buildPutUrl / buildGetUrl 输出 `/api/storage/_put?...` 这样的相对 URL。浏览器自动按当前 页面 origin 解析,无论用户从 `localhost` / IP / 域名访问都正确。 需要绝对 URL 的场景(如 `NextResponse.redirect`)由调用方传入 `requestOrigin`, 从请求 URL `new URL(request.url).origin` 派生。 **绝对不要**改回 `http://127.0.0.1:8225` 或烤死的 localhost — 浏览器无法访问 容器内地址。这条规则适用于: - `services/environment.ts` 的 `getBaseUrl` 在浏览器中返回 `''` - `services/runtimeConfig.ts` 不再读 `localhost` 默认值 - `libs/storage.ts` 的 `getStorageBase()` 返回 `''` - `services/cloudService.ts` 上传/下载用相对路径 - 任何 `new URL(...)` 调用必须传 `base` 参数 ### 2. getRemoteBookFilename 必须返回非空(CORS / URL 修复 #2) `utils/book.ts` 的 `local` 分支必须返回 `/.`,绝对 不能返回 `''`。返回空字符串会让 cloud path `cfp = 'Readest/Books/'` 出现空 段,被 `isSafeObjectKeyName` 拒绝('Invalid fileName'),同时上传写入的 `fileKey` 缺少文件名段,下载请求对不上 → 'File not found'。 ### 3. 中文文件名安全 `utils/misc.ts` 的 `makeSafeFilename()` 已正确处理 Windows 保留字符、控制字符、 超过 250 字节等。不要在 `getRemoteBookFilename` 里再 escape 一次 — 双重 escape 会让签名 URL 不匹配。 ### 4. 代理路由必须强制登录(SSRF 防护) - `/api/proxy/wiki` - `/api/proxy/resource` - `/api/translate/google` 这三个端点全部调用 `validateUserAndToken(authHeader)`,未登录返回 401。 **永远不要**为方便测试而改成 `optional auth` — 公开代理会被滥用为 SSRF 跳板。 `isProxyEnabled()` 在客户端默认 false,用户主动开启后才把 wiki / 字典请求 走代理。代理服务端 `isPrivateHost()` 拒绝 localhost / 10.x / 172.16-31 / 192.168 / metadata (169.254.169.254)。 ### 5. feed:// 书籍同步 `uploadBook()` 在 `cloudService.ts` 中检测到 `book.url.startsWith('feed://')` 时跳过 blob 上传,只上传封面 + 标记为已上传。`downloadBook()` 同理。这样 RSS 订阅能跟普通书一样走自动同步,且不消耗存储配额。 ### 6. feed:// 书籍分享 — 接收方独立,owner 删除自动失效 **关键**:接收方 import 后是**完全独立**的副本 — 字节级复制到接收方命名空间 (`/Readest/...`)+ 独立 DB row。原 owner 删除自己的书**不影响** 接收方的副本。 - `shareServer.ts` 的 `resolveActiveShare` 检测到 `bookUrl.startsWith('feed://')` 时不要求 `bookFile`(cover 仍可上传),返回 `isFeedBook: true` + descriptor URL - `share/create/route.ts` 在 `bookUrl` 为 feed:// 时跳过 file lookup,直接创建 「无文件分享」 - `share/[token]/download/route.ts` 在 `isFeedBook` 时返回 descriptor JSON 而非文件 redirect - `share/[token]/import/route.ts` 在 `isFeedBook` 时只复制 cover 到接收方 命名空间,返回 descriptor 让接收方客户端重建订阅 **ACL 安全**:owner 删除书籍时,`pages/api/storage/delete.ts` 自动 revoke 该 `(userId, bookHash)` 的所有活跃 BookShare 行(`revokedAt = now()`)。接收方访问 已 revoke 的分享链接时返回 410 `revoked`。接收方已保存的副本不受影响 — 字节级 复制后是独立 owner 的独立 row,原 owner 的 file 删除不会级联到接收方的 file。 ### 7. 分享过期时间 — 永久 + 自定义日历 `ShareBookDialog.tsx` 的「Expires in」选项: - `[1, 3, 7]` 天 — 预设 - `Permanent` (expirationDays=0) — 服务端把 expiresAt 设为 9999-12-31 - `Custom` (expirationDays=-1) — 弹出 `` 日历选择器, 客户端计算天数(1-365)传给服务端 服务端 API 已支持 `expirationDays` 0 (永久) 和 1-365 任意整数。 ### 8. 加密设置同步(v8.18.4 新增) `UserSetting` 表(migration `016_user_settings.sql`)存储每个用户的: - `scope = 'system'` — SystemSettings(KOSync、Readwise、OPDS、proxy 等) - `scope = 'global_view'` — globalViewSettings(字体大小、主题、布局) - `scope = 'global_read'` — globalReadSettings(翻页、自动滚动、TTS 等) - `scope = 'reading_stats'` — ReadingStatsPayload(v8.19.4 新增,见 #9) `encryptedPayload` = base64(JSON(CipherEnvelope)),用 vault key K 加密。 K 是从用户密码 PBKDF2 派生的 AES 密钥,存服务端为 `User.encryptedVaultKey` (用密码派生的 KE 加密 K),登录后客户端解密拿到 K,存内存 `vaultState.ts`。 服务端只存密文,不解密 — 跨设备同步时其他设备 GET 后在客户端解密。 API: - `GET /api/settings?scope=system` — 取最新密文 - `PUT /api/settings?scope=system` — 上传密文(upsert) `ALLOWED_SCOPES = ['system', 'global_view', 'global_read', 'reading_stats']` 是服务端白名单,任何其他 scope 都返回 400。 **客户端推送**:`store/settingsStore.ts` 的 `saveSettings` 调用后,通过 `scheduleEncryptedPush(settings)` 3 秒 debounce 推送(拖滑块时不每个值都推)。 同时调用 `scheduleReadingStatsPush(envConfig)` 10 秒 debounce 推送阅读统计 (统计变化频率低,独立 timer 不阻塞 settings 推送)。 **客户端拉取**:`hooks/useLibrary.ts` 在 library 加载时按顺序拉 system → global_view → global_read → reading_stats,last-writer-wins 合并到本地。 **安全**:服务端无法读取用户设置内容,只做存储转发。Vault key 永远不离开 客户端。改密码时清空 `User.encryptedVaultKey`,强制用户重新设置 vault。 ### 9. 阅读统计加密同步(v8.19.4 新增) **问题**:阅读统计(StatPage 行)此前只通过 KOSync / `/api/sync` 同步最新进度 (一本书一条 row),完整的每页阅读历史(KOReader `page_stat_data`)从未离开 设备。多设备用户切换设备时丢失每页时间数据。 **方案**:复用 v8.18.4 的加密设置同步通道,新增 `scope = 'reading_stats'`。 `services/sync/statsSync.ts` 提供 `pushReadingStats` / `pullReadingStats`: - push:序列化本地 `statistics.db` 的全部 `page_stat_data` 行为 `PageStatEvent[]`,加密后 PUT 到服务端。payload 形如 `{ stats: PageStatEvent[], pushedAt: number }`。 - pull:GET 服务端密文,客户端解密,把事件交给 `StatisticsDb.applyRemoteEvents([], events)`。该函数已实现 last-writer-wins per (bookHash, page, startTime) — 在 INSERT 时 `ON CONFLICT(id_book, page, start_time) DO UPDATE SET duration = max(duration, excluded.duration)`。 **为什么 books 数组传空**:Book 元数据本身已经通过 `/api/sync` 同步,stats 只需要 page 事件。`applyRemoteEvents` 在遇到无 books 记录的事件时调 `ensureBookId(e.bookMd5)` 创建占位书行(title = bookMd5),真实 title 由后续 `/api/sync` 拉取时 upsert 覆盖。 **推送时机**:`store/settingsStore.ts` 的 `saveSettings` 之后调度 `scheduleReadingStatsPush`,10 秒 debounce。10s 是因为统计变化频率远低于 settings — 一页平均看 60 秒,10s debounce 足以合并一连串翻页而不延迟「关书 关闭」太明显。失败时 `console.warn` + 不影响 settings 推送。 **拉取时机**:`hooks/useLibrary.ts` 在 library 加载时拉一次,best-effort, 失败时 `console.warn` 不阻塞 library 加载。`StatisticsDb.open(appService)` 是 per-tab 单例,与 ReadingStatsTracker 共享连接。 ### 10. 批量下载 URL 输入框语法 `RemoteDownloadDialog.tsx` 的「批量下载」tab 没有「Advanced Options」全局 配置。每个 URL 行自带指令:`URL | cookie:VALUE | header:Key: VALUE`。 **绝对不要**为「方便」加回全局 Cookies/Headers 输入框 — 那会让用户混淆 「全局 vs per-URL」。 ### 11. RSS 订阅源封面 — 自动识别站点 favicon `services/rss/favicon.ts` 的 `fetchFeedFavicon()` 按以下顺序查找: 1. 站点 HTML `` 2. ``(任意类型) 3. `` 4. `` 5. `/favicon.ico` 走 `isProxyEnabled()` 时通过 `/api/proxy/resource` 服务端代理获取(绕过 GFW), 超时 5 秒。失败时回退到默认的 RSS 橙色图标,不影响订阅。 `feedBook.ts` 的 `ensureFeedBookCover()` 在生成 SVG 封面时把 favicon 作为 avatar 嵌入。 ### 12. 删除文章批注点击的优雅降级 `BooknoteItem.handleClickItem` 用 try/catch 包住 `goTo(cfi)`。RSS 文章被删除 但批注仍在 config.json 时,跳转失败 → 显示 toast「The highlighted location is no longer available in this book.」而不是让面板崩溃。 ### 13. 阅读统计清除 `DELETE /api/usage/stats` 删除当前用户的 `StatPage` + `UsageStat` 行(需 Bearer auth)。Danger Zone 中的「Clear Reading Statistics」按钮触发此 API。 书籍、进度、批注不受影响。 ### 14. 文件去重 + 引用计数(v8.19.0) **问题**:多用户上传同一本书会占用双倍存储;同一用户重导入同一本书也会 重复存储。 **方案**:`File` 表新增 `contentHash` / `originalFileKey` / `refCount` 三列 (migration `018_file_dedup.sql`)。 - `contentHash`:书籍内容的 hash,去重 key。 - `originalFileKey`:引用文件指向 owner 的 fileKey;owner 自身为 NULL。 - `refCount`:多少用户引用此物理文件。owner = 1,reference = 0(引用自己 不算自己的引用计数,只算 owner 的)。 **上传逻辑**:`storage/upload.ts` 在写入前查 `contentHash` 是否已存在: - 不存在 → 写物理文件 + 创建 owner File row(refCount=1, originalFileKey=NULL) - 已存在且当前用户不是 owner → 不写物理文件,创建 reference File row (refCount=0, originalFileKey=owner.fileKey),原子 bump owner.refCount **删除逻辑**:`storage/delete.ts` / `recycle-bin/clear.ts`: - owner File:decrement refCount,== 0 时物理删除文件 + File row - reference File:decrement owner 的 refCount(owner 可能因此跌到 0), 删自己的 reference File row **Avatar 例外**:avatar 文件用 `fileKey = 'avatar/'`,不参与去重 (contentHash = NULL, originalFileKey = NULL, refCount = 1)。 ### 15. 回收站(v8.19.0) **问题**:用户误删书籍后无法恢复;物理删除太激进。 **方案**:`RecycleBinItem` 表(migration `019_recycle_bin.sql`)。 删除书籍时不立即物理删除,而是: 1. 把 Book.deletedAt 设为 now(软删除) 2. 创建 RecycleBinItem 行,记录 bookHash / bookTitle / bookFormat / fileKey / deletedAt / expiresAt = now + 30 天(默认,`RECYCLE_BIN_EXPIRE_DAYS` 可配) 3. 不删 File 行 — 文件去重保证物理文件不会被误删 **API**: - `GET /api/recycle-bin` — 列出当前用户的回收站条目(先自动清理过期的) - `POST /api/recycle-bin/restore` `{ ids: string[] }` — 恢复:Book.deletedAt 清回 null + 删 RecycleBinItem 行(File 行不动,去重 refCount 也不动) - `POST /api/recycle-bin/clear` `{ ids: string[] } | { all: true }` — 永久 删除:物理删 Book / File 行(按 #14 的去重规则),删 RecycleBinItem 行 **Admin 跨用户**(v8.19.4): - `GET /api/admin/users/[id]/recycle-bin` — 列出目标用户的回收站 - `POST /api/admin/users/[id]/recycle-bin?action=restore` `{ ids: string[] }` - `POST /api/admin/users/[id]/recycle-bin?action=delete` `{ ids: string[] }` **自动清理**:`GET /api/recycle-bin`(user 和 admin)会先 `deleteMany({ where: { expiresAt: { lt: now } } })`,所以前端不需要定时器。 **前端**:`app/user/components/RecycleBinDialog.tsx`(不在 Lite 自定义清单 里 — 沿用上游 UI)。`UserManagement.tsx` 的 UserDetailModal 里也展示回收站 条目(admin 视角)。 ### 16. 角色层级(v8.19.0) `User.role` 字段:`'user'` | `'admin'` | `'super_admin'`。 super_admin 由 `SUPER_ADMIN_EMAIL` 环境变量控制 — 该邮箱的用户在 `validateAdmin` 时被识别为 super_admin,**不**通过 DB role 字段。这样即使 DB 被篡改,攻击者也无法把自己升级为 super_admin。 **`utils/permissions.ts`**: - `isSuperAdmin(user)`:role === 'super_admin' || email === SUPER_ADMIN_EMAIL - `isAdmin(user)`:role === 'admin' || role === 'super_admin' - `canManageUser(current, target)`: - 不能操作自己 - super_admin 可以管理除自己外的所有人,但不能管理其他 super_admin - admin 只能管理 user - `canCreateRole(current, role)`: - 不能创建 super_admin(永远) - 只有 super_admin 能创建 admin - admin 能创建 user **`utils/access.ts`** 的 `validateAdmin` 同时检查 role 和 SUPER_ADMIN_EMAIL, 让 env-controlled super_admin 也能访问 /api/admin/*。 **API 保护**: - `/api/admin/users` (GET / POST):`validateAdmin` - `/api/admin/users/[id]` (PUT / DELETE):`validateAdmin` + `canManageUser` - `/api/admin/users/[id]/books` (GET):`validateUserAndToken` + `isAdmin` - `/api/admin/users/[id]/recycle-bin` (GET / POST):`validateUserAndToken` + `isAdmin` **前端**(`UserManagement.tsx`): - `RoleBadge`:super_admin 金色徽章,admin 蓝色,user 不显示 - `canManageUserClient`:客户端权限判断(与 canManageUser 对应) - `isSuperAdminClient`:基于 currentUser.userRole(Lite AuthUser 含 userRole 字段;上游 Supabase User 不含,TS 类型是 Supabase User 所以用 `unknown` cast 安全读取) - 「Edit」按钮只在 canManage 时显示 - 「Role」选择器只在 super_admin 编辑非 super_admin 时显示 - 不能删除最后一个 admin(API 校验) - 不能降级最后一个 admin(API 校验) ### 17. 头像存储(v8.18.9) **问题**:原版用户头像是 Supabase Storage,Lite 没有对象存储 UI。 **方案**: - `User.avatarUrl` 字段(migration `017_user_avatar.sql` + `020_user_avatar_column.sql`) 存任意 http(s) / data: URL。**拒绝 SVG**(XSS 风险,`