1. 先搞清楚这是一套什么样的系统
1.1 电池销售业务的真实痛点在哪
每年一到课程设计或者毕业设计的季节,就能看到大量同学在到处找管理系统源码。Java + Vue 的组合这两年特别火,因为它既有后端的业务逻辑、事务处理、数据库设计,又有前端的页面交互、路由控制、状态管理,一套做下来基本覆盖了企业级开发的主要环节,写进简历里也拿得出手。电池销售系统就是这类项目里很典型的一个选题——表面上看是一个「商品进销存」,但真的把需求拆开之后,你会发现它牵扯到的问题远比想象的要多。
电池这个品类本身有三个很特殊的属性,决定了销售系统的设计不能照搬通用的「商品 + 订单」模型:
第一个是型号维度。电池不是按「SKU 单品」管理的,而是按「型号 + 规格」管理。比如同样是 18650 锂电池,容量有 2000mAh、2600mAh、3500mAh 之分,还有平头尖头、带保护板不带保护板的区别。库存和价格必须挂在型号规格这一层,而不是笼统地挂在「电池」这个大类上。
第二个是批次和日期。电池是有保质期和循环寿命的,实体销售中经常会遇到批次管理的问题。虽然课程设计项目通常不会做到完整的批次追溯,但数据库设计时预留生产日期、批次号字段,是一个很加分的细节。
第三个是客户与价格的关系。电池销售中有大量批发客户,批发价和零售价往往不一样。所以订单表里的成交单价必须从商品表里冗余出来,不能实时去查商品表——否则商品调价之后,历史订单的金额就全错了。
所以这套系统的核心业务闭环是:商品管理(电池型号规格)→ 客户管理 → 下单 → 扣库存 → 记录流水 → 销售统计。这个链路想清楚之后,整个项目的骨架就定了。
1.2 系统要覆盖哪些角色和流程
从角色来看,这个系统通常分两类用户:管理员和普通销售员。管理员负责商品维护、客户管理、查看全局报表;销售员负责日常下单、查看库存、处理自己的订单。权限控制在真实场景里是必要的,但在课程设计或毕设里,有些同学会做成「单角色 + 简单登录」,我建议还是做成两套角色——答辩时老师问「为什么需要权限设计」这个问题,你能给出清晰回答,这就是一个亮点。
从流程来看,最核心的是销售下单流程:
选择客户 → 选择电池型号 → 输入数量 → 校验库存 → 计算金额 → 生成订单 → 扣减库存 → 记录流水。
这个流程里最容易做错的地方,就是「校验库存」和「扣减库存」这两个动作。很多同学直接在 Service 里先查一次库存,判断够不够,然后插入订单,再 update 库存——这看起来没问题,实际上在高并发或者多线程环境下会出大乱子。后面我会专门讲这里为什么必须用事务和锁。
除了销售主流程,还有采购入库的逆流程。采购入库时增加库存并写流水,销售出库时减少库存并写流水。只要把「库存变化」这件事单独拿一张表来记录,系统就天然具备了对账的基础。
1.3 这个项目交付物的正确理解方式
标题里写了「源码 + 数据库 + 文档」,这意味着它不只是一个代码仓库,而是一套完整可交付的项目。源码负责功能实现,数据库脚本负责环境初始化,文档负责让别人能看懂、能复现、能二次开发。
我接触过很多拿到这类项目却跑不起来的同学,问题往往不出在代码本身,而是出在环境割裂:数据库脚本用 MySQL 5.7 写的,本地装的是 MySQL 8,密码加密方式变了导致连不上;前端用的 Node 14 启动,本地装的是 Node 18,依赖装完直接报错;后端端口被占用、Redis 没启动、JDK 版本不对……这些问题占了排障时间的一大半。
所以这篇文章打算用「做项目的人」的视角,把技术选型的依据、数据库拆表的过程、前后端关键实现、部署过程中最容易卡壳的地方,全部串一遍。看完你不仅知道这套系统每行代码在干什么,还能真正理解为什么这样设计,遇到问题怎么排查。
2. 技术栈选型的考量:为什么偏要是 Java + Vue
2.1 后端选型的真实逻辑
Java 后端在这个项目里几乎没有悬念地选了 Spring Boot + MyBatis Plus + MySQL,这不是跟风,而是这套组合在「教学项目」和「企业实际开发」之间达到的平衡点是最好的。
Spring Boot 解决的是配置地狱的问题。如果回到 SSH 时代,光是一个 Spring 的 XML 配置就能劝退一大半初学者。Spring Boot 的自动配置让项目可以从零快速跑起来,需要自定义的地方用 application.yml 就能处理,这对课程设计和毕设来说太重要了——因为你的时间应该花在业务逻辑上,而不是花在配置容器上。
MyBatis 是另一个关键点。国内企业用 MyBatis 的比例相当高,尤其是涉及复杂 SQL 的场景,它的灵活度比 JPA/Hibernate 高得多。而 MyBatis Plus 又在 MyBatis 基础上补足了单表 CRUD 的痛点:BaseMapper 自带增删改查,分页插件一行配置搞定,这写代码的效率提升不是一星半点。我在实际做这个项目时,80% 的数据库操作都用 MyBatis Plus 内置方法解决了,只有订单统计、多表关联查询才自己写 XML 里的 SQL。
数据库用 MySQL 8 而不是 5.7 的原因很简单:MySQL 8 支持窗口函数,做「按月统计销售趋势」这类报表会方便很多。虽然这个项目用不到太复杂的窗口函数,但既然是新项目,没必要选一个即将过时的版本。
2.2 前端为什么用 Vue 2 而不是直接上 Vue 3
这是个好问题,也是很多人在选型时纠结过的问题。Vue 3 已经是主流了,Composition API 也让代码组织更灵活,但如果你是在做课程设计或毕业设计,我反而建议根据你的基础来选。
如果你之前完全没接触过 Vue,或者项目要求里没有明确指定版本,Vue 2 + Element UI 的生态是最成熟的。Element UI 的组件文档、案例数量、问题解决方案存量都极其丰富,你遇到一个表格操作卡住或者弹窗不显示的 bug,搜一下基本都有现成答案。Vue 3 的 Element Plus 虽然也不错,但社区沉淀的教程数量还是比 Vue 2 时代少一些,对新手更不友好。
另外还有一个很现实的原因:很多院校的课程大纲里教的还是 Vue 2,期末答辩时老师更熟悉的是 Vue 2 的写法。用 Vue 2 做出来的东西,老师在代码里一眼能看懂你在干什么;用 Vue 3 的 setup 语法,老师反而要反应一下。当然如果你已经有 Vue 3 基础,直接上 Vue 3 也完全没问题,技术栈本身不影响系统功能。
这个项目的技术栈最终定下来是:
- 后端:Spring Boot 2.7 + MyBatis Plus 3.5 + MySQL 8 + Maven
- 前端:Vue 2 + Vue Router + Vuex + Axios + Element UI
- 鉴权:JWT(JSON Web Token)
- 开发工具:IDEA + VS Code + Navicat
这套组合覆盖了前后端分离开发的核心环节,而且都是国内企业里真实在用的东西,不是那种「只存在于课本里」的技术。
2.3 前后端分离项目的基础目录结构
拿到源码之后,第一件事应该是搞清楚目录结构,而不是急着启动。
后端代码的典型分包方式是:
controller:接收 HTTP 请求,校验参数,调用 serviceservice:业务逻辑,事务边界在这里mapper:数据访问层,MyBatis Plus 的 Mapper 接口entity:数据库表对应的实体类dto:前端传参的数据对象,跟 entity 分离vo:返回给前端的视图对象config:配置类,比如跨域、MyBatis Plus 分页插件utils:工具类,比如 JWT 工具、日期工具common:统一返回值、统一异常处理、常量
前端的基本目录是:
views:页面组件,比如Login.vue、OrderList.vuerouter:路由配置store:Vuex 状态管理api:封装所有后端接口请求components:公共组件utils:axios 实例、token 存储等工具layout:主布局框架,一般是左侧菜单 + 右侧内容区
这个结构本身就是一个「标准答案」。答辩时老师问「你的项目是怎么组织的」,你能把这个目录结构复述一遍,就已经能说明你真的理解了项目。
3. 数据库设计:从订单反推出来的表结构
3.1 五张核心表是怎么拆出来的
数据库设计是整个系统最见功底的部分。很多同学的数据库是「想到哪建到哪」,表之间没有外键逻辑,字段命名混乱,导致后面写代码满头包。我设计这个系统时用的是「从订单反推」的思路——先确定整个业务最核心的事件是「产生一笔销售订单」,然后围绕这个事件拆解数据需求。
一笔订单发生之前需要什么?需要知道是哪个客户买的,所以有customer表。需要知道买的是什么商品,所以有battery_product表。订单发生之后要记录什么?订单编号、下单时间、总金额、经手人,所以有sales_order表。一笔订单里可能同时包含多个型号的电池,所以订单和商品是多对多的关系,必须拆一张order_item明细表出来。库存变化怎么追踪?单独建stock_record流水表,记录每一次入库和出库的变化。
这么一推,五张核心表就出来了:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| sys_user | 系统用户 | 用户名、密码(加密存储)、角色 |
| battery_product | 电池商品 | 型号、类型、容量、电压、单价、库存量 |
| customer | 客户信息 | 客户名称、联系人、电话、地址、客户等级 |
| sales_order | 销售订单主表 | 订单编号、客户 ID、总金额、订单状态、下单时间 |
| order_item | 订单明细表 | 订单 ID、商品 ID、数量、成交单价、小计金额 |
| stock_record | 库存流水表 | 商品 ID、变动类型(入库/出库)、变动数量、关联订单号 |
凌乱的业务需求一旦落到表上,就变得非常清晰了。我在做这个项目时有个体会:如果数据库设计阶段花了一整天去推敲,后面写代码反而特别快;如果数据库随便建一建就动手写代码,后面改表结构的时间会让你怀疑人生。
3.2 库存流水为什么值得单独建表
很多简化版的管理系统会把库存数量直接存在商品表里,出库时直接update商品表的库存字段。这样做的问题是:你只知道现在的库存是多少,但不知道这些库存是怎么变化的。
单独建一张stock_record流水表,本质上是把「状态」和「变化」分开存储。商品表里的库存是一个「状态」,它告诉系统当前还剩多少;流水表里记录的是「变化」,它告诉系统每一次库存变动的来龙去脉。有了这张表,就可以回答很多真实业务里必须回答的问题:这个月进了多少货?卖了哪些型号?哪笔订单导致某个型号库存告急?
这也是实际企业系统里普遍采用的做法。库存流水听起来是额外的复杂度,但长远来看,它是系统对账、审计、复盘的基础。对于电池这种有保质期、有批次概念的品类,流水表的存在让未来的批次追溯变得可能。
3.3 建表时容易忽略的细节字段
建表时每个人的习惯不一样,但有几个字段我建议不管什么表都加上,项目答辩时能体现专业性:
第一个是创建时间和更新时间。MySQL 里可以直接用datetime类型,MyBatis Plus 里通过@TableField(fill = FieldFill.INSERT)自动填充。有了这两个字段,排查数据问题时会轻松很多。
第二个是逻辑删除标记。用deleted字段配合 MyBatis Plus 的逻辑删除功能,执行 delete 操作时实际上是 update,数据不会物理消失。这个设计在企业里的标准做法,学生在项目中能主动用上,本身就说明对数据安全有意识。
第三个是remark备注字段。电池销售过程中有太多临时性的信息需要记录,比如「这批 18650 电池是特价清仓」「这个客户要求月底开票」,有备注字段可以让系统应对各种非结构化的需求。
另外特别提醒一个常见问题:金额字段用decimal而不是double或float。浮点数在数据库里的精度问题是经典老坑,搞过支付系统的人都知道,一分钱能差出天大的事。电池批发订单金额动辄上万,用decimal(10,2)是最稳妥的选择。
4. 后端实现的几个关键环节
4.1 登录鉴权:为什么用 JWT 而不是 Session
前后端分离的架构下,登录状态怎么保持是一个必须解决的问题。传统单体应用的 Session 方案里,服务器要保存用户的登录状态,前端通过 Cookie 里的 SessionID 来识别身份。但前后端分离之后,前端可能跑在 8080 端口,后端跑在 8081 端口,跨域请求下 Cookie 处理本身就麻烦,而且如果未来要做多个服务实例,Session 同步更是噩梦。
JWT 的思路完全不同。用户登录成功后,服务器签发一个加密的 Token 返回给前端,前端把它存在本地,每次请求时放在请求头里带上。后端通过解析 Token 来确认用户身份,不需要在服务端保存任何会话数据。
这个项目的 JWT 实现逻辑是这样的:
public class JwtUtils { private static final String SECRET = "battery-sale-secret-key"; public static String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }Token 的有效期设置为 24 小时,前端拦截到 401 状态码时自动跳转到登录页。这个方案写起来简单,逻辑清晰,答辩时也容易讲明白。
4.2 订单事务:扣库存和生成订单必须捂在一个事务里
这是整个项目最容易出错,同时也是最值得深入理解的一个点。
前面提到销售下单流程的几个步骤:校验库存、生成订单、扣减库存、写流水。如果这些操作不是原子的——比如订单生成了但库存扣减失败,或者库存扣了但订单没生成——那么系统就处于一个数据不一致的状态。这种不一致在金额和库存相关的系统里是绝对不允许出现的。
Spring 解决这个问题的方式就是@Transactional注解。给方法加上这个注解之后,方法内的所有数据库操作会放在同一个数据库事务里执行,要么全部成功,要么全部回滚。
但@Transactional不是加上就万无一失。有一个非常经典的坑:如果库存校验和扣减操作之间有并发问题,两个请求同时读到库存还有 10 件,都认为可以卖 10 件,结果库存变成了负数。解决方案有好几种,课程设计层面最容易被忽视但最实用的一种是加数据库行锁:
@Override @Transactional(rollbackFor = Exception.class) public boolean createOrder(OrderCreateDTO dto) { // 1. 生成订单主表记录 // 2. 遍历商品明细,逐个扣库存 for (OrderItemDTO item : dto.getItems()) { BatteryProduct product = batteryProductMapper.selectByIdForUpdate(item.getProductId()); if (product.getStock() < item.getQuantity()) { throw new BusinessException("商品 [" + product.getModel() + "] 库存不足"); } product.setStock(product.getStock() - item.getQuantity()); batteryProductMapper.updateById(product); } // 3. 写入库存流水 // 4. 返回订单号 }selectByIdForUpdate使用了SELECT ... FOR UPDATE语句,查询记录的这一刻就把这条商品记录锁住了,其他事务必须等当前事务提交后才能继续操作同一行数据。这是一个在并发场景下非常实用的手段,也是面试官喜欢深挖的点。
4.3 Controller、Service、Mapper 各层的边界划分
分层的本质是「职责分离」。很多初学同学把业务逻辑全写在 Controller 里,一个方法几百行,看起来也能跑,但一旦项目变大,代码根本没法维护。
我的习惯是这么分的:
Controller 层只干三件事:接收参数、简单校验、调用 Service。它不应该出现任何if (xxx == null)之类的业务判断。心里默念:Controller 的每个方法应该短到一屏能看完。
Service 层是业务逻辑的核心,也是事务注解所在的地方。订单生成、库存扣减、金额计算、数据分析这些实际业务规则,全都应该在这里。Service 里可以调用多个 Mapper 方法,也可以调用其他 Service 的方法,但不要写 SQL。
Mapper 层只做数据访问。单表操作用 MyBatis Plus 内置方法,多表关联或者复杂统计就在 XML 里写 SQL。一个 Mapper 方法只对应一个数据库操作,不要在一个方法里塞太多逻辑。
这种划分在执行效率上不是最优的——多一层就多一点调用开销——但在可维护性和可理解性上收益巨大。做课程设计或者刚入行写代码,养成「职责分明」的习惯比追求性能重要得多。
4.4 统一返回值和全局异常处理
前端调用后端接口时,最不喜欢的就是后端返回的数据格式五花八门:有的接口返回 JSON 对象,有的返回数组,出错时有的返回字符串,有的直接报 500。前端对接时每个接口都要单独处理,这种体验很糟糕。
解决方式是定义一个统一的结果对象,比如Result<T>:
public class Result<T> { private Integer code; private String message; private T data; // 静态方法:success(data)、error(message) }所有接口都返回Result,前端的 Axios 拦截器里做一个统一的响应处理。成功时code为 200,取出data传给页面;失败时code非 200,弹出message提示用户。这样前端的错误处理代码只写一遍,后端接口的返回格式也统一了。
全局异常处理用@RestControllerAdvice配合@ExceptionHandler:业务异常抛BusinessException,参数校验失败抛MethodArgumentNotValidException,未知异常兜底捕获返回通用的服务器错误提示。这样数据库报错、空指针异常这些不该暴露给用户的东西,都不会直接摔到前端页面上。
5. 前端实现的组织方式
5.1 路由、权限和页面骨架
前端是一个标准的 Vue 2 单页应用,整体页面骨架采用「左侧菜单 + 右侧内容区」的布局,使用 Element UI 的el-container组件搭建。
路由配置要区分两种情况:不需要登录就能访问的(比如登录页),以及必须登录才能访问的(业务页面)。实现方式是 Vue Router 的全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') { next(); } else if (!token) { next('/login'); } else { next(); } });这个逻辑看起来简单,但有细节要补充:从localStorage里取到 token 只代表「前端认为用户登录过」,token 是否过期、是否被篡改,必须让后端在接口层面去校验。前端守卫只是用户体验层面的拦截,真正的安全边界在后端。
菜单和权限的联动方式有两类做法:一类是后端根据用户角色返回菜单列表,前端动态生成路由;另一类是前端写死所有菜单,根据角色控制显隐。课程设计项目用后者就足够了,因为角色只有管理员和销售员两种,菜单差异不大。前者是更「企业级」的做法,但复杂度提升明显,没有太多必要。
5.2 Axios 封装和请求拦截
前端跟后端通信的入口统一在api目录下封装一个 axios 实例,而不是每个页面直接import axios from 'axios'。所有请求都通过这个实例发出,好处是可以统一处理三件事:请求头带 token、响应返回统一处理、错误状态码统一拦截。
import axios from 'axios'; import { Message } from 'element-ui'; const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { Message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } Message.error(error.message || '网络异常'); return Promise.reject(error); } );注意请求拦截器里把 token 放进了Authorization请求头,后端拦截器会从请求头里解析 token。响应拦截器里统一判断code字段,成功时直接把data返回给页面方法,这样页面里的代码可以很简洁:
const data = await getOrderList(params); this.tableData = data;这个封装做好了,前端每个页面组件可以少写几十行重复代码。
5.3 订单页面从选商品到提交的完整交互
订单功能是前端最核心的页面,也是交互最复杂的一块。页面上通常分几个区域:
第一步是客户选择。用el-select下拉框,支持远程搜索——输入关键字调后端接口模糊查询客户名称,选中后显示客户详情。
第二步是选择商品。推荐用表格加「添加」按钮的方式:点击添加后弹出一个商品选择对话框,展示商品列表,支持按型号、类型过滤,选中后填充到订单明细表格里。明细表格每一行包含商品型号、库存余量、单价、数量、小计金额,数量列用el-input-number控制,设置最小值 1,最大值不超过库存。
第三步是金额计算。监听明细行的数量变化,动态重新计算每行的小计和订单总金额,展示在页面底部。这一步要特别注意:前端展示的金额只是「预估金额」,真正权威的金额必须以服务端计算为准。所以哪怕前端把金额算得很精确,提交订单时后端仍然会重新遍历明细、重新算一遍金额。前后端都计算的意义在于:前端即时反馈,后端保证正确。
第四步是提交订单。点击提交后调用后端创建订单接口,成功后清空明细并跳到订单列表页,同时在列表页刷新出最新订单。整个流程在el-dialog或单独页面里完成都可以,我比较建议用单独页面,代码逻辑清晰,也方便后续跳转。
5.4 数据统计页面的 ECharts 图表展示
销售管理系统的「管理」二字,很大程度体现在数据统计上。这个项目里我做了三个图表:近 7 天销售趋势折线图、电池类型销售占比饼图、各型号销量排行柱状图。
图表用 ECharts 实现,推荐按需引入而不是全量引入,否则打包体积会大不少。统计接口的后端实现是典型的聚合查询,SQL 里按日期分组:
<select id="selectSalesTrend" resultType="map"> SELECT DATE(create_time) AS date, SUM(total_amount) AS amount, COUNT(*) AS order_count FROM sales_order WHERE create_time >= #{startDate} AND create_time <= #{endDate} GROUP BY DATE(create_time) ORDER BY date </select>前端拿到聚合数据后直接组装成 ECharts 需要的格式。图表渲染的常见坑在于:容器没有高度时图表不显示、数据更新后图表不刷新。解决方式是给图表容器设置固定高度,数据变化后用setOption并传入notMerge: true。
6. 从源码到能跑起来:环境配置与部署指南
6.1 后端启动前必须改的几个配置
很多同学拿到源码的第一步是兴奋地双击启动,然后被一堆报错泼冷水。后端启动失败的绝大多数原因集中在配置文件上。打开src/main/resources/application.yml,你需要确认这几个配置项:
数据库连接相关:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/battery_sale?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码有几点提醒:数据库名battery_sale要先用 Navicat 或命令行执行建库脚本创建;serverTimezone=Asia/Shanghai必须要加,否则报时区错误;MySQL 8 的驱动要用com.mysql.cj.jdbc.Driver,MySQL 5.7 用的com.mysql.jdbc.Driver在新驱动里会报错。
端口默认是 8080,如果你本机有别的服务占用了 8080,改成 8081 之类不冲突的端口。Token 密钥、Token 过期时间这类配置在application.yml里可以预留出来,不要写死在代码里。
6.2 前端开发和构建的关键命令
前端项目拿到手,依次执行这几条命令就能跑起来:
npm install npm run devnpm install安装依赖时经常会卡住或者报错。常见解法是切换到国内镜像源。比如用npx cnpm或者配置.npmrc:
registry=https://registry.npmmirror.com如果安装过程中出现 node-sass 相关的报错,大概率是 Node 版本和 node-sass 版本不匹配。这是前端项目最经典的坑,解决方案是查看package.json里声明的 node-sass 版本,然后确认本地 Node 版本与之兼容。最省事的方式是安装项目作者推荐的 Node 版本。
npm run dev启动后默认监听 8080 端口,但这里有个前后端联调的关键问题:前端页面跑在 8080,后端接口在 8081,浏览器会直接发起跨域请求。开发环境的跨域问题通过 Vite 或 Vue CLI 的 devServer 代理配置解决,把/api前缀的请求转发到后端地址。生产环境的跨域问题则在 Nginx 反向代理层解决——前端静态文件和控制台由 Nginx 处理,/api开头的请求转发到 Java 后端端口。
6.3 前后端整合部署的一次完整演示
前面讲的是开发环境的启动方式,如果你希望把整个系统部署到一台服务器上,方案是这样:
用 Maven 打包后端项目,执行mvn clean package -DskipTests,在target目录下得到一个 jar 包,服务器上执行java -jar battery-sale.jar就能启动后端。
前端执行npm run build,打包产物在dist目录里。把dist文件夹里的静态文件放到 Nginx 的html目录下,Nginx 配置里增加反向代理规则:
location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样部署完成之后,用户访问 Nginx 的 80 端口,页面加载前端静态文件,接口请求自动转发到后端的 8080 端口。前后端不再有跨域问题,因为所有请求的域名和端口在浏览器看来都是同一个。
如果不想用 Nginx,也可以用 Spring Boot 直接托管前端静态文件——把dist目录复制到项目的src/main/resources/static下,再重新打包,后端 jar 包就同时包含前后端代码。这种方式部署更简单,但不符合前后端分离的最佳实践,自己练手可以,项目里演示一下就够了。
7. 实际开发中踩过的坑,每个都是血泪换来的
7.1 日期时间字段的时区问题
这个坑我印象太深了。数据库里存的create_time是北京时间,但查询结果返回给前端时,发现所有时间都多了 8 个小时。原因就是 JDBC 连接串没有指定时区,默认用了服务器的 UTC 时区。解决方案就是配置里加serverTimezone=Asia/Shanghai,同时把 Jackson 反序列化的时区也设置成东八区:
spring: jackson: time-zone: GMT+8另一个相关的问题是日期格式。前端表格里显示的是类似于2024-06-01T10:30:00这种带字母 T 的格式,不够友好。解决方案是在实体类的日期字段上加@JsonFormat注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;这类问题对系统的功能不产生致命影响,但就是这些细节决定了别人愿不愿意用你的系统。
7.2 跨域配置的两个方案
跨域问题几乎每个前后端分离项目都会遇到。浏览器控制台刷出一片红色的 CORS 报错,是很多同学最崩溃的时刻。
解决方案有两个。开发阶段最推荐是配置前端 devServer 代理,因为代理模式下浏览器认为所有请求都是同源的,根本不触发 CORS。做法是在 Vue CLI 的vue.config.js里配置:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };同时后端接口路径不要写/api前缀,前端请求写/api/xxx,代理把/api去掉后再转发到后端。这样前端代码和后端接口互不干扰,联调体验最顺。
第二个方案是后端加 CORS 配置。用@CrossOrigin注解或者实现WebMvcConfigurer接口统一配置允许的跨域来源。这个方案可行,但需要把允许的来源、请求方法、请求头都配清楚。写错了不但不能解决跨域,反而会引入新的安全问题。实际项目里我一般是「开发用代理,部署用 Nginx,后端 CORS 兜底」。
7.3 数据库字段命名导致的 MyBatis 映射问题
这是一个非常隐蔽的坑。MySQL 里字段命名通常是snake_case,比如customer_name,Java 实体类属性是customerName。MyBatis Plus 默认开启了下划线转驼峰的映射规则,所以大部分情况下能自动对应。但如果某个字段的命名没按规范来,或者 SQL 查询使用了别名,结果集列名和实体属性对应不上,就会查出null。
这类问题排查起来特别难受,因为代码不报错,只是字段值是空的。排查思路是:先去数据库执行一遍同样的 SQL 看结果集,再检查实体类属性和查询结果列名的对应关系。很多同学在这个问题上耗掉半天时间,最后发现只是少写了一个别名。
7.4 前端表格里操作按钮的权限控制
前端列表页常见操作是「编辑」「删除」「查看详情」。在销售系统中,不同的角色应该看到不同的操作按钮。比如普通销售员只能查看自己的订单,不能删除订单,而管理员可以作废异常订单。
这个权限控制的实现方法很简单,Vuex 里存储当前登录用户的角色信息,模板中通过指令或者v-if判断:
<el-button v-if="role === 'admin'" type="danger" size="mini">删除</el-button> <el-button v-if="role === 'admin'" type="warning" size="mini">作废</el-button>但要注意的是,前端按钮隐藏只是「体验层」的权限控制,真正的权限校验必须放在后端。否则一个懂点技术的人直接调接口就能删订单了。后端每个接口都应该通过注解或代码校验当前用户的角色权限,双管齐下才是安全的做法。
7.5 文档与源码保持一致才是最难的
标题里提到了文档,这也是我最后想专门说的一点。项目的文档一般包含三份:需求文档、数据库设计文档、系统部署说明文档。课程设计的文档往往是一份大而全的「说明书」,毕业设计则要求更规范一些。
我的经验是写文档最忌讳的是「写完代码再回忆着写」。正确做法是开发过程中随手记录——新建一张表记录一下字段含义,写完一个接口记录一下入参出参和业务规则,跑通一个页面记录一下操作流程。比如在订单事务那一节,除了说明@Transactional的作用,还会配上「SELECT ... FOR UPDATE 为什么能防止并发超卖」这段排查过程,这类内容是普通资料里不容易找到的。
文档里要特别包含的是如何初始化数据库、如何修改配置、如何启动前后端,这三个部分是让其他同学能把项目跑起来的前提条件。很多时候源码是对的,但文档里写的数据库密码过期了、前端 Node 版本提示要求不对,照做就跑不起来。把这类文本写好,你的交付物才是真正可复用的。
最后再分享一个小技巧
如果你拿到的项目里没有数据库初始化脚本,千万不要自己手动建表。手动建表不仅耗时,而且还容易漏掉字段。正确的做法是让后端框架自己完成建表:在application.yml里临时开启 MyBatis Plus 的自动建表和自动填充:
mybatis-plus: global-config: db-config: table-prefix: '' configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置完之后启动一次项目,控制台会输出所有的建表日志和 SQL 执行日志。这些日志既可以帮助你确认表结构是否创建成功,也可以帮助你调试接口执行的具体 SQL 语句。查完再把日志打印关掉就好。这个技巧在你拿到任何一套 Spring Boot + MyBatis 项目源码时都通用。
说到底,像电池销售系统这样的管理类项目,真正的价值不在于功能有多炫,而在于你通过它理解了完整的前后端数据流:前端页面发起请求,路由到后端 Controller,Service 处理业务逻辑,Mapper 操作数据库,数据再反向流回前端渲染。把这条链路吃透了,你会发现绝大多数管理系统本质上都是在做同样的事。下一次即使换一个业务场景,你也能很快从这套骨架里迁移过去。