Merge pull request 'feat/multi-channel-framework [AC-INIT]合并功能代码' (#12) from feat/multi-channel-framework into main

Reviewed-on: MerCry/ai-robot#12
This commit is contained in:
MerCry 2026-02-24 03:54:59 +00:00
commit b9f678a203
87 changed files with 9941 additions and 0 deletions

2
.gitignore vendored
View File

@ -24,3 +24,5 @@
hs_err_pid*
replay_pid*
/target/
/.idea/

View File

@ -0,0 +1,271 @@
# ai-robot-MCA - Progress
> 多渠道适配主框架架构改造进度文档
> 遵循 `docs/session-handoff-protocol.md` 协议
---
## 📋 Context
- module: `ai-robot`
- feature: `MCA` (Multi-Channel Adapter)
- status: 🔄 进行中
---
## 🔗 Spec References (SSOT)
- agents: `AGENTS.md`
- contracting: `spec/contracting.md`
- requirements: `spec/ai-robot/requirements.md`
- openapi_provider: `spec/ai-robot/openapi.provider.yaml`
- openapi_deps: `spec/ai-robot/openapi.deps.yaml`
- design: `spec/ai-robot/design.md`
- tasks: `spec/ai-robot/tasks.md`
---
## 📊 Overall Progress (Phases)
- [x] Phase 1: 基础设施 (100%) ✅ [tasks.md: TASK-001 ~ TASK-005]
- [x] Phase 2: 渠道适配层 (100%) ✅ [tasks.md: TASK-010 ~ TASK-013]
- [x] Phase 3: 消息路由层 (100%) ✅ [tasks.md: TASK-020 ~ TASK-023]
- [x] Phase 4: AI 服务客户端 (100%) ✅ [tasks.md: TASK-030 ~ TASK-033]
- [ ] Phase 5: 集成测试 (0%) ⏳ [tasks.md: TASK-040 ~ TASK-042]
---
## 🔄 Current Phase
### Goal
完成集成测试,验证多渠道适配框架的完整功能。
### Sub Tasks
- [x] TASK-030: 定义 AI 服务 DTO ✅ [AC-MCA-04-REQ, AC-MCA-05]
- [x] TASK-031: 实现 AiServiceClient ✅ [AC-MCA-04, AC-MCA-05]
- [x] TASK-032: 实现熔断与降级 ✅ [AC-MCA-06, AC-MCA-07]
- [x] TASK-033: 删除旧 AiService ✅
- [ ] TASK-040: 集成测试 ⏳
- [ ] TASK-041: 端到端测试 ⏳
- [ ] TASK-042: 性能测试 ⏳
### Next Action (Must be Specific)
**Immediate**: 执行 Phase 5 集成测试任务。
**Details**:
1. task: TASK-040 集成测试
2. action: 编写集成测试验证消息路由流程
3. reference:
- `spec/ai-robot/tasks.md` TASK-040 定义
4. constraints:
- 测试覆盖 InboundMessage → AI Service → OutboundMessage 完整流程
- 验证幂等性、熔断降级逻辑
---
## 🏗️ Technical Context
### Module Structure (Only What Matters)
```
src/main/java/com/wecom/robot/
├── dto/
│ ├── InboundMessage.java # TASK-001 ✅
│ ├── OutboundMessage.java # TASK-001 ✅
│ ├── SignatureInfo.java # TASK-001 ✅
│ └── ai/
│ ├── ChatRequest.java # TASK-030
│ └── ChatResponse.java # TASK-030
├── config/
│ ├── AiServiceConfig.java # TASK-002 ✅
│ └── ChannelConfig.java # TASK-002 ✅
├── adapter/
│ ├── ChannelAdapter.java # TASK-010 ✅
│ ├── ServiceStateCapable.java # TASK-010 ✅
│ ├── TransferCapable.java # TASK-010 ✅
│ ├── MessageSyncCapable.java # TASK-010 ✅
│ ├── WeChatAdapter.java # TASK-011 ✅
│ └── ChannelAdapterFactory.java # TASK-012 ✅
├── service/
│ ├── MessageRouterService.java # TASK-020 ✅
│ ├── AiServiceClient.java # TASK-031
│ └── impl/
│ ├── MessageRouterServiceImpl.java # TASK-021 ✅
│ └── AiServiceClientImpl.java # TASK-031
├── util/
│ └── IdempotentHelper.java # TASK-005 ✅
└── entity/
└── Session.java # TASK-003 ✅
```
### Key Decisions (Why / Impact)
- decision: 统一消息模型 (InboundMessage/OutboundMessage)
reason: 实现渠道无关的消息处理Controller 层负责验签/解析Router 层处理统一消息
impact: 后续新增渠道只需实现 ChannelAdapter无需修改核心路由逻辑
- decision: 使用 Resilience4j 实现熔断
reason: 与 Spring Boot 2.7 兼容良好,支持断路器、限流、超时
impact: AI 服务调用具备熔断/降级能力,提升系统稳定性
- decision: 内部字段统一用 `content`AI 服务契约用 `currentMessage`
reason: 保持内部命名一致性,映射在 AiServiceClient 层处理
impact: 避免后续 DTO 命名混乱
- decision: ChannelAdapter 接口分离为核心能力和可选能力
reason: 不同渠道支持的能力不同,接口分离允许按需实现
impact: WeChatAdapter 实现全部接口,其他渠道可按需实现
### Code Snippets (Reference)
```java
// ChannelAdapter 接口定义 (design.md 3.1)
public interface ChannelAdapter {
String getChannelType();
boolean sendMessage(OutboundMessage message);
}
// MessageRouterService 接口定义 (design.md 3.2)
public interface MessageRouterService {
void processInboundMessage(InboundMessage message);
void routeBySessionState(Session session, InboundMessage message);
void dispatchToAiService(Session session, InboundMessage message);
void dispatchToManualCs(Session session, InboundMessage message);
void dispatchToPendingPool(Session session, InboundMessage message);
}
```
---
## 🧾 Session History
### Session #5 (2026-02-24)
- completed:
- L2 契约升级: openapi.provider.yaml L0 → L2 ✅
- L2 契约对齐: DTO 添加 validation 注解 ✅
- L2 契约对齐: Controller 添加 @Valid 校验 ✅
- L2 契约对齐: 创建全局异常处理器 ✅
- changes:
- 更新 spec/ai-robot/openapi.provider.yaml (L0 → L2)
- 更新 src/main/java/com/wecom/robot/dto/ApiResponse.java (code=200 → 0, 添加注解)
- 更新 src/main/java/com/wecom/robot/dto/SessionInfo.java (添加 @NotBlank, @Size, channelType 字段)
- 更新 src/main/java/com/wecom/robot/dto/MessageInfo.java (添加 @NotBlank, @Size)
- 更新 src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java (添加 @NotBlank, @Size)
- 更新 src/main/java/com/wecom/robot/dto/SendMessageRequest.java (添加 @NotBlank, @Size)
- 更新 src/main/java/com/wecom/robot/controller/SessionController.java (添加 @Valid, channelType 参数)
- 新增 src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java
- commits: f09f22f, 0786e6a
### Session #4 (2026-02-24)
- completed:
- TASK-001: 定义统一消息模型 DTO ✅
- TASK-002: 新增配置类 ✅
- TASK-003: 数据库 Schema 变更 ✅
- TASK-004: 添加 Resilience4j 依赖 ✅
- TASK-005: 消息幂等性工具类 ✅
- TASK-030: 定义 AI 服务 DTO ✅
- TASK-031: 实现 AiServiceClient ✅
- TASK-032: 实现熔断与降级 ✅
- TASK-033: 删除旧 AiService ✅
- changes:
- 新增 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
- 新增 src/main/java/com/wecom/robot/config/AiServiceConfig.java
- 新增 src/main/java/com/wecom/robot/config/ChannelConfig.java
- 新增 src/main/java/com/wecom/robot/config/RestTemplateConfig.java
- 新增 src/main/java/com/wecom/robot/util/IdempotentHelper.java
- 新增 src/main/java/com/wecom/robot/dto/ai/ChatRequest.java
- 新增 src/main/java/com/wecom/robot/dto/ai/ChatResponse.java
- 新增 src/main/java/com/wecom/robot/dto/ai/ChatMessage.java
- 新增 src/main/java/com/wecom/robot/service/AiServiceClient.java
- 新增 src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java
- 新增 src/main/resources/db/migration/V1__add_channel_type.sql
- 删除 src/main/java/com/wecom/robot/service/AiService.java
- 删除 src/main/java/com/wecom/robot/config/AiConfig.java
- 更新 src/main/resources/application.yml (添加 ai-service, channel, resilience4j 配置)
- 更新 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java
- 更新 src/main/java/com/wecom/robot/controller/DebugController.java
- 更新 pom.xml (添加 Resilience4j 依赖)
- commits: 多个独立 commit
### Session #3 (2026-02-24)
- completed:
- TASK-010: 定义 ChannelAdapter 接口 ✅
- TASK-011: 实现 WeChatAdapter ✅
- TASK-012: 创建 ChannelAdapterFactory ✅
- TASK-013: 重构 WecomCallbackController ✅
- TASK-022: 重构 MessageProcessService ✅
- changes:
- 新增 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
- 新增 src/main/java/com/wecom/robot/adapter/WeChatAdapter.java
- 新增 src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java
- 更新 src/main/java/com/wecom/robot/controller/WecomCallbackController.java
- 更新 src/main/java/com/wecom/robot/service/MessageProcessService.java
- commits: 4e9c5ba, 2631c53, 07561fe
### Session #2 (2026-02-24)
- completed:
- TASK-020: 定义 MessageRouterService 接口
- TASK-021: 实现 MessageRouterServiceImpl
- 创建 `src/main/java/com/wecom/robot/service/MessageRouterService.java`
- 创建 `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java`
- 实现 5 个核心方法processInboundMessage, routeBySessionState, dispatchToAiService, dispatchToManualCs, dispatchToPendingPool
- 实现幂等性检查(基于 Redis SETNX
- changes:
- 新增 src/main/java/com/wecom/robot/service/MessageRouterService.java
- 新增 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java
- 更新 docs/progress/ai-robot-mca-progress.md
- 更新 spec/ai-robot/tasks.md
- commits: b9792c8, 0b6fcf5
### Session #2 (2026-02-24)
- completed:
- TASK-020: 定义 MessageRouterService 接口
- TASK-021: 实现 MessageRouterServiceImpl
- TASK-022: 重构 MessageProcessService
- TASK-023: 更新 SessionManagerService 支持 channelType
- 创建 `src/main/java/com/wecom/robot/service/MessageRouterService.java`
- 创建 `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java`
- 更新 Session 实体添加 channelType 字段
- 更新 SessionManagerService 支持按渠道类型创建和筛选会话
- 实现 5 个核心方法processInboundMessage, routeBySessionState, dispatchToAiService, dispatchToManualCs, dispatchToPendingPool
- 实现幂等性检查(基于 Redis SETNX
- changes:
- 新增 src/main/java/com/wecom/robot/service/MessageRouterService.java
- 新增 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java
- 更新 src/main/java/com/wecom/robot/service/MessageProcessService.java
- 更新 src/main/java/com/wecom/robot/entity/Session.java
- 更新 src/main/java/com/wecom/robot/service/SessionManagerService.java
- 更新 docs/progress/ai-robot-mca-progress.md
- 更新 spec/ai-robot/tasks.md
- commits: b9792c8, 0b6fcf5, db378af, a8d7474
### Session #1 (2026-02-24)
- completed:
- 创建 spec/ai-robot/ 目录结构
- 编写 requirements.md (v0.2.0)
- 编写 openapi.deps.yaml (L0)
- 编写 openapi.provider.yaml (L0)
- 编写 design.md (v0.2.0)
- 编写 tasks.md (20 个任务)
- 所有规范文件已提交到 Git
- changes:
- 新增 spec/ai-robot/requirements.md
- 新增 spec/ai-robot/openapi.deps.yaml
- 新增 spec/ai-robot/openapi.provider.yaml
- 新增 spec/ai-robot/design.md
- 新增 spec/ai-robot/tasks.md
---
## 🚀 Startup Guide
1. 读取本进度文档,定位当前 Phase 与 Next Action。
2. 打开并阅读 Spec References 指向的模块规范requirements/openapi/design/tasks
3. 直接执行 Next ActionTASK-030: 创建 ChatRequest/ChatResponse DTO
4. 每完成一个子任务,更新本进度文档并提交 Git。

BIN
lib/commons-codec-1.9.jar Normal file

Binary file not shown.

137
pom.xml Normal file
View File

@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.wecom</groupId>
<artifactId>wecom-robot</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>wecom-robot</name>
<description>企业微信智能客服系统</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<hutool.version>5.8.22</hutool.version>
<fastjson.version>2.0.40</fastjson.version>
<resilience4j.version>2.1.0</resilience4j.version>
<project.basedir>${project.basedir}</project.basedir>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.9</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/commons-codec-1.9.jar</systemPath>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-timelimiter</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<includeSystemScope>true</includeSystemScope>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

636
spec/ai-robot/design.md Normal file
View File

@ -0,0 +1,636 @@
---
feature_id: "MCA"
title: "多渠道适配主框架架构设计"
status: "draft"
version: "0.2.0"
owners:
- "architect"
- "backend"
last_updated: "2026-02-24"
---
# 多渠道适配主框架架构设计design.md
## 1. 系统架构
### 1.1 整体架构图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 外部系统 │
├─────────────────┬─────────────────┬─────────────────┬───────────────────────┤
│ 企业微信 API │ 抖音 API │ 京东 API │ 前端工作台 │
└────────┬────────┴────────┬────────┴────────┬────────┴──────────┬────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Java 主框架 (Spring Boot) │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 入口层 (Controller Layer) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │WecomCallback │ │DouyinCallback│ │ JdCallback │ (预留) │ │
│ │ │ Controller │ │ Controller │ │ Controller │ │ │
│ │ └──────┬───────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │ │
│ │ │ 验签/解密/解析 → InboundMessage │ │
│ │ ▼ │ │
│ └─────────┼───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 消息路由层 (Message Router) │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ MessageRouterService (渠道无关) │ │ │
│ │ │ - processInboundMessage(InboundMessage) │ │ │
│ │ │ - routeBySessionState(Session, InboundMessage) │ │ │
│ │ │ - dispatchToAiService(Session, InboundMessage) │ │ │
│ │ │ - dispatchToManualCs(Session, InboundMessage) │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 渠道适配层 (Channel Adapter) │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ ChannelAdapter 接口 (核心能力) │ │ │
│ │ │ - getChannelType() │ │ │
│ │ │ - sendMessage(OutboundMessage) │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ 可选能力接口 (Optional Capabilities) │ │ │
│ │ │ - ServiceStateCapable - TransferCapable - MessageSyncCapable│ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ WeChatAdapter│ │DouyinAdapter │ │ JdAdapter │ (预留) │ │
│ │ │ (已实现) │ │ (预留) │ │ (预留) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐ │
│ │ AI 服务客户端 │ │ 会话管理层 │ │ WebSocket 服务│ │
│ │AiServiceClient│ │SessionManagerService │ │WebSocketService│ │
│ └──────┬───────┘ └──────────────────────┘ └──────────────┘ │
│ │ │
└─────────┼───────────────────────────────────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────────────────────────────────────────┐
│ Python AI 服务 (独立部署) │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ OpenAI Client│ │DeepSeek Client│ │ 其他模型 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ AI 服务核心逻辑 │ │
│ │ - /ai/chat 生成 AI 回复 │ │
│ │ - /ai/health 健康检查 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.2 模块职责
| 模块 | 职责 | 关联 AC |
|-----|------|--------|
| **入口层** | 接收渠道回调,验签/解密/解析,转换为统一的 InboundMessage | AC-MCA-08 |
| **消息路由层** | 渠道无关的消息路由,根据会话状态分发到 AI 或人工 | AC-MCA-08 ~ AC-MCA-10 |
| **渠道适配层** | 封装各渠道 API 差异,提供统一的消息发送接口 | AC-MCA-01 ~ AC-MCA-03 |
| **AI 服务客户端** | 调用 Python AI 服务,处理超时/降级 | AC-MCA-04 ~ AC-MCA-07 |
| **会话管理层** | 管理会话生命周期、状态变更、消息持久化 | AC-MCA-11 ~ AC-MCA-12 |
| **WebSocket 服务** | 实时推送消息到人工客服工作台 | AC-MCA-10 |
| **Python AI 服务** | AI 模型推理、置信度评估、转人工建议 | AC-MCA-04 ~ AC-MCA-05 |
## 2. 统一消息模型
### 2.1 入站消息 (InboundMessage)
```java
@Data
public class InboundMessage {
private String channelType; // 渠道类型: wechat/douyin/jd
private String channelMessageId; // 渠道原始消息ID (用于幂等)
private String sessionKey; // 会话标识 (customerId + kfId 组合)
private String customerId; // 客户ID
private String kfId; // 客服账号ID
private String sender; // 发送者标识
private String content; // 消息内容 (统一字段名)
private String msgType; // 消息类型: text/image/voice 等
private String rawPayload; // 原始消息体 (JSON/XML)
private Long timestamp; // 消息时间戳
private SignatureInfo signatureInfo; // 签名信息
private Map<String, Object> metadata; // 扩展元数据
}
@Data
public class SignatureInfo {
private String signature; // 签名值
private String timestamp; // 签名时间戳
private String nonce; // 随机数
private String algorithm; // 签名算法 (可选)
}
```
### 2.2 出站消息 (OutboundMessage)
```java
@Data
public class OutboundMessage {
private String channelType; // 渠道类型
private String receiver; // 接收者ID (customerId)
private String kfId; // 客服账号ID
private String content; // 消息内容
private String msgType; // 消息类型
private Map<String, Object> metadata; // 扩展元数据
}
```
### 2.3 字段映射策略
> **重要**:内部统一使用 `content` 字段名,与 AI 服务契约 (`currentMessage`) 的映射在 AiServiceClient 层处理。
| 内部字段 | AI 服务契约字段 | 映射位置 |
|---------|----------------|---------|
| `InboundMessage.content` | `ChatRequest.currentMessage` | `AiServiceClient.generateReply()` |
| `InboundMessage.sessionKey` | `ChatRequest.sessionId` | `AiServiceClient.generateReply()` |
| `InboundMessage.channelType` | `ChatRequest.channelType` | `AiServiceClient.generateReply()` |
```java
public ChatRequest toChatRequest(InboundMessage msg, List<Message> history) {
ChatRequest request = new ChatRequest();
request.setSessionId(msg.getSessionKey());
request.setCurrentMessage(msg.getContent()); // content → currentMessage
request.setChannelType(msg.getChannelType());
request.setHistory(history);
return request;
}
```
## 3. 核心接口设计
### 3.1 渠道适配器接口
```java
// 核心能力接口(所有渠道必须实现)
public interface ChannelAdapter {
String getChannelType();
void sendMessage(OutboundMessage message);
}
// 可选能力接口:服务状态管理
public interface ServiceStateCapable {
ServiceState getServiceState(String kfId, String customerId);
boolean transServiceState(String kfId, String customerId, int newState, String servicerId);
}
// 可选能力接口:转人工
public interface TransferCapable {
boolean transferToPool(String kfId, String customerId);
boolean transferToManual(String kfId, String customerId, String servicerId);
}
// 可选能力接口:消息同步
public interface MessageSyncCapable {
SyncMsgResponse syncMessages(String kfId, String cursor);
}
```
### 3.2 消息路由服务接口
```java
public interface MessageRouterService {
void processInboundMessage(InboundMessage message);
void routeBySessionState(Session session, InboundMessage message);
void dispatchToAiService(Session session, InboundMessage message);
void dispatchToManualCs(Session session, InboundMessage message);
void dispatchToPendingPool(Session session, InboundMessage message);
}
```
### 3.3 AI 服务客户端接口
```java
public interface AiServiceClient {
ChatResponse generateReply(ChatRequest request);
boolean healthCheck();
}
```
## 4. 核心流程
### 4.1 消息处理主流程(渠道无关)
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 渠道回调入口 │────▶│ 验签/解密/解析 │────▶│ 构建 InboundMessage│
│ (Controller) │ │ (渠道专属逻辑) │ │ (统一消息模型) │
└──────────────────┘ └────────┬─────────┘ └──────────────────┘
┌──────────────────┐
│ MessageRouter │
│ processInbound │
│ Message() │
└────────┬─────────┘
┌──────────────────┐
│ 幂等检查 (msgId) │
│ Redis SETNX │
└────────┬─────────┘
┌──────────────────┐
│ 获取/创建会话 │
│ SessionManager │
└────────┬─────────┘
┌──────────────────┐
│ 获取渠道服务状态 │
│ (可选能力检测) │
└────────┬─────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ AI 状态 │ │ POOL 状态 │ │MANUAL 状态│
│ │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│dispatchTo │ │dispatchTo│ │dispatchTo │
│ AiService │ │PendingPool│ │ ManualCs │
└──────┬───────┘ └──────────┘ └──────────────┘
┌──────────────┐
│ 判断是否转人工│
│shouldTransfer│
└──────┬───────┘
┌───────┴───────┐
▼ ▼
┌───────┐ ┌──────────────┐
│发送回复│ │ 转入待接入池 │
│给用户 │ │ TransferCapable│
└───────┘ └──────────────┘
```
### 4.2 AI 服务调用流程
```
┌──────────────────┐
│ 构造 ChatRequest │
│ sessionId │
│ currentMessage │←── content 映射
│ channelType │
│ history (可选) │
└────────┬─────────┘
┌──────────────────┐ ┌──────────────────┐
│ HTTP POST │────▶│ Python AI 服务 │
│ /ai/chat │ │ 超时: 5s │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ ┌──────────────┼──────────────┐
│ ▼ ▼ ▼
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ 成功响应 │ │ 超时/失败 │ │ 服务不可用│
│ │ 200 OK │ │ Timeout │ │ 503 │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ ▼ ▼ ▼
│ ┌──────────┐ ┌──────────────────────┐
│ │ 返回回复 │ │ 降级处理 │
│ │ reply │ │ 返回固定回复 │
│ │confidence│ │ "正在转接人工客服..." │
│ │shouldTransfer│ └──────────┬───────────┘
│ └──────────┘ │
│ ▼
│ ┌──────────────┐
│ │ 触发转人工 │
│ │ TransferCapable│
│ └──────────────┘
┌──────────────────┐
│ 处理响应 │
│ - 保存消息 │
│ - 发送给用户 │
│ - 判断转人工 │
└──────────────────┘
```
## 5. 数据模型
### 5.1 实体关系图
```
┌──────────────────┐ ┌──────────────────┐
│ Session │ │ Message │
├──────────────────┤ ├──────────────────┤
│ sessionId (PK) │──────▶│ msgId (PK) │
│ customerId │ 1:N │ sessionId (FK) │
│ kfId │ │ senderType │
│ channelType (新) │ │ senderId │
│ status │ │ content │
│ wxServiceState │ │ msgType │
│ manualCsId │ │ rawData │
│ createdAt │ │ createdAt │
│ updatedAt │ └──────────────────┘
└──────────────────┘
│ 1:N
┌──────────────────┐
│ TransferLog │
├──────────────────┤
│ id (PK) │
│ sessionId (FK) │
│ triggerReason │
│ triggerTime │
│ acceptedCsId │
│ acceptedTime │
└──────────────────┘
```
### 5.2 数据库变更
> **口径说明**:本次仅做最小 schema 变更,新增 `channel_type` 字段,默认值为 `wechat`;可通过在线 DDL 方式执行;不涉及数据迁移。符合 requirements.md 中"仅增加渠道类型字段,不进行大规模迁移"的范围约定。
| 表名 | 变更类型 | 变更内容 |
|-----|---------|---------|
| `session` | 新增字段 | `channel_type VARCHAR(20) DEFAULT 'wechat'` |
**DDL 示例**
```sql
ALTER TABLE session ADD COLUMN channel_type VARCHAR(20) DEFAULT 'wechat'
COMMENT '渠道类型: wechat/douyin/jd';
```
### 5.3 Redis 缓存结构
| Key 模式 | 类型 | 说明 | TTL |
|---------|------|------|-----|
| `wecom:access_token` | String | 微信 access_token | 7200s - 300s |
| `wecom:cursor:{openKfId}` | String | 消息同步游标 | 永久 |
| `session:status:{sessionId}` | String | 会话状态缓存 | 24h |
| `session:msg_count:{sessionId}` | String | 消息计数 | 24h |
| `idempotent:{msgId}` | String | 消息幂等键 | 1h |
## 6. 跨模块调用策略
### 6.1 AI 服务调用
| 配置项 | 值 | 说明 |
|-------|---|------|
| **超时时间** | 5 秒 | 连接 + 读取总超时 |
| **重试次数** | 0 | 不重试,直接降级 |
| **熔断阈值** | 5 次/分钟 | 连续失败 5 次触发熔断 |
| **熔断时间** | 30 秒 | 熔断后等待时间 |
| **降级策略** | 返回固定回复 + 转人工 | 见下方降级逻辑 |
### 6.2 熔断器选型
> **选型决策**:使用 **Resilience4j** 作为熔断器实现,与 Spring Boot 2.7 兼容。
| 方案 | 说明 |
|-----|------|
| **Resilience4j** | 推荐。轻量级,支持断路器、限流、重试,与 Spring Boot 2.7 兼容良好 |
| 最小实现 | 仅做 timeout + fallback不做熔断不推荐与 requirements 不一致) |
**熔断状态存储**
- 单实例内存存储CircuitBreakerRegistry
- 多实例:可扩展为 Redis 存储(通过 Resilience4j + Redis 实现)
**依赖配置**
```xml
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
<version>2.1.0</version>
</dependency>
```
### 6.3 降级逻辑
```java
@Service
public class AiServiceClientImpl implements AiServiceClient {
@CircuitBreaker(name = "aiService", fallbackMethod = "fallback")
@TimeLimiter(name = "aiService")
public ChatResponse generateReply(ChatRequest request) {
// HTTP 调用 Python AI 服务
}
public ChatResponse fallback(ChatRequest request, Throwable cause) {
log.warn("AI 服务降级: sessionId={}, cause={}",
request.getSessionId(), cause.getMessage());
ChatResponse response = new ChatResponse();
response.setReply("抱歉,我暂时无法回答您的问题,正在为您转接人工客服...");
response.setConfidence(0.0);
response.setShouldTransfer(true);
return response;
}
}
```
### 6.4 错误映射
| AI 服务错误 | 主框架处理 | 用户感知 |
|------------|-----------|---------|
| 200 OK | 正常处理 | 返回 AI 回复 |
| 400 Bad Request | 记录日志,降级 | 转人工 |
| 500 Internal Error | 记录日志,降级 | 转人工 |
| 503 Service Unavailable | 记录日志,降级 | 转人工 |
| Timeout | 记录日志,降级 | 转人工 |
| Connection Refused | 触发熔断,降级 | 转人工 |
## 7. 消息幂等性设计
### 7.1 幂等键
- 使用 `InboundMessage.channelMessageId` 作为幂等键
- 微信渠道:使用微信返回的 `msgId`
- 其他渠道:使用渠道返回的消息 ID 或生成唯一 ID
### 7.2 幂等处理流程
```
┌──────────────────┐
│ 收到消息 │
│ channelMessageId │
└────────┬─────────┘
┌──────────────────┐ ┌──────────────────┐
│ Redis 检查 │────▶│ Key 不存在 │
│ idempotent:{msgId}│ │ 继续处理 │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Key 已存在 │ │ 设置 Key (TTL 1h)│
│ 跳过处理 │ │ 处理消息 │
└──────────────────┘ └──────────────────┘
```
### 7.3 实现代码
```java
public boolean processMessageIdempotent(String channelMessageId, Runnable processor) {
String key = "idempotent:" + channelMessageId;
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 1, TimeUnit.HOURS);
if (Boolean.TRUE.equals(absent)) {
processor.run();
return true;
}
log.info("重复消息,跳过处理: channelMessageId={}", channelMessageId);
return false;
}
```
## 8. 配置管理
### 8.1 新增配置项
```yaml
# application.yml 新增配置
ai-service:
url: http://ai-service:8080
timeout: 5000
resilience4j:
circuitbreaker:
instances:
aiService:
failure-rate-threshold: 50
sliding-window-size: 10
sliding-window-type: COUNT_BASED
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
timelimiter:
instances:
aiService:
timeout-duration: 5s
channel:
default: wechat
adapters:
wechat:
enabled: true
douyin:
enabled: false
jd:
enabled: false
```
### 8.2 配置类
```java
@Data
@Component
@ConfigurationProperties(prefix = "ai-service")
public class AiServiceConfig {
private String url;
private int timeout = 5000;
}
@Data
@Component
@ConfigurationProperties(prefix = "channel")
public class ChannelConfig {
private String default;
private Map<String, AdapterConfig> 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 |

View File

@ -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: 会话IDAC-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.0AC-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

View File

@ -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: "<xml><Encrypt>...</Encrypt></xml>"
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 | 会话已关闭 |

View File

@ -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 服务独立部署,主框架通过内网调用

347
spec/ai-robot/tasks.md Normal file
View File

@ -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% |

View File

@ -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);
}
}

