news 2026/10/21 5:24:54

SSM+Vue社团管理系统实战:验证码防刷与部署全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM+Vue社团管理系统实战:验证码防刷与部署全流程解析

做这类大学生社团管理系统的人真的太多了,每年课程设计、毕业设计里都能见到。很多人交上来的东西功能列表看着挺全,但一问细节就露馅,尤其是登录页那个验证码——大多数人是直接从别的项目复制过来的,问它怎么生成、怎么校验、为什么刷新之后还是显示旧图,答不上来。我最近刚好把一个“SSM + Vue 带验证码”的社团管理项目从零到上线完整跑了一遍,包括数据库设计、后端接口、前端对接、验证码防刷、部署到服务器踩坑,今天就把整条线捋出来讲清楚。这篇东西适合三类人看:正在做类似课设或毕设的学生、刚接触前后端分离开发的新手、以及想系统复习一遍 SSM 技术栈的开发者。

1. 项目概述:一个常见的课设题目,到底有多少坑

1.1 核心需求解析

先把这个系统到底要做什么拆清楚。标题上写着“大学生社团管理系统”,那么围绕这个业务域,最核心的功能无非是几块:用户注册与登录、社团信息展示与检索、社团成员管理、活动发布与报名审核、公告通知,以及一个超级管理员后台来做整体数据维护。

实际开发中如果不先做需求梳理就直接建表写代码,后面一定会乱。我拿到这个项目后的第一件事,是列了一个功能清单,按照角色视角去划分:普通学生能做什么,社团管理员能做什么,超级管理员能做什么。

  • 学生端:注册登录、浏览社团、申请加入社团、查看活动列表、报名活动、查看自己的报名状态、维护个人资料。
  • 社团管理员:管理自己社团的基本信息、审核入社申请、发布活动、审核活动报名、发布公告。
  • 超级管理员:管理全部社团数据、管理用户账号、查看系统统计数据、拥有所有社团的管理权限。

这套需求模型很典型,几乎涵盖了大部分中小型管理系统的通用模块。重点不在于功能多少,而在于每个功能的边界怎么划、权限怎么控、数据流转怎么设计。

1.2 技术选型背后的考虑

为什么这类项目仍然用 SSM 而不是直接上 Spring Boot?原因很现实:很多高校的课程大纲还在用 SSM 作为教学主线,Spring 的 IOC 和 AOP、SpringMVC 的请求映射、MyBatis 的 SQL 映射,这三样东西拆开学,是理解 Java Web 后端原理的最好路径。Spring Boot 固然上手快、配置少,但它把太多细节封装掉了,初学者根本不知道请求是怎么进来的、事务是怎么生效的。

而前端选 Vue 也很好理解。传统的 JSP + JSTL 那种服务端渲染方式,页面交互僵硬,维护起来也痛苦。Vue 负责做单页应用,后端只提供 JSON 接口,前后端职责分离,联调方便,这也是目前大多数公司在用的协作模式。所以这套项目虽然表面上是“老技术 + 新技术”的混搭,但它非常贴近真实开发场景。

验证码这个需求之所以被我单独拎出来讲,是因为它在整个系统里非常典型——它不算核心业务,但实现细节极多。你需要在后端画图、生成随机字符串、存 Session、设置过期时间、画干扰线;前端还要处理缓存刷新、点击更换、校验失败后的联动重置。很多人在这个环节上直接抄现成代码,导致做成的东西漏洞百出,这恰恰是拉开完成度的地方。

1.3 这套系统练的是什么

说完技术选型,再说说这套项目真正的训练价值。它不像那种“图书管理系统”一样只做几张 CRUD 页面,也不像高并发秒杀项目那样上来就是分布式、消息队列。它的难度恰好在中间:你要处理好三种角色的权限关系,要设计合理的数据表结构,要把验证码这种独立模块做得严谨,还要让 Vue 前端和 SSM 后端在跨域、会话、Token 这些环节上顺利协作。

做完这个项目,你学的不是某个框架的某个 API,而是一整套从需求到部署的工程思维。后面哪怕转到 Spring Boot,转到微服务,这套思维方式都能直接平移。

