agent_9art/product1.0/v2-final-完整开发方案.md
jiao 31a395be5e feat(01): Phase 1 - 基础设施 + 数据库 + 公共模块
- 创建10个微服务目录结构和Maven多模块配置
- 完成15张MySQL 5.7表设计和初始化数据(40意图+8话术分类+管理员)
- 实现common模块:Result、PageResult、异常处理、MyBatisPlus/Redis/RabbitMQ/跨域配置
- 实现BaseEntity、AutoFillHandler、JsonUtils、JsonTypeHandler
- 配置API Gateway路由和全部服务的application.yml
- 添加docker-compose.yml(MySQL+Redis+RabbitMQ)
2026-05-12 17:55:11 +08:00

292 KiB
Raw Blame History

第九联盟AI坐席辅助系统 — 完整开发方案(面向AI编程助手)

版本: v3.0-final 日期: 2025年7月 项目: 第九联盟(9artedu.com)AI坐席辅助系统 开发工具: Kimi Code / Claude Code 适用环境: 线上MySQL 5.7(JSON→TEXT适配,向量存Redis) 交互方案: 方案D(混合)— PC侧边栏H5 + 手机侧边栏H5 + 模板卡片推送


文档目录


第一部分:项目概述与架构设计

1.1 项目概述

1.1.1 项目基本信息

项目 详情
项目名称 第九联盟AI坐席辅助系统(9artedu-ai-assistant)
版本 v3.0-final
目标用户 第九联盟课程顾问/销售(坐席)
服务对象 CG美术培训潜在学员(外部联系人)
核心业务 基于企微会话存档+LLM的话术实时推荐
部署环境 用户自有服务器(线上MySQL 5.7)

1.1.2 业务背景

第九联盟(9ART EDU,母公司上海点晴信息科技有限公司)是中国CG数字艺术人才孵化基地,提供以下6大核心方向的CG美术培训课程:

  • 3D场景UE地编就业班
  • 次世代角色模型就业班
  • 2D原画就业班(角色/场景方向)
  • 游戏/影视动画设计就业班
  • 游戏特效就业班
  • 3D大师研修班

业务数据:

  • 10+年游戏制作经验
  • 500+家就业推荐企业(腾讯、网易、米哈游等)
  • 8000+名累计服务学员
  • 6大校区(上海、西安、厦门、武汉、青岛、合肥)
  • 央视CCTV2财经频道专题报道

1.1.3 核心功能概述

┌─────────────────────────────────────────────────────────────────────┐
│                    AI坐席辅助系统核心功能                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  📥 数据接入层                                                      │
│  ├── 企微会话存档拉取(C SDK + JNA)                                │
│  ├── RSA解密 + 消息解析                                              │
│  └── 回调接收(msgaudit_notify,15秒间隔)                          │
│                                                                     │
│  🧠 AI分析引擎层                                                    │
│  ├── 对话上下文理解(最近10轮滑动窗口)                              │
│  ├── 意图识别(40个场景多标签分类)                                  │
│  ├── 学员画像构建(5维度标签体系)                                   │
│  ├── 话术召回(规则召回+向量召回+热门召回)                          │
│  ├── 话术排序(多因子加权+MMR多样性)                                │
│  └── LLM动态话术生成(兜底策略)                                     │
│                                                                     │
│  📤 输出交互层                                                      │
│  ├── PC端侧边栏H5(360px,完整功能)                                │
│  ├── 手机端侧边栏H5(80%高度,精简操作)                            │
│  ├── 模板卡片消息推送(高优先级场景主动推送)                        │
│  └── 一键发送(sendChatMessage)                                    │
│                                                                     │
│  ⚙️ 管理后台层                                                      │
│  ├── 话术库管理(CRUD+分类+导入导出)                               │
│  ├── 数据统计看板(使用数据+效果分析)                               │
│  └── 系统配置(推送设置+免打扰)                                    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

1.2 技术选型(明确推荐)

层级 技术选型 版本 选型理由
后端框架 Spring Boot 2.7.x 成熟稳定,生态丰富,兼容MySQL 5.7
ORM MyBatis-Plus 3.5.x 简化CRUD,代码生成,ActiveRecord模式
数据库 MySQL 5.7 用户已有线上环境,不增加额外成本
缓存 Redis 6.x 会话缓存、向量缓存、频率控制
消息队列 RabbitMQ 3.x 异步处理、削峰填谷、事件驱动
向量存储 Redis Hash - MySQL 5.7不支持向量类型,Redis存Embedding
API网关 Spring Cloud Gateway 3.1.x 路由、限流、鉴权统一入口
前端框架 React 18 + TypeScript 18.x 组件化开发,类型安全
移动端UI Ant Design Mobile 5.x 企微H5移动端适配
PC端UI Ant Design 5.x 管理后台UI组件
构建工具 Vite 5.x 快速构建,HMR
状态管理 Zustand 4.x 轻量,无样板代码
大模型主选 通义千问API(qwen-turbo) - 国产、低成本、中文优秀、数据合规
大模型备选 通义千问(qwen-plus) - 复杂场景备选
Embedding 通义千问文本Embedding API - 向量化解析,1536维
企微SDK 企业微信JS-SDK 2.0 2.0.2 ww.register方式,iOS兼容
C SDK集成 JNA 5.13.x Java调用企微C SDK,无需JNI编译
容器化 Docker + Docker Compose - 快速部署,环境一致

1.2.1 MySQL 5.7关键限制与适配方案

限制 影响 解决方案
不支持JSON数据类型 无法使用JSON字段存储结构化数据 使用TEXT类型存储JSON字符串,应用层序列化/反序列化
不支持向量数据类型 无法存储Embedding向量 向量存储使用Redis Hash,MySQL只存业务数据和向量标记
utf8mb4支持 需要存储emoji等特殊字符 所有表使用utf8mb4字符集
JSON函数缺失 无法使用JSON_EXTRACT等 应用层处理JSON数据,Java中使用Jackson/Gson

1.3 系统架构图

1.3.1 全景架构图

graph TB
    subgraph Client["客户端层"]
        PC["企微PC客户端"]
        Mobile["企微手机客户端(iOS/Android)"]
        SidePC["侧边栏H5-PC 360px"]
        SideMobile["侧边栏H5-Mobile 80%高度"]
        AdminWeb["管理后台Web"]
        CardPush["模板卡片推送"]
    end

    subgraph Access["接入层"]
        Nginx["Nginx负载均衡/SSL终端"]
        Gateway["API Gateway<br/>Spring Cloud Gateway"]
        WSDK["企微JS-SDK<br/>ww.register/agentConfig"]
        OAuth["企微OAuth2认证"]
        Callback["企微回调接收<br/>msgaudit_notify/事件推送"]
    end

    subgraph Service["服务层-10个微服务集群"]
        ArchS["archive-service<br/>会话存档拉取与解析"]
        ConvS["conversation-service<br/>对话上下文管理"]
        IntentS["intent-service<br/>意图识别与学员画像"]
        RecS["recommendation-service<br/>话术匹配推荐与排序"]
        GenS["generation-service<br/>LLM话术生成+Prompt引擎"]
        KnowS["knowledge-service<br/>知识库管理"]
        AuthS["auth-service<br/>认证授权"]
        KbAdmS["kb-admin-service<br/>话术库管理后台"]
        AnaS["analytics-service<br/>数据分析+话术效果"]
    end

    subgraph Data["数据层"]
        MySQL[("MySQL 5.7<br/>业务数据/话术库/会话<br/>TEXT存JSON")]
        Redis[("Redis 6.x<br/>缓存/会话/向量存储<br/>实时状态")]
        MQ[("RabbitMQ 3.x<br/>消息队列/异步处理")]
    end

    subgraph External["外部依赖"]
        WeComAPI["企业微信API<br/>存档SDK/消息推送"]
        LLM1["通义千问API<br/>qwen-turbo(主)"]
        LLM2["通义千问API<br/>qwen-plus(备)"]
        EmbedAPI["文本Embedding API"]
    end

    PC --> SidePC
    Mobile --> SideMobile
    SidePC --> Nginx
    SideMobile --> Nginx
    AdminWeb --> Nginx
    Nginx --> Gateway
    Gateway --> OAuth
    OAuth --> WeComAPI
    SidePC --> WSDK
    SideMobile --> WSDK
    WSDK --> WeComAPI
    CardPush --> WeComAPI

    Gateway --> AuthS
    Gateway --> ArchS
    Gateway --> ConvS
    Gateway --> IntentS
    Gateway --> RecS
    Gateway --> GenS
    Gateway --> KnowS
    Gateway --> KbAdmS
    Gateway --> AnaS

    ArchS --> ConvS
    ConvS --> IntentS
    IntentS --> RecS
    RecS --> GenS
    ConvS --> RecS
    KnowS --> RecS
    KbAdmS --> RecS
    GenS --> LLM1
    GenS --> LLM2

    ArchS --> MySQL
    ArchS --> Redis
    ConvS --> Redis
    ConvS --> MySQL
    IntentS --> MySQL
    IntentS --> Redis
    RecS --> MySQL
    RecS --> Redis
    GenS --> Redis
    GenS --> MySQL
    KnowS --> MySQL
    AuthS --> MySQL
    AuthS --> Redis
    KbAdmS --> MySQL
    KbAdmS --> Redis
    AnaS --> MySQL
    AnaS --> Redis

    MQ -.-> ArchS
    MQ -.-> ConvS
    MQ -.-> RecS
    MQ -.-> AnaS

    Callback --> ArchS
    ArchS --> WeComAPI

    style ArchS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style ConvS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style IntentS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style RecS fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style GenS fill:#fff3e0,stroke:#e65100,stroke-width:2px

1.3.2 核心数据流图

graph LR
    subgraph DataIn["数据输入"]
        WX["企微存档消息"]
        KB["话术库"]
        KL["知识库"]
    end

    subgraph Pipeline["处理流水线"]
        direction TB
        P1["Step1: 对话解析<br/>轮次提取|角色识别"]
        P2["Step2: 上下文管理<br/>窗口构建|状态追踪"]
        P3["Step3: 意图识别<br/>40类分类|槽位填充"]
        P4["Step4: 画像更新<br/>类型|基础|意向度"]
        P5["Step5: 话术召回<br/>规则召回|向量召回|热门召回"]
        P6["Step6: 话术排序<br/>多因子评分|MMR多样性"]
        P7["Step7: 结果输出<br/>Top5推荐|状态摘要"]

        P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
    end

    subgraph Output["输出层"]
        J["推荐话术列表<br/>Top 3-5条"]
        K["学员画像更新<br/>标签体系"]
        L["质量分析报告"]
    end

    subgraph Feedback["反馈闭环"]
        F1["坐席选择反馈"]
        F2["转化率追踪"]
        F3["模型更新"]
    end

    WX --> P1
    KB --> P5
    KL --> P3
    KL --> P5
    P7 --> J
    P7 --> K
    J --> F1 --> F2 --> F3
    F3 --> KB

1.3.3 对话状态机

stateDiagram-v2
    [*] --> 开场白: 新会话开始
    开场白 --> 需求探询: 学员回应
    需求探询 --> 课程推荐: 基础信息已收集
    需求探询 --> 异议处理: 学员提出顾虑
    课程推荐 --> 价值塑造: 学员感兴趣
    课程推荐 --> 异议处理: 学员有疑问
    价值塑造 --> 报价沟通: 询问价格
    报价沟通 --> 异议处理: 价格顾虑
    报价沟通 --> 促成报名: 接受价格
    异议处理 --> 课程推荐: 重新推荐
    异议处理 --> 报价沟通: 顾虑消除
    异议处理 --> 促成报名: 异议解决
    促成报名 --> 跟进维护: 暂不报名
    促成报名 --> [*]: 报名成功
    跟进维护 --> 课程推荐: 再次咨询
    跟进维护 --> [*]: 超过7天未回复

1.3.4 端到端数据流时序

sequenceDiagram
    participant Student as 学员
    participant WeCom as 企业微信
    participant Archive as archive-service
    participant MQ as RabbitMQ
    participant Conv as conversation-service
    participant Intent as intent-service
    participant Rec as recommendation-service
    participant WS as WebSocket
    participant Staff as 坐席

    Note over Student,Staff: 端到端延迟目标 < 25秒

    Student->>WeCom: 发送消息
    Note right of WeCom: T+0s

    WeCom->>Archive: msgaudit_notify回调
    Note right of WeCom: T+~15s(回调间隔)

    Archive->>Archive: GetChatData拉取
    Note right of Archive: T+~16s

    Archive->>Archive: RSA解密+解析
    Note right of Archive: T+~17s

    Archive->>MQ: 发布消息事件
    Note right of MQ: T+~17.5s

    MQ->>Conv: 消费消息事件
    Note right of Conv: T+~18s

    Conv->>Conv: 更新上下文窗口
    Conv->>Intent: 调用意图识别API
    Note right of Intent: T+~18.5s

    Intent->>Intent: LLM意图分析
    Note right of Intent: T+~19s(200-500ms)

    Intent-->>Conv: 返回意图+画像
    Conv->>Rec: 调用话术推荐API
    Note right of Rec: T+~19.5s

    Rec->>Rec: 三层召回+排序
    Note right of Rec: T+~20s(100-300ms)

    Rec->>WS: 推送推荐结果
    Note right of WS: T+~21s

    WS->>Staff: 侧边栏更新推荐
    Note left of Staff: T+~22s

    Staff->>WS: 点击发送
    WS->>Rec: 记录使用反馈

    Note over Student,Staff: 总延迟: ~22秒(含15秒回调间隔)

1.3.5 话术推荐引擎架构

graph TD
    A["输入: 意图+画像+阶段+学员消息"] --> B{"话术库召回"}
    B --> C["规则召回<br/>意图码+阶段+画像标签<br/>MySQL查询"]
    B --> D["向量召回<br/>学员消息Embedding<br/>Redis向量检索"]
    B --> E["热门召回<br/>近7天高成功率<br/>Redis ZSET"]
    C --> F["候选话术池<br/>去重+过滤"]
    D --> F
    E --> F
    F --> G{"候选话术数量"}
    G -->|">=3条"| H["多因子排序<br/>加权评分"]
    G -->|"<3条"| I["LLM动态生成<br/>补充话术"]
    G -->|"0条"| J["纯LLM生成<br/>兜底话术"]
    H --> K["MMR多样性重排"]
    I --> H
    J --> L["LLM话术审核<br/>安全性+准确性"]
    K --> M["Top 5输出<br/>含推荐理由"]
    L --> M

1.4 项目目录结构

ai-assistant/
├── backend/                              # 后端服务
│   ├── archive-service/                  # 会话存档服务
│   │   ├── src/main/java/com/artedu/archive/
│   │   │   ├── ArchiveServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── ArchiveCallbackController.java
│   │   │   ├── service/
│   │   │   │   ├── ArchivePullService.java
│   │   │   │   ├── ArchiveSdkService.java
│   │   │   │   └── MessageStoreService.java
│   │   │   ├── sdk/
│   │   │   │   └── WeWorkFinanceSdk.java    # JNA接口
│   │   │   ├── entity/
│   │   │   │   └── ArchiveMessage.java
│   │   │   ├── mapper/
│   │   │   │   └── ArchiveMessageMapper.java
│   │   │   └── config/
│   │   │       └── RabbitConfig.java
│   │   ├── src/main/resources/
│   │   │   ├── application.yml
│   │   │   └── libWeWorkFinanceSdk.so     # Linux C SDK
│   │   └── Dockerfile
│   │
│   ├── conversation-service/             # 对话管理服务
│   │   ├── src/main/java/com/artedu/conversation/
│   │   │   ├── ConversationServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── ConversationController.java
│   │   │   ├── service/
│   │   │   │   ├── ConversationManager.java
│   │   │   │   ├── ContextWindowService.java
│   │   │   │   ├── TurnParserService.java
│   │   │   │   └── MessageEventHandler.java
│   │   │   ├── entity/
│   │   │   │   ├── Conversation.java
│   │   │   │   └── ConversationTurn.java
│   │   │   ├── mapper/
│   │   │   └── config/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── intent-service/                   # 意图识别服务
│   │   ├── src/main/java/com/artedu/intent/
│   │   │   ├── IntentServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── IntentController.java
│   │   │   ├── service/
│   │   │   │   ├── IntentRecognitionService.java
│   │   │   │   ├── UserProfilingService.java
│   │   │   │   └── RuleClassifier.java
│   │   │   ├── entity/
│   │   │   │   ├── IntentCategory.java
│   │   │   │   ├── IntentRecognition.java
│   │   │   │   └── CustomerProfile.java
│   │   │   ├── mapper/
│   │   │   └── prompt/
│   │   │       └── IntentPromptTemplate.java
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── recommendation-service/           # 话术推荐服务
│   │   ├── src/main/java/com/artedu/recommend/
│   │   │   ├── RecommendationServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── RecommendController.java
│   │   │   ├── service/
│   │   │   │   ├── RecallService.java
│   │   │   │   ├── RankingService.java
│   │   │   │   ├── MMRService.java
│   │   │   │   └── FeedbackService.java
│   │   │   ├── entity/
│   │   │   │   ├── Utterance.java
│   │   │   │   └── Recommendation.java
│   │   │   ├── mapper/
│   │   │   └── config/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── generation-service/               # LLM生成服务
│   │   ├── src/main/java/com/artedu/generation/
│   │   │   ├── GenerationServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── GenerationController.java
│   │   │   ├── service/
│   │   │   │   ├── LLMClient.java
│   │   │   │   ├── QianwenClient.java
│   │   │   │   ├── PromptEngine.java
│   │   │   │   └── TokenControlService.java
│   │   │   ├── dto/
│   │   │   └── config/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── auth-service/                     # 认证授权服务
│   │   ├── src/main/java/com/artedu/auth/
│   │   │   ├── AuthServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── AuthController.java
│   │   │   ├── service/
│   │   │   │   ├── WeComOAuthService.java
│   │   │   │   └── JwtService.java
│   │   │   ├── entity/
│   │   │   │   └── Staff.java
│   │   │   ├── mapper/
│   │   │   └── security/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── kb-admin-service/                 # 话术库管理服务
│   │   ├── src/main/java/com/artedu/kbadmin/
│   │   │   ├── KbAdminServiceApplication.java
│   │   │   ├── controller/
│   │   │   │   └── UtteranceController.java
│   │   │   ├── service/
│   │   │   │   ├── UtteranceCrudService.java
│   │   │   │   ├── CategoryService.java
│   │   │   │   └── ImportExportService.java
│   │   │   ├── entity/
│   │   │   └── mapper/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── analytics-service/                # 数据分析服务
│   │   ├── src/main/java/com/artedu/analytics/
│   │   │   ├── AnalyticsServiceApplication.java
│   │   │   ├── controller/
│   │   │   └── service/
│   │   ├── src/main/resources/
│   │   │   └── application.yml
│   │   └── Dockerfile
│   │
│   ├── gateway/                          # API网关
│   │   ├── src/main/java/com/artedu/gateway/
│   │   │   └── GatewayApplication.java
│   │   ├── src/main/resources/
│   │   │   ├── application.yml
│   │   │   └── bootstrap.yml
│   │   └── Dockerfile
│   │
│   └── common/                           # 公共模块
│       └── src/main/java/com/artedu/common/
│           ├── result/Result.java          # 统一响应
│           ├── exception/                # 全局异常
│           ├── config/                   # 公共配置
│           └── util/                     # 工具类
│
├── frontend/                             # 前端
│   ├── sidebar/                          # 侧边栏H5(PC+手机)
│   │   ├── src/
│   │   │   ├── main.tsx                  # 入口
│   │   │   ├── App.tsx                   # 根组件
│   │   │   ├── components/               # 组件
│   │   │   │   ├── Header.tsx
│   │   │   │   ├── TabBar.tsx
│   │   │   │   ├── ProfileCard.tsx
│   │   │   │   ├── StageIndicator.tsx
│   │   │   │   ├── ScriptCard.tsx
│   │   │   │   ├── ScriptRecommend.tsx
│   │   │   │   ├── AnalysisPanel.tsx
│   │   │   │   └── SearchPanel.tsx
│   │   │   ├── services/                 # API调用
│   │   │   │   ├── api.ts
│   │   │   │   └── websocket.ts
│   │   │   ├── stores/                   # 状态管理
│   │   │   │   ├── useScriptStore.ts
│   │   │   │   ├── useProfileStore.ts
│   │   │   │   └── useStageStore.ts
│   │   │   ├── hooks/                    # 自定义Hook
│   │   │   │   └── useWeComSdk.ts
│   │   │   └── types/                    # 类型定义
│   │   │       └── index.ts
│   │   ├── index.html
│   │   ├── vite.config.ts
│   │   ├── package.json
│   │   └── Dockerfile
│   │
│   └── admin/                            # 管理后台
│       ├── src/
│       ├── package.json
│       └── Dockerfile
│
├── docs/                                 # 文档
│   ├── api/                              # API文档
│   └── deploy/                           # 部署文档
│
├── docker-compose.yml                    # Docker编排
├── nginx.conf                            # Nginx配置
├── init.sql                              # 数据库初始化
└── README.md

第二部分:数据库设计(MySQL 5.7适配版)

2.1 数据库设计原则

  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 会话存档消息表

-- ============================================================
-- 企微会话存档消息表 (archive_messages)
-- 存储企微会话存档解密后的原始消息
-- MySQL 5.7适配: JSON → TEXT,应用层序列化/反序列化
-- ============================================================
CREATE TABLE IF NOT EXISTS `archive_messages` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `msgid` VARCHAR(255) NOT NULL COMMENT '企微消息唯一ID',
    `seq` BIGINT(20) NOT NULL COMMENT '存档序列号,用于增量拉取',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `action` VARCHAR(32) DEFAULT 'send' COMMENT 'send/recall/agree/disagree',
    `from_user` VARCHAR(128) NOT NULL COMMENT '发送者userid',
    `from_role` VARCHAR(32) NOT NULL COMMENT '发送者角色: INTERNAL-企业内部成员 EXTERNAL-外部联系人 SYSTEM-系统',
    `to_user` VARCHAR(128) COMMENT '接收者userid(单聊)',
    `tolist` TEXT COMMENT '接收者列表(群聊),JSON数组字符串',
    `roomid` VARCHAR(255) COMMENT '群聊ID(群聊时有值)',
    `msgtype` VARCHAR(64) NOT NULL COMMENT 'text/image/voice/video/file/link/location/weapp/chatrecord/voip',
    `msgtime` BIGINT(20) NOT NULL COMMENT '消息发送时间,UTC毫秒时间戳',
    `content` LONGTEXT COMMENT '文本消息内容(长文本使用LONGTEXT)',
    `media_data` TEXT COMMENT '媒体消息元数据(sdkfileid/文件大小/时长等),JSON字符串',
    `session_id` VARCHAR(128) COMMENT '关联的会话ID(业务生成)',
    `decrypt_status` TINYINT DEFAULT 1 COMMENT '解密状态: 1成功 2失败',
    `decrypt_error` VARCHAR(512) COMMENT '解密失败原因',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_msgid_corp` (`msgid`, `corp_id`) COMMENT '消息ID+企业ID唯一',
    INDEX `idx_seq` (`seq`) COMMENT '序列号索引,用于增量拉取',
    INDEX `idx_session_id` (`session_id`) COMMENT '会话ID索引',
    INDEX `idx_from_user_msgtime` (`from_user`, `msgtime`) COMMENT '发送者+时间索引',
    INDEX `idx_to_user_msgtime` (`to_user`, `msgtime`) COMMENT '接收者+时间索引',
    INDEX `idx_roomid_msgtime` (`roomid`, `msgtime`) COMMENT '群聊ID+时间索引',
    INDEX `idx_msgtime` (`msgtime`) COMMENT '消息时间索引',
    INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微会话存档消息表';

2.2.2 会话表

-- ============================================================
-- 会话表 (conversations)
-- 管理学员与坐席的对话会话
-- ============================================================
CREATE TABLE IF NOT EXISTS `conversations` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `session_id` VARCHAR(128) NOT NULL COMMENT '会话唯一ID(业务生成)',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `customer_id` VARCHAR(128) NOT NULL COMMENT '学员(外部联系人)userid',
    `customer_name` VARCHAR(64) COMMENT '学员昵称',
    `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席(企业成员)userid',
    `staff_name` VARCHAR(64) COMMENT '坐席姓名',
    `room_id` VARCHAR(128) COMMENT '群聊ID(群聊时有值)',
    `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '会话状态: ACTIVE-活跃 IDLE-空闲 CLOSED-关闭 ARCHIVED-归档',
    `current_stage` VARCHAR(32) DEFAULT '开场白' COMMENT '当前对话阶段',
    `stage_confidence` DECIMAL(4,3) DEFAULT 0.000 COMMENT '阶段判断置信度',
    `round_count` INT(11) DEFAULT 0 COMMENT '对话轮次',
    `intent_count` INT(11) DEFAULT 0 COMMENT '意图识别次数',
    `last_msg_id` VARCHAR(255) COMMENT '最后一条消息ID',
    `last_msg_time` TIMESTAMP NULL COMMENT '最后消息时间',
    `last_msg_content` VARCHAR(512) COMMENT '最后消息内容摘要',
    `start_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '会话开始时间',
    `end_time` TIMESTAMP NULL COMMENT '会话结束时间',
    `idle_duration` INT(11) DEFAULT 0 COMMENT '空闲时长(分钟)',
    `context_summary` TEXT COMMENT '对话摘要(LLM生成)',
    `key_events` TEXT COMMENT '关键事件列表,JSON字符串',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_session_id` (`session_id`) COMMENT '会话ID唯一',
    INDEX `idx_corp_staff` (`corp_id`, `staff_id`, `status`) COMMENT '企业+坐席+状态',
    INDEX `idx_corp_customer` (`corp_id`, `customer_id`, `status`) COMMENT '企业+学员+状态',
    INDEX `idx_status_time` (`status`, `last_msg_time`) COMMENT '状态+时间',
    INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表';

2.2.3 对话轮次表

-- ============================================================
-- 对话轮次表 (conversation_turns)
-- 记录每个对话轮次的详细信息
-- ============================================================
CREATE TABLE IF NOT EXISTS `conversation_turns` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `turn_id` VARCHAR(128) NOT NULL COMMENT '轮次ID',
    `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
    `turn_number` INT(11) NOT NULL COMMENT '轮次序号',
    `student_msg_id` VARCHAR(255) COMMENT '学员消息ID',
    `student_content` TEXT COMMENT '学员消息内容',
    `seat_msg_id` VARCHAR(255) COMMENT '坐席回复消息ID',
    `seat_content` TEXT COMMENT '坐席回复内容',
    `turn_type` VARCHAR(32) DEFAULT 'QUERY' COMMENT '轮次类型: OPEN-开场 QUERY-咨询 OBJECTION-异议 INTEREST-意向 SILENCE-沉默 FOLLOWUP-跟进 CLOSE-收尾',
    `primary_intent` VARCHAR(32) COMMENT '主意图编码',
    `intent_confidence` DECIMAL(4,3) COMMENT '意图置信度',
    `student_emotion` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '学员情绪: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极 ANXIOUS-焦虑 EXCITED-兴奋',
    `key_topics` TEXT COMMENT '涉及主题列表,JSON字符串',
    `slot_changes` TEXT COMMENT '槽位变化,JSON字符串',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY `uk_turn_id` (`turn_id`) COMMENT '轮次ID唯一',
    INDEX `idx_session_turn` (`session_id`, `turn_number`) COMMENT '会话+轮次号',
    INDEX `idx_session_time` (`session_id`, `created_at`) COMMENT '会话+时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话轮次表';

2.2.4 意图分类表

-- ============================================================
-- 意图分类表 (intent_categories)
-- 存储40个意图分类的定义
-- ============================================================
CREATE TABLE IF NOT EXISTS `intent_categories` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `code` VARCHAR(32) NOT NULL COMMENT '意图编码,如INT-COURSE-01',
    `name` VARCHAR(64) NOT NULL COMMENT '意图名称',
    `domain` VARCHAR(32) NOT NULL COMMENT '所属领域',
    `description` VARCHAR(256) COMMENT '意图说明',
    `priority` VARCHAR(8) DEFAULT 'P2' COMMENT '优先级: P1-高 P2-中 P3-低',
    `trigger_keywords` TEXT COMMENT '触发关键词列表,JSON数组字符串',
    `sample_expressions` TEXT COMMENT '典型表达示例,JSON数组字符串',
    `status` TINYINT DEFAULT 1 COMMENT '状态: 1启用 0停用',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_code` (`code`) COMMENT '意图编码唯一',
    INDEX `idx_domain_priority` (`domain`, `priority`) COMMENT '领域+优先级'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图分类表';

2.2.5 意图识别记录表

-- ============================================================
-- 意图识别记录表 (intent_recognitions)
-- 每次意图识别的详细记录
-- ============================================================
CREATE TABLE IF NOT EXISTS `intent_recognitions` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
    `turn_id` VARCHAR(128) COMMENT '轮次ID',
    `msg_id` VARCHAR(255) NOT NULL COMMENT '消息ID',
    `customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID',
    `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID',
    `message_content` TEXT COMMENT '消息内容摘要',
    `primary_intent_code` VARCHAR(32) NOT NULL COMMENT '主意图编码',
    `primary_intent_confidence` DECIMAL(4,3) COMMENT '主意图置信度',
    `secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串',
    `sentiment` VARCHAR(32) DEFAULT 'NEUTRAL' COMMENT '情感: POSITIVE-积极 NEUTRAL-中性 NEGATIVE-消极',
    `sentiment_confidence` DECIMAL(4,3) COMMENT '情感置信度',
    `recognition_method` VARCHAR(32) DEFAULT 'HYBRID' COMMENT '识别方法: RULE-规则 LLM-大模型 HYBRID-混合',
    `llm_model` VARCHAR(64) COMMENT '使用的LLM模型',
    `prompt_tokens` INT(11) COMMENT 'Prompt Token数',
    `completion_tokens` INT(11) COMMENT 'Completion Token数',
    `elapsed_ms` INT(11) COMMENT '识别耗时(毫秒)',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    INDEX `idx_session_msg` (`session_id`, `msg_id`) COMMENT '会话+消息',
    INDEX `idx_customer_time` (`customer_id`, `created_at`) COMMENT '学员+时间',
    INDEX `idx_intent_code` (`primary_intent_code`, `created_at`) COMMENT '意图编码+时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='意图识别记录表';

2.2.6 学员画像表

