diff --git a/.gitignore b/.gitignore index 9154f4c..cd6fe60 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ hs_err_pid* replay_pid* +/target/ +/.idea/ diff --git a/docs/progress/ai-robot-mca-progress.md b/docs/progress/ai-robot-mca-progress.md new file mode 100644 index 0000000..fd79434 --- /dev/null +++ b/docs/progress/ai-robot-mca-progress.md @@ -0,0 +1,271 @@ +# ai-robot-MCA - Progress + +> 多渠道适配主框架架构改造进度文档 +> 遵循 `docs/session-handoff-protocol.md` 协议 + +--- + +## 📋 Context + +- module: `ai-robot` +- feature: `MCA` (Multi-Channel Adapter) +- status: 🔄 进行中 + +--- + +## 🔗 Spec References (SSOT) + +- agents: `AGENTS.md` +- contracting: `spec/contracting.md` +- requirements: `spec/ai-robot/requirements.md` +- openapi_provider: `spec/ai-robot/openapi.provider.yaml` +- openapi_deps: `spec/ai-robot/openapi.deps.yaml` +- design: `spec/ai-robot/design.md` +- tasks: `spec/ai-robot/tasks.md` + +--- + +## 📊 Overall Progress (Phases) + +- [x] Phase 1: 基础设施 (100%) ✅ [tasks.md: TASK-001 ~ TASK-005] +- [x] Phase 2: 渠道适配层 (100%) ✅ [tasks.md: TASK-010 ~ TASK-013] +- [x] Phase 3: 消息路由层 (100%) ✅ [tasks.md: TASK-020 ~ TASK-023] +- [x] Phase 4: AI 服务客户端 (100%) ✅ [tasks.md: TASK-030 ~ TASK-033] +- [ ] Phase 5: 集成测试 (0%) ⏳ [tasks.md: TASK-040 ~ TASK-042] + +--- + +## 🔄 Current Phase + +### Goal +完成集成测试,验证多渠道适配框架的完整功能。 + +### Sub Tasks +- [x] TASK-030: 定义 AI 服务 DTO ✅ [AC-MCA-04-REQ, AC-MCA-05] +- [x] TASK-031: 实现 AiServiceClient ✅ [AC-MCA-04, AC-MCA-05] +- [x] TASK-032: 实现熔断与降级 ✅ [AC-MCA-06, AC-MCA-07] +- [x] TASK-033: 删除旧 AiService ✅ +- [ ] TASK-040: 集成测试 ⏳ +- [ ] TASK-041: 端到端测试 ⏳ +- [ ] TASK-042: 性能测试 ⏳ + +### Next Action (Must be Specific) + +**Immediate**: 执行 Phase 5 集成测试任务。 + +**Details**: +1. task: TASK-040 集成测试 +2. action: 编写集成测试验证消息路由流程 +3. reference: + - `spec/ai-robot/tasks.md` TASK-040 定义 +4. constraints: + - 测试覆盖 InboundMessage → AI Service → OutboundMessage 完整流程 + - 验证幂等性、熔断降级逻辑 + +--- + +## 🏗️ Technical Context + +### Module Structure (Only What Matters) + +``` +src/main/java/com/wecom/robot/ +├── dto/ +│ ├── InboundMessage.java # TASK-001 ✅ +│ ├── OutboundMessage.java # TASK-001 ✅ +│ ├── SignatureInfo.java # TASK-001 ✅ +│ └── ai/ +│ ├── ChatRequest.java # TASK-030 +│ └── ChatResponse.java # TASK-030 +├── config/ +│ ├── AiServiceConfig.java # TASK-002 ✅ +│ └── ChannelConfig.java # TASK-002 ✅ +├── adapter/ +│ ├── ChannelAdapter.java # TASK-010 ✅ +│ ├── ServiceStateCapable.java # TASK-010 ✅ +│ ├── TransferCapable.java # TASK-010 ✅ +│ ├── MessageSyncCapable.java # TASK-010 ✅ +│ ├── WeChatAdapter.java # TASK-011 ✅ +│ └── ChannelAdapterFactory.java # TASK-012 ✅ +├── service/ +│ ├── MessageRouterService.java # TASK-020 ✅ +│ ├── AiServiceClient.java # TASK-031 +│ └── impl/ +│ ├── MessageRouterServiceImpl.java # TASK-021 ✅ +│ └── AiServiceClientImpl.java # TASK-031 +├── util/ +│ └── IdempotentHelper.java # TASK-005 ✅ +└── entity/ + └── Session.java # TASK-003 ✅ +``` + +### Key Decisions (Why / Impact) + +- decision: 统一消息模型 (InboundMessage/OutboundMessage) + reason: 实现渠道无关的消息处理,Controller 层负责验签/解析,Router 层处理统一消息 + impact: 后续新增渠道只需实现 ChannelAdapter,无需修改核心路由逻辑 + +- decision: 使用 Resilience4j 实现熔断 + reason: 与 Spring Boot 2.7 兼容良好,支持断路器、限流、超时 + impact: AI 服务调用具备熔断/降级能力,提升系统稳定性 + +- decision: 内部字段统一用 `content`,AI 服务契约用 `currentMessage` + reason: 保持内部命名一致性,映射在 AiServiceClient 层处理 + impact: 避免后续 DTO 命名混乱 + +- decision: ChannelAdapter 接口分离为核心能力和可选能力 + reason: 不同渠道支持的能力不同,接口分离允许按需实现 + impact: WeChatAdapter 实现全部接口,其他渠道可按需实现 + +### Code Snippets (Reference) + +```java +// ChannelAdapter 接口定义 (design.md 3.1) +public interface ChannelAdapter { + String getChannelType(); + boolean sendMessage(OutboundMessage message); +} + +// MessageRouterService 接口定义 (design.md 3.2) +public interface MessageRouterService { + void processInboundMessage(InboundMessage message); + void routeBySessionState(Session session, InboundMessage message); + void dispatchToAiService(Session session, InboundMessage message); + void dispatchToManualCs(Session session, InboundMessage message); + void dispatchToPendingPool(Session session, InboundMessage message); +} +``` + +--- + +## 🧾 Session History + +### Session #5 (2026-02-24) +- completed: + - L2 契约升级: openapi.provider.yaml L0 → L2 ✅ + - L2 契约对齐: DTO 添加 validation 注解 ✅ + - L2 契约对齐: Controller 添加 @Valid 校验 ✅ + - L2 契约对齐: 创建全局异常处理器 ✅ +- changes: + - 更新 spec/ai-robot/openapi.provider.yaml (L0 → L2) + - 更新 src/main/java/com/wecom/robot/dto/ApiResponse.java (code=200 → 0, 添加注解) + - 更新 src/main/java/com/wecom/robot/dto/SessionInfo.java (添加 @NotBlank, @Size, channelType 字段) + - 更新 src/main/java/com/wecom/robot/dto/MessageInfo.java (添加 @NotBlank, @Size) + - 更新 src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java (添加 @NotBlank, @Size) + - 更新 src/main/java/com/wecom/robot/dto/SendMessageRequest.java (添加 @NotBlank, @Size) + - 更新 src/main/java/com/wecom/robot/controller/SessionController.java (添加 @Valid, channelType 参数) + - 新增 src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java +- commits: f09f22f, 0786e6a + +### Session #4 (2026-02-24) +- completed: + - TASK-001: 定义统一消息模型 DTO ✅ + - TASK-002: 新增配置类 ✅ + - TASK-003: 数据库 Schema 变更 ✅ + - TASK-004: 添加 Resilience4j 依赖 ✅ + - TASK-005: 消息幂等性工具类 ✅ + - TASK-030: 定义 AI 服务 DTO ✅ + - TASK-031: 实现 AiServiceClient ✅ + - TASK-032: 实现熔断与降级 ✅ + - TASK-033: 删除旧 AiService ✅ +- changes: + - 新增 src/main/java/com/wecom/robot/dto/InboundMessage.java + - 新增 src/main/java/com/wecom/robot/dto/OutboundMessage.java + - 新增 src/main/java/com/wecom/robot/dto/SignatureInfo.java + - 新增 src/main/java/com/wecom/robot/config/AiServiceConfig.java + - 新增 src/main/java/com/wecom/robot/config/ChannelConfig.java + - 新增 src/main/java/com/wecom/robot/config/RestTemplateConfig.java + - 新增 src/main/java/com/wecom/robot/util/IdempotentHelper.java + - 新增 src/main/java/com/wecom/robot/dto/ai/ChatRequest.java + - 新增 src/main/java/com/wecom/robot/dto/ai/ChatResponse.java + - 新增 src/main/java/com/wecom/robot/dto/ai/ChatMessage.java + - 新增 src/main/java/com/wecom/robot/service/AiServiceClient.java + - 新增 src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java + - 新增 src/main/resources/db/migration/V1__add_channel_type.sql + - 删除 src/main/java/com/wecom/robot/service/AiService.java + - 删除 src/main/java/com/wecom/robot/config/AiConfig.java + - 更新 src/main/resources/application.yml (添加 ai-service, channel, resilience4j 配置) + - 更新 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java + - 更新 src/main/java/com/wecom/robot/controller/DebugController.java + - 更新 pom.xml (添加 Resilience4j 依赖) +- commits: 多个独立 commit + +### Session #3 (2026-02-24) +- completed: + - TASK-010: 定义 ChannelAdapter 接口 ✅ + - TASK-011: 实现 WeChatAdapter ✅ + - TASK-012: 创建 ChannelAdapterFactory ✅ + - TASK-013: 重构 WecomCallbackController ✅ + - TASK-022: 重构 MessageProcessService ✅ +- changes: + - 新增 src/main/java/com/wecom/robot/adapter/ChannelAdapter.java + - 新增 src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java + - 新增 src/main/java/com/wecom/robot/adapter/TransferCapable.java + - 新增 src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java + - 新增 src/main/java/com/wecom/robot/adapter/WeChatAdapter.java + - 新增 src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java + - 更新 src/main/java/com/wecom/robot/controller/WecomCallbackController.java + - 更新 src/main/java/com/wecom/robot/service/MessageProcessService.java +- commits: 4e9c5ba, 2631c53, 07561fe + +### Session #2 (2026-02-24) +- completed: + - TASK-020: 定义 MessageRouterService 接口 + - TASK-021: 实现 MessageRouterServiceImpl + - 创建 `src/main/java/com/wecom/robot/service/MessageRouterService.java` + - 创建 `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java` + - 实现 5 个核心方法:processInboundMessage, routeBySessionState, dispatchToAiService, dispatchToManualCs, dispatchToPendingPool + - 实现幂等性检查(基于 Redis SETNX) +- changes: + - 新增 src/main/java/com/wecom/robot/service/MessageRouterService.java + - 新增 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java + - 更新 docs/progress/ai-robot-mca-progress.md + - 更新 spec/ai-robot/tasks.md +- commits: b9792c8, 0b6fcf5 + +### Session #2 (2026-02-24) +- completed: + - TASK-020: 定义 MessageRouterService 接口 + - TASK-021: 实现 MessageRouterServiceImpl + - TASK-022: 重构 MessageProcessService + - TASK-023: 更新 SessionManagerService 支持 channelType + - 创建 `src/main/java/com/wecom/robot/service/MessageRouterService.java` + - 创建 `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java` + - 更新 Session 实体添加 channelType 字段 + - 更新 SessionManagerService 支持按渠道类型创建和筛选会话 + - 实现 5 个核心方法:processInboundMessage, routeBySessionState, dispatchToAiService, dispatchToManualCs, dispatchToPendingPool + - 实现幂等性检查(基于 Redis SETNX) +- changes: + - 新增 src/main/java/com/wecom/robot/service/MessageRouterService.java + - 新增 src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java + - 更新 src/main/java/com/wecom/robot/service/MessageProcessService.java + - 更新 src/main/java/com/wecom/robot/entity/Session.java + - 更新 src/main/java/com/wecom/robot/service/SessionManagerService.java + - 更新 docs/progress/ai-robot-mca-progress.md + - 更新 spec/ai-robot/tasks.md +- commits: b9792c8, 0b6fcf5, db378af, a8d7474 + +### Session #1 (2026-02-24) +- completed: + - 创建 spec/ai-robot/ 目录结构 + - 编写 requirements.md (v0.2.0) + - 编写 openapi.deps.yaml (L0) + - 编写 openapi.provider.yaml (L0) + - 编写 design.md (v0.2.0) + - 编写 tasks.md (20 个任务) + - 所有规范文件已提交到 Git +- changes: + - 新增 spec/ai-robot/requirements.md + - 新增 spec/ai-robot/openapi.deps.yaml + - 新增 spec/ai-robot/openapi.provider.yaml + - 新增 spec/ai-robot/design.md + - 新增 spec/ai-robot/tasks.md + +--- + +## 🚀 Startup Guide + +1. 读取本进度文档,定位当前 Phase 与 Next Action。 +2. 打开并阅读 Spec References 指向的模块规范(requirements/openapi/design/tasks)。 +3. 直接执行 Next Action(TASK-030: 创建 ChatRequest/ChatResponse DTO)。 +4. 每完成一个子任务,更新本进度文档并提交 Git。 diff --git a/lib/commons-codec-1.9.jar b/lib/commons-codec-1.9.jar new file mode 100644 index 0000000..ef35f1c Binary files /dev/null and b/lib/commons-codec-1.9.jar differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c92b321 --- /dev/null +++ b/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + com.wecom + wecom-robot + 1.0.0 + jar + + wecom-robot + 企业微信智能客服系统 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 1.8 + 3.5.3.1 + 5.8.22 + 2.0.40 + 2.1.0 + ${project.basedir} + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-validation + + + + com.mysql + mysql-connector-j + runtime + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + + cn.hutool + hutool-all + ${hutool.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + commons-codec + commons-codec + 1.9 + system + ${project.basedir}/lib/commons-codec-1.9.jar + + + + io.github.resilience4j + resilience4j-spring-boot2 + ${resilience4j.version} + + + + io.github.resilience4j + resilience4j-timelimiter + ${resilience4j.version} + + + + org.flywaydb + flyway-core + + + + org.flywaydb + flyway-mysql + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + org.projectlombok + lombok + + + + + + + + diff --git a/spec/ai-robot/design.md b/spec/ai-robot/design.md new file mode 100644 index 0000000..ae6c56d --- /dev/null +++ b/spec/ai-robot/design.md @@ -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 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 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 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 + + io.github.resilience4j + resilience4j-spring-boot2 + 2.1.0 + +``` + +### 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 adapters; + + @Data + public static class AdapterConfig { + private boolean enabled; + } +} +``` + +## 9. 部署架构 + +### 9.1 部署拓扑 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 负载均衡器 │ +└─────────────────────────────┬───────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Java 主 │ │ Java 主 │ │ Java 主 │ + │ 框架实例1│ │ 框架实例2│ │ 框架实例3│ + └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + └──────────────┼──────────────┘ + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Redis │ │ MySQL │ │Python AI │ + │ (Cluster)│ │ (Master) │ │ 服务 │ + └──────────┘ └──────────┘ └──────────┘ +``` + +### 9.2 服务依赖 + +| 服务 | 依赖关系 | 健康检查 | +|-----|---------|---------| +| Java 主框架 | 依赖 Redis, MySQL, Python AI | `/actuator/health` | +| Python AI 服务 | 无外部依赖 | `/ai/health` | + +## 10. 安全设计 + +### 10.1 渠道回调鉴权 + +| 渠道 | 鉴权方式 | 验证逻辑 | +|-----|---------|---------| +| 微信 | msg_signature + timestamp + nonce | **沿用现有 WeCom 官方验签/解密方案**(复用现有 `WXBizMsgCrypt` 实现) | +| 抖音 | X-Signature + X-Timestamp | 待实现 | +| 京东 | signature + timestamp | 待实现 | + +> **说明**:微信回调验签/加解密使用企业微信官方方案,具体算法细节封装在现有 `WXBizMsgCrypt` 类中,不在本设计文档展开。 + +### 10.2 内部服务鉴权 + +- Java 主框架 → Python AI 服务:内网调用,无需鉴权(可扩展为 mTLS) +- WebSocket 连接:路径参数 `{csId}` 标识身份(可扩展为 Token 验证) + +## 11. 监控与告警 + +> **说明**:本节为后续演进预留,MVP 阶段可暂不实现。 + +### 11.1 关键指标 + +| 指标 | 类型 | 说明 | +|-----|------|------| +| `ai.service.latency` | Histogram | AI 服务调用延迟 | +| `ai.service.error.rate` | Counter | AI 服务错误率 | +| `ai.service.circuit.breaker.open` | Gauge | 熔断器状态 | +| `message.process.count` | Counter | 消息处理数量 | +| `message.idempotent.skip` | Counter | 幂等跳过数量 | +| `session.active.count` | Gauge | 活跃会话数 | + +### 11.2 告警规则 + +| 规则 | 条件 | 级别 | +|-----|------|------| +| AI 服务不可用 | 连续失败 5 次 | Critical | +| AI 服务延迟过高 | P99 > 3s | Warning | +| 熔断器触发 | circuit.breaker.open = 1 | Critical | diff --git a/spec/ai-robot/openapi.deps.yaml b/spec/ai-robot/openapi.deps.yaml new file mode 100644 index 0000000..d9ec669 --- /dev/null +++ b/spec/ai-robot/openapi.deps.yaml @@ -0,0 +1,188 @@ +openapi: 3.0.3 +info: + title: AI Service API + description: | + Python AI 服务接口契约。 + + 本文件定义主框架对 AI 服务的接口需求(Consumer-First)。 + 由主框架作为调用方,Python AI 服务作为提供方实现。 + version: 1.0.0 + x-contract-level: L0 + x-consumer: "java-main-framework" + x-provider: "python-ai-service" + +servers: + - url: http://ai-service:8080 + description: AI 服务地址 + +paths: + /ai/chat: + post: + operationId: generateReply + summary: 生成 AI 回复 + description: | + 根据用户消息和会话历史生成 AI 回复。 + + 覆盖验收标准: + - AC-MCA-04: 主框架通过 HTTP POST 调用 AI 服务 + - AC-MCA-05: 响应包含 reply、confidence、shouldTransfer 字段 + - AC-MCA-06: AI 服务不可用时的降级处理(主框架侧实现) + - AC-MCA-07: 超时处理(主框架侧实现) + tags: + - AI Chat + x-requirements: + - AC-MCA-04 + - AC-MCA-04-REQ + - AC-MCA-04-OPT + - AC-MCA-05 + - AC-MCA-06 + - AC-MCA-07 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatRequest' + example: + sessionId: "kf_001_wx123456_1708765432000" + currentMessage: "我想了解产品价格" + channelType: "wechat" + responses: + '200': + description: 成功生成回复 + content: + application/json: + schema: + $ref: '#/components/schemas/ChatResponse' + example: + reply: "您好,我们的产品价格根据套餐不同有所差异。" + confidence: 0.92 + shouldTransfer: false + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: 服务不可用 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ai/health: + get: + operationId: healthCheck + summary: 健康检查 + description: 检查 AI 服务是否正常运行 + tags: + - Health + responses: + '200': + description: 服务正常 + content: + application/json: + schema: + type: object + properties: + status: + type: string + '503': + description: 服务不健康 + +components: + schemas: + ChatRequest: + type: object + required: + - sessionId + - currentMessage + - channelType + properties: + sessionId: + type: string + description: 会话ID(AC-MCA-04-REQ 必填) + currentMessage: + type: string + description: 当前用户消息(AC-MCA-04-REQ 必填) + channelType: + type: string + description: 渠道类型(AC-MCA-04-REQ 必填) + enum: + - wechat + - douyin + - jd + history: + type: array + description: 历史消息列表(AC-MCA-04-OPT 可选) + items: + $ref: '#/components/schemas/ChatMessage' + metadata: + type: object + description: 扩展元数据(AC-MCA-04-OPT 可选) + additionalProperties: true + + ChatMessage: + type: object + required: + - role + - content + properties: + role: + type: string + enum: + - user + - assistant + content: + type: string + + ChatResponse: + type: object + required: + - reply + - confidence + - shouldTransfer + properties: + reply: + type: string + description: AI 回复内容(AC-MCA-05 必填) + confidence: + type: number + format: double + description: 置信度评分 0.0-1.0(AC-MCA-05 必填) + shouldTransfer: + type: boolean + description: 是否建议转人工(AC-MCA-05 必填) + transferReason: + type: string + description: 转人工原因(可选) + metadata: + type: object + description: 响应元数据(可选) + additionalProperties: true + + ErrorResponse: + type: object + required: + - code + - message + properties: + code: + type: string + description: 错误代码 + message: + type: string + description: 错误消息 + details: + type: array + description: 详细错误信息(可选) + items: + type: object + additionalProperties: true diff --git a/spec/ai-robot/openapi.provider.yaml b/spec/ai-robot/openapi.provider.yaml new file mode 100644 index 0000000..05248fe --- /dev/null +++ b/spec/ai-robot/openapi.provider.yaml @@ -0,0 +1,894 @@ +openapi: 3.0.3 +info: + title: Multi-Channel Customer Service API + description: | + 多渠道客服主框架对外提供的 API 契约。 + + 本文件定义主框架对外提供的能力(Provider): + - 渠道消息回调接口(微信、抖音、京东等) + - 人工客服工作台 REST API + - WebSocket 实时通信协议说明 + version: 1.0.0 + x-contract-level: L2 + x-consumer: "frontend, wechat-server, douyin-server, jd-server" + x-provider: "java-main-framework" + +servers: + - url: http://{host}:{port} + description: | + 服务地址占位符,根据环境替换: + - 开发环境: http://localhost:8080 + - 测试环境: http://ai-robot-test:8080 + - 生产环境: http://ai-robot:8080 + variables: + host: + default: localhost + description: 服务主机名 + port: + default: "8080" + description: 服务端口 + +tags: + - name: Channel Callback + description: 渠道消息回调接口 + - name: Session Management + description: 会话管理接口 + - name: WebSocket + description: WebSocket 实时通信 + +paths: + /wecom/callback: + get: + operationId: verifyWecomUrl + summary: 微信回调 URL 验证 + description: | + 企业微信回调 URL 验证接口。 + + 用于验证回调 URL 的有效性,企业微信在配置回调时会发送 GET 请求。 + tags: + - Channel Callback + parameters: + - name: msg_signature + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + description: 消息签名,用于验证请求来源 + - name: timestamp + in: query + required: true + schema: + type: string + pattern: '^\d+$' + description: 时间戳(秒级),用于防重放攻击 + - name: nonce + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 随机字符串,用于防重放攻击 + - name: echostr + in: query + required: true + schema: + type: string + minLength: 1 + description: 加密的随机字符串,验证成功后需解密返回 + responses: + '200': + description: 验证成功,返回解密后的 echostr + content: + text/plain: + schema: + type: string + example: "1234567890" + '400': + description: 请求参数错误 + content: + text/plain: + schema: + type: string + enum: + - error + '401': + description: 签名验证失败 + content: + text/plain: + schema: + type: string + enum: + - error + '500': + description: 服务器内部错误 + content: + text/plain: + schema: + type: string + enum: + - error + + post: + operationId: handleWecomCallback + summary: 微信回调消息处理 + description: | + 企业微信回调消息处理入口。 + + 覆盖验收标准: + - AC-MCA-08: 根据渠道类型路由到对应的渠道适配器 + + 消息处理流程: + 1. 接收加密的 XML 消息 + 2. 解密并解析消息内容 + 3. 根据消息类型路由处理 + 4. 返回 success 确认 + tags: + - Channel Callback + x-requirements: + - AC-MCA-08 + parameters: + - name: msg_signature + in: query + required: false + schema: + type: string + maxLength: 128 + description: 消息签名(用于验签) + - name: timestamp + in: query + required: false + schema: + type: string + pattern: '^\d+$' + description: 时间戳(用于防重放) + - name: nonce + in: query + required: false + schema: + type: string + maxLength: 64 + description: 随机数(用于防重放) + requestBody: + required: true + content: + application/xml: + schema: + type: string + description: 加密的 XML 消息 + example: "..." + responses: + '200': + description: 处理成功 + content: + text/plain: + schema: + type: string + enum: + - success + '400': + description: 请求格式错误 + content: + text/plain: + schema: + type: string + enum: + - success + '401': + description: 签名验证失败 + content: + text/plain: + schema: + type: string + enum: + - success + '500': + description: 服务器内部错误(仍返回 success 以避免微信重试) + content: + text/plain: + schema: + type: string + enum: + - success + + /channel/{channelType}/callback: + post: + operationId: handleChannelCallback + summary: 通用渠道回调接口(预留) + description: | + 通用渠道消息回调接口,用于接入新渠道。 + + 当前为预留接口,后续实现抖音、京东等渠道时使用。 + + ### 鉴权/签名机制(各渠道实现时需补充) + 不同渠道需要不同的验签方式,建议通过以下方式传递: + + **方式一:Header 传递** + - `X-Signature`: 消息签名 + - `X-Timestamp`: 时间戳(防重放) + - `X-Nonce`: 随机数(防重放) + + **方式二:Query 参数传递** + - `signature`: 消息签名 + - `timestamp`: 时间戳 + - `nonce`: 随机数 + + **方式三:Body 内嵌** + - requestBody 中包含 `rawPayload` + `signature` 字段 + + 具体签名算法(HMAC-SHA256、RSA 等)由各渠道适配器实现时确定。 + tags: + - Channel Callback + parameters: + - name: channelType + in: path + required: true + schema: + type: string + enum: + - wechat + - douyin + - jd + description: 渠道类型 + - name: X-Signature + in: header + required: false + schema: + type: string + maxLength: 256 + description: 消息签名(可选,具体由渠道决定) + - name: X-Timestamp + in: header + required: false + schema: + type: string + pattern: '^\d+$' + description: 时间戳(可选,用于防重放) + - name: X-Nonce + in: header + required: false + schema: + type: string + maxLength: 64 + description: 随机数(可选,用于防重放) + requestBody: + required: true + content: + application/json: + schema: + type: object + description: 渠道消息体(格式由各渠道定义) + responses: + '200': + description: 处理成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: 请求格式错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '401': + description: 签名验证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: 不支持的渠道类型 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions: + get: + operationId: getSessions + summary: 获取会话列表 + description: | + 获取客服工作台的会话列表。 + + 覆盖验收标准: + - AC-MCA-12: 支持按渠道类型筛选 + tags: + - Session Management + x-requirements: + - AC-MCA-12 + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: + - ai + - pending + - manual + - closed + description: 会话状态筛选 + - name: csId + in: query + required: false + schema: + type: string + maxLength: 64 + description: 客服ID筛选 + - name: channelType + in: query + required: false + schema: + type: string + enum: + - wechat + - douyin + - jd + description: 渠道类型筛选 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/SessionListResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions/{sessionId}: + get: + operationId: getSession + summary: 获取会话详情 + description: | + 获取指定会话的详细信息。 + tags: + - Session Management + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: 会话不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions/{sessionId}/history: + get: + operationId: getSessionHistory + summary: 获取会话消息历史 + description: | + 获取指定会话的消息历史记录。 + tags: + - Session Management + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageListResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: 会话不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions/{sessionId}/accept: + post: + operationId: acceptSession + summary: 接入会话 + description: | + 客服接入待处理的会话。 + + 仅状态为 `pending` 的会话可被接入。 + tags: + - Session Management + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - csId + properties: + csId: + type: string + minLength: 1 + maxLength: 64 + description: 客服ID + example: + csId: "cs_001" + responses: + '200': + description: 接入成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: 会话状态不正确或参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + examples: + invalid_status: + value: + code: 400 + message: "会话状态不正确" + missing_csId: + value: + code: 400 + message: "客服ID不能为空" + '404': + description: 会话不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + code: 404 + message: "会话不存在" + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions/{sessionId}/message: + post: + operationId: sendSessionMessage + summary: 发送消息 + description: | + 客服向会话发送消息。 + + 仅状态为 `manual` 的会话可发送消息。 + tags: + - Session Management + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + minLength: 1 + maxLength: 4096 + description: 消息内容 + msgType: + type: string + enum: + - text + - image + - file + default: text + description: 消息类型 + example: + content: "您好,请问有什么可以帮助您的?" + msgType: "text" + responses: + '200': + description: 发送成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: 会话状态不正确或参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + examples: + invalid_status: + value: + code: 400 + message: "会话状态不正确" + missing_content: + value: + code: 400 + message: "消息内容不能为空" + '404': + description: 会话不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误或消息发送失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/sessions/{sessionId}/close: + post: + operationId: closeSession + summary: 关闭会话 + description: | + 关闭指定的会话。 + tags: + - Session Management + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + responses: + '200': + description: 关闭成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: 会话不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + +components: + schemas: + ApiResponse: + type: object + required: + - code + - message + properties: + code: + type: integer + description: 响应码(0=成功,非0=失败) + enum: + - 0 + - 400 + - 404 + - 500 + message: + type: string + minLength: 1 + maxLength: 256 + description: 响应消息 + data: + type: object + description: 响应数据(可选) + example: + code: 0 + message: "success" + + SessionListResponse: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + required: + - code + - message + - data + properties: + code: + type: integer + enum: + - 0 + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/SessionInfo' + example: + code: 0 + message: "success" + data: + - sessionId: "session_001" + customerId: "customer_001" + status: "manual" + channelType: "wechat" + + SessionResponse: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + required: + - code + - message + - data + properties: + code: + type: integer + enum: + - 0 + message: + type: string + data: + $ref: '#/components/schemas/SessionInfo' + + SessionInfo: + type: object + required: + - sessionId + - customerId + - status + properties: + sessionId: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + customerId: + type: string + minLength: 1 + maxLength: 64 + description: 客户ID + kfId: + type: string + maxLength: 64 + description: 客服账号ID + channelType: + type: string + description: 渠道类型 + enum: + - wechat + - douyin + - jd + status: + type: string + description: 会话状态 + enum: + - ai + - pending + - manual + - closed + manualCsId: + type: string + maxLength: 64 + description: 接待客服ID + lastMessage: + type: string + maxLength: 4096 + description: 最后一条消息 + lastMessageTime: + type: string + format: date-time + description: 最后消息时间 + messageCount: + type: integer + minimum: 0 + description: 消息数量 + createdAt: + type: string + format: date-time + description: 创建时间 + updatedAt: + type: string + format: date-time + description: 更新时间 + metadata: + type: object + description: 扩展元数据 + + MessageListResponse: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + required: + - code + - message + - data + properties: + code: + type: integer + enum: + - 0 + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/MessageInfo' + + MessageInfo: + type: object + required: + - msgId + - sessionId + - senderType + - content + properties: + msgId: + type: string + minLength: 1 + maxLength: 128 + description: 消息ID + sessionId: + type: string + minLength: 1 + maxLength: 64 + description: 会话ID + senderType: + type: string + description: 发送者类型 + enum: + - customer + - ai + - manual + senderId: + type: string + maxLength: 64 + description: 发送者ID + content: + type: string + minLength: 1 + maxLength: 4096 + description: 消息内容 + msgType: + type: string + description: 消息类型 + enum: + - text + - image + - file + - event + createdAt: + type: string + format: date-time + description: 创建时间 + +x-websocket: + path: /ws/cs/{csId} + description: | + ## WebSocket 实时通信协议 + + 客服工作台通过 WebSocket 接收实时消息推送。 + + 覆盖验收标准: + - AC-MCA-10: 会话状态为 MANUAL 时推送消息到人工客服工作台 + + ### 连接地址 + ``` + ws://{host}:{port}/ws/cs/{csId} + ``` + + ### 认证/鉴权方式 + - **路径参数**: `{csId}` 客服ID,用于标识连接身份 + - **可选增强**: 后续可增加 Token 验证(Query 参数或 Header) + - Query: `?token=xxx` + - Header: `Authorization: Bearer xxx` + + ### 客户端发送消息格式 + ```json + { + "type": "bind_session", + "sessionId": "会话ID" + } + ``` + + ### 服务端推送消息格式 + ```json + { + "type": "new_message", + "sessionId": "会话ID", + "data": { + "msgId": "消息ID", + "content": "消息内容", + "senderType": "customer", + "senderId": "客户ID", + "msgType": "text", + "createdAt": "2026-02-24T10:00:00" + } + } + ``` + + ### 推送事件类型 + | type | 说明 | + |------|------| + | new_message | 新消息通知 | + | new_pending_session | 新待接入会话 | + | session_accepted | 会话被接入 | + | session_closed | 会话已关闭 | diff --git a/spec/ai-robot/requirements.md b/spec/ai-robot/requirements.md new file mode 100644 index 0000000..df0b654 --- /dev/null +++ b/spec/ai-robot/requirements.md @@ -0,0 +1,189 @@ +--- +feature_id: "MCA" +title: "多渠道适配主框架架构改造" +status: "draft" +version: "0.2.0" +owners: + - "backend" + - "architect" +last_updated: "2026-02-24" +source: + type: "conversation" + ref: "架构改造需求" +--- + +# 多渠道适配主框架架构改造(MCA) + +## 1. 背景与目标 + +### 1.1 背景 + +当前系统为"企业微信智能客服系统",核心逻辑围绕企业微信客服 API 构建: +- 微信消息接收、加解密、同步 +- AI 回复生成(紧耦合在 Java 主应用中) +- 会话状态管理、转人工逻辑 +- 人工客服工作台(WebSocket) + +随着业务扩展,需要接入更多渠道(抖音、京东等),同时 AI 服务需要独立演进(支持多模型、Prompt 工程、RAG 等)。当前架构存在以下问题: + +1. **渠道耦合**:AI 服务、消息处理与微信 API 紧密耦合,难以扩展新渠道 +2. **AI 服务受限**:Java 生态对 AI/LLM 支持不如 Python 丰富,迭代效率低 +3. **职责不清**:消息路由、AI 调用、状态管理混杂在同一服务中 + +### 1.2 目标 + +1. **多渠道适配**:抽象渠道适配层,支持 WeChat/Douyin/JD 等渠道的快速接入 +2. **AI 服务剥离**:将 AI 服务剥离为独立 Python 服务,主框架通过 HTTP 调用 +3. **职责清晰**:主框架负责消息路由、会话管理、渠道适配;AI 服务负责模型推理 + +### 1.3 非目标(Out of Scope) + +- 本次改造不涉及前端界面重构 +- 不涉及数据库迁移或数据模型重大变更 +- 不涉及 AI 模型训练或微调 + +## 2. 模块边界(Scope) + +### 2.1 覆盖 + +| 模块 | 说明 | +|-----|------| +| **渠道适配层** | 抽象 ChannelAdapter 接口,实现 WeChatAdapter,预留 DouyinAdapter/JdAdapter 扩展点 | +| **消息路由层** | MessageProcessService 重构,支持多渠道消息分发 | +| **会话管理层** | SessionManagerService 保持不变,增加渠道类型字段 | +| **AI 服务客户端** | 新增 AiServiceClient,通过 HTTP 调用 Python AI 服务 | +| **Python AI 服务** | 独立服务,提供 `/ai/chat` 等接口 | +| **配置管理** | 支持多渠道配置、AI 服务配置 | + +### 2.2 不覆盖 + +| 模块 | 说明 | +|-----|------| +| **抖音/京东适配器实现** | 仅预留接口,后续迭代实现 | +| **人工客服工作台** | WebSocket 相关逻辑保持不变 | +| **数据库表结构** | 仅增加渠道类型字段,不进行大规模迁移 | +| **前端界面** | 不涉及 | + +## 3. 依赖盘点(Dependencies) + +### 3.1 本模块依赖的外部服务 + +| 依赖 | 用途 | 契约文件 | +|-----|------|---------| +| **Python AI 服务** | AI 回复生成、置信度评估 | `openapi.deps.yaml` | +| **企业微信 API** | 微信消息收发、会话状态管理 | 第三方 API | +| **Redis** | 会话状态缓存、Token 缓存 | 基础设施 | +| **MySQL** | 会话、消息持久化 | 基础设施 | + +### 3.2 本模块对外提供的能力 + +| 能力 | 消费方 | 契约文件 | +|-----|-------|---------| +| **人工客服工作台 API** | 前端 | `openapi.provider.yaml` | +| **WebSocket 消息推送** | 前端 | `openapi.provider.yaml` | + +## 4. 用户故事(User Stories) + +### 4.1 渠道适配 + +- [US-MCA-01] 作为系统架构师,我希望主框架支持多渠道适配器接口,以便快速接入新渠道(抖音、京东等)。 + +### 4.2 AI 服务剥离 + +- [US-MCA-02] 作为 AI 工程师,我希望 AI 服务独立部署为 Python 服务,以便使用 Python 生态的 AI 框架和工具。 +- [US-MCA-03] 作为后端开发者,我希望主框架通过 HTTP 调用 AI 服务,以便主框架与 AI 服务独立演进。 + +### 4.3 消息路由 + +- [US-MCA-04] 作为系统运维,我希望消息路由逻辑与渠道适配解耦,以便新增渠道时不影响核心路由逻辑。 + +### 4.4 会话管理 + +- [US-MCA-05] 作为系统运维,我希望会话管理支持多渠道标识,以便区分不同渠道的会话。 + +## 5. 验收标准(Acceptance Criteria, EARS) + +### 5.1 渠道适配层 + +#### 5.1.1 核心能力接口(所有渠道必须实现) + +- [AC-MCA-01] WHEN 定义 ChannelAdapter 核心接口 THEN 系统 SHALL 包含 `receiveMessage`(接收消息)、`sendMessage`(发送消息)、`getChannelType`(获取渠道类型)方法签名。 + +#### 5.1.2 可选能力接口(按渠道特性实现) + +- [AC-MCA-01-OPT-01] WHEN 渠道支持服务状态管理 THEN 系统 SHALL 实现 `ServiceStateCapable` 接口,包含 `getServiceState`、`transServiceState` 方法。 +- [AC-MCA-01-OPT-02] WHEN 渠道支持转人工 THEN 系统 SHALL 实现 `TransferCapable` 接口,包含 `transferToManual`、`transferToPool` 方法。 +- [AC-MCA-01-OPT-03] WHEN 渠道支持消息同步 THEN 系统 SHALL 实现 `MessageSyncCapable` 接口,包含 `syncMessages` 方法。 + +> **设计说明**:可选能力接口的具体定义将在 `design.md` 中详细说明。主框架在运行时通过能力检测(如 `instanceof` 或 `Optional.ofNullable`)判断渠道是否支持某能力。 + +#### 5.1.3 适配器实现 + +- [AC-MCA-02] WHEN 实现 WeChatAdapter THEN 系统 SHALL 实现核心接口及所有可选能力接口,封装现有 WecomApiService 的所有功能。 +- [AC-MCA-03] WHEN 新增渠道适配器 THEN 系统 SHALL 至少实现核心接口,可选能力按需实现,无需修改核心路由逻辑。 + +### 5.2 AI 服务剥离 + +#### 5.2.1 请求契约 + +- [AC-MCA-04] WHEN 主框架调用 AI 服务 THEN 系统 SHALL 通过 HTTP POST `/ai/chat` 接口获取 AI 回复。 +- [AC-MCA-04-REQ] WHEN 构造 AI 服务请求 THEN 系统 SHALL 包含以下最小字段:`sessionId`(会话ID)、`currentMessage`(当前消息)、`channelType`(渠道类型)。 +- [AC-MCA-04-OPT] WHEN 构造 AI 服务请求 THEN 系统 MAY 包含以下可选字段:`history`(历史消息)、`metadata`(扩展元数据)。 + +#### 5.2.2 响应契约 + +- [AC-MCA-05] WHEN AI 服务返回回复 THEN 系统 SHALL 包含 `reply`(回复内容)、`confidence`(置信度)、`shouldTransfer`(是否建议转人工)字段。 + +#### 5.2.3 容错处理 + +- [AC-MCA-06] WHEN AI 服务不可用 THEN 系统 SHALL 返回降级回复并记录错误日志,不影响消息接收流程。 +- [AC-MCA-07] WHEN AI 服务响应超时 THEN 系统 SHALL 在配置的超时时间后返回降级回复。 + +### 5.3 消息路由 + +- [AC-MCA-08] WHEN 收到消息 THEN 系统 SHALL 根据渠道类型路由到对应的渠道适配器。 +- [AC-MCA-09] WHEN 会话状态为 AI THEN 系统 SHALL 调用 AI 服务生成回复。 +- [AC-MCA-10] WHEN 会话状态为 MANUAL THEN 系统 SHALL 推送消息到人工客服工作台。 + +### 5.4 消息幂等性 + +- [AC-MCA-11-IDEMPOTENT] WHEN 收到重复的 messageId THEN 系统 SHALL 幂等处理,不重复调用 AI 服务、不重复发送回复消息。 + +### 5.5 会话管理 + +- [AC-MCA-11] WHEN 创建会话 THEN 系统 SHALL 记录渠道类型(channelType)。 +- [AC-MCA-12] WHEN 查询会话 THEN 系统 SHALL 支持按渠道类型筛选。 + +### 5.6 兼容性 + +- [AC-MCA-13] WHEN 改造完成后 THEN 系统 SHALL 保持现有微信渠道功能完全兼容,无业务中断。 + +## 6. 追踪映射(Traceability) + +| AC ID | Endpoint | 方法 | operationId | 备注 | +|-------|----------|------|-------------|------| +| AC-MCA-04 | /ai/chat | POST | generateReply | AI 服务接口(deps) | +| AC-MCA-04-REQ | /ai/chat | POST | generateReply | AI 请求最小字段 | +| AC-MCA-05 | /ai/chat | POST | generateReply | AI 服务响应格式 | +| AC-MCA-06 | /ai/chat | POST | generateReply | 降级处理 | +| AC-MCA-07 | /ai/chat | POST | generateReply | 超时处理 | +| AC-MCA-08 | /wecom/callback | POST | handleWecomCallback | **微信渠道** Provider Endpoint,其它渠道后续补齐 | +| AC-MCA-09 | /ai/chat | POST | generateReply | AI 状态路由 | +| AC-MCA-10 | WebSocket | - | pushToManualCs | 人工状态路由 | +| AC-MCA-11-IDEMPOTENT | - | - | - | 幂等处理(内部逻辑,无对外接口) | + +## 7. 风险与约束 + +### 7.1 技术风险 + +| 风险 | 影响 | 缓解措施 | +|-----|------|---------| +| AI 服务调用延迟 | 用户体验下降 | 设置合理超时、异步处理、降级策略 | +| 渠道 API 差异 | 适配器实现复杂 | 抽象公共接口、渠道特有能力单独处理 | + +### 7.2 约束 + +- Java 版本:1.8(不升级) +- Spring Boot 版本:2.7.18(不升级) +- AI 服务通信协议:HTTP REST(非 gRPC) +- 部署方式:AI 服务独立部署,主框架通过内网调用 diff --git a/spec/ai-robot/tasks.md b/spec/ai-robot/tasks.md new file mode 100644 index 0000000..6b647e9 --- /dev/null +++ b/spec/ai-robot/tasks.md @@ -0,0 +1,347 @@ +--- +feature_id: "MCA" +title: "多渠道适配主框架任务清单" +status: "draft" +version: "0.1.0" +owners: + - "backend" +last_updated: "2026-02-24" +--- + +# 多渠道适配主框架任务清单(tasks.md) + +## 任务概览 + +| 阶段 | 任务数 | 说明 | +|-----|-------|------| +| Phase 1: 基础设施 | 5 | 统一消息模型、配置、数据库 | +| Phase 2: 渠道适配层 | 4 | ChannelAdapter 接口与 WeChatAdapter 重构 | +| Phase 3: 消息路由层 | 4 | MessageRouterService 重构 | +| Phase 4: AI 服务客户端 | 4 | AiServiceClient 实现 | +| Phase 5: 集成测试 | 3 | 端到端测试 | + +--- + +## Phase 1: 基础设施 + +### TASK-001: 定义统一消息模型 DTO +- **状态**: ✅ 已完成 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08 +- **描述**: 创建 `InboundMessage`、`OutboundMessage`、`SignatureInfo` 等 DTO 类 +- **产出物**: + - `src/main/java/com/wecom/robot/dto/InboundMessage.java` + - `src/main/java/com/wecom/robot/dto/OutboundMessage.java` + - `src/main/java/com/wecom/robot/dto/SignatureInfo.java` +- **验收标准**: + - [x] DTO 类包含 design.md 2.1/2.2 定义的所有字段 + - [x] 包含 Lombok 注解 (@Data, @Builder) + - [x] 单元测试覆盖字段映射 + +### TASK-002: 新增配置类 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-04 +- **描述**: 创建 AI 服务配置类和渠道配置类 +- **产出物**: + - `src/main/java/com/wecom/robot/config/AiServiceConfig.java` + - `src/main/java/com/wecom/robot/config/ChannelConfig.java` + - `src/main/resources/application.yml` 更新 +- **验收标准**: + - [ ] 配置类可正确读取 application.yml + - [ ] 包含默认值 + +### TASK-003: 数据库 Schema 变更 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-11 +- **描述**: Session 表新增 channel_type 字段 +- **产出物**: + - `src/main/resources/db/migration/V1__add_channel_type.sql` (如使用 Flyway) + - 或手动 DDL 脚本 +- **验收标准**: + - [ ] DDL 可在线执行 + - [ ] 默认值为 'wechat' + - [ ] Session 实体类同步更新 + +### TASK-004: 添加 Resilience4j 依赖 +- **状态**: ⏳ 待开始 +- **优先级**: P1 +- **关联 AC**: AC-MCA-06, AC-MCA-07 +- **描述**: 在 pom.xml 添加 Resilience4j 依赖 +- **产出物**: + - `pom.xml` 更新 +- **验收标准**: + - [ ] 依赖正确添加 + - [ ] 项目可正常构建 + +### TASK-005: 消息幂等性工具类 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-11-IDEMPOTENT +- **描述**: 实现基于 Redis 的消息幂等性处理 +- **产出物**: + - `src/main/java/com/wecom/robot/util/IdempotentHelper.java` +- **验收标准**: + - [ ] 使用 Redis SETNX 实现 + - [ ] TTL 1 小时 + - [ ] 单元测试覆盖 + +--- + +## Phase 2: 渠道适配层 + +### TASK-010: 定义 ChannelAdapter 接口 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-01 +- **描述**: 创建核心能力接口和可选能力接口 +- **产出物**: + - `src/main/java/com/wecom/robot/adapter/ChannelAdapter.java` + - `src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java` + - `src/main/java/com/wecom/robot/adapter/TransferCapable.java` + - `src/main/java/com/wecom/robot/adapter/MessageSyncCapable.java` +- **验收标准**: + - [ ] 接口定义与 design.md 3.1 一致 + - [ ] sendMessage 使用 OutboundMessage 参数 + +### TASK-011: 实现 WeChatAdapter +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-02 +- **描述**: 将现有 WecomApiService 重构为 WeChatAdapter +- **产出物**: + - `src/main/java/com/wecom/robot/adapter/WeChatAdapter.java` +- **验收标准**: + - [ ] 实现 ChannelAdapter 核心接口 + - [ ] 实现 ServiceStateCapable、TransferCapable、MessageSyncCapable + - [ ] 现有功能保持兼容 + +### TASK-012: 创建 ChannelAdapterFactory +- **状态**: ⏳ 待开始 +- **优先级**: P1 +- **关联 AC**: AC-MCA-03 +- **描述**: 创建渠道适配器工厂,根据 channelType 获取对应适配器 +- **产出物**: + - `src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java` +- **验收标准**: + - [ ] 支持 wechat 渠道 + - [ ] 预留 douyin、jd 扩展点 + +### TASK-013: 重构 WecomCallbackController +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08 +- **描述**: Controller 负责验签/解密/解析,构建 InboundMessage +- **产出物**: + - `src/main/java/com/wecom/robot/controller/WecomCallbackController.java` 更新 +- **验收标准**: + - [ ] 验签/解密逻辑保持不变 + - [ ] 输出 InboundMessage 传递给 MessageRouterService + +--- + +## Phase 3: 消息路由层 + +### TASK-020: 定义 MessageRouterService 接口 +- **状态**: ✅ 已完成 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08 +- **描述**: 创建渠道无关的消息路由服务接口 +- **产出物**: + - `src/main/java/com/wecom/robot/service/MessageRouterService.java` +- **验收标准**: + - [x] 接口定义与 design.md 3.2 一致 + +### TASK-021: 实现 MessageRouterServiceImpl +- **状态**: ✅ 已完成 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10 +- **描述**: 实现消息路由核心逻辑 +- **产出物**: + - `src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java` +- **验收标准**: + - [x] processInboundMessage 实现完整流程 + - [x] routeBySessionState 根据状态路由 + - [x] 幂等性检查 + +### TASK-022: 重构 MessageProcessService +- **状态**: ✅ 已完成 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08 +- **描述**: 将现有 MessageProcessService 逻辑迁移到 MessageRouterServiceImpl +- **产出物**: + - `src/main/java/com/wecom/robot/service/MessageProcessService.java` 更新或删除 +- **验收标准**: + - [x] 现有功能保持兼容 + - [x] 微信专属逻辑移至 WeChatAdapter + +### TASK-023: 更新 SessionManagerService +- **状态**: ✅ 已完成 +- **优先级**: P0 +- **关联 AC**: AC-MCA-11, AC-MCA-12 +- **描述**: 支持渠道类型字段 +- **产出物**: + - `src/main/java/com/wecom/robot/service/SessionManagerService.java` 更新 + - `src/main/java/com/wecom/robot/entity/Session.java` 更新 +- **验收标准**: + - [x] 创建会话时记录 channelType + - [x] 支持按 channelType 筛选 + +--- + +## Phase 4: AI 服务客户端 + +### TASK-030: 定义 AI 服务 DTO +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-04-REQ, AC-MCA-05 +- **描述**: 创建 ChatRequest、ChatResponse DTO +- **产出物**: + - `src/main/java/com/wecom/robot/dto/ai/ChatRequest.java` + - `src/main/java/com/wecom/robot/dto/ai/ChatResponse.java` +- **验收标准**: + - [ ] 字段与 openapi.deps.yaml 一致 + - [ ] 包含映射方法 (InboundMessage → ChatRequest) + +### TASK-031: 实现 AiServiceClient +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-04, AC-MCA-05 +- **描述**: 实现 HTTP 调用 Python AI 服务 +- **产出物**: + - `src/main/java/com/wecom/robot/service/AiServiceClient.java` + - `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java` +- **验收标准**: + - [ ] 使用 RestTemplate 调用 /ai/chat + - [ ] 超时 5 秒 + - [ ] 正确映射字段 + +### TASK-032: 实现熔断与降级 +- **状态**: ⏳ 待开始 +- **优先级**: P1 +- **关联 AC**: AC-MCA-06, AC-MCA-07 +- **描述**: 使用 Resilience4j 实现熔断和降级 +- **产出物**: + - `src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java` 更新 + - `src/main/resources/application.yml` 更新 +- **验收标准**: + - [ ] @CircuitBreaker 注解配置 + - [ ] @TimeLimiter 注解配置 + - [ ] fallback 方法返回降级回复 + +### TASK-033: 删除旧 AiService +- **状态**: ⏳ 待开始 +- **优先级**: P2 +- **关联 AC**: - +- **描述**: 删除旧的 AiService 类,清理相关配置 +- **产出物**: + - 删除 `src/main/java/com/wecom/robot/service/AiService.java` + - 删除 `src/main/java/com/wecom/robot/config/AiConfig.java` +- **验收标准**: + - [ ] 无编译错误 + - [ ] 无运行时错误 + +--- + +## Phase 5: 集成测试 + +### TASK-040: 微信回调端到端测试 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-08, AC-MCA-09, AC-MCA-10 +- **描述**: 测试微信回调完整流程 +- **产出物**: + - `src/test/java/com/wecom/robot/integration/WecomCallbackIntegrationTest.java` +- **验收标准**: + - [ ] 消息正确路由到 AI 服务 + - [ ] 消息正确路由到人工客服 + - [ ] 幂等性验证 + +### TASK-041: AI 服务调用测试 +- **状态**: ⏳ 待开始 +- **优先级**: P0 +- **关联 AC**: AC-MCA-04, AC-MCA-05, AC-MCA-06, AC-MCA-07 +- **描述**: 测试 AI 服务调用、超时、降级 +- **产出物**: + - `src/test/java/com/wecom/robot/service/AiServiceClientTest.java` +- **验收标准**: + - [ ] 正常调用返回正确响应 + - [ ] 超时触发降级 + - [ ] 熔断触发降级 + +### TASK-042: 会话管理测试 +- **状态**: ⏳ 待开始 +- **优先级**: P1 +- **关联 AC**: AC-MCA-11, AC-MCA-12 +- **描述**: 测试会话创建、状态变更、渠道类型 +- **产出物**: + - `src/test/java/com/wecom/robot/service/SessionManagerServiceTest.java` +- **验收标准**: + - [ ] 会话创建包含 channelType + - [ ] 支持按 channelType 筛选 + +--- + +## 待澄清事项 + +| ID | 问题 | 状态 | 备注 | +|----|------|------|------| +| CLARIFY-001 | AI 服务超时时间确认 | ✅ 已确认 | 5 秒 | +| CLARIFY-002 | 降级回复策略确认 | ✅ 已确认 | 返回固定回复 + 转人工 | +| CLARIFY-003 | 历史消息数量限制 | ✅ 已确认 | 50 条(openapi.deps.yaml) | +| CLARIFY-004 | 渠道扩展优先级 | ✅ 已确认 | WeChat → Douyin → JD | +| CLARIFY-005 | Python AI 服务部署方式 | ⏳ 待确认 | 独立进程 / Docker / K8s | + +--- + +## 任务依赖关系 + +``` +Phase 1 (基础设施) + │ + ├── TASK-001 (DTO) ─────────────────────────────────────────┐ + ├── TASK-002 (配置) ────────────────────────────────────────┤ + ├── TASK-003 (数据库) ──────────────────────────────────────┤ + ├── TASK-004 (Resilience4j) ──┐ │ + └── TASK-005 (幂等性) ────────┤ │ + │ │ +Phase 2 (渠道适配层) │ │ + │ │ │ + ├── TASK-010 (接口) ◄────────┼─────────────────────────────┤ + ├── TASK-011 (WeChatAdapter) ◄┘ │ + ├── TASK-012 (Factory) │ + └── TASK-013 (Controller) ◄─────────────────────────────────┘ + │ +Phase 3 (消息路由层) │ + │ │ + ├── TASK-020 (接口) ◄────────┘ + ├── TASK-021 (实现) + ├── TASK-022 (重构) + └── TASK-023 (Session) + │ +Phase 4 (AI 服务客户端) │ + │ │ + ├── TASK-030 (DTO) ◄─────────┘ + ├── TASK-031 (实现) + ├── TASK-032 (熔断) + └── TASK-033 (清理) + │ +Phase 5 (集成测试) │ + │ │ + ├── TASK-040 ◄───────────────┘ + ├── TASK-041 + └── TASK-042 +``` + +--- + +## 进度统计 + +| 指标 | 数值 | +|-----|------| +| 总任务数 | 20 | +| 已完成 | 4 | +| 进行中 | 0 | +| 待开始 | 16 | +| 完成率 | 20% | diff --git a/src/main/java/com/wecom/robot/WecomRobotApplication.java b/src/main/java/com/wecom/robot/WecomRobotApplication.java new file mode 100644 index 0000000..3e16b98 --- /dev/null +++ b/src/main/java/com/wecom/robot/WecomRobotApplication.java @@ -0,0 +1,18 @@ +package com.wecom.robot; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@MapperScan("com.wecom.robot.mapper") +@EnableAsync +@EnableScheduling +public class WecomRobotApplication { + + public static void main(String[] args) { + SpringApplication.run(WecomRobotApplication.class, args); + } +} diff --git a/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java b/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java new file mode 100644 index 0000000..0cd1cfa --- /dev/null +++ b/src/main/java/com/wecom/robot/adapter/ChannelAdapter.java @@ -0,0 +1,31 @@ +package com.wecom.robot.adapter; + +import com.wecom.robot.dto.OutboundMessage; + +/** + * 渠道适配器核心能力接口 + *

