lutong 9 órája
szülő
commit
e020326ad7

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

@@ -28,4 +28,7 @@ public class AnalyticsContentInteractDTO {
 
     @ApiModelProperty("事件唯一ID(幂等,可选)")
     private String eventId;
+
+    @ApiModelProperty("互动子类型(like/comment/share)")
+    private String eventSubtype;
 }

+ 4 - 2
alien-entity/src/main/java/shop/alien/mapper/AnalyticsEventMapper.java

@@ -49,12 +49,13 @@ public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
     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, " +
+            "COALESCE(SUM(CASE WHEN event_code IN ('user.heartbeat', 'user.logout') 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, " +
+            "MAX(shop_type) AS shopType, " +
             "COUNT(CASE WHEN event_code = 'merchant.expose' THEN 1 END) AS exposeCount, " +
             "COUNT(CASE WHEN event_code = 'merchant.click' THEN 1 END) AS clickCount, " +
             "COUNT(CASE WHEN event_code IN ('merchant.detail', 'merchant.view') THEN 1 END) AS detailViewCount, " +
@@ -64,7 +65,8 @@ public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
             "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, " +
             "COALESCE(SUM(CASE WHEN event_code = 'pay.success' THEN amount ELSE 0 END), 0) AS gmv, " +
-            "COUNT(CASE WHEN event_code = 'pay.success' THEN 1 END) AS payCount " +
+            "COUNT(CASE WHEN event_code = 'pay.success' THEN 1 END) AS payCount, " +
+            "COUNT(DISTINCT CASE WHEN event_code = 'pay.success' AND user_id IS NOT NULL THEN user_id END) AS payUserCount " +
             "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);

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

@@ -16,7 +16,7 @@ public interface AnalyticsMerchantStatMapper extends BaseMapper<AnalyticsMerchan
             "SELECT merchant_id FROM analytics_merchant_stat_history " +
             "WHERE stat_date <= #{statDate} AND settle_status = 1 " +
             "UNION ALL " +
-            "SELECT merchant_id FROM analytics_merchant_stat_today WHERE settle_status = 1" +
+            "SELECT merchant_id FROM analytics_merchant_stat_today WHERE settle_status = 1 AND #{statDate} >= CURDATE()" +
             ") t")
     Long countSettledMerchants(@Param("statDate") Date statDate);
 

+ 1 - 1
alien-entity/src/main/resources/mapper/AnalyticsContentReportMapper.xml

@@ -60,7 +60,7 @@
         FROM (
             <include refid="contentUnion"/>
         ) t
-        GROUP BY content_id
+        GROUP BY content_id, content_type
         ORDER BY interactionCount DESC
         LIMIT 10
     </select>

+ 1 - 2
alien-entity/src/main/resources/mapper/AnalyticsMerchantReportMapper.xml

@@ -16,8 +16,7 @@
 
     <select id="sumMerchantMetrics" resultType="java.util.HashMap">
         SELECT COALESCE(SUM(today_gmv), 0) AS totalGmv,
-               COALESCE(AVG(avg_order_amount), 0) AS avgOrderAmount,
-               COALESCE(AVG(verify_rate), 0) AS verifyRate
+               COALESCE(ROUND(SUM(today_gmv) / NULLIF(SUM(pay_user_count), 0), 2), 0) AS avgOrderAmount
         FROM analytics_daily_summary
         WHERE stat_date &gt;= #{startDate}
           AND stat_date &lt;= #{endDate}

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

@@ -13,7 +13,7 @@ import shop.alien.job.feign.AlienStoreFeign;
  * XXL-JOB 调度建议:
  * <ul>
  *   <li>analyticsArchiveDaily:0 0 0 * * ?(每天零点归档)</li>
- *   <li>analyticsCalculateTodayHourly:0 0 * * * ?(每小时)</li>
+ *   <li>analyticsCalculateTodayHourly:5 5 * * * ?(每小时第5分钟汇总当日,避开零点归档)</li>
  *   <li>analyticsCalculateYesterdayDaily:0 0 2 * * ?(每天凌晨2点)</li>
  *   <li>analyticsCalculateRetention:0 0 3 * * ?(每天凌晨3点)</li>
  * </ul>

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

@@ -463,7 +463,7 @@ alien-entity/src/main/resources/db/migration/analytics_schema.sql
 | Handler | Cron 建议 | 说明 |
 |---------|-----------|------|
 | `analyticsArchiveDaily` | `0 0 0 * * ?` | **零点**将昨日今日表明细归档到历史表 |
