Prechádzať zdrojové kódy

埋点半成品 未完待续

lutong 14 hodín pred
rodič
commit
a815a5f9f7
56 zmenil súbory, kde vykonal 4321 pridanie a 0 odobranie
  1. 47 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsAiChatStat.java
  2. 51 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsAiRequest.java
  3. 68 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsContentStat.java
  4. 136 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsDailySummary.java
  5. 94 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsEvent.java
  6. 34 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsEventCode.java
  7. 61 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsMerchantStat.java
  8. 109 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsScene.java
  9. 64 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsStatJobLog.java
  10. 17 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsStatScope.java
  11. 70 0
      alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsUserStat.java
  12. 16 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiChatDetailDTO.java
  13. 38 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiChatEndDTO.java
  14. 31 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiRequestDTO.java
  15. 21 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsBatchTrackDTO.java
  16. 25 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsCalculateDTO.java
  17. 16 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentDetailDTO.java
  18. 31 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentInteractDTO.java
  19. 38 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentPublishDTO.java
  20. 57 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsFrontReportDTO.java
  21. 31 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsHeartbeatDTO.java
  22. 25 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsMerchantDetailDTO.java
  23. 34 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsMerchantViewDTO.java
  24. 56 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsTrackEventDTO.java
  25. 24 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserDetailDTO.java
  26. 39 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserLoginDTO.java
  27. 42 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserLogoutDTO.java
  28. 38 0
      alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserRegisterDTO.java
  29. 27 0
      alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsContentStatVo.java
  30. 28 0
      alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsMerchantStatVo.java
  31. 21 0
      alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsUserStatVo.java
  32. 9 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsAiChatStatMapper.java
  33. 17 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsAiRequestMapper.java
  34. 9 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsContentStatMapper.java
  35. 9 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsDailySummaryMapper.java
  36. 88 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsEventMapper.java
  37. 17 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsMerchantStatMapper.java
  38. 9 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsStatJobLogMapper.java
  39. 9 0
      alien-entity/src/main/java/shop/alien/mapper/AnalyticsUserStatMapper.java
  40. 207 0
      alien-entity/src/main/resources/db/migration/analytics_tables.sql
  41. 18 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  42. 74 0
      alien-job/src/main/java/shop/alien/job/store/AnalyticsStatisticsJob.java
  43. 260 0
      alien-store/doc/analytics-front-sdk.js
  44. 407 0
      alien-store/doc/前端埋点接入指南.md
  45. 69 0
      alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsDetailController.java
  46. 132 0
      alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsFrontController.java
  47. 65 0
      alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsJobController.java
  48. 129 0
      alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsStatController.java
  49. 17 0
      alien-store/src/main/java/shop/alien/store/service/analytics/AnalyticsStatEnrichService.java
  50. 19 0
      alien-store/src/main/java/shop/alien/store/service/analytics/AnalyticsStatisticsService.java
  51. 40 0
      alien-store/src/main/java/shop/alien/store/service/analytics/AnalyticsTrackService.java
  52. 157 0
      alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsStatEnrichServiceImpl.java
  53. 397 0
      alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsStatisticsServiceImpl.java
  54. 676 0
      alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsTrackServiceImpl.java
  55. 53 0
      alien-store/src/main/java/shop/alien/store/util/analytics/AnalyticsDateUtil.java
  56. 45 0
      alien-store/src/main/java/shop/alien/store/util/analytics/AnalyticsFrontHelper.java

+ 47 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsAiChatStat.java

@@ -0,0 +1,47 @@
+package shop.alien.entity.analytics;
+
+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.util.Date;
+
+/**
+ * AI 对话明细统计实体。
+ *
+ * <p>表名:analytics_ai_chat_stat</p>
+ * <p>字段:对话ID、用户ID、开始时间、消息数、AI响应时长(ms)</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_ai_chat_stat")
+@ApiModel(value = "AnalyticsAiChatStat", description = "AI对话明细统计")
+public class AnalyticsAiChatStat {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("对话ID")
+    @TableField("chat_id")
+    private String chatId;
+
+    @ApiModelProperty("用户ID")
+    @TableField("user_id")
+    private Long userId;
+
+    @ApiModelProperty("开始时间")
+    @TableField("start_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    @ApiModelProperty("消息数")
+    @TableField("message_count")
+    private Integer messageCount;
+
+    @ApiModelProperty("AI响应时长(ms)")
+    @TableField("ai_response_duration_ms")
+    private Long aiResponseDurationMs;
+}

+ 51 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsAiRequest.java

@@ -0,0 +1,51 @@
+package shop.alien.entity.analytics;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * AI 请求明细统计实体。
+ *
+ * <p>表名:analytics_ai_request</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_ai_request")
+@ApiModel(value = "AnalyticsAiRequest", description = "AI请求明细统计")
+public class AnalyticsAiRequest {
+
+    /** 主键 */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /** 请求唯一ID(幂等) */
+    @ApiModelProperty("请求唯一ID")
+    @TableField("request_id")
+    private String requestId;
+
+    @ApiModelProperty("AI接口名称")
+    @TableField("api_name")
+    private String apiName;
+
+    @ApiModelProperty("AI接口地址")
+    @TableField("api_url")
+    private String apiUrl;
+
+    @ApiModelProperty("响应时长(ms)")
+    @TableField("response_duration_ms")
+    private Long responseDurationMs;
+
+    @ApiModelProperty("是否超时(0否1是)")
+    @TableField("is_timeout")
+    private Integer isTimeout;
+
+    /** 入库时间 */
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    private Date createdTime;
+}

+ 68 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsContentStat.java

