mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: 移除 OneTalk 图片宽高链路
This commit is contained in:
@@ -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 与 size,validator 只接受无宽高的 canonical image。
|
||||
- 更新仍描述“当前 normalized contract / confirmation matching”的非归档 docs;原始 OneTalk 证据样本可保留宽高,但旁注说明这两个 raw 字段被 decoder 忽略且不会跨 MAIN 边界。
|
||||
- 不修改归档任务记录或上游 raw protocol 事实。
|
||||
|
||||
## Risks and rollback
|
||||
|
||||
- 最大风险是 strict v6 与未更新端并存;这是预期 fail-closed,发布要在维护窗口执行。不能通过兼容 adapter 回滚该风险。
|
||||
- 数据迁移会永久删除两个非业务 JSONB 字段。回滚代码只能恢复到“无宽高 content”的 v6 定义;若要恢复旧定义,必须另写新 migration 并接受历史尺寸不可恢复。
|
||||
- 若 `TEST_DATABASE_URL` 不可用,migration 数据效果只能以 SQL review、Drizzle check 和 unit projection 测试验证;生产执行前必须在目标数据库的维护窗口跑 migration。
|
||||
@@ -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 artifact;migration 执行后不回填尺寸。若要恢复旧客户端,必须先停止 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 当前索引同一 HEAD,indexed `2026-09-11T02:51:38Z`。
|
||||
- 复用证据与缺口:复用本任务 `prd.md`、`design.md`、`implement.md` 的 v6 严格切换和 live-confirmation 约束,以及 `.trellis/spec/chrome-extension/frontend/onetalk/{page-bridge,send-sop,runtime-sync}.md`;GitNexus 对 `normalizeImage`、`metadataFrom`、`createSendObservationCorrelator` 的 upstream 影响均为 LOW,但索引对部分 const/type 只给出符号级关系,仍以源码搜索为准。未执行 Chromium、Bright/Mind server、PostgreSQL 或外部 `trade-mind` runtime 验证。
|
||||
|
||||
## 发现
|
||||
|
||||
### 1. 当前实际数据流与 owner
|
||||
|
||||
```text
|
||||
OneTalk raw contentType=101/custom.type=7
|
||||
-> decodeMediaPayload(Base64 -> UTF-8 -> JSON)
|
||||
-> normalizeImage (MAIN only)
|
||||
-> observedMessage (shared isOneTalkMessage guard)
|
||||
-> WebSocket observer sink / page-script-entry
|
||||
-> sendObservation.observe(batch.messages) [先于 observedSink]
|
||||
-> Service Worker page bridge -> Bright send.confirmation
|
||||
```
|
||||
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts:175-210` 当前将 `payload.width`、`payload.height` 写入 normalized image,并用 `ONETALK_MAX_IMAGE_DIMENSION_PX` 校验;因此缺失、错误类型或超界的 raw dimension 会生成 `media_invalid_schema`。`decodeOneTalkRawContent:289-302` 和 flat-history 分支 `265-286` 都汇入同一个 `normalizeImage`,不能另建 parser。
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:148-247` 先校验身份,再调用唯一 content decoder;只有完整 `isOneTalkMessage` 才生成带 `upstreamType: 1` 的观察消息。该边界只输出 normalized content,不能把 raw `custom.data` 或 raw dimensions 带出 MAIN。
|
||||
- live 入口是 `new.ts:12-51` → `index.ts:9-32` → `websocket.ts:40-97`;raw history WebSocket envelope 在 `index.ts:24-31` 被忽略,历史由 SDK adapter `history.ts:71-143` 使用同一 content decoder。`page-script-entry.ts:38-46,56-90` 在发布 Bright sink 前先调用 correlator,故 live sent batch 可以直接完成 pending send。
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:24-41,115-182` 的 image pending expected 当前为 `sizeBytes`, `md5`, 可选 `width`, `height`, `fileId`;`matchesImage:119-125` 读取并比较两项 dimensions。`matches:162-181` 保留既有顺序:目标会话 + `sent` + 完整消息;有 candidate ID 时只按 ID(不回退指纹/时间),无 ID 时才使用 `sentAfterMs` 到 `now + 5_000ms` 的窗口及 image fingerprint。
|
||||
- `send-observation.ts:184-376` 的 `createSendObservationCorrelator` 仍是唯一 pending/终态 owner;图片预算由 `ONETALK_IMAGE_SEND_TIMEOUT_MS:11` 保持 45 秒,finish 时只允许完整 sent message 变成 `confirmed_sent`,多 pending 匹配是 `send_ambiguous`,错误/超时继续 `delivery_unknown`。
|
||||
- `apps/chrome-extension/src/onetalk/main-page/image-send.ts:21-29,185-217` 的 `FinalImageMetadata` 与 `metadataFrom` 读取 `width`/`height` 并将其可选写入 expected metadata;`finalMetadataFromSendFileArgs:240-255` 从直接参数及 `mediaInfo`/`relationInfo`/`fileInfo` 收集候选。`installUploaderInterceptor:304-385` 在匹配 `tmpKey` 后先做 metadata/current-target fence,再调用 correlator;`sendOneTalkMedia:408-592` 在 fetch/blob/File 后、native `sendFile` 前注册短生命周期 pending。生产 relation callback 的无 dimensions 形状已经在 `image-send.test.js:148-155` 注释和 `relationMetadata:98-103` fixture 中存在,当前缺口主要是 MAIN decoder/contract 强制 live payload 带 dimensions。
|
||||
|
||||
### 2. 共享合同与公共读边界
|
||||
|
||||
- `apps/onetalk-contract/src/content.ts:5-30` 定义 `ONETALK_CONTENT_VERSION=1`、媒体大小上限和 `OneTalkImageContent`;`CONTENT_KEYS.image:82-96` 与 `isOneTalkImageContent:282-296` 同时决定 exact-shape accepted keys。移除 dimensions 时必须同时删除 type、key list、dimension constant 和 guard reads;保留 `fileId/extension/sizeBytes/isOriginal/md5/previewUrl/urlScope` 与现有 media size/URL allowlist。
|
||||
- `apps/onetalk-contract/src/messages.ts:94-121` 的 `isValidOneTalkMessagePayload` 通过 `isOneTalkMessageContent` 验证 observed frames;`apps/onetalk-contract/src/sending.ts:140-166` 的 confirmed send guard 通过 `isOneTalkMessage`/`isOneTalkCenterMessage` 验证完整 sent message。因此没有另一份 message/send image schema 可安全绕过共享 guard。
|
||||
- `apps/server/src/onetalk/read-projection.ts:84-110` 只对持久化 `content` 再调用共享 `isOneTalkMessageContent` 并浅拷贝,server source 没有独立 dimensions reader;但 server image fixtures 仍在 `apps/server/test/onetalk-read-domain.test.ts:395-420,478-518`,由 server scope 清理后才能读取无宽高历史。
|
||||
- Page bridge contract 已规定 MAIN decoder 是唯一 raw boundary、ISOLATED/SW 不重解析 `custom.data`:`.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md:64-70,111-115,193`。send SOP 已规定候选 ID 优先、完整 sent fact、45 秒媒体观察和三态 fail-closed:`.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md:37-43,67-78`。
|
||||
|
||||
### 3. `width`/`height` 全量业务读写点(当前 checkout)
|
||||
|
||||
- Canonical shared writer/reader:`apps/onetalk-contract/src/content.ts:7,18-30,82-96,282-296`。
|
||||
- MAIN raw/flat-history normalized writer/reader:`apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts:6,187-210`。
|
||||
- MAIN upload metadata writer/reader:`apps/chrome-extension/src/onetalk/main-page/image-send.ts:21-29,185-217`;不是 UI geometry。
|
||||
- MAIN live correlator expected/matcher reader:`apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:24-35,115-125`。
|
||||
- Extension test canonical/raw fixtures:`apps/chrome-extension/test/onetalk-media-content-decoder.test.js:19-28,54-70,107-136,157-191`;send correlator fixture `onetalk-send-observation.test.js:169-224`;image upload/callback fixture `onetalk-image-send.test.js:61-96`;flat-history fixture `onetalk-websocket-tap.test.js:355-371`。这些应改成 canonical 无宽高,并新增 raw dimension absent/one-sided/wrong/over-bound/present cases均产出相同无宽高 canonical content;若保留 raw evidence 字段,必须只出现在明确标注的 input fixture 中而不出现在 expected/public output。
|
||||
- Harness/server 是 R1 之外的共享下游:`apps/mind-test-harness/src/harness/validators.ts:18-30` 和 `messages.ts:17-35` 仍验证/显示 dimensions;`apps/server/test/onetalk-read-domain.test.ts` 的 image expected 仍带 dimensions。CSS/layout geometry(例如 `apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts:27-36`、`action-status-tooltip.ts:47-100`、harness `styles.ts`)不是消息字段,不应被全局文本替换。
|
||||
- 当前源码未发现其他 extension page-bridge、Service Worker、Bright client 或 database schema 对 image dimensions 的业务读写;Service Worker 只传递已收窄消息。`apps/chrome-extension/src/onetalk/service-worker/flows/frame-writers.ts:145-176` 直接写 message frame,但 payload 类型来自 shared contract。
|
||||
|
||||
### 4. 协议版本消费者与升级影响
|
||||
|
||||
- owner/decoder:`apps/onetalk-contract/src/wire.ts:41,123-135` 定义 v5 常量和 frame 类型;`decoder.ts:177-197` 对非当前版本返回 `onetalk_protocol_upgrade_required`,不能静默剥离旧 image fields。
|
||||
- shared frame creators 全部导入该常量:`connection.ts:3,185-221`、`conversation-sync.ts:10,271-351`、`messages.ts:10,177-220`、`contact-profiles.ts:4,165-176`、`buyer-facts.ts:4,401-416`、`sending.ts:8,173-204`;`index.ts:39-53` 对外导出。把常量升至 6 会统一改变所有 frame builders,不应只改 send/image frame。
|
||||
- extension exact consumers:`service-worker/transport/bright-client.ts:3-12,232-260` 生成 plugin hello/heartbeat 并调用 shared decoder;`service-worker/flows/frame-writers.ts:3-24,145-210` 手工生成 observed/sync frames;`bright-client.test.js` 与 `bright-protocol.test.js` 使用 shared constant 验证 handshake/upgrade。
|
||||
- server admission consumers:`apps/server/src/cutover-policy.ts:3,11-43`、`websocket/handler.ts:197-271`、`websocket/index.ts:70-87`、`websocket/connection-store.ts:123-126`、`http/onetalk/public-context.ts:136-170`、`http/onetalk/summary.ts:46-55`。这些都要求 current `ONETALK_PROTOCOL_VERSION`;`cutover-policy` 的 mode 字符串仍叫 `bright-v5`(13,26,39),它是独立 release-mode label,是否改名为 v6 需由 cross-cutting owner 明确,不能误以为只改数字常量即可。
|
||||
- hard-coded test consumers:`apps/server/test/observation-batcher.test.ts:53-71` 直接写 `protocolVersion: 5`;`apps/server/test/mind-authorization.test.ts:359-369` 直接以 `canAdmit("bright-v5", ..., 5)` 验证。`apps/onetalk-contract/test/contract.test.ts:191-219,1295-1303` 和 extension/harness tests 多数引用 shared constant,但测试名称/文档仍写 v5。
|
||||
- harness consumers:`apps/mind-test-harness/src/harness/contract.ts:3-36` 从 shared contract 注入 `protocolVersion`,`harness/websocket.ts:48-174` 使用注入值发送 hello/heartbeat/send;这与 image validator 是同一个 downstream release participant。
|
||||
|
||||
GitNexus pre-change evidence:`normalizeImage` upstream 4 symbols / 2 observer processes / LOW;`metadataFrom` upstream 3 / `sendOneTalkMedia` process / LOW;`createSendObservationCorrelator` upstream 1 (`page-script-entry.ts`) / LOW。它们支持 extension module 边界,但 shared contract/protocol 的实际 fan-out以源码 imports 和 frame builders为准。
|
||||
|
||||
## 候选 Scope 与依赖
|
||||
|
||||
1. **Shared v6 image contract(cross-cutting prerequisite)**:owner `apps/onetalk-contract/src/content.ts`、`wire.ts`、相关 contract tests;交付无宽高 exact-shape image、v6 decoder/builders、旧 v5 明确 upgrade error。应排除 extension implementation、server migration 和 harness UI。所有消费 shared content/frame 的 writer 必须等待此 scope;旧 v5 client 不可兼容运行。
|
||||
2. **Chrome MAIN image observation/send confirmation(本 R1 package-local implementation scope,依赖 1)**:owner `content-decoder.ts`、`image-send.ts`、`send-observation.ts` 及其 decoder/image-send/send-observation/websocket tests;交付 raw dimensions 完全忽略、无宽高 live sent 可确认、candidate ID 优先/唯一 fingerprint/时间窗/目标身份/45 秒终态不变。可排除 server persistence/read projection、harness、external Mind。该 scope 与 server migration、harness validator 的源码写入可 write-disjoint,但由于同一 shared contract 和 v6 wire prerequisite,不能在 scope 1 未稳定前开始;合同稳定后才可并行。
|
||||
3. **明确不属于 R1 的下游**:server read projection/JSONB migration 和 harness validation/rendering 需要消费新的 canonical shape;不能由 MAIN scope 通过保留 dimensions 或兼容 parser 解耦。
|
||||
|
||||
## Invariant 与验收探针
|
||||
|
||||
### Static/unit probes(implement/check 可执行)
|
||||
|
||||
- `rg -n -i --glob '!**/dist/**' --glob '!**/node_modules/**' '\b(width|height)\b|ONETALK_MAX_IMAGE_DIMENSION_PX' apps/onetalk-contract/src apps/chrome-extension/src/onetalk/main-page apps/chrome-extension/test/onetalk-{media-content-decoder,image-send,send-observation,websocket-tap}.test.js`:预期无 canonical/metadata/correlator业务读写;仅允许明确 raw input fixture,不能出现在 expected normalized/public content;区分 CSS geometry。
|
||||
- `pnpm --filter @trade-message-center/onetalk-contract test`:contract guard 断言无宽高 image accepted、任一额外 dimension key rejected、保留 size/URL/nullable metadata 边界,旧 v5 frame 返回 upgrade error。
|
||||
- `pnpm --filter @trade-message-center/chrome-extension test`:decoder 断言 raw image dimensions 缺失、单边、错误类型、超界、正常存在均得到同一个无宽高 normalized content,非法 size/URL 仍 anomaly;websocket/parse tests 断言 history/live 共用 decoder且 raw 不跨 bridge。
|
||||
- correlator/image tests:无 candidate ID 时仅 `conversationId + sent + kind=image + sizeBytes + md5 + optional fileId + window + unique` 可确认;candidate ID 仍可越过时间/指纹但不能越过完整 sent guard;wrong conversation/direction、received、不完整、同图并发均不确认/`send_ambiguous`;relation metadata 中缺 dimensions 或带任意 dimensions 均不改变 expected。
|
||||
- `pnpm typecheck`、`pnpm build`、`pnpm format:check`、`git diff --check`;改协议后确认所有 frame creator/import 编译通过,并搜索 hard-coded `protocolVersion: 5` / `canAdmit(..., 5)`。
|
||||
|
||||
### Runtime probes(研究期间未执行)
|
||||
|
||||
- Chromium/CDP 隔离要求:使用无敏感测试图片、已重载 v6 extension 并重开 OneTalk tab;确认 MAIN/ISOLATED/SW build hash相同。当前已知浏览器规则要求先观察真实 `page_command_result`,不能把 SDK Promise、页面图片、WebSocket frame count 当成功证据。
|
||||
- 最小探针:唯一同账号页面、selected 会话可与目标不同;发送流程 fetch → File → `tmpKey` interceptor → native `sendFile` → live WebSocket sent observation。记录仅脱敏 stage/request reference;检查 live canonical content 不含 `width`/`height`,并确认同一次 pending 得到 `confirmed_sent`。
|
||||
- 反例矩阵:raw payload dimension 缺失、仅 width、仅 height、字符串/负数/超界/随机 object 与正常 dimensions 各跑一次;其它 `size` 非法、URL host/action 非法仍产生原 anomaly。并发同图两 pending 必须 `send_ambiguous`,断线/目标账号变化/超时必须保留 `delivery_unknown`。
|
||||
- v6 deployment probe(需维护窗口,不在研究中启动):停旧 server → 执行已审查 JSONB migration → 启动 v6 server → 发布并重连 v6 extension/Mind;旧 v5 hello/frames 应收到明确 `onetalk_protocol_upgrade_required`,不接受/剥离旧 dimensions。生产/真实 Mind 联调当前未验证。
|
||||
|
||||
## 注意事项 / 未找到内容
|
||||
|
||||
- `docs/onetalk-image-send-observation-feasibility.md:9-25,435-459,472-554,560-578,620-648` 把 dimensions 写成 canonical image 和 correlator 条件;`docs/onetalk-media-message-sync-prd.md:145-146,235-252` 也有 image contract/code;这些当前文档必须同步为无宽高版本。`docs/onetalk-media-message-format-investigation.md:152-210`、`docs/onetalk-message-content-formats.md:301-363` 同时包含已验证 raw payload 样本(应保留但明确 raw dimensions 在 MAIN 边界被忽略)和旧的推荐 normalized contract(应更新/标注)。
|
||||
- `apps/server/src/onetalk/read-projection.ts`、`service.ts`、repository/schema 没有独立 image dimension reader;它们依赖 shared guard/JSONB 通用拷贝,但历史 image JSONB 在 guard 更新后需要 migration 才可读取。R1 不改 server migration。
|
||||
- 与消息合同无关的 DOM/CSS `width`/`height`(例如 conversation-id-copy 的可见矩形)必须保留;全局删词会破坏页面控件测试。
|
||||
- `ONETALK_PROTOCOL_VERSION` 升级是全协议 strict cutover,不是 image-only adapter;共享常量变更会影响 auth/heartbeat/sync/profile/buyer/message/send 所有 frame。`bright-v5` mode label 的处理是待 cross-cutting architecture decision。
|
||||
- 外部参考:无新增网络/外部文档;仓库内运行态调查仅作为历史证据,当前轮次未重新启动 Chromium、服务或数据库。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 研究:服务端图片持久化、读取投影与 Mind 测试支架
|
||||
|
||||
- 查询:核查 OneTalk `kind=image` 在 Bright PostgreSQL JSONB、严格读取投影、Drizzle migration/journal、server integration/read tests、Mind test harness validator/rendering 及非归档文档中的宽高消费者;确认 v6 无宽高数据迁移的最小安全范围。
|
||||
- 范围:internal
|
||||
- 日期:2026-09-11
|
||||
- 协调类别:cross-cutting
|
||||
- 阻塞:server 持久化/读取与 migration scope;Mind harness rendering/validator;公共 v6 contract/extension writer 必须先稳定并让旧 v5 writer 明确失败;不阻塞与数据持久化无关的 raw OneTalk 观察研究。
|
||||
- 共享边界:`apps/server/src/database/schema/onetalk.ts:60-118` 的 `onetalk_message.content` JSONB;`apps/server/src/onetalk/read-projection.ts:84-113` 的唯一 public message projection;共享 contract 的 `OneTalkMessageContent` guard 被 server/harness 消费;`apps/mind-test-harness/src/harness/{contract,validators,messages}.ts` 注入同一版本/内容规则。
|
||||
- 证据基线:checkout `/Users/ybf/code/trade-message-center-worktree`,HEAD `b47720872da147e9da791de77424007e21e1a74c`(`origin/main`/`main`);本轮开始时唯一 dirty path 为未跟踪的任务目录 `.trellis/tasks/09-11-remove-image-dimensions/`,未修改产品代码或测试。已刷新 source:server schema/read repository/projection/service,Drizzle SQL/meta/journal/migrate/config,server read/migration integration tests,harness validator/messages/contract/page,非归档 docs。
|
||||
- 复用证据与缺口:`.trellis/tasks/archive/2026-09/09-02-onetalk-media-message-sync/research/server-media-current.md:31-45,60-75,120-127` 已覆盖旧媒体持久化/读模型基线,但其 v3/v2 结论已过时;本报告刷新到当前 v5/0008 基线并补充 v6 dimension-only migration 风险。无当前任务的其它 research artifact 可复用;真实 PostgreSQL、Mind production、Chromium live smoke 仍未验证。
|
||||
|
||||
## 发现
|
||||
|
||||
### 服务端事实与读投影
|
||||
|
||||
- `onetalk_message.content` 是唯一 normalized JSONB 内容事实,schema 只以对象、`version=1`、`kind in ('text','image','file')` 做数据库层 CHECK;服务端 TypeScript 列类型直接是 `OneTalkMessageContent`(`apps/server/src/database/schema/onetalk.ts:60-118`)。没有独立的 width/height 列或图片 projection 列。
|
||||
- 写入 repository 对已验证消息浅复制 `content`,不重新解释 raw payload(`apps/server/src/onetalk/repository.ts:134-157`);重复复合键保留首次内容事实,因此迁移是旧图片去宽高的必要步骤,重新观察 duplicate 不会自动修复 JSONB(`apps/server/src/onetalk/repository.ts:306-365`)。
|
||||
- 读取 repository 只按账号+会话受限查询消息并复制 JSONB,read service 再统一走 `projectCenterMessage`(`apps/server/src/onetalk/read-repository.ts:240-251,371-400`;`apps/server/src/onetalk/read-service.ts:176-215`)。`toOneTalkCenterMessage` 在返回 Mind-facing message 前调用共享 `isOneTalkMessageContent`,无本地 image decoder/fallback(`apps/server/src/onetalk/read-projection.ts:84-113`)。因此旧 image JSONB 若仍有 dimensions,在新 strict guard 下会读失败;迁移必须先于新 server 读取窗口执行。
|
||||
- 现有 read-domain image fixtures 在 `apps/server/test/onetalk-read-domain.test.ts:395-462,464-539` 明确含 `width/height`,并断言 history 与 `message.created` 使用同一 normalized object;v6 应仅删除 fixture 的两个字段,同时保留 parity、public key whitelist、非图片 content 不变。
|
||||
- 当前 read PostgreSQL integration 真实插入消息 JSONB 并通过 service 读取(`apps/server/test/onetalk-read-postgres.integration.test.ts:133-179,270-307`),但没有旧 image migration/readback 用例;它是新增迁移 integration probe 的最佳归属,不应扩展到 JOIN 或新的 message table。
|
||||
|
||||
### Drizzle migration / journal
|
||||
|
||||
- migration 单一目录和 journal 配置在 `apps/server/src/database/migration-config.ts:1-8`;`drizzle.config.ts:7-12` 使用 `schema: ./src/database/schema/**/*.ts`、`out: ./drizzle` 和该 journal;实际 migrator 使用 `drizzle.__drizzle_migrations`、schema `drizzle`、postgres.js `{ max: 1 }`(`apps/server/src/database/migrate.ts:37-65`)。
|
||||
- 当前 SQL 文件到 `0008_lucky_captain_universe.sql`,`meta/_journal.json:4-68` 有 idx 0..8 且 tag 与文件 stem 一致;当前 latest snapshot 是 `meta/0008_snapshot.json`,不存在 0006/0007 snapshot。新 migration 只能追加 `0009_<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 owner:shared `apps/onetalk-contract` guard 是 canonical content shape owner;Bright PostgreSQL `onetalk_message.content` 是 persisted fact owner;`read-projection.ts` 是 public message projection owner;MAIN extension decoder 是 raw `width/height` 可见边界;harness validator/renderer 只能消费 shared normalized shape。任何 local fallback 或第二个 raw parser 都违反事实来源唯一性。
|
||||
- async/副作用边界:read path 是无写入的受限查询 → strict guard → public projection(`read-repository.ts:371-400`, `read-service.ts:176-215`, `read-projection.ts:95-113`);migration 是独立一次性 job,由 `runMigrations` 在单客户端 apply 后 finally close(`migrate.ts:37-65`)。不可逆边界为 JSONB `UPDATE`,必须在旧 server 停止后执行;异常/数据库失败不得向下游伪造成功。
|
||||
|
||||
### Static/unit probes
|
||||
|
||||
1. `rg -n -i --glob '!docs/archive/**' --glob '!.trellis/tasks/archive/**' '\b(width|height)\b' apps/server apps/mind-test-harness docs`:代码结果只允许 UI/CSS geometry、raw-evidence 注释/fixture(若保留并明确边界)和非 canonical 文本;不应有 server public image/validator/renderer/matching dimensions。
|
||||
2. `node --experimental-strip-types --test apps/server/test/onetalk-read-domain.test.ts apps/server/test/migrate.test.ts`:无宽高 image fixture 可被 read projection 接受;带 dimensions 的 persisted shape 应被 shared strict guard 拒绝;migration runner 成功/失败都 close client。
|
||||
3. `pnpm --filter @trade-message-center/server db:check`:Drizzle snapshots/journal 结构一致;不能证明 JSONB 数据变化。
|
||||
4. `pnpm --filter @trade-message-center/mind-test-harness typecheck`:支架 TypeScript 编译;按规范不执行/添加 harness test script。可另用 `node --check` 检查从 `createOneTalkHarnessHtml` 提取的内嵌脚本,但需注明是 static syntax probe,不是浏览器 DOM smoke。
|
||||
5. `pnpm typecheck && pnpm build && pnpm test && pnpm format:check && git diff --check`:按任务计划执行根门禁;server package test 仍是 source + compiled dual run。
|
||||
|
||||
### Runtime/database probes(本轮未启动服务,未获得运行时证明)
|
||||
|
||||
1. 隔离 PostgreSQL(必须显式 `TEST_DATABASE_URL`,每个 test timeout ≤60s):先 `runMigrations` 到 0008,插入同一账号下 text/file、image-with-width-height、image-with-only-width、image-with-only-height、image-with-unrelated-extra-key 及非 image sentinel;应用 0009,再查询 `content`。预期:只有 image 行移除顶层 `width`/`height`,其它字段/非 image JSON 不变;重复 `runMigrations` 不再更新/重复 journal;`drizzle.__drizzle_migrations` 恰有一个 0009 记录。
|
||||
2. 同一隔离数据库在迁移后用 `createOneTalkReadRepository` + `createOneTalkReadService` 读取 direct conversation;预期 image history/public projection 成功,输出无 dimensions;旧带 dimensions shape 在迁移前通过 new strict read guard 失败是预期窗口风险,不能让新 server 在迁移前读旧库。
|
||||
3. migration transaction/failure probe:让 SQL 或连接失败,确认 `db:migrate` 返回非零且 migration client finally close;确认失败不会发送 ACK/事件(读路径本身没有 ACK)。禁止连接生产 DB、执行 `db:reset`、手工 DELETE 或启动 server 作为“迁移证明”。
|
||||
4. Chromium/Mind harness smoke(需独立授权和可用环境):在 v6 server + v6 extension + 当前 harness/Mind client 完整切换后发送测试图片,检查 live `message.created`/`send.result` 的 canonical content 无 dimensions,并确认 harness 显示 extension/size/preview/error,不显示像素尺寸。没有 `TEST_DATABASE_URL`、Chromium/CDP、Mind session 时只能报告 external_unverified/blocked,不得以 static/unit 结果冒充端到端完成。
|
||||
|
||||
## 注意事项 / 未找到内容
|
||||
|
||||
- 当前 server migration/test 基线没有专门的 data-only JSONB migration、image cleanup integration 或 migration SQL 语义测试;`migrate.test.ts` 仅覆盖 URL、apply 和 client close(`apps/server/test/migrate.test.ts:24-61`)。新增 deterministic migration/readback test 是必要的,而不是依赖 `db:check`。
|
||||
- 当前 `apps/server/test/onetalk-read-postgres.integration.test.ts` 与 `apps/server/test/onetalk-postgres.integration.test.ts` 在缺少 `TEST_DATABASE_URL` 时明确 skip;本轮 read-domain 12/12 通过,migration/read integration 命令共 4 个 PG skips、4 个 unit passes。真实 PostgreSQL data mutation 尚未执行。
|
||||
- `OneTalkImageContent` 当前是 content v1;任务要求把 wire protocol 从 v5 升至 v6,但不要把 content version 误升为 v2,除非 shared contract owner 的设计另有决定。DB CHECK 仍允许 `version=1`,其 exact image keys 由 shared guard 负责。
|
||||
- 迁移只能处理现有 persisted canonical JSONB 顶层键;不能清洗 `onetalk_message_anomaly.payload`、raw OneTalk capture、IndexedDB、日志或历史文档中的 raw width/height,因为它们属于诊断/证据或外部边界,且任务明确排除。
|
||||
- 未找到独立 trade-mind checkout 的字段级图片 width/height consumer;本任务不修改 `/Users/ybf/work/trade-mind`,部署者需单独验证其 client 已切到 v6。未找到 harness 自动化 DOM test、生产 Mind UI 或 live media push runtime proof。
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"]) &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 evidence,v6 downstream 不读取。
|
||||
|
||||
### 4.7 最终页面发送参数
|
||||
|
||||
@@ -341,7 +343,7 @@ conversationCode = input.cid || sdkContext.cid;
|
||||
|
||||
因此只要传入非空目标 `cid`,当前页面 selected conversation 不参与最终路由;缺失 `cid` 时才会回退 SDK 当前上下文,存在发错会话风险。
|
||||
|
||||
图片被转换为 BaaS `originalData`:
|
||||
图片会被转换为 BaaS raw `originalData`:
|
||||
|
||||
```ts
|
||||
{
|
||||
@@ -356,6 +358,8 @@ conversationCode = input.cid || sdkContext.cid;
|
||||
}
|
||||
```
|
||||
|
||||
这是页面上游的 raw upload payload 示例,不是 v6 canonical contract。即使该 raw payload 含 `width` / `height`,MAIN decoder 也会忽略它们,且 post-upload metadata、correlator 和下游 public content 都不会读取或传递这两个字段。
|
||||
|
||||
### 4.8 `sendImageMessage` 快捷入口
|
||||
|
||||
页面 SDK 还暴露:
|
||||
@@ -441,8 +445,6 @@ type OneTalkImageContent = {
|
||||
fileId: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5: string | null;
|
||||
previewUrl: string | null;
|
||||
@@ -454,8 +456,8 @@ send correlator 不需要也不应该继续读取 raw `originalData`。应比较
|
||||
|
||||
```text
|
||||
message.content.sizeBytes
|
||||
message.content.width
|
||||
message.content.height
|
||||
message.content.md5
|
||||
message.content.fileId (when present)
|
||||
```
|
||||
|
||||
### 5.3 observer 与 correlator 的调用顺序
|
||||
@@ -469,11 +471,11 @@ observedSink(batch);
|
||||
|
||||
因此图片 live batch 在跨 MAIN bridge、写 IndexedDB 或上传 Bright 之前,已经可以交给发送确认 correlator。无需新增第二个 WebSocket observer,也不应增加另一套 raw payload parser。
|
||||
|
||||
## 6. 建议的图片确认模型
|
||||
## 6. 当前 v6 图片确认模型
|
||||
|
||||
### 6.1 Pending 数据
|
||||
|
||||
现有 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 的 `PendingSend` 只保存字符串正文。图片支持应改为判别联合,而不是给文本结构追加一组可选字段:
|
||||
v6 的 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 已使用 text/image/file 判别联合;图片 pending 保存目标会话、候选消息 ID 与最终 post-upload metadata,而不是给文本记录追加可选字段。下列 image 分支是当前实现的形状:
|
||||
|
||||
```ts
|
||||
type PendingTextSend = {
|
||||
@@ -491,21 +493,19 @@ type PendingImageSend = {
|
||||
candidateMessageIds: Set<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 Protocol(CDP,`127.0.0.1:9222`)只读运行时探查、历史 WebSocket 帧捕获、已加载 SDK bundle 静态检索、仓库代码追踪
|
||||
> 安全边界:本报告不保存或展示 Cookie、`sid`、`chatToken`、加密账号、签名 URL、消息正文和二进制内容;示例只保留字段名、类型和脱敏结构。
|
||||
>
|
||||
> v6 同步说明(2026-09-11):raw WebSocket/SDK 样本仍是历史调查证据;当前规范性图片合同只包含 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`(另有 `version`、`kind`)。MAIN decoder 忽略 raw `width` / `height`,它们不跨 normalized boundary;本报告不是 v6 live runtime 验收。
|
||||
|
||||
## 1. 摘要
|
||||
|
||||
@@ -13,7 +15,7 @@ OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文
|
||||
- 图片:`contentType = 101`,`content.custom.type = 7`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
- 附件:`contentType = 101`,`content.custom.type = 10010`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
|
||||
当前扩展的传输和持久化边界已经能够保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当前没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。
|
||||
以下是 2026-09-01 的历史调查结论:当时扩展的传输和持久化边界会保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当时没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。它不描述当前 v6 流程。
|
||||
|
||||
本次 CDP 实测在当前登录页面的两个已加载会话中调用了只读历史接口,捕获到一页 20 条消息的历史 WebSocket 帧;现有 `parseOneTalkMessages` 返回了全部 20 条,其中包含一条图片和一条附件。没有点击上传、发送或下载,发送侧结论只来自 SDK 和 bundle 的方法/调用形态分析。
|
||||
|
||||
@@ -30,9 +32,9 @@ OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文
|
||||
5. 对同一帧运行仓库现有 `parseOneTalkMessages` 后,图片和附件仍保留在 `message.content`,但 `message.text` 为 `null`。
|
||||
6. 图片和附件的 `custom.data` 经 Base64 解码后是 JSON,而不是二进制图片或文件本体。
|
||||
|
||||
### 2.2 代码级确认、尚未做完整端到端实测
|
||||
### 2.2 历史代码级确认、尚未做完整端到端实测
|
||||
|
||||
- Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。
|
||||
- 当时 Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。
|
||||
- 当前没有真实数据库写入后的 Mind 页面媒体渲染回归测试。
|
||||
- 没有执行真实图片上传、附件上传、发送确认和下载操作,因此不能把 SDK bundle 中的发送能力称为扩展已经支持的能力。
|
||||
|
||||
@@ -180,7 +182,7 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是元数据,不是图片二进制本体。
|
||||
这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是 raw 上游元数据,不是图片二进制本体。此处的 `width` / `height` 仅保留为调查证据;当前 v6 MAIN decoder 忽略它们,且它们不会跨出 normalized boundary。
|
||||
|
||||
### 5.2 页面 SDK 归一化形态
|
||||
|
||||
@@ -207,7 +209,9 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位
|
||||
}
|
||||
```
|
||||
|
||||
`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。同步事实应继续保留原始内容,归类字段只作为投影依据。
|
||||
`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。上游 raw 内容只在 MAIN 边界短暂存在;同步事实使用其规范化投影,归类字段只作为投影依据。
|
||||
|
||||
SDK `originalData` 中的 `width` / `height` 同样只是 raw SDK 证据,不是 current v6 normalized image contract 的字段;MAIN decoder 不读取或传递它们。
|
||||
|
||||
## 6. 附件消息格式
|
||||
|
||||
@@ -279,28 +283,26 @@ MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位
|
||||
|
||||
`subType = 61`、`msgType = 10010` 是页面显示/消息模型的归类,不是可以脱离 `custom.type = 10010` 单独使用的稳定事实键。
|
||||
|
||||
## 7. 当前仓库的数据流追踪
|
||||
## 7. 历史数据流与当前 v6 边界
|
||||
|
||||
### 7.1 页面观察器
|
||||
|
||||
历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中:
|
||||
2026-09-01 的历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在当时的 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中:
|
||||
|
||||
1. 只要 `message.content` 是对象,就把它作为完整 `content` 保留。
|
||||
2. 从 `content.contentType` 提取 `contentType`。
|
||||
3. 只有 `content.text.content` 是字符串时,才填充 `text`。
|
||||
4. 图片/附件没有 `content.text.content`,所以 `text` 为 `null`。
|
||||
|
||||
因此,当前代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。
|
||||
因此,历史代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。
|
||||
|
||||
### 7.2 页面桥与 Service Worker
|
||||
|
||||
[页面桥转换](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66) 会复制观察消息的所有 JSON 字段;如果原消息已有 `content`,不会用 `text` 覆盖它。`content` 进入 Service Worker 后仍然是通用 JSON 值。
|
||||
这是历史实现:页面桥会复制观察消息的 JSON 字段,`content` 进入 Service Worker 后仍是通用 JSON 值。它已被 v6 normalized boundary 取代。
|
||||
|
||||
### 7.3 Bright 服务与数据库
|
||||
|
||||
[Bright 归一化](/Users/ybf/code/trade-message-center-worktree/apps/server/src/onetalk/service.ts:170) 对 `content` 执行通用 JSON 校验和敏感键过滤,然后将清洗后的 `content` 持久化。当前公共契约中的 [OneTalkMessage](/Users/ybf/code/trade-message-center-worktree/apps/onetalk-contract/src/model.ts:203) 也将 `content` 定义为通用 `OneTalkJsonValue`,没有要求它必须含有 `text`。
|
||||
|
||||
所以现有事实链可以保存:
|
||||
历史 Bright 归一化对 `content` 执行通用 JSON 校验和敏感键过滤,然后持久化。历史事实链为:
|
||||
|
||||
```text
|
||||
OneTalk raw content
|
||||
@@ -311,19 +313,32 @@ OneTalk raw content
|
||||
→ Mind history response
|
||||
```
|
||||
|
||||
当前缺少的是在某个明确边界增加媒体语义投影,而不是重新设计这条事实链。
|
||||
这条 raw 事实链仅为历史调查证据,当前不得使用。v6 的规范路径为:
|
||||
|
||||
## 8. 已经可以做到什么
|
||||
```text
|
||||
OneTalk raw content
|
||||
→ MAIN decoder(raw 只停留在此处)
|
||||
→ normalized content
|
||||
→ page bridge / Service Worker / IndexedDB
|
||||
→ Bright canonical JSONB / HTTP history
|
||||
→ Mind read model
|
||||
```
|
||||
|
||||
| 能力 | 当前状态 | 证据/限制 |
|
||||
| ----------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
||||
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 现场验收。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -7,16 +7,20 @@
|
||||
> 调试环境:Chromium `154.0.8012.0`,CDP `127.0.0.1:9222`,Trade Message Center `0.8.6`
|
||||
>
|
||||
> 调查方式:通过 Chromium CDP 观察真实 OneTalk WebSocket 响应、调用页面只读历史 SDK,并对照扩展 IndexedDB、共享协议和 Bright 存储代码。调查过程中没有发送消息、没有调用会改变已读状态的 API,也没有记录正文、账号、token、完整 URL 或 URL 查询参数值。
|
||||
>
|
||||
> v6 同步说明(2026-09-11):下列 raw 样本和当时能力结论保留为调查证据;当前规范性图片合同为无尺寸的 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`(另有 `version`、`kind`)。MAIN decoder 忽略 raw `width` / `height`,它们不跨 normalized boundary;本报告不是 v6 live runtime 验收。
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
当前系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**:
|
||||
以下结论是本报告调查时(2026-09-02)的历史快照:当时系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**:
|
||||
|
||||
- 文本能够从 `content.text.content` 提取为 `message.text`。
|
||||
- 图片和附件能够作为原始 `contentType=101/custom` JSON 被观察、写入 IndexedDB,并通过 Bright ACK。
|
||||
- 图片和附件没有规范化的 `kind`、文件名、扩展名、大小、宽高、缩略图或下载地址合同。
|
||||
- 图片和附件当时没有规范化的 `kind`、文件名、扩展名、大小、缩略图或下载地址合同;当前 v6 image canonical contract 见第 6 节。
|
||||
- 消费端如果只读取 `message.text`,就会表现为“文本存在,图片和附件不存在”。
|
||||
|
||||
这不是当前 v6 数据流。当前 raw OneTalk content 只在 MAIN decoder 内解码和收窄;页面桥、Service Worker、IndexedDB、Bright JSONB、HTTP read 和 Mind 下游只接收 normalized content,Bright 不保存 raw JSON。
|
||||
|
||||
真实样本已确认以下映射:
|
||||
|
||||
| 业务类型 | WebSocket 原始类型 | Base64 解码后的判定 | OneTalk SDK 归一化类型 |
|
||||
@@ -28,7 +32,7 @@
|
||||
|
||||
本次真实附件样本为 PDF;没有抓到 TXT 附件、实时图片 push 或实时文件 push,因此这些场景不能标记为已验证。
|
||||
|
||||
另有一个必须优先修复的安全问题:当前文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。
|
||||
调查时还发现一个必须优先修复的安全问题:文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。
|
||||
|
||||
## 2. 调查范围与证据边界
|
||||
|
||||
@@ -188,7 +192,7 @@ SDK hasMore:boolean
|
||||
WebSocket hasMore:0 | 1
|
||||
```
|
||||
|
||||
SDK `list[]` 条目确认包含:
|
||||
调查时 SDK `list[]` 条目确认包含:
|
||||
|
||||
```text
|
||||
autoReply
|
||||
@@ -217,6 +221,8 @@ uuid
|
||||
viewType
|
||||
```
|
||||
|
||||
这份 SDK 字段清单是历史 raw/boundary-only 证据,包含的 `originalData.width` / `originalData.height` 只可在 MAIN decoder 边界被忽略,不能成为 canonical 字段、存储字段或下游输入。
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 已经被 SDK 转成展示字符串。
|
||||
@@ -313,6 +319,8 @@ Base64 解码结果:JSON object
|
||||
}
|
||||
```
|
||||
|
||||
这是 OneTalk raw payload 证据,不是 normalized contract。raw `width` / `height` 可保留在此样本中,但 v6 MAIN decoder 忽略它们,绝不将其传过页面桥或写入 canonical content。
|
||||
|
||||
### 6.3 SDK 归一化结果
|
||||
|
||||
```text
|
||||
@@ -339,15 +347,15 @@ width
|
||||
|
||||
```ts
|
||||
type OneTalkImageContent = {
|
||||
version: 1;
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
md5: string | null;
|
||||
previewUrl: string | null;
|
||||
urlScope: "onetalk_session";
|
||||
};
|
||||
```
|
||||
|
||||
@@ -357,9 +365,9 @@ type OneTalkImageContent = {
|
||||
- `custom.data` 必须是有大小上限的合法 Base64。
|
||||
- Base64 解码结果必须是 UTF-8 JSON object。
|
||||
- `fileId`、`suffix`、`url` 必须为非空字符串。
|
||||
- `size`、`width`、`height` 必须为有限非负整数,并设置合理上限。
|
||||
- `size` 必须为有限非负整数,并设置合理上限;raw `width` / `height` 被 MAIN decoder 忽略,不参与校验或输出。
|
||||
- `isOriginal` 的 `0/1` 显式转换为 boolean。
|
||||
- `url` 只接受绝对 HTTPS URL,并在确认真实主机后加入固定 host allowlist。
|
||||
- `url` 只接受绝对 HTTPS URL,并在确认真实主机后映射为 `previewUrl`;`urlScope` 固定为 `onetalk_session`。
|
||||
- 不根据 `suffix` 猜造 OneTalk 未提供的 MIME;UI 可以使用安全扩展名映射做展示提示。
|
||||
|
||||
## 7. 文件附件原始格式
|
||||
@@ -507,7 +515,7 @@ decoded.params 通过文件 schema
|
||||
|
||||
其它合法但未支持的 `cardType` 应返回受控的 `unsupported` 内容,不应丢弃整条消息,也不应保留完整 raw payload。
|
||||
|
||||
## 9. 当前代码为什么表现为“只有文本”
|
||||
## 9. 调查时的代码为什么表现为“只有文本”
|
||||
|
||||
### 9.1 页面观察器
|
||||
|
||||
@@ -557,7 +565,7 @@ onetalk_sync_candidates
|
||||
|
||||
### 9.3 共享协议与 Bright
|
||||
|
||||
当前 `OneTalkMessage` 定义:
|
||||
调查时的 `OneTalkMessage` 定义:
|
||||
|
||||
```ts
|
||||
type OneTalkMessage = {
|
||||
@@ -586,7 +594,7 @@ text text nullable
|
||||
content jsonb
|
||||
```
|
||||
|
||||
因此 Bright 能保存媒体 raw JSON,但不能告诉 Mind:
|
||||
因此调查时的 Bright 能保存媒体 raw JSON,但不能告诉 Mind:
|
||||
|
||||
```text
|
||||
这是图片还是文件
|
||||
@@ -617,7 +625,7 @@ unsupported fallback
|
||||
|
||||
真实 Mind UI 如果只读取 `message.text`,媒体消息自然不可见。
|
||||
|
||||
## 10. 当前安全风险
|
||||
## 10. 调查时发现的安全风险
|
||||
|
||||
### 10.1 字符串内部的敏感字段绕过清洗
|
||||
|
||||
@@ -646,7 +654,7 @@ if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
1. `text.extension.basicMessageInfo` 中的序列化 JSON。
|
||||
2. `custom.data` 中的 Base64 JSON。
|
||||
|
||||
真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。当前实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。
|
||||
真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。调查时的实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。
|
||||
|
||||
### 10.2 推荐的安全边界
|
||||
|
||||
@@ -689,7 +697,20 @@ Authorization
|
||||
|
||||
在上述行为没有真实验证前,只能称其为 `sourceUrl`,不能承诺“Mind 可直接下载”。
|
||||
|
||||
## 11. 推荐的目标内容合同
|
||||
## 11. 当前 v6 内容边界
|
||||
|
||||
当前规范性数据流是:
|
||||
|
||||
```text
|
||||
OneTalk raw message
|
||||
→ MAIN decoder(raw 仅在此处出现)
|
||||
→ exact normalized content
|
||||
→ page bridge / Service Worker / IndexedDB
|
||||
→ Bright canonical JSONB / message.created / HTTP history
|
||||
→ Mind read model
|
||||
```
|
||||
|
||||
因此 Bright 不保存 raw payload、`custom.data` 或 SDK row;它只保存共享 guard 已接受的 normalized content。下列合同说明该边界的目标形状。
|
||||
|
||||
建议让 `content` 成为消息展示内容的唯一事实源:
|
||||
|
||||
@@ -700,15 +721,15 @@ type OneTalkNormalizedContent =
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
version: 1;
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
md5: string | null;
|
||||
previewUrl: string | null;
|
||||
urlScope: "onetalk_session";
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
@@ -740,7 +761,7 @@ message.content.kind === "text" ? message.content.text : null;
|
||||
|
||||
## 12. 可以做到什么
|
||||
|
||||
### 12.1 当前已经做到
|
||||
### 12.1 调查时已经做到
|
||||
|
||||
- 观察文本、图片和 custom card 的原始 WebSocket content。
|
||||
- 使用 raw `channelAccountId + conversationId + messageId` 保持消息幂等。
|
||||
@@ -769,7 +790,7 @@ message.content.kind === "text" ? message.content.text : null;
|
||||
- 支持音频、视频、语音、压缩包或所有 OneTalk custom card:没有真实样本和枚举。
|
||||
- checkpoint 已完整收敛:本次末态仍为 `uploading/succeeded`。
|
||||
|
||||
## 13. 推荐 PRD 要求
|
||||
## 13. 当前 v6 不变量
|
||||
|
||||
### R1. 单一内容合同
|
||||
|
||||
@@ -799,7 +820,7 @@ channelAccountId + conversationId + raw messageId
|
||||
|
||||
- URL 只允许 HTTPS 和固定 OneTalk host allowlist。
|
||||
- URL query/hash/userinfo 不进入日志或错误。
|
||||
- 文件大小、宽高和时间字段必须有上下限。
|
||||
- 文件大小和时间字段必须有上下限;图片 raw `width` / `height` 由 MAIN decoder 忽略,不能成为 normalized 字段、匹配条件或展示数据。
|
||||
- 文件扩展名规范化为小写有限字符集。
|
||||
- 文件名按纯文本处理并限制长度。
|
||||
- MD5 只能作为来源元数据,不能替代消息 ID 或安全签名。
|
||||
@@ -860,7 +881,7 @@ aliIdEncrypt
|
||||
## 14. 推荐验收标准
|
||||
|
||||
- [ ] 真实文本历史消息归一化为 `{ kind: "text", text }`,并且 content 不含 extension。
|
||||
- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为 image,尺寸、大小、后缀与真实载荷一致。
|
||||
- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为无尺寸 v6 image;`fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 与 `urlScope` 通过 canonical contract,raw `width` / `height` 不跨 MAIN 边界。
|
||||
- [ ] 真实 PDF 样本从 `custom.type=10010/cardType=12` 归一化为 file,文件名、扩展名、大小和 URL 元数据一致。
|
||||
- [ ] `custom.type=10010/cardType=2000` 不会被识别成文件。
|
||||
- [ ] 非法 Base64、非法 JSON、超限数据、非 HTTPS URL、缺字段和大小溢出均 fail closed,不影响同批其它消息。
|
||||
@@ -872,7 +893,7 @@ aliIdEncrypt
|
||||
- [ ] 获得真实 live 图片和附件 push 后证明历史与实时共用同一 decoder。
|
||||
- [ ] 验证媒体 URL 在有 Cookie、无 Cookie、跨 Origin、过期和跳转场景的行为,再决定保存 URL、保存 ID 或增加受控代理。
|
||||
|
||||
## 15. 相关代码位置
|
||||
## 15. 调查时的相关代码位置
|
||||
|
||||
| 层 | 文件 | 当前行为 |
|
||||
| -------------- | --------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
@@ -890,15 +911,13 @@ aliIdEncrypt
|
||||
|
||||
## 16. 最终判断
|
||||
|
||||
OneTalk 当前真实载荷已经提供实现图片和文件同步所需的核心元数据,且 raw `custom.data` 可以在页面内稳定识别为 Base64 JSON。第一阶段不需要下载媒体文件,也不需要新建第二套消息表;可以在现有消息链上增加一个唯一的内容 decoder 和跨层 typed contract。
|
||||
|
||||
实现工作的核心不是“把 `contentType=101` 放行”,因为当前已经放行;真正需要完成的是:
|
||||
历史调查证明 OneTalk raw payload 提供实现图片和文件同步所需的核心元数据。当前 v6 不再沿用该报告中 raw content 跨页面桥、IndexedDB、Bright 或 Mind 的历史路径;实现核心已经收敛为 MAIN decoder 的单一白名单边界:
|
||||
|
||||
```text
|
||||
opaque raw content
|
||||
→ 严格、安全、可测试的 text/image/file/unsupported 合同
|
||||
→ Bright 持久化与事件保持同一语义
|
||||
→ Mind 按 kind 展示
|
||||
→ Bright 持久化与事件只使用 normalized content
|
||||
→ Mind read model 只接收 normalized content
|
||||
```
|
||||
|
||||
同时必须先关闭 raw 字符串中嵌套凭证可能进入 Bright 的安全缺口,否则新增媒体支持会进一步扩大敏感 payload 的存储范围。
|
||||
raw 字符串中嵌套凭证进入 Bright 是本报告记录的历史风险;当前 v6 边界以不让 raw payload 离开 MAIN decoder 的方式关闭它。真实 v6 Chromium 运行态验收仍未执行。
|
||||
|
||||
Reference in New Issue
Block a user