Compare commits

..

32 Commits

Author SHA1 Message Date
MerCry 19062980bf 修改初始配置文件 适配gitea[AC-INIT] 2026-02-24 11:46:41 +08:00
MerCry 2388c71c54 docs: 更新进度文档-记录L2契约对齐 [AC-MCA-08][AC-MCA-12] 2026-02-24 11:43:44 +08:00
MerCry 0786e6a040 feat: L2契约对齐-添加validation注解和全局异常处理 [AC-MCA-08][AC-MCA-12] 2026-02-24 11:42:36 +08:00
MerCry f09f22f447 spec: 升级 openapi.provider.yaml 至 L2 契约等级 [AC-MCA-08][AC-MCA-12] 2026-02-24 11:31:30 +08:00
MerCry 84edbccb1b fix(TASK-031): 修复 Java 8 兼容性问题
- 将 Map.of() 替换为 HashMap

- 添加 HashMap import
2026-02-24 11:17:46 +08:00
MerCry 067c70f116 feat: 引入 Flyway 数据库迁移管理 [AC-INIT]
- 添加 flyway-core 和 flyway-mysql 依赖

- 将 init.sql 转换为 Flyway 迁移脚本 V1__init.sql

- 删除旧的 init.sql 和 V1__add_channel_type.sql

- 配置 application.yml Flyway 设置
2026-02-24 11:12:13 +08:00
MerCry 339dccde43 docs: 更新 progress 文档 - Phase 1 & Phase 4 已完成 2026-02-24 10:31:27 +08:00
MerCry 4b1fcf453f feat(MCA): TASK-033 删除旧 AiService 和 AiConfig
- 删除旧的 AiService 类

- 删除旧的 AiConfig 类

- 更新 MessageRouterServiceImpl 使用 AiServiceClient

- 更新 DebugController 移除 AiService 引用

- 无编译错误
2026-02-24 10:27:40 +08:00
MerCry 1fbdf4777a feat(MCA): TASK-031 实现 AiServiceClient [AC-MCA-04, AC-MCA-05]
- 创建 AiServiceClient 接口

- 创建 AiServiceClientImpl 实现类

- 使用 RestTemplate 调用 /ai/chat

- 配置 Resilience4j 熔断和超时
2026-02-24 10:23:17 +08:00
MerCry 56ffb522ac feat(MCA): TASK-030 定义 AI 服务 DTO [AC-MCA-04-REQ, AC-MCA-05]
- 创建 ChatRequest DTO

- 创建 ChatResponse DTO

- 创建 ChatMessage DTO

- 包含 InboundMessage 到 ChatRequest 的映射方法

- 单元测试覆盖
2026-02-24 10:20:31 +08:00
MerCry d3b696d9bb feat(MCA): TASK-005 消息幂等性工具类 [AC-MCA-11-IDEMPOTENT]
- 创建 IdempotentHelper 工具类

- 使用 Redis SETNX 实现

- TTL 1 小时

- 单元测试覆盖
2026-02-24 10:18:34 +08:00
MerCry 6da295d571 feat(MCA): TASK-004 添加 Resilience4j 依赖 [AC-MCA-06, AC-MCA-07]
- 添加 resilience4j-spring-boot2 2.1.0

- 添加 resilience4j-timelimiter 2.1.0

- 项目可正常构建
2026-02-24 10:16:35 +08:00
MerCry ed730cb9f6 feat(MCA): TASK-003 数据库 Schema 变更 [AC-MCA-11]
- Session 表新增 channel_type 字段

- 默认值为 wechat

- 创建迁移脚本 V1__add_channel_type.sql
2026-02-24 10:15:16 +08:00
MerCry 4c0ef55e75 docs(MCA): complete Phase 3 progress update 2026-02-24 01:32:04 +08:00
MerCry 2925fc1150 docs(MCA): 更新进度文档 - Phase 2 完成 2026-02-24 01:31:36 +08:00
MerCry a8d7474338 feat(MCA): add channelType support to Session and SessionManagerService [TASK-023] [AC-MCA-11] [AC-MCA-12] 2026-02-24 01:30:56 +08:00
MerCry 07561fee16 feat(MCA): TASK-013 重构 WecomCallbackController [AC-MCA-08]
- Controller 负责验签/解密/解析

- 构建 InboundMessage 传递给 MessageRouterService

- MessageProcessService 使用统一消息模型
2026-02-24 01:29:58 +08:00
MerCry db378afd6d refactor(MCA): integrate MessageRouterService into MessageProcessService [TASK-022] [AC-MCA-08] 2026-02-24 01:28:27 +08:00
MerCry f1e5c931bc docs(MCA): update progress for TASK-021 completion 2026-02-24 01:26:27 +08:00
MerCry 0b6fcf56a7 feat(MCA): implement MessageRouterServiceImpl [TASK-021] [AC-MCA-08] [AC-MCA-09] [AC-MCA-10] 2026-02-24 01:25:00 +08:00
MerCry 48c70eb239 docs(MCA): mark TASK-020 as completed in tasks.md 2026-02-24 01:05:15 +08:00
MerCry 2631c53371 docs(MCA): 更新进度文档 - TASK-010 完成 2026-02-24 01:04:59 +08:00
MerCry eb94eca920 docs(MCA): update progress for TASK-020 completion 2026-02-24 01:04:25 +08:00
MerCry 4e9c5ba2eb feat(MCA): TASK-010 定义 ChannelAdapter 接口 [AC-MCA-01]
- 创建 ChannelAdapter 核心能力接口

- 创建 ServiceStateCapable 可选能力接口

- 创建 TransferCapable 可选能力接口

- 创建 MessageSyncCapable 可选能力接口

接口定义与 design.md 3.1 一致,sendMessage 使用 OutboundMessage 参数
2026-02-24 01:03:06 +08:00
MerCry b9792c8673 feat(MCA): define MessageRouterService interface [TASK-020] [AC-MCA-08] 2026-02-24 01:02:35 +08:00
MerCry bbfffec1f0 docs: 添加 MCA 进度文档 [AC-INIT] 2026-02-24 00:57:55 +08:00
MerCry 39f04494e2 docs: 添加多渠道适配主框架任务清单 (tasks.md) [AC-INIT] 2026-02-24 00:55:41 +08:00
MerCry 872f0a5d75 docs: 添加多渠道适配主框架架构设计 (design.md) [AC-MCA-01] 2026-02-24 00:51:29 +08:00
MerCry d8bf3a1e7a docs: 添加主框架对外 API 契约 (openapi.provider.yaml) [AC-MCA-08] 2026-02-24 00:42:03 +08:00
MerCry 502142acb2 docs: 添加 Python AI 服务接口契约 (openapi.deps.yaml) [AC-MCA-04] 2026-02-24 00:32:52 +08:00
MerCry cf9cd75f13 docs: 添加多渠道适配主框架需求规范 [AC-INIT] 2026-02-24 00:27:13 +08:00
MerCry 874901c8dd chore: stop tracking .idea 2026-02-24 00:05:12 +08:00
55 changed files with 4503 additions and 461 deletions

4
.gitignore vendored
View File

@ -1,5 +1,3 @@
/target/
/.idea/
# ---> Java
# Compiled class file
*.class
@ -26,3 +24,5 @@
hs_err_pid*
replay_pid*
/target/
/.idea/

8
.idea/.gitignore vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="wecom-robot" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="wecom-robot" options="-parameters" />
</option>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
</component>
</project>

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

View File

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

23
pom.xml
View File

@ -24,6 +24,7 @@
<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>
@ -91,6 +92,28 @@
<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>

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

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

View File

@ -0,0 +1,188 @@
openapi: 3.0.3
info:
title: AI Service API
description: |
Python AI 服务接口契约。
本文件定义主框架对 AI 服务的接口需求Consumer-First
由主框架作为调用方Python AI 服务作为提供方实现。
version: 1.0.0
x-contract-level: L0
x-consumer: "java-main-framework"
x-provider: "python-ai-service"
servers:
- url: http://ai-service:8080
description: AI 服务地址
paths:
/ai/chat:
post:
operationId: generateReply
summary: 生成 AI 回复
description: |
根据用户消息和会话历史生成 AI 回复。
覆盖验收标准:
- AC-MCA-04: 主框架通过 HTTP POST 调用 AI 服务
- AC-MCA-05: 响应包含 reply、confidence、shouldTransfer 字段
- AC-MCA-06: AI 服务不可用时的降级处理(主框架侧实现)
- AC-MCA-07: 超时处理(主框架侧实现)
tags:
- AI Chat
x-requirements:
- AC-MCA-04
- AC-MCA-04-REQ
- AC-MCA-04-OPT
- AC-MCA-05
- AC-MCA-06
- AC-MCA-07
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChatRequest'
example:
sessionId: "kf_001_wx123456_1708765432000"
currentMessage: "我想了解产品价格"
channelType: "wechat"
responses:
'200':
description: 成功生成回复
content:
application/json:
schema:
$ref: '#/components/schemas/ChatResponse'
example:
reply: "您好,我们的产品价格根据套餐不同有所差异。"
confidence: 0.92
shouldTransfer: false
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: 服务内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'503':
description: 服务不可用
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/ai/health:
get:
operationId: healthCheck
summary: 健康检查
description: 检查 AI 服务是否正常运行
tags:
- Health
responses:
'200':
description: 服务正常
content:
application/json:
schema:
type: object
properties:
status:
type: string
'503':
description: 服务不健康
components:
schemas:
ChatRequest:
type: object
required:
- sessionId
- currentMessage
- channelType
properties:
sessionId:
type: string
description: 会话IDAC-MCA-04-REQ 必填)
currentMessage:
type: string
description: 当前用户消息AC-MCA-04-REQ 必填)
channelType:
type: string
description: 渠道类型AC-MCA-04-REQ 必填)
enum:
- wechat
- douyin
- jd
history:
type: array
description: 历史消息列表AC-MCA-04-OPT 可选)
items:
$ref: '#/components/schemas/ChatMessage'
metadata:
type: object
description: 扩展元数据AC-MCA-04-OPT 可选)
additionalProperties: true
ChatMessage:
type: object
required:
- role
- content
properties:
role:
type: string
enum:
- user
- assistant
content:
type: string
ChatResponse:
type: object
required:
- reply
- confidence
- shouldTransfer
properties:
reply:
type: string
description: AI 回复内容AC-MCA-05 必填)
confidence:
type: number
format: double
description: 置信度评分 0.0-1.0AC-MCA-05 必填)
shouldTransfer:
type: boolean
description: 是否建议转人工AC-MCA-05 必填)
transferReason:
type: string
description: 转人工原因(可选)
metadata:
type: object
description: 响应元数据(可选)
additionalProperties: true
ErrorResponse:
type: object
required:
- code
- message
properties:
code:
type: string
description: 错误代码
message:
type: string
description: 错误消息
details:
type: array
description: 详细错误信息(可选)
items:
type: object
additionalProperties: true

View File

@ -0,0 +1,894 @@
openapi: 3.0.3
info:
title: Multi-Channel Customer Service API
description: |
多渠道客服主框架对外提供的 API 契约。
本文件定义主框架对外提供的能力Provider
- 渠道消息回调接口(微信、抖音、京东等)
- 人工客服工作台 REST API
- WebSocket 实时通信协议说明
version: 1.0.0
x-contract-level: L2
x-consumer: "frontend, wechat-server, douyin-server, jd-server"
x-provider: "java-main-framework"
servers:
- url: http://{host}:{port}
description: |
服务地址占位符,根据环境替换:
- 开发环境: http://localhost:8080
- 测试环境: http://ai-robot-test:8080
- 生产环境: http://ai-robot:8080
variables:
host:
default: localhost
description: 服务主机名
port:
default: "8080"
description: 服务端口
tags:
- name: Channel Callback
description: 渠道消息回调接口
- name: Session Management
description: 会话管理接口
- name: WebSocket
description: WebSocket 实时通信
paths:
/wecom/callback:
get:
operationId: verifyWecomUrl
summary: 微信回调 URL 验证
description: |
企业微信回调 URL 验证接口。
用于验证回调 URL 的有效性,企业微信在配置回调时会发送 GET 请求。
tags:
- Channel Callback
parameters:
- name: msg_signature
in: query
required: true
schema:
type: string
minLength: 1
maxLength: 128
description: 消息签名,用于验证请求来源
- name: timestamp
in: query
required: true
schema:
type: string
pattern: '^\d+$'
description: 时间戳(秒级),用于防重放攻击
- name: nonce
in: query
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 随机字符串,用于防重放攻击
- name: echostr
in: query
required: true
schema:
type: string
minLength: 1
description: 加密的随机字符串,验证成功后需解密返回
responses:
'200':
description: 验证成功,返回解密后的 echostr
content:
text/plain:
schema:
type: string
example: "1234567890"
'400':
description: 请求参数错误
content:
text/plain:
schema:
type: string
enum:
- error
'401':
description: 签名验证失败
content:
text/plain:
schema:
type: string
enum:
- error
'500':
description: 服务器内部错误
content:
text/plain:
schema:
type: string
enum:
- error
post:
operationId: handleWecomCallback
summary: 微信回调消息处理
description: |
企业微信回调消息处理入口。
覆盖验收标准:
- AC-MCA-08: 根据渠道类型路由到对应的渠道适配器
消息处理流程:
1. 接收加密的 XML 消息
2. 解密并解析消息内容
3. 根据消息类型路由处理
4. 返回 success 确认
tags:
- Channel Callback
x-requirements:
- AC-MCA-08
parameters:
- name: msg_signature
in: query
required: false
schema:
type: string
maxLength: 128
description: 消息签名(用于验签)
- name: timestamp
in: query
required: false
schema:
type: string
pattern: '^\d+$'
description: 时间戳(用于防重放)
- name: nonce
in: query
required: false
schema:
type: string
maxLength: 64
description: 随机数(用于防重放)
requestBody:
required: true
content:
application/xml:
schema:
type: string
description: 加密的 XML 消息
example: "<xml><Encrypt>...</Encrypt></xml>"
responses:
'200':
description: 处理成功
content:
text/plain:
schema:
type: string
enum:
- success
'400':
description: 请求格式错误
content:
text/plain:
schema:
type: string
enum:
- success
'401':
description: 签名验证失败
content:
text/plain:
schema:
type: string
enum:
- success
'500':
description: 服务器内部错误(仍返回 success 以避免微信重试)
content:
text/plain:
schema:
type: string
enum:
- success
/channel/{channelType}/callback:
post:
operationId: handleChannelCallback
summary: 通用渠道回调接口(预留)
description: |
通用渠道消息回调接口,用于接入新渠道。
当前为预留接口,后续实现抖音、京东等渠道时使用。
### 鉴权/签名机制(各渠道实现时需补充)
不同渠道需要不同的验签方式,建议通过以下方式传递:
**方式一Header 传递**
- `X-Signature`: 消息签名
- `X-Timestamp`: 时间戳(防重放)
- `X-Nonce`: 随机数(防重放)
**方式二Query 参数传递**
- `signature`: 消息签名
- `timestamp`: 时间戳
- `nonce`: 随机数
**方式三Body 内嵌**
- requestBody 中包含 `rawPayload` + `signature` 字段
具体签名算法HMAC-SHA256、RSA 等)由各渠道适配器实现时确定。
tags:
- Channel Callback
parameters:
- name: channelType
in: path
required: true
schema:
type: string
enum:
- wechat
- douyin
- jd
description: 渠道类型
- name: X-Signature
in: header
required: false
schema:
type: string
maxLength: 256
description: 消息签名(可选,具体由渠道决定)
- name: X-Timestamp
in: header
required: false
schema:
type: string
pattern: '^\d+$'
description: 时间戳(可选,用于防重放)
- name: X-Nonce
in: header
required: false
schema:
type: string
maxLength: 64
description: 随机数(可选,用于防重放)
requestBody:
required: true
content:
application/json:
schema:
type: object
description: 渠道消息体(格式由各渠道定义)
responses:
'200':
description: 处理成功
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'400':
description: 请求格式错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'401':
description: 签名验证失败
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'404':
description: 不支持的渠道类型
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions:
get:
operationId: getSessions
summary: 获取会话列表
description: |
获取客服工作台的会话列表。
覆盖验收标准:
- AC-MCA-12: 支持按渠道类型筛选
tags:
- Session Management
x-requirements:
- AC-MCA-12
parameters:
- name: status
in: query
required: false
schema:
type: string
enum:
- ai
- pending
- manual
- closed
description: 会话状态筛选
- name: csId
in: query
required: false
schema:
type: string
maxLength: 64
description: 客服ID筛选
- name: channelType
in: query
required: false
schema:
type: string
enum:
- wechat
- douyin
- jd
description: 渠道类型筛选
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/SessionListResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions/{sessionId}:
get:
operationId: getSession
summary: 获取会话详情
description: |
获取指定会话的详细信息。
tags:
- Session Management
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 会话ID
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'404':
description: 会话不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions/{sessionId}/history:
get:
operationId: getSessionHistory
summary: 获取会话消息历史
description: |
获取指定会话的消息历史记录。
tags:
- Session Management
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 会话ID
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/MessageListResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'404':
description: 会话不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions/{sessionId}/accept:
post:
operationId: acceptSession
summary: 接入会话
description: |
客服接入待处理的会话。
仅状态为 `pending` 的会话可被接入。
tags:
- Session Management
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 会话ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- csId
properties:
csId:
type: string
minLength: 1
maxLength: 64
description: 客服ID
example:
csId: "cs_001"
responses:
'200':
description: 接入成功
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'400':
description: 会话状态不正确或参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
examples:
invalid_status:
value:
code: 400
message: "会话状态不正确"
missing_csId:
value:
code: 400
message: "客服ID不能为空"
'404':
description: 会话不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
example:
code: 404
message: "会话不存在"
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions/{sessionId}/message:
post:
operationId: sendSessionMessage
summary: 发送消息
description: |
客服向会话发送消息。
仅状态为 `manual` 的会话可发送消息。
tags:
- Session Management
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 会话ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- content
properties:
content:
type: string
minLength: 1
maxLength: 4096
description: 消息内容
msgType:
type: string
enum:
- text
- image
- file
default: text
description: 消息类型
example:
content: "您好,请问有什么可以帮助您的?"
msgType: "text"
responses:
'200':
description: 发送成功
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'400':
description: 会话状态不正确或参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
examples:
invalid_status:
value:
code: 400
message: "会话状态不正确"
missing_content:
value:
code: 400
message: "消息内容不能为空"
'404':
description: 会话不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误或消息发送失败
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
/api/sessions/{sessionId}/close:
post:
operationId: closeSession
summary: 关闭会话
description: |
关闭指定的会话。
tags:
- Session Management
parameters:
- name: sessionId
in: path
required: true
schema:
type: string
minLength: 1
maxLength: 64
description: 会话ID
responses:
'200':
description: 关闭成功
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'404':
description: 会话不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'500':
description: 服务器内部错误
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
components:
schemas:
ApiResponse:
type: object
required:
- code
- message
properties:
code:
type: integer
description: 响应码0=成功非0=失败)
enum:
- 0
- 400
- 404
- 500
message:
type: string
minLength: 1
maxLength: 256
description: 响应消息
data:
type: object
description: 响应数据(可选)
example:
code: 0
message: "success"
SessionListResponse:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
required:
- code
- message
- data
properties:
code:
type: integer
enum:
- 0
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/SessionInfo'
example:
code: 0
message: "success"
data:
- sessionId: "session_001"
customerId: "customer_001"
status: "manual"
channelType: "wechat"
SessionResponse:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
required:
- code
- message
- data
properties:
code:
type: integer
enum:
- 0
message:
type: string
data:
$ref: '#/components/schemas/SessionInfo'
SessionInfo:
type: object
required:
- sessionId
- customerId
- status
properties:
sessionId:
type: string
minLength: 1
maxLength: 64
description: 会话ID
customerId:
type: string
minLength: 1
maxLength: 64
description: 客户ID
kfId:
type: string
maxLength: 64
description: 客服账号ID
channelType:
type: string
description: 渠道类型
enum:
- wechat
- douyin
- jd
status:
type: string
description: 会话状态
enum:
- ai
- pending
- manual
- closed
manualCsId:
type: string
maxLength: 64
description: 接待客服ID
lastMessage:
type: string
maxLength: 4096
description: 最后一条消息
lastMessageTime:
type: string
format: date-time
description: 最后消息时间
messageCount:
type: integer
minimum: 0
description: 消息数量
createdAt:
type: string
format: date-time
description: 创建时间
updatedAt:
type: string
format: date-time
description: 更新时间
metadata:
type: object
description: 扩展元数据
MessageListResponse:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
required:
- code
- message
- data
properties:
code:
type: integer
enum:
- 0
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/MessageInfo'
MessageInfo:
type: object
required:
- msgId
- sessionId
- senderType
- content
properties:
msgId:
type: string
minLength: 1
maxLength: 128
description: 消息ID
sessionId:
type: string
minLength: 1
maxLength: 64
description: 会话ID
senderType:
type: string
description: 发送者类型
enum:
- customer
- ai
- manual
senderId:
type: string
maxLength: 64
description: 发送者ID
content:
type: string
minLength: 1
maxLength: 4096
description: 消息内容
msgType:
type: string
description: 消息类型
enum:
- text
- image
- file
- event
createdAt:
type: string
format: date-time
description: 创建时间
x-websocket:
path: /ws/cs/{csId}
description: |
## WebSocket 实时通信协议
客服工作台通过 WebSocket 接收实时消息推送。
覆盖验收标准:
- AC-MCA-10: 会话状态为 MANUAL 时推送消息到人工客服工作台
### 连接地址
```
ws://{host}:{port}/ws/cs/{csId}
```
### 认证/鉴权方式
- **路径参数**: `{csId}` 客服ID用于标识连接身份
- **可选增强**: 后续可增加 Token 验证Query 参数或 Header
- Query: `?token=xxx`
- Header: `Authorization: Bearer xxx`
### 客户端发送消息格式
```json
{
"type": "bind_session",
"sessionId": "会话ID"
}
```
### 服务端推送消息格式
```json
{
"type": "new_message",
"sessionId": "会话ID",
"data": {
"msgId": "消息ID",
"content": "消息内容",
"senderType": "customer",
"senderId": "客户ID",
"msgType": "text",
"createdAt": "2026-02-24T10:00:00"
}
}
```
### 推送事件类型
| type | 说明 |
|------|------|
| new_message | 新消息通知 |
| new_pending_session | 新待接入会话 |
| session_accepted | 会话被接入 |
| session_closed | 会话已关闭 |

