- 创建10个微服务目录结构和Maven多模块配置 - 完成15张MySQL 5.7表设计和初始化数据(40意图+8话术分类+管理员) - 实现common模块:Result、PageResult、异常处理、MyBatisPlus/Redis/RabbitMQ/跨域配置 - 实现BaseEntity、AutoFillHandler、JsonUtils、JsonTypeHandler - 配置API Gateway路由和全部服务的application.yml - 添加docker-compose.yml(MySQL+Redis+RabbitMQ)
8063 lines
292 KiB
Markdown
8063 lines
292 KiB
Markdown
# 第九联盟AI坐席辅助系统 — 完整开发方案(面向AI编程助手)
|
||
|
||
> **版本**: v3.0-final
|
||
> **日期**: 2025年7月
|
||
> **项目**: 第九联盟(9artedu.com)AI坐席辅助系统
|
||
> **开发工具**: Kimi Code / Claude Code
|
||
> **适用环境**: 线上MySQL 5.7(JSON→TEXT适配,向量存Redis)
|
||
> **交互方案**: 方案D(混合)— PC侧边栏H5 + 手机侧边栏H5 + 模板卡片推送
|
||
|
||
---
|
||
|
||
## 文档目录
|
||
|
||
- [第一部分:项目概述与架构设计](#第一部分项目概述与架构设计)
|
||
- [第二部分:数据库设计(MySQL 5.7适配版)](#第二部分数据库设计mysql-57适配版)
|
||
- [第三部分:后端服务详细实现](#第三部分后端服务详细实现)
|
||
- [第四部分:前端实现](#第四部分前端实现)
|
||
- [第五部分:企微集成配置指南](#第五部分企微集成配置指南)
|
||
- [第六部分:部署与运维](#第六部分部署与运维)
|
||
- [第七部分:开发指南(面向AI编程助手)](#第七部分开发指南面向ai编程助手)
|
||
|
||
---
|
||
|
||
## 第一部分:项目概述与架构设计
|
||
|
||
### 1.1 项目概述
|
||
|
||
#### 1.1.1 项目基本信息
|
||
|
||
| 项目 | 详情 |
|
||
|------|------|
|
||
| **项目名称** | 第九联盟AI坐席辅助系统(9artedu-ai-assistant) |
|
||
| **版本** | v3.0-final |
|
||
| **目标用户** | 第九联盟课程顾问/销售(坐席) |
|
||
| **服务对象** | CG美术培训潜在学员(外部联系人) |
|
||
| **核心业务** | 基于企微会话存档+LLM的话术实时推荐 |
|
||
| **部署环境** | 用户自有服务器(线上MySQL 5.7) |
|
||
|
||
#### 1.1.2 业务背景
|
||
|
||
第九联盟(9ART EDU,母公司上海点晴信息科技有限公司)是中国CG数字艺术人才孵化基地,提供以下6大核心方向的CG美术培训课程:
|
||
|
||
- 3D场景UE地编就业班
|
||
- 次世代角色模型就业班
|
||
- 2D原画就业班(角色/场景方向)
|
||
- 游戏/影视动画设计就业班
|
||
- 游戏特效就业班
|
||
- 3D大师研修班
|
||
|
||
**业务数据**:
|
||
- 10+年游戏制作经验
|
||
- 500+家就业推荐企业(腾讯、网易、米哈游等)
|
||
- 8000+名累计服务学员
|
||
- 6大校区(上海、西安、厦门、武汉、青岛、合肥)
|
||
- 央视CCTV2财经频道专题报道
|
||
|
||
#### 1.1.3 核心功能概述
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ AI坐席辅助系统核心功能 │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 📥 数据接入层 │
|
||
│ ├── 企微会话存档拉取(C SDK + JNA) │
|
||
│ ├── RSA解密 + 消息解析 │
|
||
│ └── 回调接收(msgaudit_notify,15秒间隔) │
|
||
│ │
|
||
│ 🧠 AI分析引擎层 │
|
||
│ ├── 对话上下文理解(最近10轮滑动窗口) │
|
||
│ ├── 意图识别(40个场景多标签分类) │
|
||
│ ├── 学员画像构建(5维度标签体系) │
|
||
│ ├── 话术召回(规则召回+向量召回+热门召回) │
|
||
│ ├── 话术排序(多因子加权+MMR多样性) │
|
||
│ └── LLM动态话术生成(兜底策略) │
|
||
│ │
|
||
│ 📤 输出交互层 │
|
||
│ ├── PC端侧边栏H5(360px,完整功能) │
|
||
│ ├── 手机端侧边栏H5(80%高度,精简操作) │
|
||
│ ├── 模板卡片消息推送(高优先级场景主动推送) │
|
||
│ └── 一键发送(sendChatMessage) │
|
||
│ │
|
||
│ ⚙️ 管理后台层 │
|
||
│ ├── 话术库管理(CRUD+分类+导入导出) │
|
||
│ ├── 数据统计看板(使用数据+效果分析) │
|
||
│ └── 系统配置(推送设置+免打扰) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.2 技术选型(明确推荐)
|
||
|
||
| 层级 | 技术选型 | 版本 | 选型理由 |
|
||
|------|---------|------|---------|
|
||
| 后端框架 | **Spring Boot** | 2.7.x | 成熟稳定,生态丰富,兼容MySQL 5.7 |
|
||
| ORM | **MyBatis-Plus** | 3.5.x | 简化CRUD,代码生成,ActiveRecord模式 |
|
||
| 数据库 | **MySQL** | 5.7 | 用户已有线上环境,不增加额外成本 |
|
||
| 缓存 | **Redis** | 6.x | 会话缓存、向量缓存、频率控制 |
|
||
| 消息队列 | **RabbitMQ** | 3.x | 异步处理、削峰填谷、事件驱动 |
|
||
| 向量存储 | **Redis Hash** | - | MySQL 5.7不支持向量类型,Redis存Embedding |
|
||
| API网关 | **Spring Cloud Gateway** | 3.1.x | 路由、限流、鉴权统一入口 |
|
||
| 前端框架 | **React 18 + TypeScript** | 18.x | 组件化开发,类型安全 |
|
||
| 移动端UI | **Ant Design Mobile** | 5.x | 企微H5移动端适配 |
|
||
| PC端UI | **Ant Design** | 5.x | 管理后台UI组件 |
|
||
| 构建工具 | **Vite** | 5.x | 快速构建,HMR |
|
||
| 状态管理 | **Zustand** | 4.x | 轻量,无样板代码 |
|
||
| 大模型主选 | **通义千问API(qwen-turbo)** | - | 国产、低成本、中文优秀、数据合规 |
|
||
| 大模型备选 | **通义千问(qwen-plus)** | - | 复杂场景备选 |
|
||
| Embedding | **通义千问文本Embedding API** | - | 向量化解析,1536维 |
|
||
| 企微SDK | **企业微信JS-SDK 2.0** | 2.0.2 | ww.register方式,iOS兼容 |
|
||
| C SDK集成 | **JNA** | 5.13.x | Java调用企微C SDK,无需JNI编译 |
|
||
| 容器化 | **Docker + Docker Compose** | - | 快速部署,环境一致 |
|
||
|
||
#### 1.2.1 MySQL 5.7关键限制与适配方案
|
||
|
||
| 限制 | 影响 | 解决方案 |
|
||
|------|------|---------|
|
||
| 不支持JSON数据类型 | 无法使用JSON字段存储结构化数据 | 使用TEXT类型存储JSON字符串,应用层序列化/反序列化 |
|
||
| 不支持向量数据类型 | 无法存储Embedding向量 | 向量存储使用Redis Hash,MySQL只存业务数据和向量标记 |
|
||
| utf8mb4支持 | 需要存储emoji等特殊字符 | 所有表使用utf8mb4字符集 |
|
||
| JSON函数缺失 | 无法使用JSON_EXTRACT等 | 应用层处理JSON数据,Java中使用Jackson/Gson |
|
||
|
||
### 1.3 系统架构图
|
||
|
||
#### 1.3.1 全景架构图
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph Client["客户端层"]
|
||
PC["企微PC客户端"]
|
||
Mobile["企微手机客户端(iOS/Android)"]
|
||
SidePC["侧边栏H5-PC 360px"]
|
||
SideMobile["侧边栏H5-Mobile 80%高度"]
|
||
AdminWeb["管理后台Web"]
|
||
CardPush["模板卡片推送"]
|
||
end
|
||
|
||
subgraph Access["接入层"]
|
||
Nginx["Nginx负载均衡/SSL终端"]
|
||
Gateway["API Gateway<br/>Spring Cloud Gateway"]
|
||
WSDK["企微JS-SDK<br/>ww.register/agentConfig"]
|
||
OAuth["企微OAuth2认证"]
|
||
Callback["企微回调接收<br/>msgaudit_notify/事件推送"]
|
||
end
|
||
|
||
subgraph Service["服务层-10个微服务集群"]
|
||
ArchS["archive-service<br/>会话存档拉取与解析"]
|
||
ConvS["conversation-service<br/>对话上下文管理"]
|
||
IntentS["intent-service<br/>意图识别与学员画像"]
|
||
RecS["recommendation-service<br/>话术匹配推荐与排序"]
|
||
GenS["generation-service<br/>LLM话术生成+Prompt引擎"]
|
||
KnowS["knowledge-service<br/>知识库管理"]
|
||
AuthS["auth-service<br/>认证授权"]
|
||
KbAdmS["kb-admin-service<br/>话术库管理后台"]
|
||
AnaS["analytics-service<br/>数据分析+话术效果"]
|
||
end
|
||
|
||
subgraph Data["数据层"]
|
||
MySQL[("MySQL 5.7<br/>业务数据/话术库/会话<br/>TEXT存JSON")]
|
||
Redis[("Redis 6.x<br/>缓存/会话/向量存储<br/>实时状态")]
|
||
MQ[("RabbitMQ 3.x<br/>消息队列/异步处理")]
|
||
end
|
||
|
||
subgraph External["外部依赖"]
|
||
WeComAPI["企业微信API<br/>存档SDK/消息推送"]
|
||
LLM1["通义千问API<br/>qwen-turbo(主)"]
|
||
LLM2["通义千问API<br/>qwen-plus(备)"]
|
||
EmbedAPI["文本Embedding API"]
|
||
end
|
||
|
||
PC --> SidePC
|
||
Mobile --> SideMobile
|
||
SidePC --> Nginx
|
||
SideMobile --> Nginx
|
||
AdminWeb --> Nginx
|
||
Nginx --> Gateway
|
||
Gateway --> OAuth
|
||
OAuth --> WeComAPI
|
||
SidePC --> WSDK
|
||
SideMobile --> WSDK
|
||
WSDK --> WeComAPI
|
||
CardPush --> WeComAPI
|
||
|
||
Gateway --> AuthS
|
||
Gateway --> ArchS
|
||
Gateway --> ConvS
|
||
Gateway --> IntentS
|
||
Gateway --> RecS
|
||
Gateway --> GenS
|
||
Gateway --> KnowS
|
||
Gateway --> KbAdmS
|
||
Gateway --> AnaS
|
||
|
||
ArchS --> ConvS
|
||
ConvS --> IntentS
|
||
IntentS --> RecS
|
||
RecS --> GenS
|
||
ConvS --> RecS
|
||
KnowS --> RecS
|
||
KbAdmS --> RecS
|
||
GenS --> LLM1
|
||
GenS --> LLM2
|
||
|
||
ArchS --> MySQL
|
||
ArchS --> Redis
|
||
ConvS --> Redis
|
||
ConvS --> MySQL
|
||
IntentS --> MySQL
|
||
IntentS --> Redis
|
||
RecS --> MySQL
|
||
RecS --> Redis
|
||
GenS --> Redis
|
||
GenS --> MySQL
|
||
KnowS --> MySQL
|
||
AuthS --> MySQL
|
||
AuthS --> Redis
|
||
KbAdmS --> MySQL
|
||
KbAdmS --> Redis
|
||
AnaS --> MySQL
|
||
AnaS --> Redis
|
||
|
||
MQ -.-> ArchS
|
||
MQ -.-> ConvS
|
||
MQ -.-> RecS
|
||
MQ -.-> AnaS
|
||
|
||
Callback --> ArchS
|
||
ArchS --> WeComAPI
|
||
|
||
style ArchS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||
style ConvS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||
style IntentS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||
style RecS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||
style GenS fill:#fff3e0,stroke:#e65100,stroke-width:2px
|
||
```
|
||
|
||
#### 1.3.2 核心数据流图
|
||
|
||
```mermaid
|
||
graph LR
|
||
subgraph DataIn["数据输入"]
|
||
WX["企微存档消息"]
|
||
KB["话术库"]
|
||
KL["知识库"]
|
||
end
|
||
|
||
subgraph Pipeline["处理流水线"]
|
||
direction TB
|
||
P1["Step1: 对话解析<br/>轮次提取|角色识别"]
|
||
P2["Step2: 上下文管理<br/>窗口构建|状态追踪"]
|
||
P3["Step3: 意图识别<br/>40类分类|槽位填充"]
|
||
P4["Step4: 画像更新<br/>类型|基础|意向度"]
|
||
P5["Step5: 话术召回<br/>规则召回|向量召回|热门召回"]
|
||
P6["Step6: 话术排序<br/>多因子评分|MMR多样性"]
|
||
P7["Step7: 结果输出<br/>Top5推荐|状态摘要"]
|
||
|
||
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
|
||
end
|
||
|
||
subgraph Output["输出层"]
|
||
J["推荐话术列表<br/>Top 3-5条"]
|
||
K["学员画像更新<br/>标签体系"]
|
||
L["质量分析报告"]
|
||
end
|
||
|
||
subgraph Feedback["反馈闭环"]
|
||
F1["坐席选择反馈"]
|
||
F2["转化率追踪"]
|
||
F3["模型更新"]
|
||
end
|
||
|
||
WX --> P1
|
||
KB --> P5
|
||
KL --> P3
|
||
KL --> P5
|
||
P7 --> J
|
||
P7 --> K
|
||
J --> F1 --> F2 --> F3
|
||
F3 --> KB
|
||
```
|
||
|
||
#### 1.3.3 对话状态机
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> 开场白: 新会话开始
|
||
开场白 --> 需求探询: 学员回应
|
||
需求探询 --> 课程推荐: 基础信息已收集
|
||
需求探询 --> 异议处理: 学员提出顾虑
|
||
课程推荐 --> 价值塑造: 学员感兴趣
|
||
课程推荐 --> 异议处理: 学员有疑问
|
||
价值塑造 --> 报价沟通: 询问价格
|
||
报价沟通 --> 异议处理: 价格顾虑
|
||
报价沟通 --> 促成报名: 接受价格
|
||
异议处理 --> 课程推荐: 重新推荐
|
||
异议处理 --> 报价沟通: 顾虑消除
|
||
异议处理 --> 促成报名: 异议解决
|
||
促成报名 --> 跟进维护: 暂不报名
|
||
促成报名 --> [*]: 报名成功
|
||
跟进维护 --> 课程推荐: 再次咨询
|
||
跟进维护 --> [*]: 超过7天未回复
|
||
```
|
||
|
||
#### 1.3.4 端到端数据流时序
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Student as 学员
|
||
participant WeCom as 企业微信
|
||
participant Archive as archive-service
|
||
participant MQ as RabbitMQ
|
||
participant Conv as conversation-service
|
||
participant Intent as intent-service
|
||
participant Rec as recommendation-service
|
||
participant WS as WebSocket
|
||
participant Staff as 坐席
|
||
|
||
Note over Student,Staff: 端到端延迟目标 < 25秒
|
||
|
||
Student->>WeCom: 发送消息
|
||
Note right of WeCom: T+0s
|
||
|
||
WeCom->>Archive: msgaudit_notify回调
|
||
Note right of WeCom: T+~15s(回调间隔)
|
||
|
||
Archive->>Archive: GetChatData拉取
|
||
Note right of Archive: T+~16s
|
||
|
||
Archive->>Archive: RSA解密+解析
|
||
Note right of Archive: T+~17s
|
||
|
||
Archive->>MQ: 发布消息事件
|
||
Note right of MQ: T+~17.5s
|
||
|
||
MQ->>Conv: 消费消息事件
|
||
Note right of Conv: T+~18s
|
||
|
||
Conv->>Conv: 更新上下文窗口
|
||
Conv->>Intent: 调用意图识别API
|
||
Note right of Intent: T+~18.5s
|
||
|
||
Intent->>Intent: LLM意图分析
|
||
Note right of Intent: T+~19s(200-500ms)
|
||
|
||
Intent-->>Conv: 返回意图+画像
|
||
Conv->>Rec: 调用话术推荐API
|
||
Note right of Rec: T+~19.5s
|
||
|
||
Rec->>Rec: 三层召回+排序
|
||
Note right of Rec: T+~20s(100-300ms)
|
||
|
||
Rec->>WS: 推送推荐结果
|
||
Note right of WS: T+~21s
|
||
|
||
WS->>Staff: 侧边栏更新推荐
|
||
Note left of Staff: T+~22s
|
||
|
||
Staff->>WS: 点击发送
|
||
WS->>Rec: 记录使用反馈
|
||
|
||
Note over Student,Staff: 总延迟: ~22秒(含15秒回调间隔)
|
||
```
|
||
|
||
#### 1.3.5 话术推荐引擎架构
|
||
|
||
```mermaid
|
||
graph TD
|
||
A["输入: 意图+画像+阶段+学员消息"] --> B{"话术库召回"}
|
||
B --> C["规则召回<br/>意图码+阶段+画像标签<br/>MySQL查询"]
|
||
B --> D["向量召回<br/>学员消息Embedding<br/>Redis向量检索"]
|
||
B --> E["热门召回<br/>近7天高成功率<br/>Redis ZSET"]
|
||
C --> F["候选话术池<br/>去重+过滤"]
|
||
D --> F
|
||
E --> F
|
||
F --> G{"候选话术数量"}
|
||
G -->|">=3条"| H["多因子排序<br/>加权评分"]
|
||
G -->|"<3条"| I["LLM动态生成<br/>补充话术"]
|
||
G -->|"0条"| J["纯LLM生成<br/>兜底话术"]
|
||
H --> K["MMR多样性重排"]
|
||
I --> H
|
||
J --> L["LLM话术审核<br/>安全性+准确性"]
|
||
K --> M["Top 5输出<br/>含推荐理由"]
|
||
L --> M
|
||
```
|
||
|
||
### 1.4 项目目录结构
|
||
|
||
```
|
||
ai-assistant/
|
||
├── backend/ # 后端服务
|
||
│ ├── archive-service/ # 会话存档服务
|
||
│ │ ├── src/main/java/com/artedu/archive/
|
||
│ │ │ ├── ArchiveServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── ArchiveCallbackController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── ArchivePullService.java
|
||
│ │ │ │ ├── ArchiveSdkService.java
|
||
│ │ │ │ └── MessageStoreService.java
|
||
│ │ │ ├── sdk/
|
||
│ │ │ │ └── WeWorkFinanceSdk.java # JNA接口
|
||
│ │ │ ├── entity/
|
||
│ │ │ │ └── ArchiveMessage.java
|
||
│ │ │ ├── mapper/
|
||
│ │ │ │ └── ArchiveMessageMapper.java
|
||
│ │ │ └── config/
|
||
│ │ │ └── RabbitConfig.java
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ ├── application.yml
|
||
│ │ │ └── libWeWorkFinanceSdk.so # Linux C SDK
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── conversation-service/ # 对话管理服务
|
||
│ │ ├── src/main/java/com/artedu/conversation/
|
||
│ │ │ ├── ConversationServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── ConversationController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── ConversationManager.java
|
||
│ │ │ │ ├── ContextWindowService.java
|
||
│ │ │ │ ├── TurnParserService.java
|
||
│ │ │ │ └── MessageEventHandler.java
|
||
│ │ │ ├── entity/
|
||
│ │ │ │ ├── Conversation.java
|
||
│ │ │ │ └── ConversationTurn.java
|
||
│ │ │ ├── mapper/
|
||
│ │ │ └── config/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── intent-service/ # 意图识别服务
|
||
│ │ ├── src/main/java/com/artedu/intent/
|
||
│ │ │ ├── IntentServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── IntentController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── IntentRecognitionService.java
|
||
│ │ │ │ ├── UserProfilingService.java
|
||
│ │ │ │ └── RuleClassifier.java
|
||
│ │ │ ├── entity/
|
||
│ │ │ │ ├── IntentCategory.java
|
||
│ │ │ │ ├── IntentRecognition.java
|
||
│ │ │ │ └── CustomerProfile.java
|
||
│ │ │ ├── mapper/
|
||
│ │ │ └── prompt/
|
||
│ │ │ └── IntentPromptTemplate.java
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── recommendation-service/ # 话术推荐服务
|
||
│ │ ├── src/main/java/com/artedu/recommend/
|
||
│ │ │ ├── RecommendationServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── RecommendController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── RecallService.java
|
||
│ │ │ │ ├── RankingService.java
|
||
│ │ │ │ ├── MMRService.java
|
||
│ │ │ │ └── FeedbackService.java
|
||
│ │ │ ├── entity/
|
||
│ │ │ │ ├── Utterance.java
|
||
│ │ │ │ └── Recommendation.java
|
||
│ │ │ ├── mapper/
|
||
│ │ │ └── config/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── generation-service/ # LLM生成服务
|
||
│ │ ├── src/main/java/com/artedu/generation/
|
||
│ │ │ ├── GenerationServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── GenerationController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── LLMClient.java
|
||
│ │ │ │ ├── QianwenClient.java
|
||
│ │ │ │ ├── PromptEngine.java
|
||
│ │ │ │ └── TokenControlService.java
|
||
│ │ │ ├── dto/
|
||
│ │ │ └── config/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── auth-service/ # 认证授权服务
|
||
│ │ ├── src/main/java/com/artedu/auth/
|
||
│ │ │ ├── AuthServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── AuthController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── WeComOAuthService.java
|
||
│ │ │ │ └── JwtService.java
|
||
│ │ │ ├── entity/
|
||
│ │ │ │ └── Staff.java
|
||
│ │ │ ├── mapper/
|
||
│ │ │ └── security/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── kb-admin-service/ # 话术库管理服务
|
||
│ │ ├── src/main/java/com/artedu/kbadmin/
|
||
│ │ │ ├── KbAdminServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ │ └── UtteranceController.java
|
||
│ │ │ ├── service/
|
||
│ │ │ │ ├── UtteranceCrudService.java
|
||
│ │ │ │ ├── CategoryService.java
|
||
│ │ │ │ └── ImportExportService.java
|
||
│ │ │ ├── entity/
|
||
│ │ │ └── mapper/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── analytics-service/ # 数据分析服务
|
||
│ │ ├── src/main/java/com/artedu/analytics/
|
||
│ │ │ ├── AnalyticsServiceApplication.java
|
||
│ │ │ ├── controller/
|
||
│ │ │ └── service/
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ └── application.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ ├── gateway/ # API网关
|
||
│ │ ├── src/main/java/com/artedu/gateway/
|
||
│ │ │ └── GatewayApplication.java
|
||
│ │ ├── src/main/resources/
|
||
│ │ │ ├── application.yml
|
||
│ │ │ └── bootstrap.yml
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ └── common/ # 公共模块
|
||
│ └── src/main/java/com/artedu/common/
|
||
│ ├── result/Result.java # 统一响应
|
||
│ ├── exception/ # 全局异常
|
||
│ ├── config/ # 公共配置
|
||
│ └── util/ # 工具类
|
||
│
|
||
├── frontend/ # 前端
|
||
│ ├── sidebar/ # 侧边栏H5(PC+手机)
|
||
│ │ ├── src/
|
||
│ │ │ ├── main.tsx # 入口
|
||
│ │ │ ├── App.tsx # 根组件
|
||
│ │ │ ├── components/ # 组件
|
||
│ │ │ │ ├── Header.tsx
|
||
│ │ │ │ ├── TabBar.tsx
|
||
│ │ │ │ ├── ProfileCard.tsx
|
||
│ │ │ │ ├── StageIndicator.tsx
|
||
│ │ │ │ ├── ScriptCard.tsx
|
||
│ │ │ │ ├── ScriptRecommend.tsx
|
||
│ │ │ │ ├── AnalysisPanel.tsx
|
||
│ │ │ │ └── SearchPanel.tsx
|
||
│ │ │ ├── services/ # API调用
|
||
│ │ │ │ ├── api.ts
|
||
│ │ │ │ └── websocket.ts
|
||
│ │ │ ├── stores/ # 状态管理
|
||
│ │ │ │ ├── useScriptStore.ts
|
||
│ │ │ │ ├── useProfileStore.ts
|
||
│ │ │ │ └── useStageStore.ts
|
||
│ │ │ ├── hooks/ # 自定义Hook
|
||
│ │ │ │ └── useWeComSdk.ts
|
||
│ │ │ └── types/ # 类型定义
|
||
│ │ │ └── index.ts
|
||
│ │ ├── index.html
|
||
│ │ ├── vite.config.ts
|
||
│ │ ├── package.json
|
||
│ │ └── Dockerfile
|
||
│ │
|
||
│ └── admin/ # 管理后台
|
||
│ ├── src/
|
||
│ ├── package.json
|
||
│ └── Dockerfile
|
||
│
|
||
├── docs/ # 文档
|
||
│ ├── api/ # API文档
|
||
│ └── deploy/ # 部署文档
|
||
│
|
||
├── docker-compose.yml # Docker编排
|
||
├── nginx.conf # Nginx配置
|
||
├── init.sql # 数据库初始化
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
## 第二部分:数据库设计(MySQL 5.7适配版)
|
||
|
||
### 2.1 数据库设计原则
|
||
|
||
1. **JSON数据使用TEXT类型存储**:MySQL 5.7不支持JSON类型,所有结构化数据使用TEXT存储,应用层使用Jackson进行序列化/反序列化
|
||
2. **向量数据不存入MySQL**:Embedding向量使用Redis Hash存储,MySQL只存储`has_vector`标记和`vector_updated_at`时间
|
||
3. **所有表使用InnoDB引擎**:支持事务、行级锁、外键
|
||
4. **字符集utf8mb4**:支持emoji等特殊字符
|
||
5. **每张表包含created_at和updated_at**:统一审计字段
|
||
6. **索引策略**:
|
||
- 主键使用BIGINT AUTO_INCREMENT
|
||
- 业务唯一键使用VARCHAR+唯一索引
|
||
- 查询字段添加普通索引
|
||
- 避免过多索引影响写入性能
|
||
|
||
### 2.2 完整建表SQL
|
||
|
||
#### 2.2.1 会话存档消息表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 企微会话存档消息表 (archive_messages)
|
||
-- 存储企微会话存档解密后的原始消息
|
||
-- MySQL 5.7适配: JSON → TEXT,应用层序列化/反序列化
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `archive_messages` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`msgid` VARCHAR(255) NOT NULL COMMENT '企微消息唯一ID',
|
||
`seq` BIGINT(20) NOT NULL COMMENT '存档序列号,用于增量拉取',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`action` VARCHAR(32) DEFAULT 'send' COMMENT 'send/recall/agree/disagree',
|
||
`from_user` VARCHAR(128) NOT NULL COMMENT '发送者userid',
|
||
`from_role` VARCHAR(32) NOT NULL COMMENT '发送者角色: INTERNAL-企业内部成员 EXTERNAL-外部联系人 SYSTEM-系统',
|
||
`to_user` VARCHAR(128) COMMENT '接收者userid(单聊)',
|
||
`tolist` TEXT COMMENT '接收者列表(群聊),JSON数组字符串',
|
||
`roomid` VARCHAR(255) COMMENT '群聊ID(群聊时有值)',
|
||
`msgtype` VARCHAR(64) NOT NULL COMMENT 'text/image/voice/video/file/link/location/weapp/chatrecord/voip',
|
||
`msgtime` BIGINT(20) NOT NULL COMMENT '消息发送时间,UTC毫秒时间戳',
|
||
`content` LONGTEXT COMMENT '文本消息内容(长文本使用LONGTEXT)',
|
||
`media_data` TEXT COMMENT '媒体消息元数据(sdkfileid/文件大小/时长等),JSON字符串',
|
||
`session_id` VARCHAR(128) COMMENT '关联的会话ID(业务生成)',
|
||
`decrypt_status` TINYINT DEFAULT 1 COMMENT '解密状态: 1成功 2失败',
|
||
`decrypt_error` VARCHAR(512) COMMENT '解密失败原因',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_msgid_corp` (`msgid`, `corp_id`) COMMENT '消息ID+企业ID唯一',
|
||
INDEX `idx_seq` (`seq`) COMMENT '序列号索引,用于增量拉取',
|
||
INDEX `idx_session_id` (`session_id`) COMMENT '会话ID索引',
|
||
INDEX `idx_from_user_msgtime` (`from_user`, `msgtime`) COMMENT '发送者+时间索引',
|
||
INDEX `idx_to_user_msgtime` (`to_user`, `msgtime`) COMMENT '接收者+时间索引',
|
||
INDEX `idx_roomid_msgtime` (`roomid`, `msgtime`) COMMENT '群聊ID+时间索引',
|
||
INDEX `idx_msgtime` (`msgtime`) COMMENT '消息时间索引',
|
||
INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微会话存档消息表';
|
||
```
|
||
|
||
#### 2.2.2 会话表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 会话表 (conversations)
|
||
-- 管理学员与坐席的对话会话
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `conversations` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`session_id` VARCHAR(128) NOT NULL COMMENT '会话唯一ID(业务生成)',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员(外部联系人)userid',
|
||
`customer_name` VARCHAR(64) COMMENT '学员昵称',
|
||
`staff_id` VARCHAR(128) NOT NULL COMMENT '坐席(企业成员)userid',
|
||
`staff_name` VARCHAR(64) COMMENT '坐席姓名',
|
||
`room_id` VARCHAR(128) COMMENT '群聊ID(群聊时有值)',
|
||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '会话状态: ACTIVE-活跃 IDLE-空闲 CLOSED-关闭 ARCHIVED-归档',
|
||
`current_stage` VARCHAR(32) DEFAULT '开场白' COMMENT '当前对话阶段',
|
||
`stage_confidence` DECIMAL(4,3) DEFAULT 0.000 COMMENT '阶段判断置信度',
|
||
`round_count` INT(11) DEFAULT 0 COMMENT '对话轮次',
|
||
`intent_count` INT(11) DEFAULT 0 COMMENT '意图识别次数',
|
||
`last_msg_id` VARCHAR(255) COMMENT '最后一条消息ID',
|
||
`last_msg_time` TIMESTAMP NULL COMMENT '最后消息时间',
|
||
`last_msg_content` VARCHAR(512) COMMENT '最后消息内容摘要',
|
||
`start_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '会话开始时间',
|
||
`end_time` TIMESTAMP NULL COMMENT '会话结束时间',
|
||
`idle_duration` INT(11) DEFAULT 0 COMMENT '空闲时长(分钟)',
|
||
`context_summary` TEXT COMMENT '对话摘要(LLM生成)',
|
||
`key_events` TEXT COMMENT '关键事件列表,JSON字符串',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_session_id` (`session_id`) COMMENT '会话ID唯一',
|
||
INDEX `idx_corp_staff` (`corp_id`, `staff_id`, `status`) COMMENT '企业+坐席+状态',
|
||
INDEX `idx_corp_customer` (`corp_id`, `customer_id`, `status`) COMMENT '企业+学员+状态',
|
||
INDEX `idx_status_time` (`status`, `last_msg_time`) COMMENT '状态+时间',
|
||
INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表';
|
||
```
|
||
|
||
#### 2.2.3 对话轮次表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 对话轮次表 (conversation_turns)
|
||
-- 记录每个对话轮次的详细信息
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `conversation_turns` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`turn_id` VARCHAR(128) NOT NULL COMMENT '轮次ID',
|
||
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
|
||
`turn_number` INT(11) NOT NULL COMMENT '轮次序号',
|
||
`student_msg_id` VARCHAR(255) COMMENT '学员消息ID',
|
||
`student_content` TEXT COMMENT '学员消息内容',
|
||
`seat_msg_id` VARCHAR(255) COMMENT '坐席回复消息ID',
|
||
`seat_content` TEXT COMMENT '坐席回复内容',
|
||
`turn_type` VARCHAR(32) DEFAULT 'QUERY' COMMENT '轮次类型: OPEN-开场 QUERY-咨询 OBJECTION-异议 INTEREST-意向 SILENCE-沉默 FOLLOWUP-跟进 CLOSE-收尾',
|
||
`primary_intent` VARCHAR(32) COMMENT '主意图编码',
|
||
`intent_confidence` DECIMAL(4,3) COMMENT '意图置信度',
|
||
`student_emotion` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '学员情绪: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极 ANXIOUS-焦虑 EXCITED-兴奋',
|
||
`key_topics` TEXT COMMENT '涉及主题列表,JSON字符串',
|
||
`slot_changes` TEXT COMMENT '槽位变化,JSON字符串',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
UNIQUE KEY `uk_turn_id` (`turn_id`) COMMENT '轮次ID唯一',
|
||
INDEX `idx_session_turn` (`session_id`, `turn_number`) COMMENT '会话+轮次号',
|
||
INDEX `idx_session_time` (`session_id`, `created_at`) COMMENT '会话+时间'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话轮次表';
|
||
```
|
||
|
||
#### 2.2.4 意图分类表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 意图分类表 (intent_categories)
|
||
-- 存储40个意图分类的定义
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `intent_categories` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`code` VARCHAR(32) NOT NULL COMMENT '意图编码,如INT-COURSE-01',
|
||
`name` VARCHAR(64) NOT NULL COMMENT '意图名称',
|
||
`domain` VARCHAR(32) NOT NULL COMMENT '所属领域',
|
||
`description` VARCHAR(256) COMMENT '意图说明',
|
||
`priority` VARCHAR(8) DEFAULT 'P2' COMMENT '优先级: P1-高 P2-中 P3-低',
|
||
`trigger_keywords` TEXT COMMENT '触发关键词列表,JSON数组字符串',
|
||
`sample_expressions` TEXT COMMENT '典型表达示例,JSON数组字符串',
|
||
`status` TINYINT DEFAULT 1 COMMENT '状态: 1启用 0停用',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_code` (`code`) COMMENT '意图编码唯一',
|
||
INDEX `idx_domain_priority` (`domain`, `priority`) COMMENT '领域+优先级'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图分类表';
|
||
```
|
||
|
||
#### 2.2.5 意图识别记录表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 意图识别记录表 (intent_recognitions)
|
||
-- 每次意图识别的详细记录
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `intent_recognitions` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
|
||
`turn_id` VARCHAR(128) COMMENT '轮次ID',
|
||
`msg_id` VARCHAR(255) NOT NULL COMMENT '消息ID',
|
||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID',
|
||
`staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID',
|
||
`message_content` TEXT COMMENT '消息内容摘要',
|
||
`primary_intent_code` VARCHAR(32) NOT NULL COMMENT '主意图编码',
|
||
`primary_intent_confidence` DECIMAL(4,3) COMMENT '主意图置信度',
|
||
`secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串',
|
||
`sentiment` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '情感: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极',
|
||
`sentiment_confidence` DECIMAL(4,3) COMMENT '情感置信度',
|
||
`recognition_method` VARCHAR(32) DEFAULT 'HYBRID' COMMENT '识别方法: RULE-规则 LLM-大模型 HYBRID-混合',
|
||
`llm_model` VARCHAR(64) COMMENT '使用的LLM模型',
|
||
`prompt_tokens` INT(11) COMMENT 'Prompt Token数',
|
||
`completion_tokens` INT(11) COMMENT 'Completion Token数',
|
||
`elapsed_ms` INT(11) COMMENT '识别耗时(毫秒)',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
INDEX `idx_session_msg` (`session_id`, `msg_id`) COMMENT '会话+消息',
|
||
INDEX `idx_customer_time` (`customer_id`, `created_at`) COMMENT '学员+时间',
|
||
INDEX `idx_intent_code` (`primary_intent_code`, `created_at`) COMMENT '意图编码+时间'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图识别记录表';
|
||
```
|
||
|
||
#### 2.2.6 学员画像表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 学员画像表 (customer_profiles)
|
||
-- 存储学员的多维度画像
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `customer_profiles` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员userid',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`student_type` VARCHAR(32) COMMENT '学员类型: 在校大学生/转行人员/在职提升/高中毕业生/家长代询',
|
||
`student_type_confidence` DECIMAL(4,3) COMMENT '学员类型置信度',
|
||
`skill_level` VARCHAR(32) COMMENT '基础水平: 零基础/有美术基础/相关专业/有从业经验',
|
||
`skill_level_confidence` DECIMAL(4,3) COMMENT '基础水平置信度',
|
||
`intent_level` VARCHAR(16) DEFAULT '低' COMMENT '意向度等级: 高/中/低/无意向',
|
||
`intent_score` DECIMAL(6,2) DEFAULT 0.00 COMMENT '意向度分数',
|
||
`concern_focus` VARCHAR(32) COMMENT '关注重点: 价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型',
|
||
`concern_focus_confidence` DECIMAL(4,3) COMMENT '关注重点置信度',
|
||
`decision_stage` VARCHAR(32) DEFAULT '信息了解' COMMENT '决策阶段: 信息了解/方案比较/购买决策/报名成交',
|
||
`decision_stage_confidence` DECIMAL(4,3) COMMENT '决策阶段置信度',
|
||
`interested_courses` TEXT COMMENT '意向课程列表,JSON字符串',
|
||
`budget_hint` VARCHAR(64) COMMENT '预算暗示',
|
||
`preferred_city` VARCHAR(32) COMMENT '意向城市',
|
||
`age` INT(11) COMMENT '年龄',
|
||
`education` VARCHAR(32) COMMENT '学历',
|
||
`current_occupation` VARCHAR(64) COMMENT '当前职业',
|
||
`slot_data` TEXT COMMENT '完整槽位数据,JSON字符串',
|
||
`conversation_count` INT(11) DEFAULT 0 COMMENT '对话次数',
|
||
`last_conversation_time` TIMESTAMP NULL COMMENT '上次对话时间',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '学员+企业唯一',
|
||
INDEX `idx_intent_score` (`intent_score`) COMMENT '意向度分数索引',
|
||
INDEX `idx_decision_stage` (`decision_stage`) COMMENT '决策阶段索引'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员画像表';
|
||
```
|
||
|
||
#### 2.2.7 话术库表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 话术库表 (utterances)
|
||
-- 存储所有推荐话术
|
||
-- MySQL 5.7适配: JSON标签 → TEXT,向量存Redis
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `utterances` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`utterance_id` VARCHAR(64) NOT NULL COMMENT '话术唯一ID(业务编码)',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`title` VARCHAR(128) NOT NULL COMMENT '话术标题',
|
||
`content` TEXT NOT NULL COMMENT '话术内容(富文本)',
|
||
`content_text` TEXT COMMENT '纯文本内容(用于全文检索)',
|
||
`version` INT(11) DEFAULT 1 COMMENT '版本号',
|
||
`status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '状态: DRAFT-草稿 ACTIVE-启用 INACTIVE-停用 DEPRECATED-废弃',
|
||
`priority` INT(11) DEFAULT 5 COMMENT '优先级(1-10,越小越优先)',
|
||
`stage_tags` TEXT COMMENT '适用阶段标签,JSON数组字符串',
|
||
`intent_tags` TEXT COMMENT '匹配意图标签,JSON数组字符串',
|
||
`profile_tags` TEXT COMMENT '适用画像标签,JSON数组字符串',
|
||
`course_type_tags` TEXT COMMENT '适用课程标签,JSON数组字符串',
|
||
`emotion_tags` TEXT COMMENT '情感标签,JSON数组字符串',
|
||
`topic_tags` TEXT COMMENT '主题标签,JSON数组字符串',
|
||
`used_count` INT(11) DEFAULT 0 COMMENT '使用次数',
|
||
`selected_count` INT(11) DEFAULT 0 COMMENT '选中次数',
|
||
`positive_feedback` INT(11) DEFAULT 0 COMMENT '正面反馈数',
|
||
`negative_feedback` INT(11) DEFAULT 0 COMMENT '负面反馈数',
|
||
`conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数',
|
||
`success_rate` DECIMAL(5,4) DEFAULT 0.0000 COMMENT '成功率(0-1)',
|
||
`avg_response_time_ms` INT(11) COMMENT '平均响应时间(毫秒)',
|
||
`has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量: 0否 1是',
|
||
`vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间',
|
||
`trigger_keywords` TEXT COMMENT '触发关键词,JSON字符串',
|
||
`variables` TEXT COMMENT '变量定义,JSON字符串',
|
||
`created_by` VARCHAR(64) COMMENT '创建人',
|
||
`reviewed_by` VARCHAR(64) COMMENT '审核人',
|
||
`source` VARCHAR(32) DEFAULT 'MANUAL' COMMENT '来源: MANUAL-人工录入 IMPORTED-导入 LLM_GENERATED-AI生成',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_utterance_id` (`utterance_id`) COMMENT '话术ID唯一',
|
||
INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态',
|
||
INDEX `idx_success_rate` (`success_rate`) COMMENT '成功率索引',
|
||
INDEX `idx_used_count` (`used_count`) COMMENT '使用次数索引',
|
||
FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索索引'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术库表';
|
||
```
|
||
|
||
#### 2.2.8 话术分类表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 话术分类表 (utterance_categories)
|
||
-- 话术按销售阶段分类
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `utterance_categories` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`code` VARCHAR(32) NOT NULL COMMENT '分类编码',
|
||
`name` VARCHAR(64) NOT NULL COMMENT '分类名称',
|
||
`parent_code` VARCHAR(32) DEFAULT 'ROOT' COMMENT '父级编码',
|
||
`sort_order` INT(11) DEFAULT 0 COMMENT '排序号',
|
||
`color` VARCHAR(16) DEFAULT '#1890ff' COMMENT '显示颜色',
|
||
`description` VARCHAR(256) COMMENT '分类说明',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
UNIQUE KEY `uk_code` (`code`) COMMENT '分类编码唯一'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术分类表';
|
||
```
|
||
|
||
#### 2.2.9 话术效果表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 话术效果表 (utterance_performance)
|
||
-- 每小时聚合的话术使用效果
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `utterance_performance` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`utterance_id` VARCHAR(64) NOT NULL COMMENT '话术ID',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`hour_window` VARCHAR(13) NOT NULL COMMENT '时间窗口,格式: 2025-01-15-14',
|
||
`show_count` INT(11) DEFAULT 0 COMMENT '展示次数',
|
||
`click_count` INT(11) DEFAULT 0 COMMENT '点击次数',
|
||
`send_count` INT(11) DEFAULT 0 COMMENT '发送次数',
|
||
`positive_count` INT(11) DEFAULT 0 COMMENT '正面反馈数',
|
||
`negative_count` INT(11) DEFAULT 0 COMMENT '负面反馈数',
|
||
`ignore_count` INT(11) DEFAULT 0 COMMENT '忽略次数',
|
||
`conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数',
|
||
`ctr` DECIMAL(5,4) COMMENT '点击率',
|
||
`conversion_rate` DECIMAL(5,4) COMMENT '转化率',
|
||
`score` DECIMAL(6,4) COMMENT '综合评分(EWA算法)',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
UNIQUE KEY `uk_utterance_hour` (`utterance_id`, `hour_window`) COMMENT '话术+时间窗口唯一',
|
||
INDEX `idx_corp_hour` (`corp_id`, `hour_window`) COMMENT '企业+时间窗口'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术效果表';
|
||
```
|
||
|
||
#### 2.2.10 推荐记录表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 推荐记录表 (recommendations)
|
||
-- 记录每次话术推荐的详细日志
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `recommendations` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`recommendation_id` VARCHAR(128) NOT NULL COMMENT '推荐记录ID',
|
||
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
|
||
`turn_id` VARCHAR(128) COMMENT '轮次ID',
|
||
`customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID',
|
||
`staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`primary_intent` VARCHAR(32) COMMENT '主意图编码',
|
||
`secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串',
|
||
`student_type` VARCHAR(32) COMMENT '学员类型',
|
||
`current_stage` VARCHAR(32) COMMENT '当前阶段',
|
||
`student_message` VARCHAR(512) COMMENT '学员消息',
|
||
`recommended_count` INT(11) DEFAULT 0 COMMENT '推荐条数',
|
||
`recall_sources` TEXT COMMENT '召回来源统计,JSON字符串',
|
||
`generation_mode` VARCHAR(32) DEFAULT 'KB_ONLY' COMMENT '生成模式: KB_ONLY-话术库 KB_LLM_MIX-混合 LLM_ONLY-纯AI',
|
||
`elapsed_ms` INT(11) COMMENT '推荐耗时(毫秒)',
|
||
`staff_action` VARCHAR(32) DEFAULT 'NOT_SEEN' COMMENT '坐席操作: SENT-发送 EDITED-编辑后发送 IGNORED-忽略 NOT_SEEN-未查看',
|
||
`feedback` VARCHAR(32) DEFAULT 'NONE' COMMENT '反馈: POSITIVE-正面 NEGATIVE-负面 NONE-无',
|
||
`feedback_reason` VARCHAR(128) COMMENT '反馈原因',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`action_time` TIMESTAMP NULL COMMENT '操作时间',
|
||
UNIQUE KEY `uk_rec_id` (`recommendation_id`) COMMENT '推荐ID唯一',
|
||
INDEX `idx_session` (`session_id`, `created_at`) COMMENT '会话+时间',
|
||
INDEX `idx_staff` (`staff_id`, `created_at`) COMMENT '坐席+时间',
|
||
INDEX `idx_intent` (`primary_intent`, `created_at`) COMMENT '意图+时间'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐记录表';
|
||
```
|
||
|
||
#### 2.2.11 知识库文档表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 知识库文档表 (knowledge_documents)
|
||
-- 存储知识库文档和FAQ
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `knowledge_documents` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`doc_id` VARCHAR(64) NOT NULL COMMENT '文档唯一ID',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`title` VARCHAR(256) NOT NULL COMMENT '文档标题',
|
||
`content` LONGTEXT COMMENT '文档内容',
|
||
`content_text` LONGTEXT COMMENT '纯文本内容(用于全文检索)',
|
||
`doc_type` VARCHAR(32) DEFAULT 'KNOWLEDGE' COMMENT '文档类型: KNOWLEDGE-知识 UTTERANCE-话术 FAQ-常见问题',
|
||
`category` VARCHAR(64) COMMENT '分类',
|
||
`tags` TEXT COMMENT '标签,JSON数组字符串',
|
||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 INACTIVE-停用',
|
||
`has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量',
|
||
`vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间',
|
||
`created_by` VARCHAR(64) COMMENT '创建人',
|
||
`updated_by` VARCHAR(64) COMMENT '更新人',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_doc_id` (`doc_id`) COMMENT '文档ID唯一',
|
||
INDEX `idx_corp_type` (`corp_id`, `doc_type`, `status`) COMMENT '企业+类型+状态',
|
||
FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档表';
|
||
```
|
||
|
||
#### 2.2.12 员工表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 员工表 (staffs)
|
||
-- 企业坐席员工信息
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `staffs` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`staff_id` VARCHAR(128) NOT NULL COMMENT '企微用户ID',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`name` VARCHAR(64) COMMENT '姓名',
|
||
`avatar` VARCHAR(512) COMMENT '头像URL',
|
||
`department` VARCHAR(128) COMMENT '部门',
|
||
`role` VARCHAR(32) DEFAULT 'ADVISOR' COMMENT '角色: ADMIN-管理员 SUPERVISOR-主管 ADVISOR-顾问',
|
||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-在职 INACTIVE-离职',
|
||
`push_settings` TEXT COMMENT '推送设置,JSON字符串',
|
||
`last_login_time` TIMESTAMP NULL COMMENT '最后登录时间',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_staff_corp` (`staff_id`, `corp_id`) COMMENT '员工+企业唯一',
|
||
INDEX `idx_corp_role` (`corp_id`, `role`) COMMENT '企业+角色'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';
|
||
```
|
||
|
||
#### 2.2.13 客户表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 客户表 (customers)
|
||
-- 外部联系人(学员)信息
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `customers` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`customer_id` VARCHAR(128) NOT NULL COMMENT '外部联系人userid',
|
||
`corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
|
||
`name` VARCHAR(64) COMMENT '姓名/昵称',
|
||
`avatar` VARCHAR(512) COMMENT '头像URL',
|
||
`type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业',
|
||
`gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女',
|
||
`phone` VARCHAR(32) COMMENT '手机号',
|
||
`unionid` VARCHAR(128) COMMENT '微信unionid',
|
||
`add_time` TIMESTAMP NULL COMMENT '添加时间',
|
||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '客户+企业唯一',
|
||
INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';
|
||
```
|
||
|
||
#### 2.2.14 管理员表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 管理员表 (admins)
|
||
-- 系统管理员
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `admins` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
|
||
`password` VARCHAR(256) NOT NULL COMMENT '密码(BCrypt加密)',
|
||
`name` VARCHAR(64) COMMENT '姓名',
|
||
`email` VARCHAR(128) COMMENT '邮箱',
|
||
`phone` VARCHAR(32) COMMENT '手机号',
|
||
`role` VARCHAR(32) DEFAULT 'ADMIN' COMMENT '角色: SUPER_ADMIN-超级管理员 ADMIN-管理员 OPERATOR-运营',
|
||
`status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 DISABLED-停用',
|
||
`last_login_time` TIMESTAMP NULL COMMENT '最后登录时间',
|
||
`last_login_ip` VARCHAR(64) COMMENT '最后登录IP',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
UNIQUE KEY `uk_username` (`username`) COMMENT '用户名唯一'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
|
||
```
|
||
|
||
#### 2.2.15 操作日志表
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 操作日志表 (operation_logs)
|
||
-- 系统操作审计日志
|
||
-- ============================================================
|
||
CREATE TABLE IF NOT EXISTS `operation_logs` (
|
||
`id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
|
||
`operator_id` VARCHAR(64) NOT NULL COMMENT '操作人ID',
|
||
`operator_name` VARCHAR(64) COMMENT '操作人姓名',
|
||
`operator_type` VARCHAR(32) DEFAULT 'STAFF' COMMENT '操作人类型: STAFF-员工 ADMIN-管理员',
|
||
`module` VARCHAR(64) COMMENT '操作模块',
|
||
`action` VARCHAR(64) COMMENT '操作动作',
|
||
`description` TEXT COMMENT '操作描述',
|
||
`request_params` TEXT COMMENT '请求参数',
|
||
`response_result` TEXT COMMENT '响应结果',
|
||
`ip` VARCHAR(64) COMMENT '操作IP',
|
||
`user_agent` VARCHAR(512) COMMENT 'User-Agent',
|
||
`status` TINYINT DEFAULT 1 COMMENT '状态: 1成功 0失败',
|
||
`elapsed_ms` INT(11) COMMENT '耗时(毫秒)',
|
||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
INDEX `idx_operator` (`operator_id`, `created_at`) COMMENT '操作人+时间',
|
||
INDEX `idx_module` (`module`, `action`, `created_at`) COMMENT '模块+动作+时间',
|
||
INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||
```
|
||
|
||
### 2.3 初始化数据SQL
|
||
|
||
#### 2.3.1 40个意图分类初始化
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 初始化意图分类(40个场景)
|
||
-- 分类: 课程相关(8) + 价格相关(5) + 就业相关(7) + 师资相关(5) + 基础条件(4) + 校区相关(4) + 机构资质(3) + 其他(4)
|
||
-- ============================================================
|
||
|
||
-- 课程相关 (8个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-COURSE-01', '课程内容咨询', '课程相关', '询问具体学什么内容', 'P1', '["学什么","课程","内容","教什么","学什么软件"]', '["你们的课程具体学什么内容?","课程都教哪些软件?","能说一下课程体系吗?"]', 1),
|
||
('INT-COURSE-02', '课程选择建议', '课程相关', '不知道该选哪个方向', 'P1', '["选哪个","推荐","适合我","怎么选","方向"]', '["我应该学3D建模还是原画?","哪个方向好就业?","您能帮我推荐一下吗?"]', 1),
|
||
('INT-COURSE-03', '课程大纲索要', '课程相关', '想看详细课程安排', 'P1', '["大纲","课程表","安排","详细","阶段"]', '["能发一下课程大纲吗?","具体是怎么安排的?","每个阶段学什么?"]', 1),
|
||
('INT-COURSE-04', '试听预约咨询', '课程相关', '想试听或体验课程', 'P1', '["试听","体验","免费","公开课","试学"]', '["有免费试听吗?","怎么预约试听课?","可以体验一下吗?"]', 1),
|
||
('INT-COURSE-05', '课程更新迭代', '课程相关', '担心课程内容过时', 'P2', '["更新","过时","新版","跟上","行业"]', '["课程内容会更新吗?","跟得上行业吗?","用的是最新技术吗?"]', 1),
|
||
('INT-COURSE-06', '线上vs线下选择', '课程相关', '不知道选哪种学习方式', 'P1', '["线上","线下","面授","直播","网课","区别"]', '["线上和线下有什么区别?","线上课程效果好吗?","可以线上学习吗?"]', 1),
|
||
('INT-COURSE-07', '课程难度询问', '课程相关', '担心零基础能否学会', 'P1', '["零基础","能学会吗","难吗","没有基础","小白","能不能学会"]', '["零基础能学会吗?","我没有美术基础可以学吗?","课程难度大不大?"]', 1),
|
||
('INT-COURSE-08', '学习周期询问', '课程相关', '想知道学多久', 'P2', '["多久","多长时间","几个月","学多久","周期"]', '["课程要学多久?","多长时间能学会?","几个月可以学完?"]', 1);
|
||
|
||
-- 价格相关 (5个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-PRICE-01', '学费价格询问', '价格相关', '直接问学费多少', 'P1', '["多少钱","学费","价格","费用","收费","怎么收费"]', '["学费多少钱?","课程怎么收费?","一共多少钱?"]', 1),
|
||
('INT-PRICE-02', '优惠活动询问', '价格相关', '想知道有没有优惠', 'P1', '["优惠","活动","打折","减免","便宜","特价"]', '["现在有优惠活动吗?","能便宜点吗?","有没有折扣?"]', 1),
|
||
('INT-PRICE-03', '分期付款咨询', '价格相关', '关心付款方式', 'P2', '["分期","贷款","月付","首付","月供","花呗"]', '["可以分期付款吗?","有贷款方案吗?","月供多少?"]', 1),
|
||
('INT-PRICE-04', '退费政策询问', '价格相关', '关心退费保障', 'P2', '["退费","退款","退钱","学不好退","保障"]', '["学不好可以退费吗?","退费流程是怎样的?","有保障吗?"]', 1),
|
||
('INT-PRICE-05', '性价比比较', '价格相关', '和别家比较', 'P2', '["对比","比较","别家","其他机构","性价比","划算"]', '["你们的课程和XX比怎么样?","为什么价格比别人高?","性价比高吗?"]', 1);
|
||
|
||
-- 就业相关 (7个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-JOB-01', '就业方向咨询', '就业相关', '学完能做什么', 'P1', '["就业","工作","岗位","方向","做什么"]', '["学完能去哪些公司工作?","就业方向有哪些?","能做什么岗位?"]', 1),
|
||
('INT-JOB-02', '薪资水平询问', '就业相关', '关心工资待遇', 'P1', '["工资","薪资","月薪","收入","待遇","多少钱一个月"]', '["毕业后工资能拿多少?","行业薪资水平怎么样?","能挣多少?"]', 1),
|
||
('INT-JOB-03', '就业率询问', '就业相关', '关心能否找到工作', 'P1', '["就业率","找到工作","保证","推荐","找工作"]', '["就业率高吗?","能保证就业吗?","好找工作吗?"]', 1),
|
||
('INT-JOB-04', '就业服务了解', '就业相关', '了解就业推荐服务', 'P2', '["就业服务","推荐","怎么找工作","帮找工作"]', '["你们怎么帮学员找工作?","有就业推荐吗?","就业服务包含什么?"]', 1),
|
||
('INT-JOB-05', '合作企业询问', '就业相关', '想知道合作企业', 'P2', '["合作企业","大厂","腾讯","米哈游","网易","公司"]', '["合作企业有哪些?","有米哈游/腾讯这样的公司吗?","就业单位有哪些?"]', 1),
|
||
('INT-JOB-06', '作品集指导', '就业相关', '关心作品质量', 'P2', '["作品集","作品","项目","Demo","案例"]', '["学完能做出作品集吗?","作品质量怎么样?","能参与项目吗?"]', 1),
|
||
('INT-JOB-07', '实习机会咨询', '就业相关', '想参与真实项目', 'P3', '["实习","真实项目","实训","参与项目","实战"]', '["有实习机会吗?","可以参与真实项目吗?","有企业实训吗?"]', 1);
|
||
|
||
-- 师资相关 (5个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-TEACH-01', '师资背景询问', '师资相关', '想知道老师是谁', 'P2', '["老师","师资","讲师","谁教的","教学经验"]', '["老师是什么背景?","老师有项目经验吗?","谁来讲课?"]', 1),
|
||
('INT-TEACH-02', '教学模式了解', '师资相关', '想知道怎么上课', 'P2', '["怎么上课","教学方式","班型","一对一","多少人"]', '["是怎么上课的?","一个班多少人?","教学模式是怎样的?"]', 1),
|
||
('INT-TEACH-03', '课后答疑服务', '师资相关', '关心课后辅导', 'P2', '["答疑","辅导","课后","问问题","指导"]', '["课后有问题可以问老师吗?","答疑及时吗?","有课后辅导吗?"]', 1),
|
||
('INT-TEACH-04', '师生比例询问', '师资相关', '关心教学质量', 'P3', '["一个老师","带多少学生","比例","师生"]', '["一个老师带多少学生?","能照顾到每个人吗?","班级多大?"]', 1),
|
||
('INT-TEACH-05', '学习效果保障', '师资相关', '担心学不会怎么办', 'P2', '["学不会","怎么办","重修","保障","跟不上"]', '["学不会怎么办?","怎么保证学习效果?","跟不上怎么办?"]', 1);
|
||
|
||
-- 基础条件 (4个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-BASIC-01', '学历要求咨询', '基础条件', '学历是否够', 'P2', '["学历","要求","高中","大专","本科"]', '["需要什么学历?","高中生可以学吗?","学历要求高吗?"]', 1),
|
||
('INT-BASIC-02', '年龄限制询问', '基础条件', '年龄是否合适', 'P2', '["年龄","多大","几岁","限制","30岁"]', '["有没有年龄限制?","30岁了还能学吗?","年龄大能学会吗?"]', 1),
|
||
('INT-BASIC-03', '零基础入学', '基础条件', '零基础可行性', 'P1', '["零基础","没有基础","小白","完全不会","从来没"]', '["我没有美术基础可以学吗?","零基础能学会吗?","完全没接触过可以学吗?"]', 1),
|
||
('INT-BASIC-04', '证书颁发询问', '基础条件', '学完发什么证书', 'P3', '["证书","毕业证","结业证","证明","认证"]', '["学完发证书吗?","证书有用吗?","有毕业证吗?"]', 1);
|
||
|
||
-- 校区相关 (4个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-LOC-01', '校区地址询问', '校区相关', '想知道校区在哪', 'P2', '["校区","地址","在哪","位置","交通"]', '["校区在哪里?","附近有地铁吗?","具体地址是什么?"]', 1),
|
||
('INT-LOC-02', '校区参观预约', '校区相关', '想去参观', 'P2', '["参观","看看","实地考察","走访","预约"]', '["可以参观校区吗?","怎么预约参观?","能去看看吗?"]', 1),
|
||
('INT-LOC-03', '教学设备了解', '校区相关', '关心硬件条件', 'P3', '["设备","电脑","配置","硬件","机房"]', '["需要自备电脑吗?","校区提供设备吗?","电脑配置怎么样?"]', 1),
|
||
('INT-LOC-04', '住宿安排咨询', '校区相关', '外地学员住宿', 'P3', '["住宿","宿舍","外地","住哪","租房"]', '["外地学员有住宿吗?","住宿怎么安排?","有宿舍吗?"]', 1);
|
||
|
||
-- 机构资质 (3个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-QUAL-01', '机构正规性', '机构资质', '是否正规机构', 'P2', '["正规","资质","办学","合法","备案"]', '["你们是正规机构吗?","有办学资质吗?","是国家认可的吗?"]', 1),
|
||
('INT-QUAL-02', '口碑评价询问', '机构资质', '想看学员评价', 'P3', '["评价","口碑","反馈","怎么样","好吗"]', '["学员评价怎么样?","有老学员反馈吗?","口碑如何?"]', 1),
|
||
('INT-QUAL-03', '媒体报道了解', '机构资质', '想看权威背书', 'P3', '["报道","媒体","电视","新闻","央视"]', '["有上过电视报道吗?","媒体报道过吗?","央视那个是真的吗?"]', 1);
|
||
|
||
-- 其他 (4个)
|
||
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
|
||
('INT-OTHER-01', '大师课咨询', '其他', '进阶课程', 'P3', '["大师课","研修班","进阶","艺术家","高级"]', '["大师课和普通课什么区别?","研修班怎么报名?","大师课学费多少?"]', 1),
|
||
('INT-OTHER-02', '企业培训定制', '其他', 'B端需求', 'P3', '["企业培训","定制","公司","团队","B端"]', '["有企业定制培训吗?","公司团队能报名吗?","企业内训怎么合作?"]', 1),
|
||
('INT-OTHER-03', '比赛活动咨询', '其他', 'GGAC等赛事', 'P3', '["比赛","GGAC","大赛","活动","赛事"]', '["GGAC比赛怎么参加?","有比赛推荐吗?","学员能参加大赛吗?"]', 1),
|
||
('INT-OTHER-04', '转班转学咨询', '其他', '变更需求', 'P3', '["转班","换方向","转学","调整","换校区"]', '["可以换课程方向吗?","可以换校区吗?","转班怎么办理?"]', 1);
|
||
```
|
||
|
||
#### 2.3.2 8个话术分类初始化
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 初始化话术分类(8个销售阶段)
|
||
-- ============================================================
|
||
INSERT INTO `utterance_categories` (`code`, `name`, `parent_code`, `sort_order`, `color`, `description`) VALUES
|
||
('STAGE-01', '开场白', 'ROOT', 1, '#52c41a', '首次接待学员/家长,建立信任,收集基础信息'),
|
||
('STAGE-02', '需求探询', 'ROOT', 2, '#1890ff', '了解学员背景和目标,精准画像,挖掘需求'),
|
||
('STAGE-03', '课程推荐', 'ROOT', 3, '#fa8c16', '匹配适合的课程方向,精准推荐1-2个方向'),
|
||
('STAGE-04', '价值塑造', 'ROOT', 4, '#722ed1', '强调优势和差异化,建立价值认同'),
|
||
('STAGE-05', '报价沟通', 'ROOT', 5, '#eb2f96', '价格说明和方案,化解价格顾虑'),
|
||
('STAGE-06', '异议处理', 'ROOT', 6, '#f5222d', '应对各类顾虑,消除障碍,建立信心'),
|
||
('STAGE-07', '促成报名', 'ROOT', 7, '#faad14', '推动转化成交,促成立即行动'),
|
||
('STAGE-08', '跟进维护', 'ROOT', 8, '#13c2c2', '未成交学员跟进,保持联系,等待时机');
|
||
```
|
||
|
||
#### 2.3.3 20条核心话术初始化
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 初始化核心话术(20条,覆盖所有关键场景)
|
||
-- 编号规则: STAGE阶段-课程方向-学员类型-序号
|
||
-- ============================================================
|
||
|
||
-- ===== 开场白 (2条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S01-001', 'default', '首次接待通用开场白', '您好!欢迎咨询第九联盟🎨 我是您的课程顾问老师。\n\n我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。\n\n请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍~', '您好!欢迎咨询第九联盟 我是您的课程顾问老师。我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍', 'ACTIVE', 1, '["STAGE-01"]', '["INT-COURSE-01","INT-COURSE-02"]', '["通用"]', 128, 0.8500, 'MANUAL'),
|
||
('UTT-S01-002', 'default', '老学员/二次咨询开场', '您好!欢迎再次咨询第九联盟🎨\n\n看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的【课程方向】还感兴趣吗?\n\n我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', '您好!欢迎再次咨询第九联盟 看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的课程方向还感兴趣吗?我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', 'ACTIVE', 2, '["STAGE-01"]', '["INT-COURSE-01"]', '["老学员"]', 45, 0.7800, 'MANUAL');
|
||
|
||
-- ===== 需求探询 (3条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S02-001', 'default', '转行人员背景了解', '理解您想转行的想法!CG行业确实是一个非常好的选择。\n\n为了给您更精准的建议,想了解一下:\n1️⃣ 您目前从事什么行业?工作了多久?\n2️⃣ 有没有接触过美术或设计相关的软件?\n3️⃣ 是计划脱产学习还是周末学习?\n4️⃣ 有没有特别感兴趣的方向?\n\n我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢~', '理解您想转行的想法!CG行业确实是一个非常好的选择。为了给您更精准的建议,想了解一下:1.您目前从事什么行业?工作了多久?2.有没有接触过美术或设计相关的软件?3.是计划脱产学习还是周末学习?4.有没有特别感兴趣的方向?我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-COURSE-06"]', '["转行人员"]', 96, 0.7800, 'MANUAL'),
|
||
('UTT-S02-002', 'default', '在校生需求了解', '同学您好!很高兴为您介绍我们的课程~\n\n想先了解一下您的情况:\n1️⃣ 您现在是大几?什么专业呀?\n2️⃣ 毕业后是想直接就业还是考研?\n3️⃣ 之前接触过CG相关的软件吗?\n4️⃣ 有没有特别关注的发展方向?\n\n根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦~', '同学您好!很高兴为您介绍我们的课程。想先了解一下您的情况:1.您现在是大几?什么专业呀?2.毕业后是想直接就业还是考研?3.之前接触过CG相关的软件吗?4.有没有特别关注的发展方向?根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01"]', '["在校大学生"]', 72, 0.8200, 'MANUAL'),
|
||
('UTT-S02-003', 'default', '家长代询需求了解', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。\n\n为了更好地为您介绍,想了解一下:\n1️⃣ 孩子现在多大?是高中在读还是即将毕业?\n2️⃣ 之前有没有美术或绘画的基础?\n3️⃣ 孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)\n4️⃣ 是在本地学习还是可以到其他城市?\n\n我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障。', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。为了更好地为您介绍,想了解一下:1.孩子现在多大?是高中在读还是即将毕业?2.之前有没有美术或绘画的基础?3.孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)4.是在本地学习还是可以到其他城市?我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01","INT-BASIC-02"]', '["家长代询"]', 58, 0.7500, 'MANUAL');
|
||
|
||
-- ===== 课程推荐 (3条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `course_type_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S03-001', 'default', '推荐3D场景UE地编就业班(零基础)', '根据您的情况,我强烈推荐我们的【3D场景UE地编就业班】!\n\n💡 为什么适合您:\n✅ 零基础入学:从软件操作开始教,无需任何基础\n✅ 六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被\n✅ 所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等\n✅ 5个月培训+2个月企业实训,毕业就有项目经验\n✅ 就业方向:3D场景模型师、UE地编师、场景美术师\n\n📊 就业数据:往期学员平均薪资12-18K,就业推荐率95%+\n\n您看需要我发您详细的课程大纲和学员作品吗?', '根据您的情况,我强烈推荐我们的3D场景UE地编就业班!为什么适合您:零基础入学:从软件操作开始教,无需任何基础。六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被。所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等。5个月培训+2个月企业实训,毕业就有项目经验。就业方向:3D场景模型师、UE地编师、场景美术师。就业数据:往期学员平均薪资12-18K,就业推荐率95%+。您看需要我发您详细的课程大纲和学员作品吗?', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["转行人员","零基础"]', '["3D场景UE地编"]', 156, 0.7200, 'MANUAL'),
|
||
('UTT-S03-002', 'default', '推荐次世代角色模型就业班', '根据您有美术基础的情况,【次世代角色模型就业班】非常适合您!\n\n💡 课程亮点:\n✅ 所学软件:MD、ZB、MAYA、XGEN、PT等业界主流\n✅ 从角色道具建模→各类套装→角色模型制作→项目实训\n✅ 毕业至少完成3套角色模型作品\n✅ 就业方向:3D角色模型师、次世代角色设计师\n\n📊 行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+\n\n您的美术基础会让您在角色方向有很大优势,学习起来会更快上手!', '根据您有美术基础的情况,次世代角色模型就业班非常适合您!课程亮点:所学软件:MD、ZB、MAYA、XGEN、PT等业界主流。从角色道具建模→各类套装→角色模型制作→项目实训。毕业至少完成3套角色模型作品。就业方向:3D角色模型师、次世代角色设计师。行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+。您的美术基础会让您在角色方向有很大优势,学习起来会更快上手', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础"]', '["次世代角色模型"]', 98, 0.7600, 'MANUAL'),
|
||
('UTT-S03-003', 'default', '推荐2D原画就业班', '了解到您有绘画基础,【2D原画就业班】是您的最佳选择!\n\n💡 课程特色:\n✅ 角色/场景双方向可选\n✅ 从基础到进阶系统教学,6-8个月完成全流程\n✅ 真实项目实训,毕业就有作品集\n✅ 就业方向:角色原画师、场景原画师、概念设计师、游戏主美\n\n📊 行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月\n\n有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂!', '了解到您有绘画基础,2D原画就业班是您的最佳选择!课程特色:角色/场景双方向可选。从基础到进阶系统教学,6-8个月完成全流程。真实项目实训,毕业就有作品集。就业方向:角色原画师、场景原画师、概念设计师、游戏主美。行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月。有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础","在校大学生"]', '["2D原画"]', 82, 0.8000, 'MANUAL');
|
||
|
||
-- ===== 价值塑造 (3条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S04-001', 'default', '强调师资团队实力', '您关心师资说明您对学习质量很重视,这点非常关键!\n\n我们的师资团队可以说是行业顶配了:\n🏆 老师均来自腾讯、网易、EA等一线游戏公司\n🏆 平均8年以上项目经验+3年以上教学经验\n🏆 参与过《黑神话悟空》《王者荣耀》《原神》等知名项目\n\n🎓 特别值得一提的是我们的【大师研修班】:\n• 联合16位国内顶级CG艺术家授课\n• 包含《黑神话悟空》3D美术总监王琛老师\n• NVIDIA首席数字艺术家光叔·许喆隆老师\n• 开天工作室创始人罗其胜老师\n\n不仅是学技术,更是和行业内最顶尖的大佬面对面学习!', '您关心师资说明您对学习质量很重视,这点非常关键!我们的师资团队可以说是行业顶配了:老师均来自腾讯、网易、EA等一线游戏公司。平均8年以上项目经验+3年以上教学经验。参与过《黑神话悟空》《王者荣耀》《原神》等知名项目。特别值得一提的是我们的大师研修班:联合16位国内顶级CG艺术家授课。包含《黑神话悟空》3D美术总监王琛老师。NVIDIA首席数字艺术家光叔·许喆隆老师。开天工作室创始人罗其胜老师。不仅是学技术,更是和行业内最顶尖的大佬面对面学习', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-01","INT-TEACH-02"]', '["师资看重型"]', 112, 0.6800, 'MANUAL'),
|
||
('UTT-S04-002', 'default', '利用央视报道建立品牌信任', '关于我们机构的资质和实力,有一个特别值得骄傲的——\n\n📺 我们第九联盟被【CCTV2央视财经频道】《正点财经》栏目做了专题报道!\n\n能获得央视的认可,是因为:\n✅ 母公司点晴科技10年游戏美术制作经验\n✅ 与腾讯、网易、米哈游等大厂的深度合作\n✅ 自办GGAC全球游戏动漫美术概念大赛的行业影响力\n✅ 8000+学员的成功就业案例\n✅ 2023年度影响力教育品牌\n\n央视都认可的教育机构,您可以放心选择!', '关于我们机构的资质和实力,有一个特别值得骄傲的。我们第九联盟被CCTV2央视财经频道《正点财经》栏目做了专题报道!能获得央视的认可,是因为:母公司点晴科技10年游戏美术制作经验。与腾讯、网易、米哈游等大厂的深度合作。自办GGAC全球游戏动漫美术概念大赛的行业影响力。8000+学员的成功就业案例。2023年度影响力教育品牌。央视都认可的教育机构,您可以放心选择', 'ACTIVE', 1, '["STAGE-04"]', '["INT-QUAL-01","INT-QUAL-03"]', '["品牌信任型","家长代询"]', 89, 0.8000, 'MANUAL'),
|
||
('UTT-S04-003', 'default', '强调实战教学模式', '我们的教学模式是业内独一无二的"5+2"模式:\n\n📚 5个月技能培训 + 2个月企业实训\n\n这意味着什么?\n✅ 毕业时您已经有真实项目经验,不是零基础新人\n✅ 实训期间进入合作企业,表现优秀可直接留用\n✅ 简历上有实际项目,面试更有竞争力\n✅ 500+合作企业就业推荐网络\n\n相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!\n\n因为企业要的是能直接上手干活的人,而我们培养的就是这样的人。', '我们的教学模式是业内独一无二的5+2模式:5个月技能培训+2个月企业实训。这意味着什么?毕业时您已经有真实项目经验,不是零基础新人。实训期间进入合作企业,表现优秀可直接留用。简历上有实际项目,面试更有竞争力。500+合作企业就业推荐网络。相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!因为企业要的是能直接上手干活的人,而我们培养的就是这样的人', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-02","INT-JOB-01"]', '["就业导向型"]', 76, 0.7400, 'MANUAL');
|
||
|
||
-- ===== 报价沟通 (2条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S05-001', 'default', '线下班学费说明+价值锚定', '我们的线下全日制面授班学费是💰29,800元。\n\n我理解价格是您需要考虑的重要因素,让我帮您算一笔账:\n\n📊 投入产出分析:\n• 学习周期:7个月(5个月培训+2个月实训)\n• 毕业后平均薪资:12-18K/月\n• 按最低12K算,3个月就回本了\n• 相比大学四年十几万学费,这个投入产出比是非常高的\n\n💡 而且您得到的是:\n✅ 一线大厂老师的面对面教学\n✅ 真实企业项目的实战经验\n✅ 500+合作企业的就业推荐\n✅ 终身职业规划服务\n\n这不仅仅是一门课程,更是进入游戏行业的职业通道。', '我们的线下全日制面授班学费是29,800元。我理解价格是您需要考虑的重要因素,让我帮您算一笔账:投入产出分析:学习周期:7个月(5个月培训+2个月实训)。毕业后平均薪资:12-18K/月。按最低12K算,3个月就回本了。相比大学四年十几万学费,这个投入产出比是非常高的。而且您得到的是:一线大厂老师的面对面教学。真实企业项目的实战经验。500+合作企业的就业推荐。终身职业规划服务。这不仅仅是一门课程,更是进入游戏行业的职业通道', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 134, 0.6500, 'MANUAL'),
|
||
('UTT-S05-002', 'default', '分期方案说明', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:\n\n💳 付款方案:\n✅ 12期分期付款,月供约2,500元\n✅ 支持花呗/信用卡分期\n✅ 早鸟优惠:提前报名减免2,000元\n✅ 团报优惠:2人同行各减1,000元\n\n🎁 限时福利:\n现在报名还赠送价值3,000元的预科课程!\n\n月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。\n\n您看哪种付款方式比较适合您?', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:12期分期付款,月供约2,500元。支持花呗/信用卡分期。早鸟优惠:提前报名减免2,000元。团报优惠:2人同行各减1,000元。限时福利:现在报名还赠送价值3,000元的预科课程!月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。您看哪种付款方式比较适合您?', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-03","INT-PRICE-02"]', '["价格敏感型"]', 108, 0.7000, 'MANUAL');
|
||
|
||
-- ===== 异议处理 (4条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S06-001', 'default', '应对"价格太贵"异议', '完全理解您的顾虑,投资学习确实需要慎重考虑。\n\n其实和您算一下,我们的性价比是非常高的:\n\n📈 横向对比:\n• 同类机构学费普遍在3-5万\n• 我们还包含2个月企业实训(其他机构一般没有)\n• 师资都是一线大厂出身\n\n💳 付款方案:\n• 支持12期分期,月供只要2,500多\n• 现在报名可享早鸟优惠,减免2,000元\n• 可以申请试听,满意后再决定\n\n🎁 限时福利:\n本周报名额外赠送价值3,000元的预科课程!\n\n您可以先来试听一下,亲身感受教学质量,再决定也不迟~', '完全理解您的顾虑,投资学习确实需要慎重考虑。其实和您算一下,我们的性价比是非常高的:横向对比:同类机构学费普遍在3-5万。我们还包含2个月企业实训(其他机构一般没有)。师资都是一线大厂出身。付款方案:支持12期分期,月供只要2,500多。现在报名可享早鸟优惠,减免2,000元。可以申请试听,满意后再决定。限时福利:本周报名额外赠送价值3,000元的预科课程!您可以先来试听一下,亲身感受教学质量,再决定也不迟', 'ACTIVE', 1, '["STAGE-06"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 142, 0.5800, 'MANUAL'),
|
||
('UTT-S06-002', 'default', '应对"零基础怕学不会"异议', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。\n\n🛡️ 我们的保障体系:\n\n1️⃣ 【预科课程】:正式开课前有免费预科,先打基础\n2️⃣ 【小班教学】:每班不超过25人,确保老师能照顾到每个人\n3️⃣ 【一对一辅导】:课后有问题随时问,老师一对一答疑\n4️⃣ 【阶段考核】:每个阶段都有测试,跟不上可以免费重修\n5️⃣ 【就业协议】:签订正式培训协议,保障您的权益\n\n👨🎓 真实案例:\n小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。\n\n只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。我们的保障体系:1.预科课程:正式开课前有免费预科,先打基础。2.小班教学:每班不超过25人,确保老师能照顾到每个人。3.一对一辅导:课后有问题随时问,老师一对一答疑。4.阶段考核:每个阶段都有测试,跟不上可以免费重修。5.就业协议:签订正式培训协议,保障您的权益。真实案例:小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-07","INT-TEACH-05"]', '["转行人员","零基础"]', 186, 0.7000, 'MANUAL'),
|
||
('UTT-S06-003', 'default', '应对"要和家人商量"异议', '完全理解!和家人商量是负责任的做法。\n\n我帮您整理一下关键信息,方便您和家人沟通:\n\n📋 第九联盟核心优势:\n✅ 母公司点晴科技10年游戏制作经验\n✅ CCTV2央视财经专题报道\n✅ 与腾讯、网易、米哈游等500+企业合作\n✅ 师资均来自一线大厂,8年+经验\n✅ 5个月培训+2个月企业实训\n✅ 签订正式培训协议,就业推荐\n\n📱 建议您可以:\n1. 先预约一节免费试听课,带家人一起来\n2. 我们的校区随时欢迎参观\n3. 有任何问题随时联系我\n\n对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款。', '完全理解!和家人商量是负责任的做法。我帮您整理一下关键信息,方便您和家人沟通:第九联盟核心优势:母公司点晴科技10年游戏制作经验。CCTV2央视财经专题报道。与腾讯、网易、米哈游等500+企业合作。师资均来自一线大厂,8年+经验。5个月培训+2个月企业实训。签订正式培训协议,就业推荐。建议您可以:先预约一节免费试听课,带家人一起来。我们的校区随时欢迎参观。有任何问题随时联系我。对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-06","INT-QUAL-01"]', '["通用"]', 95, 0.6500, 'MANUAL'),
|
||
('UTT-S06-004', 'default', '应对"考虑一下"异议', '没问题,考虑清楚是对的!\n\n为了帮您更好地做决定,我可以:\n1. 发您一些学员的作品集和就业案例\n2. 安排一节免费的线上/线下试听课\n3. 给您发详细的课程大纲\n\n⏰ 友情提示:\n我们本期的优惠活动截止到【日期】,名额也有限。\n\n另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会~\n\n我帮您预约一个名额吧?', '没问题,考虑清楚是对的!为了帮您更好地做决定,我可以:1.发您一些学员的作品集和就业案例。2.安排一节免费的线上/线下试听课。3.给您发详细的课程大纲。友情提示:我们本期的优惠活动截止到XX日期,名额也有限。另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会。我帮您预约一个名额吧?', 'ACTIVE', 2, '["STAGE-06"]', '["INT-COURSE-04"]', '["通用"]', 78, 0.6200, 'MANUAL');
|
||
|
||
-- ===== 促成报名 (2条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S07-001', 'default', '限时优惠促成报名', '和您聊了这么多,感觉您对我们的课程和前景都很认可!\n\n🎉 现在报名正是最好的时机:\n\n✅ 本月优惠:学费直减2,000元(仅剩最后3个名额)\n✅ 赠送价值3,000元的预科课程\n✅ 优先安排下期班级座位\n✅ 赠送全套学习资料包\n\n⏰ 优惠截止日期:本月31日\n\n建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!\n\n我这边帮您预留一个名额,您看可以吗?', '和您聊了这么多,感觉您对我们的课程和前景都很认可!现在报名正是最好的时机:本月优惠:学费直减2,000元(仅剩最后3个名额)。赠送价值3,000元的预科课程。优先安排下期班级座位。赠送全套学习资料包。优惠截止日期:本月31日。建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!我这边帮您预留一个名额,您看可以吗?', 'ACTIVE', 1, '["STAGE-07"]', '["INT-PRICE-02","INT-COURSE-04"]', '["高意向"]', 67, 0.4500, 'MANUAL'),
|
||
('UTT-S07-002', 'default', '试听后促成报名', '试听完感觉怎么样?是不是比想象中更容易上手?\n\n我们很多学员试听完最大的感受就是:"原来零基础也能做出这样的效果!"\n\n🎉 现在报名您还能享受:\n\n✅ 试听当日报名额外优惠1,000元\n✅ 赠送全套软件安装包和教学视频\n✅ 优先安排住宿(外地学员)\n✅ 加入学员群,提前认识同学\n\n您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续。', '试听完感觉怎么样?是不是比想象中更容易上手?我们很多学员试听完最大的感受就是:原来零基础也能做出这样的效果!现在报名您还能享受:试听当日报名额外优惠1,000元。赠送全套软件安装包和教学视频。优先安排住宿(外地学员)。加入学员群,提前认识同学。您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续', 'ACTIVE', 1, '["STAGE-07"]', '["INT-COURSE-04","INT-PRICE-03"]', '["高意向"]', 52, 0.5500, 'MANUAL');
|
||
|
||
-- ===== 跟进维护 (1条) =====
|
||
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
|
||
('UTT-S08-001', 'default', '3天后未报名跟进', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?\n\n🎁 给您带来一个好消息:\n本周我们有一个【免费的线上公开课】,主题是"零基础如何入门3D建模",由我们资深讲师罗老师主讲。\n\n不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!\n\n📅 时间:本周六(1月18日)下午2:00\n📍 形式:线上直播\n\n我帮您预约一个名额吧,您那天有时间吗?', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?给您带来一个好消息:本周我们有一个免费的线上公开课,主题是零基础如何入门3D建模,由我们资深讲师罗老师主讲。不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!时间:本周六下午2:00。形式:线上直播。我帮您预约一个名额吧,您那天有时间吗?', 'ACTIVE', 1, '["STAGE-08"]', '["INT-COURSE-04","INT-OTHER-03"]', '["中意向","低意向"]', 45, 0.3500, 'MANUAL');
|
||
```
|
||
|
||
|
||
|
||
#### 2.3.4 系统管理员初始化
|
||
|
||
```sql
|
||
-- ============================================================
|
||
-- 初始化系统管理员
|
||
-- 密码: admin123(BCrypt加密后)
|
||
-- ============================================================
|
||
INSERT INTO `admins` (`username`, `password`, `name`, `email`, `role`, `status`, `created_at`) VALUES
|
||
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iOE1Qh6G', '系统管理员', 'admin@9artedu.com', 'SUPER_ADMIN', 'ACTIVE', NOW());
|
||
-- 默认密码: admin123,请在首次登录后立即修改
|
||
```
|
||
|
||
### 2.4 MySQL 5.7 JSON处理说明
|
||
|
||
由于MySQL 5.7不支持JSON类型,所有需要存储结构化数据的字段使用TEXT类型,在Java应用层进行序列化和反序列化。
|
||
|
||
#### 2.4.1 Jackson序列化工具类
|
||
|
||
```java
|
||
package com.artedu.common.util;
|
||
|
||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||
import com.fasterxml.jackson.core.type.TypeReference;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
|
||
import java.util.Collections;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* JSON工具类 - 用于MySQL 5.7 TEXT字段的序列化/反序列化
|
||
* 替代MySQL 8.0的JSON类型
|
||
*/
|
||
@Slf4j
|
||
public class JsonUtils {
|
||
|
||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||
|
||
static {
|
||
// 禁用日期时间戳输出,使用ISO格式
|
||
OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||
}
|
||
|
||
/**
|
||
* 将对象序列化为JSON字符串
|
||
*/
|
||
public static String toJson(Object obj) {
|
||
if (obj == null) {
|
||
return null;
|
||
}
|
||
try {
|
||
return OBJECT_MAPPER.writeValueAsString(obj);
|
||
} catch (JsonProcessingException e) {
|
||
log.error("JSON序列化失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将JSON字符串反序列化为对象
|
||
*/
|
||
public static <T> T fromJson(String json, Class<T> clazz) {
|
||
if (json == null || json.isEmpty()) {
|
||
return null;
|
||
}
|
||
try {
|
||
return OBJECT_MAPPER.readValue(json, clazz);
|
||
} catch (JsonProcessingException e) {
|
||
log.error("JSON反序列化失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将JSON字符串反序列化为List
|
||
*/
|
||
public static <T> List<T> fromJsonList(String json, Class<T> clazz) {
|
||
if (json == null || json.isEmpty()) {
|
||
return Collections.emptyList();
|
||
}
|
||
try {
|
||
return OBJECT_MAPPER.readValue(json, OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz));
|
||
} catch (JsonProcessingException e) {
|
||
log.error("JSON反序列化为List失败: {}", e.getMessage(), e);
|
||
return Collections.emptyList();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将JSON字符串反序列化为Map
|
||
*/
|
||
public static Map<String, Object> fromJsonMap(String json) {
|
||
if (json == null || json.isEmpty()) {
|
||
return Collections.emptyMap();
|
||
}
|
||
try {
|
||
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
|
||
} catch (JsonProcessingException e) {
|
||
log.error("JSON反序列化为Map失败: {}", e.getMessage(), e);
|
||
return Collections.emptyMap();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 安全获取JSON字符串(防止null)
|
||
*/
|
||
public static String safeJson(Object obj) {
|
||
String json = toJson(obj);
|
||
return json != null ? json : "{}";
|
||
}
|
||
|
||
/**
|
||
* 安全解析JSON字符串(防止null)
|
||
*/
|
||
public static String safeParse(String json) {
|
||
return json != null && !json.isEmpty() ? json : "{}";
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.4.2 MyBatis TypeHandler(自动序列化/反序列化)
|
||
|
||
```java
|
||
package com.artedu.common.mybatis;
|
||
|
||
import com.artedu.common.util.JsonUtils;
|
||
import org.apache.ibatis.type.BaseTypeHandler;
|
||
import org.apache.ibatis.type.JdbcType;
|
||
import org.apache.ibatis.type.MappedJdbcTypes;
|
||
import org.apache.ibatis.type.MappedTypes;
|
||
|
||
import java.sql.CallableStatement;
|
||
import java.sql.PreparedStatement;
|
||
import java.sql.ResultSet;
|
||
import java.sql.SQLException;
|
||
|
||
/**
|
||
* MyBatis JSON类型处理器 - 用于MySQL 5.7 TEXT字段
|
||
* 自动将Java对象序列化为JSON字符串存入TEXT字段
|
||
* 读取时自动将JSON字符串反序列化为Java对象
|
||
*
|
||
* 使用方法:
|
||
* 1. 在实体类字段上添加 @TableField(typeHandler = JsonTypeHandler.class)
|
||
* 2. 或在application.yml中配置默认类型处理器
|
||
*/
|
||
@MappedJdbcTypes(JdbcType.VARCHAR)
|
||
@MappedTypes(Object.class)
|
||
public class JsonTypeHandler extends BaseTypeHandler<Object> {
|
||
|
||
private final Class<?> type;
|
||
|
||
public JsonTypeHandler(Class<?> type) {
|
||
if (type == null) {
|
||
throw new IllegalArgumentException("Type argument cannot be null");
|
||
}
|
||
this.type = type;
|
||
}
|
||
|
||
@Override
|
||
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException {
|
||
ps.setString(i, JsonUtils.toJson(parameter));
|
||
}
|
||
|
||
@Override
|
||
public Object getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||
String json = rs.getString(columnName);
|
||
return parseJson(json);
|
||
}
|
||
|
||
@Override
|
||
public Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||
String json = rs.getString(columnIndex);
|
||
return parseJson(json);
|
||
}
|
||
|
||
@Override
|
||
public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||
String json = cs.getString(columnIndex);
|
||
return parseJson(json);
|
||
}
|
||
|
||
private Object parseJson(String json) {
|
||
if (json == null || json.isEmpty()) {
|
||
return null;
|
||
}
|
||
return JsonUtils.fromJson(json, type);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 第三部分:后端服务详细实现
|
||
|
||
### 3.1 公共模块(common)
|
||
|
||
所有服务共享的公共代码,通过Maven依赖引入。
|
||
|
||
#### 3.1.1 统一响应结构 Result<T>
|
||
|
||
```java
|
||
package com.artedu.common.result;
|
||
|
||
import lombok.Data;
|
||
|
||
import java.io.Serializable;
|
||
|
||
/**
|
||
* 统一API响应结构
|
||
* 所有接口返回此结构,确保前端处理一致
|
||
*
|
||
* @param <T> 响应数据类型
|
||
*/
|
||
@Data
|
||
public class Result<T> implements Serializable {
|
||
|
||
private static final long serialVersionUID = 1L;
|
||
|
||
/**
|
||
* 状态码: 0-成功 其他-失败
|
||
*/
|
||
private Integer code;
|
||
|
||
/**
|
||
* 提示消息
|
||
*/
|
||
private String message;
|
||
|
||
/**
|
||
* 响应数据
|
||
*/
|
||
private T data;
|
||
|
||
/**
|
||
* 时间戳
|
||
*/
|
||
private Long timestamp;
|
||
|
||
public Result() {
|
||
this.timestamp = System.currentTimeMillis();
|
||
}
|
||
|
||
/**
|
||
* 成功响应
|
||
*/
|
||
public static <T> Result<T> success() {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(0);
|
||
result.setMessage("success");
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 成功响应(带数据)
|
||
*/
|
||
public static <T> Result<T> success(T data) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(0);
|
||
result.setMessage("success");
|
||
result.setData(data);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 成功响应(带数据和消息)
|
||
*/
|
||
public static <T> Result<T> success(T data, String message) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(0);
|
||
result.setMessage(message);
|
||
result.setData(data);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 失败响应
|
||
*/
|
||
public static <T> Result<T> fail(String message) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(-1);
|
||
result.setMessage(message);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 失败响应(带状态码)
|
||
*/
|
||
public static <T> Result<T> fail(int code, String message) {
|
||
Result<T> result = new Result<>();
|
||
result.setCode(code);
|
||
result.setMessage(message);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 是否成功
|
||
*/
|
||
public boolean isSuccess() {
|
||
return this.code != null && this.code == 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.2 分页响应 PageResult<T>
|
||
|
||
```java
|
||
package com.artedu.common.result;
|
||
|
||
import lombok.Data;
|
||
|
||
import java.io.Serializable;
|
||
import java.util.Collections;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 分页响应结构
|
||
*/
|
||
@Data
|
||
public class PageResult<T> implements Serializable {
|
||
|
||
private static final long serialVersionUID = 1L;
|
||
|
||
/**
|
||
* 当前页
|
||
*/
|
||
private Integer page;
|
||
|
||
/**
|
||
* 每页大小
|
||
*/
|
||
private Integer size;
|
||
|
||
/**
|
||
* 总记录数
|
||
*/
|
||
private Long total;
|
||
|
||
/**
|
||
* 总页数
|
||
*/
|
||
private Integer totalPages;
|
||
|
||
/**
|
||
* 数据列表
|
||
*/
|
||
private List<T> list;
|
||
|
||
public static <T> PageResult<T> of(Integer page, Integer size, Long total, List<T> list) {
|
||
PageResult<T> result = new PageResult<>();
|
||
result.setPage(page);
|
||
result.setSize(size);
|
||
result.setTotal(total);
|
||
result.setTotalPages((int) Math.ceil((double) total / size));
|
||
result.setList(list != null ? list : Collections.emptyList());
|
||
return result;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.3 全局异常处理
|
||
|
||
```java
|
||
package com.artedu.common.exception;
|
||
|
||
import com.artedu.common.result.Result;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.validation.BindException;
|
||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||
|
||
/**
|
||
* 全局异常处理器
|
||
* 统一处理控制器层抛出的异常,转换为标准响应格式
|
||
*/
|
||
@Slf4j
|
||
@RestControllerAdvice
|
||
public class GlobalExceptionHandler {
|
||
|
||
/**
|
||
* 处理业务异常
|
||
*/
|
||
@ExceptionHandler(BusinessException.class)
|
||
public Result<Void> handleBusinessException(BusinessException e) {
|
||
log.warn("业务异常: {}", e.getMessage());
|
||
return Result.fail(e.getCode(), e.getMessage());
|
||
}
|
||
|
||
/**
|
||
* 处理参数校验异常
|
||
*/
|
||
@ExceptionHandler(BindException.class)
|
||
public Result<Void> handleBindException(BindException e) {
|
||
String message = e.getBindingResult().getFieldErrors().stream()
|
||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||
.findFirst()
|
||
.orElse("参数校验失败");
|
||
log.warn("参数校验失败: {}", message);
|
||
return Result.fail(400, message);
|
||
}
|
||
|
||
/**
|
||
* 处理其他未知异常
|
||
*/
|
||
@ExceptionHandler(Exception.class)
|
||
public Result<Void> handleException(Exception e) {
|
||
log.error("系统异常: {}", e.getMessage(), e);
|
||
return Result.fail(500, "系统繁忙,请稍后重试");
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.4 业务异常类
|
||
|
||
```java
|
||
package com.artedu.common.exception;
|
||
|
||
import lombok.Getter;
|
||
|
||
/**
|
||
* 业务异常
|
||
* 用于抛出可预期的业务逻辑错误
|
||
*/
|
||
@Getter
|
||
public class BusinessException extends RuntimeException {
|
||
|
||
private static final long serialVersionUID = 1L;
|
||
|
||
/**
|
||
* 错误码
|
||
*/
|
||
private final int code;
|
||
|
||
public BusinessException(String message) {
|
||
super(message);
|
||
this.code = -1;
|
||
}
|
||
|
||
public BusinessException(int code, String message) {
|
||
super(message);
|
||
this.code = code;
|
||
}
|
||
|
||
public BusinessException(String message, Throwable cause) {
|
||
super(message, cause);
|
||
this.code = -1;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.5 MyBatis-Plus 分页配置
|
||
|
||
```java
|
||
package com.artedu.common.config;
|
||
|
||
import com.baomidou.mybatisplus.annotation.DbType;
|
||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
|
||
/**
|
||
* MyBatis-Plus配置
|
||
* 启用分页插件(MySQL 5.7方言)
|
||
*/
|
||
@Configuration
|
||
public class MyBatisPlusConfig {
|
||
|
||
/**
|
||
* 分页插件配置
|
||
* DbType.MYSQL 指定MySQL方言,确保分页SQL正确生成
|
||
*/
|
||
@Bean
|
||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||
return interceptor;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.6 Redis配置
|
||
|
||
```java
|
||
package com.artedu.common.config;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||
|
||
/**
|
||
* Redis配置
|
||
* 配置Key和Value的序列化方式
|
||
*/
|
||
@Configuration
|
||
public class RedisConfig {
|
||
|
||
@Bean
|
||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||
template.setConnectionFactory(connectionFactory);
|
||
|
||
// Key使用String序列化
|
||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||
template.setKeySerializer(stringSerializer);
|
||
template.setHashKeySerializer(stringSerializer);
|
||
|
||
// Value使用JSON序列化(带类型信息)
|
||
ObjectMapper mapper = new ObjectMapper();
|
||
mapper.activateDefaultTyping(
|
||
LaissezFaireSubTypeValidator.instance,
|
||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||
JsonTypeInfo.As.PROPERTY
|
||
);
|
||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper);
|
||
template.setValueSerializer(jsonSerializer);
|
||
template.setHashValueSerializer(jsonSerializer);
|
||
|
||
template.afterPropertiesSet();
|
||
return template;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.7 RabbitMQ配置
|
||
|
||
```java
|
||
package com.artedu.common.config;
|
||
|
||
import org.springframework.amqp.core.*;
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
|
||
/**
|
||
* RabbitMQ配置
|
||
* 定义消息队列、交换机和绑定关系
|
||
*/
|
||
@Configuration
|
||
public class RabbitConfig {
|
||
|
||
// ========== 交换机定义 ==========
|
||
|
||
/**
|
||
* 存档消息交换机 - Direct类型
|
||
*/
|
||
public static final String ARCHIVE_EXCHANGE = "archive.exchange";
|
||
|
||
/**
|
||
* 对话事件交换机 - Topic类型
|
||
*/
|
||
public static final String CONVERSATION_EXCHANGE = "conversation.exchange";
|
||
|
||
// ========== 队列定义 ==========
|
||
|
||
/**
|
||
* 新消息队列 - 存档服务→对话服务
|
||
*/
|
||
public static final String QUEUE_NEW_MESSAGE = "archive.new_message.queue";
|
||
|
||
/**
|
||
* 意图分析队列 - 对话服务→意图服务
|
||
*/
|
||
public static final String QUEUE_INTENT_ANALYZE = "conversation.intent_analyze.queue";
|
||
|
||
/**
|
||
* 话术推荐队列 - 意图服务→推荐服务
|
||
*/
|
||
public static final String QUEUE_RECOMMEND = "intent.recommend.queue";
|
||
|
||
// ========== 路由键定义 ==========
|
||
|
||
public static final String ROUTING_KEY_NEW_MESSAGE = "archive.message.new";
|
||
public static final String ROUTING_KEY_INTENT_ANALYZE = "conversation.intent.analyze";
|
||
public static final String ROUTING_KEY_RECOMMEND = "intent.recommend";
|
||
|
||
@Bean
|
||
public DirectExchange archiveExchange() {
|
||
return new DirectExchange(ARCHIVE_EXCHANGE, true, false);
|
||
}
|
||
|
||
@Bean
|
||
public TopicExchange conversationExchange() {
|
||
return new TopicExchange(CONVERSATION_EXCHANGE, true, false);
|
||
}
|
||
|
||
@Bean
|
||
public Queue newMessageQueue() {
|
||
return QueueBuilder.durable(QUEUE_NEW_MESSAGE)
|
||
.withArgument("x-dead-letter-exchange", "")
|
||
.withArgument("x-dead-letter-routing-key", QUEUE_NEW_MESSAGE + ".dlq")
|
||
.build();
|
||
}
|
||
|
||
@Bean
|
||
public Queue intentAnalyzeQueue() {
|
||
return QueueBuilder.durable(QUEUE_INTENT_ANALYZE)
|
||
.withArgument("x-dead-letter-exchange", "")
|
||
.withArgument("x-dead-letter-routing-key", QUEUE_INTENT_ANALYZE + ".dlq")
|
||
.build();
|
||
}
|
||
|
||
@Bean
|
||
public Queue recommendQueue() {
|
||
return QueueBuilder.durable(QUEUE_RECOMMEND)
|
||
.withArgument("x-dead-letter-exchange", "")
|
||
.withArgument("x-dead-letter-routing-key", QUEUE_RECOMMEND + ".dlq")
|
||
.build();
|
||
}
|
||
|
||
@Bean
|
||
public Binding newMessageBinding() {
|
||
return BindingBuilder.bind(newMessageQueue())
|
||
.to(archiveExchange())
|
||
.with(ROUTING_KEY_NEW_MESSAGE);
|
||
}
|
||
|
||
@Bean
|
||
public Binding intentAnalyzeBinding() {
|
||
return BindingBuilder.bind(intentAnalyzeQueue())
|
||
.to(conversationExchange())
|
||
.with(ROUTING_KEY_INTENT_ANALYZE);
|
||
}
|
||
|
||
@Bean
|
||
public Binding recommendBinding() {
|
||
return BindingBuilder.bind(recommendQueue())
|
||
.to(conversationExchange())
|
||
.with(ROUTING_KEY_RECOMMEND);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.8 跨域配置
|
||
|
||
```java
|
||
package com.artedu.common.config;
|
||
|
||
import org.springframework.context.annotation.Bean;
|
||
import org.springframework.context.annotation.Configuration;
|
||
import org.springframework.web.cors.CorsConfiguration;
|
||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||
import org.springframework.web.filter.CorsFilter;
|
||
|
||
/**
|
||
* 跨域配置
|
||
* 允许企微H5侧边栏跨域访问API
|
||
*/
|
||
@Configuration
|
||
public class CorsConfig {
|
||
|
||
@Bean
|
||
public CorsFilter corsFilter() {
|
||
CorsConfiguration config = new CorsConfiguration();
|
||
// 允许企微域名
|
||
config.addAllowedOriginPattern("*");
|
||
// 允许携带凭证(cookies)
|
||
config.setAllowCredentials(true);
|
||
// 允许所有请求头
|
||
config.addAllowedHeader("*");
|
||
// 允许所有请求方法
|
||
config.addAllowedMethod("*");
|
||
// 暴露响应头
|
||
config.addExposedHeader("Authorization");
|
||
config.addExposedHeader("X-Request-Id");
|
||
// 预检请求缓存时间(1小时)
|
||
config.setMaxAge(3600L);
|
||
|
||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||
source.registerCorsConfiguration("/**", config);
|
||
|
||
return new CorsFilter(source);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.1.9 通用实体基类
|
||
|
||
```java
|
||
package com.artedu.common.entity;
|
||
|
||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||
import com.baomidou.mybatisplus.annotation.IdType;
|
||
import com.baomidou.mybatisplus.annotation.TableField;
|
||
import com.baomidou.mybatisplus.annotation.TableId;
|
||
import lombok.Data;
|
||
|
||
import java.time.LocalDateTime;
|
||
|
||
/**
|
||
* 通用实体基类
|
||
* 所有实体类继承此类,自动填充创建时间和更新时间
|
||
*/
|
||
@Data
|
||
public class BaseEntity {
|
||
|
||
/**
|
||
* 主键ID(自增)
|
||
*/
|
||
@TableId(type = IdType.AUTO)
|
||
private Long id;
|
||
|
||
/**
|
||
* 创建时间(自动填充)
|
||
*/
|
||
@TableField(fill = FieldFill.INSERT)
|
||
private LocalDateTime createdAt;
|
||
|
||
/**
|
||
* 更新时间(自动填充)
|
||
*/
|
||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||
private LocalDateTime updatedAt;
|
||
}
|
||
```
|
||
|
||
#### 3.1.10 自动填充处理器
|
||
|
||
```java
|
||
package com.artedu.common.handler;
|
||
|
||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.apache.ibatis.reflection.MetaObject;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
import java.time.LocalDateTime;
|
||
|
||
/**
|
||
* MyBatis-Plus字段自动填充处理器
|
||
* 自动填充created_at和updated_at字段
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
public class AutoFillHandler implements MetaObjectHandler {
|
||
|
||
@Override
|
||
public void insertFill(MetaObject metaObject) {
|
||
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
|
||
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
|
||
}
|
||
|
||
@Override
|
||
public void updateFill(MetaObject metaObject) {
|
||
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 认证授权服务(auth-service)
|
||
|
||
负责企微OAuth2登录、JWT签发和权限控制。
|
||
|
||
#### 3.2.1 启动类
|
||
|
||
```java
|
||
package com.artedu.auth;
|
||
|
||
import org.springframework.boot.SpringApplication;
|
||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||
|
||
/**
|
||
* 认证授权服务启动类
|
||
*/
|
||
@SpringBootApplication(scanBasePackages = {"com.artedu.auth", "com.artedu.common"})
|
||
@EnableDiscoveryClient
|
||
public class AuthServiceApplication {
|
||
|
||
public static void main(String[] args) {
|
||
SpringApplication.run(AuthServiceApplication.class, args);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 企微OAuth2 Service
|
||
|
||
```java
|
||
package com.artedu.auth.service;
|
||
|
||
import com.artedu.auth.entity.Staff;
|
||
import com.artedu.auth.mapper.StaffMapper;
|
||
import com.artedu.common.exception.BusinessException;
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.Map;
|
||
import java.util.concurrent.TimeUnit;
|
||
|
||
/**
|
||
* 企业微信OAuth2认证服务
|
||
* 处理企微登录流程,获取用户信息
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class WeComOAuthService {
|
||
|
||
@Value("${wecom.corp-id}")
|
||
private String corpId;
|
||
|
||
@Value("${wecom.agent-id}")
|
||
private String agentId;
|
||
|
||
@Value("${wecom.secret}")
|
||
private String secret;
|
||
|
||
@Autowired
|
||
private RestTemplate restTemplate;
|
||
|
||
@Autowired
|
||
private StaffMapper staffMapper;
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
private static final String ACCESS_TOKEN_KEY = "wecom:access_token";
|
||
private static final String USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token={accessToken}&code={code}";
|
||
private static final String USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={accessToken}&userid={userId}";
|
||
|
||
/**
|
||
* 获取企微AccessToken(带缓存)
|
||
*/
|
||
public String getAccessToken() {
|
||
// 先从Redis获取
|
||
String token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
|
||
if (token != null) {
|
||
return token;
|
||
}
|
||
|
||
// 从企微API获取
|
||
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
|
||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||
|
||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||
log.error("获取AccessToken失败: {}", response.getBody());
|
||
throw new BusinessException("获取企微AccessToken失败");
|
||
}
|
||
|
||
token = (String) result.get("access_token");
|
||
// 缓存7200秒(企微token有效期2小时)
|
||
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* 通过OAuth Code获取用户ID
|
||
*/
|
||
public String getUserIdByCode(String code) {
|
||
String accessToken = getAccessToken();
|
||
String url = USER_INFO_URL.replace("{accessToken}", accessToken).replace("{code}", code);
|
||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||
|
||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||
log.error("获取用户信息失败: {}", response.getBody());
|
||
throw new BusinessException("OAuth认证失败,请重新登录");
|
||
}
|
||
|
||
String userId = (String) result.get("UserId");
|
||
if (userId == null) {
|
||
throw new BusinessException("获取用户ID失败,可能不在企业通讯录中");
|
||
}
|
||
return userId;
|
||
}
|
||
|
||
/**
|
||
* 获取用户详细信息
|
||
*/
|
||
public Map<String, Object> getUserDetail(String userId) {
|
||
String accessToken = getAccessToken();
|
||
String url = USER_DETAIL_URL.replace("{accessToken}", accessToken).replace("{userId}", userId);
|
||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||
|
||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||
log.error("获取用户详情失败: {}", response.getBody());
|
||
throw new BusinessException("获取用户信息失败");
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 处理登录:OAuth Code → 用户信息 → JWT Token
|
||
*/
|
||
public Staff handleLogin(String code) {
|
||
// 1. 通过Code获取UserId
|
||
String userId = getUserIdByCode(code);
|
||
|
||
// 2. 获取用户详情
|
||
Map<String, Object> userDetail = getUserDetail(userId);
|
||
|
||
// 3. 查询或创建本地用户
|
||
Staff staff = staffMapper.selectOne(
|
||
new LambdaQueryWrapper<Staff>()
|
||
.eq(Staff::getStaffId, userId)
|
||
.eq(Staff::getCorpId, corpId)
|
||
);
|
||
|
||
if (staff == null) {
|
||
// 新用户,创建记录
|
||
staff = new Staff();
|
||
staff.setStaffId(userId);
|
||
staff.setCorpId(corpId);
|
||
staff.setName((String) userDetail.get("name"));
|
||
staff.setAvatar((String) userDetail.get("avatar"));
|
||
staff.setDepartment(formatDepartment(userDetail.get("department")));
|
||
staff.setRole("ADVISOR");
|
||
staff.setStatus("ACTIVE");
|
||
staffMapper.insert(staff);
|
||
log.info("新用户注册: userId={}, name={}", userId, staff.getName());
|
||
} else {
|
||
// 更新用户信息
|
||
staff.setName((String) userDetail.get("name"));
|
||
staff.setAvatar((String) userDetail.get("avatar"));
|
||
staffMapper.updateById(staff);
|
||
}
|
||
|
||
return staff;
|
||
}
|
||
|
||
/**
|
||
* 格式化部门信息
|
||
*/
|
||
private String formatDepartment(Object department) {
|
||
if (department == null) {
|
||
return "";
|
||
}
|
||
return JsonUtils.toJson(department);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.3 JWT Service
|
||
|
||
```java
|
||
package com.artedu.auth.service;
|
||
|
||
import io.jsonwebtoken.Claims;
|
||
import io.jsonwebtoken.Jwts;
|
||
import io.jsonwebtoken.SignatureAlgorithm;
|
||
import io.jsonwebtoken.security.Keys;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import javax.crypto.SecretKey;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.Date;
|
||
import java.util.HashMap;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* JWT Token服务
|
||
* 负责生成和验证JWT Token
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class JwtService {
|
||
|
||
@Value("${jwt.secret}")
|
||
private String secret;
|
||
|
||
@Value("${jwt.expiration:86400}")
|
||
private Long expiration; // 默认24小时
|
||
|
||
/**
|
||
* 生成SecretKey
|
||
*/
|
||
private SecretKey getSecretKey() {
|
||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||
}
|
||
|
||
/**
|
||
* 生成JWT Token
|
||
*
|
||
* @param userId 用户ID
|
||
* @param corpId 企业ID
|
||
* @param userName 用户姓名
|
||
* @param role 角色
|
||
* @return JWT Token字符串
|
||
*/
|
||
public String generateToken(String userId, String corpId, String userName, String role) {
|
||
Map<String, Object> claims = new HashMap<>();
|
||
claims.put("userId", userId);
|
||
claims.put("corpId", corpId);
|
||
claims.put("userName", userName);
|
||
claims.put("role", role);
|
||
claims.put("type", "access");
|
||
|
||
Date now = new Date();
|
||
Date expiryDate = new Date(now.getTime() + expiration * 1000);
|
||
|
||
return Jwts.builder()
|
||
.setClaims(claims)
|
||
.setSubject(userId)
|
||
.setIssuedAt(now)
|
||
.setExpiration(expiryDate)
|
||
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
|
||
.compact();
|
||
}
|
||
|
||
/**
|
||
* 验证并解析Token
|
||
*/
|
||
public Claims validateToken(String token) {
|
||
return Jwts.parserBuilder()
|
||
.setSigningKey(getSecretKey())
|
||
.build()
|
||
.parseClaimsJws(token)
|
||
.getBody();
|
||
}
|
||
|
||
/**
|
||
* 从Token中获取用户ID
|
||
*/
|
||
public String getUserIdFromToken(String token) {
|
||
Claims claims = validateToken(token);
|
||
return claims.get("userId", String.class);
|
||
}
|
||
|
||
/**
|
||
* 检查Token是否过期
|
||
*/
|
||
public boolean isTokenExpired(String token) {
|
||
try {
|
||
Claims claims = validateToken(token);
|
||
return claims.getExpiration().before(new Date());
|
||
} catch (Exception e) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取Token剩余有效时间(秒)
|
||
*/
|
||
public long getExpirationSeconds(String token) {
|
||
try {
|
||
Claims claims = validateToken(token);
|
||
long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
|
||
return Math.max(remain / 1000, 0);
|
||
} catch (Exception e) {
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.4 Auth Controller
|
||
|
||
```java
|
||
package com.artedu.auth.controller;
|
||
|
||
import com.artedu.auth.entity.Staff;
|
||
import com.artedu.auth.service.JwtService;
|
||
import com.artedu.auth.service.WeComOAuthService;
|
||
import com.artedu.common.result.Result;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
import java.util.HashMap;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 认证授权控制器
|
||
* 处理企微OAuth登录和Token刷新
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/auth")
|
||
public class AuthController {
|
||
|
||
@Autowired
|
||
private WeComOAuthService weComOAuthService;
|
||
|
||
@Autowired
|
||
private JwtService jwtService;
|
||
|
||
/**
|
||
* 企微OAuth登录
|
||
*
|
||
* @param code 企微OAuth授权码
|
||
* @return JWT Token和用户信息
|
||
*/
|
||
@PostMapping("/login")
|
||
public Result<Map<String, Object>> login(@RequestParam("code") String code) {
|
||
log.info("企微OAuth登录, code={}", code);
|
||
|
||
// 1. 通过OAuth Code获取用户信息
|
||
Staff staff = weComOAuthService.handleLogin(code);
|
||
|
||
// 2. 生成JWT Token
|
||
String token = jwtService.generateToken(
|
||
staff.getStaffId(),
|
||
staff.getCorpId(),
|
||
staff.getName(),
|
||
staff.getRole()
|
||
);
|
||
|
||
// 3. 返回Token和用户信息
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("token", token);
|
||
result.put("tokenType", "Bearer");
|
||
result.put("expiresIn", 86400);
|
||
result.put("userId", staff.getStaffId());
|
||
result.put("userName", staff.getName());
|
||
result.put("avatar", staff.getAvatar());
|
||
result.put("role", staff.getRole());
|
||
|
||
log.info("登录成功: userId={}, name={}", staff.getStaffId(), staff.getName());
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 刷新Token
|
||
*/
|
||
@PostMapping("/refresh")
|
||
public Result<Map<String, Object>> refreshToken(@RequestHeader("Authorization") String authHeader) {
|
||
String oldToken = authHeader.replace("Bearer ", "");
|
||
|
||
// 验证旧Token
|
||
if (jwtService.isTokenExpired(oldToken)) {
|
||
return Result.fail("Token已过期,请重新登录");
|
||
}
|
||
|
||
// 解析旧Token信息
|
||
io.jsonwebtoken.Claims claims = jwtService.validateToken(oldToken);
|
||
String userId = claims.get("userId", String.class);
|
||
String corpId = claims.get("corpId", String.class);
|
||
String userName = claims.get("userName", String.class);
|
||
String role = claims.get("role", String.class);
|
||
|
||
// 生成新Token
|
||
String newToken = jwtService.generateToken(userId, corpId, userName, role);
|
||
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("token", newToken);
|
||
result.put("tokenType", "Bearer");
|
||
result.put("expiresIn", 86400);
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 获取当前登录用户信息
|
||
*/
|
||
@GetMapping("/info")
|
||
public Result<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
|
||
String token = authHeader.replace("Bearer ", "");
|
||
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
|
||
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("userId", claims.get("userId"));
|
||
result.put("userName", claims.get("userName"));
|
||
result.put("corpId", claims.get("corpId"));
|
||
result.put("role", claims.get("role"));
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* JS-SDK签名接口
|
||
* 用于前端调用ww.register时获取签名
|
||
*/
|
||
@GetMapping("/signature")
|
||
public Result<Map<String, Object>> getJsSdkSignature(
|
||
@RequestParam("url") String url,
|
||
@RequestHeader("Authorization") String authHeader) {
|
||
|
||
String token = authHeader.replace("Bearer ", "");
|
||
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
|
||
String corpId = claims.get("corpId", String.class);
|
||
|
||
// 调用企微API获取jsapi_ticket
|
||
String ticket = weComOAuthService.getJsApiTicket();
|
||
String nonceStr = java.util.UUID.randomUUID().toString().replace("-", "");
|
||
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
|
||
|
||
// 拼接签名字符串
|
||
String signature = generateSignature(ticket, nonceStr, timestamp, url);
|
||
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("corpId", corpId);
|
||
result.put("agentId", "1000002"); // 替换为实际agentId
|
||
result.put("nonceStr", nonceStr);
|
||
result.put("timestamp", timestamp);
|
||
result.put("signature", signature);
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 生成JS-SDK签名
|
||
*/
|
||
private String generateSignature(String ticket, String nonceStr, String timestamp, String url) {
|
||
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url;
|
||
try {
|
||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1");
|
||
digest.update(string1.getBytes());
|
||
byte[] messageDigest = digest.digest();
|
||
StringBuilder hexString = new StringBuilder();
|
||
for (byte b : messageDigest) {
|
||
String shaHex = Integer.toHexString(b & 0xFF);
|
||
if (shaHex.length() < 2) {
|
||
hexString.append(0);
|
||
}
|
||
hexString.append(shaHex);
|
||
}
|
||
return hexString.toString();
|
||
} catch (Exception e) {
|
||
log.error("生成签名失败", e);
|
||
return "";
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.5 Staff实体类
|
||
|
||
```java
|
||
package com.artedu.auth.entity;
|
||
|
||
import com.artedu.common.entity.BaseEntity;
|
||
import com.baomidou.mybatisplus.annotation.TableName;
|
||
import lombok.Data;
|
||
import lombok.EqualsAndHashCode;
|
||
|
||
/**
|
||
* 员工实体
|
||
*/
|
||
@Data
|
||
@EqualsAndHashCode(callSuper = true)
|
||
@TableName("staffs")
|
||
public class Staff extends BaseEntity {
|
||
|
||
private static final long serialVersionUID = 1L;
|
||
|
||
/**
|
||
* 企微用户ID
|
||
*/
|
||
private String staffId;
|
||
|
||
/**
|
||
* 企业ID
|
||
*/
|
||
private String corpId;
|
||
|
||
/**
|
||
* 姓名
|
||
*/
|
||
private String name;
|
||
|
||
/**
|
||
* 头像URL
|
||
*/
|
||
private String avatar;
|
||
|
||
/**
|
||
* 部门
|
||
*/
|
||
private String department;
|
||
|
||
/**
|
||
* 角色
|
||
*/
|
||
private String role;
|
||
|
||
/**
|
||
* 状态
|
||
*/
|
||
private String status;
|
||
|
||
/**
|
||
* 推送设置(JSON字符串)
|
||
*/
|
||
private String pushSettings;
|
||
|
||
/**
|
||
* 最后登录时间
|
||
*/
|
||
private java.time.LocalDateTime lastLoginTime;
|
||
}
|
||
```
|
||
|
||
#### 3.2.6 Staff Mapper
|
||
|
||
```java
|
||
package com.artedu.auth.mapper;
|
||
|
||
import com.artedu.auth.entity.Staff;
|
||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||
import org.apache.ibatis.annotations.Mapper;
|
||
|
||
/**
|
||
* 员工数据访问层
|
||
*/
|
||
@Mapper
|
||
public interface StaffMapper extends BaseMapper<Staff> {
|
||
}
|
||
```
|
||
|
||
#### 3.2.7 application.yml
|
||
|
||
```yaml
|
||
server:
|
||
port: 8081
|
||
|
||
spring:
|
||
application:
|
||
name: auth-service
|
||
datasource:
|
||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
|
||
username: ${MYSQL_USERNAME:root}
|
||
password: ${MYSQL_PASSWORD:root}
|
||
redis:
|
||
host: ${REDIS_HOST:localhost}
|
||
port: ${REDIS_PORT:6379}
|
||
password: ${REDIS_PASSWORD:}
|
||
database: 0
|
||
lettuce:
|
||
pool:
|
||
max-active: 8
|
||
max-idle: 8
|
||
min-idle: 0
|
||
cloud:
|
||
nacos:
|
||
discovery:
|
||
server-addr: ${NACOS_HOST:localhost}:${NACOS_PORT:8848}
|
||
|
||
# 企微配置
|
||
wecom:
|
||
corp-id: ${WECOM_CORP_ID:}
|
||
agent-id: ${WECOM_AGENT_ID:}
|
||
secret: ${WECOM_SECRET:}
|
||
|
||
# JWT配置
|
||
jwt:
|
||
secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters}
|
||
expiration: 86400
|
||
|
||
# MyBatis-Plus
|
||
mybatis-plus:
|
||
configuration:
|
||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||
global-config:
|
||
db-config:
|
||
logic-delete-field: deleted
|
||
logic-delete-value: 1
|
||
logic-not-delete-value: 0
|
||
```
|
||
|
||
### 3.3 会话存档服务(archive-service)
|
||
|
||
负责接收企微回调、拉取存档消息、RSA解密和存储。
|
||
|
||
#### 3.3.1 启动类
|
||
|
||
```java
|
||
package com.artedu.archive;
|
||
|
||
import org.springframework.boot.SpringApplication;
|
||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||
|
||
/**
|
||
* 会话存档服务启动类
|
||
*/
|
||
@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"})
|
||
@EnableDiscoveryClient
|
||
public class ArchiveServiceApplication {
|
||
|
||
public static void main(String[] args) {
|
||
SpringApplication.run(ArchiveServiceApplication.class, args);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.2 JNA接口定义(调用企微C SDK)
|
||
|
||
```java
|
||
package com.artedu.archive.sdk;
|
||
|
||
import com.sun.jna.Library;
|
||
import com.sun.jna.Native;
|
||
import com.sun.jna.Pointer;
|
||
import com.sun.jna.Structure;
|
||
|
||
import java.util.Arrays;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 企微会话存档C SDK的JNA接口定义
|
||
* 通过JNA调用本地动态库(.so/.dll)
|
||
*/
|
||
public interface WeWorkFinanceSdk extends Library {
|
||
|
||
// 加载本地动态库
|
||
WeWorkFinanceSdk INSTANCE = Native.load(
|
||
System.getProperty("os.name").toLowerCase().contains("win") ? "WeWorkFinanceSdk" : "WeWorkFinanceSdk",
|
||
WeWorkFinanceSdk.class
|
||
);
|
||
|
||
/**
|
||
* 初始化SDK
|
||
*
|
||
* @return SDK指针
|
||
*/
|
||
long NewSdk();
|
||
|
||
/**
|
||
* 初始化SDK配置
|
||
*
|
||
* @param sdk SDK指针
|
||
* @param corpId 企业ID
|
||
* @param secret 存档Secret
|
||
* @return 0表示成功
|
||
*/
|
||
int Init(long sdk, String corpId, String secret);
|
||
|
||
/**
|
||
* 拉取聊天记录
|
||
*
|
||
* @param sdk SDK指针
|
||
* @param seq 起始序列号
|
||
* @param limit 拉取条数(最大1000)
|
||
* @param proxy 代理地址
|
||
* @param passwd 代理密码
|
||
* @param timeout 超时时间(秒)
|
||
* @param chatData 返回数据切片
|
||
* @return 0表示成功
|
||
*/
|
||
int GetChatData(long sdk, long seq, int limit, String proxy, String passwd, int timeout, long chatData);
|
||
|
||
/**
|
||
* 解密数据
|
||
*
|
||
* @param sdk SDK指针
|
||
* @param encryptKey 解密密钥
|
||
* @param encryptMsg 加密消息
|
||
* @param msg 返回解密后的消息切片
|
||
* @return 0表示成功
|
||
*/
|
||
int DecryptData(long sdk, String encryptKey, String encryptMsg, long msg);
|
||
|
||
/**
|
||
* 释放SDK资源
|
||
*/
|
||
int DestroySdk(long sdk);
|
||
|
||
/**
|
||
* 释放切片
|
||
*/
|
||
void FreeSlice(long slice);
|
||
|
||
/**
|
||
* 数据切片结构
|
||
*/
|
||
class Slice_t extends Structure {
|
||
public Pointer content;
|
||
public int len;
|
||
|
||
@Override
|
||
protected List<String> getFieldOrder() {
|
||
return Arrays.asList("content", "len");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.3 RSA解密工具类
|
||
|
||
```java
|
||
package com.artedu.archive.util;
|
||
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
import javax.crypto.Cipher;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.security.*;
|
||
import java.security.spec.PKCS8EncodedKeySpec;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* RSA解密工具类
|
||
* 用于解密企微会话存档的encrypt_random_key
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
public class RsaDecryptUtil {
|
||
|
||
private static final String ALGORITHM = "RSA";
|
||
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
|
||
|
||
/**
|
||
* 从Base64编码的私钥字符串加载私钥
|
||
*/
|
||
public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception {
|
||
byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
|
||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
|
||
return keyFactory.generatePrivate(spec);
|
||
}
|
||
|
||
/**
|
||
* RSA私钥解密
|
||
*
|
||
* @param encryptedData Base64编码的加密数据
|
||
* @param base64PrivateKey Base64编码的私钥
|
||
* @return 解密后的字符串
|
||
*/
|
||
public String decrypt(String encryptedData, String base64PrivateKey) {
|
||
try {
|
||
PrivateKey privateKey = loadPrivateKey(base64PrivateKey);
|
||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
|
||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||
} catch (Exception e) {
|
||
log.error("RSA解密失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成RSA密钥对(用于测试或初始化)
|
||
*/
|
||
public static void generateKeyPair() throws Exception {
|
||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
|
||
keyGen.initialize(2048);
|
||
KeyPair keyPair = keyGen.generateKeyPair();
|
||
|
||
String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||
String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||
|
||
log.info("=== RSA公钥(上传到企微后台) ===");
|
||
log.info(publicKey);
|
||
log.info("=== RSA私钥(安全保存) ===");
|
||
log.info(privateKey);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.4 存档消息实体类
|
||
|
||
```java
|
||
package com.artedu.archive.entity;
|
||
|
||
import com.baomidou.mybatisplus.annotation.IdType;
|
||
import com.baomidou.mybatisplus.annotation.TableId;
|
||
import com.baomidou.mybatisplus.annotation.TableName;
|
||
import lombok.Data;
|
||
|
||
import java.time.LocalDateTime;
|
||
|
||
/**
|
||
* 存档消息实体
|
||
* 对应archive_messages表
|
||
*/
|
||
@Data
|
||
@TableName("archive_messages")
|
||
public class ArchiveMessage {
|
||
|
||
@TableId(type = IdType.AUTO)
|
||
private Long id;
|
||
|
||
/**
|
||
* 企微消息唯一ID
|
||
*/
|
||
private String msgid;
|
||
|
||
/**
|
||
* 存档序列号
|
||
*/
|
||
private Long seq;
|
||
|
||
/**
|
||
* 企业ID
|
||
*/
|
||
private String corpId;
|
||
|
||
/**
|
||
* 动作类型
|
||
*/
|
||
private String action;
|
||
|
||
/**
|
||
* 发送者
|
||
*/
|
||
private String fromUser;
|
||
|
||
/**
|
||
* 发送者角色
|
||
*/
|
||
private String fromRole;
|
||
|
||
/**
|
||
* 接收者
|
||
*/
|
||
private String toUser;
|
||
|
||
/**
|
||
* 接收者列表(JSON字符串)
|
||
*/
|
||
private String tolist;
|
||
|
||
/**
|
||
* 群聊ID
|
||
*/
|
||
private String roomid;
|
||
|
||
/**
|
||
* 消息类型
|
||
*/
|
||
private String msgtype;
|
||
|
||
/**
|
||
* 消息时间(毫秒时间戳)
|
||
*/
|
||
private Long msgtime;
|
||
|
||
/**
|
||
* 消息内容
|
||
*/
|
||
private String content;
|
||
|
||
/**
|
||
* 媒体数据(JSON字符串)
|
||
*/
|
||
private String mediaData;
|
||
|
||
/**
|
||
* 会话ID
|
||
*/
|
||
private String sessionId;
|
||
|
||
/**
|
||
* 解密状态
|
||
*/
|
||
private Integer decryptStatus;
|
||
|
||
/**
|
||
* 解密错误信息
|
||
*/
|
||
private String decryptError;
|
||
|
||
/**
|
||
* 创建时间
|
||
*/
|
||
private LocalDateTime createdAt;
|
||
|
||
/**
|
||
* 更新时间
|
||
*/
|
||
private LocalDateTime updatedAt;
|
||
}
|
||
```
|
||
|
||
#### 3.3.5 存档消息Mapper
|
||
|
||
```java
|
||
package com.artedu.archive.mapper;
|
||
|
||
import com.artedu.archive.entity.ArchiveMessage;
|
||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||
import org.apache.ibatis.annotations.Mapper;
|
||
import org.apache.ibatis.annotations.Param;
|
||
import org.apache.ibatis.annotations.Select;
|
||
|
||
/**
|
||
* 存档消息数据访问层
|
||
*/
|
||
@Mapper
|
||
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
||
|
||
/**
|
||
* 查询最大序列号
|
||
*/
|
||
@Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}")
|
||
Long selectMaxSeq(@Param("corpId") String corpId);
|
||
|
||
/**
|
||
* 根据msgid查询是否存在
|
||
*/
|
||
@Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}")
|
||
int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId);
|
||
}
|
||
```
|
||
|
||
#### 3.3.6 存档拉取服务
|
||
|
||
```java
|
||
package com.artedu.archive.service;
|
||
|
||
import com.artedu.archive.entity.ArchiveMessage;
|
||
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||
import com.artedu.archive.sdk.WeWorkFinanceSdk;
|
||
import com.artedu.archive.util.RsaDecryptUtil;
|
||
import com.artedu.common.config.RabbitConfig;
|
||
import com.artedu.common.util.JsonUtils;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import javax.annotation.PostConstruct;
|
||
import javax.annotation.PreDestroy;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.concurrent.TimeUnit;
|
||
|
||
/**
|
||
* 存档拉取服务
|
||
* 通过C SDK拉取企微会话存档消息
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class ArchivePullService {
|
||
|
||
@Value("${wecom.archive.corp-id}")
|
||
private String corpId;
|
||
|
||
@Value("${wecom.archive.secret}")
|
||
private String secret;
|
||
|
||
@Value("${wecom.archive.rsa-private-key:}")
|
||
private String rsaPrivateKey;
|
||
|
||
@Autowired
|
||
private ArchiveMessageMapper archiveMessageMapper;
|
||
|
||
@Autowired
|
||
private RabbitTemplate rabbitTemplate;
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
@Autowired
|
||
private RsaDecryptUtil rsaDecryptUtil;
|
||
|
||
private long sdk;
|
||
private static final String LAST_SEQ_KEY = "archive:last_seq:";
|
||
|
||
/**
|
||
* 初始化SDK
|
||
*/
|
||
@PostConstruct
|
||
public void init() {
|
||
sdk = WeWorkFinanceSdk.INSTANCE.NewSdk();
|
||
int ret = WeWorkFinanceSdk.INSTANCE.Init(sdk, corpId, secret);
|
||
if (ret != 0) {
|
||
log.error("企微存档SDK初始化失败, ret={}", ret);
|
||
throw new RuntimeException("SDK初始化失败: " + ret);
|
||
}
|
||
log.info("企微存档SDK初始化成功");
|
||
}
|
||
|
||
/**
|
||
* 释放SDK资源
|
||
*/
|
||
@PreDestroy
|
||
public void destroy() {
|
||
WeWorkFinanceSdk.INSTANCE.DestroySdk(sdk);
|
||
log.info("企微存档SDK已释放");
|
||
}
|
||
|
||
/**
|
||
* 拉取存档消息(核心方法)
|
||
*
|
||
* @param seq 起始序列号
|
||
* @param limit 拉取条数
|
||
* @return 拉取的消息列表
|
||
*/
|
||
public List<ArchiveMessage> pullMessages(long seq, int limit) {
|
||
List<ArchiveMessage> messages = new ArrayList<>();
|
||
|
||
// 分配切片内存
|
||
long chatDataSlice = 0; // 实际需要通过JNA分配内存
|
||
|
||
try {
|
||
// 调用SDK拉取数据
|
||
int ret = WeWorkFinanceSdk.INSTANCE.GetChatData(sdk, seq, limit, null, null, 5, chatDataSlice);
|
||
if (ret != 0) {
|
||
log.error("GetChatData失败, ret={}, seq={}", ret, seq);
|
||
return messages;
|
||
}
|
||
|
||
// 解析返回的JSON数据
|
||
// 注意:实际需要从chatDataSlice中提取内容
|
||
String jsonData = extractFromSlice(chatDataSlice);
|
||
if (jsonData == null || jsonData.isEmpty()) {
|
||
return messages;
|
||
}
|
||
|
||
Map<String, Object> result = JsonUtils.fromJsonMap(jsonData);
|
||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||
log.error("拉取存档返回错误: {}", jsonData);
|
||
return messages;
|
||
}
|
||
|
||
// 解析聊天数据列表
|
||
List<Map<String, Object>> chatDataList = (List<Map<String, Object>>) result.get("chatdata");
|
||
if (chatDataList == null || chatDataList.isEmpty()) {
|
||
return messages;
|
||
}
|
||
|
||
for (Map<String, Object> chatData : chatDataList) {
|
||
try {
|
||
ArchiveMessage message = parseAndDecrypt(chatData);
|
||
if (message != null) {
|
||
messages.add(message);
|
||
}
|
||
} catch (Exception e) {
|
||
log.error("解析单条消息失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
log.info("拉取存档消息完成: seq={}, limit={}, 成功解析{}条", seq, limit, messages.size());
|
||
|
||
} finally {
|
||
// 释放切片内存
|
||
if (chatDataSlice != 0) {
|
||
WeWorkFinanceSdk.INSTANCE.FreeSlice(chatDataSlice);
|
||
}
|
||
}
|
||
|
||
return messages;
|
||
}
|
||
|
||
/**
|
||
* 解析并解密单条消息
|
||
*/
|
||
private ArchiveMessage parseAndDecrypt(Map<String, Object> chatData) {
|
||
String encryptRandomKey = (String) chatData.get("encrypt_random_key");
|
||
String encryptChatMsg = (String) chatData.get("encrypt_chat_msg");
|
||
Long msgSeq = ((Number) chatData.get("seq")).longValue();
|
||
|
||
// 1. RSA解密获取AES密钥
|
||
String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey);
|
||
if (encryptKey == null) {
|
||
log.error("解密encrypt_random_key失败, seq={}", msgSeq);
|
||
return null;
|
||
}
|
||
|
||
// 2. AES解密消息内容
|
||
// 注意:实际需要通过SDK的DecryptData方法解密
|
||
String decryptedMsg = decryptMessage(encryptKey, encryptChatMsg);
|
||
if (decryptedMsg == null) {
|
||
log.error("解密消息内容失败, seq={}", msgSeq);
|
||
return null;
|
||
}
|
||
|
||
// 3. 解析解密后的JSON消息
|
||
Map<String, Object> msgMap = JsonUtils.fromJsonMap(decryptedMsg);
|
||
if (msgMap == null) {
|
||
return null;
|
||
}
|
||
|
||
ArchiveMessage message = new ArchiveMessage();
|
||
message.setMsgid((String) msgMap.get("msgid"));
|
||
message.setSeq(msgSeq);
|
||
message.setCorpId(corpId);
|
||
message.setAction((String) msgMap.getOrDefault("action", "send"));
|
||
message.setFromUser((String) msgMap.get("from"));
|
||
message.setFromRole(detectRole((String) msgMap.get("from")));
|
||
message.setToUser((String) msgMap.get("tolist"));
|
||
message.setRoomid((String) msgMap.get("roomid"));
|
||
message.setMsgtype((String) msgMap.get("msgtype"));
|
||
Object msgTimeObj = msgMap.get("msgtime");
|
||
if (msgTimeObj != null) {
|
||
message.setMsgtime(((Number) msgTimeObj).longValue());
|
||
}
|
||
|
||
// 提取文本内容
|
||
Object content = msgMap.get("content");
|
||
if (content != null) {
|
||
message.setContent(content.toString());
|
||
}
|
||
|
||
message.setTolist(JsonUtils.toJson(msgMap.get("tolist")));
|
||
message.setMediaData(JsonUtils.toJson(msgMap.get("mediaData")));
|
||
message.setDecryptStatus(1);
|
||
message.setSessionId(generateSessionId(message));
|
||
|
||
return message;
|
||
}
|
||
|
||
/**
|
||
* 使用SDK解密消息
|
||
*/
|
||
private String decryptMessage(String encryptKey, String encryptMsg) {
|
||
long msgSlice = 0; // 实际需要通过JNA分配内存
|
||
try {
|
||
int ret = WeWorkFinanceSdk.INSTANCE.DecryptData(sdk, encryptKey, encryptMsg, msgSlice);
|
||
if (ret != 0) {
|
||
log.error("DecryptData失败, ret={}", ret);
|
||
return null;
|
||
}
|
||
return extractFromSlice(msgSlice);
|
||
} finally {
|
||
if (msgSlice != 0) {
|
||
WeWorkFinanceSdk.INSTANCE.FreeSlice(msgSlice);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从切片中提取字符串内容
|
||
* 注意:实际实现需要根据JNA的内存布局来读取
|
||
*/
|
||
private String extractFromSlice(long slice) {
|
||
// 这里需要根据实际的C SDK内存布局来实现
|
||
// 临时返回空字符串,实际项目中需要正确实现
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* 检测消息发送者角色
|
||
*/
|
||
private String detectRole(String fromUser) {
|
||
// 简单判断:以"wm"开头的是外部联系人
|
||
if (fromUser != null && fromUser.startsWith("wm")) {
|
||
return "EXTERNAL";
|
||
}
|
||
return "INTERNAL";
|
||
}
|
||
|
||
/**
|
||
* 生成会话ID
|
||
*/
|
||
private String generateSessionId(ArchiveMessage message) {
|
||
// 会话ID格式: corpId_fromUser_toUser
|
||
if (message.getRoomid() != null && !message.getRoomid().isEmpty()) {
|
||
return corpId + "_" + message.getRoomid();
|
||
}
|
||
return corpId + "_" + message.getFromUser() + "_" + message.getToUser();
|
||
}
|
||
|
||
/**
|
||
* 保存消息并发送MQ事件
|
||
*/
|
||
public void saveAndNotify(List<ArchiveMessage> messages) {
|
||
for (ArchiveMessage message : messages) {
|
||
try {
|
||
// 去重检查
|
||
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
|
||
if (count > 0) {
|
||
log.debug("消息已存在,跳过: msgid={}", message.getMsgid());
|
||
continue;
|
||
}
|
||
|
||
// 保存消息
|
||
archiveMessageMapper.insert(message);
|
||
|
||
// 发送MQ事件,触发后续处理
|
||
rabbitTemplate.convertAndSend(
|
||
RabbitConfig.ARCHIVE_EXCHANGE,
|
||
RabbitConfig.ROUTING_KEY_NEW_MESSAGE,
|
||
JsonUtils.toJson(message)
|
||
);
|
||
|
||
// 更新最后拉取的seq
|
||
redisTemplate.opsForValue().set(
|
||
LAST_SEQ_KEY + corpId,
|
||
message.getSeq(),
|
||
7, TimeUnit.DAYS
|
||
);
|
||
|
||
} catch (Exception e) {
|
||
log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取上次拉取的序列号
|
||
*/
|
||
public long getLastSeq() {
|
||
Object seq = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId);
|
||
if (seq != null) {
|
||
return ((Number) seq).longValue();
|
||
}
|
||
// 从数据库查询
|
||
Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId);
|
||
return maxSeq != null ? maxSeq : 0L;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.3.7 回调接收Controller
|
||
|
||
```java
|
||
package com.artedu.archive.controller;
|
||
|
||
import com.artedu.archive.service.ArchivePullService;
|
||
import com.artedu.common.result.Result;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
import java.util.concurrent.CompletableFuture;
|
||
|
||
/**
|
||
* 企微回调控制器
|
||
* 接收企微的msgaudit_notify回调
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/archive")
|
||
public class ArchiveCallbackController {
|
||
|
||
@Autowired
|
||
private ArchivePullService archivePullService;
|
||
|
||
/**
|
||
* 企微回调接口
|
||
* 企微每隔约15秒推送一次msgaudit_notify事件
|
||
*/
|
||
@PostMapping("/callback")
|
||
public Result<String> callback(
|
||
@RequestParam("msg_signature") String msgSignature,
|
||
@RequestParam("timestamp") String timestamp,
|
||
@RequestParam("nonce") String nonce,
|
||
@RequestBody String requestBody) {
|
||
|
||
log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
|
||
|
||
// 1. 验证回调签名(防止伪造)
|
||
// TODO: 实现签名验证逻辑
|
||
|
||
// 2. 解析回调内容
|
||
if (requestBody.contains("msgaudit_notify")) {
|
||
log.info("收到msgaudit_notify回调,触发存档拉取");
|
||
|
||
// 3. 异步触发存档拉取(不阻塞回调响应)
|
||
CompletableFuture.runAsync(() -> {
|
||
try {
|
||
long lastSeq = archivePullService.getLastSeq();
|
||
var messages = archivePullService.pullMessages(lastSeq, 1000);
|
||
archivePullService.saveAndNotify(messages);
|
||
log.info("回调触发拉取完成,共{}条消息", messages.size());
|
||
} catch (Exception e) {
|
||
log.error("回调触发拉取失败: {}", e.getMessage(), e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 4. 立即返回success(企微要求5秒内响应)
|
||
return Result.success("success");
|
||
}
|
||
|
||
/**
|
||
* 手动触发存档拉取(调试用)
|
||
*/
|
||
@PostMapping("/pull")
|
||
public Result<String> manualPull(
|
||
@RequestParam(value = "seq", required = false) Long seq,
|
||
@RequestParam(value = "limit", defaultValue = "1000") Integer limit) {
|
||
|
||
long startSeq = seq != null ? seq : archivePullService.getLastSeq();
|
||
log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit);
|
||
|
||
var messages = archivePullService.pullMessages(startSeq, limit);
|
||
archivePullService.saveAndNotify(messages);
|
||
|
||
return Result.success("拉取完成,共" + messages.size() + "条消息");
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 对话服务(conversation-service)
|
||
|
||
负责对话上下文管理、轮次解析和状态追踪。
|
||
|
||
#### 3.4.1 对话管理服务
|
||
|
||
```java
|
||
package com.artedu.conversation.service;
|
||
|
||
import com.artedu.common.exception.BusinessException;
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.artedu.conversation.entity.Conversation;
|
||
import com.artedu.conversation.entity.ConversationTurn;
|
||
import com.artedu.conversation.mapper.ConversationMapper;
|
||
import com.artedu.conversation.mapper.ConversationTurnMapper;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.time.temporal.ChronoUnit;
|
||
import java.util.List;
|
||
import java.util.concurrent.TimeUnit;
|
||
|
||
/**
|
||
* 对话管理服务
|
||
* 管理会话生命周期、上下文窗口、轮次解析
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class ConversationManager {
|
||
|
||
@Autowired
|
||
private ConversationMapper conversationMapper;
|
||
|
||
@Autowired
|
||
private ConversationTurnMapper conversationTurnMapper;
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
private static final String CONV_CONTEXT_KEY = "conv:context:";
|
||
private static final int MAX_CONTEXT_ROUNDS = 10;
|
||
private static final int CONTEXT_EXPIRE_MINUTES = 30;
|
||
|
||
// ========== 会话管理 ==========
|
||
|
||
/**
|
||
* 获取或创建会话
|
||
*/
|
||
public Conversation getOrCreateSession(String sessionId, String corpId,
|
||
String customerId, String staffId) {
|
||
// 先从数据库查询
|
||
Conversation conv = conversationMapper.selectOne(
|
||
new LambdaQueryWrapper<Conversation>()
|
||
.eq(Conversation::getSessionId, sessionId)
|
||
);
|
||
|
||
if (conv == null) {
|
||
// 创建新会话
|
||
conv = new Conversation();
|
||
conv.setSessionId(sessionId);
|
||
conv.setCorpId(corpId);
|
||
conv.setCustomerId(customerId);
|
||
conv.setStaffId(staffId);
|
||
conv.setStatus("ACTIVE");
|
||
conv.setCurrentStage("开场白");
|
||
conv.setStageConfidence(0.0);
|
||
conv.setRoundCount(0);
|
||
conv.setStartTime(LocalDateTime.now());
|
||
conversationMapper.insert(conv);
|
||
log.info("创建新会话: sessionId={}", sessionId);
|
||
}
|
||
|
||
return conv;
|
||
}
|
||
|
||
/**
|
||
* 更新会话状态
|
||
*/
|
||
public void updateConversationStatus(String sessionId, String stage,
|
||
Double confidence, String summary) {
|
||
Conversation conv = conversationMapper.selectOne(
|
||
new LambdaQueryWrapper<Conversation>()
|
||
.eq(Conversation::getSessionId, sessionId)
|
||
);
|
||
if (conv != null) {
|
||
conv.setCurrentStage(stage);
|
||
if (confidence != null) {
|
||
conv.setStageConfidence(confidence);
|
||
}
|
||
if (summary != null) {
|
||
conv.setContextSummary(summary);
|
||
}
|
||
conv.setUpdatedAt(LocalDateTime.now());
|
||
conversationMapper.updateById(conv);
|
||
}
|
||
}
|
||
|
||
// ========== 上下文窗口管理 ==========
|
||
|
||
/**
|
||
* 添加消息到上下文窗口
|
||
*/
|
||
public void addMessageToContext(String sessionId, String role, String content, Long msgTime) {
|
||
String key = CONV_CONTEXT_KEY + sessionId;
|
||
|
||
// 构建消息对象
|
||
ContextMessage message = new ContextMessage(role, content, msgTime);
|
||
String messageJson = JsonUtils.toJson(message);
|
||
|
||
// 使用Redis List存储,右侧插入
|
||
redisTemplate.opsForList().rightPush(key, messageJson);
|
||
|
||
// 限制窗口大小(保留最近20条消息 = 10轮对话)
|
||
Long size = redisTemplate.opsForList().size(key);
|
||
if (size != null && size > MAX_CONTEXT_ROUNDS * 2) {
|
||
redisTemplate.opsForList().trim(key, -MAX_CONTEXT_ROUNDS * 2, -1);
|
||
}
|
||
|
||
// 刷新过期时间
|
||
redisTemplate.expire(key, CONTEXT_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||
}
|
||
|
||
/**
|
||
* 获取上下文窗口中的消息
|
||
*/
|
||
public List<ContextMessage> getContextMessages(String sessionId) {
|
||
String key = CONV_CONTEXT_KEY + sessionId;
|
||
List<Object> jsonList = redisTemplate.opsForList().range(key, 0, -1);
|
||
if (jsonList == null) {
|
||
return List.of();
|
||
}
|
||
return jsonList.stream()
|
||
.map(obj -> JsonUtils.fromJson((String) obj, ContextMessage.class))
|
||
.filter(msg -> msg != null)
|
||
.toList();
|
||
}
|
||
|
||
/**
|
||
* 获取上下文字符串(用于Prompt)
|
||
*/
|
||
public String getContextString(String sessionId) {
|
||
List<ContextMessage> messages = getContextMessages(sessionId);
|
||
StringBuilder sb = new StringBuilder();
|
||
for (ContextMessage msg : messages) {
|
||
String roleName = "student".equals(msg.getRole()) ? "学员" : "顾问";
|
||
sb.append(roleName).append(": ").append(msg.getContent()).append("\n");
|
||
}
|
||
return sb.toString();
|
||
}
|
||
|
||
/**
|
||
* 清空上下文窗口
|
||
*/
|
||
public void clearContext(String sessionId) {
|
||
String key = CONV_CONTEXT_KEY + sessionId;
|
||
redisTemplate.delete(key);
|
||
}
|
||
|
||
// ========== 轮次管理 ==========
|
||
|
||
/**
|
||
* 创建对话轮次
|
||
*/
|
||
public ConversationTurn createTurn(String sessionId, int turnNumber,
|
||
String studentMsgId, String studentContent,
|
||
String turnType) {
|
||
ConversationTurn turn = new ConversationTurn();
|
||
turn.setTurnId(sessionId + "_" + turnNumber);
|
||
turn.setSessionId(sessionId);
|
||
turn.setTurnNumber(turnNumber);
|
||
turn.setStudentMsgId(studentMsgId);
|
||
turn.setStudentContent(studentContent);
|
||
turn.setTurnType(turnType != null ? turnType : "QUERY");
|
||
turn.setCreatedAt(LocalDateTime.now());
|
||
conversationTurnMapper.insert(turn);
|
||
|
||
// 更新会话轮次计数
|
||
Conversation conv = conversationMapper.selectOne(
|
||
new LambdaQueryWrapper<Conversation>()
|
||
.eq(Conversation::getSessionId, sessionId)
|
||
);
|
||
if (conv != null) {
|
||
conv.setRoundCount(turnNumber);
|
||
conv.setUpdatedAt(LocalDateTime.now());
|
||
conversationMapper.updateById(conv);
|
||
}
|
||
|
||
return turn;
|
||
}
|
||
|
||
/**
|
||
* 更新坐席回复
|
||
*/
|
||
public void updateSeatReply(String turnId, String seatMsgId, String seatContent) {
|
||
ConversationTurn turn = conversationTurnMapper.selectOne(
|
||
new LambdaQueryWrapper<ConversationTurn>()
|
||
.eq(ConversationTurn::getTurnId, turnId)
|
||
);
|
||
if (turn != null) {
|
||
turn.setSeatMsgId(seatMsgId);
|
||
turn.setSeatContent(seatContent);
|
||
conversationTurnMapper.updateById(turn);
|
||
}
|
||
}
|
||
|
||
// ========== 消息事件处理 ==========
|
||
|
||
/**
|
||
* 监听存档新消息事件
|
||
*/
|
||
@RabbitListener(queues = "archive.new_message.queue")
|
||
public void handleNewMessage(String messageJson) {
|
||
try {
|
||
ArchiveMessageEvent event = JsonUtils.fromJson(messageJson, ArchiveMessageEvent.class);
|
||
if (event == null) {
|
||
return;
|
||
}
|
||
|
||
log.debug("收到新消息事件: sessionId={}, msgType={}",
|
||
event.getSessionId(), event.getMsgtype());
|
||
|
||
// 1. 获取或创建会话
|
||
Conversation conv = getOrCreateSession(
|
||
event.getSessionId(),
|
||
event.getCorpId(),
|
||
event.getFromUser(),
|
||
event.getToUser()
|
||
);
|
||
|
||
// 2. 添加消息到上下文
|
||
String role = "EXTERNAL".equals(event.getFromRole()) ? "student" : "seat";
|
||
addMessageToContext(event.getSessionId(), role, event.getContent(), event.getMsgtime());
|
||
|
||
// 3. 更新会话最后消息
|
||
conv.setLastMsgId(event.getMsgid());
|
||
conv.setLastMsgTime(LocalDateTime.now());
|
||
conv.setLastMsgContent(truncateContent(event.getContent(), 200));
|
||
conv.setIdleDuration(0);
|
||
conversationMapper.updateById(conv);
|
||
|
||
// 4. 如果是学员消息,创建新轮次
|
||
if ("student".equals(role)) {
|
||
String turnType = detectTurnType(event.getContent());
|
||
createTurn(event.getSessionId(), conv.getRoundCount() + 1,
|
||
event.getMsgid(), event.getContent(), turnType);
|
||
} else {
|
||
// 坐席回复,更新当前轮次
|
||
updateLastTurn(conv.getSessionId(), conv.getRoundCount(),
|
||
event.getMsgid(), event.getContent());
|
||
}
|
||
|
||
} catch (Exception e) {
|
||
log.error("处理新消息事件失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检测轮次类型
|
||
*/
|
||
private String detectTurnType(String content) {
|
||
if (content == null || content.isEmpty()) {
|
||
return "QUERY";
|
||
}
|
||
String lower = content.toLowerCase();
|
||
if (lower.contains("贵") || lower.contains("贵") || lower.contains("考虑") || lower.contains("担心")) {
|
||
return "OBJECTION";
|
||
}
|
||
if (lower.contains("报名") || lower.contains("报名") || lower.contains("决定") || lower.contains("确定")) {
|
||
return "INTEREST";
|
||
}
|
||
if (lower.contains("?") || lower.contains("?") || lower.contains("吗")) {
|
||
return "QUERY";
|
||
}
|
||
return "QUERY";
|
||
}
|
||
|
||
private void updateLastTurn(String sessionId, int roundNumber, String seatMsgId, String content) {
|
||
ConversationTurn turn = conversationTurnMapper.selectOne(
|
||
new LambdaQueryWrapper<ConversationTurn>()
|
||
.eq(ConversationTurn::getSessionId, sessionId)
|
||
.eq(ConversationTurn::getTurnNumber, roundNumber)
|
||
);
|
||
if (turn != null) {
|
||
turn.setSeatMsgId(seatMsgId);
|
||
turn.setSeatContent(content);
|
||
conversationTurnMapper.updateById(turn);
|
||
}
|
||
}
|
||
|
||
private String truncateContent(String content, int maxLength) {
|
||
if (content == null) {
|
||
return "";
|
||
}
|
||
return content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
|
||
}
|
||
|
||
// ========== 内部消息类 ==========
|
||
|
||
/**
|
||
* 存档消息事件
|
||
*/
|
||
public static class ArchiveMessageEvent {
|
||
private String msgid;
|
||
private String corpId;
|
||
private String sessionId;
|
||
private String fromUser;
|
||
private String fromRole;
|
||
private String toUser;
|
||
private String msgtype;
|
||
private String content;
|
||
private Long msgtime;
|
||
// getters and setters
|
||
public String getMsgid() { return msgid; }
|
||
public void setMsgid(String msgid) { this.msgid = msgid; }
|
||
public String getCorpId() { return corpId; }
|
||
public void setCorpId(String corpId) { this.corpId = corpId; }
|
||
public String getSessionId() { return sessionId; }
|
||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||
public String getFromUser() { return fromUser; }
|
||
public void setFromUser(String fromUser) { this.fromUser = fromUser; }
|
||
public String getFromRole() { return fromRole; }
|
||
public void setFromRole(String fromRole) { this.fromRole = fromRole; }
|
||
public String getToUser() { return toUser; }
|
||
public void setToUser(String toUser) { this.toUser = toUser; }
|
||
public String getMsgtype() { return msgtype; }
|
||
public void setMsgtype(String msgtype) { this.msgtype = msgtype; }
|
||
public String getContent() { return content; }
|
||
public void setContent(String content) { this.content = content; }
|
||
public Long getMsgtime() { return msgtime; }
|
||
public void setMsgtime(Long msgtime) { this.msgtime = msgtime; }
|
||
}
|
||
|
||
/**
|
||
* 上下文消息
|
||
*/
|
||
public static class ContextMessage {
|
||
private String role;
|
||
private String content;
|
||
private Long msgTime;
|
||
|
||
public ContextMessage() {}
|
||
|
||
public ContextMessage(String role, String content, Long msgTime) {
|
||
this.role = role;
|
||
this.content = content;
|
||
this.msgTime = msgTime;
|
||
}
|
||
|
||
public String getRole() { return role; }
|
||
public void setRole(String role) { this.role = role; }
|
||
public String getContent() { return content; }
|
||
public void setContent(String content) { this.content = content; }
|
||
public Long getMsgTime() { return msgTime; }
|
||
public void setMsgTime(Long msgTime) { this.msgTime = msgTime; }
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.4.2 对话Controller
|
||
|
||
```java
|
||
package com.artedu.conversation.controller;
|
||
|
||
import com.artedu.common.result.Result;
|
||
import com.artedu.conversation.entity.Conversation;
|
||
import com.artedu.conversation.service.ConversationManager;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
import java.util.HashMap;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 对话管理控制器
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/conversations")
|
||
public class ConversationController {
|
||
|
||
@Autowired
|
||
private ConversationManager conversationManager;
|
||
|
||
/**
|
||
* 获取对话上下文
|
||
*/
|
||
@GetMapping("/{sessionId}/context")
|
||
public Result<Map<String, Object>> getContext(@PathVariable("sessionId") String sessionId) {
|
||
String contextStr = conversationManager.getContextString(sessionId);
|
||
var messages = conversationManager.getContextMessages(sessionId);
|
||
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("sessionId", sessionId);
|
||
result.put("context", contextStr);
|
||
result.put("messageCount", messages.size());
|
||
result.put("messages", messages);
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 获取会话信息
|
||
*/
|
||
@GetMapping("/{sessionId}")
|
||
public Result<Conversation> getConversation(@PathVariable("sessionId") String sessionId) {
|
||
// TODO: 实现查询
|
||
return Result.success();
|
||
}
|
||
|
||
/**
|
||
* 更新会话状态
|
||
*/
|
||
@PutMapping("/{sessionId}/status")
|
||
public Result<String> updateStatus(
|
||
@PathVariable("sessionId") String sessionId,
|
||
@RequestParam("stage") String stage,
|
||
@RequestParam(value = "confidence", required = false) Double confidence,
|
||
@RequestParam(value = "summary", required = false) String summary) {
|
||
|
||
conversationManager.updateConversationStatus(sessionId, stage, confidence, summary);
|
||
return Result.success("状态更新成功");
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.5 意图服务(intent-service)
|
||
|
||
负责意图识别(40场景分类)、学员画像构建和槽位填充。
|
||
|
||
#### 3.5.1 意图识别服务
|
||
|
||
```java
|
||
package com.artedu.intent.service;
|
||
|
||
import com.artedu.common.config.RabbitConfig;
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.artedu.intent.entity.CustomerProfile;
|
||
import com.artedu.intent.entity.IntentCategory;
|
||
import com.artedu.intent.entity.IntentRecognition;
|
||
import com.artedu.intent.mapper.CustomerProfileMapper;
|
||
import com.artedu.intent.mapper.IntentCategoryMapper;
|
||
import com.artedu.intent.mapper.IntentRecognitionMapper;
|
||
import com.artedu.intent.prompt.IntentPromptTemplate;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.UUID;
|
||
import java.util.concurrent.TimeUnit;
|
||
|
||
/**
|
||
* 意图识别服务
|
||
* 基于规则+LLM混合策略进行40场景意图分类
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class IntentRecognitionService {
|
||
|
||
@Autowired
|
||
private IntentCategoryMapper intentCategoryMapper;
|
||
|
||
@Autowired
|
||
private IntentRecognitionMapper intentRecognitionMapper;
|
||
|
||
@Autowired
|
||
private CustomerProfileMapper customerProfileMapper;
|
||
|
||
@Autowired
|
||
private LLMIntentClient llmIntentClient;
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
@Autowired
|
||
private RabbitTemplate rabbitTemplate;
|
||
|
||
private static final String INTENT_CACHE_KEY = "intent:";
|
||
private static final String RULE_MATCH_THRESHOLD = "0.85";
|
||
|
||
/**
|
||
* 核心方法:识别意图
|
||
* 采用规则匹配 → LLM识别的混合策略
|
||
*/
|
||
public IntentResult recognize(String message, String contextSummary,
|
||
String customerId, String corpId, String staffId) {
|
||
long startTime = System.currentTimeMillis();
|
||
|
||
// 1. 检查缓存(同会话5分钟内相同消息命中缓存)
|
||
String cacheKey = INTENT_CACHE_KEY + customerId + ":" + message.hashCode();
|
||
IntentResult cached = (IntentResult) redisTemplate.opsForValue().get(cacheKey);
|
||
if (cached != null) {
|
||
log.debug("意图识别命中缓存: customerId={}", customerId);
|
||
return cached;
|
||
}
|
||
|
||
// 2. 快速规则匹配(<50ms)
|
||
IntentResult ruleResult = ruleMatch(message);
|
||
if (ruleResult != null && ruleResult.getConfidence() > 0.85) {
|
||
log.debug("意图识别规则匹配命中: intent={}, confidence={}",
|
||
ruleResult.getPrimaryIntent().getCode(), ruleResult.getConfidence());
|
||
|
||
// 保存识别记录
|
||
saveRecognition(message, contextSummary, customerId, corpId, staffId, ruleResult, "RULE", startTime);
|
||
|
||
// 更新学员画像
|
||
updateProfile(customerId, corpId, ruleResult);
|
||
|
||
// 缓存结果
|
||
redisTemplate.opsForValue().set(cacheKey, ruleResult, 5, TimeUnit.MINUTES);
|
||
|
||
return ruleResult;
|
||
}
|
||
|
||
// 3. LLM识别(200-500ms)
|
||
IntentResult llmResult = llmIntentClient.recognize(message, contextSummary, getAllCategories());
|
||
|
||
// 4. 规则校准(如果规则匹配了但置信度不高,用LLM结果覆盖)
|
||
if (ruleResult != null && llmResult != null) {
|
||
if (ruleResult.getPrimaryIntent().getCode()
|
||
.equals(llmResult.getPrimaryIntent().getCode())) {
|
||
// 规则与LLM一致,提升置信度
|
||
llmResult.setConfidence(Math.min(1.0, llmResult.getConfidence() + 0.05));
|
||
}
|
||
}
|
||
|
||
// 5. 保存识别记录
|
||
saveRecognition(message, contextSummary, customerId, corpId, staffId, llmResult, "HYBRID", startTime);
|
||
|
||
// 6. 更新学员画像
|
||
updateProfile(customerId, corpId, llmResult);
|
||
|
||
// 7. 缓存结果
|
||
redisTemplate.opsForValue().set(cacheKey, llmResult, 5, TimeUnit.MINUTES);
|
||
|
||
// 8. 发送推荐事件
|
||
sendRecommendEvent(customerId, corpId, staffId, llmResult, contextSummary);
|
||
|
||
log.info("意图识别完成: intent={}, confidence={}, elapsed={}ms",
|
||
llmResult.getPrimaryIntent().getCode(),
|
||
llmResult.getConfidence(),
|
||
System.currentTimeMillis() - startTime);
|
||
|
||
return llmResult;
|
||
}
|
||
|
||
/**
|
||
* 规则匹配:基于关键词快速匹配
|
||
*/
|
||
private IntentResult ruleMatch(String message) {
|
||
if (message == null || message.isEmpty()) {
|
||
return null;
|
||
}
|
||
String lowerMsg = message.toLowerCase();
|
||
|
||
// 获取所有启用的意图分类
|
||
List<IntentCategory> categories = intentCategoryMapper.selectList(
|
||
new LambdaQueryWrapper<IntentCategory>()
|
||
.eq(IntentCategory::getStatus, 1)
|
||
);
|
||
|
||
IntentCategory bestMatch = null;
|
||
double bestScore = 0;
|
||
|
||
for (IntentCategory category : categories) {
|
||
// 解析触发关键词
|
||
List<String> keywords = JsonUtils.fromJsonList(
|
||
category.getTriggerKeywords(), String.class);
|
||
if (keywords == null || keywords.isEmpty()) {
|
||
continue;
|
||
}
|
||
|
||
// 计算匹配分数
|
||
int matchCount = 0;
|
||
for (String keyword : keywords) {
|
||
if (lowerMsg.contains(keyword.toLowerCase())) {
|
||
matchCount++;
|
||
}
|
||
}
|
||
|
||
if (matchCount > 0) {
|
||
double score = (double) matchCount / keywords.size();
|
||
// 根据优先级调整分数
|
||
if ("P1".equals(category.getPriority())) {
|
||
score *= 1.1;
|
||
}
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestMatch = category;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bestMatch != null && bestScore > 0.3) {
|
||
IntentResult result = new IntentResult();
|
||
IntentResult.IntentInfo intentInfo = new IntentResult.IntentInfo();
|
||
intentInfo.setCode(bestMatch.getCode());
|
||
intentInfo.setName(bestMatch.getName());
|
||
intentInfo.setDomain(bestMatch.getDomain());
|
||
intentInfo.setConfidence(Math.min(1.0, bestScore));
|
||
result.setPrimaryIntent(intentInfo);
|
||
result.setConfidence(bestScore);
|
||
return result;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 保存意图识别记录
|
||
*/
|
||
private void saveRecognition(String message, String contextSummary, String customerId,
|
||
String corpId, String staffId, IntentResult result,
|
||
String method, long startTime) {
|
||
try {
|
||
IntentRecognition record = new IntentRecognition();
|
||
record.setSessionId(contextSummary); // 简化处理
|
||
record.setMsgId(UUID.randomUUID().toString());
|
||
record.setCustomerId(customerId);
|
||
record.setStaffId(staffId);
|
||
record.setMessageContent(message.length() > 200 ? message.substring(0, 200) : message);
|
||
record.setPrimaryIntentCode(result.getPrimaryIntent().getCode());
|
||
record.setPrimaryIntentConfidence(result.getPrimaryIntent().getConfidence());
|
||
record.setSecondaryIntents(JsonUtils.toJson(result.getSecondaryIntents()));
|
||
record.setSentiment(result.getSentiment());
|
||
record.setRecognitionMethod(method);
|
||
record.setElapsedMs((int) (System.currentTimeMillis() - startTime));
|
||
record.setCreatedAt(LocalDateTime.now());
|
||
|
||
intentRecognitionMapper.insert(record);
|
||
} catch (Exception e) {
|
||
log.error("保存意图识别记录失败: {}", e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新学员画像
|
||
*/
|
||
private void updateProfile(String customerId, String corpId, IntentResult result) {
|
||
try {
|
||
CustomerProfile profile = customerProfileMapper.selectOne(
|
||
new LambdaQueryWrapper<CustomerProfile>()
|
||
.eq(CustomerProfile::getCustomerId, customerId)
|
||
.eq(CustomerProfile::getCorpId, corpId)
|
||
);
|
||
|
||
if (profile == null) {
|
||
profile = new CustomerProfile();
|
||
profile.setCustomerId(customerId);
|
||
profile.setCorpId(corpId);
|
||
profile.setIntentLevel("低");
|
||
profile.setIntentScore(0.0);
|
||
profile.setDecisionStage("信息了解");
|
||
profile.setConversationCount(1);
|
||
}
|
||
|
||
// 根据意图更新画像
|
||
String intentCode = result.getPrimaryIntent().getCode();
|
||
double score = result.getPrimaryIntent().getConfidence();
|
||
|
||
// 更新意向度
|
||
updateIntentScore(profile, intentCode, score);
|
||
|
||
// 根据意图更新关注重点
|
||
updateConcernFocus(profile, intentCode);
|
||
|
||
// 更新决策阶段
|
||
updateDecisionStage(profile, intentCode);
|
||
|
||
profile.setLastConversationTime(LocalDateTime.now());
|
||
profile.setUpdatedAt(LocalDateTime.now());
|
||
|
||
if (profile.getId() == null) {
|
||
customerProfileMapper.insert(profile);
|
||
} else {
|
||
customerProfileMapper.updateById(profile);
|
||
}
|
||
|
||
} catch (Exception e) {
|
||
log.error("更新学员画像失败: {}", e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新意向度分数
|
||
*/
|
||
private void updateIntentScore(CustomerProfile profile, String intentCode, double score) {
|
||
double currentScore = profile.getIntentScore() != null ? profile.getIntentScore() : 0;
|
||
double addScore = 0;
|
||
|
||
// 根据意图类型增加分数
|
||
if (intentCode.startsWith("INT-PRICE")) {
|
||
addScore = 15; // 询问价格 → 中高意向
|
||
} else if (intentCode.startsWith("INT-COURSE")) {
|
||
addScore = 10; // 询问课程 → 中等意向
|
||
} else if (intentCode.startsWith("INT-JOB")) {
|
||
addScore = 10; // 询问就业 → 中等意向
|
||
} else if (intentCode.startsWith("INT-TEACH")) {
|
||
addScore = 8; // 询问师资 → 中低意向
|
||
} else if (intentCode.startsWith("INT-LOC")) {
|
||
addScore = 12; // 询问校区 → 接近决策
|
||
} else if (intentCode.startsWith("INT-QUAL")) {
|
||
addScore = 5; // 询问资质 → 初步了解
|
||
} else {
|
||
addScore = 5;
|
||
}
|
||
|
||
// 加权
|
||
addScore *= score;
|
||
double newScore = currentScore + addScore;
|
||
profile.setIntentScore(newScore);
|
||
|
||
// 更新意向度等级
|
||
if (newScore >= 60) {
|
||
profile.setIntentLevel("高");
|
||
} else if (newScore >= 30) {
|
||
profile.setIntentLevel("中");
|
||
} else if (newScore >= 0) {
|
||
profile.setIntentLevel("低");
|
||
} else {
|
||
profile.setIntentLevel("无意向");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新关注重点
|
||
*/
|
||
private void updateConcernFocus(CustomerProfile profile, String intentCode) {
|
||
String focus = profile.getConcernFocus();
|
||
|
||
if (intentCode.startsWith("INT-PRICE")) {
|
||
focus = "价格敏感型";
|
||
} else if (intentCode.startsWith("INT-JOB")) {
|
||
focus = "就业导向型";
|
||
} else if (intentCode.startsWith("INT-TEACH")) {
|
||
focus = "师资看重型";
|
||
} else if (intentCode.startsWith("INT-COURSE")) {
|
||
if (focus == null) {
|
||
focus = "时间灵活型";
|
||
}
|
||
} else if (intentCode.startsWith("INT-QUAL")) {
|
||
focus = "品牌信任型";
|
||
}
|
||
|
||
profile.setConcernFocus(focus);
|
||
}
|
||
|
||
/**
|
||
* 更新决策阶段
|
||
*/
|
||
private void updateDecisionStage(CustomerProfile profile, String intentCode) {
|
||
String currentStage = profile.getDecisionStage();
|
||
if (currentStage == null) {
|
||
currentStage = "信息了解";
|
||
}
|
||
|
||
// 阶段推进逻辑
|
||
if (intentCode.startsWith("INT-PRICE") && "信息了解".equals(currentStage)) {
|
||
profile.setDecisionStage("方案比较");
|
||
} else if (intentCode.startsWith("INT-LOC") && "方案比较".equals(currentStage)) {
|
||
profile.setDecisionStage("购买决策");
|
||
} else if (intentCode.contains("试听") && "购买决策".equals(currentStage)) {
|
||
profile.setDecisionStage("报名成交");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送推荐事件
|
||
*/
|
||
private void sendRecommendEvent(String customerId, String corpId, String staffId,
|
||
IntentResult result, String contextSummary) {
|
||
try {
|
||
Map<String, Object> event = Map.of(
|
||
"customerId", customerId,
|
||
"corpId", corpId,
|
||
"staffId", staffId,
|
||
"primaryIntent", result.getPrimaryIntent(),
|
||
"secondaryIntents", result.getSecondaryIntents(),
|
||
"contextSummary", contextSummary,
|
||
"timestamp", System.currentTimeMillis()
|
||
);
|
||
|
||
rabbitTemplate.convertAndSend(
|
||
RabbitConfig.CONVERSATION_EXCHANGE,
|
||
RabbitConfig.ROUTING_KEY_RECOMMEND,
|
||
JsonUtils.toJson(event)
|
||
);
|
||
} catch (Exception e) {
|
||
log.error("发送推荐事件失败: {}", e.getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有意图分类
|
||
*/
|
||
private List<IntentCategory> getAllCategories() {
|
||
return intentCategoryMapper.selectList(
|
||
new LambdaQueryWrapper<IntentCategory>()
|
||
.eq(IntentCategory::getStatus, 1)
|
||
);
|
||
}
|
||
|
||
// ========== 内部类 ==========
|
||
|
||
/**
|
||
* 意图识别结果
|
||
*/
|
||
public static class IntentResult {
|
||
private IntentInfo primaryIntent;
|
||
private List<IntentInfo> secondaryIntents;
|
||
private Double confidence;
|
||
private String sentiment;
|
||
private String explanation;
|
||
|
||
// getters and setters
|
||
public IntentInfo getPrimaryIntent() { return primaryIntent; }
|
||
public void setPrimaryIntent(IntentInfo primaryIntent) { this.primaryIntent = primaryIntent; }
|
||
public List<IntentInfo> getSecondaryIntents() { return secondaryIntents; }
|
||
public void setSecondaryIntents(List<IntentInfo> secondaryIntents) { this.secondaryIntents = secondaryIntents; }
|
||
public Double getConfidence() { return confidence; }
|
||
public void setConfidence(Double confidence) { this.confidence = confidence; }
|
||
public String getSentiment() { return sentiment; }
|
||
public void setSentiment(String sentiment) { this.sentiment = sentiment; }
|
||
public String getExplanation() { return explanation; }
|
||
public void setExplanation(String explanation) { this.explanation = explanation; }
|
||
|
||
public static class IntentInfo {
|
||
private String code;
|
||
private String name;
|
||
private String domain;
|
||
private Double confidence;
|
||
|
||
public String getCode() { return code; }
|
||
public void setCode(String code) { this.code = code; }
|
||
public String getName() { return name; }
|
||
public void setName(String name) { this.name = name; }
|
||
public String getDomain() { return domain; }
|
||
public void setDomain(String domain) { this.domain = domain; }
|
||
public Double getConfidence() { return confidence; }
|
||
public void setConfidence(Double confidence) { this.confidence = confidence; }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.2 LLM意图识别客户端
|
||
|
||
```java
|
||
package com.artedu.intent.service;
|
||
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.artedu.intent.entity.IntentCategory;
|
||
import com.artedu.intent.prompt.IntentPromptTemplate;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.*;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* LLM意图识别客户端
|
||
* 调用通义千问API进行意图识别
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class LLMIntentClient {
|
||
|
||
@Value("${llm.qianwen.api-key}")
|
||
private String apiKey;
|
||
|
||
@Value("${llm.qianwen.model:qwen-turbo}")
|
||
private String model;
|
||
|
||
@Autowired
|
||
private RestTemplate restTemplate;
|
||
|
||
private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||
|
||
/**
|
||
* 调用LLM进行意图识别
|
||
*/
|
||
public IntentRecognitionService.IntentResult recognize(
|
||
String message, String contextSummary, List<IntentCategory> categories) {
|
||
|
||
// 构建Prompt
|
||
String prompt = IntentPromptTemplate.buildIntentPrompt(message, contextSummary, categories);
|
||
|
||
// 调用通义千问API
|
||
String response = callQianwen(prompt);
|
||
|
||
// 解析响应
|
||
return parseResponse(response);
|
||
}
|
||
|
||
/**
|
||
* 调用通义千问API
|
||
*/
|
||
private String callQianwen(String prompt) {
|
||
try {
|
||
// 构建请求体
|
||
Map<String, Object> requestBody = Map.of(
|
||
"model", model,
|
||
"input", Map.of("prompt", prompt),
|
||
"parameters", Map.of(
|
||
"result_format", "json",
|
||
"max_tokens", 1500,
|
||
"temperature", 0.3
|
||
)
|
||
);
|
||
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("Authorization", "Bearer " + apiKey);
|
||
|
||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||
|
||
ResponseEntity<String> response = restTemplate.exchange(
|
||
API_URL,
|
||
HttpMethod.POST,
|
||
request,
|
||
String.class
|
||
);
|
||
|
||
return response.getBody();
|
||
|
||
} catch (Exception e) {
|
||
log.error("调用通义千问API失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析LLM响应
|
||
*/
|
||
private IntentRecognitionService.IntentResult parseResponse(String response) {
|
||
IntentRecognitionService.IntentResult result = new IntentRecognitionService.IntentResult();
|
||
|
||
try {
|
||
Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
|
||
if (responseMap == null) {
|
||
return createDefaultResult();
|
||
}
|
||
|
||
// 提取output
|
||
Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
|
||
if (output == null) {
|
||
return createDefaultResult();
|
||
}
|
||
|
||
// 提取JSON结果
|
||
String jsonText = (String) output.get("text");
|
||
if (jsonText == null) {
|
||
return createDefaultResult();
|
||
}
|
||
|
||
// 解析意图结果
|
||
Map<String, Object> intentData = JsonUtils.fromJsonMap(jsonText);
|
||
if (intentData == null) {
|
||
return createDefaultResult();
|
||
}
|
||
|
||
// 主意图
|
||
Map<String, Object> primaryIntentMap = (Map<String, Object>) intentData.get("primary_intent");
|
||
if (primaryIntentMap != null) {
|
||
IntentRecognitionService.IntentResult.IntentInfo primary = new IntentRecognitionService.IntentResult.IntentInfo();
|
||
primary.setCode((String) primaryIntentMap.get("code"));
|
||
primary.setName((String) primaryIntentMap.get("name"));
|
||
primary.setConfidence(((Number) primaryIntentMap.get("confidence")).doubleValue());
|
||
result.setPrimaryIntent(primary);
|
||
result.setConfidence(primary.getConfidence());
|
||
}
|
||
|
||
// 辅意图
|
||
List<Map<String, Object>> secondaryList = (List<Map<String, Object>>) intentData.get("secondary_intents");
|
||
if (secondaryList != null) {
|
||
List<IntentRecognitionService.IntentResult.IntentInfo> secondaryIntents = new ArrayList<>();
|
||
for (Map<String, Object> sec : secondaryList) {
|
||
IntentRecognitionService.IntentResult.IntentInfo info = new IntentRecognitionService.IntentResult.IntentInfo();
|
||
info.setCode((String) sec.get("code"));
|
||
info.setName((String) sec.get("name"));
|
||
info.setConfidence(((Number) sec.get("confidence")).doubleValue());
|
||
secondaryIntents.add(info);
|
||
}
|
||
result.setSecondaryIntents(secondaryIntents);
|
||
}
|
||
|
||
// 情感
|
||
result.setSentiment((String) intentData.getOrDefault("sentiment", "NEUTRAL"));
|
||
result.setExplanation((String) intentData.get("explanation"));
|
||
|
||
return result;
|
||
|
||
} catch (Exception e) {
|
||
log.error("解析LLM响应失败: {}", e.getMessage(), e);
|
||
return createDefaultResult();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建默认结果
|
||
*/
|
||
private IntentRecognitionService.IntentResult createDefaultResult() {
|
||
IntentRecognitionService.IntentResult result = new IntentRecognitionService.IntentResult();
|
||
IntentRecognitionService.IntentResult.IntentInfo intent = new IntentRecognitionService.IntentResult.IntentInfo();
|
||
intent.setCode("INT-COURSE-01");
|
||
intent.setName("课程内容咨询");
|
||
intent.setConfidence(0.5);
|
||
result.setPrimaryIntent(intent);
|
||
result.setConfidence(0.5);
|
||
result.setSentiment("NEUTRAL");
|
||
result.setSecondaryIntents(new ArrayList<>());
|
||
return result;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.3 意图识别Prompt模板
|
||
|
||
```java
|
||
package com.artedu.intent.prompt;
|
||
|
||
import com.artedu.intent.entity.IntentCategory;
|
||
|
||
import java.util.List;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* 意图识别Prompt模板
|
||
* 构建完整的LLM Prompt,包含角色定义、意图分类、示例等
|
||
*/
|
||
public class IntentPromptTemplate {
|
||
|
||
/**
|
||
* 构建意图识别Prompt
|
||
*/
|
||
public static String buildIntentPrompt(String message, String contextSummary,
|
||
List<IntentCategory> categories) {
|
||
StringBuilder prompt = new StringBuilder();
|
||
|
||
// 1. 角色定义
|
||
prompt.append("# 角色定义\n");
|
||
prompt.append("你是一位CG数字艺术教育行业的资深课程顾问助手,服务于第九联盟(9ART EDU)。\n");
|
||
prompt.append("你的任务是分析学员的最新消息,识别其咨询意图。\n\n");
|
||
|
||
// 2. 意图分类定义
|
||
prompt.append("# 意图分类体系(40个场景)\n\n");
|
||
|
||
// 按领域分组
|
||
String currentDomain = "";
|
||
for (IntentCategory category : categories) {
|
||
if (!category.getDomain().equals(currentDomain)) {
|
||
currentDomain = category.getDomain();
|
||
prompt.append("## ").append(currentDomain).append("\n");
|
||
}
|
||
prompt.append("- ").append(category.getCode())
|
||
.append(" ").append(category.getName())
|
||
.append(": ").append(category.getDescription()).append("\n");
|
||
}
|
||
|
||
// 3. 学员画像体系
|
||
prompt.append("\n# 学员画像体系\n");
|
||
prompt.append("- 学员类型:在校大学生/转行人员/在职提升/高中毕业生/家长代询\n");
|
||
prompt.append("- 基础水平:零基础/有美术基础/相关专业/有从业经验\n");
|
||
prompt.append("- 意向度:高(>=60分)/中(30-59)/低(0-29)/无意向(<0)\n");
|
||
prompt.append("- 关注重点:价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型\n");
|
||
prompt.append("- 决策阶段:信息了解/方案比较/购买决策/报名成交\n");
|
||
|
||
// 4. 情感分类
|
||
prompt.append("\n# 情感分类\n");
|
||
prompt.append("- POSITIVE:积极、感兴趣、认可\n");
|
||
prompt.append("- NEUTRAL:中性、询问信息\n");
|
||
prompt.append("- NEGATIVE:消极、不满、拒绝\n");
|
||
|
||
// 5. 输出格式说明
|
||
prompt.append("\n# 输出格式(严格JSON)\n");
|
||
prompt.append("请严格按以下JSON格式输出,不要添加任何其他内容:\n");
|
||
prompt.append("{\n");
|
||
prompt.append(" \"primary_intent\": {\"code\": \"意图编码\", \"name\": \"意图名称\", \"confidence\": 0.95},\n");
|
||
prompt.append(" \"secondary_intents\": [\n");
|
||
prompt.append(" {\"code\": \"辅意图编码\", \"name\": \"辅意图名称\", \"confidence\": 0.75}\n");
|
||
prompt.append(" ],\n");
|
||
prompt.append(" \"sentiment\": \"POSITIVE/NEUTRAL/NEGATIVE\",\n");
|
||
prompt.append(" \"explanation\": \"意图判断的简要说明\"\n");
|
||
prompt.append("}\n\n");
|
||
|
||
// 6. Few-shot示例
|
||
prompt.append("# 示例\n\n");
|
||
prompt.append("## 示例1\n");
|
||
prompt.append("学员消息:\"我没有美术基础,之前做销售的,想转行学3D建模。你们的课零基础能学吗?\"\n");
|
||
prompt.append("输出:\n");
|
||
prompt.append("{\n");
|
||
prompt.append(" \"primary_intent\": {\"code\": \"INT-COURSE-07\", \"name\": \"课程难度询问\", \"confidence\": 0.92},\n");
|
||
prompt.append(" \"secondary_intents\": [\n");
|
||
prompt.append(" {\"code\": \"INT-COURSE-02\", \"name\": \"课程选择建议\", \"confidence\": 0.70}\n");
|
||
prompt.append(" ],\n");
|
||
prompt.append(" \"sentiment\": \"NEUTRAL\",\n");
|
||
prompt.append(" \"explanation\": \"学员核心关注零基础能否学会(主意图),同时隐含了课程选择需求\"\n");
|
||
prompt.append("}\n\n");
|
||
|
||
prompt.append("## 示例2\n");
|
||
prompt.append("学员消息:\"学费多少钱?\"\n");
|
||
prompt.append("输出:\n");
|
||
prompt.append("{\n");
|
||
prompt.append(" \"primary_intent\": {\"code\": \"INT-PRICE-01\", \"name\": \"学费价格询问\", \"confidence\": 0.98},\n");
|
||
prompt.append(" \"secondary_intents\": [],\n");
|
||
prompt.append(" \"sentiment\": \"NEUTRAL\",\n");
|
||
prompt.append(" \"explanation\": \"直接询问学费价格,意图明确\"\n");
|
||
prompt.append("}\n\n");
|
||
|
||
// 7. 实际输入
|
||
prompt.append("# 实际输入\n\n");
|
||
if (contextSummary != null && !contextSummary.isEmpty()) {
|
||
prompt.append("对话上下文摘要:\"").append(contextSummary).append("\"\n");
|
||
}
|
||
prompt.append("学员最新消息:\"").append(message).append("\"\n\n");
|
||
prompt.append("请输出JSON:");
|
||
|
||
return prompt.toString();
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.4 意图Controller
|
||
|
||
```java
|
||
package com.artedu.intent.controller;
|
||
|
||
import com.artedu.common.result.Result;
|
||
import com.artedu.intent.entity.CustomerProfile;
|
||
import com.artedu.intent.entity.IntentCategory;
|
||
import com.artedu.intent.mapper.CustomerProfileMapper;
|
||
import com.artedu.intent.mapper.IntentCategoryMapper;
|
||
import com.artedu.intent.service.IntentRecognitionService;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 意图识别控制器
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/intent")
|
||
public class IntentController {
|
||
|
||
@Autowired
|
||
private IntentRecognitionService intentRecognitionService;
|
||
|
||
@Autowired
|
||
private IntentCategoryMapper intentCategoryMapper;
|
||
|
||
@Autowired
|
||
private CustomerProfileMapper customerProfileMapper;
|
||
|
||
/**
|
||
* 意图识别接口
|
||
*/
|
||
@PostMapping("/recognize")
|
||
public Result<IntentRecognitionService.IntentResult> recognize(
|
||
@RequestBody IntentRecognizeRequest request) {
|
||
|
||
IntentRecognitionService.IntentResult result = intentRecognitionService.recognize(
|
||
request.getMessage(),
|
||
request.getContextSummary(),
|
||
request.getCustomerId(),
|
||
request.getCorpId(),
|
||
request.getStaffId()
|
||
);
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 获取意图分类列表
|
||
*/
|
||
@GetMapping("/categories")
|
||
public Result<List<IntentCategory>> getCategories() {
|
||
List<IntentCategory> categories = intentCategoryMapper.selectList(
|
||
new LambdaQueryWrapper<IntentCategory>()
|
||
.eq(IntentCategory::getStatus, 1)
|
||
.orderByAsc(IntentCategory::getDomain, IntentCategory::getCode)
|
||
);
|
||
return Result.success(categories);
|
||
}
|
||
|
||
/**
|
||
* 获取学员画像
|
||
*/
|
||
@GetMapping("/profile/{customerId}")
|
||
public Result<CustomerProfile> getProfile(
|
||
@PathVariable("customerId") String customerId,
|
||
@RequestParam("corpId") String corpId) {
|
||
CustomerProfile profile = customerProfileMapper.selectOne(
|
||
new LambdaQueryWrapper<CustomerProfile>()
|
||
.eq(CustomerProfile::getCustomerId, customerId)
|
||
.eq(CustomerProfile::getCorpId, corpId)
|
||
);
|
||
return Result.success(profile);
|
||
}
|
||
|
||
// 请求DTO
|
||
public static class IntentRecognizeRequest {
|
||
private String message;
|
||
private String contextSummary;
|
||
private String customerId;
|
||
private String corpId;
|
||
private String staffId;
|
||
|
||
// getters and setters
|
||
public String getMessage() { return message; }
|
||
public void setMessage(String message) { this.message = message; }
|
||
public String getContextSummary() { return contextSummary; }
|
||
public void setContextSummary(String contextSummary) { this.contextSummary = contextSummary; }
|
||
public String getCustomerId() { return customerId; }
|
||
public void setCustomerId(String customerId) { this.customerId = customerId; }
|
||
public String getCorpId() { return corpId; }
|
||
public void setCorpId(String corpId) { this.corpId = corpId; }
|
||
public String getStaffId() { return staffId; }
|
||
public void setStaffId(String staffId) { this.staffId = staffId; }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 推荐服务(recommendation-service)
|
||
|
||
负责话术召回、多因子排序、LLM兜底生成。
|
||
|
||
#### 3.6.1 推荐服务
|
||
|
||
```java
|
||
package com.artedu.recommend.service;
|
||
|
||
import com.artedu.common.result.Result;
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.artedu.recommend.entity.Utterance;
|
||
import com.artedu.recommend.mapper.UtteranceMapper;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.util.*;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* 话术推荐服务
|
||
* 三层召回(规则+向量+热门)+ 多因子排序
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class RecommendationService {
|
||
|
||
@Autowired
|
||
private UtteranceMapper utteranceMapper;
|
||
|
||
@Autowired
|
||
private VectorRecallService vectorRecallService;
|
||
|
||
@Autowired
|
||
private LLMGenerateService llmGenerateService;
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
private static final int RECALL_LIMIT = 30; // 召回上限
|
||
private static final int TOP_K = 5; // 输出Top5
|
||
|
||
// 权重配置
|
||
private static final double W_INTENT = 0.35; // 意图匹配权重
|
||
private static final double W_PROFILE = 0.25; // 画像匹配权重
|
||
private static final double W_STAGE = 0.20; // 阶段匹配权重
|
||
private static final double W_SEMANTIC = 0.15; // 语义匹配权重
|
||
private static final double W_HISTORY = 0.05; // 历史成功率权重
|
||
|
||
/**
|
||
* 核心推荐方法
|
||
*/
|
||
public RecommendResult recommend(String message, String intentCode,
|
||
String studentType, String currentStage,
|
||
String customerId, String corpId) {
|
||
long startTime = System.currentTimeMillis();
|
||
|
||
// 1. 三层召回
|
||
List<Utterance> candidates = recall(message, intentCode, studentType, currentStage, corpId);
|
||
|
||
// 2. 去重
|
||
candidates = candidates.stream()
|
||
.distinct()
|
||
.filter(u -> "ACTIVE".equals(u.getStatus()))
|
||
.collect(Collectors.toList());
|
||
|
||
RecommendResult result = new RecommendResult();
|
||
|
||
if (candidates.isEmpty()) {
|
||
// 话术库无匹配,使用LLM兜底生成
|
||
log.info("话术库无匹配,使用LLM兜底生成");
|
||
Utterance generated = llmGenerateService.generate(message, intentCode, studentType, currentStage);
|
||
if (generated != null) {
|
||
result.setGenerationMode("LLM_ONLY");
|
||
result.setRecommendations(List.of(toRecommendItem(generated, 1.0)));
|
||
}
|
||
} else if (candidates.size() < 3) {
|
||
// 话术库匹配不足,LLM补充
|
||
log.info("话术库匹配不足({}条),LLM补充", candidates.size());
|
||
List<RecommendItem> items = rank(candidates, intentCode, studentType, currentStage);
|
||
Utterance generated = llmGenerateService.generate(message, intentCode, studentType, currentStage);
|
||
if (generated != null) {
|
||
items.add(toRecommendItem(generated, 0.85));
|
||
}
|
||
result.setGenerationMode("KB_LLM_MIX");
|
||
result.setRecommendations(items.subList(0, Math.min(TOP_K, items.size())));
|
||
} else {
|
||
// 话术库匹配充足,排序输出
|
||
log.info("话术库匹配充足({}条),多因子排序", candidates.size());
|
||
List<RecommendItem> items = rank(candidates, intentCode, studentType, currentStage);
|
||
result.setGenerationMode("KB_ONLY");
|
||
result.setRecommendations(items.subList(0, Math.min(TOP_K, items.size())));
|
||
}
|
||
|
||
result.setElapsedMs((int) (System.currentTimeMillis() - startTime));
|
||
result.setRecallSources(Map.of(
|
||
"rule", (int) candidates.stream().filter(u -> u.getUtteranceId().startsWith("UTT")).count(),
|
||
"vector", 0,
|
||
"hot", 0
|
||
));
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 三层召回
|
||
*/
|
||
private List<Utterance> recall(String message, String intentCode,
|
||
String studentType, String currentStage, String corpId) {
|
||
List<Utterance> candidates = new ArrayList<>();
|
||
|
||
// 第一层:规则召回(意图码+阶段+学员类型)
|
||
List<Utterance> ruleResults = ruleRecall(intentCode, currentStage, studentType, corpId);
|
||
candidates.addAll(ruleResults);
|
||
log.debug("规则召回: {}条", ruleResults.size());
|
||
|
||
// 第二层:向量召回(语义相似度)
|
||
List<Utterance> vectorResults = vectorRecallService.recall(message, 15);
|
||
candidates.addAll(vectorResults);
|
||
log.debug("向量召回: {}条", vectorResults.size());
|
||
|
||
// 第三层:热门召回(近7天高成功率话术)
|
||
List<Utterance> hotResults = hotRecall(corpId, 8);
|
||
candidates.addAll(hotResults);
|
||
log.debug("热门召回: {}条", hotResults.size());
|
||
|
||
return candidates;
|
||
}
|
||
|
||
/**
|
||
* 规则召回
|
||
*/
|
||
private List<Utterance> ruleRecall(String intentCode, String currentStage,
|
||
String studentType, String corpId) {
|
||
// MySQL 5.7适配:使用LIKE替代JSON_CONTAINS
|
||
LambdaQueryWrapper<Utterance> wrapper = new LambdaQueryWrapper<>();
|
||
wrapper.eq(Utterance::getCorpId, corpId)
|
||
.eq(Utterance::getStatus, "ACTIVE")
|
||
.like(Utterance::getIntentTags, intentCode); // TEXT字段用LIKE匹配
|
||
|
||
if (currentStage != null && !currentStage.isEmpty()) {
|
||
wrapper.like(Utterance::getStageTags, currentStage);
|
||
}
|
||
|
||
// 按成功率降序
|
||
wrapper.orderByDesc(Utterance::getSuccessRate)
|
||
.last("LIMIT 15");
|
||
|
||
return utteranceMapper.selectList(wrapper);
|
||
}
|
||
|
||
/**
|
||
* 热门召回
|
||
*/
|
||
private List<Utterance> hotRecall(String corpId, int limit) {
|
||
LambdaQueryWrapper<Utterance> wrapper = new LambdaQueryWrapper<>();
|
||
wrapper.eq(Utterance::getCorpId, corpId)
|
||
.eq(Utterance::getStatus, "ACTIVE")
|
||
.ge(Utterance::getSuccessRate, 0.3)
|
||
.orderByDesc(Utterance::getUsedCount)
|
||
.last("LIMIT " + limit);
|
||
|
||
return utteranceMapper.selectList(wrapper);
|
||
}
|
||
|
||
/**
|
||
* 多因子排序
|
||
*/
|
||
private List<RecommendItem> rank(List<Utterance> candidates, String intentCode,
|
||
String studentType, String currentStage) {
|
||
List<ScoredUtterance> scoredList = new ArrayList<>();
|
||
|
||
for (Utterance u : candidates) {
|
||
double score = calculateScore(u, intentCode, studentType, currentStage);
|
||
scoredList.add(new ScoredUtterance(u, score));
|
||
}
|
||
|
||
// 按分数降序排序
|
||
scoredList.sort((a, b) -> Double.compare(b.score, a.score));
|
||
|
||
// 取TopK
|
||
return scoredList.stream()
|
||
.limit(TOP_K)
|
||
.map(su -> toRecommendItem(su.utterance, su.score))
|
||
.collect(Collectors.toList());
|
||
}
|
||
|
||
/**
|
||
* 计算多因子得分
|
||
*/
|
||
private double calculateScore(Utterance u, String intentCode,
|
||
String studentType, String currentStage) {
|
||
double intentMatch = calculateIntentMatch(u, intentCode);
|
||
double profileMatch = calculateProfileMatch(u, studentType);
|
||
double stageMatch = calculateStageMatch(u, currentStage);
|
||
double semanticMatch = u.getSuccessRate(); // 用成功率近似语义匹配
|
||
double historyScore = u.getSuccessRate();
|
||
|
||
return W_INTENT * intentMatch
|
||
+ W_PROFILE * profileMatch
|
||
+ W_STAGE * stageMatch
|
||
+ W_SEMANTIC * semanticMatch
|
||
+ W_HISTORY * historyScore;
|
||
}
|
||
|
||
/**
|
||
* 计算意图匹配度
|
||
*/
|
||
private double calculateIntentMatch(Utterance u, String intentCode) {
|
||
if (u.getIntentTags() == null || intentCode == null) {
|
||
return 0;
|
||
}
|
||
return u.getIntentTags().contains(intentCode) ? 1.0 : 0.0;
|
||
}
|
||
|
||
/**
|
||
* 计算画像匹配度
|
||
*/
|
||
private double calculateProfileMatch(Utterance u, String studentType) {
|
||
if (u.getProfileTags() == null || studentType == null) {
|
||
return 0.3; // 默认中等匹配
|
||
}
|
||
return u.getProfileTags().contains(studentType) ? 1.0 : 0.0;
|
||
}
|
||
|
||
/**
|
||
* 计算阶段匹配度
|
||
*/
|
||
private double calculateStageMatch(Utterance u, String currentStage) {
|
||
if (u.getStageTags() == null || currentStage == null) {
|
||
return 0.3;
|
||
}
|
||
return u.getStageTags().contains(currentStage) ? 1.0 : 0.0;
|
||
}
|
||
|
||
/**
|
||
* 转换为推荐项
|
||
*/
|
||
private RecommendItem toRecommendItem(Utterance u, double score) {
|
||
RecommendItem item = new RecommendItem();
|
||
item.setUtteranceId(u.getUtteranceId());
|
||
item.setTitle(u.getTitle());
|
||
item.setContent(u.getContent());
|
||
item.setScore(score);
|
||
item.setSuccessRate(u.getSuccessRate());
|
||
item.setUsedCount(u.getUsedCount());
|
||
item.setSource(u.getSource());
|
||
return item;
|
||
}
|
||
|
||
/**
|
||
* 监听推荐事件
|
||
*/
|
||
@RabbitListener(queues = "intent.recommend.queue")
|
||
public void handleRecommendEvent(String eventJson) {
|
||
try {
|
||
Map<String, Object> event = JsonUtils.fromJsonMap(eventJson);
|
||
if (event == null) return;
|
||
|
||
String customerId = (String) event.get("customerId");
|
||
String corpId = (String) event.get("corpId");
|
||
String staffId = (String) event.get("staffId");
|
||
Map<String, Object> primaryIntent = (Map<String, Object>) event.get("primaryIntent");
|
||
String intentCode = primaryIntent != null ? (String) primaryIntent.get("code") : null;
|
||
String contextSummary = (String) event.get("contextSummary");
|
||
|
||
if (intentCode == null) return;
|
||
|
||
// 执行推荐
|
||
RecommendResult result = recommend(
|
||
contextSummary, intentCode, null, null, customerId, corpId
|
||
);
|
||
|
||
// 推送结果到侧边栏(通过WebSocket或模板卡片)
|
||
// TODO: 实现推送逻辑
|
||
|
||
log.info("推荐完成: customerId={}, recommendations={}",
|
||
customerId, result.getRecommendations().size());
|
||
|
||
} catch (Exception e) {
|
||
log.error("处理推荐事件失败: {}", e.getMessage(), e);
|
||
}
|
||
}
|
||
|
||
// ========== 内部类 ==========
|
||
|
||
private static class ScoredUtterance {
|
||
Utterance utterance;
|
||
double score;
|
||
|
||
ScoredUtterance(Utterance utterance, double score) {
|
||
this.utterance = utterance;
|
||
this.score = score;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 推荐结果
|
||
*/
|
||
public static class RecommendResult {
|
||
private List<RecommendItem> recommendations;
|
||
private String generationMode;
|
||
private int elapsedMs;
|
||
private Map<String, Integer> recallSources;
|
||
|
||
public List<RecommendItem> getRecommendations() { return recommendations; }
|
||
public void setRecommendations(List<RecommendItem> recommendations) { this.recommendations = recommendations; }
|
||
public String getGenerationMode() { return generationMode; }
|
||
public void setGenerationMode(String generationMode) { this.generationMode = generationMode; }
|
||
public int getElapsedMs() { return elapsedMs; }
|
||
public void setElapsedMs(int elapsedMs) { this.elapsedMs = elapsedMs; }
|
||
public Map<String, Integer> getRecallSources() { return recallSources; }
|
||
public void setRecallSources(Map<String, Integer> recallSources) { this.recallSources = recallSources; }
|
||
}
|
||
|
||
/**
|
||
* 推荐项
|
||
*/
|
||
public static class RecommendItem {
|
||
private String utteranceId;
|
||
private String title;
|
||
private String content;
|
||
private double score;
|
||
private double successRate;
|
||
private int usedCount;
|
||
private String source;
|
||
|
||
public String getUtteranceId() { return utteranceId; }
|
||
public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; }
|
||
public String getTitle() { return title; }
|
||
public void setTitle(String title) { this.title = title; }
|
||
public String getContent() { return content; }
|
||
public void setContent(String content) { this.content = content; }
|
||
public double getScore() { return score; }
|
||
public void setScore(double score) { this.score = score; }
|
||
public double getSuccessRate() { return successRate; }
|
||
public void setSuccessRate(double successRate) { this.successRate = successRate; }
|
||
public int getUsedCount() { return usedCount; }
|
||
public void setUsedCount(int usedCount) { this.usedCount = usedCount; }
|
||
public String getSource() { return source; }
|
||
public void setSource(String source) { this.source = source; }
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.6.2 向量召回服务
|
||
|
||
```java
|
||
package com.artedu.recommend.service;
|
||
|
||
import com.artedu.recommend.entity.Utterance;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.data.redis.core.RedisTemplate;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 向量召回服务
|
||
* 使用Redis Hash存储和检索Embedding向量
|
||
* MySQL 5.7不支持向量类型,向量存储在Redis中
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class VectorRecallService {
|
||
|
||
@Autowired
|
||
private RedisTemplate<String, Object> redisTemplate;
|
||
|
||
@Autowired
|
||
private EmbeddingService embeddingService;
|
||
|
||
private static final String VECTOR_KEY_PREFIX = "vector:utterance:";
|
||
|
||
/**
|
||
* 向量召回
|
||
* 计算消息向量与话术库向量的余弦相似度,返回TopK
|
||
*/
|
||
public List<Utterance> recall(String message, int topK) {
|
||
List<Utterance> results = new ArrayList<>();
|
||
|
||
try {
|
||
// 1. 计算消息向量
|
||
float[] messageVector = embeddingService.embed(message);
|
||
|
||
// 2. 获取所有话术向量(从Redis)
|
||
// 注意:实际生产环境应使用向量数据库(如Milvus)
|
||
// 这里简化处理,从Redis获取
|
||
List<String> utteranceIds = getAllVectorIds();
|
||
|
||
// 3. 计算相似度
|
||
List<ScoredUtterance> scoredList = new ArrayList<>();
|
||
for (String utteranceId : utteranceIds) {
|
||
float[] vector = getVector(utteranceId);
|
||
if (vector != null) {
|
||
double similarity = cosineSimilarity(messageVector, vector);
|
||
if (similarity > 0.7) { // 相似度阈值
|
||
Utterance u = new Utterance();
|
||
u.setUtteranceId(utteranceId);
|
||
scoredList.add(new ScoredUtterance(u, similarity));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 按相似度排序,取TopK
|
||
scoredList.sort((a, b) -> Double.compare(b.score, a.score));
|
||
scoredList.stream()
|
||
.limit(topK)
|
||
.forEach(s -> results.add(s.utterance));
|
||
|
||
} catch (Exception e) {
|
||
log.error("向量召回失败: {}", e.getMessage(), e);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 存储话术向量
|
||
*/
|
||
public void storeVector(String utteranceId, float[] vector) {
|
||
try {
|
||
String key = VECTOR_KEY_PREFIX + utteranceId;
|
||
// 将float数组转为List<Double>存储
|
||
List<Double> vectorList = new ArrayList<>();
|
||
for (float v : vector) {
|
||
vectorList.add((double) v);
|
||
}
|
||
redisTemplate.opsForValue().set(key, vectorList);
|
||
} catch (Exception e) {
|
||
log.error("存储向量失败: utteranceId={}", utteranceId, e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取话术向量
|
||
*/
|
||
private float[] getVector(String utteranceId) {
|
||
try {
|
||
String key = VECTOR_KEY_PREFIX + utteranceId;
|
||
List<Double> vectorList = (List<Double>) redisTemplate.opsForValue().get(key);
|
||
if (vectorList == null) {
|
||
return null;
|
||
}
|
||
float[] vector = new float[vectorList.size()];
|
||
for (int i = 0; i < vectorList.size(); i++) {
|
||
vector[i] = vectorList.get(i).floatValue();
|
||
}
|
||
return vector;
|
||
} catch (Exception e) {
|
||
log.error("获取向量失败: utteranceId={}", utteranceId, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有有向量的话术ID
|
||
*/
|
||
private List<String> getAllVectorIds() {
|
||
// 简化实现:从Redis keys获取
|
||
// 实际生产环境应使用更好的索引机制
|
||
var keys = redisTemplate.keys(VECTOR_KEY_PREFIX + "*");
|
||
if (keys == null) {
|
||
return List.of();
|
||
}
|
||
return keys.stream()
|
||
.map(k -> k.replace(VECTOR_KEY_PREFIX, ""))
|
||
.toList();
|
||
}
|
||
|
||
/**
|
||
* 计算余弦相似度
|
||
*/
|
||
private double cosineSimilarity(float[] a, float[] b) {
|
||
if (a.length != b.length) {
|
||
return 0;
|
||
}
|
||
double dotProduct = 0;
|
||
double normA = 0;
|
||
double normB = 0;
|
||
for (int i = 0; i < a.length; i++) {
|
||
dotProduct += a[i] * b[i];
|
||
normA += a[i] * a[i];
|
||
normB += b[i] * b[i];
|
||
}
|
||
if (normA == 0 || normB == 0) {
|
||
return 0;
|
||
}
|
||
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||
}
|
||
|
||
private static class ScoredUtterance {
|
||
Utterance utterance;
|
||
double score;
|
||
|
||
ScoredUtterance(Utterance utterance, double score) {
|
||
this.utterance = utterance;
|
||
this.score = score;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.6.3 LLM兜底生成服务
|
||
|
||
```java
|
||
package com.artedu.recommend.service;
|
||
|
||
import com.artedu.common.util.JsonUtils;
|
||
import com.artedu.recommend.entity.Utterance;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.*;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.Map;
|
||
import java.util.UUID;
|
||
|
||
/**
|
||
* LLM兜底话术生成服务
|
||
* 当话术库匹配不足时,调用通义千问动态生成话术
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class LLMGenerateService {
|
||
|
||
@Value("${llm.qianwen.api-key}")
|
||
private String apiKey;
|
||
|
||
@Value("${llm.qianwen.model:qwen-turbo}")
|
||
private String model;
|
||
|
||
@Autowired
|
||
private RestTemplate restTemplate;
|
||
|
||
private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||
|
||
/**
|
||
* 生成兜底话术
|
||
*/
|
||
public Utterance generate(String message, String intentCode,
|
||
String studentType, String currentStage) {
|
||
try {
|
||
// 构建生成Prompt
|
||
String prompt = buildGeneratePrompt(message, intentCode, studentType, currentStage);
|
||
|
||
// 调用LLM
|
||
String response = callLLM(prompt);
|
||
if (response == null) {
|
||
return null;
|
||
}
|
||
|
||
// 解析响应
|
||
String content = extractContent(response);
|
||
if (content == null || content.isEmpty()) {
|
||
return null;
|
||
}
|
||
|
||
// 构建话术对象
|
||
Utterance utterance = new Utterance();
|
||
utterance.setUtteranceId("LLM_" + UUID.randomUUID().toString().substring(0, 8));
|
||
utterance.setTitle("AI生成-" + intentCode);
|
||
utterance.setContent(content);
|
||
utterance.setContentText(content);
|
||
utterance.setStatus("ACTIVE");
|
||
utterance.setSource("LLM_GENERATED");
|
||
utterance.setSuccessRate(0.5); // AI生成的话术默认中等成功率
|
||
utterance.setUsedCount(0);
|
||
|
||
log.info("LLM生成话术成功: utteranceId={}, length={}",
|
||
utterance.getUtteranceId(), content.length());
|
||
|
||
return utterance;
|
||
|
||
} catch (Exception e) {
|
||
log.error("LLM生成话术失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建生成Prompt
|
||
*/
|
||
private String buildGeneratePrompt(String message, String intentCode,
|
||
String studentType, String currentStage) {
|
||
StringBuilder prompt = new StringBuilder();
|
||
|
||
prompt.append("# 角色\n");
|
||
prompt.append("你是第九联盟(9ART EDU)的资深课程顾问,擅长根据学员的问题生成专业、热情、有说服力的话术回复。\n\n");
|
||
|
||
prompt.append("# 业务背景\n");
|
||
prompt.append("第九联盟是CG数字艺术教育培训机构,母公司点晴科技有10年游戏制作经验。\n");
|
||
prompt.append("课程包括:3D场景UE地编、次世代角色模型、2D原画、游戏动画、游戏特效、UE开发、3D大师研修班。\n");
|
||
prompt.append("校区:上海、西安、厦门、武汉、青岛、合肥。\n");
|
||
prompt.append("就业合作企业:腾讯、网易、米哈游等500+企业。\n");
|
||
prompt.append("教学模式:5个月培训+2个月企业实训。\n");
|
||
prompt.append("师资:来自一线游戏大厂,平均8年+项目经验。\n\n");
|
||
|
||
prompt.append("# 生成要求\n");
|
||
prompt.append("1. 针对学员的具体问题生成回复话术\n");
|
||
prompt.append("2. 语气专业、热情、有亲和力\n");
|
||
prompt.append("3. 包含具体的业务数据(价格、时间等)\n");
|
||
prompt.append("4. 适当使用emoji和分段,增强可读性\n");
|
||
prompt.append("5. 引导学员下一步行动(预约试听、了解大纲等)\n");
|
||
prompt.append("6. 不要编造不存在的数据,不确定的标注[需人工确认]\n\n");
|
||
|
||
prompt.append("# 当前场景\n");
|
||
prompt.append("- 学员问题:\"").append(message).append("\"\n");
|
||
if (intentCode != null) {
|
||
prompt.append("- 意图编码:").append(intentCode).append("\n");
|
||
}
|
||
if (studentType != null) {
|
||
prompt.append("- 学员类型:").append(studentType).append("\n");
|
||
}
|
||
if (currentStage != null) {
|
||
prompt.append("- 对话阶段:").append(currentStage).append("\n");
|
||
}
|
||
|
||
prompt.append("\n请生成回复话术:");
|
||
|
||
return prompt.toString();
|
||
}
|
||
|
||
/**
|
||
* 调用LLM
|
||
*/
|
||
private String callLLM(String prompt) {
|
||
try {
|
||
Map<String, Object> requestBody = Map.of(
|
||
"model", model,
|
||
"input", Map.of("prompt", prompt),
|
||
"parameters", Map.of(
|
||
"result_format", "text",
|
||
"max_tokens", 800,
|
||
"temperature", 0.7
|
||
)
|
||
);
|
||
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("Authorization", "Bearer " + apiKey);
|
||
|
||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||
|
||
ResponseEntity<String> response = restTemplate.exchange(
|
||
API_URL,
|
||
HttpMethod.POST,
|
||
request,
|
||
String.class
|
||
);
|
||
|
||
return response.getBody();
|
||
|
||
} catch (Exception e) {
|
||
log.error("调用LLM失败: {}", e.getMessage(), e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 提取LLM响应中的文本内容
|
||
*/
|
||
private String extractContent(String response) {
|
||
try {
|
||
Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
|
||
if (responseMap == null) return null;
|
||
|
||
Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
|
||
if (output == null) return null;
|
||
|
||
return (String) output.get("text");
|
||
} catch (Exception e) {
|
||
log.error("提取LLM内容失败: {}", e.getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.6.4 Embedding服务
|
||
|
||
```java
|
||
package com.artedu.recommend.service;
|
||
|
||
import com.artedu.common.util.JsonUtils;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.*;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* Embedding服务
|
||
* 调用通义千问文本Embedding API,将文本转为向量
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class EmbeddingService {
|
||
|
||
@Value("${llm.qianwen.api-key}")
|
||
private String apiKey;
|
||
|
||
@Autowired
|
||
private RestTemplate restTemplate;
|
||
|
||
private static final String EMBEDDING_API_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
|
||
private static final String EMBEDDING_MODEL = "text-embedding-v2";
|
||
|
||
/**
|
||
* 获取文本的Embedding向量
|
||
*
|
||
* @param text 输入文本
|
||
* @return 1536维向量
|
||
*/
|
||
public float[] embed(String text) {
|
||
try {
|
||
// 文本长度限制(Embedding API有长度限制)
|
||
if (text.length() > 2000) {
|
||
text = text.substring(0, 2000);
|
||
}
|
||
|
||
Map<String, Object> requestBody = Map.of(
|
||
"model", EMBEDDING_MODEL,
|
||
"input", Map.of("texts", List.of(text))
|
||
);
|
||
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("Authorization", "Bearer " + apiKey);
|
||
|
||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||
|
||
ResponseEntity<String> response = restTemplate.exchange(
|
||
EMBEDDING_API_URL,
|
||
HttpMethod.POST,
|
||
request,
|
||
String.class
|
||
);
|
||
|
||
return parseEmbedding(response.getBody());
|
||
|
||
} catch (Exception e) {
|
||
log.error("获取Embedding失败: {}", e.getMessage(), e);
|
||
// 返回零向量作为降级
|
||
return new float[1536];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析Embedding响应
|
||
*/
|
||
private float[] parseEmbedding(String response) {
|
||
try {
|
||
Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
|
||
if (responseMap == null) return new float[1536];
|
||
|
||
Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
|
||
if (output == null) return new float[1536];
|
||
|
||
List<Map<String, Object>> embeddings = (List<Map<String, Object>>) output.get("embeddings");
|
||
if (embeddings == null || embeddings.isEmpty()) return new float[1536];
|
||
|
||
List<Double> embeddingList = (List<Double>) embeddings.get(0).get("embedding");
|
||
if (embeddingList == null) return new float[1536];
|
||
|
||
float[] result = new float[embeddingList.size()];
|
||
for (int i = 0; i < embeddingList.size(); i++) {
|
||
result[i] = embeddingList.get(i).floatValue();
|
||
}
|
||
|
||
return result;
|
||
|
||
} catch (Exception e) {
|
||
log.error("解析Embedding响应失败: {}", e.getMessage());
|
||
return new float[1536];
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.6.5 推荐Controller
|
||
|
||
```java
|
||
package com.artedu.recommend.controller;
|
||
|
||
import com.artedu.common.result.Result;
|
||
import com.artedu.recommend.service.RecommendationService;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
/**
|
||
* 话术推荐控制器
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/recommend")
|
||
public class RecommendController {
|
||
|
||
@Autowired
|
||
private RecommendationService recommendationService;
|
||
|
||
/**
|
||
* 话术推荐接口
|
||
*/
|
||
@PostMapping
|
||
public Result<RecommendationService.RecommendResult> recommend(
|
||
@RequestBody RecommendRequest request) {
|
||
|
||
RecommendationService.RecommendResult result = recommendationService.recommend(
|
||
request.getMessage(),
|
||
request.getIntentCode(),
|
||
request.getStudentType(),
|
||
request.getCurrentStage(),
|
||
request.getCustomerId(),
|
||
request.getCorpId()
|
||
);
|
||
|
||
return Result.success(result);
|
||
}
|
||
|
||
/**
|
||
* 话术反馈接口
|
||
*/
|
||
@PostMapping("/feedback")
|
||
public Result<String> feedback(@RequestBody FeedbackRequest request) {
|
||
// TODO: 实现反馈收集
|
||
return Result.success("反馈已记录");
|
||
}
|
||
|
||
// 请求DTO
|
||
public static class RecommendRequest {
|
||
private String message;
|
||
private String intentCode;
|
||
private String studentType;
|
||
private String currentStage;
|
||
private String customerId;
|
||
private String corpId;
|
||
|
||
public String getMessage() { return message; }
|
||
public void setMessage(String message) { this.message = message; }
|
||
public String getIntentCode() { return intentCode; }
|
||
public void setIntentCode(String intentCode) { this.intentCode = intentCode; }
|
||
public String getStudentType() { return studentType; }
|
||
public void setStudentType(String studentType) { this.studentType = studentType; }
|
||
public String getCurrentStage() { return currentStage; }
|
||
public void setCurrentStage(String currentStage) { this.currentStage = currentStage; }
|
||
public String getCustomerId() { return customerId; }
|
||
public void setCustomerId(String customerId) { this.customerId = customerId; }
|
||
public String getCorpId() { return corpId; }
|
||
public void setCorpId(String corpId) { this.corpId = corpId; }
|
||
}
|
||
|
||
public static class FeedbackRequest {
|
||
private String utteranceId;
|
||
private String action;
|
||
private Boolean helpful;
|
||
|
||
public String getUtteranceId() { return utteranceId; }
|
||
public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; }
|
||
public String getAction() { return action; }
|
||
public void setAction(String action) { this.action = action; }
|
||
public Boolean getHelpful() { return helpful; }
|
||
public void setHelpful(Boolean helpful) { this.helpful = helpful; }
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
|
||
---
|
||
|
||
## 第四部分:前端实现
|
||
|
||
### 4.1 侧边栏H5项目(React + TypeScript)
|
||
|
||
#### 4.1.1 package.json
|
||
|
||
```json
|
||
{
|
||
"name": "ai-assistant-sidebar",
|
||
"version": "1.0.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||
"build": "tsc && vite build",
|
||
"preview": "vite preview",
|
||
"lint": "eslint . --ext ts,tsx"
|
||
},
|
||
"dependencies": {
|
||
"react": "^18.2.0",
|
||
"react-dom": "^18.2.0",
|
||
"zustand": "^4.4.0",
|
||
"antd-mobile": "^5.32.0",
|
||
"antd-mobile-icons": "^0.3.0",
|
||
"wecom-jssdk": "^2.0.2",
|
||
"axios": "^1.5.0",
|
||
"clsx": "^2.0.0"
|
||
},
|
||
"devDependencies": {
|
||
"@types/react": "^18.2.0",
|
||
"@types/react-dom": "^18.2.0",
|
||
"@vitejs/plugin-react": "^4.0.0",
|
||
"typescript": "^5.2.0",
|
||
"vite": "^4.4.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.1.2 Vite配置
|
||
|
||
```typescript
|
||
// vite.config.ts
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
import path from 'path';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, './src'),
|
||
},
|
||
},
|
||
server: {
|
||
port: 3000,
|
||
host: '0.0.0.0',
|
||
// 允许企微域名跨域
|
||
cors: true,
|
||
},
|
||
build: {
|
||
outDir: 'dist',
|
||
sourcemap: true,
|
||
// 移动端适配:使用vw单位
|
||
cssTarget: 'chrome61',
|
||
},
|
||
base: '/sidebar/',
|
||
});
|
||
```
|
||
|
||
#### 4.1.3 HTML入口
|
||
|
||
```html
|
||
<!-- index.html -->
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||
<title>AI话术助手</title>
|
||
<!-- 企微JS-SDK -->
|
||
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
|
||
<script src="https://open.work.weixin.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
#### 4.1.4 入口文件
|
||
|
||
```typescript
|
||
// src/main.tsx
|
||
import React from 'react';
|
||
import ReactDOM from 'react-dom/client';
|
||
import App from './App';
|
||
import './styles/global.css';
|
||
|
||
// 企微JS-SDK环境检测
|
||
console.log('环境检测:', {
|
||
ww: typeof ww !== 'undefined',
|
||
wx: typeof wx !== 'undefined',
|
||
isWeCom: navigator.userAgent.toLowerCase().includes('wxwork'),
|
||
isMobile: /Mobile|Android|iPhone/.test(navigator.userAgent),
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
#### 4.1.5 全局样式
|
||
|
||
```css
|
||
/* src/styles/global.css */
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html, body, #root {
|
||
height: 100%;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
/* 企微侧边栏容器适配 */
|
||
.sidebar-container {
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
/* PC端侧边栏固定宽度 */
|
||
@media (min-width: 768px) {
|
||
.sidebar-container {
|
||
width: 360px;
|
||
min-width: 360px;
|
||
max-width: 360px;
|
||
}
|
||
}
|
||
|
||
/* 手机端侧边栏全宽 */
|
||
@media (max-width: 767px) {
|
||
.sidebar-container {
|
||
width: 100vw;
|
||
height: 80vh;
|
||
border-radius: 12px 12px 0 0;
|
||
}
|
||
}
|
||
|
||
/* 滚动条样式 */
|
||
::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
::-webkit-scrollbar-thumb {
|
||
background: #d9d9d9;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* 话术卡片样式 */
|
||
.script-card {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
margin: 8px 12px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.script-card:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
/* 学员画像标签 */
|
||
.profile-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
margin-right: 6px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.profile-tag-primary {
|
||
background: #e6f7ff;
|
||
color: #1890ff;
|
||
border: 1px solid #91d5ff;
|
||
}
|
||
|
||
.profile-tag-success {
|
||
background: #f6ffed;
|
||
color: #52c41a;
|
||
border: 1px solid #b7eb8f;
|
||
}
|
||
|
||
.profile-tag-warning {
|
||
background: #fffbe6;
|
||
color: #faad14;
|
||
border: 1px solid #ffe58f;
|
||
}
|
||
|
||
/* 一键发送按钮 */
|
||
.send-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 6px 12px;
|
||
border-radius: 16px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: none;
|
||
}
|
||
|
||
.send-btn-primary {
|
||
background: #1677ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.send-btn-primary:active {
|
||
background: #0958d9;
|
||
}
|
||
```
|
||
|
||
#### 4.1.6 企微SDK Hook(核心)
|
||
|
||
```typescript
|
||
// src/hooks/useWeComSdk.ts
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
|
||
/**
|
||
* 企微环境类型
|
||
*/
|
||
export type WeComEnv = 'pc' | 'mobile' | 'unknown';
|
||
|
||
/**
|
||
* 企微SDK Hook
|
||
* 负责初始化ww.register、获取客户ID、发送消息
|
||
*/
|
||
export function useWeComSdk() {
|
||
const [env, setEnv] = useState<WeComEnv>('unknown');
|
||
const [customerId, setCustomerId] = useState<string>('');
|
||
const [staffId, setStaffId] = useState<string>('');
|
||
const [isReady, setIsReady] = useState(false);
|
||
const [error, setError] = useState<string>('');
|
||
|
||
/**
|
||
* 检测企微环境
|
||
*/
|
||
useEffect(() => {
|
||
const userAgent = navigator.userAgent.toLowerCase();
|
||
if (userAgent.includes('wxwork')) {
|
||
if (userAgent.includes('windowswechat') || userAgent.includes('macwechat')) {
|
||
setEnv('pc');
|
||
} else {
|
||
setEnv('mobile');
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
/**
|
||
* 初始化企微JS-SDK
|
||
*/
|
||
const initSdk = useCallback(async () => {
|
||
if (typeof ww === 'undefined') {
|
||
setError('当前不在企业微信环境中');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 从后端获取签名参数
|
||
const response = await fetch(
|
||
`${import.meta.env.VITE_API_BASE_URL}/api/v1/auth/signature?url=` +
|
||
encodeURIComponent(window.location.href.split('#')[0])
|
||
);
|
||
const result = await response.json();
|
||
|
||
if (result.code !== 0) {
|
||
setError('获取签名失败: ' + result.message);
|
||
return;
|
||
}
|
||
|
||
const { corpId, agentId, nonceStr, timestamp, signature } = result.data;
|
||
|
||
// iOS特殊处理:需使用setTimeout包裹
|
||
const doRegister = () => {
|
||
ww.register({
|
||
corpId: corpId,
|
||
agentId: agentId,
|
||
jsApiList: [
|
||
'sendChatMessage',
|
||
'getCurExternalContact',
|
||
'getCurExternalChat',
|
||
'openExistedChatWithMsg',
|
||
],
|
||
getAgentConfigSignature: () => {
|
||
return {
|
||
timestamp: timestamp,
|
||
nonceStr: nonceStr,
|
||
signature: signature,
|
||
};
|
||
},
|
||
success: () => {
|
||
console.log('企微JS-SDK注册成功');
|
||
setIsReady(true);
|
||
// 获取当前客户ID
|
||
fetchCustomerId();
|
||
},
|
||
fail: (err: any) => {
|
||
console.error('企微JS-SDK注册失败:', err);
|
||
setError('SDK注册失败: ' + JSON.stringify(err));
|
||
},
|
||
});
|
||
};
|
||
|
||
if (env === 'mobile' && /iPhone|iPad/.test(navigator.userAgent)) {
|
||
setTimeout(doRegister, 100);
|
||
} else {
|
||
doRegister();
|
||
}
|
||
|
||
} catch (e) {
|
||
setError('初始化SDK异常: ' + (e as Error).message);
|
||
}
|
||
}, [env]);
|
||
|
||
/**
|
||
* 获取当前客户ID(外部联系人)
|
||
*/
|
||
const fetchCustomerId = useCallback(() => {
|
||
if (typeof ww === 'undefined') return;
|
||
|
||
ww.invoke('getCurExternalContact', {}, (res: any) => {
|
||
if (res.err_msg === 'getCurExternalContact:ok') {
|
||
setCustomerId(res.userId || '');
|
||
console.log('当前客户ID:', res.userId);
|
||
} else {
|
||
console.warn('获取客户ID失败:', res.err_msg);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 发送话术到聊天窗口(核心功能)
|
||
*/
|
||
const sendScript = useCallback(
|
||
(content: string, enterChat: boolean = false): Promise<boolean> => {
|
||
return new Promise((resolve) => {
|
||
if (typeof ww === 'undefined') {
|
||
setError('SDK未初始化');
|
||
resolve(false);
|
||
return;
|
||
}
|
||
|
||
ww.invoke(
|
||
'sendChatMessage',
|
||
{
|
||
msgtype: 'text',
|
||
text: {
|
||
content: content,
|
||
},
|
||
enterChat: enterChat, // PC端false不进入会话,手机端true进入会话
|
||
},
|
||
(res: any) => {
|
||
if (res.err_msg === 'sendChatMessage:ok') {
|
||
console.log('话术发送成功');
|
||
resolve(true);
|
||
} else {
|
||
console.error('话术发送失败:', res.err_msg);
|
||
setError('发送失败: ' + res.err_msg);
|
||
resolve(false);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
/**
|
||
* 打开已有会话(配合enterChat:true使用)
|
||
*/
|
||
const openChat = useCallback((userId: string) => {
|
||
if (typeof ww === 'undefined') return;
|
||
|
||
ww.invoke(
|
||
'openExistedChatWithMsg',
|
||
{
|
||
userid: userId,
|
||
},
|
||
(res: any) => {
|
||
console.log('打开会话结果:', res);
|
||
}
|
||
);
|
||
}, []);
|
||
|
||
return {
|
||
env,
|
||
customerId,
|
||
staffId,
|
||
isReady,
|
||
error,
|
||
initSdk,
|
||
sendScript,
|
||
openChat,
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 4.1.7 API服务
|
||
|
||
```typescript
|
||
// src/services/api.ts
|
||
/**
|
||
* API服务
|
||
* 封装所有后端API调用
|
||
*/
|
||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||
|
||
/**
|
||
* 获取Token
|
||
*/
|
||
function getToken(): string {
|
||
return localStorage.getItem('token') || '';
|
||
}
|
||
|
||
/**
|
||
* 通用请求方法
|
||
*/
|
||
async function request<T>(
|
||
url: string,
|
||
options: RequestInit = {}
|
||
): Promise<{ code: number; data: T; message: string }> {
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
||
...((options.headers as Record<string, string>) || {}),
|
||
};
|
||
|
||
const response = await fetch(`${API_BASE}${url}`, {
|
||
...options,
|
||
headers,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
/**
|
||
* 话术推荐API
|
||
*/
|
||
export async function recommendScripts(params: {
|
||
message: string;
|
||
intentCode?: string;
|
||
studentType?: string;
|
||
currentStage?: string;
|
||
customerId: string;
|
||
corpId: string;
|
||
}) {
|
||
return request<RecommendResult>('/api/v1/recommend', {
|
||
method: 'POST',
|
||
body: JSON.stringify(params),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取学员画像API
|
||
*/
|
||
export async function getCustomerProfile(customerId: string, corpId: string) {
|
||
return request<CustomerProfile>(`/api/v1/intent/profile/${customerId}?corpId=${corpId}`);
|
||
}
|
||
|
||
/**
|
||
* 获取话术库列表
|
||
*/
|
||
export async function getUtteranceList(params: {
|
||
page?: number;
|
||
size?: number;
|
||
stage?: string;
|
||
keyword?: string;
|
||
}) {
|
||
const query = new URLSearchParams();
|
||
if (params.page) query.set('page', params.page.toString());
|
||
if (params.size) query.set('size', params.size.toString());
|
||
if (params.stage) query.set('stage', params.stage);
|
||
if (params.keyword) query.set('keyword', params.keyword);
|
||
|
||
return request<PageResult<Utterance>>(`/api/v1/kb/utterances?${query.toString()}`);
|
||
}
|
||
|
||
/**
|
||
* 提交话术反馈
|
||
*/
|
||
export async function submitFeedback(params: {
|
||
utteranceId: string;
|
||
action: 'send' | 'useful' | 'useless';
|
||
}) {
|
||
return request('/api/v1/recommend/feedback', {
|
||
method: 'POST',
|
||
body: JSON.stringify(params),
|
||
});
|
||
}
|
||
|
||
// 类型定义
|
||
export interface RecommendResult {
|
||
recommendations: RecommendItem[];
|
||
generationMode: string;
|
||
elapsedMs: number;
|
||
recallSources: Record<string, number>;
|
||
}
|
||
|
||
export interface RecommendItem {
|
||
utteranceId: string;
|
||
title: string;
|
||
content: string;
|
||
score: number;
|
||
successRate: number;
|
||
usedCount: number;
|
||
source: string;
|
||
}
|
||
|
||
export interface CustomerProfile {
|
||
customerId: string;
|
||
studentType: string;
|
||
intentLevel: string;
|
||
intentScore: number;
|
||
concernFocus: string;
|
||
decisionStage: string;
|
||
conversationCount: number;
|
||
}
|
||
|
||
export interface Utterance {
|
||
utteranceId: string;
|
||
title: string;
|
||
content: string;
|
||
stageTags: string[];
|
||
intentTags: string[];
|
||
successRate: number;
|
||
usedCount: number;
|
||
}
|
||
|
||
export interface PageResult<T> {
|
||
page: number;
|
||
size: number;
|
||
total: number;
|
||
list: T[];
|
||
}
|
||
```
|
||
|
||
#### 4.1.8 Zustand状态管理
|
||
|
||
```typescript
|
||
// src/stores/useScriptStore.ts
|
||
import { create } from 'zustand';
|
||
import type { RecommendItem, RecommendResult } from '../services/api';
|
||
|
||
/**
|
||
* 话术推荐状态管理
|
||
*/
|
||
interface ScriptState {
|
||
// 状态
|
||
isLoading: boolean;
|
||
recommendations: RecommendItem[];
|
||
currentScript: RecommendItem | null;
|
||
error: string;
|
||
|
||
// 动作
|
||
setRecommendations: (items: RecommendItem[]) => void;
|
||
setCurrentScript: (script: RecommendItem | null) => void;
|
||
setLoading: (loading: boolean) => void;
|
||
setError: (error: string) => void;
|
||
loadRecommendations: (result: RecommendResult) => void;
|
||
clearRecommendations: () => void;
|
||
}
|
||
|
||
export const useScriptStore = create<ScriptState>((set) => ({
|
||
isLoading: false,
|
||
recommendations: [],
|
||
currentScript: null,
|
||
error: '',
|
||
|
||
setRecommendations: (items) => set({ recommendations: items, error: '' }),
|
||
setCurrentScript: (script) => set({ currentScript: script }),
|
||
setLoading: (loading) => set({ isLoading: loading }),
|
||
setError: (error) => set({ error, isLoading: false }),
|
||
|
||
loadRecommendations: (result) =>
|
||
set({
|
||
recommendations: result.recommendations,
|
||
isLoading: false,
|
||
error: '',
|
||
}),
|
||
|
||
clearRecommendations: () =>
|
||
set({
|
||
recommendations: [],
|
||
currentScript: null,
|
||
error: '',
|
||
}),
|
||
}));
|
||
```
|
||
|
||
```typescript
|
||
// src/stores/useProfileStore.ts
|
||
import { create } from 'zustand';
|
||
|
||
/**
|
||
* 学员画像状态管理
|
||
*/
|
||
interface ProfileState {
|
||
customerId: string;
|
||
customerName: string;
|
||
studentType: string;
|
||
intentLevel: string;
|
||
intentScore: number;
|
||
concernFocus: string;
|
||
decisionStage: string;
|
||
conversationCount: number;
|
||
isLoading: boolean;
|
||
|
||
setProfile: (profile: Partial<Omit<ProfileState, 'setProfile' | 'isLoading'>>) => void;
|
||
setLoading: (loading: boolean) => void;
|
||
clearProfile: () => void;
|
||
}
|
||
|
||
export const useProfileStore = create<ProfileState>((set) => ({
|
||
customerId: '',
|
||
customerName: '',
|
||
studentType: '',
|
||
intentLevel: '低',
|
||
intentScore: 0,
|
||
concernFocus: '',
|
||
decisionStage: '信息了解',
|
||
conversationCount: 0,
|
||
isLoading: false,
|
||
|
||
setProfile: (profile) => set((state) => ({ ...state, ...profile })),
|
||
setLoading: (loading) => set({ isLoading: loading }),
|
||
clearProfile: () =>
|
||
set({
|
||
customerId: '',
|
||
customerName: '',
|
||
studentType: '',
|
||
intentLevel: '低',
|
||
intentScore: 0,
|
||
concernFocus: '',
|
||
decisionStage: '信息了解',
|
||
conversationCount: 0,
|
||
}),
|
||
}));
|
||
```
|
||
|
||
#### 4.1.9 根组件 App.tsx
|
||
|
||
```tsx
|
||
// src/App.tsx
|
||
import React, { useEffect } from 'react';
|
||
import { useWeComSdk } from './hooks/useWeComSdk';
|
||
import Header from './components/Header';
|
||
import TabBar from './components/TabBar';
|
||
import ProfileCard from './components/ProfileCard';
|
||
import ScriptRecommend from './components/ScriptRecommend';
|
||
import AnalysisPanel from './components/AnalysisPanel';
|
||
import { Toast } from 'antd-mobile';
|
||
|
||
/**
|
||
* 侧边栏主应用组件
|
||
* 整合企微SDK、话术推荐、学员画像等功能
|
||
*/
|
||
const App: React.FC = () => {
|
||
const { env, isReady, error, initSdk, customerId } = useWeComSdk();
|
||
const [activeTab, setActiveTab] = React.useState<'recommend' | 'analysis' | 'search'>('recommend');
|
||
|
||
// 初始化SDK
|
||
useEffect(() => {
|
||
initSdk();
|
||
}, [initSdk]);
|
||
|
||
// 错误提示
|
||
useEffect(() => {
|
||
if (error) {
|
||
Toast.show({ content: error, position: 'center' });
|
||
}
|
||
}, [error]);
|
||
|
||
// 根据当前Tab渲染内容
|
||
const renderContent = () => {
|
||
switch (activeTab) {
|
||
case 'recommend':
|
||
return <ScriptRecommend />;
|
||
case 'analysis':
|
||
return <AnalysisPanel />;
|
||
case 'search':
|
||
return <SearchPlaceholder />;
|
||
default:
|
||
return <ScriptRecommend />;
|
||
}
|
||
};
|
||
|
||
if (!isReady) {
|
||
return (
|
||
<div className="sidebar-container" style={{ justifyContent: 'center', alignItems: 'center' }}>
|
||
<div style={{ textAlign: 'center', color: '#999' }}>
|
||
<div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
|
||
<div>正在初始化AI助手...</div>
|
||
{error && <div style={{ color: '#ff4d4f', marginTop: 8, fontSize: 12 }}>{error}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="sidebar-container">
|
||
{/* 顶部栏 */}
|
||
<Header env={env} customerId={customerId} />
|
||
|
||
{/* 学员画像卡片 */}
|
||
<ProfileCard />
|
||
|
||
{/* Tab切换栏 */}
|
||
<TabBar activeTab={activeTab} onChange={setActiveTab} />
|
||
|
||
{/* 主内容区 */}
|
||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||
{renderContent()}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 搜索占位组件
|
||
*/
|
||
const SearchPlaceholder: React.FC = () => (
|
||
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>
|
||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
|
||
<div>话术搜索功能开发中</div>
|
||
</div>
|
||
);
|
||
|
||
export default App;
|
||
```
|
||
|
||
#### 4.1.10 Header组件
|
||
|
||
```tsx
|
||
// src/components/Header.tsx
|
||
import React from 'react';
|
||
import { useWeComSdk } from '../hooks/useWeComSdk';
|
||
import type { WeComEnv } from '../hooks/useWeComSdk';
|
||
|
||
interface HeaderProps {
|
||
env: WeComEnv;
|
||
customerId: string;
|
||
}
|
||
|
||
/**
|
||
* 顶部栏组件
|
||
* 显示标题、环境标识、刷新按钮
|
||
*/
|
||
const Header: React.FC<HeaderProps> = ({ env, customerId }) => {
|
||
const { env: sdkEnv } = useWeComSdk();
|
||
|
||
return (
|
||
<header
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '12px 16px',
|
||
background: '#fff',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 20 }}>🤖</span>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>AI话术助手</div>
|
||
<div style={{ fontSize: 11, color: '#999' }}>
|
||
{env === 'pc' ? '💻 PC端' : '📱 手机端'} | {customerId ? '已连接' : '未连接'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
style={{
|
||
padding: '4px 10px',
|
||
borderRadius: 12,
|
||
border: '1px solid #d9d9d9',
|
||
background: '#fff',
|
||
fontSize: 12,
|
||
cursor: 'pointer',
|
||
color: '#666',
|
||
}}
|
||
>
|
||
🔄 刷新
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
};
|
||
|
||
export default Header;
|
||
```
|
||
|
||
#### 4.1.11 ProfileCard组件(学员画像)
|
||
|
||
```tsx
|
||
// src/components/ProfileCard.tsx
|
||
import React, { useEffect } from 'react';
|
||
import { useProfileStore } from '../stores/useProfileStore';
|
||
import { Tag } from 'antd-mobile';
|
||
|
||
/**
|
||
* 学员画像卡片
|
||
* 展示学员类型、意向度、决策阶段等信息
|
||
*/
|
||
const ProfileCard: React.FC = () => {
|
||
const profile = useProfileStore();
|
||
|
||
// 意向度颜色映射
|
||
const getIntentColor = (level: string) => {
|
||
switch (level) {
|
||
case '高': return '#ff4d4f';
|
||
case '中': return '#faad14';
|
||
case '低': return '#52c41a';
|
||
default: return '#999';
|
||
}
|
||
};
|
||
|
||
// 决策阶段图标映射
|
||
const getStageIcon = (stage: string) => {
|
||
switch (stage) {
|
||
case '开场白': return '👋';
|
||
case '需求探询': return '🔍';
|
||
case '课程推荐': return '📚';
|
||
case '价值塑造': return '💎';
|
||
case '报价沟通': return '💰';
|
||
case '异议处理': return '⚡';
|
||
case '促成报名': return '🎯';
|
||
case '跟进维护': return '📞';
|
||
default: return '📝';
|
||
}
|
||
};
|
||
|
||
if (!profile.customerId) {
|
||
return (
|
||
<div style={{ padding: '12px 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
|
||
<div style={{ color: '#999', fontSize: 13, textAlign: 'center' }}>
|
||
暂无学员画像数据
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: '12px 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
|
||
{/* 学员类型 + 意向度 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>
|
||
{profile.customerName || '未知学员'}
|
||
</span>
|
||
{profile.studentType && (
|
||
<Tag color='primary' fill='outline' style={{ fontSize: 11 }}>
|
||
{profile.studentType}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
padding: '2px 8px',
|
||
borderRadius: 10,
|
||
background: `${getIntentColor(profile.intentLevel)}15`,
|
||
color: getIntentColor(profile.intentLevel),
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}>
|
||
<span>🔥</span>
|
||
<span>{profile.intentLevel}意向({Math.round(profile.intentScore)})</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标签行 */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{profile.decisionStage && (
|
||
<span className="profile-tag profile-tag-primary">
|
||
{getStageIcon(profile.decisionStage)} {profile.decisionStage}
|
||
</span>
|
||
)}
|
||
{profile.concernFocus && (
|
||
<span className="profile-tag profile-tag-warning">
|
||
💡 {profile.concernFocus}
|
||
</span>
|
||
)}
|
||
<span className="profile-tag profile-tag-success">
|
||
💬 {profile.conversationCount}次对话
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ProfileCard;
|
||
```
|
||
|
||
#### 4.1.12 ScriptRecommend组件(话术推荐列表)
|
||
|
||
```tsx
|
||
// src/components/ScriptRecommend.tsx
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useScriptStore } from '../stores/useScriptStore';
|
||
import { useWeComSdk } from '../hooks/useWeComSdk';
|
||
import { recommendScripts, submitFeedback } from '../services/api';
|
||
import { Loading, Toast } from 'antd-mobile';
|
||
import ScriptCard from './ScriptCard';
|
||
|
||
/**
|
||
* 话术推荐列表组件
|
||
* 展示AI推荐的话术列表,支持一键发送
|
||
*/
|
||
const ScriptRecommend: React.FC = () => {
|
||
const { customerId } = useWeComSdk();
|
||
const { recommendations, isLoading, setRecommendations, setLoading, setError } = useScriptStore();
|
||
const [lastMessage, setLastMessage] = useState('');
|
||
|
||
// 组件加载时获取推荐
|
||
useEffect(() => {
|
||
if (customerId) {
|
||
loadRecommendations();
|
||
}
|
||
}, [customerId]);
|
||
|
||
/**
|
||
* 加载话术推荐
|
||
*/
|
||
const loadRecommendations = async () => {
|
||
if (!customerId) {
|
||
Toast.show({ content: '请先获取客户ID', position: 'center' });
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await recommendScripts({
|
||
message: lastMessage || '获取推荐',
|
||
customerId: customerId,
|
||
corpId: localStorage.getItem('corpId') || 'default',
|
||
});
|
||
|
||
if (result.code === 0) {
|
||
setRecommendations(result.data.recommendations);
|
||
} else {
|
||
setError(result.message);
|
||
}
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
Toast.show({ content: '获取推荐失败', position: 'center' });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: 40 }}>
|
||
<Loading color='primary' />
|
||
<span style={{ marginLeft: 8, color: '#999' }}>AI正在分析对话...</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (recommendations.length === 0) {
|
||
return (
|
||
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>
|
||
<div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
|
||
<div>暂无推荐话术</div>
|
||
<button
|
||
onClick={loadRecommendations}
|
||
style={{
|
||
marginTop: 16,
|
||
padding: '6px 16px',
|
||
borderRadius: 16,
|
||
border: 'none',
|
||
background: '#1677ff',
|
||
color: '#fff',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
获取推荐
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* 推荐头部 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '8px 16px',
|
||
background: '#f0f7ff',
|
||
borderBottom: '1px solid #d6e4ff',
|
||
}}>
|
||
<div style={{ fontSize: 12, color: '#1677ff', fontWeight: 500 }}>
|
||
💡 为您推荐 {recommendations.length} 条话术
|
||
</div>
|
||
<button
|
||
onClick={loadRecommendations}
|
||
style={{
|
||
padding: '2px 8px',
|
||
borderRadius: 10,
|
||
border: '1px solid #1677ff',
|
||
background: 'transparent',
|
||
color: '#1677ff',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
{/* 话术卡片列表 */}
|
||
{recommendations.map((script, index) => (
|
||
<ScriptCard key={script.utteranceId} script={script} rank={index + 1} />
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ScriptRecommend;
|
||
```
|
||
|
||
#### 4.1.13 ScriptCard组件(单条话术卡片)
|
||
|
||
```tsx
|
||
// src/components/ScriptCard.tsx
|
||
import React, { useState } from 'react';
|
||
import { useWeComSdk } from '../hooks/useWeComSdk';
|
||
import type { RecommendItem } from '../services/api';
|
||
import { Toast } from 'antd-mobile';
|
||
|
||
interface ScriptCardProps {
|
||
script: RecommendItem;
|
||
rank: number;
|
||
}
|
||
|
||
/**
|
||
* 单条话术卡片
|
||
* 包含话术内容、匹配分数、一键发送/编辑发送按钮
|
||
*/
|
||
const ScriptCard: React.FC<ScriptCardProps> = ({ script, rank }) => {
|
||
const { env, sendScript } = useWeComSdk();
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [isSending, setIsSending] = useState(false);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [editContent, setEditContent] = useState(script.content);
|
||
|
||
// 最大显示字符数
|
||
const MAX_PREVIEW_LENGTH = 120;
|
||
const shouldTruncate = script.content.length > MAX_PREVIEW_LENGTH;
|
||
const displayContent = isExpanded
|
||
? script.content
|
||
: shouldTruncate
|
||
? script.content.substring(0, MAX_PREVIEW_LENGTH) + '...'
|
||
: script.content;
|
||
|
||
/**
|
||
* 一键发送
|
||
*/
|
||
const handleSend = async () => {
|
||
setIsSending(true);
|
||
const content = isEditing ? editContent : script.content;
|
||
// PC端不进入会话,手机端进入会话
|
||
const enterChat = env === 'mobile';
|
||
const success = await sendScript(content, enterChat);
|
||
setIsSending(false);
|
||
|
||
if (success) {
|
||
Toast.show({ content: '已发送到对话框 ✅', position: 'center' });
|
||
// 记录使用反馈
|
||
submitFeedback(script.utteranceId, 'send');
|
||
} else {
|
||
Toast.show({ content: '发送失败,请重试', position: 'center' });
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 编辑后发送
|
||
*/
|
||
const handleEditSend = async () => {
|
||
if (!isEditing) {
|
||
setIsEditing(true);
|
||
return;
|
||
}
|
||
await handleSend();
|
||
setIsEditing(false);
|
||
};
|
||
|
||
/**
|
||
* 提交反馈
|
||
*/
|
||
const submitFeedback = (utteranceId: string, action: string) => {
|
||
// 异步提交反馈
|
||
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/v1/recommend/feedback`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ utteranceId, action }),
|
||
}).catch(() => { /* 静默处理 */ });
|
||
};
|
||
|
||
return (
|
||
<div className="script-card">
|
||
{/* 卡片头部 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{/* 排名 */}
|
||
<span style={{
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: 10,
|
||
background: rank <= 3 ? '#1677ff' : '#f0f0f0',
|
||
color: rank <= 3 ? '#fff' : '#999',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}>
|
||
{rank}
|
||
</span>
|
||
{/* 标题 */}
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>
|
||
{script.title}
|
||
</span>
|
||
</div>
|
||
{/* 匹配分数 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<span style={{
|
||
padding: '2px 6px',
|
||
borderRadius: 8,
|
||
background: script.score > 0.8 ? '#f6ffed' : script.score > 0.6 ? '#fffbe6' : '#fff2f0',
|
||
color: script.score > 0.8 ? '#52c41a' : script.score > 0.6 ? '#faad14' : '#ff4d4f',
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
}}>
|
||
{Math.round(script.score * 100)}%匹配
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 话术内容 */}
|
||
<div style={{ marginBottom: 8 }}>
|
||
{isEditing ? (
|
||
<textarea
|
||
value={editContent}
|
||
onChange={(e) => setEditContent(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
minHeight: 100,
|
||
padding: 8,
|
||
borderRadius: 8,
|
||
border: '1px solid #1677ff',
|
||
fontSize: 13,
|
||
lineHeight: 1.6,
|
||
resize: 'vertical',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
) : (
|
||
<pre style={{
|
||
fontSize: 13,
|
||
lineHeight: 1.6,
|
||
color: '#444',
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
fontFamily: 'inherit',
|
||
margin: 0,
|
||
}}>
|
||
{displayContent}
|
||
</pre>
|
||
)}
|
||
|
||
{/* 展开/收起 */}
|
||
{shouldTruncate && !isEditing && (
|
||
<button
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
style={{
|
||
marginTop: 4,
|
||
border: 'none',
|
||
background: 'transparent',
|
||
color: '#1677ff',
|
||
fontSize: 12,
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
}}
|
||
>
|
||
{isExpanded ? '收起 ▲' : '展开 ▼'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 元数据 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 10,
|
||
fontSize: 11,
|
||
color: '#999',
|
||
}}>
|
||
<span>📊 成功率{Math.round((script.successRate || 0) * 100)}%</span>
|
||
<span>🔥 使用{script.usedCount}次</span>
|
||
<span style={{
|
||
padding: '1px 4px',
|
||
borderRadius: 4,
|
||
background: script.source === 'LLM_GENERATED' ? '#e6f7ff' : '#f6ffed',
|
||
color: script.source === 'LLM_GENERATED' ? '#1677ff' : '#52c41a',
|
||
fontSize: 10,
|
||
}}>
|
||
{script.source === 'LLM_GENERATED' ? '🤖 AI生成' : '📚 话术库'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
{/* 一键发送 */}
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={isSending}
|
||
className="send-btn send-btn-primary"
|
||
style={{ flex: 1 }}
|
||
>
|
||
{isSending ? '发送中...' : '🚀 一键发送'}
|
||
</button>
|
||
|
||
{/* 编辑发送 */}
|
||
<button
|
||
onClick={handleEditSend}
|
||
className="send-btn"
|
||
style={{
|
||
flex: 1,
|
||
background: '#f0f0f0',
|
||
color: '#666',
|
||
}}
|
||
>
|
||
{isEditing ? '💾 保存发送' : '✏️ 编辑发送'}
|
||
</button>
|
||
|
||
{/* 取消编辑 */}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
setIsEditing(false);
|
||
setEditContent(script.content);
|
||
}}
|
||
className="send-btn"
|
||
style={{
|
||
background: '#fff2f0',
|
||
color: '#ff4d4f',
|
||
}}
|
||
>
|
||
❌ 取消
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ScriptCard;
|
||
```
|
||
|
||
#### 4.1.14 TabBar组件
|
||
|
||
```tsx
|
||
// src/components/TabBar.tsx
|
||
import React from 'react';
|
||
|
||
interface TabBarProps {
|
||
activeTab: 'recommend' | 'analysis' | 'search';
|
||
onChange: (tab: 'recommend' | 'analysis' | 'search') => void;
|
||
}
|
||
|
||
/**
|
||
* Tab切换栏
|
||
* 智能推荐 / 对话分析 / 话术搜索
|
||
*/
|
||
const TabBar: React.FC<TabBarProps> = ({ activeTab, onChange }) => {
|
||
const tabs = [
|
||
{ key: 'recommend' as const, label: '智能推荐', icon: '💡' },
|
||
{ key: 'analysis' as const, label: '对话分析', icon: '📊' },
|
||
{ key: 'search' as const, label: '话术搜索', icon: '🔍' },
|
||
];
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex',
|
||
background: '#fff',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
flexShrink: 0,
|
||
}}>
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => onChange(tab.key)}
|
||
style={{
|
||
flex: 1,
|
||
padding: '10px 0',
|
||
border: 'none',
|
||
background: 'transparent',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
color: activeTab === tab.key ? '#1677ff' : '#666',
|
||
fontWeight: activeTab === tab.key ? 600 : 400,
|
||
borderBottom: activeTab === tab.key ? '2px solid #1677ff' : '2px solid transparent',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
<span style={{ marginRight: 4 }}>{tab.icon}</span>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TabBar;
|
||
```
|
||
|
||
#### 4.1.15 AnalysisPanel组件(对话分析面板)
|
||
|
||
```tsx
|
||
// src/components/AnalysisPanel.tsx
|
||
import React from 'react';
|
||
import { useProfileStore } from '../stores/useProfileStore';
|
||
|
||
/**
|
||
* 对话分析面板
|
||
* 展示对话摘要、意图历史、关键信息等
|
||
*/
|
||
const AnalysisPanel: React.FC = () => {
|
||
const profile = useProfileStore();
|
||
|
||
return (
|
||
<div style={{ padding: 16 }}>
|
||
{/* 对话摘要 */}
|
||
<Section title="📋 对话摘要" icon="📋">
|
||
<div style={{ fontSize: 13, color: '#666', lineHeight: 1.8 }}>
|
||
学员为{profile.studentType || '未知类型'},当前处于
|
||
<strong style={{ color: '#1677ff' }}>{profile.decisionStage || '信息了解'}</strong>阶段。
|
||
关注重点为{profile.concernFocus || '未明确'},意向度
|
||
<strong style={{ color: profile.intentLevel === '高' ? '#ff4d4f' : '#faad14' }}>
|
||
{profile.intentLevel || '低'}
|
||
</strong>。
|
||
</div>
|
||
</Section>
|
||
|
||
{/* 关键信息 */}
|
||
<Section title="🔑 关键信息" icon="🔑">
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||
<InfoItem label="学员类型" value={profile.studentType || '-'} />
|
||
<InfoItem label="意向度" value={profile.intentLevel || '-'} />
|
||
<InfoItem label="关注重点" value={profile.concernFocus || '-'} />
|
||
<InfoItem label="决策阶段" value={profile.decisionStage || '-'} />
|
||
<InfoItem label="对话次数" value={String(profile.conversationCount || 0)} />
|
||
<InfoItem label="意向分数" value={String(Math.round(profile.intentScore || 0))} />
|
||
</div>
|
||
</Section>
|
||
|
||
{/* 建议话术方向 */}
|
||
<Section title="💡 建议话术方向" icon="💡">
|
||
<div style={{ fontSize: 13, color: '#666', lineHeight: 1.8 }}>
|
||
{getStageAdvice(profile.decisionStage)}
|
||
</div>
|
||
</Section>
|
||
|
||
{/* 风险提示 */}
|
||
{profile.intentLevel === '低' && (
|
||
<div style={{
|
||
marginTop: 12,
|
||
padding: 12,
|
||
borderRadius: 8,
|
||
background: '#fffbe6',
|
||
border: '1px solid #ffe58f',
|
||
}}>
|
||
<div style={{ fontSize: 13, color: '#d48806' }}>
|
||
⚠️ 当前学员意向度较低,建议耐心引导,先建立信任关系,避免急于推销。
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 获取阶段建议
|
||
*/
|
||
function getStageAdvice(stage: string): string {
|
||
switch (stage) {
|
||
case '开场白':
|
||
return '1. 热情问候,建立信任\n2. 了解学员基本信息\n3. 确认学员兴趣方向\n4. 引导进入需求探询阶段';
|
||
case '需求探询':
|
||
return '1. 深入了解学员背景和目标\n2. 挖掘核心需求\n3. 记录关键信息到画像\n4. 适时引入课程推荐';
|
||
case '课程推荐':
|
||
return '1. 精准匹配1-2个课程方向\n2. 详细介绍匹配课程的亮点\n3. 用数据支撑推荐\n4. 引导进入价值塑造阶段';
|
||
case '价值塑造':
|
||
return '1. 强调品牌优势和差异化\n2. 展示成功案例\n3. 提及权威背书(央视报道等)\n4. 消除学员顾虑';
|
||
case '报价沟通':
|
||
return '1. 明确说明价格\n2. 强调投入产出比\n3. 介绍分期付款方案\n4. 提供限时优惠';
|
||
case '异议处理':
|
||
return '1. 先认同学员顾虑\n2. 用事实和数据回应\n3. 提供替代方案\n4. 引导回到正向话题';
|
||
case '促成报名':
|
||
return '1. 制造紧迫感\n2. 明确报名流程\n3. 提供额外优惠\n4. 协助完成报名';
|
||
default:
|
||
return '根据学员画像和对话上下文,灵活调整话术策略。重点关注学员的核心需求和顾虑点。';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 信息项子组件
|
||
*/
|
||
const InfoItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||
<div style={{
|
||
padding: 8,
|
||
borderRadius: 8,
|
||
background: '#f5f5f5',
|
||
}}>
|
||
<div style={{ fontSize: 11, color: '#999', marginBottom: 2 }}>{label}</div>
|
||
<div style={{ fontSize: 13, color: '#333', fontWeight: 500 }}>{value}</div>
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* 区块子组件
|
||
*/
|
||
const Section: React.FC<{ title: string; icon: string; children: React.ReactNode }> = ({
|
||
title,
|
||
children,
|
||
}) => (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: '#333',
|
||
marginBottom: 8,
|
||
paddingLeft: 8,
|
||
borderLeft: '3px solid #1677ff',
|
||
}}>
|
||
{title}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
|
||
export default AnalysisPanel;
|
||
```
|
||
|
||
### 4.2 管理后台
|
||
|
||
管理后台基于React + Ant Design实现,包含话术库管理、数据统计看板等功能。
|
||
|
||
#### 4.2.1 管理后台路由配置
|
||
|
||
```typescript
|
||
// frontend/admin/src/router.tsx
|
||
import React from 'react';
|
||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||
import Layout from './components/Layout';
|
||
import Dashboard from './pages/Dashboard';
|
||
import UtteranceList from './pages/UtteranceList';
|
||
import UtteranceEdit from './pages/UtteranceEdit';
|
||
import DataStats from './pages/DataStats';
|
||
import Settings from './pages/Settings';
|
||
|
||
/**
|
||
* 管理后台路由
|
||
*/
|
||
const AdminRouter: React.FC = () => (
|
||
<BrowserRouter>
|
||
<Routes>
|
||
<Route path="/" element={<Layout />}>
|
||
<Route index element={<Dashboard />} />
|
||
<Route path="utterances" element={<UtteranceList />} />
|
||
<Route path="utterances/edit/:id?" element={<UtteranceEdit />} />
|
||
<Route path="stats" element={<DataStats />} />
|
||
<Route path="settings" element={<Settings />} />
|
||
</Route>
|
||
</Routes>
|
||
</BrowserRouter>
|
||
);
|
||
|
||
export default AdminRouter;
|
||
```
|
||
|
||
#### 4.2.2 话术库管理页面
|
||
|
||
```tsx
|
||
// frontend/admin/src/pages/UtteranceList.tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { Table, Button, Input, Tag, Space, Modal, message } from 'antd';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
/**
|
||
* 话术库管理页面
|
||
* 话术列表、搜索、启用/停用、删除
|
||
*/
|
||
const UtteranceList: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const [loading, setLoading] = useState(false);
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
|
||
|
||
// 加载话术列表
|
||
const loadData = async (page: number = 1, pageSize: number = 20) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch(
|
||
`/api/v1/kb/utterances?page=${page}&size=${pageSize}&keyword=${keyword}`
|
||
);
|
||
const result = await response.json();
|
||
if (result.code === 0) {
|
||
setData(result.data.list);
|
||
setPagination({ ...pagination, current: page, total: result.data.total });
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { loadData(); }, []);
|
||
|
||
const columns = [
|
||
{ title: '话术ID', dataIndex: 'utteranceId', key: 'utteranceId', width: 180 },
|
||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||
{
|
||
title: '阶段',
|
||
dataIndex: 'stageTags',
|
||
key: 'stageTags',
|
||
render: (tags: string) => {
|
||
try {
|
||
const tagList = JSON.parse(tags);
|
||
return tagList.map((t: string) => <Tag key={t} color="blue">{t}</Tag>);
|
||
} catch { return '-'; }
|
||
},
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
render: (status: string) => (
|
||
<Tag color={status === 'ACTIVE' ? 'green' : status === 'DRAFT' ? 'orange' : 'red'}>
|
||
{status === 'ACTIVE' ? '启用' : status === 'DRAFT' ? '草稿' : '停用'}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '成功率',
|
||
dataIndex: 'successRate',
|
||
key: 'successRate',
|
||
render: (rate: number) => `${Math.round((rate || 0) * 100)}%`,
|
||
},
|
||
{ title: '使用次数', dataIndex: 'usedCount', key: 'usedCount' },
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
<Button type="link" onClick={() => navigate(`/utterances/edit/${record.utteranceId}`)}>
|
||
编辑
|
||
</Button>
|
||
<Button type="link" danger onClick={() => handleDelete(record.utteranceId)}>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const handleDelete = (utteranceId: string) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除话术「${utteranceId}」吗?`,
|
||
onOk: async () => {
|
||
await fetch(`/api/v1/kb/utterances/${utteranceId}`, { method: 'DELETE' });
|
||
message.success('删除成功');
|
||
loadData(pagination.current);
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||
<Input.Search
|
||
placeholder="搜索话术标题/内容"
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
onSearch={() => loadData(1)}
|
||
style={{ width: 300 }}
|
||
/>
|
||
<Button type="primary" onClick={() => navigate('/utterances/edit')}>
|
||
+ 新建话术
|
||
</Button>
|
||
</div>
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={data}
|
||
loading={loading}
|
||
pagination={pagination}
|
||
onChange={(p) => loadData(p.current, p.pageSize)}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default UtteranceList;
|
||
```
|
||
|
||
---
|
||
|
||
## 第五部分:企微集成配置指南
|
||
|
||
### 5.1 企微后台配置步骤
|
||
|
||
#### 5.1.1 注册企业微信
|
||
|
||
1. 访问 https://work.weixin.qq.com/ 注册企业
|
||
2. 完成企业认证(可选,但认证后功能更完整)
|
||
3. 获取企业ID(CorpID)- 在「我的企业」→「企业ID」中查看
|
||
|
||
#### 5.1.2 创建自建应用
|
||
|
||
1. 进入「应用管理」
|
||
2. 点击「创建应用」
|
||
3. 填写应用信息:
|
||
- 应用名称:AI话术助手
|
||
- 应用Logo:上传应用图标
|
||
- 可见范围:选择需要使用的部门/人员
|
||
4. 创建完成后记录:
|
||
- AgentId
|
||
- Secret(点击「查看」获取)
|
||
|
||
#### 5.1.3 配置可信域名
|
||
|
||
1. 进入应用详情页 →「网页授权及JS-SDK」
|
||
2. 点击「设置可信域名」
|
||
3. 填写域名:
|
||
- 申请校验域名(下载校验文件上传到服务器根目录)
|
||
- 或者使用已备案域名,上传校验文件验证
|
||
4. 同时配置「授权回调域」(用于OAuth登录)
|
||
|
||
#### 5.1.4 申请会话内容存档
|
||
|
||
1. 进入「管理工具」→「会话内容存档」
|
||
2. 点击「开通」并选择开通范围(全部或部分成员)
|
||
3. 开通后获取:
|
||
- 企业ID(CorpID)
|
||
- 存档Secret(在「会话内容存档」→「API」中获取)
|
||
4. 下载C SDK(按操作系统选择Linux/Windows版本)
|
||
|
||
#### 5.1.5 配置聊天工具栏
|
||
|
||
1. 进入「客户联系」→「加客户」→「聊天工具栏」
|
||
2. 点击「配置应用页面」
|
||
3. 选择刚才创建的「AI话术助手」应用
|
||
4. 配置页面参数:
|
||
- 页面名称:AI话术助手
|
||
- 页面URL:https://your-domain.com/sidebar/
|
||
- 点击「确定」保存
|
||
|
||
#### 5.1.6 配置回调URL
|
||
|
||
1. 进入「管理工具」→「会话内容存档」→「API」
|
||
2. 配置回调:
|
||
- 回调URL:https://your-domain.com/api/v1/archive/callback
|
||
- Token:随机生成16位字符串
|
||
- EncodingAESKey:点击「随机获取」
|
||
3. 记录Token和EncodingAESKey(后端配置需要)
|
||
|
||
### 5.2 RSA密钥生成与配置
|
||
|
||
#### 5.2.1 生成RSA密钥对
|
||
|
||
```bash
|
||
# 使用OpenSSL生成2048位RSA密钥对
|
||
|
||
# 1. 生成私钥(PKCS#1格式,企微要求)
|
||
openssl genrsa -out private_key.pem 2048
|
||
|
||
# 2. 从私钥提取公钥
|
||
openssl rsa -in private_key.pem -pubout -out public_key.pem
|
||
|
||
# 3. 将公钥转为Base64(上传到企微后台)
|
||
base64 -w 0 public_key.pem > public_key_base64.txt
|
||
|
||
# 4. 查看私钥(妥善保存,不要泄露)
|
||
cat private_key.pem
|
||
|
||
# 5. 查看公钥Base64
|
||
cat public_key_base64.txt
|
||
```
|
||
|
||
#### 5.2.2 上传公钥到企微后台
|
||
|
||
1. 进入「管理工具」→「会话内容存档」→「API」
|
||
2. 找到「公钥配置」
|
||
3. 将 public_key_base64.txt 的内容粘贴进去
|
||
4. 点击「保存」
|
||
|
||
#### 5.2.3 私钥存储到服务器
|
||
|
||
```bash
|
||
# 1. 创建密钥目录
|
||
mkdir -p /opt/ai-assistant/keys
|
||
chmod 700 /opt/ai-assistant/keys
|
||
|
||
# 2. 将私钥复制到服务器
|
||
cp private_key.pem /opt/ai-assistant/keys/
|
||
chmod 600 /opt/ai-assistant/keys/private_key.pem
|
||
|
||
# 3. 设置环境变量(在docker-compose.yml或启动脚本中)
|
||
export ARCHIVE_RSA_PRIVATE_KEY_PATH=/opt/ai-assistant/keys/private_key.pem
|
||
|
||
# 或者将私钥内容直接作为环境变量(不推荐,太长)
|
||
# export ARCHIVE_RSA_PRIVATE_KEY=$(cat /opt/ai-assistant/keys/private_key.pem)
|
||
```
|
||
|
||
### 5.3 JS-SDK配置代码
|
||
|
||
#### 5.3.1 后端签名生成接口
|
||
|
||
```java
|
||
package com.artedu.auth.controller;
|
||
|
||
import org.springframework.web.bind.annotation.*;
|
||
import java.security.MessageDigest;
|
||
import java.util.Formatter;
|
||
import java.util.UUID;
|
||
|
||
/**
|
||
* 企微JS-SDK签名接口
|
||
*/
|
||
@RestController
|
||
@RequestMapping("/api/v1/auth")
|
||
public class JsSdkSignatureController {
|
||
|
||
/**
|
||
* 获取JS-SDK签名
|
||
* 前端调用ww.register时需要
|
||
*/
|
||
@GetMapping("/signature")
|
||
public Result<Map<String, Object>> getSignature(
|
||
@RequestParam("url") String url,
|
||
@RequestParam("corpId") String corpId) {
|
||
|
||
try {
|
||
// 1. 获取jsapi_ticket(通过企微API)
|
||
String ticket = getJsApiTicket(corpId);
|
||
|
||
// 2. 生成随机字符串
|
||
String nonceStr = UUID.randomUUID().toString().replace("-", "");
|
||
|
||
// 3. 生成时间戳
|
||
long timestamp = System.currentTimeMillis() / 1000;
|
||
|
||
// 4. 拼接签名字符串
|
||
String string1 = String.format(
|
||
"jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s",
|
||
ticket, nonceStr, timestamp, url
|
||
);
|
||
|
||
// 5. SHA1签名
|
||
String signature = sha1(string1);
|
||
|
||
Map<String, Object> result = new HashMap<>();
|
||
result.put("corpId", corpId);
|
||
result.put("agentId", getAgentId(corpId));
|
||
result.put("nonceStr", nonceStr);
|
||
result.put("timestamp", timestamp);
|
||
result.put("signature", signature);
|
||
|
||
return Result.success(result);
|
||
|
||
} catch (Exception e) {
|
||
log.error("生成签名失败", e);
|
||
return Result.fail("生成签名失败");
|
||
}
|
||
}
|
||
|
||
private String sha1(String input) throws Exception {
|
||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||
byte[] digest = md.digest(input.getBytes());
|
||
try (Formatter formatter = new Formatter()) {
|
||
for (byte b : digest) {
|
||
formatter.format("%02x", b);
|
||
}
|
||
return formatter.toString();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 5.3.2 前端ww.register调用
|
||
|
||
已在「第四部分」4.1.6节提供完整代码,此处补充完整流程图:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as 前端H5
|
||
participant BE as 后端服务
|
||
participant WeComAPI as 企微API
|
||
participant WeComClient as 企微客户端
|
||
|
||
FE->>BE: 1. GET /api/v1/auth/signature?url=当前页面URL
|
||
BE->>WeComAPI: 2. 获取jsapi_ticket(用access_token)
|
||
WeComAPI-->>BE: 返回ticket
|
||
BE->>BE: 3. 生成nonceStr + timestamp
|
||
BE->>BE: 4. SHA1签名(ticket+nonceStr+timestamp+url)
|
||
BE-->>FE: 返回{corpId, agentId, nonceStr, timestamp, signature}
|
||
|
||
FE->>FE: 5. 判断iOS/Android
|
||
alt iOS
|
||
FE->>FE: setTimeout 100ms后调用
|
||
end
|
||
FE->>WeComClient: 6. ww.register({corpId, agentId, jsApiList, getAgentConfigSignature})
|
||
WeComClient-->>FE: 注册成功回调
|
||
FE->>WeComClient: 7. ww.invoke('getCurExternalContact')
|
||
WeComClient-->>FE: 返回customerId
|
||
FE->>FE: 8. 初始化完成,加载话术推荐
|
||
```
|
||
|
||
---
|
||
|
||
## 第六部分:部署与运维
|
||
|
||
### 6.1 Docker Compose部署
|
||
|
||
#### 6.1.1 完整docker-compose.yml
|
||
|
||
```yaml
|
||
version: '3.8'
|
||
|
||
services:
|
||
# ========== 前端 ==========
|
||
sidebar:
|
||
build:
|
||
context: ./frontend/sidebar
|
||
dockerfile: Dockerfile
|
||
container_name: ai-sidebar
|
||
ports:
|
||
- "3000:80"
|
||
depends_on:
|
||
- gateway
|
||
networks:
|
||
- ai-network
|
||
|
||
admin:
|
||
build:
|
||
context: ./frontend/admin
|
||
dockerfile: Dockerfile
|
||
container_name: ai-admin
|
||
ports:
|
||
- "3001:80"
|
||
depends_on:
|
||
- gateway
|
||
networks:
|
||
- ai-network
|
||
|
||
# ========== API网关 ==========
|
||
gateway:
|
||
build:
|
||
context: ./backend/gateway
|
||
dockerfile: Dockerfile
|
||
container_name: ai-gateway
|
||
ports:
|
||
- "8080:8080"
|
||
environment:
|
||
- NACOS_HOST=nacos
|
||
- NACOS_PORT=8848
|
||
depends_on:
|
||
- nacos
|
||
- redis
|
||
- rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
# ========== 业务服务 ==========
|
||
auth-service:
|
||
build:
|
||
context: ./backend/auth-service
|
||
dockerfile: Dockerfile
|
||
container_name: ai-auth
|
||
environment:
|
||
- SERVER_PORT=8081
|
||
- MYSQL_HOST=mysql
|
||
- MYSQL_PORT=3306
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- MYSQL_USERNAME=root
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- WECOM_CORP_ID=${WECOM_CORP_ID}
|
||
- WECOM_AGENT_ID=${WECOM_AGENT_ID}
|
||
- WECOM_SECRET=${WECOM_SECRET}
|
||
- JWT_SECRET=${JWT_SECRET}
|
||
- NACOS_HOST=nacos
|
||
depends_on:
|
||
- mysql
|
||
- redis
|
||
networks:
|
||
- ai-network
|
||
|
||
archive-service:
|
||
build:
|
||
context: ./backend/archive-service
|
||
dockerfile: Dockerfile
|
||
container_name: ai-archive
|
||
environment:
|
||
- SERVER_PORT=8082
|
||
- MYSQL_HOST=mysql
|
||
- MYSQL_PORT=3306
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- MYSQL_USERNAME=root
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- WECOM_ARCHIVE_CORP_ID=${WECOM_CORP_ID}
|
||
- WECOM_ARCHIVE_SECRET=${WECOM_ARCHIVE_SECRET}
|
||
- ARCHIVE_RSA_PRIVATE_KEY_PATH=/keys/private_key.pem
|
||
- NACOS_HOST=nacos
|
||
volumes:
|
||
- ./keys:/keys:ro
|
||
depends_on:
|
||
- mysql
|
||
- redis
|
||
- rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
conversation-service:
|
||
build:
|
||
context: ./backend/conversation-service
|
||
dockerfile: Dockerfile
|
||
container_name: ai-conversation
|
||
environment:
|
||
- SERVER_PORT=8083
|
||
- MYSQL_HOST=mysql
|
||
- MYSQL_PORT=3306
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- MYSQL_USERNAME=root
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- RABBITMQ_HOST=rabbitmq
|
||
- RABBITMQ_PORT=5672
|
||
- RABBITMQ_USERNAME=guest
|
||
- RABBITMQ_PASSWORD=guest
|
||
- NACOS_HOST=nacos
|
||
depends_on:
|
||
- mysql
|
||
- redis
|
||
- rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
intent-service:
|
||
build:
|
||
context: ./backend/intent-service
|
||
dockerfile: Dockerfile
|
||
container_name: ai-intent
|
||
environment:
|
||
- SERVER_PORT=8084
|
||
- MYSQL_HOST=mysql
|
||
- MYSQL_PORT=3306
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- MYSQL_USERNAME=root
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- LLM_QIANWEN_API_KEY=${LLM_API_KEY}
|
||
- LLM_QIANWEN_MODEL=qwen-turbo
|
||
- NACOS_HOST=nacos
|
||
depends_on:
|
||
- mysql
|
||
- redis
|
||
- rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
recommendation-service:
|
||
build:
|
||
context: ./backend/recommendation-service
|
||
dockerfile: Dockerfile
|
||
container_name: ai-recommend
|
||
environment:
|
||
- SERVER_PORT=8085
|
||
- MYSQL_HOST=mysql
|
||
- MYSQL_PORT=3306
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- MYSQL_USERNAME=root
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
- REDIS_HOST=redis
|
||
- REDIS_PORT=6379
|
||
- LLM_QIANWEN_API_KEY=${LLM_API_KEY}
|
||
- LLM_QIANWEN_MODEL=qwen-turbo
|
||
- NACOS_HOST=nacos
|
||
depends_on:
|
||
- mysql
|
||
- redis
|
||
- rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
# ========== 基础设施 ==========
|
||
nginx:
|
||
image: nginx:1.25-alpine
|
||
container_name: ai-nginx
|
||
ports:
|
||
- "80:80"
|
||
- "443:443"
|
||
volumes:
|
||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||
- ./ssl:/etc/nginx/ssl:ro
|
||
depends_on:
|
||
- gateway
|
||
- sidebar
|
||
- admin
|
||
networks:
|
||
- ai-network
|
||
|
||
mysql:
|
||
image: mysql:5.7
|
||
container_name: ai-mysql
|
||
ports:
|
||
- "3306:3306"
|
||
environment:
|
||
- MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD}
|
||
- MYSQL_DATABASE=ai_assistant
|
||
- TZ=Asia/Shanghai
|
||
volumes:
|
||
- mysql_data:/var/lib/mysql
|
||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||
- ./mysql.cnf:/etc/mysql/conf.d/custom.cnf:ro
|
||
command: >
|
||
--character-set-server=utf8mb4
|
||
--collation-server=utf8mb4_unicode_ci
|
||
--default-time-zone=+8:00
|
||
networks:
|
||
- ai-network
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
container_name: ai-redis
|
||
ports:
|
||
- "6379:6379"
|
||
volumes:
|
||
- redis_data:/data
|
||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||
networks:
|
||
- ai-network
|
||
|
||
rabbitmq:
|
||
image: rabbitmq:3.12-management-alpine
|
||
container_name: ai-rabbitmq
|
||
ports:
|
||
- "5672:5672"
|
||
- "15672:15672"
|
||
environment:
|
||
- RABBITMQ_DEFAULT_USER=guest
|
||
- RABBITMQ_DEFAULT_PASS=guest
|
||
volumes:
|
||
- rabbitmq_data:/var/lib/rabbitmq
|
||
networks:
|
||
- ai-network
|
||
|
||
nacos:
|
||
image: nacos/nacos-server:v2.2.3
|
||
container_name: ai-nacos
|
||
ports:
|
||
- "8848:8848"
|
||
environment:
|
||
- MODE=standalone
|
||
- PREFER_HOST_MODE=hostname
|
||
- SPRING_DATASOURCE_PLATFORM=mysql
|
||
- MYSQL_SERVICE_HOST=mysql
|
||
- MYSQL_SERVICE_PORT=3306
|
||
- MYSQL_SERVICE_DB_NAME=nacos
|
||
- MYSQL_SERVICE_USER=root
|
||
- MYSQL_SERVICE_PASSWORD=${MYSQL_PASSWORD}
|
||
depends_on:
|
||
- mysql
|
||
networks:
|
||
- ai-network
|
||
|
||
volumes:
|
||
mysql_data:
|
||
redis_data:
|
||
rabbitmq_data:
|
||
|
||
networks:
|
||
ai-network:
|
||
driver: bridge
|
||
```
|
||
|
||
#### 6.1.2 Nginx配置
|
||
|
||
```nginx
|
||
# nginx.conf
|
||
user nginx;
|
||
worker_processes auto;
|
||
error_log /var/log/nginx/error.log warn;
|
||
pid /var/run/nginx.pid;
|
||
|
||
events {
|
||
worker_connections 1024;
|
||
}
|
||
|
||
http {
|
||
include /etc/nginx/mime.types;
|
||
default_type application/octet-stream;
|
||
|
||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||
'$status $body_bytes_sent "$http_referer" '
|
||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||
access_log /var/log/nginx/access.log main;
|
||
|
||
# Gzip压缩
|
||
gzip on;
|
||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||
gzip_min_length 1000;
|
||
|
||
# 企微可信域名校验
|
||
server {
|
||
listen 80;
|
||
server_name your-domain.com;
|
||
|
||
location /WW_verify_*.txt {
|
||
root /var/www;
|
||
}
|
||
|
||
# 侧边栏H5
|
||
location /sidebar/ {
|
||
proxy_pass http://sidebar:80/;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
# 管理后台
|
||
location /admin/ {
|
||
proxy_pass http://admin:80/;
|
||
proxy_set_header Host $host;
|
||
}
|
||
|
||
# API网关
|
||
location /api/ {
|
||
proxy_pass http://gateway:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_connect_timeout 30s;
|
||
proxy_send_timeout 30s;
|
||
proxy_read_timeout 30s;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.2 MySQL 5.7配置优化
|
||
|
||
#### 6.2.1 my.cnf关键参数
|
||
|
||
```ini
|
||
# mysql.cnf - MySQL 5.7优化配置
|
||
[mysqld]
|
||
# 基础配置
|
||
port = 3306
|
||
character-set-server = utf8mb4
|
||
collation-server = utf8mb4_unicode_ci
|
||
skip-character-set-client-handshake
|
||
default-time-zone = +8:00
|
||
|
||
# InnoDB配置
|
||
innodb_buffer_pool_size = 512M
|
||
innodb_log_file_size = 128M
|
||
innodb_flush_log_at_trx_commit = 2
|
||
innodb_flush_method = O_DIRECT
|
||
innodb_file_per_table = 1
|
||
|
||
# 连接配置
|
||
max_connections = 200
|
||
wait_timeout = 28800
|
||
interactive_timeout = 28800
|
||
|
||
# 查询缓存(MySQL 5.7已废弃,关闭)
|
||
query_cache_type = 0
|
||
query_cache_size = 0
|
||
|
||
# 临时表
|
||
tmp_table_size = 64M
|
||
max_heap_table_size = 64M
|
||
|
||
# 日志
|
||
slow_query_log = 1
|
||
slow_query_log_file = /var/log/mysql/slow.log
|
||
long_query_time = 2
|
||
|
||
# 排序和连接
|
||
sort_buffer_size = 2M
|
||
join_buffer_size = 2M
|
||
read_buffer_size = 1M
|
||
read_rnd_buffer_size = 2M
|
||
|
||
# 安全
|
||
local_infile = 0
|
||
symbolic-links = 0
|
||
|
||
[mysql]
|
||
default-character-set = utf8mb4
|
||
|
||
[client]
|
||
default-character-set = utf8mb4
|
||
```
|
||
|
||
#### 6.2.2 连接池配置
|
||
|
||
```yaml
|
||
# 各服务的application.yml中的连接池配置
|
||
spring:
|
||
datasource:
|
||
hikari:
|
||
minimum-idle: 5
|
||
maximum-pool-size: 20
|
||
idle-timeout: 300000
|
||
max-lifetime: 1200000
|
||
connection-timeout: 20000
|
||
pool-name: HikariPool-AI
|
||
```
|
||
|
||
### 6.3 环境变量清单
|
||
|
||
| 变量名 | 说明 | 开发环境 | 生产环境 |
|
||
|--------|------|---------|---------|
|
||
| `MYSQL_HOST` | MySQL主机地址 | localhost | mysql |
|
||
| `MYSQL_PORT` | MySQL端口 | 3306 | 3306 |
|
||
| `MYSQL_DATABASE` | 数据库名 | ai_assistant | ai_assistant |
|
||
| `MYSQL_USERNAME` | MySQL用户名 | root | root |
|
||
| `MYSQL_PASSWORD` | MySQL密码 | root | (强密码) |
|
||
| `REDIS_HOST` | Redis主机地址 | localhost | redis |
|
||
| `REDIS_PORT` | Redis端口 | 6379 | 6379 |
|
||
| `REDIS_PASSWORD` | Redis密码 | (空) | (强密码) |
|
||
| `RABBITMQ_HOST` | RabbitMQ主机 | localhost | rabbitmq |
|
||
| `RABBITMQ_PORT` | RabbitMQ端口 | 5672 | 5672 |
|
||
| `RABBITMQ_USERNAME` | RabbitMQ用户名 | guest | (自定义) |
|
||
| `RABBITMQ_PASSWORD` | RabbitMQ密码 | guest | (强密码) |
|
||
| `WECOM_CORP_ID` | 企微企业ID | - | wx... |
|
||
| `WECOM_AGENT_ID` | 企微应用ID | - | 1000002 |
|
||
| `WECOM_SECRET` | 企微应用Secret | - | (从企微后台获取) |
|
||
| `WECOM_ARCHIVE_SECRET` | 存档Secret | - | (从企微后台获取) |
|
||
| `JWT_SECRET` | JWT密钥 | dev-secret | (32位以上随机字符串) |
|
||
| `LLM_API_KEY` | 通义千问API Key | - | sk-... |
|
||
| `ARCHIVE_RSA_PRIVATE_KEY_PATH` | RSA私钥路径 | ./keys/private.pem | /keys/private.pem |
|
||
| `NACOS_HOST` | Nacos主机 | localhost | nacos |
|
||
| `NACOS_PORT` | Nacos端口 | 8848 | 8848 |
|
||
|
||
---
|
||
|
||
## 第七部分:开发指南(面向AI编程助手)
|
||
|
||
### 7.1 开发顺序建议
|
||
|
||
推荐按以下顺序开发,每个阶段完成后进行测试:
|
||
|
||
```
|
||
Phase 1: 基础设施(第1-2周)
|
||
├── 1. 数据库初始化(init.sql)
|
||
│ └── 运行建表SQL + 初始化数据
|
||
├── 2. 认证授权服务(auth-service)
|
||
│ └── 企微OAuth + JWT + 员工管理
|
||
├── 3. Docker环境搭建
|
||
│ └── docker-compose up -d mysql redis rabbitmq nacos
|
||
└── 测试:确认登录流程可用
|
||
|
||
Phase 2: 数据接入(第3-4周)
|
||
├── 4. 会话存档服务(archive-service)
|
||
│ └── 回调接收 + SDK集成 + 消息存储
|
||
├── 5. 对话服务(conversation-service)
|
||
│ └── 轮次解析 + 上下文管理
|
||
└── 测试:确认消息能正常存入数据库
|
||
|
||
Phase 3: AI引擎(第5-7周)
|
||
├── 6. 意图服务(intent-service)
|
||
│ └── 规则匹配 + LLM识别 + 画像构建
|
||
├── 7. LLM生成服务(generation-service)
|
||
│ └── Prompt工程 + Token控制 + 流式输出
|
||
├── 8. 推荐服务(recommendation-service)
|
||
│ └── 三层召回 + 多因子排序 + MMR
|
||
└── 测试:确认推荐结果合理
|
||
|
||
Phase 4: 前端开发(第8-9周)
|
||
├── 9. 侧边栏H5
|
||
│ ├── PC端适配(360px固定宽度)
|
||
│ ├── 手机端适配(80%高度底部浮层)
|
||
│ ├── 企微JS-SDK集成
|
||
│ └── 一键发送功能
|
||
├── 10. 管理后台
|
||
│ ├── 话术库CRUD
|
||
│ └── 数据统计看板
|
||
└── 测试:确认PC端和手机端都可用
|
||
|
||
Phase 5: 集成测试(第10-12周)
|
||
├── 11. 端到端测试
|
||
│ └── 完整对话流程验证
|
||
├── 12. 性能优化
|
||
│ └── 接口响应时间 < 500ms
|
||
└── 13. 上线部署
|
||
└── 生产环境Docker部署
|
||
```
|
||
|
||
### 7.2 Kimi Code / Claude Code 使用建议
|
||
|
||
#### 7.2.1 如何分模块让AI生成代码
|
||
|
||
建议每次让AI处理一个独立的模块,Prompt示例:
|
||
|
||
```markdown
|
||
## 任务:生成[服务名]的[功能模块]
|
||
|
||
### 上下文
|
||
- 项目:第九联盟AI坐席辅助系统
|
||
- 技术栈:Spring Boot 2.7 + MyBatis-Plus + MySQL 5.7 + Redis + RabbitMQ
|
||
- 当前服务:[服务名],端口[端口号]
|
||
|
||
### 需求
|
||
[详细描述需求]
|
||
|
||
### 数据库表
|
||
```sql
|
||
[建表SQL]
|
||
```
|
||
|
||
### 要求
|
||
1. 完整的Java代码(含package、import、注解)
|
||
2. 使用MyBatis-Plus进行CRUD操作
|
||
3. JSON字段使用TEXT类型存储(MySQL 5.7兼容)
|
||
4. 中文注释
|
||
5. 包含单元测试
|
||
|
||
### 输出
|
||
请生成以下文件:
|
||
1. Entity.java
|
||
2. Mapper.java
|
||
3. Service.java
|
||
4. Controller.java
|
||
5. DTO.java(如需要)
|
||
```
|
||
|
||
#### 7.2.2 代码Review的Prompt模板
|
||
|
||
```markdown
|
||
请对以下Java代码进行Review,关注:
|
||
1. MySQL 5.7兼容性(不能用JSON类型)
|
||
2. 安全性(SQL注入、XSS等)
|
||
3. 性能(N+1查询、索引使用)
|
||
4. 代码风格(命名规范、注释完整性)
|
||
5. 错误处理(异常捕获、降级策略)
|
||
|
||
代码:
|
||
[粘贴代码]
|
||
|
||
请输出:
|
||
1. 发现的问题(严重程度:高/中/低)
|
||
2. 修改建议(含修改后的代码片段)
|
||
3. 评分(1-10分)
|
||
```
|
||
|
||
#### 7.2.3 调试排错的Prompt模板
|
||
|
||
```markdown
|
||
## 问题描述
|
||
[描述遇到的问题]
|
||
|
||
## 错误日志
|
||
```
|
||
[粘贴错误日志]
|
||
```
|
||
|
||
## 相关代码
|
||
```java
|
||
[粘贴相关代码]
|
||
```
|
||
|
||
## 环境信息
|
||
- MySQL 5.7
|
||
- Spring Boot 2.7.x
|
||
- [其他相关信息]
|
||
|
||
请分析可能的原因并给出解决方案。
|
||
```
|
||
|
||
### 7.3 常见问题与解决方案
|
||
|
||
#### 7.3.1 MySQL 5.7 JSON处理
|
||
|
||
**问题**:MySQL 5.7不支持JSON类型,如何存储结构化数据?
|
||
|
||
**解决方案**:
|
||
1. 所有JSON字段使用TEXT类型
|
||
2. 实体类中使用String类型
|
||
3. 添加getter/setter方法进行序列化/反序列化
|
||
4. 使用MyBatis TypeHandler自动处理
|
||
|
||
```java
|
||
// 实体类示例
|
||
@Data
|
||
public class Utterance {
|
||
private Long id;
|
||
private String title;
|
||
private String content;
|
||
// TEXT类型存储JSON字符串
|
||
private String intentTags;
|
||
private String stageTags;
|
||
private String profileTags;
|
||
|
||
// 便捷方法:获取标签列表
|
||
public List<String> getIntentTagList() {
|
||
return JsonUtils.fromJsonList(intentTags, String.class);
|
||
}
|
||
|
||
// 便捷方法:设置标签列表
|
||
public void setIntentTagList(List<String> tags) {
|
||
this.intentTags = JsonUtils.toJson(tags);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 7.3.2 企微JS-SDK调试
|
||
|
||
**问题**:ww.register调用失败,提示"invalid signature"
|
||
|
||
**排查步骤**:
|
||
1. 确认corpId和agentId正确
|
||
2. 确认当前页面URL与签名时使用的URL一致(去掉#后面的部分)
|
||
3. 确认jsapi_ticket未过期(2小时有效期)
|
||
4. 确认签名算法正确(SHA1)
|
||
5. iOS设备需使用setTimeout包裹
|
||
|
||
**调试代码**:
|
||
```typescript
|
||
// 在ww.register前打印调试信息
|
||
console.log('Debug:', {
|
||
corpId,
|
||
agentId,
|
||
url: window.location.href.split('#')[0],
|
||
nonceStr,
|
||
timestamp,
|
||
signature,
|
||
userAgent: navigator.userAgent,
|
||
});
|
||
```
|
||
|
||
#### 7.3.3 LLM调用优化
|
||
|
||
**问题**:LLM调用慢(2-3秒),影响用户体验
|
||
|
||
**解决方案**:
|
||
1. **异步调用**:LLM调用改为异步,不阻塞主流程
|
||
2. **缓存机制**:相同输入的LLM结果缓存5分钟
|
||
3. **降级策略**:LLM失败时返回规则匹配结果
|
||
4. **模型选择**:意图识别用qwen-turbo(快速),话术生成用qwen-plus(高质量)
|
||
5. **流式输出**:对话生成使用SSE流式输出
|
||
|
||
#### 7.3.4 性能调优
|
||
|
||
| 优化项 | 策略 | 目标 |
|
||
|--------|------|------|
|
||
| 数据库查询 | 添加索引 + 分页查询 | 单条查询 < 50ms |
|
||
| LLM调用 | 异步 + 缓存 + 降级 | 首次调用 < 2s,缓存命中 < 10ms |
|
||
| 向量检索 | Redis Hash + 近似计算 | TopK检索 < 100ms |
|
||
| 接口响应 | 线程池 + 超时控制 | P99 < 500ms |
|
||
| 消息处理 | 批量处理 + 队列削峰 | 1000条/秒 |
|
||
|
||
#### 7.3.5 上线检查清单
|
||
|
||
```markdown
|
||
## 上线前检查清单
|
||
|
||
### 数据库
|
||
- [ ] init.sql已成功执行
|
||
- [ ] 所有表使用InnoDB + utf8mb4
|
||
- [ ] 索引已创建
|
||
- [ ] 初始化数据已插入(40个意图 + 8个分类 + 20条话术)
|
||
|
||
### 企微配置
|
||
- [ ] CorpID正确配置
|
||
- [ ] AgentID正确配置
|
||
- [ ] Secret正确配置
|
||
- [ ] 存档Secret正确配置
|
||
- [ ] RSA私钥已上传服务器
|
||
- [ ] 回调URL已配置
|
||
- [ ] 可信域名已配置
|
||
- [ ] 聊天工具栏已配置
|
||
|
||
### 环境变量
|
||
- [ ] MySQL密码已设置(强密码)
|
||
- [ ] Redis密码已设置(强密码)
|
||
- [ ] JWT Secret已设置(32位以上随机字符串)
|
||
- [ ] 通义千问API Key已配置
|
||
|
||
### 服务启动
|
||
- [ ] MySQL已启动且可连接
|
||
- [ ] Redis已启动且可连接
|
||
- [ ] RabbitMQ已启动且可连接
|
||
- [ ] Nacos已启动且可访问
|
||
- [ ] 所有微服务已注册到Nacos
|
||
|
||
### 功能验证
|
||
- [ ] 企微OAuth登录正常
|
||
- [ ] JS-SDK注册成功
|
||
- [ ] 消息存档正常拉取
|
||
- [ ] 意图识别正常
|
||
- [ ] 话术推荐正常
|
||
- [ ] 一键发送正常
|
||
- [ ] 管理后台可访问
|
||
```
|
||
|
||
---
|
||
|
||
## 附录
|
||
|
||
### A. 完整的意图分类表(40个)
|
||
|
||
| 领域 | 编码 | 名称 | 优先级 |
|
||
|------|------|------|--------|
|
||
| 课程相关 | INT-COURSE-01 | 课程内容咨询 | P1 |
|
||
| 课程相关 | INT-COURSE-02 | 课程选择建议 | P1 |
|
||
| 课程相关 | INT-COURSE-03 | 课程大纲索要 | P1 |
|
||
| 课程相关 | INT-COURSE-04 | 试听预约咨询 | P1 |
|
||
| 课程相关 | INT-COURSE-05 | 课程更新迭代 | P2 |
|
||
| 课程相关 | INT-COURSE-06 | 线上vs线下选择 | P1 |
|
||
| 课程相关 | INT-COURSE-07 | 课程难度询问 | P1 |
|
||
| 课程相关 | INT-COURSE-08 | 学习周期询问 | P2 |
|
||
| 价格相关 | INT-PRICE-01 | 学费价格询问 | P1 |
|
||
| 价格相关 | INT-PRICE-02 | 优惠活动询问 | P1 |
|
||
| 价格相关 | INT-PRICE-03 | 分期付款咨询 | P2 |
|
||
| 价格相关 | INT-PRICE-04 | 退费政策询问 | P2 |
|
||
| 价格相关 | INT-PRICE-05 | 性价比比较 | P2 |
|
||
| 就业相关 | INT-JOB-01 | 就业方向咨询 | P1 |
|
||
| 就业相关 | INT-JOB-02 | 薪资水平询问 | P1 |
|
||
| 就业相关 | INT-JOB-03 | 就业率询问 | P1 |
|
||
| 就业相关 | INT-JOB-04 | 就业服务了解 | P2 |
|
||
| 就业相关 | INT-JOB-05 | 合作企业询问 | P2 |
|
||
| 就业相关 | INT-JOB-06 | 作品集指导 | P2 |
|
||
| 就业相关 | INT-JOB-07 | 实习机会咨询 | P3 |
|
||
| 师资相关 | INT-TEACH-01 | 师资背景询问 | P2 |
|
||
| 师资相关 | INT-TEACH-02 | 教学模式了解 | P2 |
|
||
| 师资相关 | INT-TEACH-03 | 课后答疑服务 | P2 |
|
||
| 师资相关 | INT-TEACH-04 | 师生比例询问 | P3 |
|
||
| 师资相关 | INT-TEACH-05 | 学习效果保障 | P2 |
|
||
| 基础条件 | INT-BASIC-01 | 学历要求咨询 | P2 |
|
||
| 基础条件 | INT-BASIC-02 | 年龄限制询问 | P2 |
|
||
| 基础条件 | INT-BASIC-03 | 零基础入学 | P1 |
|
||
| 基础条件 | INT-BASIC-04 | 证书颁发询问 | P3 |
|
||
| 校区相关 | INT-LOC-01 | 校区地址询问 | P2 |
|
||
| 校区相关 | INT-LOC-02 | 校区参观预约 | P2 |
|
||
| 校区相关 | INT-LOC-03 | 教学设备了解 | P3 |
|
||
| 校区相关 | INT-LOC-04 | 住宿安排咨询 | P3 |
|
||
| 机构资质 | INT-QUAL-01 | 机构正规性 | P2 |
|
||
| 机构资质 | INT-QUAL-02 | 口碑评价询问 | P3 |
|
||
| 机构资质 | INT-QUAL-03 | 媒体报道了解 | P3 |
|
||
| 其他 | INT-OTHER-01 | 大师课咨询 | P3 |
|
||
| 其他 | INT-OTHER-02 | 企业培训定制 | P3 |
|
||
| 其他 | INT-OTHER-03 | 比赛活动咨询 | P3 |
|
||
| 其他 | INT-OTHER-04 | 转班转学咨询 | P3 |
|
||
|
||
### B. 核心Prompt模板汇总
|
||
|
||
| Prompt类型 | 模型 | Token预算 | 用途 |
|
||
|-----------|------|----------|------|
|
||
| 意图识别 | qwen-turbo | 2000 | 40意图分类+情感分析 |
|
||
| 话术生成 | qwen-turbo | 800 | 动态生成兜底话术 |
|
||
| 上下文分析 | qwen-turbo | 1500 | 对话状态判断 |
|
||
| 学员画像 | qwen-turbo | 1000 | 画像更新推理 |
|
||
|
||
### C. 参考文档
|
||
|
||
1. 企业微信会话内容存档API文档:https://developer.work.weixin.qq.com/document/path/91360
|
||
2. 企业微信JS-SDK文档:https://developer.work.weixin.qq.com/document/path/90513
|
||
3. 通义千问API文档:https://help.aliyun.com/document_detail/611472.html
|
||
4. MySQL 5.7官方文档:https://dev.mysql.com/doc/refman/5.7/en/
|
||
5. Spring Boot官方文档:https://docs.spring.io/spring-boot/docs/2.7.x/
|
||
6. MyBatis-Plus文档:https://baomidou.com/
|
||
|
||
---
|
||
|
||
> **文档版本**: v3.0-final
|
||
> **最后更新**: 2025年7月
|
||
> **适用对象**: Kimi Code / Claude Code 等AI编程助手
|
||
> **状态**: 可执行开发方案
|