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