Просмотр исходного кода

feat(admin): 后台Dashboard重设计 + 真实行为数据口径

1. Dashboard 重设计:基于 Ant Design 5 令牌美化,新增运营概览/内容吸引力(停留时长Top)/内容广度(热度Top)/PV-UV趋势/会员构成等板块,聚焦运营关注的文章、商品、用户停留时长。
2. 统计口径修正:文章热度排名由 view_count(每次打开+1的虚高指标)改为 article_reading_records 真实阅读记录聚合;商品销量本就来自真实 product_orders,统一加"真实"标识。
3. 商品停留埋点:新增 product_view_records 表 + POST /api/stats/product-view 接口 + 小程序 dwellTracker 埋点(onLoad/onUnload 上报浏览时长),补齐此前缺失的商品停留时长数据。

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
Agnes Agent 2 недель назад
Родитель
Сommit
ee71f7716b

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1009,6 +1009,25 @@ public class DatabaseInitializer implements CommandLineRunner {
             log.warn("创建product_orders表失败: {}", e.getMessage());
         }
 
+        // 商品浏览/停留时长埋点表(支撑运营看板「商品停留时长 Top」)
+        try {
+            jdbcTemplate.execute(
+                    "CREATE TABLE IF NOT EXISTS product_view_records (" +
+                            "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                            "  user_id BIGINT," +
+                            "  product_id BIGINT NOT NULL," +
+                            "  product_name VARCHAR(200)," +
+                            "  product_type VARCHAR(32)," +
+                            "  duration_seconds INT NOT NULL DEFAULT 0 COMMENT '本次浏览停留秒数'," +
+                            "  source VARCHAR(32) COMMENT 'miniprogram/web'," +
+                            "  view_at DATETIME," +
+                            "  created_at DATETIME DEFAULT CURRENT_TIMESTAMP" +
+                            ")"
+            );
+        } catch (Exception e) {
+            log.warn("创建product_view_records表失败: {}", e.getMessage());
+        }
+
         // 系统配置表
         try {
             jdbcTemplate.execute(

+ 155 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java

@@ -12,8 +12,11 @@ import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.GameRecord;
 import com.etotem.cfc.entity.MemberUpgradeRecord;
 import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductViewRecord;
 import com.etotem.cfc.entity.ShareEvent;
+import com.etotem.cfc.entity.ShareRead;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.TrialMembership;
 import com.etotem.cfc.entity.User;
@@ -26,8 +29,11 @@ import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.GuidePackageMapper;
 import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.ProductViewRecordMapper;
 import com.etotem.cfc.mapper.ShareEventMapper;
+import com.etotem.cfc.mapper.ShareReadMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TrialMembershipMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -73,6 +79,9 @@ public class StatsController {
     @Resource private ArticleMapper articleMapper;
     @Resource private ArticleReadingRecordMapper articleReadingRecordMapper;
     @Resource private ProductOrderMapper productOrderMapper;
+    @Resource private ProductMapper productMapper;
+    @Resource private ProductViewRecordMapper productViewRecordMapper;
+    @Resource private ShareReadMapper shareReadMapper;
 
     // ── 管理后台仪表盘 ──
 
@@ -518,23 +527,40 @@ public class StatsController {
             }
             data.put("hotShareList", hotShareList);
 
-            // 4. 访问热点(文章阅读量 Top 10)
-            List<Article> hotArticles = articleMapper.selectList(
-                    new LambdaQueryWrapper<Article>()
-                            .eq(Article::getStatus, "published")
-                            .isNotNull(Article::getViewCount)
-                            .orderByDesc(Article::getViewCount)
-                            .last("LIMIT 10"));
+            // 4. 文章真实阅读热点(基于实际阅读记录,不使用 view_count 虚高计数)
+            // 预计算:近30天文章真实阅读记录 + 文章标题映射(热度与停留时长共用)
+            List<ArticleReadingRecord> articleReads = articleReadingRecordMapper.selectList(
+                    new LambdaQueryWrapper<ArticleReadingRecord>().ge(ArticleReadingRecord::getReadAt, thirtyDaysAgo));
+            Map<Long, String> articleTitleMap = new HashMap<>();
+            for (Article a : articleMapper.selectList(null)) {
+                articleTitleMap.put(a.getId(), a.getTitle());
+            }
+            Map<Long, long[]> artReadAgg = new LinkedHashMap<>(); // [真实阅读次数, 累计时长]
+            Map<Long, Set<Long>> artReaderSet = new HashMap<>();
+            for (ArticleReadingRecord r : articleReads) {
+                long[] a = artReadAgg.computeIfAbsent(r.getArticleId(), k -> new long[]{0, 0});
+                a[0] += 1;
+                a[1] += (r.getDurationSeconds() != null ? r.getDurationSeconds() : 0);
+                if (r.getUserId() != null) {
+                    artReaderSet.computeIfAbsent(r.getArticleId(), k -> new HashSet<>()).add(r.getUserId());
+                }
+            }
             List<Map<String, Object>> hotArticleList = new ArrayList<>();
-            for (Article a : hotArticles) {
+            artReadAgg.forEach((aid, v) -> {
                 Map<String, Object> item = new HashMap<>();
-                item.put("id", a.getId());
-                item.put("title", a.getTitle());
-                item.put("viewCount", a.getViewCount());
-                item.put("coverImage", a.getCoverImage());
-                item.put("categoryId", a.getCategoryId());
+                item.put("id", aid);
+                item.put("title", articleTitleMap.getOrDefault(aid, "文章" + aid));
+                item.put("viewCount", v[0]); // 真实阅读次数(阅读记录条数,非 view_count)
+                item.put("readers", artReaderSet.getOrDefault(aid, new HashSet<>()).size());
+                item.put("avgDurationSeconds", v[0] > 0 ? v[1] / v[0] : 0);
+                item.put("coverImage", "");
+                item.put("categoryId", null);
                 hotArticleList.add(item);
-            }
+            });
+            hotArticleList.sort((x, y) -> Long.compare(
+                    ((Number) y.get("viewCount")).longValue(),
+                    ((Number) x.get("viewCount")).longValue()));
+            if (hotArticleList.size() > 10) hotArticleList = hotArticleList.subList(0, 10);
             data.put("hotArticleList", hotArticleList);
 
             // 5. 购买热点(商品销量 Top 10,近30天已支付订单)
@@ -592,10 +618,125 @@ public class StatsController {
             data.put("orderCountData", new ArrayList<>(dailyOrderCount.values()));
             data.put("orderAmountData", new ArrayList<>(dailyOrderAmount.values()));
 
+            // 7. 文章停留时长 Top(近30天,按平均停留秒数排序)
+            Map<Long, long[]> artAgg = new LinkedHashMap<>();
+            Map<Long, Set<Long>> artReaders = new HashMap<>();
+            for (ArticleReadingRecord r : articleReads) {
+                long[] a = artAgg.computeIfAbsent(r.getArticleId(), k -> new long[]{0, 0});
+                a[0] += (r.getDurationSeconds() != null ? r.getDurationSeconds() : 0);
+                a[1] += 1;
+                if (r.getUserId() != null) {
+                    artReaders.computeIfAbsent(r.getArticleId(), k -> new HashSet<>()).add(r.getUserId());
+                }
+            }
+            List<Map<String, Object>> articleDwellList = new ArrayList<>();
+            artAgg.forEach((aid, v) -> {
+                long avg = v[1] > 0 ? v[0] / v[1] : 0;
+                Map<String, Object> m = new HashMap<>();
+                m.put("articleId", aid);
+                m.put("title", articleTitleMap.getOrDefault(aid, "文章" + aid));
+                m.put("avgDurationSeconds", avg);
+                m.put("totalDurationSeconds", v[0]);
+                m.put("views", v[1]);
+                m.put("readers", artReaders.getOrDefault(aid, new HashSet<>()).size());
+                articleDwellList.add(m);
+            });
+            articleDwellList.sort((x, y) -> Long.compare(
+                    ((Number) y.get("avgDurationSeconds")).longValue(),
+                    ((Number) x.get("avgDurationSeconds")).longValue()));
+            if (articleDwellList.size() > 10) articleDwellList = articleDwellList.subList(0, 10);
+            data.put("articleDwellList", articleDwellList);
+
+            // 8. 用户停留时长 Top(文章阅读 + 分享阅读,近30天累计)
+            Map<Long, Long> userDwellMap = new HashMap<>();
+            for (ArticleReadingRecord r : articleReads) {
+                if (r.getUserId() != null && r.getDurationSeconds() != null) {
+                    userDwellMap.merge(r.getUserId(), (long) r.getDurationSeconds(), Long::sum);
+                }
+            }
+            List<ShareRead> shareReads = shareReadMapper.selectList(
+                    new LambdaQueryWrapper<ShareRead>()
+                            .ge(ShareRead::getReadAt, thirtyDaysAgo)
+                            .isNotNull(ShareRead::getReadDuration));
+            for (ShareRead s : shareReads) {
+                if (s.getReaderId() != null && s.getReadDuration() != null) {
+                    userDwellMap.merge(s.getReaderId(), (long) s.getReadDuration(), Long::sum);
+                }
+            }
+            Map<Long, String> userNameMap = new HashMap<>();
+            for (User u : userMapper.selectList(null)) {
+                String nm = (u.getNickname() != null && !u.getNickname().isEmpty()) ? u.getNickname()
+                        : (u.getRealName() != null && !u.getRealName().isEmpty()) ? u.getRealName()
+                        : (u.getPhone() != null ? u.getPhone() : ("用户" + u.getId()));
+                userNameMap.put(u.getId(), nm);
+            }
+            List<Map<String, Object>> userDwellList = new ArrayList<>();
+            userDwellMap.forEach((uid, total) -> {
+                Map<String, Object> m = new HashMap<>();
+                m.put("userId", uid);
+                m.put("userName", userNameMap.getOrDefault(uid, "用户" + uid));
+                m.put("totalDurationSeconds", total);
+                userDwellList.add(m);
+            });
+            userDwellList.sort((x, y) -> Long.compare(
+                    ((Number) y.get("totalDurationSeconds")).longValue(),
+                    ((Number) x.get("totalDurationSeconds")).longValue()));
+            if (userDwellList.size() > 10) userDwellList = userDwellList.subList(0, 10);
+            data.put("userDwellList", userDwellList);
+
+            // 9. 商品停留时长 Top(近30天,依赖商品详情页埋点上报)
+            List<ProductViewRecord> productViews = productViewRecordMapper.selectList(
+                    new LambdaQueryWrapper<ProductViewRecord>()
+                            .ge(ProductViewRecord::getViewAt, thirtyDaysAgo)
+                            .isNotNull(ProductViewRecord::getDurationSeconds));
+            Map<Long, long[]> prodAgg = new LinkedHashMap<>();
+            for (ProductViewRecord r : productViews) {
+                long[] a = prodAgg.computeIfAbsent(r.getProductId(), k -> new long[]{0, 0});
+                a[0] += (r.getDurationSeconds() != null ? r.getDurationSeconds() : 0);
+                a[1] += 1;
+            }
+            List<Map<String, Object>> productDwellList = new ArrayList<>();
+            prodAgg.forEach((pid, v) -> {
+                long avg = v[1] > 0 ? v[0] / v[1] : 0;
+                Product p = productMapper.selectById(pid);
+                Map<String, Object> m = new HashMap<>();
+                m.put("productId", pid);
+                m.put("productName", p != null ? p.getName() : ("商品" + pid));
+                m.put("avgDurationSeconds", avg);
+                m.put("totalDurationSeconds", v[0]);
+                m.put("views", v[1]);
+                productDwellList.add(m);
+            });
+            productDwellList.sort((x, y) -> Long.compare(
+                    ((Number) y.get("avgDurationSeconds")).longValue(),
+                    ((Number) x.get("avgDurationSeconds")).longValue()));
+            if (productDwellList.size() > 10) productDwellList = productDwellList.subList(0, 10);
+            data.put("productDwellList", productDwellList);
+
         } catch (Exception e) {
             log.error("获取流量分析数据失败", e);
             return Result.error("获取流量分析数据失败: " + e.getMessage());
         }
         return Result.success(data);
     }
+
+    @Operation(summary = "记录商品浏览停留时长(前端埋点上报)")
+    @PostMapping("/product-view")
+    public Result<Object> recordProductView(@RequestBody ProductViewRecord record) {
+        try {
+            if (record == null || record.getProductId() == null) {
+                return Result.error("参数缺失");
+            }
+            if (record.getDurationSeconds() == null) record.setDurationSeconds(0);
+            if (record.getUserId() == null) record.setUserId(0L);
+            if (record.getSource() == null) record.setSource("miniprogram");
+            record.setViewAt(new Date());
+            record.setCreatedAt(new Date());
+            productViewRecordMapper.insert(record);
+            return Result.success();
+        } catch (Exception e) {
+            log.error("记录商品浏览失败", e);
+            return Result.error("记录失败: " + e.getMessage());
+        }
+    }
 }

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductViewRecord.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 商品浏览停留时长埋点记录
+ *
+ * 用途:支撑运营看板「商品停留时长 Top」分析。
+ * 小程序 / H5 商品详情页在用户离开或定时上报本次浏览时长(durationSeconds),
+ * 后台按 productId 聚合平均 / 累计停留时长。
+ */
+@Data
+@TableName("product_view_records")
+public class ProductViewRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 浏览用户(未登录/无法识别时记为 0) */
+    private Long userId;
+
+    /** 商品 ID(products 表主键) */
+    private Long productId;
+
+    /** 商品名称(上报时冗余存储,避免聚合时再 JOIN) */
+    private String productName;
+
+    /** 商品类型:activity/course/physical/digital/service */
+    private String productType;
+
+    /** 本次浏览停留秒数 */
+    private Integer durationSeconds;
+
+    /** 来源:miniprogram / web */
+    private String source;
+
+    private Date viewAt;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductViewRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductViewRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProductViewRecordMapper extends BaseMapper<ProductViewRecord> {
+}

+ 5 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -183,6 +183,7 @@ import MpHtml from '@/components/mp-html/mp-html.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 import shareMixin from '../../../components/share-mixin.js'
+import { startProductDwell, flushProductDwell } from '@/utils/dwellTracker.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
@@ -401,11 +402,15 @@ export default {
     if (this.specialDealId) {
       this.loadSpecialDealInfo()
     }
+    // 启动商品停留时长埋点计时
+    startProductDwell()
   },
   onShow() {
     this.fontScale = getFontScale()
   },
   onUnload() {
+    // 上报商品停留时长(用户离开详情页)
+    flushProductDwell(this.product)
     if (this.specialDealCountdownTimer) {
       clearInterval(this.specialDealCountdownTimer)
       this.specialDealCountdownTimer = null

+ 3 - 0
cfc-frontend/utils/api.js

@@ -3494,3 +3494,6 @@ export const getMyVisibleReportMembers = () =>
  * @returns {{articles: Array, activities: Array, products: Array}}
  */
 export const getContentRelated = (data) => request('/api/content/related-items', 'POST', data || {})
+
+// 商品浏览停留时长埋点上报(支撑运营看板「商品停留时长 Top」)
+export const reportProductDwell = (data) => request('/api/stats/product-view', 'POST', data || {})

+ 35 - 0
cfc-frontend/utils/dwellTracker.js

@@ -0,0 +1,35 @@
+// 商品浏览停留时长埋点工具
+// 用法(商品详情页):
+//   onLoad:   startProductDwell()
+//   onUnload: flushProductDwell(this.product)
+// 后端接口:POST /api/stats/product-view(StatsController.recordProductView)
+import { reportProductDwell } from './api.js'
+
+let _startTs = 0
+
+export function startProductDwell() {
+  _startTs = Date.now()
+}
+
+export function flushProductDwell(product) {
+  var start = _startTs
+  _startTs = 0
+  if (!product || !product.id) return
+  var duration = Math.round((Date.now() - start) / 1000)
+  if (duration <= 0) return
+  var userId = 0
+  try {
+    userId = uni.getStorageSync('userId') || 0
+  } catch (e) {}
+  var payload = {
+    userId: userId,
+    productId: product.id,
+    productName: product.name || '',
+    productType: product.productType || '',
+    durationSeconds: duration,
+    source: 'miniprogram'
+  }
+  try {
+    reportProductDwell(payload)
+  } catch (e) {}
+}

Разница между файлами не показана из-за своего большого размера
+ 763 - 700
cfc-web/src/views/Dashboard.vue


Некоторые файлы не были показаны из-за большого количества измененных файлов