Merge branch 'main' into setup-gate
PR Check (SDD Full Gate) / sdd-full-gate (pull_request) Failing after 2s
Details
PR Check (SDD Full Gate) / sdd-full-gate (pull_request) Failing after 2s
Details
This commit is contained in:
commit
14a5723d1c
|
|
@ -24,3 +24,5 @@
|
|||
hs_err_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