|
@@ -0,0 +1,421 @@
|
|
|
|
|
+package com.adx.tracking.httpapi;
|
|
|
|
|
+
|
|
|
|
|
+import com.adx.tracking.baidu.AdxClient;
|
|
|
|
|
+import com.adx.tracking.baidu.ConversionClient;
|
|
|
|
|
+import com.adx.tracking.baidu.model.ConversionQuery;
|
|
|
|
|
+import com.adx.tracking.baidu.model.NormalizedBidResponse;
|
|
|
|
|
+import com.adx.tracking.baidu.model.TrackingResult;
|
|
|
|
|
+import com.adx.tracking.conversionsync.ConversionSyncService;
|
|
|
|
|
+import com.adx.tracking.conversionsync.RetryService;
|
|
|
|
|
+import com.adx.tracking.storage.RedisHotStore;
|
|
|
|
|
+import com.adx.tracking.storage.model.BidRecord;
|
|
|
|
|
+import com.adx.tracking.storage.model.TrackingRecord;
|
|
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
|
|
+import org.springframework.lang.Nullable;
|
|
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
|
|
+import org.springframework.web.server.ResponseStatusException;
|
|
|
|
|
+
|
|
|
|
|
+import jakarta.servlet.http.HttpServletRequest;
|
|
|
|
|
+import jakarta.servlet.http.HttpServletResponse;
|
|
|
|
|
+import java.io.IOException;
|
|
|
|
|
+import java.time.Instant;
|
|
|
|
|
+import java.util.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 对应 Go internal/httpapi/router.go 的所有路由。
|
|
|
|
|
+ * 路由列表:
|
|
|
|
|
+ * GET /health
|
|
|
|
|
+ * POST /baidu/adx/request
|
|
|
|
|
+ * POST /baidu/adx/report-impression
|
|
|
|
|
+ * POST /baidu/adx/report-click
|
|
|
|
|
+ * POST /baidu/conversions/query
|
|
|
|
|
+ * GET /media/kuaishou/impression
|
|
|
|
|
+ * GET /media/kuaishou/click
|
|
|
|
|
+ * POST /jobs/kuaishou/conversions/sync
|
|
|
|
|
+ * POST /jobs/kuaishou/callbacks/retry
|
|
|
|
|
+ */
|
|
|
|
|
+@RestController
|
|
|
|
|
+public class AdxController {
|
|
|
|
|
+
|
|
|
|
|
+ private final AdxClient adxClient;
|
|
|
|
|
+ private final ConversionClient conversionClient;
|
|
|
|
|
+ private final RedisHotStore hotStore;
|
|
|
|
|
+ private final ConversionSyncService conversionSyncer; // nullable
|
|
|
|
|
+ private final RetryService retryService; // nullable
|
|
|
|
|
+ private final MediaPlacement kuaishouPlacement; // nullable
|
|
|
|
|
+
|
|
|
|
|
+ public AdxController(AdxClient adxClient,
|
|
|
|
|
+ ConversionClient conversionClient,
|
|
|
|
|
+ RedisHotStore hotStore,
|
|
|
|
|
+ @Nullable ConversionSyncService conversionSyncer,
|
|
|
|
|
+ @Nullable RetryService retryService,
|
|
|
|
|
+ @Nullable MediaPlacement kuaishouPlacement) {
|
|
|
|
|
+ this.adxClient = adxClient;
|
|
|
|
|
+ this.conversionClient = conversionClient;
|
|
|
|
|
+ this.hotStore = hotStore;
|
|
|
|
|
+ this.conversionSyncer = conversionSyncer;
|
|
|
|
|
+ this.retryService = retryService;
|
|
|
|
|
+ this.kuaishouPlacement = kuaishouPlacement;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── /health ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @GetMapping("/health")
|
|
|
|
|
+ public Map<String, Boolean> health() {
|
|
|
|
|
+ return Map.of("ok", true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /baidu/adx/request ─────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/baidu/adx/request")
|
|
|
|
|
+ public NormalizedBidResponse baiduAdxRequest(@RequestBody Map<String, Object> body) throws Exception {
|
|
|
|
|
+ NormalizedBidResponse response = adxClient.requestBid(body);
|
|
|
|
|
+ if (response != null && response.getQk() != null && !response.getQk().isBlank()) {
|
|
|
|
|
+ BidRecord record = bidRecordFromResponse(response, mediaParamsFromBody(body));
|
|
|
|
|
+ hotStore.recordBid(record);
|
|
|
|
|
+ }
|
|
|
|
|
+ return response;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /baidu/adx/report-impression ───────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/baidu/adx/report-impression")
|
|
|
|
|
+ public Map<String, Object> baiduAdxReportImpression(@RequestBody Map<String, Object> body) throws Exception {
|
|
|
|
|
+ String qk = (String) body.get("qk");
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ List<String> showUrls = (List<String>) body.getOrDefault("showUrls", Collections.emptyList());
|
|
|
|
|
+ long price = toLong(body.get("price"));
|
|
|
|
|
+
|
|
|
|
|
+ List<TrackingResult> results = adxClient.reportImpression(showUrls, price);
|
|
|
|
|
+ recordTrackingResults(qk, "impression", results);
|
|
|
|
|
+ return Map.of("data", results);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /baidu/adx/report-click ────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/baidu/adx/report-click")
|
|
|
|
|
+ public Map<String, Object> baiduAdxReportClick(@RequestBody Map<String, Object> body) throws Exception {
|
|
|
|
|
+ String qk = (String) body.get("qk");
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ List<String> clickUrls = (List<String>) body.getOrDefault("clickUrls", Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ List<TrackingResult> results = adxClient.reportClick(clickUrls);
|
|
|
|
|
+ recordTrackingResults(qk, "click", results);
|
|
|
|
|
+ return Map.of("data", results);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /baidu/conversions/query ───────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/baidu/conversions/query")
|
|
|
|
|
+ public Object baiduConversionsQuery(@RequestBody ConversionQuery query) throws Exception {
|
|
|
|
|
+ return conversionClient.queryPayments(query);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── GET /media/kuaishou/impression ──────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @GetMapping("/media/kuaishou/impression")
|
|
|
|
|
+ public Map<String, Object> kuaishouImpression(HttpServletRequest request) throws Exception {
|
|
|
|
|
+ Map<String, String> params = flatQueryParams(request);
|
|
|
|
|
+ String traceId = mediaTraceId(params);
|
|
|
|
|
+ if (traceId == null || traceId.isBlank()) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kuaishouPlacement == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "kuaishou placement not configured");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> bidRequest = mediaBidRequest(request, "kuaishou", traceId, params, kuaishouPlacement);
|
|
|
|
|
+ NormalizedBidResponse response = adxClient.requestBid(bidRequest);
|
|
|
|
|
+ if (response == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ BidRecord record = bidRecordFromMediaResponse(response, "kuaishou", traceId, params);
|
|
|
|
|
+ hotStore.recordBid(record);
|
|
|
|
|
+
|
|
|
|
|
+ List<TrackingResult> trackingResults = Collections.emptyList();
|
|
|
|
|
+ if (response.getBid() != null && response.getBid().getShowUrls() != null
|
|
|
|
|
+ && !response.getBid().getShowUrls().isEmpty()) {
|
|
|
|
|
+ trackingResults = adxClient.reportImpression(
|
|
|
|
|
+ response.getBid().getShowUrls(), response.getBid().getPrice());
|
|
|
|
|
+ recordTrackingResults(response.getQk(), "impression", trackingResults);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Map.of("qk", nvl(response.getQk()), "traceId", traceId, "tracking", trackingResults);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── GET /media/kuaishou/click ────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @GetMapping("/media/kuaishou/click")
|
|
|
|
|
+ public Object kuaishouClick(HttpServletRequest request, HttpServletResponse response)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+ if (kuaishouPlacement == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "kuaishou placement not configured");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, String> params = flatQueryParams(request);
|
|
|
|
|
+ String traceId = mediaTraceId(params);
|
|
|
|
|
+ if (traceId == null || traceId.isBlank()) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing media trace id");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 先从 Redis 按 mediaTrace 查找已有 bid
|
|
|
|
|
+ BidRecord record = hotStore.findBidByMediaTrace("kuaishou", traceId);
|
|
|
|
|
+ if (record == null) {
|
|
|
|
|
+ // 未找到,临时发起竞价请求
|
|
|
|
|
+ Map<String, Object> bidRequest = mediaBidRequest(request, "kuaishou", traceId, params, kuaishouPlacement);
|
|
|
|
|
+ NormalizedBidResponse resp = adxClient.requestBid(bidRequest);
|
|
|
|
|
+ if (resp == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "empty baidu ADX response");
|
|
|
|
|
+ }
|
|
|
|
|
+ BidRecord newRecord = bidRecordFromMediaResponse(resp, "kuaishou", traceId, params);
|
|
|
|
|
+ hotStore.recordBid(newRecord);
|
|
|
|
|
+ record = newRecord;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<TrackingResult> trackingResults = adxClient.reportClick(
|
|
|
|
|
+ record.getClickUrls() != null ? record.getClickUrls() : Collections.emptyList());
|
|
|
|
|
+ recordTrackingResults(record.getQk(), "click", trackingResults);
|
|
|
|
|
+
|
|
|
|
|
+ // 重定向到 landing page 或 app store link
|
|
|
|
|
+ String destination = record.getAppStoreLink() != null && !record.getAppStoreLink().isBlank()
|
|
|
|
|
+ ? record.getAppStoreLink() : record.getLandingPage();
|
|
|
|
|
+ if (destination != null && !destination.isBlank()) {
|
|
|
|
|
+ response.sendRedirect(destination);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Map.of("qk", nvl(record.getQk()), "traceId", traceId, "tracking", trackingResults);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /jobs/kuaishou/conversions/sync ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/jobs/kuaishou/conversions/sync")
|
|
|
|
|
+ public Object kuaishouConversionsSync(@RequestBody ConversionQuery query) throws Exception {
|
|
|
|
|
+ if (conversionSyncer == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
|
|
|
|
+ "kuaishou conversion syncer is not configured");
|
|
|
|
|
+ }
|
|
|
|
|
+ return conversionSyncer.syncKuaishouConversions(query);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── POST /jobs/kuaishou/callbacks/retry ──────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/jobs/kuaishou/callbacks/retry")
|
|
|
|
|
+ public Object kuaishouCallbacksRetry(@RequestBody Map<String, Object> body) {
|
|
|
|
|
+ if (retryService == null) {
|
|
|
|
|
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
|
|
|
|
+ "kuaishou callback retryer is not configured");
|
|
|
|
|
+ }
|
|
|
|
|
+ int limit = toInt(body.get("limit"));
|
|
|
|
|
+ return retryService.retryKuaishouCallbacks(limit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─── 私有工具方法(对应 Go router.go 中的辅助函数)─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ private BidRecord bidRecordFromResponse(NormalizedBidResponse response,
|
|
|
|
|
+ Map<String, String> mediaParams) {
|
|
|
|
|
+ BidRecord r = new BidRecord();
|
|
|
|
|
+ r.setQk(response.getQk());
|
|
|
|
|
+ r.setReqId(response.getReqId());
|
|
|
|
|
+ r.setBidId(response.getBidId());
|
|
|
|
|
+ r.setMediaParams(mediaParams);
|
|
|
|
|
+ r.setCreatedAt(Instant.now());
|
|
|
|
|
+ if (response.getBid() != null) {
|
|
|
|
|
+ r.setCreativeId(response.getBid().getCrid());
|
|
|
|
|
+ r.setImpId(response.getBid().getImpId());
|
|
|
|
|
+ r.setTagId(response.getBid().getTagId());
|
|
|
|
|
+ r.setPrice(response.getBid().getPrice());
|
|
|
|
|
+ r.setShowUrls(response.getBid().getShowUrls());
|
|
|
|
|
+ r.setClickUrls(response.getBid().getClickUrls());
|
|
|
|
|
+ r.setLandingPage(response.getBid().getLandingPage());
|
|
|
|
|
+ r.setAppStoreLink(response.getBid().getAppStoreLink());
|
|
|
|
|
+ if (response.getBid().getAdm() != null) {
|
|
|
|
|
+ r.setPackageName(response.getBid().getAdm().getPackageName());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private BidRecord bidRecordFromMediaResponse(NormalizedBidResponse response,
|
|
|
|
|
+ String media, String traceId,
|
|
|
|
|
+ Map<String, String> mediaParams) {
|
|
|
|
|
+ BidRecord r = bidRecordFromResponse(response, mediaParams);
|
|
|
|
|
+ r.setMedia(media);
|
|
|
|
|
+ r.setMediaTraceId(traceId);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private Map<String, String> mediaParamsFromBody(Map<String, Object> body) {
|
|
|
|
|
+ Object raw = body.get("mediaParams");
|
|
|
|
|
+ if (!(raw instanceof Map)) return null;
|
|
|
|
|
+ Map<Object, Object> rawMap = (Map<Object, Object>) raw;
|
|
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
|
|
+ rawMap.forEach((k, v) -> params.put(String.valueOf(k), String.valueOf(v)));
|
|
|
|
|
+ return params.isEmpty() ? null : params;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, String> flatQueryParams(HttpServletRequest request) {
|
|
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
|
|
+ request.getParameterMap().forEach((k, v) -> {
|
|
|
|
|
+ if (v != null && v.length > 0) params.put(k, v[0]);
|
|
|
|
|
+ });
|
|
|
|
|
+ return params;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String mediaTraceId(Map<String, String> params) {
|
|
|
|
|
+ for (String key : List.of("trace_id", "click_id", "callback", "request_id",
|
|
|
|
|
+ "event_id", "oaid", "imei", "idfa", "caid")) {
|
|
|
|
|
+ String v = params.get(key);
|
|
|
|
|
+ if (v != null && !v.isBlank()) return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> mediaBidRequest(HttpServletRequest request, String media,
|
|
|
|
|
+ String traceId, Map<String, String> params,
|
|
|
|
|
+ MediaPlacement placement) {
|
|
|
|
|
+ MediaPlacement.Resolved resolved = placement.resolve(params);
|
|
|
|
|
+ List<Integer> actionTypes = placement.getActionTypes();
|
|
|
|
|
+ if (actionTypes == null || actionTypes.isEmpty()) actionTypes = List.of(1);
|
|
|
|
|
+
|
|
|
|
|
+ params.put("baidu_app_id", resolved.appId());
|
|
|
|
|
+ params.put("baidu_tag_id", resolved.tagId());
|
|
|
|
|
+ if (resolved.platform() != null && !resolved.platform().isBlank()) {
|
|
|
|
|
+ params.put("baidu_platform", resolved.platform());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> imp = new LinkedHashMap<>();
|
|
|
|
|
+ imp.put("id", "1");
|
|
|
|
|
+ imp.put("appId", resolved.appId());
|
|
|
|
|
+ imp.put("tagId", resolved.tagId());
|
|
|
|
|
+ imp.put("secure", 1);
|
|
|
|
|
+ imp.put("adType", 0);
|
|
|
|
|
+ imp.put("assets", defaultBaiduAssets());
|
|
|
|
|
+ imp.put("actionType", actionTypes);
|
|
|
|
|
+ imp.put("maxCount", 1);
|
|
|
|
|
+ if (placement.getBidFloor() > 0) imp.put("bidFloor", placement.getBidFloor());
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> device = new LinkedHashMap<>();
|
|
|
|
|
+ // 对应 Go gin c.ClientIP():默认信任反向代理,优先解析 X-Forwarded-For(取第一个非空 IP)、
|
|
|
|
|
+ // 再 X-Real-IP,最后回退到 RemoteAddr。生产环境必经反向代理,
|
|
|
|
|
+ // 直接使用 request.getRemoteAddr() 会得到代理 IP(如 127.0.0.1 / 内网 LB IP),
|
|
|
|
|
+ // 导致百度 ADX 拿到错误的客户端 IP。
|
|
|
|
|
+ device.put("ip", clientIp(request));
|
|
|
|
|
+ device.put("ua", AdxClient.BAIDU_USER_AGENT);
|
|
|
|
|
+ device.put("deviceType", 1);
|
|
|
|
|
+ int os = baiduOsForPlatform(resolved.platform());
|
|
|
|
|
+ if (os != 0) device.put("os", os);
|
|
|
|
|
+ Map<String, Object> uid = baiduUidFromParams(params);
|
|
|
|
|
+ if (!uid.isEmpty()) device.put("uid", uid);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> bidReq = new LinkedHashMap<>();
|
|
|
|
|
+ bidReq.put("mediaId", placement.getBaiduMediaId());
|
|
|
|
|
+ // 对应 Go: fmt.Sprintf("%s-%d", traceID, time.Now().UnixNano())
|
|
|
|
|
+ // 必须是纳秒精度,毫秒或 nanoTime() 都会使 reqId 与 Go 实现不一致
|
|
|
|
|
+ Instant now = Instant.now();
|
|
|
|
|
+ long nanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
|
|
|
|
|
+ bidReq.put("reqId", traceId + "-" + nanos);
|
|
|
|
|
+ bidReq.put("imp", List.of(imp));
|
|
|
|
|
+ bidReq.put("device", device);
|
|
|
|
|
+ bidReq.put("media", media);
|
|
|
|
|
+ bidReq.put("traceId", traceId);
|
|
|
|
|
+ bidReq.put("mediaParams", params);
|
|
|
|
|
+ return bidReq;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<Map<String, Integer>> defaultBaiduAssets() {
|
|
|
|
|
+ return List.of(
|
|
|
|
|
+ Map.of("templateId", 1, "ratio", 2),
|
|
|
|
|
+ Map.of("templateId", 1, "ratio", 7),
|
|
|
|
|
+ Map.of("templateId", 2, "ratio", 1),
|
|
|
|
|
+ Map.of("templateId", 2, "ratio", 4),
|
|
|
|
|
+ Map.of("templateId", 3, "ratio", 2),
|
|
|
|
|
+ Map.of("templateId", 4, "ratio", 4),
|
|
|
|
|
+ Map.of("templateId", 4, "ratio", 5)
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int baiduOsForPlatform(String platform) {
|
|
|
|
|
+ if (platform == null) return 0;
|
|
|
|
|
+ return switch (platform) {
|
|
|
|
|
+ case "ios" -> 1;
|
|
|
|
|
+ case "android" -> 2;
|
|
|
|
|
+ default -> 0;
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> baiduUidFromParams(Map<String, String> p) {
|
|
|
|
|
+ Map<String, Object> uid = new LinkedHashMap<>();
|
|
|
|
|
+ putUid(uid, "did", firstParam(p, "did", "imei"));
|
|
|
|
|
+ putUid(uid, "didMd5", firstParam(p, "did_md5", "didMd5", "imei_md5", "imeiMd5"));
|
|
|
|
|
+ putUid(uid, "dpid", firstParam(p, "dpid", "android_id", "androidId"));
|
|
|
|
|
+ putUid(uid, "dpidMd5", firstParam(p, "dpid_md5", "dpidMd5", "android_id_md5", "androidIdMd5"));
|
|
|
|
|
+ putUid(uid, "idfa", p.get("idfa"));
|
|
|
|
|
+ putUid(uid, "idfaMd5", firstParam(p, "idfa_md5", "idfaMd5"));
|
|
|
|
|
+ putUid(uid, "oaid", p.get("oaid"));
|
|
|
|
|
+ putUid(uid, "oaidMd5", firstParam(p, "oaid_md5", "oaidMd5"));
|
|
|
|
|
+ return uid;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void putUid(Map<String, Object> uid, String key, String value) {
|
|
|
|
|
+ if (value != null && !value.isBlank()) uid.put(key, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String firstParam(Map<String, String> p, String... keys) {
|
|
|
|
|
+ for (String k : keys) {
|
|
|
|
|
+ String v = p.get(k);
|
|
|
|
|
+ if (v != null && !v.isBlank()) return v.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void recordTrackingResults(String qk, String kind, List<TrackingResult> results) {
|
|
|
|
|
+ if (hotStore == null || results == null) return;
|
|
|
|
|
+ for (TrackingResult r : results) {
|
|
|
|
|
+ TrackingRecord record = new TrackingRecord();
|
|
|
|
|
+ record.setQk(qk);
|
|
|
|
|
+ record.setKind(kind);
|
|
|
|
|
+ record.setUrl(r.getUrl());
|
|
|
|
|
+ record.setStatus(r.getStatus());
|
|
|
|
|
+ record.setOk(r.isOk());
|
|
|
|
|
+ record.setCreatedAt(Instant.now());
|
|
|
|
|
+ hotStore.recordTracking(record);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 对应 Go gin Context.ClientIP() 的默认行为。
|
|
|
|
|
+ * 解析顺序:X-Forwarded-For 第一个非空 IP → X-Real-IP → RemoteAddr。
|
|
|
|
|
+ */
|
|
|
|
|
+ static String clientIp(HttpServletRequest request) {
|
|
|
|
|
+ String xff = request.getHeader("X-Forwarded-For");
|
|
|
|
|
+ if (xff != null && !xff.isBlank()) {
|
|
|
|
|
+ // X-Forwarded-For 格式可能为 "client, proxy1, proxy2",取第一个
|
|
|
|
|
+ for (String part : xff.split(",")) {
|
|
|
|
|
+ String ip = part.trim();
|
|
|
|
|
+ if (!ip.isEmpty()) return ip;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ String xri = request.getHeader("X-Real-IP");
|
|
|
|
|
+ if (xri != null && !xri.isBlank()) {
|
|
|
|
|
+ return xri.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return request.getRemoteAddr();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static long toLong(Object v) {
|
|
|
|
|
+ if (v == null) return 0L;
|
|
|
|
|
+ if (v instanceof Number n) return n.longValue();
|
|
|
|
|
+ try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) { return 0L; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static int toInt(Object v) {
|
|
|
|
|
+ if (v == null) return 0;
|
|
|
|
|
+ if (v instanceof Number n) return n.intValue();
|
|
|
|
|
+ try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) { return 0; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String nvl(String s) { return s != null ? s : ""; }
|
|
|
|
|
+}
|