# App 注销与缓存归属独立复审

日期：2026-10-03  
范围：`juku-mobile` 当前工作树。  
方式：只读源码复审、隔离内存 store 复现、已有测试与 TypeScript 检查；没有改业务源码、删除设备文件、连接真实 API、运行 Android/iOS 真机或发布。

本仓库没有发现适用于 `juku-mobile` 的 `AGENTS.md`。复审覆盖 `offlineOwnership.ts`、`store/downloads.ts`、`files.ts`、`queries/playback.ts`、`auth/ConsumerAuthGate.tsx`、`auth/client.ts`、新增 `auth/deletion.ts` 与 `screens/DeleteAccountScreen.tsx`，以及 Downloads、Drama、LocalPlayer、My、Account、Settings 页面；为检查全局旧 cache 读取，补看了 `SettingsScreen.tsx`、`App.tsx`、viewer scope 和 progress journal。

## 结论

没有发现当前可由现有 UI/下载入口触发的 P1。owner-scoped 基础对“已存在且带 owner/authorization/access 的记录”已经形成实际播放和删除门禁；A 的清理只按 A 的 `origin + userId` 处理，失败删除会先锁定记录，不能继续本地播放。旧 ownerless 记录在迁移后保留为 `legacy_locked`，只有单独的旧缓存确认入口会尝试清理。

本轮确认了三项旧报告发现已关闭：Settings 摘要已按 owner 过滤；记录 hydration、`add` 和 `removeLocalDownload` 已按 owner/URI 隔离；LocalPlayer 的长过期 timer 会按剩余期限重新排程。联合夹具现在会等待 Go 子进程并要求退出码为 0；修复测试 fixture 的完整剧集元数据后，最新读取到的 Go 日志以 `ok juku/internal/app 11.693s` 结束，联合结果为 29/29 通过。此前 `GET hongguoduanju.com/detail` 的失败是修复前 fixture 运行历史，不是当前联合结论。

新增 RN 注销流程本轮没有发现当前 P1/P2。它复用官方 Better Auth 1.7.7 Expo 客户端和官方 `$fetch`，要求 OTP 后的新同 owner session，`deleteUser` 未知结果只 GET 原 operation status，不重复 POST；MMKV 收据不含邮箱、OTP、cookie 或 bearer token。上轮报告的两个注销恢复 P2 已由当前代码和新测试关闭：收据现在按 `origin + userId` 保留多条记录，清理还精确匹配 `operationId`；收据删除异常会变成可重试的 `deletion_recovery_unavailable`，不会丢掉终态记录或重发删除 POST。

另有一个需真实 Go+PG+Better Auth 联调确认的条件边界：`account_terminal` 从 session GET 返回时，当前 RN 会在本机清理前报错；现有夹具只覆盖 null/same/foreign。它不作为本轮已确认 P2 或新发布 gate。

## 已验证的 owner 和清理边界

### owner 归属和播放

- `src/playback/offlineOwnership.ts:6-13` 只接受规范 origin 和非空 user id；`sameDownloadOwner` 在 `:15-16` 同时比较 origin 与 user id。
- `downloadAccessible` 在 `:23-24` 同时要求 `access === 'ready'`、owner 完全相同，以及 `permitId/orderId/assetId/deviceId` 均为非空字符串、`expiresAt` 晚于当前时间。这里是元数据门禁，不是设备证明或真实离线许可证验证；文件首行和 `src/screens/LocalPlayerScreen.tsx:23-26` 都明确保留了 O00/O01 边界。
- `src/store/downloads.ts:59-72` 在持久化恢复时确认文件仍存在；没有 owner/authorization/access 的旧记录被改为 `legacy_locked`，不会猜当前账号。`useDramaDownloads` 在 `:138-144`、`useDownloadedIndexes` 在 `src/queries/playback.ts:214-221` 都要求 owner、authorization、有效期和文件存在。
- `src/screens/LocalPlayerScreen.tsx:28-48` 对 route record 和切集 sibling 都再次检查当前 owner、授权有效期和文件存在；失效或换账号时 `active` 变空并在 `:89-98` 返回锁定/登录状态，未把 URI 交给 `VideoStage`。
- `src/screens/DramaScreen.tsx:87-112` 已缓存分集只导航到本地播放器；联网续播先读取 owner 绑定的状态并在 `:107` 再核对当前 viewer。下载按钮在 `:114-135` 先要求 linked identity；`src/queries/playback.ts:124-134` 再以官方 `getConsumerSession` 读取会话，随后无条件抛出 `offline_authorization_required`，没有创建文件、任务、扣账或把旧 viewer 下载 URL 当许可发行器。

