agent_9art/product1.0/v2-final-完整开发方案.md
jiao 31a395be5e feat(01): Phase 1 - 基础设施 + 数据库 + 公共模块
- 创建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)
2026-05-12 17:55:11 +08:00

8063 lines
292 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 第九联盟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 + "&timestamp=" + 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&timestamp=%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编程助手
> **状态**: 可执行开发方案