做毕业设计选了“线上医药用品分销系统”这个方向,技术栈是Java + SSM + J2EE,前后折腾了差不多两个月。这个题目的核心不只是把CRUD写完,而是要在分销业务里把库存、订单、权限、数据一致性这些“真正麻烦”的东西理清楚。这篇文章把我从选题、数据库设计、SSM整合到并发踩坑的完整过程记录下来,给后面选类似题目的同学做一个参考。
先说结论:如果你已经有一定Java基础,SSM框架的配置层并没有传说中那么吓人,真正的难点在业务逻辑设计,尤其是药品这种对库存准确性和订单状态一致性要求极高的场景。这套系统做完以后,你对Spring的IoC、事务传播机制、MyBatis的动态SQL,还有J2EE分层架构的理解,会比你刷五十道面试题来得深得多。
1. 项目整体设计与技术选型
1.1 为什么是J2EE + SSM而不是Spring Boot
做这个选题之前我纠结过一阵子:现在企业里新项目基本都上Spring Boot了,毕业设计还用SSM会不会显得过时?后来和导师聊完,加上自己写了一段时间代码,反而觉得SSM这个组合更适合这种带“论文性质”的系统设计。
理由主要有三个。第一,SSM的配置是显式的,每个Bean、每个拦截器、每个Mapper都要自己声明,你对“框架到底帮我们做了什么”会有非常具体的感知。Spring Boot虽然省事,但很多东西被自动配置藏起来了,写论文的时候反而不好讲深。第二,很多学校实验室的老机器、老JDK环境下,SSM项目的兼容性比Spring Boot 2.x更稳,我身边就有同学因为SpringBoot版本和JDK版本不匹配折腾了整整两天。第三,线上医药用品分销系统这种业务,核心价值在业务流程和数据模型,不是在框架有多新,用SSM能把篇幅集中在业务上。
技术组合我定的是:
- J2EE规范下的分层架构:表现层、业务层、持久层,严格按接口隔离;
- Spring 5.1.x:负责Bean管理、声明式事务、AOP日志;
- SpringMVC 5.1.x:承担请求路由、参数绑定、JSON响应;
- MyBatis 3.4.x:持久层,手写SQL,处理复杂查询;
- MySQL 5.7:数据存储,InnoDB引擎;
- Maven 3.6:依赖管理和构建。
这套组合的好处是每一层都有清晰的技术对标点,论文里可以逐个展开讲原理,答辩老师问“SpringMVC的工作原理”“MyBatis的一级缓存和二级缓存区别”这类问题的时候,你都能从自己的项目里找到对应的实现,而不是背课本。
1.2 分销系统区别于普通商城的关键需求
医药用品分销系统不是普通B2C商城,它的业务特征决定了系统的设计方向。普通商城可以接受超卖之后退款,药品分销系统如果库存数据出错,往小了说是对账麻烦,往大了说涉及下游药店缺货甚至效期管理失控。这就是我选题时最看重的一个专业点:库存准确性与订单一致性必须从架构层面保证,而不能靠程序员自觉。
具体来说,分销系统的核心角色包括:平台管理员、上游供应商、下游分销商(药店或诊所)、普通客户。订单路径比普通电商长,大概是:分销商下单 → 系统校验库存和资质 → 支付/结算 → 仓库发货 → 分销商确认收货 → 可能产生退货单。每一环都要有状态记录和时间戳,这就是论文里可以分章节写的业务支撑点。
对比一下普通商城和分销系统的差异:
| 维度 | 普通商城 | 医药分销系统 |
|---|---|---|
| 订单路径 | 用户下单→支付→发货 | 分销商下单→资质校验→支付→出库→收货→退货/换货 |
| 库存要求 | 允许超卖后补偿 | 严格管控,出库数据必须与库存精确一致 |
| 用户类型 | 单一C端用户 | 管理员/供应商/分销商/客户多角色 |
| 合规性 | 一般无要求 | 药品经营资质、批号效期记录 |
| 数据关注点 | 订单量、转化率 | 库存周转、效期预警、账实相符 |
正因为这些差异,我在数据库设计阶段把表拆得比一般课设细很多,后面会详细讲表结构。
1.3 论文的功能模块划分
这个系统的功能模块我最终定成了五个大块:
- 用户与权限模块:登录、角色管理、菜单权限、操作日志。基于RBAC模型实现,用户-角色-权限三级关联。
- 药品信息管理模块:药品基础信息、分类管理、供应商关联、批号与效期管理。
- 库存管理模块:入库、出库、库存盘点、效期预警、库存流水查询。
- 订单流程模块:分销商下单、订单审核、发货出库、退货处理、订单状态跟踪。
- 统计报表模块:销售额统计、库存周转率、热销药品排名、供应商供货统计。
论文里我把每个模块都对应到一个章节,每个模块都画了业务流程图和时序图。这里给个建议:论文不是代码堆砌,评审老师更关心你有没有“业务抽象能力”,所以模块设计章节值得多花时间把前因后果讲清楚。
2. 核心理论:SSM框架的执行链路
2.1 Spring IoC如何帮我们解耦业务
在写代码之前,我先把Spring的IoC容器在系统里的角色想明白了:它不是“配置文件解析器”,而是一个对象工厂加对象管理容器。所有Service、Dao、Controller、事务管理器,都是容器里的Bean,由容器负责创建、注入依赖、管理生命周期。
我实际设计的时候,遵循了这样的依赖关系:Controller依赖Service接口,Service实现类依赖Dao接口,Dao接口依赖MyBatis的Mapper代理。这样一来,业务层的实现替换只需要改配置,不需要动Controller。我论文里专门写了这么一句:这种依赖倒置的结构让医药分销系统的业务逻辑能够独立于框架细节进行单元测试,这句话答辩的时候很好用,因为能讲出你的设计理由是“可测试性”而不只是“框架要求”。
举个例子,订单模块有一个OrderService接口,实现类OrderServiceImpl里注入OrderDao、InventoryDao、DrugInfoDao三个Mapper。没有IoC的话,你得在每个Service里new一个DAO实现类,耦合一眼就能看出来;有IoC之后,所有DAO都是接口代理,你甚至可以写一个Mock实现来做纯业务测试。
2.2 SpringMVC从请求到响应的完整旅程
SpringMVC的执行流程是论文里必讲的重点,但很多同学只是照抄书上的理论,没有和自己项目对应起来。这里我用系统里“分销商提交订单”这个请求来说明一次完整的请求旅程。
请求路径假定为POST /order/submit,参数是订单项列表。流程如下:
DispatcherServlet接收到请求,它是前端控制器,所有请求的唯一入口。- 通过
HandlerMapping找到对应的HandlerMethod,也就是OrderController.submitOrder()。 - 通过
HandlerAdapter执行Controller方法,在此之前SpringMVC会做参数绑定:@RequestBody把JSON字符串反序列化成OrderSubmitDTO对象。 - Controller调用
OrderService.submitOrder(dto),业务层抛出异常时,由@ExceptionHandler统一捕获并转成JSONResult响应。 - 方法返回后,
HandlerAdapter拿到返回值,如果是@ResponseBody,就交给MappingJackson2HttpMessageConverter序列化为JSON。 DispatcherServlet把JSON写回客户端,一次请求结束。
这段流程在项目里对应的调试经验是:如果前端传的参数和后端VO字段对不上,多半会报HttpMessageNotReadableException,这种问题查起来很花时间,所以我会在DTO字段上加上@NotNull和@JsonProperty注解,让前端联调的时候尽早暴露问题。
2.3 MyBatis的持久层设计与动态SQL
MyBatis在这个项目里最大的优势是动态SQL。药品信息的查询条件非常多:按分类查、按供应商查、按效期区间查、按关键词查。如果用JPA,这种场景会有大量的规范方法名或JPQL拼接;MyBatis则可以用<where>、<if>、<foreach>标签灵活处理。
我写的一个典型查询是药品分页列表:
<select id="selectDrugPage" resultType="map"> SELECT d.id, d.drug_code, d.drug_name, d.specification, d.unit, d.purchase_price, d.sale_price, d.supplier_id, s.supplier_name, d.expire_date, d.stock_quantity, d.status FROM drug_info d LEFT JOIN supplier_info s ON d.supplier_id = s.id <where> <if test="keyword != null and keyword != ''"> AND (d.drug_name LIKE CONCAT('%', #{keyword}, '%') OR d.drug_code LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND d.category_id = #{categoryId} </if> <if test="supplierId != null"> AND d.supplier_id = #{supplierId} </if> <if test="status != null"> AND d.status = #{status} </if> </where> ORDER BY d.updated_time DESC </select>这段SQL有几个执行细节值得注意。CONCAT('%', #{keyword}, '%')比直接在Java层拼好%keyword%再传参更适合MySQL的索引使用(虽然LIKE '%xx%'本身可能不走索引,但至少不会引入SQL注入风险)。另外<where>标签会自动处理第一个AND,这个语法在复杂的筛选条件下能省掉大量重复判断代码。
还有一点,MyBatis的一级缓存是SqlSession级别的,默认开启。在你的一次请求过程中,如果多次执行同一条SQL且中间没有更新操作,第二次会直接命中缓存。但要注意:如果你的Service方法里先查后更新再查,一级缓存不会自动失效,必须手动清除或者确保查和更新在不同的SqlSession里。这个问题我实际遇到过,后面在问题排查章节详细展开。
3. 数据库设计与库存一致性方案
3.1 表结构设计:分销业务的数据底座
数据库设计是我这次项目里花时间最多、也最值得展开写的部分。一共设计了18张表,核心的表包括:药品分类表、药品信息表、供应商表、库存表、库存流水表、用户表、角色表、权限表、用户角色关联表、角色权限关联表、订单主表、订单明细表、退货表、操作日志表。
这里特别说三个容易被忽略的设计点。
第一,药品信息表和库存表一定要分离。我见过很多课设把“库存数量”直接写在药品表里,做起来简单,但分销业务中药品信息更新(比如改价格、改规格)和库存变动(入库、出库、盘点)的频率完全不同,混在一张表里会导致行锁竞争非常激烈。我把drug_info表和inventory表分开后,库存变更只操作inventory表,药品基础数据查询可以缓存,互不影响。
第二,库存流水表是库存模块的“账本”。每一笔入库、出库、盘点调整都在inventory_log里记一条记录,包含:药品ID、变动前数量、变动数量、变动后数量、变动类型(IN/OUT/ADJUST)、关联单号、操作人、时间。这样做的好处是任何时候出现账实不符,都能追溯到底。
第三,订单表里要冗余一个“快照价格”字段。这个我一开始没想到,后来做退货需求时才发现:如果分销商下单价是10元,三个月后药品涨到12元,退货时不可能按当前价格退,必须按下单时点的价格退。所以order_detail表里必须有order_price字段,保存下单那一刻的成交价,而不是去关联最新的drug_info.sale_price。
订单主表和明细表的结构我设计成:
order_main - id, order_no, distributor_id, total_amount, order_status, payment_status, delivery_status, create_time, audit_time, remark order_detail - id, order_id, drug_id, drug_code, drug_name, quantity, order_price, subtotal_amount, status订单状态我定义了一个枚举:PENDING_AUDIT(待审核)、AUDITED(已审核)、PAID(已支付)、DELIVERED(已发货)、RECEIVED(已收货)、RETURNING(退货中)、FINISHED(已完成)、CANCELLED(已取消)。状态流转必须按照固定的方向走,不允许跳转,这也是论文里可以重点写“状态机设计”的素材。
3.2 库存扣减与防超卖:一条SQL的觉悟
分销系统最核心的并发问题是:两个分销商同时买了同一个药品,库存只剩10件,一个买8件一个买5件,如果代码是先查库存再判断再更新,那极有可能两个请求都读到10,然后各自扣数,最后库存变成负数。这就是典型的超卖。
我最初的写法是业务层手动控制:
Inventory inventory = inventoryDao.selectByDrugId(drugId); if (inventory.getAvailableStock() < quantity) { throw new BusinessException("库存不足"); } inventory.setAvailableStock(inventory.getAvailableStock() - quantity); inventoryDao.updateById(inventory);这个写法在单机单线程测试下完全没问题,但用JMeter模拟30个并发请求后,库存就变成了负数。原因很简单:两个线程同时select到了同一个库存快照。
正确的做法是在SQL层面做条件更新:
<update id="deductStockWithCondition"> UPDATE inventory SET available_stock = available_stock - #{quantity}, updated_time = NOW() WHERE drug_id = #{drugId} AND available_stock >= #{quantity} </update>执行这条UPDATE后,返回值int如果小于1,就说明条件不满足(库存不足),这时业务层抛出异常并回滚。这个方案不需要锁,也不需要事务嵌套,是最轻量、最高效的防超卖手段。配合MySQL的InnoDB行锁,在同一条记录上并发更新时会排队执行,最终结果始终保持一致。
我在论文里专门用了一节对比三种方案:悲观锁(SELECT ... FOR UPDATE)、乐观锁(版本号机制)、条件更新。最终选定条件更新的原因是:分销系统的写入并发量并没那么极端(一个药品一分钟能有几十单算很高了),条件更新没有版本号字段的额外维护成本,也没有悲观锁的连接占用风险,代码可读性也最好。
3.3 事务边界:什么操作必须放一个事务里
订单提交流程涉及多个表的写操作:创建订单主表、创建订单明细、扣减库存、写入库存流水、记录操作日志。这五个步骤必须在一个数据库事务里,否则会出现“订单创建成功但库存没扣”这类严重问题。
在Spring里我用@Transactional注解标注submitOrder方法,并指定了事务管理器:
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) public void submitOrder(OrderSubmitDTO dto) { // 1. 校验分销商资质 // 2. 创建订单主表 // 3. 批量创建订单明细 // 4. 条件更新库存,失败则抛异常 // 5. 写入库存流水 // 6. 记录操作日志 }这里有个关键细节:rollbackFor = Exception.class必须显式声明。Spring的默认行为是只有遇到RuntimeException才回滚,如果业务代码里抛的是自定义检查异常(比如BizException extends Exception),不加这个参数的话事务不会回滚,数据就处于半提交状态。这是我实际踩过的一个坑,写论文时值得专门提一句。
另外,对于“审核订单”和“发货出库”这种耗时操作,我把它们拆成了独立事务,而不是和“提交订单”挤在同一个长事务里。原因是一个事务持有的数据库连接、行锁要一直维持到事务提交,如果长事务里再调用外部接口或做复杂计算,连接池的压力会显著增大。短事务是分布式系统里的普遍原则。
4. 系统实现:从Controller到Service的完整链路
4.1 表现层:SpringMVC的规范化返回
这个系统的所有Controller都统一返回JSONResult对象,格式是:
{ "code": 200, "message": "成功", "data": {} }定义统一返回结构的好处,在前后端分离开发中体现得很明显:前端只需要处理三种情况(成功、业务失败、系统异常),而不是每个接口自己定义一套结构。前端的拦截器里根据code判断是否需要弹出错误提示、是否需要跳转登录页。
JSONResult我用一个泛型类实现:
public class JSONResult<T> { private Integer code; private String message; private T data; public static <T> JSONResult<T> success(T data) { ... } public static <T> JSONResult<T> error(String message) { ... } }Controller里不需要写任何try-catch,业务异常统一由@ExceptionHandler捕获。我自己定义了一个BusinessException(继承RuntimeException),业务校验不通过时就抛出它,对应的异常处理器会把message返回给前端。而未知异常则在全局异常处理器里记录日志,返回“系统繁忙”这种通用提示,避免把异常堆栈暴露给用户。
这种设计让我在写论文的“表现层设计”章节时有很多实际代码可以贴,而不是泛泛地写“采用MVC模式”。
4.2 业务层:订单状态机的推进逻辑
订单状态机是这个项目里业务层最有含金量的部分。我把它实现成了一个独立的类OrderStateMachine,核心逻辑是维护一张允许转移的状态映射表:
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new EnumMap<>(OrderStatus.class); static { TRANSITIONS.put(PENDING_AUDIT, EnumSet.of(AUDITED, CANCELLED)); TRANSITIONS.put(AUDITED, EnumSet.of(PAID, CANCELLED)); TRANSITIONS.put(PAID, EnumSet.of(DELIVERED, RETURNING, CANCELLED)); TRANSITIONS.put(DELIVERED, EnumSet.of(RECEIVED, RETURNING)); TRANSITIONS.put(RECEIVED, EnumSet.of(FINISHED, RETURNING)); TRANSITIONS.put(RETURNING, EnumSet.of(FINISHED, CANCELLED)); } public static void validateTransition(OrderStatus current, OrderStatus target) { if (!TRANSITIONS.getOrDefault(current, Collections.emptySet()).contains(target)) { throw new BusinessException("非法订单状态流转: " + current + " -> " + target); } }每次订单状态更新前,Service层都会调用validateTransition做前置校验。这个设计在答辩时很加分,因为它展示了你对业务规则的理解——而不是简单地在数据库里改一个status字段的字符串值。
4.3 权限模块:RBAC的实现细节
管理端的用户权限我采用经典的RBAC模型,五张表:sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu。登录后,用户的角色和菜单权限被加载进内存,SpringMVC拦截器对每个请求做权限校验。
拦截器实现的核心逻辑:
public class PermissionInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 判断当前请求路径是否需要登录 // 从Redis或Session中获取当前用户及角色 // 校验角色是否拥有该路径对应的菜单权限码 // 校验失败则返回403,并写入JSONResult } }这里想提示一个实际经验:权限校验不能只判断“是否登录”,还要判断“是否有该操作的权限”。我最初只拦了登录状态,结果普通操作员能访问管理员的数据导出接口,这个漏洞是测试阶段发现的。所以现在的做法是,每个需要权限的接口都标记了对应的权限码,比如drug:add、order:audit、inventory:adjust,拦截器通过请求的URI匹配到权限码,再检查用户角色是否包含该权限。
5. 常见问题与排查技巧实录
5.1 启动阶段遇到的坑
问题一:Invalid bound statement (not found)
这个报错的意思是Mapper接口找到了,但对应的XML里的SQL找不到。我排查的时候先确认三件事:XML文件的namespace是否正确映射到了Mapper接口的全限定名;mapper-locations配置是否指向了XML目录;XML文件的id是否和接口方法名一致。结果发现是Maven构建时没有把src/main/java下的XML文件打包到classes目录,因为XML放在了java目录而不是resources目录。解决方式是在pom.xml里加资源配置:
<resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources>这类配置问题在SSM项目里特别典型,Spring Boot几乎没有这个问题,所以如果你一开始就用Boot,反而不会理解这些底层机制。
问题二:Maven依赖冲突导致NoClassDefFoundError
最开始我在pom.xml里直接引入了spring-webmvc和mybatis-spring,结果依赖传递下来的Spring版本不一致,运行期间报各种类找不到。后来统一使用spring-framework-bom来锁定所有Spring组件的版本,才彻底解决:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-framework-bom</artifactId> <version>5.1.20.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>5.2 并发场景下暴露的数据一致性问题
我用JMeter做了提交订单的并发测试,线程数设为50,每个线程下单一笔。测试后发现库存数据比预期多扣或者少扣的情况都有,原因排查如下:
第一次出现“少扣”(实际库存比理论值多)是因为我在业务层先通过selectByDrugId拿到库存,然后判断在内存里改了对象,最后执行updateById。并发场景下两个请求都读到了同一个库存值,各自减了数量再写回,后写回的覆盖了先写回的,等于丢了一次扣减。
改成deductStockWithCondition的SQL条件更新后,这个问题就消失了。因为更新操作本身是原子的,available_stock >= #{quantity}条件能保证不会扣超,也不会丢更新。这次实践让我对“不要从数据库取出来算完再写回去”这句话有了切身体会。
还有一个和缓存相关的坑:MyBatis二级缓存在我加了<cache>配置后,出现了药品信息修改后其他仍然读到旧值的问题。原因是我把drug_info的查询开启二级缓存后,更新操作没有及时清空对应缓存。MyBatis默认的缓存更新策略是flushInterval不设置的话,UPDATE会触发flushCache清空缓存,但对联表查询和自定义SQL不一定起作用。后来我对那些可能被更新的药品查询直接关闭了二级缓存,只保留一级缓存,数据就准确了。
5.3 一个很隐蔽的JSON序列化问题
订单时间字段在时区显示上出现过偏差。数据库存的是DATETIME,Java实体用java.util.Date,默认JSON序列化输出的是时间戳数字。前端拿到的是时间戳,需要自己格式化,但不同浏览器对时间戳的解析结果有差异,容易显示错一天。后来统一配置了Jackson:
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializerByType(Date.class, new DateSerializer(false, new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))); }; }同样的时间,如果没有明确时区,前后端各解释各的就会差8小时。这个问题在论文的“关键技术难点”里也可以作为一个实际案例来写,体现你遇到过并解决了问题。
6. 论文写作与答辩准备的经验
这个部分可能看起来和“系统实现”关系不大,但既然你的标题带着“论文”两个字,我多说几句实际经验。
6.1 论文结构怎么组织
我最终采用的论文目录结构是:绪论(背景、意义、国内外现状)、相关技术介绍(J2EE、SSM、MySQL)、系统分析(可行性分析、需求分析、用例图)、系统设计(总体架构、功能模块设计、数据库设计)、系统实现(每个模块的代码和截图)、系统测试(功能测试、性能测试)、总结与展望。
这里比较重要的是“系统设计”这一章不要只贴表格,要有你自己的决策过程。比如为什么药品表和库存表要分离、为什么防超卖要用条件更新而不是悲观锁,这些决策理由才是论文的核心价值。技术选型章节不要写太长,三页足够,重点放在业务上。
6.2 测试数据和你需要准备的截图
答辩时老师一定会问系统测试部分的数据。我准备了三种:
- 功能测试用例表:用例编号、模块、操作步骤、预期结果、实际结果、是否通过。
- 并发测试数据:JMeter测试的线程数、吞吐量、响应时间、超卖是否发生。
- 业务场景测试:完整走一遍“提交订单→审核→支付→发货→收货→退货”的全流程截图。
截图要提前整理好,每个核心界面至少一张,包括:药品列表页、库存流水页、订单审核页、角色权限配置页、统计报表页。论文里图片不要用模糊的手机照片,用清晰的截图,标注好图号。
6.3 答辩时最容易被问的问题
我把自己被问到和同学被问到的问题做一个高频清单,提前准备答案会从容很多:
- SSM框架的请求流程是什么?
- Spring的事务传播机制有哪些?你项目里用到了哪个?
- MyBatis的
#{}和${}有什么区别? - 如何防止超卖?你的库存扣减SQL是怎么写的?
- 你们系统怎么保证批号效期管理的准确性?
- 如果分销商下单后一直不支付,订单怎么处理?
- 数据库为什么要分药品表和库存表,而不是合并成一张表?
- 分类表如果要做无限级分类,你的设计怎么扩展?
这些问题全部能从你的实际项目代码里找到答案,关键是不要背概念,要把代码和概念对应起来讲。比如问到#{}和${}的区别,你就说“我在权限模块的菜单排序里用了${},因为ORDER BY不支持占位符参数绑定,但其他所有查询条件我都是用#{}防止SQL注入”。
我在这套系统上投入的时间大概是这样:需求分析和数据库设计两周,框架搭建和联调两周,功能模块实现三周,测试和改bug一周,论文写作和答辩准备两周。如果你也是一个人从零开始做,这个时间预算可以参考,建议不要压缩数据库设计和需求分析的环节,这部分做扎实了,后面的代码只是照图施工而已。
最后分享一个个人体会:做一个完整的SSM项目,最大的收获不是“我学会了三个框架”,而是你看清了一个请求从浏览器到数据库再返回的完整链路,以及业务系统中“数据一致性”这四个字到底意味着什么。这些经验在你以后不管用Spring Boot还是其他技术栈,都会一直有用。