简介基于SpringBoot与Uniapp的前后端分离校园圈项目源码面向校园社区与信息化建设场景覆盖校园集市、表白墙、论坛、失物招领、校园墙、跳蚤市场六个板块。后端集成SpringBootMyBatisPlusSpringSecurityquartzMinIORedisMongoDB前端基于Uniapp与uView适合学习企业级前后端分离架构与多模块社区系统。压缩包共564个文件大小约1.85MB。113个java文件对应后端接口与业务逻辑127个vue文件覆盖功能页面253个js文件承担前端交互与工具封装另有sql数据库脚本、yml/properties配置、xml映射文件及md说明文档结构清晰便于按模块检索。通过源码可学习SpringSecurity权限控制、Redis缓存、MongoDB非结构化存储及定时任务调度等中间件实战集成数据库脚本可直接建表配合前端页面快速跑通完整流程。已有75人学习适合具备Java和Vue基础、期望完整掌握校园社区类项目开发全流程的开发者。1. 校园圈项目到底是什么一套能直接跑的 SpringBoot Uniapp 前后端分离源码接手一套校园社区系统时大多数人的心理预期是“把代码拿到手、改个 IP、跑起来”就能用。但现实通常像同事那样——后端是 SpringBoot 搭的前端是 Uniapp 写的前后端分离架构本身不算复杂可光是把数据库脚本导进去、把登录接口调通、让 H5 和小程序都指向同一个服务端就折腾了两天。这份“校园圈”源码解决的就是这个问题它把校园论坛、表白墙、失物招领、跳蚤市场集市这类功能收敛在一个项目里后端用 SpringBoot 提供 REST API前端用 Uniapp 做多端展示两者通过 JSON Token 交互。适合正在做课程设计的学生、想接校园类外包的开发者以及想完整走一遍前后端分离实战的入门者。2. SpringBoot 后端拆解登录鉴权与帖子发布链路2.1 目录结构先分清三个包的职责再动手拿到源码后别急着点 Run先把 SpringBoot 后端目录结构过一遍。我拆过的校园类项目基本都是标准三层架构这个校园圈也不例外controller 收请求、service 写业务、mapper 管数据库entity 对应表结构config 放跨域和拦截器配置。com.campuscircle ├── config # 跨域配置、登录拦截器、文件上传路径 ├── controller # REST 接口只做参数接收和结果返回 ├── service # 业务逻辑层处理帖子、评论、点赞等 ├── mapper # MyBatis 数据访问层写 SQL 的地方 ├── entity # 和数据库表对应的实体类 ├── utils # JWT 工具、MD5 工具、统一返回结果 └── common # 统一返回体 Result、异常处理这里最值钱的其实是 config 包。跨域配置、登录拦截器、上传目录全部集中在这里你后面联调时遇到的大部分问题归根结底都是这三处的配置没对齐。我的习惯是拿到项目先打开配置文件看三个值端口号、数据库连接串、上传路径前缀确认这三个没问题再谈改功能。2.2 登录接口从数据库校验到 Token 下发是怎么串起来的登录是所有业务的前提因为后面每个接口都要靠登录返回的 Token 来识别用户身份。这个项目里登录接口的路径是/api/user/login前端传 JSON 格式的用户名和密码后端校验通过后返回一个 Token 字符串。RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; PostMapping(/login) public Result login(RequestBody LoginDto dto) { // 1. 参数校验用户名和密码不能为空 if (StringUtils.isBlank(dto.getUsername()) || StringUtils.isBlank(dto.getPassword())) { return Result.error(用户名和密码不能为空); } // 2. 查库校验密码入库前做了 MD5 摘要 User user userService.login(dto.getUsername(), MD5Util.md5(dto.getPassword())); if (user null) { return Result.error(用户名或密码错误); } // 3. 生成 Token有效期 7 天 String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.ok(new LoginVo(token, user.getNickname())); } }这段代码的逻辑分三步走。第一步是做参数非空校验避免空字符串打到数据库第二步是调 service 层查用户表这里注意密码不是明文存储而是先做一次 MD5 再拿去比对所以注册时存进库里的也必须是MD5Util.md5(密码)的结果否则永远登录不进去第三步是生成 TokenJwtUtil.createToken的第一个参数是用户 ID第二个参数是角色字段后续接口通过解析 Token 就能知道当前是谁在操作。常见做法是 Token 有效期设 7 天这样用户不用频繁重登但代价是泄密风险窗口变长。如果你要上线给学生用建议把有效期改成 2 小时再配合后端 Redis 做续期这个属于后续可优化的点不影响当前功能跑通。2.3 帖子发布内容校验与用户身份绑定帖子发布是校园圈的核心操作不管是表白墙、论坛吐槽还是集市商品本质上都是往 post 表里插一条记录。区别只在于类型字段 type 不同1 表示表白墙2 表示论坛帖子3 表示失物招领4 表示集市商品。PostMapping(/post/add) public Result addPost(RequestBody Post post, RequestHeader(token) String token) { // 1. 从 token 中解析出当前用户 ID Integer userId JwtUtil.parseToken(token); if (userId null) { return Result.error(401, 登录已过期请重新登录); } // 2. 补全帖子基础字段 post.setUserId(userId); post.setStatus(1); // 1正常0待审核-1已删除 post.setCreateTime(new Date()); if (StringUtils.isBlank(post.getContent()) || post.getContent().length() 500) { return Result.error(内容不能为空且不能超过500字); } // 3. 插入数据库 postService.addPost(post); return Result.ok(发布成功); }两个细节值得注意。第一个是RequestHeader(token)这个注解让 SpringBoot 自动从请求头里取 token 字段这是前后端分离项目里常见的身份传递方式——前端每次请求在 Header 里带上 token后端拦截器先解析它解析失败就直接拒掉。第二个是 open 状态码设计我见过不少项目把所有帖子直接设为可见但表白墙这种场景最好加一层审核开关status 设为 0 表示待审核管理员看到内容不合适可以改成 -1 隐藏比直接从数据库删记录安全得多。拦截器的作用相当于给所有接口加了一道门。下面这段是 config 包里最常见的拦截器写法public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains(/login) || request.getRequestURI().contains(/images)) { return true; } String token request.getHeader(token); Integer userId JwtUtil.parseToken(token); if (userId null) { response.setStatus(401); return false; } request.setAttribute(userId, userId); return true; } }这段的核心是把 userId 塞进 request 里这样 controller 里就不用反复解析 Token直接(Integer) request.getAttribute(userId)就能拿当前用户。放行/images很关键否则前端上传的图片浏览器访问时会被拦截器挡掉。3. Uniapp 前端拆解请求封装与多端兼容的三个关键点3.1 页面结构tab 栏、列表页和发布页是怎么组织的Uniapp 前端和一个纯 Vue 项目的区别在于它多了一个pages.json这个文件记录了所有页面的路由、导航栏标题和 tab 栏配置。校园圈的页面划分并不复杂底部 tab 一般固定四个首页、发布、消息、我的。{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 校园圈 } }, { path: pages/square/square, style: { navigationBarTitleText: 广场 } }, { path: pages/publish/publish, style: { navigationBarTitleText: 发布 } }, { path: pages/message/message, style: { navigationBarTitleText: 消息 } }, { path: pages/mine/mine, style: { navigationBarTitleText: 我的 } }, { path: pages/detail/detail, style: { navigationBarTitleText: 详情 } } ], tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/square/square, text: 广场 }, { pagePath: pages/publish/publish, text: 发布 }, { pagePath: pages/message/message, text: 消息 }, { pagePath: pages/mine/mine, text: 我的 } ] } }tab 栏的每一项对应一个页面发布页放在中间 tab 是最常见的交互设计用户点一下就能直接进入发帖界面。detail 页不是 tab 页它是列表页点击跳转进来的详情页所以放在 pages 数组里而不是 tabBar 的 list 里。这个区分在 Uniapp 里很关键放进 tabBar 的页面实例是常驻的普通页面则是每次跳转重新创建。3.2 请求封装一个 request.js 兼容 H5、小程序和 App之前有同事问过我一个搜索量很高的问题uniapp 封装 H5 如何指向 2 个域名。实际场景是开发环境用一个域名生产环境用另一个域名而小程序的 request 合法域名还要在微信公众平台配置。如果每个页面都硬编码 baseURL后面换域名能让你改到怀疑人生。// utils/request.js const BASE_URL { development: http://localhost:8080/api, production: https://your-domain.com/api } // #ifdef H5 const baseURL BASE_URL[process.env.NODE_ENV] // #endif // #ifdef MP-WEIXIN const baseURL https://your-domain.com/api // #endif function request(url, method GET, data {}) { return new Promise((resolve, reject) { uni.request({ url: baseURL url, method, data, header: { Content-Type: application/json, token: uni.getStorageSync(token) || }, success: (res) { if (res.statusCode 401) { uni.navigateTo({ url: /pages/login/login }) return } if (res.data.code 200) { resolve(res.data) } else { uni.showToast({ title: res.data.msg, icon: none }) reject(res.data) } }, fail: (err) reject(err) }) }) } export default request关键点是用条件编译区分环境。#ifdef H5和#endif之间的代码只在编译到 H5 平台时才保留小程序端完全不会打进包里这样两边的 baseURL 就不会互相干扰。process.env.NODE_ENV在 H5 编译时由 uni-app 工具注入开发模式是 development发行模式是 production所以 H5 端天然支持“两个域名”的切换。统一给 Header 加 token 这个设计一定要保留。你不在封装层加就要在每个页面的 request 里反复写一遍遗漏任何一个页面就会出现“这个页面要登录、那个页面不要登录”的诡异问题。3.3 列表页和发布页从拉数据到提交的完整链路列表页最核心的逻辑是 onLoad 时调接口拿数据渲染列表。这里有个容易翻车的点Uniapp 的uni.request是异步的但如果接口数据量大直接塞进 data 里会给小程序带来渲染压力。常见做法是只保留当前页的数据下拉触底时做分页追加。// pages/index/index.vue export default { data() { return { list: [], page: 1, total: 0, loading: false } }, onLoad() { this.loadPosts() }, // 下拉触底加载下一页 onReachBottom() { if (this.list.length this.total) { this.page this.page 1 this.loadPosts() } }, methods: { async loadPosts() { const res await request(/post/list?page${this.page}size10) if (this.page 1) { this.list res.data.records } else { this.list [...this.list, ...res.data.records] } this.total res.data.total } } }page和size是分页参数后端用 MyBatis 的分页插件处理。第一次加载 page 为 1直接覆盖 list下拉触底后 page 加 1走追加逻辑。onReachBottom是 Uniapp 页面生命周期自带的触底事件小程序和 H5 都支持App 端需要配置enablePullDownRefresh配合使用。发布页面更简单表单提交时把内容 POST 到/post/add接口。唯一要注意的是 type 字段的联动如果用户选的是失物招领除了 content 内容还要额外传一个场地 location 字段如果选的是集市要传标题和价格。后端需要区分处理前端就按 type 动态显示对应表单项。4. 联调与部署避坑五个让我熬夜的翻车现场前后端分离项目第一次跑通重点不在代码多高级而在于把环境对齐。我拆这套校园圈源码时踩过不少坑下面这几条是最典型的按“现象 → 原因 → 解决”给你排好。4.1 登录接口通了却进不了首页拦截器拦住了所有请求现象Postman 里调登录接口能正常返回 Token但前端一登录就跳回登录页所有业务接口全部 401。原因拦截器里只放行了/login但前端调用登录接口时实际路径是/api/user/login。如果放行条件写的是contains(/login)倒能匹配上但更常见的是拦截器把整个/api/**都拦了又没有放行OPTIONS请求——浏览器跨域预检直接失败。解决拦截器里加一句话放行预检请求。if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }这行代码我每次写拦截器都会强制带上。浏览器在跨域场景下会先发一个 OPTIONS 预检请求探测后端是否允许当前域名访问这个请求没有业务含义拦截器直接放行即可。4.2 中文全部变成问号数据库连接串少了一个参数现象前端发的中文能正常看到请求参数但存进 MySQL 再查出来全是????。原因MySQL 表字段是 utf8mb4 编码但 JDBC 连接串没指定 characterEncoding默认用了系统编码中文就乱掉了。解决检查application.yml里的连接串确保characterEncodingutf8mb4和对应 url 参数写全。spring: datasource: url: jdbc:mysql://localhost:3306/campus_circle?useUnicodetruecharacterEncodingutf8mb4 username: root password: yourpassword另外检查 MySQL 服务端字符集show variables like character_set%出来如果是 latin1直接在 my.cnf 里把 default-character-set 设为 utf8mb4 再重启。这两个地方都要对缺一个都会乱码。4.3 图片上传一直失败静态资源映射和上传路径没对齐现象点击上传图片后一直转圈控制台报 404 或者 500。原因后端保存文件到本地磁盘/uploads目录但前端访问图片时走的是http://localhost:8080/images/xxx.jpg后端没有配置这个 URL 和磁盘目录的映射关系。解决在 SpringBoot 的 WebMvcConfigurer 里加静态资源映射。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceHandler(/uploads/**) .addResourceHandler(/images/**); } }前端的图片 URL 前缀和后端映射路径必须完全一致比如前端组装http://ip:8080/images/xxx.jpg后端就要把/images/**映射到实际磁盘目录。同时检查上传目录是否存在Windows 上常见路径是D:/upload/Linux 服务器上则要提前mkdir -p /data/uploads否则保存文件时报“系统找不到指定的路径”。4.4 H5 页面在服务器上请求 502跨域和 Nginx 转发缺一环现象本地调试一切正常打成 H5 包部署到 Nginx 后页面能打开但接口全部 502。原因H5 页面跑在 Nginx 域名下后端接口跑在 8080 端口的 SpringBoot 进程里。浏览器跨域请求没被正确转发或者后端跨域配置没放行新域名。解决两步走。后端跨域配置改成允许所有来源并开放所有方法Nginx 里把/api/路径反向代理到 Java 进程。location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }Nginx 的proxy_pass结尾带不带/语义完全不同。带/表示把匹配到的/api/去掉再转发不带则保留原路径。通用的做法是不带/让后端接口路径保持不变少一层适配代码。4.5 微信小程序里 Token 丢失同步存储和异步存储混用现象小程序里登录成功后能拿到 Token跳转页面后调用接口却提示 Token 为空。原因登录成功回调里用的是uni.setStorageSync但后续请求用的是异步读取或搞混了 key 名造成 Token 根本没存进去或者读错 key。解决把 Token 的存取统一到一个地方。我的习惯是在 login 页面成功后立即执行uni.setStorageSync(token, res.data.token)然后 request.js 里也用同步方式读取同一个 key。不要一套代码里同时出现 token 和 Token 两个命名大小写不一致会让你排查半天。5. 数据库设计与初始化从表结构到测试数据准备5.1 核心表梳理一张表撑起多类业务校园圈的表设计不算复杂核心是 post 表承担了表白墙、论坛、失物招领、集市四类内容的存储用 type 字段区分实现多态。下面这张表基本覆盖了这个项目的数据库全貌。表名用途关键字段user用户表id, username, password(md5), nickname, avatar, rolepost帖子/表白/失物/集市通用表id, user_id, type, content, image_urls, contact, status, create_timecomment评论表id, post_id, user_id, content, create_timelike_record点赞表id, post_id, user_id, create_timemessage系统消息/私信表id, from_user_id, to_user_id, content, is_read, create_timetype 字段是这个表设计的精髓。表白墙写 type1、论坛吐槽写 type2、失物招领写 type3、集市商品写 type4。列表页拉数据时只按 type 过滤比如首页看全部广场页选 type2 只看论坛帖子失物招领页只看 type3。这种设计用一张表撑起多种业务改动成本远低于每种业务各建一张表。5.2 初始化脚本导入与验证初始化脚本一般是一个campus_circle.sql文件里面包含建库、建表和少量基础管理员账号数据。导入命令在 Windows 和 Linux 上有细微差别。# 先建库再导数据 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS campus_circle DEFAULT CHARSET utf8mb4; mysql -uroot -p campus_circle campus_circle.sql # 导入后验证表数量 mysql -uroot -p -e USE campus_circle; SHOW TABLES;导入过程最常见的报错是Unknown collation: utf8mb4_0900_ai_ci这是 MySQL 8.0 默认的排序规则如果脚本是用 8.0 的 Navicat 导出的拿到 5.7 的库执行就会报这个错。解决方式是打开 sql 文件把所有utf8mb4_0900_ai_ci替换成utf8mb4_general_ci或者干脆用 8.0 数据库执行不用换。5.3 造一批测试数据页面不至于白屏新拉起来的项目最容易出现“页面能打开但列表空空如也”的情况不是代码有问题是数据库里没数据。比起手动一条条插入用 SQL 生成一批模拟数据效率高得多。-- 插入 5 个测试用户 INSERT INTO user (username, password, nickname, role) VALUES (student01, MD5(123456), 大一新生, 1), (student02, MD5(123456), 研二学长, 1), (teacher01, MD5(123456), 辅导员王老师, 2); -- 给每个用户发 3 条表白墙内容 INSERT INTO post (user_id, type, content, status) SELECT id, 1, CONCAT(今天在图书馆看到你想认识一下), 1 FROM user WHERE user_id 3;批量插入测试数据时注意 user_id 要对应真实存在的用户不然列表页渲染时会因为 join 不到用户信息而报错。也用 MD5 函数直接生成密文密码这样测试账号 student01/123456 可以直接登录不用再修改代码里的校验逻辑。6. 二次开发技巧四步新增一个“校园活动报名”模块这个校园圈项目跑通之后你大概率想加自己的模块。拿“校园活动报名”举例完整链路很短建表、写后端接口、写前端页面、联调验证四步就能走完。6.1 从建表到页面一次完整的小业务闭环第一步建表。活动报名需要一个活动表和报名记录表活动表存标题、时间、地点、人数上限报名记录表存用户和活动关系。activity # ID、标题、时间、地点、人数上限 activity_join # ID、活动ID、用户ID、报名时间第二步写接口。在 controller 层新增 ActivityController报名接口接收 activityId 和 userId。这里要注意的是报名前查一下是否已满员这是个典型的并发边界问题学生抢活动时同时提交会导致超报。PostMapping(/activity/join) public Result join(RequestBody JoinDto dto, RequestHeader(token) String token) { Integer userId JwtUtil.parseToken(token); Activity activity activityService.findById(dto.getActivityId()); if (activity.getJoinedCount() activity.getMaxCount()) { return Result.error(报名人数已满); } activityService.join(activity.getId(), userId); return Result.ok(报名成功); }第三步写前端页面。在 pages/activity/activity.vue 里用列表展示活动信息报名按钮点击后调/activity/join接口。关注一下按钮状态的切换已报名和已满员两种状态要置灰这个可以在接口返回后根据结果动态改。6.2 验证清单改完一个功能要过哪几关模块写完不能只试“正常路径”必须把异常路径也走一遍。我的做法是强制自己按下面这个清单测少一项都不算完成未登录时调报名接口应该返回 401 并跳转登录页报名时活动已满员应该提示“人数已满”而不是直接报错同一个人重复报名应该被后端 reject 掉前端按钮也要同步变灰申请名额达到上限后列表页剩余名额数字要立即变化这里靠的是重新拉列表接口。这套项目最大的价值不在于功能花哨而在于它把校园社区的常见业务完整串了一遍。从那以后我每次拿到源码第一件事就是先跑通登录接口和一个列表接口确认鉴权链路和数据结构都通了再去研究增加模块而不是先把所有代码读完再动手。这个习惯帮我避免了很多无效加班希望也能帮到你。本文还有配套的精品资源点击获取