简介:这是一份基于Java语言的uav-patrol-backend巡维保障系统后端设计源码,面向Java后端开发者及无人机巡维保障相关项目的学习与研究人员。项目围绕无人机巡维保障场景,完整呈现核心业务逻辑、数据存储处理、安全验证及外部接口交互等后端能力,模块化组织方式便于代码复用与团队协作。资源共51个文件、约93KB,主体为44个Java源文件,另有3个XML与2个YAML配置文件,分别承担依赖构建与多环境参数配置职责,使应用可灵活适配开发、测试与生产环境;随包附带.gitignore与readme.txt,前者帮助规范版本管理,后者提供基础安装与运行指引。目前已有332人学习下载。通过研读源码与配置结构,可系统掌握Java后端工程从依赖管理到环境部署的完整链路,对学习Maven构建、多环境配置及巡维系统后端架构设计均有直接参考价值。
1. 一套巡维保障系统的 Java 后端,开门先想清楚哪三件事
无人机巡逻任务下发之后,飞控数据、拍摄影像、设备状态、维保记录全部要回到后端入库流转。uav-patrol-backend 就是这样一个基于 Java 语言的巡维保障系统后端:它把"巡逻任务怎么派、无人机飞没飞、航线数据存哪、什么时候该保养"这些碎片化业务收拢成一组可维护的接口服务。这篇笔记不适合零基础学 Java 的人,适合正在接手巡逻类业务后端、或者准备从零搭一套这类系统的开发。我会按自己的真实落地路径来讲:先讲技术选型和工程结构,再把任务、航线、设备维保三个核心模块的代码设计拆开,最后把坐标精度、时区、并发调度、跨域、文件上传这些坑一条条摊平。读完你能直接照着这套思路去改代码,而不是停留在概念层面。
2. Spring Boot 工程落地:巡维后端的模块划分与依赖选型
2.1 为什么这套系统逃不开 Java + Spring Boot 这套组合
巡维保障系统不是高并发互联网项目,它的核心特征是:业务状态多、接口数量多、下发/上报/维护链路交错。Spring Boot 最擅长把这类 CRUD 加状态流转的活儿快速组织起来。自动化装配省掉大量 XML 配置,starter 生态里引一个依赖就是一项完整能力,MyBatis Plus 在持久层又能把单表操作的重复代码吃掉一大半。这套组合在前后端分离项目实战里几乎是默认答案,甲方后续二次开发也容易找人,Java 岗人才储备比小众语言厚实得多。
我在类似项目里见过有人用 Node.js 或 Python 写后端,短期开发确实快,但一旦涉及多角色权限、任务状态流转、设备台账这类复杂业务,后端还是 Java 生态的边界更清晰。Spring Boot 的拦截器、注解驱动开发、事务管理,配合 MyBatis Plus 的代码生成器和分页插件,能把一半的重复劳动挡在开工之前。如果你接手的是已有代码,大概率也是这套技术栈,迁移成本最低。
依赖选型上我会固定这几样:Spring Boot 2.7 或 3.x 看团队熟悉度,MyBatis Plus 3.5.x 做持久层,Redis 做缓存和分布式锁,Knife4j 生成接口文档,JWT 做无状态认证。文件存储用 MinIO 或 OSS,看部署环境是内网还是公有云。这些选型不激进,每一样都有大量生产案例兜底,不容易翻车。
2.2 Maven 多模块怎么拆才不返工
我一般把巡维后端拆成五个 Maven 模块,边界按"职责"而不是按"业务大小"切:
<modules> <module>patrol-common</module> <module>patrol-framework</module> <module>patrol-system</module> <module>patrol-business</module> <module>patrol-api</module> </modules>patrol-common 放工具类、常量、统一返回结果 Result 、全局异常处理器,不依赖任何业务代码。patrol-framework 放配置类、安全拦截器、MyBatis Plus 配置、Redis 配置,是 framework 层。patrol-system 管用户、角色、菜单、字典这类基础权限数据。patrol-business 是巡维核心业务模块,任务、航线、设备、维保都在这一层。patrol-api 是启动模块,放 Application 启动类和 application.yml。
这样拆的好处是依赖单向:common 被所有人依赖,business 只依赖 common 和 framework,绝不反向依赖 system。不然项目写到一半,业务代码开始调用户模块的内部方法,模块边界立刻失效,后面想拆微服务或者做单元测试都是血泪。
目录层面,business 模块内部按业务分包而不是按技术分层分包:
patrol-business ├── controller // 接口层 ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 入参出参对象 └── enums // 状态枚举controller 只做参数接收和结果包装,service 写业务规则,mapper 只碰 SQL。我见过不少人按 controller/service/mapper 这种技术分层建包,结果每个包下堆几十个文件,找 TaskService 要翻三屏。按业务分包以后,新增一个功能只需要在对应业务包下加类,改动范围是可控的。实体类用 Lombok 的 @Data 注解省 getter/setter,但 DTO 里的校验注解一定要写全,@NotNull、@Size 这些在参数入口就把非法值挡住,比在 service 里判空干净得多。
2.3 基础配置文件里容易被忽略的参数
application.yml 是后端最先接触的配置文件,以下是我每次新建项目都直接抄的配置,里面有几个参数是新手的重灾区:
spring: datasource: url: jdbc:mysql://localhost:3306/patrol_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: patrol_dev password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 3 servlet: multipart: max-file-size: 500MB max-request-size: 1GB jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: truedatasource url 里的 serverTimezone=Asia/Shanghai 是必须的,不加这一项,MySQL 驱动 8.x 会默认用服务器时区,前后台时间差 8 小时的问题就从这里来的。multipart 的 max-file-size 在巡维系统里要放宽,无人机拍回来的现场照片和视频动不动几十 MB 到几百 MB,默认 1MB 的上传限制在真实场景里根本不够用。MyBatis Plus 的逻辑删除配置建议全局开启,删除操作都改成 UPDATE,这样不丢历史数据,巡维记录这种要追溯的数据尤其需要。
参数说明就一句话:这台服务的时区一致性优先,文件上传限制按业务真实大小来,逻辑删除在巡维场景属于标配而不是可选项。这三个点你配对了,后面至少少踩一半的坑。
3. 编写巡维核心业务:任务状态机、航线校验与维保提醒三个模块
3.1 巡逻任务模块:状态机的建模与控制层实现
巡逻任务最核心的不是 CRUD,而是状态流转。一个任务从创建到结束,要经过待下发、已下发、执行中、已完成、已中止这几个状态,每个状态能做的操作不一样。无头绪地写 if-else 判断,代码会膨胀到没法维护,我用枚举加统一的状态流转服务来收敛逻辑。先定义状态枚举:
public enum TaskStatus { PENDING(0, "待下发"), DISPATCHED(1, "已下发"), EXECUTING(2, "执行中"), COMPLETED(3, "已完成"), TERMINATED(4, "已中止"); private final int code; private final String desc; TaskStatus(int code, String desc) { this.code = code; this.desc = desc; } public boolean canTransitTo(TaskStatus target) { switch (this) { case PENDING: return target == DISPATCHED || target == TERMINATED; case DISPATCHED: return target == EXECUTING || target == TERMINATED; case EXECUTING: return target == COMPLETED || target == TERMINATED; default: return false; } } }这个枚举把状态机的核心规则固化了。canTransitTo 方法里写清楚每个状态允许跳到哪个状态,非法流转直接返回 false,接口层拿到 false 就抛业务异常。这样状态流转的规则集中在一个类里,加新状态或者改规则,只需要动这一处,不会散落到各个 service 里。
任务下发接口是这个模块的代表性接口,我按下面的方式实现:
@PostMapping("/task/dispatch") public Result<Void> dispatchTask(@RequestBody @Valid DispatchTaskDTO dto) { PatrolTask task = taskService.getById(dto.getTaskId()); if (task == null) { throw new BizException("任务不存在"); } DroneInfo drone = droneService.getById(dto.getDroneId()); if (drone.getStatus() != DroneStatus.IDLE.getCode()) { throw new BizException("该无人机当前不可用,状态:" + drone.getStatus()); } // 状态机校验:只有待下发的任务能走到已下发 if (!TaskStatus.of(task.getStatus()).canTransitTo(TaskStatus.DISPATCHED)) { throw new BizException("当前状态不允许下发"); } task.setStatus(TaskStatus.DISPATCHED.getCode()); task.setDroneId(drone.getId()); task.setDispatchTime(LocalDateTime.now()); taskService.updateById(task); droneService.updateStatus(drone.getId(), DroneStatus.OCCUPIED.getCode()); // 此处通过 MQ 或者直接调用下发指令到无人机服务端 commandClient.sendDispatchCommand(task.getId(), drone.getSn()); return Result.success(); }方法的逻辑说明:先查任务和无人机,无人机状态不是空闲就不能接单。然后用状态机的 canTransitTo 做合法性校验,只有待下发状态的任务能变为已下发。更新任务状态的同时要把无人机置为占用,最后通过 commandClient 将下发指令推给无人机服务端。这里涉及两个表的更新,transactional 注解一定要加在 service 层方法上,两个更新要么都成功要么都回滚,否则会出"任务已下发但无人机还是空闲"这种脏数据。
参数说明:DispatchTaskDTO 里 taskId 和 droneId 都加了 @NotNull 校验,droneId 不能为空,否则一个任务下发但没有绑定飞机,后面执行环节就断掉了。commandClient 是提前封装好的 HTTP 客户端或消息生产者,接口内部超时时间建议设置 3 秒以上,无人机服务端处理指令可能需要时间,超时太短会产生重复下发。
3.2 航线模块:路径点存储结构与坐标校验
巡逻航线就是一组有序的经纬度坐标点,配合高度和预设速度。存储结构上不要用文本字段塞一大串 JSON,每个路径点作为独立记录存表,用 route_id 关联,再用 sort_no 控制顺序。这种设计看着多一张表,实际上对航线的增删改查、局部替换某个点、校验点是否越界都友好得多。表结构如下:
@TableName("route_point") public class RoutePoint { @TableId(type = IdType.AUTO) private Long id; private Long routeId; private Integer sortNo; @TableField("longitude") private BigDecimal longitude; @TableField("latitude") private BigDecimal latitude; private BigDecimal altitude; private BigDecimal speed; }这里有两个常被忽略的点。第一,longitude 和 latitude 用的是 BigDecimal,不是 Double,更不是 Float——理由下面避坑章节会展开,这里先记住:坐标精度是底线问题。第二,sortNo 用 Integer,航线编辑时如果插入一个中间点,后面的 sortNo 需要整体后移,所以新增接口里必须做一批 update 操作,不要只在应用层排序而数据库里的 sort_no 乱掉。
航线新增接口里的核心校验逻辑,不能只存不验:
private static final BigDecimal MIN_LNG = new BigDecimal("73.5"); private static final BigDecimal MAX_LNG = new BigDecimal("135.1"); private static final BigDecimal MIN_LAT = new BigDecimal("18.1"); private static final BigDecimal MAX_LAT = new BigDecimal("53.6"); public void validateRoutePoint(RoutePoint point) { if (point.getLongitude().compareTo(MIN_LNG) < 0 || point.getLongitude().compareTo(MAX_LNG) > 0) { throw new BizException("经度超出中国范围边界"); } if (point.getLatitude().compareTo(MIN_LAT) < 0 || point.getLatitude().compareTo(MAX_LAT) > 0) { throw new BizException("纬度超出中国范围边界"); } // 相邻路径点的距离不能过近,避免无意义悬停 RoutePoint prev = routePointService.getPrevPoint(point.getRouteId(), point.getSortNo()); if (prev != null && calcDistance(prev, point) < 5.0) { throw new BizException("相邻路径点距离小于5米,请检查航线合理性"); } }逻辑说明:坐标范围校验是一条红线,防止有人往库里塞非法坐标,后面无人机执行航线时直接飞丢。距离校验通过计算两个路径点之间的水平距离,过滤掉重复或几乎重叠的点。calcDistance 用球面距离公式(Haversine)计算,单位是米。校验失败直接抛 BizException,全局异常处理器会把它转成 HTTP 400 和统一错误码。
参数说明:范围阈值按业务部署区域来,如果只做某个园区巡逻,可以把坐标范围缩小到园区边界,准确率更高。最小间距 5 米是经验值,固定翼飞机的航线间距至少 20 米以上,多旋翼可以小一点,这个参数要按实际飞行器类型调。航线点数量建议限制在 200 个以内,超过的话接口响应时间明显变长,无人机自身的飞控载荷也吃不消。
3.3 设备维保模块:维保周期和提醒闭环
无人机不是消耗完就扔的耗材,动力电池、螺旋桨、云台相机都有寿命周期。维保模块要解决的是一件事:到期了,系统要能自己想起来提醒。我采用的方案是设备表上存维保周期字段,再在任务完成上报时顺带推动维保状态检查。先看设备实体:
@TableName("drone_info") public class DroneInfo { @TableId(type = IdType.AUTO) private Long id; private String sn; private String model; private Integer flyHours; private Integer maintenanceCycleHours; private LocalDateTime nextMaintenanceTime; private Integer maintenanceStatus; }next_maintenance_time 这个字段是关键。它不是一个手工维护的时间值,而是按"最近一次维保时间 + 飞行小时数"动态计算出来的。代码落地时,我的习惯是在任务完成上报的 service 方法里调用一个独立的检查方法:
@Transactional(rollbackFor = Exception.class) public void onTaskComplete(PatrolTask task) { DroneInfo drone = droneService.getById(task.getDroneId()); drone.setFlyHours(drone.getFlyHours() + task.getFlightMinutes()); // 如果累计飞行时长已超过维保周期,则标记需保养 if (drone.getFlyHours() >= drone.getMaintenanceCycleHours()) { drone.setMaintenanceStatus(MaintenanceStatus.DUE.getCode()); drone.setNextMaintenanceTime(LocalDateTime.now()); maintenanceService.createRemind(drone.getId(), "无人机 " + drone.getSn() + " 累计飞行时长已达维保周期"); } droneService.updateById(drone); // 检查该无人机是否有未完成的维保工单 if (drone.getMaintenanceStatus() == MaintenanceStatus.DUE.getCode() && maintenanceService.hasActiveMaintenanceOrder(drone.getId())) { throw new BizException("无人机存在未闭环维保工单,不能继续接新任务"); } }逻辑说明:任务完成上报是巡维业务的一个关键钩子点,所有与飞行时长、设备损耗相关的计算都聚合在这里。判断逻辑是累计飞行小时数超过维保周期阈值,就置为待保养状态并生成一条提醒记录。在生成新任务前还要检查有没有未闭环的维保工单,有就不允许继续派活,这个约束直接写在任务创建接口里。
参数说明:maintenanceCycleHours 按设备型号维护,多旋翼一般是 50 到 100 小时,具体看厂商手册。提醒方式可以是站内信、WebSocket 推送或者对接企业微信,这些在 maintenanceService.createRemind 里做适配。这个方案比定时扫描全表更准的原因在于它驱动的时机是真实飞行结束,不依赖定时任务的周期精度,也不存在晚上定时任务跑批时数据库锁表的焦虑。
4. 数据库设计与事务边界:让多表操作不再提心吊胆
4.1 巡维系统的核心表结构长什么样
巡维保障系统数据库设计的核心表其实就六张:patrol_task 任务表、drone_info 无人机表、route_point 路径点表、patrol_route 航线表、maintenance_order 维保工单表、patrol_log 巡逻日志表。我用任务表和日志表作为设计样例:
CREATE TABLE `patrol_task` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `task_no` VARCHAR(32) NOT NULL COMMENT '任务编号,业务唯一标识', `task_name` VARCHAR(128) NOT NULL COMMENT '任务名称', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待下发 1已下发 2执行中 3已完成 4已中止', `drone_id` BIGINT DEFAULT NULL COMMENT '执行任务的无人机id', `route_id` BIGINT DEFAULT NULL COMMENT '航线id', `plan_start_time` DATETIME NOT NULL COMMENT '计划开始时间', `actual_start_time` DATETIME DEFAULT NULL COMMENT '实际开始时间', `actual_end_time` DATETIME DEFAULT NULL COMMENT '实际结束时间', `flight_minutes` INT DEFAULT 0 COMMENT '飞行时长(分钟)', `dispatch_user_id` BIGINT DEFAULT NULL COMMENT '下发操作人', `deleted` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_task_no` (`task_no`), KEY `idx_status` (`status`), KEY `idx_drone_id` (`drone_id`), KEY `idx_plan_start_time` (`plan_start_time`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='巡逻任务表';任务表的设计参考点:task_no 是业务唯一编码,格式建议用"日期+序列号",比如 T20250214-0001,这个字段必须有唯一索引,防止并发下发时生成重复任务。status 用 TINYINT 存而不是字符串,节省空间且查询效率高,枚举表和代码里的枚举一一对应。drone_id 允许为空,因为任务可以建好但不绑定飞机,绑定动作发生在下发环节。plan_start_time 和 actual_start_time 分开存,一个表示计划的巡检时间窗口,一个是实际起飞时间,别把两个含义混到一个字段里。deleted 字段配合 MyBatis Plus 的逻辑删除,任务记录不留硬删,审计回溯时还能找到。
巡逻日志表是另一个容易设计错的地方:
CREATE TABLE `patrol_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `task_id` BIGINT NOT NULL COMMENT '任务id', `drone_sn` VARCHAR(32) NOT NULL COMMENT '无人机序列号', `log_type` TINYINT NOT NULL COMMENT '日志类型:1飞控日志 2影像日志 3异常报警', `content` TEXT COMMENT '日志内容或OSS文件路径', `longitude` DECIMAL(10, 7) DEFAULT NULL, `latitude` DECIMAL(10, 7) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_task_id` (`task_id`), KEY `idx_log_type` (`log_type`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='巡逻日志表';这里刻意不去做 task_id 的唯一索引,因为一个任务会产生多条日志,索引建在 task_id 上做范围查询。log_type 区分日志类型,不同类型的 content 含义不同,文本日志存内容,影像日志存 OSS 或 MinIO 的文件路径。经纬度用 DECIMAL(10, 7) 而不是 DOUBLE,这个选择结合避坑章节看就明白了。create_time 用默认当前时间,写入时不需要应用程序主动赋值,避免时区转换带来的人为误差。
4.2 事务边界怎么划,幂等怎么做
巡维后端里事务最容易出错的地方就是把事务加在 controller 上或者跨模块调用时事务失效。事务必须加在 service 实现类的 public 方法上,且要通过 Spring 代理调用才能生效。同类内部方法直接调用 this.xxx() 时事务会失效——这是一个典型问题:A 方法加了 @Transactional,B 方法也加了,A 内部调用 this.b(),B 的事务不生效。解决办法是注入自身代理或者把 B 移到另一个 service 类里。我的习惯是在同一业务链路的方法上直接加事务,比如下发任务那个方法,涉及任务状态更新和无人机状态更新两个写操作,就必须放在同一个事务里,任一步失败都回滚。
事务的隔离级别用默认的 READ_COMMITTED 就够了,不要随意升级。但 REAPEATABLE_READ 下有一个长事务不要做太久,超过 3 秒的写事务会拖慢整个库的连接池,巡维系统虽然并发不高,但无人机数据上报是持续不断的。
幂等控制是另一个必须处理的细节。任务下发接口、日志上报接口都是重复调用高风险接口。无人机服务端网络抖动时 retry 是常态,前端用户手快连续点击也会触发。最简单可靠的幂等方案是 Redis 分布式锁加业务唯一键:
public void dispatchTaskWithIdempotency(String taskNo, DispatchTaskDTO dto) { String lockKey = "patrol:dispatch:" + taskNo; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (!locked) { throw new BizException("任务正在下发中,请勿重复操作"); } try { dispatchTask(dto); } finally { redisTemplate.delete(lockKey); } }逻辑说明:以 taskNo 作为锁的 key,setIfAbsent 是原子操作,同一时刻只有第一个请求能拿到锁,后续请求直接抛出"处理中"的提示。30 秒的过期时间是兜底,防止业务异常导致锁未释放造成的死锁。finally 里删除锁是为了正常流程结束后释放,不过要注意:如果把过期时间设置的太短,比如 3 秒,而业务实际执行了 5 秒,锁自动过期了,第二个请求就能进来造成重复执行。过期时间必须大于业务最差耗时。
另一种幂等方案是数据库唯一索引解决,比如 create_patrol_log 表里加 task_id 加 log_seq 的唯一索引,重复插入时直接报 DuplicateEntry 异常捕获后返回成功。这个方法用在日志插入这种高频写入场景,没有额外 Redis 开销,比分布式锁更适合数据上报类的接口。
5. 避坑记录:巡维后端项目最常见的五个翻车现场
5.1 Float 存经纬度,任务航线越飞越偏
现象:无人机实际飞行轨迹和规划航线偏离明显,目测误差在几十米以上,数据库里的坐标值小数点后第三位就开始出现截断。
原因:route_point 表的 longitude 和 latitude 字段一开始用了 FLOAT 类型。FLOAT 在 MySQL 里是单精度浮点,有效位数大约 7 位,而一个完整的经纬度如 116.397026, 39.918058 的有效位数是 8 到 9 位。存进去的时候小数部分就被截断了,航线上每个点都偏一点点,累计起来飞行偏差直接爆表。
解决:把字段改成 DECIMAL(10, 7),这种定点类型能精确存储到小数点后 7 位,换算成距离精度大约是 1 厘米左右,完全满足无人机航线需求。注意 DECIMAL 的精度不能手滑定义成 DECIMAL(8, 4),那还不如 FLOAT。另外实体类里用 BigDecimal 而不是 Double 接收数据,JDBC 驱动对 DECIMAL 类型才会精确映射,Double 依然会引入精度损失。
ALTER TABLE route_point MODIFY COLUMN longitude DECIMAL(10, 7) NOT NULL COMMENT '经度', MODIFY COLUMN latitude DECIMAL(10, 7) NOT NULL COMMENT '纬度';5.2 时间字段在前后台差了 8 个小时
现象:后端的 create_time 看着是正确的北京时间,接口返回给前端后变成了 UTC 时间。或者反过来,前端传了一个计划开始时间,后端存进数据库后发现差了 8 个小时。
原因:常见情况是三处不一致。MySQL 连接串里没加 serverTimezone=Asia/Shanghai,数据库驱动把时区当作系统默认值。Spring Boot 的 jackson 配置没有指定 time-zone,后端返回 JSON 时时间被序列化成 UTC。再就是连接的 MySQL 实例本身 time_zone 参数是 SYSTEM,而容器系统时区是 UTC。
解决:三个地方全部对齐。MySQL 连接 URL 加 serverTimezone=Asia/Shanghai,Spring 配置加 spring.jackson.time-zone: Asia/Shanghai,MySQL 实例执行 set global time_zone = '+08:00'。实际排查时用show variables like '%time_zone%'先确认数据库侧,再看应用配置,两边都改成 +08:00 就能消除这个 8 小时问题,不用去代码里转换。
5.3 并发点击下发任务,同一台无人机被派了两次
现象:用户在任务列表页对多个任务快速操作,或者两个操作员同时下发任务给同一台无人机,后台日志显示无人机被分配到了两个任务上,飞行记录交叉错乱。
原因:业务代码里先查 drone 状态,再更新为占用,这两个步骤不是原子的。两个并发请求同时查到无人机是空闲,然后都执行了 update,都认为自己的更新成功了。这就是典型的先检查后更新的竞态条件。
解决:用乐观锁或者条件更新。最稳妥的办法是数据库条件更新,把状态检查放到 UPDATE 的 WHERE 条件里:
UPDATE drone_info SET status = 1 WHERE id = #{droneId} AND status = 0受影响行数为 0 就说明无人机已经被占用,业务层拿到这个结果直接抛异常。这种方式不需要引入分布式锁,实现最简洁且绝对安全。类似的状态竞争问题在任务状态更新、维保工单核销场景下同样存在,统一用"条件更新 + 受影响行数判断"解决。
5.4 前后端分离部署后接口却能通但进不来
现象:前端部署在 Nginx 上,后端 Boot 应用单独跑在 8080 端口,前端页面打开接口调用报跨域错误,网络面板显示 CORS 拦截。在 Controller 上加了 @CrossOrigin 注解后,某些浏览器还是报错。
原因:@CrossOrigin 加在单个 Controller 上只能解决单个接口的跨域,前端实际访问的是几十个接口,加漏一个就报一次。另一种情况是接口请求走的是自定义 Header(比如 Authorization 携带 JWT),但 CORS 配置里没把 allowedHeaders 配全,预检请求直接失败了。
解决:用全局 CORS 配置替换注解方式,统一放行。下面的配置是生产实践里的常规稳妥配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns 配 * 表示允许所有来源,allowCredentials 配 true 允许携带 Cookie。注意 allowedOrigins 用 * 在带凭证请求时是无效的,必须用 allowedOriginPatterns,这是很多人报错却找不到原因的点。生产环境如果对来源有要求,把 * 换成具体的域名列表,用逗号分隔。
5.5 巡检影像上传到一半就超时断开
现象:无人机执行完任务,传回一段 300MB 的视频文件到后端,传到一半就报网关超时或者连接被重置,传上来的文件也是损坏的,无法播放。
原因:典型配置在我前面提到的 multipart 配置缺失或者过小。另外如果前后端之间有 Nginx 作为反向代理,Nginx 默认的 client_max_body_size 是 1MB,超过就返回 413,但很多场景 Nginx 错误页没被前端正确处理,表现就是"连接断开"的假象。
解决:后端把 spring.servlet.multipart.max-file-size 调到实际业务需要的值,比如 500MB 或 1GB。Nginx 层也要同步调整:
client_max_body_size 1000m; proxy_read_timeout 300s; proxy_send_timeout 300s;客户端上传超时时间也要加大,axios 默认没有超时限制,但如果是原生的 XMLHttpRequest 或者某些封装库,默认超时可能只有 30 秒,300MB 文件在普通带宽下 30 秒根本传不完。上传场景建议不要走 HTTP 同步请求,改用分片上传或预签名 URL 直传 OSS/MinIO,这样大文件不经过后端应用服务器,内存和带宽压力都小很多。但如果系统初期就一台服务器,直接把超时时间和体积限制调到位是最省事的解法。
6. 上线前的收尾:接口自测、日志链路和性能验证的小习惯
系统功能写完不等于能交付,我上线前总会花两天时间做三件事:接口自测、日志链路检查、简单压力验证。
接口自测我习惯用 Knife4j 的调试页面而不是 Postman,因为 Knife4j 和后端代码共享同一份接口定义。Controller 上把 @ApiOperation 注解写清楚,Dto 字段加 @ApiModelProperty 注明含义和约束,Knife4j 页面就能直接调接口、看参数说明、查响应结构。巡维系统里任务下发、任务完成上报这两个接口是核心链路,自测时我会专门验证参数缺省、非法状态流转、无人机状态占用这几种异常分支,而不是只测正常路径。
日志链路检查这件事,很多项目栽在"线上出问题查不到日志"上。我常用的方案是在网关拦截器或者全局过滤器中生成一个 requestId,放到 Logback 的 MDC 里,在日志配置中输出这个字段:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %X{requestId} %-5level %logger{36} - %msg%n</pattern>@Component public class RequestIdFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String requestId = UUID.randomUUID().toString().replace("-", ""); MDC.put("requestId", requestId); try { chain.doFilter(request, response); } finally { MDC.remove("requestId"); } } }这样日志里每一条记录都有唯一的 requestId 贯穿整个调用链,从入口到 SQL 输出全串起来。排查问题时先拿 requestId 搜全链路,快速定位到哪一步出了问题,不用再靠时间戳猜。这个习惯在前后端分离项目排障时价值极高,前端报一个任务下发失败,后端日志一搜 requestId 就能看到是状态校验没过还是无人机服务端没响应。
性能验证方面我不追求复杂压测,用 JMeter 跑两个核心场景就够:同时下发 50 个任务、同时上报 200 条巡逻日志。关注两个指标:平均响应时间不超过 500 毫秒,错误率为 0。如果在这两个场景下出现异常,优先看数据库连接池配置和慢 SQL,用 MySQL 的慢查询日志把执行时间超过 500ms 的 SQL 捞出来,加索引或改写。巡维系统的并发量不会像电商那样夸张,但接口响应慢会直接影响无人机调度效率——飞控的回传数据迟迟不能确认,下一个任务就得等着。
我做过几个类似项目后养成的习惯是:每次上线前把上述三件事做成 checklist 过一遍,接口自测、requestId 日志、核心链路压测,三关都过了才敢交付。这些功夫平时看着不起眼,真到现场出了问题才知道有多值钱。这套方案你按自己的业务去调整,核心是状态机、坐标精度、并发控制、日志追溯这些骨架别散架,希望帮到你。
本文还有配套的精品资源,点击获取