简介:围绕基于微信小程序的大学生线上聊天交友系统的毕业设计答辩场景,这份PPT以315KB单文件形式提供,属于1个pptx格式的成套答辩演示文稿,面向正在准备选题答辩、中期检查或最终答辩的计算机相关专业学生。内容覆盖微信小程序定义与应用场景、系统设计背景与目标意义、B/S架构与MySQL数据存储方案、Eclipse可视化开发、Java面向对象特性及SpringBoot框架选型,并梳理了管理员与用户两大模块下的动态信息管理、私信处理、申请好友、通知信息等功能划分与权限设计。既可帮助读者在答辩前对照整理技术路线与功能结构,也能用于快速复盘系统实现要点、组织汇报逻辑与应答思路。目前已有60人学习,适合需要一份结构完整、技术点清晰的聊天交友类项目答辩参考的同学。
1. 答辩PPT 交上去之前,评审先翻的往往是技术选型那一页
不少同学的毕业设计 PPT 做得花哨,封面、目录、致谢一应俱全,结果答辩现场被一句「你这个聊天记录是怎么存、怎么查的」问住。这套基于微信小程序的大学生线上聊天交友系统,真正值钱的部分不是页面好看,而是「小程序端 + SpringBoot 后端 + MySQL」这条链路上每一个环节都能自圆其说:登录态怎么维持、好友申请怎么防重复、私信会话列表用什么 SQL 拉、消息多了怎么分页。它适合两类人:一类是要交毕设、必须把设计说清楚的学生;另一类是想拿一套带小程序端的社交类项目练手,补齐「客户端登录态 + 后端关系建模」这块短板的开发者。下面按小程序端、后端建模、消息实时性、答辩落地四段拆开讲,代码和表结构都能直接抄。
2. 微信小程序端的页面骨架与登录态维持
小程序这一端看起来简单,坑大多集中在两个地方:页面注册与导航栏的尺寸,以及登录态从哪里来、存在哪、什么时候失效。前者影响的是「苹果手机上滑不动」这类体验问题,后者影响的是「真机调试时请求 401」。
2.1 app.json 页面注册与顶部导航栏高度
小程序的页面不是靠路由跳转字符串随便写的,所有页面都必须先在app.json的pages数组里注册,数组第一项就是启动页。tabBar 里的pagePath也必须出现在pages中,否则编译期直接报错。
{ "pages": [ "pages/login/login", "pages/index/index", "pages/dynamic/dynamic", "pages/friend/apply", "pages/message/chat", "pages/mine/mine" ], "window": { "navigationBarTitleText": "校园交友", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "enablePullDownRefresh": true }, "tabBar": { "color": "#8a8a8a", "selectedColor": "#07c160", "list": [ { "pagePath": "pages/index/index", "text": "动态" }, { "pagePath": "pages/message/chat", "text": "消息" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "sitemapLocation": "sitemap.json" }window里配置的是全局导航栏样式,单个页面想覆盖,就在该页面同级的xxx.json里再写一份navigationStyle。做自定义导航栏时,顶部高度不能写死 44px,得按机型算:
// 自定义导航栏高度 = 状态栏高度 + 胶囊高度 + 上下间距 const windowInfo = wx.getWindowInfo(); // 基础库 2.20.1+ 推荐用这个 const menuRect = wx.getMenuButtonBoundingClientRect(); const navHeight = menuRect.bottom + (menuRect.top - windowInfo.statusBarHeight); this.setData({ navHeight, statusBarHeight: windowInfo.statusBarHeight });wx.getWindowInfo()返回statusBarHeight,胶囊按钮的top与状态栏高度的差值就是导航栏上下留白,乘 2 之后再加胶囊高度,才是自定义导航栏的真实高度。写死数值在 iPhone SE 和 Pro Max 上会各错一次。
| 页面路由 | 功能 | 对应后端接口 |
|---|---|---|
| pages/login/login | 静默登录、资料补全 | POST /auth/login |
| pages/index/index | 动态列表、点赞 | GET /dynamic/page |
| pages/dynamic/dynamic | 动态详情、评论 | GET /dynamic/{id} |
| pages/friend/apply | 搜索用户、申请好友 | POST /friend/apply |
| pages/message/chat | 会话列表、聊天窗 | GET /message/session |
| pages/mine/mine | 我的资料、通知 | GET /notice/list |
2.2 wx.login 的 code 换 token 全链路
小程序端拿不到用户的 openid,只能通过wx.login拿一个临时code,把它交给自己的后端,由后端去换 openid。这个 code 是一次性的,有效期约五分钟,且只能被消费一次,所以千万不要在前端反复重试同一个 code。
// utils/auth.js const BASE_URL = 'https://api.example.edu/api'; function login() { return new Promise((resolve, reject) => { wx.login({ success(res) { if (!res.code) return reject(new Error('wx.login 未返回 code')); wx.request({ url: `${BASE_URL}/auth/login`, method: 'POST', data: { code: res.code }, success(r) { // 后端返回自定义 token,不是微信的 session_key if (r.data.code === 200) { wx.setStorageSync('token', r.data.data.token); wx.setStorageSync('uid', r.data.data.userId); resolve(r.data.data); } else { reject(new Error(r.data.msg)); } }, fail: reject }); }, fail: reject }); }); } module.exports = { login, BASE_URL };后端这一段是整个登录链路的重点,常见的做法是:用 code 换 openid,首次登录自动落库建用户,再签发一个自己的 token(JWT 或 UUID 存 Redis 都行),后续所有接口只认这个 token。
@PostMapping("/auth/login") public R login(@RequestBody LoginDTO dto) { // 1. code 换 openid / session_key,secret 只能放服务端 String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + dto.getCode() + "&grant_type=authorization_code"; JSONObject session = restTemplate.getForObject(url, JSONObject.class); String openid = session.getString("openid"); if (openid == null) { return R.fail("登录失败:" + session.getString("errcode")); } // 2. 首次登录自动建号,昵称头像留空由用户补全 User user = userService.findOrCreateByOpenid(openid); // 3. 签发自定义 token,有效期 7 天,前端放请求头 String token = jwtUtil.sign(user.getId(), 7 * 24 * 3600); return R.ok(new LoginVO(token, user.getId(), user.getNickname())); }参数上要留意三处:appid和secret必须写在配置文件里而不是硬编码;grant_type固定为authorization_code,写错会直接返回 errcode;签发 token 时把userId编进去而不是 openid,业务层就不必再查一次用户表。返回体统一用R(code/msg/data)包装,前端只判断code === 200,能省掉大量重复分支。
2.3 setData 的键路径与 scroll-view 的机型差异
小程序没有虚拟 DOM,数据更新全靠setData。把整个列表重新赋值一次,在聊天页这种高频场景下会明显卡顿,正确做法是用键路径只更新变化的那一项:
// 只更新第 idx 条消息的已读状态,不重刷整个列表 const key = `msgList[${idx}].read`; this.setData({ [key]: true }); // 追加一条消息:用 concat 生成新数组,避免直接 push 触发不了视图 this.setData({ msgList: this.data.msgList.concat(newMsg) });setData的数据量有上限,单次建议控制在几十 KB 以内,聊天记录一定要做分页加载,别一次性把几百条消息塞进 data。另一个高频坑是滚动:iOS 上scroll-view如果没有显式高度,内容会撑开整页然后整页滚动,看起来就像「小程序滑不动」。稳定写法是给它一个确定高度,聊天页通常是:
/* 聊天页布局:固定头部 + 自适应滚动区 + 固定输入框 */ .chat-scroll { height: calc(100vh - 200rpx); }200rpx大致是顶部导航栏加底部输入框的占用高度,机型差异用calc加固定 rpx 兜底就够了。列表项渲染一定要带wx:key,键值用消息主键而不是数组下标,否则删除一条消息时后面的内容会串位。
3. SpringBoot 后端的权限切分与好友关系建模
后端要解决的问题比前端更抽象:谁在请求、能请求什么、好友关系怎么表达、私信怎么存。这三件事决定答辩时被追问「数据库三范式」能不能接得住。
3.1 管理员与用户模块的权限拦截
系统分管理员和用户两个角色,实现上不要在每个 Controller 里写if (role == admin),那是自找麻烦。常见做法是一个拦截器统一校验 token,并用 ThreadLocal 把当前用户 id 透传给业务层:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { String token = req.getHeader("token"); if (token == null || !jwtUtil.verify(token)) { resp.setStatus(401); return false; // 未登录直接拦下,不进入 Controller } String uri = req.getRequestURI(); if (uri.startsWith("/api/admin") && !"ADMIN".equals(jwtUtil.getRole(token))) { resp.setStatus(403); return false; // 登录了但不是管理员 } CurrentUser.set(jwtUtil.getUserId(token)); // 业务层随用随取 return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { CurrentUser.remove(); // 线程池复用,不清理会串号 } }preHandle里区分了 401 和 403 两种返回,前端就能分别做「跳登录页」和「提示无权限」两种处理。afterCompletion里的CurrentUser.remove()是最容易被漏掉的一行,Tomcat 线程复用同一个 ThreadLocal,不清理就会出现 A 用户读到 B 用户 id 的事故,答辩时如果老师问「多用户并发下怎么保证隔离」,这就是标准答案。
3.2 MySQL 五张核心表的设计
社交类系统的表设计没有什么玄学,关键是把「关系表」和「内容表」分清楚。好友申请是一张带状态的流水表,好友关系是申请通过后落的一条双向记录,私信则是只增不改的消息表。
-- 用户表 CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE COMMENT '微信唯一标识', nickname VARCHAR(32) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL, gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', school VARCHAR(64) DEFAULT NULL, role VARCHAR(16) NOT NULL DEFAULT 'USER' COMMENT 'USER/ADMIN', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 好友申请表:一条申请一行,状态流转 CREATE TABLE t_friend_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_id BIGINT NOT NULL, to_id BIGINT NOT NULL, reason VARCHAR(120) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已同意 2已拒绝', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_pair (from_id, to_id) -- 防同一对用户重复申请 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 私信表:只增不改,读状态单独一列 CREATE TABLE t_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_id BIGINT NOT NULL, to_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, is_read TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_from_time (from_id, create_time), KEY idx_to_time (to_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_pair (from_id, to_id)这个唯一索引是防重复申请的关键:用户手快连点两次,第二次插入直接被数据库拒绝,业务层捕获DuplicateKeyException转成「已发送过申请」的提示,比先查再插更可靠,也没有并发窗口。t_message上建的两个联合索引按(用户, 时间)排序,拉会话列表和拉聊天记录都走得到索引。
| 表名 | 作用 | 关键索引 |
|---|---|---|
| t_user | 用户与角色 | uk openid |
| t_dynamic | 动态内容与作者 | idx author_id |
| t_friend_apply | 好友申请流水 | uk (from_id, to_id) |
| t_friend | 已通过的好友关系 | uk (user_id, friend_id) |
| t_message | 私信消息 | idx (from_id, time) / (to_id, time) |
3.3 分层规范与分页接口
后端按 Controller → Service → Mapper 三层走,Controller 只做参数校验和返回包装,业务判断全部放 Service。分页统一用LIMIT offset, size,不要用前端传过来的page直接算,服务端要限制size上限:
@GetMapping("/dynamic/page") public R pageDynamic(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { int safeSize = Math.min(size, 50); // 防止前端传 size=9999 拖库 PageHelper.startPage(page, safeSize); List<DynamicVO> list = dynamicService.listWithAuthor(); return R.ok(new PageVO<>(list, ((Page<DynamicVO>) list).getTotal())); // 总条数给前端算页数 }Math.min(size, 50)这行是很多毕设被扣分的地方——分页参数不设上限,一个size=99999的请求就能把整表拉出来。PageHelper.startPage必须紧贴查询方法,中间插入其他查询会串到别的 SQL 上。PageVO里带上total,前端才能算出总页数并展示「没有更多了」。
4. 私信会话列表、未读数与真机联调排错
消息模块是这套系统里最能体现设计水平的部分,也是最容易在答辩现场被追问细节的地方。核心就三件事:会话列表怎么查、未读数怎么算、真机请求打不通怎么办。
4.1 会话列表的一条 SQL 解决
会话列表的本质是「按对方 id 分组,取每组最新一条消息」。用IF把「我发给别人」和「别人发给我」统一成同一个 peer_id,再分组取最大时间:
-- 参数:当前用户 id = ? SELECT t.peer_id AS peerId, MAX(t.create_time) AS lastTime, SUBSTRING_INDEX(GROUP_CONCAT(t.content ORDER BY t.create_time DESC), ',', 1) AS lastContent FROM ( SELECT IF(from_id = ?, to_id, from_id) AS peer_id, content, create_time FROM t_message WHERE from_id = ? OR to_id = ? ) t GROUP BY t.peer_id ORDER BY lastTime DESC LIMIT 20;内层子查询做的是方向归一化:如果这条消息是我发出的,peer_id 就是接收方;反之则是发送方。外层按 peer_id 分组,MAX(create_time)拿到每个会话的最后活跃时间。GROUP_CONCAT ... SUBSTRING_INDEX用来取每个会话的最后一句内容,MySQL 默认group_concat_max_len是 1024,消息内容长的话要么调大这个参数,要么就退一步:先查会话和最新时间,再用IN批量查这批 id 的最新消息,两次查询换来更稳的结果。数据量上到几万条时,第二种写法明显更抗压。
4.2 未读数计算与订阅消息推送
未读数不要每次去COUNT(*)一遍全表,社交系统的读取频率远高于写入,常见的折中做法是把消息拉取和已读回执分开:打开会话时前端调一次/message/read把该会话的is_read批量置 1,会话列表里的红点则由后端聚合返回。
-- 每个会话的未读数:只统计别人发给我的、且未读的 SELECT from_id AS peerId, COUNT(*) AS unread FROM t_message WHERE to_id = ? AND is_read = 0 GROUP BY from_id;WHERE to_id = ? AND is_read = 0走idx_to_time前缀,只要有to_id就能定位,is_read做过滤。如果未读消息量特别大,可以在t_friend表上加一个unread_count冗余字段,发送消息时+1,已读时清零,用一次更新的代价换掉一次聚合查询——这是典型的读写权衡,答辩时能讲清楚收益和代价就是加分项。
用户不在小程序里时,靠轮询是推不到提醒的,需要走微信订阅消息。流程是:前端在用户点击「接收提醒」时调wx.requestSubscribeMessage拿到授权,后端在写入消息后用用户的 openid 调一次下发接口。要注意订阅消息是一次授权一次下发,长期推送需要用户在每次关键操作时重新授权,这个限制必须在 PPT 里说清楚,否则演示时「怎么没收到通知」会很尴尬。
4.3 真机调试请求打不通的排查顺序
开发工具里一切正常,扫码到手机上就 401 或直接超时,这是小程序开发最典型的现场问题。排查按下面的顺序走,基本能覆盖九成情况。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 真机 401,工具正常 | token 存了但没带在请求头 | 统一在 request 封装里注入 token |
| 请求超时 | 请求域名未配置或非 https | 开发阶段勾选「不校验合法域名」,上线前配好域名 |
| 偶尔 401 | token 过期但没刷新 | 封装 401 拦截,重新 wx.login 换 token 后重试一次 |
| setData 后视图不更新 | 直接改 data 属性未走 setData | 用键路径写法更新嵌套字段 |
| 页面整体滑动而非局部 | scroll-view 无显式高度 | 给 scroll-view 设固定高度或 calc 高度 |
把请求统一封装成一个request()函数,是解决这类问题的根本办法:
// utils/request.js:统一注入 token、统一处理 401 重登 function request(options) { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ ...options, header: { 'content-type': 'application/json', token, ...(options.header || {}) }, success(res) { if (res.statusCode === 401) { // 只重试一次,避免死循环 require('./auth').login() .then(() => request(options)) .then(resolve).catch(reject); return; } resolve(res.data); }, fail: reject }); }); } module.exports = { request };注意 401 重试必须加次数上限或者用一个retrying标记,否则 token 一旦拿不到,请求会无限递归下去。另外真机上的wx.request对超时的默认容忍比工具里短,后端接口如果涉及多表联查,最好在 PPT 里把响应时间标出来,避免演示时卡顿。
5. 答辩PPT 与源码包的落地技巧
代码写完只是完成一半,答辩的分数很大程度上取决于「你能不能指着 PPT 上的模块,准确说出它对应哪段代码、哪张表」。
5.1 PPT 结构对齐代码包结构
不要把 PPT 做成 UI 截图集。评审最想看的是这张图:系统架构分层 + 模块划分 + 数据库 ER 关系。建议的顺序是——选题背景与意义(一页,说清传统管理方式的痛点);技术选型(一页,小程序 + SpringBoot + MySQL,写清为什么不用纯 JSP);系统架构图(B/S 分层);功能模块图(管理员模块 / 用户模块两大块,下面挂动态、私信、申请好友、通知四类功能);数据库设计(ER 图 + 核心表字段);核心流程时序(登录、申请好友、发私信三条链路各一页);最后是运行截图。
架构图上的每一层都要能指到具体的包名:controller、service、mapper、entity、vo、config。老师问「你的拦截器在哪一层」,你得能立刻说出config包下的WebMvcConfig里注册了AuthInterceptor。
5.2 演示脚本与初始化数据
现场演示最怕两件事:数据库是空的、网络不通。提前准备一份初始化脚本:
-- demo_init.sql:演示前执行,保证前后端都有数据可看 INSERT INTO t_user (openid, nickname, gender, school, role) VALUES ('demo_openid_001', '张同学', 1, '计算机学院', 'USER'), ('demo_openid_002', '李同学', 2, '外国语学院', 'USER'), ('admin_openid_000', '管理员', 0, '教务处', 'ADMIN'); INSERT INTO t_dynamic (author_id, content, create_time) VALUES (1, '今晚图书馆三楼组队自习,有一起的吗?', NOW()), (2, '分享一份数据结构复习提纲,需要的私信我。', NOW());演示脚本按「登录 → 浏览动态 → 搜索用户 → 发送好友申请 → 对方同意 → 私信聊天 → 查看未读红点 → 后台看通知」的顺序走一遍,每一步都对应 PPT 上的一页,节奏控制在五分钟内。演示账号用两个,一个手机开小程序、一个用开发者工具,这样好友申请的「对方同意」环节能当场跑通,比对着截图讲有说服力得多。
5.3 高频追问的答题锚点
答辩提问集中在三处:为什么这么选、并发怎么办、安全怎么保证。选型问题上,回答要点是「SpringBoot 内嵌 Tomcat、起步依赖减少 jar 冲突、约定优于配置」,把「不用写 web.xml、不用手动配 DispatcherServlet」讲出来就够了。安全问题上,锚点是三句话:openid 与 secret 只在服务端出现、token 拦截器统一鉴权并区分 401/403、SQL 全部走参数化查询不拼字符串。并发问题上,锚点是好友申请的唯一索引防重、消息表的读写分离思路、以及Math.min(size, 50)这类边界限制。
被问到「这个系统有什么不足」时,坦诚给出两条比硬撑更得分:一是消息实时性目前靠轮询加订阅消息,量级上来后需要引入长连接;二是未读数用实时聚合,数据量大时应改为冗余计数字段。说清改进方向,体现的是你对自己代码边界的认知深度。最后别忘了源码包里的README要写清 JDK 版本、MySQL 版本、导入 SQL 的顺序、小程序app.js里后端地址的修改位置——评审如果愿意亲手跑一次,这份说明就是你额外的分数。
本文还有配套的精品资源,点击获取