Răsfoiți Sursa

feat:巨量引擎开发v0.1完成

刘云鑫 10 ore în urmă
părinte
comite
0d053cda0a

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/LifeUser.java

@@ -221,4 +221,7 @@ public class LifeUser implements Serializable {
     @TableField("device_id")
     private String deviceId;
 
+    @ApiModelProperty(value = "ocean_idfa_or_oaid")
+    @TableField("ocean_idfa_or_oaid")
+    private String oceanIdfaOrOaid;
 }

+ 12 - 0
alien-entity/src/main/java/shop/alien/entity/store/OceanEngineClickMonitorLog.java

@@ -201,6 +201,18 @@ public class OceanEngineClickMonitorLog implements Serializable {
     @TableField("remark")
     private String remark;
 
+    @ApiModelProperty(value = "转化事件类型(回传专用,如 active_register)")
+    @TableField(exist = false)
+    private String eventType;
+
+    @ApiModelProperty(value = "匹配类型(回传专用,默认 0)")
+    @TableField(exist = false)
+    private Integer matchType;
+
+    @ApiModelProperty(value = "转化回传时间戳毫秒(回传专用,为空则取 clickTsMs 或当前时间)")
+    @TableField(exist = false)
+    private Long conversionTimestamp;
+
     @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
     @TableField("delete_flag")
     @TableLogic

+ 1 - 5
alien-gateway/src/main/java/shop/alien/gateway/service/LifeUserService.java

@@ -2,7 +2,6 @@ package shop.alien.gateway.service;
 
 import com.alibaba.fastjson2.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import lombok.RequiredArgsConstructor;
 import org.apache.commons.lang3.StringUtils;
@@ -10,7 +9,6 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 import shop.alien.entity.result.R;
 import shop.alien.entity.second.LifeUserLog;
@@ -19,14 +17,12 @@ import shop.alien.entity.second.SecondUserCredit;
 import shop.alien.entity.second.SecondUserCreditRecord;
 import shop.alien.entity.second.enums.SecondUserCreditScoreEnum;
 import shop.alien.entity.store.LifeUser;
-import shop.alien.entity.store.StoreImg;
-import shop.alien.entity.store.StoreUser;
 import shop.alien.entity.store.vo.LifeUserVo;
 import shop.alien.gateway.config.BaseRedisService;
 import shop.alien.gateway.config.RiskControlProperties;
 import shop.alien.gateway.feign.SecondServiceFeign;
-import shop.alien.gateway.mapper.LifeUserLogGatewayMapper;
 import shop.alien.gateway.mapper.LifeUserGatewayMapper;
+import shop.alien.gateway.mapper.LifeUserLogGatewayMapper;
 import shop.alien.mapper.second.SecondRiskControlRecordMapper;
 import shop.alien.mapper.second.SecondUserCreditMapper;
 import shop.alien.mapper.second.SecondUserCreditRecordMapper;

+ 38 - 5
alien-store/src/main/java/shop/alien/store/controller/OceanEngineAdTrackController.java

@@ -1,17 +1,15 @@
 package shop.alien.store.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
-import io.swagger.annotations.Api;
-import io.swagger.annotations.ApiImplicitParam;
-import io.swagger.annotations.ApiImplicitParams;
-import io.swagger.annotations.ApiOperation;
-import io.swagger.annotations.ApiOperationSupport;
+import io.swagger.annotations.*;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.OceanEngineClickMonitorLog;
 import shop.alien.store.dto.OceanEngineClickBindUserDto;
+import shop.alien.store.dto.OceanEngineConversionResultVo;
+import shop.alien.store.service.LifeUserService;
 import shop.alien.store.service.OceanEngineClickMonitorLogService;
 
 import javax.servlet.http.HttpServletRequest;
@@ -30,6 +28,8 @@ public class OceanEngineAdTrackController {
 
     private final OceanEngineClickMonitorLogService clickMonitorLogService;
 
+    private final LifeUserService lifeUserService;
+
     /**
      * 点击监测链接接收入口(巨量引擎以 GET 方式调用)
      * 配置示例:https://your-domain.com/oceanEngine/click/monitor?clickid=__CLICKID__&callback=__CALLBACK_PARAM__&...
@@ -78,4 +78,37 @@ public class OceanEngineAdTrackController {
             @RequestParam(required = false) Integer bindStatus) {
         return clickMonitorLogService.list(pageNum, pageSize, clickId, advertiserId, userId, bindStatus);
     }
+
+    @ApiOperation("转化事件回传巨量引擎(注册/付费等)")
+    @ApiOperationSupport(order = 5)
+    @PostMapping("/conversion/report")
+    public R<OceanEngineConversionResultVo> reportConversion(@RequestBody OceanEngineClickMonitorLog clickLog) {
+        return clickMonitorLogService.reportConversion(clickLog);
+    }
+
+
+    @ApiOperation("保存用户设备ID(idfa/oaid),巨量引擎使用")
+    @ApiOperationSupport
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name= "userType", value= "用户类型1,用户;2,商户;3,律师", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "oceanIdfaOrOaid", value = "巨量引擎点击监测标签idfa/oaid", dataType = "String", paramType = "query", required = true)
+    })
+    @PostMapping("/saveOceanIdfaOrOaid")
+    public R<String> saveOceanIdfaOrOaid(@RequestParam("id") Integer id,
+                                         @RequestParam("userType") Integer userType,
+                                         @RequestParam("oceanIdfaOrOaid") String oceanIdfaOrOaid) {
+        log.info("LifeUserController.saveOceanIdfaOrOaid?id={}&userType={}&oceanIdfaOrOaid={}", id, userType, oceanIdfaOrOaid);
+        if( userType == 1) {
+            return lifeUserService.saveOceanIdfaOrOaid(id, oceanIdfaOrOaid);
+        } else if (userType == 2) {
+            // TODO 目前不做处理 留口
+//            return storeUserService.saveOceanIdfaOrOaid(id, oceanIdfaOrOaid);
+        } else if (userType == 3) {
+            // TODO 目前不做处理
+        }
+        return R.fail("用户类型错误");
+    }
+
+
 }

+ 31 - 0
alien-store/src/main/java/shop/alien/store/dto/OceanEngineConversionResultVo.java

@@ -0,0 +1,31 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 巨量引擎转化回传结果
+ */
+@Data
+@ApiModel(value = "OceanEngineConversionResultVo", description = "巨量引擎转化回传结果")
+public class OceanEngineConversionResultVo {
+
+    @ApiModelProperty(value = "是否回传成功")
+    private Boolean success;
+
+    @ApiModelProperty(value = "HTTP 状态码")
+    private Integer httpCode;
+
+    @ApiModelProperty(value = "实际使用的 callback")
+    private String callback;
+
+    @ApiModelProperty(value = "请求体 JSON")
+    private String requestBody;
+
+    @ApiModelProperty(value = "响应体")
+    private String responseBody;
+
+    @ApiModelProperty(value = "提示信息")
+    private String message;
+}

+ 41 - 7
alien-store/src/main/java/shop/alien/store/service/LifeUserService.java

@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import shop.alien.config.properties.RiskControlProperties;
+import shop.alien.entity.result.R;
 import shop.alien.entity.second.LifeUserLog;
 import shop.alien.entity.second.SecondGoods;
 import shop.alien.entity.second.SecondRiskControlRecord;
@@ -33,20 +34,16 @@ import shop.alien.entity.store.vo.LifeMessageVo;
 import shop.alien.entity.store.vo.LifeUserVo;
 import shop.alien.entity.store.vo.WebSocketVo;
 import shop.alien.mapper.*;
-import shop.alien.mapper.second.LifeUserLogMapper;
-import shop.alien.mapper.second.SecondGoodsMapper;
-import shop.alien.mapper.second.SecondRiskControlRecordMapper;
-import shop.alien.mapper.second.SecondTradeRecordMapper;
-import shop.alien.mapper.second.SecondUserCreditMapper;
-import shop.alien.util.common.constant.LawyerStatusEnum;
+import shop.alien.mapper.second.*;
 import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.config.WebSocketProcess;
 import shop.alien.store.feign.SecondServiceFeign;
 import shop.alien.store.service.clockin.ClockInRecommendCacheService;
 import shop.alien.store.service.dynamics.DynamicsRecommendCacheService;
 import shop.alien.store.util.FunctionMagic;
-import shop.alien.util.type.LifeFansIdentityQuery;
 import shop.alien.store.util.LifeDynamicsIdentityHelper;
+import shop.alien.util.common.constant.LawyerStatusEnum;
+import shop.alien.util.type.LifeFansIdentityQuery;
 import shop.alien.util.type.LifeNoticeUtil;
 import shop.alien.util.type.TypeUtil;
 
@@ -156,6 +153,11 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
 
     private final LawyerConsultationOrderMapper lawyerConsultationOrderMapper;
 
+
+    private final OceanEngineClickMonitorLogService oceanEngineClickMonitorLogService;
+
+
+
     @Autowired
     private RiskControlProperties riskControlProperties;
 
@@ -784,4 +786,36 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
         Long count = lifeUserMapper.countTotalDeviceNum();
         return count == null ? 0L : count;
     }
+
+    public R<String> saveOceanIdfaOrOaid(Integer id, String oceanIdfaOrOaid) {
+        LifeUser user = lifeUserMapper.selectById(id);
+        if(user == null) {
+            return R.fail("用户不存在");
+        }
+        user.setOceanIdfaOrOaid(oceanIdfaOrOaid);
+        lifeUserMapper.updateById(user);
+        // 1.查看巨量引擎相关记录
+        LambdaQueryWrapper<OceanEngineClickMonitorLog> queryWrapper = new LambdaQueryWrapper<>();
+// 1. 先拼接多字段or匹配(OAID/OAID_MD5/AndroidId/IDFA任一相等)
+        queryWrapper.and(
+                wrapper -> wrapper.eq(OceanEngineClickMonitorLog::getOaid, oceanIdfaOrOaid)
+                        .or()
+                        .eq(OceanEngineClickMonitorLog::getOaidMd5, oceanIdfaOrOaid)
+                        .or()
+                        .eq(OceanEngineClickMonitorLog::getAndroidId, oceanIdfaOrOaid)
+                        .or()
+                        .eq(OceanEngineClickMonitorLog::getIdfa, oceanIdfaOrOaid)
+        );
+// 2. 增加创建时间 >= 当前时间 - 1小时(核心:近一小时)
+        LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
+        queryWrapper.ge(OceanEngineClickMonitorLog::getCreatedTime, oneHourAgo);
+        List<OceanEngineClickMonitorLog> list = oceanEngineClickMonitorLogService.list(queryWrapper);
+        if(list.size() > 0) {
+            // 2.有相关记录,调用转化回传
+            list.forEach(oceanEngineClickMonitorLog -> {
+                oceanEngineClickMonitorLogService.reportConversion(oceanEngineClickMonitorLog);
+            });
+        }
+        return R.success("保存成功");
+    }
 }