View File

@ -0,0 +1,189 @@
---
feature_id: "MCA"
title: "多渠道适配主框架架构改造"
status: "draft"
version: "0.2.0"
owners:
- "backend"
- "architect"
last_updated: "2026-02-24"
source:
type: "conversation"
ref: "架构改造需求"
---
# 多渠道适配主框架架构改造MCA
## 1. 背景与目标
### 1.1 背景
当前系统为"企业微信智能客服系统",核心逻辑围绕企业微信客服 API 构建:
- 微信消息接收、加解密、同步
- AI 回复生成(紧耦合在 Java 主应用中)
- 会话状态管理、转人工逻辑
- 人工客服工作台WebSocket
随着业务扩展,需要接入更多渠道(抖音、京东等),同时 AI 服务需要独立演进支持多模型、Prompt 工程、RAG 等)。当前架构存在以下问题:
1. **渠道耦合**AI 服务、消息处理与微信 API 紧密耦合,难以扩展新渠道
2. **AI 服务受限**Java 生态对 AI/LLM 支持不如 Python 丰富,迭代效率低
3. **职责不清**消息路由、AI 调用、状态管理混杂在同一服务中
### 1.2 目标
1. **多渠道适配**:抽象渠道适配层,支持 WeChat/Douyin/JD 等渠道的快速接入
2. **AI 服务剥离**:将 AI 服务剥离为独立 Python 服务,主框架通过 HTTP 调用
3. **职责清晰**主框架负责消息路由、会话管理、渠道适配AI 服务负责模型推理
### 1.3 非目标Out of Scope
- 本次改造不涉及前端界面重构
- 不涉及数据库迁移或数据模型重大变更
- 不涉及 AI 模型训练或微调
## 2. 模块边界Scope
### 2.1 覆盖
| 模块 | 说明 |
|-----|------|
| **渠道适配层** | 抽象 ChannelAdapter 接口,实现 WeChatAdapter预留 DouyinAdapter/JdAdapter 扩展点 |
| **消息路由层** | MessageProcessService 重构,支持多渠道消息分发 |
| **会话管理层** | SessionManagerService 保持不变,增加渠道类型字段 |
| **AI 服务客户端** | 新增 AiServiceClient通过 HTTP 调用 Python AI 服务 |
| **Python AI 服务** | 独立服务,提供 `/ai/chat` 等接口 |
| **配置管理** | 支持多渠道配置、AI 服务配置 |
### 2.2 不覆盖
| 模块 | 说明 |
|-----|------|
| **抖音/京东适配器实现** | 仅预留接口,后续迭代实现 |
| **人工客服工作台** | WebSocket 相关逻辑保持不变 |
| **数据库表结构** | 仅增加渠道类型字段,不进行大规模迁移 |
| **前端界面** | 不涉及 |
## 3. 依赖盘点Dependencies
### 3.1 本模块依赖的外部服务
| 依赖 | 用途 | 契约文件 |
|-----|------|---------|
| **Python AI 服务** | AI 回复生成、置信度评估 | `openapi.deps.yaml` |
| **企业微信 API** | 微信消息收发、会话状态管理 | 第三方 API |
| **Redis** | 会话状态缓存、Token 缓存 | 基础设施 |
| **MySQL** | 会话、消息持久化 | 基础设施 |
### 3.2 本模块对外提供的能力
| 能力 | 消费方 | 契约文件 |
|-----|-------|---------|
| **人工客服工作台 API** | 前端 | `openapi.provider.yaml` |
| **WebSocket 消息推送** | 前端 | `openapi.provider.yaml` |
## 4. 用户故事User Stories
### 4.1 渠道适配
- [US-MCA-01] 作为系统架构师,我希望主框架支持多渠道适配器接口,以便快速接入新渠道(抖音、京东等)。
### 4.2 AI 服务剥离
- [US-MCA-02] 作为 AI 工程师,我希望 AI 服务独立部署为 Python 服务,以便使用 Python 生态的 AI 框架和工具。
- [US-MCA-03] 作为后端开发者,我希望主框架通过 HTTP 调用 AI 服务,以便主框架与 AI 服务独立演进。
### 4.3 消息路由
- [US-MCA-04] 作为系统运维,我希望消息路由逻辑与渠道适配解耦,以便新增渠道时不影响核心路由逻辑。
### 4.4 会话管理
- [US-MCA-05] 作为系统运维,我希望会话管理支持多渠道标识,以便区分不同渠道的会话。
## 5. 验收标准Acceptance Criteria, EARS
### 5.1 渠道适配层
#### 5.1.1 核心能力接口(所有渠道必须实现)
- [AC-MCA-01] WHEN 定义 ChannelAdapter 核心接口 THEN 系统 SHALL 包含 `receiveMessage`(接收消息)、`sendMessage`(发送消息)、`getChannelType`(获取渠道类型)方法签名。
#### 5.1.2 可选能力接口(按渠道特性实现)
- [AC-MCA-01-OPT-01] WHEN 渠道支持服务状态管理 THEN 系统 SHALL 实现 `ServiceStateCapable` 接口,包含 `getServiceState`、`transServiceState` 方法。
- [AC-MCA-01-OPT-02] WHEN 渠道支持转人工 THEN 系统 SHALL 实现 `TransferCapable` 接口,包含 `transferToManual`、`transferToPool` 方法。
- [AC-MCA-01-OPT-03] WHEN 渠道支持消息同步 THEN 系统 SHALL 实现 `MessageSyncCapable` 接口,包含 `syncMessages` 方法。
> **设计说明**:可选能力接口的具体定义将在 `design.md` 中详细说明。主框架在运行时通过能力检测(如 `instanceof``Optional.ofNullable`)判断渠道是否支持某能力。
#### 5.1.3 适配器实现
- [AC-MCA-02] WHEN 实现 WeChatAdapter THEN 系统 SHALL 实现核心接口及所有可选能力接口,封装现有 WecomApiService 的所有功能。
- [AC-MCA-03] WHEN 新增渠道适配器 THEN 系统 SHALL 至少实现核心接口,可选能力按需实现,无需修改核心路由逻辑。
### 5.2 AI 服务剥离
#### 5.2.1 请求契约
- [AC-MCA-04] WHEN 主框架调用 AI 服务 THEN 系统 SHALL 通过 HTTP POST `/ai/chat` 接口获取 AI 回复。
- [AC-MCA-04-REQ] WHEN 构造 AI 服务请求 THEN 系统 SHALL 包含以下最小字段:`sessionId`会话ID、`currentMessage`(当前消息)、`channelType`(渠道类型)。
- [AC-MCA-04-OPT] WHEN 构造 AI 服务请求 THEN 系统 MAY 包含以下可选字段:`history`(历史消息)、`metadata`(扩展元数据)。
#### 5.2.2 响应契约
- [AC-MCA-05] WHEN AI 服务返回回复 THEN 系统 SHALL 包含 `reply`(回复内容)、`confidence`(置信度)、`shouldTransfer`(是否建议转人工)字段。
#### 5.2.3 容错处理
- [AC-MCA-06] WHEN AI 服务不可用 THEN 系统 SHALL 返回降级回复并记录错误日志,不影响消息接收流程。
- [AC-MCA-07] WHEN AI 服务响应超时 THEN 系统 SHALL 在配置的超时时间后返回降级回复。
### 5.3 消息路由
- [AC-MCA-08] WHEN 收到消息 THEN 系统 SHALL 根据渠道类型路由到对应的渠道适配器。
- [AC-MCA-09] WHEN 会话状态为 AI THEN 系统 SHALL 调用 AI 服务生成回复。
- [AC-MCA-10] WHEN 会话状态为 MANUAL THEN 系统 SHALL 推送消息到人工客服工作台。
### 5.4 消息幂等性
- [AC-MCA-11-IDEMPOTENT] WHEN 收到重复的 messageId THEN 系统 SHALL 幂等处理,不重复调用 AI 服务、不重复发送回复消息。
### 5.5 会话管理
- [AC-MCA-11] WHEN 创建会话 THEN 系统 SHALL 记录渠道类型channelType
- [AC-MCA-12] WHEN 查询会话 THEN 系统 SHALL 支持按渠道类型筛选。
### 5.6 兼容性
- [AC-MCA-13] WHEN 改造完成后 THEN 系统 SHALL 保持现有微信渠道功能完全兼容,无业务中断。
## 6. 追踪映射Traceability
| AC ID | Endpoint | 方法 | operationId | 备注 |
|-------|----------|------|-------------|------|
| AC-MCA-04 | /ai/chat | POST | generateReply | AI 服务接口deps |
| AC-MCA-04-REQ | /ai/chat | POST | generateReply | AI 请求最小字段 |
| AC-MCA-05 | /ai/chat | POST | generateReply | AI 服务响应格式 |
| AC-MCA-06 | /ai/chat | POST | generateReply | 降级处理 |
| AC-MCA-07 | /ai/chat | POST | generateReply | 超时处理 |
| AC-MCA-08 | /wecom/callback | POST | handleWecomCallback | **微信渠道** Provider Endpoint其它渠道后续补齐 |
| AC-MCA-09 | /ai/chat | POST | generateReply | AI 状态路由 |
| AC-MCA-10 | WebSocket | - | pushToManualCs | 人工状态路由 |
| AC-MCA-11-IDEMPOTENT | - | - | - | 幂等处理(内部逻辑,无对外接口) |
## 7. 风险与约束
### 7.1 技术风险
| 风险 | 影响 | 缓解措施 |
|-----|------|---------|
| AI 服务调用延迟 | 用户体验下降 | 设置合理超时、异步处理、降级策略 |
| 渠道 API 差异 | 适配器实现复杂 | 抽象公共接口、渠道特有能力单独处理 |
### 7.2 约束
- Java 版本1.8(不升级)
- Spring Boot 版本2.7.18(不升级)
- AI 服务通信协议HTTP REST非 gRPC
- 部署方式AI 服务独立部署,主框架通过内网调用

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