-- ============================================================
-- 学员画像表 (customer_profiles)
-- 存储学员的多维度画像
-- ============================================================
CREATE TABLE IF NOT EXISTS `customer_profiles` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `customer_id` VARCHAR(128) NOT NULL COMMENT '学员userid',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `student_type` VARCHAR(32) COMMENT '学员类型: 在校大学生/转行人员/在职提升/高中毕业生/家长代询',
    `student_type_confidence` DECIMAL(4,3) COMMENT '学员类型置信度',
    `skill_level` VARCHAR(32) COMMENT '基础水平: 零基础/有美术基础/相关专业/有从业经验',
    `skill_level_confidence` DECIMAL(4,3) COMMENT '基础水平置信度',
    `intent_level` VARCHAR(16) DEFAULT '低' COMMENT '意向度等级: 高/中/低/无意向',
    `intent_score` DECIMAL(6,2) DEFAULT 0.00 COMMENT '意向度分数',
    `concern_focus` VARCHAR(32) COMMENT '关注重点: 价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型',
    `concern_focus_confidence` DECIMAL(4,3) COMMENT '关注重点置信度',
    `decision_stage` VARCHAR(32) DEFAULT '信息了解' COMMENT '决策阶段: 信息了解/方案比较/购买决策/报名成交',
    `decision_stage_confidence` DECIMAL(4,3) COMMENT '决策阶段置信度',
    `interested_courses` TEXT COMMENT '意向课程列表,JSON字符串',
    `budget_hint` VARCHAR(64) COMMENT '预算暗示',
    `preferred_city` VARCHAR(32) COMMENT '意向城市',
    `age` INT(11) COMMENT '年龄',
    `education` VARCHAR(32) COMMENT '学历',
    `current_occupation` VARCHAR(64) COMMENT '当前职业',
    `slot_data` TEXT COMMENT '完整槽位数据,JSON字符串',
    `conversation_count` INT(11) DEFAULT 0 COMMENT '对话次数',
    `last_conversation_time` TIMESTAMP NULL COMMENT '上次对话时间',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '学员+企业唯一',
    INDEX `idx_intent_score` (`intent_score`) COMMENT '意向度分数索引',
    INDEX `idx_decision_stage` (`decision_stage`) COMMENT '决策阶段索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员画像表';

2.2.7 话术库表

-- ============================================================
-- 话术库表 (utterances)
-- 存储所有推荐话术
-- MySQL 5.7适配: JSON标签 → TEXT,向量存Redis
-- ============================================================
CREATE TABLE IF NOT EXISTS `utterances` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `utterance_id` VARCHAR(64) NOT NULL COMMENT '话术唯一ID(业务编码)',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `title` VARCHAR(128) NOT NULL COMMENT '话术标题',
    `content` TEXT NOT NULL COMMENT '话术内容(富文本)',
    `content_text` TEXT COMMENT '纯文本内容(用于全文检索)',
    `version` INT(11) DEFAULT 1 COMMENT '版本号',
    `status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '状态: DRAFT-草稿 ACTIVE-启用 INACTIVE-停用 DEPRECATED-废弃',
    `priority` INT(11) DEFAULT 5 COMMENT '优先级(1-10,越小越优先)',
    `stage_tags` TEXT COMMENT '适用阶段标签,JSON数组字符串',
    `intent_tags` TEXT COMMENT '匹配意图标签,JSON数组字符串',
    `profile_tags` TEXT COMMENT '适用画像标签,JSON数组字符串',
    `course_type_tags` TEXT COMMENT '适用课程标签,JSON数组字符串',
    `emotion_tags` TEXT COMMENT '情感标签,JSON数组字符串',
    `topic_tags` TEXT COMMENT '主题标签,JSON数组字符串',
    `used_count` INT(11) DEFAULT 0 COMMENT '使用次数',
    `selected_count` INT(11) DEFAULT 0 COMMENT '选中次数',
    `positive_feedback` INT(11) DEFAULT 0 COMMENT '正面反馈数',
    `negative_feedback` INT(11) DEFAULT 0 COMMENT '负面反馈数',
    `conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数',
    `success_rate` DECIMAL(5,4) DEFAULT 0.0000 COMMENT '成功率(0-1)',
    `avg_response_time_ms` INT(11) COMMENT '平均响应时间(毫秒)',
    `has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量: 0否 1是',
    `vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间',
    `trigger_keywords` TEXT COMMENT '触发关键词,JSON字符串',
    `variables` TEXT COMMENT '变量定义,JSON字符串',
    `created_by` VARCHAR(64) COMMENT '创建人',
    `reviewed_by` VARCHAR(64) COMMENT '审核人',
    `source` VARCHAR(32) DEFAULT 'MANUAL' COMMENT '来源: MANUAL-人工录入 IMPORTED-导入 LLM_GENERATED-AI生成',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_utterance_id` (`utterance_id`) COMMENT '话术ID唯一',
    INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态',
    INDEX `idx_success_rate` (`success_rate`) COMMENT '成功率索引',
    INDEX `idx_used_count` (`used_count`) COMMENT '使用次数索引',
    FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术库表';

2.2.8 话术分类表

-- ============================================================
-- 话术分类表 (utterance_categories)
-- 话术按销售阶段分类
-- ============================================================
CREATE TABLE IF NOT EXISTS `utterance_categories` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `code` VARCHAR(32) NOT NULL COMMENT '分类编码',
    `name` VARCHAR(64) NOT NULL COMMENT '分类名称',
    `parent_code` VARCHAR(32) DEFAULT 'ROOT' COMMENT '父级编码',
    `sort_order` INT(11) DEFAULT 0 COMMENT '排序号',
    `color` VARCHAR(16) DEFAULT '#1890ff' COMMENT '显示颜色',
    `description` VARCHAR(256) COMMENT '分类说明',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY `uk_code` (`code`) COMMENT '分类编码唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术分类表';

2.2.9 话术效果表

-- ============================================================
-- 话术效果表 (utterance_performance)
-- 每小时聚合的话术使用效果
-- ============================================================
CREATE TABLE IF NOT EXISTS `utterance_performance` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `utterance_id` VARCHAR(64) NOT NULL COMMENT '话术ID',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `hour_window` VARCHAR(13) NOT NULL COMMENT '时间窗口,格式: 2025-01-15-14',
    `show_count` INT(11) DEFAULT 0 COMMENT '展示次数',
    `click_count` INT(11) DEFAULT 0 COMMENT '点击次数',
    `send_count` INT(11) DEFAULT 0 COMMENT '发送次数',
    `positive_count` INT(11) DEFAULT 0 COMMENT '正面反馈数',
    `negative_count` INT(11) DEFAULT 0 COMMENT '负面反馈数',
    `ignore_count` INT(11) DEFAULT 0 COMMENT '忽略次数',
    `conversion_count` INT(11) DEFAULT 0 COMMENT '转化次数',
    `ctr` DECIMAL(5,4) COMMENT '点击率',
    `conversion_rate` DECIMAL(5,4) COMMENT '转化率',
    `score` DECIMAL(6,4) COMMENT '综合评分(EWA算法)',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY `uk_utterance_hour` (`utterance_id`, `hour_window`) COMMENT '话术+时间窗口唯一',
    INDEX `idx_corp_hour` (`corp_id`, `hour_window`) COMMENT '企业+时间窗口'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='话术效果表';

2.2.10 推荐记录表

-- ============================================================
-- 推荐记录表 (recommendations)
-- 记录每次话术推荐的详细日志
-- ============================================================
CREATE TABLE IF NOT EXISTS `recommendations` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `recommendation_id` VARCHAR(128) NOT NULL COMMENT '推荐记录ID',
    `session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
    `turn_id` VARCHAR(128) COMMENT '轮次ID',
    `customer_id` VARCHAR(128) NOT NULL COMMENT '学员ID',
    `staff_id` VARCHAR(128) NOT NULL COMMENT '坐席ID',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `primary_intent` VARCHAR(32) COMMENT '主意图编码',
    `secondary_intents` TEXT COMMENT '辅意图列表,JSON字符串',
    `student_type` VARCHAR(32) COMMENT '学员类型',
    `current_stage` VARCHAR(32) COMMENT '当前阶段',
    `student_message` VARCHAR(512) COMMENT '学员消息',
    `recommended_count` INT(11) DEFAULT 0 COMMENT '推荐条数',
    `recall_sources` TEXT COMMENT '召回来源统计,JSON字符串',
    `generation_mode` VARCHAR(32) DEFAULT 'KB_ONLY' COMMENT '生成模式: KB_ONLY-话术库 KB_LLM_MIX-混合 LLM_ONLY-纯AI',
    `elapsed_ms` INT(11) COMMENT '推荐耗时(毫秒)',
    `staff_action` VARCHAR(32) DEFAULT 'NOT_SEEN' COMMENT '坐席操作: SENT-发送 EDITED-编辑后发送 IGNORED-忽略 NOT_SEEN-未查看',
    `feedback` VARCHAR(32) DEFAULT 'NONE' COMMENT '反馈: POSITIVE-正面 NEGATIVE-负面 NONE-无',
    `feedback_reason` VARCHAR(128) COMMENT '反馈原因',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `action_time` TIMESTAMP NULL COMMENT '操作时间',
    UNIQUE KEY `uk_rec_id` (`recommendation_id`) COMMENT '推荐ID唯一',
    INDEX `idx_session` (`session_id`, `created_at`) COMMENT '会话+时间',
    INDEX `idx_staff` (`staff_id`, `created_at`) COMMENT '坐席+时间',
    INDEX `idx_intent` (`primary_intent`, `created_at`) COMMENT '意图+时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐记录表';

2.2.11 知识库文档表

-- ============================================================
-- 知识库文档表 (knowledge_documents)
-- 存储知识库文档和FAQ
-- ============================================================
CREATE TABLE IF NOT EXISTS `knowledge_documents` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `doc_id` VARCHAR(64) NOT NULL COMMENT '文档唯一ID',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `title` VARCHAR(256) NOT NULL COMMENT '文档标题',
    `content` LONGTEXT COMMENT '文档内容',
    `content_text` LONGTEXT COMMENT '纯文本内容(用于全文检索)',
    `doc_type` VARCHAR(32) DEFAULT 'KNOWLEDGE' COMMENT '文档类型: KNOWLEDGE-知识 UTTERANCE-话术 FAQ-常见问题',
    `category` VARCHAR(64) COMMENT '分类',
    `tags` TEXT COMMENT '标签,JSON数组字符串',
    `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 INACTIVE-停用',
    `has_vector` TINYINT DEFAULT 0 COMMENT '是否已生成向量',
    `vector_updated_at` TIMESTAMP NULL COMMENT '向量更新时间',
    `created_by` VARCHAR(64) COMMENT '创建人',
    `updated_by` VARCHAR(64) COMMENT '更新人',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_doc_id` (`doc_id`) COMMENT '文档ID唯一',
    INDEX `idx_corp_type` (`corp_id`, `doc_type`, `status`) COMMENT '企业+类型+状态',
    FULLTEXT KEY `ft_content` (`content_text`) COMMENT '全文检索'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文档表';

2.2.12 员工表

-- ============================================================
-- 员工表 (staffs)
-- 企业坐席员工信息
-- ============================================================
CREATE TABLE IF NOT EXISTS `staffs` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `staff_id` VARCHAR(128) NOT NULL COMMENT '企微用户ID',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `name` VARCHAR(64) COMMENT '姓名',
    `avatar` VARCHAR(512) COMMENT '头像URL',
    `department` VARCHAR(128) COMMENT '部门',
    `role` VARCHAR(32) DEFAULT 'ADVISOR' COMMENT '角色: ADMIN-管理员 SUPERVISOR-主管 ADVISOR-顾问',
    `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-在职 INACTIVE-离职',
    `push_settings` TEXT COMMENT '推送设置,JSON字符串',
    `last_login_time` TIMESTAMP NULL COMMENT '最后登录时间',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_staff_corp` (`staff_id`, `corp_id`) COMMENT '员工+企业唯一',
    INDEX `idx_corp_role` (`corp_id`, `role`) COMMENT '企业+角色'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';

2.2.13 客户表

-- ============================================================
-- 客户表 (customers)
-- 外部联系人(学员)信息
-- ============================================================
CREATE TABLE IF NOT EXISTS `customers` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `customer_id` VARCHAR(128) NOT NULL COMMENT '外部联系人userid',
    `corp_id` VARCHAR(64) NOT NULL COMMENT '企业ID',
    `name` VARCHAR(64) COMMENT '姓名/昵称',
    `avatar` VARCHAR(512) COMMENT '头像URL',
    `type` VARCHAR(32) DEFAULT 'UNKNOWN' COMMENT '客户类型: UNKNOWN-未知 INDIVIDUAL-个人 PARENT-家长 ENTERPRISE-企业',
    `gender` VARCHAR(8) COMMENT '性别: MALE-男 FEMALE-女',
    `phone` VARCHAR(32) COMMENT '手机号',
    `unionid` VARCHAR(128) COMMENT '微信unionid',
    `add_time` TIMESTAMP NULL COMMENT '添加时间',
    `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-正常 BLOCKED-拉黑 DELETED-删除',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_customer_corp` (`customer_id`, `corp_id`) COMMENT '客户+企业唯一',
    INDEX `idx_corp_status` (`corp_id`, `status`) COMMENT '企业+状态'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';

2.2.14 管理员表

-- ============================================================
-- 管理员表 (admins)
-- 系统管理员
-- ============================================================
CREATE TABLE IF NOT EXISTS `admins` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `username` VARCHAR(64) NOT NULL COMMENT '用户名',
    `password` VARCHAR(256) NOT NULL COMMENT '密码(BCrypt加密)',
    `name` VARCHAR(64) COMMENT '姓名',
    `email` VARCHAR(128) COMMENT '邮箱',
    `phone` VARCHAR(32) COMMENT '手机号',
    `role` VARCHAR(32) DEFAULT 'ADMIN' COMMENT '角色: SUPER_ADMIN-超级管理员 ADMIN-管理员 OPERATOR-运营',
    `status` VARCHAR(32) DEFAULT 'ACTIVE' COMMENT '状态: ACTIVE-启用 DISABLED-停用',
    `last_login_time` TIMESTAMP NULL COMMENT '最后登录时间',
    `last_login_ip` VARCHAR(64) COMMENT '最后登录IP',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY `uk_username` (`username`) COMMENT '用户名唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';

2.2.15 操作日志表

