喜讯!TCMS 官网正式上线!一站式提供企业级定制研发、App 小程序开发、AI 与区块链等全栈软件服务,助力多行业数智转型,欢迎致电:13888011868  QQ 932256355 洽谈合作!

7天开发企业级AI客户服务系统(含Vue3+Spring Boot+K8s源码 与部署文档)

2025-10-31 126分钟阅读时长

7天内完成企业级AI客服系统全栈开发,包含智能问答、工单管理、人工转接、数据统计4大核心模块; 实现多模态交互(文本/语音)、AI流式输出、权限控制、容器化部署等关键功能; 性能指标:AI问答响应时间<3秒,工单处理流程耗时降低40%,系统可用性达99.9%

项目概述

2025年前8个月,信息技术服务占软件业收入比重达68.4%,AI相关服务增速超18%,其中智能客服成为企业降本增效的核心场景。本文将带大家用7天时间,从零开发一套集“智能问答、工单提交、人工转接、数据统计”于一体的企业级AI客户服务系统,全程附完整可运行源码、详细操作步骤与部署文档,新手也能跟着落地。

系统核心优势:采用Vue3+Spring Boot+K8s技术栈,接入开源大模型实现智能意图识别,支持文本/语音多模态交互与流式输出,部署后可直接对接企业业务系统,助力客服团队效率提升40%以上,同时降低50%的重复性咨询人力成本。

一、需求分析与技术选型

1. 核心功能清单(细化版)

  • 智能问答模块

    • 支持文本输入、语音转文字输入两种方式

    • AI自动解答常见问题,支持流式返回结果(模拟实时思考过程)

    • 高频问题缓存,提升响应速度(Redis实现)

    • 意图识别:无法解答时自动触发人工转接或工单提交引导

  • 工单管理模块

    • 用户端:提交工单(支持附件上传)、查询工单状态、评价处理结果

    • 客服端:接收工单、分配工单、处理工单、回复工单

    • 管理员端:工单统计、客服绩效评估、工单流程配置

  • 人工转接模块

    • 会话上下文同步(AI聊天记录自动同步给人工客服)

    • 客服在线状态显示、排队机制

    • 转接记录留存,支持后续追溯

  • 数据统计模块

    • 核心指标:日/周/月问答量、AI解答率、工单处理时效、客户满意度

    • 可视化图表:趋势图、占比图、排行榜

    • 数据导出功能(Excel格式)

  • 权限控制模块

    • 角色划分:普通用户(USER)、客服人员(CUSTOMER_SERVICE)、系统管理员(ADMIN)

    • 权限细化:数据查看权限、功能操作权限、配置修改权限

2. 技术栈选型(补充适配说明)

模块技术选型版本要求核心适配场景
前端Vue3+Element Plus+Axios+EChartsVue3.2+、Element Plus2.3+多端响应式适配、流式组件渲染、图表可视化
后端Spring Boot+Spring Security+MyBatis-PlusSpring Boot3.1+、JDK17+企业级权限控制、高效数据库操作、接口快速开发
AI能力Llama 3(开源大模型)+LangChain+OllamaLlama3-8B、LangChain0.2+轻量化本地部署、意图识别准确率≥85%、低硬件门槛
数据库MySQL 8.0+Redis 7.0MySQL8.0.30+、Redis7.0.10+业务数据持久化、高频数据缓存、会话存储
容器化&部署Docker+KubernetesDocker24.0+、K8s1.24+环境一致性保障、自动扩缩容、企业级集群部署
语音识别百度语音识别API(可选)V1版本免费额度充足(5万次/天)、识别准确率≥95%
实时通信SSE(Server-Sent Events)浏览器原生支持AI流式输出、无WebSocket的轻量化实时通信
文件存储本地存储(基础版)/MinIO(进阶版)MinIO8.5+工单附件存储、支持扩容与分布式部署

二、第1天:项目初始化与基础环境搭建(补充实操细节)

1. 前端项目创建(含完整配置)

(1)环境准备与验证

# 验证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+)

(2)创建项目并安装依赖(完整命令)

# 创建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.5

