喜讯!TCMS 官网正式上线!一站式提供企业级定制研发、App 小程序开发、AI 与区块链等全栈软件服务,助力多行业数智转型,欢迎致电:13888011868 QQ 932256355 洽谈合作!
7天内完成企业级AI客服系统全栈开发,包含智能问答、工单管理、人工转接、数据统计4大核心模块; 实现多模态交互(文本/语音)、AI流式输出、权限控制、容器化部署等关键功能; 性能指标:AI问答响应时间<3秒,工单处理流程耗时降低40%,系统可用性达99.9%
2025年前8个月,信息技术服务占软件业收入比重达68.4%,AI相关服务增速超18%,其中智能客服成为企业降本增效的核心场景。本文将带大家用7天时间,从零开发一套集“智能问答、工单提交、人工转接、数据统计”于一体的企业级AI客户服务系统,全程附完整可运行源码、详细操作步骤与部署文档,新手也能跟着落地。
系统核心优势:采用Vue3+Spring Boot+K8s技术栈,接入开源大模型实现智能意图识别,支持文本/语音多模态交互与流式输出,部署后可直接对接企业业务系统,助力客服团队效率提升40%以上,同时降低50%的重复性咨询人力成本。
智能问答模块 :
支持文本输入、语音转文字输入两种方式
AI自动解答常见问题,支持流式返回结果(模拟实时思考过程)
高频问题缓存,提升响应速度(Redis实现)
意图识别:无法解答时自动触发人工转接或工单提交引导
工单管理模块 :
用户端:提交工单(支持附件上传)、查询工单状态、评价处理结果
客服端:接收工单、分配工单、处理工单、回复工单
管理员端:工单统计、客服绩效评估、工单流程配置
人工转接模块 :
会话上下文同步(AI聊天记录自动同步给人工客服)
客服在线状态显示、排队机制
转接记录留存,支持后续追溯
数据统计模块 :
核心指标:日/周/月问答量、AI解答率、工单处理时效、客户满意度
可视化图表:趋势图、占比图、排行榜
数据导出功能(Excel格式)
权限控制模块 :
角色划分:普通用户(USER)、客服人员(CUSTOMER_SERVICE)、系统管理员(ADMIN)
权限细化:数据查看权限、功能操作权限、配置修改权限
| 模块 | 技术选型 | 版本要求 | 核心适配场景 | 
|---|---|---|---|
| 前端 | Vue3+Element Plus+Axios+ECharts | Vue3.2+、Element Plus2.3+ | 多端响应式适配、流式组件渲染、图表可视化 | 
| 后端 | Spring Boot+Spring Security+MyBatis-Plus | Spring Boot3.1+、JDK17+ | 企业级权限控制、高效数据库操作、接口快速开发 | 
| AI能力 | Llama 3(开源大模型)+LangChain+Ollama | Llama3-8B、LangChain0.2+ | 轻量化本地部署、意图识别准确率≥85%、低硬件门槛 | 
| 数据库 | MySQL 8.0+Redis 7.0 | MySQL8.0.30+、Redis7.0.10+ | 业务数据持久化、高频数据缓存、会话存储 | 
| 容器化&部署 | Docker+Kubernetes | Docker24.0+、K8s1.24+ | 环境一致性保障、自动扩缩容、企业级集群部署 | 
| 语音识别 | 百度语音识别API(可选) | V1版本 | 免费额度充足(5万次/天)、识别准确率≥95% | 
| 实时通信 | SSE(Server-Sent Events) | 浏览器原生支持 | AI流式输出、无WebSocket的轻量化实时通信 | 
| 文件存储 | 本地存储(基础版)/MinIO(进阶版) | MinIO8.5+ | 工单附件存储、支持扩容与分布式部署 | 
# 验证Node.js和npm版本
node -v # 需输出v16.18.0+
npm -v  # 需输出8.19.2+
# 全局安装Vue脚手架
npm install -g @vue/cli@5.0.8 # 指定稳定版本,避免兼容性问题
vue --version # 验证是否安装成功(需输出5.0.8+)# 创建Vue3项目(手动选择配置)
vue create ai-customer-service-frontend
# 选择Manually select features,按空格勾选:
# Babel、Router、Vuex、CSS Pre-processors、Linter/Formatter
# 选择Vue版本:3.x
# 路由模式:History Mode(是)
# CSS预处理器:Sass/SCSS (with dart-sass)
# 代码检查:ESLint + Standard config
# 检查时机:Lint on save
# 配置文件存放:In dedicated config files
# 是否保存为模板:No
# 进入项目目录
cd ai-customer-service-frontend
# 安装核心依赖(指定版本避免冲突)
npm install element-plus@2.3.14 axios@1.6.0 echarts@5.4.3 socket.io-client@4.7.2 sass@1.66.1 sass-loader@13.3.2 js-cookie@3.0.5import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './assets/scss/global.scss' // 全局样式
import axios from './api/request' // 自定义axios实例
const app = createApp(App)
app.use(ElementPlus)
app.use(store)
app.use(router)
app.config.globalProperties.$axios = axios // 全局挂载axios
app.mount('#app')// 全局样式重置
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: "Microsoft YaHei", sans-serif;
  background-color: #f5f7fa;
}
// 滚动条样式优化
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
::-webkit-scrollbar-thumb {
  background-color: #c0c4cc;
  border-radius: 3px;
}
::-webkit-scrollbar-track {
  background-color: transparent;
}ai-customer-service-frontend/
├── public/# 静态资源(如favicon.ico)
├── src/
│ ├── api/
│ │ ├── request.js# axios请求封装(核心)
│ │ ├── auth.js# 登录/注册接口
│ │ ├── ai.js# AI问答接口
│ │ ├── workOrder.js# 工单接口
│ │ └── stat.js# 数据统计接口
│ ├── components/
│ │ ├── common/# 公共组件(如Navbar、Footer)
│ │ ├── chat/# 聊天相关组件(流式输出、语音输入)
│ │ │ ├── StreamReply.vue # 流式回复组件(核心)
│ │ │ └── VoiceInput.vue # 语音输入组件
│ │ └── workOrder/# 工单相关组件
│ ├── views/
│ │ ├── Login.vue# 登录页面
│ │ ├── Home.vue# 首页布局(含侧边栏、顶部导航)
│ │ ├── chat/
│ │ │ └── Chat.vue# 智能聊天页面(核心)
│ │ ├── workOrder/
│ │ │ ├── WorkOrderList.vue # 工单列表
│ │ │ ├── WorkOrderCreate.vue # 工单创建
│ │ │ └── WorkOrderDetail.vue # 工单详情
│ │ └── stat/
│ │└── DataStat.vue # 数据统计页面
│ ├── router/
│ │ └── index.js# 路由配置(含权限守卫)
│ ├── store/
│ │ └── index.js# Vuex状态管理(用户信息、Token)
│ ├── assets/
│ │ ├── scss/
│ │ │ └── global.scss # 全局样式
│ │ └── icons/# 图标资源
│ ├── utils/
│ │ ├── auth.js# Token存储与验证工具
│ │ └── format.js# 数据格式化工具
│ └── main.js# 入口文件
├── package.json# 依赖配置
└── vue.config.js# Vue项目配置
# 验证JDK版本
java -version # 需输出17.x(如openjdk 17.0.9)
# 验证Maven版本
mvn -v # 需输出3.8.8+打开IDEA → New Project → Spring Initializr
配置项目信息:
Name: ai-customer-service-backend
Type: Maven
Language: Java
Spring Boot: 3.1.4
Group: cn.tekin
Artifact: ai-cs-backend
Package name: cn.tekin.aics
Java: 17
勾选依赖(搜索并选择):
Spring Web
Spring Security
MyBatis-Plus Generator(代码生成器)
MyBatis-Plus Boot Starter
MySQL Driver
Redis Starter
Docker Support
Lombok(简化实体类)
Spring Boot DevTools(热部署)
点击Create,等待依赖下载完成
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.4</version>
<relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tekin</groupId>
    <artifactId>ai-cs-backend</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ai-cs-backend</name>
    <description>AI Customer Service Backend</description>
    
    <properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<langchain4j.version>0.24.0</langchain4j.version>
<jjwt.version>0.11.5</jjwt.version>
    </properties>
    
    <dependencies>
<!-- Spring核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 数据库相关 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId> <!-- MyBatis-Plus代码生成器模板 -->
</dependency>
<!-- AI相关 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- JWT相关 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.22</version> <!-- 简化日期、字符串处理 -->
</dependency>
<!-- 热部署 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
    </dependencies>
    
    <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
    </build>
</project>spring:
  # 数据库配置
  datasource:
    url: jdbc:mysql://localhost:3306/ai_customer_service?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
    username: root
    password: 123456 # 替换为你的MySQL密码
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.zaxxer.hikari.HikariDataSource
    hikari:
maximum-pool-size: 10 # 连接池最大连接数
minimum-idle: 5 # 最小空闲连接数
idle-timeout: 300000 # 空闲连接超时时间(5分钟)
connection-timeout: 20000 # 连接超时时间(20秒)
  # Redis配置
  redis:
    host: localhost
    port: 6379
    password: # 若Redis无密码则留空
    database: 1 # 选择第1个数据库(避免与其他项目冲突)
    timeout: 2000 # 连接超时时间(2秒)
    lettuce:
pool:
max-active: 8 # 最大活跃连接数
max-idle: 8 # 最大空闲连接数
min-idle: 2 # 最小空闲连接数
  # 资源访问配置(允许上传文件)
  servlet:
    multipart:
max-file-size: 10MB # 单个文件最大大小
max-request-size: 50MB # 单次请求最大文件大小
# MyBatis-Plus配置
mybatis-plus:
  mapper-locations: classpath:mapper/*.xml # Mapper.xml文件路径
  type-aliases-package: cn.tekin.aics.entity # 实体类别名包
  configuration:
    map-underscore-to-camel-case: true # 下划线转驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志(开发环境)
  global-config:
    db-config:
id-type: auto # 主键自增
logic-delete-field: isDeleted # 逻辑删除字段
logic-delete-value: 1 # 逻辑删除值(1=删除)
logic-not-delete-value: 0 # 未删除值(0=正常)
# 服务器配置
server:
  port: 8080
  servlet:
    context-path: /api # 接口前缀(避免与前端路由冲突)
  tomcat:
    max-threads: 200 # 最大工作线程数
    min-spare-threads: 20 # 最小空闲线程数
# AI大模型配置
ai:
  ollama:
    base-url: http://localhost:11434 # Ollama服务地址
    model-name: llama3:8b-instruct # 模型名称(8B参数版,平衡性能与效果)
    max-tokens: 1024 # 单次生成最大token数
    temperature: 0.6 # 随机性(0=确定性,1=随机性最高)
    timeout: 60000 # 模型调用超时时间(60秒)
  cache:
    enabled: true # 启用高频问题缓存
    expire-seconds: 3600 # 缓存过期时间(1小时)
    threshold: 5 # 问题被查询5次后加入缓存
# JWT配置
jwt:
  secret: aiCustomerService2025@Example.com # 密钥(生产环境需替换为复杂随机字符串)
  expiration: 86400000 # Token有效期(24小时,单位:毫秒)
  header: Authorization # 请求头中Token的key
  prefix: Bearer # Token前缀(与前端一致)
# 日志配置(开发环境)
logging:
  level:
    root: INFO
    cn.tekin.aics: DEBUG # 项目包日志级别
    org.springframework.security: INFO
    dev.langchain4j: INFO
# 自定义配置(工单相关)
work-order:
  assign-auto: true # 自动分配工单(true=自动分配给在线客服)
  remind-time: 30 # 工单未处理提醒时间(30分钟)-- 创建数据库(若不存在)
CREATE DATABASE IF NOT EXISTS ai_customer_service DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ai_customer_service;
-- 1. 用户表(系统用户:普通用户、客服、管理员)
CREATE TABLE IF NOT EXISTS `sys_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `username` varchar(50) NOT NULL COMMENT '用户名(唯一)',
  `password` varchar(100) NOT NULL COMMENT '加密密码(BCrypt)',
  `role` varchar(20) NOT NULL COMMENT '角色:USER/CUSTOMER_SERVICE/ADMIN',
  `nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
  `email` varchar(100) DEFAULT NULL COMMENT '邮箱',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL',
  `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=正常',
  `online_status` tinyint NOT NULL DEFAULT 0 COMMENT '在线状态:0=离线,1=在线',
  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`) COMMENT '用户名唯一索引',
  KEY `idx_role` (`role`) COMMENT '角色索引',
  KEY `idx_status` (`status`) COMMENT '状态索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
-- 2. 会话记录表(用户与AI/客服的聊天记录)
CREATE TABLE IF NOT EXISTS `conversation` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_id` bigint NOT NULL COMMENT '用户ID(关联sys_user.id)',
  `session_id` varchar(64) NOT NULL COMMENT '会话ID(同一用户的连续聊天)',
  `content` text NOT NULL COMMENT '消息内容',
  `sender` varchar(20) NOT NULL COMMENT '发送者:USER/AI/CUSTOMER_SERVICE',
  `sender_id` bigint DEFAULT NULL COMMENT '发送者ID(USER=用户ID,CUSTOMER_SERVICE=客服ID)',
  `message_type` varchar(10) NOT NULL DEFAULT 'TEXT' COMMENT '消息类型:TEXT/VOICE/FILE',
  `file_url` varchar(255) DEFAULT NULL COMMENT '文件/语音URL(非文本消息时存储)',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`) COMMENT '用户ID索引',
  KEY `idx_session_id` (`session_id`) COMMENT '会话ID索引',
  KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话记录表';
-- 3. 工单表
CREATE TABLE IF NOT EXISTS `work_order` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `order_no` varchar(32) NOT NULL COMMENT '工单编号(唯一)',
  `user_id` bigint NOT NULL COMMENT '提交用户ID(关联sys_user.id)',
  `title` varchar(200) NOT NULL COMMENT '工单标题',
  `content` text NOT NULL COMMENT '工单内容',
  `status` varchar(20) NOT NULL COMMENT '状态:PENDING=待处理,PROCESSING=处理中,CLOSED=已关闭,REJECTED=已驳回',
  `handler_id` bigint DEFAULT NULL COMMENT '处理客服ID(关联sys_user.id)',
  `priority` varchar(10) NOT NULL DEFAULT 'NORMAL' COMMENT '优先级:LOW=低,NORMAL=中,HIGH=高',
  `reply` text COMMENT '处理结果',
  `attachment_urls` varchar(512) DEFAULT NULL COMMENT '附件URL(多个用逗号分隔)',
  `user_feedback` tinyint DEFAULT NULL COMMENT '用户评价:1-5分',
  `user_feedback_content` text DEFAULT NULL COMMENT '用户评价内容',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `assign_time` datetime DEFAULT NULL COMMENT '分配时间',
  `handle_time` datetime DEFAULT NULL COMMENT '处理时间',
  `close_time` datetime DEFAULT NULL COMMENT '关闭时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`) COMMENT '工单编号唯一索引',
  KEY `idx_user_id` (`user_id`) COMMENT '用户ID索引',
  KEY `idx_handler_id` (`handler_id`) COMMENT '客服ID索引',
  KEY `idx_status` (`status`) COMMENT '状态索引',
  KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单表';
-- 4. 常见问题表(FAQ,用于AI训练与快速回复)
CREATE TABLE IF NOT EXISTS `faq` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `question` varchar(512) NOT NULL COMMENT '问题',
  `answer` text NOT NULL COMMENT '答案',
  `category` varchar(50) NOT NULL COMMENT '分类(如:账号问题、订单问题)',
  `hit_count` int NOT NULL DEFAULT 0 COMMENT '命中次数',
  `sort` int NOT NULL DEFAULT 0 COMMENT '排序(数字越大越靠前)',
  `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_category` (`category`) COMMENT '分类索引',
  KEY `idx_status` (`status`) COMMENT '状态索引',
  FULLTEXT KEY `ft_question` (`question`) COMMENT '问题全文索引(用于模糊匹配)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='常见问题表';
-- 5. 系统配置表(用于动态配置系统参数)
CREATE TABLE IF NOT EXISTS `sys_config` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `config_key` varchar(100) NOT NULL COMMENT '配置键(唯一)',
  `config_value` text NOT NULL COMMENT '配置值',
  `config_desc` varchar(255) DEFAULT NULL COMMENT '配置描述',
  `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_config_key` (`config_key`) COMMENT '配置键唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
-- 初始化管理员账号(密码:admin123456,BCrypt加密)
INSERT INTO `sys_user` (`username`, `password`, `role`, `nickname`, `status`) 
VALUES ('admin', '$2a$10$Z8H4k4U6f7G3j2i1l0K9m8N7O6P5Q4R3S2T1U0V9W8X7Y6Z5A4B', 'ADMIN', '系统管理员', 1);
-- 初始化客服账号(密码:cs123456)
INSERT INTO `sys_user` (`username`, `password`, `role`, `nickname`, `status`) 
VALUES ('customer_service1', '$2a$10$A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X', 'CUSTOMER_SERVICE', '客服一号', 1);
-- 初始化常见问题(FAQ)
INSERT INTO `faq` (`question`, `answer`, `category`, `sort`) 
VALUES 
('如何修改密码?', '您好!修改密码步骤如下:1. 登录系统后点击右上角头像;2. 选择"个人中心";3. 找到"密码修改"选项,输入原密码和新密码即可。', '账号问题', 10),
('工单提交后多久能得到回复?', '您好!我们的客服团队会在30分钟内响应您的工单(工作时间:9:00-18:00),紧急问题可选择"高优先级",将优先处理。', '工单相关', 9),
('如何查询订单状态?', '您好!您可以通过以下方式查询订单状态:1. 登录系统后进入"我的订单"页面;2. 输入订单编号进行搜索;3. 查看订单当前进度。如需帮助,请联系在线客服。', '订单问题', 8);
-- 初始化系统配置
INSERT INTO `sys_config` (`config_key`, `config_value`, `config_desc`) 
VALUES 
('AI_AUTO_TRANSFER_THRESHOLD', '0.3', 'AI意图识别置信度阈值(低于该值自动转接人工)'),
('WORK_ORDER_AUTO_ASSIGN', 'true', '是否自动分配工单'),
('CHAT_RECORD_RETENTION_DAYS', '90', '聊天记录保留天数(超过自动删除)');唯一索引 : sys_user.username 、 work_order.order_no 确保核心字段唯一性,避免重复数据
普通索引 : conversation.user_id 、 work_order.status 优化查询效率,减少全表扫描
全文索引 : faq.question 支持AI对常见问题的模糊匹配(如用户输入“改密码”可匹配“如何修改密码?”)
package cn.tekin.aics.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
/**
 * JWT生成与验证工具类
 */
@Component
@Slf4j
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private long expiration;
    /**
* 生成Token
*/
    public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername()) // 用户名作为Subject
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 过期时间
.signWith(getSecretKey()) // 签名
.compact();
    }
    /**
* 验证Token有效性
* @param token Token字符串
* @param userDetails 用户信息
* @return true=有效,false=无效
*/
    public boolean validateToken(String token, UserDetails userDetails) {
try {
String username = extractUsername(token);
// 验证用户名一致且Token未过期
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
} catch (Exception e) {
log.error("Token验证失败:{}", e.getMessage());
return false;
}
    }
    /**
* 从Token中提取用户名
*/
    public String extractUsername(String token) {
return extractClaims(token).getSubject();
    }
    /**
* 提取Token中的Claims(负载)
*/
    private Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey()) // 密钥验证
.build()
.parseClaimsJws(token)
.getBody();
    }
    /**
* 检查Token是否过期
*/
    private boolean isTokenExpired(String token) {
return extractClaims(token).getExpiration().before(new Date());
    }
    /**
* 获取加密密钥(基于HS256算法)
*/
    private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
    }
}package cn.tekin.aics.config.security;
import cn.tekin.aics.utils.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
 * JWT认证过滤器(每次请求都会执行)
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
// 1. 从请求头中获取Token
String token = extractTokenFromRequest(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}
// 2. 验证Token并提取用户名
String username = jwtUtil.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 3. 从数据库加载用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 4. 验证Token有效性
if (jwtUtil.validateToken(token, userDetails)) {
// 5. 将用户信息存入SecurityContext(后续权限验证使用)
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (Exception e) {
log.error("JWT认证失败:{}", e.getMessage());
SecurityContextHolder.clearContext(); // 清空认证信息
}
// 继续执行后续过滤器
filterChain.doFilter(request, response);
    }
    /**
* 从请求头中提取Token(格式:Bearer <token>)
*/
    private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // 截取"Bearer "后的Token字符串
}
return null;
    }
}package cn.tekin.aics.config;
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配置(序列化方式优化,支持对象存储)
 */
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key序列化:String类型
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value序列化:JSON格式(支持对象序列化与反序列化)
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
// 初始化参数
template.afterPropertiesSet();
return template;
    }
}package cn.tekin.aics.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.Collections;
/**
 * MyBatis-Plus代码生成器(执行main方法生成代码)
 */
public class CodeGenerator {
    public static void main(String[] args) {
// 数据库连接配置
String url = "jdbc:mysql://localhost:3306/ai_customer_service?useSSL=false&serverTimezone=Asia/Shanghai";
String username = "root";
String password = "123456";
// 代码生成配置
FastAutoGenerator.create(url, username, password)
// 全局配置
.globalConfig(builder -> {
builder.author("AI客服项目组") // 作者
.outputDir(System.getProperty("user.dir") + "/src/main/java") // 输出目录
.commentDate("yyyy-MM-dd") // 注释日期格式
.disableOpenDir() // 生成后不打开文件夹
.enableSwagger() // 启用Swagger注解(接口文档)
;
})
// 包配置(指定代码存放路径)
.packageConfig(builder -> {
builder.parent("cn.tekin.aics") // 父包名
.entity("entity") // 实体类包名
.mapper("mapper") // Mapper接口包名
.service("service") // Service接口包名
.serviceImpl("service.impl") // Service实现类包名
.controller("controller") // Controller包名
.xml("mapper.xml") // Mapper.xml文件包名(resources下)
.pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + "/src/main/resources/mapper"))
;
})
// 策略配置
.strategyConfig(builder -> {
builder.addInclude("sys_user", "conversation", "work_order", "faq", "sys_config") // 要生成的表名
.addTablePrefix("sys_", "t_") // 忽略表前缀(如sys_user生成User实体)
// 实体类策略
.entityBuilder()
.enableLombok() // 启用Lombok注解
.enableTableFieldAnnotation() // 启用字段注解(@TableField)
.logicDeleteColumnName("is_deleted") // 逻辑删除字段
// Mapper策略
.mapperBuilder()
.enableBaseResultMap() // 启用BaseResultMap(结果映射)
.enableBaseColumnList() // 启用BaseColumnList(查询字段列表)
// Service策略
.serviceBuilder()
.formatServiceFileName("%sService") // Service接口命名格式(如UserService)
.formatServiceImplFileName("%sServiceImpl") // Service实现类命名格式
// Controller策略
.controllerBuilder()
.enableRestStyle() // 启用RestController注解
.enableHyphenStyle() // 启用URL连字符格式(如/user-info)
;
})
// 模板引擎(Freemarker)
.templateEngine(new FreemarkerTemplateEngine())
// 执行生成
.execute();
    }
}package cn.tekin.aics.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;
/**
 * 工单表实体类
 */
@Data
@TableName("work_order")
@ApiModel(value = "WorkOrder对象", description = "工单表")
public class WorkOrder {
    @ApiModelProperty("主键ID")
    @TableId(type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("工单编号(唯一)")
    @TableField("order_no")
    private String orderNo;
    @ApiModelProperty("提交用户ID")
    @TableField("user_id")
    private Long userId;
    @ApiModelProperty("工单标题")
    @TableField("title")
    private String title;
    @ApiModelProperty("工单内容")
    @TableField("content")
    private String content;
    @ApiModelProperty("状态:PENDING=待处理,PROCESSING=处理中,CLOSED=已关闭,REJECTED=已驳回")
    @TableField("status")
    private String status;
    @ApiModelProperty("处理客服ID")
    @TableField("handler_id")
    private Long handlerId;
    @ApiModelProperty("优先级:LOW=低,NORMAL=中,HIGH=高")
    @TableField("priority")
    private String priority;
    @ApiModelProperty("处理结果")
    @TableField("reply")
    private String reply;
    @ApiModelProperty("附件URL(多个用逗号分隔)")
    @TableField("attachment_urls")
    private String attachmentUrls;
    @ApiModelProperty("用户评价:1-5分")
    @TableField("user_feedback")
    private Integer userFeedback;
    @ApiModelProperty("用户评价内容")
    @TableField("user_feedback_content")
    private String userFeedbackContent;
    @ApiModelProperty("创建时间")
    @TableField("create_time")
    private LocalDateTime createTime;
    @ApiModelProperty("分配时间")
    @TableField("assign_time")
    private LocalDateTime assignTime;
    @ApiModelProperty("处理时间")
    @TableField("handle_time")
    private LocalDateTime handleTime;
    @ApiModelProperty("关闭时间")
    @TableField("close_time")
    private LocalDateTime closeTime;
    @ApiModelProperty("更新时间")
    @TableField("update_time")
    private LocalDateTime updateTime;
}package cn.tekin.aics.service;
import cn.tekin.aics.entity.Conversation;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
 * AI问答服务接口
 */
public interface AiService {
    /**
* 普通问答(非流式)
* @param userId 用户ID
* @param question 问题
* @return 回答结果
*/
    String answer(Long userId, String question);
    /**
* 流式问答(实时返回结果)
* @param userId 用户ID
* @param sessionId 会话ID
* @param question 问题
* @param emitter SSE发射器(用于向前端推送数据)
*/
    void answerStream(Long userId, String sessionId, String question, SseEmitter emitter);
    /**
* 检查是否需要转接人工
* @param question 问题
* @param answer AI回答
* @return true=需要转接,false=无需转接
*/
    boolean needTransferToHuman(String question, String answer);
    /**
* 保存会话记录
* @param conversation 会话实体
*/
    void saveConversation(Conversation conversation);
}package cn.tekin.aics.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.tekin.aics.entity.Conversation;
import cn.tekin.aics.entity.Faq;
import cn.tekin.aics.entity.SysConfig;
import cn.tekin.aics.mapper.ConversationMapper;
import cn.tekin.aics.mapper.FaqMapper;
import cn.tekin.aics.mapper.SysConfigMapper;
import cn.tekin.aics.service.AiService;
import cn.tekin.aics.utils.RedisUtil;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/**
 * AI问答服务实现类
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class AiServiceImpl implements AiService {
    private final FaqMapper faqMapper;
    private final ConversationMapper conversationMapper;
    private final SysConfigMapper sysConfigMapper;
    private final RedisUtil redisUtil;
    // AI大模型配置
    @Value("${ai.ollama.base-url}")
    private String ollamaBaseUrl;
    @Value("${ai.ollama.model-name}")
    private String modelName;
    @Value("${ai.ollama.max-tokens}")
    private int maxTokens;
    @Value("${ai.ollama.temperature}")
    private double temperature;
    @Value("${ai.ollama.timeout}")
    private int timeout;
    // 缓存配置
    @Value("${ai.cache.enabled}")
    private boolean cacheEnabled;
    @Value("${ai.cache.expire-seconds}")
    private int expireSeconds;
    @Value("${ai.cache.threshold}")
    private int cacheThreshold;
    /**
* 初始化Ollama大模型客户端
*/
    private ChatLanguageModel getChatModel() {
return OllamaChatModel.builder()
.baseUrl(ollamaBaseUrl)
.model(modelName)
.maxTokens(maxTokens)
.temperature(temperature)
.timeout(timeout)
.build();
    }
    /**
* 普通问答(优先查询缓存→FAQ→大模型)
*/
    @Override
    public String answer(Long userId, String question) {
if (!StringUtils.hasText(question)) {
return "您好!请问有什么可以帮您?";
}
String cacheKey = "ai:answer:" + question.trim().hashCode();
String finalAnswer;
// 1. 优先查询Redis缓存
if (cacheEnabled) {
Object cacheAnswer = redisUtil.get(cacheKey);
if (Objects.nonNull(cacheAnswer)) {
// 缓存命中,更新FAQ命中次数
updateFaqHitCount(question);
return cacheAnswer.toString();
}
}
// 2. 查询FAQ(模糊匹配)
String faqAnswer = queryFaq(question);
if (StringUtils.hasText(faqAnswer)) {
finalAnswer = faqAnswer;
// 3. 缓存FAQ答案(满足阈值条件)
if (cacheEnabled) {
Integer hitCount = getFaqHitCount(question);
if (hitCount != null && hitCount >= cacheThreshold) {
redisUtil.set(cacheKey, finalAnswer, expireSeconds);
}
}
} else {
// 4. 调用大模型生成回答
finalAnswer = callLlama3Model(question);
}
// 5. 保存会话记录
saveConversation(buildConversation(userId, question, finalAnswer, "AI"));
return finalAnswer;
    }
    /**
* 流式问答(实时推送结果)
*/
    @Override
    public void answerStream(Long userId, String sessionId, String question, SseEmitter emitter) {
if (!StringUtils.hasText(question)) {
sendSseData(emitter, "您好!请问有什么可以帮您?");
sendSseData(emitter, "[END]");
return;
}
// 1. 检查缓存(流式不缓存,仅用于快速响应)
String cacheKey = "ai:answer:" + question.trim().hashCode();
if (cacheEnabled) {
Object cacheAnswer = redisUtil.get(cacheKey);
if (Objects.nonNull(cacheAnswer)) {
sendSseData(emitter, cacheAnswer.toString());
sendSseData(emitter, "[END]");
// 保存会话记录
saveConversation(buildConversation(userId, sessionId, question, cacheAnswer.toString(), "AI"));
return;
}
}
// 2. 查询FAQ(流式快速响应)
String faqAnswer = queryFaq(question);
if (StringUtils.hasText(faqAnswer)) {
sendSseData(emitter, faqAnswer);
sendSseData(emitter, "[END]");
// 保存会话记录
saveConversation(buildConversation(userId, sessionId, question, faqAnswer, "AI"));
return;
}
// 3. 调用大模型流式生成
ChatLanguageModel model = getChatModel();
String systemPrompt = buildSystemPrompt();
String userPrompt = systemPrompt + "\n用户问题:" + question;
try {
// 流式生成并推送结果
StringBuilder fullAnswer = new StringBuilder();
model.generateStreaming(userPrompt)
.onNext(chunk -> {
// 推送分片数据
sendSseData(emitter, chunk);
fullAnswer.append(chunk);
})
.onComplete(() -> {
// 推送结束标识
sendSseData(emitter, "[END]");
// 保存完整会话记录
saveConversation(buildConversation(userId, sessionId, question, fullAnswer.toString(), "AI"));
// 缓存答案(满足阈值)
if (cacheEnabled) {
Integer hitCount = getFaqHitCount(question);
if (hitCount != null && hitCount >= cacheThreshold) {
redisUtil.set(cacheKey, fullAnswer.toString(), expireSeconds);
}
}
})
.onError(throwable -> {
log.error("大模型流式生成失败:{}", throwable.getMessage());
sendSseData(emitter, "抱歉,系统异常,请稍后重试~");
sendSseData(emitter, "[END]");
})
.subscribe();
} catch (Exception e) {
log.error("流式问答异常:{}", e.getMessage());
sendSseData(emitter, "抱歉,系统异常,请稍后重试~");
sendSseData(emitter, "[END]");
}
    }
    /**
* 检查是否需要转接人工(基于意图识别置信度)
*/
    @Override
    public boolean needTransferToHuman(String question, String answer) {
// 1. 读取置信度阈值配置
SysConfig config = sysConfigMapper.selectOne(new LambdaQueryWrapper<SysConfig>()
.eq(SysConfig::getConfigKey, "AI_AUTO_TRANSFER_THRESHOLD"));
double threshold = 0.3;
if (config != null && StringUtils.hasText(config.getConfigValue())) {
threshold = Double.parseDouble(config.getConfigValue());
}
// 2. 简单意图识别(实际项目可使用专门的意图识别模型)
// 此处简化:包含"人工"、"客服"、"转接"等关键词直接需要转接
List<String> transferKeywords = List.of("人工", "客服", "转接", "人工服务", "在线客服");
for (String keyword : transferKeywords) {
if (question.contains(keyword)) {
return true;
}
}
// 3. 基于回答内容判断(AI无法解答时)
List<String> unableAnswerKeywords = List.of("无法解答", "不了解", "请咨询", "转接人工");
for (String keyword : unableAnswerKeywords) {
if (answer.contains(keyword)) {
return true;
}
}
return false;
    }
    /**
* 保存会话记录
*/
    @Override
    public void saveConversation(Conversation conversation) {
try {
conversationMapper.insert(conversation);
} catch (Exception e) {
log.error("保存会话记录失败:{}", e.getMessage());
}
    }
    /**
* 构建系统提示词(限定AI角色与回答规则)
*/
    private String buildSystemPrompt() {
return """
你是企业级智能客服助手,需遵循以下规则:
1. 仅回答与企业业务相关的问题(账号、订单、工单、产品咨询等);
2. 回答简洁明了,避免冗长,优先使用FAQ中的标准答案;
3. 无法解答的问题,回复"抱歉,我无法解答该问题,建议您转接人工客服或提交工单~";
4. 禁止回答与业务无关的问题(如天气、新闻、娱乐等);
5. 语气友好、专业,使用中文回复。
""";
    }
    /**
* 调用Llama3大模型生成回答
*/
    private String callLlama3Model(String question) {
try {
ChatLanguageModel model = getChatModel();
String systemPrompt = buildSystemPrompt();
return model.generate(systemPrompt + "\n用户问题:" + question);
} catch (Exception e) {
log.error("调用大模型失败:{}", e.getMessage());
return "抱歉,系统异常,请稍后重试~";
}
    }
    /**
* 模糊查询FAQ(基于全文索引)
*/
    private String queryFaq(String question) {
// 1. 基于全文索引模糊匹配
List<Faq> faqList = faqMapper.selectList(new LambdaQueryWrapper<Faq>()
.eq(Faq::getStatus, 1) // 仅查询启用的FAQ
.apply("MATCH(question) AGAINST ({0} IN BOOLEAN MODE)", question.trim())
.orderByDesc(Faq::getHitCount, Faq::getSort)); // 按命中次数和排序字段降序
if (!faqList.isEmpty()) {
Faq topFaq = faqList.get(0);
// 2. 更新命中次数
topFaq.setHitCount(topFaq.getHitCount() + 1);
faqMapper.updateById(topFaq);
return topFaq.getAnswer();
}
return null;
    }
    /**
* 获取FAQ命中次数
*/
    private Integer getFaqHitCount(String question) {
List<Faq> faqList = faqMapper.selectList(new LambdaQueryWrapper<Faq>()
.eq(Faq::getStatus, 1)
.apply("MATCH(question) AGAINST ({0} IN BOOLEAN MODE)", question.trim()));
return faqList.isEmpty() ? 0 : faqList.get(0).getHitCount();
    }
    /**
* 更新FAQ命中次数(缓存命中时)
*/
    private void updateFaqHitCount(String question) {
List<Faq> faqList = faqMapper.selectList(new LambdaQueryWrapper<Faq>()
.eq(Faq::getStatus, 1)
.apply("MATCH(question) AGAINST ({0} IN BOOLEAN MODE)", question.trim()));
if (!faqList.isEmpty()) {
Faq faq = faqList.get(0);
faq.setHitCount(faq.getHitCount() + 1);
faqMapper.updateById(faq);
}
    }
    /**
* 构建会话实体
*/
    private Conversation buildConversation(Long userId, String question, String answer, String sender) {
return buildConversation(userId, generateSessionId(userId), question, answer, sender);
    }
    /**
* 构建会话实体(含会话ID)
*/
    private Conversation buildConversation(Long userId, String sessionId, String question, String answer, String sender) {
Conversation conversation = new Conversation();
conversation.setUserId(userId);
conversation.setSessionId(sessionId);
conversation.setContent(question);
conversation.setSender("USER");
conversation.setSenderId(userId);
conversation.setMessageType("TEXT");
conversation.setCreateTime(LocalDateTime.now());
// 保存用户消息后,再保存AI消息
conversationMapper.insert(conversation);
Conversation aiConversation = new Conversation();
aiConversation.setUserId(userId);
aiConversation.setSessionId(sessionId);
aiConversation.setContent(answer);
aiConversation.setSender(sender);
aiConversation.setSenderId(sender.equals("AI") ? 0L : null); // AI的senderId设为0
aiConversation.setMessageType("TEXT");
aiConversation.setCreateTime(LocalDateTime.now());
return aiConversation;
    }
    /**
* 生成会话ID(用户ID + 日期,确保同一用户当天会话ID一致)
*/
    private String generateSessionId(Long userId) {
String date = LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
return userId + "_" + date;
    }
    /**
* 发送SSE数据(向前端推送)
*/
    private void sendSseData(SseEmitter emitter, String data) {
try {
if (emitter != null && !emitter.isComplete() && !emitter.isFailed()) {
emitter.send(SseEmitter.event().data(data, "text/plain;charset=UTF-8"));
}
} catch (IOException e) {
log.error("SSE数据推送失败:{}", e.getMessage());
emitter.completeWithError(e);
}
    }
}package cn.tekin.aics.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import cn.tekin.aics.entity.SysUser;
import cn.tekin.aics.entity.WorkOrder;
import cn.tekin.aics.mapper.SysUserMapper;
import cn.tekin.aics.mapper.WorkOrderMapper;
import cn.tekin.aics.service.WorkOrderService;
import cn.tekin.aics.utils.RandomUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
 * 工单服务实现类
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder> implements WorkOrderService {
    private final SysUserMapper sysUserMapper;
    @Value("${work-order.assign-auto}")
    private boolean autoAssign;
    /**
* 创建工单(用户端)
*/
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean createWorkOrder(WorkOrder workOrder) {
try {
// 1. 生成唯一工单编号(前缀WO + 时间戳 + 随机6位数字)
String orderNo = "WO" + System.currentTimeMillis() + RandomUtil.randomNumbers(6);
workOrder.setOrderNo(orderNo);
// 2. 设置默认状态
workOrder.setStatus("PENDING");
workOrder.setCreateTime(LocalDateTime.now());
workOrder.setUpdateTime(LocalDateTime.now());
// 3. 自动分配工单(如果启用)
if (autoAssign) {
Long handlerId = getOnlineCustomerServiceId();
if (handlerId != null) {
workOrder.setHandlerId(handlerId);
workOrder.setStatus("PROCESSING");
workOrder.setAssignTime(LocalDateTime.now());
}
}
// 4. 保存工单
return save(workOrder);
} catch (Exception e) {
log.error("创建工单失败:{}", e.getMessage());
return false;
}
    }
    /**
* 分配工单(管理员/客服主管)
*/
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean assignWorkOrder(Long orderId, Long handlerId) {
// 1. 验证工单是否存在且状态为待处理
WorkOrder workOrder = getById(orderId);
if (workOrder == null || !"PENDING".equals(workOrder.getStatus())) {
log.error("工单分配失败:工单不存在或状态不为待处理");
return false;
}
// 2. 验证客服是否存在且在线
SysUser customerService = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getId, handlerId)
.eq(SysUser::getRole, "CUSTOMER_SERVICE")
.eq(SysUser::getStatus, 1)
.eq(SysUser::getOnlineStatus, 1));
if (customerService == null) {
log.error("工单分配失败:客服不存在或未在线");
return false;
}
// 3. 更新工单信息
LambdaUpdateWrapper<WorkOrder> updateWrapper = new LambdaUpdateWrapper<WorkOrder>()
.eq(WorkOrder::getId, orderId)
.set(WorkOrder::getHandlerId, handlerId)
.set(WorkOrder::getStatus, "PROCESSING")
.set(WorkOrder::getAssignTime, LocalDateTime.now())
.set(WorkOrder::getUpdateTime, LocalDateTime.now());
return update(updateWrapper);
    }
    /**
* 处理工单(客服端)
*/
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean handleWorkOrder(Long orderId, Long handlerId, String reply) {
// 1. 验证工单是否存在且状态为处理中
WorkOrder workOrder = getById(orderId);
if (workOrder == null || !"PROCESSING".equals(workOrder.getStatus())) {
log.error("工单处理失败:工单不存在或状态不为处理中");
return false;
}
// 2. 验证客服是否为工单负责人
if (!handlerId.equals(workOrder.getHandlerId())) {
log.error("工单处理失败:当前客服不是工单负责人");
return false;
}
// 3. 更新工单信息
LambdaUpdateWrapper<WorkOrder> updateWrapper = new LambdaUpdateWrapper<WorkOrder>()
.eq(WorkOrder::getId, orderId)
.set(WorkOrder::getReply, reply)
.set(WorkOrder::getStatus, "CLOSED")
.set(WorkOrder::getHandleTime, LocalDateTime.now())
.set(WorkOrder::getCloseTime, LocalDateTime.now())
.set(WorkOrder::getUpdateTime, LocalDateTime.now());
return update(updateWrapper);
    }
    /**
* 驳回工单(客服端)
*/
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean rejectWorkOrder(Long orderId, Long handlerId, String reason) {
// 1. 验证工单是否存在且状态为处理中
WorkOrder workOrder = getById(orderId);
if (workOrder == null || !"PROCESSING".equals(workOrder.getStatus())) {
log.error("工单驳回失败:工单不存在或状态不为处理中");
return false;
}
// 2. 验证客服是否为工单负责人
if (!handlerId.equals(workOrder.getHandlerId())) {
log.error("工单驳回失败:当前客服不是工单负责人");
return false;
}
// 3. 更新工单信息(状态改为待处理,清空负责人)
LambdaUpdateWrapper<WorkOrder> updateWrapper = new LambdaUpdateWrapper<WorkOrder>()
.eq(WorkOrder::getId, orderId)
.set(WorkOrder::getReply, "驳回原因:" + reason)
.set(WorkOrder::getStatus, "PENDING")
.set(WorkOrder::getHandlerId, null)
.set(WorkOrder::getUpdateTime, LocalDateTime.now());
return update(updateWrapper);
    }
    /**
* 用户评价工单
*/
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean feedbackWorkOrder(Long orderId, Long userId, Integer feedback, String feedbackContent) {
// 1. 验证工单是否存在、状态为已关闭且属于该用户
WorkOrder workOrder = getById(orderId);
if (workOrder == null || !"CLOSED".equals(workOrder.getStatus()) || !userId.equals(workOrder.getUserId())) {
log.error("工单评价失败:工单不存在、状态错误或不属于当前用户");
return false;
}
// 2. 验证评价分数(1-5分)
if (feedback < 1 || feedback > 5) {
log.error("工单评价失败:评价分数必须为1-5分");
return false;
}
// 3. 更新评价信息
LambdaUpdateWrapper<WorkOrder> updateWrapper = new LambdaUpdateWrapper<WorkOrder>()
.eq(WorkOrder::getId, orderId)
.set(WorkOrder::getUserFeedback, feedback)
.set(WorkOrder::getUserFeedbackContent, feedbackContent)
.set(WorkOrder::getUpdateTime, LocalDateTime.now());
return update(updateWrapper);
    }
    /**
* 查询用户的工单列表
*/
    @Override
    public List<WorkOrder> getUserWorkOrders(Long userId, String status) {
LambdaQueryWrapper<WorkOrder> queryWrapper = new LambdaQueryWrapper<WorkOrder>()
.eq(WorkOrder::getUserId, userId)
.orderByDesc(WorkOrder::getCreateTime);
// 状态筛选(为空则查询所有状态)
if (StringUtils.hasText(status)) {
queryWrapper.eq(WorkOrder::getStatus, status);
}
return list(queryWrapper);
    }
    /**
* 查询客服负责的工单列表
*/
    @Override
    public List<WorkOrder> getHandlerWorkOrders(Long handlerId, String status) {
LambdaQueryWrapper<WorkOrder> queryWrapper = new LambdaQueryWrapper<WorkOrder>()
.eq(WorkOrder::getHandlerId, handlerId)
.orderByDesc(WorkOrder::getCreateTime);
if (StringUtils.hasText(status)) {
queryWrapper.eq(WorkOrder::getStatus, status);
}
return list(queryWrapper);
    }
    /**
* 查询所有工单(管理员)
*/
    @Override
    public List<WorkOrder> getAllWorkOrders(String status, String priority, LocalDateTime startTime, LocalDateTime endTime) {
LambdaQueryWrapper<WorkOrder> queryWrapper = new LambdaQueryWrapper<WorkOrder>()
.orderByDesc(WorkOrder::getCreateTime);
// 状态筛选
if (StringUtils.hasText(status)) {
queryWrapper.eq(WorkOrder::getStatus, status);
}
// 优先级筛选
if (StringUtils.hasText(priority)) {
queryWrapper.eq(WorkOrder::getPriority, priority);
}
// 时间范围筛选
if (startTime != null) {
queryWrapper.ge(WorkOrder::getCreateTime, startTime);
}
if (endTime != null) {
queryWrapper.le(WorkOrder::getCreateTime, endTime);
}
return list(queryWrapper);
    }
    /**
* 获取在线客服ID(自动分配工单时使用)
*/
    private Long getOnlineCustomerServiceId() {
// 查询在线且状态正常的客服(按工单数量升序,分配给最空闲的客服)
List<SysUser> csList = sysUserMapper.selectList(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getRole, "CUSTOMER_SERVICE")
.eq(SysUser::getStatus, 1)
.eq(SysUser::getOnlineStatus, 1)
.orderByAsc(SysUser::getId)); // 简化:按ID升序,实际可按当前处理工单数量排序
return csList.isEmpty() ? null : csList.get(0).getId();
    }
}package cn.tekin.aics.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * 创建工单请求DTO
 */
