简介:这是一套基于JFinal与MyBatis的药品管理系统完整源码与数据库,面向计算机相关专业毕业设计或JavaWeb初学者。系统前端采用Html、jQuery、EasyUI搭建交互界面,后端由JFinal与MyBatis实现业务逻辑与数据持久化,内置管理员、内容运营、只读三类角色,覆盖药品供货管理、客户管理、库存管理、药品管理等核心模块。压缩包共595个文件,约1.65MB,包含98个Java源码、41个HTML页面、93个JavaScript脚本、192个CSS样式及137张PNG图片,另有2份SQL数据库脚本可直接导入MySQL,并附有环境配置说明。已有27人学习下载。借助该包可快速理解分层开发思路,掌握权限控制、增删改查及多表关联的完整实现,尤其适合需要快速搭建药品管理课题、撰写毕业设计文档的读者。
1. 为什么这套“源码 + 数据库”的药品管理系统值得逐行看
药品管理系统是 JFinal 生态里流传最广的课程设计题目之一,压缩包形态通常是「Java 源码 + 一个 .sql 数据库文件」。它的需求很固定:药品字典维护、按批次入库出库、库存不足与近效期预警、操作员登录。对 jfinal 入门者来说,这是第一个能同时看到路由、Model、事务和页面怎么串起来的完整例子;对有几年经验的开发,重点反而在工程层:MySQL 8 连接参数、Tomcat 版本匹配、导入 SQL 的注意事项。下面按数据库设计、增删改查、出入库事务、部署验证四步展开,每一步都给可直接抄的代码。
2. 药品管理系统的表结构与 JFinal 工程骨架
2.1 数据库表结构:六张核心表与库存批次拆分
先看数据库,这是整个系统的地基。最常见的错误设计是把库存数量直接放在药品表里,出入库时 update 一个字段,写起来简单,但一旦要支持批次号和近效期就完全没法扩展。我一般会拆成六张表:药品信息、库存、供应商、入库流水、出库流水、系统用户。核心建表语句如下。
CREATE TABLE drug_info ( id INT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(30) NOT NULL UNIQUE COMMENT '药品编码', drug_name VARCHAR(64) NOT NULL, pinyin_code VARCHAR(30) COMMENT '拼音助记码', spec VARCHAR(40) COMMENT '规格 如 0.25g*24片', unit VARCHAR(10) NOT NULL DEFAULT '盒', manufacturer VARCHAR(100), approval_number VARCHAR(40), price_cost DECIMAL(10,2) DEFAULT 0, price_retail DECIMAL(10,2) DEFAULT 0, stock_lower_limit INT DEFAULT 10 COMMENT '库存下限', status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE drug_stock ( id INT PRIMARY KEY AUTO_INCREMENT, drug_id INT NOT NULL, batch_no VARCHAR(40) COMMENT '批次号', produce_date DATE, expire_date DATE, quantity INT NOT NULL DEFAULT 0, KEY idx_drug_id (drug_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, sup_name VARCHAR(80) NOT NULL, contact VARCHAR(30), phone VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inbound_record ( id INT PRIMARY KEY AUTO_INCREMENT, drug_id INT NOT NULL, supplier_id INT, batch_no VARCHAR(40), quantity INT NOT NULL, purchase_price DECIMAL(10,2), operator VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_drug_id (drug_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sale_record ( id INT PRIMARY KEY AUTO_INCREMENT, drug_id INT NOT NULL, batch_no VARCHAR(40), quantity INT NOT NULL, sale_price DECIMAL(10,2), operator VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_drug_id (drug_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(32) NOT NULL, real_name VARCHAR(30) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;各司其职的关系是:drug_info回答“这是什么药”,drug_stock回答“某个批次的药还剩多少”,两张流水表负责记录每一次数量变化的来源和去向。这样查近效期、按批次出库、统计某供应商的进货量都只需要 join,不用改表结构。主键统一 int 自增,刻意不建外键约束,外键关系由 Service 层保证,避免在课程设计里最常见的“删除父表记录报外键错误”。drug_code加唯一索引,既是业务规则,也是代码里做去重判断的依据。
药品表里的pinyin_code是给收银端按拼音首字母搜药用。如果源码里没有自动生成逻辑,最简单的做法是新增保存时用一个小工具类做汉字转拼音,或者让用户手工填,不要在列表页实时转换。补充一个细节:drug_stock一定要给drug_id建普通索引,因为后续查询和for update都会走这个字段,没索引时 InnoDB 会锁住全表。
2.2 最小工程:用 jfinal-undertow 起一个能跑的入口
拿到压缩包先别急着往 Tomcat 里丢。现在的 JFinal 工程大多用 jfinal-undertow 做开发期容器,一个main方法就能起服务,不需要配置 web.xml。Maven 依赖里这一组是 jfinal 4.x 最常见的组合,版本号以你实际源码里的 pom 为准。
<dependency> <groupId>com.jfinal</groupId> <artifactId>jfinal</artifactId> <version>4.9.05</version> </dependency> <dependency> <groupId>com.jfinal</groupId> <artifactId>jfinal-undertow</artifactId> <version>2.6</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency>入口类只需要几行。
import com.jfinal.server.undertow.UndertowServer; public class DrugApplication { public static void main(String[] args) { UndertowServer.start(DrugConfig.class, 8080, true); } }UndertowServer.start第一参数是继承JFinalConfig的配置类,第二参数是端口,第三个true表示开发模式。为什么开发期不用 Tomcat?因为 Undertow 启动快、支持热加载,改 Java 代码自动重启,改页面文件即时生效,调接口效率高得多。页面统一放在src/main/webapp下,模板路径由baseViewPath决定,下一节配置里会看到。
2.3 MySQL 8 数据源与 ActiveRecord 插件注册顺序
配置类的方法执行顺序是固定的:configConstant→configRoute→configPlugin→configInterceptor→configHandler。数据库相关配置集中在下面这段。
public class DrugConfig extends JFinalConfig { public void configConstant(Constants me) { me.setDevMode(true); } public void configRoute(Routes me) { me.setBaseViewPath("/WEB-INF/views"); me.add("/drug", DrugController.class); me.add("/stock", StockController.class); } public void configPlugin(Plugins me) { DruidPlugin druid = new DruidPlugin( "jdbc:mysql://127.0.0.1:3306/drug_db" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai", "root", "你的密码", "com.mysql.cj.jdbc.Driver"); me.add(druid); ActiveRecordPlugin arp = new ActiveRecordPlugin(druid); arp.setShowSql(true); arp.addMapping("drug_info", "id", Drug.class); arp.addMapping("drug_stock", "id", DrugStock.class); arp.addMapping("inbound_record", "id", InboundRecord.class); arp.addMapping("sale_record", "id", SaleRecord.class); arp.addMapping("supplier", "id", Supplier.class); arp.addMapping("sys_user", "id", SysUser.class); me.add(arp); } }setShowSql(true)会把每条 SQL 打到控制台,后面调分页和事务时它是第一排错工具。addMapping的第一个参数是表名,第二个是主键名,第三个是 Model 类。主键配错会导致update()和deleteById()生成错误的 where 条件,这是从源码抄过来以后最容易被忽略的一行。插件注册顺序有讲究:必须先me.add(druid)再me.add(arp),插件是按添加顺序启动的,顺序反了 ActiveRecordPlugin 启动时数据源还没就绪,直接报空连接池。
连接串里characterEncoding=utf8是 MySQL 连接层的编码,库表字符集是另一个维度,两层都设成 utf8mb4 才能保证中文、emoji 不出乱码。MySQL 8 的驱动类必须是com.mysql.cj.jdbc.Driver,写成旧的com.mysql.jdbc.Driver会抛Cannot load driver class。下面是平时最常见的几类连接问题。
| 启动或运行时的现象 | 最常见根因 | 处理办法 |
|---|---|---|
| Cannot load driver class | 驱动类名写旧 | 换成com.mysql.cj.jdbc.Driver |
| 中文写入变问号 | 连接串缺编码参数 | 加useUnicode=true&characterEncoding=utf8 |
| Communications link failure | 缺时区参数或端口不通 | 加serverTimezone=Asia/Shanghai,确认 3306 |
| Table 'drug_db.drug_info' doesn't exist | 库建了但没导表 | 先执行源码附带的 sql 脚本 |
3. 药品信息增删改查:用 JFinal ActiveRecord 把数据库操作写薄
3.1 Model 定义与映射:先建表还是先写 Java 类
AR 模式下 Model 不需要写 getter/setter,一个空类就能用,这也是 JFinal 写增删改查比 SSM 快的原因。定义如下。
import com.jfinal.plugin.activerecord.Model; public class Drug extends Model<Drug> { public static final Drug dao = new Drug(); }dao是静态单例,只负责发起查询;要承载业务数据必须new Drug()。新手常见错误是把dao当普通对象用,dao.set("drug_name", ...)之后调用update(),发现影响行数是 0——因为dao上并没有主键,更新语句的 where 条件根本拼不出来。项目里表一多,可以用 JFinal 自带的Generator按表生成BaseDrug和Drug,表结构变更后重新生成即可,课程设计源码里大量采用这种方式,区别只是多了个BaseModel父类。
表单提交时,<input>的name必须和表字段一致,比如drug_name、drug_code,这样getModel(Drug.class)才能把请求参数整体塞进 Model。字段名对不上时不会报错,只是保存时那一列是 null。
3.2 列表分页与模糊查询:paginate 的参数顺序别记反
药品列表是后台管理页的门面,通常带关键字搜索和分页。JFinal 的paginate一行搞定。
public void index() { int pn = getInt("pn", 1); String kw = get("kw", "").trim(); Page<Drug> page = Drug.dao.paginate( pn, 10, "select *", "from drug_info where status = 1 " + "and (drug_name like ? or pinyin_code like ? or drug_code like ?) " + "order by drug_code", "%" + kw + "%", "%" + kw + "%", "%" + kw + "%"); setAttr("page", page); setAttr("kw", kw); render("drug/list.html"); }paginate的 5 个参数分别是:页码、每页条数、select 子句、from 子句(where 和 order by 都放这里)、查询占位符参数。JFinal 会先执行一条count(*)再查当前页数据,所以第一个参数只写select *,千万别把 join 出来的字段写进去,否则 count 语句直接报语法错误。搜索条件的 3 个占位符都要用%kw%包起来,这是模糊查询的标准姿势。页面里用 JFinal Template 引擎取值。
#for(drug : page.pageList) <tr> <td>#(drug.drug_code)</td> <td>#(drug.drug_name)</td> <td>#(drug.spec)</td> </tr> #endpage.pageList就是当前页的数据集合,#(expr)会自动转义输出。注意getInt("pn", 1)的第二个参数是默认值,用户没传页码时不会空指针,这是 JFinal Controller 参数取值的常用写法。
3.3 @Before 校验器:把表单错误拦在写库之前
保存药品如果只靠前端必填校验,绕过页面直接 POST 就会把脏数据写进库。JFinal 的Validator配合@Before注解可以做服务端校验,新建一个校验类。
import com.jfinal.core.Controller; import com.jfinal.kit.Ret; import com.jfinal.validate.Validator; public class DrugValidator extends Validator { protected void validate(Controller c) { validateRequiredString("drug_name", "nameMsg", "药品名称不能为空"); validateRequiredString("drug_code", "codeMsg", "药品编码不能为空"); validateRegex("price_retail", "^\\d+(\\.\\d{1,2})?$", "priceMsg", "零售价最多两位小数"); validateInteger("stock_lower_limit", 0, 99999999, "limitMsg", "库存下限只能是整数"); } protected void handleError(Controller c) { Ret ret = Ret.fail("msg", "表单校验未通过"); if (c.getAttr("nameMsg") != null) ret.set("drug_name", c.getAttr("nameMsg")); if (c.getAttr("codeMsg") != null) ret.set("drug_code", c.getAttr("codeMsg")); if (c.getAttr("priceMsg") != null) ret.set("price_retail", c.getAttr("priceMsg")); if (c.getAttr("limitMsg") != null) ret.set("stock_lower_limit", c.getAttr("limitMsg")); c.renderJson(ret); } }Controller 里在保存方法上打个注解即可。
@Before(DrugValidator.class) public void save() { Drug drug = getModel(Drug.class); boolean ok = (drug.getInt("id") == null) ? drug.save() : drug.update(); renderJson(ok ? Ret.ok("msg", "保存成功") : Ret.fail("msg", "保存失败")); }校验逻辑的执行时机在 action 方法之前,失败时根本不会进入save()。validate系列方法的第二个参数是错误信息的 key,校验失败后 JFinal 会把它作为 attr 存进 Controller,所以handleError里能用c.getAttr("nameMsg")取回具体错误。注意这里新增和修改共用一个save(),判断依据是id是否为空;而校验先于保存执行,所以它看到的是原始表单值,不会受保存操作影响。
ActiveRecord 的常用方法与 SQL 对应关系如下。
| Model 方法 | 实际执行的 SQL 关键部分 | 常用场景 |
|---|---|---|
save() | INSERT INTO drug_info (...) VALUES (...) | 新增药品 |
update() | UPDATE drug_info SET ... WHERE id=? | 修改药品 |
deleteById(id) | DELETE FROM drug_info WHERE id=? | 删除单条 |
findById(id) | SELECT * FROM drug_info WHERE id=? | 查单条回显表单 |
paginate(pn, ps, "select *", "from ...", args) | 先count(*)再LIMIT | 列表分页 |
4. 药品入库出库与库存扣减:用 Db.tx 把两条写操作绑成一次成功
4.1 入库处理:先写流水,再合并库存批次
入库动作包含两步:写inbound_record流水、更新drug_stock库存数量。任何一步失败都不能让另一边单独提交,否则演示时库存和流水对不上,这就是事务存在的理由。JFinal 的Db.tx支持 lambda 写法。
public void inbound() { boolean ok = Db.tx(() -> { Integer drugId = getInt("drug_id"); int qty = getInt("qty", 0); String batchNo = get("batch_no"); if (drugId == null || qty <= 0 || batchNo == null || batchNo.trim().isEmpty()) { return false; } new InboundRecord() .set("drug_id", drugId) .set("supplier_id", getInt("supplier_id")) .set("batch_no", batchNo) .set("quantity", qty) .set("purchase_price", getBigDecimal("purchase_price")) .set("operator", getSessionAttr("loginUser")) .save(); DrugStock stock = DrugStock.dao.findFirst( "select * from drug_stock where drug_id = ? and batch_no = ?", drugId, batchNo); if (stock == null) { new DrugStock() .set("drug_id", drugId) .set("batch_no", batchNo) .set("produce_date", get("produce_date")) .set("expire_date", get("expire_date")) .set("quantity", qty) .save(); } else { stock.set("quantity", stock.getInt("quantity") + qty).update(); } return true; }); renderJson(ok ? Ret.ok("msg", "入库成功") : Ret.fail("msg", "入库失败,请检查参数")); }Db.tx的回调里只要return false或抛出异常,事务整体回滚,两条写操作一起撤销。这里有两个坑要提醒。第一,回调最后一个动作必须显式return true,如果省略,lambda 会把最后一句update()的布尔返回值当结果,而update()在更新行数影响为 0 时返回 false,流水已经写进库却被整体回滚,表现就是“流水有记录但库存没变”。第二,同一药品同一批次要合并到一条库存记录,所以先查后插入或累加;批次为空时直接返回 false,避免产生一条没有批次的脏库存。
getInt("qty", 0)的默认值 0 在这里起到了防御作用,表单没传数量时事务直接失败,不会出现数量为 null 的流水。
4.2 出库扣减:for update 锁行,防止超卖
出库是药品系统的并发高危点。两个收银员同时卖同一盒药,不加锁时两边都查出库存 10,各自减 5 再写回,最终库存变成 5,实际却卖出了 10,这就是超卖。锁行的标准写法是for update。
public void sale() { boolean ok = Db.tx(() -> { Integer drugId = getInt("drug_id"); int qty = getInt("qty", 0); if (drugId == null || qty <= 0) { return false; } DrugStock stock = DrugStock.dao.findFirst( "select * from drug_stock where drug_id = ? and quantity > 0 " + "order by expire_date limit 1 for update", drugId); if (stock == null || stock.getInt("quantity") < qty) { return false; } stock.set("quantity", stock.getInt("quantity") - qty).update(); new SaleRecord() .set("drug_id", drugId) .set("batch_no", stock.get("batch_no")) .set("quantity", qty) .set("sale_price", getBigDecimal("sale_price")) .set("operator", getSessionAttr("loginUser")) .save(); return true; }); renderJson(ok ? Ret.ok("msg", "出库成功") : Ret.fail("msg", "库存不足")); }for update会把命中的行锁住,直到事务提交。同一药品的并发出库因此变成串行:第二个事务必须等第一个提交后才能读到数据,读到的是扣减后的真实库存,超卖条件不再成立。order by expire_date limit 1是药房必须遵循的 FEFO 策略——效期近的先出,避免先进先出把临期药压在库里。这个查询还有一个前提:drug_stock上drug_id必须有索引,否则 InnoDB 会升级成锁全表。
两种扣减策略的取舍如下。
| 策略 | 并发下的表现 | 适用场景 |
|---|---|---|
| 先查库存再普通 update,不加锁 | 两个请求读到相同库存,可能超卖 | 单机演示、无并发 |
select ... for update锁行后扣减 | 同一药品串行化,扣减准确 | 多人同时收银、真实环境 |
4.3 近效期与库存下限的预警查询
预警是药品系统区别于普通进销存的功能点。效期 90 天内的批次要能查出来,库存低于下限的药品要能列出来。这两个查询不需要 Model,Db.find返回的就是通用行记录。
import com.jfinal.plugin.activerecord.Record; public void expiryWarning() { List<Record> list = Db.find( "select d.drug_code, d.drug_name, d.spec, s.batch_no, s.quantity, s.expire_date " + "from drug_stock s join drug_info d on d.id = s.drug_id " + "where s.quantity > 0 and s.expire_date <= date_add(curdate(), interval 90 day) " + "order by s.expire_date"); renderJson(list); } public void lowStock() { List<Record> list = Db.find( "select d.drug_code, d.drug_name, d.stock_lower_limit, " + "ifnull(sum(s.quantity), 0) as total " + "from drug_info d left join drug_stock s on s.drug_id = d.id " + "where d.status = 1 group by d.id " + "having total <= d.stock_lower_limit"); renderJson(list); }第一个查询用date_add(curdate(), interval 90 day)计算出预警截止日期,比在 Java 里算好再传参数更直白,也方便以后改成 30 天或 180 天。第二个查询的价值在于left join加ifnull:没有库存记录的药品 total 是 0 而不是 null,否则这些新品会被total <= x的条件漏掉;having用来过滤聚合结果,where在这里不能引用total别名,这是 SQL 语法层面的硬限制。Record是 ActiveRecord 对单行结果的通用封装,多表 join 没有对应 Model 时都用它,前端模板里照样用#(r.drug_name)取值。
5. 药品管理系统部署到 Tomcat:三个必查点
5.1 Tomcat 用 9,不要用 10
JFinal 4.x 基于 javax.servlet 编译,Tomcat 10 起把包名换成了 jakarta.servlet,直接把 war 丢进 Tomcat 10 会报 ClassNotFoundException 或启动一半失败。判断源码是 javax 还是 jakarta,看任何一个 Java 文件里的 import 即可。课程设计源码绝大多数是 javax 时代写的,本地演示用 Tomcat 9.0.x 最稳;用 jfinal-undertow 方式打包的工程不受影响,因为它内置了 servlet 容器。
5.2 SQL 导入:先建库,再指定字符集,最后执行脚本
压缩包里的 .sql 文件有两种形态:带CREATE DATABASE和USE语句的可以直接整体导入;只有建表语句的必须先建库再导。推荐下面的顺序。
mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS drug_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p drug_db < drug_db.sql导完执行SHOW TABLES;确认六张表都在。连接串里 localhost 在部分带防火墙的机器上会被解析成 ::1 导致连不上,改成 127.0.0.1 更保险。如果后面改了表结构,记得两处同步:sql 脚本本身,以及对应的 Model 或 Generator 生成的 BaseModel,只改数据库不改代码,保存时程序会报列不存在。
5.3 对账验证:库存数字必须等于流水差
部署完别急着截图,先做一次可复核的验证。浏览器里新增一个测试药品,入库 20 件,再出库 6 件,然后执行下面的 SQL。
SELECT d.id, d.drug_name, (SELECT IFNULL(SUM(quantity), 0) FROM inbound_record i WHERE i.drug_id = d.id) AS in_qty, (SELECT IFNULL(SUM(quantity), 0) FROM sale_record s WHERE s.drug_id = d.id) AS out_qty, (SELECT IFNULL(SUM(quantity), 0) FROM drug_stock st WHERE st.drug_id = d.id) AS stock_qty FROM drug_info d WHERE d.drug_name = '测试药';期望结果是in_qty - out_qty等于stock_qty,也就是 14。对不上时按三步查:先看控制台里setShowSql打出的日志,确认 update 语句的 where 有没有带对drug_id和批次;再查drug_stock是否被拆成了多条同批次记录;最后检查事务回调有没有显式return true,漏了它会让流水和库存一起回滚。这三步走完,问题基本定位到具体某一行 SQL,而不是在页面和数据库之间反复猜。
本文还有配套的精品资源,点击获取