-- ============================================================
-- 操作日志表 (operation_logs)
-- 系统操作审计日志
-- ============================================================
CREATE TABLE IF NOT EXISTS `operation_logs` (
    `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
    `operator_id` VARCHAR(64) NOT NULL COMMENT '操作人ID',
    `operator_name` VARCHAR(64) COMMENT '操作人姓名',
    `operator_type` VARCHAR(32) DEFAULT 'STAFF' COMMENT '操作人类型: STAFF-员工 ADMIN-管理员',
    `module` VARCHAR(64) COMMENT '操作模块',
    `action` VARCHAR(64) COMMENT '操作动作',
    `description` TEXT COMMENT '操作描述',
    `request_params` TEXT COMMENT '请求参数',
    `response_result` TEXT COMMENT '响应结果',
    `ip` VARCHAR(64) COMMENT '操作IP',
    `user_agent` VARCHAR(512) COMMENT 'User-Agent',
    `status` TINYINT DEFAULT 1 COMMENT '状态: 1成功 0失败',
    `elapsed_ms` INT(11) COMMENT '耗时(毫秒)',
    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    INDEX `idx_operator` (`operator_id`, `created_at`) COMMENT '操作人+时间',
    INDEX `idx_module` (`module`, `action`, `created_at`) COMMENT '模块+动作+时间',
    INDEX `idx_created_at` (`created_at`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';

2.3 初始化数据SQL

2.3.1 40个意图分类初始化

-- ============================================================
-- 初始化意图分类(40个场景)
-- 分类: 课程相关(8) + 价格相关(5) + 就业相关(7) + 师资相关(5) + 基础条件(4) + 校区相关(4) + 机构资质(3) + 其他(4)
-- ============================================================

-- 课程相关 (8个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-COURSE-01', '课程内容咨询', '课程相关', '询问具体学什么内容', 'P1', '["学什么","课程","内容","教什么","学什么软件"]', '["你们的课程具体学什么内容?","课程都教哪些软件?","能说一下课程体系吗?"]', 1),
('INT-COURSE-02', '课程选择建议', '课程相关', '不知道该选哪个方向', 'P1', '["选哪个","推荐","适合我","怎么选","方向"]', '["我应该学3D建模还是原画?","哪个方向好就业?","您能帮我推荐一下吗?"]', 1),
('INT-COURSE-03', '课程大纲索要', '课程相关', '想看详细课程安排', 'P1', '["大纲","课程表","安排","详细","阶段"]', '["能发一下课程大纲吗?","具体是怎么安排的?","每个阶段学什么?"]', 1),
('INT-COURSE-04', '试听预约咨询', '课程相关', '想试听或体验课程', 'P1', '["试听","体验","免费","公开课","试学"]', '["有免费试听吗?","怎么预约试听课?","可以体验一下吗?"]', 1),
('INT-COURSE-05', '课程更新迭代', '课程相关', '担心课程内容过时', 'P2', '["更新","过时","新版","跟上","行业"]', '["课程内容会更新吗?","跟得上行业吗?","用的是最新技术吗?"]', 1),
('INT-COURSE-06', '线上vs线下选择', '课程相关', '不知道选哪种学习方式', 'P1', '["线上","线下","面授","直播","网课","区别"]', '["线上和线下有什么区别?","线上课程效果好吗?","可以线上学习吗?"]', 1),
('INT-COURSE-07', '课程难度询问', '课程相关', '担心零基础能否学会', 'P1', '["零基础","能学会吗","难吗","没有基础","小白","能不能学会"]', '["零基础能学会吗?","我没有美术基础可以学吗?","课程难度大不大?"]', 1),
('INT-COURSE-08', '学习周期询问', '课程相关', '想知道学多久', 'P2', '["多久","多长时间","几个月","学多久","周期"]', '["课程要学多久?","多长时间能学会?","几个月可以学完?"]', 1);

-- 价格相关 (5个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-PRICE-01', '学费价格询问', '价格相关', '直接问学费多少', 'P1', '["多少钱","学费","价格","费用","收费","怎么收费"]', '["学费多少钱?","课程怎么收费?","一共多少钱?"]', 1),
('INT-PRICE-02', '优惠活动询问', '价格相关', '想知道有没有优惠', 'P1', '["优惠","活动","打折","减免","便宜","特价"]', '["现在有优惠活动吗?","能便宜点吗?","有没有折扣?"]', 1),
('INT-PRICE-03', '分期付款咨询', '价格相关', '关心付款方式', 'P2', '["分期","贷款","月付","首付","月供","花呗"]', '["可以分期付款吗?","有贷款方案吗?","月供多少?"]', 1),
('INT-PRICE-04', '退费政策询问', '价格相关', '关心退费保障', 'P2', '["退费","退款","退钱","学不好退","保障"]', '["学不好可以退费吗?","退费流程是怎样的?","有保障吗?"]', 1),
('INT-PRICE-05', '性价比比较', '价格相关', '和别家比较', 'P2', '["对比","比较","别家","其他机构","性价比","划算"]', '["你们的课程和XX比怎么样?","为什么价格比别人高?","性价比高吗?"]', 1);

-- 就业相关 (7个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-JOB-01', '就业方向咨询', '就业相关', '学完能做什么', 'P1', '["就业","工作","岗位","方向","做什么"]', '["学完能去哪些公司工作?","就业方向有哪些?","能做什么岗位?"]', 1),
('INT-JOB-02', '薪资水平询问', '就业相关', '关心工资待遇', 'P1', '["工资","薪资","月薪","收入","待遇","多少钱一个月"]', '["毕业后工资能拿多少?","行业薪资水平怎么样?","能挣多少?"]', 1),
('INT-JOB-03', '就业率询问', '就业相关', '关心能否找到工作', 'P1', '["就业率","找到工作","保证","推荐","找工作"]', '["就业率高吗?","能保证就业吗?","好找工作吗?"]', 1),
('INT-JOB-04', '就业服务了解', '就业相关', '了解就业推荐服务', 'P2', '["就业服务","推荐","怎么找工作","帮找工作"]', '["你们怎么帮学员找工作?","有就业推荐吗?","就业服务包含什么?"]', 1),
('INT-JOB-05', '合作企业询问', '就业相关', '想知道合作企业', 'P2', '["合作企业","大厂","腾讯","米哈游","网易","公司"]', '["合作企业有哪些?","有米哈游/腾讯这样的公司吗?","就业单位有哪些?"]', 1),
('INT-JOB-06', '作品集指导', '就业相关', '关心作品质量', 'P2', '["作品集","作品","项目","Demo","案例"]', '["学完能做出作品集吗?","作品质量怎么样?","能参与项目吗?"]', 1),
('INT-JOB-07', '实习机会咨询', '就业相关', '想参与真实项目', 'P3', '["实习","真实项目","实训","参与项目","实战"]', '["有实习机会吗?","可以参与真实项目吗?","有企业实训吗?"]', 1);

-- 师资相关 (5个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-TEACH-01', '师资背景询问', '师资相关', '想知道老师是谁', 'P2', '["老师","师资","讲师","谁教的","教学经验"]', '["老师是什么背景?","老师有项目经验吗?","谁来讲课?"]', 1),
('INT-TEACH-02', '教学模式了解', '师资相关', '想知道怎么上课', 'P2', '["怎么上课","教学方式","班型","一对一","多少人"]', '["是怎么上课的?","一个班多少人?","教学模式是怎样的?"]', 1),
('INT-TEACH-03', '课后答疑服务', '师资相关', '关心课后辅导', 'P2', '["答疑","辅导","课后","问问题","指导"]', '["课后有问题可以问老师吗?","答疑及时吗?","有课后辅导吗?"]', 1),
('INT-TEACH-04', '师生比例询问', '师资相关', '关心教学质量', 'P3', '["一个老师","带多少学生","比例","师生"]', '["一个老师带多少学生?","能照顾到每个人吗?","班级多大?"]', 1),
('INT-TEACH-05', '学习效果保障', '师资相关', '担心学不会怎么办', 'P2', '["学不会","怎么办","重修","保障","跟不上"]', '["学不会怎么办?","怎么保证学习效果?","跟不上怎么办?"]', 1);

-- 基础条件 (4个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-BASIC-01', '学历要求咨询', '基础条件', '学历是否够', 'P2', '["学历","要求","高中","大专","本科"]', '["需要什么学历?","高中生可以学吗?","学历要求高吗?"]', 1),
('INT-BASIC-02', '年龄限制询问', '基础条件', '年龄是否合适', 'P2', '["年龄","多大","几岁","限制","30岁"]', '["有没有年龄限制?","30岁了还能学吗?","年龄大能学会吗?"]', 1),
('INT-BASIC-03', '零基础入学', '基础条件', '零基础可行性', 'P1', '["零基础","没有基础","小白","完全不会","从来没"]', '["我没有美术基础可以学吗?","零基础能学会吗?","完全没接触过可以学吗?"]', 1),
('INT-BASIC-04', '证书颁发询问', '基础条件', '学完发什么证书', 'P3', '["证书","毕业证","结业证","证明","认证"]', '["学完发证书吗?","证书有用吗?","有毕业证吗?"]', 1);

-- 校区相关 (4个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-LOC-01', '校区地址询问', '校区相关', '想知道校区在哪', 'P2', '["校区","地址","在哪","位置","交通"]', '["校区在哪里?","附近有地铁吗?","具体地址是什么?"]', 1),
('INT-LOC-02', '校区参观预约', '校区相关', '想去参观', 'P2', '["参观","看看","实地考察","走访","预约"]', '["可以参观校区吗?","怎么预约参观?","能去看看吗?"]', 1),
('INT-LOC-03', '教学设备了解', '校区相关', '关心硬件条件', 'P3', '["设备","电脑","配置","硬件","机房"]', '["需要自备电脑吗?","校区提供设备吗?","电脑配置怎么样?"]', 1),
('INT-LOC-04', '住宿安排咨询', '校区相关', '外地学员住宿', 'P3', '["住宿","宿舍","外地","住哪","租房"]', '["外地学员有住宿吗?","住宿怎么安排?","有宿舍吗?"]', 1);

-- 机构资质 (3个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-QUAL-01', '机构正规性', '机构资质', '是否正规机构', 'P2', '["正规","资质","办学","合法","备案"]', '["你们是正规机构吗?","有办学资质吗?","是国家认可的吗?"]', 1),
('INT-QUAL-02', '口碑评价询问', '机构资质', '想看学员评价', 'P3', '["评价","口碑","反馈","怎么样","好吗"]', '["学员评价怎么样?","有老学员反馈吗?","口碑如何?"]', 1),
('INT-QUAL-03', '媒体报道了解', '机构资质', '想看权威背书', 'P3', '["报道","媒体","电视","新闻","央视"]', '["有上过电视报道吗?","媒体报道过吗?","央视那个是真的吗?"]', 1);

-- 其他 (4个)
INSERT INTO `intent_categories` (`code`, `name`, `domain`, `description`, `priority`, `trigger_keywords`, `sample_expressions`, `status`) VALUES
('INT-OTHER-01', '大师课咨询', '其他', '进阶课程', 'P3', '["大师课","研修班","进阶","艺术家","高级"]', '["大师课和普通课什么区别?","研修班怎么报名?","大师课学费多少?"]', 1),
('INT-OTHER-02', '企业培训定制', '其他', 'B端需求', 'P3', '["企业培训","定制","公司","团队","B端"]', '["有企业定制培训吗?","公司团队能报名吗?","企业内训怎么合作?"]', 1),
('INT-OTHER-03', '比赛活动咨询', '其他', 'GGAC等赛事', 'P3', '["比赛","GGAC","大赛","活动","赛事"]', '["GGAC比赛怎么参加?","有比赛推荐吗?","学员能参加大赛吗?"]', 1),
('INT-OTHER-04', '转班转学咨询', '其他', '变更需求', 'P3', '["转班","换方向","转学","调整","换校区"]', '["可以换课程方向吗?","可以换校区吗?","转班怎么办理?"]', 1);

2.3.2 8个话术分类初始化

-- ============================================================
-- 初始化话术分类(8个销售阶段)
-- ============================================================
INSERT INTO `utterance_categories` (`code`, `name`, `parent_code`, `sort_order`, `color`, `description`) VALUES
('STAGE-01', '开场白', 'ROOT', 1, '#52c41a', '首次接待学员/家长,建立信任,收集基础信息'),
('STAGE-02', '需求探询', 'ROOT', 2, '#1890ff', '了解学员背景和目标,精准画像,挖掘需求'),
('STAGE-03', '课程推荐', 'ROOT', 3, '#fa8c16', '匹配适合的课程方向,精准推荐1-2个方向'),
('STAGE-04', '价值塑造', 'ROOT', 4, '#722ed1', '强调优势和差异化,建立价值认同'),
('STAGE-05', '报价沟通', 'ROOT', 5, '#eb2f96', '价格说明和方案,化解价格顾虑'),
('STAGE-06', '异议处理', 'ROOT', 6, '#f5222d', '应对各类顾虑,消除障碍,建立信心'),
('STAGE-07', '促成报名', 'ROOT', 7, '#faad14', '推动转化成交,促成立即行动'),
('STAGE-08', '跟进维护', 'ROOT', 8, '#13c2c2', '未成交学员跟进,保持联系,等待时机');

2.3.3 20条核心话术初始化

-- ============================================================
-- 初始化核心话术(20条,覆盖所有关键场景)
-- 编号规则: STAGE阶段-课程方向-学员类型-序号
-- ============================================================

-- ===== 开场白 (2条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S01-001', 'default', '首次接待通用开场白', '您好!欢迎咨询第九联盟🎨 我是您的课程顾问老师。\n\n我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。\n\n请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍~', '您好!欢迎咨询第九联盟 我是您的课程顾问老师。我们专注CG数字艺术教育10年,累计培养8000+学员,与腾讯、米哈游、网易等500+企业有就业合作。请问您是想了解哪个方向的课程呢?是3D建模、原画设计、游戏动画还是特效?我可以为您详细介绍', 'ACTIVE', 1, '["STAGE-01"]', '["INT-COURSE-01","INT-COURSE-02"]', '["通用"]', 128, 0.8500, 'MANUAL'),
('UTT-S01-002', 'default', '老学员/二次咨询开场', '您好!欢迎再次咨询第九联盟🎨\n\n看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的【课程方向】还感兴趣吗?\n\n我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', '您好!欢迎再次咨询第九联盟 看到您之前了解过我们的课程,这次想深入哪个方向呢?上次考虑的课程方向还感兴趣吗?我们最近有一些新的课程升级和优惠活动,我给您介绍一下?', 'ACTIVE', 2, '["STAGE-01"]', '["INT-COURSE-01"]', '["老学员"]', 45, 0.7800, 'MANUAL');

-- ===== 需求探询 (3条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S02-001', 'default', '转行人员背景了解', '理解您想转行的想法!CG行业确实是一个非常好的选择。\n\n为了给您更精准的建议,想了解一下:\n1️⃣ 您目前从事什么行业?工作了多久?\n2️⃣ 有没有接触过美术或设计相关的软件?\n3️⃣ 是计划脱产学习还是周末学习?\n4️⃣ 有没有特别感兴趣的方向?\n\n我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢~', '理解您想转行的想法!CG行业确实是一个非常好的选择。为了给您更精准的建议,想了解一下:1.您目前从事什么行业?工作了多久?2.有没有接触过美术或设计相关的软件?3.是计划脱产学习还是周末学习?4.有没有特别感兴趣的方向?我们有很多和您情况类似的学员,都成功转行入行了,平均薪资在12-18K左右呢', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-COURSE-06"]', '["转行人员"]', 96, 0.7800, 'MANUAL'),
('UTT-S02-002', 'default', '在校生需求了解', '同学您好!很高兴为您介绍我们的课程~\n\n想先了解一下您的情况:\n1️⃣ 您现在是大几?什么专业呀?\n2️⃣ 毕业后是想直接就业还是考研?\n3️⃣ 之前接触过CG相关的软件吗?\n4️⃣ 有没有特别关注的发展方向?\n\n根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦~', '同学您好!很高兴为您介绍我们的课程。想先了解一下您的情况:1.您现在是大几?什么专业呀?2.毕业后是想直接就业还是考研?3.之前接触过CG相关的软件吗?4.有没有特别关注的发展方向?根据您的情况,我可以帮您规划最适合的学习路径!很多在校生提前学习,毕业后直接拿到大厂offer哦', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01"]', '["在校大学生"]', 72, 0.8200, 'MANUAL'),
('UTT-S02-003', 'default', '家长代询需求了解', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。\n\n为了更好地为您介绍,想了解一下:\n1️⃣ 孩子现在多大?是高中在读还是即将毕业?\n2️⃣ 之前有没有美术或绘画的基础?\n3️⃣ 孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)\n4️⃣ 是在本地学习还是可以到其他城市?\n\n我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障。', '您好!感谢您为孩子咨询我们的课程,这说明您非常关注孩子的未来发展。为了更好地为您介绍,想了解一下:1.孩子现在多大?是高中在读还是即将毕业?2.之前有没有美术或绘画的基础?3.孩子对哪个方向比较感兴趣?(3D建模/原画/动画/特效)4.是在本地学习还是可以到其他城市?我们有很多和您孩子情况类似的学员,都取得了很好的发展。我先给您详细介绍一下我们的课程体系和教学保障', 'ACTIVE', 1, '["STAGE-02"]', '["INT-COURSE-02","INT-BASIC-01","INT-BASIC-02"]', '["家长代询"]', 58, 0.7500, 'MANUAL');

-- ===== 课程推荐 (3条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `course_type_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S03-001', 'default', '推荐3D场景UE地编就业班(零基础)', '根据您的情况,我强烈推荐我们的【3D场景UE地编就业班】!\n\n💡 为什么适合您:\n✅ 零基础入学:从软件操作开始教,无需任何基础\n✅ 六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被\n✅ 所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等\n✅ 5个月培训+2个月企业实训,毕业就有项目经验\n✅ 就业方向:3D场景模型师、UE地编师、场景美术师\n\n📊 就业数据:往期学员平均薪资12-18K,就业推荐率95%+\n\n您看需要我发您详细的课程大纲和学员作品吗?', '根据您的情况,我强烈推荐我们的3D场景UE地编就业班!为什么适合您:零基础入学:从软件操作开始教,无需任何基础。六大模块系统学习:预科→道具→硬表面→建筑场景→UE场景→生态植被。所学软件:3DS MAX、MAYA、ZBrush、Speedtree、Unreal Engine等。5个月培训+2个月企业实训,毕业就有项目经验。就业方向:3D场景模型师、UE地编师、场景美术师。就业数据:往期学员平均薪资12-18K,就业推荐率95%+。您看需要我发您详细的课程大纲和学员作品吗?', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["转行人员","零基础"]', '["3D场景UE地编"]', 156, 0.7200, 'MANUAL'),
('UTT-S03-002', 'default', '推荐次世代角色模型就业班', '根据您有美术基础的情况,【次世代角色模型就业班】非常适合您!\n\n💡 课程亮点:\n✅ 所学软件:MD、ZB、MAYA、XGEN、PT等业界主流\n✅ 从角色道具建模→各类套装→角色模型制作→项目实训\n✅ 毕业至少完成3套角色模型作品\n✅ 就业方向:3D角色模型师、次世代角色设计师\n\n📊 行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+\n\n您的美术基础会让您在角色方向有很大优势,学习起来会更快上手!', '根据您有美术基础的情况,次世代角色模型就业班非常适合您!课程亮点:所学软件:MD、ZB、MAYA、XGEN、PT等业界主流。从角色道具建模→各类套装→角色模型制作→项目实训。毕业至少完成3套角色模型作品。就业方向:3D角色模型师、次世代角色设计师。行业数据:角色原画师平均薪资15,140元/月,每月招聘需求6.1万+。您的美术基础会让您在角色方向有很大优势,学习起来会更快上手', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础"]', '["次世代角色模型"]', 98, 0.7600, 'MANUAL'),
('UTT-S03-003', 'default', '推荐2D原画就业班', '了解到您有绘画基础,【2D原画就业班】是您的最佳选择!\n\n💡 课程特色:\n✅ 角色/场景双方向可选\n✅ 从基础到进阶系统教学,6-8个月完成全流程\n✅ 真实项目实训,毕业就有作品集\n✅ 就业方向:角色原画师、场景原画师、概念设计师、游戏主美\n\n📊 行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月\n\n有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂!', '了解到您有绘画基础,2D原画就业班是您的最佳选择!课程特色:角色/场景双方向可选。从基础到进阶系统教学,6-8个月完成全流程。真实项目实训,毕业就有作品集。就业方向:角色原画师、场景原画师、概念设计师、游戏主美。行业薪资:角色原画师平均15,140元/月,场景原画师平均14,972元/月。有绘画基础的同学在这个方向上优势非常明显,很多毕业就进了米哈游、网易这样的大厂', 'ACTIVE', 1, '["STAGE-03"]', '["INT-COURSE-01","INT-COURSE-02"]', '["有美术基础","在校大学生"]', '["2D原画"]', 82, 0.8000, 'MANUAL');

-- ===== 价值塑造 (3条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S04-001', 'default', '强调师资团队实力', '您关心师资说明您对学习质量很重视,这点非常关键!\n\n我们的师资团队可以说是行业顶配了:\n🏆 老师均来自腾讯、网易、EA等一线游戏公司\n🏆 平均8年以上项目经验+3年以上教学经验\n🏆 参与过《黑神话悟空》《王者荣耀》《原神》等知名项目\n\n🎓 特别值得一提的是我们的【大师研修班】:\n• 联合16位国内顶级CG艺术家授课\n• 包含《黑神话悟空》3D美术总监王琛老师\n• NVIDIA首席数字艺术家光叔·许喆隆老师\n• 开天工作室创始人罗其胜老师\n\n不仅是学技术,更是和行业内最顶尖的大佬面对面学习!', '您关心师资说明您对学习质量很重视,这点非常关键!我们的师资团队可以说是行业顶配了:老师均来自腾讯、网易、EA等一线游戏公司。平均8年以上项目经验+3年以上教学经验。参与过《黑神话悟空》《王者荣耀》《原神》等知名项目。特别值得一提的是我们的大师研修班:联合16位国内顶级CG艺术家授课。包含《黑神话悟空》3D美术总监王琛老师。NVIDIA首席数字艺术家光叔·许喆隆老师。开天工作室创始人罗其胜老师。不仅是学技术,更是和行业内最顶尖的大佬面对面学习', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-01","INT-TEACH-02"]', '["师资看重型"]', 112, 0.6800, 'MANUAL'),
('UTT-S04-002', 'default', '利用央视报道建立品牌信任', '关于我们机构的资质和实力,有一个特别值得骄傲的——\n\n📺 我们第九联盟被【CCTV2央视财经频道】《正点财经》栏目做了专题报道!\n\n能获得央视的认可,是因为:\n✅ 母公司点晴科技10年游戏美术制作经验\n✅ 与腾讯、网易、米哈游等大厂的深度合作\n✅ 自办GGAC全球游戏动漫美术概念大赛的行业影响力\n✅ 8000+学员的成功就业案例\n✅ 2023年度影响力教育品牌\n\n央视都认可的教育机构,您可以放心选择!', '关于我们机构的资质和实力,有一个特别值得骄傲的。我们第九联盟被CCTV2央视财经频道《正点财经》栏目做了专题报道!能获得央视的认可,是因为:母公司点晴科技10年游戏美术制作经验。与腾讯、网易、米哈游等大厂的深度合作。自办GGAC全球游戏动漫美术概念大赛的行业影响力。8000+学员的成功就业案例。2023年度影响力教育品牌。央视都认可的教育机构,您可以放心选择', 'ACTIVE', 1, '["STAGE-04"]', '["INT-QUAL-01","INT-QUAL-03"]', '["品牌信任型","家长代询"]', 89, 0.8000, 'MANUAL'),
('UTT-S04-003', 'default', '强调实战教学模式', '我们的教学模式是业内独一无二的"5+2"模式:\n\n📚 5个月技能培训 + 2个月企业实训\n\n这意味着什么?\n✅ 毕业时您已经有真实项目经验,不是零基础新人\n✅ 实训期间进入合作企业,表现优秀可直接留用\n✅ 简历上有实际项目,面试更有竞争力\n✅ 500+合作企业就业推荐网络\n\n相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!\n\n因为企业要的是能直接上手干活的人,而我们培养的就是这样的人。', '我们的教学模式是业内独一无二的5+2模式:5个月技能培训+2个月企业实训。这意味着什么?毕业时您已经有真实项目经验,不是零基础新人。实训期间进入合作企业,表现优秀可直接留用。简历上有实际项目,面试更有竞争力。500+合作企业就业推荐网络。相比只教理论的培训机构,我们的学员就业薪资平均高出20-30%!因为企业要的是能直接上手干活的人,而我们培养的就是这样的人', 'ACTIVE', 1, '["STAGE-04"]', '["INT-TEACH-02","INT-JOB-01"]', '["就业导向型"]', 76, 0.7400, 'MANUAL');

-- ===== 报价沟通 (2条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S05-001', 'default', '线下班学费说明+价值锚定', '我们的线下全日制面授班学费是💰29,800元。\n\n我理解价格是您需要考虑的重要因素,让我帮您算一笔账:\n\n📊 投入产出分析:\n• 学习周期:7个月(5个月培训+2个月实训)\n• 毕业后平均薪资:12-18K/月\n• 按最低12K算,3个月就回本了\n• 相比大学四年十几万学费,这个投入产出比是非常高的\n\n💡 而且您得到的是:\n✅ 一线大厂老师的面对面教学\n✅ 真实企业项目的实战经验\n✅ 500+合作企业的就业推荐\n✅ 终身职业规划服务\n\n这不仅仅是一门课程,更是进入游戏行业的职业通道。', '我们的线下全日制面授班学费是29,800元。我理解价格是您需要考虑的重要因素,让我帮您算一笔账:投入产出分析:学习周期:7个月(5个月培训+2个月实训)。毕业后平均薪资:12-18K/月。按最低12K算,3个月就回本了。相比大学四年十几万学费,这个投入产出比是非常高的。而且您得到的是:一线大厂老师的面对面教学。真实企业项目的实战经验。500+合作企业的就业推荐。终身职业规划服务。这不仅仅是一门课程,更是进入游戏行业的职业通道', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 134, 0.6500, 'MANUAL'),
('UTT-S05-002', 'default', '分期方案说明', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:\n\n💳 付款方案:\n✅ 12期分期付款,月供约2,500元\n✅ 支持花呗/信用卡分期\n✅ 早鸟优惠:提前报名减免2,000元\n✅ 团报优惠:2人同行各减1,000元\n\n🎁 限时福利:\n现在报名还赠送价值3,000元的预科课程!\n\n月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。\n\n您看哪种付款方式比较适合您?', '我们完全理解一次性付费有压力,所以我们提供多种灵活的付款方式:12期分期付款,月供约2,500元。支持花呗/信用卡分期。早鸟优惠:提前报名减免2,000元。团报优惠:2人同行各减1,000元。限时福利:现在报名还赠送价值3,000元的预科课程!月供2,500元,相当于每天80多块钱,一杯咖啡的钱,就能换来进入CG行业的机会。您看哪种付款方式比较适合您?', 'ACTIVE', 1, '["STAGE-05"]', '["INT-PRICE-03","INT-PRICE-02"]', '["价格敏感型"]', 108, 0.7000, 'MANUAL');

-- ===== 异议处理 (4条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S06-001', 'default', '应对"价格太贵"异议', '完全理解您的顾虑,投资学习确实需要慎重考虑。\n\n其实和您算一下,我们的性价比是非常高的:\n\n📈 横向对比:\n• 同类机构学费普遍在3-5万\n• 我们还包含2个月企业实训(其他机构一般没有)\n• 师资都是一线大厂出身\n\n💳 付款方案:\n• 支持12期分期,月供只要2,500多\n• 现在报名可享早鸟优惠,减免2,000元\n• 可以申请试听,满意后再决定\n\n🎁 限时福利:\n本周报名额外赠送价值3,000元的预科课程!\n\n您可以先来试听一下,亲身感受教学质量,再决定也不迟~', '完全理解您的顾虑,投资学习确实需要慎重考虑。其实和您算一下,我们的性价比是非常高的:横向对比:同类机构学费普遍在3-5万。我们还包含2个月企业实训(其他机构一般没有)。师资都是一线大厂出身。付款方案:支持12期分期,月供只要2,500多。现在报名可享早鸟优惠,减免2,000元。可以申请试听,满意后再决定。限时福利:本周报名额外赠送价值3,000元的预科课程!您可以先来试听一下,亲身感受教学质量,再决定也不迟', 'ACTIVE', 1, '["STAGE-06"]', '["INT-PRICE-01","INT-PRICE-05"]', '["价格敏感型"]', 142, 0.5800, 'MANUAL'),
('UTT-S06-002', 'default', '应对"零基础怕学不会"异议', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。\n\n🛡️ 我们的保障体系:\n\n1️⃣ 【预科课程】:正式开课前有免费预科,先打基础\n2️⃣ 【小班教学】:每班不超过25人,确保老师能照顾到每个人\n3️⃣ 【一对一辅导】:课后有问题随时问,老师一对一答疑\n4️⃣ 【阶段考核】:每个阶段都有测试,跟不上可以免费重修\n5️⃣ 【就业协议】:签订正式培训协议,保障您的权益\n\n👨‍🎓 真实案例:\n小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。\n\n只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', '这个担心完全可以理解!其实您知道吗,我们80%的学员都是零基础入门的。我们的保障体系:1.预科课程:正式开课前有免费预科,先打基础。2.小班教学:每班不超过25人,确保老师能照顾到每个人。3.一对一辅导:课后有问题随时问,老师一对一答疑。4.阶段考核:每个阶段都有测试,跟不上可以免费重修。5.就业协议:签订正式培训协议,保障您的权益。真实案例:小李,之前是做销售的,纯零基础,学完进了某游戏公司做场景模型,现在月薪15K。只要您有学习的决心,我们就有办法让您学会!要不要安排一节试听课,您亲自感受一下?', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-07","INT-TEACH-05"]', '["转行人员","零基础"]', 186, 0.7000, 'MANUAL'),
('UTT-S06-003', 'default', '应对"要和家人商量"异议', '完全理解!和家人商量是负责任的做法。\n\n我帮您整理一下关键信息,方便您和家人沟通:\n\n📋 第九联盟核心优势:\n✅ 母公司点晴科技10年游戏制作经验\n✅ CCTV2央视财经专题报道\n✅ 与腾讯、网易、米哈游等500+企业合作\n✅ 师资均来自一线大厂,8年+经验\n✅ 5个月培训+2个月企业实训\n✅ 签订正式培训协议,就业推荐\n\n📱 建议您可以:\n1. 先预约一节免费试听课,带家人一起来\n2. 我们的校区随时欢迎参观\n3. 有任何问题随时联系我\n\n对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款。', '完全理解!和家人商量是负责任的做法。我帮您整理一下关键信息,方便您和家人沟通:第九联盟核心优势:母公司点晴科技10年游戏制作经验。CCTV2央视财经专题报道。与腾讯、网易、米哈游等500+企业合作。师资均来自一线大厂,8年+经验。5个月培训+2个月企业实训。签订正式培训协议,就业推荐。建议您可以:先预约一节免费试听课,带家人一起来。我们的校区随时欢迎参观。有任何问题随时联系我。对了,目前的优惠活动截止日期是本月31日,建议可以先锁定名额,即使后续有变化也可以全额退款', 'ACTIVE', 1, '["STAGE-06"]', '["INT-COURSE-06","INT-QUAL-01"]', '["通用"]', 95, 0.6500, 'MANUAL'),
('UTT-S06-004', 'default', '应对"考虑一下"异议', '没问题,考虑清楚是对的!\n\n为了帮您更好地做决定,我可以:\n1. 发您一些学员的作品集和就业案例\n2. 安排一节免费的线上/线下试听课\n3. 给您发详细的课程大纲\n\n⏰ 友情提示:\n我们本期的优惠活动截止到【日期】,名额也有限。\n\n另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会~\n\n我帮您预约一个名额吧?', '没问题,考虑清楚是对的!为了帮您更好地做决定,我可以:1.发您一些学员的作品集和就业案例。2.安排一节免费的线上/线下试听课。3.给您发详细的课程大纲。友情提示:我们本期的优惠活动截止到XX日期,名额也有限。另外,这周六下午2点有一场免费的线上公开课《零基础如何入门3D建模》,您可以先来听听,不用做任何决定,就当多了解一个机会。我帮您预约一个名额吧?', 'ACTIVE', 2, '["STAGE-06"]', '["INT-COURSE-04"]', '["通用"]', 78, 0.6200, 'MANUAL');

-- ===== 促成报名 (2条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S07-001', 'default', '限时优惠促成报名', '和您聊了这么多,感觉您对我们的课程和前景都很认可!\n\n🎉 现在报名正是最好的时机:\n\n✅ 本月优惠:学费直减2,000元(仅剩最后3个名额)\n✅ 赠送价值3,000元的预科课程\n✅ 优先安排下期班级座位\n✅ 赠送全套学习资料包\n\n⏰ 优惠截止日期:本月31日\n\n建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!\n\n我这边帮您预留一个名额,您看可以吗?', '和您聊了这么多,感觉您对我们的课程和前景都很认可!现在报名正是最好的时机:本月优惠:学费直减2,000元(仅剩最后3个名额)。赠送价值3,000元的预科课程。优先安排下期班级座位。赠送全套学习资料包。优惠截止日期:本月31日。建议您今天就先锁定名额,即使后面计划有变,我们也有完善的退费政策,完全没有后顾之忧!我这边帮您预留一个名额,您看可以吗?', 'ACTIVE', 1, '["STAGE-07"]', '["INT-PRICE-02","INT-COURSE-04"]', '["高意向"]', 67, 0.4500, 'MANUAL'),
('UTT-S07-002', 'default', '试听后促成报名', '试听完感觉怎么样?是不是比想象中更容易上手?\n\n我们很多学员试听完最大的感受就是:"原来零基础也能做出这样的效果!"\n\n🎉 现在报名您还能享受:\n\n✅ 试听当日报名额外优惠1,000元\n✅ 赠送全套软件安装包和教学视频\n✅ 优先安排住宿(外地学员)\n✅ 加入学员群,提前认识同学\n\n您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续。', '试听完感觉怎么样?是不是比想象中更容易上手?我们很多学员试听完最大的感受就是:原来零基础也能做出这样的效果!现在报名您还能享受:试听当日报名额外优惠1,000元。赠送全套软件安装包和教学视频。优先安排住宿(外地学员)。加入学员群,提前认识同学。您看是选择12期分期还是一次性付款呢?我现在帮您办理报名手续', 'ACTIVE', 1, '["STAGE-07"]', '["INT-COURSE-04","INT-PRICE-03"]', '["高意向"]', 52, 0.5500, 'MANUAL');

-- ===== 跟进维护 (1条) =====
INSERT INTO `utterances` (`utterance_id`, `corp_id`, `title`, `content`, `content_text`, `status`, `priority`, `stage_tags`, `intent_tags`, `profile_tags`, `used_count`, `success_rate`, `source`) VALUES
('UTT-S08-001', 'default', '3天后未报名跟进', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?\n\n🎁 给您带来一个好消息:\n本周我们有一个【免费的线上公开课】,主题是"零基础如何入门3D建模",由我们资深讲师罗老师主讲。\n\n不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!\n\n📅 时间:本周六(1月18日)下午2:00\n📍 形式:线上直播\n\n我帮您预约一个名额吧,您那天有时间吗?', '您好!上次和您聊过之后,不知道您考虑得怎么样了呢?给您带来一个好消息:本周我们有一个免费的线上公开课,主题是零基础如何入门3D建模,由我们资深讲师罗老师主讲。不管您最后是否报名,这场公开课都会让您对行业和学习路径有更清晰的了解,完全免费的!时间:本周六下午2:00。形式:线上直播。我帮您预约一个名额吧,您那天有时间吗?', 'ACTIVE', 1, '["STAGE-08"]', '["INT-COURSE-04","INT-OTHER-03"]', '["中意向","低意向"]', 45, 0.3500, 'MANUAL');

2.3.4 系统管理员初始化

-- ============================================================
-- 初始化系统管理员
-- 密码: admin123(BCrypt加密后)
-- ============================================================
INSERT INTO `admins` (`username`, `password`, `name`, `email`, `role`, `status`, `created_at`) VALUES
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iOE1Qh6G', '系统管理员', 'admin@9artedu.com', 'SUPER_ADMIN', 'ACTIVE', NOW());
-- 默认密码: admin123,请在首次登录后立即修改

2.4 MySQL 5.7 JSON处理说明

由于MySQL 5.7不支持JSON类型,所有需要存储结构化数据的字段使用TEXT类型,在Java应用层进行序列化和反序列化。

2.4.1 Jackson序列化工具类

package com.artedu.common.util;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.extern.slf4j.Slf4j;

import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * JSON工具类 - 用于MySQL 5.7 TEXT字段的序列化/反序列化
 * 替代MySQL 8.0的JSON类型
 */
@Slf4j
public class JsonUtils {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    static {
        // 禁用日期时间戳输出,使用ISO格式
        OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }

    /**
     * 将对象序列化为JSON字符串
     */
    public static String toJson(Object obj) {
        if (obj == null) {
            return null;
        }
        try {
            return OBJECT_MAPPER.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            log.error("JSON序列化失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 将JSON字符串反序列化为对象
     */
    public static <T> T fromJson(String json, Class<T> clazz) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(json, clazz);
        } catch (JsonProcessingException e) {
            log.error("JSON反序列化失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 将JSON字符串反序列化为List
     */
    public static <T> List<T> fromJsonList(String json, Class<T> clazz) {
        if (json == null || json.isEmpty()) {
            return Collections.emptyList();
        }
        try {
            return OBJECT_MAPPER.readValue(json, OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz));
        } catch (JsonProcessingException e) {
            log.error("JSON反序列化为List失败: {}", e.getMessage(), e);
            return Collections.emptyList();
        }
    }

    /**
     * 将JSON字符串反序列化为Map
     */
    public static Map<String, Object> fromJsonMap(String json) {
        if (json == null || json.isEmpty()) {
            return Collections.emptyMap();
        }
        try {
            return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
        } catch (JsonProcessingException e) {
            log.error("JSON反序列化为Map失败: {}", e.getMessage(), e);
            return Collections.emptyMap();
        }
    }

    /**
     * 安全获取JSON字符串(防止null)
     */
    public static String safeJson(Object obj) {
        String json = toJson(obj);
        return json != null ? json : "{}";
    }

    /**
     * 安全解析JSON字符串(防止null)
     */
    public static String safeParse(String json) {
        return json != null && !json.isEmpty() ? json : "{}";
    }
}

2.4.2 MyBatis TypeHandler(自动序列化/反序列化)

package com.artedu.common.mybatis;

import com.artedu.common.util.JsonUtils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * MyBatis JSON类型处理器 - 用于MySQL 5.7 TEXT字段
 * 自动将Java对象序列化为JSON字符串存入TEXT字段
 * 读取时自动将JSON字符串反序列化为Java对象
 *
 * 使用方法:
 * 1. 在实体类字段上添加 @TableField(typeHandler = JsonTypeHandler.class)
 * 2. 或在application.yml中配置默认类型处理器
 */
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(Object.class)
public class JsonTypeHandler extends BaseTypeHandler<Object> {

    private final Class<?> type;

    public JsonTypeHandler(Class<?> type) {
        if (type == null) {
            throw new IllegalArgumentException("Type argument cannot be null");
        }
        this.type = type;
    }

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, JsonUtils.toJson(parameter));
    }

    @Override
    public Object getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String json = rs.getString(columnName);
        return parseJson(json);
    }

    @Override
    public Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String json = rs.getString(columnIndex);
        return parseJson(json);
    }

    @Override
    public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String json = cs.getString(columnIndex);
        return parseJson(json);
    }

    private Object parseJson(String json) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        return JsonUtils.fromJson(json, type);
    }
}

第三部分:后端服务详细实现

3.1 公共模块(common)

所有服务共享的公共代码,通过Maven依赖引入。

3.1.1 统一响应结构 Result

package com.artedu.common.result;

import lombok.Data;

import java.io.Serializable;

/**
 * 统一API响应结构
 * 所有接口返回此结构,确保前端处理一致
 *
 * @param <T> 响应数据类型
 */
@Data
public class Result<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 状态码: 0-成功 其他-失败
     */
    private Integer code;

    /**
     * 提示消息
     */
    private String message;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 时间戳
     */
    private Long timestamp;

    public Result() {
        this.timestamp = System.currentTimeMillis();
    }

    /**
     * 成功响应
     */
    public static <T> Result<T> success() {
        Result<T> result = new Result<>();
        result.setCode(0);
        result.setMessage("success");
        return result;
    }

    /**
     * 成功响应(带数据)
     */
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(0);
        result.setMessage("success");
        result.setData(data);
        return result;
    }

    /**
     * 成功响应(带数据和消息)
     */
    public static <T> Result<T> success(T data, String message) {
        Result<T> result = new Result<>();
        result.setCode(0);
        result.setMessage(message);
        result.setData(data);
        return result;
    }

    /**
     * 失败响应
     */
    public static <T> Result<T> fail(String message) {
        Result<T> result = new Result<>();
        result.setCode(-1);
        result.setMessage(message);
        return result;
    }

    /**
     * 失败响应(带状态码)
     */
    public static <T> Result<T> fail(int code, String message) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        return result;
    }

    /**
     * 是否成功
     */
    public boolean isSuccess() {
        return this.code != null && this.code == 0;
    }
}

3.1.2 分页响应 PageResult

package com.artedu.common.result;

import lombok.Data;

import java.io.Serializable;
import java.util.Collections;
import java.util.List;

/**
 * 分页响应结构
 */
@Data
public class PageResult<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 当前页
     */
    private Integer page;

    /**
     * 每页大小
     */
    private Integer size;

    /**
     * 总记录数
     */
    private Long total;

    /**
     * 总页数
     */
    private Integer totalPages;

    /**
     * 数据列表
     */
    private List<T> list;

    public static <T> PageResult<T> of(Integer page, Integer size, Long total, List<T> list) {
        PageResult<T> result = new PageResult<>();
        result.setPage(page);
        result.setSize(size);
        result.setTotal(total);
        result.setTotalPages((int) Math.ceil((double) total / size));
        result.setList(list != null ? list : Collections.emptyList());
        return result;
    }
}

3.1.3 全局异常处理

package com.artedu.common.exception;

import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 * 统一处理控制器层抛出的异常,转换为标准响应格式
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    /**
     * 处理参数校验异常
     */
    @ExceptionHandler(BindException.class)
    public Result<Void> handleBindException(BindException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .findFirst()
                .orElse("参数校验失败");
        log.warn("参数校验失败: {}", message);
        return Result.fail(400, message);
    }

    /**
     * 处理其他未知异常
     */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常: {}", e.getMessage(), e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

3.1.4 业务异常类

package com.artedu.common.exception;

import lombok.Getter;

/**
 * 业务异常
 * 用于抛出可预期的业务逻辑错误
 */
@Getter
public class BusinessException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    /**
     * 错误码
     */
    private final int code;

    public BusinessException(String message) {
        super(message);
        this.code = -1;
    }

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
        this.code = -1;
    }
}

3.1.5 MyBatis-Plus 分页配置

package com.artedu.common.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * MyBatis-Plus配置
 * 启用分页插件(MySQL 5.7方言)
 */
@Configuration
public class MyBatisPlusConfig {

    /**
     * 分页插件配置
     * DbType.MYSQL 指定MySQL方言,确保分页SQL正确生成
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

3.1.6 Redis配置

package com.artedu.common.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis配置
 * 配置Key和Value的序列化方式
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // Key使用String序列化
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);

        // Value使用JSON序列化(带类型信息)
        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper);
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

3.1.7 RabbitMQ配置

package com.artedu.common.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * RabbitMQ配置
 * 定义消息队列、交换机和绑定关系
 */
@Configuration
public class RabbitConfig {

    // ========== 交换机定义 ==========

    /**
     * 存档消息交换机 - Direct类型
     */
    public static final String ARCHIVE_EXCHANGE = "archive.exchange";

    /**
     * 对话事件交换机 - Topic类型
     */
    public static final String CONVERSATION_EXCHANGE = "conversation.exchange";

    // ========== 队列定义 ==========

    /**
     * 新消息队列 - 存档服务→对话服务
     */
    public static final String QUEUE_NEW_MESSAGE = "archive.new_message.queue";

    /**
     * 意图分析队列 - 对话服务→意图服务
     */
    public static final String QUEUE_INTENT_ANALYZE = "conversation.intent_analyze.queue";

    /**
     * 话术推荐队列 - 意图服务→推荐服务
     */
    public static final String QUEUE_RECOMMEND = "intent.recommend.queue";

    // ========== 路由键定义 ==========

    public static final String ROUTING_KEY_NEW_MESSAGE = "archive.message.new";
    public static final String ROUTING_KEY_INTENT_ANALYZE = "conversation.intent.analyze";
    public static final String ROUTING_KEY_RECOMMEND = "intent.recommend";

    @Bean
    public DirectExchange archiveExchange() {
        return new DirectExchange(ARCHIVE_EXCHANGE, true, false);
    }

    @Bean
    public TopicExchange conversationExchange() {
        return new TopicExchange(CONVERSATION_EXCHANGE, true, false);
    }

    @Bean
    public Queue newMessageQueue() {
        return QueueBuilder.durable(QUEUE_NEW_MESSAGE)
                .withArgument("x-dead-letter-exchange", "")
                .withArgument("x-dead-letter-routing-key", QUEUE_NEW_MESSAGE + ".dlq")
                .build();
    }

    @Bean
    public Queue intentAnalyzeQueue() {
        return QueueBuilder.durable(QUEUE_INTENT_ANALYZE)
                .withArgument("x-dead-letter-exchange", "")
                .withArgument("x-dead-letter-routing-key", QUEUE_INTENT_ANALYZE + ".dlq")
                .build();
    }

    @Bean
    public Queue recommendQueue() {
        return QueueBuilder.durable(QUEUE_RECOMMEND)
                .withArgument("x-dead-letter-exchange", "")
                .withArgument("x-dead-letter-routing-key", QUEUE_RECOMMEND + ".dlq")
                .build();
    }

    @Bean
    public Binding newMessageBinding() {
        return BindingBuilder.bind(newMessageQueue())
                .to(archiveExchange())
                .with(ROUTING_KEY_NEW_MESSAGE);
    }

    @Bean
    public Binding intentAnalyzeBinding() {
        return BindingBuilder.bind(intentAnalyzeQueue())
                .to(conversationExchange())
                .with(ROUTING_KEY_INTENT_ANALYZE);
    }

    @Bean
    public Binding recommendBinding() {
        return BindingBuilder.bind(recommendQueue())
                .to(conversationExchange())
                .with(ROUTING_KEY_RECOMMEND);
    }
}

3.1.8 跨域配置

package com.artedu.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * 跨域配置
 * 允许企微H5侧边栏跨域访问API
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        // 允许企微域名
        config.addAllowedOriginPattern("*");
        // 允许携带凭证(cookies)
        config.setAllowCredentials(true);
        // 允许所有请求头
        config.addAllowedHeader("*");
        // 允许所有请求方法
        config.addAllowedMethod("*");
        // 暴露响应头
        config.addExposedHeader("Authorization");
        config.addExposedHeader("X-Request-Id");
        // 预检请求缓存时间(1小时)
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }
}

3.1.9 通用实体基类

