Skip to content

服务器模式:同一张截图重复上传时不再重复处理 - #1

Merged
AliceLJY merged 2 commits into
mainfrom
claude/screenshot-dedup
Sep 25, 2026
Merged

AliceLJY merged 2 commits into
mainfrom
claude/screenshot-dedup

Conversation

@AliceLJY

@AliceLJY AliceLJY commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

分步计划

  1. 基线:从 main(4004351)开出 claude/screenshot-dedup,先在未改动的代码上跑 server / app 的 npm ci && npm test && npm run typecheck,记下现状。
  2. 服务器存储:在 server/src/db.ts 打开数据库时建服务器专用表 server_screenshot_hashes(不经共享 schema,不动 PRAGMA user_version),加「按哈希查找 / 记录哈希 / 列出缺哈希的老记录」三个方法;所有删除 screenshot 行的路径同步删哈希。
  3. 服务器判重:POST /api/screenshots 在图片写盘后、建 screenshot 行和调模型之前,算 SHA-256(node:crypto)并判重。命中返回 200 + 已有结果,并删掉刚写盘的文件;没命中照旧 201,整条处理成功后才记哈希。
  4. 老数据补齐:每个进程第一次判重前,给图片文件还在、且已处理完成的老记录补算哈希。
  5. app:ScreenshotUploadResponse 加 duplicate_of_screenshot_id?。流程层不把重复响应的卡片当新卡,展示 notice;单张直接打开已有详情,多张只标记那一张,点开再看。本地模式不受影响。
  6. 测试:server 与 app 按任务要求补测试。另外临时改坏实现(不判重 / 提前记哈希 / 跳过补齐 / 重复响应照样加卡等),确认新测试都会变红,再原样恢复。
  7. README:中英文分别更新「已知限制 / 后续工作」。
  8. 交付:跑通验证命令,推送,开 draft PR,盯 CI。
  9. 独立审查:另起一个审查代理,逐步推演 app 各条流程和服务器的失败路径。它没找到阻断性问题,提出的几处边界情况已在第二个提交 321800f 里修掉,详见文末「审查后的补充修改」。

响应格式的变化

POST /api/screenshots:

  • 没命中(新图片):和改动前逐字段一致,仍是 201,data 只有 screenshot_id、cards(测试对整个响应做了 deepEqual)。

  • 命中(文件字节完全相同):返回 200。不建新行、不调 perceiveScreenshot、不产生卡片或 observation,刚写盘的重复文件会被删掉:

    {
      "ok": true,
      "data": {
        "screenshot_id": 12,
        "cards": ["…第 12 张截图当前的卡片,状态按数据库现状,不重置…"],
        "duplicate_of_screenshot_id": 12,
        "processing_notice": "这张截图之前上传过,没有重复处理,已为你显示上次的结果。"
      }
    }

shared/types.ts 与 app/src/types.ts 的 ScreenshotUploadResponse 都加了可选的 duplicate_of_screenshot_id?: number。shared/types.ts 同时补了 processing_notice?: string:app 侧类型早就有这个字段,服务器要发它需要这个类型。/api/notes(粘贴文本)不变。

哈希表结构与补齐方式

在 server/src/db.ts 的 MailuoDb 构造函数里、共享 schema 初始化之后执行:

CREATE TABLE IF NOT EXISTS server_screenshot_hashes (
  screenshot_id INTEGER PRIMARY KEY REFERENCES screenshots(id) ON DELETE CASCADE,
  sha256 TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS server_screenshot_hashes_sha256
  ON server_screenshot_hashes (sha256);
  • 只在服务器侧:不写进 shared/core/schema.ts / migrations.ts,不改 PRAGMA user_version。测试流程:先建只有共享 schema 的库,再用 MailuoDb 打开两次;两次都不报错,user_version 前后都是 1。
  • 普通索引,不设唯一:升级前同一个文件可能已经传过好几次,补齐时会得到相同哈希;判重时取其中最新的一条,也就是「上次的结果」。查找时 JOIN screenshots,只返回还存在的截图。
  • 删除时保持一致:全仓只有 MailuoDb.deleteScreenshotUploadArtifacts 会删 screenshot 行。它的调用方是 cleanupPartialScreenshotUpload(/api/screenshots 与 /api/notes 的失败清理),以及 server/src/cli.ts 里 runE2eFlow 的失败清理;cli.ts 没有独立的清理命令。现在这个方法在同一事务里先删哈希行;外键 ON DELETE CASCADE 再给以后新增的删除路径兜底(MailuoDb 一直开着 PRAGMA foreign_keys = ON)。
  • 什么时候记:只在 saveScreenshotAnalysis 成功之后才调 recordScreenshotSha256。感知、归并、提议、落库任何一步失败,这次上传都会被清理掉;同一张图可以再传,也会被正常处理。测试还在处理进行中检查过哈希表,确认那时是空的。
  • 记哈希失败:只记一条错误日志,已经成功的上传照常返回 201,数据保留;下次进程启动时的补齐会把这条哈希补上(有测试覆盖)。
  • 补齐(server/src/screenshot-hash.ts):每个进程在第一次判重前跑一次,之后的上传直接复用结果。如果补齐本身出错,只记日志、放行这次上传,下次上传再试。只补同时满足三个条件的行:已处理完成(raw_extraction 非空)、文件在服务器自己的截图目录里(SCREENSHOT_DIR,默认 server/data/screenshots/)、文件还读得到。服务器存的文件名唯一、从不改写;CLI 跑 e2e 时指向的外部文件可能被改写,所以不补。文件已经不在的、粘贴文本行(data: URI)、没处理完的残留行也都跳过。补到几条会写一条 info 日志:Backfilled hashes for earlier screenshot uploads。
  • 并发:按允许的简化,不加锁。判重处留了注释 // simplified: no lock; concurrent identical uploads may both be processed。

app 侧

只有收到带 duplicate_of_screenshot_id 的响应时才走新逻辑。本地模式永远不返回这个字段,所有新分支都挂在这个字段上。

  • 流程层(app/src/flow-context.tsx):重复响应对应的批次项是 status: "success"、cards: [],processingNotice 是服务器给的提示,新字段 duplicateOf 记已有截图的 id,以及服务器回复时那张截图还剩几张待确认卡片。它的卡片不进本批待确认卡片,不算作卡片来源,也不抢「当前截图」。打开已有详情(getScreenshotDetail)时,详情挂到这一项上,批次里的其它项保持不动;原来这一步会整批替换。
  • 单张:上传完直接进 /review/<已有 id>,页面加载已有详情,显示提示和那张截图当前的卡片(已确认、已跳过、待确认都按数据库现状)。刚打开时就算没有待确认卡片,也不会自动跳到洞察页;只有在这一页确认或跳过过卡片、全部处理完之后,才照常跳过去。
  • 多张:照常进入第一张新处理的截图,其余几张照常确认。之前传过的那一张在确认页的分组标题上标「之前传过」,并显示提示和「查看上次的结果」按钮,点开后在原位加载已有详情。
    • 如果上次那张还有待确认卡片,分组里会提示「上次还有 N 张卡片没确认」,而且在它被打开之前,本批不会自动跳到洞察页,免得这些卡片被悄悄跳过。典型场景:上传的回包丢了、服务器其实已经处理完,重传时就会被认成「之前传过」。
    • 整批都是之前传过的:不自动跳转,只弹个提示。
    • 上传页「批次处理结果」里会逐张列出「之前传过」并可点开;单张上传完成时如果不在上传页,也从这里进去看。打开过的旧截图里还有待确认卡片时,上传页的「查看待确认卡片」会直接进到那张。
  • 同一张图在一批里选了两次:第二张指向第一张,提示「上次的结果就是本批的第 N 张」,同一批卡片不会挂两份。

动了哪些文件

服务器

  • server/src/db.ts:建服务器专用表;新增 findScreenshotIdBySha256、recordScreenshotSha256、listScreenshotsMissingSha256;deleteScreenshotUploadArtifacts 同步删哈希。
  • server/src/screenshot-hash.ts(新):hashFileSha256、backfillScreenshotHashes。
  • server/src/app.ts:POST /api/screenshots 判重、返回 200、成功后记哈希、每个进程补齐一次。
  • shared/types.ts:响应类型加两个可选字段。

app

  • app/src/types.ts:响应类型加 duplicate_of_screenshot_id?。
  • app/src/upload-batch.ts:isDuplicateUploadResponse、getDuplicateUploadItems、getUploadReviewScreenshotId。
  • app/src/flow-context.tsx:批次项新增 duplicateOf(类型 FlowDuplicateOf);重复响应不加卡;findFlowItemForScreenshot、applyScreenshotDetailToItems、applyUploadResponseCards、hasUnopenedPendingDuplicate。
  • app/app/(tabs)/index.tsx:决定进哪张确认页;整批重复时的提示;「批次处理结果」里的「之前传过」和「查看上次的结果」。
  • app/app/review/[screenshotId].tsx:「之前传过」标记;在原位打开已有详情;看已有详情时不自动跳洞察页。

文档:README.md、README_EN.md 的「已知限制 / 后续工作」,中英文各写各的。

测试

  • 新增文件:server/src/tests/screenshot-hash.test.ts、app/src/tests/duplicate-upload.test.ts。另外 server/src/tests/app.test.ts 加了 5 个路由测试,server/src/tests/db.test.ts 加了 3 个。
  • 为新行为调整的既有测试(没有跳过测试,也没有放宽断言):
    • server/src/tests/app.test.ts 的「keeps interaction cards while only high-confidence progress…」原本把同一张 fixtures/screenshot-1.png 传两次,用来走两个置信度分支。现在同一文件会被判重,所以第二次改传 fixtures/screenshot-2.png,测试本意(两次独立处理)不变。
    • server/src/tests/db.test.ts 的「initializes the full M1 schema」在表清单里加上 server_screenshot_hashes。
    • app/src/tests/transition-mitigation.test.ts 检查「进确认页的入口都走 scheduleReviewPush」,入口数从 2 改为 3。新入口 openPreviousUpload 也走这条防闪退路径;「全文件只有一处 router.push('/review/…')」的断言保持不变。
    • 两个构造批次项的测试夹具补上新字段 duplicateOf: null。
  • 测试数据全是合成的:几十字节的假图片 buffer、示例联系人 一类的占位名,以及 fixtures/ 里已有的虚构截图。

没做什么

  • 手机本地模式(app/src/local/)完全没动,本地模式重复上传仍会重复处理。
  • 只认「文件完全相同」:重新截一次图,或者网页端与手机端各自压缩后文件不同,都识别不了(README 已写明)。
    • 判重看的是服务器收到的字节。app 上传前会把截图统一重新压成 JPEG(app/src/upload-image.ts:最长边 1280、质量 0.8),所以「同一个文件」实际是同一端 app 对同一张原图压缩后的结果。
    • 网页端:我在云端的 Chromium 里按同样方式把同一张图压了两次(两个独立页面),字节完全一致。
    • 安卓端:原生压缩是否逐字节稳定,在这里验证不了,需要真机验收第 3 步确认。
    • 如果之前上传时用的 app 版本压缩参数不同,老截图也会认不出来。
  • /api/notes 粘贴文本不判重。
  • 重复上传时,这次填的「补充说明」既不保存也不使用,因为没有重新处理。
  • 不加锁:两个完全相同的上传同时到达时,可能都会被处理。
  • 用 sqlite3 命令行手动删 screenshots 行时(它默认不开外键),对应的哈希行不会跟着删;之后如果新行复用了同一个 id,可能认错。app 里所有删除路径都会同步删哈希,所以只在手动改库时才会遇到。
  • 旧版 app 连新服务器也不会触发重复处理,只是它不认识新字段,会把上次的卡片当成本批卡片显示。所以验收时 app 也要用这个分支的版本。
  • 没改 shared/core/schema.ts、shared/core/migrations.ts、deploy/、.github/、PLAN.md、docs/PLAN-V2.md、版本号、tag、打包脚本;没加任何依赖(只用 node:crypto)。

本机验收(owner)

先备份 server/data/mailuo.sqlite 和 server/data/screenshots/,再用这个分支重启服务器。网页版需要重新构建:bash scripts/build-web.sh。

  1. 老库照常打开:sqlite3 server/data/mailuo.sqlite 'PRAGMA user_version; .schema server_screenshot_hashes'。user_version 应和升级前一样(目前是 1),并能看到新表和索引。
  2. 老数据补齐:从网页上传一张升级前传过的原图文件。必须是同一个文件:不要重新截图,也不要换一端上传,因为另一端压缩后文件会不同。
    • 应很快返回,显示「这张截图之前上传过,没有重复处理,已为你显示上次的结果。」,并直接打开那张老截图的详情,卡片状态和之前一致。
    • 服务器日志里出现一次 Backfilled hashes for earlier screenshot uploads,时机是本次进程的第一次上传。
    • server/data/screenshots/ 里没有多出新文件,SELECT COUNT(*) FROM screenshots; 的结果不变。
  3. 单张重复:上传一张新图,确认或跳过其中几张卡,再从同一端传同一个文件。第二次应几乎立刻返回(没有再调模型),直接打开刚才那张的详情,卡片状态保持;联系人页的 observations 没有翻倍。网页和安卓各做一遍:两端之间互相认不出是预期的,但同一端必须能认出;安卓这一遍同时验证原生 JPEG 压缩是否逐字节稳定。
    • 如果那张旧截图还有待确认卡片,在打开的详情里把它们处理完,应照常自动进入洞察页;如果打开时就已全部处理过,应停在详情页,不会一闪跳走。
  4. 批量:一次选 3 张,其中 1 张是之前传过的。应照常进入确认页,另外两张正常确认;那一张标「之前传过」,点「查看上次的结果」会在原位展开上次的结果。
    • 如果那张旧截图还有待确认卡片,分组里应提示「上次还有 N 张卡片没确认」。确认完另外两张后,页面不会自动跳走,要等打开并处理完那几张才跳。
    • 如果一批里全是之前传过的:不会自动跳转,上传页会给出提示。打开其中一张还有待确认卡片的,不处理就返回,上传页会出现「查看待确认卡片」,点它应直接进到那张。
  5. 失败不挡重传:临时把 DASHSCOPE_API_KEY 改错,传一张新图,应该失败。改回来后再传同一张,应被正常处理(出新卡片),不会显示「之前传过」。
  6. 本地模式回归:手机切到本地模式,传一张图,再传同一张。行为应和升级前一样,仍会重复处理。

验证命令输出

两条命令都在本次会话里原样跑过。完整 TAP 输出很长,下面只保留版本、安装摘要、测试汇总和 typecheck 结果。

以下是第二个提交 321800f 上的结果。

server:cd server && npm ci && npm test && npm run typecheck,整条命令退出码 0。

$ node --version
v22.22.2
added 83 packages, and audited 84 packages in 2s
found 0 vulnerabilities
> mailuo-server@0.1.0 test
> node --import tsx --test src/tests/**/*.test.ts
# tests 263
# suites 0
# pass 263
# fail 0
# cancelled 0
# skipped 0
# todo 0
> mailuo-server@0.1.0 typecheck
> tsc --noEmit
(退出码 0)

app:cd app && npm ci && npm test && npm run typecheck。npm test 有 5 个失败,退出码 1,&& 链因此没走到 typecheck;typecheck 单独跑,退出码 0。

$ node --version
v22.22.2
added 583 packages, and audited 584 packages in 14s
> mailuo-app@1.0.0 test
> node --import tsx --test src/tests/**/*.test.ts
not ok 162 - out-of-order interaction stays blocked while its contact anchor is pending
not ok 163 - review fields show and block a rejected same-screenshot contact dependency
not ok 164 - review fields preserve cross-screenshot batch wording
not ok 165 - review fields follow core precedence for multiple same-screenshot contacts
not ok 166 - review fields do not replace an existing or deferred interaction link
# tests 198
# suites 0
# pass 193
# fail 5
# cancelled 0
# skipped 0
# todo 0

$ npm run typecheck
> mailuo-app@1.0.0 typecheck
> tsc --noEmit
(退出码 0)

这 5 个失败与本改动无关,只出现在这个云端容器里:

  • 5 个都在 app/src/tests/review-fields.test.ts,报错是 ERR_INVALID_RETURN_PROPERTY_VALUE: Expected a string, an ArrayBuffer, or a TypedArray to be returned for the "source" from the "load" hook but got undefined。
  • 原因:这个测试用 node:module 的 registerHooks 加载 review-fields.tsx。app/package.json 没有 "type": "module",所以这个文件按 CommonJS 解析。容器里只有 Node 22.22.2,它的同步 load hook 不接受 CommonJS 返回空 source;CI 用的是 Node 22.23.2,没有这个问题。
  • 证据:在未改动的 origin/main(4004351)上用同一个容器跑 npm test,结果同样是 # tests 193 / # pass 188 / # fail 5,失败的正是这 5 个。main 最近一次 CI(run 35947581051,Node 22.23.2)是 193/193 全过。
  • 我没有改这个测试,也没有自己换 Node 版本;CI 结果以本 PR 的 checks 为准。第一个提交 3737860 的 CI 里 app 是 198/198、server 全过。

审查后的补充修改(321800f)

独立审查没找到阻断性问题。下面几处它指出的边界情况都已修掉。第 2、4、6 条落在流程层或服务器上,补了测试,并临时改回旧逻辑确认测试会变红;第 1、3、5 条是页面上的接线,这里跑不了页面,只靠类型检查和逐步推演验证,需要在本机验收时顺带看一下:

  1. 在「查看上次的结果」页确认完剩下的卡片后,会卡在确认页、进不了洞察页 → 只要在本页确认或跳过过卡片,就恢复处理完自动跳洞察页。
  2. 混合批次里,还有待确认卡片的「之前传过」项可能被自动跳洞察页直接越过 → 记下待确认张数,没打开前不自动跳,并在分组里提示张数。
  3. 整批都是重复时,上传页「查看待确认卡片」点了只弹提示 → 回退到第一张待确认卡片;「之前都上传过」的提示只在整批都是重复时出现。
  4. 记录哈希这一步如果失败,本已成功的上传会变成 500,数据也会被清掉 → 改为只记日志,照常返回 201,下次启动时补齐。
  5. 「查看上次的结果」按钮和页面自己的加载共用一个令牌,可能互相打断 → 按钮改用独立令牌。
  6. 补齐会给 CLI 指向的外部文件算哈希,那些文件以后可能被改写 → 只补服务器截图目录里的文件。

两处有意没改:

  • 同一张旧图在一批里选了两次时,点第二张会展开到第一张的分组里。这是纯展示问题,第二张会提示「上次的结果就是本批的第 1 张」。
  • 手动删库导致 id 复用的情况,已写进上面「没做什么」。

🤖 Generated with Claude Code

https://claude.ai/code/session_01KTuFAfAdgU7H5k9kFMWkVP

- server:POST /api/screenshots 写盘后先算 SHA-256 判重;命中时不建行、不调模型、
  删掉刚写盘的文件,返回 200 + 已有截图当前的卡片、duplicate_of_screenshot_id 与提示;
  没命中与改动前逐字段一致(201),整条处理成功后才记哈希
- server:db.ts 打开数据库时建服务器专用表 server_screenshot_hashes(sha256 索引,
  不动 PRAGMA user_version);deleteScreenshotUploadArtifacts 同步删哈希,外键级联兜底
- server:第一次判重前给已处理完成、图片文件还在的老记录补算哈希
- app:ScreenshotUploadResponse 加 duplicate_of_screenshot_id;重复响应的卡片不进本批,
  单张直接打开已有详情,多张只标「之前传过」、点开在原位加载;本地模式不受影响
- README 中英文同步已知限制与后续工作
- 测试:同字节重复、不同字节、失败后重传、老数据补齐、专用表建两次、删除一致性、
  app 流程层重复响应与无该字段时行为不变;全部使用合成数据

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTuFAfAdgU7H5k9kFMWkVP
- app:重复项记下上一张截图还剩几张待确认;未打开且还有待确认卡片的「之前传过」项
  会拦住自动跳洞察页,并在分组里提示张数(比如上传回包丢了、服务器其实已处理完再重传)
- app:查看上次结果的视图里,只要在本页确认或跳过过卡片,就恢复处理完自动跳洞察页
- app:整批都是重复时,上传页「查看待确认卡片」回退到第一张待确认卡片;
  「之前都上传过」的提示只在整批都是重复时出现
- app:「查看上次的结果」按钮用独立的加载令牌,不会打断本页自己的加载
- server:记录哈希失败只记日志,已成功的上传照常返回 201,下次启动的补齐会补上
- server:补齐只处理服务器截图目录里的文件,不碰 CLI 指向的外部文件
- 测试:记哈希失败仍 201 且重启后能认出;目录外文件不补齐;待确认重复项的拦截规则

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTuFAfAdgU7H5k9kFMWkVP
@AliceLJY
AliceLJY marked this pull request as ready for review September 25, 2026 18:12
@AliceLJY
AliceLJY merged commit b6dbdaa into main Sep 25, 2026
2 checks passed
@AliceLJY
AliceLJY deleted the claude/screenshot-dedup branch September 25, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants