Bläddra i källkod

feat:巨量引擎

刘云鑫 9 timmar sedan
förälder
incheckning
d866a77372

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

@@ -0,0 +1,218 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 巨量引擎点击监测日志表
+ *
+ * @author system
+ * @since 2025-06-25
+ */
+@Data
+@JsonInclude
+@TableName("ocean_engine_click_monitor_log")
+@ApiModel(value = "OceanEngineClickMonitorLog对象", description = "巨量引擎点击监测日志表")
+public class OceanEngineClickMonitorLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** bind_status:未绑定 */
+    public static final int BIND_STATUS_UNBOUND = 0;
+    /** bind_status:已绑定 */
+    public static final int BIND_STATUS_BOUND = 1;
+    /** bind_status:绑定失败 */
+    public static final int BIND_STATUS_FAILED = 2;
+
+    /** ad_version:巨量广告 1.0 */
+    public static final int AD_VERSION_V1 = 1;
+    /** ad_version:巨量广告 2.0 */
+    public static final int AD_VERSION_V2 = 2;
+
+    @ApiModelProperty(value = "主键")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty(value = "点击唯一标识")
+    @TableField("click_id")
+    private String clickId;
+
+    @ApiModelProperty(value = "归因回调参数")
+    @TableField("callback_param")
+    private String callbackParam;
+
+    @ApiModelProperty(value = "请求下发ID")
+    @TableField("request_id")
+    private String requestId;
+
+    @ApiModelProperty(value = "广告主账户ID")
+    @TableField("advertiser_id")
+    private String advertiserId;
+
+    @ApiModelProperty(value = "广告版本:1-1.0, 2-2.0")
+    @TableField("ad_version")
+    private Integer adVersion;
+
+    @ApiModelProperty(value = "广告组ID(1.0)")
+    @TableField("campaign_id")
+    private String campaignId;
+
+    @ApiModelProperty(value = "广告计划ID(1.0)")
+    @TableField("aid")
+    private String aid;
+
+    @ApiModelProperty(value = "广告创意ID(1.0)")
+    @TableField("cid")
+    private String cid;
+
+    @ApiModelProperty(value = "项目ID(2.0)")
+    @TableField("project_id")
+    private String projectId;
+
+    @ApiModelProperty(value = "广告ID(2.0)")
+    @TableField("promotion_id")
+    private String promotionId;
+
+    @ApiModelProperty(value = "转化目标ID")
+    @TableField("convert_id")
+    private String convertId;
+
+    @ApiModelProperty(value = "广告投放位置")
+    @TableField("csite")
+    private String csite;
+
+    @ApiModelProperty(value = "联盟站点")
+    @TableField("union_site")
+    private String unionSite;
+
+    @ApiModelProperty(value = "图片素材ID")
+    @TableField("mid1")
+    private String mid1;
+
+    @ApiModelProperty(value = "标题素材ID")
+    @TableField("mid2")
+    private String mid2;
+
+    @ApiModelProperty(value = "视频素材ID")
+    @TableField("mid3")
+    private String mid3;
+
+    @ApiModelProperty(value = "试玩素材ID")
+    @TableField("mid4")
+    private String mid4;
+
+    @ApiModelProperty(value = "落地页素材ID")
+    @TableField("mid5")
+    private String mid5;
+
+    @ApiModelProperty(value = "安卓下载详情页ID")
+    @TableField("mid6")
+    private String mid6;
+
+    @ApiModelProperty(value = "点击时间戳(秒)")
+    @TableField("click_ts")
+    private Long clickTs;
+
+    @ApiModelProperty(value = "点击时间戳(毫秒)")
+    @TableField("click_ts_ms")
+    private Long clickTsMs;
+
+    @ApiModelProperty(value = "客户端IP")
+    @TableField("client_ip")
+    private String clientIp;
+
+    @ApiModelProperty(value = "IPv4")
+    @TableField("client_ipv4")
+    private String clientIpv4;
+
+    @ApiModelProperty(value = "IPv6")
+    @TableField("client_ipv6")
+    private String clientIpv6;
+
+    @ApiModelProperty(value = "User-Agent")
+    @TableField("user_agent")
+    private String userAgent;
+
+    @ApiModelProperty(value = "操作系统")
+    @TableField("os_type")
+    private String osType;
+
+    @ApiModelProperty(value = "设备型号")
+    @TableField("device_model")
+    private String deviceModel;
+
+    @ApiModelProperty(value = "iOS设备标识")
+    @TableField("idfa")
+    private String idfa;
+
+    @ApiModelProperty(value = "Android IMEI")
+    @TableField("imei")
+    private String imei;
+
+    @ApiModelProperty(value = "Android OAID")
+    @TableField("oaid")
+    private String oaid;
+
+    @ApiModelProperty(value = "OAID MD5")
+    @TableField("oaid_md5")
+    private String oaidMd5;
+
+    @ApiModelProperty(value = "Android ID")
+    @TableField("android_id")
+    private String androidId;
+
+    @ApiModelProperty(value = "关联用户ID")
+    @TableField("user_id")
+    private Integer userId;
+
+    @ApiModelProperty(value = "关联门店ID")
+    @TableField("store_id")
+    private Integer storeId;
+
+    @ApiModelProperty(value = "绑定状态:0-未绑定, 1-已绑定, 2-绑定失败")
+    @TableField("bind_status")
+    private Integer bindStatus;
+
+    @ApiModelProperty(value = "绑定时间")
+    @TableField("bind_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date bindTime;
+
+    @ApiModelProperty(value = "实际收到的完整监测URL")
+    @TableField("monitor_url")
+    private String monitorUrl;
+
+    @ApiModelProperty(value = "原始Query参数JSON")
+    @TableField("raw_params")
+    private String rawParams;
+
+    @ApiModelProperty(value = "服务端接收请求来源IP")
+    @TableField("server_ip")
+    private String serverIp;
+
+    @ApiModelProperty(value = "备注")
+    @TableField("remark")
+    private String remark;
+
+    @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty(value = "创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty(value = "修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+}

+ 13 - 0
alien-entity/src/main/java/shop/alien/mapper/OceanEngineClickMonitorLogMapper.java

@@ -0,0 +1,13 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.OceanEngineClickMonitorLog;
+
+/**
+ * 巨量引擎点击监测日志 Mapper
+ *
+ * @author system
+ * @since 2025-06-25
+ */
+public interface OceanEngineClickMonitorLogMapper extends BaseMapper<OceanEngineClickMonitorLog> {
+}

+ 81 - 0
alien-store/src/main/java/shop/alien/store/controller/OceanEngineAdTrackController.java

@@ -0,0 +1,81 @@
+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 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.service.OceanEngineClickMonitorLogService;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 巨量引擎广告点击监测
+ * 文档:https://open.oceanengine.com/labels/7/docs/1696710655781900
+ */
+@Api(tags = {"巨量引擎广告监测"})
+@Slf4j
+@RestController
+@CrossOrigin
+@RequestMapping("/oceanEngine")
+@RequiredArgsConstructor
+public class OceanEngineAdTrackController {
+
+    private final OceanEngineClickMonitorLogService clickMonitorLogService;
+
+    /**
+     * 点击监测链接接收入口(巨量引擎以 GET 方式调用)
+     * 配置示例:https://your-domain.com/oceanEngine/click/monitor?clickid=__CLICKID__&callback=__CALLBACK_PARAM__&...
+     */
+    @ApiOperation("点击监测链接接收(巨量引擎 GET 回调)")
+    @ApiOperationSupport(order = 1)
+    @GetMapping("/click/monitor")
+    public R<Long> receiveClickMonitor(HttpServletRequest request) {
+        return clickMonitorLogService.receiveClickMonitor(request);
+    }
+
+    @ApiOperation("点击记录绑定用户")
+    @ApiOperationSupport(order = 2)
+    @PostMapping("/click/bindUser")
+    public R<String> bindUser(@RequestBody OceanEngineClickBindUserDto bindDto) {
+        return clickMonitorLogService.bindUser(bindDto);
+    }
+
+    @ApiOperation("根据主键查询点击监测记录")
+    @ApiOperationSupport(order = 3)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "id", value = "主键ID", dataType = "Long", paramType = "query", required = true)
+    })
+    @GetMapping("/click/getById")
+    public R<OceanEngineClickMonitorLog> getClickById(@RequestParam Long id) {
+        return clickMonitorLogService.getInfoById(id);
+    }
+
+    @ApiOperation("点击监测记录分页列表")
+    @ApiOperationSupport(order = 4)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "clickId", value = "点击ID", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "advertiserId", value = "广告主ID", dataType = "String", paramType = "query"),
+            @ApiImplicitParam(name = "userId", value = "用户ID", dataType = "Integer", paramType = "query"),
+            @ApiImplicitParam(name = "bindStatus", value = "绑定状态", dataType = "Integer", paramType = "query")
+    })
+    @GetMapping("/click/list")
+    public R<IPage<OceanEngineClickMonitorLog>> clickList(
+            @RequestParam Integer pageNum,
+            @RequestParam Integer pageSize,
+            @RequestParam(required = false) String clickId,
+            @RequestParam(required = false) String advertiserId,
+            @RequestParam(required = false) Integer userId,
+            @RequestParam(required = false) Integer bindStatus) {
+        return clickMonitorLogService.list(pageNum, pageSize, clickId, advertiserId, userId, bindStatus);
+    }
+}

