puzhibing
5 天以前 8def7d50fdac0d2d464edce1d94f5bb29c519302
修改高德账号切换bug
2个文件已添加
3个文件已修改
765 ■■■■■ 已修改文件
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/dao/GDKeyMapper.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDFalconUtil.java 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDKeyManager.java 349 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDMapElectricFenceUtil.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDMapGeocodingUtil.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/dao/GDKeyMapper.java
New file
@@ -0,0 +1,31 @@
package com.stylefeng.guns.modular.system.dao;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
 * 高德地图key管理 Mapper(注解方式,无需XML)
 */
public interface GDKeyMapper {
    /**
     * 查询可用key(启用、未删除、未用完)
     */
    @Select("SELECT gdKey FROM t_gaode_account WHERE status = 1 AND delFlag = 0 AND useStatus <> 3")
    List<String> selectAvailableKeys();
    /**
     * 查询已用完的key(用于每日凌晨重置校验)
     */
    @Select("SELECT gdKey FROM t_gaode_account WHERE status = 1 AND delFlag = 0 AND useStatus = 3")
    List<String> selectExhaustedKeys();
    /**
     * 更新key的使用状态
     */
    @Update("UPDATE t_gaode_account SET useStatus = #{useStatus} WHERE gdKey = #{gdKey} AND delFlag = 0")
    int updateUseStatus(@Param("gdKey") String gdKey, @Param("useStatus") Integer useStatus);
}
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDFalconUtil.java
@@ -14,21 +14,26 @@
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
 * 高德猎鹰服务
 * key次数用完时自动切换可用key(切换后自动重建serverId)
 */
