☰
SpringBoot+Vue3+MyBatis美食网站系统开发实战
2026/10/14 2:49:54 网站建设 项目流程

去年秋天接了个私房菜馆老板的单子,对方想做一套在线点餐和菜品展示系统,方便顾客在手机上翻菜单、提前下单、预约到店。我一听需求,脑子里冒出的第一套方案就是 Java SpringBoot + Vue3 + MyBatis + MySQL 的经典前后端分离组合。折腾了一个多月,从空的 Maven 工程到能跑通浏览、搜索、下单、后台管理的完整 BS 网站系统,中间踩了不少坑,也沉淀了不少经验。这篇博文就把整个项目的技术选型思路、数据库设计、核心接口实现、认证鉴权、部署优化到排坑实录全部分享出来,给正在做课设、毕设,或者想接类似私活的朋友一份能直接参考的完整路线。

这套系统的业务逻辑并不复杂,但麻雀虽小五脏俱全。涉及用户端、商家端两个视角,包含菜品浏览、分类检索、关键词搜索、菜品详情、购物车、订单提交、支付状态模拟、后台菜品管理、订单管理等模块。全文会按照一个真实项目从0到1的顺序来讲:先交代需求怎么梳理、技术栈为什么这么选,然后重点拆解数据库建模的细节和容易翻车的字段设计,再逐块讲核心接口和前端页面的配合方式,中间穿插 JWT + Redis 登录鉴权的完整实现,最后分享性能优化、部署上线以及开发过程中反复折腾我的几个坑。

1. 项目从需求到成型:为什么最后选了 SpringBoot + Vue3 + MyBatis

1.1 需求不是想出来的,是聊出来的

接这个项目的第一件事不是打开 IDE,而是跟老板聊了一下午。我发现绝大多数"美食网站系统"翻车的原因不在技术,而在需求根本没聊透。比如老板一开始只说"做个网站,能看菜、能下单就行",但追问下来才理清楚有几类角色、几条核心流程。

这套系统最终定位为 BS 架构(Browser/Server 架构),即用户通过浏览器访问,不需要安装客户端。核心角色有两个:普通顾客和商家管理员。顾客能注册登录、浏览菜品、按分类筛选、搜索、加购物车、提交订单、查看历史订单;管理员能维护菜品分类和菜品信息、上架下架、查看和管理订单状态。

关键流程其实就两条:一条是"顾客浏览菜品 → 加购物车 → 提交订单 → 模拟支付 → 生成订单",另一条是"管理员登录后台 → 维护菜品 → 处理订单状态"。把这俩流程在纸面上画清楚,后续所有页面和接口的设计都变得非常有方向感。反过来,如果一上来就闷头写代码,很容易出现页面做完了但流程串不起来的情况。

1.2 技术栈的合理性分析:不追新,只求稳

SpringBoot 选它的理由不用多说,约定优于配置,内嵌 Tomcat,一个 Jar 包就能跑,生态极其成熟。对私活或者毕设这种体量的项目来说,SSH 那套过于老旧,Spring Boot 3.x 又太新,部分依赖兼容性还没完全跟上,所以我选了 Spring Boot 2.7.x,稳定且资料多,遇到问题一搜一大把。

前端用 Vue3 是因为 Composition API 写起来逻辑聚合度更高,配合 Vite 冷启动快,开发体验明显优于 Vue2 的 Webpack 方案。再加上 Element Plus 组件库,做后台管理界面几乎是拼积木,几天的功夫就能把菜品表格、弹窗表单、状态标签这些页面全部搭出来。Vue3 的响应式机制在处理购物车增减、筛选条件联动这种交互场景时非常顺手。

数据访问层选 MyBatis 而不是 MyBatis-Plus 或者 JPA,纯粹是个人偏好。这个项目里有不少多表关联查询和多条件动态 SQL,MyBatis 的 XML 文件能把复杂 SQL 写得很直白,排查问题一目了然。虽然 MyBatis-Plus 能省掉大量单表 CRUD 的样板代码,但考虑到某些 SQL 需要精细控制,比如后文的推荐权重计算,我还是选择了原生的 MyBatis 搭配一个轻量级的通用 Mapper 插件来兼顾效率。

