
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-js与aws4fetch依赖。两个阶段先检查后合并合并流程被刻意设计成两个脚本、两步走阶段一只读核验用inspect-accounts.mjs确认两个账号的所有权与数据分布阶段二合并执行先用merge-accounts.mjs的干跑模式生成完整迁移计划并人工审阅确认无误后再加--apply真正执行。三、阶段一inspect-accounts.mjs 只读检查inspect-accounts.mjs的输出分为以下几个部分每部分都服务于合并决策用户解析通过 GoTrue admin 接口GET {url}/auth/v1/admin/users?filteremailper_page50按邮箱精确匹配用户若命中多个会给出警告。脚本还处理了gmail.com↔googlemail.com的别名归一。身份信息输出 user id、邮箱确认状态、创建时间、最后登录时间、app_metadata.providers以及 GoTrue 的 identities 列表可以看到该账号绑定了哪些登录提供方。各表行数books区分 live / deleted、book_configs、book_notes、files区分 live / deleted、stat_books、stat_pages、stat_archives、replicas、replica_keys、book_shares、send_addresses、send_allowed_senders、send_inbox、subscriptions、customers等用于快速判断数据规模。套餐与支付完整 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头发起服务端PUTCopyObject把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 fromIdfile_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 冲突裁决同一主键两边都有books、book_configs、book_notes、stat_books、stat_pages都是用户键控 业务主键的结构例如books以book_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 约束。R2S3 兼容对象存储没有 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.tsbooks以synced_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_shares、send_addresses、send_allowed_senders、send_inbox、subscriptions、customers、payments、stat_archives、replica_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变成负数配额低于免费档且无人钳制。来源账号上留一条合成审计行providerreadest、storage_gb0、statuscompleted、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_gb0且无feature键因此不影响任何求和也不改变isCustomizationPurchase。两个账号都要按 App 自身逻辑重算storage_purchased_bytes对 completed 状态支付行求和让数据库落在应用自己会写入的位置。提示买家来源账号的配额后果来源账号若已在免费档之上使用存储购买权益移走的瞬间即超配额超配额只会阻止新的上传见 upload.ts 的配额检查不会删除任何数据。配额显示依赖 JWT claimsgetStoragePlanData从令牌读取见 utils/access.ts 附近的实现所以两个账号都需要退出登录/重新登录后才会显示新配额。与购买恢复相关的更完整背景iOS 一次性购买丢失的 incident、手动入账配方、服务端安全网演进记录在 apple-iap-lost-storage-purchase-restore-verify.md 中可作延伸阅读。六、合并后的验证与设备端收尾合并执行完毕后原文档强调以下收尾步骤缺一不可验证目标账号数据完整性确认搬来的书籍、配置、笔记、统计、副本均可见验证购买权益与配额确认目标账号的订阅 / 存储扩容未受影响、plans.storage_usage_bytes重算正确干跑阶段的配额预估应在此时兑现单独检查悬空行来源账号残留的 dangling files 行需另行审视应用侧暂无垃圾回收是已知的后续改进点每个设备上退出登录并重新登录目标账号因为 Readest 的AuthContext.logout见 context/AuthContext.tsx会把本地书库保留在磁盘上重新登录后全新登录流程会把完整本地状态推上云端——这样即使有人在合并后、重新登录前于旧账号下写了新进度这些进度也不会丢失刷新配额显示getStoragePlanData从 JWT claims 读取套餐与用量utils/access.ts因此两个账号都只有在 token 刷新或退出/重新登录后才会看到新配额。七、核心事实速查表事实说明源码依据files.file_key格式${user_id}/Readest/Books/hash/nameUNIQUE无 renamemerge-accounts.mjsbooks拉取游标服务端触发器books_set_synced_at打synced_at now()016_add_books_synced_at.sql、sync.tsconfigs/notes/stats 游标updated_at cursor合并需手动 stampnow()sync.ts冲突裁决同主键取updated_at较新者败者留在来源/删除于目标merge-accounts.mjsstorage_usage_bytes livefiles行file_size之和无独立写入器App 靠触发器维护脚本最后按同一口径重算merge-accounts.mjs悬空行来源上传接口先落行、后签 URLPUT 失败即产生pages/api/storage/upload.ts合并不转移购买payments/subscriptions/customers 等 9 张表明确 untouchedmerge-accounts.mjs新配额可见时机需要 token 刷新或退出/重新登录JWT claimsutils/access.ts八、总结一次安全合并的操作清单用inspect-accounts.mjs核验两个账号的所有权书籍标题、支付记录、身份来源并确认两账号无订阅交叉需求若需转移一次性存储扩容按 storage-purchase-account-transfer.md 的配方单独处理该脚本需重建先于或独立于数据合并运行merge-accounts.mjs --from 邮箱 --to 邮箱干跑逐段审阅 R2 复制量、行移动量、冲突裁决、悬空行与配额预估特别留意OVER quota警告确认无误后加--apply执行脚本会严格按复制并校验 → 改行 → 删源 → 重算配额顺序完成合并后逐设备退出登录并重新登录目标账号触发本地书库全量推送与配额刷新最后单独审视来源账号残留的悬空行。这套流程的可靠性建立在两个底层设计之上对象存储层先复制后删源的顺序保证与数据库层按主键取新、按游标通知设备的同步语义。理解这两点就能理解为什么一次合并必须同时处理 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),仅供参考