+ 8 - 0
alien-store/src/main/java/shop/alien/store/service/OceanEngineClickMonitorLogService.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.OceanEngineClickMonitorLog;
 import shop.alien.store.dto.OceanEngineClickBindUserDto;
+import shop.alien.store.dto.OceanEngineConversionResultVo;
 
 import javax.servlet.http.HttpServletRequest;
 
@@ -37,4 +38,11 @@ public interface OceanEngineClickMonitorLogService extends IService<OceanEngineC
     R<IPage<OceanEngineClickMonitorLog>> list(Integer pageNum, Integer pageSize,
                                               String clickId, String advertiserId,
                                               Integer userId, Integer bindStatus);
+
+    /**
+     * 转化事件回传巨量引擎(POST https://analytics.oceanengine.com/api/v2/conversion)
+     *
+     * @param clickLog 点击监测记录(可只传 id,或传 callbackParam/androidId 等完整字段)
+     */
+    R<OceanEngineConversionResultVo> reportConversion(OceanEngineClickMonitorLog clickLog);
 }

+ 339 - 0
alien-store/src/main/java/shop/alien/store/service/impl/OceanEngineClickMonitorLogServiceImpl.java

@@ -8,14 +8,22 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.OceanEngineClickMonitorLog;
 import shop.alien.mapper.OceanEngineClickMonitorLogMapper;
 import shop.alien.store.dto.OceanEngineClickBindUserDto;
