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;
|
}
|
}
|
}
|