后端开发日记⑦:飞行任务与大疆开放平台适配

飞行模块同时面对两套数据:项目数据库中的配送任务,以及大疆运载云端互联平台中的设备、航线和任务。两者不能完全混为一体,否则第三方接口一旦变化,订单详情也会跟着失效。
1. 本地任务保存业务关系
HyFlightTask 负责连接订单和外部飞行数据,保存当前任务 ID、大疆任务 ID、航线标识、任务状态等信息。订单通过 currentTaskId 指向当前飞行任务,过程日志也可以记录同一个任务 ID。
本地表回答“这次配送使用哪个飞行任务”,大疆平台回答“设备、航线和实时任务现在怎样”。职责分开后,即使外部平台暂时不可用,订单关系仍然存在。
2. 通用客户端统一鉴权
DjiApiClient 封装 GET、POST、PUT、DELETE 和表单请求。每次调用都会生成:
X-DJI-Access-KeyX-DJI-TimestampX-DJI-NonceX-DJI-Signature签名使用请求方法、时间戳和随机数等信息计算,AccessKey 与 SecretKey 只从服务端配置读取。前端永远不接触密钥,也不负责生成签名。
客户端还统一处理连接超时、请求超时、JSON 序列化、非 2xx 响应和空响应。业务 Service 不需要在每个 API 方法里重复这些代码。
3. OpenAPI Service 只表达业务动作
通用客户端之上再建立 DjiOpenApiServiceImpl,提供设备、拓扑、直播、航线、地图元素、指令和任务等方法:
listDevices(params)getWayline(waylineId, params)createTask(body)startTask(taskId, params)getTaskTrack(taskId, params)Service 负责拼接带 groupId 的路径并编码路径参数,Controller 只转发经过权限校验的请求。这样第三方 URL 变化时,修改集中在适配层。
4. 订单轨迹如何拿到航线
用户查看订单轨迹时,后端先查本地飞行任务,再尝试找到大疆原始航线 ID,随后请求航线详情。第三方返回的点字段可能使用 longitude/lng/lon 与 latitude/lat 等不同命名,因此要先归一化:
Double lng = numberValue(pickFirst(point, "longitude", "lng", "lon"));Double lat = numberValue(pickFirst(point, "latitude", "lat"));无效坐标和零坐标会被过滤,高度与速度存在时再附加到结果中。成功获取的航线标记为 WGS84,让前端知道后续需要做什么坐标处理。
5. 第三方失败不能拖垮订单详情
航线是详情的增强信息,不是读取订单的必要条件。大疆接口异常时,后端不会让整个订单详情失败,而是回退到接驳点与船舶坐标组成的基础路线,并标记为 GCJ02。
大疆航线成功 -> 返回完整航点大疆航线失败 -> 返回起点和终点基础坐标也不足 -> 只返回订单与任务信息这种降级要根据业务重要性决定。创建或启动飞行任务失败不能被忽略,但展示辅助航线失败可以回退。
6. 配置对象如何承接 YAML
大疆连接参数使用 @ConfigurationProperties 绑定:
@ConfigurationProperties(prefix = "dji.api")public class DjiApiProperties { private String baseUrl; private String accessKey; private String secretKey; private String groupId; private Duration connectTimeout; private Duration requestTimeout;}YAML 中的 connect-timeout: 5s 会直接转换为 Duration,比读取字符串后手动解析更安全。配置对象集中描述第三方平台需要的所有参数,客户端构造时只依赖这一个对象。
真实密钥不应直接写在仓库配置中,可以改为:
dji: api: base-url: ${DJI_API_BASE_URL:https://ta-api-cn.dji.com} access-key: ${DJI_ACCESS_KEY:} secret-key: ${DJI_SECRET_KEY:} group-id: ${DJI_GROUP_ID:}冒号后面是缺省值;密钥缺省为空,客户端会在发送请求前明确提示“AK/SK 未配置”,而不是带着空请求头访问第三方。
7. 通用请求方法逐段理解
public Object request( String method, String path, Object body, String contentType) { try { String normalizedMethod = method.toUpperCase(Locale.ROOT); HttpRequest request = buildRequest( normalizedMethod, path, body, contentType); HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString( StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new ServiceException( "大疆 API 服务响应异常"); } if (StringUtils.isEmpty(response.body())) { return Collections.emptyMap(); } return objectMapper.readValue( response.body(), Object.class); } catch (ServiceException e) { throw e; } catch (Exception e) { throw new ServiceException("调用大疆 API 服务失败"); }}方法先统一 HTTP 动词大小写,再构造请求。响应判断使用完整 2xx 范围,而不是只接受 200,因为创建或无内容响应可能使用其他成功状态码。
空响应统一返回空 Map,调用方就不必反复处理空字符串。已经属于业务提示的 ServiceException 原样抛出,网络、编码和 JSON 解析等底层异常则被转换成不暴露密钥与内部堆栈的统一信息。
日志可以记录方法、路径、状态码和第三方请求 ID,但要避免输出签名、SecretKey 或包含个人数据的完整请求体。
8. 请求签名代码逐行说明
private String sign( String method, String timestamp, String nonce) throws Exception { String signText = properties.getAccessKey() + method + timestamp + nonce; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec( properties.getSecretKey().getBytes( StandardCharsets.UTF_8), "HmacSHA256")); byte[] digest = mac.doFinal( signText.getBytes(StandardCharsets.UTF_8)); return toHex(digest);}签名原文的拼接顺序必须与平台协议完全一致。Locale.ROOT 统一 HTTP 动词大小写,UTF-8 统一字符串到字节的编码,否则同一文本在不同环境可能算出不同结果。
HMAC 与普通哈希的区别是它使用 SecretKey。服务端和大疆平台持有相同密钥,平台用相同原文重新计算,结果一致才接受请求。SecretKey 本身不会通过网络发送。
摘要字节最后转换成小写十六进制字符串。若平台要求 Base64 或大写十六进制,转换方式也必须严格匹配,不能只确认算法名相同。
9. buildRequest 怎样区分请求体
if ("GET".equals(method) || "DELETE".equals(method)) { return builder.method( method, HttpRequest.BodyPublishers.noBody()) .build();}
if (StringUtils.isEmpty(contentType)) { return builder.method( method, HttpRequest.BodyPublishers.noBody()) .build();}
builder.header("Content-Type", contentType);String requestBody = "application/x-www-form-urlencoded".equals(contentType) ? String.valueOf(body) : toJson(body);return builder.method( method, HttpRequest.BodyPublishers.ofString( requestBody, StandardCharsets.UTF_8)) .build();GET 和 DELETE 的参数通过查询字符串传递;POST 可以选择 JSON、表单或无请求体。postWithQuery 就使用无 Content-Type 的 POST,用于平台要求“参数在 URL、请求体为空”的特殊接口。
表单编码不能简单使用 Map.toString(),所以公共客户端先通过 buildFormBody 对键和值分别 URL 编码,再交给这里发送。
10. 路径和查询参数如何编码
private String groupPath(String template, String... variables) { String groupId = properties.getGroupId(); if (StringUtils.isEmpty(groupId)) { throw new ServiceException("大疆运载 group_id 未配置"); } Object[] args = new Object[variables.length + 1]; args[0] = encodePathSegment(groupId); for (int i = 0; i < variables.length; i++) { args[i + 1] = encodePathSegment(variables[i]); } return String.format(template, args);}路径段和查询参数都需要编码,但规则并不完全相同。路径中的空格应编码为 %20,而标准表单编码可能产生 +,因此 encodePathSegment 会把 + 再替换成 %20。
外部 ID 不能不经处理直接插入路径,否则斜杠、问号等字符可能改变 URL 结构。
11. 航线响应归一化的代码
for (Map<String, Object> item : getObjectList(source)) { Double longitude = numberValue(pickFirst( item, "longitude", "lng", "lon")); Double latitude = numberValue(pickFirst( item, "latitude", "lat")); if (longitude == null || latitude == null || longitude == 0D || latitude == 0D) { continue; } Map<String, Object> point = new LinkedHashMap<>(); point.put("longitude", longitude); point.put("latitude", latitude); Object height = pickFirst( item, "height", "altitude", "ellipsoid_height"); if (height != null) { point.put("height", height); } result.add(point);}pickFirst 让适配层兼容第三方不同版本的字段命名,numberValue 同时接受 JSON 数字和数字字符串。使用 LinkedHashMap 保持字段输出顺序稳定,便于日志和接口调试。
归一化后的对象只保留业务真正需要的经纬度、高度和速度。第三方响应中其他字段不会直接扩散到前端契约。
12. 降级代码应该捕获到哪里
订单详情只把第三方航线当作增强信息,因此调用处捕获异常:
try { Object response = djiOpenApiService .getWayline(waylineId, Collections.emptyMap()); List<Map<String, Object>> points = normalizeDjiWaylinePoints(extractPoints(response)); order.setWaylinePoints(points); order.setRouteCoordinateSystem("WGS84");} catch (Exception ignored) { // 继续构造基础路线}实际项目中不建议完全忽略日志,可以用 debug 或带频率限制的 warn 记录订单 ID 和第三方请求 ID。这样用户不受影响,运维仍能发现航线服务长期异常。
降级只包住获取增强数据的最小范围,不能把整个订单查询都包在空 catch 中,否则数据库错误也会被误认为“没有航线”。
13. 本篇小结
第三方平台接入最好分成“通用 HTTP 客户端、平台能力适配、本地业务编排”三层。密钥和签名停留在服务端,外部字段在边界处归一化,再为非关键展示准备回退数据,业务代码才不会完全受制于外部接口。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