+ * 所有渠道适配器必须实现此接口,提供渠道类型标识和消息发送能力。 + * [AC-MCA-01] 渠道适配层核心接口 + * + * @see ServiceStateCapable + * @see TransferCapable + * @see MessageSyncCapable + */ +public interface ChannelAdapter { + + /** + * 获取渠道类型标识 + * + * @return 渠道类型,如 "wechat", "douyin", "jd" + */ + String getChannelType(); + + /** + * 发送消息到渠道 + * + * @param message 出站消息对象 + * @return 发送是否成功 + */ + boolean sendMessage(OutboundMessage message); +} diff --git a/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java b/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java new file mode 100644 index 0000000..1617777 --- /dev/null +++ b/src/main/java/com/wecom/robot/adapter/ChannelAdapterFactory.java @@ -0,0 +1,115 @@ +package com.wecom.robot.adapter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 渠道适配器工厂 + *

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

+ * 提供从渠道同步历史消息的能力。 + * 渠道适配器可选择性实现此接口。 + * [AC-MCA-01] 渠道适配层可选能力接口 + */ +public interface MessageSyncCapable { + + /** + * 同步消息 + * + * @param kfId 客服账号ID + * @param cursor 游标(用于分页获取) + * @return 同步消息响应 + */ + SyncMsgResponse syncMessages(String kfId, String cursor); +} diff --git a/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java b/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java new file mode 100644 index 0000000..d95c7a9 --- /dev/null +++ b/src/main/java/com/wecom/robot/adapter/ServiceStateCapable.java @@ -0,0 +1,33 @@ +package com.wecom.robot.adapter; + +import com.wecom.robot.dto.ServiceStateResponse; + +/** + * 服务状态管理能力接口(可选) + *

+ * 提供渠道服务状态的获取和变更能力。 + * 渠道适配器可选择性实现此接口。 + * [AC-MCA-01] 渠道适配层可选能力接口 + */ +public interface ServiceStateCapable { + + /** + * 获取服务状态 + * + * @param kfId 客服账号ID + * @param customerId 客户ID + * @return 服务状态响应 + */ + ServiceStateResponse getServiceState(String kfId, String customerId); + + /** + * 变更服务状态 + * + * @param kfId 客服账号ID + * @param customerId 客户ID + * @param newState 新状态值 + * @param servicerId 人工客服ID(可选) + * @return 变更是否成功 + */ + boolean transServiceState(String kfId, String customerId, int newState, String servicerId); +} diff --git a/src/main/java/com/wecom/robot/adapter/TransferCapable.java b/src/main/java/com/wecom/robot/adapter/TransferCapable.java new file mode 100644 index 0000000..54845f1 --- /dev/null +++ b/src/main/java/com/wecom/robot/adapter/TransferCapable.java @@ -0,0 +1,30 @@ +package com.wecom.robot.adapter; + +/** + * 转人工能力接口(可选) + *

+ * 提供将客户转入待接入池或转给指定人工客服的能力。 + * 渠道适配器可选择性实现此接口。 + * [AC-MCA-01] 渠道适配层可选能力接口 + */ +public interface TransferCapable { + + /** + * 转入待接入池 + * + * @param kfId 客服账号ID + * @param customerId 客户ID + * @return 转移是否成功 + */ + boolean transferToPool(String kfId, String customerId); + + /** + * 转给指定人工客服 + * + * @param kfId 客服账号ID + * @param customerId 客户ID + * @param servicerId 人工客服ID + * @return 转移是否成功 + */ + boolean transferToManual(String kfId, String customerId, String servicerId); +} diff --git a/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java b/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java new file mode 100644 index 0000000..e4a0464 --- /dev/null +++ b/src/main/java/com/wecom/robot/adapter/WeChatAdapter.java @@ -0,0 +1,275 @@ +package com.wecom.robot.adapter; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.wecom.robot.config.WecomConfig; +import com.wecom.robot.dto.OutboundMessage; +import com.wecom.robot.dto.ServiceStateResponse; +import com.wecom.robot.dto.SyncMsgResponse; +import com.wecom.robot.dto.WxSendMessageRequest; +import com.wecom.robot.dto.InboundMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * 企业微信渠道适配器 + *