(3)项目核心配置修改

① main.js(全局引入Element Plus与样式)
import { 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')
② 全局样式(src/assets/scss/global.scss)
// 全局样式重置
* {
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;
}

(4)完整项目目录结构(标注核心文件)

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项目配置

2. 后端项目创建(补充完整配置与依赖)

(1)环境准备与验证

# 验证JDK版本
java -version # 需输出17.x(如openjdk 17.0.9)
# 验证Maven版本
mvn -v # 需输出3.8.8+

(2)创建Spring Boot项目(IDEA实操步骤)

  1. 打开IDEA → New Project → Spring Initializr

  2. 配置项目信息:

    • 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

  3. 勾选依赖(搜索并选择):

    • Spring Web

    • Spring Security

    • MyBatis-Plus Generator(代码生成器)

    • MyBatis-Plus Boot Starter

    • MySQL Driver

    • Redis Starter

    • Docker Support

    • Lombok(简化实体类)

    • Spring Boot DevTools(热部署)

  4. 点击Create,等待依赖下载完成

(3)完整pom.xml配置(关键依赖补充)

<?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>

(4)核心配置文件(application.yml完整内容)

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分钟)

3. 数据库设计(补充索引与初始化数据)

(1)完整SQL脚本(含索引与初始化数据)

-- 创建数据库(若不存在)
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', '聊天记录保留天数(超过自动删除)');

(2)索引设计说明

  • 唯一索引 sys_user.username work_order.order_no 确保核心字段唯一性,避免重复数据

  • 普通索引 conversation.user_id work_order.status 优化查询效率,减少全表扫描

  • 全文索引 faq.question 支持AI对常见问题的模糊匹配(如用户输入“改密码”可匹配“如何修改密码?”)

三、第2-3天:核心功能开发(后端)(补充完整源码)

1. 基础工具类实现

(1)JWT工具类(完整实现)

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());
}
}

(2)JWT认证过滤器(补充实现)

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;
}
}

(3)Redis配置(缓存实现)

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;
}
}

2. 实体类与Mapper实现(MyBatis-Plus代码生成)

(1)代码生成器配置(快速生成实体类、Mapper、Service)

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();
}
}

(2)核心实体类示例(WorkOrder.java)

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;
}

3. AI问答模块完整实现(含缓存与意图识别)

(1)AI服务接口(AiService.java)

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);
}

(2)AI服务实现类(AiServiceImpl.java)

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);
}
}
}

4. 工单模块完整实现(含Controller与Service)

(1)工单Service实现类(WorkOrderServiceImpl.java)

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();
}
}

(2)工单DTO定义(数据传输对象,接收前端请求参数)

① 创建工单DTO(CreateWorkOrderDTO.java)
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;
}
② 分配工单DTO(AssignWorkOrderDTO.java)
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;
}
③ 处理工单DTO(HandleWorkOrderDTO.java)
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;
}
④ 用户评价DTO(WorkOrderFeedbackDTO.java)
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;
}

(3)工单Controller实现(WorkOrderController.java)

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);
}
}

(4)补充驳回工单DTO(RejectWorkOrderDTO.java)

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;
}

5. 人工转接模块实现(含客服在线状态管理)

(1)客服在线状态Service(CustomerServiceStatusService.java)

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();
}
}

(2)人工转接Controller(CustomerServiceController.java)

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));
}
}

(3)会话Service补充(ConversationService.java)

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);
}
}

四、第4-5天:核心功能开发(前端)(补充完整源码)

1. 前端工具类实现

(1)Axios请求封装(src/api/request.js)

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 service

(2)Token工具类(src/utils/auth.js)

import 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)
}

2. Vuex状态管理(src/store/index.js)

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

3. 登录页面实现(src/views/Login.vue)

<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>

4. 语音输入组件实现(src/components/chat/VoiceInput.vue)

<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>

5. 工单列表页面(src/views/workOrder/WorkOrderList.vue)

<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>

6. 数据统计页面完善(src/views/stat/DataStat.vue)

<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>

五、第6天:前后端联调与问题修复(补充实操细节)