package com.artedu.common.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 通用实体基类
 * 所有实体类继承此类,自动填充创建时间和更新时间
 */
@Data
public class BaseEntity {

    /**
     * 主键ID(自增)
     */
    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 创建时间(自动填充)
     */
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;

    /**
     * 更新时间(自动填充)
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;
}

3.1.10 自动填充处理器

package com.artedu.common.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * MyBatis-Plus字段自动填充处理器
 * 自动填充created_at和updated_at字段
 */
@Slf4j
@Component
public class AutoFillHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
    }
}

3.2 认证授权服务(auth-service)

负责企微OAuth2登录、JWT签发和权限控制。

3.2.1 启动类

package com.artedu.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

/**
 * 认证授权服务启动类
 */
@SpringBootApplication(scanBasePackages = {"com.artedu.auth", "com.artedu.common"})
@EnableDiscoveryClient
public class AuthServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthServiceApplication.class, args);
    }
}

3.2.2 企微OAuth2 Service

package com.artedu.auth.service;

import com.artedu.auth.entity.Staff;
import com.artedu.auth.mapper.StaffMapper;
import com.artedu.common.exception.BusinessException;
import com.artedu.common.util.JsonUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 企业微信OAuth2认证服务
 * 处理企微登录流程,获取用户信息
 */
@Slf4j
@Service
public class WeComOAuthService {

    @Value("${wecom.corp-id}")
    private String corpId;

    @Value("${wecom.agent-id}")
    private String agentId;

    @Value("${wecom.secret}")
    private String secret;

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private StaffMapper staffMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String ACCESS_TOKEN_KEY = "wecom:access_token";
    private static final String USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token={accessToken}&code={code}";
    private static final String USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={accessToken}&userid={userId}";

    /**
     * 获取企微AccessToken(带缓存)
     */
    public String getAccessToken() {
        // 先从Redis获取
        String token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
        if (token != null) {
            return token;
        }

        // 从企微API获取
        String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());

        if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
            log.error("获取AccessToken失败: {}", response.getBody());
            throw new BusinessException("获取企微AccessToken失败");
        }

        token = (String) result.get("access_token");
        // 缓存7200秒(企微token有效期2小时)
        redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
        return token;
    }

    /**
     * 通过OAuth Code获取用户ID
     */
    public String getUserIdByCode(String code) {
        String accessToken = getAccessToken();
        String url = USER_INFO_URL.replace("{accessToken}", accessToken).replace("{code}", code);
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());

        if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
            log.error("获取用户信息失败: {}", response.getBody());
            throw new BusinessException("OAuth认证失败,请重新登录");
        }

        String userId = (String) result.get("UserId");
        if (userId == null) {
            throw new BusinessException("获取用户ID失败,可能不在企业通讯录中");
        }
        return userId;
    }

    /**
     * 获取用户详细信息
     */
    public Map<String, Object> getUserDetail(String userId) {
        String accessToken = getAccessToken();
        String url = USER_DETAIL_URL.replace("{accessToken}", accessToken).replace("{userId}", userId);
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());

        if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
            log.error("获取用户详情失败: {}", response.getBody());
            throw new BusinessException("获取用户信息失败");
        }
        return result;
    }

    /**
     * 处理登录:OAuth Code → 用户信息 → JWT Token
     */
    public Staff handleLogin(String code) {
        // 1. 通过Code获取UserId
        String userId = getUserIdByCode(code);

        // 2. 获取用户详情
        Map<String, Object> userDetail = getUserDetail(userId);

        // 3. 查询或创建本地用户
        Staff staff = staffMapper.selectOne(
                new LambdaQueryWrapper<Staff>()
                        .eq(Staff::getStaffId, userId)
                        .eq(Staff::getCorpId, corpId)
        );

        if (staff == null) {
            // 新用户,创建记录
            staff = new Staff();
            staff.setStaffId(userId);
            staff.setCorpId(corpId);
            staff.setName((String) userDetail.get("name"));
            staff.setAvatar((String) userDetail.get("avatar"));
            staff.setDepartment(formatDepartment(userDetail.get("department")));
            staff.setRole("ADVISOR");
            staff.setStatus("ACTIVE");
            staffMapper.insert(staff);
            log.info("新用户注册: userId={}, name={}", userId, staff.getName());
        } else {
            // 更新用户信息
            staff.setName((String) userDetail.get("name"));
            staff.setAvatar((String) userDetail.get("avatar"));
            staffMapper.updateById(staff);
        }

        return staff;
    }

    /**
     * 格式化部门信息
     */
    private String formatDepartment(Object department) {
        if (department == null) {
            return "";
        }
        return JsonUtils.toJson(department);
    }
}

3.2.3 JWT Service

package com.artedu.auth.service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * JWT Token服务
 * 负责生成和验证JWT Token
 */
@Slf4j
@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration:86400}")
    private Long expiration; // 默认24小时

    /**
     * 生成SecretKey
     */
    private SecretKey getSecretKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * 生成JWT Token
     *
     * @param userId   用户ID
     * @param corpId   企业ID
     * @param userName 用户姓名
     * @param role     角色
     * @return JWT Token字符串
     */
    public String generateToken(String userId, String corpId, String userName, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("corpId", corpId);
        claims.put("userName", userName);
        claims.put("role", role);
        claims.put("type", "access");

        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration * 1000);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userId)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(getSecretKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * 验证并解析Token
     */
    public Claims validateToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 从Token中获取用户ID
     */
    public String getUserIdFromToken(String token) {
        Claims claims = validateToken(token);
        return claims.get("userId", String.class);
    }

    /**
     * 检查Token是否过期
     */
    public boolean isTokenExpired(String token) {
        try {
            Claims claims = validateToken(token);
            return claims.getExpiration().before(new Date());
        } catch (Exception e) {
            return true;
        }
    }

    /**
     * 获取Token剩余有效时间(秒)
     */
    public long getExpirationSeconds(String token) {
        try {
            Claims claims = validateToken(token);
            long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
            return Math.max(remain / 1000, 0);
        } catch (Exception e) {
            return 0;
        }
    }
}

3.2.4 Auth Controller

package com.artedu.auth.controller;

import com.artedu.auth.entity.Staff;
import com.artedu.auth.service.JwtService;
import com.artedu.auth.service.WeComOAuthService;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
 * 认证授权控制器
 * 处理企微OAuth登录和Token刷新
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    @Autowired
    private WeComOAuthService weComOAuthService;

    @Autowired
    private JwtService jwtService;

    /**
     * 企微OAuth登录
     *
     * @param code 企微OAuth授权码
     * @return JWT Token和用户信息
     */
    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestParam("code") String code) {
        log.info("企微OAuth登录, code={}", code);

        // 1. 通过OAuth Code获取用户信息
        Staff staff = weComOAuthService.handleLogin(code);

        // 2. 生成JWT Token
        String token = jwtService.generateToken(
                staff.getStaffId(),
                staff.getCorpId(),
                staff.getName(),
                staff.getRole()
        );

        // 3. 返回Token和用户信息
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("tokenType", "Bearer");
        result.put("expiresIn", 86400);
        result.put("userId", staff.getStaffId());
        result.put("userName", staff.getName());
        result.put("avatar", staff.getAvatar());
        result.put("role", staff.getRole());

        log.info("登录成功: userId={}, name={}", staff.getStaffId(), staff.getName());
        return Result.success(result);
    }

    /**
     * 刷新Token
     */
    @PostMapping("/refresh")
    public Result<Map<String, Object>> refreshToken(@RequestHeader("Authorization") String authHeader) {
        String oldToken = authHeader.replace("Bearer ", "");

        // 验证旧Token
        if (jwtService.isTokenExpired(oldToken)) {
            return Result.fail("Token已过期,请重新登录");
        }

        // 解析旧Token信息
        io.jsonwebtoken.Claims claims = jwtService.validateToken(oldToken);
        String userId = claims.get("userId", String.class);
        String corpId = claims.get("corpId", String.class);
        String userName = claims.get("userName", String.class);
        String role = claims.get("role", String.class);

        // 生成新Token
        String newToken = jwtService.generateToken(userId, corpId, userName, role);

        Map<String, Object> result = new HashMap<>();
        result.put("token", newToken);
        result.put("tokenType", "Bearer");
        result.put("expiresIn", 86400);

        return Result.success(result);
    }

    /**
     * 获取当前登录用户信息
     */
    @GetMapping("/info")
    public Result<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
        String token = authHeader.replace("Bearer ", "");
        io.jsonwebtoken.Claims claims = jwtService.validateToken(token);

        Map<String, Object> result = new HashMap<>();
        result.put("userId", claims.get("userId"));
        result.put("userName", claims.get("userName"));
        result.put("corpId", claims.get("corpId"));
        result.put("role", claims.get("role"));

        return Result.success(result);
    }

    /**
     * JS-SDK签名接口
     * 用于前端调用ww.register时获取签名
     */
    @GetMapping("/signature")
    public Result<Map<String, Object>> getJsSdkSignature(
            @RequestParam("url") String url,
            @RequestHeader("Authorization") String authHeader) {

        String token = authHeader.replace("Bearer ", "");
        io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
        String corpId = claims.get("corpId", String.class);

        // 调用企微API获取jsapi_ticket
        String ticket = weComOAuthService.getJsApiTicket();
        String nonceStr = java.util.UUID.randomUUID().toString().replace("-", "");
        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);

        // 拼接签名字符串
        String signature = generateSignature(ticket, nonceStr, timestamp, url);

        Map<String, Object> result = new HashMap<>();
        result.put("corpId", corpId);
        result.put("agentId", "1000002"); // 替换为实际agentId
        result.put("nonceStr", nonceStr);
        result.put("timestamp", timestamp);
        result.put("signature", signature);

        return Result.success(result);
    }

    /**
     * 生成JS-SDK签名
     */
    private String generateSignature(String ticket, String nonceStr, String timestamp, String url) {
        String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
        try {
            java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1");
            digest.update(string1.getBytes());
            byte[] messageDigest = digest.digest();
            StringBuilder hexString = new StringBuilder();
            for (byte b : messageDigest) {
                String shaHex = Integer.toHexString(b & 0xFF);
                if (shaHex.length() < 2) {
                    hexString.append(0);
                }
                hexString.append(shaHex);
            }
            return hexString.toString();
        } catch (Exception e) {
            log.error("生成签名失败", e);
            return "";
        }
    }
}

3.2.5 Staff实体类

package com.artedu.auth.entity;

import com.artedu.common.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 员工实体
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("staffs")
public class Staff extends BaseEntity {

    private static final long serialVersionUID = 1L;

    /**
     * 企微用户ID
     */
    private String staffId;

    /**
     * 企业ID
     */
    private String corpId;

    /**
     * 姓名
     */
    private String name;

    /**
     * 头像URL
     */
    private String avatar;

    /**
     * 部门
     */
    private String department;

    /**
     * 角色
     */
    private String role;

    /**
     * 状态
     */
    private String status;

    /**
     * 推送设置(JSON字符串)
     */
    private String pushSettings;

    /**
     * 最后登录时间
     */
    private java.time.LocalDateTime lastLoginTime;
}

3.2.6 Staff Mapper

package com.artedu.auth.mapper;

import com.artedu.auth.entity.Staff;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

/**
 * 员工数据访问层
 */
@Mapper
public interface StaffMapper extends BaseMapper<Staff> {
}

3.2.7 application.yml

server:
  port: 8081

spring:
  application:
    name: auth-service
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
    username: ${MYSQL_USERNAME:root}
    password: ${MYSQL_PASSWORD:root}
  redis:
    host: ${REDIS_HOST:localhost}
    port: ${REDIS_PORT:6379}
    password: ${REDIS_PASSWORD:}
    database: 0
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_HOST:localhost}:${NACOS_PORT:8848}

# 企微配置
wecom:
  corp-id: ${WECOM_CORP_ID:}
  agent-id: ${WECOM_AGENT_ID:}
  secret: ${WECOM_SECRET:}

# JWT配置
jwt:
  secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters}
  expiration: 86400

# MyBatis-Plus
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0

3.3 会话存档服务(archive-service)

负责接收企微回调、拉取存档消息、RSA解密和存储。

3.3.1 启动类

package com.artedu.archive;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

/**
 * 会话存档服务启动类
 */
@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"})
@EnableDiscoveryClient
public class ArchiveServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ArchiveServiceApplication.class, args);
    }
}

3.3.2 JNA接口定义(调用企微C SDK)

package com.artedu.archive.sdk;

import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;

import java.util.Arrays;
import java.util.List;

/**
 * 企微会话存档C SDK的JNA接口定义
 * 通过JNA调用本地动态库(.so/.dll)
 */
public interface WeWorkFinanceSdk extends Library {

    // 加载本地动态库
    WeWorkFinanceSdk INSTANCE = Native.load(
            System.getProperty("os.name").toLowerCase().contains("win") ? "WeWorkFinanceSdk" : "WeWorkFinanceSdk",
            WeWorkFinanceSdk.class
    );

    /**
     * 初始化SDK
     *
     * @return SDK指针
     */
    long NewSdk();

    /**
     * 初始化SDK配置
     *
     * @param sdk    SDK指针
     * @param corpId 企业ID
     * @param secret 存档Secret
     * @return 0表示成功
     */
    int Init(long sdk, String corpId, String secret);

    /**
     * 拉取聊天记录
     *
     * @param sdk     SDK指针
     * @param seq     起始序列号
     * @param limit   拉取条数(最大1000)
     * @param proxy   代理地址
     * @param passwd  代理密码
     * @param timeout 超时时间(秒)
     * @param chatData 返回数据切片
     * @return 0表示成功
     */
    int GetChatData(long sdk, long seq, int limit, String proxy, String passwd, int timeout, long chatData);

    /**
     * 解密数据
     *
     * @param sdk           SDK指针
     * @param encryptKey    解密密钥
     * @param encryptMsg    加密消息
     * @param msg          返回解密后的消息切片
     * @return 0表示成功
     */
    int DecryptData(long sdk, String encryptKey, String encryptMsg, long msg);

    /**
     * 释放SDK资源
     */
    int DestroySdk(long sdk);

    /**
     * 释放切片
     */
    void FreeSlice(long slice);

    /**
     * 数据切片结构
     */
    class Slice_t extends Structure {
        public Pointer content;
        public int len;

        @Override
        protected List<String> getFieldOrder() {
            return Arrays.asList("content", "len");
        }
    }
}

3.3.3 RSA解密工具类

package com.artedu.archive.util;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;

/**
 * RSA解密工具类
 * 用于解密企微会话存档的encrypt_random_key
 */
@Slf4j
@Component
public class RsaDecryptUtil {

    private static final String ALGORITHM = "RSA";
    private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";

    /**
     * 从Base64编码的私钥字符串加载私钥
     */
    public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception {
        byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
        return keyFactory.generatePrivate(spec);
    }

    /**
     * RSA私钥解密
     *
     * @param encryptedData      Base64编码的加密数据
     * @param base64PrivateKey   Base64编码的私钥
     * @return 解密后的字符串
     */
    public String decrypt(String encryptedData, String base64PrivateKey) {
        try {
            PrivateKey privateKey = loadPrivateKey(base64PrivateKey);
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.DECRYPT_MODE, privateKey);
            byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
            byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
            return new String(decryptedBytes, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("RSA解密失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 生成RSA密钥对(用于测试或初始化)
     */
    public static void generateKeyPair() throws Exception {
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
        keyGen.initialize(2048);
        KeyPair keyPair = keyGen.generateKeyPair();

        String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
        String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());

        log.info("=== RSA公钥(上传到企微后台) ===");
        log.info(publicKey);
        log.info("=== RSA私钥(安全保存) ===");
        log.info(privateKey);
    }
}

3.3.4 存档消息实体类

package com.artedu.archive.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 存档消息实体
 * 对应archive_messages表
 */
@Data
@TableName("archive_messages")
public class ArchiveMessage {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 企微消息唯一ID
     */
    private String msgid;

    /**
     * 存档序列号
     */
    private Long seq;

    /**
     * 企业ID
     */
    private String corpId;

    /**
     * 动作类型
     */
    private String action;

    /**
     * 发送者
     */
    private String fromUser;

    /**
     * 发送者角色
     */
    private String fromRole;

    /**
     * 接收者
     */
    private String toUser;

    /**
     * 接收者列表(JSON字符串)
     */
    private String tolist;

    /**
     * 群聊ID
     */
    private String roomid;

    /**
     * 消息类型
     */
    private String msgtype;

    /**
     * 消息时间(毫秒时间戳)
     */
    private Long msgtime;

    /**
     * 消息内容
     */
    private String content;

    /**
     * 媒体数据(JSON字符串)
     */
    private String mediaData;

    /**
     * 会话ID
     */
    private String sessionId;

    /**
     * 解密状态
     */
    private Integer decryptStatus;

    /**
     * 解密错误信息
     */
    private String decryptError;

    /**
     * 创建时间
     */
    private LocalDateTime createdAt;

    /**
     * 更新时间
     */
    private LocalDateTime updatedAt;
}

3.3.5 存档消息Mapper

package com.artedu.archive.mapper;

import com.artedu.archive.entity.ArchiveMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

/**
 * 存档消息数据访问层
 */
@Mapper
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {

    /**
     * 查询最大序列号
     */
    @Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}")
    Long selectMaxSeq(@Param("corpId") String corpId);

    /**
     * 根据msgid查询是否存在
     */
    @Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}")
    int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId);
}

3.3.6 存档拉取服务

package com.artedu.archive.service;

import com.artedu.archive.entity.ArchiveMessage;
import com.artedu.archive.mapper.ArchiveMessageMapper;
import com.artedu.archive.sdk.WeWorkFinanceSdk;
import com.artedu.archive.util.RsaDecryptUtil;
import com.artedu.common.config.RabbitConfig;
import com.artedu.common.util.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 存档拉取服务
 * 通过C SDK拉取企微会话存档消息
 */
@Slf4j
@Service
public class ArchivePullService {

    @Value("${wecom.archive.corp-id}")
    private String corpId;

    @Value("${wecom.archive.secret}")
    private String secret;

    @Value("${wecom.archive.rsa-private-key:}")
    private String rsaPrivateKey;

    @Autowired
    private ArchiveMessageMapper archiveMessageMapper;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RsaDecryptUtil rsaDecryptUtil;

    private long sdk;
    private static final String LAST_SEQ_KEY = "archive:last_seq:";

    /**
     * 初始化SDK
     */
    @PostConstruct
    public void init() {
        sdk = WeWorkFinanceSdk.INSTANCE.NewSdk();
        int ret = WeWorkFinanceSdk.INSTANCE.Init(sdk, corpId, secret);
        if (ret != 0) {
            log.error("企微存档SDK初始化失败, ret={}", ret);
            throw new RuntimeException("SDK初始化失败: " + ret);
        }
        log.info("企微存档SDK初始化成功");
    }

    /**
     * 释放SDK资源
     */
    @PreDestroy
    public void destroy() {
        WeWorkFinanceSdk.INSTANCE.DestroySdk(sdk);
        log.info("企微存档SDK已释放");
    }

    /**
     * 拉取存档消息(核心方法)
     *
     * @param seq   起始序列号
     * @param limit 拉取条数
     * @return 拉取的消息列表
     */
    public List<ArchiveMessage> pullMessages(long seq, int limit) {
        List<ArchiveMessage> messages = new ArrayList<>();

        // 分配切片内存
        long chatDataSlice = 0; // 实际需要通过JNA分配内存

        try {
            // 调用SDK拉取数据
            int ret = WeWorkFinanceSdk.INSTANCE.GetChatData(sdk, seq, limit, null, null, 5, chatDataSlice);
            if (ret != 0) {
                log.error("GetChatData失败, ret={}, seq={}", ret, seq);
                return messages;
            }

            // 解析返回的JSON数据
            // 注意:实际需要从chatDataSlice中提取内容
            String jsonData = extractFromSlice(chatDataSlice);
            if (jsonData == null || jsonData.isEmpty()) {
                return messages;
            }

            Map<String, Object> result = JsonUtils.fromJsonMap(jsonData);
            if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
                log.error("拉取存档返回错误: {}", jsonData);
                return messages;
            }

            // 解析聊天数据列表
            List<Map<String, Object>> chatDataList = (List<Map<String, Object>>) result.get("chatdata");
            if (chatDataList == null || chatDataList.isEmpty()) {
                return messages;
            }

            for (Map<String, Object> chatData : chatDataList) {
                try {
                    ArchiveMessage message = parseAndDecrypt(chatData);
                    if (message != null) {
                        messages.add(message);
                    }
                } catch (Exception e) {
                    log.error("解析单条消息失败: {}", e.getMessage(), e);
                }
            }

            log.info("拉取存档消息完成: seq={}, limit={}, 成功解析{}条", seq, limit, messages.size());

        } finally {
            // 释放切片内存
            if (chatDataSlice != 0) {
                WeWorkFinanceSdk.INSTANCE.FreeSlice(chatDataSlice);
            }
        }

        return messages;
    }

    /**
     * 解析并解密单条消息
     */
    private ArchiveMessage parseAndDecrypt(Map<String, Object> chatData) {
        String encryptRandomKey = (String) chatData.get("encrypt_random_key");
        String encryptChatMsg = (String) chatData.get("encrypt_chat_msg");
        Long msgSeq = ((Number) chatData.get("seq")).longValue();

        // 1. RSA解密获取AES密钥
        String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey);
        if (encryptKey == null) {
            log.error("解密encrypt_random_key失败, seq={}", msgSeq);
            return null;
        }

        // 2. AES解密消息内容
        // 注意:实际需要通过SDK的DecryptData方法解密
        String decryptedMsg = decryptMessage(encryptKey, encryptChatMsg);
        if (decryptedMsg == null) {
            log.error("解密消息内容失败, seq={}", msgSeq);
            return null;
        }

        // 3. 解析解密后的JSON消息
        Map<String, Object> msgMap = JsonUtils.fromJsonMap(decryptedMsg);
        if (msgMap == null) {
            return null;
        }

        ArchiveMessage message = new ArchiveMessage();
        message.setMsgid((String) msgMap.get("msgid"));
        message.setSeq(msgSeq);
        message.setCorpId(corpId);
        message.setAction((String) msgMap.getOrDefault("action", "send"));
        message.setFromUser((String) msgMap.get("from"));
        message.setFromRole(detectRole((String) msgMap.get("from")));
        message.setToUser((String) msgMap.get("tolist"));
        message.setRoomid((String) msgMap.get("roomid"));
        message.setMsgtype((String) msgMap.get("msgtype"));
        Object msgTimeObj = msgMap.get("msgtime");
        if (msgTimeObj != null) {
            message.setMsgtime(((Number) msgTimeObj).longValue());
        }

        // 提取文本内容
        Object content = msgMap.get("content");
        if (content != null) {
            message.setContent(content.toString());
        }

        message.setTolist(JsonUtils.toJson(msgMap.get("tolist")));
        message.setMediaData(JsonUtils.toJson(msgMap.get("mediaData")));
        message.setDecryptStatus(1);
        message.setSessionId(generateSessionId(message));

        return message;
    }

    /**
     * 使用SDK解密消息
     */
    private String decryptMessage(String encryptKey, String encryptMsg) {
        long msgSlice = 0; // 实际需要通过JNA分配内存
        try {
            int ret = WeWorkFinanceSdk.INSTANCE.DecryptData(sdk, encryptKey, encryptMsg, msgSlice);
            if (ret != 0) {
                log.error("DecryptData失败, ret={}", ret);
                return null;
            }
            return extractFromSlice(msgSlice);
        } finally {
            if (msgSlice != 0) {
                WeWorkFinanceSdk.INSTANCE.FreeSlice(msgSlice);
            }
        }
    }

    /**
     * 从切片中提取字符串内容
     * 注意:实际实现需要根据JNA的内存布局来读取
     */
    private String extractFromSlice(long slice) {
        // 这里需要根据实际的C SDK内存布局来实现
        // 临时返回空字符串,实际项目中需要正确实现
        return "";
    }

    /**
     * 检测消息发送者角色
     */
    private String detectRole(String fromUser) {
        // 简单判断:以"wm"开头的是外部联系人
        if (fromUser != null && fromUser.startsWith("wm")) {
            return "EXTERNAL";
        }
        return "INTERNAL";
    }

    /**
     * 生成会话ID
     */
    private String generateSessionId(ArchiveMessage message) {
        // 会话ID格式: corpId_fromUser_toUser
        if (message.getRoomid() != null && !message.getRoomid().isEmpty()) {
            return corpId + "_" + message.getRoomid();
        }
        return corpId + "_" + message.getFromUser() + "_" + message.getToUser();
    }

    /**
     * 保存消息并发送MQ事件
     */
    public void saveAndNotify(List<ArchiveMessage> messages) {
        for (ArchiveMessage message : messages) {
            try {
                // 去重检查
                int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
                if (count > 0) {
                    log.debug("消息已存在,跳过: msgid={}", message.getMsgid());
                    continue;
                }

                // 保存消息
                archiveMessageMapper.insert(message);

                // 发送MQ事件,触发后续处理
                rabbitTemplate.convertAndSend(
                        RabbitConfig.ARCHIVE_EXCHANGE,
                        RabbitConfig.ROUTING_KEY_NEW_MESSAGE,
                        JsonUtils.toJson(message)
                );

                // 更新最后拉取的seq
                redisTemplate.opsForValue().set(
                        LAST_SEQ_KEY + corpId,
                        message.getSeq(),
                        7, TimeUnit.DAYS
                );

            } catch (Exception e) {
                log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage());
            }
        }
    }

    /**
     * 获取上次拉取的序列号
     */
    public long getLastSeq() {
        Object seq = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId);
        if (seq != null) {
            return ((Number) seq).longValue();
        }
        // 从数据库查询
        Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId);
        return maxSeq != null ? maxSeq : 0L;
    }
}

3.3.7 回调接收Controller

package com.artedu.archive.controller;

import com.artedu.archive.service.ArchivePullService;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.CompletableFuture;

/**
 * 企微回调控制器
 * 接收企微的msgaudit_notify回调
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/archive")
public class ArchiveCallbackController {

    @Autowired
    private ArchivePullService archivePullService;

    /**
     * 企微回调接口
     * 企微每隔约15秒推送一次msgaudit_notify事件
     */
    @PostMapping("/callback")
    public Result<String> callback(
            @RequestParam("msg_signature") String msgSignature,
            @RequestParam("timestamp") String timestamp,
            @RequestParam("nonce") String nonce,
            @RequestBody String requestBody) {

        log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);

        // 1. 验证回调签名(防止伪造)
        // TODO: 实现签名验证逻辑

        // 2. 解析回调内容
        if (requestBody.contains("msgaudit_notify")) {
            log.info("收到msgaudit_notify回调,触发存档拉取");

            // 3. 异步触发存档拉取(不阻塞回调响应)
            CompletableFuture.runAsync(() -> {
                try {
                    long lastSeq = archivePullService.getLastSeq();
                    var messages = archivePullService.pullMessages(lastSeq, 1000);
                    archivePullService.saveAndNotify(messages);
                    log.info("回调触发拉取完成,共{}条消息", messages.size());
                } catch (Exception e) {
                    log.error("回调触发拉取失败: {}", e.getMessage(), e);
                }
            });
        }

        // 4. 立即返回success(企微要求5秒内响应)
        return Result.success("success");
    }

    /**
     * 手动触发存档拉取(调试用)
     */
    @PostMapping("/pull")
    public Result<String> manualPull(
            @RequestParam(value = "seq", required = false) Long seq,
            @RequestParam(value = "limit", defaultValue = "1000") Integer limit) {

        long startSeq = seq != null ? seq : archivePullService.getLastSeq();
        log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit);

        var messages = archivePullService.pullMessages(startSeq, limit);
        archivePullService.saveAndNotify(messages);

        return Result.success("拉取完成,共" + messages.size() + "条消息");
    }
}

3.4 对话服务(conversation-service)

负责对话上下文管理、轮次解析和状态追踪。

3.4.1 对话管理服务

package com.artedu.conversation.service;

import com.artedu.common.exception.BusinessException;
import com.artedu.common.util.JsonUtils;
import com.artedu.conversation.entity.Conversation;
import com.artedu.conversation.entity.ConversationTurn;
import com.artedu.conversation.mapper.ConversationMapper;
import com.artedu.conversation.mapper.ConversationTurnMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 对话管理服务
 * 管理会话生命周期、上下文窗口、轮次解析
 */
@Slf4j
@Service
public class ConversationManager {

    @Autowired
    private ConversationMapper conversationMapper;

    @Autowired
    private ConversationTurnMapper conversationTurnMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String CONV_CONTEXT_KEY = "conv:context:";
    private static final int MAX_CONTEXT_ROUNDS = 10;
    private static final int CONTEXT_EXPIRE_MINUTES = 30;

    // ========== 会话管理 ==========

    /**
     * 获取或创建会话
     */
    public Conversation getOrCreateSession(String sessionId, String corpId,
                                            String customerId, String staffId) {
        // 先从数据库查询
        Conversation conv = conversationMapper.selectOne(
                new LambdaQueryWrapper<Conversation>()
                        .eq(Conversation::getSessionId, sessionId)
        );

        if (conv == null) {
            // 创建新会话
            conv = new Conversation();
            conv.setSessionId(sessionId);
            conv.setCorpId(corpId);
            conv.setCustomerId(customerId);
            conv.setStaffId(staffId);
            conv.setStatus("ACTIVE");
            conv.setCurrentStage("开场白");
            conv.setStageConfidence(0.0);
            conv.setRoundCount(0);
            conv.setStartTime(LocalDateTime.now());
            conversationMapper.insert(conv);
            log.info("创建新会话: sessionId={}", sessionId);
        }

        return conv;
    }

    /**
     * 更新会话状态
     */
    public void updateConversationStatus(String sessionId, String stage,
                                          Double confidence, String summary) {
        Conversation conv = conversationMapper.selectOne(
                new LambdaQueryWrapper<Conversation>()
                        .eq(Conversation::getSessionId, sessionId)
        );
        if (conv != null) {
            conv.setCurrentStage(stage);
            if (confidence != null) {
                conv.setStageConfidence(confidence);
            }
            if (summary != null) {
                conv.setContextSummary(summary);
            }
            conv.setUpdatedAt(LocalDateTime.now());
            conversationMapper.updateById(conv);
        }
    }

    // ========== 上下文窗口管理 ==========