@ -0,0 +1,347 @@
---
feature_id: "MCA"
title: "多渠道适配主框架任务清单"
status: "draft"
version: "0.1.0"
owners:
- "backend"
last_updated: "2026-02-24"
---
# 多渠道适配主框架任务清单tasks.md
## 任务概览
| 阶段 | 任务数 | 说明 |
|-----|-------|------|
| Phase 1: 基础设施 | 5 | 统一消息模型、配置、数据库 |
| Phase 2: 渠道适配层 | 4 | ChannelAdapter 接口与 WeChatAdapter 重构 |
| Phase 3: 消息路由层 | 4 | MessageRouterService 重构 |
| Phase 4: AI 服务客户端 | 4 | AiServiceClient 实现 |
| Phase 5: 集成测试 | 3 | 端到端测试 |
---
## Phase 1: 基础设施
### TASK-001: 定义统一消息模型 DTO
- **状态**: ✅ 已完成
- **优先级**: P0
- **关联 AC**: AC-MCA-08
- **描述**: 创建 `InboundMessage`、`OutboundMessage`、`SignatureInfo` 等 DTO 类
- **产出物**:
- `src/main/java/com/wecom/robot/dto/InboundMessage.java`
- `src/main/java/com/wecom/robot/dto/OutboundMessage.java`
- `src/main/java/com/wecom/robot/dto/SignatureInfo.java`
- **验收标准**:
- [x] DTO 类包含 design.md 2.1/2.2 定义的所有字段
- [x] 包含 Lombok 注解 (@Data, @Builder)
- [x] 单元测试覆盖字段映射
### TASK-002: 新增配置类
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-04
- **描述**: 创建 AI 服务配置类和渠道配置类
- **产出物**:
- `src/main/java/com/wecom/robot/config/AiServiceConfig.java`
- `src/main/java/com/wecom/robot/config/ChannelConfig.java`
- `src/main/resources/application.yml` 更新
- **验收标准**:
- [ ] 配置类可正确读取 application.yml
- [ ] 包含默认值
### TASK-003: 数据库 Schema 变更
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-11
- **描述**: Session 表新增 channel_type 字段
- **产出物**:
- `src/main/resources/db/migration/V1__add_channel_type.sql` (如使用 Flyway)
- 或手动 DDL 脚本
- **验收标准**:
- [ ] DDL 可在线执行
- [ ] 默认值为 'wechat'
- [ ] Session 实体类同步更新
### TASK-004: 添加 Resilience4j 依赖
- **状态**: ⏳ 待开始
- **优先级**: P1
- **关联 AC**: AC-MCA-06, AC-MCA-07
- **描述**: 在 pom.xml 添加 Resilience4j 依赖
- **产出物**:
- `pom.xml` 更新
- **验收标准**:
- [ ] 依赖正确添加
- [ ] 项目可正常构建
### TASK-005: 消息幂等性工具类
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-11-IDEMPOTENT
- **描述**: 实现基于 Redis 的消息幂等性处理
- **产出物**:
- `src/main/java/com/wecom/robot/util/IdempotentHelper.java`
- **验收标准**:
- [ ] 使用 Redis SETNX 实现
- [ ] TTL 1 小时
- [ ] 单元测试覆盖
---
## Phase 2: 渠道适配层
### TASK-010: 定义 ChannelAdapter 接口
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-01
- **描述**: 创建核心能力接口和可选能力接口
- **产出物**:
- `src/main/java/com/wecom/robot/adapter/ChannelAdapter.java`
- `src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java`
- `src/main/java/com/wecom/robot/adapter/TransferCapable.java`
- `src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java`
- **验收标准**:
- [ ] 接口定义与 design.md 3.1 一致
- [ ] sendMessage 使用 OutboundMessage 参数
### TASK-011: 实现 WeChatAdapter
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-02
- **描述**: 将现有 WecomApiService 重构为 WeChatAdapter
- **产出物**:
- `src/main/java/com/wecom/robot/adapter/WeChatAdapter.java`
- **验收标准**:
- [ ] 实现 ChannelAdapter 核心接口
- [ ] 实现 ServiceStateCapable、TransferCapable、MessageSyncCapable
- [ ] 现有功能保持兼容
### TASK-012: 创建 ChannelAdapterFactory
- **状态**: ⏳ 待开始
- **优先级**: P1
- **关联 AC**: AC-MCA-03
- **描述**: 创建渠道适配器工厂,根据 channelType 获取对应适配器
- **产出物**:
- `src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java`
- **验收标准**:
- [ ] 支持 wechat 渠道
- [ ] 预留 douyin、jd 扩展点
### TASK-013: 重构 WecomCallbackController
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-08
- **描述**: Controller 负责验签/解密/解析,构建 InboundMessage
- **产出物**:
- `src/main/java/com/wecom/robot/controller/WecomCallbackController.java` 更新
- **验收标准**:
- [ ] 验签/解密逻辑保持不变
- [ ] 输出 InboundMessage 传递给 MessageRouterService
---
## Phase 3: 消息路由层
### TASK-020: 定义 MessageRouterService 接口
- **状态**: ✅ 已完成
- **优先级**: P0
- **关联 AC**: AC-MCA-08
- **描述**: 创建渠道无关的消息路由服务接口
- **产出物**:
- `src/main/java/com/wecom/robot/service/MessageRouterService.java`
- **验收标准**:
- [x] 接口定义与 design.md 3.2 一致
### TASK-021: 实现 MessageRouterServiceImpl
- **状态**: ✅ 已完成
- **优先级**: P0
- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10
- **描述**: 实现消息路由核心逻辑
- **产出物**:
- `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java`
- **验收标准**:
- [x] processInboundMessage 实现完整流程
- [x] routeBySessionState 根据状态路由
- [x] 幂等性检查
### TASK-022: 重构 MessageProcessService
- **状态**: ✅ 已完成
- **优先级**: P0
- **关联 AC**: AC-MCA-08
- **描述**: 将现有 MessageProcessService 逻辑迁移到 MessageRouterServiceImpl
- **产出物**:
- `src/main/java/com/wecom/robot/service/MessageProcessService.java` 更新或删除
- **验收标准**:
- [x] 现有功能保持兼容
- [x] 微信专属逻辑移至 WeChatAdapter
### TASK-023: 更新 SessionManagerService
- **状态**: ✅ 已完成
- **优先级**: P0
- **关联 AC**: AC-MCA-11, AC-MCA-12
- **描述**: 支持渠道类型字段
- **产出物**:
- `src/main/java/com/wecom/robot/service/SessionManagerService.java` 更新
- `src/main/java/com/wecom/robot/entity/Session.java` 更新
- **验收标准**:
- [x] 创建会话时记录 channelType
- [x] 支持按 channelType 筛选
---
## Phase 4: AI 服务客户端
### TASK-030: 定义 AI 服务 DTO
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-04-REQ, AC-MCA-05
- **描述**: 创建 ChatRequest、ChatResponse DTO
- **产出物**:
- `src/main/java/com/wecom/robot/dto/ai/ChatRequest.java`
- `src/main/java/com/wecom/robot/dto/ai/ChatResponse.java`
- **验收标准**:
- [ ] 字段与 openapi.deps.yaml 一致
- [ ] 包含映射方法 (InboundMessage → ChatRequest)
### TASK-031: 实现 AiServiceClient
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-04, AC-MCA-05
- **描述**: 实现 HTTP 调用 Python AI 服务
- **产出物**:
- `src/main/java/com/wecom/robot/service/AiServiceClient.java`
- `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java`
- **验收标准**:
- [ ] 使用 RestTemplate 调用 /ai/chat
- [ ] 超时 5 秒
- [ ] 正确映射字段
### TASK-032: 实现熔断与降级
- **状态**: ⏳ 待开始
- **优先级**: P1
- **关联 AC**: AC-MCA-06, AC-MCA-07
- **描述**: 使用 Resilience4j 实现熔断和降级
- **产出物**:
- `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java` 更新
- `src/main/resources/application.yml` 更新
- **验收标准**:
- [ ] @CircuitBreaker 注解配置
- [ ] @TimeLimiter 注解配置
- [ ] fallback 方法返回降级回复
### TASK-033: 删除旧 AiService
- **状态**: ⏳ 待开始
- **优先级**: P2
- **关联 AC**: -
- **描述**: 删除旧的 AiService 类,清理相关配置
- **产出物**:
- 删除 `src/main/java/com/wecom/robot/service/AiService.java`
- 删除 `src/main/java/com/wecom/robot/config/AiConfig.java`
- **验收标准**:
- [ ] 无编译错误
- [ ] 无运行时错误
---
## Phase 5: 集成测试
### TASK-040: 微信回调端到端测试
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10
- **描述**: 测试微信回调完整流程
- **产出物**:
- `src/test/java/com/wecom/robot/integration/WecomCallbackIntegrationTest.java`
- **验收标准**:
- [ ] 消息正确路由到 AI 服务
- [ ] 消息正确路由到人工客服
- [ ] 幂等性验证
### TASK-041: AI 服务调用测试
- **状态**: ⏳ 待开始
- **优先级**: P0
- **关联 AC**: AC-MCA-04, AC-MCA-05, AC-MCA-06, AC-MCA-07
- **描述**: 测试 AI 服务调用、超时、降级
- **产出物**:
- `src/test/java/com/wecom/robot/service/AiServiceClientTest.java`
- **验收标准**:
- [ ] 正常调用返回正确响应
- [ ] 超时触发降级
- [ ] 熔断触发降级
### TASK-042: 会话管理测试
- **状态**: ⏳ 待开始
- **优先级**: P1
- **关联 AC**: AC-MCA-11, AC-MCA-12
- **描述**: 测试会话创建、状态变更、渠道类型
- **产出物**:
- `src/test/java/com/wecom/robot/service/SessionManagerServiceTest.java`
- **验收标准**:
- [ ] 会话创建包含 channelType
- [ ] 支持按 channelType 筛选
---
## 待澄清事项
| ID | 问题 | 状态 | 备注 |
|----|------|------|------|
| CLARIFY-001 | AI 服务超时时间确认 | ✅ 已确认 | 5 秒 |
| CLARIFY-002 | 降级回复策略确认 | ✅ 已确认 | 返回固定回复 + 转人工 |
| CLARIFY-003 | 历史消息数量限制 | ✅ 已确认 | 50 条openapi.deps.yaml |
| CLARIFY-004 | 渠道扩展优先级 | ✅ 已确认 | WeChat → Douyin → JD |
| CLARIFY-005 | Python AI 服务部署方式 | ⏳ 待确认 | 独立进程 / Docker / K8s |
---
## 任务依赖关系
```
Phase 1 (基础设施)
├── TASK-001 (DTO) ─────────────────────────────────────────┐
├── TASK-002 (配置) ────────────────────────────────────────┤
├── TASK-003 (数据库) ──────────────────────────────────────┤
├── TASK-004 (Resilience4j) ──┐ │
└── TASK-005 (幂等性) ────────┤ │
│ │
Phase 2 (渠道适配层) │ │
│ │ │
├── TASK-010 (接口) ◄────────┼─────────────────────────────┤
├── TASK-011 (WeChatAdapter) ◄┘ │
├── TASK-012 (Factory) │
└── TASK-013 (Controller) ◄─────────────────────────────────┘
Phase 3 (消息路由层) │
│ │
├── TASK-020 (接口) ◄────────┘
├── TASK-021 (实现)
├── TASK-022 (重构)
└── TASK-023 (Session)
Phase 4 (AI 服务客户端) │
│ │
├── TASK-030 (DTO) ◄─────────┘
├── TASK-031 (实现)
├── TASK-032 (熔断)
└── TASK-033 (清理)
Phase 5 (集成测试) │
│ │
├── TASK-040 ◄───────────────┘
├── TASK-041
└── TASK-042
```
---
## 进度统计
| 指标 | 数值 |
|-----|------|
| 总任务数 | 20 |
| 已完成 | 4 |
| 进行中 | 0 |
| 待开始 | 16 |
| 完成率 | 20% |