+ * 实现企业微信渠道的消息发送、服务状态管理、转人工、消息同步等能力。 + * [AC-MCA-02] 企业微信渠道适配器实现 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WeChatAdapter implements ChannelAdapter, + ServiceStateCapable, TransferCapable, MessageSyncCapable { + + private static final String CHANNEL_TYPE = "wechat"; + + private static final String GET_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpId}&corpsecret={secret}"; + private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={accessToken}"; + private static final String SYNC_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={accessToken}"; + private static final String GET_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/get?access_token={accessToken}"; + private static final String TRANS_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/trans?access_token={accessToken}"; + private static final String SEND_EVENT_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token={accessToken}"; + + private static final String REDIS_TOKEN_KEY = "wecom:access_token"; + private static final String REDIS_TOKEN_LOCK_KEY = "wecom:access_token_lock"; + private static final String REDIS_CURSOR_KEY_PREFIX = "wecom:cursor:"; + + private final WecomConfig wecomConfig; + private final StringRedisTemplate redisTemplate; + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public String getChannelType() { + return CHANNEL_TYPE; + } + + @Override + public boolean sendMessage(OutboundMessage message) { + WxSendMessageRequest wxRequest = convertToWxRequest(message); + return sendWxMessage(wxRequest); + } + + private WxSendMessageRequest convertToWxRequest(OutboundMessage message) { + String msgType = message.getMsgType(); + if (msgType == null || msgType.isEmpty()) { + msgType = InboundMessage.MSG_TYPE_TEXT; + } + + WxSendMessageRequest wxRequest = new WxSendMessageRequest(); + wxRequest.setTouser(message.getReceiver()); + wxRequest.setOpenKfid(message.getKfId()); + wxRequest.setMsgtype(msgType); + + switch (msgType) { + case InboundMessage.MSG_TYPE_TEXT: + default: + WxSendMessageRequest.TextContent textContent = new WxSendMessageRequest.TextContent(); + textContent.setContent(message.getContent()); + wxRequest.setText(textContent); + break; + } + + return wxRequest; + } + + private boolean sendWxMessage(WxSendMessageRequest request) { + String accessToken = getAccessToken(); + String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(JSON.toJSONString(request), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + JSONObject json = JSON.parseObject(response.getBody()); + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("[AC-MCA-02] 发送消息失败: {}", json); + return false; + } + + log.info("[AC-MCA-02] 消息发送成功: msgId={}", json.getString("msgid")); + return true; + } + + public boolean sendTextMessage(String touser, String openKfid, String content) { + WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content); + return sendWxMessage(request); + } + + @Override + public ServiceStateResponse getServiceState(String kfId, String customerId) { + String accessToken = getAccessToken(); + String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("open_kfid", kfId); + body.put("external_userid", customerId); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("[AC-MCA-02] 获取会话状态响应: {}", response.getBody()); + + return JSON.parseObject(response.getBody(), ServiceStateResponse.class); + } + + @Override + public boolean transServiceState(String kfId, String customerId, int newState, String servicerId) { + String accessToken = getAccessToken(); + String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("open_kfid", kfId); + body.put("external_userid", customerId); + body.put("service_state", newState); + if (servicerId != null && !servicerId.isEmpty()) { + body.put("servicer_userid", servicerId); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("[AC-MCA-02] 变更会话状态响应: {}", response.getBody()); + + JSONObject result = JSON.parseObject(response.getBody()); + return result.getInteger("errcode") == null || result.getInteger("errcode") == 0; + } + + @Override + public boolean transferToPool(String kfId, String customerId) { + return transServiceState(kfId, customerId, ServiceStateResponse.STATE_POOL, null); + } + + @Override + public boolean transferToManual(String kfId, String customerId, String servicerId) { + return transServiceState(kfId, customerId, ServiceStateResponse.STATE_MANUAL, servicerId); + } + + @Override + public SyncMsgResponse syncMessages(String kfId, String cursor) { + String accessToken = getAccessToken(); + String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken); + + String savedCursor = cursor != null ? cursor : getCursor(kfId); + + JSONObject body = new JSONObject(); + body.put("open_kfid", kfId); + if (savedCursor != null && !savedCursor.isEmpty()) { + body.put("cursor", savedCursor); + } + body.put("limit", 1000); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("[AC-MCA-02] sync_msg响应: {}", response.getBody()); + + SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class); + + if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) { + saveCursor(kfId, syncResponse.getNextCursor()); + } + + return syncResponse; + } + + public boolean sendWelcomeMsg(String code, String content) { + String accessToken = getAccessToken(); + String url = SEND_EVENT_MSG_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("code", code); + body.put("msgtype", "text"); + JSONObject text = new JSONObject(); + text.put("content", content); + body.put("text", text); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + JSONObject json = JSON.parseObject(response.getBody()); + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("[AC-MCA-02] 发送欢迎语失败: {}", json); + return false; + } + + log.info("[AC-MCA-02] 发送欢迎语成功"); + return true; + } + + public boolean endSession(String kfId, String customerId) { + return transServiceState(kfId, customerId, ServiceStateResponse.STATE_CLOSED, null); + } + + private String getAccessToken() { + String cachedToken = redisTemplate.opsForValue().get(REDIS_TOKEN_KEY); + if (cachedToken != null) { + return cachedToken; + } + + Boolean locked = redisTemplate.opsForValue().setIfAbsent(REDIS_TOKEN_LOCK_KEY, "1", 10, TimeUnit.SECONDS); + if (Boolean.TRUE.equals(locked)) { + try { + String url = GET_ACCESS_TOKEN_URL + .replace("{corpId}", wecomConfig.getCorpId()) + .replace("{secret}", wecomConfig.getSecret()); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + JSONObject json = JSON.parseObject(response.getBody()); + + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("[AC-MCA-02] 获取access_token失败: {}", json); + throw new RuntimeException("获取access_token失败: " + json.getString("errmsg")); + } + + String accessToken = json.getString("access_token"); + long expiresIn = json.getLongValue("expires_in"); + + redisTemplate.opsForValue().set(REDIS_TOKEN_KEY, accessToken, expiresIn - 300, TimeUnit.SECONDS); + return accessToken; + } finally { + redisTemplate.delete(REDIS_TOKEN_LOCK_KEY); + } + } else { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return getAccessToken(); + } + } + + private String getCursor(String openKfid) { + return redisTemplate.opsForValue().get(REDIS_CURSOR_KEY_PREFIX + openKfid); + } + + private void saveCursor(String openKfid, String cursor) { + redisTemplate.opsForValue().set(REDIS_CURSOR_KEY_PREFIX + openKfid, cursor); + } + + public void clearCursor(String openKfid) { + redisTemplate.delete(REDIS_CURSOR_KEY_PREFIX + openKfid); + } +} diff --git a/src/main/java/com/wecom/robot/config/AiServiceConfig.java b/src/main/java/com/wecom/robot/config/AiServiceConfig.java new file mode 100644 index 0000000..e246cda --- /dev/null +++ b/src/main/java/com/wecom/robot/config/AiServiceConfig.java @@ -0,0 +1,15 @@ +package com.wecom.robot.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "ai-service") +public class AiServiceConfig { + + private String url; + + private int timeout = 5000; +} diff --git a/src/main/java/com/wecom/robot/config/ChannelConfig.java b/src/main/java/com/wecom/robot/config/ChannelConfig.java new file mode 100644 index 0000000..5a10f31 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/ChannelConfig.java @@ -0,0 +1,22 @@ +package com.wecom.robot.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Data +@Component +@ConfigurationProperties(prefix = "channel") +public class ChannelConfig { + + private String defaultChannel = "wechat"; + + private Map adapters; + + @Data + public static class AdapterConfig { + private boolean enabled; + } +} diff --git a/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java b/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..db17d99 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package com.wecom.robot.config; + +import com.wecom.robot.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("参数校验失败: {}", message); + return ApiResponse.error(400, message); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolationException(ConstraintViolationException ex) { + String message = ex.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining("; ")); + log.warn("约束校验失败: {}", message); + return ApiResponse.error(400, message); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleException(Exception ex) { + log.error("服务器内部错误", ex); + return ApiResponse.error(500, "服务器内部错误"); + } +} diff --git a/src/main/java/com/wecom/robot/config/RestTemplateConfig.java b/src/main/java/com/wecom/robot/config/RestTemplateConfig.java new file mode 100644 index 0000000..a49fb63 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/RestTemplateConfig.java @@ -0,0 +1,18 @@ +package com.wecom.robot.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(5000); + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/wecom/robot/config/TransferConfig.java b/src/main/java/com/wecom/robot/config/TransferConfig.java new file mode 100644 index 0000000..432ad1f --- /dev/null +++ b/src/main/java/com/wecom/robot/config/TransferConfig.java @@ -0,0 +1,19 @@ +package com.wecom.robot.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Data +@Component +@ConfigurationProperties(prefix = "transfer") +public class TransferConfig { + + private List keywords; + private double confidenceThreshold; + private int maxFailRounds; + private long maxSessionDuration; + private int maxMessageRounds; +} diff --git a/src/main/java/com/wecom/robot/config/WebSocketConfig.java b/src/main/java/com/wecom/robot/config/WebSocketConfig.java new file mode 100644 index 0000000..97520ec --- /dev/null +++ b/src/main/java/com/wecom/robot/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.wecom.robot.config; + +import com.wecom.robot.websocket.CsWebSocketHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + private final CsWebSocketHandler csWebSocketHandler; + + public WebSocketConfig(CsWebSocketHandler csWebSocketHandler) { + this.csWebSocketHandler = csWebSocketHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(csWebSocketHandler, "/ws/cs/*") + .setAllowedOrigins("*"); + } +} diff --git a/src/main/java/com/wecom/robot/config/WecomConfig.java b/src/main/java/com/wecom/robot/config/WecomConfig.java new file mode 100644 index 0000000..199ef63 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/WecomConfig.java @@ -0,0 +1,25 @@ +package com.wecom.robot.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Data +@Component +@ConfigurationProperties(prefix = "wecom") +public class WecomConfig { + + private String corpId; + private String agentId; + private String secret; + private String token; + private String encodingAesKey; + private KfConfig kf; + + @Data + public static class KfConfig { + private String callbackUrl; + } +} diff --git a/src/main/java/com/wecom/robot/controller/ChatHistoryController.java b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java new file mode 100644 index 0000000..07dbe44 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java @@ -0,0 +1,132 @@ +package com.wecom.robot.controller; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.wecom.robot.dto.ApiResponse; +import com.wecom.robot.entity.Message; +import com.wecom.robot.entity.Session; +import com.wecom.robot.service.SessionManagerService; +import com.wecom.robot.service.WecomApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@Slf4j +@RestController +@RequestMapping("/chat-history/api") +@RequiredArgsConstructor +public class ChatHistoryController { + + private final SessionManagerService sessionManagerService; + private final WecomApiService wecomApiService; + + @GetMapping("/kf-accounts") + public ApiResponse>> getKfAccounts() { + try { + JSONObject result = wecomApiService.getKfAccountList(0, 100); + List> accounts = new ArrayList<>(); + + Integer errcode = result.getInteger("errcode"); + if (errcode == null || errcode == 0) { + JSONArray accountList = result.getJSONArray("account_list"); + if (accountList != null) { + for (int i = 0; i < accountList.size(); i++) { + JSONObject account = accountList.getJSONObject(i); + Map map = new HashMap<>(); + map.put("openKfId", account.getString("open_kfid")); + map.put("name", account.getString("name")); + map.put("avatar", account.getString("avatar")); + accounts.add(map); + } + } + } + + return ApiResponse.success(accounts); + } catch (Exception e) { + log.error("获取客服账号列表失败", e); + return ApiResponse.error("获取客服账号列表失败: " + e.getMessage()); + } + } + + @GetMapping("/sessions") + public ApiResponse>> getSessions( + @RequestParam String openKfId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "50") int limit) { + try { + List sessions = sessionManagerService.getSessionsByKfId(openKfId, status, limit); + List> result = new ArrayList<>(); + + for (Session session : sessions) { + Map map = new HashMap<>(); + map.put("sessionId", session.getSessionId()); + map.put("customerId", session.getCustomerId()); + map.put("kfId", session.getKfId()); + map.put("status", session.getStatus()); + map.put("wxServiceState", session.getWxServiceState()); + map.put("manualCsId", session.getManualCsId()); + map.put("createdAt", session.getCreatedAt() != null ? session.getCreatedAt().toString() : null); + map.put("updatedAt", session.getUpdatedAt() != null ? session.getUpdatedAt().toString() : null); + + int msgCount = sessionManagerService.getMessageCount(session.getSessionId()); + map.put("messageCount", msgCount); + + result.add(map); + } + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取会话列表失败", e); + return ApiResponse.error("获取会话列表失败: " + e.getMessage()); + } + } + + @GetMapping("/messages") + public ApiResponse>> getMessages(@RequestParam String sessionId) { + try { + List messages = sessionManagerService.getSessionMessages(sessionId); + List> result = new ArrayList<>(); + + for (Message msg : messages) { + Map map = new HashMap<>(); + map.put("msgId", msg.getMsgId()); + map.put("sessionId", msg.getSessionId()); + map.put("senderType", msg.getSenderType()); + map.put("senderId", msg.getSenderId()); + map.put("content", msg.getContent()); + map.put("msgType", msg.getMsgType()); + map.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null); + result.add(map); + } + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取消息列表失败", e); + return ApiResponse.error("获取消息列表失败: " + e.getMessage()); + } + } + + @GetMapping("/session/{sessionId}/detail") + public ApiResponse> getSessionDetail(@PathVariable String sessionId) { + try { + Session session = sessionManagerService.getSession(sessionId); + if (session == null) { + return ApiResponse.error("会话不存在"); + } + + Map result = new HashMap<>(); + result.put("session", session); + + List messages = sessionManagerService.getSessionMessages(sessionId); + result.put("messages", messages); + result.put("messageCount", messages.size()); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取会话详情失败", e); + return ApiResponse.error("获取会话详情失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/wecom/robot/controller/DebugController.java b/src/main/java/com/wecom/robot/controller/DebugController.java new file mode 100644 index 0000000..7c5535c --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/DebugController.java @@ -0,0 +1,141 @@ +package com.wecom.robot.controller; + +import com.wecom.robot.config.WecomConfig; +import com.wecom.robot.dto.ApiResponse; +import com.wecom.robot.entity.Message; +import com.wecom.robot.service.SessionManagerService; +import com.wecom.robot.util.WXBizMsgCrypt; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/debug") +@RequiredArgsConstructor +public class DebugController { + + private final WecomConfig wecomConfig; + private final SessionManagerService sessionManagerService; + + @GetMapping("/config") + public ApiResponse> getConfig() { + Map config = new HashMap<>(); + config.put("corpId", wecomConfig.getCorpId()); + config.put("token", wecomConfig.getToken()); + config.put("encodingAesKey", wecomConfig.getEncodingAesKey()); + config.put("encodingAesKeyLength", wecomConfig.getEncodingAesKey() != null ? wecomConfig.getEncodingAesKey().length() : 0); + + try { + byte[] aesKey = Base64.getDecoder().decode(wecomConfig.getEncodingAesKey() + "="); + config.put("aesKeyLength", aesKey.length); + config.put("aesKeyHex", bytesToHex(aesKey)); + } catch (Exception e) { + config.put("aesKeyError", e.getMessage()); + } + + return ApiResponse.success(config); + } + + @PostMapping("/decrypt") + public ApiResponse> testDecrypt( + @RequestParam String msgSignature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestBody String encryptedXml) { + + Map result = new HashMap<>(); + result.put("msgSignature", msgSignature); + result.put("timestamp", timestamp); + result.put("nonce", nonce); + + try { + WXBizMsgCrypt wxcpt = new WXBizMsgCrypt( + wecomConfig.getToken(), + wecomConfig.getEncodingAesKey(), + wecomConfig.getCorpId() + ); + + String decrypted = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, encryptedXml); + result.put("decrypted", decrypted); + result.put("success", true); + } catch (Exception e) { + result.put("error", e.getMessage()); + result.put("success", false); + log.error("解密测试失败", e); + } + + return ApiResponse.success(result); + } + + @GetMapping("/verify-url") + public ApiResponse> testVerifyUrl( + @RequestParam String msgSignature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestParam String echostr) { + + Map result = new HashMap<>(); + result.put("msgSignature", msgSignature); + result.put("timestamp", timestamp); + result.put("nonce", nonce); + result.put("echostr", echostr); + + try { + WXBizMsgCrypt wxcpt = new WXBizMsgCrypt( + wecomConfig.getToken(), + wecomConfig.getEncodingAesKey(), + wecomConfig.getCorpId() + ); + + String decrypted = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr); + result.put("decrypted", decrypted); + result.put("success", true); + } catch (Exception e) { + result.put("error", e.getMessage()); + result.put("success", false); + log.error("URL验证测试失败", e); + } + + return ApiResponse.success(result); + } + + @GetMapping("/ai/session/{sessionId}/context") + public ApiResponse> getSessionAiContext( + @PathVariable String sessionId) { + + Map result = new HashMap<>(); + + result.put("sessionId", sessionId); + + List history = sessionManagerService.getSessionMessages(sessionId); + result.put("historyCount", history.size()); + + List> historyList = new java.util.ArrayList<>(); + for (Message msg : history) { + Map msgMap = new HashMap<>(); + msgMap.put("senderType", msg.getSenderType()); + msgMap.put("senderId", msg.getSenderId()); + msgMap.put("content", msg.getContent()); + msgMap.put("msgType", msg.getMsgType()); + msgMap.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null); + historyList.add(msgMap); + } + result.put("history", historyList); + + return ApiResponse.success(result); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/wecom/robot/controller/SessionController.java b/src/main/java/com/wecom/robot/controller/SessionController.java new file mode 100644 index 0000000..4ddd9ff --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/SessionController.java @@ -0,0 +1,197 @@ +package com.wecom.robot.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.wecom.robot.dto.*; +import com.wecom.robot.entity.Message; +import com.wecom.robot.entity.Session; +import com.wecom.robot.mapper.MessageMapper; +import com.wecom.robot.mapper.SessionMapper; +import com.wecom.robot.service.SessionManagerService; +import com.wecom.robot.service.WecomApiService; +import com.wecom.robot.service.WebSocketService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequestMapping("/api/sessions") +@RequiredArgsConstructor +public class SessionController { + + private final SessionMapper sessionMapper; + private final MessageMapper messageMapper; + private final SessionManagerService sessionManagerService; + private final WecomApiService wecomApiService; + private final WebSocketService webSocketService; + + @GetMapping + public ApiResponse> getSessions( + @RequestParam(required = false) String status, + @RequestParam(required = false) String csId, + @RequestParam(required = false) String channelType) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + + if (status != null) { + query.eq(Session::getStatus, status); + } + + if (csId != null) { + query.eq(Session::getManualCsId, csId); + } + + if (channelType != null) { + query.eq(Session::getChannelType, channelType); + } + + query.orderByDesc(Session::getUpdatedAt); + + List sessions = sessionMapper.selectList(query); + + List sessionInfos = sessions.stream().map(session -> { + SessionInfo info = new SessionInfo(); + info.setSessionId(session.getSessionId()); + info.setCustomerId(session.getCustomerId()); + info.setKfId(session.getKfId()); + info.setChannelType(session.getChannelType()); + info.setStatus(session.getStatus()); + info.setManualCsId(session.getManualCsId()); + info.setCreatedAt(session.getCreatedAt()); + info.setUpdatedAt(session.getUpdatedAt()); + info.setMetadata(session.getMetadata()); + + LambdaQueryWrapper msgQuery = new LambdaQueryWrapper<>(); + msgQuery.eq(Message::getSessionId, session.getSessionId()) + .orderByDesc(Message::getCreatedAt) + .last("LIMIT 1"); + Message lastMsg = messageMapper.selectOne(msgQuery); + if (lastMsg != null) { + info.setLastMessage(lastMsg.getContent()); + info.setLastMessageTime(lastMsg.getCreatedAt()); + } + + int msgCount = sessionManagerService.getMessageCount(session.getSessionId()); + info.setMessageCount(msgCount); + + return info; + }).collect(Collectors.toList()); + + return ApiResponse.success(sessionInfos); + } + + @GetMapping("/{sessionId}") + public ApiResponse getSession(@PathVariable String sessionId) { + Session session = sessionMapper.selectById(sessionId); + if (session == null) { + return ApiResponse.error(404, "会话不存在"); + } + + SessionInfo info = new SessionInfo(); + info.setSessionId(session.getSessionId()); + info.setCustomerId(session.getCustomerId()); + info.setKfId(session.getKfId()); + info.setChannelType(session.getChannelType()); + info.setStatus(session.getStatus()); + info.setManualCsId(session.getManualCsId()); + info.setCreatedAt(session.getCreatedAt()); + info.setUpdatedAt(session.getUpdatedAt()); + info.setMetadata(session.getMetadata()); + + return ApiResponse.success(info); + } + + @GetMapping("/{sessionId}/history") + public ApiResponse> getSessionHistory(@PathVariable String sessionId) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Message::getSessionId, sessionId) + .orderByAsc(Message::getCreatedAt); + + List messages = messageMapper.selectList(query); + + List messageInfos = messages.stream().map(msg -> { + MessageInfo info = new MessageInfo(); + info.setMsgId(msg.getMsgId()); + info.setSessionId(msg.getSessionId()); + info.setSenderType(msg.getSenderType()); + info.setSenderId(msg.getSenderId()); + info.setContent(msg.getContent()); + info.setMsgType(msg.getMsgType()); + info.setCreatedAt(msg.getCreatedAt()); + return info; + }).collect(Collectors.toList()); + + return ApiResponse.success(messageInfos); + } + + @PostMapping("/{sessionId}/accept") + public ApiResponse acceptSession( + @PathVariable String sessionId, + @Valid @RequestBody AcceptSessionRequest request) { + Session session = sessionMapper.selectById(sessionId); + if (session == null) { + return ApiResponse.error(404, "会话不存在"); + } + + if (!Session.STATUS_PENDING.equals(session.getStatus())) { + return ApiResponse.error(400, "会话状态不正确"); + } + + sessionManagerService.acceptTransfer(sessionId, request.getCsId()); + webSocketService.notifySessionAccepted(sessionId, request.getCsId()); + + return ApiResponse.success(null); + } + + @PostMapping("/{sessionId}/message") + public ApiResponse sendMessage( + @PathVariable String sessionId, + @Valid @RequestBody SendMessageRequest request) { + Session session = sessionMapper.selectById(sessionId); + if (session == null) { + return ApiResponse.error(404, "会话不存在"); + } + + if (!Session.STATUS_MANUAL.equals(session.getStatus())) { + return ApiResponse.error(400, "会话状态不正确"); + } + + boolean success = wecomApiService.sendTextMessage( + session.getCustomerId(), + session.getKfId(), + request.getContent() + ); + + if (!success) { + return ApiResponse.error(500, "消息发送失败"); + } + + sessionManagerService.saveMessage( + "manual_" + System.currentTimeMillis(), + sessionId, + Message.SENDER_TYPE_MANUAL, + session.getManualCsId(), + request.getContent(), + request.getMsgType() != null ? request.getMsgType() : "text", + null + ); + + return ApiResponse.success(null); + } + + @PostMapping("/{sessionId}/close") + public ApiResponse closeSession(@PathVariable String sessionId) { + Session session = sessionMapper.selectById(sessionId); + if (session == null) { + return ApiResponse.error(404, "会话不存在"); + } + + sessionManagerService.closeSession(sessionId); + webSocketService.notifySessionClosed(sessionId); + + return ApiResponse.success(null); + } +} diff --git a/src/main/java/com/wecom/robot/controller/TestController.java b/src/main/java/com/wecom/robot/controller/TestController.java new file mode 100644 index 0000000..e80d707 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/TestController.java @@ -0,0 +1,150 @@ +package com.wecom.robot.controller; + +import com.alibaba.fastjson.JSON; +import com.wecom.robot.dto.ApiResponse; +import com.wecom.robot.dto.SyncMsgResponse; +import com.wecom.robot.dto.WxCallbackMessage; +import com.wecom.robot.service.MessageProcessService; +import com.wecom.robot.service.WecomApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/test") +@RequiredArgsConstructor +public class TestController { + + private final MessageProcessService messageProcessService; + private final WecomApiService wecomApiService; + + @PostMapping("/send-message") + public ApiResponse> sendTestMessage( + @RequestParam(required = false, defaultValue = "test_customer_001") String customerId, + @RequestParam(required = false, defaultValue = "test_kf_001") String kfId, + @RequestParam String content) { + + WxCallbackMessage message = new WxCallbackMessage(); + message.setToUserName("system"); + message.setFromUserName(customerId); + message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000)); + message.setMsgType("text"); + message.setContent(content); + message.setMsgId(UUID.randomUUID().toString()); + message.setOpenKfId(kfId); + message.setExternalUserId(customerId); + + Map rawData = new HashMap<>(); + rawData.put("ToUserName", "system"); + rawData.put("FromUserName", customerId); + rawData.put("MsgType", "text"); + rawData.put("Content", content); + rawData.put("MsgId", message.getMsgId()); + rawData.put("OpenKfId", kfId); + rawData.put("ExternalUserId", customerId); + message.setRawData(rawData); + + log.info("模拟发送消息(测试模式): customerId={}, kfId={}, content={}", customerId, kfId, content); + + messageProcessService.processMessage(message); + + Map result = new HashMap<>(); + result.put("msgId", message.getMsgId()); + result.put("customerId", customerId); + result.put("kfId", kfId); + result.put("content", content); + result.put("mode", "test_direct"); + + return ApiResponse.success(result); + } + + @PostMapping("/trigger-transfer") + public ApiResponse> triggerTransfer( + @RequestParam(required = false, defaultValue = "test_customer_001") String customerId, + @RequestParam(required = false, defaultValue = "test_kf_001") String kfId) { + + WxCallbackMessage message = new WxCallbackMessage(); + message.setToUserName("system"); + message.setFromUserName(customerId); + message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000)); + message.setMsgType("text"); + message.setContent("我要转人工"); + message.setMsgId(UUID.randomUUID().toString()); + message.setOpenKfId(kfId); + message.setExternalUserId(customerId); + + Map rawData = new HashMap<>(); + rawData.put("ToUserName", "system"); + rawData.put("FromUserName", customerId); + rawData.put("MsgType", "text"); + rawData.put("Content", "我要转人工"); + rawData.put("MsgId", message.getMsgId()); + rawData.put("OpenKfId", kfId); + rawData.put("ExternalUserId", customerId); + message.setRawData(rawData); + + log.info("模拟触发转人工(测试模式): customerId={}, kfId={}", customerId, kfId); + + messageProcessService.processMessage(message); + + Map result = new HashMap<>(); + result.put("msgId", message.getMsgId()); + result.put("customerId", customerId); + result.put("kfId", kfId); + result.put("trigger", "transfer"); + result.put("mode", "test_direct"); + + return ApiResponse.success(result); + } + + @PostMapping("/simulate-event") + public ApiResponse> simulateKfMsgEvent( + @RequestParam(required = false, defaultValue = "test_kf_001") String kfId, + @RequestParam(required = false) String token) { + + WxCallbackMessage event = new WxCallbackMessage(); + event.setMsgType("event"); + event.setEvent(WxCallbackMessage.EVENT_KF_MSG_FROM_CUSTOMER); + event.setOpenKfId(kfId); + event.setToken(token); + event.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000)); + + log.info("模拟客户消息事件: kfId={}, token={}", kfId, token); + + messageProcessService.processKfMessageEvent(event); + + Map result = new HashMap<>(); + result.put("kfId", kfId); + result.put("token", token); + result.put("mode", "event_sync"); + + return ApiResponse.success(result); + } + + @GetMapping("/sync-msg") + public ApiResponse syncMessages( + @RequestParam String kfId, + @RequestParam(required = false) String token) { + + log.info("手动拉取消息: kfId={}, token={}", kfId, token); + + SyncMsgResponse response = wecomApiService.syncMessagesByToken(kfId, token); + + return ApiResponse.success(response); + } + + @PostMapping("/clear-cursor") + public ApiResponse clearCursor( + @RequestParam String kfId) { + + log.info("清除cursor: kfId={}", kfId); + wecomApiService.clearCursor(kfId); + + return ApiResponse.success(null); + } +} diff --git a/src/main/java/com/wecom/robot/controller/WecomCallbackController.java b/src/main/java/com/wecom/robot/controller/WecomCallbackController.java new file mode 100644 index 0000000..5bb3132 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/WecomCallbackController.java @@ -0,0 +1,114 @@ +package com.wecom.robot.controller; + +import com.wecom.robot.adapter.ChannelAdapter; +import com.wecom.robot.adapter.MessageSyncCapable; +import com.wecom.robot.config.WecomConfig; +import com.wecom.robot.dto.*; +import com.wecom.robot.service.MessageProcessService; +import com.wecom.robot.service.MessageRouterService; +import com.wecom.robot.util.WXBizMsgCrypt; +import com.wecom.robot.util.XmlUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 企业微信回调控制器 + *