-| `analyticsCalculateTodayHourly` | `0 0 * * * ?` | 每小时汇总当日 |
+| `analyticsCalculateTodayHourly` | `5 5 * * * ?` | 每小时第5分钟汇总当日(避开零点归档) |
 | `analyticsCalculateYesterdayDaily` | `0 0 2 * * ?` | 凌晨 2 点汇总前日(在归档之后) |
 | `analyticsCalculateRetention` | `0 0 3 * * ?` | 凌晨 3 点计算留存 |
 

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsArchiveServiceImpl.java

@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import shop.alien.mapper.AnalyticsArchiveMapper;
+import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.service.analytics.AnalyticsArchiveService;
 import shop.alien.store.util.analytics.AnalyticsDateUtil;
 
@@ -18,11 +19,26 @@ public class AnalyticsArchiveServiceImpl implements AnalyticsArchiveService {
 
     private static final int HISTORY_RETAIN_DAYS = 30;
 
+    private static final String ARCHIVE_LOCK_KEY = "analytics:stat:archive";
+
     private final AnalyticsArchiveMapper archiveMapper;
+    private final BaseRedisService baseRedisService;
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void archiveYesterday() {
+        String lockToken = baseRedisService.lock(ARCHIVE_LOCK_KEY, 300000, 5000);
+        if (lockToken == null) {
+            throw new IllegalStateException("归档任务正在执行中,请稍后再试");
+        }
+        try {
+            doArchiveYesterday();
+        } finally {
+            baseRedisService.unlock(ARCHIVE_LOCK_KEY, lockToken);
+        }
+    }
+
+    private void doArchiveYesterday() {
         Calendar calendar = Calendar.getInstance();
         calendar.add(Calendar.DAY_OF_MONTH, -1);
         Date archiveDate = AnalyticsDateUtil.truncateToDate(calendar.getTime());

+ 32 - 32
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsContentReportServiceImpl.java

@@ -91,16 +91,15 @@ public class AnalyticsContentReportServiceImpl implements AnalyticsContentReport
     @Override
     public List<AnalyticsAuditTrendVo> auditTrend(String period) {
         AnalyticsPeriodContext ctx = AnalyticsPeriodContext.resolve(period);
-        List<Map<String, Object>> rows = contentReportMapper.listAuditTrend(ctx.getStartDate(), ctx.getEndDate());
-        if (rows.isEmpty()) {
-            return buildAuditTrendFromEvents(ctx);
-        }
-        return rows.stream().map(row -> {
-            AnalyticsAuditTrendVo vo = new AnalyticsAuditTrendVo();
+        Map<Date, AnalyticsAuditTrendVo> summaryMap = new HashMap<>();
+        for (Map<String, Object> row : contentReportMapper.listAuditTrend(ctx.getStartDate(), ctx.getEndDate())) {
             Object statDate = row.get("statDate");
-            if (statDate instanceof Date) {
-                vo.setStatDate((Date) statDate);
+            if (!(statDate instanceof Date)) {
+                continue;
             }
+            Date day = AnalyticsDateUtil.truncateToDate((Date) statDate);
+            AnalyticsAuditTrendVo vo = new AnalyticsAuditTrendVo();
+            vo.setStatDate(day);
             vo.setLabel(stringValue(row.get("label")));
             int submit = intValue(row.get("submitCount"));
             int pass = intValue(row.get("passCount"));
@@ -109,8 +108,31 @@ public class AnalyticsContentReportServiceImpl implements AnalyticsContentReport
             vo.setPassCount(pass);
             vo.setRejectCount(reject);
             vo.setFailCount(Math.max(submit - pass - reject, 0));
-            return vo;
-        }).collect(Collectors.toList());
+            summaryMap.put(day, vo);
+        }
+
+        List<AnalyticsAuditTrendVo> result = new ArrayList<>();
+        Date cursor = ctx.getStartDate();
+        while (!cursor.after(ctx.getEndDate())) {
+            AnalyticsAuditTrendVo vo = summaryMap.get(cursor);
+            if (vo == null || (vo.getSubmitCount() == 0 && vo.getPassCount() == 0 && vo.getRejectCount() == 0)) {
+                Date dayStart = AnalyticsDateUtil.dayStart(cursor);
+                Date dayEnd = AnalyticsDateUtil.dayEndExclusive(cursor);
+                int submit = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_SUBMIT, dayStart, dayEnd));
+                int pass = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_PASS, dayStart, dayEnd));
+                int reject = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_REJECT, dayStart, dayEnd));
+                vo = new AnalyticsAuditTrendVo();
+                vo.setStatDate(cursor);
+                vo.setLabel(new SimpleDateFormat("M-d").format(cursor));
+                vo.setSubmitCount(submit);
+                vo.setPassCount(pass);
+                vo.setRejectCount(reject);
+                vo.setFailCount(Math.max(submit - pass - reject, 0));
+            }
+            result.add(vo);
+            cursor = AnalyticsDateUtil.addDays(cursor, 1);
+        }
+        return result;
     }
 
     @Override
