先泼个冷水:这类“源码+数据库+文档”的全栈管理系统,市面上十个里有八个能跑,但真正能让你答辩、上线、二次开发不出洋相的,其实不多。电池销售系统这个题目看着普通,但它把商品管理、库存、订单、客户、统计这些琐碎的环节都串起来了,再加上Java和Vue的组合,算是一个很典型的“毕业设计 + 小型企业进销存”混合体。
我最近正好帮朋友把一套这样的系统从零搭起来跑通,顺便踩了不少坑。这里把我拆这个项目的完整思路、数据库设计里的关键决策、前后端实现时的细节,以及排查过的几个棘手问题都整理出来。如果你正准备做类似的系统,或者已经拿到了一份源码但不知道怎么讲清楚、怎么改,这篇内容应该能让你少走很多弯路。
1. 整体设计与思路拆解
拿到“电池销售系统”这个题目,第一件事不是写代码,而是先想清楚它到底要管什么。
电池这个品类和普通商品不一样,它有明显的分类维度:按用途分有启动电池、储能电池、动力电池;按化学体系分有铅酸、锂电、镍氢。一套销售系统如果只做简单的增删改查,那和“通用进销存”没区别,答辩时也讲不出亮点。所以我从业务角度把它重新拆成几个核心链路:
- 商品管理:电池的分类、品牌、型号、容量(Ah)、电压(V)、化学类型、质保期、库存上下限预警。
- 客户管理:企业客户为主,个人客户为辅,一个客户多次采购,要记录联系人和历史价格。
- 订单管理:创建销售订单、订单审核、出库发货、售后退换货,以及订单状态的全流程跟踪。
- 库存管理:商品入库、出库、盘点、调拨,以及库存流水记录。每次销售出库必须扣减库存,并且要留痕。
- 统计分析:按天、按月统计销量和销售额,按电池品类分析销售占比,按客户统计复购率。
1.1 为什么选 Spring Boot + Vue 的组合
技术选型上,Java 后端搭配 Vue 前端是目前最主流的方案,没有之一。Spring Boot 的好处是零配置启动、内嵌 Tomcat、生态成熟,MyBatis-Plus 能把单表 CRUD 的效率拉满,非常适合这种以数据管理为核心的系统。前端用 Vue 是因为组件化开发对中后台页面太友好了,配合 Element UI 的表格、表单、弹窗、分页组件,一个人写完整套页面大概也就两三天的事情。
这套技术栈的另一个价值在于:它基本覆盖了企业中后台项目的主流需求,你做完之后拿到面试场上也能讲,不会像“纯 PHP 写的小商城”那样拿不出手。
1.2 模块划分决定开发节奏
我做这套系统的模块划分是这样的(参考平时做项目的习惯):
电池销售系统 ├── 系统管理 │ ├── 用户与角色 │ ├── 菜单权限 │ └── 操作日志 ├── 基础数据 │ ├── 电池分类 │ ├── 电池商品 │ └── 客户档案 ├── 销售管理 │ ├── 销售订单 │ ├── 订单审核 │ ├── 退换货 │ └── 销售开票(预留) ├── 库存管理 │ ├── 入库单 │ ├── 出库单 │ ├── 库存查询 │ └── 库存流水 └── 统计报表 ├── 销售日报/月报 ├── 品类销售占比 └── 客户销量排行这么拆分的好处是,面试官或者导师问起来,你每一块都能说出对应的页面、接口和表,而不是只憋出来一句“就是一个卖电池的商城”。
2. 数据库设计:一套销售系统的地基
数据库设计是整个系统里最值得花时间的部分。表结构建得合理,后面所有业务逻辑都是顺着写的;表建歪了,后面到处是补丁。
以我这次的实现为例,核心表一共设计了 12 张左右:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| sys_user | 用户表 | id, username, password(BCrypt), role_id, status |
| sys_role | 角色表 | id, role_name, role_key |
| battery_category | 电池分类表 | id, category_name, parent_id |
| battery_info | 电池商品表 | id, category_id, model, capacity, voltage, price, stock, warn_stock |
| customer | 客户表 | id, customer_name, contact_phone, address, level |
| sale_order | 销售订单主表 | id, order_no, customer_id, total_price, status, create_time |
| sale_order_item | 销售订单明细表 | id, order_id, battery_id, quantity, price, subtotal |
| stock_record | 库存流水表 | id, battery_id, change_type, quantity, before_stock, after_stock |
| warehouse_in | 入库单 | id, supplier, battery_id, quantity, in_time, operator |
| warehouse_out | 出库单 | id, sale_order_id, battery_id, quantity, out_time, operator |
| customer_return | 退换货表 | id, order_id, battery_id, quantity, return_reason, status |
| sale_report | 销售统计汇总表(可选) | id, stat_date, category_id, total_amount, total_count |
2.1 订单主表和明细表为什么必须拆开
这是很多新手容易犯的错:把所有商品直接塞在订单表里,一个字段存好几个商品 ID,查询时再用逗号分隔解析。这种设计一旦订单商品数量多起来,统计计算会非常痛苦。
正确的做法就是上面这样拆成主表和明细表。主表存一次订单的整体信息,比如订单号、客户 ID、总金额、订单状态;明细表存这一单里的每一项商品,比如电池 ID、数量、单价、小计。两表通过 order_id 关联,查询时一条 SQL join 就能把整个订单拉出来,统计销售额也顺手。
还有一个细节:明细表里保存的price是下单时的快照价格,而不是随时去查商品表的最新价格。这样订单历史才是准的,后续改价不影响已经下单的数据。
2.2 金额和数量字段的类型:一个别踩的坑
金额字段一定使用 DECIMAL 类型,不要用 DOUBLE 或 FLOAT。这个原则适用于所有涉及钱的系统。原因很简单:浮点数在二进制下本身有精度问题,算着算着可能出现 0.1 + 0.2 = 0.30000000000000004 这种情况。电池单价算上小数,累计总价,一旦出现精度误差,客户对账时发现差一分钱,那体验就很糟糕了。
数量字段用 INT 就好,除非你的业务里有“1.5 节电池”这种需求。价格字段我建议 DECIMAL(10, 2),10 位整数加 2 位小数,对绝大多数销售场景足够。
2.3 库存扣减:不要用先查再改的脑瘫方案
库存扣减一定要用“乐观锁 + 条件更新”的方式。我先贴一段核心代码:
int rows = batteryInfoMapper.update( null, new LambdaUpdateWrapper<BatteryInfo>() .setSql("stock = stock - " + quantity) .eq(BatteryInfo::getId, batteryId) .ge(BatteryInfo::getStock, quantity) ); if (rows == 0) { throw new BizException("库存不足,扣减失败"); }这个写法的关键在于.ge(BatteryInfo::getStock, quantity),也就是更新语句里带上“当前库存必须大于等于扣减数量”这个条件。数据库层面保证不会出现超卖,而且不需要额外加锁,性能也还好。
如果你用的 MyBatis-Plus 版本比较旧,setSql传字符串可能有 SQL 注入风险,实操时建议用set(BatteryInfo::getStock, ...)配合应用层计算的方式,或者直接写 XML 里的自定义 SQL。
3. 实操过程与核心环节实现
下面按从零到跑通的顺序,把关键步骤和现场记录整理出来。这里的配置统一按 Spring Boot 2.7 + JDK 8 + MySQL 5.7 + Vue 2 + Element UI 来写,这也是这套系统最常见的配置组合。
3.1 环境准备:先把坑填平
整体的环境清单如下:
| 组件 | 版本建议 | 备注 |
|---|---|---|
| JDK | 8 或 11 | 如果源码是 JDK8 编译的直接用 8,别乱升 |
| Maven | 3.6+ | 如果拉依赖慢,配阿里云镜像 |
| MySQL | 5.7 或 8.0 | 注意驱动版本要匹配 |
| Node.js | 14 或 16 | Vite 需要 14.18+,老项目用 webpack 也可以 14 |
| IDE | IntelliJ IDEA / VSCode | 后端主力 IDEA,前端 VSCode 也够 |
依赖安装完成后,我建议先不要直接跑完整项目,而是先做“最小验证”:后端启动、前端启动,分别能起一个页面,确认环境没问题再导入整个项目。
3.2 后端接口实现要点
后端整体结构按照 Controller → Service → Mapper 三层来写。这里我挑几个容易写歪的接口重点讲。
登录接口与 JWT 鉴权
登录接口是系统的门户,一般流程是:
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 1. 查询用户 LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, dto.getUsername()); SysUser user = sysUserMapper.selectOne(wrapper); // 2. 校验密码(BCrypt匹配) boolean match = BCrypt.checkpw(dto.getPassword(), user.getPassword()); if (!match) throw new BizException("用户名或密码错误"); // 3. 生成JWT,返回token String token = JwtUtil.createToken(user.getId(), user.getUsername()); return Result.success(token); }这里有个隐藏细节:密码匹配失败不要返回“密码错误”,统一返回“用户名或密码错误”,防止被猜到合法用户名。这个是最基础的安全习惯。
拦截器校验 Token
JWT 不像 Session 那样自动帮你存登录状态,需要在拦截器里做解析校验。我常用的写法是实现 HandlerInterceptor 的 preHandle 方法:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BizException("未登录,请先登录"); } Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); return true; }对应的配置里要排除登录、注册、静态资源等路径,否则每次访问都要 token,前端调试时会疯掉。
订单创建与事务控制
创建订单是典型的多步骤写操作:插入主表、插入明细表、扣减库存、记录库存流水。这几步必须保证要么全部成功、要么全部回滚,所以需要加上@Transactional注解。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 生成订单号 String orderNo = generateOrderNo(); // 2. 插入主表 // 3. 遍历明细,逐个插入明细表 // 4. 逐个扣减库存 // 5. 记录库存流水 return orderId; }关于事务的粒度,建议一个业务用例一个事务,不要一把梭把整个 Service 类的所有方法都加上@Transactional。那样接口会互相嵌套,性能会受影响,排查问题的时候也麻烦。
订单号生成的一个常见坑
生成唯一订单号,很多同学喜欢直接用时间戳,或者时间戳加随机数。我实测下来最简单可靠的做法是:yyyyMMddHHmmss + 4位随机数,再加个日志去重即可。如果要更严格一点,可以在数据库里给 order_no 字段建唯一索引,这样就算随机数碰撞了,插入也会报错,你能及时发现并重试。
3.3 前端页面的实现与联调
前端我是用 Vue 2 + Element UI 写的,只是辅助性的几个关键点说一下。
路由与权限控制
前端路由需要配合登录状态做访问控制。我的做法是:在路由配置里给需要登录的页面加上meta: { requiresAuth: true },然后在路由守卫里检查 token:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });这个方案虽然简单,但够用。只要后端接口有 JWT 拦截器兜底,前端路由守卫只是优化体验而已,防不住真正绕过前端的请求。
前后端联调要处理的跨域
开发环境里前端跑在 8080,后端跑在 9090,直接请求必然跨域。我第一次搭这套系统时就在这卡了半小时。推荐方案:前端在 vite.config.js 或 vue.config.js 里配置代理。
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true, } } }同时后端也开启跨域支持,加一个全局 CORS 配置类。两边都配好之后,联调基本不用再碰跨域问题。
Element UI 表格与表单绑定
中后台页面大量用 table + dialog + form 的组合。一个我踩过的坑是:el-table 的行数据字段要和后端返回的 JSON 字段名严格匹配,否则显示的列是空的。如果后端是驼峰命名,前端也要用驼峰,不要在这里做重命名转换,否则每个表格都要写 formatter,太累。
4. 常见问题与排查技巧实录
这部分实操价值最高,我把跑这套系统过程中最典型的问题按出现频率列出来。如果你拿到源码跑不起来,大概率是下面这些问题之一。
4.1 后端启动失败:“Could not create connection to database server”
这个报错百分之八九十是数据库连接配置问题。具体查这几个地方:
- MySQL 是否启动,端口是不是默认的 3306,如果不是,要改配置里的 URL。
- URL 里的账号密码是否正确,注意密码如果包含特殊字符(如 @、#),要 URL 编码。
- 时区问题。MySQL 8.x 默认时区和驱动不一致,我一般在 URL 后面加上:
?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true - 驱动版本和 MySQL 版本要匹配。MySQL 8.x 要用
com.mysql.cj.jdbc.Driver,MySQL 5.7 可以用旧驱动,但建议统一新的。
4.2 前端页面白屏或接口 404
如果页面能打开但表格数据是空的,用浏览器 F12 看 Network。常见是这两种:
- 请求路径带了一截
/api,后端没有做对应的 context-path 处理。要么前端代理去掉/api前缀,要么后端 Controller 路由统一加/api前缀,两边对齐即可。 - 后端接口地址是
/api/battery/list,但前端写的是/battery/list。这个就是纯手滑,看清楚再改。
4.3 登录后跳回登录页
这个一般是 token 存储或校验不一致导致的。排查点:登录接口返回的 token 是否成功写入 localStorage;axios 请求拦截器是否在每次请求头中带上了Authorization字段;后端 JWT 解析用的密钥是否和生成时一致。很多人会在 Utils 类里生成一个随机密钥,每次重启生成的 token 都不同,导致前端拿着旧 token 请求,后端解析失败,表现出“登录成功了但立刻失效”的现象。密钥要写死,或者从配置文件中读。
4.4 MyBatis-Plus 字段映射不上的诡异问题
我在做系统时遇到过这样一个情况:数据库字段是order_no,Java 实体是orderNo,MyBatis-Plus 平时会自动做驼峰转下划线的映射。但如果 MyBatis-Plus 配置里的map-underscore-to-camel-case为 false,或者全局配置没生效,查询结果就会出现 orderNo 一直为 null,但其他字段正常的问题。
解决办法:检查 application.yml 里是否有以下配置:
mybatis-plus: configuration: map-underscore-to-camel-case: true然后所有数据库字段和实体字段的映射做到一一对应。
4.5 数据库脚本导入报错
主流情况下,源码包里的 SQL 文件直接用 Navicat 或命令行 source 导入即可。但有几个细节:
- 有些 SQL 文件是用 MySQL 8 导出的,其中包含类似
utf8mb4_0900_ai_ci的排序规则,MySQL 5.7 不支持,会报错。解决办法是打开 SQL 文件,把utf8mb4_0900_ai_ci全局替换成utf8mb4_general_ci,再导入。 - 如果你的字符集设置了 utf8mb4,导入前先确认库的默认字符集也是 utf8mb4,否则中文会变问号。
- 导入时不要开着外键检查,除非你的建表顺序有问题。一般 Navicat 直接 “运行 SQL 文件” 就行。
4.6 上传到服务器后部署
准备把系统部署到服务器上时,要处理的点:
- 前端先打包:
npm run build,生成 dist 目录。 - 后端打包:
mvn package -DskipTests,生成 jar 包。 - 静态文件处理:把 dist 目录里的 index.html 和 assets 复制到 Nginx 的 html 目录,配置 Nginx 将所有
/api请求转发到后端端口,其他请求指向前端页面。
这里有个很容易忽略的点:Vite 打包后的资源路径默认是/,如果你的 Nginx 不只是服务这一个系统,而是放在子路径下,比如https://域名/battery/,那就会所有静态资源 404。解决办法是在打包时配置 base:
vite build --base=/battery/同理,Vue Router 如果用了 history 模式,也需要在 Nginx 配置中做 history 路由回退,否则刷新页面就 404。更省心的做法是用 hash 模式,不用额外配置。
5. 一份靠谱的文档应该包含什么
这里单独说文档部分,因为“源码+数据库+文档”中的文档质量,直接决定这套系统的交付价值。
我见过很多项目文档写成了流水账,第一章写项目背景,第二章写环境安装,第三章粘贴大量代码截图,真正核心的内容反而没有。文档应该包含的是这四类:
- 系统部署文档:从环境准备到启动成功的全流程,每一步都要带验证方式。比如启动后端后访问哪个端口能看到什么日志、启动前端后浏览器输入什么地址能打开页面。
- 数据库设计说明:包含每个表的用途、核心字段解释、表关系说明。不要只贴一张 ER 图,要说明“为什么订单主表和明细表要分开”“为什么库存流水要独立建表”。
- 接口文档:列出所有核心接口的请求方式、路径、入参、出参示例。如果有与前端的对应关系更好。
- 运维注意事项:比如数据库备份方式、默认账号密码、密钥修改指引、如何换端口、如何换数据库连接。
写文档时有一点经验:把“默认账号密码是什么”放在醒目的地方,加上“首次登录后请修改密码”的提示。这样购买或拿到源码的人不用从代码里翻半天找账号信息,体验会好很多。
这套系统的数据模型本身并不复杂,真正决定它“好不好用”的,往往是你对业务细节的理解深度。比如电池这个品类,如果我再加一个“质保期提醒”或“临期电池预警”的功能,整个系统的卖点立刻就出来了,而这些需求在客户眼里比界面炫不炫重要得多。
我个人做完这个项目最深的体会是:销售系统的核心不在页面,而在数据一致性。库存、订单、金额这三条线,所有修改都要经过事务、要有日志、要有可追溯的流水。把这些基础打牢,所谓“高并发”“大数据量”那些东西都是后话;如果基础没打好,不管用多新的技术栈,上线后都是一团乱麻。
最后分享一个小扩展方向:如果你想把系统做得有区分度,可以考虑加上 Redis 缓存热点电池商品的库存和价格,前端再加一套基于 ECharts 的销售趋势图。这两个改动工作量不大,但对系统的流畅度和展示效果提升非常明显,拿出来讲也更有说服力。