Files
ai-customer-service/docs/SUB2API_MINIMAL_WEBHOOK_MAPPING.md

10 KiB
Raw Blame History

Sub2API 最小接入映射清单

状态:可用于最小 webhook 接入验证
最近更新2026-05-06
适用范围:ai-customer-service 当前 Phase 1 实现
目标:验证“能否挂到 tksea 服务器上的 Sub2API 后面跑最小 webhook 场景”


1. 结论先行

当前版本的 ai-customer-service

  • 足以支持 Sub2API 的最小 webhook 转发接入
  • 不足以支持完整的 Sub2API 适配层

这里的“最小 webhook 转发接入”指的是:

Sub2API 把用户消息按当前统一格式转成一个标准 JSON请求到
POST /api/v1/customer-service/webhook
POST /api/v1/customer-service/webhook/{channel}

然后消息进入当前客服主链:

webhook -> dialog -> intent -> handoff -> ticket/audit/dedup

这里的“不足以支持完整适配层”指的是:

  1. 当前没有真正的 Sub2API 原生适配器实现
  2. 当前没有落地 GET /api/v1/customer-service/kb
  3. 当前没有 Sub2API 原生消息结构到 UnifiedMessage 的自动转换层
  4. 当前没有 Sub2API 联调合同测试闭环

所以本清单的定位非常明确:

先验证最小消息转发能不能跑通,不等同于“已经完成 Sub2API 深度集成”。


2. 当前 webhook 的真实契约

当前服务真实接收的消息结构在:

真实字段如下:

{
  "message_id": "string",
  "channel": "string",
  "open_id": "string",
  "user_id": "string, optional",
  "content": "string",
  "content_type": "string, optional",
  "timestamp": "RFC3339 timestamp, optional",
  "reply_to": "string, optional"
}

最小可用集合只有 3 个必填字段:

{
  "channel": "string",
  "open_id": "string",
  "content": "string"
}

如果要启用去重,建议再补:

{
  "message_id": "stable unique id"
}

真实入口在:

可用路径:

  1. POST /api/v1/customer-service/webhook
  2. POST /api/v1/customer-service/webhook/{channel}

第二种路径下URL 中的 {channel} 会覆盖 body 里的 channel


3. Sub2API -> 当前 webhook 的最小字段映射

3.1 推荐映射

当前 webhook 字段 Sub2API 侧来源 必填 说明
message_id 上游消息唯一 ID / request ID / event ID 建议 用于 dedup为空则不去重
channel 固定值或来源渠道标识 例如 sub2api / web / widget
open_id 用户唯一标识 必须稳定;可用 user id / external user id
user_id 平台内部用户 ID 当前主链不强依赖
content 用户原始文本 当前仅文本主链最稳
content_type 固定 text/plaintext 当前可省略
timestamp 事件时间 不传则服务端自动补当前时间
reply_to 上游会话/消息关联 ID 当前主链不强依赖

3.2 最小推荐 body

最稳的最小 body

{
  "message_id": "sub2api-msg-001",
  "channel": "sub2api",
  "open_id": "user-123",
  "content": "我要退款"
}

如果走带 channel 的路径:

POST /api/v1/customer-service/webhook/sub2api

那么 body 可以进一步简化为:

{
  "message_id": "sub2api-msg-001",
  "open_id": "user-123",
  "content": "我要退款"
}

但从当前实现看,没有 body 内 channel 会被判缺字段,因为 handler 先校验 body再由 path override 覆盖。
所以现阶段最稳妥的做法仍然是:

即使用了 /webhook/{channel}body 里也继续带上 channel

推荐保持:

{
  "message_id": "sub2api-msg-001",
  "channel": "sub2api",
  "open_id": "user-123",
  "content": "我要退款"
}

4. 不能直接多传“原生大包”

这是当前接入里最容易踩坑的一点。

webhook_handler.go 对 JSON 使用了:

decoder.DisallowUnknownFields()

这意味着:

body 里只要带当前结构外的字段,就会直接 400

所以 Sub2API 那边不能把自己原生完整事件包直接透传过来,例如这类做法会失败:

{
  "message_id": "sub2api-msg-001",
  "channel": "sub2api",
  "open_id": "user-123",
  "content": "我要退款",
  "conversation": {},
  "metadata": {},
  "user": {},
  "model": "gpt-4o"
}

正确做法是:

先在 Sub2API 侧或中间 shim 中裁剪,只保留当前 webhook 认识的字段。


5. 签名鉴权要求

当前 webhook 如果启用了 AI_CS_WEBHOOK_SECRET,就必须带签名。

真实逻辑在:

默认请求头:

  • X-CS-Timestamp
  • X-CS-Signature

签名算法:

hex(hmac_sha256(secret, timestamp + "." + raw_body))

注意:

  1. timestamp 是 Unix 秒级时间戳
  2. raw_body最终发送出去的原始 JSON 字节串
  3. 不能对 body 做二次格式化后再复算
  4. 默认允许时钟偏差是 300s

5.1 伪代码

ts = current_unix_seconds()
body = exact_json_bytes
signature = HMAC_SHA256_HEX(secret, ts + "." + body)

