简介:这份物流管理系统前后台项目包面向计算机专业学生、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/ pinia:user、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 TableDataInfo<WaybillVO> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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: GMT+8serverTimezone和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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Map<Integer, Set<Integer>> 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 粒度向上」,只能这么绕一下。
下单接口还要做幂等。客户端的网络重试、用户连点两下提交,都会产生重复订单。这个项目的做法是前端生成一个requestId(uuid),后端用 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); List<Waybill> 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 id="updateStatus"> 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 </update>IF(#{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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';后台登录后调/getRouters,后端按当前用户的角色查菜单,过滤掉menu_type='F'的按钮,把剩下的组装成树返回。前端拿到树之后转成路由对象,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 → 把用户信息塞进 Redis(30 分钟过期)→ 返回一个 JWT,JWT 的 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> LambdaQueryWrapper<T> build(Object query, Map<String, SFunction<T, ?>> eqFields, Map<String, SFunction<T, ?>> likeFields) { LambdaQueryWrapper<T> 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 调是通的
现象:前端登录成功,跳转到首页后所有请求都返回 401,Network 里能看到请求带了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 里统一BigDecimal,DTO 里也不要出现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 加serverTimezone=Asia/Shanghai,Jackson 配time-zone: GMT+8和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」「金额超大」「金额为负」三种边界数据各造一遍,再往上提代码——因为物流这行,钱和单号错一个,后面全是电话。希望帮到你。
本文还有配套的精品资源,点击获取