mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
docs: clarify OneTalk business card profile projection
This commit is contained in:
@@ -63,6 +63,7 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
|
||||
- MAIN world constructs a new whitelist object from `window.__conversationListData__` and `im-conversation-list:syncData`; it never forwards a raw row or response.
|
||||
- `channelAccountId` comes only from the logged-in page identity (`currentUserAccountId` or `IcbuIM.UserUtil.currentUser.accountId`). URL `activeAccountId` identifies the selected counterpart and is never an account fallback.
|
||||
- The durable profile key is `[channelAccountId, conversationId]`; `aliId` remains a profile field and does not define ownership. A profile may be persisted before the technical conversation row is discovered.
|
||||
- Profile observation is intentionally independent from message observation: `__conversationListData__` and message history/live callbacks may arrive in either order. Do not join a profile snapshot into a `business_card` while decoding a message; the message fact stores only the marker and the server read path may later use this current profile by the scoped key.
|
||||
- Avatar candidates are validated absolute HTTP(S) URLs. CRM avatar data is accepted only after `buyerInfo` and the OneTalk customer identifier match; otherwise the observation is discarded.
|
||||
- `conversation.discovered` is emitted with exactly `conversationType: "direct"`. The client does not guess a conversation type from UI labels, and the server does not infer direct from a missing value.
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ rejected
|
||||
|
||||
Observation 先写最新 pending,再由现有 Bright WebSocket 发送 contact.profile.observed。同 fingerprint 且无 pending 时只推进更高 observed high-water mark;任何不高于 uploaded/rejected high-water mark 的不同 fingerprint 也跳过。断线、Service Worker 重启或新页面连接只从 pending 重建发送。收到 contact.profile.ack 后,必须等待 readwrite transaction oncomplete,且只确认仍匹配的 fingerprint 和 observedAtMs;迟到旧 ACK 不得删除新 pending。收到 `ws.error` `profile_observed_at_future` 时只丢弃该 request 的精确 pending,避免无限重试。
|
||||
|
||||
消息与 profile 的耐久状态仍然分离:历史/实时消息中的 `business_card` 只持久化 `{ version: 1, kind: "business_card" }` marker。由于 `__conversationListData__` 与消息采集可能异步,消息候选不得等待或读取 profile ledger;当前客户资料只在服务端读取时按 `[channelAccountId, conversationId]` 以内存方式组合到对外 view。没有 profile 时保留 marker,不能 fallback 到登录人资料。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
### Durable-first observation
|
||||
|
||||
@@ -73,12 +73,13 @@ const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
|
||||
### Ownership
|
||||
|
||||
- MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
|
||||
- OneTalk raw `contentType`、`custom.data`、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared `OneTalkMessageContent` 的 `text | image | file | business_card | inquiry | order` v1 联合,才允许跨 bridge。业务卡只允许历史 SDK 完整 tuple 的白名单投影;原始正文、完整 `contact`/`params`、`sign`、加密标识和 token 不得跨 bridge。
|
||||
- OneTalk raw `contentType`、`custom.data`、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared `OneTalkMessageContent` 的 `text | image | file | business_card | inquiry | order` v1 联合,才允许跨 bridge。业务卡只允许历史 SDK 完整 tuple 的白名单投影;名片跨 bridge 的形状严格是 `{ version: 1, kind: "business_card" }` marker,不携带客户资料。原始正文、`item.contact`、完整 `contact`/`params`、`sign`、加密标识和 token 不得跨 bridge。
|
||||
- `onetalk.page.observed.diagnostics` 是可选 exact-shape 的安全计数投影:只允许非负 `unsupportedSkippedCount`、`invalidObservationCount`,以及无重复的 `{ code, mediaKind, count }` anomaly。`code` 只能是 shared `media_*` 或 `card_*` 白名单,`mediaKind` 只能是 `image`、`file` 或 `card`;不得放入原始 SDK 字段、失败输入或异常文本。
|
||||
- ISOLATED Content Script 只拥有页面桥和 \`runtime.Port\`;不得解释业务 payload、保存同步状态或选择备用页面。
|
||||
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、命令路由、上传编排和 IndexedDB 访问。
|
||||
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
|
||||
- 联系人资料走独立的 onetalk.page.profile-observed → profile ledger → contact.profile.observed 路径;不得并入 message.observed。
|
||||
- `__conversationListData__` 与消息观察的到达时序不保证一致;page bridge 不等待或拼接 profile。名片读取 view 只能由服务端按 `[channelAccountId, conversationId]` 读取当前 profile 后以内存组合产生,不能把 view 形状回写消息事实。
|
||||
|
||||
### Bidirectional flow
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Bright WebSocket
|
||||
- ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
|
||||
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
|
||||
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
|
||||
- `protocolVersion=5` 与 `content.version=1` 独立演进;跨 MAIN 的消息仅是 shared normalized `text | image | file | business_card | inquiry | order` content。业务卡只允许已观察的 `sdk_flat_history` 完整联合条件产生:名片只含消息时点四项资料快照,询盘只有类别,订单只含批准的摘要投影。raw `custom.data`、顶层 `text/contentType`、卡片原始正文、`params`、`sign` 和完整 `contact` 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
|
||||
- `protocolVersion=5` 与 `content.version=1` 独立演进;跨 MAIN 的消息仅是 shared normalized `text | image | file | business_card | inquiry | order` content。业务卡只允许已观察的 `sdk_flat_history` 完整联合条件产生:名片在消息事实中只保存 `{ version: 1, kind: "business_card" }` marker,询盘只有类别,订单只含批准的摘要投影。名片的客户资料由独立 profile ledger 按 `[channelAccountId, conversationId]` 关联,并只在服务端读取时以内存组合扩展;不会因为 `__conversationListData__` 与消息观察异步而在 MAIN 侧拼接。raw `custom.data`、顶层 `text/contentType`、卡片原始正文、`params`、`sign` 和完整 `contact` 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
|
||||
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
|
||||
- Bright 是联系人资料当前事实源;Mind 只提供页面/插件授权上下文,不接收 profile delivery,也不作为 Bright read model 的 profile projection owner。
|
||||
- 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。
|
||||
@@ -266,18 +266,37 @@ type OneTalkMessageContentKind =
|
||||
| "text" | "image" | "file"
|
||||
| "business_card" | "inquiry" | "order";
|
||||
|
||||
type OneTalkBusinessCardContent = {
|
||||
version: 1;
|
||||
kind: "business_card";
|
||||
};
|
||||
|
||||
type OneTalkBusinessCardViewContent = OneTalkBusinessCardContent & {
|
||||
contactName: string | null;
|
||||
companyName: string | null;
|
||||
countryCode: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type OneTalkCenterMessageContent =
|
||||
| OneTalkMessageContent
|
||||
| OneTalkBusinessCardViewContent;
|
||||
|
||||
decodeOneTalkMessageContent(source: OneTalkMessageContentSource) ->
|
||||
{ status: "decoded", content: OneTalkMessageContent }
|
||||
| { status: "ignored" | "unsupported_skipped" }
|
||||
| { status: "anomaly", code: OneTalkMediaAnomalyCode, mediaKind: "image" | "file" | "card" };
|
||||
```
|
||||
|
||||
`apps/onetalk-contract/src/content.ts` 是这六个 kind、exact-shape guard 和跨层读取模型的唯一 owner。`message-observer/content-decoder.ts` 是历史 SDK tuple 分类、受控 Base64 摘要解码和白名单投影的唯一生产点。
|
||||
`apps/onetalk-contract/src/content.ts` 是这六个 kind、exact-shape guard 和跨层读取模型的唯一 owner。`message-observer/content-decoder.ts` 是历史 SDK tuple 分类、受控 Base64 摘要解码和白名单投影的唯一生产点;它对名片只生产 marker,不生产客户资料 view。`read-projection.ts` 是服务端读取时将 marker 与同一账号/会话的当前 profile 组合为 `OneTalkBusinessCardViewContent` 的唯一 owner。
|
||||
|
||||
### 7.3 Contracts
|
||||
|
||||
- 历史业务卡必须同时满足 `messageType="rec"`、`type=1`、`viewType=0`、`msgType=10010`,以及唯一的 `(subType, cardType)`:名片 `(57,1)`、询盘 `(50,6)`、订单 `(59,9)`。任一条件缺失均不分类。
|
||||
- 名片只投影 `contactName`、`companyName`、`countryCode`、`avatarUrl` 的消息时点快照;它不能覆盖 `contact.profile.observed` 当前资料 ledger。
|
||||
- 名片在 MAIN、bridge、IndexedDB 与消息事实表中只投影 `{ version: 1, kind: "business_card" }` marker。历史条目的 `item.contact` 可能是登录人或发送者资料,不是与会话关联客户的可靠来源,禁止用于名片内容。
|
||||
- `window.__conversationListData__` / profile 观察与消息采集可能异步完成;消息观察阶段不得等待、读取或拼接 profile。profile 仍沿独立 `contact.profile.observed` 链路写入 Bright `onetalk_contact_profiles`,业务键为 `[channelAccountId, conversationId]`。
|
||||
- `/messages` 等读取边界先按账号和会话读取当前 profile,再以内存方式把 marker 扩展为 `OneTalkBusinessCardViewContent`;只允许 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项。没有 profile 时返回 marker;profile 存在但单字段缺失时,该字段为 `null`。读取投影不得反向修改消息事实或 profile ledger,也不得使用其他会话/账号的资料。
|
||||
- `isOneTalkMessageContent` 只接受 marker;`isOneTalkCenterMessageContent` 接受 marker 或四字段 view。持久化 contract 与对外读取 view 是两个明确层次,不得把 view 形状写回消息事实。
|
||||
- 询盘只有 `{ version: 1, kind: "inquiry" }`;订单只投影关联 ID、金额/币种、状态键、动作键与可用 `payStep`。所有 ID 接受受限字符串、任意 safe integer 或 `null`。
|
||||
- Base64、UTF-8、JSON、大小、动作 `properties` 或任一 order schema 失败必须形成安全的 `card_*` anomaly;不得降级为 text、空 order、inquiry 或正常 unsupported。
|
||||
- 仅 normalized content 可经 page bridge、IndexedDB、Bright、JSONB 与 Mind read 复用。`content` 原文、完整 `contact`、`params`、加密标识、`sign`、token 和未批准 URL 永不跨 MAIN。
|
||||
@@ -287,6 +306,11 @@ decodeOneTalkMessageContent(source: OneTalkMessageContentSource) ->
|
||||
| 条件 | 必须结果 |
|
||||
| --- | --- |
|
||||
| 完整历史 tuple + exact-shape 白名单投影 | `decoded` 对应业务 kind |
|
||||
| 完整名片 tuple `(57,1)` | 只返回 marker;不读取 `item.contact` 或异步 `__conversationListData__` |
|
||||
| `item.contact` 存在,或其资料看起来像当前登录人/发送者 | 忽略该对象,不把资料写入 message content |
|
||||
| 读取时存在同账号同会话 profile | marker 在 server read projection 中扩展四项 view 字段 |
|
||||
| 读取时 profile 不存在 | 返回 marker,不伪造空资料或 fallback 到登录人 |
|
||||
| profile 只有部分字段 | view 中缺失字段保留 `null`;不从消息 raw/contact 补齐 |
|
||||
| 同为 `msgType=10010` 但 subtype/cardType 不匹配 | 保留既有 file/unsupported 行为,不误分类 |
|
||||
| 订单摘要非 Base64、非 UTF-8、非 JSON、过大或 schema/action properties 非对象 | `card_*` anomaly,不携带 raw 输入 |
|
||||
| card kind 出现额外键或非法 URL/金额/ID | shared guard 拒绝,桥/服务端不接纳 |
|
||||
@@ -294,25 +318,30 @@ decodeOneTalkMessageContent(source: OneTalkMessageContentSource) ->
|
||||
|
||||
### 7.5 Good / Base / Bad Cases
|
||||
|
||||
- Good:完整订单 tuple 的摘要解码后只发送金额、币种、状态键、批准的 action 和关联 ID。
|
||||
- Base:询盘完整匹配时只发送类别;没有被观察到的商品详情不会被补成空字符串。
|
||||
- Bad:以 `msgType=10010` 或 DOM 标题猜测类别;把 `contact`、`params`、`sign` 或订单正文放进 bridge/frame/JSONB;用 `payStep: null` 掩盖非法 action properties。
|
||||
- Good:完整名片 tuple 先持久化 marker;读取同账号同会话的当前 profile 后,返回四项批准 view 字段。
|
||||
- Base:profile 尚未到达或没有客户资料时仍返回 marker;部分 profile 只在对应 view 字段返回 `null`。
|
||||
- Bad:在观察阶段读取 `item.contact`/`__conversationListData__` 拼接名片,或把读取 view 写回消息事实;以 `msgType=10010` 或 DOM 标题猜测类别;把 `params`、`sign` 或订单正文放进 bridge/frame/JSONB。
|
||||
|
||||
### 7.6 Tests Required
|
||||
|
||||
- contract:六类 exact-shape、额外键、名片 URL、订单金额/ID/action 边界(包括负 safe integer)和旧 kind 回归。
|
||||
- extension:三种完整 tuple、相邻 10010 卡、raw WebSocket 不变、每一种订单解码失败和敏感字段未越界。
|
||||
- server:wire/domain/read round-trip、JSONB kind CHECK、提交 → ACK → publish 次序与旧 content 回归。
|
||||
- harness:按 shared exact-shape 验证后,名片只展示快照、询盘只展示分类、订单只展示批准字段;真实 Chromium 历史卡与 PostgreSQL migration 未运行时单独标记 deferred。
|
||||
- extension:三种完整 tuple、名片只输出 marker、`item.contact` 不越界、异步 profile 不参与观察、相邻 10010 卡、raw WebSocket 不变、每一种订单解码失败和敏感字段未越界。
|
||||
- server:marker 与同账号同会话 profile 的 read projection、无 profile/部分 profile 行为、wire/domain/read round-trip、JSONB kind CHECK、旧行迁移、提交 → ACK → publish 次序与旧 content 回归。
|
||||
- harness:按 shared exact-shape 验证 marker 或 view;无客户资料显示“客户资料暂未提供”,有资料时显示关联客户资料;真实 Chromium 历史卡与 PostgreSQL migration 未运行时单独标记 deferred。
|
||||
|
||||
### 7.7 Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: 宽松类型判断和 raw 字段穿透。
|
||||
if (item.msgType === 10010) return { kind: "order", params: item.originalData.params };
|
||||
// Wrong: 在消息观察阶段把 item.contact(可能是登录人/发送者)拼入名片。
|
||||
if (matchesHistoryCard(item, 57, 1)) {
|
||||
return { kind: "business_card", contactName: item.contact?.name };
|
||||
}
|
||||
|
||||
// Correct: MAIN 内完整 tuple 后逐字段投影;任何不合规摘要成为 anomaly。
|
||||
if (matchesHistoryCard(item, 59, 9)) return normalizeOrder(item);
|
||||
// Correct: MAIN 只产生 marker;读取边界再按账号+会话组合当前客户资料。
|
||||
if (matchesHistoryCard(item, 57, 1)) {
|
||||
return { version: 1, kind: "business_card" };
|
||||
}
|
||||
const view = projectCenterMessage(marker, currentConversationProfile);
|
||||
```
|
||||
|
||||
## 8. Reading and change ownership
|
||||
|
||||
@@ -23,7 +23,7 @@ MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
|
||||
## 3. Contracts
|
||||
|
||||
- 包可消费 `@trade-message-center/onetalk-contract`,但不提供给其它 workspace 包消费。任何 `apps/*/package.json` 都不得声明 `@trade-message-center/mind-test-harness` 为任一 dependency 类字段。
|
||||
- 联调页的 `OneTalkCenterMessage.content` 必须按 shared `content.version=1` exact-shape 验证 `text | image | file | business_card | inquiry | order`。名片只显示已批准的消息时点快照,询盘只显示分类,订单只显示批准的金额、状态和 action 字段;不得读取或展示原始 card 正文、`params`、`sign`、token 或完整联系人对象。
|
||||
- 联调页的 `OneTalkCenterMessage.content` 必须按 shared `content.version=1` exact-shape 验证 `text | image | file | business_card | inquiry | order`。名片允许 marker `{ version: 1, kind: "business_card" }`,也允许服务端按同账号同会话客户资料补全的四字段 view;没有客户资料时显示“客户资料暂未提供”,不得用登录人资料填充。询盘只显示分类,订单只显示批准的金额、状态和 action 字段;不得读取或展示原始 card 正文、`params`、`sign`、token 或完整联系人对象。
|
||||
- 包内不得有 `test/` 目录、`*.test.*` 文件、`test` 脚本、`build` 脚本、TypeScript `outDir` 或提交的 `dist/`。根 `pnpm build` 和生产 Dockerfile 均不得 filter/copy 该包。
|
||||
- 该包是顺道编写的低优先级工具,不得被根 `dev`、`typecheck`、`test` 或 `build` 调度;实现完成不要求测试。其启动或运行失败只在独立命令中报告,不能停止、等待或改变 server/extension 主流程。
|
||||
- 浏览器的 Origin 必须与 server 的 `MIND_PAGE_ORIGIN` 精确相等;默认页面地址为 `http://127.0.0.1:8788`。Cookie 快捷入口只写页面所在 loopback host 的开发 Cookie,生产 Cookie 仍由 Mind 登录设置。
|
||||
|
||||
@@ -28,6 +28,7 @@ runPackages({ env: { ...process.env, BUILD_HASH: buildHash } });
|
||||
- `BUILD_HASH` 唯一生成者是 workspace 根命令;下层不得从 mode env 读取、重新生成或覆盖。
|
||||
- 允许默认值时须在代码旁或所属规范写明来源、范围和不掩盖错误的理由,并测试存在/缺失两条路径。
|
||||
- 候选值优先级无证据时不得使用 `a || b || c` 猜测,应显式报错或补齐契约。
|
||||
- OneTalk `business_card` 的客户资料缺失不是消息事实错误:消息持久化必须保留 `{ version: 1, kind: "business_card" }` marker;读取时没有同账号同会话 profile 也返回 marker,不能 fallback 到登录人/发送者 `contact`,profile 单字段缺失才按 view 合同返回 `null`。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
@@ -38,6 +39,8 @@ runPackages({ env: { ...process.env, BUILD_HASH: buildHash } });
|
||||
| 契约明确缺失时默认值 | 允许,记录证据并测试主值、缺失值和有意义 falsey 值 |
|
||||
| `0`/`false`/`""` 合法却被 `||`/`??` 当缺失 | 按契约使用显式条件;仅 nullish 契约使用 `??` |
|
||||
| 多候选值无确认优先级 | 不猜测,显式报错或先补契约 |
|
||||
| 名片读取时没有客户 profile | 保留 marker;不得用消息 `item.contact`、登录人资料或其他会话资料补偿 |
|
||||
| 名片 profile 只有部分字段 | view 仅对缺失字段返回 `null`;不得回写 marker 或消息事实 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
@@ -45,14 +48,17 @@ runPackages({ env: { ...process.env, BUILD_HASH: buildHash } });
|
||||
- Good:必填 database URL、协议版本或设备 binding 缺失时返回稳定错误。
|
||||
- Base:契约规定空标题显示“未命名会话”,且测试覆盖空/真实标题。
|
||||
- Base:契约规定仅 `undefined` 表示未配置,重试次数 `0` 合法,因此使用 `??` 并测试 `0`/缺失。
|
||||
- Base:名片当前客户资料尚未采集时,读取返回 marker;资料稍后到达由下一次读取以内存投影反映,不修改消息事实。
|
||||
- Bad:`process.env.BUILD_HASH || randomBytes(8)...` 或 `?? randomBytes(...)` 在下层自行生成 hash。
|
||||
- Bad:`Number(env.PORT) || 3000` 或 `env.PORT ?? 3000` 没有端口默认值契约。
|
||||
- Bad:在消息观察阶段等待异步 `__conversationListData__`,或把登录人 `item.contact` 当作客户资料 fallback。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- 必填配置覆盖缺失、空值和合法值,缺失断言稳定错误,而不是只断言不抛异常。
|
||||
- workspace 构建断言根命令只生成一次且所有 package 收到相同 hash;package 单独缺失时失败。
|
||||
- 允许默认值覆盖真实、缺失及契约中合法的 `0`/`false`/`""`。
|
||||
- 名片读取覆盖 profile 全缺失、部分字段缺失和完整字段,断言 marker/view/null 形状以及没有登录人资料 fallback。
|
||||
- 修改前后搜索 `||`、`??`、三元默认值和 mode env 读取,确认没有第二个来源。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
当前已建立 OneTalk Bright 事实存储 schema,定义位于
|
||||
[`apps/server/src/database/schema/onetalk.ts`](../../../../apps/server/src/database/schema/onetalk.ts):
|
||||
|
||||
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 是唯一内容事实,只承载 shared contract 的 versioned `text | image | file | business_card | inquiry | order` JSON,不保存包含认证信息的完整 OneTalk envelope、业务卡 raw 正文或 SDK payload。
|
||||
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 是唯一内容事实,只承载 shared contract 的 versioned `text | image | file | business_card | inquiry | order` JSON;其中 `business_card` 严格只保存 `{ version: 1, kind: "business_card" }` marker,不保存客户资料、包含认证信息的完整 OneTalk envelope、业务卡 raw 正文或 SDK payload。
|
||||
- `onetalk_conversation`:插件发现的技术会话和共享同步锚点。`channel_account_id + conversation_id` 复合主键,不按 binding 或设备复制;`conversation_kind` 只接受显式 `direct`,未知历史会话保持 `null`;`sync_phase`、`sync_result`、`latest_message_id` 和 `history_complete` 表达同步进度及锚点状态,并允许零消息会话。
|
||||
- `onetalk_contact_profile`:Bright 当前联系人资料事实。`channel_account_id + conversation_id` 复合主键,不建立到技术会话表的外键;资料字段允许显式 `null`,只有严格较新的 `observed_at_ms` 才能覆盖整行。
|
||||
- `onetalk_contact_profile`:Bright 当前联系人资料事实。`channel_account_id + conversation_id` 复合主键,不建立到技术会话表的外键;资料字段允许显式 `null`,只有严格较新的 `observed_at_ms` 才能覆盖整行。它是名片读取 view 的唯一客户资料来源,不是消息事实的嵌入列。
|
||||
- `onetalk_message_anomaly`:缺字段、协议和同步异常的独立诊断事实。`fingerprint` 仅用于诊断合并;`payload` 必须由写入边界清洗,不能被消息读取、发送或锚点流程消费。
|
||||
|
||||
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通运行路径不提供物理删除;profile 当前行由读取服务另行受限读取后在内存组合,不复制进 conversation 或 message。媒体切换 migration `0005_young_squadron_supreme` 是一次性开发数据重置:仅 `DELETE` 本仓库拥有的 OneTalk message/anomaly/profile/conversation 事实,再删除 `text/content_type` 并为 `content` 加 v1 kind CHECK;不触及授权、binding 或其它渠道。
|
||||
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通运行路径不提供物理删除;profile 当前行由读取服务另行受限读取后在内存组合,不复制进 conversation 或 message。媒体切换 migration `0005_young_squadron_supreme` 是一次性开发数据重置:仅 `DELETE` 本仓库拥有的 OneTalk message/anomaly/profile/conversation 事实,再删除 `text/content_type` 并为 `content` 加 v1 kind CHECK;不触及授权、binding 或其它渠道。后续 `0011_mushy_baron_strucker` 在建立名片 exact CHECK 前,将已有带客户资料字段的 `business_card` content 归一化为 marker。
|
||||
|
||||
## Scenario: Schema 注释与 PostgreSQL 备注
|
||||
|
||||
@@ -212,7 +212,8 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
|
||||
- 重复消息返回 `duplicate`,不得覆盖首次事实或再次触发外部事件;允许只更新 `last_observed_at`。
|
||||
- 跨 workspace 收到相同 `channel_account_id + conversation_id + message_id` 时,必须沿用同一条已存在事实:返回 `duplicate`,保留首次写入的 `workspace_id`、`mind_user_id`、`binding` 和 `device_id`,不得因后续 workspace 改写来源上下文。
|
||||
- 消息读取按 `channel_account_id + conversation_id` 读取共享事实;workspace 隔离由 Mind 授权 scope 负责,不能把 `workspace_id` 加入消息事实主键或作为第二套消息副本维度。
|
||||
- `content jsonb` 必须是对象,且 `version=1`、`kind in (text,image,file,business_card,inquiry,order)`;应用边界再用 shared exact decoder 验证完整字段。不得保留顶层 `text`、`content_type`、raw content、`params`、`sign`、完整 `contact` 或平行投影列。
|
||||
- `content jsonb` 必须是对象,且 `version=1`、`kind in (text,image,file,business_card,inquiry,order)`;应用边界再用 shared exact decoder 验证完整字段。`kind=business_card` 时 JSON 必须精确等于 `{ "version": 1, "kind": "business_card" }`,不得把 `contactName` 等 view 字段写回数据库。不得保留顶层 `text`、`content_type`、raw content、`params`、`sign`、完整 `contact` 或平行投影列。
|
||||
- 名片 view 只能由读取服务先按 `channel_account_id + conversation_id` 读取 `onetalk_contact_profile`,再以内存方式投影四个批准字段;profile 缺失返回 marker,单字段缺失返回 `null`,不得 JOIN 其他账号/会话,也不得回退到登录人资料。
|
||||
- `content` 或文本相同本身不构成重复;只要 `message_id` 或 `conversation_id` 不同,就按新的 OneTalk 事实入库。
|
||||
- `discoverConversation` 只按账号/会话幂等 upsert,不清空已有消息计数、同步结果或锚点。
|
||||
- anomaly 以 `fingerprint` 唯一合并并递增 `occurrence_count`;payload 必须是领域层清洗后的 JSON。
|
||||
@@ -231,6 +232,9 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
|
||||
| 消息/异常/同步事务失败 | `OneTalkDatabaseError`;不发成功 ACK 或事件 |
|
||||
| sync latest ID 为 null 且会话零消息 | 允许成功保持空锚点 |
|
||||
| sync latest ID 缺失或不属于同账号/会话 | 记录同步 anomaly,状态为 `incomplete`,不推进锚点 |
|
||||
| business_card content 含 marker 以外字段 | 数据库 CHECK 拒绝;旧数据须由 `0011` 迁移先归一化再建立 CHECK |
|
||||
| business_card 读取时没有同账号同会话 profile | 返回 marker;不伪造空资料或使用消息 `contact` |
|
||||
| profile 只有部分批准字段 | 读取 view 对缺失字段返回 `null`,消息事实不变 |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
@@ -243,7 +247,7 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
|
||||
|
||||
- Domain:断言未知会话 reject、缺字段/未知 content anomaly、fingerprint 合并、metadata-only anomaly、四类同步结果和 latest ID 所属校验。
|
||||
- WebSocket:断言逐条 ACK、accepted/duplicate/anomaly/rejected 分流、plugin-only 写边界、数据库错误和精确 Mind 发布。
|
||||
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言 `0005` 只清空 OneTalk owned facts、旧列被删除,`0010` 只扩大 v1 kind CHECK,六类 content 都能 round-trip 且 raw card 字段不能入库;同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
|
||||
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言 `0005` 只清空 OneTalk owned facts、旧列被删除,`0010` 只扩大 v1 kind CHECK,`0011` 将已有 business_card 归一化为 marker 后建立 exact CHECK;六类 content 都能 round-trip 且 raw card/profile 字段不能入库。另测附带资料的名片插入被拒绝、读取服务按账号/会话 profile 组合 view、无 profile 返回 marker、部分 profile 返回 `null`;同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
|
||||
- Static:`db:check`、无 legacy outbox/dispatch 引用、`database commit -> ACK -> publish` 数据流检查。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
@@ -240,7 +240,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。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` 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。
|
||||
|
||||
@@ -259,19 +259,23 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null
|
||||
| history 尚未完整且请求 internal summary | HTTP `503 history_incomplete`,`Retry-After: 30` |
|
||||
| 数据库读取失败 | HTTP `503`,`database_unavailable`;不返回内部异常 |
|
||||
| 插件无在线连接 | 仍可读历史;响应/事件状态为 `offline`,实时发送能力保持禁用 |
|
||||
| 消息 kind 为 `business_card` 且存储 content 含客户资料字段 | 数据库 CHECK/应用 exact-shape 拒绝;迁移先归一化为 marker |
|
||||
| 名片读取时同账号同会话没有 profile | 返回 marker,不回退到登录人或 `item.contact` |
|
||||
| 名片 profile 仅有部分批准字段 | 返回 view,其余批准字段为 `null` |
|
||||
| profile 属于其他账号或会话 | 不参与组合;按无 profile 处理 |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:页面先读取 Bright direct list/detail/history,保存并回传 opaque cursor,再连接 WS;断线刷新依靠历史恢复,收到重复 `message.created` 不重复渲染。
|
||||
- Base:插件状态来自当前进程 registry;页面/接口显示 `offline` 不代表历史不可读,当前 profile 变化可以实时反映到列表名称和头像。
|
||||
- Bad:HTTP 路由访问 Mind legacy 表或代理插件接口,使用 anchor/latestMessageId 作为 cursor,放宽 direct filter,或把未提交消息先推给页面。
|
||||
- Base:插件状态来自当前进程 registry;页面/接口显示 `offline` 不代表历史不可读,当前 profile 变化可以实时反映到列表名称和头像;名片没有 profile 时仍能稳定返回 marker。
|
||||
- Bad:HTTP 路由访问 Mind legacy 表或代理插件接口,使用 anchor/latestMessageId 作为 cursor,放宽 direct filter,在消息观察时使用登录人的 `item.contact`,把读取 view 写回 message,或把未提交消息先推给页面。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;六类 content 都必须只含 shared normalized 字段,业务卡不能恢复 Base64 或 raw SDK 字段。
|
||||
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;六类 content 都必须只含 shared normalized 字段,业务卡必须验证 marker→同账号同会话 profile view、无 profile marker、部分 profile `null`,且不能恢复 Base64 或 raw SDK 字段。
|
||||
- WebSocket:Mind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
|
||||
- Mind 联调页行为(offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段)按 mind-test-harness 规范手工验证;server 自动化测试只覆盖 Bright public HTTP/WS 路径与稳定错误。
|
||||
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离。
|
||||
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离;历史业务卡仅能存 marker,旧行清理后数据库 CHECK 拒绝附带资料字段。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
@@ -292,6 +296,18 @@ return reply.send(page);
|
||||
|
||||
读取边界只消费 Bright 事实表和独立 keyset cursor;同步 anchor 仍只表达插件同步状态。
|
||||
|
||||
```ts
|
||||
// Wrong: 把消息观察时拿到的登录人/发送者 contact 当成会话客户。
|
||||
const content = normalizeBusinessCard(item, item.contact);
|
||||
await messageRepository.insertMessage(context, source, { ...message, content });
|
||||
|
||||
// Correct: message 事实只存 marker;读取时按受限会话 profile 组合 view。
|
||||
const stored = { version: 1, kind: "business_card" } as const;
|
||||
const conversation = await conversationRepository.read(scope, conversationId);
|
||||
const profile = await contactProfileRepository.read(scope.channelAccountId, conversation.id);
|
||||
return projectCenterMessage({ ...message, content: stored }, profile ?? undefined);
|
||||
```
|
||||
|
||||
## Scenario: OneTalk WS 固定协议内核与静态已认证路由
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"检查 raw WebSocket 不变、durable-first、ACK 和复合消息幂等键未回归。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"检查 raw SDK 字段没有穿过 MAIN-world 页面桥。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"检查新 content kind 通过既有候选/上传流程,不新增补偿或 outbox。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"检查名片快照没有覆盖或重用 contact profile ledger。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"检查名片 message marker 不读取或覆盖独立 contact profile ledger,读取时只按账号+会话受限组合。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"执行扩展 focused/full tests、strict typecheck、build 和 format gate。"}
|
||||
{"file":".trellis/spec/server/backend/index.md","reason":"检查 server decoder、repository、transaction 与 Mind read 保持 fail-closed 和职责边界。"}
|
||||
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"执行 server tests、migration check、strict typecheck、build 和 format gate。"}
|
||||
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"复核名片 tuple、白名单与未覆盖字段没有被过度解释。"}
|
||||
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"复核名片 tuple、item.contact 登录人/发送者语义、marker 落库与读取时 profile 组合边界。"}
|
||||
{"file":"docs/onetalk-inquiry-message-format-observation-2026-09-11.md","reason":"复核询盘仍只分类,未从 DOM 或原始 content 推断业务字段。"}
|
||||
{"file":"docs/onetalk-order-message-format-observation-2026-09-11.md","reason":"复核订单解码、白名单、sign 排除和未覆盖业务字段。"}
|
||||
|
||||
+21
-10
@@ -14,6 +14,9 @@
|
||||
type OneTalkBusinessCardContent = {
|
||||
version: 1;
|
||||
kind: "business_card";
|
||||
};
|
||||
|
||||
type OneTalkBusinessCardViewContent = OneTalkBusinessCardContent & {
|
||||
contactName: string | null;
|
||||
companyName: string | null;
|
||||
countryCode: string | null;
|
||||
@@ -48,9 +51,9 @@ type OneTalkOrderContent = {
|
||||
};
|
||||
```
|
||||
|
||||
所有内容 kind 继续 exact-shape 验证。名片四项允许 `null`,但非空字符串必须符合共享文本/头像 URL 限制;这是消息时点的 `contact` 投影,和现有 profile ledger 的当前资料不是同一事实。询盘只有类别,不含伪造的空业务字段。订单金额必须是有限非负数,币种、状态键和 action 键必须是非空受限字符串,`payStep` 可以为 `null`;五个关联字段只接受原样字符串、safe integer 或 `null`,不把值 stringify、拼接或从其它字段补偿。动作数和 Base64 输入大小都有固定上限;超限或任一 schema 失败均进入显式 anomaly。
|
||||
所有内容 kind 继续 exact-shape 验证。消息事实中的名片严格只允许 marker;四字段只存在于读取 view,非空字符串必须符合共享文本/头像 URL 限制。view 由服务端读取同一账号同一会话的当前 profile 以内存组合,profile 缺失时返回 marker,单字段缺失时返回 `null`,不使用历史条目的 `contact`。询盘只有类别,不含伪造的空业务字段。订单金额必须是有限非负数,币种、状态键和 action 键必须是非空受限字符串,`payStep` 可以为 `null`;五个关联字段只接受原样字符串、safe integer 或 `null`,不把值 stringify、拼接或从其它字段补偿。动作数和 Base64 输入大小都有固定上限;超限或任一 schema 失败均进入显式 anomaly。
|
||||
|
||||
`sign`、原始 `content`、完整 `contact`、完整 `params`、询盘加密标识、令牌和原始 URL 不出 MAIN world。头像 URL 仅以 `avatarUrl` 白名单字段跨层,并沿用现有绝对 HTTP(S) URL 校验。
|
||||
`sign`、原始 `content`、完整 `contact`、完整 `params`、询盘加密标识、令牌和原始 URL 不出 MAIN world。头像 URL 只沿独立 profile 观察链路以 `avatarUrl` 白名单字段跨层,并沿用现有绝对 HTTP(S) URL 校验;它不是名片消息事实字段。
|
||||
|
||||
## 3. MAIN-world 分类与解码
|
||||
|
||||
@@ -58,22 +61,22 @@ type OneTalkOrderContent = {
|
||||
|
||||
| kind | 必须同时成立 | MAIN-world 投影 |
|
||||
| --- | --- | --- |
|
||||
| `business_card` | `messageType="rec"`、`type=1`、`viewType=0`、`msgType=10010`、`subType=57`、`originalData.cardType=1` | `contact.name/companyName/complianceCountryCode/fullPortrait` 的四项快照 |
|
||||
| `business_card` | `messageType="rec"`、`type=1`、`viewType=0`、`msgType=10010`、`subType=57`、`originalData.cardType=1` | 只输出 `{ version: 1, kind: "business_card" }` marker;不读取 `item.contact` |
|
||||
| `inquiry` | 相同前三项和 `msgType=10010`,并且 `subType=50`、`cardType=6` | 仅 `{ version: 1, kind: "inquiry" }` |
|
||||
| `order` | 相同前三项和 `msgType=10010`,并且 `subType=59`、`cardType=9` | 五个关联字段和受控 Base64-UTF-8-JSON 订单摘要 |
|
||||
|
||||
为将 `messageType/type/viewType` 与 decoder 统一,`OneTalkMessageContentSource` 必须仅为历史 SDK 分支携带所需判别元数据;它仍是 MAIN 内部类型,不能进入结果。历史适配器必须丢弃顶层 `content`,只把必要、非敏感的 `contact` 子字段作为独立 decoder 输入。任何联合条件不完整的 `10010` 卡继续遵循既有 ignore/unsupported 路径,不能被分类为文件、订单或询盘。
|
||||
为将 `messageType/type/viewType` 与 decoder 统一,`OneTalkMessageContentSource` 必须仅为历史 SDK 分支携带所需判别元数据;它仍是 MAIN 内部类型,不能进入结果。历史适配器必须丢弃顶层 `content` 和完整 `contact`,名片 decoder 不接收客户资料输入。`__conversationListData__` / profile observer 与消息采集异步时,消息流程不得等待或合并 profile。任何联合条件不完整的 `10010` 卡继续遵循既有 ignore/unsupported 路径,不能被分类为文件、订单或询盘。
|
||||
|
||||
订单嵌套 `params.params` 解码复用当前严格 Base64 → UTF-8 fatal → JSON 流程,但与媒体 anomaly 分开命名为 card anomaly。Base64、UTF-8、JSON、对象/字段类型、金额、动作数组或上限不合规均保留可计数的异常;合法非纳入类别仍为 `unsupported_skipped`,不是异常。
|
||||
|
||||
## 4. 跨层与持久化
|
||||
|
||||
数据流不变:
|
||||
消息事实数据流保持不变,名片资料在读取边界独立组合:
|
||||
|
||||
```text
|
||||
SDK flat history (MAIN only)
|
||||
-> exact card classifier + whitelist decoder
|
||||
-> shared OneTalkMessage.content
|
||||
-> shared OneTalkMessage.content (business_card marker only)
|
||||
-> page bridge / runtime.Port
|
||||
-> IndexedDB candidate (durable first)
|
||||
-> messages.observed Bright frame
|
||||
@@ -81,22 +84,30 @@ SDK flat history (MAIN only)
|
||||
-> existing Mind history / message.created projection
|
||||
```
|
||||
|
||||
```text
|
||||
Bright onetalk_message marker
|
||||
+ scoped onetalk_contact_profile(channelAccountId, conversationId)
|
||||
-> server in-memory read projection
|
||||
-> optional OneTalkBusinessCardViewContent
|
||||
```
|
||||
|
||||
`page-bridge`、Service Worker、IndexedDB candidate、Bright creator、server service/repository 和 Mind read projection 只复用扩展后的 shared guard 与 `content` JSON;它们不得读取 SDK 字段或重算分类。消息先 durable write,再上传、服务端提交、ACK 与 publish 的顺序不变。
|
||||
|
||||
服务端 `onetalk_message_content_v1_chk` 当前只允许 `text/image/file`,因此需生成一份 Drizzle migration,把约束扩展为六个 kind,并同步 schema。旧行不迁移、不重写;新 JSONB 仍受 contract guard 约束。读取层从同一 `content` JSONB 投影,Mind 不新增字段或读取端点。
|
||||
服务端 `onetalk_message_content_v1_chk` 必须在 schema 与 migration 中要求 `kind=business_card` 的 JSONB 精确等于 marker。`0011` 先把已有带资料字段的名片行归一化为 marker,再建立 exact CHECK;新 JSONB 仍受 contract guard 约束。读取层从同一 `content` JSONB 读取 marker,再按 `[channelAccountId, conversationId]` 受限读取当前 profile 并以内存投影,Mind 不新增写入端点,也不把 view 回写数据库。
|
||||
|
||||
## 5. 兼容性、失败与回滚
|
||||
|
||||
- 已有 text/image/file exact-shape、media URL 和 raw WebSocket 路径不变。
|
||||
- 既有未知业务卡仍产生 `unsupported_skipped`;新三类仅在完整历史联合条件下改变为可上传消息。
|
||||
- 订单不能因解码失败降级为 `inquiry`、`text`、空 `order` 或正常 `unsupported`;必须生成安全、可观测 card anomaly,且不携带 raw payload。
|
||||
- 名片内容不得驱动或覆盖 `contact.profile.observed`;两条链路都可存在但分别表达消息快照和当前资料。
|
||||
- 名片观察不读取或写入 `contact.profile.observed`;消息事实 marker 与当前 profile 是两条独立事实。读取 view 缺 profile 时必须保留 marker,不能用登录人 `contact` 或其他会话资料补齐。
|
||||
- 旧数据库行若曾保存名片资料,迁移只清理名片 content 的额外键,不修改 profile 当前事实;回滚前必须评估已经写入的新 marker/view 读语义。
|
||||
- 回滚顺序是先停止产生新 kind,再在部署允许时执行反向 DB check 迁移;现有 JSONB 中的新 kind 会使旧 server 约束/guard 不兼容,因此必须按“contract → server migration → extension”正向顺序发布,回滚前评估已写入的新行。
|
||||
|
||||
## 6. 验证矩阵
|
||||
|
||||
1. contract:新 kind exact-shape、额外键拒绝、null/URL/ID/金额/action 边界及原有 kind 回归。
|
||||
2. MAIN decoder:每个完整联合条件成功;相邻 `10010` 的附件与未知卡不误判;订单各解码失败分别可观测;业务卡原始 `content` 不进入结果。
|
||||
2. MAIN decoder:每个完整联合条件成功;名片只生成 marker 且不读取 `item.contact`;相邻 `10010` 的附件与未知卡不误判;订单各解码失败分别可观测;业务卡原始 `content` 不进入结果。
|
||||
3. bridge + durable sync:新 kind 可以通过既有 decoder、写入 candidate、创建 canonical frame,敏感原始键不存在。
|
||||
4. server:wire guard、repository copy、JSONB migration/check、读投影和 publish 均接受新 kind;复合幂等键、ACK 和旧 content 回归。
|
||||
4. server:wire guard、repository copy、marker exact CHECK、`0011` 旧行归一化、按账号/会话 profile 的读投影(无 profile/部分字段)和 publish 均接受新 kind;复合幂等键、ACK 和旧 content 回归。
|
||||
5. quality:定向测试、typecheck、build、全量测试、format check 与 migration check;真实 Chromium 只作为未来历史样本 smoke,WebSocket raw 不在本期验收。
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"保持 MAIN/ISOLATED/SW 所有权、消息复合幂等键和 durable-write-before-upload 不变量。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面桥只接收 normalized message,不能传递 SDK raw content、params 或 contact 对象。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"复核候选持久化、ACK、重启恢复和消息事实的 existing lifecycle。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"区分名片消息时点快照与现有联系人当前资料 owner,避免 second source of truth。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"确认名片只保存 message marker,当前客户资料仍由独立 ledger owner,读取时按账号+会话组合。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"要求从 SDK unknown 输入在 MAIN 单点收窄,禁止消费者局部断言或重复 parser。"}
|
||||
{"file":".trellis/spec/server/backend/index.md","reason":"服务端 OneTalk wire、授权、repository 和数据库边界的实现基线。"}
|
||||
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"规定 server migration、strict typecheck、build 与 test 的验证门禁。"}
|
||||
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"名片历史 SDK 联合条件和四项 contact 快照的运行态观察证据。"}
|
||||
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"名片历史 SDK 联合条件、item.contact 登录人/发送者语义与 marker/profile 读取边界的运行态证据。"}
|
||||
{"file":"docs/onetalk-inquiry-message-format-observation-2026-09-11.md","reason":"询盘仅分类、没有稳定业务字段和不得使用 DOM/content 推断的观察边界。"}
|
||||
{"file":"docs/onetalk-order-message-format-observation-2026-09-11.md","reason":"订单历史 SDK 联合条件、Base64 摘要字段和 sign/raw payload 排除边界。"}
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@
|
||||
|
||||
| Scope ID | Execution workspace | Workspace role | Owns | Excludes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| STRUCTURED-MESSAGE | `/Users/ybf/work/trade-message-center` | task-branch | shared content contract、历史 SDK decoder/adapter、bridge guard compatibility、server JSONB migration/read projection、Mind test harness consumer 和对应回归测试 | raw WebSocket decoder/new-message collection、contact profile ledger、发送/授权/路由语义、任何 SDK raw payload 透传 |
|
||||
| STRUCTURED-MESSAGE | `/Users/ybf/work/trade-message-center` | task-branch | shared content contract、历史 SDK decoder/adapter、bridge guard compatibility、server JSONB migration/read projection、Mind test harness consumer 和对应回归测试 | raw WebSocket decoder/new-message collection、contact profile ledger 的写入/owner 改动(读取时按账号+会话组合除外)、发送/授权/路由语义、任何 SDK raw payload 透传 |
|
||||
|
||||
用户在 2026-09-11 的“开始!”确认在已审阅的 PRD/设计范围内实施本 scope。所有实施/检查代理使用同一个已声明的 primary checkout;不得把范围外文件或用户既有改动重置、纳入或覆盖。
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
- 先运行 contract package 的 typecheck/test,保证任何跨层代码只消费共享合同。
|
||||
|
||||
2. **MAIN 历史消息归一化**
|
||||
- 在 `message-observer/content-decoder.ts` 增加历史业务卡 tuple matcher、名片白名单投影和受限订单摘要解码;扩展内部 source metadata,但不让 raw SDK 字段进入输出。
|
||||
- 在 `history.ts` 只提取 decoder 所需的 `messageType`、`type`、`viewType` 和四个名片 `contact` 候选;继续丢弃业务卡 `content` 与完整原始对象。
|
||||
- 在 `message-observer/content-decoder.ts` 增加历史业务卡 tuple matcher、名片 marker 投影和受限订单摘要解码;扩展内部 source metadata,但不让 raw SDK 字段或客户资料进入输出。
|
||||
- 在 `history.ts` 只提取 decoder 所需的 `messageType`、`type`、`viewType`;不提取名片 `contact` 候选,继续丢弃业务卡 `content` 与完整原始对象。`__conversationListData__` / profile observer 的异步结果不得拼入消息观察。
|
||||
- 不修改 `decodeOneTalkRawContent`、`new.ts` 的 raw 入口或 WebSocket tap;为完整匹配、相邻卡、未知卡、每种订单失败和 raw 不回归补充 extension focused tests。
|
||||
|
||||
3. **传递、耐久与服务端接纳**
|
||||
- 以 shared guard 驱动既有 page bridge、IndexedDB、frame writer、server ingest、repository 和读投影;仅修正因封闭 union 扩展造成的 exhaustive type/test 缺口,禁止增加第二个卡片 parser。
|
||||
- 更新 `apps/server/src/database/schema/onetalk.ts` 中 JSONB kind check,并用 `pnpm --filter @trade-message-center/server db:generate` 生成 `apps/server/drizzle/` 迁移及 meta 快照。迁移只扩大约束,不改写历史消息。
|
||||
- 补 server wire/领域/repository/reading 回归测试,断言完整白名单 round-trip,且 `sign`、原始 `content`、完整 `contact/params` 不存在。
|
||||
- 更新 `apps/server/src/database/schema/onetalk.ts` 中 JSONB kind check,并用 `pnpm --filter @trade-message-center/server db:generate` 生成 `apps/server/drizzle/` 迁移及 meta 快照;`0011` 先把旧名片 content 归一化为 marker,再建立 exact check。
|
||||
- 补 server wire/领域/repository/reading 回归测试,断言名片 marker round-trip、按同账号同会话 profile 读取时组合 view、无 profile 返回 marker、部分字段为 `null`,且 `sign`、原始 `content`、完整 `contact/params` 不存在。
|
||||
|
||||
4. **全链路审查与质量门禁**
|
||||
- 执行定向 contract、extension 和 server 测试;检查 contract ↔ page ↔ frame ↔ IndexedDB ↔ server JSONB ↔ Mind projection 的 kind 一致性。
|
||||
@@ -37,7 +37,7 @@
|
||||
| content union | exact-shape guard 漏分支,导致桥或 server 拒绝合法卡 | contract 是唯一类型/validator owner;所有消费者复用它 |
|
||||
| 历史 tuple | `msgType=10010` 误分类附件或未知卡 | 六元联合条件完整,附件仍是 file,未知卡仍 unsupported |
|
||||
| order Base64 | 空值或解码失败变成空订单 | 每种失败进入可观测 card anomaly,raw 不越界 |
|
||||
| 名片快照 | 覆盖当前 contact profile 或复制完整 `contact` | 仅四个字段进入 message content;profile ledger 无修改 |
|
||||
| 名片资料来源 | 在观察时读取 `item.contact` 或等待异步页面 map | message content 仅为 marker;读取时才按账号+会话 profile 组合四字段 view;profile ledger 无修改 |
|
||||
| server migration | 新 kind 被 JSONB check 拒绝或旧行受影响 | 先扩 contract,再迁移 check;只扩大允许集合 |
|
||||
| 运行态来源 | 顺手修改 raw WebSocket 形成未验证采集 | raw decoder/new message 行为和测试均不变 |
|
||||
|
||||
|
||||
+8
-6
@@ -8,14 +8,15 @@
|
||||
|
||||
- 2026-09-11 的 Chromium CDP 只读 SDK 观察确认三种历史消息必须用联合条件识别:名片 `messageType=rec/type=1/viewType=0/msgType=10010/subType=57/cardType=1`;询盘为 `subType=50/cardType=6`;订单为 `subType=59/cardType=9`。`msgType=10010` 本身不是充分条件,附件也属于该卡片族。
|
||||
- 当前 MAIN-world 内容归一化只支持 text、image 和 `cardType=12` 的 file;其余业务卡片会返回 `unsupported_skipped`,随后在批处理中只计数而不会跨层传递。
|
||||
- 名片样本的 `contact` 含显示名、公司、国家/地区代码及头像 URL 候选,但它们是会话联系人资料而非名片内容;未验证独立邮箱字段或可复用的正文格式。
|
||||
- 名片样本的 `contact` 含显示名、公司、国家/地区代码及头像 URL 候选;运行态复核确认这些字段对应登录人/发送者资料,而不是与当前会话关联的客户资料,不能作为名片内容来源。未验证独立邮箱字段或可复用的正文格式。
|
||||
- `window.__conversationListData__` / 联系人资料采集与消息历史采集可能异步到达;不能在消息观察时把两者拼成一条名片消息。客户资料必须沿独立 profile ledger 按 `[channelAccountId, conversationId]` 保存。
|
||||
- 询盘样本的 `params` 只观察到加密的询盘/贸易关联标识和若干来源字段;商品标题、数量、需求和图片仅存在于当前可见 DOM,不能成为稳定消息合同。
|
||||
- 订单样本有可解码的 Base64-UTF-8-JSON 摘要,包含订单/实付金额与币种、状态键、动作和部分付款阶段;只有 D3 列出的订单关联字段可跨边界,签名、参与方标识和完整 payload 仍不可跨边界。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:为名片、询盘和订单定义互斥、可验证的信息采集类别;类别判断必须要求完整的 SDK 联合判别条件,不能通过 `msgType=10010` 或 UI 文本猜测。
|
||||
- R2:继续在 MAIN world 内完成卡片识别、受控解码与白名单投影;页面桥、Service Worker、持久化、Bright 与 Mind 之间只能传递最终类别和 D2/D3 批准字段,不能传递原始 `message`、`content`、`originalData.params`、`contact` 整体、`sign`、加密标识、令牌或序列化原始对象。
|
||||
- R2:继续在 MAIN world 内完成卡片识别、受控解码与白名单投影;名片在页面桥、Service Worker、持久化和 Bright 消息事实中只能传递 `{ version: 1, kind: "business_card" }` marker。客户资料走独立 profile ledger,Mind 读取时才按同账号同会话关联并以内存方式扩展 view;任何边界都不能传递原始 `message`、`content`、`originalData.params`、`contact` 整体、`sign`、加密标识、令牌或序列化原始对象。
|
||||
- R3:未知或不匹配 schema 的业务卡片必须保持显式、可观测的 `unsupported` / anomaly 结果;不得伪装为文本、文件、空订单或空询盘。
|
||||
- R4:保留已有文本、图片、文件和 `cardType=12` 附件识别语义;新类别不得扩大 D2 以外的联系人资料、媒体 URL 或业务详情采集范围。
|
||||
- R5:为每一种纳入 MVP 的类别覆盖:完整匹配、相同 `msgType` 的相邻类别不误判、异常解码或 schema、白名单边界及跨层合同。
|
||||
@@ -23,14 +24,14 @@
|
||||
## Product Direction
|
||||
|
||||
- D1:本期目标是尽可能采集对业务有用的原始消息信息;用户要求先共同确认逐类采集边界。
|
||||
- D2:名片卡片纳入 `contact.name`、`contact.companyName`、`contact.complianceCountryCode` 和 `contact.fullPortrait`。它们必须标注为消息观察时取得的会话联系人资料快照,不能宣称是名片正文的权威字段;缺值如实保留为 `null`,不猜测或回填。
|
||||
- D2:名片消息事实只纳入 `{ version: 1, kind: "business_card" }` marker。`contact.name`、`contact.companyName`、`contact.complianceCountryCode` 和 `contact.fullPortrait` 不从历史条目的 `item.contact` 采集,也不写入 message content;对外读取时,服务端可从同一 `channelAccountId + conversationId` 的当前 profile ledger 以内存方式补出 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项。没有 profile 返回 marker,profile 单字段缺失返回 `null`,不回退到登录人资料。
|
||||
- D3:订单卡片纳入订单金额及币种、实付金额及币种、`statusMessageKey`、动作列表的名称/状态键和可用 `payStep`,以及 `orderId`、`contractId`、`bizCode`、`id`、`tenant` 订单关联字段。嵌套 Base64 摘要仍只在 MAIN world 解码;跨层只传其白名单投影。
|
||||
- D4:询盘本期只归类,暂不纳入专有业务字段;既有 `unsupported` / anomaly 路径须保持可观测,不能伪造为空询盘内容。
|
||||
- D5:所有已接纳类别继续使用现有通用消息事实:`messageId`、`conversationId`、`senderId`、`direction`、`sentAtMs`、`participantIds`、`readStatus`、`messageStatus` 和 `unreadCount`;`channelAccountId` 是插件帧/耐久键的账号作用域,而非消息内容字段。分类扩展不得删改这些字段的身份、时间和去重语义。
|
||||
- D6(约束):这不授权透传原始 SDK 对象或序列化原始 payload。任何跨 MAIN-world 的字段必须逐字段白名单投影;`sign`、令牌、完整 `contact`、完整 `params` 和原始正文仍默认禁止。名片头像 URL 是 D2 的明确例外,沿用现有头像 URL 校验与 `null` 语义。
|
||||
- D6(约束):这不授权透传原始 SDK 对象或序列化原始 payload。任何跨 MAIN-world 的字段必须逐字段白名单投影;`sign`、令牌、完整 `contact`、完整 `params` 和原始正文仍默认禁止。头像 URL 只允许作为独立 profile ledger 的批准字段,并在读取 view 中按既有 URL 校验与 `null` 语义返回,不得写入消息事实。
|
||||
- D7:业务卡原始消息的 `content` 不在本期采集范围:不得解析、持久化、跨层传递或据此推断结构化字段。此限制只作用于本期新业务卡;既有纯文本消息的 `content.kind = "text"` 归一化和传递语义保持不变。
|
||||
- D8:名片、询盘和订单只从已观察到完整联合判别条件的 SDK 历史扁平消息采集。WebSocket raw 路径本期不改动,继续使用既有 text/image/file 归一化和 `unsupported` 结果;不得以 `custom.type=10010`、`cardType` 或 UI 文本猜测新类别。
|
||||
- D9:名片 `content` 保存的是消息时点的资料快照;既有 `contact.profile.observed` 仍是会话当前资料的唯一 owner。两者语义不同,不能相互覆盖、回填或把消息快照写进 profile ledger。
|
||||
- D9:名片 `content` 只保存 marker;既有 `contact.profile.observed` / `onetalk_contact_profile` 是会话当前客户资料的唯一 owner。读取服务按账号+会话以内存组合 marker 与 profile view,绝不把 view 写回消息事实,也不把消息观察到的登录人/发送者资料写入 profile ledger。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
@@ -43,7 +44,8 @@
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 三类业务卡各有精确、测试覆盖的历史 SDK 联合判别条件,且不会把附件或其它 `msgType=10010` 卡片误归类;WebSocket raw 行为不变。
|
||||
- [ ] 名片资料快照、订单白名单字段和仅分类的询盘各有明确的跨层合同与来源语义;敏感字段与未验证字段不能通过桥、持久化或服务端输入验证。
|
||||
- [ ] 名片 marker、读取时客户资料 view、订单白名单字段和仅分类的询盘各有明确的跨层合同与来源语义;敏感字段与未验证字段不能通过桥、持久化或服务端输入验证。
|
||||
- [ ] 名片消息落库只含 `kind`/`version`;读取时无客户资料仍返回 marker,部分资料只返回对应 `null` 字段,且不会使用登录人 `contact` 或异步页面 map 作为 fallback。
|
||||
- [ ] Base64 订单摘要的大小、编码、JSON 和 schema 失败均得到显式可观测结果,绝不静默变为“无订单”。
|
||||
- [ ] 已有 text/image/file 与 `unsupported` 行为回归通过;不需要新类别的信息仍保持原样。
|
||||
- [ ] 任务规划在实现前明确 MVP 字段范围、兼容策略、测试边界及未覆盖的运行态变体。
|
||||
|
||||
@@ -33,9 +33,9 @@ originalData.params keys =
|
||||
|
||||
`showCompanyName`、`showEmailAddress` 和 `showCertifications` 是展示开关;它们不是公司名、邮箱或认证详情本身。
|
||||
|
||||
### 2.2 会话联系人对象
|
||||
### 2.2 历史条目的 `contact`(登录人/发送者资料,不能作为客户来源)
|
||||
|
||||
同一条 SDK 条目的 `contact` 中可观察到以下候选字段:
|
||||
同一条 SDK 条目的 `contact` 中可观察到以下候选字段。运行态复核确认,这组资料对应登录人或发送者侧的用户资料,不等于当前会话关联客户资料;它不能被当作名片内容来源。
|
||||
|
||||
```text
|
||||
accountId, accountIdEncrypt, aliId, aliIdEncrypt,
|
||||
@@ -45,12 +45,12 @@ companyName, complianceCountryCode, currentTimeZone, serviceType
|
||||
|
||||
可谨慎使用的资料含义如下:
|
||||
|
||||
| 字段 | 可表达的信息 | 限制 |
|
||||
| ------------------------------- | ------------- | ---------------------------------------- |
|
||||
| `contact.name` | 联系人显示名 | 是会话联系人资料,不能证明是名片固定字段 |
|
||||
| `contact.companyName` | 联系人公司名 | 可为空或滞后 |
|
||||
| `contact.complianceCountryCode` | 国家/地区代码 | 国旗由 UI 按代码渲染,不是消息图片 |
|
||||
| `contact.fullPortrait` | 头像候选 URL | 本样本未填;不能假设必有 |
|
||||
| 字段 | 观察到的信息 | 使用限制 |
|
||||
| ------------------------------- | ------------- | ----------------------------------------------------- |
|
||||
| `contact.name` | 显示名 | 可能是登录人/发送者资料,不能证明是会话客户或名片字段 |
|
||||
| `contact.companyName` | 公司名 | 可为空或滞后;不能写入名片消息事实 |
|
||||
| `contact.complianceCountryCode` | 国家/地区代码 | 国旗由 UI 按代码渲染,不是消息图片 |
|
||||
| `contact.fullPortrait` | 头像候选 URL | 本样本未填;不能假设必有或作为名片快照保存 |
|
||||
|
||||
截图中的邮箱没有观察到独立的 `email` JSON 字段。本样本 `content` 是非 JSON 的普通字符串;邮箱可能出现在其中的展示文本,但没有验证出可复用的字段格式。
|
||||
|
||||
@@ -88,9 +88,20 @@ const getBusinessCardMessages = async (conversation) => {
|
||||
};
|
||||
```
|
||||
|
||||
如果后续业务需要联系人名称、公司、国家代码或头像,必须明确它们是 `contact` 资料观察,不是名片内容的权威声明。跨层同步只应传递经业务批准的白名单字段;不能透传 `contact` 整体对象、加密标识、`chatToken`、`content` 或 `sign`。
|
||||
如果后续业务需要联系人名称、公司、国家代码或头像,应从独立的联系人资料观察链路获取,而不是复用历史条目的 `contact`。`window.__conversationListData__` / profile 观察与消息采集可能异步到达,因此消息观察只输出 `{ version: 1, kind: "business_card" }` marker;Bright 读取 `/messages` 时再按 `[channelAccountId, conversationId]` 读取当前客户资料,并以内存方式补出 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项。没有客户资料时返回 marker,单字段缺失时返回 `null`,不回退到登录人资料,也不把 view 写回消息事实。
|
||||
|
||||
## 4. 已验证与未覆盖
|
||||
跨层同步只应传递经业务批准的 profile 白名单字段;不能透传 `contact` 整体对象、加密标识、`chatToken`、`content` 或 `sign`。
|
||||
|
||||
- 已验证:`10010/57/cardType=1` 判别组合,`originalData.params` 键集合,联系人候选资料和 `icbuData` 可用性。
|
||||
## 4. 跨层使用边界(实施后)
|
||||
|
||||
- MAIN 历史 decoder 仍使用完整 `(messageType, type, viewType, msgType, subType, cardType)` 联合条件识别名片,但只生成 `business_card` marker。
|
||||
- `item.contact` 不参与名片消息归一化;它可能描述登录人/发送者,不能代表当前会话关联客户。
|
||||
- 客户资料沿独立 `contact.profile.observed` → `onetalk_contact_profile` 路径持久化。消息事实表的 `content` 只保留 `kind` 和 `version`,数据库 exact CHECK 会拒绝附带客户字段的名片 JSON。
|
||||
- 服务端读取先按账号和会话读取当前 profile,再以内存组合出可选 view。profile 不存在时对外仍是 marker;部分 profile 只返回对应 `null`。
|
||||
- 以上实施边界不改变本页“单个真实样本的运行态观察”性质;它记录的是如何避免把观察到的登录人资料误当成客户资料。
|
||||
|
||||
## 5. 已验证与未覆盖
|
||||
|
||||
- 已验证:`10010/57/cardType=1` 判别组合,`originalData.params` 键集合,`contact` 候选资料和 `icbuData` 可用性;`contact` 不应作为会话客户名片来源。
|
||||
- 已实施的跨层规则:消息事实只保存 marker,读取时按同账号同会话 profile 组合 view,资料缺失/部分缺失分别返回 marker/`null`。
|
||||
- 未覆盖:`content` 的名片展示文本格式,独立邮箱字段来源,头像字段在不同名片中的填充率,以及名片详情/跳转链接。
|
||||
|
||||
Reference in New Issue
Block a user