先纠正一个容易误导新手的点项目名里的“springboot062”不是Spring Boot 0.62版本它更像是项目管理表格里的一个编号比如“校园平台第062号”。我按这个编号重新整理了一套基于Java的校园消费点评系统技术底子就是Spring Boot前端配Vue数据落MySQL。它解决的事情很具体校园里食堂、超市、水果店、打印店这些商户信息一直很分散学生想知道哪家好吃、哪家不坑大多只能靠口头问。这个系统就是让学生按消费场景给商户打分、写图文评价帮后来的人做消费决策。这套系统适合几类人刚学完Spring Boot想做完整全栈项目的人准备拿校园场景做课程设计或毕业设计的人以及想把校内信息平台落地试试水的同学。这篇不会只丢一堆源码链接我会按真实做项目的顺序从需求拆解、数据库设计、接口实现、防刷风控一直讲到打包部署和问题排查。你照着操作最好能跑通一个属于你自己的版本。1. 项目整体设计校园场景的功能边界怎么划1.1 校园消费点评和大众点评差在哪做校园消费点评最容易犯的错误是上来就照搬大众点评的功能把优惠券、会员等级、商家广告位、直播带货全塞进去。校园场景的用户群非常聚焦基本就是在校学生和少量教职工消费范围也集中在学校内部及周边几百米商户数量相对有限。功能一旦做多开发周期拉长后期维护成本也会压垮一个独立开发者。我最后划定的核心功能是四个商家信息展示、图文点评、评分统计、商户管理。商家信息展示是基础学生能按分类浏览或搜索图文点评是核心学生消费后可以评分并写感受评分统计是决策依据商户列表页要展示平均分和点评数商户管理则给商家一个后台用来修改营业信息、回复点评。这就够用了。登录方式也要按校园场景来定。直接用手机号登录并不现实因为学生很少有主动注册额外账号的动力。更符合学校习惯的是账号密码加学号绑定管理员后台统一导入。这样学生不用记复杂的注册流程商户也能通过后台生成账号。简单、可控也符合一个内部平台的身份边界。1.2 功能模块怎么拆才不臃肿我把系统拆成三个端学生端、商户端、管理端。学生端负责浏览商家、搜索、发点评、查看个人点评记录商户端只开放给商家可以维护店铺资料查看自己店铺收到的点评并选择正常回复管理端主要做账号审核、商户审核、违规点评下架还有统计数据总览。这样拆分有一个明显好处权限边界清楚。学生不可能改店铺信息商户不可能给别的商家评分管理端也不用处理日常业务细节点。放到代码里就是三种角色对应三套接口用拦截器只放行对应角色即可。我的真实体会是校园项目不需要特别复杂的RBAC权限模型角色少直接在拦截器判断角色就行部门、组织、菜单这种通用权限设计对校内场景是过度设计。1.3 后端为什么就用Spring Boot而不是更炫的框架技术选型的时候一定会有人问为什么不搞微服务为什么不直接用Node.js我给的答案很直接校园消费点评系统本质上是一个业务逻辑集中在点评事务、数据量在万级到十万级左右的中小型Web应用单体架构完全够用而且Spring Boot的开发效率和生态在这类项目里是最稳的。Spring Boot最大的价值不是性能而是约定优于配置。一个Web项目要处理的JSON序列化、参数校验、数据库连接池、事务管理Spring Boot都帮你安排好了。对需要快速交付的项目来说省下的时间可以用来打磨业务细节。配套选择MyBatis还是JPA我倾向MyBatis原因是查询语句可控尤其是商家平均分这种统计SQL手写SQL更容易调优也方便给接手的人看明白。前端选Vue也不是因为比React好而是因为一个中小型后台加展示页面Vue的模板语法和生态上手更快。项目里如果用独立的前后端分离结构Vue用起来和Spring Boot天然配合JSON一进一出开发边界很清楚。后面如果要做小程序版本后端接口还能继续复用只是把Vue层换成小程序页面这个架构不会浪费。2. 数据库设计先把核心表关系立起来2.1 核心表之间的关系校园消费点评系统的数据模型并不复杂核心就四张表用户表、商户表、点评表、订单记录表。订单记录表经常被忽略但它是点评防刷和数据可信度的关键。只有真实消费过的用户才能对某家商户发起点评这就需要一笔订单记录作为前置校验条件。用户和商户之间是多对多关系中间通过订单和点评来串联。一个用户可以对多个商户点评一个商户会收到多个用户的点评。为了避免重复点评数据库层面可以用一张点评表加一个唯一索引比如user_id merchant_id order_id在源头上防止同一次消费产生多条点评。商户表里我刻意加了两个冗余字段平均分avg_score和点评数comment_count。虽然这两个字段可以从点评表实时聚合出来但商户列表页每次查询都实时聚合用户量一大就会拖慢响应。保留冗余字段在发布点评时同步更新查询效率会高很多。这就是典型的空间换时间适合列表页高频读的场景。同步更新时要注意事务别让平均分和点评数不一致。2.2 建表SQL可以直接抄下面给出整理后的简化版建表SQL。字符集统一用utf8mb4不要用utf8因为MySQL的utf8存不全生僻字和部分符号点评内容里出现一些特殊字符是常有的事。id统一用BIGINT AUTO_INCREMENT对校园项目来说足够不需要引入雪花算法。CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE COMMENT 登录账号可以是学号, password VARCHAR(128) NOT NULL COMMENT BCrypt密文, nickname VARCHAR(64) DEFAULT COMMENT 显示昵称, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1商家 2管理员, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1禁用, avatar_url VARCHAR(255) DEFAULT COMMENT 头像地址, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE merchant ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(128) NOT NULL COMMENT 商户名称, category VARCHAR(32) NOT NULL COMMENT 分类如食堂/超市/打印店, address VARCHAR(255) DEFAULT COMMENT 地址描述, phone VARCHAR(20) DEFAULT , avg_score DECIMAL(3,2) NOT NULL DEFAULT 5.00 COMMENT 平均分, comment_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 点评数量, status TINYINT NOT NULL DEFAULT 0 COMMENT 0营业 1停业 2待审核, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商户表; CREATE TABLE review ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 点评用户ID, merchant_id BIGINT NOT NULL COMMENT 商户ID, order_id BIGINT DEFAULT NULL COMMENT 关联订单ID, score TINYINT NOT NULL COMMENT 评分1-5, content VARCHAR(1000) NOT NULL COMMENT 点评内容, images VARCHAR(1024) DEFAULT COMMENT 图片地址多个用逗号分隔, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1隐藏 2待审, like_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 点赞数, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_merchant_created (merchant_id, created_at), KEY idx_user_created (user_id, created_at), UNIQUE KEY uk_user_merchant_order (user_id, merchant_id, order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT点评表; CREATE TABLE consume_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 消费用户ID, merchant_id BIGINT NOT NULL COMMENT 消费商户ID, amount DECIMAL(10,2) DEFAULT 0 COMMENT 消费金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未核销 1已核销 2已取消, consumed_at DATETIME DEFAULT NULL COMMENT 实际消费时间, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_merchant (user_id, merchant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT消费订单表;设计点评表时有一点要说明UNIQUE KEY uk_user_merchant_order里的order_id字段允许为空因为有些点评可能不是从线上订单来的比如学生线下消费后补点评。但MySQL对唯一索引中NULL值的处理是允许多条为NULL所以真正的防重复点评不能只靠这个唯一索引还需要在代码层做判断。这一点后面会专门讲。2.3 关于冗余字段和索引的思考avg_score和comment_count两个字段的更新逻辑我建议不要在每次插入点评时用comment_count 1这种简单累加。因为一旦做点评隐藏、删除、审核驳回累加逻辑会变得非常难维护。更稳妥的做法是重算UPDATE merchant SET avg_score (SELECT ROUND(AVG(score), 2) FROM review WHERE merchant_id #{merchantId} AND status 0), comment_count (SELECT COUNT(*) FROM review WHERE merchant_id #{merchantId} AND status 0) WHERE id #{merchantId};这样做牺牲了一点点性能但换来了准确性。点评量到几万条时这条SQL加在点评事务里还能接受如果真的到了百万条再考虑用统计表异步刷新或者引入缓存。这就是分阶段优化不要一开始就上复杂架构。索引方面除了主键我建立了merchant_id created_at和user_id created_at两个组合索引。前者支撑“商户详情页按时间倒序看点评”这个高频查询后者支撑“个人中心我的点评列表”。组合索引的字段顺序和查询条件要匹配否则会失效。比如查询条件只带created_at的时候上述组合索引其实帮不上忙但这类查询频率很低可以不管。3. 后端核心功能实现认证、点评、检索与文件上传3.1 用户认证与登录态管理校园项目用户量不大用JWT做无状态登录是最省事的。用户提交账号密码后端校验通过后签发一个有效期比如48小时的Token前端存到本地每次请求在Authorization头里带上。用JWT不是因为比Session更安全而是前后端分离项目里不依赖服务端Session方便以后扩展小程序端。密码存储一定用BCrypt不要用MD5或SHA1。可能有人觉得校内系统没必要太严格但密码明文入库这种问题一旦出现就是灾难级的。Spring Security里自带BCryptPasswordEncoder也可以只引入一个BCrypt工具类自己在登录逻辑里校验完全够用。生成Token时只放userId和role两个字段就够了不要放昵称、头像这种随时可能变化的信息。否则用户改完昵称后老Token里的数据还是旧的又得额外处理。校验逻辑放在一个拦截器或过滤器里先解析Token再把用户信息放进当前请求上下文。这样Controller里就能直接拿当前登录用户ID不用每个接口都传用户ID参数。3.2 点评提交为什么必须先校验订单记录点评接口是整个系统最核心的接口也是坑最多的接口。正常流程是学生到店消费在商家页面选择“我已消费”系统生成一笔消费记录再在订单详情里发起点评。后端收到点评请求后要做四件事校验当前用户是否登录角色是否为普通学生校验点评对象商户是否存在且处于营业状态校验该用户对该订单是否已经点评过防止重复提交校验评分范围一般限定1到5的整数。第三步是防重复点评的重中之重。我在代码里用一个reviewMapper.countRecentByUserAndMerchant方法同时校验“同一个用户同一个商户7天内只能点评一次”这个规则比唯一索引更贴近真实场景也防止有人换订单反复刷同一家店。业务规则可以用下面的逻辑表达Transactional(rollbackFor Exception.class) public void createReview(Long userId, ReviewCreateDTO dto) { ConsumeOrder order consumeOrderMapper.selectById(dto.getOrderId()); if (order null || !order.getMerchantId().equals(dto.getMerchantId())) { throw new BizException(订单不存在或不属于该商户); } if (!order.getUserId().equals(userId)) { throw new BizException(只能评价自己的消费订单); } if (order.getStatus() ! 1) { throw new BizException(订单未完成不能评价); } int cnt reviewMapper.countByOrderId(dto.getOrderId()); if (cnt 0) { throw new BizException(该订单已经点评过了); } int recentCnt reviewMapper.countRecentByUserAndMerchant(userId, dto.getMerchantId(), 7); if (recentCnt 0) { throw new BizException(7天内已评价过该商家请换个时间再来); } Review review new Review(); review.setUserId(userId); review.setMerchantId(dto.getMerchantId()); review.setOrderId(dto.getOrderId()); review.setScore(dto.getScore()); review.setContent(dto.getContent()); reviewMapper.insert(review); merchantMapper.recalcMerchantScore(dto.getMerchantId()); }这里必须用Transactional。因为插入点评和更新商户平均分是两步操作如果只有点评插入成功平均分没有更新数据就永远对不上了。事务的意义就在这里要么都成功要么都回滚。我踩过这个坑曾经以为只要都给前端返回成功就行结果商户平均分和点评列表对不上排查了很久才发现是小概率的事务边界没控制好。3.3 商家检索排序小数据量别急着上中间件商家列表页需要支持分类筛选和关键词搜索。关键词搜索最简单的是用MySQL的LIKESELECT id, name, category, address, avg_score, comment_count FROM merchant WHERE status 0 AND ( name LIKE CONCAT(%, #{keyword}, %) OR category LIKE CONCAT(%, #{keyword}, %) ) ORDER BY avg_score DESC, comment_count DESC LIMIT #{offset}, #{pageSize};校园周边商户数量撑死几百家这个SQL性能没有问题。排序规则我选择了先按平均分倒序再按点评数量倒序这比单纯按点评数排序更合理因为只有一两个人打了满分5.0的商家不应该排在几百人打过4.5分的商家前面。如果需要更精细的加权可以用贝叶斯平均分排序但对校园项目来说这样已经是收益最高的方案了。有同学喜欢在这个阶段引入全文本检索引擎我不太推荐。部署和运维成本高一个几十条商户数据的小项目用分布式检索引擎属于明显的过度设计。以后真的做到了多校区、商户几千家再考虑接入也不晚。3.4 图片上传与本地存储防坑点评里的图片上传是标配。这个项目把图片存本地磁盘而不是数据库。做法是后端接收MultipartFile按日期生成目录比如/data/campus-review/upload/2025/06/12/文件名用UUID拼接原始后缀防止重名和路径穿越。数据库中只保存可访问的URL相对路径比如/upload/2025/06/12/xxx.jpg。这里最大的坑是静态资源映射。Spring Boot默认不会把你自定义的目录映射成可访问的URL需要额外配置Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file:///data/campus-review/upload/); } }图片URL由前端拼后端返回相对路径前端在请求时拼上服务器域名即可。不要在后端代码里硬编码本地地址否则换环境运行就会有一批图片地址失效。本地路径可以放到配置文件中统一管理后续想迁到对象存储也更好改。4. 防刷与风控校园系统也得有底线4.1 校园点评常见的几种刷法不要以为校园系统就不会有人刷。学生之间的竞争心理、个别商户雇人刷好评、同寝室互相点赞这些情况都可能出现。最常见的刷法无非是注册大量小号去刷好评同一账号反复点评同一商户还有短时间高频点赞某个点评。注册大量小号的问题在校园环境里有天然约束账号由管理员统一导入学号唯一普通学生注册如果放开的话容易产生垃圾账号。我建议默认关闭自主注册管理员在后台导入名单。虽然对演示项目来说关闭注册会显得少了功能但校内环境里统一账号体系本来就是常态。如果想保留演示体验可以加一个“学号校验码”的验证再结合图形验证码。4.2 接口层的常规限制接口层做防刷不需要太复杂的风控引擎先做三层限制就够了。第一层是登录限制。同一个账号短时间连续输错密码锁定几分钟防止暴力破解。这个可以用一个内存计数器实现或者用Redis做。第二层是点评频率限制。同一个用户每天最多发N条点评比如20条同一个商户7天内只能点评一次。这个限制我在点评提交接口里已经做了。第三层是点赞限制。点赞接口要做幂等校验同一个用户对同一条点评只能点赞一次点赞后再点就是取消不能无限叠加。用Redis做接口限流也很方便固定窗口计数就够。比如“同一个用户每分钟最多请求点评接口5次”可以用一个incr命令加过期时间实现。如果项目没有Redis也可以用本地集合加时间戳实现但多实例部署时会失效。校园项目一般单实例部署本地方案也能跑图省事就用Redis后面部署多实例也稳。4.3 幂等与并发问题处理点评提交在极高并发下有一种经典问题用户连点两次提交按钮后端同时收到两个请求都通过了“订单是否点评过”的校验最终插入两条点评。解决思路分两步走。第一步前端在按钮点击后立即置灰防止手抖第二步后端不能只信前端的防重还要在事务里做真正的兜底。比如在点评插入前给对应订单加一把锁ConsumeOrder lock consumeOrderMapper.selectByIdForUpdate(orderId);select ... for update会把这条订单记录锁住另一个请求在等待锁时完成第一个事务提交此时再校验订单已点评就会被挡回去。这个方案的代价是性能略低但校园业务的并发量完全扛得住。如果不想碰数据库锁还可以用Redis的setNx做一个分布式锁不过复杂度会高一些。对校园项目来说数据库行锁比分布式锁实在得多。5. 前后端联调与权限控制接口约定不能靠嘴说5.1 前端路由与登录态注入前后端分离项目最容易乱的地方是接口约定。我一般要求前端把API请求统一封装在一个请求文件里所有请求自动带上Authorization头。后端返回格式统一用Result对象code、message、data三个字段。请求成功code为0业务失败code为非0系统异常code为500。public class ResultT { private int code; private String message; private T data; }前端判断code时不要用嵌套的if去处理各种错误码统一在拦截器里处理code为401就跳转登录页code为403就提示无权限其他错误提示message。这样后端增加错误码时前端主流程不用改。前端路由也要配合登录态。比如点评页面需要登录才能访问路由守卫里判断本地有没有Token没有就跳登录页。但判断本地有没有Token只是体验层面的控制真正的权限校验永远在后端。有人会把“有没有Token”当成“是否有权限”这是不对的。Token只能证明“你是谁”不能单独证明“你能干什么”。5.2 后端拦截器与跨域配置后端的权限校验放在拦截器里做。要注意两个点放行白名单要明确跨域配置要能覆盖带Token的请求。常见的跨域错误是前端自定义了Authorization头但后端没有允许这个请求头。registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600);allowedOriginPatterns(*)比allowedOrigins(*)更省心尤其是在配合allowCredentials(true)时后者在部分Spring版本里不允许和通配符一起使用。还有一个细节跨域预检请求OPTIONS必须放行不要让拦截器把OPTIONS也拦下来。我见过最典型的报错是后端已经在Controller上写了跨域注解但请求还是被拦截器拦在门外因为拦截器的顺序在跨域处理之前。5.3 分页参数与搜索接口的约定列表接口我统一使用pageNum和pageSize作为分页参数返回结构里包含total、pages、list三个字段。不要让前端传page和limit两种叫法混着用会让对接人抓狂。对商家列表搜索参数统一放在keyword里分类参数用category状态参数后端根据角色自动补全学生端只能拿到status0的在营商家。分页查询我用分页插件减少样板代码。如果你的团队不想引插件自己用LIMIT写也可以。但要注意一个排序稳定性问题ORDER BY avg_score DESC存在大量分数相同的记录时SQL查询顺序不稳定分页可能出现记录跳动。推荐在排序条件里加一个id DESC作为第二排序键比如ORDER BY avg_score DESC, id DESC这样每个分页页面的结果才是确定的。5.4 接口文档得跟上不然改到想骂人前后端联调最怕的是“口口相传”。改一个字段后端在群里说一声前端记错等到联调才发现来回折腾一整天。小型项目不一定要上重型接口文档平台但至少要保留一份接口清单标注清楚接口路径、请求参数、返回结构、是否需要登录。如果团队对注解式文档比较熟可以在Spring Boot里直接集成接口文档生成工具让前端通过页面查看在线接口列表。这样后端代码更新文档同步更新前端随时能看到最新接口定义。即使不做在线文档我也建议用统一DTO接收前端参数不要用Map散装接收。Map用起来一时爽代码维护火葬场字段名打错一个字母编译期完全看不出来。6. 打包部署从本地跑到服务器6.1 配置文件按环境拆分开发环境和生产环境不可能用同一套配置。我习惯用Spring Boot的多环境配置文件application.yml放公共配置application-dev.yml放开发环境application-prod.yml放生产环境。启动时通过--spring.profiles.activeprod指定环境。这里要特别注意数据库地址和图片存储路径不能写死生产环境可以用环境变量注入。一个更安全的做法是把数据库密码放到服务器的环境变量里。比如jdbc:mysql://localhost:3306/campus_review中密码不写在application-prod.yml里而是用${DB_PASSWORD}来引用。这样即使配置文件泄露密码也不会直接暴露。spring: datasource: url: jdbc:mysql://localhost:3306/campus_review?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} servlet: multipart: max-file-size: 5MB max-request-size: 20MB server: port: 80806.2 Maven打包和部署脚本后端打包直接用Maven命令mvn clean package -DskipTests打出来的Jar包放到服务器的/opt/campus-review目录。启动命令我习惯用nohup java -jar campus-review.jar --spring.profiles.activeprod \ --server.port8080 /opt/campus-review/logs/app.log 21 nohup和能让进程在断开终端连接后继续运行。如果不加nohup你关掉远程终端应用就会一起挂掉。这是一个新手很容易踩的坑。用进程守护工具或者写一个简单的启动脚本都可以但校园项目用systemd来管理Jar包是最正规的做法崩溃后会自动拉起日志也统一管理。6.3 Nginx反向代理与静态资源前后端分离部署时Nginx主要做三件事静态资源托管、API反向代理、Gzip压缩。前端Vue打包后生成dist目录复制到Nginx的静态目录。API请求统一走/api/前缀反向代理到后端的8080端口。前端请求的图片资源走/upload/前缀直接映射到后端的文件目录。Nginx配置里有一个容易出错的地方是try_files。前端使用路由的history模式时刷新某个二级路由页面会报404因为Nginx找不到对应的静态文件。需要在location /里配置location / { root /usr/share/nginx/html/campus-review; index index.html; try_files $uri $uri/ /index.html; }try_files会把所有不存在的路径回退到index.html这样前端路由才能正常工作。如果用hash模式就不会有这个坑但URL里会多一个#体验不如history模式。7. 问题排查与避坑实录7.1 时间总是差8小时点评列表的时间比北京时间晚了8小时这个问题几乎每个用MySQL的开发者都会遇到。原因有两个层面一是数据库连接串没加时区参数二是JVM默认时区不是东八区。Spring Boot里可以统一在配置里解决spring: jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss数据库连接串加上serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8。检查问题时先看数据库服务器时区再看JDBC连接串时区最后看JSON序列化时区从这三个层面排查能找到绝大多数时间错乱问题。7.2 图片上传成功但前端访问404图片上传报成功但浏览器打开图片链接是404原因基本是静态资源映射没配置或者配置里的路径和实际落盘路径不一致。比如代码里写的存储根目录是/data/campus-review/upload/但服务器上实际目录是/data/campus_review/upload/差一个下划线查很久都查不出来。我建议把存储路径做成配置项并在项目启动日志中打印出来log.info(图片存储目录{}, uploadPath);启动日志里看到真实目录和后端返回的相对路径一对比问题立刻就能定位。如果映射路径需要跨操作系统Windows本地开发用file:///D:/upload/Linux服务器用file:///data/upload/不要想当然写成同一种。7.3 跨域配置写了但前端还是报错前后端联调时最常见的报错是跨域请求被阻止。第一反应是后端跨域没配置好但还有一个隐藏原因是前端请求被开发服务器代理走了。比如Vue开发环境端口是5173后端是8080很多同学在前端发请求时直接写http://localhost:8080/api后端就算配置了跨域也会被各种预检请求搞得焦头烂额。更省心的做法是给前端配置代理开发时请求地址写成/api由Vite或Webpack代理到后端。这样浏览器看到的请求是同源的根本没有跨域问题。生产环境则由Nginx做反向代理同样不存在跨域。跨域配置只保留给特殊调试场景。这个方案比我一开始硬啃跨域配置要稳定得多。7.4 并发点评导致商户平均分错乱商户平均分偶发不对点评数倒是正常这种情况我遇到过两次。原因都是发布点评服务里更新商户统计分时用了简单的累加而并发请求下累加操作互相覆盖。解决办法就是我前面说的改成重算并确保整个操作在一个事务里。这种问题不是必现的可能测试100次才出现一次但一旦上线后被用户发现数据就会失真。避免这类问题还有一个习惯在写涉及统计数据的更新语句时一定要主动测边界条件。不要只验证“单次点评成功”还要模拟“重复点评被拦截”“并发提交两条”“删除点评后统计分回滚”这些场景。把异常路径当正常路径来设计是后端开发从入门到进阶的一个明显分水岭。8. 一些没写进文档的经验8.1 项目做到一半最容易被拖垮的事这类项目拖垮进度的往往不是技术难点而是需求一直在变。今天学生说想要评论点赞明天商家说想要回复点评后天管理员说想按食堂档口统计排名。需求变没问题但不要每变一个需求就改一次表结构。我的做法是第一版先固化核心表结构点评、商户、用户这三类数据不要轻易动像点赞、回复这种功能能用关联表扩展就扩展不要在核心表上堆字段。另外一点是学会做减法。一个校园点评系统最核心的就是点评流程走得通。先把“登录-浏览商户-提交点评-展示评分-后台审核”这条主线做成闭环再去加点赞、回复、公告、数据报表这些加分项。顺序对了项目不会烂尾顺序错了很可能到最后连主流程都有Bug。还有一件容易被忽略的事项目一旦开始就要把关键接口的测试数据准备好。比如商户分类、测试账号、示例点评图片。没有数据支撑前端会一直处于“空页面调试”的状态很多样式和交互问题根本暴露不出来。8.2 这套系统还能往哪些方向扩如果以后要做第二版我比较看好的方向有三个。第一个是做校园外卖或预定模式在现有订单表上扩展配送信息点评和订单就能打通成更完整的消费闭环。第二个是做一个简单的商户经营数据看板给商户展示点评趋势、关键词分析和评分变化不用做成复杂BI几条统计图就能让商户觉得系统有价值。第三个是把当前单体接口抽成可复用API手机小程序版直接对接进一步扩大校园覆盖面。这三个方向都不需要推翻现有架构。这也说明当时没有把技术方案做复杂化是对的。单体、MySQL、标准化接口对于一个校园消费点评系统来说已经足够稳了。如果就我个人经验提一个最实际的建议那就是把图片存储在一开始就接到对象存储服务上别贪图省力存本地。本地存储会在服务器磁盘满了之后变成运维负担迁移时也会因为路径问题折腾很久。第一版用对象存储可能多花半天配置但后续会替你省下很多周六晚上的麻烦。