Publish current project state

This commit is contained in:
2026-09-14 10:47:21 +08:00
parent 0dd188c46f
commit 4ad56ee9f8
186 changed files with 6289 additions and 1657 deletions
@@ -1,7 +1,7 @@
# 商务通分类同步与会话分类配置实施计划
> 日期:2026-09-11
> 状态:首版实现完成,待真实商务通账号灰度验证
> 状态:首版本地实现完成,外部协议/真实商务通账号灰度验证仍待完成
> 后续能力缺口修复:[`2026-09-12-shangwutong-capability-gap-repair-plan.md`](2026-09-12-shangwutong-capability-gap-repair-plan.md)
> 关联调研:[`docs/research/2026-09-11-shangwutong-pc-classification-protocol.md`](../research/2026-09-11-shangwutong-pc-classification-protocol.md)
@@ -236,10 +236,10 @@ shangwutong_classification_caches
- Connector 回调客户端:`channels/shangwutong/internal/gochat/classifications.go`;
- Connector 会话操作:`channels/shangwutong/internal/delivery/outbound.go`;
- Connector webhook 分发:`channels/shangwutong/internal/httpapi/server.go`;
- GoChat 缓存模型与迁移:`backend/internal/model/channel_shangwutong_classification_cache.go`、`backend/migrations/000087_*`;
- GoChat API 与任务:`backend/internal/handler/api/v1/shangwutong_connector_handler.go`、`backend/internal/service/shangwutong_webhook_delivery.go`;
- GoChat 缓存模型与迁移:`backend/internal/model/channel_shangwutong_classification_cache.go`、`backend/migrations/000087_*`;同步事件使用 `last_sync_event_id` 做代际保护;
- GoChat API 与任务:`backend/internal/handler/api/v1/shangwutong_connector_handler.go`、`backend/internal/service/shangwutong_webhook_delivery.go`;分类回调按 operation 保存状态并对 customer color 做同 contact-inbox 会话传播;
- 前端同步按钮:`frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue`;
- 前端会话下拉:`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`。
- 前端会话下拉:`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`;按会话/操作 generation 丢弃过期请求,分别展示 chatkind/customer color 的 pending、失败和不确定状态。
验证结果:
@@ -249,4 +249,4 @@ cd backend && GOCHAT_TEST_DB=sqlite go test ./...
cd frontend && pnpm build
```
均已通过。前端修改文件的 ESLint 无错误;现有设置组件保留一条既有的动态 i18n key warning。真实商务通账号的灰度协议验证仍待安排。
均已通过。另已通过后端/Connector 的分类回调幂等、旧事件隔离、客户颜色同 contact-inbox 传播和无关属性保留回归测试;前端修改文件的 ESLint 无错误。真实商务通账号的灰度协议验证仍待安排,RESET、独立远程资料事件及 kind=52 历史能力仍不开放。
@@ -1,9 +1,11 @@
# 商务通消息与联系人/会话资料能力缺口修复计划
> 日期:2026-09-12
> 状态:已完成文档补缺审查;修复待实施,范围决策待确认;现有自动化基线通过不代表新增能力完成。
> 状态:**部分实现,实施后审查未通过,待整改**。现有本地测试通过不覆盖新发现的回调、事务、CID、恢复和 UI 缺陷,不可仅以“待灰度”描述。未冻结的能力继续关闭。
>
> 审查基线:`main@6e62f5094fa2381c5b283eeb9be3f94a95096f1c` 加 2026-09-12 当前未提交工作树(包括分类相关改动),不是纯 HEAD 或已发布版本。
> 整改入口:[`2026-09-13-shangwutong-review-remediation-plan.md`](2026-09-13-shangwutong-review-remediation-plan.md),列出 F01–F18 的触发场景、修复方向与验收门槛。本轮只落地文档,尚未修复这些问题。
>
> 初始审查基线:`main@6e62f5094fa2381c5b283eeb9be3f94a95096f1c` 加 2026-09-12 未提交工作树。实施后复审基线:`main@0dd188c46f41e9d89644e25fe3c0b6a06ed9ae73` 加 2026-09-13 本次未提交变更(含未跟踪文件),均不是已发布版本。
>
> 总计划:[`2026-07-31-shangwutong-connector-development-plan.md`](2026-07-31-shangwutong-connector-development-plan.md)
> 分类计划:[`2026-09-11-shangwutong-classification-sync-plan.md`](2026-09-11-shangwutong-classification-sync-plan.md)
@@ -46,41 +48,44 @@
| ID | 优先级 | 状态 | 缺口与目标 |
| --- | ---: | --- | --- |
| SWT-R01 | P0 | 待产品确认 | 冻结 `cnote` 是否纳入范围,以及它与 GoChat Contact Note 的关系;若不做,必须在 UI/API/文档中明确“不支持”。 |
| SWT-R02 | P0 | 待实施 | 先验证并阻断现有改名发送空 `cnote` 造成远程备注丢失的风险,此安全项不依赖 R01;双向编辑仅在 R01 批准后补齐输入、持久化、出入站映射、冲突和失败处理。 |
| SWT-R03 | P1 | 待实施 | 补齐联系人姓名修改的最终状态回写。远程 rename 的成功、失败、不确定结果必须能回到 GoChat,并在 UI 中呈现 pending/failed/uncertain;不能在 Connector 接受 202 后视为远程成功。 |
| SWT-R04 | P1 | 待实施 | 处理姓名修改与 CID 获取之间的竞态。缺少 CID 时不得静默丢弃;应延迟到 CID 可用后重放,或向用户返回明确不可执行状态。 |
| SWT-R02 | P0 | 部分实现(协议安全门禁未闭合;F01) | 先验证并阻断现有改名发送空 `cnote` 造成远程备注丢失的风险,此安全项不依赖 R01;双向编辑仅在 R01 批准后补齐输入、持久化、出入站映射、冲突和失败处理。 |
| SWT-R03 | P1 | 部分实现(状态/结果恢复/UI 缺口;F02–F04/F09/F10/F14) | 补齐联系人姓名修改的最终状态回写。远程 rename 的成功、失败、不确定结果必须能回到 GoChat,并在 UI 中呈现 pending/failed/uncertain;不能在 Connector 接受 202 后视为远程成功。 |
| SWT-R04 | P1 | 部分实现(断连重试/晚到 CID 过期缺陷;F04/F08) | 处理姓名修改与 CID 获取之间的竞态。缺少 CID 时进入 durable operation 等待队列;CID 到达唤醒同一 `(account_id, SID)` 的待处理 rename,超过 24 小时回写 `cid_wait_timeout`,不受普通 10 次重试上限影响。 |
| SWT-R05 | P1 | 待实施 | 补齐商务通远程 `chatkind` → GoChat 的入站同步,并区分远程事件与 GoChat 发起操作的确认回调。 |
| SWT-R06 | P1 | 待实施 | 明确客户颜色的渠道客户级作用域。至少以 `(account_id, inbox_id, CID)` 隔离,关联 `(inbox_id, source_id)`;在同一远程身份范围传播到相关会话,统一 ID/名称,禁止裸 CID 跨站点归并。 |
| SWT-R06 | P1 | 部分实现(CID 范围/原子传播待修;F06/F07) | 明确客户颜色的渠道客户级作用域。至少以 `(account_id, inbox_id, CID)` 隔离,关联 `(inbox_id, source_id)`;在同一远程身份范围传播到相关会话,统一 ID/名称,禁止裸 CID 跨站点归并。 |
| SWT-R07 | P1 | 待确认 | 审核会话分类修改的角色权限;Connector 服务鉴权和资源范围已有,但分类修改方法未见独立角色级限制。 |
| SWT-R08 | P2 | 待实施 | 在商务通会话联系人侧栏补充 GoChat 原生 Contact Label 的入口(如产品需要),但保持其与商务通颜色分类完全独立。 |
| SWT-R09 | P2 | 待实施 | 当前联系人备注 HTTP 路由实际使用 `ContactService → NoteRepo → notes`;先盘点 `contact_notes` 历史数据及写入方,无迁移/回滚证据不得删表;补齐 API/store 和会话侧栏、联系人详情两处编辑 UI。 |
| SWT-R09 | P2 | 部分实现(编辑数据安全/草稿待修;F12/F13;迁移待盘点) | 当前联系人备注 HTTP 路由实际使用 `ContactService → NoteRepo → notes`;先盘点 `contact_notes` 历史数据及写入方,无迁移/回滚证据不得删表;补齐 API/store 和会话侧栏、联系人详情两处编辑 UI。 |
| SWT-R10 | P2 | 待确认 | 分别冻结 kind=52 被动历史正文导入、主动历史拉取、消息编辑、反应四项范围;当前 kind=52 仅提取 CID,不导入历史正文,亦无完整主动分页历史 API。 |
| SWT-R11 | P1 | 待实施 | 增加跨系统端到端测试和真实账号灰度验证,覆盖姓名、`cnote`、CID、分类、多会话和失败重试。 |
| SWT-R12 | P2 | 待实施 | 补充 README、runbook 和分类计划中的能力矩阵、错误状态、未支持项和灰度验收证据。 |
| SWT-R13 | P1 | 待实施 | 修复原生会话标签更新返回对象、前端预期数组的契约不匹配;验证普通更新、批量增删、筛选、GET、序列化的数据源一致性。 |
| SWT-R14 | P1 | 待实施 | 资料操作和 catalog 同步补持久化关联、重复/乱序保护、原子更新及最终状态;验证事件 ID 不等于验证操作真实存在,旧回调不得覆盖新值。 |
| SWT-R15 | P1 | 待实施 | 补联系人合并/软删除、inbox 删除/停用/重绑定、配置版本变化时,资料归属与 pending 操作/旧回调的迁移或终止规则。 |
| SWT-R16 | P1 | 待实施 | 补原生姓名/备注/标签/合并/删除的真实路由权限、父联系人有效性与跨租户校验;复用现有六个二值权限,不新增三态或 CE/EE 限制。 |
| SWT-R17 | P1 | 待实施 | 补分类组件切换会话竞态、独立操作状态、延迟确认、catalog 失效及刷新后恢复;旧请求不得更新新会话 UI。 |
| SWT-R18 | P1 | 待实施 | 复用现有操作结果队列补 rename、结果重试耗尽补偿、catalog 回传失败可恢复性;保留不确定证据,补账号级队头阻塞监控与恢复验收。 |
| SWT-R12 | P2 | 部分实现(已落地整改文档;F18) | README、runbook、分类计划和能力矩阵需随整改同步;监控实现与完成声明须核实,真实灰度证据仍待补。 |
| SWT-R13 | P1 | 部分实现(响应数组已修,双数据源未统一;F16) | 修复原生会话标签更新返回对象、前端预期数组的契约不匹配;验证普通更新、批量增删、筛选、GET、序列化的数据源一致性。 |
| SWT-R14 | P1 | 部分实现(回调/代际/原子提交缺陷;F02–F07/F11) | 资料操作和 catalog 同步补持久化关联、重复/乱序保护、原子更新及最终状态;分类回调必须匹配已持久化 operation 的 event、目标和值,未知/错目标回调拒绝。 |
| SWT-R15 | P1 | 部分实现(待生命周期验收) | 补联系人合并/软删除、inbox 删除/停用/重绑定、配置版本变化时,资料归属与 pending 操作/旧回调的迁移或终止规则。 |
| SWT-R16 | P1 | 部分实现(待全量权限验收) | 补原生姓名/备注/标签/合并/删除的真实路由权限、父联系人有效性与跨租户校验;复用现有六个二值权限,不新增三态或 CE/EE 限制。 |
| SWT-R17 | P1 | 部分实现(持久状态恢复/独立字段待修;F15) | 补分类组件切换会话竞态、独立操作状态、延迟确认、catalog 失效及刷新后恢复;旧请求不得更新新会话 UI。 |
| SWT-R18 | P1 | 部分实现(不确定/耗尽补偿/catalog 恢复待实现;F03/F08–F11/F18) | 复用现有操作结果队列补 rename、结果重试耗尽补偿、catalog 回传失败可恢复性;保留不确定证据,补账号级队头阻塞监控和人工结果重放验收。 |
### 3.1 审查确认的具体缺口
以下为静态证据,不表示已通过失败复现测试;实现时以符号和实际工作树复核,不能将本节直接计为修复完成。
以下按 2026-09-13 实施后复审更新;除分类同 event 异目标值已运行复现外,均为源码/调用链证据,不表示已执行对应并发或端到端测试。修复用例、顺序与证据口径见整改计划;不能将本节直接计为完成。
| 证据位置 | 当前事实 | 补充约束 |
| --- | --- | --- |
| `ConversationHandler.UpdateLabels`;`dashboard/store/modules/conversationLabels.js`;上游 `conversations/labels/create.json.jbuilder` | 更新返回 `payload: {conversationId, labels}`,store 直接保存 payload,消费者需要数组;上游返回数组 | R13 修复真实响应;mock action 测试通过不足以证明兼容 |
| `ShangwutongConnectorHandler.UpdateClassificationStatus` | 校验幂等 header 和 event_id 的拼接,但未核对持久化 pending 操作;读改写整份 `additional_attributes` | R14 绑定实际操作、目标及字段,防伪造关联、重复、乱序和丢失并发属性 |
| 同上;`UpdateClassificationCatalog/UpdateClassificationSyncStatus` | 会话分类与颜色共享一个状态槽;catalog 仅记最近成功 event_id,失败回调无代际约束 | 不同操作不得互相吞状态,旧失败不能覆盖新同步成功,重复回调不刷新版本 |
| `ShangwutongClassifications.vue` 的 `waitForConfirmation/fetchClassifications` | 保存后最多 10 次、间隔 500ms 查询;异步流程持续读取当前 props;未确认后仍可再次操作 | 固定请求目标并忽略失效响应;区分仍 pending、failed、uncertain,刷新后可恢复,不能因前端等待结束判失败 |
| `ContactLabels.vue` 的 mounted/watch | 初次加载依赖路由 contactId,而会话侧栏可能只传 prop | R08 复用组件前补首次 prop 加载、切换隔离;全量替换标签前必须读到完整旧集合 |
| `ContactService.GetNote/UpdateNote/DeleteNote`;上游 contacts/base_controller | 单条操作检查备注归属,但未统一加载有效父联系人;上游先加载账户内联系人 | R16 补软删除后的单条访问负向用例,与列表/新增语义一致 |
| Connector `swt/operations.go:ChangeContactName`;参考 Android `RenamedThread.java` | Go 实现显式发送 `cnote: ""`,Android 传入调用者提供的备注;服务器清空语义尚未实测 | R02 不能断言一定清空或认定空值安全;缺省/空串/原值三种形态必须有协议证据 |
| Connector `delivery/inbound.go`、`db/queries/inbound.sql` | 已有 CID→GoChat 的延期回调,联系人尚未创建可等待至事件创建后 24 小时;不是缺 CID 时的人工改名意图队列 | R04 保留已有机制,仅补相反方向缺口及到期处置 |
| `db/queries/operations.sql`、`delivery/outbound.go` | 结果队列目前仅覆盖分类/颜色;回调达 10 次后进入 failed,重启仅恢复 syncing;pending/syncing 结果会阻挡同账号后续操作 | R18 扩展现有机制,补仅重放结果的人工补偿,不重复远程写 |
| 同上 | uncertain 观察窗口为 5 分钟,超时写 failed/`uncertain_timeout`;依赖的 updated_at 也会被回调重试更新 | 超时仍非远程拒绝证据;固定截止需独立时间字段,不能让重试无限延后观察窗口 |
| `account/manager.go:SyncClassifications`、`httpapi/server.go` | catalog 同步直接执行并回传,失败状态回传错误被忽略,不走分类操作结果队列 | R18 单独补 catalog 故障恢复,不能套用业务操作持久回传保证 |
| `ConversationHandler.UpdateLabels`、conversation label repositories/store | 普通响应已改为数组;普通更新与批量/关联删除仍使用不同数据源 | R13/F16 补混合真实路由一致性 |
| `ShangwutongConnectorHandler.UpdateClassificationStatus`、联系人结果 handler | 分类已检查事件但未拒绝同 event 异目标值;联系人无实际 operation 也能接受结果 | R14/F02 锁内绑定真实操作与不可变目标 |
| 分类 pending helper、`shangwutong_webhook_delivery.go` | 分类状态与任务仍分开提交;资料 webhook 投递永久失败缺少对应状态收尾 | F03 同事务入队、事件匹配补偿,区分明确拒绝与投递不明 |
| `shangwutong_contact_listener.go` | 改名状态与入队已同事务;但用请求时间与旧回调 updated_at 比较,可能发送新操作却不接纳其状态 | F04 使用不可变请求代际 |
| 分类颜色传播、catalog handlers | 颜色主要按 contact-inbox 而非同 inbox/CID 多 SID;peer 先写后发现 stale 可部分提交;catalog 代际检查与 Save 非原子 | F05–F07 补原子代际、传播与身份范围 |
| `ShangwutongClassifications.vue` | 已捕获请求目标和区分状态槽;刷新不恢复持久状态,一字段失败可重置另一字段,查询结束推断 uncertain | F15 补独立状态与组件用例 |
| `ContactLabels.vue`、`ContactService.GetNote/UpdateNote/DeleteNote` | 已改进 prop 首次加载和有效父联系人校验 | 保留修复;R16 完整权限/生命周期验收仍待完成 |
| 两处 `ContactNotes.vue`、`Editor.vue` | 新编辑弹窗继承 200 字符截断;会话编辑快捷键走 create;切换/失败草稿仍有缺陷 | F12/F13 防止数据损失并补组件测试 |
| Connector `ChangeContactName`;参考 `RenamedThread.java` | 已省略空 cnote,但省略语义未验证时仍发送远程改名 | R02/F01 必须有实际 fail-closed 门禁,不断言已证实备注损坏 |
| Connector `processOperation`、CID wake SQL | 已有等待与唤醒;检查在 WithSession 内,断连可提前耗尽;晚到 CID 可绕过 24h 过期 | F08 分离等待与发送预算并持久执行截止 |
| operation 结果 SQL、`processOperationResult` | 结果队列已扩展 rename;uncertain 超时仍变 failed,截止受 updated_at 影响;耗尽后无结果补偿入口 | F09/F10 保留证据、固定观察截止、仅结果补偿 |
| `SyncClassifications`、`httpapi/server.go` | catalog 同步直接执行,失败状态回传错误被忽略 | F11 补持久恢复,不套用业务操作结果保证 |
| 联系人 public API、serializer、前端改名入口 | 来源 header 与事件测试尚未贯通真实路径;已序列化改名状态无前端消费者 | F14/F17 补实际 UI/路由证据,不宣称现有路径已发生循环 |
| Connector metrics 与 runbook | 部分规划指标无生产者 | F18 如实标明待实现,并补操作队列告警/补偿 |
### 3.2 实施前必须冻结的契约
@@ -107,10 +112,10 @@
- [ ] 先为“仅改姓名、不损坏已有远程备注”建立协议门禁;协议未知时阻止危险请求,不能等待双向备注获批再处理。
- [ ] 仅在 R01 批准后扩展 `contact_updated` 及消费端 `cnote` 解码、入站映射和持久化;单加 webhook 字段不足以打通链路。
- [ ] 为 rename 建立可查询的 GoChat 结果状态:`pending`、`succeeded`、`failed`、`uncertain`;复用 `outbound_operations` 终态/结果回传,不新建一套通用调度系统。
- [ ] 结果回调具有幂等键和持久重试;远程已执行但响应丢失维持不确定、禁止自动重发副作用。超过结果补传上限可告警并仅重放原结果,重启不等于死信已恢复。
- [ ] CID 缺失时将姓名修改放入待处理队列;CID 到达后按联系人/inbox/source 幂等重放。
- [ ] 对远程入站姓名/备注更新增加来源标记,避免把远程同步误判为新的人工修改。
- [ ] 为 rename 建立可查询且 UI 可见的 `pending/succeeded/failed/uncertain`;已有结果队列仍需修复关联、代际和 UI(F02/F04/F14)。
- [ ] 结果回调幂等且持久重试;响应丢失保持不确定,不重复远程副作用;实现耗尽告警及仅结果补偿(F09/F10/F18),不是仅待演练。
- [ ] CID 缺失保留操作、到达唤醒、24h 过期;补断连重试/晚到 CID/连续改名和重启用例(F04/F08),既有 helper 测试不代表完整等待策略通过。
- [ ] 来源标记须通过真实 Connector public API 到保存/事件/listener 链路验证(F17);当前路径不发事件不能被描述为已验证完整事件传播。
### 阶段 2:商务通分类一致性
@@ -122,8 +127,8 @@
- [ ] 分类修改失败/不确定时保留明确状态,不显示虚假成功。
- [ ] 评估并实现 `RESET` 清除能力;协议未验证前继续保持禁用。
- [ ] 为分类修改补充角色权限校验和跨 inbox/跨 account 负向测试。
- [ ] R14:保存实际 pending 操作并核对结果关联;catalog 请求/回调按代际处理,原子更新属性,不丢并行消息/分类产生的无关字段。
- [ ] R17:分类与颜色分别展示已确认值、请求值、pending/失败/不确定;切换会话/inbox/卸载后旧查询不可污染当前 UI。catalog 名称变更、ID 删除/未知/过期和失败后旧缓存的可用策略须明确。
- [ ] R14:保存实际操作并核对结果目标;catalog 代际和传播写入原子化,补 F02–F07/F11 的失败与并发回归,不丢无关属性。
- [ ] R17:分别恢复和展示已确认值、请求值及持久状态;仅重置失败字段,停止前端等待不改变远程结果;补 F15 组件/刷新/切页/catalog 失效用例。
### 阶段 3:GoChat 原生资料能力
@@ -191,16 +196,16 @@
| 实时消息及最终状态 | 已有链路;本轮只跑本地回归,未重新验证所有真实消息类型 | R10/R11,V23/V24 |
| kind=52 被动历史 | 仅提取 CID,不导入历史正文;保留原始事件不等于页面可读历史 | R10,V21 |
| 主动历史拉取、编辑、反应 | 未见完整能力;逐项待范围确认,不能与撤回混同 | R10,V22 |
| 人工姓名出站 | 已有异步执行;缺最终状态、缺 CID 意图保全,另有空备注风险 | R02–R04/R18,V01/V03–V06 |
| 远程姓名/备注入站 | 事件字段/来源与冲突契约待验证,不把普通消息带姓名当作完整资料事件同步 | R01/R02,V02/V07 |
| 人工姓名出站 | 已有 durable 链路;协议安全门禁、CID 截止、结果恢复及 UI 展示未闭合,不能按完成验收 | R02–R04/R18,V01/V03–V06 |
| 远程姓名/备注入站 | 已添加来源 header/事件字段;实际 public API 走直接保存,完整事件传播未验证;独立资料事件语义仍待证据 | R01/R02,V02/V07 |
| `cnote` 双向编辑 | 未实现、未获范围确认;独立于 Contact Note | R01/R02,V01/V02 |
| 分类 catalog | 有读取/缓存/手工同步;失败回传和同步代际保证不完整 | R14/R18,V11/V12 |
| 分类 catalog | 读取/缓存/手工同步已有;已补 login 返回字段作为真实服务器的 catalog 来源,并支持双重 URL 解码/尾分隔符;失败回传和同步代际保证仍不完整 | R14/R18,V11/V12 |
| 会话分类出站 / 远程入站 | 出站及结果队列已有;独立远程修改事件尚待证据与实现 | R05/R14,V07–V09 |
| 客户颜色入站/出站 | kind=29 当前只写会话文本属性;出站已有,客户级传播和 ID/名称一致性未完成 | R06,V10/V12 |
| 客户颜色入站/出站 | 已确认真实 kind=29 载荷为数字 color ID(本次为 `10`);当前仍只写会话文本属性,出站已有,客户级传播和 ID/名称一致性未完成 | R06,V10/V12 |
| 分类/颜色清除 | 出站 RESET 仍禁用,需单独协议门禁 | R05/R06,V13 |
| 原生 Contact Label | 本地能力已有;商务通侧栏入口待确认及首次加载修复 | R08,V16 |
| 原生 Conversation Label | 本地链路已有,但响应与 store 不匹配,数据源一致性待核对 | R13,V15 |
| 原生 Contact Note | 当前 HTTP 使用 notes;编辑 API/store/UI、历史表安全及有效父联系人待补 | R09/R16,V17/V18 |
| 原生 Contact Label | 本地能力已有,prop 首次加载保护已改进;商务通侧栏入口仍待产品确认,组件验收待补 | R08,V16 |
| 原生 Conversation Label | 响应数组已修;普通/批量/关联删除仍有双数据源不一致,未完成完整 R13 | R13,V15 |
| 原生 Contact Note | 当前 HTTP 使用 notes;update API/store 和父联系人校验已有,编辑截断/快捷键/草稿隔离待修,历史表盘点仍待补 | R09/R16,V17/V18 |
| CRM 标签 | 保持独立,本期不新增映射或同步 | R12,V19 |
### 6.2 必须新增或补齐的验收矩阵
@@ -235,45 +240,57 @@
| V24 | R10/R11 | 文本/图片/文件/语音、撤回先后、输入状态及会话控制回归;保留幂等与 progress/sent/failed/uncertain,资料修复不改变消息语义 | 现有自动化+授权真实账号 |
| V25 | R02/R11/R18 | 全链路以操作 ID 关联但不记录明文姓名/备注/token/签名;日志、错误/UI 不泄露跨租户数据,补偿可审计 | 日志检查+负向测试 |
### 6.3 本轮实际执行的验证(2026-09-12)
### 6.3 实施阶段历史验证记录(2026-09-13)
以下命令从仓库根目录分别运行;只验证当前工作树既有代码,**本轮未新增业务代码或回归测试**。
以下保留上一轮实施阶段的命令与结果,不代表实施后审查通过,也不是本次文档落地重新执行的记录。新增失败用例和覆盖缺口见下方复审结论;真实商务通协议和真实账号不在本地测试范围。
```bash
# Connector 全部现有测试;额外对关键包禁用缓存复跑
(cd channels/shangwutong && go test ./...)
(cd channels/shangwutong && go test -count=1 ./internal/swt ./internal/delivery ./internal/store ./internal/httpapi ./internal/account)
# Connector:全包 race、vet 和无缓存测试
(cd channels/shangwutong && go test -race ./...)
(cd channels/shangwutong && go vet ./...)
# 后端:SQLite 定向;不包含生产 PG 并发/迁移验收
(cd backend && GOCHAT_TEST_DB=sqlite go test ./internal/service ./internal/handler/api/v1 \
-run 'Test.*(Shangwutong|ContactUpdate|ContactNote|ContactLabel|ConversationLabel)' -count=1)
# 后端:全模块 SQLite 测试、vet 和构建
(cd backend && GOCHAT_TEST_DB=sqlite go test ./... -count=1)
(cd backend && go vet ./...)
(cd backend && go build ./...)
# 前端:既有 store 与 inbox 设置测试,不包含尚未补的分类组件测试
(cd frontend && TZ=UTC pnpm exec vitest run \
app/javascript/dashboard/store/modules/specs/contactLabels \
app/javascript/dashboard/store/modules/specs/contactNotes \
app/javascript/dashboard/store/modules/specs/conversationLabels \
app/javascript/dashboard/routes/dashboard/settings/inbox/channels/specs/Shangwutong.spec.js \
--maxWorkers=2 --minWorkers=1)
# 前端:全量测试、ESLint、生产构建
(cd frontend && TZ=UTC pnpm test -- --run --maxWorkers=2 --minWorkers=1)
(cd frontend && pnpm exec eslint \
app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue \
app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue \
app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue \
app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue \
app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue \
app/javascript/dashboard/store/modules/contactNotes.js \
app/javascript/dashboard/store/modules/contactLabels.js \
app/javascript/dashboard/api/contactNotes.js)
(cd frontend && pnpm run build)
```
| 检查 | 本轮结果 | 不覆盖的内容 |
| --- | --- | --- |
| Connector 全套及五个关键包无缓存复跑 | 通过 | 真实服务 cnote/事件编号/RESET/历史语义;新增 V01–V25 |
| 后端两个包定向测试 | 通过 | 完整 RBAC 路由、PG 并发/迁移、修复后的新契约 |
| 前端 | 10 文件、33 测试通过 | 标签真实后端响应、分类组件竞态、备注编辑和浏览器 E2E |
| 上游及本地源码对照 | 已核查并登记 §3.1 | 静态缺口不等于已运行失败复现测试 |
| Connector race/vet/全包测试 | 通过 | 真实服务 cnote/事件编号/RESET/历史语义 |
| 后端全模块 SQLite 测试 | 通过 | 真实 PostgreSQL 并发/迁移、全量 RBAC 灰度 |
| 后端 vet/build | 通过 | 真实远程服务 |
| 前端全量测试 | 352 文件、3451 测试通过 | 浏览器 E2E;存在依赖 source-map 与既有 jsdom warning |
| 前端 ESLint/build | 通过;构建转换 3469 modules | 真实商务通账号 |
| 实施阶段新增 Connector/后端定向回归 | 既有用例通过;不代表整改反例通过 | 同 event 异值、完整来源链路、分类入队故障、断连/晚到 CID、PostgreSQL 并发、真实账号/补偿 |
本地基线日志/改前备份位于 `/tmp/gochat-swt-plan-review-20260912/`(临时证据,不是 CI 长期归档);关键包无缓存复跑记录见本次 Connector 只读审查产物。合入/发布前必须把有效验收证据归档到团队可访问的 CI 或报告位置,并记录确切提交/工作树版本。原生标签 action mock 通过不能抵消 R13 的真实契约缺口。
已有实现可保留:改名省略空 `cnote`、operation 结果队列扩展、CID 等待/唤醒基础路径、改名 listener 的状态与 webhook 事务入队、分类独立状态槽、数组响应、备注 update API/store、标签首次加载保护和有效父联系人校验。以上均不是相关 R 项完整验收。
本轮未做:真实商务通登录/写入、浏览器 E2E、生产 PostgreSQL 并发/迁移、故障注入、灰度/部署。所有新增验收项仍待验证。
**实施后复审纠正(2026-09-13)**:分类同 event 异目标值已通过临时 Go overlay 复现:请求 color-1,回调 color-2 返回 200/`updated:true`,预期 409。联系人无实际操作的另一临时测试超时,未计入复现。其余发现为源码/调用链确认,涉及备注 200 字符截断、编辑模式/草稿、状态与 outbox 缝隙、回调代际竞争、CID 范围与截止、不确定证据、补偿入口及 UI 恢复;并发/组件场景尚需新增测试。详见整改计划 F01–F18,原先“已实现”的相应条目已回退为部分实现。
**新增协议证据(2026-09-13)**:授权 `swt.test` 的只读探针显示 login 返回 `colorkind0/colorkind1` 共 12 个 customer-color 条目,值为双重 URL 编码且带尾 `|`;同轮 heartbeat 命中 kind=29,解码后的文本为数字 color ID `10`。`SiteSetting.aspx` 返回 HTTP 200 但空 body 且无分类 headers,logout 返回 `r=ok`。Connector 已把 login catalog 作为优先来源,并保留 `SiteSetting.aspx` 作为无 login catalog 时的兼容回退;证据见 `reference/shang-wu-tong/results/real-tests/20260913T113244Z/catalog-login-kind29/manifest.json`。这只证明 catalog 来源和 kind=29 载荷形态,不证明颜色客户级传播或真实写入。
仍未做且不得标记为已验收:真实商务通登录/写入、远程 `cnote` 三种空值语义、远程独立改名/备注/chatkind 事件、kind=52 正文历史/主动历史拉取、消息编辑/反应、RESET、浏览器 E2E、生产 PostgreSQL 故障注入/迁移、灰度部署及告警补偿演练。
### 6.4 发布、观察与回滚
- **进入实施**:R01/R07/R08/R10 等范围或权限待决项留明确决策,不擅自开放;R02 数据保全及现有错误修复优先。接口字段/状态以冻结契约为准,不在多份文档各维护不同版本。
- **进入灰度**:适用 V01–V25 全部有自动化证据;涉及远程语义的 fixture 必须能追溯脱敏样本。备份、迁移回滚、结果补偿、操作审计及真实账号授权齐备,协议未知的能力继续关闭。
- **真实验证**:登记专用测试站点/inbox、允许写入对象和原值、执行人、测试时间及恢复步骤;至少验证姓名且已有非空备注、会话分类、颜色、多 SID、一项失败/不确定与仅结果补传。不获授权不操作真实客户,不用反复写入推测远程结果。
- **灰度观察**:按 inbox 查看 pending 数/最老年龄、缺 CID 到期、明确失败与 uncertain_timeout、回调重试/耗尽、catalog 最后成功时间与故障、账号队头阻塞;告警阈值、观察窗口和负责人在放量前填写,未填写不放量。
- **灰度观察**:按 inbox 查看 pending 数/最老年龄、缺 CID 到期、明确失败与不确定/观察到期、回调重试/耗尽、catalog 最后成功时间与故障、账号队头阻塞;观察到期不等于远程失败。先完成 F18 的指标实现核验;告警阈值、观察窗口和负责人未填写不放量。
- **停止条件**:备注误清空、跨租户/渠道串写、旧结果覆盖、重复远程副作用、迁移数据不一致立即停止对应出站功能;保存队列和审计证据,不能清空队列掩盖问题。代码回滚不撤销远程写入,恢复原值须核对远程当前值并重新授权。
- **退出灰度**:范围内适用用例真实验证完成、无未处理 P0/P1、数据库兼容/回滚与结果补偿演练通过、README/runbook/能力矩阵一致,才逐项标记完成。仅本轮现有测试通过不可发布宣称新增能力。
@@ -0,0 +1,293 @@
# 商务通能力修复实施后审查:整改执行计划
> 日期:2026-09-13
> 状态:**实施中;本地自动化验收部分通过,真实协议 smoke 已有局部证据,仍阻止按“全部完成”验收/发布**。完整真实写操作、PostgreSQL 并发和浏览器端到端送达仍未通过。
> 审查对象:`main@0dd188c46f41e9d89644e25fe3c0b6a06ed9ae73` 加本次未提交工作树,包括未跟踪的 `channels/shangwutong/internal/gochat/contact_status.go`,不是纯 HEAD。
> 要求来源:[能力缺口修复计划](2026-09-12-shangwutong-capability-gap-repair-plan.md) 的原始 R01–R18、V01–V25 和 §3.2 契约;同时对照实施后修改的完成声明。本文细化整改,不改变原计划的产品范围与授权边界。
## 1. 结论与执行边界
当前仍是部分符合要求。已实现的 outbox、结果队列、备注 API/store、标签双字段事务同步、分类结果持久队列和状态 UI 可保留;本地反例与全量测试已补强,但不能据 SQLite/单元测试将 R02/R03/R04/R06/R09/R13/R14/R17/R18 判为外部完成。
本轮已在保留 dirty worktree 的前提下落地 Connector、后端、前端及文档整改,并执行本地/隔离 PostgreSQL 验证;经用户授权使用 `swt.test` 完成真实登录、heartbeat、入站 marker、一次 `send.aspx r=ok` 与结束会话,另保留发送/接管无 `r` 的失败证据;未执行生产迁移、提交或部署。后续实施仍不得重置、不覆盖他人修改。
继续遵守:
- `cnote`、商务通 chatkind/颜色、GoChat Note/Label、CRM 标签彼此独立。
- 未冻结的双向 `cnote`、独立远程资料/chatkind 事件、kind 18/19/20、kind=52 正文、主动历史、RESET、编辑/反应不借整改开放。
- 复用 `WorkerPool.EnqueueInTransaction`、既有后台任务和 `outbound_operations`;不另建通用队列/调度框架。
- 202 仅表示持久化受理;超时不是远程失败证据;重放结果不得重复远程副作用。
- 仅成功案例、mock action 测试和 SQLite 测试不能替代负向、组件、PostgreSQL 并发及真实协议验证。
- 原生能力先追踪 `docs/chatwoot/` 对应 controller/model/serializer/前端消费者;该目录与反编译参考目录只读。
## 2. 证据口径
| 标记 | 含义 | 本次证据 |
| --- | --- | --- |
| 已复现 | 整改前实际运行失败用例 | 分类同 event、同 operation、不同目标值回调返回 200 且 `updated:true`,整改后已纳入 409 回归 |
| 本地验收 | 当前工作树实际运行通过 | Connector 全量 race/vet/build;后端 SQLite 全量 race/vet/build;隔离 PostgreSQL 并发/冒烟用例;前端测试、构建、修改文件 lint/format;迁移 010/011 往返及数据保护回归 |
| 真实 smoke | 已获授权并保留脱敏证据 | 登录/heartbeat/入站 marker/一次 `send.aspx r=ok`/结束会话;UI 未观察到立即回复,后续同 SID 发送与接管无 `r` |
| 源码确认 | 已定位调用链但尚未完成完整外部场景 | `cnote` 语义、完整写操作/接管、浏览器端到端送达、生产监控和完整 PostgreSQL 故障注入 |
| 未完成验证 | 不可当作通过或失败复现 | LSP/CodeGraph 不可用或 inconclusive;生产级告警/补偿演练仍未完成 |
分类复现步骤:在 `TestShangwutongClassificationCallbackIsScopedAndStaleSafe` 受理 `color-1` 后,用其 event ID 先回调 `customer_color_id=color-2`、`status=succeeded`。路由实际返回 200。复现使用临时 Go overlay,未修改仓库测试;实施时须转为持久回归,不能依赖 `/tmp` 路径作为交付证据。
以下位置以文件和符号为准,行号随整改变化。初始审查状态见上文;当前登记以第 6.5 节为准。P0 表示既定安全门禁,不表示已证实发生远程数据损坏。
## 3. 整改清单
### F01 — P0 安全门禁:未知 `cnote` 语义不能直接发送 rename
- **对应**:R02;V01。
- **位置**:`channels/shangwutong/internal/swt/operations.go` 的 `ChangeContactName`;`internal/swt/operations_test.go`;`reference/shang-wu-tong/reverse/swt-decompiled/sources/com/reception/app/business/sendfile/net/RenamedThread.java`。
- **问题**:删除空 `cnote` 改为省略后仍无条件请求远程;省略语义未验证。不能断言省略一定清空,也不能认定省略安全。
- **修复**:协议保全证据缺失时,明确拒绝实际远程 rename,并通过既有失败结果链路给出“协议未验证/功能未开放”原因。不得用新备注值、猜测的原值或空串绕过。取得安全请求形态证据后才能解除门禁;不依赖双向备注获批。
- **验收**:未验证模式调用计数为 0,失败原因可见;fixture 分开覆盖缺省、空串、原值。远程已有非空备注的真实保全验证必须另获授权。
### F02 — P1:结果必须绑定真实操作、身份和不可变目标值
- **对应**:R14;V08/V14。
- **位置**:`backend/internal/handler/api/v1/shangwutong_connector_handler.go` 的 `UpdateClassificationStatus`、`UpdateContactOperationStatus`、`persistContactOperationStatus`。
- **问题**:分类的 `existingValue` 只用于识别完全相同的重放,未拒绝同 event 异值;联系人无 operation 时可被首次回调直接写成成功。
- **修复**:在锁内核对已持久化的 event、operation、account/inbox、contact/contact-inbox、目标字段和值;保留不可变操作信息,不在终态替换时丢弃。未知操作拒绝,同键异负载冲突。已知旧代际只作无副作用 stale 处理,不能靠拼接 idempotency header 证明操作存在。
- **验收**:真实 middleware 路由覆盖未知 event、无 pending、错 operation/目标/值/身份、终态同值重放、终态异值冲突、跨租户和旧代际。错误请求不改发起会话、peer、联系人元数据,不生成出站任务。把已复现的 color-1→color-2 用例纳入仓库。
### F03 — P1:分类状态与 outbox 原子化,补投递终止结果
- **对应**:R03/R14/R18;V04/V05/V09。
- **位置**:上述 handler 的 `UpdateConversationClassification`、`markClassificationPending/Failed`;`backend/internal/service/shangwutong_webhook_delivery.go`;对照 `shangwutong_contact_listener.go` 的事务入队。
- **问题**:分类 pending 先独立提交、再 enqueue;中途退出留下无任务状态。A 入队失败的无条件补偿可覆盖 B。webhook 被永久拒绝或重试耗尽时,资料操作可能永远 pending,现有失败收尾主要针对消息。
- **修复**:同事务持久化操作状态与任务,提交后 publish。后续补偿必须匹配当前 event。投递明确拒绝记录本地投递失败;投递结果不明保留不确定证据,不伪装远程拒绝;已知仅投递重试/结果补传不改变远程执行次数。
- **验收**:事务写状态后入队失败、commit 前/后进程退出、publish 失败恢复、A 失败与 B 新请求交错、Connector 4xx/超时/耗尽。每个 pending 均有可发现任务或明确恢复记录,恢复不丢新状态、不重发已确认副作用。
### F04 — P1:改名请求代际不能与回调时间比较
- **对应**:R03/R04/R14;V03/V08/V09。
- **位置**:`backend/internal/service/shangwutong_contact_listener.go` 的 pending helper;`shangwutong_webhook_delivery.go` 的 `newShangwutongContactJob`;handler 的联系人结果持久化。
- **触发**:A pending → B 已保存(t2) → A 回调(t3>t2) → B listener。B 被旧回调的 `updated_at` 判旧,仍入队发送,B 回调又因元数据仍是 A 而 stale。
- **修复**:使用明确、不可变的请求代际/序列,结果更新时间单独保留;确定同身份同字段的串行/最新意图策略。helper 返回是否接受该操作,状态未接纳的任务不可继续发送。复用现有操作记录,必要字段才增加迁移,不用回调时间或重启时间冒充版本。
- **验收**:复现上述精确交错;A→B→C、重复派发、终态后重复 listener、重启、相同时间精度多次改名。不出现“执行 B,状态永远显示 A”或过期 A 最后覆盖远程。
### F05 — P1:catalog 代际核对与写入必须原子化
- **对应**:R14/R18;V11。
- **位置**:handler 的 `SyncClassifications`、`UpdateClassificationCatalog`、`UpdateClassificationSyncStatus`。
- **触发**:A 回调通过旧快照检查 → B 写入新代际 → A 无条件 Save 恢复 A → B 回调被判 stale。
- **修复**:使用锁内验证更新或带 `last_sync_event_id` 条件的原子更新;首次缓存创建处理唯一性竞争。成功、失败、请求入队失败均遵守同一代际规则,重复结果不无谓刷新版本。
- **验收**:挂载 catalog 真实路由,用屏障控制读/写交错;A 成功/失败分别晚于 B pending/success、重复成功、并发首次创建、入队失败。旧代际不能覆盖或让新代际失去接收结果的资格。
### F06 — P1:先锁定并验证发起操作,再传播颜色
- **对应**:R14;V09。
- **位置**:handler `UpdateClassificationStatus` 的多会话事务。
- **问题**:遍历 peer 先写入,随后在发起会话发现 stale/conflict 却返回 nil,事务可能提交已写 peer。
- **修复**:任何传播写入前完成发起操作锁内验证;采用一致锁顺序,失效时整笔事务无写入或回滚,不用返回 nil 的提前退出提交部分结果。
- **验收**:先创建 peer 再创建发起会话;A 通过预检查后 B 替换操作,再执行 A。断言 stale/conflict 时所有相关行均未改变;覆盖并行消息属性更新、不丢无关字段。PostgreSQL 锁/事务测试不可用 SQLite 结果替代。
### F07 — P1:客户颜色按 account/inbox/CID 归属传播
- **对应**:R06/R15;V10/V20。
- **位置**:handler 的颜色传播查询;`backend/internal/model/contact_inbox.go` 及对应 repository。
- **问题**:相同 contact-inbox 不是同 CID 多 SID 的完整集合;按 contact_id 回退又可能包含合并后不同远程身份。
- **修复**:先确定当前有效远程绑定,再在 account/inbox/CID 范围解析相关 contact-inbox/会话;不按裸 CID 跨站点合并,不以本地 Contact 代替远程客户身份。CID 缺失/歧义不得扩大查询范围。
- **验收**:同 account/inbox/CID、不同 SID 与 contact-inbox 全部一致;跨 inbox/account 相同 CID 不变;同 Contact 合并了不同 CID 不串写;空/失效 CID、旧配置回调无副作用。
### F08 — P1:CID 等待与会话可用性、发送重试和截止分离
- **对应**:R04/R18;V03/V06。
- **位置**:`channels/shangwutong/internal/delivery/outbound.go` 的 `processOperation/retryContactNameCID`;`db/queries/operations.sql` 的 wake/claim;`internal/delivery/inbound.go`。
- **问题**:在 `WithSession` 内检查 CID,断连先消耗十次通用重试;若超过 24h 后先收到 CID,当前仅在 CID 为空时的超时分支会被绕过。
- **修复**:需要等待的操作先解析 CID/等待状态再请求远程会话;等待重试不耗用真正的远程发送预算。保留可重启恢复的等待截止与 CID 学到时间/解除等待证据;恢复、claim、wake 均遵守它。晚于截止取得 CID 的意图不得执行。已在截止前解除 CID 等待但尚未发送的任务,按独立发送策略处理,不能混用两种截止。
- **验收**:连续十次以上无 CID 且断连、CID 到达前后断连、截止前取得 CID、截止后先入站再出站、恰好截止、重启后过期、后续请求代际。通过真实 worker/会话桩循环,不只改内存 attempts 后直接调用 helper;过期待办远程调用计数为 0。24h 是当前代码/计划口径,不新增生产 SLO 承诺。
### F09 — P1:不确定证据不随观察超时变为确定失败
- **对应**:R03/R18;V04–V06。
- **位置**:`channels/shangwutong/db/queries/operations.sql` 的 `FailExpiredUncertainOperations` 和结果状态更新;`internal/delivery/outbound.go`;GoChat 结果 handler。
- **问题**:观察超时生成 `failed/uncertain_timeout`,若 GoChat 已接受 uncertain 则冲突;依赖会被回调更新的 `updated_at`,截止还会移动。
- **修复**:远程结果、结果投递状态、队头屏障释放分别处理。保持真实 uncertain 证据;用不可变观察起点/截止控制调度,不能靠放宽后端终态校验掩盖伪造结果。迁移保留历史结果,不能重置待办来“恢复”。
- **验收**:成功发送响应丢失 → uncertain 回调成功/失败/耗尽 → 固定截止 → 重启。贯通实际 GoChat 状态路由,结果不冲突、不虚构失败,回调活动不延长截止;释放屏障后另一 SID 可继续但原副作用不重发。
### F10 — P1:补结果耗尽后的可审计、仅结果补偿入口
- **对应**:R18;V05/V06/V25。
- **位置**:Connector `processOperationResult`、结果 claim/retry/fail/recover SQL、现有管理命令;`docs/runbooks/shangwutong-connector.md`。
- **问题**:十次失败后停在 `result_sync_status=failed`;startup 只恢复 syncing,重放原 webhook 也不重启结果补传;“只重放结果”目前仅为文字指引。
- **修复**:通过现有管理入口增加最小、受权限和身份范围约束的补偿操作,只重新安排已存结果,不改远程 delivery 状态或结果证据,不创建新的业务命令。记录操作者、操作 ID、时间、原因和结果,不记录明文敏感资料。
- **验收**:远程成功一次 → 十次结果失败 → 重启 → 人工补偿 → GoChat 最终收到;全程远程调用恰为一次。覆盖重复/并发补偿、跨租户、无结果/仍 delivering/已补传成功的请求;提供准确可执行的 runbook 步骤,不杜撰尚不存在的 CLI。
### F11 — P1:catalog 读取及回传双故障也必须可恢复
- **对应**:R14/R18;V11。
- **位置**:`channels/shangwutong/internal/account/manager.go:SyncClassifications`;`internal/httpapi/server.go`;catalog client;GoChat catalog 状态 handler。
- **问题**:当前同步直接执行,失败状态回传错误被忽略,不受业务 operation 结果队列保证。
- **修复**:复用既有持久任务/结果基础设施保存同步代际和待回传结果;不得把“同步请求受理”视为“目录更新完成”。回传失败与重新读取远程 catalog 的策略分开,不能重新读取后将旧代际伪装成新结果。
- **验收**:远程读取失败且 GoChat 同时不可达;远程读取成功但结果回传失败;任意阶段重启;新代际取代旧代际。恢复后可追踪终态、不无限 syncing、不被旧结果覆盖。
### F12 — P1:长备注编辑不得静默截断
- **对应**:R09/R16;V17。
- **位置**:两处 `ContactNotes.vue` 的新编辑弹窗;`frontend/app/javascript/dashboard/components-next/Editor/Editor.vue`。
- **问题**:编辑器默认 `maxLength=200`,无 actions slot 时 watcher 会 `slice(0, 200)`;已有合法长备注被编辑后可能丢内容。
- **修复**:在备注调用处显式禁用偶然继承的截断,或使用已确认的非破坏性长度校验。不得为了备注修改全站 Editor 默认行为;不得保存截断后的数据而不给错误。
- **验收**:两处 UI 编辑 >200 字符、中文/多行/富文本内容,PATCH 完整;服务端拒绝时草稿仍完整;普通 Editor 调用无回归。
### F13 — P1:备注新增/编辑、快捷键、切换和失败草稿隔离
- **对应**:R09/R16;V17/V23。
- **位置**:`frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue`;`dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue`。
- **问题**:编辑共用 create buffer,Ctrl/Cmd+Enter 仍执行 onAdd;联系人切换未关闭 edit;旧请求完成清空新草稿;联系人详情创建失败仍立即清空草稿(既有问题,原计划明确要求修复)。
- **修复**:按当前模式路由快捷键并防止 create 在 edit 状态执行;隔离草稿与编辑身份,切换时完整重置;捕获提交目标/代际,忽略旧请求对当前弹窗的清理。创建、更新成功后才清空,失败提示并保留草稿;不移除键盘支持来回避错误。
- **验收**:两处新增/编辑/删除与失败;键盘保存编辑只有 PATCH 无 POST;A 编辑切 B、A 提交后 B 起草再返回 A 响应;无跨联系人请求和草稿损失,重复点击不重复提交。
### F14 — P1:改名远程状态真正进入 UI
- **对应**:R03;V04/V23。
- **位置**:`backend/internal/handler/api/v1/crm_serializer.go`;前端 `conversation/contact/ContactInfo.vue`、`components-next/Contacts/Pages/ContactDetails.vue` 及联系人 store。
- **问题**:序列化了 `shangwutong_contact_name_operation`,前端没有消费者;本地修改成功提示不代表远程成功。
- **修复**:两处改名入口展示每个相关 inbox 的 pending/succeeded/failed/uncertain 及安全错误信息;本地保存和远程确认分开。复用实际联系人数据刷新机制,不无条件新增轮询;刷新/重入恢复状态,同联系人多 inbox 不能合并成单一成功提示。
- **验收**:本地保存成功但远程拒绝/响应丢失/缺 CID/协议未开放;多个 inbox 不同结果;浏览器刷新仍正确。将实际 API 序列化结果交给组件,不只手写状态 mock。
### F15 — P1:分类 UI 持久状态、独立字段与等待语义一致
- **对应**:R17;V09/V12/V23。
- **位置**:`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`。
- **问题**:刷新不恢复 operation 状态/请求值;一个字段失败调用全量 resetSelections 干扰另一个 pending 字段;十次查询后本地制造 uncertain 并允许再次提交。
- **修复**:从持久化 operation 恢复已确认值、请求值和状态;只重置失败字段;停止观察只意味着前端暂未获新结果,不能改变远程状态。明确 pending/uncertain 展示及重新提交规则;保持会话/inbox/卸载代际保护。
- **验收**:颜色 pending、chatkind 失败及反向组合;pending/failed/uncertain 页面重载;>5 秒仍 pending;旧成功/失败/查询回包晚到新会话;catalog 空/过期/ID 删除或改名;无虚假状态、误清选择或重复操作。
### F16 — P1:会话标签跨写入入口采用一致数据源
- **对应**:R13;V15。
- **位置**:`backend/internal/service/conversation_service.go`、`repository/conversation_repo.go`、`repository/conversation_label_repo.go`、`router/router.go`;前端 conversationLabels store/composable。
- **问题**:普通更新/GET/序列化使用 `conversations.labels`;批量增删/关联删除使用 `conversation_labels`,无同步。数组响应正确不代表完整 R13 已完成。
- **修复**:追踪真实路由及上游行为,让所有仍开放的写入复用一致服务/数据源,必要时事务内同步现有表示;不先引入无关重构、双写框架或删表。若涉及历史数据修复,先盘点并提供迁移/回滚方案。
- **验收**:批量添加→GET/序列化/筛选;普通添加→关联删除→GET;批量移除、清空、重载和真实 store/composable 消费;关联移除不得删除账户标签。
### F17 — P1 验收缺口:来源标记必须验证真实请求路径
- **对应**:R02/R14/R15;V07/V19/V20。
- **位置**:Connector `internal/gochat/messaging.go`;后端 widget handler、`WidgetService.PublicUpdateContact/updateContactFields`、`ContactRepo.Update`、`ContactService.Update`、`ShangwutongContactListener`。
- **事实边界**:实际 public API 走 WidgetService→ContactRepo,未到新增 origin 的 ContactService 事件生产点;该路径当前不发 contact event,**不能据此宣称实际存在反向循环**,也不能将分离测试当作端到端传播证据。
- **修复/决策**:保留 public Widget 直接保存路径,明确不从公开请求 header 派生 trusted origin,也不由 Connector client 发送可伪造的 origin/event-ID header;当前路径不广播 contact event。若未来必须发事件,只能在实际受信生产点传递来源、事件 ID 与渠道范围。不为“证明字段存在”无条件广播;任何新增事件行为先对照上游,记录有意差异。
- **验收**:用 Connector client→真实鉴权/public contact 路由→数据库/事件→listener 贯通测试;远程更新不产生 rename、本地人工改名仍产生正确任务。覆盖伪造来源、重复入站、多个 inbox,保持 Note/Label 不外泄;独立远程事件的真实语义仍待授权证据。
### F18 — P2:监控与完成声明必须对应实际实现
- **对应**:R12/R18;V06/V25。
- **位置**:`channels/shangwutong/internal/observability/metrics.go`;Connector README、runbook、分类计划、原能力计划。
- **问题**:runbook 新列的若干 rename/catalog/stale 指标无生产者;监控仍主要覆盖消息而非 operation。完成标记超出了实际用例与实现。
- **修复**:优先使用已有 metrics 通道增加必要 operation 统计,或明确标注“规划/未实现”;实现前不得作为可用告警指引。覆盖 pending 最老年龄、CID 等待/到期、结果耗尽、catalog 故障和账号队头阻塞;避免 contact/SID/event_id 等高基数敏感标签。
- **验收**:持久任务变化真实反映在 exporter/查询中;结果耗尽可见且有操作手册;另一 SID 被队头阻塞可观察/恢复;阈值、负责人、补偿入口均有真实配置或明确未完成标记。文档不将时间超时描述为远程明确失败。
## 4. 实施顺序与依赖
每项必须先补能够失败的最小回归,再修共享路径;不用测试数量替代场景覆盖。负责人、reviewer、环境与证据路径在开工/验收时登记,本文不虚构人员或日期。
| 阶段 | 工作 | 依赖与退出条件 |
| --- | --- | --- |
| A:止损 | F01、F12、F13 | 协议未验证不会发请求;备注无截断/重复新增/丢草稿 |
| B:操作身份与持久边界 | F02、F03、F04、F05、F06 | 回调身份/代际锁内校验,任务与状态一致,失效操作无部分提交 |
| C:渠道身份与 Connector 恢复 | F07、F08、F09、F10、F11 | 依赖 B 的结果契约;范围、截止、不确定、仅结果补偿贯通 |
| D:本地契约与 UI | F14、F15、F16、F17 | F16 可独立推进;UI 使用 B/C 的真实持久状态,来源结论有真实路由证据 |
| E:运维与验收 | F18、全量回归、V14/V20 遗留门禁 | 文档/指标/补偿一致、P0/P1 清零;外部授权仍单独申请 |
同一工作树同时只允许一个写入者;并行只能分离工作树或做只读 review。冻结原计划 §3.2 中未决的身份/权限/代际策略,不由实施者擅自扩大功能。
如必须新增持久字段:后端使用 `backend/migrations/` 编号 up/down;Connector 使用其 `db/migrations/` 并核对 `sqlc.yaml` schema 输入,执行 `go tool sqlc generate` 更新生成代码,不直接手改 generated。SQLite Connector 重启测试与后端 PostgreSQL 并发/迁移测试分别验收,二者不能互相替代。
## 5. 原要求覆盖与状态登记
以下映射用于回填原计划,不新增第二套产品契约。F 项完成不自动意味着对应 R 的真实灰度完成。
| 原要求 | 当前判定 | 本次整改/仍保留门禁 |
| --- | --- | --- |
| R02 | 部分实现,安全门禁未闭合 | F01、F17;真实 cnote 语义及授权 |
| R03 | 部分实现,结果链路与 UI 均有缺口 | F02–F04、F09–F10、F14 |
| R04 | 部分实现 | F04、F08;等待/重启/过期端到端用例 |
| R06 | 部分实现 | F06–F07、F15;真实同 CID 多 SID 证据 |
| R09 | 部分实现 | F12–F13;历史 notes/contact_notes 盘点和迁移门禁仍保留 |
| R12 | 部分实现 | F18;纠正文档即可先交付,不假称指标/灰度完成 |
| R13 | 部分实现,响应数组已修 | F16;混合真实路由一致性 |
| R14 | 部分实现 | F02–F07、F11、F17;并发和实际操作关联 |
| R17 | 部分实现 | F15;持久状态/独立字段/浏览器交互 |
| R18 | 部分实现 | F03、F08–F11、F18;恢复/补偿/监控不是仅待演练 |
| R01/R05/R07/R08/R10/R11 | 保持待确认/待实施 | 产品、未知协议、权限、可选入口、历史范围、跨系统/真实灰度,不因整改自动开放 |
| R15/R16 | 保持部分实现 | 合并/软删除、停用/删除/重绑、配置代际、六维二值权限真实路由验收;见 V14/V20 |
遗留门禁必须覆盖:旧任务不得投递到重新绑定的站点;旧回调不得写入合并/删除/重建后的错误对象;权限测试必须挂载真实 middleware。已有账户归属检查或服务签名不等于完整用户权限/生命周期验收。
## 6. 回归执行与证据清单
下列是可复跑的验收命令;本轮执行结果登记在 §6.5。每次复跑仍应记录 commit/dirty diff 基线、命令、环境、退出码、关键断言、日志路径与剩余风险。
```bash
# 仓库根目录:Connector(使用本模块 toolchain)
(cd channels/shangwutong && go test -race ./... -count=1)
(cd channels/shangwutong && go vet ./... && go build ./...)
# 修改了查询/schema 才执行,并检查生成 diff:
(cd channels/shangwutong && go tool sqlc generate)
# 后端 Go module 在 backend,不在仓库根目录
(cd backend && GOCHAT_TEST_DB=sqlite go test ./... -count=1)
(cd backend && GOCHAT_TEST_DB=sqlite go test -race ./... -count=1)
# PostgreSQL 隔离实例上的并发/冒烟门禁(连接串由环境提供,不提交)
(cd backend && GOCHAT_TEST_DB=postgres GOCHAT_TEST_DB_URL="$GOCHAT_TEST_DB_URL" go test -race ./internal/service -run 'TestEnsureCaptainAgentBotBindingConcurrentCallsStayUnique|TestCaptainSkillServicePostgresSmoke' -count=1)
(cd backend && go vet ./... && go build ./...)
# 前端:需增加组件用例,不仅 action/mutation mock
(cd frontend && TZ=UTC pnpm test)
(cd frontend && pnpm build)
# 对实际修改文件列表另跑 pnpm exec eslint 与 pnpm exec prettier --check
git diff --check
```
- PostgreSQL:使用专用隔离数据库/schema,按现有测试配置运行新增事务/锁/迁移用例;不得连接生产库。具体命令和连接配置在实现用例时记录,不伪造目前不存在的测试脚本。
- Connector:用持久 SQLite 数据库重开进程/存储,证明恢复;调用计数贯穿发送、结果回调、耗尽、补偿,不能只直接调用 recovery SQL 就宣称完整重启验收。
- 跨服务:至少一组实际 GoChat 状态路由消费 Connector 持久结果,覆盖 uncertain/耗尽、未知/异值、代际冲突。
- 组件:两处备注、新增/编辑快捷键/长内容/草稿;标签混合 API→store;分类并行状态/刷新/切页;两处 rename 状态显示。
- 故障注入:可控屏障/事务故障/明确时钟边界,不依赖 sleep 竞态碰运气;留下稳定断言的仓库测试。
- 浏览器 E2E:已按用户授权使用 browser-harness 操作测试站点;录制路径和正/负结果均登记在 `reference/shang-wu-tong/results/real-tests/`,不得将一次 UI 未观察到回复解释为远程失败。
- 敏感信息:证据以操作 ID 关联,脱敏姓名、备注、token、签名;不提交密钥、数据库文件、临时日志。
整改登记模板(每个 F 独立填写):
| F 编号 | 状态 | 负责人/reviewer | 修复提交/文件 | 定向测试/环境 | 结果与证据 | 未完成门禁 |
| --- | --- | --- | --- | --- | --- | --- |
| Fxx | 待实施/实施中/本地验收通过/外部门禁通过 | 待登记 | 待登记 | 待登记 | 不填即未验证 | 待登记 |
## 6.5 当前实施登记(2026-09-13)
| F 编号 | 状态 | 修复/证据 | 未完成门禁 |
| --- | --- | --- | --- |
| F01 | 本地验收通过,外部门禁保留 | `ChangeContactName` fail-closed;远程调用计数测试为 0 | 真实 `cnote` 缺省/空串/原值语义与授权 |
| F02 | 本地验收通过,真实协议仅部分 | 分类/联系人回调锁内校验 event、operation、对象、目标和值;同 event 异值 409;真实入站 marker 已被 Go client 解析 | PostgreSQL 并发与真实 middleware 环境 |
| F03 | 本地验收通过 | 状态/任务事务化;webhook 4xx 标 failed、超时/耗尽标 uncertain;新增负向回归 | PostgreSQL 故障注入、publish/进程退出演练 |
| F04 | 本地验收通过 | 联系人 version/generation/event 快照与迟到回调保护 | PostgreSQL 锁竞争、重启端到端 |
| F05 | 本地验收通过 | catalog event 锁内更新、唯一冲突 upsert、旧代际保护 | PostgreSQL 交错并发 |
| F06 | 本地回归通过 | 先锁/验证发起会话再传播,失败不写 peer;后端 SQLite race 全量通过 | PostgreSQL 锁/事务证据 |
| F07 | 本地回归通过 | 同 account/inbox/CID 覆盖不同 SID/contact-inbox;跨 inbox/account 不串写 | 真实合并/重绑数据与 PostgreSQL |
| F08 | Connector 回归通过 | CID 等待独立于发送 attempts,wake/24h cutoff 有回归 | 真实 worker 重启、边界时钟与协议事件 |
| F09 | Connector 回归通过 | `uncertain_since` 固定观察起点;结果回调不移动截止且不改成确定失败;010 rollback 有数据时拒绝丢证据 | PostgreSQL/真实结果路由 |
| F10 | Connector 本地验收通过 | `compensate-result` 仅重放已有结果并写审计 | 十次耗尽后的持久 SQLite 重启/人工演练 |
| F11 | Connector 本地验收通过 | catalog 读取/回传分阶段持久化,重试不重复读取已保存 catalog,启动恢复 syncing;migration 010/011 往返及有数据时 rollback 拒绝丢失证据 | 真实 GoChat 不可达、PostgreSQL 和新旧 event 交错 |
| F12 | 前端测试/构建通过 | 两处备注调用禁用偶然 200 字符截断 | 浏览器组件交互与真实服务端拒绝 |
| F13 | 前端测试/构建通过 | create/edit 快捷键、联系人代际、失败保留草稿回归 | 浏览器 E2E 与请求交错 |
| F14 | 前后端静态/定向通过 | 两处详情页消费每个 inbox 的改名状态和错误 | 浏览器刷新、多 inbox 实际 API |
| F15 | 前端测试/构建通过 | 分类状态 hydration、字段级 reset、5 秒等待保持 pending | 浏览器刷新/晚到响应/真实 catalog |
| F16 | 后端/前端回归通过 | `conversation_labels` 与 `conversations.labels` 事务同步 | 真实混合路由、筛选和历史数据盘点 |
| F17 | 本地边界通过,真实入站部分验收 | public Widget 只使用 request context;Connector client 不发送可伪造 origin/event-ID header;browser→真实站点→heartbeat 入站 marker 已留证;public 路径不主动广播 contact event;listener 保留 trusted context skip | Connector client→真实 public 路由的联系人字段/事件端到端、伪造来源及多 inbox |
| F18 | 本地指标/文档部分通过 | operation/catalog counters、catalog queue gauge、补偿/失败 runbook 已有测试/说明;oldest-age gauge 明确未提供 | 生产 exporter、阈值/负责人/告警和长时间 soak |
真实 `swt.test` smoke 的脱敏索引与 raw cache 位置见 `reference/shang-wu-tong/results/real-tests/README.md`;浏览器录制路径也在该索引中。本节只登记当前证据,不替代完整 PostgreSQL、真实写操作/接管、浏览器端到端送达和生产监控门禁;未完成项继续保持阻止发布。
## 7. 完成与发布条件
- **文档完成**:整改项、要求映射、触发场景、验收断言和责任登记入口齐全;不等于软件修复完成。
- **代码整改可合并**:本次适用 P0/P1 修复且定向反例转绿;无源数据截断、错误目标写入、丢 pending、假终态或重复副作用;必须的 PostgreSQL 并发和组件测试通过;不可执行的外部能力有实际 fail-closed 门禁;独立复审逐项处置全部发现。F18 的生产监控可在明确标记未实现的前提下单列,但未完成不得宣称 R18/发布就绪。
- **真实验证授权**:本轮 `swt.test` 由用户明确授权;实际使用范围为登录/heartbeat、测试站点访客 marker、一次文本回复及结束该测试会话,证据见 `reference/shang-wu-tong/results/real-tests/README.md`。不得据此扩大到 `cnote` 原值保全、其他访客或生产迁移权限。
- **发布**:适用 V01–V25 按所需证据层级通过;无未处置 P0/P1;迁移/回滚、备份、结果补偿、审计/告警、生命周期和权限验证齐备;README/runbook/分类计划/能力矩阵一致。范围未批准项记录“不纳入+决策”,不可冒充测试通过,也不能以“不纳入”绕开备注保全和权限边界。
- **停止/回滚**:发现备注损坏、跨身份写入、旧值反盖、重复远程写或迁移不一致,停止对应出站能力并保留队列和证据。代码回滚不撤销远程副作用,恢复远程原值仍需核对现状和重新授权。
关联:[分类计划](2026-09-11-shangwutong-classification-sync-plan.md) · [运行手册](../runbooks/shangwutong-connector.md) · [Connector README](../../channels/shangwutong/README.md)。临时审查报告不作为唯一交付依赖;本文件已收录整改事实、限制和验收要求。
+20 -3
View File
@@ -27,6 +27,8 @@ docker compose up -d postgres redis gochat worker
docker compose run --rm shangwutong migrate up
docker compose up -d shangwutong
docker compose exec shangwutong shangwutong doctor
# 结果回调失败且已有结果证据时,按 operation/account 显式补偿:
# shangwutong compensate-result --account-id <id> --operation-id <id> --actor <operator> --reason '<ticket/reason>'
```
依次确认:
@@ -46,7 +48,10 @@ curl -fsS http://127.0.0.1:9100/metrics
3. 验证访客文本入站、坐席文本出站、撤回、图片、文件、语音、会话结束和 typing。
4. 在线切换 online/busy/away/offline;确认只影响目标 Inbox。
5. 提交错误新密码,确认旧 session 仍工作且配置状态为 rejected;再提交正确密码恢复。
6. 运行至少一个工作日灰度后分批创建剩余 Inbox,持续观察队列、心跳和认证错误。
6. 在测试 Inbox 验证联系人改名:已有非空远程备注时只改姓名不得清空;无 CID 时 durable operation 保留并显示 pending,CID 入站唤醒后再执行,超过 24 小时回写 `cid_wait_timeout`;结果分别可见 pending/succeeded/failed/uncertain。联系人状态与 webhook outbox 必须同事务提交,enqueue 失败不得留下孤立 pending。
7. 验证分类 catalog 同步、chatkind 和 customer color 的独立结果;同一 inbox/contact-inbox 的相关会话颜色一致,跨 inbox 相同 CID 不串写。GoChat 原生标签/备注操作不得调用商务通分类接口。远程 Connector-origin 联系人事件不得再次触发反向 rename。
8. 运行至少一个工作日灰度后分批创建剩余 Inbox,持续观察队列、心跳和认证错误。
## 4. 日常监控
@@ -67,9 +72,13 @@ swt_connector_unknown_event_total
swt_connector_unmapped_retraction_total
swt_connector_contract_error_total
swt_connector_sqlite_write_duration_seconds
swt_connector_contact_metadata_wait_total
swt_connector_operation_result_sync_total
swt_connector_classification_sync_total
swt_connector_classification_sync_queue_depth
```
建议告警:connected 比例低于 95%;pending 最老记录超过 2 分钟;uncertain 大于 0;status sync 超过 2 分钟;未知 kind、raw-only 或 unmapped retraction 持续增长;SQLite 检查失败或卷空间低于 20%。指标标签不包含账号 ID、用户名、session ID 或 Inbox ID。
建议告警:connected 比例低于 95%;CID pending 最老记录超过 2 分钟或接近 24 小时;uncertain 大于 0;结果回调重试/耗尽;status sync 超过 2 分钟;分类 catalog 同步失败;分类回调冲突通过 GoChat API 日志/响应审计观察;未知 kind、raw-only 或 unmapped retraction 持续增长;SQLite 检查失败或卷空间低于 20%。指标标签不包含账号 ID、用户名、session ID 或 Inbox ID。
日志禁止输出 password、pending password、`ma`、HMAC/webhook secret、Authorization、完整登录 body 和访客正文。发现泄露时先轮换相应 token/secret,再保全并限制日志访问,最后修复 redaction 后重新部署。
@@ -111,12 +120,16 @@ docker compose exec shangwutong shangwutong doctor
## 7. 故障处理
- `classification_sync_requested` 投递失败:4xx 会把匹配的 pending catalog 标为 `failed`;超时/耗尽只保留 pending 错误证据,重新点击同步生成新 event。Connector 本地 `classification_sync_results` 将读取阶段与回传阶段分开持久化,状态可在 `swt_connector_classification_sync_queue_depth` 和 SQLite 中追踪;旧 event 不得覆盖新 event。
- `auth_failed`:核对 Inbox 的 session ID、username、最近密码版本;不要直接改 SQLite。通过 GoChat 配置页提交新密码。
- `verification_required`:当前不自动绕过 vcode/ecsq。保留账号离线,按真实商务通客户端完成人工验证并记录流程证据。
- `relogin_required` / `tickint_reset`:Connector 会清理 token 并用保存密码重登;观察是否形成登录风暴。
- inbound 堆积:检查 GoChat Application/Public API、service token grant 和附件下载;cursor 已在原始事件持久化后推进,不要回退 cursor 猜测重放。
- outbound uncertain:等待观察窗口和 kind=3;不要重复点击发送。超时 failed 后才允许显式 retry。
- outbound uncertain:等待观察窗口和 kind=3;不要重复点击发送。观察超时对分类/改名只保留 `uncertain` 和 `uncertain_timeout`,不得改写成确定 failed;结果同步失败时使用显式 `compensate-result` 仅重放结果回调,不重发远程操作;`cid_unavailable` 的 pending operation 由 CID 入站唤醒,重启不丢失,超过 24 小时的 `cid_wait_timeout` 转人工处理。`uncertain_since` 是固定观察起点,结果回调不会延长截止;消息与 operation 结果同步状态分开看。
- 分类 stale callback:保留当前 operation 状态,检查 event_id、inbox 和 conversation 绑定;不得手工改写 `additional_attributes` 绕过代际保护。
- `invalid_signature`:检查 Inbox webhook secret、系统时钟和 lifecycle secret 轮换顺序。
指标未提供“最老 pending”或“最接近 CID 24 小时截止”的独立 gauge;告警应由 SQLite 只读查询补充(按 `created_at`、`uncertain_since`、`result_sync_status` 和 `result_sync_attempts`),不要把缺失的指标写成已配置告警。
- SQLite BUSY/损坏:停止流量,保全 DB/WAL/SHM,运行只读检查并从验证过的在线备份恢复。
## 8. 生产证据清单
@@ -124,6 +137,10 @@ docker compose exec shangwutong shangwutong doctor
以下结果必须分别记录,不能用单元测试替代:
- 真实账号登录、session 恢复、online/busy/away/offline、logout 和密码正确/错误更新。
- 真实账号改名(远程已有非空 cnote)、无 CID 延迟/24 小时到期、成功/失败/不确定结果回写;分类 catalog、chatkind、customer color 多会话和跨 inbox 隔离。
- 远程联系人更新的来源/event ID 传播与反向循环抑制;联系人状态与 webhook outbox enqueue 失败回滚;Connector 重启后 pending CID operation 可恢复。
- 明确记录 `cnote` 缺省/空串/原值三种协议语义;未完成记录前保持改名不发送空 `cnote`。
- GoChat 原生 Contact Note/Contact Label/Conversation Label 与商务通字段互不污染。
- 文本、图片、文件、语音双向收发,撤回及连续相同内容的 uncertain 歧义样本。
- 负 kind 与 65/66/67 cursor 连续抓包;kind=52 不同版本真实 fixture。
- vcode/ecsq 人工恢复流程。