做后端这些年,接手过不少业务系统,但今年上半年这个"爱心商城系统"算是我完整走完的一套偏电商的项目:SpringBoot + Vue3 + MyBatis,MySQL 存数据,前后端完全分离。表面看就是一个商城,但业务上有不一样的地方——它不是一个单纯的买卖平台,而是围绕公益义卖场景来设计,用户买商品的同时能看到自己产生的公益贡献,商城后台还要维护公益项目、义卖商品、订单和贡献流水。这套系统做完,从数据库建模到后端接口、前端页面再到服务器部署,踩了不少坑也攒了不少经验,今天就把整个过程完整拆给你。
我假设看这篇东西的人有两种:一种是刚学完 SpringBoot 和 Vue 但没做过完整前后端分离项目的新手,想找一套可以复刻的实战参考;另一种是已经做过类似商城系统、想看看别人怎么处理订单、事务、权限、联调这些问题。不管哪种,这篇文章都尽量写到我当时的真实决策和操作细节,能直接当开发手册用。
1. 项目定位与整体架构设计
1.1 商城系统到底要解决什么问题
先把这个系统的业务逻辑说清楚。爱心商城不是普通电商,核心业务围绕"义卖 + 公益贡献"展开。普通用户注册登录后,可以浏览义卖商品、加入购物车、下单支付,支付完成后系统会根据订单金额按一定比例生成公益贡献值,关联到用户选择支持的公益项目上。管理端则要维护公益项目信息、上架下架商品、处理订单状态、查看贡献汇总。
这里有个关键点:订单不只是订单,它还是贡献数据的来源。所以数据库设计时我必须把订单、订单明细、贡献记录分开,每笔订单的状态流转要清楚,贡献记录要能追溯到具体订单。这个业务模型直接决定了后面所有表结构的设计方向。
我一开始也想过要不要做复杂的积分体系,后来和需求方反复确认,第一版只做"消费即贡献"这一条主链路,保证核心流程先跑通。这也是我后来反复和做系统的人强调的一句话:第一版永远不要贪多,把主链路做扎实。
1.2 技术选型为什么是这套组合
技术栈选型这件事,很多新人以为是"哪个火选哪个",实际上背后全是成本和团队熟悉度的考量。
后端选 SpringBoot 2.7.x,理由很朴素:生态成熟,招人好招,遇到问题随便一搜就有答案。如果选一些小众框架,文档少不说,出了奇怪的问题你连排查方向都没有。SpringBoot 内置的自动配置能把项目搭建成本压到最低,我本地从初始化到跑起来一个空工程不超过十分钟。
持久层用 MyBatis 而不是 JPA,是因为商城这类系统的查询太灵活了。商品列表要按分类、价格区间、关键字动态组合筛选,订单列表要按状态、时间、用户多种条件叠加查,MyBatis 动态 SQL 处理这种场景得心应手。虽然很多人觉得写 XML 麻烦,但我更宁愿 SQL 掌握在自己手上,SQL 好不好优化、能不能加索引提示,心里都有数。
前端选 Vue3 配 Vite,组合态 API 写起来逻辑内聚,响应式系统比 Vue2 的 Object.defineProperty 方案在处理深层对象时更省心。加上 Element Plus 做后台管理界面,开发效率提升非常明显。
数据库就是 MySQL 8.0,InnoDB 引擎。MySQL 在中小型项目里的平衡性最好,配套工具多,团队都熟,没必要为了"高大上"去上 PostgreSQL 或者其他重型方案。这套选型放到市场上看,就是一套非常稳妥、不会出大错的主流组合,对个人学习或者团队交付都很合适。
1.3 前后端分离架构如何落地
前后端分离这四个字说起来简单,真正落地时涉及一套约定。我这个项目的物理结构是这样:前端一个独立工程,负责页面渲染和用户交互;后端一个独立工程,只提供 JSON 接口;两者之间通过 HTTP 通信,前端开发时走代理转发,生产环境用 Nginx 把静态页面和接口请求指向同一个域名下,避免跨域。
后端工程内部没有再拆微服务,这个体量的项目拆微服务纯属给自己找事。模块化分层就够了:Controller 层只做参数接收和响应封装,Service 层写业务逻辑,Mapper 层通过 MyBatis 访问数据库。每层职责清晰,出了问题能快速定位是接口问题还是业务逻辑问题还是 SQL 问题。
接口规范上用 RESTful 风格,资源用名词,操作靠 HTTP 方法区分。比如 GET /api/goods 查商品列表,POST /api/orders 创建订单。约定好统一的响应体结构,前端拿到后不用每个接口单独判断格式。我用的响应体结构是{ code, message, data },code 为 0 表示成功,非 0 表示业务异常,前端 axios 拦截器统一处理。
前端路由用 Vue Router,页面按用户端和管理端划分:用户端是商城前台,包括首页、商品列表、商品详情、购物车、订单确认、个人中心;管理端是后台,包括商品管理、订单管理、公益项目管理、数据看板。两个端共用一套接口,只是不同角色能访问的接口权限不同。
2. 数据库设计与核心表结构
2.1 三个核心业务域怎么拆
商城系统再怎么复杂,核心逃不出三个域:用户域、交易域、商品域。我这套系统因为带公益贡献的业务,还要加一个"贡献域",但本质上它依附于交易域,靠订单金额触发。
用户域处理的是账号体系,包括用户基本信息、登录凭证、收货地址。交易域是订单和订单明细,这是整个系统数据一致性要求最高的地方。商品域包括商品基本信息、分类、库存、上下架状态。贡献域记录每次购物产生的贡献流水,关联公益项目和订单。
域与域之间的关联我尽量用外键约束吗?没有。生产环境中我很少用物理外键,逻辑关联就够了。外键在项目初期看着规整,到后面做分表、做数据归档、做批量删除时全是坑。订单表里的 userId、goodsId 这种字段,靠应用层保证逻辑正确,数据库层面只建索引。
2.2 几张关键表的定义
直接看我实际用的建表语句,以订单主表为例:
CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2已发货 3已完成 4已取消', `receiver_name` varchar(50) NOT NULL COMMENT '收货人姓名', `receiver_phone` varchar(20) NOT NULL COMMENT '收货人电话', `receiver_address` varchar(255) NOT NULL COMMENT '收货地址', `charity_project_id` bigint(20) DEFAULT NULL COMMENT '支持的公益项目ID', `contribution_amount` decimal(10,2) DEFAULT NULL COMMENT '产生的公益贡献金额', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';这里几个细节值得说一下。
订单编号我不用自增ID直接展示,因为自增ID会暴露订单量,而且多表合并或数据迁移时容易冲突。我用的是"时间戳 + 随机数"拼接的订单号,格式类似20240515103012xxxxxx,在 Service 层生成,唯一索引兜底防止重复。
金额字段一律用decimal(10,2),绝不使用 float 或 double。浮点数在二进制里无法精确表示,商城涉及钱,一点点精度误差都可能引发对账问题。这个道理我在培训新人时反复强调。
订单状态用 tinyint 存数字,不存字符串。原因很简单:数字占用空间小,查询快,而且状态枚举在代码里是明确的,不会因为文案改动影响数据。前端展示时再映射成对应文字。
订单明细表t_order_item记录每个商品的快照信息:商品名称、单价、数量、小计金额。这里必须把商品名称和价格冗余进来,不能只存 goodsId。因为商品可能后续改名、调价甚至下架,如果订单明细只关联 goodsId,历史订单显示的信息就全都乱了。快照思维在电商系统里非常重要。
2.3 字段设计要点和索引规划
索引这块,我的原则是"按查询需求建索引,不为建而建"。开发时先想清楚有哪些查询路径,再决定索引,不要一上来把所有字段都建上索引。
订单表的索引就是上面的设计:订单号建唯一索引,因为订单号有唯一性约束;userId 建普通索引,因为个人中心要查"我的订单";status 建索引,因为后台管理经常按状态过滤订单。注意联合索引的使用,如果常见查询是"按用户 + 状态"查订单,那建(user_id, status)联合索引比分别建两个索引效率更高。
商品表、商品分类表、购物车表、用户表、会员地址表、公益项目表、贡献记录表的建表逻辑类似,都在高频查询字段上建索引。比如商品表的上下架状态和分类ID,贡献记录表的用户ID和订单ID。
一个容易忽略的点是字符集和排序规则。MySQL 8.0 默认字符集已经改成 utf8mb4,比 utf8 能多存 emoji 等四字节字符。但如果你用的还是 MySQL 5.7,建表时一定要显式指定DEFAULT CHARSET=utf8mb4,否则表情符号和生僻字会存不进去,前端输入个昵称带个 emoji 就直接报错。排序规则我用utf8mb4_general_ci,对中文场景够用,性能也好。
还有一个我后来才意识到的:所有表必须带create_time和update_time两个时间字段,能自动填充就自动填充。排查问题时这两个字段是救命稻草,比如用户说"我下单后订单消失了",你一看create_time和update_time就能判断是不是状态被更新了。
3. 后端关键实现与业务逻辑
3.1 SpringBoot 工程结构怎么组织
后端的包结构这样的:
com.example.charitymall ├── controller # 接口层 │ ├── UserController.java │ ├── GoodsController.java │ ├── CartController.java │ ├── OrderController.java │ └── AdminController.java ├── service # 业务层 │ ├── UserService.java │ ├── OrderService.java │ ├── CartService.java │ └── GoodsService.java ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体 ├── dto # 接口传输对象 ├── common # 统一响应、异常、工具类 └── config # 配置类(安全、跨域、拦截器)这个结构看起来常规,但有几个值得讲清楚的细节。
实体类和数据传输对象必须分开。entity对应数据库表结构,字段和列一一映射;dto是接口层的输入输出载体,字段可以自由裁剪组合。很多人图省事直接用 entity 接收前端参数,返回接口数据也用 entity,短期没问题,一旦业务变化就会很痛。比如用户表的密码字段,如果直接用 entity 返回给前端,密码哈希就泄露了。我用一个UserInfoVO专门做脱敏返回。
Controller 层只负责三件事:接收参数、调用 Service、封装响应。所有业务校验逻辑放 Service,Controller 里不要出现 if else 判断业务规则。一方面是为了复用,同一个校验规则可能多个接口都要用;另一方面是可测试性,Service 层的业务逻辑可以脱离 HTTP 层单独写单元测试。
3.2 MyBatis 动态 SQL 是查询灵活性的关键
商品列表页的筛选条件在不同页面差异很大:首页要按销量排序,分类页要按分类 ID 筛选,搜索页要按关键字模糊查询并支持价格区间。写死 SQL 无法覆盖这么多组合,MyBatis 的动态 SQL 正好处理这个。
看一个商品条件查询的 Mapper XML:
<select id="selectGoodsByCondition" resultType="com.example.charitymall.entity.Goods"> SELECT id, goods_name, price, stock, cover_url, sales_count, status FROM t_goods <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND goods_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> AND status = 1 </where> ORDER BY <choose> <when test="sortType == 'sales'">sales_count DESC</when> <when test="sortType == 'price_asc'">price ASC</when> <when test="sortType == 'price_desc'">price DESC</when> <otherwise>create_time DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>注意<where>标签的用法:它会自动去掉条件块开头的 AND,同时如果没有条件成立,整个 WHERE 子句也不会出现,避免语法错误。
聚合查询用 Map 接收还是建一个独立 VO?我的做法是建独立统计 VO,比如ContributionStatVO,包含 charityProjectId、projectName、totalContribution 字段。用 Map 接收虽然省事,但字段名不明确,前端联调时容易对不上、找不着数据含义。建 VO 能让接口的返回结构清清楚楚。
动态 SQL 的调试是个难点。我强烈建议在application.yml里把 Mapper 的日志级别设为 DEBUG:
logging: level: com.example.charitymall.mapper: debug这样 MyBatis 会把最终执行的真实 SQL 打出来,排查 SQL 拼接问题特别高效。我记得一开始调商品条件查询时,有个<if>条件判断了错误的参数名,SQL 里直接缺了一块条件,就是靠日志把真实 SQL 打出来一眼看穿的。
3.3 下单流程与事务控制
下单是整个系统最核心、最容易出问题的环节,完整链路是:校验购物车或直接购买的商品 → 校验库存 → 计算金额 → 生成订单 → 扣减库存 → 生成订单明细 → 生成贡献记录。
这条链路涉及多张表的写入操作,任何一步失败都必须全部回滚。我用@Transactional解决:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, CreateOrderRequest request) { // 1. 查询购物车数据 List<CartItem> cartItems = cartMapper.selectCheckedItems(userId); if (cartItems.isEmpty()) { throw new BizException("请选择要结算的商品"); } // 2. 循环校验库存 for (CartItem item : cartItems) { Goods goods = goodsMapper.selectByIdForUpdate(item.getGoodsId()); if (goods.getStock() < item.getQuantity()) { throw new BizException("商品[" + goods.getGoodsName() + "]库存不足"); } } // 3. 生成订单号、计算总金额、插入订单主表 // 4. 插入订单明细表 // 5. 扣减库存 // 6. 计算贡献值、生成贡献记录 return orderVO; }事务这里有两个关键点。
第一,rollbackFor = Exception.class必须写。Spring 的@Transactional默认只在 RuntimeException 时回滚,如果业务抛的是受检异常(比如 IOException、自定义的 Checked Exception),事务不会回滚,数据就写了一半进数据库。我习惯所有业务异常都继承 RuntimeException,同时显式声明 rollbackFor,双保险。
第二,库存校验和扣减必须防并发超卖。我用的方式是查库存时加FOR UPDATE行锁,selectByIdForUpdate在事务里锁住商品行,这样两个用户同时对同一商品下单时,第二个用户的查询会阻塞,等第一个用户事务提交后再执行,重新读到最新的库存值。这是"悲观锁"方案,并发量不大的商城系统完全够用。想再优化可以改成"乐观锁 + 重试",用UPDATE t_goods SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num},利用受影响行数判断是否超卖。这个方案我第一次没采用,因为涉及重试机制,代码复杂度上来了,第一版先保证正确性。
还有一个很容易踩的事务失效场景,我放在后面第六节专门说。
3.4 登录鉴权与接口安全
用户端和管理端的鉴权我统一用 JWT。用户登录成功后,后端生成一个携带用户ID和角色信息的 token 返回,前端存在 localStorage 或 sessionStorage 里,每次请求在请求头带上Authorization: Bearer <token>,后端用一个拦截器校验 token 有效性。
具体实现是写一个AuthInterceptor注册进 Spring MVC 的拦截器链,放行登录注册接口,其余接口全部校验 token。管理端接口额外校验角色必须是管理员,否则返回 403。
这里有个安全细节: token 里不要放敏感信息,只放 userId、userName、role 这些非敏感字段。密码哈希、手机号、身份证这种绝对不放。因为 JWT 是明文编码的,base64 解码就能看到内容。我的 token 用的是 HS256 签名,密钥放在配置文件里,通过@Value注入,不会写死在代码里。
密码存储用 BCrypt 加密,不用 MD5。MD5 加盐虽然也能用,但 BCrypt 是专门为密码设计的自适应哈希算法,计算成本可调,抗暴力破解能力比 MD5 强一个量级。Spring Security 的BCryptPasswordEncoder直接用就行,用起来也就两行代码的事。
越权问题也要防。比如用户只能查询自己的订单,接口层光靠 token 还不够,必须从 token 解析出 userId,再和查询条件里的 userId 做比对。不能只依赖前端传入的 userId 参数,那样随便改个参数就能看别人的订单。我在 OrderController 里统一用CurrentUserHolder.getUserId()获取当前登录用户ID,前端传的 userId 一律忽略。
4. Vue3 前端项目实现与联调细节
4.1 前端工程结构和状态管理
前端用的 Vite 脚手架创建 Vue3 项目,目录结构按业务模块划分:
src ├── api # 接口请求封装 │ ├── goods.js │ ├── cart.js │ ├── order.js │ └── user.js ├── assets # 静态资源 ├── components # 公共组件 ├── router # 路由配置 ├── store # Pinia 状态管理 │ ├── user.js │ └── cart.js ├── views # 页面组件 │ ├── home │ ├── goods │ ├── cart │ ├── order │ ├── user │ └── admin ├── utils │ ├── request.js # axios 封装 │ └── auth.js # token 存取 └── App.vueaxios 封装是整个前端基础设施的核心,我统一在utils/request.js里做了请求拦截和响应拦截。请求拦截器统一加 token,响应拦截器统一处理 code 非 0 的情况,弹出错误提示并把用户踢到登录页。
// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { getToken, clearToken } from './auth' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { clearToken() router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request状态管理用 Pinia,比 Vuex 简单不少。购物车的数据结构和后端保持一致:一个数组,每项包含 goodsId、goodsName、price、quantity、checked 标记。购物车的数据我选择存在后端数据库里,不存本地,这样用户换设备购物车还能同步。商品列表页的筛选条件用 URL query 传递,这样用户可以分享链接、刷新页面后筛选条件不丢。
4.2 核心页面和交互怎么实现
商品列表页是一个典型的筛选 + 列表组合。我用onMounted里调用getGoodsList,筛选条件改变时重新拉数据。需要注意的地方是"搜索"按钮和"重置"按钮的处理:搜索重新拉第一页,重置清空所有条件再拉数据。分页用 Element Plus 的el-pagination,切换页码时带着当前筛选条件请求。
购物车页面有个典型的交互陷阱:商品数量和选中状态都是"立即生效"的操作,我会在用户点击加号、减号、勾选框时立刻调用后端接口,而不是等用户点"结算"才一次性提交。一开始我图省事把购物车数据全量提交,结果用户改数量后不结算就走了,再回来发现数量和上次不一样,体验很糟。改成即时同步后,数据一致性问题就没了。
下单确认页面要展示三块信息:收货地址、商品明细、支持哪个公益项目。公益项目选择用单选卡片组,默认选中一个公益项目,用户也可以切换。这样下单时把 charityProjectId 传给后端,后端生成订单时写进t_order表。
路由守卫必须提一句。没有登录的用户访问"我的订单""购物车结算"这些页面,要直接跳转到登录页,登录成功后再回到之前的页面。我在 router 的beforeEach里判断目标路由的meta.requiresAuth和 token 是否存在。
router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !getToken()) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })4.3 前后端联调时的接口约定
联调阶段出问题最多的地方,是接口返回结构和字段命名不一致。前端用的字段是驼峰命名goodsName,后端 Java 实体也是驼峰。但 MyBatis 查询出来映射到实体没问题,转成 JSON 后也保持驼峰,所以这里天然一致,前提是后端不要再写一大堆 getter setter 搞乱。
时间格式是联调最常见的坑。Java 侧 LocalDateTime 序列化成 JSON 默认格式是2024-05-15T10:30:12,中间带个 T,前端显示很不友好。我在配置里全局统一了序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8时区也要显式指定 GMT+8,否则部署到服务器上如果系统时区不是中国时区,用户看到的时间就会差 8 个小时。这个问题我在测试环境踩过,明明存的正确,查出来却少了 8 小时,差点以为数据库写错了。
联调时另一个建议是开发环境用 Vite 代理解决跨域。在vite.config.js里这样配:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }前端所有请求走/api前缀,Vite 把请求代理到后端 8080 端口。这样浏览器里看不到跨域,后端也不用额外配 CORS,等部署到 Nginx 时再用反向代理处理,生产环境天然同源。
5. 环境搭建与部署上线
5.1 本地开发环境怎么搭
后端的运行环境:JDK 1.8 或 17 都行,Maven 3.8+,MySQL 8.0,IDEA。前端环境:Node.js 16+,npm 或 pnpm。搭建过程中最容易卡住的是依赖下载,国内网络环境下 Maven 仓库建议配阿里云镜像,npm 配淘宝镜像,否则下载 Spring Boot 依赖和 Vite 依赖能让人怀疑人生。
后端启动步骤:先在 MySQL 里执行初始化 SQL 脚本,建库建表;然后修改application.yml里的数据库连接信息;最后运行主启动类。启动时会加载 Mapper XML,如果 XML 写错了会直接启动报错,所以第一次启动看到报错先不要慌,看日志里提示的是哪个 mapper 文件。
前端启动步骤:npm install安装依赖,npm run dev启动开发服务器,默认 5173 端口。Vite 的冷启动速度很快,基本秒开。如果npm install报错,先把node_modules删了重新装,很多莫名其妙的依赖问题这样都能解决。
5.2 打包与生产部署
前端打包用npm run build,产物会生成到dist目录。强调的是构建前要把baseURL配置成相对路径或同域下的/api,不能是http://localhost:8080,否则部署到服务器后请求地址就写死了。
后端打包用 Maven 的mvn clean package -DskipTests,打出来的 jar 包直接扔服务器:
java -jar charity-mall-1.0.0.jar --spring.profiles.active=prod生产环境我用 Nginx 做静态文件服务和反向代理。Nginx 配置有两块关键内容:把/路径指向dist目录做前端静态资源服务;把/api/路径反向代理到后端服务端口:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; 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; } }try_files $uri $uri/ /index.html这行很重要,否则刷新 Vue Router 的 history 路由页面时会报 404。因为浏览器请求的是/goods/1,服务器上并没有这个物理文件,必须让 Nginx 把所有路由都回退到index.html,再由前端路由接管。
数据库初始化用 SQL 脚本文件导入。我建议把建表和初始化数据分成两个脚本:schema.sql只负责结构,data.sql放入初始分类、管理员账号、公益项目等基础数据。分工清楚,后续测试环境重置也方便。
5.3 性能和并发上的优化实践
上线之后用压测工具简单跑了一下,发现商品列表接口响应偏慢,排查下来是商品详情查询里有个循环查询分类名称的问题:一次性查出 50 条商品,然后每条商品单独查一次分类表,产生 50 条 SQL。优化方案很简单——先查出商品列表,再用WHERE category_id IN (...)一次性查出所有分类,代码里做内存映射。SQL 数量从 51 条降到 2 条,接口响应时间从 800ms 左右降到 150ms。这种 N+1 查询问题在 MyBatis 项目里非常常见,一旦发现循环里查数据库,就要警惕。
数据库连接池我用的 HikariCP,Spring Boot 2.x 默认集成。连接池配置里maximum-pool-size我设置在 20,minimum-idle设置在 5。有个经验是连接池不是越大越好,MySQL 默认连接数上限有限,连接池设太大反而拖垮数据库。并发量上来了优先加缓存、优化 SQL,不要盲目调大连接池。
商品详情页加了一层本地缓存,用 Caffeine 做的,缓存键是 goodsId,缓存过期时间 5 分钟。商品信息属于读多写少的数据,缓存收益很大。订单这类写操作频繁、数据一致性要求高的数据,不做缓存,直接走数据库。
6. 常见问题与排查实录
6.1 跨域问题是不是都怪后端
开发阶段遇到跨域报错,先别急着改后端代码。前后端分离项目,开发环境最推荐的方式就是 Vite 代理,前端配置server.proxy即可。但如果前端直接调用后端地址,比如axios.get('http://localhost:8080/api/goods'),就一定会跨域,后端就得配 CORS。我通常两套方案都保留:开发环境优先代理,后端也配一个 CORS 配置类,万一有人直接用 IP 调试时不至于蒙圈。
后端 CORS 配置要注意一个问题:如果同时用了拦截器并且拦截器里直接返回了 401 错误,OPTIONS 预检请求也会被拦截。必须把预检请求放行,否则前端实际请求发不过去。我见过几次,前端报跨域,后端查日志发现 OPTIONS 请求被拦截了,调了半天 CORS 配置都没用,实际上问题在拦截器里。
6.2 @Transactional 失效的几种情况
这个坑必须单独说,因为我第一次做商城项目时就在这里栽过。写了下单的@Transactional,测试时故意让库存扣减抛异常,结果订单表里还是写进去了数据。排查半天发现:我的事务方法在同一个类里被另外一个方法调用了,而 Spring 的@Transactional是基于代理的,同类内部调用不会经过代理对象,事务注解直接失效。
解决方案有两个:把事务方法移到另一个 Service 类里调用,或者注入自身代理。我用的第一种,把下单逻辑单独抽到一个OrderTransactionService,写完后再调。这样结构也更清晰,事务边界更明确。
另一个事务失效场景是方法不是 public 的,事务注解不生效。Spring 官方文档明确说@Transactional只能作用于 public 方法。当时我为了代码少写几个修饰符把方法写成默认访问权限,结果事务同样失效了。
还有一个环境:@Transactional和异步方法组合时,异步调用的事务是独立的,报错不会回滚主线程的事务。这个场景在生成贡献记录时差点踩到,如果用了@Async发通知、记日志,记得这些异步操作不参与主事务。
6.3 MyBatis 参数和 ResultMap 的坑
MyBatis 传参时,如果 Mapper 接口方法有多个参数,必须用@Param注解给每个参数起名,否则 XML 里只能按param1、param2这种名字取,写着写着就乱了。我在商品条件查询接口里用了@Param("categoryId")、@Param("pageSize")这种命名方式,XML 里一眼能看出对应关系。
还有Integer和int的区别。XML 里<if test="price != null">判断的是封装类型,如果参数是基本类型 int,默认值是 0,不可能为 null,<if>永远成立,就会拼上price = 0这种错误条件。所以前端传参时,Spring MVC 会把缺失参数封装成 null,Mapper 参数那侧用到封装类型 Integer、Long 而不是基本类型,这个细节很值得留意。
ResultMap 我基本都显式定义,用autoMapping反而容易出问题。当数据库字段是下划线命名goods_name,Java 实体是驼峰goodsName,如果项目全局开启了map-underscore-to-camel-case: true,大多数普通字段能自动映射。但遇到 JOIN 查询结果中两个表都有create_time字段,映射就会互相覆盖。显式 ResultMap 能明确每个字段的来源,排查问题时看得清清楚楚。
6.4 前端联调里的几个小问题
第一个是 token 过期后请求还在继续。我在 axios 响应拦截器里判断 code 为 401 后清 token 并跳转登录页,但如果同时有多个请求都返回 401,会跳转多次。可以加一个标记位控制,只跳转一次。
第二个是购物车数量同步问题。用户开两个标签页操作同一个账号,后端购物车数据会被覆盖。这个属于单机版系统的固有局限,第一版没做多端实时同步,记录下来作为后续优化项。如果想做,可以用 WebSocket 推送或轮询方案,但这在第一版确实没必要。
第三个是图片上传。商城商品需要上传图片,我第一版用了本地磁盘存储,把图片存到服务器的/data/images目录并做了静态资源映射。这种做法简单,但要注意两点:生产环境 Nginx 要额外配一个/images/路径指向这个目录;服务器重启或迁移时图片文件要单独备份。如果图片量大,后面应该接对象存储,但第一版先跑通功能,不提前引入复杂度。
7. 后续还能怎么扩展
整个系统做完,我自己的感觉是"主链路已经稳了,但离商业化系统还有距离"。如果这个项目还要继续往深处走,有几个方向值得考虑:商品模块可以加多规格 SKU、优惠券和营销活动;订单模块可以对接支付网关,把模拟支付替换成真实支付;贡献体系可以加用户累计贡献等级、公益项目透明度展示;性能上可以引入 Redis 缓存商品热点数据、用消息队列处理下单后的贡献计算和通知。这些扩展方向和现有表结构没有冲突,因为我设计的订单表、订单明细表、贡献记录表在建模时就已经给后续扩展留了余地。
最后分享一个我做项目时的个人习惯:每做完一个模块,我会把当时踩的坑和排查思路记录在一个文档里,哪怕只是两三行字。这个项目做到后期,这份"踩坑笔记"已经积累了上百条,很多问题看一眼现象就能直接定位。等到再做一个类似系统时,前期的坑基本不会再踩第二遍,开发速度快了不止一倍。这种沉淀比代码本身更有价值,建议你也试试。