微信小程序社团管理系统:Java后端与BaseDAO前后端闭环解析

发布时间:2026/10/22 21:50:56
微信小程序社团管理系统:Java后端与BaseDAO前后端闭环解析
简介基于微信小程序的学生社团管理系统设计源码面向高校计算机相关专业学生期末大作业、案例设计等场景。系统涵盖社团创建与维护、活动发布与报名、公告通知、成员管理、财务收支记录等核心模块帮助学习者理解小程序从界面设计到前后端交互的完整开发链路。资源包共368个文件压缩后约36.82MB。其中42个java源码及class编译文件展示后端实现59个jar为依赖库36个xml用于配置映射19个wxss与16个wxml构成小程序前端25个js与25个html支撑Web端管理31个png及15个jpg为界面素材另有sql数据库脚本可直接导入建表。当前已有147人学习该资源对完成期末大作业、案例设计或二次开发的读者该源码提供可复用的项目分层、统一API接口、规范数据库设计及完整业务闭环既可作为课程设计基础框架也可作为研读微信小程序与Java服务端协同开发的实战参考。1. 期末大作业选到社团管理系统这套源码把前后端闭环全给齐了期末大作业选到社团管理系统最拖进度的不是画页面而是小程序请求发出去后端就是不给数据这个黑匣子。这套基于微信小程序的学生社团管理系统源码就是一份可以直接上手的案例设计后端是 Java 分层结构Controller、BaseDAO、工具类分别对应接口层、数据层和通用处理小程序端则是标准的 WXML、WXSS、JavaScript 工程。活动发布、报名、收藏、评论、入社申请、社团风采都有现成接口和页面兜底数据库脚本也配套好了。赶期末大作业的同学可以拿它当起点把原本两周的联调工作压缩到一晚上想入门微信小程序开发的人也能从这套代码里看到完整的前后端调用闭环而不是零散的知识点。2. 源码包的 Java 后端从 Controller 到 BaseDAO 的调用链与模块归属拿到源码包先别急着点运行。先把后端目录结构过一遍这套代码的后端是典型的 Java Web 分层风格对外暴露的是几个注解式 ControllerApiController 处理登录等通用请求ActivityController、ActivityApplyController、ActivityCollectController、ActivityCommentController、StunionApplyController、SceneryController 按业务路径各自承接接口数据访问统一走 BaseDAODateUtil 和 StringUtil 两个工具类负责日期格式化与字符串校验。整体没有引入重框架属于打开代码能看懂逻辑的课设风格。对期末大作业反而是优点——答辩时老师问每一层在干什么你能从头讲到尾。2.1 从 Controller 类名反推模块建立接口地图先看类名再看里面每个方法接口地图就出来了。下表是这套源码里 Controller 与业务模块的对应关系以及每个模块最典型的接口路径后面改代码、加功能都要以这张表为起点。Controller业务模块典型接口ActivityController活动发布与管理GET /api/activity/list、GET /api/activity/detailActivityApplyController活动报名POST /api/apply/add、POST /api/apply/cancelActivityCollectController活动收藏POST /api/collect/add、GET /api/collect/listActivityCommentController活动评论POST /api/comment/add、GET /api/comment/listStunionApplyController入社申请POST /api/stunion/apply、GET /api/stunion/statusSceneryController社团风采展示GET /api/scenery/listApiController登录与统一入口POST /api/login请求从微信小程序到数据库的完整链路是小程序端 wx.request 发起 HTTP 请求应用服务器根据每个 Controller 上的 WebServlet 路径映射把请求分发到对应方法Controller 调 BaseDAO 拼 SQL 查 MySQL拿到结果后转成 JSON 返回给小程序端渲染。这条链路里Controller 只做参数接收和结果返回不直接写 JDBC 代码这是这套源码在结构上做得比较干净的地方。以活动模块为例Controller 里常见的写法是这样// ActivityController活动模块的接口入口 WebServlet(/api/activity/*) public class ActivityController extends HttpServlet { private BaseDAO baseDAO new BaseDAO(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path req.getPathInfo(); // 例如 /list、/detail resp.setContentType(application/json;charsetUTF-8); PrintWriter out resp.getWriter(); if (/list.equals(path)) { // 活动列表按创建时间倒序返回核心字段 String sql SELECT id, title, location, start_time, end_time, max_people, apply_count, cover_image FROM activity ORDER BY create_time DESC; ListMapString, Object list baseDAO.queryList(sql); out.write({\code\:0,\data\: toJson(list) }); } else if (/detail.equals(path)) { // 活动详情接收 id 参数查单条 String id req.getParameter(id); MapString, Object row baseDAO.queryOne( SELECT * FROM activity WHERE id ?, id); out.write({\code\:0,\data\: toJson(row) }); } else { out.write({\code\:404,\msg\:\接口不存在\}); } } }这里有几处需要说明。WebServlet是 Servlet 3.0 的注解式路由省掉了 web.xml 里一大段映射配置req.getPathInfo()取到的是/api/activity/后面的路径所以一次 doGet 里可以用 if-else 区分 list 和 detail 两个接口。toJson方法常见做法是用 Gson 或 fastjson 实现把ListMap转成 JSON 字符串如果你拿到的版本是 Spring Boot 写的则直接用RestController加返回值就行思路完全一样。baseDAO.queryList和queryOne是数据访问层的两个核心方法下面详细拆。2.2 BaseDAO 与 JDBC 封装50 行看懂数据层BaseDAO 是这套源码里所有 Controller 共用的数据访问基类核心就三个方法queryList 查列表、queryOne 查单条、update 执行增删改。代码写法如下// BaseDAO统一 JDBC 访问Controller 层不出现任何连接管理代码 public class BaseDAO { // 连接参数按你本地 MySQL 环境改库名、账号、密码 private static final String URL jdbc:mysql://localhost:3306/club_db?useSSLfalsecharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; // 通用查询返回 ListMapString, Object每行是一个 Map public ListMapString, Object queryList(String sql, Object... params) { ListMapString, Object result new ArrayList(); // try-with-resources连接、语句、结果集自动关闭 try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement(sql)) { // 占位符参数逐个绑定拒绝拼接 SQL for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { ResultSetMetaData meta rs.getMetaData(); int columnCount meta.getColumnCount(); while (rs.next()) { MapString, Object row new HashMap(); // 用列名当 keyController 层拿到的是字段名可读的 Map for (int i 1; i columnCount; i) { row.put(meta.getColumnLabel(i), rs.getObject(i)); } result.add(row); } } } catch (SQLException e) { e.printStackTrace(); } return result; } // 查单条queryList 的封装没数据返回 null public MapString, Object queryOne(String sql, Object... params) { ListMapString, Object list queryList(sql, params); return list.isEmpty() ? null : list.get(0); } // 通用更新insert / update / delete 都走这里 public int update(String sql, Object... params) { int affected 0; try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } affected ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } return affected; } }这套封装的三个要点值得在答辩时专门讲。第一所有 SQL 都走 PreparedStatement 的占位符绑定参数用setObject逐个设进去而不是把前端传来的值直接拼进 SQL 字符串——这是防 SQL 注入最基本、也最容易被课设评委追问的点。第二try-with-resources保证 Connection、PreparedStatement、ResultSet 用完自动关闭不会把数据库连接耗尽。第三用ResultSetMetaData动态取列名Controller 层拿到的 Map 的 key 就是数据库字段名后续就算给表加列DAO 层代码也不用改。这种每次操作新建一个连接的写法性能上不算好但在课设规模下完全够用而且逻辑最直白。如果以后想上生产把这里换成 Druid 或 HikariCP 连接池Controller 层的代码一行都不用动——这就是把数据访问抽到 BaseDAO 里的最大价值。数据库这块如果压缩包里附带了建库脚本常见课设包里都会配一份 club_db.sql拿到手先建库、再导入、最后改 BaseDAO 里的连接参数顺序别反不然启动后端必报连接失败。2.3 DateUtil 与 StringUtil工具类为什么单独抽一层DateUtil 和 StringUtil 在源码包里体积不大但地位很关键。先看代码// DateUtil统一日期格式接口层用它格式化时间字段 public class DateUtil { private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); // Date 转字符串null 直接返回空串避免小程序端拿到 null public static String format(Date date) { if (date null) return ; return SDF.format(date); } // 字符串转 Date解析失败返回 null由调用方决定怎么处理 public static Date parse(String str) { if (str null || str.isEmpty()) return null; try { return SDF.parse(str); } catch (ParseException e) { return null; } } }// StringUtil参数校验与手机号脱敏 public class StringUtil { // 判空同时处理 null、空串、纯空格三种情况 public static boolean isEmpty(String str) { return str null || str.trim().isEmpty(); } public static boolean isNotEmpty(String str) { return !isEmpty(str); } // 手机号打码中间四位替换成星号列表页展示用 public static String maskPhone(String phone) { if (phone null || phone.length() 7) return phone; return phone.substring(0, 3) **** phone.substring(7); } }为什么要单独抽出来而不是散落在各个 Controller 里一个很现实的原因报名、评论、入社申请好几个接口都要校验参数是否为空如果每个 Controller 自己写一遍很容易出现一个接口报参数不能为空另一个接口报参数缺失这种前后端文案对不上的情况。统一走 StringUtil判断逻辑和报错风格就收敛到一处。DateUtil 同理数据库里存的是 DATETIME转成 JSON 时如果每个接口各自 new SimpleDateFormat格式很容易不统一小程序端new Date(2024-10-15 19:00:00)能解析new Date(2024-10-15T19:00:00.00000:00)也能解析但显示到页面上就乱了。统一格式化之后整个系统的时间字段风格一致前后端都省心。3. 核心业务接口拆解活动、报名、收藏、评论、入社申请的请求与响应后端分层看懂之后接下来把五个核心模块的接口逐个过一遍。这一章不看页面只看接口层面的请求参数、响应格式和状态字段设计。把这些约定搞清楚小程序端的页面就能照着接口写反过来改后端也不会把前端带崩。3.1 活动列表与详情字段裁剪、排序与分页活动模块是整套系统的主干。列表接口返回的核心字段包括 id、title、location、startTime、endTime、maxPeople、applyCount、coverImage。注意字段名在返回 JSON 里统一用驼峰小程序端 JS 里直接访问item.title、item.applyCount不用做额外映射。活动列表接口的响应格式约定如下{ code: 0, data: [ { id: 1, title: 秋季招新见面会, location: 第一教学楼 201, startTime: 2024-10-15 19:00:00, endTime: 2024-10-15 21:00:00, maxPeople: 80, applyCount: 42, coverImage: /upload/cover/20241001.jpg } ], msg: success }三个约定需要记住。一是code为 0 表示成功非 0 表示失败msg放给用户看的提示文案二是applyCount是已报名人数需要和maxPeople一起判断前端要不要显示已满徽标三是coverImage的路径是相对路径小程序端wx.request拿到的图片 URL 必须拼上后端 IP 加端口这个问题在避坑章会单独展开。活动详情的接口在列表基础上多了活动介绍 description、联系人 contactName、联系电话 contactPhone 等字段。课设规模的活动数量通常在一百条以内我的习惯是先不做分页把列表接口的 SQL 加一个LIMIT 100兜底把复杂度留给真正需要分页的评论模块。3.2 报名链路先查人数、再插入、最后更新计数活动报名是这套系统里逻辑最完整的接口核心流程是三步查当前报名数、判断是否超员、插入报名记录并更新活动表的报名数。对应 ActivityApplyController 里的典型实现// 报名返回 0 成功1 已报过2 人数已满 public int apply(int activityId, int studentId) { // 第一步确认该学生没有重复报名 MapString, Object exists baseDAO.queryOne( SELECT id FROM activity_apply WHERE activity_id ? AND student_id ?, activityId, studentId); if (exists ! null) { return 1; // 已报过前端提示请勿重复报名 } // 第二步查当前已报名人数和活动的 max_people 比较 MapString, Object activity baseDAO.queryOne( SELECT max_people, apply_count FROM activity WHERE id ?, activityId); int maxPeople ((Number) activity.get(max_people)).intValue(); int applyCount ((Number) activity.get(apply_count)).intValue(); if (applyCount maxPeople) { return 2; // 人数已满 } // 第三步插入报名记录同时把活动表报名数 1 baseDAO.update( INSERT INTO activity_apply(activity_id, student_id, create_time) VALUES(?, ?, NOW()), activityId, studentId); baseDAO.update( UPDATE activity SET apply_count apply_count 1 WHERE id ?, activityId); return 0; }这个实现逻辑清楚但有一个并发隐患两个学生同时报名最后一个名额时两步查人数可能同时读到同样的值导致超员。课设答辩时老师最爱问这一点。常见做法是给activity_apply表加UNIQUE(activity_id, student_id)唯一索引配合报名接口里先查再插的逻辑至少能防住重复报名要彻底防超员就得用事务加行锁或者把UPDATE activity SET apply_count apply_count 1 WHERE id ? AND apply_count max_people这种带条件更新的写法作为兜底。这一层在避坑章会给一条具体的排查路径。3.3 收藏与评论写操作接口的幂等性收藏和评论都是高频写操作接口设计要点不同。收藏接口 ActivityCollectController 必须先查再插防止同一学生重复收藏同一活动// 收藏重复点击时做幂等处理第二次点击直接取消收藏 public int toggleCollect(int activityId, int studentId) { MapString, Object exists baseDAO.queryOne( SELECT id FROM activity_collect WHERE activity_id ? AND student_id ?, activityId, studentId); if (exists ! null) { // 已收藏删除记录相当于取消收藏 baseDAO.update( DELETE FROM activity_collect WHERE activity_id ? AND student_id ?, activityId, studentId); return 1; // 返回已取消收藏 } // 未收藏插入记录 baseDAO.update( INSERT INTO activity_collect(activity_id, student_id, create_time) VALUES(?, ?, NOW()), activityId, studentId); return 0; // 返回收藏成功 }这个接口的语义特意设计成切换前端按钮不用维护复杂的状态每次点击都调同一个接口根据返回值切换图标即可。评论接口 ActivityCommentController 相对简单主要是校验内容不能为空、不能超长然后插入评论表并按创建时间倒序返回列表// 评论列表按时间倒序附带评论者昵称和头像 String sql SELECT c.id, c.content, c.create_time, s.nickname, s.avatar FROM activity_comment c LEFT JOIN student s ON c.student_id s.id WHERE c.activity_id ? ORDER BY c.create_time DESC; ListMapString, Object list baseDAO.queryList(sql, activityId);这里用到了 LEFT JOIN 把评论表和 student 用户表关联起来一次查询就把评论内容、评论人昵称、头像全部带出来小程序端不需要再发第二次请求。要注意的是评论接口在插入前必须做内容长度校验超过 500 字直接拒绝否则数据库字段定义得够长页面布局却会被长文本撑爆。3.4 入社申请审批流的状态字段设计StunionApplyController 管的是学生申请加入社团的流程它不是直接插一条记录就完事而是有一个状态字段贯穿整个申请链路。常见的状态定义是0 待审批、1 已通过、2 已拒绝必要时加 3 已撤销。申请提交接口只负责插入一条 state0 的记录审批动作由社团管理员端把 state 改成 1 或 2。// 提交入社申请校验学生是否已经在社团避免重复申请 public int applyStunion(int studentId, int stunionId) { // 检查是否已是社团成员 MapString, Object member baseDAO.queryOne( SELECT id FROM stunion_member WHERE student_id ? AND stunion_id ?, studentId, stunionId); if (member ! null) { return 1; // 已在社团 } // 检查是否已有待审批申请 MapString, Object pending baseDAO.queryOne( SELECT id FROM stunion_apply WHERE student_id ? AND stunion_id ? AND state 0, studentId, stunionId); if (pending ! null) { return 2; // 重复申请 } baseDAO.update( INSERT INTO stunion_apply(student_id, stunion_id, reason, state, create_time) VALUES(?, ?, ?, 0, NOW()), studentId, stunionId, reason); return 0; }状态字段是这套代码里最值得在答辩时展开的设计所有审批类功能都能用同一个状态机思路去表达申请提交是状态从无到 0审批通过是 0 变 1拒绝是 0 变 2。小程序端我的申请页面只需要按 state 字段渲染不同状态的卡片样式后端加一个查询接口前端不用改逻辑就能展示全部状态。当初设计时要是把状态直接写成字符串待审批已通过现在改一个状态就得改好几处页面还容易因为文案不一致闹乌龙。4. 小程序端联调wx.request 封装、登录态与数据类型对齐后端接口约定清楚之后小程序端的活就好干了。微信小程序的开发工具里可以直接导入前端工程目录调试时打开不校验合法域名选项就能在模拟器里请求本地后端。这一章把联调期最核心的三块讲透请求封装、登录态、数据类型对齐。4.1 wx.request 统一封装Promise 化与错误码处理小程序原生的 wx.request 是回调风格每个页面都写 success/fail 会非常啰嗦而且后端返回的 code 非 0 的情况还要在每个页面单独处理。常见做法是抽一个 utils/request.js把所有请求统一封装成 Promise// utils/request.js统一请求封装 const BASE_URL http://localhost:8080/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json }, success(res) { // 后端约定 code 为 0 表示成功非 0 表示业务失败 if (res.data.code 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || 操作失败, icon: none }); reject(res.data); } }, fail(err) { // 网络层失败后端没起、IP 不通、域名没配都会走这里 wx.showToast({ title: 网络异常请稍后重试, icon: none }); reject(err); } }); }); } module.exports { request };封装之后页面里调用接口变成这样// pages/activity/list.js加载活动列表 const { request } require(../../utils/request); Page({ data: { list: [] }, onLoad() { this.loadList(); }, async loadList() { try { const data await request(/activity/list); this.setData({ list: data }); } catch (e) { // 错误已经在 request 层处理过这里不用重复弹提示 console.error(加载活动列表失败, e); } } });这段封装有两个参数需要根据后端实际调整BASE_URL 的端口要和后端容器或框架的端口一致header 里的 Content-Type 如果后端用的是 request.getParameter 取值可以去掉这行让小程序按表单格式提交避免拿不到参数的经典翻车现场。小程序的工程结构是标准的app.json 注册页面路由pages 目录下每个页面由 .wxml、.wxss、.js、.json 四个文件组成。这套源码里活动列表页、活动详情页、报名页、我的社团页都在这套结构里改样式主要动 .wxss改交互逻辑主要动 .js。页面跳转用wx.navigateTo({ url: /pages/activity/detail?id item.id })详情页在 onLoad(options) 里拿 options.id 再调接口。4.2 登录态wx.login 拿 code后端换 openid社团系统需要知道当前操作的是哪个学生小程序端拿用户身份的标准路径是 wx.login 拿 code把 code 发给后端后端再调微信接口换 openid。源码包里 ApiController 里的登录接口就是干这个的// pages/login/login.js微信登录换取 openid wx.login({ success(res) { if (res.code) { // 把 code 发给后端 /api/login wx.request({ url: http://localhost:8080/api/login, method: POST, data: { code: res.code }, success(r) { if (r.data.code 0) { // 后端返回 openid 和 userId存到全局变量 getApp().globalData.openid r.data.data.openid; getApp().globalData.userId r.data.data.userId; wx.switchTab({ url: /pages/index/index }); } } }); } else { console.error(微信登录失败, res.errMsg); } } });后端的登录接口要做的事有两步用 code 调微信接口换 openid再拿 openid 去 student 表查对应的学生记录查不到就自动注册一条新记录。课设阶段如果不想真实对接微信登录也可以简化成输入学号直接登录但接口设计上仍然要保留 openid 这层概念这样后续接真实微信登录时不用改前端。一个常见的坑是只在登录页内存里存了 userId活动报名、收藏这些接口在小程序重新启动后就拿不到 userId 了。我的习惯是把 userId 同时写进wx.setStorageSync启动时在 App.onLaunch 里读出来放回 globalData保证整个会话期间身份不丢。这个细节虽然不起眼但在演示时退出小程序再进来报名按钮报错的场景能让老师当场扣分。4.3 数据类型对齐时间、布尔、图片路径三个高频翻车点前后端联调 80% 的问题出在数据类型对不上。下面是这套系统里最容易出问题的三个点。时间字段是最典型的。后端数据库 DATETIME 经过 JDBC 取出再序列化如果中间没有统一格式化小程序端收到的可能是2024-10-15T19:00:00.00000:00这种带 T 和时区的字符串iOS 上 new Date() 解析会得到错误的时间甚至 NaN。解决方法是后端统一走 DateUtil 格式化前端展示时再统一用 formatTime 工具函数处理两层都别省。布尔值也容易翻车。后端返回的 isEnd 如果直接从 MySQL 的 tinyint 取出来JSON 里是 0 或 1不是 true 或 false小程序端if (item.isEnd)的判断在值为 0 的时候不会进分支显示逻辑刚好反掉。统一在后端把 tinyint 字段转成 Boolean或者前端判断时写item.isEnd 1二选一但整个项目必须只选一种。图片路径是第三个高频坑。后端返回的 coverImage 是相对路径/upload/cover/20241001.jpg小程序端image src直接填这个值会请求到小程序的本地资源目录必然 404。常见做法是后端返回完整 URL即服务器地址加端口加相对路径或者前端在请求封装里统一拼前缀。我一般让后端直接返回完整 URL省得每个页面各拼一遍也避免改一处漏一处的尴尬。5. 避坑指南部署、联调、答辩前最常踩的 5 个坑这五条坑是我和几个做课设的同学把整套系统跑通之后总结出来的每条都是实打实让人卡过半天以上的货。建议动手部署之前先把这一章过一遍至少能省出一个晚上的调试时间。5.1 模拟器能通、真机不通域名白名单与不校验选项现象小程序模拟器里请求本地后端一切正常一用手机预览就全部失败控制台报request:fail。原因模拟器可以勾选不校验合法域名真机预览在开发版也可以勾但一旦切到体验版或正式版微信会强制校验 request 合法域名本地 IP 和 localhost 都不在名单里请求直接被拦。解决开发调试阶段用开发版 不校验合法域名需要给老师演示时最简单的是用一台电脑开热点手机连热点访问电脑的局域网 IP再勾选不校验域名全程不需要公网服务器。如果要上线再把后端部署到有公网 IP 的服务器并在微信公众平台把域名加进 request 合法域名列表同时后端必须支持 HTTPS。5.2 时间字段带 T 和时区iOS 上日期显示成 NaN现象活动开始时间在 iOS 上显示NaN-NaN-NaNAndroid 上却显示正常。原因后端返回的时间字符串是2024-10-15T19:00:00.00000:00这种 ISO 8601 格式iOS 的 JavaScript 引擎解析这种带 T 和时区的字符串时兼容性差直接解析失败。解决后端返回前统一用 DateUtil 格式化成yyyy-MM-dd HH:mm:ss前端展示前再包一层 formatTime 函数兜底。两层都做了基本不会再出现时间显示问题。排查这类问题先看后端接口返回的原始 JSON确认字符串格式别在前端反复改代码那是浪费时间。5.3 MySQL 8.x 连接报 SSL 和时区错误现象后端启动后第一次查库就报Communications link failure控制台里还有 SSL 和 serverTimezone 的警告。原因MySQL 8.x 默认开启 SSL 校验且 JDBC 连接 URL 里没带时区参数驱动要求显式指定。解决连接 URL 改成jdbc:mysql://localhost:3306/club_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。如果还报驱动类找不到检查引入的 mysql-connector-java 版本是否和 MySQL 8.x 匹配8.0 以上的驱动类名是com.mysql.cj.jdbc.Driver而不是老的com.mysql.jdbc.Driver。这个坑几乎人手一份因为课设电脑上装 MySQL 8.x 的比例太高了。5.4 报名人数超员并发抢最后一个名额现象活动限 50 人两个学生同时点报名最终报名记录 51 条。原因报名接口是先查 apply_count再 insert两个请求同时读到 apply_count49都认为没满都插入了记录经典的并发竞态。解决至少加一道UNIQUE(activity_id, student_id)唯一索引防重复报名要防超员把更新报名数量的 SQL 改成带条件的UPDATE activity SET apply_count apply_count 1 WHERE id ? AND apply_count max_peopleaffected rows 为 0 说明名额已被抢完直接返回人数已满。这两条加上答辩时老师再问并发问题你就有实际改造过的方案可以讲。5.5 后端端口被占用或接口路径对不上现象后端启动报Port 8080 was already in use或者小程序发请求得到 404。原因上一个没关干净的后端进程占着端口或者 Controller 的 WebServlet 路由路径与小程序端 BASE_URL 里的路径拼接之后不一致。解决端口冲突在 Windows 上用netstat -ano | findstr 8080找到占用进程并结束macOS/Linux 用lsof -i :8080。路径对不上时打开小程序的 utils/request.js 看 BASE_URL再对照后端的 WebServlet 注解路径确保BASE_URL path拼出来的完整路径和后端映射一致。有一个习惯可以帮你减少这类问题所有接口路径全部以小写开头、统一不带尾斜杠前后端都按这个约定写。6. 期末改造加分给这套源码低成本加功能并拿下答辩6.1 最小改动加一个活动签到功能答辩想拿高分光跑通原有功能不够最好有一个自己能讲清楚的小改造。签到功能是成本最低的选择数据库加一张 sign_in 表字段就四个 id、activity_id、student_id、create_time加唯一索引防重复签到后端在 ActivityApplyController 里加一个 sign 方法逻辑和报名类似先查有没有报名记录、再查有没有签到过小程序端在活动详情页加一个签到按钮调新接口返回成功就换成已签到。整个改造新增代码不超过 80 行但答辩时你能讲清楚我加了一张表、一个接口、一个页面按钮这比复述原有功能有说服力得多。6.2 答辩演示脚本与高频提问演示时按这条路径走节奏最稳先打开数据库展示表结构再启动后端看控制台日志最后进小程序模拟器依次演示活动列表、查看详情、报名、收藏、评论、入社申请最后展示我的申请状态变化。演示完主动说一句我改过报名接口加了防重复报名的唯一索引。这句话基本能堵住一半的追问。老师常问的三个点提前准备一是 BaseDAO 为什么用 PreparedStatement——答防 SQL 注入参数绑定而不是拼接二是报名并发怎么解决——答唯一索引加条件更新兜底三是 openid 登录流程——答 wx.login 拿 code后端换 openid再关联 student 表。这三个问题答顺了答辩基本就稳了。从那以后我每次拿到课设源码第一件事都是先把数据库脚本跑通、再打开后端、最后才点小程序模拟器这个顺序帮我避掉了至少一半的联调坑。希望帮到你。本文还有配套的精品资源点击获取