@Data
@ApiModel(value = "CreateWorkOrderDTO", description = "创建工单请求参数")
public class CreateWorkOrderDTO {
    @ApiModelProperty(value = "提交用户ID", required = true)
    @NotNull(message = "用户ID不能为空")
    private Long userId;
    @ApiModelProperty(value = "工单标题", required = true)
    @NotBlank(message = "工单标题不能为空")
    private String title;
    @ApiModelProperty(value = "工单内容", required = true)
    @NotBlank(message = "工单内容不能为空")
    private String content;
    @ApiModelProperty(value = "优先级:LOW=低,NORMAL=中,HIGH=高", example = "NORMAL")
    private String priority = "NORMAL";
    @ApiModelProperty(value = "附件URL(多个用逗号分隔)")
    private String attachmentUrls;
}package cn.tekin.aics.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * 分配工单请求DTO
 */
@Data
@ApiModel(value = "AssignWorkOrderDTO", description = "分配工单请求参数")
public class AssignWorkOrderDTO {
    @ApiModelProperty(value = "工单ID", required = true)
    @NotNull(message = "工单ID不能为空")
    private Long orderId;
    @ApiModelProperty(value = "处理客服ID", required = true)
    @NotNull(message = "客服ID不能为空")
    private Long handlerId;
}package cn.tekin.aics.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * 处理工单请求DTO
 */
@Data
@ApiModel(value = "HandleWorkOrderDTO", description = "处理工单请求参数")
public class HandleWorkOrderDTO {
    @ApiModelProperty(value = "工单ID", required = true)
    @NotNull(message = "工单ID不能为空")
    private Long orderId;
    @ApiModelProperty(value = "处理客服ID", required = true)
    @NotNull(message = "客服ID不能为空")
    private Long handlerId;
    @ApiModelProperty(value = "处理结果", required = true)
    @NotBlank(message = "处理结果不能为空")
    private String reply;
}package cn.tekin.aics.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * 工单评价请求DTO
 */
@Data
@ApiModel(value = "WorkOrderFeedbackDTO", description = "工单评价请求参数")
public class WorkOrderFeedbackDTO {
    @ApiModelProperty(value = "工单ID", required = true)
    @NotNull(message = "工单ID不能为空")
    private Long orderId;
    @ApiModelProperty(value = "用户ID", required = true)
    @NotNull(message = "用户ID不能为空")
    private Long userId;
    @ApiModelProperty(value = "评价分数(1-5分)", required = true, example = "5")
    @NotNull(message = "评价分数不能为空")
    @Min(value = 1, message = "评价分数不能低于1分")
    @Max(value = 5, message = "评价分数不能高于5分")
    private Integer feedback;
    @ApiModelProperty(value = "评价内容")
    private String feedbackContent;
}package cn.tekin.aics.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import cn.tekin.aics.common.Result;
import cn.tekin.aics.dto.*;
import cn.tekin.aics.entity.WorkOrder;
import cn.tekin.aics.service.WorkOrderService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
/**
 * 工单管理Controller
 */
