这可能是园区类美食平台最值得参考的一套前后端分离实现
今年在整理一个校园周边美食探索及分享平台时,我把整套技术栈定在了SpringBoot+Vue3+MyBatis+MySQL上。市面上的校园外卖、二手交易项目很多,但专门针对“校园周边美食探索+用户分享”这个细分场景做的前后端分离系统其实不算多,这套源码从功能设计到落地的踩坑过程,我觉得很值得拿出来从头讲一遍。
这套系统解决的核心问题很聚焦:学校周边的美食店铺分散、评价信息零散,学生想找“附近有什么好吃的、哪家评价靠谱、有没有人晒过实拍图”完全靠口口相传。平台把这些需求线上化:店铺信息聚合、菜单展示、用户发布美食分享帖、评论互动、收藏点赞,再加上后台对店铺和内容的管理。如果你正在做毕业设计、课设,或者想找一套Java全栈项目练手并实际把它跑起来,这篇文章应该能帮你省掉大量的试错时间。
我尽量不写流水账,把标题里提到的技术栈怎么落地、关键表结构怎么设计、哪些api实现起来最绕、前台和后台联调时有哪些坑,全部拆开讲。
1. 项目要解决的核心问题与用户链路设计
1.1 校园美食场景下的真实需求梳理
做这类平台最容易犯的错误,是上来就堆功能:购物车、订单、支付、骑手配送全加上。但“校园周边美食探索及分享平台”的定位不是外卖点餐,它更像一个校园版的大众点评+内容社区。
我梳理需求时先列了几个核心问题:
- 学生怎么发现学校周边有哪些店?—— 需要店铺列表、分类筛选、关键词搜索。
- 怎么判断一家店值不值得去?—— 需要用户评分、评论、实拍图分享。
- 用户凭什么愿意来这个平台?—— 需要有“分享”的激励场景:发帖子、晒探店图、被点赞收藏。
- 内容怎么保证不失控?—— 需要后台管理端,能审核店铺和帖子。
所以功能边界很清晰:C端(用户端)负责浏览店铺、查看详情、发布分享动态、互动;B端(管理端)负责店铺管理、分类管理、用户管理、内容审核。不需要做订单支付,把“探索+分享”这两个核心动作做扎实就够了。
1.2 用户端与管理端功能边界
我把整个系统拆成两个端,对应两套Vue3应用和两套后端接口模块:
用户端(前台)核心功能:
- 注册、登录(手机号+密码,JWT鉴权)
- 首页店铺推荐列表(按评分、距离、销量维度)
- 店铺分类浏览(快餐、火锅、奶茶、小吃等)
- 店铺详情:菜单、评分、评论列表、位置信息
- 探店分享帖:发布、浏览、点赞、收藏、评论
- 个人中心:我发布的帖子、我的收藏、我的评论记录
管理端(后台)核心功能:
- 管理员登录(独立账号体系)
- 店铺管理:审核、上架下架、编辑店铺信息
- 分类管理:维护店铺分类
- 分享帖管理:删除违规帖子
- 用户管理:查看用户列表、禁用账号
这套权限模型非常简单直接:前端控制路由跳转,后端通过拦截器校验JWT并区分角色。对比那些动辄引入SpringSecurity+OAuth2的重量级方案,这种轻量实现更容易读懂,也更符合“平台系统源码”的教学和扩展场景。
1.3 核心链路:从“发现店铺”到“分享种草”的闭环
这个平台最有意思的部分是业务闭环,用户的行为不是单向的“搜-看-走”,而是可以形成循环:
- 新用户进入首页,看到评分高的店铺推荐列表。
- 点进店铺详情,看到菜单、评论和用户发的探店帖。
- 到店消费后,用户在平台发布一条带图的分享帖。
- 其他用户刷到帖子,点赞、收藏、评论,同时被种草这家店。
- 帖子热度影响店铺评分和推荐权重,形成新一轮曝光。
我在实现时,让“分享帖”成为连接用户和店铺的桥梁,而不是割裂的两个模块。帖子表里直接冗余了店铺ID,用户发帖时可以关联探店对应的店铺。这样一个简单的设计就让首页既能按店铺维度推荐,也能按内容维度刷动态,后面做Feed流也留了扩展空间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端落地:SpringBoot+MyBatis的选型理由与分层设计
2.1 为什么选SpringBoot 2.7而不是SpringBoot 3.x
写代码之前先卡版本。说实话,如果从零学SpringBoot,直接上3.x没有太大问题,但这个项目我坚持用了SpringBoot 2.7.x,原因是它刚好处于一个非常舒适的兼容区间:
- JDK 8 / 11 / 17 都能跑,不用被强制上17。
- MyBatis、MyBatis-Plus、Druid、JWT等生态组件兼容性非常成熟。
- 绝大多数网上资料和教程都是基于2.x,排查问题容易得多。
如果你用的是SpringBoot 3.x,需要注意它基于Jakarta EE,javax.servlet要改成jakarta.servlet,很多老版本第三方库会直接无法兼容。这项目里如果当时选3.x,光是适配这些依赖就得额外消耗一两天。
我推荐跟着我这边的配置走:JDK 8 + SpringBoot 2.7.14 + MyBatis-Plus 3.5.3.1 + MySQL 8.0。
2.2 Maven多模块还是单模块?我选择了更直观的单模块
项目结构这块我犹豫过。理论上前后端分离项目,后端可以拆成common、system、business多个Maven模块,但考虑到这个项目是要给别人看源码、用来学习或做毕设的,单模块SpringBoot工程反而更直观。读者打开项目后,一眼就能看完controller、service、mapper分层的全貌。
我的包结构是这样的:
code复制com.campus.food
├── config # 配置类:跨域、拦截器、MyBatisPlus分页、文件上传
├── controller # 接口层:用户端controller / admin端controller
├── entity # 实体类
├── mapper # MyBatis Mapper接口
├── service # 业务逻辑层
│ └── impl
├── dto # 接收参数的对象
├── vo # 返回给前端的数据对象
├── utils # JWT、MD5等工具类
├── common # 统一返回结果、异常处理
└── FoodApplication.java
实体类用驼峰命名,表字段用下划线,MyBatis-Plus开启驼峰映射后无感转换。Mapper层基于MyBatis-Plus的BaseMapper,单表CRUD基本不用写SQL,复杂查询用@Select注解配合XML,兼顾开发速度和SQL可控性。
2.3 MyBatis与MyBatis-Plus:不只是插件那么简单
标题里写的是MyBatis,实际上我引入了MyBatis-Plus增强包。这不是偷懒,而是基于一个非常实际的判断:这个业务场景中80%的数据库操作是单表CRUD,用原生MyBatis手写这些SQL完全是在浪费时间。
MyBatis-Plus让我省力的几个点:
BaseMapper<T>直接提供insert、deleteById、selectById、updateById,单表操作一行代码都不用写SQL。- 分页插件
PaginationInnerInterceptor,配合Page<T>对象几行代码搞定分页。 LambdaQueryWrapper用lambda表达式构造查询条件,字段名从代码级硬编码变成编译期可检查,改表字段名不会因为SQL字符串拼错而线上炸掉。
举一个店铺列表分页的实际例子:
java复制@Override
public Page<ShopVO> getShopPage(int pageNum, int pageSize, String categoryId, String keyword) {
Page<Shop> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(categoryId), Shop::getCategoryId, categoryId)
.like(StringUtils.hasText(keyword), Shop::getName, keyword)
.eq(Shop::getStatus, 1)
.orderByDesc(Shop::getRating);
Page<Shop> shopPage = shopMapper.selectPage(page, wrapper);
// 转换为VO并填充分类名称、评分人数等冗余信息
return convertToVO(shopPage);
}
当然这有一个隐藏问题:如果SQL全交给MP生成,复杂多表关联的时候性能可能会翻车。所以像店铺详情页的“店铺+菜单+最新评论”这种聚合查询,我仍然手写了XML里的自定义SQL。MyBatis的灵活SQL控制 + MyBatis-Plus的开发效率,这样组合是这套技术栈里最有生产力的搭配。
2.4 数据库表结构设计:从表反推这个业务到底做了什么
数据库我用的是MySQL 8.0,字符集utf8mb4,排序规则utf8mb4_general_ci。很多人忽视字符集,等上传emoji表情时直接乱码才回头改表,那才是最难受的。分享帖里用户可能打emoji,所以utf8mb4是必须的。
核心表一共6张,我按业务依赖顺序列出来:
用户表 user
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 登录名 |
| password | varchar(100) | 加密后的密码,存MD5值 |
| nickname | varchar(50) | 昵称 |
| avatar | varchar(255) | 头像URL |
| role | tinyint | 角色:0用户 1管理员 |
| status | tinyint | 状态:0正常 1禁用 |
| create_time | datetime | 注册时间 |
店铺分类表 category:id、name、sort、status。
店铺表 shop
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar(100) | 店铺名称 |
| category_id | bigint | 所属分类 |
| image | varchar(255) | 店铺主图 |
| address | varchar(255) | 地址 |
| phone | varchar(20) | 联系电话 |
| rating | decimal(3,2) | 平均评分,默认4.50 |
| rating_count | int | 评论人数 |
| description | text | 店铺介绍 |
| status | int | 待审核/正常/下架 |
| create_time | datetime | 创建时间 |
菜单表 shop_menu:id、shop_id、name、price、image、is_recommend。
分享帖表 post
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 发帖用户ID |
| shop_id | bigint | 关联店铺ID,没有关联则填NULL |
| content | text | 帖子正文 |
| images | varchar(1000) | 多图URL,逗号分隔 |
| like_count | int | 点赞数 |
| favorite_count | int | 收藏数 |
| comment_count | int | 评论数 |
| status | tinyint | 0正常 1违规 2待审核 |
| create_time | datetime | 发布时间 |
评论表 comment:id、post_id、user_id、content、create_time。
点赞/收藏表 like_record:id、user_id、target_type(1帖子 2店铺)、target_id、create_time,用联合唯一索引uk_user_target防止重复点赞。
这几张表之间的外键我没有在数据库层面强约束,而是完全依赖应用层逻辑。原因很简单:外键会让插入、删除多出额外校验开销,分布式场景下几乎没人用物理外键。只要在接口层做好存在性校验和事务管理,数据一致性是可靠的。
3. 核心接口的实现细节与关键代码剖析
3.1 登录鉴权:JWT这样接入,简单又够用
管理端和用户端的登录鉴权,我用了同一个JWT工具类。用户登录成功后,根据用户ID和角色生成一个有效期7天的token,后续请求在Header中携带Authorization: Bearer <token>,由拦截器统一解析。
工具类的核心代码:
java复制public class JwtUtil {
private static final String SECRET = "your-256-bit-secret-key";
private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000;
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 parseToken(String token) {
return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}
}
拦截器里做的处理很简单:放行登录、注册、店铺列表等公开接口;对需要登录的接口,解析token并写入ThreadLocal或Request域中,controller里直接通过@RequestAttribute("userId")获取当前用户ID。
一个常见的坑是:token过期或者前端时间不准,导致请求明明带了token却被判为未登录。我在拦截器里对过期token做了专门的异常返回,前端401状态时自动清除本地token并跳转登录页,这个交互细节比后端写一堆状态码更实用。
3.2 店铺分页与关键词搜索:用MyBatis-Plus构造动态SQL
店铺列表是首页的核心接口,它需要同时支持分类筛选、关键词模糊搜索、按照评分/销量排序。使用LambdaQueryWrapper的好处是条件可以动态拼接:
java复制public Page<Shop> searchShops(Integer categoryId, String keyword, int page, int size) {
Page<Shop> p = new Page<>(page, size);
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(categoryId != null, Shop::getCategoryId, categoryId)
.and(StringUtils.hasText(keyword), w -> w
.like(Shop::getName, keyword)
.or()
.like(Shop::getDescription, keyword))
.eq(Shop::getStatus, 1)
.orderByDesc(Shop::getRating);
return shopMapper.selectPage(p, wrapper);
}
注意看这个.and(...)重载用法,它保证“名称OR描述匹配关键词”和“分类筛选”这两个条件是AND关系,而不是OR关系,避免WHERE条件拼乱。
实测下来这个纯MP方案在单表数据量低于10万条时性能完全够用,首页接口响应时间一般是15ms左右。如果店铺数据量涨到几十万,那时候再考虑引入Elasticsearch或者MySQL全文索引,但在毕设和学习场景下没必要提前复杂化。
3.3 发帖与图片上传:本地存储就够了,别一开始就上OSS
用户发探店帖时,需要上传最多9张图片。图片上传接口我用的是SpringBoot的MultipartFile,本地磁盘存储:
java复制@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error("上传文件为空");
}
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
String fileName = UUID.randomUUID().toString().replace("-", "") + ext;
String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date());
String dir = uploadDir + "/" + datePath;
File dirFile = new File(dir);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
file.transferTo(new File(dir + "/" + fileName));
return Result.success("/upload/" + datePath + "/" + fileName);
}
图片访问通过配置虚拟路径映射:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + uploadDir + "/");
}
}
这里一定要配置跨域,不然前端Vue3开发服务器访问不到上传接口。跨域配置放到CorsFilter或WebMvcConfigurer里。我第一版就漏了静态资源的跨域问题,前端列表页图片全部加载失败,排查了好一会才发现是addResourceHandlers和跨域配置叠加导致的。
什么时候需要上OSS? 如果这个系统是真正部署到云服务器,且图片量每天增长几百MB,那确实应该用对象存储。但如果只是课题展示、测试环境、个人服务器,本地磁盘+每日目录归档的方案足够稳定,成本几乎为零,而且没有第三方依赖,也更容易让后来者看懂。
3.4 点赞收藏和评论:事务和唯一索引解决数据一致性
点赞和收藏逻辑看起来简单,实际有三个坑需要防着:
第一个坑:重复点赞。 这条数据能出现两条就会污染计数。解决思路是like_record表加联合唯一索引(user_id, target_type, target_id),数据库层面硬挡死重复插入。代码里用try { insert } catch (DuplicateKeyException e) { 忽略 },实测比先select再insert的方式更高效也更安全。
第二个坑:点赞计数不一致。 用户点赞后post.like_count要+1,取消点赞要-1。我的做法是直接通过SQL原子更新,而不是先把计数查出来加1再更新回去:
java复制@Update("UPDATE post SET like_count = like_count + 1 WHERE id = #{postId}")
int incrLikeCount(Long postId);
@Update("UPDATE post SET like_count = like_count - 1 WHERE id = #{postId}")
int decrLikeCount(Long postId);
如果先select旧值,再加1,两个并发请求同时读到相同的旧值,更新时就会互相覆盖,最终计数比实际点赞次数少。这个细节在面试里经常被问到,也是实际线上项目必须考虑的点。
第三个坑:事务边界。 点赞和取消点赞涉及“插入点赞记录+更新计数”两步,必须用@Transactional包起来。我在service层写了likePost和unlikePost两个方法,方法内部先操作记录表再更新计数,任何一步失败都会回滚,不会出现“记录了点赞但计数没变”的脏数据。
评论模块比点赞稍复杂,但核心还是三个动作:插入评论、post的comment_count+1、返回最新评论列表。我用@Transactional保证插入和计数更新同时成功。评论列表查询采用分页,每页10条,按create_time倒序排列。因为评论属于高频写入,我没有在评论表上做复杂的索引优化,只加了(post_id, create_time)联合索引,足够满足当前负载。
4. Vue3前端:组合式API、状态管理与接口联调实战
4.1 为什么Vue3一定要用<script setup>语法
Vue3项目我选择了Vite作为构建工具,脚手架用npm create vite@latest初始化。第二个关键选型是使用<script setup>组合式API语法,而不是选项式API。
原因是显而易见的:Composition API按功能聚合代码,同一个功能的变量、方法、生命周期钩子写在一起,可读性比选项式的data/methods/computed三块分离好得多。一个店铺详情页的代码结构大概是这样的:
vue复制<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { getShopDetail } from '@/api/shop'
const route = useRoute()
const shopId = route.params.id
const shop = ref({})
const menuList = ref([])
const commentList = ref([])
const loading = ref(true)
const ratingText = computed(() =>
shop.value.rating ? shop.value.rating.toFixed(1) : '暂无评分'
)
const loadShopDetail = async () => {
loading.value = true
try {
const res = await getShopDetail(shopId)
shop.value = res.data.shop
menuList.value = res.data.menuList
commentList.value = res.data.commentList
} finally {
loading.value = false
}
}
onMounted(loadShopDetail)
</script>
所有相关状态放在一起,页面复杂的逻辑链路一眼就能读完,不用在data和methods之间来回跳跃查找。对新手来说,这种写法可能需要几天适应,但一旦习惯了几乎回不到选项式API。
4.2 Axios封装:拦截器统一处理token、错误码和401
前端和后端交互的核心是Axios。我封装了一个request.js统一处理三件事:设置BaseURL、自动附加token、统一处理响应错误。
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
ElMessage.warning('登录已过期,请重新登录')
} else {
ElMessage.error(error.message || '网络异常')
}
return Promise.reject(error)
}
)
这里有两个细节值得说明:
- BaseURL为什么是
/api而不是直接写后端地址? 因为生产环境最终是Nginx反向代理,前端静态资源和服务都在同一个域名下,/api代理到后端8080端口。开发时通过Vite的server.proxy配置转发到后端,这样代码里不需要硬编码任何IP,避免环境切换时还要改代码。这个配置贯穿整个开发过程,让前后端联调顺畅了很多。 - 为什么用
ElMessage而不是浏览器原生alert? Element Plus的提示组件不阻塞页面,视觉上也统一。判断响应码在拦截器里统一处理,业务页面就完全不用关心错误逻辑,只处理成功结果,代码会干净很多。
4.3 路由权限控制:动态路由和按钮级控制怎么做
用户端和管理端是两个独立的Vue应用,如果用统一登录状态,路由权限就得做好。这个项目的权限模型是简化过的:
- 用户端路由:
/login、/register、/、/shop/:id、/post/create、/user。 - 管理端路由:
/admin/login、/admin/dashboard、/admin/shop/list、/admin/post/list。
我在前端路由配置里使用meta字段标记需要登录的页面:
javascript复制{
path: '/post/create',
component: () => import('@/views/post/CreatePost.vue'),
meta: { requiresAuth: true }
},
{
path: '/user',
component: () => import('@/views/user/UserCenter.vue'),
meta: { requiresAuth: true }
}
然后在全局前置守卫里判断:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
管理端的路由守卫除了检查token,还要检查localStorage中存的role值是否为管理员。这种方式比后端返回权限菜单再动态注册路由简单得多,虽然灵活性差一些,但在这个体量的项目中已经非常够用。
4.4 前后端联调时最容易翻车的几个问题
整个项目开发过程中,联调的坑大概集中在这几个地方:
- 跨域问题。 Vite开发服务器默认端口5173,后端8080,端口不同必然跨域。我用Vite的proxy解决,而不是后端加
@CrossOrigin。配置在vite.config.js里:
javascript复制export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
- 日期格式问题。 后端返回的
LocalDateTime默认序列化格式是一长串时间戳格式,前端直接显示很难看。我在后端全局配置了Jackson的日期格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
-
Long型ID精度丢失。 数据库自增ID到了19位,前端JavaScript的Number精度只有16位,后端返回给前端时ID末尾几位会被自动转成0,导致详情页取不到数据。这个问题只有真实联调时才会遇到,空跑后端Swagger根本不会暴露。解决方式是在实体类主键上添加
@JsonSerialize(using = ToStringSerializer.class)强制转字符串。 -
图片路径被拼错。 前端用的是
baseURL: '/api',后端图片访问路径是/api/upload/**,但上传接口返回的URL是/upload/xxx.jpg。前端需要手动拼接/api前缀,或者让后端返回完整路径。我在后端返回图片URL时直接拼上资源前缀,前端拿到什么就显示什么,省掉一层心智负担。
5. 数据库设计的数据回显与统计查询
5.1 首页推荐的店铺评分:如何聚合计算
店铺平均评分这个数据,我做成了“实时计算+冗余存储”的组合。商铺详情页的评分,是通过汇总comment表中该店铺关联帖子的评分字段动态算出来的;但店铺列表页为了性能,直接读取shop.rating列。
这样设计的做法是:每次用户发布针对某店铺的评分后,后台异步任务重新计算该店铺的平均评分,并更新shop.rating和shop.rating_count。冷启动阶段可以使用一个定时任务,每天凌晨3点对所有店铺批量重算一次评分。
SQL长这样:
sql复制UPDATE shop s
SET s.rating = (
SELECT ROUND(AVG(rate), 2)
FROM post p
JOIN comment c ON c.post_id = p.id
WHERE p.shop_id = s.id AND c.rate IS NOT NULL
),
s.rating_count = (
SELECT COUNT(*)
FROM post p
JOIN comment c ON c.post_id = p.id
WHERE p.shop_id = s.id AND c.rate IS NOT NULL
)
WHERE s.id = #{shopId}
这样做属于“最终一致”而不是“实时一致”,但对于展示型平台完全够用。评论评分比较低频,用户对几分钟内更新延迟完全无感,却省掉了每次列表查询时的聚合开销。
5.2 用户动态的帖子流与评论数统计
个人中心的“我的动态”按用户ID查询帖子列表,同样必须分页。Vue3端用el-pagination组件,后端返回结构统一成:
json复制{
"records": [...],
"total": 128,
"size": 10,
"current": 1
}
评论数统计问题,我在post表里冗余了comment_count字段,发布评论时调用incrCommentCount SQL原子自增。这比每次都SELECT COUNT(*) FROM comment WHERE post_id = ?要快得多,开发时顺手做掉的优化,线上能一直受益。
5.3 管理后台数据看板:统计SQL怎么写
管理端首页做了一个简单的数据看板:总用户数、总店铺数、总帖子数、待审核店铺数。这个完全没有必要上复杂的大屏框架,四个SQL统计就搞定了:
java复制@Mapper
public interface StatsMapper {
@Select("SELECT COUNT(*) FROM user WHERE role = 0")
Long countUsers();
@Select("SELECT COUNT(*) FROM shop")
Long countShops();
@Select("SELECT COUNT(*) FROM post")
Long countPosts();
@Select("SELECT COUNT(*) FROM shop WHERE status = 0")
Long countPendingShops();
}
如果要画趋势图,再按日期分组统计近7天的发帖数:
sql复制SELECT DATE(create_time) AS day, COUNT(*) AS cnt
FROM post
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(create_time)
数据给到前端用ECharts折线图展示,效果很直观。
6. 从开发到上线:数据库初始化、打包部署与性能优化
6.1 数据库初始化脚本与演示数据
源码里我附带了一份init.sql,包含建库、建表、初始数据。初始数据非常关键,因为一个没有店铺、没有帖子的平台打开后非常劝退。我手动往库里塞了大约30家虚构但贴近真实的校园周边店铺,分布在校门口、商业街、宿舍区附近,分类覆盖快餐、奶茶、烧烤、面食、甜点等。
店铺的评分数据我故意做了差异化:有的4.2分,有的4.8分,这样首页推荐排序能看出明显区别,也方便前端测试排序功能。分享帖准备了20条左右,内容仿照真实的探店笔记,比如“这家炸鸡外酥里嫩,强烈推荐蜂蜜黄油味”。演示账号方面:
- 用户账号:
student01/123456 - 管理员账号:
admin/123456
这样拿到源码的人导入数据库后,登录、看数据、发帖反馈的体验是完整的,不用自己手动造数据,降低启动门槛。
6.2 后端打包:JAR包常见的三个坑
后端打包我用Maven的package命令,直接生成可执行JAR。打包过程中有三个坑值得单独提醒:
第一个坑:静态资源配置路径。 之前我在WebConfig里写的uploadDir用的相对路径./upload/,但项目在本地IDE里运行时和打包成JAR后,工作目录不一样,可能导致图片上传后找不到目录。建议改成绝对路径,或者用配置文件指定:
yaml复制upload:
dir: /data/campus-food/upload
上线后统一放在/data/campus-food下,和JAR包、日志目录放一起,管理起来很清晰。
第二个坑:配置文件分离。 我提供的application.yml里数据库密码是开发环境的,如果把JAR直接丢到云服务器上,直接用开发配置当然能跑通,但数据库地址和密码如果写死在JAR内,换环境就要重新打包。我的做法是用--spring.profiles.active=prod指定生产环境配置,把application-prod.yml单独放到服务器配置目录下。
第三个坑:内存占用。 默认SpringBoot启动时如果没有设置JVM参数,在1核2G的小服务器上可能会内存告警。我一般这样启动:
bash复制java -Xms256m -Xmx512m -jar campus-food-server.jar --spring.profiles.active=prod
这样内存占用控制在500M以内,1G内存的服务器也能轻松带得动。
6.3 前端构建与Nginx反向代理
前端通过npm run build生成dist目录,里面是纯静态文件。我用Nginx作为Web服务器,同时反向代理后端接口。
核心配置片段:
nginx复制server {
listen 80;
server_name your-domain.com;
root /var/www/campus-food/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
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;
}
location /upload/ {
alias /data/campus-food/upload/;
}
}
这里的关键点是location /api/的反向代理去掉/api前缀:proxy_pass http://127.0.0.1:8080/;。也就是前端请求/api/shop/list,Nginx会转发到http://127.0.0.1:8080/shop/list,后端接口里不需要加/api前缀。如果后端controller上有@RequestMapping("/api/shop"),那proxy_pass就不能带末尾的斜杠,这一点很容易混淆,两种配置方案背后映射规则完全不同。
6.4 性能优化清单:做完这些,系统才能扛住并发
关于性能,我把这个项目里能做并且值得做的优化梳理了一份清单,按优先级排序:
- 数据库连接池参数调优。 Druid连接池初始连接数设为10,最小空闲连接数5,最大活跃连接数50。默认配置往往偏保守,多用户并发访问时容易等连接池分配。
- 热点数据加Redis缓存。 首页店铺推荐列表、店铺分类是典型的读多写少数据,完全可以缓存到Redis并设置10分钟过期。我实测缓存后首页接口响应时间从15ms降到4ms左右,对体验提升很明显。
- MyBatis二级缓存。 MyBatis-Plus默认开启一级缓存(SqlSession级别),二级缓存需要手动配置。对于更新不频繁的点赞数、收藏数可以开启二级缓存,但要特别注意:如果数据被其他系统或定时任务修改,缓存不会自动失效,会读到脏数据。我在这个项目里没有全局开二级缓存,只针对
category表这种几乎不变化的数据开启。 - 前端代码分割。 Vite默认分包是按路由懒加载的。我把Element Plus组件的按需导入配置好,首屏JS体积会小很多。实测配置前后,首屏加载从2.1s降到1.2s,在校园网这种不算太流畅的网络环境下感知非常明显。
还有一个小建议:如果部署到服务器后出现“图片上传成功但访问404”,请先检查Nginx的/upload/别名路径和后端配置的upload.dir是否指向同一个目录,这是我在部署阶段排查时间最长的问题,最后发现是Nginx alias路径少了末尾斜杠导致的。
7. 这套项目往后还能怎么扩展
如果做完这个项目你还想继续深挖,几个方向比较自然:
- 给店铺加上地图定位和“距离最近”排序,接入高德地图或百度地图的Web服务API,让探索功能更强。
- 把分享帖改成图文动态流,加一个简单的推荐算法:用户点赞、收藏的店铺分类和帖子标签作为特征,用朴素贝叶斯做粗粒度推荐。
- 引入Redis缓存点赞数和热度排行,做一个“本周热门探店”榜单。
- 管理端可以加图表数据统计,用ECharts展示各分类店铺占比、每日发帖趋势、用户增长曲线。
- 分享帖增加话题标签功能,比如#平价美食 #夜宵首选,方便内容归类。
我个人做这个项目的最大体会是:技术栈本身并不复杂,难的是把业务场景理解透彻,然后把表结构、接口、页面按照真实使用流程组织起来。这套源码里没有炫技的成分,也没有神秘的黑魔法,但它把SpringBoot+Vue3前后端分离项目里最常遇到的问题基本都过了一遍,从JWT鉴权、文件上传、分页查询到Nginx部署,每一步都是可以在这个项目里直接看到完整代码的。如果你正在找一套能完整跑起来的校园主题全栈项目,从这个开始,我觉得很合适。