View File

@ -0,0 +1,31 @@
package com.wecom.robot.adapter;
import com.wecom.robot.dto.OutboundMessage;
/**
* 渠道适配器核心能力接口
* <p>
* 所有渠道适配器必须实现此接口提供渠道类型标识和消息发送能力
* [AC-MCA-01] 渠道适配层核心接口
*
* @see ServiceStateCapable
* @see TransferCapable
* @see MessageSyncCapable
*/
public interface ChannelAdapter {
/**
* 获取渠道类型标识
*
* @return 渠道类型 "wechat", "douyin", "jd"
*/
String getChannelType();
/**
* 发送消息到渠道
*
* @param message 出站消息对象
* @return 发送是否成功
*/
boolean sendMessage(OutboundMessage message);
}

View File

@ -0,0 +1,115 @@
package com.wecom.robot.adapter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 渠道适配器工厂
* <p>
* 根据渠道类型获取对应的渠道适配器实例
* [AC-MCA-03] 渠道适配器工厂
*/
@Slf4j
@Component
public class ChannelAdapterFactory {
private final Map<String, ChannelAdapter> adapterMap;
public ChannelAdapterFactory(List<ChannelAdapter> adapters) {
this.adapterMap = adapters.stream()
.collect(Collectors.toMap(
ChannelAdapter::getChannelType,
Function.identity(),
(existing, replacement) -> existing
));
log.info("[AC-MCA-03] 已注册渠道适配器: {}", adapterMap.keySet());
}
/**
* 根据渠道类型获取适配器
*
* @param channelType 渠道类型 (wechat/douyin/jd)
* @return 渠道适配器实例
* @throws IllegalArgumentException 如果渠道类型不支持
*/
public ChannelAdapter getAdapter(String channelType) {
ChannelAdapter adapter = adapterMap.get(channelType);
if (adapter == null) {
log.error("[AC-MCA-03] 不支持的渠道类型: {}", channelType);
throw new IllegalArgumentException("不支持的渠道类型: " + channelType);
}
return adapter;
}
/**
* 检查渠道类型是否支持
*
* @param channelType 渠道类型
* @return 是否支持
*/
public boolean isSupported(String channelType) {
return adapterMap.containsKey(channelType);
}
/**
* 获取所有支持的渠道类型
*
* @return 渠道类型集合
*/
public java.util.Set<String> getSupportedChannelTypes() {
return adapterMap.keySet();
}
/**
* 获取适配器并检查是否支持指定能力
*
* @param channelType 渠道类型
* @param capabilityClass 能力接口类
* @param <T> 能力类型
* @return 能力实例如果不支持则返回 null
*/
public <T> T getAdapterWithCapability(String channelType, Class<T> capabilityClass) {
ChannelAdapter adapter = getAdapter(channelType);
if (capabilityClass.isInstance(adapter)) {
return capabilityClass.cast(adapter);
}
log.warn("[AC-MCA-03] 渠道 {} 不支持能力: {}", channelType, capabilityClass.getSimpleName());
return null;
}
/**
* 获取服务状态管理能力
*
* @param channelType 渠道类型
* @return ServiceStateCapable 实例如果不支持则返回 null
*/
public ServiceStateCapable getServiceStateCapable(String channelType) {
return getAdapterWithCapability(channelType, ServiceStateCapable.class);
}
/**
* 获取转人工能力
*
* @param channelType 渠道类型
* @return TransferCapable 实例如果不支持则返回 null
*/
public TransferCapable getTransferCapable(String channelType) {
return getAdapterWithCapability(channelType, TransferCapable.class);
}
/**
* 获取消息同步能力
*
* @param channelType 渠道类型
* @return MessageSyncCapable 实例如果不支持则返回 null
*/
public MessageSyncCapable getMessageSyncCapable(String channelType) {
return getAdapterWithCapability(channelType, MessageSyncCapable.class);
}
}

View File

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

View File

@ -0,0 +1,33 @@
package com.wecom.robot.adapter;
import com.wecom.robot.dto.ServiceStateResponse;
/**
* 服务状态管理能力接口可选
* <p>
* 提供渠道服务状态的获取和变更能力
* 渠道适配器可选择性实现此接口
* [AC-MCA-01] 渠道适配层可选能力接口
*/
public interface ServiceStateCapable {
/**
* 获取服务状态
*
* @param kfId 客服账号ID
* @param customerId 客户ID
* @return 服务状态响应
*/
ServiceStateResponse getServiceState(String kfId, String customerId);
/**
* 变更服务状态
*
* @param kfId 客服账号ID
* @param customerId 客户ID
* @param newState 新状态值
* @param servicerId 人工客服ID可选
* @return 变更是否成功
*/
boolean transServiceState(String kfId, String customerId, int newState, String servicerId);
}

View File

@ -0,0 +1,30 @@
package com.wecom.robot.adapter;
/**
* 转人工能力接口可选
* <p>
* 提供将客户转入待接入池或转给指定人工客服的能力
* 渠道适配器可选择性实现此接口
* [AC-MCA-01] 渠道适配层可选能力接口
*/
public interface TransferCapable {
/**
* 转入待接入池
*
* @param kfId 客服账号ID
* @param customerId 客户ID
* @return 转移是否成功
*/
boolean transferToPool(String kfId, String customerId);
/**
* 转给指定人工客服
*
* @param kfId 客服账号ID
* @param customerId 客户ID
* @param servicerId 人工客服ID
* @return 转移是否成功
*/
boolean transferToManual(String kfId, String customerId, String servicerId);
}

View File