+ 25 - 0
alien-store/src/main/java/shop/alien/store/dto/OceanEngineClickBindUserDto.java

@@ -0,0 +1,25 @@
+package shop.alien.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 巨量引擎点击监测绑定用户请求
+ */
+@Data
+@ApiModel(value = "OceanEngineClickBindUserDto", description = "点击监测绑定用户")
+public class OceanEngineClickBindUserDto {
+
+    @ApiModelProperty(value = "点击ID(click_id 与 callback_param 至少填一个)")
+    private String clickId;
+
+    @ApiModelProperty(value = "回调参数")
+    private String callbackParam;
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    private Integer userId;
+
+    @ApiModelProperty(value = "门店ID")
+    private Integer storeId;
+}

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

@@ -0,0 +1,40 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+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 javax.servlet.http.HttpServletRequest;
+
+/**
+ * 巨量引擎点击监测服务
+ */
+public interface OceanEngineClickMonitorLogService extends IService<OceanEngineClickMonitorLog> {
+
+    /**
+     * 接收巨量引擎点击监测 GET 请求并落库
+     *
+     * @param request 原始 HTTP 请求(含全部 Query 参数)
+     * @return 处理结果
+     */
+    R<Long> receiveClickMonitor(HttpServletRequest request);
+
+    /**
+     * 将点击记录绑定到业务用户
+     */
+    R<String> bindUser(OceanEngineClickBindUserDto bindDto);
+
+    /**
+     * 根据主键查询
+     */
+    R<OceanEngineClickMonitorLog> getInfoById(Long id);
+
+    /**
+     * 分页查询点击监测日志
+     */
+    R<IPage<OceanEngineClickMonitorLog>> list(Integer pageNum, Integer pageSize,
+                                              String clickId, String advertiserId,
+                                              Integer userId, Integer bindStatus);
+}

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

