AdminController.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. package com.adx.tencent.httpapi;
  2. import com.adx.tencent.baidu.ConversionClient;
  3. import com.adx.tencent.baidu.model.ConversionQuery;
  4. import com.adx.tencent.conversionsync.ConversionBackfillJobService;
  5. import com.adx.tencent.conversionsync.ConversionSyncRunner;
  6. import com.adx.tencent.conversionsync.ConversionSyncService;
  7. import com.adx.tencent.conversionsync.ManualTencentCallbackService;
  8. import com.adx.tencent.conversionsync.RetryService;
  9. import org.springframework.data.redis.core.Cursor;
  10. import org.springframework.data.redis.core.ScanOptions;
  11. import org.springframework.data.redis.core.StringRedisTemplate;
  12. import org.springframework.http.HttpStatus;
  13. import org.springframework.lang.Nullable;
  14. import org.springframework.web.bind.annotation.*;
  15. import org.springframework.web.server.ResponseStatusException;
  16. import java.time.Duration;
  17. import java.util.ArrayList;
  18. import java.util.List;
  19. import java.util.Map;
  20. import java.util.UUID;
  21. import java.util.concurrent.CompletableFuture;
  22. import java.util.concurrent.TimeUnit;
  23. /**
  24. * 管理接口:手动触发转化同步和回调重试。
  25. */
  26. @RestController
  27. @RequestMapping("/admin")
  28. public class AdminController {
  29. private final ConversionClient conversionClient;
  30. private final ConversionSyncRunner conversionSyncRunner;
  31. private final ConversionBackfillJobService backfillJobService;
  32. private final ConversionSyncService conversionSyncer;
  33. private final RetryService retryService;
  34. private final ManualTencentCallbackService manualTencentCallbackService;
  35. private final StringRedisTemplate redisTemplate;
  36. public AdminController(ConversionClient conversionClient,
  37. @Nullable ConversionSyncRunner conversionSyncRunner,
  38. @Nullable ConversionBackfillJobService backfillJobService,
  39. @Nullable ConversionSyncService conversionSyncer,
  40. @Nullable RetryService retryService,
  41. @Nullable ManualTencentCallbackService manualTencentCallbackService,
  42. @Nullable StringRedisTemplate redisTemplate) {
  43. this.conversionClient = conversionClient;
  44. this.conversionSyncRunner = conversionSyncRunner;
  45. this.backfillJobService = backfillJobService;
  46. this.conversionSyncer = conversionSyncer;
  47. this.retryService = retryService;
  48. this.manualTencentCallbackService = manualTencentCallbackService;
  49. this.redisTemplate = redisTemplate;
  50. }
  51. @PostMapping("/conversions/query")
  52. public Object queryConversions(@RequestBody ConversionQuery query) throws Exception {
  53. return conversionClient.queryPayments(query);
  54. }
  55. @PostMapping("/conversions/sync")
  56. public Object syncConversions(@RequestBody ConversionQuery query) throws Exception {
  57. if (conversionSyncer == null) {
  58. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  59. "conversion syncer is not configured");
  60. }
  61. return conversionSyncer.syncTencentConversions(query);
  62. }
  63. @PostMapping("/conversions/backfill")
  64. public Object backfillConversions(@RequestBody(required = false) Map<String, Object> body) throws Exception {
  65. if (backfillJobService == null) {
  66. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  67. "conversion backfill job service is not configured");
  68. }
  69. List<Integer> offsets = parseOffsets(body);
  70. return backfillJobService.submit(offsets);
  71. }
  72. @GetMapping("/conversions/backfill/{jobId}")
  73. public Object getBackfillJob(@PathVariable("jobId") String jobId) {
  74. if (backfillJobService == null) {
  75. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  76. "conversion backfill job service is not configured");
  77. }
  78. ConversionBackfillJobService.JobState job = backfillJobService.getJob(jobId);
  79. if (job == null) {
  80. throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
  81. }
  82. return job;
  83. }
  84. @PostMapping("/callbacks/retry")
  85. public Object retryCallbacks(@RequestBody Map<String, Object> body) {
  86. if (retryService == null) {
  87. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  88. "callback retryer is not configured");
  89. }
  90. int limit = toInt(body.get("limit"));
  91. return retryService.retryTencentCallbacks(limit);
  92. }
  93. @PostMapping("/callbacks/tencent/manual-replay")
  94. public Object manualReplayTencentCallback(@RequestBody Map<String, Object> body) {
  95. if (manualTencentCallbackService == null) {
  96. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  97. "manual tencent callback service is not configured");
  98. }
  99. long id = toLong(body.get("id"));
  100. if (id <= 0) {
  101. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "id is required");
  102. }
  103. return manualTencentCallbackService.replayDeductedCallback(id);
  104. }
  105. @PostMapping("/redis/tighten-bid-media-ttl")
  106. public Object tightenBidMediaTtl(@RequestBody(required = false) Map<String, Object> body) {
  107. if (redisTemplate == null) {
  108. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  109. "redis template is not configured");
  110. }
  111. long ttlSeconds = toLong(body == null ? null : body.get("ttlSeconds"));
  112. if (ttlSeconds <= 0) {
  113. ttlSeconds = Duration.ofDays(3).getSeconds();
  114. }
  115. long scanCount = toLong(body == null ? null : body.get("scanCount"));
  116. if (scanCount <= 0) {
  117. scanCount = 2000;
  118. }
  119. long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
  120. if (maxKeys <= 0) {
  121. maxKeys = 200000;
  122. }
  123. List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
  124. if (patterns.isEmpty()) {
  125. patterns = defaultBidMediaPatterns();
  126. }
  127. List<Map<String, Object>> results = new ArrayList<>();
  128. long totalScanned = 0;
  129. long totalUpdated = 0;
  130. for (String pattern : patterns) {
  131. TtlTightenResult result = tightenTtl(pattern, ttlSeconds, scanCount, Math.max(0, maxKeys - totalScanned));
  132. results.add(Map.of(
  133. "pattern", pattern,
  134. "scanned", result.scanned(),
  135. "updated", result.updated(),
  136. "stoppedByLimit", result.stoppedByLimit()
  137. ));
  138. totalScanned += result.scanned();
  139. totalUpdated += result.updated();
  140. if (totalScanned >= maxKeys) {
  141. break;
  142. }
  143. }
  144. return Map.of(
  145. "ttlSeconds", ttlSeconds,
  146. "ttlHuman", Duration.ofSeconds(ttlSeconds).toString(),
  147. "scanCount", scanCount,
  148. "maxKeys", maxKeys,
  149. "scanned", totalScanned,
  150. "updated", totalUpdated,
  151. "patterns", results
  152. );
  153. }
  154. @PostMapping("/redis/delete-bid-media-expiring-before")
  155. public Object deleteBidMediaExpiringBefore(@RequestBody(required = false) Map<String, Object> body) {
  156. if (redisTemplate == null) {
  157. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  158. "redis template is not configured");
  159. }
  160. long ttlLessThanSeconds = toLong(body == null ? null : body.get("ttlLessThanSeconds"));
  161. if (ttlLessThanSeconds <= 0) {
  162. ttlLessThanSeconds = Duration.ofDays(2).getSeconds();
  163. }
  164. long scanCount = toLong(body == null ? null : body.get("scanCount"));
  165. if (scanCount <= 0) {
  166. scanCount = 1000;
  167. }
  168. long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
  169. if (maxKeys <= 0) {
  170. maxKeys = 50000;
  171. }
  172. List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
  173. if (patterns.isEmpty()) {
  174. patterns = defaultBidMediaPatterns();
  175. }
  176. String jobId = UUID.randomUUID().toString();
  177. RedisMaintenanceJob job = new RedisMaintenanceJob(jobId, "RUNNING", ttlLessThanSeconds, scanCount, maxKeys,
  178. 0, 0, null, List.of());
  179. saveRedisMaintenanceJob(job);
  180. List<String> taskPatterns = List.copyOf(patterns);
  181. long taskTtlLessThanSeconds = ttlLessThanSeconds;
  182. long taskScanCount = scanCount;
  183. long taskMaxKeys = maxKeys;
  184. CompletableFuture.runAsync(() -> runDeleteExpiringJob(jobId, taskPatterns, taskTtlLessThanSeconds,
  185. taskScanCount, taskMaxKeys));
  186. return Map.of(
  187. "jobId", jobId,
  188. "status", "RUNNING",
  189. "ttlLessThanSeconds", ttlLessThanSeconds,
  190. "ttlLessThanHuman", Duration.ofSeconds(ttlLessThanSeconds).toString(),
  191. "scanCount", scanCount,
  192. "maxKeys", maxKeys,
  193. "patterns", taskPatterns
  194. );
  195. }
  196. @GetMapping("/redis/delete-bid-media-expiring-before/{jobId}")
  197. public Object getDeleteBidMediaExpiringBeforeJob(@PathVariable("jobId") String jobId) {
  198. Map<Object, Object> job = readRedisMaintenanceJob(jobId);
  199. if (job == null) {
  200. throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
  201. }
  202. return job;
  203. }
  204. private void runDeleteExpiringJob(String jobId, List<String> patterns, long ttlLessThanSeconds,
  205. long scanCount, long maxKeys) {
  206. List<Map<String, Object>> results = new ArrayList<>();
  207. long totalScanned = 0;
  208. long totalDeleted = 0;
  209. try {
  210. for (String pattern : patterns) {
  211. DeleteExpiringResult result = deleteExpiringKeys(pattern, ttlLessThanSeconds, scanCount,
  212. Math.max(0, maxKeys - totalScanned));
  213. results.add(Map.of(
  214. "pattern", pattern,
  215. "scanned", result.scanned(),
  216. "deleted", result.deleted(),
  217. "stoppedByLimit", result.stoppedByLimit()
  218. ));
  219. totalScanned += result.scanned();
  220. totalDeleted += result.deleted();
  221. saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "RUNNING", ttlLessThanSeconds,
  222. scanCount, maxKeys, totalScanned, totalDeleted, null, List.copyOf(results)));
  223. if (totalScanned >= maxKeys) {
  224. break;
  225. }
  226. }
  227. saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "COMPLETED", ttlLessThanSeconds,
  228. scanCount, maxKeys, totalScanned, totalDeleted, null, List.copyOf(results)));
  229. } catch (Exception e) {
  230. saveRedisMaintenanceJob(new RedisMaintenanceJob(jobId, "FAILED", ttlLessThanSeconds,
  231. scanCount, maxKeys, totalScanned, totalDeleted, rootMessage(e), List.copyOf(results)));
  232. }
  233. }
  234. @PostMapping("/redis/delete-bid-media-expiring-before-sync")
  235. public Object deleteBidMediaExpiringBeforeSync(@RequestBody(required = false) Map<String, Object> body) {
  236. if (redisTemplate == null) {
  237. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
  238. "redis template is not configured");
  239. }
  240. long ttlLessThanSeconds = toLong(body == null ? null : body.get("ttlLessThanSeconds"));
  241. if (ttlLessThanSeconds <= 0) {
  242. ttlLessThanSeconds = Duration.ofDays(2).getSeconds();
  243. }
  244. long scanCount = toLong(body == null ? null : body.get("scanCount"));
  245. if (scanCount <= 0) {
  246. scanCount = 1000;
  247. }
  248. long maxKeys = toLong(body == null ? null : body.get("maxKeys"));
  249. if (maxKeys <= 0) {
  250. maxKeys = 50000;
  251. }
  252. List<String> patterns = parseStringList(body == null ? null : body.get("patterns"));
  253. if (patterns.isEmpty()) {
  254. patterns = defaultBidMediaPatterns();
  255. }
  256. List<Map<String, Object>> results = new ArrayList<>();
  257. long totalScanned = 0;
  258. long totalDeleted = 0;
  259. for (String pattern : patterns) {
  260. DeleteExpiringResult result = deleteExpiringKeys(pattern, ttlLessThanSeconds, scanCount,
  261. Math.max(0, maxKeys - totalScanned));
  262. results.add(Map.of(
  263. "pattern", pattern,
  264. "scanned", result.scanned(),
  265. "deleted", result.deleted(),
  266. "stoppedByLimit", result.stoppedByLimit()
  267. ));
  268. totalScanned += result.scanned();
  269. totalDeleted += result.deleted();
  270. if (totalScanned >= maxKeys) {
  271. break;
  272. }
  273. }
  274. return Map.of(
  275. "ttlLessThanSeconds", ttlLessThanSeconds,
  276. "ttlLessThanHuman", Duration.ofSeconds(ttlLessThanSeconds).toString(),
  277. "scanCount", scanCount,
  278. "maxKeys", maxKeys,
  279. "scanned", totalScanned,
  280. "deleted", totalDeleted,
  281. "patterns", results
  282. );
  283. }
  284. private static int toInt(Object v) {
  285. if (v == null) return 0;
  286. if (v instanceof Number n) return n.intValue();
  287. try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) { return 0; }
  288. }
  289. private static long toLong(Object v) {
  290. if (v == null) return 0L;
  291. if (v instanceof Number n) return n.longValue();
  292. try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) { return 0L; }
  293. }
  294. private TtlTightenResult tightenTtl(String pattern, long ttlSeconds, long scanCount, long maxKeys) {
  295. if (maxKeys <= 0 || pattern == null || pattern.isBlank()) {
  296. return new TtlTightenResult(0, 0, true);
  297. }
  298. long scanned = 0;
  299. long updated = 0;
  300. ScanOptions options = ScanOptions.scanOptions().match(pattern).count(scanCount).build();
  301. try (Cursor<String> cursor = redisTemplate.scan(options)) {
  302. while (cursor.hasNext()) {
  303. String key = cursor.next();
  304. scanned++;
  305. Long currentTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
  306. if (currentTtl == null || currentTtl < 0 || currentTtl > ttlSeconds) {
  307. Boolean ok = redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
  308. if (Boolean.TRUE.equals(ok)) {
  309. updated++;
  310. }
  311. }
  312. if (scanned >= maxKeys) {
  313. return new TtlTightenResult(scanned, updated, true);
  314. }
  315. }
  316. }
  317. return new TtlTightenResult(scanned, updated, false);
  318. }
  319. private DeleteExpiringResult deleteExpiringKeys(String pattern, long ttlLessThanSeconds, long scanCount, long maxKeys) {
  320. if (maxKeys <= 0 || pattern == null || pattern.isBlank()) {
  321. return new DeleteExpiringResult(0, 0, true);
  322. }
  323. long scanned = 0;
  324. long deleted = 0;
  325. ScanOptions options = ScanOptions.scanOptions().match(pattern).count(scanCount).build();
  326. try (Cursor<String> cursor = redisTemplate.scan(options)) {
  327. while (cursor.hasNext()) {
  328. String key = cursor.next();
  329. scanned++;
  330. Long currentTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
  331. if (currentTtl != null && currentTtl > 0 && currentTtl < ttlLessThanSeconds) {
  332. Boolean ok = redisTemplate.delete(key);
  333. if (Boolean.TRUE.equals(ok)) {
  334. deleted++;
  335. }
  336. }
  337. if (scanned >= maxKeys) {
  338. return new DeleteExpiringResult(scanned, deleted, true);
  339. }
  340. }
  341. }
  342. return new DeleteExpiringResult(scanned, deleted, false);
  343. }
  344. private void saveRedisMaintenanceJob(RedisMaintenanceJob job) {
  345. try {
  346. String key = redisJobKey(job.jobId());
  347. Map<String, String> values = Map.of(
  348. "jobId", job.jobId(),
  349. "status", job.status(),
  350. "ttlLessThanSeconds", String.valueOf(job.ttlLessThanSeconds()),
  351. "scanCount", String.valueOf(job.scanCount()),
  352. "maxKeys", String.valueOf(job.maxKeys()),
  353. "scanned", String.valueOf(job.scanned()),
  354. "deleted", String.valueOf(job.deleted()),
  355. "error", job.error() == null ? "" : job.error(),
  356. "patterns", job.patterns().toString()
  357. );
  358. redisTemplate.opsForHash().putAll(key, values);
  359. redisTemplate.expire(key, Duration.ofHours(2));
  360. } catch (Exception ignored) {
  361. // Job 状态只用于管理查询,写状态失败不影响清理任务继续执行。
  362. }
  363. }
  364. private Map<Object, Object> readRedisMaintenanceJob(String jobId) {
  365. Map<Object, Object> payload = redisTemplate.opsForHash().entries(redisJobKey(jobId));
  366. if (payload == null || payload.isEmpty()) {
  367. return null;
  368. }
  369. return payload;
  370. }
  371. private static List<String> parseStringList(Object raw) {
  372. if (!(raw instanceof Iterable<?> iterable)) {
  373. return List.of();
  374. }
  375. List<String> values = new ArrayList<>();
  376. for (Object item : iterable) {
  377. if (item != null && !item.toString().isBlank()) {
  378. values.add(item.toString());
  379. }
  380. }
  381. return values;
  382. }
  383. private static List<String> defaultBidMediaPatterns() {
  384. return List.of(
  385. "adx:bid:*",
  386. "adx:media:*",
  387. "adx:honor:bid:*",
  388. "adx:honor:media:*",
  389. "adx:kuaishou:bid:*",
  390. "adx:kuaishou:media:*",
  391. "adx:vivo:bid:*",
  392. "adx:vivo:media:*"
  393. );
  394. }
  395. private static List<Integer> parseOffsets(Map<String, Object> body) {
  396. if (body == null || body.isEmpty()) {
  397. return defaultBackfillOffsets();
  398. }
  399. Object raw = body.get("offsets");
  400. if (!(raw instanceof Iterable<?> iterable)) {
  401. return defaultBackfillOffsets();
  402. }
  403. List<Integer> offsets = new java.util.ArrayList<>();
  404. for (Object item : iterable) {
  405. offsets.add(toInt(item));
  406. }
  407. return offsets.isEmpty() ? defaultBackfillOffsets() : offsets;
  408. }
  409. private static List<Integer> defaultBackfillOffsets() {
  410. return List.of(-1, -2, -3, -4, -5, -6, -7);
  411. }
  412. private static String redisJobKey(String jobId) {
  413. return "adx:admin:redis-maintenance-job:" + jobId;
  414. }
  415. private static String rootMessage(Throwable e) {
  416. Throwable cur = e;
  417. while (cur.getCause() != null) {
  418. cur = cur.getCause();
  419. }
  420. return cur.getMessage() == null ? cur.getClass().getName() : cur.getMessage();
  421. }
  422. private record TtlTightenResult(long scanned, long updated, boolean stoppedByLimit) {}
  423. private record DeleteExpiringResult(long scanned, long deleted, boolean stoppedByLimit) {}
  424. private record RedisMaintenanceJob(String jobId, String status, long ttlLessThanSeconds, long scanCount,
  425. long maxKeys, long scanned, long deleted, String error,
  426. List<Map<String, Object>> patterns) {}
  427. }