View File

@ -0,0 +1,31 @@
package com.wecom.robot.adapter;
import com.wecom.robot.dto.OutboundMessage;
/**
* 渠道适配器核心能力接口
* <p>
* 所有渠道适配器必须实现此接口提供渠道类型标识和消息发送能力
* [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);
}

View File

@ -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;
/**
* 渠道适配器工厂
* <p>
* 根据渠道类型获取对应的渠道适配器实例
* [AC-MCA-03] 渠道适配器工厂
*/
@Slf4j
@Component
public class ChannelAdapterFactory {
private final Map<String, ChannelAdapter> adapterMap;
public ChannelAdapterFactory(List<ChannelAdapter> 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<String> getSupportedChannelTypes() {
return adapterMap.keySet();
}
/**
* 获取适配器并检查是否支持指定能力
*
* @param channelType 渠道类型
* @param capabilityClass 能力接口类
* @param <T> 能力类型
* @return 能力实例如果不支持则返回 null
*/
public <T> T getAdapterWithCapability(String channelType, Class<T> 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);
}
}

View File

@ -0,0 +1,22 @@
package com.wecom.robot.adapter;
import com.wecom.robot.dto.SyncMsgResponse;
/**
* 消息同步能力接口可选
* <p>
* 提供从渠道同步历史消息的能力
* 渠道适配器可选择性实现此接口
* [AC-MCA-01] 渠道适配层可选能力接口
*/
public interface MessageSyncCapable {
/**
* 同步消息
*
* @param kfId 客服账号ID
* @param cursor 游标用于分页获取
* @return 同步消息响应
*/
SyncMsgResponse syncMessages(String kfId, String cursor);
}

View File

@ -0,0 +1,33 @@
package com.wecom.robot.adapter;
import com.wecom.robot.dto.ServiceStateResponse;
/**
* 服务状态管理能力接口可选
* <p>
* 提供渠道服务状态的获取和变更能力
* 渠道适配器可选择性实现此接口
* [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);
}

View File

@ -0,0 +1,30 @@
package com.wecom.robot.adapter;
/**
* 转人工能力接口可选
* <p>
* 提供将客户转入待接入池或转给指定人工客服的能力
* 渠道适配器可选择性实现此接口
* [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);
}

View File

@ -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;
/**
* 企业微信渠道适配器
* <p>
* 实现企业微信渠道的消息发送服务状态管理转人工消息同步等能力
* [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<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> 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<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> 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<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> 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<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> 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<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> 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<String> 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);
}
}

View File

@ -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;
}

View File

@ -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<String, AdapterConfig> adapters;
@Data
public static class AdapterConfig {
private boolean enabled;
}
}

View File

@ -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<Void> 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<Void> 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<Void> handleException(Exception ex) {
log.error("服务器内部错误", ex);
return ApiResponse.error(500, "服务器内部错误");
}
}

View File

@ -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);
}
}

View File

@ -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<String> keywords;
private double confidenceThreshold;
private int maxFailRounds;
private long maxSessionDuration;
private int maxMessageRounds;
}

View File

@ -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("*");
}
}

View File

@ -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;
}
}

View File

@ -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<List<Map<String, Object>>> getKfAccounts() {
try {
JSONObject result = wecomApiService.getKfAccountList(0, 100);
List<Map<String, Object>> accounts = new ArrayList<>();
Integer errcode = result.getInteger("errcode");
if (errcode == null || errcode == 0) {
JSONArray accountList = result.getJSONArray("account_list");
if (accountList != null) {
for (int i = 0; i < accountList.size(); i++) {
JSONObject account = accountList.getJSONObject(i);
Map<String, Object> map = new HashMap<>();
map.put("openKfId", account.getString("open_kfid"));
map.put("name", account.getString("name"));
map.put("avatar", account.getString("avatar"));
accounts.add(map);
}
}
}
return ApiResponse.success(accounts);
} catch (Exception e) {
log.error("获取客服账号列表失败", e);
return ApiResponse.error("获取客服账号列表失败: " + e.getMessage());
}
}
@GetMapping("/sessions")
public ApiResponse<List<Map<String, Object>>> getSessions(
@RequestParam String openKfId,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "50") int limit) {
try {
List<Session> sessions = sessionManagerService.getSessionsByKfId(openKfId, status, limit);
List<Map<String, Object>> result = new ArrayList<>();
for (Session session : sessions) {
Map<String, Object> map = new HashMap<>();
map.put("sessionId", session.getSessionId());
map.put("customerId", session.getCustomerId());
map.put("kfId", session.getKfId());
map.put("status", session.getStatus());
map.put("wxServiceState", session.getWxServiceState());
map.put("manualCsId", session.getManualCsId());
map.put("createdAt", session.getCreatedAt() != null ? session.getCreatedAt().toString() : null);
map.put("updatedAt", session.getUpdatedAt() != null ? session.getUpdatedAt().toString() : null);
int msgCount = sessionManagerService.getMessageCount(session.getSessionId());
map.put("messageCount", msgCount);
result.add(map);
}
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取会话列表失败", e);
return ApiResponse.error("获取会话列表失败: " + e.getMessage());
}
}
@GetMapping("/messages")
public ApiResponse<List<Map<String, Object>>> getMessages(@RequestParam String sessionId) {
try {
List<Message> messages = sessionManagerService.getSessionMessages(sessionId);
List<Map<String, Object>> result = new ArrayList<>();
for (Message msg : messages) {
Map<String, Object> map = new HashMap<>();
map.put("msgId", msg.getMsgId());
map.put("sessionId", msg.getSessionId());
map.put("senderType", msg.getSenderType());
map.put("senderId", msg.getSenderId());
map.put("content", msg.getContent());
map.put("msgType", msg.getMsgType());
map.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null);
result.add(map);
}
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取消息列表失败", e);
return ApiResponse.error("获取消息列表失败: " + e.getMessage());
}
}
@GetMapping("/session/{sessionId}/detail")
public ApiResponse<Map<String, Object>> getSessionDetail(@PathVariable String sessionId) {
try {
Session session = sessionManagerService.getSession(sessionId);
if (session == null) {
return ApiResponse.error("会话不存在");
}
Map<String, Object> result = new HashMap<>();
result.put("session", session);
List<Message> messages = sessionManagerService.getSessionMessages(sessionId);
result.put("messages", messages);
result.put("messageCount", messages.size());
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取会话详情失败", e);
return ApiResponse.error("获取会话详情失败: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,141 @@
package com.wecom.robot.controller;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.entity.Message;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.util.WXBizMsgCrypt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/debug")
@RequiredArgsConstructor
public class DebugController {
private final WecomConfig wecomConfig;
private final SessionManagerService sessionManagerService;
@GetMapping("/config")
public ApiResponse<Map<String, Object>> getConfig() {
Map<String, Object> config = new HashMap<>();
config.put("corpId", wecomConfig.getCorpId());
config.put("token", wecomConfig.getToken());
config.put("encodingAesKey", wecomConfig.getEncodingAesKey());
config.put("encodingAesKeyLength", wecomConfig.getEncodingAesKey() != null ? wecomConfig.getEncodingAesKey().length() : 0);
try {
byte[] aesKey = Base64.getDecoder().decode(wecomConfig.getEncodingAesKey() + "=");
config.put("aesKeyLength", aesKey.length);
config.put("aesKeyHex", bytesToHex(aesKey));
} catch (Exception e) {
config.put("aesKeyError", e.getMessage());
}
return ApiResponse.success(config);
}
@PostMapping("/decrypt")
public ApiResponse<Map<String, Object>> testDecrypt(
@RequestParam String msgSignature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestBody String encryptedXml) {
Map<String, Object> result = new HashMap<>();
result.put("msgSignature", msgSignature);
result.put("timestamp", timestamp);
result.put("nonce", nonce);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decrypted = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, encryptedXml);
result.put("decrypted", decrypted);
result.put("success", true);
} catch (Exception e) {
result.put("error", e.getMessage());
result.put("success", false);
log.error("解密测试失败", e);
}
return ApiResponse.success(result);
}
@GetMapping("/verify-url")
public ApiResponse<Map<String, Object>> testVerifyUrl(
@RequestParam String msgSignature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr) {
Map<String, Object> result = new HashMap<>();
result.put("msgSignature", msgSignature);
result.put("timestamp", timestamp);
result.put("nonce", nonce);
result.put("echostr", echostr);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decrypted = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr);
result.put("decrypted", decrypted);
result.put("success", true);
} catch (Exception e) {
result.put("error", e.getMessage());
result.put("success", false);
log.error("URL验证测试失败", e);
}
return ApiResponse.success(result);
}
@GetMapping("/ai/session/{sessionId}/context")
public ApiResponse<Map<String, Object>> getSessionAiContext(
@PathVariable String sessionId) {
Map<String, Object> result = new HashMap<>();
result.put("sessionId", sessionId);
List<Message> history = sessionManagerService.getSessionMessages(sessionId);
result.put("historyCount", history.size());
List<Map<String, Object>> historyList = new java.util.ArrayList<>();
for (Message msg : history) {
Map<String, Object> msgMap = new HashMap<>();
msgMap.put("senderType", msg.getSenderType());
msgMap.put("senderId", msg.getSenderId());
msgMap.put("content", msg.getContent());
msgMap.put("msgType", msg.getMsgType());
msgMap.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null);
historyList.add(msgMap);
}
result.put("history", historyList);
return ApiResponse.success(result);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

View File

@ -0,0 +1,197 @@
package com.wecom.robot.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wecom.robot.dto.*;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.mapper.MessageMapper;
import com.wecom.robot.mapper.SessionMapper;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.service.WecomApiService;
import com.wecom.robot.service.WebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/api/sessions")
@RequiredArgsConstructor
public class SessionController {
private final SessionMapper sessionMapper;
private final MessageMapper messageMapper;
private final SessionManagerService sessionManagerService;
private final WecomApiService wecomApiService;
private final WebSocketService webSocketService;
@GetMapping
public ApiResponse<List<SessionInfo>> getSessions(
@RequestParam(required = false) String status,
@RequestParam(required = false) String csId,
@RequestParam(required = false) String channelType) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
if (status != null) {
query.eq(Session::getStatus, status);
}
if (csId != null) {
query.eq(Session::getManualCsId, csId);
}
if (channelType != null) {
query.eq(Session::getChannelType, channelType);
}
query.orderByDesc(Session::getUpdatedAt);
List<Session> sessions = sessionMapper.selectList(query);
List<SessionInfo> sessionInfos = sessions.stream().map(session -> {
SessionInfo info = new SessionInfo();
info.setSessionId(session.getSessionId());
info.setCustomerId(session.getCustomerId());
info.setKfId(session.getKfId());
info.setChannelType(session.getChannelType());
info.setStatus(session.getStatus());
info.setManualCsId(session.getManualCsId());
info.setCreatedAt(session.getCreatedAt());
info.setUpdatedAt(session.getUpdatedAt());
info.setMetadata(session.getMetadata());
LambdaQueryWrapper<Message> msgQuery = new LambdaQueryWrapper<>();
msgQuery.eq(Message::getSessionId, session.getSessionId())
.orderByDesc(Message::getCreatedAt)
.last("LIMIT 1");
Message lastMsg = messageMapper.selectOne(msgQuery);
if (lastMsg != null) {
info.setLastMessage(lastMsg.getContent());
info.setLastMessageTime(lastMsg.getCreatedAt());
}
int msgCount = sessionManagerService.getMessageCount(session.getSessionId());
info.setMessageCount(msgCount);
return info;
}).collect(Collectors.toList());
return ApiResponse.success(sessionInfos);
}
@GetMapping("/{sessionId}")
public ApiResponse<SessionInfo> getSession(@PathVariable String sessionId) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
SessionInfo info = new SessionInfo();
info.setSessionId(session.getSessionId());
info.setCustomerId(session.getCustomerId());
info.setKfId(session.getKfId());
info.setChannelType(session.getChannelType());
info.setStatus(session.getStatus());
info.setManualCsId(session.getManualCsId());
info.setCreatedAt(session.getCreatedAt());
info.setUpdatedAt(session.getUpdatedAt());
info.setMetadata(session.getMetadata());
return ApiResponse.success(info);
}
@GetMapping("/{sessionId}/history")
public ApiResponse<List<MessageInfo>> getSessionHistory(@PathVariable String sessionId) {
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId)
.orderByAsc(Message::getCreatedAt);
List<Message> messages = messageMapper.selectList(query);
List<MessageInfo> messageInfos = messages.stream().map(msg -> {
MessageInfo info = new MessageInfo();
info.setMsgId(msg.getMsgId());
info.setSessionId(msg.getSessionId());
info.setSenderType(msg.getSenderType());
info.setSenderId(msg.getSenderId());
info.setContent(msg.getContent());
info.setMsgType(msg.getMsgType());
info.setCreatedAt(msg.getCreatedAt());
return info;
}).collect(Collectors.toList());
return ApiResponse.success(messageInfos);
}
@PostMapping("/{sessionId}/accept")
public ApiResponse<Void> acceptSession(
@PathVariable String sessionId,
@Valid @RequestBody AcceptSessionRequest request) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
if (!Session.STATUS_PENDING.equals(session.getStatus())) {
return ApiResponse.error(400, "会话状态不正确");
}
sessionManagerService.acceptTransfer(sessionId, request.getCsId());
webSocketService.notifySessionAccepted(sessionId, request.getCsId());
return ApiResponse.success(null);
}
@PostMapping("/{sessionId}/message")
public ApiResponse<Void> sendMessage(
@PathVariable String sessionId,
@Valid @RequestBody SendMessageRequest request) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
if (!Session.STATUS_MANUAL.equals(session.getStatus())) {
return ApiResponse.error(400, "会话状态不正确");
}
boolean success = wecomApiService.sendTextMessage(
session.getCustomerId(),
session.getKfId(),
request.getContent()
);
if (!success) {
return ApiResponse.error(500, "消息发送失败");
}
sessionManagerService.saveMessage(
"manual_" + System.currentTimeMillis(),
sessionId,
Message.SENDER_TYPE_MANUAL,
session.getManualCsId(),
request.getContent(),
request.getMsgType() != null ? request.getMsgType() : "text",
null
);
return ApiResponse.success(null);
}
@PostMapping("/{sessionId}/close")
public ApiResponse<Void> closeSession(@PathVariable String sessionId) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
sessionManagerService.closeSession(sessionId);
webSocketService.notifySessionClosed(sessionId);
return ApiResponse.success(null);
}
}

View File

@ -0,0 +1,150 @@
package com.wecom.robot.controller;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.service.MessageProcessService;
import com.wecom.robot.service.WecomApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("/test")
@RequiredArgsConstructor
public class TestController {
private final MessageProcessService messageProcessService;
private final WecomApiService wecomApiService;
@PostMapping("/send-message")
public ApiResponse<Map<String, Object>> sendTestMessage(
@RequestParam(required = false, defaultValue = "test_customer_001") String customerId,
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId,
@RequestParam String content) {
WxCallbackMessage message = new WxCallbackMessage();
message.setToUserName("system");
message.setFromUserName(customerId);
message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
message.setMsgType("text");
message.setContent(content);
message.setMsgId(UUID.randomUUID().toString());
message.setOpenKfId(kfId);
message.setExternalUserId(customerId);
Map<String, String> rawData = new HashMap<>();
rawData.put("ToUserName", "system");
rawData.put("FromUserName", customerId);
rawData.put("MsgType", "text");
rawData.put("Content", content);
rawData.put("MsgId", message.getMsgId());
rawData.put("OpenKfId", kfId);
rawData.put("ExternalUserId", customerId);
message.setRawData(rawData);
log.info("模拟发送消息(测试模式): customerId={}, kfId={}, content={}", customerId, kfId, content);
messageProcessService.processMessage(message);
Map<String, Object> result = new HashMap<>();
result.put("msgId", message.getMsgId());
result.put("customerId", customerId);
result.put("kfId", kfId);
result.put("content", content);
result.put("mode", "test_direct");
return ApiResponse.success(result);
}
@PostMapping("/trigger-transfer")
public ApiResponse<Map<String, Object>> triggerTransfer(
@RequestParam(required = false, defaultValue = "test_customer_001") String customerId,
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId) {
WxCallbackMessage message = new WxCallbackMessage();
message.setToUserName("system");
message.setFromUserName(customerId);
message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
message.setMsgType("text");
message.setContent("我要转人工");
message.setMsgId(UUID.randomUUID().toString());
message.setOpenKfId(kfId);
message.setExternalUserId(customerId);
Map<String, String> rawData = new HashMap<>();
rawData.put("ToUserName", "system");
rawData.put("FromUserName", customerId);
rawData.put("MsgType", "text");
rawData.put("Content", "我要转人工");
rawData.put("MsgId", message.getMsgId());
rawData.put("OpenKfId", kfId);
rawData.put("ExternalUserId", customerId);
message.setRawData(rawData);
log.info("模拟触发转人工(测试模式): customerId={}, kfId={}", customerId, kfId);
messageProcessService.processMessage(message);
Map<String, Object> result = new HashMap<>();
result.put("msgId", message.getMsgId());
result.put("customerId", customerId);
result.put("kfId", kfId);
result.put("trigger", "transfer");
result.put("mode", "test_direct");
return ApiResponse.success(result);
}
@PostMapping("/simulate-event")
public ApiResponse<Map<String, Object>> simulateKfMsgEvent(
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId,
@RequestParam(required = false) String token) {
WxCallbackMessage event = new WxCallbackMessage();
event.setMsgType("event");
event.setEvent(WxCallbackMessage.EVENT_KF_MSG_FROM_CUSTOMER);
event.setOpenKfId(kfId);
event.setToken(token);
event.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
log.info("模拟客户消息事件: kfId={}, token={}", kfId, token);
messageProcessService.processKfMessageEvent(event);
Map<String, Object> result = new HashMap<>();
result.put("kfId", kfId);
result.put("token", token);
result.put("mode", "event_sync");
return ApiResponse.success(result);
}
@GetMapping("/sync-msg")
public ApiResponse<SyncMsgResponse> syncMessages(
@RequestParam String kfId,
@RequestParam(required = false) String token) {
log.info("手动拉取消息: kfId={}, token={}", kfId, token);
SyncMsgResponse response = wecomApiService.syncMessagesByToken(kfId, token);
return ApiResponse.success(response);
}
@PostMapping("/clear-cursor")
public ApiResponse<Void> clearCursor(
@RequestParam String kfId) {
log.info("清除cursor: kfId={}", kfId);
wecomApiService.clearCursor(kfId);
return ApiResponse.success(null);
}
}

View File

@ -0,0 +1,114 @@
package com.wecom.robot.controller;
import com.wecom.robot.adapter.ChannelAdapter;
import com.wecom.robot.adapter.MessageSyncCapable;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.*;
import com.wecom.robot.service.MessageProcessService;
import com.wecom.robot.service.MessageRouterService;
import com.wecom.robot.util.WXBizMsgCrypt;
import com.wecom.robot.util.XmlUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 企业微信回调控制器
* <p>
* 负责验签/解密/解析构建 InboundMessage 传递给 MessageRouterService
* [AC-MCA-08] 入口层控制器
*/
@Slf4j
@RestController
@RequestMapping("/wecom")
@RequiredArgsConstructor
public class WecomCallbackController {
private static final String CHANNEL_TYPE = "wechat";
private final WecomConfig wecomConfig;
private final MessageProcessService messageProcessService;
private final MessageRouterService messageRouterService;
private final Map<String, ChannelAdapter> channelAdapters;
@GetMapping("/callback")
public String verifyUrl(
@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
log.info("收到URL验证请求: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String result = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr);
log.info("URL验证成功返回: {}", result);
return result;
} catch (Exception e) {
log.error("URL验证失败", e);
return "error";
}
}
@PostMapping("/callback")
public String handleCallback(
@RequestParam(value = "msg_signature", required = false) String msgSignature,
@RequestParam(value = "timestamp", required = false) String timestamp,
@RequestParam(value = "nonce", required = false) String nonce,
@RequestBody String requestBody) {
log.info("收到回调消息: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
log.debug("消息内容: {}", requestBody);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decryptedXml = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, requestBody);
log.info("解密后的XML: {}", decryptedXml);
Map<String, String> messageMap = XmlUtil.parseXml(decryptedXml);
WxCallbackMessage message = WxCallbackMessage.fromMap(messageMap);
log.info("解析后的消息: msgType={}, event={}, openKfId={}",
message.getMsgType(), message.getEvent(), message.getOpenKfId());
if ("event".equals(message.getMsgType())) {
handleEvent(message);
} else {
log.warn("收到非事件消息: msgType={}", message.getMsgType());
}
return "success";
} catch (Exception e) {
log.error("处理回调消息失败", e);
return "success";
}
}
private void handleEvent(WxCallbackMessage message) {
String event = message.getEvent();
log.info("处理事件: event={}, openKfId={}, token={}",
event, message.getOpenKfId(), message.getToken());
if (message.isKfMsgEvent()) {
log.info("收到客户消息事件通知: openKfId={}, token={}",
message.getOpenKfId(), message.getToken());
messageProcessService.processKfMessageEvent(message);
} else if (message.isAccountOnlineEvent()) {
log.info("客服账号上线: openKfId={}", message.getOpenKfId());
} else if (message.isAccountOfflineEvent()) {
log.info("客服账号下线: openKfId={}", message.getOpenKfId());
} else {
log.info("其他事件: event={}", event);
}
}
}

