diff --git a/.trellis/tasks/09-11-remove-image-dimensions/check.jsonl b/.trellis/tasks/09-11-remove-image-dimensions/check.jsonl new file mode 100644 index 0000000..988ef2a --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/project/async-state-boundaries.md","reason":"检查移除关联字段后仍保持唯一 pending owner 和 terminal 语义。"} +{"file":".trellis/spec/project/database-query-composition.md","reason":"检查数据迁移只清理目标 JSONB 字段且不引入越界读取。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"检查 confirmed_sent 仍只来自完整 live sent 事实。"} +{"file":".trellis/tasks/archive/2026-09/09-10-mind-onetalk-image-send/research/quality-check-2026-09-10.md","reason":"回归图片发送的并发、timeout、生命周期和真实运行时验证边界。"} diff --git a/.trellis/tasks/09-11-remove-image-dimensions/design.md b/.trellis/tasks/09-11-remove-image-dimensions/design.md new file mode 100644 index 0000000..4e7a35f --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/design.md @@ -0,0 +1,47 @@ +# 技术设计:从 OneTalk 图片链路移除宽高 + +## Boundary and invariant + +`width` 与 `height` 是 OneTalk 上游 raw image payload 的非业务字段,不再是 Trade Message Center 的任何规范化图片事实。MAIN decoder 是唯一可以看见 raw payload 的边界,但不得读取、校验、投影或以它们影响结果。此后图片确认的可靠性来自:候选 message ID 优先;无 ID 时,目标会话、sent 方向、image kind、`sizeBytes`、`md5`、可用 `fileId`、发送时钟窗口和唯一性共同约束。 + +```text +OneTalk raw contentType=101/custom.type=7 + (raw width/height ignored) + ↓ +OneTalkImageContent without dimensions + ↓ +live batch → SendObservationCorrelator + ↓ unique match +PageCommandResult confirmed_sent + ↓ +send.confirmation → durable JSONB fact → message.created/send.result +``` + +不改变事实来源或终态顺序:SDK/HTTP 成功仍不确认;仅完整 live sent message 能确认;server 继续 `commit -> message.created -> conversation.updated -> send.result`。 + +## Contract and decoder + +1. `apps/onetalk-contract/src/content.ts` 删除 `OneTalkImageContent.width` / `.height`、`CONTENT_KEYS.image` 对应键和 `ONETALK_MAX_IMAGE_DIMENSION_PX`。exact-shape guard 将带任一 dimension key 的 canonical content 判为无效。 +2. `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts` 删除 dimension import、normalized 字段和 validation。raw payload 即使带错误类型、超界值或仅单边字段,也不影响图片的其它安全校验和无宽高 normalized output。 +3. `apps/chrome-extension/src/onetalk/main-page/image-send.ts` 的 final image metadata 只提取 `sizeBytes`、非空 `md5` 和可选 `fileId`;`send-observation.ts` 的 pending expected / matcher 不再保留或比较 dimensions。 +4. 所有 message frame、send confirmation/result 和 HTTP projection 由同一 content guard 收窄,因此不额外建立另一个 image result 或 legacy parser。 + +## Durable data and protocol rollout + +- 新 custom Drizzle migration 仅执行:对 `onetalk_message.content ->> 'kind' = 'image'` 的 JSONB,移除顶层 `width`、`height` 键。文本、文件、其余 image key、主键和索引不变;不改已执行 migration。 +- 迁移要被正确登记到 Drizzle journal。它是数据迁移,不需要扭曲 TypeScript schema 或添加另一份 runtime normalizer。 +- `ONETALK_PROTOCOL_VERSION` 由 5 升至 6,所有 current-workspace frame builders、decoders、fixtures 和 harness 同步更新。没有 v5 adapter、双写或字段回填。 +- 严格切换的运行顺序:停止旧 server → `db:migrate` 清理 JSONB → 启动 v6 server → 发布并重连 v6 extension/Mind client。迁移前后都不能让旧 server 读取已清理图片,也不能让新 server 读取带宽高的旧事实。 +- 当前工作区只能修改 shared Bright/extension/harness/server;真实 Mind workspace 是外部 release participant。只读检查未发现该 checkout 的字段消费者,但发布者仍须验证其实际部署的 client 已切至 v6。 + +## Presentation and documentation + +- Mind test harness image metadata 只展示 extension 与 size,validator 只接受无宽高的 canonical image。 +- 更新仍描述“当前 normalized contract / confirmation matching”的非归档 docs;原始 OneTalk 证据样本可保留宽高,但旁注说明这两个 raw 字段被 decoder 忽略且不会跨 MAIN 边界。 +- 不修改归档任务记录或上游 raw protocol 事实。 + +## Risks and rollback + +- 最大风险是 strict v6 与未更新端并存;这是预期 fail-closed,发布要在维护窗口执行。不能通过兼容 adapter 回滚该风险。 +- 数据迁移会永久删除两个非业务 JSONB 字段。回滚代码只能恢复到“无宽高 content”的 v6 定义;若要恢复旧定义,必须另写新 migration 并接受历史尺寸不可恢复。 +- 若 `TEST_DATABASE_URL` 不可用,migration 数据效果只能以 SQL review、Drizzle check 和 unit projection 测试验证;生产执行前必须在目标数据库的维护窗口跑 migration。 diff --git a/.trellis/tasks/09-11-remove-image-dimensions/implement.jsonl b/.trellis/tasks/09-11-remove-image-dimensions/implement.jsonl new file mode 100644 index 0000000..5814254 --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/project/architecture.md","reason":"共享契约与跨包职责的唯一所有权。"} +{"file":".trellis/spec/project/async-state-boundaries.md","reason":"发送确认的 pending、timeout 与终态不变量必须保持。"} +{"file":".trellis/spec/project/database-query-composition.md","reason":"JSONB 数据迁移和读取投影边界。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"OneTalk live 事实确认与 no-retry 发送语义。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"MAIN、ISOLATED 和 Service Worker 的 frame/command 边界。"} diff --git a/.trellis/tasks/09-11-remove-image-dimensions/implement.md b/.trellis/tasks/09-11-remove-image-dimensions/implement.md new file mode 100644 index 0000000..6171ccf --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/implement.md @@ -0,0 +1,30 @@ +# 执行计划:从 OneTalk 图片链路移除宽高 + +## 1. 锁定共享 v6 无宽高合同 + +1. 在写任何 symbol 前,对 `OneTalkImageContent`、`normalizeImage`、`metadataFrom`、`createSendObservationCorrelator` 和 protocol version 分别执行 GitNexus upstream impact,审查 HIGH/CRITICAL 结果后再改。 +2. 修改 shared image type、exact key list 和 guard,删除 image-dimension constant;把 v5 wire version 统一提升为 v6。 +3. 更新 contract fixtures:无宽高图片有效,带任一 canonical width/height key 无效;移除 dimension bounds cases,保留 size/URL/nullable metadata 边界。 + +## 2. 收敛 MAIN 发送、live decode 与确认 + +1. 删除 raw image decoder 对 width/height 的读和校验;测试 raw image 缺字段、单字段、错误值和正常值都产出相同的无宽高 canonical content。 +2. 删除上传 metadata 与 correlator expected/match 中的 width/height;保留 `tmpKey` 隔离、候选 ID 优先、unique composite fallback、账号/目标 fence、45 秒 timeout 和 ambiguous failure。 +3. 更新 extension test fixtures、WebSocket tap / decoder / image-send / send-observation tests;新增“无宽高 live sent image 使实际 pending send 得到 confirmed_sent”的特征测试。 + +## 3. 清理读模型、支架和持久化数据 + +1. 更新 server read-projection / websocket test images,使 v6 public result/read payload 不含 dimensions;断言旧 canonical dimensions 被 strict guard 拒绝。 +2. 更新 harness validator 与 image display,删除 dimensions 输出;只以 shared content contract 验证 v6 frames。 +3. 用 Drizzle custom migration 新增 `0009` 数据迁移和 journal entry:仅移除 image JSONB 顶层 dimensions。更新必要的 migration/read tests;有 `TEST_DATABASE_URL` 时以 60 秒硬超时运行 integration test,断言升级前数据被清理并可读。 + +## 4. 文档、质量门和真实联调 + +1. 更新当前 docs 的 normalized image contract/confirmation fingerprint,明确 raw dimensions 被忽略;不重写 raw capture 或归档任务记录。 +2. 运行 targeted contract, decoder/websocket/image-send/send-observation, server read/WS tests;随后 `pnpm typecheck`、`pnpm build`、`pnpm test`、`pnpm format:check`、`pnpm --filter @trade-message-center/server db:check`、`git diff --check`。 +3. 执行 GitNexus `detect_changes({scope:"all"})`,检查变更仅落在 shared contract、MAIN observer/send、server migration/read tests、harness 与相关 docs。 +4. 在 v6 维护窗口按“停旧 server → migrate → v6 server → v6 extension/Mind client”运行。使用 Chromium 发送一张无敏感测试图片,确认 trace 经 live observation 到 `confirmed_sent`;没有该窗口时记录为未验证。 + +## Rollback point + +在执行 production migration 前,可回滚工作树和 release artifact;migration 执行后不回填尺寸。若要恢复旧客户端,必须先停止 v6 流量,再发布一个新的、显式记录的数据/协议迁移,不能修改或撤销已执行 migration。 diff --git a/.trellis/tasks/09-11-remove-image-dimensions/prd.md b/.trellis/tasks/09-11-remove-image-dimensions/prd.md new file mode 100644 index 0000000..49e2081 --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/prd.md @@ -0,0 +1,43 @@ +# 从 OneTalk 图片链路移除宽高 + +## Goal + +让 Mind 发往 OneTalk 的图片在 OneTalk 原生发送成功并出现完整 live sent 事实后,能稳定取得 `confirmed_sent`;宽度与高度不再是任何公开或持久化图片事实、匹配条件、协议字段或展示元数据。 + +## Background and confirmed facts + +- 当前链路存在矛盾:MAIN 上传回调的最终 relation metadata 容许没有 `width` / `height`(`apps/chrome-extension/src/onetalk/main-page/image-send.ts:185-217`),但 live decoder 和共享 `OneTalkImageContent` 将它们设为必填(`apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts:201-210`、`apps/onetalk-contract/src/content.ts:18-30,282-296`)。无宽高的真实图片因此变成 `media_invalid_schema`,不会参与 `sendObservation`,随后 timeout 为 `delivery_unknown`。 +- `OneTalkImageContent` 写入 `onetalk_message.content` JSONB;严格读取投影会再次使用同一 guard(`apps/server/src/onetalk/read-projection.ts:95-110`)。只改运行时代码会让旧图片 JSONB 无法读取。 +- 当前 Mind 测试支架会校验并展示宽高(`apps/mind-test-harness/src/harness/validators.ts:21-24`、`apps/mind-test-harness/src/harness/messages.ts:21-26`)。检索到的 `/Users/ybf/work/trade-mind` 工作副本把 Bright message content 作为通用 JSON 读取,未找到图片宽高字段消费者。 +- OneTalk 原始上游 payload 仍可能带宽高;本任务不控制或改写上游 payload,只规定在 MAIN 解码边界忽略它们,绝不跨出该边界。 +- 现有 WebSocket/消息合同采用 exact-shape v1 content 和 protocol v5。旧扩展会继续发送带宽高的 image content;新扩展会省略它们,因此这是跨版本协议兼容性决策。 + +## Requirements + +1. 共享 `OneTalkImageContent`、其 exact-shape guard、全部 frame/result/public read model 仅保留图片的 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`;不得保留或接受 `width`、`height`。 +2. MAIN raw-image decoder 必须忽略上游 raw payload 的宽高,并且在其缺失、错误、超界或存在时均不会因宽高生成 anomaly;其余图片安全校验和 URL allowlist 不变。 +3. 图片发送的 post-upload metadata 和 live correlator 不再读取、保存或比较宽高;仍按候选 message ID 优先,或按同会话、sent 方向、图片 kind、size、md5/fileId(可用时)、时间窗和唯一性确认。歧义仍为 `send_ambiguous`,不以 SDK/HTTP 成功冒充确认。 +4. Mind 测试支架和当前仓库内的测试 fixture / validator /展示不得读取、传输或显示宽高。 +5. 新迁移必须从既有 `onetalk_message.content` 的 `kind=image` JSONB 中删除两个键;迁移不可修改既有 migration,且只影响图片事实。完成后所有历史图片应通过新的 strict guard 和读取投影。 +6. 当前非归档文档中定义图片 canonical contract、匹配条件或支架展示的内容必须同步为无宽高版本;保留原始 OneTalk payload 历史证据,但明确它被边界忽略。 + +## Out of scope + +- 改动 OneTalk 上游原始 WebSocket payload、上传压缩策略、图片文件本身或 OneTalk 的 UI 尺寸行为。 +- 改变 `confirmed_sent` 的事实条件、45 秒图片预算、send result 三态、授权、会话路由、数据库幂等键或自动重试策略。 +- 修改外部 `trade-mind` 工作区;当前证据没有找到其字段级宽高消费者。 +- 修改归档 Trellis task 的历史记录。 + +## Acceptance criteria + +- [ ] `width` 与 `height` 不再出现在本仓库任何非归档的 image canonical type、validator、normalized content、send correlator expected metadata、Mind test-harness validator/rendering、fixture 或 public result/read payload 中。 +- [ ] 同一 image raw payload 无论是否包含任意值/类型的 `width` / `height`,都会在其它必填字段有效时解码为相同的无宽高 canonical image content;无宽高 live sent 图片可使匹配的 image attempt 得到 `confirmed_sent`。 +- [ ] 保留候选 ID 优先、错误会话/方向不匹配、时间窗、相同内容并发歧义、下载/upload/native send 错误以及 45 秒 timeout 的既有安全语义。 +- [ ] 对迁移前带宽高的图片 JSONB,迁移后仅移除这两个顶层 content 键;非图片内容和其它 image 字段不变,新的 server read projection 可读。 +- [ ] shared contract、extension、server、harness 的受影响测试、类型检查、构建、格式检查和 migration check 通过;有 `TEST_DATABASE_URL` 时,PostgreSQL integration 以不超过 60 秒的超时验证数据迁移。 +- [ ] Chromium 复验一次 Mind 图片发送:图片在 OneTalk 出现后,live observation 得到 canonical 无宽高 message 并向 Mind 返回 `confirmed_sent`。若现场条件不可用,清楚记录为未验证而不声称完成。 + +## Release decision + +- 用户已批准严格升级:wire protocol 从 v5 升到 v6,不接收或剥离旧 v5 image content 的宽高。旧端必须收到明确的协议升级错误,不能静默继续传递已废弃字段。 +- 因此需要协调停机窗口:先停止旧 server 对已迁移图片 JSONB 的读取,再执行一次性 JSONB 数据迁移,启动 v6 server,发布/重连 v6 extension 和实际 Mind client。部署不完整时的显式不可用优于隐藏兼容或错误确认。 diff --git a/.trellis/tasks/09-11-remove-image-dimensions/research/contract-extension.md b/.trellis/tasks/09-11-remove-image-dimensions/research/contract-extension.md new file mode 100644 index 0000000..0dd5078 --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/research/contract-extension.md @@ -0,0 +1,90 @@ +# 研究:共享 OneTalk 图片合同与 Chrome Extension MAIN 链路 + +- 查询:核对 OneTalk 图片 canonical type/guard、MAIN raw/flat-history 解码、上传 metadata、live sent correlator、页面观察发布顺序、v5 协议常量消费者、现有测试与文档中的 `width`/`height` 读写点。 +- 范围:internal(仓库源码、测试、规格、GitNexus);运行态未执行。 +- 日期:2026-09-11 +- 协调类别:cross-cutting +- 阻塞:共享合同/协议版本 scope 必须先稳定;Chrome extension MAIN scope 才能独立收敛。extension scope 还依赖 server/harness 接受同一个 v6 frame/content 形状。 +- 共享边界:`apps/onetalk-contract/src/content.ts` 是 image canonical type/strict guard 唯一 owner;`apps/onetalk-contract/src/wire.ts` 是 wire version owner;`apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts` 是 raw OneTalk 到 normalized content 的唯一 MAIN decoder;`send-observation.ts` 是 live/send correlator owner。 +- 证据基线:checkout `/Users/ybf/code/trade-message-center-worktree`,branch `main`,HEAD `b47720872da147e9da791de77424007e21e1a74c`(2026-09-11);产品源码/测试无 dirty path,只有本任务 `.trellis/tasks/09-11-remove-image-dimensions/` 未跟踪。GitNexus 当前索引同一 HEAD,indexed `2026-09-11T02:51:38Z`。 +- 复用证据与缺口:复用本任务 `prd.md`、`design.md`、`implement.md` 的 v6 严格切换和 live-confirmation 约束,以及 `.trellis/spec/chrome-extension/frontend/onetalk/{page-bridge,send-sop,runtime-sync}.md`;GitNexus 对 `normalizeImage`、`metadataFrom`、`createSendObservationCorrelator` 的 upstream 影响均为 LOW,但索引对部分 const/type 只给出符号级关系,仍以源码搜索为准。未执行 Chromium、Bright/Mind server、PostgreSQL 或外部 `trade-mind` runtime 验证。 + +## 发现 + +### 1. 当前实际数据流与 owner + +```text +OneTalk raw contentType=101/custom.type=7 + -> decodeMediaPayload(Base64 -> UTF-8 -> JSON) + -> normalizeImage (MAIN only) + -> observedMessage (shared isOneTalkMessage guard) + -> WebSocket observer sink / page-script-entry + -> sendObservation.observe(batch.messages) [先于 observedSink] + -> Service Worker page bridge -> Bright send.confirmation +``` + +- `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts:175-210` 当前将 `payload.width`、`payload.height` 写入 normalized image,并用 `ONETALK_MAX_IMAGE_DIMENSION_PX` 校验;因此缺失、错误类型或超界的 raw dimension 会生成 `media_invalid_schema`。`decodeOneTalkRawContent:289-302` 和 flat-history 分支 `265-286` 都汇入同一个 `normalizeImage`,不能另建 parser。 +- `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:148-247` 先校验身份,再调用唯一 content decoder;只有完整 `isOneTalkMessage` 才生成带 `upstreamType: 1` 的观察消息。该边界只输出 normalized content,不能把 raw `custom.data` 或 raw dimensions 带出 MAIN。 +- live 入口是 `new.ts:12-51` → `index.ts:9-32` → `websocket.ts:40-97`;raw history WebSocket envelope 在 `index.ts:24-31` 被忽略,历史由 SDK adapter `history.ts:71-143` 使用同一 content decoder。`page-script-entry.ts:38-46,56-90` 在发布 Bright sink 前先调用 correlator,故 live sent batch 可以直接完成 pending send。 +- `apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:24-41,115-182` 的 image pending expected 当前为 `sizeBytes`, `md5`, 可选 `width`, `height`, `fileId`;`matchesImage:119-125` 读取并比较两项 dimensions。`matches:162-181` 保留既有顺序:目标会话 + `sent` + 完整消息;有 candidate ID 时只按 ID(不回退指纹/时间),无 ID 时才使用 `sentAfterMs` 到 `now + 5_000ms` 的窗口及 image fingerprint。 +- `send-observation.ts:184-376` 的 `createSendObservationCorrelator` 仍是唯一 pending/终态 owner;图片预算由 `ONETALK_IMAGE_SEND_TIMEOUT_MS:11` 保持 45 秒,finish 时只允许完整 sent message 变成 `confirmed_sent`,多 pending 匹配是 `send_ambiguous`,错误/超时继续 `delivery_unknown`。 +- `apps/chrome-extension/src/onetalk/main-page/image-send.ts:21-29,185-217` 的 `FinalImageMetadata` 与 `metadataFrom` 读取 `width`/`height` 并将其可选写入 expected metadata;`finalMetadataFromSendFileArgs:240-255` 从直接参数及 `mediaInfo`/`relationInfo`/`fileInfo` 收集候选。`installUploaderInterceptor:304-385` 在匹配 `tmpKey` 后先做 metadata/current-target fence,再调用 correlator;`sendOneTalkMedia:408-592` 在 fetch/blob/File 后、native `sendFile` 前注册短生命周期 pending。生产 relation callback 的无 dimensions 形状已经在 `image-send.test.js:148-155` 注释和 `relationMetadata:98-103` fixture 中存在,当前缺口主要是 MAIN decoder/contract 强制 live payload 带 dimensions。 + +### 2. 共享合同与公共读边界 + +- `apps/onetalk-contract/src/content.ts:5-30` 定义 `ONETALK_CONTENT_VERSION=1`、媒体大小上限和 `OneTalkImageContent`;`CONTENT_KEYS.image:82-96` 与 `isOneTalkImageContent:282-296` 同时决定 exact-shape accepted keys。移除 dimensions 时必须同时删除 type、key list、dimension constant 和 guard reads;保留 `fileId/extension/sizeBytes/isOriginal/md5/previewUrl/urlScope` 与现有 media size/URL allowlist。 +- `apps/onetalk-contract/src/messages.ts:94-121` 的 `isValidOneTalkMessagePayload` 通过 `isOneTalkMessageContent` 验证 observed frames;`apps/onetalk-contract/src/sending.ts:140-166` 的 confirmed send guard 通过 `isOneTalkMessage`/`isOneTalkCenterMessage` 验证完整 sent message。因此没有另一份 message/send image schema 可安全绕过共享 guard。 +- `apps/server/src/onetalk/read-projection.ts:84-110` 只对持久化 `content` 再调用共享 `isOneTalkMessageContent` 并浅拷贝,server source 没有独立 dimensions reader;但 server image fixtures 仍在 `apps/server/test/onetalk-read-domain.test.ts:395-420,478-518`,由 server scope 清理后才能读取无宽高历史。 +- Page bridge contract 已规定 MAIN decoder 是唯一 raw boundary、ISOLATED/SW 不重解析 `custom.data`:`.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md:64-70,111-115,193`。send SOP 已规定候选 ID 优先、完整 sent fact、45 秒媒体观察和三态 fail-closed:`.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md:37-43,67-78`。 + +### 3. `width`/`height` 全量业务读写点(当前 checkout) + +- Canonical shared writer/reader:`apps/onetalk-contract/src/content.ts:7,18-30,82-96,282-296`。 +- MAIN raw/flat-history normalized writer/reader:`apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts:6,187-210`。 +- MAIN upload metadata writer/reader:`apps/chrome-extension/src/onetalk/main-page/image-send.ts:21-29,185-217`;不是 UI geometry。 +- MAIN live correlator expected/matcher reader:`apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:24-35,115-125`。 +- Extension test canonical/raw fixtures:`apps/chrome-extension/test/onetalk-media-content-decoder.test.js:19-28,54-70,107-136,157-191`;send correlator fixture `onetalk-send-observation.test.js:169-224`;image upload/callback fixture `onetalk-image-send.test.js:61-96`;flat-history fixture `onetalk-websocket-tap.test.js:355-371`。这些应改成 canonical 无宽高,并新增 raw dimension absent/one-sided/wrong/over-bound/present cases均产出相同无宽高 canonical content;若保留 raw evidence 字段,必须只出现在明确标注的 input fixture 中而不出现在 expected/public output。 +- Harness/server 是 R1 之外的共享下游:`apps/mind-test-harness/src/harness/validators.ts:18-30` 和 `messages.ts:17-35` 仍验证/显示 dimensions;`apps/server/test/onetalk-read-domain.test.ts` 的 image expected 仍带 dimensions。CSS/layout geometry(例如 `apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts:27-36`、`action-status-tooltip.ts:47-100`、harness `styles.ts`)不是消息字段,不应被全局文本替换。 +- 当前源码未发现其他 extension page-bridge、Service Worker、Bright client 或 database schema 对 image dimensions 的业务读写;Service Worker 只传递已收窄消息。`apps/chrome-extension/src/onetalk/service-worker/flows/frame-writers.ts:145-176` 直接写 message frame,但 payload 类型来自 shared contract。 + +### 4. 协议版本消费者与升级影响 + +- owner/decoder:`apps/onetalk-contract/src/wire.ts:41,123-135` 定义 v5 常量和 frame 类型;`decoder.ts:177-197` 对非当前版本返回 `onetalk_protocol_upgrade_required`,不能静默剥离旧 image fields。 +- shared frame creators 全部导入该常量:`connection.ts:3,185-221`、`conversation-sync.ts:10,271-351`、`messages.ts:10,177-220`、`contact-profiles.ts:4,165-176`、`buyer-facts.ts:4,401-416`、`sending.ts:8,173-204`;`index.ts:39-53` 对外导出。把常量升至 6 会统一改变所有 frame builders,不应只改 send/image frame。 +- extension exact consumers:`service-worker/transport/bright-client.ts:3-12,232-260` 生成 plugin hello/heartbeat 并调用 shared decoder;`service-worker/flows/frame-writers.ts:3-24,145-210` 手工生成 observed/sync frames;`bright-client.test.js` 与 `bright-protocol.test.js` 使用 shared constant 验证 handshake/upgrade。 +- server admission consumers:`apps/server/src/cutover-policy.ts:3,11-43`、`websocket/handler.ts:197-271`、`websocket/index.ts:70-87`、`websocket/connection-store.ts:123-126`、`http/onetalk/public-context.ts:136-170`、`http/onetalk/summary.ts:46-55`。这些都要求 current `ONETALK_PROTOCOL_VERSION`;`cutover-policy` 的 mode 字符串仍叫 `bright-v5`(13,26,39),它是独立 release-mode label,是否改名为 v6 需由 cross-cutting owner 明确,不能误以为只改数字常量即可。 +- hard-coded test consumers:`apps/server/test/observation-batcher.test.ts:53-71` 直接写 `protocolVersion: 5`;`apps/server/test/mind-authorization.test.ts:359-369` 直接以 `canAdmit("bright-v5", ..., 5)` 验证。`apps/onetalk-contract/test/contract.test.ts:191-219,1295-1303` 和 extension/harness tests 多数引用 shared constant,但测试名称/文档仍写 v5。 +- harness consumers:`apps/mind-test-harness/src/harness/contract.ts:3-36` 从 shared contract 注入 `protocolVersion`,`harness/websocket.ts:48-174` 使用注入值发送 hello/heartbeat/send;这与 image validator 是同一个 downstream release participant。 + +GitNexus pre-change evidence:`normalizeImage` upstream 4 symbols / 2 observer processes / LOW;`metadataFrom` upstream 3 / `sendOneTalkMedia` process / LOW;`createSendObservationCorrelator` upstream 1 (`page-script-entry.ts`) / LOW。它们支持 extension module 边界,但 shared contract/protocol 的实际 fan-out以源码 imports 和 frame builders为准。 + +## 候选 Scope 与依赖 + +1. **Shared v6 image contract(cross-cutting prerequisite)**:owner `apps/onetalk-contract/src/content.ts`、`wire.ts`、相关 contract tests;交付无宽高 exact-shape image、v6 decoder/builders、旧 v5 明确 upgrade error。应排除 extension implementation、server migration 和 harness UI。所有消费 shared content/frame 的 writer 必须等待此 scope;旧 v5 client 不可兼容运行。 +2. **Chrome MAIN image observation/send confirmation(本 R1 package-local implementation scope,依赖 1)**:owner `content-decoder.ts`、`image-send.ts`、`send-observation.ts` 及其 decoder/image-send/send-observation/websocket tests;交付 raw dimensions 完全忽略、无宽高 live sent 可确认、candidate ID 优先/唯一 fingerprint/时间窗/目标身份/45 秒终态不变。可排除 server persistence/read projection、harness、external Mind。该 scope 与 server migration、harness validator 的源码写入可 write-disjoint,但由于同一 shared contract 和 v6 wire prerequisite,不能在 scope 1 未稳定前开始;合同稳定后才可并行。 +3. **明确不属于 R1 的下游**:server read projection/JSONB migration 和 harness validation/rendering 需要消费新的 canonical shape;不能由 MAIN scope 通过保留 dimensions 或兼容 parser 解耦。 + +## Invariant 与验收探针 + +### Static/unit probes(implement/check 可执行) + +- `rg -n -i --glob '!**/dist/**' --glob '!**/node_modules/**' '\b(width|height)\b|ONETALK_MAX_IMAGE_DIMENSION_PX' apps/onetalk-contract/src apps/chrome-extension/src/onetalk/main-page apps/chrome-extension/test/onetalk-{media-content-decoder,image-send,send-observation,websocket-tap}.test.js`:预期无 canonical/metadata/correlator业务读写;仅允许明确 raw input fixture,不能出现在 expected normalized/public content;区分 CSS geometry。 +- `pnpm --filter @trade-message-center/onetalk-contract test`:contract guard 断言无宽高 image accepted、任一额外 dimension key rejected、保留 size/URL/nullable metadata 边界,旧 v5 frame 返回 upgrade error。 +- `pnpm --filter @trade-message-center/chrome-extension test`:decoder 断言 raw image dimensions 缺失、单边、错误类型、超界、正常存在均得到同一个无宽高 normalized content,非法 size/URL 仍 anomaly;websocket/parse tests 断言 history/live 共用 decoder且 raw 不跨 bridge。 +- correlator/image tests:无 candidate ID 时仅 `conversationId + sent + kind=image + sizeBytes + md5 + optional fileId + window + unique` 可确认;candidate ID 仍可越过时间/指纹但不能越过完整 sent guard;wrong conversation/direction、received、不完整、同图并发均不确认/`send_ambiguous`;relation metadata 中缺 dimensions 或带任意 dimensions 均不改变 expected。 +- `pnpm typecheck`、`pnpm build`、`pnpm format:check`、`git diff --check`;改协议后确认所有 frame creator/import 编译通过,并搜索 hard-coded `protocolVersion: 5` / `canAdmit(..., 5)`。 + +### Runtime probes(研究期间未执行) + +- Chromium/CDP 隔离要求:使用无敏感测试图片、已重载 v6 extension 并重开 OneTalk tab;确认 MAIN/ISOLATED/SW build hash相同。当前已知浏览器规则要求先观察真实 `page_command_result`,不能把 SDK Promise、页面图片、WebSocket frame count 当成功证据。 +- 最小探针:唯一同账号页面、selected 会话可与目标不同;发送流程 fetch → File → `tmpKey` interceptor → native `sendFile` → live WebSocket sent observation。记录仅脱敏 stage/request reference;检查 live canonical content 不含 `width`/`height`,并确认同一次 pending 得到 `confirmed_sent`。 +- 反例矩阵:raw payload dimension 缺失、仅 width、仅 height、字符串/负数/超界/随机 object 与正常 dimensions 各跑一次;其它 `size` 非法、URL host/action 非法仍产生原 anomaly。并发同图两 pending 必须 `send_ambiguous`,断线/目标账号变化/超时必须保留 `delivery_unknown`。 +- v6 deployment probe(需维护窗口,不在研究中启动):停旧 server → 执行已审查 JSONB migration → 启动 v6 server → 发布并重连 v6 extension/Mind;旧 v5 hello/frames 应收到明确 `onetalk_protocol_upgrade_required`,不接受/剥离旧 dimensions。生产/真实 Mind 联调当前未验证。 + +## 注意事项 / 未找到内容 + +- `docs/onetalk-image-send-observation-feasibility.md:9-25,435-459,472-554,560-578,620-648` 把 dimensions 写成 canonical image 和 correlator 条件;`docs/onetalk-media-message-sync-prd.md:145-146,235-252` 也有 image contract/code;这些当前文档必须同步为无宽高版本。`docs/onetalk-media-message-format-investigation.md:152-210`、`docs/onetalk-message-content-formats.md:301-363` 同时包含已验证 raw payload 样本(应保留但明确 raw dimensions 在 MAIN 边界被忽略)和旧的推荐 normalized contract(应更新/标注)。 +- `apps/server/src/onetalk/read-projection.ts`、`service.ts`、repository/schema 没有独立 image dimension reader;它们依赖 shared guard/JSONB 通用拷贝,但历史 image JSONB 在 guard 更新后需要 migration 才可读取。R1 不改 server migration。 +- 与消息合同无关的 DOM/CSS `width`/`height`(例如 conversation-id-copy 的可见矩形)必须保留;全局删词会破坏页面控件测试。 +- `ONETALK_PROTOCOL_VERSION` 升级是全协议 strict cutover,不是 image-only adapter;共享常量变更会影响 auth/heartbeat/sync/profile/buyer/message/send 所有 frame。`bright-v5` mode label 的处理是待 cross-cutting architecture decision。 +- 外部参考:无新增网络/外部文档;仓库内运行态调查仅作为历史证据,当前轮次未重新启动 Chromium、服务或数据库。 diff --git a/.trellis/tasks/09-11-remove-image-dimensions/research/persistence-harness.md b/.trellis/tasks/09-11-remove-image-dimensions/research/persistence-harness.md new file mode 100644 index 0000000..ed0f4ce --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/research/persistence-harness.md @@ -0,0 +1,78 @@ +# 研究:服务端图片持久化、读取投影与 Mind 测试支架 + +- 查询:核查 OneTalk `kind=image` 在 Bright PostgreSQL JSONB、严格读取投影、Drizzle migration/journal、server integration/read tests、Mind test harness validator/rendering 及非归档文档中的宽高消费者;确认 v6 无宽高数据迁移的最小安全范围。 +- 范围:internal +- 日期:2026-09-11 +- 协调类别:cross-cutting +- 阻塞:server 持久化/读取与 migration scope;Mind harness rendering/validator;公共 v6 contract/extension writer 必须先稳定并让旧 v5 writer 明确失败;不阻塞与数据持久化无关的 raw OneTalk 观察研究。 +- 共享边界:`apps/server/src/database/schema/onetalk.ts:60-118` 的 `onetalk_message.content` JSONB;`apps/server/src/onetalk/read-projection.ts:84-113` 的唯一 public message projection;共享 contract 的 `OneTalkMessageContent` guard 被 server/harness 消费;`apps/mind-test-harness/src/harness/{contract,validators,messages}.ts` 注入同一版本/内容规则。 +- 证据基线:checkout `/Users/ybf/code/trade-message-center-worktree`,HEAD `b47720872da147e9da791de77424007e21e1a74c`(`origin/main`/`main`);本轮开始时唯一 dirty path 为未跟踪的任务目录 `.trellis/tasks/09-11-remove-image-dimensions/`,未修改产品代码或测试。已刷新 source:server schema/read repository/projection/service,Drizzle SQL/meta/journal/migrate/config,server read/migration integration tests,harness validator/messages/contract/page,非归档 docs。 +- 复用证据与缺口:`.trellis/tasks/archive/2026-09/09-02-onetalk-media-message-sync/research/server-media-current.md:31-45,60-75,120-127` 已覆盖旧媒体持久化/读模型基线,但其 v3/v2 结论已过时;本报告刷新到当前 v5/0008 基线并补充 v6 dimension-only migration 风险。无当前任务的其它 research artifact 可复用;真实 PostgreSQL、Mind production、Chromium live smoke 仍未验证。 + +## 发现 + +### 服务端事实与读投影 + +- `onetalk_message.content` 是唯一 normalized JSONB 内容事实,schema 只以对象、`version=1`、`kind in ('text','image','file')` 做数据库层 CHECK;服务端 TypeScript 列类型直接是 `OneTalkMessageContent`(`apps/server/src/database/schema/onetalk.ts:60-118`)。没有独立的 width/height 列或图片 projection 列。 +- 写入 repository 对已验证消息浅复制 `content`,不重新解释 raw payload(`apps/server/src/onetalk/repository.ts:134-157`);重复复合键保留首次内容事实,因此迁移是旧图片去宽高的必要步骤,重新观察 duplicate 不会自动修复 JSONB(`apps/server/src/onetalk/repository.ts:306-365`)。 +- 读取 repository 只按账号+会话受限查询消息并复制 JSONB,read service 再统一走 `projectCenterMessage`(`apps/server/src/onetalk/read-repository.ts:240-251,371-400`;`apps/server/src/onetalk/read-service.ts:176-215`)。`toOneTalkCenterMessage` 在返回 Mind-facing message 前调用共享 `isOneTalkMessageContent`,无本地 image decoder/fallback(`apps/server/src/onetalk/read-projection.ts:84-113`)。因此旧 image JSONB 若仍有 dimensions,在新 strict guard 下会读失败;迁移必须先于新 server 读取窗口执行。 +- 现有 read-domain image fixtures 在 `apps/server/test/onetalk-read-domain.test.ts:395-462,464-539` 明确含 `width/height`,并断言 history 与 `message.created` 使用同一 normalized object;v6 应仅删除 fixture 的两个字段,同时保留 parity、public key whitelist、非图片 content 不变。 +- 当前 read PostgreSQL integration 真实插入消息 JSONB 并通过 service 读取(`apps/server/test/onetalk-read-postgres.integration.test.ts:133-179,270-307`),但没有旧 image migration/readback 用例;它是新增迁移 integration probe 的最佳归属,不应扩展到 JOIN 或新的 message table。 + +### Drizzle migration / journal + +- migration 单一目录和 journal 配置在 `apps/server/src/database/migration-config.ts:1-8`;`drizzle.config.ts:7-12` 使用 `schema: ./src/database/schema/**/*.ts`、`out: ./drizzle` 和该 journal;实际 migrator 使用 `drizzle.__drizzle_migrations`、schema `drizzle`、postgres.js `{ max: 1 }`(`apps/server/src/database/migrate.ts:37-65`)。 +- 当前 SQL 文件到 `0008_lucky_captain_universe.sql`,`meta/_journal.json:4-68` 有 idx 0..8 且 tag 与文件 stem 一致;当前 latest snapshot 是 `meta/0008_snapshot.json`,不存在 0006/0007 snapshot。新 migration 只能追加 `0009_.sql` 和 journal idx 9,不能编辑已执行 SQL、重排 idx 或伪造旧 migration。 +- 规范要求 schema source、generated SQL/meta、journal 受审查,已执行 migration 不可修改;`db:generate`/`db:check`/`db:migrate` 的边界和一次性 job 约束见 `.trellis/spec/server/backend/database-guidelines.md:94-151`。本轮执行 `pnpm --filter @trade-message-center/server db:check`,结果为 `Everything's fine`;该命令只检查 Drizzle 输出/快照,不执行 JSONB 数据清理,也不能证明迁移结果。 +- 任务设计的 `0009` 应是 data-only custom SQL:只对 `onetalk_message` 中 `content ->> 'kind' = 'image'` 的行执行顶层 `- 'width' - 'height'`(或等价、明确 null-safe 的 JSONB 更新),不改 schema/check、主键、索引、非 image 行或 image 其它键。建议 SQL 明确带 `WHERE content ->> 'kind' = 'image'`,避免把异常/非 image JSON 任意重写;`UPDATE ... SET content = content - 'width' - 'height' WHERE ...` 对缺失键保持幂等。 +- 迁移文件要使用现有 `--> statement-breakpoint` 约定;若用手工 data-only migration,应在 isolated PostgreSQL 上验证 migrator 能从 journal 发现并只执行一次。Drizzle Kit `check` 主要调用 `prepareOutFolder`/`validateWithReport` 检查 snapshot 目录(本地 `drizzle-kit@0.31.10` `bin.cjs` check handler),不会检查业务 SQL 是否覆盖每种 JSONB 行;不能以 `db:check` 代替数据 probe。 +- 迁移是不可逆数据删除。严格 v6 发布顺序必须是:停止旧 server/reader → 在隔离或维护窗口执行 0009 → 启动 v6 server → 发布并重连 v6 extension/Mind client。迁移后回滚代码只能继续读取无宽高 JSONB;恢复旧尺寸需要另写显式迁移且尺寸已不可恢复。不要用 `db:reset` 或手工生产 DELETE 作为本任务验证。 + +### Mind test harness + +- harness 是独立的人工联调工具,不加入根 `test/build`,只可依赖 shared contract;规范见 `.trellis/spec/mind-test-harness/development/boundary.md:1-44`。其 contract script 注入 shared `ONETALK_PROTOCOL_VERSION`、`ONETALK_CONTENT_VERSION` 和 enums,不应自行复制 protocol/version(`apps/mind-test-harness/src/harness/contract.ts:3-36`)。 +- 当前页面 validator 的 image exact key list 要求 `height`/`width`,并把二者当非负整数(`apps/mind-test-harness/src/harness/validators.ts:17-29`)。v6 validator 要求无 dimensions 的 exact key set;因为 `hasExactKeys` 先比较 key 数量和排序(`validators.ts:7-11`),带任意旧 width/height 会 fail closed,而非静默剥离。 +- 当前 renderer 在 image metadata 中读取并显示 `content.width + ' × ' + content.height`(`apps/mind-test-harness/src/harness/messages.ts:17-34`)。v6 只应展示 extension、sizeBytes 及既有 preview/error 状态;`harnessWebsocketScript` 的 frame 展示只脱敏 source URL,不读取 image dimensions(`apps/mind-test-harness/src/harness/websocket.ts:20-36`)。 +- harness 没有自己的 test 文件/脚本,按规范不应新增测试包或把它接入根门禁;可执行检查是 TypeScript 与生成 HTML 内嵌 script 语法/手工 smoke(规范 `boundary.md:45-66`)。server tests 可通过 `createOneTalkHarnessHtml` 的 static assertions 或 injected HTML 检查 validator/renderer 字符串,但不能声称 harness 自身有自动 DOM runtime coverage。 + +### 非归档文档锚点与排除边界 + +- 必须同步的当前 contract/方案文档:`docs/onetalk-media-message-sync-prd.md:135-153,228-259,387-425` 定义 image public/payload type、映射和 data flow;`docs/onetalk-image-send-observation-feasibility.md:7-25,435-459,472-556,558-578` 定义发送 matching 与 image contract;`docs/onetalk-file-send-observation-feasibility-2026-09-10.md:620-637` 的图片/文件比较表仍把 `width/height` 当展示字段和匹配指纹。它们应改为无宽高 canonical/matching,保留“raw 字段存在但 MAIN 边界忽略”的说明。 +- `docs/bright-conversation-list-api.md:115-173` 仍写旧 `CenterMessage`/`contentType` raw-era public shape,并不是本次只读 persistence scope 的新 canonical owner;若 v6 implementer 更新 public API 文档,应以 shared `OneTalkCenterMessage` 为准并只删除 dimensions,不借机重开 legacy API 重构。`docs/onetalk-summary-internal-api.md:11-21` 明确复用 Bright message contract,没有独立图片字段消费者。 +- `docs/onetalk-message-content-formats.md:292-363,692-729,798-813,860-889` 同时包含原始图片样本(可保留为 raw evidence)和历史/建议 normalized contract(需标注已过时并同步无宽高)。`docs/onetalk-media-message-format-investigation.md:170-205,323-329` 是原始字段/历史调查证据,应保留 raw `width/height` 样本但明确它们不跨 MAIN 边界;不要编辑归档 Trellis task 或归档研究记录。 +- 明确排除:`.trellis/tasks/archive/**`、`docs/archive/**`、raw capture/调查样本中描述 OneTalk 上游 `width/height` 的历史证据、独立 `/Users/ybf/work/trade-mind` workspace。任务 PRD 也明确不改外部 trade-mind、不改上游 raw payload、不修改归档记录(`.trellis/tasks/09-11-remove-image-dimensions/prd.md:7-35`)。 + +## 候选 Scope 与依赖 + +- `S2-server-persistence-read`(一个可独立验收的持久化闭环):拥有 `apps/server/test/onetalk-read-domain.test.ts`、`apps/server/test/onetalk-read-postgres.integration.test.ts` 及必要的 server read projection fixture;拥有/修改新 `apps/server/drizzle/0009_*.sql` 与 `meta/_journal.json`(以及若工具要求的 current snapshot);不修改旧 SQL、不改 server schema CHECK、不引入第二套 image normalizer、不使用 SQL JOIN。前提:shared v6 `OneTalkImageContent`/strict guard 稳定;迁移 writer 必须与 v6 reader 的 exact shape 同步。验收:旧 image JSONB 仅失去两个顶层键,非 image 与其它 image 键字节/语义不变;migration repeatable;新 read projection 能读取迁移后行并 history/public parity 保持。 +- `S3-harness-projection`(一个可独立验收的人工联调展示闭环):拥有 `apps/mind-test-harness/src/harness/validators.ts`、`messages.ts` 及相关 generated HTML static checks;排除 server route、外部 Mind、独立 harness tests/package scripts。前提:shared v6 protocol/content constants;与 server persistence writer write-disjoint,除非共同更新的 docs 被集中 owner 管理。验收:无宽高 image 通过 validator 并展示 extension/size/preview 状态;任一 width/height exact-key 注入被拒绝;页面脚本不读取/显示 dimensions。 +- `S4-active-docs`(文档同步职责):拥有上列非归档 canonical/方案文档中明确描述 normalized image contract、matching、harness display 的段落;排除 raw evidence sample 的字段事实、archive 目录和 trade-mind。前提:shared v6 contract 定义已决;应在代码闭环验收后执行,避免文档再次漂移。 +- 这些 scopes 不是按目录机械切分:S2 是 persisted read/migration 一个闭环;S3 是 validator/rendering 一个闭环;S4 是语义同步闭环。S2/S3 可在 shared v6 contract 结论稳定后并行,实际 shared docs/contract/schema 文件写入仍需各自 owner 协调;S4 必须等待最终 canonical key set 和 matching 规则,且 migration 发布顺序由 S2 维护。 + +## Invariant 与验收探针 + +- invariant owner:shared `apps/onetalk-contract` guard 是 canonical content shape owner;Bright PostgreSQL `onetalk_message.content` 是 persisted fact owner;`read-projection.ts` 是 public message projection owner;MAIN extension decoder 是 raw `width/height` 可见边界;harness validator/renderer 只能消费 shared normalized shape。任何 local fallback 或第二个 raw parser 都违反事实来源唯一性。 +- async/副作用边界:read path 是无写入的受限查询 → strict guard → public projection(`read-repository.ts:371-400`, `read-service.ts:176-215`, `read-projection.ts:95-113`);migration 是独立一次性 job,由 `runMigrations` 在单客户端 apply 后 finally close(`migrate.ts:37-65`)。不可逆边界为 JSONB `UPDATE`,必须在旧 server 停止后执行;异常/数据库失败不得向下游伪造成功。 + +### Static/unit probes + +1. `rg -n -i --glob '!docs/archive/**' --glob '!.trellis/tasks/archive/**' '\b(width|height)\b' apps/server apps/mind-test-harness docs`:代码结果只允许 UI/CSS geometry、raw-evidence 注释/fixture(若保留并明确边界)和非 canonical 文本;不应有 server public image/validator/renderer/matching dimensions。 +2. `node --experimental-strip-types --test apps/server/test/onetalk-read-domain.test.ts apps/server/test/migrate.test.ts`:无宽高 image fixture 可被 read projection 接受;带 dimensions 的 persisted shape 应被 shared strict guard 拒绝;migration runner 成功/失败都 close client。 +3. `pnpm --filter @trade-message-center/server db:check`:Drizzle snapshots/journal 结构一致;不能证明 JSONB 数据变化。 +4. `pnpm --filter @trade-message-center/mind-test-harness typecheck`:支架 TypeScript 编译;按规范不执行/添加 harness test script。可另用 `node --check` 检查从 `createOneTalkHarnessHtml` 提取的内嵌脚本,但需注明是 static syntax probe,不是浏览器 DOM smoke。 +5. `pnpm typecheck && pnpm build && pnpm test && pnpm format:check && git diff --check`:按任务计划执行根门禁;server package test 仍是 source + compiled dual run。 + +### Runtime/database probes(本轮未启动服务,未获得运行时证明) + +1. 隔离 PostgreSQL(必须显式 `TEST_DATABASE_URL`,每个 test timeout ≤60s):先 `runMigrations` 到 0008,插入同一账号下 text/file、image-with-width-height、image-with-only-width、image-with-only-height、image-with-unrelated-extra-key 及非 image sentinel;应用 0009,再查询 `content`。预期:只有 image 行移除顶层 `width`/`height`,其它字段/非 image JSON 不变;重复 `runMigrations` 不再更新/重复 journal;`drizzle.__drizzle_migrations` 恰有一个 0009 记录。 +2. 同一隔离数据库在迁移后用 `createOneTalkReadRepository` + `createOneTalkReadService` 读取 direct conversation;预期 image history/public projection 成功,输出无 dimensions;旧带 dimensions shape 在迁移前通过 new strict read guard 失败是预期窗口风险,不能让新 server 在迁移前读旧库。 +3. migration transaction/failure probe:让 SQL 或连接失败,确认 `db:migrate` 返回非零且 migration client finally close;确认失败不会发送 ACK/事件(读路径本身没有 ACK)。禁止连接生产 DB、执行 `db:reset`、手工 DELETE 或启动 server 作为“迁移证明”。 +4. Chromium/Mind harness smoke(需独立授权和可用环境):在 v6 server + v6 extension + 当前 harness/Mind client 完整切换后发送测试图片,检查 live `message.created`/`send.result` 的 canonical content 无 dimensions,并确认 harness 显示 extension/size/preview/error,不显示像素尺寸。没有 `TEST_DATABASE_URL`、Chromium/CDP、Mind session 时只能报告 external_unverified/blocked,不得以 static/unit 结果冒充端到端完成。 + +## 注意事项 / 未找到内容 + +- 当前 server migration/test 基线没有专门的 data-only JSONB migration、image cleanup integration 或 migration SQL 语义测试;`migrate.test.ts` 仅覆盖 URL、apply 和 client close(`apps/server/test/migrate.test.ts:24-61`)。新增 deterministic migration/readback test 是必要的,而不是依赖 `db:check`。 +- 当前 `apps/server/test/onetalk-read-postgres.integration.test.ts` 与 `apps/server/test/onetalk-postgres.integration.test.ts` 在缺少 `TEST_DATABASE_URL` 时明确 skip;本轮 read-domain 12/12 通过,migration/read integration 命令共 4 个 PG skips、4 个 unit passes。真实 PostgreSQL data mutation 尚未执行。 +- `OneTalkImageContent` 当前是 content v1;任务要求把 wire protocol 从 v5 升至 v6,但不要把 content version 误升为 v2,除非 shared contract owner 的设计另有决定。DB CHECK 仍允许 `version=1`,其 exact image keys 由 shared guard 负责。 +- 迁移只能处理现有 persisted canonical JSONB 顶层键;不能清洗 `onetalk_message_anomaly.payload`、raw OneTalk capture、IndexedDB、日志或历史文档中的 raw width/height,因为它们属于诊断/证据或外部边界,且任务明确排除。 +- 未找到独立 trade-mind checkout 的字段级图片 width/height consumer;本任务不修改 `/Users/ybf/work/trade-mind`,部署者需单独验证其 client 已切到 v6。未找到 harness 自动化 DOM test、生产 Mind UI 或 live media push runtime proof。 diff --git a/.trellis/tasks/09-11-remove-image-dimensions/task.json b/.trellis/tasks/09-11-remove-image-dimensions/task.json new file mode 100644 index 0000000..7756479 --- /dev/null +++ b/.trellis/tasks/09-11-remove-image-dimensions/task.json @@ -0,0 +1,26 @@ +{ + "id": "remove-image-dimensions", + "name": "remove-image-dimensions", + "title": "移除 OneTalk 图片宽高链路", + "description": "从 Mind 到 Bright、扩展与展示的图片消息合同中完全移除宽高,并修复 live 确认误判。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "ybf", + "assignee": "ybf", + "createdAt": "2026-09-11", + "completedAt": null, + "branch": "09-11-remove-image-dimensions", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/apps/chrome-extension/src/onetalk/main-page/image-send.ts b/apps/chrome-extension/src/onetalk/main-page/image-send.ts index 021a1e0..8b6d7b8 100644 --- a/apps/chrome-extension/src/onetalk/main-page/image-send.ts +++ b/apps/chrome-extension/src/onetalk/main-page/image-send.ts @@ -22,9 +22,6 @@ type FinalImageMetadata = { sizeBytes: number; /** OneTalk's upload relation callback always provides this stable content key. */ md5: string; - /** Present in some SDK variants, but absent from the production relation callback. */ - width?: number; - height?: number; fileId?: string; }; @@ -200,23 +197,9 @@ const metadataFrom = (value: unknown): FinalImageMetadata | null => { (fileId !== undefined && typeof fileId !== "string") ) return null; - const width = value.width; - const height = value.height; - const dimensionsArePresent = width !== undefined || height !== undefined; - if ( - dimensionsArePresent && - (typeof width !== "number" || - !Number.isSafeInteger(width) || - width < 0 || - typeof height !== "number" || - !Number.isSafeInteger(height) || - height < 0) - ) - return null; return { sizeBytes, md5, - ...(dimensionsArePresent ? { width, height } : {}), ...(fileId === undefined ? {} : { fileId }), }; }; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts index 52a08ce..91e26e1 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts @@ -3,7 +3,6 @@ import { isOneTalkMessageContent, ONETALK_CONTENT_VERSION, - ONETALK_MAX_IMAGE_DIMENSION_PX, ONETALK_MAX_MEDIA_SIZE_BYTES, type OneTalkMessageContent, } from "@trade-message-center/onetalk-contract"; @@ -191,18 +190,12 @@ const normalizeImage = (payload: Record): OneTalkRawContentDeco extension: typeof payload.suffix === "string" ? payload.suffix.toLowerCase() : payload.suffix, sizeBytes: payload.size, - width: payload.width, - height: payload.height, isOriginal: payload.isOriginal === 1, md5: nullableString(payload.md5), previewUrl, urlScope: "onetalk_session" as const, }; - if ( - !isBoundedInteger(payload.size, ONETALK_MAX_MEDIA_SIZE_BYTES) || - !isBoundedInteger(payload.width, ONETALK_MAX_IMAGE_DIMENSION_PX) || - !isBoundedInteger(payload.height, ONETALK_MAX_IMAGE_DIMENSION_PX) - ) { + if (!isBoundedInteger(payload.size, ONETALK_MAX_MEDIA_SIZE_BYTES)) { return anomaly("media_invalid_schema", "image"); } return isOneTalkMessageContent(normalized) diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts index 2caa6cf..11ad72a 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts @@ -29,8 +29,6 @@ type PendingImageSend = { expected: { sizeBytes: number; md5: string; - width?: number; - height?: number; fileId?: string; }; resolve: (result: PageCommandResult) => void; @@ -119,8 +117,6 @@ const matchesImage = ( if (message.content.kind !== "image") return false; if (message.content.sizeBytes !== expected.sizeBytes || message.content.md5 !== expected.md5) return false; - if (expected.width !== undefined && message.content.width !== expected.width) return false; - if (expected.height !== undefined && message.content.height !== expected.height) return false; return expected.fileId === undefined || message.content.fileId === expected.fileId; }; diff --git a/apps/chrome-extension/test/onetalk-image-send.test.js b/apps/chrome-extension/test/onetalk-image-send.test.js index 19fa6ac..9a0c37c 100644 --- a/apps/chrome-extension/test/onetalk-image-send.test.js +++ b/apps/chrome-extension/test/onetalk-image-send.test.js @@ -72,8 +72,6 @@ const observedImage = (conversationId, messageId, fileId) => ({ fileId, extension: "jpg", sizeBytes: 123, - width: 10, - height: 20, isOriginal: true, md5: `hash-${fileId}`, previewUrl: null, @@ -85,11 +83,12 @@ const observedImage = (conversationId, messageId, fileId) => ({ unreadCount: 0, }); +// Raw-only relation callback fixture: dimensions are hostile input and must not enter expected metadata. const metadata = (fileId) => ({ mediaInfo: { nodeSize: 123, - width: 10, - height: 20, + width: "ignored-raw-value", + height: -1, md5: `hash-${fileId}`, fileId, }, @@ -118,7 +117,7 @@ const createUploader = () => { }; }; -test("keeps source and bytes in MAIN while sending a named File to the exact target", async () => { +test("confirms an actual pending image send from a matching no-dimension live observation", async () => { const originalFetch = globalThis.fetch; const pageWindow = createPageWindow(); const clock = createClock(); diff --git a/apps/chrome-extension/test/onetalk-media-content-decoder.test.js b/apps/chrome-extension/test/onetalk-media-content-decoder.test.js index 44d00b1..cff7ace 100644 --- a/apps/chrome-extension/test/onetalk-media-content-decoder.test.js +++ b/apps/chrome-extension/test/onetalk-media-content-decoder.test.js @@ -16,6 +16,7 @@ const redirect = (action, id) => const thumbnail = (id) => `https://clouddisk.alibaba.com/file/videoThumb.htm?appkey=onetalk&id=${id}&parentId=parent-1&scene=im&secOperateAliId=operation-1`; +// Raw-only input fixture: upstream dimensions are intentionally present; normalized output must omit them. const imagePayload = { fileId: "image-jpeg-1", suffix: "JPG", @@ -60,8 +61,6 @@ test("normalizes real-shape JPEG, ZIP, PDF, and generic cardType=12 files", () = fileId: "image-jpeg-1", extension: "jpg", sizeBytes: 263_333, - width: 1_280, - height: 720, isOriginal: true, md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68", previewUrl: redirect("imagePreview", "image-jpeg-1"), @@ -132,7 +131,7 @@ test("normalizes exact SDK flat history media through the shared media contract" subType: 60, originalData: { ...imagePayload, width: "wide" }, }), - { status: "anomaly", code: "media_invalid_schema", mediaKind: "image" }, + decodeOneTalkRawContent(rawImage()), ); assert.deepEqual( decodeOneTalkMessageContent({ @@ -178,7 +177,6 @@ test("skips a legal non-file business card and aggregates every safe media anoma { contentType: 101, custom: { type: 7, data: "AAAA".repeat(131_073) } }, "media_payload_too_large", ], - [rawImage({ ...imagePayload, width: "wide" }), "media_invalid_schema"], [rawImage({ ...imagePayload, url: "https://evil.example/preview" }), "media_invalid_url"], ]; for (const [raw, code] of cases) { @@ -190,6 +188,41 @@ test("skips a legal non-file business card and aggregates every safe media anoma } }); +test("ignores all raw image dimensions at both live and flat-history decoder boundaries", () => { + const expected = decodeOneTalkRawContent(rawImage()); + const variants = [ + (() => { + const { width: _width, height: _height, ...withoutDimensions } = imagePayload; + return withoutDimensions; + })(), + { ...imagePayload, height: undefined }, + { ...imagePayload, width: undefined }, + { ...imagePayload, width: "wide", height: { raw: true } }, + { ...imagePayload, width: -1, height: -2 }, + { ...imagePayload, width: 65_536, height: Number.MAX_SAFE_INTEGER }, + imagePayload, + ]; + for (const payload of variants) { + assert.deepEqual(decodeOneTalkRawContent(rawImage(payload)), expected); + assert.deepEqual( + decodeOneTalkMessageContent({ + source: "sdk_flat_history", + msgType: 102, + subType: 60, + originalData: payload, + }), + expected, + ); + } +}); + +test("keeps malformed image sizes as schema anomalies while dimensions remain ignored", () => { + assert.deepEqual( + decodeOneTalkRawContent(rawImage({ ...imagePayload, size: -1, width: "ignored" })), + { status: "anomaly", code: "media_invalid_schema", mediaKind: "image" }, + ); +}); + test("enforces file suffix and URL action rules while preserving missing URL states", () => { const noUrls = decodeOneTalkRawContent( rawFile({ diff --git a/apps/chrome-extension/test/onetalk-send-observation.test.js b/apps/chrome-extension/test/onetalk-send-observation.test.js index dd81d42..dd7c0bc 100644 --- a/apps/chrome-extension/test/onetalk-send-observation.test.js +++ b/apps/chrome-extension/test/onetalk-send-observation.test.js @@ -166,7 +166,7 @@ test("does not confirm received, wrong-conversation, incomplete, or ambiguous ob assert.deepEqual(await second, { status: "delivery_unknown", reason: "send_ambiguous" }); }); -test("confirms images from relation metadata and fails closed on same-image ambiguity", async () => { +test("confirms no-dimension live images from relation metadata and fails closed on same-image ambiguity", async () => { const expected = { sizeBytes: 123, md5: "hash-1" }; const image = () => completeSent({ @@ -177,8 +177,6 @@ test("confirms images from relation metadata and fails closed on same-image ambi fileId: "file-1", extension: "jpg", sizeBytes: 123, - width: 10, - height: 20, isOriginal: true, md5: "hash-1", previewUrl: null, diff --git a/apps/chrome-extension/test/onetalk-websocket-tap.test.js b/apps/chrome-extension/test/onetalk-websocket-tap.test.js index f4d8897..dc8d112 100644 --- a/apps/chrome-extension/test/onetalk-websocket-tap.test.js +++ b/apps/chrome-extension/test/onetalk-websocket-tap.test.js @@ -363,8 +363,6 @@ test("adapts exact SDK flat history images and attachments through the sole hist fileId: "history-image", suffix: "JPG", size: 263_333, - width: 1_280, - height: 720, isOriginal: 1, md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68", url: redirect("imagePreview", "history-image"), diff --git a/apps/mind-test-harness/src/harness/messages.ts b/apps/mind-test-harness/src/harness/messages.ts index 102b380..11e6f23 100644 --- a/apps/mind-test-harness/src/harness/messages.ts +++ b/apps/mind-test-harness/src/harness/messages.ts @@ -19,7 +19,7 @@ export const harnessMessagesScript = String.raw` const messageKey = ( return '

