From 905e9fbfd680da49dcbbfbeae8719d2bac1c5c46 Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:32:46 +0800 Subject: [PATCH 1/2] fix: unify OneTalk rendered card Mind content types --- .trellis/spec/server/backend/index.md | 2 +- .../spec/server/backend/service-foundation.md | 63 +++++++- .../check.jsonl | 2 + .../design.md | 51 ++++++ .../implement.jsonl | 2 + .../implement.md | 67 ++++++++ .../prd.md | 65 ++++++++ .../task.json | 26 +++ apps/server/src/onetalk/read-projection.ts | 86 ++++++++-- .../server/src/onetalk/rendered-card-model.ts | 14 +- .../src/onetalk/rendered-card-repository.ts | 26 ++- apps/server/src/onetalk/repository.ts | 12 +- .../plugin/flows/rendered-card-flow.ts | 3 +- .../plugin/flows/send-confirmation-flow.ts | 5 +- .../src/websocket/plugin/flows/sync-flows.ts | 8 +- apps/server/test/onetalk-domain.test.ts | 112 +++++++++++++ apps/server/test/onetalk-read-domain.test.ts | 125 +++++++++++++- .../test/onetalk-rendered-card-flow.test.ts | 52 +++++- .../onetalk-rendered-card-repository.test.ts | 2 +- apps/server/test/onetalk-websocket.test.ts | 120 ++++++++++++-- packages/onetalk-contract/src/content.ts | 153 +++++++++++++++++- packages/onetalk-contract/src/index.ts | 12 ++ .../onetalk-contract/src/rendered-cards.ts | 111 ++++++++----- .../onetalk-contract/test/contract.test.ts | 107 +++++++++++- 24 files changed, 1118 insertions(+), 108 deletions(-) create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/check.jsonl create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.jsonl create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.md create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md create mode 100644 .trellis/tasks/09-15-unify-onetalk-card-mind-types/task.json diff --git a/.trellis/spec/server/backend/index.md b/.trellis/spec/server/backend/index.md index 72116ef..2a5e699 100644 --- a/.trellis/spec/server/backend/index.md +++ b/.trellis/spec/server/backend/index.md @@ -36,7 +36,7 @@ | [错误处理](./error-handling.md) | HTTP/WS 稳定错误映射与安全边界 | 已建立 | | [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 | | [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 | -| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 | +| [服务基础设施](./service-foundation.md) | Fastify、WebSocket、ORM 与 OneTalk Mind 公共投影契约 | 已建立 | | [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 | | [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、workspace 声明、CORS/Origin 与 fail-closed 边界 | Center 已实现;真实 Mind/浏览器联调待验 | | [OneTalk 单会话历史重建](./mind-history-rebuild.md) | rebuild 独立授权、scoped clear、generation/rebuild correlation、原子 reset 与 completion anchor | 已实现;真实 PostgreSQL/浏览器联调另行验证 | diff --git a/.trellis/spec/server/backend/service-foundation.md b/.trellis/spec/server/backend/service-foundation.md index e2585b5..8d1f6b3 100644 --- a/.trellis/spec/server/backend/service-foundation.md +++ b/.trellis/spec/server/backend/service-foundation.md @@ -301,7 +301,7 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null - 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation`:`name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0;`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。 - 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。 - 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。内部 `7777` summary listener 必须同时提供两端时间。 -- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1` 的 `text | image | file | business_card | inquiry | order` union。`business_card` 在消息事实中严格是 `{ version: 1, kind: "business_card" }` marker;read projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项 view 字段。没有 profile 时返回 marker,profile 部分缺失时对应字段为 `null`,且 view 不得写回 message/profile。read projection 不得解 Base64、`custom.data`、`contentType`、卡片原始正文/`params`、文件名或 URL fallback;它只复制 shared contract 已批准的字段。 +- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1` 的 `text | image | file | business_card | inquiry | order | product` union。`business_card` 在消息事实中严格是 `{ version: 1, kind: "business_card" }` marker;read projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项 view 字段。没有 profile 时返回 marker,profile 部分缺失时对应字段为 `null`,且 view 不得写回 message/profile。read projection 不得解 Base64、`custom.data`、`contentType`、卡片原始正文/`params`、文件名或 URL fallback;它只复制 shared contract 已批准的字段。 - Mind 联调页由 `apps/mind-test-harness/` 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。 - 插件 `plugin.status`、`sync.status` 和 `message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`;internal summary listener 不注册 CORS。 @@ -369,6 +369,67 @@ const profile = await contactProfileRepository.read(scope.channelAccountId, conv return projectCenterMessage({ ...message, content: stored }, profile ?? undefined); ``` +## Scenario: OneTalk 渲染卡补全的 Mind 公共投影 + +### 1. Scope / Trigger + +- Trigger:插件已经独立持久化 `rendered.card.observed`,而 Bright 要经 HTTP history、`message.created` 或 `message.updated` 把对应展示信息提供给 Mind。 +- Scope:`packages/onetalk-contract/src/content.ts` 的 Center-only content union 与 `apps/server/src/onetalk/read-projection.ts` 的唯一投影;读取、live sync、send confirmation 和 rendered-card flow 都是该投影的消费者。 +- Excluded:插件 wire、ACK、`onetalk_rendered_card_content` schema、补全去重/冲突规则、Trade-Mind 解码或展示;它们不得把内部 `rendered_*` type 当作 public content。 + +### 2. Signatures + +```ts +toOneTalkCenterMessage( + message: OneTalkMessage, + renderedCardContent?: OneTalkRenderedCardContent, + customerProfile?: OneTalkReadCustomerProfile, +): OneTalkCenterMessage; +``` + +`OneTalkCenterMessageContent` 的 `inquiry`、`product`、`order` 分支分别可增加已批准的展示字段;`OneTalkMessageContent` 与 `OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。 + +### 3. Contracts + +- 输出的判别字段始终是基础 `inquiry | product | order`,绝不输出 `rendered_inquiry | rendered_product | rendered_order`。无补全时,基础内容保持 exact shape,不用 `null` 或空对象伪造展示字段。 +- `rendered_inquiry` 只能补充 `product`、`purchaseQuantity`、`requirementText`、`inquiryReference`、`actions`;`rendered_product` 只能补充 `title`、`imageUrl`、`priceDisplay`、`minimumOrder`、`serviceBadges`;`rendered_order` 只能补充 `title`、`products`、`productCount`、`status`、`payment`、`delivery`、`action`。 +- product 的 `sourceUrl` 与 `productId` 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。 +- `projectCenterMessage`、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 `toOneTalkCenterMessage`;不得在调用点以 `renderedCardContent ?? message.content` 替换内容。 +- 补全先到时,后续单条或批量 accepted 基础消息都必须按同一 `channelAccountId + conversationId + messageId` 受限读取已有 supplement,并在 `message.created` 投影前带回。提交、guard、ACK 和 publish 顺序保持不变。 + +### 4. Validation & Error Matrix + +| Condition | Result | +| --- | --- | +| 无 supplement | 公开基础 content exact shape,不新增可选字段 | +| supplement 与基础 kind 不一致 | 投影失败,不发布 HTTP/WS 伪内容 | +| rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 | +| unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 | +| supplement 先到、base message 后由 batch live accepted | repository 带回 supplement;`message.created` 使用基础 kind 加批准字段 | +| duplicate/conflict supplement 或补全写失败 | 保持既有 ACK/冲突语义;不改写基础消息事实 | + +### 5. Good / Base / Bad Cases + +- Good:HTTP、`message.created` 和 `message.updated` 都调用同一个投影,订单公开为 `kind: "order"` 并可带 `status`/`payment`,内部 `rendered_order` 不离开服务端边界。 +- Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 `message.updated`。 +- Bad:repository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。 + +### 6. Tests Required + +- Contract:三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。 +- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 `rendered_*`。 +- Repository:batch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。 + +### 7. Wrong vs Correct + +```ts +// Wrong: internal supplement replaces the public discriminated content. +toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content }); + +// Correct: the sole projection validates and merges two separate facts. +toOneTalkCenterMessage(message, renderedCardContent); +``` + ## Scenario: OneTalk WS 固定协议内核与静态已认证路由 ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/check.jsonl b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/check.jsonl new file mode 100644 index 0000000..154f106 --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "Server validation and review requirements"} +{"file": ".trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md", "reason": "Accepted scope and observable contract criteria"} diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md new file mode 100644 index 0000000..a5cb123 --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md @@ -0,0 +1,51 @@ +# 统一 OneTalk 二次卡片 Mind 类型设计 + +## 边界 + +插件继续产生 `rendered.card.observed`,服务端继续把已验证的 `OneTalkRenderedCardContent` 写入 +`onetalk_rendered_card_content`。这些均为 Bright 内部协议和存储格式。本设计只改变 Center 公共消息 +投影:HTTP `messages[]`、`message.created` 与 `message.updated` 均输出相同的基础 kind 内容。 + +## 公共内容契约 + +`OneTalkMessageContent` 保持插件输入的 exact-shape 校验,不接收二次字段。新增仅供 Center 输出的三种 +内容类型,并组成 `OneTalkCenterMessageContent`: + +| 基础 kind | 原有字段 | 仅在有补全时增加的字段 | +| --- | --- | --- | +| `inquiry` | `version`, `kind` | `product?`, `purchaseQuantity?`, `requirementText?`, `inquiryReference?`, `actions?` | +| `product` | `version`, `kind`, `sourceUrl`, `productId` | `title?`, `imageUrl?`, `priceDisplay?`, `minimumOrder?`, `serviceBadges?` | +| `order` | 当前全部订单原始字段 | `title?`, `products?`, `productCount?`, `status?`, `payment?`, `delivery?`, `action?` | + +缺少补全时,三个内容仍为原有 exact shape;存在补全时只允许表中列出的可选键。嵌套图像、数量、动作、 +订单状态、金额与配送对象沿用 rendered-card 的既有字段校验;内部判断函数被导出复用,避免产生第二套 +字段验证规则。 + +## 投影与一致性 + +`toOneTalkCenterMessage` 接收基础消息及可选补全内容,成为唯一 public projection owner: + +1. 无补全时直接投影基础内容。 +2. 补全 kind 必须与基础 kind 对应;否则抛出明确错误,绝不猜测类型或静默降级。 +3. `rendered_product` 只能补充标题、图片、价格、MOQ、服务标记;输出中的 `sourceUrl`、`productId` + 必须保留基础消息值,并要求与补全值一致。 +4. 所有输出通过 `isOneTalkCenterMessageContent` 严格验证后才能进入 HTTP 或 WebSocket frame。 + +读取模型已把补全内容带回基础消息行;`projectCenterMessage` 将其作为 optional supplement 传给该函数。 +实时路径亦传入基础消息加 supplement:历史/实时 observation、发送确认与 rendered-card 补全通知都不得 +自行替换 `content`。 + +rendered-card repository 在补全到达且基础消息已存在时,返回基础消息内容和已保存 supplement 的两个事实, +供唯一投影函数合并;不更改写入、去重、冲突记录、ACK 或发布时序。 + +## 兼容与回滚 + +不改数据库 schema 或迁移;已有 rendered-card JSONB 继续可读取,并在下一次 HTTP 读取或实时发布时被 +转换为基础 kind。回滚只需恢复公共投影代码,内部记录、插件 wire 和原始消息事实不受影响。 + +## 验证策略 + +- contract 测试覆盖三种补全投影的完整和缺省形状,以及未知键、错误嵌套、kind/商品身份不一致。 +- server read-domain 测试覆盖 HTTP 读取投影;WebSocket 测试覆盖 `message.created` 与 + `message.updated`。 +- 保留现有插件 card reader、ledger / ACK 和服务端 repository 测试,证明内部协议没有回归。 diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.jsonl b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.jsonl new file mode 100644 index 0000000..8436f64 --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/project/module-ownership.md", "reason": "Shared contract type ownership and canonical exports"} +{"file": ".trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md", "reason": "Public content contract, projection ownership, and internal-boundary decisions"} diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.md b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.md new file mode 100644 index 0000000..3ac4588 --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/implement.md @@ -0,0 +1,67 @@ +# 统一 OneTalk 二次卡片 Mind 类型实施计划 + +**目标:** 只改变 Bright 面向 Mind 的 HTTP 与 WebSocket 消息内容,使二次卡片补全成为 +`product`、`inquiry`、`order` 的可选字段。 + +**全局约束:** 不改插件、Page bridge、rendered-card wire、ACK、数据库 schema 或 Trade-Mind;保留 +基础消息的字段和值;HTTP 与 WebSocket 必须复用同一公开投影。 + +## 文件与职责 + +- 修改 `packages/onetalk-contract/src/content.ts`:定义并验证 Center-only 卡片补全内容。 +- 修改 `packages/onetalk-contract/src/rendered-cards.ts`:导出 Center 校验可复用的嵌套值 guard,不改变 + rendered-card frame shape。 +- 修改 `apps/server/src/onetalk/read-projection.ts`:唯一地合并基础内容与 rendered supplement。 +- 修改 `apps/server/src/onetalk/rendered-card-model.ts` 与 `rendered-card-repository.ts`:补全实时更新返回 + 基础消息与 supplement,而不是已经替换 kind 的公开消息。 +- 修改 `apps/server/src/websocket/plugin/flows/rendered-card-flow.ts`、`sync-flows.ts`、 + `send-confirmation-flow.ts`:把基础消息和 supplement 交给统一投影。 +- 修改 `packages/onetalk-contract/test/contract.test.ts`、`apps/server/test/onetalk-read-domain.test.ts`、 + `apps/server/test/onetalk-rendered-card-flow.test.ts`、`apps/server/test/onetalk-websocket.test.ts`:锁定 + HTTP / WebSocket 公开契约和内部帧不变性。 + +## 执行步骤 + +### 1. 先锁定公共 contract + +- [ ] 在 `contract.test.ts` 写三组 Center `content` 断言:基础 `inquiry` / `product` / `order` 不带 + supplement 时保持 exact shape;带 supplement 时 kind 不变且仅出现已确认可选键;`rendered_*` 不再是 + Center content。 +- [ ] 写失败用例:未知键、部分嵌套对象、补全 kind 与基础 kind 不符、商品补全标识不同均被拒绝。 +- [ ] 在 `content.ts` 定义 `OneTalkCenterInquiryContent`、`OneTalkCenterProductContent`、 + `OneTalkCenterOrderContent`,仅让 `OneTalkCenterMessageContent` 使用它们;`decodeOneTalkMessageContent` + 继续只接受插件原始 exact shape。 +- [ ] 在 `rendered-cards.ts` 导出图像、数量、动作和订单显示字段的值 guard,并由 Center guard 调用; + 不改变 `OneTalkRenderedCardContent` 或 `rendered.card.observed`。 +- [ ] 运行 `pnpm --filter @trade-message-center/onetalk-contract test`。 + +### 2. 将补全合并收敛为一个服务端投影 + +- [ ] 在 `onetalk-read-domain.test.ts` 先写三个基础消息加 supplement 的断言,验证输出使用基础 kind、 + 基础 product 标识不被覆盖、无 supplement 的内容完全不变。 +- [ ] 在 `read-projection.ts` 增加将 `OneTalkMessageContent` 和可选 `OneTalkRenderedCardContent` + 合并为 `OneTalkCenterMessageContent` 的私有函数,并让 `toOneTalkCenterMessage` 与 + `projectCenterMessage` 都只经过该函数。 +- [ ] 在 `rendered-card-model.ts` / `rendered-card-repository.ts` 保留现有写入、冲突与 ACK 结果,但让 + `message` 保持基础内容;将已保存的 rendered content 作为独立的 supplement 返回。 +- [ ] 运行 `pnpm --filter @trade-message-center/server test -- onetalk-read-domain.test.ts` + 或等价 Node 定向测试,确认 HTTP 投影覆盖三种类型。 + +### 3. 接入所有 Mind WebSocket 发布路径 + +- [ ] 在 `onetalk-rendered-card-flow.test.ts` 把补全到达后的 `message.updated` 期望改为基础 kind 加 + 可选字段,并确认 ACK payload 未改变。 +- [ ] 在 `onetalk-websocket.test.ts` 覆盖:补全先到后基础 live message 产生 `message.created`、 + 基础先到后补全产生 `message.updated`、发送确认命中已有补全;三种情况下均不得发布 `rendered_*`。 +- [ ] 在 `rendered-card-flow.ts`、`sync-flows.ts`、`send-confirmation-flow.ts` 删除局部 + `renderedCardContent ?? message.content` 的替换逻辑,改为将基础消息和 optional supplement 传入 + `toOneTalkCenterMessage`。 +- [ ] 运行 `node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-flow.test.ts + apps/server/test/onetalk-websocket.test.ts`。 + +### 4. 全量边界检查 + +- [ ] 运行 `pnpm --filter @trade-message-center/onetalk-contract typecheck`、 + `pnpm --filter @trade-message-center/server typecheck`、目标 contract/server 测试。 +- [ ] 运行 `git diff --check`,人工确认不存在 Chrome 插件与 Trade-Mind 路径改动,且无数据库迁移。 +- [ ] 在提交前执行 GitNexus `detect_changes`,审阅受影响执行流只限 contract、读取投影与 Mind 发布。 diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md new file mode 100644 index 0000000..171edda --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md @@ -0,0 +1,65 @@ +# 统一 OneTalk 二次卡片 Mind 类型 + +## Goal + +让 Mind 通过 Bright HTTP 历史读取和实时 WebSocket 通知接收的二次卡片保持既有 +`product`、`inquiry`、`order` 判别类型;二次采集出的展示信息作为各类型的可选字段, +不向 Mind 暴露 `rendered_product`、`rendered_inquiry`、`rendered_order`。 + +## Confirmed Facts + +- 插件与 Bright 内部仍使用 `rendered.card.observed` 帧和 `OneTalkRenderedCardContent` 补全账本; + 本任务不改采集、桥接、持久化格式或 ACK 语义。 +- Bright HTTP 历史路由和 `message.created` / `message.updated` 都经同一公开消息投影;当前投影以 + `renderedCardContent` 直接替换基础 `content`,因此泄漏了 `rendered_*` kind。 +- 基础内容已定义 `inquiry`、`order`、`product`;二次采集已拥有三类对应展示字段。 + +## Requirements + +- R1:对已关联二次补全的基础消息,Bright 对 Mind 的 HTTP 历史响应与实时消息通知都保持基础 + `content.kind`,不得输出任何 `rendered_*` kind。 +- R2:`inquiry`、`order`、`product` 的原有字段和含义不变;没有二次补全时,不新增展示字段。 +- R3:二次补全信息只作为对应基础类型的可选字段公开;不得公开 raw React Fiber props、内部 + `contentFingerprint`、`observedAtMs` 或补全账本的内部类型。 +- R4:不修改 Chrome 插件采集、Page bridge、`rendered.card.observed` / ACK 协议或补全表 schema。 +- R5:本仓公开类型验证继续严格拒绝未知字段、错误嵌套形状、补全类型与基础类型不一致,以及商品补全 + 身份与基础 `sourceUrl` / `productId` 不一致。 + +### Confirmed Public Field Map + +- `inquiry`:保留 `{ version, kind }`,并可选增加 + `product`、`purchaseQuantity`、`requirementText`、`inquiryReference`、`actions`。 +- `product`:保留必填 `sourceUrl`、`productId`,并可选增加 + `title`、`imageUrl`、`priceDisplay`、`minimumOrder`、`serviceBadges`。 +- `order`:保留现有原始订单字段,并可选增加 + `title`、`products`、`productCount`、`status`、`payment`、`delivery`、`action`。 +- 补全字段只在存在已接受补全时出现;内部 `rendered_product.product.sourceUrl` 和 `productId` + 不覆盖基础 `product` 的同名事实。 + +## Acceptance Criteria + +- [ ] HTTP 历史响应中,已补全的订单、询盘、商品卡 `content.kind` 分别仍为 + `order`、`inquiry`、`product`。 +- [ ] WebSocket `message.created` / `message.updated` 中的相同消息使用与 HTTP 一致的内容形状。 +- [ ] 不含二次补全的既有基础卡片保持既有形状;二次字段按可选字段缺省,不以 `null` 或空对象伪造。 +- [ ] 插件侧现有二次采集、独立提交、重连补发与 ACK 测试保持通过。 +- [ ] Bright contract/server 的定向测试、类型检查通过。 + +## Out of Scope + +- 修改插件采集字段、DOM/Fiber 分类、内部 rendered-card wire 帧或数据库补全表。 +- 修改 Trade-Mind 的类型、HTTP / WebSocket 解码、状态管理或消息展示;Mind 端自行兼容本次公开契约。 +- 为卡片新增操作能力、详情跳转、购买/询盘动作处理或 UI 视觉设计。 +- 修改历史消息的基础身份、分页、授权或发送流程。 + +## Planning Decision + +只修改 `trade-message-center`:先在公共 Center content union 中表达保持基础 kind 的补全投影, +再让 HTTP、`message.created`、`message.updated` 复用同一个投影函数。内部 rendered-card 内容继续 +仅作为持久化补全事实,不能作为 Mind-facing content 直接发布。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/09-15-unify-onetalk-card-mind-types/task.json b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/task.json new file mode 100644 index 0000000..4be73ad --- /dev/null +++ b/.trellis/tasks/09-15-unify-onetalk-card-mind-types/task.json @@ -0,0 +1,26 @@ +{ + "id": "unify-onetalk-card-mind-types", + "name": "unify-onetalk-card-mind-types", + "title": "统一 OneTalk 二次卡片 Mind 类型", + "description": "将二次采集卡片投影为基本类型的可选字段", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "ybf", + "assignee": "ybf", + "createdAt": "2026-09-15", + "completedAt": null, + "branch": "09-15-unify-onetalk-card-mind-types", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/apps/server/src/onetalk/read-projection.ts b/apps/server/src/onetalk/read-projection.ts index d6e8cd1..a187ede 100644 --- a/apps/server/src/onetalk/read-projection.ts +++ b/apps/server/src/onetalk/read-projection.ts @@ -2,10 +2,13 @@ import { isOneTalkCenterMessageContent, + isOneTalkMessageContent, + isOneTalkRenderedCardContent, type OneTalkHttpConversation, type OneTalkCenterMessage, type OneTalkMessage, type OneTalkCenterMessageContent, + type OneTalkRenderedCardContent, } from "@trade-message-center/onetalk-contract"; import type { @@ -85,10 +88,73 @@ export const toHttpConversation = ( type OneTalkCenterMessageInput = Pick< OneTalkMessage, - "messageId" | "conversationId" | "senderId" | "participantIds" | "direction" | "sentAtMs" + | "messageId" + | "conversationId" + | "senderId" + | "participantIds" + | "direction" + | "sentAtMs" + | "content" >; -type OneTalkCenterMessageInputWithEffectiveContent = Omit & { - content: OneTalkCenterMessageContent; + +const supplementMessageContent = ( + content: OneTalkMessage["content"], + renderedCardContent: OneTalkRenderedCardContent | undefined, +): OneTalkCenterMessageContent => { + if (!isOneTalkMessageContent(content)) { + throw new Error("Invalid persisted OneTalk message content"); + } + if (renderedCardContent === undefined) return content; + if (!isOneTalkRenderedCardContent(renderedCardContent)) { + throw new Error("Invalid persisted OneTalk rendered-card supplement"); + } + if (renderedCardContent.kind === "rendered_inquiry") { + if (content.kind !== "inquiry") { + throw new Error("OneTalk rendered-card supplement kind does not match base message"); + } + return { + ...content, + product: renderedCardContent.product, + purchaseQuantity: renderedCardContent.purchaseQuantity, + requirementText: renderedCardContent.requirementText, + inquiryReference: renderedCardContent.inquiryReference, + actions: renderedCardContent.actions, + }; + } + if (renderedCardContent.kind === "rendered_product") { + if (content.kind !== "product") { + throw new Error("OneTalk rendered-card supplement kind does not match base message"); + } + if ( + content.sourceUrl !== renderedCardContent.product.sourceUrl || + content.productId !== renderedCardContent.product.productId + ) { + throw new Error( + "OneTalk rendered-product supplement identity does not match base message", + ); + } + return { + ...content, + title: renderedCardContent.product.title, + imageUrl: renderedCardContent.product.imageUrl, + priceDisplay: renderedCardContent.priceDisplay, + minimumOrder: renderedCardContent.minimumOrder, + serviceBadges: renderedCardContent.serviceBadges, + }; + } + if (content.kind !== "order") { + throw new Error("OneTalk rendered-card supplement kind does not match base message"); + } + return { + ...content, + title: renderedCardContent.title, + products: renderedCardContent.products, + productCount: renderedCardContent.productCount, + status: renderedCardContent.status, + payment: renderedCardContent.payment, + delivery: renderedCardContent.delivery, + action: renderedCardContent.action, + }; }; const projectMessageContent = ( @@ -110,13 +176,15 @@ const projectMessageContent = ( /** 从 normalized JSONB 事实和会话客户资料投影 Mind-facing 消息。 */ export const toOneTalkCenterMessage = ( - message: OneTalkCenterMessageInputWithEffectiveContent, + message: OneTalkCenterMessageInput, + renderedCardContent?: OneTalkRenderedCardContent, customerProfile?: Pick< OneTalkReadCustomerProfile, "name" | "avatarUrl" | "countryCode" | "companyName" >, ): OneTalkCenterMessage => { - if (!isOneTalkCenterMessageContent(message.content)) { + const content = supplementMessageContent(message.content, renderedCardContent); + if (!isOneTalkCenterMessageContent(content)) { throw new Error("Invalid persisted OneTalk message content"); } return { @@ -126,7 +194,7 @@ export const toOneTalkCenterMessage = ( participantIds: [...message.participantIds], direction: message.direction, sentAtMs: message.sentAtMs, - content: projectMessageContent(message.content, customerProfile), + content: projectMessageContent(content, customerProfile), }; }; @@ -136,8 +204,4 @@ export const projectCenterMessage = ( OneTalkReadCustomerProfile, "name" | "avatarUrl" | "countryCode" | "companyName" >, -): OneTalkCenterMessage => - toOneTalkCenterMessage( - { ...row, content: row.renderedCardContent ?? row.content }, - customerProfile, - ); +): OneTalkCenterMessage => toOneTalkCenterMessage(row, row.renderedCardContent, customerProfile); diff --git a/apps/server/src/onetalk/rendered-card-model.ts b/apps/server/src/onetalk/rendered-card-model.ts index eb4eb4a..678c81c 100644 --- a/apps/server/src/onetalk/rendered-card-model.ts +++ b/apps/server/src/onetalk/rendered-card-model.ts @@ -1,7 +1,7 @@ // 定义 OneTalk 渲染卡片补全的服务端领域边界 import type { - OneTalkCenterMessage, + OneTalkMessage, OneTalkRenderedCardContent, OneTalkRenderedCardObservation, } from "@trade-message-center/onetalk-contract"; @@ -10,18 +10,18 @@ import type { OneTalkCommitGuard } from "./model.ts"; export type OneTalkRenderedCardStoreResult = | { status: "accepted"; - content: OneTalkRenderedCardContent; - message: OneTalkCenterMessage | null; + renderedCardContent: OneTalkRenderedCardContent; + message: OneTalkMessage | null; } | { status: "duplicate"; - content: OneTalkRenderedCardContent; - message: OneTalkCenterMessage | null; + renderedCardContent: OneTalkRenderedCardContent; + message: OneTalkMessage | null; } | { status: "conflict"; - content: OneTalkRenderedCardContent; - message: OneTalkCenterMessage | null; + renderedCardContent: OneTalkRenderedCardContent; + message: OneTalkMessage | null; }; export type OneTalkRenderedCardRepository = { diff --git a/apps/server/src/onetalk/rendered-card-repository.ts b/apps/server/src/onetalk/rendered-card-repository.ts index 8b2c736..20161c9 100644 --- a/apps/server/src/onetalk/rendered-card-repository.ts +++ b/apps/server/src/onetalk/rendered-card-repository.ts @@ -3,7 +3,7 @@ import { and, eq, sql } from "drizzle-orm"; import { isSameOneTalkRenderedCardContent, - type OneTalkCenterMessage, + type OneTalkMessage, type OneTalkRenderedCardContent, } from "@trade-message-center/onetalk-contract"; import type { Database } from "../database/index.ts"; @@ -32,17 +32,17 @@ const baseMessageCondition = (input: { eq(onetalkMessage.conversationId, input.observation.conversationId), eq(onetalkMessage.messageId, input.observation.messageId), ); -const effectiveMessageFor = ( - row: typeof onetalkMessage.$inferSelect, - content: OneTalkRenderedCardContent, -): OneTalkCenterMessage => ({ +const baseMessageFor = (row: typeof onetalkMessage.$inferSelect): OneTalkMessage => ({ messageId: row.messageId, conversationId: row.conversationId, senderId: row.senderId, participantIds: [...row.participantIds], direction: row.direction, sentAtMs: row.sentAtMs, - content, + content: row.content, + readStatus: row.readStatus, + messageStatus: row.messageStatus, + unreadCount: row.unreadCount, }); const existingBaseMessageFor = async ( transaction: Parameters[0]>[0], @@ -87,10 +87,8 @@ const store = async ( input.commitGuard?.assertValid(); return { status: "accepted", - content: input.observation.content, - message: baseMessage - ? effectiveMessageFor(baseMessage, input.observation.content) - : null, + renderedCardContent: input.observation.content, + message: baseMessage ? baseMessageFor(baseMessage) : null, }; } const saved = record.renderedCardContent as OneTalkRenderedCardContent; @@ -104,8 +102,8 @@ const store = async ( input.commitGuard?.assertValid(); return { status: "duplicate", - content: saved, - message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null, + renderedCardContent: saved, + message: baseMessage ? baseMessageFor(baseMessage) : null, }; } await transaction @@ -121,8 +119,8 @@ const store = async ( input.commitGuard?.assertValid(); return { status: "conflict", - content: saved, - message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null, + renderedCardContent: saved, + message: baseMessage ? baseMessageFor(baseMessage) : null, }; }); diff --git a/apps/server/src/onetalk/repository.ts b/apps/server/src/onetalk/repository.ts index 44f556c..743e724 100644 --- a/apps/server/src/onetalk/repository.ts +++ b/apps/server/src/onetalk/repository.ts @@ -606,7 +606,17 @@ const guardedObserveMessages = async ( ) : aggregate?.lastContactTimeMs, }); - results.push({ status: "accepted", message: toMessage(insertedRow) }); + const renderedCardContent = await renderedCardContentFor( + transaction, + context, + message, + ); + guard.assertValid(); + results.push({ + status: "accepted", + message: toMessage(insertedRow), + ...(renderedCardContent === null ? {} : { renderedCardContent }), + }); continue; } diff --git a/apps/server/src/websocket/plugin/flows/rendered-card-flow.ts b/apps/server/src/websocket/plugin/flows/rendered-card-flow.ts index 50e28a1..94b8901 100644 --- a/apps/server/src/websocket/plugin/flows/rendered-card-flow.ts +++ b/apps/server/src/websocket/plugin/flows/rendered-card-flow.ts @@ -6,6 +6,7 @@ import { type OneTalkErrorCode, type OneTalkRenderedCardObservedFrame, } from "@trade-message-center/onetalk-contract"; +import { toOneTalkCenterMessage } from "../../../onetalk/read-projection.ts"; import type { OneTalkRenderedCardService, OneTalkSourceContext } from "../../../onetalk/index.ts"; import type { OneTalkAuthenticatedClientFrame } from "../../authenticated-router.ts"; import type { OneTalkConnectionRegistry, OneTalkRegisteredConnection } from "../../registry.ts"; @@ -95,7 +96,7 @@ export const createOneTalkRenderedCardFlow = (options: { if (result.status !== "accepted" || result.message === null) return; guard.assertValid(); await options.registry.publishMessageUpdated({ - message: result.message, + message: toOneTalkCenterMessage(result.message, result.renderedCardContent), requestId: frame.requestId, scope: scopeFor(context), policyEpoch, diff --git a/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts b/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts index 3e3e35a..884ad10 100644 --- a/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts +++ b/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts @@ -53,10 +53,7 @@ export const createOneTalkSendConfirmationFlow = ( if (result.status === "accepted") { pending.commitGuard.assertValid(); await options.registry.publishMessageCreated({ - message: toOneTalkCenterMessage({ - ...result.message, - content: result.renderedCardContent ?? result.message.content, - }), + message: toOneTalkCenterMessage(result.message, result.renderedCardContent), requestId: pending.frame.requestId, scope: pending.mind.mindScope, policyEpoch: pending.policyEpoch, diff --git a/apps/server/src/websocket/plugin/flows/sync-flows.ts b/apps/server/src/websocket/plugin/flows/sync-flows.ts index d9c60b2..a5a79e1 100644 --- a/apps/server/src/websocket/plugin/flows/sync-flows.ts +++ b/apps/server/src/websocket/plugin/flows/sync-flows.ts @@ -393,10 +393,10 @@ export const createOneTalkSyncFlows = (options: OneTalkSyncFlowsOptions): OneTal queued.guard.assertValid(); const scope = scopeForContext(request.context); await options.publishMessageCreated({ - message: toOneTalkCenterMessage({ - ...result.message, - content: result.renderedCardContent ?? result.message.content, - }), + message: toOneTalkCenterMessage( + result.message, + result.renderedCardContent, + ), requestId: queued.frame.requestId, scope, policyEpoch: queued.policyEpoch, diff --git a/apps/server/test/onetalk-domain.test.ts b/apps/server/test/onetalk-domain.test.ts index 733a073..6e7f372 100644 --- a/apps/server/test/onetalk-domain.test.ts +++ b/apps/server/test/onetalk-domain.test.ts @@ -9,11 +9,17 @@ import type { } from "@trade-message-center/onetalk-contract"; import { + createOneTalkRepository, createOneTalkService, type OneTalkConversationState, type OneTalkRepository, type OneTalkSourceContext, } from "../src/onetalk/index.ts"; +import { + onetalkConversation, + onetalkMessage, + onetalkRenderedCardContent, +} from "../src/database/schema/onetalk.ts"; const context: OneTalkSourceContext = { binding: "binding-1", @@ -412,6 +418,112 @@ test("persists an approved structured order without server-side card parsing", a assert.equal(JSON.stringify(result).includes("sign"), false); }); +test("returns an existing rendered-card supplement for a batched live observation", async () => { + const observedMessage = message({ + content: { + version: 1, + kind: "order", + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 1, + orderAmountCurrency: "USD", + paymentAmount: 1, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + }, + }); + const renderedCardContent = { + version: 1 as const, + kind: "rendered_order" as const, + title: "Late order", + products: [], + productCount: 0, + status: { code: null, text: "Paid" }, + payment: { totalDisplay: "$1", discountDisplay: null }, + delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null }, + action: { label: null, status: null }, + }; + const baseRow = { + channelAccountId: context.channelAccountId, + conversationId: "conversation-1", + messageId: "message-1", + senderId: "sender-1", + binding: context.binding, + mindUserId: context.mindUserId, + workspaceId: context.workspaceId, + deviceId: context.deviceId, + direction: "received" as const, + observationType: "new" as const, + sentAtMs: 1_700_000_000_000, + content: observedMessage.content, + participantIds: ["sender-1", "login-user-1"], + readStatus: 1, + messageStatus: 2, + unreadCount: 0, + firstObservedAt: new Date("2026-09-15T00:00:00.000Z"), + lastObservedAt: new Date("2026-09-15T00:00:00.000Z"), + }; + const transaction = { + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: () => { + const rows = + table === onetalkConversation + ? [ + { + conversationId: "conversation-1", + conversationKind: "direct" as const, + historyGeneration: "initial", + }, + ] + : table === onetalkRenderedCardContent + ? [{ content: renderedCardContent }] + : []; + const promise = Promise.resolve(rows); + return Object.assign(promise, { for: () => promise }); + }, + }), + }), + }), + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ returning: async () => [baseRow] }), + }), + }), + update: () => ({ set: () => ({ where: async () => [] }) }), + }; + const database = { + transaction: async (run: (value: typeof transaction) => Promise) => + run(transaction), + }; + const repository = createOneTalkRepository(database as never); + + const results = await repository.guardedObserveMessages( + context, + [ + { + historyGeneration: "initial", + observationSource: "live", + normalization: { ok: true, message: observedMessage }, + }, + ], + { assertValid: () => undefined }, + ); + + assert.deepEqual(results, [ + { + status: "accepted", + message: observedMessage, + renderedCardContent, + }, + ]); +}); + test("advances only valid shared anchors and preserves incomplete outcomes", async () => { const emptyHarness = createRepositoryHarness(); const emptyService = createOneTalkService(emptyHarness.repository); diff --git a/apps/server/test/onetalk-read-domain.test.ts b/apps/server/test/onetalk-read-domain.test.ts index bec5eb8..040b572 100644 --- a/apps/server/test/onetalk-read-domain.test.ts +++ b/apps/server/test/onetalk-read-domain.test.ts @@ -3,7 +3,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { OneTalkMessage } from "@trade-message-center/onetalk-contract"; +import type { + OneTalkMessage, + OneTalkRenderedCardContent, +} from "@trade-message-center/onetalk-contract"; import { createOneTalkReadService, @@ -575,6 +578,124 @@ test("projects one persisted product fact identically and rejects raw-query JSON ); }); +test("projects inquiry, product, and order supplements without changing their base kinds", () => { + const product = { + version: 1 as const, + kind: "product" as const, + sourceUrl: + "https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html", + productId: "1601456609478", + }; + const cases: Array<{ + content: OneTalkMessage["content"]; + renderedCardContent: OneTalkRenderedCardContent; + }> = [ + { + content: { version: 1 as const, kind: "inquiry" as const }, + renderedCardContent: { + version: 1 as const, + kind: "rendered_inquiry" as const, + product: { imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }, + purchaseQuantity: { value: "100", unit: "pieces" }, + requirementText: "Need delivery this week", + inquiryReference: "inquiry-1", + actions: [{ label: "Reply", available: true }], + }, + }, + { + content: product, + renderedCardContent: { + version: 1 as const, + kind: "rendered_product" as const, + product: { + imageUrl: "https://img.alicdn.com/item.jpg", + title: "Widget", + sourceUrl: product.sourceUrl, + productId: product.productId, + }, + priceDisplay: "US $10.00", + minimumOrder: { value: "1", unit: "piece" }, + serviceBadges: ["Fast dispatch"], + }, + }, + { + content: { + version: 1 as const, + kind: "order" as const, + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 10, + orderAmountCurrency: "USD", + paymentAmount: 10, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + }, + renderedCardContent: { + version: 1 as const, + kind: "rendered_order" as const, + title: "Order summary", + products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }], + productCount: 1, + status: { code: "paid", text: "Paid" }, + payment: { totalDisplay: "US $10.00", discountDisplay: null }, + delivery: { + shippingAddress: "1 Market Street", + methodLabel: null, + dateLabel: null, + }, + action: { label: "View order", status: "available" }, + }, + }, + ]; + + for (const entry of cases) { + const persisted = message({ + content: entry.content, + renderedCardContent: entry.renderedCardContent, + }); + const history = projectCenterMessage(persisted); + const live = toOneTalkCenterMessage(persisted, entry.renderedCardContent); + + assert.deepEqual(history, live); + assert.equal(history.content.kind, entry.content.kind); + assert.equal(history.content.kind.startsWith("rendered_"), false); + } +}); + +test("rejects a rendered-card supplement with a different base kind or product identity", () => { + const product = { + version: 1 as const, + kind: "product" as const, + sourceUrl: + "https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609479.html", + productId: "1601456609479", + }; + const renderedProduct = { + version: 1 as const, + kind: "rendered_product" as const, + product: { + imageUrl: "https://img.alicdn.com/item.jpg", + title: "Widget", + sourceUrl: + "https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html", + productId: "1601456609478", + }, + priceDisplay: "US $10.00", + minimumOrder: { value: "1", unit: "piece" }, + serviceBadges: ["Fast dispatch"], + }; + + assert.throws( + () => toOneTalkCenterMessage(message({ content: product }), renderedProduct), + /identity does not match/, + ); + assert.throws(() => toOneTalkCenterMessage(message(), renderedProduct), /kind does not match/); +}); + test("rejects malformed rendered-card JSONB at the read boundary", () => { assert.throws( () => @@ -585,7 +706,7 @@ test("rejects malformed rendered-card JSONB at the read boundary", () => { kind: "rendered_order", } as never, }), - /Invalid persisted OneTalk message content/, + /Invalid persisted OneTalk rendered-card supplement/, ); }); diff --git a/apps/server/test/onetalk-rendered-card-flow.test.ts b/apps/server/test/onetalk-rendered-card-flow.test.ts index cbb0d64..06cce6a 100644 --- a/apps/server/test/onetalk-rendered-card-flow.test.ts +++ b/apps/server/test/onetalk-rendered-card-flow.test.ts @@ -46,7 +46,24 @@ const message = { participantIds: ["sender-1", "receiver-1"], direction: "received" as const, sentAtMs: 99, - content, + readStatus: 1, + messageStatus: 2, + unreadCount: 0, + content: { + version: 1 as const, + kind: "order" as const, + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 1, + orderAmountCurrency: "USD", + paymentAmount: 1, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + }, }; test("ACKs an independently committed card when its base message has not arrived", async () => { @@ -55,7 +72,11 @@ test("ACKs an independently committed card when its base message has not arrived const canonical = {} as never; const flow = createOneTalkRenderedCardFlow({ service: { - ingest: async () => ({ status: "accepted" as const, content, message: null }), + ingest: async () => ({ + status: "accepted" as const, + renderedCardContent: content, + message: null, + }), }, registry: { getCanonicalConnection: () => canonical, @@ -97,7 +118,13 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) { const published: unknown[] = []; const flow = createOneTalkRenderedCardFlow({ service: { - ingest: async () => ({ status, content, message }), + ingest: async () => { + if (status === "accepted") + return { status, renderedCardContent: content, message }; + if (status === "duplicate") + return { status, renderedCardContent: content, message }; + return { status, renderedCardContent: content, message }; + }, }, registry: { getCanonicalConnection: () => canonical, @@ -137,7 +164,24 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) { assert.equal(published.length, status === "accepted" ? 1 : 0); if (status === "accepted") { assert.deepEqual(published[0], { - message, + message: { + messageId: message.messageId, + conversationId: message.conversationId, + senderId: message.senderId, + participantIds: message.participantIds, + direction: message.direction, + sentAtMs: message.sentAtMs, + content: { + ...message.content, + title: content.title, + products: content.products, + productCount: content.productCount, + status: content.status, + payment: content.payment, + delivery: content.delivery, + action: content.action, + }, + }, requestId: "request-1", scope: { mindUserId: "mind-user-1", diff --git a/apps/server/test/onetalk-rendered-card-repository.test.ts b/apps/server/test/onetalk-rendered-card-repository.test.ts index 79d8910..148fc52 100644 --- a/apps/server/test/onetalk-rendered-card-repository.test.ts +++ b/apps/server/test/onetalk-rendered-card-repository.test.ts @@ -55,7 +55,7 @@ test("stores a rendered-card supplement when its base message is absent", async receivedAt: new Date("2026-09-15T00:00:00.000Z"), }); - assert.deepEqual(result, { status: "accepted", content, message: null }); + assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null }); assert.deepEqual(inserted, [ { channelAccountId: "account-1", diff --git a/apps/server/test/onetalk-websocket.test.ts b/apps/server/test/onetalk-websocket.test.ts index 2c7a87c..71641bd 100644 --- a/apps/server/test/onetalk-websocket.test.ts +++ b/apps/server/test/onetalk-websocket.test.ts @@ -110,6 +110,37 @@ const message = (messageId = "message-1"): OneTalkMessage => { }; }; +const orderMessage = (messageId = "message-1"): OneTalkMessage => ({ + ...message(messageId), + content: { + version: 1, + kind: "order", + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 1, + orderAmountCurrency: "USD", + paymentAmount: 1, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + }, +}); + +const renderedOrderSupplement = { + version: 1 as const, + kind: "rendered_order" as const, + title: "Confirmed order", + products: [], + productCount: 0, + status: { code: null, text: "Paid" }, + payment: { totalDisplay: "$1", discountDisplay: null }, + delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null }, + action: { label: null, status: null }, +}; + const centerMessage = (value: OneTalkMessage): OneTalkCenterMessage => ({ messageId: value.messageId, conversationId: value.conversationId, @@ -424,9 +455,10 @@ const closeApp = async ( }; test("executes a wire assertion for every declared OneTalk client frame", async () => { - const service = createService(async (_context, _source, observedMessage) => ({ + const service = createService(async (_context, source, observedMessage) => ({ status: "accepted" as const, message: observedMessage, + ...(source === "send_confirmation" ? { renderedCardContent: renderedOrderSupplement } : {}), })); const renderedCardInputs: unknown[] = []; const app = createApp(testConfig, { @@ -456,11 +488,8 @@ test("executes a wire assertion for every declared OneTalk client frame", async renderedCardInputs.push(input); return { status: "accepted" as const, - content: input.observation.content, - message: { - ...centerMessage(message(input.observation.messageId)), - content: input.observation.content, - }, + renderedCardContent: input.observation.content, + message: orderMessage(input.observation.messageId), }; }, }, @@ -850,14 +879,39 @@ test("executes a wire assertion for every declared OneTalk client frame", async }); assert.deepEqual(renderedCardFrames[1]?.payload, { message: { - ...centerMessage(message("matrix-rendered-card-message")), - content: renderedCardContent, + ...centerMessage(orderMessage("matrix-rendered-card-message")), + content: { + version: 1, + kind: "order", + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 1, + orderAmountCurrency: "USD", + paymentAmount: 1, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + title: "Matrix order", + products: [], + productCount: 0, + status: { code: null, text: "Paid" }, + payment: { totalDisplay: "$1", discountDisplay: null }, + delivery: { + shippingAddress: "Matrix address", + methodLabel: null, + dateLabel: null, + }, + action: { label: null, status: null }, + }, }, }); assert.equal(renderedCardInputs.length, 1); recordCoverage("rendered.card.observed", renderedCardFrames); - const sentMessage = { ...message("matrix-sent"), direction: "sent" as const }; + const sentMessage = { ...orderMessage("matrix-sent"), direction: "sent" as const }; const confirmationFrames = nextMessages(mind, 3); plugin.send( JSON.stringify({ @@ -879,11 +933,30 @@ test("executes a wire assertion for every declared OneTalk client frame", async CLIENT_FRAME_COVERAGE_BASELINES["send.confirmation"], ); const sendResult = sentConfirmationFrames.find((frame) => frame.type === "send.result"); + const messageCreated = sentConfirmationFrames.find( + (frame) => frame.type === "message.created", + ); if (!sendResult) assert.fail("send confirmation must produce a send.result"); + if (!messageCreated) assert.fail("send confirmation must produce a message.created frame"); assert.deepEqual(sendResult.payload, { status: "confirmed_sent", message: centerMessage(sentMessage), }); + assert.deepEqual(messageCreated.payload, { + message: { + ...centerMessage(sentMessage), + content: { + ...sentMessage.content, + title: renderedOrderSupplement.title, + products: renderedOrderSupplement.products, + productCount: renderedOrderSupplement.productCount, + status: renderedOrderSupplement.status, + payment: renderedOrderSupplement.payment, + delivery: renderedOrderSupplement.delivery, + action: renderedOrderSupplement.action, + }, + }, + }); const decodedSendResult = decodeOneTalkFrame(sendResult); assert.equal(decodedSendResult.ok, true); recordCoverage("send.confirmation", sentConfirmationFrames); @@ -1720,13 +1793,36 @@ test("publishes an existing rendered-card supplement when its base live message const acknowledgement = nextMessage(plugin); const created = nextFrame(mind, (value) => value.type === "message.created"); - plugin.send(JSON.stringify(observedFrame("late-base-message", message("late-message")))); + plugin.send( + JSON.stringify(observedFrame("late-base-message", orderMessage("late-message"))), + ); assert.equal((await acknowledgement).type, "message.ack"); assert.deepEqual((await created).payload, { message: { - ...centerMessage(message("late-message")), - content: renderedCardContent, + ...centerMessage(orderMessage("late-message")), + content: { + version: 1, + kind: "order", + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 1, + orderAmountCurrency: "USD", + paymentAmount: 1, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + title: "Late order", + products: [], + productCount: 0, + status: { code: null, text: "Paid" }, + payment: { totalDisplay: "$1", discountDisplay: null }, + delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null }, + action: { label: null, status: null }, + }, }, }); } finally { diff --git a/packages/onetalk-contract/src/content.ts b/packages/onetalk-contract/src/content.ts index 823acbd..3802d85 100644 --- a/packages/onetalk-contract/src/content.ts +++ b/packages/onetalk-contract/src/content.ts @@ -3,7 +3,25 @@ import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts"; import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts"; import { isOneTalkProductReference } from "./product-url.ts"; -import { isOneTalkRenderedCardContent, type OneTalkRenderedCardContent } from "./rendered-cards.ts"; +import { + isOneTalkRenderedCardAction, + isOneTalkRenderedCardDelivery, + isOneTalkRenderedCardIdentifier, + isOneTalkRenderedCardImage, + isOneTalkRenderedCardImageUrl, + isOneTalkRenderedCardOrderAction, + isOneTalkRenderedCardOrderStatus, + isOneTalkRenderedCardPayment, + isOneTalkRenderedCardQuantity, + isOneTalkRenderedCardText, + type OneTalkRenderedCardAction, + type OneTalkRenderedCardDelivery, + type OneTalkRenderedCardImage, + type OneTalkRenderedCardOrderAction, + type OneTalkRenderedCardOrderStatus, + type OneTalkRenderedCardPayment, + type OneTalkRenderedCardQuantity, +} from "./rendered-cards.ts"; export const ONETALK_CONTENT_VERSION = 1 as const; export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3; @@ -103,6 +121,35 @@ export type OneTalkProductContent = { productId: string; }; +/** Mind 读取端可选公开的询盘卡展示补全;基础 kind 永远保持 inquiry。 */ +export type OneTalkCenterInquiryContent = OneTalkInquiryContent & { + product?: OneTalkRenderedCardImage; + purchaseQuantity?: OneTalkRenderedCardQuantity; + requirementText?: string; + inquiryReference?: string; + actions?: OneTalkRenderedCardAction[]; +}; + +/** Mind 读取端可选公开的商品卡展示补全;基础身份不由补全覆盖。 */ +export type OneTalkCenterProductContent = OneTalkProductContent & { + title?: string; + imageUrl?: string | null; + priceDisplay?: string; + minimumOrder?: OneTalkRenderedCardQuantity; + serviceBadges?: string[]; +}; + +/** Mind 读取端可选公开的订单卡展示补全;原始订单字段保持完整。 */ +export type OneTalkCenterOrderContent = OneTalkOrderContent & { + title?: string; + products?: OneTalkRenderedCardImage[]; + productCount?: number; + status?: OneTalkRenderedCardOrderStatus; + payment?: OneTalkRenderedCardPayment; + delivery?: OneTalkRenderedCardDelivery; + action?: OneTalkRenderedCardOrderAction; +}; + export type OneTalkMessageContent = | OneTalkTextContent | OneTalkImageContent @@ -113,9 +160,14 @@ export type OneTalkMessageContent = | OneTalkProductContent; export type OneTalkCenterMessageContent = - | OneTalkMessageContent - | OneTalkBusinessCardViewContent - | OneTalkRenderedCardContent; + | Exclude< + OneTalkMessageContent, + OneTalkInquiryContent | OneTalkOrderContent | OneTalkProductContent + > + | OneTalkCenterInquiryContent + | OneTalkCenterOrderContent + | OneTalkCenterProductContent + | OneTalkBusinessCardViewContent; /** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */ export type CenterConversation = { @@ -230,6 +282,15 @@ const hasExactKeys = (value: Record, keys: readonly string[]): ); }; +const hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).every((key) => keys.includes(key)); + +const withoutKeys = ( + value: Record, + keys: readonly string[], +): Record => + Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); + const isNonBlankString = (value: unknown, maximumLength: number): value is string => { return ( typeof value === "string" && @@ -501,6 +562,82 @@ const isOneTalkProductContent = ( return true; }; +const CENTER_INQUIRY_SUPPLEMENT_KEYS = [ + "product", + "purchaseQuantity", + "requirementText", + "inquiryReference", + "actions", +] as const; +const CENTER_PRODUCT_SUPPLEMENT_KEYS = [ + "title", + "imageUrl", + "priceDisplay", + "minimumOrder", + "serviceBadges", +] as const; +const CENTER_ORDER_SUPPLEMENT_KEYS = [ + "title", + "products", + "productCount", + "status", + "payment", + "delivery", + "action", +] as const; + +const isOptional = ( + value: unknown, + guard: (candidate: unknown) => candidate is Value, +): boolean => value === undefined || guard(value); + +const isOneTalkCenterInquiryContent = ( + value: Record, +): value is OneTalkCenterInquiryContent => + hasOnlyKeys(value, [...CONTENT_KEYS.inquiry, ...CENTER_INQUIRY_SUPPLEMENT_KEYS]) && + value.version === ONETALK_CONTENT_VERSION && + value.kind === "inquiry" && + isOptional(value.product, isOneTalkRenderedCardImage) && + isOptional(value.purchaseQuantity, isOneTalkRenderedCardQuantity) && + isOptional(value.requirementText, isOneTalkRenderedCardText) && + isOptional(value.inquiryReference, isOneTalkRenderedCardIdentifier) && + (value.actions === undefined || + (Array.isArray(value.actions) && + value.actions.length <= MAX_ORDER_ACTIONS && + value.actions.every(isOneTalkRenderedCardAction))); + +const isOneTalkCenterProductContent = ( + value: Record, +): value is OneTalkCenterProductContent => + hasOnlyKeys(value, [...CONTENT_KEYS.product, ...CENTER_PRODUCT_SUPPLEMENT_KEYS]) && + isOneTalkProductContent(withoutKeys(value, CENTER_PRODUCT_SUPPLEMENT_KEYS)) && + isOptional(value.title, isOneTalkRenderedCardText) && + (value.imageUrl === undefined || + value.imageUrl === null || + isOneTalkRenderedCardImageUrl(value.imageUrl)) && + isOptional(value.priceDisplay, isOneTalkRenderedCardText) && + isOptional(value.minimumOrder, isOneTalkRenderedCardQuantity) && + (value.serviceBadges === undefined || + (Array.isArray(value.serviceBadges) && + value.serviceBadges.length <= MAX_ORDER_ACTIONS && + value.serviceBadges.every(isOneTalkRenderedCardText))); + +const isOneTalkCenterOrderContent = ( + value: Record, +): value is OneTalkCenterOrderContent => + hasOnlyKeys(value, [...CONTENT_KEYS.order, ...CENTER_ORDER_SUPPLEMENT_KEYS]) && + isOneTalkOrderContent(withoutKeys(value, CENTER_ORDER_SUPPLEMENT_KEYS)) && + isOptional(value.title, isOneTalkRenderedCardText) && + (value.products === undefined || + (Array.isArray(value.products) && + value.products.length <= MAX_ORDER_ACTIONS && + value.products.every(isOneTalkRenderedCardImage))) && + (value.productCount === undefined || isNonNegativeSafeInteger(value.productCount)) && + isOptional(value.status, isOneTalkRenderedCardOrderStatus) && + isOptional(value.payment, isOneTalkRenderedCardPayment) && + isOptional(value.delivery, isOneTalkRenderedCardDelivery) && + isOptional(value.action, isOneTalkRenderedCardOrderAction); + /** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */ export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => { if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false }; @@ -539,8 +676,12 @@ export const isOneTalkCenterMessageContent = ( value: unknown, ): value is OneTalkCenterMessageContent => { if (isOneTalkMessageContent(value)) return true; - if (isOneTalkRenderedCardContent(value)) return true; - return isPlainRecord(value) && isOneTalkBusinessCardViewContent(value); + if (!isPlainRecord(value)) return false; + if (isOneTalkBusinessCardViewContent(value)) return true; + if (value.kind === "inquiry") return isOneTalkCenterInquiryContent(value); + if (value.kind === "product") return isOneTalkCenterProductContent(value); + if (value.kind === "order") return isOneTalkCenterOrderContent(value); + return false; }; /** 判断值是否为 exact-shape 的 Mind 公开会话读取模型。 */ diff --git a/packages/onetalk-contract/src/index.ts b/packages/onetalk-contract/src/index.ts index 0b70bb3..fa83ea5 100644 --- a/packages/onetalk-contract/src/index.ts +++ b/packages/onetalk-contract/src/index.ts @@ -130,7 +130,15 @@ export { createOneTalkRenderedCardContentFingerprint, createOneTalkRenderedCardObservedFrame, isOneTalkRenderedCardContent, + isOneTalkRenderedCardImage, isOneTalkRenderedCardImageUrl, + isOneTalkRenderedCardIdentifier, + isOneTalkRenderedCardQuantity, + isOneTalkRenderedCardAction, + isOneTalkRenderedCardOrderStatus, + isOneTalkRenderedCardPayment, + isOneTalkRenderedCardDelivery, + isOneTalkRenderedCardOrderAction, isOneTalkRenderedCardText, isSameOneTalkRenderedCardContent, isValidOneTalkRenderedCardPayload, @@ -141,6 +149,10 @@ export type { OneTalkRenderedCardAckFrame, OneTalkRenderedCardContent, OneTalkRenderedCardImage, + OneTalkRenderedCardOrderStatus, + OneTalkRenderedCardPayment, + OneTalkRenderedCardDelivery, + OneTalkRenderedCardOrderAction, OneTalkRenderedCardObservation, OneTalkRenderedCardObservedFrame, OneTalkRenderedCardQuantity, diff --git a/packages/onetalk-contract/src/rendered-cards.ts b/packages/onetalk-contract/src/rendered-cards.ts index 43ad507..e26e25a 100644 --- a/packages/onetalk-contract/src/rendered-cards.ts +++ b/packages/onetalk-contract/src/rendered-cards.ts @@ -18,6 +18,14 @@ export type OneTalkRenderedCardAckStatus = (typeof ONETALK_RENDERED_CARD_ACK_STA export type OneTalkRenderedCardImage = { imageUrl: string | null; title: string }; export type OneTalkRenderedCardQuantity = { value: string; unit: string }; export type OneTalkRenderedCardAction = { label: string; available: boolean }; +export type OneTalkRenderedCardOrderStatus = { code: string | null; text: string }; +export type OneTalkRenderedCardPayment = { totalDisplay: string; discountDisplay: string | null }; +export type OneTalkRenderedCardDelivery = { + shippingAddress: string; + methodLabel: string | null; + dateLabel: string | null; +}; +export type OneTalkRenderedCardOrderAction = { label: string | null; status: string | null }; export type OneTalkRenderedInquiryContent = { version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION; kind: "rendered_inquiry"; @@ -41,10 +49,10 @@ export type OneTalkRenderedOrderContent = { title: string; products: OneTalkRenderedCardImage[]; productCount: number; - status: { code: string | null; text: string }; - payment: { totalDisplay: string; discountDisplay: string | null }; - delivery: { shippingAddress: string; methodLabel: string | null; dateLabel: string | null }; - action: { label: string | null; status: string | null }; + status: OneTalkRenderedCardOrderStatus; + payment: OneTalkRenderedCardPayment; + delivery: OneTalkRenderedCardDelivery; + action: OneTalkRenderedCardOrderAction; }; export type OneTalkRenderedCardContent = | OneTalkRenderedInquiryContent @@ -101,7 +109,8 @@ const isRenderedCardText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is export const isOneTalkRenderedCardText = (value: unknown): value is string => isRenderedCardText(value); -const isIdentifier = (value: unknown): value is string => +/** 验证 rendered-card 中的受限稳定标识符。 */ +export const isOneTalkRenderedCardIdentifier = (value: unknown): value is string => isRenderedCardText(value, MAX_IDENTIFIER_LENGTH); const isSafeNonNegativeInteger = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; @@ -127,22 +136,62 @@ export const isOneTalkRenderedCardImageUrl = (value: unknown): value is string = const isNullableImageUrl = (value: unknown): value is string | null => value === null || isOneTalkRenderedCardImageUrl(value); -const isCardImage = (value: unknown): value is OneTalkRenderedCardImage => +/** 验证可安全公开的 rendered-card 图片展示值。 */ +export const isOneTalkRenderedCardImage = (value: unknown): value is OneTalkRenderedCardImage => isPlainRecord(value) && hasExactKeys(value, ["imageUrl", "title"]) && isNullableImageUrl(value.imageUrl) && isOneTalkRenderedCardText(value.title); -const isQuantity = (value: unknown): value is OneTalkRenderedCardQuantity => +/** 验证可安全公开的 rendered-card 数量展示值。 */ +export const isOneTalkRenderedCardQuantity = ( + value: unknown, +): value is OneTalkRenderedCardQuantity => isPlainRecord(value) && hasExactKeys(value, ["value", "unit"]) && isOneTalkRenderedCardText(value.value) && isOneTalkRenderedCardText(value.unit); -const isAction = (value: unknown): value is OneTalkRenderedCardAction => +/** 验证可安全公开的 rendered-card 操作展示值。 */ +export const isOneTalkRenderedCardAction = (value: unknown): value is OneTalkRenderedCardAction => isPlainRecord(value) && hasExactKeys(value, ["label", "available"]) && isOneTalkRenderedCardText(value.label) && typeof value.available === "boolean"; +/** 验证可安全公开的 rendered-card 订单状态展示值。 */ +export const isOneTalkRenderedCardOrderStatus = ( + value: unknown, +): value is OneTalkRenderedCardOrderStatus => + isPlainRecord(value) && + hasExactKeys(value, ["code", "text"]) && + (value.code === null || isOneTalkRenderedCardIdentifier(value.code)) && + isOneTalkRenderedCardText(value.text); + +/** 验证可安全公开的 rendered-card 支付展示值。 */ +export const isOneTalkRenderedCardPayment = (value: unknown): value is OneTalkRenderedCardPayment => + isPlainRecord(value) && + hasExactKeys(value, ["totalDisplay", "discountDisplay"]) && + isOneTalkRenderedCardText(value.totalDisplay) && + (value.discountDisplay === null || isOneTalkRenderedCardText(value.discountDisplay)); + +/** 验证可安全公开的 rendered-card 配送展示值。 */ +export const isOneTalkRenderedCardDelivery = ( + value: unknown, +): value is OneTalkRenderedCardDelivery => + isPlainRecord(value) && + hasExactKeys(value, ["shippingAddress", "methodLabel", "dateLabel"]) && + isOneTalkRenderedCardText(value.shippingAddress) && + (value.methodLabel === null || isOneTalkRenderedCardText(value.methodLabel)) && + (value.dateLabel === null || isOneTalkRenderedCardText(value.dateLabel)); + +/** 验证可安全公开的 rendered-card 订单动作展示值。 */ +export const isOneTalkRenderedCardOrderAction = ( + value: unknown, +): value is OneTalkRenderedCardOrderAction => + isPlainRecord(value) && + hasExactKeys(value, ["label", "status"]) && + (value.label === null || isOneTalkRenderedCardText(value.label)) && + (value.status === null || isOneTalkRenderedCardText(value.status)); + const canonicalize = (value: unknown): string => { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; @@ -193,13 +242,13 @@ export const isOneTalkRenderedCardContent = ( "inquiryReference", "actions", ]) && - isCardImage(value.product) && - isQuantity(value.purchaseQuantity) && + isOneTalkRenderedCardImage(value.product) && + isOneTalkRenderedCardQuantity(value.purchaseQuantity) && isOneTalkRenderedCardText(value.requirementText) && - isIdentifier(value.inquiryReference) && + isOneTalkRenderedCardIdentifier(value.inquiryReference) && Array.isArray(value.actions) && value.actions.length <= MAX_ITEMS && - value.actions.every(isAction) + value.actions.every(isOneTalkRenderedCardAction) ); } if (value.kind === "rendered_product") { @@ -222,7 +271,7 @@ export const isOneTalkRenderedCardContent = ( productId: product.productId, }) && isOneTalkRenderedCardText(value.priceDisplay) && - isQuantity(value.minimumOrder) && + isOneTalkRenderedCardQuantity(value.minimumOrder) && Array.isArray(value.serviceBadges) && value.serviceBadges.length <= MAX_ITEMS && value.serviceBadges.every((badge) => isOneTalkRenderedCardText(badge)) @@ -248,26 +297,12 @@ export const isOneTalkRenderedCardContent = ( isOneTalkRenderedCardText(value.title) && Array.isArray(value.products) && value.products.length <= MAX_ITEMS && - value.products.every(isCardImage) && + value.products.every(isOneTalkRenderedCardImage) && isSafeNonNegativeInteger(value.productCount) && - isPlainRecord(status) && - hasExactKeys(status, ["code", "text"]) && - (status.code === null || isIdentifier(status.code)) && - isOneTalkRenderedCardText(status.text) && - isPlainRecord(payment) && - hasExactKeys(payment, ["totalDisplay", "discountDisplay"]) && - isOneTalkRenderedCardText(payment.totalDisplay) && - (payment.discountDisplay === null || - isOneTalkRenderedCardText(payment.discountDisplay)) && - isPlainRecord(delivery) && - hasExactKeys(delivery, ["shippingAddress", "methodLabel", "dateLabel"]) && - isOneTalkRenderedCardText(delivery.shippingAddress) && - (delivery.methodLabel === null || isOneTalkRenderedCardText(delivery.methodLabel)) && - (delivery.dateLabel === null || isOneTalkRenderedCardText(delivery.dateLabel)) && - isPlainRecord(action) && - hasExactKeys(action, ["label", "status"]) && - (action.label === null || isOneTalkRenderedCardText(action.label)) && - (action.status === null || isOneTalkRenderedCardText(action.status)) + isOneTalkRenderedCardOrderStatus(status) && + isOneTalkRenderedCardPayment(payment) && + isOneTalkRenderedCardDelivery(delivery) && + isOneTalkRenderedCardOrderAction(action) ); } return false; @@ -282,10 +317,10 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation "contentFingerprint", "observedAtMs", ]) && - isIdentifier(value.conversationId) && - isIdentifier(value.messageId) && + isOneTalkRenderedCardIdentifier(value.conversationId) && + isOneTalkRenderedCardIdentifier(value.messageId) && isOneTalkRenderedCardContent(value.content) && - isIdentifier(value.contentFingerprint) && + isOneTalkRenderedCardIdentifier(value.contentFingerprint) && isSafeNonNegativeInteger(value.observedAtMs) && value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content); @@ -303,9 +338,9 @@ export const isValidOneTalkRenderedCardPayload = (type: string, value: unknown): "status", ...(value.rejectionCode === undefined ? [] : ["rejectionCode"]), ]) && - isIdentifier(value.conversationId) && - isIdentifier(value.messageId) && - isIdentifier(value.contentFingerprint) && + isOneTalkRenderedCardIdentifier(value.conversationId) && + isOneTalkRenderedCardIdentifier(value.messageId) && + isOneTalkRenderedCardIdentifier(value.contentFingerprint) && isSafeNonNegativeInteger(value.observedAtMs) && typeof value.status === "string" && ONETALK_RENDERED_CARD_ACK_STATUSES.includes(value.status as OneTalkRenderedCardAckStatus) && diff --git a/packages/onetalk-contract/test/contract.test.ts b/packages/onetalk-contract/test/contract.test.ts index a960b61..8f452f3 100644 --- a/packages/onetalk-contract/test/contract.test.ts +++ b/packages/onetalk-contract/test/contract.test.ts @@ -52,6 +52,7 @@ import { isOneTalkOutboundContent, isPlainRecord, isOneTalkCenterMessage, + isOneTalkCenterMessageContent, isNextOneTalkConversationsDiscoveredFragment, isOneTalkMessage, isOneTalkMessageContent, @@ -192,7 +193,28 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl participantIds: ["sender-1", "receiver-1"], direction: "received", sentAtMs: 1_700_000_000_000, - content, + content: { + version: 1, + kind: "order", + orderId: "order-1", + bizCode: null, + contractId: null, + id: null, + tenant: null, + orderAmount: 10, + orderAmountCurrency: "USD", + paymentAmount: 10, + paymentAmountCurrency: "USD", + statusMessageKey: "paid", + actions: [], + title: content.title, + products: content.products, + productCount: content.productCount, + status: content.status, + payment: content.payment, + delivery: content.delivery, + action: content.action, + }, }, ); assert.deepEqual(decodeOneTalkFrame(updated), { ok: true, frame: updated }); @@ -1397,6 +1419,89 @@ test("stores exact business-card markers and only exposes customer fields in pub } }); +test("projects rendered-card display facts onto their base Center content kinds", () => { + const renderedInquiry = { + version: 1 as const, + kind: "rendered_inquiry" as const, + product: { imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }, + purchaseQuantity: { value: "100", unit: "pieces" }, + requirementText: "Need delivery this week", + inquiryReference: "inquiry-1", + actions: [{ label: "Reply", available: true }], + }; + const renderedProduct = { + version: 1 as const, + kind: "rendered_product" as const, + product: { + imageUrl: "https://img.alicdn.com/item.jpg", + title: "Widget", + sourceUrl: productContent.sourceUrl, + productId: productContent.productId, + }, + priceDisplay: "US $10.00", + minimumOrder: { value: "1", unit: "piece" }, + serviceBadges: ["Fast dispatch"], + }; + const renderedOrder = { + version: 1 as const, + kind: "rendered_order" as const, + title: "Order summary", + products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }], + productCount: 1, + status: { code: "paid", text: "Paid" }, + payment: { totalDisplay: "US $10.00", discountDisplay: null }, + delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null }, + action: { label: "View order", status: "available" }, + }; + const centerInquiry = { + ...inquiryContent, + product: renderedInquiry.product, + purchaseQuantity: renderedInquiry.purchaseQuantity, + requirementText: renderedInquiry.requirementText, + inquiryReference: renderedInquiry.inquiryReference, + actions: renderedInquiry.actions, + }; + const centerProduct = { + ...productContent, + title: renderedProduct.product.title, + imageUrl: renderedProduct.product.imageUrl, + priceDisplay: renderedProduct.priceDisplay, + minimumOrder: renderedProduct.minimumOrder, + serviceBadges: renderedProduct.serviceBadges, + }; + const centerOrder = { + ...orderContent, + title: renderedOrder.title, + products: renderedOrder.products, + productCount: renderedOrder.productCount, + status: renderedOrder.status, + payment: renderedOrder.payment, + delivery: renderedOrder.delivery, + action: renderedOrder.action, + }; + + for (const content of [ + inquiryContent, + productContent, + orderContent, + centerInquiry, + centerProduct, + centerOrder, + ]) { + assert.equal(isOneTalkCenterMessageContent(content), true); + } + for (const content of [renderedInquiry, renderedProduct, renderedOrder]) { + assert.equal(isOneTalkCenterMessageContent(content), false); + } + for (const content of [ + { ...centerInquiry, unexpected: true }, + { ...centerProduct, minimumOrder: { value: "1" } }, + { ...centerOrder, status: { code: "paid" } }, + ]) { + assert.equal(isOneTalkCenterMessageContent(content), false); + } +}); + test("normalizes raw product URLs and only accepts their exact query-free product facts", () => { const rawUrl = `${productContent.sourceUrl}?chatToken=secret&targetEncrypt=encrypted`; assert.deepEqual(normalizeOneTalkProductUrl(rawUrl), { From 1b1dd34104f06baa3ab0a606642a7121c199a7ab Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:33:19 +0800 Subject: [PATCH 2/2] chore: record journal --- .trellis/workspace/ybf/index.md | 5 +++-- .trellis/workspace/ybf/journal-1.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/ybf/index.md b/.trellis/workspace/ybf/index.md index c725742..9b1e41d 100644 --- a/.trellis/workspace/ybf/index.md +++ b/.trellis/workspace/ybf/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 74 +- **Total Sessions**: 75 - **Last Active**: 2026-09-15 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~1613 | Active | +| `journal-1.md` | ~1635 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 75 | 2026-09-15 | 统一 OneTalk 二次卡片 Mind 类型 | `905e9fb` | `09-15-unify-onetalk-card-mind-types` | | 74 | 2026-09-15 | 统一 OneTalk buyer facts IndexedDB | `cd888ac` | `dev-fix` | | 73 | 2026-09-15 | 完成 React 卡片监测 | `b28ccb0` | `09-14-react-card-monitor` | | 72 | 2026-09-15 | Index OneTalk durable ledger queries | `c4623ee` | `09-14-onetalk-indexeddb-query-indexes` | diff --git a/.trellis/workspace/ybf/journal-1.md b/.trellis/workspace/ybf/journal-1.md index 7219334..89af237 100644 --- a/.trellis/workspace/ybf/journal-1.md +++ b/.trellis/workspace/ybf/journal-1.md @@ -1611,3 +1611,25 @@ Enforced unique workspace context across Mind authorization HTTP and WebSocket f ### Next Steps - 修复既有 readAll 类型错误后重建并加载扩展,完成 v10 浏览器写入 smoke + + +## Session 75: 统一 OneTalk 二次卡片 Mind 类型 + + +**Date**: 2026-09-15 +**Task**: 统一 OneTalk 二次卡片 Mind 类型 +**Branch**: `09-15-unify-onetalk-card-mind-types` + +### Summary + +完成 OneTalk 二次卡片的 Mind 公开投影统一:保留基础 kind、增加严格可选补全字段、复用 HTTP 与 WebSocket 投影并补齐回归测试。contract/server/extension 完整测试、类型检查和构建通过;全仓格式检查仍受未改动的 observation-pipeline Oxfmt 基线问题影响。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `905e9fb` | fix: unify OneTalk rendered card Mind content types | + +### Status + +[OK] **Completed**