news 2026/9/20 20:05:33

Readest 账号合并实操指南:基于 merge-accounts.mjs 将同一用户的云端数据归并到单一账号

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Readest 账号合并实操指南:基于 merge-accounts.mjs 将同一用户的云端数据归并到单一账号

Readest 账号合并实操指南:基于 merge-accounts.mjs 将同一用户的云端数据归并到单一账号

【免费下载链接】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 的用户数据(书籍、配置、笔记、统计、副本与 R2 文件)全部按user_id落在云端,当同一用户因历史原因持有多个账号(如早期用 Apple 登录、后来改用 Google 登录)时,数据会被分散在多处。本文以仓库中的 account-merge-recipe.md 记忆文档为骨架,结合scripts/db/下的两个运维脚本与底层同步/存储实现,完整讲解"将同一人的两个账号云端数据合并进其中一个"的通用流程:从只读检查、干跑预览,到 R2 对象复制、数据库行重定向、冲突裁决、配额重算与设备端验证,并说明购买权益为何必须单独转移。读完本文,你可以安全、可复现地完成一次 Readest 账号合并。

一、合并的前提:什么场景才需要账号合并

账号合并(account merge)不是产品内置功能,而是面向同一自然人拥有多个 Readest 账号时的运维补救手段。典型场景包括:

  • 用户早期使用 Apple ID 登录,后来改用 Google OAuth 登录,两套账号各自积累了书籍与笔记;
  • 用户在不同平台(iOS / Android / 桌面)分别用不同邮箱注册;
  • 用户希望把旧账号中的云端书库、阅读配置、阅读统计、书籍副本统一收拢到当前主力账号,之后只维护一个账号。

因此合并的核心语义是:把来源账号(source)的云端数据,迁移进目标账号(destination),两个账号属于同一人。这一点在合并之前必须通过人工核验(见下文"只读检查"),因为它涉及把数据从一个身份名下搬到另一个身份名下。

合并只处理云端数据,不处理购买权益。来源账号的订阅、一次性存储扩容、支付记录等不会随数据合并自动转移,若确需移动购买权益,必须走单独的转移流程(见第五节)。

二、运维脚本概览与运行环境

