shench3
2026-03-03 c90dd2da4cfa60c06099c4e66fba6b203bf90b59
feat: vin转uuid、地图文件上传逻辑补充
19个文件已添加
2个文件已修改
1716 ■■■■■ 已修改文件
xp-robotaxi-map-boot/pom.xml 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java 237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java 276 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/main/resources/application-local.yml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
xp-robotaxi-map-boot/src/test/resources/logback-test.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/OssProperties.java
New file
@@ -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";
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/config/VinSaltProperties.java
New file
@@ -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;
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/MapAreaDataDto.java
New file
@@ -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;
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Node.java
New file
@@ -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();
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddAreaDto.java
New file
@@ -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;
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/OddPointDto.java
New file
@@ -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;
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Point2D.java
New file
@@ -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();
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/RTree.java
New file
@@ -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();
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/dto/Rect.java
New file
@@ -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();
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/AdcOssHandleService.java
New file
@@ -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);
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/RobotaxiOssService.java
New file
@@ -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);
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/AdcOssHandleServiceImpl.java
New file
@@ -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");
        }
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/service/serviceimpl/RobotaxiOssServiceImpl.java
New file
@@ -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);
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/MapAreaDataProtoWriter.java
New file
@@ -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());
        }
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/PbRTreeBuilder.java
New file
@@ -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);
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/VehicleCodec.java
New file
@@ -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';
    }
}
xp-robotaxi-map-boot/src/main/java/com/xiaopeng/map/utils/XngpAreaRTreeFbsUtil.java
New file
@@ -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);
    }
}
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:}
xp-robotaxi-map-boot/src/test/java/com/xiaopeng/map/service/AdcOssHandleServiceTest.java
New file
@@ -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)");
    }
}
xp-robotaxi-map-boot/src/test/resources/logback-test.xml
New file
@@ -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>