拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java毕业设计实战:Spring Boot超市收银与进销存系统完整拆解

Java毕业设计实战:Spring Boot超市收银与进销存系统完整拆解 每年到这个时间点总有准备做毕业设计或者想找练手项目的朋友来问我Java毕业设计选什么题才能既体现主流技术栈面试又拿得出手我的答案非常统一——超市收银管理系统。这是一套典型的商超POS收银与进销存一体化项目前台要处理扫码、购物车、结算、支付、小票打印后台要处理商品档案、采购入库、库存盘点、供应商、报表统计几乎把Java后端开发在高频业务场景里的常见问题都覆盖了一遍。无论你要做毕业设计还是想补齐Spring Boot项目经验这套系统都是性价比很高的选择。它比XX管理系统那种纯CRUD题目业务链路更完整又不像电商中台那样需要分布式基础设施才能落地。这篇文章我会从架构设计、数据库建模、模块实现、并发与事务、权限安全、报表导出几个层面完整拆解顺带把开发过程中踩过的坑和面试官爱问的点一起整理出来。1. 项目整体的功能范围与架构设计1.1 从需求倒推系统边界先跑通业务场景再写代码我见过太多人一拿到超市收银系统就开始建表写Controller最后写着写着发现前台收银和后台库存数据对不上或者采购入库和盘点流程根本没法衔接。这类问题的根源都是前期没有把业务场景完整走一遍。一个真实的超市收银场景是这样的早上采购员带着供应商送货单做采购入库库存增加收银台开始营业顾客扫码、选购、支付库存减少营业结束店长看销售日报和毛利报表核对库存是否和账面一致财务定期和供应商对账库存低于预警线时提示补货。把这个场景拆开系统的核心链路就是采购入库→库存增加→前台销售→库存扣减→报表汇总。所以系统必须同时覆盖前台收银和后台进销存两块而不是只做一个收银台。很多初学者的误区就在这里以为收银系统就是结算界面加一个订单表忽略了后台进销存才是整个系统能不能落地的关键。我最终规划的功能范围是一个适合毕业设计量级的MVP版本模块核心功能关键实体前台收银扫码/关键字检索商品、购物车、结算、多支付方式、小票打印销售单、销售明细、支付流水商品管理商品档案、条形码、分类、售价/进价、上下架商品、分类库存管理采购入库、销售出库、库存预警、盘点调账库存、库存流水、采购单、盘点单供应商管理供应商档案、供应商关联商品供应商用户权限多角色登录、操作权限控制用户、角色、菜单报表统计销售日报、商品销售排行、毛利统计、库存预警聚合查询这里我不建议一上来就上微服务、Redis、MQ这些重型组件。单体应用加上清晰模块分包足够承载这套业务而且部署简单、调试容易。等业务真正做透了再考虑拆分也不迟。组件的选择应该服务于当前业务复杂度而不是为了简历上多写几个技术名词。1.2 技术栈选型为什么我用Spring Boot MyBatis-Plus技术栈选型上我最终的方案是后端Spring Boot 2.7 Spring Security MyBatis-Plus MySQL 8.0前端用Vue 3 Element Plus辅助工具包括Redis做验证码缓存和权限缓存选配、POI报表导出、Lombok。Spring Boot是目前Java后端最主流的方式没有太多可争议的。MyBatis-Plus值得单独说一下这套系统的查询条件非常密集商品按名称/条码/分类/状态筛选订单按时间/支付方式/收银员筛选MP的条件构造器和分页插件能少写大量XML。对毕业设计和中小型项目来说开发效率比MyBatis原生高出不少。前端我建议直接用Vue Element Plus做前后端分离。收银台页面需要频繁刷新购物车、弹出支付框、联动小票打印分离式开发对接起来更灵活。如果前端基础薄弱用Thymeleaf Bootstrap也能完成一套Spring体系内搞定但页面上和接口的耦合度会高一些后续做收银台改造会比较费劲。这套技术栈配合起来的优点从开发和部署两个角度看得很明显开发时后端只管接口前端只管页面两边可以并行部署时后端打一个jar包前端打包成静态文件丢进Nginx整体运维成本很低。1.3 数据库设计核心五张表打底加一张流水表数据库是整个系统的地基也是最容易返工的部分。我把核心表控制在这样一个范围t_product商品表、t_stock库存表、t_sale_order/t_sale_order_item销售单和明细、t_purchase_order/t_purchase_order_item采购单和明细、t_stock_flow库存流水表、t_user/t_role/t_user_role用户角色表。这里最想强调的是t_stock_flow这张表。很多人做进销存容易把库存数直接改来改去改完就没了痕迹一旦对不上账根本无法追溯。加一张库存流水表每一次入库、出库、盘盈亏都写一条记录含变动前数量、变动后数量、关联单号库存就是流水的汇总结果这才叫账实一致。商品表和库存表分开设计的意图也很明确商品是静态档案库存是动态数据。如果合并在一张表里商品新增但还没进货时库存为0逻辑上勉强能用但一旦要支持多门店共享商品档案、独立库存合并表的改动成本会非常高。提前分开扩展性更好。CREATE TABLE t_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, barcode VARCHAR(64) DEFAULT NULL COMMENT 条码, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 零售价, cost_price DECIMAL(10,2) DEFAULT 0 COMMENT 进价, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_barcode (barcode) ); CREATE TABLE t_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL UNIQUE, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存, warn_stock INT DEFAULT 10 COMMENT 预警值, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );另一个细节金额字段统一用DECIMAL千万别用float/double这是金额精度丢失的高发区。很多初学者在这个问题上踩坑后面对账对不上才回过头来改字段类型改动面非常大。2. 前台POS收银模块跑通扫码→结算→支付→小票主链路2.1 收银主链路购物车、状态机和统一事务收银台的购物车和电商购物车不一样它的生命周期很短收银员扫码加购、系统展示列表、顾客付款、订单落库、购物车清空。所以购物车我建议直接用前端内存对象或者Redis缓存不要在数据库里建购物车表否则收银员一天开几百单购物车表会堆积大量垃圾数据。后端的收银结算接口只需要做三件事。第一校验每个商品的上架状态和库存是否充足。第二用后端数据库里的价格计算总价这是原则问题不能拿前端传过来的单价做基数否则接口被篡改就会出现一分钱买走商品的严重事故。第三在同一个事务里生成销售单主单和明细、扣减库存、写库存流水、保存支付流水。扣减库存和生成销售单必须放在同一个事务里。我见过不少实现把下单和扣库存拆成两个接口调用结果订单生成了、库存没扣第二天盘点全对不上。这类问题在并发请求下更容易出现因为两个接口之间有时间窗口任何异常都会造成数据不一致。把这四步操作收进一个事务方法内任何一个环节失败全部回滚才能保证核心链路的数据安全。2.2 订单状态机设计别让订单只有待支付/已支付两种态做过实际业务的人都知道订单不能只挂两个状态就完事。我设计的销售单状态如下状态含义触发动作0-待支付订单已生成等待支付提交结算1-已支付支付成功库存已扣支付回调/收银员确认2-已退款退货退款完成库存回补售后操作3-已作废未支付超时或人工取消超时任务/人工操作为什么一定要留待支付状态因为会员储值卡支付、微信支付回调都可能存在延迟如果没有待支付状态退款和重试逻辑就完全没有落点。这个细节平时写课程作业感受不到但真正对接外部支付渠道时就会发现支付结果的通知不是即时的状态机必须是完整的。2.3 支付流水落账幂等和防重复入账支付这块如果做真实对接微信/支付宝需要记录外部交易号、回调状态、支付时间如果只是毕业设计演示可以做模拟支付并在前端弹一个二维码窗口模拟扫码。但支付流水的字段建议按真实接口预留不要图省事砍字段。CREATE TABLE t_payment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, pay_type VARCHAR(16) NOT NULL COMMENT CASH/WECHAT/ALIPAY/MEMBER, pay_no VARCHAR(64) NOT NULL COMMENT 支付流水号, amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1成功 2失败, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_pay_no (pay_no) );pay_no要有唯一索引这是幂等操作的底线。支付回调或者前端重复点击支付按钮时先通过pay_no查库已经处理过就直接返回成功防止重复入账。这个套路在真实支付系统里叫幂等处理是必须养成的习惯。2.4 58mm小票打印的实操细节小票打印是收银系统里最容易掉链子的部分自动化测试也测不准关键是现场配置。常见做法有两种后端生成排版好的文本或图片交给打印机驱动或者浏览器端调小票打印机打印HTML片段。我推荐后者前端在小票打印的专用页面上渲染模板数据来自后端的getReceipt接口这样保证打印内容和订单数据一致调试也方便。几个实操注意点58mm热敏纸每行一般是32个中文字符宽度模板里不要设计太长的行否则会换行错乱需要在打印页面隐藏浏览器默认页眉页脚把margin设为0后端返回的实收、找零、会员余额等字段统一转成两位小数字符串再拼入模板不要在模板里做金额格式化中文乱码问题多半是打印指令集没切换热敏打印机一般要正确发送ESC/POS初始化指令并设置字符集。这些细节不写在教科书里但直接决定系统在真实门店能不能用。3. 后台进销存模块采购、库存、盘点的账实一致设计3.1 采购入库流程状态驱动防止重复入库采购流程我建议设计成采购单草稿→审核→入库→完成四个状态。每一次状态流转都必须校验当前状态比如已经入库的采购单不能被再次入库否则库存会翻倍。这个状态机状态校验的思路在这套系统里到处都要用销售单、采购单、盘点单本质上都是一样的。对应到代码采购单有一个status字段入库操作是一个统一的事务Transactional(rollbackFor Exception.class) public void purchaseInbound(Long purchaseId) { PurchaseOrder order purchaseOrderMapper.selectById(purchaseId); // 状态校验只有审核通过才能入库 if (order.getStatus() ! 2) { throw new BusinessException(当前采购单状态不允许入库); } // 遍历明细逐条增加库存并写流水 ListPurchaseOrderItem items purchaseItemMapper.selectList( Wrappers.PurchaseOrderItemlambdaQuery() .eq(PurchaseOrderItem::getPurchaseId, purchaseId) ); for (PurchaseOrderItem item : items) { stockService.increaseStock(item.getProductId(), item.getQuantity(), PURCHASE, purchaseId.toString()); } // 更新采购单状态为已入库 order.setStatus(3); purchaseOrderMapper.updateById(order); }关键就一句话状态校验必须在循环操作之前。如果先改了状态再处理明细中途一旦某条明细失败事务回滚虽然能把状态恢复但代码逻辑上容易埋雷。先判断后操作才不容易出现隐蔽问题。3.2 库存扣减的并发控制条件UPDATE是最稳的兜底收银高峰期两台收银机同时卖同一个商品如果代码是先查库存判断库存足够再UPDATE减少100%会出超卖。原因很简单查询和更新之间不是原子的两个请求可能同时读到库存为5然后各自减1最终库存剩3而不是4如果商品只剩1件两个订单都能下单成功直接负库存。正确做法是用一条条件UPDATE把判断足够再扣减变成数据库层面的原子操作Integer rows stockMapper.deductStock(product.getId(), quantity); if (rows null || rows 0) { throw new BusinessException(商品[ product.getName() ]库存不足); }UPDATE t_stock SET quantity quantity - #{quantity} WHERE product_id #{productId} AND quantity #{quantity}这里MySQL会对命中的行加锁更新成功返回1失败返回0天然避免了超卖。这种方案比在应用层加synchronized、分布式锁都简单可靠而且没有额外组件开销是单体阶段最推荐的实现方式。补充一下有的项目会在此基础上加version字段做乐观锁UPDATE t_stock SET quantity quantity - #{quantity}, version version 1 WHERE product_id #{productId} AND version #{version}这种方案常用于库存总量不敏感、但防止数据覆盖的场景。在超市收银这个业务里条件UPDATE已经足够因为扣减操作本身就是可重试、可补偿的。如果面试官追问万一库存扣了但订单失败、事务回滚呢答案就是通过事务保证扣库存和订单落库在同一个事务方法里任何一个异常都会完整回滚。3.3 盘点流程系统算差异人工确认后调账盘点在课程设计里不常出现但真实门店每个月都要做。我设计的流程是生成盘点单系统冻结当前账面库存快照门店员工录入实盘数量提交后系统自动计算差异实盘数减账面数主管确认后生成库存调整单统一入账。这里最容易漏掉的是盘点期间继续销售导致账面变化。如果盘点单生成时没有记录snapshot员工录到一半前台还在卖货库存已经变了最终差异会算错。我的做法是盘点单生成时直接记录当时的库存数量作为snapshot调整库存时以snapshot和实盘数的差异为基准后期再做一次实时库存的二次对齐。更严谨的方案是做盘点锁定暂停期间销售但对毕业设计来说snapshot方案已经足够。4. 系统安全与权限设计Spring Security JWT 的整合要点4.1 RBAC模型四个角色的权限边界超市的角色不多但权限边界一定要清晰。我划分了四类角色超级管理员拥有全部权限包括系统配置和用户管理店长能看到全部业务数据负责报表查看、盘点审核、采购审核收银员只接触前台收银能收银、查商品但不能看成本和毛利采购员管商品、供应商和采购单但不能动销售数据和价格。数据库上就是典型RBAC五张表用户、角色、菜单、用户角色关联、角色菜单关联。在Spring Security中用PreAuthorize注解直接挂敏感接口PreAuthorize(hasRole(ADMIN) or hasRole(MANAGER)) PostMapping(/purchase/review) public RVoid reviewPurchase(RequestBody ReviewDTO dto) { // 只有管理员和店长能审核采购单 }这里有个容易忽略的点授权注解的表达式要写清楚角色之间的包含关系比如店长可以查看全部报表和采购员只能查看采购报表不能简单用hasAnyRole把所有角色都放进去。权限的最小化原则做的时候多花十分钟后面管理端更安全。4.2 Spring Security配置在Spring Boot 3中的坑在Spring Boot 2时代大家习惯继承WebSecurityConfigurerAdapter但Spring Boot 3里这套已经彻底废弃必须改成SecurityFilterChain方式Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(auth - auth .requestMatchers(/auth/login).permitAll() .requestMatchers(/pos/**).hasAnyRole(ADMIN, MANAGER, CASHIER) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }我见过不少读者把旧配置搬进新项目启动直接报错找不到方法多半就是配置方式没有跟随Boot版本升级。建议直接以SecurityFilterChain为主省心。JWT的流转也不复杂登录成功后生成token自定义JwtAuthFilter继承OncePerRequestFilter从请求头解析token、校验签名、把用户信息塞进SecurityContext。token里只放userId和role这些非敏感信息不要放密码。4.3 密码存储与接口防护的底线用户密码统一用BCrypt加密Spring Security自带BCryptPasswordEncoder。密码字段在任何查询接口里都不允许原样返回统一做JSON序列化忽略或脱敏。开发时很多人喜欢在查看用户接口里直接返回password字段这是绝对要避免的低级漏洞。接口防护层面除登录和验证码接口之外所有接口都走JWT校验。前端调用时在请求头带Authorization字段后端过滤器统一拦截。另外建议加一个简单的操作日志切面把谁在什么时间操作了什么接口记录下来一方面便于排查问题另一方面也是审计需要。这个功能加起来不难但对项目的完整度提升很明显。5. 报表统计与数据导出让数据反过来指导经营5.1 销售日报的SQL写法与索引策略报表本质上是聚合查询。以销售日报为例SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS sale_date, SUM(total_amount) AS total_amount, COUNT(*) AS order_count FROM t_sale_order WHERE create_time #{startTime} AND create_time #{endTime} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY sale_date DESC问题在于数据量大之后查询会变慢。多门店多商品跑一年订单表可能几十万行解决方案是给create_time建索引并且把时间筛选条件放在WHERE最前面。更细粒度的报表比如按商品统计销售排行明细表量级更大最好给sale_order_item的product_id和create_time建复合索引避免全表扫描。如果面试官追问报表优化可以先答索引再答按天分表、定时汇总宽表。毕业设计做到索引加合理查询就够用了但要有能力说明下一步的优化方向这样显得不是只会写一条SQL。5.2 POI导出ExcelXSSFWorkbook还是SXSSFWorkbook报表导出和库存预警导出都用POI实现。小数据量用XSSFWorkbook就行但如果要导出全量销售明细几十万行会直接撑爆内存这时候必须用SXSSFWorkbook流式写入try (SXSSFWorkbook workbook new SXSSFWorkbook(100)) { // 内存中保留100行 Sheet sheet workbook.createSheet(销售明细); int i 0; for (SaleOrderItem item : items) { Row row sheet.createRow(i); row.createCell(0).setCellValue(item.getProductName()); row.createCell(1).setCellValue(item.getQuantity()); row.createCell(2).setCellValue(item.getAmount().doubleValue()); } // 设置列宽和表头样式 }SXSSFWorkbook的本质是滚动窗口超过窗口的行会被刷到磁盘内存占用大幅下降。导出文件名建议用销售明细_20250101.xlsx这种带日期的格式方便门店归档。同时导出接口最好做成异步任务前端轮询下载进度避免同步导出大文件时HTTP请求超时。6. 开发中的高频坑与排查实录6.1 库存扣成负数并发测试才能暴露的问题我第一次做这个项目时单机测怎么都是对的一上JMeter模拟20个并发同时买同一件商品就出现负库存。定位过程也很典型先看业务日志发现所有请求都查到了库存1原因就是查询和更新之间留了窗口。修复方法就是前面讲的统一改成条件UPDATE再压测就稳定了。这个经历给我的教训是凡是先查再改的代码在并发下都是定时炸弹。即便场景是低并发的超市收银也要习惯把并发安全写进代码里而不是等出问题再补。6.2 Transactional悄悄失效的三种写法事务失效是Spring里的经典坑我踩过并且帮人排查过的有三个典型场景方法内部自调用同类里a()调用b()b上的Transactional不会生效因为调用过程没经过Spring代理对象事务管理器根本感知不到异常被catch吞掉事务方法里try-catch把运行时异常拦住Spring感知不到异常事务照常提交数据就错乱了方法不是publicSpring的声明式事务默认基于代理private方法不会被拦截。排查时可以打断点观察事务是否真的开启或者把日志级别调到debug观察Using transaction...和Rolling back日志。最常见的是第二种所以我写业务代码时事务方法内基本不catch异常要么catch后手动标记setRollbackOnly要么直接抛出去让外层统一处理。很多人为了让代码不报错随手吞异常最后数据错了反而更麻烦。6.3 金额精度与POI导出的经典问题金额计算必须用BigDecimal。不要用float/double也不要以为数据库存了DECIMAL前端就安全——后端在加购合计、找零计算时如果用double运算0.10.2这类问题会立刻冒出来。BigDecimal构造时用字符串构造器new BigDecimal(19.9)千万不要用new BigDecimal(19.9)这种double入参的方式否则精度在构造阶段就已经丢了。POI导出大数据量内存溢出也是一个高频问题。直接用XSSFWorkbook导出5万行以上堆内存会快速上涨小内存服务器直接OOM。解决方式是改用SXSSFWorkbook或者限制单次导出行数并提示用户分批导出。这个坑的根源是不了解POI的内存模型了解后其实很好规避。7. 扩展思路与面试官追问清单7.1 多门店支持要怎么改如果题目想升级加多门店维度其实就两处改动t_stock和t_sale_order等表加shop_id字段所有库存操作和查询都按shop_id过滤。更进一步就是引入门店独立数据库以及基于消息补偿或分布式事务的方案但那种量级一般不对毕业设计做要求。了解核心思路即可账务分离、库存独立、商品档案共享这是零售系统的经典设计。7.2 面试官常常追问的8个问题最后整理一份我在面试和帮读者模拟时高频出现的追问清单追问点回答思路超卖怎么解决条件UPDATE原子扣减配合数据库行锁事务什么时候失效自调用、异常被吞、非public方法金额为什么不用double二进制浮点无法精确表示小数必须用BigDecimal订单和库存一致性同一个本地事务异常全量回滚库存流水设计目的可追溯、账实一致、方便盘点和审计JWT和Session的区别JWT无状态、跨端、服务端不存会话报表慢了怎么办索引、聚合宽表、缓存、分表密码怎么存BCrypt加盐哈希不存明文不返回明文每一题都能从这个项目的代码里找到对应实现这也是这套系统面试好聊的根本原因。它不是虚构的业务你亲手解决过超卖、排查过事务失效这些经历比背八股文有说服力得多面试官从你的描述里能听出你是真的写过、真的踩过坑。说到最后我个人的体会是做这类系统最忌讳的是求大求全。先把商品建档、采购入库、收银下单、库存扣减、销售日报这条最小闭环打通再去扩展会员、报表、权限这些附加模块。我帮人改过的项目里凡是前期地基打得稳的——库存流水表设计对、金额用BigDecimal、扣库存用条件UPDATE——后面都顺得很反之返工的地方基本都在这些看似不起眼的细节上。如果你正在做或者准备做这套系统希望这份拆解能帮你少踩几个坑。最后再分享一个小技巧开发时把日志里的SQL执行时间打开配合一个简单的并发压测脚本很多隐藏问题在交付之前就能提前暴露。尤其收银类系统真正上线后最怕的就是并发和账实不一致这两类问题越早发现返工成本越低。
返回列表