@@ -183,28 +205,6 @@ public class AnalyticsContentReportServiceImpl implements AnalyticsContentReport
         return metrics;
     }
 
-    private List<AnalyticsAuditTrendVo> buildAuditTrendFromEvents(AnalyticsPeriodContext ctx) {
-        Date cursor = ctx.getStartDate();
-        List<AnalyticsAuditTrendVo> result = new ArrayList<>();
-        while (!cursor.after(ctx.getEndDate())) {
-            Date dayStart = AnalyticsDateUtil.dayStart(cursor);
-            Date dayEnd = AnalyticsDateUtil.dayEndExclusive(cursor);
-            int submit = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_SUBMIT, dayStart, dayEnd));
-            int pass = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_PASS, dayStart, dayEnd));
-            int reject = intValue(eventMapper.countByEventCode(AnalyticsEventCode.AUDIT_REJECT, dayStart, dayEnd));
-            AnalyticsAuditTrendVo vo = new AnalyticsAuditTrendVo();
-            vo.setStatDate(cursor);
-            vo.setLabel(new SimpleDateFormat("M-d").format(cursor));
-            vo.setSubmitCount(submit);
-            vo.setPassCount(pass);
-            vo.setRejectCount(reject);
-            vo.setFailCount(Math.max(submit - pass - reject, 0));
-            result.add(vo);
-            cursor = AnalyticsDateUtil.addDays(cursor, 1);
-        }
-        return result;
-    }
-
     private AnalyticsPeriodContext previousContext(AnalyticsPeriodContext ctx) {
         long days = daysBetween(ctx.getStartDate(), ctx.getEndDate());
         Date prevEnd = AnalyticsDateUtil.addDays(ctx.getStartDate(), -1);

+ 13 - 10
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsDashboardServiceImpl.java

@@ -66,7 +66,11 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
         vo.setNewUserCount(rows.stream().mapToInt(s -> defaultInt(s.getNewUserCount())).sum());
         vo.setAiChatCount(rows.stream().mapToInt(s -> defaultInt(s.getAiChatCount())).sum());
         vo.setContentPublishCount(rows.stream().mapToInt(s -> defaultInt(s.getContentPublishCount())).sum());
-        vo.setMerchantVisitUv(rows.stream().mapToInt(s -> defaultInt(s.getMerchantVisitUv())).sum());
+        if (dayCount == 1) {
+            vo.setMerchantVisitUv(defaultInt(rows.isEmpty() ? 0 : rows.get(0).getMerchantVisitUv()));
+        } else {
+            vo.setMerchantVisitUv(intValue(eventMapper.countMerchantVisitUv(ctx.getStartTime(), ctx.getEndTimeExclusive())));
+        }
 
         long totalAiMs = rows.stream()
                 .mapToLong(s -> s.getAiResponseDurationTotalMs() != null ? s.getAiResponseDurationTotalMs() : 0L)
@@ -95,9 +99,9 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
         AnalyticsPeriodContext ctx = AnalyticsPeriodContext.resolve(period);
         if (ctx.isHourly()) {
             List<Map<String, Object>> chatRows = dashboardMapper.countEventByHour(
-                    AnalyticsEventCode.AI_CHAT_MESSAGE, ctx.getStartTime(), ctx.getEndTimeExclusive());
+                    AnalyticsEventCode.AI_CHAT_END, ctx.getStartTime(), ctx.getEndTimeExclusive());
             List<Map<String, Object>> userRows = dashboardMapper.countDistinctUserByEventByHour(
-                    AnalyticsEventCode.AI_CHAT_START, ctx.getStartTime(), ctx.getEndTimeExclusive());
+                    AnalyticsEventCode.AI_CHAT_END, ctx.getStartTime(), ctx.getEndTimeExclusive());
             Map<String, Long> chatMap = toCountMap(chatRows);
             Map<String, Long> userMap = toCountMap(userRows);
             Set<String> labels = new TreeSet<>();
