
简介这是一套基于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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, sup_name VARCHAR(80) NOT NULL, contact VARCHAR(30), phone VARCHAR(20) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;各司其职的关系是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 groupIdcom.jfinal/groupId artifactIdjfinal/artifactId version4.9.05/version /dependency dependency groupIdcom.jfinal/groupId artifactIdjfinal-undertow/artifactId version2.6/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.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 ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 启动时数据源还没就绪直接报空连接池。连接串里characterEncodingutf8是 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中文写入变问号连接串缺编码参数加useUnicodetruecharacterEncodingutf8Communications link failure缺时区参数或端口不通加serverTimezoneAsia/Shanghai确认 3306Table drug_db.drug_info doesnt 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 ModelDrug { 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(); PageDrug 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 天内的批次要能查出来库存低于下限的药品要能列出来。这两个查询不需要 ModelDb.find返回的就是通用行记录。import com.jfinal.plugin.activerecord.Record; public void expiryWarning() { ListRecord 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() { ListRecord 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不要用 10JFinal 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而不是在页面和数据库之间反复猜。本文还有配套的精品资源点击获取