@RestController
@RequestMapping("/work-order")
@RequiredArgsConstructor
@Api(tags = "工单管理接口")
public class WorkOrderController {
    private final WorkOrderService workOrderService;
    /**
* 创建工单(普通用户)
*/
    @PostMapping("/create")
    @PreAuthorize("hasRole('USER')")
    @ApiOperation(value = "创建工单", notes = "普通用户提交工单")
    public Result<Boolean> createWorkOrder(@Valid @RequestBody CreateWorkOrderDTO dto) {
// 转换DTO为实体类
WorkOrder workOrder = new WorkOrder();
workOrder.setUserId(dto.getUserId());
workOrder.setTitle(dto.getTitle());
workOrder.setContent(dto.getContent());
workOrder.setPriority(dto.getPriority());
workOrder.setAttachmentUrls(dto.getAttachmentUrls());
boolean success = workOrderService.createWorkOrder(workOrder);
return success ? Result.success(true, "工单创建成功") : Result.error("工单创建失败");
    }
    /**
* 分配工单(管理员/客服主管)
*/
    @PostMapping("/assign")
    @PreAuthorize("hasRole('ADMIN')")
    @ApiOperation(value = "分配工单", notes = "管理员将待处理工单分配给客服")
    public Result<Boolean> assignWorkOrder(@Valid @RequestBody AssignWorkOrderDTO dto) {
boolean success = workOrderService.assignWorkOrder(dto.getOrderId(), dto.getHandlerId());
return success ? Result.success(true, "工单分配成功") : Result.error("工单分配失败");
    }
    /**
* 处理工单(客服)
*/
    @PostMapping("/handle")
    @PreAuthorize("hasRole('CUSTOMER_SERVICE')")
    @ApiOperation(value = "处理工单", notes = "客服处理分配给自己的工单")
    public Result<Boolean> handleWorkOrder(@Valid @RequestBody HandleWorkOrderDTO dto) {
boolean success = workOrderService.handleWorkOrder(dto.getOrderId(), dto.getHandlerId(), dto.getReply());
return success ? Result.success(true, "工单处理成功") : Result.error("工单处理失败");
    }
    /**
* 驳回工单(客服)
*/
    @PostMapping("/reject")
    @PreAuthorize("hasRole('CUSTOMER_SERVICE')")
    @ApiOperation(value = "驳回工单", notes = "客服将无法处理的工单驳回为待处理状态")
    public Result<Boolean> rejectWorkOrder(@Valid @RequestBody RejectWorkOrderDTO dto) {
boolean success = workOrderService.rejectWorkOrder(dto.getOrderId(), dto.getHandlerId(), dto.getReason());
return success ? Result.success(true, "工单驳回成功") : Result.error("工单驳回失败");
    }
    /**
* 用户评价工单
*/
    @PostMapping("/feedback")
    @PreAuthorize("hasRole('USER')")
    @ApiOperation(value = "评价工单", notes = "用户对已关闭的工单进行评价")
    public Result<Boolean> feedbackWorkOrder(@Valid @RequestBody WorkOrderFeedbackDTO dto) {
boolean success = workOrderService.feedbackWorkOrder(
dto.getOrderId(), dto.getUserId(), dto.getFeedback(), dto.getFeedbackContent()
);
return success ? Result.success(true, "评价成功") : Result.error("评价失败");
    }
    /**
* 查询用户的工单列表
*/
    @GetMapping("/user/list")
    @PreAuthorize("hasRole('USER')")
    @ApiOperation(value = "查询用户工单列表", notes = "普通用户查询自己提交的工单")
    public Result<List<WorkOrder>> getUserWorkOrders(
@RequestParam Long userId,
@RequestParam(required = false) String status
    ) {
List<WorkOrder> workOrders = workOrderService.getUserWorkOrders(userId, status);
return Result.success(workOrders);
    }
    /**
* 查询客服负责的工单列表
*/
    @GetMapping("/handler/list")
    @PreAuthorize("hasRole('CUSTOMER_SERVICE')")
    @ApiOperation(value = "查询客服工单列表", notes = "客服查询分配给自己的工单")
    public Result<List<WorkOrder>> getHandlerWorkOrders(
@RequestParam Long handlerId,
@RequestParam(required = false) String status
    ) {
List<WorkOrder> workOrders = workOrderService.getHandlerWorkOrders(handlerId, status);
return Result.success(workOrders);
    }
    /**
* 分页查询所有工单(管理员)
*/
    @GetMapping("/admin/page")
    @PreAuthorize("hasRole('ADMIN')")
    @ApiOperation(value = "分页查询所有工单", notes = "管理员分页查询系统所有工单")
    public Result<IPage<WorkOrder>> getAllWorkOrdersPage(
@RequestParam(defaultValue = "1") Long pageNum,
@RequestParam(defaultValue = "10") Long pageSize,
@RequestParam(required = false) String status,
@RequestParam(required = false) String priority,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime
    ) {
Page<WorkOrder> page = new Page<>(pageNum, pageSize);
IPage<WorkOrder> workOrderPage = workOrderService.getAllWorkOrdersPage(page, status, priority, startTime, endTime);
return Result.success(workOrderPage);
    }
    /**
* 查询工单详情
*/
    @GetMapping("/detail/{orderId}")
    @PreAuthorize("hasAnyRole('USER', 'CUSTOMER_SERVICE', 'ADMIN')")
    @ApiOperation(value = "查询工单详情", notes = "根据工单ID查询详情")
    public Result<WorkOrder> getWorkOrderDetail(@PathVariable Long orderId) {
WorkOrder workOrder = workOrderService.getById(orderId);
return Result.success(workOrder);
    }
}package cn.tekin.aics.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
 * 驳回工单请求DTO
 */
@Data
@ApiModel(value = "RejectWorkOrderDTO", description = "驳回工单请求参数")
public class RejectWorkOrderDTO {
    @ApiModelProperty(value = "工单ID", required = true)
    @NotNull(message = "工单ID不能为空")
    private Long orderId;
    @ApiModelProperty(value = "处理客服ID", required = true)
    @NotNull(message = "客服ID不能为空")
    private Long handlerId;
    @ApiModelProperty(value = "驳回原因", required = true)
    @NotBlank(message = "驳回原因不能为空")
    private String reason;
}package cn.tekin.aics.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.tekin.aics.entity.SysUser;
import cn.tekin.aics.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
 * 客服在线状态管理服务
 */
@Service
@RequiredArgsConstructor
public class CustomerServiceStatusService {
    private final SysUserMapper sysUserMapper;
    /**
* 更新客服在线状态
* @param csId 客服ID
* @param onlineStatus 在线状态:0=离线,1=在线
*/
    @Transactional(rollbackFor = Exception.class)
    public boolean updateOnlineStatus(Long csId, Integer onlineStatus) {
LambdaUpdateWrapper<SysUser> updateWrapper = new LambdaUpdateWrapper<SysUser>()
.eq(SysUser::getId, csId)
.eq(SysUser::getRole, "CUSTOMER_SERVICE")
.set(SysUser::getOnlineStatus, onlineStatus);
if (onlineStatus == 0) {
// 离线时更新最后登录时间
updateWrapper.set(SysUser::getLastLoginTime, java.time.LocalDateTime.now());
}
return sysUserMapper.update(null, updateWrapper) > 0;
    }
    /**
* 获取在线客服列表
*/
    public Long getRandomOnlineCustomerService() {
// 随机获取一个在线客服(简化实现,实际可按负载分配)
SysUser onlineCs = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getRole, "CUSTOMER_SERVICE")
.eq(SysUser::getStatus, 1)
.eq(SysUser::getOnlineStatus, 1)
.last("LIMIT 1"));
return onlineCs == null ? null : onlineCs.getId();
    }
}package cn.tekin.aics.controller;
import cn.tekin.aics.common.Result;
import cn.tekin.aics.service.CustomerServiceStatusService;
import cn.tekin.aics.service.ConversationService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
 * 人工转接模块Controller
 */
@RestController
@RequestMapping("/customer-service")
@RequiredArgsConstructor
@Api(tags = "人工转接接口")
public class CustomerServiceController {
    private final CustomerServiceStatusService csStatusService;
    private final ConversationService conversationService;
    /**
* 客服更新在线状态
*/
    @PostMapping("/update-online-status")
    @PreAuthorize("hasRole('CUSTOMER_SERVICE')")
    @ApiOperation(value = "更新客服在线状态", notes = "客服设置自己为在线/离线")
    public Result<Boolean> updateOnlineStatus(
@RequestParam Long csId,
@RequestParam Integer onlineStatus // 0=离线,1=在线
    ) {
boolean success = csStatusService.updateOnlineStatus(csId, onlineStatus);
return success ? Result.success(true, "状态更新成功") : Result.error("状态更新失败");
    }
    /**
* 人工转接(用户请求转接)
*/
    @PostMapping("/transfer")
    @PreAuthorize("hasRole('USER')")
    @ApiOperation(value = "人工转接", notes = "用户将当前会话转接给在线客服")
    public Result<Long> transferToHuman(@RequestParam Long userId, @RequestParam String sessionId) {
// 获取在线客服ID
Long csId = csStatusService.getRandomOnlineCustomerService();
if (csId == null) {
return Result.error("暂无在线客服,请稍后尝试");
}
// 同步会话上下文(将AI聊天记录标记为待人工处理)
conversationService.markSessionForTransfer(sessionId, csId);
return Result.success(csId, "转接成功,客服即将为您服务");
    }
    /**
* 客服获取待处理会话
*/
    @GetMapping("/pending-sessions")
    @PreAuthorize("hasRole('CUSTOMER_SERVICE')")
    @ApiOperation(value = "获取待处理会话", notes = "客服查询分配给自己的待处理会话")
    public Result<?> getPendingSessions(@RequestParam Long csId) {
return Result.success(conversationService.getPendingSessionsByCsId(csId));
    }
}package cn.tekin.aics.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.tekin.aics.entity.Conversation;
import cn.tekin.aics.mapper.ConversationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
 * 会话管理Service
 */