@@ -174,8 +178,8 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
     @Override
     public List<AnalyticsRetentionPointVo> userRetention(String period) {
         AnalyticsPeriodContext ctx = AnalyticsPeriodContext.resolve(period);
-        Date cohortStart = ctx.getStartTime();
-        Date cohortEnd = ctx.getEndTimeExclusive();
+        Date cohortStart = AnalyticsDateUtil.dayStart(ctx.getStartDate());
+        Date cohortEnd = AnalyticsDateUtil.dayEndExclusive(ctx.getStartDate());
         Long cohortSize = eventMapper.countRegisterUsers(cohortStart, cohortEnd);
         long size = cohortSize != null ? cohortSize : 0L;
         if (size <= 0) {
@@ -288,7 +292,7 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
         }
         Date day = AnalyticsDateUtil.truncateToDate(summary.getStatDate());
         return intValue(eventMapper.countByEventCode(
-                AnalyticsEventCode.AI_CHAT_MESSAGE,
+                AnalyticsEventCode.AI_CHAT_END,
                 AnalyticsDateUtil.dayStart(day),
                 AnalyticsDateUtil.dayEndExclusive(day)));
     }
@@ -299,7 +303,7 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
         }
         Date day = AnalyticsDateUtil.truncateToDate(summary.getStatDate());
         return intValue(eventMapper.countDistinctUserByEventCode(
-                AnalyticsEventCode.AI_CHAT_START,
+                AnalyticsEventCode.AI_CHAT_END,
                 AnalyticsDateUtil.dayStart(day),
                 AnalyticsDateUtil.dayEndExclusive(day)));
     }
@@ -342,11 +346,10 @@ public class AnalyticsDashboardServiceImpl implements AnalyticsDashboardService
         vo.setDau(intValue(eventMapper.countDau(ctx.getStartTime(), ctx.getEndTimeExclusive())));
         vo.setNewUserCount(intValue(eventMapper.countRegisterUsers(ctx.getStartTime(), ctx.getEndTimeExclusive())));
         vo.setAiChatCount(intValue(eventMapper.countByEventCode(
-                AnalyticsEventCode.AI_CHAT_MESSAGE, ctx.getStartTime(), ctx.getEndTimeExclusive())));
+                AnalyticsEventCode.AI_CHAT_END, ctx.getStartTime(), ctx.getEndTimeExclusive())));
         vo.setContentPublishCount(intValue(eventMapper.countByEventCode(
                 AnalyticsEventCode.CONTENT_PUBLISH, ctx.getStartTime(), ctx.getEndTimeExclusive())));
-        vo.setMerchantVisitUv(intValue(eventMapper.countDistinctUserByEventCode(
-                AnalyticsEventCode.MERCHANT_VIEW, ctx.getStartTime(), ctx.getEndTimeExclusive())));
+        vo.setMerchantVisitUv(intValue(eventMapper.countMerchantVisitUv(ctx.getStartTime(), ctx.getEndTimeExclusive())));
         vo.setAiAvgResponseMs(0L);
         vo.setConversionRate(BigDecimal.ZERO);
         if (ctx.isHourly()) {

+ 11 - 2
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsMerchantReportServiceImpl.java

@@ -9,6 +9,8 @@ import shop.alien.entity.analytics.AnalyticsMerchantStat;
 import shop.alien.entity.analytics.vo.merchantreport.*;
 import shop.alien.entity.analytics.vo.userreport.AnalyticsDistributionItemVo;
 import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.analytics.AnalyticsEventCode;
+import shop.alien.mapper.AnalyticsEventMapper;
 import shop.alien.mapper.AnalyticsMerchantReportMapper;
 import shop.alien.mapper.AnalyticsMerchantStatMapper;
 import shop.alien.mapper.StoreInfoMapper;
@@ -28,6 +30,7 @@ public class AnalyticsMerchantReportServiceImpl implements AnalyticsMerchantRepo
 
     private final AnalyticsMerchantReportMapper merchantReportMapper;
     private final AnalyticsMerchantStatMapper merchantStatMapper;
+    private final AnalyticsEventMapper eventMapper;
     private final StoreInfoMapper storeInfoMapper;
 
     @Override
@@ -62,8 +65,8 @@ public class AnalyticsMerchantReportServiceImpl implements AnalyticsMerchantRepo
         vo.setGmv(gmvNow);
         vo.setGmvChangeRate(calcChangeRate(gmvNow, gmvPrev));
 
-        BigDecimal verifyNow = toBigDecimal(metricsNow.get("verifyRate"));
-        BigDecimal verifyPrev = toBigDecimal(metricsPrev.get("verifyRate"));
+        BigDecimal verifyNow = calcPeriodVerifyRate(ctx);
+        BigDecimal verifyPrev = calcPeriodVerifyRate(prevCtx);
         vo.setVerifyRate(verifyNow);
         vo.setVerifyRateDelta(subtractRate(verifyNow, verifyPrev));
 
@@ -230,6 +233,12 @@ public class AnalyticsMerchantReportServiceImpl implements AnalyticsMerchantRepo
         }
     }
 
