mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
Merge pull request #51 from sinanyuntu/dev
完善 OneTalk 消息中心:同步重建、商品与卡片采集、工作区鉴权及扩展存储优化
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "trade-message-center"
|
||||
|
||||
[setup]
|
||||
script = "pnpm install"
|
||||
@@ -65,67 +65,67 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
|
||||
|
||||
### Source and identity
|
||||
|
||||
- MAIN world constructs a new whitelist object from `window.__conversationListData__` and `im-conversation-list:syncData`; it never forwards a raw row or response.
|
||||
- `onetalk.contact.snapshot` is an account-level command with the exact payload `{ action: "onetalk.contact.snapshot" }`; it reads only already-loaded direct profiles. It is attempted once only when the same account has a usable page, Bright is authenticated, and the durable ledger has no record for that account. Repeated hello, replay, selection change, Port reconnect or Bright reconnect must not create a second initial snapshot.
|
||||
- `onetalk.contact.collect` has the exact payload `{ action: "onetalk.contact.collect", conversationId: string }`. MAIN finds only that direct conversation in the current list; it does not use the selected conversation, switch UI, scan history or infer a missing identity. `syncData` may still publish a previously seen direct profile; publication is not upload eligibility.
|
||||
- A live `messageType: "new"` from either direction requests a targeted collect for its own `conversationId`. History and manual history sync never request a profile collect.
|
||||
- `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.
|
||||
- MAIN world 从 `window.__conversationListData__` 和 `im-conversation-list:syncData` 构造新的白名单对象;绝不转发原始行或原始响应。
|
||||
- `onetalk.contact.snapshot` 是账号级命令,payload 严格为 `{ action: "onetalk.contact.snapshot" }`;它只读取已加载的 direct 资料。仅当同一账号存在可用页面、Bright 已认证且该账号的 durable ledger 无记录时尝试一次。重复 hello、replay、选择变化、Port 重连或 Bright 重连不得产生第二次 initial snapshot。
|
||||
- `onetalk.contact.collect` 的 payload 严格为 `{ action: "onetalk.contact.collect", conversationId: string }`。MAIN 只在当前列表中查找该 direct 会话;不使用 selected 会话、不切换 UI、不扫描历史、不推断缺失身份。`syncData` 仍可重复发布已见过的 direct profile;发布不等于上传资格。
|
||||
- 任一方向的实时 `messageType: "new"` 只触发其自身 `conversationId` 的 targeted collect。历史同步和手动历史同步从不请求 profile collect。
|
||||
- `channelAccountId` 只能来自页面登录人身份(`currentUserAccountId` 或 `IcbuIM.UserUtil.currentUser.accountId`)。URL `activeAccountId` 标识当前对话客户,绝不能作为账号回退。
|
||||
- durable profile 的键是 `[channelAccountId, conversationId]`;`aliId` 只是资料字段,不定义归属。资料可以在技术会话行被发现之前持久化。
|
||||
- 资料观察有意独立于消息观察:`__conversationListData__` 与消息 history/live 回调可能以任意顺序到达。解码消息时不得把 profile snapshot 拼接进 `business_card`;消息事实只存 marker,服务端读取路径稍后按 scope 键使用这份当前资料。
|
||||
- 头像候选必须是校验通过的绝对 HTTP(S) URL。CRM 头像数据只在 `buyerInfo` 与 OneTalk 客户标识匹配后接受;否则丢弃该观察。
|
||||
- `conversation.discovered` 只以 `conversationType: "direct"` 发出。客户端不从 UI 文案猜测会话类型,服务端也不从缺失值推断 direct。
|
||||
|
||||
### Bright WebSocket boundary
|
||||
|
||||
- Protocol version is `5`. `contact.profile.observed` is a plugin-direction frame with `profiles.length` in `1..100` and serialized UTF-8 size at most `256 KiB`; the extension additionally emits at most 50 profiles per frame. Every profile has the exact 13 whitelisted fields above.
|
||||
- `contact.profile.ack` is a plugin-direction frame with `{ status: "delivered"; profileCount: number }`. It means the Bright transaction and its authorization/connection fences completed; it is not a Mind HTTP response and does not mean CRM business data was committed elsewhere.
|
||||
- Profile ingestion uses the existing plugin binding, `sync` operation and `read` permission. No second socket, page credential, Cookie, Mind user/workspace field or profile HTTP endpoint is introduced.
|
||||
- If any profile has `observedAtMs > receivedAtMs + 5 minutes`, Bright rejects the whole batch with `ws.error` code `profile_observed_at_future`; it performs no profile DB write and sends no profile ACK. The coordinator discards only the matching pending request entries so the rejected batch is not retried forever.
|
||||
- Bright OneTalk wire `protocolVersion` 为 `5`——它与页面桥 envelope(`ONE_TALK_PAGE_BRIDGE_VERSION = 4`)和消息 `content.version`(1)是相互独立的版本轴;见 [runtime-sync.md](./runtime-sync.md) 的版本对照表。`contact.profile.observed` 是插件方向 frame,`profiles.length` 在 `1..100`,序列化 UTF-8 大小不超过 `256 KiB`;扩展每 frame 额外最多发送 50 条 profile。每条 profile 恰好包含上述 13 个白名单字段。
|
||||
- `contact.profile.ack` 是插件方向 frame,内容为 `{ status: "delivered"; profileCount: number }`。它表示 Bright 事务及其授权/连接 fence 已完成;它不是 Mind HTTP 响应,也不代表 CRM 业务数据已在别处提交。
|
||||
- 资料摄取使用既有插件 binding、`sync` 操作和 `read` 权限。不引入第二个 socket、页面凭证、Cookie、Mind user/workspace 字段或 profile HTTP endpoint。
|
||||
- 任何 profile 的 `observedAtMs > receivedAtMs + 5 分钟` 时,Bright 整批拒绝并返回 `ws.error` 码 `profile_observed_at_future`;不写 profile 数据库、不发 profile ACK。协调器只丢弃匹配的 pending 请求项,使被拒批次不会无限重试。
|
||||
|
||||
### Durable lifecycle
|
||||
|
||||
- `ONE_TALK_SYNC_DATABASE_VERSION` is `8`. The independent `onetalk_contact_profiles` store is keyed by the account/conversation pair; message, candidate, checkpoint and anomaly stores remain separate.
|
||||
- The durable profile fingerprint is the only upload-deduplication gate. For every received profile, read its `[channelAccountId, conversationId]` record before `putPendingProfile()`. If either `pending.fingerprint` or `lastUploadedFingerprint` equals the incoming fingerprint, end after that read: do not update a timestamp/high-water mark, write IndexedDB, emit `profile_observed` or flush/send. A different fingerprint follows the existing durable-first pending write.
|
||||
- Same-key observations serialize the `getProfile → putPendingProfile` transition. Concurrent equal fingerprints may each perform their own read, but at most one may write, diagnose or flush. A read failure stays observable to the caller; tracker cleanup must not create a second unhandled rejection. If the coordinator is disposed while a durable read is pending, its completion performs no write, diagnostic or flush.
|
||||
- An ACK marks uploaded only after a readwrite transaction and only when the current pending fingerprint and `observedAtMs` still match. Older, unknown, duplicate or mismatched ACKs are no-ops. A future-skew error removes only the exact matching pending snapshot and records its rejected observation watermark.
|
||||
- Reconnect, Service Worker restart and a newly authenticated Bright client rebuild sends from durable pending records. There is no profile deletion, TTL, or fallback to a message row/Mind database.
|
||||
- Page/configuration callbacks use page, connection and configuration identity fences. A stale callback cannot route a snapshot to another account or operate a replacement coordinator.
|
||||
- `ONE_TALK_SYNC_DATABASE_VERSION` 为 `10`。独立的 `onetalk_contact_profiles` store 以账号/会话对为键;消息、candidate、checkpoint、anomaly 各 store 保持独立。v8→v9 只建立既有消息/profile 的 scope query indexes 并保留 ledger records;v9→v10 只在统一主 schema 新增 `onetalk_buyer_facts` store,并为 `onetalk_rendered_card_ledger` 建立 `[channelAccountId, status]` 与 `[channelAccountId, conversationId]` indexes,保留 profile、rendered-card 与所有既有 OneTalk ledger records/indexes,不重写既有记录。`hasProfileRecord(account)` 走账号 index count,pending flush 以 `[channelAccountId, pending.observedAtMs]` 的合法账号前缀范围读取。无 `pending.observedAtMs` 的 uploaded/rejected record 不得出现在该 index 结果中,也不得回退到 object-store 全表扫描。
|
||||
- durable profile 指纹是上传去重的唯一闸门。每收到一条 profile,先在 `putPendingProfile()` 之前读取它的 `[channelAccountId, conversationId]` 记录。若 `pending.fingerprint` 或 `lastUploadedFingerprint` 与来入指纹相同,读后即结束:不更新时间戳/高水位、不写 IndexedDB、不发 `profile_observed` 诊断、不 flush、不发送。指纹不同时走既有的 durable-first pending 写入。
|
||||
- 同键观察对 `getProfile → putPendingProfile` 转换做串行化。并发的相同指纹可以各自执行读取,但最多一个能写入、诊断或 flush。读取失败对调用方保持可见;tracker 清理不得制造第二个未处理 rejection。durable 读进行期间协调器被 dispose 时,其完成不产生写入、诊断或 flush。
|
||||
- 只有 readwrite transaction 完成且当前 pending 的 fingerprint 与 `observedAtMs` 仍然匹配时,ACK 才标记 uploaded。更旧、未知、重复或不匹配的 ACK 一律 no-op。future-skew 错误只移除精确匹配的 pending snapshot,并记录其被拒观察水位。
|
||||
- 重连、Service Worker 重启和新认证的 Bright client 都从 durable pending 记录重建发送。不存在 profile 删除、TTL,也不回退到消息行或 Mind 数据库。
|
||||
- 页面/配置回调受 page、connection、configuration 三重身份 fence 约束。过期的回调不能把 snapshot 路由到别的账号,也不能操作替换后的协调器。
|
||||
|
||||
### Sensitive-field boundary
|
||||
|
||||
`chatToken`, `aliIdEncrypt`, `accountIdEncrypt`, `loginIdEncrypt`, `kHTAccessToken`, Cookie, raw rows/responses and Mind user/workspace fields never cross MAIN→bridge, page→Port, Bright business payloads, diagnostics or the profile ledger. `deviceId` remains only in the existing plugin connection scope/connection-integrity context; it is not a profile field or database key.
|
||||
`chatToken`、`aliIdEncrypt`、`accountIdEncrypt`、`loginIdEncrypt`、`kHTAccessToken`、Cookie、原始行/响应以及 Mind user/workspace 字段不得跨越 MAIN→bridge、page→Port、Bright 业务 payload、诊断或 profile ledger。`deviceId` 仍只存在于既有插件连接 scope/connection-integrity 上下文;它不是 profile 字段,也不是数据库键。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| Condition | Result |
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| Missing `conversationId`/`aliId`, invalid fingerprint/time/status or extra profile key | reject as `invalid_message`; no send |
|
||||
| Group row, missing logged-in identity or CRM identifier mismatch | drop observation; do not write the old account or unmatched customer |
|
||||
| Extra frame key, sensitive key or wrong frame direction/scope | reject as `invalid_message`; no secret echo |
|
||||
| Empty or over-limit profile batch | `invalid_message`; no Bright DB write, send or ACK |
|
||||
| Equal pending/uploaded fingerprint | exactly one ledger read for that observation, then no write, diagnostic, flush or send |
|
||||
| Different fingerprint | durable-first pending write, `profile_observed` diagnostic and bounded flush are allowed |
|
||||
| Any profile beyond the five-minute future skew | whole batch `profile_observed_at_future`; no DB write/ACK, matching pending is discarded |
|
||||
| IndexedDB write/commit abort | no Bright send or uploaded state; failure remains explicit |
|
||||
| Bright offline, transport error or authorization/connection fence failure | no ACK; pending remains unless the explicit future-skew error was received |
|
||||
| ACK does not match request count or current fingerprint/time | no uploaded mark; pending/current record is preserved |
|
||||
| Auth, page/account/configuration epoch or coordinator becomes stale during the initial ledger check | no snapshot command; an old setup claim cannot block a later authenticated setup |
|
||||
| Coordinator is disposed after a durable read starts | no pending write, diagnostic or flush; a real read failure remains a caller-visible failure |
|
||||
| 缺少 `conversationId`/`aliId`、指纹/时间/状态非法或出现额外 profile 字段 | 以 `invalid_message` 拒绝;不发送 |
|
||||
| 群聊行、缺登录身份或 CRM 客户标识不匹配 | 丢弃观察;不写旧账号或不匹配的客户 |
|
||||
| frame 出现额外字段、敏感字段或方向/scope 错误 | 以 `invalid_message` 拒绝;不回显秘密 |
|
||||
| profile 批次为空或超限 | `invalid_message`;不写 Bright 数据库、不发送、不 ACK |
|
||||
| pending/已上传指纹与来入指纹相同 | 该观察只做一次 ledger 读取,然后不写入、不诊断、不 flush、不发送 |
|
||||
| 指纹不同 | 允许 durable-first pending 写入、`profile_observed` 诊断和有界 flush |
|
||||
| 任何 profile 超过 5 分钟 future-skew | 整批 `profile_observed_at_future`;不写库、不 ACK,丢弃匹配 pending |
|
||||
| IndexedDB 写入/提交中止 | 不发送 Bright、不标记 uploaded;失败保持显式 |
|
||||
| Bright 离线、传输错误或授权/连接 fence 失败 | 不 ACK;除非收到明确的 future-skew 错误,pending 保留 |
|
||||
| ACK 与请求计数或当前指纹/时间不匹配 | 不标记 uploaded;保留 pending/当前记录 |
|
||||
| initial ledger 检查期间授权、page/account/configuration epoch 或 coordinator 过期 | 不发 snapshot 命令;旧的 setup claim 不能阻塞后续已认证的 setup |
|
||||
| durable 读开始后 coordinator 被 dispose | 不写 pending、不诊断、不 flush;真实的读取失败仍对调用方可见 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good: MAIN derives a fixed profile from the logged-in account, durable state commits under `[account, conversation]`, Bright receives a bounded frame, and the exact current ACK fence marks it uploaded.
|
||||
- Good: an old profile arrives again through `syncData` or a targeted live collect; Service Worker reads its ledger fingerprint once and returns without a write, diagnostic or Bright frame.
|
||||
- Base: a profile with nullable business fields is a valid partial snapshot; a later strictly newer snapshot may overwrite every stored field, including explicit `null`.
|
||||
- Bad: using MAIN `seen`, page selection, a timer or a second account baseline as upload dedupe; treating an equal fingerprint as a timestamp refresh; routing a live message to a full list scan; or clearing pending on send start.
|
||||
- Good:MAIN 从登录人账号导出固定 profile,durable 状态按 `[account, conversation]` 提交,Bright 收到有界 frame,精确匹配当前的 ACK fence 后才标记 uploaded。
|
||||
- Good:旧 profile 经 `syncData` 或 targeted live collect 再次到达;Service Worker 只读一次 ledger 指纹即返回,不写入、不诊断、不发 Bright frame。
|
||||
- Base:含可空业务字段的 profile 是合法的 partial snapshot;之后严格更新的 snapshot 可以覆盖全部存储字段,包括显式 `null`。
|
||||
- Bad:用 MAIN `seen`、页面选择、timer 或第二个账号基线做上传去重;把相同指纹当作时间戳刷新;把实时消息路由成全列表扫描;或在发送开始时清空 pending。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- Contract: exact profile/frame keys, protocol version, direction/scope, direct discovery type, sensitive/unknown key rejection, empty/over-limit batches and `256 KiB` byte limit.
|
||||
- Observer: initial snapshot, repeatable `syncData` publication, exact snapshot/collect payload validation, targeted direct lookup, group/unknown target exclusion, logged-in identity, logout/account switch, CRM customer matching and avatar URL validation.
|
||||
- Ledger: the pre-v7 (`oldVersion < 7`) upgrade clears every old OneTalk store before recreating current profile and message-sync state, and the v7→v8 upgrade keeps the five existing stores; account/conversation key, durable-first ordering, same-pending/uploaded one-read-zero-write behavior, same-key concurrent writes, dispose-during-read, explicit read rejection, ACK/CAS, future-skew discard, reconnect and restart recovery. The upgrade must preserve configuration/deviceId and must not rekey/retry an old pending ledger.
|
||||
- Service Worker: existing Bright binding/read/sync authorization, first-auth setup in both page-first and auth-first order, auth-loss-after-ledger-read, live sent/received targeted collect, history exclusion, 50/51 profile batches, ACK count, future error mapping and stale callback/page identity fences; direct discovery always carries `conversationType: "direct"`.
|
||||
- Direct typecheck, contract/extension focused and full tests, format check and `git diff --check` are required. Real Chromium, Bright PostgreSQL and production Mind integration are separate external checks.
|
||||
- Contract:精确 profile/frame 字段、协议版本、方向/scope、direct discovery 类型、敏感/未知字段拒绝、空/超限批次和 `256 KiB` 字节上限。
|
||||
- Observer:initial snapshot、可重复的 `syncData` 发布、精确 snapshot/collect payload 校验、targeted direct 查找、群聊/未知目标排除、登录身份、登出/切账号、CRM 客户匹配和头像 URL 校验。
|
||||
- Ledger:pre-v7(`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 store,v8→v9 逐条保留六个 OneTalk store record 并创建 scope index,v9→v10 逐条保留所有既有 OneTalk ledger records/indexes,新增 buyer facts store,并为 rendered-card ledger 新增 account/status 与 account/conversation indexes;账号/会话键、durable-first 顺序、同 pending/已上传指纹一次读零写、同键并发写、读中 dispose、显式读取失败、ACK/CAS、future-skew 丢弃、重连与重启恢复。测试必须区分 profile account/pending、rendered-card scope indexes 与 `objectStore.getAll()`,并确认 uploaded profile record 不会混入 pending flush、会话重建不会遗漏对应 rendered-card record。升级必须保留 configuration/deviceId,且不得 rekey 或重试旧 pending ledger。
|
||||
- Service Worker:既有 Bright binding/read/sync 授权、page-first 与 auth-first 两种顺序的首次认证 setup、ledger 读取后授权丢失、实时 sent/received targeted collect、历史排除、50/51 条 profile 批次、ACK 计数、future 错误映射和过期回调/页面身份 fence;direct discovery 始终携带 `conversationType: "direct"`。
|
||||
- 必须执行定向 typecheck、contract/extension focused 与全量测试、format check 和 `git diff --check`。真实 Chromium、Bright PostgreSQL 和生产 Mind 集成是另行执行的外部检查。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
@@ -139,7 +139,7 @@ await store.markProfileUploaded({ channelAccountId, aliId, fingerprint, uploaded
|
||||
### Correct
|
||||
|
||||
```ts
|
||||
// Do not let a page-local seen set or observedAt timestamp decide upload eligibility.
|
||||
// 不要让页面本地 seen 集合或 observedAt 时间戳决定上传资格。
|
||||
const existing = await store.getProfile(channelAccountId, profile.conversationId);
|
||||
if (
|
||||
existing?.pending?.fingerprint === profile.profileFingerprint ||
|
||||
@@ -149,7 +149,7 @@ if (
|
||||
}
|
||||
await store.putPendingProfile(channelAccountId, profile);
|
||||
const requestId = bright.sendContactProfiles({ profiles: [profile] });
|
||||
// On the matching ACK, mark only the current pending [account, conversation, fingerprint, time].
|
||||
// 匹配的 ACK 到达时,只标记当前 pending 的 [account, conversation, fingerprint, time]。
|
||||
await store.markProfileUploaded({
|
||||
channelAccountId,
|
||||
conversationId: profile.conversationId,
|
||||
@@ -161,4 +161,4 @@ await store.markProfileUploaded({
|
||||
|
||||
### Design Decision: Bright owns profile facts
|
||||
|
||||
The profile is a Bright-readable fact because the conversation read API must combine the current snapshot in memory without a second Mind data path or a SQL JOIN. The existing plugin WebSocket remains the transport and authorization boundary; a separate Mind delivery adapter would create a second owner, duplicate retry/ACK semantics and make the account/conversation invariant unverifiable.
|
||||
profile 作为 Bright 可读的事实存在,是因为会话读取 API 必须在内存中组合当前 snapshot,而不引入第二条 Mind 数据路径或 SQL JOIN。既有插件 WebSocket 仍是传输与授权边界;单独的 Mind delivery 适配器会制造第二个 owner、重复的重试/ACK 语义,并使账号/会话不变量无法验证。
|
||||
|
||||
@@ -61,10 +61,19 @@ rejected
|
||||
|
||||
### Profile ledger (independent state machine)
|
||||
|
||||
联系人资料不使用消息 candidate/checkpoint/anomaly store。`ONE_TALK_SYNC_DATABASE_VERSION=8`:从 `oldVersion < 7` 升级时,transaction 必须清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、`onetalk_contact_profiles`),不重键或回补旧 ledger;从 v7 升级到 v8 时只新增 `onetalk_conversation_bootstraps` store,必须保留五个既有 store 的记录。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;首次升级后必须重新采集 profile 并从 clean state 执行 full sync,v7→v8 不得因为新增 bootstrap store 清理既有事实。v7 checkpoint 新增 OneTalk 会话列表来源的 `latestMessageAtMs`,用于同步完成后更新服务端会话活动时间。
|
||||
联系人资料不使用消息 candidate/checkpoint/anomaly store。`ONE_TALK_SYNC_DATABASE_VERSION=10`:从 `oldVersion < 7` 升级时,transaction 必须先清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、`onetalk_contact_profiles`),不重键或回补旧 ledger;从 v7 升级到 v8 时只新增 `onetalk_conversation_bootstraps` store,必须保留五个既有 store 的记录;从 v7/v8 升级到 v9 时只为既有消息、checkpoint、candidate、anomaly、profile records 建立账号、会话与状态查询索引,六个 store 的记录逐条保留;v9→v10 只在统一 `trade-message-center` schema 新增 `onetalk_buyer_facts` store,并为 `onetalk_rendered_card_ledger` 新增受限查询 indexes,逐条保留所有既有 stores、records 与 indexes。数据库名、版本与 upgrade handler 只由 `storage.ts` 拥有;buyer ledger 通过该主库路径打开,不得创建独立数据库。legacy clear 完成后才建 index,避免为必然删除的 records 建索引。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;首次 pre-v7 升级后必须重新采集 profile 并从 clean state 执行 full sync,v7→v8/v9/v10 不得因为 schema 演进清理既有事实。v7 checkpoint 新增 OneTalk 会话列表来源的 `latestMessageAtMs`,用于同步完成后更新服务端会话活动时间。
|
||||
|
||||
清空后的 `onetalk_contact_profiles` 业务键为 `channelAccountId + conversationId`;记录包含 key、账号、conversationId、资料字段中的 aliId、lastUploadedFingerprint、uploaded/rejected observed high-water mark、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。资料上传到 Bright persistence,不调用 Mind profile HTTP。
|
||||
|
||||
### Scope-bounded reads and the v10 rendered-card ledger
|
||||
|
||||
- `checkpoint` 使用 `by_account`;candidate 使用 `by_account_status`、`by_account_conversation` 与 `by_account_conversation_status`;message/anomaly 使用 `by_account_conversation`;profile 使用 `by_account` 和 `by_account_pending_observed_at`。bootstrap 继续只按稳定主键读取,不为无消费者的查询建 index。
|
||||
- rendered-card ledger 使用 `by_account_status` 读取精确 `[channelAccountId, "pending_ack"]`,使用 `by_account_conversation` 在会话重建 transaction 中删除精确 `[channelAccountId, conversationId]` records;这两种读取均不得恢复 `objectStore.getAll()` 或内存过滤其它账号。
|
||||
- 已知 ACK `candidateKey` 必须直接主键读取,且只接纳仍为 `pending_ack` 的 candidate;没有 request key 时才兼容查找 `(channelAccountId, conversationId, messageId)`。确认后只比较 checkpoint 当前 `latestMessageId` candidate 与本次 confirmed candidate 的 `candidateOrder`,不得重建全会话候选列表。
|
||||
- profile pending 读取必须使用 `[channelAccountId, pending.observedAtMs]` 的合法账号前缀 `IDBKeyRange`;缺少 `pending.observedAtMs` 的 uploaded/rejected record 没有该 index entry,不得由调用方再过滤整表。
|
||||
- 会话重建必须在一个 readwrite transaction 中,对目标 scope index 执行 `getAllKeys()` 并删除 message/candidate/anomaly/rendered-card record,再直接删除该会话 checkpoint;不得在 transaction 外读后删,也不得使用 object-store `getAll()`。
|
||||
- v10 database 一旦打开,v9 binary 打开同一数据库会因版本较低失败。发布回滚只能通过 v11 前向迁移,或保留 v10 schema 并仅禁用非 schema 行为;不得重发 v9 或清除 durable ledger。
|
||||
|
||||
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 到登录人资料。
|
||||
@@ -171,7 +180,70 @@ Service Worker 重启后必须从 IndexedDB 恢复:
|
||||
- 断线恢复会重新发现会话并恢复未确认事实和 completion 声明。
|
||||
- \`delivery_unknown\` 不创建发送任务、不自动重发;迟到消息继续进入普通 observation。
|
||||
- 页面同步路由、Bright ACK 和 IndexedDB 事务顺序在重启/断线下保持一致。
|
||||
- `oldVersion < 7` 升级到 v8 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile、会话活动时间并执行 full sync;v7→v8 必须断言新增 bootstrap store 且五个既有 store 逐条保留;配置、deviceId 与其它渠道数据在两条路径都保持不变。
|
||||
- `oldVersion < 7` 升级到 v10 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile、会话活动时间并执行 full sync;v7→v8 必须断言新增 bootstrap store 且五个既有 store 逐条保留;v8→v9 必须断言六个 OneTalk store 的 records 逐条保留、目标 index 存在且读取不调用 `objectStore.getAll()`;v9→v10 必须断言所有既有 OneTalk records/indexes 逐条保留,新增 `onetalk_buyer_facts`,rendered-card records 保留且 `by_account_status` 与 `by_account_conversation` 同时存在,pending read 只返回目标账号的 `pending_ack`,history cleanup 只删除目标账号/会话的 rendered-card record;配置、deviceId 与其它渠道数据在所有升级路径保持不变。
|
||||
|
||||
## Scenario: v10 rendered-card ledger scoped indexes
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
当 rendered-card pending recovery、会话重建或 `ONE_TALK_SYNC_DATABASE_VERSION` 发生变化时,必须按此场景实现 v10。它修复索引重构遗漏,不得改变 ledger 的 durable-first、first-content-wins、ACK 或 conflict 规则。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
```ts
|
||||
const ONE_TALK_SYNC_DATABASE_VERSION = 10;
|
||||
|
||||
type OneTalkRenderedCardLedgerStore = {
|
||||
listPending: (channelAccountId: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
|
||||
};
|
||||
```
|
||||
|
||||
`onetalk_rendered_card_ledger` 的 v10 indexes 是 `by_account_status: [channelAccountId, status]` 与 `by_account_conversation: [channelAccountId, conversationId]`。
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- v9→v10 只创建上述两个 index;不能清库、重键、回填或改写既有 record。
|
||||
- `listPending(channelAccountId)` 只能通过 `by_account_status` 查询 `[channelAccountId, "pending_ack"]`。
|
||||
- `clearConversationHistory(channelAccountId, conversationId)` 在既有 readwrite transaction 中,以 `by_account_conversation.getAllKeys([channelAccountId, conversationId])` 删除 rendered-card record。
|
||||
- `readAll`、`objectStore.getAll()` 和跨账号内存过滤都不是 rendered-card pending/rebuild 的兼容 fallback。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 必须行为 |
|
||||
| --- | --- |
|
||||
| v9 数据库含 rendered-card record | 升级后 record 保留,两个 v10 index 存在 |
|
||||
| `listPending("account-a")` | 仅返回 account-a 的 `pending_ack` record |
|
||||
| 同账号 confirmed/rejected record | pending read 不返回 |
|
||||
| history rebuild 清理 account-a/conversation-1 | 只删除该 scope 的 rendered-card record |
|
||||
| 缺 index、全表读取或未带 account 的 pending read | 显式失败或测试失败;不得扫描其它账号 |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:v9 record 不变地升级到 v10,pending upload 只读取当前账号。
|
||||
- Base:同一账号的其它会话与其它账号的同名会话均保留。
|
||||
- Bad:为恢复编译而重新引入 `readAll`,或重建时遗漏 rendered-card ledger。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- storage migration 覆盖 v9 record 保留和两个 index 存在。
|
||||
- pending read 覆盖两个账号、`pending_ack` 与 terminal status,并断言没有 object-store `getAll()`。
|
||||
- history cleanup 覆盖目标会话、同账号另一会话与另一账号同名会话。
|
||||
- 运行 storage/coordinator focused tests、extension typecheck、format 和 diff check。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: the removed helper scans every account and the rebuild loop omits cards.
|
||||
const pending = await readAll(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Correct: both recovery and rebuild use an exact durable scope index.
|
||||
await readByIndex(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME, "by_account_status", [
|
||||
channelAccountId,
|
||||
"pending_ack",
|
||||
]);
|
||||
```
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
当 MAIN-world 页面脚本需要主动读取 OneTalk 全部会话的历史时使用本契约。目标是触发页面官方 SDK 的只读历史请求,让现有 WebSocket observer 继续观察响应;不得改变已读状态、当前会话、页面滚动位置或页面消息 store。
|
||||
当 MAIN-world 页面脚本需要主动读取 OneTalk 全部会话的历史时使用本契约。目标是触发页面官方 SDK 的只读历史请求,让现有 WebSocket observer 继续观察响应;不得改变已读状态、当前会话、页面滚动位置或页面消息 store。目录名 `current-conversation-history` 是早期仅同步当前会话时期的遗留命名;本契约与该目录现状同时覆盖全会话遍历与 Service Worker 下发的单会话历史命令。
|
||||
|
||||
实现必须使用功能目录并按变化原因拆分:
|
||||
|
||||
```text
|
||||
current-conversation-history/
|
||||
├── entry.ts # 只安装页面全局入口
|
||||
├── index.ts # 只编排同步、节流、进度与并发
|
||||
├── sdk.ts # 只处理 URL/SDK、会话游标和 no-read 请求
|
||||
└── model.ts # 只拥有领域类型、规范化和错误契约
|
||||
├── entry.ts # 只安装页面全局入口
|
||||
├── index.ts # 只编排同步、节流、进度与并发
|
||||
├── all-conversations.ts # 只编排 OneTalk 全会话只读历史同步
|
||||
├── page-command.ts # 只处理 Service Worker 下发的历史同步命令
|
||||
├── sdk.ts # 只处理 URL/SDK、会话游标和 no-read 请求
|
||||
└── model.ts # 只拥有领域类型、规范化和错误契约
|
||||
```
|
||||
|
||||
历史同步错误码只由功能内的 `model.ts` 拥有;抛出 `Error` 并返回 `never` 的无业务基础原语由 `src/lib/error.ts` 提供。OneTalk 账号 ID 的类型和守卫属于多个 main-page 功能可共享的页面领域概念,由 `src/onetalk/main-page/model.ts` 拥有。分页游标身份辅助函数遵循项目级最近共同所有者规则:只服务一个分页实现时留在该实现;多个同层分页实现确定共享时,提升到最近共同父目录;只有不含渠道语义时才可进入 `lib`。不要把这些概念放进编排入口或业务 `utils.ts`,也不要用业务化包装函数重复基础原语。
|
||||
@@ -371,3 +373,70 @@ normalizeOneTalkMessageId("message-1.PNM"); // "message-1.PNM"
|
||||
### 2026-09-06 实时消息分类回归
|
||||
|
||||
生产 0.8.15 把 SDK history 的 `message.type === 1` 前置校验用于 live。实际 live 的 `lastMessage.message` 没有 type,导致 `invalid_observation` 与发送 `send_state_lost`。外层 `type` 是会话判别,不能复制进 message 冒充消息类别。history 继续严格校验;live 不要求不存在的字段,但已有未知类别、完整身份、内容白名单校验继续生效。内部 `upstreamType: 1` 是通过来源适配与内容解码后的业务分类标记,不证明 live 原文含 type。
|
||||
|
||||
## Scenario: OneTalk 商品详情链接的安全归一化
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger:raw 文本或 SDK flat history 的 `msgType=101/subType=1` 可能是已验证的 OneTalk 商品详情链接,且 URL query 含 `chatToken` 或加密跳转信息。
|
||||
- Scope:`@trade-message-center/onetalk-contract` 的 `normalizeOneTalkProductUrl()` 是唯一 URL 解析/验证 owner;MAIN world 在跨 bridge 前调用它。页面 bridge、Service Worker、Bright 和 Mind 只处理归一化的 product content。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
```ts
|
||||
normalizeOneTalkProductUrl(value: unknown): {
|
||||
sourceUrl: string;
|
||||
productId: string;
|
||||
} | null;
|
||||
|
||||
type OneTalkProductContent = {
|
||||
version: 1;
|
||||
kind: "product";
|
||||
sourceUrl: string;
|
||||
productId: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- 仅接受未经 trim 的 `https://chinese.alibaba.com/product-detail/<slug>-<nonzero decimal productId>.html`;`slug` 只含 ASCII 字母、数字和 `-`,路径、host、协议、大小写和 ID 都不能由调用方自行宽松解释。
|
||||
- raw URL 可携带 query;成功时只返回 `origin + pathname` 的 query-free `sourceUrl`,绝不返回、记录或跨 MAIN world 传递 raw URL、`chatToken`、加密 target 或 SDK `originalData`。
|
||||
- 已归一化 product content 再调用同一函数校验,且其 `sourceUrl`、`productId` 必须与结果完全相等;因而已持久化/transport 的 `sourceUrl` 不得带 query、fragment、credentials、port 或额外字段。
|
||||
- 在 `new URL()` 之前必须检验原始 authority 和 pathname。WHATWG 会静默消除默认/空 port、空 fragment 和 dot segment;不得在已规范化的 `url.pathname` 上判定原始输入合法。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 输入 | 行为 |
|
||||
| --- | --- |
|
||||
| 验证 host/path 的 raw URL,带 query | 产生严格的 query-free `product` 内容 |
|
||||
| 普通文本、错误 host/path/slug、前导零 ID | 继续既有 `text`,不是 anomaly |
|
||||
| 显式或空 port、userinfo、空/非空 fragment、空白 | 不归类为 product |
|
||||
| literal/percent-encoded dot segment 或 percent-encoded ID | 不归类为 product;不得利用 URL 自动规范化放行 |
|
||||
| product `sourceUrl` 含 query、ID 不一致或含 extra key | shared decoder 拒绝;不得写库/ACK/publish |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:MAIN decoder 将 `...?chatToken=secret` 转为只含安全 `sourceUrl` 与 `productId` 的对象,再走既有 observation 链路。
|
||||
- Base:不匹配的 HTTPS URL 保持文本内容,图片、文件、名片、询盘和订单的分类不变。
|
||||
- Bad:在 content decoder、server 或 read projection 各写一份 regex/URL parser,或把 query-stripped 字段错误当成 raw URL 原样转发。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- contract 覆盖 raw query 清除、exact-shape frame、ID 一致性与错误 host/path/slug/extra keys。
|
||||
- 回归覆盖默认/空 port、userinfo、空/非空 fragment、literal/encoded dot segment、percent-encoded product ID,防止 WHATWG 自动规范化绕过边界。
|
||||
- MAIN raw 与 flat history 同时断言输出 product 且序列化结果不含 query credential;普通 URL 继续为 text。
|
||||
- 服务端读取断言 history 与 `message.created` 投影相同,手工 query-bearing JSONB 在读取边界失败;迁移只放宽 kind CHECK,保留既有 kind 和名片 marker。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: URL 已丢失原始 dot segment / default port 语法,无法再安全判断。
|
||||
const url = new URL(rawUrl);
|
||||
if (url.hostname === "chinese.alibaba.com") return { sourceUrl: url.href };
|
||||
|
||||
// Correct: 先验证 raw authority/path,再解析并只导出安全派生字段。
|
||||
const product = normalizeOneTalkProductUrl(rawUrl);
|
||||
if (product !== null) {
|
||||
return { version: 1, kind: "product", ...product };
|
||||
}
|
||||
```
|
||||
|
||||
@@ -52,6 +52,10 @@ type OneTalkPageMessage =
|
||||
| {
|
||||
type: "onetalk.page.connection-status";
|
||||
disconnected: boolean;
|
||||
}
|
||||
| {
|
||||
type: "onetalk.page.binding-status";
|
||||
unbound: boolean;
|
||||
};
|
||||
|
||||
type OneTalkPageHello = {
|
||||
@@ -105,6 +109,9 @@ Bright -> Service Worker WebSocket -> unique page Port
|
||||
exact-shape envelope,只允许 `source`、`version`、`type` 与 `disconnected: boolean` 四个字段。
|
||||
它不属于 command 或 command-result:MAIN 只调用本地显示回调,不产生结果消息;bridge 不携带账号、binding、错误或 WebSocket 细节。
|
||||
|
||||
`onetalk.page.binding-status` 同样是 Service Worker → ISOLATED → MAIN 的 additive v4
|
||||
exact-shape envelope,只允许 `source`、`version`、`type` 与 `unbound: boolean` 四个字段。它是按已注册页面账号由 Service Worker 派生的显示投影:无已校验配置、配置账号与页面账号不一致,或当前配置处于稳定 `binding_revoked` 错误时为 true;offline、重连和其它错误为 false。它不属于 command 或 command-result,不能携带 binding、账号、错误码或 Bright 数据;ISOLATED 只经共享 decoder 和方向 guard 原样转发,MAIN 只调用本地显示回调。
|
||||
|
||||
### Bridge implementation
|
||||
|
||||
\`decodeOneTalkPageMessage(value: unknown)\` 是 MAIN、ISOLATED 和 Service Worker 使用页面消息的唯一运行时入口。消费者必须先解码,再用方向守卫收窄:
|
||||
@@ -123,7 +130,12 @@ ISOLATED 只做以下动作:
|
||||
|
||||
Port 断开或发送失败时,丢弃当前内存消息,不建立页面本地队列、重试或持久化。
|
||||
|
||||
bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断。每条业务消息还必须携带精确的内部来源证明 `upstreamType: 1`:MAIN 只在 raw 上游 `type === 1` 后生成它,decoder 拒绝缺失或错误值,Service Worker 在 durable 转换前再次验证并剥离该字段。`type === 2` 的平台消息不进入 bridge;其它缺失或未知 raw type 作为 invalid observation 暴露。`unsupported_skipped` 或媒体 anomaly 只含稳定 code/类型/计数;不得携带消息 ID、正文、URL 或 raw payload。ISOLATED 与 Service Worker 不得重新解析 Base64、`custom.data`、平台文案或推测媒体链接。
|
||||
bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断:
|
||||
|
||||
- 每条业务消息必须携带内部来源证明 `upstreamType: 1`;MAIN 只在 raw 上游 `type === 1` 后生成它,decoder 拒绝缺失或错误值,Service Worker 在 durable 转换前再次验证并剥离。
|
||||
- `type === 2` 的平台消息不进入 bridge;其它缺失或未知 raw type 作为 invalid observation 暴露。
|
||||
- `unsupported_skipped` 或媒体 anomaly 只含稳定 code/类型/计数,不得携带消息 ID、正文、URL 或 raw payload。
|
||||
- ISOLATED 与 Service Worker 不得重新解析 Base64、`custom.data`、平台文案或推测媒体链接。
|
||||
|
||||
每个 `historyProgress` 必须携带从 OneTalk 会话列表 `lastContactTimeLong` 归一化得到的 `latestMessageAtMs: number | null`。该字段只描述会话列表活动时间,不改变消息历史的 `(sentAtMs, messageId)` 分页。
|
||||
|
||||
@@ -141,7 +153,7 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
|
||||
|
||||
页面身份变化时,旧页面 command correlation 必须先收敛,再用新身份替换注册;迟到的旧结果不得恢复旧请求。
|
||||
|
||||
重复、相同的合法 hello 保持既有页面身份,不得重启 snapshot、buyer delivery、page-ready 或命令路由生命周期;但当该账号的断线 UI latch 仍活跃时,Service Worker 必须只向同账号页面重放 `disconnected: true`。该重放不是 command,也不改变任何页面身份或同步状态。
|
||||
重复、相同的合法 hello 保持既有页面身份,不得重启 snapshot、buyer delivery、page-ready 或命令路由生命周期;但 Service Worker 必须只向同账号页面重放当前断线 latch 与 `unbound` 显示投影。该重放不是 command,也不改变任何页面身份或同步状态。
|
||||
|
||||
Profile envelope 必须显式携带当次读取的 channelAccountId。Service Worker 只接受它与同一 Port 最近一次合法 hello 及当前配置账号完全相同的消息;logout、切账号和旧 Port 消息均 fail closed。
|
||||
|
||||
@@ -157,9 +169,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
|
||||
- 账号级同步命令只发送到唯一同账号页面;
|
||||
- onetalk.sync.conversation 只在同一 MAIN 页面已完成 onetalk.discover-conversations 并缓存 direct 会话后使用;它必须令 command 与 route 的 conversationId 相等,并投递到唯一同账号页面,不要求该页面当前 selected 会话就是历史目标;MAIN cache 继续是目标会话存在性的唯一证明;
|
||||
- \`onetalk.sync\` 保持 \`channelAccountId + conversationId\` 的精确页面路由;
|
||||
- \`onetalk.send\` 只要求唯一同账号页面,忽略该页面当前 selected conversation;command 的 conversationId 交给 MAIN 后,SDK input 必须以同值 \`cid\` 指定目标,\`conversationCode\` 只作兼容字段;
|
||||
- \`onetalk.send\` 的零页面和多页面分别返回 \`waiting_for_page\`、\`ambiguous_page_route\`;
|
||||
- 页面当前 selected 会话与发送目标不一致,不属于发送路由拒绝;
|
||||
- \`onetalk.send\` 只投递唯一同账号页面;其零/多页面 reason、selected 状态处理、SDK 输入与确认语义以 [OneTalk PWA 出站发送 SOP](./send-sop.md) 为唯一契约,此处不重复;
|
||||
- 页面身份与 binding/账号不一致时仍 fail closed。
|
||||
|
||||
\`sendRequestId\`、页面 \`requestId\` 和 Port pending map 只用于当前内存关联。发送终态、页面断线或身份变化后,不把 command 写入 IndexedDB、消息表或任务表。
|
||||
@@ -171,26 +181,24 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
|
||||
| Port 名称、sender URL、tab ID 或 frame ID 不合法 | 忽略连接,不加入注册表 |
|
||||
| envelope、方向或 JSON 结构非法 | 忽略消息 |
|
||||
| connection-status 缺字段、额外字段或错误方向 | 忽略消息,不调用 MAIN 显示回调 |
|
||||
| binding-status 缺字段、额外字段、非 boolean 或错误方向 | 忽略消息,不调用 MAIN 显示回调 |
|
||||
| observation 尚未收到合法 hello | 不调用观察回调 |
|
||||
| 页面账号无有效 active binding | fail closed |
|
||||
| onetalk.sync.conversation 的 command/route ID 不同、无同账号页面或同账号多页 | 不投递;分别维持精确路由、waiting_for_page 或 ambiguous_page_route |
|
||||
| \`onetalk.sync\` 无精确页面 | 返回同步对应的 \`waiting_for_page\`、\`page_identity_mismatch\` 或 \`ambiguous_page_route\` |
|
||||
| \`onetalk.send\` 无同账号页面 | \`rejected_before_send/waiting_for_page\` |
|
||||
| \`onetalk.send\` 同账号页面多于一个 | \`rejected_before_send/ambiguous_page_route\`,不广播 |
|
||||
| \`onetalk.send\` 页面当前会话不是目标 | 仍投递到唯一同账号页面,不返回 \`page_identity_mismatch\` |
|
||||
| 页面 Port post 失败或断开 | \`delivery_unknown/send_connection_lost\`,不重试 |
|
||||
| 页面 hello 身份变化 | 清理旧 pending correlation,待定命令返回 \`delivery_unknown/send_connection_lost\` |
|
||||
| Service Worker 重新实例化 | 页面注册表为空,等待页面重新连接 |
|
||||
| Profile envelope 账号与 hello/config 账号不一致 | 丢弃 profile observation,不写 ledger、不发 Bright |
|
||||
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle;活跃断线 latch 仅重放同账号 UI 状态 |
|
||||
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle;当前断线/未绑定显示状态仅重放给同账号页面 |
|
||||
| contact snapshot/collect action 缺字段、多字段或字段类型错误 | `rejected_before_send/invalid_request`;不访问 observer、SDK 或页面状态 |
|
||||
| collect target 不在当前 loaded direct list、为群聊或无登录身份 | `completed/profileCount: 0`;不切换 UI、不扫描历史、不猜测 target |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:页面先发送合法 hello,Service Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
|
||||
- Good:当前页面打开会话 A,但发送目标为会话 B;只要同账号页面唯一,command 仍投递,不改变页面 selected 状态。
|
||||
- Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 A;onetalk.sync.conversation(B) 仍投递到该唯一账号页面,并由 MAIN cache 解析 B。
|
||||
- Good:账号 A 的 page Port 收到 `{ type: "onetalk.page.binding-status", unbound: true }`,账号 B 的 Port 不收到 A 的状态;MAIN 不回传 command-result。
|
||||
- Good:live message 指向 direct 会话 B 时,Service Worker 把精确 collect(B) 投递到唯一同账号页面;B 未选中也不会改变路由结果。
|
||||
- Base:页面 selected 状态变化后重新发送 hello;旧请求不会被新身份的迟到结果完成。
|
||||
- Bad:将 URL \`conversationId\` 当成 SPA 会话身份、取第一个 selected 节点、广播到所有页面或跨账号回退。
|
||||
@@ -202,28 +210,15 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
|
||||
- hello 的唯一/零/多 selected 状态能正确编码,且 URL 不变时 selected 变化能刷新身份。
|
||||
- onetalk.sync.conversation 在当前 selected 会话不同但同账号页面唯一时仍投递;同账号多页时必须拒绝且不广播。
|
||||
- \`onetalk.sync\` 继续使用精确会话路由。
|
||||
- \`onetalk.send\` 在当前 selected 为其它会话、零 selected 或多 selected 时,仍向唯一同账号页面投递。
|
||||
- \`onetalk.send\` 同账号零页面返回 \`waiting_for_page\`,多页面返回 \`ambiguous_page_route\`,均不广播。
|
||||
- \`onetalk.send\` 的页面选择与 reason 语义按 [OneTalk PWA 出站发送 SOP](./send-sop.md) 的 Tests Required 覆盖,桥接层测试不重复断言。
|
||||
- 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
|
||||
- contact snapshot 和 collect 都覆盖 exact payload rejection;collect 覆盖非 selected direct target、缺失 target、群聊和缺失页面登录身份。
|
||||
- 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
|
||||
- connection-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的离线状态重放。
|
||||
- connection-status 与 binding-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的状态重放;binding-status 还覆盖缺配置、账号不匹配、`binding_revoked`、offline/其它错误不触发、配置恢复和 Port 断开后不遗留投递。
|
||||
- raw `contentType`、`custom.data`、顶层 `text` 或未知 content version 均不能通过 bridge;history/live 必须复用 MAIN 的同一 decoder。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
\`\`\`ts
|
||||
// Wrong: 发送也要求当前页面 selected 会话先等于目标。
|
||||
if (page.conversationId !== command.conversationId) {
|
||||
return { status: "rejected_before_send", reason: "page_identity_mismatch" };
|
||||
}
|
||||
|
||||
// Correct: 发送只路由到唯一同账号页面,目标由 MAIN 传给 SDK 的 cid 指定。
|
||||
const page = findUniqueAccountPage(channelAccountId);
|
||||
if (!page) return { status: "rejected_before_send", reason: "waiting_for_page" };
|
||||
page.port.postMessage(createPageCommand(command));
|
||||
\`\`\`
|
||||
|
||||
\`\`\`ts
|
||||
// Wrong: 页面断开后重新广播,可能造成重复发送。
|
||||
for (const port of allPagePorts) port.postMessage(command);
|
||||
@@ -231,3 +226,11 @@ for (const port of allPagePorts) port.postMessage(command);
|
||||
// Correct: 断开收敛为 unknown;迟到事实由普通 observation 链路处理。
|
||||
return { status: "delivery_unknown", reason: "send_connection_lost" };
|
||||
\`\`\`
|
||||
|
||||
\`\`\`ts
|
||||
// Wrong: 将配置、账号或错误细节越过 bridge 交给 MAIN 再判断未绑定。
|
||||
port.postMessage({ type: "onetalk.page.binding-status", channelAccountId, error });
|
||||
|
||||
// Correct: Service Worker 按页面账号派生并只发送最小显示投影。
|
||||
runtime.publishBindingStatus(channelAccountId, unbound);
|
||||
\`\`\`
|
||||
|
||||
@@ -22,9 +22,13 @@ const installOneTalkActionStatusTooltip = (pageWindow: Window): void => {};
|
||||
class ConnectionStatusTooltip {
|
||||
update(disconnected: boolean): void;
|
||||
}
|
||||
|
||||
class BindingStatusTooltip {
|
||||
update(unbound: boolean): void;
|
||||
}
|
||||
```
|
||||
|
||||
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件实现在 `src/onetalk/main-page/conversation-id-copy.ts`,ID 读取复用 `readCurrentConversationId(pageWindow)`。动作提示实现在 `src/onetalk/main-page/action-status-tooltip.ts`,并将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`。
|
||||
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件和动作提示的 DOM 实现分别位于 `src/onetalk/main-page/dom/conversation-id-copy.ts` 与 `src/onetalk/main-page/dom/action-status-tooltip.ts`。旧根路径仅保留兼容 re-export,不能再次拥有 DOM API。复制控件的 ID 读取复用 `readCurrentConversationId(pageWindow)`;动作提示将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
@@ -33,6 +37,7 @@ class ConnectionStatusTooltip {
|
||||
- 按钮直接追加到标题节点,使用 `inline-flex` 和 `justify-content: center` 保持同行及文字水平居中;文字为“复制会话 Id”,字体显式为 `12px`,不能依赖会话标题的继承字体。
|
||||
- 点击处理在用户手势中调用 `navigator.clipboard.writeText(conversationId)`;成功显示“已复制”,不可用或 reject 显示“复制失败”,随后恢复按钮文案。不会以 `execCommand`、隐形文本框或其它方式降级复制。
|
||||
- 页面 DOM 尚未就绪或 SPA 重绘时可由 `MutationObserver` 重新尝试挂载;同一页面只能存在一个该属性的按钮。
|
||||
- 非观测页面 DOM 查询、控件创建和更新仅归 `main-page/dom/` 所有;调用方只能使用其语义 API,不能重新取得同一 DOM 权限。
|
||||
- 动作提示以固定定位、`pointer-events: none` 的 extension-owned `data-tmc-action-status-*` DOM 节点呈现,不依赖 OneTalk 的业务 DOM,也不改变宿主布局。多个活动条目按照首次 `start` 的顺序纵向显示。
|
||||
- `start` 对同一 `id` 幂等,不覆盖已有行;`update` 与 `close` 只作用于已存在的 `id`,找不到时返回 `false`,不得隐式创建行。颜色只接受 `neutral`、`info`、`warning`、`error`,任意 CSS 色值必须显式失败。
|
||||
- 状态 facade 必须保留身份,并在再次安装、`start` 或 `update` 时重新挂载所有仍活动的行;宿主移除了 container 或某一行都不能改变状态顺序或丢失活动项。
|
||||
@@ -40,7 +45,8 @@ class ConnectionStatusTooltip {
|
||||
- tooltip facade 或页面 DOM 的异常必须在调用动作提示的本地通知边界内隔离,不能改变 discovery/history 命令结果、重试、消息观察、checkpoint 或持久化。
|
||||
- `onetalk.sync.conversation` 的 `finalAttempt` 仅是页面命令元数据。成功立即结算该会话;失败仅在此标记为 `true` 时结算,第一次可重试失败不得推进显示进度。
|
||||
- `ConnectionStatusTooltip` 只消费已经由 MAIN page bridge 校验的 `disconnected: boolean`,使用固定 id `onetalk-connection-status`。`true` 幂等调用 `tooltip.start(..., "连接已断开,正在尝试重新连接", "warning")`,`false` 只关闭该 id;不得解释 Bright 原始状态或错误。
|
||||
- 断线行与历史 bootstrap 使用不同 id,保留首次插入顺序、关闭和宿主重挂载语义,彼此不能重排或结算。
|
||||
- `BindingStatusTooltip` 只消费已经由 MAIN page bridge 校验的 `unbound: boolean`,使用固定 id `onetalk-binding-status`。`true` 幂等调用 `tooltip.start(..., "当前账号未绑定", "error")`,`false` 只关闭该 id;不得读取配置、页面账号、binding、错误码或 Bright 数据。
|
||||
- 断线、未绑定和历史 bootstrap 使用不同 id,保留首次插入顺序、关闭和宿主重挂载语义,彼此不能重排或结算。未绑定提示只复用 extension-owned 动作提示,不得查询或修改 OneTalk tabs、activeBar、布局或业务 DOM。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
@@ -58,13 +64,16 @@ class ConnectionStatusTooltip {
|
||||
| 传入预设外颜色 | 抛出 `onetalk_action_status_tooltip_color_invalid`,不改变既有行 |
|
||||
| container 或活动行被宿主移除 | 下次安装、`start` 或 `update` 重新按原插入顺序挂载全部活动行 |
|
||||
| 连接提示为 `true` / `false` | 分别只启动 warning 行 / 关闭固定断线行,不影响历史行 |
|
||||
| 未绑定提示为 `true` / `false` | 分别只启动 error 行“当前账号未绑定” / 关闭固定未绑定行,不影响断线或历史行 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:用户选中单一会话,按钮和标题同行;点击后仅把该 DOM `data-cid` 写入剪贴板。
|
||||
- Good:`tooltip.start("collect-history", "正在收集所有对话历史", "info")` 与另一个 `id` 纵向共存,且不会改变页面桥或同步流程。
|
||||
- Good:`BindingStatusTooltip.update(true)` 只创建 extension-owned error 行;false 只关闭该行,断线 warning 与历史行保持原有顺序。
|
||||
- Base:页面首次加载时标题未出现;控件暂不显示,DOM 稳定后再安装。
|
||||
- Bad:读取地址栏 `conversationId`、复制标题文字或多个 selected 节点中的第一个值。
|
||||
- Bad:由 `BindingStatusTooltip` 查询 `.im-next-tabs-bar`、创建 activeBar badge,或解释配置、账号、错误码。
|
||||
- Bad:因为复制失败而创建 bridge frame、写 IndexedDB、上报 Bright 或显示成功状态。
|
||||
- Bad:让调用方传入 `"#ff0000"` 等任意 CSS 色值,或在 `update` 找不到 `id` 时静默创建新状态行。
|
||||
|
||||
@@ -75,7 +84,8 @@ class ConnectionStatusTooltip {
|
||||
- `test/onetalk-action-status-tooltip.test.js` 覆盖同 `id` 去重、多个 `id` 的纵向插入顺序、`update` / `close` 的 boolean 结果、预设颜色和原始 CSS 色拒绝。
|
||||
- 动作提示测试必须覆盖容器或行被外部移除后,通过 `update` 或重复安装恢复同一 facade、全部活动行和原始顺序。
|
||||
- 断线提示测试覆盖与 bootstrap 行并列、重复 `true`、`false` 关闭及容器重挂载。
|
||||
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
|
||||
- 未绑定提示测试覆盖与断线和 bootstrap 行独立共存、重复 `true` 去重、`false` 仅关闭未绑定行及容器重挂载;源码断言它不查询或创建 OneTalk host DOM。
|
||||
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经仓库根 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
@@ -98,3 +108,13 @@ tooltip.start("collect-history", "正在收集所有对话历史", "info");
|
||||
tooltip.update("collect-history", "已收集一半", "warning");
|
||||
tooltip.close("collect-history");
|
||||
```
|
||||
|
||||
```ts
|
||||
// Wrong: UI 层重新解释跨层绑定状态,并侵入 OneTalk 宿主布局。
|
||||
const unbound = readConfig().channelAccountId !== readCurrentPageAccountId();
|
||||
installBadgeIntoActiveBar(unbound);
|
||||
|
||||
// Correct: 只消费 bridge 已校验的最小布尔投影,复用既有 tooltip 生命周期。
|
||||
const bindingStatus = new BindingStatusTooltip(tooltip);
|
||||
bindingStatus.update(unbound);
|
||||
```
|
||||
|
||||
@@ -17,7 +17,7 @@ OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:
|
||||
| --- | --- |
|
||||
| [OneTalk 页面桥、Port 与命令路由](./page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和 command 路由 |
|
||||
| [OneTalk 耐久同步与连接生命周期](./durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint、重启恢复和连接生命周期 |
|
||||
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、v7 清空后重采集 ledger、ACK/HWM/future-skew 和账号/epoch 隔离 |
|
||||
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、pre-v7 清空后重采集、v9 profile scope indexes、v10 rendered-card scope indexes 与统一 buyer facts store、ACK/HWM/future-skew 和账号/epoch 隔离 |
|
||||
| [OneTalk 扩展安装实例设备身份](./device-identity.md) | deviceId 生成、迁移、独立存储、配置清除和生命周期 |
|
||||
| [OneTalk Service Worker 状态与诊断](./runtime-diagnostics.md) | getSnapshot、错误投影、敏感信息脱敏和 development 构建 |
|
||||
| [OneTalk PWA 出站发送 SOP](./send-sop.md) | sendUIMessages 输入、SDK-only 发送和 WebSocket 旁路事实确认 |
|
||||
@@ -36,7 +36,7 @@ OneTalk MAIN world
|
||||
-> Bright WebSocket upload
|
||||
-> per-message ACK
|
||||
|
||||
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker v7 clean-state profile ledger → existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`。
|
||||
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker pre-v7 clean-state / v9 scope-indexed profile ledger and v10 rendered-card scope-indexed ledger(位于统一 schema,另含 buyer facts store)→ existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`。
|
||||
\`\`\`
|
||||
|
||||
服务端命令:
|
||||
@@ -57,7 +57,7 @@ Bright WebSocket
|
||||
## 3. Global ownership
|
||||
|
||||
- MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
|
||||
- bootstrap 的发现/逐会话提示是 MAIN world 内单页 UI 状态:页面入口拥有一个控制器,控制器拥有总数和已结算 ID,通用 tooltip facade 只拥有 DOM 行。发现命令可携带已有 bootstrap marker 的受限 `terminalConversationIds`,控制器只用其与新发现列表的交集预结算。它不是 `historyProgress` 的展示投影,也不进入 observation schema、Service Worker 状态、IndexedDB、Bright 或诊断。
|
||||
- bootstrap 的发现/逐会话提示是 MAIN world 内单页 UI 状态,唯一契约见 [OneTalk 页面增强控件](./page-controls.md);它不是 `historyProgress` 的展示投影,也不进入 observation schema、Service Worker 状态、IndexedDB、Bright 或诊断。
|
||||
- ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
|
||||
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
|
||||
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
|
||||
@@ -94,6 +94,7 @@ Bright WebSocket
|
||||
- `message-observer/send-observation.ts` 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。`image-send.ts` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
|
||||
- profile observer 可将 `syncData` 的 direct profiles 再次发布;它不维护 `seen`、baseline 或其他上传资格状态。snapshot 只读取当前已加载资料,collect 只读取一个明确 conversationId;两者都复用已安装 observer,命令只返回统计,不能再二次 publish。
|
||||
- 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。
|
||||
- `card-observer/entry.ts` 以 MutationObserver 批次作为 rendered-card 的唯一主动触发。`.message-item-wrapper` 只表示 React 已挂载,不表示 Fiber/template 已齐备:首次扫描及该 wrapper 子树的 `childList`、attributes 或 text 变化都必须立即重新读取;只有 reader 成功产生 exact 白名单 observation 后按 `conversationId + messageId + fingerprint` 去重。reader 返回 `null` 不写 bridge/ledger,并等待下一次该 wrapper 的 DOM 变化;不得全局轮询、用 DOM 文案兜底或把 raw Fiber/props 跨 MAIN。
|
||||
|
||||
#### Validation & Error Matrix
|
||||
|
||||
@@ -108,6 +109,8 @@ Bright WebSocket
|
||||
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
|
||||
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
|
||||
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
|
||||
| rendered-card wrapper 先挂载、后补齐 Fiber/template | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
|
||||
| rendered-card reader 仍无完整白名单模板 | 不发布、不写 ledger;仅等待该 wrapper 的下一次子树变化 |
|
||||
|
||||
#### Good / Base / Bad Cases
|
||||
|
||||
@@ -119,6 +122,7 @@ Bright WebSocket
|
||||
|
||||
- `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/collect exact payload、targeted collect、未知动作与实际页面入口安装顺序。
|
||||
- `onetalk-send-page.test.js` 保留 send/history 的输入、结果、tooltip 和发布行为;`onetalk-send-observation.test.js` 与媒体测试保留唯一确认、timeout/异常及不重试断言。
|
||||
- `onetalk-rendered-card-observer.test.js` 覆盖 wrapper 首次读取失败、后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。
|
||||
- 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。
|
||||
|
||||
#### Wrong vs Correct
|
||||
@@ -133,6 +137,16 @@ const sendObservation = createSendObservationCorrelator();
|
||||
const publish = createOneTalkObservedPublisher(sink, sendObservation.observe);
|
||||
```
|
||||
|
||||
### 3.2 Version vocabulary
|
||||
|
||||
各文档提到版本号时必须使用下表名称,不得统一简写为 "version" 或“协议版本”;三者互相独立演进:
|
||||
|
||||
| 版本 | 唯一所有者 | 表达的边界 |
|
||||
| --- | --- | --- |
|
||||
| `protocolVersion=5` | `@trade-message-center/onetalk-contract` | Bright 插件 WebSocket wire frame 协议 |
|
||||
| `ONE_TALK_PAGE_BRIDGE_VERSION = 4` | `src/onetalk/page-bridge/model.ts` | MAIN/ISOLATED/Service Worker 页面消息 envelope |
|
||||
| `content.version=1` | shared page message content contract(page bridge model/decoder) | 消息正文 versioned `text \| image \| file \| business_card \| inquiry \| order` content 结构 |
|
||||
|
||||
## 4. Service Worker transport、frame router 与业务 Flow
|
||||
|
||||
### 4.1 Scope / Trigger
|
||||
@@ -223,7 +237,7 @@ writer.sendSendConfirmation(result);
|
||||
- 页面命令不得广播、跨账号回退或随机选择。
|
||||
- bootstrap 重试仅在 `onetalk.sync.conversation` 命令中携带私有布尔 `finalAttempt`;它只决定页面本地提示何时将失败会话视为终态,不能加入 observation、checkpoint、持久化、Bright frame 或诊断 payload。
|
||||
- onetalk.sync 使用精确的账号/会话页面路由。
|
||||
- onetalk.send 只要求唯一同账号页面;目标会话交给 MAIN 后必须由 SDK input 的 cid 指定,conversationCode 仅为同值兼容字段,当前 selected 会话不作为发送前置条件。
|
||||
- onetalk.send 的页面选择、SDK 输入与确认语义唯一见 [OneTalk PWA 出站发送 SOP](./send-sop.md)。
|
||||
- 页面身份变化、Port 断开和 Service Worker 重启必须让旧 correlation 失效;不得由迟到结果恢复旧请求。
|
||||
- 相同 page identity 的重复 hello 不重复启动 snapshot;profile page/config/connection epoch 失效时不得操作替换后的 engine/coordinator。
|
||||
- Profile 初始 setup 同时受 Bright authenticated、page identity、configuration epoch 和 active coordinator fence 约束;任何 await 后都要重验。ledger read 后认证丢失、页面/账号切换或 coordinator 替换时不得发送 snapshot;失效 claim 不得阻塞下一次有效 setup。
|
||||
@@ -237,11 +251,11 @@ writer.sendSendConfirmation(result);
|
||||
- `authorization_unavailable` 表示授权依赖暂时不可用,只关闭当前 Bright socket 并沿既有连接退避自动重连;只有凭证、授权版本、binding、scope 或协议版本等确定性错误才阻断自动重连并进入 unauthorized。
|
||||
- Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
|
||||
- live `messageType: "new"`(sent 或 received)只触发所属 conversation 的 profile collect;history 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAIN `seen`、时间水位或 timer 再建第二去重状态。
|
||||
- `oldVersion < 7` 的数据库先执行 v7 全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。ACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
|
||||
- `oldVersion < 7` 的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 profile scope index、逐条保留 records;v9→v10 只新增 rendered-card scope indexes 与 buyer facts store、逐条保留既有 records/indexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取 `[channelAccountId, pending.observedAtMs]` index;v10 rendered-card pending flush 只读取 `[channelAccountId, "pending_ack"]`,两者都不得扫描其它账号或 terminal record。ACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
|
||||
|
||||
### Send and protocol boundaries
|
||||
|
||||
- PWA 出站发送只调用已验证的 sendUIMessages({ cid, conversationCode, content, ext });cid 是权威目标字段,conversationCode 必须与 cid 同值。
|
||||
- PWA 出站发送的 SDK 输入、页面路由与旁路关联确认以 [OneTalk PWA 出站发送 SOP](./send-sop.md) 为唯一契约;本节只保留跨层不变量。
|
||||
- SDK 返回的 opId、undefined、页面显示文本和任意普通对象都不是 sent fact。
|
||||
- 发送事实只由 WebSocket 旁路观察到的目标会话、完整、direction: sent 消息证明。
|
||||
- 页面任意 JSON result 必须经过 page-send-outcome adapter;Bright 只接受 canonical OneTalkSendResultPayload。
|
||||
@@ -282,7 +296,12 @@ const resolveDirection = (
|
||||
- self 输入必须来自页面 owner 的 `accountId` 与登录账号一致、以及 owner 的 `aliId` 与 participant 的明确关系;participant 必须是完整的两个、无重复、无空白且形如单一 `@icbu` suffix 的原始值。
|
||||
- participant 的排列只保留页面收到的原始顺序,不表达 self 角色;`senderId` 永远等于原始 `message.sender.uid`。
|
||||
- `senderId === selfParticipant` 才产生 `sent`;sender 是另一个 participant 才产生 `received`;sender 不在集合或 self 无法唯一确定时不写 `direction`。
|
||||
- 不得使用 URL `activeAccountId`、participant 固定位置、时间顺序、`target.id`、`fromAccId`/`toAccId`、将 accountId 与 aliId 直接比较/拼接,或 UI 文本猜测 self。
|
||||
- self 判定禁止使用以下任何替代来源:
|
||||
- URL `activeAccountId`,或 participant 的固定位置;
|
||||
- 消息时间顺序;
|
||||
- `target.id` 或 `fromAccId`/`toAccId`;
|
||||
- accountId 与 aliId 的直接比较或拼接;
|
||||
- UI 文本猜测。
|
||||
- self participant 只存在于 MAIN 函数参数/内存上下文,不进入 `ObservedOneTalkMessage`、Page Bridge、Bright wire、IndexedDB 或 server schema。
|
||||
|
||||
### 6.4 Validation & Error Matrix
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- `module` 和 `moduleResolution` 都是 `NodeNext`;
|
||||
- `forceConsistentCasingInFileNames: true`;
|
||||
- `resolveJsonModule: true`;
|
||||
- `skipLibCheck: true`。
|
||||
- `skipLibCheck: true`;
|
||||
- 扩展覆盖为 `module: ESNext`、`moduleResolution: Bundler`、`lib: [ES2022, DOM]`、`types: []` 和 `noEmit: true`。
|
||||
|
||||
`tsconfig.json` 的 `include` 必须覆盖 `src/`、`popup/` 和 `vite.config.ts`,确保业务模块、通用能力和 UI 入口都经过同一套严格检查;不要创建更宽松的局部配置绕过错误。
|
||||
|
||||
@@ -1,122 +1,88 @@
|
||||
# Code Reuse Thinking Guide
|
||||
# 代码复用思维指南
|
||||
|
||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
||||
> 写新代码前先确认它是否已经存在。本指南是思考清单;正典归属规则见 [模块组织](../project/module-organization.md)。
|
||||
|
||||
---
|
||||
## 为什么复用重要
|
||||
|
||||
## The Problem
|
||||
复制或重写既有逻辑时,bug 修复不会传播、行为随时间分叉、代码库更难理解。重复代码是不一致 bug 的首要来源。
|
||||
|
||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
||||
## 写新代码之前
|
||||
|
||||
When you copy-paste or rewrite existing logic:
|
||||
- Bug fixes don't propagate
|
||||
- Behavior diverges over time
|
||||
- Codebase becomes harder to understand
|
||||
|
||||
---
|
||||
|
||||
## Before Writing New Code
|
||||
|
||||
### Step 1: Search First
|
||||
先搜索:
|
||||
|
||||
```bash
|
||||
# Search for similar function names
|
||||
# 搜索相似的函数名
|
||||
grep -r "functionName" .
|
||||
|
||||
# Search for similar logic
|
||||
# 搜索相似的逻辑
|
||||
grep -r "keyword" .
|
||||
```
|
||||
|
||||
### Step 2: Ask These Questions
|
||||
再回答:
|
||||
|
||||
| Question | If Yes... |
|
||||
|----------|-----------|
|
||||
| Does a similar function exist? | Use or extend it |
|
||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
||||
| Could this be a shared utility? | Create it in the right place |
|
||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
||||
| 问题 | 如果是 |
|
||||
| --- | --- |
|
||||
| 已有相似函数? | 使用或扩展它 |
|
||||
| 该模式在别处出现过? | 沿用既有模式 |
|
||||
| 可以成为共享工具? | 放到正确位置创建 |
|
||||
| 正在从别的文件复制代码? | 停下,抽取为共享实现 |
|
||||
|
||||
---
|
||||
## 常见重复模式
|
||||
|
||||
## Common Duplication Patterns
|
||||
1. **复制的函数**:不要把校验函数复制到另一个文件;抽取到共享位置,按需导入。
|
||||
2. **相似组件**:不要新建 80% 相同的组件;用 props/变体扩展现有组件。
|
||||
3. **重复常量**:常量只定义一处,其余位置导入。
|
||||
4. **重复的 payload 字段提取**:多个消费者各自 cast 同一 JSON/event 字段,即使只有两行也是重复的契约逻辑——每个消费者都私有定义了“合法 payload 是什么”:
|
||||
|
||||
### Pattern 1: Copy-Paste Functions
|
||||
```typescript
|
||||
const description = (ev as { description?: string }).description;
|
||||
const context = (ev as { context?: ContextEntry[] }).context;
|
||||
```
|
||||
|
||||
**Bad**: Copying a validation function to another file
|
||||
把 decoder、类型守卫或投影放在数据 owner 旁边:
|
||||
|
||||
**Good**: Extract to shared utilities, import where needed
|
||||
```typescript
|
||||
if (isThreadEvent(ev)) {
|
||||
renderThreadEvent(ev);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Similar Components
|
||||
同一未定型 payload 字段被 2 处以上读取时,先建共享守卫/规范化/投影,再允许第三个读者。
|
||||
|
||||
**Bad**: Creating a new component that's 80% similar to existing
|
||||
## 何时抽象
|
||||
|
||||
**Good**: Extend existing component with props/variants
|
||||
**抽象**:
|
||||
|
||||
### Pattern 3: Repeated Constants
|
||||
- 重复物是契约(payload 字段形状、类型守卫、规范化、错误码、reducer 转移):出现第二个真实消费者就收敛,见上方模式 4 与跨层指南的错误 4。
|
||||
- 重复物是普通实现逻辑:出现 3 次以上(rule of three)。
|
||||
- 逻辑复杂到两份拷贝会各自产生 bug。
|
||||
|
||||
**Bad**: Defining the same constant in multiple files
|
||||
**不抽象**:
|
||||
|
||||
**Good**: Single source of truth, import everywhere
|
||||
- 只有一个消费者且不是契约。领域守卫即使只有一个消费者也是契约——它直接放在所有者处,由消费者导入(见 [module-organization](../project/module-organization.md))。
|
||||
- 平凡的一行代码。
|
||||
- 抽象后比重复更复杂。
|
||||
- 只是“将来可能有人用”——假设性复用不构成提升理由;位置永远按 module-organization 的最近共同所有者规则。
|
||||
|
||||
### Pattern 4: Repeated Payload Field Extraction
|
||||
## 批量修改之后
|
||||
|
||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
||||
对多个文件做了相似修改后:
|
||||
|
||||
1. 复查是否覆盖全部实例。
|
||||
2. grep 搜索遗漏。
|
||||
3. 考虑是否应该抽象。
|
||||
|
||||
### 状态转移使用穷举结构
|
||||
|
||||
状态由 `action`/`kind`/`status`/`phase` 这类值派生时,优先用一个 `switch` reducer,而不是分散的 `if/else` 更新:
|
||||
|
||||
```typescript
|
||||
const description = (ev as { description?: string }).description;
|
||||
const context = (ev as { context?: ContextEntry[] }).context;
|
||||
```
|
||||
|
||||
This is duplicated contract logic even when the code is only two lines. Each
|
||||
consumer now has its own definition of what a valid payload means.
|
||||
|
||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
||||
|
||||
```typescript
|
||||
if (isThreadEvent(ev)) {
|
||||
renderThreadEvent(ev);
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
||||
shared type guard / normalizer / projection before adding a third reader.
|
||||
|
||||
---
|
||||
|
||||
## When to Abstract
|
||||
|
||||
**Abstract when**:
|
||||
- Same code appears 3+ times
|
||||
- Logic is complex enough to have bugs
|
||||
- Multiple people might need this
|
||||
|
||||
**Don't abstract when**:
|
||||
- Only used once
|
||||
- Trivial one-liner
|
||||
- Abstraction would be more complex than duplication
|
||||
|
||||
---
|
||||
|
||||
## After Batch Modifications
|
||||
|
||||
When you've made similar changes to multiple files:
|
||||
|
||||
1. **Review**: Did you catch all instances?
|
||||
2. **Search**: Run grep to find any missed
|
||||
3. **Consider**: Should this be abstracted?
|
||||
|
||||
### Reducers Should Use Exhaustive Structure
|
||||
|
||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
||||
|
||||
```typescript
|
||||
// BAD - action-specific state transitions are hard to audit
|
||||
// 差:按 action 分散的状态转移难以审计
|
||||
if (action === "opened") { ... }
|
||||
else if (action === "comment") { ... }
|
||||
else if (action === "status") { ... }
|
||||
|
||||
// GOOD - one reducer owns the transition table
|
||||
// 好:一个 reducer 拥有整张转移表
|
||||
switch (event.action) {
|
||||
case "opened":
|
||||
...
|
||||
@@ -127,97 +93,13 @@ switch (event.action) {
|
||||
}
|
||||
```
|
||||
|
||||
This matters when the event log is the source of truth. A reducer is the
|
||||
documented replay model; display code and commands should not duplicate pieces
|
||||
of that replay model.
|
||||
事件日志是事实源时尤其重要:reducer 是成文的回放模型,展示代码和命令不得复制回放模型的片段。
|
||||
|
||||
---
|
||||
## 提交前检查
|
||||
|
||||
## Checklist Before Commit
|
||||
|
||||
- [ ] Searched for existing similar code
|
||||
- [ ] No copy-pasted logic that should be shared
|
||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
||||
- [ ] Constants defined in one place
|
||||
- [ ] Similar patterns follow same structure
|
||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Python if/elif/else Exhaustive Check
|
||||
|
||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
||||
|
||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
||||
|
||||
**Example** (`cli_adapter.py`):
|
||||
```python
|
||||
# BAD: "gemini" falls through to else, returns "claude"
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
else:
|
||||
return "claude" # gemini silently gets "claude"!
|
||||
|
||||
# GOOD: explicit branch for every platform
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
else:
|
||||
return "claude"
|
||||
```
|
||||
|
||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
||||
|
||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
||||
|
||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
||||
|
||||
**Prevention**:
|
||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
||||
|
||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
||||
|
||||
---
|
||||
|
||||
## Template File Registration (Trellis-specific)
|
||||
|
||||
When adding new files to `src/templates/trellis/scripts/`:
|
||||
|
||||
**Single registration point**: `src/templates/trellis/index.ts`
|
||||
|
||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
||||
2. Add to `getAllScripts()` Map
|
||||
|
||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
||||
|
||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
||||
|
||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
||||
|
||||
### Quick Checklist for New Scripts
|
||||
|
||||
```bash
|
||||
# After adding a new .py file, verify it's in getAllScripts():
|
||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
||||
```
|
||||
|
||||
### Template Sync Convention
|
||||
|
||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
||||
|
||||
```bash
|
||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
||||
```
|
||||
|
||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
||||
- [ ] 已搜索既有相似代码
|
||||
- [ ] 没有应当共享的复制粘贴逻辑
|
||||
- [ ] 共享 decoder 之外没有重复的未定型 payload 字段提取
|
||||
- [ ] 常量只定义在一处
|
||||
- [ ] 相似模式遵循相同结构
|
||||
- [ ] reducer/action 转移集中在一个 reducer 或命令分发器中
|
||||
|
||||
@@ -1,327 +1,96 @@
|
||||
# Cross-Layer Thinking Guide
|
||||
# 跨层思维指南
|
||||
|
||||
> **Purpose**: Think through data flow across layers before implementing.
|
||||
> 实现前先梳理数据在层间的流动。本指南是思考清单,不替代各层正典规范。
|
||||
|
||||
---
|
||||
## 为什么跨层容易出 bug
|
||||
|
||||
## The Problem
|
||||
多数 bug 发生在层边界而不是层内部:
|
||||
|
||||
**Most bugs happen at layer boundaries**, not within layers.
|
||||
- API 返回格式 A,前端按格式 B 解析。
|
||||
- 数据库存 X,Service 转成 Y 时丢失字段。
|
||||
- 多层各自实现同一逻辑,行为逐渐分叉。
|
||||
|
||||
Common cross-layer bugs:
|
||||
## 实现跨层功能之前
|
||||
|
||||
- API returns format A, frontend expects format B
|
||||
- Database stores X, service transforms to Y, but loses data
|
||||
- Multiple layers implement the same logic differently
|
||||
|
||||
---
|
||||
|
||||
## Before Implementing Cross-Layer Features
|
||||
|
||||
### Step 1: Map the Data Flow
|
||||
|
||||
Draw out how data moves:
|
||||
### 第一步:画出数据流
|
||||
|
||||
```
|
||||
Source → Transform → Store → Retrieve → Transform → Display
|
||||
```
|
||||
|
||||
For each arrow, ask:
|
||||
对每个箭头问三个问题:数据在这里是什么格式?可能出什么错?谁负责校验?
|
||||
|
||||
- What format is the data in?
|
||||
- What could go wrong?
|
||||
- Who is responsible for validation?
|
||||
### 第二步:识别边界
|
||||
|
||||
### Step 2: Identify Boundaries
|
||||
| 边界 | 常见问题 |
|
||||
| --- | --- |
|
||||
| API ↔ Service | 类型不匹配、字段缺失 |
|
||||
| Service ↔ Database | 格式转换、null 处理 |
|
||||
| 后端 ↔ 前端 | 序列化、日期格式 |
|
||||
| 组件 ↔ 组件 | props 形状变化 |
|
||||
|
||||
| Boundary | Common Issues |
|
||||
| --------------------- | --------------------------------- |
|
||||
| API ↔ Service | Type mismatches, missing fields |
|
||||
| Service ↔ Database | Format conversions, null handling |
|
||||
| Backend ↔ Frontend | Serialization, date formats |
|
||||
| Component ↔ Component | Props shape changes |
|
||||
### 第三步:定义契约
|
||||
|
||||
### Step 3: Define Contracts
|
||||
对每个边界明确:输入的精确格式、输出的精确格式、可能发生的错误。
|
||||
|
||||
For each boundary:
|
||||
## 常见跨层错误
|
||||
|
||||
- What is the exact input format?
|
||||
- What is the exact output format?
|
||||
- What errors can occur?
|
||||
1. **隐式格式假设**:不检查就假设日期或字段格式;在边界处显式转换。
|
||||
2. **分散校验**:同一件事在多层各校验一遍,规则逐渐漂移;在入口校验一次。
|
||||
3. **泄漏的抽象**:组件知道数据库 schema;每层只认识相邻层。
|
||||
4. **每个消费者自己解析 payload**:消费代码对原始事件/消息逐字段 inline cast:
|
||||
|
||||
---
|
||||
```typescript
|
||||
const thread = (ev as { thread?: string }).thread;
|
||||
const labels = (ev as { labels?: string[] }).labels;
|
||||
```
|
||||
|
||||
## Common Cross-Layer Mistakes
|
||||
看似局部,实际意味着每个消费者私有维护一份事件契约,下一次字段变更只会改到其中一个命令。在事件边界解码一次,导出定型投影:
|
||||
|
||||
### Mistake 1: Implicit Format Assumptions
|
||||
```typescript
|
||||
if (!isThreadEvent(ev)) return false;
|
||||
return ev.thread === filter.thread;
|
||||
```
|
||||
|
||||
**Bad**: Assuming date format without checking
|
||||
对 append-only 日志、JSON 流、RPC payload 或配置文件,为以下内容各建唯一 owner:事件/payload 类型定义;从 `unknown` 出发的类型守卫与规范化;UI 命令使用的元数据投影;从事实源回放状态的 reducer。渲染代码可以格式化字段,但不得重新定义 payload 契约。
|
||||
|
||||
**Good**: Explicit format conversion at boundaries
|
||||
2 读者的阈值针对契约所有权而非提升:共享 owner 放在真实消费者的最近共同父目录(见 [module-organization](../project/module-organization.md)),不是包级 utils。
|
||||
|
||||
### Mistake 2: Scattered Validation
|
||||
## 跨层功能检查单
|
||||
|
||||
**Bad**: Validating the same thing in multiple layers
|
||||
实现前:
|
||||
|
||||
**Good**: Validate once at the entry point
|
||||
- [ ] 画出了完整数据流
|
||||
- [ ] 识别了全部层边界
|
||||
- [ ] 定义了每个边界的格式
|
||||
- [ ] 决定了校验发生的位置
|
||||
|
||||
### Mistake 3: Leaky Abstractions
|
||||
实现后:
|
||||
|
||||
**Bad**: Component knows about database schema
|
||||
- [ ] 用边界情况测试(null、空、非法)
|
||||
- [ ] 验证了每个边界的错误处理
|
||||
- [ ] 确认数据可以完整往返
|
||||
- [ ] 确认消费者导入共享 decoder/投影,而不是局部 cast payload 字段
|
||||
- [ ] 确认派生状态回指来源事件标识(`seq`/`id`/`version`),而不是发明第二个 cursor
|
||||
|
||||
**Good**: Each layer only knows its neighbors
|
||||
## 何时写流程文档
|
||||
|
||||
### Mistake 4: Every Consumer Parses The Same Payload
|
||||
满足任一条件时创建详细流程文档:功能跨 3 层以上;多个团队参与;数据格式复杂;该功能曾出过 bug。
|
||||
|
||||
**Bad**: A command reads JSONL events and casts fields inline:
|
||||
## 事件日志与投影边界
|
||||
|
||||
```typescript
|
||||
const thread = (ev as { thread?: string }).thread;
|
||||
const labels = (ev as { labels?: string[] }).labels;
|
||||
```
|
||||
|
||||
This looks local, but it means every consumer owns a private version of the
|
||||
event contract. The next field change will update one command and miss another.
|
||||
|
||||
**Good**: Decode once at the event boundary, then export typed projections:
|
||||
|
||||
```typescript
|
||||
if (!isThreadEvent(ev)) return false;
|
||||
return ev.thread === filter.thread;
|
||||
```
|
||||
|
||||
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
|
||||
create one owner for:
|
||||
|
||||
- event / payload type definitions
|
||||
- type guards and normalization from `unknown`
|
||||
- metadata projections used by UI commands
|
||||
- reducers that replay state from the source of truth
|
||||
|
||||
Rendering code may format fields, but it must not redefine the payload contract.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Cross-Layer Features
|
||||
|
||||
Before implementation:
|
||||
|
||||
- [ ] Mapped the complete data flow
|
||||
- [ ] Identified all layer boundaries
|
||||
- [ ] Defined format at each boundary
|
||||
- [ ] Decided where validation happens
|
||||
|
||||
After implementation:
|
||||
|
||||
- [ ] Tested with edge cases (null, empty, invalid)
|
||||
- [ ] Verified error handling at each boundary
|
||||
- [ ] Checked data survives round-trip
|
||||
- [ ] Checked that consumers import shared decoders / projections instead of
|
||||
casting payload fields locally
|
||||
- [ ] Checked that derived state points back to the source event identifier
|
||||
(`seq`, `id`, `version`) instead of inventing a second cursor
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
---
|
||||
|
||||
## Versioned Documentation Boundary
|
||||
|
||||
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
|
||||
version routing, and the rendered version selector must all describe the same
|
||||
release line.
|
||||
|
||||
### Checklist: Before Editing Versioned Docs
|
||||
|
||||
- [ ] Identify the target release line: stable, beta, or RC
|
||||
- [ ] Verify the edited MDX path matches that line:
|
||||
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
|
||||
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
|
||||
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
|
||||
- [ ] Verify `docs.json` navigation points the version label to the same paths
|
||||
- [ ] Grep the opposite tree for release-line-specific terms before committing
|
||||
- [ ] Treat beta content appearing under root release paths as a source-path bug,
|
||||
not a rendering bug
|
||||
|
||||
**Real-world example**: A beta-only task workflow change documented
|
||||
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
|
||||
mode banners under root `start/` and `advanced/` paths. The docs site then
|
||||
served 0.6 beta behavior under the Release selector. The fix was to restore root
|
||||
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
|
||||
audit for beta markers against the root release tree.
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## When to Create Flow Documentation
|
||||
|
||||
Create detailed flow docs when:
|
||||
|
||||
- Feature spans 3+ layers
|
||||
- Multiple teams are involved
|
||||
- Data format is complex
|
||||
- Feature has caused bugs before
|
||||
|
||||
---
|
||||
|
||||
## Event Log / Projection Boundary
|
||||
|
||||
Append-only logs are cross-layer contracts. A single event travels through:
|
||||
append-only 日志是跨层契约。一条事件经过:
|
||||
|
||||
```
|
||||
CLI input → event writer → events.jsonl → reader → filter → reducer → display
|
||||
页面观测 / 外部输入 → event writer → durable store (IndexedDB / PostgreSQL) → reader / router → filter → reducer / projection → display
|
||||
```
|
||||
|
||||
### Checklist: After Adding A New Event Kind Or Field
|
||||
新增 event kind 或字段之后:
|
||||
|
||||
- [ ] Add the event kind to the central event taxonomy
|
||||
- [ ] Add a typed event variant or type guard at the event layer
|
||||
- [ ] Add normalization helpers for array/object fields that come from
|
||||
user input or JSON
|
||||
- [ ] Keep `seq` / `id` assignment in the event writer only
|
||||
- [ ] Make filters and reducers consume the typed event guard, not local casts
|
||||
- [ ] Make display code consume reducer output or typed events, not raw JSON
|
||||
- [ ] Add at least one regression that proves history replay and live filtering
|
||||
use the same filter model
|
||||
|
||||
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
|
||||
`context`, labels, and `lastSeq`. The first implementation replayed thread
|
||||
state correctly, but several commands still re-parsed event payload fields with
|
||||
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
|
||||
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
|
||||
projection, and make `reduceThreads` the only thread replay reducer.
|
||||
- [ ] 把 kind 加入中央事件分类表
|
||||
- [ ] 在事件层添加定型事件变体或类型守卫
|
||||
- [ ] 为来自用户输入或 JSON 的数组/对象字段添加规范化辅助
|
||||
- [ ] `seq`/`id` 的分配只保留在 event writer
|
||||
- [ ] filter 和 reducer 消费定型事件守卫,不做局部 cast
|
||||
- [ ] 展示代码消费 reducer 输出或定型事件,不消费原始 JSON
|
||||
- [ ] 至少一条回归证明历史回放与实时过滤使用同一过滤模型
|
||||
|
||||
@@ -1,99 +1,55 @@
|
||||
# Thinking Guides
|
||||
# 思维指南
|
||||
|
||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
||||
> 本目录只提供编码前的思考清单,不重复代码归属和文件职责规则;正典规则见 [项目级开发规范](../project/index.md)。
|
||||
|
||||
项目内任何编码任务都必须先读取 [项目级开发规范](../project/index.md)。本目录只提供思考清单,不重复代码归属和文件职责规则。
|
||||
## 可用指南
|
||||
|
||||
---
|
||||
| 指南 | 用途 | 使用时机 |
|
||||
| --- | --- | --- |
|
||||
| [代码复用思维指南](./code-reuse-thinking-guide.md) | 识别重复模式并收敛 | 发现相似代码、修改常量或配置、新建工具函数 |
|
||||
| [跨层思维指南](./cross-layer-thinking-guide.md) | 梳理跨层数据流 | 功能跨 3 层以上、数据格式跨层变化、新增事件/RPC/配置字段 |
|
||||
|
||||
## Why Thinking Guides?
|
||||
## 何时考虑代码复用
|
||||
|
||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
||||
- 正在编写与现有代码相似的逻辑,或同一模式已出现 3 次以上。
|
||||
- 正在多处新增同一字段,或修改任何常量/配置(先搜索全部引用)。
|
||||
- 正在新建工具/辅助函数(先确认是否已存在)。
|
||||
- 两个文件各自用局部 cast 读取同一个未定型 payload 字段。
|
||||
- 多个分支从 `kind`/`action` 更新同一份派生状态。
|
||||
|
||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
||||
- Didn't think about edge cases → runtime errors
|
||||
- Didn't think about future maintainers → unreadable code
|
||||
→ 阅读 [代码复用思维指南](./code-reuse-thinking-guide.md)
|
||||
|
||||
These guides help you **ask the right questions before coding**.
|
||||
## 何时考虑跨层问题
|
||||
|
||||
---
|
||||
- 功能涉及 3 层以上(API、Service、组件、数据库)。
|
||||
- 数据格式在层间发生变化,或多个消费者需要同一数据。
|
||||
- 正在新增 event kind、JSONL 记录、RPC payload 或配置字段。
|
||||
- UI 或命令代码开始直接 cast 原始 payload 字段。
|
||||
- 不确定某段逻辑应该放在哪一层。
|
||||
|
||||
## Available Guides
|
||||
→ 阅读 [跨层思维指南](./cross-layer-thinking-guide.md)
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
|-------|---------|-------------|
|
||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
||||
## 修改前的固定动作
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Thinking Triggers
|
||||
|
||||
### When to Think About Cross-Layer Issues
|
||||
|
||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
||||
- [ ] UI / command code starts casting raw payload fields directly
|
||||
|
||||
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
|
||||
|
||||
### When to Think About Code Reuse
|
||||
|
||||
- [ ] You're writing similar code to something that exists
|
||||
- [ ] You see the same pattern repeated 3+ times
|
||||
- [ ] You're adding a new field to multiple places
|
||||
- [ ] **You're modifying any constant or config**
|
||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
||||
- [ ] Two files read the same untyped payload field with local casts
|
||||
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
||||
|
||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||
|
||||
### When Verifying AI Cross-Review Results
|
||||
|
||||
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
||||
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
||||
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
||||
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
||||
|
||||
**Common AI reviewer false-positive patterns**:
|
||||
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
||||
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
||||
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
||||
|
||||
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
修改任何值之前,先搜索它的全部引用:
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
grep -r "value_to_change" .
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
这一个习惯能避免大多数“改了 A 忘了改 B”的问题。
|
||||
|
||||
---
|
||||
## 核对 AI 评审结论
|
||||
|
||||
## How to Use This Directory
|
||||
AI 评审的每条 CRITICAL/WARNING 结论必须对照真实代码核实后才能排入优先级;AI 评审存在不可忽略的误报率。常见误报模式:
|
||||
|
||||
1. **Before coding**: Skim the relevant thinking guide
|
||||
2. **During coding**: If something feels repetitive or complex, check the guides
|
||||
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
|
||||
1. **信任边界误判**:把内部数据(打包的 JSON manifest 等)当作不可信外部输入。
|
||||
2. **忽略设计注释**:把代码注释中已说明的既定行为当作 bug。
|
||||
3. **变量误读**:没有追溯变量的真实定义(例如 Map 按路径还是按名称为键)。
|
||||
|
||||
---
|
||||
核对方法:
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it to the relevant guide.
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
- 评审称“用户输入可能恶意”→ 确认数据实际来源(内部 manifest、用户配置还是外部 API)。
|
||||
- 评审称“缺少校验”→ 确认数据是否来自可信内部来源。
|
||||
- 评审称“行为变更”→ 读代码注释,确认是否既定设计。
|
||||
- 评审称测试存在“bug”→ 心理上删除被测功能:若测试仍通过,则是同义反复测试。
|
||||
|
||||
@@ -14,11 +14,13 @@ MIND_MOCK_PORT=8787
|
||||
MIND_TEST_HARNESS_HOST=127.0.0.1
|
||||
MIND_TEST_HARNESS_PORT=8788
|
||||
MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
|
||||
MIND_MOCK_CHANNEL_ACCOUNT_ID=243340382,286995452
|
||||
```
|
||||
|
||||
- `src/entry.ts` 必须启动 Mind 授权模拟监听器和 Bright 联调页监听器。
|
||||
- `pnpm dev` 不得启动、等待或依赖该包;只有显式 `pnpm dev:harness` 才运行它。
|
||||
- 联调页使用 `MIND_TEST_HARNESS_BRIGHT_BASE_URL` 请求 Bright HTTP 和 WS;不能再由 `apps/server` 的 `/harness` 路由提供。
|
||||
- `MIND_MOCK_CHANNEL_ACCOUNT_ID` 使用逗号分隔的非空唯一账号列表;Mind mock 对列表内账号授权,并在成功响应中回显本次请求的单个 `channelAccountId`。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
@@ -34,6 +36,7 @@ MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
|
||||
| 条件 | 结果 |
|
||||
| --- | --- |
|
||||
| `MIND_TEST_HARNESS_PORT` 或 `MIND_MOCK_PORT` 非 0–65535 整数 | 启动前抛出对应环境变量错误 |
|
||||
| `MIND_MOCK_CHANNEL_ACCOUNT_ID` 含空项或重复账号 | 启动前抛出对应环境变量错误 |
|
||||
| Bright base URL 不是精确 HTTP/HTTPS origin | 启动前抛出 `Invalid MIND_TEST_HARNESS_BRIGHT_BASE_URL` |
|
||||
| 监听端口已被占用 | 启动失败并关闭已打开的另一监听器 |
|
||||
| 非 `GET /` 请求联调页监听器 | 返回 404 |
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
|
||||
## 2. 主题导航
|
||||
|
||||
| 主题 | Canonical 文档 | 旧章节映射 | 负责内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 模块组织 | [module-organization.md](./module-organization.md) | Signatures / Roles、3.1–3.8;原 4–7 中模块相关规则、案例、测试和反例 | 文件角色、功能聚合、共享层级、上下文适配、职责和拆分时机 |
|
||||
| 类型所有权与模块出口 | [module-ownership.md](./module-ownership.md) | 3.10;原 4–7 中类型出口相关规则、案例、测试和反例 | 类型唯一所有者、canonical import path、公共 facade 与 re-export |
|
||||
| 异步流程与状态管理 | [async-state-boundaries.md](./async-state-boundaries.md) | 新增项目约束 | 状态唯一 owner、等待后重新确认、一次性完成和安全重构 |
|
||||
| 源文件约定 | [source-file-conventions.md](./source-file-conventions.md) | 3.9、3.12;原 4–7 中文件排列和函数风格相关规则、案例、测试和反例 | bottom-up、main-last、文件头/主函数注释、箭头函数和 `fail` 收窄 |
|
||||
| 格式化 | [formatting.md](./formatting.md) | 3.11;原 4–7 中格式化相关规则、案例和测试 | Oxfmt、VSCode 保存、`.editorconfig` 与提交/CI 一致性 |
|
||||
| 缺失值与默认值 | [missing-values.md](./missing-values.md) | 3.13 全部七段;原 4–7 中缺失值相关规则、案例、测试和反例 | 缺失值补偿、`BUILD_HASH` 单一生成点和边界错误契约 |
|
||||
| 结构化值等价性 | [structured-value-equality.md](./structured-value-equality.md) | 新增项目约束 | 事实等价、快照/集合/序列关系与序列化边界 |
|
||||
| 数据库查询组合 | [database-query-composition.md](./database-query-composition.md) | 新增项目约束 | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 |
|
||||
| 主题 | Canonical 文档 | 负责内容 |
|
||||
| --- | --- | --- |
|
||||
| 模块组织 | [module-organization.md](./module-organization.md) | 文件角色、功能聚合、共享层级、上下文适配、职责和拆分时机 |
|
||||
| 类型所有权与模块出口 | [module-ownership.md](./module-ownership.md) | 类型唯一所有者、canonical import path、公共 facade 与 re-export |
|
||||
| 异步流程与状态管理 | [async-state-boundaries.md](./async-state-boundaries.md) | 状态唯一 owner、等待后重新确认、一次性完成和安全重构 |
|
||||
| 源文件约定 | [source-file-conventions.md](./source-file-conventions.md) | bottom-up、main-last、文件头/主函数注释、箭头函数和 `fail` 收窄 |
|
||||
| 格式化 | [formatting.md](./formatting.md) | Oxfmt、VSCode 保存、`.editorconfig` 与提交/CI 一致性 |
|
||||
| 缺失值与默认值 | [missing-values.md](./missing-values.md) | 缺失值补偿、`BUILD_HASH` 单一生成点和边界错误契约 |
|
||||
| 结构化值等价性 | [structured-value-equality.md](./structured-value-equality.md) | 事实等价、快照/集合/序列关系与序列化边界 |
|
||||
| 数据库查询组合 | [database-query-composition.md](./database-query-composition.md) | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 |
|
||||
|
||||
## 3. 阅读顺序
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ package/context/
|
||||
|
||||
### 3.2–3.5 共享层级、原语与领域语义
|
||||
|
||||
先判断参数、返回值和行为是否含领域语义,再找真实/已确定消费者,选择覆盖它们的最近共同所有者,最后检查反向或循环依赖。语言级基础原语放包内共享基础目录;上下文共享领域概念放上下文根 `model.ts`;单功能契约放功能 `model.ts`;纯无副作用辅助才放最近公共 `utils.ts`。不得因可能复用就提升到包级或跨包引用内部实现。
|
||||
先判断参数、返回值和行为是否含领域语义,再找真实/已确定消费者,选择覆盖它们的最接近共同所有者,最后检查反向或循环依赖。语言级基础原语放包内共享基础目录;上下文共享领域概念放上下文根 `model.ts`;单功能契约放功能 `model.ts`;纯无副作用辅助才放最近公共 `utils.ts`。不得因可能复用就提升到包级或跨包引用内部实现。
|
||||
|
||||
| 概念范围 | 归属 | 示例 |
|
||||
| --- | --- | --- |
|
||||
@@ -58,6 +58,12 @@ package/context/
|
||||
|
||||
归属判断顺序固定为:判断参数/返回值/行为是否含领域语义;找真实或已确定消费者;选择最近共同所有者;检查提升后是否产生反向或循环依赖。
|
||||
|
||||
合并与归属是两个独立判断:
|
||||
|
||||
- 重复物是契约(payload 字段形状、类型守卫、规范化、错误码、状态转移表)时,出现第二个真实消费者就必须收敛到唯一所有者,不等第三次:两份定义已经是第二事实源。
|
||||
- 重复物是无契约语义的实现细节时,等到第三次出现、或逻辑复杂到两份拷贝会各自漂移再合并。
|
||||
- 两种情况的所有者位置都按上文最近共同所有者规则选择;两个功能共享放共同父目录,不是包级。“不得因可能复用就提升”禁止的是只有一个消费者时的预防性提升,不禁止已出现真实重复后的收敛。
|
||||
|
||||
```ts
|
||||
// src/lib/error.ts
|
||||
export const fail = (message: string): never => { throw new Error(message); };
|
||||
@@ -70,8 +76,8 @@ fail(featureErrorCodes.invalidPage);
|
||||
```ts
|
||||
export type OneTalkAccountId = string | number;
|
||||
export const isAccountId = (value: unknown): value is OneTalkAccountId =>
|
||||
(typeof value === "string" && value.length > 0) ||
|
||||
(typeof value === "number" && Number.isFinite(value));
|
||||
(typeof value === "string" && value.length > 0) ||
|
||||
(typeof value === "number" && Number.isFinite(value));
|
||||
```
|
||||
|
||||
基础 URL 形状判断不理解渠道,属于基础设施;账号 ID 守卫属于渠道领域;功能错误码属于功能契约。函数归属依据契约、语义和依赖范围,不依据名字、长度或当前调用次数。
|
||||
@@ -85,11 +91,11 @@ export const isAccountId = (value: unknown): value is OneTalkAccountId =>
|
||||
```ts
|
||||
type ContextHost = { location: Pick<Location, "href"> };
|
||||
export const readContextKey = (host: ContextHost): string | null => {
|
||||
try { return new URL(host.location.href).searchParams.get("contextKey"); }
|
||||
catch { return null; }
|
||||
try { return new URL(host.location.href).searchParams.get("contextKey"); }
|
||||
catch { return null; }
|
||||
};
|
||||
export const isSameContext = (host: ContextHost, expected: string): boolean =>
|
||||
readContextKey(host) === expected;
|
||||
readContextKey(host) === expected;
|
||||
if (!isSameContext(host, contextKey)) return fail(featureErrorCodes.contextChanged);
|
||||
```
|
||||
|
||||
@@ -104,273 +110,6 @@ if (!isSameContext(host, contextKey)) return fail(featureErrorCodes.contextChang
|
||||
|
||||
满足任一条件应拆文件/建立功能目录:不同需求独立修改的分支;入口开始读取分支内部字段;两个分支共享领域契约/转换;重要概念藏在 `utils.ts`/入口;功能文件挤占父目录使调用链和归属不可见。不要仅因函数长、文件少或预期未来扩展而拆分。
|
||||
|
||||
### 3.9 有状态同步协调器的职责拆分
|
||||
|
||||
#### 1. Scope / Trigger
|
||||
|
||||
当一个工厂函数或 class 同时拥有多个异步流程、多个可变状态集合和明确生命周期时,按职责拆成组件。典型触发信号包括:同一实现同时处理连接状态、门禁、事件订阅、队列、持久化状态机、重试和 ACK;一个事件需要更新三类以上互相独立的状态;或入口文件已经无法从字段名直接看出状态的唯一 owner。
|
||||
|
||||
这不是“看到文件很长就拆文件”,也不是把同一批函数机械搬到多个文件。拆分依据是变化原因和状态所有权:会因连接生命周期变化的状态放生命周期组件,会因候选投递变化的状态放 ACK 组件,始终一起变化的状态保留在同一组件。
|
||||
|
||||
#### 2. Signatures
|
||||
|
||||
公开接口保持稳定,由 facade 负责组合内部组件:
|
||||
|
||||
```ts
|
||||
type SyncEngine = {
|
||||
ingestObservedBatch(input: ObservationInput): Promise<ObservationResult>;
|
||||
startSync(input: StartInput): Promise<StartResult>;
|
||||
resume(conversationId?: string): Promise<StartResult[]>;
|
||||
getStatus(): SyncStatus;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
class Lifecycle {
|
||||
isBusinessGateOpen(): boolean;
|
||||
currentEpoch(): string;
|
||||
getStatus(): SyncStatus;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class ConversationQueue {
|
||||
enqueue<T>(key: ConversationKey, operation: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
class ObservationPipeline {
|
||||
ingest(input: ObservationInput): Promise<ObservationResult>;
|
||||
}
|
||||
|
||||
class CheckpointCoordinator {
|
||||
applyPageProgress(progress: PageProgress): Promise<CheckpointUpdate>;
|
||||
}
|
||||
|
||||
class BootstrapCoordinator {
|
||||
start(input: StartInput): Promise<StartResult>;
|
||||
resume(conversationId?: string): Promise<StartResult[]>;
|
||||
}
|
||||
|
||||
class AckCompletionCoordinator {
|
||||
handleAcknowledgement(frame: AckFrame): Promise<void>;
|
||||
tryComplete(key: ConversationKey): Promise<void>;
|
||||
}
|
||||
|
||||
const createSyncEngine = (options: SyncEngineOptions): SyncEngine =>
|
||||
new SyncEngineImpl(options);
|
||||
```
|
||||
|
||||
#### 3. Contracts
|
||||
|
||||
组件边界按以下规则落地:
|
||||
|
||||
```text
|
||||
SyncEngine facade
|
||||
├── Lifecycle 生命周期、gate、epoch、状态、外部订阅
|
||||
├── ConversationQueue 按会话串行和 active key
|
||||
├── ObservationPipeline 页面观测归一化、先落库、进度串行化
|
||||
├── CheckpointCoordinator checkpoint 读写和状态转换
|
||||
├── BootstrapCoordinator 页面扫描、bootstrap、start/resume
|
||||
└── AckCompletionCoordinator 候选投递、ACK、completion、重试
|
||||
```
|
||||
|
||||
- `SyncEngine` 只组合依赖、分发外部事件并代理公开 API;不得直接实现 payload 解析、队列细节或 checkpoint 转换。
|
||||
- `Lifecycle` 是连接状态、`disposed`、epoch、业务 gate、status/diagnostic/error sink 和 Bright/page subscription 的 owner。权威的当前 anchor snapshot 也应由生命周期或明确的 snapshot owner 保存,不能由多个协调器各自复制。
|
||||
- `ConversationQueue` 只保证同一 `channelAccountId + conversationId` 的串行执行并投影活动数量;不得读取协议字段、调用 storage 或决定重试策略。
|
||||
- `ObservationPipeline` 负责 page observation 到领域 observation 的转换、按会话串行 durable write,以及把“已持久化”结果通知协调器;不得拥有 ACK request map 或页面扫描流程。
|
||||
- `CheckpointCoordinator` 是 checkpoint 状态转换的唯一 owner;其它组件只能通过具名方法读取或申请转换,不得自行拼接 `phase`、`anchorState`、`syncResult` 的第二套规则。
|
||||
- `BootstrapCoordinator` 负责页面 command、历史/增量扫描、bootstrap 和恢复;候选上传、ACK 和 completion 必须委托给 `AckCompletionCoordinator`。
|
||||
- `AckCompletionCoordinator` 负责 request-to-candidate 关联、discovery、逐条发送、ACK 状态落库、completion 声明和 completion 重试;不得自行解释页面分页协议。
|
||||
- 组件之间只依赖更低层的窄接口或回调,不反向 import facade,不形成循环依赖。需要跨组件通知时传递 `onAnchorFound`、`onConversationReady` 这类具名回调,而不是暴露整个引擎实例。
|
||||
- 重构只改变职责归属时,facade 的公开类型、错误码、事件时序和持久化字段必须保持不变;协议行为变化必须另立契约和测试。
|
||||
|
||||
#### 4. Validation & Error Matrix
|
||||
|
||||
| 发现 | 处理 |
|
||||
| --- | --- |
|
||||
| facade 直接读取页面 payload 或解析 ACK 字段 | 移到对应 pipeline/ACK 组件,facade 只分发 |
|
||||
| 两个组件都保存同一份 anchor、mode 或 disposed 状态 | 指定唯一 owner,通过 getter/回调访问 |
|
||||
| queue 组件开始调用 Bright、storage 或决定业务错误 | 删除业务依赖,保留纯串行能力 |
|
||||
| Bootstrap 直接更新 candidate 状态或发送 completion | 委托 `AckCompletionCoordinator` |
|
||||
| Observation 直接推进 checkpoint phase | 委托 `CheckpointCoordinator`,只传递观察结果 |
|
||||
| 组件 import facade 或出现组件循环依赖 | 提取窄接口、回调或无副作用领域函数 |
|
||||
| 为了“变成 class”只移动函数、公开全部字段或保留一个 1000+ 行 class | 停止机械转换,按状态 owner 重新切分 |
|
||||
| 公开工厂/接口被内部组件直接替代 | 保留 facade 和稳定的 factory,避免调用方与实现结构耦合 |
|
||||
|
||||
#### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:`sync-engine.ts` 只有组件构造、Bright frame/status 分发和公开方法代理;六个组件分别拥有自己的 Map/Set、异步流程和错误边界。
|
||||
- Good:anchor snapshot 只由 lifecycle 保存,bootstrap 通过 `getAnchors()` 读取;ACK 组件通过 checkpoint coordinator 更新候选和完成状态。
|
||||
- Base:流程很小、只有一个异步状态且没有跨事件生命周期时,闭包工厂可以继续保留,不为形式引入 class 或目录。
|
||||
- Bad:一个 `createSyncEngine` 同时声明 `queues`、`requestCandidates`、`anchors`、`bootstrapPromise`,并在同一函数中处理 page、Bright、IndexedDB、ACK 和重试。
|
||||
- Bad:把所有函数拆到 `utils.ts`,但状态仍由 facade 隐式共享;文件数量增加了,职责和 owner 没有变清晰。
|
||||
|
||||
#### 6. Tests Required
|
||||
|
||||
- facade 测试:公开方法、错误码、状态投影和 dispose 行为与重构前一致。
|
||||
- queue 测试:同一复合会话键串行,不同会话可并行,active count 在入队/完成时符合契约。
|
||||
- observation/checkpoint 测试:先 durable write 后继续流程;每种 page progress 只由 checkpoint coordinator 产生合法状态转换。
|
||||
- bootstrap 测试:full/incremental start、bootstrap single-flight、失败重试和 resume 委托到正确组件。
|
||||
- ACK/completion 测试:requestId 关联、逐条 ACK、`completionSent`、匹配 anchor snapshot 后完成,以及断线重试。
|
||||
- 组合测试:anchor snapshot、页面 ready、Bright authenticated 的事件顺序不会导致重复 bootstrap、提前上传或错误推进锚点。
|
||||
- 结构检查:组件不 import facade,不出现循环依赖;共享状态只有一个 owner,公开 factory 使用稳定的 `SyncEngine` 接口。
|
||||
|
||||
#### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong:facade 同时拥有所有状态和业务流程。
|
||||
const createSyncEngine = (options: Options): SyncEngine => {
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
const requestCandidates = new Map<string, string>();
|
||||
const anchors = new Map<string, string | null>();
|
||||
|
||||
const onPageObservation = async (message: PageMessage) => {
|
||||
// 解析页面消息、写 checkpoint、发送 Bright、处理 ACK……
|
||||
};
|
||||
|
||||
return { onPageObservation, /* 其它几十个方法 */ };
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// Correct:facade 只组合和分发,状态由职责组件拥有。
|
||||
class SyncEngineImpl implements SyncEngine {
|
||||
private readonly lifecycle: Lifecycle;
|
||||
private readonly observations: ObservationPipeline;
|
||||
private readonly bootstrap: BootstrapCoordinator;
|
||||
private readonly ack: AckCompletionCoordinator;
|
||||
|
||||
public constructor(
|
||||
lifecycle: Lifecycle,
|
||||
observations: ObservationPipeline,
|
||||
bootstrap: BootstrapCoordinator,
|
||||
ack: AckCompletionCoordinator,
|
||||
) {
|
||||
this.lifecycle = lifecycle;
|
||||
this.observations = observations;
|
||||
this.bootstrap = bootstrap;
|
||||
this.ack = ack;
|
||||
}
|
||||
|
||||
public ingestObservedBatch(input: ObservationInput): Promise<ObservationResult> {
|
||||
return this.observations.ingest(input);
|
||||
}
|
||||
|
||||
public startSync(input: StartInput): Promise<StartResult> {
|
||||
return this.bootstrap.start(input);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.10 边界内核、静态路由与业务 Flow
|
||||
|
||||
#### 1. Scope / Trigger
|
||||
|
||||
当一条输入链同时包含原始传输数据、协议解码、会话/端点门禁、多个业务分支和跨模块状态时,使用本节确定边界和目录。典型场景包括 WebSocket、Port、消息队列、RPC 或浏览器事件;适用于新增 frame family、拆分大 handler、引入 route table、移动 transport 或替换业务发送接口。
|
||||
|
||||
这类重构必须按变化原因拆分,而不是把同一套逻辑机械复制到多个文件。目标是让传输边界、路由所有权、业务 Flow 和共享状态各自只有一个事实源。
|
||||
|
||||
#### 2. Signatures
|
||||
|
||||
```ts
|
||||
type ProtocolFrame = {
|
||||
type: string;
|
||||
requestId: string;
|
||||
scope: Scope;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type AuthenticatedFrame = Exclude<ProtocolFrame, { type: "handshake" }>;
|
||||
|
||||
type RouteDefinition<
|
||||
TContext,
|
||||
TFrame extends AuthenticatedFrame = AuthenticatedFrame,
|
||||
> = {
|
||||
type: TFrame["type"];
|
||||
owner: "endpoint_a" | "endpoint_b" | readonly ("endpoint_a" | "endpoint_b")[];
|
||||
authorization: "session" | "operation";
|
||||
handle: (context: TContext, frame: TFrame) => Promise<void>;
|
||||
};
|
||||
|
||||
type FrameRouter<TContext> = {
|
||||
dispatch: (context: TContext, frame: AuthenticatedFrame) => Promise<void>;
|
||||
};
|
||||
|
||||
type EndpointHandler = {
|
||||
handleAuthenticated: (frame: AuthenticatedFrame) => Promise<void>;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
const createProtocolKernel = (options: ProtocolKernelOptions): ProtocolKernel => {};
|
||||
const createAuthenticatedRouter = <TContext>(
|
||||
routes: readonly RouteDefinition<TContext>[],
|
||||
): FrameRouter<TContext> => {};
|
||||
```
|
||||
|
||||
#### 3. Contracts
|
||||
|
||||
- 共享 contract/schema 是 frame union、payload、方向和 decoder 的唯一所有者;transport 不复制 schema 或业务字段校验。
|
||||
- `ProtocolKernel` 只负责原始数据大小限制、解析/解码、连接/版本准入、未认证门禁、单连接 FIFO 和通用生命周期;不得选择业务 Flow 或保存业务状态。
|
||||
- 静态 route table 必须从已认证 frame 集合推导或显式覆盖该集合:握手帧不进入普通路由;每个非共享业务帧恰有一个 endpoint owner;明确允许共享的控制帧才可有多个 owner。
|
||||
- route metadata 一旦被定义为 canonical,生产 dispatch 必须通过同一 router 调用 route handler。不能同时保留一套未被生产调用的 router 和另一套手写 `if`/`switch` 分支;如果只需要启动期完整性检查,应使用名称明确的 validator,不要把它包装成运行时 router。
|
||||
- Endpoint handler 只拥有本端的握手、授权适配和业务 Flow 组合;跨端 registry、连接代际、pending 状态、发布器或资源生命周期由最近共同 owner 管理。
|
||||
- 业务 Flow 接收已收窄的 frame 和窄接口;不得再次解析 raw transport data、绕过 guarded send、创建第二份 route table 或复制共享状态。
|
||||
- composition root 只负责注入依赖、注册唯一入口和对称 dispose;模块初始化不得通过副作用自动注册隐藏路由。
|
||||
|
||||
```text
|
||||
raw input
|
||||
-> ProtocolKernel
|
||||
decode / admission / FIFO / session gate
|
||||
-> EndpointHandler
|
||||
endpoint ownership / authorization / composition
|
||||
-> FrameRouter
|
||||
one canonical route owner
|
||||
-> BusinessFlow
|
||||
domain order / persistence / acknowledgement / publish
|
||||
```
|
||||
|
||||
#### 4. Validation & Error Matrix
|
||||
|
||||
| 发现 | 处理 |
|
||||
| --- | --- |
|
||||
| route table 缺失、重复、未知或包含握手帧 | 在构造或注册阶段失败;不得启动不完整的路由 |
|
||||
| route table 存在但生产入口绕过它 | 视为结构性缺陷;接入同一 router,或删除未使用的运行时抽象 |
|
||||
| endpoint 收到非本端 frame | 返回稳定的 scope/ownership 错误,不调用业务 collaborator |
|
||||
| raw frame 解码失败或超限 | 在进入业务层前按协议错误结束;不执行写入、发送、ACK 或发布 |
|
||||
| Flow 解析 raw data 或重新定义 payload 校验 | 移到 protocol/contract 边界,Flow 只消费已收窄输入 |
|
||||
| 两个模块各自保存连接、代际、pending、timer 或 terminal 状态 | 指定一个 state owner,其余模块只使用窄读写接口 |
|
||||
| composition root 替换配置或关闭 | 先使旧代际失效,再取消 subscription/timer,最后释放连接和 Flow |
|
||||
|
||||
#### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:`ProtocolKernel` 完成解码和 FIFO,`EndpointHandler` 注入 `FrameRouter`,业务 Flow 只处理自己的已收窄 frame;生产测试从真实 composition root 触发 `router.dispatch`。
|
||||
- Good:新增 frame type 时,contract list、route metadata、endpoint owner、业务 handler 和 route integration test 在同一变更中更新。
|
||||
- Base:只有一个小型同步分支、没有跨端状态且没有独立生命周期时,可以保留局部 `switch`;一旦引入 route table,就必须让它成为实际 dispatch 的唯一映射。
|
||||
- Bad:创建了完整 router 但生产代码继续使用另一套 `if` 分支,导致 route metadata、授权策略和实际 handler 逐渐漂移。
|
||||
- Bad:transport 同时构造业务 frame、执行页面命令、维护 pending map;或把所有 frame 广播给多个 Flow 以“避免漏处理”。
|
||||
|
||||
#### 6. Tests Required
|
||||
|
||||
- contract-derived route test:覆盖完整 frame 集合、握手排除、missing、duplicate、unknown、共享控制帧和单 owner 约束。
|
||||
- production composition test:通过真实入口验证 frame 确实经由 canonical router 到达唯一 Flow;不能只实例化一个未被生产使用的 router 做孤立单测。
|
||||
- endpoint boundary test:错 endpoint、错 scope、错 connection type 和未授权 frame 均不调用业务 collaborator,并返回稳定错误。
|
||||
- ordering test:解码、FIFO、授权、业务 Flow 和最终动作顺序保持不变;结构重构前后使用同一组行为特征测试。
|
||||
- ownership/import audit:确认 transport、router、Flow 和 shared state owner 的依赖方向,无反向依赖、循环依赖或第二份状态。
|
||||
- lifecycle test:配置替换、socket close、dispose 和迟到结果不会让旧 Flow、timer 或 subscription 继续产生副作用。
|
||||
|
||||
#### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong:声明了 router,但生产路径另有一套路由事实源。
|
||||
const router = createAuthenticatedRouter(routes);
|
||||
if (frame.type === "data_a") return flowA.handle(frame);
|
||||
if (frame.type === "data_b") return flowB.handle(frame);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Correct:生产入口使用唯一的 canonical dispatch。
|
||||
const router = createAuthenticatedRouter(routes);
|
||||
return router.dispatch(context, frame);
|
||||
```
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| 发现 | 处理 |
|
||||
@@ -396,9 +135,9 @@ return router.dispatch(context, frame);
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- 基础原语如果只代理语言内建行为,只需由使用方错误路径覆盖;不要为一行代理复制无价值测试。
|
||||
- 基础原语如果只代理语言内置行为,只需由使用方错误路径覆盖;不要为一行代理复制无价值测试。
|
||||
- 领域守卫覆盖接受值、边界值和拒绝值,并断言类型收窄所依赖的运行时条件。
|
||||
- 外部上下文适配覆盖有效、格式异常和缺失;一致性覆盖未变化/变化,并断言功能错误映射不泄漏到共享能力。
|
||||
- 外部上下文适配覆盖有效、格式异常和缺失;一致性覆盖未变化/变化,并断言功能错误映射不泄露到共享能力。
|
||||
- 入口、分发和分支重构保留原有行为测试,证明只改变职责归属和输出未变。
|
||||
- 移动共享概念后检查导入方向、跨包内部引用、反向依赖和循环依赖。
|
||||
- 修改补偿逻辑前后搜索同类 `||`、`??`、三元和 mode env 读取,相关边界规则见 [missing-values.md](./missing-values.md)。
|
||||
@@ -408,15 +147,15 @@ return router.dispatch(context, frame);
|
||||
```ts
|
||||
// Wrong:分发器继续读取并转换 Variant A 内部字段。
|
||||
export const parseFeatureInput = (data: unknown): DomainModel[] => {
|
||||
if (isVariantA(data)) { /* 解析 A 的字段…… */ }
|
||||
return [];
|
||||
if (isVariantA(data)) { /* 解析 A 的字段…… */ }
|
||||
return [];
|
||||
};
|
||||
|
||||
// Correct:分发器只识别并委派。
|
||||
export const parseFeatureInput = (data: unknown): DomainModel[] => {
|
||||
if (isVariantA(data)) return parseVariantA(data);
|
||||
if (isVariantB(data)) return parseVariantB(data);
|
||||
return [];
|
||||
if (isVariantA(data)) return parseVariantA(data);
|
||||
if (isVariantB(data)) return parseVariantB(data);
|
||||
return [];
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const syncHistory = (): void => { loadPage(); };
|
||||
|
||||
```ts
|
||||
const normalize = (value: unknown): Record<string, unknown> => {
|
||||
if (!isRecord(value)) return fail("invalid_value");
|
||||
return value;
|
||||
if (!isRecord(value)) return fail("invalid_value");
|
||||
return value;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
当前已建立 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;其中 `business_card` 严格只保存 `{ version: 1, kind: "business_card" }` marker,不保存客户资料、包含认证信息的完整 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 | product` JSON;`product` 仅保存 query-free 的商品详情 `sourceUrl` 与路径提取的 `productId`。其中 `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` 才能覆盖整行。它是名片读取 view 的唯一客户资料来源,不是消息事实的嵌入列。
|
||||
- `onetalk_message_anomaly`:缺字段、协议和同步异常的独立诊断事实。`fingerprint` 仅用于诊断合并;`payload` 必须由写入边界清洗,不能被消息读取、发送或锚点流程消费。
|
||||
- `onetalk_buyer_fact`:买家标签、特征与联系细节的当前事实,独立于联系人资料。`channel_account_id + conversation_id` 复合主键;`tags_state`/`features_state`/`contact_details_state` 各来源独立表达 confirmed/failed,confirmed 来源整体替换、failed 只更新自身审计;`fact_fingerprint` 必须由最终合并后的集合计算。
|
||||
|
||||
生成的初始迁移为 `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。
|
||||
生成的初始迁移为 `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 或其它渠道。后续增量迁移:`0006_onetalk_buyer_facts` 新增 `onetalk_buyer_fact` 表;`0007_center_contact_time` 为 `onetalk_conversation` 增加规范会话活动时间 `last_contact_time_ms` 与白名单 `message_preview`;`0008_lucky_captain_universe` 为 buyer fact 增加 contact details 白名单列;`0009_remove_image_dimensions` 移除 image content 的 `width`/`height`;`0010_wooden_naoko` 将 content v1 CHECK 扩展到六类 kind;`0011_mushy_baron_strucker` 在建立名片 exact CHECK 前,将已有带客户资料字段的 `business_card` content 归一化为 marker;`0013_nifty_thunderbird` 只重建同一 CHECK 以加入 `product`,不回填、更新或删除历史事实。
|
||||
|
||||
## Scenario: Schema 注释与 PostgreSQL 备注
|
||||
|
||||
@@ -212,7 +213,7 @@ 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 验证完整字段。`kind=business_card` 时 JSON 必须精确等于 `{ "version": 1, "kind": "business_card" }`,不得把 `contactName` 等 view 字段写回数据库。不得保留顶层 `text`、`content_type`、raw content、`params`、`sign`、完整 `contact` 或平行投影列。
|
||||
- `content jsonb` 必须是对象,且 `version=1`、`kind in (text,image,file,business_card,inquiry,order,product)`;应用边界再用 shared exact decoder 验证完整字段。`kind=business_card` 时 JSON 必须精确等于 `{ "version": 1, "kind": "business_card" }`;`kind=product` 时仅允许共享合同校验过的 query-free canonical `sourceUrl` 与相等的 `productId`,不得把 `contactName`、`chatToken`、原始 URL 或商品卡 DTO 写回数据库。不得保留顶层 `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,不清空已有消息计数、同步结果或锚点。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 服务端开发规范
|
||||
|
||||
> 这份规范记录 `@trade-message-center/server` 当前已经确认的服务端基线。OneTalk WebSocket 契约、Bright 历史读取、Mind HTTP 授权适配器和传输错误码已建立;真实生产 Mind 服务的联调仍留待 `08-26-mind-integration-cutover`。
|
||||
> 这份规范记录 `@trade-message-center/server` 当前已经确认的服务端基线。OneTalk WebSocket 契约、Bright 历史读取、Mind HTTP 授权适配器和传输错误码已建立;真实生产 Mind 服务的联调尚未完成,需在真实 Mind 环境单独验收。
|
||||
|
||||
## 当前基线
|
||||
|
||||
@@ -9,6 +9,23 @@
|
||||
- 根 TypeScript 配置使用严格模式、`ES2022` 和 `NodeNext`,服务端代码应复用该配置。
|
||||
- 当前已建立 Drizzle 迁移配置、OneTalk 事实 schema 和一次性执行入口;本地 Mind 授权模拟与 Bright 联调页归独立的 `mind-test-harness`,不属于 server。生产日志库仍未引入。OneTalk 协议使用共享 contract 包,测试使用 Node.js 内置 `node:test`。
|
||||
|
||||
## 共享术语
|
||||
|
||||
本目录各契约文档共用以下术语,正文不再重复定义:
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| canonical connection | `connection-store` 中唯一登记的已认证 WebSocket 连接;每个副作用边界前都复核当前操作仍属于它 |
|
||||
| generation | 连接代数;连接替换或重连后递增,旧 generation 的回调与副作用一律失效 |
|
||||
| policy epoch | `OneTalkCutoverPolicy` 的单调计数;`pause()` 使旧 epoch 失效并关闭既有连接 |
|
||||
| admission | cutover policy 对新连接/帧的准入判定(`enabled`/`paused`/`epoch`);不准入则拒绝,不进入业务处理 |
|
||||
| commit guard | 随事务传入的 `OneTalkCommitGuard`;事务前后复核,失效即回滚,不返回伪造成功 |
|
||||
| post-write fence | 事务提交成功后、ACK/发布前对 policy epoch、canonical connection、generation 和授权的最后一轮同步复核 |
|
||||
| pending-send / SendAttempt | registry 预占的发送尝试状态机(`reserved → … → terminal`);每个 `sendRequestId` 只有一个 attempt |
|
||||
| fail closed | 依赖不可用或校验失败时拒绝动作并暴露稳定错误码;绝不降级为成功、空数据或默认值 |
|
||||
| HWM(high-water mark) | 已上传/已拒绝观察时间的单调水位;不高于水位的观察直接跳过 |
|
||||
| future-skew | 观察 `observedAtMs` 超过 Bright 接收时间 5 分钟;整批拒绝且只丢弃匹配 pending |
|
||||
|
||||
## 规范目录
|
||||
|
||||
| 文档 | 内容 | 状态 |
|
||||
@@ -21,7 +38,8 @@
|
||||
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
|
||||
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
|
||||
| [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 |
|
||||
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、CORS/Origin 与 fail-closed 边界 | 已建立适配器与本地 mock |
|
||||
| [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/浏览器联调另行验证 |
|
||||
| [OneTalk 联系人资料 Bright 持久化](./mind-contact-profile.md) | profile composite key、严格时间前进 upsert、future-skew 拒绝、transaction/ACK fence 和 read-model 内存组合 | 已实现并有 focused tests;真实 PostgreSQL 另行验证 |
|
||||
| [OneTalk 买家事实 Bright 持久化与读取](./mind-buyer-fact.md) | buyer source replace、transaction/ACK fence、无 JOIN read projection | 已实现并有 PostgreSQL integration tests |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mind HTTP 授权目标契约
|
||||
# Mind HTTP 授权契约
|
||||
|
||||
> 本文是 `08-26-mind-integration-cutover` 的目标契约,记录已确认的集成边界;在真实 Mind 联调完成前,不代表生产适配器已经实现。
|
||||
> 本文记录已确认且已在 Center 实现的集成边界。真实 Mind 服务与浏览器联调尚未完成,不能把本地测试描述为生产授权链路已验证。
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
Body: { channelAccountId: string; binding: string }
|
||||
|
||||
POST /internal/bright/onetalk/authorize-session
|
||||
Headers: Cookie: <original Mind login cookie>
|
||||
Headers:
|
||||
Cookie: <original Mind login cookie>
|
||||
Sinan-Pilot-Workspace-Id: <Mind page workspace declaration>
|
||||
Body: { channelAccountId: string }
|
||||
|
||||
### Bright adapter
|
||||
@@ -53,18 +55,19 @@
|
||||
## 3. Contracts
|
||||
|
||||
- binding endpoint 的请求只含 channelAccountId + binding,不含 deviceId、Cookie、Mind user 或 workspace。
|
||||
- Session endpoint 的请求由 Bright 转发原始 Cookie,并只含目标 channelAccountId;Mind 从 Session 推导 user/workspace/权限/active binding。
|
||||
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScope;mindScope.channelAccountId 必须与请求目标一致。
|
||||
- Mind page HTTP 请求必须带唯一、非空的 `Sinan-Pilot-Workspace-Id`;Mind page WebSocket 必须带唯一、非空且大小写敏感的 `workspaceId` query。Center 在 `mind-workspace-context.ts` 统一解析这两个边界,不 trim/重写后再转发;缺失、空白或多值一律失败关闭,绝不选择默认空间或从 hello 推导空间。
|
||||
- Session endpoint 由 Bright 转发原始 Cookie、精确同名的 workspace header,以及仍仅含目标 channelAccountId 的 JSON body;Mind 从可信 Session 与该空间声明校验 user/workspace/权限/active binding。workspace 是请求声明,不是 Center 接受的身份。
|
||||
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScope;mindScope.channelAccountId 与 mindScope.workspaceId 必须分别匹配 session 请求目标账号与 workspace。binding 请求仍只校验其 binding/账号契约。
|
||||
- 允许结果映射为 allowed: true;Mind 返回的拒绝 code 只能是已登记的稳定 code。Bright 不把 Mind 原始 JSON 传给页面或消息领域。
|
||||
- Mind page HTTP/WS 不使用 page token,不在 WS hello 中传递 Cookie;WS handler 从 upgrade request header 读取 Cookie,并调用同一个 Session adapter。
|
||||
- Mind page WS hello 中的 scope 只是客户端声明,必须与 Session endpoint 返回的 mindScope 完整一致;不能用声明 scope 冒充用户或 workspace。
|
||||
- Mind 页面位于 mind.<domain>、Bright 位于 bright.<domain> 时,页面 HTTP 必须使用 credentials: "include";Bright CORS 只允许精确 MIND_PAGE_ORIGIN 并返回 credentials,不得使用 *。
|
||||
- Mind page WS hello 中的 scope 只是客户端声明,必须同时等于 upgrade query 的 workspace 与 Session endpoint 返回的完整 mindScope;不能用声明 scope 冒充用户或 workspace。每个 socket 的 session capability 捕获 query workspace,后续 scope 不同即 `scope_mismatch`,不得调用 Mind。
|
||||
- Mind 页面位于 mind.<domain>、Bright 位于 bright.<domain> 时,页面 HTTP 必须使用 credentials: "include";Bright CORS 只允许精确 MIND_PAGE_ORIGIN 并返回 credentials,不得使用 *。public-read preflight 只额外允许精确 `Sinan-Pilot-Workspace-Id` header。
|
||||
- WebSocket 必须按连接类型检查精确 Origin:mind_page 允许 MIND_PAGE_ORIGIN,plugin 允许已登记的扩展 Origin。Cookie 不进入 query、Authorization、localStorage、错误、日志或业务表。Bright 只在单次授权调用内存中转发 Cookie。
|
||||
- Bright 不缓存授权结果跨越复核边界。HTTP 每次请求、WS 连接/业务 frame/heartbeat 都重新授权;binding 或授权版本变化时关闭旧连接。
|
||||
- Bright 不缓存授权结果跨越复核边界。HTTP 每次请求、WS 连接/业务 frame/heartbeat 都重新授权;Mind publisher、rebuild status 与 pending-send 的无 session-capability fallback 也必须从已登记的完整 `mindScope` 传 account + workspace。binding 或授权版本变化时关闭旧连接。
|
||||
- contact.profile.observed 复用 plugin 的 `sync` + `read` 授权;资料写入 Bright 的 profile repository,不调用 Mind profile endpoint,也不读取/转发任何 profile HTTP response body。
|
||||
- profile transaction 完成后,WebSocket handler 仍必须确认 binding、完整 Mind scope、authorizationVersion、read、canonical connection、policy epoch 和 commit guard;远程 revoke/version/read removal 不能产生迟到 ACK。future-skew 则在二次授权前返回稳定的 `profile_observed_at_future`,不写库、不 ACK。
|
||||
|
||||
## 8. Bright v3 operation fences
|
||||
## 4. Bright v3 operation fences
|
||||
|
||||
- `OneTalkCutoverPolicy` 只拥有 Bright v3 的 `enabled`、`paused` 和单调 `epoch`;`capture()`/`isCurrent(epoch)` 必须在每个异步副作用边界前后使用。`pause()` 使旧 epoch 失效并通知 registry 关闭既有 WebSocket,关闭语义为 `1013/authorization_unavailable`,不得先发送 `ws.error`。
|
||||
- registry 是 `SendAttempt` 的唯一 owner。`sendRequestId` 在任何 Mind/plugin authorization `await` 前同步预占,状态只能按 `reserved -> authorizing -> dispatched -> awaiting_confirmation -> confirming -> terminal` 推进;wire send 前失败是 `rejected_before_send`,wire send 后只能是 `confirmed_sent` 或 `delivery_unknown`。
|
||||
@@ -72,10 +75,13 @@
|
||||
- 远程 plugin observation、conversation discovery 和 sync completion 只能调用 required guarded repository ports;guard 必须进入真实数据库 transaction,并覆盖查询、insert、update、duplicate read 和 callback 返回前的边界。缺 guard 时不得 fallback 为无保护写入。
|
||||
- 保留 Bright 的共享消息/anchor 主键语义,不将 `deviceId` 加入事实键;新 binding 按完整 Mind scope 替换旧 plugin routing。无 Mind revocation push/lease 时,只能声明下一次授权调用 fail closed,不能宣称无输入即时获知 takeover。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
## 5. Validation & Error Matrix
|
||||
|
||||
| 条件 | Mind/Bright 结果 |
|
||||
| --- | --- |
|
||||
| Mind HTTP 缺失、空白或歧义 workspace header | 403 scope_mismatch;不调用 reader 或 Bright read service |
|
||||
| Mind WS 缺失、空白或重复 workspaceId query | upgrade 前 HTTP 403;不创建 socket/session |
|
||||
| Mind WS query、hello scope 或 Mind 返回 workspace 不相等 | scope_mismatch;hello/fence 不产生后续副作用 |
|
||||
| Cookie 缺失、过期或 Session 无效 | 401 auth_required |
|
||||
| Session 有效但无目标账号范围 | 403 scope_mismatch |
|
||||
| binding 不存在、撤销或被接管 | 403 binding_revoked |
|
||||
@@ -85,24 +91,29 @@
|
||||
| WS Origin 非对应连接类型 allowlist | 拒绝握手并关闭连接 |
|
||||
| 返回授权版本与连接初始值不同 | Bright 返回 authorization_version_changed 并关闭连接 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
## 6. Good / Base / Bad Cases
|
||||
|
||||
- Good:插件只提交账号和 binding;页面只携带浏览器 Cookie;Bright 使用 Mind 返回的真实 scope 做精确路由。
|
||||
- Good:插件只提交账号和 binding;Mind 页面 HTTP 发送 Cookie 与唯一 workspace header,WS URL 发送相同 workspace query;Bright 使用 Mind 返回的真实完整 scope 做精确路由。
|
||||
- Good:workspace A 的 reader/WS authorization 只能匹配 A;Mind 在换绑或空间切换后拒绝旧 scope 时,下一次 heartbeat、send、rebuild 或 publish fence 不产生 ACK、wire send、rebuild status 或页面发布。
|
||||
- Good:Mind 接管新 binding 后旧 binding endpoint 立即拒绝,Bright 关闭旧连接,迟到发送结果保持 delivery_unknown。
|
||||
- Base:本地开发使用内存 authorization fixture;它模拟两个 endpoint 的结果,但不被称为 Mind view,也不连接 Mind DB。
|
||||
- Bad:Bright 直接查询 Mind session/binding 表、把 mindUserId header 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
|
||||
- Bad:Bright 直接查询 Mind session/binding 表、把 mindUserId/header workspace 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
|
||||
- Bad:只在 WS hello 校验 workspace、在 HTTP/WS 各入口复制解析逻辑、或让 publisher/rebuild/send fallback 缩窄为 account-only 授权。
|
||||
|
||||
## 6. Tests Required
|
||||
## 7. Tests Required
|
||||
|
||||
- 两个 Mind endpoint 的 contract test:成功上下文、字段严格解码、Session 无效、scope/权限拒绝、binding 撤销和版本变化。
|
||||
- HTTP client failure test:超时、网络错误、5xx、非 JSON、未知 code、缺字段均映射为 authorization_unavailable。
|
||||
- Bright HTTP/WS 共用 Session adapter test:页面伪造 user/workspace 被拒绝,Cookie 不出现在响应、日志或持久化。
|
||||
- Session adapter test:只对 session endpoint 发送精确 workspace header,JSON body 仍严格等于 `{ channelAccountId }`;plugin binding 不获得 header;返回 workspace 不匹配为 scope_mismatch。
|
||||
- Public-read/WS ingress test:CORS preflight 允许精确 header;HTTP 的缺失/空白/多值 header 在 reader/service 前失败;WS 的缺失/空白/重复 query 在 upgrade 前失败,query 与 hello 不同关闭为 scope_mismatch。
|
||||
- WS fence test:session capability、publisher、rebuild status、pending send 的所有 Mind fallback 都转发 workspace;workspace mismatch 后 `message.created`、`sync.status`、`conversation.updated`、wire send 与 rebuild status 均不产生。
|
||||
- 同域三级域名 browser smoke:credentialed CORS、Cookie、WS handshake 和 Origin allowlist。
|
||||
- takeover test:旧 binding 的 connect/heartbeat/upload/send/late result 全部拒绝,新设备复用共享锚点。
|
||||
- SendAttempt/commit fence test:同 ID 并发只 dispatch 一次,pre-wire 与 post-wire 断线映射不同,confirmation terminal/late no-op,pause/disconnect 在各数据库异步边界回滚未提交写入。
|
||||
- 根 pnpm format:check、pnpm typecheck、pnpm test、pnpm build 和 git diff --check。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
## 8. Wrong vs Correct
|
||||
|
||||
### Wrong
|
||||
|
||||
@@ -115,11 +126,15 @@
|
||||
|
||||
### Correct
|
||||
|
||||
const workspaceId = readMindWorkspaceHeader(request.headers);
|
||||
if (workspaceId === undefined) return failClosed("scope_mismatch");
|
||||
const authorization = await mindClient.authorizeSession({
|
||||
cookie: request.headers.cookie,
|
||||
channelAccountId: request.params.channelAccountId,
|
||||
workspaceId,
|
||||
});
|
||||
if (!authorization.allowed) return failClosed(authorization.code);
|
||||
if (authorization.mindScope.workspaceId !== workspaceId) return failClosed("scope_mismatch");
|
||||
return service.readHistory(authorization.mindScope, request.params.conversationId);
|
||||
|
||||
Cookie 是登录上下文,不是页面可自定义的业务字段;Mind 是身份和 binding 的事实源,Bright 只消费两个 HTTP 判定结果。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# OneTalk 单会话历史重建契约
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
- Trigger:Mind 需要删除并重新拉取一个 direct conversation 的历史;这是 destructive `rebuild` 操作,不是 read 的别名。
|
||||
- Scope:`POST /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/history/rebuild`、`storage.delete.*`、`history.sync.*`、`rebuild.status`、插件 durable ledger 与 Bright conversation generation。
|
||||
- Excluded:账户 bootstrap marker、`onetalk_contact_profile`、buyer fact、其它 conversation、发送队列及任意 raw SDK/IndexedDB key 删除。
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
```ts
|
||||
POST /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/history/rebuild
|
||||
-> { scope, conversationId, rebuildId, status: "server_reset_committed", resync }
|
||||
|
||||
storage.delete.command { rebuildId, target: { kind: "conversation_history", conversationId } }
|
||||
storage.delete.ack { rebuildId, conversationId, status: "cleared" | "rejected", reason? }
|
||||
history.sync.command { rebuildId, historyGeneration, conversationId, mode: "full" }
|
||||
history.sync.ack { rebuildId, conversationId, status: "started" | "rejected", reason? }
|
||||
sync.complete { conversationId, historyGeneration, rebuildId?, ... }
|
||||
```
|
||||
|
||||
`rebuildId` 是一次内存操作关联 token;`historyGeneration` 是 `onetalk_conversation` 持久化的事实边界。repository 的 reset 与 guarded sync-anomaly 写入均必须接受 `OneTalkCommitGuard`。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
- HTTP、Mind status 与 plugin command 都要求精确 scope、binding、authorizationVersion 和独立的 `rebuild` permission;read permission 不能替代它。
|
||||
- 顺序固定为:plugin scoped durable clear -> matching ACK -> 重新授权 -> Bright transaction delete/reset/new generation -> `history.sync.command`。HTTP 200 只表示 transaction 已提交;sync start 失败仍返回 `server_reset_committed` 加失败 `resync`。
|
||||
- 插件只删除 `(channelAccountId, conversationId)` 的 messages/checkpoints/candidates/conversation anomalies;clear 后保持 quiesced。`rebuildId + historyGeneration + page requestId` 必须关联到 history observation、progress、candidate upload 和 `sync.complete`;未关联或过期的 page callback 不得写入当前 generation。
|
||||
- `sync.complete` 只有携带匹配 rebuild token,且服务端最终持久化结果为 `historyComplete=true` 与 `succeeded|succeeded_with_anomalies` 时,才回发完整 `anchor.snapshot` 并发布 `rebuild.status: sync_completed`。anchor snapshot 是全量替换语义,不能只发送目标会话。
|
||||
- reset transaction 在每个数据库副作用边界都复核 canonical socket、connection generation、cutover epoch、rebuild permission 和 heartbeat lease;sync anomaly 写入要在锁住目标 conversation 且验证 generation 的同一 transaction 中完成。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 结果 |
|
||||
| --- | --- |
|
||||
| Mind 或 plugin 只有 read | 403/拒绝 rebuild,不执行 clear/reset |
|
||||
| 无 canonical fresh rebuild plugin | `plugin_offline` 或 `plugin_heartbeat_stale`,不写库 |
|
||||
| clear ACK 不匹配、超时或 rejected | 不 reset,返回稳定 pre-commit error |
|
||||
| clear 后授权、policy、canonical connection 或 lease 失效 | 不 reset;已提交后失效只报告 resync failed,不回滚事实 |
|
||||
| 旧 generation completion/observation | reject 或丢弃;不得写 message、anomaly、anchor 或 status |
|
||||
| reset 抢在旧 completion 前提交 | 旧 anomaly 不能落库,completion 变为 rejected |
|
||||
| persisted completion 是 incomplete/failed | 不发 rebuild completion snapshot/status |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:同一 conversation 的旧 page history callback 在 rebuild release 后抵达,因 requestId 不匹配被丢弃。
|
||||
- Good:服务器 reset 后同步命令未启动,HTTP 仍为 200,Mind 得到 `resync.status=failed`,下次请求可安全重试。
|
||||
- Base:同一 WebSocket 上完成一次 rebuild 后,带新 rebuildId 的下一次 clear 可替换完成态 token;迟到的旧 sync command 不能释放新的 pending token。
|
||||
- Bad:用 `read` 授权 destructive route、发送任意 store/key delete 命令、把单会话 anchor 当作全量 snapshot,或在先读 generation 后无条件写 anomaly。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- Contract:rebuild permission 独立、所有 control frame 与 `sync.complete` 的 exact-key/direction 检查。
|
||||
- Extension:scoped IDB clear 保留 profile/buyer/bootstrap;quiesce、旧 command/ACK/discovery/page callback、重复 completion snapshot 和连续 rebuild 回归。
|
||||
- Server:read-only 403、single-flight account+conversation、每个 await 后 revoke/policy/lease fence、post-commit HTTP semantics、matching completion 的 full anchor/status、incomplete completion no-op。
|
||||
- Repository:可控 race 让 reset 赢过旧 completion,断言没有旧 generation anomaly;有 `TEST_DATABASE_URL` 时再验证真实 PostgreSQL 行锁交错。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
### Wrong
|
||||
|
||||
```ts
|
||||
await plugin.clear();
|
||||
await repository.deleteMessages(scope, conversationId);
|
||||
startSync(); // 未关联 rebuild token,旧 callback 可混入
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```ts
|
||||
await matchingClearAck(rebuildId);
|
||||
await authorizeCurrentPlugin();
|
||||
await repository.resetConversationHistory(context, conversationId, historyGeneration, guard);
|
||||
sendHistorySync({ rebuildId, historyGeneration, conversationId, mode: "full" });
|
||||
// 插件仅接受同 rebuildId 绑定的 page request;服务端只完成匹配 token 的持久化结果。
|
||||
```
|
||||
@@ -18,42 +18,42 @@ startServer(): Promise<void>
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
- Required environment keys: `HOST`, `PORT`, `DATABASE_URL`。
|
||||
- Optional `NODE_ENV` is normalized at the configuration boundary; only exact trimmed `development` enables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。
|
||||
- `GET /health` returns `{ "status": "ok" }`。
|
||||
- WebSocket routes are `GET /ws/plugin` and `GET /ws/mind`; each route binds its connection type before upgrade and checks its exact Origin allowlist. `GET /ws` is reject-only compatibility behavior (`426 onetalk_protocol_upgrade_required`) and has no legacy handler, outbox or dispatch path. Malformed or unsupported protocol frames close with code `1003`, and connection/handler errors close with `1011`。
|
||||
- `createApp` never calls `listen`。`entry.ts` 是唯一进程入口,它调用 `runtime.ts` 的双 listener lifecycle;`runtime.ts` 只绑定 public `HOST:PORT` 与 internal `0.0.0.0:7777`,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
|
||||
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs。
|
||||
- `createApp` composes one injected/default `OneTalkService`, `OneTalkProfileService` and `OneTalkReadService` over the same `database.db`; `AppDependencies` may inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters。
|
||||
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created`。`message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
|
||||
- `contact.profile.observed` is a Bright persistence path: canonical binding/read/sync authorization → profile service → guarded Bright transaction → post-write fence → `contact.profile.ack`; it never calls Mind profile HTTP or the message service. `AppDependencies.profileService` is the test/deployment seam.
|
||||
- 必填环境变量:`HOST`、`PORT`、`DATABASE_URL`。
|
||||
- 可选的 `NODE_ENV` 在配置边界规范化;只有 trim 后精确等于 `development` 的值才启用仅开发环境的 loopback Mind HTTP 配置,缺失或未知值保持 fail-closed。
|
||||
- `GET /health` 返回 `{ "status": "ok" }`。
|
||||
- WebSocket 路由是 `GET /ws/plugin` 和 `GET /ws/mind`;每条路由在 upgrade 前绑定自己的连接类型并检查精确的 Origin allowlist。`GET /ws` 是仅拒绝的兼容行为(`426 onetalk_protocol_upgrade_required`),没有 legacy handler、outbox 或分发路径。畸形或不支持的协议帧以 `1003` 关闭,连接/handler 错误以 `1011` 关闭。
|
||||
- `createApp` 永不调用 `listen`。`entry.ts` 是唯一进程入口,它调用 `runtime.ts` 的双 listener lifecycle;`runtime.ts` 只绑定 public `HOST:PORT` 与 internal `0.0.0.0:7777`,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
|
||||
- 数据库资源通过应用 `onClose` hook 关闭;URL 和凭证不进入响应或日志。
|
||||
- `createApp` 在同一个 `database.db` 上组合注入/默认的 `OneTalkService`、`OneTalkProfileService` 和 `OneTalkReadService`;`AppDependencies` 可以为测试或部署适配器注入这些领域端口、连接 registry 和 publisher 失败 sink。
|
||||
- WebSocket 业务帧经过 service boundary;成功观察的顺序是数据库提交 → 插件 `message.ack` → 已授权 Mind `message.created`。`message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
|
||||
- `contact.profile.observed` 是 Bright 持久化路径:canonical binding/read/sync 授权 → profile service → guarded Bright transaction → post-write fence → `contact.profile.ack`;它不调用 Mind profile HTTP 或消息服务。`AppDependencies.profileService` 是测试/部署接缝。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| Condition | Result |
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| Missing/blank `HOST` | throw `Missing HOST` |
|
||||
| Missing/blank `PORT` | throw `Missing PORT` |
|
||||
| `PORT` outside `1..65535` or non-integer | throw `Invalid PORT: expected integer 1-65535` |
|
||||
| Missing/blank `DATABASE_URL` | throw `Missing DATABASE_URL` |
|
||||
| Malformed or unsupported WebSocket frame received | send the stable protocol error, then close current socket with `1003` |
|
||||
| WebSocket error received | close current socket with `1011` |
|
||||
| Profile future-skew or guarded persistence rejected | stable `profile_observed_at_future`/database failure; no profile ACK |
|
||||
| Profile transaction succeeds but authorization/connection/policy fence is stale | no late ACK; plugin pending remains |
|
||||
| 缺失或空白的 `HOST` | 抛出 `Missing HOST` |
|
||||
| 缺失或空白的 `PORT` | 抛出 `Missing PORT` |
|
||||
| `PORT` 不在 `1..65535` 或非整数 | 抛出 `Invalid PORT: expected integer 1-65535` |
|
||||
| 缺失或空白的 `DATABASE_URL` | 抛出 `Missing DATABASE_URL` |
|
||||
| 收到畸形或不支持的 WebSocket 帧 | 发送稳定协议错误,然后以 `1003` 关闭当前 socket |
|
||||
| 收到 WebSocket 错误 | 以 `1011` 关闭当前 socket |
|
||||
| profile future-skew 或 guarded 持久化被拒绝 | 稳定的 `profile_observed_at_future`/数据库失败;无 profile ACK |
|
||||
| profile 事务成功但授权/连接/policy fence 已过期 | 无迟到 ACK;插件 pending 保留 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good: tests inject `{ db, close }`, call `app.ready()`, verify `/health`, then observe one close call。
|
||||
- Base: production startup creates a Drizzle client from `DATABASE_URL` and listens on configured host/port。
|
||||
- Bad: route handler reads `process.env`, creates a second postgres client, or returns the connection URL in an error。
|
||||
- Good:测试注入 `{ db, close }`,调用 `app.ready()`,验证 `/health`,然后观察 close 恰好被调用一次。
|
||||
- Base:生产启动从 `DATABASE_URL` 创建 Drizzle client,并在配置的 host/port 上监听。
|
||||
- Bad:路由 handler 读取 `process.env`、创建第二个 postgres client,或在错误中返回连接 URL。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- Configuration test asserts missing URL and invalid port errors contain field/error class but not secret values。
|
||||
- Health test asserts HTTP `200` and exact `{ status: "ok" }` response。
|
||||
- Lifecycle test asserts injected database `close` runs once on `app.close()`。
|
||||
- WebSocket registration test asserts `websocketServer` exists, `GET /ws/plugin`/`GET /ws/mind` are registered, and `GET /ws` is reject-only; OneTalk protocol tests assert mock-authorized handshake, heartbeat, version rejection, and authorization failures。
|
||||
- OneTalk business tests assert raw observation validation, conversation discovery, sync completion, anchor snapshot, plugin-only writes and publish-after-commit behavior。
|
||||
- 配置测试断言缺失 URL 和非法端口的错误包含字段/错误类别,但不包含秘密值。
|
||||
- Health 测试断言 HTTP `200` 和精确的 `{ status: "ok" }` 响应。
|
||||
- Lifecycle 测试断言注入的数据库 `close` 在 `app.close()` 时恰好执行一次。
|
||||
- WebSocket 注册测试断言 `websocketServer` 存在、`GET /ws/plugin` 和 `GET /ws/mind` 已注册、`GET /ws` 为仅拒绝;OneTalk 协议测试断言 mock 授权握手、heartbeat、版本拒绝和授权失败。
|
||||
- OneTalk 业务测试断言原始观察校验、会话发现、同步完成、anchor snapshot、仅插件写入和提交后发布行为。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
@@ -69,7 +69,7 @@ app.get("/health", async () => ({ databaseUrl: process.env.DATABASE_URL }));
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
```
|
||||
|
||||
The health boundary is stable and secret-free; database probing belongs in a later operational contract。
|
||||
health 边界稳定且不含秘密;数据库探测属于后续的运维契约。
|
||||
|
||||
## Scenario: OneTalk authenticated WebSocket route metadata
|
||||
|
||||
@@ -119,10 +119,10 @@ defineOneTalkEndpointRouteHandler(type, handler) -> OneTalkEndpointRouteHandler
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: endpoint type list and production selector can drift independently.
|
||||
// Wrong: endpoint type list 与生产选择器可能各自漂移。
|
||||
if (frame.type === "send.request") return sendRequestFlow.handleRequest(socket, frame, epoch);
|
||||
|
||||
// Correct: the fixed endpoint dispatches an exact handler table validated by shared metadata.
|
||||
// Correct: 固定 endpoint 分发由 shared metadata 校验过的精确 handler table。
|
||||
const router = createOneTalkEndpointAuthenticatedRouter("mind_page", metadata, handlers);
|
||||
return router.dispatch(frame);
|
||||
```
|
||||
@@ -460,24 +460,24 @@ createOneTalkProfileFlow({ profileService, registry, reauthorize, ...narrowPorts
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: non-client classification can hide a cross-session frame from the scope guard.
|
||||
// Wrong: non-client 分类可能让跨会话 frame 绕过 scope guard。
|
||||
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
|
||||
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Correct: a decoder-valid frame must belong to the current session before route classification.
|
||||
// Correct: 在路由分类之前,decoder 合法的 frame 必须先确认属于当前会话。
|
||||
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
|
||||
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
|
||||
return router.dispatch(context, frame);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Wrong: the shared transport chooses endpoint business behavior.
|
||||
// Wrong: 共享 transport 决定 endpoint 的业务行为。
|
||||
if (state.connectionType === "plugin") return pluginSyncFlow.handle(frame);
|
||||
return mindSendFlow.handle(frame);
|
||||
|
||||
// Correct: each fixed endpoint injects only its own hello and authenticated callbacks.
|
||||
// Correct: 每个固定 endpoint 只注入自己的 hello 和已认证回调。
|
||||
return createOneTalkWebSocketHandler(
|
||||
{ ...sharedOptions, expectedConnectionType: "plugin" },
|
||||
{ create: createPluginEndpointCallbacks },
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 服务端前端组件规范
|
||||
|
||||
## 当前状态
|
||||
|
||||
服务端包没有 UI 框架和组件文件。当前不能从仓库推导出组件目录、Props 形式、组合方式或样式实现。
|
||||
|
||||
## 当前可执行的边界
|
||||
|
||||
- 不要在没有渲染入口和前端依赖时创建框架组件。
|
||||
- 组件公开输入应使用明确的 TypeScript 类型;服务端 API 响应不能未经适配直接成为 UI 的长期 Props 契约。
|
||||
- 组件不负责建立数据库连接、调用任意服务端内部模块或拼装未定义的 API 错误结构。
|
||||
- 样式方案和可访问性测试方式在首个 UI 任务中确定后,必须用真实示例更新本文件。
|
||||
|
||||
## 首次组件落地要求
|
||||
|
||||
需说明组件的渲染环境、目录归属、Props/事件契约、加载/空/错误状态、样式隔离方式、键盘与焦点行为,以及可执行的验证命令。
|
||||
|
||||
## 可访问性最低要求
|
||||
|
||||
面向用户的 UI 必须提供可访问名称、正确的语义元素和可键盘完成的核心操作。服务端渲染场景还要验证输出 HTML 与客户端交互之间的边界。
|
||||
|
||||
## 常见误区
|
||||
|
||||
- 把 Fastify 路由处理器直接当成组件数据层,跳过稳定的 API 契约。
|
||||
- 在组件中复制服务端 DTO 的字段解析,导致后端变更时多处失配。
|
||||
- 在没有真实复用需求时提前建立通用设计系统。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 服务端前端目录结构
|
||||
|
||||
## 当前状态
|
||||
|
||||
服务端包不包含原生 HTML 联调页,也没有前端构建产物:
|
||||
|
||||
```text
|
||||
apps/
|
||||
├── server/
|
||||
│ └── src/ # Fastify HTTP/WS 和业务实现
|
||||
└── mind-test-harness/
|
||||
└── src/ # 独立联调页与 Mind 外部依赖模拟
|
||||
```
|
||||
|
||||
`mind-test-harness` 自己监听页面端口,并通过显式 Bright base URL 读取会话、历史、插件状态和同步事件;不连接数据库、不读取 Mind legacy 消息、不引入 UI 框架或前端依赖。server 不注册 `/harness` 路由,也不导入该包。
|
||||
|
||||
## 边界规则
|
||||
|
||||
- 服务端 API、业务逻辑和数据访问不属于本前端层,即使它们最终为 UI 提供数据,也应留在 backend 规范描述的边界内。
|
||||
- 如果未来增加正式管理界面或服务端渲染,入口、静态资源、组件和页面目录必须基于实际构建工具重新确定,并在这里登记;不要把联调页直接演化为正式应用。
|
||||
- 不要把扩展包的页面或资源复制到服务端包;两个包通过明确的 API/消息契约协作。
|
||||
|
||||
## 联调页边界
|
||||
|
||||
- 页面脚本只负责输入、请求编排、运行时响应校验、状态展示、历史分页和事实去重;HTTP/WS route、service、repository 保持在 server backend 层。
|
||||
- 页面使用独立 history cursor 恢复;重连时先重新读取历史,再建立 WS,避免把内存中的事件状态当作可靠存储。
|
||||
- 页面展示 raw `conversationId`/`messageId`/sender/login/direction/sentAtMs,文本进入 `innerHTML` 前必须转义。
|
||||
- 发送按钮在当前未实现发送链路时保持禁用,不得伪造成功或写入客户端 outbox。
|
||||
|
||||
## 命名
|
||||
|
||||
包目录为 `server`、包名为 `@trade-message-center/server`。`mind-test-harness` 仅用于开发手工测试,不能被 server 或其它包依赖。若加入正式 UI,再按其真实入口和功能边界拆分文件。
|
||||
|
||||
## 参考文件
|
||||
|
||||
- [`apps/server/package.json`](../../../../apps/server/package.json):当前服务端包的真实内容。
|
||||
- [`apps/mind-test-harness/package.json`](../../../../apps/mind-test-harness/package.json):独立开发测试工具,不参与构建。
|
||||
- [`tsconfig.base.json`](../../../../tsconfig.base.json):共享编译配置。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 服务端前端 Hook 规范
|
||||
|
||||
## 当前状态
|
||||
|
||||
服务端包没有 UI 框架、Hook 文件或数据请求库。当前没有可以复用的 Hook 模式,也没有服务端前端的请求缓存契约。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 只有实际引入支持 Hook 的前端技术后,才使用 `use...` 命名;普通数据转换函数不应伪装成 Hook。
|
||||
- Hook 只组合 UI 状态和生命周期;HTTP 请求、鉴权、错误转换等边界逻辑应由明确的客户端适配模块承载。
|
||||
- 不要在多个 Hook 中各自解释同一 API 响应,统一类型和边界校验后再交给 UI 使用。
|
||||
- 当前没有 React Query、SWR 或其他缓存库,不要凭空规定缓存刷新策略。
|
||||
|
||||
## 首次建立约定时
|
||||
|
||||
提供至少两个真实使用示例,并写明依赖变化、异步状态、取消/卸载、错误显示和测试方式。若 UI 运行在服务端渲染环境,还需说明服务端与客户端执行边界。
|
||||
|
||||
## 常见误区
|
||||
|
||||
- 把服务端内部函数直接导入 Hook,绕过 HTTP 或消息契约。
|
||||
- 在 Hook 中吞掉请求错误,只返回空列表,让界面无法区分失败和无数据。
|
||||
- 为了共享一个无状态格式化函数而引入 Hook 依赖。
|
||||
@@ -1,32 +0,0 @@
|
||||
# 服务端前端开发规范
|
||||
|
||||
> `@trade-message-center/server` 是纯服务端包,不包含本地 HTML harness、正式前端框架或生产 UI。
|
||||
|
||||
## 当前基线
|
||||
|
||||
- `apps/server/package.json` 只有 Fastify 依赖,没有 React/Vue/Svelte、样式库或独立前端构建工具。
|
||||
- 手工 Bright 联调页面归 `apps/mind-test-harness/` 所有;server 只提供其 HTTP/WS 业务边界。
|
||||
- TypeScript 仍必须遵循根 `tsconfig.base.json` 的严格模式和 `NodeNext` 模块解析。
|
||||
|
||||
## 规范目录
|
||||
|
||||
| 文档 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| [项目级代码架构](../../project/architecture.md) | 所有包共用的文件职责、共享层级、main-last 与注释规则 | 项目级必读 |
|
||||
| [目录结构](./directory-structure.md) | 原生联调页与未来正式 UI 边界 | 已建立 |
|
||||
| [组件规范](./component-guidelines.md) | 组件引入前的最小边界 | 已建立基线 |
|
||||
| [Hook 规范](./hook-guidelines.md) | Hook 与服务端数据请求边界 | 已建立基线 |
|
||||
| [状态管理](./state-management.md) | UI 状态与服务端状态分类 | 已建立基线 |
|
||||
| [质量规范](./quality-guidelines.md) | 原生联调页的质量和验证方式 | 已建立 |
|
||||
| [类型安全](./type-safety.md) | TypeScript 和运行时边界 | 已建立基线 |
|
||||
|
||||
## 开始前检查
|
||||
|
||||
1. 先阅读 [项目级代码架构](../../project/architecture.md),再确认这次工作确实在服务端包内引入了 UI 或服务端渲染,而不是把 API/路由误归入本层。
|
||||
2. 读取服务端 backend 规范,明确 UI 与 HTTP/业务层的接口边界。
|
||||
3. 首次引入前端依赖时,同时记录入口、构建脚本、渲染环境和验证命令。
|
||||
4. 运行根 `pnpm typecheck`、`pnpm build`、`pnpm test`,并区分真实执行与因 `--if-present` 跳过的脚本。
|
||||
|
||||
## 文档维护
|
||||
|
||||
当服务端出现正式前端代码后,用真实组件、入口和测试补充本文件;不要把 `mind-test-harness` 的手工测试能力重新放回 server。
|
||||
@@ -1,45 +0,0 @@
|
||||
# 服务端前端质量规范
|
||||
|
||||
## 已确认的工具链
|
||||
|
||||
- 使用 pnpm `11.7.0`、Node.js `>=22.22.2 <23` 和根级严格 TypeScript 配置。
|
||||
- 根级 `dev`、`build`、`typecheck`、`test` 命令只调用实际存在的子包脚本;手工联调页由 `mind-test-harness` 独立提供,server 不新增前端构建脚本。
|
||||
- 根级 `format` 和 `format:check` 使用 Oxfmt;lint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。
|
||||
- 当前没有 lint、组件测试、端到端测试或浏览器构建工具,不能把这些能力写成已经启用的检查项。
|
||||
|
||||
## 必须遵守
|
||||
|
||||
- UI 代码必须通过根 `tsconfig.base.json` 的严格检查,并保持与服务端 API/消息契约的类型边界。
|
||||
- 用户可见状态至少要考虑加载、空数据、错误和权限失败;实际测试方案确定后补上自动化验证。
|
||||
- 联调页必须显式展示加载、空数据、插件 online/offline、授权失败、历史读取失败、WS 断线和同步状态;发送能力未接入时保持禁用。
|
||||
- 联调页只能调用 Bright HTTP/WS;不得在浏览器脚本中访问数据库、server 内部模块、Mind legacy 接口或保存 credential。
|
||||
- 来自 HTTP/WS 的未知数据必须先运行时校验;消息正文进入 DOM 前必须转义,事实去重键必须包含 scope、会话和原始 messageId。
|
||||
- 联调页专属依赖和脚本写入 `apps/mind-test-harness/package.json`;该包不应声明 build/test 脚本,也不得成为其它包依赖。
|
||||
- 引入浏览器端能力时,明确哪些代码只能在客户端执行,避免在服务端环境访问浏览器全局对象。
|
||||
|
||||
## 禁止做法
|
||||
|
||||
- 未经任务说明把服务端包改造成前端应用,或直接复制扩展包实现。
|
||||
- 用 `any`、无理由断言或静默默认值掩盖 API 契约问题。
|
||||
- 在组件中直接访问数据库、环境密钥或服务端内部模块。
|
||||
- 宣称没有配置的 lint/测试工具已经通过。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
pnpm format:check
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
首次出现服务端前端后,应把实际入口和测试脚本接入 workspace,并记录浏览器/SSR 场景的额外验证方法。
|
||||
|
||||
当前联调页没有独立浏览器构建或 E2E runner;最低验证包括服务端 typecheck/build/test、`GET http://127.0.0.1:8788/` 内容 smoke,以及真实本地 HTTP/WS 网络 smoke。
|
||||
|
||||
## 评审清单
|
||||
|
||||
- 是否保持浏览器、服务端和 API 边界清晰?
|
||||
- 是否如实区分了自动化检查与手工验证?
|
||||
- 是否覆盖了加载、空、错误、权限、插件离线、重连恢复和可访问性状态?
|
||||
- 是否引入了未经记录的 UI 框架、状态库或构建工具?
|
||||
@@ -1,22 +0,0 @@
|
||||
# 服务端前端状态管理
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前没有服务端前端入口、状态文件、状态库或数据缓存层。不能把扩展包的未来状态方案直接复制到这里。
|
||||
|
||||
## 现阶段规则
|
||||
|
||||
- 不要在没有第二个状态消费者前引入全局状态库。
|
||||
- 新增状态前先区分视图局部状态、跨页面状态、服务端数据和派生值。
|
||||
- API 返回数据的缓存、失效和重新获取规则必须在请求边界确定,不能让每个组件自行决定。
|
||||
- 可从其他状态计算出的值不应重复存储,除非有明确的性能或快照需求。
|
||||
|
||||
## 首次落地时的记录项
|
||||
|
||||
补充状态库或持久化方案时,需要说明初始化和清理时机、服务端渲染/客户端 hydration 边界、错误与加载状态、缓存失效方式和测试入口,并引用真实使用文件。
|
||||
|
||||
## 常见误区
|
||||
|
||||
- 把服务端数据库状态、请求缓存和 UI 交互状态混在一个 store 中。
|
||||
- 将 API 响应复制到多个局部状态,导致刷新后出现不一致。
|
||||
- 没有定义权限或会话变化时的清理策略。
|
||||
@@ -1,28 +0,0 @@
|
||||
# 服务端前端类型安全
|
||||
|
||||
## 编译基线
|
||||
|
||||
服务端前端代码应使用根 [`tsconfig.base.json`](../../../../tsconfig.base.json),当前已确认 `strict: true`、`target: ES2022`、`module: NodeNext`、`moduleResolution: NodeNext`、大小写一致性检查和 JSON 模块导入。
|
||||
|
||||
## 类型组织
|
||||
|
||||
当前没有服务端前端源文件或共享类型目录。类型先靠近实际使用的页面/组件或 API 适配模块;当多个功能共享同一 API、消息或表单契约时,再抽到包内明确的共享位置。不要通过相对路径引用扩展包内部类型。
|
||||
|
||||
## 运行时校验
|
||||
|
||||
服务端包当前没有运行时 schema 库。来自 HTTP、数据库、扩展消息或服务端渲染输入的数据不能只依赖编译期类型;Mind 联调页(`apps/mind-test-harness`)的原生浏览器脚本使用显式 shape guard 校验 scope、conversation、history、message、plugin status 和 sync status,失败时进入可见错误状态。
|
||||
|
||||
## 常见模式
|
||||
|
||||
- API 响应类型和 UI 展示模型可以不同,转换应集中在适配边界而不是散落在组件中。
|
||||
- 对可选字段、空列表、错误响应和权限失败使用显式联合类型或状态类型,避免用空字符串/空对象代表所有失败。
|
||||
- 复用类型前先搜索已有定义,避免多个消费者各自断言同一原始 payload。
|
||||
- 原生页面的动态文本必须经过 HTML 转义;不要把未经校验的 API 字段直接拼入 `innerHTML`。
|
||||
- 历史 cursor 由服务端生成和验证,浏览器只保存/回传 opaque string,不解析或重建 cursor 内部字段。
|
||||
|
||||
## 禁止做法
|
||||
|
||||
- 用 `any` 绕过 API 或表单类型错误。
|
||||
- 对服务端响应直接使用 `as`,没有运行时检查或可信来源说明。
|
||||
- 在 UI 层重复复制后端 DTO 字段,并让两份定义长期漂移。
|
||||
- 为了方便导入而破坏 workspace 包边界。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Verify the patch keeps MAIN, ISOLATED, Service Worker, and Bright ownership separated."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Verify the observer change preserves durable-first and base-candidate-confirmed delivery semantics."}
|
||||
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Verify storeImageUrl removal changes exact snapshots intentionally without weakening duplicate or conflict behavior."}
|
||||
@@ -0,0 +1,54 @@
|
||||
# 统一 OneTalk React 卡片 reader 设计
|
||||
|
||||
## Scope and ownership
|
||||
|
||||
本任务是一个共享实现单元,不拆 child task:订单、询盘、商品都由同一个 MAIN observer、同一 identity/boundary rule 与同一 rendered-card union 承载;拆分会让 strict classifier、shared product projection 和 contract shape 在不同任务内失配。
|
||||
|
||||
仅修改 contract 的 rendered-product shape、Chrome extension 的 MAIN card reader/test,以及恢复 rendered-card ledger 已承诺的 scope index 查询。既有 page bridge、ISOLATED forwarder、Service Worker coordinator、ledger 状态机、Bright flow、server supplement repository 和 Mind publisher 不改行为。
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
mounted .message-item-wrapper
|
||||
-> common bounded Fiber identity reader
|
||||
-> strict card classifier
|
||||
-> order | inquiry | product typed projection
|
||||
-> exact OneTalkRenderedCardContent validation
|
||||
-> existing page bridge / ledger / Bright / server supplement
|
||||
```
|
||||
|
||||
MutationObserver 只发现挂载;Fiber `itemData` 继续提供 message identity、conversation cross-check、direction 与 sentAt evidence。模板 `props.data` 只能由各 reader 的直接 predicate 选中,不能递归复制对象。每次读取前后均要求唯一 selected conversation 未变化。
|
||||
|
||||
## Strict classifiers and projections
|
||||
|
||||
| Kind | Required identity/classifier | Template predicate and projection |
|
||||
| --- | --- | --- |
|
||||
| order | `msgType=10010`, `originalData.cardType=9` | `data.cardTitle`、商品列表、`orderStatusText`、`shippingAddress`;列表项 title allowlist 增加真实 `productName`,image allowlist 保持 `productImage` 等现有路径。 |
|
||||
| inquiry | `msgType=10010`, `originalData.cardType=6` | `data.inquiryCardDTO`、`displayProducts/products`、`inquiryContent`、`inquiryID`;首个 displayed product 映射 name/image/MOQ/unit;动作按 `detailStr/detailAction`、`quoteStr/quoteAction`、`actionName/inquiryAction` 的固定顺序产生 `{ label, available }`,不穿透动作对象。 |
|
||||
| product | `msgType=101` 且同一有界 Fiber 子树存在 renderer `cardType=54` | `data.title`、`productImage`、`price`、`moq`、`moqUnit`、`productAction`;`productAction.actionParams.url` 经 canonical product URL normalizer 得到 product reference;固定顺序收集非空 `benefitFirstText`、`benefitSecondText`、`promotionTag` 为 badges。 |
|
||||
|
||||
只有上述三种分类可产生 observation。文件 `cardType=12`、未知 cardType、缺失 template、非 `cardType=54` 的 `msgType=101`、身份或会话不一致均返回 `null`,不会写 ledger。
|
||||
|
||||
## Shared contract changes
|
||||
|
||||
- 从 `OneTalkRenderedProductContent` 删除 `storeImageUrl`,并从精确键校验、clone/canonical fingerprint 的输入 shape、所有 fixture 与断言中去除。当前仓库不存在 rendered-product producer 或 stored fixture,故不需要 JSONB migration/backfill;新的 strict decoder 不接受该旧键。
|
||||
- 把 product URL canonicalization/validation 提取为不依赖 `content.ts` 或 `rendered-cards.ts` 的 contract-owned module。避免 `content.ts` 已依赖 rendered-card union 时产生循环依赖。
|
||||
- 基础消息 `kind: "product"` 与 rendered-card product reader 共用该 normalizer;rendered-card validator 要求 `sourceUrl` 是 canonical product URL 且从路径导出的 product ID 与 payload `productId` 精确相等,不能复用图片 URL validator。
|
||||
- `product.imageUrl` 仍单独使用 card image URL validator。商品没有独立店铺图的运行时证据,contract 中不保留任何替代字段。
|
||||
|
||||
## Safety and compatibility
|
||||
|
||||
- 不桥接 raw `content`、`originalData`、`inquiryCardDTO`、action params、callback、trace、hidden ID、URL query/fragment/credential、DOM/Fiber reference。
|
||||
- 既有 rendered-card ledger 的 first-content-wins、fingerprint、ACK 与 conflict 语义不变;contract 收窄后 fingerprint 自然只基于允许字段。
|
||||
- 当前协议的唯一运行 producer 是订单 reader;当前 source tree 中没有 rendered-product producer。因此删除未生产字段不需要服务器 migration 或 wire-version bump;新 extension 与 server 必须仍使用同一 contract build。
|
||||
- rendered-card ledger 必须由 v9 升级到 v10,且 v10 只在 `onetalk_rendered_card_ledger` 创建 `[channelAccountId, status]` 与 `[channelAccountId, conversationId]` indexes。`listPending(channelAccountId)` 通过前者读取 `[channelAccountId, "pending_ack"]`;`clearConversationHistory` 通过后者在既有 transaction 中删除精确会话。两者都不恢复 `readAll()`,不清理或修改范围外的 ledger record。
|
||||
- 实际扩展重载和重新打开 OneTalk 标签才会让 MAIN/ISOLATED/Worker 均加载新 bundle。该用户可见操作不在本 task 的自动验证授权内。
|
||||
|
||||
## Test design
|
||||
|
||||
- Contract: three valid card variants; product reference canonical/ID match; reject `storeImageUrl`、image URL as source URL、query/credential/fragment/non-product product URL and mismatched ID.
|
||||
- MAIN observer: fixture each true card; verify exact normalized content and base evidence; reject classifier lookalikes, files, missing data, conversation switch and unsafe URL/text.
|
||||
- Existing bridge/coordinator regression: accepted observation remains durable-first and waits for confirmed base candidate. No test should reimplement downstream behavior already owned there.
|
||||
- Storage: a v9 rendered-card record survives the v10 upgrade; scoped `listPending` returns only target-account pending records and does not call object-store `getAll()`.
|
||||
- Storage: the existing history-cleanup regression proves only the matching account/conversation rendered-card record is deleted through the new scope index.
|
||||
- Runtime: with user authorization to reload/reopen only if needed, use Chrome read-only probes to verify the three classifier/template predicates and only normalized bridge output. Database acceptance remains conditional on an existing confirmed base fact and valid authenticated Bright connection.
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Preserve MAIN-to-bridge ownership and exact payload boundaries while adding three typed readers."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Preserve durable-first rendered-card upload, confirmed-base gating, and ACK recovery."}
|
||||
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Keep the rendered-card fingerprint and conflict snapshot semantics intact after contract narrowing."}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 统一 OneTalk React 卡片 reader 实施计划
|
||||
|
||||
## Preconditions
|
||||
|
||||
- 在修改 shared symbols 前,对 contract normalizer、rendered-card validator 和 card observer entry 执行 GitNexus upstream impact;若 risk 为 HIGH/CRITICAL,先向用户报告 blast radius。
|
||||
- 读取 `trellis-before-dev` 和受影响 layer 指南,确认当前 worktree、contract build 输入与现有测试命令。
|
||||
|
||||
## Ordered implementation
|
||||
|
||||
1. 以 failing storage test 复现 `listPending` 的缺失 helper,并覆盖 v9→v10 record 保留、账号/status scope 与禁止 `getAll()`。
|
||||
2. 仅新增 rendered-card ledger 的 `[channelAccountId, status]` 与 `[channelAccountId, conversationId]` indexes,升级 IndexedDB 到 v10;将 `listPending` 改为必填账号的 indexed read,并让既有 `clearConversationHistory` 以第二个 index 删除精确会话;不改变 ledger record、ACK 或其它清理语义。
|
||||
3. 提取 contract-owned product URL canonicalizer,使基础 product content 与 rendered-product validator 共享同一 host/path/ID 规则;保留基础 product 行为的回归。
|
||||
4. 收窄 `OneTalkRenderedProductContent`:删除 `storeImageUrl`,改 rendered product URL 校验为 canonical product reference + exact `productId`,更新 export/types/contract fixtures/fingerprint assertions。
|
||||
5. 将 card observer 重构为 common bounded identity/Fiber helpers、strict classifier 和三种 typed reader。保持 observer lifecycle、selected conversation before/after check 和 base evidence 原样。
|
||||
6. 扩展 shared product item projection 以接受 `productName`;实现 order、inquiry、product 的 exact template predicates 和白名单 projection。商品仅写 `product.imageUrl`,不读取或输出 `hsfImg`。
|
||||
7. 为三类 reader 添加真实形状 fixture,覆盖 valid path、every classifier false path、缺失/unsafe fields、会话失配与 exact output schema;更新 contract and observer targeted tests。
|
||||
8. 运行 extension/contract focused tests,然后 format/typecheck/build。检查 bridge/coordinator regressions,确认无协议、ledger 状态机、server schema 或 migration diff。
|
||||
9. 在本地 diff review 后,用 Chrome 只读探针验证三类 runtime predicate;若需要 reload extension/reopen OneTalk tab,先取得用户授权。仅在基础 message candidate 已确认且 Bright 已认证时验证 ledger/DB 后续边界。
|
||||
|
||||
## Validation
|
||||
|
||||
1. `pnpm --filter @trade-message-center/onetalk-contract test`
|
||||
2. `pnpm --filter @trade-message-center/chrome-extension test -- onetalk-rendered-card-observer`
|
||||
3. 相关 contract/extension test files 的 Node test 命令(单次 backend command 如需运行,上限 60 秒)
|
||||
4. `pnpm format:check`
|
||||
5. `pnpm typecheck`
|
||||
6. `pnpm build`
|
||||
7. `git diff --check`,GitNexus `detect_changes()`,并复核所有 changed symbols/flows
|
||||
8. Chrome read-only runtime probes;可用时再报告 ledger/ACK/DB 证据,不能以 bundle 或单元测试替代。
|
||||
|
||||
## Rollback and risk controls
|
||||
|
||||
- 在 shared contract validation 失败或 contract build 不一致时,不加载新 extension bundle;修正 contract parity 后再验证。
|
||||
- 不引入 URL fallback、通用 props crawl、DOM text inference 或自动点击来提高命中率;任何未知模板继续拒绝。
|
||||
- 只允许 rendered-card ledger 的 v10 index-only 迁移。若后续发现外部环境已有携带 `storeImageUrl` 的 rendered-product rows,停止部署并另建兼容/数据迁移任务,不能在本 task 中静默接纳旧 shape。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 统一修复 OneTalk React 卡片观察
|
||||
|
||||
## Goal
|
||||
|
||||
让已挂载的 OneTalk 订单、询盘与商品卡片均能由 MAIN world 的 React Fiber 白名单读取器产生受控的 rendered-card observation,并沿既有 bridge、durable ledger、Bright 补全事实与 Mind `message.updated` 链路入库和读取。基础消息事实及其同步语义保持不变。
|
||||
|
||||
## Confirmed facts
|
||||
|
||||
- 已有 observer 仅接受订单 guard(`msgType=10010` 与 `originalData.cardType=9`),因此询盘和商品在归一化前被拒绝。
|
||||
- 真实订单通过 identity、卡片类型和模板谓词;其 `productInfoList/productList` 项实际使用 `productName` 与 `productImage`。现有共享商品投影仅接受 `title`、`productTitle`、`name`,导致订单整体 observation 为 `null`,本地 rendered-card ledger 没有记录。
|
||||
- 真实询盘使用 `msgType=10010` 与 `originalData.cardType=6`。其 Fiber `memoizedProps.data` 含 `inquiryCardDTO`、`displayProducts/products`、`inquiryContent`、`inquiryID`;商品项使用 `productName`、`productImage`、`productMOQ`、`productUnit`。该样本中 `inquiryContent`、`richTextContent` 与 `inquiryCardDTO.summary` 相等;可见标签来自 `detailStr`、`quoteStr`、`actionName`。
|
||||
- 14 条真实 `msgType=101` 消息中,仅一条在 Fiber 子树有 `props.cardType=54`,因此商品必须同时满足两层 guard。该模板的 `props.data` 含 `title`、`productImage`、`price`、`moq`、`moqUnit`、`productAction`、`benefitFirstText`、`benefitSecondText` 与 `promotionTag`。
|
||||
- 商品 `productAction.actionParams.url` 是无凭据、无端口、无 query/fragment 的 `https://chinese.alibaba.com/product-detail/...`;其路径产品 ID 与 `hsfId` 一致,可经现有 `normalizeOneTalkProductUrl` 产生安全的 `sourceUrl` 与 `productId`。
|
||||
- 当前 `rendered_product.product.sourceUrl` 错误地使用卡片图片 URL 白名单校验;该白名单不含 `chinese.alibaba.com`,与 `normalizeOneTalkProductUrl` 的唯一允许结果不相容。因此在启用商品 reader 前,contract 必须改为以 canonical product URL 规则验证 `sourceUrl` 与 `productId`。
|
||||
- 商品 `productImage` 与 `hsfImg` 都通过现有图片 URL 白名单,但本次样本二者相同,无法将 `hsfImg` 证明为独立的店铺图。两个值均在 Fiber 子树的 image URL props 中传递;实际 DOM 图片已被渲染器转换,无法作同值关联。用户明确决定商品只保留商品图,不保留或构造店铺图。
|
||||
- 既有专用 bridge、rendered-card ledger、`rendered.card.observed/ack`、服务端补全表和读取投影已经处理受控 `OneTalkRenderedCardContent`。本 task 不改变它们的身份、ACK、冲突、授权、发布或数据库语义。
|
||||
- 已确认 `6be3b839` 新增 rendered-card ledger 的 `listPending()` 后,`61df41d` 的 IndexedDB 索引重构删除了通用 `readAll()`,却没有迁移该 consumer,导致 Chrome extension TypeScript check 在未定义的 `readAll` 处失败。用户已授权把这个阻塞本 task 验证的缺口作为最小前置修复。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. One dispatcher, three strict readers
|
||||
|
||||
- 保留 MutationObserver 仅作为 `.message-item-wrapper` 挂载触发器,且保留现有 `messageId + conversationCode + channelAccountId` 身份、会话前后不变和 base evidence 校验。
|
||||
- 将当前订单专用 reader 改为按明确 classifier 分派的三个 typed reader;不得泛化地递归读取或桥接完整 props。
|
||||
- 订单仅接受 `msgType=10010` 与 `originalData.cardType=9`,并要求已验证的订单模板谓词。
|
||||
- 询盘仅接受 `msgType=10010` 与 `originalData.cardType=6`,并要求已验证的询盘模板谓词。
|
||||
- 商品仅接受 `msgType=101` 且同一受限 Fiber 子树存在 renderer `cardType=54`,并要求已验证的商品模板谓词;其余 `msgType=101` 必须继续忽略。
|
||||
- 未知卡、文件卡、身份不完整、会话切换、缺失模板或任一白名单字段不满足时 fail closed,且不退回 DOM 文案、节点顺序、CSS class 或 raw `originalData.params` 推断。
|
||||
|
||||
### R2. Exact white-listed projections
|
||||
|
||||
- 订单商品投影支持已验证的 `productName` 和 `productImage`,并保留现有兼容字段;订单其余内容保持既有受控 contract。
|
||||
- 询盘投影为 `rendered_inquiry`:商品名/图片、`productMOQ + productUnit`、`inquiryContent`、`inquiryID` 和仅标签/可用性的动作信息。不得跨出 action params、callback、URL、原始 DTO 或原始 content。
|
||||
- 商品投影为 `rendered_product`:`title`、`productImage`、`price`、`moq + moqUnit`、经 `normalizeOneTalkProductUrl` 清洗的 `productAction.actionParams.url`,以及仅当前可见且非空的服务/促销文案。`storeImageUrl` 必须从 shared contract、校验、fingerprint、reader、bridge payload、持久化 JSON 和测试样例中完全删除。不得桥接 `traceInfo`、action params、action list 或产品原始对象。
|
||||
- `rendered_product.product.sourceUrl` 必须由 product URL canonicalizer 验证,并与 `productId` 对应;不得使用图片 URL validator 代替产品引用 validator。
|
||||
- 每个 reader 必须依赖共享 contract 的文本、图片 URL、产品 URL、fingerprint 与 equality 规则;不得自行复制安全校验或构造不安全 URL。
|
||||
|
||||
### R3. Preserve the established downstream boundary
|
||||
|
||||
- 三类 observation 统一走已有 page bridge、Service Worker coordinator、独立 ledger、Bright ACK 和 server supplement 流程;不得以新消息、重跑基础同步或直接写数据库的方式绕过它。
|
||||
- 基础 `content`、消息/会话时间字段、checkpoint、history cursor、`message.created` 和重复/冲突语义不得改变。
|
||||
|
||||
### R4. Regression and runtime verification
|
||||
|
||||
- 为订单、询盘、商品各添加 reader fixture;覆盖真实字段名、strict classifier、模板缺失、身份/会话失配、文件/未知卡及非商品 `msgType=101`。
|
||||
- 订单 regression 必须证明 `productName` 不再使整张卡拒绝;询盘和商品 regression 必须证明只生成各自的 exact contract shape、且不含原始或可执行字段。
|
||||
- 保留并扩展 observer/bridge/coordinator tests,证明 observation 仍先写 ledger,且只在基础 candidate 已确认后发送。
|
||||
- 在真实 Chrome 页面无点击、无刷新地验证三类 card reader 只产生规范化 observation;数据库验证必须在对应基础消息存在、连接授权且 ACK 成功的边界下单独报告。
|
||||
|
||||
### R5. Repair the rendered-card ledger's indexed pending query
|
||||
|
||||
- 把 `listPending` 收窄为必填 `channelAccountId`,并只读取该账号的 `pending_ack` rendered-card ledger records;不得恢复全 store `getAll()` 或在内存过滤所有账号。
|
||||
- 以新 IndexedDB version 只为 `onetalk_rendered_card_ledger` 新增 `[channelAccountId, status]` 与 `[channelAccountId, conversationId]` indexes。现有 records 必须保留,不能清库、重键、回填或改变 ACK/first-content-wins 行为。
|
||||
- `clearConversationHistory` 必须在其既有 readwrite transaction 中通过 `[channelAccountId, conversationId]` 删除同一会话的 rendered-card records;这恢复其已有测试和 history rebuild 语义,不扩大清理范围。
|
||||
- 为由旧 v9 数据库升级的 record 保留、pending scope 查询和 conversation cleanup 添加回归;此 scope 只修复 index refactor 的遗漏,不改变 bridge、ledger 状态机、wire、服务端或业务卡 reader 行为。
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 不改变 rendered-card wire version、bridge envelope、服务端 schema/migration、授权、ACK/冲突状态机或 Mind 事件语义。唯一例外是 R5 的 IndexedDB v10 index-only 升级。
|
||||
- 不主动点击、翻页、导航、请求额外 OneTalk 接口或改写 React/OneTalk 页面状态。
|
||||
- 不采集文件、图片消息、文本消息、名片、未知卡或任何未白名单的 Fiber/DOM 字段。
|
||||
- 不将订单状态变化视为可覆盖的补全更新,也不将卡片动作变为可执行命令。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 已挂载的真实订单可产生 `rendered_order` observation,并且实际 `productName` 作为商品标题进入受控 contract。
|
||||
- [ ] 满足 `cardType=6` 的询盘可产生完整、受控的 `rendered_inquiry` observation;文件卡和非询盘 `msgType=10010` 不产生 observation。
|
||||
- [ ] 仅同时满足 `msgType=101` 和 renderer `cardType=54` 的商品可产生 `rendered_product` observation;其他 `msgType=101` 保持忽略。
|
||||
- [ ] 三类 projection 均通过 contract 验证、没有 raw content/DTO/params/callback/trace 信息、隐藏 ID 或未经批准 URL;`rendered_product` 的任何路径均不包含 `storeImageUrl`。
|
||||
- [ ] canonical 的 `chinese.alibaba.com/product-detail/...` 商品引用通过 rendered-product contract;图片 URL、带 credential/fragment 的 URL、非 canonical product path 或与 URL 不一致的 `productId` 均被拒绝。
|
||||
- [ ] 订单、询盘、商品的 targeted reader/observer/coordinator tests 通过,且现有 rendered-card、基础同步和历史读取回归不变。
|
||||
- [ ] 真实 Chrome 只读探针确认三类卡各自命中正确 classifier 和模板谓词;端到端入库仅在不触发主动同步的前提下、已有基础消息确认时验证。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 商品只保存 `product.imageUrl`。`storeImageUrl` 没有业务含义或运行时证据,必须完全删除;不得以 `null`、`hsfImg` 或任何替代字段保留该概念。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "onetalk-rendered-card-readers",
|
||||
"name": "onetalk-rendered-card-readers",
|
||||
"title": "统一修复 OneTalk React 卡片观察",
|
||||
"description": "统一实现订单、询盘和商品的 React Fiber 卡片读取、白名单投影及回归验证。",
|
||||
"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-onetalk-rendered-card-readers",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "跨包所有权与边界验收。"}
|
||||
{"file": ".trellis/spec/project/async-state-boundaries.md", "reason": "异步命令、ACK 与状态转换检查。"}
|
||||
{"file": ".trellis/spec/project/database-query-composition.md", "reason": "事务范围与无 JOIN 规则。"}
|
||||
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "服务端检查命令和测试要求。"}
|
||||
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "HTTP/WS 错误映射验收。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/quality-guidelines.md", "reason": "扩展检查命令和测试要求。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "plugin durable reset 与 completion fence 验收。"}
|
||||
{"file": ".trellis/tasks/09-12-mind-rebuild-conversation-history/research/rebuild-reset-protocol.md", "reason": "重建边界、race matrix 与运行时验证前提。"}
|
||||
@@ -0,0 +1,81 @@
|
||||
# 单会话历史重建设计
|
||||
|
||||
## 边界与成功语义
|
||||
|
||||
Mind 通过 `POST /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/history/rebuild` 请求重建。该接口的成功仅表示 Bright 已在一个数据库事务内删除目标消息/会话级异常并重置目标会话的消息派生索引;它不表示插件已经拉完历史,也不表示 Mind 已经看到新消息。
|
||||
|
||||
请求必须具备新的 `rebuild` permission、合法 Mind Origin/Cookie、当前 cutover admission 和精确的 `channelAccountId` scope。Bright 只选择一个同时匹配 Mind scope、binding、authorization version、`rebuild` permission 且 heartbeat lease 新鲜的 canonical plugin。`isPluginOnline()` 仅说明存在连接,不能作为该判断的证据。
|
||||
|
||||
操作状态仅在进程内存在,用于同会话互斥、回执等待和连接/策略代际检查;不新增可恢复的 rebuild operation 表。进程重启、插件断线、命令超时或数据库失败会留下稳定原因,Mind 可重新提交同一会话的重建请求。重试是幂等的 scoped clear/reset,不依赖上一次操作仍在内存中。
|
||||
|
||||
## HTTP 契约
|
||||
|
||||
成功响应为 HTTP 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": { "mindUserId": "…", "workspaceId": "…", "channelAccountId": "…" },
|
||||
"conversationId": "…",
|
||||
"rebuildId": "…",
|
||||
"status": "server_reset_committed",
|
||||
"resync": { "status": "started" }
|
||||
}
|
||||
```
|
||||
|
||||
若 Bright 已提交删除、但后续同步命令未确认,仍返回 HTTP 200,`resync` 改为 `{ "status": "failed", "reason": "…" }`。这符合“服务端删除是唯一成功依据”。在 Bright transaction 提交前失败则不返回成功,使用稳定错误:既有授权/CORS 错误、`conversation_not_found`、`rebuild_in_progress`、`plugin_offline`、`plugin_heartbeat_stale`、`plugin_reset_rejected`、`plugin_reset_timeout`、`database_unavailable` 或 `internal_error`。
|
||||
|
||||
public CORS 需显式允许这个 POST 和 `content-type`;不能把 GET-only read middleware 偷换为状态变更授权。
|
||||
|
||||
## WebSocket 协议
|
||||
|
||||
增加受限的、可复用的 plugin local-storage 命令,不让 Bright 传递任意 IndexedDB store 或 key:
|
||||
|
||||
| 帧 | 方向 | 核心 payload | 职责 |
|
||||
| --- | --- | --- | --- |
|
||||
| `storage.delete.command` | Bright → plugin | `rebuildId`, `{ kind: "conversation_history", conversationId }` | 让插件停止目标会话旧 generation,并删除限定的 durable ledger。 |
|
||||
| `storage.delete.ack` | plugin → Bright | `rebuildId`, `conversationId`, `status`, `reason?` | 只在 IndexedDB transaction `oncomplete` 后确认。 |
|
||||
| `history.sync.command` | Bright → plugin | `rebuildId`, `historyGeneration`, `conversationId`, `mode: "full"` | 在 Bright reset commit 后释放目标会话,并启动 full sync。 |
|
||||
| `history.sync.ack` | plugin → Bright | `rebuildId`, `conversationId`, `status`, `reason?` | 回报已启动或立即失败,供 HTTP 的 `resync` 字段和 Mind status 使用。 |
|
||||
| `rebuild.status` | Bright → Mind | `rebuildId`, `conversationId`, `stage`, `reason?` | 发布 pre-commit 失败、local cleared、server committed、sync started/completed/failed。 |
|
||||
|
||||
这些帧是新领域协议,不能复用 `send.command`、pending-send coordinator 或 `sendRequestId`。协议 decoder 使用精确 key/方向校验。为了让旧页面观测无法在 reset 后重写新事实,`historyGeneration` 必须成为消息观察、消息 ACK、`sync.complete` 和 anchor snapshot 的强制关联字段;这是一项 breaking wire change,应将共享协议从 v6 升到 v7,而不是把该字段做成可选 fallback。
|
||||
|
||||
## 删除和 generation 栅栏
|
||||
|
||||
`storage.delete.command` 的插件实现只在 `trade-message-center` IDB 的一个 readwrite transaction 中,按 `(channelAccountId, conversationId)` 删除:
|
||||
|
||||
- `onetalk_messages`
|
||||
- `onetalk_sync_checkpoints`
|
||||
- `onetalk_sync_candidates`
|
||||
- `onetalk_sync_anomalies`(只删除有完全相同 conversationId 的行;账号级 anomaly 保留)
|
||||
|
||||
不得删除 `onetalk_contact_profiles`、独立数据库 `trade-message-center-onetalk-buyer-facts`、`onetalk_conversation_bootstraps`、Chrome 配置或其它会话的记录。bootstrap marker 是 account/migration 级调度状态,不是单会话 history ledger。
|
||||
|
||||
插件收到 delete command 后把该会话置为 quiesced:等候该会话 queue 和 observation write chain 收敛、取消目标 pending ACK/completion memory,并拒绝旧 generation 的迟到页面观察、ACK 和 page result。删除 ACK 后继续保持 quiesced,不能立即启动 full sync;否则新消息可能在 Bright 随后的 DELETE 前入库。
|
||||
|
||||
Bright 收到匹配 ACK 后重新验证 socket canonical generation、lease、binding、authorization version 和 cutover epoch,并在一个 transaction 内:
|
||||
|
||||
1. 删除目标 `onetalk_message` 和目标 `onetalk_message_anomaly`。
|
||||
2. 保留 `onetalk_conversation` identity/discovery/list-display 字段、`onetalk_contact_profile` 和 `onetalk_buyer_fact`。
|
||||
3. 重置 `lastMessageAtMs`、`latestMessageId`、`historyComplete`、`messageCount`、`anchorUpdatedAt`、`syncPhase`、`syncResult`。
|
||||
4. 写入新的 opaque `historyGeneration`,作为以后该会话所有消息/完成帧的唯一代际。
|
||||
|
||||
`historyGeneration` 是持久化的会话事实边界,不是可恢复的 rebuild operation:它让服务端在 reset 后拒绝 pre-reset frame,并让重启后的 Bright 仍能区分旧 generation。全量同步命令把它传回插件;Service Worker 对目标会话的后续 history 和 live observation 均附带该 generation。服务端只接受与 conversation 当前 generation 相等的观察与 completion。
|
||||
|
||||
## 重建触发与完成
|
||||
|
||||
当前 `maybeBootstrap()` 不能作为触发机制:已完成的 `onetalk_conversation_bootstraps` marker 会抑制 page-ready/reconnect 下的历史 bootstrap,清掉该 marker 又会触发账户级 batch,违反单会话范围。
|
||||
|
||||
Bright commit 后发送 `history.sync.command`。插件先刷新 MAIN-world direct-conversation cache,再复用现有 `startSync({ mode: "full" })` 和精确 `onetalk.sync.conversation` page command;不新增第二套 OneTalk 拉取逻辑。`history.sync.ack` 若超时、断线或页面 cache 缺失,属于 post-commit best-effort 失败并带稳定 reason,Mind 可安全重试整个 HTTP rebuild。
|
||||
|
||||
当同 generation 的 `sync.complete` 已由 Bright 持久化并产生 successful sync status 后,Bright 向插件回发当前 `anchor.snapshot`。这复用现有 plugin completion coordinator,使 checkpoint 从 uploading 收敛;仅收到 plugin `sync.complete` 不足以表示本地 durable completion。`rebuild.status` 会带同一 rebuildId 通知 Mind;它是观察重建是否已拉回的信号,而不是 HTTP 成功判定。
|
||||
|
||||
## 缓存错位处理
|
||||
|
||||
现有模型中,插件是 durable upload ledger:`accepted`/`duplicate` ACK 令本地 candidate 终态,anchor snapshot 只给增量 stop boundary。Bright 单方删除既不会使 confirmed candidate 重传,也不会绕过 completed bootstrap marker 重新拉历史。因此不存在可依赖的“等待自动同步自行修复”机制。
|
||||
|
||||
本设计用 plugin clear -> ACK -> Bright transaction -> explicit release 的顺序消除成功路径错位。若 plugin clear 后 Bright transaction 失败,插件保持目标会话 quiesced,HTTP 返回数据库原因;重试会再次 clear 并尝试 transaction。若 Bright 已提交但 sync command 失败,则两端该会话历史可为空;HTTP 仍成功,Mind 收到明确的 `rebuild.status` reason,并以新请求触发下一次 full sync。
|
||||
|
||||
## 回滚与观测
|
||||
|
||||
提交前失败不删除 Bright 消息;提交后不回滚服务器状态。因为 plugin IndexedDB 与 PostgreSQL 没有跨系统事务,日志/状态只记录 `rebuildId`、conversationId、阶段、稳定 reason,绝不记录消息内容、cookie、binding 或原始 SDK payload。测试必须用可控 Promise 验证每个 await 后的 connection/policy/generation 重校验,且不能把 200 或 `conversation.updated` 误判为 history rebuild completed。
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "跨包契约、数据边界与模块所有权。"}
|
||||
{"file": ".trellis/spec/project/async-state-boundaries.md", "reason": "重建单飞、ACK 与跨 await 重校验。"}
|
||||
{"file": ".trellis/spec/project/structured-value-equality.md", "reason": "rebuildId、generation 与消息事实身份。"}
|
||||
{"file": ".trellis/spec/project/database-query-composition.md", "reason": "scoped reset 事务保持无 SQL JOIN。"}
|
||||
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "HTTP/WS 稳定失败语义。"}
|
||||
{"file": ".trellis/spec/server/backend/mind-authorization.md", "reason": "Mind session、Origin、CORS 与授权边界。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md", "reason": "插件到 Bright 的同步链路与数据边界。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md", "reason": "页面命令与定向路由契约。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "IndexedDB、ACK、checkpoint 和恢复语义。"}
|
||||
{"file": ".trellis/tasks/09-12-mind-rebuild-conversation-history/research/rebuild-reset-protocol.md", "reason": "当前源码证据、reset 序列和竞态风险。"}
|
||||
@@ -0,0 +1,36 @@
|
||||
# 单会话历史重建实施计划
|
||||
|
||||
## 1. Shared contract and generation fence
|
||||
|
||||
1. 将 protocol version 从 v6 升到 v7;在 shared contract 中加入 `rebuild` permission、HTTP rebuild route 常量、稳定 HTTP/action errors、storage delete/sync command/ACK/rebuild status frame 类型、decoder/encoder 和 strict payload tests。
|
||||
2. 为 target conversation 的 `message.observed`、`messages.observed`、对应 ACK、`sync.complete`、`sync.status`/anchor projection 定义必填 `historyGeneration` 关联;更新 wire direction、authenticated route table 和 version-upgrade tests。
|
||||
3. 在 `onetalk_conversation` schema/migration 和 OneTalk model/repository 中加入 current `historyGeneration`,确保新发现会话和 reset 后会话都拥有明确 generation;禁止 optional/legacy fallback。
|
||||
4. 为服务端建立一个 rebuild coordinator owner:同 `(channelAccountId, conversationId)` in-process single-flight、fresh canonical-plugin selector、ACK awaiter 和每个 await 后的 connection/policy/authorization revalidation。它不承担跨重启恢复。
|
||||
|
||||
## 2. Bright reset and HTTP boundary
|
||||
|
||||
1. 在 OneTalk repository/service 添加一个 scoped reset domain operation:确认 direct conversation 存在,在单 PostgreSQL transaction 中删除 target message/anomaly 行,重置所有消息派生字段,写新 generation,保留会话/profile/buyer/list-display字段;不使用 SQL JOIN。
|
||||
2. 新建 rebuild HTTP route,复用 public origin/cutover/session boundary但不复用 read-only permission helper;显式授权 `rebuild`,更新 CORS preflight 允许 POST。
|
||||
3. HTTP flow:选 fresh plugin -> send `storage.delete.command` -> wait matching ACK -> revalidate -> commit server reset -> send `history.sync.command` -> bounded wait `history.sync.ack` -> 返回 server-reset-committed + resync result。commit 前失败为 HTTP error;commit 后 command failure 为 HTTP 200 的 resync failure。
|
||||
4. 向授权 Mind connections 发布 correlated `rebuild.status`。本 operation 的 success stage 固定为 `server_reset_committed`;sync terminal stage是独立观测信息。
|
||||
|
||||
## 3. Plugin scoped cleanup and full-sync reuse
|
||||
|
||||
1. 扩展 `OneTalkSyncStore`,实现按 conversation 扫描/删除四个 target stores 的单一 IDB transaction;添加 targeted test fixture,覆盖非目标会话、account-level anomaly、profile/buyer/bootstrap 保留和 transaction abort 无 ACK。
|
||||
2. 在 sync engine/ack coordinator/queue 中实现 per-conversation quiesce and generation owner;清理仅该会话的 active maps、pending ACK/completion,并阻断旧 generation 的迟到结果,不调用全连接 `resetConnection()`。
|
||||
3. 在 worker frame router 新增 storage delete 和 history sync command handlers。delete ACK 只能在 durable clear 后发出;sync command 先刷新 target page cache,再调用现有 `startSync(full)`,为所有 target observation/completion 绑定 generation,并回报 start outcome。
|
||||
4. 复用 existing `anchor.snapshot` completion path:同 generation `sync.complete` 成功持久化后,接受 Bright refreshed anchor snapshot 才结束 local checkpoint。不要修改为在插件发送 `sync.complete` 时自行宣告成功。
|
||||
|
||||
## 4. Tests and validation
|
||||
|
||||
1. Contract focused tests:v7 decoder directions/exact keys、permission、generation required、unknown/old frame rejection、rebuild status correlation。
|
||||
2. Extension focused tests:IDB scoped clear、quiesce/generation matrix、old page observation/ACK/completion no-op、fresh full start uses exact conversation command、bootstrap marker never triggers account batch、sync-start failure reason。
|
||||
3. Server focused tests:HTTP auth/origin/CORS/errors;fresh-heartbeat exact plugin selection;ACK-before-delete order;transaction scope/preservation; post-commit sync failure still 200;duplicate retry;Mind status correlation;plugin replace/revoke/pause/timeout database-failure matrices。
|
||||
4. Run targeted tests first, then `pnpm typecheck`, relevant package builds, root format/diff checks and GitNexus `detect_changes()` before commit. Backend tests use a 60-second timeout; PostgreSQL integration uses only an isolated test DB and is reported as skipped if unavailable.
|
||||
5. Runtime smoke, if dedicated test account/plugin are available: POST one conversation rebuild, capture delete command/ACK, inspect four local stores and Bright rows, verify post-commit full-sync command, refreshed anchor snapshot and Mind status. Never exercise a real customer conversation.
|
||||
|
||||
## Review gates
|
||||
|
||||
- Before code edits: run GitNexus upstream impact on every target handler/function and report any HIGH/CRITICAL blast radius.
|
||||
- Before integrating server/plugin changes: verify one contract owner, no raw IndexedDB store-name payload, no raw OneTalk SDK data crossing the bridge, and no secondary history source.
|
||||
- Before start/commit: verify all generation checks cross each await, `server_reset_committed` is not confused with `sync_completed`, and the diff does not alter send or account-wide bootstrap behavior.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Expose HTTP endpoint for Mind to rebuild one conversation's history
|
||||
|
||||
## Goal
|
||||
|
||||
Allow Mind to start an unconditional, full reconstruction for exactly one authorized OneTalk conversation through a new Bright HTTP endpoint. Reconstruction first removes the targeted message and history-sync state from both Bright and the connected plugin, retains the conversation record and customer-profile facts, then starts a fresh full OneTalk history collection.
|
||||
|
||||
## Confirmed facts
|
||||
|
||||
- Bright already exposes `GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages`; it authorizes the requesting Mind session for the path account and returns one page of persisted messages plus an opaque cursor. [apps/server/src/http/onetalk/messages.ts:15-51; apps/server/src/http/onetalk/read.ts:72-121]
|
||||
- This public route uses the Mind session authorization `read` permission, exact configured origin/CORS handling, and a cutover-policy admission epoch. [apps/server/src/http/onetalk/public-context.ts:65-120; apps/server/src/http/onetalk/public-context.ts:143-215]
|
||||
- The only current `historyComplete` gate is the internal summary listener. It requires an explicit time window and returns `503 history_incomplete` with `Retry-After: 30` until the stored conversation is complete. [apps/server/src/http/onetalk/summary.ts:37-84; apps/server/src/http/onetalk/read.ts:83-114; apps/server/src/onetalk/read-service.ts:160-176]
|
||||
- Mind's current WebSocket command surface accepts only `send.request` and `heartbeat`; OneTalk collection/synchronization is not currently a command exposed to Mind. [apps/server/src/websocket/mind/index.ts:50-91]
|
||||
- Stored facts remain scoped by `channelAccountId + conversationId + messageId`; the read service creates opaque cursors bound to both the account and conversation. [apps/server/src/onetalk/read-service.ts:54-76; apps/server/src/onetalk/read-service.ts:186-216]
|
||||
- Bright has no per-conversation destructive history operation. The message table is separate from `onetalk_conversation`; profile and buyer-fact tables deliberately have no foreign key to the conversation row. [apps/server/src/database/schema/onetalk.ts:60-180; apps/server/src/database/schema/onetalk.ts:182-315]
|
||||
- Plugin IndexedDB stores per-conversation messages, checkpoints, candidates, and scoped anomalies separately from contact-profile ledger records. Its current public store exposes no per-conversation deletion operation. [apps/chrome-extension/src/onetalk/service-worker/storage.ts:8-177; apps/chrome-extension/src/onetalk/service-worker/storage.ts:574-744]
|
||||
- The Bright-to-plugin protocol has no history-rebuild command or acknowledgement today; the only server-initiated plugin command is the separately coordinated send command. [packages/onetalk-contract/src/wire.ts:43-164; apps/server/src/websocket/registry.ts:34-119; apps/server/src/websocket/pending-send-coordinator.ts:123-255]
|
||||
|
||||
## Agreed product decisions
|
||||
|
||||
- Add a **new** HTTP endpoint rather than changing the existing paginated message-read endpoint.
|
||||
- The action is an unconditional reconstruction: it must not reject merely because `historyComplete` is already true, false, or the stored history is stale.
|
||||
- Scope is one `channelAccountId` and one `conversationId` per request.
|
||||
- Preserve the existing Bright `onetalk_conversation` row and customer-profile facts on both Bright and the plugin.
|
||||
- Clear the targeted message facts and history indexes on both Bright and the plugin before starting a fresh full collection.
|
||||
- Add a reusable Bright-to-plugin WebSocket storage-deletion command with a correlated acknowledgement. The concrete rebuild operation uses its `conversation_history` target rather than allowing Bright to name arbitrary IndexedDB stores.
|
||||
- The required orchestration order is: Mind request -> Bright verifies one fresh, authorized plugin heartbeat -> plugin durable deletion acknowledgement -> Bright deletes/reset its scoped facts -> Bright sends an explicit full single-conversation synchronization command -> Mind receives correlated follow-up status.
|
||||
- Bright's committed deletion/reset of the target conversation's server-side message facts and indexes is the sole success criterion for the HTTP rebuild operation. Plugin resynchronization is a best-effort follow-up: its failure does not roll back or change a committed server reset.
|
||||
- Do not persist rebuild operations for crash recovery. A retry is allowed to repeat the idempotent plugin clear and Bright reset; a pre-commit failure returns its reason, while a post-commit resynchronization failure is reported separately as a non-successful follow-up.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The endpoint is a state-changing HTTP operation, not a history read. It must use the existing Mind session/origin/cutover boundary and require a new explicit `rebuild` permission; the existing `read` permission must not authorize deletion.
|
||||
- The endpoint is expected to be `POST /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/history/rebuild`. It returns success only after the Bright delete/reset transaction commits, together with a request/rebuild ID and the best-effort resynchronization dispatch outcome; it must not return success merely because the request was parsed.
|
||||
- The generic plugin command has a semantic target such as `conversation_history` and a caller-generated operation ID; it does not expose implementation store names over the wire. Its acknowledgement proves the scoped IndexedDB transaction committed before Bright removes its own facts.
|
||||
- The server reset must delete only target-scope `onetalk_message` and `onetalk_message_anomaly` facts. In the same transaction it must reset the conversation's message-derived state (`lastMessageAtMs`, `latestMessageId`, `historyComplete`, `messageCount`, anchor state, and sync result), without deleting the conversation row or customer-profile/buyer facts. It retains the account-level anomalies that have no `conversationId`.
|
||||
- The plugin reset must delete records for the target `(channelAccountId, conversationId)` from `onetalk_messages`, `onetalk_sync_checkpoints`, `onetalk_sync_candidates`, and `onetalk_sync_anomalies`. It must leave `onetalk_contact_profiles` and the separate `trade-message-center-onetalk-buyer-facts` database intact. The account-wide `onetalk_conversation_bootstraps` marker is not a per-conversation history index and must not be deleted or rewritten by this command.
|
||||
- The rebuild coordinator must choose exactly one canonical plugin whose heartbeat lease is fresh and whose scope, binding, authorization version, and `rebuild` permission match the Mind request. Existing `isPluginOnline` is insufficient because it only proves a connection exists, not a fresh heartbeat.
|
||||
- An explicit server-to-plugin full-sync command must refresh the target page cache and reuse the existing plugin `startSync({ mode: "full" })` / exact `onetalk.sync.conversation` page-command path. Waiting for `maybeBootstrap()` is incorrect: a completed account-wide bootstrap marker suppresses reconnect/page-ready bootstrap work.
|
||||
- The storage deletion command leaves the target conversation quiesced after its ACK. Only after Bright commits the server reset may a correlated single-conversation full-sync command release it. This prevents a newly observed full-history batch from being deleted by the server transaction that follows the plugin ACK.
|
||||
- The storage deletion and resync commands need a single per-conversation operation/generation correlation. It must flow through targeted page observations/progress, plugin ACK/completion, and Bright's observation writer so that late pre-reset messages, ACKs, or page results are discarded instead of being written into the reconstructed generation.
|
||||
- After Bright accepts the reset-triggered `sync.complete`, it must send the existing refreshed `anchor.snapshot` evidence back to the plugin so the plugin's existing completion coordinator can close the local checkpoint; a plugin-originated `sync.complete` alone is not its durable local completion signal.
|
||||
- Mind receives a correlated `rebuild.status` WebSocket event for pre-commit rejection, storage-cleared, server-reset-committed, sync-started, sync-completed, and best-effort sync failure stages. HTTP success means `server-reset-committed`; it does not claim that full history has already reappeared. A bare uncorrelated `sync.status` or `conversation.updated` must never be treated as reconstruction completion.
|
||||
- Mind must not receive unscoped, account-wide, or raw OneTalk SDK payloads.
|
||||
- Authorization, origin handling, cutover admission, and error semantics must stay fail-closed.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An authorized Mind caller can POST one exact direct conversation; the route rejects a missing/invalid `rebuild` permission, mismatched account scope/origin, unavailable cutover, unknown conversation, concurrent same-conversation operation, missing plugin, stale heartbeat, reset rejection, timeout, and database failure with stable reasons and no unintended server deletion.
|
||||
- [ ] Before Bright deletes any server facts, exactly one matching fresh plugin receives a semantic `conversation_history` storage-delete command and returns a correlated durable ACK; a stale/replaced/revoked connection cannot advance the operation.
|
||||
- [ ] The plugin clear changes only target-scope message/checkpoint/candidate/conversation anomaly records. It preserves target profile/buyer records, account bootstrap marker, account-level anomalies, configuration, and every other conversation's state.
|
||||
- [ ] On a matching ACK, one Bright transaction deletes only target message/conversation-anomaly rows and resets the stated derived conversation fields while preserving the conversation, profile, buyer facts, and list-display fields.
|
||||
- [ ] A committed Bright reset returns HTTP success even if the subsequent sync dispatch/start fails; the response and correlated Mind status expose the exact post-commit failure reason. A pre-commit failure returns no success.
|
||||
- [ ] A post-commit release invokes only the target full-sync path, never account-wide bootstrap/batch collection; a matching generation is required for all resulting observations and completion. Late old-generation page results, observations, ACKs, completions, replacement sockets, and policy changes cannot rewrite the reset conversation.
|
||||
- [ ] Bright sends refreshed anchor evidence after accepting the reset generation's sync completion; Mind can distinguish `server_reset_committed`, `sync_started`, `sync_completed`, and best-effort sync failure by `rebuildId`, without treating HTTP success or a generic conversation update as full-history completion.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Reconstructing multiple conversations or the whole account in one request.
|
||||
- Duplicating messages into Mind's database or making Mind read Bright's database directly.
|
||||
- Deleting the conversation row, contact-profile facts, buyer facts, or account-wide bootstrap state.
|
||||
- Changing the OneTalk fact identity, live WebSocket send protocol, or unrelated history reads.
|
||||
- Clearing the account-wide discovery bootstrap marker, the contact-profile ledger, buyer-fact ledger, or unrelated conversations' IndexedDB records.
|
||||
|
||||
## Cache-mismatch behavior
|
||||
|
||||
- The plugin's IndexedDB is a durable upload ledger, not a server-history cache. `accepted`/`duplicate` ACKs mark local candidates as delivered, and `anchor.snapshot` supplies only the server's newest stop boundary for later incremental reads.
|
||||
- Bright deleting a conversation does not notify an existing plugin. A plugin with confirmed local candidates will not replay them; an account bootstrap marker already in `complete` also suppresses automatic historical bootstrap. Therefore neither side can repair this deletion mismatch by waiting for current automatic sync alone.
|
||||
- The rebuild flow repairs the mismatch by deleting the plugin's target ledger first, then committing Bright's target reset, then explicitly releasing the plugin's existing one-conversation full-sync path. If the server transaction fails after plugin deletion, the request fails with its database reason and retry repeats the safe clear/reset sequence. If the post-commit sync dispatch/start fails, the HTTP request remains successful and Mind receives the specific follow-up reason; retry can initiate another full reconstruction.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# 研究:单会话重建 reset 协议与执行序列
|
||||
|
||||
- 查询:核对 Mind HTTP → Bright 授权/插件 presence → Bright→插件 IndexedDB 清理 → ACK → Bright 数据库清理 → 单会话 full sync → Mind 完成通知;确认现有 store/key、服务端表/字段、`maybeBootstrap`/`startSync` 复用性及 reset 竞态。
|
||||
- 范围:internal
|
||||
- 日期:2026-09-12
|
||||
- 协调类别:cross-cutting
|
||||
- 阻塞:HTTP rebuild 路由与授权、shared WebSocket contract/decoder、Bright plugin command routing/registry、插件 IndexedDB reset 与 sync engine、Bright OneTalk repository/状态通知;这些 writer 共享 resetId/generation 与 ACK/完成语义,不能各自先写。
|
||||
- 共享边界:`packages/onetalk-contract/src/{wire,decoder,conversation-sync,messages}.ts`;`apps/server/src/{http/onetalk/public-context.ts,websocket/{connection-store,registry}.ts,websocket/plugin/index.ts,onetalk/{repository,model,service}.ts}`;`apps/chrome-extension/src/onetalk/service-worker/{storage.ts,routing/frame-router.ts,sync-engine.ts,sync-engine/bootstrap-coordinator.ts,sync-engine/ack-completion.ts}`;`apps/chrome-extension/src/onetalk/main-page/current-conversation-history/{all-conversations.ts,page-command.ts}.ts`。
|
||||
- 证据基线:checkout `/Users/ybf/code/trade-message-center-worktree`,HEAD `ce61b4cc72f72ba96167c59b9791241856d4c3cc`;`git status --short --branch` 仅显示任务目录未跟踪文件,无产品代码 dirty path。GitNexus worktree index 记录同一 HEAD(2026-09-12 08:34 UTC),未运行服务、浏览器、真实 WebSocket 或 PostgreSQL runtime probe。
|
||||
- 复用证据与缺口:既有任务 `prd.md` 已覆盖单会话、保留会话/profile、清理消息/同步状态的目标;本文件刷新了当前源码锚点。仓库没有 reset/delete command、reset ACK、重建任务持久化、服务端主动选 plugin/send command 或 Mind rebuild completion contract;这些是实现前缺口。
|
||||
|
||||
## 发现
|
||||
|
||||
### 1. 插件 IndexedDB 的精确清理边界
|
||||
|
||||
`apps/chrome-extension/src/onetalk/service-worker/storage.ts:15-23` 现有数据库是 `trade-message-center` v8,固定 object stores 为:
|
||||
|
||||
- `onetalk_messages`:记录含 `channelAccountId`、`conversationId`、`messageId`,key 为 `JSON.stringify([channelAccountId, conversationId, messageId])`(`storage.ts:54-59,253-270`)。只删目标 account+conversation,保留其它会话。
|
||||
- `onetalk_sync_candidates`:同一三元业务 key,保存页面事实和 `pending_ack/confirmed/anomaly/rejected/awaiting_anchor` 状态(`storage.ts:81-99,467-496`)。必须与 messages 一起删,否则旧候选会重新上传。
|
||||
- `onetalk_sync_checkpoints`:key 为 `JSON.stringify([channelAccountId, conversationId])`,保存 mode、phase、anchor、分页位置、`historyComplete`、`latestMessageId`、`completionSent` 等(`storage.ts:61-79,206-216,589-617`)。这是当前所谓历史/同步 index 的主要持久化记录,应删除后由 full run 新建。
|
||||
- `onetalk_sync_anomalies`:key 为 account、可选 conversation、code、排序后的 fields、source;记录按 `channelAccountId` 和可选 `conversationId` 过滤(`storage.ts:101-111,237-250,718-729`)。目标会话 anomaly 应删;`conversationId` 缺失的 account-level diagnostic 不能误删。
|
||||
|
||||
`openSyncDatabase` 只以 `keyPath: "key"` 建 store,没有 `createIndex`(`storage.ts:273-317`);因此“删除 history index”不是删一个 IDB index,而是按完整字段遍历/删除上述记录。清理要在同一 readwrite transaction 中完成,并以 transaction `oncomplete` 作为 ACK 前置证据。现有 profile ledger 使用同一 pair key,但独立于消息 pipeline(`storage.ts:113-131,775-881`),必须保留 `onetalk_contact_profiles`。`onetalk_conversation_bootstraps` 的 key 是 `[channelAccountId, migrationId]`,是 account-wide discovery marker,不是单会话消息 state;单会话 reset 不应清除它(`storage.ts:25-52,222-223,319-363`)。Chrome 配置、deviceId 和其它渠道数据也不在删除范围。
|
||||
|
||||
### 2. Bright 数据库的清理/保留边界
|
||||
|
||||
当前 OneTalk schema 只有四类相关表:
|
||||
|
||||
- 删除 `onetalk_message` 中完整 scope `channel_account_id + conversation_id` 的全部行。表主键和查询 key 是三元组,时间读取索引为 `(channel_account_id, conversation_id, sent_at_ms, message_id)`(`apps/server/src/database/schema/onetalk.ts:60-119`;`apps/server/src/onetalk/repository.ts:83-93,333-361`)。物理 B-tree 会由 PostgreSQL 随 DELETE 维护,不应把索引对象本身当业务数据删除。
|
||||
- 清除 `onetalk_message_anomaly` 中同一 account+conversation 的诊断行,保留 conversationId 为空的账号级诊断(`schema/onetalk.ts:255-301`)。不清理会留下旧同步异常,污染重建后的诊断/结果。
|
||||
- 保留 `onetalk_conversation` 主记录及发现元数据:主键 `(channel_account_id, conversation_id)`,并保留 `participantIds`、`bizType`、`conversationType`、`conversationKind`、`joinTimeMs`、`modifyTimeMs`、`firstDiscoveredAt`,以及面向会话列表的 `lastContactTimeMs`/`messagePreview`(`schema/onetalk.ts:121-180`)。更新 `lastObservedAt` 为 reset 时间是合理的审计副作用。
|
||||
- 在同一 server transaction 中重置消息派生同步字段:`syncPhase='initial'`、`syncResult='incomplete'`、`latestMessageId=NULL`、`historyComplete=false`、`messageCount=0`、`anchorUpdatedAt=NULL`;建议同时把 `lastMessageAtMs=NULL`,因为它是消息事实派生值,而现有写入只做 monotonic max(`repository.ts:99-106,650-687`),不清空会在新历史比旧数据更旧/不完整时保留陈旧活动时间。`lastContactTimeMs`/`messagePreview` 是会话列表活动摘要,不属于消息事实,建议保留并让后续 discovery 继续按既有 monotonic 规则更新。
|
||||
- 保留 `onetalk_contact_profile` 整行(`schema/onetalk.ts:182-218`)以及 `onetalk_buyer_fact` 整行(`schema/onetalk.ts:220-253`)。两者均以 account+conversation pair 独立存储,读取时由 `read-repository.ts:122-180,254-329` 受限查询后以内存组合;它们是“对应客户资料/买家资料”,不能随消息 reset 删除。
|
||||
|
||||
服务端 reset repository 需要以 account+conversation 条件包住 DELETE/UPDATE,不能按 `conversationId` 单独删,也不能借用公共 read path。对 HTTP 触发的 destructive operation,应在插件 ACK 后开启事务,并在事务写入前/后使用同一连接/policy guard;数据库事务 rollback 只覆盖 Bright,无法回滚已提交的插件 IndexedDB 清理。
|
||||
|
||||
### 3. 现有授权、presence 和 WS 结构不能直接完成该序列
|
||||
|
||||
当前 public HTTP 上下文只实现 `authorizeRead`:读取 Cookie、校验精确 `channelAccountId`、要求 `read`,并在请求前后检查 cutover epoch(`apps/server/src/http/onetalk/public-context.ts:65-94,158-204`)。新路由必须使用同一 Origin/Cookie/cutover 边界;CORS preflight 当前只允许 GET(`public-context.ts:15-16,132-155`),POST rebuild 需要显式加入,不能复用 GET-only 语义。contract 的授权 operation 已有 `sync`,权限集合目前只有 `read/send`(`packages/onetalk-contract/src/connection.ts:7-14`);最小兼容方案是按 `operation: "sync"` 授权并要求 `read`,是否增加独立 destructive permission 是产品决策。
|
||||
|
||||
`OneTalkConnectionStore.isPluginLeaseFresh` 已按 canonical registered plugin 和 `lastHeartbeatAtMs > now - heartbeatTimeoutMs` 判断新鲜度(`apps/server/src/websocket/connection-store.ts:60-78,246-253`),默认 heartbeat interval/timeout 为 25s/75s(`connection-store.ts:104-116`)。但 `OneTalkConnectionRegistry` 对外只暴露 `isPluginOnline`,它只代表存在同 scope plugin,不检查 lease 新鲜度(`registry.ts:30-41,106-119`)。新 flow 需要一个 registry-owned “唯一 canonical plugin + fresh lease + binding/scope/permission” 选择/guard;不能把 `isPluginOnline` 当 heartbeat 证明,也不能按 account 广播或跨 binding fallback。
|
||||
|
||||
当前 Bright→插件只有 `send.command`,且它通过 pending-send coordinator 路由页面发送并要求 `sendRequestId`(`packages/onetalk-contract/src/wire.ts:43-70,88-105`;`apps/chrome-extension/src/onetalk/service-worker/routing/frame-router.ts:40-59`;`send-command-flow.ts:36-97`)。复用 `send.command` 做删除或同步会把 destructive action 错接入发送/消息事实 pipeline,结论是不可复用。应增加 domain-specific server→plugin command(建议 `conversation.reset`/`sync.reset`,payload 只有目标 conversation 与 `rebuildId`,不接受任意 objectStore/key),以及明确的 plugin→server ACK。若“普适”要求可复用,也只能让 payload 的 target 是固定白名单的 conversation message-state,不应暴露任意 IndexedDB store 名称/键。
|
||||
|
||||
### 4. `maybeBootstrap`、batch bootstrap 和 `startSync` 的复用结论
|
||||
|
||||
`BootstrapCoordinator.maybeBootstrap` 先要求 Bright 已认证、收到 anchor snapshot、页面 ready(`apps/chrome-extension/src/onetalk/service-worker/sync-engine/bootstrap-coordinator.ts:326-344`),然后读取 account-wide `onetalk_conversation_bootstraps` marker;marker 已经是 `complete`/`complete_with_skips` 时直接返回,不会重新同步目标(`bootstrap-coordinator.ts:346-374`)。若强行清 marker,`runDiscoveryBootstrap` 会发全 account `conversations.discovered` 并逐个 `startSync`(`bootstrap-coordinator.ts:376-467`),违反单会话闭环并可能重建其它会话。因此不要用清 marker 或 account batch 作为 reset 后启动手段。
|
||||
|
||||
现有 `startSync` 是可复用的 full history engine,但它只在页面 cache 已有目标 SDK conversation 时才能调用 `syncCollectedConversation`;页面 cache 未命中会返回 `conversationCacheMissing`(`apps/chrome-extension/src/onetalk/main-page/current-conversation-history/all-conversations.ts:205-214,324-354`)。其内部 full path 会先写扫描 checkpoint、路由 `onetalk.sync.conversation`、等待观察写入,再 apply page result、drain ACK candidates、发送 `sync.complete`(`bootstrap-coordinator.ts:148-275,555-614`)。最小复用方式是新增明确的 Bright→plugin `sync.start`(或 reset flow 的第二阶段):插件收到后先调用已有 `onetalk.discover-conversations` 刷新 MAIN 的 page cache,再调用 `engine.startSync({ channelAccountId, conversationId, mode: "full" })`;不要直接调用 `maybeBootstrap`,也不要发 account-wide `onetalk.sync`。
|
||||
|
||||
当前 `sync.complete` 是 plugin→Bright 的完成声明,不是成功 ACK;插件发送后 checkpoint 仍为 `uploading`,必须等待服务端下一次 `anchor.snapshot` 与 latest message 精确匹配才进入 `completed`(`durable-sync.md:100-109`;`ack-completion.ts:241-294,524-556`)。`anchor.snapshot` 目前只在 plugin hello 后由 server `listAnchors` 发送(`apps/server/src/websocket/plugin/index.ts:464-488`),不能假设 reset 后会自动再发一次;显式单会话 sync command 需要沿用该 checkpoint/ACK 机制,并在适当时请求或复用既有 anchor snapshot 证据。
|
||||
|
||||
### 5. 推荐的最小闭环与完成通知
|
||||
|
||||
推荐的逻辑顺序是:
|
||||
|
||||
1. Mind `POST` 新 rebuild route;Bright 使用精确 Mind session/origin/cutover 授权,校验 direct conversation 存在,并选择同 scope/binding 的唯一 fresh plugin。未找到 fresh plugin 时不做任何删除,返回稳定的 `503 plugin_offline`/`plugin_heartbeat_stale`(最终错误码需统一)。
|
||||
2. Bright 发送 `conversation.reset`(携带 `rebuildId`,在 plugin WS 上只投递到该 canonical plugin),等待 plugin ACK。ACK 只能在四个目标 IDB state store 的 readwrite transaction `oncomplete` 后产生,且应回显同一 `rebuildId`/conversation。
|
||||
3. 收到 ACK 后重新检查 canonical plugin、lease、binding/authorization version、scope、policy epoch,随后在一个 Bright DB transaction 中删除目标 message/anomaly、重置 conversation 派生字段,提交后才能进入下一步。
|
||||
4. DB commit 后发送 `conversation.sync.start`(或 reset coordinator 的 second-phase command),plugin 复用 `startSync(full)` 的历史获取、durable candidate、逐条 ACK 和 `sync.complete`,但必须先刷新目标 page cache;不能复用 account-wide `maybeBootstrap`/batch。
|
||||
5. HTTP 建议返回 `202 Accepted` + `{ conversationId, rebuildId, status: "accepted" }`,不持有请求等待历史页数。Mind 以 rebuildId 订阅 completion;现有 `sync.status`/`conversation.updated` 可复用传输,但当前 status payload 没有 rebuild correlation,至少需在 reset-triggered flow 的 status(以及失败终态)增加可匹配 `rebuildId`,或新增 `rebuild.status`。仅凭 `conversation.updated` 不足以证明重建完成;成功条件应为同 rebuildId、同 conversation 的 `sync.status` 且 `historyComplete=true`、`syncResult=succeeded|succeeded_with_anomalies`、`anchorAdvanced=true`。失败/断线应有明确终态,不能把 HTTP accepted 当完成。
|
||||
|
||||
### 6. 竞态与最小 generation/correlation 边界
|
||||
|
||||
现有 `ConversationQueue` 只串行调用者对同一 `(account,conversation)` 的 operation(`sync-engine/conversation-queue.ts:7-49`);`ObservationPipeline` 另有独立 observation write chain(`observation-pipeline.ts:65-75,227-232,309-347`),page command 的历史 fetch 还通过 MAIN `postMessage`,页面观察 envelope 没有 command requestId/generation(`page-bridge/model.ts:81-106,406-438`)。所以仅“清 store + 调 `startSync`”不能阻止旧 history/live observation 或 ACK 迟到回写。
|
||||
|
||||
必须至少有一个 reset-owned per-conversation generation/correlation boundary:
|
||||
|
||||
- `rebuildId` 贯穿 HTTP response、reset command/ACK、sync-start command、plugin checkpoint/active memory、post-reset sync completion/status;服务端只接受当前 rebuild 的 completion/observation,旧 requestId/旧 rebuild 的结果 no-op。
|
||||
- Plugin reset handler 先让目标进入 quiescing,串行等待该会话现有 `ConversationQueue` 与 `ObservationPipeline` 写入/页面同步请求收敛,清理 `AckCompletionCoordinator` 的目标 `requestCandidates`、`explicitlySentCandidates`、`pendingDiscoveries`、`pendingIncompletes` 和 `CheckpointCoordinator` active maps,然后在同一 IDB transaction 清理。不能调用全连接 `resetConnection()`,否则会影响其它会话。
|
||||
- 页面历史 command 在每个跨 await 后需要检查当前 rebuild token;旧 command 的观察不能在 reset 后以新 generation 上传。由于当前 page observed 无 correlation,若不扩展 page command/observation envelope,就无法证明“reset 前已产生、reset 后才到达”的 live/history 事件属于哪一代;这是实现前必须选择的 contract 设计点。最小安全选择是为 targeted history observation/progress 加 `rebuildId`,并在 reset quiescing 期间丢弃未带当前 token 的目标观察;或者取消/失效旧页面 command 并让所有旧结果明确 no-op。
|
||||
- Server 的 `ObservationBatcher` 会在 `handleObservation` 返回后异步 flush(`apps/server/src/websocket/plugin/flows/sync-flows.ts:318-395`);reset 必须与该 conversation 的 batch flush/commit 有同一 writer lock 或 generation guard。否则 pre-reset message batch 可能在 DB DELETE 后才入库。
|
||||
- ACK/complete 还有跨 await 的 canonical connection/policy guard:plugin replaced、heartbeat 过期、Mind revoke、cutover pause、socket close 都必须让旧 ACK/commit/publish no-op。现有 registry generation/commit guard 可作为底层机制(`connection-store.ts:142-159,205-243`),但需要扩展到 rebuild coordinator。
|
||||
|
||||
分布式顺序不可原子回滚:plugin IDB ACK 后 Bright DB DELETE 失败,会留下“插件已清、server 仍有旧消息”;DB DELETE 成功后 Bright 在发 sync command 前崩溃,也会留下空但 incomplete 的会话。`rebuildId` 必须可重试且 reset 幂等;若要求进程重启后自动恢复,则应把 pending rebuild 状态持久化(独立 operation 表或 conversation 派生状态),不能只放内存 Map。若产品选择“只允许在线且调用失败即终止”,仍需明确这些 ACK 后故障的重试/人工恢复语义。
|
||||
|
||||
## 候选 Scope 与依赖
|
||||
|
||||
1. **Cross-cutting contract/operation fence(前置)**:定义 reset/sync-start/ACK 以及 completion correlation 的 exact payload、方向、`rebuildId`/generation 和稳定错误码;拥有 `wire.ts`、`decoder.ts`、相关 contract 测试。必须先于 server/plugin writer;不允许任意 store/key 删除。
|
||||
2. **Bright HTTP + rebuild coordinator(依赖 1)**:新增单会话 POST 路由、Mind auth/CORS/cutover、fresh plugin 选择、ACK 等待、DB transaction、202 response 和 retry/status ownership。拥有 server HTTP/rebuild service/repository/registry adapter;排除 plugin IDB 实现和 Mind UI。实际共享写入是 registry/plugin command send、`onetalk_message`/`onetalk_message_anomaly`/`onetalk_conversation`。
|
||||
3. **Plugin reset/sync command flow(依赖 1,必须等 2 的 wire/sequence contract)**:Service Worker 收到 Bright command,按会话 quiesce/generation,清四个消息 state stores,保留 profile/buyer/bootstrap/config,ACK oncomplete,并触发 page-cache refresh + `startSync(full)`。拥有 storage API、targeted memory invalidation、frame-router、sync-engine/page command;排除 server DB deletion。
|
||||
4. **Mind completion consumer(依赖 1/2)**:仅消费同 account/conversation/rebuildId 的 accepted/status/final event;不能用旧 `sync.status` 或 `conversation.updated` 猜新 rebuild。若 Mind 代码不在本仓库,则只交付 contract/fixture 及联调说明。
|
||||
|
||||
就“可并发”而言,contract/operation fence 必须先完成;其后 Bright coordinator 与 plugin reset/sync 可在 contract 稳定且各自 write-disjoint、shared-write-free、runtime-isolated 时并发实现,但两者都不能在 fence 定义前开始。Mind consumer 需等 server emitted status shape 稳定后开始。目录不同本身不构成并发依据。
|
||||
|
||||
## Invariant 与验收探针
|
||||
|
||||
结构性 invariant:
|
||||
|
||||
- 删除范围严格是 `(channelAccountId, conversationId)`;不得删除 conversation/profile/buyer/bootstrap/account-level anomaly/其它会话。
|
||||
- plugin ACK 只代表 IDB transaction 已完成;Bright DB deletion 发生在 ACK 之后;DB commit 之后才发 sync-start;completion 只匹配同 conversation+rebuildId/generation。
|
||||
- 同一 conversation 同时最多一个 active rebuild;同 rebuildId retry 幂等;旧 connection generation、旧 ACK、旧 page command、旧 observation 不得改变新 generation state。
|
||||
- full rebuild 最终只能产生目标会话事实;其它会话 checkpoint/候选/消息、profile/buyer ledger 和 account bootstrap marker 不变。
|
||||
|
||||
Static/unit probes(不启动服务,不使用端口/DB/浏览器):
|
||||
|
||||
- `rg -n 'ONE_TALK_(MESSAGE|CHECKPOINT|CANDIDATE|ANOMALY|CONTACT_PROFILE|CONVERSATION_BOOTSTRAP)_STORE_NAME|createIndex|syncCollectedConversation|maybeBootstrap|startSync|isPluginLeaseFresh|isPluginOnline' apps/chrome-extension/src apps/server/src packages/onetalk-contract/src`;预期能审计固定 store/key、无现有 reset command、`maybeBootstrap` account-wide、`startSync`/page cache 依赖和 fresh lease 差异。
|
||||
- 用 fake IndexedDB unit test 预置目标/非目标 message、candidate、checkpoint、anomaly、profile、bootstrap 记录;assert 目标四 store 删除、非目标/`contact_profiles`/bootstrap 保留,transaction abort 时无 ACK。覆盖 anomaly `conversationId=undefined` 不被删。
|
||||
- server repository test 预置四表事实;assert single DB transaction 删除目标 message/targeted anomaly、重置规定 conversation 字段、保留 identity/activity/profile/buyer 和其它会话。验证 `lastMessageAtMs` 的 null 决策及 no JOIN/完整 scope 条件。
|
||||
- protocol/router test 覆盖 reset/sync-start/ACK exact keys、plugin-only direction、错误 scope、duplicate rebuildId、旧 generation no-op;assert 不进入 `send.command`/pending-send。
|
||||
- controllable Promise sequence test:fresh lease/授权失败无任何删除;IDB ACK 前 DB 无变化;ACK 后 DB fail 的 partial boundary 可重试;DB commit 前 policy/socket/generation 失效不发 sync-start;发送失败保持 incomplete。
|
||||
- generation mutation matrix:旧 observation、旧 message ACK、旧 sync.complete、plugin replacement、heartbeat expiry、policy pause、Mind revoke、page identity/Port disconnect 均不改变新 rebuild;同 rebuildId retry 可重复得到确定结果。
|
||||
- completion test:Mind 只接受同 `rebuildId` 的 `sync.status` terminal;旧 status/只含 `conversation.updated`/HTTP 202 均不视为完成;success、anomaly、failure、offline 都有可区分终态。
|
||||
|
||||
Runtime probes(本研究未执行,需隔离的 test DB、fake/real IndexedDB、独立插件/Mind 账号和可控 WebSocket):
|
||||
|
||||
- `pnpm --filter @trade-message-center/server test`:在新增 focused tests 后验证 HTTP/WS/auth/repository sequence,后端测试 hard timeout 60s;不得使用生产 DB。
|
||||
- `pnpm --filter @trade-message-center/chrome-extension test`:验证 fake IndexedDB、page bridge、sync engine 和 SW frame router;不要仅以构建成功代替 ACK/transaction 证据。
|
||||
- 真实 probe 需先向测试 Bright 发 POST,再捕获 plugin WS reset command/ACK、检查四个 store 与 server 四表快照、捕获 sync-start 和 Mind 同 rebuildId terminal status;一次测试只使用独立 account/conversation,避免污染其它会话。当前未启动 server、数据库、Chrome 或服务端口,故不能声称 runtime 已证明。
|
||||
|
||||
## 注意事项 / 未找到内容
|
||||
|
||||
- 当前协议版本是 `ONETALK_PROTOCOL_VERSION=6`(`packages/onetalk-contract/src/wire.ts:41`);spec 中部分旧文字仍称 v5,新增 frame 应以源码 v6 为准并同步测试/规范。
|
||||
- `isPluginOnline` 不是 fresh heartbeat 证明;当前没有 Bright 主动 ping plugin 的 request/response。需决定使用新鲜 lease 作为“检查心跳”,还是另加一次性 heartbeat probe。
|
||||
- 当前 server 无 rebuild operation table/outbox,plugin reset 后的 DB failure、server crash、WS ACK 丢失和 sync-start 丢失都没有自动恢复来源;若要求“无条件”包含断线/重启,必须把 operation/generation 持久化或明确 deferred retry。
|
||||
- 当前页面历史 fetch 的 observation envelope 没有 requestId/rebuildId,迟到 pre-reset history/live observation 的来源无法静态区分;不能只靠现有 `ConversationQueue`/`observationWrites` 宣称已解决。
|
||||
- 当前 `sync.status` 的 `OneTalkSyncStatusPayload` 只有 conversation/mode/result/count/anchor 字段(`packages/onetalk-contract/src/conversation-sync.ts:93-107`),`conversation.updated` 也只有投影会话和 moveToTop(`conversation-sync.ts:108-112`);Mind completion correlation 必须新增字段或独立 status frame。
|
||||
- 没有可引用的外部文档或生产版本参考;本研究仅依据当前 checkout 的源码、migration、spec 和测试命名,未做真实 runtime 验证。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "mind-rebuild-conversation-history",
|
||||
"name": "mind-rebuild-conversation-history",
|
||||
"title": "Expose HTTP endpoint for Mind to rebuild conversation history",
|
||||
"description": "Expose a Bright HTTP endpoint that enables Mind to request reconstruction of a OneTalk conversation's stored history under the existing authorization and data-boundary rules.",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-12",
|
||||
"completedAt": "2026-09-14",
|
||||
"branch": "09-12-mind-rebuild-conversation-history",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/project/source-file-conventions.md","reason":"检查新增和移动 TypeScript 文件的职责注释、bottom-up 和 main-last。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查包级依赖位置、Node test runner、typecheck 和 Vite 构建。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"验证重构后复制控件和提示浮层的回归边界。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"验证页面身份、bridge 和自包含 MAIN 入口不发生行为漂移。"}
|
||||
@@ -0,0 +1,44 @@
|
||||
# 非观测 DOM 边界设计
|
||||
|
||||
## Boundary
|
||||
|
||||
`apps/chrome-extension/src/onetalk/main-page/dom/` 是本任务中 OneTalk MAIN-world 非观测 DOM 访问的唯一所有者。它是 OneTalk 专属的页面适配边界,不提升到 `src/lib/`,也不与 Popup 共用。
|
||||
|
||||
```text
|
||||
main-page/
|
||||
├── dom/
|
||||
│ ├── selection.ts # selected 会话和标题节点的 DOM 读取
|
||||
│ ├── selection-events.ts # document click 订阅
|
||||
│ ├── react-file-uploader.ts # file input 到 React uploader 的窄适配
|
||||
│ ├── action-status-tooltip.ts # extension-owned 状态浮层
|
||||
│ └── conversation-id-copy.ts # extension-owned 复制按钮
|
||||
├── page-context.ts # URL/页面运行时身份与 selection 领域投影
|
||||
├── image-send.ts # 媒体发送状态机和结果语义
|
||||
└── page-script-entry.ts # 组合入口
|
||||
```
|
||||
|
||||
`buyer-fact-observer/`、`contact-observer/` 不移动、不改 import、不改测试;它们仍自行拥有被动观察和投影。
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| 现有责任 | 新 DOM 所有者 | 保留的业务所有者 |
|
||||
| --- | --- | --- |
|
||||
| selected `[data-cid]`、标题定位 | `dom/selection.ts` | `page-context.ts` 的 `none/single/multiple/unavailable` 语义 |
|
||||
| document click 刷新 identity | `dom/selection-events.ts` | `page-bridge/main.ts` 的 hello/retry/bridge 生命周期 |
|
||||
| 找到 file input 并上溯 React uploader | `dom/react-file-uploader.ts` | `image-send.ts` 的请求截止、上传拦截和 delivery result |
|
||||
| tooltip DOM 创建、重挂载和销毁 | `dom/action-status-tooltip.ts` | connection/history 组件对 tooltip facade 的调用 |
|
||||
| 复制按钮 DOM 创建、更新和移除 | `dom/conversation-id-copy.ts` | 当前会话 ID 可信来源及 Clipboard 成功/失败语义 |
|
||||
|
||||
移动只改变 DOM 适配层归属。公开函数签名、stable error/reason、桥接 message、SDK 发送和页面可见行为必须不变。
|
||||
|
||||
## Testing Library decision
|
||||
|
||||
`@testing-library/dom` 与 `@testing-library/user-event` 是测试依赖,而不是 MAIN-world 自动化引擎。现有代码没有主动操作 OneTalk UI 的业务命令;把 `user-event` 打进生产包既不能产生 trusted event,也会增加无消费者的 bundle 依赖。
|
||||
|
||||
测试层新增由 `jsdom` 创建和销毁的真实 DOM fixture,以 Testing Library 的 role/name 查询和 `userEvent.setup().click` 覆盖 extension-owned 复制按钮。项目的 Node `>=22.22.2` 基线满足当前 jsdom 的 Node 要求;每个测试必须显式传入 fixture document,并在结束时恢复替换过的全局对象。现有手写 fixture 仍可保留给页面结构异常、MutationObserver 和边界错误的单元测试。未来新增真实页面动作时,另行定义动作契约、失败语义和运行时策略,不能把测试工具直接暴露给 Bridge command。
|
||||
|
||||
## Compatibility and rollback
|
||||
|
||||
- 入口仍从 `page-script-entry.ts` 安装同名控件,MAIN/ISOLATED 入口和 Manifest 不变。
|
||||
- 所有新导入必须保持 MAIN entry 的 IIFE + `inlineDynamicImports` 构建约束;Testing Library 仅被 test 文件导入,不能进入该入口依赖图。
|
||||
- 这是可逆目录/依赖重构:若语义回归,恢复原 import 路径和 DOM adapter 文件即可;不得通过并行旧/新实现作运行时 fallback。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/project/module-organization.md","reason":"确定 OneTalk 专属 DOM 外部适配器的最近共同所有者和职责边界。"}
|
||||
{"file":".trellis/spec/project/module-ownership.md","reason":"移动类型与导出时保持唯一所有者和 canonical import path。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/architecture.md","reason":"遵守渠道目录、MAIN world 和 Popup 独立入口边界。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"保留会话复制和状态提示的可见行为与错误契约。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"保留 bridge identity 刷新和 MAIN IIFE 契约。"}
|
||||
@@ -0,0 +1,33 @@
|
||||
# 实施计划
|
||||
|
||||
## 1. 建立 DOM 适配目录
|
||||
|
||||
- 新建 `src/onetalk/main-page/dom/`,按设计中的五类 DOM 责任迁移实现。
|
||||
- 在移动前,对每个被改动的导出符号执行 GitNexus upstream impact;风险为 HIGH 或 CRITICAL 时先报告并复核范围。
|
||||
- 保持函数名与返回值稳定;只在 `page-context.ts` 和 `image-send.ts` 保留领域编排,改为调用 DOM adapter。
|
||||
|
||||
## 2. 更新组合与测试导入
|
||||
|
||||
- 更新 `page-script-entry.ts`、`page-bridge/main.ts`、`page-context.ts`、`image-send.ts` 的内部导入,确保没有残留的非 observer 直接 `document.querySelector*`、元素创建/挂载或 document click 订阅。
|
||||
- 更新 `onetalk-action-status-tooltip`、`onetalk-conversation-id-copy`、`onetalk-page-context`、`onetalk-send-page`、`onetalk-page-bridge`、`onetalk-image-send` 和 `onetalk-file-send` 的必要导入与 fixture。
|
||||
- 不修改 `main-page/buyer-fact-observer/`、`main-page/contact-observer/` 或其测试。
|
||||
|
||||
## 3. 引入并使用 Testing Library
|
||||
|
||||
- 在 `apps/chrome-extension/package.json` 的 `devDependencies` 添加 `@testing-library/dom`、`@testing-library/user-event` 和 `jsdom`。
|
||||
- 新增最小测试 helper,负责创建/销毁真实 document 和恢复全局状态;不替换所有现有 fixture。
|
||||
- 让复制控件测试通过 Testing Library 按 role/name 定位按钮,并用 `userEvent.click` 验证 Clipboard 调用与成功/失败反馈。
|
||||
|
||||
## 4. 验证与审查
|
||||
|
||||
- 运行受影响的 focused tests,单次 Node test 命令不超过 60 秒。
|
||||
- 运行 `pnpm --filter @trade-message-center/chrome-extension typecheck`、扩展 build 和完整扩展 test。
|
||||
- 搜索非 observer 的 MAIN-page source,确认 DOM API 只在 `main-page/dom/` 和明确保留的 Popup 中出现。
|
||||
- 执行 GitNexus `detect_changes()`,确认只影响 DOM adapter、调用方、测试和依赖清单;审查没有改变 bridge、发送或观察语义。
|
||||
|
||||
## Risk points
|
||||
|
||||
- `readSelectedConversationIds` 供 page bridge、买家事实和联系人资料路径调用;只抽取 DOM 读取,不得改其零/多选结果。
|
||||
- 复制控件自身使用 MutationObserver 来适配 SPA 重绘,但其文件不是被冻结的 observer 目录,仍随控件迁移。
|
||||
- React file uploader 查找依赖宿主内部 fiber,迁移必须保持 null/多候选和发送失败语义。
|
||||
- Node 内置 test runner 没有浏览器 DOM;新测试 runtime 必须局部化,不能污染其它测试的全局对象。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 集中非观测 DOM 交互
|
||||
|
||||
## Goal
|
||||
|
||||
把 OneTalk 页面中非观测用途的直接 DOM 访问收敛到明确的 DOM 边界,并使用 `@testing-library/dom` 与 `@testing-library/user-event` 对该边界的用户交互进行验证。这样新增页面动作不再复制手写 fixture 事件序列,同时不改变现有发送、身份判断或桥接行为。
|
||||
|
||||
## Confirmed facts
|
||||
|
||||
- OneTalk 页面脚本在 `MAIN` world、`document_start` 注入,并且必须构建为自包含 IIFE。
|
||||
- 当前没有 `element.click()`、`dispatchEvent()`、`InputEvent` 或 `user-event`;DOM 使用主要是读取页面状态,或注入扩展自有提示和复制控件。
|
||||
- 非 observer 的 OneTalk DOM 触点位于:`action-status-tooltip.ts`、`conversation-id-copy.ts`、`page-context.ts`、`image-send.ts` 和 `page-bridge/main.ts`。
|
||||
- `main-page/buyer-fact-observer/` 与 `main-page/contact-observer/`(包括其中的 `MutationObserver` 和 DOM 读取)不在本任务范围内,保持原路径与行为不变。
|
||||
- 扩展尚未引入 Testing Library;运行时只有浏览器原生 DOM,测试使用 Node 内置 test runner 和手写页面 fixture。
|
||||
- Popup 是扩展自有页面,不纳入本任务;`popup/popup.ts` 保持原路径和行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:把本任务范围内 OneTalk 页面 DOM 查询、页面自有控件创建/更新,以及未来动作的公共入口整理到一个专属目录;业务编排、协议解码和页面桥不因目录整理而获得新的 DOM 权限。
|
||||
- R2:引入 `@testing-library/dom` 与 `@testing-library/user-event` 作为 DOM 边界的测试工具,并用真实 DOM fixture 验证受控页面按钮的定位和点击。它们不进入生产 MAIN-world bundle,也不被误当成能生成 trusted event 的运行时自动化方案。
|
||||
- R3:保留当前的 OneTalk SDK 发送和媒体上传策略;本任务不把发送流程改成 DOM 点击,也不引入 `chrome.debugger`、CDP 或任意 selector/任意脚本的远程执行能力。
|
||||
- R4:维持 `channelAccountId + conversationId` 的精确页面身份判断、MAIN/ISOLATED/SW 桥接契约,以及现有页面控件的可见行为。
|
||||
- R5:新增依赖作为扩展测试依赖,并提供 Node test runner 可运行的浏览器 DOM 环境;生产 MAIN-world IIFE 不得引入测试运行时、暴露原始 payload、凭据或开放执行面。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] AC1:所有纳入范围的 OneTalk 页面直接 DOM 访问均由新目录拥有;调用方只通过其语义 API 使用页面 DOM。
|
||||
- [x] AC2:`buyer-fact-observer/` 与 `contact-observer/` 的生产代码保持未修改。
|
||||
- [x] AC3:至少一项受控页面按钮交互通过 `@testing-library/dom` 和 `@testing-library/user-event` 在真实 DOM fixture 中验证;生产入口不打包这些测试依赖。
|
||||
- [x] AC4:会话 ID 复制控件、动作状态提示、当前会话识别、图片/文件上传器定位、桥接 identity 刷新与重构前行为一致。
|
||||
- [x] AC5:相关 focused tests、`pnpm --filter @trade-message-center/chrome-extension typecheck` 和扩展构建通过;MAIN-world 入口没有产生 code-splitting 或顶层 import 问题。
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 被动 DOM 观测:`main-page/buyer-fact-observer/`、`main-page/contact-observer/` 及其 `MutationObserver`。
|
||||
- OneTalk 页面实际用户动作的新增业务功能、发送语义、协议、授权、Bridge 合约和服务端改动。
|
||||
- Playwright、`chrome.debugger`、CDP 或 Native Messaging 自动化。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "centralize-dom-interactions",
|
||||
"name": "centralize-dom-interactions",
|
||||
"title": "集中非观测 DOM 交互",
|
||||
"description": "将 Chrome 扩展中 observer 目录以外的页面 DOM 访问收敛到明确边界,并采用 Testing Library。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-14",
|
||||
"branch": "09-14-centralize-dom-interactions",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"检查共享合同唯一所有者和跨包依赖方向。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查扩展的 focused test、typecheck、build 验证。"}
|
||||
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"检查服务端迁移、读取投影和测试验证。"}
|
||||
@@ -0,0 +1,65 @@
|
||||
# 商品消息收集设计
|
||||
|
||||
## 做到什么程度
|
||||
|
||||
本任务的完成边界是“收集可安全表达的商品链接事实”,而不是“收集页面渲染的商品卡”。商品卡 DTO 中的 `productImage`、标题、价格、MOQ 来自当前页面 React 模板数据,不是原始 OneTalk 消息接口返回;它们不在本任务的可信输入与持久化范围内。
|
||||
|
||||
系统只保存一个经过严格规范化的详情页引用和从路径提取的 `productId`。后续若要做商品详情富化,应单开任务,先确定合法数据源、刷新策略、价格/库存时效和访问凭据边界。
|
||||
|
||||
## 数据流
|
||||
|
||||
```text
|
||||
OneTalk 接口 data.messageList[].content
|
||||
= 含 query 的原始商品详情 URL
|
||||
↓ SDK 历史适配
|
||||
SDK 扁平项 originalData.text
|
||||
↓ MAIN world: 共享 normalizeOneTalkProductUrl()
|
||||
{ version: 1, kind: "product", sourceUrl, productId }
|
||||
↓ 既有 page bridge / Service Worker
|
||||
message.observed 或 messages.observed
|
||||
↓ Bright: contract 校验 → DB transaction → ACK → message.created
|
||||
onetalk_message.content JSONB
|
||||
↓ Bright read projection
|
||||
Mind history / message.created 的 content
|
||||
```
|
||||
|
||||
原始实时消息入口也使用相同逻辑:`contentType=1` → `text.content.content` → `normalizeOneTalkProductUrl()`。这样实时/历史入口不会各自维护分类规则。
|
||||
|
||||
## 责任边界
|
||||
|
||||
| 层 | 负责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| `packages/onetalk-contract/src/content.ts` | `product` 类型、精确形状、URL 规范化、`productId` 一致性校验 | 网络请求、SDK 读取、DB 写入 |
|
||||
| `content-decoder.ts`(MAIN world) | 对暂存原始文本调用共享解析,再回退为 text | 跨边界传 raw URL 或解析页面卡片 |
|
||||
| page bridge / Service Worker | 传递已验证的 observation frame | 新增商品专用协议或修改 ACK 顺序 |
|
||||
| server | 用共享合同验证、把 JSONB 事实入库、既有 ACK/发布/读取投影 | 从 URL 补商品资料 |
|
||||
| database | 容纳 `content` JSONB,允许 `kind=product` | 保存 raw 消息或提供商品缓存 |
|
||||
|
||||
## 归类规则
|
||||
|
||||
`normalizeOneTalkProductUrl(value)` 仅返回安全的 `{ sourceUrl, productId }` 或 `null`:
|
||||
|
||||
1. 输入必须是去首尾空白后未发生变化的字符串,长度受共享文本上限限制。
|
||||
2. URL 必须为 HTTPS、host 精确为 `chinese.alibaba.com`、无 port、username、password 和 hash。
|
||||
3. pathname 必须匹配 `/product-detail/<slug>-<productId>.html`;`productId` 是路径最后一个、非零开头的数字段。
|
||||
4. 输出的 `sourceUrl` 固定为 `url.origin + url.pathname`,永远不带 query;`productId` 必须等于该路径提取值。
|
||||
5. 验证已持久化的 product 时再次执行相同规范化,要求其输出严格等于存储的 `sourceUrl` 和 `productId`。
|
||||
|
||||
若任一商品规则不满足,文本正常继续归类为 `text`;因为它不是损坏媒体,也不记作 anomaly。
|
||||
|
||||
## 存储与兼容性
|
||||
|
||||
- 为 `onetalk_message_content_v1_chk` 新增 `product` 到 kind 枚举;由于数据库没有旧 `product` 行,迁移只有 drop/add constraint,不做 `UPDATE`。
|
||||
- SQL check 不扩展为每种内容的 JSON schema;与现有 text/image/file/order 一致,exact-shape 由共享合同在插件入站 frame 和服务端读取 JSONB 时强制。
|
||||
- `read-projection.ts` 已对非 `business_card` 内容做精确浅复制;`product` 走通用分支,使 history 与 `message.created` 的 content 一致。
|
||||
- 旧客户端不认识 `product` 时会在其共享合同读取边界失败,所以该变更要求扩展、shared contract 与 server 作为一个兼容版本部署;任务不提供静默降级为 text。
|
||||
|
||||
## 安全不变量
|
||||
|
||||
- 只有 MAIN world 可以看到原始 URL;离开它的第一个对象已经移除了 query。
|
||||
- `sourceUrl` 是名称明确的安全派生字段,不是重命名后的 raw `content`。
|
||||
- 任何将 `originalData`、原始 content、URL query、chatToken、加密目标或 React 卡片 DTO 放入 normalized content、frame、日志、数据库或 Mind 响应的改动都违反本设计。
|
||||
|
||||
## 回滚
|
||||
|
||||
新迁移不改写旧事实。回滚应用会停止新 product 写入;但在旧服务读取包含 product 的新行时会因其不认识该 kind 而失败,故部署/回滚须按共享合同、扩展和服务端的兼容版本整体执行。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"product 从 MAIN world 经过协议、数据库和 Mind read projection 的跨层合同变更。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/history-sync.md","reason":"商品源字段来自 SDK 扁平历史消息,沿既有历史同步采集链路进入解码器。"}
|
||||
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"任务增加 JSONB 内容 kind 的迁移,不改变查询或引入 JOIN。"}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 商品消息收集实施计划
|
||||
|
||||
## 变更边界
|
||||
|
||||
最小行为缺口是:已验证的商品详情 URL 目前被当作 text,且数据库约束不允许 product;需要在共享消息合同的唯一 owner 中表达 product,并让两个文本输入入口共用这个判定。
|
||||
|
||||
预期改动文件:
|
||||
|
||||
| 文件/区域 | 原因 |
|
||||
| --- | --- |
|
||||
| `packages/onetalk-contract/src/content.ts` 与合同测试 | 新增 product 精确类型、唯一 URL 规范化函数和所有 transport/read 验证 |
|
||||
| `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts` 与解码测试 | MAIN-world 从 raw/SDK flat history 文本安全归类 product |
|
||||
| `apps/server/src/database/schema/onetalk.ts`、下一条 Drizzle migration/meta、迁移测试 | 允许 JSONB kind `product`,不修改历史内容 |
|
||||
| `apps/server/test/onetalk-read-domain.test.ts` | 锁住 product 的 history/message.created 读取投影和畸形行拒绝 |
|
||||
|
||||
明确不改 page bridge、Service Worker、ACK/发布顺序、授权、去重、查询逻辑、商品详情访问和 UI。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 实施前加载 `trellis-before-dev` 规定的 shared、Chrome extension、server 规范;对每个待编辑 symbol 做 GitNexus upstream impact,HIGH/CRITICAL 影响先报告。
|
||||
2. 在共享合同实现 product 类型与 `normalizeOneTalkProductUrl()`;让 `decodeOneTalkMessageContent()` 对 product 做 exact-shape + canonical URL/ID 一致性校验。
|
||||
3. 扩展合同测试:有效 product、拒绝 query/credentials/fragment/错误 host/path/ID/额外字段,及 observed/message.created 接受 product。
|
||||
4. 将 MAIN-world `normalizeText` 改成首先调用共享解析器;raw `contentType=1` 与 SDK `msgType=101`/`subType=1` 都复用该入口。增加 raw、扁平历史、普通 URL、保密查询参数清除、既有类型不回归的测试。
|
||||
5. 扩展 server schema kind 枚举;按 Drizzle 当前流程生成新的迁移和 meta。检查迁移仅重建约束,且仍保留 business_card marker-only 条件与所有既有 kinds。
|
||||
6. 扩展 migration/read-domain 测试,锁住 JSONB product 行经 history 和 `message.created` 得到同一精确对象,以及手工畸形 product JSONB 被读取边界拒绝。
|
||||
7. 独立检查完整 diff:禁止 raw URL/credential 漏出与重复 URL parser;验证 runtime data flow 是 decode → contract → store → ACK → publish。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `pnpm --filter @trade-message-center/onetalk-contract test`
|
||||
2. `pnpm --filter @trade-message-center/chrome-extension test`(60 秒超时)
|
||||
3. `pnpm --filter @trade-message-center/server test`(60 秒超时)
|
||||
4. 三个包的 `typecheck`,以及 `pnpm --filter @trade-message-center/server db:check`
|
||||
5. `pnpm format:check`、`git diff --check`,并以 GitNexus `detect_changes()` 检查仅影响合同、解码、约束迁移和读取投影。
|
||||
|
||||
## 运行时证据边界
|
||||
|
||||
本任务已有原始接口与 SDK 字段的 Chrome 运行时证据;实现后若加载新扩展,可额外验证该同一消息只发布 query-free product frame。没有重新加载扩展和触发历史读取时,不宣称浏览器端到端验收完成。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 收集 OneTalk 商品消息
|
||||
|
||||
## 目标
|
||||
|
||||
将已经在 OneTalk 中发送或接收的“商品详情链接消息”收集为 `product` 消息事实,让 Bright 持久化并让 Mind 读取一个去凭据、可稳定关联商品的最小引用。
|
||||
|
||||
本任务只收集链接本身派生的信息;不爬取商品页,也不尝试还原页面商品卡的标题、价格、主图或 MOQ。
|
||||
|
||||
## 实时证据与字段来源
|
||||
|
||||
已在会话 `2208314000798-2500002169502#11011@icbu` 的真实 OneTalk 页面验证以下链路:
|
||||
|
||||
| 字段/信息 | 原始位置 | 用途 | 是否可跨 MAIN world / 入库 |
|
||||
| --- | --- | --- | --- |
|
||||
| 商品详情原始 URL | `POST /message/listRecentMessage.htm` 响应的 `data.messageList[].content` | 页面/API 的原始消息正文 | 否;查询参数携带 `chatToken` 与加密登录目标 |
|
||||
| 同一原始 URL | OneTalk SDK 扁平历史项的 `originalData.text` | 扩展历史采集的实际输入 | 仅在 MAIN world 暂存并解析 |
|
||||
| 展示文案 | 扁平历史项的 `content` | SDK 给 UI 的展示内容 | 否;不是规范消息正文,不能作为判定来源 |
|
||||
| 商品 URL 类别线索 | 扁平历史项的 `msgType=101`、`subType=1` | 与现有普通文本消息共用的 SDK 分类 | 只作为进入文本/商品识别的前提,不能单独认定为商品 |
|
||||
| `sourceUrl` | 从 `originalData.text` 使用 `URL` 解析后取 `origin + pathname` | 安全、无查询参数的商品详情页引用 | 是 |
|
||||
| `productId` | 从已验证路径 `/product-detail/<slug>-<数字>.html` 最后一个数字段提取 | 稳定商品标识 | 是 |
|
||||
|
||||
本任务只接受实时验证过的 `https://chinese.alibaba.com/product-detail/<slug>-<数字>.html` 形态:不接受其它 host、路径、协议、端口、用户名/密码、fragment 或无法提取数字 `productId` 的链接。合法但不匹配该形态的文本仍是 `text`,而不是错误或 `product`。
|
||||
|
||||
## 现状与行为缺口
|
||||
|
||||
- `history.ts` 将 SDK 扁平历史项的 `originalData` 交给 `content-decoder.ts`;现有 `msgType=101` / `subType=1` 且 `originalData.text` 为字符串的消息会进入 `normalizeText`。
|
||||
- `content-decoder.ts` 运行于 MAIN world。它已有图片、文件、名片、询盘、订单的安全归一化入口,但没有商品 URL 分支。
|
||||
- `packages/onetalk-contract` 的 `OneTalkMessageContent` 当前为 `text | image | file | business_card | inquiry | order`,所有 observation frame、服务端摄入、读取投影都依赖该共享合同。
|
||||
- `onetalk_message.content` 是唯一的规范化 JSONB 消息内容;目前数据库 check 约束的 kind 列表不含 `product`。
|
||||
|
||||
## 目标合同与数据库事实
|
||||
|
||||
新增内容必须是精确形状,不能携带任何原始字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "product",
|
||||
"sourceUrl": "https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
|
||||
"productId": "1601456609478"
|
||||
}
|
||||
```
|
||||
|
||||
该对象将作为 `onetalk_message.content` 的 JSONB 值写入;表中同一事实行同时已有既有消息元数据:
|
||||
|
||||
```text
|
||||
channel_account_id ← 已认证的插件/页面账号 scope(不是 URL 或 activeAccountId)
|
||||
conversation_id ← OneTalk 消息的 cid / conversationCode
|
||||
message_id ← OneTalk messageId,经既有 normalizeOneTalkMessageId 规范化
|
||||
sender_id ← OneTalk sender.uid
|
||||
direction ← 由 sender 与当前账号的既有方向判定
|
||||
sent_at_ms ← OneTalk createAt / sendTime
|
||||
content ← 上述精确 product JSONB
|
||||
participant_ids、read_status、message_status、unread_count ← 既有消息观察字段
|
||||
first_observed_at、last_observed_at ← Bright 观察时间
|
||||
```
|
||||
|
||||
不会新增 `raw_content`、`product_url`、`product_id` 等独立列,不会持久化 `originalData`,也不会保留原始 URL 的 query string。因此 `chatToken`、加密登录目标和任意追踪参数不会出现在 observation frame、数据库 JSONB、日志或 Mind 响应中。
|
||||
|
||||
## 需求
|
||||
|
||||
1. 在共享 OneTalk 内容合同中新增 `OneTalkProductContent`,精确字段为 `version`、`kind`、`sourceUrl`、`productId`;合同校验要拒绝 query、错误 host/path、ID 不一致、额外字段和其它畸形值。
|
||||
2. 在共享合同中提供唯一的商品 URL 规范化/解析函数,供 MAIN-world 解码和已归一化内容校验共用,禁止第二套路径/URL 解析规则。
|
||||
3. 原始文本与 SDK 扁平历史文本入口都先尝试该商品解析;命中后归一化为 `product`,否则保持现有 `text` 行为。
|
||||
4. 更新数据库 kind 约束并新增迁移;迁移只增加 `product` 允许值,不回填或改写已有事实。
|
||||
5. 通过现有 `message.observed` / `messages.observed`、数据库摄入、ACK 后发布、history 和 `message.created` 读取链路传递 `product`,不新增 transport、授权、去重或发布顺序。
|
||||
6. 添加合同、扩展解码、迁移与服务端读取回归测试;测试证明 raw URL 查询参数不会越过 MAIN-world 边界。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 请求商品详情页、调用商品 API、解析 React 商品卡 DTO,或保存 `productImage`、标题、价格、MOQ、促销和下单动作。
|
||||
- 扩展支持的 host/path 以外的商品链接。
|
||||
- 商品消息发送、URL 可访问性探测、图片/附件下载,或迁移历史 `text` 行为。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 已验证 URL 形态的历史或原始文本消息会产生上述精确 `product` 内容,并进入既有观察与提交链路。
|
||||
- [ ] 普通文本 URL 与不能严格解析的候选 URL 仍为 `text`;图片、文件、名片、询盘和订单行为不变。
|
||||
- [ ] `onetalk_message.content` 保存精确的 product JSONB,读取 history 与 `message.created` 返回相同的安全对象。
|
||||
- [ ] 原始 `content`、`originalData`、URL 查询参数、`chatToken` 和加密登录目标不出现在消息 frame、数据库内容、诊断或 Mind 读模型中。
|
||||
- [ ] 合同、入站 frame 和持久化读取会拒绝畸形/附加字段的 `product` 内容;数据库迁移保留所有既有 kind 约束。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "collect-product-messages",
|
||||
"name": "collect-product-messages",
|
||||
"title": "收集 OneTalk 商品消息",
|
||||
"description": "将商品详情消息安全归一化为 product 内容,并经既有同步链路提交给 Bright/Mind。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-14",
|
||||
"branch": "09-14-collect-product-messages",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"Review state ownership, transaction boundaries, source layout, and no duplicate derived source."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Verify durable ledger, migration, ACK, and recovery behavior against the canonical contract."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"Verify profile pending and ACK behavior remains independent from message ledger changes."}
|
||||
@@ -0,0 +1,74 @@
|
||||
# OneTalk IndexedDB 查询索引设计
|
||||
|
||||
## Design decision
|
||||
|
||||
采用一次 v9 schema migration、scope/status 索引和精确 ACK 主键读取。保留所有 ledger records 和 JSON 序列化主键;将“整表传输到 JS 后过滤”改为 IndexedDB 内部按索引范围限制。confirmed candidate 不是临时队列,继续作为可恢复、可审计的 durable ledger。
|
||||
|
||||
## Storage schema
|
||||
|
||||
`ensureSyncStores` 拆成“确保 store 存在”和“确保 index 存在”两部分,只在 `onupgradeneeded` 的 versionchange transaction 执行。v9 的私有 index:
|
||||
|
||||
| Store | Index | Key path | Consumer |
|
||||
| --- | --- | --- | --- |
|
||||
| message | `by_account_conversation` | `[channelAccountId, conversationId]` | rebuild delete keys |
|
||||
| checkpoint | `by_account` | `channelAccountId` | resume、retry completion、bootstrap snapshot |
|
||||
| candidate | `by_account_status` | `[channelAccountId, status]` | account pending resume |
|
||||
| candidate | `by_account_conversation_status` | `[channelAccountId, conversationId, status]` | drain、awaiting-anchor、pending count |
|
||||
| candidate | `by_account_conversation` | `[channelAccountId, conversationId]` | rebuild delete keys |
|
||||
| anomaly | `by_account` | `channelAccountId` | account anomaly read |
|
||||
| anomaly | `by_account_conversation` | `[channelAccountId, conversationId]` | conversation anomaly/read-delete |
|
||||
| contact profile | `by_account` | `channelAccountId` | record count |
|
||||
| contact profile | `by_account_pending_observed_at` | `[channelAccountId, pending.observedAtMs]` | pending flush |
|
||||
|
||||
bootstrap 继续只有主键,因为只按稳定 `(channelAccountId, migrationId)` 读取。profile pending index 使用已有嵌套标量;没有 pending 的 record 没有 index entry,v8 的 pending record 会在创建 index 时自动进入,不增加平行 `hasPending` 字段或 backfill source of truth。
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
1. `ONE_TALK_SYNC_DATABASE_VERSION` 从 8 升至 9。
|
||||
2. 先确保六个 stores 存在;若 `oldVersion < 7`,按既有契约清空五个 legacy stores;最后通过 `indexNames.contains` 幂等建 index。
|
||||
3. v7/v8 → v9 保留 records,浏览器从现有 records 建 index;不重键、不回放、不清库。
|
||||
4. 保留 `onblocked` rejection,不增加 silent fallback。
|
||||
|
||||
先清 legacy 再建 index,避免为必然删除的数据建索引。v9 数据库不能由 v8 代码打开,因此线上回滚只能是 v10 前向兼容修复,不能直接重发旧扩展。
|
||||
|
||||
## Query API and call-site refactor
|
||||
|
||||
以带范围的 `readByIndex`、`readKeysByIndex`、`countByIndex` 和既有 `readOne` 取代通用 `readAll`。所有生产调用已有账号 scope,收紧 optional account 参数,不保留跨账号全表枚举。
|
||||
|
||||
| Current path | Replacement | Result bound |
|
||||
| --- | --- | --- |
|
||||
| `listCheckpoints(account)` | checkpoint `by_account.getAll(only(account))` | account checkpoints |
|
||||
| `listPendingCandidates(account)` | candidate `by_account_status.getAll(only([account, pending_ack]))` | account pending |
|
||||
| conversation candidate list + filter | `by_account_conversation_status` | conversation and status |
|
||||
| request key → pending list → find | `getCandidateByKey(requestKey)` + `pending_ack` guard | one candidate |
|
||||
| latest confirmed from all candidates | checkpoint `latestMessageId` candidate vs just-confirmed candidate by `candidateOrder` | at most two candidates |
|
||||
| completion pending check | status index `count` | one count |
|
||||
| `listAnomalies` | account or scope index | account or conversation |
|
||||
| `listPendingProfiles(account)` | profile pending compound index range | account pending profiles |
|
||||
| `hasProfileRecord(account)` | profile account-index `count` | one count |
|
||||
| `clearConversationHistory` | scope-index `getAllKeys`; checkpoint direct delete | target conversation keys only |
|
||||
|
||||
`AckCompletionCoordinator` 保持协议语义的具体变更:
|
||||
|
||||
- `activateAwaitingCandidates` 精确取 anchor,只列出 `awaiting_anchor`。
|
||||
- `drainConversation` 只列出 `pending_ack`,保留排序、批次和 in-flight guards。
|
||||
- 有 request key 的 ACK 直接读取 candidate,并要求它仍是 `pending_ack`;没有 key 时保留 `(account, conversation, messageId)` 回退。
|
||||
- durable status 更新后,以 checkpoint 的 `latestMessageId` 精确读取当前最新 candidate,并与刚确认 candidate 用 `candidateOrder` 比较;因此乱序 ACK 仍得到相同最大 confirmed message,无需物化整会话。
|
||||
- `tryComplete` 用 pending index count 代替全 candidate `some`。explicit-start gate 改为发送/ACK 调用携带精确的 in-memory 标记,不扫描 durable history 寻找该标记;现有 reset/clear 时同步清理。
|
||||
|
||||
不解析或依赖 JSON key 的字符串排序,不改变既有 `getCandidate(account, conversation, messageId)`。
|
||||
|
||||
## Transaction and failure semantics
|
||||
|
||||
会话重建仍使用同一个四 store readwrite transaction:每个 scope index `getAllKeys(range)` 后对返回 key `delete`,最后等待该 transaction `oncomplete`。不在 transaction 外先读后删,不引入异常吞没或 fallback。
|
||||
|
||||
ACK 顺序仍为 durable candidate 读取 → candidate 状态写入 → checkpoint 写入(若需要)→ completion 判断。既有 lifecycle、generation、quiesce 和 authorization guards 均不因缩小查询范围而绕开。
|
||||
|
||||
## Test design
|
||||
|
||||
1. 扩展 storage/profile tests 的 Fake IndexedDB,使其可观测 `objectStore.getAll`,并最小实现 `createIndex`、`index`、`getAll`、`getAllKeys`、`count`;加入 v8 seed → v9 upgrade。
|
||||
2. 以两账号、两会话、不同 candidate status、账号级 anomaly、pending/non-pending profile 断言精确结果,并断言目标读取未调用 object-store `getAll`。
|
||||
3. 覆盖会话清理只删除目标会话;覆盖精确 request key、批量/乱序 ACK、pending resume、awaiting-anchor、completion gate 和 profile flush。
|
||||
4. 保留并重跑 `<7` clear、v7→v8 保留断言;新增 v8→v9 preservation。
|
||||
|
||||
功能测试证明查询范围,不将 fake timing 当性能证据。真实 Chromium profile 的 IDB latency、index-build 时间、Worker memory/termination 采样作为后续独立验证。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"Preserve state ownership, async boundaries, and source responsibilities during storage/query refactor."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Preserve durable-first, candidate ACK, checkpoint, restart, and migration contracts."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"Preserve independent profile ledger and ACK semantics while indexing profile reads."}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Implementation plan: OneTalk IndexedDB scoped queries
|
||||
|
||||
## Scope and ownership
|
||||
|
||||
Primary implementation owner is `apps/chrome-extension/src/onetalk/service-worker/storage.ts`. Affected consumers are `sync-engine/ack-completion.ts` and existing test doubles under `apps/chrome-extension/test/`; Bright, Mind, page-bridge and authorization contracts are out of scope.
|
||||
|
||||
## Ordered work
|
||||
|
||||
1. Before each production-symbol edit, run GitNexus impact analysis and load the target frontend/durable-sync specs.
|
||||
2. Add v9 index constants/helpers and versionchange migration; preserve old-version clear order and existing open/blocked failure behavior.
|
||||
3. Replace generic all-store reader with bounded primary/index helpers. Add exact candidate-key, status-scoped candidate, scoped anomaly/profile, and transaction-local `getAllKeys` delete APIs.
|
||||
4. Refactor `AckCompletionCoordinator` to use direct request-key reads, status-scoped lists, checkpoint-vs-candidate latest comparison, and indexed pending completion checks. Keep queue/generation/ACK order unchanged.
|
||||
5. Update type-conforming test fakes and focused tests; add v8→v9 compatibility and query-path instrumentation.
|
||||
6. Run focused tests, typecheck, build, full tests and format check; review for retention, protocol or fallback regressions.
|
||||
7. Before commit, run GitNexus `detect_changes()` and inspect affected flows. Commit/release remain separately authorized.
|
||||
|
||||
## Validation commands
|
||||
|
||||
```bash
|
||||
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-sync-storage.test.js
|
||||
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-contact-profile-storage.test.js
|
||||
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-sync-engine.test.js
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm format:check
|
||||
```
|
||||
|
||||
Use the repository-supported focused-test argument form confirmed at implementation time. Any unexpectedly affected backend unit test keeps the project 60-second timeout rule.
|
||||
|
||||
## Review gates
|
||||
|
||||
- Every target query carries an account/conversation/status IndexedDB range; target code has no `objectStore.getAll()`.
|
||||
- No confirmed candidate deletion, primary-key shape change, raw payload index or Bright frame change.
|
||||
- v8 preservation and `<7` cleanup are verified independently.
|
||||
- New reads/writes stay in IndexedDB transactions and `oncomplete` remains the durable boundary.
|
||||
- Release notes state the no-binary-rollback v9 constraint.
|
||||
|
||||
## Rollback
|
||||
|
||||
Before release, source rollback is normal Git reversal. After a profile receives v9, do not deploy v8 against that profile; publish a v10 forward migration that restores compatible behavior, or retain v9 schema code and explicitly disable only a non-schema behavior. Never clear durable ledger data as rollback.
|
||||
@@ -0,0 +1,67 @@
|
||||
# 修复 OneTalk IndexedDB 查询全表扫描
|
||||
|
||||
## Goal
|
||||
|
||||
在不改变 OneTalk durable-first、候选 ACK、锚点推进、profile ledger 或历史保留语义的前提下,消除同步账本按整个对象仓库 `getAll()` 后在内存过滤的查询路径。目标是让 ACK、重连恢复、会话重建和 profile flush 的读取范围由账号、会话和状态键限定,而不是由历史总量决定。
|
||||
|
||||
## Confirmed facts
|
||||
|
||||
- `storage.ts` 当前数据库版本为 8;`ensureSyncStores` 仅为六个 OneTalk store 建立 `{ keyPath: "key" }`,没有二级索引(`apps/chrome-extension/src/onetalk/service-worker/storage.ts:16,275-288`)。
|
||||
- `readAll` 调用 `objectStore.getAll()`;`listCheckpoints`、`listCandidates`、`listPendingCandidates`、`listAnomalies`、`listPendingProfiles`、`hasProfileRecord` 都经由该路径读取整表(`storage.ts:384-390,605-611,644-671,722-730,841-862`)。
|
||||
- `clearConversationHistory` 对 message、checkpoint、candidate、anomaly 四个 store 各调用一次 `getAll()`,再逐记录删除(`storage.ts:740-765`)。
|
||||
- 每条有效观察都会持久化 candidate,确认后保留;仅显式会话重建清理候选。因此 candidate 表的规模会随历史同步积累(`storage.ts:514-555`)。
|
||||
- 已关联的 ACK 请求携带精确 `candidateKey`,但 `handleAcknowledgementResult` 仍先 `listPendingCandidates(account)` 再 `.find(key)`;批量 ACK 对每个结果串行执行(`ack-completion.ts:377-405,545-548`)。
|
||||
- 认证/重连会调用 `resumePendingCandidates` 和 `retrySentCompletions`(`sync-engine.ts:340-350,367-378`)。现有调用点都传入 `channelAccountId`;没有生产调用需要跨账号列出 pending candidate 或 pending profile。
|
||||
- v7→v8 必须保留既有 OneTalk ledger;`oldVersion < 7` 才会清空五个 legacy store。该兼容契约不可倒退(`.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md`)。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. Schema migration
|
||||
|
||||
- 将同步数据库升级到 v9;为既有和新建 store 幂等创建查询索引。
|
||||
- v8→v9 只建立索引,必须逐条保留 message、checkpoint、candidate、anomaly、profile 和 bootstrap 记录;不得重键、清库或回放观察。
|
||||
- `oldVersion < 7` 的既有清库规则保持不变;清理完成后再建立索引,避免为必然删除的 legacy 记录建立索引。
|
||||
- index 名称和 key path 是 `storage.ts` 私有 schema,不能泄露到 Bright、Mind 或页面桥合同。
|
||||
|
||||
### R2. Scope-bounded reads
|
||||
|
||||
- candidate:支持按精确 key、`[channelAccountId, conversationId]` 和候选状态读取;按账号恢复 pending 时只读取该账号的 `pending_ack`。
|
||||
- checkpoint:按账号列出恢复/完成声明所需 checkpoint;按精确主键删除单会话 checkpoint。
|
||||
- message、candidate、anomaly:会话重建只读取匹配 `[channelAccountId, conversationId]` 的 keys,再在同一 readwrite transaction 删除;不得物化不相关记录。
|
||||
- profile:`hasProfileRecord` 使用账号索引计数;pending flush 仅从该账号的 pending profile 索引读取。
|
||||
- anomaly 的现有账号/会话查询保持结果和账号级 anomaly 的排除语义。
|
||||
|
||||
### R3. ACK and sync semantics
|
||||
|
||||
- ACK 已有 `candidateKey` 时必须以主键直接读取,且只接受 `pending_ack` candidate;保留没有 request key 时按 `(account, conversation, messageId)` 的兼容查找。
|
||||
- 按会话 drain 只读取 `pending_ack`;激活增量候选只读取 `awaiting_anchor` 及精确 anchor candidate。
|
||||
- ACK 后最新 confirmed message 的选择继续遵守 `candidateOrder`。不得因索引重构把较旧、乱序 ACK 覆盖成最新锚点。
|
||||
- `tryComplete` 只查询是否仍有 pending candidate;现有 explicit-start/boundary gate 语义不得变化。
|
||||
- 不删除 confirmed candidate,不改业务主键、不改 Bright frame、不改变 ACK 先 durable write 后推进的顺序。
|
||||
|
||||
### R4. Verification
|
||||
|
||||
- 测试必须证明 v8→v9 记录保留和 index 创建;`oldVersion < 7` 的清理行为继续成立。
|
||||
- 存储层测试必须能区分 `objectStore.getAll()` 与 `IDBIndex.getAll()` / `getAllKeys()` / `count()`,并断言目标查询未走全表读取。
|
||||
- 同步引擎测试必须覆盖精确 request key ACK、批量 ACK、断线恢复、增量 anchor 和 completion 路径,确认 observable 协议行为未变。
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 不引入 confirmed candidate 过期/压缩策略,不删除历史消息或改变 retention。
|
||||
- 不变更 IndexedDB database name、JSON 序列化主键、OneTalk/Bright/Mind frame、页面桥或授权逻辑。
|
||||
- 不承诺本任务已在真实用户 profile 的数万条数据上测得 worker 卡顿或内存下降;该问题需要单独的真实浏览器性能采样。
|
||||
- 不为只按主键读取的 bootstrap store 增加无消费者的索引。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] AC1: v9 upgrade 为当前 records 建立所需索引,v8→v9 六个 OneTalk store 的记录数量与内容保持不变;`oldVersion < 7` 仍按现有契约清空五个 legacy store。
|
||||
- [ ] AC2: `storage.ts` 不再存在用于业务查询或会话删除的 `objectStore.getAll()`;所有目标读取均由主键或相应 `IDBIndex` 限定。
|
||||
- [ ] AC3: 正常 ACK 不再调用 `listPendingCandidates` 来按已知 request key 找 candidate;乱序 ACK 下 `latestMessageId` 仍是 `candidateOrder` 最大的 confirmed candidate。
|
||||
- [ ] AC4: 重连恢复、pending drain、awaiting-anchor 激活、completion、profile flush 和会话重建的结果与现有 focused tests 一致,且不读取其他账号/会话记录。
|
||||
- [ ] AC5: 目标存储、profile、同步引擎测试以及 typecheck、build、全量 test 和 format check 通过。
|
||||
|
||||
## Risks and operational constraints
|
||||
|
||||
- 建立新索引需要在首次打开 v9 数据库时遍历现有记录,成本为一次性迁移;升级被阻塞时必须继续显式失败,不能伪造成功或回退到全表扫描。
|
||||
- IndexedDB 版本不可降级。v9 已写入后,旧的 v8 扩展不能打开同一数据库;发布回滚必须使用前向 v10 修复或等待客户端更新,不能直接回退扩展包。
|
||||
- 测试中的轻量 Fake IndexedDB 当前没有 `IDBIndex` 行为。测试 harness 需要扩展为最小的可观测 index 模型,或替换为能验证真实 index API 的测试依赖;不得仅靠 mock 返回值宣称查询走了索引。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "onetalk-indexeddb-query-indexes",
|
||||
"name": "onetalk-indexeddb-query-indexes",
|
||||
"title": "修复 OneTalk IndexedDB 查询全表扫描",
|
||||
"description": "为 OneTalk 同步账本设计索引、迁移与查询重构方案,消除 candidate、checkpoint 和 profile 的全表读取。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-15",
|
||||
"branch": "09-14-onetalk-indexeddb-query-indexes",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"评审状态所有权与跨层职责"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome 扩展质量基线与验证命令"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"验证 tooltip 复用与宿主 DOM 边界"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"验证 exact-shape、方向和账号隔离"}
|
||||
@@ -0,0 +1,53 @@
|
||||
# 技术设计:OneTalk 未绑定 tooltip 提醒
|
||||
|
||||
## 边界与职责
|
||||
|
||||
| 层 | 职责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| Sync controller | 依据已校验配置、稳定错误状态触发未绑定状态刷新 | 改变配置、授权、重连策略 |
|
||||
| Page runtime host / runtime | 保存当前配置事实,按 Page Port 账号计算、发布、重放 `unbound` | 解释 DOM、创建 tooltip |
|
||||
| Page bridge | 唯一 decoder、方向检查和透明转发 | 解释绑定业务含义 |
|
||||
| MAIN `BindingStatusTooltip` | 将已校验布尔值映射为既有 tooltip 的固定 error 行 | 读取配置、错误码或页面账号 |
|
||||
|
||||
## 数据流与契约
|
||||
|
||||
```text
|
||||
已校验配置 + 页面账号 + binding_revoked
|
||||
-> Sync controller / Page runtime host: unbound:boolean
|
||||
-> 匹配账号 Page Port
|
||||
-> ISOLATED 原样转发
|
||||
-> MAIN exact-shape decoder
|
||||
-> BindingStatusTooltip
|
||||
-> action-status-tooltip error row
|
||||
```
|
||||
|
||||
新增页面消息保持 v4、Service Worker → MAIN 方向的 exact shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
|
||||
version: ONE_TALK_PAGE_BRIDGE_VERSION,
|
||||
type: "onetalk.page.binding-status",
|
||||
unbound: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
任何额外字段、错误类型、非 boolean、错误 source/version/origin 或反向流动都被 decoder/guard 丢弃。消息不得含 `binding`、channelAccountId、错误码、requestId 或 Bright 原始数据。
|
||||
|
||||
## 状态规则
|
||||
|
||||
- `unbound` 是派生显示状态,而非第二份绑定或配置状态:无通过既有校验的配置、页面账号不等于配置账号、或当前配置收到 `binding_revoked` 时为 `true`;其它错误及 offline 为 `false`。
|
||||
- 状态变化必须 fan-out 到所有匹配账号 Page Port,配置账号变化时旧账号收到 `true` 以清除错误的已绑定视觉,新账号收到其新计算值。无配置时已连接页面在 hello/replay 时得到 `true`。
|
||||
- 重复合法 hello 不改变同步/命令生命周期,但必须重放该页面账号当前 `unbound` 值;端口断开和身份变化不保留旧页面状态。
|
||||
|
||||
## MAIN UI 设计
|
||||
|
||||
新增 `main-page/binding-status-tooltip.ts`,结构与 `connection-status-tooltip.ts` 对齐:它持有 `OneTalkActionStatusTooltip`,使用固定 id `onetalk-binding-status` 与文案“当前账号未绑定”。`update(true)` 调用 `tooltip.start(id, text, "error")`,`update(false)` 调用 `tooltip.close(id)`。
|
||||
|
||||
`page-script-entry.ts` 在既有 tooltip 安装后构造该类,并将已校验的 bridge 回调交给它。不会新增 DOM 模块、属性、CSS、activeBar 选择器、MutationObserver 或页面位置逻辑;tooltip facade 已经拥有行去重、顺序、容器重挂与显示样式。
|
||||
|
||||
## 兼容性与回滚
|
||||
|
||||
- 黄色 `ConnectionStatusTooltip` 与红色 `BindingStatusTooltip` 使用不同固定 id,因此可同时显示,任何一方关闭不影响另一方或历史进度行。
|
||||
- 这是只读显示投影失败边界:解析、Port 或 tooltip 显示失败不得影响消息观察、命令、同步、配置或 Bright 连接。
|
||||
- 回滚只需移除 binding-status 消息变体、账号投递与 `BindingStatusTooltip`;没有迁移、持久化或宿主 DOM 需要恢复。
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"项目级职责、状态所有权与契约边界"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome 扩展包基线与验证命令"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"复用 MAIN tooltip 控件的生命周期与测试契约"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面桥 exact-shape、方向与账号隔离契约"}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 实施计划:OneTalk 未绑定 tooltip 提醒
|
||||
|
||||
## 1. 页面状态契约
|
||||
|
||||
1. 在 `page-bridge/model.ts` 定义、创建、解码和方向收窄 `onetalk.page.binding-status` exact-shape 消息。
|
||||
2. 在 `page-bridge/main.ts` 增加独立的显示回调消费者;ISOLATED 仅用共享 decoder 与方向 guard 原样转发。
|
||||
3. 扩展 bridge 测试:唯一合法 shape 被消费;额外字段、非 boolean、错误 source/version/origin/direction 均拒绝;消息不进入 command-result 流程。
|
||||
|
||||
## 2. Service Worker 派生、重放与账号隔离
|
||||
|
||||
1. 在 `sync-controller.ts` 的既有状态通知路径调用 host 更新;复用既有配置和错误状态,不复制校验或授权逻辑。
|
||||
2. 在 `page-runtime-host.ts` 保存最小配置/撤销事实,并在 hello、重复 hello、配置替换、恢复、身份变化和断开时按账号计算/重放显示状态。
|
||||
3. 在 `runtime.ts` 添加只向匹配账号 Page Port 发布的最小 API;不广播、不建立队列、不重试。
|
||||
4. 覆盖未配置、账号不匹配、`binding_revoked`、offline/其他错误、恢复清除、重复 hello 和多账号 Port 的回归测试。
|
||||
|
||||
## 3. 复用既有 tooltip
|
||||
|
||||
1. 新增 `main-page/binding-status-tooltip.ts`,仅将 `unbound` 映射为固定 id 的 error 行生命周期;不新增 CSS 或 DOM 控件。
|
||||
2. 在 `page-script-entry.ts` 复用已安装的 tooltip facade,连接 bridge 回调与该语义类。
|
||||
3. 扩展既有 tooltip 测试:红色未绑定行与黄色断线/历史行独立共存、去重、重挂与关闭。
|
||||
4. 明确断言源码/测试中不创建 activeBar/tabs DOM 提醒、无 `.im-next-tabs-bar` 选择器、无 MutationObserver。
|
||||
|
||||
## 4. 验证与评审
|
||||
|
||||
1. 审查每层仅拥有自己的状态:Service Worker 派生、bridge 透明转发、MAIN 映射到已有 tooltip。
|
||||
2. 审查所有 bridge 消息与诊断均不含 binding、账号、错误细节或 Bright 原始数据。
|
||||
3. 执行定向 Node 测试,随后执行:
|
||||
|
||||
```sh
|
||||
pnpm --filter @trade-message-center/chrome-extension typecheck
|
||||
node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run build
|
||||
pnpm --filter @trade-message-center/chrome-extension test
|
||||
pnpm exec oxfmt --check <changed-files>
|
||||
git diff --check
|
||||
```
|
||||
|
||||
4. 用户未要求真实 Chrome 验证时不操作其页面、存储或同步;若之后明确要求,可仅以只读 DOM 检查 tooltip 行及 activeBar 无扩展节点。
|
||||
|
||||
## 回滚点
|
||||
|
||||
本功能没有 migration 或协议服务端变更。若出现显示问题,回滚 binding-status 的页面投影及 `BindingStatusTooltip` 即可;既有 tooltip、黄色断线和同步业务链路保持不变。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 复用 OneTalk tooltip 展示未绑定提醒
|
||||
|
||||
## Goal
|
||||
|
||||
当当前 OneTalk 页面账号不可同步且原因是未绑定时,在既有 extension-owned action-status tooltip 中显示红色“当前账号未绑定”状态行。它必须与现有的黄色断线和历史进度提示使用同一视觉与生命周期体系,不能侵入 OneTalk 原生 tabs/activeBar。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- 先前实现将独立绝对定位 badge 追加到 `.im-next-tabs-bar`,截图显示它挤入了原生“重要/未读”tabs;用户已回退该实现。
|
||||
- `main-page/dom/action-status-tooltip.ts` 已提供有状态的 `start`、`update`、`close` facade 和受限 `error` 颜色,`ConnectionStatusTooltip` 已是消费页面桥 `disconnected: boolean` 的同类模式。
|
||||
- 页面桥 v4 已有 Service Worker → ISOLATED → MAIN 的 `onetalk.page.connection-status` exact-shape 投影;它不携带账号、错误码或原始 Bright 数据。
|
||||
- 未绑定的判定仅限:没有已通过现有校验的配置、配置账号与页面账号不一致,或当前配置收到稳定的 `binding_revoked` 错误。普通离线、重连及其他错误不是未绑定。
|
||||
|
||||
## Requirements
|
||||
|
||||
- Service Worker 以最小 `{ unbound: boolean }` 投影当前页面账号的未绑定状态;不得向 MAIN、DOM 或日志泄露 binding、账号、错误码或 Bright frame。
|
||||
- 该状态按账号投递和重放:合法 hello、重复 hello、配置替换、配置恢复、`binding_revoked` 与 Port 断开都不能造成跨账号显示或遗留状态。
|
||||
- MAIN 仅把已经由页面桥校验的布尔值交给独立的 `BindingStatusTooltip` 语义封装;`true` 启动固定 id 的 `error` 行,`false` 关闭该行。
|
||||
- 提醒文案固定为“当前账号未绑定”,并与黄色断线提示、历史 bootstrap 提示独立共存、独立关闭和重挂。
|
||||
- 不新增 `.im-next-tabs-bar` 查询、activeBar DOM、MutationObserver、样式字段或其他 OneTalk 宿主页面 UI 修改。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 命中未配置、账号不匹配或 `binding_revoked` 时,页面显示既有 tooltip 体系的红色“当前账号未绑定”状态行;普通 offline/重连不会产生该行。
|
||||
- [ ] 绑定恢复或有效同账号配置替换后,红色行被移除;断线的黄色行和历史进度行不受影响。
|
||||
- [ ] 页面桥只接受/转发 `onetalk.page.binding-status` 的 exact shape,拒绝额外字段、非 boolean、错误 source/version/origin/direction,且不会产生 command-result。
|
||||
- [ ] 状态只到达匹配账号的 Page Port;重复 hello 重放当前状态,页面脚本重复安装或 tooltip 容器被移除后仍保持唯一、正确的红色状态行。
|
||||
- [ ] DOM 中不存在扩展创建的 activeBar/tabs 提醒节点,且不读取页面配置或 binding。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不改变绑定授权、配置持久化、Bright WebSocket 协议、自动重连或同步业务流程。
|
||||
- 不添加 Popup、系统通知、其它页面告警或新的通用 tooltip 样式能力。
|
||||
- 不修改 OneTalk 原生 activeBar/tabs、布局、class 或内容。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "onetalk-unbound-tooltip-warning",
|
||||
"name": "onetalk-unbound-tooltip-warning",
|
||||
"title": "复用 OneTalk tooltip 展示未绑定提醒",
|
||||
"description": "将 OneTalk 当前账号未绑定状态接入既有 action-status tooltip 的 error 行,不创建 activeBar 自定义 DOM 控件。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-14",
|
||||
"branch": "09-14-onetalk-unbound-tooltip-warning",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"Review ownership, dependency direction, and cross-layer final-action boundaries."}
|
||||
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review duplicate requests, late acknowledgements, connection replacement, and final action ordering."}
|
||||
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Review fingerprint collision and same-key/different-content conflict behavior."}
|
||||
{"file":".trellis/spec/project/database-query-composition.md","reason":"Review scoped base/enrichment reads and ensure no SQL or Drizzle JOIN was introduced."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Review bridge payload allowlists, MAIN-world boundary, existing sync compatibility, and protocol event ownership."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Review IndexedDB migration preservation, durable-first upload, recovery, and ACK terminal transitions."}
|
||||
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"Review schema comments, migration execution, server transaction, idempotency, and publication behavior."}
|
||||
@@ -0,0 +1,171 @@
|
||||
# React 卡片监测设计
|
||||
|
||||
## 1. Ownership and flow
|
||||
|
||||
```text
|
||||
OneTalk DOM mutation
|
||||
-> MAIN card observer (mount trigger only)
|
||||
-> MAIN React Fiber card reader + content normalizer
|
||||
-> page bridge exact envelope
|
||||
-> ISOLATED stateless forwarder
|
||||
-> Service Worker rendered-card coordinator
|
||||
-> IndexedDB rendered-card ledger (durable first)
|
||||
-> Bright rendered-card.observed
|
||||
-> PostgreSQL rendered-card fact transaction
|
||||
-> rendered-card.ack
|
||||
-> local terminal ledger update
|
||||
-> authorized Mind message.updated
|
||||
```
|
||||
|
||||
The existing message path remains independent:
|
||||
|
||||
```text
|
||||
base message durable candidate -> messages.observed -> onetalk_message.content
|
||||
-> message.ack -> message.created
|
||||
```
|
||||
|
||||
The card coordinator sends only after the matching base candidate is confirmed. A card observation therefore cannot create a message fact, advance a history anchor, or alter initial message publication.
|
||||
|
||||
The rendered-card path is a content supplement, not message ingestion. Its only cross-layer identity is the already-existing `[channelAccountId, conversationId, messageId]`; its own `observedAtMs` records when the supplement was rendered, never when the message was sent.
|
||||
|
||||
## 2. MAIN-world card reader
|
||||
|
||||
### 2.1 Trigger and lifecycle
|
||||
|
||||
- A dedicated `main-page/card-observer/` owns one MutationObserver scoped to the message-list root. It watches `childList + subtree`, collects newly added `.message-item-wrapper` elements, and schedules one bounded post-commit read with `setTimeout(0)`.
|
||||
- The observer has an element `WeakSet` for one DOM incarnation. It disconnects on `pagehide`; if the message-list root is replaced, it disconnects and rebinds. It does not watch attributes or character data and it does not retain removed elements.
|
||||
- The timer is only a React-commit boundary, not a retry loop. If Fiber data is absent after that read, the node is rejected with a minimal diagnostic. A subsequent actual mount mutation may produce one new attempt.
|
||||
|
||||
### 2.2 Fiber adapter
|
||||
|
||||
`react-card-reader.ts` is the only module permitted to inspect `__reactFiber$*` and Fiber `memoizedProps`. It:
|
||||
|
||||
1. locates the Fiber property dynamically on the mounted wrapper/descendants;
|
||||
2. climbs a bounded parent chain to one props record containing `itemData`;
|
||||
3. uses `itemData` only for the named identity/classification allowlist;
|
||||
4. searches the card's bounded Fiber subtree for the single type-specific template `props.data` record that owns its visible business fields;
|
||||
5. returns either typed `OneTalkRenderedCardContent` or a diagnostic code, never raw `itemData`, template context or Fiber objects.
|
||||
|
||||
Required common fields are:
|
||||
|
||||
| Fiber source | Rule | Use |
|
||||
| --- | --- | --- |
|
||||
| `itemData.messageId` | safe integer/string normalized by existing message-ID normalizer | message identity |
|
||||
| `itemData.conversationCode` | nonblank string and equal to selected `data-cid` before and after extraction | conversation identity |
|
||||
| `itemData.messageType` | `rec` or `send` | cross-check direction against base candidate |
|
||||
| `itemData.sendTime` | non-negative safe integer | cross-check metadata; not a replacement identity |
|
||||
| `itemData.msgType` | safe integer | classifier guard only |
|
||||
|
||||
`channelAccountId` is still read by the existing MAIN page-context owner. The reader returns only a normalized `RenderedCardObservation`; the DOM node and Fiber are not bridged or persisted.
|
||||
|
||||
The template-data search is intentionally not a generic object crawl. Each card reader owns a verified predicate over its direct component props, then projects exact allowlisted paths. For example, the live order sample's template data is recognized by the concurrent presence of `cardTitle`, `productInfoList`, `orderStatusText` and `shippingAddress`; only the projection below leaves MAIN world.
|
||||
|
||||
### 2.3 Rendered-card content contract
|
||||
|
||||
Base `OneTalkMessageContent` remains immutable. This feature introduces a separate, exact `OneTalkRenderedCardContent` union for the visual information the existing message/history source cannot provide. Its values are all produced from typed template projections in MAIN world.
|
||||
|
||||
| Kind | Required Fiber evidence | Exact displayed projection | Consumer-visible purpose | Rejected / never bridged |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| inquiry | `msgType=10010`, `originalData.cardType=6`, plus a verified inquiry template-data predicate | product `{imageUrl, title}`, `purchaseQuantity {value, unit}`, `requirementText`, `inquiryReference`, `actions[{label, available}]` | full inquiry-card history/event view, including the buyer's visible request and quote/detail affordances as display-only state | hidden encrypted IDs, callbacks, click URLs/params, raw `params`, `from`, `to`, `sign` |
|
||||
| product | a second sample confirming renderer `cardType=54`, `msgType=101`, and a verified template-data predicate | optional store badge/logo, `product {imageUrl, title, sourceUrl, productId}`, `priceDisplay`, `minimumOrder {value, unit}`, `serviceBadges[]` | full product-card history/event view and safe read-only product reference | raw source text, query/credential-bearing URL, seller/contact object, navigation/action objects |
|
||||
| order | `msgType=10010`, `originalData.cardType=9`, template predicate `{cardTitle, productInfoList, orderStatusText, shippingAddress}` | `title`, `products[{imageUrl, title}]`, `productCount`, `status {code, text}`, `payment {totalDisplay, discountDisplay}`, `delivery {shippingAddress, methodLabel, dateLabel}`, `action {label, status}` | full order-card history/event view, including visible shipping information, without executable order behavior | `orderAction.actionParams`, trace/click data, callbacks, raw `params`, Base64, customer/contact objects |
|
||||
|
||||
`imageUrl` is `string | null`: it is emitted only after a card-image URL validator accepts HTTPS, an allowlisted host, and no credentials/query/fragment; otherwise the rest of the card is retained with `null` image. All visible text is bounded, control-character-checked plain text. `shippingAddress` and `requirementText` are designated sensitive rendered-card content: they are stored and delivered only through the existing authorized account/conversation read scope, never added to diagnostics or general logs.
|
||||
|
||||
Common Fiber fields serve only association: `messageId` joins the base message, `conversationCode` proves its scope, and `messageType`/`sendTime` cross-check it. `msgType` and the two card-type values are classification guards; none becomes consumer-visible card content. Ledger-only `contentFingerprint`, `observedAtMs`, terminal status and conflict metadata exist only for duplicate suppression, late-ACK matching and recovery.
|
||||
|
||||
The rendered-card contract owns exact guards, clone/equality function and canonical fingerprint tuple; individual readers must not form their own JSON fingerprints.
|
||||
|
||||
## 3. Bridge, local ledger and ACK
|
||||
|
||||
### 3.1 New envelope and coordinator
|
||||
|
||||
- Add one exact page-bridge envelope (`onetalk.page.rendered-card-observed`) containing a nonempty array of typed normalized observations plus `channelAccountId`. It is separate from `onetalk.page.observed`; the latter remains the base-message flow.
|
||||
- ISOLATED verifies source/origin/direction and forwards without interpreting the payload. The Service Worker decodes it with the page-bridge model, then hands it to the rendered-card coordinator.
|
||||
- Add `rendered.card.observed` and `rendered.card.ack` plugin wire frames. Bump `ONETALK_PROTOCOL_VERSION` from 7 to 8 and teach the central frame type lists, connection-direction checks and exact payload decoder about the new family. There is no permissive old-protocol fallback.
|
||||
- The observed payload contains only `channelAccountId`, `conversationId`, `messageId`, validated rendered content, its fingerprint and `observedAtMs`. It does not carry `sentAtMs`, direction, participants, read status, message status, history generation or conversation activity values.
|
||||
|
||||
### 3.2 IndexedDB record
|
||||
|
||||
Database version advances from 8 to 9. `ensureSyncStores` creates `onetalk_rendered_card_ledger`; upgrades from v8 only add this store and preserve every existing store. The record uses the existing `candidateKey(channelAccountId, conversationId, messageId)`:
|
||||
|
||||
```ts
|
||||
type RenderedCardLedgerRecord = {
|
||||
key: string;
|
||||
channelAccountId: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
content: OneTalkRenderedCardContent;
|
||||
contentFingerprint: string;
|
||||
observedAtMs: number;
|
||||
status: "pending_ack" | "confirmed" | "rejected";
|
||||
requestId?: string;
|
||||
firstObservedAt: number;
|
||||
updatedAt: number;
|
||||
confirmedAt?: number;
|
||||
rejectedAt?: number;
|
||||
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
|
||||
};
|
||||
```
|
||||
|
||||
The state owner is a dedicated rendered-card coordinator, not the generic sync engine and not a page-local `Set`.
|
||||
|
||||
| Input/state | Result |
|
||||
| --- | --- |
|
||||
| same key + same validated content | keep existing terminal/pending record; update observation time only |
|
||||
| same key + different validated content before terminal ACK | keep first pending and record `content_conflict` diagnostic; do not replace outbound payload |
|
||||
| same key + different validated content after confirmed | keep confirmed first content; record terminal conflict diagnostic |
|
||||
| matching accepted or duplicate ACK | atomically mark matching pending fingerprint/observation confirmed |
|
||||
| matching conflict/rejected ACK | atomically mark matching pending rejected; no automatic retry |
|
||||
| missing/unconfirmed base candidate | retain card record pending locally; do not send until base candidate confirms |
|
||||
| worker restart/reconnect | list `pending_ack`, verify base candidate confirmation, resend exact durable snapshot |
|
||||
|
||||
The coordinator uses the same commit fence as other Service Worker flows: durable write completes before wire send; after each await it verifies that the coordinator, connection generation and pending record still match; a stale ACK cannot modify a replacement record.
|
||||
|
||||
## 4. Bright storage and conflict semantics
|
||||
|
||||
### 4.1 Schema
|
||||
|
||||
Add `onetalk_rendered_card_content` rather than changing `onetalk_message.content`.
|
||||
|
||||
```text
|
||||
primary key: channel_account_id + conversation_id + message_id
|
||||
rendered_card_content: JSONB, exact OneTalkRenderedCardContent
|
||||
rendered_card_content_fingerprint: TEXT
|
||||
rendered_card_observed_at_ms: BIGINT
|
||||
first_confirmed_at / last_observed_at: TIMESTAMPTZ
|
||||
conflict_count: INTEGER
|
||||
last_conflicting_fingerprint: TEXT NULL
|
||||
last_conflict_observed_at_ms: BIGINT NULL
|
||||
```
|
||||
|
||||
The table holds no workspace/binding/device copy; those remain on the base fact. Every source field and migration SQL receives matching safe comments. There is no foreign-key-driven implicit behavior: the repository first verifies the base message under the same account/conversation scope, then writes this row in one guarded transaction. The base-message read is verification only: no `onetalk_message` or `onetalk_conversation` row is updated. History reset explicitly clears the matching rendered-card rows in its existing transaction.
|
||||
|
||||
### 4.2 Ingest result
|
||||
|
||||
`storeRenderedCardContent` is a repository/service operation distinct from `insertMessage`, and must not call `observeMessage`/`observeMessages`:
|
||||
|
||||
1. validate the exact shared frame/content and require base message existence;
|
||||
2. lock/read the card row by complete message key;
|
||||
3. insert the first normalized snapshot and return `accepted`;
|
||||
4. if content and fingerprint are semantically equal, update only `lastObservedAt` and return `duplicate`;
|
||||
5. if the fingerprint/content differs, increment conflict metadata without overwriting content and return `conflict`;
|
||||
6. commit, then ACK, then only for `accepted` publish `message.updated` with the effective message.
|
||||
|
||||
It never inserts/updates the base message, and never updates `sent_at_ms`, base `last_observed_at`, `last_message_at_ms`, `last_contact_time_ms`, `message_count`, `latest_message_id`, checkpoint, anchor or history cursor. `rendered_card_observed_at_ms`, supplement `last_observed_at` and conflict timestamps belong only to the enrichment row.
|
||||
|
||||
Fingerprint is a stable contract-owned canonical tuple, not equality by JSON text. The server additionally compares validated snapshots so a fingerprint collision cannot silently be treated as duplicate.
|
||||
|
||||
## 5. Read and event projection
|
||||
|
||||
- `read-repository.ts` retains its base-message page query. It derives unique `[channelAccountId, conversationId, messageId]` keys, reads matching rendered-card rows in a second scoped query, rejects duplicate enrichment keys, and combines via a full-key `Map` without changing ordering/cursor behavior.
|
||||
- `effectiveContent = renderedCardContent ?? content` is a controlled union of base and rendered-card content, applied by one projection function used by history HTTP/internal reads and live event construction. No enrichment preserves existing output byte-for-byte at the model level.
|
||||
- Add `message.updated` as a Mind-page-only frame with the same `OneTalkCenterMessage` payload shape as `message.created`. It reuses the base message's original `sentAtMs`; the publisher serializes it on the existing account queue and targets only exact authorized scope.
|
||||
- Base accepted result continues to send one `message.created`. Rendered-card accepted result sends one in-place `message.updated`; it must not publish `conversation.updated`, move the conversation, increment a count or change history cursor/order. Duplicate, conflict, rejected and no-Mind-connection cases do not manufacture other events.
|
||||
|
||||
## 6. Failure, migration and rollout boundaries
|
||||
|
||||
- Any missing Fiber itemData, identity mismatch, unsupported card, raw/schema validation failure, bridge decode failure, IndexedDB failure, base-message absence, transaction/commit-guard failure or protocol mismatch fails closed; it emits no success ACK or Mind update.
|
||||
- The DB migration is additive. It must be generated from the Drizzle schema, include SQL comments, be applied by the existing migration job only, and never edit historical migration files.
|
||||
- The extension IDB v8→v9 upgrade is additive and must not clear current messages, candidates, checkpoints, profiles or bootstrap state.
|
||||
- Product remains disabled until the second live runtime sample proves the expected Fiber evidence and produces a safe canonical product URL; this is an explicit release gate, not a DOM fallback.
|
||||
@@ -0,0 +1,7 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"Defines single-owner boundaries, async sequencing, type ownership, and required project-level checks for this cross-layer implementation."}
|
||||
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Defines durable write, ACK, publish, retry, late-ACK, and lifecycle requirements for the card ledger."}
|
||||
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Defines canonical snapshot equality and conflict handling for rendered-card fingerprints."}
|
||||
{"file":".trellis/spec/project/database-query-composition.md","reason":"Requires scoped reads plus in-memory full-key composition instead of SQL JOIN for effective content."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Defines MAIN/ISOLATED/Service Worker ownership, normalized content boundary, and message identity/ACK path."}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Defines IndexedDB durable-first state, restart recovery, ACK behavior, and message candidate boundaries."}
|
||||
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"Defines Drizzle migration, comments, transaction, message idempotency, and commit-ACK-publish contracts."}
|
||||
@@ -0,0 +1,48 @@
|
||||
# React 卡片监测实施计划
|
||||
|
||||
## Delivery shape
|
||||
|
||||
One cross-layer task is retained rather than split into child tasks: the normalized card content, wire protocol, local ACK ledger, server transaction, history projection and Mind event must agree atomically. A partial extension-only or server-only delivery would create a protocol/state orphan.
|
||||
|
||||
## Ordered work
|
||||
|
||||
1. Capture and sanitize live inquiry and second product-card Fiber samples; record field paths/types for every visible screenshot field, then add fixtures. Do not enable either contract until this evidence passes.
|
||||
2. Extend `packages/onetalk-contract` with the rendered-card content union, rendered-card observation/ACK frame family, exact payload guards, `message.updated`, shared content clone/equality/fingerprint helpers, and safe image/text validators. Bump wire protocol 7→8 and update central frame unions/exports/decoders.
|
||||
3. Add `apps/chrome-extension/src/onetalk/main-page/card-observer/`: narrow DOM mount trigger, bounded identity Fiber adapter, bounded template-data reader, typed rendered-content normalizer, pagehide/root-rebind lifecycle, and diagnostics. Reuse existing product URL normalization and order-summary validation where compatible, but do not treat their smaller base-content projection as the rendered-card schema.
|
||||
4. Extend page-bridge model/main/isolated and Service Worker runtime routing with the dedicated rendered-card envelope. Keep ISOLATED transport-only and retain base `onetalk.page.observed` behavior unchanged.
|
||||
5. Add the v9 IndexedDB rendered-card ledger to `service-worker/storage.ts`, plus a dedicated coordinator that enforces durable-first write, base-candidate-confirmed gating, exact ACK matching and restart recovery. Do not put this state in generic candidate records.
|
||||
6. Add server schema/model/repository/service support for `onetalk_rendered_card_content`; generate and review additive Drizzle migration and comments. Implement first-content acceptance, semantic duplicate, no-overwrite conflict metadata, base-message absence and reset cleanup through a dedicated supplement method that never calls the base message observation service.
|
||||
7. Extend plugin router/flow, registry and Mind publisher for rendered-card observed/ack and `message.updated`. Keep `commit -> ACK -> publish` under the existing guard/fence and publish only acceptance.
|
||||
8. Update read repository/projection and HTTP/internal history paths to perform scoped base/enrichment reads plus in-memory full-key composition. Share the effective-content projection with the live update event.
|
||||
9. Add focused tests, then run package/root verification and a real Chrome smoke for each available card kind. Product smoke remains blocked until its second sample exists.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Layer | Required cases |
|
||||
| --- | --- |
|
||||
| contract | exact rendered-card frames, protocol v8 rejection, full inquiry/product/order rendered schemas, image/text privacy validators, fingerprint canonicalization, snapshot mismatch, `message.updated` frame |
|
||||
| MAIN reader | every visible screenshot field for inquiry/product/order, card 12 ignored, unknown/missing Fiber/template data, dynamic Fiber property, message/conversation mismatch, direction/time validation, root replacement/pagehide |
|
||||
| page bridge/SW | exact envelope direction/origin checks, no raw fields, durable-write-before-send, duplicate mutation, pending recovery, late/mismatched ACK, conflict terminal state, base-candidate gate |
|
||||
| IndexedDB | v8→v9 preserves all prior stores, key isolation by account/conversation/message, repeated mount dedup, restart resend, no alternate-content overwrite |
|
||||
| server domain/repository | missing base reject, first accept, equal duplicate, different conflict metadata/no overwrite, transaction guard failure, scoped reset cleanup, source context preservation; assert no mutation to base message `sentAtMs`/`lastObservedAt` or conversation `lastMessageAtMs`/`lastContactTimeMs`/count/anchor |
|
||||
| PostgreSQL | generated migration/comment round-trip, table/check/index, accepted/duplicate/conflict rows, reset cleanup, two scoped reads plus no JOIN effective projection |
|
||||
| read/event | no enrichment returns base content; enrichment overrides only view; history and `message.updated` match while preserving base `sentAtMs`, cursor and ordering; `message.created` once; no conversation move/update; exact scope and no event for duplicate/conflict/reject |
|
||||
| runtime | browser inspection of inquiry/order and product when sample available; verify normalized observations only, no raw fields or duplicate network frames |
|
||||
|
||||
## Validation commands
|
||||
|
||||
1. Targeted Node tests for changed contract, card reader, bridge, storage, coordinator, server repository/read/publisher paths. Backend unit commands use a 60-second timeout.
|
||||
2. `pnpm format:check`
|
||||
3. `pnpm typecheck`
|
||||
4. `pnpm test`
|
||||
5. `pnpm build`
|
||||
6. `pnpm --filter @trade-message-center/server db:generate` then `db:check`; with configured `TEST_DATABASE_URL`, run focused PostgreSQL migration/integration tests.
|
||||
7. `git diff --check`; before commit run GitNexus impact/detect-changes and inspect affected flows.
|
||||
8. Chrome smoke on the authenticated OneTalk page without clicks/reloads: verify the card reader produces only the approved normalized shape and that repeated React render does not cause a second successful upload.
|
||||
|
||||
## Risk gates and rollback
|
||||
|
||||
- Do not start product code until the second live sample is archived as a sanitized fixture and validates the safe URL path.
|
||||
- A protocol v8 deployment requires extension/server contract parity. Mismatched peers reject at the existing hello/version boundary; there is no dual wire behavior.
|
||||
- If database migration validation fails, do not deploy the schema/extension pair. The migration is additive; rollback disables the new extension coordinator/event consumer while preserving base messages and the new inert enrichment rows.
|
||||
- Any discovered requirement to update order status is a follow-up task, not a conditional overwrite in this task.
|
||||
@@ -0,0 +1,79 @@
|
||||
# React 卡片监测
|
||||
|
||||
## Goal
|
||||
|
||||
将 OneTalk 已挂载消息组件中“基础同步无法直接取得”的可见卡片业务内容安全补全到既有消息事实:商品、询盘与订单只在 MAIN world 读取和归一化;扩展与服务端分别建立独立补全账本;Mind 历史读取与实时事件返回补全后的有效内容,同时保留基础 `content` 不可变。
|
||||
|
||||
## Confirmed runtime evidence
|
||||
|
||||
- 当前真实 OneTalk 页面中,卡片消息节点可沿 React Fiber 上溯至 `memoizedProps.itemData`;同一对象提供 `messageId`(number)、`conversationCode`(string)、`messageType`、`msgType`、`originalData` 与发送时间。询盘页已验证 `conversationCode` 等于唯一选中 `.contact-item-container.selected[data-cid]`。
|
||||
- 事实身份固定为 `channelAccountId + conversationId + messageId`。`channelAccountId` 由现有 MAIN page context 读取;`conversationId` 来自经校验的 `conversationCode`;禁止使用 DOM 文本、顺序、时间或 CSS class 合成/猜测身份。
|
||||
- 已实测 `originalData.cardType`:询盘 `6`、订单 `9`、文件 `12`。外层 `msgType: 10010` 不足以分类,文件必须严格排除。商品当前仅有一份真实样本:渲染器 props `cardType: 54`,基础项为接收侧 `msgType: 101`;尚不能视为稳定协议。
|
||||
- 订单卡已验证:卡片模板的 React Fiber `memoizedProps.data`(不是 `itemData.originalData.params`)包含 `productInfoList/productList` 的商品图和名称、`productAmount`、`orderStatusText`、`shouldPayAmount`、`shippingAddress`、`cardTitle`、交付/优惠/备注展示字段,以及只读 `orderAction`。此前 decoder 只取得其中的金额、状态和 action 摘要,遗漏了截图中的商品与收货信息。
|
||||
- 现有消息同步账本和 Bright 消息事实都以同一复合键去重;首次事实 `commit -> plugin ACK -> message.created`,重复不会再发布。现有基础 `content` 是版本化、归一化 JSON,且不保存 raw content、`params`、签名、Base64 或 token。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. Fiber-only card fact reader
|
||||
|
||||
- MutationObserver 只负责发现新挂载的 `.message-item-wrapper`;消息身份来自关联 Fiber `itemData`,二次业务内容来自匹配卡片模板的受限 Fiber `props.data`。不得从 DOM 文案、节点顺序或 class 猜测任何字段。
|
||||
- Reader 必须在 MAIN world 对 `messageId`、`conversationCode`、`msgType` 与相应 `originalData` 路径逐项校验,并确认读取前后唯一选中会话未变化;再按卡片类型从允许的模板 `data` 路径抽取可见内容。基础消息的方向、`sentAtMs` 与参与者信息不由此路径读取或修改。
|
||||
- 任一身份或类型条件不成立时,丢弃该节点并记录最小、无敏感内容的诊断;不得退回 DOM 文案、顺序或 class 猜测。
|
||||
|
||||
### R2. Complete rendered-card contracts and field boundary
|
||||
|
||||
- Secondary content is a new shared `OneTalkRenderedCardContent` union, stored separately from the immutable base `OneTalkMessageContent`. It is not an unbounded copy of Fiber props: only business fields actually rendered inside the card are projected.
|
||||
- Identity fields read from Fiber are never content fields: `channelAccountId + conversationCode + messageId` form the association/dedup key; `messageType` and `sendTime` are cross-checks; `msgType`, source `cardType` and renderer `cardType` are classifier guards.
|
||||
|
||||
| Card | Required evidence | Persisted rendered content | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| inquiry | `msgType=10010`, `originalData.cardType=6`; a real template-data sample before enabling | product image reference, product title, purchase quantity/value + unit, full displayed detailed requirement text, displayed inquiry reference ID, visible action labels/availability | reconstruct the complete inquiry card in history/UI and make the buyer requirement available to authorized consumers; action data remains display-only |
|
||||
| product | second live renderer-`cardType=54` sample plus template-data shape | product/store image reference, product title, price/range display, minimum order quantity + unit, displayed service/return badges, canonical product reference (`sourceUrl`, `productId`) | reconstruct the product card and provide a safe product reference; no automatic navigation |
|
||||
| order | `msgType=10010`, `originalData.cardType=9`, template `data` shape | card title; product count; ordered product summaries `{imageUrl, title}`; order status code/text; displayed payable/discount/total amounts; displayed delivery/shipping labels; full displayed shipping address; read-only action label/status | reconstruct the order card exactly enough for authorized history/UI; the address is business-content display, not a scope/authorization field; no action execution |
|
||||
|
||||
- Screenshot-derived inquiry/product fields are target requirements, not yet verified Fiber paths. Each needs a sanitized live schema fixture before release. Order fields listed above are already verified as template-data paths in the current runtime.
|
||||
- Every user-visible string is bounded, control-character-checked and preserved as typed content rather than raw HTML. Image references must be HTTPS, credential/query/fragment-free and pass a dedicated URL validator; otherwise the text/count data is still captured but the image field is `null`.
|
||||
- Visible action labels may be stored; action URLs, action params, callbacks, EventBus objects, template context and click-trace data never cross MAIN world.
|
||||
- Never collect, bridge, persist, log or publish: raw `content`; full `originalData`; full raw `params`; hidden encrypted IDs not displayed by the card; `from`/`to`; `sign`; Base64; unapproved URL query/fragment/credentials; React functions, EventBus objects or DOM/Fiber references.
|
||||
|
||||
The ledger—not rendered content—stores `contentFingerprint`, `observedAtMs`, ACK status, first/last-seen time and conflict metadata. Their only purpose is duplicate suppression, exact late-ACK matching, recovery and diagnostics. `contentFingerprint` never replaces semantic equality of two validated snapshots.
|
||||
|
||||
### R3. Extension durable card ledger
|
||||
|
||||
- 保留既有消息 candidate/checkpoint 账本不变;新增独立 rendered-card ledger,以消息复合键、规范化内容指纹、`pending_ack | confirmed | rejected`、观察时间和必要终态时间记录补全上传。
|
||||
- 卡片仅在对应基础消息 candidate 已确认后发送。重渲染、虚拟列表重挂、Service Worker 重启、断线和 ACK 丢失都从 ledger 中恢复 pending,而非重新依赖页面内存。
|
||||
- 相同复合键和相同内容指纹只上传一次;服务端明确 duplicate 后本地确认。服务端 conflict 使本地成为 terminal rejected 并保留最小诊断,不能无限重试或覆盖本地已确认内容。
|
||||
|
||||
### R4. Server-side independent enrichment
|
||||
|
||||
- 基础 `onetalk_message.content` 继续由现有一次性消息同步写入,永不被 React 补全覆盖。
|
||||
- 新增独立 rendered-card 内容事实表,记录 exact `OneTalkRenderedCardContent`、`renderedCardContentFingerprint`、`renderedCardObservedAt` 及冲突元数据,主键同基础消息复合键。仅在同一基础消息已存在时可写入。
|
||||
- 首个经验证的补全写入 accepted;相同内容为 duplicate;不同内容记录 conflict(包括冲突计数、最后冲突指纹/时间)且不覆盖首个已确认补全。订单的动态状态更新不在本期隐式允许。
|
||||
- 服务端以同一授权 scope 和完整复合键分别读取基础消息与补全事实,在内存中计算 `effectiveContent = renderedCardContent ?? content`;不得增加 SQL/Drizzle JOIN。
|
||||
- rendered-card 接口不得调用 `observeMessage` 或 `observeMessages`。除补全表本身外,它不得插入、更新或触发更新 `onetalk_message`、`onetalk_conversation`、candidate/checkpoint 或 history anchor;特别是不得改写 `sent_at_ms`、基础 `last_observed_at`、`last_message_at_ms`、`last_contact_time_ms`、`message_count`、`latest_message_id`、分页 cursor 或排序。
|
||||
|
||||
### R5. Protocol, reads and Mind events
|
||||
|
||||
- 建立专用 rendered-card observed/ack wire family 和精确 decoder;升级 OneTalk wire protocol,旧协议双方 fail closed。
|
||||
- 历史 HTTP/内部读取和实时 Mind WebSocket 均返回 `effectiveContent = renderedCardContent ?? content`;其公开类型是 base 与 rendered-card content 的受控联合。
|
||||
- 基础消息首次 accepted 后只发布 `message.created`;补全首次 accepted 后只发布 `message.updated`;duplicate、conflict、rejected 均不发布。发布顺序仍为 server commit -> plugin ACK -> authorized exact-scope Mind event。
|
||||
- `message.updated` 仅替换已有消息的 effective content;它携带基础消息原有的 `sentAtMs`,但不改变历史位置、会话活动时间、消息计数或 `conversation.updated` 的 move-to-top 语义。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 三类卡各有可审计的 `Fiber path -> rendered content` 合同、真实判别证据、字段白名单、字段拒绝规则和 targeted tests;商品与询盘规则各有第二份独立运行时样本,否则不启用。
|
||||
- [ ] 任意一次卡片 observation 都能证明其复合身份,且会话切换/重新渲染/虚拟列表重挂不会错配或重复上传。
|
||||
- [ ] 本地 IndexedDB 升级新增独立 ledger,不清理现有 v8 账本;在重启、重复 Mutation、ACK 重放/丢失、server duplicate/conflict 下有确定终态。
|
||||
- [ ] PostgreSQL migration 新增独立补全事实表、完整注释和索引;重复、冲突、基础消息缺失、事务/commit-guard 失败均有可验证语义。
|
||||
- [ ] 每个 rendered-card accepted/duplicate/conflict/rejected 回归均断言基础消息及会话的 `sent_at_ms`、`last_observed_at`、`last_message_at_ms`、`last_contact_time_ms`、`message_count`、anchor/cursor 与排序完全不变。
|
||||
- [ ] history read 与 `message.updated` 返回相同 effective content,原基础 content 保持不变;无补全记录时保持原响应。
|
||||
- [ ] `message.created` 不因补全再次发布;补全 accepted 仅一次 `message.updated`,并只发给当前授权的精确 Mind scope。
|
||||
- [ ] 截图中可见的完整询盘需求、商品/订单摘要和订单收货地址可按受控内容合同返回给授权消费者;未桥接、持久化、日志化或发布 raw `content`、`originalData.params`、签名、Base64、隐藏加密 ID、`from`/`to` 或未经批准 URL。
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 不修改 OneTalk 页面/React 状态,不主动点击、翻页或请求额外业务接口。
|
||||
- 不改变既有历史同步、消息 candidate/checkpoint、发送确认或基础消息去重语义。
|
||||
- 不将二次补全伪装为新消息、重新观察、历史分页或会话活动;不更新任何基础消息/会话时间字段。
|
||||
- 不采集文件、图片、文本、客户名片或未知卡。
|
||||
- 不做订单动态字段覆盖;该能力需另定义允许更新字段、内容版本和事件语义。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "react-card-monitor",
|
||||
"name": "react-card-monitor",
|
||||
"title": "React 卡片监测",
|
||||
"description": "规划商品、订单与询盘卡片的 React Fiber 采集、字段合同和 IndexedDB 去重。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-15",
|
||||
"branch": "dev",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Server test, type-check, build, and formatting verification requirements."}
|
||||
{"file":".trellis/spec/server/backend/mind-authorization.md","reason":"Authorization security and regression matrix used to review the implementation."}
|
||||
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review authorization fences and late-result invalidation behavior."}
|
||||
{"file": ".trellis/tasks/09-14-workspace-authorization-rebinding/research/auth-context-research.md", "reason": "Independent check matrix, source anchors, and high-risk publish fallback evidence."}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Center 空间授权上下文设计
|
||||
|
||||
## Boundary and Invariant
|
||||
|
||||
`workspaceId` 是 Mind 页面请求声明的空间选择,不是身份。Center 只在 Mind 页面边界接收它,将其附带 Cookie 和 TM `channelAccountId` 转交固定 Mind session-authorization endpoint;Center 不查询 Mind 数据库、不从任何用户 ID/header 推导身份,也不向插件连接增加该字段。
|
||||
|
||||
一个 Mind 页面授权判定只能对同时满足以下条件的空间有效:
|
||||
|
||||
```text
|
||||
HTTP header / WS query workspaceId
|
||||
== Mind authorization response.mindScope.workspaceId
|
||||
== HTTP response / authenticated WS state workspaceId
|
||||
```
|
||||
|
||||
任一缺失或不相等均失败关闭;不会选择默认空间、复用旧 decision 或降级为只按账号授权。
|
||||
|
||||
## Canonical Workspace Context
|
||||
|
||||
在 `apps/server/src/` 创建一个 Center-only 的 Mind workspace context owner,统一拥有:
|
||||
|
||||
- 对外发送 header 的精确名称 `Sinan-Pilot-Workspace-Id` 与小写入站 header key;
|
||||
- HTTP header 的单值、非空校验;
|
||||
- `/ws/mind` URL 中 `workspaceId` 的单值、非空校验;
|
||||
- 从请求边界取得值后供授权 reader 使用的窄 `workspaceId` 值。
|
||||
|
||||
它不处理 Cookie、Mind user、channel account 或授权结果。这些分别仍由 HTTP/WS ingress、Mind authorization adapter 和 protocol session owner 负责。重复 query/header、空白值或多值合并被视为无效;不会 trim/重写后再向 Mind 转发。
|
||||
|
||||
## Contract Change
|
||||
|
||||
`OneTalkMindAuthorizationRequest.scope` 从仅 `channelAccountId` 改为 `{ channelAccountId, workspaceId }`。这是 Center 与现有 mock authorization reader 共用的授权调用契约;响应 contract 和请求 JSON body 都不改变。
|
||||
|
||||
Mind authorization adapter 对 `mind_page` request:
|
||||
|
||||
```text
|
||||
POST /internal/bright/onetalk/authorize-session
|
||||
Cookie: <original request cookie>
|
||||
Sinan-Pilot-Workspace-Id: <received workspaceId>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "channelAccountId": "..." }
|
||||
```
|
||||
|
||||
只允许固定 session URL;`workspaceId` 不参与 URL、日志、诊断或持久化。成功 response 必须同时匹配请求的 `channelAccountId` 和 `workspaceId`,否则 reader 返回 `scope_mismatch`。Plugin binding authorization 不获得该字段或 header。
|
||||
|
||||
## HTTP Data Flow
|
||||
|
||||
```text
|
||||
Mind HTTP read
|
||||
-> public context reads exactly one workspace header
|
||||
-> rejects invalid context with 403 scope_mismatch
|
||||
-> calls Mind session authorization with cookie + account + workspace
|
||||
-> adapter validates response account/workspace equality
|
||||
-> route calls existing Bright read service with Mind-returned scope
|
||||
```
|
||||
|
||||
会话列表、会话详情、消息历史与现有 public rebuild route 共用此 flow;internal `7777` summary route 不属于 Mind page,不变。CORS preflight 把精确 workspace header 加入 allow-list,仍要求精确 Mind Origin 和 credentialed CORS。
|
||||
|
||||
## WebSocket Data Flow and Fences
|
||||
|
||||
```text
|
||||
GET /ws/mind?workspaceId=<id>
|
||||
-> upgrade hook requires exactly one nonblank query value
|
||||
-> request context pins it to this socket
|
||||
-> hello scope must equal query workspaceId
|
||||
-> session authorization captures cookie + pinned workspaceId
|
||||
-> every hello/heartbeat/send/rebuild/publish authorization forwards pinned value
|
||||
-> each existing authorization/version/binding/scope fence stays in force
|
||||
```
|
||||
|
||||
Invalid query is rejected before endpoint creation with HTTP 403. A valid query but mismatching `ws.hello.scope.workspaceId` produces the existing WebSocket `scope_mismatch` close. `OneTalkMindSessionAuthorization` owns the per-socket captured workspace value; it rejects an invocation whose full Mind scope does not match that value before asking Mind.
|
||||
|
||||
Existing registry and pending-send fallback authorization calls (used when a manually registered/test connection has no session capability) must pass both account and workspace values from the connection. This preserves the same invariant for publish, rebuild and send fences rather than relying on a normally-present capability.
|
||||
|
||||
There is no new revocation push. As today, HTTP authorizes each request and WebSocket fences reauthorize before heartbeat, send, rebuild and publish. When Mind denies/changes binding/version/scope after a rebind, the next fence sends only its stable failure, unregisters/closes the old socket where existing flow requires, and performs no read, send, rebuild status publish or message publish.
|
||||
|
||||
## Error Mapping
|
||||
|
||||
| Boundary | Invalid condition | Center result |
|
||||
| --- | --- | --- |
|
||||
| Public HTTP | missing, blank or ambiguous workspace header | `403 { error: { code: "scope_mismatch" } }`, no Mind/Bright call |
|
||||
| CORS preflight | workspace header with valid Mind Origin/method | `204`, includes `Sinan-Pilot-Workspace-Id` in allowed headers |
|
||||
| WS upgrade | missing, blank or repeated `workspaceId` | HTTP `403`, no socket/session |
|
||||
| WS hello | query workspace differs from hello scope | `ws.error scope_mismatch`, close `1008` |
|
||||
| Mind response | returned workspace/account differs from requested values | existing authorization rejection `scope_mismatch` |
|
||||
| Mind denial/unavailability | denied, timeout, malformed payload | existing stable failure/close behavior |
|
||||
|
||||
## Compatibility and Rollout
|
||||
|
||||
This is intentionally a breaking requirement for Mind page callers: header/query omission is rejected. Mind UI must deploy its HTTP header and WebSocket query changes before or with Center. During space switching it must close old socket, cancel old reads, clear state, establish the new connection, and reject late callbacks by its own epoch; those are Mind-owned changes and are not implemented here.
|
||||
|
||||
Center's message tables, composite fact identity, deduplication, history records and plugin protocol remain byte-for-byte outside this change. `/api/oss/harness-upload` is explicitly excluded.
|
||||
|
||||
Rollback is a code rollback of this Center contract only; no Center migration or data repair occurs.
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/server/backend/mind-authorization.md","reason":"Mind session authorization boundary, cookie forwarding, fail-closed scope validation, and existing WS fences."}
|
||||
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Stable HTTP and WebSocket error code and response-shape requirements."}
|
||||
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Reauthorization and post-await invalidation rules for send, rebuild, and publish fences."}
|
||||
{"file":".trellis/spec/project/module-organization.md","reason":"Ownership of the workspace input contract shared by HTTP, WS, and authorization adapter."}
|
||||
{"file": ".trellis/tasks/09-14-workspace-authorization-rebinding/research/auth-context-research.md", "reason": "Current-worktree authorization chain audit and high-risk publish fallback evidence."}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Implementation Plan
|
||||
|
||||
## 1. Define the Center authorization contract
|
||||
|
||||
- Extend the Mind-page authorization request scope with `workspaceId` in `packages/onetalk-contract/src/authorization.ts`.
|
||||
- Make the mock authorization reader match Mind page records by both `channelAccountId` and `workspaceId`, retaining existing plugin behavior.
|
||||
- Add a Center server workspace-context module as the single owner for the canonical header name and HTTP/WS boundary parsing.
|
||||
|
||||
## 2. Forward and validate Mind session workspace context
|
||||
|
||||
- Change `apps/server/src/mind-authorization.ts` so only session authorization adds `Sinan-Pilot-Workspace-Id`; preserve Cookie forwarding and JSON body shape.
|
||||
- Require the returned Mind scope to match both requested account and workspace before returning an allowed decision.
|
||||
- Propagate the workspace field through `readAuthorizationVersion` and all fallback Mind-page authorization calls.
|
||||
|
||||
## 3. Secure public HTTP reads and CORS
|
||||
|
||||
- In `apps/server/src/http/onetalk/public-context.ts`, parse workspace context before calling the authorization reader and return existing `scope_mismatch` on invalid input.
|
||||
- Expand only the Bright public-read CORS allow-list/response to include `Sinan-Pilot-Workspace-Id`.
|
||||
- Exercise list, detail, history and public rebuild authorization paths. Leave internal summary and OSS harness paths unchanged.
|
||||
|
||||
## 4. Pin WebSocket workspace context
|
||||
|
||||
- In `apps/server/src/websocket/index.ts`, validate the Mind WS query during upgrade and put the accepted value in request context.
|
||||
- In `apps/server/src/websocket/handler.ts` and `apps/server/src/websocket/mind/*`, require hello/query equality and construct `OneTalkMindSessionAuthorization` with the pinned workspace.
|
||||
- Preserve existing single-socket FIFO, registry generation, canonical-connection and post-authorization fences; update only their authorization request shape so heartbeat, send, rebuild and publish cannot omit workspace.
|
||||
|
||||
## 5. Tests and review
|
||||
|
||||
- Update contract/mock tests for the expanded Mind-page request scope.
|
||||
- Extend `apps/server/test/mind-authorization.test.ts` for outgoing header, unchanged body, response workspace mismatch, and no plugin header.
|
||||
- Extend `apps/server/test/onetalk-http.test.ts` for header-required reads, preflight and no reader/service call on invalid header.
|
||||
- Extend `apps/server/test/websocket.test.ts` plus the focused WS fence tests for query-required connection, hello mismatch, query forwarding across heartbeat, and rebind failure blocking publish/send/rebuild.
|
||||
- Run focused tests (each no more than 60 seconds), then `pnpm format:check`, `pnpm typecheck`, `pnpm build`, `pnpm test`, and `git diff --check`. Before any commit, run the required GitNexus change detection and inspect only task-owned diffs.
|
||||
|
||||
## Risk and Rollback Gates
|
||||
|
||||
- Before modifying each source symbol, run GitNexus upstream impact analysis and stop for HIGH/CRITICAL findings until reviewed.
|
||||
- Do not alter authorization response schemas, error-code schemas, request JSON bodies, message storage, deduplication, history content, plugin handshake, or OSS harness upload.
|
||||
- If Mind has not deployed the required header/query before Center rollout, all Mind-page reads and connections will fail closed by design; rollout requires coordinated deployment, not a fallback.
|
||||
- A rollback restores the preceding Center code; no migration or data cleanup is involved.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Center 空间授权 Header 透传与会话失效
|
||||
|
||||
## Goal
|
||||
|
||||
Center 从 Mind 页面 HTTP 请求的 `Sinan-Pilot-Workspace-Id` header 与 Mind 页面 WebSocket URL 的 `workspaceId` query 取得当前空间,并在每次向 Mind 请求授权时将其原样置入同名 header。这样 Mind 能以可信 Session、空间与 TM 账号信息作最终校验,Center 不会跨空间复用授权结果。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Mind 页面三条 Bright 读取路由(会话列表、会话详情、消息历史)均经过 `createPublicReadContext` 的逐请求 `authorizeRead`;当前只从请求转发 Cookie,授权 request body 仅含 `channelAccountId`(`apps/server/src/http/onetalk/public-context.ts:74-102`、`apps/server/src/mind-authorization.ts:173-194`)。
|
||||
- 读取 API 的 CORS 预检当前仅允许 `content-type`,因此浏览器携带空间 header 会被预检拒绝(`apps/server/src/http/onetalk/public-context.ts:17,136-164`)。
|
||||
- Mind 页面 WebSocket 仅将 Cookie 与 Origin 保存到 request context;`/ws/mind` 的 URL query 尚未被解析或传至授权 reader(`apps/server/src/websocket/index.ts:64-131`)。
|
||||
- Mind 页面 WS 建连后保留 session-only authorization capability,心跳、发送和发布前重授信均会调用它;它当前未携带请求空间(`apps/server/src/websocket/mind/session-authorization.ts:11-42`)。
|
||||
- 授权 reader 只允许两个固定 Mind endpoint,且以 Cookie 调用 session endpoint、失败关闭;这是扩展 header 的唯一对外授权调用点(`apps/server/src/mind-authorization.ts:12-13,101-148,151-229`)。
|
||||
|
||||
## In Scope
|
||||
|
||||
- [R1] 定义一个 Center 内部唯一 owner,用于解析并校验非空 `Sinan-Pilot-Workspace-Id` / `workspaceId`,并以精确名称把值转交 Mind 授权调用。
|
||||
- [R2] 对会话列表、会话详情、消息历史以及已有的会话重建 HTTP 路径:缺少、空白或重复的空间 header 一律以稳定拒绝响应失败关闭;可用 header 必须在 CORS 预检中获准。
|
||||
- [R3] 对 `/ws/mind?workspaceId=...`:缺少、空白或重复 query 一律在建连前拒绝;有效值绑定至该连接,并在 hello、心跳、发送、重建和推送前的重授权请求中持续转发给 Mind。
|
||||
- [R4] 保持现有 HTTP 每次读取重新授权,以及 WS 关键副作用前重新授权的 fail-closed 行为;Mind 使旧空间/binding 失效后,Center 不得把旧连接缓存的授权用于推送、发送或读取/重建。
|
||||
- [R5] 添加针对授权 reader、HTTP CORS/读取路由和 Mind WS 的回归,证明 header/query 被精确透传、缺失或伪造值被拒绝、以及 Mind 返回空间不匹配或旧授权失效时无数据/副作用泄漏。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Mind 前端的 HTTP header 注入、WebSocket 重连、取消旧请求、清理状态和 callback epoch 防串写;Center 只定义并接收其接口契约。
|
||||
- Mind 后端的可信 Session 用户读取、成员/角色/账号归属检查、binding 换绑事务、唯一约束、存量重复清理,以及后台纪要任务停止逻辑。
|
||||
- Center 消息表、消息去重键、历史事实与新空间完整历史的读取语义。
|
||||
- Chrome extension/plugin 的授权 payload、binding 存储或协议;它不接收 Mind 页面 workspace header/query。
|
||||
- 仅供本地联调的 `/api/oss/harness-upload`;它没有 TM 账号或 binding 上下文,用户明确排除。
|
||||
|
||||
## Interface Contract for Mind
|
||||
|
||||
- HTTP reads use `Sinan-Pilot-Workspace-Id: <current workspace ID>` and credentials. On browser CORS preflight, the requested header must be allowed by Center.
|
||||
- Mind page WebSocket connects to `/ws/mind?workspaceId=<current workspace ID>` and credentials; query parameter names are case-sensitive.
|
||||
- On switching spaces, Mind UI must close the old socket, abort old reads, clear old data, then create requests/connection for the new space. It must ignore late callbacks from an old request or socket epoch.
|
||||
- Center forwards the received value only to the fixed Mind session-authorization endpoint as `Sinan-Pilot-Workspace-Id`; it never trusts the value as a user identity or changes the request body contract.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [AC1] Each of the three HTTP read routes accepts a single nonblank workspace header, passes it unchanged to Mind authorization, and allows the browser CORS preflight for that exact header.
|
||||
- [AC2] HTTP routes reject a missing, blank, or ambiguous workspace header before any Bright read or Mind authorization request; no other workspace fallback exists.
|
||||
- [AC3] `/ws/mind` accepts only one nonblank `workspaceId`; it passes that value unchanged to every session authorization request associated with that socket.
|
||||
- [AC4] A differing/denied result from Mind blocks the HTTP request and prevents old Mind WS connections from publishing, sending, or rebuilding after their next authorization fence; no old successful decision is reused.
|
||||
- [AC5] Existing plugin WebSocket behavior, Center message storage, deduplication, and historical read projections remain unchanged.
|
||||
- [AC6] Focused tests cover successful forwarding, HTTP preflight, rejected header/query cases, Mind-denied cross-space access, and post-invalidation WS fences.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None. The actual Mind UI/backend rollout and its persistence migration are explicitly owned outside this task.
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# 研究:workspace authorization context 与 rebinding 边界
|
||||
|
||||
- 查询:审计 `OneTalkMindAuthorizationRequest`、内存 mock reader、Mind session authorization adapter、public HTTP/CORS、Mind WebSocket upgrade/session/registry fallback、发布/发送/rebuild fences 及现有测试夹具;使用 `rg`/带行号源码阅读/GitNexus `query`、`context`、`impact`。
|
||||
- 范围:internal
|
||||
- 日期:2026-09-14
|
||||
- 协调类别:cross-cutting
|
||||
- 阻塞:`auth-context-research` 依赖的 contract/mock、Mind adapter、public HTTP context/CORS、Mind WS ingress/session/fallback、回归测试实现 scope;这些 scope 需要先共享同一 workspace request contract 和 boundary parser 结论。插件授权、消息存储/去重和 internal summary 路径不被本研究阻塞。
|
||||
- 共享边界:`packages/onetalk-contract/src/authorization.ts` 的 Mind-page request contract;Center 内计划新增的 workspace context owner(`apps/server/src/`,当前不存在);`apps/server/src/mind-authorization.ts` 的固定 session endpoint header;`apps/server/src/http/onetalk/public-context.ts` 的 public HTTP/CORS context;`apps/server/src/websocket/index.ts` → `handler.ts` → `websocket/mind/*` 的 request context;`registry.ts`/`mind/publisher.ts`/`pending-send-coordinator.ts` 的 fallback authorization。
|
||||
- 证据基线:当前 checkout `/Users/ybf/work/trade-message-center`,`HEAD=854e9c46eb64ac3ddc4d81a8cc1039cfb2f4d9a0`,`dev...origin/dev`;相关生产/测试文件无 dirty path,只有任务目录未跟踪。GitNexus 当前索引为同名 repo 的旧快照(本 checkout 索引记录 `c9468f91d30e511ff40715f62a7ca9b79c751f06`,落后 11 commits;另有 `/Users/ybf/code/trade-message-center-worktree` 快照落后 1 commit),故图谱仅作调用关系补充,当前源码/测试为最终事实。
|
||||
- 复用证据与缺口:任务现有 `prd.md`、`design.md`、`implement.md` 已覆盖目标契约与 rollout;本报告刷新当前 source anchors。没有既有 `research/` 报告可复用。GitNexus 对匿名 type `OneTalkMindAuthorizationRequest` 未建节点,对许多箭头函数产生 Function/Const 双候选;下述静态搜索补足这些缺口。
|
||||
|
||||
## 发现
|
||||
|
||||
### 1. 当前 contract 与 mock reader
|
||||
|
||||
- `OneTalkMindAuthorizationRequest` 当前是 `packages/onetalk-contract/src/authorization.ts:86-92` 的 type,不是 interface。它的 `scope` 仅是 `Pick<OneTalkMindScope, "channelAccountId">`,`cookie?: string`;因此实现里虽然有些调用传入完整 `OneTalkMindScope`,类型契约仍只表达 account。Plugin request 在 `:78-84` 维持 `{ channelAccountId, deviceId } + binding`,不能把 workspace 字段或 header 扩展到 plugin contract。
|
||||
- `OneTalkAuthorizationReader` 在 `:123-130` 是唯一 reader facade;`readAuthorizationVersion` 仍按 `scope, binding?, cookie?` 传递,workspace 若要参与 Mind-page recheck,必须通过 Mind request scope 传入,而不能另建并行参数/缓存。
|
||||
- `MockAuthorizationRecord` 在 `:142-149` 同时保存 plugin `scope` 与完整 `mindScope`。`recordKey` `:158-160` 只按 `[channelAccountId,binding]` 建 key,符合当前 plugin binding 语义;不能把它改成页面 workspace header 的事实源。
|
||||
- `findRecord` `:177-195` 的 plugin 分支按 account/binding 匹配;Mind 分支 `:191-194` 只按 `record.mindScope.channelAccountId === request.scope.channelAccountId`,再优先 active。它目前会在同 account 的不同 workspace 间错误复用 active record;需要改成 account + workspace 精确匹配,且没有 workspace fallback。plugin 分支必须保持现状。
|
||||
- `decisionForRecord` `:213-235` 只在 plugin binding 上做额外核对;对 Mind request 需要显式检查 requested workspace 与 record/decision workspace 一致,否则 mock 会成为第二个不完整事实源。
|
||||
- `createMockAuthorizationReader` `:250-328` 由 package root `packages/onetalk-contract/src/index.ts:3-6` re-export;真实调用者来自 `apps/server/test/{websocket,onetalk-websocket,onetalk-http,onetalk-profile-websocket,onetalk-buyer-websocket}.test.ts` 与 `packages/onetalk-contract/test/contract.test.ts`,均通过 root import。`readAuthorizationVersion` `:268-293` 对 Mind 分支传完整 scope 对象,但当前类型只要求 account;扩展后应由该同一 request shape 承载 workspace。
|
||||
|
||||
### 2. Mind adapter 的真实外部调用点
|
||||
|
||||
- `apps/server/src/mind-authorization.ts:49-90` 的 `decisionFromResponse` 当前只校验返回 `mindScope.channelAccountId` 与 expected account,binding endpoint 另校验 binding;成功 response 的完整 `mindScope` 会被返回。实现 workspace rebinding 时,session expected 值必须同时带 channel account 与 workspace,并在这里把返回 workspace 不匹配映射为 `scope_mismatch`。
|
||||
- `requestAuthorization` `:92-131` 是唯一实际 `fetch` helper;固定 POST、`content-type: application/json`、`redirect: "error"`、`AbortSignal.timeout`、Cookie 单次内存转发均应保留。当前 headers `:110-115` 只有 content-type 与可选 lowercase `cookie`,没有 `Sinan-Pilot-Workspace-Id`。
|
||||
- `createMindAuthorizationReader` `:150-231` 只允许固定 binding/session URL (`:12-13`)。plugin 分支 `:158-182` 不应获得 workspace header;Mind session 分支 `:184-208` 才应把 request scope.workspaceId 原样加入精确 header,同时 body 仍只含 `{ channelAccountId }`。缺 Cookie 仍在 `:184-196` 先返回 `auth_required`,不可因 workspace 改动而发上游请求。
|
||||
- `readAuthorizationVersion` `:212-229` 重新构造 heartbeat request,是 WS/其他版本 fence 的间接入口;Mind scope 的 workspace 必须随该 request 进入 adapter。当前 `decisionFromResponse`/reader 捕获异常的 unavailable 行为、safe diagnostics(不含 Cookie/workspace)应保留。
|
||||
- 现有 adapter 测试 `apps/server/test/mind-authorization.test.ts:26-54,136-163,189-358` 已锁住 plugin body 无 Cookie、session Cookie/body、严格 status/JSON/transport failure;缺少 session workspace header、body 不增加 workspace、返回 workspace mismatch 和 plugin 不发该 header 的断言。
|
||||
|
||||
### 3. Public HTTP/CORS 链路
|
||||
|
||||
- `apps/server/src/http/onetalk/public-context.ts:17-18` 的 CORS allow-list 只有 `content-type`;`allowsCorsRequestHeaders` `:125-135` 按逗号拆分、lowercase、严格 allow-list 判断;`installPublicPreflight` `:142-165` 只返回 `access-control-allow-headers: content-type`。有效 Mind Origin/method 的预检在当前实现不会允许 workspace header。
|
||||
- `authorizeRead` `:74-104` 是 public read/rebuild 共享授权 owner:account 空值先 `403 scope_mismatch`,Cookie 从 `request.headers.cookie` 读取,调用 reader 的 request scope 仅 `{ channelAccountId }`,授权成功后只复核返回 account 与 permission。workspace boundary 应在调用它之前解析并注入 scope;无效/缺失/重复值必须在任何 reader/read service 前返回 `403 scope_mismatch`。
|
||||
- `createPublicReadContext` `:168-240` 保存每请求 cutover epoch,`mindOriginGuard` `:179-185` 先 admission、capture epoch、Origin 检查、CORS headers;其后 `authorizeRequestScope`/`authorizeRebuildScope` 都调用同一个 `authorizeRead`。这说明 workspace parser 应位于该 context 或其唯一 boundary owner,不应在 list/detail/messages/rebuild 四路由各自复制。
|
||||
- `apps/server/src/http/onetalk/public.ts:17-27` 组合 preflight、列表、详情、消息、rebuild;四个路由分别在 `conversation-list.ts:20-60`、`conversation.ts:21-53`、`messages.ts:17-50`、`rebuild.ts:22-49` 使用同一 `mindOriginGuard` 与 context authorization。列表/详情在 service await 后有 `requestIsAdmitted`,messages/rebuild 也有共享 read/rebuild fence;这些 post-await checks 不能被 workspace patch 删除。
|
||||
- `apps/server/src/http/onetalk/summary.ts:37-84` 是 internal 7777 summary route,使用 account-only scope、时间窗和 cutover admission,无 Cookie/CORS;任务明确排除它,不能把 public workspace header 扩展到此路径。
|
||||
- `apps/server/test/onetalk-http.test.ts:141-144` 的 `headers()` 只提供 Cookie + Origin,被大量 public route 测试复用;`627-697` 的预检测试目前断言只允许 `content-type` 并拒绝 retired summary headers;`232-265`、`267-337`、`339-393` 覆盖 list/detail/history 的 service scope/错误边界。加入强制 header 后,测试 fixture 必须统一提供一个有效 header,另补缺失、空白、重复/ambiguous header 在 reader/service 前零调用的探针。
|
||||
|
||||
### 4. Mind WebSocket upgrade、session capability 与 fallback
|
||||
|
||||
- `apps/server/src/websocket/index.ts:52-132` 的 `registerWebsocketRoutes` 在 `onRequest` 只根据 `request.url.startsWith` 判 route type、检查 cutover 与精确 Origin(`:66-99`);`endpointOptions` `:101-121` 只把 Cookie/Origin/origin allow-list 放入 `requestContext`。Mind query 未解析,`/ws/mind` 当前缺少 workspace 仍能进入 socket handler。
|
||||
- `installWebsocket` `:135-180` 构造 shared registry/router 并注册 `/ws/plugin`、`/ws/mind`;直接安装未注入 reader 时保持 `createUnavailableAuthorizationReader`,不可因新增 query 解析改变 plugin/default fail-closed 语义。
|
||||
- `apps/server/src/websocket/handler.ts:53-65` 的 `OneTalkWebSocketHandlerOptions.requestContext` 没有 workspace;通用 handler `:217-251` 只把 authorization/requestContext 交给 endpoint,`dispose` `:307-326` 清理 session capability、unregister。workspace 一旦通过 upgrade 接受,应放入同一 request context,并在 unregister/close 的代际边界保持不可复用。
|
||||
- `apps/server/src/websocket/mind/index.ts:35-173` 是 Mind endpoint owner。`onHello` `:93-169` 先创建 `OneTalkMindSessionAuthorization`(当前只传 reader + cookie,`:97-100`),检查 Mind Origin/shape,调用 connect authorization `:112-117`,再检查 hello frame scope 与 Mind 返回 scope 完整相等 `:130-134`,最后把 capability 写入 state/registry `:145-168`。query workspace 应在 upgrade 前验证并与 hello scope workspace 比较;有效 workspace 应绑定 capability,而不是只相信 hello。
|
||||
- heartbeat `:59-89` 优先走 `state.sessionAuthorization.authorize(frame.scope, "heartbeat")`,无 capability 时 fallback 到 `context.authorize`,并在授权 await 后检查 policy、`sessionAuthorizationFailure`、recordHeartbeat;这些既有顺序必须保持。hello/query mismatch 应稳定 `scope_mismatch`/1008,且不能调用 reader/Bright service。
|
||||
- `apps/server/src/websocket/mind/session-authorization.ts:11-43` 的 `OneTalkMindSessionAuthorization` 是不可序列化 session capability owner:当前私有状态只有 reader、Cookie、revoked;`authorize` `:25-39` 将 operation/scope/cookie 转给 reader,`clear` `:41-43` 清 Cookie 并禁止后续调用。它是最窄的 workspace 持有点:应捕获 pinned workspace,拒绝调用 scope.workspaceId 与 pinned 值不一致,并保持 clear 后 `auth_required`、不泄漏 Cookie 的行为。
|
||||
- `apps/server/src/websocket/registry.ts:167-224` 的 `publishRebuildStatus` 对每个 Mind connection 在 publish 前重新授权。若有 session capability,走 `authorize(connection.scope, "rebuild")`;否则 `:186-190` fallback 只传 `{ channelAccountId }`。手工注册/测试 connection 可能无 capability,这条 fallback 也必须带 workspace;授权后仍需复核 full scope、binding、version、permission、canonical connection、socket open、epoch/policy(`:191-203`),最终 `socket.send` 前不能插入 await。
|
||||
- `apps/server/src/websocket/mind/publisher.ts:101-221` 的 `publishToConnections` 覆盖 `message.created`、`sync.status`、`conversation.updated` 与 plugin status。它在 `:128-135` 对 Mind connection 优先 session capability,否则同样只传 account 的 fallback。授权失败会发送稳定 error、unregister/close(`:165-197`);最后一次授权后的 canonical/generation/open/epoch fence `:200-210` 必须保留。GitNexus 对该 symbol 的 upstream impact 为 `HIGH`:4 个 direct callers,3 个 publish execution flows(message/sync/conversation),所以改动前必须由主会话再次核对当前索引/源码并显式审查。
|
||||
- `apps/server/src/websocket/pending-send-coordinator.ts:123-287` 的 `requestSend` 在第一次 authorization await 前同步 reserve request ID;Mind authorization `:211-224` 优先 session capability,否则 fallback `:216-220` 只传 account。workspace 必须进入 capability/fallback,但必须保留双端 auth、canonical/generation/lease/epoch/open 的 final no-await fence `:239-257`,不可因为字段变更改变 `rejected_before_send`/`delivery_unknown` 映射。
|
||||
- confirmation path `:289-365` 只重新授权 plugin,故不应新增 Mind workspace header;它的 claim-before-await、commit guard、terminal/no-op 语义是回归基线。
|
||||
- `apps/server/src/websocket/connection-store.ts:19-43,80-101,142-159,166-243` 已将完整 `mindScope`(包含 workspace)存于 registered connection;plugin owner key `:80-82` 已按 mindUser/workspace/account 区分。该事实来源应继续由 connection/session owner 保存,不能在 publisher/pending/registry 各自复制 pinned workspace state。
|
||||
|
||||
### 5. Mock/harness 与现有 fixture 边界
|
||||
|
||||
- `apps/mind-test-harness/src/authorization/session.ts:13-21,38-71` 的 mock session request 当前只严格接受 body `{ channelAccountId }`,用 Cookie 做 Session 校验,随后按 account 与 fixture scope 返回 authorization;它不读取 workspace header。该 harness 是 Mind-side mock,不是 Center adapter,若本任务只改 Center,不能把 header 解析职责倒灌到 harness;但联调回归必须明确它尚未模拟 workspace header/mismatch。
|
||||
- `apps/mind-test-harness/src/authorization/binding.ts:13-25,38-70` 的 binding request 仅 account+binding,必须保持不带 Cookie/workspace;`src/response.ts:37-47` 返回完整 fixture `mindScope`。
|
||||
- `apps/mind-test-harness/src/config.ts:115-153` fixture 默认 workspace 为 `workspace-1`;`harness/reading.ts:50-65` 的 `readResponse` 支持传 headers 但当前 list/history 调用 `:161-163`、`:206-211` 未注入 workspace header;`harness/websocket.ts:150-154,189-221` 当前构造 `/ws/mind` URL 无 query。根据 PRD,这些是 Mind UI/rollout owner 的接口缺口,不应成为 Center 内部 fallback;真实浏览器联调仍未执行。
|
||||
- Server WS fixture `apps/server/test/websocket.test.ts:187-195` 与 `apps/server/test/onetalk-websocket.test.ts:319-325` 的 `openSocket` 当前所有 Mind path 都不带 query;`connectMindPage` helpers `:344-378`/`onetalk-websocket.test.ts:344-378` 的 hello 仍是 scope-only。测试改造需先统一 helper 的 valid workspace URL,再专门覆盖 missing/blank/repeated query 与 hello mismatch。
|
||||
|
||||
## 候选 Scope 与依赖
|
||||
|
||||
### Scope A:Center workspace contract + parser owner(cross-cutting,先行)
|
||||
|
||||
- 语义职责:定义精确 outgoing header 名 `Sinan-Pilot-Workspace-Id`、入站 canonical lowercase lookup、HTTP header/WS query 单值非空/重复检测和窄 workspace value;不处理 Cookie、Mind user、channel account、authorization response 或业务错误之外的流程。
|
||||
- 交付物/拥有路径:`packages/onetalk-contract/src/authorization.ts` 的 Mind scope request contract;`apps/server/src/` 下新的 Center-only context/model 模块(具体文件名由实现设计决定)。
|
||||
- 排除路径:plugin request/authorization、Mind DB、harness UI、internal summary、message facts。
|
||||
- 接口依赖:A 必须先于 B/C/D/E;所有 Mind HTTP/WS writer 都等待 parser 名称和值语义稳定。workspace 原样透传意味着 parser 应验证空白但不 trim 后重写 outgoing value;设计需明确是否允许内部检查 trim、但传输保持原字节。
|
||||
- 并行性:A 与任何修改依赖其类型/解析结果的 writer 不可并发;A 稳定后,B(adapter)与 C(HTTP)可在代码写入上并行,但两者都必须使用同一 parser/contract,不得各自定义字段名。
|
||||
|
||||
### Scope B:Mind session authorization adapter + mock reader(package/adapter-local,依赖 A)
|
||||
|
||||
- 语义职责:让 `OneTalkMindAuthorizationRequest.scope` 表达 account+workspace;mock 按双键匹配 Mind record;session adapter 原样发 header、body 保持 account-only、响应双 scope equality;plugin adapter byte-for-byte 保持现状。
|
||||
- 拥有路径:`packages/onetalk-contract/src/authorization.ts`;`apps/server/src/mind-authorization.ts`;相关 contract/adapter tests。
|
||||
- 排除路径:HTTP route parsing、WS query ingress、Mind backend/migration。
|
||||
- 实际共享写入:contract type 会触发所有手写 `OneTalkAuthorizationRequest` fixture/reader wrapper 的类型更新;必须搜索并更新 server tests,不能只改 production adapter。
|
||||
- 依赖/可并行:等待 A;B 完成后 C/D/E 才能验证 successful forwarding。B 内 mock 与 adapter 可独立实现,但共享 request shape,推荐先 contract type/mock,再 adapter。
|
||||
|
||||
### Scope C:Public HTTP reads + CORS(package-local,依赖 A+B)
|
||||
|
||||
- 语义职责:在 `createPublicReadContext` 统一解析 workspace,缺失/空白/重复在 reader/read service 前返回 `403 scope_mismatch`;将 workspace 传给 reader;只把精确 header 加入 public read preflight;保留 list/detail/messages/rebuild 的 epoch/post-await/error semantics。
|
||||
- 拥有路径:`apps/server/src/http/onetalk/public-context.ts`、`public.ts`、四个 public route 文件与 `apps/server/test/onetalk-http.test.ts`。
|
||||
- 排除路径:`summary.ts` internal route、OSS harness upload CORS、Mind UI header injection。
|
||||
- 依赖:B 的 reader request type;C 不能直接修改 adapter response logic。A+B 稳定后可与 D 并行(write-disjoint),但 C 的 test fixture 不应和 D 的 WS helpers 共享可变 state。
|
||||
|
||||
### Scope D:Mind WebSocket ingress + pinned session capability(package-local,依赖 A+B)
|
||||
|
||||
- 语义职责:升级前严格接受 exactly-one nonblank `workspaceId` query;把 pinned value 放 request context;hello/query/full-Mind-scope equality;`OneTalkMindSessionAuthorization` 捕获 workspace 并贯穿 hello/heartbeat/send/rebuild/publish authorization;保留 Origin、Cookie、FIFO、close and policy fences。
|
||||
- 拥有路径:`apps/server/src/websocket/index.ts`、`handler.ts` request context type、`websocket/mind/index.ts`、`mind/session-authorization.ts` 与 WS upgrade/hello tests。
|
||||
- 排除路径:plugin query/header、Mind UI reconnect/epoch clearing、protocol wire scope schema。
|
||||
- 依赖:B 的 request shape;D 先于 E 的 fallback/publish writer,因为 E 需知道 connection capability/pinned workspace 如何表达。D 与 C 可并行,均是 B-ready/write-disjoint;两者不具备 shared-write-free 的测试 fixture(各自改 helpers),测试文件冲突需主会话协调。
|
||||
|
||||
### Scope E:Registry/publisher/pending fallback fences(cross-cutting downstream,依赖 A+B+D)
|
||||
|
||||
- 语义职责:补齐无 session capability 的 manually registered/test Mind connection fallback;每个 fallback request 带 account+workspace;保留 reauth-before-side-effect、canonical/generation/epoch/socket fences;确保 revoked/mismatch 后 no publish/no send/no rebuild/read。
|
||||
- 拥有路径:`apps/server/src/websocket/registry.ts`、`mind/publisher.ts`、`pending-send-coordinator.ts`,必要时 `history-rebuild-coordinator.ts` 仅确认 plugin branch 不受影响;对应 `onetalk-websocket.test.ts`、`onetalk-history-rebuild-coordinator.test.ts`。
|
||||
- 排除路径:plugin authorization semantics、confirmation plugin-only reauth、message repository/DB schema。
|
||||
- 依赖:必须等待 D 对 capability pinned workspace 的最终形状;否则容易添加第二个 fallback truth。E 可在 D 完成后与 C 的测试补充并行,但必须单独处理 GitNexus 标记的 `publishToConnections` HIGH blast radius。
|
||||
|
||||
### Scope F:回归矩阵(跨层验证,依赖 A-E)
|
||||
|
||||
- 语义职责:证明 HTTP header/query exact forwarding、invalid ingress zero-call、Mind response mismatch, old connection post-invalidation fences;保持 existing client-frame wire matrix。
|
||||
- 拥有路径:`packages/onetalk-contract/test/contract.test.ts`、`apps/server/test/mind-authorization.test.ts`、`apps/server/test/onetalk-http.test.ts`、`apps/server/test/websocket.test.ts`、`apps/server/test/onetalk-websocket.test.ts`,必要时 history rebuild focused test。
|
||||
- 排除路径:harness/real Mind rollout acceptance;后者可作为 runtime gap 记录,不应在 Center 测试中伪造成功。
|
||||
- 可并行:各 focused test file 在代码 contract 完成后基本 write-disjoint;不得在 contract type 未稳定时并行修改几十个 fixture,避免重复修复和第二套测试 helper。
|
||||
|
||||
## Invariant 与验收探针
|
||||
|
||||
### 结构性 invariant / owner / facts source
|
||||
|
||||
1. 一个 Mind-page authorization decision 只有在 `requested workspace == Mind returned mindScope.workspaceId == response/registered WS state.workspaceId` 时有效;`mindScope.channelAccountId` 同理。workspace 不是身份,不从 user/header/account 推导。唯一 owner 分层:boundary context owner 负责取得/校验值,Mind adapter 负责 returned-scope equality,WS session/connection owner 负责 pinned lifetime;publisher/registry/pending 只消费窄 capability/connection,不复制 parser/decision。
|
||||
2. HTTP fact source 是每次请求精确一个 `Sinan-Pilot-Workspace-Id` header;WS fact source 是 upgrade URL 精确一个 case-sensitive `workspaceId` query。缺失、空白、重复/ambiguity 均 fail closed;不得默认 workspace、复用旧 decision、从 hello-only scope fallback。
|
||||
3. Mind adapter 是唯一 Center 对外授权 HTTP writer。session only 发 `Cookie` + `Sinan-Pilot-Workspace-Id` + JSON body `{ channelAccountId }`;plugin binding 不得发 Cookie/workspace header。Mind 返回值是 scope/permission/version 的事实源,Center 不连 Mind DB。
|
||||
4. 任何旧 WS cached successful decision 不能跨过下一次 authorization fence:heartbeat、send、rebuild、publish(以及 session capability 的 read)都必须 reauthorize;denied/mismatch/unavailable 后不得继续 read/send/rebuild/status/message publish。HTTP 每次 request 仍授权一次,且 read service await 后复核 epoch/admission。
|
||||
|
||||
### Snapshot、await、mutation、副作用和 rollback 映射
|
||||
|
||||
| 边界 | 当前 anchor | 需要保持/新增的控制点 | 不可逆副作用与失败语义 |
|
||||
| --- | --- | --- | --- |
|
||||
| HTTP ingress | `mindOriginGuard` captured epoch + request header | parser 在 `authorizeRead` 前;invalid 不触 reader/service | 无读取/授权请求;稳定 `403 scope_mismatch` |
|
||||
| HTTP adapter | `authorizeRead` account/Cookie + adapter fetch | request scope 带 workspace;response account/workspace equality | Mind call 是外部副作用,但失败映射现有 auth codes/unavailable;不缓存 |
|
||||
| HTTP read | list/detail/messages `await readService` | `requestIsAdmitted` after await;返回 scope 是 Mind returned scope | 失效后不返回数据,`503 authorization_unavailable`;不把 partial result 发出 |
|
||||
| HTTP rebuild | `authorizeRebuildScope` → registry await | authorization/rebuild permission + request admission before/after | reset 是 commit boundary;post-commit resync 失败不回滚 committed reset,但不发失效 status |
|
||||
| WS upgrade | `onRequest` Origin/cutover | parse query before endpoint/socket session; exactly-one workspace | invalid query HTTP 403;不创建 authenticated session/registry entry |
|
||||
| WS hello | `onHello` connect authorization await | query workspace == hello scope == returned scope;policy check after await | accepted frame/registry register only after all checks;mismatch 1008/`scope_mismatch` |
|
||||
| WS capability | session auth `authorize` | capture Cookie + pinned workspace; `clear` invalidates both | clear/unregister/pause 后 future call `auth_required`;Cookie 不出诊断/frames |
|
||||
| WS heartbeat/send/rebuild/publish | each capability/fallback authorization await | post-await policy/canonical/generation/open/full-scope fence; no await before send | no heartbeat ACK/send.command/status/message.created/rebuild reset when fence fails |
|
||||
| send confirmation | pending claim before authorization, guarded process | plugin-only reauth remains plugin-only; no workspace addition | DB commit → ACK/publish ordering and delivery_unknown semantics unchanged |
|
||||
|
||||
### Static/unit probes(本研究未运行 runtime probe)
|
||||
|
||||
- `rg -n "scope: \{ channelAccountId:|connectionType: \"mind_page\"|authorize\(connection\.scope|authorize\(connection\.mindScope" apps/server/src packages/onetalk-contract/src`:实现后预期所有 Mind-page authorization request(含 publisher/registry/pending fallback)都能看见 workspace-bearing scope;plugin branch 仍 account/device/binding-only。
|
||||
- `rg -n "Sinan-Pilot-Workspace-Id|workspaceId" apps/server/src/mind-authorization.ts apps/server/src/http/onetalk/public-context.ts apps/server/src/websocket`:header 名只由 context/adapter owner 使用;public CORS allow-list 和 WS query parser 位置明确,无第二个常量。
|
||||
- `rg -n "authorize-session|headers:|body:" apps/server/src/mind-authorization.ts` + adapter tests:session outgoing header exact、body 精确等于 `{ channelAccountId }`、plugin request header 无 Cookie/workspace。
|
||||
- `rg -n "MIND_SESSION_AUTHORIZATION_PATH|MIND_BINDING_AUTHORIZATION_PATH|scope.*channelAccountId" packages/onetalk-contract apps/server apps/mind-test-harness`:固定 endpoint、contract shape、harness body contract 无意外漂移。
|
||||
- Contract/unit matrix:mock records same account/different workspace;requested workspace A only matches A;missing/blank/unknown returns `scope_mismatch`; plugin same account/different device remains allowed when binding matches; revoked/unavailable/permission errors retain old codes。
|
||||
- HTTP unit matrix:valid single header → one reader call with unchanged workspace; missing/empty/duplicate → `403` and zero reader/read-service/registry calls; OPTIONS with exact header → `204` and allow-headers includes exact header; retired identity headers remain irrelevant; internal summary unchanged.
|
||||
- WS unit/wire matrix via `injectWS`:missing/blank/repeated query rejected before socket endpoint; valid query + hello same workspace accepts; valid query + different hello closes 1008 with `scope_mismatch`; captured outgoing Mind auth requests all preserve workspace across connect/heartbeat/send/rebuild/publish; manually registered no-capability fallback also includes workspace.
|
||||
- Fence/latch matrix:pause/revoke/rebind during each authorization `await` yields no subsequent `socket.send`, reset, ACK, publish or read response; ensure `publishToConnections` HIGH-risk flows each assert no `message.created`, `sync.status`, `conversation.updated` after mismatch.
|
||||
- `git diff --check`, focused contract/server tests, `pnpm format:check`, `pnpm typecheck`, `pnpm build`, `pnpm test` are implement/check gates; use <=60s timeout for each backend unit invocation as project instructions require.
|
||||
|
||||
### Runtime probes(未执行;需要独立端口/真实或本地 Mind/mock/浏览器状态)
|
||||
|
||||
- Start isolated Mind mock + Center, make credentialed browser CORS OPTIONS/GET with one workspace header; expected 204 allow exact header and read request observed by Mind with exact header/body/Cookie. Do not treat process startup alone as evidence.
|
||||
- Open real `/ws/mind?workspaceId=A` with Mind Origin/Cookie, observe connect/heartbeat/send/rebuild/publish authorization request headers; switch mock to workspace B/revoke old scope and assert next fence closes/blocks old socket. Use isolated ports and no production credentials.
|
||||
- Run harness browser flow only after its own UI has been updated to send HTTP header and WS query; current harness source does not do so (`harness/reading.ts:50-52`, `harness/websocket.ts:150-154`), so a current run would be an expected integration failure, not a Center regression proof.
|
||||
- Real Mind backend Session/member/binding checks, persistence rebinding transaction, revocation push absence, and browser callback epoch behavior remain external/unverified boundaries explicitly outside this scope.
|
||||
|
||||
## 影响与 GitNexus upstream impact 结果
|
||||
|
||||
以下是对拟修改 symbol 的当前索引 upstream 检查;因索引落后,已用当前源码 `rg`/行号复核 direct callers:
|
||||
|
||||
- `createMockAuthorizationReader` (`packages/onetalk-contract/src/authorization.ts:250`):GitNexus `LOW`, direct `0`(type/function dual representation and package-root import edges undercount); current search shows five server fixture files plus contract tests.
|
||||
- `createMindAuthorizationReader` (`apps/server/src/mind-authorization.ts:150`):`LOW`, direct `2` (`apps/server/test/mind-authorization.test.ts`, `apps/server/test/websocket.test.ts`); `app.ts` imports it through `authorizationFor` as an additional source-level caller.
|
||||
- `authorizeRead` (`apps/server/src/http/onetalk/public-context.ts:74`):`LOW`, direct `2` (`authorizeRequestScope`, `authorizeRebuildScope`); both are exposed through one public context used by four public routes.
|
||||
- `createPublicReadContext` (`apps/server/src/http/onetalk/public-context.ts:168`):`LOW`, direct `1` (`installOneTalkReadRoutes`), one `Onetalk` process and eight transitive test/app symbols.
|
||||
- `installPublicPreflight` (`apps/server/src/http/onetalk/public-context.ts:142`):`LOW`, direct `1` (`installOneTalkReadRoutes`); its returned allow-list controls all public read/rebuild OPTIONS routes.
|
||||
- `registerWebsocketRoutes` (`apps/server/src/websocket/index.ts:52`):`LOW`, direct `1` (`installWebsocket`), with `createOneTalkMindWebSocketHandler` and plugin handler downstream.
|
||||
- `OneTalkMindSessionAuthorization` (`apps/server/src/websocket/mind/session-authorization.ts:11`):`LOW`, direct `2` (`onHello` and `mind/index.ts` import), but transitive app/index/test callers; current index confirms direct `onHello` construction.
|
||||
- `publishToConnections` (`apps/server/src/websocket/mind/publisher.ts:101`):`HIGH`, direct `4` (`publishPluginStatus`, `publishMessageCreated`, `publishSyncStatus`, `publishConversationUpdated`), 3 affected processes and Mind module. Main session must warn/review before editing this symbol.
|
||||
|
||||
GitNexus additionally reports `createOneTalkConnectionRegistry` (`registry.ts:99`) `LOW`, direct test callers 3, and `authorizePlugin` (`history-rebuild-coordinator.ts:97`) `LOW`, direct request/handleCompletion 2. The latter is plugin-only and should remain unchanged except compile-time request-shape fallout.
|
||||
|
||||
## 找到的文件
|
||||
|
||||
- `packages/onetalk-contract/src/authorization.ts` — OneTalk authorization request/decision types, mock record lookup and reader implementations。
|
||||
- `packages/onetalk-contract/src/index.ts` — contract package root re-exports authorization API consumed by server/tests。
|
||||
- `apps/server/src/mind-authorization.ts` — fixed Mind binding/session HTTP adapter, response decoder integration, Cookie/header construction and diagnostics。
|
||||
- `apps/server/src/http/onetalk/public-context.ts` — public read/rebuild authorization, CORS preflight and cutover request context。
|
||||
- `apps/server/src/http/onetalk/public.ts` — public route composition。
|
||||
- `apps/server/src/http/onetalk/conversation-list.ts` — public list route using shared context。
|
||||
- `apps/server/src/http/onetalk/conversation.ts` — public detail route using shared context。
|
||||
- `apps/server/src/http/onetalk/messages.ts` — public history route using shared context。
|
||||
- `apps/server/src/http/onetalk/rebuild.ts` — public destructive rebuild route using rebuild authorization/context。
|
||||
- `apps/server/src/http/onetalk/summary.ts` — internal account-only summary route; explicitly out of scope。
|
||||
- `apps/server/src/websocket/index.ts` — WS Origin/cutover upgrade hook, requestContext assembly and endpoint registration。
|
||||
- `apps/server/src/websocket/handler.ts` — shared handler requestContext/state/dispose and policy/session fences。
|
||||
- `apps/server/src/websocket/mind/index.ts` — Mind hello/heartbeat/send endpoint and session capability construction。
|
||||
- `apps/server/src/websocket/mind/session-authorization.ts` — per-socket Cookie/session capability。
|
||||
- `apps/server/src/websocket/mind/publisher.ts` — Mind event reauthorization, full-scope/generation fence and socket publish。
|
||||
- `apps/server/src/websocket/registry.ts` — canonical connection registry, rebuild-status fallback authorization and commit guards。
|
||||
- `apps/server/src/websocket/pending-send-coordinator.ts` — pending send state, Mind/plugin authorization, final dispatch fence and confirmation path。
|
||||
- `apps/server/src/websocket/connection-store.ts` — registered connection full `mindScope`, canonical/generation/presence state and commit guard owner。
|
||||
- `apps/server/src/websocket/history-rebuild-coordinator.ts` — plugin-side rebuild authorization/reset flow; current workspace use is source context, not Mind-page adapter。
|
||||
- `apps/server/test/mind-authorization.test.ts` — adapter request/response/status/Cookie/diagnostic fixtures。
|
||||
- `apps/server/test/onetalk-http.test.ts` — public read/rebuild/CORS/internal summary fixtures and request helper。
|
||||
- `apps/server/test/websocket.test.ts` — WS handshake, Origin, Cookie, fallback/default authorization fixtures and `openSocket` helper。
|
||||
- `apps/server/test/onetalk-websocket.test.ts` — wire matrix, publisher/send/rebuild fence fixtures and `openSocket`/Mind helpers。
|
||||
- `apps/server/test/onetalk-history-rebuild-coordinator.test.ts` — rebuild clear/reset/post-commit and revoked Mind status fixture。
|
||||
- `apps/server/test/app.test.ts` — app/route registration and safe Mind adapter diagnostics fixture。
|
||||
- `apps/server/test/onetalk-profile-websocket.test.ts` — plugin profile authorization and connection fixtures; preserve plugin behavior。
|
||||
- `apps/server/test/onetalk-buyer-websocket.test.ts` — plugin buyer-fact authorization and connection fixtures; preserve plugin behavior。
|
||||
- `packages/onetalk-contract/test/contract.test.ts` — canonical decoder/mock authorization/revoke/permission contract tests。
|
||||
- `apps/mind-test-harness/src/authorization/session.ts` — local Mind mock session body/Cookie contract; does not yet model workspace header。
|
||||
- `apps/mind-test-harness/src/authorization/binding.ts` — local Mind mock binding body contract; must remain account+binding only。
|
||||
- `apps/mind-test-harness/src/config.ts` — local fixture defaults including `workspace-1`。
|
||||
- `apps/mind-test-harness/src/harness/reading.ts` — current browser read fetches with credentials but no workspace header。
|
||||
- `apps/mind-test-harness/src/harness/websocket.ts` — current browser Mind WS URL has no `workspaceId` query。
|
||||
|
||||
## 代码模式
|
||||
|
||||
- `apps/server/src/http/onetalk/public-context.ts:179-185` — capture cutover epoch before external authorization/read; Origin/CORS is boundary-only。
|
||||
- `apps/server/src/http/onetalk/public-context.ts:74-104` — shared authorization result maps stable status/code, then checks returned account and required permission。
|
||||
- `apps/server/src/http/onetalk/read.ts:97-120` — after read await, re-check admission before exposing data; database/unknown errors map to stable codes。
|
||||
- `apps/server/src/mind-authorization.ts:92-131` — one fixed fetch helper, timeout/redirect policy, no raw error payload/diagnostic secret。
|
||||
- `apps/server/src/mind-authorization.ts:184-208` — session Cookie required before fixed session endpoint call; plugin branch must stay separate。
|
||||
- `apps/server/src/websocket/mind/index.ts:112-168` — hello auth → policy fence → full returned scope comparison → state/registry registration → accepted frame。
|
||||
- `apps/server/src/websocket/mind/session-authorization.ts:25-43` — capability captures opaque Cookie, reauths per operation, `clear()` revokes and removes Cookie。
|
||||
- `apps/server/src/websocket/mind/publisher.ts:126-210` — auth await, returned binding/version/full-scope/permission comparison, canonical/generation/open/epoch fence, immediate socket send。
|
||||
- `apps/server/src/websocket/pending-send-coordinator.ts:172-205,211-285` — reserve before first await; no-await final fence before wire send; terminal cleanup owns pending maps。
|
||||
- `apps/server/src/websocket/registry.ts:174-224` — rebuild status reauth per candidate and skips stale/mismatched connections without changing committed reset semantics。
|
||||
- `apps/server/src/websocket/connection-store.ts:142-159,166-243` — canonical connection/generation/epoch commit guard and plugin owner key including workspace。
|
||||
- `apps/server/test/onetalk-websocket.test.ts:42-56,422-560` — client frame matrix derived from `ONETALK_CLIENT_FRAME_TYPES`; wire assertions, not private handler calls。
|
||||
|
||||
## 已验证事实、假设与外部/未验证边界
|
||||
|
||||
### 已验证事实
|
||||
|
||||
- 当前 checkout/HEAD/dirty state 如证据基线所述。
|
||||
- Center 当前 Mind request contract 只表达 account;adapter session header 只转发 Cookie;public preflight 只允许 content-type;WS upgrade/session request context 不含 query workspace。
|
||||
- Mind connection state/registry 已保存完整 returned `mindScope`,但 fallback authorization 在 publisher/registry/pending 中把它缩成 account-only request。
|
||||
- HTTP 路由和 WS 关键副作用已经有 post-await policy/canonical/generation/permission fences;插件 binding/device/owner key 行为已有测试覆盖。
|
||||
- GitNexus upstream impact 对拟改高层 symbol 已执行;最高风险为 `publishToConnections=HIGH`,其余已列 symbol 返回 LOW/类型节点未找到。
|
||||
|
||||
### 假设/实现前待确认
|
||||
|
||||
- 新 workspace context owner 的具体文件名和导出 facade 未在当前树中存在;由实现设计决定,但必须只有一个 parser/header constant owner。
|
||||
- “重复 header/query”需要在 Fastify/Node request 表示层验证为 ambiguous;不能依赖框架是否把多值合并成逗号字符串而误放行。实现应针对实际 `string|string[]` 与 raw URL 行为补 unit probe。
|
||||
- parser 可以内部使用 `trim()` 判断 blank,但设计目标要求向 Mind 转发接收值原样;是否允许周围空格作为非空值必须由实现/测试锁定,不能隐式 trim 后转发。
|
||||
- `OneTalkMindSessionCapability.authorize` 扩展 workspace 的最终签名尚未实现;建议 capability 捕获 pinned workspace,并继续要求每次调用传完整 Mind scope 以支持 frame scope equality。
|
||||
|
||||
### 外部/未验证边界
|
||||
|
||||
- 真实 Mind Session 是否会读取该 header、如何校验用户/member/binding、换绑事务/唯一约束、远端 revocation push 均未从外部系统验证,且在 PRD 中排除。
|
||||
- 当前没有启动 Center/Mind/mock、没有使用 ports/database/container/测试账号、没有进行浏览器 CORS/WS smoke;上述 runtime probes 仅是可执行计划,不是运行时证明。
|
||||
- `apps/mind-test-harness` 当前不会发送 header/query;Center contract rollout 需要 Mind UI/harness 先后或同步部署,不能在 Center 中加入 fallback。
|
||||
|
||||
## 外部参考(文档、版本)
|
||||
|
||||
- 未进行互联网/外部文档检索;本报告依据当前 checkout 与仓库规范。
|
||||
- 相关 repo specs:`.trellis/spec/server/backend/mind-authorization.md`(Mind fixed endpoints/Cookie/CORS/WS/fail-closed)、`.trellis/spec/server/backend/error-handling.md`(stable HTTP/WS errors)、`.trellis/spec/server/backend/service-foundation.md`(Fastify/WS lifecycle)、`.trellis/spec/project/async-state-boundaries.md`(await 后重新确认与副作用顺序)、`.trellis/spec/project/module-ownership.md` 与 `module-organization.md`(唯一 contract/parser owner)、`.trellis/spec/guides/cross-layer-thinking-guide.md`。
|
||||
- 工具链事实:Node `v22.22.2`、pnpm `11.7.0`、workspace package version `0.8.27`;不得把这些本地版本信息当作真实 Mind 生产版本证明。
|
||||
|
||||
## 相关 spec
|
||||
|
||||
- `.trellis/spec/server/backend/index.md`
|
||||
- `.trellis/spec/server/backend/mind-authorization.md`
|
||||
- `.trellis/spec/server/backend/error-handling.md`
|
||||
- `.trellis/spec/server/backend/service-foundation.md`
|
||||
- `.trellis/spec/project/architecture.md`
|
||||
- `.trellis/spec/project/async-state-boundaries.md`
|
||||
- `.trellis/spec/project/module-ownership.md`
|
||||
- `.trellis/spec/project/module-organization.md`
|
||||
- `.trellis/spec/guides/cross-layer-thinking-guide.md`
|
||||
|
||||
## 注意事项 / 未找到内容
|
||||
|
||||
- 未找到现成 Center workspace context/parser 模块;不能让四个 public route、WS upgrade、adapter 各自复制 header/query 解析。
|
||||
- 未找到 `OneTalkMindAuthorizationRequest` 的 GitNexus 节点;这是索引能力缺口,不是源码中缺少定义。
|
||||
- 未找到现有 session authorization workspace header/query 回归;当前 tests 证明的是 Cookie/account-only 行为,需要显式扩展。
|
||||
- 未找到真实 Mind/浏览器运行时证据;不要把本地 mock 或启动成功描述为跨空间授权已验证。
|
||||
- 高风险提醒:修改 `publishToConnections` 前主会话必须确认 GitNexus HIGH blast radius,并检查 message/sync/conversation publish 三条 execution flows;任何失败都必须保持 no publish/no stale socket。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "workspace-authorization-rebinding",
|
||||
"name": "workspace-authorization-rebinding",
|
||||
"title": "空间授权边界与换绑失效",
|
||||
"description": "统一 Center 与 Mind 的空间授权、换绑和旧会话失效边界",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-14",
|
||||
"completedAt": "2026-09-14",
|
||||
"branch": "dev",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"检查 schema 打开路径和业务 store 职责是否保持单一所有者。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"检查 v10 升级不破坏 durable-first、ACK 与前向 schema 语义。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查扩展测试、严格类型、构建与格式化门禁。"}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 统一 buyer facts IndexedDB 设计
|
||||
|
||||
## 决策
|
||||
|
||||
`onetalk_buyer_facts` 进入 `trade-message-center` 的 OneTalk 统一 schema。
|
||||
`storage.ts` 是数据库名称、schema version 与 `onupgradeneeded` 的唯一 owner;
|
||||
buyer facts 的领域读写逻辑继续留在 `buyer-fact-store.ts`。
|
||||
|
||||
不迁移历史数据,也不把旧库清理写入扩展运行时。用户确认扩展尚未上线,因此旧的
|
||||
`trade-message-center-onetalk-buyer-facts` 仅在当前开发浏览器 profile 中直接
|
||||
删除。这样不会为不存在的生产兼容需求引入第二套状态、启动 I/O 或失败分支。
|
||||
|
||||
## Schema 与调用边界
|
||||
|
||||
1. `storage.ts` 将主库 version 从 9 升至 10,并在统一 store registry 加入
|
||||
`onetalk_buyer_facts`。
|
||||
2. 主库打开 helper 由 `storage.ts` 导出给 buyer facts store 使用;所有调用者通过
|
||||
同一数据库名、version 和 upgrade handler 打开数据库。
|
||||
3. `buyer-fact-store.ts` 删除私有的数据库名、version 与 upgrade handler,只保留
|
||||
以 `onetalk_buyer_facts` 为对象存储的 pending、ACK 和 high-water 写入语义。
|
||||
4. `configured-sync-session.ts` 的 coordinator 装配和 Bright 协议不变;它继续只
|
||||
依赖 `OneTalkBuyerFactStore` 接口。
|
||||
|
||||
这避免了“仅把数据库名改为主库”造成的 version 竞争:如果 buyer facts 自行请求
|
||||
不同 version 或自行 upgrade,旧 Service Worker 打开主库时可能抛出
|
||||
`VersionError`,并且无法保证既有 stores/indexes 同步创建。
|
||||
|
||||
## 删除与回滚
|
||||
|
||||
- 代码切换后,用浏览器 IndexedDB 工具直接删除旧独立库;删除前不读取、不迁移其
|
||||
records,符合未上线且无数据保留要求。
|
||||
- 清理后重新加载扩展并触发 buyer facts 写入,验证旧库不会被重建。
|
||||
- v10 一旦打开,v9 或更旧源码无法安全打开同一主库;本任务不允许通过恢复旧独立库名
|
||||
回滚。出现问题时,只能发布后续兼容 v10 schema 的前向修复;本任务不提供数据回滚,
|
||||
因为用户明确放弃了旧开发数据,也不得将浏览器数据库降级到 v9。
|
||||
|
||||
## 验证设计
|
||||
|
||||
- buyer facts focused test 使用记录 `open(name, version)` 的 fake,断言主库名和
|
||||
主 schema version,并覆盖 buyer store 随主库 upgrade 创建。
|
||||
- storage focused test 从 v9 升至 v10,断言既有 stores/records/indexes 保留且新增
|
||||
buyer store。
|
||||
- 现有 buyer pending/ACK/contact-details regressions 保持通过,证明持久化位置改变
|
||||
未改变领域状态机。
|
||||
- 实现后运行 focused tests、typecheck、format check、build 与相关 Chrome runtime
|
||||
smoke;最后直接查看当前 profile 的 IndexedDB 名称。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/project/architecture.md","reason":"统一 IndexedDB schema owner、模块职责与状态唯一所有者约束。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"OneTalk durable ledger 的 v9 升级行为、前向 schema 与 ACK 不变量。"}
|
||||
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"扩展包类型检查、构建、测试与格式化验证要求。"}
|
||||
@@ -0,0 +1,32 @@
|
||||
# 实施计划
|
||||
|
||||
## 预检
|
||||
|
||||
1. 读取 `trellis-before-dev` 与本任务相关的扩展规范。
|
||||
2. 对将被修改的 `openSyncDatabase`(或其最终共享 helper)和
|
||||
`createOneTalkBuyerFactStore` 执行 GitNexus upstream impact analysis;若风险为
|
||||
HIGH/CRITICAL,先向用户报告再编辑。
|
||||
3. 确认工作树只含本任务规划文件变更。
|
||||
|
||||
## 实现
|
||||
|
||||
1. 先扩展 buyer facts store 的 fake/test,令其断言实际主数据库名与 version,且
|
||||
能运行统一 upgrade handler。
|
||||
2. 在 `storage.ts` 集中加入 buyer object store、升级 schema 到 v10,并提供唯一的
|
||||
主库打开路径;新增 v9→v10 保留既有数据的回归测试。
|
||||
3. 让 `buyer-fact-store.ts` 复用该打开路径和 store 常量,删除私有数据库配置与
|
||||
upgrade handler;保留所有事实合并、pending 和 ACK 行为。
|
||||
4. 更新 `durable-sync.md`,将 v10 store/升级不变量记为当前基线。
|
||||
5. 在已更新的开发扩展中直接删除错误独立库;重新加载后进行 browser runtime
|
||||
smoke,确认新的 buyer write 只创建/使用主库。
|
||||
|
||||
## 验证与评审
|
||||
|
||||
1. 运行 buyer facts store、sync storage 与 Service Worker storage focused tests。
|
||||
2. 运行 `pnpm format:check`、`pnpm typecheck`、`pnpm build` 与适用的扩展测试。
|
||||
3. 运行 GitNexus `detect_changes()`,确认范围只包括 OneTalk storage、buyer store、
|
||||
对应测试及 durable-sync 规范。
|
||||
4. 检查 diff:不存在旧数据库名、迁移/兼容分支、无关 schema 删除或 buyer 状态机
|
||||
行为改变。
|
||||
5. 通过 Trellis check 后提交一个与本任务对应的 commit;不推送或创建 PR,除非用户
|
||||
后续明确授权。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 统一 OneTalk buyer facts IndexedDB
|
||||
|
||||
## Goal
|
||||
|
||||
将 buyer facts 账本纳入统一 OneTalk IndexedDB schema,避免再创建错误的独立
|
||||
数据库;由于该扩展尚未上线,直接删除当前开发环境中的旧库,不保留迁移兼容层。
|
||||
|
||||
## Background
|
||||
|
||||
- 当前 `apps/chrome-extension/src/onetalk/service-worker/buyer-fact-store.ts:12-14`
|
||||
将 `onetalk_buyer_facts` 打开在独立数据库
|
||||
`trade-message-center-onetalk-buyer-facts`(版本 1);这是截图中多出的
|
||||
IndexedDB。
|
||||
- 统一 OneTalk 数据库是
|
||||
`apps/chrome-extension/src/onetalk/service-worker/storage.ts:18-26` 的
|
||||
`trade-message-center`(当前 schema 版本 9)。消息、同步、联系人资料与
|
||||
rendered-card 账本均在该库中。
|
||||
- 联系人资料账本虽在业务语义上独立,仍通过
|
||||
`storage.ts:1189-1201` 的统一数据库打开路径使用主库。这说明 buyer facts
|
||||
的独立数据库不是“独立 ledger”的必要条件。
|
||||
- buyer facts 的初始实现(commit `24e0228`)即使用独立库;测试 fake 的
|
||||
`open(_name, version)` 忽略数据库名称,故此前未覆盖该错误。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1:`onetalk_buyer_facts` 必须属于统一 `trade-message-center` IndexedDB
|
||||
schema,由一个版本与升级入口管理;不得再创建
|
||||
`trade-message-center-onetalk-buyer-facts`。
|
||||
- R2:不实现旧独立库的数据迁移、兼容读取或运行时清理分支。旧库仅作为当前
|
||||
未上线开发环境的本地状态直接删除。
|
||||
- R3:合库后 buyer facts 的 key、同账号隔离、pending/ACK 语义,以及已确认
|
||||
字段不被后续 pending 快照清除的现有行为保持不变。
|
||||
- R4:在源码不再引用旧库后,直接删除当前浏览器 profile 中的
|
||||
`trade-message-center-onetalk-buyer-facts`;该删除已获用户授权。
|
||||
- R5:测试必须断言实际打开的数据库名和升级路径,而非只使用忽略库名的 fake。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 新安装或当前开发扩展升级后,DevTools 只将 buyer facts 写入
|
||||
`trade-message-center` 的 `onetalk_buyer_facts` object store。
|
||||
- [ ] 不存在旧库迁移、兼容读取或自动清理的生产代码;当前开发 profile 的旧
|
||||
独立库被直接删除。
|
||||
- [ ] 升级主库不会遗漏现有 sync/profile/rendered-card stores 和 indexes,也不
|
||||
触发版本冲突。
|
||||
- [ ] 清理旧库后重新加载扩展并触发 buyer facts 写入,不会重新创建旧库。
|
||||
- [ ] buyer fact store 的 focused tests、扩展 typecheck 与相关存储测试通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不改变 buyer facts 的 DOM 观察、Bright wire contract、服务端持久化或授权。
|
||||
- 不改变其他 OneTalk ledger 的数据模型、清理策略或索引,除非统一 schema
|
||||
升级必需。
|
||||
- 不清除浏览器中与此次错误库无关的 IndexedDB 数据。
|
||||
|
||||
## Notes
|
||||
|
||||
- 用户已确认“直接删除,我没有发上线”。本任务仍涉及统一 IndexedDB schema
|
||||
version 与 durable ledger,属于复杂任务;需补充 `design.md` 与
|
||||
`implement.md`,再进行实现审批。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "unify-buyer-facts-indexeddb",
|
||||
"name": "unify-buyer-facts-indexeddb",
|
||||
"title": "统一 OneTalk buyer facts IndexedDB",
|
||||
"description": "将 buyer facts 账本纳入统一 OneTalk IndexedDB schema,并删除未上线开发环境的错误独立库。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-15",
|
||||
"completedAt": "2026-09-15",
|
||||
"branch": "dev-fix",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 65
|
||||
- **Last Active**: 2026-09-13
|
||||
- **Total Sessions**: 74
|
||||
- **Last Active**: 2026-09-15
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~1433 | Active |
|
||||
| `journal-1.md` | ~1613 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,14 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 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` |
|
||||
| 71 | 2026-09-14 | Implement Mind workspace authorization | `c9417f4` | `dev` |
|
||||
| 70 | 2026-09-14 | Warn unbound OneTalk accounts | `b92c25a` | `09-14-onetalk-unbound-tooltip-warning` |
|
||||
| 68 | 2026-09-14 | Mind conversation history rebuild endpoint | `7012bf9`, `54af59c` | `09-12-mind-rebuild-conversation-history` |
|
||||
| 67 | 2026-09-14 | 收集 OneTalk 商品消息 | `4042c34` | `09-14-collect-product-messages` |
|
||||
| 66 | 2026-09-14 | 集中 OneTalk 非观测 DOM 交互 | `66d03fd` | `09-14-centralize-dom-interactions` |
|
||||
| 65 | 2026-09-13 | OneTalk profile active sync | `f93255e` | `09-12-onetalk-profile-active-sync` |
|
||||
| 65 | 2026-09-12 | OneTalk 采集与命令流程边界整理 | `15efc84` | `09-12-onetalk-flow-boundaries` |
|
||||
| 64 | 2026-09-12 | Harden Mind send liveness | `440df49`, `cc2ee8f` | `09-12-mind-send-liveness` |
|
||||
|
||||
@@ -1265,8 +1265,8 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 59: 隔离发送观测 pending
|
||||
|
||||
<!-- trellis-session: v=2 fp=045d23556149a966 -->
|
||||
|
||||
**Date**: 2026-09-11
|
||||
@@ -1276,7 +1276,9 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
### Summary
|
||||
|
||||
修复 OneTalk live observation 误结算无关图片和附件 pending;补充唯一与多重匹配回归,并更新发送 SOP。
|
||||
|
||||
## Session 60: 移除 pending-send 终态历史
|
||||
|
||||
<!-- trellis-session: v=2 fp=daace1c5e04ddf35 -->
|
||||
|
||||
**Date**: 2026-09-11
|
||||
@@ -1289,17 +1291,17 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `1a899a9` | fix: isolate pending send observations |
|
||||
| Hash | Message |
|
||||
| --------- | ----------------------------------------- |
|
||||
| `1a899a9` | fix: isolate pending send observations |
|
||||
| `63d9e5b` | fix: remove pending send terminal history |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 61: 收敛 OneTalk WebSocket 路由分发
|
||||
|
||||
<!-- trellis-session: v=2 fp=df00ad1bc5438aed -->
|
||||
|
||||
**Date**: 2026-09-12
|
||||
@@ -1312,16 +1314,16 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| Hash | Message |
|
||||
| --------- | --------------------------------------------- |
|
||||
| `bd9cc6a` | refactor: centralize WebSocket route dispatch |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 62: 规范化 OneTalk 合约包位置
|
||||
|
||||
<!-- trellis-session: v=2 fp=bd1df2949d2c5cd9 -->
|
||||
|
||||
**Date**: 2026-09-12
|
||||
@@ -1334,16 +1336,16 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| Hash | Message |
|
||||
| --------- | ------------------------------------------------------------ |
|
||||
| `4d46b5b` | refactor(onetalk-contract): move shared contract to packages |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 63: OneTalk 结构化消息信息采集分类
|
||||
|
||||
<!-- trellis-session: v=2 fp=2d7d7ce2d2198700 -->
|
||||
|
||||
**Date**: 2026-09-12
|
||||
@@ -1353,7 +1355,9 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
|
||||
### Summary
|
||||
|
||||
完成并合并 OneTalk 历史 SDK 结构化业务卡采集:以完整联合条件识别名片、询盘和订单;名片落库严格保持 marker,读取时才按账号和会话组合独立客户资料;订单仅传递白名单摘要并为异常保留可观测结果。任务经 PR #42/#43 合入 main,后续结构化事实等价性修复也已在 main。当前工作树干净;收尾复验中合同运行时测试 36/36 通过、迁移 check 通过,跨包测试和 typecheck 因本地 node_modules 仍指向已迁移的旧 apps/onetalk-contract 路径且缺少 @types/node 未能重跑。
|
||||
|
||||
## Session 64: Harden Mind send liveness
|
||||
|
||||
<!-- trellis-session: v=2 fp=67f025dc90dd2a4f -->
|
||||
|
||||
**Date**: 2026-09-12
|
||||
@@ -1366,20 +1370,20 @@ Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline term
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `f92f00d` | feat: normalize OneTalk business card content |
|
||||
| `31400fc` | fix: keep OneTalk business cards as markers |
|
||||
| Hash | Message |
|
||||
| --------- | ------------------------------------------------------ |
|
||||
| `f92f00d` | feat: normalize OneTalk business card content |
|
||||
| `31400fc` | fix: keep OneTalk business cards as markers |
|
||||
| `86447cc` | docs: clarify OneTalk business card profile projection |
|
||||
| `440df49` | fix(onetalk): harden mind send liveness |
|
||||
| `cc2ee8f` | docs(trellis): record mind send liveness contracts |
|
||||
| `440df49` | fix(onetalk): harden mind send liveness |
|
||||
| `cc2ee8f` | docs(trellis): record mind send liveness contracts |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 64: OneTalk 采集与命令流程边界整理
|
||||
|
||||
<!-- trellis-session: v=2 fp=856e0497d494b088 -->
|
||||
|
||||
**Date**: 2026-09-12
|
||||
@@ -1397,8 +1401,8 @@ Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline term
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| Hash | Message |
|
||||
| --------- | -------------------------------------------------------- |
|
||||
| `15efc84` | refactor(onetalk): separate collection and command flows |
|
||||
|
||||
### Testing
|
||||
@@ -1410,8 +1414,8 @@ Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline term
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 65: OneTalk profile active sync
|
||||
|
||||
<!-- trellis-session: v=2 fp=2ef84c1ed02f1730 -->
|
||||
|
||||
**Date**: 2026-09-13
|
||||
@@ -1424,10 +1428,186 @@ Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline term
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| Hash | Message |
|
||||
| --------- | ------------------------------------------- |
|
||||
| `f93255e` | feat(onetalk): sync active contact profiles |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
## Session 66: 集中 OneTalk 非观测 DOM 交互
|
||||
|
||||
<!-- trellis-session: v=2 fp=a322166a7d34363f -->
|
||||
|
||||
**Date**: 2026-09-14
|
||||
**Task**: 集中 OneTalk 非观测 DOM 交互
|
||||
**Branch**: `09-14-centralize-dom-interactions`
|
||||
|
||||
### Summary
|
||||
|
||||
将 OneTalk 非观测 DOM 查询、控件和点击订阅集中到 main-page/dom,新增 Testing Library/jsdom 测试,并通过全量扩展测试、类型检查和构建验证。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
| --------- | ----------------------------------------- |
|
||||
| `66d03fd` | feat: centralize OneTalk DOM interactions |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
## Session 67: 收集 OneTalk 商品消息
|
||||
|
||||
<!-- trellis-session: v=2 fp=69cc698db4951ea6 -->
|
||||
|
||||
**Date**: 2026-09-14
|
||||
**Task**: 收集 OneTalk 商品消息
|
||||
**Branch**: `09-14-collect-product-messages`
|
||||
|
||||
### Summary
|
||||
|
||||
新增严格 product 内容合同、MAIN-world 商品 URL 脱敏归一化、JSONB kind 迁移与读取回归。
|
||||
|
||||
## Session 68: Mind conversation history rebuild endpoint
|
||||
|
||||
<!-- trellis-session: v=2 fp=dfa024de36f75323 -->
|
||||
|
||||
**Date**: 2026-09-14
|
||||
**Task**: Mind conversation history rebuild endpoint
|
||||
**Branch**: `09-12-mind-rebuild-conversation-history`
|
||||
|
||||
### Summary
|
||||
|
||||
完成 Mind 单会话历史重建 HTTP/WS 契约、Bright scoped reset、插件定向清理与 full-sync generation fence;已通过任务检查并归档任务。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
| --------- | ----------------------------------------- |
|
||||
| `4042c34` | feat: collect OneTalk product messages |
|
||||
| `7012bf9` | feat: update message synchronization flow |
|
||||
| `54af59c` | feat: expand message center capabilities |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
## Session 70: Warn unbound OneTalk accounts
|
||||
|
||||
<!-- trellis-session: v=2 fp=5cd4db216f0afd73 -->
|
||||
|
||||
**Date**: 2026-09-14
|
||||
**Task**: Warn unbound OneTalk accounts
|
||||
**Branch**: `09-14-onetalk-unbound-tooltip-warning`
|
||||
|
||||
### Summary
|
||||
|
||||
Added a minimal account-scoped binding-status projection from the Service Worker through the v4 page bridge to the existing action-status tooltip. The red unbound row is independent from connection/history rows, exact-shape validated, and documented; code-level checks passed without Chrome testing.
|
||||
|
||||
## Session 71: Implement Mind workspace authorization
|
||||
|
||||
<!-- trellis-session: v=2 fp=63bee56b45039c07 -->
|
||||
|
||||
**Date**: 2026-09-14
|
||||
**Task**: Implement Mind workspace authorization
|
||||
**Branch**: `dev`
|
||||
|
||||
### Summary
|
||||
|
||||
Enforced unique workspace context across Mind authorization HTTP and WebSocket flows, added regression coverage for rebinding fences, and documented the contract.
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
| --------- | ------------------------------------------ |
|
||||
| `c9417f4` | feat: enforce Mind workspace authorization |
|
||||
| `b28ccb0` | feat: update message synchronization flow |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
## Session 72: 完成 React 卡片监测
|
||||
|
||||
<!-- trellis-session: v=2 fp=777eb5fb6d74bc65 -->
|
||||
|
||||
**Date**: 2026-09-15
|
||||
**Task**: 完成 React 卡片监测
|
||||
**Branch**: `09-14-react-card-monitor`
|
||||
|
||||
### Summary
|
||||
|
||||
完成 09-14-react-card-monitor 的 React 卡片监测跨层实现与测试,并归档任务;会话记录关联工作提交 b28ccb0。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
| --------- | ----------------------------------- |
|
||||
| `b92c25a` | feat: warn unbound OneTalk accounts |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 73: Index OneTalk durable ledger queries
|
||||
<!-- trellis-session: v=2 fp=a13b11deef810009 -->
|
||||
|
||||
**Date**: 2026-09-15
|
||||
**Task**: Index OneTalk durable ledger queries
|
||||
**Branch**: `09-14-onetalk-indexeddb-query-indexes`
|
||||
|
||||
### Summary
|
||||
|
||||
升级 OneTalk IndexedDB 至 v9,增加账号、会话和状态索引,消除同步账本全表读取,并以迁移、ACK 与范围查询回归测试锁定行为。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `c4623ee` | feat: index OneTalk sync ledger queries |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 74: 统一 OneTalk buyer facts IndexedDB
|
||||
<!-- trellis-session: v=2 fp=03cfc00161d86a76 -->
|
||||
|
||||
**Date**: 2026-09-15
|
||||
**Task**: 统一 OneTalk buyer facts IndexedDB
|
||||
**Package**: chrome-extension
|
||||
**Branch**: `dev-fix`
|
||||
|
||||
### Summary
|
||||
|
||||
将 buyer facts 接入统一 v10 IndexedDB schema,删除错误开发库并保留 v8/v9 升级回归。
|
||||
|
||||
### Main Changes
|
||||
|
||||
- buyer ledger 复用主数据库名称、版本与唯一升级入口
|
||||
- v9→v10 新增 buyer store 并保留既有 stores、records 与 indexes
|
||||
- 已从当前扩展 Worker 精确删除错误独立 IndexedDB
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `cd888ac` | fix: unify OneTalk buyer facts IndexedDB |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] buyer、v8→v9、v9→v10 定向回归 3/3 通过
|
||||
- [OK] 全量扩展测试 402/403 通过;剩余 rendered-card 清理失败为既有基线
|
||||
- [OK] typecheck/build 仍被 HEAD 既有 storage.ts readAll 未定义阻断
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- 修复既有 readAll 类型错误后重建并加载扩展,完成 v10 浏览器写入 smoke
|
||||
|
||||
@@ -110,7 +110,7 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/trade_message_center
|
||||
|
||||
### OneTalk 本地开发
|
||||
|
||||
`pnpm dev` 只以 `NODE_ENV=development` 启动 Bright server 与 Chrome 扩展。`mind-test-harness` 是顺道使用的低优先级手工工具,必须另开终端运行 `pnpm dev:harness`;它的失败只报告给该命令,绝不阻断主开发流程。启动后,其 Mind 授权模拟默认监听 `127.0.0.1:8787`,独立 Bright 联调页默认监听 `127.0.0.1:8788`。Bright 通过 `MIND_AUTH_BASE_URL` 访问模拟的两个授权接口,不再需要 `ONETALK_DEV_*` 身份 fixture;该工具不连接 Mind 数据库,也不属于生产授权实现。
|
||||
`pnpm dev` 只以 `NODE_ENV=development` 启动 Bright server 与 Chrome 扩展。`mind-test-harness` 仅允许在 `NODE_ENV=development` 下运行,是顺道使用的低优先级手工工具;必须另开终端执行 `NODE_ENV=development pnpm dev:harness`,不得在 test、staging 或 production 环境启动。它的失败只报告给该命令,绝不阻断主开发流程。启动后,其 Mind 授权模拟默认监听 `127.0.0.1:8787`,独立 Bright 联调页默认监听 `127.0.0.1:8788`。Bright 通过 `MIND_AUTH_BASE_URL` 访问模拟的两个授权接口,不再需要 `ONETALK_DEV_*` 身份 fixture;该工具不连接 Mind 数据库,也不属于生产授权实现。
|
||||
|
||||
联调页的“上传本地文件到 OSS”把原始文件 POST 给 Bright,再由 server 使用本地环境中的 `OSS_*` 凭据 PUT 到 `harness-uploads/<uuid>/<fileName>`;浏览器不会接触 AccessKey,也不依赖 OSS 浏览器 CORS。成功后页面展示 30 分钟有效的 HTTPS 地址,并自动填入图片或附件发送字段。运行时必须让 `MIND_PAGE_ORIGIN` 与联调页 Origin 精确一致;若四项 `OSS_*` 都未配置,接口返回 `503 oss_upload_unavailable`,不会伪造地址。
|
||||
|
||||
@@ -134,9 +134,9 @@ Bright 不连接 Mind 数据库、不读取认证视图、不共享 Mind 登录
|
||||
|
||||
### Mind 本地测试支架
|
||||
|
||||
`mind-test-harness` 是仅供手工测试的独立开发工具:它模拟两个 Mind 授权接口,并提供 Bright HTTP/WS 联调页。Mind 模拟默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `286995452`、binding `binding-123` 和 Cookie `mind_session=mock-valid`;通过 `MIND_MOCK_*` 环境变量覆盖 fixture 后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable`,`MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable`。
|
||||
`mind-test-harness` 是仅供手工测试的独立开发工具:它模拟两个 Mind 授权接口,并提供 Bright HTTP/WS 联调页。Mind 模拟默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `243340382`、`286995452`、binding `binding-123` 和 Cookie `mind_session=mock-valid`;`MIND_MOCK_CHANNEL_ACCOUNT_ID` 支持逗号分隔的账号列表,设置 `MIND_MOCK_*` 环境变量后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable`,`MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable`。
|
||||
|
||||
根目录执行 `pnpm dev` 时不会启动支架;需要手工验证时才另开终端运行 `pnpm dev:harness`。支架不被任何 workspace 包依赖,不含测试文件,不声明 build 脚本,也不进入根 `dev`、`typecheck`、`test`、`build` 或生产 Docker 镜像。支架错误只在其独立命令中报告,不能改变主流程结果。
|
||||
根目录执行 `pnpm dev` 时不会启动支架;需要手工验证时才另开终端在开发环境运行 `NODE_ENV=development pnpm dev:harness`。支架不被任何 workspace 包依赖,不含测试文件,不声明 build 脚本,也不进入根 `dev`、`typecheck`、`test`、`build` 或生产 Docker 镜像。支架错误只在其独立命令中报告,不能改变主流程结果。
|
||||
|
||||
直接验证两个接口:
|
||||
|
||||
@@ -150,15 +150,15 @@ curl -sS http://127.0.0.1:8787/internal/bright/onetalk/authorize-session \
|
||||
--data '{"channelAccountId":"286995452"}'
|
||||
```
|
||||
|
||||
手工验证时单独运行:
|
||||
手工验证时只允许在开发环境单独运行:
|
||||
|
||||
```bash
|
||||
pnpm dev:harness
|
||||
NODE_ENV=development pnpm dev:harness
|
||||
```
|
||||
|
||||
`pnpm --filter @trade-message-center/server dev:mind-http` 是仅供显式测试配置使用的 server 启动脚本;普通 `pnpm dev` 已直接使用 development-only 的 loopback Mind HTTP 配置。支架不模拟真实 Mind 的 Cookie Domain/SameSite/Secure、三级域 CORS/TLS 或 takeover 事务;它只在独立本地页面中暴露 Bright 的手工操作入口。
|
||||
|
||||
`mind-test-harness` 仅参与显式的 `pnpm dev:harness`;它不进入根 `pnpm dev`、`typecheck`、`test` 或 `build`,也不会被 `Dockerfile.server` 复制到生产镜像。完成支架改动无需新增或执行测试;若出现错误,只报告,不阻断主流程。
|
||||
`mind-test-harness` 仅允许在开发环境参与显式的 `NODE_ENV=development pnpm dev:harness`;它不进入根 `pnpm dev`、`typecheck`、`test` 或 `build`,也不会被 `Dockerfile.server` 复制到生产镜像。完成支架改动无需新增或执行测试;若出现错误,只报告,不阻断主流程。
|
||||
|
||||
`VITE_BRIGHT_WEBSOCKET_URL` 用于 Chrome 扩展构建:
|
||||
|
||||
@@ -254,7 +254,7 @@ pnpm dev
|
||||
pnpm dev:server
|
||||
```
|
||||
|
||||
该命令直接启动 server watch,不调用 workspace 级 `with-build-hash`,也跳过 server `predev` 的 contract 构建;因此不会重新构建 Chrome 扩展,也不会改变当前扩展产物中的 `BUILD_HASH`。首次 checkout 或修改 `onetalk-contract` 后,请先执行 `pnpm --filter @trade-message-center/onetalk-contract build`。如果 server 需要本地 Mind 测试支架,请另开终端运行 `pnpm dev:harness`。
|
||||
该命令直接启动 server watch,不调用 workspace 级 `with-build-hash`,也跳过 server `predev` 的 contract 构建;因此不会重新构建 Chrome 扩展,也不会改变当前扩展产物中的 `BUILD_HASH`。首次 checkout 或修改 `onetalk-contract` 后,请先执行 `pnpm --filter @trade-message-center/onetalk-contract build`。如果 server 需要本地 Mind 测试支架,请另开终端在开发环境运行 `NODE_ENV=development pnpm dev:harness`。
|
||||
|
||||
检查 server 是否启动:
|
||||
|
||||
@@ -274,7 +274,7 @@ curl http://127.0.0.1:7878/health
|
||||
http://127.0.0.1:8788/
|
||||
```
|
||||
|
||||
本地 harness 中的 OneTalk account 应与 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `286995452`)一致;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面会连接配置的 Bright 地址的 `/ws/mind`,插件使用 Bright 的 `/ws/plugin`;`MIND_PAGE_ORIGIN` 必须设为该页面的 Origin。
|
||||
本地 harness 中的 OneTalk account 应包含在 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `243340382,286995452`)中;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面会连接配置的 Bright 地址的 `/ws/mind`,插件使用 Bright 的 `/ws/plugin`;`MIND_PAGE_ORIGIN` 必须设为该页面的 Origin。
|
||||
|
||||
## 加载和配置 Chrome 扩展
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"@trade-message-center/onetalk-contract": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
|
||||
@@ -1,192 +1,7 @@
|
||||
// 管理 OneTalk 页面进行中动作提示
|
||||
|
||||
import { isObjectRecord } from "../../lib/guards.ts";
|
||||
|
||||
export type TooltipColor = "neutral" | "info" | "warning" | "error";
|
||||
|
||||
export type OneTalkActionStatusTooltip = {
|
||||
start: (id: string, text: string, color?: TooltipColor) => void;
|
||||
update: (id: string, text: string, color?: TooltipColor) => boolean;
|
||||
close: (id: string) => boolean;
|
||||
};
|
||||
|
||||
type OneTalkActionStatusTooltipWindow = Pick<Window, "document"> & {
|
||||
__tradeMessageCenterOneTalk?: unknown;
|
||||
};
|
||||
|
||||
type ActionStatus = {
|
||||
color: TooltipColor;
|
||||
row: HTMLDivElement;
|
||||
};
|
||||
|
||||
const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip";
|
||||
const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row";
|
||||
const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color";
|
||||
const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral";
|
||||
const tooltipRefreshes = new WeakMap<OneTalkActionStatusTooltipWindow, () => void>();
|
||||
|
||||
const TOOLTIP_COLORS: Readonly<
|
||||
Record<TooltipColor, { background: string; border: string; text: string }>
|
||||
> = {
|
||||
neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" },
|
||||
info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" },
|
||||
warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" },
|
||||
error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" },
|
||||
};
|
||||
|
||||
const isTooltipColor = (value: unknown): value is TooltipColor => {
|
||||
return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value);
|
||||
};
|
||||
|
||||
const requireTooltipColor = (value: unknown): TooltipColor => {
|
||||
if (isTooltipColor(value)) return value;
|
||||
throw new TypeError("onetalk_action_status_tooltip_color_invalid");
|
||||
};
|
||||
|
||||
const statusRowStyles = (color: TooltipColor): string => {
|
||||
const theme = TOOLTIP_COLORS[color];
|
||||
return [
|
||||
"box-sizing: border-box",
|
||||
"width: 100%",
|
||||
"padding: 8px 12px",
|
||||
"border: 1px solid " + theme.border,
|
||||
"border-radius: 6px",
|
||||
"background: " + theme.background,
|
||||
"color: " + theme.text,
|
||||
"font-size: 13px",
|
||||
"line-height: 20px",
|
||||
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
"box-shadow: 0 2px 8px rgb(0 0 0 / 12%)",
|
||||
].join(";");
|
||||
};
|
||||
|
||||
const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => {
|
||||
status.color = color;
|
||||
status.row.textContent = text;
|
||||
status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color);
|
||||
status.row.style.cssText = statusRowStyles(color);
|
||||
};
|
||||
|
||||
const createStatusRow = (
|
||||
document: Document,
|
||||
id: string,
|
||||
text: string,
|
||||
color: TooltipColor,
|
||||
): ActionStatus => {
|
||||
const row = document.createElement("div");
|
||||
row.setAttribute(STATUS_ROW_ATTRIBUTE, "");
|
||||
row.dataset.tmcActionStatusId = id;
|
||||
const status = { color, row };
|
||||
updateStatusRow(status, text, color);
|
||||
return status;
|
||||
};
|
||||
|
||||
const createStatusContainer = (document: Document): HTMLDivElement => {
|
||||
const container = document.createElement("div");
|
||||
container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, "");
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("role", "status");
|
||||
container.style.cssText = [
|
||||
"position: fixed",
|
||||
"top: 0",
|
||||
"left: 50%",
|
||||
"transform: translateX(-50%)",
|
||||
"z-index: 2147483647",
|
||||
"display: flex",
|
||||
"flex-direction: column",
|
||||
"gap: 8px",
|
||||
"width: min(600px, calc(100vw - 96px))",
|
||||
"pointer-events: none",
|
||||
].join(";");
|
||||
return container;
|
||||
};
|
||||
|
||||
const findStatusContainer = (document: Document): HTMLDivElement | null => {
|
||||
return document.querySelector<HTMLDivElement>(`div[${STATUS_CONTAINER_ATTRIBUTE}]`);
|
||||
};
|
||||
|
||||
const ensureStatusContainer = (
|
||||
document: Document,
|
||||
statuses: ReadonlyMap<string, ActionStatus>,
|
||||
): HTMLDivElement => {
|
||||
const existing = findStatusContainer(document);
|
||||
const container = existing ?? createStatusContainer(document);
|
||||
if (!existing) {
|
||||
if (!document.body)
|
||||
throw new Error("onetalk_action_status_tooltip_document_body_unavailable");
|
||||
document.body.append(container);
|
||||
}
|
||||
for (const status of statuses.values()) container.append(status.row);
|
||||
return container;
|
||||
};
|
||||
|
||||
const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => {
|
||||
return (
|
||||
isObjectRecord(value) &&
|
||||
typeof value.start === "function" &&
|
||||
typeof value.update === "function" &&
|
||||
typeof value.close === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const createTooltipFacade = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): OneTalkActionStatusTooltip => {
|
||||
const statuses = new Map<string, ActionStatus>();
|
||||
const refresh = (): void => {
|
||||
if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses);
|
||||
};
|
||||
tooltipRefreshes.set(pageWindow, refresh);
|
||||
const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => {
|
||||
if (statuses.has(id)) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const resolvedColor = requireTooltipColor(color);
|
||||
const status = createStatusRow(pageWindow.document, id, text, resolvedColor);
|
||||
statuses.set(id, status);
|
||||
const container = ensureStatusContainer(pageWindow.document, statuses);
|
||||
if (status.row.parentElement !== container) container.append(status.row);
|
||||
};
|
||||
const update = (id: string, text: string, color?: TooltipColor): boolean => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return false;
|
||||
const resolvedColor = color === undefined ? status.color : requireTooltipColor(color);
|
||||
updateStatusRow(status, text, resolvedColor);
|
||||
ensureStatusContainer(pageWindow.document, statuses);
|
||||
return true;
|
||||
};
|
||||
const close = (id: string): boolean => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return false;
|
||||
statuses.delete(id);
|
||||
status.row.remove();
|
||||
if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove();
|
||||
return true;
|
||||
};
|
||||
return { close, start, update };
|
||||
};
|
||||
|
||||
/** 读取已安装的页面动作提示 facade。 */
|
||||
export const readOneTalkActionStatusTooltip = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): OneTalkActionStatusTooltip | null => {
|
||||
const namespace = pageWindow.__tradeMessageCenterOneTalk;
|
||||
return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip)
|
||||
? namespace.tooltip
|
||||
: null;
|
||||
};
|
||||
|
||||
/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */
|
||||
export const installOneTalkActionStatusTooltip = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): void => {
|
||||
const existing = pageWindow.__tradeMessageCenterOneTalk;
|
||||
const namespace: Record<string, unknown> = isObjectRecord(existing) ? existing : {};
|
||||
if (isTooltipFacade(namespace.tooltip)) {
|
||||
tooltipRefreshes.get(pageWindow)?.();
|
||||
return;
|
||||
}
|
||||
namespace.tooltip = createTooltipFacade(pageWindow);
|
||||
pageWindow.__tradeMessageCenterOneTalk = namespace;
|
||||
};
|
||||
// 兼容现有控件消费者;DOM 实现在专属适配目录中。
|
||||
export {
|
||||
installOneTalkActionStatusTooltip,
|
||||
readOneTalkActionStatusTooltip,
|
||||
type OneTalkActionStatusTooltip,
|
||||
type TooltipColor,
|
||||
} from "./dom/action-status-tooltip.ts";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 将已校验的页面绑定状态投影到既有动作提示。
|
||||
|
||||
import type { OneTalkActionStatusTooltip } from "./dom/action-status-tooltip.ts";
|
||||
|
||||
const BINDING_STATUS_TOOLTIP_ID = "onetalk-binding-status";
|
||||
const BINDING_UNBOUND_TEXT = "当前账号未绑定";
|
||||
|
||||
/** 管理当前页面的未绑定提示行。 */
|
||||
export class BindingStatusTooltip {
|
||||
private readonly tooltip: OneTalkActionStatusTooltip;
|
||||
|
||||
public constructor(tooltip: OneTalkActionStatusTooltip) {
|
||||
this.tooltip = tooltip;
|
||||
}
|
||||
|
||||
public update(unbound: boolean): void {
|
||||
if (unbound) {
|
||||
this.tooltip.start(BINDING_STATUS_TOOLTIP_ID, BINDING_UNBOUND_TEXT, "error");
|
||||
return;
|
||||
}
|
||||
this.tooltip.close(BINDING_STATUS_TOOLTIP_ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 安装渲染卡片 observer:DOM mutation 即时读取,另以当前列表周期重扫补足 Fiber 延迟就绪。
|
||||
|
||||
import type { OneTalkRenderedCardObservation } from "@trade-message-center/onetalk-contract";
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
|
||||
import { readOneTalkRenderedCard } from "./react-card-reader.ts";
|
||||
|
||||
const RENDERED_CARD_RESCAN_INTERVAL_MS = 1_000;
|
||||
|
||||
const messageListRootFor = (
|
||||
document: NonNullable<OneTalkPageWindow["document"]>,
|
||||
): Element | null => {
|
||||
const firstMessage = document.querySelectorAll(".message-item-wrapper")[0];
|
||||
if (!firstMessage) return null;
|
||||
let parent = firstMessage.parentElement;
|
||||
while (parent) {
|
||||
if (parent.querySelectorAll(".message-item-wrapper").length > 1) return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return firstMessage.parentElement;
|
||||
};
|
||||
|
||||
const renderedCardWrapperFor = (node: Node): Element | null => {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return element?.matches(".message-item-wrapper")
|
||||
? element
|
||||
: (element?.closest(".message-item-wrapper") ?? null);
|
||||
};
|
||||
|
||||
/** 安装 Fiber card reader;未验证类别一律不上传。 */
|
||||
export const installOneTalkRenderedCardObserver = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
sink: (
|
||||
observations: OneTalkRenderedCardObservation[],
|
||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
|
||||
) => void,
|
||||
): void => {
|
||||
const document = pageWindow.document;
|
||||
if (!document || !pageWindow.MutationObserver) return;
|
||||
const observedCardKeys = new Set<string>();
|
||||
let disposed = false;
|
||||
const inspect = (wrapper: Element): void => {
|
||||
if (disposed) return;
|
||||
const read = readOneTalkRenderedCard(pageWindow, wrapper);
|
||||
if (!read) return;
|
||||
const observationKey = JSON.stringify([
|
||||
read.observation.conversationId,
|
||||
read.observation.messageId,
|
||||
read.observation.contentFingerprint,
|
||||
]);
|
||||
if (observedCardKeys.has(observationKey)) return;
|
||||
observedCardKeys.add(observationKey);
|
||||
sink([read.observation], [read.baseEvidence]);
|
||||
};
|
||||
let root: Element | null = null;
|
||||
let observingDocument = false;
|
||||
const observer = new pageWindow.MutationObserver((records) => {
|
||||
if (disposed) return;
|
||||
if (messageListRootFor(document) !== root) {
|
||||
bind();
|
||||
return;
|
||||
}
|
||||
for (const record of records) {
|
||||
if (root && record.target !== root && !root.contains(record.target)) continue;
|
||||
const wrappers = new Set<Element>();
|
||||
const targetWrapper = renderedCardWrapperFor(record.target);
|
||||
if (targetWrapper) wrappers.add(targetWrapper);
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
const wrapper = renderedCardWrapperFor(node);
|
||||
if (wrapper) wrappers.add(wrapper);
|
||||
for (const wrapper of Array.from(node.querySelectorAll(".message-item-wrapper")))
|
||||
wrappers.add(wrapper);
|
||||
}
|
||||
for (const wrapper of wrappers) inspect(wrapper);
|
||||
}
|
||||
});
|
||||
const bind = (): void => {
|
||||
if (disposed) return;
|
||||
const nextRoot = messageListRootFor(document);
|
||||
if (nextRoot === root && (root !== null || observingDocument)) return;
|
||||
observer.disconnect();
|
||||
root = nextRoot;
|
||||
if (!root) {
|
||||
observingDocument = true;
|
||||
observer.observe(document as unknown as Node, { childList: true, subtree: true });
|
||||
return;
|
||||
}
|
||||
observingDocument = false;
|
||||
for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper")))
|
||||
inspect(wrapper);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
if (root.parentElement) observer.observe(root.parentElement, { childList: true });
|
||||
};
|
||||
|
||||
const rescanCurrentMessageList = (): void => {
|
||||
if (disposed) return;
|
||||
if (messageListRootFor(document) !== root) {
|
||||
bind();
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper")))
|
||||
inspect(wrapper);
|
||||
};
|
||||
|
||||
bind();
|
||||
const rescanTimer = globalThis.setInterval(
|
||||
rescanCurrentMessageList,
|
||||
RENDERED_CARD_RESCAN_INTERVAL_MS,
|
||||
);
|
||||
pageWindow.addEventListener("popstate", bind);
|
||||
pageWindow.addEventListener("hashchange", bind);
|
||||
pageWindow.addEventListener("pagehide", () => {
|
||||
disposed = true;
|
||||
globalThis.clearInterval(rescanTimer);
|
||||
observer.disconnect();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
// 从 OneTalk React Fiber 白名单投影三类渲染卡片;不让 raw props 离开 MAIN world。
|
||||
|
||||
import {
|
||||
createOneTalkRenderedCardContentFingerprint,
|
||||
isOneTalkRenderedCardContent,
|
||||
isOneTalkRenderedCardImageUrl,
|
||||
isOneTalkRenderedCardText,
|
||||
normalizeOneTalkProductUrl,
|
||||
type OneTalkRenderedCardContent,
|
||||
type OneTalkRenderedCardObservation,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
import { isObjectRecord } from "../../../lib/guards.ts";
|
||||
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
|
||||
import { readConversationSelection } from "../page-context.ts";
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
|
||||
type FiberRecord = {
|
||||
return?: FiberRecord | null;
|
||||
child?: FiberRecord | null;
|
||||
sibling?: FiberRecord | null;
|
||||
memoizedProps?: unknown;
|
||||
};
|
||||
type ItemData = {
|
||||
messageId: string;
|
||||
conversationCode: string;
|
||||
messageType: "rec" | "send";
|
||||
sendTime: number;
|
||||
msgType: number;
|
||||
originalData: Record<string, unknown>;
|
||||
};
|
||||
export type OneTalkRenderedCardRead = {
|
||||
observation: OneTalkRenderedCardObservation;
|
||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
|
||||
};
|
||||
type CardClassification =
|
||||
| { kind: "order"; data: Record<string, unknown> }
|
||||
| { kind: "inquiry"; data: Record<string, unknown> }
|
||||
| { kind: "product"; data: Record<string, unknown> };
|
||||
|
||||
const MAX_FIBER_ANCESTORS = 60;
|
||||
const MAX_FIBER_DESCENDANTS = 120;
|
||||
|
||||
const text = (value: unknown): string | null => (isOneTalkRenderedCardText(value) ? value : null);
|
||||
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
|
||||
const imageUrl = (value: unknown): string | null =>
|
||||
isOneTalkRenderedCardImageUrl(value) ? value : null;
|
||||
const scalarId = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim().length > 0
|
||||
? value
|
||||
: typeof value === "number" && Number.isSafeInteger(value)
|
||||
? String(value)
|
||||
: null;
|
||||
const nonNegativeInteger = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
|
||||
const fiberFor = (node: Node): FiberRecord | null => {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (!key.startsWith("__reactFiber$")) continue;
|
||||
const fiber = (node as unknown as Record<string, unknown>)[key];
|
||||
if (isObjectRecord(fiber)) return fiber as FiberRecord;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fiberIn = (wrapper: Element): FiberRecord | null =>
|
||||
fiberFor(wrapper) ??
|
||||
Array.from(wrapper.querySelectorAll("*"))
|
||||
.map(fiberFor)
|
||||
.find((value): value is FiberRecord => value !== null) ??
|
||||
null;
|
||||
|
||||
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
|
||||
let cursor: FiberRecord | null | undefined = fiber;
|
||||
for (let depth = 0; cursor && depth < MAX_FIBER_ANCESTORS; depth += 1) {
|
||||
const current: FiberRecord = cursor;
|
||||
cursor = current.return;
|
||||
const props = current.memoizedProps;
|
||||
if (!isObjectRecord(props) || !isObjectRecord(props.itemData)) continue;
|
||||
const item = props.itemData;
|
||||
const messageId = scalarId(item.messageId);
|
||||
const conversationCode = text(item.conversationCode);
|
||||
const messageType =
|
||||
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
|
||||
const sendTime = nonNegativeInteger(item.sendTime);
|
||||
const msgType = nonNegativeInteger(item.msgType);
|
||||
if (
|
||||
!messageId ||
|
||||
!conversationCode ||
|
||||
!messageType ||
|
||||
sendTime === null ||
|
||||
msgType === null ||
|
||||
!isObjectRecord(item.originalData)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
conversationCode,
|
||||
messageType,
|
||||
sendTime,
|
||||
msgType,
|
||||
originalData: item.originalData,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fibersFor = (fiber: FiberRecord): FiberRecord[] => {
|
||||
const pending: FiberRecord[] = [fiber];
|
||||
for (let index = 0; index < pending.length && index < MAX_FIBER_DESCENDANTS; index += 1) {
|
||||
const current = pending[index]!;
|
||||
if (current.child) pending.push(current.child);
|
||||
if (current !== fiber && current.sibling) pending.push(current.sibling);
|
||||
}
|
||||
return pending.slice(0, MAX_FIBER_DESCENDANTS);
|
||||
};
|
||||
|
||||
const templateDataFor = (
|
||||
fiber: FiberRecord,
|
||||
predicate: (data: Record<string, unknown>) => boolean,
|
||||
): Record<string, unknown> | null => {
|
||||
for (const current of fibersFor(fiber)) {
|
||||
const props = current.memoizedProps;
|
||||
if (!isObjectRecord(props) || !isObjectRecord(props.data) || !predicate(props.data))
|
||||
continue;
|
||||
return props.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasRendererCardType = (fiber: FiberRecord, cardType: number): boolean =>
|
||||
fibersFor(fiber).some((current) => {
|
||||
const props = current.memoizedProps;
|
||||
return isObjectRecord(props) && props.cardType === cardType;
|
||||
});
|
||||
|
||||
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
const title =
|
||||
text(value.productName) ??
|
||||
text(value.title) ??
|
||||
text(value.productTitle) ??
|
||||
text(value.name);
|
||||
if (!title) return null;
|
||||
const imageValues = [value.imageUrl, value.productImage, value.image].filter(
|
||||
(image): image is unknown => image !== undefined && image !== null,
|
||||
);
|
||||
if (imageValues.some((value) => imageUrl(value) === null)) return null;
|
||||
return {
|
||||
imageUrl:
|
||||
imageValues.map(imageUrl).find((value): value is string => value !== null) ?? null,
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
const orderTemplate = (data: Record<string, unknown>): boolean =>
|
||||
text(data.cardTitle) !== null &&
|
||||
Array.isArray(data.productInfoList ?? data.productList) &&
|
||||
text(data.orderStatusText) !== null &&
|
||||
text(data.shippingAddress) !== null;
|
||||
|
||||
const inquiryTemplate = (data: Record<string, unknown>): boolean =>
|
||||
isObjectRecord(data.inquiryCardDTO) &&
|
||||
Array.isArray(data.displayProducts ?? data.products) &&
|
||||
text(data.inquiryContent) !== null &&
|
||||
scalarId(data.inquiryID) !== null;
|
||||
|
||||
const productTemplate = (data: Record<string, unknown>): boolean => {
|
||||
const productAction = data.productAction;
|
||||
return (
|
||||
text(data.title) !== null &&
|
||||
imageUrl(data.productImage) !== null &&
|
||||
text(data.price) !== null &&
|
||||
text(data.moq) !== null &&
|
||||
text(data.moqUnit) !== null &&
|
||||
isObjectRecord(productAction) &&
|
||||
isObjectRecord(productAction.actionParams) &&
|
||||
normalizeOneTalkProductUrl(productAction.actionParams.url) !== null
|
||||
);
|
||||
};
|
||||
|
||||
const classifyRenderedCard = (item: ItemData, fiber: FiberRecord): CardClassification | null => {
|
||||
if (item.msgType === 10010 && item.originalData.cardType === 9) {
|
||||
const data = templateDataFor(fiber, orderTemplate);
|
||||
return data ? { kind: "order", data } : null;
|
||||
}
|
||||
if (item.msgType === 10010 && item.originalData.cardType === 6) {
|
||||
const data = templateDataFor(fiber, inquiryTemplate);
|
||||
return data ? { kind: "inquiry", data } : null;
|
||||
}
|
||||
if (item.msgType === 101 && hasRendererCardType(fiber, 54)) {
|
||||
const data = templateDataFor(fiber, productTemplate);
|
||||
return data ? { kind: "product", data } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const orderContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const products = data.productInfoList ?? data.productList;
|
||||
if (!Array.isArray(products)) return null;
|
||||
const projected = products.map(productFor);
|
||||
const title = text(data.cardTitle);
|
||||
const statusText = text(data.orderStatusText);
|
||||
const shippingAddress = text(data.shippingAddress);
|
||||
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
|
||||
if (
|
||||
!title ||
|
||||
!statusText ||
|
||||
!shippingAddress ||
|
||||
!totalDisplay ||
|
||||
projected.some((item) => item === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const action = isObjectRecord(data.orderAction) ? data.orderAction : {};
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_order",
|
||||
title,
|
||||
products: projected as { imageUrl: string | null; title: string }[],
|
||||
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
|
||||
status: { code: scalarId(data.orderStatus), text: statusText },
|
||||
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
|
||||
delivery: {
|
||||
shippingAddress,
|
||||
methodLabel: nullableText(data.shippingMethodText),
|
||||
dateLabel: nullableText(data.deliveryTimeText),
|
||||
},
|
||||
action: {
|
||||
label: nullableText(action.label) ?? nullableText(action.name),
|
||||
status: nullableText(action.status),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const inquiryActionsFor = (data: Record<string, unknown>) =>
|
||||
[
|
||||
[data.detailStr, data.detailAction],
|
||||
[data.quoteStr, data.quoteAction],
|
||||
[data.actionName, data.inquiryAction],
|
||||
].flatMap(([label, action]) => {
|
||||
const approvedLabel = text(label);
|
||||
return approvedLabel ? [{ label: approvedLabel, available: isObjectRecord(action) }] : [];
|
||||
});
|
||||
|
||||
const inquiryContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const products = data.displayProducts ?? data.products;
|
||||
const requirementText = text(data.inquiryContent);
|
||||
const inquiryReference = scalarId(data.inquiryID);
|
||||
if (!Array.isArray(products) || !requirementText || !inquiryReference) return null;
|
||||
const product = productFor(products[0]);
|
||||
if (!product || !isObjectRecord(products[0])) return null;
|
||||
const value = text(products[0].productMOQ);
|
||||
const unit = text(products[0].productUnit);
|
||||
if (!value || !unit) return null;
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_inquiry",
|
||||
product,
|
||||
purchaseQuantity: { value, unit },
|
||||
requirementText,
|
||||
inquiryReference,
|
||||
actions: inquiryActionsFor(data),
|
||||
};
|
||||
};
|
||||
|
||||
const productContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const productAction = data.productAction;
|
||||
if (!isObjectRecord(productAction) || !isObjectRecord(productAction.actionParams)) return null;
|
||||
const reference = normalizeOneTalkProductUrl(productAction.actionParams.url);
|
||||
const title = text(data.title);
|
||||
const productImage = imageUrl(data.productImage);
|
||||
const priceDisplay = text(data.price);
|
||||
const value = text(data.moq);
|
||||
const unit = text(data.moqUnit);
|
||||
if (!reference || !title || !productImage || !priceDisplay || !value || !unit) return null;
|
||||
const serviceBadges = [data.benefitFirstText, data.benefitSecondText, data.promotionTag].reduce(
|
||||
(badges: string[], value) => {
|
||||
const badge = text(value);
|
||||
return badge && !badges.includes(badge) ? [...badges, badge] : badges;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_product",
|
||||
product: { title, imageUrl: productImage, ...reference },
|
||||
priceDisplay,
|
||||
minimumOrder: { value, unit },
|
||||
serviceBadges,
|
||||
};
|
||||
};
|
||||
|
||||
const contentFor = (classification: CardClassification): OneTalkRenderedCardContent | null => {
|
||||
const content =
|
||||
classification.kind === "order"
|
||||
? orderContentFor(classification.data)
|
||||
: classification.kind === "inquiry"
|
||||
? inquiryContentFor(classification.data)
|
||||
: productContentFor(classification.data);
|
||||
return content && isOneTalkRenderedCardContent(content) ? content : null;
|
||||
};
|
||||
|
||||
/** 读取一次受限 Fiber 卡片,并同时返回 downstream 所需的基础消息证据。 */
|
||||
export const readOneTalkRenderedCard = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
wrapper: Element,
|
||||
): OneTalkRenderedCardRead | null => {
|
||||
const before = readConversationSelection(pageWindow);
|
||||
if (before.kind !== "single") return null;
|
||||
const fiber = fiberIn(wrapper);
|
||||
if (!fiber) return null;
|
||||
const item = itemDataFor(fiber);
|
||||
if (!item || item.conversationCode !== before.conversationId) return null;
|
||||
const classification = classifyRenderedCard(item, fiber);
|
||||
if (!classification) return null;
|
||||
const content = contentFor(classification);
|
||||
const after = readConversationSelection(pageWindow);
|
||||
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
|
||||
return null;
|
||||
const observation = {
|
||||
conversationId: item.conversationCode,
|
||||
messageId: item.messageId,
|
||||
content,
|
||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||
observedAtMs: Date.now(),
|
||||
};
|
||||
return {
|
||||
observation,
|
||||
baseEvidence: {
|
||||
conversationId: item.conversationCode,
|
||||
messageId: item.messageId,
|
||||
direction: item.messageType === "rec" ? "received" : "sent",
|
||||
sentAtMs: item.sendTime,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,147 +1,2 @@
|
||||
// 在当前会话标题旁提供复制会话 ID 控件
|
||||
|
||||
import { readCurrentConversationId } from "./page-context.ts";
|
||||
import type { OneTalkPageWindow } from "./model.ts";
|
||||
|
||||
type OneTalkConversationIdCopyWindow = OneTalkPageWindow &
|
||||
Pick<Window, "clearTimeout" | "document" | "navigator" | "setTimeout"> & {
|
||||
MutationObserver?: typeof MutationObserver;
|
||||
};
|
||||
|
||||
const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy";
|
||||
const HEADER_MAX_BOTTOM_PX = 72;
|
||||
const BUTTON_LABEL = "复制会话 Id";
|
||||
const COPIED_LABEL = "已复制";
|
||||
const COPY_FAILED_LABEL = "复制失败";
|
||||
const BUTTON_RESTORE_DELAY_MS = 1_500;
|
||||
|
||||
const selectedConversationElement = (document: Document): Element | null => {
|
||||
const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]");
|
||||
return selected.length === 1 ? selected[0] : null;
|
||||
};
|
||||
|
||||
const headerTitleElement = (document: Document): Element | null => {
|
||||
const selected = selectedConversationElement(document);
|
||||
if (!selected) return null;
|
||||
const selectedRect = selected.getBoundingClientRect();
|
||||
const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY;
|
||||
const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => {
|
||||
if (element.children.length > 0 || !element.textContent?.trim()) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.bottom <= HEADER_MAX_BOTTOM_PX &&
|
||||
rect.left >= selectedRect.right &&
|
||||
rect.left < maxLeft * 0.75
|
||||
);
|
||||
});
|
||||
return (
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().left - right.getBoundingClientRect().left ||
|
||||
left.getBoundingClientRect().top - right.getBoundingClientRect().top,
|
||||
)[0] ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const updateButtonLabel = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
button: HTMLButtonElement,
|
||||
label: string,
|
||||
): void => {
|
||||
button.textContent = label;
|
||||
button.setAttribute("aria-label", `${label}会话 Id`);
|
||||
pageWindow.clearTimeout(Number(button.dataset.restoreTimer));
|
||||
button.dataset.restoreTimer = String(
|
||||
pageWindow.setTimeout(() => {
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
}, BUTTON_RESTORE_DELAY_MS),
|
||||
);
|
||||
};
|
||||
|
||||
const createCopyButton = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
conversationId: string,
|
||||
): HTMLButtonElement => {
|
||||
const button = pageWindow.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute(COPY_BUTTON_ATTRIBUTE, "");
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
button.title = BUTTON_LABEL;
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.dataset.conversationId = conversationId;
|
||||
button.style.cssText = [
|
||||
"margin-left: 8px",
|
||||
"padding: 2px 7px",
|
||||
"display: inline-flex",
|
||||
"align-items: center",
|
||||
"justify-content: center",
|
||||
"border: 1px solid #d8dce5",
|
||||
"border-radius: 4px",
|
||||
"background: #fff",
|
||||
"color: #315efb",
|
||||
"font-size: 12px",
|
||||
"line-height: 18px",
|
||||
"text-align: center",
|
||||
"cursor: pointer",
|
||||
"vertical-align: middle",
|
||||
].join(";");
|
||||
button.addEventListener("click", () => {
|
||||
const currentId = button.dataset.conversationId;
|
||||
if (!currentId) return;
|
||||
const clipboard = pageWindow.navigator.clipboard;
|
||||
if (!clipboard) {
|
||||
updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL);
|
||||
return;
|
||||
}
|
||||
void clipboard
|
||||
.writeText(currentId)
|
||||
.then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL))
|
||||
.catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL));
|
||||
});
|
||||
return button;
|
||||
};
|
||||
|
||||
const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => {
|
||||
const existing = pageWindow.document.querySelector<HTMLButtonElement>(
|
||||
`button[${COPY_BUTTON_ATTRIBUTE}]`,
|
||||
);
|
||||
const conversationId = readCurrentConversationId(pageWindow);
|
||||
if (!conversationId) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
existing.dataset.conversationId = conversationId;
|
||||
return;
|
||||
}
|
||||
const title = headerTitleElement(pageWindow.document);
|
||||
if (!title) return;
|
||||
title.append(createCopyButton(pageWindow, conversationId));
|
||||
};
|
||||
|
||||
/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */
|
||||
export const installOneTalkConversationIdCopyControl = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
): void => {
|
||||
let refreshScheduled = false;
|
||||
const refresh = (): void => {
|
||||
refreshScheduled = false;
|
||||
updateCopyControl(pageWindow);
|
||||
};
|
||||
const scheduleRefresh = (): void => {
|
||||
if (refreshScheduled) return;
|
||||
refreshScheduled = true;
|
||||
pageWindow.setTimeout(refresh, 0);
|
||||
};
|
||||
const observer = pageWindow.MutationObserver
|
||||
? new pageWindow.MutationObserver(scheduleRefresh)
|
||||
: null;
|
||||
if (observer && pageWindow.document.documentElement) {
|
||||
observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
// 兼容现有控件消费者;DOM 实现在专属适配目录中。
|
||||
export { installOneTalkConversationIdCopyControl } from "./dom/conversation-id-copy.ts";
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
// 处理 Service Worker 下发的历史同步命令
|
||||
|
||||
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
|
||||
import { createOneTalkPageObservedMessage } from "../../page-bridge/model.ts";
|
||||
import { createOneTalkPageHistoryProgressSink } from "../../page-bridge/main.ts";
|
||||
import type {
|
||||
OneTalkPageBridgeWindow,
|
||||
@@ -121,9 +122,24 @@ const observeHistoryItems = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
items: unknown[],
|
||||
observedSink?: OneTalkObservedMessageSink,
|
||||
historyRequestId?: string,
|
||||
): void => {
|
||||
if (!observedSink) return;
|
||||
observedSink(createHistoryMessageBatch(items, pageWindow));
|
||||
const batch = createHistoryMessageBatch(items, pageWindow);
|
||||
if (historyRequestId === undefined) {
|
||||
observedSink(batch);
|
||||
return;
|
||||
}
|
||||
const bridgeWindow = pageWindow as unknown as OneTalkPageBridgeWindow;
|
||||
bridgeWindow.postMessage(
|
||||
createOneTalkPageObservedMessage(
|
||||
batch.messages,
|
||||
undefined,
|
||||
batch.diagnostics,
|
||||
historyRequestId,
|
||||
),
|
||||
bridgeWindow.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
/** 执行页面同步命令并只返回可序列化的进度摘要。 */
|
||||
@@ -195,9 +211,10 @@ export const handleOneTalkHistoryCommand = async (
|
||||
startTimeStamps: startTimeStamps ?? undefined,
|
||||
onProgress: createOneTalkPageHistoryProgressSink(
|
||||
pageWindow as unknown as OneTalkPageBridgeWindow,
|
||||
message.requestId,
|
||||
),
|
||||
onHistoryItems: (items: unknown[]) =>
|
||||
observeHistoryItems(pageWindow, items, observedSink),
|
||||
observeHistoryItems(pageWindow, items, observedSink, message.requestId),
|
||||
};
|
||||
if (action === "onetalk.sync.conversation") {
|
||||
const conversationId = message.command.conversationId;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// 管理 OneTalk 页面进行中动作提示的 DOM 边界。
|
||||
|
||||
import { isObjectRecord } from "../../../lib/guards.ts";
|
||||
|
||||
export type TooltipColor = "neutral" | "info" | "warning" | "error";
|
||||
|
||||
export type OneTalkActionStatusTooltip = {
|
||||
start: (id: string, text: string, color?: TooltipColor) => void;
|
||||
update: (id: string, text: string, color?: TooltipColor) => boolean;
|
||||
close: (id: string) => boolean;
|
||||
};
|
||||
|
||||
type OneTalkActionStatusTooltipWindow = Pick<Window, "document"> & {
|
||||
__tradeMessageCenterOneTalk?: unknown;
|
||||
};
|
||||
|
||||
type ActionStatus = {
|
||||
color: TooltipColor;
|
||||
row: HTMLDivElement;
|
||||
};
|
||||
|
||||
const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip";
|
||||
const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row";
|
||||
const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color";
|
||||
const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral";
|
||||
const tooltipRefreshes = new WeakMap<OneTalkActionStatusTooltipWindow, () => void>();
|
||||
|
||||
const TOOLTIP_COLORS: Readonly<
|
||||
Record<TooltipColor, { background: string; border: string; text: string }>
|
||||
> = {
|
||||
neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" },
|
||||
info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" },
|
||||
warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" },
|
||||
error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" },
|
||||
};
|
||||
|
||||
const isTooltipColor = (value: unknown): value is TooltipColor => {
|
||||
return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value);
|
||||
};
|
||||
|
||||
const requireTooltipColor = (value: unknown): TooltipColor => {
|
||||
if (isTooltipColor(value)) return value;
|
||||
throw new TypeError("onetalk_action_status_tooltip_color_invalid");
|
||||
};
|
||||
|
||||
const statusRowStyles = (color: TooltipColor): string => {
|
||||
const theme = TOOLTIP_COLORS[color];
|
||||
return [
|
||||
"box-sizing: border-box",
|
||||
"width: 100%",
|
||||
"padding: 8px 12px",
|
||||
"border: 1px solid " + theme.border,
|
||||
"border-radius: 6px",
|
||||
"background: " + theme.background,
|
||||
"color: " + theme.text,
|
||||
"white-space: normal",
|
||||
"font-size: 13px",
|
||||
"line-height: 20px",
|
||||
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
"box-shadow: 0 2px 8px rgb(0 0 0 / 12%)",
|
||||
].join(";");
|
||||
};
|
||||
|
||||
const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => {
|
||||
status.color = color;
|
||||
status.row.textContent = text;
|
||||
status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color);
|
||||
status.row.style.cssText = statusRowStyles(color);
|
||||
};
|
||||
|
||||
const createStatusRow = (
|
||||
document: Document,
|
||||
id: string,
|
||||
text: string,
|
||||
color: TooltipColor,
|
||||
): ActionStatus => {
|
||||
const row = document.createElement("div");
|
||||
row.setAttribute(STATUS_ROW_ATTRIBUTE, "");
|
||||
row.dataset.tmcActionStatusId = id;
|
||||
const status = { color, row };
|
||||
updateStatusRow(status, text, color);
|
||||
return status;
|
||||
};
|
||||
|
||||
const createStatusContainer = (document: Document): HTMLDivElement => {
|
||||
const container = document.createElement("div");
|
||||
container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, "");
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("role", "status");
|
||||
container.style.cssText = [
|
||||
"position: fixed",
|
||||
"top: 0",
|
||||
"left: 50%",
|
||||
"transform: translateX(-50%)",
|
||||
"z-index: 2147483647",
|
||||
"display: flex",
|
||||
"flex-direction: column",
|
||||
"gap: 8px",
|
||||
"width: min(400px, calc(100vw - 96px))",
|
||||
"pointer-events: none",
|
||||
].join(";");
|
||||
return container;
|
||||
};
|
||||
|
||||
const findStatusContainer = (document: Document): HTMLDivElement | null => {
|
||||
return document.querySelector<HTMLDivElement>(`div[${STATUS_CONTAINER_ATTRIBUTE}]`);
|
||||
};
|
||||
|
||||
const ensureStatusContainer = (
|
||||
document: Document,
|
||||
statuses: ReadonlyMap<string, ActionStatus>,
|
||||
): HTMLDivElement => {
|
||||
const existing = findStatusContainer(document);
|
||||
const container = existing ?? createStatusContainer(document);
|
||||
if (!existing) {
|
||||
if (!document.body)
|
||||
throw new Error("onetalk_action_status_tooltip_document_body_unavailable");
|
||||
document.body.append(container);
|
||||
}
|
||||
for (const status of statuses.values()) container.append(status.row);
|
||||
return container;
|
||||
};
|
||||
|
||||
const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => {
|
||||
return (
|
||||
isObjectRecord(value) &&
|
||||
typeof value.start === "function" &&
|
||||
typeof value.update === "function" &&
|
||||
typeof value.close === "function"
|
||||
);
|
||||
};
|
||||
|
||||
const createTooltipFacade = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): OneTalkActionStatusTooltip => {
|
||||
const statuses = new Map<string, ActionStatus>();
|
||||
const refresh = (): void => {
|
||||
if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses);
|
||||
};
|
||||
tooltipRefreshes.set(pageWindow, refresh);
|
||||
const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => {
|
||||
if (statuses.has(id)) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const resolvedColor = requireTooltipColor(color);
|
||||
const status = createStatusRow(pageWindow.document, id, text, resolvedColor);
|
||||
statuses.set(id, status);
|
||||
const container = ensureStatusContainer(pageWindow.document, statuses);
|
||||
if (status.row.parentElement !== container) container.append(status.row);
|
||||
};
|
||||
const update = (id: string, text: string, color?: TooltipColor): boolean => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return false;
|
||||
const resolvedColor = color === undefined ? status.color : requireTooltipColor(color);
|
||||
updateStatusRow(status, text, resolvedColor);
|
||||
ensureStatusContainer(pageWindow.document, statuses);
|
||||
return true;
|
||||
};
|
||||
const close = (id: string): boolean => {
|
||||
const status = statuses.get(id);
|
||||
if (!status) return false;
|
||||
statuses.delete(id);
|
||||
status.row.remove();
|
||||
if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove();
|
||||
return true;
|
||||
};
|
||||
return { close, start, update };
|
||||
};
|
||||
|
||||
/** 读取已安装的页面动作提示 facade。 */
|
||||
export const readOneTalkActionStatusTooltip = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): OneTalkActionStatusTooltip | null => {
|
||||
const namespace = pageWindow.__tradeMessageCenterOneTalk;
|
||||
return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip)
|
||||
? namespace.tooltip
|
||||
: null;
|
||||
};
|
||||
|
||||
/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */
|
||||
export const installOneTalkActionStatusTooltip = (
|
||||
pageWindow: OneTalkActionStatusTooltipWindow,
|
||||
): void => {
|
||||
const existing = pageWindow.__tradeMessageCenterOneTalk;
|
||||
const namespace: Record<string, unknown> = isObjectRecord(existing) ? existing : {};
|
||||
if (isTooltipFacade(namespace.tooltip)) {
|
||||
tooltipRefreshes.get(pageWindow)?.();
|
||||
return;
|
||||
}
|
||||
namespace.tooltip = createTooltipFacade(pageWindow);
|
||||
pageWindow.__tradeMessageCenterOneTalk = namespace;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
// 安装 OneTalk 会话 ID 复制控件。
|
||||
|
||||
import { readCurrentConversationId } from "../page-context.ts";
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
|
||||
type OneTalkConversationIdCopyWindow = OneTalkPageWindow &
|
||||
Pick<Window, "clearTimeout" | "document" | "navigator" | "setTimeout"> & {
|
||||
MutationObserver?: typeof MutationObserver;
|
||||
};
|
||||
|
||||
const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy";
|
||||
const HEADER_MAX_BOTTOM_PX = 72;
|
||||
const BUTTON_LABEL = "复制会话 Id";
|
||||
const COPIED_LABEL = "已复制";
|
||||
const COPY_FAILED_LABEL = "复制失败";
|
||||
const BUTTON_RESTORE_DELAY_MS = 1_500;
|
||||
|
||||
const selectedConversationElement = (document: Document): Element | null => {
|
||||
const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]");
|
||||
return selected.length === 1 ? selected[0] : null;
|
||||
};
|
||||
|
||||
const headerTitleElement = (document: Document): Element | null => {
|
||||
const selected = selectedConversationElement(document);
|
||||
if (!selected) return null;
|
||||
const selectedRect = selected.getBoundingClientRect();
|
||||
const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY;
|
||||
const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => {
|
||||
if (element.children.length > 0 || !element.textContent?.trim()) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.bottom <= HEADER_MAX_BOTTOM_PX &&
|
||||
rect.left >= selectedRect.right &&
|
||||
rect.left < maxLeft * 0.75
|
||||
);
|
||||
});
|
||||
return (
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().left - right.getBoundingClientRect().left ||
|
||||
left.getBoundingClientRect().top - right.getBoundingClientRect().top,
|
||||
)[0] ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const updateButtonLabel = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
button: HTMLButtonElement,
|
||||
label: string,
|
||||
): void => {
|
||||
button.textContent = label;
|
||||
button.setAttribute("aria-label", `${label}会话 Id`);
|
||||
pageWindow.clearTimeout(Number(button.dataset.restoreTimer));
|
||||
button.dataset.restoreTimer = String(
|
||||
pageWindow.setTimeout(() => {
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
}, BUTTON_RESTORE_DELAY_MS),
|
||||
);
|
||||
};
|
||||
|
||||
const createCopyButton = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
conversationId: string,
|
||||
): HTMLButtonElement => {
|
||||
const button = pageWindow.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute(COPY_BUTTON_ATTRIBUTE, "");
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
button.title = BUTTON_LABEL;
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.dataset.conversationId = conversationId;
|
||||
button.style.cssText = [
|
||||
"margin-left: 8px",
|
||||
"padding: 2px 7px",
|
||||
"display: inline-flex",
|
||||
"align-items: center",
|
||||
"justify-content: center",
|
||||
"border: 1px solid #d8dce5",
|
||||
"border-radius: 4px",
|
||||
"background: #fff",
|
||||
"color: #315efb",
|
||||
"font-size: 12px",
|
||||
"line-height: 18px",
|
||||
"text-align: center",
|
||||
"cursor: pointer",
|
||||
"vertical-align: middle",
|
||||
].join(";");
|
||||
button.addEventListener("click", () => {
|
||||
const currentId = button.dataset.conversationId;
|
||||
if (!currentId) return;
|
||||
const clipboard = pageWindow.navigator.clipboard;
|
||||
if (!clipboard) {
|
||||
updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL);
|
||||
return;
|
||||
}
|
||||
void clipboard
|
||||
.writeText(currentId)
|
||||
.then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL))
|
||||
.catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL));
|
||||
});
|
||||
return button;
|
||||
};
|
||||
|
||||
const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => {
|
||||
const existing = pageWindow.document.querySelector<HTMLButtonElement>(
|
||||
`button[${COPY_BUTTON_ATTRIBUTE}]`,
|
||||
);
|
||||
const conversationId = readCurrentConversationId(pageWindow);
|
||||
if (!conversationId) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
existing.dataset.conversationId = conversationId;
|
||||
return;
|
||||
}
|
||||
const title = headerTitleElement(pageWindow.document);
|
||||
if (!title) return;
|
||||
title.append(createCopyButton(pageWindow, conversationId));
|
||||
};
|
||||
|
||||
/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */
|
||||
export const installOneTalkConversationIdCopyControl = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
): void => {
|
||||
let refreshScheduled = false;
|
||||
const refresh = (): void => {
|
||||
refreshScheduled = false;
|
||||
updateCopyControl(pageWindow);
|
||||
};
|
||||
const scheduleRefresh = (): void => {
|
||||
if (refreshScheduled) return;
|
||||
refreshScheduled = true;
|
||||
pageWindow.setTimeout(refresh, 0);
|
||||
};
|
||||
const observer = pageWindow.MutationObserver
|
||||
? new pageWindow.MutationObserver(scheduleRefresh)
|
||||
: null;
|
||||
if (observer && pageWindow.document.documentElement) {
|
||||
observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// 定位 OneTalk React 文件上传器。
|
||||
|
||||
import { isObjectRecord } from "../../../lib/guards.ts";
|
||||
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
|
||||
export type OneTalkImageUploader = {
|
||||
owner: Record<string, unknown>;
|
||||
sendFileToOss: (input: Record<string, unknown>) => unknown;
|
||||
sendFile: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
/** 查找页面 React file input 对应的上传器。 */
|
||||
export const findReactFileUploader = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
): OneTalkImageUploader | null => {
|
||||
const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []);
|
||||
for (const input of inputs) {
|
||||
for (
|
||||
let node: (Element & { parentElement: Element | null }) | null = input;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
|
||||
const fiber = fiberKey ? (node as unknown as Record<string, unknown>)[fiberKey] : null;
|
||||
for (
|
||||
let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord(
|
||||
fiber,
|
||||
)
|
||||
? fiber
|
||||
: null;
|
||||
current;
|
||||
current = isObjectRecord(current.return) ? current.return : null
|
||||
) {
|
||||
const state = current.stateNode;
|
||||
if (
|
||||
isObjectRecord(state) &&
|
||||
typeof state.sendFileToOss === "function" &&
|
||||
typeof state.sendFile === "function"
|
||||
) {
|
||||
return {
|
||||
owner: state,
|
||||
sendFileToOss: state.sendFileToOss as (
|
||||
input: Record<string, unknown>,
|
||||
) => unknown,
|
||||
sendFile: state.sendFile as (...args: unknown[]) => unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// 订阅 OneTalk 当前会话选择点击。
|
||||
|
||||
/** 订阅当前页面的点击事件以刷新会话身份。 */
|
||||
export const subscribeToDocumentClicks = (
|
||||
document: Pick<Document, "addEventListener"> | undefined,
|
||||
onClick: () => void,
|
||||
): void => {
|
||||
document?.addEventListener("click", onClick);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// 读取 OneTalk SPA 当前选中会话的 DOM 边界。
|
||||
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
|
||||
/** 读取页面中当前选中会话的非空 ID 列表。 */
|
||||
export const readSelectedConversationIdsFromDom = (pageWindow: OneTalkPageWindow): string[] => {
|
||||
if (!pageWindow.document) return [];
|
||||
try {
|
||||
return Array.from(
|
||||
pageWindow.document.querySelectorAll(".contact-item-container.selected[data-cid]"),
|
||||
)
|
||||
.map((element) => element.getAttribute("data-cid")?.trim() ?? "")
|
||||
.filter((value) => value.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import type { OneTalkOutboundContent } from "@trade-message-center/onetalk-contr
|
||||
import { isObjectRecord } from "../../lib/guards.ts";
|
||||
import { traceOneTalkImageSend } from "../diagnostics/image-send-trace.ts";
|
||||
import type { PageCommandResult } from "../page-bridge/model.ts";
|
||||
import { findReactFileUploader, type OneTalkImageUploader } from "./dom/react-file-uploader.ts";
|
||||
import { readChannelAccountId } from "./page-context.ts";
|
||||
import type { OneTalkPageWindow } from "./model.ts";
|
||||
import {
|
||||
@@ -17,11 +18,7 @@ type MediaSendObservationCorrelator = Pick<
|
||||
"executeImage" | "executeFile"
|
||||
>;
|
||||
|
||||
export type OneTalkImageUploader = {
|
||||
owner: Record<string, unknown>;
|
||||
sendFileToOss: (input: Record<string, unknown>) => unknown;
|
||||
sendFile: (...args: unknown[]) => unknown;
|
||||
};
|
||||
export type { OneTalkImageUploader } from "./dom/react-file-uploader.ts";
|
||||
|
||||
type FinalImageMetadata = {
|
||||
sizeBytes: number;
|
||||
@@ -149,45 +146,6 @@ const isCurrentImageTarget = (
|
||||
);
|
||||
};
|
||||
|
||||
const uploaderFromReactFileInput = (pageWindow: OneTalkPageWindow): OneTalkImageUploader | null => {
|
||||
const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []);
|
||||
for (const input of inputs) {
|
||||
for (
|
||||
let node: (Element & { parentElement: Element | null }) | null = input;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
|
||||
const fiber = fiberKey ? (node as unknown as Record<string, unknown>)[fiberKey] : null;
|
||||
for (
|
||||
let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord(
|
||||
fiber,
|
||||
)
|
||||
? fiber
|
||||
: null;
|
||||
current;
|
||||
current = isObjectRecord(current.return) ? current.return : null
|
||||
) {
|
||||
const state = current.stateNode;
|
||||
if (
|
||||
isObjectRecord(state) &&
|
||||
typeof state.sendFileToOss === "function" &&
|
||||
typeof state.sendFile === "function"
|
||||
) {
|
||||
return {
|
||||
owner: state,
|
||||
sendFileToOss: state.sendFileToOss as (
|
||||
input: Record<string, unknown>,
|
||||
) => unknown,
|
||||
sendFile: state.sendFile as (...args: unknown[]) => unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const metadataFrom = (value: unknown): FinalImageMetadata | null => {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
const sizeBytes = value.nodeSize ?? value.size;
|
||||
@@ -415,7 +373,7 @@ const sendOneTalkMedia = async (input: {
|
||||
input.runtime?.cancel ?? ((timer: ImageSendTimer) => globalThis.clearTimeout(timer)),
|
||||
};
|
||||
const context = imageTargetContext(input.pageWindow, input.conversationId);
|
||||
const uploader = (input.findUploader ?? uploaderFromReactFileInput)(input.pageWindow);
|
||||
const uploader = (input.findUploader ?? findReactFileUploader)(input.pageWindow);
|
||||
if (!context) {
|
||||
traceOneTalkImageSend(input.requestId, {
|
||||
stage: "target_context_resolved",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
isOneTalkMessageContent,
|
||||
normalizeOneTalkProductUrl,
|
||||
ONETALK_CONTENT_VERSION,
|
||||
ONETALK_MAX_MEDIA_SIZE_BYTES,
|
||||
type OneTalkMessageContent,
|
||||
@@ -215,6 +216,17 @@ const optionalUrl = (
|
||||
|
||||
const normalizeText = (content: Record<string, unknown>): OneTalkRawContentDecodeResult => {
|
||||
const text = isObjectRecord(content.text) ? content.text.content : undefined;
|
||||
const product = normalizeOneTalkProductUrl(text);
|
||||
if (product !== null) {
|
||||
const normalized = {
|
||||
version: ONETALK_CONTENT_VERSION,
|
||||
kind: "product" as const,
|
||||
...product,
|
||||
};
|
||||
return isOneTalkMessageContent(normalized)
|
||||
? { status: "decoded", content: normalized }
|
||||
: { status: "ignored" };
|
||||
}
|
||||
const normalized = { version: ONETALK_CONTENT_VERSION, kind: "text" as const, text };
|
||||
return isOneTalkMessageContent(normalized)
|
||||
? { status: "decoded", content: normalized }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user