+ * 负责验签/解密/解析,构建 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 channelAdapters; + + @GetMapping("/callback") + public String verifyUrl( + @RequestParam("msg_signature") String msgSignature, + @RequestParam("timestamp") String timestamp, + @RequestParam("nonce") String nonce, + @RequestParam("echostr") String echostr) { + log.info("收到URL验证请求: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce); + + try { + WXBizMsgCrypt wxcpt = new WXBizMsgCrypt( + wecomConfig.getToken(), + wecomConfig.getEncodingAesKey(), + wecomConfig.getCorpId() + ); + String result = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr); + log.info("URL验证成功,返回: {}", result); + return result; + } catch (Exception e) { + log.error("URL验证失败", e); + return "error"; + } + } + + @PostMapping("/callback") + public String handleCallback( + @RequestParam(value = "msg_signature", required = false) String msgSignature, + @RequestParam(value = "timestamp", required = false) String timestamp, + @RequestParam(value = "nonce", required = false) String nonce, + @RequestBody String requestBody) { + log.info("收到回调消息: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce); + log.debug("消息内容: {}", requestBody); + + try { + WXBizMsgCrypt wxcpt = new WXBizMsgCrypt( + wecomConfig.getToken(), + wecomConfig.getEncodingAesKey(), + wecomConfig.getCorpId() + ); + + String decryptedXml = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, requestBody); + log.info("解密后的XML: {}", decryptedXml); + + Map messageMap = XmlUtil.parseXml(decryptedXml); + WxCallbackMessage message = WxCallbackMessage.fromMap(messageMap); + + log.info("解析后的消息: msgType={}, event={}, openKfId={}", + message.getMsgType(), message.getEvent(), message.getOpenKfId()); + + if ("event".equals(message.getMsgType())) { + handleEvent(message); + } else { + log.warn("收到非事件消息: msgType={}", message.getMsgType()); + } + + return "success"; + } catch (Exception e) { + log.error("处理回调消息失败", e); + return "success"; + } + } + + private void handleEvent(WxCallbackMessage message) { + String event = message.getEvent(); + log.info("处理事件: event={}, openKfId={}, token={}", + event, message.getOpenKfId(), message.getToken()); + + if (message.isKfMsgEvent()) { + log.info("收到客户消息事件通知: openKfId={}, token={}", + message.getOpenKfId(), message.getToken()); + messageProcessService.processKfMessageEvent(message); + } else if (message.isAccountOnlineEvent()) { + log.info("客服账号上线: openKfId={}", message.getOpenKfId()); + } else if (message.isAccountOfflineEvent()) { + log.info("客服账号下线: openKfId={}", message.getOpenKfId()); + } else { + log.info("其他事件: event={}", event); + } + } +} diff --git a/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java b/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java new file mode 100644 index 0000000..8162911 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java @@ -0,0 +1,14 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class AcceptSessionRequest { + + @NotBlank(message = "客服ID不能为空") + @Size(min = 1, max = 64, message = "客服ID长度必须在1-64之间") + private String csId; +} diff --git a/src/main/java/com/wecom/robot/dto/ApiResponse.java b/src/main/java/com/wecom/robot/dto/ApiResponse.java new file mode 100644 index 0000000..367c811 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ApiResponse.java @@ -0,0 +1,30 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class ApiResponse { + + private int code; + private String message; + private T data; + + public static ApiResponse success(T data) { + ApiResponse response = new ApiResponse<>(); + response.setCode(0); + response.setMessage("success"); + response.setData(data); + return response; + } + + public static ApiResponse error(int code, String message) { + ApiResponse response = new ApiResponse<>(); + response.setCode(code); + response.setMessage(message); + return response; + } + + public static ApiResponse error(String message) { + return error(500, message); + } +} diff --git a/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java b/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java new file mode 100644 index 0000000..4796eb2 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java @@ -0,0 +1,34 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class ChatCompletionRequest { + + private String model; + private List messages; + private double temperature; + private int maxTokens; + + @Data + public static class Message { + private String role; + private String content; + + public Message(String role, String content) { + this.role = role; + this.content = content; + } + } + + public static ChatCompletionRequest create(String model, List messages) { + ChatCompletionRequest request = new ChatCompletionRequest(); + request.setModel(model); + request.setMessages(messages); + request.setTemperature(0.7); + request.setMaxTokens(2000); + return request; + } +} diff --git a/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java b/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java new file mode 100644 index 0000000..c9bd67c --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java @@ -0,0 +1,43 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class ChatCompletionResponse { + + private String id; + private String object; + private long created; + private String model; + private List choices; + private Usage usage; + + @Data + public static class Choice { + private int index; + private Message message; + private String finishReason; + } + + @Data + public static class Message { + private String role; + private String content; + } + + @Data + public static class Usage { + private int promptTokens; + private int completionTokens; + private int totalTokens; + } + + public String getContent() { + if (choices != null && !choices.isEmpty()) { + return choices.get(0).getMessage().getContent(); + } + return null; + } +} diff --git a/src/main/java/com/wecom/robot/dto/InboundMessage.java b/src/main/java/com/wecom/robot/dto/InboundMessage.java new file mode 100644 index 0000000..c1136cd --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/InboundMessage.java @@ -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 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"; +} diff --git a/src/main/java/com/wecom/robot/dto/MessageInfo.java b/src/main/java/com/wecom/robot/dto/MessageInfo.java new file mode 100644 index 0000000..becc891 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/MessageInfo.java @@ -0,0 +1,32 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class MessageInfo { + + @NotBlank + @Size(min = 1, max = 128) + private String msgId; + + @NotBlank + @Size(min = 1, max = 64) + private String sessionId; + + @NotBlank + private String senderType; + + @Size(max = 64) + private String senderId; + + @NotBlank + @Size(min = 1, max = 4096) + private String content; + + private String msgType; + + private java.time.LocalDateTime createdAt; +} diff --git a/src/main/java/com/wecom/robot/dto/OutboundMessage.java b/src/main/java/com/wecom/robot/dto/OutboundMessage.java new file mode 100644 index 0000000..cb5420a --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/OutboundMessage.java @@ -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 metadata; +} diff --git a/src/main/java/com/wecom/robot/dto/SendMessageRequest.java b/src/main/java/com/wecom/robot/dto/SendMessageRequest.java new file mode 100644 index 0000000..c9badc3 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SendMessageRequest.java @@ -0,0 +1,16 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class SendMessageRequest { + + @NotBlank(message = "消息内容不能为空") + @Size(min = 1, max = 4096, message = "消息内容长度必须在1-4096之间") + private String content; + + private String msgType; +} diff --git a/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java b/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java new file mode 100644 index 0000000..111a670 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java @@ -0,0 +1,34 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class ServiceStateResponse { + + private Integer errcode; + private String errmsg; + private Integer serviceState; + private String servicerUserid; + + public boolean isSuccess() { + return errcode == null || errcode == 0; + } + + public static final int STATE_UNTREATED = 0; + public static final int STATE_AI = 1; + public static final int STATE_POOL = 2; + public static final int STATE_MANUAL = 3; + public static final int STATE_CLOSED = 4; + + public String getStateDesc() { + if (serviceState == null) return "未知"; + switch (serviceState) { + case STATE_UNTREATED: return "未处理"; + case STATE_AI: return "智能助手接待"; + case STATE_POOL: return "待接入池排队"; + case STATE_MANUAL: return "人工接待"; + case STATE_CLOSED: return "已结束"; + default: return "未知(" + serviceState + ")"; + } + } +} diff --git a/src/main/java/com/wecom/robot/dto/SessionInfo.java b/src/main/java/com/wecom/robot/dto/SessionInfo.java new file mode 100644 index 0000000..0b7a912 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SessionInfo.java @@ -0,0 +1,44 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; + +@Data +public class SessionInfo { + + @NotBlank + @Size(min = 1, max = 64) + private String sessionId; + + @NotBlank + @Size(min = 1, max = 64) + private String customerId; + + @Size(max = 64) + private String kfId; + + @Size(max = 64) + private String channelType; + + @NotBlank + private String status; + + @Size(max = 64) + private String manualCsId; + + @Size(max = 4096) + private String lastMessage; + + private LocalDateTime lastMessageTime; + + private int messageCount; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private String metadata; +} diff --git a/src/main/java/com/wecom/robot/dto/SignatureInfo.java b/src/main/java/com/wecom/robot/dto/SignatureInfo.java new file mode 100644 index 0000000..ade7eff --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SignatureInfo.java @@ -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; +} diff --git a/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java b/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java new file mode 100644 index 0000000..387bb4e --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java @@ -0,0 +1,212 @@ +package com.wecom.robot.dto; + +import com.alibaba.fastjson.annotation.JSONField; +import lombok.Data; + +import java.util.List; + +@Data +public class SyncMsgResponse { + + private Integer errcode; + private String errmsg; + private String nextCursor; + private Boolean hasMore; + private List msgList; + + @Data + public static class MsgItem { + private String msgId; + private Integer origin; + private String externalUserId; + private String openKfId; + private Long sendTime; + private String msgType; + private String servicerUserid; + private String originData; + + private TextContent text; + private ImageContent image; + private VoiceContent voice; + private VideoContent video; + private FileContent file; + private LocationContent location; + private LinkContent link; + private BusinessCardContent businessCard; + private MiniprogramContent miniprogram; + private MsgMenuContent msgmenu; + private EventContent event; + + public static final int ORIGIN_CUSTOMER = 3; + public static final int ORIGIN_SYSTEM_EVENT = 4; + public static final int ORIGIN_SERVICER = 5; + + public String getTextContent() { + return text != null ? text.getContent() : null; + } + + public String getImageMediaId() { + return image != null ? image.getMediaId() : null; + } + + public boolean isFromCustomer() { + return origin != null && origin == ORIGIN_CUSTOMER; + } + + public boolean isSystemEvent() { + return origin != null && origin == ORIGIN_SYSTEM_EVENT; + } + + public boolean isFromServicer() { + return origin != null && origin == ORIGIN_SERVICER; + } + + public boolean isEvent() { + return "event".equals(msgType); + } + + public String getEventType() { + return event != null ? event.getEventType() : null; + } + } + + @Data + public static class TextContent { + private String content; + private String menuId; + } + + @Data + public static class ImageContent { + private String mediaId; + } + + @Data + public static class VoiceContent { + private String mediaId; + } + + @Data + public static class VideoContent { + private String mediaId; + } + + @Data + public static class FileContent { + private String mediaId; + } + + @Data + public static class LocationContent { + private Double latitude; + private Double longitude; + private String name; + private String address; + } + + @Data + public static class LinkContent { + private String title; + private String desc; + private String url; + private String picUrl; + } + + @Data + public static class BusinessCardContent { + private String userid; + } + + @Data + public static class MiniprogramContent { + private String title; + private String appid; + private String pagepath; + private String thumbMediaId; + } + + @Data + public static class MsgMenuContent { + private String headContent; + private List list; + private String tailContent; + } + + @Data + public static class MenuItem { + private String type; + private MenuClick click; + private MenuView view; + private MenuMiniprogram miniprogram; + } + + @Data + public static class MenuClick { + private String id; + private String content; + } + + @Data + public static class MenuView { + private String url; + private String content; + } + + @Data + public static class MenuMiniprogram { + private String appid; + private String pagepath; + private String content; + } + + @Data + public static class EventContent { + @JSONField(name = "event_type") + private String eventType; + private String openKfId; + private String externalUserid; + private String scene; + private String sceneParam; + private String welcomeCode; + private String failMsgid; + private Integer failType; + private String servicerUserid; + private Integer status; + private Integer stopType; + private Integer changeType; + private String oldServicerUserid; + private String newServicerUserid; + private String msgCode; + private String recallMsgid; + private Integer rejectSwitch; + private WechatChannels wechatChannels; + + public static final String EVENT_ENTER_SESSION = "enter_session"; + public static final String EVENT_MSG_SEND_FAIL = "msg_send_fail"; + public static final String EVENT_SERVICER_STATUS_CHANGE = "servicer_status_change"; + public static final String EVENT_SESSION_STATUS_CHANGE = "session_status_change"; + public static final String EVENT_USER_RECALL_MSG = "user_recall_msg"; + public static final String EVENT_SERVICER_RECALL_MSG = "servicer_recall_msg"; + public static final String EVENT_REJECT_CUSTOMER_MSG_SWITCH_CHANGE = "reject_customer_msg_switch_change"; + + public static final int CHANGE_TYPE_FROM_POOL = 1; + public static final int CHANGE_TYPE_TRANSFER = 2; + public static final int CHANGE_TYPE_END = 3; + public static final int CHANGE_TYPE_REENTER = 4; + } + + @Data + public static class WechatChannels { + private String nickname; + private String shopNickname; + private Integer scene; + } + + public boolean isSuccess() { + return errcode == null || errcode == 0; + } + + public boolean hasMessages() { + return msgList != null && !msgList.isEmpty(); + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxAccessToken.java b/src/main/java/com/wecom/robot/dto/WxAccessToken.java new file mode 100644 index 0000000..89cda3f --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxAccessToken.java @@ -0,0 +1,21 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class WxAccessToken { + + private String accessToken; + private long expiresIn; + private long createTime; + + public WxAccessToken(String accessToken, long expiresIn) { + this.accessToken = accessToken; + this.expiresIn = expiresIn; + this.createTime = System.currentTimeMillis(); + } + + public boolean isExpired() { + return System.currentTimeMillis() - createTime > (expiresIn - 300) * 1000; + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java b/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java new file mode 100644 index 0000000..b0e459d --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java @@ -0,0 +1,65 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +import java.util.Map; + +@Data +public class WxCallbackMessage { + + private String toUserName; + private String fromUserName; + private String createTime; + private String msgType; + private String content; + private String msgId; + private String event; + private String openKfId; + private String externalUserId; + private String welcomeCode; + private String token; + private String origin; + private String serviceCorpId; + private String changeType; + private String servicerUserid; + private Map rawData; + + public static final String EVENT_KF_MSG_OR_EVENT = "kf_msg_or_event"; + public static final String EVENT_KF_MSG_FROM_CUSTOMER = "kf_msg_from_customer"; + public static final String EVENT_KF_ACCOUNT_ONLINE = "kf_account_online"; + public static final String EVENT_KF_ACCOUNT_OFFLINE = "kf_account_offline"; + public static final String EVENT_MSG_AUDIT_APPROVED = "msg_audit_approved"; + + public static WxCallbackMessage fromMap(Map map) { + WxCallbackMessage msg = new WxCallbackMessage(); + msg.setToUserName(map.get("ToUserName")); + msg.setFromUserName(map.get("FromUserName")); + msg.setCreateTime(map.get("CreateTime")); + msg.setMsgType(map.get("MsgType")); + msg.setContent(map.get("Content")); + msg.setMsgId(map.get("MsgId")); + msg.setEvent(map.get("Event")); + msg.setOpenKfId(map.get("OpenKfId")); + msg.setExternalUserId(map.get("ExternalUserId")); + msg.setWelcomeCode(map.get("WelcomeCode")); + msg.setToken(map.get("Token")); + msg.setOrigin(map.get("Origin")); + msg.setServiceCorpId(map.get("ServiceCorpId")); + msg.setChangeType(map.get("ChangeType")); + msg.setServicerUserid(map.get("ServicerUserid")); + msg.setRawData(map); + return msg; + } + + public boolean isKfMsgEvent() { + return EVENT_KF_MSG_OR_EVENT.equals(event) || EVENT_KF_MSG_FROM_CUSTOMER.equals(event); + } + + public boolean isAccountOnlineEvent() { + return EVENT_KF_ACCOUNT_ONLINE.equals(event); + } + + public boolean isAccountOfflineEvent() { + return EVENT_KF_ACCOUNT_OFFLINE.equals(event); + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java b/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java new file mode 100644 index 0000000..fbfa517 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java @@ -0,0 +1,58 @@ +package com.wecom.robot.dto; + +import com.alibaba.fastjson.annotation.JSONField; +import lombok.Data; + +@Data +public class WxSendMessageRequest { + + private String touser; + + @JSONField(name = "open_kfid") + private String openKfid; + + private String msgtype; + private TextContent text; + private ImageContent image; + private LinkContent link; + + @Data + public static class TextContent { + private String content; + } + + @Data + public static class ImageContent { + private String mediaId; + } + + @Data + public static class LinkContent { + private String title; + private String desc; + private String url; + private String thumbMediaId; + } + + public static WxSendMessageRequest text(String touser, String openKfid, String content) { + WxSendMessageRequest request = new WxSendMessageRequest(); + request.setTouser(touser); + request.setOpenKfid(openKfid); + request.setMsgtype("text"); + TextContent textContent = new TextContent(); + textContent.setContent(content); + request.setText(textContent); + return request; + } + + public static WxSendMessageRequest image(String touser, String openKfid, String mediaId) { + WxSendMessageRequest request = new WxSendMessageRequest(); + request.setTouser(touser); + request.setOpenKfid(openKfid); + request.setMsgtype("image"); + ImageContent imageContent = new ImageContent(); + imageContent.setMediaId(mediaId); + request.setImage(imageContent); + return request; + } +} diff --git a/src/main/java/com/wecom/robot/dto/ai/ChatMessage.java b/src/main/java/com/wecom/robot/dto/ai/ChatMessage.java new file mode 100644 index 0000000..6f6cfdd --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ai/ChatMessage.java @@ -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(); + } +} diff --git a/src/main/java/com/wecom/robot/dto/ai/ChatRequest.java b/src/main/java/com/wecom/robot/dto/ai/ChatRequest.java new file mode 100644 index 0000000..1b7a6ef --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ai/ChatRequest.java @@ -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 history = new ArrayList<>(); + + @Builder.Default + private Map 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 history) { + return ChatRequest.builder() + .sessionId(msg.getSessionKey()) + .currentMessage(msg.getContent()) + .channelType(msg.getChannelType()) + .history(history) + .build(); + } +} diff --git a/src/main/java/com/wecom/robot/dto/ai/ChatResponse.java b/src/main/java/com/wecom/robot/dto/ai/ChatResponse.java new file mode 100644 index 0000000..5a62c66 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ai/ChatResponse.java @@ -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 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(); + } +} diff --git a/src/main/java/com/wecom/robot/entity/KfAccount.java b/src/main/java/com/wecom/robot/entity/KfAccount.java new file mode 100644 index 0000000..4d8a7b5 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/KfAccount.java @@ -0,0 +1,34 @@ +package com.wecom.robot.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("kf_account") +public class KfAccount implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "kf_id", type = IdType.INPUT) + private String kfId; + + private String name; + + private String avatar; + + private String status; + + private String bindManualId; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + public static final String STATUS_ONLINE = "online"; + public static final String STATUS_OFFLINE = "offline"; +} diff --git a/src/main/java/com/wecom/robot/entity/Message.java b/src/main/java/com/wecom/robot/entity/Message.java new file mode 100644 index 0000000..fb5c366 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/Message.java @@ -0,0 +1,39 @@ +package com.wecom.robot.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("message") +public class Message implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "msg_id", type = IdType.ASSIGN_ID) + private String msgId; + + private String sessionId; + + private String senderType; + + private String senderId; + + private String content; + + private String msgType; + + private LocalDateTime createdAt; + + @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class) + private String rawData; + + public static final String SENDER_TYPE_CUSTOMER = "customer"; + public static final String SENDER_TYPE_AI = "ai"; + public static final String SENDER_TYPE_MANUAL = "manual"; +} diff --git a/src/main/java/com/wecom/robot/entity/Session.java b/src/main/java/com/wecom/robot/entity/Session.java new file mode 100644 index 0000000..5b56999 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/Session.java @@ -0,0 +1,48 @@ +package com.wecom.robot.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("session") +public class Session implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "session_id", type = IdType.ASSIGN_ID) + private String sessionId; + + private String customerId; + + private String kfId; + + private String channelType; + + private String status; + + private Integer wxServiceState; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + private String manualCsId; + + @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class) + private String metadata; + + public static final String STATUS_AI = "AI"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_MANUAL = "MANUAL"; + public static final String STATUS_CLOSED = "CLOSED"; + + public static final String CHANNEL_WECHAT = "wechat"; + public static final String CHANNEL_DOUYIN = "douyin"; + public static final String CHANNEL_JD = "jd"; +} diff --git a/src/main/java/com/wecom/robot/entity/TransferLog.java b/src/main/java/com/wecom/robot/entity/TransferLog.java new file mode 100644 index 0000000..2678de4 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/TransferLog.java @@ -0,0 +1,29 @@ +package com.wecom.robot.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@TableName("transfer_log") +public class TransferLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String sessionId; + + private String triggerReason; + + private LocalDateTime triggerTime; + + private LocalDateTime acceptedTime; + + private String acceptedCsId; +} diff --git a/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java b/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java new file mode 100644 index 0000000..e001772 --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java @@ -0,0 +1,9 @@ +package com.wecom.robot.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wecom.robot.entity.KfAccount; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface KfAccountMapper extends BaseMapper { +} diff --git a/src/main/java/com/wecom/robot/mapper/MessageMapper.java b/src/main/java/com/wecom/robot/mapper/MessageMapper.java new file mode 100644 index 0000000..eb50e9e --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/MessageMapper.java @@ -0,0 +1,9 @@ +package com.wecom.robot.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wecom.robot.entity.Message; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MessageMapper extends BaseMapper { +} diff --git a/src/main/java/com/wecom/robot/mapper/SessionMapper.java b/src/main/java/com/wecom/robot/mapper/SessionMapper.java new file mode 100644 index 0000000..69e391a --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/SessionMapper.java @@ -0,0 +1,9 @@ +package com.wecom.robot.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wecom.robot.entity.Session; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SessionMapper extends BaseMapper { +} diff --git a/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java b/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java new file mode 100644 index 0000000..98b2bef --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java @@ -0,0 +1,9 @@ +package com.wecom.robot.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wecom.robot.entity.TransferLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TransferLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/wecom/robot/service/AiServiceClient.java b/src/main/java/com/wecom/robot/service/AiServiceClient.java new file mode 100644 index 0000000..d1221f3 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/AiServiceClient.java @@ -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 generateReply(ChatRequest request); + + boolean healthCheck(); +} diff --git a/src/main/java/com/wecom/robot/service/MessageProcessService.java b/src/main/java/com/wecom/robot/service/MessageProcessService.java new file mode 100644 index 0000000..34530d8 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/MessageProcessService.java @@ -0,0 +1,304 @@ +package com.wecom.robot.service; + +import com.alibaba.fastjson.JSON; +import com.wecom.robot.adapter.ChannelAdapter; +import com.wecom.robot.adapter.MessageSyncCapable; +import com.wecom.robot.dto.InboundMessage; +import com.wecom.robot.dto.ServiceStateResponse; +import com.wecom.robot.dto.SyncMsgResponse; +import com.wecom.robot.dto.WxCallbackMessage; +import com.wecom.robot.entity.Message; +import com.wecom.robot.entity.Session; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 消息处理服务 + *

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