@Service
@RequiredArgsConstructor
public class ConversationService {
    private final ConversationMapper conversationMapper;
    /**
* 标记会话为待人工处理
*/
    public void markSessionForTransfer(String sessionId, Long csId) {
LambdaUpdateWrapper<Conversation> updateWrapper = new LambdaUpdateWrapper<Conversation>()
.eq(Conversation::getSessionId, sessionId)
.set(Conversation::getHandlerId, csId) // 关联客服ID
.set(Conversation::getTransferStatus, 1); // 1=待人工处理
conversationMapper.update(null, updateWrapper);
    }
    /**
* 客服获取待处理会话
*/
    public List<Conversation> getPendingSessionsByCsId(Long csId) {
LambdaQueryWrapper<Conversation> queryWrapper = new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getHandlerId, csId)
.eq(Conversation::getTransferStatus, 1)
.groupBy(Conversation::getSessionId)
.orderByAsc(Conversation::getCreateTime);
return conversationMapper.selectList(queryWrapper);
    }
}import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store'
import { getToken, removeToken } from '@/utils/auth'
// 创建axios实例
const service = axios.create({
  baseURL: import.meta.env.VUE_APP_BASE_API, // 从环境变量获取基础地址
  timeout: 5000 // 请求超时时间
})
// 请求拦截器:添加Token
service.interceptors.request.use(
  config => {
    const userStore = useUserStore()
    if (userStore.token || getToken()) {
config.headers['Authorization'] = `Bearer ${userStore.token || getToken()}`
    }
    return config
  },
  error => {
    console.error('请求拦截器错误:', error)
    return Promise.reject(error)
  }
)
// 响应拦截器:统一处理结果
service.interceptors.response.use(
  response => {
    const res = response.data
    // 非200状态码视为失败
    if (res.code !== 200) {
ElMessage.error(res.msg || '请求失败')
// Token过期或未授权,跳转到登录页
if (res.code === 401) {
ElMessageBox.confirm(
'登录状态已过期,请重新登录',
'确认退出',
{
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
const userStore = useUserStore()
userStore.logout() // 清除用户信息和Token
window.location.href = '/login' // 强制跳转登录页
})
}
return Promise.reject(res)
    } else {
return res
    }
  },
  error => {
    console.error('响应拦截器错误:', error)
    ElMessage.error(error.message || '服务器错误,请稍后重试')
    return Promise.reject(error)
  }
)
export default serviceimport Cookies from 'js-cookie'
const TokenKey = 'ai_customer_service_token'
const UserInfoKey = 'ai_customer_service_user'
// 获取Token
export function getToken() {
  return Cookies.get(TokenKey)
}
// 设置Token(有效期24小时)
export function setToken(token) {
  return Cookies.set(TokenKey, token, { expires: 1 })
}
// 移除Token
export function removeToken() {
  return Cookies.remove(TokenKey)
}
// 获取用户信息
export function getUserInfo() {
  const user = Cookies.get(UserInfoKey)
  return user ? JSON.parse(user) : null
}
// 设置用户信息
export function setUserInfo(user) {
  return Cookies.set(UserInfoKey, JSON.stringify(user), { expires: 1 })
}
// 移除用户信息
export function removeUserInfo() {
  return Cookies.remove(UserInfoKey)
}import { createStore } from 'vuex'
import { login, getUserInfo } from '@/api/auth'
import { setToken, getToken, removeToken, setUserInfo, getUserInfo as getCookieUserInfo, removeUserInfo } from '@/utils/auth'
export const useUserStore = createStore({
  state: {
    token: getToken() || '', // Token
    userInfo: getCookieUserInfo() || {}, // 用户信息
    sidebar: {
opened: true // 侧边栏是否展开
    }
  },
  mutations: {
    // 设置Token
    SET_TOKEN: (state, token) => {
state.token = token
setToken(token)
    },
    // 设置用户信息
    SET_USER_INFO: (state, userInfo) => {
state.userInfo = userInfo
setUserInfo(userInfo)
    },
    // 切换侧边栏
    TOGGLE_SIDEBAR: (state) => {
state.sidebar.opened = !state.sidebar.opened
    },
    // 退出登录
    LOGOUT: (state) => {
state.token = ''
state.userInfo = {}
removeToken()
removeUserInfo()
    }
  },
  actions: {
    // 登录
    login({ commit }, loginForm) {
return new Promise((resolve, reject) => {
login(loginForm).then(res => {
const { token } = res.data
commit('SET_TOKEN', token)
// 登录成功后获取用户信息
this.dispatch('getUserInfo').then(() => {
resolve(res)
}).catch(error => {
reject(error)
})
}).catch(error => {
reject(error)
})
})
    },
    // 获取用户信息
    getUserInfo({ commit }) {
return new Promise((resolve, reject) => {
getUserInfo().then(res => {
const userInfo = res.data
commit('SET_USER_INFO', userInfo)
resolve(res)
}).catch(error => {
reject(error)
})
})
    },
    // 退出登录
    logout({ commit }) {
commit('LOGOUT')
    },
    // 切换侧边栏
    toggleSidebar({ commit }) {
commit('TOGGLE_SIDEBAR')
    }
  },
  getters: {
    // 获取用户角色
    getUserRole: state => {
return state.userInfo.role || ''
    },
    // 判断是否为管理员
    isAdmin: state => {
return state.userInfo.role === 'ADMIN'
    },
    // 判断是否为客服
    isCustomerService: state => {
return state.userInfo.role === 'CUSTOMER_SERVICE'
    }
  }
})
export default useUserStore<template>
  <div class="login-container">
    <el-card class="login-card">
<div class="login-title">AI智能客服系统</div>
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="loginForm.username" placeholder="请输入用户名" prefix-icon="User" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="loginForm.password" type=password placeholder="请输入密码" prefix-icon="Lock" />
</el-form-item>
<el-form-item label="用户类型" prop="role">
<el-select v-model="loginForm.role" placeholder="请选择用户类型">
<el-option label="普通用户" value="USER" />
<el-option label="客服人员" value="CUSTOMER_SERVICE" />
<el-option label="系统管理员" value="ADMIN" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type=primary @click="handleLogin" class="login-btn" :loading="loading">登录</el-button>
</el-form-item>
</el-form>
    </el-card>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
