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

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

飞行模块同时面对两套数据:项目数据库中的配送任务,以及大疆运载云端互联平台中的设备、航线和任务。两者不能完全混为一体,否则第三方接口一旦变化,订单详情也会跟着失效。

1. 本地任务保存业务关系#

HyFlightTask 负责连接订单和外部飞行数据,保存当前任务 ID、大疆任务 ID、航线标识、任务状态等信息。订单通过 currentTaskId 指向当前飞行任务,过程日志也可以记录同一个任务 ID。

本地表回答“这次配送使用哪个飞行任务”,大疆平台回答“设备、航线和实时任务现在怎样”。职责分开后,即使外部平台暂时不可用,订单关系仍然存在。

2. 通用客户端统一鉴权#

DjiApiClient 封装 GET、POST、PUT、DELETE 和表单请求。每次调用都会生成:

X-DJI-Access-Key
X-DJI-Timestamp
X-DJI-Nonce
X-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 客户端、平台能力适配、本地业务编排”三层。密钥和签名停留在服务端,外部字段在边界处归一化,再为非关键展示准备回退数据,业务代码才不会完全受制于外部接口。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记⑦:飞行任务与大疆开放平台适配
https://firefly.cuteleaf.cn/posts/backend-dev-diary-7/
作者
Yore
发布于
2026-07-16
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0