    /**
     * 添加消息到上下文窗口
     */
    public void addMessageToContext(String sessionId, String role, String content, Long msgTime) {
        String key = CONV_CONTEXT_KEY + sessionId;

        // 构建消息对象
        ContextMessage message = new ContextMessage(role, content, msgTime);
        String messageJson = JsonUtils.toJson(message);

        // 使用Redis List存储,右侧插入
        redisTemplate.opsForList().rightPush(key, messageJson);

        // 限制窗口大小(保留最近20条消息 = 10轮对话)
        Long size = redisTemplate.opsForList().size(key);
        if (size != null && size > MAX_CONTEXT_ROUNDS * 2) {
            redisTemplate.opsForList().trim(key, -MAX_CONTEXT_ROUNDS * 2, -1);
        }

        // 刷新过期时间
        redisTemplate.expire(key, CONTEXT_EXPIRE_MINUTES, TimeUnit.MINUTES);
    }

    /**
     * 获取上下文窗口中的消息
     */
    public List<ContextMessage> getContextMessages(String sessionId) {
        String key = CONV_CONTEXT_KEY + sessionId;
        List<Object> jsonList = redisTemplate.opsForList().range(key, 0, -1);
        if (jsonList == null) {
            return List.of();
        }
        return jsonList.stream()
                .map(obj -> JsonUtils.fromJson((String) obj, ContextMessage.class))
                .filter(msg -> msg != null)
                .toList();
    }

    /**
     * 获取上下文字符串(用于Prompt)
     */
    public String getContextString(String sessionId) {
        List<ContextMessage> messages = getContextMessages(sessionId);
        StringBuilder sb = new StringBuilder();
        for (ContextMessage msg : messages) {
            String roleName = "student".equals(msg.getRole()) ? "学员" : "顾问";
            sb.append(roleName).append(": ").append(msg.getContent()).append("\n");
        }
        return sb.toString();
    }

    /**
     * 清空上下文窗口
     */
    public void clearContext(String sessionId) {
        String key = CONV_CONTEXT_KEY + sessionId;
        redisTemplate.delete(key);
    }

    // ========== 轮次管理 ==========

    /**
     * 创建对话轮次
     */
    public ConversationTurn createTurn(String sessionId, int turnNumber,
                                        String studentMsgId, String studentContent,
                                        String turnType) {
        ConversationTurn turn = new ConversationTurn();
        turn.setTurnId(sessionId + "_" + turnNumber);
        turn.setSessionId(sessionId);
        turn.setTurnNumber(turnNumber);
        turn.setStudentMsgId(studentMsgId);
        turn.setStudentContent(studentContent);
        turn.setTurnType(turnType != null ? turnType : "QUERY");
        turn.setCreatedAt(LocalDateTime.now());
        conversationTurnMapper.insert(turn);

        // 更新会话轮次计数
        Conversation conv = conversationMapper.selectOne(
                new LambdaQueryWrapper<Conversation>()
                        .eq(Conversation::getSessionId, sessionId)
        );
        if (conv != null) {
            conv.setRoundCount(turnNumber);
            conv.setUpdatedAt(LocalDateTime.now());
            conversationMapper.updateById(conv);
        }

        return turn;
    }

    /**
     * 更新坐席回复
     */
    public void updateSeatReply(String turnId, String seatMsgId, String seatContent) {
        ConversationTurn turn = conversationTurnMapper.selectOne(
                new LambdaQueryWrapper<ConversationTurn>()
                        .eq(ConversationTurn::getTurnId, turnId)
        );
        if (turn != null) {
            turn.setSeatMsgId(seatMsgId);
            turn.setSeatContent(seatContent);
            conversationTurnMapper.updateById(turn);
        }
    }

    // ========== 消息事件处理 ==========

    /**
     * 监听存档新消息事件
     */
    @RabbitListener(queues = "archive.new_message.queue")
    public void handleNewMessage(String messageJson) {
        try {
            ArchiveMessageEvent event = JsonUtils.fromJson(messageJson, ArchiveMessageEvent.class);
            if (event == null) {
                return;
            }

            log.debug("收到新消息事件: sessionId={}, msgType={}",
                    event.getSessionId(), event.getMsgtype());

            // 1. 获取或创建会话
            Conversation conv = getOrCreateSession(
                    event.getSessionId(),
                    event.getCorpId(),
                    event.getFromUser(),
                    event.getToUser()
            );

            // 2. 添加消息到上下文
            String role = "EXTERNAL".equals(event.getFromRole()) ? "student" : "seat";
            addMessageToContext(event.getSessionId(), role, event.getContent(), event.getMsgtime());

            // 3. 更新会话最后消息
            conv.setLastMsgId(event.getMsgid());
            conv.setLastMsgTime(LocalDateTime.now());
            conv.setLastMsgContent(truncateContent(event.getContent(), 200));
            conv.setIdleDuration(0);
            conversationMapper.updateById(conv);

            // 4. 如果是学员消息,创建新轮次
            if ("student".equals(role)) {
                String turnType = detectTurnType(event.getContent());
                createTurn(event.getSessionId(), conv.getRoundCount() + 1,
                        event.getMsgid(), event.getContent(), turnType);
            } else {
                // 坐席回复,更新当前轮次
                updateLastTurn(conv.getSessionId(), conv.getRoundCount(),
                        event.getMsgid(), event.getContent());
            }

        } catch (Exception e) {
            log.error("处理新消息事件失败: {}", e.getMessage(), e);
        }
    }

    /**
     * 检测轮次类型
     */
    private String detectTurnType(String content) {
        if (content == null || content.isEmpty()) {
            return "QUERY";
        }
        String lower = content.toLowerCase();
        if (lower.contains("贵") || lower.contains("贵") || lower.contains("考虑") || lower.contains("担心")) {
            return "OBJECTION";
        }
        if (lower.contains("报名") || lower.contains("报名") || lower.contains("决定") || lower.contains("确定")) {
            return "INTEREST";
        }
        if (lower.contains("?") || lower.contains("?") || lower.contains("吗")) {
            return "QUERY";
        }
        return "QUERY";
    }

    private void updateLastTurn(String sessionId, int roundNumber, String seatMsgId, String content) {
        ConversationTurn turn = conversationTurnMapper.selectOne(
                new LambdaQueryWrapper<ConversationTurn>()
                        .eq(ConversationTurn::getSessionId, sessionId)
                        .eq(ConversationTurn::getTurnNumber, roundNumber)
        );
        if (turn != null) {
            turn.setSeatMsgId(seatMsgId);
            turn.setSeatContent(content);
            conversationTurnMapper.updateById(turn);
        }
    }

    private String truncateContent(String content, int maxLength) {
        if (content == null) {
            return "";
        }
        return content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
    }

    // ========== 内部消息类 ==========

    /**
     * 存档消息事件
     */
    public static class ArchiveMessageEvent {
        private String msgid;
        private String corpId;
        private String sessionId;
        private String fromUser;
        private String fromRole;
        private String toUser;
        private String msgtype;
        private String content;
        private Long msgtime;
        // getters and setters
        public String getMsgid() { return msgid; }
        public void setMsgid(String msgid) { this.msgid = msgid; }
        public String getCorpId() { return corpId; }
        public void setCorpId(String corpId) { this.corpId = corpId; }
        public String getSessionId() { return sessionId; }
        public void setSessionId(String sessionId) { this.sessionId = sessionId; }
        public String getFromUser() { return fromUser; }
        public void setFromUser(String fromUser) { this.fromUser = fromUser; }
        public String getFromRole() { return fromRole; }
        public void setFromRole(String fromRole) { this.fromRole = fromRole; }
        public String getToUser() { return toUser; }
        public void setToUser(String toUser) { this.toUser = toUser; }
        public String getMsgtype() { return msgtype; }
        public void setMsgtype(String msgtype) { this.msgtype = msgtype; }
        public String getContent() { return content; }
        public void setContent(String content) { this.content = content; }
        public Long getMsgtime() { return msgtime; }
        public void setMsgtime(Long msgtime) { this.msgtime = msgtime; }
    }

    /**
     * 上下文消息
     */
    public static class ContextMessage {
        private String role;
        private String content;
        private Long msgTime;

        public ContextMessage() {}

        public ContextMessage(String role, String content, Long msgTime) {
            this.role = role;
            this.content = content;
            this.msgTime = msgTime;
        }

        public String getRole() { return role; }
        public void setRole(String role) { this.role = role; }
        public String getContent() { return content; }
        public void setContent(String content) { this.content = content; }
        public Long getMsgTime() { return msgTime; }
        public void setMsgTime(Long msgTime) { this.msgTime = msgTime; }
    }
}

3.4.2 对话Controller

package com.artedu.conversation.controller;

import com.artedu.common.result.Result;
import com.artedu.conversation.entity.Conversation;
import com.artedu.conversation.service.ConversationManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
 * 对话管理控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/conversations")
public class ConversationController {

    @Autowired
    private ConversationManager conversationManager;

    /**
     * 获取对话上下文
     */
    @GetMapping("/{sessionId}/context")
    public Result<Map<String, Object>> getContext(@PathVariable("sessionId") String sessionId) {
        String contextStr = conversationManager.getContextString(sessionId);
        var messages = conversationManager.getContextMessages(sessionId);

        Map<String, Object> result = new HashMap<>();
        result.put("sessionId", sessionId);
        result.put("context", contextStr);
        result.put("messageCount", messages.size());
        result.put("messages", messages);

        return Result.success(result);
    }

    /**
     * 获取会话信息
     */
    @GetMapping("/{sessionId}")
    public Result<Conversation> getConversation(@PathVariable("sessionId") String sessionId) {
        // TODO: 实现查询
        return Result.success();
    }

    /**
     * 更新会话状态
     */
    @PutMapping("/{sessionId}/status")
    public Result<String> updateStatus(
            @PathVariable("sessionId") String sessionId,
            @RequestParam("stage") String stage,
            @RequestParam(value = "confidence", required = false) Double confidence,
            @RequestParam(value = "summary", required = false) String summary) {

        conversationManager.updateConversationStatus(sessionId, stage, confidence, summary);
        return Result.success("状态更新成功");
    }
}

3.5 意图服务(intent-service)

负责意图识别(40场景分类)、学员画像构建和槽位填充。

3.5.1 意图识别服务

package com.artedu.intent.service;

import com.artedu.common.config.RabbitConfig;
import com.artedu.common.util.JsonUtils;
import com.artedu.intent.entity.CustomerProfile;
import com.artedu.intent.entity.IntentCategory;
import com.artedu.intent.entity.IntentRecognition;
import com.artedu.intent.mapper.CustomerProfileMapper;
import com.artedu.intent.mapper.IntentCategoryMapper;
import com.artedu.intent.mapper.IntentRecognitionMapper;
import com.artedu.intent.prompt.IntentPromptTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 意图识别服务
 * 基于规则+LLM混合策略进行40场景意图分类
 */
@Slf4j
@Service
public class IntentRecognitionService {

    @Autowired
    private IntentCategoryMapper intentCategoryMapper;

    @Autowired
    private IntentRecognitionMapper intentRecognitionMapper;

    @Autowired
    private CustomerProfileMapper customerProfileMapper;

    @Autowired
    private LLMIntentClient llmIntentClient;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    private static final String INTENT_CACHE_KEY = "intent:";
    private static final String RULE_MATCH_THRESHOLD = "0.85";

    /**
     * 核心方法:识别意图
     * 采用规则匹配 → LLM识别的混合策略
     */
    public IntentResult recognize(String message, String contextSummary,
                                   String customerId, String corpId, String staffId) {
        long startTime = System.currentTimeMillis();

        // 1. 检查缓存(同会话5分钟内相同消息命中缓存)
        String cacheKey = INTENT_CACHE_KEY + customerId + ":" + message.hashCode();
        IntentResult cached = (IntentResult) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            log.debug("意图识别命中缓存: customerId={}", customerId);
            return cached;
        }

        // 2. 快速规则匹配(<50ms)
        IntentResult ruleResult = ruleMatch(message);
        if (ruleResult != null && ruleResult.getConfidence() > 0.85) {
            log.debug("意图识别规则匹配命中: intent={}, confidence={}",
                    ruleResult.getPrimaryIntent().getCode(), ruleResult.getConfidence());

            // 保存识别记录
            saveRecognition(message, contextSummary, customerId, corpId, staffId, ruleResult, "RULE", startTime);

            // 更新学员画像
            updateProfile(customerId, corpId, ruleResult);

            // 缓存结果
            redisTemplate.opsForValue().set(cacheKey, ruleResult, 5, TimeUnit.MINUTES);

            return ruleResult;
        }

        // 3. LLM识别(200-500ms)
        IntentResult llmResult = llmIntentClient.recognize(message, contextSummary, getAllCategories());

        // 4. 规则校准(如果规则匹配了但置信度不高,用LLM结果覆盖)
        if (ruleResult != null && llmResult != null) {
            if (ruleResult.getPrimaryIntent().getCode()
                    .equals(llmResult.getPrimaryIntent().getCode())) {
                // 规则与LLM一致,提升置信度
                llmResult.setConfidence(Math.min(1.0, llmResult.getConfidence() + 0.05));
            }
        }

        // 5. 保存识别记录
        saveRecognition(message, contextSummary, customerId, corpId, staffId, llmResult, "HYBRID", startTime);

        // 6. 更新学员画像
        updateProfile(customerId, corpId, llmResult);

        // 7. 缓存结果
        redisTemplate.opsForValue().set(cacheKey, llmResult, 5, TimeUnit.MINUTES);

        // 8. 发送推荐事件
        sendRecommendEvent(customerId, corpId, staffId, llmResult, contextSummary);

        log.info("意图识别完成: intent={}, confidence={}, elapsed={}ms",
                llmResult.getPrimaryIntent().getCode(),
                llmResult.getConfidence(),
                System.currentTimeMillis() - startTime);

        return llmResult;
    }

    /**
     * 规则匹配:基于关键词快速匹配
     */
    private IntentResult ruleMatch(String message) {
        if (message == null || message.isEmpty()) {
            return null;
        }
        String lowerMsg = message.toLowerCase();

        // 获取所有启用的意图分类
        List<IntentCategory> categories = intentCategoryMapper.selectList(
                new LambdaQueryWrapper<IntentCategory>()
                        .eq(IntentCategory::getStatus, 1)
        );

        IntentCategory bestMatch = null;
        double bestScore = 0;

        for (IntentCategory category : categories) {
            // 解析触发关键词
            List<String> keywords = JsonUtils.fromJsonList(
                    category.getTriggerKeywords(), String.class);
            if (keywords == null || keywords.isEmpty()) {
                continue;
            }

            // 计算匹配分数
            int matchCount = 0;
            for (String keyword : keywords) {
                if (lowerMsg.contains(keyword.toLowerCase())) {
                    matchCount++;
                }
            }

            if (matchCount > 0) {
                double score = (double) matchCount / keywords.size();
                // 根据优先级调整分数
                if ("P1".equals(category.getPriority())) {
                    score *= 1.1;
                }
                if (score > bestScore) {
                    bestScore = score;
                    bestMatch = category;
                }
            }
        }

        if (bestMatch != null && bestScore > 0.3) {
            IntentResult result = new IntentResult();
            IntentResult.IntentInfo intentInfo = new IntentResult.IntentInfo();
            intentInfo.setCode(bestMatch.getCode());
            intentInfo.setName(bestMatch.getName());
            intentInfo.setDomain(bestMatch.getDomain());
            intentInfo.setConfidence(Math.min(1.0, bestScore));
            result.setPrimaryIntent(intentInfo);
            result.setConfidence(bestScore);
            return result;
        }

        return null;
    }

    /**
     * 保存意图识别记录
     */
    private void saveRecognition(String message, String contextSummary, String customerId,
                                  String corpId, String staffId, IntentResult result,
                                  String method, long startTime) {
        try {
            IntentRecognition record = new IntentRecognition();
            record.setSessionId(contextSummary); // 简化处理
            record.setMsgId(UUID.randomUUID().toString());
            record.setCustomerId(customerId);
            record.setStaffId(staffId);
            record.setMessageContent(message.length() > 200 ? message.substring(0, 200) : message);
            record.setPrimaryIntentCode(result.getPrimaryIntent().getCode());
            record.setPrimaryIntentConfidence(result.getPrimaryIntent().getConfidence());
            record.setSecondaryIntents(JsonUtils.toJson(result.getSecondaryIntents()));
            record.setSentiment(result.getSentiment());
            record.setRecognitionMethod(method);
            record.setElapsedMs((int) (System.currentTimeMillis() - startTime));
            record.setCreatedAt(LocalDateTime.now());

            intentRecognitionMapper.insert(record);
        } catch (Exception e) {
            log.error("保存意图识别记录失败: {}", e.getMessage());
        }
    }

    /**
     * 更新学员画像
     */
    private void updateProfile(String customerId, String corpId, IntentResult result) {
        try {
            CustomerProfile profile = customerProfileMapper.selectOne(
                    new LambdaQueryWrapper<CustomerProfile>()
                            .eq(CustomerProfile::getCustomerId, customerId)
                            .eq(CustomerProfile::getCorpId, corpId)
            );

            if (profile == null) {
                profile = new CustomerProfile();
                profile.setCustomerId(customerId);
                profile.setCorpId(corpId);
                profile.setIntentLevel("低");
                profile.setIntentScore(0.0);
                profile.setDecisionStage("信息了解");
                profile.setConversationCount(1);
            }

            // 根据意图更新画像
            String intentCode = result.getPrimaryIntent().getCode();
            double score = result.getPrimaryIntent().getConfidence();

            // 更新意向度
            updateIntentScore(profile, intentCode, score);

            // 根据意图更新关注重点
            updateConcernFocus(profile, intentCode);

            // 更新决策阶段
            updateDecisionStage(profile, intentCode);

            profile.setLastConversationTime(LocalDateTime.now());
            profile.setUpdatedAt(LocalDateTime.now());

            if (profile.getId() == null) {
                customerProfileMapper.insert(profile);
            } else {
                customerProfileMapper.updateById(profile);
            }

        } catch (Exception e) {
            log.error("更新学员画像失败: {}", e.getMessage());
        }
    }

    /**
     * 更新意向度分数
     */
    private void updateIntentScore(CustomerProfile profile, String intentCode, double score) {
        double currentScore = profile.getIntentScore() != null ? profile.getIntentScore() : 0;
        double addScore = 0;

        // 根据意图类型增加分数
        if (intentCode.startsWith("INT-PRICE")) {
            addScore = 15; // 询问价格 → 中高意向
        } else if (intentCode.startsWith("INT-COURSE")) {
            addScore = 10; // 询问课程 → 中等意向
        } else if (intentCode.startsWith("INT-JOB")) {
            addScore = 10; // 询问就业 → 中等意向
        } else if (intentCode.startsWith("INT-TEACH")) {
            addScore = 8;  // 询问师资 → 中低意向
        } else if (intentCode.startsWith("INT-LOC")) {
            addScore = 12; // 询问校区 → 接近决策
        } else if (intentCode.startsWith("INT-QUAL")) {
            addScore = 5;  // 询问资质 → 初步了解
        } else {
            addScore = 5;
        }

        // 加权
        addScore *= score;
        double newScore = currentScore + addScore;
        profile.setIntentScore(newScore);

        // 更新意向度等级
        if (newScore >= 60) {
            profile.setIntentLevel("高");
        } else if (newScore >= 30) {
            profile.setIntentLevel("中");
        } else if (newScore >= 0) {
            profile.setIntentLevel("低");
        } else {
            profile.setIntentLevel("无意向");
        }
    }

    /**
     * 更新关注重点
     */
    private void updateConcernFocus(CustomerProfile profile, String intentCode) {
        String focus = profile.getConcernFocus();

        if (intentCode.startsWith("INT-PRICE")) {
            focus = "价格敏感型";
        } else if (intentCode.startsWith("INT-JOB")) {
            focus = "就业导向型";
        } else if (intentCode.startsWith("INT-TEACH")) {
            focus = "师资看重型";
        } else if (intentCode.startsWith("INT-COURSE")) {
            if (focus == null) {
                focus = "时间灵活型";
            }
        } else if (intentCode.startsWith("INT-QUAL")) {
            focus = "品牌信任型";
        }

        profile.setConcernFocus(focus);
    }

    /**
     * 更新决策阶段
     */
    private void updateDecisionStage(CustomerProfile profile, String intentCode) {
        String currentStage = profile.getDecisionStage();
        if (currentStage == null) {
            currentStage = "信息了解";
        }

        // 阶段推进逻辑
        if (intentCode.startsWith("INT-PRICE") && "信息了解".equals(currentStage)) {
            profile.setDecisionStage("方案比较");
        } else if (intentCode.startsWith("INT-LOC") && "方案比较".equals(currentStage)) {
            profile.setDecisionStage("购买决策");
        } else if (intentCode.contains("试听") && "购买决策".equals(currentStage)) {
            profile.setDecisionStage("报名成交");
        }
    }

    /**
     * 发送推荐事件
     */
    private void sendRecommendEvent(String customerId, String corpId, String staffId,
                                     IntentResult result, String contextSummary) {
        try {
            Map<String, Object> event = Map.of(
                    "customerId", customerId,
                    "corpId", corpId,
                    "staffId", staffId,
                    "primaryIntent", result.getPrimaryIntent(),
                    "secondaryIntents", result.getSecondaryIntents(),
                    "contextSummary", contextSummary,
                    "timestamp", System.currentTimeMillis()
            );

            rabbitTemplate.convertAndSend(
                    RabbitConfig.CONVERSATION_EXCHANGE,
                    RabbitConfig.ROUTING_KEY_RECOMMEND,
                    JsonUtils.toJson(event)
            );
        } catch (Exception e) {
            log.error("发送推荐事件失败: {}", e.getMessage());
        }
    }

    /**
     * 获取所有意图分类
     */
    private List<IntentCategory> getAllCategories() {
        return intentCategoryMapper.selectList(
                new LambdaQueryWrapper<IntentCategory>()
                        .eq(IntentCategory::getStatus, 1)
        );
    }

    // ========== 内部类 ==========

    /**
     * 意图识别结果
     */
    public static class IntentResult {
        private IntentInfo primaryIntent;
        private List<IntentInfo> secondaryIntents;
        private Double confidence;
        private String sentiment;
        private String explanation;

        // getters and setters
        public IntentInfo getPrimaryIntent() { return primaryIntent; }
        public void setPrimaryIntent(IntentInfo primaryIntent) { this.primaryIntent = primaryIntent; }
        public List<IntentInfo> getSecondaryIntents() { return secondaryIntents; }
        public void setSecondaryIntents(List<IntentInfo> secondaryIntents) { this.secondaryIntents = secondaryIntents; }
        public Double getConfidence() { return confidence; }
        public void setConfidence(Double confidence) { this.confidence = confidence; }
        public String getSentiment() { return sentiment; }
        public void setSentiment(String sentiment) { this.sentiment = sentiment; }
        public String getExplanation() { return explanation; }
        public void setExplanation(String explanation) { this.explanation = explanation; }

        public static class IntentInfo {
            private String code;
            private String name;
            private String domain;
            private Double confidence;

            public String getCode() { return code; }
            public void setCode(String code) { this.code = code; }
            public String getName() { return name; }
            public void setName(String name) { this.name = name; }
            public String getDomain() { return domain; }
            public void setDomain(String domain) { this.domain = domain; }
            public Double getConfidence() { return confidence; }
            public void setConfidence(Double confidence) { this.confidence = confidence; }
        }
    }
}

3.5.2 LLM意图识别客户端

package com.artedu.intent.service;

import com.artedu.common.util.JsonUtils;
import com.artedu.intent.entity.IntentCategory;
import com.artedu.intent.prompt.IntentPromptTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * LLM意图识别客户端
 * 调用通义千问API进行意图识别
 */
@Slf4j
@Service
public class LLMIntentClient {

    @Value("${llm.qianwen.api-key}")
    private String apiKey;

    @Value("${llm.qianwen.model:qwen-turbo}")
    private String model;

    @Autowired
    private RestTemplate restTemplate;

    private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";

    /**
     * 调用LLM进行意图识别
     */
    public IntentRecognitionService.IntentResult recognize(
            String message, String contextSummary, List<IntentCategory> categories) {

        // 构建Prompt
        String prompt = IntentPromptTemplate.buildIntentPrompt(message, contextSummary, categories);

        // 调用通义千问API
        String response = callQianwen(prompt);

        // 解析响应
        return parseResponse(response);
    }

    /**
     * 调用通义千问API
     */
    private String callQianwen(String prompt) {
        try {
            // 构建请求体
            Map<String, Object> requestBody = Map.of(
                    "model", model,
                    "input", Map.of("prompt", prompt),
                    "parameters", Map.of(
                            "result_format", "json",
                            "max_tokens", 1500,
                            "temperature", 0.3
                    )
            );

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + apiKey);

            HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    API_URL,
                    HttpMethod.POST,
                    request,
                    String.class
            );

