# 第九联盟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 ? (