简介一份基于Spring Boot框架与微信小程序的药店管理系统源码包面向计算机专业课程设计、毕业设计以及希望学习SSM、MyBatis Plus和uniapp前后端分离开发的读者覆盖从后端接口到小程序展示的完整业务链路。资源共包含1271个文件核心类型包括Java后端代码、Vue前端页面、WXML/WXSS小程序界面、JavaScript逻辑脚本以及PNG/SVG/JPG等界面图片素材同时附有SQL数据库脚本、XML/Properties配置文件和构建批处理文件压缩包大小14.63MB。功能模块涉及用户登录注册、药品分类与药品信息管理、购物车、收货地址、留言板及文件上传下载等目录按前端、后端、静态资源划分便于快速定位与二次开发。已有105人学习下载适合直接导入运行通过源码注释和结构梳理理解业务实现为课程汇报或项目扩展提供扎实基础。1. 药店管理系统这个项目为什么 Spring Boot 和微信小程序成了默认搭配接过药店管理系统这类活的人都知道最磨人的环节不是写接口而是跟店长对库存账同一盒药昨天盘点 20 盒今天卖了两单就剩 17 盒中间哪一笔没记上只能翻单子。基于 Spring Boot 框架和微信小程序做的这套药店管理系统就是把进销存从纸面搬到线上——后端用一个 Jar 包跑接口小程序端给店员做收银和盘点顾客端做在线下单一套代码两条链路都覆盖。这套源码适合三类人拿它做毕设或课程设计的学生接药店、诊所项目的小团队想快速搭个门店后台的技术负责人。下面从表结构讲到接口和小程序联调最后把过期预警和销量榜两个实用功能补上照着跑通不难。2. 先拆业务流程再建库药店进销存落到六类核心表上2.1 为什么先定表结构而不是先写代码药店的业务闭环是「采购入库 → 库存上架 → 销售出库 → 补货预警」中间还夹着批号和有效期。如果一上来就写 Controller后面加批次效期字段时接口、页面、报表全要返工。常见做法是先定六类核心表药品档案t_drug、批次库存t_stock、出入库流水t_stock_log、销售单与明细t_sale/t_sale_item、供应商t_supplier、员工与会员t_staff/t_member。选型上有一个容易忽略的点药品不要走通用商品表。处方药和非处方药字段差异大处方药要留处方编号和药师信息通用表要么加一堆空字段要么拆扩展表徒增复杂度。另外库存必须按批次拆行一个药品多个批号各有各的生产日期和有效期否则效期预警完全没法做。金额统一用decimal(10,2)数量用decimal(10,2)而不是 int因为拆零药按最小单位卖时可能是 0.5 盒。不要用 float0.1 加 0.2 的精度玄学在财务计算里是事故不是玩笑。时间统一datetime别混用 timestamp。表结构定稿前把这张表理清楚基本能覆盖一个单体药店的完整业务表名核心职责关键索引 / 字段t_drug药品档案、售价、处方标记drug_code 唯一索引category_id 普通索引t_stock批次库存、效期、库存量(drug_id, batch_no) 唯一索引t_stock_log每一笔出入库流水(drug_id, create_time) 联合索引t_sale / t_sale_item销售主单 明细sale_no 唯一索引sale_id 索引t_supplier供应商与采购价name 普通索引t_staff / t_member登录员工与会员mobile 唯一索引openid 普通索引2.2 药品档案与批次库存表有效期管理就靠这两张表建表我从头写顺手把字段含义标出来。这是整套系统的地基后面所有接口都在这个结构上跑CREATE TABLE t_drug ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, drug_code VARCHAR(64) NOT NULL COMMENT 国药准字或商品条码, name VARCHAR(128) NOT NULL COMMENT 药品名称, category_id BIGINT NOT NULL DEFAULT 0 COMMENT 分类ID, spec VARCHAR(64) NOT NULL DEFAULT COMMENT 规格如 0.5g*24片, unit VARCHAR(16) NOT NULL DEFAULT 盒 COMMENT 销售单位, manufacturer VARCHAR(128) NOT NULL DEFAULT COMMENT 生产厂家, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 采购价, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 零售价, prescription_flag TINYINT NOT NULL DEFAULT 0 COMMENT 1处方药, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1已删除, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_drug_code (drug_code), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品档案表; CREATE TABLE t_stock ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, drug_id BIGINT NOT NULL COMMENT 药品ID, batch_no VARCHAR(64) NOT NULL COMMENT 生产批号, produce_date DATE NOT NULL COMMENT 生产日期, expire_date DATE NOT NULL COMMENT 有效期至, stock_qty DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 当前库存数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_drug_batch (drug_id, batch_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT批次库存表;重点解释两个设计决策。第一t_stock按drug_id batch_no唯一约束不允许同一药品同一批号存在两行盘点和出库时按批号精确扣减。第二stock_qty旁边放version字段做乐观锁后面砍并发扣减造成的超卖问题要依赖它MyBatis-Plus 里对应Version注解。效期字段用DATE而不是DATETIME因为有效期只关心到天用 DATETIME 反而会在expire_date当天判断时引入时间偏差。expire_date上一定要建索引效期预警扫描是按日期范围查的没索引的话数据量上千之后扫描就开始变慢。2.3 销售主表与明细表先写流水再回写库存销售单的设计思路是「主单 明细 库存流水」三件套一起落库缺一不可CREATE TABLE t_sale ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, sale_no VARCHAR(32) NOT NULL COMMENT 销售单号日期序列, staff_id BIGINT NOT NULL COMMENT 操作员工ID, member_id BIGINT NOT NULL DEFAULT 0 COMMENT 会员ID0表示非会员, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, pay_type TINYINT NOT NULL DEFAULT 1 COMMENT 1微信 2支付宝 3现金, sale_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, delete_flag TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_sale_no (sale_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售主表; CREATE TABLE t_sale_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, sale_id BIGINT NOT NULL COMMENT 销售主表ID, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) NOT NULL COMMENT 冗余批号退换货用, qty DECIMAL(10,2) NOT NULL COMMENT 销售数量, price DECIMAL(10,2) NOT NULL COMMENT 成交单价快门价时可能不等于档案价, amount DECIMAL(10,2) NOT NULL COMMENT 小计金额, expire_date DATE NOT NULL COMMENT 冗余效期打印小票用, PRIMARY KEY (id), KEY idx_sale_id (sale_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售明细表;t_sale_item里冗余batch_no和expire_date不是为了省性能而是为了退换货时能定位到具体批次。如果只存drug_id顾客拿回一盒过期货要退货你根本不知道当初卖给他的是哪个批号。冗余字段会带来数据一致性维护成本但销售明细是只读追加的写完不再改这个成本可控。销售单的写入顺序有讲究先写明细、再减库存、最后插主单。任何一步失败整个事务回滚不会出现「库存扣了但销售单没生成」的脏账。sale_no生成规则我一般用「yyyyMMdd 4 位序号」每天从 0001 开始单机门店够用需要多门店并发时在前缀里加门店编号。2.4 库存流水、供应商与员工表报表和登录都靠它们还差三张辅助表。t_stock_log是每次出入库的流水账字段做成change_typeIN/OUT/ADJUST、change_qty、before_qty、after_qty、order_no、staff_id。这样做的好处是任何一段时间的对账都可以用流水重算库存而不是信任当前值。门店里经常出现「昨天盘点对不上」的情况有流水就能查是哪笔操作把数量改没了。供应商表和员工表相对常规。t_supplier重点记联系人、电话、账期t_staff要特别处理openid和mobile员工登录走微信小程序wx.login换openid后绑定到员工档案手机号是辅助手段不能反过来用手机号做主键。会员表t_member只在有储值或积分需求时建否则可以先用member_id0顶住非会员场景。顺带一提这套源码的表结构里逻辑删除delete_flag是个容易踩坑的设计。药品档案被顾客加入收藏后再下架如果物理删除收藏页会出现「药品不存在」的诡异状态。保留逻辑删除查询时由 MyBatis-Plus 的TableLogic自动拼接delete_flag 0删除就变成更新历史数据全都能追溯。3. Spring Boot 后端药品库存与权限拦截的最小可运行结构3.1 工程骨架和配置一个数据源、一个分页插件、一个端口解压这套源码后一般能见到两个工程目录pharmacy-admin后端和pharmacy-miniapp小程序端。后端工程结构常见如下pharmacy-admin/ ├── pom.xml ├── src/main/java/com/pharmacy/ │ ├── PharmacyApplication.java │ ├── config/ # MyBatis-Plus 分页、Web 拦截器 │ ├── controller/ │ ├── service/ │ ├── mapper/ │ └── entity/ └── src/main/resources/ ├── application.yml └── mapper/ # 自定义 XMLapplication.yml是一切的起点几个关键参数直接决定能不能跑起来server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pharmacy?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 10 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: global-config: db-config: logic-delete-field: deleteFlag logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case一定要开否则数据库的drug_code映射不到实体的drugCode全得靠注解救火。逻辑删除的全局配置写在 yml 里比在每个实体上加注解少写一堆重复代码。端口号如果被占用直接改server.port比如 8081。用 IntelliJ IDEA 社区版也不用担心——社区版没有 Spring 插件但能正常编译运行命令行mvn spring-boot:run或打 Jar 包都行IDE 只负责写代码不负责业务逻辑。数据源配置有个容易被忽视的点serverTimezoneAsia/Shanghai。不加这个MySQL 8 的驱动时区会跟本地时间差 8 小时登录时间、销售时间全错位查问题时会怀疑人生。3.2 统一返回结构和分页检索接口契约先定死后端接口必须有一个统一返回外壳否则小程序端每个请求都要try-catch判断各种字段。我习惯固定成ApiResultT结构是codemessagedata其中code0表示成功非 0 都是业务异常public class ApiResultT { private int code; private String message; private T data; public static T ApiResultT ok(T data) { ApiResultT r new ApiResult(); r.code 0; r.message ok; r.data data; return r; } public static ApiResult? error(String message) { ApiResultObject r new ApiResult(); r.code 500; r.message message; return r; } }有了外壳药品检索接口写起来很干净RestController RequestMapping(/api/drug) public class DrugController { Autowired private DrugService drugService; GetMapping(/search) public ApiResultPageDrug search( RequestParam(value keyword, required false) String keyword, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { return ApiResult.ok(drugService.search(keyword, page, size)); } }Service 里用 MyBatis-Plus 的 LambdaQueryWrapper 做条件拼接public PageDrug search(String keyword, int page, int size) { PageDrug p new Page(page, size); LambdaQueryWrapperDrug qw new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { qw.like(Drug::getName, keyword) .or() .like(Drug::getDrugCode, keyword); } qw.orderByDesc(Drug::getUpdateTime); return drugMapper.selectPage(p, qw); }这里有两个细节。第一or()后面必须跟like(Drug::getDrugCode, keyword)否则or会带着前面的条件一起失效变成全表查询。第二size参数建议在 Controller 层拦截一下超过 100 重置为 100防止有人传 100000 把数据库拖垮。分页返回值Page里的records、total、current字段是小程序端分页加载的依据字段名要提前和小程序端约定好。3.3 扣库存事务加乐观锁把并发扣成负数的问题堵住药店收银台最常见的并发场景是两个店员同时卖同一盒药都读到库存还剩 1 盒一个卖了出去另一个也提交成功最后库存变成 -1。纯靠 Java 代码判断库存不够用必须让数据库参与并发控制。推荐的做法是「事务 条件更新 乐观锁」三层防护。核心逻辑写在一个createSale方法里Transactional(rollbackFor Exception.class) public void createSale(SaleCreateRequest req) { BigDecimal total BigDecimal.ZERO; for (SaleItemRequest item : req.getItems()) { Stock stock stockMapper.selectOne(new LambdaQueryWrapperStock() .eq(Stock::getDrugId, item.getDrugId()) .eq(Stock::getBatchNo, item.getBatchNo())); if (stock null || stock.getStockQty().compareTo(item.getQty()) 0) { throw new BizException(库存不足); } stock.setStockQty(stock.getStockQty().subtract(item.getQty())); stock.setVersion(stock.getVersion() 1); int rows stockMapper.updateStockWithVersion(stock); if (rows 0) { throw new BizException(库存已被其他操作修改请重试); } stockLogMapper.insert(buildStockLog(stock, item, OUT)); total total.add(item.getAmount()); } saleMapper.insert(buildSale(req, total)); }配套的 XML 更新语句是精髓UPDATE t_stock SET stock_qty #{stockQty}, version version 1 WHERE id #{id} AND version #{oldVersion}updateStockWithVersion影响行数为 0 时说明这条库存已经被别的请求更新过当前事务直接抛异常回滚让用户重试。注意金额比较不能用或BigDecimal 必须用compareTo否则在精度不一致时会误判。Transactional(rollbackFor Exception.class)要显式声明默认只回滚 RuntimeException业务异常不受理会造成库存扣了但销售单没生成。3.4 登录拦截器接口不是谁都能调后端不是所有接口都公开的。登录、药品搜索可以白名单销售单提交必须校验登录态。用拦截器实现比在每个 Controller 里复制一遍 token 校验代码干净得多Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (/api/auth/login.equals(request.getRequestURI())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(staffId, claims.get(staffId)); return true; } catch (Exception e) { // 解析失败走 401 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录已过期\}); return false; } }注册拦截器时注意放行路径Configuration public class WebConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/drug/search); } }token 用 JWT HS256 签发过期时间建议 7 天药店店员每天多次打开小程序太频繁重新登录体验很差。密钥放 yml 配置里不要硬编码在代码中。上线后想看接口调用情况Spring Boot 的 actuator 可以暴露 health 和 metrics 端点配合 Spring Boot Admin 能看到接口 QPS 和响应耗时这个在源码里一般没实现加依赖后配置一个 security 用户就能用。3.5 编译启动一条命令跑到本地后端写完后验证三步走。先在命令行编译打包mvn clean package -DskipTests然后启动 Jar 包java -jar target/pharmacy-0.0.1-SNAPSHOT.jar启动无报错后看三件事第一端口起来没有curl http://localhost:8080/api/drug/search?page1size1应该返回 JSON第二MyBatis-Plus 有没有报找不到 mapper 的错误报了就检查MapperScan的包路径第三数据库连没连上报Communications link failure就是 MySQL 地址或端口不对。日志里出现Started PharmacyApplication就说明这个黑匣子已经亮了。4. 微信小程序端登录、检索与收银台下单的通路4.1 请求封装一处改 baseURL全局换环境小程序的工程骨架要比后端简单常见的目录划分是pharmacy-miniapp/ ├── app.js ├── app.json ├── app.wxss ├── utils/ │ └── request.js ├── pages/ │ ├── login/ │ ├── index/ # 药品检索列表 │ ├── detail/ # 药品详情 │ ├── cart/ # 收银台本地购物车 │ └── order/ # 下单结果联调期间最坑的就是请求地址。小程序开发者工具里能跑通是因为工具的宿主环境是 PC真机预览时localhost指向的是手机自己永远连不上电脑。所以请求封装必须把 baseURL 集中在一处const BASE_URL http://192.168.1.101:8080/api; // 开发环境局域网IP function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method, data, header: { Content-Type: application/json, Authorization: Bearer (wx.getStorageSync(token) || ) }, success(res) { if (res.data.code 401) { wx.removeStorageSync(token); wx.redirectTo({ url: /pages/login/login }); return; } if (res.data.code ! 0) { reject(new Error(res.data.message || 请求失败)); return; } resolve(res.data.data); }, fail(err) { reject(err); } }); }); } module.exports { request };token 统一在 header 注入401 统一跳登录页业务请求里就不需要关心鉴权逻辑。这个封装决定了全项目的请求风格后续所有页面都require(../../utils/request)就行。生产环境把BASE_URL换成已备案的 HTTPS 域名并在小程序后台配置合法域名开发阶段勾选开发者工具里的「不校验合法域名」真机预览同样要开调试。自定义导航栏的页面有个尺寸问题要注意wx.getMenuButtonBoundingClientRect()拿到的胶囊按钮位置是自动适配顶部导航栏高度的基础。不同机型状态栏高度不同写死top: 44px会在大屏手机上偏上、在带刘海的设备上顶头。用胶囊位置反推标题栏高度是原生小程序的通用做法。连锁门店还会遇到多账号的问题。多门店多小程序账号时不要在一个小程序里来回切换门店身份常见做法是给不同门店配不同 appid 的壳工程后端按 openid 区分员工再用门店字段做数据隔离。多个前端壳共用一个后端后端只需要在员工表里加store_id。4.2 手机号登录链路个人主体做不了先用 code 换 openid登录页面的核心逻辑只有两步调wx.login拿 code把 code 发给后端换 token。注意wx.login的 code 只能用一次登录页onLoad调一次就够不能在onShow里反复调const { request } require(../../utils/request); Page({ onLoad() { wx.login({ success: async (res) { try { const data await request(/auth/login, POST, { code: res.code }); wx.setStorageSync(token, data.token); wx.setStorageSync(staffId, data.staffId); wx.redirectTo({ url: /pages/index/index }); } catch (err) { wx.showToast({ title: 登录失败 err.message, icon: none }); } } }); }, bindGetPhoneNumber(e) { if (e.detail.errMsg ! getPhoneNumber:ok) { wx.showToast({ title: 需要授权手机号, icon: none }); return; } // e.detail.code 是一次性code有效期5分钟 // 传给后端调微信接口换取真实手机号 } });这里容易踩一个大坑微信小程序登录获取手机号的功能个人主体的小程序没有这个接口权限。接私活时如果甲方用个人主体注册的小程序手机号登录这条路走不通只能退回「账号密码 openid 绑定」的方式。开发阶段可以用测试手机号或后端 mock上线前改回来。后端拿到 code 后用 appid 和 secret 调微信的jscode2session接口换 openid再查t_staff表完成登录。secret 一定只能放在后端小程序代码里出现 secret 等于把后台钥匙贴在店门口。4.3 药品检索与加入收银台列表页、详情页、购物车的数据流列表页的核心是分页加载和搜索。小程序端和 MyBatis-Plus 的Page字段要严格对齐page对应currentsize对应size返回的records里才是数据列表const { request } require(../../utils/request); Page({ data: { list: [], page: 1, size: 10, keyword: , finished: false }, async loadList(reset true) { if (reset) { this.setData({ page: 1, finished: false }); } if (this.data.finished) return; const keyword encodeURIComponent(this.data.keyword); const data await request( /drug/search?page${this.data.page}size${this.data.size}keyword${keyword} ); this.setData({ list: reset ? data.records : this.data.list.concat(data.records), page: this.data.page 1, finished: data.records.length this.data.size }); }, onReachBottom() { this.loadList(false); } });onReachBottom触底加载时传false把下一页的数据追加到当前列表而不是覆盖。finished判断用records.length size而不是data.total因为总数查询在大数据量下也耗资源列表页没必要精确知道还剩多少条。加入购物车不调后端接口直接用wx.setStorageSync(cart, items)存在本地店员扫完几盒一起结账。购物车里存的是drugId batchNo qty页面展示时再从t_drug补商品信息。这个方案的好处是响应快坏处是如果顾客等太久库存可能已经被别人买走所以下单接口要保留第 3.3 节那套库存校验。金额单位必须全局统一。要么全用「分」做整数运算要么全用「元」但所有计算走Math.round或后端返回字符串。我在真实项目里见过前端0.1 0.2算成0.30000000000000004收银台金额对不上的情况这属于典型的前端精度坑统一用后端计算金额能绕开。4.4 下单提交价格以服务器为准失败时保留购物车提交订单时前端只传drugId、batchNo、qty不传价格。价格必须由后端按t_drug.sale_price重算防止有人改请求参数用 0.01 元买药async submitOrder() { const cart wx.getStorageSync(cart) || []; if (cart.length 0) { wx.showToast({ title: 购物车是空的, icon: none }); return; } try { const items cart.map(item ({ drugId: item.drugId, batchNo: item.batchNo, qty: item.qty })); await request(/sale/create, POST, { items }); wx.removeStorageSync(cart); wx.redirectTo({ url: /pages/order/order?success1 }); } catch (err) { wx.showToast({ title: err.message, icon: none }); // 购物车保留用户调整数量后重试 } }下单成功的标记是后端返回code0前端拿到后清空本地购物车再跳转。失败时不清理购物车店员改完数量还能重试。这里强烈建议在后端接口加一个DecimalMin(0.01)校验前端虽然也校验了正整数但恶意请求不一定走前端页面。4.5 包体积与真机差异为什么传一个包要先瘦身小程序单包体积限制是 2MB开发时如果图片直接放进工程随便几张药品图就超了。开发者工具会报source size 2612kb exceed max limit 2mb这类错误无论原生开发还是用 uni-app 打包主包超过 2MB 都会被拦。解决办法是把药品图片放到云存储或 CDN数据库只存图片 URL小程序端用image src{{item.imageUrl}}加载。公共样式抽到app.wxss页面级样式去掉重复代码也能省几十 KB。真机和开发者工具的最大差异是网络环境。PC 上开发者工具能直连局域网 IP手机真机预览则要求手机和电脑在同一个网段且后端服务绑定的 IP 不能被防火墙拦住。这些属于联调阶段才爆出来的问题放在下一章细说。5. 连调阶段最容易翻车的五个坑从接口 404 到库存负数5.1 五个典型联调报错每个都能耗掉半天这条翻车清单是我在多个类似项目里反复踩过的现象高度一致原因各不相同。现象 1开发者工具能打开页面手机预览一片空白或接口超时。原因BASE_URL写的是localhost或127.0.0.1真机上指向的是手机自己或者电脑防火墙拦了 8080 端口的局域网访问。解决把BASE_URL改成电脑的局域网 IP比如http://192.168.1.101:8080/api并确认手机和电脑同一网段。后端启动时一般默认监听0.0.0.0不用刻意改server.address但防火墙要放行端口。现象 2后端控制台一直打 401小程序端明明拿到了 token。原因后端期望的 header 是Authorization: Bearer xxx前端传成了Authorization: Bearerxxx或者 token 字段名对不上。解决在后端拦截器里加一行日志打印原始 header——log.info(auth authHeader)对比前后端拼串规则。不要靠猜打印出来比对着黑盒猜快得多。现象 3列表页中文乱码药品名变成「?????」。原因MySQL 连接串没有characterEncodingutf8或者建库时字符集用了 latin1。解决连接串补上useUnicodetruecharacterEncodingutf8建库语句统一DEFAULT CHARSETutf8mb4。utf8mb4 和 utf8 的区别是前者能存 emoji药品名称里偶尔会有特殊符号直接上 utf8mb4 一劳永逸。现象 4库存被减成负数或者两个人同时下单时库存对不上。原因Service 方法没加Transactional或者扣减逻辑是「先 select 再 update」两步走没有并发控制。解决把扣减语句改成UPDATE t_stock SET stock_qty stock_qty - #{qty} WHERE id #{id} AND stock_qty #{qty}影响行数为 0 时抛异常回滚要更稳就再加第 3.3 节里的 version 乐观锁。现象 5页面跳转超过 10 次后按钮失灵点哪儿都没反应。原因小程序页面栈上限是 10 层wx.navigateTo压栈超过限制后会静默失败。解决列表页进详情页用navigateTo从收银台跳下单成功页用redirectTo把当前页替换掉而不是继续压栈。购物车、登录这类页面用wx.switchTab或wx.reLaunch直接切。这五个问题几乎每一个都能通过「打印日志 确认环境」两步定位到最怕的是改两行代码就重新编译然后发现现象没变接着又继续乱改。联调期的血泪经验就一句话先确认请求真的发到了后端再查后端逻辑。5.2 微信登录的隐藏坑code 一次性、appid 不匹配、个人主体限制登录相关的报错是另一大类高发区现象和原因往往对不上因为微信那侧的黑盒不会告诉你完整上下文。最常见的三种现象后端调jscode2session第一次成功第二次用同一个 code 报错业务日志里看到 40029 或 40163。原因wx.login返回的 code 是一次性凭证5 分钟有效而且用一次就失效。如果生命周期里多次调wx.login后一次拿到的 code 可能是重复的前端还拿旧 code 去换。解决整个小程序生命周期只在登录页调一次wx.login后端把 code 结果缓存 10 分钟同一 code 二次请求直接返回缓存。现象微信返回 10002 之类的系统错误后端的 appid 和 secret 看着没问题。原因10002多数是「appid 不存在」或「secret 不匹配」有些源码里把 secret 写死在代码中换环境后没同步改。解决把 appid 和 secret 全部挪到application.yml按环境区分 profile启动时打印 appid 的前 4 位方便确认加载的是不是预期配置。现象真机上点「手机号登录」按钮没反应或者一直提示「该功能需申请」。原因微信小程序登录获取手机号的接口需要企业主体资质个人主体小程序没有权限开发版也只能在体验成员白名单里验证。解决先确认小程序主体是「企业」还是「个人」个人主体就换账号密码登录或在后端做测试手机号放行别跟微信的规则硬顶。5.3 联调环境边界CORS、合法域名与「玄学重启」接口在小程序里能通但如果在浏览器或第三方工具里调试先检查 CORS。小程序wx.request不受浏览器同源策略限制但浏览器调试接口时会报跨域错误。后端加一个简单的全局 CORS 配置允许开发环境来源即可上线后有 HTTPS 域名反向代理时再收窄。合法域名是另一个容易被忽略的边界。开发阶段勾选「不校验合法域名」确实能跑但真机预览时如果不勾选请求会直接失败页面停在 loading。记住一个原则开发环境靠「不校验合法域名 局域网 IP」发布环境靠「备案 HTTPS 域名 微信公众平台配置 request 合法域名」。这两套配置不能混否则就会出现「在办公室能跑、一到客户现场就挂」的怪事。最后讲一个看似玄学的现象改完application.yml或小程序app.js行为没有变化。最常见的解释是旧进程还在跑。后端改了端口但访问的还是旧 8080小程序改了BASE_URL但缓存还留着旧代码。遇到这种情况先查端口占用和进程列表再怀疑代码——很多「玄学」其实是环境问题不是逻辑问题。我自己的习惯是每次重启后端时清掉target目录重新打包小程序端改完配置后杀掉开发者工具进程重开物理上排除缓存干扰。6. 把库存做「活」过期预警与销量榜这两个小功能最值得加6.1 每天扫一遍效期把 90 天内到期的药品推给店长药店最怕的不是卖不动是顾客拿着过期货上门。效期管理光靠店长翻冰箱不现实加一个定时任务每天扫一次库存表90 天内到期的批次自动提醒Component public class ExpireWarnTask { Autowired private StockMapper stockMapper; Autowired private WarnService warnService; Scheduled(cron 0 0 7 * * ?) public void scanExpire() { LocalDate warnDate LocalDate.now().plusDays(90); ListStock stocks stockMapper.selectList(new LambdaQueryWrapperStock() .le(Stock::getExpireDate, warnDate) .gt(Stock::getExpireDate, LocalDate.now()) .gt(Stock::getStockQty, 0)); for (Stock stock : stocks) { warnService.push(stock.getDrugId(), stock.getBatchNo(), stock.getExpireDate()); } } }cron 表达式0 0 7 * * ?表示每天早上 7 点整跑一次错开营业高峰。le和gt的组合把「已过期」和「今天到期」都排除在外——已过期的应该走报废流程而不是预警没必要天天刷屏。预警窗口 90 天是个经验值社区店可以缩到 30 天连锁仓库可以调到 180 天按周转速度定。6.2 最近 30 天销量榜 SQL 与验证方法销量榜是店长最常看的报表也是这套系统里最有说服力的功能之一SELECT d.name, SUM(i.qty) AS sale_qty, SUM(i.amount) AS sale_amount FROM t_sale_item i LEFT JOIN t_drug d ON d.id i.drug_id WHERE i.create_time DATE_SUB(NOW(), INTERVAL 30 DAY) AND d.delete_flag 0 GROUP BY d.id, d.name ORDER BY sale_qty DESC LIMIT 10;用DATE_SUB(NOW(), INTERVAL 30 DAY)做滚动窗口而不是查自然月好处是店长任何时候打开都是「最近 30 天」的真实数据不用等到月初才能看上月报表。SUM(i.qty)按药品聚合销量相同的再按sale_amount排序能直接看出哪些是走量、哪些是走额。验证这两个功能有个取巧的办法先在测试库插一条「今天刚卖」的明细跑销量榜 SQL 确认排名变化再临时把定时任务的 cron 改成0/10 * * * * ?每 10 秒扫一次观察日志里的推送内容确认预警条件正确后改回0 0 7 * * ?。注意测试数据用完要删掉否则会把线上排名污染。我最早做这类系统时一上来就先把报表和预警全做完了结果正式跑起来发现库存流水缺了好几天对账对不上只能连夜补数据。后来学乖了核心表一定先落流水报表和定时任务放在二期先把进销存闭环跑稳。这套思路换成任何进销存场景都一样适用——表结构和联调方法是通用的业务变了底座没变。学会先建表、再连链路、最后补增强的顺序会少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取