View File

@ -0,0 +1,14 @@
package com.wecom.robot.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class AcceptSessionRequest {
@NotBlank(message = "客服ID不能为空")
@Size(min = 1, max = 64, message = "客服ID长度必须在1-64之间")
private String csId;
}

View File

@ -0,0 +1,30 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(0);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
public static <T> ApiResponse<T> error(String message) {
return error(500, message);
}
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChatCompletionRequest {
private String model;
private List<Message> messages;
private double temperature;
private int maxTokens;
@Data
public static class Message {
private String role;
private String content;
public Message(String role, String content) {
this.role = role;
this.content = content;
}
}
public static ChatCompletionRequest create(String model, List<Message> messages) {
ChatCompletionRequest request = new ChatCompletionRequest();
request.setModel(model);
request.setMessages(messages);
request.setTemperature(0.7);
request.setMaxTokens(2000);
return request;
}
}

View File

@ -0,0 +1,43 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChatCompletionResponse {
private String id;
private String object;
private long created;
private String model;
private List<Choice> choices;
private Usage usage;
@Data
public static class Choice {
private int index;
private Message message;
private String finishReason;
}
@Data
public static class Message {
private String role;
private String content;
}
@Data
public static class Usage {
private int promptTokens;
private int completionTokens;
private int totalTokens;
}
public String getContent() {
if (choices != null && !choices.isEmpty()) {
return choices.get(0).getMessage().getContent();
}
return null;
}
}

View File

@ -0,0 +1,49 @@
package com.wecom.robot.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InboundMessage {
private String channelType;
private String channelMessageId;
private String sessionKey;
private String customerId;
private String kfId;
private String sender;
private String content;
private String msgType;
private String rawPayload;
private Long timestamp;
private SignatureInfo signatureInfo;
private Map<String, Object> metadata;
public static final String CHANNEL_WECHAT = "wechat";
public static final String CHANNEL_DOUYIN = "douyin";
public static final String CHANNEL_JD = "jd";
public static final String MSG_TYPE_TEXT = "text";
public static final String MSG_TYPE_IMAGE = "image";
public static final String MSG_TYPE_VOICE = "voice";
public static final String MSG_TYPE_VIDEO = "video";
public static final String MSG_TYPE_EVENT = "event";
}

View File

@ -0,0 +1,32 @@
package com.wecom.robot.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class MessageInfo {
@NotBlank
@Size(min = 1, max = 128)
private String msgId;
@NotBlank
@Size(min = 1, max = 64)
private String sessionId;
@NotBlank
private String senderType;
@Size(max = 64)
private String senderId;
@NotBlank
@Size(min = 1, max = 4096)
private String content;
private String msgType;
private java.time.LocalDateTime createdAt;
}

View File

@ -0,0 +1,27 @@
package com.wecom.robot.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OutboundMessage {
private String channelType;
private String receiver;
private String kfId;
private String content;
private String msgType;
private Map<String, Object> metadata;
}

View File

@ -0,0 +1,16 @@
package com.wecom.robot.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class SendMessageRequest {
@NotBlank(message = "消息内容不能为空")
@Size(min = 1, max = 4096, message = "消息内容长度必须在1-4096之间")
private String content;
private String msgType;
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class ServiceStateResponse {
private Integer errcode;
private String errmsg;
private Integer serviceState;
private String servicerUserid;
public boolean isSuccess() {
return errcode == null || errcode == 0;
}
public static final int STATE_UNTREATED = 0;
public static final int STATE_AI = 1;
public static final int STATE_POOL = 2;
public static final int STATE_MANUAL = 3;
public static final int STATE_CLOSED = 4;
public String getStateDesc() {
if (serviceState == null) return "未知";
switch (serviceState) {
case STATE_UNTREATED: return "未处理";
case STATE_AI: return "智能助手接待";
case STATE_POOL: return "待接入池排队";
case STATE_MANUAL: return "人工接待";
case STATE_CLOSED: return "已结束";
default: return "未知(" + serviceState + ")";
}
}
}

View File

@ -0,0 +1,44 @@
package com.wecom.robot.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
@Data
public class SessionInfo {
@NotBlank
@Size(min = 1, max = 64)
private String sessionId;
@NotBlank
@Size(min = 1, max = 64)
private String customerId;
@Size(max = 64)
private String kfId;
@Size(max = 64)
private String channelType;
@NotBlank
private String status;
@Size(max = 64)
private String manualCsId;
@Size(max = 4096)
private String lastMessage;
private LocalDateTime lastMessageTime;
private int messageCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String metadata;
}

View File

@ -0,0 +1,21 @@
package com.wecom.robot.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignatureInfo {
private String signature;
private String timestamp;
private String nonce;
private String algorithm;
}

View File

@ -0,0 +1,212 @@
package com.wecom.robot.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
@Data
public class SyncMsgResponse {
private Integer errcode;
private String errmsg;
private String nextCursor;
private Boolean hasMore;
private List<MsgItem> msgList;
@Data
public static class MsgItem {
private String msgId;
private Integer origin;
private String externalUserId;
private String openKfId;
private Long sendTime;
private String msgType;
private String servicerUserid;
private String originData;
private TextContent text;
private ImageContent image;
private VoiceContent voice;
private VideoContent video;
private FileContent file;
private LocationContent location;
private LinkContent link;
private BusinessCardContent businessCard;
private MiniprogramContent miniprogram;
private MsgMenuContent msgmenu;
private EventContent event;
public static final int ORIGIN_CUSTOMER = 3;
public static final int ORIGIN_SYSTEM_EVENT = 4;
public static final int ORIGIN_SERVICER = 5;
public String getTextContent() {
return text != null ? text.getContent() : null;
}
public String getImageMediaId() {
return image != null ? image.getMediaId() : null;
}
public boolean isFromCustomer() {
return origin != null && origin == ORIGIN_CUSTOMER;
}
public boolean isSystemEvent() {
return origin != null && origin == ORIGIN_SYSTEM_EVENT;
}
public boolean isFromServicer() {
return origin != null && origin == ORIGIN_SERVICER;
}
public boolean isEvent() {
return "event".equals(msgType);
}
public String getEventType() {
return event != null ? event.getEventType() : null;
}
}
@Data
public static class TextContent {
private String content;
private String menuId;
}
@Data
public static class ImageContent {
private String mediaId;
}
@Data
public static class VoiceContent {
private String mediaId;
}
@Data
public static class VideoContent {
private String mediaId;
}
@Data
public static class FileContent {
private String mediaId;
}
@Data
public static class LocationContent {
private Double latitude;
private Double longitude;
private String name;
private String address;
}
@Data
public static class LinkContent {
private String title;
private String desc;
private String url;
private String picUrl;
}
@Data
public static class BusinessCardContent {
private String userid;
}
@Data
public static class MiniprogramContent {
private String title;
private String appid;
private String pagepath;
private String thumbMediaId;
}
@Data
public static class MsgMenuContent {
private String headContent;
private List<MenuItem> list;
private String tailContent;
}
@Data
public static class MenuItem {
private String type;
private MenuClick click;
private MenuView view;
private MenuMiniprogram miniprogram;
}
@Data
public static class MenuClick {
private String id;
private String content;
}
@Data
public static class MenuView {
private String url;
private String content;
}
@Data
public static class MenuMiniprogram {
private String appid;
private String pagepath;
private String content;
}
@Data
public static class EventContent {
@JSONField(name = "event_type")
private String eventType;
private String openKfId;
private String externalUserid;
private String scene;
private String sceneParam;
private String welcomeCode;
private String failMsgid;
private Integer failType;
private String servicerUserid;
private Integer status;
private Integer stopType;
private Integer changeType;
private String oldServicerUserid;
private String newServicerUserid;
private String msgCode;
private String recallMsgid;
private Integer rejectSwitch;
private WechatChannels wechatChannels;
public static final String EVENT_ENTER_SESSION = "enter_session";
public static final String EVENT_MSG_SEND_FAIL = "msg_send_fail";
public static final String EVENT_SERVICER_STATUS_CHANGE = "servicer_status_change";
public static final String EVENT_SESSION_STATUS_CHANGE = "session_status_change";
public static final String EVENT_USER_RECALL_MSG = "user_recall_msg";
public static final String EVENT_SERVICER_RECALL_MSG = "servicer_recall_msg";
public static final String EVENT_REJECT_CUSTOMER_MSG_SWITCH_CHANGE = "reject_customer_msg_switch_change";
public static final int CHANGE_TYPE_FROM_POOL = 1;
public static final int CHANGE_TYPE_TRANSFER = 2;
public static final int CHANGE_TYPE_END = 3;
public static final int CHANGE_TYPE_REENTER = 4;
}
@Data
public static class WechatChannels {
private String nickname;
private String shopNickname;
private Integer scene;
}
public boolean isSuccess() {
return errcode == null || errcode == 0;
}
public boolean hasMessages() {
return msgList != null && !msgList.isEmpty();
}
}

View File

@ -0,0 +1,21 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class WxAccessToken {
private String accessToken;
private long expiresIn;
private long createTime;
public WxAccessToken(String accessToken, long expiresIn) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
this.createTime = System.currentTimeMillis();
}
public boolean isExpired() {
return System.currentTimeMillis() - createTime > (expiresIn - 300) * 1000;
}
}

View File

@ -0,0 +1,65 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.Map;
@Data
public class WxCallbackMessage {
private String toUserName;
private String fromUserName;
private String createTime;
private String msgType;
private String content;
private String msgId;
private String event;
private String openKfId;
private String externalUserId;
private String welcomeCode;
private String token;
private String origin;
private String serviceCorpId;
private String changeType;
private String servicerUserid;
private Map<String, String> rawData;
public static final String EVENT_KF_MSG_OR_EVENT = "kf_msg_or_event";
public static final String EVENT_KF_MSG_FROM_CUSTOMER = "kf_msg_from_customer";
public static final String EVENT_KF_ACCOUNT_ONLINE = "kf_account_online";
public static final String EVENT_KF_ACCOUNT_OFFLINE = "kf_account_offline";
public static final String EVENT_MSG_AUDIT_APPROVED = "msg_audit_approved";
public static WxCallbackMessage fromMap(Map<String, String> map) {
WxCallbackMessage msg = new WxCallbackMessage();
msg.setToUserName(map.get("ToUserName"));
msg.setFromUserName(map.get("FromUserName"));
msg.setCreateTime(map.get("CreateTime"));
msg.setMsgType(map.get("MsgType"));
msg.setContent(map.get("Content"));
msg.setMsgId(map.get("MsgId"));
msg.setEvent(map.get("Event"));
msg.setOpenKfId(map.get("OpenKfId"));
msg.setExternalUserId(map.get("ExternalUserId"));
msg.setWelcomeCode(map.get("WelcomeCode"));
msg.setToken(map.get("Token"));
msg.setOrigin(map.get("Origin"));
msg.setServiceCorpId(map.get("ServiceCorpId"));
msg.setChangeType(map.get("ChangeType"));
msg.setServicerUserid(map.get("ServicerUserid"));
msg.setRawData(map);
return msg;
}
public boolean isKfMsgEvent() {
return EVENT_KF_MSG_OR_EVENT.equals(event) || EVENT_KF_MSG_FROM_CUSTOMER.equals(event);
}
public boolean isAccountOnlineEvent() {
return EVENT_KF_ACCOUNT_ONLINE.equals(event);
}
public boolean isAccountOfflineEvent() {
return EVENT_KF_ACCOUNT_OFFLINE.equals(event);
}
}

View File

@ -0,0 +1,58 @@
package com.wecom.robot.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
@Data
public class WxSendMessageRequest {
private String touser;
@JSONField(name = "open_kfid")
private String openKfid;
private String msgtype;
private TextContent text;
private ImageContent image;
private LinkContent link;
@Data
public static class TextContent {
private String content;
}
@Data
public static class ImageContent {
private String mediaId;
}
@Data
public static class LinkContent {
private String title;
private String desc;
private String url;
private String thumbMediaId;
}
public static WxSendMessageRequest text(String touser, String openKfid, String content) {
WxSendMessageRequest request = new WxSendMessageRequest();
request.setTouser(touser);
request.setOpenKfid(openKfid);
request.setMsgtype("text");
TextContent textContent = new TextContent();
textContent.setContent(content);
request.setText(textContent);
return request;
}
public static WxSendMessageRequest image(String touser, String openKfid, String mediaId) {
WxSendMessageRequest request = new WxSendMessageRequest();
request.setTouser(touser);
request.setOpenKfid(openKfid);
request.setMsgtype("image");
ImageContent imageContent = new ImageContent();
imageContent.setMediaId(mediaId);
request.setImage(imageContent);
return request;
}
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.dto.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String role;
private String content;
public static final String ROLE_USER = "user";
public static final String ROLE_ASSISTANT = "assistant";
public static ChatMessage userMessage(String content) {
return ChatMessage.builder()
.role(ROLE_USER)
.content(content)
.build();
}
public static ChatMessage assistantMessage(String content) {
return ChatMessage.builder()
.role(ROLE_ASSISTANT)
.content(content)
.build();
}
}

View File

@ -0,0 +1,48 @@
package com.wecom.robot.dto.ai;
import com.wecom.robot.dto.InboundMessage;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatRequest {
private String sessionId;
private String currentMessage;
private String channelType;
@Builder.Default
private List<ChatMessage> history = new ArrayList<>();
@Builder.Default
private Map<String, Object> metadata = new HashMap<>();
public static ChatRequest fromInboundMessage(InboundMessage msg) {
return ChatRequest.builder()
.sessionId(msg.getSessionKey())
.currentMessage(msg.getContent())
.channelType(msg.getChannelType())
.build();
}
public static ChatRequest fromInboundMessage(InboundMessage msg, List<ChatMessage> history) {
return ChatRequest.builder()
.sessionId(msg.getSessionKey())
.currentMessage(msg.getContent())
.channelType(msg.getChannelType())
.history(history)
.build();
}
}

View File

@ -0,0 +1,44 @@
package com.wecom.robot.dto.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatResponse {
private String reply;
private Double confidence;
private Boolean shouldTransfer;
private String transferReason;
@Builder.Default
private Map<String, Object> metadata = new HashMap<>();
public static ChatResponse fallback(String reply) {
return ChatResponse.builder()
.reply(reply)
.confidence(0.0)
.shouldTransfer(true)
.build();
}
public static ChatResponse fallbackWithTransfer(String reply, String reason) {
return ChatResponse.builder()
.reply(reply)
.confidence(0.0)
.shouldTransfer(true)
.transferReason(reason)
.build();
}
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("kf_account")
public class KfAccount implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "kf_id", type = IdType.INPUT)
private String kfId;
private String name;
private String avatar;
private String status;
private String bindManualId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static final String STATUS_ONLINE = "online";
public static final String STATUS_OFFLINE = "offline";
}

View File

@ -0,0 +1,39 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("message")
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "msg_id", type = IdType.ASSIGN_ID)
private String msgId;
private String sessionId;
private String senderType;
private String senderId;
private String content;
private String msgType;
private LocalDateTime createdAt;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private String rawData;
public static final String SENDER_TYPE_CUSTOMER = "customer";
public static final String SENDER_TYPE_AI = "ai";
public static final String SENDER_TYPE_MANUAL = "manual";
}

View File

@ -0,0 +1,48 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("session")
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "session_id", type = IdType.ASSIGN_ID)
private String sessionId;
private String customerId;
private String kfId;
private String channelType;
private String status;
private Integer wxServiceState;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String manualCsId;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private String metadata;
public static final String STATUS_AI = "AI";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_MANUAL = "MANUAL";
public static final String STATUS_CLOSED = "CLOSED";
public static final String CHANNEL_WECHAT = "wechat";
public static final String CHANNEL_DOUYIN = "douyin";
public static final String CHANNEL_JD = "jd";
}

