SpringBoot+Vue校园美食平台实战:从数据库设计到部署上线
1. 先把这个项目讲清楚校园美食平台到底在解决什么问题做这个项目之前我其实先问了自己一个问题校园周边的美食推荐美团和大众点评难道不够用吗答案是不够。高校聚集区的餐饮生态跟普通城区差别挺大店铺更替快、学生群体密度高、口碑传播极其依赖熟人网络评测和分享的方式也更随意。大平台上的评分模型对校园场景来说显得太重了一条“食堂三楼新开的麻辣香锅分量实在”的短评可能比一篇几百字的探店长文更有价值。所以这个平台的核心定位就三条轻量、实时、本地化。轻量指的是发布门槛低学生用户随手拍照、写两三句话就能完成一次分享实时指的是信息更新快店铺开张、关门、换菜单这些变化能被第一时间反映到平台上本地化则是把范围限定在“校园周边三公里”只做学校附近的巷子、商圈、家属区小馆子。系统本身采用 SpringBoot Vue MyBatis MySQL 这套非常成熟的组合完整覆盖了前台展示、后台管理、用户体系、店铺与美食内容管理、评论互动等模块。如果你是正在做毕业设计、课程项目或者想给学校社团做一个内部美食推荐工具这个项目结构可以作为一套比较完整的参考蓝本。从学习角度讲这个项目最大的价值在于它不是一个孤立的 CRUD 玩具。它包含了完整的前后端分离、多角色权限控制、文件上传、搜索筛选、数据统计这些实际项目必须面对的问题。做完一遍你对“企业级”这三个字到底意味着什么会有比看十篇架构文章都更具体的体感。提示整套系统的业务逻辑并不复杂复杂度主要集中在“内容审核”和“店铺状态管理”这两块这也是校园类平台跟普通个人博客类系统的本质区别。后文我会展开讲。2. 为什么选这套技术栈选型思路与取舍先聊聊技术选型。说实话市面上做这类信息管理系统的技术方案很多光后端就有 SpringBoot、Django、Flask、Express、Go 的 Gin 等等前端更有 Vue、React、ElementUI、Ant Design 一大堆。为什么最终锁定了 SpringBoot Vue MyBatis MySQL 这个组合2.1 后端选 SpringBoot 的真实原因SpringBoot 对中小型信息管理系统的适配度确实高。第一它内置了 Tomcat不用单独部署外部容器本地调试和服务器部署都省事第二生态非常完整权限控制有 SpringSecurity、接口文档有 Knife4j、文件上传有现成的处理方案基本不需要造轮子第三Java 的强类型特性在这种数据字段明确的管理系统里优势明显一个实体类对应一张表字段错了一起编译期就能发现。有人可能会问那用 Python 的 Django 不是更快吗这个问题我也认真想过。如果纯粹比“写代码的速度”Django 的 Admin 后台确实能省很多事但考虑到一个团队协作和后期维护的成本Java 的技术栈在国内企业里普及率更高网上资料、问题解决方案也多出问题的定位成本更低。而且既然标题里写了“企业级”三个字那未来的演进方向大概率是 SpringCloud 微服务、消息队列、分布式缓存这一套从 SpringBoot 起步是最平滑的路径。2.2 前端为什么是 VueVue 在国内中小型项目里的统治力是毋庸置疑的。它上手平缓、模板语法直观配合 ElementUI 组件库后台管理界面的效率提升非常明显。这个系统里有用户端和后台管理端两个视角如果用原生 JavaScript 去写那些表格、表单、弹窗页面逻辑会非常臃肿。Vue 的双向绑定和组件化直接把 UI 层面的复杂度消化掉了大半。2.3 MyBatis 而非 JPA 的理由持久层框架我选了 MyBatis 而不是 Spring Data JPA核心原因是SQL 的可控性和可优化性。校园美食平台里有一个高频操作是“根据经纬度距离排序”或者“按评分、销量多条件动态筛选”这类场景 SQL 稍微复杂一点用 JPA 的 Query 方法派生和 Specification 写起来很别扭反而直接用 XML 里写 SQL 更清晰。我还是坚持一个观点不要把 JPA 的自动建表、自动管理字段当作省事在业务规则变化频繁的系统中SQL 掌握在自己手里才最安心。MyBatis 的 XML 文件虽然多写几行但排查问题时一眼就能看到原始 SQL对了解数据库到底做了什么非常有帮助。2.4 MySQL 的稳定性与易用性数据库选了 MySQL 8.x原因是它在中小型应用的普适性、性能和运维成本之间达到了最好的平衡。8.0 版本的窗口函数、公共表表达式CTE也让一些复杂统计报表的编写简单了很多。美食分享平台的并发量在校园场景下不可能突破天际MySQL 配合合理的索引设计和 Redis 缓存完全够用。3. 数据库设计把美食平台的核心表结构拆开数据库设计是整个项目的地基。很多刚开始做项目的同学喜欢一上来就建表结果做到一半发现字段不够用或者关系没理清再回头改表结构牵一发而动全身。我这次设计时反复推演了业务流程最终落定 10 张核心表接下来挑几张有代表性的详细拆解。3.1 用户表与角色权限的设计逻辑用户表user是系统的入口字段上除了常规的用户名、密码、昵称、头像、手机号之外我特意加了一个role字段区分普通用户、商家和管理员三种角色。CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 加密后的密码, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, role TINYINT NOT NULL DEFAULT 1 COMMENT 角色1-普通用户 2-商家 3-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-正常 0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;为什么选择在用户表里直接放role字段而不是建一张用户-角色关联表很简单这个系统的角色是固定且单一的不存在一个用户同时拥有多个角色的情况。引入复杂的 RBAC 表结构虽然看起来更“企业级”但只会增加查询成本和编码负担。真正的企业级设计不是堆砌概念而是在适当的复杂度上做出恰当的选择。密码存的是 BCrypt 加密后的哈希值这个比 MD5 安全得多。很多初学者把密码明文直接入库这在真实项目中是不可接受的。3.2 店铺表与美食表的主从关系店铺表shop和美食表dish是内容核心它们的逻辑是典型的主从表关系。一家店铺对应多条美食记录一条美食记录必须归属于一家店铺。店铺表重点字段shop_name、address、longitude、latitude经纬度用于周边距离计算、category店铺分类如川菜、快餐、奶茶、avg_price人均消费、rating综合评分、status营业状态、shop_owner_id关联商家用户ID。CREATE TABLE dish ( id INT AUTO_INCREMENT PRIMARY KEY, shop_id INT NOT NULL COMMENT 所属店铺ID, dish_name VARCHAR(100) NOT NULL COMMENT 菜品名称, cover_img VARCHAR(255) DEFAULT NULL COMMENT 封面图, price DECIMAL(10,2) NOT NULL COMMENT 价格, description VARCHAR(500) DEFAULT NULL COMMENT 菜品描述, sales_count INT DEFAULT 0 COMMENT 月销量, rating DECIMAL(3,2) DEFAULT 0 COMMENT 评分, status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架, KEY idx_shop_id (shop_id), CONSTRAINT fk_dish_shop FOREIGN KEY (shop_id) REFERENCES shop (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT美食/菜品表;外键约束我保留着因为数据一致性很重要。有些团队为了性能宁愿在应用层控制关联也不建外键但在这种低并发的管理系统里建外键能省掉大量脏数据问题。3.3 评论、收藏、浏览记录三张交互表的设计这三张表是平台互动性的支撑。评论表comment包含评论内容、评分、图片、回复层级等字段收藏表favorite是用户与店铺/美食的多对多关系映射浏览记录表browse_history记录了用户的浏览行为用于后续的“猜你喜欢”。这些表设计时我特别留意了一个点冗余设计。比如评论表里既存shop_id也存dish_id这样既可以对店铺评论也可以对单个菜品评论。收藏表同样冗余了店铺名和图片快照避免列表页展示时需要连表查询店铺信息。CREATE TABLE comment ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL COMMENT 评论用户ID, shop_id INT DEFAULT NULL COMMENT 评论的店铺ID, dish_id INT DEFAULT NULL COMMENT 评论的菜品ID, rating TINYINT DEFAULT 5 COMMENT 评分1-5, content VARCHAR(1000) NOT NULL COMMENT 评论内容, images VARCHAR(1000) DEFAULT NULL COMMENT 评论图片JSON数组, parent_id INT DEFAULT NULL COMMENT 父评论ID用于回复, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评论表;这里有个细节parent_id字段允许为空。如果把这个字段设计成 NOT NULL 且默认指向自身回复功能的数据维护就麻烦很多。允许 NULL 再在代码里判断是更务实的做法。3.4 关于索引设计的一些实操心得索引这块我踩过不少坑简单分享几个原则。第一最左前缀法则如果查询条件主要是shop_id status那就建一个(shop_id, status)的联合索引而不是分开建两个独立索引。第二不要在低选择度的列上建索引比如status字段只有 1 和 0 两个值单独建索引基本没意义反而增加写入开销。第三覆盖索引对列表页性能提升非常明显。美食列表页的典型查询是“按店铺分类评分排序、按销量排序”我建了(category, rating)和(category, sales_count)两个联合索引来支撑。实测数据量在百万级别时分页查询基本都在几十毫秒内完成。4. 后端实现要点SpringBoot 与 MyBatis 落地笔记4.1 项目工程结构与依赖规划后端工程采用 Maven 多模块还是单模块我选择的是单模块 分包结构原因很简单——项目规模还没大到需要按模块拆分的程度。单模块部署简单、调试方便目录清晰也足够支撑起一套完整系统。com.campus.food ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问层 ├── entity // 实体类 ├── dto // 数据传输对象 ├── vo // 视图对象 ├── config // 配置类 ├── common // 通用工具与返回结果封装 ├── interceptor // 拦截器 └── exception // 统一异常处理Controller 只做参数接收和结果返回业务逻辑全部下沉到 Service 层。很多人不理解为什么多一层这么重要等你需要复用逻辑、加事务控制、做单元测试的时候就明白了。要养成面向接口编程的习惯Service 先定接口再写实现后续换实现不用改动调用方。依赖管理上用 SpringBoot 官方推荐的 BOM 方式管理版本号避免手动指定版本造成依赖冲突parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent业务层的依赖主要是 Web、MyBatis、MySQL 驱动、Lombok、Validation、Security 这六个 starter。4.2 统一返回结果与全局异常处理前后端分离的关键之一就是接口数据的标准化。我封装了一个通用返回类Data public class ResultT { private Integer code; // 200成功500失败 private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }配套的还有全局异常处理器。这里面有一个很多人不注意的细节业务异常、参数校验异常、运行时异常必须分开处理返回给前端的 message 不能直接暴露堆栈信息。像“SQL 语法错误”这种敏感信息直接返回给前端不仅难看还有安全风险。在全局异常处理器里统一对异常信息做包装同时把完整堆栈打到日志文件里方便排查。4.3 登录鉴权与 JWT 接入这里可能是整个后端项目里最关键的一块。具体方案是登录成功后用 JWT 生成 token 返回给前端前端每次请求在请求头里带上Authorization: Bearer xxx后端通过拦截器统一校验。JWT 工具有两个要点一是 secret 密钥要足够长至少 32 位以上防止被暴力破解二是过期时间不能太长我这里设置的是 24 小时配合 Redis 做续期操作。拦截器实现的核心逻辑Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行跨域预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims ! null) { request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录状态已过期请重新登录\}); return false; } }这里要留意拦截器过滤的路径范围。注册、登录、店铺列表这些公开接口应该放行用户管理、内容审核这些接口必须拦截。把公开接口列表放到配置类里统一管理这样之后加接口也不会混乱。4.4 店铺和美食模块的多条件分页查询这个功能是整个项目里 MyBatis 用得最体现水平的地方。前端列表页有分类筛选、评分排序、距离排序、价格区间等多个筛选条件组合起来变化非常多。在 Service 层拼 SQL 的方式会导致代码混乱且无法用缓存优化最合理的方案是在 MyBatis XML 中用where和if标签动态拼接select idselectShopPage resultTypecom.campus.food.vo.ShopVO SELECT s.*, IFNULL(AVG(c.rating), 0) AS rating, COUNT(c.id) AS comment_count FROM shop s LEFT JOIN comment c ON c.shop_id s.id where if testcategory ! null and category ! AND s.category #{category} /if if testkeyword ! null and keyword ! AND (s.shop_name LIKE CONCAT(%, #{keyword}, %) OR s.address LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND s.status #{status} /if /where GROUP BY s.id choose when testsortRule rating ORDER BY rating DESC /when when testsortRule sales ORDER BY s.monthly_sales DESC /when otherwise ORDER BY s.create_time DESC /otherwise /choose LIMIT #{offset}, #{pageSize} /select注意这里的LEFT JOIN comment部分因为有的店铺可能还没收到过评论如果误用INNER JOIN这些新店铺就直接从列表里消失了。这种小坑不实际写一遍真的容易踩进去。4.5 文件上传模块的本地存储实现美食平台的图片上传是硬需求。开发阶段我把图片存到了本地目录部署时再映射成静态资源访问。具体做法是配置类里定义上传根路径从application.yml读取按日期生成子目录避免单目录文件过多文件名用 UUID 拼接原扩展名防止重名上传成功后返回/images/2024/11/28/uuid.jpg这样的虚拟路径public String upload(MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(上传文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); SimpleDateFormat df new SimpleDateFormat(yyyyMMdd); String datePath df.format(new Date()); File dir new File(uploadPath / datePath); if (!dir.exists()) { dir.mkdirs(); } String newFileName UUID.randomUUID().toString().replace(-, ) ext; try { file.transferTo(new File(dir.getAbsolutePath() / newFileName)); } catch (IOException e) { throw new BusinessException(文件上传失败); } return /images/ datePath / newFileName; }有个非常容易被忽略的问题文件上传大小默认限制是 1MB美食图片普遍超出这个限制。所以我额外配置了spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB同时在前端组件里也做了 10MB 的大小校验双端校验才能避免用户上传到一半才报错。5. 前端页面与交互Vue 端的核心模块实现前端是用户直接接触的部分也是决定项目“胖不胖”的关键。我使用 Vue 2 Element UI 这套经典组合虽然 Vue 3 已经成熟但对这个项目而言 Vue 2 的生态稳定性更好社区资料也更多适合作为学习项目参考。5.1 前端工程结构与路由设计前端采用标准vue-cli生成的项目结构按视图模块组织frontend/src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 通用组件 ├── router // 路由配置 ├── store // 全局状态管理 ├── views │ ├── home // 首页 │ ├── shop // 店铺相关 │ ├── dish // 美食相关 │ ├── user // 用户中心 │ ├── admin // 后台管理 │ └── login // 登录注册 └── utils // 工具函数路由设计时我做了两个层面的区分用户端路由和管理端路由。管理端加了meta: { requiresAuth: true, role: admin }在全局路由守卫里判断用户角色没有管理员权限直接跳转首页。这种前端路由守卫只是体验优化真正的安全防线还是在后端的拦截器上。5.2 Axios 请求封装与 Token 管理前端所有请求通过 Axios 统一处理主要做了三件事第一请求拦截器统一添加 tokenservice.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })第二响应拦截器统一处理业务错误码和 401 跳转service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } Message.error(res.message) return Promise.reject(new Error(res.message)) }, error { // 处理网络异常 Message.error(网络异常请稍后重试) return Promise.reject(error) } )第三注意重复请求问题。列表页频繁切换筛选条件时前一个请求可能还没返回后一个就发出去了容易造成数据错乱。我在该项目里做了一层请求去重处理使用一个pendingSet集合存储进行中的请求标识相同标识的请求在发出前先取消前一个。let pendingMap new Map() function getRequestKey(config) { return ${config.method}:${config.url}:${JSON.stringify(config.params)} } export function addPending(config) { const key getRequestKey(config) config.cancelToken new CancelToken(cancel { if (!pendingMap.has(key)) { pendingMap.set(key, cancel) } }) } export function removePending(config) { const key getRequestKey(config) if (pendingMap.has(key)) { pendingMap.delete(key) } }这个细节不大但对列表筛选体验的提升立竿见影。5.3 首页与店铺列表页的组件拆分首页我采用的是经典的“搜索框 分类导航 热门店铺推荐”三段式布局。Element UI 的Carousel轮播组件展示推荐位下方分类用Tabs标签页切换。店铺列表页的筛选区是核心交互我把搜索关键词、分类、排序方式、人均价格区间这些条件都统一通过query对象管理传给后端const query reactive({ keyword: , category: , sortRule: default, minPrice: , maxPrice: , page: 1, pageSize: 8 })分页组件使用 Element UI 的Pagination特别要注意每次切换筛选条件时要把page重置回 1否则会出现搜索结果为空或越界的情况。5.4 后台管理页面的表格与表单实践后台管理涉及店铺审核、美食管理、用户管理、评论审核四个模块页面结构有很强的共性顶部搜索区 中间表格区 底部弹窗表单。店铺审核页比较有代表性。商家提交店铺信息后管理员在表格里看到待审核状态点击“通过”或“驳回”。这里我用了一个精巧的设计店铺状态的更新接口使用path方式传参语义更清晰export function reviewShop(id, status, reason) { return request({ url: /admin/shop/review/${id}, method: put, data: { status, reason } }) }前端表格里的状态展示用Tag标签加颜色区分待审核是橙色、已通过是绿色、已驳回是红色。通过:row-class-name回调给特殊状态的行添加背景色让管理员一眼扫过去就知道哪些需要处理。弹窗表单用Dialog Form组合这里踩过一个表单校验的坑在打开弹窗之前必须调用resetFields()清空上一次的校验状态和数据否则上次打开时填的内容会残留校验错误提示也不会消失。6. 部署上线服务器环境准备与打包踩坑6.1 前后端分离的部署拓扑这套系统的部署采用经典的前后端分离方案前端打包成静态文件交给 Nginx后端打成 Jar 包用 Systemd 守护进程运行。服务器配置是 2 核 4G单机部署 MySQL 和 Redis 也完全够用。域名映射和端口规划如下服务端口说明Nginx443/80监听前端请求配置 HTTPSSpringBoot Jar8080后端 API 服务MySQL3306数据库Redis6379缓存服务6.2 后端打包与系统服务配置后端打包前有一个特别容易忽略的坑application.yml里配置的数据库连接、Redis 连接、图片上传路径这些在不同环境下的值不同。我通过 Spring Boot 的 Profile 机制区分了开发环境与生产环境spring: profiles: active: profile.active再配合 Maven 的profiles配置打包时用-Pprod参数就能轻松切换环境配置。打包命令是mvn clean package -DskipTests打出来的 Jar 包用 Systemd 管理[Unit] DescriptionCampus Food Platform Afternetwork.target [Service] Userwww WorkingDirectory/www/campus-food ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar /www/campus-food/app.jar Restarton-failure RestartSec10 [Install] WantedBymulti-user.targetJVM 参数配置 512M 到 1G 的堆内存对于这个项目规模足够了。Restarton-failure保证进程异常退出后自动拉起这在无人值守的服务器上非常关键。6.3 Nginx 反向代理与前端静态文件Nginx 配置文件需要实现两个功能一是托管前端打包后的静态文件二是反向代理/api开头的请求到后端 8080 端口。server { listen 443 ssl http2; server_name food.example.com; # SSL 证书配置省略 location / { root /www/campus-food/dist; index index.html; 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 /images/ { alias /www/campus-food/upload/; } }try_files $uri $uri/ /index.html;是 SPA 应用必须配置的不然刷新路由页面的时候会出现 404。图片静态路径单独映射到上传目录也方便后续迁移到 OSS 对象存储。6.4 上线时踩过的三个真实坑第一个坑是 MySQL 编码问题。生产环境建库时忘了指定字符集默认为latin1导致中文乱码。解决方法是建库时显式指定CREATE DATABASE campus_food CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;第二个坑是数据库连接配置里没有加时区参数Spring Boot 连接 MySQL 8.x 时报了The server time zone value的错误。需要在 JDBC 连接串里加上serverTimezoneAsia/Shanghai。第三个坑最有价值后端没有配置 CORS 跨域结果前端构建完成后自己的域名调接口被拦截。开发时我在前端用了代理方式解决跨域上线后必须改成后端允许跨域访问或者干脆用 Nginx 反向代理规避跨域。考虑到生产和开发的一致性我用 Nginx 做了统一域名下的反向代理前端代码里请求地址全部写成相对路径/api/xxx这样不管在开发还是生产环境都能通。7. 内容安全与敏感词过滤校园平台不可绕过的一环其实正文标题里没有单独提这一步但做一个公开的分享平台内容审核一定跑不掉。如果用户发布的店铺评论、美食描述里带了不合适的词审核机制不过关平台迟早出问题。这部分我做的比较谨慎专门抽出来分享。7.1 后端敏感词过滤的实现我在后端集成了一层基于 DFA确定有穷自动机算法的敏感词过滤器。整体思路是把风险词库构建成状态机然后对用户输入的评论、店铺简介、菜品描述进行扫描匹配命中后直接替换成***。Component public class SensitiveWordFilter { private static final MapCharacter, Map WORD_MAP new HashMap(); public static void loadWordLib(SetString words) { for (String word : words) { Map current WORD_MAP; for (char c : word.toCharArray()) { if (!current.containsKey(c)) { current.put(c, new HashMap()); } current (Map) current.get(c); } current.put(end, true); } } public static String filter(String text) { StringBuilder result new StringBuilder(); StringBuilder currentWord new StringBuilder(); Map current WORD_MAP; boolean matched false; for (char c : text.toCharArray()) { if (current.containsKey(c)) { currentWord.append(c); current (Map) current.get(c); matched true; if (current.containsKey(end)) { int start result.length() - currentWord.length() 1; result.replace(start, start currentWord.length(), ***); currentWord.setLength(0); current WORD_MAP; matched false; } } else { if (matched) { result.append(currentWord).append(c); currentWord.setLength(0); current WORD_MAP; matched false; } else { result.append(c); } } } return result.toString(); } }7.2 评论审核的实用设计光靠机器过滤还不够建议同时做人工审核。我把审核拆成两级“机审 人审”。机审通过敏感词过滤和图片检测把明显有问题的内容拦截下来人审则处理边缘情况。评论表加一个audit_status字段0-待审核、1-通过、2-拒绝普通用户发布评论后状态置为待审核通过后评论区才展示。有人会觉得人工审核效率低但校园平台的评论量其实可控。我在后台给管理员设计了批量审核操作勾选多个评论一键通过配合 Tab 筛选“只显示待审核”一天处理一次足够了。7.3 用户举报与信用分机制平台上线后还有一个能显著提升内容质量的模块用户举报和信用分。用户可以对评论和店铺发起举报系统记录举报原因和举报人后台处理时可以看到被举报内容的历史记录。同时在用户表里加score字段用户每次被审核拒绝或举报属实就减分连续违规直接禁言。这是运营层面的机制但技术上的实现很简单效果却很好它让平台从“单向审核”变成“用户自治”内容垃圾明显减少。8. 缓存优化Redis 在美食平台里的三板斧项目里如果完全不引入 Redis也跑得动但并发上来了会很快暴露瓶颈。我在三个地方用到了 Redis都是非常实际的优化场景。8.1 店铺详情页缓存店铺详情页的访问频率远高于修改频率非常符合缓存的使用场景。查询逻辑是先查缓存缓存没命中再查数据库查到后写回缓存并设置过期时间。public ShopVO getShopDetail(Integer id) { String cacheKey campus:food:shop:detail: id; String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return JSON.parseObject(cached, ShopVO.class); } ShopVO shopVO shopMapper.selectShopDetail(id); if (shopVO ! null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(shopVO), 30, TimeUnit.MINUTES); } return shopVO; }这里的一个关键点是缓存更新策略。一开始我用的是简单的“更新数据库后删缓存”但遇到并发读写时会存在一个问题一个请求更新完缓存后另一个请求读取旧缓存导致数据不一致。后面改成“更新数据库后延迟双删”先删缓存、再更新数据库、短暂延迟后再次删缓存。虽然多了一次删除操作但一致性可靠性高得多。8.2 热门搜索榜单首页展示“热门搜索关键词”这个如果用 SQL 去GROUP BY统计每次展示数据库压力不小。我用 Redis 的ZSet数据结构记录搜索关键词public void recordSearchWord(String keyword) { redisTemplate.opsForZSet().incrementScore(campus:food:hot_search, keyword, 1); } public ListString getHotSearchWords(int topN) { SetString words redisTemplate.opsForZSet() .reverseRange(campus:food:hot_search, 0, topN - 1); return new ArrayList(words); }每次搜索时调用recordSearchWord给关键词加 1 分首页展示时直接取 ZSet 的前 10 名。这个方案的性能开销几乎可以忽略代码也简洁。8.3 验证码存储登录注册时的图形验证码必须有过期时间Redis 天然适合。验证码生成后存储到 Redis设置 5 分钟过期校验时取出比对并删除。用 Redis 替代内存缓存的好处是集群部署时验证码也能共享不至于用户在一台机器上获取验证码、请求被负载均衡到另一台机器后校验失败。9. 从开发到维护我踩过这些坑帮你提前规避最后这部分不讲架构了聊点实在的。做这类项目过程中踩过的坑很多都是不实际做一遍永远不会在文档里看到的东西。9.1 事务注解失效的经典场景Transactional注解如果是加在私有方法、同类内部调用或非 Spring 管理的类上都不会生效。最典型的坑还是本地方法自调用Service 层的方法 A 调用同类的方法 BB 上的Transactional直接失效因为事务是通过代理对象实现的自调用走的是原始对象而非代理。解决方式有两种要么把需要事务的方法拆到不同 Service 类里互调要么在自调用处通过ApplicationContext.getBean获取代理对象。9.2 数据库连接的时区与编码坑新装 MySQL 之后建议立刻执行这两条SET GLOBAL time_zone 8:00; SHOW VARIABLES LIKE character_set%;如果是云数据库也要在控制台确认参数组里的时区设置。这个坑的排查特征很明显本地跑得好好的一部署到服务器查询时间返回的时间比实际提前了 8 小时那基本就是 JDBC 连接串或者数据库时区的问题。9.3 Element UI 表格数据不刷新的问题Vue 2 中直接通过下标方式给this.tableData[0].name xx赋值页面往往不会正确更新视图。排查到后来发现是因为 Vue 2 的响应式系统无法拦截到数组下标赋值操作。正确做法是用this.$set(this.tableData, 0, newItem)或使用splice方法。这个坑在后台管理系统的列表操作中极常见批量审核、单条修改都会踩代码层面只能靠规范约束。9.4 Nginx 代理上传文件超时商家上传美食图片请求比较大时Nginx 默认的 60 秒超时设置可能不够用会出现前端报错“Request Entity Too Large”或“504 Gateway Timeout”。解决方式是在 Nginx 配置里显式调大上传大小和超时时间client_max_body_size 50m; proxy_read_timeout 300s; proxy_send_timeout 300s;9.5 日志打印别贪多刚开始做项目时容易在代码里到处打印日志结果线上日志文件几天就能撑爆磁盘。我的建议是遵循分级打日志业务关键操作用info级别记录参数和结果异常和错误用error级别记录完整堆栈循环体内的日志一律用debug级别并默认关闭。配合logback-spring.xml做按天切割和保留周期appender nameERROR_FILE classch.qos.logback.core.rolling.RollingFileAppender filter classch.qos.logback.classic.filter.ThresholdFilter levelERROR/level /filter rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/error.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy /appender我在实际排查问题过程中几乎都是靠日志定位的。日志打得好服务器上少熬夜这不是玩笑话。本来还打算专门写一章关于怎么用Knife4j生成接口文档、怎么给前端提供联调环境的内容但篇幅已经很长了。最后说两句我自己的体会做这类管理系统技术选型不是越新越好稳定、可维护、团队能接得住才是最重要的。SpringBoot 这套技术栈虽然没什么炫酷的地方但每一步都很扎实出了问题总是有人能给你答案。如果你正在自己搭一套类似系统建议先从数据库设计开始把表结构理清楚再去写代码能省掉后面大把返工的力气。