SpringBoot+SSM实战:中药材门店库存批次与进销存系统设计
2026/9/24 22:59:31 网站建设 项目流程

1. 为什么做中药材店铺管理系统:业务痛点与方案选型

1.1 门店管理到底管什么

做这个系统之前,我专门跑了几家做中药材生意的门店蹲了几天。不蹲不知道,一蹲才发现这行跟普通超市、便利店的管理逻辑差别非常大。普通零售店管好货品条码、进销存、价格就能跑起来,但中药材门店面对的是另一套完全不同的玩法:药材按“品名+产地+规格+炮制方法”区分,同一个黄芪可能同时有好几个批次,每个批次进货价不同、产地不同、采收年份不同;销售时经常不是扫码出库,而是按克称重、按处方抓药,一单里十几味药是常事;最关键的是,药材有保质期和养护要求,放久了虫蛀、霉变、走油,账面库存还没动,实物已经不能卖了。

这些场景凑在一起,靠Excel登记基本是灾难。我见过一家门店用七八个表格来回倒数据,采购单、入库单、销售小票、盘点表各记各的,月底对账能对到凌晨。库存预警更是全靠老师傅的经验——“这味药快断货了、那批防风的货快过期了”都装在脑子里,人一休息生意就乱。所以这个中药材店铺管理系统要解决的核心问题,不是简单地做一套增删改查后台,而是把中药材特有的批次、保质期、产地、规格、按克出库这些逻辑真正落到系统里。

1.2 技术栈选型:为什么是Java+SpringBoot+SSM这套组合

聊技术选型之前,先直接说结论:这个项目用的是Java + SpringBoot + SSM(Spring + 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),即保留三位小数,而不是用FLOATDOUBLE,因为浮点数在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_monthsorigin_place在后续的效期预警和采购分析中会反复用到,所以建议一开始就建好,不要后面加字段。processing_method是中药材行业特有的维度,同一个药材名配上不同炮制方法就是两个商品SKU,我在做唯一约束时也特意设置为(herb_name, processing_method, origin_place, harvest_year)组合唯一,防止重复建档。

3.2 库存批次表的妙用:批次+效期+货位三层结构

库存表是整个系统的“账本”,我建议拆成两张表来设计:库存批次表(t_stock_batch)和库存汇总表(t_stock_total)。

库存批次表每一条记录代表一个药材在某批次下的库存行,字段包括:idherb_idbatch_no(批次号)、purchase_price(批次进价)、production_date(生产日期)、expire_date(有效期至)、quantity(当前批次剩余数量)、shelf_location(货位)、supplier_id(供应商)、status(批次状态,正常/锁定/过期)。这里的关键是:每一批次独立记录进价和有效期,这为后续的先进先出出库、毛利计算、效期预警提供了数据基础。

库存汇总表则按herb_id聚合了一个药材的总库存数,字段包括idherb_idtotal_quantityunusual_quantity(预留/锁定数量)、updated_time。日常查询列表用汇总表,速度会非常快,不用每次SUM(quantity) GROUP BY herb_id扫全表。这里就是典型的“空间换时间”思路,管理系统的库存列表、Dashboard看板都要频繁查当前库存,如果每次都重算汇总,数据量一大就卡。

批次出库逻辑上我建议按“先进先出”(FIFO)原则处理。系统在创建销售明细后,按到期时间从早到晚依次扣除各批次数量。比如某个药材有三个批次,客户要50克,那么就优先扣最早到期的那批,不够再扣下一批。这套逻辑写成一个事务里的Service方法,扣减时用SELECT ... FOR UPDATE锁住相关批次行,防止并发操作超扣。

3.3 销售订单与出入库流水:单据和流水分离更利于对账

进销存管理系统的另一条关键设计原则是“单据与流水分离”。通俗讲,订单表和库存流水表各司其职:订单表记录业务发生的事实(谁在什么时间向谁买了什么),流水表记录库存变动的明细账目。

销售主表(t_sale_order)字段包括:idorder_no(单号)、customer_idsale_datetotal_amount(应收)、discount_amount(优惠)、actual_amount(实收)、pay_type(支付方式)、operator_id(操作人)、statusremark。销售明细表(t_sale_order_item)包括:idorder_idherb_idherb_name_snapshot(药材名称快照)、quantityunit_priceamount。这里建议把药材名称做快照存储,因为药材名称字段在后续修改时不影响历史单据显示,否则一旦改名或停用,历史订单上显示的药材名就跟着变了,对账时非常困扰。

出入库流水表(t_stock_flow)字段包括:idherb_idbatch_idflow_type(采购入库/销售出库/盘点调整/报损)、biz_no(关联业务单号)、quantity(正负值,入库为正、出库为负)、before_quantityafter_quantityoperate_timeoperator_id。流水表是审计追溯的依据,所有库存变动都必须有据可查。每次扣库存前记录before_quantity,扣完记录after_quantity,这看起来啰嗦,但在排查数据问题时价值连城——你能从流水里完整还原某批货的“一生”。

4. 后端实操:SpringBoot整合SSM的关键实现

4.1 工程结构组织:分包别拍脑袋,按功能模块划

很多初学者建SpringBoot工程时喜欢按controllerservicemapperentity这种“按层分包”,所有Controller塞一个包,所有Service塞一个包。项目小的时候看不出问题,一旦模块变多,一百多个Java文件堆在几个包里,找文件要用IDE的全局搜索,协同开发时改文件的冲突概率也大。

这个项目我采用的是“按功能模块分包,模块内再分层”的结构,目录大致如下:

com.herb.shop ├── common // 通用工具、常量、异常处理、返回结果封装 ├── config // SpringBoot配置类、拦截器注册、跨域配置 ├── module │ ├── system // 系统管理:用户、角色、菜单 │ ├── herb // 药材档案 │ ├── purchase // 采购管理:采购单、入库单 │ ├── sale // 销售管理:销售单、出库 │ ├── stock // 库存管理:批次、盘点、流水 │ ├── report // 报表统计 │ └── customer // 客户管理

这样做的直接好处是:改销售模块的代码不需要在几十个包之间跳来跳去,一个purchase包底下把Controller、Service、Mapper、Entity全部放在一起,属于“高内聚”的组织方式。通用模块common里放统一返回结果类Result<T>、全局异常处理器GlobalExceptionHandler、分页对象PageResult<T>,保证所有接口返回结构一致,前端对接省心很多。

4.2 MyBatis多表联查与动态SQL:复杂查询的解法

MyBatis在这个系统里承担着所有数据访问职责。我的做法是所有的简单单表操作用MyBatis-Plus,复杂多表查询在XML里手写SQL。这样各取所长:单表CRUD不写重复代码,复杂查询SQL可控、好调优。

举一个库存列表查询的典型案例。前端需要展示一份按条件筛选的库存列表,条件包括:药材分类、品名关键字、产地、是否有库存、效期预警范围。对应SQL其实要关联t_herb_infot_stock_batcht_stock_total三张表,还要带分页。用MyBatis动态SQL来实现大概是这个样子:

<select id="selectStockList" resultType="com.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 test="category != null and category != ''"> AND h.category = #{category} </if> <if test="herbName != null and herbName != ''"> AND h.herb_name LIKE CONCAT('%', #{herbName}, '%') </if> <if test="originPlace != null and originPlace != ''"> AND h.origin_place = #{originPlace} </if> <if test="onlyHasStock"> AND ht.total_quantity &gt; 0 </if> </where> ORDER BY h.herb_code </select>

