1. 这个订餐系统到底解决什么问题
1.1 企业内部订餐的典型痛点
先说个场景。公司规模一上来,食堂也好、楼下合作餐厅也好,每天中午那几百号人的订餐需求,靠微信群接龙、Excel登记、前台人工统计,基本就是灾难现场。菜品变更通知不到、谁定了什么餐对不上账、月底结算的时候财务拿着厚厚一沓纸质单子想骂人。
SSM员工订餐系统这种东西,本质上就是把"员工选菜—提交订单—后台处理—统计结算"这条链路搬上线。它和外卖平台最大的区别是场景封闭——用户固定(企业内部员工)、菜品固定(食堂或合作商家提供)、结算方式固定(月结或餐补扣款),所以系统的核心不是花哨的营销功能,而是稳定、清晰、好维护。
这个小项目的信息量其实非常大。它虽然没有微服务、没有分布式缓存、没有消息队列这些大厂标配,但一个完整的SSM(Spring + SpringMVC + MyBatis)项目该有的东西全都有:MVC分层、ORM持久化、依赖注入、事务管理、JSP视图渲染、jQuery异步交互、MySQL表设计。换句话说,这是理解JavaWeb后端开发全流程的最佳训练场之一。
1.2 系统角色划分与核心流程
这套系统我建议拆成三类角色来设计:
| 角色 | 核心操作 | 对应页面 |
|---|---|---|
| 员工 | 浏览菜品、提交订单、查看个人订单记录 | 订餐首页、个人中心 |
| 食堂/后台管理员 | 菜品上下架、价格调整、查看订单、处理结算 | 菜品管理、订单管理 |
| 系统管理员 | 员工账号管理、部门维护、数据统计 | 用户管理、统计报表 |
核心流程一句话就能说清:员工登录 → 查看当天菜单 → 加购/直接下单 → 管理员收到订单 → 出餐/结算。但实现的时候,订单状态流转、菜品库存扣减、重复下单校验这些细节,才是真正花时间的地方。
我实测下来,这个项目最适合两类人:一是准备找Java开发实习/校招的在校生,用来把SSM八股文落到代码里;二是公司内部确实有这个需求,需要快速搭一个能用的内部工具的技术同学。两类人的诉求不同,但底层代码架构是同一套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么这套组合到了现在依然能打
2.1 SSM + JSP + jQuery的组合逻辑
很多人会问:现在都Spring Boot + Vue前后端分离了,学这套老掉牙的东西还有什么意义?我的观点是:技术会迭代,但底层逻辑不会变。Spring Boot再方便,它背后的核心依然是IoC容器、AOP、MVC分发、ORM映射这些SSM时代就定型的理念。你把SSM跑通了,Spring Boot的上手成本会直线下降,因为你已经理解了"它为什么这么设计"。
回到这个项目本身。SSM三个框架各自的职责是这样的:
- Spring:IoC容器管对象,AOP管事务和日志。在订餐系统里,
OrderService、UserService、MenuService这些业务对象都由Spring统一管理,事务边界由@Transactional或XML配置控制。 - SpringMVC:负责HTTP层的路由分发,请求进来之后,
DispatcherServlet根据URL映射找到对应的Controller方法,处理完返回视图名或JSON数据。 - MyBatis:把Java方法和SQL语句做个映射。订餐系统里的SQL虽然不复杂,但MyBatis让
ResultMap到Java对象、动态SQL拼接(比如按日期范围筛选订单)都变得很干净。
JSP + jQuery的组合在2024年看起来确实古典,但它的优势是开发效率极高。JSP里可以嵌Java代码直接取数据渲染,jQuery的$.ajax + $(selector)操作DOM,对于这种内部管理系统来说,不需要前端工程化那套构建流程,改完刷新就能看到效果,调试效率非常高。
2.2 为什么这个项目不需要Spring Boot和前后端分离
这个问题我经常被问,直接说结论:不是不能用,而是没必要。
前后端分离的前提是前端逻辑足够复杂——复杂到需要用组件状态管理、路由守卫、构建优化那一套。但内部订餐系统的主要页面形态是表单、表格、列表,交互是点击按钮、弹窗确认、刷新数据。你用Vue写一套,再用Node起一个mock服务,还得处理跨域,部署的时候dist文件还要找个Nginx做静态资源映射——这一套下来,工作量起码翻一倍,收益呢?几乎没有。
Spring Boot同理。它不是不能做这个项目,而是对SSM初学者来说,自动配置会掩盖太多细节。你在Spring Boot里加一个spring-boot-starter-web,Tomcat内嵌了、DispatcherServlet自动注册了、@ComponentScan自动扫描了——运行起来很爽,但你对"配置文件里url-pattern为什么这样写""BeanDefinition怎么被扫描的"完全没有感知。这些细节恰恰是面试官最爱深挖的。
所以我的建议很明确:
- 如果你是为了学习和面试,SSM + JSP + jQuery是更好的选择,因为每个环节都得手写,每一行配置都有它的理由。
- 如果你是为了快速出活,那直接用Spring Boot + Thymeleaf或者Vue3 + Element Plus都行,别在SSM上浪费时间。
3. 数据库设计:订单系统的表结构拆解
3.1 核心表结构与字段设计思路
这个项目的数据库设计是整个系统的地基。我的建表思路是这样的:
用户表(t_user)
code复制id BIGINT PRIMARY KEY AUTO_INCREMENT
username VARCHAR(50) UNIQUE NOT NULL
password VARCHAR(100) NOT NULL
real_name VARCHAR(50)
department VARCHAR(50)
role TINYINT DEFAULT 1 -- 1员工 2管理员
create_time DATETIME
菜品表(t_menu)
code复制id BIGINT PRIMARY KEY AUTO_INCREMENT
dish_name VARCHAR(100) NOT NULL
price DECIMAL(10,2) NOT NULL
description VARCHAR(500)
image_url VARCHAR(200)
status TINYINT DEFAULT 1 -- 1上架 0下架
category VARCHAR(50)
create_time DATETIME
订单主表(t_order)
code复制id BIGINT PRIMARY KEY AUTO_INCREMENT
order_no VARCHAR(32) UNIQUE NOT NULL
user_id BIGINT NOT NULL
total_amount DECIMAL(10,2) NOT NULL
status TINYINT DEFAULT 0 -- 0待处理 1已完成 2已取消
order_date DATE NOT NULL
remark VARCHAR(200)
create_time DATETIME
订单明细表(t_order_item)
code复制id BIGINT PRIMARY KEY AUTO_INCREMENT
order_id BIGINT NOT NULL
dish_id BIGINT NOT NULL
dish_name VARCHAR(100) -- 冗余菜品名称,防止菜品改名后历史订单出错
price DECIMAL(10,2) -- 冗余下单时价格,防止菜品价格调整影响历史订单
quantity INT NOT NULL DEFAULT 1
3.2 为什么订单明细要冗余字段
这条经验是踩坑踩出来的:订单明细表里一定要冗余dish_name和price。原因很简单,如果哪天管理员把菜品价格从15块调成18块,或者把"鱼香肉丝"改成了"鱼香肉丝(微辣)",历史订单的展示和结算就会出问题。联表查t_menu拿到的都是当前的数据,不是下单那一刻的数据。
我当时接手的一个旧系统就是没做冗余,月底财务对账的时候发现某个员工8月份的订单金额和菜品名称对不上,查了半天才发现是菜品后来被改过。从那以后,凡是涉及订单、交易、日志这类"历史记录"性质的业务,我一律做字段冗余。
另一个容易被忽略的点是order_date这个字段。我是故意把下单日期单独拎出来的,而不是直接用create_time。原因是业务上有"今天订明天的餐"这种场景——员工今天晚上下单,第二天中午食堂才出餐。如果你想查"某一天实际要出多少份餐",拿create_time查就会漏。order_date的设计让这个查询变成WHERE order_date = '2024-11-20',一条SQL就搞定。
3.3 订单号生成策略
订单号这块我推荐用yyyyMMddHHmmss + 随机数的方式,不要用数据库自增ID直接暴露给用户。原因有两个:一是自增ID容易被爬虫遍历,能通过订单号推测出平台的单量;二是多表联查的时候,订单号作为业务主键可读性更强,客服/管理员看到订单号能直接知道是哪天的单子。
生成逻辑不复杂,Java代码里拼一下就行:
java复制public String generateOrderNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String timeStr = sdf.format(new Date());
int randomNum = (int)((Math.random() * 9 + 1) * 1000); // 1000-9999
return timeStr + randomNum;
}
4. 从零搭建SSM项目骨架
4.1 Maven工程结构与依赖管理
SSM项目的标准Maven结构是这样的:
code复制ssm-order-system/
├── pom.xml
├── src
│ └── main
│ ├── java
│ │ └── com/example/order
│ │ ├── controller
│ │ ├── service
│ │ │ ├── impl
│ │ │ └── OrderService.java
│ │ ├── dao
│ │ ├── entity
│ │ ├── interceptor
│ │ ├── common
│ │ └── config
│ ├── resources
│ │ ├── jdbc.properties
│ │ ├── spring-context.xml
│ │ ├── spring-mvc.xml
│ │ └── mapper
│ └── webapp
│ ├── WEB-INF
│ │ ├── web.xml
│ │ └── views
│ └── static
pom.xml里的核心依赖,版本搭配很重要。我在实际项目中经过反复验证的稳定组合是:
xml复制<properties>
<spring.version>5.3.39</spring.version>
<mybatis.version>3.5.16</mybatis.version>
</properties>
<!-- Spring核心 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>2.1.2</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<!-- 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.23</version>
</dependency>
<!-- JSP/Servlet -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
特别提醒一个坑:Spring 6.x和javax.servlet已经分家了。Spring 6.0开始支持jakarta.servlet命名空间,和传统的JSP部署环境不兼容。如果你用的是Spring 6.x,就别用javax.servlet,否则容器启动的时候会报NoClassDefFoundError: javax/servlet/Filter之类的错。我上面的版本搭配是基于javax的经典组合,部署在Tomcat 9以及更早版本上没问题。
4.2 Spring与SpringMVC的配置文件分工
SSM项目里我习惯把配置拆成两份:spring-context.xml管理Service、DAO和事务,spring-mvc.xml管理Controller和视图解析器。分开配置的好处是职责清晰,避免Spring容器和SpringMVC容器互相扫描覆盖。
spring-context.xml的核心配置:
xml复制<context:component-scan base-package="com.example.order">
<context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
</context:component-scan>
<context:property-placeholder location="classpath:jdbc.properties"/>
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="${jdbc.driver}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="mapperLocations" value="classpath:mapper/*.xml"/>
<property name="typeAliasesPackage" value="com.example.order.entity"/>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="com.example.order.dao"/>
</bean>
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
spring-mvc.xml的核心配置:
xml复制<context:component-scan base-package="com.example.order.controller"/>
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/"/>
<property name="suffix" value=".jsp"/>
</bean>
<mvc:annotation-driven/>
<mvc:resources mapping="/static/**" location="/static/"/>
<!-- 文件上传 -->
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="maxUploadSize" value="5242880"/>
<property name="defaultEncoding" value="UTF-8"/>
</bean>
然后web.xml里把这两个容器串起来,核心是ContextLoaderListener加载根容器、DispatcherServlet加载SpringMVC容器、CharacterEncodingFilter统一编码:
xml复制<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>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
配置里有个细节:DispatcherServlet的url-pattern我配的是/,不是*.do——这样REST风格的URL更干净,不需要在Controller里为每个方法单独加后缀。
5. 核心业务代码实现:从下单到结算
5.1 用户下单的完整链路
先看Controller层。下单接口的设计要注意事务边界——订单主表和明细表的插入必须在一个事务里,否则会出现"主表有数据、明细表没有"的情况。事务的粒度尽量小,只把真正要做写操作的代码包进去:
java复制@Controller
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
@RequestMapping(value = "/submit", method = RequestMethod.POST)
@ResponseBody
public Result submit(@RequestBody OrderSubmitVO vo, HttpSession session) {
User loginUser = (User) session.getAttribute("loginUser");
if (loginUser == null) {
return Result.error("请先登录");
}
try {
String orderNo = orderService.createOrder(loginUser.getId(), vo.getItems(), vo.getOrderDate());
return Result.success("下单成功", orderNo);
} catch (BusinessException e) {
return Result.error(e.getMessage());
}
}
}
Service层是业务逻辑的核心,下单的整个流程我拆成五个步骤:
java复制@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Autowired
private MenuMapper menuMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public String createOrder(Long userId, List<OrderItemVO> items, String orderDate) {
// 1. 参数校验
if (items == null || items.isEmpty()) {
throw new BusinessException("请选择菜品");
}
// 2. 检查当天是否已经下过单
int count = orderMapper.countByUserAndDate(userId, orderDate);
if (count > 0) {
throw new BusinessException("您当天已经下过单了");
}
// 3. 计算总价(价格以数据库为准,不信任前端传值)
BigDecimal totalAmount = BigDecimal.ZERO;
for (OrderItemVO item : items) {
Menu menu = menuMapper.selectByPrimaryKey(item.getDishId());
if (menu == null || menu.getStatus() != 1) {
throw new BusinessException("菜品不存在或已下架");
}
totalAmount = totalAmount.add(menu.getPrice().multiply(new BigDecimal(item.getQuantity())));
}
// 4. 生成订单号并插入主表
String orderNo = generateOrderNo();
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalAmount(totalAmount);
order.setOrderDate(orderDate);
order.setStatus(0);
orderMapper.insertSelective(order);
// 5. 插入明细表
for (OrderItemVO item : items) {
Menu menu = menuMapper.selectByPrimaryKey(item.getDishId());
OrderItem orderItem = new OrderItem();
orderItem.setOrderId(order.getId());
orderItem.setDishId(menu.getId());
orderItem.setDishName(menu.getDishName());
orderItem.setPrice(menu.getPrice());
orderItem.setQuantity(item.getQuantity());
orderItemMapper.insertSelective(orderItem);
}
return orderNo;
}
}
几个设计要点值得说一下。
第一,价格必须后端算。前端传上来的totalAmount完全不可信,等于把自己家门钥匙交给陌生人。我在很多项目里见过直接的写法:前端把总价传过来,后端直接用——这种系统上线不到一个月就会被薅羊毛。
第二,下单前要检查重复下单。员工一天可能就点一次餐,这个校验就是一把锁。虽然在高并发场景下会存在竞态条件,但对于内部系统来说,单条SQL的count查询加用户行为约束(页面置灰)已经足够。
第三,@Transactional(rollbackFor = Exception.class)这个写法要特别注意。默认情况下Spring的事务只对RuntimeException回滚,如果业务方法里抛的是IOException这类受检异常,事务不会回滚,数据就脏了。所以建议显式指定rollbackFor = Exception.class,让所有异常都触发回滚。
5.2 jQuery + JSP的前端交互设计
JSP页面这边,我的做法是分成两类:一类是服务端渲染的列表页,直接用JSTL + EL标签在JSP里把数据渲染好;另一类是操作类的弹窗和局部刷新,用jQuery的$.ajax异步请求,不整页刷新。
员工订餐首页的核心交互是:左侧展示菜品分类,右侧展示菜品卡片,点击"加入订餐"后右侧购物车区域实时更新,最后点"提交订单"。
菜品列表用JSTL渲染:
jsp复制<c:forEach items="${menuList}" var="menu">
<div class="dish-card" data-id="${menu.id}" data-name="${menu.dishName}" data-price="${menu.price}">
<div class="dish-img">
<img src="${menu.imageUrl}" alt="${menu.dishName}"/>
</div>
<div class="dish-info">
<h4>${menu.dishName}</h4>
<span class="price">¥${menu.price}</span>
<button class="btn-add" onclick="addToCart(${menu.id})">加入订餐</button>
</div>
</div>
</c:forEach>
购物车这部分用jQuery操作一个JavaScript数组来维护,不额外请求后端。我当初用jQuery来实现购物车逻辑是因为当时前端工程化还不流行,原生JavaScript写起来比较繁琐。你可以用原生JavaScript的Map来替代:
javascript复制var cart = [];
function addToCart(dishId) {
// 从页面上获取菜品信息(data属性存了id/name/price)
var $card = $('.dish-card[data-id="' + dishId + '"]');
var dishName = $card.data('name');
var price = parseFloat($card.data('price'));
// 判断是否已经在购物车里
var found = cart.find(function(item) {
return item.dishId === dishId;
});
if (found) {
found.quantity += 1;
} else {
cart.push({
dishId: dishId,
dishName: dishName,
price: price,
quantity: 1
});
}
renderCart();
}
function renderCart() {
var $tbody = $('#cartTable tbody');
$tbody.empty();
var total = 0;
$.each(cart, function(index, item) {
var subtotal = item.price * item.quantity;
total += subtotal;
var $tr = $('<tr>')
.append($('<td>').text(item.dishName))
.append($('<td>').text(item.price.toFixed(2)))
.append($('<td>').html(
'<button onclick="changeQty(' + index + ', -1)">-</button> ' +
item.quantity +
' <button onclick="changeQty(' + index + ', 1)">+</button>'
))
.append($('<td>').text(subtotal.toFixed(2)))
.append($('<td>').html('<button onclick="removeFromCart(' + index + ')">删除</button>'));
$tbody.append($tr);
});
$('#totalAmount').text(total.toFixed(2));
}
function submitOrder() {
if (cart.length === 0) {
alert('请先选择菜品');
return;
}
var payload = {
items: cart.map(function(item) {
return {
dishId: item.dishId,
quantity: item.quantity
};
}),
orderDate: $('#orderDate').val()
};
$.ajax({
url: '/order/submit',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
dataType: 'json',
success: function(res) {
if (res.code === 200) {
alert('下单成功,订单号:' + res.data);
cart = [];
renderCart();
} else {
alert(res.msg);
}
},
error: function() {
alert('网络异常,请稍后重试');
}
});
}
后端Controller接收JSON,用@RequestBody注解直接绑定到VO对象。这要求Jackson依赖在classpath里,SpringMVC会自动配置消息转换器。
5.3 管理员端的菜品管理和订单处理
管理员端主要就两个功能:维护菜品、处理订单。
菜品管理就是一个典型的CRUD,但要注意图片上传的处理。菜品图片我建议存到服务器的/static/upload/目录下,数据库里只存相对路径。上传的代码:
java复制@RequestMapping(value = "/upload", method = RequestMethod.POST)
@ResponseBody
public Result upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) {
try {
// 获取项目部署的绝对路径,然后拼上upload目录
String realPath = request.getServletContext().getRealPath("/static/upload/");
File dir = new File(realPath);
if (!dir.exists()) {
dir.mkdirs();
}
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 用时间戳+随机数重命名,防止文件名冲突和路径穿越
String newFileName = System.currentTimeMillis() + "_" + new Random().nextInt(1000) + suffix;
file.transferTo(new File(dir, newFileName));
String url = "/static/upload/" + newFileName;
return Result.success("上传成功", url);
} catch (IOException e) {
e.printStackTrace();
return Result.error("文件上传失败");
}
}
订单处理这头比较有意思的是状态流转。我的设计是三种状态:0待处理 → 1已完成,或者0待处理 → 2已取消。管理员看到订单后确认出餐,就把状态改成已完成;如果员工想取消,权限上可以让员工取消待处理的订单,但已完成之后就不能撤销了。状态机不复杂,但一定要在Service层做校验,不能直接让前端改状态。
java复制@Transactional(rollbackFor = Exception.class)
public void updateOrderStatus(Long orderId, Integer targetStatus, User operator) {
Order order = orderMapper.selectByPrimaryKey(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
// 待处理 -> 已完成 只有管理员能操作
if (order.getStatus() == 0 && targetStatus == 1 && operator.getRole() != 2) {
throw new BusinessException("无权限完成该订单");
}
// 待处理 -> 已取消 员工和管理员都可以
if (order.getStatus() != 0) {
throw new BusinessException("当前状态不允许取消");
}
order.setStatus(targetStatus);
orderMapper.updateByPrimaryKeySelective(order);
}
5.4 统计报表:一个SQL的问题
月底结算和餐补统计是管理员最头疼的工作。我的做法是把统计功能直接放到后台首页,展示当日订单数、当日营业额、本周各菜品销量排行。
核心SQL其实不复杂,关键是用好GROUP BY和聚合函数:
xml复制<select id="countByDate" resultType="map">
SELECT order_date, COUNT(*) AS order_count, IFNULL(SUM(total_amount), 0) AS total_amount
FROM t_order
WHERE order_date BETWEEN #{startDate} AND #{endDate}
GROUP BY order_date
ORDER BY order_date
</select>
<select id="topDishes" resultType="map">
SELECT oi.dish_name, SUM(oi.quantity) AS total_quantity
FROM t_order_item oi
INNER JOIN t_order o ON oi.order_id = o.id
WHERE o.status = 1
AND o.order_date BETWEEN #{startDate} AND #{endDate}
GROUP BY oi.dish_name
ORDER BY total_quantity DESC
LIMIT 10
</select>
IFNULL(SUM(total_amount), 0)这个写法一定要记得。如果某天没有订单,SUM的结果是NULL,Java端拿个null还得做NPE防护,不如直接在SQL层面兜底。
6. 部署上线前必须处理的几个坑
6.1 中文乱码,从Tomcat到数据库全链路排查
中文乱码是JSP项目里出现频率最高的问题之一。这个坑特别隐蔽,因为它可能出现在任何一个环节,而且各个环境变量的编码设置不统一,查起来非常头疼。
我的排查顺序是从请求到响应的全链路:
第一层,JSP页面本身。顶部必须加上:
jsp复制<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
第二层,请求过滤器。web.xml里的CharacterEncodingFilter要配置,并且要确保它的url-pattern是/*,过滤所有请求。
第三层,Tomcat的server.xml。在Connector配置里加URIEncoding="UTF-8",否则GET请求携带的中文参数会乱码:
xml复制<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
URIEncoding="UTF-8"/>
第四层,数据库连接。jdbc.properties里的URL必须指定编码:
code复制jdbc.url=jdbc:mysql://localhost:3306/order_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
serverTimezone=Asia/Shanghai这个是MySQL 8.x必须加的,否则会报时区错误。MySQL驱动版本和8.0数据库版本的对应关系也要注意,5.x驱动连8.x数据库会有协议不匹配的问题。
6.2 MyBatis映射文件使用中的常见问题
MyBatis的XML映射文件有几个经典的坑,新人必踩。
第一个是resultType和resultMap混淆。如果你查询返回的是一个"字段名和属性名不完全一致"的对象,比如Java属性叫orderNo,数据库列叫order_no,你要么在SQL里加别名,要么用resultMap显式映射。我推荐一个更省事的方案:在spring-context.xml里开启MyBatis的驼峰映射:
xml复制<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<!-- 省略其他配置 -->
<property name="configuration">
<bean class="org.apache.ibatis.session.Configuration">
<property name="mapUnderscoreToCamelCase" value="true"/>
</bean>
</property>
</bean>
开启之后,order_no会自动映射到orderNo,省掉一堆resultMap配置。
第二个是#{}和${}的区别。这是面试八股文里的高频题,但在实际项目中真的会犯。#{}是预编译参数占位符,MyBatis会生成?占位符并设置参数,能有效防止SQL注入;${}是字符串拼接,直接把值拼进SQL里,只有排序列名、表名这种SQL结构部分才用。比如排序功能:
java复制// 注意:这里排序列名是白名单校验过的,不能直接拼用户输入
String sortField = "create_time"; // 通过逻辑判断映射,而不是直接接收入参
第三个典型坑是<和>符号在XML里会被解析成标签,小于等于这种条件必须用转义字符。比如查"一个月以内的订单":
xml复制<select id="selectRecentOrders" resultType="Order">
SELECT * FROM t_order
WHERE create_time >= #{startTime}
</select>
或者用CDATA包裹起来:
xml复制<select id="selectRecentOrders" resultType="Order">
SELECT * FROM t_order
WHERE create_time <![CDATA[ >= ]]> #{startTime}
</select>
6.3 「Tomcat部署路径」与「静态资源访问」的坑位
很多人会把图片上传路径写成项目src目录下的某个自定义文件夹,比如src/main/webapp/upload。这在开发环境上没问题,但一旦打成WAR包部署到服务器,问题就来了:如果你把项目重新部署(删除旧的WAR包重新发布),所有上传的图片文件都会丢失,因为它们在Tomcat的webapps目录下,不在应用的源码包里。
正确做法是把上传目录配置在外部路径,比如Linux服务器上的/data/order-system/upload,然后通过Tomcat的虚拟路径映射让外部目录能被访问。SpringMVC的mvc:resources映射可以这样做:
xml复制<mvc:resources mapping="/upload/**" location="file:/data/order-system/upload/"/>
这样图片就存在项目工程之外了,重启、重新部署都不会丢。打包WAR之前,用mvn clean package确认下没有把本地的绝对路径带进配置文件就行。
6.4 JVM内存溢出问题
员工订餐系统虽然部署规模不大,但在自己电脑上跑开发环境时,没遇到过几次java.lang.OutOfMemoryError都不好意思说做过JavaWeb项目。Tomcat启动时内存不够,通常报的是insufficient memory,解决方案是改Tomcat的catalina.sh:
bash复制JAVA_OPTS="-Xms512m -Xmx1024m -XX:PermSize=128m -XX:MaxPermSize=256m"
注意:Java 8之后没有PermSize了,换成了MetaspaceSize。如果你用的是JDK 8+,应该写:
bash复制JAVA_OPTS="-Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m"
-Xms和-Xmx设置成一样的话,可以避免JVM运行时动态调整堆大小带来的性能波动。
不过我得提醒一句:如果项目里用了本地开发时的内存缓存、大文件上传、数据导出这种功能,内存问题还要结合具体代码排查,单纯调大JVM参数治标不治本。我之前见过一个导报表OOM的案例,最后发现是代码里一次性把所有数据都加载到了List里,改成分批查询才解决。
7. 前端页面交互的补充:jQuery的扩展思路
7.1 用jQuery实现弹窗和表单验证
JSP项目里的弹窗我强烈建议用自己封装的简单弹窗,或者引入Layer这种轻量级组件。不用BootStrap Modal的原因是它依赖Bootstrap的整套CSS/JS,而后台管理系统往往不需要那么重的框架。
自己封装一个dialog函数,核心代码其实很少:
javascript复制function showDialog(title, content, options) {
var $mask = $('<div class="dialog-mask"></div>');
var $dialog = $('<div class="dialog-container"></div>');
$dialog.append('<div class="dialog-title">' + title + '</div>');
$dialog.append('<div class="dialog-content">' + content + '</div>');
if (options && options.confirmText) {
var $confirm = $('<button class="btn-confirm">' + options.confirmText + '</button>');
$confirm.on('click', function() {
if (options.onConfirm) {
options.onConfirm();
}
});
$dialog.append($confirm);
}
$dialog.append('<button class="btn-cancel">取消</button>');
$dialog.find('.btn-cancel').on('click', function() {
$mask.remove();
});
$mask.html($dialog);
$('body').append($mask);
}
表单验证方面,jQuery Validate是个不错的插件。如果你不想引入额外库,也可以在提交前手动判断:
javascript复制function validateForm() {
var username = $('#username').val().trim();
var password = $('#password').val().trim();
if (username === '') {
showDialog('提示', '用户名不能为空');
return false;
}
if (password.length < 6) {
showDialog('提示', '密码长度不能少于6位');
return false;
}
return true;
}
7.2 页面加载完成后的数据渲染模式
JSP项目里最常见的痛点是"页面加载时需要先请求数据再渲染"。我的方案是:优先用JSTL在服务端渲染数据。因为内部系统对首屏加载速度不敏感,服务端渲染可以少一次Ajax请求,而且对SEO没有任何要求。
如果页面里需要动态更新局部内容,再用jQuery的Ajax请求JSON数据,然后在success回调里面用DOM操作填充。比如管理员端的订单列表刷新:
javascript复制function refreshOrderTable() {
var status = $('#statusFilter').val();
$.ajax({
url: '/admin/order/list',
type: 'GET',
data: { status: status },
dataType: 'json',
success: function(res) {
if (res.code === 200) {
var $tbody = $('#orderTable tbody');
$tbody.empty();
if (res.data.length === 0) {
$tbody.append('<tr><td colspan="6" style="text-align:center;">暂无数据</td></tr>');
return;
}
$.each(res.data, function(index, order) {
var statusText = '待处理';
var statusClass = 'status-waiting';
if (order.status === 1) {
statusText = '已完成';
statusClass = 'status-done';
} else if (order.status === 2) {
statusText = '已取消';
statusClass = 'status-canceled';
}
var $tr = $('<tr>')
.append($('<td>').text(order.orderNo))
.append($('<td>').text(order.realName))
.append($('<td>').text(order.totalAmount.toFixed(2)))
.append($('<td>').text(order.orderDate))
.append($('<td>').html('<span class="' + statusClass + '">' + statusText + '</span>'));
$tr.appendTo($tbody);
});
}
}
});
}
这种渲染方式在上面的jQuery场景下,比纯模板字符串拼接更直观,也方便后续扩展按钮事件。
8. 从SSM员工订餐系统延伸出去的思考
8.1 这个项目的面试价值
如果你是在校生,想把SSM项目写到简历上,我建议面试前把下面这几个问题彻底搞明白,否则很容易被追问卡住:
- Spring容器和SpringMVC容器是什么关系?为什么
Controller要排除在spring-context.xml的扫描范围之外? - MyBatis的
MapperScannerConfigurer工作原理是什么?它是怎么把Mapper接口变成代理对象的? #{}和${}的区别是什么?为什么${}有SQL注入风险?- Spring事务的传播行为有哪些?
REQUIRED和REQUIRES_NEW的区别是什么? - 如果把项目改成Spring Boot + Vue3前后端分离,你的架构要怎么调整?
前四个问题在这个项目里都有明确的落地点,第五个问题能显示出你不仅有"会用"的能力,还有"架构迁移"的思维。可以结合vue3连接ssm框架的搜索需求来思考,大致改造方向是:把Controller返回值统一改成JSON、前端改用Vue3管理状态、跨域问题交给后端CORS配置,部署时前后端分离用Nginx做代理转发。
8.2 后续能加的功能点
这个系统的扩展空间很大,如果你想做一个更完善的版本,可以考虑:
权限控制升级:现在只是简单的role字段判断,可以接入Spring Security做细粒度权限控制,比如"菜品管理员只能管菜品,不能看订单统计"。
Redis缓存:菜品列表这种读多写少的数据,可以放到Redis里缓存,减轻MySQL压力。订单提交时可以对order_date加一个分布式锁,防止重复下单的竞态条件。
消息通知:员工下单成功后,给管理员推送一个通知。轻量级方案是WebSocket + STOMP,重一点就直接接钉钉/企业微信机器人。
Excel导出:月度对账时把订单数据导出成Excel。Apache POI或者EasyExcel都能实现,建议直接用EasyExcel,阿里封装好了API,写起来快很多。
从阅读同类的搜索词来看,很多人在找jquery实现"二维码拖拽"这类偏前端的交互场景,而SSM员工订餐系统侧重点更多在业务逻辑和闭环流程上。两者并不冲突——前端交互是给系统加体验分的,而后端稳定性才是系统能真正跑起来的前提。
我个人在实际操作中的体会是,做这类管理系统,一开始不用追求技术的新,而是要追求整个链路的完整和闭环。你亲手把一个员工从登录、看菜单、下单、到管理员出餐、月底统计的完整流程跑通,对JavaWeb开发的理解会上一个台阶。这个项目不算难,但五脏俱全,适合动手能力强的开发者拿来练手,也适合内部团队直接二开使用。
最后分享一个小技巧:开发环境中一定把日志级别调到DEBUG,尤其要盯MyBatis的SQL输出。你看着订单插入和明细插入的日志一条条打印出来,事务提交是否正常一目了然。SSH框架排错很多时候就是看日志,日志清楚,问题就成功解决了一半。
