
搞过毕设的人都知道餐饮娱乐行业的收银管理系统是JavaWeb方向里特别经典的一类题目——业务链路长、模块划分清晰、技术点覆盖面广从基础CRUD到订单并发、金额计算、会员营销、数据报表全都能串起来。我今年刚做完这个“基于SpringBoot的餐饮娱乐一体化收银与订单管控平台”从选题分析、数据库设计到核心功能落地、答辩准备一路走下来积累了不少一手经验。这篇帖子就把整个项目的设计与实现过程完整拆开讲一遍包括表结构怎么设计、收银结算的坑在哪里、会员营销模块怎么做得有亮点以及我在实际开发过程中踩过哪些坑、怎么排查的。不管你是正在选毕设题目的学生还是想用SpringBoot快速搭一套收银系统的开发者这篇文章应该都能给你一些可以直接抄作业的参考。1. 项目概述与核心需求拆解1.1 这个系统到底要解决什么问题先说清楚业务背景。餐饮和娱乐行业餐厅、火锅店、KTV、酒吧、棋牌室、台球厅这些有一个共同特点消费场景复杂收款方式多样而且特别依赖会员复购。传统做法是点餐、收银、会员、报表各自用一套独立软件或者干脆靠手工记账结果就是数据对不上、会员信息割裂、老板想看个营业报表还得自己拿Excel做。所以这个项目的核心目标就是把“消费结算”和“会员营销”两条线整合到一个平台里。具体拆开来看系统要解决这么几类问题前台收银员需要快速完成点单、加菜、退菜、折扣、结算这一整套操作而且支持现金、刷卡、扫码、会员储值等多种支付方式。服务员需要能查看桌台状态、开台、换桌、并台订单要能实时同步到后厨的显示终端。老板和店长需要看实时营业额、不同时间段的销售趋势、菜品销量排行用来做经营决策。会员管理不能只停留在“能查到会员信息”层面要有等级成长、积分累计、储值余额、优惠券发放这些真实营销场景。所以这不仅仅是一个“增删改查”的常规毕设而是要把一套完整的收银业务流程跑通。我当时给系统定的一个设计原则是所有业务数据都要围绕“订单”这条主线流转。会员、商品、桌台都是静态基础数据而每一笔订单会串联起桌台状态变更、库存扣减、金额计算、支付记录、积分变动这才是系统真正有价值的地方。1.2 技术选型为什么是SpringBoot JavaWeb很多人在选技术栈的时候纠结我直接说结论SpringBoot是这类毕设最稳妥的选择没有之一。理由很简单。第一SpringBoot大幅降低了配置成本不用像传统SSH或SSM那样写一堆XML配置文件一个spring-boot-starter-web就能把Web环境跑起来。对于毕设这种周期紧、需要快速出成果的项目来说开发效率是第一位的。第二SpringBoot的生态太成熟了整合MyBatis、Redis、JWT、微信支付这些都有现成的starter遇到问题网上资料一搜一大把。第三答辩的时候评委老师对SpringBoot的认可度普遍比较高因为它代表了当前企业级Java开发的实际主流方向比纯Servlet/JSP的“老古董”架构更有说服力。我最终选定的技术组合是后端SpringBoot 2.7.x MyBatis-Plus MySQL 8.0前端Thymeleaf模板引擎 Bootstrap jQuery也可以换成Vue前后端分离但纯Thymeleaf对毕设来说更好演示、更好讲解权限控制Spring Security或者拦截器 Session我最后用了拦截器方案简单直观接口文档Swagger Knife4j方便答辩的时候现场演示接口这里重点说一下为什么用MyBatis-Plus而不是原生MyBatis。MyBatis-Plus提供了BaseMapper单表CRUD几乎不用写SQL能省下大量重复代码同时它还支持分页插件、逻辑删除、自动填充这些功能对提升开发效率非常明显。当然复杂的多表关联查询还是要手写SQL这个逃不掉但也正好是展示你SQL能力的机会。这套组合在答辩时也很好解释基础CRUD用MP提升效率复杂业务查询手写SQL保证灵活性和可控性。2. 系统整体设计思路2.1 模块划分与权限设计系统按业务功能拆成六大模块系统管理、桌台管理、菜品/商品管理、订单管理、收银结算、会员营销。每个模块内部的职责要单一模块之间通过Service层接口交互不能出现Controller直接跨模块调用Mapper的情况。权限设计这块我采用的是“基于角色的菜单权限控制”。说白了就是用户表关联角色角色关联菜单权限登录的时候查出这个角色能看到哪些菜单然后在前端做菜单渲染、在后端用拦截器校验接口访问权限。系统里我设计了三种角色角色权限范围典型操作管理员全部模块系统配置、报表统计、员工管理收银员桌台、点单、收银、会员查询开台、加菜、结算、充值服务员桌台、点单开台、换桌、下单、呼叫服务后端的权限拦截逻辑很好写就是写一个HandlerInterceptor在preHandle里判断当前用户Session是否存在、访问的接口是否在角色的权限清单里。这里有个小技巧权限校验不能只在前端隐藏菜单就完事后端一定要做二次校验因为接口任何人都可以直接调用。我做项目的时候专门在拦截器里加了URL匹配逻辑把每个Controller接口的路径和权限码对应起来这个细节在答辩的时候可以主动讲出来老师会觉得你有安全意识。2.2 数据库表设计与关键字段说明数据库设计是这类项目的灵魂表设计得好不好直接决定后面业务逻辑的复杂度。我根据业务流程把表分成四组一共设计了十几张表核心的几张我逐个说。基础数据组sys_user员工账号、sys_role角色、sys_menu菜单权限、category菜品分类、product菜品/商品、dining_table桌台。订单核心组orders主订单、order_item订单明细。这里我特别说一下主订单表的设计思路。orders表除了记录订单号、桌台ID、下单时间这些基本信息还要有一个order_status字段来记录订单的完整生命周期0待支付、1已支付、2已退菜、3已完结。千万别把“支付状态”和“订单状态”混成一个字段我一开始就是这么干的结果后面要做订单统计和退款逻辑的时候把自己绕晕了。后来拆成两个字段pay_status只管支付order_status管订单流程配合上discount_amount、total_amount、actual_amount三个金额字段逻辑才清晰起来。order_item订单明细表要记录每一道菜的单价、数量、金额快照。为什么叫快照因为菜品价格是可以改的如果只关联菜品ID菜单改价之后历史订单的金额就失真了。所以明细表里的菜品名称、单价、折扣比例都要冗余存一份这在做营业额统计和菜品销量分析的时候非常重要。支付与会员组payment_record支付流水、member会员、member_recharge_record储值记录、member_points_record积分流水、member_coupon会员优惠券、coupon优惠券模板。营销与统计辅助recharge_rule充值活动规则、consume_discount消费折扣规则。这两张表是我为会员营销模块加的后面细说。在设计表的时候有几个容易踩坑的点我单独列一下金额字段一律用DECIMAL(10,2)不要用double或float。二进制浮点数在计算0.10.2这类场景时会有精度误差做金额计算绝对不能用这一点后面我会专门讲。所有业务表都要有create_time、update_time、deleted这三个审计字段。MyBatis-Plus的MetaObjectHandler可以做字段自动填充逻辑删除用TableLogic注解这样查询的时候自动过滤已删除数据删除操作变成逻辑删防止误删导致的数据丢失。订单号不要用自增ID直接暴露给用户要用“日期随机数自增序列”组合生成。我当时用yyyyMMddHHmmss 4位随机数拼接保证在单门店场景下不会重复。3. 核心功能实现细节3.1 收银结算流程从点单到支付的全链路收银模块是整个系统的核心也是业务逻辑最密集的地方。完整的收银链路是这样的第一步顾客进店后服务员选择对应桌台点击“开台”系统把桌台状态从空闲改为占用同时生成一个待支付的订单草稿。第二步服务员在桌台上加菜每条菜品记录写入order_item同时实时更新订单的total_amount。这里有个交互细节加菜操作不应该在每次点击后都立即请求后端重新计算总价而是在前端先把菜品明细缓存起来只有当服务员点“确认下单”时才一次性提交到后端。这样既减少了请求次数也避免订单明细频繁插入、删除产生的数据碎片。第三步结账时收银员进入结算页面可以看到订单明细、订单总金额、可用的会员折扣、优惠券。收银员核对无误后选择支付方式现金、微信、支付宝、会员储值系统生成支付流水、更新订单状态、释放桌台、累计会员积分一个完整的事务就结束了。第四步如果有部分菜品需要退菜比如顾客觉得菜做错了、上菜太慢不要了走退菜流程。退菜本质上是生成一条负数的订单明细或者更新原明细的退菜状态同时要重新计算订单金额。这里我原来设计的是“直接删除明细”后来发现会导致后厨出餐单据和实际收款对不上改成“标记退菜状态”的方案就合理多了。收银结算这个流程用代码实现的时候我用了一个很关键的机制——事务边界要覆盖整个结算过程。也就是说从记录支付流水到更新订单状态、扣减会员储值、发放积分整个操作必须在同一个Spring事务里。任何一步失败全部回滚不能让钱扣了但积分没到账这种问题出现。3.2 桌台状态管控与订单生命周期桌台管理表面上是简单CRUD但实际上它和订单状态是强耦合的。我把桌台状态定义成四种空闲、占用、待结账、已锁定。空闲对应开台占用对应点单中待结账对应已完成点单但还没支付的状态已锁定对应需要进行盘点或预留的场景。状态流转逻辑是这样的空闲 - 开台 - 占用 - 提交结算 - 待结账 - 完成支付 - 空闲这里有一个特别容易忽略的问题桌台状态和订单状态必须保持在同一个事务里更新。比如开台的时候先插入订单记录再把桌台状态改为占用这两步如果分开执行就会出现“订单建了但桌台还是空闲”的中间状态。如果中途崩溃桌台状态和订单状态就对不上了。所以我所有涉及桌台状态变更的操作都是放在同一个Transactional方法里完成的。换桌和并台也是经营场景里真实存在的需求。换桌的逻辑相对简单就是更新订单的桌台ID同时把原桌台释放、新桌台占用。并台比如两桌顾客是一起来的最后要一起结账业务上稍微复杂一些我的实现方案是引入“合并支付”机制允许把多个待结账状态的订单关联到一个支付流水上在orders表加一个merge_parent_id字段记录合并后的主订单ID结账的时候按合并订单汇总金额支付。这个功能在答辩时是很有亮点的因为大部分毕设不会想到并台这种真实经营场景。3.3 会员营销模块让系统增值的部分会员营销模块是拉开毕设档次的关键也是评委老师比较感兴趣的点。我设计了三个核心营销玩法第一个是储值卡充值活动。会员可以预充值系统里有recharge_rule这张表配置充值活动比如“充500送80、充1000送200”。充值时计算赠送金额把本金和赠送分账记录因为后续如果做退卡赠送金额的处理规则和本金不一样。会员结账时选择“会员储值支付”系统校验余额充足后扣减余额并生成支付流水。这里我在会员表里同时存了balance余额字段每次充值、消费都会同步更新这个字段。第二个是积分体系。消费金额按比例换算成积分我设定的是1元1积分积分累计到一定数值可以兑换优惠券或者抵扣现金。积分的变动我全量记录到member_points_record记录了积分增减类型、变动数值、关联订单号。这里有一个小坑积分计算要在“实际支付金额”基础上算不能在“订单原价”基础上算。因为如果有折扣、优惠券积分还按原价发放就不合理了。我一开始就犯了这个错后来改过来。第三个是优惠券营销。系统支持按消费金额自动发放优惠券比如满200减30也支持管理员手动定向发放。优惠券要设计有效期过期自动失效。做这个功能的时候我吃了点苦头——优惠券的状态判断不能只看数据库的status字段还要在查询时实时判断当前时间是否在valid_start_time和valid_end_time之间。因为如果优惠券“未使用”但已经过期了它在业务上应该被认定为“不可用”。我在Service层封装了一个getAvailableCoupons方法把所有校验逻辑集中处理比散落在Controller里干净很多。3.4 营业报表统计报表统计是老板视角的核心功能。我做了一个营业统计页面按日、周、月维度展示营业额、订单数、客单价、优惠金额这些指标同时支持菜品销量排行Top10和时段客流分析。报表这块我之前想用复杂的SQL实现后来发现MyBatis-Plus的LambdaQueryWrapper应付不了多表聚合统计还是老老实实手写SQL。比如按天统计营业额SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS date, COUNT(*) AS order_count, SUM(actual_amount) AS total_amount, SUM(discount_amount) AS discount_amount FROM orders WHERE pay_status 1 AND deleted 0 AND create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY date DESC这里我用了actual_amount实付金额和discount_amount优惠金额两个字段分开统计这样报表里既能显示实际收入也能显示发了多少优惠老板一看就知道营销成本有多少。另外一个关键点是统计时一定要过滤pay_status 1只统计已支付的订单不然待结账的草稿订单会把数据搞乱。4. 关键技术实现与代码实战4.1 项目工程结构搭建我用Maven管理项目标准的SpringBoot三层架构。工程结构这样划分com.example.cashier ├── controller // 接口层接收请求、参数校验、返回结果 ├── service // 业务层核心业务逻辑 │ └── impl ├── mapper // 数据访问层MyBatis-Plus的Mapper接口 ├── entity // 实体类对应数据库表结构 ├── dto // 数据传输对象接收前端参数、组装返回数据 ├── vo // 视图对象返回给前端的视图数据 ├── config // 配置WebMvcConfig、拦截器、MyBatis-Plus配置 ├── common // 通用统一返回结果、异常处理、工具类 └── CashierApplication.java统一返回结果类我封装成了ResultT包含code、msg、data三个字段。这个看似很基础但真的很重要——如果每个接口返回的数据格式都不统一前端处理起来会很痛苦而且答辩展示接口文档的时候也会显得杂乱。全局异常处理我也用RestControllerAdvice做了一套业务异常抛一个自定义的BusinessException全局捕获后转成统一的错误返回体这个设计能让代码里少一多半的try-catch。4.2 收银结算核心代码订单结算是我整个项目里事务逻辑最复杂的一段我贴一下核心实现简化后的版本Transactional(rollbackFor Exception.class) public void settleOrder(SettleOrderRequest request) { // 1. 查询订单并校验订单状态 Orders order orderMapper.selectById(request.getOrderId()); if (order null || order.getPayStatus() ! 0) { throw new BusinessException(订单不存在或已支付); } // 2. 计算应付金额订单总额 - 优惠券抵扣 - 会员折扣 BigDecimal totalAmount order.getTotalAmount(); BigDecimal discountAmount BigDecimal.ZERO; if (request.getCouponId() ! null) { MemberCoupon memberCoupon memberCouponMapper.selectById(request.getCouponId()); if (memberCoupon null || !isCouponAvailable(memberCoupon)) { throw new BusinessException(优惠券不可用); } discountAmount discountAmount.add(memberCoupon.getFaceValue()); order.setCouponId(memberCoupon.getId()); } // 会员折扣 BigDecimal memberDiscount BigDecimal.ZERO; if (request.getMemberId() ! null) { Member member memberMapper.selectById(request.getMemberId()); if (member ! null member.getLevel() ! null member.getLevel() 1) { memberDiscount totalAmount.multiply( new BigDecimal(0.05) ).setScale(2, RoundingMode.HALF_UP); } } BigDecimal actualAmount totalAmount .subtract(discountAmount) .subtract(memberDiscount) .max(BigDecimal.ZERO); // 3. 记录支付流水 PaymentRecord payment new PaymentRecord(); payment.setOrderId(order.getId()); payment.setPayType(request.getPayType()); payment.setPayAmount(actualAmount); payment.setPayStatus(1); paymentRecordMapper.insert(payment); // 4. 更新订单状态 order.setPayStatus(1); order.setOrderStatus(3); order.setActualAmount(actualAmount); orderMapper.updateById(order); // 5. 释放桌台 diningTableMapper.updateStatusById(order.getTableId(), TABLE_STATUS_FREE); // 6. 会员积分和储值扣减 if (request.getMemberId() ! null) { Member member memberMapper.selectById(request.getMemberId()); if (PAY_TYPE_BALANCE.equals(request.getPayType())) { member.setBalance(member.getBalance().subtract(actualAmount)); } int points actualAmount.setScale(0, RoundingMode.DOWN).intValue(); member.setPoints(member.getPoints() points); memberMapper.updateById(member); MemberPointsRecord pointsRecord new MemberPointsRecord(); pointsRecord.setMemberId(member.getId()); pointsRecord.setOrderId(order.getId()); pointsRecord.setPoints(points); pointsRecord.setType(POINTS_TYPE_CONSUME); pointsRecordMapper.insert(pointsRecord); } }这段代码有几个值得注意的细节。第一Transactional(rollbackFor Exception.class)这个注解一定要写。Spring默认只在遇到RuntimeException时才回滚事务如果你的业务代码抛的是自定义的BusinessException它可能继承自RuntimeException那没问题但为了保险起见明确指定rollbackFor Exception.class可以避免踩到“异常没触发回滚”的坑。第二金额计算全部用BigDecimal加减乘除都要用方法调用而不是运算符。这里我用了setScale(2, RoundingMode.HALF_UP)做四舍五入保留两位小数这是金额计算的标准做法。第三积分计算用了RoundingMode.DOWN也就是向下取整。这个取舍是刻意的1块钱按1积分发放如果消费了9.5元向下取整发9分避免积分拆出小数。虽然看起来是小事但这类细节在答辩现场往往能加分。4.3 数据层MyBatis-Plus的应用MyBatis-Plus在项目里的实际使用我分三层说。单表CRUD直接用BaseMapper提供的selectById、insert、updateById、deleteById不需要写任何SQL。实体类和表字段的映射用注解标注驼峰和下划线的自动转换MyBatis-Plus默认就支持比如Java里的createTime会自动映射create_time列这个不需要额外配置。条件是动态组合的场景用LambdaQueryWrapper比如LambdaQueryWrapperOrders wrapper Wrappers.lambdaQuery(); wrapper.eq(Orders::getTableId, tableId) .eq(Orders::getPayStatus, 0) .orderByDesc(Orders::getCreateTime); ListOrders orders orderMapper.selectList(wrapper);LambdaQueryWrapper的好处有两个一是方法引用写法在编译期就能检查字段名是否正确不会出现把列名拼写错然后到运行期才报错的情况二是代码可读性好别人包括答辩老师一看就知道查询条件是什么。分页查询用MyBatis-Plus的分页插件在配置类里注册一个MybatisPlusInterceptor加上PaginationInnerInterceptor然后selectPage就能用了。分页插件要特意说一句因为它是MyBatis-Plus官网明确要求“必须配置插件才能生效”的功能很多新手漏了这一步导致分页方法调用后返回的数据不分页排查半天才发现是插件没注册。4.4 环境配置从零把项目跑起来很多同学搭建SpringBoot项目的时候卡在环境配置上。我说一下我们的配置方案尤其是一些容易出问题的点。application.yml里主要配置数据源、MyBatis-Plus、服务器端口。数据源我用的是Druid连接池spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cashier_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有三个配置细节值得注意。数据库连接的URL里必须带serverTimezoneAsia/Shanghai。MySQL 8.x的驱动对时区要求很严格不指定时区的话连接会报错。这是一个很常见的问题网上搜“MySQL时区报错”能搜出一堆提问。map-underscore-to-camel-case: true是驼峰映射开关配合MyBatis-Plus默认开启这个配置实体类的userId才能自动映射到user_id列。开发阶段把log-impl设为StdOutImpl这样每次执行SQL都会在控制台打印出来排查问题特别方便。上线前再关掉就行。5. 常见问题与排查经验实录5.1 并发下单导致的数据一致性问题这是我开发过程中遇到的最典型的问题也值得单独拿出来说。当时我做了一个压力测试场景多个收银员同时给同一张桌台并发加菜结果发现订单明细偶尔会出现丢失——明明两个请求都提交成功了但订单里只有一份明细。排查后发现原因在于前端每个“加菜”请求都是独立的HTTP请求后端两个线程同时读到同一个订单的order_item表然后各自插入自己的明细理论上不应该丢数据才对。真正的问题出在我用了一个叫“临时明细”的机制加菜时先往order_item里插入一条状态为“草稿”的记录提交订单时再把这些草稿记录改成“已确认”。两个并发请求同时做“把草稿改成已确认”的更新操作时因为没有加锁一个请求把另一个请求的明细状态覆盖了。解决办法是给订单加一个状态机锁。我在每次提交订单时先执行UPDATE orders SET order_status 1 WHERE id ? AND order_status 0通过受影响行数判断当前是否有其他请求正在操作这个订单。如果影响行数为0说明订单已经被别人处理过了直接返回“订单状态已变更请刷新重试”。这个方案本质上是乐观锁的思想用条件更新的原子性来保证并发安全比在代码里加synchronized块靠谱得多——因为synchronized只对单机单进程有效而且锁粒度不好控制。这里我总结了一个规律凡是涉及到“多个操作同时修改同一个业务对象”的场景优先考虑在数据库层面用条件更新或版本号解决而不是在Java代码里加锁。数据库的行锁和条件更新是数据库自己保证原子性的不会有分布式环境下锁失效的问题。5.2 BigDecimal精度丢失的经典坑这个坑太经典了我必须详细说。最早我图省事金额字段在Java里直接用Double结果发现一个很奇怪的现象一笔订单总额是88.90元打完折应该是68.20元但算出来是68.19999999999999。打印出来的账单金额就变成了68.2但数据库里存的其实是那个无限接近的小数后续再做统计的时候SUM出来总是比实际应该有的金额差那么几分钱。原因很明确double和float在计算机里是二进制浮点数无法精确表示0.1、0.2这类十进制小数。菜品的定价通常精确到分一旦多个价格相加、相乘误差就会累积。彻底修复方案分三处Java实体类中所有金额字段类型改为BigDecimal。数据库字段类型使用DECIMAL(10,2)保证存储层的精度。所有金额计算都在Service层显式使用BigDecimal的add、subtract、multiply、divide方法并且统一用RoundingMode.HALF_UP处理四舍五入。给你一个最简单的口头禅“商品价格是BigDecimal接口入参也转BigDecimal别用String接收金额再强转也别用Double做任何金额运算。”这个坑我在答辩的时候主动讲出来老师很认可因为它说明我真的在项目里遇到了实际问题并且知道怎么修复。5.3 事务失效的原因排查有段时间我发现一个诡异的现象订单结算的时候支付流水已经写进去了但会员积分偶尔没加上。排查到最后发现是因为我在Service的一个方法里调用了另一个同类的方法而被调用的那个方法上的Transactional没有生效。原因就是Spring事务的“自调用失效”问题。Spring的Transactional是基于AOP代理实现的——调用Service方法时实际调用的是Spring生成的代理对象代理对象才会开启事务。但如果在同一个类内部一个方法直接调用另一个方法this.doXxx()调用的是原始对象的方法不经过代理事务注解自然就失效了。解决方案有三种我推荐第二种把两个方法拆到不同的Service类里通过注入的Service对象调用保证经过代理。在同一个类里通过Autowired注入自身Spring容器的代理对象或者从ApplicationContext里取代理对象再调用。直接在方法最外层加Transactional让内层方法不声明事务。我在项目里最终的做法是把“结算订单”这个完整流程抽象成一个独立的方法所有事务逻辑全部放在这个方法里而不是让多个小方法各自带事务互相嵌套。这样事务边界清晰也好讲解。5.4 前端接口参数校验的几个建议参数校验看起来是小事但做得不好会在联调、答辩演示时出洋相。比如前端传了一个空的orderId过来或者传了一个不存在的会员ID后端如果没有校验就直接查询、计算最后报一个莫名其妙的空指针异常演示现场就很尴尬。我的做法是三层校验Controller层用ValidatedNotNull、Min这些注解拦截格式错误Spring会抛出MethodArgumentNotValidException再被我全局异常处理器转成统一的参数错误返回。Service层做业务校验比如“优惠券是否属于当前会员”“菜品是否在售”“桌台是否空闲”这些必须查库才能验证的逻辑。前端做基本的必填校验避免把错误请求发到后端。三层校验看着冗余但真实项目里每一层都是必要的。Controller层拦截低级错误Service层保证业务安全前端提升用户体验。5.5 一个排查了很久的SQL问题最后分享一个我曾经卡了很久的问题。做营业统计的时候我发现同样的时间段、同样的店铺用Navicat执行SQL查询出来的营业额和系统页面上显示的不一样。一开始以为是缓存问题清了缓存还是不一样后来仔细对比发现我犯了一个低级错误——Java代码查询的时候SQL里的create_time BETWEEN #{startTime} AND #{endTime}传入的startTime和endTime是字符串我传的是2025-01-01 00:00:00和2025-01-01 23:59:59看起来没问题但在某些情况下23:59:59会漏掉在这最后一秒内创建的订单。实际上问题出在我用LocalDateTime转字符串的时候格式化掉了毫秒。比如2025-01-01 23:59:59.500格式化后变成2025-01-01 23:59:59那么数据库里这一秒内的订单就可能超出BETWEEN边界。这不是边界问题是格式化丢精度的问题。后来统一用LocalDateTime参数直接传给SQL不再转字符串问题彻底解决。这个小问题花费了我整整一个下午验证了一个经验时间范围查询能用类型就用类型别在中间环节转字符串。尤其是涉及时间边界时字符串格式化会引入不可预料的精度丢失。6. 我的经验总结与后续扩展建议做完这个项目我最大的体会是毕设选题的价值不在于功能数量多而在于业务链路完整、问题真实、有自己独立的思考。收银管理系统的魅力在于它把JavaWeb的核心知识点——SpringBoot、MyBatis、事务、并发、缓存、报表——自然地串到了一条真实的业务链路上每个技术点都不是为了用而用而是业务真的需要它。从答辩角度给几个实操建议系统演示前一定要准备两套数据一套是演示用的“干净数据”订单、会员、桌台状态都是预置好的合理场景另一套是压测/测试用的“脏数据”专门用来演示异常处理和数据校验。千万不要拿真实测试数据直接开演示页面上一堆乱代码很减分。讲解系统的时候不要按照“登录、管理、报表”这种功能清单来念要按“用户故事”来讲。比如顾客到店开台服务员点单餐后结账用会员储值加优惠券抵扣消费后积分到账老板在报表页看到今日营业额和菜品销量排行。用一个完整的业务场景把系统串起来评委跟着你的思路走会觉得你对业务的理解很深入。主动讲一两个踩坑经历比如我前面说的BigDecimal精度问题、事务自调用失效问题。评委老师都做过开发知道真实项目里这些东西才是最容易出问题的地方你能讲清楚说明项目是真的自己动手做的。如果后续想在这个项目上继续扩展我觉得有两个方向性价比比较高。一个是引入Redis做热点数据缓存比如桌台状态、菜品列表、会员信息这类读多写少的数据可以大幅提升并发能力也方便在答辩时讲缓存一致性的设计思路。另一个是用WebSocket实现后厨出餐屏的实时推送——订单一提交后厨终端马上显示新单这个功能在真实收银系统里特别常见而大部分毕设都不会做做出来就是差异化亮点。当然扩展到这一步的前提是先把现有的收银、订单、会员、报表这些核心链路做扎实基础打牢了加什么都只是时间问题。总的来说这套系统的设计思路和实现方案覆盖了SpringBoot从环境搭建到业务落地再到问题排查的完整过程。如果正在做类似题目的同学能顺着这个思路重新梳理一遍自己的项目哪怕只借鉴其中一部分方案应该也能让整个项目的结构更清晰、代码更规范、答辩更有底气。