十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot网上药店系统核心实现:库存扣减、订单事务与定时任务

SpringBoot网上药店系统核心实现:库存扣减、订单事务与定时任务 简介这是一份基于SpringBoot构建的网上药店管理信息系统设计与实现源码配套毕业论文适合计算机相关专业学生用于毕业设计、课程设计或Spring Boot项目实战参考。系统覆盖产品管理与药品管理两大业务板块仓库信息维护、出入库管理、订单管理、存储规则管理以及药品信息维护、效期管理、价格管理、销售管理和计划管理并针对库存不足或接近漫溢状态提供文字提示。资源包共456个文件约20.24MB包含Java源文件、class编译文件、XML配置、HTML/CSS/JS前端页面、SQL数据库脚本以及YML配置等类型齐全便于本地部署与二次开发。已有166人学习/下载。附带毕业论文文档和数据库脚本可直接辅助完成从开题、设计、编码到答辩的完整流程同时有助于学习者掌握SpringBoot整合业务模块、数据持久化及前端交互的实战能力。1. 网上药店系统里 SpringBoot 该扛多少业务一个看起来很普通的网上药店管理信息系统一旦落到代码层面难度并不在页面多华丽而在药品这种商品的各种约束条件。药品有批次、有有效期、有批准文号卖出去之后还要能追溯订单超时未支付要自动取消取消之后库存要原路退回。这些规则全部堆在一个 SpringBoot 单体项目里如果从一开始就把订单、库存、批次、用户、药品这些核心表设计错了后面所有实现都只是在错的地基上打补丁。这篇内容直接服务三类人拿这个题目做毕业设计的学生接私活需要快速交付的工程师以及想评估单体 SpringBoot 能不能承载医药电商业务的技术负责人。我会把需求分析到表结构、下单事务、定时任务、鉴权与报表这条路完整走一遍中间给出可以直接抄走的代码和参数。2. 网上药店的数据模型设计与 SpringBoot 项目初始化2.1 药品、批次与库存为什么必须拆成三张表而不是一张很多初版项目会把药品名称、规格、生产厂家、库存数量全部塞进一张 drug 表看起来简单但很快会遇到两个问题同一款药在不同批次进货价不同、有效期不同快过期的批次需要优先出库按批准文号做追溯时你根本说不清这一盒药是哪个批次的。所以把数据模型拆成药品基础信息、批次信息、批次库存三个层次是医药业务里绕不开的底座。药品基础信息表存固定属性批准文号、通用名、商品名、规格、单位、生产厂商、处方药标志。批次表存每一次采购入库形成的批次批次号、生产日期、有效期至、采购价、库存数量、状态。两个字段关系一旦确立后续的所有出库、有效期预警、报表统计才有地方落脚。CREATE TABLE drug ( id BIGINT PRIMARY KEY AUTO_INCREMENT, approval_number VARCHAR(64) NOT NULL COMMENT 批准文号, generic_name VARCHAR(128) NOT NULL COMMENT 通用名, brand_name VARCHAR(128) COMMENT 商品名, specification VARCHAR(128) COMMENT 规格, unit VARCHAR(20) COMMENT 单位, manufacturer VARCHAR(255) COMMENT 生产厂家, is_rx TINYINT DEFAULT 0 COMMENT 是否处方药 1是 0否, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE drug_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL COMMENT 关联drug.id, batch_no VARCHAR(64) NOT NULL COMMENT 批次号, produce_date DATE COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 有效期至, quantity INT NOT NULL DEFAULT 0 COMMENT 当前可用数量, status TINYINT DEFAULT 1 COMMENT 1正常 0停用 );批次的 quantity 字段才是真正被下单事务扣减的字段drug 表里不需要冗余库存字段。如果某天你想在药品列表直接展示库存总数用SELECT drug_id, SUM(quantity) FROM drug_batch WHERE status1 GROUP BY drug_id去聚合而不是在 drug 表维护一个可能和批次明细不一致的冗余值。整条链路里最怕出现列存了一份数、事务又改另一份数的脏状态。2.2 从脚手架到配置文件SpringBoot 项目怎么起常见做法是先用 IDEA 或者 Spring Initializr 生成一个 SpringBoot 2.7.x 项目JDK 用 1.8这版本够稳网上资料多mybatis-plus 兼容性也好。SpringBoot 3.x 也没有问题但部分老教程用的 druid、pagehelper 等中间件需要切到 jakarta 命名空间容易出现版本冲突。使用 SpringBoot 2.7.13 绝对是做毕设和接私活最平滑的版本。依赖选择上只加必要的东西spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok、jjwt、spring-boot-starter-data-redis、poi导出报表用。不要在一开始就引入一堆微服务组件单体应用在这些业务里完全够用。上传下载大文件的需求属于典型伪需求药店后台最多传几张药品图片用 MultipartFile 加一个本地磁盘路径就够了不需要引入 MinIO 或者 OSS 的复杂度。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 20MB data: redis: host: localhost port: 6379 # 连接超时、线程池参数按默认即可 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case 打开之后数据库的batch_no能自动映射到实体的batchNo不需要写一堆 resultMap。log-impl设置成 StdOutImpl 是给开发阶段看的任何一条 SQL 都会打到控制台排查问题非常直接部署到生产环境时再把这一行去掉。2.3 实体类与表结构对齐后的第一个坑使用 mybatis-plus 时实体类上必须加TableName注解明确对应哪张表否则默认表名以类名驼峰转下划线。TableId(type IdType.AUTO)标明数据库自增主键。还有一个容易踩的坑实体字段里不要出现isRx这类以 is 开头的布尔字段。Lombok 对isRx生成的 getter/setter 是isRx()和setRx()mybatis-plus 在反射时会丢掉前缀导致 SQL 查出来的值映射不上。解决方案就是数据库字段写成is_rx TINYINT实体字段用rxFlag映射靠注解写明。TableName(drug) public class Drug { TableId(type IdType.AUTO) private Long id; private String approvalNumber; private String genericName; private String brandName; private String specification; private String unit; private String manufacturer; TableField(is_rx) private Integer rxFlag; }很多人把这些布尔/枚举字段统统设计成Integer加注释比设计成Boolean更省事。比如药品上下架状态、订单状态、支付状态统一用整型枚举代码里写一个常量类0表示未支付、1表示已支付、2表示已取消。数据在数据库里更直观排查问题时一条 SQL 就能定位不需要查询工具再做一次枚举翻译。3. 订单、库存与处方药网上药店核心交易链路的 SpringBoot 实现3.1 下单事务里如何用一条 UPDATE 避免超卖网上药店和普通电商的下单最大的区别在于库存扣减必须关联到具体批次。用户买的是某一种药系统需要从该药品的多个批次中选择最早到期的批次先行出库。这个先进先出规则由代码控制而不是由数据库自动完成。在事务里先查该药品可用批次按expire_date升序排列逐一批次扣减直到满足用户购买数量。关键点在于扣减语句本身必须带条件判断UPDATE drug_batch SET quantity quantity - #{num} WHERE id #{batchId} AND quantity #{num} AND status 1执行这条 UPDATE返回值是影响行数。如果等于 1说明扣减成功如果等于 0说明库存不足或批次已停用需要回滚整个事务。千万不要用先 SELECT 再 UPDATE的方式——两个请求同时读到库存为 5各自判断足够然后各自扣减最后库存变负数。带条件的 UPDATE 让数据库在行锁层面完成并发控制这是单体系统最简单可靠的防超卖手段。一段典型的下单核心代码Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { // 1. 查询用户购物车所选药品明细 ListCartItem cartItems cartMapper.selectByIds(request.getCartItemIds()); // 2. 创建订单主表记录状态为待支付 Order order createOrderRecord(cartItems); // 3. 锁定库存 for (CartItem item : cartItems) { lockStock(item.getDrugId(), item.getQuantity(), order.getId()); } // 4. 清空已下单购物车项 cartMapper.deleteBatchIds(request.getCartItemIds()); } private void lockStock(Long drugId, Integer needQuantity, Long orderId) { ListDrugBatch batches drugBatchMapper .findAvailableBatches(drugId); // ORDER BY expire_date ASC, id ASC int remainNeed needQuantity; for (DrugBatch batch : batches) { int deductCount Math.min(remainNeed, batch.getQuantity()); int rows drugBatchMapper.deductStock(batch.getId(), deductCount); if (rows 0) { continue; // 该批次已被其他订单扣完尝试下一个 } // 写入库存流水便于后续追踪 stockLogMapper.insert(StockLog.builder() .batchId(batch.getId()) .orderId(orderId) .changeNum(-deductCount) .build()); remainNeed - deductCount; if (remainNeed 0) { break; } } if (remainNeed 0) { throw new BusinessException(404, 药品[ drugId ]库存不足); } }Transactional(rollbackFor Exception.class)必须要有。默认情况下 Spring 只对 RuntimeException 回滚业务异常如果继承的是 Exception不回滚那么前面的库存扣减就白扣了。库存流水表是必须的它不只服务追溯需求还为后续定时任务做对账提供依据——哪些订单取消后库存没回补查流水表就能发现。3.2 订单超时自动取消与库存回补的定时任务用户下单后 30 分钟未支付系统需要自动取消订单并把锁定库存释放回去。网上药店常见做法是 SpringBoot 原生Scheduled定时轮询对中小流量系统足够可靠。定时任务每 60 秒扫描一次订单表将created_at超过 30 分钟且状态仍为待支付的订单批量更新为已取消。Component public class OrderTimeoutTask { Scheduled(fixedDelay 60000, initialDelay 10000) public void autoCancelExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredUnpaidOrders(30); for (Order order : expiredOrders) { // 尝试把订单从未支付改为已取消返回行数为 0 说明已被其他线程处理 int rows orderMapper.cancelIfStatus(order.getId(), OrderStatus.UNPAID, OrderStatus.CANCELED); if (rows 0) { continue; } // 取消成功后才释放库存 releaseStock(order.getId()); } } Transactional(rollbackFor Exception.class) public void releaseStock(Long orderId) { ListStockLog logs stockLogMapper.selectByOrderId(orderId); for (StockLog log : logs) { if (log.getChangeNum() 0) { continue; } drugBatchMapper.increaseStock(log.getBatchId(), -log.getChangeNum()); stockLogMapper.insert(StockLog.builder() .batchId(log.getBatchId()) .orderId(orderId) .changeNum(-log.getChangeNum()) .build()); } } }注意几个细节。cancelIfStatus这条 SQL 是并发安全的关键UPDATE orders SET status 2 WHERE id #{orderId} AND status 0只有原本状态是待支付的订单才会被改成已取消。如果用户刚好在这个时间点完成了支付行数为 0跳过库存释放逻辑订单状态已经被支付回调更新了。这个基于条件更新的幂等控制是订单与库存一致性最后一道防线。库存流水表记录用的是正数还是负数要约定清楚回补库存时仅把负数流水取反避免重复回补。fixedDelay 60000的含义是上一次任务执行完成后过 60 秒再执行下一次。假如某次扫描由于数据库慢查询耗了 5 秒fixedDelay 会保证下次执行从这次结束后再过 60 秒不会出现任务堆积。若想控制每次扫描的数据量SQL 里加上LIMIT 200处理完分页继续避免一次加载几万条过期订单把内存打满。3.3 处方药不能在线直接买审核状态的流转设计网上药店和普通商品系统最本质的区别就是处方药。药品属性 is_rx 等于 1 时订单不能直接进入待支付状态中间必须插入一个处方单流程。用户上传处方图片或者填写用药人信息药师在管理端审核审核通过之后订单才能支付。这个设计既是业务合规底线也是整张表结构上最难补的一个环节。常见的建模方式是在订单主表上增加一个rx_status字段0 表示非处方药不需要审核1 表示待审核2 表示已通过3 表示已驳回。同时增加一张prescription表存储处方图片 URL、用药人姓名、药师审核意见、用药诊断说明。管理端药师角色只对这些数据有操作权限药品管理、库存管理权限归仓库管理员。CREATE TABLE prescription ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, patient_name VARCHAR(64) NOT NULL COMMENT 用药人, diagnose_result VARCHAR(255) COMMENT 诊断说明, image_url VARCHAR(255) COMMENT 处方图片地址, pharmacist_id BIGINT COMMENT 审核药师ID, audit_comment VARCHAR(255) COMMENT 审核意见, status TINYINT DEFAULT 1 COMMENT 1待审核 2通过 3驳回, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, audited_at DATETIME COMMENT 审核时间 );处方药订单超时取消的时间和普通订单要区分建议给这类订单 2 小时的支付等待期因为用户要去咨询药师或者补传资料直接 30 分钟取消会伤害转化率。定时任务里要判断rx_status已经进入待审核状态的订单不参与超时取消逻辑否则药师下午审核通过用户晚上准备支付时发现订单被取消了体验非常差。审核通过之后如果超过 30 分钟未支付再进入常规超时取消逻辑。4. 登录鉴权、定时任务与统计报表管理端能力的 SpringBoot 落地4.1 双端登录与 JWT 拦截器网上药店系统有两个端口前台用户端和管理员后台两端的用户表、权限模型完全不同。常见做法是建user和admin_user两张表分别签发 JWT在拦截器里按接口路径前缀区分校验逻辑。前端发起请求时在 Authorization 头里带Bearer token拦截器解析 token把用户 ID 放入 ThreadLocal后续业务代码直接获取当前用户。Component public class AdminAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { response.setStatus(401); return false; } String token authHeader.substring(7); try { Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); // 校验角色只允许 admin 角色访问 String role claims.get(role, String.class); if (!ADMIN.equals(role)) { response.setStatus(403); return false; } AdminThreadLocal.set(claims.get(adminId, Long.class)); return true; } catch (ExpiredJwtException e) { response.setStatus(401); return false; } } }拦截器注册时按路径细分/api/admin/**走 AdminAuthInterceptor/api/user/**走 UserAuthInterceptor/api/public/**直接放行。药品列表、轮播图这类游客可见的接口放入 public 路径。生成 JWT 时 secretKey 绝对不能硬编码在代码里必须放在配置文件中并加上Value注入。也注意不要在 JWT 中保存敏感信息token 是可以被解密的只放 userID、role 即可。密码存储用 BCrypt。Spring Security 对单体项目来说太重了只引入spring-security-crypto用它独有的BCryptPasswordEncoder即可。用户密码加盐哈希数据库中即使被拖库也无法通过彩虹表反推出原密码。删除用户、重置密码这些高危操作需要二次验密——再次输入当前密码做一次 compare防止用户离开电脑后被路过的人改掉账号。4.2 近效期药品与低库存预警Scheduled 的第二个应用订单超时取消是一个定时任务库存与效期预警是第二个。每天晚上固定时间扫描批次表把有效期剩余天数小于 90 天和当前库存低于预警阈值的数据落进一张drug_warning表后台首页直接展示预警列表。用一张独立表而不是每次都实时计算是为了预警历史可回溯管理员可以看到昨天的预警、是否已处理、从那之后过期的药品数量。Component public class DrugWarningTask { Scheduled(cron 0 30 2 * * ?) public void generateDailyWarnings() { ListDrugBatch nearExpireBatches drugBatchMapper .selectNearExpireBatches(90); // expiry_date today 90 ListDrugBatch lowStockBatches drugBatchMapper .selectLowStockBatches(); // quantity warn_threshold // 合并写入 drug_warning 表 warningMapper.insertBatch(buildWarnings(nearExpireBatches, NEAR_EXPIRE)); warningMapper.insertBatch(buildWarnings(lowStockBatches, LOW_STOCK)); } }cron 表达式0 30 2 * * ?表示每天凌晨 2:30 执行。选择凌晨是为了避开数据库高峰。预警阈值不要硬编码。在sys_config表里存expire_warn_days和low_stock_threshold这样运营调整阈值时不用改代码重新发版。之所以把这个任务单独建一张表而不是在药品列表上实时筛选是因为预警天然带时间维度一张历史表方便后续做统计分析。4.3 销售统计报表的聚合 SQL 与图表对接管理端首页通常需要展示近 30 天销售额曲线、品类销售占比、订单状态分布。这部分直接用 SQL 聚合不要把所有订单加载到内存里再计算。MyBatis-Plus 里用自定义 SQL 结合Select注解返回一个 Map 或者 VO 对象。以日销售额统计为例SELECT DATE(created_at) AS stat_date, SUM(pay_amount) AS total_amount, COUNT(id) AS order_count FROM orders WHERE pay_status 1 AND created_at DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE(created_at) ORDER BY stat_date查询结果转换成ListSalesDailyVO后直接作为接口数据返回给前端前端用 ECharts 的折线图渲染。需要注意时区问题数据库的serverTimezone配置和 JVM 默认时区要一致否则DATE(created_at)按美国时区计算每天的统计会偏差几小时。低概率但排错极其隐蔽一开始就在连接串上固定Asia/Shanghai是最省心的做法。报表接口如果发现慢查询优先检查orders表是否针对(pay_status, created_at)建了联合索引。只按 pay_status 建索引在 50 万级订单数据下走DATE_SUB范围扫描会很吃力。联合索引让数据库可以快速定位到已支付且创建距今 30 天内的订单再对这较少数据量做分组聚合。5. 用并发下单脚本验证 SpringBoot 药店的库存一致性5.1 JMeter 模拟并发下单的配置模板写完代码之后最大的疑问往往是事务代码真的防超卖吗手动在浏览器里点击永远不会发现问题必须用 JMeter 开并发线程去验证。在 JMeter 中创建一个线程组线程数设为 50Ramp-Up Period 设为 0表示 50 个线程同时启动循环次数设为 1。HTTP 请求头里加Authorization值为登录后拿到的 token请求路径指向下单接口Body 传固定药品 ID 和数量 1。准备一个已知状态的前置数据某批次药品库存数量设定为 3050 个并发用户抢购。执行完压测后查询数据库-- 查看所有用户ID的订单状态和实付金额 SELECT o.id, o.user_id, o.status, SUM(od.quantity) AS total_qty FROM orders o JOIN order_detail od ON od.order_id o.id WHERE o.created_at DATE_SUB(NOW(), INTERVAL 5 MINUTE) GROUP BY o.id; -- 检查库存是否变成了负数 SELECT batch_id, quantity FROM drug_batch WHERE id #{batchId}; -- 预期结果订单成功数 30库存剩余 0没有库存为负数的批次正确的验证结论是50 个并发请求里只有 30 个成功创建订单其余 20 个收到库存不足的异常提示库存表的 quantity 字段从 30 降为 0不存在负数库存流水表中累计扣减 30 条记录。如果出现了成功订单数大于初始库存数的情况检查lockStock方法里的 UPDATE 语句是否带了quantity #{num}条件或者Transactional是否真的生效——Spring boot 的自动代理在类内部自调用this.method()时事务不会生效必须通过注入自身代理或者拆到两个类中调用。5.2 论文结论与代码包的一一对应写毕业论文的同学这里有个很实用的思路把论文的章节直接映射到代码包每一个核心图都有对应的可运行代码。这个映射表写清楚了答辩时老师说你这个数据流怎么走的你顺手就能指到代码位置讲下去而不是现场翻 IDEA。论文章节对应代码位置最关键的可展示点系统需求分析use-case/用例图 订单状态机常量下单流程和取消流程的时序图数据库设计sql/init.sql中的 E-R 图映射批次表和订单表的关联关系系统实现controller/,service/各模块库存扣减 SQL 的条件判断系统测试JMeter 测试计划 测试截图并发下的幂等性验证总结与展望论文最后两页升华点单体架构未来可拆的微服务边界最后一个进阶技巧验证定时任务的幂等性。手动把数据库里一笔订单的created_at改成 31 分钟前然后重启应用观察定时任务是否只执行了一次取消操作。执行两次这类问题在日志里很难发现因为第二次执行时cancelIfStatus影响了 0 行数据被continue跳过了。给stock_log表的(order_id, change_num)加唯一索引能在数据库层面挡住重复回补这是整个系统最后一道保险锁。验证命令是curl -X POST http://localhost:8080/api/admin/task/trigger -H Authorization: Bearer ${ADMIN_TOKEN}手动触发一次定时任务逻辑再对比执行前后的库存流水数量确保没有多出一条正数回补记录。本文还有配套的精品资源点击获取
返回列表