+    private BigDecimal calcPeriodVerifyRate(AnalyticsPeriodContext ctx) {
+        Long verify = eventMapper.countMerchantVerify(ctx.getStartTime(), ctx.getEndTimeExclusive());
+        Long visit = eventMapper.countMerchantViewUsers(ctx.getStartTime(), ctx.getEndTimeExclusive());
+        return AnalyticsDateUtil.calcRate(verify, visit);
+    }
+
     private int countSettled(Date endDate) {
         Long count = merchantStatMapper.countSettledMerchants(endDate);
         return count != null ? count.intValue() : 0;

+ 57 - 28
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsStatisticsServiceImpl.java

@@ -35,6 +35,7 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
     private final BaseRedisService baseRedisService;
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void calculate(Date statDate, String scope, String triggerType) {
         Date day = AnalyticsDateUtil.truncateToDate(statDate);
         String normalizedScope = StringUtils.hasText(scope) ? scope.toUpperCase() : AnalyticsStatScope.ALL;
@@ -120,7 +121,9 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
             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);
+            stat.setOnlineDurationMin(Math.max(
+                    defaultInt(stat.getOnlineDurationMin()),
+                    toInt(row.get("totalDurationMs")) / 60000));
 
             if (stat.getFirstLaunchTime() == null) {
                 stat.setFirstLaunchTime(findFirstEventTime(userId, AnalyticsEventCode.USER_LAUNCH));
@@ -128,8 +131,11 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
             if (stat.getRegisterTime() == null && countUserEvent(userId, AnalyticsEventCode.USER_REGISTER, start, end) > 0) {
                 stat.setRegisterTime(findFirstEventTimeInRange(userId, AnalyticsEventCode.USER_REGISTER, start, end));
             }
+
             if (countUserEvent(userId, AnalyticsEventCode.USER_REGISTER, start, end) > 0) {
                 stat.setIsNewUser(1);
+            } else {
+                stat.setIsNewUser(0);
             }
 
             if (stat.getId() == null) {
@@ -162,15 +168,19 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
                 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"))));
-            stat.setExposeCount(Math.max(defaultInt(stat.getExposeCount()), toInt(row.get("exposeCount"))));
-            stat.setClickCount(Math.max(defaultInt(stat.getClickCount()), toInt(row.get("clickCount"))));
-            stat.setDetailViewCount(Math.max(defaultInt(stat.getDetailViewCount()), toInt(row.get("detailViewCount"))));
-            stat.setContactCount(Math.max(defaultInt(stat.getContactCount()), toInt(row.get("contactCount"))));
-            stat.setVerifyCount(Math.max(defaultInt(stat.getVerifyCount()), toInt(row.get("verifyCount"))));
-            stat.setPayCount(Math.max(defaultInt(stat.getPayCount()), toInt(row.get("payCount"))));
+            stat.setVisitPv(toInt(row.get("visitPv")));
+            stat.setVisitUv(toInt(row.get("visitUv")));
+            stat.setExposeCount(toInt(row.get("exposeCount")));
+            stat.setClickCount(toInt(row.get("clickCount")));
+            stat.setDetailViewCount(toInt(row.get("detailViewCount")));
+            stat.setContactCount(toInt(row.get("contactCount")));
+            stat.setVerifyCount(toInt(row.get("verifyCount")));
+            stat.setPayCount(toInt(row.get("payCount")));
+            stat.setPayUserCount(toInt(row.get("payUserCount")));
             stat.setGmv(toBigDecimal(row.get("gmv")));
+            if (row.get("shopType") != null) {
+                stat.setShopType(toInt(row.get("shopType")));
+            }
             int verifyCount = toInt(row.get("verifyCount"));
             int visitUserCount = toInt(row.get("visitUserCount"));
             stat.setVerifyConversionRate(AnalyticsDateUtil.calcRate(verifyCount, visitUserCount));
@@ -202,14 +212,15 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
             }
             String key = event.getContentType() + ":" + event.getTargetId();
             ContentInteractAgg agg = interactAggMap.computeIfAbsent(key, k -> new ContentInteractAgg());
-            agg.total++;
+            int delta = resolveInteractDelta(event);
+            agg.total += delta;
             String subtype = event.getEventSubtype();
             if (AnalyticsEventSubtype.INTERACT_LIKE.equalsIgnoreCase(subtype)) {
-                agg.like++;
+                agg.like += delta;
             } else if (AnalyticsEventSubtype.INTERACT_COMMENT.equalsIgnoreCase(subtype)) {
-                agg.comment++;
+                agg.comment += delta;
             } else if (AnalyticsEventSubtype.INTERACT_SHARE.equalsIgnoreCase(subtype)) {
-                agg.share++;
+                agg.share += delta;
             }
         }
 
@@ -331,8 +342,8 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
 
         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_MESSAGE, start, end)));