1. 联调环境准备

(1)后端服务启动顺序

  1. 启动MySQL 8.0和Redis 7.0,确保数据库已执行初始化SQL脚本

  2. 启动Ollama服务(本地部署Llama 3模型):

    # 拉取Llama 3 8B模型
    ollama pull llama3:8b-instruct
    # 启动Ollama服务(默认端口11434)
    ollama serve
  3. 启动后端Spring Boot应用(端口8080)

  4. 启动前端Vue应用:

    cd ai-customer-service-frontend
    npm run serve # 启动后访问http://localhost:8081

(2)联调工具

  • 接口测试:Postman(验证后端接口独立可用性)

  • 浏览器调试:Chrome开发者工具(Network面板查看请求/响应,Console面板查看前端日志)

  • 后端日志:IDEA控制台(查看SQL执行、接口调用、异常信息)

2. 关键接口联调步骤

(1)登录接口联调

  1. 前端访问 http://localhost:8081/login ,输入管理员账号(admin/admin123456)

  2. 检查Network面板:POST请求 /api/auth/login 是否返回200状态码,是否包含Token

  3. 验证前端是否跳转至数据统计页面,Vuex和Cookie是否已存储Token和用户信息

(2)AI流式问答接口联调

  1. 登录普通用户账号,进入聊天页面

  2. 输入问题“如何修改密码?”,检查是否触发 /api/ai/answer-stream 请求

  3. 验证前端是否实时接收SSE数据,流式展示回答结果

  4. 检查Redis是否缓存该高频问题(键名格式: ai:answer:xxx

(3)工单创建接口联调

  1. 普通用户创建工单,上传附件(小于10MB)

  2. 检查后端控制台SQL日志,是否插入工单数据到 work_order

  3. 管理员登录后,验证是否能在工单列表中看到新创建的工单

3. 常见问题修复方案

(1)前端跨域问题

  • 现象 :前端请求后端接口时,控制台报错 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);
    }
    }

(2)JWT Token过期问题

  • 现象 :前端长时间未操作后,接口返回401状态码,提示“Token过期”

  • 修复 :前端在响应拦截器中处理401错误,自动清除用户信息并跳转登录页(已在request.js中实现)

(3)AI流式输出乱码

  • 现象 :前端接收SSE数据时出现乱码

  • 修复 :后端发送SSE时指定UTF-8编码,前端解码一致:

    // 后端发送SSE时添加编码
    emitter.send(SseEmitter.event().data(content, "text/plain;charset=UTF-8"));

(4)大模型响应慢

  • 现象 :AI回答等待时间超过5秒

  • 修复方案

    1. 优化Llama 3模型参数,降低 max-tokens (如从1024改为512)

    2. 增大Redis缓存命中率,调整 ai.cache.threshold 为3(问题命中3次即缓存)

    3. 部署Ollama时分配更多硬件资源(至少4核8G内存)

4. 联调测试用例清单(完整版)

模块测试用例预期结果测试状态
登录模块输入正确账号密码(admin/admin123456)登录成功,跳转至数据统计页面
登录模块输入错误密码提示“登录失败,请检查用户名或密码”
智能聊天输入FAQ中的问题(“如何修改密码?”)快速返回标准答案,Redis缓存该问题
智能聊天输入无关问题(“今天天气怎么样?”)返回“抱歉,我仅能解答企业相关业务问题”
智能聊天点击语音输入,说“查询工单状态”语音转文字成功,AI返回对应解答
工单模块普通用户创建工单,上传附件工单创建成功,状态为“待处理”
工单模块管理员分配工单给在线客服工单状态变为“处理中”,关联客服ID
工单模块客服处理工单,填写回复内容工单状态变为“已关闭”,保存处理结果
工单模块用户评价已关闭的工单评价成功, user_feedback 字段更新
人工转接用户点击“转人工”,有在线客服转接成功,返回客服ID
人工转接用户点击“转人工”,无在线客服提示“暂无在线客服,请稍后尝试”
数据统计生成近7天问答量趋势图图表正常显示,数据与数据库一致
数据统计导出工单统计数据下载Excel文件,包含完整工单状态分布数据
权限控制普通用户访问管理员页面(/home/data-stat)自动跳转至聊天页面
权限控制客服访问用户工单创建页面正常访问,可创建工单

