From c90dd2da4cfa60c06099c4e66fba6b203bf90b59 Mon Sep 17 00:00:00 2001
From: shench3 <shench3@xiaopeng.com>
Date: 星期六, 07 三月 2026 21:01:25 +0800
Subject: [PATCH] feat: vin转uuid、地图文件上传逻辑补充
---
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java | 50 +
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java | 276 +++++++++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java | 76 ++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java | 142 ++++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java | 71 ++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java | 172 +++++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java | 24
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java | 38 +
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java | 29 +
xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java | 63 ++
xp-robotaxi-map-boot/src/test/resources/logback-test.xml | 12
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java | 28
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java | 240 ++++++++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java | 73 ++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java | 237 ++++++++
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java | 48 +
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java | 31 +
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java | 18
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java | 38 +
xp-robotaxi-map-boot/src/main/resources/application-local.yml | 23
xp-robotaxi-map-boot/pom.xml | 27
21 files changed, 1,716 insertions(+), 0 deletions(-)
diff --git a/xp-robotaxi-map-boot/pom.xml b/xp-robotaxi-map-boot/pom.xml
index b7badad..8cccd8b 100644
--- a/xp-robotaxi-map-boot/pom.xml
+++ b/xp-robotaxi-map-boot/pom.xml
@@ -102,6 +102,12 @@
<artifactId>xp-common-log</artifactId>
</dependency>
+ <!-- xiaopeng Common Redis(XpMapController、XpElectronicFenceAreaServiceImpl、HolidayUtil 等使用 RedisService) -->
+ <dependency>
+ <groupId>com.xiaopeng</groupId>
+ <artifactId>xp-common-redis</artifactId>
+ </dependency>
+
<dependency>
<groupId>com.xiaopeng</groupId>
<artifactId>xp-common-swagger</artifactId>
@@ -169,6 +175,27 @@
<artifactId>snakeyaml</artifactId>
<version>1.33</version>
</dependency>
+
+ <!-- FlatBuffers Java runtime for map area RTree fbs 构建 -->
+ <dependency>
+ <groupId>com.google.flatbuffers</groupId>
+ <artifactId>flatbuffers-java</artifactId>
+ <version>23.5.26</version>
+ </dependency>
+
+ <!-- Protobuf for map area data proto 序列化 -->
+ <dependency>
+ <groupId>com.google.protobuf</groupId>
+ <artifactId>protobuf-java</artifactId>
+ <version>3.19.4</version>
+ </dependency>
+
+ <!-- 阿里云 OSS 官方 SDK(ROBOTAXI ODD 图层上传) -->
+ <dependency>
+ <groupId>com.aliyun.oss</groupId>
+ <artifactId>aliyun-sdk-oss</artifactId>
+ <version>3.18.5</version>
+ </dependency>
</dependencies>
<build>
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java
new file mode 100644
index 0000000..9aef1bb
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java
@@ -0,0 +1,50 @@
+package com.xiaopeng.map.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 OSS 配置属性(ROBOTAXI ODD 图层上传)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliyun.oss")
+public class OssProperties {
+
+ /**
+ * 是否启用 OSS
+ */
+ private boolean enabled = true;
+
+ /**
+ * OSS Endpoint,如 oss-cn-beijing.aliyuncs.com
+ */
+ private String endpoint;
+
+ /**
+ * AccessKey ID
+ */
+ private String accessKeyId;
+
+ /**
+ * AccessKey Secret
+ */
+ private String accessKeySecret;
+
+ /**
+ * Bucket 名称
+ */
+ private String bucketName = "cngp-crowdsourcing";
+
+ /**
+ * 路径前缀,如 ROBOTAXI_ODD_layer
+ */
+ private String pathPrefix = "ROBOTAXI_ODD_layer";
+
+ /**
+ * OSS 路径中的环境:test(测试)、staging(预发)、prod(生产)
+ * 支持占位符:${spring.profiles.active} 或环境变量 ${OSS_ENV:test}
+ */
+ private String env = "test";
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java
new file mode 100644
index 0000000..b352758
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java
@@ -0,0 +1,24 @@
+package com.xiaopeng.map.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * VIN 转 UUID 盐值配置,用于 VehicleCodec.vinToUUID
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "adc.vin")
+public class VinSaltProperties {
+
+ /**
+ * VIN 转 UUID 盐值1
+ */
+ private String salt1;
+
+ /**
+ * VIN 转 UUID 盐值2
+ */
+ private String salt2;
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java
new file mode 100644
index 0000000..2a47c05
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java
@@ -0,0 +1,28 @@
+package com.xiaopeng.map.dto;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对应 map_area_data.proto 中的 MapAreaData。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class MapAreaDataDto {
+ private int version;
+ private List<OddAreaDto> oddAreas;
+
+ public List<OddAreaDto> getOddAreas() {
+ if (oddAreas == null) {
+ oddAreas = new ArrayList<>();
+ }
+ return oddAreas;
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java
new file mode 100644
index 0000000..9c912b9
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java
@@ -0,0 +1,73 @@
+package com.xiaopeng.map.dto;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+
+/**
+ * 对应 map_area_rtree.fbs 中的 Node 表。
+ * 只实现构建 FlatBuffer 所需的最小方法集合。
+ */
+public final class Node extends Table {
+
+ private Node() {
+ }
+
+ public static int createNode(FlatBufferBuilder builder,
+ int type,
+ int rangeOffset,
+ int pointsVectorOffset,
+ int id,
+ boolean isBlacklist) {
+ builder.startTable(5);
+ addType(builder, type);
+ addRange(builder, rangeOffset);
+ addPoints(builder, pointsVectorOffset);
+ addId(builder, id);
+ addIsBlacklist(builder, isBlacklist);
+ return endNode(builder);
+ }
+
+ public static void startNode(FlatBufferBuilder builder) {
+ builder.startTable(5);
+ }
+
+ public static void addType(FlatBufferBuilder builder, int type) {
+ builder.addInt(0, type, 0);
+ }
+
+ public static void addRange(FlatBufferBuilder builder, int rangeOffset) {
+ builder.addOffset(1, rangeOffset, 0);
+ }
+
+ public static void addPoints(FlatBufferBuilder builder, int pointsOffset) {
+ builder.addOffset(2, pointsOffset, 0);
+ }
+
+ public static void addId(FlatBufferBuilder builder, int id) {
+ builder.addInt(3, id, 0);
+ }
+
+ public static void addIsBlacklist(FlatBufferBuilder builder, boolean isBlacklist) {
+ builder.addBoolean(4, isBlacklist, false);
+ }
+
+ /**
+ * 构建 points 向量。
+ */
+ public static int createPointsVector(FlatBufferBuilder builder, int[] data) {
+ builder.startVector(4, data.length, 4);
+ for (int i = data.length - 1; i >= 0; i--) {
+ builder.addOffset(data[i]);
+ }
+ return builder.endVector();
+ }
+
+ public static void startPointsVector(FlatBufferBuilder builder, int numElems) {
+ builder.startVector(4, numElems, 4);
+ }
+
+ public static int endNode(FlatBufferBuilder builder) {
+ return builder.endTable();
+ }
+}
+
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java
new file mode 100644
index 0000000..4195dc4
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java
@@ -0,0 +1,29 @@
+package com.xiaopeng.map.dto;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 对应 map_area_data.proto 中的 OddArea。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class OddAreaDto {
+ private int oddId;
+ private List<OddPointDto> oddPoints;
+ private boolean isBlacklist;
+
+ public List<OddPointDto> getOddPoints() {
+ if (oddPoints == null) {
+ oddPoints = new ArrayList<>();
+ }
+ return oddPoints;
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java
new file mode 100644
index 0000000..07e34a5
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java
@@ -0,0 +1,18 @@
+package com.xiaopeng.map.dto;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+/**
+ * 对应 map_area_data.proto 中的 OddPoint。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class OddPointDto {
+ private double latitude;
+ private double longitude;
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java
new file mode 100644
index 0000000..e7ad870
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java
@@ -0,0 +1,38 @@
+package com.xiaopeng.map.dto;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+
+/**
+ * 对应 map_area_rtree.fbs 中的 Point2D 表。
+ * 只实现构建 FlatBuffer 所需的最小方法集合。
+ */
+public final class Point2D extends Table {
+
+ private Point2D() {
+ }
+
+ public static int createPoint2D(FlatBufferBuilder builder, float x, float y) {
+ builder.startTable(2);
+ addX(builder, x);
+ addY(builder, y);
+ return endPoint2D(builder);
+ }
+
+ public static void startPoint2D(FlatBufferBuilder builder) {
+ builder.startTable(2);
+ }
+
+ public static void addX(FlatBufferBuilder builder, float x) {
+ builder.addFloat(0, x, 0.0f);
+ }
+
+ public static void addY(FlatBufferBuilder builder, float y) {
+ builder.addFloat(1, y, 0.0f);
+ }
+
+ public static int endPoint2D(FlatBufferBuilder builder) {
+ return builder.endTable();
+ }
+}
+
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java
new file mode 100644
index 0000000..0e83c3a
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java
@@ -0,0 +1,48 @@
+package com.xiaopeng.map.dto;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+
+/**
+ * 对应 map_area_rtree.fbs 中的 RTree 表。
+ * 只实现构建 FlatBuffer 所需的最小方法集合。
+ */
+public final class RTree extends Table {
+
+ private RTree() {
+ }
+
+ public static int createRTree(FlatBufferBuilder builder, int nodesVectorOffset) {
+ builder.startTable(1);
+ addNodes(builder, nodesVectorOffset);
+ return endRTree(builder);
+ }
+
+ public static void startRTree(FlatBufferBuilder builder) {
+ builder.startTable(1);
+ }
+
+ public static void addNodes(FlatBufferBuilder builder, int nodesOffset) {
+ builder.addOffset(0, nodesOffset, 0);
+ }
+
+ /**
+ * 构建 nodes 向量。
+ */
+ public static int createNodesVector(FlatBufferBuilder builder, int[] data) {
+ builder.startVector(4, data.length, 4);
+ for (int i = data.length - 1; i >= 0; i--) {
+ builder.addOffset(data[i]);
+ }
+ return builder.endVector();
+ }
+
+ public static void startNodesVector(FlatBufferBuilder builder, int numElems) {
+ builder.startVector(4, numElems, 4);
+ }
+
+ public static int endRTree(FlatBufferBuilder builder) {
+ return builder.endTable();
+ }
+}
+
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java
new file mode 100644
index 0000000..4c21dfa
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java
@@ -0,0 +1,38 @@
+package com.xiaopeng.map.dto;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+
+/**
+ * 对应 map_area_rtree.fbs 中的 Rect 表。
+ * 只实现构建 FlatBuffer 所需的最小方法集合。
+ */
+public final class Rect extends Table {
+
+ private Rect() {
+ }
+
+ public static int createRect(FlatBufferBuilder builder, int leftDownOffset, int rightUpOffset) {
+ builder.startTable(2);
+ addLeftDown(builder, leftDownOffset);
+ addRightUp(builder, rightUpOffset);
+ return endRect(builder);
+ }
+
+ public static void startRect(FlatBufferBuilder builder) {
+ builder.startTable(2);
+ }
+
+ public static void addLeftDown(FlatBufferBuilder builder, int leftDownOffset) {
+ builder.addOffset(0, leftDownOffset, 0);
+ }
+
+ public static void addRightUp(FlatBufferBuilder builder, int rightUpOffset) {
+ builder.addOffset(1, rightUpOffset, 0);
+ }
+
+ public static int endRect(FlatBufferBuilder builder) {
+ return builder.endTable();
+ }
+}
+
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java
new file mode 100644
index 0000000..8bae22b
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java
@@ -0,0 +1,31 @@
+package com.xiaopeng.map.service;
+
+import com.xiaopeng.map.utils.XngpAreaRTreeFbsUtil;
+
+import java.util.List;
+
+/**
+ * ADC OSS 处理服务,根据坐标点生成 RTree fbs 并上传至 OSS。
+ */
+public interface AdcOssHandleService {
+
+ /**
+ * 根据坐标点生成 RTree fbs 并上传至 OSS
+ *
+ * @param points 多边形顶点(使用 XngpAreaRTreeFbsUtil.LonLat)
+ * @param areaId 区域 areaId
+ * @param vin 车辆 VIN 码
+ * @param areaCode 区域编码
+ * @return 上传后的 objectKey,失败抛异常
+ */
+ String uploadOddLayerFile(List<XngpAreaRTreeFbsUtil.LonLat> points, int areaId, String vin, String areaCode);
+
+ /**
+ * 删除指定车辆在指定区域下的 ODD 图层文件
+ *
+ * @param vin 车辆 VIN 码
+ * @param areaCode 区域编码
+ * @return 成功删除的 objectKey 列表
+ */
+ List<String> deleteOddLayerFiles(String vin, String areaCode);
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java
new file mode 100644
index 0000000..e5562c9
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java
@@ -0,0 +1,71 @@
+package com.xiaopeng.map.service;
+
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * ROBOTAXI OSS 服务,用于上传 ODD 图层文件到阿里云 OSS。
+ * 路径格式:ROBOTAXI_ODD_layer/{区域编码}/{环境}/{车辆uuid}/{fileName}
+ */
+public interface RobotaxiOssService {
+
+ /**
+ * 上传 ODD 图层文件到 OSS
+ * 路径:{pathPrefix}/{areaCode}/{env}/{vehicleUuid}/{fileName}
+ *
+ * @param data 文件内容
+ * @param fileName 文件名(如 map_area_data.bin、map_area_rtree.fbs)
+ * @param areaCode 区域编码(动态)
+ * @param vehicleUuid 车辆 UUID(动态)
+ * @return 上传成功后的 objectKey,失败时抛异常
+ */
+ String uploadOddLayerFile(byte[] data, String fileName, String areaCode, String vehicleUuid);
+
+ /**
+ * 上传 ODD 图层文件到 OSS(InputStream 重载)
+ *
+ * @param inputStream 文件输入流
+ * @param contentLength 文件大小
+ * @param fileName 文件名
+ * @param areaCode 区域编码
+ * @param vehicleUuid 车辆 UUID
+ * @return 上传成功后的 objectKey,失败时抛异常
+ */
+ String uploadOddLayerFile(InputStream inputStream, long contentLength, String fileName,
+ String areaCode, String vehicleUuid);
+
+ /**
+ * 根据 OSS objectKey 删除单个文件
+ *
+ * @param objectKey OSS 对象键,如 ROBOTAXI_ODD_layer/440100/test/vehicle-001/map_area_rtree.fbs
+ * @return 是否删除成功
+ */
+ boolean deleteByObjectKey(String objectKey);
+
+ /**
+ * 根据 OSS objectKey 批量删除文件
+ *
+ * @param objectKeys OSS 对象键列表
+ * @return 成功删除的对象键列表
+ */
+ List<String> deleteByObjectKeys(List<String> objectKeys);
+
+ /**
+ * 按前缀删除 ODD 路径下所有文件
+ * 路径前缀:{pathPrefix}/{areaCode}/{env}/{vehicleUuid}/
+ *
+ * @param areaCode 区域编码
+ * @param vehicleUuid 车辆 UUID
+ * @return 成功删除的 objectKey 列表
+ */
+ List<String> deleteOddLayerFilesByPrefix(String areaCode, String vehicleUuid);
+
+ /**
+ * 根据 objectKey 生成文件下载临时链接(Presigned URL)
+ *
+ * @param objectKey OSS 对象键
+ * @param expireSeconds 有效期(秒)
+ * @return 带签名的临时下载 URL
+ */
+ String generatePresignedDownloadUrl(String objectKey, int expireSeconds);
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java
new file mode 100644
index 0000000..444e439
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java
@@ -0,0 +1,76 @@
+package com.xiaopeng.map.service.serviceimpl;
+
+import com.xiaopeng.map.config.VinSaltProperties;
+import com.xiaopeng.map.service.AdcOssHandleService;
+import com.xiaopeng.map.service.RobotaxiOssService;
+import com.xiaopeng.map.utils.VehicleCodec;
+import com.xiaopeng.map.utils.XngpAreaRTreeFbsUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * ADC OSS 处理服务实现,根据坐标点生成 RTree fbs 并上传至 OSS。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnBean(RobotaxiOssService.class)
+public class AdcOssHandleServiceImpl implements AdcOssHandleService {
+
+ private static final String FILE_NAME_FORMAT = "yyyyMMddHHmmss";
+
+ private final RobotaxiOssService robotaxiOssService;
+ private final VinSaltProperties vinSaltProperties;
+
+ @Override
+ public String uploadOddLayerFile(List<XngpAreaRTreeFbsUtil.LonLat> points, int areaId, String vin, String areaCode) {
+ validateParams(points, vin, areaCode);
+
+ String vehicleUuid = VehicleCodec.vinToUUID(vin, vinSaltProperties.getSalt1(), vinSaltProperties.getSalt2());
+
+ byte[] fbsBytes = XngpAreaRTreeFbsUtil.buildRTreeBytesFromPoints(points, areaId, false);
+ if (fbsBytes == null || fbsBytes.length == 0) {
+ throw new IllegalStateException("Failed to build RTree fbs from points");
+ }
+
+ String fileName = new SimpleDateFormat(FILE_NAME_FORMAT).format(new Date()) + "_xngp_area_data.bin";
+
+ String objectKey = robotaxiOssService.uploadOddLayerFile(fbsBytes, fileName, areaCode, vehicleUuid);
+ log.debug("AdcOssHandle uploaded ODD layer file, areaId={}, areaCode={}, objectKey={}", areaId, areaCode, objectKey);
+ return objectKey;
+ }
+
+ @Override
+ public List<String> deleteOddLayerFiles(String vin, String areaCode) {
+ if (!StringUtils.hasText(vin)) {
+ throw new IllegalArgumentException("vin cannot be empty");
+ }
+ if (!StringUtils.hasText(areaCode)) {
+ throw new IllegalArgumentException("areaCode cannot be empty");
+ }
+ String vehicleUuid = VehicleCodec.vinToUUID(vin, vinSaltProperties.getSalt1(), vinSaltProperties.getSalt2());
+ List<String> deleted = robotaxiOssService.deleteOddLayerFilesByPrefix(areaCode, vehicleUuid);
+ log.debug("AdcOssHandle deleted ODD layer files, vin={}, areaCode={}, count={}", vin, areaCode, deleted.size());
+ return deleted;
+ }
+
+ private void validateParams(List<XngpAreaRTreeFbsUtil.LonLat> points, String vin, String areaCode) {
+ if (CollectionUtils.isEmpty(points)) {
+ throw new IllegalArgumentException("points cannot be empty");
+ }
+ if (!StringUtils.hasText(vin)) {
+ throw new IllegalArgumentException("vin cannot be empty");
+ }
+ if (!StringUtils.hasText(areaCode)) {
+ throw new IllegalArgumentException("areaCode cannot be empty");
+ }
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java
new file mode 100644
index 0000000..13c7bc1
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java
@@ -0,0 +1,240 @@
+package com.xiaopeng.map.service.serviceimpl;
+
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.HttpMethod;
+import com.aliyun.oss.model.GeneratePresignedUrlRequest;
+import com.aliyun.oss.model.ListObjectsRequest;
+import com.aliyun.oss.model.OSSObjectSummary;
+import com.aliyun.oss.model.ObjectListing;
+import com.aliyun.oss.model.ObjectMetadata;
+import com.xiaopeng.map.config.OssProperties;
+import com.xiaopeng.map.service.RobotaxiOssService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * ROBOTAXI OSS 服务实现,使用阿里云官方 OSS SDK 上传 ODD 图层文件。
+ * 路径格式:ROBOTAXI_ODD_layer/{区域编码}/{环境}/{车辆uuid}/{fileName}
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@ConditionalOnProperty(name = "aliyun.oss.enabled", havingValue = "true")
+public class RobotaxiOssServiceImpl implements RobotaxiOssService {
+
+ private final OssProperties ossProperties;
+
+ @Override
+ public String uploadOddLayerFile(byte[] data, String fileName, String areaCode, String vehicleUuid) {
+ if (data == null) {
+ throw new IllegalArgumentException("data cannot be null");
+ }
+ return uploadOddLayerFile(new ByteArrayInputStream(data), data.length, fileName, areaCode, vehicleUuid);
+ }
+
+ @Override
+ public String uploadOddLayerFile(InputStream inputStream, long contentLength, String fileName,
+ String areaCode, String vehicleUuid) {
+ validateParams(fileName, areaCode, vehicleUuid);
+
+ String objectKey = buildObjectKey(areaCode, vehicleUuid, fileName);
+ String bucketName = ossProperties.getBucketName();
+
+ OSS ossClient = null;
+ try {
+ ossClient = createOssClient();
+ ObjectMetadata metadata = new ObjectMetadata();
+ metadata.setContentLength(contentLength);
+
+ ossClient.putObject(bucketName, objectKey, inputStream, metadata);
+ log.debug("Uploaded ODD layer file to OSS: {}", objectKey);
+ return objectKey;
+ } finally {
+ if (ossClient != null) {
+ ossClient.shutdown();
+ }
+ }
+ }
+
+ @Override
+ public boolean deleteByObjectKey(String objectKey) {
+ if (!StringUtils.hasText(objectKey)) {
+ throw new IllegalArgumentException("objectKey cannot be empty");
+ }
+ String bucketName = ossProperties.getBucketName();
+ OSS ossClient = null;
+ try {
+ ossClient = createOssClient();
+ ossClient.deleteObject(bucketName, objectKey);
+ log.debug("Deleted OSS object: {}", objectKey);
+ return true;
+ } catch (Exception e) {
+ log.warn("Failed to delete OSS object: {}, error: {}", objectKey, e.getMessage());
+ return false;
+ } finally {
+ if (ossClient != null) {
+ ossClient.shutdown();
+ }
+ }
+ }
+
+ @Override
+ public List<String> deleteByObjectKeys(List<String> objectKeys) {
+ if (objectKeys == null || objectKeys.isEmpty()) {
+ return new ArrayList<>();
+ }
+ String bucketName = ossProperties.getBucketName();
+ OSS ossClient = null;
+ List<String> deleted = new ArrayList<>();
+ try {
+ ossClient = createOssClient();
+ for (String key : objectKeys) {
+ if (!StringUtils.hasText(key)) {
+ continue;
+ }
+ try {
+ ossClient.deleteObject(bucketName, key);
+ deleted.add(key);
+ log.debug("Deleted OSS object: {}", key);
+ } catch (Exception e) {
+ log.warn("Failed to delete OSS object: {}, error: {}", key, e.getMessage());
+ }
+ }
+ return deleted;
+ } finally {
+ if (ossClient != null) {
+ ossClient.shutdown();
+ }
+ }
+ }
+
+ @Override
+ public List<String> deleteOddLayerFilesByPrefix(String areaCode, String vehicleUuid) {
+ validateParamsForPrefix(areaCode, vehicleUuid);
+
+ String prefix = buildObjectKeyPrefix(areaCode, vehicleUuid);
+ String bucketName = ossProperties.getBucketName();
+
+ OSS ossClient = null;
+ try {
+ ossClient = createOssClient();
+ List<String> objectKeys = new ArrayList<>();
+ String nextMarker = null;
+
+ do {
+ ListObjectsRequest request = new ListObjectsRequest(bucketName).withPrefix(prefix).withMarker(nextMarker);
+ ObjectListing listing = ossClient.listObjects(request);
+ for (OSSObjectSummary summary : listing.getObjectSummaries()) {
+ objectKeys.add(summary.getKey());
+ }
+ nextMarker = listing.isTruncated() ? listing.getNextMarker() : null;
+ } while (nextMarker != null);
+
+ if (objectKeys.isEmpty()) {
+ return new ArrayList<>();
+ }
+ return deleteByObjectKeys(objectKeys);
+ } finally {
+ if (ossClient != null) {
+ ossClient.shutdown();
+ }
+ }
+ }
+
+ @Override
+ public String generatePresignedDownloadUrl(String objectKey, int expireSeconds) {
+ if (!StringUtils.hasText(objectKey)) {
+ throw new IllegalArgumentException("objectKey cannot be empty");
+ }
+ if (expireSeconds <= 0) {
+ throw new IllegalArgumentException("expireSeconds must be positive");
+ }
+ String bucketName = ossProperties.getBucketName();
+ OSS ossClient = null;
+ try {
+ ossClient = createOssClient();
+ GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, objectKey, HttpMethod.GET);
+ request.setExpiration(new Date(System.currentTimeMillis() + expireSeconds * 1000L));
+ URL url = ossClient.generatePresignedUrl(request);
+ return url != null ? url.toString() : null;
+ } finally {
+ if (ossClient != null) {
+ ossClient.shutdown();
+ }
+ }
+ }
+
+ private void validateParamsForPrefix(String areaCode, String vehicleUuid) {
+ if (!StringUtils.hasText(areaCode)) {
+ throw new IllegalArgumentException("areaCode cannot be empty");
+ }
+ if (!StringUtils.hasText(vehicleUuid)) {
+ throw new IllegalArgumentException("vehicleUuid cannot be empty");
+ }
+ }
+
+ private String buildObjectKeyPrefix(String areaCode, String vehicleUuid) {
+ String pathPrefix = ossProperties.getPathPrefix();
+ if (!StringUtils.hasText(pathPrefix)) {
+ pathPrefix = "ROBOTAXI_ODD_layer";
+ }
+ String env = StringUtils.hasText(ossProperties.getEnv()) ? ossProperties.getEnv() : "test";
+ return String.format("%s/%s/%s/%s/", pathPrefix, areaCode, env, vehicleUuid);
+ }
+
+ private void validateParams(String fileName, String areaCode, String vehicleUuid) {
+ if (!StringUtils.hasText(fileName)) {
+ throw new IllegalArgumentException("fileName cannot be empty");
+ }
+ if (!StringUtils.hasText(areaCode)) {
+ throw new IllegalArgumentException("areaCode cannot be empty");
+ }
+ if (!StringUtils.hasText(vehicleUuid)) {
+ throw new IllegalArgumentException("vehicleUuid cannot be empty");
+ }
+ }
+
+ private String buildObjectKey(String areaCode, String vehicleUuid, String fileName) {
+ String pathPrefix = ossProperties.getPathPrefix();
+ if (!StringUtils.hasText(pathPrefix)) {
+ pathPrefix = "ROBOTAXI_ODD_layer";
+ }
+ String env = StringUtils.hasText(ossProperties.getEnv()) ? ossProperties.getEnv() : "test";
+ return String.format("%s/%s/%s/%s/%s", pathPrefix, areaCode, env, vehicleUuid, fileName);
+ }
+
+ private OSS createOssClient() {
+ String endpoint = ossProperties.getEndpoint();
+ String accessKeyId = ossProperties.getAccessKeyId();
+ String accessKeySecret = ossProperties.getAccessKeySecret();
+
+ if (!StringUtils.hasText(endpoint) || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) {
+ throw new IllegalStateException("OSS config incomplete: endpoint, accessKeyId, accessKeySecret must be set");
+ }
+
+ return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+ }
+
+ /**
+ * 构建 OSS 对象访问 URL:https://{bucket}.{endpoint}/{objectKey}
+ */
+ private String buildObjectUrl(String bucketName, String objectKey) {
+ String endpoint = ossProperties.getEndpoint();
+ if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
+ int schemeEnd = endpoint.indexOf("://") + 3;
+ endpoint = endpoint.substring(schemeEnd);
+ }
+ return String.format("https://%s.%s/%s", bucketName, endpoint, objectKey);
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java
new file mode 100644
index 0000000..862978a
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java
@@ -0,0 +1,142 @@
+package com.xiaopeng.map.utils;
+
+import com.google.protobuf.CodedOutputStream;
+import com.xiaopeng.map.dto.MapAreaDataDto;
+import com.xiaopeng.map.dto.OddAreaDto;
+import com.xiaopeng.map.dto.OddPointDto;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/**
+ * 将 MapAreaDataDto 序列化为与 map_area_data.proto 兼容的 Protobuf 二进制格式。
+ */
+public final class MapAreaDataProtoWriter {
+
+ private MapAreaDataProtoWriter() {
+ }
+
+ public static void writeTo(MapAreaDataDto data, OutputStream out) throws IOException {
+ byte[] bytes = toByteArray(data);
+ out.write(bytes);
+ }
+
+ public static byte[] toByteArray(MapAreaDataDto data) throws IOException {
+ int size = computeSize(data);
+ byte[] buf = new byte[size];
+ CodedOutputStream cos = CodedOutputStream.newInstance(buf);
+ writeMapAreaData(cos, data);
+ cos.checkNoSpaceLeft();
+ return buf;
+ }
+
+ private static int computeSize(MapAreaDataDto data) {
+ int size = 0;
+ if (data.getVersion() != 0) {
+ size += CodedOutputStream.computeUInt32Size(1, data.getVersion());
+ }
+ for (OddAreaDto area : data.getOddAreas()) {
+ int areaSize = computeOddAreaSize(area);
+ size += CodedOutputStream.computeTagSize(2) + CodedOutputStream.computeRawVarint32Size(areaSize) + areaSize;
+ }
+ return size;
+ }
+
+ private static int computeOddAreaSize(OddAreaDto area) {
+ int size = 0;
+ if (area.getOddId() != 0) {
+ size += CodedOutputStream.computeUInt32Size(1, area.getOddId());
+ }
+ for (OddPointDto pt : area.getOddPoints()) {
+ int ptSize = computeOddPointSize(pt);
+ size += CodedOutputStream.computeTagSize(2) + CodedOutputStream.computeRawVarint32Size(ptSize) + ptSize;
+ }
+ if (area.isBlacklist()) {
+ size += CodedOutputStream.computeBoolSize(3, true);
+ }
+ return size;
+ }
+
+ private static int computeOddPointSize(OddPointDto pt) {
+ int size = 0;
+ if (pt.getLatitude() != 0.0) {
+ size += CodedOutputStream.computeDoubleSize(1, pt.getLatitude());
+ }
+ if (pt.getLongitude() != 0.0) {
+ size += CodedOutputStream.computeDoubleSize(2, pt.getLongitude());
+ }
+ return size;
+ }
+
+ private static void writeMapAreaData(CodedOutputStream cos, MapAreaDataDto data) throws IOException {
+ cos.writeUInt32(1, data.getVersion());
+ for (OddAreaDto area : data.getOddAreas()) {
+ cos.writeTag(2, 2); // field 2, wire type length-delimited
+ int areaSize = computeOddAreaSize(area);
+ cos.writeRawVarint32(areaSize);
+ writeOddArea(cos, area);
+ }
+ }
+
+ private static void writeOddArea(CodedOutputStream cos, OddAreaDto area) throws IOException {
+ cos.writeUInt32(1, area.getOddId());
+ List<OddPointDto> points = area.getOddPoints();
+ for (OddPointDto pt : points) {
+ cos.writeTag(2, 2);
+ int ptSize = computeOddPointSize(pt);
+ cos.writeRawVarint32(ptSize);
+ writeOddPoint(cos, pt);
+ }
+ if (area.isBlacklist()) {
+ cos.writeBool(3, true);
+ }
+ }
+
+ private static void writeOddPoint(CodedOutputStream cos, OddPointDto pt) throws IOException {
+ cos.writeDouble(1, pt.getLatitude());
+ cos.writeDouble(2, pt.getLongitude());
+ }
+
+ /**
+ * 测试入口:使用 XngpAreaRTreeFbsUtil 的示例 GCL 数据生成 map_area_data 文件。
+ */
+ public static void main(String[] args) {
+ String gcl = "104.06249657558965_30.583221674855963," +
+ "104.06244328689024_30.58049965528362," +
+ "104.06640444191817_30.580606702069797," +
+ "104.06496563619123_30.58420035984882," +
+ "104.06249657558965_30.583221674855963";
+
+ List<XngpAreaRTreeFbsUtil.LonLat> points = XngpAreaRTreeFbsUtil.parseGcl(gcl);
+ if (points.isEmpty()) {
+ System.err.println("parseGcl failed: no points");
+ return;
+ }
+
+ OddAreaDto oddArea = OddAreaDto.builder()
+ .oddId(1)
+ .isBlacklist(false)
+ .build();
+ for (XngpAreaRTreeFbsUtil.LonLat p : points) {
+ oddArea.getOddPoints().add(OddPointDto.builder()
+ .latitude(p.getLat())
+ .longitude(p.getLon())
+ .build());
+ }
+
+ MapAreaDataDto data = MapAreaDataDto.builder()
+ .version(1)
+ .oddAreas(java.util.Collections.singletonList(oddArea))
+ .build();
+
+ String outFile = "map_area_data_test.bin";
+ try (FileOutputStream fos = new FileOutputStream(outFile)) {
+ MapAreaDataProtoWriter.writeTo(data, fos);
+ System.out.println("MapAreaDataProtoWriter success, outFile=" + outFile);
+ } catch (IOException e) {
+ System.err.println("MapAreaDataProtoWriter failed: " + e.getMessage());
+ }
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java
new file mode 100644
index 0000000..a0c6577
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java
@@ -0,0 +1,172 @@
+package com.xiaopeng.map.utils;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.xiaopeng.map.dto.Node;
+import com.xiaopeng.map.dto.Point2D;
+import com.xiaopeng.map.dto.RTree;
+import com.xiaopeng.map.dto.Rect;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 等价于 C++ PbRTreeCreater<Node>,将叶节点列表构建成完整的 R-Tree 数组后写入 FlatBuffer。
+ *
+ * R-Tree 结构:完全二叉树,以数组形式存储(与 C++ 一致)
+ * - 左子节点下标:2 * parent + 1
+ * - 右子节点下标:2 * parent + 2
+ * - 总节点数:2^(ceil(log2(n))+1) - 1
+ *
+ * 节点类型(对应 map_area_rtree.proto 中 Node.type):
+ * - 0: INVALID(空占位节点)
+ * - 1: LEAF(叶节点,存有实际区域数据)
+ * - 2: BRANCH(内部节点,存有子树的合并包围框)
+ */
+public final class PbRTreeBuilder {
+
+ public static final int NODE_TYPE_INVALID = 0;
+ public static final int NODE_TYPE_LEAF = 1;
+ public static final int NODE_TYPE_BRANCH = 2;
+
+ private PbRTreeBuilder() {
+ }
+
+ /**
+ * R-Tree 中间节点结构,对应 C++ 中 map_area_rtree.proto 的 Node。
+ */
+ public static class RTreeNode {
+ public int type = NODE_TYPE_INVALID;
+ /** 包围框(range),单位与 FlatBuffers Point2D.x/y 一致(float) */
+ public float minX, minY, maxX, maxY;
+ /** 仅 LEAF 节点有效,每项 float[]{x, y} 对应一个多边形顶点 */
+ public List<float[]> points;
+ /** 业务 ID,对应 odd_id */
+ public int id;
+ /** 是否黑名单 */
+ public boolean isBlacklist;
+ }
+
+ /**
+ * 从叶节点列表构建完整 R-Tree 数组(等价于 PbRTreeCreater.CreatePbRTree)。
+ *
+ * @param leafNodes 原始叶节点,每个对应一个 OddArea(已算好 minX/Y, maxX/Y)
+ * @return 按数组下标顺序排列的完整 R-Tree 节点列表(包含 INVALID 占位节点)
+ */
+ public static List<RTreeNode> createRTree(List<RTreeNode> leafNodes) {
+ int n = leafNodes.size();
+ // tree_height = ceil(log2(n)),n<=1 时 log2(1)=0
+ int treeHeight = (n <= 1) ? 0 : (int) Math.ceil(Math.log(n) / Math.log(2));
+ int nodeSize = (int) Math.pow(2, treeHeight + 1) - 1;
+
+ List<RTreeNode> rtreeNodes = new ArrayList<>(nodeSize);
+ for (int i = 0; i < nodeSize; i++) {
+ rtreeNodes.add(new RTreeNode()); // type = INVALID
+ }
+
+ buildRecursive(new ArrayList<>(leafNodes), 0, leafNodes.size(), 0, rtreeNodes);
+ return rtreeNodes;
+ }
+
+ private static void buildRecursive(List<RTreeNode> objects, int begin, int end,
+ int index, List<RTreeNode> rtreeNodes) {
+ int size = end - begin;
+ if (size == 0) {
+ return;
+ }
+ RTreeNode node = rtreeNodes.get(index);
+ if (size == 1) {
+ RTreeNode src = objects.get(begin);
+ node.type = NODE_TYPE_LEAF;
+ node.minX = src.minX;
+ node.minY = src.minY;
+ node.maxX = src.maxX;
+ node.maxY = src.maxY;
+ node.points = src.points;
+ node.id = src.id;
+ node.isBlacklist = src.isBlacklist;
+ return;
+ }
+
+ node.type = NODE_TYPE_BRANCH;
+ combineRect(objects, begin, end, node);
+ sort(objects, begin, end);
+
+ int mid = begin + size / 2;
+ buildRecursive(objects, begin, mid, 2 * index + 1, rtreeNodes);
+ buildRecursive(objects, mid, end, 2 * index + 2, rtreeNodes);
+ }
+
+ private static void combineRect(List<RTreeNode> objects, int begin, int end, RTreeNode out) {
+ float minX = objects.get(begin).minX;
+ float minY = objects.get(begin).minY;
+ float maxX = objects.get(begin).maxX;
+ float maxY = objects.get(begin).maxY;
+ for (int i = begin + 1; i < end; i++) {
+ RTreeNode n = objects.get(i);
+ minX = Math.min(minX, n.minX);
+ minY = Math.min(minY, n.minY);
+ maxX = Math.max(maxX, n.maxX);
+ maxY = Math.max(maxY, n.maxY);
+ }
+ out.minX = minX;
+ out.minY = minY;
+ out.maxX = maxX;
+ out.maxY = maxY;
+ }
+
+ private static void sort(List<RTreeNode> objects, int begin, int end) {
+ // 找当前子集的总包围框,以较长轴排序(与 C++ Sort 逻辑完全一致)
+ float minX = objects.get(begin).minX, minY = objects.get(begin).minY;
+ float maxX = objects.get(begin).maxX, maxY = objects.get(begin).maxY;
+ for (int i = begin + 1; i < end; i++) {
+ RTreeNode n = objects.get(i);
+ minX = Math.min(minX, n.minX);
+ minY = Math.min(minY, n.minY);
+ maxX = Math.max(maxX, n.maxX);
+ maxY = Math.max(maxY, n.maxY);
+ }
+ boolean sortByX = (maxX - minX) > (maxY - minY);
+
+ List<RTreeNode> sub = objects.subList(begin, end);
+ if (sortByX) {
+ sub.sort(Comparator.comparingDouble(n -> n.maxX));
+ } else {
+ sub.sort(Comparator.comparingDouble(n -> n.maxY));
+ }
+ }
+
+ /**
+ * 将 R-Tree 节点列表序列化为 FlatBuffer,返回 RTree table 的 offset。
+ * 与 C++ PbRTreeToFlatBuffer 的 FlatBuffer 写入逻辑对齐。
+ */
+ public static int writeFlatBuffer(FlatBufferBuilder builder, List<RTreeNode> rtreeNodes) {
+ int[] nodeOffsets = new int[rtreeNodes.size()];
+ for (int i = 0; i < rtreeNodes.size(); i++) {
+ RTreeNode n = rtreeNodes.get(i);
+
+ // points vector
+ int pointsVector;
+ if (n.points != null && !n.points.isEmpty()) {
+ int[] ptOffsets = new int[n.points.size()];
+ for (int j = 0; j < n.points.size(); j++) {
+ float[] pt = n.points.get(j);
+ ptOffsets[j] = Point2D.createPoint2D(builder, pt[0], pt[1]);
+ }
+ pointsVector = Node.createPointsVector(builder, ptOffsets);
+ } else {
+ pointsVector = Node.createPointsVector(builder, new int[0]);
+ }
+
+ // range
+ int leftDown = Point2D.createPoint2D(builder, n.minX, n.minY);
+ int rightUp = Point2D.createPoint2D(builder, n.maxX, n.maxY);
+ int rangeOffset = Rect.createRect(builder, leftDown, rightUp);
+
+ nodeOffsets[i] = Node.createNode(builder, n.type, rangeOffset, pointsVector, n.id, n.isBlacklist);
+ }
+
+ int nodesVector = RTree.createNodesVector(builder, nodeOffsets);
+ return RTree.createRTree(builder, nodesVector);
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java
new file mode 100644
index 0000000..c7a4126
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java
@@ -0,0 +1,237 @@
+package com.xiaopeng.map.utils;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.lang.RandomStringUtils;
+import org.bouncycastle.openssl.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.bouncycastle.util.io.pem.PemReader;
+
+import java.io.*;
+import java.math.BigInteger;
+import java.security.*;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.*;
+import java.util.UUID;
+
+/**
+ * @author chenjl
+ * @date 2020/6/3
+ */
+public class VehicleCodec {
+ static {
+ Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
+ }
+
+ /**
+ * 获得一个UUID
+ *
+ * @return String UUID
+ */
+ public static String getUUID() {
+ String s = UUID.randomUUID().toString();
+ // 去掉“-”符号
+ return s.substring(0, 8) + s.substring(9, 13) + s.substring(14, 18) + s.substring(19, 23) + s.substring(24);
+ }
+
+ /**
+ * 生成序列号
+ * @return
+ */
+ public static String generateSn() {
+ return String.join("-",
+ RandomStringUtils.randomAlphanumeric(4),
+ RandomStringUtils.randomAlphanumeric(4),
+ RandomStringUtils.randomAlphanumeric(4),
+ RandomStringUtils.randomAlphanumeric(4)).toUpperCase();
+ }
+
+ /**
+ * Encode binary data to base64
+ *
+ * @param value
+ * The binary data
+ * @return The base64 encoded String
+ */
+ public static String encodeBASE64(byte[] value) {
+ return new String(Base64.encodeBase64(value));
+ }
+
+ /**
+ * Decode a base64 value
+ *
+ * @param value
+ * The base64 encoded String
+ * @return decoded binary data
+ * @throws UnsupportedEncodingException
+ */
+ public static byte[] decodeBASE64(String value) {
+ try {
+ return Base64.decodeBase64(value.getBytes("utf-8"));
+ } catch (UnsupportedEncodingException ex) {
+ throw new IllegalArgumentException(ex);
+ }
+ }
+
+ /**
+ * ECDSA签名
+ * @param data 明文
+ * @param ecPrivateKey EC私钥
+ * @return 签名byte[]
+ * @throws Exception
+ */
+ public static String sign(byte[] data, ECPrivateKey ecPrivateKey) throws Exception {
+ Signature signature = Signature.getInstance("SHA256withECDSA", "BC");
+ signature.initSign(ecPrivateKey);
+ signature.update(data);
+ return encodeBASE64(signature.sign());
+ }
+
+ /**
+ * ECDSA 验签
+ * @param data 明文
+ * @param sign 签名byte[]
+ * @param ecPublicKey EC公钥
+ * @return 通过-true,失败-false
+ * @throws Exception
+ */
+ public static boolean verify(byte[] data, String sign, ECPublicKey ecPublicKey) throws Exception {
+ Signature signature = Signature.getInstance("SHA256withECDSA", "BC");
+ signature.initVerify(ecPublicKey);
+ signature.update(data);
+ return signature.verify(decodeBASE64(sign));
+ }
+
+ /**
+ * 根据X和Y,得到EC公钥
+ * @param publicKeyXBase64
+ * @param publicKeyYBase64
+ * @return
+ * @throws NoSuchAlgorithmException
+ * @throws InvalidParameterSpecException
+ * @throws InvalidKeySpecException
+ */
+ public static ECPublicKey getECPublicKeyFromPoint(String publicKeyXBase64, String publicKeyYBase64) throws NoSuchAlgorithmException, InvalidParameterSpecException, InvalidKeySpecException {
+ byte[] xkeyBytes = decodeBASE64(publicKeyXBase64);
+ byte[] ykeyBytes = decodeBASE64(publicKeyYBase64);
+
+ KeyFactory keyFactory = KeyFactory.getInstance("ECDSA");
+
+ ECPoint pubPoint = new ECPoint(new BigInteger(1, xkeyBytes), new BigInteger(1, ykeyBytes));
+ AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
+ parameters.init(new ECGenParameterSpec("secp256k1"));
+ ECParameterSpec ecParameters = parameters.getParameterSpec(ECParameterSpec.class);
+ ECPublicKeySpec pubSpec = new ECPublicKeySpec(pubPoint, ecParameters);
+
+ return (ECPublicKey) keyFactory.generatePublic(pubSpec);
+ }
+
+
+ /**
+ * 获取ECPrivateKey对象
+ * @param privateKeyBase64
+ * @return
+ * @throws NoSuchAlgorithmException
+ * @throws InvalidKeySpecException
+ */
+ public static ECPrivateKey getECPrivateKey(String privateKeyBase64) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ byte[] keyBytes = decodeBASE64(privateKeyBase64);
+ PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
+ KeyFactory keyFactory = KeyFactory.getInstance("EC");
+
+ return (ECPrivateKey) keyFactory
+ .generatePrivate(pkcs8KeySpec);
+ }
+
+
+ /**
+ * 从KEY文件解析KeyPair
+ * @param pemFile
+ * @return
+ * @throws IOException
+ */
+ public static KeyPair parsePEMFile(File pemFile) throws IOException {
+ if (!pemFile.isFile() || !pemFile.exists()) {
+ throw new FileNotFoundException(String.format("The file '%s' doesn't exist.", pemFile.getAbsolutePath()));
+ }
+ PemReader reader = new PemReader(new FileReader(pemFile));
+ PEMKeyPair pemKeyPair;
+ try (PEMParser pemParser = new PEMParser(reader)) {
+ reader.readPemObject();
+ pemKeyPair = (PEMKeyPair) pemParser.readObject();
+ return new JcaPEMKeyConverter().getKeyPair(pemKeyPair);
+ } catch (Exception e) {
+ //ignore
+ }
+ return null;
+ }
+
+ /**
+ * Write a byte array as hexadecimal String.
+ */
+ public static String byteToHexString(byte[] bytes) {
+ return String.valueOf(Hex.encodeHex(bytes));
+ }
+
+ /**
+ * Build an hexadecimal MD5 hash for a String
+ *
+ * @param value
+ * The String to hash
+ * @return An hexadecimal Hash
+ * @throws Exception
+ */
+ public static String hexMD5(String value) {
+ try {
+ MessageDigest messageDigest = MessageDigest.getInstance("MD5");
+ messageDigest.reset();
+ messageDigest.update(value.getBytes("utf-8"));
+ byte[] digest = messageDigest.digest();
+ return byteToHexString(digest);
+ } catch (Exception ex) {
+ throw new IllegalArgumentException(ex);
+ }
+ }
+
+
+
+ /**
+ * VIN 转UUID
+ * @param vin 车架号
+ * @param salt1 盐1
+ * @param salt2 盐2
+ * @return
+ */
+ public static String vinToUUID(String vin, String salt1, String salt2) {
+ return hexMD5(hexMD5(vin + salt1) + salt2).toUpperCase();
+ }
+
+ /**
+ * 将字节数组转换成16进制字符串
+ */
+ public static String encodeHexStr(final byte[] bytes){
+ if (bytes == null) {
+ return null;
+ }
+ char[] digital = "0123456789ABCDEF".toCharArray();
+ char[] result = new char[bytes.length * 2];
+ for (int i = 0; i < bytes.length; i++) {
+ result[i * 2] = digital[(bytes[i] & 0xf0) >> 4];
+ result[i * 2 + 1] = digital[bytes[i] & 0x0f];
+ }
+ return new String(result);
+ }
+
+ /**
+ * 检查是否VIN
+ * @param vin
+ * @return
+ */
+ public static boolean isVin(String vin) {
+ return vin.length() == 17 && vin.charAt(0) == 'L';
+ }
+
+
+}
diff --git a/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java
new file mode 100644
index 0000000..0d7a42d
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java
@@ -0,0 +1,276 @@
+package com.xiaopeng.map.utils;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.xiaopeng.map.dto.MapAreaDataDto;
+import com.xiaopeng.map.dto.OddAreaDto;
+import com.xiaopeng.map.dto.OddPointDto;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+/**
+ * 基于坐标生成 XNGP 区域相关数据文件的工具类,包括:
+ * - MapAreaData proto 源数据(map_area_data.proto)
+ * - RTree flatbuffer 索引数据(map_area_rtree.fbs)
+ */
+public class XngpAreaRTreeFbsUtil {
+
+ /**
+ * 简单经纬度点结构。
+ */
+ public static class LonLat {
+ private final double lon;
+ private final double lat;
+
+ public LonLat(double lon, double lat) {
+ this.lon = lon;
+ this.lat = lat;
+ }
+
+ public double getLon() {
+ return lon;
+ }
+
+ public double getLat() {
+ return lat;
+ }
+ }
+
+ /**
+ * 将形如 \"lon_lat,lon_lat,...\" 的字符串解析为坐标点列表。
+ * 例如:104.06249_30.58322,104.06244_30.58049, ...
+ */
+ public static List<LonLat> parseGcl(String gcl) {
+ if (gcl == null || gcl.isEmpty()) {
+ return java.util.Collections.emptyList();
+ }
+ String[] pairs = gcl.split(",");
+ java.util.List<LonLat> result = new java.util.ArrayList<>(pairs.length);
+ for (String pair : pairs) {
+ if (pair == null || pair.isEmpty()) {
+ continue;
+ }
+ String[] parts = pair.split("_");
+ if (parts.length != 2) {
+ continue;
+ }
+ double lon = Double.parseDouble(parts[0]);
+ double lat = Double.parseDouble(parts[1]);
+ result.add(new LonLat(lon, lat));
+ }
+ return result;
+ }
+
+ /**
+ * 基于单个 GCL 字符串构造 MapAreaData,并写入指定文件。
+ *
+ * @param gcl GCL 字符串:形如 "lon_lat,lon_lat,..."
+ * @param oddId 区域 ID,对应 OddArea.odd_id
+ * @param isBlacklist 是否黑名单区域,对应 OddArea.is_blacklist
+ * @param version MapAreaData 的版本号,对应 MapAreaData.version
+ * @param outFile 输出的 MapAreaData 二进制文件路径
+ * @return 写入是否成功
+ */
+ public static boolean buildMapAreaDataFromGcl(String gcl,
+ int oddId,
+ boolean isBlacklist,
+ int version,
+ String outFile) {
+ List<LonLat> points = parseGcl(gcl);
+ if (points == null || points.isEmpty()) {
+ return false;
+ }
+
+ // 构造 OddAreaDto
+ OddAreaDto oddArea = OddAreaDto.builder()
+ .oddId(oddId)
+ .isBlacklist(isBlacklist)
+ .build();
+ for (LonLat p : points) {
+ oddArea.getOddPoints().add(OddPointDto.builder()
+ .latitude(p.getLat())
+ .longitude(p.getLon())
+ .build());
+ }
+
+ // 构造 MapAreaDataDto
+ MapAreaDataDto data = MapAreaDataDto.builder()
+ .version(version)
+ .oddAreas(java.util.Collections.singletonList(oddArea))
+ .build();
+
+ try (FileOutputStream fos = new FileOutputStream(outFile)) {
+ MapAreaDataProtoWriter.writeTo(data, fos);
+ } catch (IOException e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 从一组多边形坐标点生成 RTree fbs 文件(单区域版本)。
+ * 等价于 C++ 流程:单个 OddArea → PbRTreeCreater → PbRTreeToFlatBuffer。
+ * Node.type 由 R-Tree 算法自动赋值(1=LEAF),不再由调用方指定。
+ *
+ * @param points 多边形顶点(经度、纬度),顺序与原始区域一致
+ * @param id 对应 proto 里的 odd_id
+ * @param isBlacklist 是否黑名单区域
+ * @param outFile 输出的 fbs 二进制文件路径
+ * @return 生成是否成功
+ */
+ public static boolean buildRTreeFromPoints(List<LonLat> points,
+ int id,
+ boolean isBlacklist,
+ String outFile) {
+ java.util.List<PbRTreeBuilder.RTreeNode> leafNodes = new java.util.ArrayList<>();
+ PbRTreeBuilder.RTreeNode leaf = buildLeafNode(points, id, isBlacklist);
+ if (leaf == null) {
+ return false;
+ }
+ leafNodes.add(leaf);
+ return writeRTreeFbs(leafNodes, outFile);
+ }
+
+ /**
+ * 从坐标点生成 RTree fbs 字节数组(内存版,供上传等场景使用)。
+ *
+ * @param points 多边形顶点
+ * @param areaId 区域 odd_id
+ * @param isBlacklist 是否黑名单
+ * @return fbs 二进制,失败返回 null
+ */
+ public static byte[] buildRTreeBytesFromPoints(List<LonLat> points, int areaId, boolean isBlacklist) {
+ java.util.List<PbRTreeBuilder.RTreeNode> leafNodes = new java.util.ArrayList<>();
+ PbRTreeBuilder.RTreeNode leaf = buildLeafNode(points, areaId, isBlacklist);
+ if (leaf == null) {
+ return null;
+ }
+ leafNodes.add(leaf);
+ return buildRTreeBytes(leafNodes);
+ }
+
+ /**
+ * 从多个区域的坐标点列表生成 RTree fbs 文件(多区域版本,与 C++ 完全对齐)。
+ *
+ * @param areaPoints 每个区域对应的坐标点列表
+ * @param ids 每个区域的 odd_id
+ * @param isBlacklists 每个区域是否黑名单
+ * @param outFile 输出的 fbs 二进制文件路径
+ * @return 生成是否成功
+ */
+ public static boolean buildRTreeFromMultiAreas(java.util.List<List<LonLat>> areaPoints,
+ java.util.List<Integer> ids,
+ java.util.List<Boolean> isBlacklists,
+ String outFile) {
+ if (areaPoints == null || areaPoints.isEmpty()) {
+ return false;
+ }
+ java.util.List<PbRTreeBuilder.RTreeNode> leafNodes = new java.util.ArrayList<>();
+ for (int i = 0; i < areaPoints.size(); i++) {
+ PbRTreeBuilder.RTreeNode leaf = buildLeafNode(
+ areaPoints.get(i),
+ ids.get(i),
+ isBlacklists.get(i)
+ );
+ if (leaf != null) {
+ leafNodes.add(leaf);
+ }
+ }
+ return writeRTreeFbs(leafNodes, outFile);
+ }
+
+ private static PbRTreeBuilder.RTreeNode buildLeafNode(List<LonLat> points, int id, boolean isBlacklist) {
+ if (points == null || points.isEmpty()) {
+ return null;
+ }
+ float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
+ float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
+
+ java.util.List<float[]> ptList = new java.util.ArrayList<>(points.size());
+ for (LonLat p : points) {
+ float x = (float) p.getLon(); // lon → x
+ float y = (float) p.getLat(); // lat → y
+ ptList.add(new float[]{x, y});
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+
+ PbRTreeBuilder.RTreeNode leaf = new PbRTreeBuilder.RTreeNode();
+ leaf.minX = minX;
+ leaf.minY = minY;
+ leaf.maxX = maxX;
+ leaf.maxY = maxY;
+ leaf.points = ptList;
+ leaf.id = id;
+ leaf.isBlacklist = isBlacklist;
+ return leaf;
+ }
+
+ private static byte[] buildRTreeBytes(java.util.List<PbRTreeBuilder.RTreeNode> leafNodes) {
+ java.util.List<PbRTreeBuilder.RTreeNode> rtreeNodes = PbRTreeBuilder.createRTree(leafNodes);
+
+ FlatBufferBuilder builder = new FlatBufferBuilder(1024);
+ int rtreeOffset = PbRTreeBuilder.writeFlatBuffer(builder, rtreeNodes);
+ builder.finish(rtreeOffset);
+
+ ByteBuffer buffer = builder.dataBuffer();
+ byte[] bytes = new byte[buffer.remaining()];
+ buffer.get(bytes);
+ return bytes;
+ }
+
+ private static boolean writeRTreeFbs(java.util.List<PbRTreeBuilder.RTreeNode> leafNodes, String outFile) {
+ byte[] bytes = buildRTreeBytes(leafNodes);
+ try (FileOutputStream fos = new FileOutputStream(outFile)) {
+ fos.write(bytes);
+ } catch (IOException e) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 简单命令行测试入口。
+ * 示例:构造一个矩形区域的四个顶点,生成 RTree fbs 文件到指定目录。
+ */
+ public static void main(String[] args) {
+ // 示例 GCL 字符串:lon_lat,lon_lat,...
+ String gcl;
+ if (args != null && args.length > 0) {
+ // 如果通过命令行传入,则优先使用参数中的 GCL 字符串
+ gcl = args[0];
+ } else {
+ // 默认示例,来源于用户提供的坐标
+ gcl = "104.06249657558965_30.583221674855963," +
+ "104.06244328689024_30.58049965528362," +
+ "104.06640444191817_30.580606702069797," +
+ "104.06496563619123_30.58420035984882," +
+ "104.06249657558965_30.583221674855963";
+ }
+
+ List<LonLat> pts = parseGcl(gcl);
+
+ int id = 1;
+ boolean isBlacklist = false;
+
+ String outFile;
+ if (args != null && args.length > 1) {
+ // 如果有第二个参数,则视为输出文件路径
+ outFile = args[1];
+ } else {
+ outFile = "xngp_area_data_test.bin";
+ }
+
+ boolean ok = buildRTreeFromPoints(pts, id, isBlacklist, outFile);
+ System.out.println("buildRTreeFromPoints result=" + ok + ", outFile=" + outFile);
+ }
+
+
+
+}
+
diff --git a/xp-robotaxi-map-boot/src/main/resources/application-local.yml b/xp-robotaxi-map-boot/src/main/resources/application-local.yml
index 642a89e..eba45eb 100644
--- a/xp-robotaxi-map-boot/src/main/resources/application-local.yml
+++ b/xp-robotaxi-map-boot/src/main/resources/application-local.yml
@@ -2,6 +2,11 @@
config:
activate:
on-profile: local
+ redis:
+ database: 99
+ host: r-bp1ip8die60uuo3ovypd.redis.rds.aliyuncs.com
+ password: syAktm2x9uDusoF_
+ port: 6379
autoconfigure:
exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
@@ -101,3 +106,21 @@
seata:
enabled: false
+
+# 阿里云 OSS 配置(ROBOTAXI ODD 图层上传)
+aliyun:
+ oss:
+ enabled: true
+ endpoint: http://oss-cn-wulanchabu.aliyuncs.com
+ access-key-id: ${ALIYUN_OSS_ACCESS_KEY_ID:}
+ access-key-secret: ${ALIYUN_OSS_ACCESS_KEY_SECRET:}
+ bucket-name: cngp-crowdsourcing
+ path-prefix: ROBOTAXI_ODD_layer
+ # 环境:test(测试)、staging(预发)、prod(生产),支持 ${OSS_ENV:test} 或直接写死
+ env: ${OSS_ENV:test}
+
+# ADC VIN 转 UUID 盐值配置
+adc:
+ vin:
+ salt1: ${VIN_SALT1:}
+ salt2: ${VIN_SALT2:}
diff --git a/xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java b/xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java
new file mode 100644
index 0000000..5dd9744
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java
@@ -0,0 +1,63 @@
+package com.xiaopeng.map.service;
+
+import com.xiaopeng.map.utils.XngpAreaRTreeFbsUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.actuate.metrics.AutoConfigureMetrics;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * AdcOssHandleService 集成测试,直接调用真实 OSS 服务。
+ * 运行前需配置环境变量:ALIYUN_OSS_ACCESS_KEY_ID、ALIYUN_OSS_ACCESS_KEY_SECRET、VIN_SALT1、VIN_SALT2
+ */
+@Slf4j
+@SpringBootTest(classes = com.xiaopeng.map.MapApplication.class)
+@ActiveProfiles("local")
+@AutoConfigureMetrics
+class AdcOssHandleServiceTest {
+
+ @Autowired(required = false)
+ private AdcOssHandleService adcOssHandleService;
+
+ @Autowired(required = false)
+ private RobotaxiOssService robotaxiOssService;
+
+ @Test
+ void uploadOddLayerFile() {
+ if (adcOssHandleService == null || robotaxiOssService == null) {
+ log.warn("OSS 未启用,测试跳过。请配置 ALIYUN_OSS_ACCESS_KEY_ID、ALIYUN_OSS_ACCESS_KEY_SECRET、VIN_SALT1、VIN_SALT2");
+ Assumptions.assumeTrue(false, "OSS 未启用,请配置环境变量");
+ }
+ // 构造测试坐标点:成都某区域多边形
+ String gcl = "104.06249_30.58322,104.06244_30.58049,104.06500_30.58100,104.06600_30.58400";
+ List<XngpAreaRTreeFbsUtil.LonLat> points = XngpAreaRTreeFbsUtil.parseGcl(gcl);
+ assertNotNull(points);
+ assertTrue(points.size() >= 3, "多边形至少需要 3 个点");
+
+ int areaId = 1;
+ String vin = "21312323333333312"; // 替换为实际测试 VIN
+ String areaCode = "510100";
+
+ String objectKey = adcOssHandleService.uploadOddLayerFile(points, areaId, vin, areaCode);
+ log.info("uploadOddLayerFile objectKey={}", objectKey);
+
+ assertNotNull(objectKey);
+ assertTrue(objectKey.contains("ROBOTAXI_ODD_layer") && objectKey.contains(areaCode),
+ "返回 objectKey 应包含路径前缀和区域编码");
+
+ // 获取下载临时链接(Presigned URL)
+ String downloadUrl = robotaxiOssService.generatePresignedDownloadUrl(objectKey, 3600);
+ log.info("generatePresignedDownloadUrl downloadUrl={}", downloadUrl);
+ assertNotNull(downloadUrl);
+ assertTrue(downloadUrl.contains("Expires") || downloadUrl.contains("Signature"),
+ "下载临时链接应包含签名参数(Expires 或 Signature)");
+ }
+}
diff --git a/xp-robotaxi-map-boot/src/test/resources/logback-test.xml b/xp-robotaxi-map-boot/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..96c8c64
--- /dev/null
+++ b/xp-robotaxi-map-boot/src/test/resources/logback-test.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+ <root level="INFO">
+ <appender-ref ref="STDOUT"/>
+ </root>
+ <logger name="com.xiaopeng.map" level="DEBUG"/>
+</configuration>
--
Gitblit v1.7.1