puzhibing
5 天以前 1f9454af3356f628c25c3e2e492869163731da56
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
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;
        }
    }
}