MySQL 没有什么悬念。成本低、部署简单、云服务器上装一个就能用,对中小流量的网站系统完全够用。InnoDB 引擎支持事务和外键约束,对这个项目里的订单一致性需求来说是必须的。开发环境 MySQL 8.0,生产环境用了同样的版本,避免出现版本差异导致的字符集或者排序规则问题。

2. 数据库是第一场硬仗:核心表设计与最容易被忽略的字段

很多初学者拿到这种项目会先写实体类再建表,这是本末倒置。我习惯先建模再写代码。美食网站系统的数据模型看起来简单,但真要满足业务需求,表的数量会远超你的想象。这套系统最终用了 9 张核心表,这里挑最重要的几张展开。

2.1 用户、菜品、分类三张基础表的设计要点

用户表 user 是系统基础,字段包含 id、username、password、nickname、avatar、phone、create_time 等。密码字段我用 BCrypt 加密后的字符串存储,长度至少设 60,否则后面加密串存不进去。头像字段只需要存相对路径或 URL,不推荐直接把图片二进制丢进数据库,后续图片存储策略在部署章节详细讲。

菜品表 dish 是核心中的核心。名称、描述、图片、价格、分类 id、销量、评分、状态这些一个都不能少。有两个字段值得重点提醒。第一个是 status 状态字段,我用 0 表示下架,1 表示上架,加了一个普通索引。所有前端查询菜品的地方都必须带上 status = 1 的条件,否则后台下架的菜还会出现在用户端,这是我第一版测试时踩过的坑。第二个是冗余字段 category_name,归类名字段,很多新手不理解为什么菜品表里要冗余分类名,直接在查询时 join 分类表不就行了?原因很简单,用户端菜品列表页高频使用,减少一次 join 对性能有好处。菜品分类变化频率低,冗余这点数据完全不会造成一致性问题。

分类表 category 就简单了,id、name、sort_order、status。sort_order 用于控制前端分类展示顺序,status 也是软删除的标志位。真实业务中不建议物理删除分类,否则历史订单关联的分类信息会变得不可追溯。

2.2 购物车和订单表的表结构设计:状态机思维

购物车表 cart 字段比想象中简单:id、user_id、dish_id、quantity、create_time、update_time。逻辑上不需要给购物车里的菜品存快照,因为购物车是临时的,用户结算前如果菜品下架或价格变动,直接提示即可。

订单表 orders 和订单明细表 order_item 是这套系统里最需要花心思的。orders 表包含 id、order_no、user_id、total_amount、status、pay_status、remark、create_time、pay_time 等核心字段。order_no 我采用时间戳加随机数的策略生成,保证订单号唯一且不暴露业务量。金额字段必须用 DECIMAL(10, 2),绝不能用 float 或 double,否则后续对账会出现 0.1 + 0.2 != 0.3 的经典问题。

订单状态我设计为整数字段:0 待支付、1 已支付待处理、2 已完成、3 已取消。支付状态单独用一个字段 pay_status,避免把支付状态和订单状态混在一个字段里导致状态转换逻辑混乱。订单明细表 order_item 则保存下单那一刻的菜品快照,包括 dish_name、dish_price、quantity、image_url,这是系统设计里非常重要的一环。为什么不能只存 dish_id?因为用户下单之后菜品可能改名、下架甚至删除,而用户的订单历史必须显示他当时购买时看到的信息。凡是涉及历史单据的场景,都必须做数据快照。

2.3 建表 SQL 若干细节:字符集、索引与自增主键