职责: + *

    + *
  • 处理入站消息的统一路由
  • + *
  • 根据会话状态分发到 AI 服务或人工客服
  • + *
  • 协调消息处理流程中的各组件
  • + *
+ * + *

关联 AC: [AC-MCA-08] 统一消息路由 + * + * @see InboundMessage + * @see Session + */ +public interface MessageRouterService { + + /** + * 处理入站消息 - 主入口方法 + * + *

执行流程: + *

    + *
  1. 幂等性检查(基于 channelMessageId)
  2. + *
  3. 获取或创建会话
  4. + *
  5. 根据会话状态路由消息
  6. + *
+ * + * @param message 入站消息,包含渠道类型、消息内容等信息 + */ + void processInboundMessage(InboundMessage message); + + /** + * 根据会话状态路由消息 + * + *

路由规则: + *

    + *
  • AI 状态 → dispatchToAiService
  • + *
  • PENDING 状态 → dispatchToPendingPool
  • + *
  • MANUAL 状态 → dispatchToManualCs
  • + *
+ * + * @param session 当前会话 + * @param message 入站消息 + */ + void routeBySessionState(Session session, InboundMessage message); + + /** + * 分发到 AI 服务处理 + * + *

调用 AI 服务生成回复,并根据返回结果判断是否需要转人工 + * + * @param session 当前会话 + * @param message 入站消息 + */ + void dispatchToAiService(Session session, InboundMessage message); + + /** + * 分发到人工客服处理 + * + *

将消息推送给在线的人工客服(通过 WebSocket) + * + * @param session 当前会话 + * @param message 入站消息 + */ + void dispatchToManualCs(Session session, InboundMessage message); + + /** + * 分发到待接入池 + * + *

将消息暂存,等待人工客服接入 + * + * @param session 当前会话 + * @param message 入站消息 + */ + void dispatchToPendingPool(Session session, InboundMessage message); +} diff --git a/src/main/java/com/wecom/robot/service/SessionManagerService.java b/src/main/java/com/wecom/robot/service/SessionManagerService.java new file mode 100644 index 0000000..85a5360 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/SessionManagerService.java @@ -0,0 +1,243 @@ +package com.wecom.robot.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.wecom.robot.entity.Message; +import com.wecom.robot.entity.Session; +import com.wecom.robot.entity.TransferLog; +import com.wecom.robot.mapper.MessageMapper; +import com.wecom.robot.mapper.SessionMapper; +import com.wecom.robot.mapper.TransferLogMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 会话管理服务 + * + *

