微信小程序+SpringBoot聊天交友系统:登录态、好友关系与私信设计
简介围绕基于微信小程序的大学生线上聊天交友系统的毕业设计答辩场景这份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/loginpages/index/index动态列表、点赞GET /dynamic/pagepages/dynamic/dynamic动态详情、评论GET /dynamic/{id}pages/friend/apply搜索用户、申请好友POST /friend/applypages/message/chat会话列表、聊天窗GET /message/sessionpages/mine/mine我的资料、通知GET /notice/list2.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首次登录自动落库建用户再签发一个自己的 tokenJWT 或 UUID 存 Redis 都行后续所有接口只认这个 token。PostMapping(/auth/login) public R login(RequestBody LoginDTO dto) { // 1. code 换 openid / session_keysecret 只能放服务端 String url https://api.weixin.qq.com/sns/jscode2session?appid appId secret appSecret js_code dto.getCode() grant_typeauthorization_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业务层就不必再查一次用户表。返回体统一用Rcode/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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 好友申请表一条申请一行状态流转 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) -- 防同一对用户重复申请 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 私信表只增不改读状态单独一列 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_pair (from_id, to_id)这个唯一索引是防重复申请的关键用户手快连点两次第二次插入直接被数据库拒绝业务层捕获DuplicateKeyException转成「已发送过申请」的提示比先查再插更可靠也没有并发窗口。t_message上建的两个联合索引按(用户, 时间)排序拉会话列表和拉聊天记录都走得到索引。表名作用关键索引t_user用户与角色uk openidt_dynamic动态内容与作者idx author_idt_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); // 防止前端传 size9999 拖库 PageHelper.startPage(page, safeSize); ListDynamicVO list dynamicService.listWithAuthor(); return R.ok(new PageVO(list, ((PageDynamicVO) list).getTotal())); // 总条数给前端算页数 }Math.min(size, 50)这行是很多毕设被扣分的地方——分页参数不设上限一个size99999的请求就能把整表拉出来。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开发阶段勾选「不校验合法域名」上线前配好域名偶尔 401token 过期但没刷新封装 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里后端地址的修改位置——评审如果愿意亲手跑一次这份说明就是你额外的分数。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →