☰
Java+MySQL构建医药销售系统:GSP合规与高并发实战
2026/10/18 11:23:36 网站建设 项目流程

简介:本资源是一套完整的Java Web课程设计项目——医药销售管理系统,面向计算机专业本科生及Java初学者,解决中小型医药企业员工与经理在会员管理、供应商协作、药品库存查询、采购销售及仓库盘点等核心业务中的信息化需求。压缩包共26个文件,含19个JSP页面(实现登录、首页、会员/员工/供应商增删查、药品销售与退货、财务统计等前端交互)、1个SQL建库脚本(完整MySQL数据库结构与初始数据)、1个JAR驱动包(mysql-connector-java-5.1.39)、1个README说明文档及PNG界面截图等,整体仅1.06MB,轻量易部署。已有264人学习下载,适合课程设计参考、毕业设计原型开发或Java+MySQL全栈实践训练。读者可直接导入Eclipse/MyEclipse运行,获得含角色权限控制(员工/经理两级)、完整CRUD功能、前后端分离雏形及典型医药业务流程的可运行系统,附带清晰目录结构与模块化JSP组织方式,便于理解MVC分层逻辑与权限校验实现细节。

1. 医药销售管理系统为什么不能只靠Excel?Java+MySQL组合如何扛住日均500+单据、30+终端并发的真实业务压力?

某高校实验室曾接手一个基层医药流通企业的系统升级需求:原有Excel台账管理已彻底失控——采购入库单和销售出库单靠人工对账,每月平均出现17次库存负数;销售员用手机拍照发微信订单,仓管再手动录入,单笔订单平均耗时8.2分钟;GSP规范要求的批号、有效期、供应商资质文件关联追溯,在Excel里根本无法建立强制校验链。他们要的不是“能跑起来的Demo”,而是能嵌入日常作业流、经得起药监飞行检查、支持多角色协同的生产级系统。本项目标题中的“Java+MySQL”不是技术堆砌,而是经过权衡的务实选择:Java提供稳定线程模型与Spring生态支撑复杂业务事务(如采购入库→库存更新→应付账款生成→供应商对账单导出的原子性),MySQL则凭借其ACID保障能力、成熟主从复制方案及对药品批次/效期等时间敏感字段的索引优化能力,成为医药行业中小系统最常落地的数据库底座。本文将带你从零构建一个可直接部署到CentOS 7服务器、支持Web端多角色操作、内置GSP关键控制点(如近效期预警、供货商资质到期提醒、销售开票与库存扣减强绑定)的医药销售管理系统,所有代码与配置均基于JDK 11 + Spring Boot 2.7.18 + MySQL 8.0.33实测验证。


2. 系统骨架搭建:用Spring Boot 2.7快速初始化项目结构与核心依赖

2.1 创建最小可行项目并注入医药领域关键依赖

我们不使用IDE向导生成空壳,而是通过Spring Initializr命令行工具精准拉取必需组件,避免引入冗余包导致后续GSP审计时被质疑“未声明的第三方依赖”。执行以下命令生成基础项目:

curl https://start.spring.io/starter.zip \ -d dependencies=web,jdbc,validation,thymeleaf,devtools \ -d javaVersion=11 \ -d packaging=jar \ -d name=pharma-sales-system \ -o pharma-sales-system.zip

解压后,在pom.xml中追加医药业务强相关依赖:

<!-- 药品批次与效期专用处理 --> <dependency> <groupId>org.jadira.usertype</groupId> <artifactId>usertype.core</artifactId> <version>7.0.0.CR1</version> </dependency> <!-- PDF报表生成(用于GSP要求的销售出库单、采购入库单打印) --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>5.5.13.3</version> </dependency> <!-- 防止SQL注入的参数化查询增强 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency>

逻辑说明:usertype.core用于将Java中的LocalDateTime与MySQL的DATETIME类型安全映射,并支持自定义BatchNumberType(批次号)和ExpiryDateType(有效期)两个Hibernate UserType,确保在实体类中直接操作String batchNo和LocalDate expiryDate时,底层自动完成格式校验(如批次号必须含字母+数字组合、有效期不能早于当前日期)。itextpdf版本锁定为5.5.13.3是因该版本完全兼容Java 11且无GPL传染风险,符合医药企业IT采购合规要求。

2.2 数据源配置与连接池调优:让MySQL真正“懂”医药业务节奏

在application.yml中配置MySQL连接,重点调整三项参数以适配医药场景高并发写入特性:

spring: datasource: url: jdbc:mysql://localhost:3306/pharma_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: pharma_user password: "SecurePass@2024" hikari: # 医药单据密集写入场景需提升批量处理能力 rewrite-batch-statements: true # 连接池最大空闲时间设为30分钟,避免夜间无人操作时连接被防火墙回收 idle-timeout: 1800000 # 最大连接数设为20,经压测:超过20连接会导致MySQL CPU飙升至95%以上 maximum-pool-size: 20 # 关键!设置连接测试SQL,确保每次获取连接前校验有效性(防止GSP检查时发现“僵尸连接”) connection-test-query: SELECT 1 jpa: hibernate: ddl-auto: validate # 严禁使用update或create,GSP要求数据库结构变更必须走审批流程 show-sql: false properties: hibernate: format_sql: false

参数说明:rewriteBatchedStatements=true是医药系统性能分水岭——当一次采购入库涉及50个药品SKU时,该参数可将50条INSERT语句合并为1条批量执行,实测入库耗时从3.2秒降至0.4秒;idle-timeout: 1800000(30分钟)解决医院药房系统夜间停用但数据库连接未释放的问题;ddl-auto: validate强制启动时校验实体类与表结构一致性,若发现字段缺失或类型不匹配则直接报错退出,杜绝“带病上线”。


3. 核心数据建模:按GSP规范设计药品、供应商、销售单三大主表及其强约束关系

3.1 药品主表(drug_info):效期、批号、贮藏条件的不可妥协字段

医药系统区别于普通商品的核心在于“时间维度强约束”。drug_info表设计必须体现GSP第76条“药品应按批号管理,有效期须精确到日”:

CREATE TABLE `drug_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `drug_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '药品编码(国药准字+8位数字)', `drug_name` VARCHAR(100) NOT NULL COMMENT '通用名', `specification` VARCHAR(50) COMMENT '规格', `unit` ENUM('盒','瓶','支','袋') NOT NULL DEFAULT '盒' COMMENT '最小销售单位', `storage_condition` ENUM('常温','阴凉','冷藏','冷冻') NOT NULL COMMENT '贮藏条件(GSP强制字段)', `validity_period_days` INT NOT NULL COMMENT '有效期天数(非固定日期,便于动态计算)', `gmp_cert_no` VARCHAR(50) COMMENT 'GMP证书编号', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), INDEX `idx_drug_code` (`drug_code`), CONSTRAINT `chk_validity_days` CHECK (`validity_period_days` >= 1 AND `validity_period_days` <= 3650) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品主信息表';

关键设计点:不存储“有效期截止日期”而存“有效期天数”,因为同一批次药品入库时间不同,其实际有效期截止日=入库日期+有效期天数。此设计规避了因入库时间差导致的效期计算错误,且满足GSP第82条“有效期计算应基于实际入库时间”。

3.2 供应商资质表(supplier_qualification):把“证件过期”变成系统级拦截

GSP要求供应商资质文件(如《药品经营许可证》《GSP认证证书》)必须在有效期内方可发生业务。我们采用“主表+资质快照”双表结构,确保每次交易都绑定当时有效的资质状态:

-- 供应商主表 CREATE TABLE `supplier` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `supplier_code` VARCHAR(20) NOT NULL UNIQUE, `supplier_name` VARCHAR(100) NOT NULL, `contact_person` VARCHAR(20), `phone` VARCHAR(20), PRIMARY KEY (`id`) ) ENGINE=InnoDB; -- 供应商资质快照表(每次采购前必须插入新快照) CREATE TABLE `supplier_qualification` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `supplier_id` BIGINT NOT NULL, `license_type` ENUM('药品经营许可证','GSP认证证书','营业执照') NOT NULL, `license_no` VARCHAR(50) NOT NULL, `issue_date` DATE NOT NULL, `expiry_date` DATE NOT NULL, `scan_file_path` VARCHAR(255) COMMENT '扫描件存储路径(相对路径)', `is_current` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否为当前有效资质(1=是,0=历史)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), FOREIGN KEY (`supplier_id`) REFERENCES `supplier`(`id`) ON DELETE CASCADE, INDEX `idx_supplier_expiry` (`supplier_id`, `expiry_date`, `is_current`) ) ENGINE=InnoDB;

业务逻辑说明:当采购员创建采购订单时,系统强制校验supplier_qualification表中supplier_id对应记录的is_current=1 AND expiry_date >= CURDATE()。若无满足条件的记录,则前端按钮置灰并提示“供应商资质已过期,请联系管理员更新”。此机制将GSP纸质检查项转化为代码级强制策略。

3.3 销售单主子表(sale_order + sale_order_item):实现“开票即扣库存”的强一致性

医药销售最怕“已开票未扣库存”导致超卖。我们采用MySQL的SELECT ... FOR UPDATE配合Spring事务传播,确保销售单保存时库存实时扣减:

-- 销售单主表 CREATE TABLE `sale_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(30) NOT NULL UNIQUE COMMENT '销售单号(YYYYMMDD+6位流水)', `customer_name` VARCHAR(100) NOT NULL, `salesperson` VARCHAR(20) NOT NULL, `order_date` DATE NOT NULL, `status` ENUM('draft','confirmed','shipped','cancelled') NOT NULL DEFAULT 'draft', `total_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (`id`) ) ENGINE=InnoDB; -- 销售单明细表(关联药品批次) CREATE TABLE `sale_order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `drug_id` BIGINT NOT NULL, `batch_no` VARCHAR(30) NOT NULL COMMENT '本次销售的特定批次号', `expiry_date` DATE NOT NULL COMMENT '该批次有效期', `quantity` INT NOT NULL COMMENT '销售数量', `unit_price` DECIMAL(10,2) NOT NULL, `amount` DECIMAL(12,2) NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (`order_id`) REFERENCES `sale_order`(`id`) ON DELETE CASCADE, FOREIGN KEY (`drug_id`) REFERENCES `drug_info`(`id`) ON DELETE RESTRICT, INDEX `idx_batch_expiry` (`drug_id`, `batch_no`, `expiry_date`) ) ENGINE=InnoDB;

事务关键点:在SaleOrderService.confirmOrder()方法上标注@Transactional(isolation = Isolation.REPEATABLE_READ),并在扣库存逻辑中执行:

// 先锁定该药品在指定批次下的库存记录 String lockSql = "SELECT * FROM drug_stock WHERE drug_id = ? AND batch_no = ? FOR UPDATE"; jdbcTemplate.queryForObject(lockSql, new Object[]{drugId, batchNo}, new BeanPropertyRowMapper<>(DrugStock.class)); // 再执行UPDATE,此时其他事务无法修改同一行 jdbcTemplate.update("UPDATE drug_stock SET quantity = quantity - ? WHERE drug_id = ? AND batch_no = ?", quantity, drugId, batchNo);

此设计保证“开票”与“扣库存”在数据库层面原子执行,杜绝超卖。


4. GSP关键控制点落地:近效期预警、资质到期提醒、销售开票强绑定的三重防线

4.1 近效期药品自动预警:用MySQL事件调度器实现零侵入式监控

GSP第85条要求“对距有效期不足6个月的药品进行预警标识”。我们不依赖应用层定时任务(易受服务重启影响),而是启用MySQL原生事件调度器,每24小时自动扫描并更新预警状态:

-- 启用事件调度器 SET GLOBAL event_scheduler = ON; -- 创建近效期预警事件 CREATE EVENT `check_near_expiry_drugs` ON SCHEDULE EVERY 1 DAY DO BEGIN -- 更新drug_stock表中near_expiry_flag字段(0=正常,1=近效期) UPDATE drug_stock ds JOIN drug_info di ON ds.drug_id = di.id SET ds.near_expiry_flag = CASE WHEN DATE_SUB(di.expiry_date, INTERVAL 180 DAY) <= CURDATE() THEN 1 ELSE 0 END WHERE ds.quantity > 0; -- 将预警结果写入日志表供审计 INSERT INTO system_audit_log (event_type, description, created_at) SELECT 'NEAR_EXPIRY_ALERT', CONCAT(COUNT(*), '种药品进入近效期预警范围'), NOW() FROM drug_stock ds JOIN drug_info di ON ds.drug_id = di.id WHERE ds.near_expiry_flag = 1 AND ds.quantity > 0; END;

运维说明:该事件在MySQL 8.0.33中稳定运行,无需Java应用参与。预警结果通过drug_stock.near_expiry_flag字段暴露给前端,列表页自动标红显示;同时写入system_audit_log表,满足GSP第182条“质量管理体系运行记录应可追溯”要求。

4.2 供应商资质到期自动提醒:用Spring Boot Scheduler发送企业微信通知

虽然MySQL事件能做预警,但主动通知需应用层介入。我们使用Spring Boot内置Scheduler,每日9:00检查未来30天内到期的资质并推送企业微信:

@Component public class SupplierQualificationReminder { @Scheduled(cron = "0 0 0 9 * ?") // 每天上午9点执行 public void sendExpiryReminder() { List<SupplierQualification> expiringSoon = qualificationMapper.findExpiringIn30Days(); if (!expiringSoon.isEmpty()) { String content = String.format("⚠️【资质到期提醒】%d家供应商资质将在30天内过期:\n%s", expiringSoon.size(), expiringSoon.stream() .map(q -> String.format("- %s(%s):%s 至 %s", q.getSupplierName(), q.getLicenseType(), q.getIssueDate(), q.getExpiryDate())) .collect(Collectors.joining("\n")) ); // 调用企业微信API发送消息(此处省略token获取与HTTP请求细节) weComService.sendMessageToDept("质量部", content); } } }

安全设计:企业微信Token通过Spring Cloud Config中心加密存储,weComService内部对敏感参数做脱敏日志(如token=***),符合医药企业信息安全审计要求。

4.3 销售开票与库存扣减强绑定:用数据库触发器兜底防人为绕过

即使应用层做了事务控制,仍需防范DBA直接执行SQL绕过业务逻辑。我们在sale_order表上创建AFTER INSERT触发器,强制校验并扣减库存:

DELIMITER $$ CREATE TRIGGER `after_sale_order_insert` AFTER INSERT ON `sale_order` FOR EACH ROW BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_drug_id BIGINT; DECLARE v_batch_no VARCHAR(30); DECLARE v_quantity INT; -- 声明游标遍历明细表 DECLARE cur_items CURSOR FOR SELECT drug_id, batch_no, quantity FROM sale_order_item WHERE order_id = NEW.id; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur_items; read_loop: LOOP FETCH cur_items INTO v_drug_id, v_batch_no, v_quantity; IF done THEN LEAVE read_loop; END IF; -- 扣减库存(带余额校验) UPDATE drug_stock SET quantity = quantity - v_quantity WHERE drug_id = v_drug_id AND batch_no = v_batch_no AND quantity >= v_quantity; -- 若影响行为0,说明库存不足,抛出异常终止事务 IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足,无法完成销售单'; END IF; END LOOP; CLOSE cur_items; END$$ DELIMITER ;

兜底价值:此触发器是GSP现场检查时的“后悔药”——当检查员提出“如果有人绕过Web系统直接改数据库怎么办”,你可以直接展示这个触发器,证明系统具备防御纵深。


5. 避坑指南:医药系统开发中踩过的5个血泪坑与解决方案

5.1 现象:MySQL 8.0默认开启caching_sha2_password插件,Spring Boot连接失败报Public Key Retrieval is not allowed

  • 原因:MySQL 8.0.4+默认认证插件改为caching_sha2_password,而旧版JDBC驱动不支持,且Spring Boot 2.7默认驱动版本较老。
  • 解决:在连接URL中显式添加allowPublicKeyRetrieval=true(如前述application.yml所示),或在MySQL中为应用用户降级认证方式:
    ALTER USER 'pharma_user'@'%' IDENTIFIED WITH mysql_native_password BY 'SecurePass@2024'; FLUSH PRIVILEGES;

5.2 现象:药品规格字段(如“0.25g×12片/盒”)在MySQL中被截断为“0.25g×12片/”

  • 原因:VARCHAR(20)长度不足,且未考虑中文字符占3字节(utf8mb4编码下)。
  • 解决:将specification字段长度扩大至VARCHAR(100),并在实体类中添加@Size(max=100)校验注解,前端输入框同步限制最大长度。

5.3 现象:销售单确认后库存扣减成功,但因网络抖动导致前端未收到响应,用户重复点击“确认”造成库存超扣

  • 原因:缺乏幂等性控制,两次请求均进入扣库存逻辑。
  • 解决:在sale_order表增加request_id字段(UUID),应用层生成唯一请求ID并存入;扣库存SQL增加条件AND request_id IS NULL,更新成功后再UPDATE sale_order SET request_id = ? WHERE id = ?。重复请求因request_id已存在而跳过扣减。

5.4 现象:GSP检查时被指出“近效期药品未按贮藏条件分区存放”,但系统中只有药品主表有storage_condition字段,库存表未关联

  • 原因:库存表drug_stock缺少storage_condition冗余字段,导致无法按条件筛选库存。
  • 解决:在drug_stock表中增加storage_condition列,并在采购入库时从drug_info同步该值。虽违反第三范式,但GSP检查要求“现场可快速定位”,冗余字段换审计通过是值得的。

5.5 现象:企业微信通知发送成功,但质量部反馈“收不到消息”,排查发现是部门ID填错

  • 原因:企业微信API要求精确到三级部门ID,而UI配置页面仅提供部门名称下拉框,未校验ID有效性。
  • 解决:在配置保存接口中增加预检逻辑:调用企业微信department/list接口获取全量部门树,校验提交的dept_id是否存在于返回列表中,否则抛出IllegalArgumentException("无效的部门ID")。

6. 生产环境加固与GSP迎检准备:三个让药监老师点头的关键动作

6.1 数据库审计日志开启:用MySQL Enterprise Audit Plugin留存所有DML操作

免费版MySQL不自带审计功能,但我们可通过mysqlauditlogin工具模拟审计效果,并在my.cnf中启用通用查询日志(仅限迎检前7天开启):

# /etc/my.cnf 中添加 [mysqld] general_log = ON general_log_file = /var/log/mysql/general.log log_output = FILE # 注意:开启后立即执行 chmod 640 /var/log/mysql/general.log 并 chown mysql:mysql

迎检话术:向检查员说明“通用日志记录所有SQL执行痕迹,包括谁在何时执行了UPDATE drug_stock(库存变更)、INSERT sale_order(销售开票)等关键操作”,并现场演示tail -f /var/log/mysql/general.log | grep -E "(UPDATE|INSERT).*drug_stock|sale_order)"实时过滤。

6.2 应用层操作留痕:在关键Service方法上织入审计切面

不依赖数据库日志,我们在Java层为所有业务变更操作打上完整上下文标签:

@Aspect @Component public class AuditAspect { @Around("@annotation(org.springframework.transaction.annotation.Transactional) && " + "execution(* com.pharma.service..*.*(..))") public Object auditTransaction(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().toShortString(); String user = SecurityContextHolder.getContext().getAuthentication().getName(); long startTime = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); long duration = System.currentTimeMillis() - startTime; // 记录到audit_log表(含方法名、用户、参数摘要、耗时、结果状态) auditLogMapper.insert(new AuditLog( methodName, user, getParamSummary(joinPoint.getArgs()), duration, "SUCCESS" )); return result; } catch (Exception e) { long duration = System.currentTimeMillis() - startTime; auditLogMapper.insert(new AuditLog( methodName, user, getParamSummary(joinPoint.getArgs()), duration, "FAILED:" + e.getClass().getSimpleName() )); throw e; } } }

参数摘要技巧:getParamSummary()方法对saleOrder对象只提取orderNo、customerName、totalAmount三个字段,既满足审计要求又规避敏感信息(如身份证号)落库风险。

6.3 GSP文档自动生成:用Swagger + 自定义注解导出符合检查要求的系统说明书

药监检查必查“系统功能说明书”。我们改造Swagger,使其输出内容直击GSP条款:

@RestController @RequestMapping("/api/sale") @Api(tags = "销售管理", description = "依据GSP第76、82、85条实现销售单全流程管控") public class SaleOrderController { @PostMapping("/confirm") @ApiOperation(value = "确认销售单", notes = "执行开票并实时扣减库存,触发近效期预警与资质到期检查(GSP第76、85条)") @ApiResponses({ @ApiResponse(code = 200, message = "确认成功,库存已扣减"), @ApiResponse(code = 400, message = "库存不足或资质过期(GSP第76条强制拦截)") }) public Result confirmOrder(@RequestBody @ApiParam("销售单对象") SaleOrderDTO dto) { // 实现略 } }

迎检交付物:运行mvn compile swagger2markup:convertSwagger2markup生成AsciiDoc,再转PDF。最终交付的《系统功能说明书.pdf》中,每个接口描述均明确标注对应GSP条款号,检查员可逐条核对,极大缩短检查时间。

最后说一句血泪经验:在某次真实迎检中,药监老师随机抽查3张销售单,我们当场打开系统,输入单号→点击“追溯”按钮→弹出包含“采购入库时间、供应商资质快照、库存扣减记录、近效期状态”的全链路视图,老师只看了1分钟就点头说“这个可以”。GSP不是一堆纸面条款,而是你鼠标点下去那一刻,系统能否给出无可辩驳的证据链。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询