-        summary.setAiChatUserCount(toInt(eventMapper.countDistinctUserByEventCode(AnalyticsEventCode.AI_CHAT_START, start, end)));
+        summary.setAiChatCount(toInt(eventMapper.countByEventCode(AnalyticsEventCode.AI_CHAT_END, start, end)));
+        summary.setAiChatUserCount(toInt(eventMapper.countDistinctUserByEventCode(AnalyticsEventCode.AI_CHAT_END, 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)));
@@ -369,10 +380,7 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
         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));
+        updatePreviousDayRetention(prevDate, prevStart, prevEnd, start, end);
 
         Long verifyCount = eventMapper.countMerchantVerify(start, end);
         Long visitUsers = eventMapper.countMerchantViewUsers(start, end);
@@ -419,20 +427,41 @@ public class AnalyticsStatisticsServiceImpl implements AnalyticsStatisticsServic
             stat.setContentId(contentId);
             stat.setAuthorType(1);
             stat.setAuthorId(0L);
-            stat.setInteractionCount(agg.total);
-            stat.setLikeCount(agg.like);
-            stat.setCommentCount(agg.comment);
-            stat.setShareCount(agg.share);
-            detailStoreService.insertContentStat(statDate, stat);
+        stat.setInteractionCount(agg.total);
+        stat.setLikeCount(agg.like);
+        stat.setCommentCount(agg.comment);
+        stat.setShareCount(agg.share);
+        detailStoreService.insertContentStat(statDate, stat);
             return;
         }
-        stat.setInteractionCount(Math.max(defaultInt(stat.getInteractionCount()), agg.total));
-        stat.setLikeCount(Math.max(defaultInt(stat.getLikeCount()), agg.like));
-        stat.setCommentCount(Math.max(defaultInt(stat.getCommentCount()), agg.comment));
-        stat.setShareCount(Math.max(defaultInt(stat.getShareCount()), agg.share));
+        stat.setInteractionCount(agg.total);
+        stat.setLikeCount(agg.like);
+        stat.setCommentCount(agg.comment);
+        stat.setShareCount(agg.share);
         detailStoreService.updateContentStat(statDate, stat);
     }
 
+    private void updatePreviousDayRetention(Date prevDate, Date cohortStart, Date cohortEnd,
+                                            Date activeStart, Date activeEnd) {
+        Long retained = eventMapper.countNextDayRetained(cohortStart, cohortEnd, activeStart, activeEnd);
+        Long cohortSize = eventMapper.countRegisterUsers(cohortStart, cohortEnd);
+        AnalyticsDailySummary prevSummary = findOrCreateDailySummary(prevDate);
+        prevSummary.setNextDayRetainedCount(toInt(retained));
+        prevSummary.setNextDayRetentionRate(AnalyticsDateUtil.calcRate(retained, cohortSize));
+        if (prevSummary.getId() == null) {
+            dailySummaryMapper.insert(prevSummary);
+        } else {
+            dailySummaryMapper.updateById(prevSummary);
+        }
+    }
+
+    private int resolveInteractDelta(AnalyticsEvent event) {
+        if (event.getAmount() != null && event.getAmount().intValue() != 0) {
+            return event.getAmount().intValue();
+        }
+        return 1;
+    }
+
     private void upsertContentPublish(Date statDate, AnalyticsEvent event) {
         Integer contentType = event.getContentType();
         Long contentId = event.getTargetId();

+ 94 - 25
alien-store/src/main/java/shop/alien/store/service/analytics/impl/AnalyticsTrackServiceImpl.java

@@ -16,6 +16,7 @@ import shop.alien.store.util.analytics.AnalyticsDateUtil;
 import shop.alien.store.util.analytics.AnalyticsFrontHelper;
 
 import javax.servlet.http.HttpServletRequest;
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.UUID;
 
@@ -44,8 +45,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
     public void trackFromFront(AnalyticsFrontReportDTO dto, HttpServletRequest request) {
         AnalyticsTrackEventDTO eventDto = convertFrontReport(dto);
         AnalyticsFrontHelper.enrichTrackEvent(eventDto, request);
-        insertEvent(eventDto);
-        syncContentAuditFromFront(dto, eventDto.getEventCode());
+        if (insertEvent(eventDto)) {
+            syncDetailFromFront(dto, eventDto.getEventCode());
+        }
     }
 
     @Override
@@ -72,18 +74,26 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
             throw new IllegalArgumentException("userId 不能为空");
         }
         Date heartbeatTime = new Date();
