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:
commit
b9f678a203
|
|
@ -24,3 +24,5 @@
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
/target/
|
||||||
|
/.idea/
|
||||||
|
|
|
||||||
|
|
@ -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 Action(TASK-030: 创建 ChatRequest/ChatResponse DTO)。
|
||||||
|
4. 每完成一个子任务,更新本进度文档并提交 Git。
|
||||||
Binary file not shown.
|
|
@ -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>
|
||||||
|
|
@ -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 |
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: AI Service API
|
||||||
|
description: |
|
||||||
|
Python AI 服务接口契约。
|
||||||
|
|
||||||
|
本文件定义主框架对 AI 服务的接口需求(Consumer-First)。
|
||||||
|
由主框架作为调用方,Python AI 服务作为提供方实现。
|
||||||
|
version: 1.0.0
|
||||||
|
x-contract-level: L0
|
||||||
|
x-consumer: "java-main-framework"
|
||||||
|
x-provider: "python-ai-service"
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://ai-service:8080
|
||||||
|
description: AI 服务地址
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/ai/chat:
|
||||||
|
post:
|
||||||
|
operationId: generateReply
|
||||||
|
summary: 生成 AI 回复
|
||||||
|
description: |
|
||||||
|
根据用户消息和会话历史生成 AI 回复。
|
||||||
|
|
||||||
|
覆盖验收标准:
|
||||||
|
- AC-MCA-04: 主框架通过 HTTP POST 调用 AI 服务
|
||||||
|
- AC-MCA-05: 响应包含 reply、confidence、shouldTransfer 字段
|
||||||
|
- AC-MCA-06: AI 服务不可用时的降级处理(主框架侧实现)
|
||||||
|
- AC-MCA-07: 超时处理(主框架侧实现)
|
||||||
|
tags:
|
||||||
|
- AI Chat
|
||||||
|
x-requirements:
|
||||||
|
- AC-MCA-04
|
||||||
|
- AC-MCA-04-REQ
|
||||||
|
- AC-MCA-04-OPT
|
||||||
|
- AC-MCA-05
|
||||||
|
- AC-MCA-06
|
||||||
|
- AC-MCA-07
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatRequest'
|
||||||
|
example:
|
||||||
|
sessionId: "kf_001_wx123456_1708765432000"
|
||||||
|
currentMessage: "我想了解产品价格"
|
||||||
|
channelType: "wechat"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 成功生成回复
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChatResponse'
|
||||||
|
example:
|
||||||
|
reply: "您好,我们的产品价格根据套餐不同有所差异。"
|
||||||
|
confidence: 0.92
|
||||||
|
shouldTransfer: false
|
||||||
|
'400':
|
||||||
|
description: 请求参数错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 服务内部错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'503':
|
||||||
|
description: 服务不可用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/ai/health:
|
||||||
|
get:
|
||||||
|
operationId: healthCheck
|
||||||
|
summary: 健康检查
|
||||||
|
description: 检查 AI 服务是否正常运行
|
||||||
|
tags:
|
||||||
|
- Health
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 服务正常
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
'503':
|
||||||
|
description: 服务不健康
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ChatRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- sessionId
|
||||||
|
- currentMessage
|
||||||
|
- channelType
|
||||||
|
properties:
|
||||||
|
sessionId:
|
||||||
|
type: string
|
||||||
|
description: 会话ID(AC-MCA-04-REQ 必填)
|
||||||
|
currentMessage:
|
||||||
|
type: string
|
||||||
|
description: 当前用户消息(AC-MCA-04-REQ 必填)
|
||||||
|
channelType:
|
||||||
|
type: string
|
||||||
|
description: 渠道类型(AC-MCA-04-REQ 必填)
|
||||||
|
enum:
|
||||||
|
- wechat
|
||||||
|
- douyin
|
||||||
|
- jd
|
||||||
|
history:
|
||||||
|
type: array
|
||||||
|
description: 历史消息列表(AC-MCA-04-OPT 可选)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ChatMessage'
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
description: 扩展元数据(AC-MCA-04-OPT 可选)
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
ChatMessage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- role
|
||||||
|
- content
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- user
|
||||||
|
- assistant
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
ChatResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- reply
|
||||||
|
- confidence
|
||||||
|
- shouldTransfer
|
||||||
|
properties:
|
||||||
|
reply:
|
||||||
|
type: string
|
||||||
|
description: AI 回复内容(AC-MCA-05 必填)
|
||||||
|
confidence:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: 置信度评分 0.0-1.0(AC-MCA-05 必填)
|
||||||
|
shouldTransfer:
|
||||||
|
type: boolean
|
||||||
|
description: 是否建议转人工(AC-MCA-05 必填)
|
||||||
|
transferReason:
|
||||||
|
type: string
|
||||||
|
description: 转人工原因(可选)
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
description: 响应元数据(可选)
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: 错误代码
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 错误消息
|
||||||
|
details:
|
||||||
|
type: array
|
||||||
|
description: 详细错误信息(可选)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
@ -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 | 会话已关闭 |
|
||||||
|
|
@ -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 服务独立部署,主框架通过内网调用
|
||||||
|
|
@ -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% |
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, "服务器内部错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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='快捷回复表';
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 供工作台调用 WebSocket,RESTful API
|
||||||
|
人工工作台前端 客服使用的界面,实时接收消息,发送回复 React/Vue,WebSocket 客户端
|
||||||
|
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 检索增强生成,一种结合检索和大模型生成的技术。
|
||||||
Loading…
Reference in New Issue