
做超市管理系统这个项目最早是因为帮一个做小型连锁超市的朋友处理进销存混乱的问题。他当时用的是 Excel 加手写单据月底盘点能对上一个星期商品过期了都不知道是哪批先进来的供应商结账更是全靠记忆。我一听就觉得这不光是上套软件的事而是得从业务流程上梳理清楚。后来我用 Java 和 Vue 把这套系统完整做出来从数据库设计到前后端联调再到打包部署整个过程踩了不少坑也积累了很多可以直接拿来用的经验。这篇就完整复盘一下整个项目需求怎么拆、库表怎么设计、后端接口怎么写、前端页面怎么对接以及那些文档里基本不会写的细节问题。1. 项目需求拆解与整体设计1.1 超市管理到底在管什么做系统前先别急着写代码得把超市的日常业务捋清楚。超市看起来就是“进货、卖货”实际上涉及的角色和流程比想象中多。门店里有店长要看整体经营数据有收银员要快速结账有库管要管出入库和盘点有采购要跟供应商对账。系统必须把这些角色的核心诉求都覆盖上否则做出来就是个花架子。我梳理下来核心业务域有这么几块商品管理商品档案的增删改查、条码维护、分类管理、价格管理进价、零售价、会员价。库存管理入库、出库、退货、报损、盘点、库存预警。这里最容易出问题因为库存不光是数量还涉及批次和有效期。销售收银前台快速结算支持扫码枪录入支持会员折扣和优惠券生成销售小票。采购与供应商采购单的创建与审核、供应商档案、进货记录与账期管理。统计报表销售日报/月报、毛利统计、库存周转、滞销商品排行。系统权限不同角色的登录与操作权限控制。这些模块中最核心、也最容易被新手忽略的是“库存变动记录”。很多系统只存一个当前库存数量结果一旦哪个环节数据错了根本查不出是谁在什么时候改的。所以必须设计一张库存流水表每一次出入库都写入一条记录这样库存数量永远可以回溯和校验。1.2 为什么选 Java Vue 这套组合技术选型上我直接用了我最熟悉、也是市面上中小型管理系统最主流的组合Spring Boot Vue。这个选择不是跟风而是综合考虑了开发效率、团队协作和学习成本。后端用 Java 的好处很直接Spring Boot 把配置简化了一大半内嵌 Tomcat打一个 Jar 包就能跑部署省事。而且 Java 生态的权限框架、数据库连接池、分页插件都特别成熟写管理系统这类 CRUD 密集型的应用非常顺手。前端用 Vue 是因为它有清晰的双向数据绑定和组件化开发方式。超市管理系统有很多“表单 表格 弹窗”的组合用 Vue 写组件复用率很高。比如商品选择组件既能用在入库单里也能用在收银台和盘点单里一个组件三处复用代码量直接降下来。前后端分离的开发模式还有一个好处前端可以并行开发。我负责后端接口另一个同伴负责页面只要提前约定好接口返回的数据结构两边各干各的最后联调花个一两天就能跑通。1.3 系统整体架构与目录结构整个系统采用经典的前后端分离架构。前端是 Vue 2 Element UI后端是 Spring Boot MyBatis Plus MySQL接口通过 JSON 交互。后端的包结构我是这样组织的按业务域而不是按技术层分com.supermarket ├── common # 通用类统一返回结果、异常处理、工具类 ├── config # 配置类跨域、拦截器、静态资源映射 ├── controller # 控制层接收请求 ├── service # 业务层核心逻辑 ├── mapper # 数据访问层 ├── entity # 数据库实体 └── dto # 接收参数和返回参数的对象前端目录结构src ├── api # 按模块封装的请求方法 ├── assets # 静态资源 ├── components # 公共组件 ├── router # 路由配置 ├── store # 状态管理 ├── views # 页面视图商品管理、库存、收银台、报表等 └── utils # 工具函数请求封装、格式化这个结构看起来简单但实际用下来非常顺手。后端按业务域分包改哪个模块的功能直接进对应的包找文件就行不用在几十个 controller 里翻。前端把 api 单独抽出来所有请求集中管理接口地址一变只改一个文件不用满页面找 axios。2. 数据库设计与核心表结构2.1 数据表规划思路数据库设计是整个项目的地基表结构一旦定下来后面改起来非常痛苦。我设计的时候坚持一个原则每张业务表都要有主键、创建时间、更新时间涉及创建人的表还要有创建人字段这样出了问题才好追溯。整个数据库我一共建了 12 张表核心的几张是sys_user用户表登录账号、密码、姓名、角色sys_role角色表管理员、收银员、库管、采购product商品表条码、名称、分类、单位、进价、售价product_category商品分类表supplier供应商表stock库存表商品、数量、预警阈值stock_record库存流水表出入库记录purchase_order采购单主表purchase_order_item采购单明细表sale_order销售订单主表sale_order_item销售订单明细表member会员表仓库和门店如果多的话还应该加仓库表但单体超市管理系统一张库存表就够了不需要过度设计。2.2 商品表与库存表的字段设计商品表是最基础的表字段设计决定了后面所有业务能不能顺畅。我吃过亏所以这次把字段设计得比较完整CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, barcode varchar(32) DEFAULT NULL COMMENT 条码, name varchar(100) NOT NULL COMMENT 商品名称, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, unit varchar(20) DEFAULT 件 COMMENT 单位, specification varchar(100) DEFAULT NULL COMMENT 规格, purchase_price decimal(10,2) DEFAULT NULL COMMENT 进货价, sale_price decimal(10,2) NOT NULL COMMENT 零售价, member_price decimal(10,2) DEFAULT NULL COMMENT 会员价, status tinyint(1) 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_barcode (barcode) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个细节我要多说一句条码字段一定要加索引而且建议尽量把条码作为商品录入的查询键。超市收银最频繁的操作就是扫条码如果没有索引数据量上来之后查询会明显变慢。条码可以为空因为有些生鲜商品没有条码这时候可以用编码或者手工选择商品。库存表的设计使用了一对一关系一个商品一条库存记录CREATE TABLE stock ( id bigint(20) NOT NULL AUTO_INCREMENT, product_id bigint(20) NOT NULL, quantity int(11) NOT NULL DEFAULT 0 COMMENT 当前库存, safety_stock int(11) DEFAULT 10 COMMENT 库存预警值, PRIMARY KEY (id), UNIQUE KEY uk_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意库存表只存当前的库存总量所有的数量变动都必须写入 stock_record 流水表这样才能追踪每一笔来源。为什么这么强调因为实际运营中经常会出现“系统库存和实物对不上”的情况你只能通过流水表去查是入库没点确认还是退货没做入库还是销售订单被作废了没回补库存。库存流水表长这样CREATE TABLE stock_record ( id bigint(20) NOT NULL AUTO_INCREMENT, product_id bigint(20) NOT NULL, change_type tinyint(1) NOT NULL COMMENT 1入库 2出库 3销售 4退货 5报损, change_quantity int(11) NOT NULL COMMENT 变动数量正负, before_quantity int(11) NOT NULL, after_quantity int(11) NOT NULL, reference_no varchar(64) DEFAULT NULL COMMENT 关联单号, remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_product_time (product_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;流水表里的 before_quantity 和 after_quantity 非常关键它们记录了变动前后的库存数量对账的时候可以直接看这条流水影响的净差值不用再去反推。reference_no 则是关联业务单据的编号比如采购单号、销售单号出了问题可以追溯到具体是哪笔业务。2.3 订单表的主子表设计销售订单和采购订单都采用“主表 明细表”的结构。主表存订单整体信息单号、总金额、操作人、时间明细表存商品明细商品、单价、数量、小计。CREATE TABLE sale_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, member_id bigint(20) DEFAULT NULL, total_amount decimal(10,2) NOT NULL COMMENT 总金额, discount_amount decimal(10,2) DEFAULT 0.00 COMMENT 优惠金额, pay_type tinyint(1) DEFAULT 1 COMMENT 1现金 2微信 3支付宝, create_by bigint(20) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个实战经验订单号不要用数据库自增 ID因为会暴露订单量而且多表关联的时候容易混。我用的是“日期 随机数”的方式生成比如202506121530001234前面的部分是时间戳后面四位随机数基本够用。如果并发量很大可以考虑用 Redis 生成唯一单号但超市管理系统的量级完全用不上。3. 后端核心功能实现解析3.1 Spring Boot 项目初始化与依赖选型创建 Spring Boot 项目的时候我建议直接用 Maven 管理依赖。核心依赖就这么几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency为什么用 MyBatis Plus 而不用原生 MyBatis因为这类管理系统的增删改查太常规了MyBatis Plus 的 BaseMapper 直接提供了单表的 CRUD 方法写分页查询只要调用 selectPage 方法就行省掉大量重复的 XML 代码。复杂的多表关联查询再手写 SQL 就行两不耽误。数据源配置我放到 application.yml 里有几个参数值得注意spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case 一定要开启不然数据库的 create_time 字段映射不到实体的 createTime 上各种莫名其妙的空值问题就是这么来的。log-impl 在开发阶段打开能直接在控制台看到执行的 SQL排查问题特别方便上线前再关掉。3.2 统一返回结果与异常处理后端接口返回格式必须统一否则前端写起来会疯。我设计了统一的 Result 类public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }前端拿到的数据永远是{code, message, data}这个结构判断 code 是不是 200是就取 data不是就弹 message。这样前端 axios 拦截器里做一次统一处理就完事不用每个页面都写一堆错误判断。配套的还有全局异常处理用 RestControllerAdvice 捕获业务异常和兜底异常。业务里只要 throw new BusinessException(库存不足)前端就能收到对应提示不然后端一报错前端拿到的是英文的一堆堆栈信息用户根本看不懂。3.3 商品管理的分页查询实现商品列表页是管理系统最典型的需求但实现起来有细节讲究。查询条件通常是商品名称模糊查询、条码精确查询、分类下拉筛选、状态筛选。用 MyBatis Plus 的 LambdaQueryWrapper 写public PageProductVO pageProducts(ProductQueryDTO dto) { PageProduct page new Page(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(dto.getName()), Product::getName, dto.getName()) .eq(StringUtils.isNotBlank(dto.getBarcode()), Product::getBarcode, dto.getBarcode()) .eq(dto.getCategoryId() ! null, Product::getCategoryId, dto.getCategoryId()) .eq(dto.getStatus() ! null, Product::getStatus, dto.getStatus()) .orderByDesc(Product::getUpdateTime); PageProduct result productMapper.selectPage(page, wrapper); // 转换 categoryName、stock 等字段 return convertToVO(result); }注意几个点模糊查询用 like但只在 name 不为空的时候拼接保证 SQL 不会出现无谓的name like %%。条码用 eq 精确匹配不是 like因为条码要么扫出来是完整值要么手动输入完整值。排序用 update_time 倒序新建和修改的商品排前面方便确认最新数据。3.4 入库与库存更新的事务处理入库操作是典型的需要事务保证的接口。整个流程是保存采购单主表和明细表逐条更新库存表逐条写库存流水。任何一步失败都要回滚不然就会出现单据已经保存了、库存却没加上的情况。直接在 service 方法上标 TransactionalTransactional(rollbackFor Exception.class) public void stockIn(StockInDTO dto) { // 1. 保存入库单主表 // 2. 保存入库单明细 for (StockInItemDTO item : dto.getItems()) { // 3. 库存存在则增加不存在则新建 // 4. 写入库存流水 } }事务注解里我加了 rollbackFor Exception.class这一点很重要。不传这个参数的话Spring 默认只在运行时异常时回滚如果抛的是自定义的受检异常事务不会回滚就会产生脏数据。代码里我是统一抛业务运行时异常的所以实际用下来只要记住业务上出错的全部抛 RuntimeException 的子类事务就会正常回滚。这里还有个实战小坑批量入库的时候循环里逐条更新库存没问题但要注意不能用“先查库存再算新库存再更新”这种三步操作并发的时候会出问题。正确做法是先尝试 UPDATE 带上数量条件或者直接用行锁。超市管理系统并发量不大但哪怕是两台收银机同时卖同一件商品也是会发生超卖的。所以销售扣库存的时候我用的是带条件的更新int rows stockMapper.deductStock(productId, quantity); if (rows 0) { throw new BusinessException(商品[ productName ]库存不足); }对应的 SQL 是UPDATE stock SET quantity quantity - #{quantity} WHERE product_id #{productId} AND quantity #{quantity}。update 影响的行数如果为 0说明库存不够直接报错。这种方式利用数据库的行锁和原子性比“先查再判断再更新”安全得多。3.5 登录认证与权限控制管理系统的登录认证用的是 JWT。流程是用户输入账号密码后端校验通过后签发一个 token前端每次请求带在 header 里后端用一个拦截器统一校验。密码存储用 BCrypt 加密不能明文存储这个是底线。JWT 工具类核心代码public String createToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }token 有效期我设的是 24 小时超市收银机是上班期间一直开着的设太短会导致员工频繁重新登录设太长又有安全风险。如果想要更严格一点可以在每次请求时刷新过期时间但那种滑动机制实现起来稍微复杂连锁小超市没必要。权限控制我用了一个简单的拦截器在拦截器里解析 token然后根据 role 字段判断是否有接口访问权限。比如销售接口只允许 admin 和 cashier 访问采购接口只允许 admin 和 purchaser 访问。如果你的角色划分更细可以引入 Spring Security 或者 Sa-Token但做管理系统起步阶段一个拦截器完全够用。4. 前端页面与交互实现4.1 Vue 项目搭建与路由规划前端项目我用 Vue CLI 创建选型的时候直接用 Vue 2因为 Element UI 对 Vue 2 的支持最稳定。要是现在从零开始也可以考虑 Vue 3 Element Plus但整体的实现方式差别不大。路由规划按照后台管理系统的标准模式用侧边栏加头部布局路由懒加载const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/Layout.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/Dashboard.vue) }, { path: product/list, name: ProductList, component: () import(/views/product/ProductList.vue) }, { path: stock/record, name: StockRecord, component: () import(/views/stock/StockRecord.vue) }, { path: sale/cashier, name: Cashier, component: () import(/views/sale/Cashier.vue) }, { path: sale/orders, name: SaleOrderList, component: () import(/views/sale/SaleOrderList.vue) }, { path: report/sales, name: SaleReport, component: () import(/views/report/SaleReport.vue) } ] } ]路由懒加载在系统不大时感觉不到差别但页面多了以后首次加载速度提升明显。我建议从一开始就养成用动态 import 的习惯。4.2 axios 请求封装与 token 管理axios 请求封装是整个前端项目的基建写好了能省掉无数重复代码。我封装了一个 request.js核心逻辑如下import axios from axios const request axios.create({ baseURL: process.env.VUE_APP_BASE_URL || http://localhost:8080/api, timeout: 10000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理返回结果 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } else { Message.error(res.message || 操作失败) return Promise.reject(new Error(res.message)) } }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } else { Message.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request这样封装之后每个模块的 API 文件只需写一行import request from /utils/request export function getProductList(params) { return request({ url: /product/page, method: get, params }) } export function createProduct(data) { return request({ url: /product, method: post, data }) }组件里调用的时候就像调本地函数一样简单不用关心请求细节。重点是响应拦截器里 401 的自动跳转token 过期了统一回登录页不会出现每个页面各自处理的情况。4.3 商品管理页面的表格加弹窗模式商品列表页是标准的“表格 搜索栏 新增/编辑弹窗”。这个页面看起来简单但有几个细节直接决定用户用起来顺不顺手。搜索栏和表格联动我用的是重置查询条件后回第一页不然用户在第 5 页搜索了一个“可乐”结果只搜出 2 条记录页面还是停在第 5 页显示空数据体验就很奇怪。新增弹窗里面的表单校验用 Element UI 的 rules这里有个很实用的场景条码字段校验的是格式但名称、价格、库存这些字段校验规则完全不同。rules: { name: [{ required: true, message: 请输入商品名称, trigger: blur }], salePrice: [ { required: true, message: 请输入零售价, trigger: blur }, { validator: validatePrice, message: 价格必须大于0, trigger: blur } ] }表单提交的时候要注意把提交按钮的 loading 状态控制好。我遇到过同事写的表单网络慢的时候用户连点三次提交按钮结果创建了三条一模一样的商品记录。正确做法是提交期间把按钮 loading 置为 true请求完成后恢复既防止重复提交也给了用户反馈。4.4 收银台页面的交互设计收银台是整个系统里交互最密集的页面设计思路和普通列表页完全不同。收银员的操作路径应该是扫码枪扫条码 → 商品自动加入列表 → 点结算 → 选择支付方式 → 完成。我做的收银台分三块区域左侧是商品列表中间是当前订单明细右侧是结算面板。扫码的时候直接监听到条码输入自动调用查询接口查到了就添加到订单明细里数量相同自动加一。结算面板显示总金额、优惠金额、应收金额选择支付方式后点击收款。收款成功调用创建订单接口接口成功后前端清理当前订单然后打印小票。这里有个细节收款接口返回的订单号要弹出成功对话框并显示找零金额。收银员可能拿了 100 块现金而订单只有 27.5 元找零金额算不准的话收银员自己还要拿计算器算这就非常影响效率了。4.5 库存盘点页面的实现思路盘点功能我单独说一下因为很多超市管理系统都做得很敷衍生成的盘点单就是一张表格实际用的时候根本不方便。我的方案是盘点页面按分类加载所有商品显示系统库存数。员工用扫码枪扫实际商品条码每扫一次实际库存数加一。全部扫完之后提交盘点单系统自动计算每个商品的盘盈盘亏数量生成盈亏记录并更新库存。这个方案的体验像收银一样顺不用员工在一张长长的表格里找到某一行再改数字。哪怕是一箱水扫码枪连续扫 12 次速度也远快于在表格里操作。后端处理盘点单的逻辑是保存盘点单及明细。对每个明细对比系统库存和实际库存。有差异的更新库存写库存流水变动类型记为“盘盈”或“盘亏”。全部放到一个事务里。这套逻辑写完后我用了一次实际盘点来测试200 个 SKU 大概二十分钟就盘完了比手工盘点的速度快了四五倍。5. 数据库脚本与前后端联调部署5.1 数据库初始化脚本说明项目里自带的 database 目录下有完整的建库建表和初始数据脚本。导入流程很简单mysql -uroot -p database/supermarket.sql或者用图形化工具比如使用 Navicat 之类直接运行脚本。导入后会自动创建 supermarket 数据库、所有表结构和初始数据。初始数据里内置了几个账号admin / admin123管理员角色有所有权限。cashier / cashier123收银员角色只能操作收银和订单查询。keeper / keeper123库管角色只能操作库存和盘点。这个小细节对于启动项目后快速验收特别重要。不然拿到一个系统连登录账号都不知道还得去翻代码里的初始化逻辑很影响体验。所以我在做抛砖引玉的示例项目时都会把演示账号写进数据脚本里并在文档中说明清楚。5.2 前后端联调中需要注意的细节前后端分离开发联调阶段最容易浪费时间的问题就是接口地址和跨域。我建议提前约定好两点第一前端所有请求都用同一个 baseURL。我前端封装 axios 时用的是process.env.VUE_APP_BASE_URL本地环境在.env.development里写http://localhost:8080/api生产环境在.env.production里写正式地址。第二开发环境下后端必须配好跨域处理。Spring Boot 里用 WebMvcConfigurer 配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个坑值得单独提一下allowCredentials(true) 的情况下allowedOrigins 不能用*必须用 allowedOriginPatterns 或者具体地址。不然浏览器会拦截请求前端明明收到了数据控制台却报跨域错误排查起来非常迷惑。5.3 项目打包部署实操记录开发阶段前端用npm run serve后端用 IDEA 启动都比较简单。上线部署我这边是前端打静态文件、后端打 Jar 包后一起放到一台服务器上用 Nginx 做反向代理。前端构建时需要在.env.production里把接口地址改成后端实际的服务地址。如果前后端部署在同一台机器上可以直接用后端的端口让 Nginx 做转发location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }后端打 Jar 包mvn clean package -DskipTests启动时指定端口默认就是 8080跑起来之后用 nohup 挂在后台nohup java -jar supermarket-server.jar supermarket.log 21 建议再加一个 JVM 参数-Xms256m -Xmx512m小项目这个内存基本够了不然默认配置会占用很大一块内存显得很浪费。数据库连接串记得把 serverTimezone 加上不然在高版本的 MySQL 驱动下连接池可能会报时区错误。这个是低版本驱动不报、升级驱动后才出现的经典问题。6. 常见问题与避坑指南6.1 数据库中文乱码问题这个问题新手几乎都会遇到。表现在页面上新增的中文正常但数据库里的中文变成问号或者反过来页面上显示乱码。原因几乎都是连接字符集不匹配。确认三个地方数据库本身字符集是 utf8mb4。后端 JDBC 连接串带characterEncodingutf8。建表时表结构也用了 utf8mb4。如果这三个都对了还乱码那就要注意一下导入 SQL 脚本时的编码很多自带的 SQL 文件是 UTF-8 编码的但图形化工具导入时用了 GBK 解码就会导致初始数据乱码。解决办法是在导入前确认编码格式或者用命令行方式导入。连接串里最关键的就是useUnicodetruecharacterEncodingutf8这两个参数在旧版本的 MySQL 驱动里必须写上新版驱动虽然默认值变了但写上更保险。6.2 前端请求后端 404 或 405 问题联调时前端一调接口就 404先检查路径拼写。另一种是不带/api前缀因为我在后端 controller 里统一加了一层RequestMapping(/api)。所以前端列出的请求路径要和后端完全一致。405 一般是请求方式不对。后端这个接口是 POST前端用了 GET就会 405。排查的时候先看浏览器控制台里实际发出的请求方法和 URL然后和后端接口定义比对基本一秒定位。6.3 商品添加成功但列表查不到这不是 bug是分页加搜索条件后最容易踩的坑。用户新增了一个商品然后搜索框里输入了一个关键词再点查询列表里自然显示不出来。这在用户体验上是个逻辑问题不是代码问题。解决办法有两种新增成功后清空搜索条件并跳回第一页或者不清空搜索条件但把本次添加的商品高亮标记出来。我采用的是前者简单直接。还有一个常见的类似问题新增后的商品没有在列表第一页而是排在了第 10 页因为列表默认按更新时间排序新增的应该在前面但如果排序逻辑写错了按创建时间升序就会排到最后一页。排查排序规则。6.4 库存超卖与负库存问题超卖的根因是并发操作下经典的概率问题。比如两台收银机同时卖同一款商品库存还剩 10 件两台机器同时都做了“查询剩余库存 - 判断够不够 - 扣减”三步操作。两台机器都查出库存是 10都判断 10 1 够扣然后都执行 update 扣减最终库存变成了 8 而不是 9如果瞬间并发量大库存就会被扣成负数。解决方式就是用我前面提到的那种带条件的 UPDATEUPDATE stock SET quantity quantity - #{qty} WHERE product_id #{pid} AND quantity #{qty}这种写法把“判断”和“扣减”合并成一个原子操作数据库的锁保证了同一时刻只有一个事务能成功扣到库存。写入影响行数为 0 时直接抛出库存不足异常。这个方法在小型系统里完全够用不用引入 Redis 分布式锁简单可靠。6.5 报表数据对不上销售报表里统计的金额和订单表对不上最常见的两个原因一是统计 SQL 里没有排除已作废的订单二是订单里退款的数据也被算进去了。我设计订单表的时候有一个status字段1 正常2 作废3 退款。所有报表统计都必须在 SQL 里加WHERE status 1。如果报表数据异常先检查这个条件。另外统计口径也要固定。到底是用订单创建时间统计还是用支付时间统计如果商品在 10 点 59 分创建订单11 点才支付成功按日统计销售额的时候这笔算 10 点的还是 11 点的要在需求阶段就定清楚不然后期改统计逻辑会非常痛苦。6.6 项目启动报端口占用本地跑起来的时候8080 端口经常会被其他项目占用。最简单的排查命令netstat -ano | findstr 8080看到占用进程后直接把端口换成 8081或者杀掉占用进程。如果是开发阶段我更推荐改端口因为你不能确定那个占用进程是不是另一个项目贸然 kill 可能把别的实验环境搞崩了。改端口直接在 application.yml 里server: port: 8081前端项目的.env.development里也要同步改成http://localhost:8081/api不然前端还是访问 8080就联调不上了。7. 系统测试与上线心得7.1 功能测试用例设计管理系统上线前至少要把核心业务链路完整走一遍。我测试时习惯按角色来设计用例而不是按页面。因为页面测出来没问题不代表业务能跑通。管理员角色的测试用例登录、退出、商品增删改查、新增用户、给用户分配角色。收银员角色的测试用例登录后只能看到收银台和订单查询菜单先选择 5 件商品结算再作废一笔订单再退掉一笔订单确认库存对应回补。库管角色的测试用例新建采购订单并按完整流程入库再做一个盘点单确认盘盈和盘亏都正确更新库存。系统里所有按钮、跳转、数据流转都过一遍尤其是异常路径——比如库存不足时结算系统必须给出明确提示而不是前端报错。7.2 上线初期的重点监控系统刚上线的一两周是最容易出问题的时候重点盯三个指标一是收银流水和库存流水的数量是否一致。每天闭店后跑一遍对账 SQL检查销售订单明细总数和库存流水里销售类型的记录总数是否匹配。二是库存预警有没有产生。看看哪些商品触发了低于安全库存的预警是不是说明采购周期设置得有偏差。三是操作日志在哪里查。如果完全没有日志出了问题只能靠翻代码但如果你在关键操作里都留了 create_by 和 create_time用 SQL 就能定位谁在什么时间做了什么操作。我对接的那家超市上线第三天就遇到一个问题有员工误把 300 件商品当作 3 件入库了系统里的库存显示 3 件。按照流程盘点单一做盘亏数量一算再结合库存流水二十分钟就查清楚了原因没有引起任何纠纷。这就是完整库表和流水记录设计的力量。7.3 数据备份建议最后提醒一个所有新手都会忽略的事情数据库备份。超市的销售数据一天就能积累上千条记录MySQL 单机运行万一磁盘坏了、机器中毒了数据就全没了。我这是一周自动备份一次数据库备份文件拷到另一台机器上。更安全一点的做法用云服务器的定时任务每天晚上凌晨自动 mysqldump 一次保留最近 30 天的备份文件。mysqldump -uroot -p123456 supermarket /backup/supermarket_$(date %Y%m%d).sql这个命令配合 crontab 就能实现自动备份。生产环境的数据是无价的备份这件事花五分钟配置能免掉未来可能发生的灾难。关于这个系统的后续扩展按我目前的经验来看最值得优先考虑的方向是会员管理与精准营销把会员的消费频次、客单价、偏好商品做分析然后跑一些简单的促销策略。另一个方向是接入电子秤和扫码支付能进一步提升收银效率。整体架构上只要数据库设计和接口规范保持得足够干净这些扩展做起来都不会伤筋动骨。我个人的习惯是每做完一个模块就回头看看有没有更顺手的写法市面上更好的方案也一直在更新但核心的那套业务逻辑是相对稳定的。超市这种业态数字化程度其实远没有想象中高能把进销存和收银这块理顺已经能实打实地降低损耗、提升效率这就是这套系统最大的价值所在。