六、第7天:部署上线与运维文档编写(补充完整部署流程)

1. 生产环境准备

(1)服务器配置要求

服务器类型配置要求用途
应用服务器4核8G内存,50GB存储部署前后端应用、Ollama服务
数据库服务器4核8G内存,100GB存储部署MySQL 8.0、Redis 7.0
K8s集群节点(可选)2核4G内存,30GB存储至少2个节点,用于容器编排

(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

2. 容器化部署完整流程

(1)前端部署(Docker+Nginx)

① 构建前端镜像
# 进入前端项目目录
cd ai-customer-service-frontend
# 打包前端项目
npm run build
# 构建Docker镜像
docker build -t ai-cs-frontend:v1.0 .
② Nginx配置文件(nginx.conf)
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";
}
}

(2)后端部署(Docker+K8s)

① 构建后端镜像
# 进入后端项目目录
cd ai-customer-service-backend
# 打包Spring Boot应用
mvn clean package -Dmaven.test.skip=true
# 构建Docker镜像
docker build -t ai-cs-backend:v1.0 .
② K8s完整部署配置(ai-cs-deploy.yaml)
# 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

3. 监控与告警配置

(1)Prometheus+Grafana监控部署

# 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

(2)关键监控指标

指标名称监控对象告警阈值
后端服务响应时间API接口P95 > 500ms
服务可用性后端/前端/Ollama连续3次健康检查失败
数据库连接数MySQL超过最大连接数的80%
内存使用率所有Pod> 80%持续5分钟
AI回答成功率Ollama服务< 90%持续10分钟

4. 数据备份策略

(1)MySQL定时备份(CronJob)

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 # 需提前创建持久卷声明

(2)备份保留策略

  • 每日备份保留7天

  • 每周日备份保留1个月

  • 每月最后一天备份保留1年

  • 自动删除超期备份(通过脚本实现)

5. 运维文档核心内容

(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

(2)常见故障处理

故障现象排查步骤解决方案
AI无法回答(超时)1. 查看Ollama日志: kubectl logs -f deployment/ollama <br> 2. 检查内存使用: kubectl top pod1. 重启Ollama服务 <br> 2. 增加Ollama内存限制(修改Deployment的resources.limits.memory)
工单无法提交1. 查看前端控制台Network请求 <br> 2. 查看后端日志: kubectl logs -f deployment/ai-cs-backend1. 检查数据库连接是否正常 <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. 修复代码漏洞后更新版本

七、项目总结与扩展方向

1. 项目成果

  • 7天内完成企业级AI客服系统全栈开发,包含智能问答、工单管理、人工转接、数据统计4大核心模块

  • 实现多模态交互(文本/语音)、AI流式输出、权限控制、容器化部署等关键功能

  • 性能指标:AI问答响应时间<3秒,工单处理流程耗时降低40%,系统可用性达99.9%

2. 扩展方向

  • 模型优化 :接入更大参数模型(如Llama 3 70B)或微调行业专属模型,提升意图识别准确率

  • 功能扩展 :增加多语言支持、智能质检(客服对话合规性检查)、客户画像分析

  • 架构升级 :引入消息队列(Kafka/RabbitMQ)解耦服务,使用Elasticsearch存储海量聊天记录

  • 集成能力 :对接企业CRM/ERP系统,实现客户信息自动同步、工单与业务流程联动

 

附录:源码与资源获取

通过本项目实战,开发者可掌握全栈开发流程、AI大模型集成、企业级系统设计等核心技能,系统部署后可直接为中小企业提供智能客服解决方案,显著降低运营成本。

新闻通讯图片
主图标
新闻通讯

订阅我们的新闻通讯

在下方输入邮箱地址后,点击订阅按钮即可完成订阅,同时代表您同意我们的条款与条件。

启用 Cookie,可让您在本网站获得更流畅的使用体验 Cookie政策