☰
专线物流调度系统:轻量级Java+Vue跨城运单管理方案
2026/10/15 23:31:22 网站建设 项目流程

简介:这是一套面向物流行业软件开发者与Java初学者的专线物流系统实战项目,聚焦于订单管理、车辆调度、货物追踪、费用计算及报表分析等核心业务场景,助力用户掌握企业级物流信息化系统的架构设计与落地能力。资源包含1028个文件,以69个Java源码文件和69个Class编译文件构成主体逻辑,辅以128个PNG与615个GIF用于界面与流程图示,37个JS、30个JSP及15个CSS支撑前端交互,32个XML与9个DB文件涵盖配置与轻量数据,整体压缩包仅1.98MB,结构紧凑、便于学习与二次开发。目前已有193人下载学习,配套的详细设计文档覆盖需求分析、模块划分、数据库设计与接口定义,结合CustomerAction、Orders、WarehouseAction等典型类名可见分层清晰、职责明确,读者可快速理解MVC架构实践,完成功能扩展、参数配置或集成改造。

1. 专线物流系统:不是通用ERP,而是为“跨城整车/零担调度”量身定制的轻量级调度中枢

你手上有3条固定线路:A市⇄B市(日均8车)、B市⇄C市(日均5车)、A市⇄C市(日均3车),每条线配2名固定司机、3台备案车辆,货主下单只关心“今天几点能装、明天几点到、谁来接单、运费多少”。这时候扔给你一个带财务模块、多仓库WMS、国际报关接口的“全功能物流平台”,反而会卡死——字段太多、流程太重、司机连登录都得教半小时。这个「专线物流系统」干的就是减法:它不碰仓储分拣,不接海关数据,只聚焦“线路-车辆-司机-运单”四要素闭环,用Java+Spring Boot+MySQL跑在一台4核8G服务器上,上线当天就能让调度员用Excel批量导入线路、拖拽式排班、微信扫码签收回传GPS轨迹。它适合中小专线公司、区域运输车队、甚至个体运输户联合体——不是要替代TMS,而是先把你最痛的“调度靠Excel、对账靠微信截图、异常靠打电话”这三件事钉死。项目包里既有可直接运行的源码,也有完整UML图、数据库ER设计、API接口文档,属于那种你下载解压后改两行配置就能进后台看真实运单流的实战型资源。

2. 技术栈与工程结构:Spring Boot 2.7.x + MyBatis-Plus + Vue 2.6 的稳扎稳打组合

这个系统没追Vue 3或Spring Boot 3,选型逻辑很务实:专线公司IT运维能力弱,服务器常是旧型号,JDK 8仍是主力;Vue 2生态成熟,Element UI组件开箱即用,司机端H5页面在安卓4.4+都能流畅加载;MyBatis-Plus省去90%的CRUD XML手写,配合代码生成器,新增一个“车辆年检提醒”表,10分钟内就能从数据库建表到前后端联调完成。整个工程采用经典分层结构,没有过度抽象,所有模块名直指业务含义——你看包路径就知道它在干什么。

2.1 工程目录结构:拒绝“src/main/java/com/xxx/yyy/zzz”式迷宫

项目采用清晰的垂直切分,按业务域而非技术层组织包结构,这是它能快速上手的关键:

src/main/java/ ├── com.example.logistics/ // 根包名,无业务含义 │ ├── config/ // 全局配置:MyBatis-Plus分页插件、Redis连接池、Swagger2配置 │ ├── constant/ // 业务常量:运单状态枚举(WAITING_PICKUP=1, IN_TRANSIT=2...)、支付方式、车型编码 │ ├── controller/ // 控制器层,命名即功能 │ │ ├── admin/ // 后台管理:线路管理、车辆管理、司机管理、运单审核 │ │ ├── dispatch/ // 调度核心:智能派单、手动改派、实时位置查看(WebSocket) │ │ └── driver/ // 司机端API:接单、装货确认、在途上报、卸货签收、电子回单上传 │ ├── entity/ // 实体类,严格对应数据库表,含Lombok注解 │ │ ├── Line.java // 专线线路:起点城市、终点城市、标准时效(小时)、基础运费、附加费规则 │ │ ├── Vehicle.java // 车辆:车牌号、车型、核载吨位、年检到期日、所属线路ID │ │ ├── Driver.java // 司机:姓名、电话、身份证号、绑定车辆ID、当前状态(空闲/在途/休息) │ │ └── Waybill.java // 运单:货主信息、货物描述、体积重量、期望装货时间、实际装货时间、GPS轨迹点列表 │ ├── mapper/ // MyBatis-Plus Mapper接口,无XML,全部注解驱动 │ │ ├── LineMapper.java │ │ ├── VehicleMapper.java │ │ └── WaybillMapper.java │ ├── service/ // 服务接口与实现,命名体现职责 │ │ ├── impl/ │ │ │ ├── DispatchServiceImpl.java // 派单核心逻辑:根据线路、车辆位置、司机状态、运单时效计算最优匹配 │ │ │ └── WaybillServiceImpl.java // 运单全生命周期管理:创建→分配→装货→在途→签收→结算 │ │ └── ... │ └── utils/ // 工具类:GPS坐标纠偏(适配国内地图偏移)、运费计算器(按里程/吨位/时效阶梯计费)、Excel导入导出工具

提示:dispatch包是系统心脏,所有调度决策都在这里发生。它不依赖AI模型,而是用一套可配置的规则引擎(硬编码在Service里):比如“同线路优先、空闲司机优先、距离装货点<5km优先、连续工作<12小时优先”。这种设计牺牲了理论上的最优解,但换来的是100%可解释、可审计、可人工干预——调度员一眼就能看出“为什么派给张三而不是李四”。

2.2 核心依赖版本:稳定压倒一切

pom.xml中关键依赖锁定如下,避免因版本升级引发线上事故:

<properties> <java.version>1.8</java.version> <spring-boot.version>2.7.18</spring-boot.version> <!-- Spring Boot 2.x 最后一个维护版 --> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <!-- 兼容JDK8,无LambdaQueryWrapper兼容性问题 --> <mysql-connector-java.version>8.0.28</mysql-connector-java.version> <!-- 避免8.0.33+的SSL默认开启导致老服务器连接失败 --> <vue.version>2.6.14</vue.version> <!-- Element UI 2.15.x 官方推荐版本 --> </properties>

特别注意mysql-connector-java版本:很多专线公司还在用CentOS 6或Windows Server 2008,这些系统OpenSSL版本老旧,新版驱动强制SSL握手会直接报SSLException: Received fatal alert: protocol_version。8.0.28是最后一个默认关闭SSL的版本,一行配置即可启用:

# application.yml 中的数据库连接串 spring: datasource: url: jdbc:mysql://localhost:3306/logistics?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8

2.3 数据库ER设计:三张核心表撑起主线业务

系统数据库精简到极致,仅12张表,其中3张是绝对核心,ER关系一目了然:

表名主键关键外键业务意义
lineid—一条专线:A市→B市,标准时效24h,基础运费800元
vehicleidline_id→line.id一辆车:粤B12345,4.2米厢货,归属A→B线路
waybillidline_id,vehicle_id,driver_id一单货:货主张三,1.5吨,今早10点装,明早9点前到

waybill表设计尤为关键,它把调度决策结果固化下来:

CREATE TABLE `waybill` ( `id` bigint NOT NULL AUTO_INCREMENT, `line_id` bigint NOT NULL COMMENT '所属线路ID', `vehicle_id` bigint NOT NULL COMMENT '指派车辆ID', `driver_id` bigint NOT NULL COMMENT '指派司机ID', `cargo_desc` varchar(255) NOT NULL COMMENT '货物描述', `weight_ton` decimal(10,2) NOT NULL COMMENT '货物重量(吨)', `volume_cubic` decimal(10,2) DEFAULT NULL COMMENT '货物体积(立方米)', `expect_pickup_time` datetime NOT NULL COMMENT '期望装货时间', `actual_pickup_time` datetime DEFAULT NULL COMMENT '实际装货时间', `actual_delivery_time` datetime DEFAULT NULL COMMENT '实际送达时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-待装货,2-已装货,3-运输中,4-已签收,5-已取消', `gps_trace` json DEFAULT NULL COMMENT 'JSON数组存储GPS点:[{lat:22.543,lng:114.056,time:"2023-10-01 10:05:22"},...]', PRIMARY KEY (`id`), KEY `idx_line_status` (`line_id`,`status`) COMMENT '按线路和状态查询运单' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

gps_trace字段用JSON类型直接存轨迹点,省去建关联表的麻烦。MySQL 5.7+原生支持JSON函数,查询“某辆车今天经过哪些地点”只需:

SELECT JSON_EXTRACT(gps_trace, '$[0].lat') AS first_lat FROM waybill WHERE vehicle_id = 123;

比JOIN一张gps_point表快得多,也更符合专线场景——轨迹数据只用于回溯和签收验证,无需做GIS空间分析。

3. 核心模块拆解:一个运单从创建到签收的完整链路

我们以一个真实业务场景切入:货主在微信小程序下单,调度员在后台一键派单,司机APP接单并上报GPS,最终货主扫码签收。这条链路覆盖了系统70%的日常操作,也是代码最值得深挖的部分。

3.1 运单创建:从Excel模板到数据库落地

货主通常不会自己填系统,而是由业务员收集信息后批量导入。系统提供标准Excel模板(template_waybill.xlsx),包含列:发货城市、收货城市、货物描述、重量(吨)、期望装货时间、联系人、电话。导入逻辑在WaybillController.importFromExcel()中:

@PostMapping("/import") public Result importFromExcel(@RequestParam("file") MultipartFile file) { try (InputStream is = file.getInputStream()) { List<WaybillImportDTO> list = ExcelUtil.readExcel(is, WaybillImportDTO.class); List<Waybill> waybills = new ArrayList<>(); for (WaybillImportDTO dto : list) { // 1. 根据城市名称查线路ID(缓存加速) Long lineId = lineService.getOrCreateLine(dto.getFromCity(), dto.getToCity()); // 2. 创建运单实体 Waybill wb = new Waybill(); wb.setLineId(lineId); wb.setCargoDesc(dto.getCargoDesc()); wb.setWeightTon(dto.getWeightTon()); wb.setExpectPickupTime(dto.getExpectPickupTime()); wb.setStatus(WaybillStatus.WAITING_PICKUP.getCode()); // 初始状态:待装货 waybills.add(wb); } // 3. 批量插入,MyBatis-Plus自动处理主键 waybillService.saveBatch(waybills); return Result.success("成功导入" + waybills.size() + "单"); } catch (Exception e) { log.error("Excel导入失败", e); return Result.fail("导入失败:" + e.getMessage()); } }

ExcelUtil.readExcel()是自研工具类,核心是Apache POI的XSSFWorkbook解析,但它做了两处关键增强:

  • 城市模糊匹配:getOrCreateLine("深圳", "广州")会先查line表是否有from_city="深圳"且to_city="广州"的记录;若无,则查from_city LIKE "%深%" AND to_city LIKE "%广%",匹配到则复用;都无才新建。避免因“深圳市”vs“深圳”、“广州市”vs“广州”导致重复建线路。
  • 时间格式容错:expect_pickup_time列可能填“2023-10-01”、“2023/10/01 10:00”、“今天10点”,工具类内置正则匹配+日期解析器,统一转为LocalDateTime。

3.2 智能派单:规则引擎如何选出“最合适”的司机

派单不是随机分配,而是基于四维规则计算得分,取最高分者。逻辑封装在DispatchServiceImpl.dispatchWaybill():

public boolean dispatchWaybill(Long waybillId) { Waybill waybill = waybillService.getById(waybillId); Line line = lineService.getById(waybill.getLineId()); // 1. 筛选同线路、状态为空闲的司机 List<Driver> candidates = driverService.list(new QueryWrapper<Driver>() .eq("line_id", line.getId()) .eq("status", DriverStatus.IDLE.getCode())); if (candidates.isEmpty()) { log.warn("线路{}无空闲司机,运单{}派单失败", line.getId(), waybillId); return false; } // 2. 计算每个候选司机的综合得分(满分100) Map<Long, Integer> scoreMap = new HashMap<>(); for (Driver driver : candidates) { int score = 0; // 规则1:距离装货点越近,得分越高(假设司机有GPS最后上报位置) double distance = gpsUtil.calculateDistance(driver.getLastLng(), driver.getLastLat(), line.getPickupLng(), line.getPickupLat()); // 单位:公里 score += Math.max(0, 40 - (int)distance); // 距离<40km得满分40,每远1km扣1分 // 规则2:今日已接单数越少,得分越高 int todayOrders = waybillService.count(new QueryWrapper<Waybill>() .eq("driver_id", driver.getId()) .ge("actual_pickup_time", LocalDate.now().atStartOfDay())); score += Math.max(0, 30 - todayOrders * 5); // 每多1单扣5分,最多扣30分 // 规则3:连续工作小时数越少,得分越高(防疲劳驾驶) long workHours = driverService.getContinuousWorkHours(driver.getId()); score += Math.max(0, 30 - (int)workHours); // 连续工作每1小时扣1分 scoreMap.put(driver.getId(), score); } // 3. 取最高分司机,更新运单 Long bestDriverId = scoreMap.entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse(null); if (bestDriverId != null) { waybill.setDriverId(bestDriverId); waybill.setVehicleId(driverService.getVehicleIdByDriver(bestDriverId)); waybill.setStatus(WaybillStatus.ASSIGNED.getCode()); waybillService.updateById(waybill); // 推送消息给司机APP(WebSocket) webSocketService.sendMessageToDriver(bestDriverId, "您有一单新运单,请查收"); return true; } return false; }

这段代码的价值在于完全透明:调度员如果质疑“为什么派给王五不派给赵六”,只要打开日志,就能看到王五得分85(距离5km得35分+今日1单得25分+连续工作2小时得25分),赵六得分72(距离12km得28分+今日2单得20分+连续工作8小时得24分)。没有黑匣子,全是可验证的数字。

3.3 司机端签收:GPS轨迹+电子回单+扫码验证三位一体

签收是纠纷高发环节,系统用三重校验堵住漏洞:

  • GPS轨迹验证:司机点击“已送达”时,APP必须上传当前位置,并检查是否在收货地址500米范围内;
  • 电子回单:调用手机摄像头拍摄货主签字的纸质回单,图片存OSS,URL存入waybill.sign_image_url;
  • 扫码验证:货主扫描运单二维码(含waybill_id加密参数),系统校验该运单确为“运输中”状态且未被签收过。

签收接口WaybillController.confirmDelivery()的关键校验:

@PostMapping("/confirm-delivery") public Result confirmDelivery(@RequestBody DeliveryConfirmDTO dto) { // 1. 解密运单ID(防篡改) Long waybillId = SecurityUtil.decryptId(dto.getEncryptedId()); Waybill waybill = waybillService.getById(waybillId); if (!WaybillStatus.IN_TRANSIT.getCode().equals(waybill.getStatus())) { return Result.fail("运单状态非运输中,无法签收"); } // 2. GPS距离校验(司机上报位置 vs 收货地址) double distance = gpsUtil.calculateDistance( dto.getLng(), dto.getLat(), waybill.getDeliveryLng(), waybill.getDeliveryLat() ); if (distance > 500) { // 单位:米 return Result.fail("当前位置距收货地址" + (int)distance + "米,超出500米范围"); } // 3. 更新运单状态与时间 waybill.setActualDeliveryTime(LocalDateTime.now()); waybill.setStatus(WaybillStatus.DELIVERED.getCode()); waybill.setSignImageUrl(dto.getImageUrl()); // OSS返回的URL waybillService.updateById(waybill); // 4. 生成电子回单PDF(调用iText7) pdfGenerator.generateDeliveryReceipt(waybillId); return Result.success("签收成功"); }

SecurityUtil.decryptId()使用AES-128-CBC,密钥硬编码在配置文件中(生产环境应改用KMS)。这样即使有人截获二维码参数,也无法伪造waybill_id——因为解密失败会直接抛异常,不会进入后续校验。

4. 部署与运行:从本地调试到生产上线的全流程

这套系统设计之初就考虑了专线公司的IT现实:没有专职运维,服务器可能是阿里云ECS入门款,数据库和应用常部署在同一台机器。部署方案必须傻瓜化,不能依赖Docker或K8s。

4.1 本地开发环境:5分钟启动后台+前端

后端(Spring Boot):

  1. 安装JDK 8、MySQL 5.7+、Redis 5+
  2. 创建数据库logistics,执行sql/logistics_init.sql(含建表+初始线路/车辆数据)
  3. 修改application-dev.yml:
    spring: datasource: url: jdbc:mysql://localhost:3306/logistics?useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379
  4. IDEA中右键LogisticsApplication.java→ Run,控制台输出Started LogisticsApplication in X seconds即成功

前端(Vue):

  1. 进入frontend/目录
  2. 执行npm install(需Node.js 14+)
  3. 修改src/config/dev.env.js中的API地址:
    'API_BASE_URL': '"http://localhost:8080/api"'
  4. 执行npm run dev,浏览器访问http://localhost:8081,登录账号admin/123456

注意:前端npm run dev启动的是Webpack Dev Server,它通过proxyTable将/api请求代理到后端8080端口,避免跨域。生产构建时npm run build会生成静态文件,需Nginx托管。

4.2 生产环境部署:单机版Nginx+Jar包方案

生产环境推荐Nginx反向代理+Spring Boot内置Tomcat,零额外中间件:

  1. 构建后端Jar包:

    # 在项目根目录执行 mvn clean package -Dmaven.test.skip=true # 生成 target/logistics-1.0.jar
  2. 编写启动脚本start.sh:

    #!/bin/bash JAR_PATH="/opt/logistics/logistics-1.0.jar" LOG_PATH="/opt/logistics/logs" mkdir -p $LOG_PATH # JVM参数针对4核8G服务器优化 JAVA_OPTS="-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200" nohup java $JAVA_OPTS -jar $JAR_PATH \ --spring.profiles.active=prod \ --server.port=8080 \ > $LOG_PATH/console.log 2>&1 & echo $! > $LOG_PATH/pid echo "Logistics started, PID saved in $LOG_PATH/pid"
  3. Nginx配置/etc/nginx/conf.d/logistics.conf:

    upstream logistics_backend { server 127.0.0.1:8080; } server { listen 80; server_name logistics.yourdomain.com; # 前端静态资源 location / { root /opt/logistics/frontend/dist; try_files $uri $uri/ /index.html; } # API代理 location /api/ { proxy_pass http://logistics_backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # WebSocket支持(用于司机位置实时推送) location /ws/ { proxy_pass http://logistics_backend/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }

    执行nginx -t && nginx -s reload重载配置。

  4. 开机自启(CentOS 7):

    # 创建systemd服务 sudo tee /etc/systemd/system/logistics.service << 'EOF' [Unit] Description=Logistics System After=network.target [Service] Type=forking User=root ExecStart=/opt/logistics/start.sh ExecStop=/bin/kill -15 $(cat /opt/logistics/logs/pid) Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable logistics sudo systemctl start logistics

4.3 常见问题排查:那些让你凌晨三点爬起来的血泪经验

部署和运行阶段踩过的坑,比代码还值得记录。以下是真实发生的5个高频问题,附带定位方法和解决命令:

现象1:后台登录成功,但进入“运单列表”页面空白,F12看Network显示GET /api/waybill/list?page=1&size=10返回500

原因:MySQL时区未同步,LocalDateTime字段入库时被转换为UTC时间,查询时条件WHERE create_time > '2023-10-01'因时区差失效。
解决:在MySQL命令行执行SET GLOBAL time_zone = '+8:00';,并修改my.cnf:

[mysqld] default-time-zone = '+08:00'

然后重启MySQL。Spring Boot侧确保application.yml中spring.jackson.time-zone=GMT+8。

现象2:司机APP点击“上报位置”无响应,WebSocket连接频繁断开

原因:Nginx默认proxy_read_timeout为60秒,而司机端心跳间隔设为90秒,超时后Nginx主动断开连接。
解决:在Nginxlocation /ws/块中增加:

proxy_read_timeout 300; # 心跳间隔的5倍 proxy_send_timeout 300;
现象3:Excel导入时,城市名称“上海市”匹配不到线路,日志显示Line not found for 上海市, 广州市

原因:line表中线路城市字段存的是“上海”、“广州”,而Excel填了“上海市”、“广州市”,LIKE "%上海%"匹配失败(因%在开头)。
解决:修改LineService.getOrCreateLine()中的模糊查询逻辑:

// 原来:like "%"+city+"%" // 改为:使用MySQL的REPLACE函数标准化 QueryWrapper<Line> wrapper = new QueryWrapper<>(); wrapper.apply("REPLACE(REPLACE(from_city, '市', ''), '省', '') = REPLACE(REPLACE({0}, '市', ''), '省', '')", fromCity);
现象4:npm run build生成的dist目录,放入Nginx后访问首页正常,但刷新二级页面(如/dispatch)返回404

原因:Vue Router使用history模式,需要Nginx配置try_files兜底。
解决:确认Nginxlocation /块中有try_files $uri $uri/ /index.html;,这是关键!漏掉分号或写成/index.htm都会失败。

现象5:服务器内存占用持续上涨,top显示Java进程RSS达6G,但jstat -gc显示堆内存稳定在2G

原因:gps_traceJSON字段过大(单条运单轨迹点超1000个),MySQL驱动在ResultSet.getString()时将整个JSON字符串加载进内存,且未及时释放。
解决:在WaybillMapper.java中,对gps_trace字段使用@Result(column = "gps_trace", property = "gpsTrace", javaType = String.class)显式指定类型,并在Service层用JSON.parseObject(str, List.class)按需解析,避免全量加载。

5. 二次开发指南:如何安全地添加“运费账单导出”功能

现在你要加一个新需求:财务人员需要每月导出所有已完成运单的运费明细,生成Excel发给客户对账。这不是简单加个按钮,而是涉及数据聚合、权限控制、异步导出防超时的完整链路。我带你一步步实现,重点讲清楚“为什么这么改”和“哪里容易翻车”。

5.1 需求拆解:明确边界与风险点

首先划清红线:

  • 不改现有表结构:waybill表已足够,运费=基础运费+附加费,附加费规则存在line表的extra_fee_ruleJSON字段里,无需新增字段;
  • 不阻塞主线程:导出可能耗时数分钟,必须异步,用户点击后立即返回“任务已提交”,后台生成文件后邮件通知;
  • 权限隔离:只有角色为FINANCE的用户才能访问此功能,不能让调度员看到财务数据。

风险点在于数据一致性:导出时运单状态可能从DELIVERED变为SETTLED(已结算),需保证导出快照是提交时刻的状态。解决方案是导出任务启动时,用SELECT ... FOR UPDATE锁住目标运单,但这会影响调度员操作。更优解是读已提交快照(Read Committed Snapshot)——MySQL 5.7+默认隔离级别,导出SQL加SELECT ... FROM waybill WHERE status=4 AND create_time BETWEEN ? AND ?天然就是快照,无需加锁。

5.2 后端开发:三步走,每步都有避坑点

第一步:定义导出DTO与Service接口
在dto/下新建BillExportDTO.java:

@Data public class BillExportDTO { @NotNull(message = "开始时间不能为空") @FutureOrPresent(message = "开始时间不能是未来") // 使用Hibernate Validator校验 private LocalDateTime startTime; @NotNull(message = "结束时间不能为空") @FutureOrPresent private LocalDateTime endTime; @Min(value = 1, message = "每页数量不能小于1") @Max(value = 10000, message = "每页数量不能大于10000") private Integer pageSize = 5000; // 防止一次导出过多OOM // 不暴露给前端的字段:任务ID、用户ID、生成状态 private Long taskId; private Long userId; private String status; // PROCESSING, SUCCESS, FAILED }

避坑点:@FutureOrPresent校验看似多余,但能拦截startTime=2099-01-01这种测试数据,避免后台SQL查出0条记录却让用户等半天。

第二步:异步任务实现(关键!)
不用Quartz,用Spring Boot自带的@Async,但必须配置线程池防止OOM:

@Configuration @EnableAsync public class AsyncConfig { @Bean("billExportTaskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); // 导出是IO密集型,2线程足够 executor.setMaxPoolSize(5); executor.setQueueCapacity(10); // 队列满时拒绝,不堆积 executor.setThreadNamePrefix("bill-export-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝时由调用线程执行 executor.initialize(); return executor; } }

BillExportService.exportBillAsync()方法:

@Async("billExportTaskExecutor") public void exportBillAsync(BillExportDTO dto) { try { // 1. 更新任务状态为PROCESSING exportTaskService.updateStatus(dto.getTaskId(), "PROCESSING"); // 2. 分页查询运单(避免单次查10万条OOM) int page = 0; List<Waybill> allWaybills = new ArrayList<>(); while (true) { Page<Waybill> pageResult = waybillService.page( new Page<>(page, dto.getPageSize()), new QueryWrapper<Waybill>() .eq("status", WaybillStatus.DELIVERED.getCode()) .between("actual_delivery_time", dto.getStartTime(), dto.getEndTime()) ); allWaybills.addAll(pageResult.getRecords()); if (pageResult.getRecords().size() < dto.getPageSize()) break; // 最后一页 page++; } // 3. 计算每单运费(调用LineService.getActualFee(),解析JSON规则) List<BillItem> billItems = new ArrayList<>(); for (Waybill wb : allWaybills) { Line line = lineService.getById(wb.getLineId()); BigDecimal fee = lineService.calculateActualFee(line, wb); // 核心算法 billItems.add(new BillItem(wb, fee)); } // 4. 生成Excel(用EasyExcel,比POI更省内存) String fileName = "bill_" + dto.getStartTime().toLocalDate() + "_" + dto.getEndTime().toLocalDate() + ".xlsx"; String filePath = "/opt/logistics/bills/" + fileName; EasyExcel.write(filePath, BillItem.class).sheet("运费明细").doWrite(billItems); // 5. 更新任务状态为SUCCESS,记录文件路径 exportTaskService.updateSuccess(dto.getTaskId(), filePath); // 6. 发送邮件(调用MailService.sendBillReadyEmail()) mailService.sendBillReadyEmail(dto.getUserId(), fileName); } catch (Exception e) { log.error("导出账单失败,taskId={}", dto.getTaskId(), e); exportTaskService.updateFailed(dto.getTaskId(), e.getMessage()); } }

避坑点:EasyExcel.write()必须指定filePath为绝对路径,且目录/opt/logistics/bills/需提前mkdir -p并赋权chown nobody:nobody,否则nohup启动的Java进程无权限写入。

第三步:Controller与权限控制
BillExportController.java:

@RestController @RequestMapping("/api/bill") @RequiredArgsConstructor public class BillExportController { private final BillExportService billExportService; private final ExportTaskService exportTaskService; @PostMapping("/export") @PreAuthorize("hasRole('FINANCE')") // Spring Security注解,只有FINANCE角色能访问 public Result exportBill(@Valid @RequestBody BillExportDTO dto, @AuthenticationPrincipal User user) { // 1. 创建导出任务记录 ExportTask task = new ExportTask(); task.setUserId(user.getId()); task.setStartTime(dto.getStartTime()); task.setEndTime(dto.getEndTime()); task.setStatus("PENDING"); exportTaskService.save(task); // 2. 设置DTO中的taskId和userId dto.setTaskId(task.getId()); dto.setUserId(user.getId()); // 3. 异步触发导出 billExportService.exportBillAsync(dto); return Result.success("导出任务已提交,完成后将邮件通知您", Collections.singletonMap("task_id", task.getId())); } @GetMapping("/task/{taskId}") public Result getTaskStatus(@PathVariable Long taskId) { ExportTask task = exportTaskService.getById(taskId); return Result.success(task); } }

避坑点:@PreAuthorize("hasRole('FINANCE')")必须配合@EnableGlobalMethodSecurity(prePostEnabled = true)启用,否则注解无效。这个注解比@Secured("ROLE_FINANCE")更灵活,支持SpEL表达式。

5.3 前端集成:一个按钮背后的用户体验细节

在财务后台页面加一个按钮,但背后要考虑:

  • 按钮状态管理:点击后禁用,显示“导出中...”,防止重复提交;
  • 进度反馈:轮询/api/bill/task/{id},根据status显示不同文案(“处理中”、“生成成功”、“导出失败:XXX”);
  • 文件下载:成功后提供<a href="/bills/xxx.xlsx" download>链接,但Nginx需配置静态文件服务:
    location /bills/ { alias /opt

本文还有配套的精品资源,点击获取

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

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

立即咨询