毕业设计或课程设计做到"管理系统"这类题目时,基于SSM的甜品店销售管理系统是一个很常见的选题。SSM(Spring + 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或注解里写SQL,MyBatis负责结果集到Java对象的映射。
对找实习和面试来说,现在很多存量系统的维护需求依然是SSM,能看明白SSM的配置结构,意味着你不会被某个框架版本绑死,换到Spring Boot或者其他MVC框架时也能快速迁移。
2. 数据库设计:十四张表怎么拆才不乱
2.1 商品、分类与规格:一对多的层级关系
甜品店的商品和一般电商商品有个明显的区别:同一个商品会有多个规格,不同规格的价格和库存都可能不一样。比如"芝士葡萄"这个商品,可以分成"冰·大杯""冰·中杯""热·中杯"三种规格,每种规格分别对应价格和库存。如果在商品表里一把梭把所有规格都塞进去,后面改价格、扣库存都很难维护。
所以商品这一块我拆了三张表:
tb_category分类表:
- id:BIGINT,自增主键
- name:VARCHAR(50),分类名称
- parent_id:BIGINT,默认0,支持二级分类
- sort_order:INT,排序权重
- create_time:DATETIME,创建时间
tb_product商品表:
- id:BIGINT,自增主键
- category_id:BIGINT,关联分类ID
- name:VARCHAR(100),商品名称
- main_image:VARCHAR(255),商品主图路径
- price:DECIMAL(10,2),默认售价
- member_price:DECIMAL(10,2),会员价,可空
- status:TINYINT,1上架 0下架
- description:TEXT,商品描述
- create_time、update_time:DATETIME
tb_product_spec规格表:
- id:BIGINT,自增主键
- product_id:BIGINT,关联商品ID
- spec_name:VARCHAR(50),规格名,如"冰·大杯·半糖"
- price:DECIMAL(10,2),规格价格
- stock:INT,当前库存
- version:INT,乐观锁版本号,后面扣库存会用到
这样设计之后,一对多的关系非常清楚:一个分类下有多个商品,一个商品下有多个规格。前台点单时展示的是规格级的价格和库存,后台维护时则先选中商品再维护具体规格。
2.2 订单主表与明细表:父子结构里的数据快照
订单设计是整个系统的核心。我只用一张订单表的话,会出现一个订单包含多个商品时字段无处安放的问题。所以必须拆成主表和明细表两张:
tb_order订单主表:
- id:BIGINT,自增主键
- order_no:VARCHAR(32),订单编号,全局唯一
- member_id:BIGINT,下单会员ID,可为空(表示散客)
- total_amount:DECIMAL(10,2),商品原价总额
- discount_amount:DECIMAL(10,2),优惠金额
- actual_amount:DECIMAL(10,2),实付金额
- pay_type:TINYINT,0现金 1微信 2支付宝 3会员卡
- status:TINYINT,0待支付 1制作中 2待取餐 3已完成 4已取消 5已退款
- remark:VARCHAR(255),备注
- create_time、pay_time、finish_time:DATETIME
tb_order_item订单明细表:
- id:BIGINT,自增主键
- order_id:BIGINT,关联订单主表ID
- product_id:BIGINT,商品ID
- spec_id:BIGINT,规格ID
- product_name:VARCHAR(100),商品名称快照
- spec_name:VARCHAR(50),规格名称快照
- price:DECIMAL(10,2),下单时单价快照
- quantity:INT,购买数量
- subtotal:DECIMAL(10,2),小计金额
为什么要强调"快照"?因为商品名称、价格这些信息是会变的。如果订单明细直接去关联商品表,过一段时间商品改价或者改了名字,历史订单打印出来就对不上了,财务对账会出现"系统里的订单和当时实际卖的价格不一致"的投诉。把名称和价格冗余到明细表里,历史订单才能保持当时的真实状态。
2.3 会员、等级与积分:三张表联动
会员体系做得好不好,直接影响甜品店的复购率。这个系统里会员相关我设计了四张表:
tb_member会员表:
- id:BIGINT,自增主键
- phone:VARCHAR(20),手机号,登录账号
- password:VARCHAR(64),登录密码
- name:VARCHAR(50),会员昵称
- level_id:BIGINT,会员等级ID
- points:INT,当前积分
- balance:DECIMAL(10,2),储值余额
- status:TINYINT,1正常 0冻结
- create_time:DATETIME
tb_member_level等级表:
- id:BIGINT,自增主键
- level_name:VARCHAR(20),等级名称
- threshold:INT,升级所需积分
- discount_rate:DECIMAL(3,2),折扣率,如0.90表示九折
tb_points_record积分流水表:
- id:BIGINT,自增主键
- member_id:BIGINT,会员ID
- change_points:INT,变动积分,正负表示增加或扣减
- source_type:TINYINT,1订单消费 2积分兑换 3后台调整
- source_id:BIGINT,来源单号ID
- remark:VARCHAR(255),备注
- create_time:DATETIME
等级和积分分开,意味着等级不需要每次算积分都去遍历流水表。会员每次消费后,先加分,再判断当前积分是否超过下一等级的门槛,超过就自动更新level_id。这个判断逻辑写在Service层,一个方法搞定,后面讲订单模块时会提到。
2.4 库存流水表:从"扣数字"到"记流水账"
很多第一次做进销存的人只会在规格表里更新stock字段,这其实是远远不够的。库存数只是一个"结果",真正的业务排查需要的是"过程"。比如店里月底盘点发现某个规格少了5份,没有流水表的话你根本不知道这5份是卖掉的、报损了还是入库时数错。
所以我在项目里加了一张tb_stock_flow库存流水表:
- id:BIGINT,自增主键
- product_id:BIGINT,商品ID
- spec_id:BIGINT,规格ID
- change_type:TINYINT,1采购入库 2销售出库 3盘点调整
- change_qty:INT,变动数量,入库为正、出库为负
- before_qty:INT,变动前库存
- after_qty:INT,变动后库存
- relation_no:VARCHAR(32),关联单号,比如订单号或入库单号
- operator_id:BIGINT,操作人员ID
- create_time:DATETIME
这样做的好处是,每一天的库存变动都有一条可追溯的记录。前台每下一单,在扣减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-package="com.zhaoyuan.service, com.zhaoyuan.dao"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <!-- 配置数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/zhaoyuan?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <!-- 配置事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>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-package="com.zhaoyuan.controller"/> <!-- 静态资源放行 --> <mvc:resources mapping="/static/**" location="/static/"/> <!-- 视图解析器,JSP放WEB-INF下防止直接访问 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" 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 name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> </configuration>mapUnderscoreToCamelCase这个配置很关键,它能让create_time自动映射到createTime属性,省去大量resultMap手写映射的工作量。
第二部分是Mapper接口和Mapper XML。接口里方法名要跟XML里statement的id一致,namespace要写接口的全限定名:
public interface ProductDao { Product findById(Long id); }<mapper namespace="com.zhaoyuan.dao.ProductDao"> <select id="findById" parameterType="long" resultType="com.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.x,url里加serverTimezone=Asia/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, List<Long> cartIds, Long couponRecordId) { // 1. 查询购物车中选中的商品项 List<Cart> carts = cartDao.selectByIds(cartIds); if (carts == null || carts.isEmpty()) { throw new BusinessException("购物车为空"); } // 2. 计算总价,同时检查商品是否下架、规格库存是否足够 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> 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先扣变成4,B再扣也基于刚才查到的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、status(0未使用 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、role(1店长 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;charset=utf-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> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.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 = "GMT+8") private Date createTime;这里还有个容易踩的坑:数据库返回的日期是DATETIME格式,JSON序列化时如果没加@JsonFormat,前端拿到的是时间戳数字,还要在JS里再转一次。所以实体类上的日期注解建议都加上,一次配置,前后端都方便。
7.3 分页插件PageHelper的"串页"问题
项目里商品列表和订单列表都用了PageHelper做分页,有一次发现一个页面查出来的数据量明显不对,某条记录出现在了不该出现的页码里。这个问题的根源是PageHelper本身基于ThreadLocal实现,分页参数放在当前线程的ThreadLocal里,会在第一次查询时自动清掉。但如果查询顺序不对,比如先调用PageHelper.startPage(1, 10),然后又执行了其他查询,分页参数就会污染到后续的查询上。
正确的写法是:
PageHelper.startPage(pageNum, pageSize); List<Order> list = orderDao.selectPage(condition); PageInfo<Order> pageInfo = new PageInfo<>(list);startPage之后必须紧跟要分页的那一条查询,中间不要穿插任何其他数据库操作。还有一点,PageHelper的版本需要和MyBatis版本匹配,不然可能出现AbstractMethodError。我用的是mybatis 3.5.13搭配pagehelper 5.3.3,稳定没出问题。
7.4 请求参数乱码与响应中文乱码
乱码问题在SSM项目里几乎人人都会遇到。POST请求中文乱码,通常是因为web.xml里的编码过滤器没配置或者forceEncoding没设成true:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>forceEncoding设为true很关键,它表示连响应也统一用UTF-8,避免页面出现中文问号。另外,JSON响应乱码还有一个细节:@RequestMapping返回JSON时,要确保produces设置了正确的字符集,或者spring-mvc.xml里配置了消息转换器指定UTF-8。这个坑在Chrome浏览器下可能不明显,但放IE和部分移动端浏览器就会暴露出来。
个人建议,做这种系统时先去web.xml把所有编码相关的配置一次配全,后面才不会无缘无故出现各种乱码,调试起来特别浪费时间。
结语:把业务想清楚再去碰代码
做"昭愿"这个系统最大的感悟是:写代码之前,得先把自己当成甜品店的店长,把每天的手工工作一项一项看明白,想清楚系统要替人解决哪些麻烦。表结构不是凭空拍脑袋拍出来的,是在梳理业务流程的过程中自然长出来的。订单要快照、库存要流水、状态变更要带条件、金额计算要后端把关,这些设计不是烧钱上高并发,而是任何一个真实业务系统都逃不掉的基本功。
后面如果你要在这个项目上继续扩展,还可以把会员储值支付、短信通知、门店扫码点餐、库存预警这些都加上。技术栈虽然是老的,但这些业务逻辑换到任何语言和框架里都是通用的,一次想透,终身受用。