            return response.getBody();

        } catch (Exception e) {
            log.error("调用通义千问API失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 解析LLM响应
     */
    private IntentRecognitionService.IntentResult parseResponse(String response) {
        IntentRecognitionService.IntentResult result = new IntentRecognitionService.IntentResult();

        try {
            Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
            if (responseMap == null) {
                return createDefaultResult();
            }

            // 提取output
            Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
            if (output == null) {
                return createDefaultResult();
            }

            // 提取JSON结果
            String jsonText = (String) output.get("text");
            if (jsonText == null) {
                return createDefaultResult();
            }

            // 解析意图结果
            Map<String, Object> intentData = JsonUtils.fromJsonMap(jsonText);
            if (intentData == null) {
                return createDefaultResult();
            }

            // 主意图
            Map<String, Object> primaryIntentMap = (Map<String, Object>) intentData.get("primary_intent");
            if (primaryIntentMap != null) {
                IntentRecognitionService.IntentResult.IntentInfo primary = new IntentRecognitionService.IntentResult.IntentInfo();
                primary.setCode((String) primaryIntentMap.get("code"));
                primary.setName((String) primaryIntentMap.get("name"));
                primary.setConfidence(((Number) primaryIntentMap.get("confidence")).doubleValue());
                result.setPrimaryIntent(primary);
                result.setConfidence(primary.getConfidence());
            }

            // 辅意图
            List<Map<String, Object>> secondaryList = (List<Map<String, Object>>) intentData.get("secondary_intents");
            if (secondaryList != null) {
                List<IntentRecognitionService.IntentResult.IntentInfo> secondaryIntents = new ArrayList<>();
                for (Map<String, Object> sec : secondaryList) {
                    IntentRecognitionService.IntentResult.IntentInfo info = new IntentRecognitionService.IntentResult.IntentInfo();
                    info.setCode((String) sec.get("code"));
                    info.setName((String) sec.get("name"));
                    info.setConfidence(((Number) sec.get("confidence")).doubleValue());
                    secondaryIntents.add(info);
                }
                result.setSecondaryIntents(secondaryIntents);
            }

            // 情感
            result.setSentiment((String) intentData.getOrDefault("sentiment", "NEUTRAL"));
            result.setExplanation((String) intentData.get("explanation"));

            return result;

        } catch (Exception e) {
            log.error("解析LLM响应失败: {}", e.getMessage(), e);
            return createDefaultResult();
        }
    }

    /**
     * 创建默认结果
     */
    private IntentRecognitionService.IntentResult createDefaultResult() {
        IntentRecognitionService.IntentResult result = new IntentRecognitionService.IntentResult();
        IntentRecognitionService.IntentResult.IntentInfo intent = new IntentRecognitionService.IntentResult.IntentInfo();
        intent.setCode("INT-COURSE-01");
        intent.setName("课程内容咨询");
        intent.setConfidence(0.5);
        result.setPrimaryIntent(intent);
        result.setConfidence(0.5);
        result.setSentiment("NEUTRAL");
        result.setSecondaryIntents(new ArrayList<>());
        return result;
    }
}

3.5.3 意图识别Prompt模板

package com.artedu.intent.prompt;

import com.artedu.intent.entity.IntentCategory;

import java.util.List;
import java.util.stream.Collectors;

/**
 * 意图识别Prompt模板
 * 构建完整的LLM Prompt,包含角色定义、意图分类、示例等
 */
public class IntentPromptTemplate {

    /**
     * 构建意图识别Prompt
     */
    public static String buildIntentPrompt(String message, String contextSummary,
                                           List<IntentCategory> categories) {
        StringBuilder prompt = new StringBuilder();

        // 1. 角色定义
        prompt.append("# 角色定义\n");
        prompt.append("你是一位CG数字艺术教育行业的资深课程顾问助手,服务于第九联盟(9ART EDU)。\n");
        prompt.append("你的任务是分析学员的最新消息,识别其咨询意图。\n\n");

        // 2. 意图分类定义
        prompt.append("# 意图分类体系(40个场景)\n\n");

        // 按领域分组
        String currentDomain = "";
        for (IntentCategory category : categories) {
            if (!category.getDomain().equals(currentDomain)) {
                currentDomain = category.getDomain();
                prompt.append("## ").append(currentDomain).append("\n");
            }
            prompt.append("- ").append(category.getCode())
                    .append(" ").append(category.getName())
                    .append(": ").append(category.getDescription()).append("\n");
        }

        // 3. 学员画像体系
        prompt.append("\n# 学员画像体系\n");
        prompt.append("- 学员类型:在校大学生/转行人员/在职提升/高中毕业生/家长代询\n");
        prompt.append("- 基础水平:零基础/有美术基础/相关专业/有从业经验\n");
        prompt.append("- 意向度:高(>=60分)/中(30-59)/低(0-29)/无意向(<0)\n");
        prompt.append("- 关注重点:价格敏感型/就业导向型/师资看重型/时间灵活型/品牌信任型\n");
        prompt.append("- 决策阶段:信息了解/方案比较/购买决策/报名成交\n");

        // 4. 情感分类
        prompt.append("\n# 情感分类\n");
        prompt.append("- POSITIVE:积极、感兴趣、认可\n");
        prompt.append("- NEUTRAL:中性、询问信息\n");
        prompt.append("- NEGATIVE:消极、不满、拒绝\n");

        // 5. 输出格式说明
        prompt.append("\n# 输出格式(严格JSON)\n");
        prompt.append("请严格按以下JSON格式输出,不要添加任何其他内容:\n");
        prompt.append("{\n");
        prompt.append("  \"primary_intent\": {\"code\": \"意图编码\", \"name\": \"意图名称\", \"confidence\": 0.95},\n");
        prompt.append("  \"secondary_intents\": [\n");
        prompt.append("    {\"code\": \"辅意图编码\", \"name\": \"辅意图名称\", \"confidence\": 0.75}\n");
        prompt.append("  ],\n");
        prompt.append("  \"sentiment\": \"POSITIVE/NEUTRAL/NEGATIVE\",\n");
        prompt.append("  \"explanation\": \"意图判断的简要说明\"\n");
        prompt.append("}\n\n");

        // 6. Few-shot示例
        prompt.append("# 示例\n\n");
        prompt.append("## 示例1\n");
        prompt.append("学员消息:\"我没有美术基础,之前做销售的,想转行学3D建模。你们的课零基础能学吗?\"\n");
        prompt.append("输出:\n");
        prompt.append("{\n");
        prompt.append("  \"primary_intent\": {\"code\": \"INT-COURSE-07\", \"name\": \"课程难度询问\", \"confidence\": 0.92},\n");
        prompt.append("  \"secondary_intents\": [\n");
        prompt.append("    {\"code\": \"INT-COURSE-02\", \"name\": \"课程选择建议\", \"confidence\": 0.70}\n");
        prompt.append("  ],\n");
        prompt.append("  \"sentiment\": \"NEUTRAL\",\n");
        prompt.append("  \"explanation\": \"学员核心关注零基础能否学会(主意图),同时隐含了课程选择需求\"\n");
        prompt.append("}\n\n");

        prompt.append("## 示例2\n");
        prompt.append("学员消息:\"学费多少钱?\"\n");
        prompt.append("输出:\n");
        prompt.append("{\n");
        prompt.append("  \"primary_intent\": {\"code\": \"INT-PRICE-01\", \"name\": \"学费价格询问\", \"confidence\": 0.98},\n");
        prompt.append("  \"secondary_intents\": [],\n");
        prompt.append("  \"sentiment\": \"NEUTRAL\",\n");
        prompt.append("  \"explanation\": \"直接询问学费价格,意图明确\"\n");
        prompt.append("}\n\n");

        // 7. 实际输入
        prompt.append("# 实际输入\n\n");
        if (contextSummary != null && !contextSummary.isEmpty()) {
            prompt.append("对话上下文摘要:\"").append(contextSummary).append("\"\n");
        }
        prompt.append("学员最新消息:\"").append(message).append("\"\n\n");
        prompt.append("请输出JSON:");

        return prompt.toString();
    }
}

3.5.4 意图Controller

package com.artedu.intent.controller;

import com.artedu.common.result.Result;
import com.artedu.intent.entity.CustomerProfile;
import com.artedu.intent.entity.IntentCategory;
import com.artedu.intent.mapper.CustomerProfileMapper;
import com.artedu.intent.mapper.IntentCategoryMapper;
import com.artedu.intent.service.IntentRecognitionService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 意图识别控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/intent")
public class IntentController {

    @Autowired
    private IntentRecognitionService intentRecognitionService;

    @Autowired
    private IntentCategoryMapper intentCategoryMapper;

    @Autowired
    private CustomerProfileMapper customerProfileMapper;

    /**
     * 意图识别接口
     */
    @PostMapping("/recognize")
    public Result<IntentRecognitionService.IntentResult> recognize(
            @RequestBody IntentRecognizeRequest request) {

        IntentRecognitionService.IntentResult result = intentRecognitionService.recognize(
                request.getMessage(),
                request.getContextSummary(),
                request.getCustomerId(),
                request.getCorpId(),
                request.getStaffId()
        );

        return Result.success(result);
    }

    /**
     * 获取意图分类列表
     */
    @GetMapping("/categories")
    public Result<List<IntentCategory>> getCategories() {
        List<IntentCategory> categories = intentCategoryMapper.selectList(
                new LambdaQueryWrapper<IntentCategory>()
                        .eq(IntentCategory::getStatus, 1)
                        .orderByAsc(IntentCategory::getDomain, IntentCategory::getCode)
        );
        return Result.success(categories);
    }

    /**
     * 获取学员画像
     */
    @GetMapping("/profile/{customerId}")
    public Result<CustomerProfile> getProfile(
            @PathVariable("customerId") String customerId,
            @RequestParam("corpId") String corpId) {
        CustomerProfile profile = customerProfileMapper.selectOne(
                new LambdaQueryWrapper<CustomerProfile>()
                        .eq(CustomerProfile::getCustomerId, customerId)
                        .eq(CustomerProfile::getCorpId, corpId)
        );
        return Result.success(profile);
    }

    // 请求DTO
    public static class IntentRecognizeRequest {
        private String message;
        private String contextSummary;
        private String customerId;
        private String corpId;
        private String staffId;

        // getters and setters
        public String getMessage() { return message; }
        public void setMessage(String message) { this.message = message; }
        public String getContextSummary() { return contextSummary; }
        public void setContextSummary(String contextSummary) { this.contextSummary = contextSummary; }
        public String getCustomerId() { return customerId; }
        public void setCustomerId(String customerId) { this.customerId = customerId; }
        public String getCorpId() { return corpId; }
        public void setCorpId(String corpId) { this.corpId = corpId; }
        public String getStaffId() { return staffId; }
        public void setStaffId(String staffId) { this.staffId = staffId; }
    }
}

3.6 推荐服务(recommendation-service)

负责话术召回、多因子排序、LLM兜底生成。

3.6.1 推荐服务

package com.artedu.recommend.service;

import com.artedu.common.result.Result;
import com.artedu.common.util.JsonUtils;
import com.artedu.recommend.entity.Utterance;
import com.artedu.recommend.mapper.UtteranceMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 话术推荐服务
 * 三层召回(规则+向量+热门)+ 多因子排序
 */
@Slf4j
@Service
public class RecommendationService {

    @Autowired
    private UtteranceMapper utteranceMapper;

    @Autowired
    private VectorRecallService vectorRecallService;

    @Autowired
    private LLMGenerateService llmGenerateService;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final int RECALL_LIMIT = 30;   // 召回上限
    private static final int TOP_K = 5;           // 输出Top5

    // 权重配置
    private static final double W_INTENT = 0.35;   // 意图匹配权重
    private static final double W_PROFILE = 0.25;  // 画像匹配权重
    private static final double W_STAGE = 0.20;    // 阶段匹配权重
    private static final double W_SEMANTIC = 0.15; // 语义匹配权重
    private static final double W_HISTORY = 0.05;  // 历史成功率权重

    /**
     * 核心推荐方法
     */
    public RecommendResult recommend(String message, String intentCode,
                                      String studentType, String currentStage,
                                      String customerId, String corpId) {
        long startTime = System.currentTimeMillis();

        // 1. 三层召回
        List<Utterance> candidates = recall(message, intentCode, studentType, currentStage, corpId);

        // 2. 去重
        candidates = candidates.stream()
                .distinct()
                .filter(u -> "ACTIVE".equals(u.getStatus()))
                .collect(Collectors.toList());

        RecommendResult result = new RecommendResult();

        if (candidates.isEmpty()) {
            // 话术库无匹配,使用LLM兜底生成
            log.info("话术库无匹配,使用LLM兜底生成");
            Utterance generated = llmGenerateService.generate(message, intentCode, studentType, currentStage);
            if (generated != null) {
                result.setGenerationMode("LLM_ONLY");
                result.setRecommendations(List.of(toRecommendItem(generated, 1.0)));
            }
        } else if (candidates.size() < 3) {
            // 话术库匹配不足,LLM补充
            log.info("话术库匹配不足({}条),LLM补充", candidates.size());
            List<RecommendItem> items = rank(candidates, intentCode, studentType, currentStage);
            Utterance generated = llmGenerateService.generate(message, intentCode, studentType, currentStage);
            if (generated != null) {
                items.add(toRecommendItem(generated, 0.85));
            }
            result.setGenerationMode("KB_LLM_MIX");
            result.setRecommendations(items.subList(0, Math.min(TOP_K, items.size())));
        } else {
            // 话术库匹配充足,排序输出
            log.info("话术库匹配充足({}条),多因子排序", candidates.size());
            List<RecommendItem> items = rank(candidates, intentCode, studentType, currentStage);
            result.setGenerationMode("KB_ONLY");
            result.setRecommendations(items.subList(0, Math.min(TOP_K, items.size())));
        }

        result.setElapsedMs((int) (System.currentTimeMillis() - startTime));
        result.setRecallSources(Map.of(
                "rule", (int) candidates.stream().filter(u -> u.getUtteranceId().startsWith("UTT")).count(),
                "vector", 0,
                "hot", 0
        ));

        return result;
    }

    /**
     * 三层召回
     */
    private List<Utterance> recall(String message, String intentCode,
                                    String studentType, String currentStage, String corpId) {
        List<Utterance> candidates = new ArrayList<>();

        // 第一层:规则召回(意图码+阶段+学员类型)
        List<Utterance> ruleResults = ruleRecall(intentCode, currentStage, studentType, corpId);
        candidates.addAll(ruleResults);
        log.debug("规则召回: {}条", ruleResults.size());

        // 第二层:向量召回(语义相似度)
        List<Utterance> vectorResults = vectorRecallService.recall(message, 15);
        candidates.addAll(vectorResults);
        log.debug("向量召回: {}条", vectorResults.size());

        // 第三层:热门召回(近7天高成功率话术)
        List<Utterance> hotResults = hotRecall(corpId, 8);
        candidates.addAll(hotResults);
        log.debug("热门召回: {}条", hotResults.size());

        return candidates;
    }

    /**
     * 规则召回
     */
    private List<Utterance> ruleRecall(String intentCode, String currentStage,
                                        String studentType, String corpId) {
        // MySQL 5.7适配:使用LIKE替代JSON_CONTAINS
        LambdaQueryWrapper<Utterance> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Utterance::getCorpId, corpId)
                .eq(Utterance::getStatus, "ACTIVE")
                .like(Utterance::getIntentTags, intentCode); // TEXT字段用LIKE匹配

        if (currentStage != null && !currentStage.isEmpty()) {
            wrapper.like(Utterance::getStageTags, currentStage);
        }

        // 按成功率降序
        wrapper.orderByDesc(Utterance::getSuccessRate)
                .last("LIMIT 15");

        return utteranceMapper.selectList(wrapper);
    }

    /**
     * 热门召回
     */
    private List<Utterance> hotRecall(String corpId, int limit) {
        LambdaQueryWrapper<Utterance> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Utterance::getCorpId, corpId)
                .eq(Utterance::getStatus, "ACTIVE")
                .ge(Utterance::getSuccessRate, 0.3)
                .orderByDesc(Utterance::getUsedCount)
                .last("LIMIT " + limit);

        return utteranceMapper.selectList(wrapper);
    }

    /**
     * 多因子排序
     */
    private List<RecommendItem> rank(List<Utterance> candidates, String intentCode,
                                      String studentType, String currentStage) {
        List<ScoredUtterance> scoredList = new ArrayList<>();

        for (Utterance u : candidates) {
            double score = calculateScore(u, intentCode, studentType, currentStage);
            scoredList.add(new ScoredUtterance(u, score));
        }

        // 按分数降序排序
        scoredList.sort((a, b) -> Double.compare(b.score, a.score));

        // 取TopK
        return scoredList.stream()
                .limit(TOP_K)
                .map(su -> toRecommendItem(su.utterance, su.score))
                .collect(Collectors.toList());
    }

    /**
     * 计算多因子得分
     */
    private double calculateScore(Utterance u, String intentCode,
                                   String studentType, String currentStage) {
        double intentMatch = calculateIntentMatch(u, intentCode);
        double profileMatch = calculateProfileMatch(u, studentType);
        double stageMatch = calculateStageMatch(u, currentStage);
        double semanticMatch = u.getSuccessRate(); // 用成功率近似语义匹配
        double historyScore = u.getSuccessRate();

        return W_INTENT * intentMatch
                + W_PROFILE * profileMatch
                + W_STAGE * stageMatch
                + W_SEMANTIC * semanticMatch
                + W_HISTORY * historyScore;
    }

    /**
     * 计算意图匹配度
     */
    private double calculateIntentMatch(Utterance u, String intentCode) {
        if (u.getIntentTags() == null || intentCode == null) {
            return 0;
        }
        return u.getIntentTags().contains(intentCode) ? 1.0 : 0.0;
    }

    /**
     * 计算画像匹配度
     */
    private double calculateProfileMatch(Utterance u, String studentType) {
        if (u.getProfileTags() == null || studentType == null) {
            return 0.3; // 默认中等匹配
        }
        return u.getProfileTags().contains(studentType) ? 1.0 : 0.0;
    }

    /**
     * 计算阶段匹配度
     */
    private double calculateStageMatch(Utterance u, String currentStage) {
        if (u.getStageTags() == null || currentStage == null) {
            return 0.3;
        }
        return u.getStageTags().contains(currentStage) ? 1.0 : 0.0;
    }

    /**
     * 转换为推荐项
     */
    private RecommendItem toRecommendItem(Utterance u, double score) {
        RecommendItem item = new RecommendItem();
        item.setUtteranceId(u.getUtteranceId());
        item.setTitle(u.getTitle());
        item.setContent(u.getContent());
        item.setScore(score);
        item.setSuccessRate(u.getSuccessRate());
        item.setUsedCount(u.getUsedCount());
        item.setSource(u.getSource());
        return item;
    }

    /**
     * 监听推荐事件
     */
    @RabbitListener(queues = "intent.recommend.queue")
    public void handleRecommendEvent(String eventJson) {
        try {
            Map<String, Object> event = JsonUtils.fromJsonMap(eventJson);
            if (event == null) return;

            String customerId = (String) event.get("customerId");
            String corpId = (String) event.get("corpId");
            String staffId = (String) event.get("staffId");
            Map<String, Object> primaryIntent = (Map<String, Object>) event.get("primaryIntent");
            String intentCode = primaryIntent != null ? (String) primaryIntent.get("code") : null;
            String contextSummary = (String) event.get("contextSummary");

            if (intentCode == null) return;

            // 执行推荐
            RecommendResult result = recommend(
                    contextSummary, intentCode, null, null, customerId, corpId
            );

            // 推送结果到侧边栏(通过WebSocket或模板卡片)
            // TODO: 实现推送逻辑

            log.info("推荐完成: customerId={}, recommendations={}",
                    customerId, result.getRecommendations().size());

        } catch (Exception e) {
            log.error("处理推荐事件失败: {}", e.getMessage(), e);
        }
    }

    // ========== 内部类 ==========

    private static class ScoredUtterance {
        Utterance utterance;
        double score;

        ScoredUtterance(Utterance utterance, double score) {
            this.utterance = utterance;
            this.score = score;
        }
    }

    /**
     * 推荐结果
     */
    public static class RecommendResult {
        private List<RecommendItem> recommendations;
        private String generationMode;
        private int elapsedMs;
        private Map<String, Integer> recallSources;

        public List<RecommendItem> getRecommendations() { return recommendations; }
        public void setRecommendations(List<RecommendItem> recommendations) { this.recommendations = recommendations; }
        public String getGenerationMode() { return generationMode; }
        public void setGenerationMode(String generationMode) { this.generationMode = generationMode; }
        public int getElapsedMs() { return elapsedMs; }
        public void setElapsedMs(int elapsedMs) { this.elapsedMs = elapsedMs; }
        public Map<String, Integer> getRecallSources() { return recallSources; }
        public void setRecallSources(Map<String, Integer> recallSources) { this.recallSources = recallSources; }
    }

    /**
     * 推荐项
     */
    public static class RecommendItem {
        private String utteranceId;
        private String title;
        private String content;
        private double score;
        private double successRate;
        private int usedCount;
        private String source;

        public String getUtteranceId() { return utteranceId; }
        public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; }
        public String getTitle() { return title; }
        public void setTitle(String title) { this.title = title; }
        public String getContent() { return content; }
        public void setContent(String content) { this.content = content; }
        public double getScore() { return score; }
        public void setScore(double score) { this.score = score; }
        public double getSuccessRate() { return successRate; }
        public void setSuccessRate(double successRate) { this.successRate = successRate; }
        public int getUsedCount() { return usedCount; }
        public void setUsedCount(int usedCount) { this.usedCount = usedCount; }
        public String getSource() { return source; }
        public void setSource(String source) { this.source = source; }
    }
}

3.6.2 向量召回服务

package com.artedu.recommend.service;

import com.artedu.recommend.entity.Utterance;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * 向量召回服务
 * 使用Redis Hash存储和检索Embedding向量
 * MySQL 5.7不支持向量类型,向量存储在Redis中
 */
@Slf4j
@Service
public class VectorRecallService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private EmbeddingService embeddingService;

    private static final String VECTOR_KEY_PREFIX = "vector:utterance:";

    /**
     * 向量召回
     * 计算消息向量与话术库向量的余弦相似度,返回TopK
     */
    public List<Utterance> recall(String message, int topK) {
        List<Utterance> results = new ArrayList<>();

        try {
            // 1. 计算消息向量
            float[] messageVector = embeddingService.embed(message);

            // 2. 获取所有话术向量(从Redis)
            // 注意:实际生产环境应使用向量数据库(如Milvus)
            // 这里简化处理,从Redis获取
            List<String> utteranceIds = getAllVectorIds();

            // 3. 计算相似度
            List<ScoredUtterance> scoredList = new ArrayList<>();
            for (String utteranceId : utteranceIds) {
                float[] vector = getVector(utteranceId);
                if (vector != null) {
                    double similarity = cosineSimilarity(messageVector, vector);
                    if (similarity > 0.7) { // 相似度阈值
                        Utterance u = new Utterance();
                        u.setUtteranceId(utteranceId);
                        scoredList.add(new ScoredUtterance(u, similarity));
                    }
                }
            }

            // 4. 按相似度排序,取TopK
            scoredList.sort((a, b) -> Double.compare(b.score, a.score));
            scoredList.stream()
                    .limit(topK)
                    .forEach(s -> results.add(s.utterance));

        } catch (Exception e) {
            log.error("向量召回失败: {}", e.getMessage(), e);
        }

        return results;
    }

    /**
     * 存储话术向量
     */
    public void storeVector(String utteranceId, float[] vector) {
        try {
            String key = VECTOR_KEY_PREFIX + utteranceId;
            // 将float数组转为List<Double>存储
            List<Double> vectorList = new ArrayList<>();
            for (float v : vector) {
                vectorList.add((double) v);
            }
            redisTemplate.opsForValue().set(key, vectorList);
        } catch (Exception e) {
            log.error("存储向量失败: utteranceId={}", utteranceId, e);
        }
    }

    /**
     * 获取话术向量
     */
    private float[] getVector(String utteranceId) {
        try {
            String key = VECTOR_KEY_PREFIX + utteranceId;
            List<Double> vectorList = (List<Double>) redisTemplate.opsForValue().get(key);
            if (vectorList == null) {
                return null;
            }
            float[] vector = new float[vectorList.size()];
            for (int i = 0; i < vectorList.size(); i++) {
                vector[i] = vectorList.get(i).floatValue();
            }
            return vector;
        } catch (Exception e) {
            log.error("获取向量失败: utteranceId={}", utteranceId, e);
            return null;
        }
    }

    /**
     * 获取所有有向量的话术ID
     */
    private List<String> getAllVectorIds() {
        // 简化实现:从Redis keys获取
        // 实际生产环境应使用更好的索引机制
        var keys = redisTemplate.keys(VECTOR_KEY_PREFIX + "*");
        if (keys == null) {
            return List.of();
        }
        return keys.stream()
                .map(k -> k.replace(VECTOR_KEY_PREFIX, ""))
                .toList();
    }

    /**
     * 计算余弦相似度
     */
    private double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            return 0;
        }
        double dotProduct = 0;
        double normA = 0;
        double normB = 0;
        for (int i = 0; i < a.length; i++) {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        if (normA == 0 || normB == 0) {
            return 0;
        }
        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }

    private static class ScoredUtterance {
        Utterance utterance;
        double score;

        ScoredUtterance(Utterance utterance, double score) {
            this.utterance = utterance;
            this.score = score;
        }
    }
}

3.6.3 LLM兜底生成服务

package com.artedu.recommend.service;

import com.artedu.common.util.JsonUtils;
import com.artedu.recommend.entity.Utterance;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.Map;
import java.util.UUID;

/**
 * LLM兜底话术生成服务
 * 当话术库匹配不足时,调用通义千问动态生成话术
 */
@Slf4j
@Service
public class LLMGenerateService {

    @Value("${llm.qianwen.api-key}")
    private String apiKey;

    @Value("${llm.qianwen.model:qwen-turbo}")
    private String model;

    @Autowired
    private RestTemplate restTemplate;

    private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";

    /**
     * 生成兜底话术
     */
    public Utterance generate(String message, String intentCode,
                               String studentType, String currentStage) {
        try {
            // 构建生成Prompt
            String prompt = buildGeneratePrompt(message, intentCode, studentType, currentStage);

            // 调用LLM
            String response = callLLM(prompt);
            if (response == null) {
                return null;
            }

            // 解析响应
            String content = extractContent(response);
            if (content == null || content.isEmpty()) {
                return null;
            }

            // 构建话术对象
            Utterance utterance = new Utterance();
            utterance.setUtteranceId("LLM_" + UUID.randomUUID().toString().substring(0, 8));
            utterance.setTitle("AI生成-" + intentCode);
            utterance.setContent(content);
            utterance.setContentText(content);
            utterance.setStatus("ACTIVE");
            utterance.setSource("LLM_GENERATED");
            utterance.setSuccessRate(0.5); // AI生成的话术默认中等成功率
            utterance.setUsedCount(0);

            log.info("LLM生成话术成功: utteranceId={}, length={}",
                    utterance.getUtteranceId(), content.length());

            return utterance;

        } catch (Exception e) {
            log.error("LLM生成话术失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 构建生成Prompt
     */
    private String buildGeneratePrompt(String message, String intentCode,
                                        String studentType, String currentStage) {
        StringBuilder prompt = new StringBuilder();

        prompt.append("# 角色\n");
        prompt.append("你是第九联盟(9ART EDU)的资深课程顾问,擅长根据学员的问题生成专业、热情、有说服力的话术回复。\n\n");

        prompt.append("# 业务背景\n");
        prompt.append("第九联盟是CG数字艺术教育培训机构,母公司点晴科技有10年游戏制作经验。\n");
        prompt.append("课程包括:3D场景UE地编、次世代角色模型、2D原画、游戏动画、游戏特效、UE开发、3D大师研修班。\n");
        prompt.append("校区:上海、西安、厦门、武汉、青岛、合肥。\n");
        prompt.append("就业合作企业:腾讯、网易、米哈游等500+企业。\n");
        prompt.append("教学模式:5个月培训+2个月企业实训。\n");
        prompt.append("师资:来自一线游戏大厂,平均8年+项目经验。\n\n");

        prompt.append("# 生成要求\n");
        prompt.append("1. 针对学员的具体问题生成回复话术\n");
        prompt.append("2. 语气专业、热情、有亲和力\n");
        prompt.append("3. 包含具体的业务数据(价格、时间等)\n");
        prompt.append("4. 适当使用emoji和分段,增强可读性\n");
        prompt.append("5. 引导学员下一步行动(预约试听、了解大纲等)\n");
        prompt.append("6. 不要编造不存在的数据,不确定的标注[需人工确认]\n\n");

        prompt.append("# 当前场景\n");
        prompt.append("- 学员问题:\"").append(message).append("\"\n");
        if (intentCode != null) {
            prompt.append("- 意图编码:").append(intentCode).append("\n");
        }
        if (studentType != null) {
            prompt.append("- 学员类型:").append(studentType).append("\n");
        }
        if (currentStage != null) {
            prompt.append("- 对话阶段:").append(currentStage).append("\n");
        }

        prompt.append("\n请生成回复话术:");

        return prompt.toString();
    }

    /**
     * 调用LLM
     */
    private String callLLM(String prompt) {
        try {
            Map<String, Object> requestBody = Map.of(
                    "model", model,
                    "input", Map.of("prompt", prompt),
                    "parameters", Map.of(
                            "result_format", "text",
                            "max_tokens", 800,
                            "temperature", 0.7
                    )
            );

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + apiKey);

            HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    API_URL,
                    HttpMethod.POST,
                    request,
                    String.class
            );

            return response.getBody();

        } catch (Exception e) {
            log.error("调用LLM失败: {}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 提取LLM响应中的文本内容
     */
    private String extractContent(String response) {
        try {
            Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
            if (responseMap == null) return null;

            Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
            if (output == null) return null;

            return (String) output.get("text");
        } catch (Exception e) {
            log.error("提取LLM内容失败: {}", e.getMessage());
            return null;
        }
    }
}

3.6.4 Embedding服务

package com.artedu.recommend.service;

import com.artedu.common.util.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Map;

/**
 * Embedding服务
 * 调用通义千问文本Embedding API,将文本转为向量
 */
@Slf4j
@Service
public class EmbeddingService {

    @Value("${llm.qianwen.api-key}")
    private String apiKey;

    @Autowired
    private RestTemplate restTemplate;

    private static final String EMBEDDING_API_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
    private static final String EMBEDDING_MODEL = "text-embedding-v2";

    /**
     * 获取文本的Embedding向量
     *
     * @param text 输入文本
     * @return 1536维向量
     */
    public float[] embed(String text) {
        try {
            // 文本长度限制(Embedding API有长度限制)
            if (text.length() > 2000) {
                text = text.substring(0, 2000);
            }

            Map<String, Object> requestBody = Map.of(
                    "model", EMBEDDING_MODEL,
                    "input", Map.of("texts", List.of(text))
            );

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + apiKey);

            HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    EMBEDDING_API_URL,
                    HttpMethod.POST,
                    request,
                    String.class
            );

            return parseEmbedding(response.getBody());

        } catch (Exception e) {
            log.error("获取Embedding失败: {}", e.getMessage(), e);
            // 返回零向量作为降级
            return new float[1536];
        }
    }

    /**
     * 解析Embedding响应
     */
    private float[] parseEmbedding(String response) {
        try {
            Map<String, Object> responseMap = JsonUtils.fromJsonMap(response);
            if (responseMap == null) return new float[1536];

            Map<String, Object> output = (Map<String, Object>) responseMap.get("output");
            if (output == null) return new float[1536];

            List<Map<String, Object>> embeddings = (List<Map<String, Object>>) output.get("embeddings");
            if (embeddings == null || embeddings.isEmpty()) return new float[1536];

            List<Double> embeddingList = (List<Double>) embeddings.get(0).get("embedding");
            if (embeddingList == null) return new float[1536];

            float[] result = new float[embeddingList.size()];
            for (int i = 0; i < embeddingList.size(); i++) {
                result[i] = embeddingList.get(i).floatValue();
            }

            return result;

        } catch (Exception e) {
            log.error("解析Embedding响应失败: {}", e.getMessage());
            return new float[1536];
        }
    }
}

3.6.5 推荐Controller

package com.artedu.recommend.controller;

import com.artedu.common.result.Result;
import com.artedu.recommend.service.RecommendationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * 话术推荐控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/recommend")
public class RecommendController {

    @Autowired
    private RecommendationService recommendationService;

    /**
     * 话术推荐接口
     */
    @PostMapping
    public Result<RecommendationService.RecommendResult> recommend(
            @RequestBody RecommendRequest request) {

        RecommendationService.RecommendResult result = recommendationService.recommend(
                request.getMessage(),
                request.getIntentCode(),
                request.getStudentType(),
                request.getCurrentStage(),
                request.getCustomerId(),
                request.getCorpId()
        );

        return Result.success(result);
    }

    /**
     * 话术反馈接口
     */
    @PostMapping("/feedback")
    public Result<String> feedback(@RequestBody FeedbackRequest request) {
        // TODO: 实现反馈收集
        return Result.success("反馈已记录");
    }

    // 请求DTO
    public static class RecommendRequest {
        private String message;
        private String intentCode;
        private String studentType;
        private String currentStage;
        private String customerId;
        private String corpId;

        public String getMessage() { return message; }
        public void setMessage(String message) { this.message = message; }
        public String getIntentCode() { return intentCode; }
        public void setIntentCode(String intentCode) { this.intentCode = intentCode; }
        public String getStudentType() { return studentType; }
        public void setStudentType(String studentType) { this.studentType = studentType; }
        public String getCurrentStage() { return currentStage; }
        public void setCurrentStage(String currentStage) { this.currentStage = currentStage; }
        public String getCustomerId() { return customerId; }
        public void setCustomerId(String customerId) { this.customerId = customerId; }
        public String getCorpId() { return corpId; }
        public void setCorpId(String corpId) { this.corpId = corpId; }
    }

    public static class FeedbackRequest {
        private String utteranceId;
        private String action;
        private Boolean helpful;

        public String getUtteranceId() { return utteranceId; }
        public void setUtteranceId(String utteranceId) { this.utteranceId = utteranceId; }
        public String getAction() { return action; }
        public void setAction(String action) { this.action = action; }
        public Boolean getHelpful() { return helpful; }
        public void setHelpful(Boolean helpful) { this.helpful = helpful; }
    }
}

第四部分:前端实现

4.1 侧边栏H5项目(React + TypeScript)

4.1.1 package.json

{
  "name": "ai-assistant-sidebar",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 0.0.0.0 --port 3000",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext ts,tsx"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "zustand": "^4.4.0",
    "antd-mobile": "^5.32.0",
    "antd-mobile-icons": "^0.3.0",
    "wecom-jssdk": "^2.0.2",
    "axios": "^1.5.0",
    "clsx": "^2.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.0.0",
    "typescript": "^5.2.0",
    "vite": "^4.4.0"
  }
}

4.1.2 Vite配置

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 3000,
    host: '0.0.0.0',
    // 允许企微域名跨域
    cors: true,
  },
  build: {
    outDir: 'dist',
    sourcemap: true,
    // 移动端适配:使用vw单位
    cssTarget: 'chrome61',
  },
  base: '/sidebar/',
});

4.1.3 HTML入口

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <title>AI话术助手</title>
  <!-- 企微JS-SDK -->
  <script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
  <script src="https://open.work.weixin.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>

4.1.4 入口文件

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';