' + escapeHtml(content.text) + '

'; } if (content.kind === 'image') { - const metadata = escapeHtml(content.extension + ' · ' + content.width + ' × ' + content.height + ' · ' + formatSize(content.sizeBytes)); + const metadata = escapeHtml(content.extension + ' · ' + formatSize(content.sizeBytes)); const preview = content.previewUrl ? 'OneTalk 图片预览' : '

图片未提供预览地址。

'; diff --git a/apps/mind-test-harness/src/harness/validators.ts b/apps/mind-test-harness/src/harness/validators.ts index e445cea..be93932 100644 --- a/apps/mind-test-harness/src/harness/validators.ts +++ b/apps/mind-test-harness/src/harness/validators.ts @@ -18,9 +18,9 @@ export const harnessValidatorsScript = String.raw` const isRecord = ( const isNormalizedContent = (value) => { if (!isRecord(value)) return false; if (value.kind === 'text') return hasExactKeys(value, ['kind', 'text', 'version']) && value.version === contentVersion && isNonBlankString(value.text); - if (value.kind === 'image') return hasExactKeys(value, ['extension', 'fileId', 'height', 'isOriginal', 'kind', 'md5', 'previewUrl', 'sizeBytes', 'urlScope', 'version', 'width']) + if (value.kind === 'image') return hasExactKeys(value, ['extension', 'fileId', 'isOriginal', 'kind', 'md5', 'previewUrl', 'sizeBytes', 'urlScope', 'version']) && value.version === contentVersion && isNonBlankString(value.fileId) && isNonBlankString(value.extension) - && isNonNegativeInteger(value.sizeBytes) && isNonNegativeInteger(value.width) && isNonNegativeInteger(value.height) + && isNonNegativeInteger(value.sizeBytes) && typeof value.isOriginal === 'boolean' && isNullableString(value.md5) && isNullableString(value.previewUrl) && value.urlScope === 'onetalk_session'; if (value.kind === 'file') return hasExactKeys(value, ['downloadState', 'downloadUrl', 'extension', 'fileId', 'fileName', 'kind', 'md5', 'parentId', 'previewUrl', 'sizeBytes', 'thumbnailUrl', 'urlScope', 'version']) && value.version === contentVersion && isNonBlankString(value.fileId) && isNonBlankString(value.parentId) && isNonBlankString(value.fileName) && isNonBlankString(value.extension) @@ -56,11 +56,14 @@ export const harnessValidatorsScript = String.raw` const isRecord = ( && (value.messagePreview === null || isNonBlankText(value.messagePreview)); const isCustomerProfile = (value) => isRecord(value) - && hasExactKeys(value, ['avatarUrl', 'buyer_features', 'buyer_tags', 'company_name', 'country_code', 'name']) + && hasExactKeys(value, ['avatarUrl', 'buyer_features', 'buyer_tags', 'company_name', 'company_website', 'country_code', 'email', 'name', 'registration_date']) && (value.name === null || isNonBlankText(value.name)) && (value.avatarUrl === null || isNonBlankString(value.avatarUrl)) && (value.buyer_tags === null || (Array.isArray(value.buyer_tags) && value.buyer_tags.every(isNonBlankText))) && (value.buyer_features === null || (Array.isArray(value.buyer_features) && value.buyer_features.every(isNonBlankText))) + && (value.email === null || isNonBlankText(value.email)) + && (value.registration_date === null || isNonBlankText(value.registration_date)) + && (value.company_website === null || isNonBlankText(value.company_website)) && (value.country_code === null || isNonBlankText(value.country_code)) && (value.company_name === null || isNonBlankText(value.company_name)); diff --git a/apps/onetalk-contract/src/content.ts b/apps/onetalk-contract/src/content.ts index 5a875dd..65b3d44 100644 --- a/apps/onetalk-contract/src/content.ts +++ b/apps/onetalk-contract/src/content.ts @@ -5,7 +5,6 @@ import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts"; export const ONETALK_CONTENT_VERSION = 1 as const; export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3; -export const ONETALK_MAX_IMAGE_DIMENSION_PX = 65_535; export const ONETALK_CONTENT_KINDS = ["text", "image", "file"] as const; export type OneTalkMessageContentKind = (typeof ONETALK_CONTENT_KINDS)[number]; @@ -22,8 +21,6 @@ export type OneTalkImageContent = { fileId: string; extension: string; sizeBytes: number; - width: number; - height: number; isOriginal: boolean; md5: string | null; previewUrl: string | null; @@ -88,8 +85,6 @@ const CONTENT_KEYS = { "fileId", "extension", "sizeBytes", - "width", - "height", "isOriginal", "md5", "previewUrl", @@ -282,8 +277,6 @@ const isOneTalkImageContent = (value: Record): value is OneTalk isNonBlankString(value.fileId, MAX_IDENTIFIER_LENGTH) && isExtension(value.extension) && isBoundedNonNegativeSafeInteger(value.sizeBytes, ONETALK_MAX_MEDIA_SIZE_BYTES) && - isBoundedNonNegativeSafeInteger(value.width, ONETALK_MAX_IMAGE_DIMENSION_PX) && - isBoundedNonNegativeSafeInteger(value.height, ONETALK_MAX_IMAGE_DIMENSION_PX) && typeof value.isOriginal === "boolean" && isMd5(value.md5) && isOptionalMediaUrl(value.previewUrl, REDIRECT_URL_PATH, ["imagePreview"]) && diff --git a/apps/onetalk-contract/src/wire.ts b/apps/onetalk-contract/src/wire.ts index 1dff898..ad1b5d6 100644 --- a/apps/onetalk-contract/src/wire.ts +++ b/apps/onetalk-contract/src/wire.ts @@ -38,7 +38,7 @@ import type { OneTalkSendResultFrame, } from "./sending.ts"; -export const ONETALK_PROTOCOL_VERSION = 5 as const; +export const ONETALK_PROTOCOL_VERSION = 6 as const; export const ONETALK_FRAME_TYPES = [ "ws.hello", diff --git a/apps/onetalk-contract/test/contract.test.ts b/apps/onetalk-contract/test/contract.test.ts index 76a3692..880828a 100644 --- a/apps/onetalk-contract/test/contract.test.ts +++ b/apps/onetalk-contract/test/contract.test.ts @@ -12,7 +12,6 @@ import { ONETALK_MESSAGE_BATCH_MAX_FRAME_BYTES, ONETALK_CONTENT_VERSION, ONETALK_ERROR_CODES, - ONETALK_MAX_IMAGE_DIMENSION_PX, ONETALK_MAX_MEDIA_SIZE_BYTES, ONETALK_MESSAGES_ROUTE, ONETALK_HISTORY_ROUTE, @@ -122,8 +121,6 @@ const jpegContent = { fileId: "image-jpeg-1", extension: "jpg", sizeBytes: 263_333, - width: 1_280, - height: 720, isOriginal: true, md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68", previewUrl: @@ -244,8 +241,8 @@ test("rejects an unknown protocol version with the upgrade error", () => { }); }); -test("hard-rejects v3 and all other old protocol versions with the upgrade error", () => { - for (const protocolVersion of [0, 1, 2, 3, 99]) { +test("hard-rejects v5 and all other old protocol versions with the upgrade error", () => { + for (const protocolVersion of [0, 1, 2, 3, 4, 5, 99]) { const result = decodeOneTalkFrame({ ...frameBase, protocolVersion, @@ -1098,6 +1095,23 @@ test("validates normalized text, JPEG, ZIP, and PDF content with exact metadata" assert.equal(imageSendCommand.type, "send.command"); }); +test("rejects legacy image dimensions as exact-shape extras", () => { + for (const [key, value] of [ + ["width", 1_280], + ["height", 720], + ["width", null], + ["height", "720"], + ["width", { raw: true }], + ["height", [720]], + ["width", Number.MAX_SAFE_INTEGER + 1], + ["height", -1], + ] as const) { + const content = { ...jpegContent, [key]: value }; + assert.equal(isOneTalkMessageContent(content), false); + assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false }); + } +}); + test("accepts multiline text while rejecting non-text control characters", () => { for (const text of ["line one\nline two", "line one\r\nline two", "column one\tcolumn two"]) { const content = { ...textContent, text }; @@ -1173,12 +1187,10 @@ test("accepts only exact outbound media sources without media policy duplication test("enforces media metadata bounds and legal nullable media URL states", () => { for (const content of [ - { ...jpegContent, sizeBytes: 0, width: 0, height: 0, previewUrl: null }, + { ...jpegContent, sizeBytes: 0, previewUrl: null }, { ...jpegContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES, - width: ONETALK_MAX_IMAGE_DIMENSION_PX, - height: ONETALK_MAX_IMAGE_DIMENSION_PX, }, { ...zipContent, sizeBytes: 0, thumbnailUrl: null }, { ...zipContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES, thumbnailUrl: null }, @@ -1196,12 +1208,8 @@ test("enforces media metadata bounds and legal nullable media URL states", () => for (const content of [ { ...jpegContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES + 1 }, { ...zipContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES + 1 }, - { ...jpegContent, width: ONETALK_MAX_IMAGE_DIMENSION_PX + 1 }, - { ...jpegContent, height: ONETALK_MAX_IMAGE_DIMENSION_PX + 1 }, { ...jpegContent, sizeBytes: Number.MAX_SAFE_INTEGER + 1 }, { ...zipContent, sizeBytes: Number.MAX_SAFE_INTEGER + 1 }, - { ...jpegContent, width: Number.MAX_SAFE_INTEGER + 1 }, - { ...jpegContent, height: Number.MAX_SAFE_INTEGER + 1 }, ]) { assert.equal(isOneTalkMessageContent(content), false); assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false }); @@ -1403,7 +1411,7 @@ test("uses the shared public Center message for created frames and internal fact }); }); -test("freezes exact public conversation and private history routes for v5", () => { +test("freezes exact public conversation and private history routes", () => { assert.equal( ONETALK_MESSAGES_ROUTE, "/api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages", diff --git a/apps/server/drizzle/0009_remove_image_dimensions.sql b/apps/server/drizzle/0009_remove_image_dimensions.sql new file mode 100644 index 0000000..460fa22 --- /dev/null +++ b/apps/server/drizzle/0009_remove_image_dimensions.sql @@ -0,0 +1,4 @@ +UPDATE "onetalk_message" +SET "content" = "content" - 'width' - 'height' +WHERE "content" ->> 'kind' = 'image'; +--> statement-breakpoint diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 7c3d0d7..83a1ae6 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1789035791605, "tag": "0008_lucky_captain_universe", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1789102776000, + "tag": "0009_remove_image_dimensions", + "breakpoints": true } ] } diff --git a/apps/server/src/cutover-policy.ts b/apps/server/src/cutover-policy.ts index e3e57f2..3ca6aff 100644 --- a/apps/server/src/cutover-policy.ts +++ b/apps/server/src/cutover-policy.ts @@ -10,7 +10,7 @@ export type OneTalkCutoverState = { export type OneTalkCutoverListener = () => void; export type OneTalkCutoverPolicy = { canAdmit: ( - mode: "bright-v4" | "bright-v5" | "legacy", + mode: "bright-v6" | "legacy", connectionType: "plugin" | "mind_page", protocolVersion: number, ) => boolean; @@ -23,7 +23,7 @@ export type OneTalkCutoverPolicy = { subscribe: (listener: OneTalkCutoverListener) => () => void; }; -/** 创建只控制 Bright v5 admission 和 monotonic epoch 的切换策略。 */ +/** 创建只控制 Bright v6 admission 和 monotonic epoch 的切换策略。 */ export const createOneTalkCutoverPolicy = ( initial: OneTalkCutoverState = { enabled: true, paused: false }, ): OneTalkCutoverPolicy => { @@ -36,7 +36,7 @@ export const createOneTalkCutoverPolicy = ( }; return { canAdmit: (mode, connectionType, protocolVersion) => - mode === "bright-v5" && + mode === "bright-v6" && state.enabled && !state.paused && protocolVersion === ONETALK_PROTOCOL_VERSION && diff --git a/apps/server/src/http/onetalk/public-context.ts b/apps/server/src/http/onetalk/public-context.ts index 159cfb9..c5592df 100644 --- a/apps/server/src/http/onetalk/public-context.ts +++ b/apps/server/src/http/onetalk/public-context.ts @@ -135,7 +135,7 @@ export const installPublicPreflight = ( options: BrightReadRouteOptions, ): void => { app.options("/api/bright/onetalk/*", async (request, reply) => { - if (!options.cutoverPolicy.canAdmit("bright-v5", "mind_page", ONETALK_PROTOCOL_VERSION)) { + if (!options.cutoverPolicy.canAdmit("bright-v6", "mind_page", ONETALK_PROTOCOL_VERSION)) { return sendError(reply, 503, "authorization_unavailable"); } if ( @@ -163,11 +163,11 @@ export const createPublicReadContext = (options: BrightReadRouteOptions): Public return ( epoch !== undefined && options.cutoverPolicy.isCurrent(epoch) && - options.cutoverPolicy.canAdmit("bright-v5", "mind_page", ONETALK_PROTOCOL_VERSION) + options.cutoverPolicy.canAdmit("bright-v6", "mind_page", ONETALK_PROTOCOL_VERSION) ); }; const mindOriginGuard = async (request: FastifyRequest, reply: FastifyReply): Promise => { - if (!options.cutoverPolicy.canAdmit("bright-v5", "mind_page", ONETALK_PROTOCOL_VERSION)) { + if (!options.cutoverPolicy.canAdmit("bright-v6", "mind_page", ONETALK_PROTOCOL_VERSION)) { return void sendError(reply, 503, "authorization_unavailable"); } requestEpochs.set(request, options.cutoverPolicy.capture()); diff --git a/apps/server/src/http/onetalk/summary.ts b/apps/server/src/http/onetalk/summary.ts index be41b3e..9162ad5 100644 --- a/apps/server/src/http/onetalk/summary.ts +++ b/apps/server/src/http/onetalk/summary.ts @@ -45,14 +45,14 @@ export const installInternalSummaryRoute = ( return ( epoch !== undefined && options.cutoverPolicy.isCurrent(epoch) && - options.cutoverPolicy.canAdmit("bright-v5", "mind_page", ONETALK_PROTOCOL_VERSION) + options.cutoverPolicy.canAdmit("bright-v6", "mind_page", ONETALK_PROTOCOL_VERSION) ); }; const internalAdmissionGuard = async ( request: FastifyRequest, reply: FastifyReply, ): Promise => { - if (!options.cutoverPolicy.canAdmit("bright-v5", "mind_page", ONETALK_PROTOCOL_VERSION)) { + if (!options.cutoverPolicy.canAdmit("bright-v6", "mind_page", ONETALK_PROTOCOL_VERSION)) { return void sendError(reply, 503, "authorization_unavailable"); } requestEpochs.set(request, options.cutoverPolicy.capture()); diff --git a/apps/server/src/websocket/connection-store.ts b/apps/server/src/websocket/connection-store.ts index cd48aca..f806ef0 100644 --- a/apps/server/src/websocket/connection-store.ts +++ b/apps/server/src/websocket/connection-store.ts @@ -122,7 +122,7 @@ export const createOneTalkConnectionStore = (options: { let suppressStatusNotifications = false; const policyAdmits = (connectionType: "plugin" | "mind_page"): boolean => - options.cutoverPolicy?.canAdmit("bright-v5", connectionType, ONETALK_PROTOCOL_VERSION) ?? + options.cutoverPolicy?.canAdmit("bright-v6", connectionType, ONETALK_PROTOCOL_VERSION) ?? true; const epochIsCurrent = (epoch: number): boolean => options.cutoverPolicy?.isCurrent(epoch) ?? true; diff --git a/apps/server/src/websocket/handler.ts b/apps/server/src/websocket/handler.ts index ba3d56b..c4a35e5 100644 --- a/apps/server/src/websocket/handler.ts +++ b/apps/server/src/websocket/handler.ts @@ -205,7 +205,7 @@ export const createOneTalkWebSocketHandler = const isPolicyCurrent = (epoch: number): boolean => (options.cutoverPolicy?.isCurrent(epoch) ?? true) && (options.cutoverPolicy?.canAdmit( - "bright-v5", + "bright-v6", options.expectedConnectionType, ONETALK_PROTOCOL_VERSION, ) ?? @@ -265,7 +265,7 @@ export const createOneTalkWebSocketHandler = expectedConnectionType: options.expectedConnectionType, canAdmit: (frame) => options.cutoverPolicy?.canAdmit( - "bright-v5", + "bright-v6", frame.connectionType, frame.protocolVersion, ) ?? true, diff --git a/apps/server/src/websocket/index.ts b/apps/server/src/websocket/index.ts index 23d4212..8378c59 100644 --- a/apps/server/src/websocket/index.ts +++ b/apps/server/src/websocket/index.ts @@ -77,7 +77,7 @@ const registerWebsocketRoutes = ( if ( routeType !== null && cutoverPolicy !== undefined && - cutoverPolicy.canAdmit("bright-v5", routeType, ONETALK_PROTOCOL_VERSION) !== true + cutoverPolicy.canAdmit("bright-v6", routeType, ONETALK_PROTOCOL_VERSION) !== true ) { reportDiagnostic(onDiagnostic, { event: "ws_decision", diff --git a/apps/server/test/mind-authorization.test.ts b/apps/server/test/mind-authorization.test.ts index 6943388..ad8ceb9 100644 --- a/apps/server/test/mind-authorization.test.ts +++ b/apps/server/test/mind-authorization.test.ts @@ -8,6 +8,7 @@ import { type MindAuthorizationClientConfig, } from "../src/mind-authorization.ts"; import { createOneTalkCutoverPolicy } from "../src/cutover-policy.ts"; +import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract"; const scope = { mindUserId: "mind-user-1", @@ -356,15 +357,16 @@ test("enforces exact Mind status-code pairs and transport failure boundaries", a ); }); -test("cutover pause is fail-closed and Bright v4 can resume", () => { +test("cutover pause is fail-closed and Bright v6 can resume", () => { const policy = createOneTalkCutoverPolicy(); - assert.equal(policy.canAdmit("bright-v5", "plugin", 5), true); + assert.equal(policy.canAdmit("bright-v6", "plugin", ONETALK_PROTOCOL_VERSION), true); policy.pause(); - assert.equal(policy.canAdmit("bright-v5", "plugin", 5), false); + assert.equal(policy.canAdmit("bright-v6", "plugin", ONETALK_PROTOCOL_VERSION), false); assert.equal(policy.resume(), true); policy.pause(); assert.equal(policy.resume(), true); assert.equal(policy.snapshot().paused, false); - assert.equal(policy.canAdmit("bright-v5", "plugin", 5), true); + assert.equal(policy.canAdmit("bright-v6", "plugin", ONETALK_PROTOCOL_VERSION), true); + assert.equal(policy.canAdmit("bright-v6", "plugin", 5), false); assert.equal(policy.canAdmit("legacy", "plugin", 3), false); }); diff --git a/apps/server/test/observation-batcher.test.ts b/apps/server/test/observation-batcher.test.ts index a69a87f..4158d9e 100644 --- a/apps/server/test/observation-batcher.test.ts +++ b/apps/server/test/observation-batcher.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract"; import type { OneTalkMessage, OneTalkMessageObservedFrame, @@ -51,7 +52,7 @@ const message = (messageId: string): OneTalkMessage => ({ const guard: OneTalkCommitGuard = { assertValid: () => {} }; const singleFrame = (requestId: string, observed: OneTalkMessage): OneTalkMessageObservedFrame => ({ - protocolVersion: 5, + protocolVersion: ONETALK_PROTOCOL_VERSION, connectionType: "plugin", type: "message.observed", requestId, @@ -63,7 +64,7 @@ const arrayFrame = ( requestId: string, messages: OneTalkMessage[], ): OneTalkMessagesObservedFrame => ({ - protocolVersion: 5, + protocolVersion: ONETALK_PROTOCOL_VERSION, connectionType: "plugin", type: "messages.observed", requestId, diff --git a/apps/server/test/onetalk-http.test.ts b/apps/server/test/onetalk-http.test.ts index 57adcc9..7cb6795 100644 --- a/apps/server/test/onetalk-http.test.ts +++ b/apps/server/test/onetalk-http.test.ts @@ -672,7 +672,7 @@ test("maps database errors without disclosing database details", async () => { } }); -test("fences an in-flight list when Bright v4 pauses during the read await", async () => { +test("fences an in-flight list when Bright v6 pauses during the read await", async () => { let begin!: () => void; let release!: (value: Awaited>) => void; const began = new Promise((resolve) => { diff --git a/apps/server/test/onetalk-image-dimensions-migration.test.ts b/apps/server/test/onetalk-image-dimensions-migration.test.ts new file mode 100644 index 0000000..175e580 --- /dev/null +++ b/apps/server/test/onetalk-image-dimensions-migration.test.ts @@ -0,0 +1,19 @@ +// 验证 OneTalk 图片宽高数据迁移的结构边界 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const migrationPath = new URL("../drizzle/0009_remove_image_dimensions.sql", import.meta.url); + +test("keeps the image-dimensions migration as one breakpoint-delimited data statement", async () => { + const sql = await readFile(migrationPath, "utf8"); + + assert.match(sql, /--> statement-breakpoint\s*$/); + assert.match(sql, /UPDATE\s+"onetalk_message"/i); + assert.match(sql, /SET\s+"content"\s*=\s*"content"\s*-\s*'width'\s*-\s*'height'/i); + assert.match(sql, /WHERE\s+"content"\s*->>\s*'kind'\s*=\s*'image'/i); + assert.equal((sql.match(/\bUPDATE\b/gi) ?? []).length, 1); + assert.equal((sql.match(/\bDELETE\b/gi) ?? []).length, 0); + assert.equal((sql.match(/\bALTER\b/gi) ?? []).length, 0); +}); diff --git a/apps/server/test/onetalk-read-domain.test.ts b/apps/server/test/onetalk-read-domain.test.ts index 3e61ae6..ed7f5a0 100644 --- a/apps/server/test/onetalk-read-domain.test.ts +++ b/apps/server/test/onetalk-read-domain.test.ts @@ -399,8 +399,6 @@ test("projects normalized text, image, and file content without raw reinterpreta fileId: "image-file-1", extension: "jpg", sizeBytes: 42_000, - width: 1280, - height: 720, isOriginal: true, md5: "a".repeat(32), previewUrl: @@ -478,8 +476,6 @@ test("projects one persisted media fact identically for history and message.crea fileId: "image-file-1", extension: "jpg", sizeBytes: 42_000, - width: 1280, - height: 720, isOriginal: true, md5: null, previewUrl: null, @@ -507,8 +503,6 @@ test("CenterMessage accepts normalized image and file values", () => { fileId: "image-file-1", extension: "jpg", sizeBytes: 42_000, - width: 1280, - height: 720, isOriginal: true, md5: null, previewUrl: null, @@ -538,6 +532,30 @@ test("CenterMessage accepts normalized image and file values", () => { assert.equal(verifiedFile.content.fileName, "quote.pdf"); }); +test("rejects persisted image content with retired dimensions at the read boundary", () => { + const legacyImage = { + ...messageFieldsForProjectionTest(), + content: { + version: 1, + kind: "image", + fileId: "image-file-1", + extension: "jpg", + sizeBytes: 42_000, + width: 1280, + height: 720, + isOriginal: true, + md5: null, + previewUrl: null, + urlScope: "onetalk_session", + }, + } as unknown as OneTalkMessage; + + assert.throws( + () => toOneTalkCenterMessage(legacyImage), + /Invalid persisted OneTalk message content/, + ); +}); + test("keeps history cursor codecs separate from legacy and list cursor shapes", () => { const historyCursor = encodeOneTalkHistoryReadCursor({ channelAccountId: "account-1", diff --git a/apps/server/test/onetalk-read-postgres.integration.test.ts b/apps/server/test/onetalk-read-postgres.integration.test.ts index c71d60b..fba6369 100644 --- a/apps/server/test/onetalk-read-postgres.integration.test.ts +++ b/apps/server/test/onetalk-read-postgres.integration.test.ts @@ -11,6 +11,7 @@ import { runMigrations } from "../src/database/migrate.ts"; import { createOneTalkReadRepository, createOneTalkReadService } from "../src/onetalk/index.ts"; const databaseUrl = process.env.TEST_DATABASE_URL?.trim(); +const imageDimensionMigrationTimestamp = 1_789_102_776_000; test( "reads only direct snapshot facts with real latest-message and profile semantics", @@ -39,6 +40,169 @@ test( }); try { + await client` + delete from drizzle.__drizzle_migrations + where created_at = ${imageDimensionMigrationTimestamp} + `; + await client` + insert into onetalk_conversation ( + channel_account_id, + conversation_id, + conversation_kind, + history_complete, + first_discovered_at, + last_observed_at + ) values ( + ${channelAccountId}, + 'migration-image', + 'direct', + true, + ${asOf}, + ${asOf} + ) + `; + const legacyImage = { + version: 1, + kind: "image", + fileId: "migration-image-file", + extension: "jpg", + sizeBytes: 42_000, + width: 1280, + height: 720, + isOriginal: true, + md5: "a".repeat(32), + previewUrl: null, + urlScope: "onetalk_session", + }; + const canonicalImage = { + version: 1, + kind: "image", + fileId: "migration-image-file", + extension: "jpg", + sizeBytes: 42_000, + isOriginal: true, + md5: "a".repeat(32), + previewUrl: null, + urlScope: "onetalk_session", + }; + const imageWithOnlyWidth = { + ...canonicalImage, + fileId: "image-with-only-width", + width: 800, + }; + const imageWithOnlyHeight = { + ...canonicalImage, + fileId: "image-with-only-height", + height: 600, + }; + const imageWithoutDimensions = { + ...canonicalImage, + fileId: "image-with-only-width", + }; + const imageWithoutHeight = { + ...canonicalImage, + fileId: "image-with-only-height", + }; + const imageWithPreservedKey = { + ...legacyImage, + fileId: "image-with-preserved-key", + retainedMigrationProbe: { source: "pre-v6" }, + }; + const preservedImage = { + ...canonicalImage, + fileId: "image-with-preserved-key", + retainedMigrationProbe: { source: "pre-v6" }, + }; + const textSentinel = { + version: 1, + kind: "text", + text: "unmodified text", + width: 900, + height: 901, + }; + for (const [conversationId, messageId, content] of [ + ["migration-image", "legacy-image", legacyImage], + ["migration-preservation", "image-only-width", imageWithOnlyWidth], + ["migration-preservation", "image-only-height", imageWithOnlyHeight], + ["migration-preservation", "image-preserved-key", imageWithPreservedKey], + ["migration-preservation", "text-sentinel", textSentinel], + ] as const) { + await client` + insert into onetalk_message ( + channel_account_id, + conversation_id, + message_id, + sender_id, + binding, + mind_user_id, + workspace_id, + device_id, + direction, + observation_type, + sent_at_ms, + content, + participant_ids, + read_status, + message_status, + unread_count, + first_observed_at, + last_observed_at + ) values ( + ${channelAccountId}, + ${conversationId}, + ${messageId}, + 'sender-1', + 'binding-1', + 'mind-user-1', + 'workspace-1', + 'device-1', + 'received', + 'history', + 1, + ${client.json(content)}, + ${["sender-1", channelAccountId]}, + 1, + 2, + 0, + ${asOf}, + ${asOf} + ) + `; + } + await runMigrations(databaseUrl); + const migratedRows = await client< + { + content: Record; + message_id: string; + }[] + >` + select message_id, content + from onetalk_message + where channel_account_id = ${channelAccountId} + order by message_id + `; + assert.deepEqual(migratedRows, [ + { message_id: "image-only-height", content: imageWithoutHeight }, + { message_id: "image-only-width", content: imageWithoutDimensions }, + { message_id: "image-preserved-key", content: preservedImage }, + { message_id: "legacy-image", content: canonicalImage }, + { message_id: "text-sentinel", content: textSentinel }, + ]); + const migratedHistory = await service.readHistory({ + scope, + conversationId: "migration-image", + }); + assert.equal(migratedHistory.status, "accepted"); + if (migratedHistory.status !== "accepted") return; + assert.deepEqual(migratedHistory.messages[0]?.content, canonicalImage); + await runMigrations(databaseUrl); + const migrationRecords = await client<{ count: string }[]>` + select count(*) as count + from drizzle.__drizzle_migrations + where created_at = ${imageDimensionMigrationTimestamp} + `; + assert.equal(migrationRecords[0]?.count, "1"); + for (const [conversationId, conversationKind, discoveredAt, lastMessageAtMs] of [ ["direct-a", "direct", asOf, 300], ["direct-b", "direct", asOf, 250], @@ -306,6 +470,7 @@ test( { status: "rejected", reason: "history_incomplete" }, ); } finally { + await runMigrations(databaseUrl); await client` delete from onetalk_message where channel_account_id = ${channelAccountId} diff --git a/apps/server/test/onetalk-websocket.test.ts b/apps/server/test/onetalk-websocket.test.ts index d8d81d9..193811f 100644 --- a/apps/server/test/onetalk-websocket.test.ts +++ b/apps/server/test/onetalk-websocket.test.ts @@ -1926,7 +1926,7 @@ test("claims confirmation once and makes a terminal late confirmation a no-op", assert.equal(processCalls, 1); }); -test("pausing Bright v4 closes existing sockets with 1013 without an error frame", async () => { +test("pausing Bright v6 closes existing sockets with 1013 without an error frame", async () => { const policy = createOneTalkCutoverPolicy(); const app = createApp(testConfig, { database: createDatabaseStub(), diff --git a/docs/onetalk-file-send-observation-feasibility-2026-09-10.md b/docs/onetalk-file-send-observation-feasibility-2026-09-10.md index 268c21c..14d78c9 100644 --- a/docs/onetalk-file-send-observation-feasibility-2026-09-10.md +++ b/docs/onetalk-file-send-observation-feasibility-2026-09-10.md @@ -4,6 +4,8 @@ > 环境:OneTalk SaaS 测试环境,Chromium CDP `127.0.0.1:9222` > 性质:运行态调查与可行性结论,不包含代码实现 > 隐私约束:本文不记录真实会话 ID、账号 ID、Token、Cookie、媒体完整 URL、URL 查询值或 MD5 原值 +> +> 历史快照说明(2026-09-11):下文的“当前”“已实现”和能力结论均指 2026-09-10 的调查环境,不构成当前工作树的 file-send 发布承诺。本轮 v6 只完成并检查了图片无尺寸合同;文件发送、其 pending/matcher 与真实 Chromium 联调须在独立范围按当前代码重新验证。 ## 1. 结论摘要 @@ -619,22 +621,24 @@ node --experimental-strip-types --test \ ## 9. 文件与图片的不同点 -| 维度 | 图片 | 普通文件 | -| ------------------ | -------------------------------- | ---------------------------------------------------- | -| 页面分类 | `imageCard` | `fileCard` | -| 页面兼容 `msgType` | `60` | `53` | -| BaaS 输入类型 | 图片类型,实测历史为 `102` | 页面输入默认 `107`,历史归一化为 `10010` | -| 历史 `subType` | `60` | `61` | -| raw content | `contentType=101/custom.type=7` | `contentType=101/custom.type=10010` | -| 二次判别 | 图片 payload schema | 必须同时满足 `cardType=12`;`10010` 本身不够 | -| 核心显示字段 | `width/height/isOriginal` | `fileName/parentId/downloadState` | -| 共同字段 | `fileId/extension/size/md5/url` | `fileId/extension/size/md5/url` | -| 大小类型 | raw `size` 为 number | raw `params.size` 为十进制 string | -| 压缩 | 约 1 MB 以上图片可能先压缩 | ZIP/PDF 等普通文件不做图片压缩 | -| 匹配指纹 | 大小 + 宽 + 高 + 可选 MD5/fileId | 文件名 + 扩展 + 大小 + 可选 MD5/fileId/parentId | -| URL 语义 | 主要是 image preview | 可能区分 office preview、download、thumbnail | -| 显式 downloadUrl | 图片合同没有下载状态 | 可为空;可由 `url.fileAction=download` 派生 | -| 文件真实性 | 可由图片解码进一步验证像素 | observer 无二进制,不能验证扩展名与 magic bytes 一致 | +| 维度 | 图片 | 普通文件 | +| ------------------ | ---------------------------------------------------- | ---------------------------------------------------- | +| 页面分类 | `imageCard` | `fileCard` | +| 页面兼容 `msgType` | `60` | `53` | +| BaaS 输入类型 | 图片类型,实测历史为 `102` | 页面输入默认 `107`,历史归一化为 `10010` | +| 历史 `subType` | `60` | `61` | +| raw content | `contentType=101/custom.type=7` | `contentType=101/custom.type=10010` | +| 二次判别 | 图片 payload schema | 必须同时满足 `cardType=12`;`10010` 本身不够 | +| 核心显示字段 | `extension/sizeBytes/isOriginal` | `fileName/parentId/downloadState` | +| 共同字段 | `fileId/extension/sizeBytes/md5/previewUrl/urlScope` | `fileId/extension/size/md5/url` | +| 大小类型 | raw `size` 为 number | raw `params.size` 为十进制 string | +| 压缩 | 约 1 MB 以上图片可能先压缩 | ZIP/PDF 等普通文件不做图片压缩 | +| 匹配指纹 | 大小 + MD5 + 可选 fileId | 文件名 + 扩展 + 大小 + 可选 MD5/fileId/parentId | +| URL 语义 | 主要是 image preview | 可能区分 office preview、download、thumbnail | +| 显式 downloadUrl | 图片合同没有下载状态 | 可为空;可由 `url.fileAction=download` 派生 | +| 文件真实性 | 仅验证 OneTalk canonical metadata | observer 无二进制,不能验证扩展名与 magic bytes 一致 | + +图片 raw payload 仍可能携带 `width` / `height`,但它们只属于 OneTalk 上游证据:v6 MAIN decoder 忽略这两个字段,normalized/public image、post-upload metadata、confirmation fingerprint 与 harness display 均不读取或显示它们。 ## 10. 关键注意点 @@ -672,7 +676,7 @@ URL 可能包含会话授权、临时签名、重定向和不同 `fileAction`。 上传、分片、大文件策略和关系建立可能耗时较长。发送确认计时器应只覆盖最终消息发送阶段,而不是整个文件上传阶段。 -### 10.6 当前代码能力边界 +### 10.6 2026-09-10 调查时的代码能力边界 - 接收/观测合同已经支持 `content.kind="file"`。 - 当前工作树中的出站合同正在扩展 `text | image`,尚未包含 `file`。 diff --git a/docs/onetalk-image-send-observation-feasibility.md b/docs/onetalk-image-send-observation-feasibility.md index 3a7f7f7..7a23696 100644 --- a/docs/onetalk-image-send-observation-feasibility.md +++ b/docs/onetalk-image-send-observation-feasibility.md @@ -3,15 +3,17 @@ > 日期:2026-09-10 > 性质:测试环境运行态调查与实现可行性报告,不是 Trellis task,不包含代码实现 > 范围:本地图片发送到非当前打开会话,以及通过 WebSocket observer 确认 sent 图片事实 +> +> v6 同步说明(2026-09-11):本报告中的 raw upload、像素尺寸和当时 live 记录是历史调查证据;当前 canonical image、post-upload metadata、confirmation fingerprint 与 harness display 均不使用 `width` / `height`。未在本报告的旧运行环境复跑 v6 live 验收。 ## 1. 结论 本次调查确认两件事: 1. **可以在页面当前打开其它会话时,向显式指定的目标会话发送图片。** 最终路由由发送参数中的 `cid` 决定,不要求切换页面 selected conversation。 -2. **可以在 WebSocket 观测阶段使用图片的大小、宽度和高度辅助确认发送结果。** live WS 图片会被 MAIN-world 解码器归一化为 `content.kind="image"`,并保留 `sizeBytes`、`width`、`height`。 +2. **可以在 WebSocket 观测阶段使用图片的已验证 canonical metadata 辅助确认发送结果。** v6 MAIN-world decoder 将 live WS 图片归一化为 `content.kind="image"`,只保留 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 和 `urlScope`。 -但 `sizeBytes + width + height` 不是唯一键。本次连续发送同一图片后,扩展存储中出现了两条不同的 live sent 消息,它们的三个字段完全相同。因此该三元组只能作为复合匹配条件,不能单独生成 `confirmed_sent`。 +但 `sizeBytes` 不是唯一键;即使 `md5` 和 `fileId` 可用,重复发送同一文件时也可能相同。本次连续发送同一图片后,扩展存储中出现了两条不同的 live sent 消息。因此 metadata 只能作为无 candidate ID 时的复合匹配条件,不能单独生成 `confirmed_sent`。 推荐确认顺序: @@ -19,7 +21,7 @@ 可靠候选 messageId → conversationId + direction=sent → content.kind=image - → post-upload sizeBytes + width + height + → post-upload sizeBytes + md5 + optional fileId → 短时间窗口 → 必须唯一匹配,否则 send_ambiguous ``` @@ -77,17 +79,17 @@ https://onetalk.alibaba.com/message/weblitePWA.htm 运行结果: -| 证据 | 结果 | -| ---------------------------- | ---------------------------------------------- | -| `prepareSendFileWithGroup` | HTTP `200` | -| `buildFileRelationWithGroup` | HTTP `200` | -| OSS 二进制上传 | 未发生,命中文件已存在/去重分支 | -| BaaS `sendMessageBase` | 已进入一次 | -| 页面 `send-msg-success` | 一次 | -| WebSocket 帧 | 出站 3、入站 3 | -| Runtime exception | 0 | -| selected conversation | 全程不变 | -| 目标会话只读历史 | 找到一条与 `I1` 大小和宽高完全一致的 sent 图片 | +| 证据 | 结果 | +| ---------------------------- | ------------------------------------ | +| `prepareSendFileWithGroup` | HTTP `200` | +| `buildFileRelationWithGroup` | HTTP `200` | +| OSS 二进制上传 | 未发生,命中文件已存在/去重分支 | +| BaaS `sendMessageBase` | 已进入一次 | +| 页面 `send-msg-success` | 一次 | +| WebSocket 帧 | 出站 3、入站 3 | +| Runtime exception | 0 | +| selected conversation | 全程不变 | +| 目标会话只读历史 | 找到一条与 `I1` 大小一致的 sent 图片 | 页面 `send-msg-success` 和 SDK Promise 只证明本地受理,不能单独证明发送完成。目标会话只读历史中的 sent 图片事实才排除了“只插入了页面假消息”的情况。 @@ -95,17 +97,17 @@ https://onetalk.alibaba.com/message/weblitePWA.htm 扩展 Service Worker 的 IndexedDB 中,对目标会话和 `I1` 的归一化字段进行只读匹配: -| 项目 | 结果 | -| ------------------------------------ | ---------------- | -| exact image candidate | 3 条 | -| `observationSource="live"` | 2 条 | -| `observationSource="history"` | 1 条 | -| candidate status | 全部 `confirmed` | -| live 记录的 `sizeBytes/width/height` | 与 `I1` 完全一致 | +| 项目 | 结果 | +| --------------------------------------------- | ---------------- | +| exact image candidate | 3 条 | +| `observationSource="live"` | 2 条 | +| `observationSource="history"` | 1 条 | +| candidate status | 全部 `confirmed` | +| 当时 live 记录的 raw `sizeBytes/width/height` | 与 `I1` 完全一致 | -手工调用的只读历史接口没有把返回值送入页面 bridge 或 Service Worker;同时 raw WebSocket history response 会被 observer 主动忽略。因此 `observationSource="live"` 的两条记录证明,图片大小和宽高确实能经过 live WS observer 到达归一化存储边界。 +手工调用的只读历史接口没有把返回值送入页面 bridge 或 Service Worker;同时 raw WebSocket history response 会被 observer 主动忽略。因此 `observationSource="live"` 的两条记录证明当时的 live 观察链已能接收图片事实。该记录来自 v6 前的调查,不构成对当前 v6 normalized shape 的运行态验证:当前 MAIN decoder 忽略 raw `width` / `height`,它们不进入归一化存储边界。 -这两条 live 消息也构成反例:相同图片重复发送时,大小和宽高完全相同,但它们是不同的消息事实。 +这两条 live 消息也构成反例:相同图片重复发送时,媒体 metadata 可以相同,但它们仍是不同的消息事实。 ## 4. 图片发送路径与参数 @@ -292,7 +294,7 @@ mtop.alibaba.interaction.clouddisk.buildFileRelationWithGroup } ``` -成功结果提供后续 `sendFile` 所需的媒体关系,例如 `fileId`、`fileCardUrl`、`redirectFileUrl`、图片尺寸和文件节点信息。 +成功结果提供后续 `sendFile` 所需的媒体关系,例如 `fileId`、`fileCardUrl`、`redirectFileUrl` 和文件节点信息;若上游关系含图片尺寸,它们仍只属于 raw upload evidence,v6 downstream 不读取。 ### 4.7 最终页面发送参数 @@ -341,7 +343,7 @@ conversationCode = input.cid || sdkContext.cid; 因此只要传入非空目标 `cid`,当前页面 selected conversation 不参与最终路由;缺失 `cid` 时才会回退 SDK 当前上下文,存在发错会话风险。 -图片被转换为 BaaS `originalData`: +图片会被转换为 BaaS raw `originalData`: ```ts { @@ -356,6 +358,8 @@ conversationCode = input.cid || sdkContext.cid; } ``` +这是页面上游的 raw upload payload 示例,不是 v6 canonical contract。即使该 raw payload 含 `width` / `height`,MAIN decoder 也会忽略它们,且 post-upload metadata、correlator 和下游 public content 都不会读取或传递这两个字段。 + ### 4.8 `sendImageMessage` 快捷入口 页面 SDK 还暴露: @@ -441,8 +445,6 @@ type OneTalkImageContent = { fileId: string; extension: string; sizeBytes: number; - width: number; - height: number; isOriginal: boolean; md5: string | null; previewUrl: string | null; @@ -454,8 +456,8 @@ send correlator 不需要也不应该继续读取 raw `originalData`。应比较 ```text message.content.sizeBytes -message.content.width -message.content.height +message.content.md5 +message.content.fileId (when present) ``` ### 5.3 observer 与 correlator 的调用顺序 @@ -469,11 +471,11 @@ observedSink(batch); 因此图片 live batch 在跨 MAIN bridge、写 IndexedDB 或上传 Bright 之前,已经可以交给发送确认 correlator。无需新增第二个 WebSocket observer,也不应增加另一套 raw payload parser。 -## 6. 建议的图片确认模型 +## 6. 当前 v6 图片确认模型 ### 6.1 Pending 数据 -现有 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 的 `PendingSend` 只保存字符串正文。图片支持应改为判别联合,而不是给文本结构追加一组可选字段: +v6 的 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 已使用 text/image/file 判别联合;图片 pending 保存目标会话、候选消息 ID 与最终 post-upload metadata,而不是给文本记录追加可选字段。下列 image 分支是当前实现的形状: ```ts type PendingTextSend = { @@ -491,21 +493,19 @@ type PendingImageSend = { candidateMessageIds: Set; expected: { sizeBytes: number; - width: number; - height: number; - md5?: string | null; + md5: string; fileId?: string; }; }; -type PendingSend = PendingTextSend | PendingImageSend; +type PendingObservation = PendingTextSend | PendingImageSend | PendingFileSend; ``` `md5` 和 `fileId` 可以提高不同图片之间的区分度,但同一文件去重或重复发送时它们也可能相同,仍不能当作每次发送的唯一 ID。 ### 6.2 登记时机 -图片 pending 必须在以下时机登记: +图片 pending 在取得最终 media relation 后、最终 native send 前登记: ```text 压缩完成 @@ -516,18 +516,18 @@ type PendingSend = PendingTextSend | PendingImageSend; → 立即调用 sendUIMessages ``` -更准确地说,应在 `buildFileRelationWithGroup` 成功、`sendFile` 已拿到最终 `nodeSize/width/height` 后,并在最终 SDK send 之前登记。 +实现以 `tmpKey` 隔离上传回调;在 `buildFileRelationWithGroup` 成功、`sendFile` 已拿到最终 `sizeBytes`、非空 `md5` 和可用 `fileId` 后,立即将该 metadata 交给 correlator 并调用最终 SDK send。45 秒预算覆盖下载、上传和 live 确认,correlator 使用其剩余时间。 不能在用户选择原始文件时登记,原因有两个: -1. 图片压缩可能改变 `sizeBytes`,甚至改变尺寸; -2. 上传时间可能超过当前 correlator 的 `10_000ms` 超时。 +1. 图片压缩可能改变最终 `sizeBytes`; +2. 上传时间可能超过该历史调查时 correlator 的 `10_000ms` 超时;当前 image 预算为 45 秒。 上传阶段和消息发送确认阶段应是两个状态,不要让消息确认定时器覆盖完整上传耗时。 ### 6.3 匹配顺序 -建议匹配逻辑: +已实现的匹配顺序: ```text 1. message.direction 必须是 sent @@ -535,8 +535,8 @@ type PendingSend = PendingTextSend | PendingImageSend; 3. message 必须通过完整 OneTalkMessage guard 4. 如果存在可靠 candidateMessageId:只按 messageId 匹配,不回退媒体指纹 5. 否则要求 pending.kind=image 且 message.content.kind=image -6. 比较 post-upload sizeBytes、width、height -7. 可选比较 md5/fileId,但不能把它们当作单次发送唯一键 +6. 比较 post-upload `sizeBytes`、`md5` 和可用 `fileId` +7. 这些 metadata 不能被当作单次发送唯一键 8. 要求消息位于 pending 生命周期和允许的时钟偏差内 9. 一个消息必须只匹配一个 pending;多个匹配立即 send_ambiguous ``` @@ -549,8 +549,8 @@ const imageMatches = ( actual: OneTalkImageContent, ): boolean => actual.sizeBytes === expected.sizeBytes && - actual.width === expected.width && - actual.height === expected.height; + actual.md5 === expected.md5 && + (expected.fileId === undefined || actual.fileId === expected.fileId); ``` 该函数只能是复合匹配的一部分,不能绕过 conversation、direction、时间窗口和唯一性检查。 @@ -561,21 +561,21 @@ const imageMatches = ( 至少需要补充以下测试: -| 用例 | 预期 | -| -------------------------- | ----------------------------------------------------------- | -| live WS 图片 raw payload | 输出 `content.kind=image` 及准确的 `sizeBytes/width/height` | -| 正确会话、方向、指纹和时间 | `confirmed_sent` | -| 错误 conversation | 不匹配 | -| `direction=received` | 不匹配 | -| 宽度、宽高或大小任一不同 | 不匹配 | -| 候选 message ID 匹配 | 即使时间窗口外仍按 ID 确认 | -| 候选 message ID 不匹配 | 不回退图片指纹 | -| 同图两个并发 pending | `send_ambiguous` | -| 图片压缩后大小变化 | 使用 post-upload 大小确认 | -| 超时无 live echo | `delivery_unknown/send_state_lost` | -| 非法 Base64/JSON/schema | anomaly,不进入 correlator | +| 用例 | 预期 | +| --------------------------------------- | ------------------------------------------------------------ | +| live WS 图片 raw payload | 忽略 raw `width` / `height`;输出无尺寸的 v6 canonical image | +| 正确会话、方向、指纹和时间 | `confirmed_sent` | +| 错误 conversation | 不匹配 | +| `direction=received` | 不匹配 | +| `sizeBytes`、`md5` 或可用 `fileId` 不同 | 不匹配 | +| 候选 message ID 匹配 | 即使时间窗口外仍按 ID 确认 | +| 候选 message ID 不匹配 | 不回退图片指纹 | +| 同图两个并发 pending | `send_ambiguous` | +| 图片压缩后大小变化 | 使用 post-upload 大小确认 | +| 超时无 live echo | `delivery_unknown/send_timeout` | +| 非法 Base64/JSON/schema | anomaly,不进入 correlator | -现有测试已经覆盖 raw 图片解码、flat history 图片归一化、非法 live 媒体隔离和文本 send confirmation;尚缺成功 live 图片直接驱动 correlator 的用例。 +定向测试覆盖 raw 图片解码、flat history 图片归一化、非法 live 媒体隔离,以及无尺寸 live sent image 驱动实际 image pending 至 `confirmed_sent`。这只是自动化证据;尚未在本报告原有 Chromium 环境执行 v6 真实发送联调。 ### 7.2 Chromium/CDP 联调 @@ -583,7 +583,7 @@ const imageMatches = ( 1. 打开会话 A,但指定目标会话 B。 2. 记录 selected conversation 的内部比较结果,不输出真实 ID。 -3. 选择一张已知大小和宽高的测试图片。 +3. 选择一张已知大小的测试图片。 4. 对大图额外记录压缩后的最终 metadata。 5. 在最终 `sendUIMessages` 前登记 image pending。 6. 观察 prepare、OSS/去重、build relation 和 BaaS send 的状态。 @@ -619,9 +619,9 @@ SDK send 已执行 ## 8. 注意点与风险 -### 8.1 大小与宽高不唯一 +### 8.1 媒体 metadata 不唯一 -同一图片重复发送会产生不同的 messageId,但 `sizeBytes/width/height` 完全相同。本次运行态已经得到两条这样的 live sent 记录。 +同一图片重复发送会产生不同的 messageId,但 `sizeBytes`、`md5` 和 `fileId` 可以完全相同。本次运行态已经得到两条这样的 live sent 记录。 不得采用: @@ -665,20 +665,22 @@ V2 `sendUIMessages` 的 Promise 可以在 local callback 得到 clientId/opId raw `originalData` 只应在 MAIN world 短暂存在。send correlator 应消费已经归一化的 `OneTalkImageContent`,不要在 correlator、Service Worker 或 Bright 中再实现第二套 Base64/JSON 图片解析。 -## 9. 实现边界建议 +上游 raw `originalData` 可含 `width` / `height`;v6 MAIN decoder 忽略它们,correlator 的 post-upload expected 和 public/read payload 只使用无尺寸的 canonical contract。 -该能力技术上可行,建议后续实现限定为: +## 9. 当前实现边界 + +当前实现限定为: 1. 为页面图片发送定义独立、最小的输入合同; 2. 在上传关系成功后生成 post-upload image fingerprint; -3. 将 `PendingSend` 改成 text/image 判别联合; +3. 使用 text/image/file 判别的 pending 集合; 4. 复用现有 live WS observer 和 normalized content,不新增旁路; 5. 候选 ID 优先,媒体指纹仅作无 ID 回退; 6. 保留唯一匹配与 `send_ambiguous`; 7. 分离 upload timeout 与 send confirmation timeout; 8. 保持 SDK 异常和不确定投递 fail closed,不自动重试。 -当前仓库的 [`page-command.ts`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts) 仍只接受字符串 `content`,[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 仍以文本正文为无 ID 回退条件。本报告确认的是图片扩展方案可行,不表示当前插件已经具备图片发送命令和图片发送确认合同。 +[`page-command.ts`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts) 现已严格接收 outbound `text | image | file` contract,并将 image 交给 `sendOneTalkImage`;[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 先按 candidate message ID 确认,缺少候选 ID 时才以同会话、sent 方向、image kind、`sizeBytes`、`md5`、可用 `fileId`、时间窗和唯一性回退。当前自动化证据不代替未执行的 v6 Chromium live 验收。 ## 10. 相关代码 diff --git a/docs/onetalk-media-message-format-investigation.md b/docs/onetalk-media-message-format-investigation.md index 07dcb90..5fd47cc 100644 --- a/docs/onetalk-media-message-format-investigation.md +++ b/docs/onetalk-media-message-format-investigation.md @@ -4,6 +4,8 @@ > 调查对象:`https://onetalk.alibaba.com/message/weblitePWA.htm` 以及当前 `trade-message-center` OneTalk 扩展链路 > 调查方式:Chromium DevTools Protocol(CDP,`127.0.0.1:9222`)只读运行时探查、历史 WebSocket 帧捕获、已加载 SDK bundle 静态检索、仓库代码追踪 > 安全边界:本报告不保存或展示 Cookie、`sid`、`chatToken`、加密账号、签名 URL、消息正文和二进制内容;示例只保留字段名、类型和脱敏结构。 +> +> v6 同步说明(2026-09-11):raw WebSocket/SDK 样本仍是历史调查证据;当前规范性图片合同只包含 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`(另有 `version`、`kind`)。MAIN decoder 忽略 raw `width` / `height`,它们不跨 normalized boundary;本报告不是 v6 live runtime 验收。 ## 1. 摘要 @@ -13,7 +15,7 @@ OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文 - 图片:`contentType = 101`,`content.custom.type = 7`,`content.custom.data` 是 Base64 编码的 JSON。 - 附件:`contentType = 101`,`content.custom.type = 10010`,`content.custom.data` 是 Base64 编码的 JSON。 -当前扩展的传输和持久化边界已经能够保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当前没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。 +以下是 2026-09-01 的历史调查结论:当时扩展的传输和持久化边界会保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当时没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。它不描述当前 v6 流程。 本次 CDP 实测在当前登录页面的两个已加载会话中调用了只读历史接口,捕获到一页 20 条消息的历史 WebSocket 帧;现有 `parseOneTalkMessages` 返回了全部 20 条,其中包含一条图片和一条附件。没有点击上传、发送或下载,发送侧结论只来自 SDK 和 bundle 的方法/调用形态分析。 @@ -30,9 +32,9 @@ OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文 5. 对同一帧运行仓库现有 `parseOneTalkMessages` 后,图片和附件仍保留在 `message.content`,但 `message.text` 为 `null`。 6. 图片和附件的 `custom.data` 经 Base64 解码后是 JSON,而不是二进制图片或文件本体。 -### 2.2 代码级确认、尚未做完整端到端实测 +### 2.2 历史代码级确认、尚未做完整端到端实测 -- Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。 +- 当时 Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。 - 当前没有真实数据库写入后的 Mind 页面媒体渲染回归测试。 - 没有执行真实图片上传、附件上传、发送确认和下载操作,因此不能把 SDK bundle 中的发送能力称为扩展已经支持的能力。 @@ -180,7 +182,7 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位 } ``` -这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是元数据,不是图片二进制本体。 +这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是 raw 上游元数据,不是图片二进制本体。此处的 `width` / `height` 仅保留为调查证据;当前 v6 MAIN decoder 忽略它们,且它们不会跨出 normalized boundary。 ### 5.2 页面 SDK 归一化形态 @@ -207,7 +209,9 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位 } ``` -`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。同步事实应继续保留原始内容,归类字段只作为投影依据。 +`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。上游 raw 内容只在 MAIN 边界短暂存在;同步事实使用其规范化投影,归类字段只作为投影依据。 + +SDK `originalData` 中的 `width` / `height` 同样只是 raw SDK 证据,不是 current v6 normalized image contract 的字段;MAIN decoder 不读取或传递它们。 ## 6. 附件消息格式 @@ -279,28 +283,26 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位 `subType = 61`、`msgType = 10010` 是页面显示/消息模型的归类,不是可以脱离 `custom.type = 10010` 单独使用的稳定事实键。 -## 7. 当前仓库的数据流追踪 +## 7. 历史数据流与当前 v6 边界 ### 7.1 页面观察器 -历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中: +2026-09-01 的历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在当时的 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中: 1. 只要 `message.content` 是对象,就把它作为完整 `content` 保留。 2. 从 `content.contentType` 提取 `contentType`。 3. 只有 `content.text.content` 是字符串时,才填充 `text`。 4. 图片/附件没有 `content.text.content`,所以 `text` 为 `null`。 -因此,当前代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。 +因此,历史代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。 ### 7.2 页面桥与 Service Worker -[页面桥转换](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66) 会复制观察消息的所有 JSON 字段;如果原消息已有 `content`,不会用 `text` 覆盖它。`content` 进入 Service Worker 后仍然是通用 JSON 值。 +这是历史实现:页面桥会复制观察消息的 JSON 字段,`content` 进入 Service Worker 后仍是通用 JSON 值。它已被 v6 normalized boundary 取代。 ### 7.3 Bright 服务与数据库 -[Bright 归一化](/Users/ybf/code/trade-message-center-worktree/apps/server/src/onetalk/service.ts:170) 对 `content` 执行通用 JSON 校验和敏感键过滤,然后将清洗后的 `content` 持久化。当前公共契约中的 [OneTalkMessage](/Users/ybf/code/trade-message-center-worktree/apps/onetalk-contract/src/model.ts:203) 也将 `content` 定义为通用 `OneTalkJsonValue`,没有要求它必须含有 `text`。 - -所以现有事实链可以保存: +历史 Bright 归一化对 `content` 执行通用 JSON 校验和敏感键过滤,然后持久化。历史事实链为: ```text OneTalk raw content @@ -311,28 +313,41 @@ OneTalk raw content → Mind history response ``` -当前缺少的是在某个明确边界增加媒体语义投影,而不是重新设计这条事实链。 +这条 raw 事实链仅为历史调查证据,当前不得使用。v6 的规范路径为: -## 8. 已经可以做到什么 +```text +OneTalk raw content + → MAIN decoder(raw 只停留在此处) + → normalized content + → page bridge / Service Worker / IndexedDB + → Bright canonical JSONB / HTTP history + → Mind read model +``` -| 能力 | 当前状态 | 证据/限制 | -| ----------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- | -| 接收文本历史消息 | 已验证 | 现有观察器提取 `text` | -| 接收图片历史消息 | 已验证 | CDP 实测 `custom.type=7`,现有 parser 保留 `content` | -| 接收附件历史消息 | 已验证 | CDP 实测 `custom.type=10010`,现有 parser 保留 `content` | -| 保留原始媒体元数据 | 代码已支持 | 通用 JSON `content` 贯穿页面桥、Service Worker、Bright | -| 按 `channelAccountId + conversationId + messageId` 幂等保存 | 代码已支持 | 媒体不改变消息业务键 | -| 在 Mind 历史接口返回原始媒体 JSON | 代码路径支持 | 尚未做真实 DB 写入和 Mind UI 回归 | -| 将图片字段投影为 `imageUrl/width/height` | 当前未实现 | 需要新增共享内容解码器/投影器 | -| 将附件字段投影为文件名、大小、预览和下载动作 | 当前未实现 | 需要处理 `downloadUrl` 为空的情况 | -| 在 Mind 页面显示图片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 | -| 在 Mind 页面显示附件卡片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 | -| OneTalk 文本发送 | 已有路径 | `sendUIMessages` 与文本确认逻辑以字符串正文为中心 | -| OneTalk 图片发送 | 页面 SDK 有方法 | bundle 观察到 `sendImageMessage({ cid, picUrl })`;扩展未接入完整发送契约 | -| OneTalk 本地文件/附件发送 | 页面 bundle 有上传流程 | 涉及 `prepareSendFileWithGroup`、OSS 上传和文件卡片;未做真实上传验证 | -| 图片/附件发送确认 | 当前未实现 | 出站确认关联器只按文本内容匹配 | -| 下载二进制到 Bright | 当前未实现 | 当前只保存消息 JSON,不保存媒体本体 | -| 群聊图片/附件全量同步 | 当前未确认 | 既有历史同步对群聊会话有跳过/不支持边界 | +Bright 不保存 raw JSON、`custom.data` 或 SDK row。 + +## 8. 2026-09-01 时已经可以做到什么 + +下表除明确标为 v6 的行外,均是历史能力快照;其中 raw content 贯穿页面桥、IndexedDB、Bright 和 Mind 的行不得作为当前实现或发布依据。 + +| 能力 | 历史状态 | 证据/限制 | +| ----------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------- | +| 接收文本历史消息 | 已验证 | 现有观察器提取 `text` | +| 接收图片历史消息 | 已验证 | CDP 实测 `custom.type=7`,现有 parser 保留 `content` | +| 接收附件历史消息 | 已验证 | CDP 实测 `custom.type=10010`,现有 parser 保留 `content` | +| 保留原始媒体元数据 | 历史实现 | 通用 JSON `content` 曾贯穿页面桥、Service Worker、Bright | +| 按 `channelAccountId + conversationId + messageId` 幂等保存 | 代码已支持 | 媒体不改变消息业务键 | +| 在 Mind 历史接口返回原始媒体 JSON | 历史路径 | 已由 v6 normalized-only boundary 取代 | +| 将图片字段投影为 v6 canonical image metadata | 当前已实现 | 只保留 fileId、extension、sizeBytes、isOriginal、md5、previewUrl、urlScope | +| 将附件字段投影为文件名、大小、预览和下载动作 | 当前未实现 | 需要处理 `downloadUrl` 为空的情况 | +| 在 Mind 页面显示图片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 | +| 在 Mind 页面显示附件卡片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 | +| OneTalk 文本发送 | 已有路径 | `sendUIMessages` 与文本确认逻辑以字符串正文为中心 | +| OneTalk 图片发送 | 页面 SDK 有方法 | bundle 观察到 `sendImageMessage({ cid, picUrl })`;扩展未接入完整发送契约 | +| OneTalk 本地文件/附件发送 | 页面 bundle 有上传流程 | 涉及 `prepareSendFileWithGroup`、OSS 上传和文件卡片;未做真实上传验证 | +| 图片/附件发送确认 | 当前未实现 | 出站确认关联器只按文本内容匹配 | +| 下载二进制到 Bright | 当前未实现 | 当前只保存消息 JSON,不保存媒体本体 | +| 群聊图片/附件全量同步 | 当前未确认 | 既有历史同步对群聊会话有跳过/不支持边界 | ## 9. 发送侧调查结果 @@ -353,7 +368,7 @@ messageService.sendImageMessage({ }); ``` -这说明 OneTalk 页面具有图片发送入口,但这不等于扩展已经具备图片发送能力。扩展当前页面命令在 [page-command.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts:97) 中要求 `command.content` 是字符串,并交给文本型 `sendUIMessages`;需要新增图片输入契约、SDK 调用和发送确认规则后才能接入。 +这说明 OneTalk 页面具有图片发送入口,但这不等于调查时的扩展已经具备图片发送能力。当时页面命令要求 `command.content` 是字符串,并交给文本型 `sendUIMessages`;这个历史限制已被当前 v6 image outbound contract 与确认路径取代。 ### 9.2 文件/附件发送 @@ -387,17 +402,17 @@ nodeName ### 9.3 发送确认限制 -当前 [send-observation.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:11) 的待确认状态以字符串 `content` 和消息时间窗口进行关联。对于图片/附件: +调查时 [send-observation.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:11) 的待确认状态以字符串 `content` 和消息时间窗口进行关联。对于图片/附件: - 图片通常没有可比较的文本正文。 - 附件卡片的 HTML/URL 可能在发送前后发生变化。 - `sendImageMessage` 或文件上传回调返回的操作 ID不能直接当作最终消息 ID,必须等待完整的 sent-direction OneTalk 观察消息。 -因此图片/附件发送确认不能简单复用“比较 `text`”的逻辑,也不能仅凭 SDK Promise resolve 就写入 Bright。 +因此图片/附件发送确认不能简单复用“比较 `text`”的逻辑,也不能仅凭 SDK Promise resolve 就写入 Bright。当前 v6 image 实现先按 candidate message ID 匹配;没有候选 ID 时,才以同会话、sent 方向、image kind、`sizeBytes`、`md5`、可用 `fileId`、时间窗和唯一性回退,歧义为 `send_ambiguous`。这项代码与自动化测试证据不替代尚未执行的 v6 Chromium 真实发送验收。 -## 10. 推荐的实现边界 +## 10. 历史建议与当前边界 -如果后续开始实现,建议保持原始事实与展示投影分离: +以下建议保留其调查背景;当前 v6 已将 raw 停在 MAIN decoder,并只让 normalized content 下游流转。 ### 10.1 共享内容解码器 @@ -427,18 +442,15 @@ Base64 解码 → UTF-8 → JSON.parse → 字段校验 不能只看 `contentType=101`,因为它至少同时承载图片和附件。 -### 10.2 原始内容必须继续保留 +### 10.2 原始内容不得越过 MAIN 边界 -投影结果不应替换原始 `content`。推荐消息同时保留: +历史提案曾建议同时保留原始 `content`;当前 v6 明确禁止这样做。页面桥以后的消息只保留: ```text -content 原始 OneTalk JSON,可用于审计、未知类型和未来兼容 -contentType 原始数字类型 -media 经过严格校验的可选语义投影 -text 仅文本便利字段 +content 经过严格校验的 normalized content ``` -未知类型进入 `unknown`,并保留原始 JSON;不能为了让 UI 正常而把未知媒体伪装成文本。 +未知类型不能为了让 UI 正常而伪装成文本,也不得携带原始 JSON 离开 MAIN decoder。 ### 10.3 URL 与二进制边界 @@ -480,10 +492,12 @@ delivery_unknown ## 12. 结论 -当前同步系统已经具备“接收并保存图片/附件原始消息”的基础条件,真正缺口集中在三处: +当前同步系统使 raw 图片/附件止于 MAIN decoder,之后只保存 normalized content;原始样本仍仅用于调查证据。当前 v6 图片合同只保留 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 与 `urlScope`,不保留 raw `width` / `height`。 -1. 将 `content.custom.data` 从 Base64 JSON 解码为经过校验的图片/附件语义对象。 -2. 在 Bright → Mind 的边界定义媒体字段白名单和 URL 生命周期处理。 -3. 为图片/附件发送建立独立输入和基于 sent-direction 事实的确认关联。 +尚未完成或未验证的缺口集中在三处: -因此不需要重写 OneTalk WebSocket、MessagePack 解码器、会话锚点或消息幂等机制。下一次实现应从共享内容投影器和测试样本开始,并把真实上传/发送抓包作为单独的运行时验证步骤。 +1. 对附件的当前运行态与发布范围重新验证。 +2. 对 URL 生命周期做真实环境验证。 +3. 在维护窗口执行 v6 Chromium 图片发送与 live-confirmation 验收。 + +因此不需要重写 OneTalk WebSocket、MessagePack 解码器、会话锚点或消息幂等机制。后续运行时工作应验证真实上传/发送和确认链路,而不能以这份历史调查替代 v6 现场验收。 diff --git a/docs/onetalk-media-message-sync-prd.md b/docs/onetalk-media-message-sync-prd.md index d0a9d67..1c2452e 100644 --- a/docs/onetalk-media-message-sync-prd.md +++ b/docs/onetalk-media-message-sync-prd.md @@ -138,16 +138,14 @@ sdkMessage.originalData.params type OneTalkUrlScope = "onetalk_session"; type OneTalkImageContent = { + version: 1; kind: "image"; fileId: string; extension: string; sizeBytes: number; - width: number; - height: number; isOriginal: boolean; md5: string | null; - previewUrl: string; - downloadUrl: null; + previewUrl: string | null; urlScope: OneTalkUrlScope; }; @@ -232,11 +230,10 @@ type OneTalkImagePayload = { fileId: string; suffix: string; size: number; - width: number; - height: number; isOriginal: 0 | 1; md5: string; url: string; + // OneTalk raw payload may contain width/height. The MAIN decoder ignores them. }; ``` @@ -244,20 +241,20 @@ type OneTalkImagePayload = { ```ts return { + version: 1, kind: "image", fileId: payload.fileId, extension: payload.suffix.toLowerCase(), sizeBytes: payload.size, - width: payload.width, - height: payload.height, isOriginal: payload.isOriginal === 1, md5: payload.md5 || null, previewUrl: payload.url, - downloadUrl: null, urlScope: "onetalk_session", }; ``` +OneTalk raw image payload may retain `width` or `height` as upstream evidence, but the MAIN decoder neither reads nor validates them. They never cross this normalized boundary into page bridge, IndexedDB, Bright, Mind, public reads, or confirmation metadata. + 图片 URL 必须是绝对 HTTPS URL,当前允许的运行态 host 为 `clouddisk.alibaba.com`,当前动作是 `fileAction=imagePreview`。 ## 8. 怎么取附件数据 @@ -400,6 +397,8 @@ Service Worker history request Raw `content.custom.data` 只能在 MAIN world 短暂存在。页面桥、IndexedDB、Bright 和 Mind 不得继续解析或持久化 OneTalk raw payload。 +图片 raw payload 中存在的 `width` / `height` 同样止于该边界:v6 canonical image 只包含 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 和 `urlScope`(以及内容 `version`、`kind`)。 + ## 12. 错误处理 - 非法 Base64:`invalid_base64`。 @@ -412,7 +411,7 @@ Raw `content.custom.data` 只能在 MAIN world 短暂存在。页面桥、Indexe ## 13. 验收标准 -- JPEG 样本被规范化为 `kind="image"`,字段与 SDK `originalData` 一致。 +- JPEG 样本被规范化为 `kind="image"`,保留 canonical 白名单字段;SDK `originalData` 中可能存在的 raw `width` / `height` 被 MAIN decoder 忽略。 - ZIP、PDF 样本都被规范化为 `kind="file"`,共同满足 `cardType=12`。 - ZIP 返回 payload 自带的下载 URL。 - PDF 当前返回 `downloadUrl=null`、`previewUrl=params.url`。 diff --git a/docs/onetalk-message-content-formats.md b/docs/onetalk-message-content-formats.md index fce2744..d232fb7 100644 --- a/docs/onetalk-message-content-formats.md +++ b/docs/onetalk-message-content-formats.md @@ -7,16 +7,20 @@ > 调试环境:Chromium `154.0.8012.0`,CDP `127.0.0.1:9222`,Trade Message Center `0.8.6` > > 调查方式:通过 Chromium CDP 观察真实 OneTalk WebSocket 响应、调用页面只读历史 SDK,并对照扩展 IndexedDB、共享协议和 Bright 存储代码。调查过程中没有发送消息、没有调用会改变已读状态的 API,也没有记录正文、账号、token、完整 URL 或 URL 查询参数值。 +> +> v6 同步说明(2026-09-11):下列 raw 样本和当时能力结论保留为调查证据;当前规范性图片合同为无尺寸的 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`(另有 `version`、`kind`)。MAIN decoder 忽略 raw `width` / `height`,它们不跨 normalized boundary;本报告不是 v6 live runtime 验收。 ## 1. 结论摘要 -当前系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**: +以下结论是本报告调查时(2026-09-02)的历史快照:当时系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**: - 文本能够从 `content.text.content` 提取为 `message.text`。 - 图片和附件能够作为原始 `contentType=101/custom` JSON 被观察、写入 IndexedDB,并通过 Bright ACK。 -- 图片和附件没有规范化的 `kind`、文件名、扩展名、大小、宽高、缩略图或下载地址合同。 +- 图片和附件当时没有规范化的 `kind`、文件名、扩展名、大小、缩略图或下载地址合同;当前 v6 image canonical contract 见第 6 节。 - 消费端如果只读取 `message.text`,就会表现为“文本存在,图片和附件不存在”。 +这不是当前 v6 数据流。当前 raw OneTalk content 只在 MAIN decoder 内解码和收窄;页面桥、Service Worker、IndexedDB、Bright JSONB、HTTP read 和 Mind 下游只接收 normalized content,Bright 不保存 raw JSON。 + 真实样本已确认以下映射: | 业务类型 | WebSocket 原始类型 | Base64 解码后的判定 | OneTalk SDK 归一化类型 | @@ -28,7 +32,7 @@ 本次真实附件样本为 PDF;没有抓到 TXT 附件、实时图片 push 或实时文件 push,因此这些场景不能标记为已验证。 -另有一个必须优先修复的安全问题:当前文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。 +调查时还发现一个必须优先修复的安全问题:文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。 ## 2. 调查范围与证据边界 @@ -188,7 +192,7 @@ SDK hasMore:boolean WebSocket hasMore:0 | 1 ``` -SDK `list[]` 条目确认包含: +调查时 SDK `list[]` 条目确认包含: ```text autoReply @@ -217,6 +221,8 @@ uuid viewType ``` +这份 SDK 字段清单是历史 raw/boundary-only 证据,包含的 `originalData.width` / `originalData.height` 只可在 MAIN decoder 边界被忽略,不能成为 canonical 字段、存储字段或下游输入。 + 其中: - `content` 已经被 SDK 转成展示字符串。 @@ -313,6 +319,8 @@ Base64 解码结果:JSON object } ``` +这是 OneTalk raw payload 证据,不是 normalized contract。raw `width` / `height` 可保留在此样本中,但 v6 MAIN decoder 忽略它们,绝不将其传过页面桥或写入 canonical content。 + ### 6.3 SDK 归一化结果 ```text @@ -339,15 +347,15 @@ width ```ts type OneTalkImageContent = { + version: 1; kind: "image"; fileId: string; - suffix: string; + extension: string; sizeBytes: number; - width: number; - height: number; isOriginal: boolean; - md5?: string; - sourceUrl: string; + md5: string | null; + previewUrl: string | null; + urlScope: "onetalk_session"; }; ``` @@ -357,9 +365,9 @@ type OneTalkImageContent = { - `custom.data` 必须是有大小上限的合法 Base64。 - Base64 解码结果必须是 UTF-8 JSON object。 - `fileId`、`suffix`、`url` 必须为非空字符串。 -- `size`、`width`、`height` 必须为有限非负整数,并设置合理上限。 +- `size` 必须为有限非负整数,并设置合理上限;raw `width` / `height` 被 MAIN decoder 忽略,不参与校验或输出。 - `isOriginal` 的 `0/1` 显式转换为 boolean。 -- `url` 只接受绝对 HTTPS URL,并在确认真实主机后加入固定 host allowlist。 +- `url` 只接受绝对 HTTPS URL,并在确认真实主机后映射为 `previewUrl`;`urlScope` 固定为 `onetalk_session`。 - 不根据 `suffix` 猜造 OneTalk 未提供的 MIME;UI 可以使用安全扩展名映射做展示提示。 ## 7. 文件附件原始格式 @@ -507,7 +515,7 @@ decoded.params 通过文件 schema 其它合法但未支持的 `cardType` 应返回受控的 `unsupported` 内容,不应丢弃整条消息,也不应保留完整 raw payload。 -## 9. 当前代码为什么表现为“只有文本” +## 9. 调查时的代码为什么表现为“只有文本” ### 9.1 页面观察器 @@ -557,7 +565,7 @@ onetalk_sync_candidates ### 9.3 共享协议与 Bright -当前 `OneTalkMessage` 定义: +调查时的 `OneTalkMessage` 定义: ```ts type OneTalkMessage = { @@ -586,7 +594,7 @@ text text nullable content jsonb ``` -因此 Bright 能保存媒体 raw JSON,但不能告诉 Mind: +因此调查时的 Bright 能保存媒体 raw JSON,但不能告诉 Mind: ```text 这是图片还是文件 @@ -617,7 +625,7 @@ unsupported fallback 真实 Mind UI 如果只读取 `message.text`,媒体消息自然不可见。 -## 10. 当前安全风险 +## 10. 调查时发现的安全风险 ### 10.1 字符串内部的敏感字段绕过清洗 @@ -646,7 +654,7 @@ if (typeof value === "string" || typeof value === "boolean") return value; 1. `text.extension.basicMessageInfo` 中的序列化 JSON。 2. `custom.data` 中的 Base64 JSON。 -真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。当前实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。 +真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。调查时的实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。 ### 10.2 推荐的安全边界 @@ -689,7 +697,20 @@ Authorization 在上述行为没有真实验证前,只能称其为 `sourceUrl`,不能承诺“Mind 可直接下载”。 -## 11. 推荐的目标内容合同 +## 11. 当前 v6 内容边界 + +当前规范性数据流是: + +```text +OneTalk raw message + → MAIN decoder(raw 仅在此处出现) + → exact normalized content + → page bridge / Service Worker / IndexedDB + → Bright canonical JSONB / message.created / HTTP history + → Mind read model +``` + +因此 Bright 不保存 raw payload、`custom.data` 或 SDK row;它只保存共享 guard 已接受的 normalized content。下列合同说明该边界的目标形状。 建议让 `content` 成为消息展示内容的唯一事实源: @@ -700,15 +721,15 @@ type OneTalkNormalizedContent = text: string; } | { + version: 1; kind: "image"; fileId: string; - suffix: string; + extension: string; sizeBytes: number; - width: number; - height: number; isOriginal: boolean; - md5?: string; - sourceUrl: string; + md5: string | null; + previewUrl: string | null; + urlScope: "onetalk_session"; } | { kind: "file"; @@ -740,7 +761,7 @@ message.content.kind === "text" ? message.content.text : null; ## 12. 可以做到什么 -### 12.1 当前已经做到 +### 12.1 调查时已经做到 - 观察文本、图片和 custom card 的原始 WebSocket content。 - 使用 raw `channelAccountId + conversationId + messageId` 保持消息幂等。 @@ -769,7 +790,7 @@ message.content.kind === "text" ? message.content.text : null; - 支持音频、视频、语音、压缩包或所有 OneTalk custom card:没有真实样本和枚举。 - checkpoint 已完整收敛:本次末态仍为 `uploading/succeeded`。 -## 13. 推荐 PRD 要求 +## 13. 当前 v6 不变量 ### R1. 单一内容合同 @@ -799,7 +820,7 @@ channelAccountId + conversationId + raw messageId - URL 只允许 HTTPS 和固定 OneTalk host allowlist。 - URL query/hash/userinfo 不进入日志或错误。 -- 文件大小、宽高和时间字段必须有上下限。 +- 文件大小和时间字段必须有上下限;图片 raw `width` / `height` 由 MAIN decoder 忽略,不能成为 normalized 字段、匹配条件或展示数据。 - 文件扩展名规范化为小写有限字符集。 - 文件名按纯文本处理并限制长度。 - MD5 只能作为来源元数据,不能替代消息 ID 或安全签名。 @@ -860,7 +881,7 @@ aliIdEncrypt ## 14. 推荐验收标准 - [ ] 真实文本历史消息归一化为 `{ kind: "text", text }`,并且 content 不含 extension。 -- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为 image,尺寸、大小、后缀与真实载荷一致。 +- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为无尺寸 v6 image;`fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 与 `urlScope` 通过 canonical contract,raw `width` / `height` 不跨 MAIN 边界。 - [ ] 真实 PDF 样本从 `custom.type=10010/cardType=12` 归一化为 file,文件名、扩展名、大小和 URL 元数据一致。 - [ ] `custom.type=10010/cardType=2000` 不会被识别成文件。 - [ ] 非法 Base64、非法 JSON、超限数据、非 HTTPS URL、缺字段和大小溢出均 fail closed,不影响同批其它消息。 @@ -872,7 +893,7 @@ aliIdEncrypt - [ ] 获得真实 live 图片和附件 push 后证明历史与实时共用同一 decoder。 - [ ] 验证媒体 URL 在有 Cookie、无 Cookie、跨 Origin、过期和跳转场景的行为,再决定保存 URL、保存 ID 或增加受控代理。 -## 15. 相关代码位置 +## 15. 调查时的相关代码位置 | 层 | 文件 | 当前行为 | | -------------- | --------------------------------------------------------------------------------- | --------------------------------------- | @@ -890,15 +911,13 @@ aliIdEncrypt ## 16. 最终判断 -OneTalk 当前真实载荷已经提供实现图片和文件同步所需的核心元数据,且 raw `custom.data` 可以在页面内稳定识别为 Base64 JSON。第一阶段不需要下载媒体文件,也不需要新建第二套消息表;可以在现有消息链上增加一个唯一的内容 decoder 和跨层 typed contract。 - -实现工作的核心不是“把 `contentType=101` 放行”,因为当前已经放行;真正需要完成的是: +历史调查证明 OneTalk raw payload 提供实现图片和文件同步所需的核心元数据。当前 v6 不再沿用该报告中 raw content 跨页面桥、IndexedDB、Bright 或 Mind 的历史路径;实现核心已经收敛为 MAIN decoder 的单一白名单边界: ```text opaque raw content → 严格、安全、可测试的 text/image/file/unsupported 合同 - → Bright 持久化与事件保持同一语义 - → Mind 按 kind 展示 + → Bright 持久化与事件只使用 normalized content + → Mind read model 只接收 normalized content ``` -同时必须先关闭 raw 字符串中嵌套凭证可能进入 Bright 的安全缺口,否则新增媒体支持会进一步扩大敏感 payload 的存储范围。 +raw 字符串中嵌套凭证进入 Bright 是本报告记录的历史风险;当前 v6 边界以不让 raw payload 离开 MAIN decoder 的方式关闭它。真实 v6 Chromium 运行态验收仍未执行。