☰
JavaWeb实战:售后工单管理系统从0到1完整实现
2026/10/22 5:38:01 网站建设 项目流程

“免费Web系列”这个坑,我算是填到第六篇了。前面五篇从Servlet规范讲到SpringBoot入门,再到MyBatis和Thymeleaf的整合,不少读者反馈说“跟着做能跑,但不知道怎么做成一个完整的东西”。案例六我就直接选了一个企业里几乎天天要用的东西——售后服务工单管理系统。这个东西不花哨,但覆盖面非常广,覆盖了业务单据流转、多角色权限、客户档案、附件上传、统计报表,还有多个实战中绕不开的并发和事务问题。

这篇博文会直接给出完整的技术方案,从项目定位、数据库设计、后端接口实现、前端页面渲染,到打包部署和上线避坑。我尽量不写空话,每一节都能直接落到代码和表结构上。适合的读者是《JavaWeb系列》一直跟下来的实战型学习者,以及那些已经会用SpringBoot写CRUD、但想知道一个完整业务系统怎么组织的同学。跟着敲完,你会明显感觉到对JavaWeb的理解比之前清晰很多——因为知识点终于不是零散的了,而是被业务串在了一起。

1. 项目定位与总体设计思路

1.1 为什么第六个案例选“售后服务工单管理”

我在规划这个免费系列时有个原则:每个案例必须覆盖一套真实业务中反复出现的模型,不能在增删改查里打转。前五篇做了登录注册、简单的博客系统、后台管理框架等内容,但那些项目对“业务”的模拟还是浅了。工单系统不一样,它在企业里几乎是IT运维、售后客服、物业报修等领域通用的骨架。

售后工单管理系统的典型业务流程不难想象:客户打电话或在线提交一个问题,客服登记工单,指派给对应工程师,工程师接单后处理并填写处理结果,客户确认后关闭工单,整个过程中还要记录谁在什么时间做了什么操作。这个流程看起来简单,但它天然包含了状态流转、单据追踪、角色协同和定时统计,每一个点都能跟JavaWeb的核心知识点对上。

举个例子,工单的状态决不是简单的“待处理、处理中、已完成”字符串字段,它涉及状态机校验,防止操作乱跳。比如一个“已关闭”的工单不能直接被改回“待处理”,只能走“重新开启”这个动作。再比如,多个客服同时抢单分配工程师的场景,如果用最简单的update语句直接改,一定会出现并发覆盖,这就逼着你去考虑乐观锁和分布式锁的取舍。这些知识点如果不放在业务场景里讲,读者很难有感觉。

所以案例六的定位很清晰:用一个真实、常见、流程完整的工单管理系统,把JavaWeb中“从前端到后端、从持久层到业务层到表现层、从单机到部署”的完整链路串起来。它不算标新立异,但做完了,你独立去接一个小外包项目或者在企业内部做一个支撑系统,就不会再发怵。

1.2 技术栈选型与替代方案

这次选型我特意压低了门槛,没有上太前沿的东西。目的很简单:免费系列的核心读者是正在构建JavaWeb体系的新手,技术太新反而会劝退。最终采用的技术栈如下。

层技术选型说明
开发语言Java 8稳定,JDK 8广泛使用,Stream和Lambda足够日常开发
核心框架SpringBoot 2.7.x自动配置成熟,兼容细腻丰富
持久层MyBatis-Plus 3.5.x内置CRUD、分页插件,极大减少样板代码
数据库MySQL 8.0使用InnoDB引擎,utf8mb4字符集
缓存Redis 5.x用于登录token与并发锁
模板引擎Thymeleaf 3.0服务端渲染,贴合传统JavaWeb开发模式
工具库Lombok + Hutool减少getter/setter,提供Bean、Date、Id等工具方法

这里我想多说一句为什么用Thymeleaf而不是前后端分离。很多人觉得现在开发必然是Vue + SpringBoot分离架构,但我在这个系列里故意保留服务端渲染,是因为对于一个还在体系化学习JavaWeb的读者来说,服务端渲染能让你直观地看到一次请求从Controller到Service到Mapper再到HTML模板的完整路径。如果你一上来就前后端分离、跨域、鉴权配一整套,人很容易被工程化细节淹没,业务逻辑反而没学到。

