|
|
@@ -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) {
|