-        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);
-        upsertUserHeartbeat(dto, heartbeatTime);
-    }
-
-    private void insertEvent(AnalyticsTrackEventDTO dto) {
+        AnalyticsTrackEventDTO eventDto = buildHeartbeatEvent(dto, heartbeatTime);
+        AnalyticsFrontHelper.enrichTrackEvent(eventDto, request);
+        if (insertEvent(eventDto)) {
+            upsertUserHeartbeat(dto, heartbeatTime);
+        }
+    }
+
+    private AnalyticsTrackEventDTO buildHeartbeatEvent(AnalyticsHeartbeatDTO dto, Date heartbeatTime) {
+        AnalyticsTrackEventDTO eventDto = new AnalyticsTrackEventDTO();
+        eventDto.setEventCode(AnalyticsEventCode.USER_HEARTBEAT);
+        eventDto.setUserId(dto.getUserId());
+        eventDto.setDurationMs(dto.getDurationMs());
+        eventDto.setDeviceType(dto.getDeviceType());
+        eventDto.setChannel(dto.getChannel());
+        eventDto.setCity(dto.getCity());
+        eventDto.setEventTime(heartbeatTime);
+        return eventDto;
+    }
+
+    private boolean insertEvent(AnalyticsTrackEventDTO dto) {
         AnalyticsEvent event = new AnalyticsEvent();
         event.setEventId(StringUtils.hasText(dto.getEventId()) ? dto.getEventId() : UUID.randomUUID().toString().replace("-", ""));
         event.setEventCode(dto.getEventCode());
@@ -103,8 +113,10 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
 
         try {
             eventMapper.insert(event);
+            return true;
         } catch (DuplicateKeyException e) {
             log.debug("埋点事件已存在,跳过: eventId={}", event.getEventId());
+            return false;
         }
     }
 
@@ -225,7 +237,15 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         AnalyticsAiChatStat existing = aiChatStatMapper.selectOne(wrapper);
 
         if (existing == null) {
-            aiChatStatMapper.insert(buildAiChatStat(dto));
+            try {
+                aiChatStatMapper.insert(buildAiChatStat(dto));
+            } catch (DuplicateKeyException e) {
+                existing = aiChatStatMapper.selectOne(wrapper);
+                if (existing != null) {
+                    mergeAiChatStat(existing, dto);
+                    aiChatStatMapper.updateById(existing);
+                }
+            }
             return;
         }
         mergeAiChatStat(existing, dto);