至于替代方案,如果读者确实想改造为前后端分离,我的建议是:后端只保留JSON接口,把登录鉴权从Redis Session改为JWT或Redis Token,前端改用Vue3 + Element Plus。改造的重点是权限控制部分,其它业务接口改动并不大。思路在后面的章节会展开。

1.3 核心业务模块与权限模型

工单本身是主角,但它不是一座孤岛。实际企业中,工单一定会关联客户档案,会关联某个产品型号,会有负责人,会有处理记录,还要有统计报表。模块拆分如下。

  • 系统登录与用户管理:管理员维护操作员账号,分配角色。
  • 客户管理:记录客户名称、联系人、联系电话、地址的基本档案。
  • 产品管理:绑定产品型号、保修期、出厂日期,与客户建立归属关系。
  • 工单管理:创建、指派、处理、关闭、重新开启,整个生命周期在此流转。
  • 工单记录:每一步操作自动写入日志,不可追溯。
  • 附件管理:工单上传图片、文档,记录本地路径。
  • 统计分析:按日统计新增工单量、按人统计处理量、平均处理时长。
  • 角色权限:采用RBAC模型,用户-角色-权限三级结构。

权限模型这里先做一个简版设计:sys_user表保存账号密码;sys_role表保存角色(管理员、客服、工程师、普通访客);sys_user_role关联用户和角色;sys_menu表保存菜单和操作权限标识;sys_role_menu保存角色与菜单操作的关系。后端在拦截器里根据登录用户查询其权限标识集合,再判断当前请求是否需要权限。

单选“管理员”“客服”“工程师”三个角色来做权限对比,大家能更容易理解。管理员可以看到全局工单,可以管理所有基础资料;客服可以创建工单、分派工程师、查看自己创建的工单;工程师只能查看指派给自己的工单、填写处理结果。这种角色差异如果设计正确,后续所有接口的逻辑复杂度都会下降很多。

2. 环境准备与数据库设计

2.1 开发环境清单与版本匹配

工单系统虽然不大,但环境版本不匹配能让人卡一个下午。这里给出我实际使用的一组兼容组合。

  • JDK 1.8(想用更高版本也行,但1.8兼容性最好)
  • Maven 3.6.3及以上
  • MySQL 8.0,字符集utf8mb4
  • Redis 5.x以上,本地默认端口6379
  • IntelliJ IDEA或Eclipse均可,IDEA更顺手
  • SpringBoot 2.7.14,对应MyBatis-Plus版本建议3.5.3.x

有一个容易踩的版本坑:MyBatis-Plus 3.5.x如果和Mybatis Spring版本冲突,启动时会报Failed to configure a DataSource之类的错误。解决办法是在pom.xml里显式引入mybatis-plus-boot-starter,并排除自带的mybatis-spring,或者统一SpringBoot父子版本到2.7.x。如果你用的是SpringBoot 3.x,那么MyBatis-Plus要升级到3.5.3以上,还要注意javax改jakarta的问题,我这里不建议新手直接上SpringBoot 3。

2.2 核心表结构设计

我没有把表结构画成复杂的ER图,直接用文字和字段说明更直观。核心表大概有十张左右,先看工单主表。

业务表中我认为最重要的就是ticket表。字段不仅要满足业务,还要预留统计查询的索引需求。建表SQL大致如下。

CREATE TABLE `ticket` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `ticket_no` varchar(32) NOT NULL COMMENT '工单编号', `title` varchar(128) NOT NULL COMMENT '工单标题', `content` text COMMENT '问题详细描述', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1待处理 2处理中 3已解决 4已关闭 5重新开启', `priority` tinyint(4) NOT NULL DEFAULT '2' COMMENT '优先级:1低 2中 3高', `customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID', `product_id` bigint(20) DEFAULT NULL COMMENT '产品ID', `creator_id` bigint(20) NOT NULL COMMENT '创建人ID', `assignee_id` bigint(20) DEFAULT NULL COMMENT '当前处理人ID', `handle_content` text COMMENT '处理内容', `handle_time` datetime DEFAULT NULL COMMENT '处理时间', `close_time` datetime DEFAULT NULL COMMENT '关闭时间', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_create` (`status`, `create_time`), KEY `idx_assignee` (`assignee_id`), KEY `idx_ticket_no` (`ticket_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单表';

这里的几个细节我单独说明。status字段我用tinyint而不是varchar,不仅是为了节省空间,更关键的是方便校验和扩展。后端定义枚举类,把数字和状态一一对应,避免出现“已关闭”写成“关闭”、“处理中”写成“正在处理”这种数据脏值。priority同理,也是数字枚举。

工单编号ticket_no我建议单独生成,不要依赖自增id暴露给用户。生成规则可以是日期加序列,比如20240115-0001,通过Redis自增或者数据库SELECT MAX+1实现。虽然简单,但能让工单看起来更规范,也更接近企业系统的真实习惯。

客户表、产品表相对简单,字段包含客户名称、联系人、电话、地址,产品表则关联了产品型号、保修截止日期等。工单记录表ticket_log则是审计信息的关键,任何状态变化都要写一条日志,字段包括工单ID、操作人ID、操作类型、操作前状态、操作后状态、操作备注、操作时间。

2.3 索引设计中的实效思考

小项目最容易忽略索引,等数据量上来再补就要挨一顿慢查询的毒打。工单列表页最常见的查询条件是:按状态筛选、按处理人筛选、按时间范围筛选、按工单号精确查。所以我建了idx_status_create、idx_assignee、idx_ticket_no这几个索引。大家可能会问,为什么是status和create_time联合索引而不是status单独索引,因为列表页默认就是按创建时间倒序,联合索引能让排序走索引,避免filesort。

ticket_log表的查询模式基本是“给定一个工单ID,查它的所有操作记录”,所以ticket_id字段必须加普通索引。customer表和product表主要按id关联,本身主键就够了,但如果在列表页有按名称模糊搜索的需求,可以再加一个name的普通索引,不过模糊查询如果用了以%开头的匹配,索引会失效,这一点后面排错章节还会提到。

3. 后端核心功能实现

3.1 工程结构初始化与统一返回格式

后端工程结构我依然沿用系列一直使用的分包方式,清晰且适合学习复制。

com.example.ticket ├── config ├── controller ├── service │ ├── impl ├── mapper ├── entity ├── dto ├── vo ├── common │ ├── result │ ├── exception │ └── constant └── interceptor

entity对应数据库表,dto是入参对象,vo是出参对象。这个区分很多初学者不在意,接口一写多就乱了。我强烈建议即便在简单项目里也保持dto和vo的独立,因为它能让接口文档更稳定,不会因为数据库字段变更导致前端数据格式漂移。

统一返回结果是一个API设计习惯,我用了一个轻量的R类,代码如下。

@Data public class R<T> { private Integer code; private String message; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> R<T> fail(String message) { R<T> r = new R<>(); r.setCode(500); r.setMessage(message); return r; } public static <T> R<T> fail(Integer code, String message) { R<T> r = new R<>(); r.setCode(code); r.setMessage(message); return r; } }

同时配一个全局异常处理器,用@RestControllerAdvice把业务异常、参数校验异常、服务器内部异常统一转成R结构返回给前端。这样前端只需要判断code是否等于200即可,不用分散地处理各种错误堆栈。

3.2 登录鉴权与权限控制方案

登录鉴权是这个案例里我特别想讲清楚的部分。很多人第一步就纠结到底用Spring Security还是自定义拦截器。我就直说了:如果你觉得Spring Security配置繁琐、很多概念不理解,那在这个工单系统里,使用“Redis存储登录状态 + 自定义拦截器 + 自定义权限注解”的方案完全够用,而且能让你真正理解鉴权的本质。

实现思路是这样的。用户提交用户名密码,后端用BCryptPasswordEncoder校验密码。校验通过后,生成一个UUID字符串作为token,以“login:token:xxxx”为key、用户ID为value写入Redis,设置过期时间30分钟。返回给前端的同时,将用户ID和用户姓名放入ThreadLocal以便后续获取当前登录人。

前端拿到token后,每次请求在Header带上Authorization: token值,后端注册一个拦截器,读取Header中的token,查Redis,查不到就返回401,查到了就把用户信息放入上下文。为了保证用户体验,在用户活跃时自动续期,每次有效访问都把过期时间重新设置为30分钟,这个逻辑只有几行代码,但能让系统“记住你”的感觉很顺。

细粒度权限控制我用自定义注解@RequiresPermission,注解上标权限标识,比如@RequiresPermission("ticket:assign")。拦截器在完成登录校验后,检查HandlerMethod注解上的权限标识,再从当前用户权限集合中判断是否有对应权限。权限集合在登录时一次从数据库查出放入Redis缓存,避免每次请求都查表,这个设计在业务量上来后很有优势。

3.3 工单状态机的设计与落地

很多初学者会直接把状态存成String字段,想在多复杂就多复杂,没有控制。一旦后端逻辑变多,就会出现各种非法状态迁移。工单系统的正确做法是在代码里定义一个状态机,严格约束每个状态允许跳转到哪些状态。

工单状态枚举如下。

public enum TicketStatus { PENDING(1, "待处理"), PROCESSING(2, "处理中"), RESOLVED(3, "已解决"), CLOSED(4, "已关闭"), REOPENED(5, "重新开启"); private final int code; private final String desc; // 构造方法、getter略 }

状态迁移规则我习惯用一个静态Map来表示,如下。

private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(1, Set.of(2, 4)); // 待处理 -> 处理中 / 已关闭 TRANSITIONS.put(2, Set.of(3)); // 处理中 -> 已解决 TRANSITIONS.put(3, Set.of(4, 5)); // 已解决 -> 已关闭 / 重新开启 TRANSITIONS.put(4, Set.of(5)); // 已关闭 -> 重新开启 TRANSITIONS.put(5, Set.of(2, 4)); // 重新开启 -> 处理中 / 已关闭 }

在工单状态变更的Service方法里,事务第一步根据工单ID查出当前状态,然后判断目标状态是否在允许集合中,不在就抛业务异常。比如客服直接将待处理工单改成已解决,校验会直接拦下,前端也会收到明确的错误提示。

既然有状态变更,就必然要有日志。事务里更新工单状态后,紧接着插入一条ticket_log记录,二者必须在一个@Transactional事务内。如果日志插入失败,工单状态更新要回滚,否则就会出现“工单显示已解决但日志里没有任何操作记录”的脏数据。

3.4 多客服抢单场景下的并发控制

工单的指派动作值得单独写一个解决方案。场景是这样的:两个客服同时看到一个“待处理”工单,都点击“指派给我”,如果后端代码只是这样写:

ticket.setStatus(2); ticket.setAssigneeId(userId); ticketService.updateById(ticket);

那么两个人都会成功,后一个提交者会覆盖前一个的指派结果,也就是所谓的“最后写入者胜”。这在真实系统里无法接受,客户会接到两个工程师的电话。

解决办法首推乐观锁。表结构里的version字段专门为此设计,更新语句必须带版本条件:

boolean update = ticketMapper.update( new LambdaUpdateWrapper<Ticket>() .eq(Ticket::getId, ticketId) .eq(Ticket::getVersion, currentVersion) .eq(Ticket::getStatus, TicketStatus.PENDING.getCode()) .set(Ticket::getStatus, TicketStatus.PROCESSING.getCode()) .set(Ticket::getAssigneeId, currentUserId) .set(Ticket::getVersion, currentVersion + 1) );

如果update返回的受影响行数为0,说明工单在刚才那一刻已经被别人抢走了,直接抛出“该工单已被其他客服处理”的提示。这里还要在查询时用selectById查出当前version,再在更新时带上version条件。

如果业务并发量更高,比如几十个客服同时抢单,可以再考虑Redis分布式锁。实现也不复杂,用setIfAbsent命令给“ticket:lock:工单ID”设锁,加过期时间防止死锁,抢到锁才执行更新,执行完释放锁。不过在我们这种售后工单系统里,乐观锁已经足够可靠,分布式锁更多是增加复杂度,这一点我在项目里通常会用乐观锁,并会在代码注释里说明“高并发场景可升级为Redis锁”,让读者知道这里的演进方向。

3.5 附件上传与统一文件管理

工单系统里,客户反馈问题往往要上传截图,工程师处理完也要附照片。附件模块看起来是辅助功能,但做不好会给运维带来不必要的麻烦。

我采用的方案是本地磁盘存储:上传文件保存到/data/www/upload目录,按日期分子目录。文件名统一改成UUID,避免中文文件名和重名覆盖问题,原始文件名则存到ticket_attachment表里。表结构包含id、ticket_id、origin_name、file_path、file_size、uploader_id、create_time等字段。

上传接口要做三道校验:第一道是后缀白名单,只允许jpg、png、gif、pdf、zip、doc、docx等;第二道是大小限制,单文件不能超过10MB,超过直接返回错误;第三道可选,用文件头魔数校验真实格式,防止有人改后缀绕过。这块如果要求不高,可以只做前两道。文件保存路径不要暴露给前端,前端下载走后端接口,后端根据file_path读取文件流,设置Content-Disposition为attachment,并正确编码原始文件名,避免中文打开乱码。

3.6 统计报表的SQL编写

统计报表是工单系统里最能体现SQL功力的部分。需求无非这几种:按天查看新增工单量、按查看每个员工的处理量、查看平均处理时长。

按天统计的SQL并不难:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS total FROM ticket WHERE create_time >= #{start} AND create_time < #{end} GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day;

但如果数据量大,DATE_FORMAT函数会让索引失效,导致全表扫描。稳妥的做法是在查询条件里就指定好时间范围,让MySQL能够使用create_time索引缩小扫描范围内,再用日期函数分组计算。针对员工处理量统计,用assignee_id关联用户表,按处理人分组统计status=3的工单数量即可。

平均处理时长的计算关键是“处理时长”的定义。我用的口径是:处理时间减创建时间,取秒数平均值,再转换为小时分钟展示。如果工单存在跨天处理、周末停机等情况,这个指标会更复杂,但对于入门项目,明确口径并实现SQL已经足够了。统计报表的接口都加@Cacheable或者手动缓存到Redis,按天过期,避免每次打开首页都压数据库。

4. 前端页面实现与交互细节

4.1 页面布局与Thymeleaf公共片段

后端接口准备就绪后,前端用Thymeleaf做服务端渲染。整个管理页面我分成三个区域:顶部显示系统名称和当前登录人,左侧是菜单树,右侧是内容区。Thymeleaf的fragment机制非常适合页面复用,我把顶部和侧边栏抽成layout片段,各功能页面只需要引入。

这里要用一个安全的小原则:菜单不是写死的,而是根据登录人的权限从数据库读取并渲染,可以用Thymeleaf的th:each循环输出,再配合后端传入的菜单列表。注意菜单的层级不要过深,两层就够了,否则递归渲染写起来也复杂。

4.2 列表页筛选与分页实现

工单列表页是使用频率最高的页面,它需要一个组合筛选区域:关键字搜索、状态下拉框、优先级下拉框、时间范围选择器。表单提交用GET请求,把筛选参数带上,Controller接收后封装成查询对象传给Service。

分页用MyBatis-Plus自带的分页插件,只需要配置一个PaginationInnerInterceptor,Service层调用page方法即可。为了前端友好,我封装了一个PageResult对象,包含total、pages、current、records四个字段,Thymeleaf页面拿到后直接遍历渲染。

分页链接的生成有一个细节要提醒:筛选条件必须在翻页时保留。如果不保留,点击第二页就丢失了之前的状态筛选,用户会觉得很恼火。实现方式是在生成分页页码链接时,把当前查询条件作为隐藏参数拼上,我习惯在Controller里把查询条件对象回传到页面,然后用th:each拼接URL。

4.3 表单交互与数据回显

工单创建页面涉及的字段比较多:标题、优先级、客户、产品、问题描述。客户和产品都是下拉框,数据由Controller在打开页面时从数据库查出并放入Model。编辑时还要回显当前值。Thymeleaf的th:field配合th:object能自动回显表单对象,这里特别需要注意日期格式的转换,HTML的datetime-local输入框和后台的LocalDateTime默认格式不一致,需要统一格式化成yyyy-MM-dd'T'HH:mm,否则浏览器显示空白。

工程师处理工单时,点击“处理”按钮弹出模态框填写处理内容和处理结果。这里我用了简单的原生JavaScript发AJAX请求,成功后刷新当前页面,避免整页跳转带来的体验下降。如果要用更现代的方式,引入axios + Vue也未尝不可,但为了系列保持统一,这些细节我保持了最少依赖原则。

操作按钮的显示条件按权限控制。比如普通工程师看不到“指派”按钮,管理员才能看到“删除”按钮。这既可以通过后端返回的权限标识判断,也可以直接在模板里根据权限集合动态控制。Thymeleaf里可以用sec:authorize,但在没有引入Spring Security的情况下,我自定义了一个简单的权限判断工具类,在模板里调用${auth.hasPermission('ticket:assign')}这样的方法,效果完全一样。

4.4 状态与优先级的中文映射与视觉标识

数据库里存的是数字状态,页面不能直接显示1、2、3。我在模板渲染时有多种方案:后端返回VO时把状态枚举的desc转好,前端直接用;或者在模板里写一个状态映射Map。为了职责分离,我推荐后端转好,前端只负责展示,这样也能避免前端对业务逻辑过于依赖。

视觉标识方面,我给不同的状态和优先级设置不同的CSS类。例如“待处理”用灰色标签、“处理中”用蓝色、“已解决”用绿色、“已关闭”是深灰色;“高优先级”是红色、“中优先级”橙色、“低优先级”灰色。这些样式在后台管理系统中是很基础的配置,写起来简单,但能显著提升操作效率。顺手贴一个模板示例片段:

<span th:class="'badge priority-' + ${ticket.priority}"> <span th:text="${ticket.priorityDesc}">优先级</span> </span>

5. 项目联调、部署与上线注意点

5.1 本地环境联调顺序与初始化数据

本地联调时,很多人一上来就启动项目,结果报各种连不上数据库或者Redis超时的错。我的习惯是先检查两个服务是否正常,再启动应用。MySQL和Redis分别用客户端连接测试一下,确认账号密码无误。

然后执行初始化SQL脚本。脚本分两类:一类是建表DDL,一类是初始数据DML。初始数据必须包含管理员账号和基础菜单,否则项目起来后连登录都进不去。

工程启动后,用IDEA直接运行Application主类,或者命令行mvn spring-boot:run。访问http://localhost:8080/login,看到登录页就说明基础链路通了。

5.2 打包与生产环境启动参数

本地跑通之后,打包部署是JavaWeb学习者必须掌握的技能。打包命令:

mvn clean package -DskipTests

生成目标目录下的jar包。在上线服务器时,我不建议直接用命令行默认参数,最好指定profile和环境变量。生产环境的application-prod.yml里配置数据库地址、Redis地址,密码通过环境变量引用,比如jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT},避免数据库密码硬编码。

启动命令优先采用nohup方式:

nohup java -jar ticket-system.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > logs/app.log 2>&1 &

生产环境不要使用root用户直接运行jar,建议创建专用运维账号,否则一旦应用被漏洞利用,后果容易失控。日志文件也要配置滚动策略,用Logback按天和按大小切分,防止单个日志文件几个月后撑爆磁盘。

5.3 上传目录、Nginx与静态资源映射

附件存储在/data/www/upload,但应用并没有直接对外暴露这个目录。线上部署我通常在前端加一层Nginx,将/upload/路径映射到文件存储目录,同时把/api/路径反向代理到后端Java进程。这样后端只处理动态请求,静态文件由Nginx高效处理,性能提升明显。

一个经典的真实教训:上传目录磁盘分区满了。业务部门反馈“图片传不上去”,排查半天发现是磁盘inode用完。这个问题很简单,但只有生产经验丰富的人才会第一反应去看磁盘空间。所以部署时我会定时巡检磁盘占用,并建议运维设置磁盘阈值告警。

5.4 数据库连接池与Redis配置

SpringBoot默认连接池是HikariCP,性能很好,基本不用手动调。但我还是建议在配置文件里显式设置几个核心参数:maximum-pool-size建议10到20之间,minimum-idle设置为5,connection-timeout设置为30000毫秒。太小了并发一高就排队,太大了浪费数据库连接资源。

