简介:这套基于Java语言的uav-patrol-backend巡维保障系统后端源码,面向需要了解无人机巡维业务场景的后端开发者与Java学习者,完整呈现了系统核心服务端的模块化设计。资源为ZIP压缩包,共51个文件,大小约93KB。其中以44个Java源文件为主体,覆盖核心业务逻辑、数据处理与接口交互;另有3个XML配置文件和2个YAML配置文件,用于管理环境参数、数据库连接与日志级别;还包含Git忽略规则文件与readme说明文档,便于项目维护与快速上手。目前已有332人学习浏览,说明其在同类资源中具备一定参考价值。通过研读源码目录与配置,读者可以掌握后端工程如何按功能拆分模块、如何通过配置文件适配不同运行环境,也能学习到Java项目在Maven构建、版本控制及文档组织方面的常见实践,为独立设计类似保障系统后端提供可复用的思路。
1. uav-patrol-backend到底解决什么问题
uav-patrol-backend是一套面向无人机巡检场景的Java后端设计方案,核心词落在「巡维保障」四个字上。无人机巡逻本身技术门槛不高,麻烦的是任务派发、航线点管理、设备状态追踪、缺陷上报、巡检记录沉淀这一整条链路:每一步都要落库、要对账、要能回溯。基于Java来做这套系统,不是因为语言时髦,而是Spring Boot加MyBatis Plus这套组合在数据一致性、定时调度和权限模型上的生态太成熟,团队不缺轮子,也不缺会用的工程师。
这套源码设计适合三类人:一是无人机巡检项目组的后端开发,想直接抄一套任务模块的领域模型;二是做设备运维平台的工程师,需要把轨迹、任务、报告串成闭环;三是准备前后端分离项目实战的初中级开发者,拿它当单体分层结构的范本读。它的核心价值不在算法,而在业务边界划分和状态流转设计,读懂了这两点,换成别的业务也能照搬骨架。
2. 领域模型与工程结构:先把边界切清楚再动手写代码
2.1 为什么选单体分层而不是微服务
uav-patrol-backend这种系统最常见的翻车点,是一上来就拆微服务。无人机巡检的业务量级通常是几十台设备、每天几千条任务记录,这个规模下微服务的注册中心、配置中心、链路追踪全是额外成本,出了问题排查还要跨服务翻日志,得不偿失。
我一般会建议保持单体分层,但用Maven多模块把边界物理切开。典型的做法是拆成四个模块:common放通用工具和异常定义,dal放数据访问层,biz放业务逻辑,api放Controller层和DTO。模块之间单向依赖,dal不依赖biz,api只依赖biz的接口。这样做的直接好处是,后续真要拆微服务时,biz模块的接口可以直接暴露成Dubbo或OpenFeign服务,不用重写业务代码。
另一个选型理由是可测试性。单体分层下,MockMvc可以直接针对Controller层做集成测试,Service层的逻辑可以用H2内存库跑单元测试,不需要启动完整的中间件环境。这对巡检系统这类强状态机业务特别重要,因为任务状态的每个流转分支都要覆盖到。
2.2 核心表结构设计与实体映射
巡维保障系统的数据模型,我按业务对象拆成四张核心表:patrol_task存巡逻任务主表,patrol_point存航线点,device_info存无人机设备信息,patrol_record存巡检过程中产生的结构化记录。四张表的关系是任务一对多航线点,任务一对多巡检记录,任务多对一设备。
@Data @TableName("patrol_task") public class PatrolTask { @TableId(type = IdType.ASSIGN_ID) private Long id; private String taskName; private Integer taskType; private Integer status; private Long deviceId; private Long assignUserId; private LocalDateTime planStartTime; private LocalDateTime planEndTime; private String description; private LocalDateTime createTime; private LocalDateTime updateTime; }实体设计上有三个关键点。第一,主键用ASSIGN_ID雪花算法而不是数据库自增,原因是巡检任务数据后续要做分库分表或数据迁移时,自增主键会成为合并冲突的源头;第二,status字段用Integer而不是枚举对象,是因为MyBatis Plus对EnumTypeHandler的配置在分布式场景下容易出序列化问题,Integer配合常量类最省心;第三,createTime和updateTime直接由数据库的DEFAULT CURRENT_TIMESTAMP维护,代码里不做赋值,避免多实例部署时应用服务器时间不一致导致的数据错乱。
航线点表设计上要注意sort字段。无人机飞行航线是有顺序的,必须显式存储点的执行次序,不能依赖数据库的插入顺序,否则并发插入时顺序就会错乱。
@Data @TableName("patrol_point") public class PatrolPoint { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long taskId; private BigDecimal longitude; private BigDecimal latitude; private BigDecimal altitude; private Integer sort; private Integer actionType; }经纬度字段必须用BigDecimal而不是Double,这是一个很多新手会忽略的坑。Double在精度上会出现类似109.12345678900001这种尾差,定位设备回传JPX文件后做轨迹回放时,误差会被放大。MyBatis Plus映射BigDecimal到数据库的DECIMAL(10,6)类型,精度完全可控。
2.3 从实体生成建表SQL的两种路径
很多人在拿到源码后第一步就卡在建表上。我常用的做法是直接在Navicat里手写DDL,但项目交付时为了保持一致性,我一般推荐用MyBatis Plus的代码生成器反向生成实体,再用Flyway管理数据库版本。如果只是本地开发调试,可以打开mybatis-plus的ddl-auto配置让框架自动建表。
spring: datasource: url: jdbc:mysql://localhost:3306/uav_patrol?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root flyway: enabled: true locations: classpath:db/migration自动建表只适合开发环境,生产环境必须用Flyway管脚本。uav-patrol-backend场景下最容易忽略的是索引设计:patrol_task表要建联合索引(device_id, status, plan_start_time),因为巡检列表页最常见的查询条件是按设备看某个时间范围内待执行的任务;patrol_point表建(task_id, sort)的唯一索引,防止航线点重复插入。
3. 核心业务链路:从任务下派到巡检报告闭环
3.1 任务状态机的设计与流转约束
巡维保障系统的业务核心是任务状态机。状态字段我设计了五种取值:PENDING待执行、EXECUTING执行中、FINISHED已完成、FAILED失败、CANCELED已取消。状态流转不是任意跳转的,必须遵循固定的约束:只有PENDING才能转EXECUTING或CANCELED,EXECUTING只能转FINISHED或FAILED,终态FINISHED和FAILED不能再跳转。
这个约束不在数据库层面做,而在Service层用一个状态校验方法统一把关。
public class TaskStatusValidator { private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put( TaskStatusEnum.PENDING.getCode(), Set.of(TaskStatusEnum.EXECUTING.getCode(), TaskStatusEnum.CANCELED.getCode()) ); ALLOWED_TRANSITIONS.put( TaskStatusEnum.EXECUTING.getCode(), Set.of(TaskStatusEnum.FINISHED.getCode(), TaskStatusEnum.FAILED.getCode()) ); } public static void validate(Integer currentStatus, Integer targetStatus) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(currentStatus); if (allowed == null || !allowed.contains(targetStatus)) { throw new BizException(ErrorCode.ILLEGAL_TASK_STATUS, String.format("任务状态不允许从%s流转到%s", currentStatus, targetStatus)); } } }状态机的价值在于,它把隐性的业务规则变成了显式代码。比如用户手动取消一个正在执行的任务,如果不做校验,任务在数据库里的状态会变成CANCELED,但设备端实际还在飞,后续对账时就永远对不上。状态校验器统一拦截这类非法操作,日志里能清楚看到是哪一步调用触发了非法流转。
3.2 任务下发接口:事务边界与批量写入
任务下发是巡检业务链路的入口,整个操作包含三件事:写入任务主表、批量写入航线点、向消息队列发送一条设备执行指令。这三件事必须处于同一个事务里,任何一步失败都要整体回滚。
@PostMapping("/task/dispatch") public Result<Long> dispatch(@RequestBody @Valid DispatchRequest request) { // 1. 校验设备是否在线 DeviceInfo device = deviceInfoMapper.selectById(request.getDeviceId()); if (device == null || device.getOnlineStatus() != 1) { throw new BizException(ErrorCode.DEVICE_OFFLINE, "设备不在线,无法下派任务"); } // 2. 创建任务主记录 PatrolTask task = new PatrolTask(); BeanUtils.copyProperties(request, task); task.setStatus(TaskStatusEnum.PENDING.getCode()); taskMapper.insert(task); // 3. 批量插入航线点 List<PatrolPoint> points = request.getPoints(); for (int i = 0; i < points.size(); i++) { PatrolPoint point = points.get(i); point.setTaskId(task.getId()); point.setSort(i); patrolPointMapper.insert(point); } // 4. 事务提交后发送异步指令 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { taskDispatchProducer.send(task.getId()); } } ); return Result.success(task.getId()); }这段代码有两个值得留意的设计细节。第一,消息发送放在afterCommit回调里,而不是直接放在方法末尾,这样可以避免事务还没提交、消费者就拿taskId去查库却查不到数据的经典问题;第二,航线点插入用循环单条插入而不是批量插入,虽然性能上批量插入更快,但巡检任务的航线点一般不超过几十个,单条插入的可读性和错误定位成本反而更优,如果航线点超过一千个,再改成SqlSession批量模式不迟。
Controller层只做参数校验和结果包装,不写业务逻辑,这是前后端分离项目实战里的共识。DispatchRequest里用了javax.validation注解做字段校验,比如taskName不能为空、points集合不为空且大小在1到200之间。校验失败时由全局异常处理器统一捕获,返回统一的Result结构体,前端拿到code非0就知道是参数问题。
3.3 巡检记录与缺陷上报的落库策略
任务执行完成后,设备端会回传两种情况:正常巡检数据和缺陷告警。正常数据按批次写入patrol_record表,缺陷告警则要走独立的缺陷流程。我的设计思路是巡检记录表只存结构化数据,缺陷图片和视频走对象存储,数据库里只存URL引用。
@Data @TableName("patrol_record") public class PatrolRecord { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long taskId; private Long deviceId; private BigDecimal longitude; private BigDecimal latitude; private Integer defectFlag; private String defectType; private String mediaUrl; private LocalDateTime recordTime; }这里有一条实战经验:defectFlag为1的缺陷记录,mediaUrl不能为空,这个约束我放在Service层做,而不是数据库约束。因为数据库约束报错信息太生硬,前端拿到的是一句SQLException,而Service层的判断可以返回“缺陷记录必须上传现场图片”这样人能看懂的提示。另一个原因是,设备端在网络差时可能先上传记录后补传图片,数据库层做强约束会阻碍这个补偿流程。
巡检记录的写入场景是高频小批量,单次可能几十条。我在Mapper层用了一个自定义批量插入方法,用MyBatis的foreach标签拼接INSERT语句,一次事务写入所有记录,实测比单条插入快三到五倍。批量插入时要注意,MySQL默认的max_allowed_packet是4MB,如果单条记录包含长文本或Base64数据,拼接后的SQL可能超限,需要同步调大这个参数。
4. 部署配置与避坑手册:五处让我翻过车的细节
4.1 本地最小启动配置
拿到这套后端源码后,本地跑通所需的服务只有MySQL和Redis,Nacos和Kafka这类中间件在单体模式下都用可替代方案。如果源码里用了消息队列,我建议本地先用Spring事件监听器做降级:当MQ不可用时,任务下发改为同步调用设备接口。这样能省掉大量环境搭建时间。
启动时最容易报错的是时区问题。MySQL 8.0默认时区是UTC,而Java应用通常用Asia/Shanghai,如果不配置serverTimezone,LocalDateTime字段会整体偏8小时。我的yml配置里固定写法是serverTimezone=Asia/Shanghai,同时连接串加上useSSL=false,不然本地MySQL没配SSL证书时控制台会刷一堆warning。
4.2 跨域配置在网关层失效
前后端分离项目里,前端跑在Vite的5173端口,后端跑在8080端口,跨域问题几乎必现。第一版我在Controller上加了@CrossOrigin注解,本地联调时好好的,但后来接入网关后跨域配置全部失效。原因是网关转发请求时会把Origin头透传,而网关自身的CORS配置和应用的CORS配置产生了重复响应头,浏览器直接报错。
解决方式是统一在一处配置CORS,应用层全部移除@CrossOrigin。我在Gateway或过滤器里配置了AllowedOrigins、AllowedMethods和AllowedHeaders,同时设置了allowCredentials为true。这里有个细节:当allowCredentials为true时,AllowedOrigins不能写成*,必须显式列出前端地址,否则浏览器会拒绝响应。
4.3 MyBatis Plus映射JSON字段失败
巡检记录里有一些扩展属性,比如设备回传的温度、风速、云台角度,这些字段在不同型号的无人机上结构不同,不适合都做成独立列。我的做法是在记录表里加一个extra_info的JSON字段,用MyBatis Plus的JacksonTypeHandler做自动映射。
这个配置踩过一个坑:如果实体类上不显式加@TableName(autoResultMap = true),JacksonTypeHandler根本不会生效。MyBatis Plus默认的resultMap不包含typeHandler信息,必须开启autoResultMap才会生成带typeHandler的映射。另一个坑是,JSON字段映射只对查出来的数据有效,插入时如果传入的是字符串而不是对象,MyBatis Plus会原样存字符串,查出后反序列化时直接报类型转换异常。处理办法是在插入前统一把JSON字符串转成对象再传给实体。
4.4 定时任务在集群下重复执行
巡维保障系统里有一个定时任务,每天凌晨自动检查所有PENDING状态且超过计划开始时间两小时的任务,把它们标记为TIMEOUT。单机部署没问题,但一旦后端做了多实例部署,这个定时任务会在每个节点上同时执行,产生重复更新和重复告警。
我的第一版方案是直接用数据库行锁,SELECT ... FOR UPDATE把符合条件的任务全部锁住再更新。但随着任务量增长,锁持有时间太长,高峰期会出现锁等待超时。后来换成分布式锁方案:基于Redis的SETNX加过期时间,每台实例在任务执行前先抢锁,抢到锁的实例才执行定时逻辑,其他实例直接跳过。锁的过期时间设置非常关键,我设为任务预估最大执行时间的五倍,比如定时任务跑批预估1分钟,锁过期时间设为5分钟,避免机器Full GC导致锁提前释放,另一个实例又抢到锁重复执行。
4.5 LocalDateTime在前后端交互时序列化不一致
Spring Boot默认用Jackson序列化LocalDateTime,序列化结果是一个数组格式,前端拿到的不是标准字符串,导致很多前端框架直接渲染报错。这个问题在联调时才会暴露,后端看日志一切正常,前端拿到的JSON里日期字段却变成了[2025,1,15,10,30,0]。
解决方式是在application.yml里全局配置Jackson的日期格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这个配置只对java.util.Date生效,对LocalDateTime不生效。LocalDateTime需要单独加@JsonFormat注解,或者全局配置一个Jackson自定义Module。我的做法是写一个Jackson2ObjectMapperBuilderCustomizer,把LocalDateTime的序列化器和反序列化器统一注册,格式统一为yyyy-MM-dd HH:mm:ss,这样所有接口返回的日期格式就全一致了。
还有一个细节要提醒:前端传日期参数时,常用的格式是yyyy-MM-dd'T'HH:mm:ss,也就是ISO 8601格式,而后端返回的是带空格的格式。要让Spring MVC正确接收ISO格式,需要在application.yml里加spring.mvc.format.date-time=iso,否则会出现DateTimeParseException。
5. 任务并发与接口幂等的进阶加固
5.1 任务下发接口的幂等控制
巡检人员在前端页面连续点击两次「下发任务」按钮,或者移动端网络差时自动重试,都会导致同一条任务被插入两次。这个问题在单体应用里也要重视,不能只依赖前端做按钮防抖。
我的做法是基于数据库唯一键做幂等:在patrol_task表上建立业务单号字段business_no,每次下发前由后端生成一个UUID,插入时如果唯一键冲突,说明是重复请求,直接返回已存在任务的ID,而不是报错。这个方案比Redis预校验可靠,因为Redis在极端情况下可能宕机或Key过期,而数据库唯一键是最终防线。
@Transactional(rollbackFor = Exception.class) public Long dispatchTask(DispatchRequest request) { String businessNo = request.getBusinessNo(); if (StringUtils.isBlank(businessNo)) { businessNo = IdWorker.getIdStr(); } PatrolTask existTask = patrolTaskMapper.selectByBusinessNo(businessNo); if (existTask != null) { return existTask.getId(); } try { PatrolTask task = buildTask(request, businessNo); patrolTaskMapper.insert(task); return task.getId(); } catch (DuplicateKeyException e) { return patrolTaskMapper.selectByBusinessNo(businessNo).getId(); } }这里有个权衡点:是先查一次再插入,还是直接插入靠异常捕获。先查一次的代价是每次请求多一次数据库查询,但可以避免绝大部分正常场景下的异常开销;直接插入靠DuplicateKeyException虽然少一次查询,但触发异常时会有多一次数据库往返和异常堆栈生成开销。我选择先查再插,因为巡检任务下发的频率不高,一次额外查询可以接受。
5.2 压测验证与性能基线参考
交付前我习惯用JMeter对三个核心接口做一轮基础压测:任务下发、巡检记录批量写入、任务列表查询。压测的目的是找到系统的性能基线,而不是追求一个好看的绝对值。我的参考配置是:4核8G的云服务器,MySQL默认配置,Tomcat线程池200,QPS基线大致是任务下发300、巡检记录批量写入800、列表查询1200。如果低于这个量级,优先排查是不是数据库连接池被耗尽。
巡检记录批量写入的压测最容易暴露问题。如果每条记录单独INSERT,数据库连接会被快速占用,连接池默认的HikariCP最大连接数10很快就耗尽,压测数据上会看到大量Connection is not available的报错。把批量插入改成foreach拼接后,同样的压测参数下QPS能提升一倍多。
最后一个习惯是,每次压测前都要用explain检查核心查询的执行计划。uav-patrol-backend这类系统的查询一般不会太复杂,但最容易出现的问题是任务列表查询用不上联合索引,因为条件里status和plan_start_time的过滤顺序与索引顺序不一致。调整索引列顺序从(device_id, status, plan_start_time)改成(status, plan_start_time, device_id)后,查询耗时从800毫秒降到50毫秒,这种优化比加缓存更直接。
这套方案我从头跟到尾,最大的教训是:后端代码设计的质量不在框架用得多新,而在状态流转有没有约束、事务边界是不是准确、边界条件有没有兜底。把这些基础做扎实,巡维保障系统无论对接多少种无人机型号、扩展多少条业务线,骨架都能稳得住。希望帮到你。
本文还有配套的精品资源,点击获取