chore(task): archive 09-14-collect-product-messages

This commit is contained in:
YBF
2026-09-14 13:05:06 +08:00
parent 4042c34d4b
commit fb521a95e0
6 changed files with 216 additions and 0 deletions
@@ -0,0 +1,3 @@
{"file":".trellis/spec/project/architecture.md","reason":"检查共享合同唯一所有者和跨包依赖方向。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查扩展的 focused test、typecheck、build 验证。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"检查服务端迁移、读取投影和测试验证。"}
@@ -0,0 +1,65 @@
# 商品消息收集设计
## 做到什么程度
本任务的完成边界是“收集可安全表达的商品链接事实”,而不是“收集页面渲染的商品卡”。商品卡 DTO 中的 `productImage`、标题、价格、MOQ 来自当前页面 React 模板数据,不是原始 OneTalk 消息接口返回;它们不在本任务的可信输入与持久化范围内。
系统只保存一个经过严格规范化的详情页引用和从路径提取的 `productId`。后续若要做商品详情富化,应单开任务,先确定合法数据源、刷新策略、价格/库存时效和访问凭据边界。
## 数据流
```text
OneTalk 接口 data.messageList[].content
= 含 query 的原始商品详情 URL
↓ SDK 历史适配
SDK 扁平项 originalData.text
↓ MAIN world: 共享 normalizeOneTalkProductUrl()
{ version: 1, kind: "product", sourceUrl, productId }
↓ 既有 page bridge / Service Worker
message.observed 或 messages.observed
↓ Bright: contract 校验 → DB transaction → ACK → message.created
onetalk_message.content JSONB
↓ Bright read projection
Mind history / message.created 的 content
```
原始实时消息入口也使用相同逻辑:`contentType=1``text.content.content``normalizeOneTalkProductUrl()`。这样实时/历史入口不会各自维护分类规则。
## 责任边界
| 层 | 负责 | 不负责 |
| --- | --- | --- |
| `packages/onetalk-contract/src/content.ts` | `product` 类型、精确形状、URL 规范化、`productId` 一致性校验 | 网络请求、SDK 读取、DB 写入 |
| `content-decoder.ts`MAIN world) | 对暂存原始文本调用共享解析,再回退为 text | 跨边界传 raw URL 或解析页面卡片 |
| page bridge / Service Worker | 传递已验证的 observation frame | 新增商品专用协议或修改 ACK 顺序 |
| server | 用共享合同验证、把 JSONB 事实入库、既有 ACK/发布/读取投影 | 从 URL 补商品资料 |
| database | 容纳 `content` JSONB,允许 `kind=product` | 保存 raw 消息或提供商品缓存 |
## 归类规则
`normalizeOneTalkProductUrl(value)` 仅返回安全的 `{ sourceUrl, productId }``null`
1. 输入必须是去首尾空白后未发生变化的字符串,长度受共享文本上限限制。
2. URL 必须为 HTTPS、host 精确为 `chinese.alibaba.com`、无 port、username、password 和 hash。
3. pathname 必须匹配 `/product-detail/<slug>-<productId>.html``productId` 是路径最后一个、非零开头的数字段。
4. 输出的 `sourceUrl` 固定为 `url.origin + url.pathname`,永远不带 query`productId` 必须等于该路径提取值。
5. 验证已持久化的 product 时再次执行相同规范化,要求其输出严格等于存储的 `sourceUrl``productId`
若任一商品规则不满足,文本正常继续归类为 `text`;因为它不是损坏媒体,也不记作 anomaly。
## 存储与兼容性
-`onetalk_message_content_v1_chk` 新增 `product` 到 kind 枚举;由于数据库没有旧 `product` 行,迁移只有 drop/add constraint,不做 `UPDATE`
- SQL check 不扩展为每种内容的 JSON schema;与现有 text/image/file/order 一致,exact-shape 由共享合同在插件入站 frame 和服务端读取 JSONB 时强制。
- `read-projection.ts` 已对非 `business_card` 内容做精确浅复制;`product` 走通用分支,使 history 与 `message.created` 的 content 一致。
- 旧客户端不认识 `product` 时会在其共享合同读取边界失败,所以该变更要求扩展、shared contract 与 server 作为一个兼容版本部署;任务不提供静默降级为 text。
## 安全不变量
- 只有 MAIN world 可以看到原始 URL;离开它的第一个对象已经移除了 query。
- `sourceUrl` 是名称明确的安全派生字段,不是重命名后的 raw `content`
- 任何将 `originalData`、原始 content、URL query、chatToken、加密目标或 React 卡片 DTO 放入 normalized content、frame、日志、数据库或 Mind 响应的改动都违反本设计。
## 回滚
新迁移不改写旧事实。回滚应用会停止新 product 写入;但在旧服务读取包含 product 的新行时会因其不认识该 kind 而失败,故部署/回滚须按共享合同、扩展和服务端的兼容版本整体执行。
@@ -0,0 +1,3 @@
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"product 从 MAIN world 经过协议、数据库和 Mind read projection 的跨层合同变更。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/history-sync.md","reason":"商品源字段来自 SDK 扁平历史消息,沿既有历史同步采集链路进入解码器。"}
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"任务增加 JSONB 内容 kind 的迁移,不改变查询或引入 JOIN。"}
@@ -0,0 +1,38 @@
# 商品消息收集实施计划
## 变更边界
最小行为缺口是:已验证的商品详情 URL 目前被当作 text,且数据库约束不允许 product;需要在共享消息合同的唯一 owner 中表达 product,并让两个文本输入入口共用这个判定。
预期改动文件:
| 文件/区域 | 原因 |
| --- | --- |
| `packages/onetalk-contract/src/content.ts` 与合同测试 | 新增 product 精确类型、唯一 URL 规范化函数和所有 transport/read 验证 |
| `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts` 与解码测试 | MAIN-world 从 raw/SDK flat history 文本安全归类 product |
| `apps/server/src/database/schema/onetalk.ts`、下一条 Drizzle migration/meta、迁移测试 | 允许 JSONB kind `product`,不修改历史内容 |
| `apps/server/test/onetalk-read-domain.test.ts` | 锁住 product 的 history/message.created 读取投影和畸形行拒绝 |
明确不改 page bridge、Service Worker、ACK/发布顺序、授权、去重、查询逻辑、商品详情访问和 UI。
## 实施顺序
1. 实施前加载 `trellis-before-dev` 规定的 shared、Chrome extension、server 规范;对每个待编辑 symbol 做 GitNexus upstream impactHIGH/CRITICAL 影响先报告。
2. 在共享合同实现 product 类型与 `normalizeOneTalkProductUrl()`;让 `decodeOneTalkMessageContent()` 对 product 做 exact-shape + canonical URL/ID 一致性校验。
3. 扩展合同测试:有效 product、拒绝 query/credentials/fragment/错误 host/path/ID/额外字段,及 observed/message.created 接受 product。
4. 将 MAIN-world `normalizeText` 改成首先调用共享解析器;raw `contentType=1` 与 SDK `msgType=101`/`subType=1` 都复用该入口。增加 raw、扁平历史、普通 URL、保密查询参数清除、既有类型不回归的测试。
5. 扩展 server schema kind 枚举;按 Drizzle 当前流程生成新的迁移和 meta。检查迁移仅重建约束,且仍保留 business_card marker-only 条件与所有既有 kinds。
6. 扩展 migration/read-domain 测试,锁住 JSONB product 行经 history 和 `message.created` 得到同一精确对象,以及手工畸形 product JSONB 被读取边界拒绝。
7. 独立检查完整 diff:禁止 raw URL/credential 漏出与重复 URL parser;验证 runtime data flow 是 decode → contract → store → ACK → publish。
## 验证
1. `pnpm --filter @trade-message-center/onetalk-contract test`
2. `pnpm --filter @trade-message-center/chrome-extension test`60 秒超时)
3. `pnpm --filter @trade-message-center/server test`60 秒超时)
4. 三个包的 `typecheck`,以及 `pnpm --filter @trade-message-center/server db:check`
5. `pnpm format:check``git diff --check`,并以 GitNexus `detect_changes()` 检查仅影响合同、解码、约束迁移和读取投影。
## 运行时证据边界
本任务已有原始接口与 SDK 字段的 Chrome 运行时证据;实现后若加载新扩展,可额外验证该同一消息只发布 query-free product frame。没有重新加载扩展和触发历史读取时,不宣称浏览器端到端验收完成。
@@ -0,0 +1,81 @@
# 收集 OneTalk 商品消息
## 目标
将已经在 OneTalk 中发送或接收的“商品详情链接消息”收集为 `product` 消息事实,让 Bright 持久化并让 Mind 读取一个去凭据、可稳定关联商品的最小引用。
本任务只收集链接本身派生的信息;不爬取商品页,也不尝试还原页面商品卡的标题、价格、主图或 MOQ。
## 实时证据与字段来源
已在会话 `2208314000798-2500002169502#11011@icbu` 的真实 OneTalk 页面验证以下链路:
| 字段/信息 | 原始位置 | 用途 | 是否可跨 MAIN world / 入库 |
| --- | --- | --- | --- |
| 商品详情原始 URL | `POST /message/listRecentMessage.htm` 响应的 `data.messageList[].content` | 页面/API 的原始消息正文 | 否;查询参数携带 `chatToken` 与加密登录目标 |
| 同一原始 URL | OneTalk SDK 扁平历史项的 `originalData.text` | 扩展历史采集的实际输入 | 仅在 MAIN world 暂存并解析 |
| 展示文案 | 扁平历史项的 `content` | SDK 给 UI 的展示内容 | 否;不是规范消息正文,不能作为判定来源 |
| 商品 URL 类别线索 | 扁平历史项的 `msgType=101``subType=1` | 与现有普通文本消息共用的 SDK 分类 | 只作为进入文本/商品识别的前提,不能单独认定为商品 |
| `sourceUrl` | 从 `originalData.text` 使用 `URL` 解析后取 `origin + pathname` | 安全、无查询参数的商品详情页引用 | 是 |
| `productId` | 从已验证路径 `/product-detail/<slug>-<数字>.html` 最后一个数字段提取 | 稳定商品标识 | 是 |
本任务只接受实时验证过的 `https://chinese.alibaba.com/product-detail/<slug>-<数字>.html` 形态:不接受其它 host、路径、协议、端口、用户名/密码、fragment 或无法提取数字 `productId` 的链接。合法但不匹配该形态的文本仍是 `text`,而不是错误或 `product`
## 现状与行为缺口
- `history.ts` 将 SDK 扁平历史项的 `originalData` 交给 `content-decoder.ts`;现有 `msgType=101` / `subType=1``originalData.text` 为字符串的消息会进入 `normalizeText`
- `content-decoder.ts` 运行于 MAIN world。它已有图片、文件、名片、询盘、订单的安全归一化入口,但没有商品 URL 分支。
- `packages/onetalk-contract``OneTalkMessageContent` 当前为 `text | image | file | business_card | inquiry | order`,所有 observation frame、服务端摄入、读取投影都依赖该共享合同。
- `onetalk_message.content` 是唯一的规范化 JSONB 消息内容;目前数据库 check 约束的 kind 列表不含 `product`
## 目标合同与数据库事实
新增内容必须是精确形状,不能携带任何原始字段:
```json
{
"version": 1,
"kind": "product",
"sourceUrl": "https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
"productId": "1601456609478"
}
```
该对象将作为 `onetalk_message.content` 的 JSONB 值写入;表中同一事实行同时已有既有消息元数据:
```text
channel_account_id ← 已认证的插件/页面账号 scope(不是 URL 或 activeAccountId
conversation_id ← OneTalk 消息的 cid / conversationCode
message_id ← OneTalk messageId,经既有 normalizeOneTalkMessageId 规范化
sender_id ← OneTalk sender.uid
direction ← 由 sender 与当前账号的既有方向判定
sent_at_ms ← OneTalk createAt / sendTime
content ← 上述精确 product JSONB
participant_ids、read_status、message_status、unread_count ← 既有消息观察字段
first_observed_at、last_observed_at ← Bright 观察时间
```
不会新增 `raw_content``product_url``product_id` 等独立列,不会持久化 `originalData`,也不会保留原始 URL 的 query string。因此 `chatToken`、加密登录目标和任意追踪参数不会出现在 observation frame、数据库 JSONB、日志或 Mind 响应中。
## 需求
1. 在共享 OneTalk 内容合同中新增 `OneTalkProductContent`,精确字段为 `version``kind``sourceUrl``productId`;合同校验要拒绝 query、错误 host/path、ID 不一致、额外字段和其它畸形值。
2. 在共享合同中提供唯一的商品 URL 规范化/解析函数,供 MAIN-world 解码和已归一化内容校验共用,禁止第二套路径/URL 解析规则。
3. 原始文本与 SDK 扁平历史文本入口都先尝试该商品解析;命中后归一化为 `product`,否则保持现有 `text` 行为。
4. 更新数据库 kind 约束并新增迁移;迁移只增加 `product` 允许值,不回填或改写已有事实。
5. 通过现有 `message.observed` / `messages.observed`、数据库摄入、ACK 后发布、history 和 `message.created` 读取链路传递 `product`,不新增 transport、授权、去重或发布顺序。
6. 添加合同、扩展解码、迁移与服务端读取回归测试;测试证明 raw URL 查询参数不会越过 MAIN-world 边界。
## 不在范围内
- 请求商品详情页、调用商品 API、解析 React 商品卡 DTO,或保存 `productImage`、标题、价格、MOQ、促销和下单动作。
- 扩展支持的 host/path 以外的商品链接。
- 商品消息发送、URL 可访问性探测、图片/附件下载,或迁移历史 `text` 行为。
## 验收标准
- [ ] 已验证 URL 形态的历史或原始文本消息会产生上述精确 `product` 内容,并进入既有观察与提交链路。
- [ ] 普通文本 URL 与不能严格解析的候选 URL 仍为 `text`;图片、文件、名片、询盘和订单行为不变。
- [ ] `onetalk_message.content` 保存精确的 product JSONB,读取 history 与 `message.created` 返回相同的安全对象。
- [ ] 原始 `content``originalData`、URL 查询参数、`chatToken` 和加密登录目标不出现在消息 frame、数据库内容、诊断或 Mind 读模型中。
- [ ] 合同、入站 frame 和持久化读取会拒绝畸形/附加字段的 `product` 内容;数据库迁移保留所有既有 kind 约束。
@@ -0,0 +1,26 @@
{
"id": "collect-product-messages",
"name": "collect-product-messages",
"title": "收集 OneTalk 商品消息",
"description": "将商品详情消息安全归一化为 product 内容,并经既有同步链路提交给 Bright/Mind。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-14",
"completedAt": "2026-09-14",
"branch": "09-14-collect-product-messages",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}