View File

@ -0,0 +1,29 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("transfer_log")
public class TransferLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String sessionId;
private String triggerReason;
private LocalDateTime triggerTime;
private LocalDateTime acceptedTime;
private String acceptedCsId;
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.KfAccount;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KfAccountMapper extends BaseMapper<KfAccount> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.Message;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.Session;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SessionMapper extends BaseMapper<Session> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.TransferLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TransferLogMapper extends BaseMapper<TransferLog> {
}

View File

@ -0,0 +1,13 @@
package com.wecom.robot.service;
import com.wecom.robot.dto.ai.ChatRequest;
import com.wecom.robot.dto.ai.ChatResponse;
import java.util.concurrent.CompletableFuture;
public interface AiServiceClient {
CompletableFuture<ChatResponse> generateReply(ChatRequest request);
boolean healthCheck();
}

View File

@ -0,0 +1,304 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.adapter.ChannelAdapter;
import com.wecom.robot.adapter.MessageSyncCapable;
import com.wecom.robot.dto.InboundMessage;
import com.wecom.robot.dto.ServiceStateResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 消息处理服务
* <p>
* 负责从微信拉取消息并转换为 InboundMessage 传递给 MessageRouterService
* [AC-MCA-08] 消息处理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageProcessService {
private static final String CHANNEL_TYPE = "wechat";
private final SessionManagerService sessionManagerService;
private final TransferService transferService;
private final WecomApiService wecomApiService;
private final WebSocketService webSocketService;
private final MessageRouterService messageRouterService;
private final Map<String, ChannelAdapter> channelAdapters;
@Async
public void processKfMessageEvent(WxCallbackMessage event) {
String openKfId = event.getOpenKfId();
String token = event.getToken();
log.info("[AC-MCA-08] 处理客户消息事件: openKfId={}, token={}", openKfId, token);
if (openKfId == null) {
log.warn("事件缺少openKfId");
return;
}
SyncMsgResponse syncResponse = wecomApiService.syncMessagesByToken(openKfId, token);
if (!syncResponse.isSuccess()) {
log.error("拉取消息失败: errcode={}, errmsg={}",
syncResponse.getErrcode(), syncResponse.getErrmsg());
return;
}
if (!syncResponse.hasMessages()) {
log.info("没有新消息");
return;
}
log.info("拉取到{}条消息", syncResponse.getMsgList().size());
for (SyncMsgResponse.MsgItem msgItem : syncResponse.getMsgList()) {
try {
processSyncedItem(msgItem);
} catch (Exception e) {
log.error("处理消息失败: msgId={}", msgItem.getMsgId(), e);
}
}
while (Boolean.TRUE.equals(syncResponse.getHasMore())) {
log.info("还有更多消息,继续拉取...");
syncResponse = wecomApiService.syncMessages(openKfId, null);
if (!syncResponse.isSuccess() || !syncResponse.hasMessages()) {
break;
}
for (SyncMsgResponse.MsgItem msgItem : syncResponse.getMsgList()) {
try {
processSyncedItem(msgItem);
} catch (Exception e) {
log.error("处理消息失败: msgId={}", msgItem.getMsgId(), e);
}
}
}
}
private void processSyncedItem(SyncMsgResponse.MsgItem msgItem) {
String customerId = msgItem.getExternalUserId();
String kfId = msgItem.getOpenKfId();
log.info("[AC-MCA-08] 处理消息项: msgId={}, origin={}, msgType={}, customerId={}",
msgItem.getMsgId(), msgItem.getOrigin(), msgItem.getMsgType(), customerId);
if (msgItem.isEvent()) {
processEventMessage(msgItem);
return;
}
if (!msgItem.isFromCustomer()) {
log.debug("非客户消息,跳过处理: origin={}", msgItem.getOrigin());
return;
}
if (customerId == null || kfId == null) {
log.warn("消息缺少必要字段: customerId={}, kfId={}", customerId, kfId);
return;
}
ServiceStateResponse wxState = wecomApiService.getServiceState(kfId, customerId);
if (!wxState.isSuccess()) {
log.warn("获取微信会话状态失败: errcode={}, errmsg={}",
wxState.getErrcode(), wxState.getErrmsg());
}
log.info("微信会话状态: {} ({})", wxState.getStateDesc(), wxState.getServiceState());
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
sessionManagerService.updateWxServiceState(session.getSessionId(), wxState.getServiceState());
InboundMessage inboundMessage = buildInboundMessage(msgItem, customerId, kfId);
messageRouterService.processInboundMessage(inboundMessage);
}
private void processEventMessage(SyncMsgResponse.MsgItem msgItem) {
SyncMsgResponse.EventContent event = msgItem.getEvent();
if (event == null) {
return;
}
String eventType = event.getEventType();
String customerId = event.getExternalUserid();
String kfId = event.getOpenKfId();
log.info("处理事件消息: eventType={}, customerId={}, kfId={}", eventType, customerId, kfId);
switch (eventType) {
case SyncMsgResponse.EventContent.EVENT_ENTER_SESSION:
handleEnterSessionEvent(event, customerId, kfId);
break;
case SyncMsgResponse.EventContent.EVENT_SESSION_STATUS_CHANGE:
handleSessionStatusChangeEvent(event, customerId, kfId);
break;
case SyncMsgResponse.EventContent.EVENT_MSG_SEND_FAIL:
log.warn("消息发送失败: failMsgid={}, failType={}",
event.getFailMsgid(), event.getFailType());
break;
case SyncMsgResponse.EventContent.EVENT_USER_RECALL_MSG:
log.info("用户撤回消息: recallMsgid={}", event.getRecallMsgid());
break;
default:
log.info("其他事件类型: {}", eventType);
}
}
private void handleEnterSessionEvent(SyncMsgResponse.EventContent event,
String customerId, String kfId) {
log.info("用户进入会话: customerId={}, scene={}, sceneParam={}",
customerId, event.getScene(), event.getSceneParam());
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
String welcomeCode = event.getWelcomeCode();
if (welcomeCode != null && !welcomeCode.isEmpty()) {
String welcomeMsg = "您好,欢迎咨询!请问有什么可以帮您?";
wecomApiService.sendWelcomeMsg(welcomeCode, welcomeMsg);
sessionManagerService.saveMessage(
"welcome_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_AI,
"AI",
welcomeMsg,
"text",
null
);
}
}
private void handleSessionStatusChangeEvent(SyncMsgResponse.EventContent event,
String customerId, String kfId) {
Integer changeType = event.getChangeType();
String newServicerUserid = event.getNewServicerUserid();
String oldServicerUserid = event.getOldServicerUserid();
log.info("会话状态变更: changeType={}, oldServicer={}, newServicer={}",
changeType, oldServicerUserid, newServicerUserid);
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
switch (changeType) {
case SyncMsgResponse.EventContent.CHANGE_TYPE_FROM_POOL:
log.info("从接待池接入会话: servicer={}", newServicerUserid);
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_MANUAL);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_TRANSFER:
log.info("转接会话: oldServicer={}, newServicer={}", oldServicerUserid, newServicerUserid);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_END:
log.info("结束会话");
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_CLOSED);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_REENTER:
log.info("重新接入已结束会话: servicer={}", newServicerUserid);
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_MANUAL);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
}
}
private InboundMessage buildInboundMessage(SyncMsgResponse.MsgItem msgItem,
String customerId, String kfId) {
String content = extractContent(msgItem);
String sessionKey = kfId + "_" + customerId;
return InboundMessage.builder()
.channelType(InboundMessage.CHANNEL_WECHAT)
.channelMessageId(msgItem.getMsgId())
.sessionKey(sessionKey)
.customerId(customerId)
.kfId(kfId)
.sender(customerId)
.content(content)
.msgType(msgItem.getMsgType())
.rawPayload(msgItem.getOriginData())
.timestamp(System.currentTimeMillis())
.build();
}
private String extractContent(SyncMsgResponse.MsgItem msgItem) {
String msgType = msgItem.getMsgType();
switch (msgType) {
case "text":
return msgItem.getTextContent();
case "image":
return "[图片]";
case "voice":
return "[语音]";
case "video":
return "[视频]";
case "file":
return "[文件]";
case "location":
SyncMsgResponse.LocationContent loc = msgItem.getLocation();
if (loc != null) {
return "[位置] " + loc.getName() + " " + loc.getAddress();
}
return "[位置]";
case "link":
SyncMsgResponse.LinkContent link = msgItem.getLink();
if (link != null) {
return "[链接] " + link.getTitle();
}
return "[链接]";
case "business_card":
return "[名片]";
case "miniprogram":
return "[小程序]";
case "msgmenu":
return "[菜单消息]";
default:
return "[" + msgType + "]";
}
}
@Async
public void processMessage(WxCallbackMessage message) {
log.info("[AC-MCA-08] 直接处理消息(测试用): msgType={}", message.getMsgType());
String customerId = message.getExternalUserId();
String kfId = message.getOpenKfId();
if (customerId == null || kfId == null) {
log.warn("消息缺少必要字段: customerId={}, kfId={}", customerId, kfId);
return;
}
String sessionKey = kfId + "_" + customerId;
InboundMessage inboundMessage = InboundMessage.builder()
.channelType(InboundMessage.CHANNEL_WECHAT)
.channelMessageId(message.getMsgId() != null ? message.getMsgId() : "test_" + System.currentTimeMillis())
.sessionKey(sessionKey)
.customerId(customerId)
.kfId(kfId)
.sender(customerId)
.content(message.getContent())
.msgType(message.getMsgType() != null ? message.getMsgType() : "text")
.rawPayload(JSON.toJSONString(message.getRawData()))
.timestamp(System.currentTimeMillis())
.build();
messageRouterService.processInboundMessage(inboundMessage);
}
}

View File

@ -0,0 +1,81 @@
package com.wecom.robot.service;
import com.wecom.robot.dto.InboundMessage;
import com.wecom.robot.entity.Session;
/**
* 消息路由服务接口 - 渠道无关的消息路由核心服务
*
* <p>职责
* <ul>
* <li>处理入站消息的统一路由</li>
* <li>根据会话状态分发到 AI 服务或人工客服</li>
* <li>协调消息处理流程中的各组件</li>
* </ul>
*
* <p>关联 AC: [AC-MCA-08] 统一消息路由
*
* @see InboundMessage
* @see Session
*/
public interface MessageRouterService {
/**
* 处理入站消息 - 主入口方法
*
* <p>执行流程
* <ol>
* <li>幂等性检查基于 channelMessageId</li>
* <li>获取或创建会话</li>
* <li>根据会话状态路由消息</li>
* </ol>
*
* @param message 入站消息包含渠道类型消息内容等信息
*/
void processInboundMessage(InboundMessage message);
/**
* 根据会话状态路由消息
*
* <p>路由规则
* <ul>
* <li>AI 状态 dispatchToAiService</li>
* <li>PENDING 状态 dispatchToPendingPool</li>
* <li>MANUAL 状态 dispatchToManualCs</li>
* </ul>
*
* @param session 当前会话
* @param message 入站消息
*/
void routeBySessionState(Session session, InboundMessage message);
/**
* 分发到 AI 服务处理
*
* <p>调用 AI 服务生成回复并根据返回结果判断是否需要转人工
*
* @param session 当前会话
* @param message 入站消息
*/
void dispatchToAiService(Session session, InboundMessage message);
/**
* 分发到人工客服处理
*
* <p>将消息推送给在线的人工客服通过 WebSocket
*
* @param session 当前会话
* @param message 入站消息
*/
void dispatchToManualCs(Session session, InboundMessage message);
/**
* 分发到待接入池
*
* <p>将消息暂存等待人工客服接入
*
* @param session 当前会话
* @param message 入站消息
*/
void dispatchToPendingPool(Session session, InboundMessage message);
}

View File

@ -0,0 +1,243 @@
package com.wecom.robot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.entity.TransferLog;
import com.wecom.robot.mapper.MessageMapper;
import com.wecom.robot.mapper.SessionMapper;
import com.wecom.robot.mapper.TransferLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 会话管理服务
*
* <p>关联 AC: [AC-MCA-11] 会话管理, [AC-MCA-12] 渠道类型支持
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SessionManagerService {
private static final String SESSION_STATUS_KEY_PREFIX = "session:status:";
private static final String SESSION_MESSAGE_COUNT_KEY_PREFIX = "session:msg_count:";
private final SessionMapper sessionMapper;
private final MessageMapper messageMapper;
private final TransferLogMapper transferLogMapper;
private final StringRedisTemplate redisTemplate;
public Session getOrCreateSession(String customerId, String kfId) {
return getOrCreateSession(customerId, kfId, Session.CHANNEL_WECHAT);
}
public Session getOrCreateSession(String customerId, String kfId, String channelType) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.eq(Session::getCustomerId, customerId)
.eq(Session::getKfId, kfId)
.ne(Session::getStatus, Session.STATUS_CLOSED)
.orderByDesc(Session::getCreatedAt)
.last("LIMIT 1");
Session session = sessionMapper.selectOne(query);
if (session == null) {
session = new Session();
session.setSessionId(generateSessionId(customerId, kfId));
session.setCustomerId(customerId);
session.setKfId(kfId);
session.setChannelType(channelType != null ? channelType : Session.CHANNEL_WECHAT);
session.setStatus(Session.STATUS_AI);
session.setWxServiceState(0);
session.setCreatedAt(LocalDateTime.now());
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.insert(session);
cacheSessionStatus(session.getSessionId(), Session.STATUS_AI);
log.info("[AC-MCA-11] 创建新会话: sessionId={}, channelType={}",
session.getSessionId(), session.getChannelType());
}
return session;
}
public Session getSession(String sessionId) {
return sessionMapper.selectById(sessionId);
}
public String getSessionStatus(String sessionId) {
String cachedStatus = redisTemplate.opsForValue().get(SESSION_STATUS_KEY_PREFIX + sessionId);
if (cachedStatus != null) {
return cachedStatus;
}
Session session = sessionMapper.selectById(sessionId);
if (session != null) {
cacheSessionStatus(sessionId, session.getStatus());
return session.getStatus();
}
return null;
}
public void updateSessionStatus(String sessionId, String status) {
Session session = new Session();
session.setSessionId(sessionId);
session.setStatus(status);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
cacheSessionStatus(sessionId, status);
}
public void updateWxServiceState(String sessionId, Integer wxServiceState) {
Session session = new Session();
session.setSessionId(sessionId);
session.setWxServiceState(wxServiceState);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
log.info("更新微信会话状态: sessionId={}, wxServiceState={}", sessionId, wxServiceState);
}
public void updateServicer(String sessionId, String servicerUserid) {
Session session = new Session();
session.setSessionId(sessionId);
session.setManualCsId(servicerUserid);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
log.info("更新接待人员: sessionId={}, servicerUserid={}", sessionId, servicerUserid);
}
public void assignManualCs(String sessionId, String csId) {
Session session = new Session();
session.setSessionId(sessionId);
session.setStatus(Session.STATUS_MANUAL);
session.setManualCsId(csId);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
cacheSessionStatus(sessionId, Session.STATUS_MANUAL);
}
@Transactional
public void transferToManual(String sessionId, String reason) {
updateSessionStatus(sessionId, Session.STATUS_PENDING);
TransferLog transferLog = new TransferLog();
transferLog.setSessionId(sessionId);
transferLog.setTriggerReason(reason);
transferLog.setTriggerTime(LocalDateTime.now());
transferLogMapper.insert(transferLog);
log.info("会话转人工: sessionId={}, reason={}", sessionId, reason);
}
public void acceptTransfer(String sessionId, String csId) {
assignManualCs(sessionId, csId);
LambdaQueryWrapper<TransferLog> query = new LambdaQueryWrapper<>();
query.eq(TransferLog::getSessionId, sessionId)
.isNull(TransferLog::getAcceptedTime)
.orderByDesc(TransferLog::getTriggerTime)
.last("LIMIT 1");
TransferLog transferLog = transferLogMapper.selectOne(query);
if (transferLog != null) {
transferLog.setAcceptedTime(LocalDateTime.now());
transferLog.setAcceptedCsId(csId);
transferLogMapper.updateById(transferLog);
}
log.info("客服接入会话: sessionId={}, csId={}", sessionId, csId);
}
public void closeSession(String sessionId) {
updateSessionStatus(sessionId, Session.STATUS_CLOSED);
redisTemplate.delete(SESSION_STATUS_KEY_PREFIX + sessionId);
redisTemplate.delete(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
log.info("会话已关闭: sessionId={}", sessionId);
}
public void saveMessage(String msgId, String sessionId, String senderType, String senderId,
String content, String msgType, String rawData) {
Message message = new Message();
message.setMsgId(msgId);
message.setSessionId(sessionId);
message.setSenderType(senderType);
message.setSenderId(senderId);
message.setContent(content);
message.setMsgType(msgType);
message.setCreatedAt(LocalDateTime.now());
message.setRawData(rawData);
messageMapper.insert(message);
incrementMessageCount(sessionId);
}
public List<Message> getSessionMessages(String sessionId) {
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId)
.orderByAsc(Message::getCreatedAt);
return messageMapper.selectList(query);
}
public int getMessageCount(String sessionId) {
String count = redisTemplate.opsForValue().get(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
if (count != null) {
return Integer.parseInt(count);
}
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId);
long dbCount = messageMapper.selectCount(query);
redisTemplate.opsForValue().set(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId, String.valueOf(dbCount));
return (int) dbCount;
}
public List<Session> getSessionsByKfId(String kfId, String status, int limit) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.eq(Session::getKfId, kfId);
if (status != null && !status.isEmpty() && !"all".equals(status)) {
query.eq(Session::getStatus, status);
}
query.orderByDesc(Session::getUpdatedAt);
query.last("LIMIT " + limit);
return sessionMapper.selectList(query);
}
public List<Session> getSessionsByChannelType(String channelType, String status, int limit) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.eq(Session::getChannelType, channelType);
if (status != null && !status.isEmpty() && !"all".equals(status)) {
query.eq(Session::getStatus, status);
}
query.orderByDesc(Session::getUpdatedAt);
query.last("LIMIT " + limit);
return sessionMapper.selectList(query);
}
public List<Session> getAllSessions(int limit) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.orderByDesc(Session::getUpdatedAt);
query.last("LIMIT " + limit);
return sessionMapper.selectList(query);
}
private void cacheSessionStatus(String sessionId, String status) {
redisTemplate.opsForValue().set(SESSION_STATUS_KEY_PREFIX + sessionId, status, 24, TimeUnit.HOURS);
}
private void incrementMessageCount(String sessionId) {
redisTemplate.opsForValue().increment(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
}
private String generateSessionId(String customerId, String kfId) {
return kfId + "_" + customerId + "_" + System.currentTimeMillis();
}
}

View File

@ -0,0 +1,87 @@
package com.wecom.robot.service;
import com.wecom.robot.config.TransferConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class TransferService {
private final TransferConfig transferConfig;
public boolean shouldTransferToManual(String message, double confidence, int messageCount, LocalDateTime sessionCreatedAt) {
if (containsKeywords(message)) {
log.info("触发转人工: 关键词匹配");
return true;
}
if (confidence < transferConfig.getConfidenceThreshold()) {
log.info("触发转人工: AI置信度过低 confidence={}", confidence);
return true;
}
if (messageCount >= transferConfig.getMaxMessageRounds()) {
log.info("触发转人工: 消息轮次过多 count={}", messageCount);
return true;
}
if (sessionCreatedAt != null) {
long duration = System.currentTimeMillis() -
sessionCreatedAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
if (duration >= transferConfig.getMaxSessionDuration()) {
log.info("触发转人工: 会话时长超限 duration={}ms", duration);
return true;
}
}
return false;
}
public String getTransferReason(String message, double confidence, int messageCount) {
List<String> keywords = transferConfig.getKeywords();
if (keywords != null) {
for (String keyword : keywords) {
if (message != null && message.contains(keyword)) {
return "关键词触发: " + keyword;
}
}
}
if (confidence < transferConfig.getConfidenceThreshold()) {
return "AI置信度过低: " + confidence;
}
if (messageCount >= transferConfig.getMaxMessageRounds()) {
return "消息轮次过多: " + messageCount;
}
return "其他原因";
}
private boolean containsKeywords(String message) {
if (message == null) {
return false;
}
List<String> keywords = transferConfig.getKeywords();
if (keywords == null) {
return false;
}
for (String keyword : keywords) {
if (message.contains(keyword)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,71 @@
package com.wecom.robot.service;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.websocket.CsWebSocketHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketService {
private final CsWebSocketHandler webSocketHandler;
public void notifyNewPendingSession(String sessionId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "new_pending_session");
message.put("sessionId", sessionId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.broadcastToAll(message);
log.info("通知新待接入会话: sessionId={}", sessionId);
}
public void notifyNewMessage(String sessionId, WxCallbackMessage wxMessage) {
Map<String, Object> message = new HashMap<>();
message.put("type", "new_message");
message.put("sessionId", sessionId);
message.put("content", wxMessage.getContent());
message.put("msgType", wxMessage.getMsgType());
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
}
public void pushMessageToCs(String sessionId, WxCallbackMessage wxMessage) {
Map<String, Object> message = new HashMap<>();
message.put("type", "customer_message");
message.put("sessionId", sessionId);
message.put("content", wxMessage.getContent());
message.put("msgType", wxMessage.getMsgType());
message.put("customerId", wxMessage.getExternalUserId());
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
log.info("推送客户消息给客服: sessionId={}", sessionId);
}
public void notifySessionAccepted(String sessionId, String csId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "session_accepted");
message.put("sessionId", sessionId);
message.put("csId", csId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToCs(csId, message);
}
public void notifySessionClosed(String sessionId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "session_closed");
message.put("sessionId", sessionId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
}
}

View File

@ -0,0 +1,256 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.ServiceStateResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxSendMessageRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class WecomApiService {
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 GET_KF_LIST_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/account/list?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();
public 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<String> response = restTemplate.getForEntity(url, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("获取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();
}
}
public boolean sendMessage(WxSendMessageRequest request) {
String accessToken = getAccessToken();
String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("发送消息失败: {}", json);
return false;
}
log.info("消息发送成功: msgId={}", json.getString("msgid"));
return true;
}
public boolean sendTextMessage(String touser, String openKfid, String content) {
WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content);
return sendMessage(request);
}
public JSONObject getKfAccountList(int offset, int limit) {
String accessToken = getAccessToken();
String url = GET_KF_LIST_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("offset", offset);
body.put("limit", limit);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
return JSON.parseObject(response.getBody());
}
public SyncMsgResponse syncMessages(String openKfid, String token) {
String accessToken = getAccessToken();
String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken);
String cursor = getCursor(openKfid);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
if (cursor != null && !cursor.isEmpty()) {
body.put("cursor", cursor);
}
if (token != null && !token.isEmpty()) {
body.put("token", token);
}
body.put("limit", 1000);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("sync_msg响应: {}", response.getBody());
SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class);
if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) {
saveCursor(openKfid, syncResponse.getNextCursor());
}
return syncResponse;
}
public SyncMsgResponse syncMessagesByToken(String openKfid, String token) {
return syncMessages(openKfid, token);
}
public ServiceStateResponse getServiceState(String openKfid, String externalUserid) {
String accessToken = getAccessToken();
String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
body.put("external_userid", externalUserid);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("获取会话状态响应: {}", response.getBody());
return JSON.parseObject(response.getBody(), ServiceStateResponse.class);
}
public JSONObject transServiceState(String openKfid, String externalUserid,
int serviceState, String servicerUserid) {
String accessToken = getAccessToken();
String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
body.put("external_userid", externalUserid);
body.put("service_state", serviceState);
if (servicerUserid != null && !servicerUserid.isEmpty()) {
body.put("servicer_userid", servicerUserid);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("变更会话状态响应: {}", response.getBody());
return JSON.parseObject(response.getBody());
}
public boolean transferToPool(String openKfid, String externalUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_POOL, null);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
public boolean transferToManual(String openKfid, String externalUserid, String servicerUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_MANUAL, servicerUserid);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
public boolean endSession(String openKfid, String externalUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_CLOSED, null);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
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<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("发送欢迎语失败: {}", json);
return false;
}
log.info("发送欢迎语成功");
return true;
}
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);
}
}