+import shop.alien.store.dto.OceanEngineConversionResultVo;
 import shop.alien.store.service.OceanEngineClickMonitorLogService;
 
 import javax.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import java.util.Date;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
@@ -31,6 +39,14 @@ public class OceanEngineClickMonitorLogServiceImpl
         extends ServiceImpl<OceanEngineClickMonitorLogMapper, OceanEngineClickMonitorLog>
         implements OceanEngineClickMonitorLogService {
 
+    private static final int HTTP_TIMEOUT_MS = 10000;
+    private static final String DEFAULT_EVENT_TYPE = "active_register";
+    private static final int DEFAULT_MATCH_TYPE = 0;
+
+    /** 巨量引擎转化回传 API 地址 */
+    @Value("${oceanengine.conversion.report-url:https://analytics.oceanengine.com/api/v2/conversion}")
+    private String conversionReportUrl;
+
     @Override
     public R<Long> receiveClickMonitor(HttpServletRequest request) {
         Map<String, String> paramMap = extractQueryParams(request);
@@ -119,6 +135,329 @@ public class OceanEngineClickMonitorLogServiceImpl
     }
 
     /**
+     * 转化事件回传巨量引擎
+     * <p>
+     * 官方接口:POST https://analytics.oceanengine.com/api/v2/conversion
+     * </p>
+     * <p>排查日志关键字:{@code [OceanEngineConversion]}</p>
+     * <p>入参支持:id / clickId / userId / callbackParam 查库,或直接传完整点击记录</p>
+     */
+    @Override
+    public R<OceanEngineConversionResultVo> reportConversion(OceanEngineClickMonitorLog clickLogParam) {
+        log.info("[OceanEngineConversion] 开始转化回传, 入参 id={}, clickId={}, userId={}, eventType={}, matchType={}",
+                clickLogParam != null ? clickLogParam.getId() : null,
+                clickLogParam != null ? clickLogParam.getClickId() : null,
+                clickLogParam != null ? clickLogParam.getUserId() : null,
+                clickLogParam != null ? clickLogParam.getEventType() : null,
+                clickLogParam != null ? clickLogParam.getMatchType() : null);
+
+        if (clickLogParam == null) {
+            log.warn("[OceanEngineConversion] 失败-入参为空");
+            return R.fail("点击监测记录不能为空");
+        }
+
+        // 步骤1:解析/合并点击监测记录(库查 + 入参覆盖)
+        OceanEngineClickMonitorLog clickLog = resolveClickLogForReport(clickLogParam);
+        if (clickLog == null) {
+            log.warn("[OceanEngineConversion] 失败-未找到点击记录, id={}, clickId={}, userId={}, callbackParam={}",
+                    clickLogParam.getId(), clickLogParam.getClickId(), clickLogParam.getUserId(),
+                    maskSensitive(clickLogParam.getCallbackParam()));
+            return R.fail("未找到点击监测记录,请传入 id / clickId / userId 或完整 callbackParam");
+        }
+        log.info("[OceanEngineConversion] 步骤1-点击记录已解析, clickLogId={}, advertiserId={}, promotionId={}, projectId={}",
+                clickLog.getId(), clickLog.getAdvertiserId(), clickLog.getPromotionId(), clickLog.getProjectId());
+
+        // 步骤2:提取归因 callback(巨量匹配核心字段)
+        String eventType = StringUtils.defaultIfBlank(clickLogParam.getEventType(), DEFAULT_EVENT_TYPE);
+        String callback = resolveCallbackFromClickLog(clickLog);
+        if (StringUtils.isBlank(callback)) {
+            log.warn("[OceanEngineConversion] 失败-callback为空, clickLogId={}, callbackParam={}, clickId={}",
+                    clickLog.getId(), maskSensitive(clickLog.getCallbackParam()), clickLog.getClickId());
+            return R.fail("callback 不能为空,点击记录需包含 callbackParam 或 clickId");
+        }
+        log.info("[OceanEngineConversion] 步骤2-callback已解析, source={}, callback={}",
+                StringUtils.isNotBlank(clickLog.getCallbackParam()) ? "callbackParam" : "clickId",
+                maskSensitive(callback));
+
+        // 步骤3:组装回传参数
+        int matchType = clickLogParam.getMatchType() != null ? clickLogParam.getMatchType() : DEFAULT_MATCH_TYPE;
+        long timestamp = resolveConversionTimestamp(clickLogParam, clickLog);
+        log.info("[OceanEngineConversion] 步骤3-回传参数, eventType={}, matchType={}, timestamp={}, androidId={}, idfa={}, oaid={}, imei={}",
+                eventType, matchType, timestamp,
+                maskSensitive(clickLog.getAndroidId()), maskSensitive(clickLog.getIdfa()),
+                maskSensitive(clickLog.getOaid()), maskSensitive(clickLog.getImei()));
+
+        JSONObject requestBody = buildConversionRequestBody(eventType, callback, matchType,
+                clickLog.getAndroidId(), clickLog.getIdfa(), clickLog.getOaid(), clickLog.getImei(), timestamp);
+        String requestBodyJson = requestBody.toJSONString();
+
+        OceanEngineConversionResultVo resultVo = new OceanEngineConversionResultVo();
+        resultVo.setCallback(callback);
+        resultVo.setRequestBody(requestBodyJson);
+
+        // 步骤4:HTTP POST 调用巨量转化回传接口
+        HttpURLConnection connection = null;
+        try {
+            log.info("[OceanEngineConversion] 步骤4-发起HTTP请求, url={}, bodyLength={}",
+                    conversionReportUrl, requestBodyJson.length());
+            log.debug("[OceanEngineConversion] 请求体详情, body={}", requestBodyJson);
+
+            URL url = new URL(conversionReportUrl);
+            connection = (HttpURLConnection) url.openConnection();
+            connection.setRequestMethod("POST");
+            connection.setConnectTimeout(HTTP_TIMEOUT_MS);
+            connection.setReadTimeout(HTTP_TIMEOUT_MS);
+            connection.setDoOutput(true);
+            connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+
+            byte[] bodyBytes = requestBodyJson.getBytes(StandardCharsets.UTF_8);
+            connection.setRequestProperty("Content-Length", String.valueOf(bodyBytes.length));
+            try (OutputStream outputStream = connection.getOutputStream()) {
+                outputStream.write(bodyBytes);
+                outputStream.flush();
+            }
+
+            int httpCode = connection.getResponseCode();
+            String responseBody = readHttpResponse(connection, httpCode);
+            boolean success = httpCode >= 200 && httpCode < 300 && isOceanEngineSuccess(responseBody);
+
+            resultVo.setHttpCode(httpCode);
+            resultVo.setResponseBody(responseBody);
+            resultVo.setSuccess(success);
+            resultVo.setMessage(success ? "转化回传成功" : "转化回传失败");
+
+            if (success) {
+                log.info("[OceanEngineConversion] 步骤5-回传成功, clickLogId={}, userId={}, eventType={}, httpCode={}, response={}",
+                        clickLog.getId(), clickLog.getUserId(), eventType, httpCode, responseBody);
+            } else {
+                log.error("[OceanEngineConversion] 步骤5-回传失败, clickLogId={}, userId={}, eventType={}, httpCode={}, response={}, requestBody={}",
+                        clickLog.getId(), clickLog.getUserId(), eventType, httpCode, responseBody, requestBodyJson);
+            }
+            return R.data(resultVo, success ? "转化回传成功" : "转化回传失败");
+        } catch (Exception ex) {
+            log.error("[OceanEngineConversion] 步骤5-回传异常, clickLogId={}, eventType={}, callback={}, url={}, requestBody={}",
+                    clickLog.getId(), eventType, maskSensitive(callback), conversionReportUrl, requestBodyJson, ex);
+            resultVo.setSuccess(false);
+            resultVo.setMessage(ex.getMessage());
+            return R.data(resultVo, "转化回传异常:" + ex.getMessage());
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 构建巨量转化回传请求体
+     */
+    private JSONObject buildConversionRequestBody(String eventType, String callback, int matchType,
+                                                  String androidId, String idfa, String oaid, String imei,
+                                                  long timestamp) {
+        JSONObject adContext = new JSONObject();
+        adContext.put("callback", callback);
+        adContext.put("match_type", matchType);
+
+        JSONObject deviceContext = new JSONObject();
+        if (StringUtils.isNotBlank(androidId)) {
+            deviceContext.put("android_id", androidId);
+        }
+        if (StringUtils.isNotBlank(idfa)) {
+            deviceContext.put("idfa", idfa);
+        }
+        if (StringUtils.isNotBlank(oaid)) {
+            deviceContext.put("oaid", oaid);
+        }
+        if (StringUtils.isNotBlank(imei)) {
+            deviceContext.put("imei", imei);
+        }
+
+        JSONObject context = new JSONObject();
+        context.put("ad", adContext);
+        if (!deviceContext.isEmpty()) {
+            context.put("device", deviceContext);
+        }
+
+        JSONObject body = new JSONObject();
+        body.put("event_type", eventType);
+        body.put("context", context);
+        body.put("timestamp", timestamp);
+        return body;
+    }
+
+    /**
+     * 解析用于回传的点击记录
+     * <p>查找优先级:id → clickId → userId(已绑定) → callbackParam → 入参直传</p>
+     */
+    private OceanEngineClickMonitorLog resolveClickLogForReport(OceanEngineClickMonitorLog clickLogParam) {
+        OceanEngineClickMonitorLog dbLog = null;
+        String resolveBy = null;
+
+        if (clickLogParam.getId() != null) {
+            resolveBy = "id";
+            dbLog = this.getById(clickLogParam.getId());
+            log.info("[OceanEngineConversion] 按id查库, id={}, found={}", clickLogParam.getId(), dbLog != null);
+        } else if (StringUtils.isNotBlank(clickLogParam.getClickId())) {
+            resolveBy = "clickId";
+            dbLog = findClickLog(clickLogParam.getClickId(), clickLogParam.getCallbackParam());
+            log.info("[OceanEngineConversion] 按clickId查库, clickId={}, found={}",
+                    clickLogParam.getClickId(), dbLog != null);
+        } else if (clickLogParam.getUserId() != null) {
+            resolveBy = "userId";
+            dbLog = this.getOne(new LambdaQueryWrapper<OceanEngineClickMonitorLog>()
+                    .eq(OceanEngineClickMonitorLog::getUserId, clickLogParam.getUserId())
+                    .eq(OceanEngineClickMonitorLog::getBindStatus, OceanEngineClickMonitorLog.BIND_STATUS_BOUND)
+                    .orderByDesc(OceanEngineClickMonitorLog::getBindTime)
+                    .last("LIMIT 1"));
+            log.info("[OceanEngineConversion] 按userId查已绑定记录, userId={}, found={}",
+                    clickLogParam.getUserId(), dbLog != null);
+        } else if (StringUtils.isNotBlank(clickLogParam.getCallbackParam())) {
+            resolveBy = "callbackParam";
+            dbLog = findClickLog(null, clickLogParam.getCallbackParam());
+            log.info("[OceanEngineConversion] 按callbackParam查库, callback={}, found={}",
+                    maskSensitive(clickLogParam.getCallbackParam()), dbLog != null);
+        }
+
+        if (dbLog == null) {
+            // 库中无记录时,入参自带 callback/clickId 则直接使用(联调/补报场景)
+            if (StringUtils.isNotBlank(clickLogParam.getCallbackParam())
+                    || StringUtils.isNotBlank(clickLogParam.getClickId())) {
+                log.info("[OceanEngineConversion] 库无记录,使用入参直传, clickId={}, callback={}",
+                        clickLogParam.getClickId(), maskSensitive(clickLogParam.getCallbackParam()));
+                return clickLogParam;
+            }
+            log.warn("[OceanEngineConversion] 查库失败且无直传字段, resolveBy={}", resolveBy);
+            return null;
+        }
+
+        OceanEngineClickMonitorLog merged = mergeClickLogForReport(dbLog, clickLogParam);
+        log.info("[OceanEngineConversion] 记录合并完成, resolveBy={}, clickLogId={}", resolveBy, merged.getId());
+        return merged;
+    }
+
+    /**
+     * 库记录与入参合并:入参非空字段覆盖库记录(设备信息、callback 等)
+     */
+    private OceanEngineClickMonitorLog mergeClickLogForReport(OceanEngineClickMonitorLog dbLog,
+                                                              OceanEngineClickMonitorLog param) {
+        if (StringUtils.isNotBlank(param.getCallbackParam())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 callbackParam");
+            dbLog.setCallbackParam(param.getCallbackParam());
+        }
+        if (StringUtils.isNotBlank(param.getClickId())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 clickId");
+            dbLog.setClickId(param.getClickId());
+        }
+        if (StringUtils.isNotBlank(param.getAndroidId())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 androidId");
+            dbLog.setAndroidId(param.getAndroidId());
+        }
+        if (StringUtils.isNotBlank(param.getIdfa())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 idfa");
+            dbLog.setIdfa(param.getIdfa());
+        }
+        if (StringUtils.isNotBlank(param.getOaid())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 oaid");
+            dbLog.setOaid(param.getOaid());
+        }
+        if (StringUtils.isNotBlank(param.getImei())) {
+            log.debug("[OceanEngineConversion] 合并覆盖 imei");
+            dbLog.setImei(param.getImei());
+        }
+        if (param.getUserId() != null) {
+            dbLog.setUserId(param.getUserId());
+        }
+        return dbLog;
+    }
+
+    /**
+     * 从点击记录解析 callback:优先 callbackParam,其次 clickId
+     */
+    private String resolveCallbackFromClickLog(OceanEngineClickMonitorLog clickLog) {
+        if (StringUtils.isNotBlank(clickLog.getCallbackParam())) {
+            return clickLog.getCallbackParam();
+        }
+        return clickLog.getClickId();
+    }
+
+    /**
+     * 解析转化回传时间戳(毫秒)
+     * <p>优先级:conversionTimestamp → clickTsMs → clickTs*1000 → 当前时间</p>
+     */
+    private long resolveConversionTimestamp(OceanEngineClickMonitorLog clickLogParam,
+                                            OceanEngineClickMonitorLog clickLog) {
+        if (clickLogParam.getConversionTimestamp() != null) {
+            log.debug("[OceanEngineConversion] 时间戳来源=conversionTimestamp, value={}",
+                    clickLogParam.getConversionTimestamp());
+            return clickLogParam.getConversionTimestamp();
+        }
+        if (clickLog.getClickTsMs() != null) {
+            log.debug("[OceanEngineConversion] 时间戳来源=clickTsMs, value={}", clickLog.getClickTsMs());
+            return clickLog.getClickTsMs();
+        }
+        if (clickLog.getClickTs() != null) {
+            long tsMs = clickLog.getClickTs() * 1000L;
+            log.debug("[OceanEngineConversion] 时间戳来源=clickTs(秒转毫秒), clickTs={}, tsMs={}",
+                    clickLog.getClickTs(), tsMs);
+            return tsMs;
+        }
+        long now = System.currentTimeMillis();
+        log.debug("[OceanEngineConversion] 时间戳来源=当前时间, value={}", now);
+        return now;
+    }
+
+    /**
+     * 判断巨量引擎业务响应是否成功(HTTP 200 且 body.code 为 0 或空)
+     */
+    private boolean isOceanEngineSuccess(String responseBody) {
+        if (StringUtils.isBlank(responseBody)) {
+            log.debug("[OceanEngineConversion] 响应体为空,视为成功");
+            return true;
+        }
+        try {
+            JSONObject json = JSONObject.parseObject(responseBody);
+            Integer code = json.getInteger("code");
+            String message = json.getString("message");
+            boolean success = code == null || code == 0;
+            if (!success) {
+                log.warn("[OceanEngineConversion] 巨量业务码非0, code={}, message={}, response={}",
+                        code, message, responseBody);
+            }
+            return success;
+        } catch (Exception ex) {
+            log.warn("[OceanEngineConversion] 响应体解析失败,视为成功, response={}", responseBody, ex);
+            return true;
+        }
+    }
+
+    /**
+     * 敏感字段脱敏输出(日志排查用,保留前6后4位)
+     */
+    private String maskSensitive(String value) {
+        if (StringUtils.isBlank(value)) {
+            return "";
+        }
+        if (value.length() <= 12) {
+            return value.substring(0, 1) + "***" + "(len=" + value.length() + ")";
+        }
+        return value.substring(0, 6) + "***" + value.substring(value.length() - 4) + "(len=" + value.length() + ")";
+    }
+
+    private String readHttpResponse(HttpURLConnection connection, int httpCode) throws Exception {
+        java.io.InputStream inputStream = httpCode >= 400 ? connection.getErrorStream() : connection.getInputStream();
+        if (inputStream == null) {
+            return "";
+        }
+        StringBuilder builder = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                builder.append(line);
+            }
+        }
+        return builder.toString();
+    }
+
+    /**
      * 从 HttpServletRequest 提取全部 Query 参数
      */
     private Map<String, String> extractQueryParams(HttpServletRequest request) {