资讯详情 Spring Boot 与微信小程序搭建药店管理系统:从建表到避坑全流程
📅 2026/10/7 2:53:52
简介这套基于Spring Boot、SSM与uniapp开发的药店管理系统源码面向Java开发者、小程序学习者以及需要完成课程设计或毕业设计的学生。系统覆盖用户注册登录、退出、密码重置、药品分类与信息管理、收货地址、购物车、留言板、文件上传下载等业务模块并内置条件查询、记录统计、求和汇总等通用接口便于快速掌握前后端联调。压缩包共1271个文件大小约14.63MB主要包含112个Java后端源码、132个Vue页面、169个JS逻辑、78个WXSS样式、76个WXML模板以及JSON、XML、SQL、图片等辅助资源目录结构完整便于导入编译或二次开发。目前已有105人学习下载。资源附带1-install.bat、2-run.bat、3-build.bat等运行脚本及配置文件、开发环境说明可帮助读者快速启动项目并理解前后端交互流程整体目录结构清晰便于按模块检索与二次开发适合课程设计、毕业设计或小程序开发参考。1. 药店管理系统为什么偏偏是 Spring Boot 加微信小程序药店管理系统的业务本身很朴素店员打开手机查药有没有货、什么价格顾客买药时能下单结账下班前对一遍库存和营业额。它轻但琐碎。用 Excel 记会漏上大型 ERP 又太重。常见的落地方案是 Spring Boot 做后端管药品数据、用户和订单微信小程序做店员端扫一扫就能用不用装 App。这套组合的源码项目很多新手能照着跑通小药店也能真拿去用所以一直是 Java 全栈里被问得最多的方向之一。这篇文章会按我平时落地的顺序讲先拆模块和数据表再把 Spring Boot 后端跑通接着把微信小程序端接上最后单独列出最容易让项目翻车的几个坑。读完你可以照着复现这套系统也能避掉我在本地联调时踩过的那些莫名其妙的问题。2. 系统模块与数据模型先把药店业务拆成能落地的四张表2.1 为什么不是微服务Spring Boot MyBatis 在这个场景的选型逻辑很多人一拿到“药店管理系统”就想着上微服务注册中心、网关、分布式事务全来一套。但真实药店的规模吃不下这套东西。一家普通药房一天几百笔订单同时在线的店员也就一二十人单体 Spring Boot 应用打成一个 jar 就能跑部署、备份、排查都简单。微服务拆开以后服务间调用链路、分布式事务一致性、日志追踪全成了额外负担对这个项目毫无必要。Spring Boot MyBatis 才是这个场景最现实的组合网上大量 spring boot mybatis 的 java 开源商城源码、管理系统源码用的几乎都是同一套。选它不是因为新潮是因为 MyBatis 对 SQL 的掌控力比全自动 ORM 更直接。药店系统里条件查询和统计特别多比如“按编码查某个药还剩多少”“按时间段统计门店销售额”“查近一周处方药销量”这些业务直接写 SQL 最好排查也最好调索引。小程序端为什么不换成 App药店店员不一定会装陌生 App而且上架审核、版本更新对个体药店来说都是麻烦。微信小程序扫码即用后端发版后店员端下次打开就自动更新不需要任何安装动作。桌面后台用网页店员端用小程序的组合是这个项目里成本最低、见效最快的形态。2.2 核心数据表药品、库存、订单、用户四张表怎么建把药店业务抽到最简核心流转链是“店员下单、药品出库、金额入账”。围绕这条链最少需要五张表用户店员、药品、库存、订单、订单明细。这是一套最小可用的建表方案直接用 MySQL 执行即可。CREATE TABLE tb_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL, nickname VARCHAR(64) DEFAULT , phone VARCHAR(20) DEFAULT , role TINYINT NOT NULL DEFAULT 1, -- 1店员 2管理员 create_time DATETIME NOT NULL, UNIQUE KEY uk_openid (openid) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE tb_drug ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_code VARCHAR(32) NOT NULL, drug_name VARCHAR(128) NOT NULL, specification VARCHAR(64) DEFAULT , -- 规格比如 10mg*14片 unit VARCHAR(16) DEFAULT , -- 单位盒/瓶/袋 price DECIMAL(10,2) NOT NULL, prescription_flag TINYINT NOT NULL DEFAULT 0, -- 0非处方 1处方 create_time DATETIME NOT NULL, UNIQUE KEY uk_drug_code (drug_code) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE tb_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drug_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL, UNIQUE KEY uk_drug (drug_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE tb_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0已下单 1已支付 2已取消 create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE tb_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL -- 下单时价格冗余保存 ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;tb_drug 里的 drug_code 是店员能直接报出来的药品编码不要拿自增 id 去当编码给店员看实际业务里店员更习惯对着药盒上的编码来核对。tb_order_item 里的 price 是冗余字段这点很关键药品以后一定会调价但订单成交价不能被调价影响所以下单那一刻把价格复制一份到明细表。金额用 DECIMAL(10,2) 而不是 float避免浮点误差。外键我一般不建物理约束订单和明细之间用逻辑外键就够。原因是扣库存、写明细时 InnoDB 的行锁和索引行为更可控物理外键在复杂 SQL 下容易给优化器添乱。业务一致性靠 Service 层事务保证这正是 3.3 节要处理的事。2.3 接口约定给小程序用的一套 Result 返回格式前后端分离后第一个要统一的就是返回体。如果每个接口各自返回不同的 JSON 结构小程序端每个请求都要单独判断后面根本维护不动。我习惯先把 Result 类定下来所有接口统一走它。public class ResultT { private Integer code; // 0成功 401未登录 500业务异常 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message ok; r.data data; return r; } public static T ResultT fail(int code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }接口层的约定只有三条成功把数据放进 data失败返回 message未登录返回 401。小程序端在 request.js 里统一判断 code不跟 HTTP 状态码强绑定。这样后端即使把某些业务错误包装成 HTTP 200前端也能按业务 code 区分。接口清单初版就四个POST /api/auth/login 用微信 code 换 tokenGET /api/drugs 分页查药品POST /api/order 下单扣库存GET /api/order/list 查历史订单。权限上用 JWT 拦截器统一校验管理员维护药品和库存的接口单独加 role 校验。核心原则是小程序端任何页面都不要直连数据库表一切走 REST API以后换前端、接第三方都不用动表结构。3. 后端从零跑通Spring Boot 工程启动、登录接口与库存扣减3.1 最小可启动工程pom.xml、application.yml 与端口配置先解决一个问题intellij idea 社区版怎么用 Spring Boot社区版没有 Spring Initializr 向导但不影响开发新建 Maven 工程后手动补 pom.xml 和启动类Maven 一样能编译。常见的做法是先建一个空 Maven 工程坐标随便填然后把下面的 pom 依赖粘进去。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web 启动器包含内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis 与 Spring Boot 集成 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency !-- MySQL 8 驱动运行时使用 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- JWT 令牌生成与校验 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependencies版本选择上Spring Boot 2.7.18 是 2.x 的收尾版本网上资料最多MyBatis starter 用 2.3.2 配套最合适。如果你非要上 Spring Boot 3.x注意 mybatis-spring-boot-starter 要换 3.0同时 javax 包全部改名 jakarta这个切换坑过不少人。接着写 application.yml这里最容易影响启动server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pharmacy?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password jackson: time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueurl 里的 serverTimezoneAsia/Shanghai 在 MySQL 8 驱动下必填否则启动直接报时区异常useSSLfalse 是本地开发通用配置。mybatis.map-underscore-to-camel-case 打开后数据库里的 drug_name 自动映射成 drugName省掉手写 resultMap。server.port 就是喝多人问的 spring boot 修改端口号的位置改完重启立即生效。启动类三行就够SpringBootApplication MapperScan(com.example.pharmacy.mapper) public class PharmacyApplication { public static void main(String[] args) { SpringApplication.run(PharmacyApplication.class, args); } }MapperScan 指定 Mapper 接口所在包容易漏漏了会报找不到 mapper bean。启动成功后在控制台看到 Tomcat started on port 8080 就算基础工程通了。3.2 微信登录接口用 code 换 openid再换 JWT微信小程序登录的标准流程是小程序 wx.login 拿到临时 code传给后端后端拿 code 加上小程序 appid、secret去调微信的 jscode2session 接口换 openidopenid 是这个用户在药店系统里的唯一身份。后端给小程序返回一个 JWT后续请求带上 token不用再反复调微信接口。RestController RequestMapping(/api/auth) public class AuthController { // 登录小程序传 code后端返回 JWT PostMapping(/login) public ResultString login(RequestBody LoginRequest req) { // 1. 用 code 请求微信接口换 openid String openid wechatAuthService.code2Session(req.getCode()); // 2. 按 openid 查用户不存在则自动注册 User user userService.findOrCreate(openid); // 3. 生成 JWT有效期 7 天 String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.ok(token); } }调用微信接口这一步很多源码直接 new 一个 RestTemplate 就用我一般会把 RestTemplate 定义成单例 Bean避免每次登录都创建连接池。微信接口有频率限制连接复用在高峰期差别很大。public String code2Session(String code) { RestTemplate rt new RestTemplate(); String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code code grant_typeauthorization_code; MapString, Object resp rt.getForObject(url, Map.class); // resp 里会有 openid、session_key失败时带 errcode if (resp null || resp.get(openid) null) { throw new BizException(微信登录失败); } return resp.get(openid).toString(); }appid 和 secret 不要硬编码在业务类里用 Value 从 application.yml 读取方便不同环境切换。JWT 的签名密钥也放配置文件要求不低于 32 位随机字符串。token 校验用拦截器统一处理从 Header 里取 Authorization 的 Bearer token解析失败就返回 401 让前端跳登录页。这里最容易踩的坑是 token 过期时间和小程序端本地缓存不同步。小程序端如果一直拿着旧 token 请求后端一直返回 401用户会反复被踢回登录页所以 4.2 节的 request.js 里必须对 401 做统一处理不要在每个页面写自己的判断。3.3 库存扣减与下单事务边界写在 Service 层药店下单的核心动作是“插订单 扣库存”这两步必须在一个事务里。错误做法是先 SELECT 查库存判断够不够再 UPDATE 扣减因为并发场景下两个请求可能同时读到同一个库存数都判断通过都去扣最后库存变成负数。正确做法是写一条原子 UPDATE让数据库决定扣不扣成功。Transactional public Long createOrder(OrderCreateRequest req) { // 1. 生成订单号格式 yyyyMMddHHmmss 随机数 String orderNo buildOrderNo(); // 2. 按明细计算总价插入订单主表 BigDecimal totalAmount calculateTotal(req.getItems()); Order order Order.create(req.getUserId(), orderNo, totalAmount); orderMapper.insert(order); // 3. 逐行插入明细同时扣库存 for (OrderItem item : req.getItems()) { orderItemMapper.insert(OrderItem.create(order.getId(), item)); int rows stockMapper.reduceStock(item.getDrugId(), item.getQuantity()); if (rows 0) { throw new BizException(库存不足: item.getDrugId()); } } return order.getId(); }对应的 Mapper 语句写在 XML 里update idreduceStock UPDATE tb_stock SET quantity quantity - #{quantity}, updated_at NOW() WHERE drug_id #{drugId} AND quantity #{quantity} /update这条 UPDATE 的 WHERE 条件带上了 quantity #{quantity}数据库行锁会保证同一时刻只有一个事务能成功更新同一行影响行数为 0 就说明库存不够直接抛异常整个事务回滚。Transactional 必须加在最外层 Service 方法上写在 Controller 或 Mapper 接口上都不会生效这是事务边界最容易搞错的地方。另一个细节下单前不要为了“判断库存”而先查一次库存。查询和 update 之间有窗口期查到的结果在并发下根本不可信。查询库存只用于列表展示真正判断成败的是 UPDATE 的影响行数。4. 小程序端实操登录、药品列表渲染与下单链路4.1 原生小程序目录结构页面、app.json 与顶部导航栏高度小程序端最省事的方案是原生开发页面结构直接编译后体积可控不用额外引入 uniapp 那套依赖。顶层目录我一般这样组织pharmacy-miniapp/ ├── app.js ├── app.json ├── app.wxss ├── utils/ │ └── request.js ├── pages/ │ ├── login/ │ ├── drugs/ │ ├── order/ │ └── mine/app.json 里配置页面路径和全局导航栏{ pages: [ pages/login/login, pages/drugs/drugs, pages/order/order, pages/mine/mine ], window: { navigationBarTitleText: 药店管理, navigationBarBackgroundColor: #1aad19, navigationBarTextStyle: white } }导航栏高度在小程序里由系统控制不同机型不一样这就是很多人反复问“微信小程序顶部导航栏高度怎么设”的原因。默认导航栏直接用 navigationBarTitleText 就行不用管高度只有做自定义导航栏才需要 wx.getWindowInfo 读 statusBarHeight再叠加胶囊按钮高度去计算。药店系统建议直接用默认导航栏省掉一整块机型适配工作。页面之间跳转用 wx.navigateTo登录、药品、订单、我的四个页面足够覆盖最小闭环。订单详情这种低频页面用一层内跳完成不需要额外申请分包。4.2 登录页wx.login 拿 code用 button 拿手机号微信登录获取手机号现在是热门话题因为政策收得很紧。小程序里拿手机号必须用户主动点 button 授权open-type 设为 getPhoneNumber而且小程序需要完成认证、满足平台条件后才拿得到手机号明文。药店店员登录场景不建议死磕手机号先用 wx.login 完成身份登录手机号作为选填资料后续再补这样既不影响下单也避免被微信认证费用和审核规则卡住。登录页核心代码只有这么一段// pages/login/login.js const request require(../../utils/request) Page({ async onLoginTap() { // wx.login 拿到的是临时 code5分钟内有效只能使用一次 const { code } await wx.login() const token await request.post(/api/auth/login, { code }) wx.setStorageSync(token, token) wx.switchTab({ url: /pages/drugs/drugs }) } })wx.login 返回的 code 有效期短拿到后必须立刻传给后端中间不要插任何异步操作。如果后端返回 401 或业务失败错误提示要交给统一封装的 request 层去 toast不要在页面里再写一遍错误处理。utils/request.js 是全局命脉token 注入和统一鉴权都在这层// utils/request.js const request { baseUrl: https://your-domain.com/api, post(url, data) { return new Promise((resolve, reject) { wx.request({ url: this.baseUrl url, method: POST, data, header: { Content-Type: application/json, Authorization: Bearer wx.getStorageSync(token) }, success(res) { if (res.data.code 0) { resolve(res.data.data) } else if (res.data.code 401) { // 登录失效清 token统一跳登录页 wx.removeStorageSync(token) wx.navigateTo({ url: /pages/login/login }) } else { wx.showToast({ title: res.data.message, icon: none }) reject(res.data) } }, fail: reject }) }) } }这里最重要的不是 request 本身而是和 2.3 节 Result 约定的对应关系。凡是 code 为 401 就清 token 跳登录凡是业务失败就统一 toast。后续新增页面时错误分支不用重写。4.3 药品列表页请求封装、token 注入与本地购物车药品列表给店员看重点不是花哨 UI而是信息密度。药名、规格、价格、库存、是否处方药要一眼看到。列表页在 onLoad 时拉一次前端按关键字过滤减少无效请求如果加了分页就要配合 onReachBottom 触底追加。!-- pages/drugs/drugs.wxml -- view classdrug-list view classdrug-item wx:for{{drugs}} wx:keyid view classdrug-name{{item.drugName}}/view view classdrug-spec{{item.specification}} / {{item.unit}}/view view classdrug-price{{item.price}}/view button sizemini bindtapaddToCart>Page({ data: { drugs: [], cart: {} }, async onLoad() { const drugs await request.get(/drugs?page1size20) this.setData({ drugs }) }, addToCart(e) { const id e.currentTarget.dataset.id // 合并同类项同一药品只增加数量 const cart { ...this.data.cart, [id]: { id, name: findDrug(id).drugName, price: findDrug(id).price, count: (this.data.cart[id]?.count || 0) 1 } } this.setData({ cart }) wx.setStorageSync(cart, cart) } })这里有个容易翻车的点不要把本地 price 当作最终价格传给后端。订单最终金额以后端查表为准前端只传药品 id 和数量。如果前端把价格也传过去后端又不校验就可能出现有人改请求参数把价格改低的情况。购物车本地状态的好处是响应快店员点加购没有网络往返。但要注意清空时机订单提交成功后必须清掉本地购物车别把上一次的脏数据留到下一单。5. 避坑与排查从微信小程序 10002 报错到库存超卖5.1 端口被占Spring Boot 起不来时的第一眼排查现象控制台报 Port 8080 already in use或者提示 Failed to bind to 0.0.0.0:8080。最常见原因是 IDEA 或终端里残留了上一次的 Java 进程没有退出。排查和解决先看当前端口被谁占用# Linux / macOS lsof -i :8080 kill -9 pid # Windows netstat -ano | findstr :8080 taskkill /PID pid /F找到残留 PID 后杀掉再重新启动。把 server.port 改成 8081 也能解决但那是治标不治本项目里如果还有其他依赖 8080 的配置端口一改反而引入新问题。Spring Boot 修改端口号本身没有玄学改配置重启即可。5.2 小程序请求报错 10002合法域名校验不过现象真机预览时 wx.request 直接失败控制台提示 request:fail url not in domain list。在开发者工具里勾选“不校验合法域名”后能通一上真机又挂。原因微信小程序正式环境要求请求域名必须加入小程序后台的 request 合法域名列表同时要求 HTTPS且证书链要被微信校验通过。错误码 10002 在登录这类接口上很常见多半是域名证书不完整微信判定为非法请求被拒。解决开发调试期可以在详情里勾选不校验合法域名上线前必须到微信公众平台配置 request 合法域名换成正式签发的 HTTPS 证书后重新预览。这也能解释为什么很多源码项目本地能用、换个手机就废——请求域名根本没有通过微信的合法域名校验。用抓包工具确认一次实际请求的 URL 和证书状态比反复猜原因快得多。5.3 MySQL 时区报错驱动 8.x 的 serverTimezone 必填现象应用启动时数据源初始化直接抛异常报错里出现 The server time zone value 或常见的 InvalidConnectionAttributeException。原因MySQL Connector/J 8.x 出于安全策略强制要求明确指定客户端与服务器之间的时区换算标准不填它就拒绝连接。这个报错在本地 MySQL 8 环境首次启动时必现。解决在 datasource 的 url 里加 serverTimezoneAsia/Shanghai同时 JVM 时区、MySQL 时区保持一致性。改完还差 8 小时说明连接字符串没生效或者项目里引了旧版本驱动把驱动统一换成 mysql-connector-j 8.0.33不要再用老的 mysql-connector-java。5.4 库存变负数并发扣减的第一个血泪教训现象看起来完全没问题的下单代码并发压测时库存出现 -2、-5。原因“先查库存再 UPDATE 扣一”这两个操作之间存在窗口期。两个请求同时查到库存够用于是都执行扣减库存就穿过了零变成负数。解决改成单条原子 UPDATEWHERE 条件带上 quantity #{quantity}用受影响行数判断成败这就是 3.3 节的方案。不要用先查再改加 synchronized 的方式分布式环境下 synchronized 只在单机有效。药店场景下并发量有限单条 UPDATE 是成本最低、最可靠的做法等以后量大了再考虑 Redis 预扣减或乐观锁不必提前过度设计。5.5 真机白屏、编译失败小程序包超过 2MB 总限制现象我在一次交付时遇到 uniapp 导出的小程序包报 source size 2612kb exceed max limit 2mb工程编译不过真机直接白屏。原因图片、字体、大 JSON 数据、非必要库都塞在主包里超出了小程序主包 2MB 上限。解决图片全部换到在线存储或 CDN不要本地打包继续超限把商品详情、图表这类低频页面拆成分包。小程序 2MB 只是主包上限整个项目还可以申请总共不超过 20MB 的分包额度。如果项目是 uniapp 打包出来的还要在构建配置里去掉未引用的组件和工具库。压缩体积这件事放到功能跑通之后再做别一开始就为了体积牺牲代码结构。6. 交付前再补一脚抓包验证、HTTPS 部署与下一步扩容6.1 用抓包工具验证接口链路本地联调看开发者工具的 Network 面板就够真机环境就得用抓包工具。Charles 这类 PC 端抓包工具可以看到小程序发出的完整请求重点确认三件事baseUrl 是不是生产域名、token 有没有正确带在 Authorization 头、微信 jscode2session 那几次请求的返回值是不是正常。登录失败时抓包看一次返回的 errcode能快速区分是 appid 配置错、secret 过期还是域名证书问题不用瞎猜。6.2 部署打 jar 包配好 HTTPS 域名再发布后端部署最省心的是 Maven 打成 jar扔到云服务器上用 java -jar 直接运行。小程序发布之前URL 必须是已备案、已配置的 HTTPS 域名并且要加进微信后台合法域名列表。我在生产环境里会把 Nginx 绑好证书把外部 HTTPS 流量转到本机 Spring Boot 端口前端 baseUrl 写域名不带端口这样以后切换服务器或者加多实例小程序端不用改代码。证书到期是个容易忽略的定时雷记得提前加日历提醒。6.3 如果还要继续做批次、过期预警与报表现在这套系统的库存只有数量而药店实际业务还有批次、生产日期、有效期这些维度。往上扩展的方向是给 tb_stock 加批次表在 tb_order_item 里记录批次号再做一个临期预警定时任务。报表模块按天汇总营业额、毛利、处方药占比这些在现有四张表上增量扩展就能做不用推翻重来。我自己的习惯是交付前把 token 有效期、包体积、HTTPS 证书三个检查项放进验收清单吃过演示现场翻车的亏之后这三点我一次都不敢漏。小程序打包前先看一次主包体积超出 2MB 就拆分包上线前在微信群内测一周让店员真实操作一个完整的买药流程把所有报错截图统一处理。这套 Spring Boot 加微信小程序的药店系统难点不在单个技术点而在把登录、下单、扣库存这条链路完整闭环过程中每一步能跑通、每个报错能定位就足够支撑一家小药店的日常运营了。希望帮到你。本文还有配套的精品资源点击获取