
在这里输入内容大家聊到 Java 课程设计或者毕业设计的时候出现频率最高的几个词往往是“商城系统”“SSM”“源码文档”。我自己做过的几个 SSM 项目里在线药品销售系统算是最有代表性、也最值得拿出来梳理的一个。原因很简单它不像普通图书商城或者零食商城那样只做简单的增删改查药品这个品类天然带着有效期、库存批次、分类层级、购买数量限制这些额外规则业务上比一般电商系统更复杂用来作为 SSM 框架的练手项目非常合适。这篇文章我会把整个系统的设计思路、表结构、核心代码和调试经验完整拆开讲跟着走一遍你做同类系统的时候就能少踩很多坑。如果你正准备写 Java 课程设计或者想把 SSM 整合能力练扎实那这套在线药品销售系统完全可以当作一个可复用的骨架。系统的定位就是“面向 C 端用户的药品商城 面向管理员的后台管理”前台负责注册、登录、浏览药品、加购物车、下单、支付模拟、查看订单后台负责药品分类维护、药品上下架、库存管理、订单处理和会员管理。源码 文档 调试三个部分源码是结构清晰的 SSM 项目文档包含需求分析、数据库设计说明和操作手册调试部分则把从环境搭建到常见报错的完整过程都覆盖到了。1. 项目概述这个药品商城系统做了什么1.1 为什么是 SSM 而不是 Spring Boot很多同学在选题时纠结“都什么年代了为什么不直接用 Spring Boot” 我理解这个想法但 SSM 作为课程设计和毕业设计的价值恰恰在于它“还处于手工拼装阶段”。SSM Spring SpringMVC MyBatis三个框架各自负责一件事你需要自己把这三层通过配置文件串起来这个过程走一遍之后你对 Spring 容器、Bean 生命周期、MVC 请求流转、SQL 映射这些底层机制的理解会深很多。Spring Boot 自动配置帮忙省掉的步骤在 SSM 里全部要自己面对这其实是提升内功的好机会。另外很多高校的课程大纲里SSM 仍然是教学重点。老师验收的时候主要看的是你对框架整合逻辑是否清晰而不是单纯看功能跑不跑得起来。所以这个项目选了 SSM一方面贴合教学要求另一方面它足够轻量一个 Tomcat 跑到老内存占用很稳定不挑电脑配置。1.2 系统角色与功能模块拆解整个系统的用户角色分两类前台消费者和后台管理员。消费者不需要登录也能浏览药品和分类但加购物车和下单必须登录管理员进入后台需要单独的角色校验前台用户没有权限访问后台接口。前台功能包括用户注册与登录注册时做用户名唯一性校验和密码加密存储药品列表展示支持按分类筛选、按药品名称关键字搜索、按价格排序药品详情页展示药品图片、通用名、规格、生产厂家、有效期、库存数量购物车模块支持添加、减少数量、删除选中项、批量结算确认订单页面展示收货人信息、订单金额、生成订单后模拟支付个人中心查看订单列表、订单详情和取消订单后台功能包括管理员登录认证基于拦截器做权限控制药品分类管理分类采用父子级结构删除父分类时检查是否还有子级和药品药品管理增加、编辑、上下架药品上架时校验有效期和库存数量库存管理采购入库和销售出库都会关联更新库存并记录操作日志订单管理查看全部订单、按订单号查询、发货、取消订单会员管理重置用户密码、禁用异常账号主流程就是“注册 → 登录 → 搜药 → 加入购物车 → 结算下单 → 模拟支付 → 后台发货”整个闭环把电商系统的核心环节都能覆盖到。2. 技术架构与核心设计思路2.1 SSM 三层架构的分工协作SSM 里三个框架的分工我用一句话概括Spring 管对象SpringMVC 管请求MyBatis 管数据库。业务代码写起来是三层Controller 层接收请求、封装参数、返回视图或 JSONService 层处理业务逻辑事务注解一般加在这一层Mapper 层负责 SQL 操作只做数据读写。在整合过程中最核心的是三个配置文件web.xml 配置 DispatcherServlet 和 ContextLoaderListenerapplicationContext.xml 配置数据源、事务管理器、Mpper 扫描spring-mvc.xml 配置控制器扫描、视图解析器、注解驱动。这就像装修房子Spring 是水电布线SpringMVC 是房门入口MyBatis 是各房间的插座面板三者都接好整体才能通电。项目里我没有使用 MyBatis 的 XML Mapper 映射文件而是用的注解方式理由是药品销售系统的 SQL 不算特别复杂注解写起来更紧凑阅读代码时不用来回切换文件。如果以后数据量大了需要动态 SQL 拼接再改成 XML 也不迟。这里给个建议表连接超过三张或者需要频繁拼条件查询时优先用 XML否则注解更高效。2.2 数据库表设计与关联关系数据库设计是这套系统里我花时间最多的地方因为药品销售涉及的数据表之间有很强的关联约束。我把所有表列出来方便你对照着看。表名说明关键字段关联关系user前台用户表id, username, password, real_name, phone, address, status与订单表关联admin管理员表id, username, password, real_name, last_login_time独立category药品分类表id, name, parent_id, sort自关联drug药品表id, category_id, name, generic_name, specification, manufacturer, unit, price, stock, image, production_date, expiry_date, is_prescription, status多对一关联 categorycart购物车表id, user_id, drug_id, quantity, checked联合关联 user 和 drugorders订单主表id, order_no, user_id, total_amount, receiver_name, receiver_phone, receiver_address, status, create_time, pay_time, ship_time一对多关联明细表order_item订单明细表id, order_id, drug_id, drug_name, drug_image, drug_spec, price, quantity, subtotal多对一关联 ordersstock_log库存日志表id, drug_id, change_type, change_quantity, after_stock, operator_id, create_time多对一关联 drug药品表里有两个字段特别值得说明。第一个是expiry_date药品有效期我在列表页和详情页都做了一个标识如果距离有效期不足 30 天列表里会出现“临期”标签防止消费者不注意买到快过期的药品。第二个是is_prescription用于标记是否为处方药当前系统只做信息展示层面的提示不涉及真实合规流程演示项目用模拟数据即可。你如果要扩展成真实商业项目这里会涉及更复杂的资质审核流程但作为课程设计理解这个字段的作用就够了。订单表里我特意设计了冗余字段drug_name、drug_image、drug_spec。按照数据库三范式这些信息应该通过drug_id关联查询但从业务角度讲订单是历史快照如果药品改名、下架甚至被删除了订单里依然要保留用户当时购买的商品信息所以这里用空间换时间刻意设计了冗余。2.3 药品库存与订单状态的流转逻辑在线药品销售系统最核心的业务闭环是加购物车 → 下单 → 扣库存 → 支付 → 发货。这里有一个特别需要注意的地方就是库存扣减的时机。我在系统里选择“提交订单、创建订单记录”的同时就锁定库存而不是等支付成功后再扣。原因是药品本身是标准化商品用户下单后如果发现库存不足体验极差所以提前锁定库存能避免超卖。对应的 Service 层代码长这样我用Transactional保证整个方法的事务一致性任何一个环节出错都会回滚Override Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long[] cartIds, String receiverName, String receiverPhone, String receiverAddress) { ListCart cartList cartMapper.selectByIds(cartIds); if (cartList.isEmpty()) { throw new BusinessException(购物车数据无效); } String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setReceiverName(receiverName); order.setReceiverPhone(receiverPhone); order.setReceiverAddress(receiverAddress); order.setStatus(0); order.setCreateTime(new Date()); BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItemList new ArrayList(); for (Cart cart : cartList) { Drug drug drugMapper.selectById(cart.getDrugId()); if (drug null || drug.getStatus() ! 1) { throw new BusinessException(商品已下架: cart.getDrugId()); } if (drug.getStock() cart.getQuantity()) { throw new BusinessException(库存不足: drug.getName()); } int rows drugMapper.decreaseStock(cart.getDrugId(), cart.getQuantity()); if (rows 0) { throw new BusinessException(库存扣减失败请重试); } // 组装明细......此处省略中间填充代码 totalAmount totalAmount.add(item.getSubtotal()); } order.setTotalAmount(totalAmount); orderMapper.insert(order); for (OrderItem item : orderItemList) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } cartMapper.deleteByIds(cartIds); return order; }这段代码里的drugMapper.decreaseStock对应这样的 SQL用条件更新防止超卖UPDATE drug SET stock stock - #{quantity}, version version 1 WHERE id #{drugId} AND stock #{quantity}stock #{quantity}这个条件非常关键它保证了并发场景下不会出现把库存扣成负数的情况。如果你用的是普通的update语句先查再改在高并发测试下立刻就会暴露超卖问题。关于这一点我在第 4 章还会专门展开讲。库存扣减成功后还需要插入一条stock_log记录方便后续排查库存变动轨迹。订单状态我用了一个Integer类型的status字段0 代表待支付1 代表已支付待发货2 代表已发货3 代表已完成4 代表已取消。每次状态变更都会更新对应的时间字段。这样做的好处是状态机清晰代码里只需要用if/else判断当前状态是否允许跳转到目标状态即可。3. 实操过程从环境搭建到核心功能实现3.1 环境准备与项目骨架搭建我用的开发环境是目前 Java 课程里最常见的组合JDK 8、Maven 3.6.3、Tomcat 8.5、MySQL 5.7开发工具任选 IDEA 或者 Eclipse。为什么不用 JDK 11 或者 17因为很多学校机房和企业里的老项目还停留在 JDK 8用太新的版本在配置 Tomcat 和某些旧版依赖时反而容易出现兼容问题课程设计图的就是一个稳定。项目结构上我按标准的 Maven Web 工程划分src ├── main │ ├── java │ │ └── com/xxx/pharmacy │ │ ├── controller │ │ ├── service │ │ ├── dao │ │ ├── entity │ │ │ └── common通用返回结果、分页、异常处理 │ ├── resources │ │ ├── springspring配置、springmvc配置 │ │ ├── mybatismybatis配置 │ │ ├── jdbc.properties │ │ └── log4j.properties │ └── webapp │ ├── WEB-INF │ │ ├── viewsJSP页面按模块分文件夹 │ │ └── web.xml │ ├── staticcss、js、images │ └── index.jsp这里最容易被忽视的是静态资源处理。如果你的spring-mvc.xml里配置了mvc:resources映射那么需要分别指定css、js、images等目录否则页面引用的static/css/style.css会被 DispatcherServlet 拦截导致加载不出来。我一开始就吃过这个亏页面结构全部正常但样式全丢控制台也不报错排查了半天才发现是静态资源没放行。3.2 Spring 和 MyBatis 整合的关键配置Spring 和 MyBatis 整合的入口是applicationContext.xml这部分配置决定了整个项目能不能跑起来。我把几个容易踩坑的点特别说明一下先看数据源配置我用的数据库连接池是阿里巴巴的 Druid配置在jdbc.properties里jdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/pharmacy?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456注意 URL 里的serverTimezone参数MySQL 5.7 和 8.x 的驱动版本不同如果缺了时区参数很容易在连接时报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这种错误。另外useSSLfalse是必要的本地开发环境没有配置 SSL 证书时不关掉会一直报警告。然后是 MyBatis 的 SqlSessionFactory 配置这里必须同时指定typeAliasesPackage和mapperLocationsbean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.pharmacy.entity/ property namemapperLocations valueclasspath:mybatis/mapper/*.xml/ /bean如果你的 Mapper 接口和 XML 映射文件不在同一个包名下就必须在 Mapper 接口上用Mapper注解并在 Spring 配置中加MapperScannerConfigurer扫描否则启动会报Invalid bound statement (not found)。这个错误是 SSM 项目里最经典的问题之一核心原因就是 Mapper 接口没有扫描到对应的 XML。3.3 SpringMVC 配置文件与请求流转spring-mvc.xml里除了包扫描之外最重要的两个配置是注解驱动和视图解析器mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.StringHttpMessageConverter property namesupportedMediaTypes list valuetext/html;charsetUTF-8/value valueapplication/json;charsetUTF-8/value /list /property /bean bean classorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter/ /mvc:message-converters /mvc:annotation-driven bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /beanInternalResourceViewResolver的 prefix 和 suffix 决定了 Controller 里返回字符串时对应到哪个 JSP。比如return drug/list实际访问的是/WEB-INF/views/drug/list.jsp。这里有一个隐藏的好处JSP 页面放在WEB-INF目录下客户端无法直接通过 URL 访问安全性更好所有页面跳转都必须经过 Controller这样更容易控制权限。请求流转路径就是标准的用户请求 → DispatcherServlet → HandlerMapping → Controller → Service → Mapper → MySQL → 视图解析 → 响应。SSM 项目调试时的一个核心技巧是在这个链条里任何一个环节出错症状都不一样。Controller 没扫到返回 404Service 里的 Bean 没注入启动报 NullPointerExceptionMapper 的 SQL 写错运行时报 SQLException。3.4 核心功能实现注册登录与拦截器用户注册登录这部分我特别强调一个点密码不要明文存储。密码学层面的原理这里不展开讲直接用 MD5 加盐是最常见的做法。我写了一个MD5Util工具类盐值用的是用户名加固定字符串的拼接这样即使用户设置了相同的密码只要用户名不同最终落库的密文也不同。登录成功后我直接通过 session.setAttribute(loginUser