宠物类的小程序项目每年毕业设计季都能刷到一堆,其中“喵咪社区”这个方向出现频率特别高——它同时踩中了内容社区和宠物经济两个热点,功能边界又足够清晰,拿来做一个完整的 SSM 后端加小程序前端的组合再合适不过。我这些年帮人看过不少这类项目,标题里带“SSM”“喵咪社区小程序”的,十个里有八个骨架差不多,但真正能跑通、能讲清楚、能扛住答辩追问的不到三成。这篇就围绕这个标题往下拆:它到底该做成什么样,后端为什么选 SSM 这套组合,社区互动和领养流转这两块的数据该怎么设计,环境怎么搭、坑怎么避、答辩怎么讲。不管你是刚接触 Java Web 的新手,还是已经写完代码但心里没底的老哥,下面这些内容应该都能直接用上。
顺带提一句,标题里挂着“免费领项目”这种字样的资源,我的建议是别急着伸手。这类东西要么是阉割版,要么塞了一堆你根本讲不出来的代码,答辩现场被问一句“这个拦截器为什么这么写”就露馅了。真要做,自己从零搭一遍,哪怕功能少两个,讲起来底气完全不一样。
1. 项目整体定位与选题拆解
1.1 宠物社区这个选题凭什么年年都有
先说清楚这个项目是什么。一句话概括:一个让养猫的人发内容、看内容、互相勾搭、顺便做猫咪领养对接的小程序,后端是一套标准的 SSM 三层架构,负责用户、内容、互动、领养四类数据的存取和业务校验。
它之所以适合做毕设,核心原因是“业务闭环完整但不复杂”。你想想看,一个内容社区需要什么?用户体系、内容发布、内容浏览、互动反馈、消息通知,这五块齐了就是一个能自洽运转的最小系统。而领养模块又给这个系统加了一条带状态的业务流程,正好能体现出你懂“状态机”这个东西——这在答辩时是个很好的加分点,因为大部分同学的项目里所有数据都是“增删改查”,没有状态流转的概念。
再一个,宠物社区的实体关系天然清晰。用户养猫,猫产生内容,内容下面有评论,评论和内容都能被点赞。这种“一个核心实体派生多条支线”的结构画 E-R 图特别顺,论文里的数据库设计章节不用硬凑。相比之下,做个电商或者 OA,表能画到二三十张,你自己都说不清每张表的用途。
适合谁参考?如果你是计算机相关专业、Java 基础过了一遍、知道 Servlet 和 JDBC 是怎么回事,但没做过完整项目,这套东西正好卡在你的能力上沿。如果你的基础更弱一点,也不用慌,SSM 这套东西的学习曲线主要在配置,代码本身不复杂,下面我会把配置部分拆开讲。
1.2 用户端和管理端到底该怎么切
很多人做这类项目容易犯一个错:把所有功能都堆在小程序里,结果小程序里出现“审核帖子”“封禁用户”这种明显该在后台干的活。正确的切法是两端分离,各管各的。
用户端(小程序)承担的是:注册登录、完善个人资料、发布图文帖子、浏览信息流、点赞评论收藏、查看猫咪档案、发起领养申请、接收系统通知、管理自己的发布内容。
管理端(Web 后台)承担的是:用户管理(查看、禁用)、内容审核(帖子、评论)、话题管理、领养申请的人工复核、数据统计(日活、发帖量、领养成功率)、轮播图和公告配置。
这个切分不是形式主义。你把它做好了,论文的“系统功能结构图”直接就是两棵清晰的树,答辩老师一眼能看懂你的系统边界在哪。而且从技术上讲,管理端用传统的 JSP 或者简单的后台模板就能搞定,不需要再写一套小程序,工作量可控。
提示:管理端千万别做成“只有登录页和一个表格”,至少要有审核动作和状态变更。老师最爱问的就是“你这个后台除了看还能干什么”。
1.3 功能清单的取舍原则
毕业设计的周期通常是三到四个月,扣掉写论文的时间,真正写代码可能就六到八周。所以功能取舍要有意识地做减法,原则是:宁可少做两个模块但每个都完整,不要铺十个模块每个都半成品。
必做清单我建议锁定在这几项:登录注册、个人信息、发帖(含图片)、信息流列表、帖子详情、评论、点赞、猫咪档案、领养申请与审核。这十项做完,系统就是能演示、能自圆其说的。
可以砍的:私信聊天(实时通信的坑太深,用留言代替即可)、复杂推荐算法(说成“按时间倒序加热度加权”就够了)、支付相关(一旦涉及钱,答辩老师会追问合规性,纯属给自己找麻烦)、视频上传(体积和转码都是坑)。
我见过有同学非要在毕设里做 WebSocket 私信,结果答辩前一天还在调连接断开的 bug,最后演示的时候干脆把入口藏了。这种投入产出比极低的模块,砍掉不丢人。
2. 技术选型背后的取舍逻辑
2.1 为什么是 SSM 而不是更新的框架
现在新项目基本都用 Spring Boot 了,为什么毕设还在用 SSM?这个问题答辩时大概率会被问到,你得有个像样的回答。
真实原因有三层。第一层是教学衔接:大部分学校的 Java Web 课程讲的就是 Spring + SpringMVC + MyBatis 这三件套,你用它,老师能听懂你在说什么,答辩沟通成本最低。第二层是可见性:Spring Boot 的自动配置把很多东西藏起来了,而 SSM 的每一个 XML 配置都是明面上的,你能指着spring-mvc.xml说“这里是视图解析器,这里是拦截器链,这里是静态资源放行”,这种“我掌控每一行配置”的叙事在答辩里非常吃香。第三层是排查能力:配置全暴露意味着出问题时你能顺着配置一层层找,这对训练基本功是有好处的。
当然代价也明显:配置文件多、依赖版本要自己对齐、启动慢、打 war 包部署比 jar 包麻烦。我的做法是折中——主体用 SSM,但依赖版本统一在pom.xml的dependencyManagement里锁死,避免版本冲突;同时把配置文件按职责拆成spring-dao.xml、spring-service.xml、spring-mvc.xml三个,各自只干一件事,出问题好定位。
<!-- pom.xml 片段:锁定关键版本,避免依赖地狱 --> <properties> <spring.version>5.3.30</spring.version> <mybatis.version>3.5.13</mybatis.version> <mysql.version>8.0.33</mysql.version> <jackson.version>2.15.2</jackson.version> </properties>版本这件事真的别偷懒。我踩过最蠢的一个坑是 MyBatis 用了 3.5.x 但mybatis-spring用了 1.2.x,结果容器启动时一直报NoClassDefFoundError,翻了两小时才反应过来是适配版本不匹配。
2.2 数据库设计从“猫”这个实体出发
设计表之前先问自己一个问题:这个系统里最核心的实体是什么?答案不是用户,是猫。用户是围绕猫聚起来的,内容是围绕猫产生的,领养是围绕猫流转的。把猫作为中心去推表结构,整个模型会非常自然。
我一般会拆成这几张表:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| t_user | 用户账号与资料 | id, open_id, nickname, avatar, phone, status |
| t_cat | 猫咪档案 | id, user_id, name, breed, age, gender, sterilized, cover, intro |
| t_post | 帖子 | id, user_id, cat_id, content, images, topic_id, like_count, comment_count, status |
| t_comment | 评论 | id, post_id, user_id, parent_id, content, create_time |
| t_like | 点赞关系 | id, user_id, target_type, target_id |
| t_adopt | 领养申请 | id, cat_id, applicant_id, reason, status, audit_remark |
| t_topic | 话题 | id, name, cover, sort |
| t_notice | 通知 | id, user_id, type, content, is_read |
几个设计要点值得展开说。
冗余计数字段。t_post里的like_count和comment_count是冗余的,理论上每次都能count(*)算出来。但信息流一页十条,十条都去 count 就是十次聚合查询,性能很难看。所以要在点赞/取消点赞、评论/删评论的事务里同步更新这两个字段。代价是要保证一致性,我后面会讲怎么核对。
多态点赞表。t_like用target_type区分点赞对象是帖子还是评论,一张表搞定两种点赞,不用建两张。对应地在应用层要加一个枚举校验,别让不合法的 type 写进去。
图片字段怎么存。t_post.images存的是多个图片地址,可以用逗号分隔的字符串,也可以用 JSON 数组。我倾向存相对路径的逗号串,比如/upload/2024/05/a1b2c3.jpg,/upload/2024/05/d4e5f6.jpg,好处是查询出来直接 split 就能拼成完整 URL,前端不用二次处理。绝对不要往数据库里塞 base64,一条帖子几兆,查列表能把数据库拖垮。
状态字段统一用 tinyint。0 正常、1 待审、2 已下架、3 已删除,全系统统一这套编码,别这张表用 0/1 那张表用 true/false,后期维护会疯。
2.3 小程序端的页面组织与技术边界
小程序端不需要花哨的框架,原生写法完全够用。页面大致这么分:首页(信息流)、话题页、发布页、消息页、我的页作为 tabBar 五个入口;详情页、猫咪档案页、领养申请页、设置页作为二级页面。
请求层一定要封装,不要在页面里到处wx.request。封装要做三件事:统一加 baseURL、统一在请求头带 token、统一处理 401 和错误提示。
// utils/request.js 的核心逻辑 const BASE_URL = 'https://your-domain.com/api'; function request(options) { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', 'token': token || '' }, success(res) { if (res.statusCode === 401) { wx.removeStorageSync('token'); wx.reLaunch({ url: '/pages/login/login' }); return reject(new Error('未登录')); } if (res.data.code !== 200) { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); return reject(res.data); } resolve(res.data.data); }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }这段封装的价值在于:全站只有一处处理登录失效,后面加接口时不用重复写判断。很多同学的代码里每个页面都写一遍if (res.data.code === 401),改需求的时候要改二十个地方,纯属自找麻烦。
3. 核心模块的落地实现
3.1 登录鉴权这条链路怎么走通
小程序的登录不能用账号密码那一套,得走平台的登录凭证换取机制。整条链路是这样:前端调登录接口拿到临时凭证 code,传给后端;后端拿 code 加上应用标识去平台服务端换取 openid 和 session_key;后端用 openid 查用户表,没有就自动注册一个;然后生成一个自己的 token(我一般用 UUID 存缓存,或者用 JWT),把 token 和用户信息返回给前端。
// 登录核心逻辑示意 @PostMapping("/login") @ResponseBody public Result login(@RequestBody LoginDTO dto) { // 1. 用 code 换取 openid String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + dto.getCode() + "&grant_type=authorization_code"; JSONObject session = JSON.parseObject(HttpUtil.get(url)); String openId = session.getString("openid"); if (StringUtils.isBlank(openId)) { return Result.fail("登录失败"); } // 2. 查用户,没有就注册 User user = userService.getByOpenId(openId); if (user == null) { user = new User(); user.setOpenId(openId); user.setNickname("喵友" + System.currentTimeMillis() % 100000); user.setStatus(0); userService.insert(user); } // 3. 签发 token String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("login:token:" + token, user.getId(), 7, TimeUnit.DAYS); return Result.ok(new LoginVO(token, user)); }几个容易出错的地方。第一,appSecret绝对不能写进小程序前端代码,只能在服务端用,一旦泄露别人就能伪造你的应用身份。第二,token 一定要设过期时间,我见过有人的 token 是永久有效的,安全问题不说,长期运行后缓存里堆了几十万个 key。第三,别把 openid 直接当 token 返回给前端,openid 是稳定标识,泄露了没法失效,必须用一次性的随机串或者带过期时间的 JWT。
校验侧用 SpringMVC 的拦截器做:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 放行静态资源 } String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { writeError(response, 401, "未登录"); return false; } Object userId = redisTemplate.opsForValue().get("login:token:" + token); if (userId == null) { writeError(response, 401, "登录已过期"); return false; } UserContext.set(Long.valueOf(userId.toString())); // ThreadLocal 存当前用户 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); // 必须清理,否则线程复用会串号 } }注意:
afterCompletion里的清理千万别漏。线程池复用线程,ThreadLocal 不清会导致下一个请求拿到上一个用户的信息,这种 bug 极其隐蔽,测试时很难复现。
3.2 发帖与图片上传的完整方案
发帖功能看着简单,实际是坑最多的模块之一。核心难点是图片上传的存储方案。
我推荐的做法是:小程序端先用wx.uploadFile把图片逐个传到后端的上传接口,接口把文件写到服务器磁盘的某个目录,返回相对路径;前端收集到所有路径后,再调发帖接口把文字内容和图片路径数组一起提交。这样图片上传和业务提交解耦,某张图传失败了可以重试,不会导致整条帖子白写。
@PostMapping("/upload") @ResponseBody public Result upload(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return Result.fail("文件为空"); } // 1. 类型白名单校验,别只信任后缀名 String original = file.getOriginalFilename(); String suffix = original.substring(original.lastIndexOf(".") + 1).toLowerCase(); List<String> allow = Arrays.asList("jpg", "jpeg", "png", "gif", "webp"); if (!allow.contains(suffix)) { return Result.fail("不支持的图片格式"); } // 2. 大小限制 5MB if (file.getSize() > 5 * 1024 * 1024) { return Result.fail("图片不能超过5MB"); } // 3. 按日期分目录,避免单目录文件过多 String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String fileName = UUID.randomUUID().toString().replace("-", "") + "." + suffix; File dir = new File(uploadRoot + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(dir, fileName)); } catch (IOException e) { return Result.fail("保存失败"); } return Result.ok("/upload/" + datePath + "/" + fileName); }为什么按日期分目录?单个目录下文件超过几万个,某些文件系统在列目录时会明显变慢,运维排查也痛苦。这个习惯在你以后做任何文件存储时都用得上。
为什么用白名单校验后缀?因为只判断Content-Type是可以伪造的,只判断后缀也不够,但两者加一起,再把上传目录配置成不可执行脚本,基本就够用了。生产环境如果图片量大,换成对象存储服务,接口逻辑几乎不变,只把transferTo换成 SDK 上传即可。
还有一个细节:MultipartFile的大小上限受两处配置控制,Spring 的multipartResolver和容器本身的限制。我见过有人只改了 Spring 配置,结果传 3MB 的图一直报错,最后发现是 Tomcat 的maxPostSize卡住了。
<!-- spring-mvc.xml 里的上传解析器 --> <bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="defaultEncoding" value="UTF-8"/> <property name="maxUploadSize" value="10485760"/> <property name="maxInMemorySize" value="4096"/> </bean>3.3 互动模块的数据一致性问题
点赞、评论、收藏这类互动功能,技术上不难,难的是“重复点击”和“计数不准”这两个问题。
先说重复点赞。用户手抖连点两下,或者网络慢导致请求重发,都可能产生两条点赞记录。解决办法是在数据库层加唯一索引,靠数据库来兜底:
ALTER TABLE t_like ADD UNIQUE KEY uk_user_target (user_id, target_type, target_id);配合业务层的处理:
@Transactional public void like(Long userId, Integer targetType, Long targetId) { try { likeMapper.insert(userId, targetType, targetId); } catch (DuplicateKeyException e) { throw new BizException("已经点过赞了"); } // 同步更新冗余计数 if (targetType == 1) { postMapper.incrLikeCount(targetId, 1); } }取消点赞就是反向操作,先delete再判断影响行数,只有真的删掉了才减计数:
@Transactional public void unlike(Long userId, Integer targetType, Long targetId) { int rows = likeMapper.delete(userId, targetType, targetId); if (rows > 0 && targetType == 1) { postMapper.incrLikeCount(targetId, -1); } }为什么要判断影响行数?如果不判断,用户重复调用取消接口,计数会被减成负数。这个 bug 我在早期项目里踩过,列表页出现 “-3 个赞” 的帖子,尴尬得不行。
再说计数对账。冗余字段终归可能因为异常(比如事务回滚、并发冲突)而不准,所以要么在后台加一个“数据修复”按钮,要么用定时任务每天凌晨跑一次校准 SQL:
-- 校准帖子点赞数 UPDATE t_post p SET p.like_count = ( SELECT COUNT(*) FROM t_like l WHERE l.target_type = 1 AND l.target_id = p.id );这个校准逻辑写进论文的“系统优化”章节,反而是个加分点,说明你考虑到了数据一致性,而不是只会 CRUD。
3.4 领养模块的状态机设计
领养是这个项目里唯一带业务流程的模块,也是最容易讲出深度的部分。它的本质是一个状态机:一条申请从提交到完结,中间要经过若干状态,每个状态能做什么、不能做什么,必须定死。
我设计的状态是这样的:
| 状态值 | 含义 | 谁能操作 | 可流转到 |
|---|---|---|---|
| 0 | 待审核 | 发布者 | 1 通过、2 拒绝 |
| 1 | 已通过 | 发布者/申请人 | 3 完成、4 取消 |
| 2 | 已拒绝 | 无 | 终态 |
| 3 | 已完成 | 无 | 终态 |
| 4 | 已取消 | 申请人 | 终态 |
关键约束是这样的:同一只猫在“已通过”状态下,不能再有新申请进来。因为领养是一对一的,一只猫只能被一个人领走。如果不加这个校验,会出现三个人同时通过的情况,系统数据就打架了。
@Transactional public void apply(Long catId, Long userId, String reason) { Cat cat = catMapper.selectById(catId); if (cat == null || cat.getStatus() != 0) { throw new BizException("该猫咪暂不可申请"); } // 同一只猫同时只能有一个通过状态的申请 int passed = adoptMapper.countByCatAndStatus(catId, 1); if (passed > 0) { throw new BizException("该猫咪已被领养"); } // 同一用户不能重复申请同一只猫 int exists = adoptMapper.countByCatAndUser(catId, userId); if (exists > 0) { throw new BizException("你已提交过申请"); } Adopt adopt = new Adopt(); adopt.setCatId(catId); adopt.setApplicantId(userId); adopt.setReason(reason); adopt.setStatus(0); adopt.setApplyTime(new Date()); adoptMapper.insert(adopt); }审核通过时的动作要成组完成:更新申请状态、更新猫咪状态为已领养、给申请人发通知。这三步必须在一个事务里,任何一步失败都要回滚。我见过有人的代码先更新申请状态,再发通知,通知发送调用外部接口超时抛异常,结果申请状态改了但猫咪状态没改,数据就乱了。
提示:把状态流转写成一张表放论文里,再配一段“为什么这样设计”的说明,答辩老师问起来你直接指着表讲,比嘴上解释十分钟管用。
4. 环境搭建与跑通全流程
4.1 后端环境与依赖清单
环境这块我列一个明确的清单,照着装就不会互相打架。JDK 用 1.8(别轻易上 17,SSM 那套老配置文件在高版本上有些兼容问题要额外处理);Maven 用 3.6 以上;Tomcat 用 8.5 或者 9.0,注意 10 以后包名从javax换成了jakarta,会引发一堆编译错误,新手别碰;MySQL 用 5.7 或 8.0,两者在驱动类和连接串上略有差异,用 8.0 的记得驱动类写com.mysql.cj.jdbc.Driver,连接串要带时区和 SSL 参数。
# db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/cat_community?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password连接串里那几个参数每一个都有用。characterEncoding=utf8不加,中文会变问号;serverTimezone不设,查出来的时间可能差 8 小时;allowPublicKeyRetrieval=true不加,某些环境下会报公钥检索失败。这几个参数我都是在不同机器上被坑过之后才记牢的。
4.2 数据库初始化与数据准备
建库的时候字符集显式指定,别依赖默认值:
CREATE DATABASE cat_community DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;为什么用utf8mb4而不是utf8?因为 MySQL 里的utf8实际是三个字节的,存不了部分生僻字和某些表情符号。用户昵称里带个猫爪表情,直接插入就报错或者变问号。这个坑我遇到过不止一次,改成utf8mb4就彻底解决。
初始化数据这块建议单独写一个init.sql,包含建表语句和一批测试数据。测试数据至少准备这些量级:20 个用户、30 只猫、80 条帖子、200 条评论、500 条点赞记录。有这批数据,你的列表页分页、点赞数显示、话题聚合才有东西可看,演示的时候不至于空荡荡。
演示数据要有点“设计感”。帖子内容别写成“测试内容1”“测试内容2”,写成真实的口吻,配几张猫的图片,话题名起得自然一点。评委看的是完整效果,一堆“test”字样的截图会让人觉得你只是跑通了流程。
4.3 前后端联调的关键配置
小程序开发阶段最大的拦路虎是请求域名校验。解决办法是在开发工具里勾上“不校验合法域名”这个选项,本地调试就畅通无阻了。但注意,这个选项只在开发工具里有效,真机预览和正式发布必须走 HTTPS 且域名备案,端口只能是 443。
联调顺序我建议这样走:先用接口测试工具把后端每个接口单独调通,确认入参出参正确;再接小程序登录,确认 token 能拿到并正确携带;然后按“列表页 → 详情页 → 发布页”的顺序接入,因为列表页能直接验证数据返回,最容易暴露格式问题。
spring-mvc.xml里别忘了配静态资源放行和 JSON 转换器,不然接口返回对象时会报 406:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.StringHttpMessageConverter"> <property name="supportedMediaTypes"> <list><value>text/plain;charset=UTF-8</value></list> </property> </bean> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="supportedMediaTypes"> <list><value>application/json;charset=UTF-8</value></list> </property> </bean> </mvc:message-converters> </mvc:annotation-driven> <!-- 上传目录映射成静态资源,方便直接访问 --> <mvc:resources mapping="/upload/**" location="file:/data/upload/"/>4.4 部署上线的最小可用方案
答辩前把系统部署到一台服务器上,比现场用本机演示可靠得多。最小方案是这样:一台带公网 IP 的服务器,装 JDK 和 Tomcat,把 war 包丢进webapps,再把 MySQL 装上,导入数据。前面挂个 Nginx 做反向代理和静态资源服务。
Nginx 配置大概是这样:
server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/ssl/your.pem; ssl_certificate_key /etc/ssl/your.key; location /api/ { proxy_pass http://127.0.0.1:8080/cat/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; client_max_body_size 10m; } location /upload/ { root /data; expires 7d; } }client_max_body_size这一行如果漏了,上传图片会返回 413,而且错误信息很不直观,我第一次遇到时找了半天。expires 7d是给图片加缓存,减少重复加载,用户体验会明显好一些。
注意:小程序要求的域名必须是 HTTPS 且不能带端口。如果你的服务器只有 IP 没有域名,那就老老实实在本地演示,别硬凑,否则现场很可能因为证书或域名问题翻车。
5. 常见问题与排查实录
5.1 登录态相关的高频故障
登录问题占了所有问题的一半以上。表现通常是:接口一直返回“未登录”,但用户明明刚登录过。排查路径我总结成这样:
| 现象 | 大概率原因 | 快速定位方式 |
|---|---|---|
| 所有接口都 401 | 请求头没带 token | 打印请求头看 token 字段 |
| 刚登录就 401 | 缓存写入失败或 key 前缀不一致 | 查缓存服务是否连通、key 是否拼错 |
| 偶尔 401 | token 过期或缓存被清 | 看缓存 TTL 和淘汰策略 |
| 换账号还是原用户 | ThreadLocal 没清理 | 检查拦截器 afterCompletion |
最隐蔽的是最后一种。表现是 A 用户退出、B 用户登录,B 看到的是 A 的数据。原因是线程池复用,ThreadLocal里的用户 id 没清。这种问题在开发时很难复现,因为请求量小、线程不一定复用,一上线就出问题。解决方式就是前面代码里写的,在afterCompletion里clear(),而且要用try-finally保证一定执行。
还有一个新手常犯的错:把 token 存在小程序端的globalData里。页面刷新或者小程序切后台被回收,globalData就没了,用户得重新登录。正确做法是存Storage,它是持久化的。
5.2 图片上传与显示的典型问题
图片问题的表现形式特别多,我按遇到过的情况列一下。
上传返回成功但图片打不开,八成是路径拼接错误。数据库里存的是/upload/2024/05/xx.jpg,前端拼的时候可能又加了一次 baseURL,变成https://domain/api/upload/...,多了一层/api,自然 404。排查方法很直接:把数据库里的路径复制出来,直接在浏览器地址栏访问,看能不能打开。
上传直接报错,先看三件事。一是multipartResolver的 id 必须是multipartResolver,写错名字 Spring 就找不到,MultipartFile直接是 null。二是前端wx.uploadFile的name字段必须和后端@RequestParam("file")一致,不一致就接收不到。三是 Tomcat 的临时目录权限,某些服务器上/tmp不可写,文件流转会失败,日志里能看到FileNotFoundException指向临时目录。
图片显示变形,是前端样式问题,给image组件设mode="aspectFill"并固定宽高,列表里用等宽等高,详情页用widthFix,视觉上就正常了。
5.3 数据与编码类的疑难杂症
中文乱码是另一个高发区,可能出在四个环节:数据库字符集、连接串参数、响应头编码、页面编码。排查顺序是从数据库往上查,先SHOW VARIABLES LIKE 'character%'确认库和表的字符集,再确认连接串里带了characterEncoding,最后确认web.xml里配了编码过滤器。
时间差 8 小时这个现象特别经典,症状是数据库里存的时间是对的,接口返回给前端就少了 8 小时。原因是 Jackson 序列化时用了 UTC 时区。解决办法有两个,一是给实体字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),二是在全局配置里统一设时区。我建议两个都做,全局配置兜底,字段注解保证格式统一。
分页查不出数据也常见。这里有个易错点:分页参数从 0 开始还是从 1 开始。前端组件通常从 1 开始传页码,而某些分页插件内部计算偏移量时按(pageNum - 1) * pageSize,如果你的插件默认按 0 开始,就会出现“第一页是空的、第二页才是第一条数据”的情况。统一约定:接口层收到的页码如果是 1 起始,在进 Service 之前先减一,注释写清楚。
5.4 性能与稳定性方面的观察
毕设项目谈性能有点奢侈,但有两个地方该做还是要做,因为答辩老师真的会问。
第一个是列表查询的字段控制。SELECT *是个坏习惯,帖子列表根本不需要content的全文,也不需要用户的密码字段。改成只查列表需要的列,数据量一大差别很明显。
<select id="selectFeed" resultType="PostVO"> SELECT p.id, p.content, p.images, p.like_count, p.comment_count, p.create_time, u.nickname, u.avatar FROM t_post p LEFT JOIN t_user u ON p.user_id = u.id WHERE p.status = 0 ORDER BY p.create_time DESC LIMIT #{offset}, #{pageSize} </select>第二个是 N+1 查询。典型场景是:查十条帖子,然后循环里每条帖子再查一次作者信息,总共十一次查询。正确做法是用 JOIN 一次查完,或者先收集所有 user_id 再批量查一次。这个问题在数据量小的时候看不出来,数据量一上来就慢得明显。
6. 毕设答辩与文档写作的实操建议
6.1 论文里该写什么、不该写什么
论文和代码是两回事。代码追求能跑,论文追求讲得通。我建议的结构是这样:
| 章节 | 该写什么 | 别写什么 |
|---|---|---|
| 绪论 | 选题背景、同类产品的不足、你要解决什么 | 空泛的行业展望 |
| 需求分析 | 用例图、功能清单、非功能需求 | 大段复制粘贴的概念定义 |
| 系统设计 | 架构图、模块划分、E-R 图、状态机 | 把每个类的每个方法都列一遍 |
| 详细实现 | 关键技术点的代码片段和思路说明 | 整页贴代码 |
| 系统测试 | 测试用例表、边界情况、缺陷记录 | 只说“测试通过” |
核心的写作原则是:多写“为什么”,少写“是什么”。你说“系统采用 SSM 架构”,这句话没有任何信息量;你说“选择 SSM 而非更新的框架,是因为课程体系衔接、配置可见性便于调试、答辩时可解释性强”,这就体现了你的思考。
还有一个技巧:把你踩过的坑写进论文。比如“初期出现时间字段相差 8 小时的问题,经排查为序列化时区配置不当,通过全局时区配置解决”。这种段落真实、有细节,比任何漂亮话都有说服力,而且完全不用担心查重。
6.2 演示环节该怎么编排
演示时间通常只有五到八分钟,必须有脚本。我建议的顺序是这样:
先花 30 秒讲一句系统定位,一句话说清楚是给谁做什么用的。然后立刻进入操作:登录(展示自动注册)、发一条带图的帖子、切到另一个账号点赞评论、进入领养模块走完一次申请和审核、最后切到管理端展示审核动作和数据看板。整个流程控制在四分钟内,剩下的时间留给提问。
演示前一定要做的事:把测试数据准备充分,确保每个列表页都有内容;把所有接口的异常提示检查一遍,别在演示时弹出“null”;把网络切换成稳定的环境,或者干脆本地跑不依赖网络(登录除外);准备一份截图备份,万一现场环境出问题,直接放图也比干站着强。
提示:演示时不要展示你没把握的功能。有个入口但点了报错,比没有这个入口糟糕得多。宁可只演示六个功能,每个都流畅。
6.3 关于源码来源这件事的实在话
回到标题里那个“免费领项目”。这类资源的真实情况是:大部分是几年前的代码,依赖版本对不上,跑起来一堆报错;有些是被改过名字的公开项目,功能结构雷同到答辩老师一眼就能认出来;还有些干脆只有前端页面,后端是空壳。你拿到手省了两周时间,但后面补窟窿可能花掉一个月,更关键的是你讲不清楚代码逻辑,一旦被追问就露怯。
如果确实想参考现成的代码,我建议的做法是:拿它当“功能清单参考”,看完知道要做哪些模块,然后自己从零搭。自己搭的过程会慢,但你写出来的每一行都能解释,改需求时也知道从哪下手。真要做二次开发,也至少把登录鉴权、发帖、评论这三个模块推倒重写一遍,这三块是所有问题的集中地,重写一遍你就懂了。
另外提醒一句,直接从网上扒代码提交,在多数学校都属于学术不端,风险远大于收益。这个账要算清楚。
我在实际做这类项目的体会是,真正决定项目质量的不是功能数量,而是有没有一两个点能讲出深度。宠物社区这个选题,领养状态机和互动数据一致性就是最好的两个切入点,把这两块吃透,剩下的都是熟练活。至于代码跑通之后怎么优化,我个人的习惯是先把每个接口用工具压一百次看看响应时间,找到最慢的那个再去改 SQL 和索引,一般改完两三个接口,整体体验就上来了,比盲目到处加缓存有效得多。