POST /api/v1/customer-service/webhook
Headers:
  Content-Type: application/json
  X-CS-Timestamp: <ts>
  X-CS-Signature: <signature>
Body:
  <body>

5.2 curl 示例

TS=$(date +%s)
BODY='{"message_id":"sub2api-msg-001","channel":"sub2api","open_id":"user-123","content":"我要退款"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "replace-with-real-secret" | awk '{print $2}')

curl -X POST "http://<host>/api/v1/customer-service/webhook" \
  -H "Content-Type: application/json" \
  -H "X-CS-Timestamp: $TS" \
  -H "X-CS-Signature: $SIG" \
  -d "$BODY"

6. 当前最小成功判定

如果接入正确,成功响应会是 HTTP 200body 类似:

{
  "received": true,
  "session_id": "uuid",
  "reply": "已为您转人工客服,请稍候,我们会尽快处理。",
  "intent": "refund",
  "handoff": true,
  "ticket_id": "uuid"
}

其中最值得看的是:

  1. received=true
  2. session_id 非空
  3. handoff 是否符合预期
  4. ticket_id 在需要转人工时非空

7. 当前最容易失败的 7 个点

7.1 body 多传了未知字段

结果:

  • 400 Bad Request

原因:

  • DisallowUnknownFields() 拒绝未知字段

处理:

  • 只保留映射表中的字段

7.2 缺 channel / open_id / content

结果:

  • 400 Bad Request

处理:

  • 保证最小 3 字段始终存在

7.3 未带签名头

结果:

  • 403

处理:

  • 带上 X-CS-Timestamp / X-CS-Signature

7.4 签名对的是“格式化后的 body”不是实际发送 body

结果:

  • 403 invalid webhook signature

处理:

  • 用最终发送的原始 JSON 字节串算签名

7.5 timestamp 漂移过大

结果:

  • 403 stale webhook request

处理:

  • 确保 Sub2API 所在机时钟同步

7.6 message_id 不稳定或重复策略错误

结果:

  • 重复消息可能被判为:
    • 正常新消息
    • duplicate message ignored

处理:

  • message_id 对同一条上游消息稳定唯一

7.7 内容过长

结果:

  • 当前不会拒绝
  • 但会被截断到 2000 字符

处理:

  • 如果 Sub2API 可能转发超长内容,最好先在上游截断或摘要化

8. 推荐的两种接法

8.1 方案 ASub2API 直接转发

前提:

  1. Sub2API 支持自定义 webhook 目标地址
  2. Sub2API 支持自定义请求头
  3. Sub2API 支持自定义 body 模板,且能只输出当前需要字段

这种方案最简单,链路最短。

8.2 方案 BSub2API -> shim -> ai-customer-service

如果 Sub2API 不能:

  1. 自定义 body 到足够细
  2. 自定义 HMAC 头
  3. 裁剪原始事件包

那就不要硬接。

应该改成:

Sub2API -> 轻量 shim -> ai-customer-service webhook

这个 shim 只做三件事:

  1. 把 Sub2API 原始消息映射成 UnifiedMessage
  2. 去掉未知字段
  3. 按当前算法补 X-CS-TimestampX-CS-Signature

这是当前版本最稳的工程方案。


9. 当前版本对 Sub2API 的真实支持边界

已支持

  1. 标准 webhook POST 接入
  2. HMAC 鉴权
  3. 基于 message_id 的 dedup
  4. 文本消息进入主链
  5. 自动产生 session / ticket / audit

未支持

  1. Sub2API 原生消息结构直接接入
  2. Sub2API 专用 adapter
  3. Sub2API 工单拉取接口合同
  4. 知识库共享接口落地
  5. Sub2API 合同测试/联调测试

因此当前准确表述是:

当前版本可以先验证“挂到 tksea 服务器上的 Sub2API 后面跑最小 webhook 场景”,但不能宣称“已经完整支持 Sub2API 集成”。


10. 建议的最小验证顺序

第一步:直接打通单条消息

目标:

  • 一条最小 body 返回 200

第二步:验证 dedup

目标:

  • 同一 message_id 重放,返回 duplicate message ignored

第三步:验证真实业务文本

目标:

  • 例如“我要退款”能触发 handoff=true

第四步:再决定要不要补 shim / adapter

如果前三步都只能靠大量平台侧 hack 才能做到,就应立即转为方案 B

加一个轻量 shim不要继续硬耦合 Sub2API 原生结构。


11. 当前建议

如果你的目标是:

“先验证能不能挂到 tksea 服务器上的 Sub2API 后面跑最小 webhook 场景”

那我建议直接按下面顺序推进:

  1. 让 Sub2API 输出最小 body
  2. 按当前签名算法补头
  3. 先连到 POST /api/v1/customer-service/webhook
  4. 跑单条消息验证
  5. 跑重复消息验证

如果 Sub2API 做不到:

  1. 自定义最小 body
  2. 自定义签名头

就立刻切到:

Sub2API -> shim -> ai-customer-service

不要在 Sub2API 本体里过度折腾。