commit 31a395be5e45ca176aedd14bb14a51338c76fccb Author: jiao Date: Tue May 12 17:55:11 2026 +0800 feat(01): Phase 1 - 基础设施 + 数据库 + 公共模块 - 创建10个微服务目录结构和Maven多模块配置 - 完成15张MySQL 5.7表设计和初始化数据(40意图+8话术分类+管理员) - 实现common模块:Result、PageResult、异常处理、MyBatisPlus/Redis/RabbitMQ/跨域配置 - 实现BaseEntity、AutoFillHandler、JsonUtils、JsonTypeHandler - 配置API Gateway路由和全部服务的application.yml - 添加docker-compose.yml(MySQL+Redis+RabbitMQ) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..08474d0 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,37 @@ +# 第九联盟AI坐席辅助系统 + +## 项目愿景 + +为第九联盟(9artedu.com)课程顾问/销售(坐席)构建一个基于企业微信会话存档 + LLM 的实时话术推荐系统。当学员通过企微咨询课程时,系统实时分析对话内容,识别学员意图,构建学员画像,并推荐最合适的话术,帮助坐席提高转化率和响应效率。 + +## 核心业务 + +- **数据接入**: 企微会话存档拉取(C SDK + JNA)、RSA解密、消息解析 +- **AI分析引擎**: 对话上下文理解(最近10轮滑动窗口)、意图识别(40个场景多标签分类)、学员画像构建(5维度标签体系)、话术召回(规则召回+向量召回+热门召回)、话术排序(多因子加权+MMR多样性)、LLM动态话术生成(兜底策略) +- **输出交互**: PC端侧边栏H5(360px)、手机端侧边栏H5(80%高度)、模板卡片消息推送、一键发送 +- **管理后台**: 话术库管理(CRUD+分类+导入导出)、数据统计看板、系统配置 + +## 目标用户 + +- **主要用户**: 第九联盟课程顾问/销售(坐席) +- **服务对象**: CG美术培训潜在学员(外部联系人) + +## 关键指标 + +- 端到端延迟目标: < 25秒(含15秒企微回调间隔) +- 话术推荐准确率: 基于坐席采纳率和转化率持续优化 +- 覆盖40个意图场景、8个销售阶段 + +## 技术栈 + +- 后端: Spring Boot 2.7.x + MyBatis-Plus + MySQL 5.7 + Redis 6.x + RabbitMQ 3.x +- 前端: React 18 + TypeScript + Vite + Ant Design / Ant Design Mobile +- LLM: 通义千问API (qwen-turbo/qwen-plus) +- 企微集成: 企业微信JS-SDK 2.0 + JNA调用C SDK +- 部署: Docker + Docker Compose + +## 约束条件 + +- 线上环境为 MySQL 5.7(JSON→TEXT适配,向量存Redis) +- 用户自有服务器部署 +- 数据合规要求(使用国产LLM) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..7f11552 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,63 @@ +# 需求文档 + +## v1 需求 + +### R1. 数据接入层 +- **R1-01**: 企微会话存档拉取(C SDK + JNA) +- **R1-02**: RSA解密 + 消息解析 +- **R1-03**: 回调接收(msgaudit_notify,15秒间隔) +- **R1-04**: 消息去重与存储 + +### R2. AI分析引擎 +- **R2-01**: 对话上下文管理(最近10轮滑动窗口) +- **R2-02**: 意图识别(40个场景多标签分类) +- **R2-03**: 学员画像构建(5维度标签体系:类型/基础/意向度/关注重点/决策阶段) +- **R2-04**: 话术召回(规则召回+向量召回+热门召回) +- **R2-05**: 话术排序(多因子加权+MMR多样性) +- **R2-06**: LLM动态话术生成(兜底策略) + +### R3. 输出交互层 +- **R3-01**: PC端侧边栏H5(360px,完整功能) +- **R3-02**: 手机端侧边栏H5(80%高度,精简操作) +- **R3-03**: 模板卡片消息推送(高优先级场景主动推送) +- **R3-04**: 一键发送(sendChatMessage) +- **R3-05**: WebSocket实时推送推荐结果 + +### R4. 管理后台 +- **R4-01**: 话术库管理(CRUD+分类+导入导出) +- **R4-02**: 数据统计看板(使用数据+效果分析) +- **R4-03**: 系统配置(推送设置+免打扰) +- **R4-04**: 管理员登录与权限控制 + +### R5. 基础设施 +- **R5-01**: 企微OAuth2认证 + JWT +- **R5-02**: API网关(路由/限流/鉴权) +- **R5-03**: MySQL 5.7 数据库(15张表) +- **R5-04**: Redis 缓存(会话/向量/Token) +- **R5-05**: RabbitMQ 消息队列(异步处理) +- **R5-06**: Docker Compose 部署 + +## v2 需求(后续迭代) + +- 多企业租户支持 +- 更复杂的反馈闭环和模型自优化 +- 更丰富的数据分析维度 +- 移动端App原生应用 + +## 数据库表清单 + +1. archive_messages - 企微会话存档消息 +2. conversations - 会话表 +3. conversation_turns - 对话轮次表 +4. intent_categories - 意图分类表(40个) +5. intent_recognitions - 意图识别记录表 +6. customer_profiles - 学员画像表 +7. utterances - 话术库表 +8. utterance_categories - 话术分类表(8个阶段) +9. utterance_performance - 话术效果表 +10. recommendations - 推荐记录表 +11. knowledge_documents - 知识库文档表 +12. staffs - 员工表 +13. customers - 客户表 +14. admins - 管理员表 +15. operation_logs - 操作日志表 diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..e1f2acb --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,50 @@ +# 路线图 + +## Phase 1: 基础设施 + 数据库 + 公共模块 +**目标**: 搭建项目骨架,创建所有服务的空壳,完成数据库设计和公共模块 +**成功标准**: +- [ ] 10个微服务目录结构完整 +- [ ] common模块包含Result、PageResult、异常处理、MyBatis配置、Redis配置、RabbitMQ配置、跨域配置、BaseEntity、AutoFillHandler、JsonUtils、JsonTypeHandler +- [ ] init.sql包含15张表创建语句和初始化数据 +- [ ] docker-compose.yml基础服务(MySQL、Redis、RabbitMQ) +- [ ] 网关服务基础路由配置 + +## Phase 2: 认证服务 + 会话存档服务 +**目标**: 实现企微OAuth登录和会话存档拉取 +**成功标准**: +- [ ] auth-service: 企微OAuth2登录、JWT签发、JS-SDK签名 +- [ ] archive-service: JNA接口、RSA解密、存档拉取、回调接收、消息存储 + +## Phase 3: 对话服务 + 意图服务 +**目标**: 实现对话上下文管理和意图识别 +**成功标准**: +- [ ] conversation-service: 会话管理、上下文窗口(Redis List)、轮次解析、消息事件处理 +- [ ] intent-service: 40个意图分类、规则匹配+LLM混合识别、学员画像构建、槽位填充 + +## Phase 4: 推荐服务 + LLM生成服务 +**目标**: 实现话术推荐引擎和动态生成 +**成功标准**: +- [ ] recommendation-service: 三层召回(规则/向量/热门)、多因子排序、MMR多样性重排、WebSocket推送 +- [ ] generation-service: 通义千问API封装、Prompt引擎、Token控制、动态话术生成 + +## Phase 5: 前端侧边栏H5 +**目标**: 实现坐席使用的侧边栏推荐界面 +**成功标准**: +- [ ] PC端侧边栏(360px): 学员画像、阶段指示器、话术推荐卡片、一键发送、搜索面板 +- [ ] 手机端侧边栏(80%高度): 精简版UI +- [ ] 企微JS-SDK集成、WebSocket实时更新 + +## Phase 6: 管理后台 + 知识库 +**目标**: 实现话术库管理和数据分析 +**成功标准**: +- [ ] kb-admin-service: 话术CRUD、分类管理、导入导出、向量生成 +- [ ] analytics-service: 数据统计、话术效果分析 +- [ ] admin前端: 登录页、话术库管理、数据看板 + +## Phase 7: 部署配置与集成测试 +**目标**: 完成Docker部署和端到端测试 +**成功标准**: +- [ ] 所有服务的Dockerfile +- [ ] docker-compose.yml完整编排 +- [ ] nginx.conf配置 +- [ ] 端到端流程验证(回调→拉取→识别→推荐→推送) diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..e725392 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,22 @@ +# 项目状态 + +## 当前阶段 +Phase 1: 基础设施 + 数据库 + 公共模块 (未开始) + +## 已完成 +- [x] 项目初始化(git repo、.planning目录) +- [x] PROJECT.md 创建 +- [x] REQUIREMENTS.md 创建 +- [x] ROADMAP.md 创建 +- [x] config.json 创建 + +## 进行中 +- [ ] Phase 1 开发 + +## 待开始 +- Phase 2: 认证服务 + 会话存档服务 +- Phase 3: 对话服务 + 意图服务 +- Phase 4: 推荐服务 + LLM生成服务 +- Phase 5: 前端侧边栏H5 +- Phase 6: 管理后台 + 知识库 +- Phase 7: 部署配置与集成测试 diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..709c1df --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,8 @@ +{ + "mode": "YOLO", + "depth": "Comprehensive", + "execution": "Parallel", + "projectName": "第九联盟AI坐席辅助系统", + "version": "1.0.0", + "createdAt": "2026-05-12" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..73f9f9f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kimi.yoloMode": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3f5a60 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# 九艺AI坐席辅助系统 + +基于 GSD 框架开发的项目。 + diff --git a/backend/analytics-service/src/main/java/com/artedu/analytics/AnalyticsServiceApplication.java b/backend/analytics-service/src/main/java/com/artedu/analytics/AnalyticsServiceApplication.java new file mode 100644 index 0000000..80b1cbd --- /dev/null +++ b/backend/analytics-service/src/main/java/com/artedu/analytics/AnalyticsServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.analytics; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 数据分析服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.analytics", "com.artedu.common"}) +public class AnalyticsServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AnalyticsServiceApplication.class, args); + } +} diff --git a/backend/analytics-service/src/main/resources/application.yml b/backend/analytics-service/src/main/resources/application.yml new file mode 100644 index 0000000..dd133e3 --- /dev/null +++ b/backend/analytics-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8088 + +spring: + application: + name: analytics-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/archive-service/pom.xml b/backend/archive-service/pom.xml new file mode 100644 index 0000000..515a9ba --- /dev/null +++ b/backend/archive-service/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + archive-service + jar + 会话存档服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + net.java.dev.jnajna + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java b/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java new file mode 100644 index 0000000..c96e8a4 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.archive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 会话存档服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"}) +public class ArchiveServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ArchiveServiceApplication.class, args); + } +} diff --git a/backend/archive-service/src/main/resources/application.yml b/backend/archive-service/src/main/resources/application.yml new file mode 100644 index 0000000..fc3437f --- /dev/null +++ b/backend/archive-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8082 + +spring: + application: + name: archive-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/auth-service/pom.xml b/backend/auth-service/pom.xml new file mode 100644 index 0000000..9248c66 --- /dev/null +++ b/backend/auth-service/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + auth-service + jar + 认证授权服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + io.jsonwebtokenjjwt-api + io.jsonwebtokenjjwt-implruntime + io.jsonwebtokenjjwt-jacksonruntime + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/auth-service/src/main/java/com/artedu/auth/AuthServiceApplication.java b/backend/auth-service/src/main/java/com/artedu/auth/AuthServiceApplication.java new file mode 100644 index 0000000..0a089af --- /dev/null +++ b/backend/auth-service/src/main/java/com/artedu/auth/AuthServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.auth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 认证授权服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.auth", "com.artedu.common"}) +public class AuthServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } +} diff --git a/backend/auth-service/src/main/resources/application.yml b/backend/auth-service/src/main/resources/application.yml new file mode 100644 index 0000000..03a0254 --- /dev/null +++ b/backend/auth-service/src/main/resources/application.yml @@ -0,0 +1,39 @@ +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 + +wecom: + corp-id: ${WECOM_CORP_ID:} + agent-id: ${WECOM_AGENT_ID:} + secret: ${WECOM_SECRET:} + +jwt: + secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters} + expiration: 86400 + +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 diff --git a/backend/common/pom.xml b/backend/common/pom.xml new file mode 100644 index 0000000..1be5a77 --- /dev/null +++ b/backend/common/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + + common + jar + 公共模块 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-amqp + + + com.baomidou + mybatis-plus-boot-starter + + + mysql + mysql-connector-java + runtime + + + org.projectlombok + lombok + true + + + com.fasterxml.jackson.core + jackson-databind + + + diff --git a/backend/common/src/main/java/com/artedu/common/config/CorsConfig.java b/backend/common/src/main/java/com/artedu/common/config/CorsConfig.java new file mode 100644 index 0000000..359c670 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/config/CorsConfig.java @@ -0,0 +1,32 @@ +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("*"); + config.setAllowCredentials(true); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.addExposedHeader("Authorization"); + config.addExposedHeader("X-Request-Id"); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/backend/common/src/main/java/com/artedu/common/config/MyBatisPlusConfig.java b/backend/common/src/main/java/com/artedu/common/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..0087422 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/config/MyBatisPlusConfig.java @@ -0,0 +1,22 @@ +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 { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/backend/common/src/main/java/com/artedu/common/config/RabbitConfig.java b/backend/common/src/main/java/com/artedu/common/config/RabbitConfig.java new file mode 100644 index 0000000..9ba9cd6 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/config/RabbitConfig.java @@ -0,0 +1,79 @@ +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 { + + public static final String ARCHIVE_EXCHANGE = "archive.exchange"; + 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); + } +} diff --git a/backend/common/src/main/java/com/artedu/common/config/RedisConfig.java b/backend/common/src/main/java/com/artedu/common/config/RedisConfig.java new file mode 100644 index 0000000..def235f --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/config/RedisConfig.java @@ -0,0 +1,42 @@ +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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + 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; + } +} diff --git a/backend/common/src/main/java/com/artedu/common/entity/BaseEntity.java b/backend/common/src/main/java/com/artedu/common/entity/BaseEntity.java new file mode 100644 index 0000000..f219e38 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/entity/BaseEntity.java @@ -0,0 +1,26 @@ +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 { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} diff --git a/backend/common/src/main/java/com/artedu/common/exception/BusinessException.java b/backend/common/src/main/java/com/artedu/common/exception/BusinessException.java new file mode 100644 index 0000000..5e9a5b8 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/exception/BusinessException.java @@ -0,0 +1,30 @@ +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; + } +} diff --git a/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java b/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ff0f5c4 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +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 handleBusinessException(BusinessException e) { + log.warn("业务异常: {}", e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(BindException.class) + public Result 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 handleException(Exception e) { + log.error("系统异常: {}", e.getMessage(), e); + return Result.fail(500, "系统繁忙,请稍后重试"); + } +} diff --git a/backend/common/src/main/java/com/artedu/common/handler/AutoFillHandler.java b/backend/common/src/main/java/com/artedu/common/handler/AutoFillHandler.java new file mode 100644 index 0000000..82278da --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/handler/AutoFillHandler.java @@ -0,0 +1,28 @@ +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()); + } +} diff --git a/backend/common/src/main/java/com/artedu/common/mybatis/JsonTypeHandler.java b/backend/common/src/main/java/com/artedu/common/mybatis/JsonTypeHandler.java new file mode 100644 index 0000000..8665c68 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/mybatis/JsonTypeHandler.java @@ -0,0 +1,61 @@ +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对象 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(Object.class) +public class JsonTypeHandler extends BaseTypeHandler { + + 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); + } +} diff --git a/backend/common/src/main/java/com/artedu/common/result/PageResult.java b/backend/common/src/main/java/com/artedu/common/result/PageResult.java new file mode 100644 index 0000000..c330068 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/result/PageResult.java @@ -0,0 +1,32 @@ +package com.artedu.common.result; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * 分页响应结构 + */ +@Data +public class PageResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private Integer page; + private Integer size; + private Long total; + private Integer totalPages; + private List list; + + public static PageResult of(Integer page, Integer size, Long total, List list) { + PageResult 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; + } +} diff --git a/backend/common/src/main/java/com/artedu/common/result/Result.java b/backend/common/src/main/java/com/artedu/common/result/Result.java new file mode 100644 index 0000000..fd39bdf --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/result/Result.java @@ -0,0 +1,82 @@ +package com.artedu.common.result; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一API响应结构 + * 所有接口返回此结构,确保前端处理一致 + * + * @param 响应数据类型 + */ +@Data +public class Result 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 Result success() { + Result result = new Result<>(); + result.setCode(0); + result.setMessage("success"); + return result; + } + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(0); + result.setMessage("success"); + result.setData(data); + return result; + } + + public static Result success(T data, String message) { + Result result = new Result<>(); + result.setCode(0); + result.setMessage(message); + result.setData(data); + return result; + } + + public static Result fail(String message) { + Result result = new Result<>(); + result.setCode(-1); + result.setMessage(message); + return result; + } + + public static Result fail(int code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + return result; + } + + public boolean isSuccess() { + return this.code != null && this.code == 0; + } +} diff --git a/backend/common/src/main/java/com/artedu/common/util/JsonUtils.java b/backend/common/src/main/java/com/artedu/common/util/JsonUtils.java new file mode 100644 index 0000000..0abc16b --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/util/JsonUtils.java @@ -0,0 +1,82 @@ +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 { + OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + 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; + } + } + + public static T fromJson(String json, Class 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; + } + } + + public static List fromJsonList(String json, Class 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(); + } + } + + public static Map fromJsonMap(String json) { + if (json == null || json.isEmpty()) { + return Collections.emptyMap(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.error("JSON反序列化为Map失败: {}", e.getMessage(), e); + return Collections.emptyMap(); + } + } + + public static String safeJson(Object obj) { + String json = toJson(obj); + return json != null ? json : "{}"; + } + + public static String safeParse(String json) { + return json != null && !json.isEmpty() ? json : "{}"; + } +} diff --git a/backend/conversation-service/pom.xml b/backend/conversation-service/pom.xml new file mode 100644 index 0000000..15b08c7 --- /dev/null +++ b/backend/conversation-service/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + conversation-service + jar + 对话管理服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-websocket + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/ConversationServiceApplication.java b/backend/conversation-service/src/main/java/com/artedu/conversation/ConversationServiceApplication.java new file mode 100644 index 0000000..553d40b --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/ConversationServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.conversation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 对话管理服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.conversation", "com.artedu.common"}) +public class ConversationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ConversationServiceApplication.class, args); + } +} diff --git a/backend/conversation-service/src/main/resources/application.yml b/backend/conversation-service/src/main/resources/application.yml new file mode 100644 index 0000000..6fc445b --- /dev/null +++ b/backend/conversation-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8083 + +spring: + application: + name: conversation-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/gateway/pom.xml b/backend/gateway/pom.xml new file mode 100644 index 0000000..771c2ad --- /dev/null +++ b/backend/gateway/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + gateway + jar + API网关 + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/gateway/src/main/java/com/artedu/gateway/GatewayApplication.java b/backend/gateway/src/main/java/com/artedu/gateway/GatewayApplication.java new file mode 100644 index 0000000..6ff3034 --- /dev/null +++ b/backend/gateway/src/main/java/com/artedu/gateway/GatewayApplication.java @@ -0,0 +1,15 @@ +package com.artedu.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API网关启动类 + */ +@SpringBootApplication +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml new file mode 100644 index 0000000..8f95182 --- /dev/null +++ b/backend/gateway/src/main/resources/application.yml @@ -0,0 +1,48 @@ +server: + port: 8080 + +spring: + application: + name: gateway + cloud: + gateway: + routes: + - id: auth-service + uri: http://localhost:8081 + predicates: + - Path=/api/v1/auth/** + - id: archive-service + uri: http://localhost:8082 + predicates: + - Path=/api/v1/archive/** + - id: conversation-service + uri: http://localhost:8083 + predicates: + - Path=/api/v1/conversations/** + - id: intent-service + uri: http://localhost:8084 + predicates: + - Path=/api/v1/intent/** + - id: recommend-service + uri: http://localhost:8085 + predicates: + - Path=/api/v1/recommend/** + - id: generation-service + uri: http://localhost:8086 + predicates: + - Path=/api/v1/generation/** + - id: kb-admin-service + uri: http://localhost:8087 + predicates: + - Path=/api/v1/admin/** + - id: analytics-service + uri: http://localhost:8088 + predicates: + - Path=/api/v1/analytics/** + globalcors: + cors-configurations: + '[/**]': + allowedOrigins: "*" + allowedMethods: "*" + allowedHeaders: "*" + allowCredentials: true diff --git a/backend/generation-service/pom.xml b/backend/generation-service/pom.xml new file mode 100644 index 0000000..07c7103 --- /dev/null +++ b/backend/generation-service/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + generation-service + jar + LLM生成服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java b/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java new file mode 100644 index 0000000..0d205e4 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.generation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * LLM生成服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.generation", "com.artedu.common"}) +public class GenerationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(GenerationServiceApplication.class, args); + } +} diff --git a/backend/generation-service/src/main/resources/application.yml b/backend/generation-service/src/main/resources/application.yml new file mode 100644 index 0000000..6d9215d --- /dev/null +++ b/backend/generation-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8086 + +spring: + application: + name: generation-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/intent-service/pom.xml b/backend/intent-service/pom.xml new file mode 100644 index 0000000..b5d1b2b --- /dev/null +++ b/backend/intent-service/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + intent-service + jar + 意图识别服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/intent-service/src/main/java/com/artedu/intent/IntentServiceApplication.java b/backend/intent-service/src/main/java/com/artedu/intent/IntentServiceApplication.java new file mode 100644 index 0000000..885976a --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/IntentServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.intent; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 意图识别服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.intent", "com.artedu.common"}) +public class IntentServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(IntentServiceApplication.class, args); + } +} diff --git a/backend/intent-service/src/main/resources/application.yml b/backend/intent-service/src/main/resources/application.yml new file mode 100644 index 0000000..222fec7 --- /dev/null +++ b/backend/intent-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8084 + +spring: + application: + name: intent-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/kb-admin-service/pom.xml b/backend/kb-admin-service/pom.xml new file mode 100644 index 0000000..658014e --- /dev/null +++ b/backend/kb-admin-service/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + kb-admin-service + jar + 话术库管理服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-we diff --git a/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/KbAdminServiceApplication.java b/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/KbAdminServiceApplication.java new file mode 100644 index 0000000..b4d2e4a --- /dev/null +++ b/backend/kb-admin-service/src/main/java/com/artedu/kbadmin/KbAdminServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.kbadmin; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 话术库管理服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.kbadmin", "com.artedu.common"}) +public class KbAdminServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(KbAdminServiceApplication.class, args); + } +} diff --git a/backend/kb-admin-service/src/main/resources/application.yml b/backend/kb-admin-service/src/main/resources/application.yml new file mode 100644 index 0000000..2b92406 --- /dev/null +++ b/backend/kb-admin-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8087 + +spring: + application: + name: kb-admin-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..75ae4c7 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + pom + + 第九联盟AI坐席辅助系统 + 基于企微会话存档+LLM的实时话术推荐系统 + + + common + gateway + auth-service + archive-service + conversation-service + intent-service + recommendation-service + generation-service + kb-admin-service + analytics-service + + + + 17 + 17 + 17 + UTF-8 + + 2.7.18 + 2021.0.8 + 3.5.5 + 0.11.5 + 5.13.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + + + net.java.dev.jna + jna + ${jna.version} + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + + diff --git a/backend/recommendation-service/pom.xml b/backend/recommendation-service/pom.xml new file mode 100644 index 0000000..95220f7 --- /dev/null +++ b/backend/recommendation-service/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.artedu + ai-assistant-parent + 1.0.0-SNAPSHOT + + recommendation-service + jar + 话术推荐服务 + + com.arteducommon${project.version} + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-websocket + + + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/RecommendationServiceApplication.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/RecommendationServiceApplication.java new file mode 100644 index 0000000..027dfa6 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/RecommendationServiceApplication.java @@ -0,0 +1,15 @@ +package com.artedu.recommend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 话术推荐服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.recommend", "com.artedu.common"}) +public class RecommendationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(RecommendationServiceApplication.class, args); + } +} diff --git a/backend/recommendation-service/src/main/resources/application.yml b/backend/recommendation-service/src/main/resources/application.yml new file mode 100644 index 0000000..96f04b4 --- /dev/null +++ b/backend/recommendation-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8085 + +spring: + application: + name: recommendation-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 + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:admin} + password: ${RABBITMQ_PASSWORD:admin} + +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6be5cbe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + mysql: + image: mysql:5.7 + container_name: ai-assistant-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: ai_assistant + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + command: --default-authentication-plugin=mysql_native_password + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:6-alpine + container_name: ai-assistant-redis + restart: always + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: ai-assistant-rabbitmq + restart: always + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: admin + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: + redis_data: + rabbitmq_data: diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..fb2df11 --- /dev/null +++ b/init.sql @@ -0,0 +1,459 @@ +-- ============================================================ +-- 第九联盟AI坐席辅助系统 - 数据库初始化脚本 +-- MySQL 5.7适配版 +-- ============================================================ + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS `ai_assistant` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `ai_assistant`; + +-- ============================================================ +-- 企微会话存档消息表 (archive_messages) +-- ============================================================ +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='企微会话存档消息表'; + +-- ============================================================ +-- 会话表 (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='会话表'; + +-- ============================================================ +-- 对话轮次表 (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='对话轮次表'; + +-- ============================================================ +-- 意图分类表 (intent_categories) +-- ============================================================ +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='意图分类表'; + +-- ============================================================ +-- 意图识别记录表 (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='意图识别记录表'; + +-- ============================================================ +-- 学员画像表 (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='学员画像表'; + +-- ============================================================ +-- 话术库表 (utterances) +-- ============================================================ +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='话术库表'; + +-- ============================================================ +-- 话术分类表 (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='话术分类表'; + +-- ============================================================ +-- 话术效果表 (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='话术效果表'; + +-- ============================================================ +-- 推荐记录表 (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='推荐记录表'; + +-- ============================================================ +-- 知识库文档表 (knowledge_documents) +-- ============================================================ +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='知识库文档表'; + +-- ============================================================ +-- 员工表 (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='员工表'; + +-- ============================================================ +-- 客户表 (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='客户表'; + +-- ============================================================ +-- 管理员表 (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='管理员表'; + +-- ============================================================ +-- 操作日志表 (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='操作日志表'; + +-- ============================================================ +-- 初始化数据 +-- ============================================================ + +-- 40个意图分类 +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), +('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), +('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), +('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), +('INT-BASIC-01', '学历要求咨询', '基础条件', '学历是否够', 'P2', '["学历","要求","高中","大专","本科"]', '["需要什么学历?","高中生可以学吗?","学历要求高吗?"]', 1), +('INT-BASIC-02', '年龄限制询问', '基础条件', '年龄是否合适', 'P2', '["年龄","多大","几岁","限制","30岁"]', '["有没有年龄限制?","30岁了还能学吗?","年龄大能学会吗?"]', 1), +('INT-BASIC-03', '零基础入学', '基础条件', '零基础可行性', 'P1', '["零基础","没有基础","小白","完全不会","从来没"]', '["我没有美术基础可以学吗?","零基础能学会吗?","完全没接触过可以学吗?"]', 1), +('INT-BASIC-04', '证书颁发询问', '基础条件', '学完发什么证书', 'P3', '["证书","毕业证","结业证","证明","认证"]', '["学完发证书吗?","证书有用吗?","有毕业证吗?"]', 1), +('INT-LOC-01', '校区地址询问', '校区相关', '想知道校区在哪', 'P2', '["校区","地址","在哪","位置","交通"]', '["校区在哪里?","附近有地铁吗?","具体地址是什么?"]', 1), +('INT-LOC-02', '校区参观预约', '校区相关', '想去参观', 'P2', '["参观","看看","实地考察","走访","预约"]', '["可以参观校区吗?","怎么预约参观?","能去看看吗?"]', 1), +('INT-LOC-03', '教学设备了解', '校区相关', '关心硬件条件', 'P3', '["设备","电脑","配置","硬件","机房"]', '["需要自备电脑吗?","校区提供设备吗?","电脑配置怎么样?"]', 1), +('INT-LOC-04', '住宿安排咨询', '校区相关', '外地学员住宿', 'P3', '["住宿","宿舍","外地","住哪","租房"]', '["外地学员有住宿吗?","住宿怎么安排?","有宿舍吗?"]', 1), +('INT-QUAL-01', '机构正规性', '机构资质', '是否正规机构', 'P2', '["正规","资质","办学","合法","备案"]', '["你们是正规机构吗?","有办学资质吗?","是国家认可的吗?"]', 1), +('INT-QUAL-02', '口碑评价询问', '机构资质', '想看学员评价', 'P3', '["评价","口碑","反馈","怎么样","好吗"]', '["学员评价怎么样?","有老学员反馈吗?","口碑如何?"]', 1), +('INT-QUAL-03', '媒体报道了解', '机构资质', '想看权威背书', 'P3', '["报道","媒体","电视","新闻","央视"]', '["有上过电视报道吗?","媒体报道过吗?","央视那个是真的吗?"]', 1), +('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); + +-- 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', '未成交学员跟进,保持联系,等待时机'); + +-- 系统管理员(密码: admin123) +INSERT INTO `admins` (`username`, `password`, `name`, `email`, `role`, `status`, `created_at`) VALUES +('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iOE1Qh6G', '系统管理员', 'admin@9artedu.com', 'SUPER_ADMIN', 'ACTIVE', NOW()); diff --git a/product1.0/v2-final-完整开发方案.md b/product1.0/v2-final-完整开发方案.md new file mode 100644 index 0000000..182d783 --- /dev/null +++ b/product1.0/v2-final-完整开发方案.md @@ -0,0 +1,8062 @@ +# 第九联盟AI坐席辅助系统 — 完整开发方案(面向AI编程助手) + +> **版本**: v3.0-final +> **日期**: 2025年7月 +> **项目**: 第九联盟(9artedu.com)AI坐席辅助系统 +> **开发工具**: Kimi Code / Claude Code +> **适用环境**: 线上MySQL 5.7(JSON→TEXT适配,向量存Redis) +> **交互方案**: 方案D(混合)— PC侧边栏H5 + 手机侧边栏H5 + 模板卡片推送 + +--- + +## 文档目录 + +- [第一部分:项目概述与架构设计](#第一部分项目概述与架构设计) +- [第二部分:数据库设计(MySQL 5.7适配版)](#第二部分数据库设计mysql-57适配版) +- [第三部分:后端服务详细实现](#第三部分后端服务详细实现) +- [第四部分:前端实现](#第四部分前端实现) +- [第五部分:企微集成配置指南](#第五部分企微集成配置指南) +- [第六部分:部署与运维](#第六部分部署与运维) +- [第七部分:开发指南(面向AI编程助手)](#第七部分开发指南面向ai编程助手) + +--- + +## 第一部分:项目概述与架构设计 + +### 1.1 项目概述 + +#### 1.1.1 项目基本信息 + +| 项目 | 详情 | +|------|------| +| **项目名称** | 第九联盟AI坐席辅助系统(9artedu-ai-assistant) | +| **版本** | v3.0-final | +| **目标用户** | 第九联盟课程顾问/销售(坐席) | +| **服务对象** | CG美术培训潜在学员(外部联系人) | +| **核心业务** | 基于企微会话存档+LLM的话术实时推荐 | +| **部署环境** | 用户自有服务器(线上MySQL 5.7) | + +#### 1.1.2 业务背景 + +第九联盟(9ART EDU,母公司上海点晴信息科技有限公司)是中国CG数字艺术人才孵化基地,提供以下6大核心方向的CG美术培训课程: + +- 3D场景UE地编就业班 +- 次世代角色模型就业班 +- 2D原画就业班(角色/场景方向) +- 游戏/影视动画设计就业班 +- 游戏特效就业班 +- 3D大师研修班 + +**业务数据**: +- 10+年游戏制作经验 +- 500+家就业推荐企业(腾讯、网易、米哈游等) +- 8000+名累计服务学员 +- 6大校区(上海、西安、厦门、武汉、青岛、合肥) +- 央视CCTV2财经频道专题报道 + +#### 1.1.3 核心功能概述 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ AI坐席辅助系统核心功能 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 📥 数据接入层 │ +│ ├── 企微会话存档拉取(C SDK + JNA) │ +│ ├── RSA解密 + 消息解析 │ +│ └── 回调接收(msgaudit_notify,15秒间隔) │ +│ │ +│ 🧠 AI分析引擎层 │ +│ ├── 对话上下文理解(最近10轮滑动窗口) │ +│ ├── 意图识别(40个场景多标签分类) │ +│ ├── 学员画像构建(5维度标签体系) │ +│ ├── 话术召回(规则召回+向量召回+热门召回) │ +│ ├── 话术排序(多因子加权+MMR多样性) │ +│ └── LLM动态话术生成(兜底策略) │ +│ │ +│ 📤 输出交互层 │ +│ ├── PC端侧边栏H5(360px,完整功能) │ +│ ├── 手机端侧边栏H5(80%高度,精简操作) │ +│ ├── 模板卡片消息推送(高优先级场景主动推送) │ +│ └── 一键发送(sendChatMessage) │ +│ │ +│ ⚙️ 管理后台层 │ +│ ├── 话术库管理(CRUD+分类+导入导出) │ +│ ├── 数据统计看板(使用数据+效果分析) │ +│ └── 系统配置(推送设置+免打扰) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 技术选型(明确推荐) + +| 层级 | 技术选型 | 版本 | 选型理由 | +|------|---------|------|---------| +| 后端框架 | **Spring Boot** | 2.7.x | 成熟稳定,生态丰富,兼容MySQL 5.7 | +| ORM | **MyBatis-Plus** | 3.5.x | 简化CRUD,代码生成,ActiveRecord模式 | +| 数据库 | **MySQL** | 5.7 | 用户已有线上环境,不增加额外成本 | +| 缓存 | **Redis** | 6.x | 会话缓存、向量缓存、频率控制 | +| 消息队列 | **RabbitMQ** | 3.x | 异步处理、削峰填谷、事件驱动 | +| 向量存储 | **Redis Hash** | - | MySQL 5.7不支持向量类型,Redis存Embedding | +| API网关 | **Spring Cloud Gateway** | 3.1.x | 路由、限流、鉴权统一入口 | +| 前端框架 | **React 18 + TypeScript** | 18.x | 组件化开发,类型安全 | +| 移动端UI | **Ant Design Mobile** | 5.x | 企微H5移动端适配 | +| PC端UI | **Ant Design** | 5.x | 管理后台UI组件 | +| 构建工具 | **Vite** | 5.x | 快速构建,HMR | +| 状态管理 | **Zustand** | 4.x | 轻量,无样板代码 | +| 大模型主选 | **通义千问API(qwen-turbo)** | - | 国产、低成本、中文优秀、数据合规 | +| 大模型备选 | **通义千问(qwen-plus)** | - | 复杂场景备选 | +| Embedding | **通义千问文本Embedding API** | - | 向量化解析,1536维 | +| 企微SDK | **企业微信JS-SDK 2.0** | 2.0.2 | ww.register方式,iOS兼容 | +| C SDK集成 | **JNA** | 5.13.x | Java调用企微C SDK,无需JNI编译 | +| 容器化 | **Docker + Docker Compose** | - | 快速部署,环境一致 | + +#### 1.2.1 MySQL 5.7关键限制与适配方案 + +| 限制 | 影响 | 解决方案 | +|------|------|---------| +| 不支持JSON数据类型 | 无法使用JSON字段存储结构化数据 | 使用TEXT类型存储JSON字符串,应用层序列化/反序列化 | +| 不支持向量数据类型 | 无法存储Embedding向量 | 向量存储使用Redis Hash,MySQL只存业务数据和向量标记 | +| utf8mb4支持 | 需要存储emoji等特殊字符 | 所有表使用utf8mb4字符集 | +| JSON函数缺失 | 无法使用JSON_EXTRACT等 | 应用层处理JSON数据,Java中使用Jackson/Gson | + +### 1.3 系统架构图 + +#### 1.3.1 全景架构图 + +```mermaid +graph TB + subgraph Client["客户端层"] + PC["企微PC客户端"] + Mobile["企微手机客户端(iOS/Android)"] + SidePC["侧边栏H5-PC 360px"] + SideMobile["侧边栏H5-Mobile 80%高度"] + AdminWeb["管理后台Web"] + CardPush["模板卡片推送"] + end + + subgraph Access["接入层"] + Nginx["Nginx负载均衡/SSL终端"] + Gateway["API Gateway
Spring Cloud Gateway"] + WSDK["企微JS-SDK
ww.register/agentConfig"] + OAuth["企微OAuth2认证"] + Callback["企微回调接收
msgaudit_notify/事件推送"] + end + + subgraph Service["服务层-10个微服务集群"] + ArchS["archive-service
会话存档拉取与解析"] + ConvS["conversation-service
对话上下文管理"] + IntentS["intent-service
意图识别与学员画像"] + RecS["recommendation-service
话术匹配推荐与排序"] + GenS["generation-service
LLM话术生成+Prompt引擎"] + KnowS["knowledge-service
知识库管理"] + AuthS["auth-service
认证授权"] + KbAdmS["kb-admin-service
话术库管理后台"] + AnaS["analytics-service
数据分析+话术效果"] + end + + subgraph Data["数据层"] + MySQL[("MySQL 5.7
业务数据/话术库/会话
TEXT存JSON")] + Redis[("Redis 6.x
缓存/会话/向量存储
实时状态")] + MQ[("RabbitMQ 3.x
消息队列/异步处理")] + end + + subgraph External["外部依赖"] + WeComAPI["企业微信API
存档SDK/消息推送"] + LLM1["通义千问API
qwen-turbo(主)"] + LLM2["通义千问API
qwen-plus(备)"] + EmbedAPI["文本Embedding API"] + end + + PC --> SidePC + Mobile --> SideMobile + SidePC --> Nginx + SideMobile --> Nginx + AdminWeb --> Nginx + Nginx --> Gateway + Gateway --> OAuth + OAuth --> WeComAPI + SidePC --> WSDK + SideMobile --> WSDK + WSDK --> WeComAPI + CardPush --> WeComAPI + + Gateway --> AuthS + Gateway --> ArchS + Gateway --> ConvS + Gateway --> IntentS + Gateway --> RecS + Gateway --> GenS + Gateway --> KnowS + Gateway --> KbAdmS + Gateway --> AnaS + + ArchS --> ConvS + ConvS --> IntentS + IntentS --> RecS + RecS --> GenS + ConvS --> RecS + KnowS --> RecS + KbAdmS --> RecS + GenS --> LLM1 + GenS --> LLM2 + + ArchS --> MySQL + ArchS --> Redis + ConvS --> Redis + ConvS --> MySQL + IntentS --> MySQL + IntentS --> Redis + RecS --> MySQL + RecS --> Redis + GenS --> Redis + GenS --> MySQL + KnowS --> MySQL + AuthS --> MySQL + AuthS --> Redis + KbAdmS --> MySQL + KbAdmS --> Redis + AnaS --> MySQL + AnaS --> Redis + + MQ -.-> ArchS + MQ -.-> ConvS + MQ -.-> RecS + MQ -.-> AnaS + + Callback --> ArchS + ArchS --> WeComAPI + + style ArchS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style ConvS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style IntentS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style RecS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style GenS fill:#fff3e0,stroke:#e65100,stroke-width:2px +``` + +#### 1.3.2 核心数据流图 + +```mermaid +graph LR + subgraph DataIn["数据输入"] + WX["企微存档消息"] + KB["话术库"] + KL["知识库"] + end + + subgraph Pipeline["处理流水线"] + direction TB + P1["Step1: 对话解析
轮次提取|角色识别"] + P2["Step2: 上下文管理
窗口构建|状态追踪"] + P3["Step3: 意图识别
40类分类|槽位填充"] + P4["Step4: 画像更新
类型|基础|意向度"] + P5["Step5: 话术召回
规则召回|向量召回|热门召回"] + P6["Step6: 话术排序
多因子评分|MMR多样性"] + P7["Step7: 结果输出
Top5推荐|状态摘要"] + + P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 + end + + subgraph Output["输出层"] + J["推荐话术列表
Top 3-5条"] + K["学员画像更新
标签体系"] + L["质量分析报告"] + end + + subgraph Feedback["反馈闭环"] + F1["坐席选择反馈"] + F2["转化率追踪"] + F3["模型更新"] + end + + WX --> P1 + KB --> P5 + KL --> P3 + KL --> P5 + P7 --> J + P7 --> K + J --> F1 --> F2 --> F3 + F3 --> KB +``` + +#### 1.3.3 对话状态机 + +```mermaid +stateDiagram-v2 + [*] --> 开场白: 新会话开始 + 开场白 --> 需求探询: 学员回应 + 需求探询 --> 课程推荐: 基础信息已收集 + 需求探询 --> 异议处理: 学员提出顾虑 + 课程推荐 --> 价值塑造: 学员感兴趣 + 课程推荐 --> 异议处理: 学员有疑问 + 价值塑造 --> 报价沟通: 询问价格 + 报价沟通 --> 异议处理: 价格顾虑 + 报价沟通 --> 促成报名: 接受价格 + 异议处理 --> 课程推荐: 重新推荐 + 异议处理 --> 报价沟通: 顾虑消除 + 异议处理 --> 促成报名: 异议解决 + 促成报名 --> 跟进维护: 暂不报名 + 促成报名 --> [*]: 报名成功 + 跟进维护 --> 课程推荐: 再次咨询 + 跟进维护 --> [*]: 超过7天未回复 +``` + +#### 1.3.4 端到端数据流时序 + +```mermaid +sequenceDiagram + participant Student as 学员 + participant WeCom as 企业微信 + participant Archive as archive-service + participant MQ as RabbitMQ + participant Conv as conversation-service + participant Intent as intent-service + participant Rec as recommendation-service + participant WS as WebSocket + participant Staff as 坐席 + + Note over Student,Staff: 端到端延迟目标 < 25秒 + + Student->>WeCom: 发送消息 + Note right of WeCom: T+0s + + WeCom->>Archive: msgaudit_notify回调 + Note right of WeCom: T+~15s(回调间隔) + + Archive->>Archive: GetChatData拉取 + Note right of Archive: T+~16s + + Archive->>Archive: RSA解密+解析 + Note right of Archive: T+~17s + + Archive->>MQ: 发布消息事件 + Note right of MQ: T+~17.5s + + MQ->>Conv: 消费消息事件 + Note right of Conv: T+~18s + + Conv->>Conv: 更新上下文窗口 + Conv->>Intent: 调用意图识别API + Note right of Intent: T+~18.5s + + Intent->>Intent: LLM意图分析 + Note right of Intent: T+~19s(200-500ms) + + Intent-->>Conv: 返回意图+画像 + Conv->>Rec: 调用话术推荐API + Note right of Rec: T+~19.5s + + Rec->>Rec: 三层召回+排序 + Note right of Rec: T+~20s(100-300ms) + + Rec->>WS: 推送推荐结果 + Note right of WS: T+~21s + + WS->>Staff: 侧边栏更新推荐 + Note left of Staff: T+~22s + + Staff->>WS: 点击发送 + WS->>Rec: 记录使用反馈 + + Note over Student,Staff: 总延迟: ~22秒(含15秒回调间隔) +``` + +#### 1.3.5 话术推荐引擎架构 + +```mermaid +graph TD + A["输入: 意图+画像+阶段+学员消息"] --> B{"话术库召回"} + B --> C["规则召回
意图码+阶段+画像标签
MySQL查询"] + B --> D["向量召回
学员消息Embedding
Redis向量检索"] + B --> E["热门召回
近7天高成功率
Redis ZSET"] + C --> F["候选话术池
去重+过滤"] + D --> F + E --> F + F --> G{"候选话术数量"} + G -->|">=3条"| H["多因子排序
加权评分"] + G -->|"<3条"| I["LLM动态生成
补充话术"] + G -->|"0条"| J["纯LLM生成
兜底话术"] + H --> K["MMR多样性重排"] + I --> H + J --> L["LLM话术审核
安全性+准确性"] + K --> M["Top 5输出
含推荐理由"] + L --> M +``` + +### 1.4 项目目录结构 + +``` +ai-assistant/ +├── backend/ # 后端服务 +│ ├── archive-service/ # 会话存档服务 +│ │ ├── src/main/java/com/artedu/archive/ +│ │ │ ├── ArchiveServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── ArchiveCallbackController.java +│ │ │ ├── service/ +│ │ │ │ ├── ArchivePullService.java +│ │ │ │ ├── ArchiveSdkService.java +│ │ │ │ └── MessageStoreService.java +│ │ │ ├── sdk/ +│ │ │ │ └── WeWorkFinanceSdk.java # JNA接口 +│ │ │ ├── entity/ +│ │ │ │ └── ArchiveMessage.java +│ │ │ ├── mapper/ +│ │ │ │ └── ArchiveMessageMapper.java +│ │ │ └── config/ +│ │ │ └── RabbitConfig.java +│ │ ├── src/main/resources/ +│ │ │ ├── application.yml +│ │ │ └── libWeWorkFinanceSdk.so # Linux C SDK +│ │ └── Dockerfile +│ │ +│ ├── conversation-service/ # 对话管理服务 +│ │ ├── src/main/java/com/artedu/conversation/ +│ │ │ ├── ConversationServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── ConversationController.java +│ │ │ ├── service/ +│ │ │ │ ├── ConversationManager.java +│ │ │ │ ├── ContextWindowService.java +│ │ │ │ ├── TurnParserService.java +│ │ │ │ └── MessageEventHandler.java +│ │ │ ├── entity/ +│ │ │ │ ├── Conversation.java +│ │ │ │ └── ConversationTurn.java +│ │ │ ├── mapper/ +│ │ │ └── config/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── intent-service/ # 意图识别服务 +│ │ ├── src/main/java/com/artedu/intent/ +│ │ │ ├── IntentServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── IntentController.java +│ │ │ ├── service/ +│ │ │ │ ├── IntentRecognitionService.java +│ │ │ │ ├── UserProfilingService.java +│ │ │ │ └── RuleClassifier.java +│ │ │ ├── entity/ +│ │ │ │ ├── IntentCategory.java +│ │ │ │ ├── IntentRecognition.java +│ │ │ │ └── CustomerProfile.java +│ │ │ ├── mapper/ +│ │ │ └── prompt/ +│ │ │ └── IntentPromptTemplate.java +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── recommendation-service/ # 话术推荐服务 +│ │ ├── src/main/java/com/artedu/recommend/ +│ │ │ ├── RecommendationServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── RecommendController.java +│ │ │ ├── service/ +│ │ │ │ ├── RecallService.java +│ │ │ │ ├── RankingService.java +│ │ │ │ ├── MMRService.java +│ │ │ │ └── FeedbackService.java +│ │ │ ├── entity/ +│ │ │ │ ├── Utterance.java +│ │ │ │ └── Recommendation.java +│ │ │ ├── mapper/ +│ │ │ └── config/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── generation-service/ # LLM生成服务 +│ │ ├── src/main/java/com/artedu/generation/ +│ │ │ ├── GenerationServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── GenerationController.java +│ │ │ ├── service/ +│ │ │ │ ├── LLMClient.java +│ │ │ │ ├── QianwenClient.java +│ │ │ │ ├── PromptEngine.java +│ │ │ │ └── TokenControlService.java +│ │ │ ├── dto/ +│ │ │ └── config/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── auth-service/ # 认证授权服务 +│ │ ├── src/main/java/com/artedu/auth/ +│ │ │ ├── AuthServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── AuthController.java +│ │ │ ├── service/ +│ │ │ │ ├── WeComOAuthService.java +│ │ │ │ └── JwtService.java +│ │ │ ├── entity/ +│ │ │ │ └── Staff.java +│ │ │ ├── mapper/ +│ │ │ └── security/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── kb-admin-service/ # 话术库管理服务 +│ │ ├── src/main/java/com/artedu/kbadmin/ +│ │ │ ├── KbAdminServiceApplication.java +│ │ │ ├── controller/ +│ │ │ │ └── UtteranceController.java +│ │ │ ├── service/ +│ │ │ │ ├── UtteranceCrudService.java +│ │ │ │ ├── CategoryService.java +│ │ │ │ └── ImportExportService.java +│ │ │ ├── entity/ +│ │ │ └── mapper/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── analytics-service/ # 数据分析服务 +│ │ ├── src/main/java/com/artedu/analytics/ +│ │ │ ├── AnalyticsServiceApplication.java +│ │ │ ├── controller/ +│ │ │ └── service/ +│ │ ├── src/main/resources/ +│ │ │ └── application.yml +│ │ └── Dockerfile +│ │ +│ ├── gateway/ # API网关 +│ │ ├── src/main/java/com/artedu/gateway/ +│ │ │ └── GatewayApplication.java +│ │ ├── src/main/resources/ +│ │ │ ├── application.yml +│ │ │ └── bootstrap.yml +│ │ └── Dockerfile +│ │ +│ └── common/ # 公共模块 +│ └── src/main/java/com/artedu/common/ +│ ├── result/Result.java # 统一响应 +│ ├── exception/ # 全局异常 +│ ├── config/ # 公共配置 +│ └── util/ # 工具类 +│ +├── frontend/ # 前端 +│ ├── sidebar/ # 侧边栏H5(PC+手机) +│ │ ├── src/ +│ │ │ ├── main.tsx # 入口 +│ │ │ ├── App.tsx # 根组件 +│ │ │ ├── components/ # 组件 +│ │ │ │ ├── Header.tsx +│ │ │ │ ├── TabBar.tsx +│ │ │ │ ├── ProfileCard.tsx +│ │ │ │ ├── StageIndicator.tsx +│ │ │ │ ├── ScriptCard.tsx +│ │ │ │ ├── ScriptRecommend.tsx +│ │ │ │ ├── AnalysisPanel.tsx +│ │ │ │ └── SearchPanel.tsx +│ │ │ ├── services/ # API调用 +│ │ │ │ ├── api.ts +│ │ │ │ └── websocket.ts +│ │ │ ├── stores/ # 状态管理 +│ │ │ │ ├── useScriptStore.ts +│ │ │ │ ├── useProfileStore.ts +│ │ │ │ └── useStageStore.ts +│ │ │ ├── hooks/ # 自定义Hook +│ │ │ │ └── useWeComSdk.ts +│ │ │ └── types/ # 类型定义 +│ │ │ └── index.ts +│ │ ├── index.html +│ │ ├── vite.config.ts +│ │ ├── package.json +│ │ └── Dockerfile +│ │ +│ └── admin/ # 管理后台 +│ ├── src/ +│ ├── package.json +│ └── Dockerfile +│ +├── docs/ # 文档 +│ ├── api/ # API文档 +│ └── deploy/ # 部署文档 +│ +├── docker-compose.yml # Docker编排 +├── nginx.conf # Nginx配置 +├── init.sql # 数据库初始化 +└── README.md +``` + +--- + +## 第二部分:数据库设计(MySQL 5.7适配版) + +### 2.1 数据库设计原则 + +1. **JSON数据使用TEXT类型存储**:MySQL 5.7不支持JSON类型,所有结构化数据使用TEXT存储,应用层使用Jackson进行序列化/反序列化 +2. **向量数据不存入MySQL**:Embedding向量使用Redis Hash存储,MySQL只存储`has_vector`标记和`vector_updated_at`时间 +3. **所有表使用InnoDB引擎**:支持事务、行级锁、外键 +4. **字符集utf8mb4**:支持emoji等特殊字符 +5. **每张表包含created_at和updated_at**:统一审计字段 +6. **索引策略**: + - 主键使用BIGINT AUTO_INCREMENT + - 业务唯一键使用VARCHAR+唯一索引 + - 查询字段添加普通索引 + - 避免过多索引影响写入性能 + +### 2.2 完整建表SQL + +#### 2.2.1 会话存档消息表 + +```sql +-- ============================================================ +-- 企微会话存档消息表 (archive_messages) +-- 存储企微会话存档解密后的原始消息 +-- MySQL 5.7适配: JSON → TEXT,应用层序列化/反序列化 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `archive_messages` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `msgid` VARCHAR(255) NOT NULL COMMENT '企微消息唯一ID', + `seq` BIGINT(20) NOT NULL COMMENT '存档序列号,用于增量拉取', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `action` VARCHAR(32) DEFAULT 'send' COMMENT 'send/recall/agree/disagree', + `from_user` VARCHAR(128) NOT NULL COMMENT '发送者userid', + `from_role` VARCHAR(32) NOT NULL COMMENT '发送者角色: INTERNAL-企业内部成员 EXTERNAL-外部联系人 SYSTEM-系统', + `to_user` VARCHAR(128) COMMENT '接收者userid(单聊)', + `tolist` TEXT COMMENT '接收者列表(群聊),JSON数组字符串', + `roomid` VARCHAR(255) COMMENT '群聊ID(群聊时有值)', + `msgtype` VARCHAR(64) NOT NULL COMMENT 'text/image/voice/video/file/link/location/weapp/chatrecord/voip', + `msgtime` BIGINT(20) NOT NULL COMMENT '消息发送时间,UTC毫秒时间戳', + `content` LONGTEXT COMMENT '文本消息内容(长文本使用LONGTEXT)', + `media_data` TEXT COMMENT '媒体消息元数据(sdkfileid/文件大小/时长等),JSON字符串', + `session_id` VARCHAR(128) COMMENT '关联的会话ID(业务生成)', + `decrypt_status` TINYINT DEFAULT 1 COMMENT '解密状态: 1成功 2失败', + `decrypt_error` VARCHAR(512) COMMENT '解密失败原因', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_msgid_corp` (`msgid`, `corp_id`) COMMENT '消息ID+企业ID唯一', + INDEX `idx_seq` (`seq`) COMMENT '序列号索引,用于增量拉取', + INDEX `idx_session_id` (`session_id`) COMMENT '会话ID索引', + INDEX `idx_from_user_msgtime` (`from_user`, `msgtime`) COMMENT '发送者+时间索引', + INDEX `idx_to_user_msgtime` (`to_user`, `msgtime`) COMMENT '接收者+时间索引', + INDEX `idx_roomid_msgtime` (`roomid`, `msgtime`) COMMENT '群聊ID+时间索引', + INDEX `idx_msgtime` (`msgtime`) COMMENT '消息时间索引', + INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微会话存档消息表'; +``` + +#### 2.2.2 会话表 + +```sql +-- ============================================================ +-- 会话表 (conversations) +-- 管理学员与坐席的对话会话 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `conversations` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话唯一ID(业务生成)', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '学员(外部联系人)userid', + `customer_name` VARCHAR(64) COMMENT '学员昵称', + `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席(企业成员)userid', + `staff_name` VARCHAR(64) COMMENT '坐席姓名', + `room_id` VARCHAR(128) COMMENT '群聊ID(群聊时有值)', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '会话状态: ACTIVE-活跃 IDLE-空闲 CLOSED-关闭 ARCHIVED-归档', + `current_stage` VARCHAR(32) DEFAULT '开场白' COMMENT '当前对话阶段', + `stage_confidence` DECIMAL(4,3) DEFAULT 0.000 COMMENT '阶段判断置信度', + `round_count` INT(11) DEFAULT 0 COMMENT '对话轮次', + `intent_count` INT(11) DEFAULT 0 COMMENT '意图识别次数', + `last_msg_id` VARCHAR(255) COMMENT '最后一条消息ID', + `last_msg_time` TIMESTAMP NULL COMMENT '最后消息时间', + `last_msg_content` VARCHAR(512) COMMENT '最后消息内容摘要', + `start_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '会话开始时间', + `end_time` TIMESTAMP NULL COMMENT '会话结束时间', + `idle_duration` INT(11) DEFAULT 0 COMMENT '空闲时长(分钟)', + `context_summary` TEXT COMMENT '对话摘要(LLM生成)', + `key_events` TEXT COMMENT '关键事件列表,JSON字符串', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_session_id` (`session_id`) COMMENT '会话ID唯一', + INDEX `idx_corp_staff` (`corp_id`, `staff_id`, `status`) COMMENT '企业+坐席+状态', + INDEX `idx_corp_customer` (`corp_id`, `customer_id`, `status`) COMMENT '企业+学员+状态', + INDEX `idx_status_time` (`status`, `last_msg_time`) COMMENT '状态+时间', + INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表'; +``` + +#### 2.2.3 对话轮次表 + +```sql +-- ============================================================ +-- 对话轮次表 (conversation_turns) +-- 记录每个对话轮次的详细信息 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `conversation_turns` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `turn_id` VARCHAR(128) NOT NULL COMMENT '轮次ID', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `turn_number` INT(11) NOT NULL COMMENT '轮次序号', + `student_msg_id` VARCHAR(255) COMMENT '学员消息ID', + `student_content` TEXT COMMENT '学员消息内容', + `seat_msg_id` VARCHAR(255) COMMENT '坐席回复消息ID', + `seat_content` TEXT COMMENT '坐席回复内容', + `turn_type` VARCHAR(32) DEFAULT 'QUERY' COMMENT '轮次类型: OPEN-开场 QUERY-咨询 OBJECTION-异议 INTEREST-意向 SILENCE-沉默 FOLLOWUP-跟进 CLOSE-收尾', + `primary_intent` VARCHAR(32) COMMENT '主意图编码', + `intent_confidence` DECIMAL(4,3) COMMENT '意图置信度', + `student_emotion` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '学员情绪: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极 ANXIOUS-焦虑 EXCITED-兴奋', + `key_topics` TEXT COMMENT '涉及主题列表,JSON字符串', + `slot_changes` TEXT COMMENT '槽位变化,JSON字符串', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY `uk_turn_id` (`turn_id`) COMMENT '轮次ID唯一', + INDEX `idx_session_turn` (`session_id`, `turn_number`) COMMENT '会话+轮次号', + INDEX `idx_session_time` (`session_id`, `created_at`) COMMENT '会话+时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话轮次表'; +``` + +#### 2.2.4 意图分类表 + +```sql +-- ============================================================ +-- 意图分类表 (intent_categories) +-- 存储40个意图分类的定义 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `intent_categories` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `code` VARCHAR(32) NOT NULL COMMENT '意图编码,如INT-COURSE-01', + `name` VARCHAR(64) NOT NULL COMMENT '意图名称', + `domain` VARCHAR(32) NOT NULL COMMENT '所属领域', + `description` VARCHAR(256) COMMENT '意图说明', + `priority` VARCHAR(8) DEFAULT 'P2' COMMENT '优先级: P1-高 P2-中 P3-低', + `trigger_keywords` TEXT COMMENT '触发关键词列表,JSON数组字符串', + `sample_expressions` TEXT COMMENT '典型表达示例,JSON数组字符串', + `status` TINYINT DEFAULT 1 COMMENT '状态: 1启用 0停用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_code` (`code`) COMMENT '意图编码唯一', + INDEX `idx_domain_priority` (`domain`, `priority`) COMMENT '领域+优先级' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图分类表'; +``` + +#### 2.2.5 意图识别记录表 + +```sql +-- ============================================================ +-- 意图识别记录表 (intent_recognitions) +-- 每次意图识别的详细记录 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `intent_recognitions` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `turn_id` VARCHAR(128) COMMENT '轮次ID', + `msg_id` VARCHAR(255) NOT NULL COMMENT '消息ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID', + `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID', + `message_content` TEXT COMMENT '消息内容摘要', + `primary_intent_code` VARCHAR(32) NOT NULL COMMENT '主意图编码', + `primary_intent_confidence` DECIMAL(4,3) COMMENT '主意图置信度', + `secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串', + `sentiment` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '情感: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极', + `sentiment_confidence` DECIMAL(4,3) COMMENT '情感置信度', + `recognition_method` VARCHAR(32) DEFAULT 'HYBRID' COMMENT '识别方法: RULE-规则 LLM-大模型 HYBRID-混合', + `llm_model` VARCHAR(64) COMMENT '使用的LLM模型', + `prompt_tokens` INT(11) COMMENT 'Prompt Token数', + `completion_tokens` INT(11) COMMENT 'Completion Token数', + `elapsed_ms` INT(11) COMMENT '识别耗时(毫秒)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_session_msg` (`session_id`, `msg_id`) COMMENT '会话+消息', + INDEX `idx_customer_time` (`customer_id`, `created_at`) COMMENT '学员+时间', + INDEX `idx_intent_code` (`primary_intent_code`, `created_at`) COMMENT '意图编码+时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图识别记录表'; +``` + +#### 2.2.6 学员画像表 + +```sql +-- ============================================================ +-- 学员画像表 (customer_profiles) +-- 存储学员的多维度画像 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `customer_profiles` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `customer_id` VARCHAR(128) NOT NULL COMMENT '学员userid', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `student_type` VARCHAR(32) COMMENT '学员类型: 在校大学生/转行人员/在职提升/高中毕业生/家长代询', + `student_type_confidence` DECIMAL(4,3) COMMENT '学员类型置信度', + `skill_level` VARCHAR(32) COMMENT '基础水平: 零基础/有美术基础/相关专业/有从业经验', + `skill_level_confidence` DECIMAL(4,3) COMMENT '基础水平置信度', + `intent_level` VARCHAR(16) DEFAULT '低' COMMENT '意向度等级: 高/中/低/无意向', + `intent_score` DECIMAL(6,2) DEFAULT 0.00 COMMENT '意向度分数', + `concern_focus` VARCHAR(32) COMMENT '关注重点: 价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型', + `concern_focus_confidence` DECIMAL(4,3) COMMENT '关注重点置信度', + `decision_stage` VARCHAR(32) DEFAULT '信息了解' COMMENT '决策阶段: 信息了解/方案比较/购买决策/报名成交', + `decision_stage_confidence` DECIMAL(4,3) COMMENT '决策阶段置信度', + `interested_courses` TEXT COMMENT '意向课程列表,JSON字符串', + `budget_hint` VARCHAR(64) COMMENT '预算暗示', + `preferred_city` VARCHAR(32) COMMENT '意向城市', + `age` INT(11) COMMENT '年龄', + `education` VARCHAR(32) COMMENT '学历', + `current_occupation` VARCHAR(64) COMMENT '当前职业', + `slot_data` TEXT COMMENT '完整槽位数据,JSON字符串', + `conversation_count` INT(11) DEFAULT 0 COMMENT '对话次数', + `last_conversation_time` TIMESTAMP NULL COMMENT '上次对话时间', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '学员+企业唯一', + INDEX `idx_intent_score` (`intent_score`) COMMENT '意向度分数索引', + INDEX `idx_decision_stage` (`decision_stage`) COMMENT '决策阶段索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员画像表'; +``` + +#### 2.2.7 话术库表 + +```sql +-- ============================================================ +-- 话术库表 (utterances) +-- 存储所有推荐话术 +-- MySQL 5.7适配: JSON标签 → TEXT,向量存Redis +-- ============================================================ +CREATE TABLE IF NOT EXISTS `utterances` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `utterance_id` VARCHAR(64) NOT NULL COMMENT '话术唯一ID(业务编码)', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `title` VARCHAR(128) NOT NULL COMMENT '话术标题', + `content` TEXT NOT NULL COMMENT '话术内容(富文本)', + `content_text` TEXT COMMENT '纯文本内容(用于全文检索)', + `version` INT(11) DEFAULT 1 COMMENT '版本号', + `status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '状态: DRAFT-草稿 ACTIVE-启用 INACTIVE-停用 DEPRECATED-废弃', + `priority` INT(11) DEFAULT 5 COMMENT '优先级(1-10,越小越优先)', + `stage_tags` TEXT COMMENT '适用阶段标签,JSON数组字符串', + `intent_tags` TEXT COMMENT '匹配意图标签,JSON数组字符串', + `profile_tags` TEXT COMMENT '适用画像标签,JSON数组字符串', + `course_type_tags` TEXT COMMENT '适用课程标签,JSON数组字符串', + `emotion_tags` TEXT COMMENT '情感标签,JSON数组字符串', + `topic_tags` TEXT COMMENT '主题标签,JSON数组字符串', + `used_count` INT(11) DEFAULT 0 COMMENT '使用次数', + `selected_count` INT(11) DEFAULT 0 COMMENT '选中次数', + `positive_feedback` INT(11) DEFAULT 0 COMMENT '正面反馈数', + `negative_feedback` INT(11) DEFAULT 0 COMMENT '负面反馈数', + `conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数', + `success_rate` DECIMAL(5,4) DEFAULT 0.0000 COMMENT '成功率(0-1)', + `avg_response_time_ms` INT(11) COMMENT '平均响应时间(毫秒)', + `has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量: 0否 1是', + `vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间', + `trigger_keywords` TEXT COMMENT '触发关键词,JSON字符串', + `variables` TEXT COMMENT '变量定义,JSON字符串', + `created_by` VARCHAR(64) COMMENT '创建人', + `reviewed_by` VARCHAR(64) COMMENT '审核人', + `source` VARCHAR(32) DEFAULT 'MANUAL' COMMENT '来源: MANUAL-人工录入 IMPORTED-导入 LLM_GENERATED-AI生成', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_utterance_id` (`utterance_id`) COMMENT '话术ID唯一', + INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态', + INDEX `idx_success_rate` (`success_rate`) COMMENT '成功率索引', + INDEX `idx_used_count` (`used_count`) COMMENT '使用次数索引', + FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术库表'; +``` + +#### 2.2.8 话术分类表 + +```sql +-- ============================================================ +-- 话术分类表 (utterance_categories) +-- 话术按销售阶段分类 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `utterance_categories` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `code` VARCHAR(32) NOT NULL COMMENT '分类编码', + `name` VARCHAR(64) NOT NULL COMMENT '分类名称', + `parent_code` VARCHAR(32) DEFAULT 'ROOT' COMMENT '父级编码', + `sort_order` INT(11) DEFAULT 0 COMMENT '排序号', + `color` VARCHAR(16) DEFAULT '#1890ff' COMMENT '显示颜色', + `description` VARCHAR(256) COMMENT '分类说明', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY `uk_code` (`code`) COMMENT '分类编码唯一' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术分类表'; +``` + +#### 2.2.9 话术效果表 + +```sql +-- ============================================================ +-- 话术效果表 (utterance_performance) +-- 每小时聚合的话术使用效果 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `utterance_performance` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `utterance_id` VARCHAR(64) NOT NULL COMMENT '话术ID', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `hour_window` VARCHAR(13) NOT NULL COMMENT '时间窗口,格式: 2025-01-15-14', + `show_count` INT(11) DEFAULT 0 COMMENT '展示次数', + `click_count` INT(11) DEFAULT 0 COMMENT '点击次数', + `send_count` INT(11) DEFAULT 0 COMMENT '发送次数', + `positive_count` INT(11) DEFAULT 0 COMMENT '正面反馈数', + `negative_count` INT(11) DEFAULT 0 COMMENT '负面反馈数', + `ignore_count` INT(11) DEFAULT 0 COMMENT '忽略次数', + `conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数', + `ctr` DECIMAL(5,4) COMMENT '点击率', + `conversion_rate` DECIMAL(5,4) COMMENT '转化率', + `score` DECIMAL(6,4) COMMENT '综合评分(EWA算法)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY `uk_utterance_hour` (`utterance_id`, `hour_window`) COMMENT '话术+时间窗口唯一', + INDEX `idx_corp_hour` (`corp_id`, `hour_window`) COMMENT '企业+时间窗口' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术效果表'; +``` + +#### 2.2.10 推荐记录表 + +```sql +-- ============================================================ +-- 推荐记录表 (recommendations) +-- 记录每次话术推荐的详细日志 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `recommendations` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `recommendation_id` VARCHAR(128) NOT NULL COMMENT '推荐记录ID', + `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID', + `turn_id` VARCHAR(128) COMMENT '轮次ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID', + `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `primary_intent` VARCHAR(32) COMMENT '主意图编码', + `secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串', + `student_type` VARCHAR(32) COMMENT '学员类型', + `current_stage` VARCHAR(32) COMMENT '当前阶段', + `student_message` VARCHAR(512) COMMENT '学员消息', + `recommended_count` INT(11) DEFAULT 0 COMMENT '推荐条数', + `recall_sources` TEXT COMMENT '召回来源统计,JSON字符串', + `generation_mode` VARCHAR(32) DEFAULT 'KB_ONLY' COMMENT '生成模式: KB_ONLY-话术库 KB_LLM_MIX-混合 LLM_ONLY-纯AI', + `elapsed_ms` INT(11) COMMENT '推荐耗时(毫秒)', + `staff_action` VARCHAR(32) DEFAULT 'NOT_SEEN' COMMENT '坐席操作: SENT-发送 EDITED-编辑后发送 IGNORED-忽略 NOT_SEEN-未查看', + `feedback` VARCHAR(32) DEFAULT 'NONE' COMMENT '反馈: POSITIVE-正面 NEGATIVE-负面 NONE-无', + `feedback_reason` VARCHAR(128) COMMENT '反馈原因', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `action_time` TIMESTAMP NULL COMMENT '操作时间', + UNIQUE KEY `uk_rec_id` (`recommendation_id`) COMMENT '推荐ID唯一', + INDEX `idx_session` (`session_id`, `created_at`) COMMENT '会话+时间', + INDEX `idx_staff` (`staff_id`, `created_at`) COMMENT '坐席+时间', + INDEX `idx_intent` (`primary_intent`, `created_at`) COMMENT '意图+时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐记录表'; +``` + +#### 2.2.11 知识库文档表 + +```sql +-- ============================================================ +-- 知识库文档表 (knowledge_documents) +-- 存储知识库文档和FAQ +-- ============================================================ +CREATE TABLE IF NOT EXISTS `knowledge_documents` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `doc_id` VARCHAR(64) NOT NULL COMMENT '文档唯一ID', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `title` VARCHAR(256) NOT NULL COMMENT '文档标题', + `content` LONGTEXT COMMENT '文档内容', + `content_text` LONGTEXT COMMENT '纯文本内容(用于全文检索)', + `doc_type` VARCHAR(32) DEFAULT 'KNOWLEDGE' COMMENT '文档类型: KNOWLEDGE-知识 UTTERANCE-话术 FAQ-常见问题', + `category` VARCHAR(64) COMMENT '分类', + `tags` TEXT COMMENT '标签,JSON数组字符串', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 INACTIVE-停用', + `has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量', + `vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间', + `created_by` VARCHAR(64) COMMENT '创建人', + `updated_by` VARCHAR(64) COMMENT '更新人', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_doc_id` (`doc_id`) COMMENT '文档ID唯一', + INDEX `idx_corp_type` (`corp_id`, `doc_type`, `status`) COMMENT '企业+类型+状态', + FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档表'; +``` + +#### 2.2.12 员工表 + +```sql +-- ============================================================ +-- 员工表 (staffs) +-- 企业坐席员工信息 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `staffs` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `staff_id` VARCHAR(128) NOT NULL COMMENT '企微用户ID', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `name` VARCHAR(64) COMMENT '姓名', + `avatar` VARCHAR(512) COMMENT '头像URL', + `department` VARCHAR(128) COMMENT '部门', + `role` VARCHAR(32) DEFAULT 'ADVISOR' COMMENT '角色: ADMIN-管理员 SUPERVISOR-主管 ADVISOR-顾问', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-在职 INACTIVE-离职', + `push_settings` TEXT COMMENT '推送设置,JSON字符串', + `last_login_time` TIMESTAMP NULL COMMENT '最后登录时间', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_staff_corp` (`staff_id`, `corp_id`) COMMENT '员工+企业唯一', + INDEX `idx_corp_role` (`corp_id`, `role`) COMMENT '企业+角色' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表'; +``` + +#### 2.2.13 客户表 + +```sql +-- ============================================================ +-- 客户表 (customers) +-- 外部联系人(学员)信息 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `customers` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `customer_id` VARCHAR(128) NOT NULL COMMENT '外部联系人userid', + `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID', + `name` VARCHAR(64) COMMENT '姓名/昵称', + `avatar` VARCHAR(512) COMMENT '头像URL', + `type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业', + `gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女', + `phone` VARCHAR(32) COMMENT '手机号', + `unionid` VARCHAR(128) COMMENT '微信unionid', + `add_time` TIMESTAMP NULL COMMENT '添加时间', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '客户+企业唯一', + INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表'; +``` + +#### 2.2.14 管理员表 + +```sql +-- ============================================================ +-- 管理员表 (admins) +-- 系统管理员 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `admins` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `username` VARCHAR(64) NOT NULL COMMENT '用户名', + `password` VARCHAR(256) NOT NULL COMMENT '密码(BCrypt加密)', + `name` VARCHAR(64) COMMENT '姓名', + `email` VARCHAR(128) COMMENT '邮箱', + `phone` VARCHAR(32) COMMENT '手机号', + `role` VARCHAR(32) DEFAULT 'ADMIN' COMMENT '角色: SUPER_ADMIN-超级管理员 ADMIN-管理员 OPERATOR-运营', + `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 DISABLED-停用', + `last_login_time` TIMESTAMP NULL COMMENT '最后登录时间', + `last_login_ip` VARCHAR(64) COMMENT '最后登录IP', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_username` (`username`) COMMENT '用户名唯一' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表'; +``` + +#### 2.2.15 操作日志表 + +```sql +-- ============================================================ +-- 操作日志表 (operation_logs) +-- 系统操作审计日志 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `operator_id` VARCHAR(64) NOT NULL COMMENT '操作人ID', + `operator_name` VARCHAR(64) COMMENT '操作人姓名', + `operator_type` VARCHAR(32) DEFAULT 'STAFF' COMMENT '操作人类型: STAFF-员工 ADMIN-管理员', + `module` VARCHAR(64) COMMENT '操作模块', + `action` VARCHAR(64) COMMENT '操作动作', + `description` TEXT COMMENT '操作描述', + `request_params` TEXT COMMENT '请求参数', + `response_result` TEXT COMMENT '响应结果', + `ip` VARCHAR(64) COMMENT '操作IP', + `user_agent` VARCHAR(512) COMMENT 'User-Agent', + `status` TINYINT DEFAULT 1 COMMENT '状态: 1成功 0失败', + `elapsed_ms` INT(11) COMMENT '耗时(毫秒)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_operator` (`operator_id`, `created_at`) COMMENT '操作人+时间', + INDEX `idx_module` (`module`, `action`, `created_at`) COMMENT '模块+动作+时间', + INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; +``` + +### 2.3 初始化数据SQL + +#### 2.3.1 40个意图分类初始化 + +```sql +-- ============================================================ +-- 初始化意图分类(40个场景) +-- 分类: 课程相关(8) + 价格相关(5) + 就业相关(7) + 师资相关(5) + 基础条件(4) + 校区相关(4) + 机构资质(3) + 其他(4) +-- ============================================================ + +-- 课程相关 (8个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-COURSE-01', '课程内容咨询', '课程相关', '询问具体学什么内容', 'P1', '["学什么","课程","内容","教什么","学什么软件"]', '["你们的课程具体学什么内容?","课程都教哪些软件?","能说一下课程体系吗?"]', 1), +('INT-COURSE-02', '课程选择建议', '课程相关', '不知道该选哪个方向', 'P1', '["选哪个","推荐","适合我","怎么选","方向"]', '["我应该学3D建模还是原画?","哪个方向好就业?","您能帮我推荐一下吗?"]', 1), +('INT-COURSE-03', '课程大纲索要', '课程相关', '想看详细课程安排', 'P1', '["大纲","课程表","安排","详细","阶段"]', '["能发一下课程大纲吗?","具体是怎么安排的?","每个阶段学什么?"]', 1), +('INT-COURSE-04', '试听预约咨询', '课程相关', '想试听或体验课程', 'P1', '["试听","体验","免费","公开课","试学"]', '["有免费试听吗?","怎么预约试听课?","可以体验一下吗?"]', 1), +('INT-COURSE-05', '课程更新迭代', '课程相关', '担心课程内容过时', 'P2', '["更新","过时","新版","跟上","行业"]', '["课程内容会更新吗?","跟得上行业吗?","用的是最新技术吗?"]', 1), +('INT-COURSE-06', '线上vs线下选择', '课程相关', '不知道选哪种学习方式', 'P1', '["线上","线下","面授","直播","网课","区别"]', '["线上和线下有什么区别?","线上课程效果好吗?","可以线上学习吗?"]', 1), +('INT-COURSE-07', '课程难度询问', '课程相关', '担心零基础能否学会', 'P1', '["零基础","能学会吗","难吗","没有基础","小白","能不能学会"]', '["零基础能学会吗?","我没有美术基础可以学吗?","课程难度大不大?"]', 1), +('INT-COURSE-08', '学习周期询问', '课程相关', '想知道学多久', 'P2', '["多久","多长时间","几个月","学多久","周期"]', '["课程要学多久?","多长时间能学会?","几个月可以学完?"]', 1); + +-- 价格相关 (5个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-PRICE-01', '学费价格询问', '价格相关', '直接问学费多少', 'P1', '["多少钱","学费","价格","费用","收费","怎么收费"]', '["学费多少钱?","课程怎么收费?","一共多少钱?"]', 1), +('INT-PRICE-02', '优惠活动询问', '价格相关', '想知道有没有优惠', 'P1', '["优惠","活动","打折","减免","便宜","特价"]', '["现在有优惠活动吗?","能便宜点吗?","有没有折扣?"]', 1), +('INT-PRICE-03', '分期付款咨询', '价格相关', '关心付款方式', 'P2', '["分期","贷款","月付","首付","月供","花呗"]', '["可以分期付款吗?","有贷款方案吗?","月供多少?"]', 1), +('INT-PRICE-04', '退费政策询问', '价格相关', '关心退费保障', 'P2', '["退费","退款","退钱","学不好退","保障"]', '["学不好可以退费吗?","退费流程是怎样的?","有保障吗?"]', 1), +('INT-PRICE-05', '性价比比较', '价格相关', '和别家比较', 'P2', '["对比","比较","别家","其他机构","性价比","划算"]', '["你们的课程和XX比怎么样?","为什么价格比别人高?","性价比高吗?"]', 1); + +-- 就业相关 (7个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-JOB-01', '就业方向咨询', '就业相关', '学完能做什么', 'P1', '["就业","工作","岗位","方向","做什么"]', '["学完能去哪些公司工作?","就业方向有哪些?","能做什么岗位?"]', 1), +('INT-JOB-02', '薪资水平询问', '就业相关', '关心工资待遇', 'P1', '["工资","薪资","月薪","收入","待遇","多少钱一个月"]', '["毕业后工资能拿多少?","行业薪资水平怎么样?","能挣多少?"]', 1), +('INT-JOB-03', '就业率询问', '就业相关', '关心能否找到工作', 'P1', '["就业率","找到工作","保证","推荐","找工作"]', '["就业率高吗?","能保证就业吗?","好找工作吗?"]', 1), +('INT-JOB-04', '就业服务了解', '就业相关', '了解就业推荐服务', 'P2', '["就业服务","推荐","怎么找工作","帮找工作"]', '["你们怎么帮学员找工作?","有就业推荐吗?","就业服务包含什么?"]', 1), +('INT-JOB-05', '合作企业询问', '就业相关', '想知道合作企业', 'P2', '["合作企业","大厂","腾讯","米哈游","网易","公司"]', '["合作企业有哪些?","有米哈游/腾讯这样的公司吗?","就业单位有哪些?"]', 1), +('INT-JOB-06', '作品集指导', '就业相关', '关心作品质量', 'P2', '["作品集","作品","项目","Demo","案例"]', '["学完能做出作品集吗?","作品质量怎么样?","能参与项目吗?"]', 1), +('INT-JOB-07', '实习机会咨询', '就业相关', '想参与真实项目', 'P3', '["实习","真实项目","实训","参与项目","实战"]', '["有实习机会吗?","可以参与真实项目吗?","有企业实训吗?"]', 1); + +-- 师资相关 (5个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-TEACH-01', '师资背景询问', '师资相关', '想知道老师是谁', 'P2', '["老师","师资","讲师","谁教的","教学经验"]', '["老师是什么背景?","老师有项目经验吗?","谁来讲课?"]', 1), +('INT-TEACH-02', '教学模式了解', '师资相关', '想知道怎么上课', 'P2', '["怎么上课","教学方式","班型","一对一","多少人"]', '["是怎么上课的?","一个班多少人?","教学模式是怎样的?"]', 1), +('INT-TEACH-03', '课后答疑服务', '师资相关', '关心课后辅导', 'P2', '["答疑","辅导","课后","问问题","指导"]', '["课后有问题可以问老师吗?","答疑及时吗?","有课后辅导吗?"]', 1), +('INT-TEACH-04', '师生比例询问', '师资相关', '关心教学质量', 'P3', '["一个老师","带多少学生","比例","师生"]', '["一个老师带多少学生?","能照顾到每个人吗?","班级多大?"]', 1), +('INT-TEACH-05', '学习效果保障', '师资相关', '担心学不会怎么办', 'P2', '["学不会","怎么办","重修","保障","跟不上"]', '["学不会怎么办?","怎么保证学习效果?","跟不上怎么办?"]', 1); + +-- 基础条件 (4个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-BASIC-01', '学历要求咨询', '基础条件', '学历是否够', 'P2', '["学历","要求","高中","大专","本科"]', '["需要什么学历?","高中生可以学吗?","学历要求高吗?"]', 1), +('INT-BASIC-02', '年龄限制询问', '基础条件', '年龄是否合适', 'P2', '["年龄","多大","几岁","限制","30岁"]', '["有没有年龄限制?","30岁了还能学吗?","年龄大能学会吗?"]', 1), +('INT-BASIC-03', '零基础入学', '基础条件', '零基础可行性', 'P1', '["零基础","没有基础","小白","完全不会","从来没"]', '["我没有美术基础可以学吗?","零基础能学会吗?","完全没接触过可以学吗?"]', 1), +('INT-BASIC-04', '证书颁发询问', '基础条件', '学完发什么证书', 'P3', '["证书","毕业证","结业证","证明","认证"]', '["学完发证书吗?","证书有用吗?","有毕业证吗?"]', 1); + +-- 校区相关 (4个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-LOC-01', '校区地址询问', '校区相关', '想知道校区在哪', 'P2', '["校区","地址","在哪","位置","交通"]', '["校区在哪里?","附近有地铁吗?","具体地址是什么?"]', 1), +('INT-LOC-02', '校区参观预约', '校区相关', '想去参观', 'P2', '["参观","看看","实地考察","走访","预约"]', '["可以参观校区吗?","怎么预约参观?","能去看看吗?"]', 1), +('INT-LOC-03', '教学设备了解', '校区相关', '关心硬件条件', 'P3', '["设备","电脑","配置","硬件","机房"]', '["需要自备电脑吗?","校区提供设备吗?","电脑配置怎么样?"]', 1), +('INT-LOC-04', '住宿安排咨询', '校区相关', '外地学员住宿', 'P3', '["住宿","宿舍","外地","住哪","租房"]', '["外地学员有住宿吗?","住宿怎么安排?","有宿舍吗?"]', 1); + +-- 机构资质 (3个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-QUAL-01', '机构正规性', '机构资质', '是否正规机构', 'P2', '["正规","资质","办学","合法","备案"]', '["你们是正规机构吗?","有办学资质吗?","是国家认可的吗?"]', 1), +('INT-QUAL-02', '口碑评价询问', '机构资质', '想看学员评价', 'P3', '["评价","口碑","反馈","怎么样","好吗"]', '["学员评价怎么样?","有老学员反馈吗?","口碑如何?"]', 1), +('INT-QUAL-03', '媒体报道了解', '机构资质', '想看权威背书', 'P3', '["报道","媒体","电视","新闻","央视"]', '["有上过电视报道吗?","媒体报道过吗?","央视那个是真的吗?"]', 1); + +-- 其他 (4个) +INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES +('INT-OTHER-01', '大师课咨询', '其他', '进阶课程', 'P3', '["大师课","研修班","进阶","艺术家","高级"]', '["大师课和普通课什么区别?","研修班怎么报名?","大师课学费多少?"]', 1), +('INT-OTHER-02', '企业培训定制', '其他', 'B端需求', 'P3', '["企业培训","定制","公司","团队","B端"]', '["有企业定制培训吗?","公司团队能报名吗?","企业内训怎么合作?"]', 1), +('INT-OTHER-03', '比赛活动咨询', '其他', 'GGAC等赛事', 'P3', '["比赛","GGAC","大赛","活动","赛事"]', '["GGAC比赛怎么参加?","有比赛推荐吗?","学员能参加大赛吗?"]', 1), +('INT-OTHER-04', '转班转学咨询', '其他', '变更需求', 'P3', '["转班","换方向","转学","调整","换校区"]', '["可以换课程方向吗?","可以换校区吗?","转班怎么办理?"]', 1); +``` + +#### 2.3.2 8个话术分类初始化 + +```sql +-- ============================================================ +-- 初始化话术分类(8个销售阶段) +-- ============================================================ +INSERT INTO `utterance_categories` (`code`, `name`, `parent_code`, `sort_order`, `color`, `description`) VALUES +('STAGE-01', '开场白', 'ROOT', 1, '#52c41a', '首次接待学员/家长,建立信任,收集基础信息'), +('STAGE-02', '需求探询', 'ROOT', 2, '#1890ff', '了解学员背景和目标,精准画像,挖掘需求'), +('STAGE-03', '课程推荐', 'ROOT', 3, '#fa8c16', '匹配适合的课程方向,精准推荐1-2个方向'), +('STAGE-04', '价值塑造', 'ROOT', 4, '#722ed1', '强调优势和差异化,建立价值认同'), +('STAGE-05', '报价沟通', 'ROOT', 5, '#eb2f96', '价格说明和方案,化解价格顾虑'), +('STAGE-06', '异议处理', 'ROOT', 6, '#f5222d', '应对各类顾虑,消除障碍,建立信心'), +('STAGE-07', '促成报名', 'ROOT', 7, '#faad14', '推动转化成交,促成立即行动'), +('STAGE-08', '跟进维护', 'ROOT', 8, '#13c2c2', '未成交学员跟进,保持联系,等待时机'); +``` + +#### 2.3.3 20条核心话术初始化 + +```sql +-- ============================================================ +-- 初始化核心话术(20条,覆盖所有关键场景) +-- 编号规则: STAGE阶段-课程方向-学员类型-序号 +-- ============================================================ + +-- ===== 开场白 (2条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S01-001', 'default', '首次接待通用开场白', '您好!欢迎咨询第九联盟🎨 我是您的课程顾问老师。\n\n我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。\n\n请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍~', '您好!欢迎咨询第九联盟 我是您的课程顾问老师。我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍', 'ACTIVE', 1, '["STAGE-01"]', '["INT-COURSE-01","INT-COURSE-02"]', '["通用"]', 128, 0.8500, 'MANUAL'), +('UTT-S01-002', 'default', '老学员/二次咨询开场', '您好!欢迎再次咨询第九联盟🎨\n\n看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的【课程方向】还感兴趣吗?\n\n我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', '您好!欢迎再次咨询第九联盟 看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的课程方向还感兴趣吗?我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', 'ACTIVE', 2, '["STAGE-01"]', '["INT-COURSE-01"]', '["老学员"]', 45, 0.7800, 'MANUAL'); + +-- ===== 需求探询 (3条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S02-001', 'default', '转行人员背景了解', '理解您想转行的想法!CG行业确实是一个非常好的选择。\n\n为了给您更精准的建议,想了解一下:\n1️⃣ 您目前从事什么行业?工作了多久?\n2️⃣ 有没有接触过美术或设计相关的软件?\n3️⃣ 是计划脱产学习还是周末学习?\n4️⃣ 有没有特别感兴趣的方向?\n\n我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢~', '理解您想转行的想法!CG行业确实是一个非常好的选择。为了给您更精准的建议,想了解一下:1.您目前从事什么行业?工作了多久?2.有没有接触过美术或设计相关的软件?3.是计划脱产学习还是周末学习?4.有没有特别感兴趣的方向?我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-COURSE-06"]', '["转行人员"]', 96, 0.7800, 'MANUAL'), +('UTT-S02-002', 'default', '在校生需求了解', '同学您好!很高兴为您介绍我们的课程~\n\n想先了解一下您的情况:\n1️⃣ 您现在是大几?什么专业呀?\n2️⃣ 毕业后是想直接就业还是考研?\n3️⃣ 之前接触过CG相关的软件吗?\n4️⃣ 有没有特别关注的发展方向?\n\n根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦~', '同学您好!很高兴为您介绍我们的课程。想先了解一下您的情况:1.您现在是大几?什么专业呀?2.毕业后是想直接就业还是考研?3.之前接触过CG相关的软件吗?4.有没有特别关注的发展方向?根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01"]', '["在校大学生"]', 72, 0.8200, 'MANUAL'), +('UTT-S02-003', 'default', '家长代询需求了解', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。\n\n为了更好地为您介绍,想了解一下:\n1️⃣ 孩子现在多大?是高中在读还是即将毕业?\n2️⃣ 之前有没有美术或绘画的基础?\n3️⃣ 孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)\n4️⃣ 是在本地学习还是可以到其他城市?\n\n我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障。', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。为了更好地为您介绍,想了解一下:1.孩子现在多大?是高中在读还是即将毕业?2.之前有没有美术或绘画的基础?3.孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)4.是在本地学习还是可以到其他城市?我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01","INT-BASIC-02"]', '["家长代询"]', 58, 0.7500, 'MANUAL'); + +-- ===== 课程推荐 (3条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `course_type_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S03-001', 'default', '推荐3D场景UE地编就业班(零基础)', '根据您的情况,我强烈推荐我们的【3D场景UE地编就业班】!\n\n💡 为什么适合您:\n✅ 零基础入学:从软件操作开始教,无需任何基础\n✅ 六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被\n✅ 所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等\n✅ 5个月培训+2个月企业实训,毕业就有项目经验\n✅ 就业方向:3D场景模型师、UE地编师、场景美术师\n\n📊 就业数据:往期学员平均薪资12-18K,就业推荐率95%+\n\n您看需要我发您详细的课程大纲和学员作品吗?', '根据您的情况,我强烈推荐我们的3D场景UE地编就业班!为什么适合您:零基础入学:从软件操作开始教,无需任何基础。六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被。所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等。5个月培训+2个月企业实训,毕业就有项目经验。就业方向:3D场景模型师、UE地编师、场景美术师。就业数据:往期学员平均薪资12-18K,就业推荐率95%+。您看需要我发您详细的课程大纲和学员作品吗?', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["转行人员","零基础"]', '["3D场景UE地编"]', 156, 0.7200, 'MANUAL'), +('UTT-S03-002', 'default', '推荐次世代角色模型就业班', '根据您有美术基础的情况,【次世代角色模型就业班】非常适合您!\n\n💡 课程亮点:\n✅ 所学软件:MD、ZB、MAYA、XGEN、PT等业界主流\n✅ 从角色道具建模→各类套装→角色模型制作→项目实训\n✅ 毕业至少完成3套角色模型作品\n✅ 就业方向:3D角色模型师、次世代角色设计师\n\n📊 行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+\n\n您的美术基础会让您在角色方向有很大优势,学习起来会更快上手!', '根据您有美术基础的情况,次世代角色模型就业班非常适合您!课程亮点:所学软件:MD、ZB、MAYA、XGEN、PT等业界主流。从角色道具建模→各类套装→角色模型制作→项目实训。毕业至少完成3套角色模型作品。就业方向:3D角色模型师、次世代角色设计师。行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+。您的美术基础会让您在角色方向有很大优势,学习起来会更快上手', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础"]', '["次世代角色模型"]', 98, 0.7600, 'MANUAL'), +('UTT-S03-003', 'default', '推荐2D原画就业班', '了解到您有绘画基础,【2D原画就业班】是您的最佳选择!\n\n💡 课程特色:\n✅ 角色/场景双方向可选\n✅ 从基础到进阶系统教学,6-8个月完成全流程\n✅ 真实项目实训,毕业就有作品集\n✅ 就业方向:角色原画师、场景原画师、概念设计师、游戏主美\n\n📊 行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月\n\n有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂!', '了解到您有绘画基础,2D原画就业班是您的最佳选择!课程特色:角色/场景双方向可选。从基础到进阶系统教学,6-8个月完成全流程。真实项目实训,毕业就有作品集。就业方向:角色原画师、场景原画师、概念设计师、游戏主美。行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月。有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础","在校大学生"]', '["2D原画"]', 82, 0.8000, 'MANUAL'); + +-- ===== 价值塑造 (3条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S04-001', 'default', '强调师资团队实力', '您关心师资说明您对学习质量很重视,这点非常关键!\n\n我们的师资团队可以说是行业顶配了:\n🏆 老师均来自腾讯、网易、EA等一线游戏公司\n🏆 平均8年以上项目经验+3年以上教学经验\n🏆 参与过《黑神话悟空》《王者荣耀》《原神》等知名项目\n\n🎓 特别值得一提的是我们的【大师研修班】:\n• 联合16位国内顶级CG艺术家授课\n• 包含《黑神话悟空》3D美术总监王琛老师\n• NVIDIA首席数字艺术家光叔·许喆隆老师\n• 开天工作室创始人罗其胜老师\n\n不仅是学技术,更是和行业内最顶尖的大佬面对面学习!', '您关心师资说明您对学习质量很重视,这点非常关键!我们的师资团队可以说是行业顶配了:老师均来自腾讯、网易、EA等一线游戏公司。平均8年以上项目经验+3年以上教学经验。参与过《黑神话悟空》《王者荣耀》《原神》等知名项目。特别值得一提的是我们的大师研修班:联合16位国内顶级CG艺术家授课。包含《黑神话悟空》3D美术总监王琛老师。NVIDIA首席数字艺术家光叔·许喆隆老师。开天工作室创始人罗其胜老师。不仅是学技术,更是和行业内最顶尖的大佬面对面学习', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-01","INT-TEACH-02"]', '["师资看重型"]', 112, 0.6800, 'MANUAL'), +('UTT-S04-002', 'default', '利用央视报道建立品牌信任', '关于我们机构的资质和实力,有一个特别值得骄傲的——\n\n📺 我们第九联盟被【CCTV2央视财经频道】《正点财经》栏目做了专题报道!\n\n能获得央视的认可,是因为:\n✅ 母公司点晴科技10年游戏美术制作经验\n✅ 与腾讯、网易、米哈游等大厂的深度合作\n✅ 自办GGAC全球游戏动漫美术概念大赛的行业影响力\n✅ 8000+学员的成功就业案例\n✅ 2023年度影响力教育品牌\n\n央视都认可的教育机构,您可以放心选择!', '关于我们机构的资质和实力,有一个特别值得骄傲的。我们第九联盟被CCTV2央视财经频道《正点财经》栏目做了专题报道!能获得央视的认可,是因为:母公司点晴科技10年游戏美术制作经验。与腾讯、网易、米哈游等大厂的深度合作。自办GGAC全球游戏动漫美术概念大赛的行业影响力。8000+学员的成功就业案例。2023年度影响力教育品牌。央视都认可的教育机构,您可以放心选择', 'ACTIVE', 1, '["STAGE-04"]', '["INT-QUAL-01","INT-QUAL-03"]', '["品牌信任型","家长代询"]', 89, 0.8000, 'MANUAL'), +('UTT-S04-003', 'default', '强调实战教学模式', '我们的教学模式是业内独一无二的"5+2"模式:\n\n📚 5个月技能培训 + 2个月企业实训\n\n这意味着什么?\n✅ 毕业时您已经有真实项目经验,不是零基础新人\n✅ 实训期间进入合作企业,表现优秀可直接留用\n✅ 简历上有实际项目,面试更有竞争力\n✅ 500+合作企业就业推荐网络\n\n相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!\n\n因为企业要的是能直接上手干活的人,而我们培养的就是这样的人。', '我们的教学模式是业内独一无二的5+2模式:5个月技能培训+2个月企业实训。这意味着什么?毕业时您已经有真实项目经验,不是零基础新人。实训期间进入合作企业,表现优秀可直接留用。简历上有实际项目,面试更有竞争力。500+合作企业就业推荐网络。相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!因为企业要的是能直接上手干活的人,而我们培养的就是这样的人', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-02","INT-JOB-01"]', '["就业导向型"]', 76, 0.7400, 'MANUAL'); + +-- ===== 报价沟通 (2条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S05-001', 'default', '线下班学费说明+价值锚定', '我们的线下全日制面授班学费是💰29,800元。\n\n我理解价格是您需要考虑的重要因素,让我帮您算一笔账:\n\n📊 投入产出分析:\n• 学习周期:7个月(5个月培训+2个月实训)\n• 毕业后平均薪资:12-18K/月\n• 按最低12K算,3个月就回本了\n• 相比大学四年十几万学费,这个投入产出比是非常高的\n\n💡 而且您得到的是:\n✅ 一线大厂老师的面对面教学\n✅ 真实企业项目的实战经验\n✅ 500+合作企业的就业推荐\n✅ 终身职业规划服务\n\n这不仅仅是一门课程,更是进入游戏行业的职业通道。', '我们的线下全日制面授班学费是29,800元。我理解价格是您需要考虑的重要因素,让我帮您算一笔账:投入产出分析:学习周期:7个月(5个月培训+2个月实训)。毕业后平均薪资:12-18K/月。按最低12K算,3个月就回本了。相比大学四年十几万学费,这个投入产出比是非常高的。而且您得到的是:一线大厂老师的面对面教学。真实企业项目的实战经验。500+合作企业的就业推荐。终身职业规划服务。这不仅仅是一门课程,更是进入游戏行业的职业通道', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 134, 0.6500, 'MANUAL'), +('UTT-S05-002', 'default', '分期方案说明', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:\n\n💳 付款方案:\n✅ 12期分期付款,月供约2,500元\n✅ 支持花呗/信用卡分期\n✅ 早鸟优惠:提前报名减免2,000元\n✅ 团报优惠:2人同行各减1,000元\n\n🎁 限时福利:\n现在报名还赠送价值3,000元的预科课程!\n\n月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。\n\n您看哪种付款方式比较适合您?', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:12期分期付款,月供约2,500元。支持花呗/信用卡分期。早鸟优惠:提前报名减免2,000元。团报优惠:2人同行各减1,000元。限时福利:现在报名还赠送价值3,000元的预科课程!月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。您看哪种付款方式比较适合您?', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-03","INT-PRICE-02"]', '["价格敏感型"]', 108, 0.7000, 'MANUAL'); + +-- ===== 异议处理 (4条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S06-001', 'default', '应对"价格太贵"异议', '完全理解您的顾虑,投资学习确实需要慎重考虑。\n\n其实和您算一下,我们的性价比是非常高的:\n\n📈 横向对比:\n• 同类机构学费普遍在3-5万\n• 我们还包含2个月企业实训(其他机构一般没有)\n• 师资都是一线大厂出身\n\n💳 付款方案:\n• 支持12期分期,月供只要2,500多\n• 现在报名可享早鸟优惠,减免2,000元\n• 可以申请试听,满意后再决定\n\n🎁 限时福利:\n本周报名额外赠送价值3,000元的预科课程!\n\n您可以先来试听一下,亲身感受教学质量,再决定也不迟~', '完全理解您的顾虑,投资学习确实需要慎重考虑。其实和您算一下,我们的性价比是非常高的:横向对比:同类机构学费普遍在3-5万。我们还包含2个月企业实训(其他机构一般没有)。师资都是一线大厂出身。付款方案:支持12期分期,月供只要2,500多。现在报名可享早鸟优惠,减免2,000元。可以申请试听,满意后再决定。限时福利:本周报名额外赠送价值3,000元的预科课程!您可以先来试听一下,亲身感受教学质量,再决定也不迟', 'ACTIVE', 1, '["STAGE-06"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 142, 0.5800, 'MANUAL'), +('UTT-S06-002', 'default', '应对"零基础怕学不会"异议', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。\n\n🛡️ 我们的保障体系:\n\n1️⃣ 【预科课程】:正式开课前有免费预科,先打基础\n2️⃣ 【小班教学】:每班不超过25人,确保老师能照顾到每个人\n3️⃣ 【一对一辅导】:课后有问题随时问,老师一对一答疑\n4️⃣ 【阶段考核】:每个阶段都有测试,跟不上可以免费重修\n5️⃣ 【就业协议】:签订正式培训协议,保障您的权益\n\n👨‍🎓 真实案例:\n小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。\n\n只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。我们的保障体系:1.预科课程:正式开课前有免费预科,先打基础。2.小班教学:每班不超过25人,确保老师能照顾到每个人。3.一对一辅导:课后有问题随时问,老师一对一答疑。4.阶段考核:每个阶段都有测试,跟不上可以免费重修。5.就业协议:签订正式培训协议,保障您的权益。真实案例:小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-07","INT-TEACH-05"]', '["转行人员","零基础"]', 186, 0.7000, 'MANUAL'), +('UTT-S06-003', 'default', '应对"要和家人商量"异议', '完全理解!和家人商量是负责任的做法。\n\n我帮您整理一下关键信息,方便您和家人沟通:\n\n📋 第九联盟核心优势:\n✅ 母公司点晴科技10年游戏制作经验\n✅ CCTV2央视财经专题报道\n✅ 与腾讯、网易、米哈游等500+企业合作\n✅ 师资均来自一线大厂,8年+经验\n✅ 5个月培训+2个月企业实训\n✅ 签订正式培训协议,就业推荐\n\n📱 建议您可以:\n1. 先预约一节免费试听课,带家人一起来\n2. 我们的校区随时欢迎参观\n3. 有任何问题随时联系我\n\n对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款。', '完全理解!和家人商量是负责任的做法。我帮您整理一下关键信息,方便您和家人沟通:第九联盟核心优势:母公司点晴科技10年游戏制作经验。CCTV2央视财经专题报道。与腾讯、网易、米哈游等500+企业合作。师资均来自一线大厂,8年+经验。5个月培训+2个月企业实训。签订正式培训协议,就业推荐。建议您可以:先预约一节免费试听课,带家人一起来。我们的校区随时欢迎参观。有任何问题随时联系我。对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-06","INT-QUAL-01"]', '["通用"]', 95, 0.6500, 'MANUAL'), +('UTT-S06-004', 'default', '应对"考虑一下"异议', '没问题,考虑清楚是对的!\n\n为了帮您更好地做决定,我可以:\n1. 发您一些学员的作品集和就业案例\n2. 安排一节免费的线上/线下试听课\n3. 给您发详细的课程大纲\n\n⏰ 友情提示:\n我们本期的优惠活动截止到【日期】,名额也有限。\n\n另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会~\n\n我帮您预约一个名额吧?', '没问题,考虑清楚是对的!为了帮您更好地做决定,我可以:1.发您一些学员的作品集和就业案例。2.安排一节免费的线上/线下试听课。3.给您发详细的课程大纲。友情提示:我们本期的优惠活动截止到XX日期,名额也有限。另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会。我帮您预约一个名额吧?', 'ACTIVE', 2, '["STAGE-06"]', '["INT-COURSE-04"]', '["通用"]', 78, 0.6200, 'MANUAL'); + +-- ===== 促成报名 (2条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S07-001', 'default', '限时优惠促成报名', '和您聊了这么多,感觉您对我们的课程和前景都很认可!\n\n🎉 现在报名正是最好的时机:\n\n✅ 本月优惠:学费直减2,000元(仅剩最后3个名额)\n✅ 赠送价值3,000元的预科课程\n✅ 优先安排下期班级座位\n✅ 赠送全套学习资料包\n\n⏰ 优惠截止日期:本月31日\n\n建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!\n\n我这边帮您预留一个名额,您看可以吗?', '和您聊了这么多,感觉您对我们的课程和前景都很认可!现在报名正是最好的时机:本月优惠:学费直减2,000元(仅剩最后3个名额)。赠送价值3,000元的预科课程。优先安排下期班级座位。赠送全套学习资料包。优惠截止日期:本月31日。建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!我这边帮您预留一个名额,您看可以吗?', 'ACTIVE', 1, '["STAGE-07"]', '["INT-PRICE-02","INT-COURSE-04"]', '["高意向"]', 67, 0.4500, 'MANUAL'), +('UTT-S07-002', 'default', '试听后促成报名', '试听完感觉怎么样?是不是比想象中更容易上手?\n\n我们很多学员试听完最大的感受就是:"原来零基础也能做出这样的效果!"\n\n🎉 现在报名您还能享受:\n\n✅ 试听当日报名额外优惠1,000元\n✅ 赠送全套软件安装包和教学视频\n✅ 优先安排住宿(外地学员)\n✅ 加入学员群,提前认识同学\n\n您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续。', '试听完感觉怎么样?是不是比想象中更容易上手?我们很多学员试听完最大的感受就是:原来零基础也能做出这样的效果!现在报名您还能享受:试听当日报名额外优惠1,000元。赠送全套软件安装包和教学视频。优先安排住宿(外地学员)。加入学员群,提前认识同学。您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续', 'ACTIVE', 1, '["STAGE-07"]', '["INT-COURSE-04","INT-PRICE-03"]', '["高意向"]', 52, 0.5500, 'MANUAL'); + +-- ===== 跟进维护 (1条) ===== +INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES +('UTT-S08-001', 'default', '3天后未报名跟进', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?\n\n🎁 给您带来一个好消息:\n本周我们有一个【免费的线上公开课】,主题是"零基础如何入门3D建模",由我们资深讲师罗老师主讲。\n\n不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!\n\n📅 时间:本周六(1月18日)下午2:00\n📍 形式:线上直播\n\n我帮您预约一个名额吧,您那天有时间吗?', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?给您带来一个好消息:本周我们有一个免费的线上公开课,主题是零基础如何入门3D建模,由我们资深讲师罗老师主讲。不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!时间:本周六下午2:00。形式:线上直播。我帮您预约一个名额吧,您那天有时间吗?', 'ACTIVE', 1, '["STAGE-08"]', '["INT-COURSE-04","INT-OTHER-03"]', '["中意向","低意向"]', 45, 0.3500, 'MANUAL'); +``` + + + +#### 2.3.4 系统管理员初始化 + +```sql +-- ============================================================ +-- 初始化系统管理员 +-- 密码: admin123(BCrypt加密后) +-- ============================================================ +INSERT INTO `admins` (`username`, `password`, `name`, `email`, `role`, `status`, `created_at`) VALUES +('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iOE1Qh6G', '系统管理员', 'admin@9artedu.com', 'SUPER_ADMIN', 'ACTIVE', NOW()); +-- 默认密码: admin123,请在首次登录后立即修改 +``` + +### 2.4 MySQL 5.7 JSON处理说明 + +由于MySQL 5.7不支持JSON类型,所有需要存储结构化数据的字段使用TEXT类型,在Java应用层进行序列化和反序列化。 + +#### 2.4.1 Jackson序列化工具类 + +```java +package com.artedu.common.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * JSON工具类 - 用于MySQL 5.7 TEXT字段的序列化/反序列化 + * 替代MySQL 8.0的JSON类型 + */ +@Slf4j +public class JsonUtils { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + static { + // 禁用日期时间戳输出,使用ISO格式 + OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + /** + * 将对象序列化为JSON字符串 + */ + public static String toJson(Object obj) { + if (obj == null) { + return null; + } + try { + return OBJECT_MAPPER.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("JSON序列化失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 将JSON字符串反序列化为对象 + */ + public static T fromJson(String json, Class 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 List fromJsonList(String json, Class 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 fromJsonMap(String json) { + if (json == null || json.isEmpty()) { + return Collections.emptyMap(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.error("JSON反序列化为Map失败: {}", e.getMessage(), e); + return Collections.emptyMap(); + } + } + + /** + * 安全获取JSON字符串(防止null) + */ + public static String safeJson(Object obj) { + String json = toJson(obj); + return json != null ? json : "{}"; + } + + /** + * 安全解析JSON字符串(防止null) + */ + public static String safeParse(String json) { + return json != null && !json.isEmpty() ? json : "{}"; + } +} +``` + +#### 2.4.2 MyBatis TypeHandler(自动序列化/反序列化) + +```java +package com.artedu.common.mybatis; + +import com.artedu.common.util.JsonUtils; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * MyBatis JSON类型处理器 - 用于MySQL 5.7 TEXT字段 + * 自动将Java对象序列化为JSON字符串存入TEXT字段 + * 读取时自动将JSON字符串反序列化为Java对象 + * + * 使用方法: + * 1. 在实体类字段上添加 @TableField(typeHandler = JsonTypeHandler.class) + * 2. 或在application.yml中配置默认类型处理器 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(Object.class) +public class JsonTypeHandler extends BaseTypeHandler { + + 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 + +```java +package com.artedu.common.result; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一API响应结构 + * 所有接口返回此结构,确保前端处理一致 + * + * @param 响应数据类型 + */ +@Data +public class Result 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 Result success() { + Result result = new Result<>(); + result.setCode(0); + result.setMessage("success"); + return result; + } + + /** + * 成功响应(带数据) + */ + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(0); + result.setMessage("success"); + result.setData(data); + return result; + } + + /** + * 成功响应(带数据和消息) + */ + public static Result success(T data, String message) { + Result result = new Result<>(); + result.setCode(0); + result.setMessage(message); + result.setData(data); + return result; + } + + /** + * 失败响应 + */ + public static Result fail(String message) { + Result result = new Result<>(); + result.setCode(-1); + result.setMessage(message); + return result; + } + + /** + * 失败响应(带状态码) + */ + public static Result fail(int code, String message) { + Result 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 + +```java +package com.artedu.common.result; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * 分页响应结构 + */ +@Data +public class PageResult implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 当前页 + */ + private Integer page; + + /** + * 每页大小 + */ + private Integer size; + + /** + * 总记录数 + */ + private Long total; + + /** + * 总页数 + */ + private Integer totalPages; + + /** + * 数据列表 + */ + private List list; + + public static PageResult of(Integer page, Integer size, Long total, List list) { + PageResult result = new PageResult<>(); + result.setPage(page); + result.setSize(size); + result.setTotal(total); + result.setTotalPages((int) Math.ceil((double) total / size)); + result.setList(list != null ? list : Collections.emptyList()); + return result; + } +} +``` + +#### 3.1.3 全局异常处理 + +```java +package com.artedu.common.exception; + +import com.artedu.common.result.Result; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.BindException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理器 + * 统一处理控制器层抛出的异常,转换为标准响应格式 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理业务异常 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException e) { + log.warn("业务异常: {}", e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } + + /** + * 处理参数校验异常 + */ + @ExceptionHandler(BindException.class) + public Result 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 handleException(Exception e) { + log.error("系统异常: {}", e.getMessage(), e); + return Result.fail(500, "系统繁忙,请稍后重试"); + } +} +``` + +#### 3.1.4 业务异常类 + +```java +package com.artedu.common.exception; + +import lombok.Getter; + +/** + * 业务异常 + * 用于抛出可预期的业务逻辑错误 + */ +@Getter +public class BusinessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private final int code; + + public BusinessException(String message) { + super(message); + this.code = -1; + } + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public BusinessException(String message, Throwable cause) { + super(message, cause); + this.code = -1; + } +} +``` + +#### 3.1.5 MyBatis-Plus 分页配置 + +```java +package com.artedu.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus配置 + * 启用分页插件(MySQL 5.7方言) + */ +@Configuration +public class MyBatisPlusConfig { + + /** + * 分页插件配置 + * DbType.MYSQL 指定MySQL方言,确保分页SQL正确生成 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} +``` + +#### 3.1.6 Redis配置 + +```java +package com.artedu.common.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis配置 + * 配置Key和Value的序列化方式 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key使用String序列化 + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + // Value使用JSON序列化(带类型信息) + ObjectMapper mapper = new ObjectMapper(); + mapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } +} +``` + +#### 3.1.7 RabbitMQ配置 + +```java +package com.artedu.common.config; + +import org.springframework.amqp.core.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * RabbitMQ配置 + * 定义消息队列、交换机和绑定关系 + */ +@Configuration +public class RabbitConfig { + + // ========== 交换机定义 ========== + + /** + * 存档消息交换机 - Direct类型 + */ + public static final String ARCHIVE_EXCHANGE = "archive.exchange"; + + /** + * 对话事件交换机 - Topic类型 + */ + public static final String CONVERSATION_EXCHANGE = "conversation.exchange"; + + // ========== 队列定义 ========== + + /** + * 新消息队列 - 存档服务→对话服务 + */ + public static final String QUEUE_NEW_MESSAGE = "archive.new_message.queue"; + + /** + * 意图分析队列 - 对话服务→意图服务 + */ + public static final String QUEUE_INTENT_ANALYZE = "conversation.intent_analyze.queue"; + + /** + * 话术推荐队列 - 意图服务→推荐服务 + */ + public static final String QUEUE_RECOMMEND = "intent.recommend.queue"; + + // ========== 路由键定义 ========== + + public static final String ROUTING_KEY_NEW_MESSAGE = "archive.message.new"; + public static final String ROUTING_KEY_INTENT_ANALYZE = "conversation.intent.analyze"; + public static final String ROUTING_KEY_RECOMMEND = "intent.recommend"; + + @Bean + public DirectExchange archiveExchange() { + return new DirectExchange(ARCHIVE_EXCHANGE, true, false); + } + + @Bean + public TopicExchange conversationExchange() { + return new TopicExchange(CONVERSATION_EXCHANGE, true, false); + } + + @Bean + public Queue newMessageQueue() { + return QueueBuilder.durable(QUEUE_NEW_MESSAGE) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", QUEUE_NEW_MESSAGE + ".dlq") + .build(); + } + + @Bean + public Queue intentAnalyzeQueue() { + return QueueBuilder.durable(QUEUE_INTENT_ANALYZE) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", QUEUE_INTENT_ANALYZE + ".dlq") + .build(); + } + + @Bean + public Queue recommendQueue() { + return QueueBuilder.durable(QUEUE_RECOMMEND) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", QUEUE_RECOMMEND + ".dlq") + .build(); + } + + @Bean + public Binding newMessageBinding() { + return BindingBuilder.bind(newMessageQueue()) + .to(archiveExchange()) + .with(ROUTING_KEY_NEW_MESSAGE); + } + + @Bean + public Binding intentAnalyzeBinding() { + return BindingBuilder.bind(intentAnalyzeQueue()) + .to(conversationExchange()) + .with(ROUTING_KEY_INTENT_ANALYZE); + } + + @Bean + public Binding recommendBinding() { + return BindingBuilder.bind(recommendQueue()) + .to(conversationExchange()) + .with(ROUTING_KEY_RECOMMEND); + } +} +``` + +#### 3.1.8 跨域配置 + +```java +package com.artedu.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 跨域配置 + * 允许企微H5侧边栏跨域访问API + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + // 允许企微域名 + config.addAllowedOriginPattern("*"); + // 允许携带凭证(cookies) + config.setAllowCredentials(true); + // 允许所有请求头 + config.addAllowedHeader("*"); + // 允许所有请求方法 + config.addAllowedMethod("*"); + // 暴露响应头 + config.addExposedHeader("Authorization"); + config.addExposedHeader("X-Request-Id"); + // 预检请求缓存时间(1小时) + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} +``` + +#### 3.1.9 通用实体基类 + +```java +package com.artedu.common.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 通用实体基类 + * 所有实体类继承此类,自动填充创建时间和更新时间 + */ +@Data +public class BaseEntity { + + /** + * 主键ID(自增) + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 创建时间(自动填充) + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + /** + * 更新时间(自动填充) + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; +} +``` + +#### 3.1.10 自动填充处理器 + +```java +package com.artedu.common.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus字段自动填充处理器 + * 自动填充created_at和updated_at字段 + */ +@Slf4j +@Component +public class AutoFillHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now()); + this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); + } +} +``` + +### 3.2 认证授权服务(auth-service) + +负责企微OAuth2登录、JWT签发和权限控制。 + +#### 3.2.1 启动类 + +```java +package com.artedu.auth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +/** + * 认证授权服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.auth", "com.artedu.common"}) +@EnableDiscoveryClient +public class AuthServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } +} +``` + +#### 3.2.2 企微OAuth2 Service + +```java +package com.artedu.auth.service; + +import com.artedu.auth.entity.Staff; +import com.artedu.auth.mapper.StaffMapper; +import com.artedu.common.exception.BusinessException; +import com.artedu.common.util.JsonUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 企业微信OAuth2认证服务 + * 处理企微登录流程,获取用户信息 + */ +@Slf4j +@Service +public class WeComOAuthService { + + @Value("${wecom.corp-id}") + private String corpId; + + @Value("${wecom.agent-id}") + private String agentId; + + @Value("${wecom.secret}") + private String secret; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private StaffMapper staffMapper; + + @Autowired + private RedisTemplate 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 response = restTemplate.getForEntity(url, String.class); + Map 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 response = restTemplate.getForEntity(url, String.class); + Map 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 getUserDetail(String userId) { + String accessToken = getAccessToken(); + String url = USER_DETAIL_URL.replace("{accessToken}", accessToken).replace("{userId}", userId); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + Map 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 userDetail = getUserDetail(userId); + + // 3. 查询或创建本地用户 + Staff staff = staffMapper.selectOne( + new LambdaQueryWrapper() + .eq(Staff::getStaffId, userId) + .eq(Staff::getCorpId, corpId) + ); + + if (staff == null) { + // 新用户,创建记录 + staff = new Staff(); + staff.setStaffId(userId); + staff.setCorpId(corpId); + staff.setName((String) userDetail.get("name")); + staff.setAvatar((String) userDetail.get("avatar")); + staff.setDepartment(formatDepartment(userDetail.get("department"))); + staff.setRole("ADVISOR"); + staff.setStatus("ACTIVE"); + staffMapper.insert(staff); + log.info("新用户注册: userId={}, name={}", userId, staff.getName()); + } else { + // 更新用户信息 + staff.setName((String) userDetail.get("name")); + staff.setAvatar((String) userDetail.get("avatar")); + staffMapper.updateById(staff); + } + + return staff; + } + + /** + * 格式化部门信息 + */ + private String formatDepartment(Object department) { + if (department == null) { + return ""; + } + return JsonUtils.toJson(department); + } +} +``` + +#### 3.2.3 JWT Service + +```java +package com.artedu.auth.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT Token服务 + * 负责生成和验证JWT Token + */ +@Slf4j +@Service +public class JwtService { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration:86400}") + private Long expiration; // 默认24小时 + + /** + * 生成SecretKey + */ + private SecretKey getSecretKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 生成JWT Token + * + * @param userId 用户ID + * @param corpId 企业ID + * @param userName 用户姓名 + * @param role 角色 + * @return JWT Token字符串 + */ + public String generateToken(String userId, String corpId, String userName, String role) { + Map claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("corpId", corpId); + claims.put("userName", userName); + claims.put("role", role); + claims.put("type", "access"); + + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration * 1000); + + return Jwts.builder() + .setClaims(claims) + .setSubject(userId) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSecretKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 验证并解析Token + */ + public Claims validateToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSecretKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * 从Token中获取用户ID + */ + public String getUserIdFromToken(String token) { + Claims claims = validateToken(token); + return claims.get("userId", String.class); + } + + /** + * 检查Token是否过期 + */ + public boolean isTokenExpired(String token) { + try { + Claims claims = validateToken(token); + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + return true; + } + } + + /** + * 获取Token剩余有效时间(秒) + */ + public long getExpirationSeconds(String token) { + try { + Claims claims = validateToken(token); + long remain = claims.getExpiration().getTime() - System.currentTimeMillis(); + return Math.max(remain / 1000, 0); + } catch (Exception e) { + return 0; + } + } +} +``` + +#### 3.2.4 Auth Controller + +```java +package com.artedu.auth.controller; + +import com.artedu.auth.entity.Staff; +import com.artedu.auth.service.JwtService; +import com.artedu.auth.service.WeComOAuthService; +import com.artedu.common.result.Result; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 认证授权控制器 + * 处理企微OAuth登录和Token刷新 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + @Autowired + private WeComOAuthService weComOAuthService; + + @Autowired + private JwtService jwtService; + + /** + * 企微OAuth登录 + * + * @param code 企微OAuth授权码 + * @return JWT Token和用户信息 + */ + @PostMapping("/login") + public Result> 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 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> 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 result = new HashMap<>(); + result.put("token", newToken); + result.put("tokenType", "Bearer"); + result.put("expiresIn", 86400); + + return Result.success(result); + } + + /** + * 获取当前登录用户信息 + */ + @GetMapping("/info") + public Result> getUserInfo(@RequestHeader("Authorization") String authHeader) { + String token = authHeader.replace("Bearer ", ""); + io.jsonwebtoken.Claims claims = jwtService.validateToken(token); + + Map 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> 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 result = new HashMap<>(); + result.put("corpId", corpId); + result.put("agentId", "1000002"); // 替换为实际agentId + result.put("nonceStr", nonceStr); + result.put("timestamp", timestamp); + result.put("signature", signature); + + return Result.success(result); + } + + /** + * 生成JS-SDK签名 + */ + private String generateSignature(String ticket, String nonceStr, String timestamp, String url) { + String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url; + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1"); + digest.update(string1.getBytes()); + byte[] messageDigest = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : messageDigest) { + String shaHex = Integer.toHexString(b & 0xFF); + if (shaHex.length() < 2) { + hexString.append(0); + } + hexString.append(shaHex); + } + return hexString.toString(); + } catch (Exception e) { + log.error("生成签名失败", e); + return ""; + } + } +} +``` + +#### 3.2.5 Staff实体类 + +```java +package com.artedu.auth.entity; + +import com.artedu.common.entity.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 员工实体 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("staffs") +public class Staff extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** + * 企微用户ID + */ + private String staffId; + + /** + * 企业ID + */ + private String corpId; + + /** + * 姓名 + */ + private String name; + + /** + * 头像URL + */ + private String avatar; + + /** + * 部门 + */ + private String department; + + /** + * 角色 + */ + private String role; + + /** + * 状态 + */ + private String status; + + /** + * 推送设置(JSON字符串) + */ + private String pushSettings; + + /** + * 最后登录时间 + */ + private java.time.LocalDateTime lastLoginTime; +} +``` + +#### 3.2.6 Staff Mapper + +```java +package com.artedu.auth.mapper; + +import com.artedu.auth.entity.Staff; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * 员工数据访问层 + */ +@Mapper +public interface StaffMapper extends BaseMapper { +} +``` + +#### 3.2.7 application.yml + +```yaml +server: + port: 8081 + +spring: + application: + name: auth-service + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false + username: ${MYSQL_USERNAME:root} + password: ${MYSQL_PASSWORD:root} + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + cloud: + nacos: + discovery: + server-addr: ${NACOS_HOST:localhost}:${NACOS_PORT:8848} + +# 企微配置 +wecom: + corp-id: ${WECOM_CORP_ID:} + agent-id: ${WECOM_AGENT_ID:} + secret: ${WECOM_SECRET:} + +# JWT配置 +jwt: + secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters} + expiration: 86400 + +# MyBatis-Plus +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +``` + +### 3.3 会话存档服务(archive-service) + +负责接收企微回调、拉取存档消息、RSA解密和存储。 + +#### 3.3.1 启动类 + +```java +package com.artedu.archive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +/** + * 会话存档服务启动类 + */ +@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"}) +@EnableDiscoveryClient +public class ArchiveServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ArchiveServiceApplication.class, args); + } +} +``` + +#### 3.3.2 JNA接口定义(调用企微C SDK) + +```java +package com.artedu.archive.sdk; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; + +import java.util.Arrays; +import java.util.List; + +/** + * 企微会话存档C SDK的JNA接口定义 + * 通过JNA调用本地动态库(.so/.dll) + */ +public interface WeWorkFinanceSdk extends Library { + + // 加载本地动态库 + WeWorkFinanceSdk INSTANCE = Native.load( + System.getProperty("os.name").toLowerCase().contains("win") ? "WeWorkFinanceSdk" : "WeWorkFinanceSdk", + WeWorkFinanceSdk.class + ); + + /** + * 初始化SDK + * + * @return SDK指针 + */ + long NewSdk(); + + /** + * 初始化SDK配置 + * + * @param sdk SDK指针 + * @param corpId 企业ID + * @param secret 存档Secret + * @return 0表示成功 + */ + int Init(long sdk, String corpId, String secret); + + /** + * 拉取聊天记录 + * + * @param sdk SDK指针 + * @param seq 起始序列号 + * @param limit 拉取条数(最大1000) + * @param proxy 代理地址 + * @param passwd 代理密码 + * @param timeout 超时时间(秒) + * @param chatData 返回数据切片 + * @return 0表示成功 + */ + int GetChatData(long sdk, long seq, int limit, String proxy, String passwd, int timeout, long chatData); + + /** + * 解密数据 + * + * @param sdk SDK指针 + * @param encryptKey 解密密钥 + * @param encryptMsg 加密消息 + * @param msg 返回解密后的消息切片 + * @return 0表示成功 + */ + int DecryptData(long sdk, String encryptKey, String encryptMsg, long msg); + + /** + * 释放SDK资源 + */ + int DestroySdk(long sdk); + + /** + * 释放切片 + */ + void FreeSlice(long slice); + + /** + * 数据切片结构 + */ + class Slice_t extends Structure { + public Pointer content; + public int len; + + @Override + protected List getFieldOrder() { + return Arrays.asList("content", "len"); + } + } +} +``` + +#### 3.3.3 RSA解密工具类 + +```java +package com.artedu.archive.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +/** + * RSA解密工具类 + * 用于解密企微会话存档的encrypt_random_key + */ +@Slf4j +@Component +public class RsaDecryptUtil { + + private static final String ALGORITHM = "RSA"; + private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + + /** + * 从Base64编码的私钥字符串加载私钥 + */ + public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); + return keyFactory.generatePrivate(spec); + } + + /** + * RSA私钥解密 + * + * @param encryptedData Base64编码的加密数据 + * @param base64PrivateKey Base64编码的私钥 + * @return 解密后的字符串 + */ + public String decrypt(String encryptedData, String base64PrivateKey) { + try { + PrivateKey privateKey = loadPrivateKey(base64PrivateKey); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("RSA解密失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 生成RSA密钥对(用于测试或初始化) + */ + public static void generateKeyPair() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + + log.info("=== RSA公钥(上传到企微后台) ==="); + log.info(publicKey); + log.info("=== RSA私钥(安全保存) ==="); + log.info(privateKey); + } +} +``` + +#### 3.3.4 存档消息实体类 + +```java +package com.artedu.archive.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 存档消息实体 + * 对应archive_messages表 + */ +@Data +@TableName("archive_messages") +public class ArchiveMessage { + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 企微消息唯一ID + */ + private String msgid; + + /** + * 存档序列号 + */ + private Long seq; + + /** + * 企业ID + */ + private String corpId; + + /** + * 动作类型 + */ + private String action; + + /** + * 发送者 + */ + private String fromUser; + + /** + * 发送者角色 + */ + private String fromRole; + + /** + * 接收者 + */ + private String toUser; + + /** + * 接收者列表(JSON字符串) + */ + private String tolist; + + /** + * 群聊ID + */ + private String roomid; + + /** + * 消息类型 + */ + private String msgtype; + + /** + * 消息时间(毫秒时间戳) + */ + private Long msgtime; + + /** + * 消息内容 + */ + private String content; + + /** + * 媒体数据(JSON字符串) + */ + private String mediaData; + + /** + * 会话ID + */ + private String sessionId; + + /** + * 解密状态 + */ + private Integer decryptStatus; + + /** + * 解密错误信息 + */ + private String decryptError; + + /** + * 创建时间 + */ + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + private LocalDateTime updatedAt; +} +``` + +#### 3.3.5 存档消息Mapper + +```java +package com.artedu.archive.mapper; + +import com.artedu.archive.entity.ArchiveMessage; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 存档消息数据访问层 + */ +@Mapper +public interface ArchiveMessageMapper extends BaseMapper { + + /** + * 查询最大序列号 + */ + @Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}") + Long selectMaxSeq(@Param("corpId") String corpId); + + /** + * 根据msgid查询是否存在 + */ + @Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}") + int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId); +} +``` + +#### 3.3.6 存档拉取服务 + +```java +package com.artedu.archive.service; + +import com.artedu.archive.entity.ArchiveMessage; +import com.artedu.archive.mapper.ArchiveMessageMapper; +import com.artedu.archive.sdk.WeWorkFinanceSdk; +import com.artedu.archive.util.RsaDecryptUtil; +import com.artedu.common.config.RabbitConfig; +import com.artedu.common.util.JsonUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 存档拉取服务 + * 通过C SDK拉取企微会话存档消息 + */ +@Slf4j +@Service +public class ArchivePullService { + + @Value("${wecom.archive.corp-id}") + private String corpId; + + @Value("${wecom.archive.secret}") + private String secret; + + @Value("${wecom.archive.rsa-private-key:}") + private String rsaPrivateKey; + + @Autowired + private ArchiveMessageMapper archiveMessageMapper; + + @Autowired + private RabbitTemplate rabbitTemplate; + + @Autowired + private RedisTemplate 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 pullMessages(long seq, int limit) { + List 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 result = JsonUtils.fromJsonMap(jsonData); + if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) { + log.error("拉取存档返回错误: {}", jsonData); + return messages; + } + + // 解析聊天数据列表 + List> chatDataList = (List>) result.get("chatdata"); + if (chatDataList == null || chatDataList.isEmpty()) { + return messages; + } + + for (Map 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 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 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 messages) { + for (ArchiveMessage message : messages) { + try { + // 去重检查 + int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId()); + if (count > 0) { + log.debug("消息已存在,跳过: msgid={}", message.getMsgid()); + continue; + } + + // 保存消息 + archiveMessageMapper.insert(message); + + // 发送MQ事件,触发后续处理 + rabbitTemplate.convertAndSend( + RabbitConfig.ARCHIVE_EXCHANGE, + RabbitConfig.ROUTING_KEY_NEW_MESSAGE, + JsonUtils.toJson(message) + ); + + // 更新最后拉取的seq + redisTemplate.opsForValue().set( + LAST_SEQ_KEY + corpId, + message.getSeq(), + 7, TimeUnit.DAYS + ); + + } catch (Exception e) { + log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage()); + } + } + } + + /** + * 获取上次拉取的序列号 + */ + public long getLastSeq() { + Object seq = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId); + if (seq != null) { + return ((Number) seq).longValue(); + } + // 从数据库查询 + Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId); + return maxSeq != null ? maxSeq : 0L; + } +} +``` + +#### 3.3.7 回调接收Controller + +```java +package com.artedu.archive.controller; + +import com.artedu.archive.service.ArchivePullService; +import com.artedu.common.result.Result; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.CompletableFuture; + +/** + * 企微回调控制器 + * 接收企微的msgaudit_notify回调 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/archive") +public class ArchiveCallbackController { + + @Autowired + private ArchivePullService archivePullService; + + /** + * 企微回调接口 + * 企微每隔约15秒推送一次msgaudit_notify事件 + */ + @PostMapping("/callback") + public Result 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 manualPull( + @RequestParam(value = "seq", required = false) Long seq, + @RequestParam(value = "limit", defaultValue = "1000") Integer limit) { + + long startSeq = seq != null ? seq : archivePullService.getLastSeq(); + log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit); + + var messages = archivePullService.pullMessages(startSeq, limit); + archivePullService.saveAndNotify(messages); + + return Result.success("拉取完成,共" + messages.size() + "条消息"); + } +} +``` + +### 3.4 对话服务(conversation-service) + +负责对话上下文管理、轮次解析和状态追踪。 + +#### 3.4.1 对话管理服务 + +```java +package com.artedu.conversation.service; + +import com.artedu.common.exception.BusinessException; +import com.artedu.common.util.JsonUtils; +import com.artedu.conversation.entity.Conversation; +import com.artedu.conversation.entity.ConversationTurn; +import com.artedu.conversation.mapper.ConversationMapper; +import com.artedu.conversation.mapper.ConversationTurnMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 对话管理服务 + * 管理会话生命周期、上下文窗口、轮次解析 + */ +@Slf4j +@Service +public class ConversationManager { + + @Autowired + private ConversationMapper conversationMapper; + + @Autowired + private ConversationTurnMapper conversationTurnMapper; + + @Autowired + private RedisTemplate 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() + .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() + .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 getContextMessages(String sessionId) { + String key = CONV_CONTEXT_KEY + sessionId; + List 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 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() + .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() + .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() + .eq(ConversationTurn::getSessionId, sessionId) + .eq(ConversationTurn::getTurnNumber, roundNumber) + ); + if (turn != null) { + turn.setSeatMsgId(seatMsgId); + turn.setSeatContent(content); + conversationTurnMapper.updateById(turn); + } + } + + private String truncateContent(String content, int maxLength) { + if (content == null) { + return ""; + } + return content.length() > maxLength ? content.substring(0, maxLength) + "..." : content; + } + + // ========== 内部消息类 ========== + + /** + * 存档消息事件 + */ + public static class ArchiveMessageEvent { + private String msgid; + private String corpId; + private String sessionId; + private String fromUser; + private String fromRole; + private String toUser; + private String msgtype; + private String content; + private Long msgtime; + // getters and setters + public String getMsgid() { return msgid; } + public void setMsgid(String msgid) { this.msgid = msgid; } + public String getCorpId() { return corpId; } + public void setCorpId(String corpId) { this.corpId = corpId; } + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + public String getFromUser() { return fromUser; } + public void setFromUser(String fromUser) { this.fromUser = fromUser; } + public String getFromRole() { return fromRole; } + public void setFromRole(String fromRole) { this.fromRole = fromRole; } + public String getToUser() { return toUser; } + public void setToUser(String toUser) { this.toUser = toUser; } + public String getMsgtype() { return msgtype; } + public void setMsgtype(String msgtype) { this.msgtype = msgtype; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public Long getMsgtime() { return msgtime; } + public void setMsgtime(Long msgtime) { this.msgtime = msgtime; } + } + + /** + * 上下文消息 + */ + public static class ContextMessage { + private String role; + private String content; + private Long msgTime; + + public ContextMessage() {} + + public ContextMessage(String role, String content, Long msgTime) { + this.role = role; + this.content = content; + this.msgTime = msgTime; + } + + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public Long getMsgTime() { return msgTime; } + public void setMsgTime(Long msgTime) { this.msgTime = msgTime; } + } +} +``` + +#### 3.4.2 对话Controller + +```java +package com.artedu.conversation.controller; + +import com.artedu.common.result.Result; +import com.artedu.conversation.entity.Conversation; +import com.artedu.conversation.service.ConversationManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 对话管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/conversations") +public class ConversationController { + + @Autowired + private ConversationManager conversationManager; + + /** + * 获取对话上下文 + */ + @GetMapping("/{sessionId}/context") + public Result> getContext(@PathVariable("sessionId") String sessionId) { + String contextStr = conversationManager.getContextString(sessionId); + var messages = conversationManager.getContextMessages(sessionId); + + Map 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 getConversation(@PathVariable("sessionId") String sessionId) { + // TODO: 实现查询 + return Result.success(); + } + + /** + * 更新会话状态 + */ + @PutMapping("/{sessionId}/status") + public Result updateStatus( + @PathVariable("sessionId") String sessionId, + @RequestParam("stage") String stage, + @RequestParam(value = "confidence", required = false) Double confidence, + @RequestParam(value = "summary", required = false) String summary) { + + conversationManager.updateConversationStatus(sessionId, stage, confidence, summary); + return Result.success("状态更新成功"); + } +} +``` + +### 3.5 意图服务(intent-service) + +负责意图识别(40场景分类)、学员画像构建和槽位填充。 + +#### 3.5.1 意图识别服务 + +```java +package com.artedu.intent.service; + +import com.artedu.common.config.RabbitConfig; +import com.artedu.common.util.JsonUtils; +import com.artedu.intent.entity.CustomerProfile; +import com.artedu.intent.entity.IntentCategory; +import com.artedu.intent.entity.IntentRecognition; +import com.artedu.intent.mapper.CustomerProfileMapper; +import com.artedu.intent.mapper.IntentCategoryMapper; +import com.artedu.intent.mapper.IntentRecognitionMapper; +import com.artedu.intent.prompt.IntentPromptTemplate; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 意图识别服务 + * 基于规则+LLM混合策略进行40场景意图分类 + */ +@Slf4j +@Service +public class IntentRecognitionService { + + @Autowired + private IntentCategoryMapper intentCategoryMapper; + + @Autowired + private IntentRecognitionMapper intentRecognitionMapper; + + @Autowired + private CustomerProfileMapper customerProfileMapper; + + @Autowired + private LLMIntentClient llmIntentClient; + + @Autowired + private RedisTemplate 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 categories = intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + ); + + IntentCategory bestMatch = null; + double bestScore = 0; + + for (IntentCategory category : categories) { + // 解析触发关键词 + List 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() + .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 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 getAllCategories() { + return intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + ); + } + + // ========== 内部类 ========== + + /** + * 意图识别结果 + */ + public static class IntentResult { + private IntentInfo primaryIntent; + private List 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 getSecondaryIntents() { return secondaryIntents; } + public void setSecondaryIntents(List secondaryIntents) { this.secondaryIntents = secondaryIntents; } + public Double getConfidence() { return confidence; } + public void setConfidence(Double confidence) { this.confidence = confidence; } + public String getSentiment() { return sentiment; } + public void setSentiment(String sentiment) { this.sentiment = sentiment; } + public String getExplanation() { return explanation; } + public void setExplanation(String explanation) { this.explanation = explanation; } + + public static class IntentInfo { + private String code; + private String name; + private String domain; + private Double confidence; + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDomain() { return domain; } + public void setDomain(String domain) { this.domain = domain; } + public Double getConfidence() { return confidence; } + public void setConfidence(Double confidence) { this.confidence = confidence; } + } + } +} +``` + +#### 3.5.2 LLM意图识别客户端 + +```java +package com.artedu.intent.service; + +import com.artedu.common.util.JsonUtils; +import com.artedu.intent.entity.IntentCategory; +import com.artedu.intent.prompt.IntentPromptTemplate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * LLM意图识别客户端 + * 调用通义千问API进行意图识别 + */ +@Slf4j +@Service +public class LLMIntentClient { + + @Value("${llm.qianwen.api-key}") + private String apiKey; + + @Value("${llm.qianwen.model:qwen-turbo}") + private String model; + + @Autowired + private RestTemplate restTemplate; + + private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; + + /** + * 调用LLM进行意图识别 + */ + public IntentRecognitionService.IntentResult recognize( + String message, String contextSummary, List 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 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> request = new HttpEntity<>(requestBody, headers); + + ResponseEntity 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 responseMap = JsonUtils.fromJsonMap(response); + if (responseMap == null) { + return createDefaultResult(); + } + + // 提取output + Map output = (Map) responseMap.get("output"); + if (output == null) { + return createDefaultResult(); + } + + // 提取JSON结果 + String jsonText = (String) output.get("text"); + if (jsonText == null) { + return createDefaultResult(); + } + + // 解析意图结果 + Map intentData = JsonUtils.fromJsonMap(jsonText); + if (intentData == null) { + return createDefaultResult(); + } + + // 主意图 + Map primaryIntentMap = (Map) 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> secondaryList = (List>) intentData.get("secondary_intents"); + if (secondaryList != null) { + List secondaryIntents = new ArrayList<>(); + for (Map sec : secondaryList) { + IntentRecognitionService.IntentResult.IntentInfo info = new IntentRecognitionService.IntentResult.IntentInfo(); + info.setCode((String) sec.get("code")); + info.setName((String) sec.get("name")); + info.setConfidence(((Number) sec.get("confidence")).doubleValue()); + secondaryIntents.add(info); + } + result.setSecondaryIntents(secondaryIntents); + } + + // 情感 + result.setSentiment((String) intentData.getOrDefault("sentiment", "NEUTRAL")); + result.setExplanation((String) intentData.get("explanation")); + + return result; + + } catch (Exception e) { + log.error("解析LLM响应失败: {}", e.getMessage(), e); + return createDefaultResult(); + } + } + + /** + * 创建默认结果 + */ + private IntentRecognitionService.IntentResult createDefaultResult() { + IntentRecognitionService.IntentResult result = new IntentRecognitionService.IntentResult(); + IntentRecognitionService.IntentResult.IntentInfo intent = new IntentRecognitionService.IntentResult.IntentInfo(); + intent.setCode("INT-COURSE-01"); + intent.setName("课程内容咨询"); + intent.setConfidence(0.5); + result.setPrimaryIntent(intent); + result.setConfidence(0.5); + result.setSentiment("NEUTRAL"); + result.setSecondaryIntents(new ArrayList<>()); + return result; + } +} +``` + +#### 3.5.3 意图识别Prompt模板 + +```java +package com.artedu.intent.prompt; + +import com.artedu.intent.entity.IntentCategory; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 意图识别Prompt模板 + * 构建完整的LLM Prompt,包含角色定义、意图分类、示例等 + */ +public class IntentPromptTemplate { + + /** + * 构建意图识别Prompt + */ + public static String buildIntentPrompt(String message, String contextSummary, + List categories) { + StringBuilder prompt = new StringBuilder(); + + // 1. 角色定义 + prompt.append("# 角色定义\n"); + prompt.append("你是一位CG数字艺术教育行业的资深课程顾问助手,服务于第九联盟(9ART EDU)。\n"); + prompt.append("你的任务是分析学员的最新消息,识别其咨询意图。\n\n"); + + // 2. 意图分类定义 + prompt.append("# 意图分类体系(40个场景)\n\n"); + + // 按领域分组 + String currentDomain = ""; + for (IntentCategory category : categories) { + if (!category.getDomain().equals(currentDomain)) { + currentDomain = category.getDomain(); + prompt.append("## ").append(currentDomain).append("\n"); + } + prompt.append("- ").append(category.getCode()) + .append(" ").append(category.getName()) + .append(": ").append(category.getDescription()).append("\n"); + } + + // 3. 学员画像体系 + prompt.append("\n# 学员画像体系\n"); + prompt.append("- 学员类型:在校大学生/转行人员/在职提升/高中毕业生/家长代询\n"); + prompt.append("- 基础水平:零基础/有美术基础/相关专业/有从业经验\n"); + prompt.append("- 意向度:高(>=60分)/中(30-59)/低(0-29)/无意向(<0)\n"); + prompt.append("- 关注重点:价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型\n"); + prompt.append("- 决策阶段:信息了解/方案比较/购买决策/报名成交\n"); + + // 4. 情感分类 + prompt.append("\n# 情感分类\n"); + prompt.append("- POSITIVE:积极、感兴趣、认可\n"); + prompt.append("- NEUTRAL:中性、询问信息\n"); + prompt.append("- NEGATIVE:消极、不满、拒绝\n"); + + // 5. 输出格式说明 + prompt.append("\n# 输出格式(严格JSON)\n"); + prompt.append("请严格按以下JSON格式输出,不要添加任何其他内容:\n"); + prompt.append("{\n"); + prompt.append(" \"primary_intent\": {\"code\": \"意图编码\", \"name\": \"意图名称\", \"confidence\": 0.95},\n"); + prompt.append(" \"secondary_intents\": [\n"); + prompt.append(" {\"code\": \"辅意图编码\", \"name\": \"辅意图名称\", \"confidence\": 0.75}\n"); + prompt.append(" ],\n"); + prompt.append(" \"sentiment\": \"POSITIVE/NEUTRAL/NEGATIVE\",\n"); + prompt.append(" \"explanation\": \"意图判断的简要说明\"\n"); + prompt.append("}\n\n"); + + // 6. Few-shot示例 + prompt.append("# 示例\n\n"); + prompt.append("## 示例1\n"); + prompt.append("学员消息:\"我没有美术基础,之前做销售的,想转行学3D建模。你们的课零基础能学吗?\"\n"); + prompt.append("输出:\n"); + prompt.append("{\n"); + prompt.append(" \"primary_intent\": {\"code\": \"INT-COURSE-07\", \"name\": \"课程难度询问\", \"confidence\": 0.92},\n"); + prompt.append(" \"secondary_intents\": [\n"); + prompt.append(" {\"code\": \"INT-COURSE-02\", \"name\": \"课程选择建议\", \"confidence\": 0.70}\n"); + prompt.append(" ],\n"); + prompt.append(" \"sentiment\": \"NEUTRAL\",\n"); + prompt.append(" \"explanation\": \"学员核心关注零基础能否学会(主意图),同时隐含了课程选择需求\"\n"); + prompt.append("}\n\n"); + + prompt.append("## 示例2\n"); + prompt.append("学员消息:\"学费多少钱?\"\n"); + prompt.append("输出:\n"); + prompt.append("{\n"); + prompt.append(" \"primary_intent\": {\"code\": \"INT-PRICE-01\", \"name\": \"学费价格询问\", \"confidence\": 0.98},\n"); + prompt.append(" \"secondary_intents\": [],\n"); + prompt.append(" \"sentiment\": \"NEUTRAL\",\n"); + prompt.append(" \"explanation\": \"直接询问学费价格,意图明确\"\n"); + prompt.append("}\n\n"); + + // 7. 实际输入 + prompt.append("# 实际输入\n\n"); + if (contextSummary != null && !contextSummary.isEmpty()) { + prompt.append("对话上下文摘要:\"").append(contextSummary).append("\"\n"); + } + prompt.append("学员最新消息:\"").append(message).append("\"\n\n"); + prompt.append("请输出JSON:"); + + return prompt.toString(); + } +} +``` + +#### 3.5.4 意图Controller + +```java +package com.artedu.intent.controller; + +import com.artedu.common.result.Result; +import com.artedu.intent.entity.CustomerProfile; +import com.artedu.intent.entity.IntentCategory; +import com.artedu.intent.mapper.CustomerProfileMapper; +import com.artedu.intent.mapper.IntentCategoryMapper; +import com.artedu.intent.service.IntentRecognitionService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 意图识别控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/intent") +public class IntentController { + + @Autowired + private IntentRecognitionService intentRecognitionService; + + @Autowired + private IntentCategoryMapper intentCategoryMapper; + + @Autowired + private CustomerProfileMapper customerProfileMapper; + + /** + * 意图识别接口 + */ + @PostMapping("/recognize") + public Result 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> getCategories() { + List categories = intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + .orderByAsc(IntentCategory::getDomain, IntentCategory::getCode) + ); + return Result.success(categories); + } + + /** + * 获取学员画像 + */ + @GetMapping("/profile/{customerId}") + public Result getProfile( + @PathVariable("customerId") String customerId, + @RequestParam("corpId") String corpId) { + CustomerProfile profile = customerProfileMapper.selectOne( + new LambdaQueryWrapper() + .eq(CustomerProfile::getCustomerId, customerId) + .eq(CustomerProfile::getCorpId, corpId) + ); + return Result.success(profile); + } + + // 请求DTO + public static class IntentRecognizeRequest { + private String message; + private String contextSummary; + private String customerId; + private String corpId; + private String staffId; + + // getters and setters + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public String getContextSummary() { return contextSummary; } + public void setContextSummary(String contextSummary) { this.contextSummary = contextSummary; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String customerId) { this.customerId = customerId; } + public String getCorpId() { return corpId; } + public void setCorpId(String corpId) { this.corpId = corpId; } + public String getStaffId() { return staffId; } + public void setStaffId(String staffId) { this.staffId = staffId; } + } +} +``` + +### 3.6 推荐服务(recommendation-service) + +负责话术召回、多因子排序、LLM兜底生成。 + +#### 3.6.1 推荐服务 + +```java +package com.artedu.recommend.service; + +import com.artedu.common.result.Result; +import com.artedu.common.util.JsonUtils; +import com.artedu.recommend.entity.Utterance; +import com.artedu.recommend.mapper.UtteranceMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 话术推荐服务 + * 三层召回(规则+向量+热门)+ 多因子排序 + */ +@Slf4j +@Service +public class RecommendationService { + + @Autowired + private UtteranceMapper utteranceMapper; + + @Autowired + private VectorRecallService vectorRecallService; + + @Autowired + private LLMGenerateService llmGenerateService; + + @Autowired + private RedisTemplate 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 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 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 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 recall(String message, String intentCode, + String studentType, String currentStage, String corpId) { + List candidates = new ArrayList<>(); + + // 第一层:规则召回(意图码+阶段+学员类型) + List ruleResults = ruleRecall(intentCode, currentStage, studentType, corpId); + candidates.addAll(ruleResults); + log.debug("规则召回: {}条", ruleResults.size()); + + // 第二层:向量召回(语义相似度) + List vectorResults = vectorRecallService.recall(message, 15); + candidates.addAll(vectorResults); + log.debug("向量召回: {}条", vectorResults.size()); + + // 第三层:热门召回(近7天高成功率话术) + List hotResults = hotRecall(corpId, 8); + candidates.addAll(hotResults); + log.debug("热门召回: {}条", hotResults.size()); + + return candidates; + } + + /** + * 规则召回 + */ + private List ruleRecall(String intentCode, String currentStage, + String studentType, String corpId) { + // MySQL 5.7适配:使用LIKE替代JSON_CONTAINS + LambdaQueryWrapper 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 hotRecall(String corpId, int limit) { + LambdaQueryWrapper 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 rank(List candidates, String intentCode, + String studentType, String currentStage) { + List 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 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 primaryIntent = (Map) 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 recommendations; + private String generationMode; + private int elapsedMs; + private Map recallSources; + + public List getRecommendations() { return recommendations; } + public void setRecommendations(List 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 getRecallSources() { return recallSources; } + public void setRecallSources(Map recallSources) { this.recallSources = recallSources; } + } + + /** + * 推荐项 + */ + public static class RecommendItem { + private String utteranceId; + private String title; + private String content; + private double score; + private double successRate; + private int usedCount; + private String source; + + public String getUtteranceId() { return utteranceId; } + public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public double getScore() { return score; } + public void setScore(double score) { this.score = score; } + public double getSuccessRate() { return successRate; } + public void setSuccessRate(double successRate) { this.successRate = successRate; } + public int getUsedCount() { return usedCount; } + public void setUsedCount(int usedCount) { this.usedCount = usedCount; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + } +} +``` + +#### 3.6.2 向量召回服务 + +```java +package com.artedu.recommend.service; + +import com.artedu.recommend.entity.Utterance; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 向量召回服务 + * 使用Redis Hash存储和检索Embedding向量 + * MySQL 5.7不支持向量类型,向量存储在Redis中 + */ +@Slf4j +@Service +public class VectorRecallService { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private EmbeddingService embeddingService; + + private static final String VECTOR_KEY_PREFIX = "vector:utterance:"; + + /** + * 向量召回 + * 计算消息向量与话术库向量的余弦相似度,返回TopK + */ + public List recall(String message, int topK) { + List results = new ArrayList<>(); + + try { + // 1. 计算消息向量 + float[] messageVector = embeddingService.embed(message); + + // 2. 获取所有话术向量(从Redis) + // 注意:实际生产环境应使用向量数据库(如Milvus) + // 这里简化处理,从Redis获取 + List utteranceIds = getAllVectorIds(); + + // 3. 计算相似度 + List 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存储 + List 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 vectorList = (List) 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 getAllVectorIds() { + // 简化实现:从Redis keys获取 + // 实际生产环境应使用更好的索引机制 + var keys = redisTemplate.keys(VECTOR_KEY_PREFIX + "*"); + if (keys == null) { + return List.of(); + } + return keys.stream() + .map(k -> k.replace(VECTOR_KEY_PREFIX, "")) + .toList(); + } + + /** + * 计算余弦相似度 + */ + private double cosineSimilarity(float[] a, float[] b) { + if (a.length != b.length) { + return 0; + } + double dotProduct = 0; + double normA = 0; + double normB = 0; + for (int i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + if (normA == 0 || normB == 0) { + return 0; + } + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + private static class ScoredUtterance { + Utterance utterance; + double score; + + ScoredUtterance(Utterance utterance, double score) { + this.utterance = utterance; + this.score = score; + } + } +} +``` + +#### 3.6.3 LLM兜底生成服务 + +```java +package com.artedu.recommend.service; + +import com.artedu.common.util.JsonUtils; +import com.artedu.recommend.entity.Utterance; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; +import java.util.UUID; + +/** + * LLM兜底话术生成服务 + * 当话术库匹配不足时,调用通义千问动态生成话术 + */ +@Slf4j +@Service +public class LLMGenerateService { + + @Value("${llm.qianwen.api-key}") + private String apiKey; + + @Value("${llm.qianwen.model:qwen-turbo}") + private String model; + + @Autowired + private RestTemplate restTemplate; + + private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; + + /** + * 生成兜底话术 + */ + public Utterance generate(String message, String intentCode, + String studentType, String currentStage) { + try { + // 构建生成Prompt + String prompt = buildGeneratePrompt(message, intentCode, studentType, currentStage); + + // 调用LLM + String response = callLLM(prompt); + if (response == null) { + return null; + } + + // 解析响应 + String content = extractContent(response); + if (content == null || content.isEmpty()) { + return null; + } + + // 构建话术对象 + Utterance utterance = new Utterance(); + utterance.setUtteranceId("LLM_" + UUID.randomUUID().toString().substring(0, 8)); + utterance.setTitle("AI生成-" + intentCode); + utterance.setContent(content); + utterance.setContentText(content); + utterance.setStatus("ACTIVE"); + utterance.setSource("LLM_GENERATED"); + utterance.setSuccessRate(0.5); // AI生成的话术默认中等成功率 + utterance.setUsedCount(0); + + log.info("LLM生成话术成功: utteranceId={}, length={}", + utterance.getUtteranceId(), content.length()); + + return utterance; + + } catch (Exception e) { + log.error("LLM生成话术失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 构建生成Prompt + */ + private String buildGeneratePrompt(String message, String intentCode, + String studentType, String currentStage) { + StringBuilder prompt = new StringBuilder(); + + prompt.append("# 角色\n"); + prompt.append("你是第九联盟(9ART EDU)的资深课程顾问,擅长根据学员的问题生成专业、热情、有说服力的话术回复。\n\n"); + + prompt.append("# 业务背景\n"); + prompt.append("第九联盟是CG数字艺术教育培训机构,母公司点晴科技有10年游戏制作经验。\n"); + prompt.append("课程包括:3D场景UE地编、次世代角色模型、2D原画、游戏动画、游戏特效、UE开发、3D大师研修班。\n"); + prompt.append("校区:上海、西安、厦门、武汉、青岛、合肥。\n"); + prompt.append("就业合作企业:腾讯、网易、米哈游等500+企业。\n"); + prompt.append("教学模式:5个月培训+2个月企业实训。\n"); + prompt.append("师资:来自一线游戏大厂,平均8年+项目经验。\n\n"); + + prompt.append("# 生成要求\n"); + prompt.append("1. 针对学员的具体问题生成回复话术\n"); + prompt.append("2. 语气专业、热情、有亲和力\n"); + prompt.append("3. 包含具体的业务数据(价格、时间等)\n"); + prompt.append("4. 适当使用emoji和分段,增强可读性\n"); + prompt.append("5. 引导学员下一步行动(预约试听、了解大纲等)\n"); + prompt.append("6. 不要编造不存在的数据,不确定的标注[需人工确认]\n\n"); + + prompt.append("# 当前场景\n"); + prompt.append("- 学员问题:\"").append(message).append("\"\n"); + if (intentCode != null) { + prompt.append("- 意图编码:").append(intentCode).append("\n"); + } + if (studentType != null) { + prompt.append("- 学员类型:").append(studentType).append("\n"); + } + if (currentStage != null) { + prompt.append("- 对话阶段:").append(currentStage).append("\n"); + } + + prompt.append("\n请生成回复话术:"); + + return prompt.toString(); + } + + /** + * 调用LLM + */ + private String callLLM(String prompt) { + try { + Map 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> request = new HttpEntity<>(requestBody, headers); + + ResponseEntity 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 responseMap = JsonUtils.fromJsonMap(response); + if (responseMap == null) return null; + + Map output = (Map) responseMap.get("output"); + if (output == null) return null; + + return (String) output.get("text"); + } catch (Exception e) { + log.error("提取LLM内容失败: {}", e.getMessage()); + return null; + } + } +} +``` + +#### 3.6.4 Embedding服务 + +```java +package com.artedu.recommend.service; + +import com.artedu.common.util.JsonUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +/** + * Embedding服务 + * 调用通义千问文本Embedding API,将文本转为向量 + */ +@Slf4j +@Service +public class EmbeddingService { + + @Value("${llm.qianwen.api-key}") + private String apiKey; + + @Autowired + private RestTemplate restTemplate; + + private static final String EMBEDDING_API_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"; + private static final String EMBEDDING_MODEL = "text-embedding-v2"; + + /** + * 获取文本的Embedding向量 + * + * @param text 输入文本 + * @return 1536维向量 + */ + public float[] embed(String text) { + try { + // 文本长度限制(Embedding API有长度限制) + if (text.length() > 2000) { + text = text.substring(0, 2000); + } + + Map 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> request = new HttpEntity<>(requestBody, headers); + + ResponseEntity 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 responseMap = JsonUtils.fromJsonMap(response); + if (responseMap == null) return new float[1536]; + + Map output = (Map) responseMap.get("output"); + if (output == null) return new float[1536]; + + List> embeddings = (List>) output.get("embeddings"); + if (embeddings == null || embeddings.isEmpty()) return new float[1536]; + + List embeddingList = (List) embeddings.get(0).get("embedding"); + if (embeddingList == null) return new float[1536]; + + float[] result = new float[embeddingList.size()]; + for (int i = 0; i < embeddingList.size(); i++) { + result[i] = embeddingList.get(i).floatValue(); + } + + return result; + + } catch (Exception e) { + log.error("解析Embedding响应失败: {}", e.getMessage()); + return new float[1536]; + } + } +} +``` + +#### 3.6.5 推荐Controller + +```java +package com.artedu.recommend.controller; + +import com.artedu.common.result.Result; +import com.artedu.recommend.service.RecommendationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 话术推荐控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/recommend") +public class RecommendController { + + @Autowired + private RecommendationService recommendationService; + + /** + * 话术推荐接口 + */ + @PostMapping + public Result 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 feedback(@RequestBody FeedbackRequest request) { + // TODO: 实现反馈收集 + return Result.success("反馈已记录"); + } + + // 请求DTO + public static class RecommendRequest { + private String message; + private String intentCode; + private String studentType; + private String currentStage; + private String customerId; + private String corpId; + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public String getIntentCode() { return intentCode; } + public void setIntentCode(String intentCode) { this.intentCode = intentCode; } + public String getStudentType() { return studentType; } + public void setStudentType(String studentType) { this.studentType = studentType; } + public String getCurrentStage() { return currentStage; } + public void setCurrentStage(String currentStage) { this.currentStage = currentStage; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String customerId) { this.customerId = customerId; } + public String getCorpId() { return corpId; } + public void setCorpId(String corpId) { this.corpId = corpId; } + } + + public static class FeedbackRequest { + private String utteranceId; + private String action; + private Boolean helpful; + + public String getUtteranceId() { return utteranceId; } + public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public Boolean getHelpful() { return helpful; } + public void setHelpful(Boolean helpful) { this.helpful = helpful; } + } +} +``` + + + +--- + +## 第四部分:前端实现 + +### 4.1 侧边栏H5项目(React + TypeScript) + +#### 4.1.1 package.json + +```json +{ + "name": "ai-assistant-sidebar", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 3000", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.4.0", + "antd-mobile": "^5.32.0", + "antd-mobile-icons": "^0.3.0", + "wecom-jssdk": "^2.0.2", + "axios": "^1.5.0", + "clsx": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.2.0", + "vite": "^4.4.0" + } +} +``` + +#### 4.1.2 Vite配置 + +```typescript +// vite.config.ts +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + host: '0.0.0.0', + // 允许企微域名跨域 + cors: true, + }, + build: { + outDir: 'dist', + sourcemap: true, + // 移动端适配:使用vw单位 + cssTarget: 'chrome61', + }, + base: '/sidebar/', +}); +``` + +#### 4.1.3 HTML入口 + +```html + + + + + + + + + AI话术助手 + + + + + +
+ + + +``` + +#### 4.1.4 入口文件 + +```typescript +// src/main.tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/global.css'; + +// 企微JS-SDK环境检测 +console.log('环境检测:', { + ww: typeof ww !== 'undefined', + wx: typeof wx !== 'undefined', + isWeCom: navigator.userAgent.toLowerCase().includes('wxwork'), + isMobile: /Mobile|Android|iPhone/.test(navigator.userAgent), +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); +``` + +#### 4.1.5 全局样式 + +```css +/* src/styles/global.css */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #root { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #f5f5f5; +} + +/* 企微侧边栏容器适配 */ +.sidebar-container { + height: 100vh; + display: flex; + flex-direction: column; + background: #f5f5f5; +} + +/* PC端侧边栏固定宽度 */ +@media (min-width: 768px) { + .sidebar-container { + width: 360px; + min-width: 360px; + max-width: 360px; + } +} + +/* 手机端侧边栏全宽 */ +@media (max-width: 767px) { + .sidebar-container { + width: 100vw; + height: 80vh; + border-radius: 12px 12px 0 0; + } +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 2px; +} + +/* 话术卡片样式 */ +.script-card { + background: #fff; + border-radius: 12px; + padding: 12px; + margin: 8px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + transition: all 0.2s ease; +} + +.script-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* 学员画像标签 */ +.profile-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + margin-right: 6px; + margin-bottom: 4px; +} + +.profile-tag-primary { + background: #e6f7ff; + color: #1890ff; + border: 1px solid #91d5ff; +} + +.profile-tag-success { + background: #f6ffed; + color: #52c41a; + border: 1px solid #b7eb8f; +} + +.profile-tag-warning { + background: #fffbe6; + color: #faad14; + border: 1px solid #ffe58f; +} + +/* 一键发送按钮 */ +.send-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.send-btn-primary { + background: #1677ff; + color: #fff; +} + +.send-btn-primary:active { + background: #0958d9; +} +``` + +#### 4.1.6 企微SDK Hook(核心) + +```typescript +// src/hooks/useWeComSdk.ts +import { useState, useEffect, useCallback } from 'react'; + +/** + * 企微环境类型 + */ +export type WeComEnv = 'pc' | 'mobile' | 'unknown'; + +/** + * 企微SDK Hook + * 负责初始化ww.register、获取客户ID、发送消息 + */ +export function useWeComSdk() { + const [env, setEnv] = useState('unknown'); + const [customerId, setCustomerId] = useState(''); + const [staffId, setStaffId] = useState(''); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(''); + + /** + * 检测企微环境 + */ + 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 => { + return new Promise((resolve) => { + if (typeof ww === 'undefined') { + setError('SDK未初始化'); + resolve(false); + return; + } + + ww.invoke( + 'sendChatMessage', + { + msgtype: 'text', + text: { + content: content, + }, + enterChat: enterChat, // PC端false不进入会话,手机端true进入会话 + }, + (res: any) => { + if (res.err_msg === 'sendChatMessage:ok') { + console.log('话术发送成功'); + resolve(true); + } else { + console.error('话术发送失败:', res.err_msg); + setError('发送失败: ' + res.err_msg); + resolve(false); + } + } + ); + }); + }, + [] + ); + + /** + * 打开已有会话(配合enterChat:true使用) + */ + const openChat = useCallback((userId: string) => { + if (typeof ww === 'undefined') return; + + ww.invoke( + 'openExistedChatWithMsg', + { + userid: userId, + }, + (res: any) => { + console.log('打开会话结果:', res); + } + ); + }, []); + + return { + env, + customerId, + staffId, + isReady, + error, + initSdk, + sendScript, + openChat, + }; +} +``` + +#### 4.1.7 API服务 + +```typescript +// src/services/api.ts +/** + * API服务 + * 封装所有后端API调用 + */ +const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'; + +/** + * 获取Token + */ +function getToken(): string { + return localStorage.getItem('token') || ''; +} + +/** + * 通用请求方法 + */ +async function request( + url: string, + options: RequestInit = {} +): Promise<{ code: number; data: T; message: string }> { + const headers: Record = { + 'Content-Type': 'application/json', + ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), + ...((options.headers as Record) || {}), + }; + + 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('/api/v1/recommend', { + method: 'POST', + body: JSON.stringify(params), + }); +} + +/** + * 获取学员画像API + */ +export async function getCustomerProfile(customerId: string, corpId: string) { + return request(`/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>(`/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; +} + +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 { + page: number; + size: number; + total: number; + list: T[]; +} +``` + +#### 4.1.8 Zustand状态管理 + +```typescript +// src/stores/useScriptStore.ts +import { create } from 'zustand'; +import type { RecommendItem, RecommendResult } from '../services/api'; + +/** + * 话术推荐状态管理 + */ +interface ScriptState { + // 状态 + isLoading: boolean; + recommendations: RecommendItem[]; + currentScript: RecommendItem | null; + error: string; + + // 动作 + setRecommendations: (items: RecommendItem[]) => void; + setCurrentScript: (script: RecommendItem | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string) => void; + loadRecommendations: (result: RecommendResult) => void; + clearRecommendations: () => void; +} + +export const useScriptStore = create((set) => ({ + isLoading: false, + recommendations: [], + currentScript: null, + error: '', + + setRecommendations: (items) => set({ recommendations: items, error: '' }), + setCurrentScript: (script) => set({ currentScript: script }), + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error, isLoading: false }), + + loadRecommendations: (result) => + set({ + recommendations: result.recommendations, + isLoading: false, + error: '', + }), + + clearRecommendations: () => + set({ + recommendations: [], + currentScript: null, + error: '', + }), +})); +``` + +```typescript +// src/stores/useProfileStore.ts +import { create } from 'zustand'; + +/** + * 学员画像状态管理 + */ +interface ProfileState { + customerId: string; + customerName: string; + studentType: string; + intentLevel: string; + intentScore: number; + concernFocus: string; + decisionStage: string; + conversationCount: number; + isLoading: boolean; + + setProfile: (profile: Partial>) => void; + setLoading: (loading: boolean) => void; + clearProfile: () => void; +} + +export const useProfileStore = create((set) => ({ + customerId: '', + customerName: '', + studentType: '', + intentLevel: '低', + intentScore: 0, + concernFocus: '', + decisionStage: '信息了解', + conversationCount: 0, + isLoading: false, + + setProfile: (profile) => set((state) => ({ ...state, ...profile })), + setLoading: (loading) => set({ isLoading: loading }), + clearProfile: () => + set({ + customerId: '', + customerName: '', + studentType: '', + intentLevel: '低', + intentScore: 0, + concernFocus: '', + decisionStage: '信息了解', + conversationCount: 0, + }), +})); +``` + +#### 4.1.9 根组件 App.tsx + +```tsx +// src/App.tsx +import React, { useEffect } from 'react'; +import { useWeComSdk } from './hooks/useWeComSdk'; +import Header from './components/Header'; +import TabBar from './components/TabBar'; +import ProfileCard from './components/ProfileCard'; +import ScriptRecommend from './components/ScriptRecommend'; +import AnalysisPanel from './components/AnalysisPanel'; +import { Toast } from 'antd-mobile'; + +/** + * 侧边栏主应用组件 + * 整合企微SDK、话术推荐、学员画像等功能 + */ +const App: React.FC = () => { + const { env, isReady, error, initSdk, customerId } = useWeComSdk(); + const [activeTab, setActiveTab] = React.useState<'recommend' | 'analysis' | 'search'>('recommend'); + + // 初始化SDK + useEffect(() => { + initSdk(); + }, [initSdk]); + + // 错误提示 + useEffect(() => { + if (error) { + Toast.show({ content: error, position: 'center' }); + } + }, [error]); + + // 根据当前Tab渲染内容 + const renderContent = () => { + switch (activeTab) { + case 'recommend': + return ; + case 'analysis': + return ; + case 'search': + return ; + default: + return ; + } + }; + + if (!isReady) { + return ( +
+
+
🤖
+
正在初始化AI助手...
+ {error &&
{error}
} +
+
+ ); + } + + return ( +
+ {/* 顶部栏 */} +
+ + {/* 学员画像卡片 */} + + + {/* Tab切换栏 */} + + + {/* 主内容区 */} +
+ {renderContent()} +
+
+ ); +}; + +/** + * 搜索占位组件 + */ +const SearchPlaceholder: React.FC = () => ( +
+
🔍
+
话术搜索功能开发中
+
+); + +export default App; +``` + +#### 4.1.10 Header组件 + +```tsx +// src/components/Header.tsx +import React from 'react'; +import { useWeComSdk } from '../hooks/useWeComSdk'; +import type { WeComEnv } from '../hooks/useWeComSdk'; + +interface HeaderProps { + env: WeComEnv; + customerId: string; +} + +/** + * 顶部栏组件 + * 显示标题、环境标识、刷新按钮 + */ +const Header: React.FC = ({ env, customerId }) => { + const { env: sdkEnv } = useWeComSdk(); + + return ( +
+
+ 🤖 +
+
AI话术助手
+
+ {env === 'pc' ? '💻 PC端' : '📱 手机端'} | {customerId ? '已连接' : '未连接'} +
+
+
+ +
+ +
+
+ ); +}; + +export default Header; +``` + +#### 4.1.11 ProfileCard组件(学员画像) + +```tsx +// src/components/ProfileCard.tsx +import React, { useEffect } from 'react'; +import { useProfileStore } from '../stores/useProfileStore'; +import { Tag } from 'antd-mobile'; + +/** + * 学员画像卡片 + * 展示学员类型、意向度、决策阶段等信息 + */ +const ProfileCard: React.FC = () => { + const profile = useProfileStore(); + + // 意向度颜色映射 + const getIntentColor = (level: string) => { + switch (level) { + case '高': return '#ff4d4f'; + case '中': return '#faad14'; + case '低': return '#52c41a'; + default: return '#999'; + } + }; + + // 决策阶段图标映射 + const getStageIcon = (stage: string) => { + switch (stage) { + case '开场白': return '👋'; + case '需求探询': return '🔍'; + case '课程推荐': return '📚'; + case '价值塑造': return '💎'; + case '报价沟通': return '💰'; + case '异议处理': return '⚡'; + case '促成报名': return '🎯'; + case '跟进维护': return '📞'; + default: return '📝'; + } + }; + + if (!profile.customerId) { + return ( +
+
+ 暂无学员画像数据 +
+
+ ); + } + + return ( +
+ {/* 学员类型 + 意向度 */} +
+
+ + {profile.customerName || '未知学员'} + + {profile.studentType && ( + + {profile.studentType} + + )} +
+
+ 🔥 + {profile.intentLevel}意向({Math.round(profile.intentScore)}) +
+
+ + {/* 标签行 */} +
+ {profile.decisionStage && ( + + {getStageIcon(profile.decisionStage)} {profile.decisionStage} + + )} + {profile.concernFocus && ( + + 💡 {profile.concernFocus} + + )} + + 💬 {profile.conversationCount}次对话 + +
+
+ ); +}; + +export default ProfileCard; +``` + +#### 4.1.12 ScriptRecommend组件(话术推荐列表) + +```tsx +// src/components/ScriptRecommend.tsx +import React, { useEffect, useState } from 'react'; +import { useScriptStore } from '../stores/useScriptStore'; +import { useWeComSdk } from '../hooks/useWeComSdk'; +import { recommendScripts, submitFeedback } from '../services/api'; +import { Loading, Toast } from 'antd-mobile'; +import ScriptCard from './ScriptCard'; + +/** + * 话术推荐列表组件 + * 展示AI推荐的话术列表,支持一键发送 + */ +const ScriptRecommend: React.FC = () => { + const { customerId } = useWeComSdk(); + const { recommendations, isLoading, setRecommendations, setLoading, setError } = useScriptStore(); + const [lastMessage, setLastMessage] = useState(''); + + // 组件加载时获取推荐 + useEffect(() => { + if (customerId) { + loadRecommendations(); + } + }, [customerId]); + + /** + * 加载话术推荐 + */ + const loadRecommendations = async () => { + if (!customerId) { + Toast.show({ content: '请先获取客户ID', position: 'center' }); + return; + } + + setLoading(true); + try { + const result = await recommendScripts({ + message: lastMessage || '获取推荐', + customerId: customerId, + corpId: localStorage.getItem('corpId') || 'default', + }); + + if (result.code === 0) { + setRecommendations(result.data.recommendations); + } else { + setError(result.message); + } + } catch (e) { + setError((e as Error).message); + Toast.show({ content: '获取推荐失败', position: 'center' }); + } finally { + setLoading(false); + } + }; + + if (isLoading) { + return ( +
+ + AI正在分析对话... +
+ ); + } + + if (recommendations.length === 0) { + return ( +
+
🤖
+
暂无推荐话术
+ +
+ ); + } + + return ( +
+ {/* 推荐头部 */} +
+
+ 💡 为您推荐 {recommendations.length} 条话术 +
+ +
+ + {/* 话术卡片列表 */} + {recommendations.map((script, index) => ( + + ))} +
+ ); +}; + +export default ScriptRecommend; +``` + +#### 4.1.13 ScriptCard组件(单条话术卡片) + +```tsx +// src/components/ScriptCard.tsx +import React, { useState } from 'react'; +import { useWeComSdk } from '../hooks/useWeComSdk'; +import type { RecommendItem } from '../services/api'; +import { Toast } from 'antd-mobile'; + +interface ScriptCardProps { + script: RecommendItem; + rank: number; +} + +/** + * 单条话术卡片 + * 包含话术内容、匹配分数、一键发送/编辑发送按钮 + */ +const ScriptCard: React.FC = ({ 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 ( +
+ {/* 卡片头部 */} +
+
+ {/* 排名 */} + + {rank} + + {/* 标题 */} + + {script.title} + +
+ {/* 匹配分数 */} +
+ 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)}%匹配 + +
+
+ + {/* 话术内容 */} +
+ {isEditing ? ( +