SpringBoot+SSM实战:中药材门店库存批次与进销存系统设计
1. 为什么做中药材店铺管理系统业务痛点与方案选型1.1 门店管理到底管什么做这个系统之前我专门跑了几家做中药材生意的门店蹲了几天。不蹲不知道一蹲才发现这行跟普通超市、便利店的管理逻辑差别非常大。普通零售店管好货品条码、进销存、价格就能跑起来但中药材门店面对的是另一套完全不同的玩法药材按“品名产地规格炮制方法”区分同一个黄芪可能同时有好几个批次每个批次进货价不同、产地不同、采收年份不同销售时经常不是扫码出库而是按克称重、按处方抓药一单里十几味药是常事最关键的是药材有保质期和养护要求放久了虫蛀、霉变、走油账面库存还没动实物已经不能卖了。这些场景凑在一起靠Excel登记基本是灾难。我见过一家门店用七八个表格来回倒数据采购单、入库单、销售小票、盘点表各记各的月底对账能对到凌晨。库存预警更是全靠老师傅的经验——“这味药快断货了、那批防风的货快过期了”都装在脑子里人一休息生意就乱。所以这个中药材店铺管理系统要解决的核心问题不是简单地做一套增删改查后台而是把中药材特有的批次、保质期、产地、规格、按克出库这些逻辑真正落到系统里。1.2 技术栈选型为什么是JavaSpringBootSSM这套组合聊技术选型之前先直接说结论这个项目用的是Java SpringBoot SSMSpring SpringMVC MyBatis的组合。很多初学者会困惑SpringBoot本身不是已经整合了SpringMVC吗为什么还要再提SSM实际上这里的“SSM”更多是指项目里以Spring生态为核心、持久层用MyBatis的这套经典架构SpringBoot负责自动配置和快速启动SpringMVC负责请求路由和控制层分层MyBatis负责数据访问。三者各管一段配合起来非常清晰。为什么做门店管理系统要选这套而不是Node.js、PHP、或者前端的纯云开发方案我自己的理解是这一类中小型管理系统的核心需求是稳定、可控、好维护。Java生态在事务管理、并发处理、权限框架上非常成熟比如库存扣减时要保证不超卖Spring的声明式事务加数据库锁就能稳妥解决而Node.js、PHP能做但团队在不同人接手时水平参差不齐容易把代码写飞。另外SpringBoot相比传统SSM的XML配置做了大量简化内嵌Tomcat打一个Jar包就能部署省去了单独装Web服务器的麻烦开发体验好很多。但如果纯粹只追求“跑起来”又没必要用微服务那套重武器——一个单体应用足够了拆成微服务反而把门店系统搞得运维负担太重。单说MyBatis的选择也很有意思。中药材管理系统的数据查询经常是多表关联加动态条件比如按品名、产地、供应商、入库时间范围查库存流水条件组合特别多。MyBatis的动态SQL在这种场景下非常灵活XML里写where、if标签就能根据前端传来的参数拼查询条件不用在Java代码里手工判断拼接SQL。相比之下Spring Data JPA虽然也能做但复杂查询的SQL控制和性能调优没有MyBatis直观。所以对于这个项目SSM里的“M”指MyBatis是恰到好处的。2. 系统核心功能模块拆解2.1 基础档案药材库、供应商、客户三本账系统的根基是基础档案模块这里我把它分成三条线药材信息库、供应商档案、客户档案。药材信息库是整个系统最核心的档案表。每一条药材记录除了常规的名称、编码、分类根茎类、果实类、花叶类等、计量单位我还专门设计了几个面向行业场景的字段产地、采收年份、炮制规格、储存条件、保质期月。你别小看这些字段它们决定了后面进货、销售、盘点能不能按中药材行业的习惯去操作。比如“当归”和“当归酒制”虽然名字上都带当归但在临床上用法完全不同采购价也差很多如果把炮制规格不单独建字段后面统计和库存就会混成一锅粥。另外产地字段对于药材采购来说非常关键——同一个品名甘肃产的当归和云南产的当归价格可能差百分之三四十销售时客户也会指定产地所以这个字段必须贯穿采购和销售全流程。供应商档案和客户档案相对常规但也要避免偷懒只建一个名称字段。供应商建议记录联系人、电话、资质证号、主营品种、结算方式客户档案则建议区分零售客户和批发客户批发客户可能需要月结、授信额度这跟后面的销售单和收款功能是挂钩的。做基础档案时我有两个经验一是编码规则要在项目启动前定好比如药材编码用“拼音首字母四位序号”不要等数据录了一半再改规则否则批量导入时容易乱二是基础档案的删除一定要做假删除逻辑删除用status字段标记停用即可因为历史单据里引用了这些档案物理删掉后对账会很麻烦。2.2 采购入库批次、保质期、货位缺一不可采购入库是经营数据的源头。传统门店的做法是进货后直接在账本上加总库存但中药材这种商品带批次属性一旦混着算库存后面查质量、退换货、过期预警就全乱套了。所以这个系统的采购模块我设计成两层采购单和采购单明细。采购单管理一次采购行为的头信息包括供应商、采购日期、采购员、期望到货日期、经手人、整单备注。采购单明细则逐条记录本次采购了哪些药材、数量、进价、生产/采收批次号、生产日期、有效期至。这里有个细节容易被新手忽略一个采购单里同一味药材可能在不同供货商的不同批号下进货价不同所以不能把“药品数量金额”一条明细塞进去必须支持同一药材多条批号记录。到货时根据采购单明细自动生成入库单库存表会在每个批次上追加数量同时写入库存流水表一条入库流水对应一个批次。关于货位我给药材库存表保留了一个shelf_location字段类似“A区-03-2”这样的编码。中药材门店通常有货柜、抽屉、阴凉库等多个存储位置同一种药材可能存在两个货位销售出库时可以指定从哪个货位扣减。不是每个系统都需要这么细但如果你把客户目标定位成“有点规模的中药材门店”这个字段值得保留。2.3 销售出库按克称重与处方抓药的特殊逻辑中药材门店的销售跟便利店销售最大的区别就是“按克出库”。客户可能说“给我来20克三七粉”也可能拿一张方子来抓药上面十几味药每味药剂量不同。这要求销售模块不能只支持扫码整件出售还得支持手动输入药材、输入重量克、自动计算金额。我实现的销售单流程是创建销售单头客户、销售日期、收款方式、整单折扣、应收金额、实收金额→ 销售单明细逐条添加药材选品、填克数、取单价、算金额→ 保存时逐条检查库存批次可用量并扣减对应批次的库存 → 生成销售出库流水 → 更新客户累计消费金额。这里有个很难注意到的逻辑中药材重量在电子秤上可能是小数但库存账上按“克”存储时高频次销售会导致库存出现浮点误差。我在设计数据库时把数量字段统一用DECIMAL(12,3)即保留三位小数而不是用FLOAT或DOUBLE因为浮点数在MySQL里累加扣减多了会丢失精度而DECIMAL是精确定点数。这一点看起来不起眼实际用上一两个月后对账差别很大。处方抓药场景还涉及一个“拆零”操作。客户拿方子来大多数药材不需要整包出售而是从整包中拆出几十克。为了不让拆零把库存批次搞乱我统一用“批次库存扣减出库流水记录”的方式处理任何一次销售出库都精确到批次不额外做拆包单。这样设计的好处是后续如果某批药材出现质量问题需要召回可以直接查出该批次卖给了哪些客户。2.4 库存盘点与预警让老师傅的经验变成系统逻辑库存管理模块在这个系统里承担着核心决策支持功能。除了常规的当前库存查询、出入库流水查询我把重点放在两个能力上库存盘点和预警。盘点的难点在于“账实相符”。门店每隔一段时间就要全面或抽样盘点传统做法是打印纸质盘点表人拿着纸去货架前数数完回到电脑前录入差异再回头复核来回折腾效率很低。我在系统里设计了盘点单流程新建盘点单 → 选择需要盘点的药材范围 → 生成盘点快照当前账面数量 → 按盘点单录入实际数量 → 系统自动计算盘盈盘亏 → 确认后生成库存调整流水并更新库存。这种方式至少省去了录纸质表再录入的步骤而且每一条库存调整都有单据可追溯审计时心里有底。预警功能则是用数据库查询定时任务实现的。用一个Spring定时任务每天扫描库存表两个核心预警维度一是库存不足预警每个药材设置min_stock预警线低于线自动生成“建议补货”提示二是效期预警按药材保质期和有效期至字段提前90天、30天分别标黄标红。这个逻辑做起来不难但业务价值极高等于把老师傅脑子里“哪批货快不行了”的经验沉淀成了系统自动扫描门店经营者每天早上看一眼预警列表就能安排补货和处理临期药材。2.5 角色权限老板看全局店员做业务权限模块我用的是经典的RBAC基于角色的访问控制模型用户表、角色表、菜单权限表、用户角色关联表、角色菜单关联表。针对门店场景我把角色划分成三类管理员老板、店长、店员。管理员拥有全部权限包括系统配置、数据统计、用户管理店长在管理员基础上增加查看进价成本、审核采购单、处理盘点单的功能店员只能操作日常零售开单、客户管理、查看本人销售记录不能看成本价和整体利润报表。这样做一方面是商业数据安全考虑另一方面也避免店员误操作导致核心配置被改动。权限拦截我推荐在SpringBoot里用拦截器HandlerInterceptor实现先按登录状态拦截未登录请求再按注解或路径规则判断当前角色是否拥有该接口权限。在Controller层配合自定义注解RequiresPermission(system:user:list)AOP统一校验代码会非常清爽。实际开发中我见过很多项目把权限判断写成每个Controller里if(user.getRole()1)短平快但后面加角色或调整菜单权限时到处都是补丁建议项目开始就按注解方式做。3. 数据库设计与核心表结构3.1 药材信息表设计字段到底怎么定数据库设计是这类管理系统的地基。地基没打好后期所有功能都是空中楼阁。我这里直接给出核心表的设计思路配合主要字段说明。药材信息表t_herb_info的字段设计如下字段名类型说明idBIGINT主键自增herb_codeVARCHAR(32)药材编码唯一herb_nameVARCHAR(64)药材名称categoryVARCHAR(32)分类如根茎类、果实类origin_placeVARCHAR(64)产地harvest_yearVARCHAR(16)采收年份processing_methodVARCHAR(32)炮制规格如生品、酒制、醋制unitVARCHAR(16)计量单位默认克shelf_life_monthsINT保质期月min_stockDECIMAL(12,3)库存预警下限sale_priceDECIMAL(10,2)零售价statusTINYINT状态0停用 1启用remarkVARCHAR(255)备注有几个字段需要单独强调。herb_code建议统一生成规则不要手工输入比如“H001”“H002”这样顺序编号也可以带分类前缀。shelf_life_months和origin_place在后续的效期预警和采购分析中会反复用到所以建议一开始就建好不要后面加字段。processing_method是中药材行业特有的维度同一个药材名配上不同炮制方法就是两个商品SKU我在做唯一约束时也特意设置为(herb_name, processing_method, origin_place, harvest_year)组合唯一防止重复建档。3.2 库存批次表的妙用批次效期货位三层结构库存表是整个系统的“账本”我建议拆成两张表来设计库存批次表t_stock_batch和库存汇总表t_stock_total。库存批次表每一条记录代表一个药材在某批次下的库存行字段包括id、herb_id、batch_no批次号、purchase_price批次进价、production_date生产日期、expire_date有效期至、quantity当前批次剩余数量、shelf_location货位、supplier_id供应商、status批次状态正常/锁定/过期。这里的关键是每一批次独立记录进价和有效期这为后续的先进先出出库、毛利计算、效期预警提供了数据基础。库存汇总表则按herb_id聚合了一个药材的总库存数字段包括id、herb_id、total_quantity、unusual_quantity预留/锁定数量、updated_time。日常查询列表用汇总表速度会非常快不用每次SUM(quantity) GROUP BY herb_id扫全表。这里就是典型的“空间换时间”思路管理系统的库存列表、Dashboard看板都要频繁查当前库存如果每次都重算汇总数据量一大就卡。批次出库逻辑上我建议按“先进先出”FIFO原则处理。系统在创建销售明细后按到期时间从早到晚依次扣除各批次数量。比如某个药材有三个批次客户要50克那么就优先扣最早到期的那批不够再扣下一批。这套逻辑写成一个事务里的Service方法扣减时用SELECT ... FOR UPDATE锁住相关批次行防止并发操作超扣。3.3 销售订单与出入库流水单据和流水分离更利于对账进销存管理系统的另一条关键设计原则是“单据与流水分离”。通俗讲订单表和库存流水表各司其职订单表记录业务发生的事实谁在什么时间向谁买了什么流水表记录库存变动的明细账目。销售主表t_sale_order字段包括id、order_no单号、customer_id、sale_date、total_amount应收、discount_amount优惠、actual_amount实收、pay_type支付方式、operator_id操作人、status、remark。销售明细表t_sale_order_item包括id、order_id、herb_id、herb_name_snapshot药材名称快照、quantity、unit_price、amount。这里建议把药材名称做快照存储因为药材名称字段在后续修改时不影响历史单据显示否则一旦改名或停用历史订单上显示的药材名就跟着变了对账时非常困扰。出入库流水表t_stock_flow字段包括id、herb_id、batch_id、flow_type采购入库/销售出库/盘点调整/报损、biz_no关联业务单号、quantity正负值入库为正、出库为负、before_quantity、after_quantity、operate_time、operator_id。流水表是审计追溯的依据所有库存变动都必须有据可查。每次扣库存前记录before_quantity扣完记录after_quantity这看起来啰嗦但在排查数据问题时价值连城——你能从流水里完整还原某批货的“一生”。4. 后端实操SpringBoot整合SSM的关键实现4.1 工程结构组织分包别拍脑袋按功能模块划很多初学者建SpringBoot工程时喜欢按controller、service、mapper、entity这种“按层分包”所有Controller塞一个包所有Service塞一个包。项目小的时候看不出问题一旦模块变多一百多个Java文件堆在几个包里找文件要用IDE的全局搜索协同开发时改文件的冲突概率也大。这个项目我采用的是“按功能模块分包模块内再分层”的结构目录大致如下com.herb.shop ├── common // 通用工具、常量、异常处理、返回结果封装 ├── config // SpringBoot配置类、拦截器注册、跨域配置 ├── module │ ├── system // 系统管理用户、角色、菜单 │ ├── herb // 药材档案 │ ├── purchase // 采购管理采购单、入库单 │ ├── sale // 销售管理销售单、出库 │ ├── stock // 库存管理批次、盘点、流水 │ ├── report // 报表统计 │ └── customer // 客户管理这样做的直接好处是改销售模块的代码不需要在几十个包之间跳来跳去一个purchase包底下把Controller、Service、Mapper、Entity全部放在一起属于“高内聚”的组织方式。通用模块common里放统一返回结果类ResultT、全局异常处理器GlobalExceptionHandler、分页对象PageResultT保证所有接口返回结构一致前端对接省心很多。4.2 MyBatis多表联查与动态SQL复杂查询的解法MyBatis在这个系统里承担着所有数据访问职责。我的做法是所有的简单单表操作用MyBatis-Plus复杂多表查询在XML里手写SQL。这样各取所长单表CRUD不写重复代码复杂查询SQL可控、好调优。举一个库存列表查询的典型案例。前端需要展示一份按条件筛选的库存列表条件包括药材分类、品名关键字、产地、是否有库存、效期预警范围。对应SQL其实要关联t_herb_info、t_stock_batch、t_stock_total三张表还要带分页。用MyBatis动态SQL来实现大概是这个样子select idselectStockList resultTypecom.herb.shop.module.stock.vo.StockVO SELECT h.id AS herbId, h.herb_name AS herbName, h.category AS category, h.origin_place AS originPlace, h.processing_method AS processingMethod, h.sale_price AS salePrice, ht.total_quantity AS totalQuantity, (SELECT COUNT(*) FROM t_stock_batch b WHERE b.herb_id h.id AND b.expire_date lt; DATE_ADD(NOW(), INTERVAL 90 DAY) AND b.quantity gt; 0) AS expireSoonBatchCount FROM t_herb_info h LEFT JOIN t_stock_total ht ON ht.herb_id h.id where h.status 1 if testcategory ! null and category ! AND h.category #{category} /if if testherbName ! null and herbName ! AND h.herb_name LIKE CONCAT(%, #{herbName}, %) /if if testoriginPlace ! null and originPlace ! AND h.origin_place #{originPlace} /if if testonlyHasStock AND ht.total_quantity gt; 0 /if /where ORDER BY h.herb_code /select这里有两个容易被坑的点。第一if testonlyHasStock在Java侧接收的是布尔类型但前端传参时要确保false能被正确解析SpringMVC中前端如果传字符串false用Boolean接收没问题但如果传空字符串就会转换失败所以前端要做好类型转换或后端提供默认值。第二动态SQL里的符号要写成lt;转义很多新手第一次写XML里的SQL在expire_date DATE_ADD(...)这里直接写了小于号结果XML解析直接报错排查半天。这是MyBatis XML文件的经典问题写的时候要格外注意。4.3 库存扣减的并发处理别让超卖成为事故库存扣减是进销存系统的核心事务也是最容易出bug的地方。想象一个场景两个店员同时开单都要买某味药库存只剩100克A要买60克B要买60克如果两条请求并发执行“查出库存量满足→再扣减”就很可能出现双双通过检查、最后库存变成-20克的局面。我采用的方案是“数据库行锁事务”核心代码逻辑如下Transactional(rollbackFor Exception.class) public void deductStock(ListSaleItemRequest items) { for (SaleItemRequest item : items) { // 按批次先进先出锁定批次行 ListStockBatch batches stockBatchMapper.selectAvailableBatchesForUpdate(item.getHerbId()); BigDecimal remain item.getQuantity(); for (StockBatch batch : batches) { if (remain.compareTo(BigDecimal.ZERO) 0) { break; } BigDecimal deductQty remain.min(batch.getQuantity()); batch.setQuantity(batch.getQuantity().subtract(deductQty)); stockBatchMapper.updateQuantity(batch.getId(), batch.getQuantity()); // 写库存流水 stockFlowMapper.insertFlow(...); remain remain.subtract(deductQty); } if (remain.compareTo(BigDecimal.ZERO) 0) { throw new InsufficientStockException(库存不足: item.getHerbName()); } } }这里的关键在于selectAvailableBatchesForUpdate方法要在SQL里加FOR UPDATE把满足条件的批次行锁住直到事务提交才释放。这样一来两个并发请求会串行执行第二个请求等锁释放后再查库存厚实就检查出不足了。关于Transactional的使用有个注意事项事务默认只在遇到RuntimeException时回滚如果方法里手动catch了异常但没有重新抛出事务不会回滚就会导致库存扣减了但业务没有完成。我习惯在事务方法里要么不catch异常要么catch后throw new RuntimeException(e)保险一点。4.4 登录与权限拦截器从Session到JWT的取舍这个项目登录态方案我选了JWTJSON Web Token。做管理系统用Session也能跑但SpringBoot项目里用JWT有几个好处前端把Token存在localStorage请求时在Header里带上后端无状态校验不依赖服务端Session存储集群部署或多实例时不需要额外做Session共享调接口测试也方便Postman里配一个Token就能访问。JWT的接入流程是这样的登录接口校验用户名密码通过后用jjwt库生成TokenToken中携带用户ID和角色编码设置过期时间比如8小时后端写一个JwtAuthInterceptor拦截所有除登录外和静态资源外的请求从Header的Authorization取出Token解析校验签名和有效期然后把用户信息放入ThreadLocal供Controller层随时取用。这里提醒一个实践细节不要在JWT里放敏感信息比如手机号、身份证号因为JWT的Payload只是Base64编码并不是加密任何人拿到Token都能解码看到里面的内容。放用户ID、角色编码这种非敏感信息即可。权限判断我是在拦截器基础上再叠加一个自定义注解AOP实现。定义一个注解RequiresPermission(stock:batch:update)在Controller方法上标注配合一个Aspect切面在方法执行前检查当前用户角色是否拥有对应的权限标识。这样开发新接口时一行注解就把权限控制声明好了不用在每个方法里手写判断逻辑。4.5 报表统计经营数据能直观看出来报表模块是老板最关心的功能也是这个系统区别于普通进销存记录的核心亮点之一。我实现了三个核心报表月度销售报表按天统计销售额和订单数SQL用DATE_FORMAT(sale_date, %Y-%m-%d)分组左关联订单明细表求出每日应收和实收再计算每日客单价和成交订单量。前端用折线图展示能一眼看出本月的销售趋势。毛利分析报表按批次进价和销售价计算单品毛利。这里依赖库存批次表里的purchase_price在销售出库扣批次库存时同时把该批次的进价带到销售明细表或者把进价冗余到t_sale_order_item的cost_price字段上这样报表就不需要再反查批次表减少关联复杂度。我实际用的是后者虽然冗余了一个字段但报表查询速度快逻辑清晰。滞销品分析报表统计最近90天没有动销记录的药材按当前库存量和保质期倒序排列帮助门店发现积压库存和即将过期但没人买的货。报表模块的实现难度不高但要注意一个前期设计问题如果是从零开发建议在设计销售明细表时就预留cost_price字段否则一到报表阶段发现缺数据又要回填历史数据极其痛苦。5. 前端与接口对接的关键细节5.1 前端技术方案Layui和Bootstrap快速搭后台这个项目的前端我选的是Layui——后台管理系统的开发效率非常高。Layui提供了一整套现成的组件表格、表单、弹窗、日期选择器、树形菜单最关键的是它对后端返回的JSON结构要求非常简单直接在table.render里配置url就能拿到数据渲染成表格分页也由组件自动处理。当然选Layui也有一个原因前端不需要复杂的Node.js构建流程直接在页面里引入layui.js和layui.css就能用部署时也不需要处理静态资源打包问题。对于门店管理系统这种内部使用的工具型系统开发速度比技术炫技更重要。前端页面我按模块划分左侧栏菜单对应系统管理、药材档案、采购管理、销售管理、库存盘点、报表中心每个菜单对应一个列表页。列表页统一风格顶部是筛选条件栏中间是表格底部是分页条新增/编辑用弹窗表单保存后刷新表格。这种模式非常成熟后端的CRUD接口一旦稳定前端页面只需要照着模板套。5.2 接口返回结构统一前端少一点if else接口设计我强烈建议统一返回结构。这个项目里我定义了统一的ResultT类public class ResultT implements Serializable { private Integer code; // 200成功非200失败 private String msg; // 提示信息 private T data; // 数据 // 静态方法 success(data)、error(msg) 等 }所有Controller接口都返回Result对象成功时Result.success(data)失败时抛出业务异常由全局异常处理器捕获后转换成Result.error(msg)。这样前端拿到响应后只需要判断code是否为200不用每个接口都猜返回结构。分页接口的返回结构我统一设计成PageResultT包含total总数、list数据列表、pageNum当前页码、pageSize每页数量。前端Layui的table.render底层会解析data.content对应列表数据data.total对应总数。这里保持一致很重要否则每个页面的渲染回调都要针对不同格式处理易错且难维护。跨域问题也要提前处理。开发环境前端和后端端口不同比如前端9000端口后端8080端口浏览器的同源策略会拦截Ajax请求。我采用后端增加跨域配置类的方式解决在SpringBoot里定义全局CORS配置允许指定来源和Header这样开发联调时完全无障碍部署后如果前后端都在同一域名下这个配置也不会带来负面影响。5.3 从需求到实现一个小模块的前后端联调全流程拿“新增药材档案”这个功能来走一遍完整流程能帮助理解前后端怎么协作。前端在药材档案管理页面点击“新增”按钮弹出一个表单弹窗表单字段至少包含药材名称、编码、分类、产地、采收年份、炮制规格、计量单位、保质期月、零售价、库存预警下限。用户在弹窗里填写完整点击保存。前端用Layui的form.on(submit(saveHerb))监听表单提交把表单里填的数据用JSON.stringify序列化通过Ajax的POST方法发送到后端接口/api/herb/add请求体里带上Content-Type: application/json。后端在HerbController里接收PostMapping(/herb/add) public ResultLong addHerb(RequestBody Validated HerbAddRequest req) { Long herbId herbService.addHerb(req); return Result.success(herbId); }RequestBody负责把JSON反序列化成HerbAddRequest对象Validated配合字段上的NotBlank、NotNull注解完成参数校验如果校验失败全局异常处理器把错误信息收集后返回给前端。HerbService在事务内完成两件事先生成药材编码然后插入t_herb_info主表同时初始化一条库存汇总记录总库存为0。保存成功后返回新记录的ID前端拿到成功后刷新表格数据新增的药材立即出现在列表第一页。这个流程看似简单但有几个经验新增接口一定要做唯一校验比如药材名称炮制规格产地的组合已经存在时应该提示“重复建档”否则地数据越录越乱前端表单校验和后端校验要同时做前端校验为了提升用户体验后端校验才是数据安全的底线因为接口可以被Postman直接调用绕开前端限制。6. 常见问题与排查技巧实录6.1 时区配置与日期处理这个坑几乎每个SpringBoot项目都会踩。MySQL连接串如果没有显式指定serverTimezone默认可能用服务器本地时区而应用服务器时区又可能是UTC最终导致从数据库查出来的DATETIME比实际时间少8小时。我的解决方案是在JDBC连接串中明确写死时区jdbc:mysql://localhost:3306/herb_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue同时建议在Java实体类中日期字段用LocalDateTime类型不要用java.util.Date。LocalDateTime配合MyBatis-Plus的自动类型处理不管是数据库读取还是JSON序列化行为都非常稳定不会出现多线程环境下SimpleDateFormat的线程安全问题。“有效期至”这类跟业务紧密相关的日期在入库时一定要根据“生产日期保质期月数”自动计算。比如生产日期是2024年6月1日保质期是24个月expire_date应该自动算为2026年6月1日不要让用户手工填。我这里用Java的LocalDate.plusMonths()计算提交时顺带在后端校验“有效期至是否大于当前日期”不合法的批次不允许入库。6.2 Long类型ID精度丢失前端数字的经典事故MySQL主键我用的是BIGINT自增后端实体类对应Long类型。问题来了Long类型的值超过JavaScript的Number安全整数范围2^53-1时前端拿到手会丢失精度。用户ID、订单ID这些超长数字到了浏览器里可能就变成123456789012345680再回传来查库查不到数据。这个问题的解决方案有很多最省事的做法是在返回给前端的JSON序列化阶段把Long类型统一转成字符串。在SpringBoot里给Jackson配置一个自定义的ToStringSerializer只针对Long.class应用Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; }这样前端拿到的ID是字符串类型不会丢精度后端接收时自动转回Long也不会出错。这个配置看起来简单但在任何老项目中加这个功能都能避免一大类“找不到数据”的诡异bug。6.3 大批量药材录入优化批量插入与事务边界如果是存量门店第一次用系统最头疼的是把几百条药材档案从一个Excel表导进系统。我做了Excel导入功能用EasyExcel库解析上传文件逐行校验后保存。如果一行一行插入数据库几百条可能要等很久用批量插入则能大幅缩短时间。EasyExcel读取文件的回调逻辑是invoke方法每次拿到一批数据我在批量插入时采用每100条为一个批次INSERT INTO t_herb_info (...) VALUES (...),(...),(...)的方式。这里要注意事务边界如果是所有数据都在一个事务中中间有一条数据非法整个文件回滚用户需要修好错误后重新导入如果每100条一个事务则可能出现部分成功部分失败。针对药材档案导入我倾向于“全部合法才提交”的策略因为药材编码和唯一约束如果部分成功用户很难搞清楚哪些已导入、哪些没导入。前端导入前先做格式预检后端再次校验双重控制保证数据质量。6.4 调试文档怎么用从部署到排查的完整路径项目配套的调试文档和部署文档很多同学拿到手就是翻一眼然后扔进收藏夹直到环境跑了才发现一堆问题。我自己使用调试文档的习惯是按“环境准备→数据库初始化→启动配置→业务验证”四步走。环境准备阶段先确认JDK版本和Maven版本。这个项目我用的JDK8SpringBoot 2.x版本JDK版本如果太高部分旧依赖可能不兼容启动时报IllegalStateException或NoClassDefFoundError配置Maven镜像为阿里云私服能大幅提升依赖下载速度。数据库初始化阶段把项目提供的herb_shop.sql脚本导入MySQL。导入后重点检查两张表sys_user里面是否已经有预置账号通常是admint_herb_info是否已经有演示数据。没有演示数据的话系统登录进去是空页面看起来就像系统坏了实际上只是没有初始化业务数据。启动配置阶段修改application.yml里的数据库连接信息、Redis地址如果用了Redis、文件上传路径。这里最容易犯的错误是文件上传路径写死成一个绝对路径换一台电脑部署就找不到目录我习惯配置成相对路径./upload跟项目启动目录相关部署时再改。最后是业务验证按“登录→新增药材→创建采购单→入库→开销售单→扣库存→查报表”的路径走一遍确认核心链路没有断。这个路径验证通过系统基本就处于可交付状态了。6.5 部署环境常见坑端口冲突、数据库编码、静态资源路径项目在本地跑得很顺畅一到服务器上就各种问题这种事我见得太多了。先把最常踩的三个坑列出来。端口冲突是最常见的。SpringBoot默认8080端口如果服务器上已经有程序占用启动就会报Port 8080 was already in use。解决方式是在启动命令里指定参数java -jar herb-shop.jar --server.port8081或者直接修改application.yml里的server.port。数据库编码问题也很隐蔽。MySQL连接串里明明写了characterEncodingutf8但导入herb_shop.sql时如果数据库本身编码不是utf8mb4中药材名称和备注里的中文就可能变成乱码。我建议在导入SQL脚本前先执行CREATE DATABASE herb_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;保证数据库、表、连接三层编码一致。静态资源路径问题主要出现在前后端分离部署时。前端页面放在Nginx的html目录下后端接口地址写在layui的table.render的url里如果写的是相对路径会请求到Nginx的地址造成404。解决方式是把后端接口地址写成完整的域名或IP加端口或者在Nginx配置反向代理把/api前缀的请求代理到后端服务。我个人推荐反代方式前端代码里依然写相对路径生产环境干净又好维护。写在最后的几点体会这个中药材店铺管理系统做下来我最深的感受是一套管理系统好不好用技术栈只是地基真正决定成败的是对行业的理解。中药材门店的批次管理、效期预警、按克出库、产地规格这些特殊逻辑每一个都是从店里老师傅的工作习惯里提炼出来的而不是坐在电脑前凭空想出来的。如果你也要做类似的进销存系统建议先花几天时间去目标用户现场待一待搞清楚他们每天到底在为什么事情烦恼再做功能设计。你会发现需求分析做透了后面的代码开发反而是很顺畅的事。最后再分享一个小技巧项目的源码和调试文档交付后最好给自己留一份“从零到一”的部署验证清单。不管你是拿这个项目做毕业设计、课程设计还是真要给门店落地部署这份清单都值得维护——因为半年后再回来看代码你很可能已经忘了当时是怎么配置的。趁热打铁把过程和踩坑记录下来是最省钱的经验沉淀方式。