去年帮一位做课程设计的同学梳理“精品水果电商系统”这个题目时,我发现一个挺有意思的现象:很多人一上来就想着做“大而全”——购物车、秒杀、优惠券、直播入口、会员积分全都往功能清单里塞,结果数据库设计了几十张表,最后连最核心的下单流程都没跑通。水果电商这个选题真正讨巧的地方,在于它把电商的主链路(浏览->加购->下单->支付->订单管理)和生鲜商品特有的痛点(规格、库存、新鲜度标识)结合得刚刚好:不算太简单,又不至于失控。这篇文章我就把这个系统的设计与实现从头到尾拆一遍:需求怎么定、表怎么建、后端核心逻辑落在哪、Vue前端怎么对接、源码拿到手之后怎么一步步跑起来,以及我在实际复现过程中踩过的那些坑。
1. 精品水果电商系统:需求边界与功能模块的划定
1.1 为什么“水果电商”是一个恰到好处的选题
电商类系统几乎每年都是课程设计和毕业设计里的“常青树”,但很多人一选就选“全品类综合商城”,这反而给自己挖了坑——商品属性千差万别,营销玩法五花八门,最后变成堆功能而不是做产品。换成精品水果,商品维度一下就收敛了:水果只需要关心产地、规格、单位、库存和图片,属性简单但又是真实电商场景。
更关键的是,水果属于典型的生鲜商品,天然带出几个必须处理的问题:库存有损耗、商品有组合规格(比如5斤装/10斤装)、用户对新鲜度和产地有敏感度。这些问题逼着你把商品管理、库存扣减、订单状态这几个核心模块做扎实,而不是浮在表面。做完这样一套系统,你掌握的链路是完整可迁移的:换成卖数码、卖服装,核心设计逻辑完全能复用。
1.2 功能模块全景拆解:用户端与管理端各管什么
整套系统按角色可以分成两个主阵地。用户端面向普通消费者,功能线路很清晰:
| 模块 | 核心功能 | 设计要点 |
|---|---|---|
| 首页展示 | 轮播图、推荐水果、分类快捷入口 | 数据来源是后台配置的轮播表和商品表 |
| 商品浏览 | 分类筛选、搜索、分页列表、详情页 | 查询条件多,适合用动态SQL拼装 |
| 购物车 | 加入、修改数量、选中/取消、合计金额 | 购物车数据冗余存了商品快照信息 |
| 订单结算 | 地址选择、提交订单、模拟支付 | 下单是一个典型的多表事务场景 |
| 个人中心 | 订单列表、订单详情、地址管理 | 订单状态贯穿用户操作主线 |
管理员端则是把同样的数据从“维护视角”再看一遍:商品管理负责增删改查和上下架,分类管理负责树状结构维护,订单管理负责审核、发货、完成等状态的推进,另外还有用户管理、轮播图设置和基础数据统计。功能摊开看,其实就是一个“对商品的增删改查 + 对订单的状态流转 + 对用户数据的维护”的组合。
1.3 权限与角色设计:游客、普通用户、管理员
这个小系统虽然角色不多,但权限边界还是得提前画清楚,不然接口安全就是一层纸。游客只能访问商品浏览和详情接口;普通用户在前台登录后,可以操作购物车、下单和查看自己的订单;管理员则进入独立的后台界面,操作商品、分类、订单和用户数据。
实现层面不需要引入复杂的权限框架,用JWT令牌携带角色字段,后端写一个简单的拦截器就能完成粗粒度鉴权:登录接口放行,带Token的请求校验身份,后台管理接口额外校验角色。我的建议是权限模型宁可初期划粗一点,也别从一开始就设计五六个角色,否则光是菜单树和路由守卫的复杂度就够你喝一壶。
1.4 非功能需求:安全、性能与可维护性
除了功能能跑,这类系统还有几个容易被忽略的“隐形需求”。安全层面,密码不能明文落库,要用BCrypt做哈希;所有后端接口不能只用前端隐藏来保护,必须做服务端校验。性能层面,无非是商品列表的分页查询、订单查询的索引优化,以及库存扣减时的并发控制,单机QPS不要求高,但逻辑不能有漏洞。可维护性层面,前端要统一封装请求工具,后端要统一返回结构体、统一异常处理。这些都做到了,后续加功能、改bug才会轻松,也才像一个“设计完整、实现可靠”的作品。
2. 技术选型分析:SpringBoot+Vue+MyBatis为什么够用且好用
2.1 后端:SpringBoot的“开箱即用”和MyBatis的“SQL可控”
先说后端。SpringBoot在这个项目里承担的是“脚手架 + 生态整合”的角色,它对新手和老手都很友好:内嵌Tomcat,不需要额外部署容器;自动配置减少了大量XML配置;starter机制让整合MySQL、MyBatis、JWT都变成几行依赖的事。项目结构上也相对固定——controller、service、mapper、entity、config分清楚,代码自然就有条理。
ORM框架我坚持选MyBatis而不是JPA/Hibernate。原因很实际:电商这类系统的查询非常依赖人对SQL的控制力。比如商品列表的模糊搜索、多条件筛选、订单列表的联表查询,用MyBatis写动态SQL,每一行都能预料到数据库会怎么执行;在排查慢查询、做索引优化时,你能直接拿到完整SQL去EXPLAIN。JPA虽然开发效率高,但在复杂的多条件拼接场景下,它会不断把简单问题“封装”成玄学问题,对初学者不友好。
2.2 前端:Vue组件化与Element UI带来的产出效率
前端选Vue,主要看中两点:组件化和生态成熟度。组件化体现在页面可以被拆成“头部搜索条、商品卡片、分页器、购物车列表行”这种可复用单元,组件的数据通过props传、事件通过emit抛,思路清晰。配合Vue Router做路由页面跳转、Vuex/Pinia做登录态和购物车这类全局状态的管理,整个前端工程结构很规整。
在UI层面,直接引入Element UI这类组件库能省掉大量样式开发时间:表格组件自带分页和排序,表单组件自带校验,弹窗组件配合表单就是一套标准的CRUD交互模式。对以“完成系统、验证设计”为目标的项目来说,这比从零手写CSS要高效可靠得多。实测下来,一个熟练一点的同学用这套组合车,三到四周做出可演示的前台后台完全没问题。
2.3 为什么不选那些更“重”的方案
这里想多说几句方案对比,因为几乎每次有人都会问“为什么不做成微服务”。我给你一个最简单的判断标准:你面对的规模和数据复杂度,是否真的需要把系统拆成多个独立部署的服务?
对比一下主流备选方案:
| 方案 | 主要优势 | 在这个项目中的真实问题 |
|---|---|---|
| SpringBoot单体 + MyBatis | 结构清晰、部署简单、逻辑可控 | ——(最终选择) |
| SpringCloud微服务 | 水平扩展、独立部署、技术栈丰富 | 光注册中心、配置中心、网关就足够你研究两周,业务体量完全不匹配 |
| JSP/Thymeleaf服务端渲染 | 写起来直接,不用前后端分离 | 前后端耦合严重,无法复用接口给移动端;页面交互复杂时DOM管理混乱 |
| 若依/RuoYi等快速开发框架 | 脚手架自带大量功能 | 适合做管理系统,但会隐藏很多底层逻辑;如果你想通过项目学原理,不建议直接用 |
单体架构在这个阶段不是“妥协”,而是“匹配”。后端一个SpringBoot应用,前端一个Vue工程,MySQL一个库,部署到一台服务器上就能完整跑起来,这才是做课程设计和毕业设计的正确打开方式。
3. 数据库设计:十几张表撑起的一个完整电商闭环
3.1 核心表梳理
这个系统的数据模型可以拆成“用户侧”“商品侧”“交易侧”三条线,再加几张辅助配置表。
| 表名 | 职责 | 核心字段 |
|---|---|---|
| user | 前台用户信息 | 用户名、密码、昵称、手机、头像、角色 |
| goods | 商品信息 | 分类ID、名称、图片、单价、原价、库存、销量、简介、上下架状态 |
| category | 商品分类 | 分类名、排序、父级ID |
| cart | 购物车 | 用户ID、商品ID、数量、加购时间 |
| address | 收货地址 | 用户ID、联系人、手机、省市区、详细地址、默认标记 |
| orders | 订单主表 | 订单号、用户ID、总金额、状态、收货信息快照、支付时间 |
| order_item | 订单明细表 | 订单ID、商品ID、商品快照、单价、数量、小计 |
| banner | 首页轮播图 | 图片地址、跳转链接、排序、是否启用 |
| notice | 系统公告 | 标题、内容、发布时间 |
这里面有两类字段设计上的“反直觉经验”,非常值得展开说。
3.2 金额和状态字段的设计细节:不要踩这些基础坑
金额字段一定要用DECIMAL,不要用FLOAT或DOUBLE。水果单价可能不高,但订单总额是精确值,任何浮点误差都会导致前后端数字对不上。比如五斤装苹果单价18.80元,买三份总额应该是56.40元,浮点运算可能算出56.399999这样的值,用户看到会直接怀疑系统有bug。DECIMAL(10,2)能保证金额精确到分,配合后端运算用BigDecimal,是最省心的选择。
状态字段记得用TINYINT或INT存数字枚举,而不是直接存中文。比如订单状态:0待支付、1已支付、2已发货、3已完成、4已取消。好处是后端可以用常量类集中管理枚举,前端再用自定义映射转成文案,将来加状态也容易扩展。有人贪图方便把“待支付”直接写死在字符串字段里,一旦订单状态新增或改名,应用代码和存量数据全都要改,属于给自己留后患。
另外所有业务表都建议带上createTime、updateTime两个通用字段,做列表排序和排查问题时会非常有用。逻辑删除字段deleted(0正常/1删除)也建议加上,商品被删除后历史订单里的明细快照尽量保留,便于对账。
3.3 订单表设计:为什么要把收货信息“快照”进去
订单这块有两个设计点,我见过太多人忽略。第一个是订单主表不要外键关联到地址表,而是直接把收货人、手机号、省市区、详细地址冗余到orders表里。原因是用户地址随时可能改,如果订单只存一个address_id,用户改了地址,历史订单的收货信息就全乱套了。把信息“快照”进订单表,订单生成那一刻起就是不可变的历史凭证。
第二个是订单明细表里要冗余商品名称、商品图片、单价这些信息,而不是只存goods_id。商品下架、改名、调价都太频繁了,订单明细必须保证历史数据完整可读。这个套路在做电商系统时是通用的:交易快照是对账、售后和数据分析的前提。
3.4 库存扣减方式:先查再扣还是直接原子更新
库存字段是个兵家必争之地。最基本的错误写法是:A查到库存还有10件,B也查到10件,然后A扣1、B扣1,数据库同时写,最终库存变成9而不是8,这就是超卖。解决办法有两种常用方案:
第一种是“原子更新”,直接用一条SQL完成扣减:
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock >= 1受影响行数等于1才说明扣减成功,否则提示库存不足。这种方式简单可靠,适合当前系统量级。
第二种是“乐观锁”,就是在表里加version字段,更新时比较version。适合高并发场景,但当前项目用第一种就够了。要注意:库存扣减必须和订单创建放在同一个数据库事务里,否则订单建了库存没扣、或者库存扣了订单没成,数据都会不一致。
4. 后端核心代码逻辑:从登录鉴权到下单事务
4.1 JWT登录鉴权:无状态会话的前后端配合
登录模块用的是JWT方案。用户提交用户名和密码后,后端校验BCrypt哈希,通过则生成Token返回前端,Token里面可以放进用户ID和角色信息。
// 生成JWT String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();前端拿到Token后存到localStorage,之后每次请求用Axios拦截器统一加到请求头里:
config.headers['Authorization'] = 'Bearer ' + token;后端再配合一个HandlerInterceptor实现登录校验,只有Token解析成功且未过期才放行。这里有一个细节提醒:JWT的密钥一定不要写在代码里,放到application.yml配置文件中,将来部署时替换也方便。
4.2 统一返回体与全局异常处理
接口设计上最值得养成的习惯,是定义一个统一的Result 返回结构,包含code、msg、data三个字段。所有Controller的返回值都包一层,前端就能用统一的逻辑去解析成功和失败,而不是每个接口各写一套判断。
全局异常处理用@RestControllerAdvice加@ExceptionHandler就能实现:业务异常统一返回业务错误码,参数异常返回400,未知异常返回500并记录日志。这一个组件能少掉前端大量“接口三不管”的尴尬情况——要么不返回、要么返回了一堆英文堆栈,前端根本没法提示用户。
4.3 商品分页查询与动态SQL
商品列表页是最典型的多条件查询场景:用户可能按分类过滤、按名称模糊搜索、按价格区间筛选、按销量排序,条件组合情况非常多。MyBatis的动态SQL在这里价值最大:
<select id="selectGoodsPage" resultType="com.example.entity.Goods"> SELECT * FROM goods <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> AND deleted = 0 </where> ORDER BY ${sortField} ${sortOrder} LIMIT #{offset}, #{pageSize} </select>这里要注意排序字段用${}而不是#{},因为排序字段是列名,不能参数化;但传入的值必须做白名单校验,防止被注入。分页我建议手写LIMIT,不要在这个小项目里引入PageHelper,理解“偏移量+条数”这个分页逻辑本身也是学习的一部分。
4.4 下单流程:事务边界与库存扣减的代码细节
下单是整套系统里最需要严谨对待的逻辑。一次提交订单,后端要依次完成:校验用户地址、创建订单主表记录、把购物车勾选项写入订单明细表、扣减库存、清空对应购物车项、在当前事务里模拟一个支付结果。
核心事务方法结构大致如下:
@Transactional(rollbackFor = Exception.class) public OrderVO submitOrder(OrderCreateDTO dto, Long userId) { // 1. 查询收货地址并校验归属 Address address = addressMapper.selectByIdAndUser(dto.getAddressId(), userId); // 2. 根据购物车勾选项计算出订单总金额 List<CartItemVO> cartItems = cartMapper.selectCheckedItems(userId); // 3. 逐条扣减库存,只要有一条失败则抛出异常回滚 for (CartItemVO item : cartItems) { int affected = goodsMapper.decreaseStock(item.getGoodsId(), item.getCount()); if (affected == 0) { throw new BusinessException("商品库存不足:" + item.getGoodsName()); } } // 4. 插入订单主表和订单明细 // 5. 清空购物车已下单项 // 6. 返回订单信息 }@Transactional默认遇到运行时异常就回滚,rollbackFor = Exception.class是为了连检查时异常也纳入回滚,这个细节必加。只要事务里任何一步失败,前面扣掉的库存和生成的订单数据都会一并回滚,数据库就能保持一致性。很多人首次写订单逻辑时会忘记把库存扣减放到事务里,结果就是库存和订单数据越跑越乱。
4.5 购物车:临时数据与商品信息的合理分离
购物车表里也有一个值得分享的设计细节:cart表只存user_id、goods_id、quantity三个核心字段,商品价格、名称、图片在查询时实时联表带出来。为什么不冗余?因为购物车是“临时数据”,它应该永远反映商品当前的真实价格和状态。下单时才做正式快照,购物车本身不需要保留历史版本。查询购物车列表的SQL大致是左连接商品表,把逻辑删除的商品过滤掉,并对商品已下架的数据做前端置灰处理。
5. 前端Vue工程:页面怎么搭、接口怎么对接
5.1 前端工程结构与路由设计
Vue工程推荐用Vue CLI或者Vite初始化,目录规划上我习惯这样分:src/api放所有接口请求定义,src/views放页面组件,src/components放复用组件,src/router放路由配置,src/store放全局状态。这样的结构不复杂,但在多人协作或者后续自己维护时非常清晰。
路由配置分成两个区域:游客和登录用户可以访问的前台路由,以及管理员专用后台路由。后台路由整体挂在一个带侧边栏布局的父路由下,并用前置守卫做登录和角色校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path.startsWith('/admin') && (!token || role !== 'ADMIN')) { next('/login') } else { next() } })5.2 用户端核心页面:首页、列表、购物车、结算
首页通常包含轮播图(从banner表读取)、分类导航和推荐商品区块。商品列表页要承担筛选、排序、分页三个任务,交互模型很固定:筛选条件变了就重载列表,分页当前页和每页条数传到后端。购物车页面有个细节是“选中状态”的管理,必须在前端维持一套本地的checked映射,最终提交订单时只把选中的商品ID列表传给后端。
结算页面是用户端代码里最容易写乱的地方:选地址、计算总价、提交订单、跳转结果页,四条逻辑串联。我的建议是整个提交过程用一个loading状态锁住按钮,防止用户重复点击导致重复下单。后端虽然有事务保证数据不会错乱,但重复订单对用户来说体验极差。
5.3 Axios拦截器与接口封装:一个前后端协同的规范
Axios封装直接影响整个前端体验。统一创建一个request.js文件,实例化axios,然后加上两个拦截器:请求拦截器往header里加Token;响应拦截器统一判断code,正确时直接返回data数据,错误时弹出统一提示,遇到401则清除登录信息并跳转登录页。
接口定义单独拆到api目录下,比如goods.js里:
export function getGoodsPage(params) { return request.get('/goods/page', { params }) }页面里只需要调用这层函数,不用每次重复写URL和错误处理。很多前端接口代码混乱的项目,通常就是缺了这层统一封装,每个页面自己处理一遍状态码,改起来极其痛苦。
5.4 管理后台CRUD套路:表格组件+弹窗表单
管理后台的前端代码其实很机械,折腾的点反而不多:一个页面就是左侧一个搜索栏、中间一个数据表格、右上角一个新增按钮,点新增/编辑时弹出一个表单弹窗。在Element UI里,这个模式写熟之后基本可以复制粘贴再改字段名。
商品管理后台稍微特殊一点,因为要处理图片上传。建议后端做一个简单的文件上传接口,前端用Upload组件适配,上传成功后把返回的URL保存到表单的image字段。注意实体上不需要把文件存进数据库,路径很多项目是G:\upload\这种绝对路径,这写着方便但是对未来上线是坑;正确做法是数据库存/boutique-fruit/upload/xxx.jpg这种相对路径,前端访问通过统一网关或静态资源映射去拼。
6. 源码复现与本地部署:从拉到代码到跑通全流程
6.1 环境版本匹配:提前确认,别等报错才后悔
源码跑不起来的大部分原因都不是代码问题,而是环境版本不匹配。我一版建议这套组合:
| 环境组件 | 推荐版本 | 避坑说明 |
|---|---|---|
| JDK | 1.8 或 11 | SpringBoot 2.x全系兼容,3.x需要JDK17,注意看pom中的parent版本 |
| Maven | 3.6+ | 仓库源建议配阿里云镜像,不然依赖下到怀疑人生 |
| MySQL | 5.7 或 8.0 | 8.0必须带驱动版本并指定serverTimezone |
| Node.js | 14到18之间 | 太新或太旧都可能触发npm依赖兼容问题 |
| SpringBoot | 2.5.x到2.7.x | 这个区间相对成熟,参考示例代码多 |
| Vue CLI | 4.x或5.x | 创建项目时选用默认配置即可 |
6.2 数据库初始化与后端启动流程
拿到源码后第一步不是打开IDEA,而是先建库导数据。在MySQL里创建一个数据库,建议字符集选utf8mb4,因为emoji或者生僻字符用utf8会存不进去。然后导入项目提供的sql文件,通常这个文件会包含建表语句和初始数据。SQL导入后,改application.yml里的数据库连接信息:地址、用户名、密码三个地方,密码别直接用明文写在配置里,本地开发可以先用环境变量覆盖:
spring: datasource: url: jdbc:mysql://localhost:3306/fruit_mall?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: ${MYSQL_USER:root} password: ${MYSQL_PASSWORD:123456}然后直接运行启动类。看到“Started Application in X seconds”这样的日志才算后端启动成功。
6.3 前端启动与联调
前端先把依赖安装好,npm install或者yarn install跑一遍。启动命令一般是npm run serve,浏览器访问生成的地址。你要注意这里的访问端口通常和后端不一致,比如前端是8080、后端是8081,这就踩到了跨域问题。Vue CLI的devServer里配置proxy是最省心的方案:
devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }这样前端发的/api请求会被本地代理转发到后端,浏览器侧就没有跨域问题了。上线后用Nginx反代理也是同样的思路,把前端的/api请求转发到Java应用的端口。
6.4 常见启动问题的排查清单
我整理一份高频问题速查表,都是实操中真实的报错现场:
| 报错现象 | 根本原因 | 快速解决 |
|---|---|---|
| Access denied for user | 数据库账号密码错误或权限不足 | 检查application.yml连接信息,用Navicat测试连接 |
| Unknown database | 数据库名跟配置文件不一致 | CREATE DATABASE补建库,核对名字大小写 |
| Public Key Retrieval is not allowed | MySQL 8.0驱动的安全策略 | JDBC URL加allowPublicKeyRetrieval=true |
| Server returns invalid timezone | 时区未设置 | URL加serverTimezone=Asia/Shanghai |
| Port 8080 was already in use | 端口被占用 | 换端口,或kill掉占用进程 |
| Module not found: 'element-ui' | 前端依赖没装全 | 重新npm install,检查package.json |
| Failed to execute goal ... (打包) | Maven依赖冲突或网络问题 | 换阿里云镜像源,clean再install |
| 前端请求接口404 | proxy没生效或后端路径不对 | 看Network里请求的实际路径,对比controller映射 |
6.5 部署到服务器的简化路径
如果只是要让系统跑在服务器上给老师演示,没必要折腾复杂的容器化。最省事的方式是:后端用mvn package打成jar包,扔到服务器上nohup java -jar xxx.jar运行;前端npm run build生成dist目录,把这个目录放到Nginx里,再配置一个location转发/api到后端端口。整台机器只要装好JDK、MySQL和Nginx就够了。服务器记得在防火墙和安全组里放行8081或你自定义的端口,不然外部访问全被拦,这是最常见也最哭笑不得的“部署失败”原因。
7. 做完这个项目后最想分享的经验与扩展方向
7.1 学习这类“完整源码”的正确姿势
拿到完整源码之后,千万别一头扎进代码逐行读,那样大概率三天后迷路。我自己的学习路径是这样:先跑起来,作为“黑盒”体验一遍用户端和管理员端全部功能,对系统有了整体感知;然后跟着项目里带的数据库设计文档把三十多张表的数据流理一遍,确认每个表之间的主外键关系;最后挑一条核心链路(商品浏览到下单完成)从头到尾追踪一次,从前端接口到Controller再到Service最后到SQL,一条线打通,你对源码的理解就超过八成的人。
这种追踪时很有用的一个技巧是全局搜索接口路径。比如前端里看到一个请求是POST /order/submit,后端就用@RequestMapping("/order/submit")去定位。多试几次,前后端的对应关系就了然于心了。
7.2 三个可以继续追加的能力点:缓存、支付、秒杀
这套系统跑通之后,它的价值绝不仅仅是交差。想让它给你的简历带来更多亮点,有明确的追加开放点。
第一个是把Redis引入进来:商品热榜、首页轮播缓存、验证码存储、购物车临时数据,这些都是Redis的经典场景。学会“数据库存主数据、Redis存热数据”之后,你对性能优化的理解会立刻升级。第二个是接入真实支付回调,微信支付和支付宝支付的AppID、回调验签、异步通知逻辑是电商项目面试必问的点,能自己写一遍回调处理,价值非常高。第三个是做一个简单的秒杀活动:Redis预扣库存加MQ异步下单,这套组合是互联网电商高并发场景的教科书实现。
7.3 一点个人体会
最后说说我做这类项目最深的一个感受:别被“系统管理系统”的题目限制住思路。水果商城听起来普通,但它的表结构、订单流程、前后端协作模式,完全可以平移套用到洗衣店预约、宠物寄养、奶茶点单等一堆场景里,换一套字段、换一套交互,就是一个新项目。整个过程中最花时间的往往不是写代码,而是想清楚数据和状态之间的流转关系。把这些想明白了,代码只是最后的落地动作。
做这个项目时我还养成了一个习惯:每完成一个功能,就用Git提交一次,提交信息写得具体一点,比如“完成购物车数量修改”“修复库存扣减并发问题”。这个习惯平时看起来不起眼,但一旦你改乱了代码想回退,或者想复盘某个功能当时怎么做的,你都会被当初的自己深深感谢。