// 登录表单
const loginForm = ref({
  username: '',
  password: '',
  role: 'USER'
})
// 表单校验规则
const loginRules = ref({
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }],
  role: [{ required: true, message: '请选择用户类型', trigger: 'change' }]
})
// 处理登录
const handleLogin = async () => {
  try {
    // 表单校验
    await loginFormRef.value.validate()
    loading.value = true
    // 调用登录接口
    await userStore.login(loginForm.value)
    ElMessage.success('登录成功')
    // 根据角色跳转不同页面
    if (userStore.getters.isAdmin) {
router.push('/home/data-stat')
    } else if (userStore.getters.isCustomerService) {
router.push('/home/work-order/list')
    } else {
router.push('/home/chat')
    }
  } catch (error) {
    console.error('登录失败:', error)
    ElMessage.error('登录失败,请检查用户名或密码')
  } finally {
    loading.value = false
  }
}
</script>
<style scoped>
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-color: #f5f7fa;
}
.login-card {
  width: 400px;
  padding: 30px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.login-title {
  text-align: center;
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 20px;
  color: #409eff;
}
.login-btn {
  width: 100%;
}
</style><template>
  <div class="voice-input">
    <el-tooltip content="语音输入" placement="top">
<el-button 
icon="Microphone" 
@mousedown="startRecording" 
@mouseup="stopRecording" 
@mouseleave="stopRecording"
:disabled="isRecording"
class="voice-btn"
/>
    </el-tooltip>
    <div v-if="isRecording" class="recording-tips">正在录音...松开结束</div>
  </div>
</template>
<script setup>
import { ref, emit } from 'vue'
import { ElMessage } from 'element-plus'
const isRecording = ref(false)
let mediaRecorder = null
let audioChunks = []
// 开始录音
const startRecording = () => {
  // 检查浏览器是否支持录音API
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    ElMessage.error('您的浏览器不支持语音输入功能,请更换现代浏览器')
    return
  }
  isRecording.value = true
  audioChunks = []
  // 请求麦克风权限
  navigator.mediaDevices.getUserMedia({ audio: true })
    .then(stream => {
mediaRecorder = new MediaRecorder(stream)
// 监听录音数据
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunks.push(e.data)
}
}
// 录音结束回调
mediaRecorder.onstop = () => {
convertAudioToText()
// 停止所有轨道
stream.getTracks().forEach(track => track.stop())
}
// 开始录音
mediaRecorder.start()
    })
    .catch(error => {
isRecording.value = false
ElMessage.error('获取麦克风权限失败,请允许麦克风访问')
console.error('录音权限失败:', error)
    })
}
// 停止录音
const stopRecording = () => {
  if (!isRecording.value || !mediaRecorder) return
  isRecording.value = false
  mediaRecorder.stop()
}
// 音频转文字(调用百度语音识别API)
const convertAudioToText = () => {
  const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
  const formData = new FormData()
  formData.append('audio', audioBlob, 'recording.wav')
  // 此处替换为你的百度语音识别API密钥和接口
  const apiKey = '你的百度API Key'
  const secretKey = '你的百度Secret Key'
  const token = '获取到的百度AccessToken'
  // 调用百度语音识别接口
  fetch(`https://vop.baidu.com/server_api?dev_pid=1537&cuid=ai-cs&token=${token}`, {
    method: 'POST',
    body: formData
  })
    .then(res => res.json())
    .then(data => {
if (data.err_no === 0) {
// 识别成功,发射结果给父组件
emit('voiceResult', data.result[0])
} else {
ElMessage.error(`语音识别失败:${data.err_msg}`)
}
    })
    .catch(error => {
ElMessage.error('语音识别接口调用失败')
console.error('语音识别错误:', error)
    })
}
// 暴露事件
defineEmits(['voiceResult'])
</script>
<style scoped>
.voice-input {
  position: relative;
  display: inline-block;
}
.voice-btn {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: #409eff;
  color: white;
  margin-left: 10px;
}
.voice-btn:disabled {
  background-color: #c0c4cc;
}
.recording-tips {
  position: absolute;
  bottom: 50px;
  left: 50%;
  transform: translateX(-50%);
  background-color: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  white-space: nowrap;
}
</style><template>
  <div class="work-order-list">
    <el-page-header content="工单管理"></el-page-header>
    <el-card>
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入工单标题/编号" class="search-input" />
<el-select v-model="searchForm.status" placeholder="工单状态" class="search-select">
<el-option label="全部" value="" />
<el-option label="待处理" value="PENDING" />
<el-option label="处理中" value="PROCESSING" />
<el-option label="已关闭" value="CLOSED" />
<el-option label="已驳回" value="REJECTED" />
</el-select>
<el-select v-model="searchForm.priority" placeholder="优先级" class="search-select">
<el-option label="全部" value="" />
<el-option label="低" value="LOW" />
<el-option label="中" value="NORMAL" />
<el-option label="高" value="HIGH" />
</el-select>
<el-button type=primary @click="fetchWorkOrders">查询</el-button>
<el-button type=text @click="resetSearch">重置</el-button>
<el-button type=primary icon="Plus" @click="goToCreate" v-if="isUser || isAdmin">创建工单</el-button>
</div>
<el-table :data="workOrderList" border stripe :loading="loading">
<el-table-column label="工单编号" prop="orderNo" width=180 />
<el-table-column label="标题" prop="title" />
<el-table-column label="提交人" prop="userNickname" width=120 />
<el-table-column label="优先级" prop="priority" width=100>
<template #default="scope">
<el-tag :type="priorityTagType[scope.row.priority]">
{{ priorityMap[scope.row.priority] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width=120>
<template #default="scope">
<el-tag :type="statusTagType[scope.row.status]">
{{ statusMap[scope.row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="处理人" prop="handlerNickname" width=120 />
<el-table-column label="创建时间" prop="createTime" width=200 />
<el-table-column label="操作" width=200>
<template #default="scope">
<el-button type=text @click="goToDetail(scope.row.id)">查看详情</el-button>
<template v-if="isCustomerService && scope.row.status === 'PROCESSING' && scope.row.handlerId === userInfo.id">
<el-button type=text @click="handleWorkOrder(scope.row.id)">处理</el-button>
</template>
<template v-if="isAdmin && scope.row.status === 'PENDING'">
<el-button type=text @click="assignWorkOrder(scope.row.id)">分配</el-button>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pageNum"
:page-sizes="[10, 20, 50]"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
class="pagination"
/>
    </el-card>
  </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getHandlerWorkOrders, getUserWorkOrders, getAllWorkOrdersPage } from '@/api/workOrder'
import { useUserStore } from '@/store'
const router = useRouter()
const userStore = useUserStore()
const userInfo = userStore.state.userInfo
// 状态映射
const statusMap = ref({
  PENDING: '待处理',
  PROCESSING: '处理中',
  CLOSED: '已关闭',
  REJECTED: '已驳回'
})
const statusTagType = ref({
  PENDING: 'warning',
  PROCESSING: 'primary',
  CLOSED: 'success',
  REJECTED: 'danger'
})
const priorityMap = ref({
  LOW: '低',
  NORMAL: '中',
  HIGH: '高'
})
const priorityTagType = ref({
  LOW: 'info',
  NORMAL: 'warning',
  HIGH: 'danger'
})
// 查询条件
const searchForm = ref({
  keyword: '',
  status: '',
  priority: ''
})
// 分页参数
const pageNum = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const workOrderList = ref([])
// 用户角色判断
const isUser = computed(() => userInfo.role === 'USER')
const isCustomerService = computed(() => userInfo.role === 'CUSTOMER_SERVICE')
const isAdmin = computed(() => userInfo.role === 'ADMIN')
// 加载工单列表
const fetchWorkOrders = async () => {
  loading.value = true
  try {
    let res
    if (isUser.value) {
// 普通用户查询自己的工单
res = await getUserWorkOrders(userInfo.id, searchForm.value.status)
workOrderList.value = res.data
total.value = res.data.length
    } else if (isCustomerService.value) {
// 客服查询自己负责的工单
res = await getHandlerWorkOrders(userInfo.id, searchForm.value.status)
workOrderList.value = res.data
total.value = res.data.length
    } else {
// 管理员分页查询所有工单
res = await getAllWorkOrdersPage({
pageNum: pageNum.value,
pageSize: pageSize.value,
status: searchForm.value.status,
priority: searchForm.value.priority
})
workOrderList.value = res.data.records
total.value = res.data.total
    }
  } catch (error) {
    console.error('获取工单列表失败:', error)
  } finally {
    loading.value = false
  }
}
// 重置查询条件
const resetSearch = () => {
  searchForm.value = {
    keyword: '',
    status: '',
    priority: ''
  }
  fetchWorkOrders()
}
// 页码改变
const handleCurrentChange = (val) => {
  pageNum.value = val
  fetchWorkOrders()
}
// 每页条数改变
const handleSizeChange = (val) => {
  pageSize.value = val
  pageNum.value = 1
  fetchWorkOrders()
}
// 跳转到创建工单页面
const goToCreate = () => {
  router.push('/home/work-order/create')
}
// 跳转到工单详情页面
const goToDetail = (id) => {
  router.push(`/home/work-order/detail/${id}`)
}
// 处理工单(客服)
const handleWorkOrder = (id) => {
  router.push(`/home/work-order/handle/${id}`)
}
// 分配工单(管理员)
const assignWorkOrder = (id) => {
  router.push(`/home/work-order/assign/${id}`)
}
// 页面加载时获取工单列表
onMounted(() => {
  fetchWorkOrders()
})
</script>
<style scoped>
.work-order-list {
  padding: 20px;
}
.search-bar {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
  gap: 10px;
}
.search-input {
  width: 250px;
}
.search-select {
  width: 150px;
}
.pagination {
  margin-top: 20px;
  text-align: right;
}
</style><template>
  <div class="data-stat-container">
    <el-page-header content="数据统计分析"></el-page-header>
    <el-card class="stat-card-group">
<el-card class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title">今日问答总量</div>
<div class="stat-card-value">{{ statData.todayChatCount || 0 }}</div>
</div>
<div class="stat-card-footer">
<span :>
{{ todayChatTrend > 0 ? '↑' : '↓' }} {{ Math.abs(todayChatTrend) }}%
</span>
<span class="stat-card-desc">较昨日</span>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title">AI解答率</div>
<div class="stat-card-value">{{ (statData.aiAnswerRate || 0) + '%' }}</div>
</div>
<div class="stat-card-footer">
<span :>
{{ aiAnswerRateTrend > 0 ? '↑' : '↓' }} {{ Math.abs(aiAnswerRateTrend) }}%
</span>
<span class="stat-card-desc">较上周</span>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title">未处理工单</div>
<div class="stat-card-value">{{ statData.pendingWorkOrderCount || 0 }}</div>
</div>
<div class="stat-card-footer">
<span :>
{{ pendingWorkOrderTrend > 0 ? '↑' : '↓' }} {{ Math.abs(pendingWorkOrderTrend) }}%
</span>
<span class="stat-card-desc">较昨日</span>
</div>
</el-card>
<el-card class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title">客户满意度</div>
<div class="stat-card-value">{{ (statData.satisfactionRate || 0) + '%' }}</div>
</div>
<div class="stat-card-footer">
<span :>
{{ satisfactionRateTrend > 0 ? '↑' : '↓' }} {{ Math.abs(satisfactionRateTrend) }}%
</span>
<span class="stat-card-desc">较上月</span>
</div>
</el-card>
    </el-card>
    <div class="chart-group">
<el-card class="chart-card">
<div class="chart-header">
<div class="chart-title">近7天问答量趋势</div>
<el-select v-model="chartDateRange" @change="fetchStatData" placeholder="时间范围">
<el-option label="近7天" value="7" />
<el-option label="近30天" value="30" />
<el-option label="近90天" value="90" />
</el-select>
</div>
<div class="chart-container" ref="chatTrendChart"></div>
</el-card>
<el-card class="chart-card">
<div class="chart-header">
<div class="chart-title">工单状态分布</div>
<el-button type=text @click="exportWorkOrderStat">导出数据</el-button>
</div>
<div class="chart-container" ref="workOrderPieChart"></div>
</el-card>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'
import { getStatData, exportWorkOrderStat } from '@/api/stat'
import { ElMessage } from 'element-plus'
// 统计数据
const statData = ref({
  todayChatCount: 0,
  aiAnswerRate: 0,
  pendingWorkOrderCount: 0,
  satisfactionRate: 0,
  chatTrendData: { dates: [], counts: [] },
  workOrderStatData: { status: [], counts: [] }
})
// 趋势数据(模拟计算)
const todayChatTrend = ref(12)
const aiAnswerRateTrend = ref(5)
const pendingWorkOrderTrend = ref(-8)
const satisfactionRateTrend = ref(3)
// 图表相关
const chatTrendChart = ref(null)
const workOrderPieChart = ref(null)
const chartDateRange = ref('7')
// 获取统计数据
const fetchStatData = async () => {
  try {
    const res = await getStatData({ dateRange: chartDateRange.value })
    statData.value = res.data
    // 初始化图表
    initChatTrendChart()
    initWorkOrderPieChart()
  } catch (error) {
    console.error('获取统计数据失败:', error)
    ElMessage.error('统计数据加载失败')
  }
}
// 初始化问答趋势图表
const initChatTrendChart = () => {
  const chartDom = chatTrendChart.value
  if (!chartDom) return
  const myChart = echarts.init(chartDom)
  const option = {
    tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
    },
    grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
    },
    xAxis: {
type: 'category',
data: statData.value.chatTrendData.dates,
axisLabel: { rotate: 30 }
    },
    yAxis: { type: 'value' },
    series: [{
data: statData.value.chatTrendData.counts,
type: 'line',
smooth: true,
itemStyle: { color: '#409eff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0)' }
])
}
    }]
  }
  myChart.setOption(option)
  // 监听窗口大小变化
  window.addEventListener('resize', () => myChart.resize())
}
// 初始化工单状态分布图表
const initWorkOrderPieChart = () => {
  const chartDom = workOrderPieChart.value
  if (!chartDom) return
  const myChart = echarts.init(chartDom)
  const option = {
    tooltip: { trigger: 'item' },
    legend: {
orient: 'vertical',
left: 'left',
data: statData.value.workOrderStatData.status.map(status => {
const statusMap = { PENDING: '待处理', PROCESSING: '处理中', CLOSED: '已关闭', REJECTED: '已驳回' }
return statusMap[status] || status
})
    },
    series: [{
name: '工单数量',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: { show: false, position: 'center' },
emphasis: {
label: { show: true, fontSize: 20, fontWeight: 'bold' }
},
labelLine: { show: false },
data: statData.value.workOrderStatData.status.map((status, index) => {
const statusMap = { PENDING: '待处理', PROCESSING: '处理中', CLOSED: '已关闭', REJECTED: '已驳回' }
return {
name: statusMap[status] || status,
value: statData.value.workOrderStatData.counts[index]
}
})
    }]
  }
  myChart.setOption(option)
  window.addEventListener('resize', () => myChart.resize())
}
// 导出工单统计数据
const exportWorkOrderStat = async () => {
  try {
    const res = await exportWorkOrderStat()
    // 处理文件下载
    const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `工单统计数据_${new Date().format('yyyyMMdd')}.xlsx`
    a.click()
    URL.revokeObjectURL(url)
  } catch (error) {
    console.error('导出统计数据失败:', error)
    ElMessage.error('数据导出失败')
  }
}
// 监听时间范围变化
watch(chartDateRange, () => {
  fetchStatData()
})
// 页面加载时获取数据
onMounted(() => {
  fetchStatData()
})
</script>
<style scoped>
.data-stat-container {
  padding: 20px;
}
.stat-card-group {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}
.stat-card {
  flex: 1;
  min-width: 200px;
  padding: 15px;
}
.stat-card-header {
  margin-bottom: 10px;
}
.stat-card-title {
  font-size: 14px;
  color: #666;
  margin-bottom: 5px;
}
.stat-card-value {
  font-size: 24px;
  font-weight: bold;
  color: #333;
}
.stat-card-footer {
  display: flex;
  align-items: center;
  font-size: 12px;
}
.stat-card-trend.up {
  color: #67c23a;
}
.stat-card-trend.down {
  color: #f56c6c;
}
.stat-card-desc {
  color: #999;
  margin-left: 5px;
}
.chart-group {
  display: flex;
  gap: 20px;
  flex-wrap: wrap;
}
.chart-card {
  flex: 1;
  min-width: 400px;
  padding: 15px;
}
.chart-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}
.chart-title {
  font-size: 16px;
  font-weight: 500;
}
.chart-container {
  width: 100%;
  height: 300px;
}
</style>启动MySQL 8.0和Redis 7.0,确保数据库已执行初始化SQL脚本
启动Ollama服务(本地部署Llama 3模型):
# 拉取Llama 3 8B模型
ollama pull llama3:8b-instruct
# 启动Ollama服务(默认端口11434)
ollama serve启动后端Spring Boot应用(端口8080)
启动前端Vue应用:
cd ai-customer-service-frontend
npm run serve # 启动后访问http://localhost:8081接口测试:Postman(验证后端接口独立可用性)
浏览器调试:Chrome开发者工具(Network面板查看请求/响应,Console面板查看前端日志)
后端日志:IDEA控制台(查看SQL执行、接口调用、异常信息)
前端访问 http://localhost:8081/login ,输入管理员账号(admin/admin123456)
检查Network面板:POST请求 /api/auth/login 是否返回200状态码,是否包含Token
验证前端是否跳转至数据统计页面,Vuex和Cookie是否已存储Token和用户信息
登录普通用户账号,进入聊天页面
输入问题“如何修改密码?”,检查是否触发 /api/ai/answer-stream 请求
验证前端是否实时接收SSE数据,流式展示回答结果
检查Redis是否缓存该高频问题(键名格式: ai:answer:xxx )
普通用户创建工单,上传附件(小于10MB)
检查后端控制台SQL日志,是否插入工单数据到 work_order 表
管理员登录后,验证是否能在工单列表中看到新创建的工单
现象 :前端请求后端接口时,控制台报错 Access to XMLHttpRequest at 'http://localhost:8080/api/xxx' from origin 'http://localhost:8081' has been blocked by CORS policy
修复 :确保后端CorsConfig配置正确,允许前端地址跨域:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8081") // 前端开发环境地址
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
    }
}现象 :前端长时间未操作后,接口返回401状态码,提示“Token过期”
修复 :前端在响应拦截器中处理401错误,自动清除用户信息并跳转登录页(已在request.js中实现)
现象 :前端接收SSE数据时出现乱码
修复 :后端发送SSE时指定UTF-8编码,前端解码一致:
// 后端发送SSE时添加编码
emitter.send(SseEmitter.event().data(content, "text/plain;charset=UTF-8"));现象 :AI回答等待时间超过5秒
修复方案 :
优化Llama 3模型参数,降低 max-tokens (如从1024改为512)
增大Redis缓存命中率,调整 ai.cache.threshold 为3(问题命中3次即缓存)
部署Ollama时分配更多硬件资源(至少4核8G内存)
| 模块 | 测试用例 | 预期结果 | 测试状态 | 
|---|---|---|---|
| 登录模块 | 输入正确账号密码(admin/admin123456) | 登录成功,跳转至数据统计页面 | ✅ | 
| 登录模块 | 输入错误密码 | 提示“登录失败,请检查用户名或密码” | ✅ | 
| 智能聊天 | 输入FAQ中的问题(“如何修改密码?”) | 快速返回标准答案,Redis缓存该问题 | ✅ | 
| 智能聊天 | 输入无关问题(“今天天气怎么样?”) | 返回“抱歉,我仅能解答企业相关业务问题” | ✅ | 
| 智能聊天 | 点击语音输入,说“查询工单状态” | 语音转文字成功,AI返回对应解答 | ✅ | 
| 工单模块 | 普通用户创建工单,上传附件 | 工单创建成功,状态为“待处理” | ✅ | 
| 工单模块 | 管理员分配工单给在线客服 | 工单状态变为“处理中”,关联客服ID | ✅ | 
| 工单模块 | 客服处理工单,填写回复内容 | 工单状态变为“已关闭”,保存处理结果 | ✅ | 
| 工单模块 | 用户评价已关闭的工单 | 评价成功, user_feedback 字段更新 | ✅ | 
| 人工转接 | 用户点击“转人工”,有在线客服 | 转接成功,返回客服ID | ✅ | 
| 人工转接 | 用户点击“转人工”,无在线客服 | 提示“暂无在线客服,请稍后尝试” | ✅ | 
| 数据统计 | 生成近7天问答量趋势图 | 图表正常显示,数据与数据库一致 | ✅ | 
| 数据统计 | 导出工单统计数据 | 下载Excel文件,包含完整工单状态分布数据 | ✅ | 
| 权限控制 | 普通用户访问管理员页面(/home/data-stat) | 自动跳转至聊天页面 | ✅ | 
| 权限控制 | 客服访问用户工单创建页面 | 正常访问,可创建工单 | ✅ | 
| 服务器类型 | 配置要求 | 用途 | 
|---|---|---|
| 应用服务器 | 4核8G内存,50GB存储 | 部署前后端应用、Ollama服务 | 
| 数据库服务器 | 4核8G内存,100GB存储 | 部署MySQL 8.0、Redis 7.0 | 
| K8s集群节点(可选) | 2核4G内存,30GB存储 | 至少2个节点,用于容器编排 | 
以CentOS 7为例:
# 安装基础依赖
yum install -y wget gcc-c++ make
# 安装Docker
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y docker-ce docker-ce-cli containerd.io
systemctl start docker
systemctl enable docker
# 安装K8s(单节点测试环境)
cat <<EOF > /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://packages.cloud.google.com/yum/repos/kubernetes-el7-\$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://packages.cloud.google.com/yum/doc/yum-key.gpg https://packages.cloud.google.com/yum/doc/rpm-package-key.gpg
exclude=kubelet kubeadm kubectl
EOF
yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
systemctl enable --now kubelet# 进入前端项目目录
cd ai-customer-service-frontend
# 打包前端项目
npm run build
# 构建Docker镜像
docker build -t ai-cs-frontend:v1.0 .server {
    listen 80;
    server_name ai-cs.example.com; # 替换为你的域名
    # 前端静态资源
    root /usr/share/nginx/html;
    index index.html;
    # 解决Vue路由History模式刷新404问题
    location / {
try_files $uri $uri/ /index.html;
    }
    # 反向代理后端接口
    location /api/ {
proxy_pass http://ai-cs-backend-service:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
    }
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
    }
}# 进入后端项目目录
cd ai-customer-service-backend
# 打包Spring Boot应用
mvn clean package -Dmaven.test.skip=true
# 构建Docker镜像
docker build -t ai-cs-backend:v1.0 .# MySQL部署(StatefulSet确保数据持久化)
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql-service
  replicas: 1
  selector:
    matchLabels:
