简介:一套基于SSM框架的药房管理系统毕业设计项目源码与数据库脚本,面向计算机相关专业毕业生、正在准备毕设答辩的学生,以及需要完整项目练手的中级Java学习者。项目经导师指导并获九十八分评审,源码全部经过本地编译与严格调试,确保可以运行,且内容经过助教老师审定。资源包共1222个文件,约19.56MB,包含48个Java核心类、70个JSP页面、大量前端样式脚本与图片资源,并提供SQL数据库脚本与XML配置,可覆盖药品分类管理、库存预警、供应商与客户信息管理、销售统计等典型药房业务模块。前端采用EasyUI、Bootstrap等组件库,界面整洁,便于阅读与二次开发。目前已有174人学习下载,适合作为课程设计、毕业设计及项目实战参考,导入开发工具即可运行,快速搭建一套完整可演示的药房管理系统,也可作为SSM框架整合学习的进阶案例。
1. Java毕设选题里的SSM药房系统:为什么说它是后台管理最稳的模板
每年毕设季,都会有一批Java方向的同学拿着“基于SSM框架的药房管理系统源码+数据库脚本”来问我,这个题目能不能做、好不好做。我的答案通常是:好好做,这是后台管理系统里最稳的一类高分毕设。它没有推荐算法的玄学成分,也没有分布式项目的部署负担,却把SSM框架的知识覆盖得很完整——Spring管对象、Spring MVC管请求、MyBatis管数据库,再配上一份像样的数据库脚本和几个核心业务模块,就是一个标准的高分项目模板。适合基础一般但想稳拿成绩的人,也适合已经在工作、想补一套SSM完整链路的人。下面按我做项目的顺序,把框架选型、数据库设计、源码实现、排错和答辩经验一次讲透。
2. SSM框架组合与工程结构:三件套怎么分工,源码包怎么搭
2.1 Spring、Spring MVC、MyBatis的分工边界:背下这张职责表
SSM三个字母拆开是Spring、Spring MVC、MyBatis。很多同学上来就问:“现在工作都写Spring Boot了,毕设为什么还折腾SSM?”我的理解是:Spring Boot替你做掉了大量自动配置,而SSM把底层的IoC容器、AOP切面、MVC分发链路全部暴露给你手写。老师要看的恰恰是这个——你能不能说清楚一次HTTP请求怎么从Controller走到Mapper,事务在哪个环节生效,Bean是怎么被管理起来的。
把三个框架放到药房系统里,职责边界其实非常清楚:
| 层 | 核心组件 | 在药房管理系统里的具体职责 |
|---|---|---|
| 表现层 | Spring MVC | 接收登录、药品增删改查、销售录入的HTTP请求,交给Controller,再渲染JSP |
| 业务层 | Spring | 管理Service实现类,声明式事务处理销售单和库存扣减,库存预警的业务判断 |
| 持久层 | MyBatis | 把Java对象映射成SQL参数,把查询结果映射成Drug、Sales等实体 |
这一层关系搞不清楚,后面写代码一定会乱。最常见的问题是新手在Controller里直接注入Mapper,看起来跑通了,但事务、参数校验、日志切面全都绕过Spring管理,答辩时一问事务隔离级别就露馅。
2.2 版本选型与Maven依赖:JDK 8、Spring 5.x、MyBatis 3.5.x
SSM组合最好锁JDK 8。别追JDK 17,很多老依赖在JDK 17上会因为模块化或者反射访问限制直接翻车。毕设要的不是最新,是最稳。我常用的版本组合是Spring 5.2.x + Spring MVC 5.2.x + MyBatis 3.5.x + mybatis-spring 2.0.x,数据库用MySQL 8.0或5.7都行。pom.xml里需要引入一组相互兼容的坐标,重点看注释里的坑。
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <spring.version>5.2.25.RELEASE</spring.version> <mybatis.version>3.5.10</mybatis.version> </properties> <dependencies> <!-- Spring 核心、MVC、JDBC 三个模块缺一不可 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- mybatis 与 mybatis-spring 必须配套,mybatis-spring 2.0.x 对应 mybatis 3.5.x --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- MySQL 8 必须用 com.mysql.cj.jdbc.Driver,5.7 建议用 5.1.49 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <!-- 连接池用 Druid,后面排查连接问题时能拿到更多监控信息 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> </dependencies>依赖版本千万别全部都写“最新版”。Spring 6.x和MyBatis 3.5.x混用、mybatis-spring 3.0.0配合Spring 5.2都会出现启动时Bean创建异常。锁版本,锁JDK 8,这是SSM项目跑起来的第一原则。
2.3 工程目录与三层代码结构:Controller-Service-Mapper怎么分
单模块Maven项目足够。目录结构直接决定答辩老师的第一印象,我看到太多人把几百行代码堆在Servlet里,那种项目连看第二眼的欲望都没有。标准做法是严格分包。
pharmacy-ssm/ ├── pom.xml └── src/main/ ├── java/com/pharmacy/ │ ├── controller/ # 只做参数接收和视图跳转 │ │ ├── LoginController.java │ │ └── DrugController.java │ ├── service/ # 业务接口 + 实现,事务注解写这里 │ │ ├── DrugService.java │ │ └── impl/DrugServiceImpl.java │ ├── mapper/ # MyBatis的Mapper接口,一个接口对应一个业务实体 │ │ ├── DrugMapper.java │ │ └── DrugMapper.xml │ ├── entity/ # 数据库表对应的JavaBean │ │ ├── Drug.java │ │ ├── User.java │ │ └── Sales.java │ ├── interceptor/ # 登录拦截器、操作日志拦截器 │ │ └── LoginInterceptor.java │ └── config/ ├── resources/ │ ├── jdbc.properties │ ├── mybatis-config.xml │ ├── spring/applicationContext.xml │ └── spring/spring-mvc.xml └── webapp/ ├── WEB-INF/views/ # JSP页面 └── static/ # CSS、JS、图片Spring容器和Spring MVC容器要分开。主流做法是applicationContext.xml扫描service、mapper、数据源;spring-mvc.xml只扫描controller。我在applicationContext里配置数据源、SqlSessionFactory、事务管理器,再把Mapper接口扫描出来:
<!-- 数据库连接参数 --> <context:property-placeholder location="classpath:jdbc.properties"/> <!-- Druid 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- SqlSessionFactory:mapper.xml 放在 classpath:mapper/ 下 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.pharmacy.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <!-- 自动扫描 Mapper 接口 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.pharmacy.mapper"/> </bean> <!-- 事务管理器,注解驱动 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>这里最容易被忽略的是MapperScannerConfigurer。没有它,每个Mapper接口都得手写bean,写十个药品相关的方法就得配十次,完全没必要。扫描包路径必须和接口包一致,否则启动时直接报找不到SqlSessionFactory。事务管理器DataSourceTransactionManager的dataSource必须和SqlSessionFactory用的dataSource是同一个,这是新手最容易踩的事务失效原因。
3. 药房数据库脚本:建库建表、初始化数据与外键索引的设计细节
3.1 核心表怎么拆:用户、药品、供应商、销售单缺一不可
数据库脚本是一份毕设的隐形评分点。药房管理系统最少要覆盖六张表:用户表sys_user、药品分类表drug_category、药品信息表drug、供应商表supplier、销售主表sales、销售明细表sales_item。药品和供应商是多对一,一张销售单对应多行明细,明细里既要关联drug_id,又要冗余药品名称和规格作为快照。
为什么明细表要冗余快照?因为药品价格和名称是会变的,半年后再翻一张三个月前的销售单,如果上面只有drug_id,显示的可能是调价后的价格,这笔账就对不上。把drug_name、specification、sale_price冗余进明细表,虽然违反第三范式,但这是零售行业的标准做法,答辩时主动讲出来能加分。
3.2 建库建表与初始化数据的完整SQL脚本
-- 建库:字符集必须用 utf8mb4,utf8 在 MySQL 8 里存生僻字和图标会报错 CREATE DATABASE IF NOT EXISTS pharmacy_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE pharmacy_db; -- 系统用户表 DROP TABLE IF EXISTS sys_user; CREATE TABLE sys_user ( user_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码,演示项目建议存MD5', real_name VARCHAR(50) NOT NULL COMMENT '姓名', role VARCHAR(20) NOT NULL DEFAULT 'ADMIN' COMMENT '角色:ADMIN-管理员', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='系统用户表'; -- 药品分类表 DROP TABLE IF EXISTS drug_category; CREATE TABLE drug_category ( cate_id INT AUTO_INCREMENT PRIMARY KEY, cate_name VARCHAR(50) NOT NULL UNIQUE COMMENT '分类名称' ) ENGINE=InnoDB COMMENT='药品分类表'; -- 药品信息表:把库存数量和预警阈值放同一张表,查询预警直接比较两个字段 DROP TABLE IF EXISTS drug; CREATE TABLE drug ( drug_id INT AUTO_INCREMENT PRIMARY KEY, drug_code VARCHAR(30) NOT NULL UNIQUE COMMENT '药品编码', drug_name VARCHAR(100) NOT NULL COMMENT '药品名称', cate_id INT NOT NULL COMMENT '分类ID', specification VARCHAR(100) NOT NULL COMMENT '规格,如0.25g*36粒', dosage_form VARCHAR(30) NOT NULL COMMENT '剂型:片剂/胶囊/注射液', manufacturer VARCHAR(100) COMMENT '生产厂家', produce_date DATE NOT NULL COMMENT '生产日期', expire_date DATE NOT NULL COMMENT '有效期至', purchase_price DECIMAL(10,2) NOT NULL COMMENT '进价', sale_price DECIMAL(10,2) NOT NULL COMMENT '零售价', stock_quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存', warning_threshold INT NOT NULL DEFAULT 20 COMMENT '库存预警阈值', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在售 0停售', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_drug_name (drug_name), CONSTRAINT fk_drug_category FOREIGN KEY (cate_id) REFERENCES drug_category (cate_id) ) ENGINE=InnoDB COMMENT='药品信息表'; -- 供应商表 DROP TABLE IF EXISTS supplier; CREATE TABLE supplier ( supplier_id INT AUTO_INCREMENT PRIMARY KEY, supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称', contact_person VARCHAR(50) COMMENT '联系人', phone VARCHAR(20) COMMENT '联系电话', address VARCHAR(200) COMMENT '地址', status TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB COMMENT='供应商表'; -- 销售主表 DROP TABLE IF EXISTS sales; CREATE TABLE sales ( sales_id INT AUTO_INCREMENT PRIMARY KEY, sales_no VARCHAR(30) NOT NULL UNIQUE COMMENT '单号,如XS20250101001', user_id INT NOT NULL COMMENT '操作员ID', customer_name VARCHAR(50) COMMENT '顾客姓名', total_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '应收金额', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_create_time (create_time), CONSTRAINT fk_sales_user FOREIGN KEY (user_id) REFERENCES sys_user (user_id) ) ENGINE=InnoDB COMMENT='销售主表'; -- 销售明细表:冗余药品名称、规格、单价快照 DROP TABLE IF EXISTS sales_item; CREATE TABLE sales_item ( item_id INT AUTO_INCREMENT PRIMARY KEY, sales_id INT NOT NULL, drug_id INT NOT NULL, drug_name VARCHAR(100) NOT NULL COMMENT '药品名称快照', spec VARCHAR(100) COMMENT '规格快照', unit_price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', quantity INT NOT NULL COMMENT '数量', amount DECIMAL(10,2) NOT NULL COMMENT '小计金额', CONSTRAINT fk_item_sales FOREIGN KEY (sales_id) REFERENCES sales (sales_id), CONSTRAINT fk_item_drug FOREIGN KEY (drug_id) REFERENCES drug (drug_id) ) ENGINE=InnoDB COMMENT='销售明细表';初始化数据一定要有,而且要精心设计。管理员账号默认admin/123456,密码存MD5。药品数据里故意放一批库存低于预警阈值的,比如库存只剩10盒、阈值20的布洛芬,这样你上讲台打开首页就能直接演示库存预警效果,不用现场改数据库。
INSERT INTO sys_user (username, password, real_name, role) VALUES ('admin', MD5('123456'), '系统管理员', 'ADMIN'), ('pharmacist', MD5('123456'), '值班药师', 'PHARMACIST'); INSERT INTO drug_category (cate_id, cate_name) VALUES (1, '抗生素类'), (2, '感冒用药'), (3, '心脑血管类'); INSERT INTO drug (drug_code, drug_name, cate_id, specification, dosage_form, manufacturer, produce_date, expire_date, purchase_price, sale_price, stock_quantity, warning_threshold) VALUES ('DR001', '阿莫西林胶囊', 1, '0.25g*36粒', '胶囊剂', '华北制药', '2024-01-15', '2026-01-14', 8.50, 12.00, 150, 30), ('DR002', '布洛芬缓释胶囊', 2, '0.3g*20粒', '胶囊剂', '中美史克', '2024-02-01', '2026-01-31', 15.00, 19.80, 10, 20);注意一点:外键约束会拖慢插入速度。但毕设数据量在几千条以内,外键带来的完整性收益远大于性能损失,所以销售明细表我保留了两条外键。实际生产系统里,这类大表一般会去掉数据库外键,把完整性校验放到Service层,这是答辩时别人问“你有没有考虑过性能”时你能答上来的内容。
3.3 外键、索引和字符集:数据库脚本里的三个细节
字符集和排序规则是脚本里最容易被忽视的地方。MySQL 8.0默认字符集已经是utf8mb4,但如果你的脚本在5.7上跑,建库时不指定,表就会用系统默认的latin1,中文全变问号。所以我每次建库都显式写DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,不给环境发挥的空间。
索引不用建太多。药品表的drug_name加普通索引,销售表的create_time加索引,销售单号本身有UNIQUE约束,这三处就是SSM项目的够用配置。每个数据表都建五六个索引只会让优化器犯难,答辩时反而说不清楚每个索引解决什么问题。我一般会在sale_time这种范围查询字段上建索引,因为销售统计的SQL必然会按日期范围过滤。
数据库脚本最好再带一个batch_data.sql放批量数据,方便扩展演示。比如Excel导入药品库存这种事,有数据支撑演示效果完全不同。
4. SSM核心源码实现:从登录拦截到库存预警的完整代码路径
4.1 登录与权限拦截的源码实现
登录模块是整个系统演示的第一步,也是老师最可能打断你问“权限是怎么控制的”的地方。我在登录上做两层:Controller负责收参数、开Session,拦截器负责所有后续请求的准入判断。
@Controller public class LoginController { @Autowired private UserService userService; @RequestMapping("/login") public String login(String username, String password, HttpSession session) { User user = userService.login(username, password); if (user == null) { // 登录失败,回到登录页并带一个error标记 return "redirect:/toLogin?error=1"; } session.setAttribute("loginUser", user); // 登录成功直接进首页,首页会展示药品列表和库存预警 return "redirect:/index"; } @RequestMapping("/logout") public String logout(HttpSession session) { session.invalidate(); return "redirect:/toLogin"; } }@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public User login(String username, String password) { // 库里存的是MD5,登录时把明文加密后再比对 String encodedPwd = DigestUtils.md5DigestAsHex( password.getBytes(StandardCharsets.UTF_8)); return userMapper.selectByUsernameAndPassword(username, encodedPwd); } }密码字段在数据库里存的是MD5后的密文,这是毕设级项目的及格线。如果你想让分数再高一点,可以在答辩时说:“生产环境我不会直接用MD5,我会用BCrypt加盐,MD5在彩虹表攻击面前就是裸奔。”这一句话就能让老师知道你不只是把代码敲了一遍。
拦截器的核心逻辑在preHandle里:检查session里有没有用户,没有就重定向到登录页。特别注意要排除登录接口和静态资源,否则CSS和JS全部被拦死,页面只剩一堆无样式的HTML。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); if (session.getAttribute("loginUser") == null) { // 未登录的用户一律弹回登录页 response.sendRedirect(request.getContextPath() + "/toLogin"); return false; } return true; } }在spring-mvc.xml里注册拦截器时,用mvc:exclude-mapping把/login和/static/**排除掉,剩下的/**全部走拦截器。
4.2 药品CRUD与MyBatis分页查询的实现
药品管理是药房的主线业务,增删改查加搜索分页是标配。我在Controller里调Service,Service里调Mapper,严格三层。分页插件用PageHelper,这是SSM项目最常用的分页方式,没有之一。它靠MyBatis拦截器在SQL执行前自动拼接LIMIT,你只需要在查询前调用一句PageHelper.startPage。
@Controller @RequestMapping("/drug") public class DrugController { @Autowired private DrugService drugService; @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, String keyword, Model model) { PageInfo<Drug> pageInfo = drugService.findPage(pageNum, pageSize, keyword); model.addAttribute("page", pageInfo); model.addAttribute("keyword", keyword); return "drug/list"; } @RequestMapping("/save") @ResponseBody public Result save(@RequestBody Drug drug) { drugService.save(drug); return Result.success("保存成功"); } @RequestMapping("/delete") @ResponseBody public Result delete(Integer id) { drugService.delete(id); return Result.success("删除成功"); } }@Service @Transactional public class DrugServiceImpl implements DrugService { @Autowired private DrugMapper drugMapper; @Override public PageInfo<Drug> findPage(int pageNum, int pageSize, String keyword) { // startPage 必须紧跟Mapper查询,中间不能插入任何业务代码 PageHelper.startPage(pageNum, pageSize); List<Drug> list = drugMapper.selectByKeyword(keyword); return new PageInfo<>(list); } @Override public void save(Drug drug) { if (drug.getDrugId() == null) { drugMapper.insert(drug); } else { drugMapper.update(drug); } } @Override public void delete(Integer id) { Drug drug = drugMapper.selectById(id); if (drug == null) { throw new RuntimeException("药品不存在"); } // 有销售明细引用的药品不允许直接删除 int refCount = drugMapper.countSalesItemByDrugId(id); if (refCount > 0) { throw new RuntimeException("该药品存在销售记录,不允许删除"); } drugMapper.deleteById(id); } }注意delete方法的第二层保护:先查引用计数,有销售记录的药品直接删会违反外键约束,在用户界面上表现为白屏异常。用这个业务判断拦住,输出一个友好提示,这是操作经验,不是书本上教的。
对应的Mapper XML里,模糊搜索的LIKE拼接不要用“%”+keyword+"%"这种Java字符串拼接,会有SQL注入风险。MyBatis里推荐用CONCAT函数,配合#{}预编译参数:
<select id="selectByKeyword" parameterType="string" resultType="com.pharmacy.entity.Drug"> SELECT drug_id, drug_name, specification, dosage_form, manufacturer, sale_price, stock_quantity, warning_threshold, status FROM drug <where> <if test="keyword != null and keyword != ''"> drug_name LIKE CONCAT('%', #{keyword}, '%') OR drug_code LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY drug_id DESC </select>4.3 库存预警和销售统计:两个容易被答辩老师追问的点
库存预警的本质是两条:查询出库存数量小于等于阈值的药品,再决定前端怎么展示。查询很直白,但这里有个关键参数设计——预警阈值不是全局写死的,而是每盒药自己带一个warning_threshold字段,同一种药在不同门店可以设置不同阈值。我一般把阈值默认值设为20,管理员在药品编辑页面随时调整。
<select id="selectWarningDrugs" resultType="com.pharmacy.entity.Drug"> SELECT drug_id, drug_name, specification, stock_quantity, warning_threshold FROM drug WHERE status = 1 AND stock_quantity <= warning_threshold ORDER BY stock_quantity ASC </select>注意XML里的小于等于号必须写成<=,直接写<=会导致XML解析报错,这是MyBatis新手必踩的坑。
销售统计用SQL聚合就够了,不需要写存储过程。按天统计销售额,遇到日期格式问题时用DATE_FORMAT。这里有个隐藏坑:直接用BETWEEN且endDate传当天日期,会漏掉当天的数据。正确做法是把endDate加一天,或者用date_format(create_time, '%Y-%m-%d') <= endDate。
<select id="selectStatByDay" parameterType="map" resultType="com.pharmacy.entity.SalesStatVO"> SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS statDate, COUNT(*) AS saleCount, SUM(total_amount) AS totalAmount FROM sales WHERE create_time >= #{startDate} AND create_time < DATE_ADD(#{endDate}, INTERVAL 1 DAY) GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY statDate </select>日期范围查询用大于等于开始日期、小于结束日期加一天,是日期区间查询的标准左闭右开写法,能彻底避免BETWEEN的边界遗漏问题。
5. 避坑排查:SSM药房系统最常见的6个翻车现场与解决方法
5.1 数据库连接失败:驱动类、URL和密码三个坑
现象:Tomcat启动时控制台报错,日志里出现“Communications link failure”或“Access denied for user 'root'@'localhost'”。
原因:MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver,很多人还在用老版本的com.mysql.jdbc.Driver;URL里没带serverTimezone参数会报时区错误;密码里有@、#这类特殊字符时,properties文件解析会截断。
解决:驱动类换成cj包,URL加上useSSL=false&serverTimezone=Asia/Shanghai,密码里的特殊字符用反斜杠转义。顺手把allowPublicKeyRetrieval=true加上,否则MySQL 8默认的caching_sha2_password认证会报Public Key Retrieval错误。
5.2 Invalid bound statement:Mapper XML没打进classpath
现象:日志报“Invalid bound statement (not found): com.pharmacy.mapper.DrugMapper.selectByKeyword”,但接口、XML文件名都对。
原因:Maven默认不会把src/main/java下的XML文件打进classpath。你把DrugMapper.xml放在和接口同一个包,等于编译后运行目录里只有.class没有.xml,MyBatis自然找不到SQL。
解决:在pom.xml的build节点里把src/main/java下的xml和properties文件加进resources,spring-mvc的mapperLocations配成classpath:mapper/.xml,如果XML按包分散则配classpath:com/pharmacy/mapper/*.xml。
5.3 中文乱码:连接参数、表字符集和页面编码要一致
现象:插入数据库的中文变成“???”,或者JSP页面上显示一堆乱码。
原因:三层编码不一致。JDBC连接URL没加characterEncoding=utf8;建表时用了latin1字符集;JSP文件没设置pageEncoding也不声明contentType。
解决:URL加characterEncoding=utf8,建库建表统一utf8mb4,JSP头部加pageEncoding="UTF-8"。同时确认Tomcat的server.xml的Connector加了URIEncoding="UTF-8",这个容易漏,GET请求中文参数乱码多半是它。
5.4 PageHelper分页错乱:startPage必须紧跟Mapper查询
现象:第一页显示20条数据,点第二页还是那20条;或者PageInfo.getTotal()返回-1。
原因:PageHelper.startPage()和Mapper查询语句之间插了其他查询或拦截器代码,导致分页线程变量被下一次查询消费;多个数据源混用也会让分页服务判错方言。
解决:startPage()之后立即调Mapper查询,中间不允许有任何其他数据库操作。拿到PageInfo后再做数据加工。多数据源场景要把PageHelper的helperDialect在初始化参数里显式指定为mysql,否则自动检测可能拿错。
5.5 事务不生效:该检查的不是方法名,而是容器配置
现象:销售单插入主表成功,明细表插入失败,但主表数据还是落库了,没有回滚。
原因:@Transactional注解写在Controller方法上或者Service内部调用上,Spring的AOP代理根本拦不到这一步;更隐蔽的是数据源没配DataSourceTransactionManager,或者配置里用的dataSource和SqlSessionFactory用的不是同一个对象。
解决:注解放在Service实现类的public方法上,事务管理器dataSource一定ref指向同一个数据源。Spring容器扫描时避免把Controller也扫进applicationContext,否则两个容器各管一份Bean,事务代理生成混乱。
5.6 静态资源404:DispatcherServlet拦截范围没放开
现象:登录页HTML能打开,但所有CSS、JS加载失败,浏览器F12全是404。
原因:spring-mvc.xml里把DispatcherServlet映射到“/”,所有请求都进了Spring MVC,而Spring MVC默认不会处理静态资源,默认的DefaultServlet被覆盖掉了。
解决:spring-mvc.xml里加<mvc:resources mapping="/static/**" location="/static/"/>,或者改用 mvc:default-servlet-handler/ 交给Tomcat的DefaultServlet处理。前端页面里的资源引用路径建议用${pageContext.request.contextPath}/static/xxx,避免部署路径不同导致引用全错。
6. 部署验证与答辩加分:最后一天还能做的三件事
别把部署留到答辩前夜。我第一次带项目的血泪经验是:换了一台电脑,JDK版本从8变成11,Tomcat从8.5换到9,项目启动花了整整两个晚上。从那以后,我给每个SSM项目都固定一套环境清单。
先建一个干净的MySQL实例,按顺序执行建库脚本和初始化数据脚本,再改jdbc.properties里的账号密码。数据库能连通后,用mvn clean package打包,把war丢进Tomcat的webapps,启动后访问http://localhost:8080/pharmacy/。能出现登录页,说明环境链路是通的。
答辩演示有一条路线建议:先登录,打开首页,第一眼停在库存预警列表上,说清楚阈值逻辑;然后演示药品搜索,用模糊查询找到“布洛芬”,编辑它的库存数,让它从预警变成安全库存;最后切到销售统计页面,展示按日期的柱状图。这三步覆盖了SSM的请求分发、MyBatis动态SQL、事务和聚合统计,比从头到尾把每个CRUD都点一遍高效得多。
最后两个加分习惯值得临时补上:一是Controller统一返回Result对象,给前端返回code、msg、data三段式结构,而不是裸字段;二是加一个全局异常处理器,用@ControllerAdvice把RuntimeException统一转成友好提示,避免答辩现场一点就白屏。这两处改动代码量不大,却能让老师看到工程化意识。
把日常工作习惯带进毕设项目是我一贯的做法。哪怕只是一个药房管理系统,也要让每个方法有日志、每条SQL有参数说明、每次异常有响应。这套SSM项目做完,你以后写Spring Boot会发现,框架变了,但分层思想没变。希望帮到你。
本文还有配套的精品资源,点击获取