Redis配置重点是超时时间和线程池。Spring Data Redis默认的Lettuce客户端在高并发场景下可能出现连接池不够的情况,但工单系统这种中低并发规模基本不涉及。如果后续要做高并发抢单升级,再考虑Jedis或Lettuce连接池参数调优即可。

6. 调试排错实录与优化建议

6.1 典型错误排查速查表

我在开发工单系统的过程中遇到过的错误,以及读者反馈过的高频问题,整理成了一张表,方便大家直接对照。

错误现象根本原因解决方案
启动报Failed to configure a DataSource数据库连接信息缺失或错误检查application.yml的url、username、password是否正确
启动报Unable to connect to RedisRedis未启动或密码不对启动Redis服务,确认auth密码
前端页面大量中文乱码数据库或页面编码不一致统一数据库连接参数characterEncoding=utf8,页面设置UTF-8
上传文件下载时文件名乱码Content-Disposition未做RFC 5987编码用URLEncoder.encode处理文件名
同一工单被两人同时抢到未加并发控制更新加乐观锁version条件
统计SQL运行很慢时间范围未走索引优化查询条件,避免函数包裹索引字段
页面缓存不更新浏览器缓存或静态资源版本未更新引入资源版本号或强制刷新
登录后每请求都401token过期或Redis数据丢失检查Redis内存淘汰策略,避免误删业务key