2. 业务模型与数据库设计

2.1 角色与权限体系

在动任何代码之前,先把权限模型定清楚,因为权限直接决定接口能不能被随便调用、数据能不能被越权访问。

我采用的是最简单的 RBAC 模型延伸版——不引入复杂的菜单权限表和角色权限关联表,而是给用户表加一个 role 字段,取值分别是 1(超级管理员)、2(社团管理员)、3(普通学生)。社团管理员再通过社团成员表关联到具体的某个社团,也就是说“这个人是管理员”和“他管的是哪个社团”是两回事。必须靠 member 表里的 club_id 和 role 共同判断。

后端拦截器在进入 Controller 之前,先通过请求头里携带的 Token 解析出用户 ID,然后查用户表拿到角色。如果是需要社团管理员权限的接口,再额外校验该用户是否属于目标社团。这种方案对当前系统的复杂度来说完全够用,性能开销也可以忽略不计。

2.2 核心数据表结构

数据库我用的 MySQL,字符集统一 utf8mb4,排序规则 utf8mb4_general_ci。表结构经过两次调整才最终定型,一开始设计得太多冗余字段,后面又删掉了不少,下面是最终版的核心表结构。

表名核心字段说明
userid, username, password, real_name, student_no, role, avatar, create_time用户表,username 唯一索引
clubid, club_name, category, description, president_id, status, create_time社团表,president_id 指向 user 表
memberid, user_id, club_id, member_role, join_time, status成员关系表,user_id 和 club_id 加唯一索引
activityid, club_id, title, content, location, start_time, end_time, max_people, status活动表,status 表示未开始/报名中/已结束
enrollid, activity_id, user_id, apply_time, audit_status报名表,activity_id 和 user_id 加唯一索引
noticeid, club_id, title, content, publish_time公告表

这六张表基本覆盖了全部业务。有人会问社团和用户的关联为什么不用一张表直接记录,原因是 member 表还要承担“申请入社”这个临时状态的存储。学生申请加入社团时,先插入一条 status 为待审核的记录,等管理员审核通过后把 status 改掉。这样申请、加入、踢出、退社这些操作都只需要操作同一张表,逻辑非常集中。

活动报名表同理。一个人对同一个活动只能报名一次,这个约束在数据库层面就用唯一索引堵死,而不是只靠 Java 代码判断,因为并发情况下代码判断容易出漏洞。

2.3 接口设计与统一返回结构

前端要对接的接口我全部走 REST 风格设计。列表类的用 GET,新增用 POST,修改用 PUT 或 POST,删除用 DELETE。实际开发中受前端框架限制,虚拟机上不支持 DELETE 方法,所以删除接口最后也改成了 POST + 路径传参,这种妥协在真实项目里很常见。

所有接口统一返回一个 Result 对象,结构如下:

public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

分页接口统一接收 pageNum 和 pageSize 两个参数,返回一个 PageResult 对象,里面包含总记录数、总页数、当前页数据列表。这样前端的分页组件可以直接绑定,不用每次都对数据做二次加工。

3. 验证码功能的完整实现

3.1 后端动态生成验证码图片

验证码是登录安全的第一道门槛,核心作用是防止自动化脚本暴力破解密码。实现方案上我用的不是引入第三方验证码组件,而是纯 Java 代码基于 BufferedImage 手绘生成,这样整个流程完全可控,也方便自定义字体、颜色和干扰线。

生成验证码的步骤拆开看就是四步:创建画布、写随机字符、画干扰线、加噪点。字符我选了数字加字母的组合,去掉容易混淆的 0 和 O、1 和 l,这样用户体验会好很多。

