adapters;
+
+ @Data
+ public static class AdapterConfig {
+ private boolean enabled;
+ }
+}
+```
+
+## 9. 部署架构
+
+### 9.1 部署拓扑
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 负载均衡器 │
+└─────────────────────────────┬───────────────────────────────┘
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
+ │ Java 主 │ │ Java 主 │ │ Java 主 │
+ │ 框架实例1│ │ 框架实例2│ │ 框架实例3│
+ └────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │
+ └──────────────┼──────────────┘
+ │
+ ┌──────────────────┼──────────────────┐
+ ▼ ▼ ▼
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
+ │ Redis │ │ MySQL │ │Python AI │
+ │ (Cluster)│ │ (Master) │ │ 服务 │
+ └──────────┘ └──────────┘ └──────────┘
+```
+
+### 9.2 服务依赖
+
+| 服务 | 依赖关系 | 健康检查 |
+|-----|---------|---------|
+| Java 主框架 | 依赖 Redis, MySQL, Python AI | `/actuator/health` |
+| Python AI 服务 | 无外部依赖 | `/ai/health` |
+
+## 10. 安全设计
+
+### 10.1 渠道回调鉴权
+
+| 渠道 | 鉴权方式 | 验证逻辑 |
+|-----|---------|---------|
+| 微信 | msg_signature + timestamp + nonce | **沿用现有 WeCom 官方验签/解密方案**(复用现有 `WXBizMsgCrypt` 实现) |
+| 抖音 | X-Signature + X-Timestamp | 待实现 |
+| 京东 | signature + timestamp | 待实现 |
+
+> **说明**:微信回调验签/加解密使用企业微信官方方案,具体算法细节封装在现有 `WXBizMsgCrypt` 类中,不在本设计文档展开。
+
+### 10.2 内部服务鉴权
+
+- Java 主框架 → Python AI 服务:内网调用,无需鉴权(可扩展为 mTLS)
+- WebSocket 连接:路径参数 `{csId}` 标识身份(可扩展为 Token 验证)
+
+## 11. 监控与告警
+
+> **说明**:本节为后续演进预留,MVP 阶段可暂不实现。
+
+### 11.1 关键指标
+
+| 指标 | 类型 | 说明 |
+|-----|------|------|
+| `ai.service.latency` | Histogram | AI 服务调用延迟 |
+| `ai.service.error.rate` | Counter | AI 服务错误率 |
+| `ai.service.circuit.breaker.open` | Gauge | 熔断器状态 |
+| `message.process.count` | Counter | 消息处理数量 |
+| `message.idempotent.skip` | Counter | 幂等跳过数量 |
+| `session.active.count` | Gauge | 活跃会话数 |
+
+### 11.2 告警规则
+
+| 规则 | 条件 | 级别 |
+|-----|------|------|
+| AI 服务不可用 | 连续失败 5 次 | Critical |
+| AI 服务延迟过高 | P99 > 3s | Warning |
+| 熔断器触发 | circuit.breaker.open = 1 | Critical |
diff --git a/spec/ai-robot/openapi.deps.yaml b/spec/ai-robot/openapi.deps.yaml
new file mode 100644
index 0000000..d9ec669
--- /dev/null
+++ b/spec/ai-robot/openapi.deps.yaml
@@ -0,0 +1,188 @@
+openapi: 3.0.3
+info:
+ title: AI Service API
+ description: |
+ Python AI 服务接口契约。
+
+ 本文件定义主框架对 AI 服务的接口需求(Consumer-First)。
+ 由主框架作为调用方,Python AI 服务作为提供方实现。
+ version: 1.0.0
+ x-contract-level: L0
+ x-consumer: "java-main-framework"
+ x-provider: "python-ai-service"
+
+servers:
+ - url: http://ai-service:8080
+ description: AI 服务地址
+
+paths:
+ /ai/chat:
+ post:
+ operationId: generateReply
+ summary: 生成 AI 回复
+ description: |
+ 根据用户消息和会话历史生成 AI 回复。
+
+ 覆盖验收标准:
+ - AC-MCA-04: 主框架通过 HTTP POST 调用 AI 服务
+ - AC-MCA-05: 响应包含 reply、confidence、shouldTransfer 字段
+ - AC-MCA-06: AI 服务不可用时的降级处理(主框架侧实现)
+ - AC-MCA-07: 超时处理(主框架侧实现)
+ tags:
+ - AI Chat
+ x-requirements:
+ - AC-MCA-04
+ - AC-MCA-04-REQ
+ - AC-MCA-04-OPT
+ - AC-MCA-05
+ - AC-MCA-06
+ - AC-MCA-07
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChatRequest'
+ example:
+ sessionId: "kf_001_wx123456_1708765432000"
+ currentMessage: "我想了解产品价格"
+ channelType: "wechat"
+ responses:
+ '200':
+ description: 成功生成回复
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChatResponse'
+ example:
+ reply: "您好,我们的产品价格根据套餐不同有所差异。"
+ confidence: 0.92
+ shouldTransfer: false
+ '400':
+ description: 请求参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: 服务内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '503':
+ description: 服务不可用
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /ai/health:
+ get:
+ operationId: healthCheck
+ summary: 健康检查
+ description: 检查 AI 服务是否正常运行
+ tags:
+ - Health
+ responses:
+ '200':
+ description: 服务正常
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ '503':
+ description: 服务不健康
+
+components:
+ schemas:
+ ChatRequest:
+ type: object
+ required:
+ - sessionId
+ - currentMessage
+ - channelType
+ properties:
+ sessionId:
+ type: string
+ description: 会话ID(AC-MCA-04-REQ 必填)
+ currentMessage:
+ type: string
+ description: 当前用户消息(AC-MCA-04-REQ 必填)
+ channelType:
+ type: string
+ description: 渠道类型(AC-MCA-04-REQ 必填)
+ enum:
+ - wechat
+ - douyin
+ - jd
+ history:
+ type: array
+ description: 历史消息列表(AC-MCA-04-OPT 可选)
+ items:
+ $ref: '#/components/schemas/ChatMessage'
+ metadata:
+ type: object
+ description: 扩展元数据(AC-MCA-04-OPT 可选)
+ additionalProperties: true
+
+ ChatMessage:
+ type: object
+ required:
+ - role
+ - content
+ properties:
+ role:
+ type: string
+ enum:
+ - user
+ - assistant
+ content:
+ type: string
+
+ ChatResponse:
+ type: object
+ required:
+ - reply
+ - confidence
+ - shouldTransfer
+ properties:
+ reply:
+ type: string
+ description: AI 回复内容(AC-MCA-05 必填)
+ confidence:
+ type: number
+ format: double
+ description: 置信度评分 0.0-1.0(AC-MCA-05 必填)
+ shouldTransfer:
+ type: boolean
+ description: 是否建议转人工(AC-MCA-05 必填)
+ transferReason:
+ type: string
+ description: 转人工原因(可选)
+ metadata:
+ type: object
+ description: 响应元数据(可选)
+ additionalProperties: true
+
+ ErrorResponse:
+ type: object
+ required:
+ - code
+ - message
+ properties:
+ code:
+ type: string
+ description: 错误代码
+ message:
+ type: string
+ description: 错误消息
+ details:
+ type: array
+ description: 详细错误信息(可选)
+ items:
+ type: object
+ additionalProperties: true
diff --git a/spec/ai-robot/openapi.provider.yaml b/spec/ai-robot/openapi.provider.yaml
new file mode 100644
index 0000000..05248fe
--- /dev/null
+++ b/spec/ai-robot/openapi.provider.yaml
@@ -0,0 +1,894 @@
+openapi: 3.0.3
+info:
+ title: Multi-Channel Customer Service API
+ description: |
+ 多渠道客服主框架对外提供的 API 契约。
+
+ 本文件定义主框架对外提供的能力(Provider):
+ - 渠道消息回调接口(微信、抖音、京东等)
+ - 人工客服工作台 REST API
+ - WebSocket 实时通信协议说明
+ version: 1.0.0
+ x-contract-level: L2
+ x-consumer: "frontend, wechat-server, douyin-server, jd-server"
+ x-provider: "java-main-framework"
+
+servers:
+ - url: http://{host}:{port}
+ description: |
+ 服务地址占位符,根据环境替换:
+ - 开发环境: http://localhost:8080
+ - 测试环境: http://ai-robot-test:8080
+ - 生产环境: http://ai-robot:8080
+ variables:
+ host:
+ default: localhost
+ description: 服务主机名
+ port:
+ default: "8080"
+ description: 服务端口
+
+tags:
+ - name: Channel Callback
+ description: 渠道消息回调接口
+ - name: Session Management
+ description: 会话管理接口
+ - name: WebSocket
+ description: WebSocket 实时通信
+
+paths:
+ /wecom/callback:
+ get:
+ operationId: verifyWecomUrl
+ summary: 微信回调 URL 验证
+ description: |
+ 企业微信回调 URL 验证接口。
+
+ 用于验证回调 URL 的有效性,企业微信在配置回调时会发送 GET 请求。
+ tags:
+ - Channel Callback
+ parameters:
+ - name: msg_signature
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
+ description: 消息签名,用于验证请求来源
+ - name: timestamp
+ in: query
+ required: true
+ schema:
+ type: string
+ pattern: '^\d+$'
+ description: 时间戳(秒级),用于防重放攻击
+ - name: nonce
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 随机字符串,用于防重放攻击
+ - name: echostr
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ description: 加密的随机字符串,验证成功后需解密返回
+ responses:
+ '200':
+ description: 验证成功,返回解密后的 echostr
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "1234567890"
+ '400':
+ description: 请求参数错误
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - error
+ '401':
+ description: 签名验证失败
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - error
+ '500':
+ description: 服务器内部错误
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - error
+
+ post:
+ operationId: handleWecomCallback
+ summary: 微信回调消息处理
+ description: |
+ 企业微信回调消息处理入口。
+
+ 覆盖验收标准:
+ - AC-MCA-08: 根据渠道类型路由到对应的渠道适配器
+
+ 消息处理流程:
+ 1. 接收加密的 XML 消息
+ 2. 解密并解析消息内容
+ 3. 根据消息类型路由处理
+ 4. 返回 success 确认
+ tags:
+ - Channel Callback
+ x-requirements:
+ - AC-MCA-08
+ parameters:
+ - name: msg_signature
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 128
+ description: 消息签名(用于验签)
+ - name: timestamp
+ in: query
+ required: false
+ schema:
+ type: string
+ pattern: '^\d+$'
+ description: 时间戳(用于防重放)
+ - name: nonce
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ description: 随机数(用于防重放)
+ requestBody:
+ required: true
+ content:
+ application/xml:
+ schema:
+ type: string
+ description: 加密的 XML 消息
+ example: "..."
+ responses:
+ '200':
+ description: 处理成功
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - success
+ '400':
+ description: 请求格式错误
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - success
+ '401':
+ description: 签名验证失败
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - success
+ '500':
+ description: 服务器内部错误(仍返回 success 以避免微信重试)
+ content:
+ text/plain:
+ schema:
+ type: string
+ enum:
+ - success
+
+ /channel/{channelType}/callback:
+ post:
+ operationId: handleChannelCallback
+ summary: 通用渠道回调接口(预留)
+ description: |
+ 通用渠道消息回调接口,用于接入新渠道。
+
+ 当前为预留接口,后续实现抖音、京东等渠道时使用。
+
+ ### 鉴权/签名机制(各渠道实现时需补充)
+ 不同渠道需要不同的验签方式,建议通过以下方式传递:
+
+ **方式一:Header 传递**
+ - `X-Signature`: 消息签名
+ - `X-Timestamp`: 时间戳(防重放)
+ - `X-Nonce`: 随机数(防重放)
+
+ **方式二:Query 参数传递**
+ - `signature`: 消息签名
+ - `timestamp`: 时间戳
+ - `nonce`: 随机数
+
+ **方式三:Body 内嵌**
+ - requestBody 中包含 `rawPayload` + `signature` 字段
+
+ 具体签名算法(HMAC-SHA256、RSA 等)由各渠道适配器实现时确定。
+ tags:
+ - Channel Callback
+ parameters:
+ - name: channelType
+ in: path
+ required: true
+ schema:
+ type: string
+ enum:
+ - wechat
+ - douyin
+ - jd
+ description: 渠道类型
+ - name: X-Signature
+ in: header
+ required: false
+ schema:
+ type: string
+ maxLength: 256
+ description: 消息签名(可选,具体由渠道决定)
+ - name: X-Timestamp
+ in: header
+ required: false
+ schema:
+ type: string
+ pattern: '^\d+$'
+ description: 时间戳(可选,用于防重放)
+ - name: X-Nonce
+ in: header
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ description: 随机数(可选,用于防重放)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ description: 渠道消息体(格式由各渠道定义)
+ responses:
+ '200':
+ description: 处理成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '400':
+ description: 请求格式错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '401':
+ description: 签名验证失败
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '404':
+ description: 不支持的渠道类型
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions:
+ get:
+ operationId: getSessions
+ summary: 获取会话列表
+ description: |
+ 获取客服工作台的会话列表。
+
+ 覆盖验收标准:
+ - AC-MCA-12: 支持按渠道类型筛选
+ tags:
+ - Session Management
+ x-requirements:
+ - AC-MCA-12
+ parameters:
+ - name: status
+ in: query
+ required: false
+ schema:
+ type: string
+ enum:
+ - ai
+ - pending
+ - manual
+ - closed
+ description: 会话状态筛选
+ - name: csId
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ description: 客服ID筛选
+ - name: channelType
+ in: query
+ required: false
+ schema:
+ type: string
+ enum:
+ - wechat
+ - douyin
+ - jd
+ description: 渠道类型筛选
+ responses:
+ '200':
+ description: 成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionListResponse'
+ '400':
+ description: 请求参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions/{sessionId}:
+ get:
+ operationId: getSession
+ summary: 获取会话详情
+ description: |
+ 获取指定会话的详细信息。
+ tags:
+ - Session Management
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ responses:
+ '200':
+ description: 成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionResponse'
+ '400':
+ description: 请求参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '404':
+ description: 会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions/{sessionId}/history:
+ get:
+ operationId: getSessionHistory
+ summary: 获取会话消息历史
+ description: |
+ 获取指定会话的消息历史记录。
+ tags:
+ - Session Management
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ responses:
+ '200':
+ description: 成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MessageListResponse'
+ '400':
+ description: 请求参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '404':
+ description: 会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions/{sessionId}/accept:
+ post:
+ operationId: acceptSession
+ summary: 接入会话
+ description: |
+ 客服接入待处理的会话。
+
+ 仅状态为 `pending` 的会话可被接入。
+ tags:
+ - Session Management
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - csId
+ properties:
+ csId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 客服ID
+ example:
+ csId: "cs_001"
+ responses:
+ '200':
+ description: 接入成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '400':
+ description: 会话状态不正确或参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ examples:
+ invalid_status:
+ value:
+ code: 400
+ message: "会话状态不正确"
+ missing_csId:
+ value:
+ code: 400
+ message: "客服ID不能为空"
+ '404':
+ description: 会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ example:
+ code: 404
+ message: "会话不存在"
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions/{sessionId}/message:
+ post:
+ operationId: sendSessionMessage
+ summary: 发送消息
+ description: |
+ 客服向会话发送消息。
+
+ 仅状态为 `manual` 的会话可发送消息。
+ tags:
+ - Session Management
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - content
+ properties:
+ content:
+ type: string
+ minLength: 1
+ maxLength: 4096
+ description: 消息内容
+ msgType:
+ type: string
+ enum:
+ - text
+ - image
+ - file
+ default: text
+ description: 消息类型
+ example:
+ content: "您好,请问有什么可以帮助您的?"
+ msgType: "text"
+ responses:
+ '200':
+ description: 发送成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '400':
+ description: 会话状态不正确或参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ examples:
+ invalid_status:
+ value:
+ code: 400
+ message: "会话状态不正确"
+ missing_content:
+ value:
+ code: 400
+ message: "消息内容不能为空"
+ '404':
+ description: 会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误或消息发送失败
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+ /api/sessions/{sessionId}/close:
+ post:
+ operationId: closeSession
+ summary: 关闭会话
+ description: |
+ 关闭指定的会话。
+ tags:
+ - Session Management
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ responses:
+ '200':
+ description: 关闭成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '400':
+ description: 请求参数错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '404':
+ description: 会话不存在
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+ '500':
+ description: 服务器内部错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ApiResponse'
+
+components:
+ schemas:
+ ApiResponse:
+ type: object
+ required:
+ - code
+ - message
+ properties:
+ code:
+ type: integer
+ description: 响应码(0=成功,非0=失败)
+ enum:
+ - 0
+ - 400
+ - 404
+ - 500
+ message:
+ type: string
+ minLength: 1
+ maxLength: 256
+ description: 响应消息
+ data:
+ type: object
+ description: 响应数据(可选)
+ example:
+ code: 0
+ message: "success"
+
+ SessionListResponse:
+ allOf:
+ - $ref: '#/components/schemas/ApiResponse'
+ - type: object
+ required:
+ - code
+ - message
+ - data
+ properties:
+ code:
+ type: integer
+ enum:
+ - 0
+ message:
+ type: string
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/SessionInfo'
+ example:
+ code: 0
+ message: "success"
+ data:
+ - sessionId: "session_001"
+ customerId: "customer_001"
+ status: "manual"
+ channelType: "wechat"
+
+ SessionResponse:
+ allOf:
+ - $ref: '#/components/schemas/ApiResponse'
+ - type: object
+ required:
+ - code
+ - message
+ - data
+ properties:
+ code:
+ type: integer
+ enum:
+ - 0
+ message:
+ type: string
+ data:
+ $ref: '#/components/schemas/SessionInfo'
+
+ SessionInfo:
+ type: object
+ required:
+ - sessionId
+ - customerId
+ - status
+ properties:
+ sessionId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ customerId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 客户ID
+ kfId:
+ type: string
+ maxLength: 64
+ description: 客服账号ID
+ channelType:
+ type: string
+ description: 渠道类型
+ enum:
+ - wechat
+ - douyin
+ - jd
+ status:
+ type: string
+ description: 会话状态
+ enum:
+ - ai
+ - pending
+ - manual
+ - closed
+ manualCsId:
+ type: string
+ maxLength: 64
+ description: 接待客服ID
+ lastMessage:
+ type: string
+ maxLength: 4096
+ description: 最后一条消息
+ lastMessageTime:
+ type: string
+ format: date-time
+ description: 最后消息时间
+ messageCount:
+ type: integer
+ minimum: 0
+ description: 消息数量
+ createdAt:
+ type: string
+ format: date-time
+ description: 创建时间
+ updatedAt:
+ type: string
+ format: date-time
+ description: 更新时间
+ metadata:
+ type: object
+ description: 扩展元数据
+
+ MessageListResponse:
+ allOf:
+ - $ref: '#/components/schemas/ApiResponse'
+ - type: object
+ required:
+ - code
+ - message
+ - data
+ properties:
+ code:
+ type: integer
+ enum:
+ - 0
+ message:
+ type: string
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/MessageInfo'
+
+ MessageInfo:
+ type: object
+ required:
+ - msgId
+ - sessionId
+ - senderType
+ - content
+ properties:
+ msgId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ description: 消息ID
+ sessionId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 会话ID
+ senderType:
+ type: string
+ description: 发送者类型
+ enum:
+ - customer
+ - ai
+ - manual
+ senderId:
+ type: string
+ maxLength: 64
+ description: 发送者ID
+ content:
+ type: string
+ minLength: 1
+ maxLength: 4096
+ description: 消息内容
+ msgType:
+ type: string
+ description: 消息类型
+ enum:
+ - text
+ - image
+ - file
+ - event
+ createdAt:
+ type: string
+ format: date-time
+ description: 创建时间
+
+x-websocket:
+ path: /ws/cs/{csId}
+ description: |
+ ## WebSocket 实时通信协议
+
+ 客服工作台通过 WebSocket 接收实时消息推送。
+
+ 覆盖验收标准:
+ - AC-MCA-10: 会话状态为 MANUAL 时推送消息到人工客服工作台
+
+ ### 连接地址
+ ```
+ ws://{host}:{port}/ws/cs/{csId}
+ ```
+
+ ### 认证/鉴权方式
+ - **路径参数**: `{csId}` 客服ID,用于标识连接身份
+ - **可选增强**: 后续可增加 Token 验证(Query 参数或 Header)
+ - Query: `?token=xxx`
+ - Header: `Authorization: Bearer xxx`
+
+ ### 客户端发送消息格式
+ ```json
+ {
+ "type": "bind_session",
+ "sessionId": "会话ID"
+ }
+ ```
+
+ ### 服务端推送消息格式
+ ```json
+ {
+ "type": "new_message",
+ "sessionId": "会话ID",
+ "data": {
+ "msgId": "消息ID",
+ "content": "消息内容",
+ "senderType": "customer",
+ "senderId": "客户ID",
+ "msgType": "text",
+ "createdAt": "2026-02-24T10:00:00"
+ }
+ }
+ ```
+
+ ### 推送事件类型
+ | type | 说明 |
+ |------|------|
+ | new_message | 新消息通知 |
+ | new_pending_session | 新待接入会话 |
+ | session_accepted | 会话被接入 |
+ | session_closed | 会话已关闭 |
diff --git a/spec/ai-robot/requirements.md b/spec/ai-robot/requirements.md
new file mode 100644
index 0000000..df0b654
--- /dev/null
+++ b/spec/ai-robot/requirements.md
@@ -0,0 +1,189 @@
+---
+feature_id: "MCA"
+title: "多渠道适配主框架架构改造"
+status: "draft"
+version: "0.2.0"
+owners:
+ - "backend"
+ - "architect"
+last_updated: "2026-02-24"
+source:
+ type: "conversation"
+ ref: "架构改造需求"
+---
+
+# 多渠道适配主框架架构改造(MCA)
+
+## 1. 背景与目标
+
+### 1.1 背景
+
+当前系统为"企业微信智能客服系统",核心逻辑围绕企业微信客服 API 构建:
+- 微信消息接收、加解密、同步
+- AI 回复生成(紧耦合在 Java 主应用中)
+- 会话状态管理、转人工逻辑
+- 人工客服工作台(WebSocket)
+
+随着业务扩展,需要接入更多渠道(抖音、京东等),同时 AI 服务需要独立演进(支持多模型、Prompt 工程、RAG 等)。当前架构存在以下问题:
+
+1. **渠道耦合**:AI 服务、消息处理与微信 API 紧密耦合,难以扩展新渠道
+2. **AI 服务受限**:Java 生态对 AI/LLM 支持不如 Python 丰富,迭代效率低
+3. **职责不清**:消息路由、AI 调用、状态管理混杂在同一服务中
+
+### 1.2 目标
+
+1. **多渠道适配**:抽象渠道适配层,支持 WeChat/Douyin/JD 等渠道的快速接入
+2. **AI 服务剥离**:将 AI 服务剥离为独立 Python 服务,主框架通过 HTTP 调用
+3. **职责清晰**:主框架负责消息路由、会话管理、渠道适配;AI 服务负责模型推理
+
+### 1.3 非目标(Out of Scope)
+
+- 本次改造不涉及前端界面重构
+- 不涉及数据库迁移或数据模型重大变更
+- 不涉及 AI 模型训练或微调
+
+## 2. 模块边界(Scope)
+
+### 2.1 覆盖
+
+| 模块 | 说明 |
+|-----|------|
+| **渠道适配层** | 抽象 ChannelAdapter 接口,实现 WeChatAdapter,预留 DouyinAdapter/JdAdapter 扩展点 |
+| **消息路由层** | MessageProcessService 重构,支持多渠道消息分发 |
+| **会话管理层** | SessionManagerService 保持不变,增加渠道类型字段 |
+| **AI 服务客户端** | 新增 AiServiceClient,通过 HTTP 调用 Python AI 服务 |
+| **Python AI 服务** | 独立服务,提供 `/ai/chat` 等接口 |
+| **配置管理** | 支持多渠道配置、AI 服务配置 |
+
+### 2.2 不覆盖
+
+| 模块 | 说明 |
+|-----|------|
+| **抖音/京东适配器实现** | 仅预留接口,后续迭代实现 |
+| **人工客服工作台** | WebSocket 相关逻辑保持不变 |
+| **数据库表结构** | 仅增加渠道类型字段,不进行大规模迁移 |
+| **前端界面** | 不涉及 |
+
+## 3. 依赖盘点(Dependencies)
+
+### 3.1 本模块依赖的外部服务
+
+| 依赖 | 用途 | 契约文件 |
+|-----|------|---------|
+| **Python AI 服务** | AI 回复生成、置信度评估 | `openapi.deps.yaml` |
+| **企业微信 API** | 微信消息收发、会话状态管理 | 第三方 API |
+| **Redis** | 会话状态缓存、Token 缓存 | 基础设施 |
+| **MySQL** | 会话、消息持久化 | 基础设施 |
+
+### 3.2 本模块对外提供的能力
+
+| 能力 | 消费方 | 契约文件 |
+|-----|-------|---------|
+| **人工客服工作台 API** | 前端 | `openapi.provider.yaml` |
+| **WebSocket 消息推送** | 前端 | `openapi.provider.yaml` |
+
+## 4. 用户故事(User Stories)
+
+### 4.1 渠道适配
+
+- [US-MCA-01] 作为系统架构师,我希望主框架支持多渠道适配器接口,以便快速接入新渠道(抖音、京东等)。
+
+### 4.2 AI 服务剥离
+
+- [US-MCA-02] 作为 AI 工程师,我希望 AI 服务独立部署为 Python 服务,以便使用 Python 生态的 AI 框架和工具。
+- [US-MCA-03] 作为后端开发者,我希望主框架通过 HTTP 调用 AI 服务,以便主框架与 AI 服务独立演进。
+
+### 4.3 消息路由
+
+- [US-MCA-04] 作为系统运维,我希望消息路由逻辑与渠道适配解耦,以便新增渠道时不影响核心路由逻辑。
+
+### 4.4 会话管理
+
+- [US-MCA-05] 作为系统运维,我希望会话管理支持多渠道标识,以便区分不同渠道的会话。
+
+## 5. 验收标准(Acceptance Criteria, EARS)
+
+### 5.1 渠道适配层
+
+#### 5.1.1 核心能力接口(所有渠道必须实现)
+
+- [AC-MCA-01] WHEN 定义 ChannelAdapter 核心接口 THEN 系统 SHALL 包含 `receiveMessage`(接收消息)、`sendMessage`(发送消息)、`getChannelType`(获取渠道类型)方法签名。
+
+#### 5.1.2 可选能力接口(按渠道特性实现)
+
+- [AC-MCA-01-OPT-01] WHEN 渠道支持服务状态管理 THEN 系统 SHALL 实现 `ServiceStateCapable` 接口,包含 `getServiceState`、`transServiceState` 方法。
+- [AC-MCA-01-OPT-02] WHEN 渠道支持转人工 THEN 系统 SHALL 实现 `TransferCapable` 接口,包含 `transferToManual`、`transferToPool` 方法。
+- [AC-MCA-01-OPT-03] WHEN 渠道支持消息同步 THEN 系统 SHALL 实现 `MessageSyncCapable` 接口,包含 `syncMessages` 方法。
+
+> **设计说明**:可选能力接口的具体定义将在 `design.md` 中详细说明。主框架在运行时通过能力检测(如 `instanceof` 或 `Optional.ofNullable`)判断渠道是否支持某能力。
+
+#### 5.1.3 适配器实现
+
+- [AC-MCA-02] WHEN 实现 WeChatAdapter THEN 系统 SHALL 实现核心接口及所有可选能力接口,封装现有 WecomApiService 的所有功能。
+- [AC-MCA-03] WHEN 新增渠道适配器 THEN 系统 SHALL 至少实现核心接口,可选能力按需实现,无需修改核心路由逻辑。
+
+### 5.2 AI 服务剥离
+
+#### 5.2.1 请求契约
+
+- [AC-MCA-04] WHEN 主框架调用 AI 服务 THEN 系统 SHALL 通过 HTTP POST `/ai/chat` 接口获取 AI 回复。
+- [AC-MCA-04-REQ] WHEN 构造 AI 服务请求 THEN 系统 SHALL 包含以下最小字段:`sessionId`(会话ID)、`currentMessage`(当前消息)、`channelType`(渠道类型)。
+- [AC-MCA-04-OPT] WHEN 构造 AI 服务请求 THEN 系统 MAY 包含以下可选字段:`history`(历史消息)、`metadata`(扩展元数据)。
+
+#### 5.2.2 响应契约
+
+- [AC-MCA-05] WHEN AI 服务返回回复 THEN 系统 SHALL 包含 `reply`(回复内容)、`confidence`(置信度)、`shouldTransfer`(是否建议转人工)字段。
+
+#### 5.2.3 容错处理
+
+- [AC-MCA-06] WHEN AI 服务不可用 THEN 系统 SHALL 返回降级回复并记录错误日志,不影响消息接收流程。
+- [AC-MCA-07] WHEN AI 服务响应超时 THEN 系统 SHALL 在配置的超时时间后返回降级回复。
+
+### 5.3 消息路由
+
+- [AC-MCA-08] WHEN 收到消息 THEN 系统 SHALL 根据渠道类型路由到对应的渠道适配器。
+- [AC-MCA-09] WHEN 会话状态为 AI THEN 系统 SHALL 调用 AI 服务生成回复。
+- [AC-MCA-10] WHEN 会话状态为 MANUAL THEN 系统 SHALL 推送消息到人工客服工作台。
+
+### 5.4 消息幂等性
+
+- [AC-MCA-11-IDEMPOTENT] WHEN 收到重复的 messageId THEN 系统 SHALL 幂等处理,不重复调用 AI 服务、不重复发送回复消息。
+
+### 5.5 会话管理
+
+- [AC-MCA-11] WHEN 创建会话 THEN 系统 SHALL 记录渠道类型(channelType)。
+- [AC-MCA-12] WHEN 查询会话 THEN 系统 SHALL 支持按渠道类型筛选。
+
+### 5.6 兼容性
+
+- [AC-MCA-13] WHEN 改造完成后 THEN 系统 SHALL 保持现有微信渠道功能完全兼容,无业务中断。
+
+## 6. 追踪映射(Traceability)
+
+| AC ID | Endpoint | 方法 | operationId | 备注 |
+|-------|----------|------|-------------|------|
+| AC-MCA-04 | /ai/chat | POST | generateReply | AI 服务接口(deps) |
+| AC-MCA-04-REQ | /ai/chat | POST | generateReply | AI 请求最小字段 |
+| AC-MCA-05 | /ai/chat | POST | generateReply | AI 服务响应格式 |
+| AC-MCA-06 | /ai/chat | POST | generateReply | 降级处理 |
+| AC-MCA-07 | /ai/chat | POST | generateReply | 超时处理 |
+| AC-MCA-08 | /wecom/callback | POST | handleWecomCallback | **微信渠道** Provider Endpoint,其它渠道后续补齐 |
+| AC-MCA-09 | /ai/chat | POST | generateReply | AI 状态路由 |
+| AC-MCA-10 | WebSocket | - | pushToManualCs | 人工状态路由 |
+| AC-MCA-11-IDEMPOTENT | - | - | - | 幂等处理(内部逻辑,无对外接口) |
+
+## 7. 风险与约束
+
+### 7.1 技术风险
+
+| 风险 | 影响 | 缓解措施 |
+|-----|------|---------|
+| AI 服务调用延迟 | 用户体验下降 | 设置合理超时、异步处理、降级策略 |
+| 渠道 API 差异 | 适配器实现复杂 | 抽象公共接口、渠道特有能力单独处理 |
+
+### 7.2 约束
+
+- Java 版本:1.8(不升级)
+- Spring Boot 版本:2.7.18(不升级)
+- AI 服务通信协议:HTTP REST(非 gRPC)
+- 部署方式:AI 服务独立部署,主框架通过内网调用
diff --git a/spec/ai-robot/tasks.md b/spec/ai-robot/tasks.md
new file mode 100644
index 0000000..6b647e9
--- /dev/null
+++ b/spec/ai-robot/tasks.md
@@ -0,0 +1,347 @@
+---
+feature_id: "MCA"
+title: "多渠道适配主框架任务清单"
+status: "draft"
+version: "0.1.0"
+owners:
+ - "backend"
+last_updated: "2026-02-24"
+---
+
+# 多渠道适配主框架任务清单(tasks.md)
+
+## 任务概览
+
+| 阶段 | 任务数 | 说明 |
+|-----|-------|------|
+| Phase 1: 基础设施 | 5 | 统一消息模型、配置、数据库 |
+| Phase 2: 渠道适配层 | 4 | ChannelAdapter 接口与 WeChatAdapter 重构 |
+| Phase 3: 消息路由层 | 4 | MessageRouterService 重构 |
+| Phase 4: AI 服务客户端 | 4 | AiServiceClient 实现 |
+| Phase 5: 集成测试 | 3 | 端到端测试 |
+
+---
+
+## Phase 1: 基础设施
+
+### TASK-001: 定义统一消息模型 DTO
+- **状态**: ✅ 已完成
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08
+- **描述**: 创建 `InboundMessage`、`OutboundMessage`、`SignatureInfo` 等 DTO 类
+- **产出物**:
+ - `src/main/java/com/wecom/robot/dto/InboundMessage.java`
+ - `src/main/java/com/wecom/robot/dto/OutboundMessage.java`
+ - `src/main/java/com/wecom/robot/dto/SignatureInfo.java`
+- **验收标准**:
+ - [x] DTO 类包含 design.md 2.1/2.2 定义的所有字段
+ - [x] 包含 Lombok 注解 (@Data, @Builder)
+ - [x] 单元测试覆盖字段映射
+
+### TASK-002: 新增配置类
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-04
+- **描述**: 创建 AI 服务配置类和渠道配置类
+- **产出物**:
+ - `src/main/java/com/wecom/robot/config/AiServiceConfig.java`
+ - `src/main/java/com/wecom/robot/config/ChannelConfig.java`
+ - `src/main/resources/application.yml` 更新
+- **验收标准**:
+ - [ ] 配置类可正确读取 application.yml
+ - [ ] 包含默认值
+
+### TASK-003: 数据库 Schema 变更
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-11
+- **描述**: Session 表新增 channel_type 字段
+- **产出物**:
+ - `src/main/resources/db/migration/V1__add_channel_type.sql` (如使用 Flyway)
+ - 或手动 DDL 脚本
+- **验收标准**:
+ - [ ] DDL 可在线执行
+ - [ ] 默认值为 'wechat'
+ - [ ] Session 实体类同步更新
+
+### TASK-004: 添加 Resilience4j 依赖
+- **状态**: ⏳ 待开始
+- **优先级**: P1
+- **关联 AC**: AC-MCA-06, AC-MCA-07
+- **描述**: 在 pom.xml 添加 Resilience4j 依赖
+- **产出物**:
+ - `pom.xml` 更新
+- **验收标准**:
+ - [ ] 依赖正确添加
+ - [ ] 项目可正常构建
+
+### TASK-005: 消息幂等性工具类
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-11-IDEMPOTENT
+- **描述**: 实现基于 Redis 的消息幂等性处理
+- **产出物**:
+ - `src/main/java/com/wecom/robot/util/IdempotentHelper.java`
+- **验收标准**:
+ - [ ] 使用 Redis SETNX 实现
+ - [ ] TTL 1 小时
+ - [ ] 单元测试覆盖
+
+---
+
+## Phase 2: 渠道适配层
+
+### TASK-010: 定义 ChannelAdapter 接口
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-01
+- **描述**: 创建核心能力接口和可选能力接口
+- **产出物**:
+ - `src/main/java/com/wecom/robot/adapter/ChannelAdapter.java`
+ - `src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java`
+ - `src/main/java/com/wecom/robot/adapter/TransferCapable.java`
+ - `src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java`
+- **验收标准**:
+ - [ ] 接口定义与 design.md 3.1 一致
+ - [ ] sendMessage 使用 OutboundMessage 参数
+
+### TASK-011: 实现 WeChatAdapter
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-02
+- **描述**: 将现有 WecomApiService 重构为 WeChatAdapter
+- **产出物**:
+ - `src/main/java/com/wecom/robot/adapter/WeChatAdapter.java`
+- **验收标准**:
+ - [ ] 实现 ChannelAdapter 核心接口
+ - [ ] 实现 ServiceStateCapable、TransferCapable、MessageSyncCapable
+ - [ ] 现有功能保持兼容
+
+### TASK-012: 创建 ChannelAdapterFactory
+- **状态**: ⏳ 待开始
+- **优先级**: P1
+- **关联 AC**: AC-MCA-03
+- **描述**: 创建渠道适配器工厂,根据 channelType 获取对应适配器
+- **产出物**:
+ - `src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java`
+- **验收标准**:
+ - [ ] 支持 wechat 渠道
+ - [ ] 预留 douyin、jd 扩展点
+
+### TASK-013: 重构 WecomCallbackController
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08
+- **描述**: Controller 负责验签/解密/解析,构建 InboundMessage
+- **产出物**:
+ - `src/main/java/com/wecom/robot/controller/WecomCallbackController.java` 更新
+- **验收标准**:
+ - [ ] 验签/解密逻辑保持不变
+ - [ ] 输出 InboundMessage 传递给 MessageRouterService
+
+---
+
+## Phase 3: 消息路由层
+
+### TASK-020: 定义 MessageRouterService 接口
+- **状态**: ✅ 已完成
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08
+- **描述**: 创建渠道无关的消息路由服务接口
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/MessageRouterService.java`
+- **验收标准**:
+ - [x] 接口定义与 design.md 3.2 一致
+
+### TASK-021: 实现 MessageRouterServiceImpl
+- **状态**: ✅ 已完成
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10
+- **描述**: 实现消息路由核心逻辑
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java`
+- **验收标准**:
+ - [x] processInboundMessage 实现完整流程
+ - [x] routeBySessionState 根据状态路由
+ - [x] 幂等性检查
+
+### TASK-022: 重构 MessageProcessService
+- **状态**: ✅ 已完成
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08
+- **描述**: 将现有 MessageProcessService 逻辑迁移到 MessageRouterServiceImpl
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/MessageProcessService.java` 更新或删除
+- **验收标准**:
+ - [x] 现有功能保持兼容
+ - [x] 微信专属逻辑移至 WeChatAdapter
+
+### TASK-023: 更新 SessionManagerService
+- **状态**: ✅ 已完成
+- **优先级**: P0
+- **关联 AC**: AC-MCA-11, AC-MCA-12
+- **描述**: 支持渠道类型字段
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/SessionManagerService.java` 更新
+ - `src/main/java/com/wecom/robot/entity/Session.java` 更新
+- **验收标准**:
+ - [x] 创建会话时记录 channelType
+ - [x] 支持按 channelType 筛选
+
+---
+
+## Phase 4: AI 服务客户端
+
+### TASK-030: 定义 AI 服务 DTO
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-04-REQ, AC-MCA-05
+- **描述**: 创建 ChatRequest、ChatResponse DTO
+- **产出物**:
+ - `src/main/java/com/wecom/robot/dto/ai/ChatRequest.java`
+ - `src/main/java/com/wecom/robot/dto/ai/ChatResponse.java`
+- **验收标准**:
+ - [ ] 字段与 openapi.deps.yaml 一致
+ - [ ] 包含映射方法 (InboundMessage → ChatRequest)
+
+### TASK-031: 实现 AiServiceClient
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-04, AC-MCA-05
+- **描述**: 实现 HTTP 调用 Python AI 服务
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/AiServiceClient.java`
+ - `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java`
+- **验收标准**:
+ - [ ] 使用 RestTemplate 调用 /ai/chat
+ - [ ] 超时 5 秒
+ - [ ] 正确映射字段
+
+### TASK-032: 实现熔断与降级
+- **状态**: ⏳ 待开始
+- **优先级**: P1
+- **关联 AC**: AC-MCA-06, AC-MCA-07
+- **描述**: 使用 Resilience4j 实现熔断和降级
+- **产出物**:
+ - `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java` 更新
+ - `src/main/resources/application.yml` 更新
+- **验收标准**:
+ - [ ] @CircuitBreaker 注解配置
+ - [ ] @TimeLimiter 注解配置
+ - [ ] fallback 方法返回降级回复
+
+### TASK-033: 删除旧 AiService
+- **状态**: ⏳ 待开始
+- **优先级**: P2
+- **关联 AC**: -
+- **描述**: 删除旧的 AiService 类,清理相关配置
+- **产出物**:
+ - 删除 `src/main/java/com/wecom/robot/service/AiService.java`
+ - 删除 `src/main/java/com/wecom/robot/config/AiConfig.java`
+- **验收标准**:
+ - [ ] 无编译错误
+ - [ ] 无运行时错误
+
+---
+
+## Phase 5: 集成测试
+
+### TASK-040: 微信回调端到端测试
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10
+- **描述**: 测试微信回调完整流程
+- **产出物**:
+ - `src/test/java/com/wecom/robot/integration/WecomCallbackIntegrationTest.java`
+- **验收标准**:
+ - [ ] 消息正确路由到 AI 服务
+ - [ ] 消息正确路由到人工客服
+ - [ ] 幂等性验证
+
+### TASK-041: AI 服务调用测试
+- **状态**: ⏳ 待开始
+- **优先级**: P0
+- **关联 AC**: AC-MCA-04, AC-MCA-05, AC-MCA-06, AC-MCA-07
+- **描述**: 测试 AI 服务调用、超时、降级
+- **产出物**:
+ - `src/test/java/com/wecom/robot/service/AiServiceClientTest.java`
+- **验收标准**:
+ - [ ] 正常调用返回正确响应
+ - [ ] 超时触发降级
+ - [ ] 熔断触发降级
+
+### TASK-042: 会话管理测试
+- **状态**: ⏳ 待开始
+- **优先级**: P1
+- **关联 AC**: AC-MCA-11, AC-MCA-12
+- **描述**: 测试会话创建、状态变更、渠道类型
+- **产出物**:
+ - `src/test/java/com/wecom/robot/service/SessionManagerServiceTest.java`
+- **验收标准**:
+ - [ ] 会话创建包含 channelType
+ - [ ] 支持按 channelType 筛选
+
+---
+
+## 待澄清事项
+
+| ID | 问题 | 状态 | 备注 |
+|----|------|------|------|
+| CLARIFY-001 | AI 服务超时时间确认 | ✅ 已确认 | 5 秒 |
+| CLARIFY-002 | 降级回复策略确认 | ✅ 已确认 | 返回固定回复 + 转人工 |
+| CLARIFY-003 | 历史消息数量限制 | ✅ 已确认 | 50 条(openapi.deps.yaml) |
+| CLARIFY-004 | 渠道扩展优先级 | ✅ 已确认 | WeChat → Douyin → JD |
+| CLARIFY-005 | Python AI 服务部署方式 | ⏳ 待确认 | 独立进程 / Docker / K8s |
+
+---
+
+## 任务依赖关系
+
+```
+Phase 1 (基础设施)
+ │
+ ├── TASK-001 (DTO) ─────────────────────────────────────────┐
+ ├── TASK-002 (配置) ────────────────────────────────────────┤
+ ├── TASK-003 (数据库) ──────────────────────────────────────┤
+ ├── TASK-004 (Resilience4j) ──┐ │
+ └── TASK-005 (幂等性) ────────┤ │
+ │ │
+Phase 2 (渠道适配层) │ │
+ │ │ │
+ ├── TASK-010 (接口) ◄────────┼─────────────────────────────┤
+ ├── TASK-011 (WeChatAdapter) ◄┘ │
+ ├── TASK-012 (Factory) │
+ └── TASK-013 (Controller) ◄─────────────────────────────────┘
+ │
+Phase 3 (消息路由层) │
+ │ │
+ ├── TASK-020 (接口) ◄────────┘
+ ├── TASK-021 (实现)
+ ├── TASK-022 (重构)
+ └── TASK-023 (Session)
+ │
+Phase 4 (AI 服务客户端) │
+ │ │
+ ├── TASK-030 (DTO) ◄─────────┘
+ ├── TASK-031 (实现)
+ ├── TASK-032 (熔断)
+ └── TASK-033 (清理)
+ │
+Phase 5 (集成测试) │
+ │ │
+ ├── TASK-040 ◄───────────────┘
+ ├── TASK-041
+ └── TASK-042
+```
+
+---
+
+## 进度统计
+
+| 指标 | 数值 |
+|-----|------|
+| 总任务数 | 20 |
+| 已完成 | 4 |
+| 进行中 | 0 |
+| 待开始 | 16 |
+| 完成率 | 20% |
diff --git a/src/main/java/com/wecom/robot/WecomRobotApplication.java b/src/main/java/com/wecom/robot/WecomRobotApplication.java
new file mode 100644
index 0000000..3e16b98
--- /dev/null
+++ b/src/main/java/com/wecom/robot/WecomRobotApplication.java
@@ -0,0 +1,18 @@
+package com.wecom.robot;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@MapperScan("com.wecom.robot.mapper")
+@EnableAsync
+@EnableScheduling
+public class WecomRobotApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(WecomRobotApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java b/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java
new file mode 100644
index 0000000..0cd1cfa
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java
@@ -0,0 +1,31 @@
+package com.wecom.robot.adapter;
+
+import com.wecom.robot.dto.OutboundMessage;
+
+/**
+ * 渠道适配器核心能力接口
+ *
+ * 所有渠道适配器必须实现此接口,提供渠道类型标识和消息发送能力。
+ * [AC-MCA-01] 渠道适配层核心接口
+ *
+ * @see ServiceStateCapable
+ * @see TransferCapable
+ * @see MessageSyncCapable
+ */
+public interface ChannelAdapter {
+
+ /**
+ * 获取渠道类型标识
+ *
+ * @return 渠道类型,如 "wechat", "douyin", "jd"
+ */
+ String getChannelType();
+
+ /**
+ * 发送消息到渠道
+ *
+ * @param message 出站消息对象
+ * @return 发送是否成功
+ */
+ boolean sendMessage(OutboundMessage message);
+}
diff --git a/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java b/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java
new file mode 100644
index 0000000..1617777
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java
@@ -0,0 +1,115 @@
+package com.wecom.robot.adapter;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 渠道适配器工厂
+ *
+ * 根据渠道类型获取对应的渠道适配器实例。
+ * [AC-MCA-03] 渠道适配器工厂
+ */
+@Slf4j
+@Component
+public class ChannelAdapterFactory {
+
+ private final Map adapterMap;
+
+ public ChannelAdapterFactory(List adapters) {
+ this.adapterMap = adapters.stream()
+ .collect(Collectors.toMap(
+ ChannelAdapter::getChannelType,
+ Function.identity(),
+ (existing, replacement) -> existing
+ ));
+
+ log.info("[AC-MCA-03] 已注册渠道适配器: {}", adapterMap.keySet());
+ }
+
+ /**
+ * 根据渠道类型获取适配器
+ *
+ * @param channelType 渠道类型 (wechat/douyin/jd)
+ * @return 渠道适配器实例
+ * @throws IllegalArgumentException 如果渠道类型不支持
+ */
+ public ChannelAdapter getAdapter(String channelType) {
+ ChannelAdapter adapter = adapterMap.get(channelType);
+ if (adapter == null) {
+ log.error("[AC-MCA-03] 不支持的渠道类型: {}", channelType);
+ throw new IllegalArgumentException("不支持的渠道类型: " + channelType);
+ }
+ return adapter;
+ }
+
+ /**
+ * 检查渠道类型是否支持
+ *
+ * @param channelType 渠道类型
+ * @return 是否支持
+ */
+ public boolean isSupported(String channelType) {
+ return adapterMap.containsKey(channelType);
+ }
+
+ /**
+ * 获取所有支持的渠道类型
+ *
+ * @return 渠道类型集合
+ */
+ public java.util.Set getSupportedChannelTypes() {
+ return adapterMap.keySet();
+ }
+
+ /**
+ * 获取适配器并检查是否支持指定能力
+ *
+ * @param channelType 渠道类型
+ * @param capabilityClass 能力接口类
+ * @param 能力类型
+ * @return 能力实例,如果不支持则返回 null
+ */
+ public T getAdapterWithCapability(String channelType, Class capabilityClass) {
+ ChannelAdapter adapter = getAdapter(channelType);
+ if (capabilityClass.isInstance(adapter)) {
+ return capabilityClass.cast(adapter);
+ }
+ log.warn("[AC-MCA-03] 渠道 {} 不支持能力: {}", channelType, capabilityClass.getSimpleName());
+ return null;
+ }
+
+ /**
+ * 获取服务状态管理能力
+ *
+ * @param channelType 渠道类型
+ * @return ServiceStateCapable 实例,如果不支持则返回 null
+ */
+ public ServiceStateCapable getServiceStateCapable(String channelType) {
+ return getAdapterWithCapability(channelType, ServiceStateCapable.class);
+ }
+
+ /**
+ * 获取转人工能力
+ *
+ * @param channelType 渠道类型
+ * @return TransferCapable 实例,如果不支持则返回 null
+ */
+ public TransferCapable getTransferCapable(String channelType) {
+ return getAdapterWithCapability(channelType, TransferCapable.class);
+ }
+
+ /**
+ * 获取消息同步能力
+ *
+ * @param channelType 渠道类型
+ * @return MessageSyncCapable 实例,如果不支持则返回 null
+ */
+ public MessageSyncCapable getMessageSyncCapable(String channelType) {
+ return getAdapterWithCapability(channelType, MessageSyncCapable.class);
+ }
+}
diff --git a/src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java b/src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java
new file mode 100644
index 0000000..e19a85a
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java
@@ -0,0 +1,22 @@
+package com.wecom.robot.adapter;
+
+import com.wecom.robot.dto.SyncMsgResponse;
+
+/**
+ * 消息同步能力接口(可选)
+ *
+ * 提供从渠道同步历史消息的能力。
+ * 渠道适配器可选择性实现此接口。
+ * [AC-MCA-01] 渠道适配层可选能力接口
+ */
+public interface MessageSyncCapable {
+
+ /**
+ * 同步消息
+ *
+ * @param kfId 客服账号ID
+ * @param cursor 游标(用于分页获取)
+ * @return 同步消息响应
+ */
+ SyncMsgResponse syncMessages(String kfId, String cursor);
+}
diff --git a/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java b/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java
new file mode 100644
index 0000000..d95c7a9
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java
@@ -0,0 +1,33 @@
+package com.wecom.robot.adapter;
+
+import com.wecom.robot.dto.ServiceStateResponse;
+
+/**
+ * 服务状态管理能力接口(可选)
+ *
+ * 提供渠道服务状态的获取和变更能力。
+ * 渠道适配器可选择性实现此接口。
+ * [AC-MCA-01] 渠道适配层可选能力接口
+ */
+public interface ServiceStateCapable {
+
+ /**
+ * 获取服务状态
+ *
+ * @param kfId 客服账号ID
+ * @param customerId 客户ID
+ * @return 服务状态响应
+ */
+ ServiceStateResponse getServiceState(String kfId, String customerId);
+
+ /**
+ * 变更服务状态
+ *
+ * @param kfId 客服账号ID
+ * @param customerId 客户ID
+ * @param newState 新状态值
+ * @param servicerId 人工客服ID(可选)
+ * @return 变更是否成功
+ */
+ boolean transServiceState(String kfId, String customerId, int newState, String servicerId);
+}
diff --git a/src/main/java/com/wecom/robot/adapter/TransferCapable.java b/src/main/java/com/wecom/robot/adapter/TransferCapable.java
new file mode 100644
index 0000000..54845f1
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/TransferCapable.java
@@ -0,0 +1,30 @@
+package com.wecom.robot.adapter;
+
+/**
+ * 转人工能力接口(可选)
+ *
+ * 提供将客户转入待接入池或转给指定人工客服的能力。
+ * 渠道适配器可选择性实现此接口。
+ * [AC-MCA-01] 渠道适配层可选能力接口
+ */
+public interface TransferCapable {
+
+ /**
+ * 转入待接入池
+ *
+ * @param kfId 客服账号ID
+ * @param customerId 客户ID
+ * @return 转移是否成功
+ */
+ boolean transferToPool(String kfId, String customerId);
+
+ /**
+ * 转给指定人工客服
+ *
+ * @param kfId 客服账号ID
+ * @param customerId 客户ID
+ * @param servicerId 人工客服ID
+ * @return 转移是否成功
+ */
+ boolean transferToManual(String kfId, String customerId, String servicerId);
+}
diff --git a/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java b/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java
new file mode 100644
index 0000000..e4a0464
--- /dev/null
+++ b/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java
@@ -0,0 +1,275 @@
+package com.wecom.robot.adapter;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.wecom.robot.config.WecomConfig;
+import com.wecom.robot.dto.OutboundMessage;
+import com.wecom.robot.dto.ServiceStateResponse;
+import com.wecom.robot.dto.SyncMsgResponse;
+import com.wecom.robot.dto.WxSendMessageRequest;
+import com.wecom.robot.dto.InboundMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 企业微信渠道适配器
+ *
+ * 实现企业微信渠道的消息发送、服务状态管理、转人工、消息同步等能力。
+ * [AC-MCA-02] 企业微信渠道适配器实现
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WeChatAdapter implements ChannelAdapter,
+ ServiceStateCapable, TransferCapable, MessageSyncCapable {
+
+ private static final String CHANNEL_TYPE = "wechat";
+
+ private static final String GET_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpId}&corpsecret={secret}";
+ private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={accessToken}";
+ private static final String SYNC_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={accessToken}";
+ private static final String GET_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/get?access_token={accessToken}";
+ private static final String TRANS_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/trans?access_token={accessToken}";
+ private static final String SEND_EVENT_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token={accessToken}";
+
+ private static final String REDIS_TOKEN_KEY = "wecom:access_token";
+ private static final String REDIS_TOKEN_LOCK_KEY = "wecom:access_token_lock";
+ private static final String REDIS_CURSOR_KEY_PREFIX = "wecom:cursor:";
+
+ private final WecomConfig wecomConfig;
+ private final StringRedisTemplate redisTemplate;
+ private final RestTemplate restTemplate = new RestTemplate();
+
+ @Override
+ public String getChannelType() {
+ return CHANNEL_TYPE;
+ }
+
+ @Override
+ public boolean sendMessage(OutboundMessage message) {
+ WxSendMessageRequest wxRequest = convertToWxRequest(message);
+ return sendWxMessage(wxRequest);
+ }
+
+ private WxSendMessageRequest convertToWxRequest(OutboundMessage message) {
+ String msgType = message.getMsgType();
+ if (msgType == null || msgType.isEmpty()) {
+ msgType = InboundMessage.MSG_TYPE_TEXT;
+ }
+
+ WxSendMessageRequest wxRequest = new WxSendMessageRequest();
+ wxRequest.setTouser(message.getReceiver());
+ wxRequest.setOpenKfid(message.getKfId());
+ wxRequest.setMsgtype(msgType);
+
+ switch (msgType) {
+ case InboundMessage.MSG_TYPE_TEXT:
+ default:
+ WxSendMessageRequest.TextContent textContent = new WxSendMessageRequest.TextContent();
+ textContent.setContent(message.getContent());
+ wxRequest.setText(textContent);
+ break;
+ }
+
+ return wxRequest;
+ }
+
+ private boolean sendWxMessage(WxSendMessageRequest request) {
+ String accessToken = getAccessToken();
+ String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity entity = new HttpEntity<>(JSON.toJSONString(request), headers);
+ ResponseEntity response = restTemplate.postForEntity(url, entity, String.class);
+
+ JSONObject json = JSON.parseObject(response.getBody());
+ if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
+ log.error("[AC-MCA-02] 发送消息失败: {}", json);
+ return false;
+ }
+
+ log.info("[AC-MCA-02] 消息发送成功: msgId={}", json.getString("msgid"));
+ return true;
+ }
+
+ public boolean sendTextMessage(String touser, String openKfid, String content) {
+ WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content);
+ return sendWxMessage(request);
+ }
+
+ @Override
+ public ServiceStateResponse getServiceState(String kfId, String customerId) {
+ String accessToken = getAccessToken();
+ String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
+
+ JSONObject body = new JSONObject();
+ body.put("open_kfid", kfId);
+ body.put("external_userid", customerId);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers);
+ ResponseEntity response = restTemplate.postForEntity(url, entity, String.class);
+
+ log.info("[AC-MCA-02] 获取会话状态响应: {}", response.getBody());
+
+ return JSON.parseObject(response.getBody(), ServiceStateResponse.class);
+ }
+
+ @Override
+ public boolean transServiceState(String kfId, String customerId, int newState, String servicerId) {
+ String accessToken = getAccessToken();
+ String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
+
+ JSONObject body = new JSONObject();
+ body.put("open_kfid", kfId);
+ body.put("external_userid", customerId);
+ body.put("service_state", newState);
+ if (servicerId != null && !servicerId.isEmpty()) {
+ body.put("servicer_userid", servicerId);
+ }
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers);
+ ResponseEntity response = restTemplate.postForEntity(url, entity, String.class);
+
+ log.info("[AC-MCA-02] 变更会话状态响应: {}", response.getBody());
+
+ JSONObject result = JSON.parseObject(response.getBody());
+ return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
+ }
+
+ @Override
+ public boolean transferToPool(String kfId, String customerId) {
+ return transServiceState(kfId, customerId, ServiceStateResponse.STATE_POOL, null);
+ }
+
+ @Override
+ public boolean transferToManual(String kfId, String customerId, String servicerId) {
+ return transServiceState(kfId, customerId, ServiceStateResponse.STATE_MANUAL, servicerId);
+ }
+
+ @Override
+ public SyncMsgResponse syncMessages(String kfId, String cursor) {
+ String accessToken = getAccessToken();
+ String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken);
+
+ String savedCursor = cursor != null ? cursor : getCursor(kfId);
+
+ JSONObject body = new JSONObject();
+ body.put("open_kfid", kfId);
+ if (savedCursor != null && !savedCursor.isEmpty()) {
+ body.put("cursor", savedCursor);
+ }
+ body.put("limit", 1000);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers);
+ ResponseEntity response = restTemplate.postForEntity(url, entity, String.class);
+
+ log.info("[AC-MCA-02] sync_msg响应: {}", response.getBody());
+
+ SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class);
+
+ if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) {
+ saveCursor(kfId, syncResponse.getNextCursor());
+ }
+
+ return syncResponse;
+ }
+
+ public boolean sendWelcomeMsg(String code, String content) {
+ String accessToken = getAccessToken();
+ String url = SEND_EVENT_MSG_URL.replace("{accessToken}", accessToken);
+
+ JSONObject body = new JSONObject();
+ body.put("code", code);
+ body.put("msgtype", "text");
+ JSONObject text = new JSONObject();
+ text.put("content", content);
+ body.put("text", text);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers);
+ ResponseEntity response = restTemplate.postForEntity(url, entity, String.class);
+
+ JSONObject json = JSON.parseObject(response.getBody());
+ if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
+ log.error("[AC-MCA-02] 发送欢迎语失败: {}", json);
+ return false;
+ }
+
+ log.info("[AC-MCA-02] 发送欢迎语成功");
+ return true;
+ }
+
+ public boolean endSession(String kfId, String customerId) {
+ return transServiceState(kfId, customerId, ServiceStateResponse.STATE_CLOSED, null);
+ }
+
+ private String getAccessToken() {
+ String cachedToken = redisTemplate.opsForValue().get(REDIS_TOKEN_KEY);
+ if (cachedToken != null) {
+ return cachedToken;
+ }
+
+ Boolean locked = redisTemplate.opsForValue().setIfAbsent(REDIS_TOKEN_LOCK_KEY, "1", 10, TimeUnit.SECONDS);
+ if (Boolean.TRUE.equals(locked)) {
+ try {
+ String url = GET_ACCESS_TOKEN_URL
+ .replace("{corpId}", wecomConfig.getCorpId())
+ .replace("{secret}", wecomConfig.getSecret());
+
+ ResponseEntity response = restTemplate.getForEntity(url, String.class);
+ JSONObject json = JSON.parseObject(response.getBody());
+
+ if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
+ log.error("[AC-MCA-02] 获取access_token失败: {}", json);
+ throw new RuntimeException("获取access_token失败: " + json.getString("errmsg"));
+ }
+
+ String accessToken = json.getString("access_token");
+ long expiresIn = json.getLongValue("expires_in");
+
+ redisTemplate.opsForValue().set(REDIS_TOKEN_KEY, accessToken, expiresIn - 300, TimeUnit.SECONDS);
+ return accessToken;
+ } finally {
+ redisTemplate.delete(REDIS_TOKEN_LOCK_KEY);
+ }
+ } else {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return getAccessToken();
+ }
+ }
+
+ private String getCursor(String openKfid) {
+ return redisTemplate.opsForValue().get(REDIS_CURSOR_KEY_PREFIX + openKfid);
+ }
+
+ private void saveCursor(String openKfid, String cursor) {
+ redisTemplate.opsForValue().set(REDIS_CURSOR_KEY_PREFIX + openKfid, cursor);
+ }
+
+ public void clearCursor(String openKfid) {
+ redisTemplate.delete(REDIS_CURSOR_KEY_PREFIX + openKfid);
+ }
+}
diff --git a/src/main/java/com/wecom/robot/config/AiServiceConfig.java b/src/main/java/com/wecom/robot/config/AiServiceConfig.java
new file mode 100644
index 0000000..e246cda
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/AiServiceConfig.java
@@ -0,0 +1,15 @@
+package com.wecom.robot.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "ai-service")
+public class AiServiceConfig {
+
+ private String url;
+
+ private int timeout = 5000;
+}
diff --git a/src/main/java/com/wecom/robot/config/ChannelConfig.java b/src/main/java/com/wecom/robot/config/ChannelConfig.java
new file mode 100644
index 0000000..5a10f31
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/ChannelConfig.java
@@ -0,0 +1,22 @@
+package com.wecom.robot.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "channel")
+public class ChannelConfig {
+
+ private String defaultChannel = "wechat";
+
+ private Map adapters;
+
+ @Data
+ public static class AdapterConfig {
+ private boolean enabled;
+ }
+}
diff --git a/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java b/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java
new file mode 100644
index 0000000..db17d99
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java
@@ -0,0 +1,46 @@
+package com.wecom.robot.config;
+
+import com.wecom.robot.dto.ApiResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.validation.ConstraintViolation;
+import javax.validation.ConstraintViolationException;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiResponse handleValidationException(MethodArgumentNotValidException ex) {
+ String message = ex.getBindingResult().getFieldErrors().stream()
+ .map(FieldError::getDefaultMessage)
+ .collect(Collectors.joining("; "));
+ log.warn("参数校验失败: {}", message);
+ return ApiResponse.error(400, message);
+ }
+
+ @ExceptionHandler(ConstraintViolationException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiResponse handleConstraintViolationException(ConstraintViolationException ex) {
+ String message = ex.getConstraintViolations().stream()
+ .map(ConstraintViolation::getMessage)
+ .collect(Collectors.joining("; "));
+ log.warn("约束校验失败: {}", message);
+ return ApiResponse.error(400, message);
+ }
+
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ApiResponse handleException(Exception ex) {
+ log.error("服务器内部错误", ex);
+ return ApiResponse.error(500, "服务器内部错误");
+ }
+}
diff --git a/src/main/java/com/wecom/robot/config/RestTemplateConfig.java b/src/main/java/com/wecom/robot/config/RestTemplateConfig.java
new file mode 100644
index 0000000..a49fb63
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/RestTemplateConfig.java
@@ -0,0 +1,18 @@
+package com.wecom.robot.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+ @Bean
+ public RestTemplate restTemplate() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(5000);
+ factory.setReadTimeout(5000);
+ return new RestTemplate(factory);
+ }
+}
diff --git a/src/main/java/com/wecom/robot/config/TransferConfig.java b/src/main/java/com/wecom/robot/config/TransferConfig.java
new file mode 100644
index 0000000..432ad1f
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/TransferConfig.java
@@ -0,0 +1,19 @@
+package com.wecom.robot.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "transfer")
+public class TransferConfig {
+
+ private List keywords;
+ private double confidenceThreshold;
+ private int maxFailRounds;
+ private long maxSessionDuration;
+ private int maxMessageRounds;
+}
diff --git a/src/main/java/com/wecom/robot/config/WebSocketConfig.java b/src/main/java/com/wecom/robot/config/WebSocketConfig.java
new file mode 100644
index 0000000..97520ec
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/WebSocketConfig.java
@@ -0,0 +1,24 @@
+package com.wecom.robot.config;
+
+import com.wecom.robot.websocket.CsWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+@Configuration
+@EnableWebSocket
+public class WebSocketConfig implements WebSocketConfigurer {
+
+ private final CsWebSocketHandler csWebSocketHandler;
+
+ public WebSocketConfig(CsWebSocketHandler csWebSocketHandler) {
+ this.csWebSocketHandler = csWebSocketHandler;
+ }
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ registry.addHandler(csWebSocketHandler, "/ws/cs/*")
+ .setAllowedOrigins("*");
+ }
+}
diff --git a/src/main/java/com/wecom/robot/config/WecomConfig.java b/src/main/java/com/wecom/robot/config/WecomConfig.java
new file mode 100644
index 0000000..199ef63
--- /dev/null
+++ b/src/main/java/com/wecom/robot/config/WecomConfig.java
@@ -0,0 +1,25 @@
+package com.wecom.robot.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "wecom")
+public class WecomConfig {
+
+ private String corpId;
+ private String agentId;
+ private String secret;
+ private String token;
+ private String encodingAesKey;
+ private KfConfig kf;
+
+ @Data
+ public static class KfConfig {
+ private String callbackUrl;
+ }
+}
diff --git a/src/main/java/com/wecom/robot/controller/ChatHistoryController.java b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java
new file mode 100644
index 0000000..07dbe44
--- /dev/null
+++ b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java
@@ -0,0 +1,132 @@
+package com.wecom.robot.controller;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.wecom.robot.dto.ApiResponse;
+import com.wecom.robot.entity.Message;
+import com.wecom.robot.entity.Session;
+import com.wecom.robot.service.SessionManagerService;
+import com.wecom.robot.service.WecomApiService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/chat-history/api")
+@RequiredArgsConstructor
+public class ChatHistoryController {
+
+ private final SessionManagerService sessionManagerService;
+ private final WecomApiService wecomApiService;
+
+ @GetMapping("/kf-accounts")
+ public ApiResponse>> getKfAccounts() {
+ try {
+ JSONObject result = wecomApiService.getKfAccountList(0, 100);
+ List