毕业设计或课程设计做到管理系统这类题目时基于SSM的甜品店销售管理系统是一个很常见的选题。SSMSpring SpringMVC MyBatis这套组合虽然在今天已经不算新但作为理解JavaWeb后端架构的入门框架它的价值依然非常高很多学校的课程体系也还是围绕它来设计。这篇博客不打算贴一堆零散的代码就算完事我想从昭愿这个甜品店销售管理系统的完整设计思路讲起把业务建模、数据库表设计、SSM整合、核心模块实现、权限控制、统计报表以及运行时的常见坑都串起来让你看完之后不只是会抄代码还能自己把这个项目从头推演一遍。如果你是正在做类似课设、毕设的学生或者刚学完SSM想找一个完整项目练手这篇文章应该能省下你不少自己摸索的时间。我会尽量还原一个真实项目从0到1的推进过程包括当时踩过的坑和后来复盘出来的原因这些内容在大多数参考项目里不会写但对答辩和面试都很加分。1. 先理清业务甜品店管理系统到底在管什么1.1 一张日结单暴露出的管理痛点做管理系统最忌讳一上来就建表写代码。哪怕是一个课设项目如果业务流程没想清楚写出来的东西也只是把增删改查堆在一起答辩时经不住追问。昭愿甜品店的业务场景可以想象成一家有稳定客流、同时经营线下门店和会员储值的甜品店。每天傍晚店长要对账传统做法是店员翻收银小票、手写台账、对照微信收款记录同时还要统计哪些甜品卖得好、哪些原料快见底了。这种手工模式有三件事特别头疼账实不符前台记录和后台库存对不上往往是前一天晚上盘点后改了库存第二天没同步。会员优惠全靠记忆哪些会员是什么等级、该打几折、积分攒了多少店长脑子记得住但店员换班之后基本靠问。畅销品缺货没人知道卖得最好的杨枝甘露下午三点就断货数据要等月底拉Excel才看得出来。所以这个系统要解决的核心问题是把商品、会员、订单、库存、统计这五块数据全部搬到线上让店长每天打开页面就能看到日结情况店员在收银台就能完成下单和会员结算。1.2 系统需要覆盖的五条核心业务线先按业务模块把系统功能拆清楚后面建表和写代码才不会被细节带跑。就昭愿甜品店这个场景而言核心模块可以分成下面五条线商品线商品分类、商品信息维护、规格管理比如冰/热大杯/中杯半糖/全糖这些选项、上架与下架。会员线会员注册登录、等级划分、积分累计与扣减、储值余额。订单线购物车、下单、金额计算、订单状态流转待支付、制作中、已完成、已取消、退款。库存线按规格记录库存数量、采购入库、销售出库、盘点调整、库存流水留痕。统计线按日/周/月的销售汇总、商品销售排行、分类占比这是店长日常最关注的部分。把这五条线拆出来之后模块之间的依赖关系也就清楚了会员和商品是基础数据订单是核心业务库存跟着订单联动统计则是订单数据的二次加工。1.3 为什么选SSM这套技术栈的学习价值在哪现在很多新项目直接用Spring Boot课设选SSM看起来好像是老技术但这恰恰是SSM在教育场景里不可替代的地方。Spring Boot把配置自动完成了很多同学做完一个项目都不清楚Spring容器是怎么加载的、DispatcherServlet是怎么分发请求的、Mapper接口是怎么找到SQL的。而SSM强迫你手写配置每写一行applicationContext.xml、spring-mvc.xml、mybatis-config.xml都是在补框架底层的课。从三层架构的角度看SSM的三个成员分工非常清晰Spring管对象。Service、DAO这些Bean的生命周期、依赖注入、事务控制都由Spring容器来管理。SpringMVC管请求。前端的HTTP请求进来之后DispatcherServlet根据URL找到对应的Controller方法把参数绑定好再返回视图或JSON。MyBatis管数据库。Mapper接口定义方法XML或注解里写SQLMyBatis负责结果集到Java对象的映射。对找实习和面试来说现在很多存量系统的维护需求依然是SSM能看明白SSM的配置结构意味着你不会被某个框架版本绑死换到Spring Boot或者其他MVC框架时也能快速迁移。2. 数据库设计十四张表怎么拆才不乱2.1 商品、分类与规格一对多的层级关系甜品店的商品和一般电商商品有个明显的区别同一个商品会有多个规格不同规格的价格和库存都可能不一样。比如芝士葡萄这个商品可以分成冰·大杯冰·中杯热·中杯三种规格每种规格分别对应价格和库存。如果在商品表里一把梭把所有规格都塞进去后面改价格、扣库存都很难维护。所以商品这一块我拆了三张表tb_category分类表idBIGINT自增主键nameVARCHAR(50)分类名称parent_idBIGINT默认0支持二级分类sort_orderINT排序权重create_timeDATETIME创建时间tb_product商品表idBIGINT自增主键category_idBIGINT关联分类IDnameVARCHAR(100)商品名称main_imageVARCHAR(255)商品主图路径priceDECIMAL(10,2)默认售价member_priceDECIMAL(10,2)会员价可空statusTINYINT1上架 0下架descriptionTEXT商品描述create_time、update_timeDATETIMEtb_product_spec规格表idBIGINT自增主键product_idBIGINT关联商品IDspec_nameVARCHAR(50)规格名如冰·大杯·半糖priceDECIMAL(10,2)规格价格stockINT当前库存versionINT乐观锁版本号后面扣库存会用到这样设计之后一对多的关系非常清楚一个分类下有多个商品一个商品下有多个规格。前台点单时展示的是规格级的价格和库存后台维护时则先选中商品再维护具体规格。2.2 订单主表与明细表父子结构里的数据快照订单设计是整个系统的核心。我只用一张订单表的话会出现一个订单包含多个商品时字段无处安放的问题。所以必须拆成主表和明细表两张tb_order订单主表idBIGINT自增主键order_noVARCHAR(32)订单编号全局唯一member_idBIGINT下单会员ID可为空表示散客total_amountDECIMAL(10,2)商品原价总额discount_amountDECIMAL(10,2)优惠金额actual_amountDECIMAL(10,2)实付金额pay_typeTINYINT0现金 1微信 2支付宝 3会员卡statusTINYINT0待支付 1制作中 2待取餐 3已完成 4已取消 5已退款remarkVARCHAR(255)备注create_time、pay_time、finish_timeDATETIMEtb_order_item订单明细表idBIGINT自增主键order_idBIGINT关联订单主表IDproduct_idBIGINT商品IDspec_idBIGINT规格IDproduct_nameVARCHAR(100)商品名称快照spec_nameVARCHAR(50)规格名称快照priceDECIMAL(10,2)下单时单价快照quantityINT购买数量subtotalDECIMAL(10,2)小计金额为什么要强调快照因为商品名称、价格这些信息是会变的。如果订单明细直接去关联商品表过一段时间商品改价或者改了名字历史订单打印出来就对不上了财务对账会出现系统里的订单和当时实际卖的价格不一致的投诉。把名称和价格冗余到明细表里历史订单才能保持当时的真实状态。2.3 会员、等级与积分三张表联动会员体系做得好不好直接影响甜品店的复购率。这个系统里会员相关我设计了四张表tb_member会员表idBIGINT自增主键phoneVARCHAR(20)手机号登录账号passwordVARCHAR(64)登录密码nameVARCHAR(50)会员昵称level_idBIGINT会员等级IDpointsINT当前积分balanceDECIMAL(10,2)储值余额statusTINYINT1正常 0冻结create_timeDATETIMEtb_member_level等级表idBIGINT自增主键level_nameVARCHAR(20)等级名称thresholdINT升级所需积分discount_rateDECIMAL(3,2)折扣率如0.90表示九折tb_points_record积分流水表idBIGINT自增主键member_idBIGINT会员IDchange_pointsINT变动积分正负表示增加或扣减source_typeTINYINT1订单消费 2积分兑换 3后台调整source_idBIGINT来源单号IDremarkVARCHAR(255)备注create_timeDATETIME等级和积分分开意味着等级不需要每次算积分都去遍历流水表。会员每次消费后先加分再判断当前积分是否超过下一等级的门槛超过就自动更新level_id。这个判断逻辑写在Service层一个方法搞定后面讲订单模块时会提到。2.4 库存流水表从扣数字到记流水账很多第一次做进销存的人只会在规格表里更新stock字段这其实是远远不够的。库存数只是一个结果真正的业务排查需要的是过程。比如店里月底盘点发现某个规格少了5份没有流水表的话你根本不知道这5份是卖掉的、报损了还是入库时数错。所以我在项目里加了一张tb_stock_flow库存流水表idBIGINT自增主键product_idBIGINT商品IDspec_idBIGINT规格IDchange_typeTINYINT1采购入库 2销售出库 3盘点调整change_qtyINT变动数量入库为正、出库为负before_qtyINT变动前库存after_qtyINT变动后库存relation_noVARCHAR(32)关联单号比如订单号或入库单号operator_idBIGINT操作人员IDcreate_timeDATETIME这样做的好处是每一天的库存变动都有一条可追溯的记录。前台每下一单在扣减stock的同时插入一条change_type为2的流水后台采购入库时插入change_type为1的流水盘点时由店长手工调整并记录change_type为3。对账的时候只要把流水的after_qty跟实际盘点数一对比问题出在哪一环立刻就能定位。2.5 建表时的几个统一约定这些约定看起来琐碎但直接影响项目后期好不好维护。我在建表时统一了下面几项主键统一用BIGINT自增不搞业务字段当主键订单号这些只做逻辑唯一。金额统一用DECIMAL(10,2)绝不用DOUBLE。DOUBLE在计算时会出精度问题做金额结算会被财务骂。需要逻辑删除的表统一加deleted字段TINYINT类型0正常1删除。用户误删商品、误删分类这类操作物理删了就找不回来逻辑删除能救命。涉及订单号和流水号的字段统一加唯一索引避免并发环境下重复单号。时间字段统一用DATETIME不用TIMESTAMP省去2038年和时区的麻烦。3. SSM三大框架的分工与整合细节3.1 Spring容器到底管了什么SSM整合的第一步是配置Spring的根容器也就是applicationContext.xml。这个容器不扫描Controller只管理Service、DAO、数据源和事务。!-- 开启注解扫描只扫描service和dao -- context:component-scan base-packagecom.zhaoyuan.service, com.zhaoyuan.dao context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan !-- 配置数据源 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/zhaoyuan?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ /bean !-- 配置事务管理器 -- bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/Service层用Service标注DAO层用Repository标注需要用到的事务在Service方法上打Transactional。这里有一个很重要的点事务一定要加在Service方法上而不是DAO方法上。因为一次下单要执行校验库存、插入订单、插入明细、扣库存、写流水五个操作这五个操作必须在同一个事务里任何一个失败前面所有操作都要回滚。如果事务加在每个DAO上第一个DAO成功、第二个失败前面的已经提交了数据库就脏了。3.2 SpringMVC的请求处理链路SpringMVC的配置文件spring-mvc.xml负责SpringMVC子容器只扫描Controller!-- 开启SpringMVC注解驱动 -- mvc:annotation-driven / !-- 只扫描controller包 -- context:component-scan base-packagecom.zhaoyuan.controller/ !-- 静态资源放行 -- mvc:resources mapping/static/** location/static// !-- 视图解析器JSP放WEB-INF下防止直接访问 -- bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean请求进到Tomcat后先被web.xml里配置的DispatcherServlet拦截然后由HandlerMapping找到对应的RequestMapping方法。参数绑定、返回值处理转发到JSP还是ResponseBody输出JSON都由SpringMVC完成。实际开发中ResponseBody这个注解用得非常频繁因为当前后端页面即使是用JSP渲染订单提交、用户登录这些操作也还是走Ajax请求比较多返回JSON比返回页面灵活得多。3.3 MyBatis把SQL放在哪里MyBatis这一层的配置包含两个部分。第一部分是mybatis-config.xml主要配置驼峰映射和日志configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings /configurationmapUnderscoreToCamelCase这个配置很关键它能让create_time自动映射到createTime属性省去大量resultMap手写映射的工作量。第二部分是Mapper接口和Mapper XML。接口里方法名要跟XML里statement的id一致namespace要写接口的全限定名public interface ProductDao { Product findById(Long id); }mapper namespacecom.zhaoyuan.dao.ProductDao select idfindById parameterTypelong resultTypecom.zhaoyuan.entity.Product SELECT * FROM tb_product WHERE id #{id} /select /mapper这里要提醒一点如果Mapper XML文件放在src/main/java目录下Maven打包时默认不会把它复制到classes目录运行时就报Invalid bound statement (not found)。我项目里统一把XML放到了src/main/resources/mapper/目录下这样就不会有这个问题。3.4 三套配置整合时最容易翻车的三个位置SSM整合的报错信息往往不太直接下面这三类问题是出现频率最高的问题现象根本原因解决办法事务不生效数据半成功半失败Spring根容器扫描了Controller或者SpringMVC子容器重复扫描了Service导致Service被创建了两份根容器只扫Service和Dao子容器只扫Controller严格分开Invalid bound statement (not found)Mapper接口扫描的包路径与XML的namespace不一致或XML没被打包XML统一放resources下namespace写接口全限定名用MapperScannerConfigurer扫描接口包启动报ClassNotFoundException或连接失败MySQL驱动版本与连接串不匹配MySQL8必须用com.mysql.cj.jdbc.Driver并加时区参数升级驱动到8.xurl里加serverTimezoneAsia/Shanghai驱动类名用cj版4. 核心业务实现下单、库存、结算的完整闭环4.1 一次下单要经过多少步下单功能是整个系统里面最容易写乱的地方。我一开始的做法是把所有逻辑堆在Controller里结果一个方法几百行后面想加优惠券都找不到从哪里下手。后来重构成了Service层的一个完整业务方法步骤如下Service public class OrderServiceImpl implements OrderService { Autowired private CartDao cartDao; Autowired private ProductSpecDao productSpecDao; Autowired private OrderDao orderDao; Autowired private OrderItemDao orderItemDao; Autowired private StockFlowDao stockFlowDao; Override Transactional(rollbackFor Exception.class) public Long submitOrder(Long memberId, ListLong cartIds, Long couponRecordId) { // 1. 查询购物车中选中的商品项 ListCart carts cartDao.selectByIds(cartIds); if (carts null || carts.isEmpty()) { throw new BusinessException(购物车为空); } // 2. 计算总价同时检查商品是否下架、规格库存是否足够 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (Cart cart : carts) { ProductSpec spec productSpecDao.selectById(cart.getSpecId()); if (spec null) { throw new BusinessException(商品规格不存在); } if (spec.getStock() cart.getQuantity()) { throw new BusinessException(spec.getSpecName() 库存不足); } totalAmount totalAmount.add(spec.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); // 组装订单明细 OrderItem item new OrderItem(); item.setProductId(cart.getProductId()); item.setSpecId(spec.getId()); item.setProductName(cart.getProductName()); item.setSpecName(spec.getSpecName()); item.setPrice(spec.getPrice()); item.setQuantity(cart.getQuantity()); item.setSubtotal(spec.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); orderItems.add(item); } // 3. 生成订单主表状态为待支付 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setMemberId(memberId); order.setTotalAmount(totalAmount); order.setDiscountAmount(BigDecimal.ZERO); order.setActualAmount(totalAmount); order.setStatus(0); orderDao.insert(order); // 4. 批量插入订单明细 for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemDao.insert(item); } // 5. 批量扣减库存并写库存流水 for (OrderItem item : orderItems) { int affected productSpecDao.deductStock(item.getSpecId(), item.getQuantity()); if (affected 0) { throw new BusinessException(item.getSpecName() 库存不足); } stockFlowDao.insert(StockFlow.buildSaleFlow(item.getSpecId(), item.getQuantity(), order.getOrderNo())); } // 6. 清除购物车中已下单的商品 cartDao.deleteByIds(cartIds); return order.getId(); } }整个方法用Transactional包住其中任何一步抛出异常前面插入的订单、明细、扣掉的库存全部回滚。这种要么全部成功要么全部失败的保证是订单系统最基本的要求。4.2 库存扣减的并发安全为什么不能先查再改很多初学版本扣库存是这么写的ProductSpec spec productSpecDao.selectById(specId); if (spec.getStock() quantity) { productSpecDao.updateStock(specId, spec.getStock() - quantity); }这个写法在单机单线程演示时看不出问题但只要有两个用户几乎同时下单就可能出现超卖。比如当前库存是5份A和B同时查到了5份A先扣变成4B再扣也基于刚才查到的5去算减完还是4库存被扣成了负数。这个场景在甜品店午高峰真的很常见。正确的做法是把检查库存并扣减合并成一条原子的UPDATE语句UPDATE tb_product_spec SET stock stock - #{quantity} WHERE id #{specId} AND stock #{quantity}这条SQL的意思是只有当前库存大于等于购买数量时才执行扣减。MySQL的行锁会保证同一时刻只有一个事务能更新这条记录affected rows返回0就说明库存不够业务层根据这个结果抛出提示。这里再补充一点如果追求更强的并发控制可以在规格表加version字段做乐观锁。但从甜品店的业务量来看原子更新的行锁机制已经完全够用没必要为了一个课设项目引入过多的复杂度。4.3 结算金额原价、会员折扣、满减券的计算顺序系统里涉及金额的地方一定要规定清楚计算顺序否则前端展示和后端结算很容易不一致。我的计算顺序是这样的先根据购物车里的规格价格累加出原价totalAmount。再查会员等级如果等级折扣率不为1原价乘以discountRate得到折后价。判断用户是否选择了满减券如果券满足满X元减Y元的条件再减掉denomination。最终得到actualAmount存订单表。折扣率和满减券的数据都必须从数据库实时查询绝对不能信任前端传过来的金额。前端传过来的价格改一下就能支付1分钱这是安全漏洞。前端只能传选了哪张券用哪个会员等级这类标识后端自己算钱。优惠券表结构如下tb_coupon优惠券id、coupon_name、denomination面额、min_amount最低消费金额、start_time、end_time、total发行总量tb_coupon_record领券记录id、coupon_id、member_id、status0未使用 1已使用 2已过期、receive_time、use_time、order_id用户在结算页看到的是自己领取且未使用的券选完之后后端要校验这张券的状态是不是未使用、当前时间是否在有效期、订单金额是否达到门槛。校验通过后才进行抵扣同时把这张券的状态改成已使用并关联订单ID。4.4 订单状态流转用影响行数防止重复操作订单状态从待支付到已完成不是随便改的每一次状态变更都要有逻辑校验。比如取消订单的场景如果用户已经支付了就不能再取消必须走退款流程如果订单已经完成了也不可能再回到制作中。我的做法是在所有状态变更的UPDATE语句里都带上当前状态条件int affected orderDao.updateStatus( orderId, targetStatus, // 目标状态 expectStatus // 期望的当前状态 ); if (affected 0) { throw new BusinessException(订单状态已变更请刷新后重试); }UPDATE tb_order SET status #{targetStatus}, finish_time NOW() WHERE id #{orderId} AND status #{expectStatus}这样做还有一个更重要的作用防止取消订单时重复恢复库存。假设订单已经取消了一次库存已经加回去了这时如果用户又点了一次取消没有状态条件限制的话库存就会被加两次。有了WHERE status 0这个条件第二次更新影响行数为0就不会继续执行后面的恢复库存逻辑。5. 权限控制登录、拦截器与操作留痕5.1 用户模型与登录会话系统涉及的使用者有两类后台的店员/店长以及前台的会员。我建了一张tb_user表来管后台用户字段包括id、username、password、real_name、role1店长 2店员、status、create_time。会员就是前面说的tb_member表。登录流程很简单前端提交用户名和密码后端根据用户名查出用户校验密码校验状态然后把用户对象放Session。后续所有需要登录的请求都通过拦截器从Session里取用户。RequestMapping(/login) ResponseBody public Result login(String username, String password, HttpSession session) { User user userService.login(username, password); if (user null) { return Result.error(用户名或密码错误); } session.setAttribute(loginUser, user); return Result.success(user); }5.2 拦截器路径与角色匹配登录校验我用了SpringMVC的HandlerInterceptor在preHandle方法里做判断。一个容易被忽略的坑是静态资源放行。如果拦截器拦了/**图片、CSS、JS全被拦下来页面打开就是一堆裸HTML样式全丢还报各种404。所以配置里必须排除静态资源。mvc:interceptors mvc:interceptor mvc:mapping path/**/ !-- 登录页和静态资源放行 -- mvc:exclude-mapping path/login/ mvc:exclude-mapping path/loginPage/ mvc:exclude-mapping path/static/**/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/images/**/ /mvc:interceptor /mvc:interceptors角色权限的判断我放在同一个拦截器里按路径前缀区分Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); String uri request.getRequestURI(); if (user null) { response.sendRedirect(request.getContextPath() /loginPage); return false; } // 店长才能访问统计和用户管理模块 if (uri.startsWith(/admin) user.getRole() ! 1) { response.setContentType(text/html;charsetutf-8); response.getWriter().write(无权限访问); return false; } return true; }店长可以看统计报表和会员管理店员只能操作收银下单。这种按路径前缀判断的方式比较简单对这个规模的项目完全够用。5.3 密码存放加盐哈希的简单实现密码明文存放在数据库里答辩时很容易被老师一个问题问倒。我在项目里用的是MD5加盐的方式注册时生成一个随机盐值数据库中保存盐值和哈希后的密码。public class PasswordUtil { public static String md5WithSalt(String password, String salt) { String input password salt; StringBuilder sb new StringBuilder(); try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(input.getBytes(StandardCharsets.UTF_8)); for (byte b : bytes) { sb.append(String.format(%02x, b)); } } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } return sb.toString(); } }登录的时候先根据用户名查出用户和盐值再用同样的算法算一遍比对结果是否一致。这样做的好处是即使数据库被人拿到彩虹表也匹配不出原始密码因为每个用户的盐都不一样。如果项目想更安全可以把MD5换成BCrypt但在SSM课设项目里MD5加盐已经足够体现安全意识和工程常识了。6. 数据统计模块让销售数据会说话6.1 日销售报表的聚合SQL统计模块是店长每天打开最多的页面。第一版我只查了订单总数和总金额后来发现销售数据要按天看趋势才有意义于是改成按日聚合SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS order_count, SUM(actual_amount) AS sales_amount FROM tb_order WHERE status IN (3, 5) AND create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day DESC注意这里有一个很关键的过滤条件status IN (3, 5)。3表示已完成5表示已退款。为什么要把已退款排除掉因为如果显示的是销售金额而不是收款金额退款订单一定要从统计口径里剔除否则月底对账会虚高店长按这个数去算提成会被店员质疑。6.2 畅销商品排行怎么查排行功能本质上是GROUP BY加ORDER BY加LIMIT。要查的是商品规格维度的销售数量排行关联订单明细表和订单主表确保只统计有效订单SELECT p.name AS product_name, s.spec_name, SUM(oi.quantity) AS total_quantity, SUM(oi.subtotal) AS total_amount FROM tb_order_item oi JOIN tb_order o ON o.id oi.order_id JOIN tb_product p ON p.id oi.product_id LEFT JOIN tb_product_spec s ON s.id oi.spec_id WHERE o.status IN (3, 5) AND o.create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY oi.product_id, oi.spec_id ORDER BY total_quantity DESC LIMIT 10这个排行榜对店长的采购决策特别有价值。比如连续一周杨枝甘露和多肉葡萄排在销量前二下周进货时这两种口味的水果原料就要多备一些同时还可以考虑把排行高的商品放在菜单首页推荐位。6.3 分类占比与前端图表对接分类占比的数据逻辑是先根据订单明细里的商品关联到分类再按分类分组求和。跟前端对接时接口返回一个JSON数组前端用ECharts的饼图直接渲染。后端返回的数据结构大致长这样[ { name: 奶茶饮品, value: 10234.50 }, { name: 甜品蛋糕, value: 8632.00 }, { name: 面包烘焙, value: 5200.00 } ]ECharts只需要在前端页面引入一个JS文件不需要后端做任何图表处理用series里的type: pie就可以把分类占比展示出来。这里最需要考虑的不是图表怎么做而是后端SQL的统计口径一定要和前端页面上的时间筛选条件保持一致。7. 踩坑实录SSM项目运行时最常见的几个问题7.1 Maven依赖冲突引起的NoSuchMethodError排查项目启动时遇到过NoSuchMethodError一开始完全摸不着头脑因为代码本身没有任何编译错误。后来才发现是依赖冲突项目中同时引入了不同版本的spring-web和spring-core某个类在编译时用的是高版本的方法运行时的类加载却命中了低版本。排查依赖冲突最快的方式是用mvn dependency:tree命令把整个依赖树打出来mvn dependency:tree -Dverbose然后在输出里找到重复出现的Spring包确认版本不一致后在pom.xml里用exclusions把传递进来的旧版本排除掉dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.30/version /dependency我的经验是所有Spring相关的依赖尽量保持同一个版本号不要一会儿5.2一会儿5.3否则报错时非常难排查。建议在pom.xml里用properties定义一个spring.version统一管理。7.2 日期参数绑定失败的400错误开发订单查询功能时前端传了一个时间范围参数格式是2024-05-20 10:30:00后端Controller方法用Date参数接收结果请求直接返回400。SpringMVC默认的日期格式是yyyy/MM/dd传带横杠和时分秒的字符串它根本不认识。解决办法有两种。第一种是给实体字段加DateTimeFormat注解DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private Date startTime;第二种是写一个全局日期转换器在spring-mvc.xml里注册ConversionService。我推荐这类项目直接写全局转换器省得每个字段都去加注解。如果后端接收的是JSON请求体用RequestBody接收那上面的方案不生效需要在实体类的日期字段上加JsonFormat注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private Date createTime;这里还有个容易踩的坑数据库返回的日期是DATETIME格式JSON序列化时如果没加JsonFormat前端拿到的是时间戳数字还要在JS里再转一次。所以实体类上的日期注解建议都加上一次配置前后端都方便。7.3 分页插件PageHelper的串页问题项目里商品列表和订单列表都用了PageHelper做分页有一次发现一个页面查出来的数据量明显不对某条记录出现在了不该出现的页码里。这个问题的根源是PageHelper本身基于ThreadLocal实现分页参数放在当前线程的ThreadLocal里会在第一次查询时自动清掉。但如果查询顺序不对比如先调用PageHelper.startPage(1, 10)然后又执行了其他查询分页参数就会污染到后续的查询上。正确的写法是PageHelper.startPage(pageNum, pageSize); ListOrder list orderDao.selectPage(condition); PageInfoOrder pageInfo new PageInfo(list);startPage之后必须紧跟要分页的那一条查询中间不要穿插任何其他数据库操作。还有一点PageHelper的版本需要和MyBatis版本匹配不然可能出现AbstractMethodError。我用的是mybatis 3.5.13搭配pagehelper 5.3.3稳定没出问题。7.4 请求参数乱码与响应中文乱码乱码问题在SSM项目里几乎人人都会遇到。POST请求中文乱码通常是因为web.xml里的编码过滤器没配置或者forceEncoding没设成truefilter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncoding设为true很关键它表示连响应也统一用UTF-8避免页面出现中文问号。另外JSON响应乱码还有一个细节RequestMapping返回JSON时要确保produces设置了正确的字符集或者spring-mvc.xml里配置了消息转换器指定UTF-8。这个坑在Chrome浏览器下可能不明显但放IE和部分移动端浏览器就会暴露出来。个人建议做这种系统时先去web.xml把所有编码相关的配置一次配全后面才不会无缘无故出现各种乱码调试起来特别浪费时间。结语把业务想清楚再去碰代码做昭愿这个系统最大的感悟是写代码之前得先把自己当成甜品店的店长把每天的手工工作一项一项看明白想清楚系统要替人解决哪些麻烦。表结构不是凭空拍脑袋拍出来的是在梳理业务流程的过程中自然长出来的。订单要快照、库存要流水、状态变更要带条件、金额计算要后端把关这些设计不是烧钱上高并发而是任何一个真实业务系统都逃不掉的基本功。后面如果你要在这个项目上继续扩展还可以把会员储值支付、短信通知、门店扫码点餐、库存预警这些都加上。技术栈虽然是老的但这些业务逻辑换到任何语言和框架里都是通用的一次想透终身受用。
企业数字化 ERP 产品动态
相关推荐
Codex Security Deep Scan Discovery 阶段全解析:从提示词模板到源码级执行机制 Codex Security Deep Scan Discovery 阶段全解析:从提示词模板到源码级执行机制 【免费下载链接】codex-security OpenAIs Codex Security CLI and TypeScript SDK for finding, validating, and fixing security vulnerabilities. npm: https://www.npmjs.com/pack… · 2026/9/24 23:43:32
Agent智能体开发实战:从模型选型到部署上线的完整指南 我最早被这个项目吸引,是因为名字里带着"九添菜菜"这种个人色彩——说实话,市面上讲大模型和Agent的教程不少,但能真正把一个智能体从零跑到上线、并且把过程中的坑都记下来的,反而不多。这个项目就是这样一份实战记录&… · 2026/9/24 23:43:32
HTTP攻击分析实战:从协议原理到攻击链还原与防御落地 写HTTP攻击分析这个话题,我其实犹豫过一阵子。市面上的文章要么把各种攻击类型背一遍,要么直接甩几个工具了事,真到了“给你一段异常流量、一批访问日志,让你说出到底发生了什么攻击、攻击者想干什么、怎么拦下来”的时候… · 2026/9/24 23:43:26
深度学习新闻分类推荐系统:从TextCNN到个性化推荐 简介:这份基于深度学习的新闻分类推荐系统Python实现源码,是专为课程设计与期末大作业准备的高分项目,下载后无需修改即可运行,适用于需要快速交付完整课题的高校学生。系统涵盖新闻数据预处理、文本分类模型训练、推荐逻辑展示等… · 2026/9/24 23:59:53
汽车电子底层软件开发:AUTOSAR与CAN总线实战解析 1. 这门“汽车电子底层软件开发就业课”到底在教什么?——不是写个LED闪烁就能上岗的很多人看到“汽车电子底层软件开发就业课”这个标题,第一反应是:不就是嵌入式C语言单片机CAN通信?刷几道LeetCode、调通一个STM32 CAN收发例程&… · 2026/9/24 23:59:53
Vim基础操作全攻略:保存退出、模式切换与高频命令实战 1. 项目概述1.1 核心需求解析今天聊聊Vim。写这个题目的原因是:几乎每个后端开发者、运维人员、数据工程师某天都会遇到一个场景——深夜加班,服务器登录界面只有黑底白字,编辑器只有vi/vim,你必须在五分钟内完成一次配置修改并保… · 2026/9/24 23:59:53
Python+CNN车牌识别实战:从数据预处理到模型训练与部署 简介:基于Python与卷积神经网络的车牌识别项目,面向计算机视觉初学者及智能交通开发者,目标是帮助用户掌握从数据预处理、模型构建到实际部署的完整流程。压缩包共25个文件,包含jpg/png图像样本、py训练脚本、md说明文档、dat数据… · 2026/9/24 23:59:53
AI元人文:从工具使用到思维重构的深度探索 最近半年我一直在琢磨一件事:AI元人文到底是什么?说白了,就是“用元视角重新审视人与AI的关系”,也在“探索AI如何反向逼着我们发现自己的思考边界”。标题里的“元探索”,在我看就是一层套一层的追问——当你用AI解决… · 2026/9/24 23:59:53