public class CaptchaUtil { private static final int WIDTH = 120; private static final int HEIGHT = 40; private static final int CODE_COUNT = 4; private static final char[] CHARS = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7', '8', '9' }; public static String generateCaptcha(HttpSession session) { BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, WIDTH, HEIGHT); StringBuilder code = new StringBuilder(); Random random = new Random(); // 写入字符 for (int i = 0; i < CODE_COUNT; i++) { char c = CHARS[random.nextInt(CHARS.length)]; code.append(c); g.setFont(new Font("Arial", Font.BOLD, 24 + random.nextInt(6))); g.setColor(new Color(random.nextInt(130) + 60, random.nextInt(130) + 60, random.nextInt(130) + 60)); // 对每个字符做轻微旋转,增加识别难度 double angle = random.nextInt(30) - 15; g.rotate(Math.toRadians(angle), 15 + i * 28, 20); g.drawString(String.valueOf(c), 10 + i * 28, 30); g.rotate(-Math.toRadians(angle), 15 + i * 28, 20); } // 画干扰线 for (int i = 0; i < 6; i++) { g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200))); g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); } // 加噪点 for (int i = 0; i < 80; i++) { g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200))); g.drawOval(random.nextInt(WIDTH), random.nextInt(HEIGHT), 1, 1); } g.dispose(); // 将验证码文本存入 Session,同时记录生成时间 session.setAttribute("captchaCode", code.toString()); session.setAttribute("captchaTime", System.currentTimeMillis()); // 写入输出流 try { ImageIO.write(image, "JPEG", response.getOutputStream()); } catch (IOException e) { e.printStackTrace(); } return code.toString(); } }

上面的代码把验证码文本放在了返回值的 code 字符串里,但这只为了本地测试用,正式环境返回给前端的是图片流。另外代码中的字体用了 Arial,它在大多数操作系统上都存在,如果你要画中文验证码就得换成系统中文字体,而且中文识别难度高、显示效果不稳定,做项目时尽量避开中文验证码。

3.2 存储与校验逻辑

验证码存哪里是个关键选择。单机部署或者小规模场景直接存 Session 是最简单的,毕竟 HttpSession 本身就是服务端的状态存储。但我加了两个细节:一是给验证码设置过期时间,5 分钟未使用就失效;二是校验成功之后立即清除 Session 中的验证码,防止同一个验证码被反复使用。

校验时机上,我建议把验证码校验放在用户名密码校验之前。如果验证码都不对,根本不需要去查数据库比对密码,这既减轻了数据库压力,也避免了密码爆破工具在验证码环节就被拦下来。

@PostMapping("/login") public Result<?> login(@RequestBody LoginDTO loginDTO, HttpSession session) { // 先校验验证码 String captchaCode = (String) session.getAttribute("captchaCode"); if (captchaCode == null || !captchaCode.equalsIgnoreCase(loginDTO.getCaptcha())) { return Result.error("验证码错误或已过期"); } // 清除验证码,防止重复使用 session.removeAttribute("captchaCode"); session.removeAttribute("captchaTime"); // 再校验用户名密码 User user = userService.login(loginDTO.getUsername(), loginDTO.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } // 登录成功,生成 Token 返回 return Result.success(tokenService.generateToken(user)); }

校验的时候我用的是 equalsIgnoreCase,这样用户在输入时不区分大小写,友好度高很多。很多人在验证码这里踩了坑:把验证码用 Base64 塞进 JSON 返回给前端,然后再由前端画到 canvas 上。这样绕了一圈,既增加了接口数据量,又容易把 Session 和前端状态搞混,完全没有必要。

3.3 前端接入与自动刷新

前端部分用 Vue 实现,验证码图片本质上就是一张普通图片,img 标签的 src 指向后端验证码接口即可。这里最容易犯的错是浏览器缓存问题——同一个 src 地址第二次加载时,浏览器可能直接返回缓存的旧图片,这就导致验证码刷新无效。

解决办法是在图片地址后面拼一个随机参数。

<template> <div class="captcha-wrapper"> <el-form-item prop="captcha"> <el-input v-model="loginForm.captcha" placeholder="请输入验证码" maxlength="4" /> <img :src="captchaUrl" class="captcha-img" alt="验证码" title="点击刷新" @click="refreshCaptcha" /> </el-form-item> </div> </template> <script> export default { data() { return { loginForm: { username: '', password: '', captcha: '' }, captchaUrl: '' }; }, created() { this.refreshCaptcha(); }, methods: { refreshCaptcha() { // 加时间戳参数,绕过浏览器缓存 this.captchaUrl = '/api/captcha?t=' + new Date().getTime(); } } }; </script>

同时我加了一个行为细节:在验证码输入框失焦并且内容为空的时候,不触发任何校验;但一旦用户提交了登录请求并且后端返回验证码错误,前端会自动调用一次 refreshCaptcha,把验证码换成新的。这个交互虽然小,但对用户体验提升很明显,用户不需要手动去点图片刷新。

3.4 验证码的踩坑清单

验证码模块是我在这个项目里踩坑最多的地方,集中说几个最典型的。

第一,图片请求跨域导致验证码拿不到 Session。如果你的前端部署在 8080 端口,后端部署在 8181 端口,那么前端请求后端接口时,如果不做跨域配置并且没有在 axios 和 SpringMVC 里正确设置 Cookie 携带策略,后端的 Session 每次都是新建的,验证码永远校验失败。这里关键在于 axios 要配置 withCredentials: true,后端跨域配置要设置 allowCredentials(true)。

第二,验证码图片不显示但接口不报错。很可能是因为图片写入流之后没有 flush,或者 Controller 方法上加了 @ResponseBody 导致返回被转成了 JSON,而不是作为图片流输出。解决方法是让接口返回 void,直接使用 HttpServletResponse 写输出流。

第三,验证码刷新后还是旧图。这个问题我刚说过,就是浏览器缓存。多个项目经验告诉你,时间戳参数一定不能忘。

第四,Session 过期导致验证码失效提示不明确。用户在页面停留太久再登录,会提示验证码错误,但实际上是 Session 超时。建议在捕获 Exception 时,对验证码为 null 的情况返回“验证码已过期,请刷新重试”,而不是笼统地返回“验证码错误”,这样用户更容易理解。

4. 业务模块的实操细节

4.1 社团列表与条件检索

社团列表是整个系统访问量最大的接口,学生登录后第一眼看到的就是它。这个接口我做了分页和关键词搜索,支持按社团名称模糊查询、按分类筛选。

MyBatis 里的动态 SQL 是这类查询最舒服的写法,不需要手动拼接字符串,也不容易出 SQL 注入问题。

<select id="selectClubPage" resultType="map"> SELECT c.id, c.club_name, c.category, c.description, u.real_name AS president_name, (SELECT COUNT(*) FROM member m WHERE m.club_id = c.id AND m.status = 1) AS member_count FROM club c LEFT JOIN user u ON c.president_id = u.id <where> <if test="clubName != null and clubName != ''"> AND c.club_name LIKE CONCAT('%', #{clubName}, '%') </if> <if test="category != null and category != ''"> AND c.category = #{category} </if> </where> ORDER BY c.create_time DESC LIMIT #{offset}, #{pageSize} </select>

这里有个值得注意的点:member_count 这个字段我是用子查询求出来的,而不是在 club 表里维护一个冗余字段。一开始我图省事在 club 表加了 member_count 字段,每次有人加入或退出都要去更新,结果经常出现数据不一致。后来改成子查询,虽然多一次扫描,但在百万级以下数据量下性能完全没问题,而且永远不会有脏数据。

4.2 活动报名与审核流程

活动报名的核心难点在于并发控制。学生报名活动时,先要判断活动是否还存在名额,再插入报名记录。如果两个学生同时提交报名,而活动只剩最后一个名额,代码层面的判断可能会出现超卖。

要从根上解决问题,不需要引入分布式锁。对于单体应用,直接把数据库约束做好就够了。enroll 表上 activity_id 和 user_id 的唯一索引保证一个用户不可能重复报名,然后活动名额的判断放在事务里,用悲观锁查询活动记录。

@Transactional public Result<?> enroll(Long activityId, Long userId) { // 悲观锁锁定活动记录,防止并发超卖 Activity activity = activityMapper.selectByIdForUpdate(activityId); if (activity == null) { return Result.error("活动不存在"); } if (activity.getStatus() != 1) { return Result.error("活动不在报名时间内"); } int enrolledCount = enrollMapper.countByActivityId(activityId); if (enrolledCount >= activity.getMaxPeople()) { return Result.error("活动名额已满"); } enrollMapper.insert(activityId, userId); return Result.success("报名成功"); }

selectByIdForUpdate 对应的 SQL 是 SELECT ... FOR UPDATE,它会把这条记录锁住,直到当前事务提交。这样当多个请求同时进来时,第二个请求必须等第一个请求提交后才能读取活动记录,天然避免了超卖。代价是并发性能有影响,但社团活动系统的并发量极低,这种牺牲是值得的。

审核环节的逻辑也不复杂。管理员查看报名列表,对每条报名记录执行通过或拒绝操作。审核通过时,还要顺带把该用户插入到 member 表,成为社团成员。这里要注意把两个操作放到同一个事务里,否则会出现“报名通过了但不是社团成员”的数据不一致。我用运行时异常触发事务回滚,确保两步操作要么全部成功,要么全部失败。

4.3 Vue 端的路由守卫与 axios 拦截器

Vue 前端有两个地方必须做权限控制:一个在请求层,一个在视图层。

请求层的拦截器管 Token。登录成功之后后端会返回一个 Token,前端存储在 localStorage 里,之后所有请求都在 header 中携带。如果请求返回 401 状态码,说明 Token 失效,这时自动清除本地 Token 并跳转到登录页。

import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { this.$message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } this.$message.error('网络异常,请稍后重试'); return Promise.reject(error); } );

视图层的路由守卫管页面跳转。未登录用户只能访问登录页,普通学生不能访问管理后台,社团管理员不能访问超级管理员专属页面。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') { next(); } else if (!token) { next('/login'); } else { const role = parseInt(localStorage.getItem('role')); if (to.meta.roles && !to.meta.roles.includes(role)) { next('/403'); } else { next(); } } });

这套双重控制非常必要。路由守卫只是用户体验层面的限制,真正的安全边界永远在后端。如果一个用户直接通过 curl 伪造请求去调用后台接口,前端路由是不会拦截的,所以后端每个需要权限的接口都必须做校验。

4.4 前后端权限控制的协同

后端权限的核心实现是基于 HandlerInterceptor 的拦截器。我在 SpringMVC 配置文件里注册了自定义拦截器,并配置了哪些路径需要拦截,哪些路径放行。比如登录接口、验证码接口、注册接口都不拦截,而管理端接口全部拦截。

拦截器里先解析 Token 获取用户信息,再根据请求路径判断所需的权限等级。

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } User user = tokenService.parseToken(token); if (user == null) { response.setStatus(401); return false; } // 将用户信息存入 request,供 Controller 使用 request.setAttribute("currentUser", user); // 检查路径对应的权限 if (request.getRequestURI().contains("/admin/") && user.getRole() != 1) { response.setStatus(403); return false; } return true; }

前端菜单也是根据角色动态渲染的。登录后后端返回用户信息包含 role,前端根据 role 判断要不要渲染“系统管理”菜单项。这样不同角色登录后看到的界面是不同的,超级管理员可以看到全部功能,社团管理员只能看到自己社团相关的内容,普通学生看到的就是社团浏览和个人中心。

5. 部署上线与高频问题排查

5.1 从本地到云端的构建部署

项目开发完成后,部署又是一个新的小关卡。SSM 项目通常打包成 war 包放在 Tomcat 的 webapps 目录下运行,而 Vue 项目打包后生成 dist 目录。这里有两个方案。

方案一:前端 dist 目录里的文件直接拷贝到 war 包或者 Tomcat 的 webapp 目录下,让 SpringMVC 同时负责静态资源和接口,这样前后端在同一个端口下运行。好处是没有跨域问题,部署简单。坏处是前后端耦合,后续如果想把前端拆出来单独部署就比较麻烦。

方案二:前端独立部署在 Nginx,后端部署在 Tomcat,前端通过 Nginx 的反向代理把 /api 路径的请求转给后端 Tomcat。这种方式前后端完全分离,也更贴近生产环境。

我实际部署时用的是方案二。Nginx 配置中关键的一段是这样:

server { listen 80; server_name your-domain.com; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8181; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里有个关键点:proxy_pass 后面的地址不能写 http://127.0.0.1:8181/api/,否则请求路径会多重复一层 api。我一开始在这里折腾了很久,改来改去总是 404,最后发现就是 proxy_pass 转发时路径拼接的问题。

部署前还有几个准备工作要做:数据库脚本要在服务器上执行一遍,MySQL 的账号密码要用环境变量或者配置文件管理而不是写死在代码里,Tomcat 的端口和 UTF-8 编码要确认无误。尤其 Tomcat 的 server.xml 里一定要配置 URIEncoding="UTF-8",否则中文参数会出现乱码。

5.2 高频问题速查表

我把实战中遇到的高频问题整理成了一张表,每个问题都对应了具体的现象和解决方案,排查时照着顺序看就行。

现象原因解决方案
前端请求接口 404前后端路径没对上或 Nginx 代理配置错误检查前端 baseURL 是否带 /api、检查 proxy_pass 路径拼接
前端请求接口跨域报错后端未配置跨域或前端未携带 Cookie后端配置 CORS,axios 开启 withCredentials
登录时验证码一直错误Session 跨域丢失或验证码过期检查 allowCredentials,检查 Session 存储与清除逻辑
日期时间显示多 8 小时后端返回 UTC 时间,前端未做本地转换后端返回时间戳或使用北京时间格式字符串
MyBatis 查询结果字段为 null数据库字段下划线和 Java 属性驼峰未映射MyBatis 开启 mapUnderscoreToCamelCase 配置
上传文件提示文件过大SpringMVC 默认上传限制 1MB在配置文件调整 maxFileSize 和 maxRequestSize
列表接口响应慢查询没有走索引或 MyBatis 循环查询了 N+1 次检查 explain 执行计划,使用联表查询替代循环
服务器重启后图片验证码失效图片存在本地磁盘导致路径丢失使用对象存储或限制部署环境一致

5.3 几个值得细说的部署坑

第一个坑是 MySQL 8 的时区问题。新版 MySQL 连接驱动对时区要求很严格,如果 JDBC 连接字符串里不加上 serverTimezone=Asia/Shanghai,连接时会直接报错。很多人在本地开发没事,一部署到服务器就崩溃,多半是这个原因。

第二个坑是 MyBatis 的驼峰映射。我建表时字段名用的是下划线风格,比如 create_time、user_id,而 Java 实体类是 createTime、userId。如果 MyBatis 没有开启映射,查出来的对象这些字段就全是 null。解决方式是在 mybatis-config.xml 里加一行配置:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>

第三个坑是 LocalDateTime 的 JSON 序列化。SSM 项目里如果引入了 Jackson 来处理 JSON,默认序列化 LocalDateTime 会输出一长串数字,前端完全没法用。需要在 ObjectMapper 里注册 JavaTimeModule,并指定日期格式:

ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

这个坑不报错,但表现出来的现象很隐蔽:前端时间字段显示成一串数字,用户看着莫名其妙。

第四个坑是文件上传问题。社团管理中管理员会传社团 logo 图片,如果直接存在本地磁盘,跨机器部署时就丢失了。我做了一个妥协方案:把图片转成 Base64 存到数据库的一个字段里。这个方案在图片很小时完全可用,省去了搭建文件服务的成本。如果图片量大了,再迁移到对象存储也不复杂。

6. 复盘与改进方向

整个项目做完,我最大的感受是:这个系统的复杂度刚刚好,做一遍能学到的东西比想象中多得多。但如果让我重新再写一遍,我会在几个地方做调整。

第一,技术栈上我会直接切换到 Spring Boot 3 + Vue 3 + TypeScript,因为 Spring Boot 内置了 Tomcat,省掉配置 SSM 的大段 XML,开发效率和可维护性都更高。不过对于没接触过 SSM 的同学,我还是建议先手写一次传统 SSM,不然直接上用 Spring Boot 会像背答案一样,不知道它帮你解决了什么。

第二,验证码的存储我会从 Session 改成 Redis。Session 有个天然的局限:如果以后系统扩展到多台服务器,Session 是存在单台服务器内存里的,负载均衡转发到另一台机器时验证码就校验不过去了。用 Redis 存验证码,key 用随机 UUID,value 存验证码文本和过期时间,Redis 又是独立部署,天然跨机器共享。

第三,权限模型可以做得更通用一些。目前的角色字段是写死的,但真实系统里角色和权限经常需要调整。我会做成 user_role、role、role_menu 三张表,把权限管理做成一个可配置的功能,而不是在代码里硬编码。

第四,日志系统一定要从一开始就加。这个项目前期我为了省事没怎么打日志,后期排查线上问题非常痛苦,只能靠猜。后来在关键业务节点加了操作日志,比如谁在什么时间创建了社团、谁审核了哪个报名、谁修改了密码,这样出问题时有据可查。

第五,前端组件封装可以更细。活动报名状态、社团成员角色这些字段,我一开始直接写了多重 if else 判断状态值和文字的对应关系,代码又长又乱。后来封装了枚举映射,再加一个通用字典组件,代码量减少三分之一,可读性也好很多。

如果你正在做类似的社团管理系统,我的建议是别急着写代码,先把数据表设计好、把权限模型画出来、把验证码流程想清楚。这三个环节只要有一环没理顺,后面大概率要返工。特别是验证码,别看它只是一个登录页的小功能,从后端生成、Session 存储、前端刷新、跨域处理到防刷设计,一整套走下来,你对 Web 应用请求生命周期的理解会深很多。

这套系统做完已经有一段时间了,我现在回头在看,觉得当时踩过的每一个坑都值回了票价。项目本身是课设也好,是练手也罢,重要的是通过它把前后端之间的请求链路彻底搞明白了。后面再做别的项目,不管技术栈怎么变,这一套从需求拆解到数据建模、从接口设计到安全校验、从本地联调到服务器部署的完整思路,都是完全通用的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 5:24:42

知识工作插件化架构设计与实现:从转换到编排的完整指南

1. 从“知识工作插件”这个命名说起第一次看到 knowledge-work-plugins 这个项目名&#xff0c;我的直觉是&#xff1a;这大概率不是一个单一工具&#xff0c;而是一套面向知识工作者的能力扩展集合。所谓“知识工作”&#xff0c;说白了就是那些以信息处理为核心的活儿——写文…

作者头像 李华
网站建设 2026/10/21 5:24:13

knowledge-work-plugins:插件化架构如何解决知识工作工具链碎片化

1. 这个插件体系到底在解决什么问题第一次看到 knowledge-work-plugins 这个命名&#xff0c;我脑子里蹦出来的不是某个具体功能&#xff0c;而是一类很典型的工程困境&#xff1a;知识型工作者的工具链太碎了。写文档的、管任务的、查资料的、做归档的、跑分析的&#xff0c;每…

作者头像 李华
网站建设 2026/10/21 5:23:24

C# Access学生管理系统毕业设计源码(WinForms三层架构)

简介&#xff1a;这是一套面向计算机专业本科生的C#毕业设计实战源码&#xff0c;聚焦学生学籍管理这一典型校园信息化场景&#xff0c;帮助初学者掌握Windows桌面应用开发、Access数据库集成与基础软件工程实践。资源包含108个文件&#xff0c;涵盖28个核心C#业务逻辑文件&…

作者头像 李华
网站建设 2026/10/21 5:21:50

REA建模实战:用事件溯源重构订单履约系统,告别状态机混乱

这次要分享的项目&#xff0c;内部代号就叫 rea。先说清楚&#xff0c;它不是某个开源框架&#xff0c;也不是某个资格认证的缩写&#xff0c;而是我们在一套业务系统里完整落地了一遍的 REA 建模方法&#xff1a;Resource-Event-Agent&#xff0c;也就是资源-事件-代理者。今年…

作者头像 李华
网站建设 2026/10/21 5:21:26

REA实时企业应用运行时:事件驱动架构下的编排引擎设计实践

先说明一下&#xff0c;我拿到的原始输入只有项目标题"rea"这一个字符串&#xff0c;正文、关键词、摘要都是空的。这种情况下&#xff0c;最合理也是最稳妥的处理方式&#xff0c;是把"REA"理解为一个内部代号&#xff1a;Real-time Enterprise Applicati…

作者头像 李华