- 创建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)
292 KiB
第九联盟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适配版)
- 第三部分:后端服务详细实现
- 第四部分:前端实现
- 第五部分:企微集成配置指南
- 第六部分:部署与运维
- 第七部分:开发指南(面向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 全景架构图
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 核心数据流图
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 对话状态机
stateDiagram-v2
[*] --> 开场白: 新会话开始
开场白 --> 需求探询: 学员回应
需求探询 --> 课程推荐: 基础信息已收集
需求探询 --> 异议处理: 学员提出顾虑
课程推荐 --> 价值塑造: 学员感兴趣
课程推荐 --> 异议处理: 学员有疑问
价值塑造 --> 报价沟通: 询问价格
报价沟通 --> 异议处理: 价格顾虑
报价沟通 --> 促成报名: 接受价格
异议处理 --> 课程推荐: 重新推荐
异议处理 --> 报价沟通: 顾虑消除
异议处理 --> 促成报名: 异议解决
促成报名 --> 跟进维护: 暂不报名
促成报名 --> [*]: 报名成功
跟进维护 --> 课程推荐: 再次咨询
跟进维护 --> [*]: 超过7天未回复
1.3.4 端到端数据流时序
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 话术推荐引擎架构
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 数据库设计原则
- JSON数据使用TEXT类型存储:MySQL 5.7不支持JSON类型,所有结构化数据使用TEXT存储,应用层使用Jackson进行序列化/反序列化
- 向量数据不存入MySQL:Embedding向量使用Redis Hash存储,MySQL只存储
has_vector标记和vector_updated_at时间 - 所有表使用InnoDB引擎:支持事务、行级锁、外键
- 字符集utf8mb4:支持emoji等特殊字符
- 每张表包含created_at和updated_at:统一审计字段
- 索引策略:
- 主键使用BIGINT AUTO_INCREMENT
- 业务唯一键使用VARCHAR+唯一索引
- 查询字段添加普通索引
- 避免过多索引影响写入性能
2.2 完整建表SQL
2.2.1 会话存档消息表
-- ============================================================
-- 企微会话存档消息表 (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 会话表
-- ============================================================
-- 会话表 (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 对话轮次表
-- ============================================================
-- 对话轮次表 (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 意图分类表
-- ============================================================
-- 意图分类表 (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 意图识别记录表
-- ============================================================
-- 意图识别记录表 (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 学员画像表
-- ============================================================
-- 学员画像表 (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 话术库表
-- ============================================================
-- 话术库表 (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 话术分类表
-- ============================================================
-- 话术分类表 (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 话术效果表
-- ============================================================
-- 话术效果表 (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 推荐记录表
-- ============================================================
-- 推荐记录表 (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 知识库文档表
-- ============================================================
-- 知识库文档表 (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 员工表
-- ============================================================
-- 员工表 (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 客户表
-- ============================================================
-- 客户表 (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 管理员表
-- ============================================================
-- 管理员表 (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 操作日志表
-- ============================================================
-- 操作日志表 (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个意图分类初始化
-- ============================================================
-- 初始化意图分类(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个话术分类初始化
-- ============================================================
-- 初始化话术分类(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条核心话术初始化
-- ============================================================
-- 初始化核心话术(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 系统管理员初始化
-- ============================================================
-- 初始化系统管理员
-- 密码: 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序列化工具类
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(自动序列化/反序列化)
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
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
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 全局异常处理
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 业务异常类
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 分页配置
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配置
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配置
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 跨域配置
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 通用实体基类
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 自动填充处理器
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 启动类
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
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
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
package com.artedu.auth.controller;
import com.artedu.auth.entity.Staff;
import com.artedu.auth.service.JwtService;
import com.artedu.auth.service.WeComOAuthService;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 认证授权控制器
* 处理企微OAuth登录和Token刷新
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@Autowired
private WeComOAuthService weComOAuthService;
@Autowired
private JwtService jwtService;
/**
* 企微OAuth登录
*
* @param code 企微OAuth授权码
* @return JWT Token和用户信息
*/
@PostMapping("/login")
public Result<Map<String, Object>> login(@RequestParam("code") String code) {
log.info("企微OAuth登录, code={}", code);
// 1. 通过OAuth Code获取用户信息
Staff staff = weComOAuthService.handleLogin(code);
// 2. 生成JWT Token
String token = jwtService.generateToken(
staff.getStaffId(),
staff.getCorpId(),
staff.getName(),
staff.getRole()
);
// 3. 返回Token和用户信息
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("tokenType", "Bearer");
result.put("expiresIn", 86400);
result.put("userId", staff.getStaffId());
result.put("userName", staff.getName());
result.put("avatar", staff.getAvatar());
result.put("role", staff.getRole());
log.info("登录成功: userId={}, name={}", staff.getStaffId(), staff.getName());
return Result.success(result);
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
public Result<Map<String, Object>> refreshToken(@RequestHeader("Authorization") String authHeader) {
String oldToken = authHeader.replace("Bearer ", "");
// 验证旧Token
if (jwtService.isTokenExpired(oldToken)) {
return Result.fail("Token已过期,请重新登录");
}
// 解析旧Token信息
io.jsonwebtoken.Claims claims = jwtService.validateToken(oldToken);
String userId = claims.get("userId", String.class);
String corpId = claims.get("corpId", String.class);
String userName = claims.get("userName", String.class);
String role = claims.get("role", String.class);
// 生成新Token
String newToken = jwtService.generateToken(userId, corpId, userName, role);
Map<String, Object> result = new HashMap<>();
result.put("token", newToken);
result.put("tokenType", "Bearer");
result.put("expiresIn", 86400);
return Result.success(result);
}
/**
* 获取当前登录用户信息
*/
@GetMapping("/info")
public Result<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
Map<String, Object> result = new HashMap<>();
result.put("userId", claims.get("userId"));
result.put("userName", claims.get("userName"));
result.put("corpId", claims.get("corpId"));
result.put("role", claims.get("role"));
return Result.success(result);
}
/**
* JS-SDK签名接口
* 用于前端调用ww.register时获取签名
*/
@GetMapping("/signature")
public Result<Map<String, Object>> getJsSdkSignature(
@RequestParam("url") String url,
@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
String corpId = claims.get("corpId", String.class);
// 调用企微API获取jsapi_ticket
String ticket = weComOAuthService.getJsApiTicket();
String nonceStr = java.util.UUID.randomUUID().toString().replace("-", "");
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
// 拼接签名字符串
String signature = generateSignature(ticket, nonceStr, timestamp, url);
Map<String, Object> result = new HashMap<>();
result.put("corpId", corpId);
result.put("agentId", "1000002"); // 替换为实际agentId
result.put("nonceStr", nonceStr);
result.put("timestamp", timestamp);
result.put("signature", signature);
return Result.success(result);
}
/**
* 生成JS-SDK签名
*/
private String generateSignature(String ticket, String nonceStr, String timestamp, String url) {
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url;
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1");
digest.update(string1.getBytes());
byte[] messageDigest = digest.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : messageDigest) {
String shaHex = Integer.toHexString(b & 0xFF);
if (shaHex.length() < 2) {
hexString.append(0);
}
hexString.append(shaHex);
}
return hexString.toString();
} catch (Exception e) {
log.error("生成签名失败", e);
return "";
}
}
}
3.2.5 Staff实体类
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
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
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 启动类
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)
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解密工具类
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 存档消息实体类
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
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 存档拉取服务
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
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 对话管理服务
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
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 意图识别服务
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意图识别客户端
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模板
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
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 推荐服务
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 向量召回服务
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兜底生成服务
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服务
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
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
{
"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配置
// 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入口
<!-- 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 入口文件
// 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 全局样式
/* 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(核心)
// 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服务
// 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状态管理
// 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: '',
}),
}));
// 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
// 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组件
// 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组件(学员画像)
// 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组件(话术推荐列表)
// 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组件(单条话术卡片)
// 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组件
// 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组件(对话分析面板)
// 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 管理后台路由配置
// 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 话术库管理页面
// 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 注册企业微信
- 访问 https://work.weixin.qq.com/ 注册企业
- 完成企业认证(可选,但认证后功能更完整)
- 获取企业ID(CorpID)- 在「我的企业」→「企业ID」中查看
5.1.2 创建自建应用
- 进入「应用管理」
- 点击「创建应用」
- 填写应用信息:
- 应用名称:AI话术助手
- 应用Logo:上传应用图标
- 可见范围:选择需要使用的部门/人员
- 创建完成后记录:
- AgentId
- Secret(点击「查看」获取)
5.1.3 配置可信域名
- 进入应用详情页 →「网页授权及JS-SDK」
- 点击「设置可信域名」
- 填写域名:
- 申请校验域名(下载校验文件上传到服务器根目录)
- 或者使用已备案域名,上传校验文件验证
- 同时配置「授权回调域」(用于OAuth登录)
5.1.4 申请会话内容存档
- 进入「管理工具」→「会话内容存档」
- 点击「开通」并选择开通范围(全部或部分成员)
- 开通后获取:
- 企业ID(CorpID)
- 存档Secret(在「会话内容存档」→「API」中获取)
- 下载C SDK(按操作系统选择Linux/Windows版本)
5.1.5 配置聊天工具栏
- 进入「客户联系」→「加客户」→「聊天工具栏」
- 点击「配置应用页面」
- 选择刚才创建的「AI话术助手」应用
- 配置页面参数:
- 页面名称:AI话术助手
- 页面URL:https://your-domain.com/sidebar/
- 点击「确定」保存
5.1.6 配置回调URL
- 进入「管理工具」→「会话内容存档」→「API」
- 配置回调:
- 回调URL:https://your-domain.com/api/v1/archive/callback
- Token:随机生成16位字符串
- EncodingAESKey:点击「随机获取」
- 记录Token和EncodingAESKey(后端配置需要)
5.2 RSA密钥生成与配置
5.2.1 生成RSA密钥对
# 使用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 上传公钥到企微后台
- 进入「管理工具」→「会话内容存档」→「API」
- 找到「公钥配置」
- 将 public_key_base64.txt 的内容粘贴进去
- 点击「保存」
5.2.3 私钥存储到服务器
# 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 后端签名生成接口
package com.artedu.auth.controller;
import org.springframework.web.bind.annotation.*;
import java.security.MessageDigest;
import java.util.Formatter;
import java.util.UUID;
/**
* 企微JS-SDK签名接口
*/
@RestController
@RequestMapping("/api/v1/auth")
public class JsSdkSignatureController {
/**
* 获取JS-SDK签名
* 前端调用ww.register时需要
*/
@GetMapping("/signature")
public Result<Map<String, Object>> getSignature(
@RequestParam("url") String url,
@RequestParam("corpId") String corpId) {
try {
// 1. 获取jsapi_ticket(通过企微API)
String ticket = getJsApiTicket(corpId);
// 2. 生成随机字符串
String nonceStr = UUID.randomUUID().toString().replace("-", "");
// 3. 生成时间戳
long timestamp = System.currentTimeMillis() / 1000;
// 4. 拼接签名字符串
String string1 = String.format(
"jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s",
ticket, nonceStr, timestamp, url
);
// 5. SHA1签名
String signature = sha1(string1);
Map<String, Object> result = new HashMap<>();
result.put("corpId", corpId);
result.put("agentId", getAgentId(corpId));
result.put("nonceStr", nonceStr);
result.put("timestamp", timestamp);
result.put("signature", signature);
return Result.success(result);
} catch (Exception e) {
log.error("生成签名失败", e);
return Result.fail("生成签名失败");
}
}
private String sha1(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA1");
byte[] digest = md.digest(input.getBytes());
try (Formatter formatter = new Formatter()) {
for (byte b : digest) {
formatter.format("%02x", b);
}
return formatter.toString();
}
}
}
5.3.2 前端ww.register调用
已在「第四部分」4.1.6节提供完整代码,此处补充完整流程图:
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
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.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关键参数
# 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 连接池配置
# 各服务的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示例:
## 任务:生成[服务名]的[功能模块]
### 上下文
- 项目:第九联盟AI坐席辅助系统
- 技术栈:Spring Boot 2.7 + MyBatis-Plus + MySQL 5.7 + Redis + RabbitMQ
- 当前服务:[服务名],端口[端口号]
### 需求
[详细描述需求]
### 数据库表
```sql
[建表SQL]
要求
- 完整的Java代码(含package、import、注解)
- 使用MyBatis-Plus进行CRUD操作
- JSON字段使用TEXT类型存储(MySQL 5.7兼容)
- 中文注释
- 包含单元测试
输出
请生成以下文件:
- Entity.java
- Mapper.java
- Service.java
- Controller.java
- 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模板
## 问题描述
[描述遇到的问题]
## 错误日志
[粘贴错误日志]
## 相关代码
```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"
排查步骤:
- 确认corpId和agentId正确
- 确认当前页面URL与签名时使用的URL一致(去掉#后面的部分)
- 确认jsapi_ticket未过期(2小时有效期)
- 确认签名算法正确(SHA1)
- iOS设备需使用setTimeout包裹
调试代码:
// 在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秒),影响用户体验
解决方案:
- 异步调用:LLM调用改为异步,不阻塞主流程
- 缓存机制:相同输入的LLM结果缓存5分钟
- 降级策略:LLM失败时返回规则匹配结果
- 模型选择:意图识别用qwen-turbo(快速),话术生成用qwen-plus(高质量)
- 流式输出:对话生成使用SSE流式输出
7.3.4 性能调优
| 优化项 | 策略 | 目标 |
|---|---|---|
| 数据库查询 | 添加索引 + 分页查询 | 单条查询 < 50ms |
| LLM调用 | 异步 + 缓存 + 降级 | 首次调用 < 2s,缓存命中 < 10ms |
| 向量检索 | Redis Hash + 近似计算 | TopK检索 < 100ms |
| 接口响应 | 线程池 + 超时控制 | P99 < 500ms |
| 消息处理 | 批量处理 + 队列削峰 | 1000条/秒 |
7.3.5 上线检查清单
## 上线前检查清单
### 数据库
- [ ] 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. 参考文档
- 企业微信会话内容存档API文档:https://developer.work.weixin.qq.com/document/path/91360
- 企业微信JS-SDK文档:https://developer.work.weixin.qq.com/document/path/90513
- 通义千问API文档:https://help.aliyun.com/document_detail/611472.html
- MySQL 5.7官方文档:https://dev.mysql.com/doc/refman/5.7/en/
- Spring Boot官方文档:https://docs.spring.io/spring-boot/docs/2.7.x/
- MyBatis-Plus文档:https://baomidou.com/
文档版本: v3.0-final 最后更新: 2025年7月 适用对象: Kimi Code / Claude Code 等AI编程助手 状态: 可执行开发方案