第六行“每请求401”值得多说一句:Redis如果配置了allkeys-lru淘汰策略,在高内存压力下可能把业务key清掉。为了避免这种风险,登录token的key最好统一加前缀,部署时调整Redis的maxmemory-policy为volatile-lru或设置独立实例。

6.2 性能优化与慢SQL排查

中小型工单系统最大的性能瓶颈几乎都在数据库。优化思路按优先级排序:第一优化慢SQL,第二加索引,第三引入缓存。

开启MySQL慢查询日志是第一步。可以在服务器配置中开启并设置阈值:

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 2;

然后在分析日志里找出执行时间大于2秒的SQL。工单列表页面最容易发生的是左模糊查询,比如WHERE title LIKE '%换屏%',这种查询无法使用B树索引,只能全表扫描。如果这类搜索是刚需,可以配合全文索引或者搜索引擎,但对于本案例,我建议用前缀匹配或者在字段上建立全文索引,前提是数据量确实大到需要这么做。

用户详情页多次查询关联数据的问题,可以用MyBatis-Plus的查询一次查出主数据,再批量查询关联数据,避免N+1查询。比如工单列表需要显示客户名称,不要在循环里逐条查客户表,而是先把所有工单的customer_id取出来。

List<Ticket> ticketList = ticketService.listByCondition(condition); Set<Long> customerIds = ticketList.stream() .map(Ticket::getCustomerId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map<Long, Customer> customerMap = customerService.listByIds(customerIds).stream() .collect(Collectors.toMap(Customer::getId, Function.identity()));

这样只要两次SQL,“根据一堆ID查客户”对MySQL来说非常快,InnoDB的聚簇索引在等值查询上效率非常高。

6.3 后续功能扩展与我的实际体会

案例六到这里,主体功能已经完整,但还可以继续加料。我在实际项目中就接过了几个扩展方向,这里一并分享。

第一个方向是消息通知。工程师被指派工单后,系统需要发通知提醒。最简单的方式是工单更新后往一个通知表里插入记录,用户在页面上看到一个小红点;更复杂一点用WebSocket实时推送。案例六里我只做了站内通知的表结构和读取接口,感兴趣的读者可以自行扩展为WebSocket版。

第二个方向是定时任务。工单超时未处理是很常见的企业痛点。可以用SpringBoot的@Scheduled注解写一个定时任务,每天早上九点扫描所有状态为“待处理”且创建时间超过24小时的工单,自动给客服或管理员发送提醒。这种方式不依赖任何第三方插件,纯Java就能实现,是练习定时任务的绝佳场景。

第三个方向是Excel导出。管理员经常需要把工单报表导出成Excel,可以用EasyPOI或阿里开源的EasyExcel。这个扩展方向对项目来说非常实用,且难度不高。

最后说说我个人的体会吧。这个系列的第六篇我特意把项目定在“售后工单管理”,是因为我发现很多初学者在看完博客系统和管理后台之后,依然不知道怎么面对真实世界的业务逻辑。工单系统恰到好处,它有清晰的流程,有复杂的单据状态,也有权限、并发、存储这些必须面对的问题。做完这个项目,再去面试“项目经验”一问,至少能讲出状态机设计、乐观锁防并发、Redis缓存登录态这几个真材实料的点,比只说“我做过增删改查”强很多。照着敲一遍,你会值回这段时间的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询