View File

@ -0,0 +1,75 @@
package com.wecom.robot.service.impl;
import com.wecom.robot.config.AiServiceConfig;
import com.wecom.robot.dto.ai.ChatRequest;
import com.wecom.robot.dto.ai.ChatResponse;
import com.wecom.robot.service.AiServiceClient;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiServiceClientImpl implements AiServiceClient {
private static final String CHAT_ENDPOINT = "/ai/chat";
private static final String HEALTH_ENDPOINT = "/ai/health";
private final AiServiceConfig aiServiceConfig;
private final RestTemplate restTemplate;
@Override
@CircuitBreaker(name = "aiService", fallbackMethod = "generateReplyFallback")
@TimeLimiter(name = "aiService")
public CompletableFuture<ChatResponse> generateReply(ChatRequest request) {
log.info("[AC-MCA-04] 调用 AI 服务: sessionId={}", request.getSessionId());
String url = aiServiceConfig.getUrl() + CHAT_ENDPOINT;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<ChatRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<ChatResponse> response = restTemplate.postForEntity(
url, entity, ChatResponse.class);
log.info("[AC-MCA-05] AI 服务响应: sessionId={}, shouldTransfer={}",
request.getSessionId(),
response.getBody() != null ? response.getBody().getShouldTransfer() : null);
return CompletableFuture.completedFuture(response.getBody());
}
public CompletableFuture<ChatResponse> generateReplyFallback(ChatRequest request, Throwable cause) {
log.warn("[AC-MCA-06][AC-MCA-07] AI 服务降级: sessionId={}, cause={}",
request.getSessionId(), cause.getMessage());
ChatResponse fallbackResponse = ChatResponse.fallbackWithTransfer(
"抱歉,我暂时无法回答您的问题,正在为您转接人工客服...",
cause.getMessage()
);
return CompletableFuture.completedFuture(fallbackResponse);
}
@Override
public boolean healthCheck() {
try {
String url = aiServiceConfig.getUrl() + HEALTH_ENDPOINT;
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
return response.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("[AC-MCA-04] AI 服务健康检查失败: {}", e.getMessage());
return false;
}
}
}

View File

@ -0,0 +1,263 @@
package com.wecom.robot.service.impl;
import com.wecom.robot.adapter.ChannelAdapter;
import com.wecom.robot.adapter.TransferCapable;
import com.wecom.robot.dto.InboundMessage;
import com.wecom.robot.dto.OutboundMessage;
import com.wecom.robot.dto.ai.ChatRequest;
import com.wecom.robot.dto.ai.ChatResponse;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.service.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageRouterServiceImpl implements MessageRouterService {
private static final String IDEMPOTENT_KEY_PREFIX = "idempotent:";
private static final long IDEMPOTENT_TTL_HOURS = 1;
private final SessionManagerService sessionManagerService;
private final AiServiceClient aiServiceClient;
private final TransferService transferService;
private final WebSocketService webSocketService;
private final Map<String, ChannelAdapter> channelAdapters;
private final StringRedisTemplate redisTemplate;
@Override
@Async
public void processInboundMessage(InboundMessage message) {
log.info("[AC-MCA-08] 处理入站消息: channelType={}, channelMessageId={}, sessionKey={}",
message.getChannelType(), message.getChannelMessageId(), message.getSessionKey());
if (!checkIdempotent(message.getChannelMessageId())) {
log.info("重复消息,跳过处理: channelMessageId={}", message.getChannelMessageId());
return;
}
Session session = getOrCreateSession(message);
saveInboundMessage(session, message);
routeBySessionState(session, message);
}
@Override
public void routeBySessionState(Session session, InboundMessage message) {
log.info("[AC-MCA-09] 根据会话状态路由: sessionId={}, status={}",
session.getSessionId(), session.getStatus());
String status = session.getStatus();
if (status == null) {
status = Session.STATUS_AI;
}
switch (status) {
case Session.STATUS_AI:
dispatchToAiService(session, message);
break;
case Session.STATUS_PENDING:
dispatchToPendingPool(session, message);
break;
case Session.STATUS_MANUAL:
dispatchToManualCs(session, message);
break;
case Session.STATUS_CLOSED:
Session newSession = sessionManagerService.getOrCreateSession(
message.getCustomerId(), message.getKfId());
dispatchToAiService(newSession, message);
break;
default:
log.warn("未知的会话状态: {}, 默认路由到AI服务", status);
dispatchToAiService(session, message);
}
}
@Override
public void dispatchToAiService(Session session, InboundMessage message) {
log.info("[AC-MCA-08] 分发到AI服务: sessionId={}, content={}",
session.getSessionId(), truncateContent(message.getContent()));
List<Message> history = sessionManagerService.getSessionMessages(session.getSessionId());
ChatRequest chatRequest = ChatRequest.fromInboundMessage(message);
ChatResponse chatResponse;
try {
chatResponse = aiServiceClient.generateReply(chatRequest).get();
} catch (Exception e) {
log.error("[AC-MCA-06] AI服务调用失败: {}", e.getMessage());
chatResponse = ChatResponse.fallbackWithTransfer(
"抱歉,我暂时无法回答您的问题,正在为您转接人工客服...",
e.getMessage()
);
}
String reply = chatResponse.getReply();
double confidence = chatResponse.getConfidence() != null ? chatResponse.getConfidence() : 0.0;
int messageCount = sessionManagerService.getMessageCount(session.getSessionId());
boolean shouldTransfer = chatResponse.getShouldTransfer() != null && chatResponse.getShouldTransfer();
if (!shouldTransfer) {
shouldTransfer = transferService.shouldTransferToManual(
message.getContent(),
confidence,
messageCount,
session.getCreatedAt()
);
}
if (shouldTransfer) {
handleTransferToManual(session, message, reply, chatResponse.getTransferReason());
} else {
sendReplyToUser(session, message, reply);
}
}
@Override
public void dispatchToManualCs(Session session, InboundMessage message) {
log.info("[AC-MCA-10] 分发到人工客服: sessionId={}, manualCsId={}",
session.getSessionId(), session.getManualCsId());
Map<String, Object> wsMessage = new HashMap<>();
wsMessage.put("type", "customer_message");
wsMessage.put("sessionId", session.getSessionId());
wsMessage.put("content", message.getContent());
wsMessage.put("msgType", message.getMsgType());
wsMessage.put("customerId", message.getCustomerId());
wsMessage.put("channelType", message.getChannelType());
wsMessage.put("timestamp", System.currentTimeMillis());
webSocketService.notifyNewMessage(session.getSessionId(),
createWxCallbackMessage(message));
log.info("消息已推送给人工客服: sessionId={}", session.getSessionId());
}
@Override
public void dispatchToPendingPool(Session session, InboundMessage message) {
log.info("[AC-MCA-10] 分发到待接入池: sessionId={}", session.getSessionId());
webSocketService.notifyNewPendingSession(session.getSessionId());
log.info("已通知待接入池有新消息: sessionId={}", session.getSessionId());
}
private boolean checkIdempotent(String channelMessageId) {
if (channelMessageId == null || channelMessageId.isEmpty()) {
log.warn("channelMessageId 为空,跳过幂等检查");
return true;
}
String key = IDEMPOTENT_KEY_PREFIX + channelMessageId;
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent(key, "1", IDEMPOTENT_TTL_HOURS, TimeUnit.HOURS);
return Boolean.TRUE.equals(absent);
}
private Session getOrCreateSession(InboundMessage message) {
return sessionManagerService.getOrCreateSession(
message.getCustomerId(),
message.getKfId(),
message.getChannelType()
);
}
private void saveInboundMessage(Session session, InboundMessage message) {
sessionManagerService.saveMessage(
message.getChannelMessageId(),
session.getSessionId(),
Message.SENDER_TYPE_CUSTOMER,
message.getCustomerId(),
message.getContent(),
message.getMsgType(),
message.getRawPayload()
);
}
private void handleTransferToManual(Session session, InboundMessage message, String reply, String transferReason) {
String reason = transferReason != null ? transferReason : transferService.getTransferReason(
message.getContent(),
0.0,
sessionManagerService.getMessageCount(session.getSessionId())
);
sessionManagerService.transferToManual(session.getSessionId(), reason);
String transferReply = reply + "\n\n正在为您转接人工客服请稍候...";
ChannelAdapter adapter = channelAdapters.get(message.getChannelType());
if (adapter != null) {
OutboundMessage outbound = OutboundMessage.builder()
.channelType(message.getChannelType())
.receiver(message.getCustomerId())
.kfId(message.getKfId())
.content(transferReply)
.msgType("text")
.build();
adapter.sendMessage(outbound);
if (adapter instanceof TransferCapable) {
boolean transferred = ((TransferCapable) adapter)
.transferToPool(message.getKfId(), message.getCustomerId());
if (transferred) {
log.info("已将会话转入待接入池: sessionId={}", session.getSessionId());
}
}
}
webSocketService.notifyNewPendingSession(session.getSessionId());
}
private void sendReplyToUser(Session session, InboundMessage message, String reply) {
ChannelAdapter adapter = channelAdapters.get(message.getChannelType());
if (adapter != null) {
OutboundMessage outbound = OutboundMessage.builder()
.channelType(message.getChannelType())
.receiver(message.getCustomerId())
.kfId(message.getKfId())
.content(reply)
.msgType("text")
.build();
adapter.sendMessage(outbound);
}
sessionManagerService.saveMessage(
"ai_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_AI,
"AI",
reply,
"text",
null
);
}
private com.wecom.robot.dto.WxCallbackMessage createWxCallbackMessage(InboundMessage message) {
com.wecom.robot.dto.WxCallbackMessage wxMessage = new com.wecom.robot.dto.WxCallbackMessage();
wxMessage.setExternalUserId(message.getCustomerId());
wxMessage.setOpenKfId(message.getKfId());
wxMessage.setContent(message.getContent());
wxMessage.setMsgType(message.getMsgType());
return wxMessage;
}
private String truncateContent(String content) {
if (content == null) {
return null;
}
return content.length() > 50 ? content.substring(0, 50) + "..." : content;
}
}

View File

@ -0,0 +1,50 @@
package com.wecom.robot.util;
@SuppressWarnings("serial")
public class AesException extends Exception {
public final static int OK = 0;
public final static int ValidateSignatureError = -40001;
public final static int ParseXmlError = -40002;
public final static int ComputeSignatureError = -40003;
public final static int IllegalAesKey = -40004;
public final static int ValidateCorpidError = -40005;
public final static int EncryptAESError = -40006;
public final static int DecryptAESError = -40007;
public final static int IllegalBuffer = -40008;
private int code;
private static String getMessage(int code) {
switch (code) {
case ValidateSignatureError:
return "签名验证错误";
case ParseXmlError:
return "xml解析失败";
case ComputeSignatureError:
return "sha加密生成签名失败";
case IllegalAesKey:
return "SymmetricKey非法";
case ValidateCorpidError:
return "corpid校验失败";
case EncryptAESError:
return "aes加密失败";
case DecryptAESError:
return "aes解密失败";
case IllegalBuffer:
return "解密后得到的buffer非法";
default:
return null;
}
}
public int getCode() {
return code;
}
AesException(int code) {
super(getMessage(code));
this.code = code;
}
}

View File

@ -0,0 +1,26 @@
package com.wecom.robot.util;
import java.util.ArrayList;
class ByteGroup {
ArrayList<Byte> byteContainer = new ArrayList<Byte>();
public byte[] toBytes() {
byte[] bytes = new byte[byteContainer.size()];
for (int i = 0; i < byteContainer.size(); i++) {
bytes[i] = byteContainer.get(i);
}
return bytes;
}
public ByteGroup addBytes(byte[] bytes) {
for (byte b : bytes) {
byteContainer.add(b);
}
return this;
}
public int size() {
return byteContainer.size();
}
}

View File

@ -0,0 +1,56 @@
package com.wecom.robot.util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@RequiredArgsConstructor
public class IdempotentHelper {
private static final String KEY_PREFIX = "idempotent:";
private static final long DEFAULT_TTL_HOURS = 1;
private final StringRedisTemplate redisTemplate;
public boolean processMessageIdempotent(String channelMessageId, Runnable processor) {
String key = KEY_PREFIX + channelMessageId;
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent(key, "1", DEFAULT_TTL_HOURS, TimeUnit.HOURS);
if (Boolean.TRUE.equals(absent)) {
processor.run();
return true;
}
log.info("[AC-MCA-11-IDEMPOTENT] 重复消息,跳过处理: channelMessageId={}", channelMessageId);
return false;
}
public boolean checkAndSet(String channelMessageId) {
String key = KEY_PREFIX + channelMessageId;
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent(key, "1", DEFAULT_TTL_HOURS, TimeUnit.HOURS);
if (Boolean.TRUE.equals(absent)) {
return true;
}
log.info("[AC-MCA-11-IDEMPOTENT] 重复消息检测: channelMessageId={}", channelMessageId);
return false;
}
public boolean exists(String channelMessageId) {
String key = KEY_PREFIX + channelMessageId;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
public void remove(String channelMessageId) {
String key = KEY_PREFIX + channelMessageId;
redisTemplate.delete(key);
}
}

View File

@ -0,0 +1,67 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.nio.charset.Charset;
import java.util.Arrays;
/**
* 提供基于PKCS7算法的加解密接口.
*/
class PKCS7Encoder {
static Charset CHARSET = Charset.forName("utf-8");
static int BLOCK_SIZE = 32;
/**
* 获得对明文进行补位填充的字节.
*
* @param count 需要进行填充补位操作的明文字节个数
* @return 补齐用的字节数组
*/
static byte[] encode(int count) {
// 计算需要填充的位数
int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE);
if (amountToPad == 0) {
amountToPad = BLOCK_SIZE;
}
// 获得补位所用的字符
char padChr = chr(amountToPad);
String tmp = new String();
for (int index = 0; index < amountToPad; index++) {
tmp += padChr;
}
return tmp.getBytes(CHARSET);
}
/**
* 删除解密后明文的补位字符
*
* @param decrypted 解密后的明文
* @return 删除补位字符后的明文
*/
static byte[] decode(byte[] decrypted) {
int pad = (int) decrypted[decrypted.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
}
/**
* 将数字转化成ASCII码对应的字符用于对明文进行补码
*
* @param a 需要转化的数字
* @return 转化得到的字符
*/
static char chr(int a) {
byte target = (byte) (a & 0xFF);
return (char) target;
}
}

View File

@ -0,0 +1,61 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.security.MessageDigest;
import java.util.Arrays;
/**
* SHA1 class
*
* 计算消息签名接口.
*/
class SHA1 {
/**
* 用SHA1算法生成安全签名
* @param token 票据
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param encrypt 密文
* @return 安全签名
* @throws AesException
*/
public static String getSHA1(String token, String timestamp, String nonce, String encrypt) throws AesException
{
try {
String[] array = new String[] { token, timestamp, nonce, encrypt };
StringBuffer sb = new StringBuffer();
// 字符串排序
Arrays.sort(array);
for (int i = 0; i < 4; i++) {
sb.append(array[i]);
}
String str = sb.toString();
// SHA1签名生成
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(str.getBytes());
byte[] digest = md.digest();
StringBuffer hexstr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexstr.append(0);
}
hexstr.append(shaHex);
}
return hexstr.toString();
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ComputeSignatureError);
}
}
}

View File