这里有两个容易被坑的点。第一,<if test="onlyHasStock">在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(List<SaleItemRequest> items) { for (SaleItemRequest item : items) { // 按批次先进先出,锁定批次行 List<StockBatch> 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的取舍

这个项目登录态方案我选了JWT(JSON Web Token)。做管理系统,用Session也能跑,但SpringBoot项目里用JWT有几个好处:前端把Token存在localStorage,请求时在Header里带上,后端无状态校验,不依赖服务端Session存储;集群部署或多实例时不需要额外做Session共享;调接口测试也方便,Postman里配一个Token就能访问。

JWT的接入流程是这样的:登录接口校验用户名密码通过后,用jjwt库生成Token,Token中携带用户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_itemcost_price字段上,这样报表就不需要再反查批次表,减少关联复杂度。我实际用的是后者,虽然冗余了一个字段,但报表查询速度快,逻辑清晰。

滞销品分析报表统计最近90天没有动销记录的药材,按当前库存量和保质期倒序排列,帮助门店发现积压库存和即将过期但没人买的货。

报表模块的实现难度不高,但要注意一个前期设计问题:如果是从零开发,建议在设计销售明细表时就预留cost_price字段,否则一到报表阶段发现缺数据,又要回填历史数据,极其痛苦。

5. 前端与接口对接的关键细节

5.1 前端技术方案:Layui和Bootstrap快速搭后台

这个项目的前端我选的是Layui——后台管理系统的开发效率非常高。Layui提供了一整套现成的组件:表格、表单、弹窗、日期选择器、树形菜单,最关键的是它对后端返回的JSON结构要求非常简单,直接在table.render里配置url就能拿到数据渲染成表格,分页也由组件自动处理。

当然选Layui也有一个原因:前端不需要复杂的Node.js构建流程,直接在页面里引入layui.jslayui.css就能用,部署时也不需要处理静态资源打包问题。对于门店管理系统这种内部使用的工具型系统,开发速度比技术炫技更重要。

前端页面我按模块划分,左侧栏菜单对应系统管理、药材档案、采购管理、销售管理、库存盘点、报表中心,每个菜单对应一个列表页。列表页统一风格:顶部是筛选条件栏,中间是表格,底部是分页条;新增/编辑用弹窗表单,保存后刷新表格。这种模式非常成熟,后端的CRUD接口一旦稳定,前端页面只需要照着模板套。

5.2 接口返回结构统一:前端少一点if else

接口设计我强烈建议统一返回结构。这个项目里我定义了统一的Result<T>类:

public class Result<T> 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,不用每个接口都猜返回结构。

分页接口的返回结构我统一设计成PageResult<T>,包含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 Result<Long> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

同时建议在Java实体类中,日期字段用LocalDateTime类型,不要用java.util.DateLocalDateTime配合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版本。这个项目我用的JDK8+,SpringBoot 2.x版本,JDK版本如果太高,部分旧依赖可能不兼容,启动时报IllegalStateExceptionNoClassDefFoundError,配置Maven镜像为阿里云私服能大幅提升依赖下载速度。

数据库初始化阶段,把项目提供的herb_shop.sql脚本导入MySQL。导入后重点检查两张表:sys_user里面是否已经有预置账号(通常是admin),t_herb_info是否已经有演示数据。没有演示数据的话,系统登录进去是空页面,看起来就像系统坏了,实际上只是没有初始化业务数据。

启动配置阶段,修改application.yml里的数据库连接信息、Redis地址(如果用了Redis)、文件上传路径。这里最容易犯的错误是文件上传路径写死成一个绝对路径,换一台电脑部署就找不到目录,我习惯配置成相对路径./upload,跟项目启动目录相关,部署时再改。

最后是业务验证,按“登录→新增药材→创建采购单→入库→开销售单→扣库存→查报表”的路径走一遍,确认核心链路没有断。这个路径验证通过,系统基本就处于可交付状态了。

6.5 部署环境常见坑:端口冲突、数据库编码、静态资源路径

项目在本地跑得很顺畅,一到服务器上就各种问题,这种事我见得太多了。先把最常踩的三个坑列出来。

端口冲突是最常见的。SpringBoot默认8080端口,如果服务器上已经有程序占用,启动就会报Port 8080 was already in use。解决方式是在启动命令里指定参数:java -jar herb-shop.jar --server.port=8081,或者直接修改application.yml里的server.port

数据库编码问题也很隐蔽。MySQL连接串里明明写了characterEncoding=utf8,但导入herb_shop.sql时如果数据库本身编码不是utf8mb4,中药材名称和备注里的中文就可能变成乱码。我建议在导入SQL脚本前先执行CREATE DATABASE herb_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;,保证数据库、表、连接三层编码一致。

静态资源路径问题主要出现在前后端分离部署时。前端页面放在Nginx的html目录下,后端接口地址写在layuitable.renderurl里,如果写的是相对路径,会请求到Nginx的地址,造成404。解决方式是把后端接口地址写成完整的域名或IP加端口,或者在Nginx配置反向代理,把/api前缀的请求代理到后端服务。我个人推荐反代方式,前端代码里依然写相对路径,生产环境干净又好维护。

写在最后的几点体会

这个中药材店铺管理系统做下来,我最深的感受是:一套管理系统好不好用,技术栈只是地基,真正决定成败的是对行业的理解。中药材门店的批次管理、效期预警、按克出库、产地规格这些特殊逻辑,每一个都是从店里老师傅的工作习惯里提炼出来的,而不是坐在电脑前凭空想出来的。如果你也要做类似的进销存系统,建议先花几天时间去目标用户现场待一待,搞清楚他们每天到底在为什么事情烦恼,再做功能设计。你会发现,需求分析做透了,后面的代码开发反而是很顺畅的事。

最后再分享一个小技巧:项目的源码和调试文档交付后,最好给自己留一份“从零到一”的部署验证清单。不管你是拿这个项目做毕业设计、课程设计,还是真要给门店落地部署,这份清单都值得维护——因为半年后再回来看代码,你很可能已经忘了当时是怎么配置的。趁热打铁把过程和踩坑记录下来,是最省钱的经验沉淀方式。

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

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

立即咨询