@@ -0,0 +1,278 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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.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.service.OceanEngineClickMonitorLogService;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 巨量引擎点击监测服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OceanEngineClickMonitorLogServiceImpl
+        extends ServiceImpl<OceanEngineClickMonitorLogMapper, OceanEngineClickMonitorLog>
+        implements OceanEngineClickMonitorLogService {
+
+    @Override
+    public R<Long> receiveClickMonitor(HttpServletRequest request) {
+        Map<String, String> paramMap = extractQueryParams(request);
+        log.info("OceanEngineClickMonitorLogServiceImpl.receiveClickMonitor, paramCount={}, queryString={}",
+                paramMap.size(), request.getQueryString());
+
+        String clickId = firstNonBlank(paramMap,
+                "clickid", "click_id", "CLICKID");
+        String callbackParam = firstNonBlank(paramMap,
+                "callback", "callback_param", "CALLBACK_PARAM");
+        String requestId = firstNonBlank(paramMap,
+                "request_id", "req_id", "REQUEST_ID");
+
+        // 幂等:相同 click_id 或 request_id 已存在则直接返回
+        OceanEngineClickMonitorLog existing = findExistingLog(clickId, requestId);
+        if (existing != null) {
+            log.info("OceanEngineClickMonitorLogServiceImpl.receiveClickMonitor, duplicate click, id={}, clickId={}",
+                    existing.getId(), clickId);
+            return R.data(existing.getId(), "重复点击,已忽略");
+        }
+
+        OceanEngineClickMonitorLog clickLog = buildClickLog(request, paramMap, clickId, callbackParam, requestId);
+        boolean saved = this.save(clickLog);
+        if (!saved) {
+            log.error("OceanEngineClickMonitorLogServiceImpl.receiveClickMonitor, save failed, clickId={}", clickId);
+            return R.fail("点击监测日志保存失败");
+        }
+        log.info("OceanEngineClickMonitorLogServiceImpl.receiveClickMonitor, saved id={}, clickId={}, advertiserId={}",
+                clickLog.getId(), clickLog.getClickId(), clickLog.getAdvertiserId());
+        return R.data(clickLog.getId(), "接收成功");
+    }
+
+    @Override
+    public R<String> bindUser(OceanEngineClickBindUserDto bindDto) {
+        log.info("OceanEngineClickMonitorLogServiceImpl.bindUser, param={}", bindDto);
+        if (bindDto == null || bindDto.getUserId() == null) {
+            return R.fail("userId不能为空");
+        }
+        if (StringUtils.isAllBlank(bindDto.getClickId(), bindDto.getCallbackParam())) {
+            return R.fail("clickId与callbackParam至少填一个");
+        }
+
+        OceanEngineClickMonitorLog clickLog = findClickLog(bindDto.getClickId(), bindDto.getCallbackParam());
+        if (clickLog == null) {
+            log.warn("OceanEngineClickMonitorLogServiceImpl.bindUser, click log not found, clickId={}, callbackParam={}",
+                    bindDto.getClickId(), bindDto.getCallbackParam());
+            return R.fail("未找到对应的点击监测记录");
+        }
+
+        clickLog.setUserId(bindDto.getUserId());
+        clickLog.setStoreId(bindDto.getStoreId());
+        clickLog.setBindStatus(OceanEngineClickMonitorLog.BIND_STATUS_BOUND);
+        clickLog.setBindTime(new Date());
+        boolean updated = this.updateById(clickLog);
+        return updated ? R.success("绑定成功") : R.fail("绑定失败");
+    }
+
+    @Override
+    public R<OceanEngineClickMonitorLog> getInfoById(Long id) {
+        log.info("OceanEngineClickMonitorLogServiceImpl.getInfoById, id={}", id);
+        return R.data(this.getById(id));
+    }
+
+    @Override
+    public R<IPage<OceanEngineClickMonitorLog>> list(Integer pageNum, Integer pageSize,
+                                                     String clickId, String advertiserId,
+                                                     Integer userId, Integer bindStatus) {
+        log.info("OceanEngineClickMonitorLogServiceImpl.list, pageNum={}, pageSize={}, clickId={}, advertiserId={}, userId={}, bindStatus={}",
+                pageNum, pageSize, clickId, advertiserId, userId, bindStatus);
+        Page<OceanEngineClickMonitorLog> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<OceanEngineClickMonitorLog> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.isNotBlank(clickId)) {
+            wrapper.eq(OceanEngineClickMonitorLog::getClickId, clickId);
+        }
+        if (StringUtils.isNotBlank(advertiserId)) {
+            wrapper.eq(OceanEngineClickMonitorLog::getAdvertiserId, advertiserId);
+        }
+        if (userId != null) {
+            wrapper.eq(OceanEngineClickMonitorLog::getUserId, userId);
+        }
+        if (bindStatus != null) {
+            wrapper.eq(OceanEngineClickMonitorLog::getBindStatus, bindStatus);
+        }
+        wrapper.orderByDesc(OceanEngineClickMonitorLog::getCreatedTime);
+        return R.data(this.page(page, wrapper));
+    }
+
+    /**
+     * 从 HttpServletRequest 提取全部 Query 参数
+     */
+    private Map<String, String> extractQueryParams(HttpServletRequest request) {
+        Map<String, String> paramMap = new LinkedHashMap<>();
+        Enumeration<String> names = request.getParameterNames();
+        while (names.hasMoreElements()) {
+            String name = names.nextElement();
+            paramMap.put(name, request.getParameter(name));
+        }
+        return paramMap;
+    }
+
+    /**
+     * 构建点击监测日志实体
+     */
+    private OceanEngineClickMonitorLog buildClickLog(HttpServletRequest request,
+                                                     Map<String, String> paramMap,
+                                                     String clickId,
+                                                     String callbackParam,
+                                                     String requestId) {
+        OceanEngineClickMonitorLog clickLog = new OceanEngineClickMonitorLog();
+        clickLog.setClickId(clickId);
+        clickLog.setCallbackParam(callbackParam);
+        clickLog.setRequestId(requestId);
+        clickLog.setAdvertiserId(firstNonBlank(paramMap, "advertiser_id", "ADVERTISER_ID"));
+        clickLog.setCampaignId(firstNonBlank(paramMap, "campaign_id", "CAMPAIGN_ID"));
+        clickLog.setAid(firstNonBlank(paramMap, "aid", "adid", "AID"));
+        clickLog.setCid(firstNonBlank(paramMap, "cid", "creativeid", "CID"));
+        clickLog.setProjectId(firstNonBlank(paramMap, "project_id", "PROJECT_ID"));
+        clickLog.setPromotionId(firstNonBlank(paramMap, "promotion_id", "PROMOTION_ID"));
+        clickLog.setConvertId(firstNonBlank(paramMap, "convert_id", "CONVERT_ID"));
+        clickLog.setCsite(firstNonBlank(paramMap, "csite", "CSITE"));
+        clickLog.setUnionSite(firstNonBlank(paramMap, "union_site", "UNION_SITE"));
+        clickLog.setMid1(firstNonBlank(paramMap, "mid1", "MID1"));
+        clickLog.setMid2(firstNonBlank(paramMap, "mid2", "MID2"));
+        clickLog.setMid3(firstNonBlank(paramMap, "mid3", "MID3"));
+        clickLog.setMid4(firstNonBlank(paramMap, "mid4", "MID4"));
+        clickLog.setMid5(firstNonBlank(paramMap, "mid5", "MID5"));
+        clickLog.setMid6(firstNonBlank(paramMap, "mid6", "MID6"));
+        clickLog.setClickTs(parseLong(firstNonBlank(paramMap, "ts", "TIMESTAMP", "TS")));
+        clickLog.setClickTsMs(parseLong(firstNonBlank(paramMap, "ts_ms", "TS_MS")));
+        clickLog.setClientIp(firstNonBlank(paramMap, "ip", "IP"));
+        clickLog.setClientIpv4(firstNonBlank(paramMap, "ipv4", "IPV4"));
+        clickLog.setClientIpv6(firstNonBlank(paramMap, "ipv6", "IPV6"));
+        clickLog.setUserAgent(firstNonBlank(paramMap, "ua", "UA"));
+        clickLog.setOsType(firstNonBlank(paramMap, "os", "OS"));
+        clickLog.setDeviceModel(firstNonBlank(paramMap, "model", "MODEL"));
+        clickLog.setIdfa(firstNonBlank(paramMap, "idfa", "IDFA"));
+        clickLog.setImei(firstNonBlank(paramMap, "imei", "IMEI"));
+        clickLog.setOaid(firstNonBlank(paramMap, "oaid", "OAID"));
+        clickLog.setOaidMd5(firstNonBlank(paramMap, "oaid_md5", "OAID_MD5"));
+        clickLog.setAndroidId(firstNonBlank(paramMap, "android_id", "ANDROIDID", "ANDROIDID1"));
+        clickLog.setAdVersion(resolveAdVersion(clickLog));
+        clickLog.setBindStatus(OceanEngineClickMonitorLog.BIND_STATUS_UNBOUND);
+        clickLog.setMonitorUrl(buildMonitorUrl(request));
+        clickLog.setRawParams(JSONObject.toJSONString(paramMap));
+        clickLog.setServerIp(resolveClientIp(request));
+        return clickLog;
+    }
+
+    /**
+     * 推断广告版本:2.0 有 project_id/promotion_id,1.0 有 aid/cid/campaign_id
+     */
+    private Integer resolveAdVersion(OceanEngineClickMonitorLog clickLog) {
+        if (StringUtils.isNotBlank(clickLog.getProjectId())
+                || StringUtils.isNotBlank(clickLog.getPromotionId())) {
+            return OceanEngineClickMonitorLog.AD_VERSION_V2;
+        }
+        if (StringUtils.isNotBlank(clickLog.getAid())
+                || StringUtils.isNotBlank(clickLog.getCid())
+                || StringUtils.isNotBlank(clickLog.getCampaignId())) {
+            return OceanEngineClickMonitorLog.AD_VERSION_V1;
+        }
+        return null;
+    }
+
+    private OceanEngineClickMonitorLog findExistingLog(String clickId, String requestId) {
+        if (StringUtils.isNotBlank(clickId)) {
+            OceanEngineClickMonitorLog byClickId = this.getOne(new LambdaQueryWrapper<OceanEngineClickMonitorLog>()
+                    .eq(OceanEngineClickMonitorLog::getClickId, clickId)
+                    .last("LIMIT 1"));
+            if (byClickId != null) {
+                return byClickId;
+            }
+        }
+        if (StringUtils.isNotBlank(requestId)) {
+            return this.getOne(new LambdaQueryWrapper<OceanEngineClickMonitorLog>()
+                    .eq(OceanEngineClickMonitorLog::getRequestId, requestId)
+                    .last("LIMIT 1"));
+        }
+        return null;
+    }
+
+    private OceanEngineClickMonitorLog findClickLog(String clickId, String callbackParam) {
+        if (StringUtils.isNotBlank(clickId)) {
+            OceanEngineClickMonitorLog log = this.getOne(new LambdaQueryWrapper<OceanEngineClickMonitorLog>()
+                    .eq(OceanEngineClickMonitorLog::getClickId, clickId)
+                    .orderByDesc(OceanEngineClickMonitorLog::getCreatedTime)
+                    .last("LIMIT 1"));
+            if (log != null) {
+                return log;
+            }
+        }
+        if (StringUtils.isNotBlank(callbackParam)) {
+            return this.getOne(new LambdaQueryWrapper<OceanEngineClickMonitorLog>()
+                    .eq(OceanEngineClickMonitorLog::getCallbackParam, callbackParam)
+                    .orderByDesc(OceanEngineClickMonitorLog::getCreatedTime)
+                    .last("LIMIT 1"));
+        }
+        return null;
+    }
+
+    private String buildMonitorUrl(HttpServletRequest request) {
+        String queryString = request.getQueryString();
+        if (StringUtils.isBlank(queryString)) {
+            return request.getRequestURL().toString();
+        }
+        return request.getRequestURL() + "?" + queryString;
+    }
+
+    /**
+     * 解析客户端真实 IP(兼容反向代理)
+     */
+    private String resolveClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (StringUtils.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+            return ip.split(",")[0].trim();
+        }
+        ip = request.getHeader("X-Real-IP");
+        if (StringUtils.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+            return ip;
+        }
+        return request.getRemoteAddr();
+    }
+
+    private String firstNonBlank(Map<String, String> paramMap, String... keys) {
+        for (String key : keys) {
+            String value = paramMap.get(key);
+            if (StringUtils.isNotBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private Long parseLong(String value) {
+        if (StringUtils.isBlank(value)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value);
+        } catch (NumberFormatException ex) {
+            log.warn("OceanEngineClickMonitorLogServiceImpl.parseLong, invalid number={}", value);
+            return null;
+        }
+    }
+}