建表时字符集统一用 utf8mb4,不要用 utf8,因为 utf8 在 MySQL 中无法存储 emoji 表情和部分生僻汉字。虽然系统里不一定有 emoji,但移动端用户昵称、评价内容里出现特殊字符的概率很高,直接用 utf8mb4 一劳永逸。排序规则用 utf8mb4_general_ci 足够,追求更精确的排序可以用 utf8mb4_unicode_ci,对中文搜索影响不大。

索引设计遵循了最左前缀原则。菜品表的分类 id 和状态字段建了联合索引 idx_category_status(category_id, status),搜索场景用的关键词字段建了普通索引,但模糊查询 LIKE '%关键词%' 其实走不了索引,这一步在性能优化章节会专门讲方案。订单表的核心索引是 idx_user_id(user_id) 和 idx_create_time(create_time),分别支撑按用户查订单和按时间统计订单两个高频场景。

主键统一用 BIGINT 自增,这是最简单可靠的方案。有同事建议我用雪花 ID 或 UUID,但对这个业务量来说自增 ID 完全没有瓶颈。真要到了分库分表的量级,再迁移也来得及。系统里所有表的逻辑外键都不建物理外键约束,只在代码层面维护关联关系。物理外键在电商系统里会严重拖慢插入和更新性能,而且会导致删数据时各种约束报错,增加运维成本。

CREATE TABLE `dish` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '菜品名称', `description` varchar(500) DEFAULT NULL COMMENT '菜品描述', `image_url` varchar(255) DEFAULT NULL COMMENT '图片地址', `price` decimal(10,2) NOT NULL COMMENT '价格', `category_id` bigint(20) NOT NULL COMMENT '分类ID', `category_name` varchar(50) DEFAULT NULL COMMENT '分类名冗余', `sales` int(11) DEFAULT 0 COMMENT '销量', `rating` decimal(2,1) DEFAULT 5.0 COMMENT '评分', `status` tinyint(4) DEFAULT 1 COMMENT '状态: 0下架 1上架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`,`status`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';

3. 前后端分离接口实战:从菜品推荐到下单,接口设计如何接地气

3.1 统一返回体与 RESTful 风格约定的必要

前后端分离开发最怕的不是功能难写,而是接口各写各的。前端拿到数据不知道什么格式,后端改个字段名前端要跟着改半天。开工第一天我就定下了统一返回体 Result,所有接口无论成功失败都返回同一结构:code、msg、data。code 为 0 表示成功,非 0 表示业务异常,HTTP 状态码只用来表示请求本身是否成功。这么设计的核心原因是浏览器对 HTTP 状态码有各种默认行为,比如 401 会弹登录框,冗余的语义绕来绕去不如全部走业务状态码。

RESTful 风格坚持了资源命名原则:获取菜品列表 GET /api/dish/list,获取详情 GET /api/dish/{id},添加购物车 POST /api/cart,提交订单 POST /api/order/submit。动词藏在 HTTP Method 里,路径上只出现名词。这种风格在团队协作中沟通成本最低,前端工程师看路径就知道调的是什么资源。

3.2 人气推荐算法:不依赖推荐引擎,SQL 也能做得很体面

美食网站一般都有"招牌推荐"或者"猜你喜欢"。很多朋友一听推荐就觉得得上算法模型,说实话小项目这么搞纯粹是过度设计。我的方案是设计一个推荐分数,在 SQL 里直接排出来。分数公式为:推荐分 = 销量 × 0.6 + 评分 × 20 + 近30天新增订单数 × 0.2。销量代表大众口味,评分代表品质,新增订单数代表近期热度。三者综合排出来的结果既不呆板,又能自然地把新品带出来。

具体实现上,我用了 MySQL 的日期函数和子查询。销量和评分直接查 dish 表,近 30 天新增订单数则从 order_item 表按菜品聚合统计。虽然简单,但实际运行下来的推荐效果非常理想,热门菜品稳定在前排,偶尔有些评分高但销量一般的"潜力股"也能冒出来,不会永远是一成不变。