@@ -247,7 +267,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setChannel(dto.getChannel());
         eventDto.setCity(dto.getCity());
         eventDto.setEventTime(registerTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         Date statDate = AnalyticsDateUtil.truncateToDate(registerTime);
         AnalyticsUserStat existing = detailStoreService.findUserStat(statDate, dto.getUserId());
@@ -290,7 +312,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setCity(dto.getCity());
         eventDto.setDeviceType(dto.getDeviceName());
         eventDto.setEventTime(activeTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         Date statDate = AnalyticsDateUtil.truncateToDate(activeTime);
         AnalyticsUserStat existing = detailStoreService.findUserStat(statDate, dto.getUserId());
@@ -331,7 +355,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setDeviceType(dto.getDeviceName());
         eventDto.setDurationMs(sessionMin > 0 ? sessionMin * 60000L : 0L);
         eventDto.setEventTime(activeTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         Date statDate = AnalyticsDateUtil.truncateToDate(activeTime);
         AnalyticsUserStat existing = detailStoreService.findUserStat(statDate, dto.getUserId());
@@ -378,7 +404,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setUserId(dto.getUserId());
         eventDto.setDurationMs(dto.getAiResponseDurationMs());
         eventDto.setEventTime(endTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         AnalyticsAiChatDetailDTO detail = new AnalyticsAiChatDetailDTO();
         detail.setChatId(dto.getChatId());
@@ -428,7 +456,9 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         if (dto.getBusinessCategory() != null) {
             eventDto.setBusinessCategory(dto.getBusinessCategory());
         }
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         AnalyticsContentDetailDTO detail = new AnalyticsContentDetailDTO();
         detail.setContentId(dto.getContentId());
@@ -464,10 +494,14 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setUserId(dto.getUserId());
         eventDto.setTargetId(dto.getContentId());
         eventDto.setContentType(dto.getContentType());
+        eventDto.setEventSubtype(dto.getEventSubtype());
+        eventDto.setAmount(BigDecimal.valueOf(dto.getIncrement()));
         eventDto.setEventTime(eventTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
-        addContentInteraction(dto.getContentType(), dto.getContentId(), dto.getIncrement());
+        addContentInteraction(dto.getContentType(), dto.getContentId(), dto.getIncrement(), dto.getEventSubtype());
     }
 
     @Override
@@ -501,8 +535,11 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         eventDto.setEventCode(AnalyticsEventCode.MERCHANT_VIEW);
         eventDto.setUserId(dto.getUserId());
         eventDto.setMerchantId(dto.getMerchantId());
+        eventDto.setShopType(dto.getShopType());
         eventDto.setEventTime(eventTime);
-        insertEvent(eventDto);
+        if (!insertEvent(eventDto)) {
+            return;
+        }
 
         Date statDate = AnalyticsDateUtil.truncateToDate(eventTime);
         addMerchantVisit(statDate, dto.getMerchantId(), dto.getShopType(), dto.getVisitUv(), dto.getVisitPv());
@@ -538,6 +575,23 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         target.setIsNewUser(1);
     }
 
+    private void syncDetailFromFront(AnalyticsFrontReportDTO dto, String eventCode) {
+        syncContentAuditFromFront(dto, eventCode);
+        if (AnalyticsEventCode.MERCHANT_VIEW.equals(eventCode)
+                && dto.getMerchantId() != null && dto.getShopType() != null) {
+            Date statDate = AnalyticsDateUtil.truncateToDate(
+                    dto.getEventTime() != null ? dto.getEventTime() : new Date());
+            addMerchantVisit(statDate, dto.getMerchantId(), dto.getShopType(), 1, 1);
+        }
+        if (AnalyticsEventCode.CONTENT_INTERACT.equals(eventCode)
+                && dto.getTargetId() != null && dto.getContentType() != null) {
+            int increment = dto.getAmount() != null ? dto.getAmount().intValue() : 1;
+            if (increment != 0) {
+                addContentInteraction(dto.getContentType(), dto.getTargetId(), increment, dto.getEventSubtype());
+            }
+        }
+    }
+
     private void syncContentAuditFromFront(AnalyticsFrontReportDTO dto, String eventCode) {
         if (dto.getTargetId() == null || dto.getContentType() == null) {
             return;
@@ -685,7 +739,7 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
         if (dto.getAuditTime() != null) target.setAuditTime(dto.getAuditTime());
     }
 
-    private void addContentInteraction(Integer contentType, Long contentId, int increment) {
+    private void addContentInteraction(Integer contentType, Long contentId, int increment, String eventSubtype) {
         Date statDate = AnalyticsDateUtil.truncateToDate(new Date());
         AnalyticsContentStat existing = detailStoreService.findContentStat(statDate, contentType, contentId);
         if (existing == null) {
@@ -695,19 +749,34 @@ public class AnalyticsTrackServiceImpl implements AnalyticsTrackService {
             stat.setAuthorType(1);
             stat.setAuthorId(0L);
             stat.setInteractionCount(Math.max(0, increment));
+            applyInteractSubtypeDelta(stat, eventSubtype, increment);
             detailStoreService.insertContentStat(statDate, stat);
             return;
         }
         int next = defaultInt(existing.getInteractionCount()) + increment;
         existing.setInteractionCount(Math.max(0, next));
+        applyInteractSubtypeDelta(existing, eventSubtype, increment);
         detailStoreService.updateContentStat(statDate, existing);
     }
 
+    private void applyInteractSubtypeDelta(AnalyticsContentStat stat, String eventSubtype, int increment) {
+        if (!StringUtils.hasText(eventSubtype)) {
+            return;
+        }
+        if (AnalyticsEventSubtype.INTERACT_LIKE.equalsIgnoreCase(eventSubtype)) {
+            stat.setLikeCount(Math.max(0, defaultInt(stat.getLikeCount()) + increment));
+        } else if (AnalyticsEventSubtype.INTERACT_COMMENT.equalsIgnoreCase(eventSubtype)) {
+            stat.setCommentCount(Math.max(0, defaultInt(stat.getCommentCount()) + increment));
+        } else if (AnalyticsEventSubtype.INTERACT_SHARE.equalsIgnoreCase(eventSubtype)) {
+            stat.setShareCount(Math.max(0, defaultInt(stat.getShareCount()) + increment));
+        }
+    }
+
     private void upsertUserHeartbeat(AnalyticsHeartbeatDTO dto, Date heartbeatTime) {
         Date statDate = AnalyticsDateUtil.truncateToDate(heartbeatTime);
         int addMin = (int) (dto.getDurationMs() / 60000);
         if (addMin <= 0) {
-            addMin = 1;
+            return;
         }
 
         AnalyticsUserStat existing = detailStoreService.findUserStat(statDate, dto.getUserId());