这几年陆陆续续帮几所高校的朋友做过社团管理系统说实话这类项目的需求复杂度远比第一眼看上去要高。今天要聊的这个项目是基于 Spring Boot 和微信小程序的高校社团管理系统核心解决社团创建审批、成员管理、活动发布报名、统计汇总这些日常事务。如果你正在做类似的毕设选题、课程项目或者单纯想给学校的社团联合会做一套内部工具这篇内容应该能帮你少走不少弯路。这个项目我把重点压在四块业务建模和角色权限怎么设计、微信登录这条链路怎么打通、活动报名如何避免名额超卖、以及部署上线时那些开发者工具里看不出来的坑。文章后面会穿插我在实际开发中踩过的真实问题比如 code 换 session 失败、真机调试请求发不出去、分页加载出现重复数据这些都是文档里很少提到的东西。适合什么人来读有 Spring Boot 基础至少能建项目、写一个 Controller以及正在做类似选题的同学。小程序端我会讲实现思路和关键代码不需要你有多深的前端功底能把页面画清楚就行。1. 项目定位与整体设计思路1.1 高校社团管理的核心痛点先说需求背景。高校社团的日常管理无非这几件事新生纳新、社团注册、活动审批、场地申请、成员管理、活动数据统计。在没有系统的年代大部分社团靠什么管Excel 表格加微信群。表格管成员名单微信群发通知活动报名靠接龙审批靠人肉跑腿。痛点非常明显信息分散。社团资料在社联的文件夹里活动通知在群里成员名单在社长的电脑里,互相之间完全割裂。通知触达差。群里接龙会被聊天消息冲掉重要通知经常淹没在斗图里面。审批流程不可追踪。社团注册申请提交上去之后卡在哪个环节没人知道申请人只能反复问。数据统计靠人工。到了期末总结社联干事需要挨个社团收集活动数量、参与人次最后汇总成 Excel熬几个通宵是常态。所以这个系统的价值在于统一信息入口、让审批流程线上化、活动数据自动沉淀。核心参与者有三类社联管理员、社长/副社长、普通学生。设计目标就是用微信小程序这个零成本入口把这三类人的日常操作全部搬到线上。1.2 为什么选 Spring Boot 微信小程序这套组合技术选型的时候我对比过好几套方案前后端分离用 Vue Spring Boot、纯服务端渲染、或者用 uni-app 做多端最终还是定了 Spring Boot 原生微信小程序。先说后端为什么用 Spring Boot而不是 Node.js 或 Python。一个很现实的原因Java 生态在高校场景里太成熟了。不管是你自己维护还是后面交接给别人Spring Boot 的资料最多、排错最容易光是这个问题别人一定遇到过这一点就能省大量时间。另外 Spring Boot 天然支持 MySQL、Redis、定时任务、文件上传这些基础能力不需要额外组装一个spring-boot-starter-web基本就齐了。内嵌 Tomcat 也让部署变得简单打包成一个 jar 就能跑。小程序端为什么用原生而不是 uni-app这个项目的目标平台很明确就是微信小程序不需要兼顾 App 和 H5。原生的好处是调试链路短、没有框架层封装带来的坑、官方文档直接对应。uni-app 虽然能一套代码多端复用但跨端框架在渲染和生命周期上有自己的性格出问题排查起来要绕一层对中小项目来说不划算。这套组合还有一个更根本的原因微信生态天然解决了身份问题。小程序用户不需要注册账号密码wx.login()拿 code后端换 openid用户身份自动建立对社团纳新这种场景体验极好——学生扫码打开小程序微信授权一下直接成为社团成员没有记忆密码的负担。1.3 角色体系与权限边界怎么划分权限设计是这类系统的地基角色定清楚了后面的表结构和接口才能稳定。系统里我设计了三种核心角色系统管理员对应社联/团委账号负责审批社团注册、活动上架、查看全局统计数据。社长/副社长管理本社团的成员、发布活动、查看报名名单、进行活动签到。普通学生浏览社团广场、申请加入社团、报名活动、查看公告消息。关于权限控制的实现方式我建议项目初期不要引入 Spring Security 全家桶。中小型项目用拦截器 自定义注解RequireRole(role 1)就够了代码量少、逻辑直白调试起来也快。Spring Security 虽然功能强大但配置项多、概念多过滤器链、ProviderManager 这些为了三个角色就去啃它性价比太低。等项目真正需要复杂授权模型了再迁移也不迟Controller 层代码改动很小。我实际用的是HandlerInterceptorThreadLocal的组合拦截器校验 JWT 令牌、解析用户 ID 和角色存进ThreadLocalController 里通过自定义注解声明访问所需角色不满足就抛 403。Component public class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; } User currentUser UserContext.get(); if (currentUser null) { response.setStatus(401); return false; } int required requireRole.value(); if (currentUser.getRole() required) { response.setStatus(403); return false; } return true; } }这里有个细节要注意角色数值从小到大1 是管理员、2 是社长、3 是学生拦截器判断currentUser.getRole() required实际上不严格因为社长也能干学生的活、管理员也能干社长的活这样大小比较反而方便。但如果角色之间没有包含关系比如超级管理员和普通管理员完全平级就得用位运算或者Set判断不要套大小比较。2. 数据模型设计表结构决定系统上限2.1 核心表结构一览数据模型是整个系统的骨架我前后调整了三版才定下来。第一版把角色直接字段堆在用户表里后来发现一个学生可以同时加入多个社团在不同社团里身份还不同——在这个社团是社长在另一个社团可能只是个普通成员。所以角色和社团的关联必须单独建表。最终的核心表就六张用户表、社团表、社团成员表、活动表、活动报名表、审批记录表。用户表user字段类型说明idbigint主键自增openidvarchar(64)微信用户的唯一标识建唯一索引nicknamevarchar(64)微信昵称avatar_urlvarchar(512)头像地址phonevarchar(20)联系电话允许空roletinyint1超管 2社长 3学生默认create_timedatetime注册时间openid 是这条表的灵魂。用户第一次登录时系统不知道他叫什么名字但 openid 是稳定的可以先把记录建出来等用户完善资料再补信息。这个允许不完整记录先存在的思路尤其适合登录链路避免为了拿一个手机号就卡住用户的进入流程。社团表club字段类型说明idbigint主键namevarchar(64)社团名称categoryvarchar(32)分类兴趣爱好/学术科技/志愿服务introtext社团简介logo_urlvarchar(512)社团 Logoadvisorvarchar(32)指导教师founder_idbigint创建人用户 IDstatustinyint0待审核 1正常 2已驳回 3已解散create_timedatetime创建时间社团成员表club_member字段类型说明idbigint主键club_idbigint社团 IDuser_idbigint用户 IDrole_in_clubtinyint1社长 2副社长 3干事 4成员statustinyint0待审核 1已通过 2已退出join_timedatetime加入时间这里有个新手最容易踩的坑把用户在社团里的角色又放回user表导致用户只能属于一个社团。这是一个典型的一对多设计错误。club_member表里冗余role_in_club字段非常关键它让一个用户在不同社团担任不同职务成为可能也让社长的查询变得简单SELECT * FROM club_member WHERE user_id ? AND role_in_club IN (1,2)就能拿到他管理的所有社团。活动表activity字段类型说明idbigint主键club_idbigint所属社团titlevarchar(128)活动标题contenttext活动详情locationvarchar(128)活动地点start_timedatetime开始时间end_timedatetime结束时间max_countint名额上限signup_countint当前已报名人数statustinyint0草稿 1报名中 2已结束 3已取消活动报名表activity_signup字段类型说明idbigint主键activity_idbigint活动 IDuser_idbigint用户 IDsignup_timedatetime报名时间checkin_statustinyint0未签到 1已签到这张表最关键的是加一个联合唯一索引UNIQUE KEY uk_activity_user (activity_id, user_id)。它的作用是数据库层面的防重复报名兜底不管应用层怎么并发同一个用户对同一个活动最多只有一条记录。审批记录表approval_record字段类型说明idbigint主键biz_typetinyint1社团注册 2活动审批biz_idbigint业务 IDapplicant_idbigint申请人approver_idbigint审批人statustinyint0待审批 1通过 2驳回remarkvarchar(512)审批意见create_timedatetime提交时间handle_timedatetime处理时间我把审批设计成一张通用表而不是社团表和活动表各搞一套审批字段。好处很直接以后想加一个新的审批类型比如场地申请不需要改表结构加一个biz_type枚举就行。坏处是多了一步关联查询但对我们这个量级的数据完全不是问题。2.2 状态字段设计思路状态字段全部用tinyint数字不要用字符串。这里面有讲究字符串状态虽然可读性好但一旦代码里出现大小写不统一、前后缀不一致查数据你就会怀疑人生。数字状态配合数据字典表或者代码里的常量类查询时映射成文字后面做筛选统计也方便。我习惯额外建一张sys_dict数据字典表存状态码和文字的对应关系。不要小看这个表到了写统计报表的时候、小程序端展示状态标签的时候你会感谢当初多建了它。2.3 索引设计实战索引不用太贪覆盖高频查询就行。我实际创建的索引就这几个user.openid唯一索引登录时按 openid 查用户必须快。club_member联合索引(club_id, status)查一个社团的成员列表。club_member联合索引(user_id, role_in_club)查用户管理的社团。activity联合索引(club_id, status)查某个社团的活动列表。activity_signup联合索引(activity_id, user_id)上面说了同时起到唯一约束和查询加速的作用。设计索引的原则是从查询语句反推。先把接口要用的 SQL 写出来再看哪些字段需要建索引不要上来就给所有字段建那样不仅浪费磁盘写入速度还会变慢。3. 后端核心环节实现3.1 微信登录链路从 code 到 Token微信登录的完整链路是小程序调用wx.login()获取一个临时凭证 code把这个 code 传给后端后端拿着 code 加上自己的 appid 和 appsecret去微信官方接口jscode2session换 openid。拿到 openid 之后查库如果是新用户就注册然后签发自己的登录令牌 JWT 返回给小程序。后续所有请求带着这个 JWT后端验证令牌即可。核心代码长这样RestController RequestMapping(/api/auth) public class AuthController { Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; Autowired private RestTemplate restTemplate; PostMapping(/login) public ResultLoginVO login(RequestBody LoginRequest request) { String url String.format( https://api.weixin.qq.com/sns/jscode2session?appid%ssecret%sjs_code%sgrant_typeauthorization_code, appid, secret, request.getCode()); MapString, Object resp restTemplate.getForObject(url, Map.class); if (resp null || resp.get(openid) null) { log.error(微信登录失败: {}, resp); return Result.error(登录失败); } String openid (String) resp.get(openid); User user userMapper.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setRole(3); user.setNickname(微信用户); userMapper.insert(user); } String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(new LoginVO(token, user)); } }这里有几个关键点都是文档里不会明说但实际让人掉坑的地方appid 和 secret 绝对不能出现在小程序端。小程序端代码是打包在用户手机上的任何人反编译都能看到secret 泄露等于别人能冒充你的小程序调用微信接口。code 是一次性的有效期为 5 分钟用完即废同一 code 禁止重复调用换取。如果接口被重复请求微信会返回40163 code been used错误。微信返回的session_key不要当成会话凭证来用。它的用途是解密用户手机号、运动数据这些敏感信息不是用来做登录态判断的。我们的会话凭证是自己签发的 JWT。JWT 我用的是jjwt库生成和校验都很简单。令牌里只放用户 ID 和角色不放大对象不然令牌体积膨胀每次请求携带消耗流量。public class JwtUtil { private static final String SECRET your-secret-key-please-change; private static final long EXPIRE 7 * 24 * 3600 * 1000L; public static String createToken(Long userId, Integer role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parse(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }JWT 的 key 生产环境一定要放到环境变量或者配置中心里不要硬编码在代码里。默认 key 没改就直接上线的话别人能用你的 key 伪造令牌等于系统裸奔。3.2 活动报名的并发控制防止名额超卖这是整个项目最需要动脑子的地方。场景是这样的活动名额 50 人开放报名瞬间 200 个学生同时点立即报名如果不做控制系统中的报名人数可能远超 50。我见过很多项目在这里直接用先查询剩余名额小于 0 就不让报名这在高并发下完全没有意义——两个请求同时读到剩余名额是 1都判断可以报名然后都执行插入最后实际报名 2 人。正确的做法是用数据库层面保证原子操作不要自己在 Java 代码里做判断。各路方案对比如下方案思路适用场景推荐度乐观锁条件更新UPDATE ... WHERE signup_count max_count中小规模并发最推荐Redis 分布式锁先抢分布式锁再操作数据库大规模高并发秒杀过度设计悲观锁SELECT FOR UPDATE锁行其他事务阻塞等待并发高但业务简单可用但影响读性能应用层同步锁synchronized或Lock单机单实例多实例部署失效社团活动这个场景同时报名人数撑死几百数据库的条件更新绰绰有余。核心就一条 SQLUpdate(UPDATE activity SET signup_count signup_count 1 WHERE id #{activityId} AND signup_count max_count) int increaseSignupCount(Long activityId);这个 SQL 的巧妙之处在于把检查名额和扣减名额合并成了一个原子操作数据库的行锁保证不会出现两人同时突破上限。int rows increaseSignupCount(...)如果 rows 为 0说明更新没有影响到任何行——也就是名额已满直接报错返回。完整报名流程要做到事务一致Transactional(rollbackFor Exception.class) public void signup(Long activityId, Long userId) { // 1. 插入报名记录利用唯一索引防重复 try { ActivitySignup signup new ActivitySignup(); signup.setActivityId(activityId); signup.setUserId(userId); signupMapper.insert(signup); } catch (DuplicateKeyException e) { throw new BizException(您已经报过名了); } // 2. 扣减名额条件更新 int rows activityMapper.increaseSignupCount(activityId); if (rows 0) { // 名额已满抛出异常触发回滚报名记录也被回滚掉 throw new BizException(活动名额已满); } }注意这里的顺序先插报名记录再扣名额。如果反过来先扣名额再插记录一旦插入失败就要回滚虽然也能实现但多一次失败重试的隐患。先插入可以利用唯一索引及早拦截重复报名避免无谓的名额扣减。事务的rollbackFor也必须设置成Exception.class因为BizException默认情况下 Spring 只在RuntimeException时才回滚如果你抛的是自定义异常而不继承RuntimeException事务不会回滚后果就是报名记录留下但名额没扣。3.3 通用审批流程怎么实现审批流程设计的核心是让流程可追踪。社团注册流程是学生提交申请 - 系统创建一份审批记录 - 社联管理员看到待办 - 通过或驳回 - 状态回写到社团表。实现上我分了两步走。第一步创建社团和提交申请同时完成社团状态设为 0待审核同时插入一条approval_recordbiz_type1、biz_id社团ID、status0。第二步管理员审批时在同一个事务里更新approval_record的状态和club.status。Transactional public void approve(Long recordId, Integer approveResult, String remark) { ApprovalRecord record approvalRecordMapper.selectById(recordId); if (record null) { throw new BizException(审批记录不存在); } if (record.getStatus() ! 0) { throw new BizException(该申请已处理过); } record.setStatus(approveResult); record.setRemark(remark); record.setApproverId(UserContext.get().getId()); record.setHandleTime(new Date()); approvalRecordMapper.updateById(record); // 回写业务状态 if (record.getBizType() 1) { Club club clubMapper.selectById(record.getBizId()); club.setStatus(approveResult 1 ? 1 : 2); clubMapper.updateById(club); } }这里要特别提防的就是重复审批。我在更新前特意查了record.getStatus() ! 0这个判断在高并发场景下其实不保险两个请求同时读到 status0都往下走但实际场景里管理员手动点击审批的操作频率很低这个风险可以接受。要真正堵死应该把更新语句写成条件更新UPDATE approval_record SET status? WHERE id? AND status0影响行数为 0 就说明已经被处理过。3.4 定时任务与统计报表活动状态流转是个容易被忽略的点。活动创建之后状态是 1报名中但到了结束时间不会自己变成 2已结束。谁去改不能等用户刷新页面时懒加载去改——那样统计就会乱。我给系统加了个简单的Scheduled定时任务每小时扫一次Scheduled(cron 0 0 * * * ?) public void autoCloseActivities() { activityMapper.autoCloseExpired(); }对应 SQLUPDATE activity SET status 2 WHERE status 1 AND end_time NOW()统计报表对于社联管理员来说是刚需。比如每个社团本学期举办了多少场活动、累计报名人次SELECT c.name, COUNT(a.id) AS activity_count, IFNULL(SUM(a.signup_count), 0) AS total_signup FROM club c LEFT JOIN activity a ON a.club_id c.id WHERE c.status 1 AND a.create_time #{semesterStart} GROUP BY c.id ORDER BY total_signup DESC这种统计用 MySQL 直接 Group By 就行数据量在小几百条的场景下毫秒级返回不需要额外引入报表引擎。4. 小程序端实现要点4.1 项目结构与页面规划小程序端我采用原生开发目录结构按页面维度拆pages/ ├── index/ # 首页社团广场社团卡片流 ├── club-detail/ # 社团详情成员列表、活动列表 ├── club-manage/ # 社长管理台成员审批、活动发布 ├── activity/ # 活动列表按时间倒序 ├── activity-detail/# 活动详情 报名入口 ├── profile/ # 我的信息展示、我的社团、我的报名 utils/ ├── request.js # wx.request 封装带 token 与 401 处理 ├── auth.js # 登录态管理页面数量控制在六七个就行尽量减少页面跳转层级小程序页面栈最多十层跳太深会有页面栈已满的白屏问题这个后面细说。4.2 请求封装与登录态刷新请求封装是前端所有接口调用的入口我把wx.request包了一层统一做三件事加 token 请求头、统一错误提示、以及处理 401 自动重新登录。const request (method, url, data) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method, data, header: { Authorization: Bearer getToken() }, success(res) { if (res.statusCode 401) { handleTokenExpired().then(() { // 重新登录后重放一次原请求 request(method, url, data).then(resolve).catch(reject); }); return; } if (res.data.code 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail(err) { reject(err); } }); }); };这里面有一个很容易被忽略的坑多个请求同时收到 401每个请求都触发重新登录就会并发调用多次wx.login()既浪费又可能因为wx.login()互斥导致部分请求失败。解决办法是用一个refreshPromise变量把刷新登录态的 Promise 存起来后续的 401 请求都等待这同一个 Promise 完成let refreshPromise null; function handleTokenExpired() { if (!refreshPromise) { refreshPromise new Promise((resolve) { wx.login({ success(res) { requestWithoutAuth(POST, /api/auth/login, { code: res.code }).then((data) { setToken(data.token); resolve(); }); } }); }).finally(() { refreshPromise null; }); } return refreshPromise; }这个细节虽然代码量不大但对体验影响很大。没有这个处理的时候我调试时经常看到后端日志里出现多个 code 同时请求其中一个成功其余全部报 code 已被使用。4.3 列表页加载与滚动分页社团广场和活动列表都是典型的无限列表。微信小程序里做分页加载官方推荐onReachBottom触底加载。实际我会加上节流锁和末页标识两个保护Page({ data: { list: [], page: 1, pageSize: 10, hasMore: true, isLoading: false }, loadList() { if (this.data.isLoading || !this.data.hasMore) return; this.setData({ isLoading: true }); request(GET, /api/club/list?page this.data.page pageSize this.data.pageSize) .then((data) { this.setData({ list: this.data.list.concat(data.records), page: this.data.page 1, hasMore: data.records.length this.data.pageSize }); }) .finally(() { this.setData({ isLoading: false }); }); }, onReachBottom() { this.loadList(); } });hasMore的判断逻辑很关键返回的记录数等于 pageSize说明还有下一页少于 pageSize 说明到底了。如果不加这个标识用户滑到底之后会一直请求最后一页空数据白白消耗流量和服务器资源。4.4 真机调试的常见障碍开发工具里跑得欢一上真机就歇菜这几乎是所有小程序新手的必经之路。最常见的两个障碍第一域名白名单。wx.request的 URL 必须在微信公众平台后台配置为合法域名而且必须 HTTPS。开发调试时可以在开发者工具里勾选不校验合法域名但真机和体验版完全不吃这一套域名没配好直接fail报url not in domain list。第二预览和真机调试的区别。预览模式下请求走的是你的开发机网络如果后端跑在localhost:8080手机上请求不到。要么用局域网 IP 后端要么直接把后端部署到测试服务器用 HTTPS 域名访问后者更像真实环境。5. 部署与运维5.1 服务器环境与配置这套系统的资源需求不高2 核 4G 的云服务器完全够用同时支撑几百人使用没有问题。我用的环境是 CentOS 7 MySQL 8.0 JDK 11 Nginx。如果不想自己装 MySQL用 Docker 起一个也很方便但新手阶段建议直接 yum 安装少一层容器排查成本。application-prod.yml关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/club_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} servlet: multipart: max-file-size: 10MB max-request-size: 50MB wx: appid: ${WX_APPID} secret: ${WX_SECRET}数据库密码、微信的 appsecret 这些敏感信息一律用环境变量注入不要写在配置文件里提交到代码仓库。5.2 构建部署流程部署流程总结下来就四步Maven 打包mvn clean package -DskipTests生成club-system.jar。上传服务器scp club-system.jar root服务器IP:/data/app/。启动服务nohup java -jar club-system.jar --spring.profiles.activeprod app.log 21 。配置 Nginx 反向代理。Nginx 这块有两件事要做一是把/api/路径反向代理到后端的 8080 端口二是把/uploads/路径映射到本地存储的图片目录。server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/cert/server.crt; ssl_certificate_key /etc/nginx/cert/server.key; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /data/app/uploads/; } }注意proxy_pass末尾的/很容易写错。如果写成http://127.0.0.1:8080没有斜杠访问/api/club/list时后端收到的路径是/api/club/list如果写了斜杠http://127.0.0.1:8080/后端收到的是/club/list前缀被剥掉。两种写法对应不同的后端路由设计搞混了就会出现 404还很难排查我在这上面吃过亏。5.3 HTTPS 为什么是刚需微信小程序从 2018 年起强制要求 request 接口必须是 HTTPS这是硬性规定。原因也简单小程序内容要过微信审查明文 HTTP 传输有被篡改的风险所以一律要加密。部署时去申请一个免费 SSL 证书绑定域名即可证书续期记得配自动刷新别等过期了才发现到时候小程序所有请求全部白屏。5.4 图片存储方案本地目录还是云存储我第一版用的是本地存储 Nginx 静态映射操作简单直接PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) suffix; File dest new File(UPLOAD_DIR, fileName); file.transferTo(dest); return Result.success(/uploads/ fileName); }本地存储的问题在于应用是多实例部署的话图片写到实例 A 磁盘请求到了实例 B 就找不到文件。解决办法很粗暴单实例部署不扩容对社团系统完全可以。要是以后真有水平扩展需求再换云存储也不迟把FileService抽成接口就能平滑迁移。6. 常见问题与排查技巧实录6.1 微信登录报错40029 与 40163 现场还原项目联调那天前端同事突然喊登录挂了。我打开日志一看微信接口返回40029 invalid code。这个错的意思是 code 无效要么过期了、要么 appid 不匹配。排查链路是这样的先看后端日志里拼的 URL 中的 appid 是不是正确的小程序 appid。曾发生过测试环境和正式环境的 appid 混了。code 过期。wx.login()返回的 code 有有效期前端拿到 code 之后如果隔了很久才调后端接口自然会失效。正确做法是点击登录按钮时立即获取 code、立即调用后端。前端把同一个 code 用了两次第二次就会报40163 code been used。这种情况通常是前端在页面多次触发了登录逻辑或者 401 自动重试时把同一个请求重发了。我的建议是在后端登录接口打全程日志把收到的 code 打出来、把微信返回结果打出来、把每个分支打清楚。日志是最好的排错工具比你在那猜快得多。6.2 并发报名测试为什么还是超卖有些同学看完方案以为加个synchronized就万事大吉但部署多实例之后锁是各管各的照样超卖。我用 Jmeter 做并发测试时也踩过坑——并发数压到 200最后报名人数是 51多了一个。查了半天发现是Transactional没生效。事务不生效的几个典型原因对照自查Spring Boot 启动类上加没加EnableTransactionManagement。低版本不加确实能用但高版本要求显式开启。Transactional加在了非 public 方法上失效。同类内部调用A 方法调同类的 B 方法B 上有Transactional但是 A 没标记事务此时 B 的事务不生效。因为 Spring 事务基于代理同类调用不走代理。解决方式是把报名逻辑独立到一个 Service 里Controller - ServiceA - ServiceB 这样跨类调用或者用Autowired注入自身代理对象来调用。6.3 小程序预览正常手机预览就报 403这个场景我印象很深。开发工具勾选了不校验合法域名接口在工具里全通真机预览登录就报 401 或者 403。原因基本是域名没配置或者证书有问题。排查清单小程序后台 - 开发管理 - 开发设置 - 服务器域名确认request合法域名里加了你的 API 域名。域名必须是备案过的没备案的域名在部分手机上报错方式还不一样有的直接请求失败。HTTPS 证书是否有效。可以在手机浏览器直接访问https://你的域名/api/club/list看返回结果证书无效会直接提示不安全。本地调试时后端起在localhost真机当然访问不到要用局域网 IP 或者线上环境。6.4 上拉加载出现重复数据这个坑是典型的分页参数错误。现象是列表加载前两页正常第三页开始出现第一页的重复条目。原因几乎永远是一个page 参数没有正确累加。页面第一次加载 page1onReachBottom触发后应该加载 page2但如果 page 的累加写在了不正确的回调位置或者请求并发导致 page 一致就会重复。比如上拉两次触发两个并发请求两个请求的 page 都是从 data 里读的同一个值 2就拿到两遍第二页。解决方式是我上面代码里的isLoading锁请求未完成时新触发的加载直接 return保证同一时间只有一个请求在执行从根上杜绝并发导致的分页错乱。6.5 文件上传成功但图片不显示上传接口返回 200图片 URL 也拿到了但小程序里image标签就是显示不出来。排查发现是 Nginx 的静态路径配置错了图片实际存放在/data/app/uploads/但alias配成了/data/app/upload/少了个s。这种低级错误最浪费时间排查方式是直接在浏览器访问图片 URL看返回的是图片还是 404能快速定位是 Nginx 路径问题还是后端存储问题。另一个显示相关的小知识如果图片 URL 的域名和业务域名不一致小程序 image 组件默认可以访问任意 HTTPS 图片但开发者工具中有时会提示不在以下合法域名列表中这个可以忽略不影响线上真机显示。6.6 数据统计口径不要用count(*)硬脑补做统计报表时最容易犯的错是把活动表记录数当成活动数量忘了过滤掉草稿和已取消状态。我在设计时就强调了状态字段的语义草稿、报名中、已结束、已取消是四个不同状态统计时必须用WHERE status IN (1,2)这样的条件明确口径否则报表数字对不上社联老师一问就露馅。类似的还有成员统计退出社团的成员已经从status1改成status2统计活跃人数时要过滤掉别把历史数据算进去。最后再分享一点做这类系统的个人体会。这个项目做完我最大的感受是Spring Boot 和小程序本身不难真正的复杂度在业务边界划分和并发一致性处理上。如果你准备做类似的系统一定要先把角色权限、状态流转和数据字典理清楚再动手写代码否则后面返工极其痛苦。活动报名的并发控制用数据库自身的条件更新已经足够迷信 Redis 分布式锁反而把问题搞复杂了。还有一个很实用的建议部署上线前拿真机完整走一遍流程——从微信授权登录、浏览社团、申请加入、社长审批、发布活动、学生报名、到管理后台看审批和统计。开发者工具里能跑通和手机上能跑通完全是两回事每个环节都亲手点一遍比你写一百行单元测试都管用。
阅读完成 · 觉得有帮助?