SELECT d.id, d.name, d.price, d.image_url, (d.sales * 0.6 + d.rating * 20 + COALESCE(oi.last30_sales, 0) * 0.2) AS recommend_score FROM dish d LEFT JOIN ( SELECT dish_id, SUM(quantity) AS last30_sales FROM order_item oi JOIN orders o ON oi.order_id = o.id WHERE o.pay_status = 1 AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY dish_id ) oi ON d.id = oi.dish_id WHERE d.status = 1 ORDER BY recommend_score DESC LIMIT 10;

这种做法的好处是零额外存储成本,不需要定时任务去维护推荐数据。虽然每刷新一次页面会多跑几次聚合查询,但在菜品量不大的情况下性能完全没问题。真要到了菜品上万、并发上千的量级,再上 Redis 缓存或专门的推荐服务也不迟。

3.3 搜索功能的关键取舍:MySQL 模糊查询能做到什么程度

搜索这块我坦白说做得比较简单,就是基于菜品名称的 LIKE 模糊查询。用户输入关键词,匹配菜品名称中包含关键词的记录。中文分词、同义词、拼音搜索这些高级能力 MySQL 原生是不支持的,硬做只会越挖越深。我做出的取舍是先用 LIKE 撑起基本搜索能力,等业务确实需要更高级的搜索时再上 Elasticsearch 或其它搜索服务。

不过 LIKE 查询有一个必须处理的细节:首尾通配符会让索引失效。WHERE name LIKE '%鱼%' 这种写法是全表扫描的。解决思路是加一个 fulltext 全文索引,用 MATCH...AGAINST 做自然语言检索。MySQL 8.0 的全文索引对中文已经有一些基础的 ngram 分词支持,对小规模的中文搜索场景效果尚可。唯一要注意的是全文索引和 LIKE 的结果集略有差异,需要根据实际体验决定用哪种。

-- 添加全文索引 ALTER TABLE dish ADD FULLTEXT INDEX ft_name (name) WITH PARSER ngram; -- 使用全文检索 SELECT * FROM dish WHERE MATCH(name) AGAINST('鱼' IN NATURAL LANGUAGE MODE) AND status = 1;

3.4 下单接口的事务边界与并发扣库存

购物车选中菜品后提交订单,这个接口涉及多张表的状态变更,必须用事务保证原子性。我给它加上了 @Transactional 注解,事务内完成以下步骤:生成订单号、插入订单表、批量插入订单明细表、清空购物车对应菜品、菜品销量自增。

并发控制想过几种方案。乐观锁方案是 update dish set sales = sales + 1 where id = ?,这样销量自增本身就是原子操作,不存在丢失更新的问题。真正需要小心的反而是重复提交。用户快速点两次提交按钮,前端按钮做了 loading 处理,后端还必须用订单号唯一索引兜底。同一个用户同一秒提交两次相同的内容,第二次插入会因为订单号冲突而失败,异常被事务捕捉后回滚,不会产生脏数据。

还有一个容易翻车的细节:事务方法不能是 private,同类内部调用也不会生效。如果事务方法被同一个类里的另一个方法直接调用,Spring 的 AOP 代理不会拦截 this 调用,事务就静默失效了。这个坑我后文排坑章节会单独详细展开。

4. 登录鉴权不只是登录页:JWT + Redis 双保险的完整落地方案

4.1 为什么选 JWT,以及 Token 过期策略的设计

用户登录功能表面看很简单,前端收用户名密码,后端比对数据库,返回成功或失败。但真正要做得体面,绕不开一个问题:登录之后怎么确认用户身份。Session 方案在前后端分离架构下显得很笨重,跨域请求要把 SessionId 传来传去,服务器集群还要处理 Session 共享。因此我选了 JWT(JSON Web Token)无状态方案。

JWT 的原理可以理解成给用户发了一张加密身份证。服务端不保存这张证,只负责验签。Token 包含三部分:Header(声明算法)、Payload(用户信息)、Signature(签名)。签名部分由服务端密钥对前两部分做 HMAC 计算生成,只要密钥不泄露,用户伪造的信息在验签阶段就会被识破。

