Readest 跨设备同步修复实录:fileless 的 RSS 订阅书如何绕过 uploadedAt 门控(Issue 5307)

发布时间:2026/9/21 15:27:07
Readest 跨设备同步修复实录:fileless 的 RSS 订阅书如何绕过 uploadedAt 门控(Issue 5307) 桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载本文基于 Readest 仓库中的问题记忆文档 rss-feed-books-not-syncing-5307.md 展开结合services/rss、services/cloudService.ts、app/library/hooks/useBooksSync.ts等源码与配套测试完整还原 #5307 从现象、根因、修复到回归测试的排查链路。读完你将理解Readest 中无文件书籍fileless book的数据模型是什么、为什么 RSS 订阅永远无法同步到第二台设备、uploadedAt这个同步门控字段的真实语义以及isFeedBook谓词在哪些环节被接入才能让订阅真正跨设备可见。问题现象订阅只在添加它的浏览器里出现Issue #5307 报告于 web.readest.com 0.11.20复现条件非常简单同一账号登录两台笔记本浏览器在其中一台添加 RSS 订阅随后观察到两个症状手动触发上传到云upload to cloud立即失败另一台浏览器的书架上始终看不到这条订阅。表面上是两个独立故障实际排查后确认是同一个根因RSS 订阅在数据层被建模为一条没有文件的书籍记录而同步链路的多处逻辑默认书籍必须有文件于是订阅在源头无法上传、在目标端被静默丢弃。数据模型feed 订阅是一条无文件书籍记录Readest 的 RSS 订阅并不像普通电子书那样下载 EPUB 文件存入Books/hash/目录。查看 feedBook.ts 中createFeedBook的实现export function createFeedBook(feedUrl: string, parsed: ParsedFeed): Book { const now Date.now(); return { hash: feedBookHash(feedUrl), url: buildFeedBookUrl(feedUrl), format: EPUB, title: parsed.title, author: , metadata: { title: parsed.title, author: , language: , feedUrl }, createdAt: now, updatedAt: now, downloadedAt: now, uploadedAt: null, deletedAt: null, }; }关键特征一目了然url不是真实文件路径而是feed://协议描述符descriptormetadata.feedUrl携带真正的订阅地址uploadedAt: null、downloadedAt: now——即已下载生成但从未上传没有任何 blob 文件落盘全书库中找不到这条订阅对应的物理文件。feed://描述符的编解码在 feedBookUrl.tsexport const FEED_SCHEME feed://; export const isFeedBookUrl (url: string): boolean url.startsWith(FEED_SCHEME); export const buildFeedBookUrl (feedUrl: string): string FEED_SCHEME encodeURIComponent(JSON.stringify({ feedUrl })); export const parseFeedBookUrl (url: string): { feedUrl: string } JSON.parse(decodeURIComponent(url.replace(FEED_SCHEME, )));即feed://后跟 URL 编码的{feedUrl:https://...}JSON。feedBookHash则对该描述符做 md5 得到书籍 hash保证同一 feed 在任何设备上生成的 hash 一致feedBook.ts。正是因为没有物理文件任何一台设备打开该订阅时readerStore都是根据 feed URL 现场重建书籍内容而对端同步拉取时transformBookFromDButils/transform.ts也会用metadata.feedUrl重新水合rehydrate出book.url。文件可以不存在但描述符必须在。根因拆解上传抛错 拉取被门控 订阅永远留在原设备第一步上传直接抛错同步失败只是表象用户看到的上传到云失败源于 cloudService.ts 的uploadBooklet bookSource await resolveBookContentSource(fs, book); // ...url 分支先把外部文件拷进 Books/ 再转成 managed... if (!isBookFileContentSource(bookSource)) { throw new Error(Book file not uploaded); }resolveBookContentSourcebookContent.ts按优先级探测书籍内容来源优先Books/hash/下的受管副本其次book.filePath外部文件、content://URI最后看book.url。而book.url是feed://开头时返回的是if (isFeedBookUrl(book.url)) { return { kind: feed, path: book.url, base: None }; }BookContentSource联合类型里明确存在{ kind: feed }这一分支bookContent.ts但isBookFileContentSource只认managed | external | url三种export function isBookFileContentSource( source: BookContentSource, ): source is BookFileContentSource { return source.kind managed || source.kind external || source.kind url; }于是feed类型被判定为非文件内容源uploadBook抛出Book file not uploaded同步队列里的上传任务失败——这就是界面上可见的同步失败。而uploadedAt因为上传从未成功永远保持null。第二步元数据其实已经上云但对端不肯接单有意思的是订阅的元数据行其实已经成功同步到了云端推送侧useBooksSync.getNewBooks只看syncedAt/updatedAt与uploadedAt无关订阅行照常进入推送队列见 useBooksSync.ts服务端拉取接口也没有uploaded_at过滤。真正卡住订阅的是拉取侧的采纳门控。useBooksSync.updateLibrary处理从云端拉回的新书时过滤条件是useBooksSync.tsconst newBooks cloudBooks.filter( (newBook) !bookHashesInLibrary.has(newBook.hash) (newBook.uploadedAt || isFeedBook(newBook) || isAudiobook(newBook)) !newBook.deletedAt, );这个newBook.uploadedAt检查是一个有意的保护普通书如果没有上传过文件对端即使收到了元数据行也拿不到文件本体把这样的行放进书架只会产生一本打不开的书。然而 feed 订阅恰好不需要文件——内容可以从metadata.feedUrl重建。保护逻辑对普通书成立却把 RSS 订阅错杀了订阅被静默丢弃永远不会出现在除添加设备以外的任何设备上。一句话总结根因上传侧因 feed 无文件而必然失败 →uploadedAt永远为 null → 拉取侧的必须有文件才采纳门控把无文件的订阅误判为不可获取 → 订阅数据到了云端却落不了地。修复方案isFeedBook 谓词 全链路接入点修复的核心是一个新谓词isFeedBook(book)feedBookUrl.tsexport const isFeedBook (book: { url?: string }): boolean !!book.url isFeedBookUrl(book.url);它的语义很明确这是一条内容源自feed://描述符、无需物理文件的书籍。随后把它接入所有基于书籍文件做决策的地方——记忆文档称之为the places that reason about a books FILE。同步修复点核心updateLibrary的新书过滤改为(newBook.uploadedAt || isFeedBook(newBook))让无文件的 feed 订阅绕过uploadedAt门控useBooksSync.ts。同时processNewBook里对 feed 书走专门的封面处理分支newBook.coverImageUrl appService isFeedBook(newBook) ? await ensureFeedBookCover(appService, newBook) : await appService?.generateCoverImageUrl(newBook);封面本地再生ensureFeedBookCover普通新书的封面从云端下载downloadBookCovers但 feed 书的uploadBook从未运行云端根本没有它的封面文件。因此新增ensureFeedBookCover从handleAddFeedSubmit中原有的封面逻辑抽取对端根据feedUrl title在本地重新生成完全一致的封面feedBook.tsexport async function ensureFeedBookCover( appService: FeedCoverWriter, book: Book, ): Promisestring | undefined { try { const feedUrl book.metadata?.feedUrl ?? (book.url ? parseFeedBookUrl(book.url).feedUrl : ); const coverFilename getCoverFilename(book); if (!(await appService.exists(coverFilename, Books))) { const pngBytes await rasterizeCoverSvg(generateFeedCoverSvg(feedUrl, book.title)); await appService.createDir(book.hash, Books, true); await appService.writeFile(coverFilename, Books, pngBytes); } return await appService.generateCoverImageUrl(book); } catch (e) { console.warn(Failed to generate feed book cover:, e); return undefined; } }封面由generateFeedCoverSvg生成经典 RSS 橙色图标 站点名 订阅标题其中站点名取自new URL(feedUrl).hostname去www.前缀feedBook.ts。由于输入只有feedUrl和title任何设备生成的封面字节一致天然满足跨设备一致性随后rasterizeCoverSvg把 SVG 先画到 canvas 再导出 PNG——源码注释解释了原因书架封面存储在Books/hash/cover.png而 Android WebView 不会渲染以 .png 扩展名提供的 SVG 字节feedBook.ts。该函数是 best-effort 的失败时退回书架仅标题的兜底封面不影响订阅本身。文件操作入口全面收口凡是可能对 feed 书发起下载/上传/分享的界面与逻辑全部用isFeedBook屏蔽否则用户会看到注定失败的操作从源码可以确认以下接入点位置改动意图useBooksSync.ts新书过滤 封面本地再生同步修复本体libraryUtils.tsgetBookContextMenuItemIds不再对 feed 书提供下载/上传/分享菜单项BookItem.tsx书架云状态徽章不再展示待上传/需下载等误导状态TransferQueuePanel.tsxUpload All 批量上传排除 feed 书BookDetailView.tsx详情页上传按钮对 feed 书隐藏记忆文档中还提到getBookContextMenuItemIds的修改no download/upload/share在 libraryUtils.ts 附近有对应判断。被否决的替代方案为什么不用更简单的做法排查过程中提出过两个更省事的候选方案均被否决理解它们的缺陷有助于避免未来踩坑创建时直接盖章uploadedAt行不通。uploadedAt的语义是文件已在云端就绪的时间点对端看到它就会认为可以下载文件进而给用户显示下载入口、去抓取一个根本不存在的文件——制造出新的坏体验。把downloadedAt置为 null 代替过于隐晦downloadedAt与文件有无并无直接关系而且无法修复已经在用户书库中存在的存量 feed 书——那些行早已生成改动创建逻辑对它们无效。相比之下显式的isFeedBook谓词语义清晰、可覆盖存量数据是正确解。回归测试五种行为被固化修复伴随的测试在 feed-books-sync.test.tsx用renderHook 模拟的syncBooks/ensureFeedBookCover覆盖了完整行为矩阵无uploadedAt的 feed 书也能被采纳上架核心回归订阅跨设备可见feed 书的封面走ensureFeedBookCover本地再生而非云端下载且封面 URL 正确写入书架普通书仍走generateCoverImageUrl云端封面路径不受影响普通书没有uploadedAt时依旧被跳过——原保护逻辑对普通书保持原样修复没有放宽文件书的安全性pushLibrary会把 feed 书推送到云端——元数据行确实上云只是文件不上传。配合 feedBookUrl.test.ts 对feed://描述符的 round-trip 与负例pse://、https://不被误判为 feed测试谓词本身的正确性也有保障。遗留问题与后续工作记忆文档明确标注了本次修复没有覆盖的边界与已知问题文件同步后端优雅降级在 WebDAV / Drive / S3 等文件同步后端下feed 书的pushBookFile会解析出no-source并跳过不会报错——这是符合预期的降级行为无需处理分享对话框本就安全ShareBookDialog的shareEnabled以fileSize ! null门控feed 书天然不满足不会出现无效分享入口疑似死代码FeedsView与feedStore对应Settings/feeds.json经排查可能是死代码——handleShowFeeds打开的是AddFeedModal没有任何地方设置showFeeds因此订阅的逐条已读状态实际上根本没有跨设备同步。这是一个已知但未在本 issue 中修复的缺口关联 issue该问题与opds-catalog-reincarnate-restart-5180OPDS 目录相关、demo-books-cloud-sync-5049demo 书不上云见 useBooksSync.ts 中isDemoBook过滤同属同步链路对特殊书型考虑不足这一类问题。结语#5307 是一次典型的数据模型与同步门控语义冲突问题feed://无文件书籍挑战了书籍文件的默认假设uploadedAt门控虽然保护了普通书不被孤儿上架却误伤了本不需要文件的 RSS 订阅。修复没有削弱原有保护普通书仍需uploadedAt才能被采纳而是为无文件书型打开显式豁免通道并把所有围绕文件做决策的 UI 入口一并收口最终以 5 条回归测试固化行为。对于阅读本文的读者这条排查链路的价值在于当同步系统中出现元数据已到、文件未到的异常状态时先检查采纳侧的门控字段与特殊书型的数据模型是否冲突再决定是放宽门控还是新增显式谓词。相关源码与文档索引问题记忆 rss-feed-books-not-syncing-5307.md、描述符与谓词 feedBookUrl.ts、订阅书构造与封面 feedBook.ts、同步主逻辑 useBooksSync.ts、内容源解析 bookContent.ts、上传入口 cloudService.ts、回归测试 feed-books-sync.test.tsx。赞分享桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载相关推荐Readest 同步类别门控与捆绑式设置副本字典偏好为何会绕过 Dictionaries 开关5465 修复实录Readest 同步类别门控与捆绑式设置副本字典偏好为何会绕过 Dictionaries 开关 5465 修复实录 导读 本文围绕 Readest桌面应用跨平台前端跨平台阅读革命Readest如何实现全设备无缝同步跨平台阅读革命Readest如何实现全设备无缝同步 在数字阅读时代读者常常面临一个痛点在手机上读到一半的电子书切换到平板或电脑时不仅需要重新寻找文件桌面应用跨平台前端Readest 跨设备同步故障修复实录从云同步 Provider 选择到 CRDT 冲突合并的工程实践Readest 跨设备同步故障修复实录从云同步 Provider 选择到 CRDT 冲突合并的工程实践 导读 本文基于 Readest 开源仓库中沉淀的同步问桌面应用跨平台前端上一篇Claude HUD重新定义Claude Code开发体验的实时状态监控系统下一篇Tolaria 原始编辑器语法高亮实践以 codemirror/lang-markdown 替代正则装饰ADR-0037创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考