### A/B 清理和失败删除

- `clearOwnerDownloads` 在 `src/store/downloads.ts:159-168` 对空 owner 不做全局清空；先 `lockOwner`，再只枚举 `sameDownloadOwner` 的记录，最后只丢弃同 owner 的任务。
- `removeLocalDownload` 在 `src/store/downloads.ts:147-155` 先把记录标成 `cleanup_pending`，`deleteMediaFile` 失败就返回 false、保留记录和锁定状态；成功后才移除记录和任务。因而失败删除不能继续通过 `downloadAccessible` 播放。
- `src/auth/ConsumerAuthGate.tsx:13-24` 把旧 owner 传给本机下载清理、progress 清理和查询清理。只有当前 viewer 没有 owner 或仍是传入 owner 时，`:17-21` 才 `forgetViewer` 并移除旧 viewer 查询；延迟 A 清理在 B 已成为当前 viewer 时不会清掉 B bridge/query。
- 单独隔离复现（不写设备、不改源码）以 A/B/ownerless 三条记录运行 `clearOwnerDownloads(A)`，结果只删除 A，B 和 ownerless 保留；把文件删除替代为失败后，A 记录变为 `cleanup_pending`，`downloadAccessible` 返回 false，随后显式重试成功才移除。仓库已有 `tests/download-owner.test.cjs` 的对应检查也覆盖这些事实。

### URI 清理范围

`src/files.ts:63-74` 的 `deleteMediaFile` 只接受 `mediaDirectory().uri + '/'` 下的单层 child：先做 root 前缀检查，再 decode 一次并拒绝空值、`.`/`..`、斜杠、反斜杠、百分号和控制字符；删除后再次确认 `file.exists` 为 false。隔离测试覆盖了外部目录、普通 `..`、编码 traversal、双重编码、删除未生效和文件已不存在的情况。清理不会按 arbitrary URI 删除媒体目录外的文件；旧 ownerless 文件只有用户明确点 legacy 清理时才进入该函数。

## 已关闭的旧发现

### Settings 摘要隔离

`src/screens/SettingsScreen.tsx:32-40,80-84` 现在通过 `downloadOwner` 与 `sameDownloadOwner` 只汇总当前 owner；旧 SettingsScreen 也不是四栏当前用户直接注册的页面，`src/navigation/RootNavigator.tsx:115-119,139` 实际注册 `ConsumerSettingsScreen`。原“B 可看到 A/ownerless 数量与空间”P2 已关闭。

### 同 ID 记录隔离

`src/store/downloads.ts:66-70` 的 hydration 去重包含 owner/id/URI，`:81` 的 `add` 只替换同 owner 同 id，`:147-155` 的 `removeLocalDownload` 同时匹配 id、URI、owner。`tests/download-owner.test.cjs:88-92` 以 A/B 同 id 复跑，清理 A 后只剩 B。原重复 id P2 已关闭；未使用的原始 `remove(id)` 仍是全局 API，当前没有调用者。

### 长许可期限 timer

`src/screens/LocalPlayerScreen.tsx:46-53` 用 `clockTick` 作为 effect 依赖，超过 JavaScript 单次 timer 上限时会按剩余期限再次排程。原约 24.85 天条件 P2 已关闭；真实 O00/O01 许可时长和真机行为仍未验收。

## 当前 RN 注销复审（旧 P2 已关闭）

### A/B 收据隔离已关闭

