
简介这份物流管理系统前后台项目包面向计算机专业学生、Java Web 初学者及课程设计、毕业设计开发者围绕订单接收、仓储管理、运输调度与配送跟踪等业务流程提供一套可直接运行参考的完整案例。压缩包共2000个文件、约65.88MB包含html、css、js构成的前台页面与交互jsp、java、class、jar支撑后台业务逻辑另有xml、properties、sql及db数据文件便于理解配置、数据库与模块划分png、jpg、gif等图片资源则用于界面与图标展示。已有1531人学习下载。项目中前台侧重下单、查询与货物跟踪界面后台覆盖订单处理、仓库出入库、调度分配和异常处理并涉及MySQL数据库、Web前端、服务器端编程、GIS/GPS定位、API对接及路径优化等知识点。读者可据此梳理前后台分层结构、参考页面与接口实现、调试数据库连接并借助现成素材完成课程设计或二次开发适合作为物流管理系统入门与功能扩展的实践样本。1. 下单的人和送货的人隔着一整套系统某开发者接手一个外包单子客户是做区域零担的手里有一堆 Excel客户下单在微信里发语音调度在表格里排车财务月底对着三张表核运费。他要的东西不复杂——前台给客户和网点用能下单、能查轨迹、能看运费后台给调度和财务用能派单、能改状态、能对账。这就是一套典型的物流管理系统前台后台双端分离业务不深但链路长。这套东西适合两类人一是手里有类似外包需求、想找个能跑起来的骨架直接改的二是想练手一个「有真实状态流转、有权限、有金额计算」的中型项目而不是增删改查玩具。下面按我拆这套代码的顺序讲从工程骨架到运单主链路再到权限和二次开发。2. 工程骨架与技术选型两份前端一份后端怎么摆先把技术栈摆清楚。后端 Spring Boot 2.7 MyBatis-Plus MySQL 8 Redis鉴权走 JWT不放 Session前台客户/网点端Vue 3 Vite Element Plus Pinia后台管理端同样是 Vue 3 技术栈但独立一个工程、独立打包、独立部署。之所以不把两端塞进一个 Vue 工程用路由前缀区分是因为这两端的用户群体、发布节奏、甚至域名都不一样合在一起后期拆权限、拆部署会非常难受。这是这个项目在结构上最值得抄的一个决定。2.1 前台后台两份工程目录组织与路由边界前台和后台的目录结构基本对称差别在业务页面和权限模型上。前台没有菜单树路由是写死的白名单加少量动态后台的路由由后端返回的菜单决定登录后才 addRoute。这份差异直接影响你后面怎么改先把目录看清楚logistics-web-client/ 前台客户下单、查件、网点收派 ├── src/ │ ├── api/ axios 封装按模块拆 order.js / track.js │ ├── router/ index.js 静态路由 guard.js 登录守卫 │ ├── store/ piniauser、cart(多包裹)、dict │ ├── views/ │ │ ├── order/ 下单、我的订单、订单详情 │ │ ├── track/ 轨迹查询 │ │ └── site/ 网点收件、派件扫描 │ └── utils/request.js 统一拦截器token、错误码、loading logistics-web-admin/ 后台调度、车辆、财务、系统管理 ├── src/ │ ├── api/ │ ├── router/ 静态路由 后端菜单动态注入 │ ├── layout/ 侧边栏、标签页、面包屑 │ ├── views/ │ │ ├── waybill/ 运单管理、改状态、批量导入 │ │ ├── dispatch/ 调度派车 │ │ ├── finance/ 运费结算、对账 │ │ └── system/ 用户、角色、菜单 │ └── directive/ v-hasPermi 按钮级权限指令 logistics-server/ 后端单体按业务域分包 ├── src/main/java/com/xxx/logistics/ │ ├── common/ 统一返回、异常、常量、工具 │ ├── framework/ 安全、拦截器、MyBatis 配置 │ ├── modules/ │ │ ├── order/ 订单 │ │ ├── waybill/ 运单核心 │ │ ├── dispatch/ 调度 │ │ ├── finance/ 计费与结算 │ │ └── system/ 用户角色菜单 │ └── LogisticsApplication.java └── src/main/resources/ ├── mapper/ XML复杂查询才写 └── application-dev.yml前端两份工程共用一套request.js思路但不要做成 npm 包共享因为两端对 401 的处理策略不同前台 401 直接跳登录页并清空本地订单草稿后台 401 要提示「登录已过期」并保留当前路由方便回跳。这种细枝末节一旦抽公共包改起来比复制粘贴还慢。路由守卫是前台最容易被改坏的地方。白名单要放行首页、查件页、静态页其余全部拦到登录。写法上不复杂但必须把「已登录访问登录页」也处理掉否则用户回退会卡死// router/guard.js 前台路由守卫 import { useUserStore } from /store/user const WHITE_LIST [/login, /home, /track, /about] router.beforeEach(async (to, from, next) { const userStore useUserStore() // 1. 白名单直接放行查件页不需要登录 if (WHITE_LIST.includes(to.path)) { // 2. 已登录用户再访问登录页直接送回首页避免回退卡死 if (to.path /login userStore.token) return next(/home) return next() } // 3. 有 token 但没用户信息说明是刷新页面先拉一次 profile if (userStore.token !userStore.userInfo.id) { try { await userStore.getProfile() } catch (e) { userStore.resetToken() return next(/login?redirect${encodeURIComponent(to.fullPath)}) } } if (!userStore.token) { return next(/login?redirect${encodeURIComponent(to.fullPath)}) } next() })redirect参数是必须的网点人员经常在待办列表点进详情后 token 过期重新登录要能回到原来那条运单不然一天点几十次很烦。getProfile失败时要主动resetToken否则会陷入「有 token 但拉不到用户信息」的死循环表现为页面一直白屏转圈这种问题在测试环境特别常见。2.2 后端分层controller 只做三件事后端的包结构按业务域分不按技术层分即不做controller/、service/大目录因为物流这行的业务域边界很清楚订单、运单、调度、财务之间调用关系少按域分后期拆微服务或者拆模块都容易。每个域内部再分 controller / service / mapper / domain这一层不用纠结。Controller 的职责我一般卡死在三件事参数校验、权限注解、调 service 返回统一结果。业务判断一律下沉尤其是状态流转这种逻辑绝对不能写在 controller 里否则后面加个「批量改状态」的接口就要复制一遍。// modules/waybill/controller/WaybillController.java RestController RequestMapping(/api/waybill) public class WaybillController { Autowired private WaybillService waybillService; // 后台分页查询运单权限点 waybill:list PreAuthorize(ss.hasPermi(waybill:list)) GetMapping(/page) public TableDataInfoWaybillVO page(WaybillQuery query) { // 只做参数兜底不做业务判断 if (query.getPageNum() null) query.setPageNum(1); if (query.getPageSize() null || query.getPageSize() 100) { query.setPageSize(10); } return TableDataInfo.build(waybillService.pageWaybill(query)); } // 后台改运单状态幂等由 service 保证 PreAuthorize(ss.hasPermi(waybill:edit)) Log(title 运单状态变更) PostMapping(/changeStatus) public AjaxResult changeStatus(RequestBody Valid WaybillStatusDTO dto) { waybillService.changeStatus(dto); return AjaxResult.success(); } }pageSize上限卡 100 是血泪经验后台的表格组件默认会把pageSize传成当前页实际条数用户手动输入一个 99999 就能把十万条运单一次性拉出来MySQL 不炸但浏览器先炸。Log注解是操作日志切面状态变更这种写操作必须留痕后面客户投诉「我这单明明是昨天签收的」时能拿出来对。PreAuthorize(ss.hasPermi(...))里的ss是一个注册到 Spring 容器里的 bean 名负责从当前登录用户里取权限集合做判断这个后面第 4 章会展开。这里只要记住权限点字符串要和后台菜单表里配置的perms字段一字不差大小写、冒号都不能错错了就是 403而且日志里只报「没有权限」不告诉你是哪个权限点排查起来很费劲。2.3 本地把两端跑起来顺序和配置启动顺序不重要但配置顺序很重要先建库、再起后端、最后起前端否则前端一打开就是一堆 401 和跨域报错容易让人误判成代码问题。数据库脚本一般放在sql/目录包含建库、建表、初始化菜单和超管账号。导入之后先改application-dev.yml里的三处数据源、Redis、文件上传路径。Redis 在这个项目里不是可选项登录 token 的黑名单、验证码、字典缓存都依赖它没起 Redis 后端启动时不会报错但一登录就抛连接异常。# application-dev.yml 关键片段 server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/logistics?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8serverTimezone和jackson.time-zone这两处必须同时配只配一处会出现「数据库里存的是 14:00接口返回 06:00」的经典问题后面第 5 章还会专门说。前端两个工程各自npm install后npm run dev前台默认 5173后台 5174在vite.config.js里配 proxy 到 8080代理前缀统一/api这样生产环境用 Nginx 转发时配置不用改。// vite.config.js 代理配置 server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true // 后端接口本身带 /api 前缀这里不要 rewrite } } }changeOrigin一定要开否则后端拿到的 Host 是 localhost:5173某些基于 Host 的判断会出错。rewrite小心别乱加如果后端RequestMapping已经写了/api再加 rewrite 去掉前缀就是 404这个坑几乎每个接手的人都会踩一次。3. 运单主链路从下单到签收的状态机与表设计整套系统里真正有厚度的是运单这一条线其余模块都是围着它转。订单客户视角和运单运营视角在这个项目里是分开的两张表一个订单可以拆成多个运单比如一票多件、不同网点发货这个设计要保留别为了省事合并成一张表合并之后财务按运单结算就对不上了。3.1 运单表结构与状态机运单主表只放会变的当前状态和关键快照轨迹单独一张表运费明细再单独一张三张表通过waybill_no关联。主表字段控制在 30 个以内多了就说明把不该放的放进来了。CREATE TABLE t_waybill ( id bigint NOT NULL AUTO_INCREMENT, waybill_no varchar(32) NOT NULL COMMENT 运单号业务唯一, order_no varchar(32) NOT NULL COMMENT 来源订单号, customer_id bigint NOT NULL COMMENT 客户ID, sender_site bigint NOT NULL COMMENT 发货网点, receiver_site bigint DEFAULT NULL COMMENT 目的网点, sender_info json NOT NULL COMMENT 寄件人快照姓名/电话/地址, receiver_info json NOT NULL COMMENT 收件人快照, goods_name varchar(64) DEFAULT NULL, weight decimal(10,2) NOT NULL DEFAULT 0 COMMENT 计费重量kg, volume decimal(10,3) DEFAULT 0, freight decimal(10,2) NOT NULL DEFAULT 0 COMMENT 运费, status tinyint NOT NULL DEFAULT 10 COMMENT 10待揽收 20已揽收 30运输中 40派送中 50已签收 90异常 0已取消, sign_time datetime DEFAULT NULL, version int NOT NULL DEFAULT 0 COMMENT 乐观锁, del_flag tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_waybill_no (waybill_no), KEY idx_customer_status (customer_id, status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT运单主表;几个设计点值得说。第一sender_info和receiver_info用 JSON 存快照不建关联表指向客户地址簿因为地址簿是会改的运单必须留下当时那一刻的地址否则客户改了默认地址历史运单全跟着变对账和纠纷都说不清。第二status用 tinyint 存状态码而不是字符串索引小、比较快但必须在代码里维护一个枚举不能到处写魔法数字。第三version乐观锁字段不是摆设后面状态流转要用。状态机的合法流转在这个项目里只有这几条10→20→30→40→50任意非终态→0取消任意非终态→90异常90→30异常修复后回到运输中。非法流转一律拒绝包括 50→40 这种「签收了又改回派送」的逆向操作客户系统里见过有人这么干最后对账对到崩溃。// modules/waybill/domain/WaybillStatus.java public enum WaybillStatus { CANCELED(0, 已取消), WAIT_PICKUP(10, 待揽收), PICKED(20, 已揽收), IN_TRANSIT(30, 运输中), DELIVERING(40, 派送中), SIGNED(50, 已签收), EXCEPTION(90, 异常); private final int code; private final String desc; WaybillStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 允许的流转关系集中维护别散落在 service 里 private static final MapInteger, SetInteger ALLOW Map.of( 10, Set.of(20, 0, 90), 20, Set.of(30, 0, 90), 30, Set.of(40, 0, 90), 40, Set.of(50, 0, 90), 90, Set.of(30, 0), 50, Set.of(), 0, Set.of() ); public static boolean canTransfer(int from, int to) { return ALLOW.getOrDefault(from, Set.of()).contains(to); } }把流转关系做成静态 Map好处是加一条流转只改这一处坏处是它不可配置。如果客户后面要「已签收可以改成拒收」就得改代码重新发版。要不要做成数据库配置表看客户改需求的频率我一般先写死等真的改第二次再抽表过早配置化会让代码多一层查表逻辑调试时反而看不清。3.2 下单接口运费计算与幂等运费计算是这个项目里唯一有算法味道的地方规则是分区 首重续重 抛比。分区表按收寄件网点的城市对维护首重 1kg 一个价续重每 0.5kg 一个价体积重 长×宽×高/6000取实重和体积重的大者作为计费重量。这套规则不复杂但金额计算必须用BigDecimal且除法必须指定精度和舍入方式否则会出现 12.005 这种中间值。// modules/finance/service/FreightCalculator.java public BigDecimal calc(BigDecimal weight, BigDecimal volume, BigDecimal length, BigDecimal width, BigDecimal height, FreightRule rule) { // 1. 体积重长宽高(cm)/6000保留3位四舍五入 BigDecimal volWeight BigDecimal.ZERO; if (length ! null width ! null height ! null) { volWeight length.multiply(width).multiply(height) .divide(new BigDecimal(6000), 3, RoundingMode.HALF_UP); } // 2. 计费重量取实重与体积重的较大者向上取整到 0.5kg BigDecimal chargeWeight weight.max(volWeight) .divide(new BigDecimal(0.5), 0, RoundingMode.CEILING) .multiply(new BigDecimal(0.5)); // 3. 首重价 续重单价 × 续重段数 BigDecimal first rule.getFirstPrice(); if (chargeWeight.compareTo(rule.getFirstWeight()) 0) { return first.setScale(2, RoundingMode.HALF_UP); } BigDecimal extraWeight chargeWeight.subtract(rule.getFirstWeight()); BigDecimal extra extraWeight .divide(new BigDecimal(0.5), 0, RoundingMode.CEILING) .multiply(rule.getExtraPrice()); return first.add(extra).setScale(2, RoundingMode.HALF_UP); }divide的第三个参数是精度第四个是舍入模式两个都不能省省了在除不尽的时候直接抛ArithmeticException而且是运行到那笔单子才炸测试不一定覆盖得到。第 2 步先除以 0.5 再乘回 0.5是为了把 1.2kg 向上取整成 1.5kg用setScale做不到这种「按 0.5 粒度向上」只能这么绕一下。下单接口还要做幂等。客户端的网络重试、用户连点两下提交都会产生重复订单。这个项目的做法是前端生成一个requestIduuid后端用 Redissetnx加 5 分钟过期做去重同时订单号本身在数据库有唯一索引兜底。// modules/order/service/OrderServiceImpl.java Transactional(rollbackFor Exception.class) public String createOrder(CreateOrderDTO dto) { // 1. Redis 幂等同一个 requestId 5 分钟内只处理一次 String key order:req: dto.getRequestId(); Boolean ok redisTemplate.opsForValue().setIfAbsent(key, 1, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(ok)) { // 重复请求直接把上次的结果查出来返回而不是报错 String exist redisTemplate.opsForValue().get(key :no); if (exist ! null) return exist; throw new ServiceException(请求正在处理中请勿重复提交); } // 2. 校验客户、网点、地址是否完整 validate(dto); // 3. 生成订单号前缀 日期 6 位序列序列用 Redis 自增 String orderNo LO DateUtil.format(new Date(), yyyyMMdd) String.format(%06d, redisTemplate.opsForValue().increment(order:seq)); // 4. 落库 拆运单 Order order buildOrder(dto, orderNo); orderMapper.insert(order); ListWaybill waybills splitWaybill(order, dto.getPackages()); waybillService.saveBatch(waybills); // 5. 回写幂等结果 redisTemplate.opsForValue().set(key :no, orderNo, Duration.ofMinutes(5)); return orderNo; }幂等这里有个细节值得注意重复请求时不要直接抛异常而是尽量返回上次的结果。因为客户端重试的场景下用户其实是想「知道到底成没成」抛异常他只知道「失败了」会再点一次反而更糟。所以第一次成功之后要把orderNo也缓存下来重复请求直接返回。3.3 状态流转与轨迹落库改状态的入口有两个后台手动改网点的收派扫描自动改。两个入口最终都走同一个 service 方法方法里做三件事查当前状态、校验流转合法性、带状态条件更新。第三步是关键不能用updateById因为那会无条件覆盖两个人同时操作同一票单子后提交的会把前面的覆盖掉。// modules/waybill/service/WaybillServiceImpl.java Transactional(rollbackFor Exception.class) public void changeStatus(WaybillStatusDTO dto) { Waybill wb waybillMapper.selectByNo(dto.getWaybillNo()); if (wb null) throw new ServiceException(运单不存在); // 1. 状态机校验非法流转直接拒绝 if (!WaybillStatus.canTransfer(wb.getStatus(), dto.getTargetStatus())) { throw new ServiceException(当前状态不允许变更为目标状态); } // 2. 带原状态作为条件更新影响行数为 0 说明被并发改过 int rows waybillMapper.updateStatus(dto.getWaybillNo(), wb.getStatus(), dto.getTargetStatus(), dto.getRemark()); if (rows 0) { throw new ServiceException(运单状态已变更请刷新后重试); } // 3. 轨迹落库status 变更和轨迹必须在同一事务里 WaybillTrack track new WaybillTrack(); track.setWaybillNo(dto.getWaybillNo()); track.setStatus(dto.getTargetStatus()); track.setContent(dto.getRemark()); track.setOperateUserId(SecurityUtils.getUserId()); track.setOperateTime(new Date()); trackMapper.insert(track); }对应的 Mapper 里那条 SQL 长这样where里带上status #{oldStatus}就是乐观锁的另一种写法比 version 字段更贴业务因为它顺带保证了「只能从 A 改到 B」!-- mapper/WaybillMapper.xml -- update idupdateStatus UPDATE t_waybill SET status #{newStatus}, sign_time IF(#{newStatus} 50, NOW(), sign_time), update_time NOW() WHERE waybill_no #{waybillNo} AND status #{oldStatus} AND del_flag 0 /updateIF(#{newStatus} 50, NOW(), sign_time)这个写法把「签收时写签收时间」塞进了 SQL省掉一次判断但要注意 MySQL 的IF是函数不是语法参数里传 null 会正常判空不会走错分支。轨迹表和主表同事务很重要否则会出现「状态改了但轨迹没记」客户查件时看到状态是运输中轨迹停在三天前投诉电话就来了。4. 权限与后台管理RBAC 怎么落到菜单和按钮上后台管理端最容易被低估的部分是权限。很多人做后台做到最后发现运营专员能看到财务数据、网点人员能改别人网点的单子然后开始到处加if (role admin)加到最后自己都理不清谁能干什么。这套项目用的是标准 RBAC五张表权限粒度到按钮。4.1 五张表与菜单树表结构是sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu。菜单表一张表兼做三件事目录、菜单、按钮用menu_type区分M 目录 / C 菜单 / F 按钮。按钮的perms字段就是前面 controller 里PreAuthorize用的那个字符串。-- 菜单表关键字段 CREATE TABLE sys_menu ( menu_id bigint NOT NULL AUTO_INCREMENT, parent_id bigint NOT NULL DEFAULT 0, menu_name varchar(50) NOT NULL, menu_type char(1) NOT NULL COMMENT M目录 C菜单 F按钮, path varchar(200) DEFAULT , component varchar(255) DEFAULT NULL COMMENT 前端组件路径, perms varchar(100) DEFAULT NULL COMMENT 权限标识如 waybill:edit, icon varchar(100) DEFAULT #, order_num int NOT NULL DEFAULT 0, visible char(1) NOT NULL DEFAULT 0, PRIMARY KEY (menu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜单权限表;后台登录后调/getRouters后端按当前用户的角色查菜单过滤掉menu_typeF的按钮把剩下的组装成树返回。前端拿到树之后转成路由对象component字段用import()动态映射。这里有个坑后端返回的component是字符串waybill/index前端不能直接import(component)Vite 需要静态可分析的路径得用import.meta.glob预先收集所有页面。// router/dynamic.js 后端菜单转前端路由 const modules import.meta.glob(../views/**/*.vue) export function buildRoutes(menus) { return menus.map(m { const route { path: m.path, name: m.name, meta: { title: m.menuName, icon: m.icon } } if (m.component Layout) { route.component Layout } else if (m.component) { // 关键拼接成 glob 的 key 形式如 ../views/waybill/index.vue const key ../views/${m.component}.vue route.component modules[key] || (() import(/views/error/404.vue)) } if (m.children m.children.length) { route.children buildRoutes(m.children) } return route }) }modules[key]找不到时兜底到 404 页面而不是直接报错。因为菜单是运营在后台自己配的配错组件路径太常见了兜底之后至少整个后台还能用只是那个菜单点进去是 404比整个路由崩掉好排查得多。4.2 JWT 登录校验与拦截器登录流程是校验用户名密码 → 生成 uuid 作为 token 的 key → 把用户信息塞进 Redis30 分钟过期→ 返回一个 JWTJWT 的 payload 里只放 uuid不放用户信息。这样做的原因是 JWT 一旦签发就无法撤销如果把角色权限放进去管理员改了某人权限那人得等 token 过期才生效。放 uuid 之后每次请求拿 uuid 去 Redis 查改权限时删掉 Redis key 就行。// framework/security/JwtAuthenticationFilter.java public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException, IOException { String token req.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); String uuid; try { // 1. 解析 JWT 拿 uuid签名不对这里就抛异常 Claims claims JwtUtil.parse(token); uuid claims.getSubject(); } catch (Exception e) { // 解析失败不拦截交给后面的鉴权入口统一返回 401 chain.doFilter(req, resp); return; } // 2. 用 uuid 去 Redis 取登录用户取不到说明已过期或已登出 String key login:token: uuid; LoginUser loginUser (LoginUser) redisTemplate.opsForValue().get(key); if (loginUser ! null) { // 3. 续期每次请求把过期时间往后推 30 分钟 redisTemplate.expire(key, Duration.ofMinutes(30)); SecurityContextHolder.getContext() .setAuthentication(new UsernamePasswordAuthenticationToken( loginUser, null, loginUser.getAuthorities())); } } chain.doFilter(req, resp); } }续期这步不能省不然用户正在填一张复杂的运单填到一半 token 过期提交时直接跳登录前面填的全丢。但续期也有代价Redis 里会一直留着活跃用户的 key所以退出登录时必须显式delete不能只靠过期。权限判断的 bean 就一行从SecurityContextHolder取当前用户看权限集合里有没有目标字符串。超管单独放行用userId 1或者角色标识判断不要用用户名判断用户名是可改的。4.3 后台列表页的通用查询封装后台有十几个列表页如果每个都手写if (StringUtils.hasText(query.getXxx()))拼条件代码量会失控。这个项目抽了一个BaseQuery和一段工具方法把「等值、模糊、时间区间」三类最常见的条件统一处理。// common/query/QueryHelper.java public static T LambdaQueryWrapperT build(Object query, MapString, SFunctionT, ? eqFields, MapString, SFunctionT, ? likeFields) { LambdaQueryWrapperT wrapper Wrappers.lambdaQuery(); // 1. 等值条件字段有值才拼 eqFields.forEach((prop, col) - { Object v ReflectUtil.getFieldValue(query, prop); if (v ! null !.equals(v)) wrapper.eq(col, v); }); // 2. 模糊条件字符串非空才拼 likeFields.forEach((prop, col) - { Object v ReflectUtil.getFieldValue(query, prop); if (v instanceof String StringUtils.hasText((String) v)) { wrapper.like(col, v); } }); return wrapper; }调用时只需要声明映射关系QueryHelper.build(query, Map.of(status, Waybill::getStatus), Map.of(waybillNo, Waybill::getWaybillNo))。用反射取字段值会有性能损耗但列表查询一天几千次这点损耗可以忽略换来的是每个列表页省掉十几行 if。注意like用的是%value%全模糊如果字段有索引且数据量大建议改成右模糊likeRight否则索引失效十万条运单的模糊查询能跑到两秒以上。5. 排查与避坑联调和上线时最容易翻车的五个点这一章是我自己踩过、也见过别人踩的。每个都按「现象 → 原因 → 解决」记遇到一样的问题可以直接对照。5.1 登录后一直 401但接口用 Postman 调是通的现象前端登录成功跳转到首页后所有请求都返回 401Network 里能看到请求带了Authorization头。原因跨域预检请求OPTIONS被后端的鉴权过滤器拦了。浏览器的 OPTIONS 请求不带自定义头Authorization是空的过滤器一看没 token 就返回 401浏览器认为预检失败真正的 POST 根本没发出去。Postman 不发预检所以调得通。解决在过滤器最前面放行 OPTIONS同时确认 Spring Security 的cors()已开启。如果用了 Nginx也要确认proxy_pass之前没有把 OPTIONS 拦掉。这类问题看着像玄学其实就是预检没过去。5.2 运费算出来差一分钱财务对不上账现象同一票单子后台详情页显示的运费和结算列表里的运费差 0.01。原因详情页是前端用weight * price算的展示值结算列表是后端BigDecimal算的。JS 的浮点数0.1 0.2 ! 0.3前端算金额必然有误差。解决金额一律以后端返回为准前端不做任何金额运算只做格式化展示。如果确实需要前端预估也必须调后端的一个calcFreight接口不要在前端写公式。数据库字段统一decimal(10,2)Java 里统一BigDecimalDTO 里也不要出现double。5.3 两个人同时改一票运单状态被覆盖现象调度 A 把运单改成「运输中」调度 B 同时改成「异常」最后库里是「异常」但 A 的轨迹记录也在看起来像两条轨迹对应一个状态。原因用了updateById后提交的整行覆盖前面的且没有做状态校验。解决改成update ... where status #{oldStatus}影响行数为 0 就抛「状态已变更」。同时轨迹表和主表放同一个事务避免只成功一半。如果业务上确实允许并发改那就要在设计上引入操作锁或者改成分段状态而不是靠数据库覆盖。5.4 分页查询 total 一直是 0但数据能查出来现象列表能显示数据分页组件显示「共 0 条」翻页也没反应。原因MyBatis-Plus 的分页插件没注册或者注册了但DbType没指定导致 count 语句没生成。解决加一个MybatisPlusInterceptor配置类注册PaginationInnerInterceptor(DbType.MYSQL)。注意这个拦截器要放在其他拦截器之前顺序错了分页不生效。另外如果 XML 里手写了limit插件会重复分页把 XML 里的 limit 删掉。5.5 时间显示差 8 小时改了配置还是不对现象数据库里create_time是2024-05-20 14:00:00接口返回2024-05-20T06:00:00.000Z。原因链路有三处时区数据库连接、JVM、Jackson 序列化。只改一处不够最常见的是只改了spring.jackson.time-zone但 JDBC URL 里的serverTimezone还是 UTC。解决三处统一。JDBC URL 加serverTimezoneAsia/ShanghaiJackson 配time-zone: GMT8和date-format服务器和 MySQL 的time_zone变量也确认一下。如果前端还用了 dayjs 之类的库确认没有再做一次本地时区转换。6. 二次开发加一个「代收货款」模块的完整改法真接手这套代码的人八成第一个需求就是加业务而不是改架构。我拿「代收货款」举例说一遍我一般怎么加加完之后怎么验证。代收货款的本质是运单签收时派件员向收件人收一笔钱这笔钱记在账上后续和客户结算。改动点其实就四个运单表加两个字段、下单接口多接一个参数、签收逻辑里判断是否代收、财务加一个代收明细查询。改动位置具体内容注意点数据库t_waybill加cod_flag、cod_amount金额用 decimal(10,2)默认 0下单 DTO加codFlag、codAmount代收金额不能大于订单货值加校验签收逻辑状态 40→50 时校验代收是否已收未收不能签收或签收后标记待收财务查询新增代收明细列表复用通用查询封装不新写拼装第一步先加字段写一个增量 SQL不要直接改初始化脚本否则已经部署的环境升级时没地方执行-- sql/upgrade/20240520_add_cod.sql ALTER TABLE t_waybill ADD COLUMN cod_flag tinyint NOT NULL DEFAULT 0 COMMENT 是否代收 0否 1是 AFTER freight, ADD COLUMN cod_amount decimal(10,2) NOT NULL DEFAULT 0 COMMENT 代收金额 AFTER cod_flag, ADD COLUMN cod_status tinyint NOT NULL DEFAULT 0 COMMENT 0未收 1已收 2已结算 AFTER cod_amount;第二步改签收逻辑在changeStatus里加一段目标状态是 50 且cod_flag 1且cod_status 0时直接把状态改成 50 但cod_status保持 0同时给派件员端返回一个提示或者更严格一点直接拒绝签收。这个取决于客户流程我一般选前者因为派件员在客户门口你拦住他不让他签收业务就做不下去了。验证方法我固定走三步先在新库跑一遍初始化脚本加增量脚本确认建表成功然后用 Postman 造三票单子分别是无代收、代收已收、代收未收走完整的 10→20→30→40→50最后登后台查代收明细列表确认三票单子里只有代收的那两票出现金额对得上。从那以后我每次加这种带金额的字段都会先在本机把「金额为 0」「金额超大」「金额为负」三种边界数据各造一遍再往上提代码——因为物流这行钱和单号错一个后面全是电话。希望帮到你。本文还有配套的精品资源点击获取