|
@@ -12,8 +12,11 @@ import com.etotem.cfc.entity.FamilyMember;
|
|
|
import com.etotem.cfc.entity.GameRecord;
|
|
import com.etotem.cfc.entity.GameRecord;
|
|
|
import com.etotem.cfc.entity.MemberUpgradeRecord;
|
|
import com.etotem.cfc.entity.MemberUpgradeRecord;
|
|
|
import com.etotem.cfc.entity.PointsLog;
|
|
import com.etotem.cfc.entity.PointsLog;
|
|
|
|
|
+import com.etotem.cfc.entity.Product;
|
|
|
import com.etotem.cfc.entity.ProductOrder;
|
|
import com.etotem.cfc.entity.ProductOrder;
|
|
|
|
|
+import com.etotem.cfc.entity.ProductViewRecord;
|
|
|
import com.etotem.cfc.entity.ShareEvent;
|
|
import com.etotem.cfc.entity.ShareEvent;
|
|
|
|
|
+import com.etotem.cfc.entity.ShareRead;
|
|
|
import com.etotem.cfc.entity.Task;
|
|
import com.etotem.cfc.entity.Task;
|
|
|
import com.etotem.cfc.entity.TrialMembership;
|
|
import com.etotem.cfc.entity.TrialMembership;
|
|
|
import com.etotem.cfc.entity.User;
|
|
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.GuidePackageMapper;
|
|
|
import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
|
|
import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
|
|
|
import com.etotem.cfc.mapper.PointsLogMapper;
|
|
import com.etotem.cfc.mapper.PointsLogMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProductMapper;
|
|
|
import com.etotem.cfc.mapper.ProductOrderMapper;
|
|
import com.etotem.cfc.mapper.ProductOrderMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProductViewRecordMapper;
|
|
|
import com.etotem.cfc.mapper.ShareEventMapper;
|
|
import com.etotem.cfc.mapper.ShareEventMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ShareReadMapper;
|
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
|
import com.etotem.cfc.mapper.TrialMembershipMapper;
|
|
import com.etotem.cfc.mapper.TrialMembershipMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
@@ -73,6 +79,9 @@ public class StatsController {
|
|
|
@Resource private ArticleMapper articleMapper;
|
|
@Resource private ArticleMapper articleMapper;
|
|
|
@Resource private ArticleReadingRecordMapper articleReadingRecordMapper;
|
|
@Resource private ArticleReadingRecordMapper articleReadingRecordMapper;
|
|
|
@Resource private ProductOrderMapper productOrderMapper;
|
|
@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);
|
|
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<>();
|
|
List<Map<String, Object>> hotArticleList = new ArrayList<>();
|
|
|
- for (Article a : hotArticles) {
|
|
|
|
|
|
|
+ artReadAgg.forEach((aid, v) -> {
|
|
|
Map<String, Object> item = new HashMap<>();
|
|
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.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);
|
|
data.put("hotArticleList", hotArticleList);
|
|
|
|
|
|
|
|
// 5. 购买热点(商品销量 Top 10,近30天已支付订单)
|
|
// 5. 购买热点(商品销量 Top 10,近30天已支付订单)
|
|
@@ -592,10 +618,125 @@ public class StatsController {
|
|
|
data.put("orderCountData", new ArrayList<>(dailyOrderCount.values()));
|
|
data.put("orderCountData", new ArrayList<>(dailyOrderCount.values()));
|
|
|
data.put("orderAmountData", new ArrayList<>(dailyOrderAmount.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) {
|
|
} catch (Exception e) {
|
|
|
log.error("获取流量分析数据失败", e);
|
|
log.error("获取流量分析数据失败", e);
|
|
|
return Result.error("获取流量分析数据失败: " + e.getMessage());
|
|
return Result.error("获取流量分析数据失败: " + e.getMessage());
|
|
|
}
|
|
}
|
|
|
return Result.success(data);
|
|
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());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|