`src/auth/deletion.ts:22-55` 兼容旧的单对象值，同时把新值保存为按 `origin + userId` 分组的动作数组；`save` 保留 A 的 submitted/deleting/deleted 收据，`clear(value)` 还会精确匹配 `origin + userId + operationId`。`src/screens/DeleteAccountScreen.tsx:51-58` 在 B 登录或启动时只选择 B 自己的记录，匿名完成状态继续保留原收据。`tests/deletion-api.test.cjs:62-70` 和 `tests/deletion-screen.test.cjs:60-70` 实际复现了 A/B 切换、同 owner 新 operation 和不同 origin，证明 B 的 intent/cancel 不会删除 A。

这是对上轮“切换到 B 丢 A 收据”P2 的关闭证据；它不意味着真实系统已经有 native 许可证或后台删除 worker。`readDeletionRecoverySession` 仍只接受 null 或原 owner，foreign session 会在本机清理前拒绝。

### 收据删除失败已变为可恢复终态

`src/auth/deletion.ts:46-55` 对 `storage.remove` 抛错或静默未删除都会抛 `ConsumerAuthError(503, 'deletion_recovery_unavailable')`，并保留原记录。`DeleteAccountScreen.tsx:31-34` 显示存储不可用而不清 React/持久状态，`:41-43` 取消未提交动作时吞掉清理失败并保留收据，`:76-88` 在服务端 `deleted` 后先完成本机 owner 清理，收据删除失败仍可重进并通过 status GET 重试；`:90-96` 的冷启动检查和重试没有重新调用 `deleteUser`。`tests/deletion-api.test.cjs:72-78`、`tests/deletion-screen.test.cjs:72-78` 对抛错和静默失败均通过。

这是对上轮“MMKV 收据删除异常卡在终态”P2 的关闭证据。系统 MMKV/Keychain 的真实写入失败、进程被杀和设备挂起仍未在真机验证，属于未确认条件而不是当前已复现缺陷。

## 条件性边界 / 未作为当前 P2

### `account_terminal` 响应契约

`src/auth/client.ts:141-152` 收到 `account_terminal` 会先尝试删除官方凭据，再抛错；`src/auth/deletion.ts:83-86` 的 `readDeletionRecoverySession` 没把它视为 null；`DeleteAccountScreen.tsx:76-88` 因而会在本机清理前停止。后端实现 `services/platform/src/auth.mjs:92-102` 确实允许 session GET 返回该错误，但当前 RN/API 夹具只验证 null/same/foreign，真实删除路径是否在该时点已经删掉 session 尚未由本轮移动端测试证明。最新联合夹具已对实际 SDK→Go→Better Auth→隔离 PostgreSQL 的收据、单次删除 POST、null session 以及 Web 进度/推荐/删除路径通过 29 个检查；该条件仍未单独覆盖，不作为当前 gate。

### O00/O01 接入前边界

当前 `src/queries/playback.ts:124-134` 在官方会话检查后固定拒绝 `offline_authorization_required`，没有创建文件、task、扣账或把 viewer 下载 URL 当许可发行器。没有真实 issuer、device binding、native asset、可恢复队列或持久许可时，不能把 `OfflineAuthorization` 元数据当 license，也不能把 raw `file://` 播放当 O00/O01 已完成。

## 未确认的条件，不作为本轮已证实 P1/P2