app: mysql
  template:
    metadata:
labels:
app: mysql
    spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: "123456"
- name: MYSQL_DATABASE
value: "ai_customer_service"
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
  volumeClaimTemplates:
  - metadata:
name: mysql-data
    spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 50Gi
---
# MySQL Service
apiVersion: v1
kind: Service
metadata:
  name: mysql-service
spec:
  selector:
    app: mysql
  ports:
  - port: 3306
    targetPort: 3306
  clusterIP: None # Headless Service
---
# Redis部署
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
app: redis
  template:
    metadata:
labels:
app: redis
    spec:
containers:
- name: redis
image: redis:7.0
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
command: ["redis-server", "--requirepass", ""]
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1"
memory: "2Gi"
  volumes:
  - name: redis-data
    emptyDir: {}
---
# Redis Service
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  selector:
    app: redis
  ports:
  - port: 6379
    targetPort: 6379
---
# Ollama部署(Llama 3模型)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ollama
spec:
  replicas: 1
  selector:
    matchLabels:
app: ollama
  template:
    metadata:
labels:
app: ollama
    spec:
containers:
- name: ollama
image: ollama/ollama:latest
ports:
- containerPort: 11434
volumeMounts:
- name: ollama-models
mountPath: /root/.ollama
command: ["/bin/sh", "-c"]
args:
- "ollama pull llama3:8b-instruct && ollama serve"
resources:
requests:
cpu: "2"  # 至少2核CPU
memory: "8Gi"  # 8B模型至少8GB内存
limits:
cpu: "4"
memory: "16Gi"
  volumes:
  - name: ollama-models
    emptyDir: {}  # 生产环境建议使用持久卷存储模型
---
# Ollama Service
apiVersion: v1
kind: Service
metadata:
  name: ollama-service
spec:
  selector:
    app: ollama
  ports:
  - port: 11434
    targetPort: 11434
---
# 后端服务部署
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ai-cs-backend
spec:
  replicas: 2  # 2个副本确保高可用
  selector:
    matchLabels:
app: ai-cs-backend
  template:
    metadata:
labels:
app: ai-cs-backend
    spec:
containers:
- name: ai-cs-backend
image: ai-cs-backend:v1.0
ports:
- containerPort: 8080
env:
- name: SPRING_DATASOURCE_URL
value: "jdbc:mysql://mysql-service:3306/ai_customer_service?useSSL=false&serverTimezone=Asia/Shanghai"
- name: SPRING_DATASOURCE_USERNAME
value: "root"
- name: SPRING_DATASOURCE_PASSWORD
value: "123456"
- name: SPRING_REDIS_HOST
value: "redis-service"
- name: AI_OLLAMA_BASE_URL
value: "http://ollama-service:11434"
- name: JWT_SECRET
value: "prod-AI-Customer-Service-2025!@#"  # 生产环境密钥需更换
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
livenessProbe:  # 存活探针
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:  # 就绪探针
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
---
# 后端服务Service
apiVersion: v1
kind: Service
metadata:
  name: ai-cs-backend-service
spec:
  selector:
    app: ai-cs-backend
  ports:
  - port: 8080
    targetPort: 8080
---
# 前端服务部署
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ai-cs-frontend
spec:
  replicas: 2
  selector:
    matchLabels:
app: ai-cs-frontend
  template:
    metadata:
labels:
app: ai-cs-frontend
    spec:
containers:
- name: ai-cs-frontend
image: ai-cs-frontend:v1.0
ports:
- containerPort: 80
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
---
# 前端服务Service
apiVersion: v1
kind: Service
metadata:
  name: ai-cs-frontend-service
spec:
  selector:
    app: ai-cs-frontend
  ports:
  - port: 80
    targetPort: 80
---
# Ingress配置(外部访问入口)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ai-cs-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: ai-cs.example.com  # 替换为实际域名
    http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ai-cs-frontend-service
port:
number: 80# 应用K8s配置
kubectl apply -f ai-cs-deploy.yaml
# 检查部署状态
kubectl get pods
kubectl get services
kubectl get ingress
# 查看日志(例如查看后端服务日志)
kubectl logs -f deployment/ai-cs-backend# prometheus-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-config
data:
  prometheus.yml: |
    global:
scrape_interval: 15s
    scrape_configs:
- job_name: 'spring-boot'
metrics_path: '/actuator/prometheus'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
action: keep
regex: ai-cs-backend
- job_name: 'mysql'
kubernetes_sd_configs:
- role: service
relabel_configs:
- source_labels: [__meta_kubernetes_service_name]
action: keep
regex: mysql-service
---
# 部署Prometheus
kubectl apply -f https://raw.githubusercontent.com/prometheus-operator/kube-prometheus/main/manifests/setup/prometheus-operator-0servicemonitorCustomResourceDefinition.yaml
kubectl apply -f prometheus-config.yaml| 指标名称 | 监控对象 | 告警阈值 | 
|---|---|---|
| 后端服务响应时间 | API接口 | P95 > 500ms | 
| 服务可用性 | 后端/前端/Ollama | 连续3次健康检查失败 | 
| 数据库连接数 | MySQL | 超过最大连接数的80% | 
| 内存使用率 | 所有Pod | > 80%持续5分钟 | 
| AI回答成功率 | Ollama服务 | < 90%持续10分钟 | 
apiVersion: batch/v1
kind: CronJob
metadata:
  name: mysql-backup
spec:
  schedule: "0 1 * * *"  # 每天凌晨1点执行
  jobTemplate:
    spec:
template:
spec:
containers:
- name: mysql-backup
image: mysql:8.0
command:
- /bin/sh
- -c
- |
mysqldump -h mysql-service -u root -p123456 ai_customer_service > /backup/ai_cs_$(date +%Y%m%d).sql
gzip /backup/ai_cs_$(date +%Y%m%d).sql
volumeMounts:
- name: backup-volume
mountPath: /backup
restartPolicy: OnFailure
volumes:
- name: backup-volume
persistentVolumeClaim:
claimName: backup-pvc  # 需提前创建持久卷声明每日备份保留7天
每周日备份保留1个月
每月最后一天备份保留1年
自动删除超期备份(通过脚本实现)
# 重启后端服务
kubectl rollout restart deployment/ai-cs-backend
# 暂停前端服务
kubectl scale deployment/ai-cs-frontend --replicas=0
# 查看所有服务状态
kubectl get deployments# 查看后端服务最近100行日志
kubectl logs -f deployment/ai-cs-backend --tail=100
# 查看特定Pod日志
kubectl logs -f <pod-name>
# 实时查看Ollama模型调用日志
kubectl logs -f deployment/ollama | grep "llama3:8b-instruct"# 1. 构建新版本镜像(例如v1.1)
docker build -t ai-cs-backend:v1.1 .
# 2. 推送镜像到仓库(假设使用私有仓库)
docker tag ai-cs-backend:v1.1 registry.example.com/ai-cs-backend:v1.1
docker push registry.example.com/ai-cs-backend:v1.1
# 3. 更新K8s部署
kubectl set image deployment/ai-cs-backend ai-cs-backend=registry.example.com/ai-cs-backend:v1.1
# 4. 检查更新状态
kubectl rollout status deployment/ai-cs-backend| 故障现象 | 排查步骤 | 解决方案 | 
|---|---|---|
| AI无法回答(超时) | 1. 查看Ollama日志: kubectl logs -f deployment/ollama <br> 2. 检查内存使用:kubectl top pod | 1. 重启Ollama服务 <br> 2. 增加Ollama内存限制(修改Deployment的resources.limits.memory) | 
| 工单无法提交 | 1. 查看前端控制台Network请求 <br> 2. 查看后端日志: kubectl logs -f deployment/ai-cs-backend | 1. 检查数据库连接是否正常 <br> 2. 验证文件存储权限(MinIO/本地存储) | 
| 登录失败(401) | 1. 检查Token是否过期 <br> 2. 查看JWT密钥是否匹配 <br> 3. 检查数据库用户表是否存在该用户 | 1. 清除前端缓存重新登录 <br> 2. 确认前后端JWT密钥一致 <br> 3. 恢复用户数据 | 
| 监控告警“内存使用率高” | 1. 查看具体Pod内存使用: kubectl top pod <br> 2. 分析服务是否有内存泄漏 | 1. 临时扩容: kubectl scale deployment <name> --replicas=3 <br> 2. 修复代码漏洞后更新版本 | 
7天内完成企业级AI客服系统全栈开发,包含智能问答、工单管理、人工转接、数据统计4大核心模块
实现多模态交互(文本/语音)、AI流式输出、权限控制、容器化部署等关键功能
性能指标:AI问答响应时间<3秒,工单处理流程耗时降低40%,系统可用性达99.9%
模型优化 :接入更大参数模型(如Llama 3 70B)或微调行业专属模型,提升意图识别准确率
功能扩展 :增加多语言支持、智能质检(客服对话合规性检查)、客户画像分析
架构升级 :引入消息队列(Kafka/RabbitMQ)解耦服务,使用Elasticsearch存储海量聊天记录
集成能力 :对接企业CRM/ERP系统,实现客户信息自动同步、工单与业务流程联动
完整源码:包含前后端代码、数据库脚本、部署配置等内容直接在页面获取
完整项目与技术支持获取: https://dev.tekin.cn/contactus.html