仓库在scripts/db/下提供了两个配套脚本,均已合入主线(记录于 MERGED #6121,提交64d594380):

脚本路径作用
inspect-accounts.mjsscripts/db/inspect-accounts.mjs只读检查账号:GoTrue 身份、各表行数、套餐与支付、书籍标题(用于所有权核验)、files 行与真实 R2 对象的对账、跨账号重叠情况
merge-accounts.mjsscripts/db/merge-accounts.mjs执行合并:R2 CopyObject 复制并校验大小 → 重定向数据行 → 删除源对象 → 重算双方plans.storage_usage_bytes默认干跑,加--apply才真正写入

两个脚本都是 Node ESM 脚本,运行方式统一为:

node --env-file=.env --env-file=.env.local scripts/db/inspect-accounts.mjs <email> [<email> ...] node --env-file=.env --env-file=.env.local scripts/db/merge-accounts.mjs --from <losing email> --to <surviving email> [--apply]

环境变量要求

脚本从环境变量读取 Supabase 与 R2 凭据(见 merge-accounts.mjs):

变量用途
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64Supabase 项目地址(Base64 编码,脚本内用atob解码)
SUPABASE_ADMIN_KEY管理端密钥,用于 GoTrue admin 接口与表写入
R2_ACCOUNT_IDCloudflare R2 账号 ID,拼接桶端点
R2_BUCKET_NAMER2 存储桶名
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEYR2 S3 兼容 API 凭据
R2_REGION可选,默认auto

缺失任一关键变量时脚本会直接报错退出。此外,脚本必须放在仓库目录内运行(而不是拷到临时目录),因为 ESM 需要从脚本文件自身所在位置解析node_modules中的@supabase/supabase-jsaws4fetch依赖。

两个阶段:先检查,后合并

合并流程被刻意设计成两个脚本、两步走:

  1. 阶段一(只读核验):用inspect-accounts.mjs确认两个账号的所有权与数据分布;
  2. 阶段二(合并执行):先用merge-accounts.mjs的干跑模式生成完整迁移计划并人工审阅,确认无误后再加--apply真正执行。

三、阶段一:inspect-accounts.mjs 只读检查

inspect-accounts.mjs的输出分为以下几个部分,每部分都服务于合并决策:

  • 用户解析:通过 GoTrue admin 接口GET {url}/auth/v1/admin/users?filter=<email>&per_page=50按邮箱精确匹配用户;若命中多个会给出警告。脚本还处理了gmail.comgooglemail.com的别名归一。
  • 身份信息:输出 user id、邮箱确认状态、创建时间、最后登录时间、app_metadata.providers,以及 GoTrue 的 identities 列表(可以看到该账号绑定了哪些登录提供方)。
  • 各表行数books(区分 live / deleted)、book_configsbook_notesfiles(区分 live / deleted)、stat_booksstat_pagesstat_archivesreplicasreplica_keysbook_sharessend_addressessend_allowed_senderssend_inboxsubscriptionscustomers等,用于快速判断数据规模。
  • 套餐与支付:完整 dumpplans行(含各*_bytes字段的易读格式化)以及payments列表(provider、product_id、storage_gb、status、金额、Apple 原始交易号 / Google 购买 token),用于核验购买权益归属
  • 书籍样本:按updated_at倒序展示至多 25 本在库书籍的标题、作者、格式与是否已上传,用于人工确认"这两个账号确实是同一个人的书"
  • 文件与 R2 对账:列出files表 live 行数与占用字节,并列出user_id/前缀下的真实 R2 对象;特别报告两类异常——"有 live files 行但 R2 中无对象"(即悬空行 dangling)与"有 R2 对象但无对应 live 行"(孤儿对象),这两类数据是合并决策的关键输入。
  • 跨账号重叠:当传入 ≥2 个邮箱时,脚本最后会输出两个账号间共同的book_hash相同的相对 file key,这正是合并时冲突裁决需要处理的对象。

inspect-accounts.mjs全程只读、绝不写库,可以放心用于工单场景的排查与人工核验。

四、阶段二:merge-accounts.mjs 合并执行

4.1 命令参数与默认行为

node --env-file=.env --env-file=.env.local scripts/db/merge-accounts.mjs \ --from <来源邮箱> --to <目标邮箱> [--apply]
  • --from失去数据的账号(合并后被腾空的来源),小写归一;
  • --to保留数据的账号(合并后的幸存者);
  • 不带--apply时默认干跑:完整打印"将发生什么"并不触碰任何数据
  • --apply时执行四步真实写入。
  • --from/--to缺失或两者相同,脚本打印 usage 并退出。

4.2 合并的四个执行步骤

--apply模式下脚本严格按照以下顺序执行(见 merge-accounts.mjs):

第 1 步:R2 对象复制 + 大小校验。对计划内每一个文件对象,用 S3 的x-amz-copy-source头发起服务端PUT(CopyObject),把<fromId>/…前缀下的对象复制为<toId>/…前缀下的对象;随后对目标键发起HEAD请求,校验content-length与源对象记录的大小完全一致,不一致即中止(ABORT)。每复制 20 个对象打印一次进度。必须先复制并校验、后改行,确保任何一步失败都不会留下"行指向不存在对象"的中间态。

第 2 步:重定向数据库行。将来源账号名下各用户键控表的行user_id改为目标账号,顺序与策略如下:

  • files:逐行UPDATE files SET user_id = <toId>, file_key = <dest> WHERE id = … AND user_id = <fromId>file_key前缀同步改写为目标前缀;
  • books:按book_hash冲突裁决后批量UPDATE … SET user_id = <toId> … WHERE user_id = <fromId> AND book_hash IN (…)(每 200 行一批,利用单列主键批量更新);若移动的书籍行标记了uploaded_at但其真实(非封面)文件没有跟随移动(悬空行),则额外把这些行的uploaded_at清空,让幸存账号可以重新上传该书;
  • book_configs/book_notes/stat_books/stat_pages:同样重定向user_id并且额外把updated_at打上当前时间戳(原因见第 4.4 节"时间戳刷新");
  • replicas:按(kind, replica_id)判重后逐行重定向。

脚本会对每张表校验"实际更新行数 == 计划移动行数",不一致立即中止,防止静默丢数据。

第 3 步:删除来源对象。仅当第 2 步的行已全部指向副本后,才对每个来源键发起DELETE;返回 404 视为"已不存在"而放行,其余非 2xx 仅告警不致命。顺序保证"先复制 → 后删源"。

第 4 步:重算双方存储用量。对两个账号分别执行SUM(file_size) over live files rows并回写plans.storage_usage_bytes(与 App 自身维护该字段的方式一致,见第 4.5 节)。

4.3 冲突裁决:同一主键两边都有

booksbook_configsbook_notesstat_booksstat_pages都是"用户键控 + 业务主键"的结构(例如booksbook_hash为主键、book_notes(book_hash, id)为主键、stat_pages(book_hash, page, start_time)为主键)。合并时若同一主键在两边都存在,裁决规则是updated_at较新者胜出(见 merge-accounts.mjs):

  • 目标侧无此键 → 直接移动来源行;
  • 双方都有且来源行updated_at更新→ 移动来源行,并先删除目标侧旧行再写入,避免主键冲突(applyKeyed中先delete()update());
  • 双方都有且目标行updated_at更新或相等→ 来源行放弃,留在来源账号不动(也不删除),干跑报告中会逐条列出leave … (target newer or equal …)

这套规则保证:同一本书在两账号各自有阅读进度时,取最后一次修改的那份;而不会产生覆盖式丢数据。

4.4 为什么 files 必须"复制 + 改键",而不是改 user_id

合并最反直觉的一点是:files 表不能只改user_id。原因是files.file_key的格式是:

${user_id}/Readest/Books/<hash>/<name>

即对象键内嵌了 user_id 前缀,且file_key是 UNIQUE 约束。R2(S3 兼容对象存储)没有 rename 操作,所以一次合并本质上等于"R2 真实复制一份 + 数据库行键重写"两步,而不是一次简单的user_id更新。这也正是第 1 步必须真实 CopyObject 的原因。

合并前脚本还会把文件划分为四类(见 merge-accounts.mjs):

类别判定处理
move源对象存在、目标键不存在复制 + 改行 + 删源
dangling有 live files 行但 R2 中无对应对象不移动,仅报告
collide目标侧已有相同file_key(行或对象)跳过,仅报告
badPrefix行内file_key不以来源user_id/开头跳过,仅报告

另外,来源前缀下存在但没有任何 livefiles行引用的孤儿对象会被原样留在来源账号(不删),干跑报告中单独列出。

4.5 悬空行(dangling rows)为什么普遍存在

files悬空行的根源在 pages/api/storage/upload.ts:该接口在返回预签名上传 URL之前就先把files行插入数据库(INSERT … file_key, file_size, book_hash …),随后才签发签名 URL。一旦客户端拿到 URL 后 PUT 上传失败——超配额、断网、进程被杀——就会留下"有行、无对象"的悬空行。

// pages/api/storage/upload.ts:先落行、再签发 URL const { data: inserted, error: insertError } = await supabase .from('files') .insert([{ user_id: user.id, book_hash, file_key: fileKey, file_size: fileSize, ... }]) .select().single(); // … 之后才 getUploadSignedUrl(fileKey, objSize, 1800)

因此合并脚本的策略是:悬空行不移动,因为它们只是过期的上传占位,搬过去只会虚增目标账号的配额占用。这是合并后唯一允许残留在来源账号的东西。原文档也明确指出:应用侧目前没有任何垃圾回收机制清理悬空行,这是一个值得后续跟进(follow-up)的改进点。

4.6 时间戳刷新:为什么 books 与 configs/notes/stats 处理不同

Readest 的拉取(pull)机制对两类表用了不同的游标,这决定了合并脚本必须区别对待(源码依据见 pages/api/sync.ts):

  • bookssynced_at为游标synced_at由数据库的books_set_synced_at触发器(BEFORE INSERT/UPDATE)强制打上now()(迁移见 016_add_books_synced_at.sql)。因此合并时只需把books.user_id改指向目标账号,触发器会自动刷新synced_at,目标账号下所有已登录设备下一次拉取就会收到这些书——无需手动改时间戳。
  • book_configs/book_notes/stat_*updated_at > cursor为游标:如果这些行搬过去后仍保留updated_at,那么一个"已经登录在目标账号、游标已推进"的设备将永远看不到这些被搬来的行(旧时间戳 < 游标)。所以合并脚本对 configs / notes / stats 统一打上updated_at = now():对 stats 而言这本来就是服务端推送会做的事,对 configs / notes 而言只是"再次确认那条已经胜出的行",让所有目标设备都能拉到。

4.7 干跑报告解读

无论是否--apply,脚本都会先打印完整的合并计划,关键段落包括:

  • R2 对象段:copy N objects, X MB (<fromId>/… -> <toId>/…),以及各跳过类别计数;
  • 各键控表段:<table>: move N of M rows,逐条列出"替换目标行(来源更新)"与"放弃(目标更新或相等)";
  • files: move N of M live rows (rewrite file_key prefix)
  • replicas: move N of M rows
  • 未触碰表(untouched)计数:book_sharessend_addressessend_allowed_senderssend_inboxsubscriptionscustomerspaymentsstat_archivesreplica_keys
  • 配额预估段:plans.storage_usage_bytes双方迁移前后对比,其中目标配额按500 MB 免费额度 + storage_purchased_bytes计算(500 * 1024 * 1024 + Number(toPlan.storage_purchased_bytes || 0)),若合并后目标会超配额,会打印! target would be OVER quota after the merge警告
  • 结尾固定输出:Dry run only. Re-run with --apply to perform the merge.

干跑是免费的"预演",任何实际合并都必须先审阅这份报告再决定是否--apply

五、购买权益的单独处理:storage-purchase-account-transfer

合并数据不会转移任何购买权益。payments/subscriptions/customers/plans中的购买字段均被merge-accounts.mjs明确列为"未触碰"(untouched)。若来源账号上的一次性存储扩容(storage purchase)需要随用户转移到目标账号,必须走另一条记忆文档记录的单独流程——见 storage-purchase-account-transfer.md。

该流程的核心要点(与合并配合使用):

  • 曾有一个专门的scripts/db/transfer-storage-purchase.mjs,但从未提交,已不存在;需要时可依据该记忆文档从零重建,参数风格与inspect-accounts.mjs/merge-accounts.mjs一致(--from/--to邮箱、默认干跑、--apply写入)。
  • 正确的做法是"重分配商店行":把来源账号上那笔payments行的归属改为目标账号(去重键apple_original_transaction_id/google_purchase_token必须跟随权益走),而不是"给目标 +N、给来源 -N"的合成记账。因为若商店行留在来源:Restore Purchases 重验会按 product_id 重写status/storage_gb,被置refunded/清零的行会被静默重新入账;而 -N 合成行在 Apple 退款使真实行退出 completed 状态后,会让来源的storage_purchased_bytes变成负数,配额低于免费档且无人钳制。
  • 来源账号上留一条合成审计行provider='readest'storage_gb=0status='completed'、metadata 记录{manual_transfer, transfer:'out', transferred_storage_gb, transferred_payment_id, transferred_to_user_id/email, transferred_at, reason};被搬走的行则打上{manual_transfer, transferred_from_user_id/email, transferred_at, reason}storage_gb=0且无feature键,因此不影响任何求和也不改变isCustomizationPurchase
  • 两个账号都要按 App 自身逻辑重算storage_purchased_bytes(对 completed 状态支付行求和),让数据库落在应用自己会写入的位置。
  • 提示买家来源账号的配额后果:来源账号若已在免费档之上使用存储,购买权益移走的瞬间即超配额;超配额只会阻止新的上传(见 upload.ts 的配额检查),不会删除任何数据。
  • 配额显示依赖 JWT claims(getStoragePlanData从令牌读取,见 utils/access.ts 附近的实现),所以两个账号都需要退出登录/重新登录后才会显示新配额。

与购买恢复相关的更完整背景(iOS 一次性购买丢失的 incident、手动入账配方、服务端安全网演进)记录在 apple-iap-lost-storage-purchase-restore-verify.md 中,可作延伸阅读。

六、合并后的验证与设备端收尾

合并执行完毕后,原文档强调以下收尾步骤,缺一不可:

  1. 验证目标账号数据完整性:确认搬来的书籍、配置、笔记、统计、副本均可见;
  2. 验证购买权益与配额:确认目标账号的订阅 / 存储扩容未受影响、plans.storage_usage_bytes重算正确(干跑阶段的配额预估应在此时兑现);
  3. 单独检查悬空行:来源账号残留的 dangling files 行需另行审视(应用侧暂无垃圾回收,是已知的后续改进点);
  4. 每个设备上退出登录并重新登录目标账号:因为 Readest 的AuthContext.logout(见 context/AuthContext.tsx)会把本地书库保留在磁盘上,重新登录后全新登录流程会把完整本地状态推上云端——这样即使有人在合并后、重新登录前于旧账号下写了新进度,这些进度也不会丢失;
  5. 刷新配额显示getStoragePlanData从 JWT claims 读取套餐与用量(utils/access.ts),因此两个账号都只有在 token 刷新或退出/重新登录后才会看到新配额。

七、核心事实速查表

事实说明源码依据
files.file_key格式${user_id}/Readest/Books/<hash>/<name>,UNIQUE,无 renamemerge-accounts.mjs
books拉取游标服务端触发器books_set_synced_atsynced_at = now()016_add_books_synced_at.sql、sync.ts
configs/notes/stats 游标updated_at > cursor,合并需手动 stampnow()sync.ts
冲突裁决同主键取updated_at较新者,败者留在来源/删除于目标merge-accounts.mjs
storage_usage_bytes= livefilesfile_size之和,无独立写入器,App 靠触发器维护,脚本最后按同一口径重算merge-accounts.mjs
悬空行来源上传接口先落行、后签 URL,PUT 失败即产生pages/api/storage/upload.ts
合并不转移购买payments/subscriptions/customers 等 9 张表明确 untouchedmerge-accounts.mjs
新配额可见时机需要 token 刷新或退出/重新登录(JWT claims)utils/access.ts

八、总结:一次安全合并的操作清单

  1. inspect-accounts.mjs核验两个账号的所有权(书籍标题、支付记录、身份来源),并确认两账号无订阅交叉需求;
  2. 若需转移一次性存储扩容,按 storage-purchase-account-transfer.md 的配方单独处理(该脚本需重建),先于或独立于数据合并;
  3. 运行merge-accounts.mjs --from <邮箱> --to <邮箱>干跑,逐段审阅 R2 复制量、行移动量、冲突裁决、悬空行与配额预估,特别留意OVER quota警告;
  4. 确认无误后加--apply执行,脚本会严格按"复制并校验 → 改行 → 删源 → 重算配额"顺序完成;
  5. 合并后逐设备退出登录并重新登录目标账号,触发本地书库全量推送与配额刷新;最后单独审视来源账号残留的悬空行。

这套流程的可靠性建立在两个底层设计之上:对象存储层"先复制后删源"的顺序保证,与数据库层"按主键取新、按游标通知设备"的同步语义。理解这两点,就能理解为什么一次合并必须同时处理 R2 键、行归属和时间戳三个维度,也就能在遇到新的账号迁移需求时,安全地复现并扩展这一运维方案。

【免费下载链接】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

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 20:03:11

Lucky 部署与功能实操指南:端口转发、DDNS 与反向代理配置

Lucky 部署与功能实操指南&#xff1a;端口转发、DDNS 与反向代理配置 【免费下载链接】lucky 软硬路由公网神器,ipv6/ipv4 端口转发,反向代理,DDNS,WOL,ipv4 stun内网穿透,cron,acme,rclone,ftp,webdav,filebrowser 项目地址: https://gitcode.com/GitHub_Trending/luc/luck…

作者头像 李华
网站建设 2026/9/20 19:56:55

嵌入式AI与TinyML:MCU上的传感器本地推理实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 19:53:56

不部署向量数据库,Java 向量搜索怎么用 sqlite-vec 跑起来?

不部署向量数据库&#xff0c;Java 向量搜索怎么用 sqlite-vec 跑起来&#xff1f; 【免费下载链接】sqlite-vec A vector search SQLite extension that runs anywhere! 项目地址: https://gitcode.com/GitHub_Trending/sq/sqlite-vec 写给正在评估向量搜索方案的 Java…

作者头像 李华