@ -0,0 +1,70 @@
package com.wecom.robot.util;
import java.io.StringReader;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import com.wecom.robot.util.WXBizMsgCrypt;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilderFactory;
public class Sample {
public static void main(String[] args) throws Exception {
String sToken = "QDG6eK";
String sCorpID = "wx5823bf96d3bd56c7";
String sEncodingAESKey = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C";
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(sToken, sEncodingAESKey, sCorpID);
String sVerifyMsgSig = "5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3";
String sVerifyTimeStamp = "1409659589";
String sVerifyNonce = "263014780";
String sVerifyEchoStr = "P9nAzCzyDtyTWESHep1vC5X9xho/qYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp+4RPcs8TgAE7OaBO+FZXvnaqQ==";
String sEchoStr;
try {
sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,
sVerifyNonce, sVerifyEchoStr);
System.out.println("verifyurl echostr: " + sEchoStr);
} catch (Exception e) {
e.printStackTrace();
}
String sReqMsgSig = "477715d11cdb4164915debcba66cb864d751f3e6";
String sReqTimeStamp = "1409659813";
String sReqNonce = "1372623149";
String sReqData = "<xml><ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName><Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt><AgentID><![CDATA[218]]></AgentID></xml>";
try {
String sMsg = wxcpt.DecryptMsg(sReqMsgSig, sReqTimeStamp, sReqNonce, sReqData);
System.out.println("after decrypt msg: " + sMsg);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(sMsg);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Content");
String Content = nodelist1.item(0).getTextContent();
System.out.println("Content" + Content);
} catch (Exception e) {
e.printStackTrace();
}
String sRespData = "<xml><ToUserName><![CDATA[mycreate]]></ToUserName><FromUserName><![CDATA[wx5823bf96d3bd56c7]]></FromUserName><CreateTime>1348831860</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[this is a test]]></Content><MsgId>1234567890123456</MsgId><AgentID>128</AgentID></xml>";
try{
String sEncryptMsg = wxcpt.EncryptMsg(sRespData, sReqTimeStamp, sReqNonce);
System.out.println("after encrypt sEncrytMsg: " + sEncryptMsg);
}
catch(Exception e)
{
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,289 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
/**
* 针对org.apache.commons.codec.binary.Base64
* 需要导入架包commons-codec-1.9或commons-codec-1.8等其他版本
* 官方下载地址http://commons.apache.org/proper/commons-codec/download_codec.cgi
*/
package com.wecom.robot.util;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Random;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
/**
* 提供接收和推送给企业微信消息的加解密接口(UTF8编码的字符串).
* <ol>
* <li>第三方回复加密消息给企业微信</li>
* <li>第三方收到企业微信发送的消息验证消息的安全性并对消息进行解密</li>
* </ol>
* 说明异常java.security.InvalidKeyException:illegal Key Size的解决方案
* <ol>
* <li>在官方网站下载JCE无限制权限策略文件JDK7的下载地址
* http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html</li>
* <li>下载后解压可以看到local_policy.jar和US_export_policy.jar以及readme.txt</li>
* <li>如果安装了JRE将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件</li>
* <li>如果安装了JDK将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件</li>
* </ol>
*/
public class WXBizMsgCrypt {
static Charset CHARSET = Charset.forName("utf-8");
Base64 base64 = new Base64();
byte[] aesKey;
String token;
String receiveid;
/**
* 构造函数
* @param token 企业微信后台开发者设置的token
* @param encodingAesKey 企业微信后台开发者设置的EncodingAESKey
* @param receiveid, 不同场景含义不同详见文档
*
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public WXBizMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException {
if (encodingAesKey.length() != 43) {
throw new AesException(AesException.IllegalAesKey);
}
this.token = token;
this.receiveid = receiveid;
aesKey = Base64.decodeBase64(encodingAesKey + "=");
}
// 生成4个字节的网络字节序
byte[] getNetworkBytesOrder(int sourceNumber) {
byte[] orderBytes = new byte[4];
orderBytes[3] = (byte) (sourceNumber & 0xFF);
orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF);
orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF);
orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF);
return orderBytes;
}
// 还原4个字节的网络字节序
int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
// 随机生成16位字符串
String getRandomStr() {
String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 16; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}
/**
* 对明文进行加密.
*
* @param text 需要加密的明文
* @return 加密后base64编码的字符串
* @throws AesException aes加密失败
*/
String encrypt(String randomStr, String text) throws AesException {
ByteGroup byteCollector = new ByteGroup();
byte[] randomStrBytes = randomStr.getBytes(CHARSET);
byte[] textBytes = text.getBytes(CHARSET);
byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length);
byte[] receiveidBytes = receiveid.getBytes(CHARSET);
// randomStr + networkBytesOrder + text + receiveid
byteCollector.addBytes(randomStrBytes);
byteCollector.addBytes(networkBytesOrder);
byteCollector.addBytes(textBytes);
byteCollector.addBytes(receiveidBytes);
// ... + pad: 使用自定义的填充方式对明文进行补位填充
byte[] padBytes = PKCS7Encoder.encode(byteCollector.size());
byteCollector.addBytes(padBytes);
// 获得最终的字节流, 未加密
byte[] unencrypted = byteCollector.toBytes();
try {
// 设置加密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
// 加密
byte[] encrypted = cipher.doFinal(unencrypted);
// 使用BASE64对加密后的字符串进行编码
String base64Encrypted = base64.encodeToString(encrypted);
return base64Encrypted;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.EncryptAESError);
}
}
/**
* 对密文进行解密.
*
* @param text 需要解密的密文
* @return 解密得到的明文
* @throws AesException aes解密失败
*/
String decrypt(String text) throws AesException {
byte[] original;
try {
// 设置解密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);
// 使用BASE64对密文进行解码
byte[] encrypted = Base64.decodeBase64(text);
// 解密
original = cipher.doFinal(encrypted);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.DecryptAESError);
}
String xmlContent, from_receiveid;
try {
// 去除补位字符
byte[] bytes = PKCS7Encoder.decode(original);
// 分离16位随机字符串,网络字节序和receiveid
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length),
CHARSET);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.IllegalBuffer);
}
// receiveid不相同的情况
if (!from_receiveid.equals(receiveid)) {
throw new AesException(AesException.ValidateCorpidError);
}
return xmlContent;
}
/**
* 将企业微信回复用户的消息加密打包.
* <ol>
* <li>对要发送的消息进行AES-CBC加密</li>
* <li>生成安全签名</li>
* <li>将消息密文和安全签名打包成xml格式</li>
* </ol>
*
* @param replyMsg 企业微信待回复用户的消息xml格式的字符串
* @param timeStamp 时间戳可以自己生成也可以用URL参数的timestamp
* @param nonce 随机串可以自己生成也可以用URL参数的nonce
*
* @return 加密后的可以直接回复用户的密文包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String EncryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException {
// 加密
String encrypt = encrypt(getRandomStr(), replyMsg);
// 生成安全签名
if (timeStamp == "") {
timeStamp = Long.toString(System.currentTimeMillis());
}
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt);
// System.out.println("发送给平台的签名是: " + signature[1].toString());
// 生成发送的xml
String result = XMLParse.generate(encrypt, signature, timeStamp, nonce);
return result;
}
/**
* 检验消息的真实性并且获取解密后的明文.
* <ol>
* <li>利用收到的密文生成安全签名进行签名验证</li>
* <li>若验证通过则提取xml中的加密消息</li>
* <li>对消息进行解密</li>
* </ol>
*
* @param msgSignature 签名串对应URL参数的msg_signature
* @param timeStamp 时间戳对应URL参数的timestamp
* @param nonce 随机串对应URL参数的nonce
* @param postData 密文对应POST请求的数据
*
* @return 解密后的原文
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws AesException {
// 密钥公众账号的app secret
// 提取密文
Object[] encrypt = XMLParse.extract(postData);
// 验证安全签名
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString());
// 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名" + msg_sign);
// System.out.println("第三方校验签名:" + signature);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
// 解密
String result = decrypt(encrypt[1].toString());
return result;
}
/**
* 验证URL
* @param msgSignature 签名串对应URL参数的msg_signature
* @param timeStamp 时间戳对应URL参数的timestamp
* @param nonce 随机串对应URL参数的nonce
* @param echoStr 随机串对应URL参数的echostr
*
* @return 解密之后的echostr
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr)
throws AesException {
String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
String result = decrypt(echoStr);
return result;
}
}

View File

@ -0,0 +1,104 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.io.StringReader;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
/**
* XMLParse class
*
* 提供提取消息格式中的密文及生成回复消息格式的接口.
*/
class XMLParse {
/**
* 提取出xml数据包中的加密消息
* @param xmltext 待提取的xml字符串
* @return 提取出的加密消息字符串
* @throws AesException
*/
public static Object[] extract(String xmltext) throws AesException {
Object[] result = new Object[3];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
String FEATURE = null;
// This is the PRIMARY defense. If DTDs (doctypes) are disallowed, almost all XML entity attacks are prevented
// Xerces 2 only - http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl
FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
dbf.setFeature(FEATURE, true);
// If you can't completely disable DTDs, then at least do the following:
// Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-general-entities
// Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-general-entities
// JDK7+ - http://xml.org/sax/features/external-general-entities
FEATURE = "http://xml.org/sax/features/external-general-entities";
dbf.setFeature(FEATURE, false);
// Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-parameter-entities
// Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities
// JDK7+ - http://xml.org/sax/features/external-parameter-entities
FEATURE = "http://xml.org/sax/features/external-parameter-entities";
dbf.setFeature(FEATURE, false);
// Disable external DTDs as well
FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
dbf.setFeature(FEATURE, false);
// and these as well, per Timothy Morgan's 2014 paper: "XML Schema, DTD, and Entity Attacks"
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
// And, per Timothy Morgan: "If for some reason support for inline DOCTYPEs are a requirement, then
// ensure the entity settings are disabled (as shown above) and beware that SSRF attacks
// (http://cwe.mitre.org/data/definitions/918.html) and denial
// of service attacks (such as billion laughs or decompression bombs via "jar:") are a risk."
// remaining parser logic
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(xmltext);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
return result;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ParseXmlError);
}
}
/**
* 生成xml消息
* @param encrypt 加密后的消息密文
* @param signature 安全签名
* @param timestamp 时间戳
* @param nonce 随机字符串
* @return 生成的xml字符串
*/
public static String generate(String encrypt, String signature, String timestamp, String nonce) {
String format = "<xml>\n" + "<Encrypt><![CDATA[%1$s]]></Encrypt>\n"
+ "<MsgSignature><![CDATA[%2$s]]></MsgSignature>\n"
+ "<TimeStamp>%3$s</TimeStamp>\n" + "<Nonce><![CDATA[%4$s]]></Nonce>\n" + "</xml>";
return String.format(format, encrypt, signature, timestamp, nonce);
}
}

View File