JWT 的天然缺点是无法主动失效。用户修改密码要求所有旧 Token 失效,用户退出登录要求 Token 立即失效,这些场景下光靠 JWT 自身机制解决不了。我的做法是引入 Redis 配合处理。登录成功后不仅返回 Token,还把 Token 存入 Redis,以用户 ID 为 Key,Token 为 Value,并设置过期时间。每次请求校验 Token 合法性之后,再查一下 Redis 里存的 Token 和当前请求携带的是否一致。不一致就说明这个 Token 已经被踢下线或者被新 Token 顶掉。这样的双保险实现起来很简单,但安全性和体验都提升了一大截。

4.2 后端拦截器的实现路径与白名单设计

后端实现用了 Spring MVC 的拦截器(HandlerInterceptor)。拦截器在请求进入 Controller 之前执行,这里做两道检查:第一,解析请求头中的 Authorization 字段,取出 JWT 字符串,用密钥验证签名和过期时间;第二,用解析出的用户 ID 去 Redis 查 Token 并比对。两道检查全部通过才放行,并把用户信息塞进 ThreadLocal 或 Request 属性,方便后续业务代码获取当前登录用户。

放行白名单是必须设计的。登录接口、注册接口、菜品列表、菜品详情、搜索接口这些无需登录就能访问的路径不能拦截。如果全部拦截,前端首次加载页面就会因为 401 白屏。我用一个常量列表维护白名单路径,支持前缀匹配,比如 /api/dish/list、/api/dish/detail/、/api/user/login。这样既保证敏感业务的安全,又不会误伤公开页面。

4.3 Vue3 前端配合方案:路由守卫与 Axios 拦截器双保险

前端这套体系跟后端拦截器是配套的。Axios 请求拦截器统一在发起请求前从 localStorage 取出 Token 并加在请求头里。响应拦截器则统一处理业务错误码,遇到 401 时清理本地登录态,跳转到登录页,并给出友好提示。

Vue Router 的路由守卫负责前端页面级别的访问控制。为需要登录的路由配置 meta 字段,记录 requiresAuth 为 true。每次路由跳转前,全局前置守卫检查目标路由是否需要登录,需要的话再看本地有没有 Token。有 Token 放行,没有则重定向到登录页,登录成功后再跳回原页面。这种方式能在用户还没发起任何接口请求前就拦截掉大部分未登录访问,体验上比白屏再跳转好得多。

JWT 密钥的生成和管理是一个常常被忽略的安全点。我用的密钥是一串 256bit 的随机字符串,存放位置在 application.yml 里,生产环境通过环境变量注入,不写死在配置文件提交到代码仓库。HS256 算法要求密钥最少 32 字节,长度不足会直接启动报错,实测时我在这上面吃过一个莫名其妙的亏。

5. 从"能跑"到"扛得住":缓存、静态资源与 Nginx 部署的关键改动

5.1 热点菜品与推荐列表为什么要缓存

项目开发完第一版后,我做了个并发测试,发现压力集中在菜品列表和推荐接口上。所有页面一加载就先查数据库,数据库扛不住全在 SQL 慢查询日志里。优化的第一步是在菜品列表和推荐接口上加缓存。

缓存实现选型我用了 Spring Cache 抽象层配合 Redis。在 Service 层方法上加 @Cacheable 注解,第一次查询后结果自动存 Redis,后续相同参数的请求直接命中缓存,数据库压力几乎归零。菜品的分类和推荐结果变化频率低,缓存 30 分钟完全够用。后台管理员修改菜品信息时,用 @CacheEvict 注解清掉对应的缓存,保证用户端最快速度看到最新菜品。

缓存击穿和雪崩也顺带做了处理。推荐接口用 @Cacheable(sync = true) 开启同步模式,防止缓存失效瞬间大量请求同时穿透到数据库。不同的业务缓存设置了不同的过期时间,避免同一时间大面积过期导致数据库瞬间压力过大。