// 企微JS-SDK环境检测
console.log('环境检测:', {
  ww: typeof ww !== 'undefined',
  wx: typeof wx !== 'undefined',
  isWeCom: navigator.userAgent.toLowerCase().includes('wxwork'),
  isMobile: /Mobile|Android|iPhone/.test(navigator.userAgent),
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

4.1.5 全局样式

/* src/styles/global.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #root {
  height: 100%;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: #f5f5f5;
}

/* 企微侧边栏容器适配 */
.sidebar-container {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: #f5f5f5;
}

/* PC端侧边栏固定宽度 */
@media (min-width: 768px) {
  .sidebar-container {
    width: 360px;
    min-width: 360px;
    max-width: 360px;
  }
}

/* 手机端侧边栏全宽 */
@media (max-width: 767px) {
  .sidebar-container {
    width: 100vw;
    height: 80vh;
    border-radius: 12px 12px 0 0;
  }
}

/* 滚动条样式 */
::-webkit-scrollbar {
  width: 4px;
}
::-webkit-scrollbar-track {
  background: transparent;
}
::-webkit-scrollbar-thumb {
  background: #d9d9d9;
  border-radius: 2px;
}

/* 话术卡片样式 */
.script-card {
  background: #fff;
  border-radius: 12px;
  padding: 12px;
  margin: 8px 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  transition: all 0.2s ease;
}

.script-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

/* 学员画像标签 */
.profile-tag {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  margin-right: 6px;
  margin-bottom: 4px;
}

.profile-tag-primary {
  background: #e6f7ff;
  color: #1890ff;
  border: 1px solid #91d5ff;
}

.profile-tag-success {
  background: #f6ffed;
  color: #52c41a;
  border: 1px solid #b7eb8f;
}

.profile-tag-warning {
  background: #fffbe6;
  color: #faad14;
  border: 1px solid #ffe58f;
}

/* 一键发送按钮 */
.send-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 6px 12px;
  border-radius: 16px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  border: none;
}

.send-btn-primary {
  background: #1677ff;
  color: #fff;
}

.send-btn-primary:active {
  background: #0958d9;
}

4.1.6 企微SDK Hook(核心)

// src/hooks/useWeComSdk.ts
import { useState, useEffect, useCallback } from 'react';

/**
 * 企微环境类型
 */
export type WeComEnv = 'pc' | 'mobile' | 'unknown';

/**
 * 企微SDK Hook
 * 负责初始化ww.register、获取客户ID、发送消息
 */
export function useWeComSdk() {
  const [env, setEnv] = useState<WeComEnv>('unknown');
  const [customerId, setCustomerId] = useState<string>('');
  const [staffId, setStaffId] = useState<string>('');
  const [isReady, setIsReady] = useState(false);
  const [error, setError] = useState<string>('');

  /**
   * 检测企微环境
   */
  useEffect(() => {
    const userAgent = navigator.userAgent.toLowerCase();
    if (userAgent.includes('wxwork')) {
      if (userAgent.includes('windowswechat') || userAgent.includes('macwechat')) {
        setEnv('pc');
      } else {
        setEnv('mobile');
      }
    }
  }, []);

  /**
   * 初始化企微JS-SDK
   */
  const initSdk = useCallback(async () => {
    if (typeof ww === 'undefined') {
      setError('当前不在企业微信环境中');
      return;
    }

    try {
      // 从后端获取签名参数
      const response = await fetch(
        `${import.meta.env.VITE_API_BASE_URL}/api/v1/auth/signature?url=` +
        encodeURIComponent(window.location.href.split('#')[0])
      );
      const result = await response.json();

      if (result.code !== 0) {
        setError('获取签名失败: ' + result.message);
        return;
      }

      const { corpId, agentId, nonceStr, timestamp, signature } = result.data;

      // iOS特殊处理:需使用setTimeout包裹
      const doRegister = () => {
        ww.register({
          corpId: corpId,
          agentId: agentId,
          jsApiList: [
            'sendChatMessage',
            'getCurExternalContact',
            'getCurExternalChat',
            'openExistedChatWithMsg',
          ],
          getAgentConfigSignature: () => {
            return {
              timestamp: timestamp,
              nonceStr: nonceStr,
              signature: signature,
            };
          },
          success: () => {
            console.log('企微JS-SDK注册成功');
            setIsReady(true);
            // 获取当前客户ID
            fetchCustomerId();
          },
          fail: (err: any) => {
            console.error('企微JS-SDK注册失败:', err);
            setError('SDK注册失败: ' + JSON.stringify(err));
          },
        });
      };

      if (env === 'mobile' && /iPhone|iPad/.test(navigator.userAgent)) {
        setTimeout(doRegister, 100);
      } else {
        doRegister();
      }

    } catch (e) {
      setError('初始化SDK异常: ' + (e as Error).message);
    }
  }, [env]);

  /**
   * 获取当前客户ID(外部联系人)
   */
  const fetchCustomerId = useCallback(() => {
    if (typeof ww === 'undefined') return;

    ww.invoke('getCurExternalContact', {}, (res: any) => {
      if (res.err_msg === 'getCurExternalContact:ok') {
        setCustomerId(res.userId || '');
        console.log('当前客户ID:', res.userId);
      } else {
        console.warn('获取客户ID失败:', res.err_msg);
      }
    });
  }, []);

  /**
   * 发送话术到聊天窗口(核心功能)
   */
  const sendScript = useCallback(
    (content: string, enterChat: boolean = false): Promise<boolean> => {
      return new Promise((resolve) => {
        if (typeof ww === 'undefined') {
          setError('SDK未初始化');
          resolve(false);
          return;
        }

        ww.invoke(
          'sendChatMessage',
          {
            msgtype: 'text',
            text: {
              content: content,
            },
            enterChat: enterChat, // PC端false不进入会话,手机端true进入会话
          },
          (res: any) => {
            if (res.err_msg === 'sendChatMessage:ok') {
              console.log('话术发送成功');
              resolve(true);
            } else {
              console.error('话术发送失败:', res.err_msg);
              setError('发送失败: ' + res.err_msg);
              resolve(false);
            }
          }
        );
      });
    },
    []
  );

  /**
   * 打开已有会话(配合enterChat:true使用)
   */
  const openChat = useCallback((userId: string) => {
    if (typeof ww === 'undefined') return;

    ww.invoke(
      'openExistedChatWithMsg',
      {
        userid: userId,
      },
      (res: any) => {
        console.log('打开会话结果:', res);
      }
    );
  }, []);

  return {
    env,
    customerId,
    staffId,
    isReady,
    error,
    initSdk,
    sendScript,
    openChat,
  };
}

4.1.7 API服务

// src/services/api.ts
/**
 * API服务
 * 封装所有后端API调用
 */
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';

/**
 * 获取Token
 */
function getToken(): string {
  return localStorage.getItem('token') || '';
}

/**
 * 通用请求方法
 */
async function request<T>(
  url: string,
  options: RequestInit = {}
): Promise<{ code: number; data: T; message: string }> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
    ...((options.headers as Record<string, string>) || {}),
  };

  const response = await fetch(`${API_BASE}${url}`, {
    ...options,
    headers,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json();
}

/**
 * 话术推荐API
 */
export async function recommendScripts(params: {
  message: string;
  intentCode?: string;
  studentType?: string;
  currentStage?: string;
  customerId: string;
  corpId: string;
}) {
  return request<RecommendResult>('/api/v1/recommend', {
    method: 'POST',
    body: JSON.stringify(params),
  });
}

/**
 * 获取学员画像API
 */
export async function getCustomerProfile(customerId: string, corpId: string) {
  return request<CustomerProfile>(`/api/v1/intent/profile/${customerId}?corpId=${corpId}`);
}

/**
 * 获取话术库列表
 */
export async function getUtteranceList(params: {
  page?: number;
  size?: number;
  stage?: string;
  keyword?: string;
}) {
  const query = new URLSearchParams();
  if (params.page) query.set('page', params.page.toString());
  if (params.size) query.set('size', params.size.toString());
  if (params.stage) query.set('stage', params.stage);
  if (params.keyword) query.set('keyword', params.keyword);

  return request<PageResult<Utterance>>(`/api/v1/kb/utterances?${query.toString()}`);
}

/**
 * 提交话术反馈
 */
export async function submitFeedback(params: {
  utteranceId: string;
  action: 'send' | 'useful' | 'useless';
}) {
  return request('/api/v1/recommend/feedback', {
    method: 'POST',
    body: JSON.stringify(params),
  });
}

// 类型定义
export interface RecommendResult {
  recommendations: RecommendItem[];
  generationMode: string;
  elapsedMs: number;
  recallSources: Record<string, number>;
}

export interface RecommendItem {
  utteranceId: string;
  title: string;
  content: string;
  score: number;
  successRate: number;
  usedCount: number;
  source: string;
}

export interface CustomerProfile {
  customerId: string;
  studentType: string;
  intentLevel: string;
  intentScore: number;
  concernFocus: string;
  decisionStage: string;
  conversationCount: number;
}

export interface Utterance {
  utteranceId: string;
  title: string;
  content: string;
  stageTags: string[];
  intentTags: string[];
  successRate: number;
  usedCount: number;
}

export interface PageResult<T> {
  page: number;
  size: number;
  total: number;
  list: T[];
}

4.1.8 Zustand状态管理

// src/stores/useScriptStore.ts
import { create } from 'zustand';
import type { RecommendItem, RecommendResult } from '../services/api';

/**
 * 话术推荐状态管理
 */
interface ScriptState {
  // 状态
  isLoading: boolean;
  recommendations: RecommendItem[];
  currentScript: RecommendItem | null;
  error: string;

  // 动作
  setRecommendations: (items: RecommendItem[]) => void;
  setCurrentScript: (script: RecommendItem | null) => void;
  setLoading: (loading: boolean) => void;
  setError: (error: string) => void;
  loadRecommendations: (result: RecommendResult) => void;
  clearRecommendations: () => void;
}

export const useScriptStore = create<ScriptState>((set) => ({
  isLoading: false,
  recommendations: [],
  currentScript: null,
  error: '',

  setRecommendations: (items) => set({ recommendations: items, error: '' }),
  setCurrentScript: (script) => set({ currentScript: script }),
  setLoading: (loading) => set({ isLoading: loading }),
  setError: (error) => set({ error, isLoading: false }),

  loadRecommendations: (result) =>
    set({
      recommendations: result.recommendations,
      isLoading: false,
      error: '',
    }),

  clearRecommendations: () =>
    set({
      recommendations: [],
      currentScript: null,
      error: '',
    }),
}));
// src/stores/useProfileStore.ts
import { create } from 'zustand';

/**
 * 学员画像状态管理
 */
interface ProfileState {
  customerId: string;
  customerName: string;
  studentType: string;
  intentLevel: string;
  intentScore: number;
  concernFocus: string;
  decisionStage: string;
  conversationCount: number;
  isLoading: boolean;

  setProfile: (profile: Partial<Omit<ProfileState, 'setProfile' | 'isLoading'>>) => void;
  setLoading: (loading: boolean) => void;
  clearProfile: () => void;
}

export const useProfileStore = create<ProfileState>((set) => ({
  customerId: '',
  customerName: '',
  studentType: '',
  intentLevel: '低',
  intentScore: 0,
  concernFocus: '',
  decisionStage: '信息了解',
  conversationCount: 0,
  isLoading: false,

  setProfile: (profile) => set((state) => ({ ...state, ...profile })),
  setLoading: (loading) => set({ isLoading: loading }),
  clearProfile: () =>
    set({
      customerId: '',
      customerName: '',
      studentType: '',
      intentLevel: '低',
      intentScore: 0,
      concernFocus: '',
      decisionStage: '信息了解',
      conversationCount: 0,
    }),
}));

4.1.9 根组件 App.tsx

// src/App.tsx
import React, { useEffect } from 'react';
import { useWeComSdk } from './hooks/useWeComSdk';
import Header from './components/Header';
import TabBar from './components/TabBar';
import ProfileCard from './components/ProfileCard';
import ScriptRecommend from './components/ScriptRecommend';
import AnalysisPanel from './components/AnalysisPanel';
import { Toast } from 'antd-mobile';

/**
 * 侧边栏主应用组件
 * 整合企微SDK、话术推荐、学员画像等功能
 */
const App: React.FC = () => {
  const { env, isReady, error, initSdk, customerId } = useWeComSdk();
  const [activeTab, setActiveTab] = React.useState<'recommend' | 'analysis' | 'search'>('recommend');

  // 初始化SDK
  useEffect(() => {
    initSdk();
  }, [initSdk]);

  // 错误提示
  useEffect(() => {
    if (error) {
      Toast.show({ content: error, position: 'center' });
    }
  }, [error]);

  // 根据当前Tab渲染内容
  const renderContent = () => {
    switch (activeTab) {
      case 'recommend':
        return <ScriptRecommend />;
      case 'analysis':
        return <AnalysisPanel />;
      case 'search':
        return <SearchPlaceholder />;
      default:
        return <ScriptRecommend />;
    }
  };

  if (!isReady) {
    return (
      <div className="sidebar-container" style={{ justifyContent: 'center', alignItems: 'center' }}>
        <div style={{ textAlign: 'center', color: '#999' }}>
          <div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
          <div>正在初始化AI助手...</div>
          {error && <div style={{ color: '#ff4d4f', marginTop: 8, fontSize: 12 }}>{error}</div>}
        </div>
      </div>
    );
  }

  return (
    <div className="sidebar-container">
      {/* 顶部栏 */}
      <Header env={env} customerId={customerId} />

      {/* 学员画像卡片 */}
      <ProfileCard />

      {/* Tab切换栏 */}
      <TabBar activeTab={activeTab} onChange={setActiveTab} />

      {/* 主内容区 */}
      <div style={{ flex: 1, overflow: 'auto' }}>
        {renderContent()}
      </div>
    </div>
  );
};

/**
 * 搜索占位组件
 */
const SearchPlaceholder: React.FC = () => (
  <div style={{ padding: 40, textAlign: 'center', color: '#999' }}>
    <div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
    <div>话术搜索功能开发中</div>
  </div>
);

export default App;

4.1.10 Header组件

// src/components/Header.tsx
import React from 'react';
import { useWeComSdk } from '../hooks/useWeComSdk';
import type { WeComEnv } from '../hooks/useWeComSdk';

interface HeaderProps {
  env: WeComEnv;
  customerId: string;
}

/**
 * 顶部栏组件
 * 显示标题、环境标识、刷新按钮
 */
const Header: React.FC<HeaderProps> = ({ env, customerId }) => {
  const { env: sdkEnv } = useWeComSdk();

  return (
    <header
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: '12px 16px',
        background: '#fff',
        borderBottom: '1px solid #f0f0f0',
        flexShrink: 0,
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ fontSize: 20 }}>🤖</span>
        <div>
          <div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>AI话术助手</div>
          <div style={{ fontSize: 11, color: '#999' }}>
            {env === 'pc' ? '💻 PC端' : '📱 手机端'} | {customerId ? '已连接' : '未连接'}
          </div>
        </div>
      </div>

      <div style={{ display: 'flex', gap: 8 }}>
        <button
          onClick={() => window.location.reload()}
          style={{
            padding: '4px 10px',
            borderRadius: 12,
            border: '1px solid #d9d9d9',
            background: '#fff',
            fontSize: 12,
            cursor: 'pointer',
            color: '#666',
          }}
        >
          🔄 刷新
        </button>
      </div>
    </header>
  );
};

export default Header;

4.1.11 ProfileCard组件(学员画像)

// src/components/ProfileCard.tsx
import React, { useEffect } from 'react';
import { useProfileStore } from '../stores/useProfileStore';
import { Tag } from 'antd-mobile';

/**
 * 学员画像卡片
 * 展示学员类型、意向度、决策阶段等信息
 */
const ProfileCard: React.FC = () => {
  const profile = useProfileStore();

  // 意向度颜色映射
  const getIntentColor = (level: string) => {
    switch (level) {
      case '高': return '#ff4d4f';
      case '中': return '#faad14';
      case '低': return '#52c41a';
      default: return '#999';
    }
  };

  // 决策阶段图标映射
  const getStageIcon = (stage: string) => {
    switch (stage) {
      case '开场白': return '👋';
      case '需求探询': return '🔍';
      case '课程推荐': return '📚';
      case '价值塑造': return '💎';
      case '报价沟通': return '💰';
      case '异议处理': return '⚡';
      case '促成报名': return '🎯';
      case '跟进维护': return '📞';
      default: return '📝';
    }
  };

  if (!profile.customerId) {
    return (
      <div style={{ padding: '12px 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
        <div style={{ color: '#999', fontSize: 13, textAlign: 'center' }}>
          暂无学员画像数据
        </div>
      </div>
    );
  }

  return (
    <div style={{ padding: '12px 16px', background: '#fff', borderBottom: '1px solid #f0f0f0' }}>
      {/* 学员类型 + 意向度 */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>
            {profile.customerName || '未知学员'}
          </span>
          {profile.studentType && (
            <Tag color='primary' fill='outline' style={{ fontSize: 11 }}>
              {profile.studentType}
            </Tag>
          )}
        </div>
        <div style={{
          display: 'flex',
          alignItems: 'center',
          gap: 4,
          padding: '2px 8px',
          borderRadius: 10,
          background: `${getIntentColor(profile.intentLevel)}15`,
          color: getIntentColor(profile.intentLevel),
          fontSize: 12,
          fontWeight: 600,
        }}>
          <span>🔥</span>
          <span>{profile.intentLevel}意向({Math.round(profile.intentScore)})</span>
        </div>
      </div>

      {/* 标签行 */}
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
        {profile.decisionStage && (
          <span className="profile-tag profile-tag-primary">
            {getStageIcon(profile.decisionStage)} {profile.decisionStage}
          </span>
        )}
        {profile.concernFocus && (
          <span className="profile-tag profile-tag-warning">
            💡 {profile.concernFocus}
          </span>
        )}
        <span className="profile-tag profile-tag-success">
          💬 {profile.conversationCount}次对话
        </span>
      </div>
    </div>
  );
};

export default ProfileCard;

4.1.12 ScriptRecommend组件(话术推荐列表)

// src/components/ScriptRecommend.tsx
import React, { useEffect, useState } from 'react';
import { useScriptStore } from '../stores/useScriptStore';
import { useWeComSdk } from '../hooks/useWeComSdk';
import { recommendScripts, submitFeedback } from '../services/api';
import { Loading, Toast } from 'antd-mobile';
import ScriptCard from './ScriptCard';

/**
 * 话术推荐列表组件
 * 展示AI推荐的话术列表,支持一键发送
 */
const ScriptRecommend: React.FC = () => {
  const { customerId } = useWeComSdk();
  const { recommendations, isLoading, setRecommendations, setLoading, setError } = useScriptStore();
  const [lastMessage, setLastMessage] = useState('');

  // 组件加载时获取推荐
  useEffect(() => {
    if (customerId) {
      loadRecommendations();
    }
  }, [customerId]);

  /**
   * 加载话术推荐
   */
  const loadRecommendations = async () => {
    if (!customerId) {
      Toast.show({ content: '请先获取客户ID', position: 'center' });
      return;
    }

    setLoading(true);
    try {
      const result = await recommendScripts({
        message: lastMessage || '获取推荐',
        customerId: customerId,
        corpId: localStorage.getItem('corpId') || 'default',
      });

      if (result.code === 0) {
        setRecommendations(result.data.recommendations);
      } else {
        setError(result.message);
      }
    } catch (e) {
      setError((e as Error).message);
      Toast.show({ content: '获取推荐失败', position: 'center' });
    } finally {
      setLoading(false);
    }
  };

  if (isLoading) {
    return (
      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: 40 }}>
        <Loading color='primary' />
        <span style={{ marginLeft: 8, color: '#999' }}>AI正在分析对话...</span>
      </div>
    );
  }

  if (recommendations.length === 0) {
    return (
      <div style={{ padding: 40, textAlign: 'center', color: '#999' }}>
        <div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
        <div>暂无推荐话术</div>
        <button
          onClick={loadRecommendations}
          style={{
            marginTop: 16,
            padding: '6px 16px',
            borderRadius: 16,
            border: 'none',
            background: '#1677ff',
            color: '#fff',
            fontSize: 13,
            cursor: 'pointer',
          }}
        >
          获取推荐
        </button>
      </div>
    );
  }

  return (
    <div>
      {/* 推荐头部 */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: '8px 16px',
        background: '#f0f7ff',
        borderBottom: '1px solid #d6e4ff',
      }}>
        <div style={{ fontSize: 12, color: '#1677ff', fontWeight: 500 }}>
          💡 为您推荐 {recommendations.length} 条话术
        </div>
        <button
          onClick={loadRecommendations}
          style={{
            padding: '2px 8px',
            borderRadius: 10,
            border: '1px solid #1677ff',
            background: 'transparent',
            color: '#1677ff',
            fontSize: 11,
            cursor: 'pointer',
          }}
        >
          刷新
        </button>
      </div>

      {/* 话术卡片列表 */}
      {recommendations.map((script, index) => (
        <ScriptCard key={script.utteranceId} script={script} rank={index + 1} />
      ))}
    </div>
  );
};

export default ScriptRecommend;

4.1.13 ScriptCard组件(单条话术卡片)

// src/components/ScriptCard.tsx
import React, { useState } from 'react';
import { useWeComSdk } from '../hooks/useWeComSdk';
import type { RecommendItem } from '../services/api';
import { Toast } from 'antd-mobile';

interface ScriptCardProps {
  script: RecommendItem;
  rank: number;
}

/**
 * 单条话术卡片
 * 包含话术内容、匹配分数、一键发送/编辑发送按钮
 */
const ScriptCard: React.FC<ScriptCardProps> = ({ script, rank }) => {
  const { env, sendScript } = useWeComSdk();
  const [isExpanded, setIsExpanded] = useState(false);
  const [isSending, setIsSending] = useState(false);
  const [isEditing, setIsEditing] = useState(false);
  const [editContent, setEditContent] = useState(script.content);

  // 最大显示字符数
  const MAX_PREVIEW_LENGTH = 120;
  const shouldTruncate = script.content.length > MAX_PREVIEW_LENGTH;
  const displayContent = isExpanded
    ? script.content
    : shouldTruncate
      ? script.content.substring(0, MAX_PREVIEW_LENGTH) + '...'
      : script.content;

  /**
   * 一键发送
   */
  const handleSend = async () => {
    setIsSending(true);
    const content = isEditing ? editContent : script.content;
    // PC端不进入会话,手机端进入会话
    const enterChat = env === 'mobile';
    const success = await sendScript(content, enterChat);
    setIsSending(false);

    if (success) {
      Toast.show({ content: '已发送到对话框 ✅', position: 'center' });
      // 记录使用反馈
      submitFeedback(script.utteranceId, 'send');
    } else {
      Toast.show({ content: '发送失败,请重试', position: 'center' });
    }
  };

  /**
   * 编辑后发送
   */
  const handleEditSend = async () => {
    if (!isEditing) {
      setIsEditing(true);
      return;
    }
    await handleSend();
    setIsEditing(false);
  };

  /**
   * 提交反馈
   */
  const submitFeedback = (utteranceId: string, action: string) => {
    // 异步提交反馈
    fetch(`${import.meta.env.VITE_API_BASE_URL}/api/v1/recommend/feedback`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ utteranceId, action }),
    }).catch(() => { /* 静默处理 */ });
  };

  return (
    <div className="script-card">
      {/* 卡片头部 */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          {/* 排名 */}
          <span style={{
            width: 20,
            height: 20,
            borderRadius: 10,
            background: rank <= 3 ? '#1677ff' : '#f0f0f0',
            color: rank <= 3 ? '#fff' : '#999',
            fontSize: 12,
            fontWeight: 600,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}>
            {rank}
          </span>
          {/* 标题 */}
          <span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>
            {script.title}
          </span>
        </div>
        {/* 匹配分数 */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
          <span style={{
            padding: '2px 6px',
            borderRadius: 8,
            background: script.score > 0.8 ? '#f6ffed' : script.score > 0.6 ? '#fffbe6' : '#fff2f0',
            color: script.score > 0.8 ? '#52c41a' : script.score > 0.6 ? '#faad14' : '#ff4d4f',
            fontSize: 11,
            fontWeight: 500,
          }}>
            {Math.round(script.score * 100)}%匹配
          </span>
        </div>
      </div>

      {/* 话术内容 */}
      <div style={{ marginBottom: 8 }}>
        {isEditing ? (
          <textarea
            value={editContent}
            onChange={(e) => setEditContent(e.target.value)}
            style={{
              width: '100%',
              minHeight: 100,
              padding: 8,
              borderRadius: 8,
              border: '1px solid #1677ff',
              fontSize: 13,
              lineHeight: 1.6,
              resize: 'vertical',
              fontFamily: 'inherit',
            }}
          />
        ) : (
          <pre style={{
            fontSize: 13,
            lineHeight: 1.6,
            color: '#444',
            whiteSpace: 'pre-wrap',
            wordBreak: 'break-word',
            fontFamily: 'inherit',
            margin: 0,
          }}>
            {displayContent}
          </pre>
        )}

        {/* 展开/收起 */}
        {shouldTruncate && !isEditing && (
          <button
            onClick={() => setIsExpanded(!isExpanded)}
            style={{
              marginTop: 4,
              border: 'none',
              background: 'transparent',
              color: '#1677ff',
              fontSize: 12,
              cursor: 'pointer',
              padding: 0,
            }}
          >
            {isExpanded ? '收起 ▲' : '展开 ▼'}
          </button>
        )}
      </div>

      {/* 元数据 */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        gap: 12,
        marginBottom: 10,
        fontSize: 11,
        color: '#999',
      }}>
        <span>📊 成功率{Math.round((script.successRate || 0) * 100)}%</span>
        <span>🔥 使用{script.usedCount}次</span>
        <span style={{
          padding: '1px 4px',
          borderRadius: 4,
          background: script.source === 'LLM_GENERATED' ? '#e6f7ff' : '#f6ffed',
          color: script.source === 'LLM_GENERATED' ? '#1677ff' : '#52c41a',
          fontSize: 10,
        }}>
          {script.source === 'LLM_GENERATED' ? '🤖 AI生成' : '📚 话术库'}
        </span>
      </div>

      {/* 操作按钮 */}
      <div style={{ display: 'flex', gap: 8 }}>
        {/* 一键发送 */}
        <button
          onClick={handleSend}
          disabled={isSending}
          className="send-btn send-btn-primary"
          style={{ flex: 1 }}
        >
          {isSending ? '发送中...' : '🚀 一键发送'}
        </button>

        {/* 编辑发送 */}
        <button
          onClick={handleEditSend}
          className="send-btn"
          style={{
            flex: 1,
            background: '#f0f0f0',
            color: '#666',
          }}
        >
          {isEditing ? '💾 保存发送' : '✏️ 编辑发送'}
        </button>

        {/* 取消编辑 */}
        {isEditing && (
          <button
            onClick={() => {
              setIsEditing(false);
              setEditContent(script.content);
            }}
            className="send-btn"
            style={{
              background: '#fff2f0',
              color: '#ff4d4f',
            }}
          >
            ❌ 取消
          </button>
        )}
      </div>
    </div>
  );
};

export default ScriptCard;

4.1.14 TabBar组件

// src/components/TabBar.tsx
import React from 'react';

interface TabBarProps {
  activeTab: 'recommend' | 'analysis' | 'search';
  onChange: (tab: 'recommend' | 'analysis' | 'search') => void;
}

/**
 * Tab切换栏
 * 智能推荐 / 对话分析 / 话术搜索
 */
const TabBar: React.FC<TabBarProps> = ({ activeTab, onChange }) => {
  const tabs = [
    { key: 'recommend' as const, label: '智能推荐', icon: '💡' },
    { key: 'analysis' as const, label: '对话分析', icon: '📊' },
    { key: 'search' as const, label: '话术搜索', icon: '🔍' },
  ];

  return (
    <div style={{
      display: 'flex',
      background: '#fff',
      borderBottom: '1px solid #f0f0f0',
      flexShrink: 0,
    }}>
      {tabs.map((tab) => (
        <button
          key={tab.key}
          onClick={() => onChange(tab.key)}
          style={{
            flex: 1,
            padding: '10px 0',
            border: 'none',
            background: 'transparent',
            fontSize: 13,
            cursor: 'pointer',
            color: activeTab === tab.key ? '#1677ff' : '#666',
            fontWeight: activeTab === tab.key ? 600 : 400,
            borderBottom: activeTab === tab.key ? '2px solid #1677ff' : '2px solid transparent',
            transition: 'all 0.2s',
          }}
        >
          <span style={{ marginRight: 4 }}>{tab.icon}</span>
          {tab.label}
        </button>
      ))}
    </div>
  );
};

export default TabBar;

4.1.15 AnalysisPanel组件(对话分析面板)

// src/components/AnalysisPanel.tsx
import React from 'react';
import { useProfileStore } from '../stores/useProfileStore';

/**
 * 对话分析面板
 * 展示对话摘要、意图历史、关键信息等
 */
const AnalysisPanel: React.FC = () => {
  const profile = useProfileStore();

  return (
    <div style={{ padding: 16 }}>
      {/* 对话摘要 */}
      <Section title="📋 对话摘要" icon="📋">
        <div style={{ fontSize: 13, color: '#666', lineHeight: 1.8 }}>
          学员为{profile.studentType || '未知类型'},当前处于
          <strong style={{ color: '#1677ff' }}>{profile.decisionStage || '信息了解'}</strong>阶段。
          关注重点为{profile.concernFocus || '未明确'},意向度
          <strong style={{ color: profile.intentLevel === '高' ? '#ff4d4f' : '#faad14' }}>
            {profile.intentLevel || '低'}
          </strong>。
        </div>
      </Section>

      {/* 关键信息 */}
      <Section title="🔑 关键信息" icon="🔑">
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
          <InfoItem label="学员类型" value={profile.studentType || '-'} />
          <InfoItem label="意向度" value={profile.intentLevel || '-'} />
          <InfoItem label="关注重点" value={profile.concernFocus || '-'} />
          <InfoItem label="决策阶段" value={profile.decisionStage || '-'} />
          <InfoItem label="对话次数" value={String(profile.conversationCount || 0)} />
          <InfoItem label="意向分数" value={String(Math.round(profile.intentScore || 0))} />
        </div>
      </Section>

      {/* 建议话术方向 */}
      <Section title="💡 建议话术方向" icon="💡">
        <div style={{ fontSize: 13, color: '#666', lineHeight: 1.8 }}>
          {getStageAdvice(profile.decisionStage)}
        </div>
      </Section>

      {/* 风险提示 */}
      {profile.intentLevel === '低' && (
        <div style={{
          marginTop: 12,
          padding: 12,
          borderRadius: 8,
          background: '#fffbe6',
          border: '1px solid #ffe58f',
        }}>
          <div style={{ fontSize: 13, color: '#d48806' }}>
            ⚠️ 当前学员意向度较低,建议耐心引导,先建立信任关系,避免急于推销。
          </div>
        </div>
      )}
    </div>
  );
};

/**
 * 获取阶段建议
 */
function getStageAdvice(stage: string): string {
  switch (stage) {
    case '开场白':
      return '1. 热情问候,建立信任\n2. 了解学员基本信息\n3. 确认学员兴趣方向\n4. 引导进入需求探询阶段';
    case '需求探询':
      return '1. 深入了解学员背景和目标\n2. 挖掘核心需求\n3. 记录关键信息到画像\n4. 适时引入课程推荐';
    case '课程推荐':
      return '1. 精准匹配1-2个课程方向\n2. 详细介绍匹配课程的亮点\n3. 用数据支撑推荐\n4. 引导进入价值塑造阶段';
    case '价值塑造':
      return '1. 强调品牌优势和差异化\n2. 展示成功案例\n3. 提及权威背书(央视报道等)\n4. 消除学员顾虑';
    case '报价沟通':
      return '1. 明确说明价格\n2. 强调投入产出比\n3. 介绍分期付款方案\n4. 提供限时优惠';
    case '异议处理':
      return '1. 先认同学员顾虑\n2. 用事实和数据回应\n3. 提供替代方案\n4. 引导回到正向话题';
    case '促成报名':
      return '1. 制造紧迫感\n2. 明确报名流程\n3. 提供额外优惠\n4. 协助完成报名';
    default:
      return '根据学员画像和对话上下文,灵活调整话术策略。重点关注学员的核心需求和顾虑点。';
  }
}

/**
 * 信息项子组件
 */
const InfoItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
  <div style={{
    padding: 8,
    borderRadius: 8,
    background: '#f5f5f5',
  }}>
    <div style={{ fontSize: 11, color: '#999', marginBottom: 2 }}>{label}</div>
    <div style={{ fontSize: 13, color: '#333', fontWeight: 500 }}>{value}</div>
  </div>
);