@Component
public class GDFalconUtil implements ApplicationRunner {
    private String key = "c473445a931ee8c42eef8b63fc3602c5";
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private GDKeyManager gdKeyManager;
    private static String serverId = null;
    /**
     * 当前serverId对应的key(key切换后需重建serverId)
     */
    private static volatile String currentKey = null;
    public String getServerId() {
@@ -46,20 +51,137 @@
    }
    /**
     * 初始化创建服务
     */
    public void init(){
        if(serverId == null){
            JSONArray jsonArray = this.selectServer();
            if(null == jsonArray){
                serverId = this.createServer("server");
            }else{
                serverId = jsonArray.getJSONObject(0).getString("sid");
            if (gdKeyManager == null || restTemplate == null) {
                // 非容器实例(如 GunsApplication.main 中 new 出来的对象),由容器内bean完成初始化
                return;
            }
            ensureServerId();
            }
        }
    /**
     * 参数构造器:key与sid切换后需按最新值重新构造
     */
    private interface ParamsBuilder {
        MultiValueMap<String, Object> build(String key, String sid);
    }
    /**
     * 确保serverId与当前使用的key对应:
     * key切换后自动查询/重建serverId;key用完自动切换可用key重试
     */
    private synchronized String ensureServerId(){
        int maxAttempts = 5;
        String lastKey = gdKeyManager.getKey();
        for (int i = 0; i < maxAttempts; i++) {
            String gdKey = gdKeyManager.getKey();
            lastKey = gdKey;
            if (serverId != null && gdKey.equals(currentKey)) {
                return gdKey;
            }
            // 1. 查询该key下的服务列表
            JSONArray jsonArray = null;
            try {
                String url = "https://tsapi.amap.com/v1/track/service/list?key=" + gdKey;
                String forObject = restTemplate.getForObject(url, String.class);
                JSONObject jsonObject = JSON.parseObject(forObject);
                if (GDKeyManager.isFalconExhausted(jsonObject)) {
                    gdKeyManager.reportExhausted(gdKey);
                    serverId = null;
                    continue;
                }
                if (jsonObject.getIntValue("errcode") == 10000) {
                    JSONObject data = jsonObject.getJSONObject("data");
                    if (null != data) {
                        jsonArray = data.getJSONArray("results");
                    }
                }
            } catch (Exception e) {
                loggerWarn("查询猎鹰服务列表异常", e);
            }
            if (jsonArray != null && jsonArray.size() > 0) {
                serverId = jsonArray.getJSONObject(0).getString("sid");
                currentKey = gdKey;
                return gdKey;
            }
            // 2. 无服务:创建新服务
            String sid = createServerInternal("server", gdKey);
            if (sid != null) {
                serverId = sid;
                currentKey = gdKey;
                return gdKey;
            }
            // 3. 创建失败(可能key用完):切换key重试
            gdKeyManager.reportExhausted(gdKey);
            serverId = null;
        }
        return lastKey;
    }
    private void loggerWarn(String msg, Exception e) {
        System.err.println("[GDFalconUtil] " + msg + ":" + e.getMessage());
    }
    /**
     * 执行猎鹰POST表单请求:key次数用完时自动切换key并重建serverId重试
     */
    private String doFalconPost(String url, ParamsBuilder builder) {
        String lastResp = null;
        int maxAttempts = 5;
        for (int i = 0; i < maxAttempts; i++) {
            String gdKey = ensureServerId();
            String sid = serverId;
            MultiValueMap<String, Object> params = builder.build(gdKey, sid);
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
            HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(params, headers);
            String resp = restTemplate.postForObject(url, requestEntity, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isFalconExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(gdKey);
                serverId = null;
                continue;
            }
            return resp;
        }
        return lastResp;
    }
    /**
     * 执行猎鹰GET请求:key次数用完时自动切换可用key重试
     */
    private String doFalconGet(String baseUrl) {
        String lastResp = null;
        int maxAttempts = 5;
        for (int i = 0; i < maxAttempts; i++) {
            String gdKey = gdKeyManager.getKey();
            String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + "key=" + gdKey;
            String resp = restTemplate.getForObject(url, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isFalconExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(gdKey);
                continue;
            }
            return resp;
        }
        return lastResp;
    }
@@ -68,6 +190,13 @@
     * @return
     */
    public String createServer(String name){
        return createServerInternal(name, gdKeyManager.getKey());
    }
    /**
     * 创建新的服务(内部直连,供ensureServerId使用)
     */
    private String createServerInternal(String name, String key){
        String url = "https://tsapi.amap.com/v1/track/service/add";
        //调用推送
        HttpHeaders headers = new HttpHeaders();
@@ -91,24 +220,6 @@
    /**
     * 查询服务
     * @return
     */
    private JSONArray selectServer(){
        String url = "https://tsapi.amap.com/v1/track/service/list?key=" + key;
        String forObject = restTemplate.getForObject(url, String.class);
        JSONObject jsonObject = JSON.parseObject(forObject);
        if(jsonObject.getIntValue("errcode") == 10000){
            JSONObject data = jsonObject.getJSONObject("data");
            if(null != data){
                return data.getJSONArray("results");
            }
        }
        return null;
    }
    /**
     * 创建终端数据
     * @param name
     * @return
@@ -118,18 +229,13 @@
        if(tid != null){
            return tid;
        }
        String url = "https://tsapi.amap.com/v1/track/terminal/add";
        //调用推送
        HttpHeaders headers = new HttpHeaders();
        // 以表单的方式提交
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        //将请求头部和参数合成一个请求
        String s = doFalconPost("https://tsapi.amap.com/v1/track/terminal/add", (key, sid) -> {
        MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();
        params.add("key", key);
        params.add("sid", serverId);
            params.add("sid", sid);
        params.add("name", name);
        HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(params, headers);
        String s = restTemplate.postForObject(url, requestEntity , String.class);
            return params;
        });
        JSONObject jsonObject = JSON.parseObject(s);
        if(jsonObject.getIntValue("errcode") == 10000){
            JSONObject data = jsonObject.getJSONObject("data");
@@ -146,8 +252,7 @@
     * @return
     */
    public String selectTerminal(String name){
        String url = "https://tsapi.amap.com/v1/track/terminal/list?key=" + key + "&name=" + name;
        String forObject = restTemplate.getForObject(url, String.class);
        String forObject = doFalconGet("https://tsapi.amap.com/v1/track/terminal/list?name=" + name);
        JSONObject jsonObject = JSON.parseObject(forObject);
        if(jsonObject.getIntValue("errcode") == 10000){
            JSONObject data = jsonObject.getJSONObject("data");
@@ -172,18 +277,13 @@
     * @return
     */
    public String createTrack(String tid){
        String url = "https://tsapi.amap.com/v1/track/trace/add";
        //调用推送
        HttpHeaders headers = new HttpHeaders();
        // 以表单的方式提交
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        //将请求头部和参数合成一个请求
        String s = doFalconPost("https://tsapi.amap.com/v1/track/trace/add", (key, sid) -> {
        MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();
        params.add("key", key);
        params.add("sid", serverId);
            params.add("sid", sid);
        params.add("tid", tid);
        HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(params, headers);
        String s = restTemplate.postForObject(url, requestEntity , String.class);
            return params;
        });
        JSONObject jsonObject = JSON.parseObject(s);
        if(jsonObject.getIntValue("errcode") == 10000){
            return jsonObject.getString("data");
@@ -199,28 +299,18 @@
     * @param trid      轨迹id
     */
    public void deleteTrack(String tid, String trid){
        String url = "https://tsapi.amap.com/v1/track/trace/delete";
        //调用推送
        HttpHeaders headers = new HttpHeaders();
        // 以表单的方式提交
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        //将请求头部和参数合成一个请求
        String s = doFalconPost("https://tsapi.amap.com/v1/track/trace/delete", (key, sid) -> {
        MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();
        params.add("key", key);
        params.add("sid", serverId);
            params.add("sid", sid);
        params.add("tid", tid);
        params.add("trid", trid);
        HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(params, headers);
        String s = restTemplate.postForObject(url, requestEntity , String.class);
            return params;
        });
        JSONObject jsonObject = JSON.parseObject(s);
        if(jsonObject.getIntValue("errcode") != 10000){
            System.err.println(jsonObject.getString("errmsg"));
        }
    }
//
//    public Map<String, Object> queryTrsearch(){
//
//    }
}
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDKeyManager.java
New file
@@ -0,0 +1,349 @@
package com.stylefeng.guns.modular.system.util;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.stylefeng.guns.modular.system.dao.GDKeyMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 高德地图key管理器
 * <p>
 * 功能:
 * 1. key池管理:从 t_gaode_account 表加载可用key(启用、未删除、未用完),轮询获取;
 *    表为空或全部用尽时降级使用兜底key(原硬编码key)。
 * 2. 自动切换:调用方检测到key"次数用完"后调用 reportExhausted 上报,
 *    管理器将key移出内存池并置库表 useStatus=3(已用完)。
 * 3. 每日凌晨重置校验:每天 00:00 将 useStatus=3 的key重置为未使用并放回池,
 *    随后调用高德最轻量接口校验配额是否真正恢复;未恢复的key重新标记已用完,
 *    进入延迟重试队列,每30分钟校验一次直到恢复额度。
 * 4. 兜底key:当天用完仅内存禁用,次日凌晨自动恢复;不写库(表内无对应记录)。
 */
@Component
public class GDKeyManager {
    private static final Logger logger = LoggerFactory.getLogger(GDKeyManager.class);
    /**
     * 兜底key(原硬编码key):司机端
     */
    public static final String FALLBACK_KEY = "39b31fa22955efa8fce9eed487241db9";
    /**
     * 配额校验接口:最轻量的通用Web服务接口,每次仅消耗1次配额
     */
    private static final String VERIFY_URL = "https://restapi.amap.com/v3/geocode/geo?output=JSON&address=%E5%8C%97%E4%BA%AC%E5%B8%82&key=";
    @Autowired
    private GDKeyMapper gdKeyMapper;
    @Autowired
    private RestTemplate restTemplate;
    /**
     * 内存可用key池(refreshPool整体替换,保证getKey不会看到clear/addAll中间态)
     */
    private volatile List<String> availableKeys = new CopyOnWriteArrayList<>();
    /**
     * 轮询下标
     */
    private final AtomicInteger index = new AtomicInteger(0);
    /**
     * 延迟校验队列:key -> 已重试次数(每日凌晨校验未恢复的key)
     */
    private final ConcurrentHashMap<String, Integer> pendingVerifyKeys = new ConcurrentHashMap<>();
    /**
     * 兜底key当日是否已用完
     */
    private volatile boolean fallbackExhausted = false;
    /**
     * 已标记"使用中"(useStatus=2)的key集合,避免热路径每次取用都写库
     */
    private final Set<String> inUseKeys = ConcurrentHashMap.newKeySet();
    @PostConstruct
    public void init() {
        refreshPool();
    }
    /**
     * 刷新可用key池
     */
    public synchronized void refreshPool() {
        try {
            List<String> keys = gdKeyMapper.selectAvailableKeys();
            List<String> newPool = new CopyOnWriteArrayList<>();
            if (keys != null) {
                newPool.addAll(keys);
            }
            // 整体替换,避免clear/addAll中间态导致getKey短暂看到空池误降级兜底key
            availableKeys = newPool;
            logger.info("[GDKeyManager] 高德key池刷新完成,可用key数:{}", newPool.size());
        } catch (Exception e) {
            // 表不存在(用户端/司机端未建表时)等异常:降级使用兜底key,不影响系统运行
            logger.warn("[GDKeyManager] 加载高德key失败(t_gaode_account表可能不存在),降级使用兜底key", e);
            availableKeys = new CopyOnWriteArrayList<>();
        }
    }
    /**
     * 获取一个可用key:池非空轮询池内key;池空使用兜底key(当日未用完时)
     */
    public String getKey() {
        List<String> pool = availableKeys;
        if (!pool.isEmpty()) {
            String key = pickFromPool(pool);
            markInUse(key);
            return key;
        }
        if (!fallbackExhausted) {
            return FALLBACK_KEY;
        }
        // 兜底key当日也已用完:尝试刷新池(可能有新录入的key)
        refreshPool();
        pool = availableKeys;
        if (!pool.isEmpty()) {
            String key = pickFromPool(pool);
            markInUse(key);
            return key;
        }
        return FALLBACK_KEY;
    }
    /**
     * 从内存池轮询取一个key(使用调用方传入的池快照,避免与定时刷新交叉导致越界)
     */
    private String pickFromPool(List<String> pool) {
        int i = Math.abs(index.getAndIncrement() % pool.size());
        return pool.get(i);
    }
    /**
     * 将表内key标记为"使用中"(useStatus=2):
     * 仅key首次被取用时写库一次;兜底key不写库;写库失败不影响取key流程。
     */
    private void markInUse(String key) {
        if (key == null || FALLBACK_KEY.equals(key) || inUseKeys.contains(key)) {
            return;
        }
        try {
            gdKeyMapper.updateUseStatus(key, 2);
            inUseKeys.add(key);
            logger.info("[GDKeyManager] 高德key[{}]开始使用,已标记为使用中", key);
        } catch (Exception e) {
            // 表不存在等异常:本次不标记,下次取用再尝试,不影响业务
            logger.warn("[GDKeyManager] 标记高德key[{}]使用中失败(表可能不存在),不影响使用", key);
        }
    }
    /**
     * 定时刷新key池:每5分钟一次,让管理后台新录入的key及时生效
     */
    @Scheduled(fixedDelay = 5 * 60 * 1000, initialDelay = 60 * 1000)
    public void scheduledRefreshPool() {
        refreshPool();
    }
    /**
     * 上报某个key次数用完:
     * 表内key -> 移出内存池 + 置库表 useStatus=3;
     * 兜底key -> 仅内存当日禁用(不写库),次日凌晨自动恢复。
     */
    public void reportExhausted(String usedKey) {
        if (usedKey == null || usedKey.isEmpty()) {
            return;
        }
        availableKeys.remove(usedKey);
        inUseKeys.remove(usedKey);
        if (FALLBACK_KEY.equals(usedKey)) {
            fallbackExhausted = true;
            logger.warn("[GDKeyManager] 兜底key次数已用完,今日禁用,次日凌晨自动恢复");
            return;
        }
        try {
            gdKeyMapper.updateUseStatus(usedKey, 3);
            logger.warn("[GDKeyManager] 高德key[{}]次数用完,已标记为已用完并移出key池", usedKey);
        } catch (Exception e) {
            logger.warn("[GDKeyManager] 更新高德key[{}]使用状态失败(表可能不存在),仅内存剔除", usedKey);
        }
    }
    /**
     * 判断Web服务API响应是否为"次数用完/不可用"(依据infocode)
     */
    public static boolean isWebKeyExhausted(JSONObject resp) {
        if (resp == null) {
            return false;
        }
        String infocode = resp.getString("infocode");
        if (infocode == null || infocode.isEmpty()) {
            return false;
        }
        switch (infocode) {
            case "10001": // key不正确或过期
            case "10003": // 超过每日配额
            case "10004": // 访问过于频繁
            case "10005": // 超出每秒访问量限制
            case "10010": // 权限不足
            case "10012": // 服务未开放/权限不足
            case "10019": // 使用配额已用尽
            case "10020": // 并发超限
            case "10044": // 签名错误/未开通
                return true;
            default:
                return false;
        }
    }
    /**
     * 判断猎鹰API响应是否为"次数用完"(依据errcode/errmsg)
     */
    public static boolean isFalconExhausted(JSONObject resp) {
        if (resp == null) {
            return false;
        }
        int errcode = resp.getIntValue("errcode");
        if (errcode == 10000) {
            return false;
        }
        if (errcode == 20001) {
            return true; // 权限不足
        }
        String errmsg = resp.getString("errmsg");
        if (errmsg == null) {
            return false;
        }
        return errmsg.contains("配额") || errmsg.contains("上限") || errmsg.contains("次数")
                || errmsg.contains("用完") || errmsg.contains("超限")
                || errmsg.contains("quota") || errmsg.contains("limit");
    }
    // ==================== 每日凌晨重置 + API校验恢复额度 ====================
    /**
     * 每日凌晨 00:00 自动执行:
     * 1. 将 useStatus=3(已用完)的key重置为未使用并放回内存池;
     * 2. 调用高德API校验配额是否真正恢复;
     * 3. 未恢复的key重新标记已用完,进入延迟重试队列,直到恢复额度。
     */
    @Scheduled(cron = "0 0 0 * * ?")
    public void dailyResetAndVerify() {
        logger.info("[GDKeyManager] 开始每日高德key配额重置校验");
        // 兜底key当日禁用标记次日自动恢复
        fallbackExhausted = false;
        List<String> exhaustedKeys;
        try {
            exhaustedKeys = gdKeyMapper.selectExhaustedKeys();
        } catch (Exception e) {
            logger.warn("[GDKeyManager] 查询已用完key失败(表可能不存在),跳过重置校验", e);
            return;
        }
        if (exhaustedKeys == null || exhaustedKeys.isEmpty()) {
            logger.info("[GDKeyManager] 无已用完的key,跳过重置校验");
            return;
        }
        for (String gdKey : exhaustedKeys) {
            // 1. 先重置为未使用
            try {
                gdKeyMapper.updateUseStatus(gdKey, 1);
            } catch (Exception e) {
                logger.warn("[GDKeyManager] 重置key[{}]使用状态失败", gdKey, e);
            }
            // 2. 调用API校验是否真正恢复额度
            boolean recovered = verifyQuota(gdKey);
            if (recovered) {
                if (!availableKeys.contains(gdKey)) {
                    availableKeys.add(gdKey);
                }
                pendingVerifyKeys.remove(gdKey);
                logger.info("[GDKeyManager] 高德key[{}]配额已恢复,重新加入key池", gdKey);
            } else {
                // 未恢复:重新标记已用完,进入延迟重试队列
                try {
                    gdKeyMapper.updateUseStatus(gdKey, 3);
                } catch (Exception e) {
                    logger.warn("[GDKeyManager] 回写key[{}]已用完状态失败", gdKey, e);
                }
                availableKeys.remove(gdKey);
                pendingVerifyKeys.put(gdKey, 0);
                logger.warn("[GDKeyManager] 高德key[{}]配额尚未恢复,进入延迟重试队列", gdKey);
            }
        }
    }
    /**
     * 延迟重试任务:每30分钟校验一次待恢复的key,直到恢复额度为止
     */
    @Scheduled(fixedDelay = 30 * 60 * 1000, initialDelay = 30 * 60 * 1000)
    public void retryPendingKeys() {
        if (pendingVerifyKeys.isEmpty()) {
            return;
        }
        for (String gdKey : new ArrayList<>(pendingVerifyKeys.keySet())) {
            boolean recovered = verifyQuota(gdKey);
            if (recovered) {
                try {
                    gdKeyMapper.updateUseStatus(gdKey, 1);
                } catch (Exception e) {
                    logger.warn("[GDKeyManager] 恢复key[{}]使用状态失败", gdKey, e);
                }
                if (!availableKeys.contains(gdKey)) {
                    availableKeys.add(gdKey);
                }
                pendingVerifyKeys.remove(gdKey);
                logger.info("[GDKeyManager] 高德key[{}]延迟校验通过,配额已恢复,重新加入key池", gdKey);
            } else {
                int count = pendingVerifyKeys.merge(gdKey, 1, Integer::sum);
                logger.warn("[GDKeyManager] 高德key[{}]配额仍未恢复(已校验{}次),继续等待", gdKey, count);
            }
        }
    }
    /**
     * 调用高德最轻量接口校验配额是否恢复
     *
     * @return true=已恢复(或无法判断,保守放行);false=明确未恢复(配额类错误)
     */
    private boolean verifyQuota(String gdKey) {
        try {
            String resp = restTemplate.getForObject(VERIFY_URL + gdKey, String.class);
            JSONObject json = JSON.parseObject(resp);
            String status = json.getString("status");
            String infocode = json.getString("infocode");
            if ("1".equals(status) && "10000".equals(infocode)) {
                return true;
            }
            // 权限类错误:该key未开通Web服务校验权限(如猎鹰专用key),无法判断配额,按已恢复处理
            if ("10044".equals(infocode) || "10012".equals(infocode)
                    || "10002".equals(infocode) || "10010".equals(infocode)) {
                logger.info("[GDKeyManager] 高德key[{}]无Web服务校验权限(infocode={}),按已恢复处理", gdKey, infocode);
                return true;
            }
            // 配额类错误:明确未恢复
            if ("10001".equals(infocode) || "10003".equals(infocode) || "10019".equals(infocode)) {
                return false;
            }
            // 其他错误(限流/网络等):保守放行,真实调用失败时会被自动切换机制再次标记
            return true;
        } catch (Exception e) {
            logger.warn("[GDKeyManager] 校验key[{}]配额异常,按已恢复处理", gdKey, e);
            return true;
        }
    }
}
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDMapElectricFenceUtil.java
@@ -33,9 +33,98 @@
    @Autowired
    private IGDInterfaceService gdInterfaceService;
    private String key = "c473445a931ee8c42eef8b63fc3602c5";
    @Autowired
    private GDKeyManager gdKeyManager;
    private JSONArray jsonArray = new JSONArray();
    /**
     * 执行GET请求:key次数用完时自动切换可用key重试
     */
    private String doGetWithRetry(String baseUrl) {
        String lastResp = null;
        int maxAttempts = 5;
        for (int i = 0; i < maxAttempts; i++) {
            String key = gdKeyManager.getKey();
            String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + "key=" + key;
            String resp = restTemplate.getForObject(url, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isWebKeyExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(key);
                continue;
            }
            return resp;
        }
        return lastResp;
    }
    /**
     * 执行POST请求(JSON体):key次数用完时自动切换可用key重试
     */
    private String doPostJsonWithRetry(String baseUrl, String jsonBody) {
        String lastResp = null;
        int maxAttempts = 5;
        HttpHeaders headers = new HttpHeaders();
        MediaType type = MediaType.parseMediaType("application/json; charset=UTF-8");
        headers.setContentType(type);
        headers.add("Accept", MediaType.APPLICATION_JSON.toString());
        for (int i = 0; i < maxAttempts; i++) {
            String key = gdKeyManager.getKey();
            String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + "key=" + key;
            HttpEntity<String> formEntity = new HttpEntity<>(jsonBody, headers);
            String resp = restTemplate.postForObject(url, formEntity, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isWebKeyExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(key);
                continue;
            }
            return resp;
        }
        return lastResp;
    }
    /**
     * 执行PATCH请求(JSON体):key次数用完时自动切换可用key重试
     */
    private String doPatchJsonWithRetry(String baseUrl, String jsonBody) {
        String lastResp = null;
        int maxAttempts = 5;
        HttpHeaders headers = new HttpHeaders();
        MediaType type = MediaType.parseMediaType("application/json; charset=UTF-8");
        headers.setContentType(type);
        headers.add("Accept", MediaType.APPLICATION_JSON.toString());
        for (int i = 0; i < maxAttempts; i++) {
            String key = gdKeyManager.getKey();
            String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + "key=" + key;
            HttpEntity<String> formEntity = new HttpEntity<>(jsonBody, headers);
            String resp = restTemplate.patchForObject(url, formEntity, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isWebKeyExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(key);
                continue;
            }
            return resp;
        }
        return lastResp;
    }
    @Bean
@@ -81,14 +170,7 @@
            json.put("alert_condition", "enter;leave");
            json.put("desc", id);
            HttpHeaders headers = new HttpHeaders();
            MediaType type = MediaType.parseMediaType("application/json; charset=UTF-8");
            headers.setContentType(type);
            headers.add("Accept", MediaType.APPLICATION_JSON.toString());
            HttpEntity<String> formEntity = new HttpEntity<>(json.toString(), headers);
            String url = "https://restapi.amap.com/v4/geofence/meta?key=" + key;
            String js = restTemplate.postForObject(url, formEntity, String.class);
            String js = doPostJsonWithRetry("https://restapi.amap.com/v4/geofence/meta", json.toString());
            JSONObject jsonObject = JSON.parseObject(js);
            JSONObject data = jsonObject.getJSONObject("data");
            String status = data.getString("status");
@@ -148,8 +230,8 @@
     * 查询以创建的所有电子围栏
     */
    public String findElectricFenc(String center, String radius, String points){
        String url = "https://restapi.amap.com/v4/geofence/meta?key=" + key;
        String forObject = restTemplate.getForObject(url, String.class);
        String url = "https://restapi.amap.com/v4/geofence/meta";
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        JSONObject data = jsonObject.getJSONObject("data");
@@ -185,8 +267,8 @@
        if(page == 1){
            jsonArray = new JSONArray();
        }
        String url = "https://restapi.amap.com/v4/geofence/meta?key=" + key + "&page_no=" + page + "&page_size=" + 30;
        String forObject = restTemplate.getForObject(url, String.class);
        String url = "https://restapi.amap.com/v4/geofence/meta?page_no=" + page + "&page_size=" + 30;
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        JSONObject data = jsonObject.getJSONObject("data");
        JSONArray rs_list = data.getJSONArray("rs_list");
@@ -275,14 +357,8 @@
        json.put("alert_condition", "enter;leave");
        json.put("desc", id + "围栏更新描述");
        HttpHeaders headers = new HttpHeaders();
        MediaType type = MediaType.parseMediaType("application/json; charset=UTF-8");
        headers.setContentType(type);
        headers.add("Accept", MediaType.APPLICATION_JSON.toString());
        HttpEntity<String> formEntity = new HttpEntity<>(json.toString(), headers);
        String url = "https://restapi.amap.com/v4/geofence/meta?key=" + key + "&gid=" + gid;
        String s = restTemplate.patchForObject(url, formEntity, String.class);
        String url = "https://restapi.amap.com/v4/geofence/meta?gid=" + gid;
        String s = doPatchJsonWithRetry(url, json.toString());
        System.out.println(s);
//        gdInterfaceService.saveData("https://restapi.amap.com/v4/geofence/meta", "更新电子围栏");
@@ -304,8 +380,8 @@
            diu = "B78F99DD-2496-4A43-843B-172CDA2D9798";
        }
        locations = locations + "," + (new Date().getTime() / 1000);
        String url = "https://restapi.amap.com/v4/geofence/status?key=" + key + "&diu=" + diu + "&locations=" + locations;
        String forObject = restTemplate.getForObject(url, String.class);
        String url = "https://restapi.amap.com/v4/geofence/status?diu=" + diu + "&locations=" + locations;
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        List<String> ids = new ArrayList<>();
        JSONObject data = jsonObject.getJSONObject("data");
@@ -334,7 +410,7 @@
     * @return
     */
    public boolean deleteElectricFenc(String gid){
        restTemplate.delete("https://restapi.amap.com/v4/geofence/meta?key={1}&gid={2}", key, gid);
        restTemplate.delete("https://restapi.amap.com/v4/geofence/meta?key={1}&gid={2}", gdKeyManager.getKey(), gid);
        System.out.println("删除:" );
//        gdInterfaceService.saveData("https://restapi.amap.com/v4/geofence/meta", "删除电子围栏");
@@ -351,9 +427,9 @@
     * @return
     */
    public Map<String, String> getDistance(String origins, String destination, Integer type){
        String url = "https://restapi.amap.com/v3/distance?key=" + key + "&origins=" + origins + "&destination=" + destination +
        String url = "https://restapi.amap.com/v3/distance?origins=" + origins + "&destination=" + destination +
                "&type=" + type;
        String forObject = restTemplate.getForObject(url, String.class);
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        String status = jsonObject.getString("status");
@@ -381,9 +457,9 @@
     * @return
     */
    public Map<String, String> getDriving(String origins, String destination, String waypoints){
        String url = "https://restapi.amap.com/v3/direction/driving?key=" + key + "&origin=" + origins + "&destination=" + destination +
        String url = "https://restapi.amap.com/v3/direction/driving?origin=" + origins + "&destination=" + destination +
                "&waypoints=" + waypoints + "&extensions=base&strategy=0";
        String forObject = restTemplate.getForObject(url, String.class);
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        String status = jsonObject.getString("status");
DriverFBTravel/guns-admin/src/main/java/com/stylefeng/guns/modular/system/util/GDMapGeocodingUtil.java
@@ -20,13 +20,40 @@
@Component
public class GDMapGeocodingUtil {
    private String key = "c473445a931ee8c42eef8b63fc3602c5";
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private IGDInterfaceService gdInterfaceService;
    @Autowired
    private GDKeyManager gdKeyManager;
    /**
     * 执行GET请求:key次数用完时自动切换可用key重试
     */
    private String doGetWithRetry(String baseUrl) {
        String lastResp = null;
        int maxAttempts = 5;
        for (int i = 0; i < maxAttempts; i++) {
            String key = gdKeyManager.getKey();
            String url = baseUrl + (baseUrl.contains("?") ? "&" : "?") + "key=" + key;
            String resp = restTemplate.getForObject(url, String.class);
            lastResp = resp;
            JSONObject jsonObject;
            try {
                jsonObject = JSON.parseObject(resp);
            } catch (Exception e) {
                return resp;
            }
            if (GDKeyManager.isWebKeyExhausted(jsonObject)) {
                gdKeyManager.reportExhausted(key);
                continue;
            }
            return resp;
        }
        return lastResp;
    }
    /**
@@ -55,9 +82,9 @@
            return map;
        }
        String url = "https://restapi.amap.com/v3/geocode/geo?key=" + key + "&output=JSON";
        String url = "https://restapi.amap.com/v3/geocode/geo?output=JSON";
        url += "&address=" + province + (ToolUtil.isNotEmpty(city) ? city : "") + (ToolUtil.isNotEmpty(county) ? county : "") + (ToolUtil.isNotEmpty(address) ? address : "");
        String forObject = restTemplate.getForObject(url, String.class);
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        String status = jsonObject.getString("status");
        List<String> list = new ArrayList<>();
@@ -85,8 +112,8 @@
     * @throws Exception
     */
    public Map<String, String> geocode(String lon, String lan) throws Exception{
        String url = "https://restapi.amap.com/v3/geocode/regeo?key=" + key + "&location=" + lon + "," + lan;
        String forObject = restTemplate.getForObject(url, String.class);
        String url = "https://restapi.amap.com/v3/geocode/regeo?location=" + lon + "," + lan;
        String forObject = doGetWithRetry(url);
        JSONObject jsonObject = JSON.parseObject(forObject);
        Map<String, String> map = new HashMap<>();