KuaishouTrackingController.java 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. package com.adx.tencent.kuaishou.controller;
  2. import com.adx.tencent.baidu.AdxClient;
  3. import com.adx.tencent.baidu.model.NormalizedBidResponse;
  4. import com.adx.tencent.baidu.model.TrackingResult;
  5. import com.adx.tencent.kuaishou.model.KuaishouBidRecord;
  6. import com.adx.tencent.kuaishou.model.KuaishouTrackingRecord;
  7. import com.adx.tencent.kuaishou.service.KuaishouPlacement;
  8. import com.adx.tencent.kuaishou.store.KuaishouHotStore;
  9. import com.adx.tencent.util.TraceIds;
  10. import com.fasterxml.jackson.databind.ObjectMapper;
  11. import jakarta.servlet.http.HttpServletRequest;
  12. import jakarta.servlet.http.HttpServletResponse;
  13. import org.slf4j.Logger;
  14. import org.slf4j.LoggerFactory;
  15. import org.springframework.http.HttpStatus;
  16. import org.springframework.lang.Nullable;
  17. import org.springframework.web.bind.annotation.GetMapping;
  18. import org.springframework.web.bind.annotation.RestController;
  19. import org.springframework.web.server.ResponseStatusException;
  20. import java.net.URLDecoder;
  21. import java.nio.charset.StandardCharsets;
  22. import java.security.MessageDigest;
  23. import java.time.Instant;
  24. import java.util.ArrayList;
  25. import java.util.Collections;
  26. import java.util.LinkedHashMap;
  27. import java.util.List;
  28. import java.util.Map;
  29. import java.util.concurrent.Semaphore;
  30. @RestController
  31. public class KuaishouTrackingController {
  32. private static final Logger log = LoggerFactory.getLogger(KuaishouTrackingController.class);
  33. private static final int MAX_CONCURRENT = 50;
  34. private final Semaphore impressionSemaphore = new Semaphore(MAX_CONCURRENT);
  35. private final Semaphore clickSemaphore = new Semaphore(MAX_CONCURRENT);
  36. private final AdxClient adxClient;
  37. private final KuaishouHotStore hotStore;
  38. private final KuaishouPlacement placement;
  39. private final ObjectMapper objectMapper;
  40. public KuaishouTrackingController(AdxClient adxClient,
  41. @Nullable KuaishouHotStore hotStore,
  42. @Nullable KuaishouPlacement placement,
  43. ObjectMapper objectMapper) {
  44. this.adxClient = adxClient;
  45. this.hotStore = hotStore;
  46. this.placement = placement;
  47. this.objectMapper = objectMapper;
  48. }
  49. @GetMapping("/kuaishou/impression")
  50. public Object impression(HttpServletRequest request) throws Exception {
  51. impressionSemaphore.acquire();
  52. try {
  53. return doImpression(request);
  54. } finally {
  55. impressionSemaphore.release();
  56. }
  57. }
  58. @GetMapping("/kuaishou/click")
  59. public Object click(HttpServletRequest request, HttpServletResponse response) throws Exception {
  60. clickSemaphore.acquire();
  61. try {
  62. return doClick(request, response);
  63. } finally {
  64. clickSemaphore.release();
  65. }
  66. }
  67. private Object doImpression(HttpServletRequest request) throws Exception {
  68. ensureReady();
  69. Map<String, String> params = normalizeParams(request);
  70. String traceId = kuaishouTraceId(params);
  71. if (traceId == null || traceId.isBlank()) {
  72. log.warn("[快手][曝光] 缺少 traceId, params={}", params.keySet());
  73. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing kuaishou trace id (callback)");
  74. }
  75. log.info("[快手][曝光][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
  76. Map<String, Object> bidRequest = buildBidRequest(request, traceId, params);
  77. NormalizedBidResponse response = adxClient.requestBid("kuaishou", traceId, bidRequest);
  78. if (response == null) {
  79. log.warn("[快手][曝光][百度结果] traceId={} empty=true", traceId);
  80. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  81. }
  82. log.info("[快手][曝光][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
  83. traceId,
  84. response.getQk(),
  85. response.getBidId(),
  86. response.getBid() != null,
  87. response.getBid() != null ? response.getBid().getPrice() : 0,
  88. response.getBid() != null && response.getBid().getClickUrls() != null ? response.getBid().getClickUrls().size() : 0,
  89. response.getBid() != null && response.getBid().getShowUrls() != null ? response.getBid().getShowUrls().size() : 0);
  90. KuaishouBidRecord record = bidRecordFromResponse(response, traceId, "impression", params);
  91. hotStore.recordBid(record);
  92. if (response.getBid() != null && response.getBid().getShowUrls() != null && !response.getBid().getShowUrls().isEmpty()) {
  93. List<TrackingResult> tracking = adxClient.reportImpression(response.getBid().getShowUrls(), response.getBid().getPrice());
  94. recordTracking(record.getQk(), record.getTagId(), "impression", tracking);
  95. }
  96. return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
  97. }
  98. private Object doClick(HttpServletRequest request, HttpServletResponse response) throws Exception {
  99. ensureReady();
  100. Map<String, String> params = normalizeParams(request);
  101. String traceId = kuaishouTraceId(params);
  102. if (traceId == null || traceId.isBlank()) {
  103. log.warn("[快手][点击] 缺少 traceId, params={}", params.keySet());
  104. throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing kuaishou trace id (callback)");
  105. }
  106. log.info("[快手][点击][入参] traceId={} ip={} params={}", traceId, clientIp(request), params);
  107. KuaishouBidRecord record = hotStore.findBidByMediaTrace(traceId);
  108. if (record == null) {
  109. NormalizedBidResponse bidResponse = adxClient.requestBid("kuaishou", traceId, buildBidRequest(request, traceId, params));
  110. if (bidResponse == null) {
  111. log.warn("[快手][点击][百度结果] traceId={} empty=true", traceId);
  112. throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
  113. }
  114. log.info("[快手][点击][竞价结果] traceId={} qk={} bidId={} success={} price={} clickUrlCount={} showUrlCount={}",
  115. traceId,
  116. bidResponse.getQk(),
  117. bidResponse.getBidId(),
  118. bidResponse.getBid() != null,
  119. bidResponse.getBid() != null ? bidResponse.getBid().getPrice() : 0,
  120. bidResponse.getBid() != null && bidResponse.getBid().getClickUrls() != null ? bidResponse.getBid().getClickUrls().size() : 0,
  121. bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null ? bidResponse.getBid().getShowUrls().size() : 0);
  122. record = bidRecordFromResponse(bidResponse, traceId, "click", params);
  123. hotStore.recordBid(record);
  124. if (bidResponse.getBid() != null && bidResponse.getBid().getShowUrls() != null && !bidResponse.getBid().getShowUrls().isEmpty()) {
  125. List<TrackingResult> impressionTracking = adxClient.reportImpression(
  126. bidResponse.getBid().getShowUrls(), bidResponse.getBid().getPrice());
  127. recordTracking(record.getQk(), record.getTagId(), "impression", impressionTracking);
  128. }
  129. }
  130. List<TrackingResult> tracking = adxClient.reportClick(
  131. record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
  132. recordTracking(record.getQk(), record.getTagId(), "click", tracking);
  133. try {
  134. hotStore.shrinkAfterClick(record);
  135. } catch (Exception ignored) {
  136. }
  137. String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
  138. ? record.getAppStoreLink() : record.getLandingPage();
  139. if (destination != null && !destination.isBlank()) {
  140. response.sendRedirect(destination);
  141. return null;
  142. }
  143. return Map.of("qk", record.getQk(), "traceId", traceId, "tagId", record.getTagId());
  144. }
  145. private void ensureReady() {
  146. if (placement == null || hotStore == null) {
  147. throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "kuaishou placement not configured");
  148. }
  149. }
  150. private Map<String, String> normalizeParams(HttpServletRequest request) {
  151. Map<String, String> params = new LinkedHashMap<>();
  152. request.getParameterMap().forEach((k, v) -> {
  153. if (v != null && v.length > 0 && v[0] != null) {
  154. params.put(k, v[0].trim());
  155. }
  156. });
  157. alias(params, "tagId", "tag_id", "baidu_tag_id");
  158. alias(params, "callback", "cb");
  159. alias(params, "platform", "os", "system", "device_os");
  160. alias(params, "accountId", "accountid");
  161. alias(params, "accountid", "accountId");
  162. alias(params, "ua", "user_agent", "useragent");
  163. alias(params, "ip", "ipv4", "m6a");
  164. alias(params, "oaidMD5", "oaid_md5", "oaidMd5", "oaidMd5");
  165. alias(params, "imeiMD5", "imei_md5", "imeiMd5");
  166. alias(params, "imeiSHA1", "imei_sha1", "imeiSha1");
  167. alias(params, "androidIdMD5", "android_id_md5", "androidIdMd5");
  168. alias(params, "androidIdSHA1", "android_id_sha1", "androidIdSha1");
  169. alias(params, "idfaMD5", "idfa_md5", "idfaMd5");
  170. alias(params, "idfaSHA1", "idfa_sha1", "idfaSha1");
  171. alias(params, "kenyid_caa", "caid", "kenyIdCaa");
  172. alias(params, "ts", "eventTime", "timestamp");
  173. normalizeDeductionParams(params);
  174. return params;
  175. }
  176. private Map<String, Object> buildBidRequest(HttpServletRequest request, String traceId, Map<String, String> params) {
  177. KuaishouPlacement.Resolved resolved = placement.resolve(params);
  178. params.put("baidu_app_id", resolved.appId());
  179. params.put("baidu_tag_id", resolved.tagId());
  180. params.put("tagId", resolved.tagId());
  181. if (resolved.platform() != null && !resolved.platform().isBlank()) {
  182. params.put("baidu_platform", resolved.platform());
  183. params.put("platform", resolved.platform());
  184. }
  185. Map<String, Object> imp = new LinkedHashMap<>();
  186. imp.put("id", "1");
  187. imp.put("appId", resolved.appId());
  188. imp.put("tagId", resolved.tagId());
  189. imp.put("secure", 1);
  190. imp.put("adType", 0);
  191. imp.put("assets", defaultBaiduAssets());
  192. imp.put("actionType", placement.getActionTypes() == null || placement.getActionTypes().isEmpty()
  193. ? List.of(0, 1, 2)
  194. : placement.getActionTypes());
  195. imp.put("maxCount", 1);
  196. if (placement.getBidFloor() > 0) {
  197. imp.put("bidFloor", placement.getBidFloor());
  198. }
  199. Map<String, Object> device = new LinkedHashMap<>();
  200. putDeviceIp(device, firstParam(params, "ip"), firstParam(params, "ipv6"), clientIp(request));
  201. device.put("ua", normalizeUserAgent(firstParam(params, "ua"), resolved.platform()));
  202. device.put("deviceType", 1);
  203. int os = baiduOsForPlatform(resolved.platform());
  204. if (os != 0) device.put("os", os);
  205. Map<String, Object> uid = baiduUidFromParams(params);
  206. if (!uid.isEmpty()) device.put("uid", uid);
  207. Map<String, Object> bidReq = new LinkedHashMap<>();
  208. bidReq.put("mediaId", placement.getBaiduMediaId());
  209. bidReq.put("reqId", baiduReqId(traceId));
  210. bidReq.put("imp", List.of(imp));
  211. bidReq.put("device", device);
  212. List<Integer> appList = parseAppList(firstParam(params, "appList", "app_list", "applist"));
  213. bidReq.put("appList", appList);
  214. log.info("[快手][百度请求] traceId={} body={}", traceId, toJson(bidReq));
  215. return bidReq;
  216. }
  217. private KuaishouBidRecord bidRecordFromResponse(NormalizedBidResponse response,
  218. String traceId,
  219. String eventType,
  220. Map<String, String> params) {
  221. KuaishouBidRecord record = new KuaishouBidRecord();
  222. record.setQk(response.getQk());
  223. record.setReqId(response.getReqId());
  224. record.setBidId(response.getBidId());
  225. record.setMedia("kuaishou");
  226. record.setMediaTraceId(traceId);
  227. record.setPlatform(params.get("baidu_platform"));
  228. record.setAccountId(firstParam(params, "accountId", "accountid"));
  229. record.setTagId(params.get("tagId"));
  230. record.setEventType(eventType);
  231. record.setMediaParams(params);
  232. record.setCreatedAt(Instant.now());
  233. if (response.getBid() != null) {
  234. record.setCreativeId(response.getBid().getCrid());
  235. record.setImpId(response.getBid().getImpId());
  236. record.setTagId(response.getBid().getTagId() != null ? response.getBid().getTagId() : record.getTagId());
  237. record.setPrice(response.getBid().getPrice());
  238. record.setShowUrls(response.getBid().getShowUrls());
  239. record.setClickUrls(response.getBid().getClickUrls());
  240. record.setLandingPage(response.getBid().getLandingPage());
  241. record.setAppStoreLink(response.getBid().getAppStoreLink());
  242. if (response.getBid().getAdm() != null) {
  243. record.setPackageName(response.getBid().getAdm().getPackageName());
  244. }
  245. }
  246. return record;
  247. }
  248. private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
  249. Map<String, Object> uid = new LinkedHashMap<>();
  250. putUidWithMd5Fallback(uid, "did", "didMd5",
  251. firstParam(p, "imei"),
  252. firstParam(p, "imeiMD5", "imei_md5", "imeiMd5"));
  253. putUidWithMd5Fallback(uid, "dpid", "dpidMd5",
  254. firstParam(p, "androidid", "androidId", "android_id"),
  255. firstParam(p, "androidIdMD5", "android_id_md5", "androidIdMd5"));
  256. putUidWithMd5Fallback(uid, "idfa", "idfaMd5",
  257. firstParam(p, "idfa"),
  258. firstParam(p, "idfaMD5", "idfa_md5", "idfaMd5"));
  259. putUidWithMd5Fallback(uid, "oaid", "oaidMd5",
  260. firstParam(p, "oaid"),
  261. firstParam(p, "oaidMD5", "oaid_md5", "oaidMd5"));
  262. List<Map<String, Object>> caid = baiduCaidFromParams(p);
  263. if (!caid.isEmpty()) {
  264. uid.put("caid", caid);
  265. }
  266. return uid;
  267. }
  268. private List<Map<String, Object>> baiduCaidFromParams(Map<String, String> params) {
  269. String rawCaid = firstParam(params, "kenyid_caa", "caid");
  270. if (rawCaid == null || rawCaid.isBlank()) {
  271. return Collections.emptyList();
  272. }
  273. String decoded;
  274. try {
  275. decoded = URLDecoder.decode(rawCaid, StandardCharsets.UTF_8);
  276. } catch (Exception e) {
  277. decoded = rawCaid;
  278. }
  279. try {
  280. Object value = objectMapper.readValue(decoded, Object.class);
  281. if (value instanceof List<?> list) {
  282. List<Map<String, Object>> result = new ArrayList<>();
  283. for (Object item : list) {
  284. Map<String, Object> caid = toCaidItem(item);
  285. if (!caid.isEmpty()) result.add(caid);
  286. }
  287. return result;
  288. }
  289. Map<String, Object> single = toCaidItem(value);
  290. return single.isEmpty() ? Collections.emptyList() : List.of(single);
  291. } catch (Exception e) {
  292. return List.of(Map.of("id", decoded));
  293. }
  294. }
  295. private Map<String, Object> toCaidItem(Object source) {
  296. if (!(source instanceof Map<?, ?> map)) {
  297. return Collections.emptyMap();
  298. }
  299. String id = firstNonBlankValue(map, "qaid", "id", "caid");
  300. if (id == null) {
  301. return Collections.emptyMap();
  302. }
  303. Map<String, Object> caid = new LinkedHashMap<>();
  304. caid.put("id", id);
  305. String version = firstNonBlankValue(map, "version");
  306. if (version != null) {
  307. caid.put("version", version);
  308. }
  309. Long generateTime = firstLongValue(map, "generateTime");
  310. if (generateTime != null) {
  311. caid.put("generateTime", generateTime);
  312. }
  313. Long vendor = firstLongValue(map, "vendor");
  314. if (vendor != null) {
  315. caid.put("vendor", vendor);
  316. }
  317. return caid;
  318. }
  319. private void recordTracking(String qk, String tagId, String kind, List<TrackingResult> results) {
  320. if (results == null) return;
  321. for (TrackingResult result : results) {
  322. KuaishouTrackingRecord record = new KuaishouTrackingRecord();
  323. record.setQk(qk);
  324. record.setTagId(tagId);
  325. record.setKind(kind);
  326. record.setUrl(result.getUrl());
  327. record.setStatus(result.getStatus());
  328. record.setOk(result.isOk());
  329. record.setCreatedAt(Instant.now());
  330. hotStore.recordTracking(record);
  331. }
  332. }
  333. private String kuaishouTraceId(Map<String, String> params) {
  334. return TraceIds.normalize(firstParam(params, "callback", "request_id", "idfaMD5", "idfa", "oaidMD5", "oaid", "imeiMD5", "imei"));
  335. }
  336. private void normalizeDeductionParams(Map<String, String> params) {
  337. String rate = firstParam(params, "deduction_rate", "deductionRate", "rate");
  338. int sanitized = 0;
  339. try {
  340. if (rate != null) sanitized = Math.max(0, Math.min(100, Integer.parseInt(rate)));
  341. } catch (NumberFormatException ignored) {
  342. }
  343. params.put(KuaishouBidRecord.DEDUCTION_RATE_KEY, String.valueOf(sanitized));
  344. }
  345. private List<Map<String, Integer>> defaultBaiduAssets() {
  346. return List.of(
  347. Map.of("templateId", 1, "ratio", 2),
  348. Map.of("templateId", 1, "ratio", 7),
  349. Map.of("templateId", 2, "ratio", 1),
  350. Map.of("templateId", 2, "ratio", 4),
  351. Map.of("templateId", 3, "ratio", 2),
  352. Map.of("templateId", 4, "ratio", 4),
  353. Map.of("templateId", 4, "ratio", 5)
  354. );
  355. }
  356. private int baiduOsForPlatform(String platform) {
  357. if (platform == null) return 0;
  358. return switch (platform) {
  359. case "ios" -> 1;
  360. case "android" -> 2;
  361. default -> 0;
  362. };
  363. }
  364. private static String normalizeUserAgent(String ua, String platform) {
  365. if (ua != null && !ua.isBlank()) return ua;
  366. if ("ios".equals(platform)) {
  367. return "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1";
  368. }
  369. return "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/UQ1A.240205.002) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.71 Mobile Safari/537.36";
  370. }
  371. private List<Integer> parseAppList(String value) {
  372. if (value == null || value.isBlank()) return Collections.emptyList();
  373. List<Integer> result = new ArrayList<>();
  374. for (String part : value.split("[,|]")) {
  375. String item = part == null ? null : part.trim();
  376. if (item == null || item.isEmpty()) continue;
  377. try {
  378. result.add(Integer.parseInt(item));
  379. } catch (NumberFormatException ignored) {
  380. }
  381. }
  382. return result;
  383. }
  384. private void alias(Map<String, String> params, String target, String... aliases) {
  385. if (params.get(target) != null && !params.get(target).isBlank()) return;
  386. for (String alias : aliases) {
  387. String value = params.get(alias);
  388. if (value != null && !value.isBlank()) {
  389. params.put(target, value.trim());
  390. return;
  391. }
  392. }
  393. }
  394. private void putUid(Map<String, Object> uid, String key, String value) {
  395. if (value != null && !value.isBlank()) uid.put(key, value);
  396. }
  397. private void putUidWithMd5Fallback(Map<String, Object> uid,
  398. String rawKey,
  399. String md5Key,
  400. String rawCandidate,
  401. String explicitMd5Candidate) {
  402. String md5Value = normalizeMd5(explicitMd5Candidate);
  403. if (md5Value != null) {
  404. uid.put(md5Key, md5Value);
  405. }
  406. if (rawCandidate == null || rawCandidate.isBlank()) {
  407. return;
  408. }
  409. String value = rawCandidate.trim();
  410. if (isMd5(value)) {
  411. uid.putIfAbsent(md5Key, value.toLowerCase());
  412. return;
  413. }
  414. uid.put(rawKey, value);
  415. uid.putIfAbsent(md5Key, md5Hex(value));
  416. }
  417. private static boolean isMd5(String value) {
  418. return value != null && value.matches("(?i)^[0-9a-f]{32}$");
  419. }
  420. private static String normalizeMd5(String value) {
  421. if (value == null || value.isBlank()) {
  422. return null;
  423. }
  424. String trimmed = value.trim();
  425. return isMd5(trimmed) ? trimmed.toLowerCase() : trimmed;
  426. }
  427. private static String md5Hex(String value) {
  428. try {
  429. MessageDigest md = MessageDigest.getInstance("MD5");
  430. byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
  431. StringBuilder sb = new StringBuilder(digest.length * 2);
  432. for (byte b : digest) {
  433. sb.append(String.format("%02x", b));
  434. }
  435. return sb.toString();
  436. } catch (Exception e) {
  437. return value;
  438. }
  439. }
  440. private String baiduReqId(String traceId) {
  441. String base = traceId == null ? "" : traceId.replaceAll("[^A-Za-z0-9_-]", "");
  442. if (base.isBlank()) {
  443. base = md5Hex(traceId == null ? "kuaishou" : traceId);
  444. }
  445. if (base.length() > 64) {
  446. base = base.substring(0, 64);
  447. }
  448. return base + "-" + Instant.now().toEpochMilli();
  449. }
  450. private static void putDeviceIp(Map<String, Object> device, String ipv4Candidate, String ipv6Candidate, String fallbackIp) {
  451. String ipv4 = normalizeIp(ipv4Candidate);
  452. String ipv6 = normalizeIp(ipv6Candidate);
  453. String fallback = normalizeIp(fallbackIp);
  454. if (ipv4 == null && ipv6 == null && fallback != null) {
  455. if (isIpv6(fallback)) {
  456. ipv6 = fallback;
  457. } else {
  458. ipv4 = fallback;
  459. }
  460. }
  461. if (ipv4 != null && isIpv6(ipv4)) {
  462. ipv6 = ipv4;
  463. ipv4 = null;
  464. }
  465. if (ipv6 != null && !isIpv6(ipv6)) {
  466. if (ipv4 == null) {
  467. ipv4 = ipv6;
  468. }
  469. ipv6 = null;
  470. }
  471. if (ipv4 != null) {
  472. device.put("ip", ipv4);
  473. }
  474. if (ipv6 != null) {
  475. device.put("ipv6", ipv6);
  476. }
  477. }
  478. private static String normalizeIp(String value) {
  479. if (value == null || value.isBlank()) {
  480. return null;
  481. }
  482. return value.trim();
  483. }
  484. private static boolean isIpv6(String value) {
  485. return value != null && value.contains(":");
  486. }
  487. private String firstParam(Map<String, String> params, String... keys) {
  488. for (String key : keys) {
  489. String value = params.get(key);
  490. if (value != null && !value.isBlank()) return value.trim();
  491. }
  492. return null;
  493. }
  494. private static String firstNonBlankValue(Map<?, ?> map, String... keys) {
  495. for (String key : keys) {
  496. Object value = map.get(key);
  497. if (value == null) continue;
  498. String text = String.valueOf(value).trim();
  499. if (!text.isEmpty()) return text;
  500. }
  501. return null;
  502. }
  503. private static Long firstLongValue(Map<?, ?> map, String... keys) {
  504. for (String key : keys) {
  505. Object value = map.get(key);
  506. if (value == null) continue;
  507. if (value instanceof Number number) return number.longValue();
  508. try {
  509. return Long.parseLong(String.valueOf(value).trim());
  510. } catch (NumberFormatException ignored) {
  511. }
  512. }
  513. return null;
  514. }
  515. private String toJson(Object value) {
  516. try {
  517. return objectMapper.writeValueAsString(value);
  518. } catch (Exception e) {
  519. return String.valueOf(value);
  520. }
  521. }
  522. static String clientIp(HttpServletRequest request) {
  523. String xff = request.getHeader("X-Forwarded-For");
  524. if (xff != null && !xff.isBlank()) {
  525. for (String part : xff.split(",")) {
  526. String ip = part.trim();
  527. if (!ip.isEmpty()) return ip;
  528. }
  529. }
  530. String xri = request.getHeader("X-Real-IP");
  531. if (xri != null && !xri.isBlank()) {
  532. return xri.trim();
  533. }
  534. return request.getRemoteAddr();
  535. }
  536. private static String safe(String value) {
  537. return value == null ? "" : value;
  538. }
  539. }