5.2 商品图片处理策略:本地存储、压缩与 CDN 相关思考

图片处理是这个项目里最容易显得业余的地方。最初我直接让前端上传原图,一张手机拍的照片动辄三四兆,页面加载慢不说,数据库和服务器磁盘的压力也很大。后来我调整了策略:前端在上传前先用 Canvas 压缩到合适的尺寸,后端接收后再按宽高比例裁剪缩略图。

具体做法是将图片保存在服务器的 /data/images 目录下,数据库只存相对路径,比如 /images/dish/20250315_xxxx.jpg。Spring Boot 配置了静态资源映射,把 /images/** 映射到本地磁盘目录。Nginx 反向代理时再把所有图片请求直接交给 Nginx 处理,Nginx 对静态文件的处理效率和缓存能力远高于应用服务器。

图片有了稳定访问路径后,后续为图片加 CDN 也非常方便,只需要把 /images 目录同步到 OSS 或者 COS,数据库路径切换成 CDN 域名即可。这套方案的扩展性比一发入魂的本地存储方案要灵活得多。

5.3 Nginx 部署细节与前后端分离的正确姿势

前后端分离项目部署有个经典误区:前端构建完的 dist 目录直接丢进 Nginx 根目录,后端接口通过 /api 路径代理到应用服务器。这个思路是对的,但有几个细节要处理好。第一个是前端路由的 history 模式,刷新页面会出现 404。必须在 Nginx 配置里加上 try_files 指令,把不存在的路径全部重写回 index.html。

第二个细节是跨域问题。开发环境下 Vue 用 Vite 代理解决跨域,生产环境部署在同一个域名下,通过路径区分动态资源和静态资源,天生不存在跨域问题。这比搞前后端分两个域名、再配置 CORS 的方案简单太多了。第三个细节是 Nginx 层启用 gzip 压缩,对 JSON 接口响应和前端 JS 文件都有显著的体积压缩效果。

server { listen 80; server_name your-domain.com; # 前端静态资源 root /var/www/food-web/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; } # 菜品图片静态资源 location /images/ { alias /data/images/; expires 30d; add_header Cache-Control "public"; } # 开启gzip gzip on; gzip_types text/plain text/css application/json application/javascript; }

HTTPS 在这个项目里我是直接上了的。现在浏览器对非 HTTPS 页面的限制越来越多,支付相关的功能在 http 环境下基本不可用,申请免费证书后配置一下 Nginx 的 SSL 模块不算复杂,但对项目的安全感和信任度提升很明显。

6. 开发中被反复折腾的几个坑:你要是也遇到,能少走不少弯路

6.1 时间字段的时区问题:前端显示永远差 8 小时

这是我在测试用户下单时间时发现的问题。数据库里存的时间明明是 14:30,前端展示出来变成了 06:30。根因是 JDBC 连接串没指定时区,MySQL 驱动默认使用了服务器时区,而 Jackson 序列化时又用了 UTC,一来一回就偏了 8 小时。解决方案是在 JDBC URL 上显式追加 serverTimezone=Asia/Shanghai,同时在后端统一配置 Jackson 的时间序列化格式,规定所有日期时间字段输出为 yyyy-MM-dd HH:mm:ss。

jdbc:mysql://localhost:3306/food_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

这个坑特别隐晦的地方在于,本地开发时 MySQL 和 Java 应用在同一台机器上,时区干扰可能不明显,一旦云服务器上部署,立马原形毕露。所以我建议从开发第一天起就统一时区配置,省得后期排查调得怀疑人生。

6.2 图片上传成功但页面不显示:SpringBoot 静态资源映射的两个版本差异

项目前后端联调时遇到过图片死活加载不出来的情况。路径检查了很多遍,数据库中存的地址也对,浏览器直接访问也报 404。后来发现是 Spring Boot 的静态资源映射配置问题,2.x 版本的资源映射器和老版本写法不太一样,把本地目录映射到 URL 路径需要重写 addResourceHandlers 方法。

这个问题在本地开发时因为前端 Vite 代理的存在常常被掩盖,前端请求图片走了代理直接转发给后端,后端处理没有报错,图片路径在浏览器中也能预览。但打包部署到 Nginx 之后,图片请求直接打到 Nginx,Nginx 找不到对应资源才暴露出来。所以联调阶段必须时刻注意"目前请求打到哪一层"。

6.3 事务失效:同类方法内部调用的经典陷阱

事务失效的坑我前后遇到过三次,最后一次才彻底搞明白原理。场景是提交订单时,我又想给用户加积分,于是把加积分的方法写在了同一个 Service 类里,直接在 submitOrder 方法内部调用 addPoints 方法,并且 addPoints 上也标了 @Transactional。看起来两个方法都有事务注解,但实际上 addPoints 的事务根本没生效。

原因是 Spring 的 @Transactional 是基于 AOP 代理实现的,外部调用走代理对象,事务拦截器能生效;但同类内部 this.addPoints() 调用走的是原始对象,不经过代理,事务注解形同虚设。一旦 addPoints 内部抛出异常,只会回滚它自己参与的那个事务,如果它根本没有事务,那就完全不回滚,导致订单数据和积分数据不一致。

解决办法有三个:一是把加积分逻辑放到独立的 Service 类,通过注入的方式跨类调用;二是把两个方法放在同一个事务方法里,让外层事务统一控制;三是用 AopContext.currentProxy() 显式获取代理对象。第一和第二种方案业务上最清晰,第三种方案虽然能解决但代码可读性较差。

还有一个高频坑是异常被 catch 后事务不触发回滚。默认情况下,只有 RuntimeException 和 Error 能回滚事务,受检异常(Exception 的子类)不会触发回滚。如果业务代码里把 SQL 异常 catch 掉并返回了错误结果,Spring 以为方法正常执行结束,事务照样提交。最好的实践是事务方法内不 catch 异常,让统一异常处理器去处理,或者在注解上显式指定 rollbackFor = Exception.class。

6.4 MyBatis 字段映射的坑:驼峰和下划线的爱恨情仇

数据库字段是下划线风格,Java 实体类是驼峰风格,MyBatis 如果没开启驼峰映射,查出来的对象字段值全是 null。这个问题很经典,但我还是要提,因为太容易发生且隐蔽。在 application.yml 里加上一行配置即可,但有一个例外需要特别注意:如果实体类里有数据库没有的字段,比如推荐的评分、展示用的冗余字段,必须要设置 resultMap 或者使用别名。

写复杂 SQL 时我习惯把查询结果的列全部取别名,alias 和实体类字段保持一致,这样既规避了映射问题,也方便排查 SQL 的执行逻辑。在这里真的想建议所有刚开始用 MyBatis 的朋友,花半小时写一套整体的映射规范,远比每个查询里写 resultMap 效率高。

7. 写在最后的项目心得:这种"小系统"最有价值的部分不在技术

项目上线后我复盘了一下,真正花时间的部分不是写 CRUD,而是需求梳理、数据建模、联调排坑和部署策略这些看似"没有技术含量"的环节。很多朋友做类似系统时喜欢纠结"要不要上微服务""要不要用 Docker""要不要用 Elasticsearch",实际上对一个小而美的美食网站系统来说,简单可靠才是第一原则。SpringBoot + Vue3 + MyBatis + MySQL 这套组合足以支撑初期业务,未来真有瓶瓶罐罐的规模再逐步演进,也比一开始就搭一个重框架导致项目迟迟落不了地强得多。

最后分享一个实战小技巧:前后端联调时,把接口文档的维护放在第一步,哪怕只是一个简单的 Markdown 文件,把每个接口的请求参数、返回结构、错误码列清楚,后续的沟通成本能降低一半以上。这个项目从零到上线,我在接口文档上花了大约半天时间,之后整个联调期几乎没有因为接口格式问题返工过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询