@ -0,0 +1,275 @@
package com.wecom.robot.adapter;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.OutboundMessage;
import com.wecom.robot.dto.ServiceStateResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxSendMessageRequest;
import com.wecom.robot.dto.InboundMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.TimeUnit;
/**
* 企业微信渠道适配器
* <p>
* 实现企业微信渠道的消息发送服务状态管理转人工消息同步等能力
* [AC-MCA-02] 企业微信渠道适配器实现
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WeChatAdapter implements ChannelAdapter,
ServiceStateCapable, TransferCapable, MessageSyncCapable {
private static final String CHANNEL_TYPE = "wechat";
private static final String GET_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpId}&corpsecret={secret}";
private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={accessToken}";
private static final String SYNC_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={accessToken}";
private static final String GET_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/get?access_token={accessToken}";
private static final String TRANS_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/trans?access_token={accessToken}";
private static final String SEND_EVENT_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token={accessToken}";
private static final String REDIS_TOKEN_KEY = "wecom:access_token";
private static final String REDIS_TOKEN_LOCK_KEY = "wecom:access_token_lock";
private static final String REDIS_CURSOR_KEY_PREFIX = "wecom:cursor:";
private final WecomConfig wecomConfig;
private final StringRedisTemplate redisTemplate;
private final RestTemplate restTemplate = new RestTemplate();
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
@Override
public boolean sendMessage(OutboundMessage message) {
WxSendMessageRequest wxRequest = convertToWxRequest(message);
return sendWxMessage(wxRequest);
}
private WxSendMessageRequest convertToWxRequest(OutboundMessage message) {
String msgType = message.getMsgType();
if (msgType == null || msgType.isEmpty()) {
msgType = InboundMessage.MSG_TYPE_TEXT;
}
WxSendMessageRequest wxRequest = new WxSendMessageRequest();
wxRequest.setTouser(message.getReceiver());
wxRequest.setOpenKfid(message.getKfId());
wxRequest.setMsgtype(msgType);
switch (msgType) {
case InboundMessage.MSG_TYPE_TEXT:
default:
WxSendMessageRequest.TextContent textContent = new WxSendMessageRequest.TextContent();
textContent.setContent(message.getContent());
wxRequest.setText(textContent);
break;
}
return wxRequest;
}
private boolean sendWxMessage(WxSendMessageRequest request) {
String accessToken = getAccessToken();
String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("[AC-MCA-02] 发送消息失败: {}", json);
return false;
}
log.info("[AC-MCA-02] 消息发送成功: msgId={}", json.getString("msgid"));
return true;
}
public boolean sendTextMessage(String touser, String openKfid, String content) {
WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content);
return sendWxMessage(request);
}
@Override
public ServiceStateResponse getServiceState(String kfId, String customerId) {
String accessToken = getAccessToken();
String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", kfId);
body.put("external_userid", customerId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("[AC-MCA-02] 获取会话状态响应: {}", response.getBody());
return JSON.parseObject(response.getBody(), ServiceStateResponse.class);
}
@Override
public boolean transServiceState(String kfId, String customerId, int newState, String servicerId) {
String accessToken = getAccessToken();
String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", kfId);
body.put("external_userid", customerId);
body.put("service_state", newState);
if (servicerId != null && !servicerId.isEmpty()) {
body.put("servicer_userid", servicerId);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("[AC-MCA-02] 变更会话状态响应: {}", response.getBody());
JSONObject result = JSON.parseObject(response.getBody());
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
@Override
public boolean transferToPool(String kfId, String customerId) {
return transServiceState(kfId, customerId, ServiceStateResponse.STATE_POOL, null);
}
@Override
public boolean transferToManual(String kfId, String customerId, String servicerId) {
return transServiceState(kfId, customerId, ServiceStateResponse.STATE_MANUAL, servicerId);
}
@Override
public SyncMsgResponse syncMessages(String kfId, String cursor) {
String accessToken = getAccessToken();
String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken);
String savedCursor = cursor != null ? cursor : getCursor(kfId);
JSONObject body = new JSONObject();
body.put("open_kfid", kfId);
if (savedCursor != null && !savedCursor.isEmpty()) {
body.put("cursor", savedCursor);
}
body.put("limit", 1000);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("[AC-MCA-02] sync_msg响应: {}", response.getBody());
SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class);
if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) {
saveCursor(kfId, syncResponse.getNextCursor());
}
return syncResponse;
}
public boolean sendWelcomeMsg(String code, String content) {
String accessToken = getAccessToken();
String url = SEND_EVENT_MSG_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("code", code);
body.put("msgtype", "text");
JSONObject text = new JSONObject();
text.put("content", content);
body.put("text", text);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("[AC-MCA-02] 发送欢迎语失败: {}", json);
return false;
}
log.info("[AC-MCA-02] 发送欢迎语成功");
return true;
}
public boolean endSession(String kfId, String customerId) {
return transServiceState(kfId, customerId, ServiceStateResponse.STATE_CLOSED, null);
}
private String getAccessToken() {
String cachedToken = redisTemplate.opsForValue().get(REDIS_TOKEN_KEY);
if (cachedToken != null) {
return cachedToken;
}
Boolean locked = redisTemplate.opsForValue().setIfAbsent(REDIS_TOKEN_LOCK_KEY, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
String url = GET_ACCESS_TOKEN_URL
.replace("{corpId}", wecomConfig.getCorpId())
.replace("{secret}", wecomConfig.getSecret());
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("[AC-MCA-02] 获取access_token失败: {}", json);
throw new RuntimeException("获取access_token失败: " + json.getString("errmsg"));
}
String accessToken = json.getString("access_token");
long expiresIn = json.getLongValue("expires_in");
redisTemplate.opsForValue().set(REDIS_TOKEN_KEY, accessToken, expiresIn - 300, TimeUnit.SECONDS);
return accessToken;
} finally {
redisTemplate.delete(REDIS_TOKEN_LOCK_KEY);
}
} else {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getAccessToken();
}
}
private String getCursor(String openKfid) {
return redisTemplate.opsForValue().get(REDIS_CURSOR_KEY_PREFIX + openKfid);
}
private void saveCursor(String openKfid, String cursor) {
redisTemplate.opsForValue().set(REDIS_CURSOR_KEY_PREFIX + openKfid, cursor);
}
public void clearCursor(String openKfid) {
redisTemplate.delete(REDIS_CURSOR_KEY_PREFIX + openKfid);
}
}

View File

@ -1,30 +0,0 @@
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")
public class AiConfig {
private boolean enabled;
private String provider;
private DeepSeekConfig deepseek;
private OpenAiConfig openai;
@Data
public static class DeepSeekConfig {
private String apiKey;
private String baseUrl;
private String model;
}
@Data
public static class OpenAiConfig {
private String apiKey;
private String baseUrl;
private String model;
}
}

View File

@ -0,0 +1,15 @@
package com.wecom.robot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "ai-service")
public class AiServiceConfig {
private String url;
private int timeout = 5000;
}

View File

@ -0,0 +1,22 @@
package com.wecom.robot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Map;
@Data
@Component
@ConfigurationProperties(prefix = "channel")
public class ChannelConfig {
private String defaultChannel = "wechat";
private Map<String, AdapterConfig> adapters;
@Data
public static class AdapterConfig {
private boolean enabled;
}
}

View File

@ -0,0 +1,46 @@
package com.wecom.robot.config;
import com.wecom.robot.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return ApiResponse.error(400, message);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleConstraintViolationException(ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
log.warn("约束校验失败: {}", message);
return ApiResponse.error(400, message);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleException(Exception ex) {
log.error("服务器内部错误", ex);
return ApiResponse.error(500, "服务器内部错误");
}
}

View File

@ -0,0 +1,18 @@
package com.wecom.robot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
}

View File

@ -2,9 +2,7 @@ package com.wecom.robot.controller;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.dto.ChatCompletionRequest;
import com.wecom.robot.entity.Message;
import com.wecom.robot.service.AiService;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.util.WXBizMsgCrypt;
import lombok.RequiredArgsConstructor;
@ -23,7 +21,6 @@ import java.util.Map;
public class DebugController {
private final WecomConfig wecomConfig;
private final AiService aiService;
private final SessionManagerService sessionManagerService;
@GetMapping("/config")
@ -108,38 +105,13 @@ public class DebugController {
return ApiResponse.success(result);
}
@GetMapping("/ai/context")
public ApiResponse<Map<String, Object>> getLastAiContext() {
Map<String, Object> result = new HashMap<>();
result.put("systemPrompt", aiService.getSystemPrompt());
result.put("lastRequestJson", aiService.getLastRequestJson());
result.put("lastConfidence", aiService.getLastConfidence());
List<ChatCompletionRequest.Message> messages = aiService.getLastRequestMessages();
result.put("messageCount", messages.size());
List<Map<String, String>> messageList = new java.util.ArrayList<>();
for (ChatCompletionRequest.Message msg : messages) {
Map<String, String> msgMap = new HashMap<>();
msgMap.put("role", msg.getRole());
msgMap.put("content", msg.getContent());
messageList.add(msgMap);
}
result.put("messages", messageList);
return ApiResponse.success(result);
}
@GetMapping("/ai/session/{sessionId}/context")
public ApiResponse<Map<String, Object>> getSessionAiContext(
@PathVariable String sessionId,
@RequestParam(required = false, defaultValue = "测试消息") String testMessage) {
@PathVariable String sessionId) {
Map<String, Object> result = new HashMap<>();
result.put("sessionId", sessionId);
result.put("systemPrompt", aiService.getSystemPrompt());
List<Message> history = sessionManagerService.getSessionMessages(sessionId);
result.put("historyCount", history.size());
@ -156,18 +128,6 @@ public class DebugController {
}
result.put("history", historyList);
List<ChatCompletionRequest.Message> contextMessages = aiService.buildMessagesForTest(history, testMessage);
result.put("contextMessageCount", contextMessages.size());
List<Map<String, String>> contextList = new java.util.ArrayList<>();
for (ChatCompletionRequest.Message msg : contextMessages) {
Map<String, String> msgMap = new HashMap<>();
msgMap.put("role", msg.getRole());
msgMap.put("content", msg.getContent());
contextList.add(msgMap);
}
result.put("contextMessages", contextList);
return ApiResponse.success(result);
}

View File

@ -13,6 +13,7 @@ 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;
@ -31,7 +32,8 @@ public class SessionController {
@GetMapping
public ApiResponse<List<SessionInfo>> getSessions(
@RequestParam(required = false) String status,
@RequestParam(required = false) String csId) {
@RequestParam(required = false) String csId,
@RequestParam(required = false) String channelType) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
if (status != null) {
@ -42,6 +44,10 @@ public class SessionController {
query.eq(Session::getManualCsId, csId);
}
if (channelType != null) {
query.eq(Session::getChannelType, channelType);
}
query.orderByDesc(Session::getUpdatedAt);
List<Session> sessions = sessionMapper.selectList(query);
@ -51,6 +57,7 @@ public class SessionController {
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());
@ -87,6 +94,7 @@ public class SessionController {
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());
@ -120,12 +128,9 @@ public class SessionController {
}
@PostMapping("/{sessionId}/accept")
public ApiResponse<Void> acceptSession(@PathVariable String sessionId, @RequestBody AcceptSessionRequest request) {
String csId = request.getCsId();
if (csId == null || csId.isEmpty()) {
return ApiResponse.error(400, "客服ID不能为空");
}
public ApiResponse<Void> acceptSession(
@PathVariable String sessionId,
@Valid @RequestBody AcceptSessionRequest request) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
@ -135,14 +140,16 @@ public class SessionController {
return ApiResponse.error(400, "会话状态不正确");
}
sessionManagerService.acceptTransfer(sessionId, csId);
webSocketService.notifySessionAccepted(sessionId, csId);
sessionManagerService.acceptTransfer(sessionId, request.getCsId());
webSocketService.notifySessionAccepted(sessionId, request.getCsId());
return ApiResponse.success(null);
}
@PostMapping("/{sessionId}/message")
public ApiResponse<Void> sendMessage(@PathVariable String sessionId, @RequestBody SendMessageRequest request) {
public ApiResponse<Void> sendMessage(
@PathVariable String sessionId,
@Valid @RequestBody SendMessageRequest request) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
@ -159,7 +166,7 @@ public class SessionController {
);
if (!success) {
return ApiResponse.error("消息发送失败");
return ApiResponse.error(500, "消息发送失败");
}
sessionManagerService.saveMessage(

View File

@ -1,8 +1,11 @@
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.WxCallbackMessage;
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;
@ -11,14 +14,24 @@ 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(

View File

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

View File

@ -11,7 +11,7 @@ public class ApiResponse<T> {
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setCode(0);
response.setMessage("success");
response.setData(data);
return response;

View File

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

View File

@ -2,16 +2,31 @@ package com.wecom.robot.dto;
import lombok.Data;
import java.time.LocalDateTime;
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 LocalDateTime createdAt;
private java.time.LocalDateTime createdAt;
}

View File

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

View File

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

View File

@ -2,20 +2,43 @@ 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;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String metadata;
private int messageCount;
@Size(max = 4096)
private String lastMessage;
private LocalDateTime lastMessageTime;
private int messageCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String metadata;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,8 @@ public class Session implements Serializable {
private String kfId;
private String channelType;
private String status;
private Integer wxServiceState;
@ -39,4 +41,8 @@ public class Session implements Serializable {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_MANUAL = "MANUAL";
public static final String STATUS_CLOSED = "CLOSED";
public static final String CHANNEL_WECHAT = "wechat";
public static final String CHANNEL_DOUYIN = "douyin";
public static final String CHANNEL_JD = "jd";
}

View File

@ -1,155 +0,0 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.config.AiConfig;
import com.wecom.robot.dto.ChatCompletionRequest;
import com.wecom.robot.dto.ChatCompletionResponse;
import com.wecom.robot.entity.Message;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private static final String SYSTEM_PROMPT = "你是ash超脑的客服请用简洁、友好的语言回答客户的问题。" +
"如果不确定答案,请诚实告知客户。" +
"回答要准确、专业,避免过于冗长,要尽量拟人化口语化,自然回答问题。";
private final AiConfig aiConfig;
private final RestTemplate restTemplate = new RestTemplate();
private double lastConfidence = 1.0;
private List<ChatCompletionRequest.Message> lastRequestMessages = new ArrayList<>();
private String lastRequestJson = "";
public String generateReply(String userMessage, List<Message> history) {
if (!aiConfig.isEnabled()) {
return "AI服务暂未开启请联系人工客服。";
}
try {
String provider = aiConfig.getProvider();
String apiUrl;
String apiKey;
String model;
if ("deepseek".equalsIgnoreCase(provider)) {
apiUrl = aiConfig.getDeepseek().getBaseUrl() + "/chat/completions";
apiKey = aiConfig.getDeepseek().getApiKey();
model = aiConfig.getDeepseek().getModel();
} else {
apiUrl = aiConfig.getOpenai().getBaseUrl() + "/chat/completions";
apiKey = aiConfig.getOpenai().getApiKey();
model = aiConfig.getOpenai().getModel();
}
List<ChatCompletionRequest.Message> messages = buildMessages(history, userMessage);
ChatCompletionRequest request = ChatCompletionRequest.create(model, messages);
lastRequestMessages = messages;
lastRequestJson = JSON.toJSONString(request, true);
log.info("===== AI请求上下文 =====");
log.info("Provider: {}, Model: {}", provider, model);
log.info("API URL: {}", apiUrl);
log.info("消息数量: {}", messages.size());
for (int i = 0; i < messages.size(); i++) {
ChatCompletionRequest.Message msg = messages.get(i);
log.info("[{}] {}: {}", i, msg.getRole(), msg.getContent());
}
log.info("===== 完整请求JSON =====");
log.info("\n{}", lastRequestJson);
log.info("========================");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);
HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, entity, String.class);
log.info("===== AI响应 =====");
log.info("Status: {}", response.getStatusCode());
log.info("Body: {}", response.getBody());
log.info("==================");
ChatCompletionResponse completionResponse = JSON.parseObject(response.getBody(), ChatCompletionResponse.class);
String reply = completionResponse.getContent();
lastConfidence = calculateConfidence(reply);
log.info("AI回复生成成功: confidence={}, reply={}", lastConfidence, reply);
return reply;
} catch (Exception e) {
log.error("AI回复生成失败", e);
lastConfidence = 0.0;
return "抱歉,我暂时无法回答您的问题,正在为您转接人工客服...";
}
}
public double getLastConfidence() {
return lastConfidence;
}
public List<ChatCompletionRequest.Message> getLastRequestMessages() {
return lastRequestMessages;
}
public String getLastRequestJson() {
return lastRequestJson;
}
public String getSystemPrompt() {
return SYSTEM_PROMPT;
}
public List<ChatCompletionRequest.Message> buildMessagesForTest(List<Message> history, String currentMessage) {
return buildMessages(history, currentMessage);
}
private List<ChatCompletionRequest.Message> buildMessages(List<Message> history, String currentMessage) {
List<ChatCompletionRequest.Message> messages = new ArrayList<>();
messages.add(new ChatCompletionRequest.Message("system", SYSTEM_PROMPT));
int startIndex = Math.max(0, history.size() - 10);
for (int i = startIndex; i < history.size(); i++) {
Message msg = history.get(i);
String role = Message.SENDER_TYPE_CUSTOMER.equals(msg.getSenderType()) ? "user" : "assistant";
messages.add(new ChatCompletionRequest.Message(role, msg.getContent()));
}
messages.add(new ChatCompletionRequest.Message("user", currentMessage));
return messages;
}
private double calculateConfidence(String reply) {
if (reply == null || reply.isEmpty()) {
return 0.0;
}
if (reply.contains("不确定") || reply.contains("不清楚") || reply.contains("无法回答")) {
return 0.4;
}
if (reply.contains("转接人工") || reply.contains("人工客服")) {
return 0.5;
}
if (reply.length() < 10) {
return 0.6;
}
return 0.85;
}
}

View File

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

View File

@ -1,6 +1,9 @@
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;
@ -12,24 +15,34 @@ 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 AiService aiService;
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("处理客户消息事件: openKfId={}, token={}", openKfId, token);
log.info("[AC-MCA-08] 处理客户消息事件: openKfId={}, token={}", openKfId, token);
if (openKfId == null) {
log.warn("事件缺少openKfId");
@ -81,7 +94,7 @@ public class MessageProcessService {
String customerId = msgItem.getExternalUserId();
String kfId = msgItem.getOpenKfId();
log.info("处理消息项: msgId={}, origin={}, msgType={}, customerId={}",
log.info("[AC-MCA-08] 处理消息项: msgId={}, origin={}, msgType={}, customerId={}",
msgItem.getMsgId(), msgItem.getOrigin(), msgItem.getMsgType(), customerId);
if (msgItem.isEvent()) {
@ -99,21 +112,6 @@ public class MessageProcessService {
return;
}
String content = extractContent(msgItem);
String msgType = msgItem.getMsgType();
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
sessionManagerService.saveMessage(
msgItem.getMsgId(),
session.getSessionId(),
Message.SENDER_TYPE_CUSTOMER,
customerId,
content,
msgType,
msgItem.getOriginData()
);
ServiceStateResponse wxState = wecomApiService.getServiceState(kfId, customerId);
if (!wxState.isSuccess()) {
log.warn("获取微信会话状态失败: errcode={}, errmsg={}",
@ -121,9 +119,13 @@ public class MessageProcessService {
}
log.info("微信会话状态: {} ({})", wxState.getStateDesc(), wxState.getServiceState());
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
sessionManagerService.updateWxServiceState(session.getSessionId(), wxState.getServiceState());
processByWxState(session, customerId, kfId, content, msgType, wxState);
InboundMessage inboundMessage = buildInboundMessage(msgItem, customerId, kfId);
messageRouterService.processInboundMessage(inboundMessage);
}
private void processEventMessage(SyncMsgResponse.MsgItem msgItem) {
@ -186,7 +188,6 @@ public class MessageProcessService {
Integer changeType = event.getChangeType();
String newServicerUserid = event.getNewServicerUserid();
String oldServicerUserid = event.getOldServicerUserid();
String msgCode = event.getMsgCode();
log.info("会话状态变更: changeType={}, oldServicer={}, newServicer={}",
changeType, oldServicerUserid, newServicerUserid);
@ -215,97 +216,23 @@ public class MessageProcessService {
}
}
private void processByWxState(Session session, String customerId, String kfId,
String content, String msgType, ServiceStateResponse wxState) {
Integer state = wxState.getServiceState();
private InboundMessage buildInboundMessage(SyncMsgResponse.MsgItem msgItem,
String customerId, String kfId) {
String content = extractContent(msgItem);
String sessionKey = kfId + "_" + customerId;
if (state == null) {
state = ServiceStateResponse.STATE_UNTREATED;
}
switch (state) {
case ServiceStateResponse.STATE_UNTREATED:
case ServiceStateResponse.STATE_AI:
processAiMessage(session, customerId, kfId, content);
break;
case ServiceStateResponse.STATE_POOL:
notifyPendingSession(session, customerId, kfId, content, msgType);
break;
case ServiceStateResponse.STATE_MANUAL:
pushToManualCs(session, customerId, kfId, content, msgType, wxState.getServicerUserid());
break;
case ServiceStateResponse.STATE_CLOSED:
Session newSession = sessionManagerService.getOrCreateSession(customerId, kfId);
processAiMessage(newSession, customerId, kfId, content);
break;
default:
log.warn("未知的微信会话状态: {}", state);
processAiMessage(session, customerId, kfId, content);
}
}
private void processAiMessage(Session session, String customerId, String kfId, String content) {
List<Message> history = sessionManagerService.getSessionMessages(session.getSessionId());
String reply = aiService.generateReply(content, history);
double confidence = aiService.getLastConfidence();
int messageCount = sessionManagerService.getMessageCount(session.getSessionId());
boolean shouldTransfer = transferService.shouldTransferToManual(
content,
confidence,
messageCount,
session.getCreatedAt()
);
if (shouldTransfer) {
String reason = transferService.getTransferReason(content, confidence, messageCount);
sessionManagerService.transferToManual(session.getSessionId(), reason);
reply = reply + "\n\n正在为您转接人工客服请稍候...";
wecomApiService.sendTextMessage(customerId, kfId, reply);
boolean transferred = wecomApiService.transferToPool(kfId, customerId);
if (transferred) {
log.info("已将会话转入待接入池: customerId={}, kfId={}", customerId, kfId);
sessionManagerService.updateWxServiceState(session.getSessionId(), ServiceStateResponse.STATE_POOL);
}
webSocketService.notifyNewPendingSession(session.getSessionId());
} else {
wecomApiService.sendTextMessage(customerId, kfId, reply);
sessionManagerService.saveMessage(
"ai_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_AI,
"AI",
reply,
"text",
null
);
}
}
private void notifyPendingSession(Session session, String customerId, String kfId,
String content, String msgType) {
WxCallbackMessage notifyMsg = new WxCallbackMessage();
notifyMsg.setExternalUserId(customerId);
notifyMsg.setOpenKfId(kfId);
notifyMsg.setContent(content);
notifyMsg.setMsgType(msgType);
webSocketService.notifyNewMessage(session.getSessionId(), notifyMsg);
}
private void pushToManualCs(Session session, String customerId, String kfId,
String content, String msgType, String servicerUserid) {
WxCallbackMessage pushMsg = new WxCallbackMessage();
pushMsg.setExternalUserId(customerId);
pushMsg.setOpenKfId(kfId);
pushMsg.setContent(content);
pushMsg.setMsgType(msgType);
pushMsg.setServicerUserid(servicerUserid);
webSocketService.pushMessageToCs(session.getSessionId(), pushMsg);
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) {
@ -347,7 +274,7 @@ public class MessageProcessService {
@Async
public void processMessage(WxCallbackMessage message) {
log.info("直接处理消息(测试用): msgType={}", message.getMsgType());
log.info("[AC-MCA-08] 直接处理消息(测试用): msgType={}", message.getMsgType());
String customerId = message.getExternalUserId();
String kfId = message.getOpenKfId();
@ -357,37 +284,21 @@ public class MessageProcessService {
return;
}
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
String status = sessionManagerService.getSessionStatus(session.getSessionId());
String sessionKey = kfId + "_" + customerId;
sessionManagerService.saveMessage(
message.getMsgId() != null ? message.getMsgId() : "test_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_CUSTOMER,
customerId,
message.getContent(),
message.getMsgType() != null ? message.getMsgType() : "text",
JSON.toJSONString(message.getRawData())
);
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();
List<Message> history = sessionManagerService.getSessionMessages(session.getSessionId());
switch (status) {
case Session.STATUS_AI:
processAiMessage(session, customerId, kfId, message.getContent());
break;
case Session.STATUS_PENDING:
notifyPendingSession(session, customerId, kfId, message.getContent(), message.getMsgType());
break;
case Session.STATUS_MANUAL:
pushToManualCs(session, customerId, kfId, message.getContent(), message.getMsgType(), null);
break;
case Session.STATUS_CLOSED:
Session newSession = sessionManagerService.getOrCreateSession(customerId, kfId);
processAiMessage(newSession, customerId, kfId, message.getContent());
break;
default:
log.warn("未知的会话状态: {}", status);
}
messageRouterService.processInboundMessage(inboundMessage);
}
}

View File

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

View File

@ -17,6 +17,11 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 会话管理服务
*
* <p>关联 AC: [AC-MCA-11] 会话管理, [AC-MCA-12] 渠道类型支持
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ -31,6 +36,10 @@ public class SessionManagerService {
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)
@ -44,6 +53,7 @@ public class SessionManagerService {
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());
@ -51,6 +61,8 @@ public class SessionManagerService {
sessionMapper.insert(session);
cacheSessionStatus(session.getSessionId(), Session.STATUS_AI);
log.info("[AC-MCA-11] 创建新会话: sessionId={}, channelType={}",
session.getSessionId(), session.getChannelType());
}
return session;
@ -199,6 +211,17 @@ public class SessionManagerService {
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);

View File

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

View File

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

View File

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

View File

@ -6,6 +6,12 @@ spring:
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
@ -28,3 +34,31 @@ transfer:
max-fail-rounds: 3
max-session-duration: 1800000
max-message-rounds: 50
ai-service:
url: http://localhost:8000
timeout: 5000
channel:
default-channel: wechat
adapters:
wechat:
enabled: true
douyin:
enabled: false
jd:
enabled: false
resilience4j:
circuitbreaker:
instances:
aiService:
failure-rate-threshold: 50
sliding-window-size: 10
sliding-window-type: COUNT_BASED
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
timelimiter:
instances:
aiService:
timeout-duration: 5s

View File

@ -1,13 +1,12 @@
-- 创建数据库
CREATE DATABASE IF NOT EXISTS wecom_robot DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE wecom_robot;
-- 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 '创建时间',
@ -18,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `session` (
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='会话表';
@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS `transfer_log` (
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为空表示公共',
@ -72,6 +72,3 @@ CREATE TABLE IF NOT EXISTS `quick_reply` (
PRIMARY KEY (`id`),
INDEX `idx_cs_id` (`cs_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快捷回复表';
-- 如果表已存在,添加新字段
ALTER TABLE `session` ADD COLUMN IF NOT EXISTS `wx_service_state` INT DEFAULT 0 COMMENT '微信会话状态';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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