feat: complete current GoChat updates
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
> 日期:2026-07-31
|
||||
> 状态:功能实现与自动化验证已完成,待真实账号灰度验证
|
||||
> 能力缺口修复计划:[`2026-09-12-shangwutong-capability-gap-repair-plan.md`](2026-09-12-shangwutong-capability-gap-repair-plan.md)
|
||||
> 实现目录:`channels/shangwutong/`
|
||||
> 协议依据:`reference/shang-wu-tong/src/` 与 `reference/shang-wu-tong/docs/`
|
||||
> GoChat 接入方式:独立 Connector + 商务通 Inbox(复用并扩展 API Inbox 能力)
|
||||
@@ -27,7 +28,7 @@
|
||||
### 1.2 已确认的架构决策
|
||||
|
||||
| 决策项 | 选择 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 商务通账号关系 | 独立站点账号 |
|
||||
| 账号与 GoChat 映射 | 一个商务通账号对应一个商务通 Inbox |
|
||||
| Connector 进程模型 | 一个 GoChat 部署对应一个 Connector 服务,统一管理全部商务通 Inbox |
|
||||
@@ -223,7 +224,7 @@ channels/shangwutong/
|
||||
依赖和工具版本首版固定如下,升级必须单独提交并跑完整 contract/规模测试:
|
||||
|
||||
| 依赖/工具 | 固定版本 | 用途 |
|
||||
|---|---:|---|
|
||||
| --- | ---: | --- |
|
||||
| Go toolchain | `1.26.x` | Connector 编译、测试和 sqlc 生成 |
|
||||
| `github.com/spf13/cobra` | `v1.10.2` | CLI 与运维命令 |
|
||||
| `github.com/gofiber/fiber/v3` | `v3.4.0` | GoChat webhook、health/ready/metrics |
|
||||
@@ -286,7 +287,7 @@ go vet ./...
|
||||
### 5.1 Connector 环境变量
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|---|---:|---|---|
|
||||
| --- | ---: | --- | --- |
|
||||
| `SWT_CONNECTOR_LISTEN` | 否 | `:9100` | webhook、health、ready、metrics 监听地址 |
|
||||
| `SWT_CONNECTOR_DB_PATH` | 是 | — | SQLite 路径,如 `/data/connector.db` |
|
||||
| `GOCHAT_BASE_URL` | 是 | — | GoChat 内部地址 |
|
||||
@@ -789,7 +790,7 @@ Connector 启动先用 SQLite 中最后一次成功配置启动已有 supervisor
|
||||
GoChat 的 `desired_presence` 是期望值;空心跳或仅有 `r=ok` 只能确认连接/session 状态,`actual_presence` 取最近一次成功的 login/status/logout,或协议明确返回的 kind=11 在线状态事件:
|
||||
|
||||
| GoChat 值 | 登录 `t0` | 在线切换端点 |
|
||||
|---|---:|---|
|
||||
| --- | ---: | --- |
|
||||
| `online` | `3` | `oc/online.aspx` |
|
||||
| `busy` | `2` | `oc/busy.aspx` |
|
||||
| `away` | `1` | `oc/away.aspx` |
|
||||
@@ -906,7 +907,7 @@ typing state change
|
||||
### 8.4 心跳错误分类
|
||||
|
||||
| 错误 | 状态与动作 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `r=ok` | 重置网络失败计数,解析并持久化事件 |
|
||||
| `tickint reset` | `relogin_required`,立即按 desired presence 登录 |
|
||||
| `server connect err` | `degraded`,带 jitter 重试,不立即反复登录 |
|
||||
@@ -996,7 +997,7 @@ RefuseWaitingVisitor
|
||||
### 9.5 消息和会话操作
|
||||
|
||||
| 功能 | 端点/方式 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| 文本/表情 | `oc/send.aspx`,HTML 内容 |
|
||||
| 图片 | `oc/sendmorepics.aspx` multipart |
|
||||
| 文件 | `oc/sendfile.aspx` multipart |
|
||||
@@ -1030,7 +1031,7 @@ RefuseWaitingVisitor
|
||||
Connector 不持有 SuperAdmin 凭据,不调用 Connector 账号创建接口,也不维护独立账号后台。`Channel::Shangwutong` 是专用 Inbox 类型,但下列消息能力直接复用和扩展 API Inbox:
|
||||
|
||||
| 能力 | 当前 GoChat 事实 | 本项目要求 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Public contact/conversation | 已支持创建、查询、更新 | 直接复用 |
|
||||
| Public message | 只创建 `incoming/text`,附件依赖 staged upload | 不作为 v1 canonical import;所有商务通消息统一走 Application API,Public 路径仅保留兼容测试 |
|
||||
| Application message | 已支持 incoming/outgoing/activity、content type 和 multipart 附件,Message model 已有 `external_source_ids` | 增加受限 `external`、`additional_attributes`、`external_source_ids` 输入和幂等键;`external=true` 时禁止触发渠道外发 worker |
|
||||
@@ -1050,14 +1051,14 @@ GoChat 消息等待外部发送结果时统一使用现有前端已识别的 `st
|
||||
### 10.2 交互方向、端点与鉴权总表
|
||||
|
||||
| 方向 | 目的 | 方法与端点 | 鉴权 | 重试级别 |
|
||||
|---|---|---|---|---|
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Connector → GoChat | 启动全量配置 reconcile | `GET /api/v1/connector/shangwutong/inboxes` | Connector service token | 分页幂等重试 |
|
||||
| Connector → GoChat | 拉取单 Inbox 配置 | `GET /api/v1/connector/shangwutong/inboxes/{inbox_id}` | Connector service token | 幂等重试 |
|
||||
| Connector → GoChat | 回写心跳/连接/凭据状态 | `PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/status` | Connector service token | 最新状态覆盖重试 |
|
||||
| Connector → GoChat | 回写坐席消息真实发送结果 | `PUT /api/v1/connector/shangwutong/inboxes/{inbox_id}/messages/{message_id}/status` | Connector service token | 幂等重试直到 synced |
|
||||
| Connector → GoChat | upsert Contact | `POST /public/api/v1/inboxes/{inbox_identifier}/contacts` | body 内 identifier HMAC | 幂等重试 |
|
||||
| Connector → GoChat | 更新 Contact | `PATCH /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}` | body 内 identifier HMAC | 幂等重试 |
|
||||
| Connector → GoChat | 查询/创建 Conversation | `GET|POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations` | Contact source ID;HMAC 已在 contact 创建时验证 | 幂等 ensure |
|
||||
| Connector → GoChat | 查询/创建 Conversation | `GET | POST /public/api/v1/inboxes/{inbox_identifier}/contacts/{swt_sid}/conversations` | Contact source ID;HMAC 已在 contact 创建时验证 | 幂等 ensure |
|
||||
| Connector → GoChat | 导入消息/附件/activity | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/messages` | Connector service token | 幂等重试 |
|
||||
| Connector → GoChat | 更新会话属性 | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/custom_attributes` | Connector service token | 幂等重试 |
|
||||
| Connector → GoChat | 显式切换会话状态 | `POST /api/v1/accounts/{gochat_account_id}/conversations/{gochat_conversation_id}/toggle_status` | Connector service token | 幂等重试 |
|
||||
@@ -1081,7 +1082,7 @@ GoChat 当前也接受 `access-token` 头中的用户 JWT,但 Connector 固定
|
||||
### 10.3 标识符、时间与幂等规范
|
||||
|
||||
| 字段 | 含义 | 格式/来源 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| `connector_account_id` | Connector SQLite 运行记录主键 | 整数,只作本地外键,不用于跨系统识别账号 |
|
||||
| `gochat_account_id` | GoChat tenant/account ID | `accounts.gochat_account_id` |
|
||||
| `gochat_inbox_id` | 商务通 Inbox 数字 ID | Connector 账号存在性和 upsert 的唯一跨系统键 |
|
||||
@@ -1633,7 +1634,7 @@ typing 采用短超时同步投递,Connector 只更新该账号 supervisor 的
|
||||
### 10.7 状态码、重试与失败语义
|
||||
|
||||
| HTTP 状态 | 语义 | 调用方动作 |
|
||||
|---:|---|---|
|
||||
| ---: | --- | --- |
|
||||
| 200 | 成功或幂等重放 | 完成 |
|
||||
| 202 | webhook/异步命令已可靠入库 | 完成,后续查状态 |
|
||||
| 400 | JSON/字段错误 | 永久失败,人工或代码修复 |
|
||||
@@ -1655,7 +1656,7 @@ typing 采用短超时同步投递,Connector 只更新该账号 supervisor 的
|
||||
### 10.8 GoChat 展示能力基线
|
||||
|
||||
| GoChat 场景 | 当前展示能力 | 商务通使用方式 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 普通文本/换行/链接 | 原生 text bubble | 直接映射,先清洗 HTML |
|
||||
| 图片/音频/视频/文件 | 原生 attachment bubble | 上传为 attachment;语音设置 `is_voice_message` |
|
||||
| 居中系统事件 | 原生 activity bubble | 状态、分配、转接、邀请等受控文案 |
|
||||
@@ -1672,7 +1673,7 @@ typing 采用短超时同步投递,Connector 只更新该账号 supervisor 的
|
||||
分类:`E`=原生近似无损;`L`=可展示但有语义损失;`A`=只更新属性/状态;`R`=只保留原始事件;`X`=需要 GoChat 扩展。一个事件可以同时采用多种策略。
|
||||
|
||||
| kind | 商务通含义 | GoChat 映射 | 分类 | 处理细节 |
|
||||
|---:|---|---|---|---|
|
||||
| ---: | --- | --- | --- | --- |
|
||||
| -8 | 首次响应统计 | Connector metric + raw;未来 reporting event ingest | R/X | 不生成聊天气泡,解析 `chat_firstresponsetime` 和 operator |
|
||||
| -7 | 内部跳过 | raw + ignored | R | 推进 mid,不展示 |
|
||||
| -5 | 客服撤回 | DELETE 已映射 outgoing message | E/R | `text` 是目标 `swt_message_id`;无目标映射时 `unmapped_retraction` |
|
||||
@@ -1715,7 +1716,7 @@ kind=11 的 `text=0/1/2/3` 分别更新 Connector `actual_presence=offline/away/
|
||||
#### 10.9.1 kind=0 状态子表
|
||||
|
||||
| text | 商务通状态 | GoChat status | 额外展示/属性 |
|
||||
|---:|---|---|---|
|
||||
| ---: | --- | --- | --- |
|
||||
| 0 | 新访客 | 不主动创建会话或保持现状 | Contact ensure,`swt_state=new_visitor` |
|
||||
| 1 | 邀请中 | 不改 status | activity“已发起邀请”,`swt_state=inviting` |
|
||||
| 3 | 等待应答 | `open` | activity“访客等待接待”,记录 waiting_since |
|
||||
@@ -1730,20 +1731,20 @@ GoChat `pending` 不代表商务通“等待应答”,因此不做该映射。
|
||||
### 10.10 kind=2/3/67 消息内容映射
|
||||
|
||||
| 商务通内容 | GoChat 展示 | 分类 | 解析与降级 |
|
||||
|---|---|---|---|
|
||||
| --- | --- | --- | --- |
|
||||
| 纯文本 | text bubble | E | URL decode + HTML entity decode,保留换行 |
|
||||
| `<P>/<BR>` HTML | text bubble | L | 白名单转 Markdown/纯文本;脚本、style、事件属性全部移除 |
|
||||
| HTML 内 `<img>` | image attachment + caption | E/L | 多图支持;无法下载时用 alt/URL fallback |
|
||||
| HTML 表情图片 | emoji/小 image 或 Unicode 文本 | L | 有已知表情字典则转 Unicode,否则保留 alt 文本 |
|
||||
| `voice_msg|source|url` | audio attachment | E | 校验来源与 MIME,设置 voice metadata |
|
||||
| `filemsg|...` | file attachment | E/L | 保留文件名、大小、MIME;字段缺失时 fallback link |
|
||||
| `baidunmdata_msg|1|url` | 商品卡片 | L/X | 首版 text 摘要 + link;需 `swt_rich_card` 才能无损 |
|
||||
| `baidunmdata_msg|3|url` | 案例卡片 | L/X | 同上 |
|
||||
| `baidunmdata_msg|other|url` | 优惠券卡片 | L/X | 同上 |
|
||||
| `voice_msg | source | url` | audio attachment | E | 校验来源与 MIME,设置 voice metadata |
|
||||
| `filemsg | ...` | file attachment | E/L | 保留文件名、大小、MIME;字段缺失时 fallback link |
|
||||
| `baidunmdata_msg | 1 | url` | 商品卡片 | L/X | 首版 text 摘要 + link;需 `swt_rich_card` 才能无损 |
|
||||
| `baidunmdata_msg | 3 | url` | 案例卡片 | L/X | 同上 |
|
||||
| `baidunmdata_msg | other | url` | 优惠券卡片 | L/X | 同上 |
|
||||
| 微信/企业微信 JSON `text` | text bubble | E | 保留渠道名在 content attributes |
|
||||
| 微信/企业微信 JSON `voice` | audio attachment | E/L | 下载地址/token 失效时 fallback text |
|
||||
| 微信/企业微信 JSON `image` | image attachment | E/L | 同上 |
|
||||
| `0|好友进入对话窗口` | activity | E | 不当作访客发言 |
|
||||
| `0 | 好友进入对话窗口` | activity | E | 不当作访客发言 |
|
||||
| 字节跳动咨询签名 URL | text/link 或 image attachment | L/R | 需真实样本确认签名资源类型与有效期 |
|
||||
| 可识别 location JSON | location attachment | X/L | 后端 metadata serializer 补齐后原生展示;之前转文本地址 |
|
||||
| 可识别 contact JSON | contact attachment | X/L | 后端 metadata serializer 补齐后原生展示;之前转姓名/电话文本 |
|
||||
@@ -1752,7 +1753,7 @@ GoChat `pending` 不代表商务通“等待应答”,因此不做该映射。
|
||||
出站 GoChat → 商务通能力矩阵:
|
||||
|
||||
| GoChat 内容 | 商务通发送 | 策略 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| text | `oc/send.aspx` | 原生支持 |
|
||||
| image attachment | `oc/sendmorepics.aspx` | 原生支持;逐项发送并保序 |
|
||||
| file attachment | `oc/sendfile.aspx` | 原生支持 |
|
||||
@@ -1765,48 +1766,48 @@ GoChat `pending` 不代表商务通“等待应答”,因此不做该映射。
|
||||
### 10.11 kind=31 系统消息子类型映射
|
||||
|
||||
| 子类型 | GoChat 映射 | 分类 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `distribute_chat|name` | activity + `swt_assignee_name` | E/A | 首版不调用 assign API;未来明确配置 operator→agent 映射后才允许分配 |
|
||||
| `distribute_lastoname|name` | attribute | A | 默认不生成气泡 |
|
||||
| `guest_direct_chat|name` | activity + assignee attribute | E/A | 不自动创建同名 GoChat agent |
|
||||
| --- | --- | --- | --- |
|
||||
| `distribute_chat | name` | activity + `swt_assignee_name` | E/A | 首版不调用 assign API;未来明确配置 operator→agent 映射后才允许分配 |
|
||||
| `distribute_lastoname | name` | attribute | A | 默认不生成气泡 |
|
||||
| `guest_direct_chat | name` | activity + assignee attribute | E/A | 不自动创建同名 GoChat agent |
|
||||
| `guest_open_chat` | status=open + activity | E | ensure conversation |
|
||||
| `guest_continue_chat` | status=open + activity | E | 已 resolved 时 reopen |
|
||||
| `cdcheck_chat_ended` | status=resolved + activity | E | 明确结束信号 |
|
||||
| `away_timeout_chat` | activity;可配置 resolve | L | 默认等待真实业务确认 |
|
||||
| `robot_chat` | `swt_robot_state=active` + activity | A/L/X | 无 GoChat 等价控制状态 |
|
||||
| `robot_chat_fenpei|...` | robot/assignee attributes + activity | A/L | 字段按真实样本解析 |
|
||||
| `invite0|name` | invitation activity | L/X | 快速邀请;无访客侧动作 UI |
|
||||
| `invite1|name` | invitation activity | L/X | 强制对话 |
|
||||
| `invite2|...` | invitation activity + raw | L/R/X | 自定义邀请正文需清洗 |
|
||||
| `invite3|name` | invitation activity | L/X | 请求直接对话 |
|
||||
| `inviteyuyue|name` | invitation activity | L/X | GoChat form 不能代表商务通原预约单 |
|
||||
| `invitepingjia|name` | invitation activity | L/X | 不映射成 GoChat CSAT,避免评价回错系统 |
|
||||
| `guest_refuse_invite|...` | activity | E/L | 保留拒绝原因文本 |
|
||||
| `distribute1_chat|...` | raw + 可选 activity | R/L | 对话列表结构需真实包确认 |
|
||||
| `lastoname_is|...` | assignee attribute | A | 不展示内部标识 |
|
||||
| `ACT_CL|wx_{id}` | Contact `swt_wechat_id` + activity | E/A | 属于敏感字段,受权限控制 |
|
||||
| `ACT_CL|num_{phone}` | 校验后更新 phone + activity | E/A | 不合法号码只保存 raw |
|
||||
| `ACT_CL|{dial}` | activity | L | 仅表示进入拨号,不等于呼叫成功 |
|
||||
| `ACT_XST|RobotAutoMsg|text` | external outgoing | E | senderName=商务通机器人 |
|
||||
| `ACT_XST|SWTSettingAutoMsg|text` | external outgoing | E | senderName=商务通自动回复 |
|
||||
| `ACT_XST|SystemAutoMsg|text` | external outgoing/activity | E/L | 有明确对话内容用 outgoing,否则 activity |
|
||||
| `ACT_XST|BaiduAutoMsg|text` | external outgoing | E | 去掉稳定前缀但保留来源属性 |
|
||||
| `ACT_XST|<human-readable notice>` | activity | L | 仅白名单可见文本 |
|
||||
| `ACT_XST|NotShow|QuDaoVisitorInfo|...` | attributes + raw,不建气泡 | A/R | 与名称语义一致,避免污染会话 |
|
||||
| `ACT_XST|NotShow|QuDaoInfoWhenReceiveVisitorMsg|json` | attributes + raw | A/R | 展开 SWTID/SWTCID/QueryWord 等已知键 |
|
||||
| `ACT_XST|NotShow|BaiduRobotReleasedNoticeMsg|...` | robot state + raw | A/R | 不展示内部控制消息 |
|
||||
| `ACT_XST|NotShow|BaiduRobotRecvKFMsgState|...` | outbound delivery hint + raw | A/R | 可用于对账,不能当新消息 |
|
||||
| `ACT_XST|NotShow|SWTRobotChatWindowNotice|...` | robot state + raw | A/R | 需样本确认字段 |
|
||||
| `ACT_XST|NotShow|BaiduLingYinCallBackUrlInfoMsg|...` | raw;可提取受控 callback link | R/X | 链接权限/有效期未确认 |
|
||||
| `ACT_XST|ByteDanceLeaveMsg|...` | 解析成功则 incoming,否则 activity+raw | L/R/X | 需真实包确认留言正文、访客 ID、媒体结构 |
|
||||
| `ACT_XST|ByteDanceSysMsg|...` | activity + raw | L/R | 不可见控制字段不展示 |
|
||||
| `ACT_XST|ByteDanceCSFenPeiMsg|...` | assignee attribute + activity | L/A | 不自动匹配 agent |
|
||||
| `robot_chat_fenpei | ...` | robot/assignee attributes + activity | A/L | 字段按真实样本解析 |
|
||||
| `invite0 | name` | invitation activity | L/X | 快速邀请;无访客侧动作 UI |
|
||||
| `invite1 | name` | invitation activity | L/X | 强制对话 |
|
||||
| `invite2 | ...` | invitation activity + raw | L/R/X | 自定义邀请正文需清洗 |
|
||||
| `invite3 | name` | invitation activity | L/X | 请求直接对话 |
|
||||
| `inviteyuyue | name` | invitation activity | L/X | GoChat form 不能代表商务通原预约单 |
|
||||
| `invitepingjia | name` | invitation activity | L/X | 不映射成 GoChat CSAT,避免评价回错系统 |
|
||||
| `guest_refuse_invite | ...` | activity | E/L | 保留拒绝原因文本 |
|
||||
| `distribute1_chat | ...` | raw + 可选 activity | R/L | 对话列表结构需真实包确认 |
|
||||
| `lastoname_is | ...` | assignee attribute | A | 不展示内部标识 |
|
||||
| `ACT_CL | wx_{id}` | Contact `swt_wechat_id` + activity | E/A | 属于敏感字段,受权限控制 |
|
||||
| `ACT_CL | num_{phone}` | 校验后更新 phone + activity | E/A | 不合法号码只保存 raw |
|
||||
| `ACT_CL | {dial}` | activity | L | 仅表示进入拨号,不等于呼叫成功 |
|
||||
| `ACT_XST | RobotAutoMsg | text` | external outgoing | E | senderName=商务通机器人 |
|
||||
| `ACT_XST | SWTSettingAutoMsg | text` | external outgoing | E | senderName=商务通自动回复 |
|
||||
| `ACT_XST | SystemAutoMsg | text` | external outgoing/activity | E/L | 有明确对话内容用 outgoing,否则 activity |
|
||||
| `ACT_XST | BaiduAutoMsg | text` | external outgoing | E | 去掉稳定前缀但保留来源属性 |
|
||||
| `ACT_XST | <human-readable notice>` | activity | L | 仅白名单可见文本 |
|
||||
| `ACT_XST | NotShow | QuDaoVisitorInfo | ...` | attributes + raw,不建气泡 | A/R | 与名称语义一致,避免污染会话 |
|
||||
| `ACT_XST | NotShow | QuDaoInfoWhenReceiveVisitorMsg | json` | attributes + raw | A/R | 展开 SWTID/SWTCID/QueryWord 等已知键 |
|
||||
| `ACT_XST | NotShow | BaiduRobotReleasedNoticeMsg | ...` | robot state + raw | A/R | 不展示内部控制消息 |
|
||||
| `ACT_XST | NotShow | BaiduRobotRecvKFMsgState | ...` | outbound delivery hint + raw | A/R | 可用于对账,不能当新消息 |
|
||||
| `ACT_XST | NotShow | SWTRobotChatWindowNotice | ...` | robot state + raw | A/R | 需样本确认字段 |
|
||||
| `ACT_XST | NotShow | BaiduLingYinCallBackUrlInfoMsg | ...` | raw;可提取受控 callback link | R/X | 链接权限/有效期未确认 |
|
||||
| `ACT_XST | ByteDanceLeaveMsg | ...` | 解析成功则 incoming,否则 activity+raw | L/R/X | 需真实包确认留言正文、访客 ID、媒体结构 |
|
||||
| `ACT_XST | ByteDanceSysMsg | ...` | activity + raw | L/R | 不可见控制字段不展示 |
|
||||
| `ACT_XST | ByteDanceCSFenPeiMsg | ...` | assignee attribute + activity | L/A | 不自动匹配 agent |
|
||||
| 其他 `ACT_XST` | 白名单文本 activity,否则 raw | L/R | 未知结构不能直接渲染 HTML |
|
||||
|
||||
### 10.12 无法直接映射的类型与解决评估
|
||||
|
||||
| 类型/场景 | 无法直接映射原因 | 首版降级 | 推荐解决方案 | 决策/优先级 |
|
||||
|---|---|---|---|---|
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 商品/案例/优惠券卡片 | GoChat 没有商务通卡片字段和稳定 bubble contract | text 摘要 + 原链接 | 新增通用 `external_rich_card` content type、JSON Schema 和前端 bubble | P1,样本齐全后做 |
|
||||
| 邀请/预约/评价动作 | GoChat conversation status/form/CSAT 会触发不同业务,不等价 | activity + attributes | 若需要在 GoChat 操作商务通邀请,增加 Connector command API 与专用 action component | P2,不阻塞收发消息 |
|
||||
| 等待/内部转接/转接中 | GoChat 只有 open/pending/resolved/snoozed,语义粒度不足 | status=open + `swt_state` + activity | 增加 channel-neutral `external_state` 展示 badge,不扩展核心 status enum | P1 |
|
||||
|
||||
@@ -2,6 +2,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)
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
# 自定义角色与安装类型清理:评审及修复计划
|
||||
|
||||
- 日期:2026-09-12
|
||||
- 基线:`main` / `6e62f509`,以及评审开始时的未提交改动。
|
||||
- 范围:角色 API、Store、Modal、客服角色编辑、权限归一化、路由与侧边栏策略、浏览器 Smoke。
|
||||
- 本文状态:两路独立只读评审及父审复核完成;R1–R11 的最小修复已实施并完成定向静态/单元验证,**仍未证明真实浏览器、PostgreSQL 端到端通过**。
|
||||
- 不涉及:工作区内并行进行的商悟通相关改动。不要据此批量回滚整个工作区。
|
||||
|
||||
## 1. 结论:方向正确,但清理边界与验收存在遗漏
|
||||
|
||||
**不建议整体回滚;建议保留主体修复,按问题逐项补齐。**
|
||||
|
||||
1. 删除 Community/Enterprise 安装版本判断符合 GoChat 的定位,不能为了修复回归重新引入版本体系。
|
||||
2. 删除版本判定时顺带删除了 Cloud 专属账单入口的可见性条件,这是明确的新增交互回归。
|
||||
3. Store 改为抛出错误是合理的,但未同步更新全部调用方,客服列表留下未处理的 Promise rejection。
|
||||
4. 两个 P1 旧缺陷应优先修复:管理员切换自定义角色却仍保留管理员身份;自定义角色 ID 未校验账户归属/有效性,查询失败还回退到可能更宽的默认 Agent 权限。
|
||||
5. 本次新增删除文案将“转为默认 Agent”称为“降级”,但某些受限角色反而会获得额外权限,需要修正文案。
|
||||
6. 零权限角色、已有会话权限刷新、错误 PATCH envelope、并发删除确认状态仍有遗漏。既有 feature flag 与 Smoke 的测试对象/启动配置也存在不一致。单元测试、构建与 Smoke self-test 通过,不足以证明权限与功能可用性闭环。
|
||||
|
||||
本轮没有证据表明“删除 Enterprise 判断直接造成了新的后端越权”。尤其需要注意:**基线 `usePolicy` 的 Enterprise 判断已固定返回 `true`**;多数相关路由删除的是已不生效的判断,Billing 的 Cloud-only 条件才是真实行为变化。不要把所有旧问题误报成本次新增回归。
|
||||
|
||||
## 2. 必须保留的设计边界
|
||||
|
||||
- 六个二值权限:key 存在即拥有该维度完整权限,不存在即无权限。不引入 `full/read/none` 三态 UI。
|
||||
- `GetPermissionMap()` 对有效维度的旧非 `none` 值归一化为 `full`,是已选择的兼容策略;未知维度仍为 `none`。上线前需检查旧 `read` 数据的影响,但不能未经确认改变此约定。
|
||||
- 保留账户权限、SuperAdmin 身份校验、业务 feature flag、合法 Cloud 计费/Paywall 边界。
|
||||
- `/enterprise/api/v1` 是兼容 API 路径,不等于安装版本限制,不能按字符串批量删除。
|
||||
- 不通过“全部启用 feature flag”或移除后端校验来掩盖入口/API 不一致。
|
||||
- 保留本次 `{ custom_role: data }` 请求封装、编辑权限数组克隆、列表错误/重试和删除影响提示;但将误导性的“降级”文案按 R11 修正。
|
||||
|
||||
## 3. 问题清单及最小修复
|
||||
|
||||
优先级:P1=权限安全或核心流程阻断;P2=交互回归、一致性与验证缺口。下列静态链路成立,不等于已执行真实用户端到端复现。
|
||||
|
||||
| 分类 | 条目 |
|
||||
| --- | --- |
|
||||
| P1 旧安全缺陷 | R1 管理员降权失败;R6 角色归属/有效性未校验及错误时扩大权限 |
|
||||
| 明确新增回归 | R2 非云 Billing;R3 客服页错误漏处理;R11 误导性删除文案 |
|
||||
| 既有异步竞争被部分修复显露 | R10 删除请求完成时清理了新的确认状态 |
|
||||
| 旧一致性/契约缺陷 | R4 feature flag;R7 零权限;R8 会话权限;R9 envelope |
|
||||
| 验证缺口 | R5 Smoke 对象、配置与断言不足 |
|
||||
|
||||
### R1 · P1 · 管理员改为自定义角色,可能没有真正降权【旧问题,本次未补齐】
|
||||
|
||||
**证据链**
|
||||
|
||||
- `frontend/app/javascript/dashboard/routes/dashboard/settings/agents/EditAgent.vue`:选择自定义角色时,只提交 `custom_role_id`,没有同时提交基础 `role: 'agent'`。
|
||||
- `backend/internal/repository/agent_repo.go` / `UpdateAgentWithActive`:只有角色非空才更新 `role`;写入自定义角色 ID 不会自动清除管理员身份。
|
||||
- `backend/internal/middleware/account_scope.go` / `AccountScopeWithService`:只有 `customRoleID > 0 && role != administrator` 才加载自定义权限;管理员走完整管理权限分支。
|
||||
- 上游参考:`docs/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb`。需结合 GoChat 自己的基础角色与授权语义处理,不能只移植关联字段赋值。
|
||||
|
||||
**触发与影响**:把现有管理员切换为权限受限的自定义角色;保存成功并不代表后端降权成功。残留 `administrator + custom_role_id` 组合可继续绕过自定义权限限制。
|
||||
|
||||
**最小修复**
|
||||
|
||||
1. 前端选择自定义角色时显式提交 `role: 'agent'` 与对应 ID。
|
||||
2. 后端服务层统一约束创建/更新状态转换,不能依赖某一个前端调用方;在同一次持久化中原子更新基础角色与关联 ID。显式同时指定 administrator 和自定义角色的矛盾请求应拒绝。
|
||||
3. 检查现有非法组合与权限缓存。数据修正前先盘点、确认意图,不直接批量降权所有关联管理员。
|
||||
4. 内置角色切换显式清空 `custom_role_id`,继续保留本次已有清空支持。
|
||||
|
||||
**验收**:管理员→受限角色后重新读取 profile,并实际调用一个允许、一个禁止的 API;禁止项必须返回 403。覆盖自定义角色→agent/administrator、跨账户 ID 拒绝,以及前端之外的直接 API 更新。
|
||||
|
||||
### R2 · P2 · 非云部署出现不可用的 Billing 导航【新增回归】
|
||||
|
||||
**证据链**
|
||||
|
||||
- `frontend/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js:8-25`:删除 Cloud-only metadata。
|
||||
- `frontend/app/javascript/dashboard/components-next/sidebar/provider.js:129-132`:只再检查 permissions / feature flags。
|
||||
- `frontend/app/javascript/dashboard/components-next/sidebar/Sidebar.vue:794-799`:无条件定义 Billing 菜单项。
|
||||
- `frontend/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue:79-98,140`:非云部署直接返回首页,并在拉取账单信息前退出。
|
||||
- `frontend/app/javascript/shared/store/globalConfig.js:55-57`:只有 `DEPLOYMENT_ENV === 'cloud'` 才认定为云部署;GoChat 默认后端运行配置未提供该值。
|
||||
- 上游同时存在 Cloud-only 菜单条件和页面自身 guard,并非两种企业/社区版本判断。
|
||||
|
||||
**影响**:普通非云部署管理员看到“账单”,点击后被跳回首页。当前证据不支持“新触发了扣费”或“新绕过后端计费权限”。
|
||||
|
||||
**最小修复**:用 Sidebar 已有的 `isOnChatwootCloud` 状态有条件地加入 Billing 菜单项;保留页面直链 guard。不恢复 `INSTALLATION_TYPES`,不移除页面 guard 来迁就菜单。
|
||||
|
||||
**验收**:非云普通/定制品牌管理员在展开与折叠菜单均看不到入口;直链仍安全跳转且无 subscription/checkout 请求。Cloud 管理员保持可见;普通 agent 与无管理权限的自定义角色仍不能访问。
|
||||
|
||||
### R3 · P2 · 角色列表错误传播未覆盖客服列表调用方【新增回归】
|
||||
|
||||
**证据链**
|
||||
|
||||
- `frontend/app/javascript/dashboard/store/modules/customRole.js`:`getCustomRole` 失败时现在通过 `throwErrorMessage` 向调用方抛出。
|
||||
- `frontend/app/javascript/dashboard/routes/dashboard/settings/agents/Index.vue:53-56`:`onMounted` 仍裸调用 `store.dispatch('customRole/getCustomRole')`,没有 await/catch。
|
||||
- 自定义角色 Index 已处理失败;客服列表这一调用点没有同步适配。上游调用方式不能直接作为依据,因为本次本地 Store 的错误契约已经变化。
|
||||
|
||||
**影响**:403、500、断网等情况下,角色映射加载失败成为未处理拒绝,页面没有明确的角色数据不可用提示。不要夸大为已证明“整个客服列表必然崩溃”。
|
||||
|
||||
**最小修复**:在页面边界捕获失败,保留客服列表的独立加载;明确角色数据加载失败/重试状态。不要把 Store 改回吞错。角色尚未解析时不得静默改写为内置角色或允许提交错误身份。
|
||||
|
||||
**验收**:角色请求 403/500/网络失败时,无 unhandled rejection,客服列表仍可显示,角色字段明确降级并可重试;请求成功后角色名称与编辑选择正确恢复。
|
||||
|
||||
### R4 · P2 · 普通非云策略忽略 feature flag,入口与实际行为不一致【旧问题】
|
||||
|
||||
**证据链**
|
||||
|
||||
- `frontend/app/javascript/dashboard/composables/usePolicy.js:47-60`:定制品牌/Cloud 分支检查 flags,普通非云分支无条件返回 `true`;HEAD 已存在此行为。
|
||||
- `frontend/app/javascript/dashboard/modules/search/components/SearchHeader.vue:52-60` 可展示高级筛选,但 `SearchView.vue:248-268,277-283,295-300` 在 `advanced_search` 未开启时会剔除对应参数。入口可用但筛选不生效。
|
||||
- `frontend/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue:83-86` 在无 Paywall 时加载;后端 `captain_custom_tool_handler.go` / `captain_custom_tool_service.go` 仍要求 `custom_tools || captain_integration_v2`,两者关闭时返回 403。
|
||||
|
||||
**最小修复**:先逐项区分业务可用性与升级入口,再让普通非云策略尊重真实可用性。保留 Cloud premium upsell,保留 Custom Tools 的“任一 flag 可用”等模块特例;直链关闭功能时显示不可用状态,不应仅凭“无 Paywall”就发请求。
|
||||
|
||||
**验收**:权限拒绝优先;advanced_search 开关同时控制 UI 和请求参数;Custom Tools 覆盖两个 flag 都关闭/任一开启;Cloud SLA/Custom Role Paywall 与定制品牌行为不退化。
|
||||
|
||||
### R5 · P2 · 现有 Smoke 不能证明这次移除版本判断的正确性【原有验证缺口】
|
||||
|
||||
**证据链**
|
||||
|
||||
1. `backend/scripts/parity_frontend_smoke.sh:7,36,795` 默认使用 `docs/chatwoot`,不是修改后的 `frontend/`;浏览器模式连接既有 Vite 服务,传目录不等于证明服务来源。
|
||||
2. `backend/scripts/parity_frontend_browser_smoke.mjs:108-148` 注入 Cloud globals,缺少非云部署用例,无法发现 R2。
|
||||
3. 独立 SPA 的 `frontend/app/javascript/entrypoints/dashboardConfig.js:1-40` 从 `window.__GOCHAT_CONFIG__` 重建 globals;旧 Smoke 注入方式会被覆盖。仅把目录改成 `frontend/` 仍不够。
|
||||
4. Shell 检查 `/vite-dev/entrypoints/dashboard.js`,独立 SPA 入口却是 `/app/javascript/entrypoints/dashboard.js`。
|
||||
5. `--self-test` 只检查请求分类;实际扩展 Smoke 主要检查挂载和请求命中,不覆盖侧栏点击、禁止态、最终路由和六权限隔离。记录 runtime error 也不等于把它作为失败门槛。
|
||||
|
||||
**最小修复**
|
||||
|
||||
- 明确区分“GoChat 独立 SPA 验证”和“上游兼容参考验证”,报告记录实际服务源与启动配置。
|
||||
- 独立 SPA fixture 使用 `__GOCHAT_CONFIG__` 契约,补非云/Cloud 两组场景。
|
||||
- 加入角色权限、flag 关闭与侧栏/直链断言;runtime error 与 unhandled rejection 纳入验收。
|
||||
- `--enterprise`/`enterpriseMode` 当前仍可作为功能测试组选择器;不是安装版本开关,不必为文字清理盲删。上一轮“已移除相关 Smoke 配置”的说法应收窄为删除 `isEnterprise`、`enterprisePlanName`、`IS_ENTERPRISE` 三个 fixture 字段。
|
||||
- 不把 route-request smoke 的成功描述为 Captain、支付或所有企业功能完成验证。
|
||||
|
||||
**验收**:修复前能检测 R2/R3,修复后通过;有明确的权限/flag 负例;测试实际修改的 GoChat SPA。任何会执行 subscription/checkout 的验证只能在隔离测试账户、DB 和支付 fixture 中执行,不能打真实计费服务。
|
||||
|
||||
### R6 · P1 · 未校验角色归属/有效性,且查询失败回退到 Agent 权限【旧安全缺陷】
|
||||
|
||||
**证据链**
|
||||
|
||||
- `backend/internal/service/agent_service.go` / `Create`、`Update`:校验 DTO 后直接传递 `custom_role_id`,未检查角色是否存活且属于目标账户。
|
||||
- `backend/internal/repository/agent_repo.go` / `UpdateAgentWithActive`:直接写入该 ID。
|
||||
- `backend/internal/service/rbac_service.go` / `GetCustomRole`:仅按 ID 读取角色。
|
||||
- `backend/internal/middleware/account_scope.go` / `AccountScopeWithService`:自定义角色读取或解析失败时,使用 `AgentDefaultPermissions`;`backend/internal/auth/policy.go` 的 Agent 默认权限包含会话/联系人读取,且会话读取允许回复。
|
||||
|
||||
**触发与影响**
|
||||
|
||||
1. 账户管理员提交其它账户的有效角色 ID,可以把外部角色权限及元数据关联到本账户成员;这不等于已经能访问其它账户的会话。
|
||||
2. 弹窗缓存角色 R,另一管理员删除 R,旧弹窗随后保存 R。无有效性校验会重新写入已删除 ID;后续读取失败时,原先仅报表/零权限用户可能得到会话、联系人和回复能力。
|
||||
3. 已分配角色的数据损坏/读取错误也可能触发同样的宽松回退。不是本次二值归一化造成的。
|
||||
|
||||
**最小修复**:创建/更新服务复用同账户、未删除角色查询,失败时拒绝整个修改,避免部分姓名/身份已保存。授权解析同样校验账户范围,并在读取/解析失败时拒绝授权或返回明确服务错误,不能自动授予 Agent 权限;同步核对 `BuildPolicyContext` 的同类回退。合法删除已在事务内清空关联,不需要依赖错误回退实现转为 Agent。实现时保护角色校验与写入之间的删除竞争。
|
||||
|
||||
**验收**:创建/更新分别覆盖外账户、不存在、软删除 ID,均无部分写入;查询失败/坏数据不能增加读取与回复权限;确定性复现“打开弹窗→删除角色→旧弹窗保存”;PostgreSQL 补删除/分配并发测试。并发调度本轮未实测。
|
||||
|
||||
### R7 · P2 · 零权限角色 API 合法,但表单拒绝且路由循环【旧契约缺口】
|
||||
|
||||
**证据**:`backend/internal/service/custom_role_service.go` 接受 `permissions: []`,profile 序列化为 `['custom_role']`。`CustomRoleModal.vue:49` 却要求至少一个权限;`frontend/app/javascript/dashboard/helper/routeHelpers.js:21-38` 在六权限均不匹配时退回 dashboard,而 `conversation.routes.js:6-12,48-52` 不允许只有 `custom_role` 标记的用户进入。独立评审用真实 Vue Router 的内存路由复现 `Infinite redirect in navigation guard`。新 SuperAdmin guard 也复用这一旧 fallback。
|
||||
|
||||
**最小修复**:允许空复选框数组。无业务权限时复用已有 `/accounts/:accountId/profile/settings` 作为合法落点,该路由已允许 `custom_role`;不赠送会话权限,不新增角色维度。
|
||||
|
||||
**验收**:创建/清空/仅改名的零权限角色;旧 all-none/未知维度角色;登录与直达受限/SuperAdmin URL 都能终止导航,且业务 API 保持拒绝。
|
||||
|
||||
### R8 · P2 · 角色变更未刷新已登录用户的前端权限【旧一致性缺陷】
|
||||
|
||||
**证据**:`frontend/app/javascript/dashboard/store/modules/customRole.js:51-77` 只更新角色记录;`agents.js:70-77` 只更新客服记录。`frontend/app/javascript/dashboard/routes/index.js:74-83` 复用初始化 profile Promise。角色变更没有通知受影响用户重新读取 profile;现有缓存失效监听只刷新 labels/inboxes/teams。
|
||||
|
||||
**影响**:另一客户端编辑/删除角色后,已打开 SPA 的权限、菜单、路由仍可能过期,新增权限不可见或被收回的入口残留。正常请求中间件每次重新读取成员与角色,**不能把 UI 过期概括为后端继续允许已撤销权限**;R1/R6 是单独的问题。
|
||||
|
||||
**最小修复**:复用 `auth.js` 的 profile 获取和路由校验路径,提交成功后通知受影响会话;已有 `page:reload` 处理器可作为低成本方案,不建立第二套权限缓存。若先采用 focus/reconnect/重新进入时刷新,应明确其延迟边界,不能声称即时生效。
|
||||
|
||||
**验收**:两客户端进行角色编辑/删除/重新分配,受影响用户获得新 profile、正确菜单和合法路由;另行验证下一请求的后端权限。真实 WebSocket 撤权本轮未测。
|
||||
|
||||
### R9 · P2 · 错误 PATCH envelope 返回成功但没有修改【旧后端契约缺陷】
|
||||
|
||||
**证据**:`backend/internal/handler/api/v1/custom_role_handler.go` / `Update` 使用非必填值类型的 `custom_role` wrapper;缺失或 null 得到零值请求,`CustomRoleService.Update` 视为字段未提供,最终仍返回 200。上游 `docs/chatwoot/enterprise/app/controllers/api/v1/accounts/custom_roles_controller.rb:25` 明确要求 wrapper。
|
||||
|
||||
**影响**:本次前端封装修好了正常调用,但其它调用方发送平铺权限撤销/改名请求时仍可能误以为成功。
|
||||
|
||||
**最小修复**:创建/更新显式要求存在且非 null 的 envelope;保留合法 wrapper 内的部分更新,严格区分 permissions 未提供与 `[]`。
|
||||
|
||||
**验收**:平铺、缺失、null wrapper 返回 4xx 且不修改;合法部分更新和 `[]` 成功。前端 API 测试同时验证真实账户作用域 URL,而不只检查测试用未带账户路径下的请求体。
|
||||
|
||||
### R10 · P2 · 前一次删除完成会清空后一次确认状态【旧竞争,本次局部修复使 loading 问题显露】
|
||||
|
||||
**证据**:`frontend/app/javascript/dashboard/routes/dashboard/settings/customRoles/Index.vue:79-81,103-126` 请求捕获删除 ID,但结束时通过当前 `activeResponse` 清理 loading 和选择。确认删除 A、打开 B 的确认框、再收到 A 的结果时,会清掉 B 的选择并遗留 A 的 loading。独立评审用实际组件回调的内存执行复现;继续确认 B 会传出 undefined ID,而不是已证明删错数据库记录。
|
||||
|
||||
**最小修复**:按请求捕获的 ID 清理 loading;只有活动确认仍指向该 ID 才清空选择。或者简单串行化删除,不允许旧请求清理新弹窗。
|
||||
|
||||
**验收**:延迟 A 的成功/失败回包并打开 B,A loading 正确结束,B 保留 ID/名称且只提交 B 的 ID。
|
||||
|
||||
### R11 · P2 · 删除提示称“降级”,实际可能增加权限【新增误导文案】
|
||||
|
||||
**证据**:`frontend/app/javascript/dashboard/i18n/locale/en/customRole.json:90` 使用 `downgrade`,中文同位置为“降级”。`backend/internal/repository/custom_role_repo.go:76-83` 实际转为默认 Agent,可能给仅报表/知识库/零权限成员增加会话、联系人和回复权限。
|
||||
|
||||
**最小修复**:改成“重新分配为默认客服(Agent)角色,部分权限可能增加”,中英文一致。保留已约定的删除后转 Agent 行为,不暗中修改删除策略。
|
||||
|
||||
**验收**:确认框明确提示权限可能增加;受限角色删除测试比较删除前后真实权限,不仅检查 `role=agent`、`custom_role_id=0`。
|
||||
|
||||
## 4. 实施顺序与拆分
|
||||
|
||||
| 阶段 | 内容 | 完成门槛 |
|
||||
| --- | --- | --- |
|
||||
| A · 权限安全 | R1、R6:原子身份转换、角色归属/有效性、失败时不扩大权限 | 实际 Agent 服务/handler 与下一请求拒绝断言;外账户/失效 ID 无部分写入 |
|
||||
| B · UI 与契约闭环 | R2、R3、R7、R9、R10、R11,分别小改动提交 | Billing 边界、错误恢复、零权限、envelope、删除竞争/提示各有负例 |
|
||||
| C · 会话权限刷新 | R8:复用 profile 与现有通知能力 | 两客户端验证;明确即时或延迟刷新边界 |
|
||||
| D · 业务开关一致性 | R4:逐项梳理策略与直链行为 | flags on/off、Cloud/非云、权限允许/拒绝矩阵通过 |
|
||||
| E · 验收工具可信化 | R5:真实源/配置契约、错误门槛、角色/部署负例 | 在隔离环境完成实际 UI 验证,报告写明来源与能力边界 |
|
||||
| F · 发布前核对 | 六权限、历史数据影响与角色生命周期回归 | 创建/编辑/分配/删除/重登录/已有会话均符合约定 |
|
||||
|
||||
每阶段独立变更、独立测试。不要把翻译库、整个 Smoke 格式化或其它业务模块重写混进修复。涉及既有账号权限数据的批量修正,须在盘点后单独确认。
|
||||
|
||||
## 5. 必补的回归矩阵
|
||||
|
||||
- **身份**:administrator、agent、每一个单权限自定义角色、零权限角色、SuperAdmin、跨账户成员。
|
||||
- **生命周期**:创建、无改动编辑、增/删某个权限、分配、管理员降权、切回内置角色、删除被使用角色、刷新已有会话。
|
||||
- **异步状态**:空 Store 打开弹窗、角色请求慢/失败、重试成功、角色在弹窗打开期间被删除;均不得空引用、无反馈提交或误降级/提权。
|
||||
- **策略**:普通非云、定制品牌非云、Cloud;相关 flag on/off;侧栏展开/折叠、搜索入口、直接 URL。
|
||||
- **安全验收**:UI 不可见不是后端拒绝的替代品;profile 列出的权限、实际 API 的 403/允许结果和页面行为需一致。
|
||||
- **兼容**:旧 `read/full/none` 数据与未知维度、已有 API 路径、Paywall 与 SuperAdmin route guard。
|
||||
|
||||
## 6. 本轮验证事实与未验证项
|
||||
|
||||
本轮修复后已重跑:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm exec vitest run \
|
||||
app/javascript/v3/helpers/specs/RouteHelper.spec.js \
|
||||
app/javascript/dashboard/helper/specs/routeHelpers.spec.js \
|
||||
app/javascript/dashboard/api/specs/customRole.spec.js \
|
||||
app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRoleModal.spec.js \
|
||||
app/javascript/dashboard/store/modules/specs/customRole/actions.spec.js \
|
||||
app/javascript/dashboard/store/modules/specs/customRole/mutations.spec.js \
|
||||
app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js
|
||||
|
||||
cd ../backend
|
||||
GOCHAT_TEST_DB=sqlite go test ./internal/model ./internal/service ./internal/handler/api/v1 -run CustomRole -count=1
|
||||
|
||||
cd ..
|
||||
git diff --check
|
||||
node --check backend/scripts/parity_frontend_browser_smoke.mjs
|
||||
node backend/scripts/parity_frontend_browser_smoke.mjs --self-test
|
||||
```
|
||||
|
||||
- 定向 Go service/repository/handler/middleware 测试通过;Go build、go vet 通过。完整 service 包仍有既有 Captain 并发/PostgreSQL smoke 失败,不归因于本次改动。
|
||||
- 相关 Vitest、前端生产构建、Node/shell 语法和 Smoke 分类 self-test 通过;相关 JavaScript ESLint 目标文件通过,Sidebar 保留的既有全文件 Prettier 缩进问题未整体格式化。
|
||||
- 新增覆盖:Agent 自定义角色账户范围/管理员矛盾状态、CustomRole envelope、非云 Billing 直链、客服角色加载失败、零权限角色落点及删除状态竞争。
|
||||
- 事件写入已复用现有 `page:reload`,但未执行真实双客户端 WebSocket 权限刷新验收。
|
||||
- 未执行真实浏览器、PostgreSQL 端到端或支付/provider 验证;Smoke 已改为默认使用 GoChat `frontend/`,并支持 Cloud/非云部署 fixture 与 Billing/runtime-exception 门禁。
|
||||
|
||||
## 7. 发布判断
|
||||
|
||||
R1/R6 两项权限安全缺陷、R2/R3/R11 新增回归及 R7–R10 生命周期问题的最小代码修复已完成。完成真实浏览器、WebSocket、PostgreSQL 和支付/provider 边界验证前,仍不应宣称“角色权限生效流程已闭环、无版本判断后的前端验证完成”。R4/R5 单列为基线缺陷和验收债务,不冒充本次删除造成的后端权限漏洞。
|
||||
|
||||
评审证据:工作流 `041489bb-e92b-45a3-ae71-57e7e5b26900`,两份只读结果为 `edition-review.md`、`rbac-review.md`,保存在本机 `/home/rogee/.pi/agent/sessions/--home-rogee-Projects-gochat--/subagent-artifacts/outputs/041489bb-e92b-45a3-ae71-57e7e5b26900/`。主要证据及方案已完整转录到本文,阅读本文不依赖该本机目录。
|
||||
@@ -0,0 +1,295 @@
|
||||
# 商务通消息与联系人/会话资料能力缺口修复计划
|
||||
|
||||
> 日期:2026-09-12
|
||||
> 状态:已完成文档补缺审查;修复待实施,范围决策待确认;现有自动化基线通过不代表新增能力完成。
|
||||
>
|
||||
> 审查基线:`main@6e62f5094fa2381c5b283eeb9be3f94a95096f1c` 加 2026-09-12 当前未提交工作树(包括分类相关改动),不是纯 HEAD 或已发布版本。
|
||||
>
|
||||
> 总计划:[`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)
|
||||
|
||||
## 1. 目的
|
||||
|
||||
补齐商务通 Connector 在以下方面的产品和技术缺口:
|
||||
|
||||
- 消息收发后的资料一致性与历史能力边界;
|
||||
- 联系人姓名、远程联系人备注 `cnote`;
|
||||
- 商务通会话分类、客户颜色分类;
|
||||
- GoChat 原生 Contact Label、Conversation Label、Contact Note;
|
||||
- CRM 客户标签与商务通分类之间的边界;
|
||||
- 失败、重试、权限、幂等和真实账号灰度验收。
|
||||
|
||||
## 2. 当前基线与概念边界
|
||||
|
||||
### 2.1 已有基线
|
||||
|
||||
- 文本、图片、文件、语音、状态、撤回、输入状态以及部分会话控制已有 Connector 链路。
|
||||
- 联系人 CID 按 `(inbox, source_id)` 保存在 `ContactInbox.ChannelMetadata`。
|
||||
- 联系人姓名已有 GoChat → Connector → 商务通 `changecname.aspx` 的异步链路。
|
||||
- 商务通分类 catalog、缓存、会话分类出站操作和结果回写已有实现。
|
||||
- GoChat 原生联系人备注、联系人标签、会话标签已有本地 API 或前端能力。
|
||||
|
||||
### 2.2 必须保持独立的概念
|
||||
|
||||
| 概念 | 存储/接口 | 本计划中的处理 |
|
||||
| --- | --- | --- |
|
||||
| 商务通 `visitors.chatkind` | 商务通会话分类 | 不转成 GoChat Conversation Label |
|
||||
| 商务通 `visitors.colors` | 商务通客户级颜色分类 | 不转成 Contact Label 或 CRM 标签 |
|
||||
| GoChat Contact Label | `Tag`/contact-label 关系 | 仅 GoChat 本地能力 |
|
||||
| GoChat Conversation Label | 普通更新链路为 `conversations.labels`;另有 conversation-label 关系路径 | 仅 GoChat 本地能力;需修复响应契约并核对两条路径一致性(R13) |
|
||||
| GoChat Contact Note | GoChat 联系人备注记录 | 不自动写入商务通 `cnote` |
|
||||
| 商务通 `cnote` | 商务通远程联系人备注 | 单独设计双向协议,不能隐式复用 Contact Note |
|
||||
| CRM 客户标签 | `CategoryInFo`、`ClientLabelConfig` 等 | 本期不与商务通分类互相转换 |
|
||||
|
||||
## 3. 修复清单
|
||||
|
||||
| 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-R05 | P1 | 待实施 | 补齐商务通远程 `chatkind` → GoChat 的入站同步,并区分远程事件与 GoChat 发起操作的确认回调。 |
|
||||
| SWT-R06 | P1 | 待实施 | 明确客户颜色的渠道客户级作用域。至少以 `(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-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 回传失败可恢复性;保留不确定证据,补账号级队头阻塞监控与恢复验收。 |
|
||||
|
||||
### 3.1 审查确认的具体缺口
|
||||
|
||||
以下为静态证据,不表示已通过失败复现测试;实现时以符号和实际工作树复核,不能将本节直接计为修复完成。
|
||||
|
||||
| 证据位置 | 当前事实 | 补充约束 |
|
||||
| --- | --- | --- |
|
||||
| `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 故障恢复,不能套用业务操作持久回传保证 |
|
||||
|
||||
### 3.2 实施前必须冻结的契约
|
||||
|
||||
- **身份与来源**:全局 `Contact.Name` 人工修改当前会广播到该联系人关联的多个商务通 inbox;远程入站姓名如何影响全局展示须确认。`cnote`、颜色和远程操作按渠道身份隔离,不因 Contact 合并而把不同站点资料混合;同时记录 SID、CID、source_id 和会话内部 ID/display_id 的用途,禁止靠 CID 或 display_id 猜测目标。
|
||||
- **字段语义**:`cnote` 缺失/未知 = 不修改,显式空值 = 请求清空(仅在协议验证且有权限时);姓名仅改名必须保留远程备注。缺字段不得覆盖已有姓名、备注或颜色;规定长度、编码、多行/特殊字符、日志脱敏和安全渲染。若远程接口强制带备注且无法安全保留,明确拒绝改名,而不是发送空串冒险。
|
||||
- **状态与一致性**:受理/持久化成功才返回 202;`pending → succeeded/failed/uncertain` 必须来自远程证据。超时不是失败或成功;结果回传重试不重新执行远程写。操作 ID 关联租户、inbox、配置/绑定代际、字段、目标值和原始操作者;不复用事件时间戳冒充可比较版本。
|
||||
- **并发与重放**:同一远程身份同一字段按确定顺序执行或串行确认,不让旧命令最后写回远程;不同字段允许独立状态。重复同键同负载返回同一结果,同键不同负载拒绝;未知/错目标/错 operation 的结果拒绝。终态重复幂等,旧结果不得回退新值;外部事件没有可靠版本时先冻结冲突/重新读取策略,不能用本机接收时间假定远程先后。
|
||||
- **持久化边界**:本地资料变更、待执行操作及待发事件不得在崩溃时留下无法发现的半状态;复用现有队列/outbox。定义排队/等待 CID/回调重试的截止、退避、最大尝试、告警与人工处理入口;具体配置和负责人在灰度前登记,不凭空承诺时限。重放期间再次修改姓名只按冻结的最新版本/串行策略执行,不补发过期值。
|
||||
- **生命周期**:合并后的原生标签保留策略需确认,上游并未在合并动作中明确标签并集,不能假称上游已有保证。软删除联系人后禁止通过旧 ID 操作资料;GoChat 删除不等于删除商务通客户。inbox 停用/删除/重绑及配置代际变化后,旧操作必须停止或经显式重新授权重建,不能投递到新站点。
|
||||
- **授权与范围**:普通座席、管理员、自定义角色和 Connector 服务主体逐接口列允许/拒绝;分别验证 `contact_manage`、`conversation_manage` 等现有二值 key 及 inbox/会话可见范围。服务签名和资源绑定不是用户管理权限;产品待决项保持未批准,不由本文默认为新增功能授权。
|
||||
|
||||
## 4. 实施阶段
|
||||
|
||||
### 阶段 0:契约和产品决策
|
||||
|
||||
- [ ] 决定 `cnote` 是否进入本期;若进入,定义字段长度、空值、权限、审计、冲突方向和删除语义。
|
||||
- [ ] 明确 GoChat Contact Note 是否永远只作为 CRM 内部记录,禁止自动映射到 `cnote`。
|
||||
- [ ] 冻结客户颜色的渠道客户级作用域,以及同一账号/inbox/CID 多会话的展示/更新规则;验证跨 inbox 同 CID 不互相覆盖。
|
||||
- [ ] 完成 §3.2 契约评审并登记负责人、决策日期、操作超时/恢复配置与验收责任人;未决功能默认不开放,但 R02 的备注保全、R13 的现有响应错误不因新功能决策而延期。
|
||||
- [ ] 冻结远程消息历史、编辑、反应能力的本期范围;不实现的能力必须列入“不支持”清单。
|
||||
- [ ] 明确普通座席、管理员和 Connector 服务主体分别可以执行哪些操作。
|
||||
|
||||
### 阶段 1:联系人姓名、CID 与远程备注
|
||||
|
||||
- [ ] 先为“仅改姓名、不损坏已有远程备注”建立协议门禁;协议未知时阻止危险请求,不能等待双向备注获批再处理。
|
||||
- [ ] 仅在 R01 批准后扩展 `contact_updated` 及消费端 `cnote` 解码、入站映射和持久化;单加 webhook 字段不足以打通链路。
|
||||
- [ ] 为 rename 建立可查询的 GoChat 结果状态:`pending`、`succeeded`、`failed`、`uncertain`;复用 `outbound_operations` 终态/结果回传,不新建一套通用调度系统。
|
||||
- [ ] 结果回调具有幂等键和持久重试;远程已执行但响应丢失维持不确定、禁止自动重发副作用。超过结果补传上限可告警并仅重放原结果,重启不等于死信已恢复。
|
||||
- [ ] CID 缺失时将姓名修改放入待处理队列;CID 到达后按联系人/inbox/source 幂等重放。
|
||||
- [ ] 对远程入站姓名/备注更新增加来源标记,避免把远程同步误判为新的人工修改。
|
||||
|
||||
### 阶段 2:商务通分类一致性
|
||||
|
||||
- [ ] 先取得远程 `chatkind`/姓名/备注事件的真实脱敏样本或可靠协议证据,再实现解析、定位和更新;kind=18/19/20 在现有映射与参考字典中未确认,继续 raw-only,禁止按数字或图标资源名猜语义。
|
||||
- [ ] 分别验证 catalog 同步、业务字段入站、出站写入及结果回传;kind=29 空颜色的入站证据不能替代出站 `RESET` 协议验收。
|
||||
- [ ] catalog 请求失败且失败状态回传也失败时仍可追踪/恢复;区分同步请求受理与目录已更新,不无限保留虚假 syncing。
|
||||
- [ ] 将客户颜色分类的远程 ID、名称和显示值统一成明确的数据契约。
|
||||
- [ ] 以 `(account_id, inbox_id, CID)` 维护客户级颜色状态;仅同一远程身份的相关会话读取同一确认值。若暂不支持多会话传播,必须明确降级且不得将 R06 标为完成。
|
||||
- [ ] 分类修改失败/不确定时保留明确状态,不显示虚假成功。
|
||||
- [ ] 评估并实现 `RESET` 清除能力;协议未验证前继续保持禁用。
|
||||
- [ ] 为分类修改补充角色权限校验和跨 inbox/跨 account 负向测试。
|
||||
- [ ] R14:保存实际 pending 操作并核对结果关联;catalog 请求/回调按代际处理,原子更新属性,不丢并行消息/分类产生的无关字段。
|
||||
- [ ] R17:分类与颜色分别展示已确认值、请求值、pending/失败/不确定;切换会话/inbox/卸载后旧查询不可污染当前 UI。catalog 名称变更、ID 删除/未知/过期和失败后旧缓存的可用策略须明确。
|
||||
|
||||
### 阶段 3:GoChat 原生资料能力
|
||||
|
||||
- [ ] 保持商务通分类与 Contact Label、Conversation Label、CRM 客户标签不互相转换。
|
||||
- [ ] 如产品确认需要,在商务通会话联系人侧栏显示 Contact Label;标签操作仍只调用 GoChat 原生 API。
|
||||
- [ ] 以当前 HTTP 链路 `notes` 为基线,盘点 `contact_notes` 的历史记录和全部写入方;若需迁移,使用编号 SQL 迁移,处理账户归属、ID 冲突、作者缺失,核对记录数/内容/作者/时间并验证备份恢复。证据不足只标记弃用,不删表、不启动双写。
|
||||
- [ ] 补 contactNotes API/store 的 update,再接会话侧栏和联系人详情两处 UI;失败保留草稿,不显示虚假成功,切换联系人不串写。
|
||||
- [ ] R13:统一会话标签 GET/更新的 `payload: string[]` 契约,覆盖普通/批量更新、关联移除、筛选、重新进入页面和空数组清空;选择与现有读写兼容的最小修复,不先做无关表结构重构。
|
||||
- [ ] R08:联系人标签组件以 prop 正确首次加载,加载失败禁用基于空集合的替换;区分移除联系人关联与删除账户标签。
|
||||
- [ ] R15/R16:所有原生资料入口统一有效父联系人、角色及资源校验;补 pending 时合并/删除和旧回调场景。
|
||||
|
||||
### 阶段 4:消息能力边界
|
||||
|
||||
- [ ] 分开决定 kind=52 被动历史正文导入和主动历史拉取;现有 `mapping_test.go` 明确锁定 kind=52 仅取 CID,新增正文导入需同步修改契约测试,而不是把测试通过当作已支持历史。
|
||||
- [ ] 若批准被动导入,校验多条消息、原始消息 ID、时间/时区、发送者方向、嵌套/损坏字段、附件及撤回;与实时 kind=2/3 去重,历史补录不得触发新消息通知、未读累加或新的自动回复。
|
||||
- [ ] 若批准主动拉取,另行冻结远程接口可用性、权限、分页/断点、停止条件、流量限制与去重;不能仅凭收到了 kind=52 宣称支持拉取。
|
||||
- [ ] 未批准/未实现的被动历史正文导入、主动历史读取、消息编辑和反应逐项在 UI/API/README 标不支持,不以 2xx 空结果冒充支持。
|
||||
- [ ] 保持现有消息发送的 progress/sent/failed/uncertain 语义,不因 HTTP 202 直接显示已发送。
|
||||
|
||||
### 阶段 5:验证、灰度和发布门禁
|
||||
|
||||
- [ ] 单元测试覆盖协议解析、空值、重复事件、CID 竞态、ID/名称规范化和 RESET。
|
||||
- [ ] 集成测试覆盖:
|
||||
- [ ] GoChat 改名 → webhook → Connector → 商务通接口 → 结果回写;
|
||||
- [ ] 商务通改名/备注 → Connector → GoChat;
|
||||
- [ ] 无 CID、CID 延迟到达、重复 webhook、超时和不确定结果;
|
||||
- [ ] 同一账号/inbox/CID 多会话的客户颜色一致性,以及跨 inbox 同 CID 隔离;
|
||||
- [ ] 远程分类与原生标签/CRM 标签互不污染;
|
||||
- [ ] 备注 UI 与实际数据库表一致。
|
||||
- [ ] 增加 `ShangwutongClassifications.vue` 组件级测试。
|
||||
- [ ] 真实账号灰度验证至少覆盖一个改名、一个颜色分类、一个会话分类、失败重试和同 CID 多会话。
|
||||
- [ ] 灰度通过后再将对应条目标记为完成;自动化测试通过不等于真实商务通协议已验收。
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
### 5.1 姓名与备注
|
||||
|
||||
- 人工改名最终能看到远程成功或明确失败/不确定状态。
|
||||
- 缺少 CID 不会静默丢失改名请求。
|
||||
- 商务通入站姓名/备注不会触发无意义的反向人工修改。
|
||||
- `cnote` 若纳入范围,GoChat 与商务通双向更新、空值和冲突语义均有测试;若不纳入,所有入口明确拒绝或隐藏。
|
||||
- GoChat Contact Note 不会被隐式写入商务通 `cnote`。
|
||||
|
||||
### 5.2 分类与标签
|
||||
|
||||
- `chatkind` 和客户颜色能分别读取、修改、确认和报告失败。
|
||||
- 客户颜色在同一账号/inbox/CID 多会话中的展示符合冻结作用域,跨渠道同 CID 不串写;远程确认值与本地待提交值分开。
|
||||
- 商务通分类不会创建、删除或修改 GoChat Contact Label、Conversation Label 或 CRM 标签。
|
||||
- 原生标签删除仍只影响 GoChat,不调用商务通分类接口;更新响应必须可被真实 store/composable 直接消费,两套读写入口一致,不能仅通过 mock 测试。
|
||||
- 未同步 catalog、失效 CID、无权限和跨 inbox 请求均有明确错误。
|
||||
|
||||
### 5.3 消息
|
||||
|
||||
- 已支持的消息类型继续保持持久化队列、幂等和最终状态回写。
|
||||
- 历史读取、编辑、反应若未实现,文档和 UI 不得暗示已支持。
|
||||
|
||||
## 6. 能力验证与发布门禁
|
||||
|
||||
### 6.1 当前能力矩阵
|
||||
|
||||
“已有链路”仅说明源码/现有测试存在,不等于真实账号验收;未提供真机证据的项目均不得宣传为已完成。
|
||||
|
||||
| 能力 | 当前状态与边界 | 修复/验收索引 |
|
||||
| --- | --- | --- |
|
||||
| 实时消息及最终状态 | 已有链路;本轮只跑本地回归,未重新验证所有真实消息类型 | R10/R11,V23/V24 |
|
||||
| kind=52 被动历史 | 仅提取 CID,不导入历史正文;保留原始事件不等于页面可读历史 | R10,V21 |
|
||||
| 主动历史拉取、编辑、反应 | 未见完整能力;逐项待范围确认,不能与撤回混同 | R10,V22 |
|
||||
| 人工姓名出站 | 已有异步执行;缺最终状态、缺 CID 意图保全,另有空备注风险 | R02–R04/R18,V01/V03–V06 |
|
||||
| 远程姓名/备注入站 | 事件字段/来源与冲突契约待验证,不把普通消息带姓名当作完整资料事件同步 | R01/R02,V02/V07 |
|
||||
| `cnote` 双向编辑 | 未实现、未获范围确认;独立于 Contact Note | R01/R02,V01/V02 |
|
||||
| 分类 catalog | 有读取/缓存/手工同步;失败回传和同步代际保证不完整 | R14/R18,V11/V12 |
|
||||
| 会话分类出站 / 远程入站 | 出站及结果队列已有;独立远程修改事件尚待证据与实现 | R05/R14,V07–V09 |
|
||||
| 客户颜色入站/出站 | kind=29 当前只写会话文本属性;出站已有,客户级传播和 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 |
|
||||
| CRM 标签 | 保持独立,本期不新增映射或同步 | R12,V19 |
|
||||
|
||||
### 6.2 必须新增或补齐的验收矩阵
|
||||
|
||||
状态统一为**待实施/待验证**,不能因 §6.3 旧测试通过直接勾选。每条最终需登记测试路径/命令、环境、结果、操作者、时间与证据位置;范围未批准的条目标记“不纳入+决策记录”,不是“通过”。P0 安全与权限负向用例不可用不纳入规避。
|
||||
|
||||
| 编号 | 修复项 | 场景与通过标准 | 所需证据层级 |
|
||||
| --- | --- | --- | --- |
|
||||
| V01 | R02 | 远程已有非空 cnote,只改姓名;验证缺省/空串/原值请求形态,不丢备注;协议不明时明确阻止 | 协议 fixture+经授权真实账号 |
|
||||
| V02 | R01/R02 | 若批准 cnote:双向新增/修改/清空、缺失不覆盖、中文多行/特殊字符/超长拒绝、并发冲突;本地 Note 不外泄 | 单元+跨服务+真实账号 |
|
||||
| V03 | R03/R04 | 改名前无 CID、CID 延迟/先于联系人到达、期间连续改名、24h 到期、重启恢复;意图不丢、旧值不反盖 | 持久队列集成+UI |
|
||||
| V04 | R03/R18 | 202 仅受理;真实成功/明确拒绝/响应丢失分别显示 succeeded/failed/uncertain;刷新保留最终状态 | 跨服务+真实账号 |
|
||||
| V05 | R14/R18 | 远程成功后本地终态提交前/后、回调前/后分别崩溃;已确认执行只补结果,无法确定执行保留不确定 | 故障注入+远程调用计数 |
|
||||
| V06 | R18 | 回调连续失败至 10 次耗尽、再恢复;可见死信并仅重放结果;覆盖另一 SID 的账号队头阻塞和固定不确定截止 | 队列集成+告警/补偿记录 |
|
||||
| V07 | R02/R05 | 远程独立改名/备注/chatkind;来源标记阻止反向循环;未验证的 kind18/19/20 仅保留 raw,不污染资料 | fixture+真实事件样本 |
|
||||
| V08 | R14 | 重复同键同值/同键异值、未知操作、错字段/错目标/旧代际回调;只接受实际操作且拒绝冲突关联 | 真实路由集成 |
|
||||
| V09 | R14/R17 | A→B 连续修改、B 后收到 A 回调、颜色与分类并行、同时消息更新属性;不回退、不丢无关属性、状态互不覆盖 | PostgreSQL 并发+组件 |
|
||||
| V10 | R06 | 同账号/inbox/CID 多 SID 传播;跨账号/inbox 相同 CID、合并联系人多个渠道身份不串写 | PostgreSQL+真实账号 |
|
||||
| V11 | R14/R18 | catalog 远程读取失败、失败状态回传失败、重复成功、旧失败晚到、进程重启;状态可恢复且旧代际无效 | 跨服务故障注入 |
|
||||
| V12 | R06/R17 | catalog 未同步/空列表/过期/ID删除/重命名、kind29空值/未知ID;保留远程 ID,按约定显示未知或清除 | 协议 fixture+组件 |
|
||||
| V13 | R05/R06 | 分类与颜色 RESET 分别验证远程请求及回读;未通过时按钮不可用且服务端拒绝 | 负向自动化+授权真实账号 |
|
||||
| V14 | R07/R16 | 普通座席/管理员/六维自定义角色/服务主体,越权 account/inbox/contact/SID/display_id、过期/篡改签名及旧配置;无权请求无副作用 | 挂载真实 middleware 的路由集成 |
|
||||
| V15 | R13 | 真实 GET/POST/PATCH 标签响应进入 store/composable;数组 .includes 可用,普通/批量更新、筛选、清空、重载一致 | API 契约+跨层前端测试 |
|
||||
| V16 | R08 | 仅 prop 首次加载、切换联系人、加载失败后尝试更新、已有标签保留、移除关联;不调用商务通 | 组件+API 调用断言 |
|
||||
| V17 | R09/R16 | 两处备注 UI 新增/编辑/删除、失败保留草稿、跨租户/软删除父联系人拒绝;作者/时间/权限正确 | 真实路由+组件 |
|
||||
| V18 | R09/R15 | 若迁移:两表历史数据、作者缺失、ID冲突、联系人合并;数量/内容/归属不丢,备份可恢复,数据盘点未完成不得迁移删表 | 隔离 PostgreSQL 迁移/回滚 |
|
||||
| V19 | R01/R08/R12 | 原生标签/备注/CRM 操作不得触发商务通字段更新;商务通事件不得创建/删除这些原生资料 | 跨服务负向调用断言 |
|
||||
| V20 | R15 | pending 时联系人合并/删除/重建、inbox 删除/停用/重绑、配置换代;旧操作停发且旧回调不写新对象 | 生命周期集成 |
|
||||
| V21 | R10 | 若批准 kind52 正文:同事件多条/重复、与实时消息重叠、时区/方向/损坏字段/晚到撤回;无重复、串会话、新通知或自动回复 | 历史 fixture+跨服务 |
|
||||
| V22 | R10/R12 | 若批准主动历史:分页、断点、停止/限流、鉴权、去重;否则历史正文/主动拉取/编辑/反应入口分别隐藏或明确拒绝 | 范围决策+API/UI |
|
||||
| V23 | R11/R17 | 分类/颜色超过前端约 5s 等待、浏览器刷新、A会话切B、卸载、快速连点、失败重试;无虚假成功/串页/重复提交,键盘和错误提示可用 | 组件+授权测试环境浏览器 |
|
||||
| V24 | R10/R11 | 文本/图片/文件/语音、撤回先后、输入状态及会话控制回归;保留幂等与 progress/sent/failed/uncertain,资料修复不改变消息语义 | 现有自动化+授权真实账号 |
|
||||
| V25 | R02/R11/R18 | 全链路以操作 ID 关联但不记录明文姓名/备注/token/签名;日志、错误/UI 不泄露跨租户数据,补偿可审计 | 日志检查+负向测试 |
|
||||
|
||||
### 6.3 本轮实际执行的验证(2026-09-12)
|
||||
|
||||
以下命令从仓库根目录分别运行;只验证当前工作树既有代码,**本轮未新增业务代码或回归测试**。
|
||||
|
||||
```bash
|
||||
# Connector 全部现有测试;额外对关键包禁用缓存复跑
|
||||
(cd channels/shangwutong && go test ./...)
|
||||
(cd channels/shangwutong && go test -count=1 ./internal/swt ./internal/delivery ./internal/store ./internal/httpapi ./internal/account)
|
||||
|
||||
# 后端: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)
|
||||
|
||||
# 前端:既有 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)
|
||||
```
|
||||
|
||||
| 检查 | 本轮结果 | 不覆盖的内容 |
|
||||
| --- | --- | --- |
|
||||
| Connector 全套及五个关键包无缓存复跑 | 通过 | 真实服务 cnote/事件编号/RESET/历史语义;新增 V01–V25 |
|
||||
| 后端两个包定向测试 | 通过 | 完整 RBAC 路由、PG 并发/迁移、修复后的新契约 |
|
||||
| 前端 | 10 文件、33 测试通过 | 标签真实后端响应、分类组件竞态、备注编辑和浏览器 E2E |
|
||||
| 上游及本地源码对照 | 已核查并登记 §3.1 | 静态缺口不等于已运行失败复现测试 |
|
||||
|
||||
本地基线日志/改前备份位于 `/tmp/gochat-swt-plan-review-20260912/`(临时证据,不是 CI 长期归档);关键包无缓存复跑记录见本次 Connector 只读审查产物。合入/发布前必须把有效验收证据归档到团队可访问的 CI 或报告位置,并记录确切提交/工作树版本。原生标签 action mock 通过不能抵消 R13 的真实契约缺口。
|
||||
|
||||
本轮未做:真实商务通登录/写入、浏览器 E2E、生产 PostgreSQL 并发/迁移、故障注入、灰度/部署。所有新增验收项仍待验证。
|
||||
|
||||
### 6.4 发布、观察与回滚
|
||||
|
||||
- **进入实施**:R01/R07/R08/R10 等范围或权限待决项留明确决策,不擅自开放;R02 数据保全及现有错误修复优先。接口字段/状态以冻结契约为准,不在多份文档各维护不同版本。
|
||||
- **进入灰度**:适用 V01–V25 全部有自动化证据;涉及远程语义的 fixture 必须能追溯脱敏样本。备份、迁移回滚、结果补偿、操作审计及真实账号授权齐备,协议未知的能力继续关闭。
|
||||
- **真实验证**:登记专用测试站点/inbox、允许写入对象和原值、执行人、测试时间及恢复步骤;至少验证姓名且已有非空备注、会话分类、颜色、多 SID、一项失败/不确定与仅结果补传。不获授权不操作真实客户,不用反复写入推测远程结果。
|
||||
- **灰度观察**:按 inbox 查看 pending 数/最老年龄、缺 CID 到期、明确失败与 uncertain_timeout、回调重试/耗尽、catalog 最后成功时间与故障、账号队头阻塞;告警阈值、观察窗口和负责人在放量前填写,未填写不放量。
|
||||
- **停止条件**:备注误清空、跨租户/渠道串写、旧结果覆盖、重复远程副作用、迁移数据不一致立即停止对应出站功能;保存队列和审计证据,不能清空队列掩盖问题。代码回滚不撤销远程写入,恢复原值须核对远程当前值并重新授权。
|
||||
- **退出灰度**:范围内适用用例真实验证完成、无未处理 P0/P1、数据库兼容/回滚与结果补偿演练通过、README/runbook/能力矩阵一致,才逐项标记完成。仅本轮现有测试通过不可发布宣称新增能力。
|
||||
|
||||
## 7. 关联实现位置
|
||||
|
||||
- 联系人更新事件:`backend/internal/service/contact_service.go`
|
||||
- 商务通联系人监听:`backend/internal/service/shangwutong_contact_listener.go`
|
||||
- 联系人 webhook:`backend/internal/service/shangwutong_webhook_delivery.go`
|
||||
- Connector webhook 接收:`channels/shangwutong/internal/httpapi/server.go`
|
||||
- 入站映射:`channels/shangwutong/internal/delivery/mapping.go`
|
||||
- 联系人/消息 API client:`channels/shangwutong/internal/gochat/messaging.go`
|
||||
- 商务通姓名操作:`channels/shangwutong/internal/swt/operations.go`
|
||||
- 分类处理:`backend/internal/handler/api/v1/shangwutong_connector_handler.go`、`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`
|
||||
- GoChat 原生备注/标签:`backend/internal/service/contact_service.go`、`frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue`、`frontend/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue`、`frontend/app/javascript/dashboard/routes/dashboard/conversation/labels/LabelBox.vue`
|
||||
- 标签真实契约:`backend/internal/handler/api/v1/conversation_handler.go`、`backend/internal/service/conversation_service.go`、`frontend/app/javascript/dashboard/store/modules/conversationLabels.js`、`frontend/app/javascript/dashboard/composables/useConversationLabels.js`
|
||||
- 操作持久化/恢复:`channels/shangwutong/db/queries/operations.sql`、`channels/shangwutong/db/queries/inbound.sql`、`channels/shangwutong/internal/delivery/outbound.go`、`channels/shangwutong/internal/account/manager.go`
|
||||
- 联系人合并与历史备注:`backend/internal/repository/contact_merge_repo.go`、`backend/internal/model/note.go`、`backend/internal/model/contact_note.go`
|
||||
- Chatwoot 只读对照:`docs/chatwoot/app/controllers/api/v1/accounts/contacts/notes_controller.rb`、`docs/chatwoot/app/controllers/concerns/label_concern.rb`、`docs/chatwoot/app/views/api/v1/accounts/conversations/labels/create.json.jbuilder`、`docs/chatwoot/app/actions/contact_merge_action.rb`
|
||||
- 商务通协议只读证据:`reference/shang-wu-tong/docs/20260629-kind-value-dictionary.md`、`reference/shang-wu-tong/reverse/swt-decompiled/sources/com/reception/app/business/heart/HeartBeat.java`、`reference/shang-wu-tong/reverse/swt-decompiled/sources/com/reception/app/business/sendfile/net/RenamedThread.java`;反编译客户端仅为线索,不替代服务器契约实测。
|
||||
@@ -11,7 +11,7 @@
|
||||
### 1.1 对比 Chatwoot DeviseTokenAuth
|
||||
|
||||
| 特性 | Chatwoot (Rails) | GoChat (Go) |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 认证库 | DeviseTokenAuth gem | 自实现 JWT middleware |
|
||||
| Token存储 | 多token机制(client_id+token对) | 单JWT + Refresh token |
|
||||
| Token传递 | HTTP headers(access-token/client/uid) | Authorization: Bearer <jwt> |
|
||||
@@ -85,6 +85,7 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
```
|
||||
|
||||
**认证级别分层:**
|
||||
|
||||
- `PublicAPI` — 无认证(WebWidget嵌入、CSAT提交、Portal文章)
|
||||
- `AuthenticatedAPI` — AuthMiddleware(绝大多数API)
|
||||
- `AdminAPI` — AuthMiddleware + RoleCheck("administrator")(账户设置、团队管理等)
|
||||
@@ -98,7 +99,7 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
### 2.1 对比 Chatwoot Pundit + CustomRole
|
||||
|
||||
| 特性 | Chatwoot | GoChat |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 权限框架 | Pundit (Policy类) | 中间件+Policy函数 |
|
||||
| 内置角色 | agent / administrator | agent / administrator |
|
||||
| 自定义角色 | CustomRole (企业版, 6权限维度) | CustomRole (企业版, 同6维度) |
|
||||
@@ -116,28 +117,23 @@ const (
|
||||
)
|
||||
|
||||
// 企业版 CustomRole
|
||||
// 当前对外契约为六个二值权限:key 存在即拥有完整权限,未勾选即无权限。
|
||||
type CustomRole struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
AccountID uint `gorm:"index"`
|
||||
Name string `gorm:"size:255"`
|
||||
Permissions Permissions `gorm:"type:jsonb"` // 6维度权限
|
||||
Permissions []string `gorm:"type:jsonb"` // 6个二值权限 key
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
ConversationManage PermissionLevel `json:"conversation_manage"` // full/read
|
||||
ConversationDelete PermissionLevel `json:"conversation_delete"` // full/none
|
||||
ContactManage PermissionLevel `json:"contact_manage"` // full/read
|
||||
ReportManage PermissionLevel `json:"report_manage"` // full/none
|
||||
KnowledgeBaseManage PermissionLevel `json:"knowledge_base_manage"` // full/read
|
||||
AutomationManage PermissionLevel `json:"automation_manage"` // full/none
|
||||
}
|
||||
|
||||
type PermissionLevel string
|
||||
const (
|
||||
PermissionFull PermissionLevel = "full"
|
||||
PermissionRead PermissionLevel = "read"
|
||||
PermissionNone PermissionLevel = "none"
|
||||
)
|
||||
// conversation_manage
|
||||
// conversation_unassigned_manage
|
||||
// conversation_participating_manage
|
||||
// contact_manage
|
||||
// report_manage
|
||||
// knowledge_base_manage
|
||||
//
|
||||
// PermissionLevel(full/read/none) 仅保留给内置角色和旧数据兼容层,
|
||||
// 不作为自定义角色前端/API 的配置粒度。
|
||||
```
|
||||
|
||||
### 2.3 权限检查实现
|
||||
@@ -230,7 +226,7 @@ type AccountUser struct {
|
||||
### 4.1 对比 Chatwoot ActionCable
|
||||
|
||||
| 特性 | Chatwoot (ActionCable) | GoChat (Redis Pub/Sub + WebSocket) |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 连接管理 | ActionCable Server (Rails内置) | gorilla/websocket + Redis subscriber |
|
||||
| Channel订阅 | `ConversationChannel`, `AccountChannel` | Redis topic: `account:{id}`, `conversation:{id}` |
|
||||
| 消息推送 | `broadcast_to` | Redis PUBLISH → WebSocket send |
|
||||
@@ -327,6 +323,7 @@ Dispatcher.dispatch(event_name, timestamp, event_data)
|
||||
```
|
||||
|
||||
**12个Listener:**
|
||||
|
||||
- ActionCableListener → WebSocket推送
|
||||
- AgentBotListener → 触发Bot响应
|
||||
- AutomationRuleListener → 触发自动化规则
|
||||
@@ -427,7 +424,7 @@ func StartConsumer(accountID uint) {
|
||||
### 5.5 对比总结
|
||||
|
||||
| 方面 | Chatwoot | GoChat |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 事件发布 | Dispatcher → Redis PUBLISH | Dispatcher → Redis PUBLISH(相同) |
|
||||
| 事件消费 | 12个独立Listener类 | Handler函数注册表(更轻量) |
|
||||
| 执行方式 | Sidekiq异步Job | goroutine异步执行 |
|
||||
@@ -441,6 +438,7 @@ func StartConsumer(accountID uint) {
|
||||
### 6.1 对比 Chatwoot
|
||||
|
||||
Chatwoot企业版SAML实现:
|
||||
|
||||
- `AccountSamlSettings` 模型存储IdP配置
|
||||
- `DeviseSamlAuthenticatable` gem处理SAML流程
|
||||
- `SamlUserBuilder` 构建User对象
|
||||
@@ -542,7 +540,7 @@ func (b *SamlUserBuilder) Build(samlResponse *SamlResponse) (*User, error) {
|
||||
## 8. 关键设计决策总结
|
||||
|
||||
| # | 决策 | 原因 | 对比Chatwoot |
|
||||
|---|---|---|---|
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | JWT替代DeviseTokenAuth | Go无对应gem,JWT更标准 | 多token→单token+refresh |
|
||||
| 2 | PolicyContext替代Pundit | 统一权限检查入口 | 12个Policy类→1个PolicyContext |
|
||||
| 3 | Handler注册表替代Listener类 | Go无Rails autoload,函数注册更轻量 | 12个Listener→Handler map |
|
||||
@@ -554,4 +552,4 @@ func (b *SamlUserBuilder) Build(samlResponse *SamlResponse) (*User, error) {
|
||||
|
||||
---
|
||||
|
||||
> **下一步:** P2B(数据库设计)和 P2C(路由设计)完成后,P2架构设计阶段完整收官。
|
||||
> **下一步:** P2B(数据库设计)和 P2C(路由设计)完成后,P2架构设计阶段完整收官。
|
||||
|
||||
Reference in New Issue
Block a user