@ -0,0 +1,65 @@
package com.wecom.robot.util;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
public class XmlUtil {
public static Map<String, String> parseXml(String xmlStr) {
Map<String, String> map = new HashMap<>();
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new ByteArrayInputStream(xmlStr.getBytes("UTF-8")));
Element root = document.getDocumentElement();
NodeList nodeList = root.getChildNodes();
for (int i = 0; i < nodeList.getLength(); i++) {
org.w3c.dom.Node node = nodeList.item(i);
if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
map.put(node.getNodeName(), node.getTextContent());
}
}
} catch (Exception e) {
throw new RuntimeException("XML解析失败", e);
}
return map;
}
public static String mapToXml(Map<String, String> map) {
StringBuilder sb = new StringBuilder();
sb.append("<xml>");
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (isNumeric(value)) {
sb.append("<").append(key).append(">").append(value).append("</").append(key).append(">");
} else {
sb.append("<").append(key).append("><![CDATA[").append(value).append("]]></").append(key).append(">");
}
}
sb.append("</xml>");
return sb.toString();
}
private static boolean isNumeric(String str) {
if (str == null || str.isEmpty()) {
return false;
}
for (char c : str.toCharArray()) {
if (!Character.isDigit(c)) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,115 @@
package com.wecom.robot.websocket;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class CsWebSocketHandler implements WebSocketHandler {
private static final Map<String, WebSocketSession> csSessions = new ConcurrentHashMap<>();
private static final Map<String, String> sessionToCsMap = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String csId = extractCsId(session);
if (csId != null) {
csSessions.put(csId, session);
log.info("客服WebSocket连接建立: csId={}, sessionId={}", csId, session.getId());
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
if (message instanceof TextMessage) {
String payload = ((TextMessage) message).getPayload();
log.debug("收到WebSocket消息: {}", payload);
Map<String, Object> msgMap = JSON.parseObject(payload, Map.class);
String type = (String) msgMap.get("type");
if ("bind_session".equals(type)) {
String sessionId = (String) msgMap.get("sessionId");
String csId = extractCsId(session);
if (sessionId != null && csId != null) {
sessionToCsMap.put(sessionId, csId);
log.info("绑定会话: sessionId={}, csId={}", sessionId, csId);
}
}
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("WebSocket传输错误: sessionId={}", session.getId(), exception);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String csId = extractCsId(session);
if (csId != null) {
csSessions.remove(csId);
sessionToCsMap.entrySet().removeIf(entry -> csId.equals(entry.getValue()));
log.info("客服WebSocket连接关闭: csId={}, status={}", csId, status);
}
}
@Override
public boolean supportsPartialMessages() {
return false;
}
public void sendMessageToCs(String csId, Object message) {
WebSocketSession session = csSessions.get(csId);
if (session != null && session.isOpen()) {
try {
String json = JSON.toJSONString(message);
session.sendMessage(new TextMessage(json));
log.debug("发送消息给客服: csId={}, message={}", csId, json);
} catch (IOException e) {
log.error("发送WebSocket消息失败: csId={}", csId, e);
}
} else {
log.warn("客服不在线: csId={}", csId);
}
}
public void broadcastToAll(Object message) {
String json = JSON.toJSONString(message);
TextMessage textMessage = new TextMessage(json);
csSessions.values().forEach(session -> {
if (session.isOpen()) {
try {
session.sendMessage(textMessage);
} catch (IOException e) {
log.error("广播消息失败: sessionId={}", session.getId(), e);
}
}
});
}
public void sendMessageToSession(String sessionId, Object message) {
String csId = sessionToCsMap.get(sessionId);
if (csId != null) {
sendMessageToCs(csId, message);
} else {
log.warn("会话未绑定客服: sessionId={}", sessionId);
}
}
private String extractCsId(WebSocketSession session) {
String path = session.getUri().getPath();
String[] parts = path.split("/");
if (parts.length >= 4) {
return parts[3];
}
return null;
}
}

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 10000
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: debug
org.springframework.web: info

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://host.docker.internal:3316/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: host.docker.internal
port: 6379
password: jiong1114
database: 0
timeout: 10000
lettuce:
pool:
max-active: 16
max-wait: -1
max-idle: 8
min-idle: 2
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: info
org.springframework.web: warn

View File

@ -0,0 +1,64 @@
server:
port: 8080
spring:
application:
name: wecom-robot
profiles:
active: dev
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
validate-on-migrate: true
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.wecom.robot.entity
configuration:
map-underscore-to-camel-case: true
wecom:
kf:
callback-url: /wecom/callback
transfer:
keywords:
- 人工
- 转人工
- 投诉
- 客服
- 人工客服
confidence-threshold: 0.6
max-fail-rounds: 3
max-session-duration: 1800000
max-message-rounds: 50
ai-service:
url: http://localhost:8000
timeout: 5000
channel:
default-channel: wechat
adapters:
wechat:
enabled: true
douyin:
enabled: false
jd:
enabled: false
resilience4j:
circuitbreaker:
instances:
aiService:
failure-rate-threshold: 50
sliding-window-size: 10
sliding-window-type: COUNT_BASED
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
timelimiter:
instances:
aiService:
timeout-duration: 5s

View File

@ -0,0 +1,74 @@
-- V1__init.sql - 初始化数据库表结构
-- Flyway 迁移脚本
-- 会话表
CREATE TABLE IF NOT EXISTS `session` (
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`customer_id` VARCHAR(64) NOT NULL COMMENT '客户ID (external_userid)',
`kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID (open_kfid)',
`channel_type` VARCHAR(20) NOT NULL DEFAULT 'wechat' COMMENT '渠道类型: wechat/douyin/jd',
`status` VARCHAR(20) NOT NULL DEFAULT 'AI' COMMENT '状态: AI/PENDING/MANUAL/CLOSED',
`wx_service_state` INT DEFAULT 0 COMMENT '微信会话状态: 0-未处理/1-智能助手/2-待接入池/3-人工接待/4-已结束',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`manual_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '人工客服ID',
`metadata` TEXT DEFAULT NULL COMMENT '扩展信息JSON',
PRIMARY KEY (`session_id`),
INDEX `idx_customer_id` (`customer_id`),
INDEX `idx_kf_id` (`kf_id`),
INDEX `idx_status` (`status`),
INDEX `idx_channel_type` (`channel_type`),
INDEX `idx_updated_at` (`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表';
-- 消息表
CREATE TABLE IF NOT EXISTS `message` (
`msg_id` VARCHAR(128) NOT NULL COMMENT '消息ID',
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`sender_type` VARCHAR(20) NOT NULL COMMENT '发送者类型: customer/ai/manual',
`sender_id` VARCHAR(64) NOT NULL COMMENT '发送者标识',
`content` TEXT NOT NULL COMMENT '消息内容',
`msg_type` VARCHAR(20) NOT NULL DEFAULT 'text' COMMENT '消息类型: text/image/link等',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`raw_data` TEXT DEFAULT NULL COMMENT '原始消息数据JSON',
PRIMARY KEY (`msg_id`),
INDEX `idx_session_id` (`session_id`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- 客服账号表
CREATE TABLE IF NOT EXISTS `kf_account` (
`kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID',
`name` VARCHAR(100) DEFAULT NULL COMMENT '客服昵称',
`avatar` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`status` VARCHAR(20) NOT NULL DEFAULT 'offline' COMMENT '状态: online/offline',
`bind_manual_id` VARCHAR(64) DEFAULT NULL COMMENT '绑定的企业微信员工ID',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`kf_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服账号表';
-- 转人工记录表
CREATE TABLE IF NOT EXISTS `transfer_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`trigger_reason` VARCHAR(200) DEFAULT NULL COMMENT '触发原因',
`trigger_time` DATETIME NOT NULL COMMENT '触发时间',
`accepted_time` DATETIME DEFAULT NULL COMMENT '客服接入时间',
`accepted_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '接入的客服ID',
PRIMARY KEY (`id`),
INDEX `idx_session_id` (`session_id`),
INDEX `idx_trigger_time` (`trigger_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转人工记录表';
-- 快捷回复表
CREATE TABLE IF NOT EXISTS `quick_reply` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`cs_id` VARCHAR(64) DEFAULT NULL COMMENT '客服ID为空表示公共',
`category` VARCHAR(50) DEFAULT NULL COMMENT '分类',
`content` VARCHAR(500) NOT NULL COMMENT '回复内容',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
INDEX `idx_cs_id` (`cs_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快捷回复表';

View File

@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天记录查询</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #1890ff;
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
margin-bottom: 8px;
}
.header p {
opacity: 0.8;
font-size: 14px;
}
.filters {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-weight: 500;
color: #333;
}
select, input {
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
min-width: 200px;
}
select:focus, input:focus {
outline: none;
border-color: #1890ff;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #1890ff;
color: white;
}
.btn-primary:hover {
background: #40a9ff;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #d9d9d9;
}
.main-content {
display: grid;
grid-template-columns: 350px 1fr;
gap: 20px;
}
.session-list {
background: white;
border-radius: 8px;
overflow: hidden;
}
.session-list-header {
padding: 15px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
}
.session-item {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.session-item:hover {
background: #f5f5f5;
}
.session-item.active {
background: #e6f7ff;
border-left: 3px solid #1890ff;
}
.session-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.session-customer-id {
font-weight: 500;
color: #333;
font-size: 14px;
}
.session-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.status-AI { background: #e6f7ff; color: #1890ff; }
.status-PENDING { background: #fff7e6; color: #fa8c16; }
.status-MANUAL { background: #f6ffed; color: #52c41a; }
.status-CLOSED { background: #f5f5f5; color: #999; }
.session-meta {
font-size: 12px;
color: #999;
}
.chat-panel {
background: white;
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 600px;
}
.chat-header {
padding: 15px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header-info {
font-size: 14px;
color: #666;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #fafafa;
}
.message {
margin-bottom: 16px;
display: flex;
flex-direction: column;
}
.message.customer {
align-items: flex-start;
}
.message.ai, .message.manual {
align-items: flex-end;
}
.message-sender {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.message-content {
max-width: 70%;
padding: 10px 15px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.message.customer .message-content {
background: white;
border: 1px solid #e8e8e8;
}
.message.ai .message-content {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.message.manual .message-content {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.message-time {
font-size: 11px;
color: #bbb;
margin-top: 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 16px;
opacity: 0.5;
}
.loading {
text-align: center;
padding: 20px;
color: #999;
}
.no-sessions {
text-align: center;
padding: 40px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>聊天记录查询</h1>
<p>查看各客服账号的历史聊天记录</p>
</div>
<div class="filters">
<div class="filter-group">
<label>客服账号:</label>
<select id="kfAccountSelect">
<option value="">请选择客服账号</option>
</select>
</div>
<div class="filter-group">
<label>会话状态:</label>
<select id="statusSelect">
<option value="all">全部</option>
<option value="AI">AI接待中</option>
<option value="PENDING">待接入</option>
<option value="MANUAL">人工接待中</option>
<option value="CLOSED">已结束</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadSessions()">查询会话</button>
<button class="btn btn-secondary" onclick="refreshKfAccounts()">刷新账号</button>
</div>
<div class="main-content">
<div class="session-list">
<div class="session-list-header">
会话列表 (<span id="sessionCount">0</span>)
</div>
<div id="sessionListContainer">
<div class="no-sessions">请选择客服账号并查询</div>
</div>
</div>
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-info" id="chatHeaderInfo">请选择会话查看聊天记录</div>
</div>
<div class="chat-messages" id="chatMessagesContainer">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<p>选择左侧会话查看聊天记录</p>
</div>
</div>
</div>
</div>
</div>
<script>
let currentSessionId = null;
async function refreshKfAccounts() {
try {
const response = await fetch('/chat-history/api/kf-accounts');
const result = await response.json();
if (result.code === 0) {
const select = document.getElementById('kfAccountSelect');
select.innerHTML = '<option value="">请选择客服账号</option>';
result.data.forEach(account => {
const option = document.createElement('option');
option.value = account.openKfId;
option.textContent = account.name || account.openKfId;
select.appendChild(option);
});
if (result.data.length === 0) {
alert('未获取到客服账号,请检查配置');
}
} else {
alert('获取客服账号失败: ' + result.message);
}
} catch (error) {
console.error('获取客服账号失败:', error);
alert('获取客服账号失败,请检查网络连接');
}
}
async function loadSessions() {
const kfId = document.getElementById('kfAccountSelect').value;
const status = document.getElementById('statusSelect').value;
if (!kfId) {
alert('请选择客服账号');
return;
}
const container = document.getElementById('sessionListContainer');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await fetch(`/chat-history/api/sessions?openKfId=${encodeURIComponent(kfId)}&status=${status}`);
const result = await response.json();
if (result.code === 0) {
document.getElementById('sessionCount').textContent = result.data.length;
renderSessionList(result.data);
} else {
container.innerHTML = `<div class="no-sessions">查询失败: ${result.message}</div>`;
}
} catch (error) {
console.error('加载会话列表失败:', error);
container.innerHTML = '<div class="no-sessions">加载失败,请重试</div>';
}
}
function renderSessionList(sessions) {
const container = document.getElementById('sessionListContainer');
if (sessions.length === 0) {
container.innerHTML = '<div class="no-sessions">暂无会话记录</div>';
return;
}
container.innerHTML = sessions.map(session => `
<div class="session-item" data-session-id="${session.sessionId}" onclick="selectSession('${session.sessionId}')">
<div class="session-item-header">
<span class="session-customer-id">${session.customerId.substring(0, 15)}...</span>
<span class="session-status status-${session.status}">${getStatusText(session.status)}</span>
</div>
<div class="session-meta">
消息: ${session.messageCount} 条 | ${formatTime(session.updatedAt)}
</div>
</div>
`).join('');
}
function getStatusText(status) {
const map = {
'AI': 'AI接待',
'PENDING': '待接入',
'MANUAL': '人工接待',
'CLOSED': '已结束'
};
return map[status] || status;
}
function formatTime(timeStr) {
if (!timeStr) return '-';
const date = new Date(timeStr);
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
}
async function selectSession(sessionId) {
currentSessionId = sessionId;
document.querySelectorAll('.session-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-session-id="${sessionId}"]`).classList.add('active');
const container = document.getElementById('chatMessagesContainer');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await fetch(`/chat-history/api/messages?sessionId=${encodeURIComponent(sessionId)}`);
const result = await response.json();
if (result.code === 0) {
document.getElementById('chatHeaderInfo').textContent =
`会话ID: ${sessionId} | 消息数: ${result.data.length}`;
renderMessages(result.data);
} else {
container.innerHTML = `<div class="no-sessions">加载失败: ${result.message}</div>`;
}
} catch (error) {
console.error('加载消息失败:', error);
container.innerHTML = '<div class="no-sessions">加载失败,请重试</div>';
}
}
function renderMessages(messages) {
const container = document.getElementById('chatMessagesContainer');
if (messages.length === 0) {
container.innerHTML = '<div class="empty-state"><p>暂无消息记录</p></div>';
return;
}
container.innerHTML = messages.map(msg => {
const senderName = getSenderName(msg.senderType, msg.senderId);
return `
<div class="message ${msg.senderType}">
<div class="message-sender">${senderName}</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-time">${formatTime(msg.createdAt)}</div>
</div>
`;
}).join('');
container.scrollTop = container.scrollHeight;
}
function getSenderName(senderType, senderId) {
switch (senderType) {
case 'customer': return '客户';
case 'ai': return 'AI助手';
case 'manual': return `客服(${senderId || '未知'})`;
default: return senderType;
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\n/g, '<br>');
}
document.addEventListener('DOMContentLoaded', function() {
refreshKfAccounts();
});
</script>
</body>
</html>

View File

@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客户模拟端</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.phone-frame {
width: 375px;
height: 700px;
background: #fff;
border-radius: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
display: flex;
flex-direction: column;
border: 8px solid #333;
}
.phone-header {
background: #ededed;
padding: 15px;
text-align: center;
border-bottom: 1px solid #d9d9d9;
position: relative;
}
.phone-header .status-bar {
position: absolute;
top: 5px;
left: 15px;
right: 15px;
display: flex;
justify-content: space-between;
font-size: 11px;
color: #333;
}
.phone-header .title {
margin-top: 15px;
font-size: 16px;
font-weight: 500;
}
.phone-header .subtitle {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.chat-area {
flex: 1;
overflow-y: auto;
padding: 15px;
background: #f5f5f5;
}
.message {
margin-bottom: 15px;
display: flex;
flex-direction: column;
}
.message.sent {
align-items: flex-end;
}
.message.received {
align-items: flex-start;
}
.message-bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
position: relative;
}
.message.sent .message-bubble {
background: #95ec69;
border-radius: 8px 0 8px 8px;
}
.message.received .message-bubble {
background: #fff;
border-radius: 0 8px 8px 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.message-time {
font-size: 10px;
color: #999;
margin-top: 4px;
}
.sender-name {
font-size: 11px;
color: #999;
margin-bottom: 3px;
}
.typing-indicator {
display: none;
align-items: center;
padding: 10px 14px;
background: #fff;
border-radius: 0 8px 8px 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
width: fit-content;
}
.typing-indicator.show {
display: flex;
}
.typing-indicator span {
width: 6px;
height: 6px;
background: #999;
border-radius: 50%;
margin: 0 2px;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
.input-area {
background: #f7f7f7;
padding: 10px;
border-top: 1px solid #e0e0e0;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-row textarea {
flex: 1;
padding: 10px;
border: none;
border-radius: 20px;
background: #fff;
resize: none;
height: 40px;
max-height: 100px;
font-size: 14px;
outline: none;
}
.input-row button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #07c160;
color: white;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.input-row button:hover {
background: #06ad56;
}
.input-row button:disabled {
background: #ccc;
}
.quick-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.quick-action {
padding: 6px 12px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 15px;
font-size: 12px;
cursor: pointer;
}
.quick-action:hover {
background: #f5f5f5;
}
.settings-panel {
position: absolute;
top: 10px;
right: 10px;
background: #fff;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 100;
display: none;
}
.settings-panel.show {
display: block;
}
.settings-panel h4 {
margin-bottom: 10px;
font-size: 14px;
}
.settings-panel input {
width: 100%;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
}
.settings-panel button {
width: 100%;
padding: 8px;
background: #07c160;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.settings-btn {
position: absolute;
top: 10px;
right: 10px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 14px;
}
.transfer-notice {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px;
margin: 10px 0;
font-size: 12px;
color: #856404;
text-align: center;
}
.system-message {
text-align: center;
font-size: 11px;
color: #999;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="phone-frame">
<div class="phone-header">
<div class="status-bar">
<span id="currentTime">12:00</span>
<span>📶 🔋</span>
</div>
<div class="title">智能客服</div>
<div class="subtitle" id="statusText">AI在线</div>
</div>
<div class="chat-area" id="chatArea">
<div class="system-message">会话已开始</div>
<div class="message received">
<div class="sender-name">客服</div>
<div class="message-bubble">您好!我是智能客服,有什么可以帮您的吗?</div>
<div class="message-time">刚刚</div>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="messageInput" placeholder="输入消息..." rows="1"></textarea>
<button onclick="sendMessage()" id="sendBtn"></button>
</div>
<div class="quick-actions">
<button class="quick-action" onclick="quickSend('你好')">你好</button>
<button class="quick-action" onclick="quickSend('转人工')">转人工</button>
<button class="quick-action" onclick="quickSend('投诉')">投诉</button>
</div>
</div>
<button class="settings-btn" onclick="toggleSettings()">⚙️</button>
<div class="settings-panel" id="settingsPanel">
<h4>测试设置</h4>
<input type="text" id="customerId" placeholder="客户ID" value="customer_001">
<input type="text" id="kfId" placeholder="客服账号ID" value="kf_001">
<button onclick="saveSettings()">保存设置</button>
<button onclick="clearChat()" style="margin-top: 8px; background: #ff4d4f;">清空聊天</button>
</div>
</div>
<script>
const baseUrl = window.location.origin;
let customerId = 'customer_001';
let kfId = 'kf_001';
let sessionStatus = 'AI';
function updateTime() {
const now = new Date();
document.getElementById('currentTime').textContent =
now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
setInterval(updateTime, 1000);
updateTime();
function toggleSettings() {
document.getElementById('settingsPanel').classList.toggle('show');
}
function saveSettings() {
customerId = document.getElementById('customerId').value || 'customer_001';
kfId = document.getElementById('kfId').value || 'kf_001';
toggleSettings();
addSystemMessage('设置已更新');
}
function clearChat() {
document.getElementById('chatArea').innerHTML = '<div class="system-message">会话已重置</div>';
sessionStatus = 'AI';
document.getElementById('statusText').textContent = 'AI在线';
toggleSettings();
}
function addSystemMessage(text) {
const chatArea = document.getElementById('chatArea');
const msg = document.createElement('div');
msg.className = 'system-message';
msg.textContent = text;
chatArea.appendChild(msg);
chatArea.scrollTop = chatArea.scrollHeight;
}
function addMessage(content, isSent, senderName = '') {
const chatArea = document.getElementById('chatArea');
const msg = document.createElement('div');
msg.className = 'message ' + (isSent ? 'sent' : 'received');
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
msg.innerHTML = `
${senderName ? '<div class="sender-name">' + senderName + '</div>' : ''}
<div class="message-bubble">${content}</div>
<div class="message-time">${time}</div>
`;
chatArea.appendChild(msg);
chatArea.scrollTop = chatArea.scrollHeight;
}
function showTyping() {
const chatArea = document.getElementById('chatArea');
const typing = document.createElement('div');
typing.className = 'message received';
typing.id = 'typingIndicator';
typing.innerHTML = `
<div class="typing-indicator show">
<span></span><span></span><span></span>
</div>
`;
chatArea.appendChild(typing);
chatArea.scrollTop = chatArea.scrollHeight;
}
function hideTyping() {
const typing = document.getElementById('typingIndicator');
if (typing) typing.remove();
}
async function sendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content) return;
addMessage(content, true);
input.value = '';
showTyping();
try {
const response = await fetch(baseUrl + '/test/send-message?' +
'customerId=' + encodeURIComponent(customerId) +
'&kfId=' + encodeURIComponent(kfId) +
'&content=' + encodeURIComponent(content), {
method: 'POST'
});
const result = await response.json();
setTimeout(() => {
hideTyping();
if (result.code === 200) {
if (content.includes('人工') || content.includes('转人工') || content.includes('投诉')) {
sessionStatus = 'PENDING';
document.getElementById('statusText').textContent = '等待人工接入...';
addTransferNotice();
}
}
}, 1000 + Math.random() * 1000);
} catch (error) {
hideTyping();
console.error('发送失败:', error);
addMessage('消息发送失败,请重试', false, '系统');
}
}
function addTransferNotice() {
const chatArea = document.getElementById('chatArea');
const notice = document.createElement('div');
notice.className = 'transfer-notice';
notice.textContent = '正在为您转接人工客服,请稍候...';
chatArea.appendChild(notice);
chatArea.scrollTop = chatArea.scrollHeight;
}
function quickSend(text) {
document.getElementById('messageInput').value = text;
sendMessage();
}
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
document.getElementById('messageInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
setInterval(async () => {
try {
const response = await fetch(baseUrl + '/api/sessions?status=MANUAL');
const result = await response.json();
if (result.code === 200) {
const mySession = result.data.find(s =>
s.customerId === customerId && s.status === 'MANUAL'
);
if (mySession && sessionStatus !== 'MANUAL') {
sessionStatus = 'MANUAL';
document.getElementById('statusText').textContent = '人工客服接待中';
addSystemMessage('人工客服已接入');
}
}
} catch (e) {}
}, 3000);
</script>
</body>
</html>

View File

@ -0,0 +1,638 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>人工客服工作台</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
}
.sidebar {
width: 300px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 15px;
background: #1890ff;
color: white;
font-size: 16px;
font-weight: bold;
}
.session-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
}
.session-tab {
flex: 1;
padding: 10px;
text-align: center;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.session-tab.active {
border-bottom-color: #1890ff;
color: #1890ff;
}
.session-list {
flex: 1;
overflow-y: auto;
}
.session-item {
padding: 12px 15px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.session-item:hover {
background: #f5f5f5;
}
.session-item.active {
background: #e6f7ff;
}
.session-item .customer-id {
font-weight: bold;
margin-bottom: 5px;
}
.session-item .last-msg {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item .time {
font-size: 11px;
color: #999;
margin-top: 3px;
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
margin-left: 5px;
}
.status-pending {
background: #fff7e6;
color: #fa8c16;
}
.status-manual {
background: #e6f7ff;
color: #1890ff;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 15px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f9f9f9;
}
.message {
margin-bottom: 15px;
display: flex;
}
.message.customer {
justify-content: flex-start;
}
.message.ai, .message.manual {
justify-content: flex-end;
}
.message-content {
max-width: 60%;
padding: 10px 15px;
border-radius: 8px;
position: relative;
}
.message.customer .message-content {
background: #fff;
border: 1px solid #e0e0e0;
}
.message.ai .message-content {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.message.manual .message-content {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.message-sender {
font-size: 11px;
color: #999;
margin-bottom: 3px;
}
.message-time {
font-size: 10px;
color: #bbb;
margin-top: 3px;
}
.chat-input {
padding: 15px;
background: #fff;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 10px;
}
.chat-input textarea {
flex: 1;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
resize: none;
height: 60px;
}
.chat-input button {
padding: 10px 20px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-input button:hover {
background: #40a9ff;
}
.chat-input button:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
.connection-status {
padding: 5px 10px;
font-size: 12px;
border-radius: 4px;
}
.connected {
background: #f6ffed;
color: #52c41a;
}
.disconnected {
background: #fff2f0;
color: #ff4d4f;
}
.actions {
display: flex;
gap: 10px;
}
.actions button {
padding: 5px 10px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
}
.actions button:hover {
border-color: #1890ff;
color: #1890ff;
}
.test-panel {
position: fixed;
right: 20px;
bottom: 20px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 15px;
width: 300px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.test-panel h4 {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.test-panel input, .test-panel textarea {
width: 100%;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.test-panel button {
width: 100%;
padding: 8px;
background: #52c41a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.test-panel button:hover {
background: #73d13d;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
客服工作台 <span id="csId">CS_001</span>
</div>
<div class="session-tabs">
<div class="session-tab active" data-status="PENDING" onclick="switchTab('PENDING')">
待接入 (<span id="pendingCount">0</span>)
</div>
<div class="session-tab" data-status="MANUAL" onclick="switchTab('MANUAL')">
进行中 (<span id="manualCount">0</span>)
</div>
</div>
<div class="session-list" id="sessionList">
</div>
</div>
<div class="main-content">
<div id="chatArea" style="display: none; height: 100%; flex-direction: column;">
<div class="chat-header">
<div>
<strong id="currentCustomer">-</strong>
<span class="status-badge" id="currentStatus">-</span>
</div>
<div class="actions">
<button onclick="acceptSession()" id="acceptBtn">接入会话</button>
<button onclick="closeSession()" id="closeBtn">结束会话</button>
</div>
</div>
<div class="chat-messages" id="chatMessages">
</div>
<div class="chat-input">
<textarea id="messageInput" placeholder="输入消息..."></textarea>
<button onclick="sendMessage()" id="sendBtn" disabled>发送</button>
</div>
</div>
<div id="emptyState" class="empty-state">
<div style="text-align: center;">
<p>请从左侧选择一个会话</p>
<p style="margin-top: 10px; font-size: 12px;">WebSocket: <span id="wsStatus" class="connection-status disconnected">未连接</span></p>
</div>
</div>
</div>
<div class="test-panel">
<h4>🧪 模拟客户消息</h4>
<input type="text" id="testCustomerId" placeholder="客户ID" value="test_customer_001">
<input type="text" id="testKfId" placeholder="客服账号ID" value="test_kf_001">
<textarea id="testContent" placeholder="消息内容"></textarea>
<button onclick="sendTestMessage()">发送测试消息</button>
<button onclick="triggerTransfer()" style="margin-top: 5px; background: #fa8c16;">触发转人工</button>
</div>
<script>
let ws = null;
let currentSessionId = null;
let currentStatus = null;
let csId = 'CS_001';
const baseUrl = window.location.origin;
function connectWebSocket() {
const wsUrl = baseUrl.replace('http', 'ws') + '/ws/cs/' + csId;
ws = new WebSocket(wsUrl);
ws.onopen = function() {
document.getElementById('wsStatus').className = 'connection-status connected';
document.getElementById('wsStatus').textContent = '已连接';
console.log('WebSocket已连接');
};
ws.onclose = function() {
document.getElementById('wsStatus').className = 'connection-status disconnected';
document.getElementById('wsStatus').textContent = '已断开';
console.log('WebSocket已断开');
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
handleWebSocketMessage(data);
};
ws.onerror = function(error) {
console.error('WebSocket错误:', error);
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'new_pending_session':
alert('有新的待接入会话!');
loadSessions();
break;
case 'new_message':
case 'customer_message':
if (currentSessionId === data.sessionId) {
addMessage('customer', data.content, data.timestamp);
}
loadSessions();
break;
case 'session_accepted':
if (currentSessionId === data.sessionId) {
currentStatus = 'MANUAL';
updateChatHeader();
document.getElementById('sendBtn').disabled = false;
document.getElementById('acceptBtn').disabled = true;
}
break;
case 'session_closed':
if (currentSessionId === data.sessionId) {
alert('会话已结束');
currentSessionId = null;
showEmptyState();
}
loadSessions();
break;
}
}
function switchTab(status) {
document.querySelectorAll('.session-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.status === status) {
tab.classList.add('active');
}
});
loadSessions(status);
}
async function loadSessions(status = 'PENDING') {
try {
const response = await fetch(baseUrl + '/api/sessions?status=' + status);
const result = await response.json();
if (result.code === 200) {
renderSessionList(result.data, status);
if (status === 'PENDING') {
document.getElementById('pendingCount').textContent = result.data.length;
} else {
document.getElementById('manualCount').textContent = result.data.length;
}
}
} catch (error) {
console.error('加载会话列表失败:', error);
}
}
function renderSessionList(sessions, status) {
const list = document.getElementById('sessionList');
list.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = 'session-item' + (currentSessionId === session.sessionId ? ' active' : '');
item.onclick = () => selectSession(session);
const time = session.lastMessageTime ? new Date(session.lastMessageTime).toLocaleString() : '-';
item.innerHTML = `
<div class="customer-id">
${session.customerId}
<span class="status-badge status-${session.status.toLowerCase()}">${session.status}</span>
</div>
<div class="last-msg">${session.lastMessage || '暂无消息'}</div>
<div class="time">${time}</div>
`;
list.appendChild(item);
});
if (sessions.length === 0) {
list.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">暂无会话</div>';
}
}
async function selectSession(session) {
currentSessionId = session.sessionId;
currentStatus = session.status;
document.querySelectorAll('.session-item').forEach(item => {
item.classList.remove('active');
});
event.currentTarget.classList.add('active');
document.getElementById('emptyState').style.display = 'none';
document.getElementById('chatArea').style.display = 'flex';
updateChatHeader();
await loadHistory();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'bind_session',
sessionId: currentSessionId
}));
}
}
function updateChatHeader() {
document.getElementById('currentCustomer').textContent = currentSessionId;
const statusBadge = document.getElementById('currentStatus');
statusBadge.textContent = currentStatus;
statusBadge.className = 'status-badge status-' + currentStatus.toLowerCase();
document.getElementById('acceptBtn').disabled = currentStatus !== 'PENDING';
document.getElementById('sendBtn').disabled = currentStatus !== 'MANUAL';
document.getElementById('closeBtn').disabled = currentStatus !== 'MANUAL';
}
async function loadHistory() {
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/history');
const result = await response.json();
if (result.code === 200) {
const container = document.getElementById('chatMessages');
container.innerHTML = '';
result.data.forEach(msg => {
addMessage(msg.senderType, msg.content, msg.createdAt);
});
container.scrollTop = container.scrollHeight;
}
} catch (error) {
console.error('加载历史消息失败:', error);
}
}
function addMessage(senderType, content, timestamp) {
const container = document.getElementById('chatMessages');
const msg = document.createElement('div');
msg.className = 'message ' + senderType;
const senderName = senderType === 'customer' ? '客户' :
senderType === 'ai' ? 'AI客服' : '人工客服';
const time = timestamp ? new Date(timestamp).toLocaleString() : new Date().toLocaleString();
msg.innerHTML = `
<div class="message-content">
<div class="message-sender">${senderName}</div>
<div>${content}</div>
<div class="message-time">${time}</div>
</div>
`;
container.appendChild(msg);
container.scrollTop = container.scrollHeight;
}
async function acceptSession() {
if (!currentSessionId) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csId: csId })
});
const result = await response.json();
if (result.code === 200) {
currentStatus = 'MANUAL';
updateChatHeader();
loadSessions('PENDING');
loadSessions('MANUAL');
} else {
alert('接入失败: ' + result.message);
}
} catch (error) {
console.error('接入会话失败:', error);
}
}
async function sendMessage() {
if (!currentSessionId || currentStatus !== 'MANUAL') return;
const content = document.getElementById('messageInput').value.trim();
if (!content) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content, msgType: 'text' })
});
const result = await response.json();
if (result.code === 200) {
addMessage('manual', content);
document.getElementById('messageInput').value = '';
} else {
alert('发送失败: ' + result.message);
}
} catch (error) {
console.error('发送消息失败:', error);
}
}
async function closeSession() {
if (!currentSessionId) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/close', {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
currentSessionId = null;
showEmptyState();
loadSessions('PENDING');
loadSessions('MANUAL');
}
} catch (error) {
console.error('结束会话失败:', error);
}
}
function showEmptyState() {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('chatArea').style.display = 'none';
}
async function sendTestMessage() {
const customerId = document.getElementById('testCustomerId').value;
const kfId = document.getElementById('testKfId').value;
const content = document.getElementById('testContent').value;
if (!content) {
alert('请输入消息内容');
return;
}
try {
const response = await fetch(baseUrl + '/test/send-message?customerId=' + customerId + '&kfId=' + kfId + '&content=' + encodeURIComponent(content), {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
alert('消息已发送!');
document.getElementById('testContent').value = '';
setTimeout(() => loadSessions('PENDING'), 500);
setTimeout(() => loadSessions('MANUAL'), 500);
}
} catch (error) {
console.error('发送测试消息失败:', error);
}
}
async function triggerTransfer() {
const customerId = document.getElementById('testCustomerId').value;
const kfId = document.getElementById('testKfId').value;
try {
const response = await fetch(baseUrl + '/test/trigger-transfer?customerId=' + customerId + '&kfId=' + kfId, {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
alert('已触发转人工!');
setTimeout(() => loadSessions('PENDING'), 500);
}
} catch (error) {
console.error('触发转人工失败:', error);
}
}
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
connectWebSocket();
loadSessions('PENDING');
loadSessions('MANUAL');
</script>
</body>
</html>

View File

@ -0,0 +1,84 @@
package com.wecom.robot.dto;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class InboundMessageTest {
@Test
void testInboundMessageBuilder() {
SignatureInfo signatureInfo = SignatureInfo.builder()
.signature("test-signature")
.timestamp("1234567890")
.nonce("test-nonce")
.algorithm("sha256")
.build();
Map<String, Object> metadata = new HashMap<>();
metadata.put("key1", "value1");
InboundMessage message = InboundMessage.builder()
.channelType(InboundMessage.CHANNEL_WECHAT)
.channelMessageId("msg-123")
.sessionKey("session-key-001")
.customerId("customer-001")
.kfId("kf-001")
.sender("user-001")
.content("Hello World")
.msgType(InboundMessage.MSG_TYPE_TEXT)
.rawPayload("{\"raw\":\"data\"}")
.timestamp(1234567890L)
.signatureInfo(signatureInfo)
.metadata(metadata)
.build();
assertEquals(InboundMessage.CHANNEL_WECHAT, message.getChannelType());
assertEquals("msg-123", message.getChannelMessageId());
assertEquals("session-key-001", message.getSessionKey());
assertEquals("customer-001", message.getCustomerId());
assertEquals("kf-001", message.getKfId());
assertEquals("user-001", message.getSender());
assertEquals("Hello World", message.getContent());
assertEquals(InboundMessage.MSG_TYPE_TEXT, message.getMsgType());
assertEquals("{\"raw\":\"data\"}", message.getRawPayload());
assertEquals(1234567890L, message.getTimestamp());
assertNotNull(message.getSignatureInfo());
assertEquals("test-signature", message.getSignatureInfo().getSignature());
assertNotNull(message.getMetadata());
assertEquals("value1", message.getMetadata().get("key1"));
}
@Test
void testInboundMessageSetters() {
InboundMessage message = new InboundMessage();
message.setChannelType(InboundMessage.CHANNEL_DOUYIN);
message.setChannelMessageId("msg-456");
message.setSessionKey("session-key-002");
message.setContent("Test message");
assertEquals(InboundMessage.CHANNEL_DOUYIN, message.getChannelType());
assertEquals("msg-456", message.getChannelMessageId());
assertEquals("session-key-002", message.getSessionKey());
assertEquals("Test message", message.getContent());
}
@Test
void testChannelTypeConstants() {
assertEquals("wechat", InboundMessage.CHANNEL_WECHAT);
assertEquals("douyin", InboundMessage.CHANNEL_DOUYIN);
assertEquals("jd", InboundMessage.CHANNEL_JD);
}
@Test
void testMsgTypeConstants() {
assertEquals("text", InboundMessage.MSG_TYPE_TEXT);
assertEquals("image", InboundMessage.MSG_TYPE_IMAGE);
assertEquals("voice", InboundMessage.MSG_TYPE_VOICE);
assertEquals("video", InboundMessage.MSG_TYPE_VIDEO);
assertEquals("event", InboundMessage.MSG_TYPE_EVENT);
}
}

View File

@ -0,0 +1,50 @@
package com.wecom.robot.dto;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OutboundMessageTest {
@Test
void testOutboundMessageBuilder() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("priority", "high");
OutboundMessage message = OutboundMessage.builder()
.channelType(InboundMessage.CHANNEL_WECHAT)
.receiver("customer-001")
.kfId("kf-001")
.content("Reply message")
.msgType("text")
.metadata(metadata)
.build();
assertEquals(InboundMessage.CHANNEL_WECHAT, message.getChannelType());
assertEquals("customer-001", message.getReceiver());
assertEquals("kf-001", message.getKfId());
assertEquals("Reply message", message.getContent());
assertEquals("text", message.getMsgType());
assertNotNull(message.getMetadata());
assertEquals("high", message.getMetadata().get("priority"));
}
@Test
void testOutboundMessageSetters() {
OutboundMessage message = new OutboundMessage();
message.setChannelType(InboundMessage.CHANNEL_JD);
message.setReceiver("jd-customer-001");
message.setKfId("jd-kf-001");
message.setContent("JD reply");
message.setMsgType("text");
assertEquals(InboundMessage.CHANNEL_JD, message.getChannelType());
assertEquals("jd-customer-001", message.getReceiver());
assertEquals("jd-kf-001", message.getKfId());
assertEquals("JD reply", message.getContent());
assertEquals("text", message.getMsgType());
}
}

View File

@ -0,0 +1,61 @@
package com.wecom.robot.dto;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SignatureInfoTest {
@Test
void testSignatureInfoBuilder() {
SignatureInfo signatureInfo = SignatureInfo.builder()
.signature("abc123signature")
.timestamp("1708700000")
.nonce("random-nonce-value")
.algorithm("hmac-sha256")
.build();
assertEquals("abc123signature", signatureInfo.getSignature());
assertEquals("1708700000", signatureInfo.getTimestamp());
assertEquals("random-nonce-value", signatureInfo.getNonce());
assertEquals("hmac-sha256", signatureInfo.getAlgorithm());
}
@Test
void testSignatureInfoSetters() {
SignatureInfo signatureInfo = new SignatureInfo();
signatureInfo.setSignature("test-sig");
signatureInfo.setTimestamp("12345");
signatureInfo.setNonce("test-nonce");
signatureInfo.setAlgorithm("md5");
assertEquals("test-sig", signatureInfo.getSignature());
assertEquals("12345", signatureInfo.getTimestamp());
assertEquals("test-nonce", signatureInfo.getNonce());
assertEquals("md5", signatureInfo.getAlgorithm());
}
@Test
void testSignatureInfoNoArgsConstructor() {
SignatureInfo signatureInfo = new SignatureInfo();
assertNull(signatureInfo.getSignature());
assertNull(signatureInfo.getTimestamp());
assertNull(signatureInfo.getNonce());
assertNull(signatureInfo.getAlgorithm());
}
@Test
void testSignatureInfoAllArgsConstructor() {
SignatureInfo signatureInfo = new SignatureInfo(
"full-sig",
"9999",
"full-nonce",
"sha1"
);
assertEquals("full-sig", signatureInfo.getSignature());
assertEquals("9999", signatureInfo.getTimestamp());
assertEquals("full-nonce", signatureInfo.getNonce());
assertEquals("sha1", signatureInfo.getAlgorithm());
}
}

View File

@ -0,0 +1,74 @@
package com.wecom.robot.dto.ai;
import com.wecom.robot.dto.InboundMessage;
import com.wecom.robot.dto.SignatureInfo;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ChatRequestTest {
@Test
void testChatRequestBuilder() {
ChatMessage msg1 = ChatMessage.userMessage("Hello");
ChatMessage msg2 = ChatMessage.assistantMessage("Hi there!");
Map<String, Object> metadata = new HashMap<>();
metadata.put("key", "value");
ChatRequest request = ChatRequest.builder()
.sessionId("session-123")
.currentMessage("How are you?")
.channelType(InboundMessage.CHANNEL_WECHAT)
.history(Arrays.asList(msg1, msg2))
.metadata(metadata)
.build();
assertEquals("session-123", request.getSessionId());
assertEquals("How are you?", request.getCurrentMessage());
assertEquals(InboundMessage.CHANNEL_WECHAT, request.getChannelType());
assertEquals(2, request.getHistory().size());
assertEquals("value", request.getMetadata().get("key"));
}
@Test
void testFromInboundMessage() {
InboundMessage inbound = InboundMessage.builder()
.channelType(InboundMessage.CHANNEL_WECHAT)
.channelMessageId("msg-123")
.sessionKey("session-key-001")
.customerId("customer-001")
.kfId("kf-001")
.content("Hello AI")
.build();
ChatRequest request = ChatRequest.fromInboundMessage(inbound);
assertEquals("session-key-001", request.getSessionId());
assertEquals("Hello AI", request.getCurrentMessage());
assertEquals(InboundMessage.CHANNEL_WECHAT, request.getChannelType());
}
@Test
void testFromInboundMessageWithHistory() {
InboundMessage inbound = InboundMessage.builder()
.sessionKey("session-002")
.content("New message")
.channelType(InboundMessage.CHANNEL_DOUYIN)
.build();
ChatMessage history1 = ChatMessage.userMessage("Previous");
ChatMessage history2 = ChatMessage.assistantMessage("Response");
ChatRequest request = ChatRequest.fromInboundMessage(inbound, Arrays.asList(history1, history2));
assertEquals("session-002", request.getSessionId());
assertEquals("New message", request.getCurrentMessage());
assertEquals(InboundMessage.CHANNEL_DOUYIN, request.getChannelType());
assertEquals(2, request.getHistory().size());
}
}

View File

@ -0,0 +1,45 @@
package com.wecom.robot.dto.ai;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ChatResponseTest {
@Test
void testChatResponseBuilder() {
ChatResponse response = ChatResponse.builder()
.reply("This is a reply")
.confidence(0.95)
.shouldTransfer(false)
.transferReason(null)
.build();
assertEquals("This is a reply", response.getReply());
assertEquals(0.95, response.getConfidence());
assertFalse(response.getShouldTransfer());
assertNull(response.getTransferReason());
}
@Test
void testFallback() {
ChatResponse response = ChatResponse.fallback("Service unavailable");
assertEquals("Service unavailable", response.getReply());
assertEquals(0.0, response.getConfidence());
assertTrue(response.getShouldTransfer());
}
@Test
void testFallbackWithTransfer() {
ChatResponse response = ChatResponse.fallbackWithTransfer(
"Transferring to human agent",
"AI service timeout"
);
assertEquals("Transferring to human agent", response.getReply());
assertEquals(0.0, response.getConfidence());
assertTrue(response.getShouldTransfer());
assertEquals("AI service timeout", response.getTransferReason());
}
}

View File

@ -0,0 +1,112 @@
package com.wecom.robot.util;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class IdempotentHelperTest {
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
private IdempotentHelper idempotentHelper;
@BeforeEach
void setUp() {
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
idempotentHelper = new IdempotentHelper(redisTemplate);
}
@Test
void testProcessMessageIdempotent_FirstTime_ShouldProcess() {
String messageId = "msg-123";
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
.thenReturn(true);
boolean[] processed = {false};
boolean result = idempotentHelper.processMessageIdempotent(messageId, () -> processed[0] = true);
assertTrue(result);
assertTrue(processed[0]);
verify(valueOperations).setIfAbsent(eq("idempotent:msg-123"), eq("1"), eq(1L), eq(TimeUnit.HOURS));
}
@Test
void testProcessMessageIdempotent_Duplicate_ShouldSkip() {
String messageId = "msg-456";
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
.thenReturn(false);
boolean[] processed = {false};
boolean result = idempotentHelper.processMessageIdempotent(messageId, () -> processed[0] = true);
assertFalse(result);
assertFalse(processed[0]);
}
@Test
void testCheckAndSet_FirstTime_ShouldReturnTrue() {
String messageId = "msg-789";
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
.thenReturn(true);
boolean result = idempotentHelper.checkAndSet(messageId);
assertTrue(result);
verify(valueOperations).setIfAbsent(eq("idempotent:msg-789"), eq("1"), eq(1L), eq(TimeUnit.HOURS));
}
@Test
void testCheckAndSet_Duplicate_ShouldReturnFalse() {
String messageId = "msg-duplicate";
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
.thenReturn(false);
boolean result = idempotentHelper.checkAndSet(messageId);
assertFalse(result);
}
@Test
void testExists_KeyExists_ShouldReturnTrue() {
String messageId = "msg-exists";
when(redisTemplate.hasKey("idempotent:msg-exists")).thenReturn(true);
boolean result = idempotentHelper.exists(messageId);
assertTrue(result);
}
@Test
void testExists_KeyNotExists_ShouldReturnFalse() {
String messageId = "msg-notexists";
when(redisTemplate.hasKey("idempotent:msg-notexists")).thenReturn(false);
boolean result = idempotentHelper.exists(messageId);
assertFalse(result);
}
@Test
void testRemove_ShouldDeleteKey() {
String messageId = "msg-remove";
idempotentHelper.remove(messageId);
verify(redisTemplate).delete("idempotent:msg-remove");
}
}

329
企业微信mcp.txt Normal file
View File

@ -0,0 +1,329 @@
1. 项目背景与目标
1.1 背景
随着企业服务量的增长传统人工客服面临响应慢、人力成本高、无法7×24小时服务的问题。同时企业微信已成为企业与客户沟通的主要渠道。为了提升客户体验、降低客服成本需要构建一套AI优先、人机协同的智能客服系统使每个客服账号既能由AI自动接待又能在必要时无缝切换至人工服务。
1.2 目标
AI自动接待客户向指定客服账号发送消息时由AI大模型结合企业知识库自动生成回复。
人工无缝接管在特定条件下如AI无法回答、客户要求转人工系统自动将会话转给真人客服且客户无感知同一客服身份、聊天记录连续
统一客服身份所有消息AI或人工均以同一个客服账号的身份发出客户始终感觉与同一位“客服”对话。
提升效率:减少人工重复劳动,缩短客户等待时间,提高问题解决率。
2. 用户角色
角色 描述 核心需求
客户 通过企业微信与客服沟通的终端用户 快速获得准确答复;需要人工时能自然转接,不重复描述问题。
AI 服务 系统后端自动处理消息的模块 理解客户意图,调用知识库,生成合规回复;识别转人工场景。
人工客服 企业微信中的真人客服 查看AI对话历史快速介入会话使用工作台回复客户。
系统管理员 负责系统配置与维护 配置转人工规则、知识库、客服账号监控会话质量和AI效果。
3. 功能需求
3.1 客服账号管理
支持在企业微信后台创建多个客服账号(通过“微信客服”功能)。
系统能够获取所有客服账号列表及在线状态。
支持将客服账号与人工客服账号绑定(可选,用于后续绩效统计)。
3.2 消息接收与处理
系统需暴露一个公网可访问的回调接口,用于接收企业微信推送的客户消息。
对接收到的消息进行解密和验签(使用企业微信提供的加解密库)。
解析消息内容、客户ID、客服账号ID、消息ID、时间戳等字段。
将消息存入数据库,并触发后续处理流程。
3.3 AI 自动回复
根据会话当前状态AI处理中调用AI大模型生成回复。
若开启了企业知识库需先通过RAG检索相关文档作为上下文补充给大模型。
支持多种回复格式:文本、图片、图文链接等(根据业务需要)。
生成的回复内容需经过安全合规检查(如敏感词过滤)。
通过企业微信API以当前客服账号的身份发送消息给客户。
3.4 转人工策略
支持配置多种转人工触发条件(可组合):
关键词触发:客户消息包含“人工”、“转人工”、“投诉”等预设词。
AI置信度阈值AI生成的回复置信度低于设定值如0.6)。
多轮失败连续N轮对话AI未能解决问题如客户重复提问
会话时长/轮次:超过设定时长或消息轮次后自动转人工。
手动触发:客户在菜单点击“联系人工客服”(需企业微信菜单支持)。
触发转人工后系统立即停止AI自动回复将会话状态置为“待人工接入”并将会话推送到人工客服队列。
3.5 人工客服工作台
会话列表:显示待接入、进行中的会话,包含客户昵称、最新消息时间、转人工原因等。
聊天窗口:
展示当前客户与AI的完整历史聊天记录客户消息+AI回复
支持人工输入文本、选择快捷回复、发送图片/图文。
发送的消息仍以同一客服账号身份发出,客户无感知。
支持查看客户资料如姓名、企业、标签可从企业微信或CRM同步。
转接/结束会话:人工客服可将会话转给其他同事,或主动结束会话。
辅助功能:快捷回复库、知识库搜索、订单查询工具(可选)。
3.6 会话状态管理
会话的生命周期由以下状态机管理:
text
[初始] -> AI处理中
| 触发转人工条件
v
待人工接入
| 客服点击接入
v
人工处理中
| 客服结束会话/客户超时
v
已结束
系统需维护每个会话的当前状态,并根据状态决定消息路由:
AI处理中消息进入AI处理模块。
待人工接入:消息不处理,仅推送到工作台待接入列表,或暂存等待人工。
人工处理中:消息直接推送到工作台,由人工回复。
3.7 上下文传递
转人工时需将完整的对话历史包括客户消息、AI回复、AI检索到的知识片段传递给人工客服工作台。
人工客服接入后,工作台默认展示全部历史,确保人工快速了解前情。
若客户在人工处理期间继续发消息,工作台实时刷新显示新消息。
3.8 数据统计与监控
记录关键指标AI回复率、转人工率、人工平均响应时间、会话满意度等。
支持管理员查看每个客服账号的接待量、AI处理占比。
支持导出报表。
4. 非功能需求
需求分类 具体要求
性能 - AI回复平均耗时 < 2秒不含网络延迟
- 人工消息推送延迟 < 1秒
- 系统支持并发会话数 ≥ 1000
可用性 - 系统可用性 ≥ 99.9%
- 关键模块(消息接收、发送)支持多副本部署,无单点故障
安全性 - 企业微信消息加密传输,需正确实现加解密规范
- API调用需携带合法Token防止重放攻击
- 敏感数据(如客户手机号)在数据库加密存储
可扩展性 - AI模型可替换如支持不同大模型API
- 知识库支持动态更新,无需重启服务
- 人工工作台可对接企业现有CRM系统
合规性 - 符合企业微信开发者协议
- 消息内容需过滤违法违规信息
5. 技术架构
5.1 整体架构图
text
+-------------------+ +-------------------+ +-------------------+
| | | | | |
| 企业微信客户端 | <------> | 企业微信服务端 | <------> | 自建 AI 客服系统 |
| (客户/客服) | | (API & 回调) | | (核心后端) |
| | | | | |
+-------------------+ +-------------------+ +-------------------+
|
v
+------------------------+
| AI 服务模块 |
| - 大模型 API |
| - 知识库 (RAG) |
| - 转人工判断逻辑 |
+------------------------+
|
v
+------------------------+
| 人工客服工作台 |
| - Web 界面 |
| - 消息推送 (WebSocket) |
| - 快捷回复库 |
+------------------------+
5.2 核心模块说明
模块 职责 关键技术
消息接收服务 接收企业微信回调请求,验签解密,将消息写入消息队列 HTTP Server (Spring Boot/Node.js),企业微信加解密库
会话管理器 维护会话状态根据状态将消息路由给AI或推送到人工工作台 Redis (存储会话状态),消息队列 (Kafka/RabbitMQ)
AI 处理器 调用大模型生成回复,集成知识库检索,判断是否转人工 大模型 API (OpenAI/DeepSeek),向量数据库 (Milvus)LangChain
消息发送服务 通过企业微信 API 发送消息给客户 HTTP Client企业微信 Access Token 管理
人工工作台后端 提供 WebSocket 推送API 供工作台调用 WebSocketRESTful API
人工工作台前端 客服使用的界面,实时接收消息,发送回复 React/VueWebSocket 客户端
5.3 数据流转示例
客户发消息 → 企业微信回调 → 消息接收服务 → 存入数据库 → 消息放入队列。
会话管理器从队列消费消息,查询 Redis 中会话状态:
若为 AI 处理中,将消息发给 AI 处理器。
若为人工处理中,通过 WebSocket 推送给对应客服的工作台。
AI 处理器生成回复 → 判断是否转人工:
若不转,将回复内容发给消息发送服务,并最终发给客户。
若转更新会话状态为待人工接入并将会话信息存入人工队列同时将本次回复发给客户AI 完成最后一句话)。
人工客服登录工作台,看到待接入列表,点击接入 → 会话状态变为人工处理中,后续消息直接推送。
6. 接口定义
6.1 企业微信回调接口
URL: POST /wecom/callback
请求参数 (URL Query): msg_signature, timestamp, nonce, echostr (仅验证时)
请求体: 加密的 XML 格式消息
处理逻辑:
验证签名,解密消息体。
如果是普通消息,解析后存入数据库并推送到消息队列。
如果是事件(如客服接入),更新会话状态。
响应: 成功返回 "ok" 明文(需加密?实际上回调需返回明文 "ok" 或加密后的 "ok"?根据文档,接收消息时需返回加密后的 success 字符串。但简化起见,我们返回明文 "ok" 即可,但必须正确处理加解密。实际应使用官方 SDK 统一处理。)
6.2 发送消息接口(调用企业微信 API
企业微信 API: POST https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token=ACCESS_TOKEN
请求体示例:
json
{
"touser": "客户ID",
"open_kfid": "客服账号ID",
"msgtype": "text",
"text": {
"content": "AI 生成的回复内容"
}
}
注意事项:需要管理 Access Token 的获取与刷新,支持多客服账号。
6.3 人工工作台 API
WebSocket 连接: ws://your-domain/ws/cs/{客服ID},用于实时接收新消息和状态更新。
REST API:
GET /api/sessions?status=pending 获取待接入会话列表
POST /api/sessions/{sessionId}/accept 客服接入会话
POST /api/sessions/{sessionId}/message 人工发送消息
GET /api/sessions/{sessionId}/history 获取完整聊天记录
7. 数据模型
7.1 会话表 (session)
字段 类型 说明
session_id string 唯一会话ID通常由企业微信客服API生成
customer_id string 客户ID (企业微信的 external_userid)
kf_id string 客服账号ID ( open_kfid )
status enum 'AI' / 'PENDING' / 'MANUAL' / 'CLOSED'
created_at datetime 会话创建时间
updated_at datetime 最后更新时间
manual_cs_id string 当前处理的人工客服ID如果状态为MANUAL
metadata json 扩展信息,如转人工原因、客户标签等
7.2 消息表 (message)
字段 类型 说明
msg_id string 企业微信消息ID唯一
session_id string 所属会话ID
sender_type enum 'customer' / 'ai' / 'manual'
sender_id string 发送者标识客户ID、AI标识、客服ID
content text 消息内容
msg_type string 'text' / 'image' / 'link' 等
created_at datetime 消息时间
raw_data json 原始消息数据(用于调试)
7.3 客服账号表 (kf_account)
字段 类型 说明
kf_id string 客服账号ID
name string 客服昵称
avatar string 头像URL
status enum 'online' / 'offline'(人工客服在线状态)
bind_manual_id string 绑定的企业微信员工ID可选
7.4 转人工记录表 (transfer_log)
字段 类型 说明
id int 自增ID
session_id string 会话ID
trigger_reason string 触发原因(关键词/AI置信度/多轮失败等)
trigger_time datetime 触发时间
accepted_time datetime 客服接入时间(可选)
accepted_cs_id string 接入的客服ID可选
8. 人机切换流程(详细)
8.1 触发转人工
当满足任一转人工条件时,系统执行以下操作:
将会话状态从 AI 更新为 PENDING待接入
记录转人工原因到数据库。
如果此时 AI 正在生成回复,等待其完成并发送(保证回复连贯),然后禁止后续 AI 调用。
将会话信息推送到人工客服队列(可通过 WebSocket 通知所有在线客服有新会话待接入)。
8.2 人工客服接入
客服在工作台看到待接入会话,点击“接入”:
后端校验该会话状态仍为 PENDING将其更新为 MANUAL并记录接入的客服ID。
通过 WebSocket 通知该客服的工作台,打开聊天窗口并显示历史消息。
如有必要,可向客户发送一条欢迎语(但建议不发送,保持无感),或让客服主动发送第一条消息。
8.3 消息路由
会话状态为 MANUAL 后,所有客户新消息通过回调进入系统,会话管理器根据状态直接推送到对应客服的 WebSocket 连接上,不再经过 AI。
客服在工作台发送的消息,调用发送消息接口以客服账号身份发出。
8.4 会话结束
客服手动结束会话,或客户超过一定时间未回复(可配置),系统将会话状态置为 CLOSED。
结束后的会话不再接收消息(客户新消息将开启新会话)。
9. 验收标准
9.1 功能验收
客户发送消息AI 能在 3 秒内回复。
当客户输入“人工”时,会话自动转为待人工,并出现在客服工作台。
客服接入后,能看到完整聊天记录,发送消息客户能收到且显示为同一客服身份。
人工回复后,客户继续发消息,消息能正确推送到该客服的工作台。
支持图片消息接收与发送(至少可转发图片)。
转人工条件可在后台配置并实时生效。
9.2 性能验收
并发 500 个会话同时进行 AI 处理,系统 CPU 负载 < 70%。
消息从客户发出到 AI 回复平均耗时 < 2.5 秒(包含企业微信 API 延迟)。
WebSocket 推送延迟 < 500ms。
9.3 安全性验收
所有回调接口正确验签,非法请求被拒绝。
Access Token 不泄露,定期刷新。
数据库敏感信息加密。
10. 附录
10.1 参考文档
企业微信微信客服 API 文档
企业微信回调消息加解密
MCP 协议介绍(可选,供参考)
10.2 术语表
术语 解释
微信客服 企业微信提供的一种客服功能,支持通过 API 收发消息,身份为客服账号。
open_kfid 客服账号的唯一标识。
external_userid 企业微信客户的唯一标识。
RAG 检索增强生成,一种结合检索和大模型生成的技术。