
最近用 SSM 完整写了一个家用二手电器回收系统从需求梳理、数据库设计到前后端联调、打包部署前后花了大概两周的业余时间。项目本身不算大但涉及到用户下单、回收员接单、管理员审核、估价、上门回收、订单流转这些完整业务闭环踩了不少坑也把 SSM 这套老技术栈的底层逻辑重新捋了一遍。这篇博客就把整个实现过程和关键代码细节分享出来给正在做 Java 课程设计、毕业设计或者想拿一个 SSM 项目练手的同学做个参考。先说下这个系统解决的核心问题家里淘汰的旧电视、老冰箱、洗衣机、空调扔了污染环境卖废品又不划算而正规回收渠道的信息又很分散。这个系统就是把“用户提交闲置电器信息——平台估价——回收员上门——回收完成结算”这条线做成线上流程用户能随时查进度管理员能统一管理订单和回收员。项目用到的核心技术就是 Java 后端开发里最经典的 SSM 组合Spring、Spring MVC、MyBatis前端用 JSP Bootstrap AJAX数据库 MySQL。1. 项目定位与业务流程拆解1.1 这个系统到底解决什么问题做系统之前得先想清楚一件事你做的不是一个 CRUD 堆积的练习题而是一个有真实业务规则的闭环系统。家用二手电器回收的真实痛点有三个。第一个是信息不对称。用户不知道旧电器值多少钱回收方不知道哪里有货源。系统用“预估价 专业评估”的方式来缓解这个问题用户提交时先根据电器类型、品牌、年限、成色等选项给出一个参考估价回收员上门后再给最终报价用户确认后才完成回收。第二个是流程跟踪难。传统的线下回收电话打完就不知道后续了。系统把回收过程拆成明确节点待审核 - 待上门 - 已回收 - 已完成每个节点都有时间记录、操作人记录用户登录后能直接看到当前状态。第三个是订单管理混乱。回收方需要知道哪些订单需要安排人、哪些已经完成、哪些用户取消了还需要对每天的回收数量、回收金额做统计。所以系统里单独做了管理后台用表格 图表展示订单数据和回收金额。这三条业务线全部打通之后系统才真正“活”了起来而不是几个页面的跳转。1.2 角色权限与流程设计系统一共分了三种角色普通用户、回收员、管理员。普通用户是前端主要使用者能注册登录、发布待回收电器、查看估价结果、预约上门时间、查看订单状态、取消订单。回收员看到的是分配给自己的订单列表能接单、更新上门状态、填写实际回收价格、完成回收。管理员拥有最高权限能看到全部订单、审核订单、管理用户和回收员账号、发布回收公告、查看统计数据。整个核心流程我最后整理成了这样一条线用户提交回收申请 - 系统根据参数生成预估价 - 管理员审核订单 - 审核通过后分配回收员 - 回收员联系用户并上门 - 填写实际回收价 - 用户确认 - 订单完成这个流程里最容易忽略的是“审核失败”的分支。如果电器信息造假或者不在回收范围内管理员需要能驳回并填写原因用户那边能实时看到驳回理由。所以订单表里专门加了一个 audit_remark 字段用来存审核意见。1.3 为什么选 SSM 而不是 Spring Boot很多人看到 SSM 会觉得过时但如果你是为了应付课程设计或者想真正理解 Java 后端的核心机制SSM 反而比 Spring Boot 更值得写一遍。Spring Boot 自带自动配置你写一个 Controller 就能跑但底层怎么包扫描、DispatcherServlet 怎么初始化、MyBatis 的 SqlSessionFactory 怎么注入这些关键问题如果你没经历过手动配置的过程很难真正理解。我这次用 SSM 就是 XML 注解混用Spring 容器管 BeanSpring MVC 管请求分发MyBatis 管数据库操作每一层都是自己手动配置的等于把框架的骨架亲手搭了一遍。另外很多学校的 Java 课程设计和毕业设计题目还在沿用 SSM这类项目和面试中常问的 Spring IOC、AOP、MyBatis 动态 SQL 高度挂钩。做完这个项目你再去看 java 面试八股文里关于 Spring Bean 生命周期、MyBatis 一级二级缓存、Spring MVC 执行原理这些问题会轻松很多。2. 数据库设计一张订单表撑起整个业务2.1 核心表结构与字段设计我前前后后把数据库改了四版第一版恨不得设计十几张表后来砍到 6 张核心表才够用。实际写代码你会发现表不是越多越好关键是字段能不能支撑业务流转。用户表 userid、username、password、phone、address、roleuser/recycler/admin、create_time。密码存的是 MD5 加密后的值虽然现在主流用 BCrypt但课程设计层面 MD5 也可以接受如果想让项目加分强烈建议改成 BCrypt面试时还能顺口说出 MD5 为什么不安全。电器类别表 categoryid、name冰箱/洗衣机/空调/电视/电脑等、unit_price基准回收价、description。这个表的作用是给估价逻辑提供一个基准值不同品类的旧电器回收价差异非常大一台旧冰箱和一台旧电视不能用一个价格体系。回收订单表 recycle_order 是整张核心表字段包括字段名类型说明idint主键自增order_novarchar(32)订单编号时间戳 随机数生成user_idint下单用户 IDcategory_idint电器类别 IDbrandvarchar(50)品牌modelvarchar(50)型号purchase_yearint购买年份condition_leveltinyint成色等级 1-5quantityint数量estimate_pricedecimal(10,2)预估价actual_pricedecimal(10,2)实际回收价statustinyint0待审核 1待上门 2已回收 3已完成 -1已取消recycler_idint回收员 IDappointment_timedatetime预约上门时间audit_remarkvarchar(255)审核备注/驳回原因create_timedatetime创建时间update_timedatetime更新时间回收记录表 recycle_record真正完成回收后写入的一条记录记录订单号、回收员、实际金额、完成时间主要给统计页面用。回收员表 user 表里通过 role 字段区分即可不需要单独建表。公告表 noticeid、title、content、create_time管理员维护用户首页展示。2.2 订单状态机的设计与流转卡点订单状态是整个系统最容易写乱的地方。我一开始就是随便定义几个 int 常量结果在 Service 层到处写魔法数字后面改状态流转逻辑时把自己坑惨了。后来我单独建了一个 OrderStatus 常量类把状态流转写清楚public class OrderStatus { public static final int PENDING_AUDIT 0; // 待审核 public static final int PENDING_RECYCLE 1; // 待上门 public static final int RECYCLED 2; // 已回收待确认 public static final int COMPLETED 3; // 已完成 public static final int CANCELLED -1; // 已取消 }状态机的关键约束有三个代码里必须对应校验第一个是只有待审核状态的订单才能被取消。第二个是只有待审核状态的订单管理员才能通过或驳回。第三个是回收员填完实际价格后订单状态从“待上门”变成“已回收待确认”用户确认后才算“已完成”。这些流转规则如果不在 Service 层统一校验直接在 Controller 里乱改 status系统很快就失控。我用一个统一的 updateOrderStatus 方法收口了所有状态变更在里面先查旧状态再判断新状态是否符合流转规则不符合直接抛业务异常。2.3 动态 SQL 在回收订单搜索中的应用订单列表页需要支持多条件组合查询按状态查、按电器类型查、按时间范围查、按订单号模糊查。用户输入的条件可能是一个也可能是多个这时候 MyBatis 动态 SQL 就派上大用场了。我在 UserMapper.xml 里写了这样一段select idsearchOrders resultTypecom.example.pojo.RecycleOrder SELECT * FROM recycle_order where if teststatus ! null AND status #{status} /if if testcategoryId ! null AND category_id #{categoryId} /if if testorderNo ! null and orderNo ! AND order_no LIKE CONCAT(%, #{orderNo}, %) /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC /select这里用 标签自动处理了 AND 前缀问题比手动写 WHERE 11 干净多了。这个技能在面试中也很加分MyBatis 动态 SQL 是 java 面试题里的高频考点尤其是 、 、 、这几个标签的实际使用场景一定要能说明白。3. 后端核心功能实现与踩坑记录3.1 登录拦截器与角色权限控制的正确姿势SSM 项目做登录校验最直接的方式是 Spring MVC 拦截器。一开始我在每个 Controller 方法里都写一遍“从 session 里取用户为空则跳转登录”代码冗余而且容易漏。后来统一抽了一个 LoginInterceptorpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object user session.getAttribute(loginUser); if (user null) { // AJAX 请求返回 JSON 提示页面请求重定向到登录页 String requestedWith request.getHeader(X-Requested-With); if (XMLHttpRequest.equals(requestedWith)) { response.setContentType(application/json;charsetutf-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); } else { response.sendRedirect(request.getContextPath() /login); } return false; } return true; } }在 spring-mvc.xml 里配置拦截路径时要注意一个细节管理员和回收员的接口得单独校验角色不能只用一个登录拦截器搞定。我后来又在拦截器里加了判断请求路径以 /admin/ 开头的必须要求 session 里的用户 role 等于 admin否则直接 403。这里最容易出的问题是对静态资源放行。如果你忘了放行 /static/、/css/、/js/** 这些路径页面样式会全部丢失你还半天找不到原因。拦截器配置一定要加mvc:exclude-mapping path/static/**/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/images/**/3.2 回收申请到上门回收的完整事务链路用户在页面上提交回收申请后端要做的事不止是 insert 一条订单记录还要生成订单编号、初始化状态、记录操作日志。管理员审核通过后要把订单状态改成待上门、分配回收员、给用户发站内消息。这些操作必须在一个事务里不然中途失败就会出现状态和数据不一致的问题。这里我用 Spring 声明式事务在 Service 方法上直接加 Transactional 注解Override Transactional(rollbackFor Exception.class) public boolean submitOrder(RecycleOrder order, Integer userId) { // 1. 生成订单号 order.setOrderNo(generateOrderNo()); // 2. 设置初始化状态 order.setUserId(userId); order.setStatus(OrderStatus.PENDING_AUDIT); order.setCreateTime(new Date()); order.setUpdateTime(new Date()); // 3. 插入订单 int rows recycleOrderMapper.insert(order); // 4. 记录日志 OperationLog log new OperationLog(); log.setOrderId(order.getId()); log.setUserId(userId); log.setAction(提交回收申请); operationLogMapper.insert(log); return rows 0; }rollbackFor Exception.class 这个配置必须写。Spring 默认只在抛出 RuntimeException 时才回滚如果你在业务代码里 try-catch 把异常吃掉了事务是不会回滚的。我在系统里自定义了 BusinessException所有业务错误统一抛这个由全局异常处理器捕获后返回友好提示。还有一点想提醒忘记关闭 MyBatis 一级缓存中的 SqlSession 或者在 Service 层直接用 Mapper 而不经过事务代理都会导致事务失效。排查方式很朴素——在方法里故意制造一个异常看数据库里之前 insert 的数据是否回滚了。这是验证事务是否生效最直接的测试手段。3.3 联表查询性能优化从 N1 到结果集映射做订单列表时订单表里只有 user_id 和 category_id但页面上要显示用户名和电器类型名。最简单粗暴的方式是在循环里逐条查用户表和类别表这就是经典的 N1 查询问题列表 20 条订单就要额外执行 40 次查询。优化方式是写一个联表查询用 MyBatis 的结果集映射把关联对象查出来resultMap idOrderVO typecom.example.vo.OrderVO id propertyid columnid/ result propertyorderNo columnorder_no/ result propertyestimatePrice columnestimate_price/ result propertystatus columnstatus/ result propertyappointmentTime columnappointment_time/ association propertyuser javaTypecom.example.pojo.User id propertyid columnuser_id/ result propertyusername columnusername/ result propertyphone columnphone/ /association association propertycategory javaTypecom.example.pojo.Category id propertyid columncategory_id/ result propertyname columncategory_name/ /association /resultMap select idselectOrderVOList resultMapOrderVO SELECT o.*, u.username, u.phone, c.name AS category_name FROM recycle_order o LEFT JOIN user u ON o.user_id u.id LEFT JOIN category c ON o.category_id c.id where if teststatus ! null AND o.status #{status} /if /where ORDER BY o.create_time DESC /select这种方式的原理是数据库层面做一次联表查询由 MyBatis 负责把查出来的扁平结果映射成对象结构比循环查单表快一个数量级。面试时能把这个优化过程讲清楚比背十道 java 面试八股文都管用。3.4 JSON 序列化循环引用懒加载不要直接丢给前端这是一个卡了我一整个晚上的问题。订单关联用户、用户关联订单列表在用 Jackson 把对象转成 JSON 返回给前端时报了无限递归错误。原因是 Order 对象里有 UserUser 对象里又有 List JSON 序列化时来回引用栈溢出。最简单的解决办法是不要让实体类直接出参。我在项目中加了一层 VOView Object专门用来承接前端需要展示的数据。比如 OrderVO 里只有用户 id、用户名、手机号没有那个反向的订单列表。这样既解决了循环引用又避免了把密码之类的敏感字段传给前端。另外一个坑是 MyBatis 的延迟加载如果你在业务代码里开启了懒加载然后在 Controller 里直接返回实体对象Jackson 序列化时一旦触发懒加载而 SqlSession 已经关闭就会报 org.apache.ibatis.binding.MapperMethod 相关的懒加载异常。建议简单项目直接关闭懒加载或者确保在 Service 层就把需要的数据都查出来。4. 前端交互与接口联调的心得4.1 JSP AJAX JSON 的组织方式现在很多课程设计都在用前后端分离但 SSM 项目最常见的组合还是 JSP Bootstrap jQuery。JSP 里渲染动态数据我优先用 JSTL 标签不会在 Scriptlet 里写大段 Java 代码。对于订单状态更新这种操作用 AJAX 异步提交局部刷新页面。处理用户点击“预约上门时间”的场景前端弹一个模态框选择时间后提交成功后用 JS 更新当前行的状态显示。这里需要特别注意 JSON 数据格式后端我统一封装了一个 Result 类结构是{ code: 200, msg: 操作成功, data: {} }前端拿到 code 后再判断操作是否成功而不是只看 HTTP 状态码。开发时浏览器按 F12 打开开发者工具切换到 Network 面板看接口返回的原始 JSON是排查前后端问题最快的手段。4.2 表单校验、防重复提交与提示优化用户提交回收申请时品牌、购买年份、成色这些信息缺一不可前端用 jQuery Validate 做基础校验后端 Service 层再做一遍完整性校验双保险。前端校验是为了用户体验后端校验才是真正的安全底线因为接口可以被任何人直接调用。防重复提交也值得做一下。用户连续点击提交按钮很容易产生多条一模一样的订单。我的方案是前端提交时先禁用按钮等 AJAX 回调后再恢复后端再配合一个简单处理——在订单表加一个 user_id create_time 的联合查询5 秒内重复提交同一类别的订单就拒绝。这个逻辑面试时也能讲算是高并发下单场景的简化版本。提示优化上所有操作成功和失败都要有明确反馈。比如审核驳回时管理员必须填写驳回原因前端弹窗里写成必填项用户侧在订单详情里能直接看到原因。4.3 分页组件与筛选参数的联动订单列表页的数据量大了以后必须分页。我用的 PageHelper 插件配置很简单PageHelper.startPage(pageNum, pageSize); ListOrderVO list recycleOrderMapper.selectOrderVOList(query); PageInfoOrderVO pageInfo new PageInfo(list);但有一个很容易踩的坑PageHelper 分页只对紧接着执行的第一条 SQL 生效。如果你在 startPage 之后又执行了其他查询语句分页可能会错乱。所以写法上要保证 startPage 和查询之间不要插入任何多余操作。前端分页条我用的 Bootstrap Pagination 组件点击页码时重新用 AJAX 请求列表接口保持筛选条件状态、电器类型不丢失。实现方式是把筛选条件缓存在一个全局 JS 对象里每次翻页带上这些参数。5. 开发环境与部署常见问题排查5.1 JDK 版本不一致导致“源发行版 17 需要目标发行版 17”这个报错在热词里出现频率极高属于每个 Java 初学者都会撞上的环境问题。报错信息是java: 警告: 源发行版 17 需要目标发行版 17出现原因通常是 IDEA 中项目的 JDK 版本、Java 编译器版本和 Maven 的编译版本不一致。比如你安装的 JDK 是 17但项目里配置的 language level 是 1.8或者所有地方都写的 17但 IDEA 当前选中的 SDK 是更低版本。我的排查顺序是这样的先按 Ctrl Alt Shift S 打开 Project Structure确认 Project SDK、Project language level、Modules 里的 Language level 三个地方一致然后打开 Settings - Build, Execution, Deployment - Compiler - Java Compiler确认 Target bytecode version最后检查 pom.xml 里的 maven-compiler-plugin 配置最好统一用properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target /properties如果本地 JDK 是 17建议项目统一用 8 或者 11不要混着来。还有一个细节改完配置后必须 Maven Reload 和重新 Build有时候只是 IDEA 缓存没刷新。5.2 中文乱码与时间格式的前后端一致性SSM 项目中文乱码的根源几乎都是编码不一致。Tomcat 默认请求编码不是 UTF-8所以必须在 web.xml 里配置编码过滤器filter filter-namecharacterEncodingFilter/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-namecharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncoding 设为 true 非常关键不设置的话响应乱码还是会出现。数据库连接串也要加上 characterEncodingutf8 和 useSSLfalse数据库表本身建表时也要用 utf8mb4 字符集。时间格式方面后端返回的 Date 类型默认序列化是一串毫秒数前端看不懂。我用 Jackson 的配置统一转成字符串mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter property nameobjectMapper bean classcom.fasterxml.jackson.databind.ObjectMapper property namedateFormat bean classjava.text.SimpleDateFormat constructor-arg valueyyyy-MM-dd HH:mm:ss/ /bean /property /bean /property /bean /mvc:message-converters /mvc:annotation-driven5.3 端口占用与 Tomcat 启动失败开发时最容易遇到的就是端口被占。Tomcat 默认 8080如果之前启动的实例没关干净再启动就会报 Address already in use: JVM_Bind。命令行查端口占用Windows 用 netstat -ano | findstr 8080然后 taskkill /PID 对应进程号 /FMac 或 Linux 用 lsof -i :8080然后 kill -9 进程号。有些时候连 Tomcat 的日志都看不到完整报错错误信息直接被 IDEA 吞了这时候去 Tomcat 安装目录的 logs 文件夹看 catalina.out 或者 localhost.log信息全得多。另外一个常见的坑是 Tomcat 部署路径问题。如果你发布到 root 路径访问地址就是 http://localhost:8080/如果带项目名就是 http://localhost:8080/java_ssm2/。前端页面的相对路径、AJAX 请求的 URL 都要跟着上下文路径走我统一在 JSP 页面里用% String path request.getContextPath(); String basePath request.getScheme() :// request.getServerName() : request.getServerPort() path /; % base href%basePath%这样页面里所有静态资源和请求链接都基于 basePath 去拼部署到任何路径都不会错。6. 从课程设计到面试项目的升级建议6.1 还能加哪些亮点功能如果你时间充裕下面这几个功能非常建议往上加每个都能成为面试时的亮点。第一是数据可视化。管理员首页放几张图表统计最近一周的回收订单量、回收金额、各类电器回收占比。可以用 ECharts 官方示例后端提供统计接口返回 JSON 给前端渲染。这项目能体现你既有后端数据处理能力又懂一点前端可视化。第二是回收价格动态调整。把估价逻辑和类别表解耦管理员在后台维护“基础价 成色系数 品牌溢价”这三套规则用户提交订单时后端根据规则计算预估价。这说明你能把生活中的业务规则抽象成可维护的系统配置而不是写死。第三是 Excel 导出。管理员需要把订单列表导出成 Excel 按月对账。用 Apache POI 写一个导出工具类几十行代码就能实现。这个功能在实际项目中几乎天天用。第四是接口防刷与日志切面。用 Spring AOP 写一个操作日志切面通过自定义注解自动记录用户操作给提交订单接口加一个简单的访问频率限制。这能明显展示你理解 Spring AOP 的切面思想而不是只会注解。6.2 面试时怎么讲这个项目做完项目之后一定不要只是把代码堆到简历上要能讲出设计思路。面试官问项目时我建议按这个顺序组织表述。先讲背景承接了一个家用二手电器回收的业务需求要解决线下回收流程不透明、订单管理混乱的问题。按用户、回收员、管理员三个角色设计权限模型核心流程是提交申请、审核、分配回收员、上门回收、确认结算。再讲技术方案后端用 SSM 框架Spring 负责 Bean 管理和事务Spring MVC 负责请求分发和参数绑定MyBatis 负责数据库操作。数据库设计了 6 张核心表订单表用状态机管理业务流转。接着讲个人亮点订单列表从 N1 查询优化成联表查询响应速度明显提升用拦截器统一处理登录鉴权把业务异常统一收口配合全局异常处理器返回友好提示。最后讲遇到的坑中文乱码问题是如何通过编码过滤器解决的MyBatis 懒加载和 JSON 循环引用是如何通过 VO 解决的事务不生效问题是如何通过 rollbackFor 解决的。按这个逻辑讲你自然就把 java 面试八股文里 Spring 容器、AOP、MyBatis 动态 SQL、事务传播行为这些知识点串联进去了比单纯背诵“什么是 IOC”要有说服力得多。7. 写在最后的经验复盘整个项目写下来我最大的体会是SSM 这套框架组合虽然“老”但恰恰因为配置看得见摸得着反而能逼你把底层原理搞清楚。Spring Boot 一键启动确实舒服可一旦出问题你都不知道去哪看配置、改配置而 SSM 项目里所有环节都是你自己安排的心里有数得多。还有一点想提醒你项目做到七八成的时候记得做一次完整的“从零部署”演练。删掉本地所有的编译产物、把数据库脚本在干净环境里重新执行一遍、用新的 Tomcat 部署 war 包、走一遍用户从注册到订单完成的完整流程。我就是在这次演练里发现了数据库脚本少了初始化数据、某个分页参数没传导致页码错乱等好几个平时单独测没问题、一结合起来就暴露的问题。这个过程很扎心但对能力的提升非常实在。最后再分享一个小技巧一定要给项目写一份简洁的 README说清楚项目运行环境、数据库初始化脚本、测试账号和操作步骤。别小看这份文档你隔一个月再回来看项目或者把项目发给别人评审它起的作用比你想的大得多。