/**
 * 区块子组件
 */
const Section: React.FC<{ title: string; icon: string; children: React.ReactNode }> = ({
  title,
  children,
}) => (
  <div style={{ marginBottom: 16 }}>
    <div style={{
      fontSize: 14,
      fontWeight: 600,
      color: '#333',
      marginBottom: 8,
      paddingLeft: 8,
      borderLeft: '3px solid #1677ff',
    }}>
      {title}
    </div>
    {children}
  </div>
);

export default AnalysisPanel;

4.2 管理后台

管理后台基于React + Ant Design实现,包含话术库管理、数据统计看板等功能。

4.2.1 管理后台路由配置

// frontend/admin/src/router.tsx
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import UtteranceList from './pages/UtteranceList';
import UtteranceEdit from './pages/UtteranceEdit';
import DataStats from './pages/DataStats';
import Settings from './pages/Settings';

/**
 * 管理后台路由
 */
const AdminRouter: React.FC = () => (
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Dashboard />} />
        <Route path="utterances" element={<UtteranceList />} />
        <Route path="utterances/edit/:id?" element={<UtteranceEdit />} />
        <Route path="stats" element={<DataStats />} />
        <Route path="settings" element={<Settings />} />
      </Route>
    </Routes>
  </BrowserRouter>
);

export default AdminRouter;

4.2.2 话术库管理页面

// frontend/admin/src/pages/UtteranceList.tsx
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Tag, Space, Modal, message } from 'antd';
import { useNavigate } from 'react-router-dom';

/**
 * 话术库管理页面
 * 话术列表、搜索、启用/停用、删除
 */
const UtteranceList: React.FC = () => {
  const navigate = useNavigate();
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<any[]>([]);
  const [keyword, setKeyword] = useState('');
  const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });

  // 加载话术列表
  const loadData = async (page: number = 1, pageSize: number = 20) => {
    setLoading(true);
    try {
      const response = await fetch(
        `/api/v1/kb/utterances?page=${page}&size=${pageSize}&keyword=${keyword}`
      );
      const result = await response.json();
      if (result.code === 0) {
        setData(result.data.list);
        setPagination({ ...pagination, current: page, total: result.data.total });
      }
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => { loadData(); }, []);

  const columns = [
    { title: '话术ID', dataIndex: 'utteranceId', key: 'utteranceId', width: 180 },
    { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
    {
      title: '阶段',
      dataIndex: 'stageTags',
      key: 'stageTags',
      render: (tags: string) => {
        try {
          const tagList = JSON.parse(tags);
          return tagList.map((t: string) => <Tag key={t} color="blue">{t}</Tag>);
        } catch { return '-'; }
      },
    },
    {
      title: '状态',
      dataIndex: 'status',
      key: 'status',
      render: (status: string) => (
        <Tag color={status === 'ACTIVE' ? 'green' : status === 'DRAFT' ? 'orange' : 'red'}>
          {status === 'ACTIVE' ? '启用' : status === 'DRAFT' ? '草稿' : '停用'}
        </Tag>
      ),
    },
    {
      title: '成功率',
      dataIndex: 'successRate',
      key: 'successRate',
      render: (rate: number) => `${Math.round((rate || 0) * 100)}%`,
    },
    { title: '使用次数', dataIndex: 'usedCount', key: 'usedCount' },
    {
      title: '操作',
      key: 'action',
      render: (_: any, record: any) => (
        <Space>
          <Button type="link" onClick={() => navigate(`/utterances/edit/${record.utteranceId}`)}>
            编辑
          </Button>
          <Button type="link" danger onClick={() => handleDelete(record.utteranceId)}>
            删除
          </Button>
        </Space>
      ),
    },
  ];

  const handleDelete = (utteranceId: string) => {
    Modal.confirm({
      title: '确认删除',
      content: `确定要删除话术「${utteranceId}」吗?`,
      onOk: async () => {
        await fetch(`/api/v1/kb/utterances/${utteranceId}`, { method: 'DELETE' });
        message.success('删除成功');
        loadData(pagination.current);
      },
    });
  };

  return (
    <div>
      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
        <Input.Search
          placeholder="搜索话术标题/内容"
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
          onSearch={() => loadData(1)}
          style={{ width: 300 }}
        />
        <Button type="primary" onClick={() => navigate('/utterances/edit')}>
          + 新建话术
        </Button>
      </div>
      <Table
        rowKey="id"
        columns={columns}
        dataSource={data}
        loading={loading}
        pagination={pagination}
        onChange={(p) => loadData(p.current, p.pageSize)}
      />
    </div>
  );
};

export default UtteranceList;

第五部分:企微集成配置指南

5.1 企微后台配置步骤

5.1.1 注册企业微信

  1. 访问 https://work.weixin.qq.com/ 注册企业
  2. 完成企业认证(可选,但认证后功能更完整)
  3. 获取企业ID(CorpID)- 在「我的企业」→「企业ID」中查看

5.1.2 创建自建应用

  1. 进入「应用管理」
  2. 点击「创建应用」
  3. 填写应用信息:
    • 应用名称:AI话术助手
    • 应用Logo:上传应用图标
    • 可见范围:选择需要使用的部门/人员
  4. 创建完成后记录:
    • AgentId
    • Secret(点击「查看」获取)

5.1.3 配置可信域名

  1. 进入应用详情页 →「网页授权及JS-SDK」
  2. 点击「设置可信域名」
  3. 填写域名:
    • 申请校验域名(下载校验文件上传到服务器根目录)
    • 或者使用已备案域名,上传校验文件验证
  4. 同时配置「授权回调域」(用于OAuth登录)

5.1.4 申请会话内容存档

  1. 进入「管理工具」→「会话内容存档」
  2. 点击「开通」并选择开通范围(全部或部分成员)
  3. 开通后获取:
    • 企业ID(CorpID)
    • 存档Secret(在「会话内容存档」→「API」中获取)
  4. 下载C SDK(按操作系统选择Linux/Windows版本)

5.1.5 配置聊天工具栏

  1. 进入「客户联系」→「加客户」→「聊天工具栏」
  2. 点击「配置应用页面」
  3. 选择刚才创建的「AI话术助手」应用
  4. 配置页面参数:

5.1.6 配置回调URL

  1. 进入「管理工具」→「会话内容存档」→「API」
  2. 配置回调:
  3. 记录Token和EncodingAESKey(后端配置需要)

5.2 RSA密钥生成与配置

5.2.1 生成RSA密钥对

# 使用OpenSSL生成2048位RSA密钥对

# 1. 生成私钥(PKCS#1格式,企微要求)
openssl genrsa -out private_key.pem 2048

# 2. 从私钥提取公钥
openssl rsa -in private_key.pem -pubout -out public_key.pem

# 3. 将公钥转为Base64(上传到企微后台)
base64 -w 0 public_key.pem > public_key_base64.txt

# 4. 查看私钥(妥善保存,不要泄露)
cat private_key.pem

# 5. 查看公钥Base64
cat public_key_base64.txt

5.2.2 上传公钥到企微后台

  1. 进入「管理工具」→「会话内容存档」→「API」
  2. 找到「公钥配置」
  3. 将 public_key_base64.txt 的内容粘贴进去
  4. 点击「保存」

5.2.3 私钥存储到服务器

# 1. 创建密钥目录
mkdir -p /opt/ai-assistant/keys
chmod 700 /opt/ai-assistant/keys

# 2. 将私钥复制到服务器
cp private_key.pem /opt/ai-assistant/keys/
chmod 600 /opt/ai-assistant/keys/private_key.pem

# 3. 设置环境变量(在docker-compose.yml或启动脚本中)
export ARCHIVE_RSA_PRIVATE_KEY_PATH=/opt/ai-assistant/keys/private_key.pem

# 或者将私钥内容直接作为环境变量(不推荐,太长)
# export ARCHIVE_RSA_PRIVATE_KEY=$(cat /opt/ai-assistant/keys/private_key.pem)

5.3 JS-SDK配置代码

5.3.1 后端签名生成接口

package com.artedu.auth.controller;

import org.springframework.web.bind.annotation.*;
import java.security.MessageDigest;
import java.util.Formatter;
import java.util.UUID;

/**
 * 企微JS-SDK签名接口
 */
@RestController
@RequestMapping("/api/v1/auth")
public class JsSdkSignatureController {

    /**
     * 获取JS-SDK签名
     * 前端调用ww.register时需要
     */
    @GetMapping("/signature")
    public Result<Map<String, Object>> getSignature(
            @RequestParam("url") String url,
            @RequestParam("corpId") String corpId) {

        try {
            // 1. 获取jsapi_ticket(通过企微API)
            String ticket = getJsApiTicket(corpId);

            // 2. 生成随机字符串
            String nonceStr = UUID.randomUUID().toString().replace("-", "");

            // 3. 生成时间戳
            long timestamp = System.currentTimeMillis() / 1000;

            // 4. 拼接签名字符串
            String string1 = String.format(
                    "jsapi_ticket=%s&noncestr=%s&timestamp=%d&url=%s",
                    ticket, nonceStr, timestamp, url
            );

            // 5. SHA1签名
            String signature = sha1(string1);

            Map<String, Object> result = new HashMap<>();
            result.put("corpId", corpId);
            result.put("agentId", getAgentId(corpId));
            result.put("nonceStr", nonceStr);
            result.put("timestamp", timestamp);
            result.put("signature", signature);

            return Result.success(result);

        } catch (Exception e) {
            log.error("生成签名失败", e);
            return Result.fail("生成签名失败");
        }
    }

    private String sha1(String input) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA1");
        byte[] digest = md.digest(input.getBytes());
        try (Formatter formatter = new Formatter()) {
            for (byte b : digest) {
                formatter.format("%02x", b);
            }
            return formatter.toString();
        }
    }
}

5.3.2 前端ww.register调用

已在「第四部分」4.1.6节提供完整代码,此处补充完整流程图:

sequenceDiagram
    participant FE as 前端H5
    participant BE as 后端服务
    participant WeComAPI as 企微API
    participant WeComClient as 企微客户端

    FE->>BE: 1. GET /api/v1/auth/signature?url=当前页面URL
    BE->>WeComAPI: 2. 获取jsapi_ticket(用access_token)
    WeComAPI-->>BE: 返回ticket
    BE->>BE: 3. 生成nonceStr + timestamp
    BE->>BE: 4. SHA1签名(ticket+nonceStr+timestamp+url)
    BE-->>FE: 返回{corpId, agentId, nonceStr, timestamp, signature}

    FE->>FE: 5. 判断iOS/Android
    alt iOS
        FE->>FE: setTimeout 100ms后调用
    end
    FE->>WeComClient: 6. ww.register({corpId, agentId, jsApiList, getAgentConfigSignature})
    WeComClient-->>FE: 注册成功回调
    FE->>WeComClient: 7. ww.invoke('getCurExternalContact')
    WeComClient-->>FE: 返回customerId
    FE->>FE: 8. 初始化完成,加载话术推荐

第六部分:部署与运维

6.1 Docker Compose部署

6.1.1 完整docker-compose.yml

version: '3.8'

services:
  # ========== 前端 ==========
  sidebar:
    build:
      context: ./frontend/sidebar
      dockerfile: Dockerfile
    container_name: ai-sidebar
    ports:
      - "3000:80"
    depends_on:
      - gateway
    networks:
      - ai-network

  admin:
    build:
      context: ./frontend/admin
      dockerfile: Dockerfile
    container_name: ai-admin
    ports:
      - "3001:80"
    depends_on:
      - gateway
    networks:
      - ai-network

  # ========== API网关 ==========
  gateway:
    build:
      context: ./backend/gateway
      dockerfile: Dockerfile
    container_name: ai-gateway
    ports:
      - "8080:8080"
    environment:
      - NACOS_HOST=nacos
      - NACOS_PORT=8848
    depends_on:
      - nacos
      - redis
      - rabbitmq
    networks:
      - ai-network

  # ========== 业务服务 ==========
  auth-service:
    build:
      context: ./backend/auth-service
      dockerfile: Dockerfile
    container_name: ai-auth
    environment:
      - SERVER_PORT=8081
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=ai_assistant
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - WECOM_CORP_ID=${WECOM_CORP_ID}
      - WECOM_AGENT_ID=${WECOM_AGENT_ID}
      - WECOM_SECRET=${WECOM_SECRET}
      - JWT_SECRET=${JWT_SECRET}
      - NACOS_HOST=nacos
    depends_on:
      - mysql
      - redis
    networks:
      - ai-network

  archive-service:
    build:
      context: ./backend/archive-service
      dockerfile: Dockerfile
    container_name: ai-archive
    environment:
      - SERVER_PORT=8082
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=ai_assistant
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - WECOM_ARCHIVE_CORP_ID=${WECOM_CORP_ID}
      - WECOM_ARCHIVE_SECRET=${WECOM_ARCHIVE_SECRET}
      - ARCHIVE_RSA_PRIVATE_KEY_PATH=/keys/private_key.pem
      - NACOS_HOST=nacos
    volumes:
      - ./keys:/keys:ro
    depends_on:
      - mysql
      - redis
      - rabbitmq
    networks:
      - ai-network

  conversation-service:
    build:
      context: ./backend/conversation-service
      dockerfile: Dockerfile
    container_name: ai-conversation
    environment:
      - SERVER_PORT=8083
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=ai_assistant
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - RABBITMQ_HOST=rabbitmq
      - RABBITMQ_PORT=5672
      - RABBITMQ_USERNAME=guest
      - RABBITMQ_PASSWORD=guest
      - NACOS_HOST=nacos
    depends_on:
      - mysql
      - redis
      - rabbitmq
    networks:
      - ai-network

  intent-service:
    build:
      context: ./backend/intent-service
      dockerfile: Dockerfile
    container_name: ai-intent
    environment:
      - SERVER_PORT=8084
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=ai_assistant
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - LLM_QIANWEN_API_KEY=${LLM_API_KEY}
      - LLM_QIANWEN_MODEL=qwen-turbo
      - NACOS_HOST=nacos
    depends_on:
      - mysql
      - redis
      - rabbitmq
    networks:
      - ai-network

  recommendation-service:
    build:
      context: ./backend/recommendation-service
      dockerfile: Dockerfile
    container_name: ai-recommend
    environment:
      - SERVER_PORT=8085
      - MYSQL_HOST=mysql
      - MYSQL_PORT=3306
      - MYSQL_DATABASE=ai_assistant
      - MYSQL_USERNAME=root
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - LLM_QIANWEN_API_KEY=${LLM_API_KEY}
      - LLM_QIANWEN_MODEL=qwen-turbo
      - NACOS_HOST=nacos
    depends_on:
      - mysql
      - redis
      - rabbitmq
    networks:
      - ai-network

  # ========== 基础设施 ==========
  nginx:
    image: nginx:1.25-alpine
    container_name: ai-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - gateway
      - sidebar
      - admin
    networks:
      - ai-network

  mysql:
    image: mysql:5.7
    container_name: ai-mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=ai_assistant
      - TZ=Asia/Shanghai
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
      - ./mysql.cnf:/etc/mysql/conf.d/custom.cnf:ro
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci
      --default-time-zone=+8:00      
    networks:
      - ai-network

  redis:
    image: redis:7-alpine
    container_name: ai-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    networks:
      - ai-network

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    container_name: ai-rabbitmq
    ports:
      - "5672:5672"
      - "15672:15672"
    environment:
      - RABBITMQ_DEFAULT_USER=guest
      - RABBITMQ_DEFAULT_PASS=guest
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      - ai-network

  nacos:
    image: nacos/nacos-server:v2.2.3
    container_name: ai-nacos
    ports:
      - "8848:8848"
    environment:
      - MODE=standalone
      - PREFER_HOST_MODE=hostname
      - SPRING_DATASOURCE_PLATFORM=mysql
      - MYSQL_SERVICE_HOST=mysql
      - MYSQL_SERVICE_PORT=3306
      - MYSQL_SERVICE_DB_NAME=nacos
      - MYSQL_SERVICE_USER=root
      - MYSQL_SERVICE_PASSWORD=${MYSQL_PASSWORD}
    depends_on:
      - mysql
    networks:
      - ai-network

volumes:
  mysql_data:
  redis_data:
  rabbitmq_data:

networks:
  ai-network:
    driver: bridge

6.1.2 Nginx配置

# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
    access_log /var/log/nginx/access.log main;

    # Gzip压缩
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;
    gzip_min_length 1000;

    # 企微可信域名校验
    server {
        listen 80;
        server_name your-domain.com;

        location /WW_verify_*.txt {
            root /var/www;
        }

        # 侧边栏H5
        location /sidebar/ {
            proxy_pass http://sidebar:80/;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        # 管理后台
        location /admin/ {
            proxy_pass http://admin:80/;
            proxy_set_header Host $host;
        }

        # API网关
        location /api/ {
            proxy_pass http://gateway:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_connect_timeout 30s;
            proxy_send_timeout 30s;
            proxy_read_timeout 30s;
        }
    }
}

6.2 MySQL 5.7配置优化

6.2.1 my.cnf关键参数

# mysql.cnf - MySQL 5.7优化配置
[mysqld]
# 基础配置
port = 3306
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
skip-character-set-client-handshake
default-time-zone = +8:00

# InnoDB配置
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
innodb_file_per_table = 1

# 连接配置
max_connections = 200
wait_timeout = 28800
interactive_timeout = 28800

# 查询缓存(MySQL 5.7已废弃,关闭)
query_cache_type = 0
query_cache_size = 0

# 临时表
tmp_table_size = 64M
max_heap_table_size = 64M

# 日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2

# 排序和连接
sort_buffer_size = 2M
join_buffer_size = 2M
read_buffer_size = 1M
read_rnd_buffer_size = 2M

# 安全
local_infile = 0
symbolic-links = 0

[mysql]
default-character-set = utf8mb4

[client]
default-character-set = utf8mb4

6.2.2 连接池配置

# 各服务的application.yml中的连接池配置
spring:
  datasource:
    hikari:
      minimum-idle: 5
      maximum-pool-size: 20
      idle-timeout: 300000
      max-lifetime: 1200000
      connection-timeout: 20000
      pool-name: HikariPool-AI

6.3 环境变量清单

变量名 说明 开发环境 生产环境
MYSQL_HOST MySQL主机地址 localhost mysql
MYSQL_PORT MySQL端口 3306 3306
MYSQL_DATABASE 数据库名 ai_assistant ai_assistant
MYSQL_USERNAME MySQL用户名 root root
MYSQL_PASSWORD MySQL密码 root (强密码)
REDIS_HOST Redis主机地址 localhost redis
REDIS_PORT Redis端口 6379 6379
REDIS_PASSWORD Redis密码 (空) (强密码)
RABBITMQ_HOST RabbitMQ主机 localhost rabbitmq
RABBITMQ_PORT RabbitMQ端口 5672 5672
RABBITMQ_USERNAME RabbitMQ用户名 guest (自定义)
RABBITMQ_PASSWORD RabbitMQ密码 guest (强密码)
WECOM_CORP_ID 企微企业ID - wx...
WECOM_AGENT_ID 企微应用ID - 1000002
WECOM_SECRET 企微应用Secret - (从企微后台获取)
WECOM_ARCHIVE_SECRET 存档Secret - (从企微后台获取)
JWT_SECRET JWT密钥 dev-secret (32位以上随机字符串)
LLM_API_KEY 通义千问API Key - sk-...
ARCHIVE_RSA_PRIVATE_KEY_PATH RSA私钥路径 ./keys/private.pem /keys/private.pem
NACOS_HOST Nacos主机 localhost nacos
NACOS_PORT Nacos端口 8848 8848

第七部分:开发指南(面向AI编程助手)

7.1 开发顺序建议

推荐按以下顺序开发,每个阶段完成后进行测试:

Phase 1: 基础设施(第1-2周)
├── 1. 数据库初始化(init.sql)
│   └── 运行建表SQL + 初始化数据
├── 2. 认证授权服务(auth-service)
│   └── 企微OAuth + JWT + 员工管理
├── 3. Docker环境搭建
│   └── docker-compose up -d mysql redis rabbitmq nacos
└── 测试:确认登录流程可用

Phase 2: 数据接入(第3-4周)
├── 4. 会话存档服务(archive-service)
│   └── 回调接收 + SDK集成 + 消息存储
├── 5. 对话服务(conversation-service)
│   └── 轮次解析 + 上下文管理
└── 测试:确认消息能正常存入数据库

Phase 3: AI引擎(第5-7周)
├── 6. 意图服务(intent-service)
│   └── 规则匹配 + LLM识别 + 画像构建
├── 7. LLM生成服务(generation-service)
│   └── Prompt工程 + Token控制 + 流式输出
├── 8. 推荐服务(recommendation-service)
│   └── 三层召回 + 多因子排序 + MMR
└── 测试:确认推荐结果合理

Phase 4: 前端开发(第8-9周)
├── 9. 侧边栏H5
│   ├── PC端适配(360px固定宽度)
│   ├── 手机端适配(80%高度底部浮层)
│   ├── 企微JS-SDK集成
│   └── 一键发送功能
├── 10. 管理后台
│   ├── 话术库CRUD
│   └── 数据统计看板
└── 测试:确认PC端和手机端都可用

Phase 5: 集成测试(第10-12周)
├── 11. 端到端测试
│   └── 完整对话流程验证
├── 12. 性能优化
│   └── 接口响应时间 < 500ms
└── 13. 上线部署
    └── 生产环境Docker部署

7.2 Kimi Code / Claude Code 使用建议

7.2.1 如何分模块让AI生成代码

建议每次让AI处理一个独立的模块,Prompt示例:

## 任务:生成[服务名]的[功能模块]

### 上下文
- 项目:第九联盟AI坐席辅助系统
- 技术栈:Spring Boot 2.7 + MyBatis-Plus + MySQL 5.7 + Redis + RabbitMQ
- 当前服务:[服务名],端口[端口号]

### 需求
[详细描述需求]

### 数据库表
```sql
[建表SQL]

要求

  1. 完整的Java代码(含package、import、注解)
  2. 使用MyBatis-Plus进行CRUD操作
  3. JSON字段使用TEXT类型存储(MySQL 5.7兼容)
  4. 中文注释
  5. 包含单元测试

输出

请生成以下文件:

  1. Entity.java
  2. Mapper.java
  3. Service.java
  4. Controller.java
  5. DTO.java(如需要)

#### 7.2.2 代码Review的Prompt模板

```markdown
请对以下Java代码进行Review,关注:
1. MySQL 5.7兼容性(不能用JSON类型)
2. 安全性(SQL注入、XSS等)
3. 性能(N+1查询、索引使用)
4. 代码风格(命名规范、注释完整性)
5. 错误处理(异常捕获、降级策略)

代码:
[粘贴代码]

请输出:
1. 发现的问题(严重程度:高/中/低)
2. 修改建议(含修改后的代码片段)
3. 评分(1-10分)

7.2.3 调试排错的Prompt模板

## 问题描述
[描述遇到的问题]

## 错误日志

[粘贴错误日志]


## 相关代码
```java
[粘贴相关代码]

环境信息

  • MySQL 5.7
  • Spring Boot 2.7.x
  • [其他相关信息]

请分析可能的原因并给出解决方案。


### 7.3 常见问题与解决方案

#### 7.3.1 MySQL 5.7 JSON处理

**问题**:MySQL 5.7不支持JSON类型,如何存储结构化数据?

**解决方案**:
1. 所有JSON字段使用TEXT类型
2. 实体类中使用String类型
3. 添加getter/setter方法进行序列化/反序列化
4. 使用MyBatis TypeHandler自动处理

```java
// 实体类示例
@Data
public class Utterance {
    private Long id;
    private String title;
    private String content;
    // TEXT类型存储JSON字符串
    private String intentTags;
    private String stageTags;
    private String profileTags;

    // 便捷方法:获取标签列表
    public List<String> getIntentTagList() {
        return JsonUtils.fromJsonList(intentTags, String.class);
    }

    // 便捷方法:设置标签列表
    public void setIntentTagList(List<String> tags) {
        this.intentTags = JsonUtils.toJson(tags);
    }
}

7.3.2 企微JS-SDK调试

问题:ww.register调用失败,提示"invalid signature"

排查步骤:

  1. 确认corpId和agentId正确
  2. 确认当前页面URL与签名时使用的URL一致(去掉#后面的部分)
  3. 确认jsapi_ticket未过期(2小时有效期)
  4. 确认签名算法正确(SHA1)
  5. iOS设备需使用setTimeout包裹

调试代码:

// 在ww.register前打印调试信息
console.log('Debug:', {
  corpId,
  agentId,
  url: window.location.href.split('#')[0],
  nonceStr,
  timestamp,
  signature,
  userAgent: navigator.userAgent,
});

7.3.3 LLM调用优化

问题:LLM调用慢(2-3秒),影响用户体验

解决方案:

  1. 异步调用:LLM调用改为异步,不阻塞主流程
  2. 缓存机制:相同输入的LLM结果缓存5分钟
  3. 降级策略:LLM失败时返回规则匹配结果
  4. 模型选择:意图识别用qwen-turbo(快速),话术生成用qwen-plus(高质量)
  5. 流式输出:对话生成使用SSE流式输出

7.3.4 性能调优

优化项 策略 目标
数据库查询 添加索引 + 分页查询 单条查询 < 50ms
LLM调用 异步 + 缓存 + 降级 首次调用 < 2s,缓存命中 < 10ms
向量检索 Redis Hash + 近似计算 TopK检索 < 100ms
接口响应 线程池 + 超时控制 P99 < 500ms
消息处理 批量处理 + 队列削峰 1000条/秒

7.3.5 上线检查清单

## 上线前检查清单

### 数据库
- [ ] init.sql已成功执行
- [ ] 所有表使用InnoDB + utf8mb4
- [ ] 索引已创建
- [ ] 初始化数据已插入(40个意图 + 8个分类 + 20条话术)

### 企微配置
- [ ] CorpID正确配置
- [ ] AgentID正确配置
- [ ] Secret正确配置
- [ ] 存档Secret正确配置
- [ ] RSA私钥已上传服务器
- [ ] 回调URL已配置
- [ ] 可信域名已配置
- [ ] 聊天工具栏已配置

### 环境变量
- [ ] MySQL密码已设置(强密码)
- [ ] Redis密码已设置(强密码)
- [ ] JWT Secret已设置(32位以上随机字符串)
- [ ] 通义千问API Key已配置

### 服务启动
- [ ] MySQL已启动且可连接
- [ ] Redis已启动且可连接
- [ ] RabbitMQ已启动且可连接
- [ ] Nacos已启动且可访问
- [ ] 所有微服务已注册到Nacos

### 功能验证
- [ ] 企微OAuth登录正常
- [ ] JS-SDK注册成功
- [ ] 消息存档正常拉取
- [ ] 意图识别正常
- [ ] 话术推荐正常
- [ ] 一键发送正常
- [ ] 管理后台可访问

附录

A. 完整的意图分类表(40个)

领域 编码 名称 优先级
课程相关 INT-COURSE-01 课程内容咨询 P1
课程相关 INT-COURSE-02 课程选择建议 P1
课程相关 INT-COURSE-03 课程大纲索要 P1
课程相关 INT-COURSE-04 试听预约咨询 P1
课程相关 INT-COURSE-05 课程更新迭代 P2
课程相关 INT-COURSE-06 线上vs线下选择 P1
课程相关 INT-COURSE-07 课程难度询问 P1
课程相关 INT-COURSE-08 学习周期询问 P2
价格相关 INT-PRICE-01 学费价格询问 P1
价格相关 INT-PRICE-02 优惠活动询问 P1
价格相关 INT-PRICE-03 分期付款咨询 P2
价格相关 INT-PRICE-04 退费政策询问 P2
价格相关 INT-PRICE-05 性价比比较 P2
就业相关 INT-JOB-01 就业方向咨询 P1
就业相关 INT-JOB-02 薪资水平询问 P1
就业相关 INT-JOB-03 就业率询问 P1
就业相关 INT-JOB-04 就业服务了解 P2
就业相关 INT-JOB-05 合作企业询问 P2
就业相关 INT-JOB-06 作品集指导 P2
就业相关 INT-JOB-07 实习机会咨询 P3
师资相关 INT-TEACH-01 师资背景询问 P2
师资相关 INT-TEACH-02 教学模式了解 P2
师资相关 INT-TEACH-03 课后答疑服务 P2
师资相关 INT-TEACH-04 师生比例询问 P3
师资相关 INT-TEACH-05 学习效果保障 P2
基础条件 INT-BASIC-01 学历要求咨询 P2
基础条件 INT-BASIC-02 年龄限制询问 P2
基础条件 INT-BASIC-03 零基础入学 P1
基础条件 INT-BASIC-04 证书颁发询问 P3
校区相关 INT-LOC-01 校区地址询问 P2
校区相关 INT-LOC-02 校区参观预约 P2
校区相关 INT-LOC-03 教学设备了解 P3
校区相关 INT-LOC-04 住宿安排咨询 P3
机构资质 INT-QUAL-01 机构正规性 P2
机构资质 INT-QUAL-02 口碑评价询问 P3
机构资质 INT-QUAL-03 媒体报道了解 P3
其他 INT-OTHER-01 大师课咨询 P3
其他 INT-OTHER-02 企业培训定制 P3
其他 INT-OTHER-03 比赛活动咨询 P3
其他 INT-OTHER-04 转班转学咨询 P3

B. 核心Prompt模板汇总

Prompt类型 模型 Token预算 用途
意图识别 qwen-turbo 2000 40意图分类+情感分析
话术生成 qwen-turbo 800 动态生成兜底话术
上下文分析 qwen-turbo 1500 对话状态判断
学员画像 qwen-turbo 1000 画像更新推理

C. 参考文档

  1. 企业微信会话内容存档API文档:https://developer.work.weixin.qq.com/document/path/91360
  2. 企业微信JS-SDK文档:https://developer.work.weixin.qq.com/document/path/90513
  3. 通义千问API文档:https://help.aliyun.com/document_detail/611472.html
  4. MySQL 5.7官方文档:https://dev.mysql.com/doc/refman/5.7/en/
  5. Spring Boot官方文档:https://docs.spring.io/spring-boot/docs/2.7.x/
  6. MyBatis-Plus文档:https://baomidou.com/

文档版本: v3.0-final 最后更新: 2025年7月 适用对象: Kimi Code / Claude Code 等AI编程助手 状态: 可执行开发方案