关联 AC: [AC-MCA-11] 会话管理, [AC-MCA-12] 渠道类型支持 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionManagerService { + + private static final String SESSION_STATUS_KEY_PREFIX = "session:status:"; + private static final String SESSION_MESSAGE_COUNT_KEY_PREFIX = "session:msg_count:"; + + private final SessionMapper sessionMapper; + private final MessageMapper messageMapper; + private final TransferLogMapper transferLogMapper; + private final StringRedisTemplate redisTemplate; + + public Session getOrCreateSession(String customerId, String kfId) { + return getOrCreateSession(customerId, kfId, Session.CHANNEL_WECHAT); + } + + public Session getOrCreateSession(String customerId, String kfId, String channelType) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Session::getCustomerId, customerId) + .eq(Session::getKfId, kfId) + .ne(Session::getStatus, Session.STATUS_CLOSED) + .orderByDesc(Session::getCreatedAt) + .last("LIMIT 1"); + + Session session = sessionMapper.selectOne(query); + if (session == null) { + session = new Session(); + session.setSessionId(generateSessionId(customerId, kfId)); + session.setCustomerId(customerId); + session.setKfId(kfId); + session.setChannelType(channelType != null ? channelType : Session.CHANNEL_WECHAT); + session.setStatus(Session.STATUS_AI); + session.setWxServiceState(0); + session.setCreatedAt(LocalDateTime.now()); + session.setUpdatedAt(LocalDateTime.now()); + sessionMapper.insert(session); + + cacheSessionStatus(session.getSessionId(), Session.STATUS_AI); + log.info("[AC-MCA-11] 创建新会话: sessionId={}, channelType={}", + session.getSessionId(), session.getChannelType()); + } + + return session; + } + + public Session getSession(String sessionId) { + return sessionMapper.selectById(sessionId); + } + + public String getSessionStatus(String sessionId) { + String cachedStatus = redisTemplate.opsForValue().get(SESSION_STATUS_KEY_PREFIX + sessionId); + if (cachedStatus != null) { + return cachedStatus; + } + + Session session = sessionMapper.selectById(sessionId); + if (session != null) { + cacheSessionStatus(sessionId, session.getStatus()); + return session.getStatus(); + } + return null; + } + + public void updateSessionStatus(String sessionId, String status) { + Session session = new Session(); + session.setSessionId(sessionId); + session.setStatus(status); + session.setUpdatedAt(LocalDateTime.now()); + sessionMapper.updateById(session); + + cacheSessionStatus(sessionId, status); + } + + public void updateWxServiceState(String sessionId, Integer wxServiceState) { + Session session = new Session(); + session.setSessionId(sessionId); + session.setWxServiceState(wxServiceState); + session.setUpdatedAt(LocalDateTime.now()); + sessionMapper.updateById(session); + log.info("更新微信会话状态: sessionId={}, wxServiceState={}", sessionId, wxServiceState); + } + + public void updateServicer(String sessionId, String servicerUserid) { + Session session = new Session(); + session.setSessionId(sessionId); + session.setManualCsId(servicerUserid); + session.setUpdatedAt(LocalDateTime.now()); + sessionMapper.updateById(session); + log.info("更新接待人员: sessionId={}, servicerUserid={}", sessionId, servicerUserid); + } + + public void assignManualCs(String sessionId, String csId) { + Session session = new Session(); + session.setSessionId(sessionId); + session.setStatus(Session.STATUS_MANUAL); + session.setManualCsId(csId); + session.setUpdatedAt(LocalDateTime.now()); + sessionMapper.updateById(session); + + cacheSessionStatus(sessionId, Session.STATUS_MANUAL); + } + + @Transactional + public void transferToManual(String sessionId, String reason) { + updateSessionStatus(sessionId, Session.STATUS_PENDING); + + TransferLog transferLog = new TransferLog(); + transferLog.setSessionId(sessionId); + transferLog.setTriggerReason(reason); + transferLog.setTriggerTime(LocalDateTime.now()); + transferLogMapper.insert(transferLog); + + log.info("会话转人工: sessionId={}, reason={}", sessionId, reason); + } + + public void acceptTransfer(String sessionId, String csId) { + assignManualCs(sessionId, csId); + + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(TransferLog::getSessionId, sessionId) + .isNull(TransferLog::getAcceptedTime) + .orderByDesc(TransferLog::getTriggerTime) + .last("LIMIT 1"); + + TransferLog transferLog = transferLogMapper.selectOne(query); + if (transferLog != null) { + transferLog.setAcceptedTime(LocalDateTime.now()); + transferLog.setAcceptedCsId(csId); + transferLogMapper.updateById(transferLog); + } + + log.info("客服接入会话: sessionId={}, csId={}", sessionId, csId); + } + + public void closeSession(String sessionId) { + updateSessionStatus(sessionId, Session.STATUS_CLOSED); + redisTemplate.delete(SESSION_STATUS_KEY_PREFIX + sessionId); + redisTemplate.delete(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId); + log.info("会话已关闭: sessionId={}", sessionId); + } + + public void saveMessage(String msgId, String sessionId, String senderType, String senderId, + String content, String msgType, String rawData) { + Message message = new Message(); + message.setMsgId(msgId); + message.setSessionId(sessionId); + message.setSenderType(senderType); + message.setSenderId(senderId); + message.setContent(content); + message.setMsgType(msgType); + message.setCreatedAt(LocalDateTime.now()); + message.setRawData(rawData); + messageMapper.insert(message); + + incrementMessageCount(sessionId); + } + + public List getSessionMessages(String sessionId) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Message::getSessionId, sessionId) + .orderByAsc(Message::getCreatedAt); + return messageMapper.selectList(query); + } + + public int getMessageCount(String sessionId) { + String count = redisTemplate.opsForValue().get(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId); + if (count != null) { + return Integer.parseInt(count); + } + + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Message::getSessionId, sessionId); + long dbCount = messageMapper.selectCount(query); + redisTemplate.opsForValue().set(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId, String.valueOf(dbCount)); + return (int) dbCount; + } + + public List getSessionsByKfId(String kfId, String status, int limit) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Session::getKfId, kfId); + if (status != null && !status.isEmpty() && !"all".equals(status)) { + query.eq(Session::getStatus, status); + } + query.orderByDesc(Session::getUpdatedAt); + query.last("LIMIT " + limit); + return sessionMapper.selectList(query); + } + + public List getSessionsByChannelType(String channelType, String status, int limit) { + LambdaQueryWrapper 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 getAllSessions(int limit) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.orderByDesc(Session::getUpdatedAt); + query.last("LIMIT " + limit); + return sessionMapper.selectList(query); + } + + private void cacheSessionStatus(String sessionId, String status) { + redisTemplate.opsForValue().set(SESSION_STATUS_KEY_PREFIX + sessionId, status, 24, TimeUnit.HOURS); + } + + private void incrementMessageCount(String sessionId) { + redisTemplate.opsForValue().increment(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId); + } + + private String generateSessionId(String customerId, String kfId) { + return kfId + "_" + customerId + "_" + System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/wecom/robot/service/TransferService.java b/src/main/java/com/wecom/robot/service/TransferService.java new file mode 100644 index 0000000..d7d059c --- /dev/null +++ b/src/main/java/com/wecom/robot/service/TransferService.java @@ -0,0 +1,87 @@ +package com.wecom.robot.service; + +import com.wecom.robot.config.TransferConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TransferService { + + private final TransferConfig transferConfig; + + public boolean shouldTransferToManual(String message, double confidence, int messageCount, LocalDateTime sessionCreatedAt) { + if (containsKeywords(message)) { + log.info("触发转人工: 关键词匹配"); + return true; + } + + if (confidence < transferConfig.getConfidenceThreshold()) { + log.info("触发转人工: AI置信度过低 confidence={}", confidence); + return true; + } + + if (messageCount >= transferConfig.getMaxMessageRounds()) { + log.info("触发转人工: 消息轮次过多 count={}", messageCount); + return true; + } + + if (sessionCreatedAt != null) { + long duration = System.currentTimeMillis() - + sessionCreatedAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + if (duration >= transferConfig.getMaxSessionDuration()) { + log.info("触发转人工: 会话时长超限 duration={}ms", duration); + return true; + } + } + + return false; + } + + public String getTransferReason(String message, double confidence, int messageCount) { + List keywords = transferConfig.getKeywords(); + if (keywords != null) { + for (String keyword : keywords) { + if (message != null && message.contains(keyword)) { + return "关键词触发: " + keyword; + } + } + } + + if (confidence < transferConfig.getConfidenceThreshold()) { + return "AI置信度过低: " + confidence; + } + + if (messageCount >= transferConfig.getMaxMessageRounds()) { + return "消息轮次过多: " + messageCount; + } + + return "其他原因"; + } + + private boolean containsKeywords(String message) { + if (message == null) { + return false; + } + + List keywords = transferConfig.getKeywords(); + if (keywords == null) { + return false; + } + + for (String keyword : keywords) { + if (message.contains(keyword)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/wecom/robot/service/WebSocketService.java b/src/main/java/com/wecom/robot/service/WebSocketService.java new file mode 100644 index 0000000..d98854b --- /dev/null +++ b/src/main/java/com/wecom/robot/service/WebSocketService.java @@ -0,0 +1,71 @@ +package com.wecom.robot.service; + +import com.wecom.robot.dto.WxCallbackMessage; +import com.wecom.robot.websocket.CsWebSocketHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebSocketService { + + private final CsWebSocketHandler webSocketHandler; + + public void notifyNewPendingSession(String sessionId) { + Map message = new HashMap<>(); + message.put("type", "new_pending_session"); + message.put("sessionId", sessionId); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.broadcastToAll(message); + log.info("通知新待接入会话: sessionId={}", sessionId); + } + + public void notifyNewMessage(String sessionId, WxCallbackMessage wxMessage) { + Map message = new HashMap<>(); + message.put("type", "new_message"); + message.put("sessionId", sessionId); + message.put("content", wxMessage.getContent()); + message.put("msgType", wxMessage.getMsgType()); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.sendMessageToSession(sessionId, message); + } + + public void pushMessageToCs(String sessionId, WxCallbackMessage wxMessage) { + Map message = new HashMap<>(); + message.put("type", "customer_message"); + message.put("sessionId", sessionId); + message.put("content", wxMessage.getContent()); + message.put("msgType", wxMessage.getMsgType()); + message.put("customerId", wxMessage.getExternalUserId()); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.sendMessageToSession(sessionId, message); + log.info("推送客户消息给客服: sessionId={}", sessionId); + } + + public void notifySessionAccepted(String sessionId, String csId) { + Map message = new HashMap<>(); + message.put("type", "session_accepted"); + message.put("sessionId", sessionId); + message.put("csId", csId); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.sendMessageToCs(csId, message); + } + + public void notifySessionClosed(String sessionId) { + Map message = new HashMap<>(); + message.put("type", "session_closed"); + message.put("sessionId", sessionId); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.sendMessageToSession(sessionId, message); + } +} diff --git a/src/main/java/com/wecom/robot/service/WecomApiService.java b/src/main/java/com/wecom/robot/service/WecomApiService.java new file mode 100644 index 0000000..c163cb2 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/WecomApiService.java @@ -0,0 +1,256 @@ +package com.wecom.robot.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.wecom.robot.config.WecomConfig; +import com.wecom.robot.dto.ServiceStateResponse; +import com.wecom.robot.dto.SyncMsgResponse; +import com.wecom.robot.dto.WxSendMessageRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WecomApiService { + + private static final String GET_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpId}&corpsecret={secret}"; + private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={accessToken}"; + private static final String GET_KF_LIST_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/account/list?access_token={accessToken}"; + private static final String SYNC_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={accessToken}"; + private static final String GET_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/get?access_token={accessToken}"; + private static final String TRANS_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/trans?access_token={accessToken}"; + private static final String SEND_EVENT_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token={accessToken}"; + + private static final String REDIS_TOKEN_KEY = "wecom:access_token"; + private static final String REDIS_TOKEN_LOCK_KEY = "wecom:access_token_lock"; + private static final String REDIS_CURSOR_KEY_PREFIX = "wecom:cursor:"; + + private final WecomConfig wecomConfig; + private final StringRedisTemplate redisTemplate; + private final RestTemplate restTemplate = new RestTemplate(); + + public String getAccessToken() { + String cachedToken = redisTemplate.opsForValue().get(REDIS_TOKEN_KEY); + if (cachedToken != null) { + return cachedToken; + } + + Boolean locked = redisTemplate.opsForValue().setIfAbsent(REDIS_TOKEN_LOCK_KEY, "1", 10, TimeUnit.SECONDS); + if (Boolean.TRUE.equals(locked)) { + try { + String url = GET_ACCESS_TOKEN_URL + .replace("{corpId}", wecomConfig.getCorpId()) + .replace("{secret}", wecomConfig.getSecret()); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + JSONObject json = JSON.parseObject(response.getBody()); + + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("获取access_token失败: {}", json); + throw new RuntimeException("获取access_token失败: " + json.getString("errmsg")); + } + + String accessToken = json.getString("access_token"); + long expiresIn = json.getLongValue("expires_in"); + + redisTemplate.opsForValue().set(REDIS_TOKEN_KEY, accessToken, expiresIn - 300, TimeUnit.SECONDS); + return accessToken; + } finally { + redisTemplate.delete(REDIS_TOKEN_LOCK_KEY); + } + } else { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return getAccessToken(); + } + } + + public boolean sendMessage(WxSendMessageRequest request) { + String accessToken = getAccessToken(); + String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(JSON.toJSONString(request), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + JSONObject json = JSON.parseObject(response.getBody()); + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("发送消息失败: {}", json); + return false; + } + + log.info("消息发送成功: msgId={}", json.getString("msgid")); + return true; + } + + public boolean sendTextMessage(String touser, String openKfid, String content) { + WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content); + return sendMessage(request); + } + + public JSONObject getKfAccountList(int offset, int limit) { + String accessToken = getAccessToken(); + String url = GET_KF_LIST_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("offset", offset); + body.put("limit", limit); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + return JSON.parseObject(response.getBody()); + } + + public SyncMsgResponse syncMessages(String openKfid, String token) { + String accessToken = getAccessToken(); + String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken); + + String cursor = getCursor(openKfid); + + JSONObject body = new JSONObject(); + body.put("open_kfid", openKfid); + if (cursor != null && !cursor.isEmpty()) { + body.put("cursor", cursor); + } + if (token != null && !token.isEmpty()) { + body.put("token", token); + } + body.put("limit", 1000); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("sync_msg响应: {}", response.getBody()); + + SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class); + + if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) { + saveCursor(openKfid, syncResponse.getNextCursor()); + } + + return syncResponse; + } + + public SyncMsgResponse syncMessagesByToken(String openKfid, String token) { + return syncMessages(openKfid, token); + } + + public ServiceStateResponse getServiceState(String openKfid, String externalUserid) { + String accessToken = getAccessToken(); + String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("open_kfid", openKfid); + body.put("external_userid", externalUserid); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("获取会话状态响应: {}", response.getBody()); + + return JSON.parseObject(response.getBody(), ServiceStateResponse.class); + } + + public JSONObject transServiceState(String openKfid, String externalUserid, + int serviceState, String servicerUserid) { + String accessToken = getAccessToken(); + String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("open_kfid", openKfid); + body.put("external_userid", externalUserid); + body.put("service_state", serviceState); + if (servicerUserid != null && !servicerUserid.isEmpty()) { + body.put("servicer_userid", servicerUserid); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + log.info("变更会话状态响应: {}", response.getBody()); + + return JSON.parseObject(response.getBody()); + } + + public boolean transferToPool(String openKfid, String externalUserid) { + JSONObject result = transServiceState(openKfid, externalUserid, + ServiceStateResponse.STATE_POOL, null); + return result.getInteger("errcode") == null || result.getInteger("errcode") == 0; + } + + public boolean transferToManual(String openKfid, String externalUserid, String servicerUserid) { + JSONObject result = transServiceState(openKfid, externalUserid, + ServiceStateResponse.STATE_MANUAL, servicerUserid); + return result.getInteger("errcode") == null || result.getInteger("errcode") == 0; + } + + public boolean endSession(String openKfid, String externalUserid) { + JSONObject result = transServiceState(openKfid, externalUserid, + ServiceStateResponse.STATE_CLOSED, null); + return result.getInteger("errcode") == null || result.getInteger("errcode") == 0; + } + + public boolean sendWelcomeMsg(String code, String content) { + String accessToken = getAccessToken(); + String url = SEND_EVENT_MSG_URL.replace("{accessToken}", accessToken); + + JSONObject body = new JSONObject(); + body.put("code", code); + body.put("msgtype", "text"); + JSONObject text = new JSONObject(); + text.put("content", content); + body.put("text", text); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + + JSONObject json = JSON.parseObject(response.getBody()); + if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { + log.error("发送欢迎语失败: {}", json); + return false; + } + + log.info("发送欢迎语成功"); + return true; + } + + private String getCursor(String openKfid) { + return redisTemplate.opsForValue().get(REDIS_CURSOR_KEY_PREFIX + openKfid); + } + + private void saveCursor(String openKfid, String cursor) { + redisTemplate.opsForValue().set(REDIS_CURSOR_KEY_PREFIX + openKfid, cursor); + } + + public void clearCursor(String openKfid) { + redisTemplate.delete(REDIS_CURSOR_KEY_PREFIX + openKfid); + } +} diff --git a/src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java b/src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java new file mode 100644 index 0000000..8193d82 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/impl/AiServiceClientImpl.java @@ -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 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 entity = new HttpEntity<>(request, headers); + + ResponseEntity 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 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 response = restTemplate.getForEntity(url, String.class); + return response.getStatusCode().is2xxSuccessful(); + } catch (Exception e) { + log.error("[AC-MCA-04] AI 服务健康检查失败: {}", e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java b/src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java new file mode 100644 index 0000000..bda8152 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/impl/MessageRouterServiceImpl.java @@ -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 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 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 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; + } +} diff --git a/src/main/java/com/wecom/robot/util/AesException.java b/src/main/java/com/wecom/robot/util/AesException.java new file mode 100644 index 0000000..afbdbe6 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/AesException.java @@ -0,0 +1,50 @@ +package com.wecom.robot.util; + +@SuppressWarnings("serial") +public class AesException extends Exception { + + public final static int OK = 0; + public final static int ValidateSignatureError = -40001; + public final static int ParseXmlError = -40002; + public final static int ComputeSignatureError = -40003; + public final static int IllegalAesKey = -40004; + public final static int ValidateCorpidError = -40005; + public final static int EncryptAESError = -40006; + public final static int DecryptAESError = -40007; + public final static int IllegalBuffer = -40008; + + private int code; + + private static String getMessage(int code) { + switch (code) { + case ValidateSignatureError: + return "签名验证错误"; + case ParseXmlError: + return "xml解析失败"; + case ComputeSignatureError: + return "sha加密生成签名失败"; + case IllegalAesKey: + return "SymmetricKey非法"; + case ValidateCorpidError: + return "corpid校验失败"; + case EncryptAESError: + return "aes加密失败"; + case DecryptAESError: + return "aes解密失败"; + case IllegalBuffer: + return "解密后得到的buffer非法"; + default: + return null; + } + } + + public int getCode() { + return code; + } + + AesException(int code) { + super(getMessage(code)); + this.code = code; + } + +} diff --git a/src/main/java/com/wecom/robot/util/ByteGroup.java b/src/main/java/com/wecom/robot/util/ByteGroup.java new file mode 100644 index 0000000..8ca51a2 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/ByteGroup.java @@ -0,0 +1,26 @@ +package com.wecom.robot.util; + +import java.util.ArrayList; + +class ByteGroup { + ArrayList byteContainer = new ArrayList(); + + public byte[] toBytes() { + byte[] bytes = new byte[byteContainer.size()]; + for (int i = 0; i < byteContainer.size(); i++) { + bytes[i] = byteContainer.get(i); + } + return bytes; + } + + public ByteGroup addBytes(byte[] bytes) { + for (byte b : bytes) { + byteContainer.add(b); + } + return this; + } + + public int size() { + return byteContainer.size(); + } +} diff --git a/src/main/java/com/wecom/robot/util/IdempotentHelper.java b/src/main/java/com/wecom/robot/util/IdempotentHelper.java new file mode 100644 index 0000000..ef65c09 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/IdempotentHelper.java @@ -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); + } +} diff --git a/src/main/java/com/wecom/robot/util/PKCS7Encoder.java b/src/main/java/com/wecom/robot/util/PKCS7Encoder.java new file mode 100644 index 0000000..6a46a7b --- /dev/null +++ b/src/main/java/com/wecom/robot/util/PKCS7Encoder.java @@ -0,0 +1,67 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.wecom.robot.util; + +import java.nio.charset.Charset; +import java.util.Arrays; + +/** + * 提供基于PKCS7算法的加解密接口. + */ +class PKCS7Encoder { + static Charset CHARSET = Charset.forName("utf-8"); + static int BLOCK_SIZE = 32; + + /** + * 获得对明文进行补位填充的字节. + * + * @param count 需要进行填充补位操作的明文字节个数 + * @return 补齐用的字节数组 + */ + static byte[] encode(int count) { + // 计算需要填充的位数 + int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE); + if (amountToPad == 0) { + amountToPad = BLOCK_SIZE; + } + // 获得补位所用的字符 + char padChr = chr(amountToPad); + String tmp = new String(); + for (int index = 0; index < amountToPad; index++) { + tmp += padChr; + } + return tmp.getBytes(CHARSET); + } + + /** + * 删除解密后明文的补位字符 + * + * @param decrypted 解密后的明文 + * @return 删除补位字符后的明文 + */ + static byte[] decode(byte[] decrypted) { + int pad = (int) decrypted[decrypted.length - 1]; + if (pad < 1 || pad > 32) { + pad = 0; + } + return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad); + } + + /** + * 将数字转化成ASCII码对应的字符,用于对明文进行补码 + * + * @param a 需要转化的数字 + * @return 转化得到的字符 + */ + static char chr(int a) { + byte target = (byte) (a & 0xFF); + return (char) target; + } + +} diff --git a/src/main/java/com/wecom/robot/util/SHA1.java b/src/main/java/com/wecom/robot/util/SHA1.java new file mode 100644 index 0000000..7f3dea2 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/SHA1.java @@ -0,0 +1,61 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.wecom.robot.util; + +import java.security.MessageDigest; +import java.util.Arrays; + +/** + * SHA1 class + * + * 计算消息签名接口. + */ +class SHA1 { + + /** + * 用SHA1算法生成安全签名 + * @param token 票据 + * @param timestamp 时间戳 + * @param nonce 随机字符串 + * @param encrypt 密文 + * @return 安全签名 + * @throws AesException + */ + public static String getSHA1(String token, String timestamp, String nonce, String encrypt) throws AesException + { + try { + String[] array = new String[] { token, timestamp, nonce, encrypt }; + StringBuffer sb = new StringBuffer(); + // 字符串排序 + Arrays.sort(array); + for (int i = 0; i < 4; i++) { + sb.append(array[i]); + } + String str = sb.toString(); + // SHA1签名生成 + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(str.getBytes()); + byte[] digest = md.digest(); + + StringBuffer hexstr = new StringBuffer(); + String shaHex = ""; + for (int i = 0; i < digest.length; i++) { + shaHex = Integer.toHexString(digest[i] & 0xFF); + if (shaHex.length() < 2) { + hexstr.append(0); + } + hexstr.append(shaHex); + } + return hexstr.toString(); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.ComputeSignatureError); + } + } +} diff --git a/src/main/java/com/wecom/robot/util/Sample.java b/src/main/java/com/wecom/robot/util/Sample.java new file mode 100644 index 0000000..35074ad --- /dev/null +++ b/src/main/java/com/wecom/robot/util/Sample.java @@ -0,0 +1,70 @@ +package com.wecom.robot.util; + +import java.io.StringReader; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import com.wecom.robot.util.WXBizMsgCrypt; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import javax.xml.parsers.DocumentBuilderFactory; + +public class Sample { + + public static void main(String[] args) throws Exception { + String sToken = "QDG6eK"; + String sCorpID = "wx5823bf96d3bd56c7"; + String sEncodingAESKey = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C"; + + WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(sToken, sEncodingAESKey, sCorpID); + String sVerifyMsgSig = "5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3"; + String sVerifyTimeStamp = "1409659589"; + String sVerifyNonce = "263014780"; + String sVerifyEchoStr = "P9nAzCzyDtyTWESHep1vC5X9xho/qYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp+4RPcs8TgAE7OaBO+FZXvnaqQ=="; + String sEchoStr; + try { + sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp, + sVerifyNonce, sVerifyEchoStr); + System.out.println("verifyurl echostr: " + sEchoStr); + } catch (Exception e) { + e.printStackTrace(); + } + + String sReqMsgSig = "477715d11cdb4164915debcba66cb864d751f3e6"; + String sReqTimeStamp = "1409659813"; + String sReqNonce = "1372623149"; + String sReqData = ""; + + try { + String sMsg = wxcpt.DecryptMsg(sReqMsgSig, sReqTimeStamp, sReqNonce, sReqData); + System.out.println("after decrypt msg: " + sMsg); + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + DocumentBuilder db = dbf.newDocumentBuilder(); + StringReader sr = new StringReader(sMsg); + InputSource is = new InputSource(sr); + Document document = db.parse(is); + + Element root = document.getDocumentElement(); + NodeList nodelist1 = root.getElementsByTagName("Content"); + String Content = nodelist1.item(0).getTextContent(); + System.out.println("Content:" + Content); + + } catch (Exception e) { + e.printStackTrace(); + } + + String sRespData = "13488318601234567890123456128"; + try{ + String sEncryptMsg = wxcpt.EncryptMsg(sRespData, sReqTimeStamp, sReqNonce); + System.out.println("after encrypt sEncrytMsg: " + sEncryptMsg); + } + catch(Exception e) + { + e.printStackTrace(); + } + + } +} diff --git a/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java b/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java new file mode 100644 index 0000000..7745955 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java @@ -0,0 +1,289 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +/** + * 针对org.apache.commons.codec.binary.Base64, + * 需要导入架包commons-codec-1.9(或commons-codec-1.8等其他版本) + * 官方下载地址:http://commons.apache.org/proper/commons-codec/download_codec.cgi + */ +package com.wecom.robot.util; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Random; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; + +/** + * 提供接收和推送给企业微信消息的加解密接口(UTF8编码的字符串). + *

    + *
  1. 第三方回复加密消息给企业微信
  2. + *
  3. 第三方收到企业微信发送的消息,验证消息的安全性,并对消息进行解密。
  4. + *
+ * 说明:异常java.security.InvalidKeyException:illegal Key Size的解决方案 + *
    + *
  1. 在官方网站下载JCE无限制权限策略文件(JDK7的下载地址: + * http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
  2. + *
  3. 下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt
  4. + *
  5. 如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件
  6. + *
  7. 如果安装了JDK,将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件
  8. + *
+ */ +public class WXBizMsgCrypt { + static Charset CHARSET = Charset.forName("utf-8"); + Base64 base64 = new Base64(); + byte[] aesKey; + String token; + String receiveid; + + /** + * 构造函数 + * @param token 企业微信后台,开发者设置的token + * @param encodingAesKey 企业微信后台,开发者设置的EncodingAESKey + * @param receiveid, 不同场景含义不同,详见文档 + * + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public WXBizMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException { + if (encodingAesKey.length() != 43) { + throw new AesException(AesException.IllegalAesKey); + } + + this.token = token; + this.receiveid = receiveid; + aesKey = Base64.decodeBase64(encodingAesKey + "="); + } + + // 生成4个字节的网络字节序 + byte[] getNetworkBytesOrder(int sourceNumber) { + byte[] orderBytes = new byte[4]; + orderBytes[3] = (byte) (sourceNumber & 0xFF); + orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF); + orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF); + orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF); + return orderBytes; + } + + // 还原4个字节的网络字节序 + int recoverNetworkBytesOrder(byte[] orderBytes) { + int sourceNumber = 0; + for (int i = 0; i < 4; i++) { + sourceNumber <<= 8; + sourceNumber |= orderBytes[i] & 0xff; + } + return sourceNumber; + } + + // 随机生成16位字符串 + String getRandomStr() { + String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + Random random = new Random(); + StringBuffer sb = new StringBuffer(); + for (int i = 0; i < 16; i++) { + int number = random.nextInt(base.length()); + sb.append(base.charAt(number)); + } + return sb.toString(); + } + + /** + * 对明文进行加密. + * + * @param text 需要加密的明文 + * @return 加密后base64编码的字符串 + * @throws AesException aes加密失败 + */ + String encrypt(String randomStr, String text) throws AesException { + ByteGroup byteCollector = new ByteGroup(); + byte[] randomStrBytes = randomStr.getBytes(CHARSET); + byte[] textBytes = text.getBytes(CHARSET); + byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length); + byte[] receiveidBytes = receiveid.getBytes(CHARSET); + + // randomStr + networkBytesOrder + text + receiveid + byteCollector.addBytes(randomStrBytes); + byteCollector.addBytes(networkBytesOrder); + byteCollector.addBytes(textBytes); + byteCollector.addBytes(receiveidBytes); + + // ... + pad: 使用自定义的填充方式对明文进行补位填充 + byte[] padBytes = PKCS7Encoder.encode(byteCollector.size()); + byteCollector.addBytes(padBytes); + + // 获得最终的字节流, 未加密 + byte[] unencrypted = byteCollector.toBytes(); + + try { + // 设置加密模式为AES的CBC模式 + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); + IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); + + // 加密 + byte[] encrypted = cipher.doFinal(unencrypted); + + // 使用BASE64对加密后的字符串进行编码 + String base64Encrypted = base64.encodeToString(encrypted); + + return base64Encrypted; + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.EncryptAESError); + } + } + + /** + * 对密文进行解密. + * + * @param text 需要解密的密文 + * @return 解密得到的明文 + * @throws AesException aes解密失败 + */ + String decrypt(String text) throws AesException { + byte[] original; + try { + // 设置解密模式为AES的CBC模式 + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES"); + IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16)); + cipher.init(Cipher.DECRYPT_MODE, key_spec, iv); + + // 使用BASE64对密文进行解码 + byte[] encrypted = Base64.decodeBase64(text); + + // 解密 + original = cipher.doFinal(encrypted); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.DecryptAESError); + } + + String xmlContent, from_receiveid; + try { + // 去除补位字符 + byte[] bytes = PKCS7Encoder.decode(original); + + // 分离16位随机字符串,网络字节序和receiveid + byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20); + + int xmlLength = recoverNetworkBytesOrder(networkOrder); + + xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET); + from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), + CHARSET); + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.IllegalBuffer); + } + + // receiveid不相同的情况 + if (!from_receiveid.equals(receiveid)) { + throw new AesException(AesException.ValidateCorpidError); + } + return xmlContent; + + } + + /** + * 将企业微信回复用户的消息加密打包. + *
    + *
  1. 对要发送的消息进行AES-CBC加密
  2. + *
  3. 生成安全签名
  4. + *
  5. 将消息密文和安全签名打包成xml格式
  6. + *
+ * + * @param replyMsg 企业微信待回复用户的消息,xml格式的字符串 + * @param timeStamp 时间戳,可以自己生成,也可以用URL参数的timestamp + * @param nonce 随机串,可以自己生成,也可以用URL参数的nonce + * + * @return 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串 + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public String EncryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException { + // 加密 + String encrypt = encrypt(getRandomStr(), replyMsg); + + // 生成安全签名 + if (timeStamp == "") { + timeStamp = Long.toString(System.currentTimeMillis()); + } + + String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt); + + // System.out.println("发送给平台的签名是: " + signature[1].toString()); + // 生成发送的xml + String result = XMLParse.generate(encrypt, signature, timeStamp, nonce); + return result; + } + + /** + * 检验消息的真实性,并且获取解密后的明文. + *
    + *
  1. 利用收到的密文生成安全签名,进行签名验证
  2. + *
  3. 若验证通过,则提取xml中的加密消息
  4. + *
  5. 对消息进行解密
  6. + *
+ * + * @param msgSignature 签名串,对应URL参数的msg_signature + * @param timeStamp 时间戳,对应URL参数的timestamp + * @param nonce 随机串,对应URL参数的nonce + * @param postData 密文,对应POST请求的数据 + * + * @return 解密后的原文 + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData) + throws AesException { + + // 密钥,公众账号的app secret + // 提取密文 + Object[] encrypt = XMLParse.extract(postData); + + // 验证安全签名 + String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString()); + + // 和URL中的签名比较是否相等 + // System.out.println("第三方收到URL中的签名:" + msg_sign); + // System.out.println("第三方校验签名:" + signature); + if (!signature.equals(msgSignature)) { + throw new AesException(AesException.ValidateSignatureError); + } + + // 解密 + String result = decrypt(encrypt[1].toString()); + return result; + } + + /** + * 验证URL + * @param msgSignature 签名串,对应URL参数的msg_signature + * @param timeStamp 时间戳,对应URL参数的timestamp + * @param nonce 随机串,对应URL参数的nonce + * @param echoStr 随机串,对应URL参数的echostr + * + * @return 解密之后的echostr + * @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息 + */ + public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr) + throws AesException { + String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr); + + if (!signature.equals(msgSignature)) { + throw new AesException(AesException.ValidateSignatureError); + } + + String result = decrypt(echoStr); + return result; + } + +} \ No newline at end of file diff --git a/src/main/java/com/wecom/robot/util/XMLParse.java b/src/main/java/com/wecom/robot/util/XMLParse.java new file mode 100644 index 0000000..34f0daf --- /dev/null +++ b/src/main/java/com/wecom/robot/util/XMLParse.java @@ -0,0 +1,104 @@ +/** + * 对企业微信发送给企业后台的消息加解密示例代码. + * + * @copyright Copyright (c) 1998-2014 Tencent Inc. + */ + +// ------------------------------------------------------------------------ + +package com.wecom.robot.util; + +import java.io.StringReader; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +/** + * XMLParse class + * + * 提供提取消息格式中的密文及生成回复消息格式的接口. + */ +class XMLParse { + + /** + * 提取出xml数据包中的加密消息 + * @param xmltext 待提取的xml字符串 + * @return 提取出的加密消息字符串 + * @throws AesException + */ + public static Object[] extract(String xmltext) throws AesException { + Object[] result = new Object[3]; + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + + String FEATURE = null; + // This is the PRIMARY defense. If DTDs (doctypes) are disallowed, almost all XML entity attacks are prevented + // Xerces 2 only - http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl + FEATURE = "http://apache.org/xml/features/disallow-doctype-decl"; + dbf.setFeature(FEATURE, true); + + // If you can't completely disable DTDs, then at least do the following: + // Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-general-entities + // Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-general-entities + // JDK7+ - http://xml.org/sax/features/external-general-entities + FEATURE = "http://xml.org/sax/features/external-general-entities"; + dbf.setFeature(FEATURE, false); + + // Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-parameter-entities + // Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities + // JDK7+ - http://xml.org/sax/features/external-parameter-entities + FEATURE = "http://xml.org/sax/features/external-parameter-entities"; + dbf.setFeature(FEATURE, false); + + // Disable external DTDs as well + FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd"; + dbf.setFeature(FEATURE, false); + + // and these as well, per Timothy Morgan's 2014 paper: "XML Schema, DTD, and Entity Attacks" + dbf.setXIncludeAware(false); + dbf.setExpandEntityReferences(false); + + // And, per Timothy Morgan: "If for some reason support for inline DOCTYPEs are a requirement, then + // ensure the entity settings are disabled (as shown above) and beware that SSRF attacks + // (http://cwe.mitre.org/data/definitions/918.html) and denial + // of service attacks (such as billion laughs or decompression bombs via "jar:") are a risk." + + // remaining parser logic + DocumentBuilder db = dbf.newDocumentBuilder(); + StringReader sr = new StringReader(xmltext); + InputSource is = new InputSource(sr); + Document document = db.parse(is); + + Element root = document.getDocumentElement(); + NodeList nodelist1 = root.getElementsByTagName("Encrypt"); + result[0] = 0; + result[1] = nodelist1.item(0).getTextContent(); + return result; + } catch (Exception e) { + e.printStackTrace(); + throw new AesException(AesException.ParseXmlError); + } + } + + /** + * 生成xml消息 + * @param encrypt 加密后的消息密文 + * @param signature 安全签名 + * @param timestamp 时间戳 + * @param nonce 随机字符串 + * @return 生成的xml字符串 + */ + public static String generate(String encrypt, String signature, String timestamp, String nonce) { + + String format = "\n" + "\n" + + "\n" + + "%3$s\n" + "\n" + ""; + return String.format(format, encrypt, signature, timestamp, nonce); + + } +} diff --git a/src/main/java/com/wecom/robot/util/XmlUtil.java b/src/main/java/com/wecom/robot/util/XmlUtil.java new file mode 100644 index 0000000..0789de3 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/XmlUtil.java @@ -0,0 +1,65 @@ +package com.wecom.robot.util; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.Map; + +public class XmlUtil { + + public static Map parseXml(String xmlStr) { + Map map = new HashMap<>(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(new ByteArrayInputStream(xmlStr.getBytes("UTF-8"))); + Element root = document.getDocumentElement(); + NodeList nodeList = root.getChildNodes(); + for (int i = 0; i < nodeList.getLength(); i++) { + org.w3c.dom.Node node = nodeList.item(i); + if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + map.put(node.getNodeName(), node.getTextContent()); + } + } + } catch (Exception e) { + throw new RuntimeException("XML解析失败", e); + } + return map; + } + + public static String mapToXml(Map map) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (isNumeric(value)) { + sb.append("<").append(key).append(">").append(value).append(""); + } else { + sb.append("<").append(key).append(">"); + } + } + sb.append(""); + return sb.toString(); + } + + private static boolean isNumeric(String str) { + if (str == null || str.isEmpty()) { + return false; + } + for (char c : str.toCharArray()) { + if (!Character.isDigit(c)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java b/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java new file mode 100644 index 0000000..720a796 --- /dev/null +++ b/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java @@ -0,0 +1,115 @@ +package com.wecom.robot.websocket; + +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.*; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +public class CsWebSocketHandler implements WebSocketHandler { + + private static final Map csSessions = new ConcurrentHashMap<>(); + private static final Map sessionToCsMap = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + String csId = extractCsId(session); + if (csId != null) { + csSessions.put(csId, session); + log.info("客服WebSocket连接建立: csId={}, sessionId={}", csId, session.getId()); + } + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + if (message instanceof TextMessage) { + String payload = ((TextMessage) message).getPayload(); + log.debug("收到WebSocket消息: {}", payload); + + Map msgMap = JSON.parseObject(payload, Map.class); + String type = (String) msgMap.get("type"); + + if ("bind_session".equals(type)) { + String sessionId = (String) msgMap.get("sessionId"); + String csId = extractCsId(session); + if (sessionId != null && csId != null) { + sessionToCsMap.put(sessionId, csId); + log.info("绑定会话: sessionId={}, csId={}", sessionId, csId); + } + } + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("WebSocket传输错误: sessionId={}", session.getId(), exception); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + String csId = extractCsId(session); + if (csId != null) { + csSessions.remove(csId); + sessionToCsMap.entrySet().removeIf(entry -> csId.equals(entry.getValue())); + log.info("客服WebSocket连接关闭: csId={}, status={}", csId, status); + } + } + + @Override + public boolean supportsPartialMessages() { + return false; + } + + public void sendMessageToCs(String csId, Object message) { + WebSocketSession session = csSessions.get(csId); + if (session != null && session.isOpen()) { + try { + String json = JSON.toJSONString(message); + session.sendMessage(new TextMessage(json)); + log.debug("发送消息给客服: csId={}, message={}", csId, json); + } catch (IOException e) { + log.error("发送WebSocket消息失败: csId={}", csId, e); + } + } else { + log.warn("客服不在线: csId={}", csId); + } + } + + public void broadcastToAll(Object message) { + String json = JSON.toJSONString(message); + TextMessage textMessage = new TextMessage(json); + + csSessions.values().forEach(session -> { + if (session.isOpen()) { + try { + session.sendMessage(textMessage); + } catch (IOException e) { + log.error("广播消息失败: sessionId={}", session.getId(), e); + } + } + }); + } + + public void sendMessageToSession(String sessionId, Object message) { + String csId = sessionToCsMap.get(sessionId); + if (csId != null) { + sendMessageToCs(csId, message); + } else { + log.warn("会话未绑定客服: sessionId={}", sessionId); + } + } + + private String extractCsId(WebSocketSession session) { + String path = session.getUri().getPath(); + String[] parts = path.split("/"); + if (parts.length >= 4) { + return parts[3]; + } + return null; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..fb60211 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,49 @@ +server: + port: 8080 + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: jiong1114 + redis: + host: localhost + port: 6379 + password: + database: 0 + timeout: 10000 + lettuce: + pool: + max-active: 8 + max-wait: -1 + max-idle: 8 + min-idle: 0 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +wecom: + corp-id: ww29e81e73b1f4c6fd + agent-id: 1000006 + secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M + token: 2wuT6pE + encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT + +ai: + enabled: true + provider: deepseek + deepseek: + api-key: sk-6cdd32d6d49d4d399b479d99e02d1672 + base-url: https://api.deepseek.com/v1 + model: deepseek-chat + openai: + api-key: your_openai_api_key + base-url: https://api.openai.com/v1 + model: gpt-3.5-turbo + +logging: + level: + com.wecom.robot: debug + org.springframework.web: info diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..d5f72e8 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,49 @@ +server: + port: 8080 + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://host.docker.internal:3316/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: jiong1114 + redis: + host: host.docker.internal + port: 6379 + password: jiong1114 + database: 0 + timeout: 10000 + lettuce: + pool: + max-active: 16 + max-wait: -1 + max-idle: 8 + min-idle: 2 + +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + +wecom: + corp-id: ww29e81e73b1f4c6fd + agent-id: 1000006 + secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M + token: 2wuT6pE + encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT + +ai: + enabled: true + provider: deepseek + deepseek: + api-key: sk-6cdd32d6d49d4d399b479d99e02d1672 + base-url: https://api.deepseek.com/v1 + model: deepseek-chat + openai: + api-key: your_openai_api_key + base-url: https://api.openai.com/v1 + model: gpt-3.5-turbo + +logging: + level: + com.wecom.robot: info + org.springframework.web: warn diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..303dd11 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,64 @@ +server: + port: 8080 + +spring: + application: + name: wecom-robot + profiles: + active: dev + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: true + +mybatis-plus: + mapper-locations: classpath:mapper/*.xml + type-aliases-package: com.wecom.robot.entity + configuration: + map-underscore-to-camel-case: true + +wecom: + kf: + callback-url: /wecom/callback + +transfer: + keywords: + - 人工 + - 转人工 + - 投诉 + - 客服 + - 人工客服 + confidence-threshold: 0.6 + max-fail-rounds: 3 + max-session-duration: 1800000 + max-message-rounds: 50 + +ai-service: + url: http://localhost:8000 + timeout: 5000 + +channel: + default-channel: wechat + adapters: + wechat: + enabled: true + douyin: + enabled: false + jd: + enabled: false + +resilience4j: + circuitbreaker: + instances: + aiService: + failure-rate-threshold: 50 + sliding-window-size: 10 + sliding-window-type: COUNT_BASED + wait-duration-in-open-state: 30s + permitted-number-of-calls-in-half-open-state: 3 + timelimiter: + instances: + aiService: + timeout-duration: 5s diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..3508b0c --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,74 @@ +-- V1__init.sql - 初始化数据库表结构 +-- Flyway 迁移脚本 + +-- 会话表 +CREATE TABLE IF NOT EXISTS `session` ( + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `customer_id` VARCHAR(64) NOT NULL COMMENT '客户ID (external_userid)', + `kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID (open_kfid)', + `channel_type` VARCHAR(20) NOT NULL DEFAULT 'wechat' COMMENT '渠道类型: wechat/douyin/jd', + `status` VARCHAR(20) NOT NULL DEFAULT 'AI' COMMENT '状态: AI/PENDING/MANUAL/CLOSED', + `wx_service_state` INT DEFAULT 0 COMMENT '微信会话状态: 0-未处理/1-智能助手/2-待接入池/3-人工接待/4-已结束', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `manual_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '人工客服ID', + `metadata` TEXT DEFAULT NULL COMMENT '扩展信息JSON', + PRIMARY KEY (`session_id`), + INDEX `idx_customer_id` (`customer_id`), + INDEX `idx_kf_id` (`kf_id`), + INDEX `idx_status` (`status`), + INDEX `idx_channel_type` (`channel_type`), + INDEX `idx_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表'; + +-- 消息表 +CREATE TABLE IF NOT EXISTS `message` ( + `msg_id` VARCHAR(128) NOT NULL COMMENT '消息ID', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `sender_type` VARCHAR(20) NOT NULL COMMENT '发送者类型: customer/ai/manual', + `sender_id` VARCHAR(64) NOT NULL COMMENT '发送者标识', + `content` TEXT NOT NULL COMMENT '消息内容', + `msg_type` VARCHAR(20) NOT NULL DEFAULT 'text' COMMENT '消息类型: text/image/link等', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `raw_data` TEXT DEFAULT NULL COMMENT '原始消息数据JSON', + PRIMARY KEY (`msg_id`), + INDEX `idx_session_id` (`session_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表'; + +-- 客服账号表 +CREATE TABLE IF NOT EXISTS `kf_account` ( + `kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID', + `name` VARCHAR(100) DEFAULT NULL COMMENT '客服昵称', + `avatar` VARCHAR(500) DEFAULT NULL COMMENT '头像URL', + `status` VARCHAR(20) NOT NULL DEFAULT 'offline' COMMENT '状态: online/offline', + `bind_manual_id` VARCHAR(64) DEFAULT NULL COMMENT '绑定的企业微信员工ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`kf_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服账号表'; + +-- 转人工记录表 +CREATE TABLE IF NOT EXISTS `transfer_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `trigger_reason` VARCHAR(200) DEFAULT NULL COMMENT '触发原因', + `trigger_time` DATETIME NOT NULL COMMENT '触发时间', + `accepted_time` DATETIME DEFAULT NULL COMMENT '客服接入时间', + `accepted_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '接入的客服ID', + PRIMARY KEY (`id`), + INDEX `idx_session_id` (`session_id`), + INDEX `idx_trigger_time` (`trigger_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转人工记录表'; + +-- 快捷回复表 +CREATE TABLE IF NOT EXISTS `quick_reply` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID', + `cs_id` VARCHAR(64) DEFAULT NULL COMMENT '客服ID,为空表示公共', + `category` VARCHAR(50) DEFAULT NULL COMMENT '分类', + `content` VARCHAR(500) NOT NULL COMMENT '回复内容', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_cs_id` (`cs_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快捷回复表'; diff --git a/src/main/resources/static/chat-history.html b/src/main/resources/static/chat-history.html new file mode 100644 index 0000000..3912e0c --- /dev/null +++ b/src/main/resources/static/chat-history.html @@ -0,0 +1,455 @@ + + + + + + 聊天记录查询 + + + +
+
+

聊天记录查询

+

查看各客服账号的历史聊天记录

+
+ +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ 会话列表 (0) +
+
+
请选择客服账号并查询
+
+
+ +
+
+
请选择会话查看聊天记录
+
+
+
+ + + +

选择左侧会话查看聊天记录

+
+
+
+
+
+ + + + diff --git a/src/main/resources/static/customer.html b/src/main/resources/static/customer.html new file mode 100644 index 0000000..34c509c --- /dev/null +++ b/src/main/resources/static/customer.html @@ -0,0 +1,458 @@ + + + + + + 客户模拟端 + + + +
+
+
+ 12:00 + 📶 🔋 +
+
智能客服
+
AI在线
+
+ +
+
会话已开始
+
+
客服
+
您好!我是智能客服,有什么可以帮您的吗?
+
刚刚
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+

测试设置

+ + + + +
+
+ + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..d4e2c5f --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,638 @@ + + + + + + 人工客服工作台 + + + + + +
+ +
+
+

请从左侧选择一个会话

+

WebSocket: 未连接

+
+
+
+ +
+

🧪 模拟客户消息

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