- `clearConsumerProgress(owner)` 在 `src/auth/ConsumerAuthGate.tsx:14-16` 只传 owner；`ProgressJournal.clear` 在 `src/playback/consumerProgress.ts:37-40` 按 owner 清理，而队列条目还带 origin。当前设置页固定 `DEFAULT_BASE_URL`，没有用户可切换多 origin 的路径；若以后同一 owner 在多个 origin 共用 journal，需要补 origin 作用域，否则清一个 origin 会清另一个 origin 的 progress queue。
- Better Auth Expo managed storage 会在值超过约 1800 字节时产生 cookie chunk keys；`src/auth/client.ts:112-123` 只直接删除官方 base `_cookie` 与 `_session_data` 键。当前夹具和常规 session cookie 未证明会产生 chunk，且不能用静态替代声称 SecureStore 已完成真机擦除；接入/部署验收需检查 chunk 清理和系统 Keychain/Keystore 失败。
- `usePlaybackLease` 在 `src/queries/playback.ts:147-211` 以 `dramaId` 而非 owner/session 计租，`hasRunningDownload` 也只看 drama；同剧快速换账号或未来同时恢复队列时可能延迟释放旧在线 playback session。这是服务端资源生命周期风险，当前下载 mutation 不创建任务、没有离线许可泄露证据，不列为本轮已确认 P2。
- 媒体记录中的 `deviceId` 仅做非空元数据检查；没有真实设备证明、许可证签发、Media3/AVAssetDownloadURLSession bridge、可恢复队列或 native 持久许可。不能把 `OfflineAuthorization` 当作 license，也不能把 LocalPlayer 的 raw `file://` 播放当作 O00/O01 通过。
- 系统文件删除失败、SecureStore 读写失败、后台挂起导致 JS timer 延迟、symlink/原生 File URI 语义均未在 Android/iOS 真机验证。源码在失败删除时选择保留并锁定记录，属于安全失败；不能从替代测试推出生产设备行为。

## 任务卡边界

任务卡把 O00 定义为实际系统离线 asset/密钥下载桥接与飞行模式/强退恢复，把 O01 定义为登录 App 缓存、队列、账号/设备绑定、许可 preview/order/license、断点与换号锁定，把 A03 定义为注销时资料、历史、片单和本机密钥的生命周期。证据：`dramivio-model-task-cards.md:36,48-49`；工程方案要求 Media3/AVAssetDownloadURLSession 桥接并用真机验收：`dramivio-engineering-plan.md:28,156-169,190-196`。

当前代码没有 native offline bridge、真实 issuer、device proof、可恢复下载队列、原生持久许可或密钥擦除。`OfflineAuthorization` 的 permit/order/asset/device 字段只是当前记录的元数据校验，不能称作 license；LocalPlayer 的 raw `file://` 播放也不能称 O00/O01 已通过。

## 测试和检查

- 本轮 `npm run typecheck`：通过，退出码 0。
- 本轮 `npm test`：203/203 通过。
- 本轮定向串行 `node --test --test-concurrency=1 tests/deletion-api.test.cjs tests/deletion-screen.test.cjs tests/download-owner.test.cjs`：33/33 通过；其中当前注销 API 11/11、注销 screen 12/12、owner cleanup 10/10。新增的 A/B 收据和收据删除失败检查包含在这 33 项中。
- `193/193`、`41/41` 和 `198/198` 是上轮历史运行数量，不是本轮独立复跑结果，不能与当前 203/203 或 33/33 混称。
- 当前联合夹具证据：`consumer-joint-checks.json` 为 `passed: true` 且包含 29 项；`consumer-joint-server.log` 以 `ok juku/internal/app 11.693s` 结束。退出处理在 `consumer-joint-fixture.mjs:12-16,164-171` 会停止 Go、等待退出并要求 `child.exitCode === 0`，因此这次结果不是仅由浏览器端计数得出。上游禁止请求仍保留在 `ui_viewer_test.go:24-29`；触发失败的 fixture 已在 `consumer_platform_test.go:317-326` 补齐与当前 app 同源的 title/tags/status/date 元数据，cover repair、playback plan 和 portrait MP4 继续由浏览器 fixture 在 `consumer-joint-fixture.mjs:28-30,68-72` 提供，不放宽真实 provider 请求。
- 当前联合范围是 Chrome + repository Go routes + Better Auth + 隔离 PostgreSQL，邮件仅本地捕获、无外部 SMTP；它证明了本次列出的 29 个路径，不替代 Android/iOS 编译、真机离线、系统凭据、真实发行器、native bridge、持久许可证或生产发布证据。O00 原生离线 PoC、O01 登录 App 缓存和 A03 注销/本机许可擦除仍是未完成里程碑；本报告只确认 owner/本机清理基础和 RN 控制流边界。
