feat: 移除 OneTalk 图片宽高链路

This commit is contained in:
YBF
2026-09-11 14:30:35 +08:00
parent a128940d94
commit 5ee817cf54
40 changed files with 849 additions and 268 deletions
@@ -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、生命周期和真实运行时验证边界。"}
@@ -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 与 sizevalidator 只接受无宽高的 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。
@@ -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 边界。"}
@@ -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 artifactmigration 执行后不回填尺寸。若要恢复旧客户端,必须先停止 v6 流量,再发布一个新的、显式记录的数据/协议迁移,不能修改或撤销已执行 migration。
@@ -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。部署不完整时的显式不可用优于隐藏兼容或错误确认。
@@ -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 当前索引同一 HEADindexed `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 contractcross-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 probesimplement/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 仍 anomalywebsocket/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 guardwrong 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、服务或数据库。
@@ -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 scopeMind 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/`,未修改产品代码或测试。已刷新 sourceserver schema/read repository/projection/serviceDrizzle SQL/meta/journal/migrate/configserver read/migration integration testsharness 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 objectv6 应仅删除 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_<tag>.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 ownershared `apps/onetalk-contract` guard 是 canonical content shape ownerBright PostgreSQL `onetalk_message.content` 是 persisted fact owner`read-projection.ts` 是 public message projection ownerMAIN 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。
@@ -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": {}
}
@@ -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 }),
};
};
@@ -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<string, unknown>): 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)
@@ -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;
};
@@ -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();
@@ -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({
@@ -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,
@@ -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"),
@@ -19,7 +19,7 @@ export const harnessMessagesScript = String.raw` const messageKey = (
return '<div class="message-content"><p>' + escapeHtml(content.text) + '</p></div>';
}
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
? '<img class="message-image" src="' + escapeHtml(content.previewUrl) + '" alt="OneTalk 图片预览" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><p class="media-error" hidden>图片预览加载失败;可稍后刷新消息。</p>'
: '<p class="media-error">图片未提供预览地址。</p>';
@@ -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));
-7
View File
@@ -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<string, unknown>): 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"]) &&
+1 -1
View File
@@ -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",
+21 -13
View File
@@ -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",
@@ -0,0 +1,4 @@
UPDATE "onetalk_message"
SET "content" = "content" - 'width' - 'height'
WHERE "content" ->> 'kind' = 'image';
--> statement-breakpoint
+7
View File
@@ -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
}
]
}
+3 -3
View File
@@ -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 &&
@@ -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<void> => {
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());
+2 -2
View File
@@ -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<void> => {
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());
@@ -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;
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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",
+6 -4
View File
@@ -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);
});
+3 -2
View File
@@ -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,
+1 -1
View File
@@ -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<ReturnType<OneTalkReadService["listConversations"]>>) => void;
const began = new Promise<void>((resolve) => {
@@ -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);
});
+24 -6
View File
@@ -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",
@@ -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<string, unknown>;
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}
+1 -1
View File
@@ -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(),
@@ -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. 结论摘要
@@ -620,21 +622,23 @@ 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` |
| 核心显示字段 | `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 |
| 匹配指纹 | 大小 + MD5 + 可选 fileId | 文件名 + 扩展 + 大小 + 可选 MD5/fileId/parentId |
| URL 语义 | 主要是 image preview | 可能区分 office preview、download、thumbnail |
| 显式 downloadUrl | 图片合同没有下载状态 | 可为空;可由 `url.fileAction=download` 派生 |
| 文件真实性 | 可由图片解码进一步验证像素 | observer 无二进制,不能验证扩展名与 magic bytes 一致 |
| 文件真实性 | 仅验证 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`
@@ -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
```
@@ -78,7 +80,7 @@ https://onetalk.alibaba.com/message/weblitePWA.htm
运行结果:
| 证据 | 结果 |
| ---------------------------- | ---------------------------------------------- |
| ---------------------------- | ------------------------------------ |
| `prepareSendFileWithGroup` | HTTP `200` |
| `buildFileRelationWithGroup` | HTTP `200` |
| OSS 二进制上传 | 未发生,命中文件已存在/去重分支 |
@@ -87,7 +89,7 @@ https://onetalk.alibaba.com/message/weblitePWA.htm
| WebSocket 帧 | 出站 3、入站 3 |
| Runtime exception | 0 |
| selected conversation | 全程不变 |
| 目标会话只读历史 | 找到一条与 `I1` 大小和宽高完全一致的 sent 图片 |
| 目标会话只读历史 | 找到一条与 `I1` 大小一致的 sent 图片 |
页面 `send-msg-success` 和 SDK Promise 只证明本地受理,不能单独证明发送完成。目标会话只读历史中的 sent 图片事实才排除了“只插入了页面假消息”的情况。
@@ -96,16 +98,16 @@ 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` 完全一致 |
| 当时 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 evidencev6 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<string>;
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、时间窗口和唯一性检查。
@@ -562,20 +562,20 @@ const imageMatches = (
至少需要补充以下测试:
| 用例 | 预期 |
| -------------------------- | ----------------------------------------------------------- |
| live WS 图片 raw payload | 输出 `content.kind=image` 及准确的 `sizeBytes/width/height` |
| --------------------------------------- | ------------------------------------------------------------ |
| 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_state_lost` |
| 超时无 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. 相关代码
@@ -4,6 +4,8 @@
> 调查对象:`https://onetalk.alibaba.com/message/weblitePWA.htm` 以及当前 `trade-message-center` OneTalk 扩展链路
> 调查方式:Chromium DevTools ProtocolCDP`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,19 +313,32 @@ OneTalk raw content
→ Mind history response
```
当前缺少的是在某个明确边界增加媒体语义投影,而不是重新设计这条事实链。
这条 raw 事实链仅为历史调查证据,当前不得使用。v6 的规范路径为:
## 8. 已经可以做到什么
```text
OneTalk raw content
→ MAIN decoderraw 只停留在此处)
→ normalized content
→ page bridge / Service Worker / IndexedDB
→ Bright canonical JSONB / HTTP history
→ Mind read model
```
| 能力 | 当前状态 | 证据/限制 |
| ----------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
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 |
| 保留原始媒体元数据 | 历史实现 | 通用 JSON `content` 贯穿页面桥、Service Worker、Bright |
| 按 `channelAccountId + conversationId + messageId` 幂等保存 | 代码已支持 | 媒体不改变消息业务键 |
| 在 Mind 历史接口返回原始媒体 JSON | 代码路径支持 | 尚未做真实 DB 写入和 Mind UI 回归 |
| 将图片字段投影为 `imageUrl/width/height` | 当前实现 | 需要新增共享内容解码器/投影器 |
| 在 Mind 历史接口返回原始媒体 JSON | 历史路径 | 已由 v6 normalized-only boundary 取代 |
| 将图片字段投影为 v6 canonical image metadata | 当前实现 | 只保留 fileId、extension、sizeBytes、isOriginal、md5、previewUrl、urlScope |
| 将附件字段投影为文件名、大小、预览和下载动作 | 当前未实现 | 需要处理 `downloadUrl` 为空的情况 |
| 在 Mind 页面显示图片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
| 在 Mind 页面显示附件卡片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
@@ -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 现场验收。
+9 -10
View File
@@ -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`
+52 -33
View File
@@ -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 contentBright 不保存 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 hasMoreboolean
WebSocket hasMore0 | 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 decoderraw 仅在此处出现)
→ 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 contractraw `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 运行态验收仍未执行