@@ -0,0 +1,68 @@
+package shop.alien.entity.analytics;
+
+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.util.Date;
+
+/**
+ * 内容明细统计实体。
+ *
+ * <p>表名:analytics_content_stat</p>
+ * <p>名称类字段不入库,查询时按 ID 回填或推导</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_content_stat")
+@ApiModel(value = "AnalyticsContentStat", description = "内容明细统计")
+public class AnalyticsContentStat {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("内容ID")
+    @TableField("content_id")
+    private Long contentId;
+
+    @ApiModelProperty("内容分类(1动态2打卡3二手商品)")
+    @TableField("content_type")
+    private Integer contentType;
+
+    @ApiModelProperty("作者类型(1用户2商家)")
+    @TableField("author_type")
+    private Integer authorType;
+
+    @ApiModelProperty("作者ID")
+    @TableField("author_id")
+    private Long authorId;
+
+    @ApiModelProperty("发布时间")
+    @TableField("publish_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date publishTime;
+
+    @ApiModelProperty("互动数")
+    @TableField("interaction_count")
+    private Integer interactionCount;
+
+    @ApiModelProperty("状态")
+    @TableField("status")
+    private Integer status;
+
+    @ApiModelProperty("审核人ID")
+    @TableField("audit_user_id")
+    private Long auditUserId;
+
+    @ApiModelProperty("审核状态")
+    @TableField("audit_status")
+    private Integer auditStatus;
+
+    @ApiModelProperty("审核时间")
+    @TableField("audit_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date auditTime;
+}

+ 136 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsDailySummary.java

@@ -0,0 +1,136 @@
+package shop.alien.entity.analytics;
+
+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.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 平台日统计主表实体。
+ *
+ * <p>表名:analytics_daily_summary</p>
+ * <p>由定时任务或手动接口从 analytics_event 及明细表汇总生成</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_daily_summary")
+@ApiModel(value = "AnalyticsDailySummary", description = "平台日统计主表")
+public class AnalyticsDailySummary {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("日期")
+    @TableField("stat_date")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDate;
+
+    @ApiModelProperty("DAU")
+    @TableField("dau")
+    private Integer dau;
+
+    @ApiModelProperty("新增用户数")
+    @TableField("new_user_count")
+    private Integer newUserCount;
+
+    @ApiModelProperty("今日AI对话次数")
+    @TableField("ai_chat_count")
+    private Integer aiChatCount;
+
+    @ApiModelProperty("今日内容发布数量")
+    @TableField("content_publish_count")
+    private Integer contentPublishCount;
+
+    @ApiModelProperty("商家访问UV数量")
+    @TableField("merchant_visit_uv")
+    private Integer merchantVisitUv;
+
+    @ApiModelProperty("AI响应时间总数(ms)")
+    @TableField("ai_response_duration_total_ms")
+    private Long aiResponseDurationTotalMs;
+
+    @ApiModelProperty("当前在线总人数")
+    @TableField("online_user_count")
+    private Integer onlineUserCount;
+
+    @ApiModelProperty("支付用户数")
+    @TableField("pay_user_count")
+    private Integer payUserCount;
+
+    @ApiModelProperty("转化率(%)")
+    @TableField("conversion_rate")
+    private BigDecimal conversionRate;
+
+    @ApiModelProperty("客单价(元)")
+    @TableField("avg_order_amount")
+    private BigDecimal avgOrderAmount;
+
+    @ApiModelProperty("今日转化率(%)")
+    @TableField("today_conversion_rate")
+    private BigDecimal todayConversionRate;
+
+    @ApiModelProperty("累计注册用户数量")
+    @TableField("total_register_user_count")
+    private Integer totalRegisterUserCount;
+
+    @ApiModelProperty("近7日新用户数量")
+    @TableField("last_7d_new_user_count")
+    private Integer last7dNewUserCount;
+
+    @ApiModelProperty("近7日活跃用户数量")
+    @TableField("last_7d_active_user_count")
+    private Integer last7dActiveUserCount;
+
+    @ApiModelProperty("首日注册用户次日有日活数")
+    @TableField("next_day_retained_count")
+    private Integer nextDayRetainedCount;
+
+    @ApiModelProperty("次日留存率(%)")
+    @TableField("next_day_retention_rate")
+    private BigDecimal nextDayRetentionRate;
+
+    @ApiModelProperty("审核通过次数")
+    @TableField("audit_pass_count")
+    private Integer auditPassCount;
+
+    @ApiModelProperty("审核提交次数")
+    @TableField("audit_submit_count")
+    private Integer auditSubmitCount;
+
+    @ApiModelProperty("审核通过率(%)")
+    @TableField("audit_pass_rate")
+    private BigDecimal auditPassRate;
+
+    @ApiModelProperty("今日内容互动数")
+    @TableField("content_interaction_count")
+    private Integer contentInteractionCount;
+
+    @ApiModelProperty("举报处理次数")
+    @TableField("report_handle_count")
+    private Integer reportHandleCount;
+
+    @ApiModelProperty("举报提交次数")
+    @TableField("report_submit_count")
+    private Integer reportSubmitCount;
+
+    @ApiModelProperty("昨日举报处理率(%)")
+    @TableField("yesterday_report_handle_rate")
+    private BigDecimal yesterdayReportHandleRate;
+
+    @ApiModelProperty("入驻商家总数")
+    @TableField("total_settled_merchant_count")
+    private Integer totalSettledMerchantCount;
+
+    @ApiModelProperty("今日GMV")
+    @TableField("today_gmv")
+    private BigDecimal todayGmv;
+
+    @ApiModelProperty("核销率(%)")
+    @TableField("verify_rate")
+    private BigDecimal verifyRate;
+}

+ 94 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsEvent.java

@@ -0,0 +1,94 @@
+package shop.alien.entity.analytics;
+
+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.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 埋点事件实体(日汇总数据源)。
+ *
+ * <p>表名:analytics_event</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_event")
+@ApiModel(value = "AnalyticsEvent", description = "埋点事件(汇总用)")
+public class AnalyticsEvent {
+
+    /** 主键 */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /** 事件唯一ID(幂等) */
+    @ApiModelProperty("事件唯一ID")
+    @TableField("event_id")
+    private String eventId;
+
+    /** 事件编码,见 {@link AnalyticsEventCode} */
+    @ApiModelProperty("事件编码")
+    @TableField("event_code")
+    private String eventCode;
+
+    /** 用户ID */
+    @ApiModelProperty("用户ID")
+    @TableField("user_id")
+    private Long userId;
+
+    /** 商户ID */
+    @ApiModelProperty("商户ID")
+    @TableField("merchant_id")
+    private Long merchantId;
+
+    /** 目标ID(如内容ID) */
+    @ApiModelProperty("目标ID")
+    @TableField("target_id")
+    private Long targetId;
+
+    /** 内容分类(1动态2打卡3二手商品) */
+    @ApiModelProperty("内容分类(1动态2打卡3二手商品)")
+    @TableField("content_type")
+    private Integer contentType;
+
+    /** 金额 */
+    @ApiModelProperty("金额")
+    @TableField("amount")
+    private BigDecimal amount;
+
+    /** 时长(毫秒) */
+    @ApiModelProperty("时长(毫秒)")
+    @TableField("duration_ms")
+    private Long durationMs;
+
+    /** 设备类型 */
+    @ApiModelProperty("设备类型")
+    @TableField("device_type")
+    private String deviceType;
+
+    /** 渠道 */
+    @ApiModelProperty("渠道")
+    @TableField("channel")
+    private String channel;
+
+    /** 城市 */
+    @ApiModelProperty("城市")
+    @TableField("city")
+    private String city;
+
+    /** 事件发生时间 */
+    @ApiModelProperty("事件时间")
+    @TableField("event_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
+    private Date eventTime;
+
+    /** 入库时间 */
+    @ApiModelProperty("创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+}

+ 34 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsEventCode.java

@@ -0,0 +1,34 @@
+package shop.alien.entity.analytics;
+
+/**
+ * 埋点事件编码
+ */
+public final class AnalyticsEventCode {
+
+    private AnalyticsEventCode() {
+    }
+
+    public static final String USER_LAUNCH = "user.launch";
+    public static final String USER_REGISTER = "user.register";
+    public static final String USER_LOGIN = "user.login";
+    public static final String USER_LOGOUT = "user.logout";
+    public static final String USER_HEARTBEAT = "user.heartbeat";
+
+    public static final String MERCHANT_VIEW = "merchant.view";
+    public static final String MERCHANT_VERIFY = "merchant.verify";
+
+    public static final String CONTENT_PUBLISH = "content.publish";
+    public static final String CONTENT_INTERACT = "content.interact";
+
+    public static final String PAY_SUCCESS = "pay.success";
+
+    public static final String AUDIT_SUBMIT = "audit.submit";
+    public static final String AUDIT_PASS = "audit.pass";
+
+    public static final String REPORT_SUBMIT = "report.submit";
+    public static final String REPORT_HANDLE = "report.handle";
+
+    public static final String AI_CHAT_START = "ai.chat.start";
+    public static final String AI_CHAT_MESSAGE = "ai.chat.message";
+    public static final String AI_CHAT_END = "ai.chat.end";
+}

+ 61 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsMerchantStat.java

@@ -0,0 +1,61 @@
+package shop.alien.entity.analytics;
+
+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.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 商户明细统计实体。
+ *
+ * <p>表名:analytics_merchant_stat</p>
+ * <p>商家名称不入库;shopType 由浏览埋点上报入库,查询时可推导 shopTypeName</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_merchant_stat")
+@ApiModel(value = "AnalyticsMerchantStat", description = "商户明细统计")
+public class AnalyticsMerchantStat {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("统计日期")
+    @TableField("stat_date")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDate;
+
+    @ApiModelProperty("商家ID")
+    @TableField("merchant_id")
+    private Long merchantId;
+
+    @ApiModelProperty("商家店铺类型(1美食2休闲娱乐3生活服务)")
+    @TableField("shop_type")
+    private Integer shopType;
+
+    @ApiModelProperty("访问UV")
+    @TableField("visit_uv")
+    private Integer visitUv;
+
+    @ApiModelProperty("访问PV")
+    @TableField("visit_pv")
+    private Integer visitPv;
+
+    @ApiModelProperty("核销转化率(%)")
+    @TableField("verify_conversion_rate")
+    private BigDecimal verifyConversionRate;
+
+    @ApiModelProperty("商家入驻时间")
+    @TableField("settle_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date settleTime;
+
+    @ApiModelProperty("商家入驻状态")
+    @TableField("settle_status")
+    private Integer settleStatus;
+}

+ 109 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsScene.java

@@ -0,0 +1,109 @@
+package shop.alien.entity.analytics;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 前端埋点场景(前端只需传 scene,后端映射为 eventCode)
+ */
+@Getter
+public enum AnalyticsScene {
+
+    APP_LAUNCH("APP_LAUNCH", "App启动", AnalyticsEventCode.USER_LAUNCH, "USER"),
+    APP_REGISTER("APP_REGISTER", "用户注册", AnalyticsEventCode.USER_REGISTER, "USER"),
+    USER_LOGIN("USER_LOGIN", "用户登录", AnalyticsEventCode.USER_LOGIN, "USER"),
+    USER_LOGOUT("USER_LOGOUT", "用户登出", AnalyticsEventCode.USER_LOGOUT, "USER"),
+    USER_HEARTBEAT("USER_HEARTBEAT", "在线心跳", AnalyticsEventCode.USER_HEARTBEAT, "USER"),
+
+    MERCHANT_VIEW("MERCHANT_VIEW", "访问商户", AnalyticsEventCode.MERCHANT_VIEW, "MERCHANT"),
+    MERCHANT_VERIFY("MERCHANT_VERIFY", "商户核销", AnalyticsEventCode.MERCHANT_VERIFY, "MERCHANT"),
+
+    CONTENT_PUBLISH("CONTENT_PUBLISH", "发布内容", AnalyticsEventCode.CONTENT_PUBLISH, "CONTENT"),
+    CONTENT_INTERACT("CONTENT_INTERACT", "内容互动", AnalyticsEventCode.CONTENT_INTERACT, "CONTENT"),
+
+    PAY_SUCCESS("PAY_SUCCESS", "支付成功", AnalyticsEventCode.PAY_SUCCESS, "PAY"),
+
+    AUDIT_SUBMIT("AUDIT_SUBMIT", "提交审核", AnalyticsEventCode.AUDIT_SUBMIT, "SYSTEM"),
+    AUDIT_PASS("AUDIT_PASS", "审核通过", AnalyticsEventCode.AUDIT_PASS, "SYSTEM"),
+
+    REPORT_SUBMIT("REPORT_SUBMIT", "提交举报", AnalyticsEventCode.REPORT_SUBMIT, "SYSTEM"),
+    REPORT_HANDLE("REPORT_HANDLE", "举报处理", AnalyticsEventCode.REPORT_HANDLE, "SYSTEM"),
+
+    AI_CHAT_START("AI_CHAT_START", "AI对话开始", AnalyticsEventCode.AI_CHAT_START, "AI"),
+    AI_CHAT_MESSAGE("AI_CHAT_MESSAGE", "AI对话消息", AnalyticsEventCode.AI_CHAT_MESSAGE, "AI"),
+    AI_CHAT_END("AI_CHAT_END", "AI对话结束", AnalyticsEventCode.AI_CHAT_END, "AI");
+
+    private final String scene;
+    private final String sceneName;
+    private final String eventCode;
+    private final String eventCategory;
+
+    AnalyticsScene(String scene, String sceneName, String eventCode, String eventCategory) {
+        this.scene = scene;
+        this.sceneName = sceneName;
+        this.eventCode = eventCode;
+        this.eventCategory = eventCategory;
+    }
+
+    public static AnalyticsScene of(String scene) {
+        if (scene == null) {
+            return null;
+        }
+        for (AnalyticsScene value : values()) {
+            if (value.scene.equalsIgnoreCase(scene)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    public static List<Map<String, String>> toCatalog() {
+        return Arrays.stream(values())
+                .map(item -> {
+                    Map<String, String> map = new LinkedHashMap<>();
+                    map.put("scene", item.scene);
+                    map.put("sceneName", item.sceneName);
+                    map.put("eventCode", item.eventCode);
+                    map.put("eventCategory", item.eventCategory);
+                    return map;
+                })
+                .collect(Collectors.toList());
+    }
+
+    public static Map<String, Object> contentTypeGuide() {
+        Map<String, Object> guide = new LinkedHashMap<>();
+        guide.put("1", "动态(DYNAMIC/POST)");
+        guide.put("2", "打卡(CHECKIN/CLOCK_IN)");
+        guide.put("3", "二手商品(SECOND_GOODS/GOODS)");
+        return Collections.unmodifiableMap(guide);
+    }
+
+    public static Map<String, Object> shopTypeGuide() {
+        Map<String, Object> guide = new LinkedHashMap<>();
+        guide.put("1", "美食");
+        guide.put("2", "休闲娱乐");
+        guide.put("3", "生活服务");
+        return Collections.unmodifiableMap(guide);
+    }
+
+    /** 事件表可上报的独立字段说明(无 JSON 扩展) */
+    public static Map<String, String> eventFieldGuide() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("userId", "用户ID");
+        fields.put("merchantId", "商户ID");
+        fields.put("targetId", "目标对象ID");
+        fields.put("contentType", "内容分类(1动态2打卡3二手商品)");
+        fields.put("amount", "金额(元)");
+        fields.put("durationMs", "时长(毫秒)");
+        fields.put("deviceType", "设备");
+        fields.put("channel", "渠道");
+        fields.put("city", "城市");
+        return Collections.unmodifiableMap(fields);
+    }
+}

+ 64 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsStatJobLog.java

@@ -0,0 +1,64 @@
+package shop.alien.entity.analytics;
+
+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.util.Date;
+
+/**
+ * 统计任务执行日志实体。
+ *
+ * <p>表名:analytics_stat_job_log</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_stat_job_log")
+@ApiModel(value = "AnalyticsStatJobLog", description = "统计任务日志")
+public class AnalyticsStatJobLog {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("任务ID")
+    @TableField("job_id")
+    private String jobId;
+
+    @ApiModelProperty("统计日期")
+    @TableField("stat_date")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDate;
+
+    @ApiModelProperty("统计范围")
+    @TableField("scope")
+    private String scope;
+
+    @ApiModelProperty("触发方式")
+    @TableField("trigger_type")
+    private String triggerType;
+
+    @ApiModelProperty("状态(0进行中1成功2失败)")
+    @TableField("status")
+    private Integer status;
+
+    @ApiModelProperty("开始时间")
+    @TableField("start_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    @ApiModelProperty("结束时间")
+    @TableField("end_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date endTime;
+
+    @ApiModelProperty("错误信息")
+    @TableField("error_msg")
+    private String errorMsg;
+
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+}

+ 17 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsStatScope.java

@@ -0,0 +1,17 @@
+package shop.alien.entity.analytics;
+
+/**
+ * 统计范围
+ */
+public final class AnalyticsStatScope {
+
+    private AnalyticsStatScope() {
+    }
+
+    public static final String ALL = "ALL";
+    public static final String USER = "USER";
+    public static final String MERCHANT = "MERCHANT";
+    public static final String CONTENT = "CONTENT";
+    public static final String AI_CHAT = "AI_CHAT";
+    public static final String DAILY = "DAILY";
+}

+ 70 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/AnalyticsUserStat.java

@@ -0,0 +1,70 @@
+package shop.alien.entity.analytics;
+
+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.util.Date;
+
+/**
+ * 用户明细统计实体。
+ *
+ * <p>表名:analytics_user_stat</p>
+ * <p>用户名称不入库,查询时按 userId 回填</p>
+ */
+@Data
+@JsonInclude
+@TableName("analytics_user_stat")
+@ApiModel(value = "AnalyticsUserStat", description = "用户明细统计")
+public class AnalyticsUserStat {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("统计日期")
+    @TableField("stat_date")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDate;
+
+    @ApiModelProperty("用户ID")
+    @TableField("user_id")
+    private Long userId;
+
+    @ApiModelProperty("首次启动时间")
+    @TableField("first_launch_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date firstLaunchTime;
+
+    @ApiModelProperty("最后活跃时间")
+    @TableField("last_active_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date lastActiveTime;
+
+    @ApiModelProperty("城市")
+    @TableField("city")
+    private String city;
+
+    @ApiModelProperty("设备")
+    @TableField("device_type")
+    private String deviceType;
+
+    @ApiModelProperty("手机号")
+    @TableField("user_phone")
+    private String userPhone;
+
+    @ApiModelProperty("注册时间")
+    @TableField("register_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date registerTime;
+
+    @ApiModelProperty("渠道")
+    @TableField("channel")
+    private String channel;
+
+    @ApiModelProperty("在线时长(分)")
+    @TableField("online_duration_min")
+    private Integer onlineDurationMin;
+}

+ 16 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiChatDetailDTO.java

@@ -0,0 +1,16 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+import shop.alien.entity.analytics.AnalyticsAiChatStat;
+
+/**
+ * AI 对话明细同步上报。
+ *
+ * <p>接口:POST /analytics/detail/ai-chat</p>
+ * <p>落库:analytics_ai_chat_stat</p>
+ */
+@Data
+@ApiModel("AI对话明细上报")
+public class AnalyticsAiChatDetailDTO extends AnalyticsAiChatStat {
+}

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiChatEndDTO.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * AI对话结束埋点上报。
+ *
+ * <p>接口:POST /analytics/front/ai-chat/end</p>
+ * <p>落库:analytics_event、analytics_ai_chat_stat</p>
+ */
+@Data
+@ApiModel("AI对话结束埋点上报")
+public class AnalyticsAiChatEndDTO {
+
+    @ApiModelProperty(value = "对话ID", required = true)
+    private String chatId;
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    private Long userId;
+
+    @ApiModelProperty(value = "开始时间", required = true)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    @ApiModelProperty(value = "消息数", required = true)
+    private Integer messageCount;
+
+    @ApiModelProperty(value = "AI响应时长(ms)", required = true)
+    private Long aiResponseDurationMs;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 31 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsAiRequestDTO.java

@@ -0,0 +1,31 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * AI 请求明细上报。
+ *
+ * <p>接口:POST /analytics/front/ai-request</p>
+ * <p>落库:analytics_ai_request</p>
+ */
+@Data
+@ApiModel("AI请求明细上报")
+public class AnalyticsAiRequestDTO {
+
+    @ApiModelProperty("请求唯一ID(幂等,可选)")
+    private String requestId;
+
+    @ApiModelProperty(value = "AI接口名称", required = true)
+    private String apiName;
+
+    @ApiModelProperty(value = "AI接口地址", required = true)
+    private String apiUrl;
+
+    @ApiModelProperty("响应时长(ms)")
+    private Long responseDurationMs;
+
+    @ApiModelProperty("是否超时(0否1是)")
+    private Integer isTimeout;
+}

+ 21 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsBatchTrackDTO.java

@@ -0,0 +1,21 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 前端批量埋点上报。
+ *
+ * <p>接口:POST /analytics/front/batch</p>
+ * <p>落库:analytics_event(单次最多50条)</p>
+ */
+@Data
+@ApiModel("前端批量埋点上报")
+public class AnalyticsBatchTrackDTO {
+
+    @ApiModelProperty(value = "事件列表", required = true)
+    private List<AnalyticsFrontReportDTO> events;
+}

+ 25 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsCalculateDTO.java

@@ -0,0 +1,25 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 统计计算触发请求。
+ *
+ * <p>接口:POST /analytics/stat/calculate</p>
+ */
+@Data
+@ApiModel("统计计算请求")
+public class AnalyticsCalculateDTO {
+
+    @ApiModelProperty(value = "统计日期(yyyy-MM-dd)", required = true)
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDate;
+
+    @ApiModelProperty(value = "统计范围(ALL/USER/MERCHANT/CONTENT/AI_CHAT/DAILY)", example = "ALL")
+    private String scope;
+}

+ 16 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentDetailDTO.java

@@ -0,0 +1,16 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+import shop.alien.entity.analytics.AnalyticsContentStat;
+
+/**
+ * 内容明细同步上报。
+ *
+ * <p>接口:POST /analytics/detail/content</p>
+ * <p>落库:analytics_content_stat</p>
+ */
+@Data
+@ApiModel("内容明细上报")
+public class AnalyticsContentDetailDTO extends AnalyticsContentStat {
+}

+ 31 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentInteractDTO.java

@@ -0,0 +1,31 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 内容互动埋点上报(评论/点赞/收藏等)。
+ *
+ * <p>接口:POST /analytics/front/content/interact</p>
+ * <p>落库:analytics_event,并累加 analytics_content_stat.interaction_count</p>
+ */
+@Data
+@ApiModel("内容互动埋点上报")
+public class AnalyticsContentInteractDTO {
+
+    @ApiModelProperty(value = "内容ID", required = true)
+    private Long contentId;
+
+    @ApiModelProperty(value = "内容分类(1动态2打卡3二手商品)", required = true)
+    private Integer contentType;
+
+    @ApiModelProperty(value = "互动数增量(评论/点赞/收藏等,取消操作可传负数)", required = true)
+    private Integer increment;
+
+    @ApiModelProperty("操作用户ID(可选,写入事件)")
+    private Long userId;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsContentPublishDTO.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 内容发布埋点上报。
+ *
+ * <p>接口:POST /analytics/front/content/publish</p>
+ * <p>落库:analytics_event、analytics_content_stat</p>
+ */
+@Data
+@ApiModel("内容发布埋点上报")
+public class AnalyticsContentPublishDTO {
+
+    @ApiModelProperty(value = "内容ID", required = true)
+    private Long contentId;
+
+    @ApiModelProperty(value = "内容分类(1动态2打卡3二手商品)", required = true)
+    private Integer contentType;
+
+    @ApiModelProperty(value = "作者类型(1用户2商家)", required = true)
+    private Integer authorType;
+
+    @ApiModelProperty(value = "作者ID", required = true)
+    private Long authorId;
+
+    @ApiModelProperty(value = "发布时间", required = true)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date publishTime;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 57 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsFrontReportDTO.java

@@ -0,0 +1,57 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 前端统一埋点上报(按 scene 映射事件编码)。
+ *
+ * <p>接口:POST /analytics/front/report</p>
+ * <p>落库:analytics_event</p>
+ */
+@Data
+@ApiModel("前端埋点上报")
+public class AnalyticsFrontReportDTO {
+
+    @ApiModelProperty(value = "埋点场景", required = true)
+    private String scene;
+
+    @ApiModelProperty("事件唯一ID")
+    private String eventId;
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty("商户ID")
+    private Long merchantId;
+
+    @ApiModelProperty("目标ID")
+    private Long targetId;
+
+    @ApiModelProperty("内容分类(1/2/3)")
+    private Integer contentType;
+
+    @ApiModelProperty("金额")
+    private BigDecimal amount;
+
+    @ApiModelProperty("时长(毫秒)")
+    private Long durationMs;
+
+    @ApiModelProperty("设备")
+    private String deviceType;
+
+    @ApiModelProperty("渠道")
+    private String channel;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("事件时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date eventTime;
+}

+ 31 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsHeartbeatDTO.java

@@ -0,0 +1,31 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 在线心跳上报。
+ *
+ * <p>接口:POST /analytics/front/heartbeat</p>
+ * <p>落库:analytics_event(事件码 user.heartbeat)</p>
+ */
+@Data
+@ApiModel("在线心跳")
+public class AnalyticsHeartbeatDTO {
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty(value = "在线时长(毫秒)", required = true)
+    private Long durationMs;
+
+    @ApiModelProperty("设备")
+    private String deviceType;
+
+    @ApiModelProperty("渠道")
+    private String channel;
+
+    @ApiModelProperty("城市")
+    private String city;
+}

+ 25 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsMerchantDetailDTO.java

@@ -0,0 +1,25 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import shop.alien.entity.analytics.AnalyticsMerchantStat;
+
+import java.util.Date;
+
+/**
+ * 商户明细同步上报。
+ *
+ * <p>接口:POST /analytics/detail/merchant</p>
+ * <p>落库:analytics_merchant_stat(visitUv/visitPv 为覆盖写入,非累加)</p>
+ * <p>字段含 shopType、visitUv、visitPv 等,与实体 {@link shop.alien.entity.analytics.AnalyticsMerchantStat} 一致</p>
+ */
+@Data
+@ApiModel("商户明细上报")
+public class AnalyticsMerchantDetailDTO extends AnalyticsMerchantStat {
+
+    @ApiModelProperty("统计日期(不传默认当天)")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDateOverride;
+}

+ 34 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsMerchantViewDTO.java

@@ -0,0 +1,34 @@
+package shop.alien.entity.analytics.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 商家浏览埋点上报。
+ *
+ * <p>接口:POST /analytics/front/merchant/view</p>
+ * <p>落库:analytics_event、analytics_merchant_stat(累加当日 UV/PV)</p>
+ */
+@Data
+@ApiModel("商家浏览埋点上报")
+public class AnalyticsMerchantViewDTO {
+
+    @ApiModelProperty(value = "商家ID", required = true)
+    private Long merchantId;
+
+    @ApiModelProperty(value = "商家店铺类型(1美食2休闲娱乐3生活服务)", required = true)
+    private Integer shopType;
+
+    @ApiModelProperty(value = "访问UV增量(前端决定,如首次访问传1否则传0)", required = true)
+    private Integer visitUv;
+
+    @ApiModelProperty(value = "访问PV增量(前端决定,通常传1)", required = true)
+    private Integer visitPv;
+
+    @ApiModelProperty("浏览用户ID(可选,写入事件)")
+    private Long userId;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 56 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsTrackEventDTO.java

@@ -0,0 +1,56 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 埋点事件上报(内部/底层)。
+ *
+ * <p>落库:analytics_event</p>
+ */
+@Data
+@ApiModel("埋点事件上报")
+public class AnalyticsTrackEventDTO {
+
+    @ApiModelProperty("事件唯一ID")
+    private String eventId;
+
+    @ApiModelProperty(value = "事件编码", required = true)
+    private String eventCode;
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty("商户ID")
+    private Long merchantId;
+
+    @ApiModelProperty("目标ID")
+    private Long targetId;
+
+    @ApiModelProperty("内容分类(1/2/3)")
+    private Integer contentType;
+
+    @ApiModelProperty("金额")
+    private BigDecimal amount;
+
+    @ApiModelProperty("时长(毫秒)")
+    private Long durationMs;
+
+    @ApiModelProperty("设备")
+    private String deviceType;
+
+    @ApiModelProperty("渠道")
+    private String channel;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("事件时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date eventTime;
+}

+ 24 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserDetailDTO.java

@@ -0,0 +1,24 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import shop.alien.entity.analytics.AnalyticsUserStat;
+
+import java.util.Date;
+
+/**
+ * 用户明细同步上报。
+ *
+ * <p>接口:POST /analytics/detail/user</p>
+ * <p>落库:analytics_user_stat</p>
+ */
+@Data
+@ApiModel("用户明细上报")
+public class AnalyticsUserDetailDTO extends AnalyticsUserStat {
+
+    @ApiModelProperty("统计日期(不传默认当天)")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date statDateOverride;
+}

+ 39 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserLoginDTO.java

@@ -0,0 +1,39 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 用户登录埋点上报。
+ *
+ * <p>接口:POST /analytics/front/user/login</p>
+ * <p>落库:analytics_event、analytics_user_stat</p>
+ */
+@Data
+@ApiModel("用户登录埋点上报")
+public class AnalyticsUserLoginDTO {
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    private Long userId;
+
+    @ApiModelProperty("首次启动时间(首次登录可不传,服务端以最后活跃时间补全)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date firstLaunchTime;
+
+    @ApiModelProperty("最后活跃时间(不传默认当前时间)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date lastActiveTime;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("设备名称")
+    private String deviceName;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 42 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserLogoutDTO.java

@@ -0,0 +1,42 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 用户登出埋点上报。
+ *
+ * <p>接口:POST /analytics/front/user/logout</p>
+ * <p>落库:analytics_event、analytics_user_stat(累加当日在线时长)</p>
+ */
+@Data
+@ApiModel("用户登出埋点上报")
+public class AnalyticsUserLogoutDTO {
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    private Long userId;
+
+    @ApiModelProperty("首次启动时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date firstLaunchTime;
+
+    @ApiModelProperty("最后活跃时间(不传默认当前时间)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date lastActiveTime;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("设备名称")
+    private String deviceName;
+
+    @ApiModelProperty(value = "本次在线时长(分)", required = true)
+    private Integer onlineDurationMin;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/dto/AnalyticsUserRegisterDTO.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.analytics.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 用户注册埋点上报。
+ *
+ * <p>接口:POST /analytics/front/user/register</p>
+ * <p>落库:analytics_event、analytics_user_stat</p>
+ */
+@Data
+@ApiModel("用户注册埋点上报")
+public class AnalyticsUserRegisterDTO {
+
+    @ApiModelProperty(value = "用户ID", required = true)
+    private Long userId;
+
+    @ApiModelProperty("手机号")
+    private String userPhone;
+
+    @ApiModelProperty("注册时间(不传默认当前时间)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date registerTime;
+
+    @ApiModelProperty("渠道")
+    private String channel;
+
+    @ApiModelProperty("城市")
+    private String city;
+
+    @ApiModelProperty("事件唯一ID(幂等,可选)")
+    private String eventId;
+}

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsContentStatVo.java

@@ -0,0 +1,27 @@
+package shop.alien.entity.analytics.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import shop.alien.entity.analytics.AnalyticsContentStat;
+
+/**
+ * 内容明细统计查询视图(含展示名称)。
+ *
+ * <p>contentTypeName、authorName、auditUserName 由服务端查询或推导后填充,不入库</p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("内容明细统计(含展示名称)")
+public class AnalyticsContentStatVo extends AnalyticsContentStat {
+
+    @ApiModelProperty("内容分类名称(按contentType推导)")
+    private String contentTypeName;
+
+    @ApiModelProperty("作者名称(按authorType+authorId查询)")
+    private String authorName;
+
+    @ApiModelProperty("审核人名称(按auditUserId查询)")
+    private String auditUserName;
+}

+ 28 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsMerchantStatVo.java

@@ -0,0 +1,28 @@
+package shop.alien.entity.analytics.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import shop.alien.entity.analytics.AnalyticsMerchantStat;
+
+/**
+ * 商户明细统计查询视图(含展示名称)。
+ *
+ * <p>shopType 入库字段,shopTypeName 由服务端按 shopType 推导</p>
+ * <p>merchantName、categoryName 由服务端按 merchantId 关联查询后填充,不入库</p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("商户明细统计(含展示名称)")
+public class AnalyticsMerchantStatVo extends AnalyticsMerchantStat {
+
+    @ApiModelProperty("商家名称(按merchantId查询)")
+    private String merchantName;
+
+    @ApiModelProperty("店铺类型名称(按shopType推导)")
+    private String shopTypeName;
+
+    @ApiModelProperty("分类名称(按merchantId查询)")
+    private String categoryName;
+}

+ 21 - 0
alien-entity/src/main/java/shop/alien/entity/analytics/vo/AnalyticsUserStatVo.java

@@ -0,0 +1,21 @@
+package shop.alien.entity.analytics.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import shop.alien.entity.analytics.AnalyticsUserStat;
+
+/**
+ * 用户明细统计查询视图(含展示名称)。
+ *
+ * <p>userName 由服务端按 userId 关联查询后填充,不入库</p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("用户明细统计(含展示名称)")
+public class AnalyticsUserStatVo extends AnalyticsUserStat {
+
+    @ApiModelProperty("用户名称(按userId查询)")
+    private String userName;
+}

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsAiChatStatMapper.java

@@ -0,0 +1,9 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import shop.alien.entity.analytics.AnalyticsAiChatStat;
+
+@Mapper
+public interface AnalyticsAiChatStatMapper extends BaseMapper<AnalyticsAiChatStat> {
+}

+ 17 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsAiRequestMapper.java

@@ -0,0 +1,17 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import shop.alien.entity.analytics.AnalyticsAiRequest;
+
+import java.util.Date;
+
+@Mapper
+public interface AnalyticsAiRequestMapper extends BaseMapper<AnalyticsAiRequest> {
+
+    @Select("SELECT COALESCE(SUM(response_duration_ms), 0) FROM analytics_ai_request " +
+            "WHERE created_time >= #{startTime} AND created_time < #{endTime}")
+    Long sumResponseDuration(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+}

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsContentStatMapper.java

@@ -0,0 +1,9 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import shop.alien.entity.analytics.AnalyticsContentStat;
+
+@Mapper
+public interface AnalyticsContentStatMapper extends BaseMapper<AnalyticsContentStat> {
+}

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsDailySummaryMapper.java

@@ -0,0 +1,9 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import shop.alien.entity.analytics.AnalyticsDailySummary;
+
+@Mapper
+public interface AnalyticsDailySummaryMapper extends BaseMapper<AnalyticsDailySummary> {
+}

+ 88 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsEventMapper.java

@@ -0,0 +1,88 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import shop.alien.entity.analytics.AnalyticsEvent;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countDau(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(*) FROM analytics_event " +
+            "WHERE event_code = #{eventCode} AND event_time >= #{startTime} AND event_time < #{endTime}")
+    Long countByEventCode(@Param("eventCode") String eventCode,
+                          @Param("startTime") Date startTime,
+                          @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = #{eventCode} AND event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countDistinctUserByEventCode(@Param("eventCode") String eventCode,
+                                      @Param("startTime") Date startTime,
+                                      @Param("endTime") Date endTime);
+
+    @Select("SELECT COALESCE(SUM(amount), 0) FROM analytics_event " +
+            "WHERE event_code = #{eventCode} AND event_time >= #{startTime} AND event_time < #{endTime}")
+    java.math.BigDecimal sumPayAmount(@Param("eventCode") String eventCode,
+                                      @Param("startTime") Date startTime,
+                                      @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = 'merchant.view' AND event_time >= #{startTime} AND event_time < #{endTime} " +
+            "AND user_id IS NOT NULL")
+    Long countMerchantVisitUv(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(*) FROM analytics_event " +
+            "WHERE event_code = 'merchant.verify' AND event_time >= #{startTime} AND event_time < #{endTime}")
+    Long countMerchantVerify(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = 'merchant.view' AND event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countMerchantViewUsers(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT user_id AS userId, MAX(event_time) AS lastActiveTime, " +
+            "COALESCE(SUM(CASE WHEN event_code = 'user.heartbeat' AND duration_ms IS NOT NULL THEN duration_ms ELSE 0 END), 0) AS totalDurationMs, " +
+            "MAX(city) AS city, MAX(device_type) AS deviceType, MAX(channel) AS channel " +
+            "FROM analytics_event WHERE event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL GROUP BY user_id")
+    List<Map<String, Object>> aggregateUserDaily(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT merchant_id AS merchantId, " +
+            "COUNT(CASE WHEN event_code = 'merchant.view' THEN 1 END) AS visitPv, " +
+            "COUNT(DISTINCT CASE WHEN event_code = 'merchant.view' AND user_id IS NOT NULL THEN user_id END) AS visitUv, " +
+            "COUNT(CASE WHEN event_code = 'merchant.verify' THEN 1 END) AS verifyCount, " +
+            "COUNT(DISTINCT CASE WHEN event_code = 'merchant.view' AND user_id IS NOT NULL THEN user_id END) AS visitUserCount " +
+            "FROM analytics_event WHERE event_time >= #{startTime} AND event_time < #{endTime} AND merchant_id IS NOT NULL GROUP BY merchant_id")
+    List<Map<String, Object>> aggregateMerchantDaily(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT r.user_id) FROM analytics_event r " +
+            "INNER JOIN analytics_event a ON r.user_id = a.user_id " +
+            "WHERE r.event_code = 'user.register' AND r.event_time >= #{prevStart} AND r.event_time < #{prevEnd} " +
+            "AND r.user_id IS NOT NULL " +
+            "AND a.event_time >= #{statStart} AND a.event_time < #{statEnd} AND a.user_id IS NOT NULL")
+    Long countNextDayRetained(@Param("prevStart") Date prevStart, @Param("prevEnd") Date prevEnd,
+                              @Param("statStart") Date statStart, @Param("statEnd") Date statEnd);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = 'user.register' AND event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countRegisterUsers(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countActiveUsersInRange(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = 'user.register' AND event_time >= #{startTime} AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countNewUsersInRange(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    @Select("SELECT COUNT(DISTINCT user_id) FROM analytics_event " +
+            "WHERE event_code = 'user.register' AND event_time < #{endTime} AND user_id IS NOT NULL")
+    Long countTotalRegisterUsers(@Param("endTime") Date endTime);
+}

+ 17 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsMerchantStatMapper.java

@@ -0,0 +1,17 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import shop.alien.entity.analytics.AnalyticsMerchantStat;
+
+import java.util.Date;
+
+@Mapper
+public interface AnalyticsMerchantStatMapper extends BaseMapper<AnalyticsMerchantStat> {
+
+    @Select("SELECT COUNT(DISTINCT merchant_id) FROM analytics_merchant_stat " +
+            "WHERE stat_date <= #{statDate} AND settle_status = 1")
+    Long countSettledMerchants(@Param("statDate") Date statDate);
+}

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsStatJobLogMapper.java

@@ -0,0 +1,9 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import shop.alien.entity.analytics.AnalyticsStatJobLog;
+
+@Mapper
+public interface AnalyticsStatJobLogMapper extends BaseMapper<AnalyticsStatJobLog> {
+}

+ 9 - 0
alien-entity/src/main/java/shop/alien/mapper/AnalyticsUserStatMapper.java

@@ -0,0 +1,9 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import shop.alien.entity.analytics.AnalyticsUserStat;
+
+@Mapper
+public interface AnalyticsUserStatMapper extends BaseMapper<AnalyticsUserStat> {
+}

+ 207 - 0
alien-entity/src/main/resources/db/migration/analytics_tables.sql

@@ -0,0 +1,207 @@
+-- =============================================================================
+-- 平台埋点统计系统 - 最终建表脚本
+-- =============================================================================
+-- 说明:
+--   1. 表尚未创建时,直接执行本文件即可
+--   2. 字段与业务需求清单严格对齐,明细表 + 日汇总表 + 内部事件表
+--   3. 明细由 /analytics/detail/* 同步,日汇总由定时任务或 /analytics/stat/calculate 生成
+-- =============================================================================
+
+SET NAMES utf8mb4;
+
+-- -----------------------------------------------------------------------------
+-- 1. analytics_event  行为事件表(内部汇总用,非业务展示)
+--    前端 /analytics/front/* 上报写入,供 DAU/留存/核销率等聚合计算
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_event` (
+  `id`           bigint        NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `event_id`     varchar(64)   NOT NULL COMMENT '事件唯一ID(幂等)',
+  `event_code`   varchar(64)   NOT NULL COMMENT '事件编码,如 user.launch / merchant.view',
+  `user_id`      bigint        DEFAULT NULL COMMENT '用户ID',
+  `merchant_id`  bigint        DEFAULT NULL COMMENT '商户ID',
+  `target_id`    bigint        DEFAULT NULL COMMENT '目标ID(内容ID/订单ID等)',
+  `content_type` tinyint       DEFAULT NULL COMMENT '内容分类(1动态2打卡3二手商品)',
+  `amount`       decimal(14,2) DEFAULT NULL COMMENT '金额(元)',
+  `duration_ms`  bigint        DEFAULT NULL COMMENT '时长(毫秒)',
+  `device_type`  varchar(16)   DEFAULT NULL COMMENT '设备',
+  `channel`      varchar(64)   DEFAULT NULL COMMENT '渠道',
+  `city`         varchar(64)   DEFAULT NULL COMMENT '城市',
+  `event_time`   datetime(3)   NOT NULL COMMENT '事件时间',
+  `created_time` datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_event_id` (`event_id`),
+  KEY `idx_event_code_time` (`event_code`, `event_time`),
+  KEY `idx_user_time` (`user_id`, `event_time`),
+  KEY `idx_merchant_time` (`merchant_id`, `event_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='埋点事件表(汇总用)';
+
+-- -----------------------------------------------------------------------------
+-- 2. analytics_user_stat  用户明细统计
+--    字段:用户ID / 首次启动时间 / 最后活跃时间 / 城市 / 设备 /
+--          手机号 / 注册时间 / 渠道 / 在线时长(分)(用户名称按userId查询)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_user_stat` (
+  `id`                  bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `stat_date`           date         NOT NULL COMMENT '统计日期',
+  `user_id`             bigint       NOT NULL COMMENT '用户ID',
+  `first_launch_time`   datetime     DEFAULT NULL COMMENT '首次启动时间',
+  `last_active_time`    datetime     DEFAULT NULL COMMENT '最后活跃时间',
+  `city`                varchar(64)  DEFAULT NULL COMMENT '城市',
+  `device_type`         varchar(16)  DEFAULT NULL COMMENT '设备',
+  `user_phone`          varchar(20)  DEFAULT NULL COMMENT '手机号',
+  `register_time`       datetime     DEFAULT NULL COMMENT '注册时间',
+  `channel`             varchar(64)  DEFAULT NULL COMMENT '渠道',
+  `online_duration_min` int          NOT NULL DEFAULT 0 COMMENT '在线时长(分)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_date_user` (`stat_date`, `user_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_last_active` (`last_active_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户明细统计';
+
+-- -----------------------------------------------------------------------------
+-- 3. analytics_merchant_stat  商户明细统计
+--    字段:商家ID / 商家店铺类型 / 访问UV / 访问PV / 核销转化率 / 商家入驻时间 / 商家入驻状态
+--          (商家名称按merchantId查询)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_merchant_stat` (
+  `id`                     bigint        NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `stat_date`              date          NOT NULL COMMENT '统计日期',
+  `merchant_id`            bigint        NOT NULL COMMENT '商家ID',
+  `shop_type`              tinyint       DEFAULT NULL COMMENT '商家店铺类型(1美食2休闲娱乐3生活服务)',
+  `visit_uv`               int           NOT NULL DEFAULT 0 COMMENT '访问UV',
+  `visit_pv`               int           NOT NULL DEFAULT 0 COMMENT '访问PV',
+  `verify_conversion_rate` decimal(10,4) DEFAULT NULL COMMENT '核销转化率(%)',
+  `settle_time`            datetime      DEFAULT NULL COMMENT '商家入驻时间',
+  `settle_status`          tinyint       DEFAULT NULL COMMENT '商家入驻状态(0待审核1已入驻2驳回3退出)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_date_merchant` (`stat_date`, `merchant_id`),
+  KEY `idx_merchant_id` (`merchant_id`),
+  KEY `idx_shop_type` (`shop_type`),
+  KEY `idx_settle_status` (`settle_status`, `stat_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商户明细统计';
+
+-- -----------------------------------------------------------------------------
+-- 4. analytics_content_stat  内容明细统计
+--    字段:内容ID / 内容分类 / 作者类型 / 作者ID / 发布时间 / 互动数 /
+--          状态 / 审核人ID / 审核状态 / 审核时间(名称类字段按ID查询或推导)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_content_stat` (
+  `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `content_id`        bigint       NOT NULL COMMENT '内容ID',
+  `content_type`      tinyint      NOT NULL COMMENT '内容分类(1动态2打卡3二手商品)',
+  `author_type`       tinyint      NOT NULL COMMENT '作者类型(1用户2商家)',
+  `author_id`         bigint       NOT NULL COMMENT '作者ID',
+  `publish_time`      datetime     DEFAULT NULL COMMENT '发布时间',
+  `interaction_count` int          NOT NULL DEFAULT 0 COMMENT '互动数',
+  `status`            tinyint      DEFAULT NULL COMMENT '状态',
+  `audit_user_id`     bigint       DEFAULT NULL COMMENT '审核人ID',
+  `audit_status`      tinyint      DEFAULT NULL COMMENT '审核状态(0审核中1通过2驳回)',
+  `audit_time`        datetime     DEFAULT NULL COMMENT '审核时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_content` (`content_type`, `content_id`),
+  KEY `idx_author` (`author_type`, `author_id`),
+  KEY `idx_publish_time` (`publish_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内容明细统计';
+
+-- -----------------------------------------------------------------------------
+-- 5. analytics_ai_chat_stat  AI对话明细统计
+--    字段:对话ID / 用户ID / 开始时间 / 消息数 / AI响应时长(ms)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_ai_chat_stat` (
+  `id`                      bigint      NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `chat_id`                 varchar(64) NOT NULL COMMENT '对话ID',
+  `user_id`                 bigint      NOT NULL COMMENT '用户ID',
+  `start_time`              datetime    NOT NULL COMMENT '开始时间',
+  `message_count`           int         NOT NULL DEFAULT 0 COMMENT '消息数',
+  `ai_response_duration_ms` bigint      NOT NULL DEFAULT 0 COMMENT 'AI响应时长(ms)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_chat_id` (`chat_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_start_time` (`start_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI对话明细统计';
+
+-- -----------------------------------------------------------------------------
+-- 6. analytics_ai_request  AI请求明细统计
+--    字段:AI接口名称 / AI接口地址 / 响应时长(ms) / 是否超时
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_ai_request` (
+  `id`                   bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `request_id`           varchar(64)  NOT NULL COMMENT '请求唯一ID(幂等)',
+  `api_name`             varchar(128) NOT NULL COMMENT 'AI接口名称',
+  `api_url`              varchar(512) NOT NULL COMMENT 'AI接口地址',
+  `response_duration_ms` bigint       NOT NULL DEFAULT 0 COMMENT '响应时长(ms)',
+  `is_timeout`           tinyint      NOT NULL DEFAULT 0 COMMENT '是否超时(0否1是)',
+  `created_time`         datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间(日汇总筛选用)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_request_id` (`request_id`),
+  KEY `idx_api_name` (`api_name`),
+  KEY `idx_created_time` (`created_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI请求明细统计';
+
+-- -----------------------------------------------------------------------------
+-- 7. analytics_daily_summary  通用日统计主表
+--    字段:日期 / DAU / 新增用户数 / 今日AI对话次数 / 今日内容发布数量 /
+--          商家访问UV / AI响应时间总数 / 当前在线总人数 / 支付用户数 / 转化率 /
+--          客单价 / 今日转化率 / 累计注册用户 / 近7日新用户 / 近7日活跃用户 /
+--          次日留存数 / 次日留存率 / 审核通过次数 / 审核提交次数 / 审核通过率 /
+--          今日内容互动数 / 举报处理次数 / 举报提交次数 / 昨日举报处理率 /
+--          入驻商家总数 / 今日GMV / 核销率
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_daily_summary` (
+  `id`                            bigint        NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `stat_date`                     date          NOT NULL COMMENT '日期',
+  `dau`                           int           NOT NULL DEFAULT 0 COMMENT 'DAU',
+  `new_user_count`                int           NOT NULL DEFAULT 0 COMMENT '新增用户数',
+  `ai_chat_count`                 int           NOT NULL DEFAULT 0 COMMENT '今日AI对话次数',
+  `content_publish_count`         int           NOT NULL DEFAULT 0 COMMENT '今日内容发布数量',
+  `merchant_visit_uv`             int           NOT NULL DEFAULT 0 COMMENT '商家访问UV数量',
+  `ai_response_duration_total_ms` bigint        NOT NULL DEFAULT 0 COMMENT 'AI响应时间总数(ms)',
+  `online_user_count`             int           NOT NULL DEFAULT 0 COMMENT '当前在线总人数',
+  `pay_user_count`                int           NOT NULL DEFAULT 0 COMMENT '支付用户数',
+  `conversion_rate`               decimal(10,4) DEFAULT NULL COMMENT '转化率(%)',
+  `avg_order_amount`              decimal(10,2) DEFAULT NULL COMMENT '客单价(元)',
+  `today_conversion_rate`         decimal(10,4) DEFAULT NULL COMMENT '今日转化率(支付成功数/DAU*100%)',
+  `total_register_user_count`     int           NOT NULL DEFAULT 0 COMMENT '累计注册用户数量',
+  `last_7d_new_user_count`        int           NOT NULL DEFAULT 0 COMMENT '近7日新用户数量',
+  `last_7d_active_user_count`     int           NOT NULL DEFAULT 0 COMMENT '近7日活跃用户数量',
+  `next_day_retained_count`       int           NOT NULL DEFAULT 0 COMMENT '首日注册用户次日有日活数',
+  `next_day_retention_rate`       decimal(10,4) DEFAULT NULL COMMENT '次日留存率(%)',
+  `audit_pass_count`              int           NOT NULL DEFAULT 0 COMMENT '审核通过次数',
+  `audit_submit_count`            int           NOT NULL DEFAULT 0 COMMENT '审核提交次数',
+  `audit_pass_rate`               decimal(10,4) DEFAULT NULL COMMENT '审核通过率(%)',
+  `content_interaction_count`     int           NOT NULL DEFAULT 0 COMMENT '今日内容互动数',
+  `report_handle_count`           int           NOT NULL DEFAULT 0 COMMENT '举报处理次数',
+  `report_submit_count`           int           NOT NULL DEFAULT 0 COMMENT '举报提交次数',
+  `yesterday_report_handle_rate`  decimal(10,4) DEFAULT NULL COMMENT '昨日举报处理率(%)',
+  `total_settled_merchant_count`  int           NOT NULL DEFAULT 0 COMMENT '入驻商家总数',
+  `today_gmv`                     decimal(14,2) NOT NULL DEFAULT 0.00 COMMENT '今日GMV',
+  `verify_rate`                   decimal(10,4) DEFAULT NULL COMMENT '核销率(%)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stat_date` (`stat_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台日统计主表';
+
+-- -----------------------------------------------------------------------------
+-- 8. analytics_stat_job_log  统计任务日志(运维辅助,非业务展示字段)
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `analytics_stat_job_log` (
+  `id`           bigint        NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `job_id`       varchar(64)   NOT NULL COMMENT '任务ID',
+  `stat_date`    date          NOT NULL COMMENT '统计日期',
+  `scope`        varchar(32)   NOT NULL COMMENT '统计范围(ALL/USER/MERCHANT/CONTENT/AI_CHAT/DAILY)',
+  `trigger_type` varchar(16)   NOT NULL COMMENT '触发方式(SCHEDULE/MANUAL)',
+  `status`       tinyint       NOT NULL DEFAULT 0 COMMENT '状态(0进行中1成功2失败)',
+  `start_time`   datetime      NOT NULL COMMENT '开始时间',
+  `end_time`     datetime      DEFAULT NULL COMMENT '结束时间',
+  `error_msg`    varchar(1000) DEFAULT NULL COMMENT '错误信息',
+  `created_time` datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_stat_date` (`stat_date`),
+  KEY `idx_job_id` (`job_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统计任务日志';
+
+-- -----------------------------------------------------------------------------
+-- 增量变更(表已存在时按需执行)
+-- -----------------------------------------------------------------------------
+-- ALTER TABLE `analytics_merchant_stat`
+--   ADD COLUMN `shop_type` tinyint DEFAULT NULL COMMENT '商家店铺类型(1美食2休闲娱乐3生活服务)' AFTER `merchant_id`,
+--   ADD KEY `idx_shop_type` (`shop_type`);

+ 18 - 0
alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java

@@ -110,4 +110,22 @@ public interface AlienStoreFeign {
      */
     @PostMapping("/storeCommentAppealSupplement/job/pollCompletedResult")
     R<String> pollStoreCommentAppealSupplementCompletedResult();
+
+    /**
+     * 平台埋点:每小时汇总当日数据
+     */
+    @PostMapping("/analytics/job/calculateTodayHourly")
+    R<String> calculateAnalyticsTodayHourly();
+
+    /**
+     * 平台埋点:汇总前日完整数据
+     */
+    @PostMapping("/analytics/job/calculateYesterdayDaily")
+    R<String> calculateAnalyticsYesterdayDaily();
+
+    /**
+     * 平台埋点:计算前日次日留存
+     */
+    @PostMapping("/analytics/job/calculateRetention")
+    R<String> calculateAnalyticsRetention();
 }

+ 74 - 0
alien-job/src/main/java/shop/alien/job/store/AnalyticsStatisticsJob.java

@@ -0,0 +1,74 @@
+package shop.alien.job.store;
+
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.job.feign.AlienStoreFeign;
+
+/**
+ * 平台埋点统计定时任务
+ * <p>
+ * XXL-JOB 调度建议:
+ * <ul>
+ *   <li>analyticsCalculateTodayHourly:0 0 * * * ?(每小时)</li>
+ *   <li>analyticsCalculateYesterdayDaily:0 0 2 * * ?(每天凌晨2点)</li>
+ *   <li>analyticsCalculateRetention:0 0 3 * * ?(每天凌晨3点)</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AnalyticsStatisticsJob {
+
+    private final AlienStoreFeign alienStoreFeign;
+
+    /**
+     * 每小时汇总当日数据(准实时看板)
+     */
+    @XxlJob("analyticsCalculateTodayHourly")
+    public void calculateTodayHourly() {
+        log.info("【定时任务】平台埋点统计-当日准实时汇总:开始执行");
+        try {
+            R<String> result = alienStoreFeign.calculateAnalyticsTodayHourly();
+            log.info("【定时任务】平台埋点统计-当日准实时汇总:执行完成,结果={}",
+                    result != null ? result.getData() : null);
+        } catch (Exception e) {
+            log.error("【定时任务】平台埋点统计-当日准实时汇总:执行异常", e);
+            throw e;
+        }
+    }
+
+    /**
+     * 每天汇总前日完整数据
+     */
+    @XxlJob("analyticsCalculateYesterdayDaily")
+    public void calculateYesterdayDaily() {
+        log.info("【定时任务】平台埋点统计-前日完整汇总:开始执行");
+        try {
+            R<String> result = alienStoreFeign.calculateAnalyticsYesterdayDaily();
+            log.info("【定时任务】平台埋点统计-前日完整汇总:执行完成,结果={}",
+                    result != null ? result.getData() : null);
+        } catch (Exception e) {
+            log.error("【定时任务】平台埋点统计-前日完整汇总:执行异常", e);
+            throw e;
+        }
+    }
+
+    /**
+     * 每天计算前日次日留存
+     */
+    @XxlJob("analyticsCalculateRetention")
+    public void calculateRetention() {
+        log.info("【定时任务】平台埋点统计-次日留存:开始执行");
+        try {
+            R<String> result = alienStoreFeign.calculateAnalyticsRetention();
+            log.info("【定时任务】平台埋点统计-次日留存:执行完成,结果={}",
+                    result != null ? result.getData() : null);
+        } catch (Exception e) {
+            log.error("【定时任务】平台埋点统计-次日留存:执行异常", e);
+            throw e;
+        }
+    }
+}

+ 260 - 0
alien-store/doc/analytics-front-sdk.js

@@ -0,0 +1,260 @@
+/**
+ * 平台埋点 SDK(uni-app / Vue 通用)
+ *
+ * 使用方式:
+ * 1. 复制到前端项目 utils/analytics.js
+ * 2. 初始化:Analytics.init({ baseUrl: 'https://api.xxx.com', getToken: () => uni.getStorageSync('token') })
+ * 3. 上报:Analytics.report('MERCHANT_VIEW', { merchantId: 1001 })
+ *
+ * 注意:请勿再调用旧接口 POST /track/event
+ */
+
+let config = {
+  baseUrl: '',
+  getToken: () => '',
+  channel: '',
+  city: '',
+};
+
+function uuid() {
+  return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    const v = c === 'x' ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+function getDeviceType() {
+  // #ifdef APP-PLUS
+  const platform = uni.getSystemInfoSync().platform;
+  if (platform === 'ios') return 'IOS';
+  if (platform === 'android') return 'ANDROID';
+  // #endif
+  // #ifdef MP-WEIXIN
+  return 'MINI_PROGRAM';
+  // #endif
+  return 'H5';
+}
+
+function request(url, data) {
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: `${config.baseUrl}${url}`,
+      method: 'POST',
+      data,
+      header: {
+        'Content-Type': 'application/json',
+        Authorization: config.getToken() || '',
+      },
+      success: (res) => {
+        if (res.data && res.data.success) {
+          resolve(res.data);
+        } else {
+          reject(res.data || res);
+        }
+      },
+      fail: reject,
+    });
+  });
+}
+
+const Analytics = {
+  init(options = {}) {
+    config = { ...config, ...options };
+  },
+
+  /**
+   * 统一埋点上报
+   * @param {string} scene 场景码,如 MERCHANT_VIEW
+   * @param {object} payload 业务参数
+   */
+  report(scene, payload = {}) {
+    const body = {
+      scene,
+      eventId: uuid(),
+      userId: payload.userId,
+      merchantId: payload.merchantId,
+      targetId: payload.targetId || payload.contentId,
+      contentType: payload.contentType,
+      amount: payload.amount,
+      durationMs: payload.durationMs,
+      deviceType: payload.deviceType || getDeviceType(),
+      channel: payload.channel || config.channel,
+      city: payload.city || config.city,
+    };
+    return request('/analytics/front/report', body);
+  },
+
+  /** 批量上报 */
+  batch(events) {
+    const list = (events || []).map((item) => ({
+      scene: item.scene,
+      eventId: uuid(),
+      userId: item.userId,
+      merchantId: item.merchantId,
+      targetId: item.targetId || item.contentId,
+      contentType: item.contentType,
+      amount: item.amount,
+      durationMs: item.durationMs,
+      deviceType: item.deviceType || getDeviceType(),
+      channel: item.channel || config.channel,
+      city: item.city || config.city,
+    }));
+    return request('/analytics/front/batch', { events: list });
+  },
+
+  /** 在线心跳,durationMs 为距上次心跳的毫秒数 */
+  heartbeat(durationMs, extra = {}) {
+    return request('/analytics/front/heartbeat', {
+      durationMs,
+      deviceType: extra.deviceType || getDeviceType(),
+      channel: extra.channel || config.channel,
+      city: extra.city || config.city,
+    });
+  },
+
+  /** App 启动 */
+  onLaunch(extra = {}) {
+    return this.report('APP_LAUNCH', extra);
+  },
+
+  /** 商家浏览埋点 */
+  merchantView(data) {
+    return request('/analytics/front/merchant/view', {
+      eventId: data.eventId || uuid(),
+      merchantId: data.merchantId,
+      shopType: data.shopType,
+      visitUv: data.visitUv,
+      visitPv: data.visitPv,
+      userId: data.userId,
+    });
+  },
+
+  /** 访问商户(委托 merchantView) */
+  onMerchantView(merchantId, extra = {}) {
+    return this.merchantView({
+      merchantId,
+      shopType: extra.shopType,
+      visitUv: extra.visitUv != null ? extra.visitUv : 0,
+      visitPv: extra.visitPv != null ? extra.visitPv : 1,
+      userId: extra.userId,
+      eventId: extra.eventId,
+    });
+  },
+
+  /** 内容互动(点赞/评论/收藏),increment 默认 +1 */
+  onContentInteract(contentId, contentType, extra = {}) {
+    return this.contentInteract({
+      contentId,
+      contentType,
+      increment: extra.increment != null ? extra.increment : 1,
+      userId: extra.userId,
+      eventId: extra.eventId,
+    });
+  },
+
+  /** 内容互动埋点 */
+  contentInteract(data) {
+    return request('/analytics/front/content/interact', {
+      eventId: data.eventId || uuid(),
+      contentId: data.contentId,
+      contentType: data.contentType,
+      increment: data.increment,
+      userId: data.userId,
+    });
+  },
+
+  /** AI 请求上报 */
+  aiRequest(data) {
+    return request('/analytics/front/ai-request', {
+      requestId: data.requestId || uuid(),
+      apiName: data.apiName,
+      apiUrl: data.apiUrl,
+      responseDurationMs: data.responseDurationMs,
+      isTimeout: data.isTimeout,
+    });
+  },
+
+  /** 用户注册埋点 */
+  userRegister(data) {
+    return request('/analytics/front/user/register', {
+      eventId: data.eventId || uuid(),
+      userId: data.userId,
+      userPhone: data.userPhone,
+      registerTime: data.registerTime,
+      channel: data.channel,
+      city: data.city,
+    });
+  },
+
+  /** 用户登录埋点 */
+  userLogin(data) {
+    return request('/analytics/front/user/login', {
+      eventId: data.eventId || uuid(),
+      userId: data.userId,
+      firstLaunchTime: data.firstLaunchTime,
+      lastActiveTime: data.lastActiveTime,
+      city: data.city,
+      deviceName: data.deviceName,
+    });
+  },
+
+  /** 用户登出埋点 */
+  userLogout(data) {
+    return request('/analytics/front/user/logout', {
+      eventId: data.eventId || uuid(),
+      userId: data.userId,
+      firstLaunchTime: data.firstLaunchTime,
+      lastActiveTime: data.lastActiveTime,
+      city: data.city,
+      deviceName: data.deviceName,
+      onlineDurationMin: data.onlineDurationMin,
+    });
+  },
+
+  /** AI 对话结束埋点 */
+  aiChatEnd(data) {
+    return request('/analytics/front/ai-chat/end', {
+      eventId: data.eventId || uuid(),
+      chatId: data.chatId,
+      userId: data.userId,
+      startTime: data.startTime,
+      messageCount: data.messageCount,
+      aiResponseDurationMs: data.aiResponseDurationMs,
+    });
+  },
+
+  /** 内容发布埋点 */
+  contentPublish(data) {
+    return request('/analytics/front/content/publish', {
+      eventId: data.eventId || uuid(),
+      contentId: data.contentId,
+      contentType: data.contentType,
+      authorType: data.authorType,
+      authorId: data.authorId,
+      publishTime: data.publishTime,
+    });
+  },
+
+  /** 启动心跳定时器,默认60秒 */
+  startHeartbeat(intervalMs = 60000) {
+    if (this._heartbeatTimer) {
+      clearInterval(this._heartbeatTimer);
+    }
+    let last = Date.now();
+    this._heartbeatTimer = setInterval(() => {
+      const now = Date.now();
+      this.heartbeat(now - last).catch(() => {});
+      last = now;
+    }, intervalMs);
+  },
+
+  stopHeartbeat() {
+    if (this._heartbeatTimer) {
+      clearInterval(this._heartbeatTimer);
+      this._heartbeatTimer = null;
+    }
+  },
+};
+
+export default Analytics;

+ 407 - 0
alien-store/doc/前端埋点接入指南.md

@@ -0,0 +1,407 @@
+# 前端埋点接入指南
+
+> **请前端统一使用 `/analytics/front` 接口,不要再调用旧接口 `POST /track/event`。**
+
+## 一、推荐接口
+
+| 接口 | 说明 |
+|------|------|
+| `POST /analytics/front/report` | **主入口**,传 `scene` 即可 |
+| `POST /analytics/front/batch` | 批量上报(最多50条) |
+| `POST /analytics/front/heartbeat` | 在线心跳(统计在线时长) |
+| `POST /analytics/front/user/register` | **用户注册埋点** |
+| `POST /analytics/front/user/login` | **用户登录埋点** |
+| `POST /analytics/front/user/logout` | **用户登出埋点** |
+| `POST /analytics/front/ai-chat/end` | **AI对话结束埋点** |
+| `POST /analytics/front/content/publish` | **内容发布埋点** |
+| `POST /analytics/front/content/interact` | **内容互动埋点**(评论/点赞/收藏) |
+| `POST /analytics/front/merchant/view` | **商家浏览埋点** |
+| `POST /analytics/front/ai-request` | AI 请求耗时上报 |
+| `GET /analytics/front/scenes` | 获取全部 scene 列表 |
+
+明细同步(业务侧主动推送完整字段):
+
+| 接口 | 说明 |
+|------|------|
+| `POST /analytics/detail/user` | 用户明细 |
+| `POST /analytics/detail/merchant` | 商户明细 |
+| `POST /analytics/detail/content` | 内容明细 |
+| `POST /analytics/detail/ai-chat` | AI 对话明细 |
+
+## 二、快速开始
+
+### 1. 复制 SDK
+
+将 `alien-store/doc/analytics-front-sdk.js` 复制到前端项目 `utils/analytics.js`。
+
+### 2. 初始化(App.vue onLaunch)
+
+```javascript
+import Analytics from '@/utils/analytics.js';
+
+Analytics.init({
+  baseUrl: 'https://your-api.com',
+  getToken: () => uni.getStorageSync('token'),
+  channel: 'app_store',
+});
+
+Analytics.onLaunch();
+Analytics.startHeartbeat(60000);
+```
+
+### 3. 用户注册 / 登录 / 登出
+
+```javascript
+// 注册成功
+Analytics.userRegister({
+  userId: 10001,
+  userPhone: '13800138000',
+  registerTime: '2026-06-15 10:00:00',
+  channel: 'app_store',
+  city: '北京',
+});
+
+// 登录成功
+Analytics.userLogin({
+  userId: 10001,
+  city: '北京',
+  deviceName: 'iPhone 15',
+});
+
+// 登出
+Analytics.userLogout({
+  userId: 10001,
+  city: '北京',
+  deviceName: 'iPhone 15',
+  onlineDurationMin: 30,
+});
+```
+
+### 4. AI 对话结束
+
+```javascript
+Analytics.aiChatEnd({
+  chatId: 'chat-uuid-001',
+  userId: 10001,
+  startTime: '2026-06-15 14:00:00',
+  messageCount: 12,
+  aiResponseDurationMs: 8500,
+});
+```
+
+### 5. 内容发布
+
+```javascript
+Analytics.contentPublish({
+  contentId: 90001,
+  contentType: 1,   // 1动态 2打卡 3二手商品
+  authorType: 1,    // 1用户 2商家
+  authorId: 10001,
+  publishTime: '2026-06-15 16:00:00',
+});
+```
+
+### 6. 内容互动(评论 / 点赞 / 收藏)
+
+```javascript
+// 点赞 +1
+Analytics.contentInteract({
+  contentId: 90001,
+  contentType: 1,
+  increment: 1,
+  userId: 10001,
+});
+
+// 取消点赞 -1
+Analytics.contentInteract({
+  contentId: 90001,
+  contentType: 1,
+  increment: -1,
+  userId: 10001,
+});
+
+// 兼容旧写法(默认 increment=1)
+Analytics.onContentInteract(90001, 1, { userId: 10001 });
+```
+
+### 7. 商家浏览
+
+```javascript
+// 进入商家页:PV+1,当日首次访问 UV+1
+Analytics.merchantView({
+  merchantId: 20001,
+  shopType: 1,   // 1美食 2休闲娱乐 3生活服务
+  visitPv: 1,
+  visitUv: 1,
+  userId: 10001,
+});
+
+// 兼容旧写法(需传 shopType、visitPv、visitUv)
+Analytics.onMerchantView(20001, {
+  shopType: 1,
+  visitPv: 1,
+  visitUv: 1,
+  userId: 10001,
+});
+```
+
+### 8. 业务页面埋点示例
+
+```javascript
+// 访问商户页
+Analytics.onMerchantView(merchantId, {
+  shopType: 1,
+  visitPv: 1,
+  visitUv: 1,
+});
+
+// 内容互动(contentType: 1动态 2打卡 3二手商品)
+Analytics.onContentInteract(contentId, 1);
+
+// 支付成功
+Analytics.report('PAY_SUCCESS', { amount: 99.00, targetId: orderId });
+
+// AI 请求耗时
+Analytics.aiRequest({
+  apiName: 'chat.completion',
+  apiUrl: '/api/ai/chat',
+  responseDurationMs: 1200,
+  isTimeout: 0,
+});
+```
+
+## 三、事件表独立字段说明
+
+`analytics_event` 表每个业务值对应独立列,**不使用 JSON 扩展字段**:
+
+| 字段 | 说明 |
+|------|------|
+| userId / merchantId / targetId | 用户、商户、目标对象 |
+| contentType | 1动态 2打卡 3二手商品 |
+| amount / durationMs | 金额、时长 |
+| deviceType / channel / city | 设备、渠道、城市 |
+
+## 四、scene 场景对照表
+
+| scene | 含义 | 主要参数 |
+|-------|------|----------|
+| `APP_LAUNCH` | App 启动 | channel, city |
+| `APP_REGISTER` | 用户注册 | - |
+| `USER_HEARTBEAT` | 在线心跳 | durationMs |
+| `MERCHANT_VIEW` | 访问商户 | merchantId |
+| `MERCHANT_VERIFY` | 商户核销 | merchantId, amount |
+| `CONTENT_PUBLISH` | 发布内容 | targetId, contentType |
+| `CONTENT_INTERACT` | 内容互动 | targetId, contentType |
+| `PAY_SUCCESS` | 支付成功 | targetId, amount |
+| `AI_CHAT_START` | AI 对话开始 | - |
+| `REPORT_SUBMIT` | 提交举报 | targetId |
+| `AUDIT_SUBMIT` | 提交审核 | targetId, contentType |
+
+完整列表调用:`GET /analytics/front/scenes`
+
+## 五、请求示例
+
+### 统一上报
+
+```http
+POST /analytics/front/report
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+  "scene": "MERCHANT_VIEW",
+  "eventId": "a1b2c3d4e5f6",
+  "merchantId": 1001,
+  "city": "北京",
+  "channel": "ios"
+}
+```
+
+> `userId` 可不传,服务端从 JWT 自动解析。
+
+### 在线心跳
+
+```http
+POST /analytics/front/heartbeat
+
+{
+  "durationMs": 60000
+}
+```
+
+### 用户注册
+
+```http
+POST /analytics/front/user/register
+
+{
+  "userId": 10001,
+  "userPhone": "13800138000",
+  "registerTime": "2026-06-15 10:00:00",
+  "channel": "app_store",
+  "city": "北京"
+}
+```
+
+### 用户登录
+
+```http
+POST /analytics/front/user/login
+
+{
+  "userId": 10001,
+  "lastActiveTime": "2026-06-15 14:30:00",
+  "city": "北京",
+  "deviceName": "iPhone 15"
+}
+```
+
+### 用户登出
+
+```http
+POST /analytics/front/user/logout
+
+{
+  "userId": 10001,
+  "lastActiveTime": "2026-06-15 15:00:00",
+  "city": "北京",
+  "deviceName": "iPhone 15",
+  "onlineDurationMin": 30
+}
+```
+
+### AI 对话结束
+
+```http
+POST /analytics/front/ai-chat/end
+
+{
+  "chatId": "chat-uuid-001",
+  "userId": 10001,
+  "startTime": "2026-06-15 14:00:00",
+  "messageCount": 12,
+  "aiResponseDurationMs": 8500
+}
+```
+
+### 内容发布
+
+```http
+POST /analytics/front/content/publish
+
+{
+  "contentId": 90001,
+  "contentType": 1,
+  "authorType": 1,
+  "authorId": 10001,
+  "publishTime": "2026-06-15 16:00:00"
+}
+```
+
+### 内容互动
+
+```http
+POST /analytics/front/content/interact
+
+{
+  "contentId": 90001,
+  "contentType": 1,
+  "increment": 1,
+  "userId": 10001
+}
+```
+
+> `increment` 由前端决定增减幅度:评论/点赞/收藏传正数,取消操作传负数。会实时累加到 `analytics_content_stat.interaction_count`。
+
+### 商家浏览
+
+```http
+POST /analytics/front/merchant/view
+
+{
+  "merchantId": 20001,
+  "shopType": 1,
+  "visitUv": 1,
+  "visitPv": 1,
+  "userId": 10001
+}
+```
+
+> `visitUv`、`visitPv` 为当日增量:通常 PV 传 1;UV 在当日首次访问该商家时传 1,否则传 0。
+
+### 商户明细同步(可选)
+
+```http
+POST /analytics/detail/merchant
+
+{
+  "merchantId": 20001,
+  "shopType": 1,
+  "visitUv": 100,
+  "visitPv": 500,
+  "settleStatus": 1
+}
+```
+
+> 明细同步接口中 `visitUv`/`visitPv` 为**覆盖写入**;浏览埋点 `/merchant/view` 为**累加写入**,请勿混用语义。
+
+> 用户名称、商家名称、作者名称等展示字段**不入库、不上报**,查询明细时由服务端按 ID 回填。
+
+### AI 请求明细
+
+```http
+POST /analytics/front/ai-request
+
+{
+  "apiName": "chat.completion",
+  "apiUrl": "/api/ai/chat",
+  "responseDurationMs": 1200,
+  "isTimeout": 0
+}
+```
+
+## 六、幂等与防重复
+
+- 每条上报建议传 `eventId`(前端 UUID)
+- 相同 `eventId` 重复提交会被服务端忽略,不会重复计数
+
+## 七、管理端统计
+
+建表脚本:`alien-entity/src/main/resources/db/migration/analytics_tables.sql`
+
+埋点只写明细,看板数据由 **alien-job** 模块 XXL-JOB 定时任务或手动接口触发汇总:
+
+| XXL-JOB Handler | Cron 建议 | 说明 |
+|-----------------|-----------|------|
+| `analyticsCalculateTodayHourly` | `0 0 * * * ?` | 每小时汇总当日 |
+| `analyticsCalculateYesterdayDaily` | `0 0 2 * * ?` | 每天凌晨2点汇总前日 |
+| `analyticsCalculateRetention` | `0 0 3 * * ?` | 每天凌晨3点计算留存 |
+
+手动触发:
+
+```http
+POST /analytics/stat/calculate
+{ "statDate": "2026-06-15", "scope": "ALL" }
+```
+
+查询看板:
+
+```http
+GET /analytics/stat/daily?statDate=2026-06-15
+```
+
+## 八、代码目录(与旧埋点完全隔离)
+
+```
+alien-store/src/main/java/shop/alien/store/
+├── controller/analytics/
+│   ├── AnalyticsFrontController   # 前端上报 /analytics/front/*
+│   ├── AnalyticsDetailController  # 明细上报 /analytics/detail/*
+│   ├── AnalyticsStatController    # 统计查询 /analytics/stat/*
+│   └── AnalyticsJobController     # 定时任务回调 /analytics/job/*
+├── service/analytics/
+└── util/analytics/
+
+alien-job/src/main/java/shop/alien/job/store/
+└── AnalyticsStatisticsJob         # XXL-JOB 调度入口
+```

+ 69 - 0
alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsDetailController.java

@@ -0,0 +1,69 @@
+package shop.alien.store.controller.analytics;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import io.swagger.annotations.ApiSort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.analytics.dto.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.service.analytics.AnalyticsTrackService;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 平台埋点 - 明细上报接口
+ */
+@Slf4j
+@Api(tags = {"平台埋点-明细上报"})
+@ApiSort(21)
+@CrossOrigin
+@RestController
+@RequestMapping("/analytics/detail")
+@RequiredArgsConstructor
+public class AnalyticsDetailController {
+
+    private final AnalyticsTrackService trackService;
+
+    @ApiOperation("上报原始事件(底层)")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/event")
+    public R<String> trackEvent(@RequestBody AnalyticsTrackEventDTO dto, HttpServletRequest request) {
+        trackService.trackEvent(dto, request);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation("上报用户明细")
+    @ApiOperationSupport(order = 2)
+    @PostMapping("/user")
+    public R<String> trackUserDetail(@RequestBody AnalyticsUserDetailDTO dto) {
+        trackService.upsertUserDetail(dto);
+        return R.success("保存成功");
+    }
+
+    @ApiOperation("上报商户明细")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/merchant")
+    public R<String> trackMerchantDetail(@RequestBody AnalyticsMerchantDetailDTO dto) {
+        trackService.upsertMerchantDetail(dto);
+        return R.success("保存成功");
+    }
+
+    @ApiOperation("上报内容明细")
+    @ApiOperationSupport(order = 4)
+    @PostMapping("/content")
+    public R<String> trackContentDetail(@RequestBody AnalyticsContentDetailDTO dto) {
+        trackService.upsertContentDetail(dto);
+        return R.success("保存成功");
+    }
+
+    @ApiOperation("上报AI对话明细")
+    @ApiOperationSupport(order = 5)
+    @PostMapping("/ai-chat")
+    public R<String> trackAiChatDetail(@RequestBody AnalyticsAiChatDetailDTO dto) {
+        trackService.upsertAiChatDetail(dto);
+        return R.success("保存成功");
+    }
+}

+ 132 - 0
alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsFrontController.java

@@ -0,0 +1,132 @@
+package shop.alien.store.controller.analytics;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import io.swagger.annotations.ApiSort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.analytics.AnalyticsScene;
+import shop.alien.entity.analytics.dto.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.service.analytics.AnalyticsTrackService;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 平台埋点 - 前端上报接口
+ */
+@Slf4j
+@Api(tags = {"平台埋点-前端上报"})
+@ApiSort(20)
+@CrossOrigin
+@RestController
+@RequestMapping("/analytics/front")
+@RequiredArgsConstructor
+public class AnalyticsFrontController {
+
+    private final AnalyticsTrackService trackService;
+
+    @ApiOperation(value = "统一埋点上报", notes = "只需传 scene,userId 可从 JWT 自动获取")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/report")
+    public R<String> report(@RequestBody AnalyticsFrontReportDTO dto, HttpServletRequest request) {
+        trackService.trackFromFront(dto, request);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "批量埋点上报", notes = "单次最多50条")
+    @ApiOperationSupport(order = 2)
+    @PostMapping("/batch")
+    public R<String> batchReport(@RequestBody AnalyticsBatchTrackDTO dto, HttpServletRequest request) {
+        trackService.batchTrackFromFront(dto, request);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "在线心跳", notes = "建议每30~60秒上报 durationMs")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/heartbeat")
+    public R<String> heartbeat(@RequestBody AnalyticsHeartbeatDTO dto, HttpServletRequest request) {
+        trackService.trackHeartbeat(dto, request);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation("获取可用埋点场景列表")
+    @ApiOperationSupport(order = 4)
+    @GetMapping("/scenes")
+    public R<Map<String, Object>> scenes() {
+        Map<String, Object> data = new HashMap<>();
+        data.put("scenes", AnalyticsScene.toCatalog());
+        data.put("contentTargetTypes", AnalyticsScene.contentTypeGuide());
+        data.put("shopTypes", AnalyticsScene.shopTypeGuide());
+        data.put("eventFields", AnalyticsScene.eventFieldGuide());
+        return R.data(data);
+    }
+
+    @ApiOperation("上报AI请求")
+    @ApiOperationSupport(order = 5)
+    @PostMapping("/ai-request")
+    public R<String> aiRequest(@RequestBody AnalyticsAiRequestDTO dto) {
+        trackService.trackAiRequest(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "用户注册埋点", notes = "注册成功后调用,写入用户明细与注册事件")
+    @ApiOperationSupport(order = 6)
+    @PostMapping("/user/register")
+    public R<String> userRegister(@RequestBody AnalyticsUserRegisterDTO dto) {
+        trackService.trackUserRegister(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "用户登录埋点", notes = "登录成功后调用,写入用户明细与登录事件")
+    @ApiOperationSupport(order = 7)
+    @PostMapping("/user/login")
+    public R<String> userLogin(@RequestBody AnalyticsUserLoginDTO dto) {
+        trackService.trackUserLogin(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "用户登出埋点", notes = "登出时调用,累加当日在线时长(分)")
+    @ApiOperationSupport(order = 8)
+    @PostMapping("/user/logout")
+    public R<String> userLogout(@RequestBody AnalyticsUserLogoutDTO dto) {
+        trackService.trackUserLogout(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "AI对话结束埋点", notes = "对话结束时调用,写入AI对话明细")
+    @ApiOperationSupport(order = 9)
+    @PostMapping("/ai-chat/end")
+    public R<String> aiChatEnd(@RequestBody AnalyticsAiChatEndDTO dto) {
+        trackService.trackAiChatEnd(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "内容发布埋点", notes = "内容发布成功后调用,写入内容明细与发布事件")
+    @ApiOperationSupport(order = 10)
+    @PostMapping("/content/publish")
+    public R<String> contentPublish(@RequestBody AnalyticsContentPublishDTO dto) {
+        trackService.trackContentPublish(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "内容互动埋点", notes = "评论/点赞/收藏等操作时调用,累加内容明细互动数")
+    @ApiOperationSupport(order = 11)
+    @PostMapping("/content/interact")
+    public R<String> contentInteract(@RequestBody AnalyticsContentInteractDTO dto) {
+        trackService.trackContentInteract(dto);
+        return R.success("上报成功");
+    }
+
+    @ApiOperation(value = "商家浏览埋点", notes = "浏览商家时调用,累加当日访问UV/PV")
+    @ApiOperationSupport(order = 12)
+    @PostMapping("/merchant/view")
+    public R<String> merchantView(@RequestBody AnalyticsMerchantViewDTO dto) {
+        trackService.trackMerchantView(dto);
+        return R.success("上报成功");
+    }
+}

+ 65 - 0
alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsJobController.java

@@ -0,0 +1,65 @@
+package shop.alien.store.controller.analytics;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import shop.alien.entity.analytics.AnalyticsStatScope;
+import shop.alien.entity.result.R;
+import shop.alien.store.service.analytics.AnalyticsStatisticsService;
+import shop.alien.store.util.analytics.AnalyticsDateUtil;
+
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * 平台埋点统计定时任务回调(供 XXL-JOB 通过 Feign 调用)
+ */
+@Slf4j
+@Api(tags = {"平台埋点-定时任务"})
+@RestController
+@RequestMapping("/analytics/job")
+@RequiredArgsConstructor
+public class AnalyticsJobController {
+
+    private static final String TRIGGER_SCHEDULE = "SCHEDULE";
+
+    private final AnalyticsStatisticsService statisticsService;
+
+    @ApiOperation("每小时汇总当日数据(准实时看板)")
+    @PostMapping("/calculateTodayHourly")
+    public R<String> calculateTodayHourly() {
+        Date today = AnalyticsDateUtil.truncateToDate(new Date());
+        log.info("analytics job: calculateTodayHourly 开始, statDate={}", today);
+        statisticsService.calculate(today, AnalyticsStatScope.ALL, TRIGGER_SCHEDULE);
+        log.info("analytics job: calculateTodayHourly 结束, statDate={}", today);
+        return R.success("统计完成");
+    }
+
+    @ApiOperation("汇总前日完整数据")
+    @PostMapping("/calculateYesterdayDaily")
+    public R<String> calculateYesterdayDaily() {
+        Calendar calendar = Calendar.getInstance();
+        calendar.add(Calendar.DAY_OF_MONTH, -1);
+        Date yesterday = AnalyticsDateUtil.truncateToDate(calendar.getTime());
+        log.info("analytics job: calculateYesterdayDaily 开始, statDate={}", yesterday);
+        statisticsService.calculate(yesterday, AnalyticsStatScope.ALL, TRIGGER_SCHEDULE);
+        log.info("analytics job: calculateYesterdayDaily 结束, statDate={}", yesterday);
+        return R.success("统计完成");
+    }
+
+    @ApiOperation("计算前日次日留存")
+    @PostMapping("/calculateRetention")
+    public R<String> calculateRetention() {
+        Calendar calendar = Calendar.getInstance();
+        calendar.add(Calendar.DAY_OF_MONTH, -1);
+        Date yesterday = AnalyticsDateUtil.truncateToDate(calendar.getTime());
+        log.info("analytics job: calculateRetention 开始, statDate={}", yesterday);
+        statisticsService.calculate(yesterday, AnalyticsStatScope.DAILY, TRIGGER_SCHEDULE);
+        log.info("analytics job: calculateRetention 结束, statDate={}", yesterday);
+        return R.success("统计完成");
+    }
+}

+ 129 - 0
alien-store/src/main/java/shop/alien/store/controller/analytics/AnalyticsStatController.java

@@ -0,0 +1,129 @@
+package shop.alien.store.controller.analytics;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiSort;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.analytics.*;
+import shop.alien.entity.analytics.dto.AnalyticsCalculateDTO;
+import shop.alien.entity.analytics.vo.AnalyticsContentStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsMerchantStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsUserStatVo;
+import shop.alien.entity.result.R;
+import shop.alien.mapper.*;
+import shop.alien.store.service.analytics.AnalyticsStatEnrichService;
+import shop.alien.store.service.analytics.AnalyticsStatisticsService;
+
+import java.util.Date;
+
+/**
+ * 平台埋点 - 统计汇总与查询
+ */
+@Slf4j
+@Api(tags = {"平台埋点-统计查询"})
+@ApiSort(22)
+@CrossOrigin
+@RestController
+@RequestMapping("/analytics/stat")
+@RequiredArgsConstructor
+public class AnalyticsStatController {
+
+    private static final String TRIGGER_MANUAL = "MANUAL";
+
+    private final AnalyticsStatisticsService statisticsService;
+    private final AnalyticsStatEnrichService statEnrichService;
+    private final AnalyticsAiChatStatMapper aiChatStatMapper;
+    private final AnalyticsAiRequestMapper aiRequestMapper;
+    private final AnalyticsStatJobLogMapper jobLogMapper;
+
+    @ApiOperation("手动触发统计汇总")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/calculate")
+    public R<String> calculateStatistics(@RequestBody AnalyticsCalculateDTO dto) {
+        if (dto.getStatDate() == null) {
+            return R.fail("statDate 不能为空");
+        }
+        String scope = dto.getScope() != null ? dto.getScope() : AnalyticsStatScope.ALL;
+        statisticsService.calculate(dto.getStatDate(), scope, TRIGGER_MANUAL);
+        return R.success("统计完成");
+    }
+
+    @ApiOperation("查询平台日统计")
+    @ApiOperationSupport(order = 2)
+    @GetMapping("/daily")
+    public R<AnalyticsDailySummary> getDailySummary(
+            @ApiParam("统计日期") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Date statDate) {
+        return R.data(statisticsService.getDailySummary(statDate));
+    }
+
+    @ApiOperation("分页查询用户明细")
+    @ApiOperationSupport(order = 3)
+    @GetMapping("/user/page")
+    public R<IPage<AnalyticsUserStatVo>> pageUserStat(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date statDate) {
+        return R.data(statEnrichService.pageUserStat(page, size, statDate));
+    }
+
+    @ApiOperation("分页查询商户明细")
+    @ApiOperationSupport(order = 4)
+    @GetMapping("/merchant/page")
+    public R<IPage<AnalyticsMerchantStatVo>> pageMerchantStat(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date statDate) {
+        return R.data(statEnrichService.pageMerchantStat(page, size, statDate));
+    }
+
+    @ApiOperation("分页查询内容明细")
+    @ApiOperationSupport(order = 5)
+    @GetMapping("/content/page")
+    public R<IPage<AnalyticsContentStatVo>> pageContentStat(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size,
+            @RequestParam(required = false) Integer contentType) {
+        return R.data(statEnrichService.pageContentStat(page, size, contentType));
+    }
+
+    @ApiOperation("分页查询AI对话明细")
+    @ApiOperationSupport(order = 6)
+    @GetMapping("/ai-chat/page")
+    public R<IPage<AnalyticsAiChatStat>> pageAiChatStat(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size) {
+        LambdaQueryWrapper<AnalyticsAiChatStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByDesc(AnalyticsAiChatStat::getStartTime);
+        return R.data(aiChatStatMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+
+    @ApiOperation("分页查询AI请求明细")
+    @ApiOperationSupport(order = 7)
+    @GetMapping("/ai-request/page")
+    public R<IPage<AnalyticsAiRequest>> pageAiRequest(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size) {
+        LambdaQueryWrapper<AnalyticsAiRequest> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByDesc(AnalyticsAiRequest::getCreatedTime);
+        return R.data(aiRequestMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+
+    @ApiOperation("分页查询统计任务日志")
+    @ApiOperationSupport(order = 8)
+    @GetMapping("/job-log/page")
+    public R<IPage<AnalyticsStatJobLog>> pageJobLog(
+            @RequestParam(defaultValue = "1") long page,
+            @RequestParam(defaultValue = "10") long size) {
+        LambdaQueryWrapper<AnalyticsStatJobLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByDesc(AnalyticsStatJobLog::getStartTime);
+        return R.data(jobLogMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+}

+ 17 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/AnalyticsStatEnrichService.java

@@ -0,0 +1,17 @@
+package shop.alien.store.service.analytics;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import shop.alien.entity.analytics.vo.AnalyticsContentStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsMerchantStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsUserStatVo;
+
+import java.util.Date;
+
+public interface AnalyticsStatEnrichService {
+
+    IPage<AnalyticsUserStatVo> pageUserStat(long page, long size, Date statDate);
+
+    IPage<AnalyticsMerchantStatVo> pageMerchantStat(long page, long size, Date statDate);
+
+    IPage<AnalyticsContentStatVo> pageContentStat(long page, long size, Integer contentType);
+}

+ 19 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/AnalyticsStatisticsService.java

@@ -0,0 +1,19 @@
+package shop.alien.store.service.analytics;
+
+import shop.alien.entity.analytics.AnalyticsDailySummary;
+
+import java.util.Date;
+
+public interface AnalyticsStatisticsService {
+
+    /**
+     * 执行统计汇总
+     *
+     * @param statDate     统计日期
+     * @param scope        统计范围
+     * @param triggerType  SCHEDULE / MANUAL
+     */
+    void calculate(Date statDate, String scope, String triggerType);
+
+    AnalyticsDailySummary getDailySummary(Date statDate);
+}

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

@@ -0,0 +1,40 @@
+package shop.alien.store.service.analytics;
+
+import shop.alien.entity.analytics.dto.*;
+
+import javax.servlet.http.HttpServletRequest;
+
+public interface AnalyticsTrackService {
+
+    void trackEvent(AnalyticsTrackEventDTO dto, HttpServletRequest request);
+
+    void trackFromFront(AnalyticsFrontReportDTO dto, HttpServletRequest request);
+
+    void batchTrackFromFront(AnalyticsBatchTrackDTO dto, HttpServletRequest request);
+
+    void trackHeartbeat(AnalyticsHeartbeatDTO dto, HttpServletRequest request);
+
+    void trackAiRequest(AnalyticsAiRequestDTO dto);
+
+    void upsertUserDetail(AnalyticsUserDetailDTO dto);
+
+    void upsertMerchantDetail(AnalyticsMerchantDetailDTO dto);
+
+    void upsertContentDetail(AnalyticsContentDetailDTO dto);
+
+    void upsertAiChatDetail(AnalyticsAiChatDetailDTO dto);
+
+    void trackUserLogin(AnalyticsUserLoginDTO dto);
+
+    void trackUserLogout(AnalyticsUserLogoutDTO dto);
+
+    void trackUserRegister(AnalyticsUserRegisterDTO dto);
+
+    void trackAiChatEnd(AnalyticsAiChatEndDTO dto);
+
+    void trackContentPublish(AnalyticsContentPublishDTO dto);
+
+    void trackContentInteract(AnalyticsContentInteractDTO dto);
+
+    void trackMerchantView(AnalyticsMerchantViewDTO dto);
+}

+ 157 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsStatEnrichServiceImpl.java

@@ -0,0 +1,157 @@
+package shop.alien.store.service.analytics.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.BeanUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.analytics.AnalyticsContentStat;
+import shop.alien.entity.analytics.AnalyticsMerchantStat;
+import shop.alien.entity.analytics.AnalyticsUserStat;
+import shop.alien.entity.analytics.vo.AnalyticsContentStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsMerchantStatVo;
+import shop.alien.entity.analytics.vo.AnalyticsUserStatVo;
+import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreUser;
+import shop.alien.mapper.AnalyticsContentStatMapper;
+import shop.alien.mapper.AnalyticsMerchantStatMapper;
+import shop.alien.mapper.AnalyticsUserStatMapper;
+import shop.alien.mapper.LifeUserMapper;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreUserMapper;
+import shop.alien.store.service.analytics.AnalyticsStatEnrichService;
+import shop.alien.store.util.analytics.AnalyticsDateUtil;
+
+import java.util.Date;
+
+@Service
+@RequiredArgsConstructor
+public class AnalyticsStatEnrichServiceImpl implements AnalyticsStatEnrichService {
+
+    private final AnalyticsUserStatMapper userStatMapper;
+    private final AnalyticsMerchantStatMapper merchantStatMapper;
+    private final AnalyticsContentStatMapper contentStatMapper;
+    private final LifeUserMapper lifeUserMapper;
+    private final StoreInfoMapper storeInfoMapper;
+    private final StoreUserMapper storeUserMapper;
+
+    @Override
+    public IPage<AnalyticsUserStatVo> pageUserStat(long page, long size, Date statDate) {
+        LambdaQueryWrapper<AnalyticsUserStat> wrapper = new LambdaQueryWrapper<>();
+        if (statDate != null) {
+            wrapper.eq(AnalyticsUserStat::getStatDate, AnalyticsDateUtil.truncateToDate(statDate));
+        }
+        wrapper.orderByDesc(AnalyticsUserStat::getLastActiveTime);
+        IPage<AnalyticsUserStat> raw = userStatMapper.selectPage(new Page<>(page, size), wrapper);
+        return raw.convert(this::toUserVo);
+    }
+
+    @Override
+    public IPage<AnalyticsMerchantStatVo> pageMerchantStat(long page, long size, Date statDate) {
+        LambdaQueryWrapper<AnalyticsMerchantStat> wrapper = new LambdaQueryWrapper<>();
+        if (statDate != null) {
+            wrapper.eq(AnalyticsMerchantStat::getStatDate, AnalyticsDateUtil.truncateToDate(statDate));
+        }
+        wrapper.orderByDesc(AnalyticsMerchantStat::getVisitPv);
+        IPage<AnalyticsMerchantStat> raw = merchantStatMapper.selectPage(new Page<>(page, size), wrapper);
+        return raw.convert(this::toMerchantVo);
+    }
+
+    @Override
+    public IPage<AnalyticsContentStatVo> pageContentStat(long page, long size, Integer contentType) {
+        LambdaQueryWrapper<AnalyticsContentStat> wrapper = new LambdaQueryWrapper<>();
+        if (contentType != null) {
+            wrapper.eq(AnalyticsContentStat::getContentType, contentType);
+        }
+        wrapper.orderByDesc(AnalyticsContentStat::getPublishTime);
+        IPage<AnalyticsContentStat> raw = contentStatMapper.selectPage(new Page<>(page, size), wrapper);
+        return raw.convert(this::toContentVo);
+    }
+
+    private AnalyticsUserStatVo toUserVo(AnalyticsUserStat stat) {
+        AnalyticsUserStatVo vo = new AnalyticsUserStatVo();
+        BeanUtils.copyProperties(stat, vo);
+        if (stat.getUserId() != null) {
+            LifeUser user = lifeUserMapper.selectById(stat.getUserId());
+            if (user != null) {
+                vo.setUserName(user.getUserName());
+            }
+        }
+        return vo;
+    }
+
+    private AnalyticsMerchantStatVo toMerchantVo(AnalyticsMerchantStat stat) {
+        AnalyticsMerchantStatVo vo = new AnalyticsMerchantStatVo();
+        BeanUtils.copyProperties(stat, vo);
+        vo.setShopTypeName(resolveShopTypeName(stat.getShopType()));
+        if (stat.getMerchantId() != null) {
+            StoreInfo store = storeInfoMapper.selectById(stat.getMerchantId());
+            if (store != null) {
+                vo.setMerchantName(store.getStoreName());
+                vo.setCategoryName(firstNonBlank(store.getBusinessCategoryName(), store.getBusinessTypeName()));
+            }
+        }
+        return vo;
+    }
+
+    private AnalyticsContentStatVo toContentVo(AnalyticsContentStat stat) {
+        AnalyticsContentStatVo vo = new AnalyticsContentStatVo();
+        BeanUtils.copyProperties(stat, vo);
+        vo.setContentTypeName(resolveContentTypeName(stat.getContentType()));
+        vo.setAuthorName(resolveAuthorName(stat.getAuthorType(), stat.getAuthorId()));
+        if (stat.getAuditUserId() != null) {
+            LifeUser auditor = lifeUserMapper.selectById(stat.getAuditUserId());
+            if (auditor != null) {
+                vo.setAuditUserName(auditor.getUserName());
+            }
+        }
+        return vo;
+    }
+
+    private String resolveAuthorName(Integer authorType, Long authorId) {
+        if (authorType == null || authorId == null || authorId <= 0) {
+            return null;
+        }
+        if (authorType == 2) {
+            StoreUser storeUser = storeUserMapper.selectById(authorId);
+            if (storeUser != null && StringUtils.hasText(storeUser.getNickName())) {
+                return storeUser.getNickName();
+            }
+            StoreInfo store = storeInfoMapper.selectById(authorId);
+            return store != null ? store.getStoreName() : null;
+        }
+        LifeUser user = lifeUserMapper.selectById(authorId);
+        return user != null ? user.getUserName() : null;
+    }
+
+    private String resolveContentTypeName(Integer contentType) {
+        if (contentType == null) {
+            return null;
+        }
+        switch (contentType) {
+            case 1: return "动态";
+            case 2: return "打卡";
+            case 3: return "二手商品";
+            default: return "未知";
+        }
+    }
+
+    private String resolveShopTypeName(Integer shopType) {
+        if (shopType == null) {
+            return null;
+        }
+        switch (shopType) {
+            case 1: return "美食";
+            case 2: return "休闲娱乐";
+            case 3: return "生活服务";
+            default: return "未知";
+        }
+    }
+
+    private String firstNonBlank(String first, String second) {
+        return StringUtils.hasText(first) ? first : second;
+    }
+}

+ 397 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsStatisticsServiceImpl.java

@@ -0,0 +1,397 @@
+package shop.alien.store.service.analytics.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.analytics.*;
+import shop.alien.mapper.*;
+import shop.alien.store.config.BaseRedisService;
+import shop.alien.store.service.analytics.AnalyticsStatisticsService;
+import shop.alien.store.util.analytics.AnalyticsDateUtil;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsService {
+
+    private static final String LOCK_PREFIX = "analytics:stat:calc:";
+
+    private final AnalyticsEventMapper eventMapper;
+    private final AnalyticsAiRequestMapper aiRequestMapper;
+    private final AnalyticsUserStatMapper userStatMapper;
+    private final AnalyticsMerchantStatMapper merchantStatMapper;
+    private final AnalyticsContentStatMapper contentStatMapper;
+    private final AnalyticsDailySummaryMapper dailySummaryMapper;
+    private final AnalyticsStatJobLogMapper jobLogMapper;
+    private final BaseRedisService baseRedisService;
+
+    @Override
+    public void calculate(Date statDate, String scope, String triggerType) {
+        Date day = AnalyticsDateUtil.truncateToDate(statDate);
+        String normalizedScope = StringUtils.hasText(scope) ? scope.toUpperCase() : AnalyticsStatScope.ALL;
+        String lockKey = LOCK_PREFIX + day.getTime() + ":" + normalizedScope;
+        String lockToken = baseRedisService.lock(lockKey, 120000, 3000);
+        if (lockToken == null) {
+            throw new IllegalStateException("统计任务正在执行中,请稍后再试");
+        }
+
+        String jobId = UUID.randomUUID().toString().replace("-", "");
+        AnalyticsStatJobLog jobLog = new AnalyticsStatJobLog();
+        jobLog.setJobId(jobId);
+        jobLog.setStatDate(day);
+        jobLog.setScope(normalizedScope);
+        jobLog.setTriggerType(triggerType);
+        jobLog.setStatus(0);
+        jobLog.setStartTime(new Date());
+        jobLogMapper.insert(jobLog);
+
+        try {
+            if (AnalyticsStatScope.ALL.equals(normalizedScope) || AnalyticsStatScope.USER.equals(normalizedScope)) {
+                calculateUserStat(day);
+            }
+            if (AnalyticsStatScope.ALL.equals(normalizedScope) || AnalyticsStatScope.MERCHANT.equals(normalizedScope)) {
+                calculateMerchantStat(day);
+            }
+            if (AnalyticsStatScope.ALL.equals(normalizedScope) || AnalyticsStatScope.CONTENT.equals(normalizedScope)) {
+                calculateContentStat(day);
+            }
+            if (AnalyticsStatScope.ALL.equals(normalizedScope) || AnalyticsStatScope.AI_CHAT.equals(normalizedScope)) {
+                calculateAiChatStat(day);
+            }
+            if (AnalyticsStatScope.ALL.equals(normalizedScope) || AnalyticsStatScope.DAILY.equals(normalizedScope)) {
+                calculateDailySummary(day);
+            }
+
+            jobLog.setStatus(1);
+            jobLog.setEndTime(new Date());
+            jobLogMapper.updateById(jobLog);
+            log.info("统计完成: statDate={}, scope={}, trigger={}", day, normalizedScope, triggerType);
+        } catch (Exception e) {
+            jobLog.setStatus(2);
+            jobLog.setEndTime(new Date());
+            jobLog.setErrorMsg(truncateError(e.getMessage()));
+            jobLogMapper.updateById(jobLog);
+            log.error("统计失败: statDate={}, scope={}", day, normalizedScope, e);
+            throw e;
+        } finally {
+            baseRedisService.unlock(lockKey, lockToken);
+        }
+    }
+
+    @Override
+    public AnalyticsDailySummary getDailySummary(Date statDate) {
+        Date day = AnalyticsDateUtil.truncateToDate(statDate);
+        LambdaQueryWrapper<AnalyticsDailySummary> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsDailySummary::getStatDate, day);
+        return dailySummaryMapper.selectOne(wrapper);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    protected void calculateUserStat(Date statDate) {
+        Date start = AnalyticsDateUtil.dayStart(statDate);
+        Date end = AnalyticsDateUtil.dayEndExclusive(statDate);
+
+        List<Map<String, Object>> aggregates = eventMapper.aggregateUserDaily(start, end);
+        for (Map<String, Object> row : aggregates) {
+            Object userIdObj = row.get("userId");
+            if (userIdObj == null) {
+                continue;
+            }
+            Long userId = ((Number) userIdObj).longValue();
+
+            AnalyticsUserStat stat = findUserStat(statDate, userId);
+            if (stat == null) {
+                stat = new AnalyticsUserStat();
+                stat.setStatDate(statDate);
+                stat.setUserId(userId);
+            }
+
+            stat.setLastActiveTime(toDate(row.get("lastActiveTime")));
+            stat.setCity(firstNonBlank(stat.getCity(), toString(row.get("city"))));
+            stat.setDeviceType(firstNonBlank(stat.getDeviceType(), toString(row.get("deviceType"))));
+            stat.setChannel(firstNonBlank(stat.getChannel(), toString(row.get("channel"))));
+            stat.setOnlineDurationMin(toInt(row.get("totalDurationMs")) / 60000);
+
+            if (stat.getFirstLaunchTime() == null) {
+                stat.setFirstLaunchTime(findFirstEventTime(userId, AnalyticsEventCode.USER_LAUNCH));
+            }
+            if (stat.getRegisterTime() == null && countUserEvent(userId, AnalyticsEventCode.USER_REGISTER, start, end) > 0) {
+                stat.setRegisterTime(findFirstEventTimeInRange(userId, AnalyticsEventCode.USER_REGISTER, start, end));
+            }
+
+            if (stat.getId() == null) {
+                userStatMapper.insert(stat);
+            } else {
+                userStatMapper.updateById(stat);
+            }
+        }
+
+        log.info("用户明细统计完成: statDate={}, activeUsers={}", statDate, aggregates.size());
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    protected void calculateMerchantStat(Date statDate) {
+        Date start = AnalyticsDateUtil.dayStart(statDate);
+        Date end = AnalyticsDateUtil.dayEndExclusive(statDate);
+
+        List<Map<String, Object>> aggregates = eventMapper.aggregateMerchantDaily(start, end);
+        for (Map<String, Object> row : aggregates) {
+            Object merchantIdObj = row.get("merchantId");
+            if (merchantIdObj == null) {
+                continue;
+            }
+            Long merchantId = ((Number) merchantIdObj).longValue();
+
+            AnalyticsMerchantStat stat = findMerchantStat(statDate, merchantId);
+            if (stat == null) {
+                stat = new AnalyticsMerchantStat();
+                stat.setStatDate(statDate);
+                stat.setMerchantId(merchantId);
+            }
+
+            stat.setVisitPv(Math.max(defaultInt(stat.getVisitPv()), toInt(row.get("visitPv"))));
+            stat.setVisitUv(Math.max(defaultInt(stat.getVisitUv()), toInt(row.get("visitUv"))));
+            int verifyCount = toInt(row.get("verifyCount"));
+            int visitUserCount = toInt(row.get("visitUserCount"));
+            stat.setVerifyConversionRate(AnalyticsDateUtil.calcRate(verifyCount, visitUserCount));
+
+            if (stat.getId() == null) {
+                merchantStatMapper.insert(stat);
+            } else {
+                merchantStatMapper.updateById(stat);
+            }
+        }
+
+        log.info("商户明细统计完成: statDate={}, merchants={}", statDate, aggregates.size());
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    protected void calculateContentStat(Date statDate) {
+        Date start = AnalyticsDateUtil.dayStart(statDate);
+        Date end = AnalyticsDateUtil.dayEndExclusive(statDate);
+
+        LambdaQueryWrapper<AnalyticsEvent> interactWrapper = new LambdaQueryWrapper<>();
+        interactWrapper.eq(AnalyticsEvent::getEventCode, AnalyticsEventCode.CONTENT_INTERACT)
+                .ge(AnalyticsEvent::getEventTime, start)
+                .lt(AnalyticsEvent::getEventTime, end);
+        List<AnalyticsEvent> interactEvents = eventMapper.selectList(interactWrapper);
+        Map<String, Integer> interactCountMap = new HashMap<>();
+        for (AnalyticsEvent event : interactEvents) {
+            if (event.getTargetId() == null || event.getContentType() == null) {
+                continue;
+            }
+            String key = event.getContentType() + ":" + event.getTargetId();
+            interactCountMap.merge(key, 1, Integer::sum);
+        }
+
+        for (Map.Entry<String, Integer> entry : interactCountMap.entrySet()) {
+            String[] parts = entry.getKey().split(":");
+            upsertContentInteraction(Integer.parseInt(parts[0]), Long.parseLong(parts[1]), entry.getValue());
+        }
+
+        log.info("内容明细统计完成: statDate={}, interactGroups={}", statDate, interactCountMap.size());
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    protected void calculateAiChatStat(Date statDate) {
+        log.info("AI对话明细由 /analytics/detail/ai-chat 同步,跳过事件汇总: statDate={}", statDate);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    protected void calculateDailySummary(Date statDate) {
+        Date start = AnalyticsDateUtil.dayStart(statDate);
+        Date end = AnalyticsDateUtil.dayEndExclusive(statDate);
+        Date prevDate = AnalyticsDateUtil.addDays(statDate, -1);
+        Date last7Start = AnalyticsDateUtil.addDays(statDate, -6);
+        Date prevStart = AnalyticsDateUtil.dayStart(prevDate);
+        Date prevEnd = AnalyticsDateUtil.dayEndExclusive(prevDate);
+
+        AnalyticsDailySummary summary = findOrCreateDailySummary(statDate);
+
+        summary.setDau(toInt(eventMapper.countDau(start, end)));
+        summary.setNewUserCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.USER_REGISTER, start, end)));
+        summary.setAiChatCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.AI_CHAT_START, start, end)));
+        summary.setContentPublishCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.CONTENT_PUBLISH, start, end)));
+        summary.setContentInteractionCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.CONTENT_INTERACT, start, end)));
+        summary.setMerchantVisitUv(toInt(eventMapper.countMerchantVisitUv(start, end)));
+        summary.setPayUserCount(toInt(eventMapper.countDistinctUserByEventCode(AnalyticsEventCode.PAY_SUCCESS, start, end)));
+        summary.setTodayGmv(defaultDecimal(eventMapper.sumPayAmount(AnalyticsEventCode.PAY_SUCCESS, start, end)));
+        summary.setAuditPassCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_PASS, start, end)));
+        summary.setAuditSubmitCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_SUBMIT, start, end)));
+        summary.setReportHandleCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.REPORT_HANDLE, start, end)));
+        summary.setReportSubmitCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.REPORT_SUBMIT, start, end)));
+        summary.setAiResponseDurationTotalMs(defaultLong(aiRequestMapper.sumResponseDuration(start, end)));
+
+        summary.setTotalRegisterUserCount(toInt(eventMapper.countTotalRegisterUsers(end)));
+        summary.setLast7dNewUserCount(toInt(eventMapper.countNewUsersInRange(last7Start, end)));
+        summary.setLast7dActiveUserCount(toInt(eventMapper.countActiveUsersInRange(last7Start, end)));
+        summary.setOnlineUserCount(countOnlineUsers(start, end));
+
+        Long retained = eventMapper.countNextDayRetained(prevStart, prevEnd, start, end);
+        Long prevNewUsers = eventMapper.countRegisterUsers(prevStart, prevEnd);
+        summary.setNextDayRetainedCount(toInt(retained));
+        summary.setNextDayRetentionRate(AnalyticsDateUtil.calcRate(retained, prevNewUsers));
+
+        Long verifyCount = eventMapper.countMerchantVerify(start, end);
+        Long visitUsers = eventMapper.countMerchantViewUsers(start, end);
+        summary.setVerifyRate(AnalyticsDateUtil.calcRate(verifyCount, visitUsers));
+        summary.setTotalSettledMerchantCount(toInt(merchantStatMapper.countSettledMerchants(statDate)));
+
+        Long paySuccessCount = eventMapper.countByEventCode(AnalyticsEventCode.PAY_SUCCESS, start, end);
+        summary.setAvgOrderAmount(calcAvgAmount(summary.getTodayGmv(), toInt(paySuccessCount)));
+        summary.setTodayConversionRate(AnalyticsDateUtil.calcRate(paySuccessCount, summary.getDau()));
+        summary.setConversionRate(summary.getTodayConversionRate());
+        summary.setAuditPassRate(AnalyticsDateUtil.calcRate(summary.getAuditPassCount(), summary.getAuditSubmitCount()));
+
+        AnalyticsDailySummary yesterdaySummary = getDailySummary(prevDate);
+        if (yesterdaySummary != null) {
+            summary.setYesterdayReportHandleRate(AnalyticsDateUtil.calcRate(
+                    yesterdaySummary.getReportHandleCount(), yesterdaySummary.getReportSubmitCount()));
+        }
+
+        if (summary.getId() == null) {
+            dailySummaryMapper.insert(summary);
+        } else {
+            dailySummaryMapper.updateById(summary);
+        }
+
+        log.info("平台日统计完成: statDate={}, dau={}", statDate, summary.getDau());
+    }
+
+    private AnalyticsDailySummary findOrCreateDailySummary(Date statDate) {
+        AnalyticsDailySummary existing = getDailySummary(statDate);
+        if (existing != null) {
+            return existing;
+        }
+        AnalyticsDailySummary summary = new AnalyticsDailySummary();
+        summary.setStatDate(statDate);
+        return summary;
+    }
+
+    private AnalyticsUserStat findUserStat(Date statDate, Long userId) {
+        LambdaQueryWrapper<AnalyticsUserStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsUserStat::getStatDate, statDate).eq(AnalyticsUserStat::getUserId, userId);
+        return userStatMapper.selectOne(wrapper);
+    }
+
+    private AnalyticsMerchantStat findMerchantStat(Date statDate, Long merchantId) {
+        LambdaQueryWrapper<AnalyticsMerchantStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsMerchantStat::getStatDate, statDate).eq(AnalyticsMerchantStat::getMerchantId, merchantId);
+        return merchantStatMapper.selectOne(wrapper);
+    }
+
+    /**
+     * 从当日事件汇总互动数。取明细上报值与事件汇总值的较大者,避免定时任务重复执行时累加翻倍。
+     */
+    private void upsertContentInteraction(Integer contentType, Long contentId, int eventDayCount) {
+        LambdaQueryWrapper<AnalyticsContentStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsContentStat::getContentType, contentType)
+                .eq(AnalyticsContentStat::getContentId, contentId);
+        AnalyticsContentStat stat = contentStatMapper.selectOne(wrapper);
+        if (stat == null) {
+            stat = new AnalyticsContentStat();
+            stat.setContentType(contentType);
+            stat.setContentId(contentId);
+            stat.setAuthorType(1);
+            stat.setAuthorId(0L);
+            stat.setInteractionCount(eventDayCount);
+            contentStatMapper.insert(stat);
+            return;
+        }
+        stat.setInteractionCount(Math.max(defaultInt(stat.getInteractionCount()), eventDayCount));
+        contentStatMapper.updateById(stat);
+    }
+
+    private int countOnlineUsers(Date start, Date end) {
+        return toInt(eventMapper.countDistinctUserByEventCode(AnalyticsEventCode.USER_HEARTBEAT, start, end));
+    }
+
+    private long countUserEvent(Long userId, String eventCode, Date start, Date end) {
+        LambdaQueryWrapper<AnalyticsEvent> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsEvent::getUserId, userId)
+                .eq(AnalyticsEvent::getEventCode, eventCode)
+                .ge(AnalyticsEvent::getEventTime, start)
+                .lt(AnalyticsEvent::getEventTime, end);
+        return eventMapper.selectCount(wrapper);
+    }
+
+    private Date findFirstEventTime(Long userId, String eventCode) {
+        LambdaQueryWrapper<AnalyticsEvent> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsEvent::getUserId, userId)
+                .eq(AnalyticsEvent::getEventCode, eventCode)
+                .orderByAsc(AnalyticsEvent::getEventTime)
+                .last("LIMIT 1");
+        AnalyticsEvent event = eventMapper.selectOne(wrapper);
+        return event != null ? event.getEventTime() : null;
+    }
+
+    private Date findFirstEventTimeInRange(Long userId, String eventCode, Date start, Date end) {
+        LambdaQueryWrapper<AnalyticsEvent> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsEvent::getUserId, userId)
+                .eq(AnalyticsEvent::getEventCode, eventCode)
+                .ge(AnalyticsEvent::getEventTime, start)
+                .lt(AnalyticsEvent::getEventTime, end)
+                .orderByAsc(AnalyticsEvent::getEventTime)
+                .last("LIMIT 1");
+        AnalyticsEvent event = eventMapper.selectOne(wrapper);
+        return event != null ? event.getEventTime() : null;
+    }
+
+    private BigDecimal calcAvgAmount(BigDecimal total, Integer count) {
+        if (total == null || count == null || count <= 0) {
+            return null;
+        }
+        return total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP);
+    }
+
+    private int defaultInt(Integer value) {
+        return value != null ? value : 0;
+    }
+
+    private BigDecimal defaultDecimal(BigDecimal value) {
+        return value != null ? value : BigDecimal.ZERO;
+    }
+
+    private long defaultLong(Long value) {
+        return value != null ? value : 0L;
+    }
+
+    private int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        return Integer.parseInt(value.toString());
+    }
+
+    private Date toDate(Object value) {
+        if (value instanceof Date) {
+            return (Date) value;
+        }
+        return null;
+    }
+
+    private String toString(Object value) {
+        return value != null ? value.toString() : null;
+    }
+
+    private String firstNonBlank(String current, String incoming) {
+        return StringUtils.hasText(current) ? current : incoming;
+    }
+
+    private String truncateError(String message) {
+        if (message == null) {
+            return null;
+        }
+        return message.length() > 900 ? message.substring(0, 900) : message;
+    }
+}

+ 676 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsTrackServiceImpl.java

@@ -0,0 +1,676 @@
+package shop.alien.store.service.analytics.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.analytics.*;
+import shop.alien.entity.analytics.dto.*;
+import shop.alien.mapper.*;
+import shop.alien.store.service.analytics.AnalyticsTrackService;
+import shop.alien.store.util.analytics.AnalyticsDateUtil;
+import shop.alien.store.util.analytics.AnalyticsFrontHelper;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Date;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
+
+    private final AnalyticsEventMapper eventMapper;
+    private final AnalyticsAiRequestMapper aiRequestMapper;
+    private final AnalyticsUserStatMapper userStatMapper;
+    private final AnalyticsMerchantStatMapper merchantStatMapper;
+    private final AnalyticsContentStatMapper contentStatMapper;
+    private final AnalyticsAiChatStatMapper aiChatStatMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackEvent(AnalyticsTrackEventDTO dto, HttpServletRequest request) {
+        if (!StringUtils.hasText(dto.getEventCode())) {
+            throw new IllegalArgumentException("eventCode 不能为空");
+        }
+        AnalyticsFrontHelper.enrichTrackEvent(dto, request);
+        insertEvent(dto);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackFromFront(AnalyticsFrontReportDTO dto, HttpServletRequest request) {
+        AnalyticsTrackEventDTO eventDto = convertFrontReport(dto);
+        AnalyticsFrontHelper.enrichTrackEvent(eventDto, request);
+        insertEvent(eventDto);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchTrackFromFront(AnalyticsBatchTrackDTO dto, HttpServletRequest request) {
+        if (dto.getEvents() == null || dto.getEvents().isEmpty()) {
+            throw new IllegalArgumentException("events 不能为空");
+        }
+        if (dto.getEvents().size() > 50) {
+            throw new IllegalArgumentException("单次最多上报50条");
+        }
+        for (AnalyticsFrontReportDTO item : dto.getEvents()) {
+            trackFromFront(item, request);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackHeartbeat(AnalyticsHeartbeatDTO dto, HttpServletRequest request) {
+        if (dto.getDurationMs() == null || dto.getDurationMs() <= 0) {
+            throw new IllegalArgumentException("durationMs 必须大于0");
+        }
+        AnalyticsFrontReportDTO report = new AnalyticsFrontReportDTO();
+        report.setScene(AnalyticsScene.USER_HEARTBEAT.getScene());
+        report.setUserId(dto.getUserId());
+        report.setDurationMs(dto.getDurationMs());
+        report.setDeviceType(dto.getDeviceType());
+        report.setChannel(dto.getChannel());
+        report.setCity(dto.getCity());
+        trackFromFront(report, request);
+    }
+
+    private void insertEvent(AnalyticsTrackEventDTO dto) {
+        AnalyticsEvent event = new AnalyticsEvent();
+        event.setEventId(StringUtils.hasText(dto.getEventId()) ? dto.getEventId() : UUID.randomUUID().toString().replace("-", ""));
+        event.setEventCode(dto.getEventCode());
+        event.setUserId(dto.getUserId());
+        event.setMerchantId(dto.getMerchantId());
+        event.setTargetId(dto.getTargetId());
+        event.setContentType(dto.getContentType());
+        event.setAmount(dto.getAmount());
+        event.setDurationMs(dto.getDurationMs());
+        event.setDeviceType(dto.getDeviceType());
+        event.setChannel(dto.getChannel());
+        event.setCity(dto.getCity());
+        event.setEventTime(dto.getEventTime() != null ? dto.getEventTime() : new Date());
+
+        try {
+            eventMapper.insert(event);
+        } catch (DuplicateKeyException e) {
+            log.debug("埋点事件已存在,跳过: eventId={}", event.getEventId());
+        }
+    }
+
+    private AnalyticsTrackEventDTO convertFrontReport(AnalyticsFrontReportDTO dto) {
+        if (!StringUtils.hasText(dto.getScene())) {
+            throw new IllegalArgumentException("scene 不能为空");
+        }
+        AnalyticsScene scene = AnalyticsScene.of(dto.getScene());
+        if (scene == null) {
+            throw new IllegalArgumentException("未知 scene: " + dto.getScene());
+        }
+
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(scene.getEventCode());
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setMerchantId(dto.getMerchantId());
+        eventDto.setTargetId(dto.getTargetId());
+        eventDto.setContentType(dto.getContentType());
+        eventDto.setAmount(dto.getAmount());
+        eventDto.setDurationMs(dto.getDurationMs());
+        eventDto.setDeviceType(dto.getDeviceType());
+        eventDto.setChannel(dto.getChannel());
+        eventDto.setCity(dto.getCity());
+        eventDto.setEventTime(dto.getEventTime());
+        return eventDto;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackAiRequest(AnalyticsAiRequestDTO dto) {
+        if (!StringUtils.hasText(dto.getApiName()) || !StringUtils.hasText(dto.getApiUrl())) {
+            throw new IllegalArgumentException("apiName 与 apiUrl 不能为空");
+        }
+
+        AnalyticsAiRequest request = new AnalyticsAiRequest();
+        request.setRequestId(StringUtils.hasText(dto.getRequestId()) ? dto.getRequestId() : UUID.randomUUID().toString().replace("-", ""));
+        request.setApiName(dto.getApiName());
+        request.setApiUrl(dto.getApiUrl());
+        request.setResponseDurationMs(dto.getResponseDurationMs() != null ? dto.getResponseDurationMs() : 0L);
+        request.setIsTimeout(dto.getIsTimeout() != null ? dto.getIsTimeout() : 0);
+
+        try {
+            aiRequestMapper.insert(request);
+        } catch (DuplicateKeyException e) {
+            log.debug("AI请求已存在,跳过: requestId={}", request.getRequestId());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void upsertUserDetail(AnalyticsUserDetailDTO dto) {
+        if (dto.getUserId() == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        Date statDate = dto.getStatDateOverride() != null ? AnalyticsDateUtil.truncateToDate(dto.getStatDateOverride())
+                : (dto.getStatDate() != null ? AnalyticsDateUtil.truncateToDate(dto.getStatDate()) : AnalyticsDateUtil.truncateToDate(new Date()));
+
+        AnalyticsUserStat existing = findUserStat(statDate, dto.getUserId());
+        if (existing == null) {
+            userStatMapper.insert(buildUserStat(dto, statDate));
+            return;
+        }
+        mergeUserStat(existing, dto, statDate);
+        userStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void upsertMerchantDetail(AnalyticsMerchantDetailDTO dto) {
+        if (dto.getMerchantId() == null) {
+            throw new IllegalArgumentException("merchantId 不能为空");
+        }
+        Date statDate = dto.getStatDateOverride() != null ? AnalyticsDateUtil.truncateToDate(dto.getStatDateOverride())
+                : (dto.getStatDate() != null ? AnalyticsDateUtil.truncateToDate(dto.getStatDate()) : AnalyticsDateUtil.truncateToDate(new Date()));
+
+        AnalyticsMerchantStat existing = findMerchantStat(statDate, dto.getMerchantId());
+        if (existing == null) {
+            merchantStatMapper.insert(buildMerchantStat(dto, statDate));
+            return;
+        }
+        mergeMerchantStat(existing, dto);
+        merchantStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void upsertContentDetail(AnalyticsContentDetailDTO dto) {
+        if (dto.getContentId() == null || dto.getContentType() == null) {
+            throw new IllegalArgumentException("contentId 与 contentType 不能为空");
+        }
+
+        LambdaQueryWrapper<AnalyticsContentStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsContentStat::getContentType, dto.getContentType())
+                .eq(AnalyticsContentStat::getContentId, dto.getContentId());
+        AnalyticsContentStat existing = contentStatMapper.selectOne(wrapper);
+
+        if (existing == null) {
+            contentStatMapper.insert(buildContentStat(dto));
+            return;
+        }
+        mergeContentStat(existing, dto);
+        contentStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void upsertAiChatDetail(AnalyticsAiChatDetailDTO dto) {
+        if (!StringUtils.hasText(dto.getChatId()) || dto.getUserId() == null) {
+            throw new IllegalArgumentException("chatId 与 userId 不能为空");
+        }
+
+        LambdaQueryWrapper<AnalyticsAiChatStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsAiChatStat::getChatId, dto.getChatId());
+        AnalyticsAiChatStat existing = aiChatStatMapper.selectOne(wrapper);
+
+        if (existing == null) {
+            aiChatStatMapper.insert(buildAiChatStat(dto));
+            return;
+        }
+        mergeAiChatStat(existing, dto);
+        aiChatStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackUserRegister(AnalyticsUserRegisterDTO dto) {
+        if (dto.getUserId() == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        Date registerTime = dto.getRegisterTime() != null ? dto.getRegisterTime() : new Date();
+
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.USER_REGISTER);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setChannel(dto.getChannel());
+        eventDto.setCity(dto.getCity());
+        eventDto.setEventTime(registerTime);
+        insertEvent(eventDto);
+
+        Date statDate = AnalyticsDateUtil.truncateToDate(registerTime);
+        AnalyticsUserStat existing = findUserStat(statDate, dto.getUserId());
+        if (existing == null) {
+            AnalyticsUserStat stat = new AnalyticsUserStat();
+            stat.setStatDate(statDate);
+            stat.setUserId(dto.getUserId());
+            stat.setUserPhone(dto.getUserPhone());
+            stat.setRegisterTime(registerTime);
+            stat.setFirstLaunchTime(registerTime);
+            stat.setLastActiveTime(registerTime);
+            stat.setChannel(dto.getChannel());
+            stat.setCity(dto.getCity());
+            stat.setOnlineDurationMin(0);
+            userStatMapper.insert(stat);
+            return;
+        }
+        applyUserRegisterMerge(existing, dto.getUserPhone(), registerTime, dto.getChannel(), dto.getCity());
+        userStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackUserLogin(AnalyticsUserLoginDTO dto) {
+        if (dto.getUserId() == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        Date activeTime = dto.getLastActiveTime() != null ? dto.getLastActiveTime() : new Date();
+        Date firstLaunch = dto.getFirstLaunchTime() != null ? dto.getFirstLaunchTime() : activeTime;
+
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.USER_LOGIN);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setCity(dto.getCity());
+        eventDto.setDeviceType(dto.getDeviceName());
+        eventDto.setEventTime(activeTime);
+        insertEvent(eventDto);
+
+        Date statDate = AnalyticsDateUtil.truncateToDate(activeTime);
+        AnalyticsUserStat existing = findUserStat(statDate, dto.getUserId());
+        if (existing == null) {
+            AnalyticsUserStat stat = new AnalyticsUserStat();
+            stat.setStatDate(statDate);
+            stat.setUserId(dto.getUserId());
+            stat.setFirstLaunchTime(firstLaunch);
+            stat.setLastActiveTime(activeTime);
+            stat.setCity(dto.getCity());
+            stat.setDeviceType(dto.getDeviceName());
+            stat.setOnlineDurationMin(0);
+            userStatMapper.insert(stat);
+            return;
+        }
+        applyUserSessionMerge(existing, dto.getFirstLaunchTime(), firstLaunch,
+                activeTime, dto.getCity(), dto.getDeviceName(), null);
+        userStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackUserLogout(AnalyticsUserLogoutDTO dto) {
+        if (dto.getUserId() == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        if (dto.getOnlineDurationMin() == null || dto.getOnlineDurationMin() < 0) {
+            throw new IllegalArgumentException("onlineDurationMin 不能为空且不能小于0");
+        }
+        Date activeTime = dto.getLastActiveTime() != null ? dto.getLastActiveTime() : new Date();
+        int sessionMin = dto.getOnlineDurationMin();
+
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.USER_LOGOUT);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setCity(dto.getCity());
+        eventDto.setDeviceType(dto.getDeviceName());
+        eventDto.setDurationMs(sessionMin > 0 ? sessionMin * 60000L : 0L);
+        eventDto.setEventTime(activeTime);
+        insertEvent(eventDto);
+
+        Date statDate = AnalyticsDateUtil.truncateToDate(activeTime);
+        AnalyticsUserStat existing = findUserStat(statDate, dto.getUserId());
+        if (existing == null) {
+            AnalyticsUserStat stat = new AnalyticsUserStat();
+            stat.setStatDate(statDate);
+            stat.setUserId(dto.getUserId());
+            stat.setFirstLaunchTime(dto.getFirstLaunchTime() != null ? dto.getFirstLaunchTime() : activeTime);
+            stat.setLastActiveTime(activeTime);
+            stat.setCity(dto.getCity());
+            stat.setDeviceType(dto.getDeviceName());
+            stat.setOnlineDurationMin(sessionMin);
+            userStatMapper.insert(stat);
+            return;
+        }
+        applyUserSessionMerge(existing, dto.getFirstLaunchTime(), activeTime,
+                activeTime, dto.getCity(), dto.getDeviceName(), sessionMin);
+        userStatMapper.updateById(existing);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackAiChatEnd(AnalyticsAiChatEndDTO dto) {
+        if (!StringUtils.hasText(dto.getChatId())) {
+            throw new IllegalArgumentException("chatId 不能为空");
+        }
+        if (dto.getUserId() == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        if (dto.getStartTime() == null) {
+            throw new IllegalArgumentException("startTime 不能为空");
+        }
+        if (dto.getMessageCount() == null) {
+            throw new IllegalArgumentException("messageCount 不能为空");
+        }
+        if (dto.getAiResponseDurationMs() == null) {
+            throw new IllegalArgumentException("aiResponseDurationMs 不能为空");
+        }
+
+        Date endTime = new Date();
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.AI_CHAT_END);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setDurationMs(dto.getAiResponseDurationMs());
+        eventDto.setEventTime(endTime);
+        insertEvent(eventDto);
+
+        AnalyticsAiChatDetailDTO detail = new AnalyticsAiChatDetailDTO();
+        detail.setChatId(dto.getChatId());
+        detail.setUserId(dto.getUserId());
+        detail.setStartTime(dto.getStartTime());
+        detail.setMessageCount(dto.getMessageCount());
+        detail.setAiResponseDurationMs(dto.getAiResponseDurationMs());
+        upsertAiChatDetail(detail);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackContentPublish(AnalyticsContentPublishDTO dto) {
+        if (dto.getContentId() == null) {
+            throw new IllegalArgumentException("contentId 不能为空");
+        }
+        if (dto.getContentType() == null) {
+            throw new IllegalArgumentException("contentType 不能为空");
+        }
+        if (dto.getAuthorType() == null) {
+            throw new IllegalArgumentException("authorType 不能为空");
+        }
+        if (dto.getAuthorId() == null) {
+            throw new IllegalArgumentException("authorId 不能为空");
+        }
+        if (dto.getPublishTime() == null) {
+            throw new IllegalArgumentException("publishTime 不能为空");
+        }
+        if (dto.getContentType() < 1 || dto.getContentType() > 3) {
+            throw new IllegalArgumentException("contentType 取值范围 1~3");
+        }
+        if (dto.getAuthorType() < 1 || dto.getAuthorType() > 2) {
+            throw new IllegalArgumentException("authorType 取值范围 1~2");
+        }
+
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.CONTENT_PUBLISH);
+        eventDto.setTargetId(dto.getContentId());
+        eventDto.setContentType(dto.getContentType());
+        if (dto.getAuthorType() == 1) {
+            eventDto.setUserId(dto.getAuthorId());
+        } else {
+            eventDto.setMerchantId(dto.getAuthorId());
+        }
+        eventDto.setEventTime(dto.getPublishTime());
+        insertEvent(eventDto);
+
+        AnalyticsContentDetailDTO detail = new AnalyticsContentDetailDTO();
+        detail.setContentId(dto.getContentId());
+        detail.setContentType(dto.getContentType());
+        detail.setAuthorType(dto.getAuthorType());
+        detail.setAuthorId(dto.getAuthorId());
+        detail.setPublishTime(dto.getPublishTime());
+        upsertContentDetail(detail);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackContentInteract(AnalyticsContentInteractDTO dto) {
+        if (dto.getContentId() == null) {
+            throw new IllegalArgumentException("contentId 不能为空");
+        }
+        if (dto.getContentType() == null) {
+            throw new IllegalArgumentException("contentType 不能为空");
+        }
+        if (dto.getIncrement() == null || dto.getIncrement() == 0) {
+            throw new IllegalArgumentException("increment 不能为空且不能为0");
+        }
+        if (dto.getContentType() < 1 || dto.getContentType() > 3) {
+            throw new IllegalArgumentException("contentType 取值范围 1~3");
+        }
+
+        Date eventTime = new Date();
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.CONTENT_INTERACT);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setTargetId(dto.getContentId());
+        eventDto.setContentType(dto.getContentType());
+        eventDto.setEventTime(eventTime);
+        insertEvent(eventDto);
+
+        addContentInteraction(dto.getContentType(), dto.getContentId(), dto.getIncrement());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void trackMerchantView(AnalyticsMerchantViewDTO dto) {
+        if (dto.getMerchantId() == null) {
+            throw new IllegalArgumentException("merchantId 不能为空");
+        }
+        if (dto.getShopType() == null) {
+            throw new IllegalArgumentException("shopType 不能为空");
+        }
+        if (dto.getVisitUv() == null) {
+            throw new IllegalArgumentException("visitUv 不能为空");
+        }
+        if (dto.getVisitPv() == null) {
+            throw new IllegalArgumentException("visitPv 不能为空");
+        }
+        if (dto.getShopType() < 1 || dto.getShopType() > 3) {
+            throw new IllegalArgumentException("shopType 取值范围 1~3");
+        }
+        if (dto.getVisitUv() < 0 || dto.getVisitPv() < 0) {
+            throw new IllegalArgumentException("visitUv、visitPv 不能为负数");
+        }
+        if (dto.getVisitUv() == 0 && dto.getVisitPv() == 0) {
+            throw new IllegalArgumentException("visitUv 与 visitPv 不能同时为0");
+        }
+
+        Date eventTime = new Date();
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventId(dto.getEventId());
+        eventDto.setEventCode(AnalyticsEventCode.MERCHANT_VIEW);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setMerchantId(dto.getMerchantId());
+        eventDto.setEventTime(eventTime);
+        insertEvent(eventDto);
+
+        Date statDate = AnalyticsDateUtil.truncateToDate(eventTime);
+        addMerchantVisit(statDate, dto.getMerchantId(), dto.getShopType(), dto.getVisitUv(), dto.getVisitPv());
+    }
+
+    private void applyUserRegisterMerge(AnalyticsUserStat target, String userPhone, Date registerTime,
+                                        String channel, String city) {
+        if (StringUtils.hasText(userPhone)) {
+            target.setUserPhone(userPhone);
+        }
+        if (target.getRegisterTime() == null) {
+            target.setRegisterTime(registerTime);
+        }
+        if (target.getFirstLaunchTime() == null) {
+            target.setFirstLaunchTime(registerTime);
+        }
+        target.setLastActiveTime(registerTime);
+        if (StringUtils.hasText(channel)) {
+            target.setChannel(channel);
+        }
+        if (StringUtils.hasText(city)) {
+            target.setCity(city);
+        }
+    }
+
+    private void applyUserSessionMerge(AnalyticsUserStat target, Date firstLaunchTime,
+                                       Date defaultFirstLaunch, Date lastActiveTime, String city,
+                                       String deviceName, Integer addOnlineDurationMin) {
+        if (firstLaunchTime != null) {
+            target.setFirstLaunchTime(firstLaunchTime);
+        } else if (target.getFirstLaunchTime() == null) {
+            target.setFirstLaunchTime(defaultFirstLaunch);
+        }
+        target.setLastActiveTime(lastActiveTime);
+        if (StringUtils.hasText(city)) {
+            target.setCity(city);
+        }
+        if (StringUtils.hasText(deviceName)) {
+            target.setDeviceType(deviceName);
+        }
+        if (addOnlineDurationMin != null && addOnlineDurationMin > 0) {
+            target.setOnlineDurationMin(defaultInt(target.getOnlineDurationMin()) + addOnlineDurationMin);
+        }
+    }
+
+    private AnalyticsUserStat findUserStat(Date statDate, Long userId) {
+        LambdaQueryWrapper<AnalyticsUserStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsUserStat::getStatDate, statDate).eq(AnalyticsUserStat::getUserId, userId);
+        return userStatMapper.selectOne(wrapper);
+    }
+
+    private AnalyticsMerchantStat findMerchantStat(Date statDate, Long merchantId) {
+        LambdaQueryWrapper<AnalyticsMerchantStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsMerchantStat::getStatDate, statDate).eq(AnalyticsMerchantStat::getMerchantId, merchantId);
+        return merchantStatMapper.selectOne(wrapper);
+    }
+
+    private AnalyticsUserStat buildUserStat(AnalyticsUserDetailDTO dto, Date statDate) {
+        AnalyticsUserStat stat = new AnalyticsUserStat();
+        stat.setStatDate(statDate);
+        stat.setUserId(dto.getUserId());
+        stat.setUserPhone(dto.getUserPhone());
+        stat.setFirstLaunchTime(dto.getFirstLaunchTime());
+        stat.setLastActiveTime(dto.getLastActiveTime());
+        stat.setRegisterTime(dto.getRegisterTime());
+        stat.setCity(dto.getCity());
+        stat.setDeviceType(dto.getDeviceType());
+        stat.setChannel(dto.getChannel());
+        stat.setOnlineDurationMin(defaultInt(dto.getOnlineDurationMin()));
+        return stat;
+    }
+
+    private void mergeUserStat(AnalyticsUserStat target, AnalyticsUserDetailDTO dto, Date statDate) {
+        target.setStatDate(statDate);
+        if (StringUtils.hasText(dto.getUserPhone())) target.setUserPhone(dto.getUserPhone());
+        if (dto.getFirstLaunchTime() != null) target.setFirstLaunchTime(dto.getFirstLaunchTime());
+        if (dto.getLastActiveTime() != null) target.setLastActiveTime(dto.getLastActiveTime());
+        if (dto.getRegisterTime() != null) target.setRegisterTime(dto.getRegisterTime());
+        if (StringUtils.hasText(dto.getCity())) target.setCity(dto.getCity());
+        if (StringUtils.hasText(dto.getDeviceType())) target.setDeviceType(dto.getDeviceType());
+        if (StringUtils.hasText(dto.getChannel())) target.setChannel(dto.getChannel());
+        if (dto.getOnlineDurationMin() != null) target.setOnlineDurationMin(dto.getOnlineDurationMin());
+    }
+
+    private AnalyticsMerchantStat buildMerchantStat(AnalyticsMerchantDetailDTO dto, Date statDate) {
+        AnalyticsMerchantStat stat = new AnalyticsMerchantStat();
+        stat.setStatDate(statDate);
+        stat.setMerchantId(dto.getMerchantId());
+        stat.setShopType(dto.getShopType());
+        stat.setVisitUv(defaultInt(dto.getVisitUv()));
+        stat.setVisitPv(defaultInt(dto.getVisitPv()));
+        stat.setVerifyConversionRate(dto.getVerifyConversionRate());
+        stat.setSettleTime(dto.getSettleTime());
+        stat.setSettleStatus(dto.getSettleStatus());
+        return stat;
+    }
+
+    private void mergeMerchantStat(AnalyticsMerchantStat target, AnalyticsMerchantDetailDTO dto) {
+        if (dto.getShopType() != null) target.setShopType(dto.getShopType());
+        if (dto.getVisitUv() != null) target.setVisitUv(dto.getVisitUv());
+        if (dto.getVisitPv() != null) target.setVisitPv(dto.getVisitPv());
+        if (dto.getVerifyConversionRate() != null) target.setVerifyConversionRate(dto.getVerifyConversionRate());
+        if (dto.getSettleTime() != null) target.setSettleTime(dto.getSettleTime());
+        if (dto.getSettleStatus() != null) target.setSettleStatus(dto.getSettleStatus());
+    }
+
+    private AnalyticsContentStat buildContentStat(AnalyticsContentDetailDTO dto) {
+        AnalyticsContentStat stat = new AnalyticsContentStat();
+        stat.setContentId(dto.getContentId());
+        stat.setContentType(dto.getContentType());
+        stat.setAuthorType(dto.getAuthorType() != null ? dto.getAuthorType() : 1);
+        stat.setAuthorId(dto.getAuthorId() != null ? dto.getAuthorId() : 0L);
+        stat.setPublishTime(dto.getPublishTime());
+        stat.setInteractionCount(defaultInt(dto.getInteractionCount()));
+        stat.setStatus(dto.getStatus());
+        stat.setAuditUserId(dto.getAuditUserId());
+        stat.setAuditStatus(dto.getAuditStatus());
+        stat.setAuditTime(dto.getAuditTime());
+        return stat;
+    }
+
+    private void mergeContentStat(AnalyticsContentStat target, AnalyticsContentDetailDTO dto) {
+        if (dto.getAuthorType() != null) target.setAuthorType(dto.getAuthorType());
+        if (dto.getAuthorId() != null) target.setAuthorId(dto.getAuthorId());
+        if (dto.getPublishTime() != null) target.setPublishTime(dto.getPublishTime());
+        if (dto.getInteractionCount() != null) target.setInteractionCount(dto.getInteractionCount());
+        if (dto.getStatus() != null) target.setStatus(dto.getStatus());
+        if (dto.getAuditUserId() != null) target.setAuditUserId(dto.getAuditUserId());
+        if (dto.getAuditStatus() != null) target.setAuditStatus(dto.getAuditStatus());
+        if (dto.getAuditTime() != null) target.setAuditTime(dto.getAuditTime());
+    }
+
+    private void addContentInteraction(Integer contentType, Long contentId, int increment) {
+        LambdaQueryWrapper<AnalyticsContentStat> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AnalyticsContentStat::getContentType, contentType)
+                .eq(AnalyticsContentStat::getContentId, contentId);
+        AnalyticsContentStat existing = contentStatMapper.selectOne(wrapper);
+        if (existing == null) {
+            AnalyticsContentStat stat = new AnalyticsContentStat();
+            stat.setContentType(contentType);
+            stat.setContentId(contentId);
+            stat.setAuthorType(1);
+            stat.setAuthorId(0L);
+            stat.setInteractionCount(Math.max(0, increment));
+            contentStatMapper.insert(stat);
+            return;
+        }
+        int next = defaultInt(existing.getInteractionCount()) + increment;
+        existing.setInteractionCount(Math.max(0, next));
+        contentStatMapper.updateById(existing);
+    }
+
+    private void addMerchantVisit(Date statDate, Long merchantId, Integer shopType, int visitUvDelta, int visitPvDelta) {
+        AnalyticsMerchantStat existing = findMerchantStat(statDate, merchantId);
+        if (existing == null) {
+            AnalyticsMerchantStat stat = new AnalyticsMerchantStat();
+            stat.setStatDate(statDate);
+            stat.setMerchantId(merchantId);
+            stat.setShopType(shopType);
+            stat.setVisitUv(Math.max(0, visitUvDelta));
+            stat.setVisitPv(Math.max(0, visitPvDelta));
+            merchantStatMapper.insert(stat);
+            return;
+        }
+        if (shopType != null) {
+            existing.setShopType(shopType);
+        }
+        existing.setVisitUv(defaultInt(existing.getVisitUv()) + visitUvDelta);
+        existing.setVisitPv(defaultInt(existing.getVisitPv()) + visitPvDelta);
+        merchantStatMapper.updateById(existing);
+    }
+
+    private AnalyticsAiChatStat buildAiChatStat(AnalyticsAiChatDetailDTO dto) {
+        AnalyticsAiChatStat stat = new AnalyticsAiChatStat();
+        stat.setChatId(dto.getChatId());
+        stat.setUserId(dto.getUserId());
+        stat.setStartTime(dto.getStartTime() != null ? dto.getStartTime() : new Date());
+        stat.setMessageCount(defaultInt(dto.getMessageCount()));
+        stat.setAiResponseDurationMs(dto.getAiResponseDurationMs() != null ? dto.getAiResponseDurationMs() : 0L);
+        return stat;
+    }
+
+    private void mergeAiChatStat(AnalyticsAiChatStat target, AnalyticsAiChatDetailDTO dto) {
+        if (dto.getStartTime() != null) target.setStartTime(dto.getStartTime());
+        if (dto.getMessageCount() != null) target.setMessageCount(dto.getMessageCount());
+        if (dto.getAiResponseDurationMs() != null) target.setAiResponseDurationMs(dto.getAiResponseDurationMs());
+    }
+
+    private int defaultInt(Integer value) {
+        return value != null ? value : 0;
+    }
+}

+ 53 - 0
alien-store/src/main/java/shop/alien/store/util/analytics/AnalyticsDateUtil.java

@@ -0,0 +1,53 @@
+package shop.alien.store.util.analytics;
+
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * 统计日期工具
+ */
+public final class AnalyticsDateUtil {
+
+    private AnalyticsDateUtil() {
+    }
+
+    public static Date truncateToDate(Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
+    public static Date dayStart(Date statDate) {
+        return truncateToDate(statDate);
+    }
+
+    public static Date dayEndExclusive(Date statDate) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(dayStart(statDate));
+        cal.add(Calendar.DAY_OF_MONTH, 1);
+        return cal.getTime();
+    }
+
+    public static Date addDays(Date date, int days) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.add(Calendar.DAY_OF_MONTH, days);
+        return cal.getTime();
+    }
+
+    public static java.math.BigDecimal calcRate(Number numerator, Number denominator) {
+        if (numerator == null || denominator == null) {
+            return null;
+        }
+        long den = denominator.longValue();
+        if (den <= 0) {
+            return null;
+        }
+        return java.math.BigDecimal.valueOf(numerator.longValue() * 100.0 / den)
+                .setScale(4, java.math.RoundingMode.HALF_UP);
+    }
+}

+ 45 - 0
alien-store/src/main/java/shop/alien/store/util/analytics/AnalyticsFrontHelper.java

@@ -0,0 +1,45 @@
+package shop.alien.store.util.analytics;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.analytics.dto.AnalyticsTrackEventDTO;
+import shop.alien.store.util.UserAgentParserUtil;
+import shop.alien.util.common.JwtUtil;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Date;
+
+@Slf4j
+public final class AnalyticsFrontHelper {
+
+    private AnalyticsFrontHelper() {
+    }
+
+    public static void enrichTrackEvent(AnalyticsTrackEventDTO dto, HttpServletRequest request) {
+        if (dto.getEventTime() == null) {
+            dto.setEventTime(new Date());
+        }
+        if (dto.getUserId() == null) {
+            dto.setUserId(resolveUserIdFromJwt());
+        }
+        if (request != null && !StringUtils.hasText(dto.getDeviceType())) {
+            String userAgent = request.getHeader("User-Agent");
+            if (StringUtils.hasText(userAgent)) {
+                dto.setDeviceType(UserAgentParserUtil.parseDeviceType(userAgent));
+            }
+        }
+    }
+
+    public static Long resolveUserIdFromJwt() {
+        try {
+            JSONObject userInfo = JwtUtil.getCurrentUserInfo();
+            if (userInfo != null && userInfo.get("userId") != null) {
+                return userInfo.getLong("userId");
+            }
+        } catch (Exception e) {
+            log.debug("无法从JWT获取用户ID", e);
+        }
+        return null;
+    }
+}