feat: 07 创作者中心批 4(数据看板,只读)

为什么:创作者在后台能看到自己的作品与定价了,但看不到"有没有人看、有没有收入"。
本批补一个只读看板,数据全部按 drama.creator_id = 当前创作者 收敛,取不到的一律 null + notes。

- 新增 CreatorDashboardService(放 admin-api):和 3-3 的运营大盘同样的理由 —— 跨内容/播放/互动/交易
  四域取数,建模块只会把依赖摊开一层;归属解析复用 CreatorCenterService.requireCurrentCreator(),
  接口不接收 creatorId
- 新增 GET /admin/creator-center/dashboard(权限点 creator-center:stat:view)
- 口径(逐条写进代码注释与 docs/api-phase1.md):viewTotal/playTotal 是短剧表累计计数;
  watchedDramas/finishedDramas 来自 watch_progress(该表唯一索引是 user_id + drama_id,
  每用户每剧一条续看进度,**不是按集**,所以字段名按「用户 × 剧」命名);
  playEvents7d/playEventsTotal 是 watch_history 流水;finishEvents 是流水中 position>=duration 的完播事件;
  favoriteTotal/commentTotal/commentPending 是我的作品的互动;unlockCount/coinIncome 取
  entitlement(source=ORDER) 与 coin_ledger(UNLOCK, biz_id=我的分集)
- paidOrderAmount 与 daily 恒为 null:前者要等 Apple/Google 商店接入,后者 creator_stat_daily 表已建但无产出任务
- 权限点 creator-center:stat:view(创作者角色 + 超管)

实测:看板输出与 SQL 手工口径逐项一致;乙的看板只有自己的数(甲 1200 播放 / 2 条评论 / 50 金币未串过去)。
This commit is contained in:
Vicky committed 2026-10-15 14:28:29 +08:00
1 parent 8b8ebf944f
commit b0fea1d237
4 files changed
+253 -4

No files matched your search

@@ -7,6 +7,7 @@ import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.admin.service.CreatorDashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 创作者中心(Web 端,挂在后台管理里):创作者看自己的作品、审核进度与通知。
@@ -29,6 +31,7 @@ import java.util.List;
public class CreatorCenterController {
private final CreatorCenterService creatorCenterService;
private final CreatorDashboardService creatorDashboardService;
/** 当前创作者的主体信息与入驻状态。 */
@GetMapping("/profile")
@@ -57,4 +60,11 @@ public class CreatorCenterController {
public Result<List<CreatorCenterNoticeVO>> notices() {
return Result.ok(creatorCenterService.notices());
}
/** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */
@GetMapping("/dashboard")
@PreAuthorize("@ss.hasPermission('creator-center:stat:view')")
public Result<Map<String, Object>> dashboard() {
return Result.ok(creatorDashboardService.overview());
}
}
@@ -0,0 +1,213 @@
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.dal.entity.Entitlement;
import com.fruitshorts.commerce.dal.mapper.CoinLedgerMapper;
import com.fruitshorts.commerce.dal.mapper.EntitlementMapper;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.service.CreatorCenterService;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.playback.dal.entity.UserFavorite;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.entity.WatchProgress;
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 创作者数据看板(只读聚合)。
*
* <p><b>为什么放在 admin-api</b>:和运营大盘({@link DashboardService})同样的理由 —— 要跨内容、播放、互动、交易四个域取数,
* 建模块只会把依赖摊开一层;而且它**只读**,放接入层聚合改动面最小。归属由
* {@link CreatorCenterService#requireCurrentCreator()} 解析,**接口不接收 creatorId**,
* 所有查询都先收敛到"我的作品 id 集合",因此天然只返回自己的数据。</p>
*
* <p><b>指标口径(逐个写清楚,前端 tooltip 可直接复用)</b>:
* <ul>
* <li>作品:按 `drama.status` 计数;`episodeTotal` / `episodeWithMedia`(`video_url` 非空)都是**当前快照**。</li>
* <li>播放:`viewTotal` / `playTotal` = 短剧表上累计计数列求和(**累计口径,不限时间**);
* `watchedDramas` / `finishedDramas` = `watch_progress` 行数(唯一索引是 user_id + drama_id,
* **每用户每剧只有一条"续看进度",不是按集**)与其中 `finished=1` 的数;`finishRate` = 后者 / 前者;
* `playEvents7d` / `playEventsTotal` = `watch_history` **流水口径**(近 7 天 / 全部);
* `finishEvents` = 流水中 `position_ms >= duration_ms` 的完播事件数。三种口径维度不同,**不可相加**。</li>
* <li>互动:`favoriteTotal` = `user_favorite` 里指向我作品的行数;`commentTotal` / `commentPending` 同理(`content_comment`)。</li>
* <li>收入:`unlockCount` = `entitlement` 中 `source=ORDER` 且剧属于我的记录数(人工补发的 ADMIN 权益不算收入);
* `coinIncome` / `coinIncome7d` = `coin_ledger` 中 `biz_type=UNLOCK` 且 `biz_id` 命中我分集的扣币绝对值合计;
* `paidOrderAmount` **恒为 null** —— 金币解锁不产生法币订单,真实金额要等接 Apple/Google 商店。</li>
* <li>`daily` **恒为 null**:`creator_stat_daily` 表已建但没有产出任务,按天趋势取不到。</li>
* </ul>
* 取不到的一律 null + notes 说明缺什么、怎么补,**不用 0 冒充**。
*/
@Service
@RequiredArgsConstructor
public class CreatorDashboardService {
private static final int RECENT_DAYS = 7;
private final CreatorCenterService creatorCenterService;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final WatchProgressMapper watchProgressMapper;
private final WatchHistoryMapper watchHistoryMapper;
private final UserFavoriteMapper userFavoriteMapper;
private final ContentCommentMapper commentMapper;
private final EntitlementMapper entitlementMapper;
private final CoinLedgerMapper coinLedgerMapper;
public Map<String, Object> overview() {
Creator creator = creatorCenterService.requireCurrentCreator();
LocalDateTime now = LocalDateTime.now();
LocalDateTime sevenDaysAgo = now.minusDays(RECENT_DAYS);
List<Drama> dramas = dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())
.orderByDesc(Drama::getId));
List<Long> dramaIds = dramas.stream().map(Drama::getId).toList();
List<DramaEpisode> episodes = dramaIds.isEmpty() ? List.of()
: dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.in(DramaEpisode::getDramaId, dramaIds)
.orderByAsc(DramaEpisode::getDramaId)
.orderByAsc(DramaEpisode::getEpNo));
List<Long> episodeIds = episodes.stream().map(DramaEpisode::getId).toList();
Map<String, Object> data = new LinkedHashMap<>();
data.put("generatedAt", now);
data.put("drama", dramaMetrics(dramas, episodes));
data.put("playback", playbackMetrics(dramas, dramaIds, sevenDaysAgo));
data.put("interaction", interactionMetrics(dramaIds));
data.put("revenue", revenueMetrics(dramaIds, episodeIds, sevenDaysAgo));
// 按天趋势没有数据源:creator_stat_daily 表已建但无产出,如实给 null
data.put("daily", null);
data.put("notes", notes());
return data;
}
private Map<String, Object> dramaMetrics(List<Drama> dramas, List<DramaEpisode> episodes) {
Map<String, Object> drama = new LinkedHashMap<>();
drama.put("total", dramas.size());
drama.put("draft", countStatus(dramas, ContentStatus.DRAFT));
drama.put("reviewing", countStatus(dramas, ContentStatus.REVIEWING));
drama.put("approved", countStatus(dramas, ContentStatus.APPROVED));
drama.put("published", countStatus(dramas, ContentStatus.PUBLISHED));
drama.put("offline", countStatus(dramas, ContentStatus.OFFLINE));
drama.put("episodeTotal", episodes.size());
drama.put("episodeWithMedia", episodes.stream()
.filter(episode -> StringUtils.hasText(episode.getVideoUrl()))
.count());
return drama;
}
private Map<String, Object> playbackMetrics(List<Drama> dramas, List<Long> dramaIds,
LocalDateTime sevenDaysAgo) {
Map<String, Object> playback = new LinkedHashMap<>();
playback.put("viewTotal", dramas.stream()
.mapToLong(drama -> drama.getViewCount() == null ? 0L : drama.getViewCount()).sum());
playback.put("playTotal", dramas.stream()
.mapToLong(drama -> drama.getPlayCount() == null ? 0L : drama.getPlayCount()).sum());
long watched = dramaIds.isEmpty() ? 0L
: orZero(watchProgressMapper.selectCount(Wrappers.<WatchProgress>lambdaQuery()
.in(WatchProgress::getDramaId, dramaIds)));
long finished = dramaIds.isEmpty() ? 0L
: orZero(watchProgressMapper.selectCount(Wrappers.<WatchProgress>lambdaQuery()
.in(WatchProgress::getDramaId, dramaIds)
.eq(WatchProgress::getFinished, 1)));
// watch_progress 的唯一索引是 (user_id, drama_id):一行代表"某人对某剧的续看进度",
// 所以口径是 用户×剧,不是按集统计,字段名也跟着叫 watchedDramas / finishedDramas
playback.put("watchedDramas", watched);
playback.put("finishedDramas", finished);
playback.put("finishRate", watched == 0 ? 0d : Math.round(finished * 1000.0 / watched) / 1000.0);
playback.put("playEvents7d", dramaIds.isEmpty() ? 0L
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
.in(WatchHistory::getDramaId, dramaIds)
.ge(WatchHistory::getWatchedAt, sevenDaysAgo))));
playback.put("playEventsTotal", dramaIds.isEmpty() ? 0L
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
.in(WatchHistory::getDramaId, dramaIds))));
playback.put("finishEvents", dramaIds.isEmpty() ? 0L
: orZero(watchHistoryMapper.selectCount(Wrappers.<WatchHistory>lambdaQuery()
.in(WatchHistory::getDramaId, dramaIds)
.gt(WatchHistory::getDurationMs, 0L)
.apply("position_ms >= duration_ms"))));
return playback;
}
private Map<String, Object> interactionMetrics(List<Long> dramaIds) {
Map<String, Object> interaction = new LinkedHashMap<>();
interaction.put("favoriteTotal", dramaIds.isEmpty() ? 0L
: orZero(userFavoriteMapper.selectCount(Wrappers.<UserFavorite>lambdaQuery()
.in(UserFavorite::getDramaId, dramaIds))));
interaction.put("commentTotal", dramaIds.isEmpty() ? 0L
: orZero(commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
.in(ContentComment::getDramaId, dramaIds))));
interaction.put("commentPending", dramaIds.isEmpty() ? 0L
: orZero(commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
.in(ContentComment::getDramaId, dramaIds)
.eq(ContentComment::getStatus, "pending"))));
return interaction;
}
private Map<String, Object> revenueMetrics(List<Long> dramaIds, List<Long> episodeIds,
LocalDateTime sevenDaysAgo) {
Map<String, Object> revenue = new LinkedHashMap<>();
revenue.put("unlockCount", dramaIds.isEmpty() ? 0L
: orZero(entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
.in(Entitlement::getDramaId, dramaIds)
.eq(Entitlement::getSource, "ORDER"))));
List<String> episodeIdText = episodeIds.stream().map(String::valueOf).toList();
revenue.put("coinIncome", episodeIdText.isEmpty() ? 0L : sumUnlockCoin(episodeIdText, null));
revenue.put("coinIncome7d", episodeIdText.isEmpty()
? 0L : sumUnlockCoin(episodeIdText, sevenDaysAgo));
// 金币解锁不产生法币订单;真实收入金额要等 Apple/Google 商店接入,这里如实给 null
revenue.put("paidOrderAmount", null);
return revenue;
}
/** 解锁扣币记在 coin_ledger:biz_type=UNLOCK、biz_id=剧集 id、金额为负,取绝对值即收入。 */
private long sumUnlockCoin(List<String> episodeIdText, LocalDateTime since) {
List<CoinLedger> ledger = coinLedgerMapper.selectList(Wrappers.<CoinLedger>lambdaQuery()
.eq(CoinLedger::getBizType, "UNLOCK")
.in(CoinLedger::getBizId, episodeIdText)
.ge(since != null, CoinLedger::getCreatedAt, since));
return ledger.stream()
.mapToLong(item -> item.getChangeAmount() == null ? 0L : -item.getChangeAmount())
.sum();
}
private long countStatus(List<Drama> dramas, ContentStatus status) {
return dramas.stream().filter(drama -> status.getCode().equals(drama.getStatus())).count();
}
private long orZero(Long value) {
return value == null ? 0L : value;
}
private List<String> notes() {
return List.of(
"viewTotal / playTotal 是短剧表上的累计计数(不限时间),与 playEvents7d / playEventsTotal 的流水口径不可相加",
"watchedDramas / finishedDramas 取自 watch_progress —— 该表唯一索引是 (user_id, drama_id),"
+ "每用户每剧只有一条续看进度,所以口径是「用户 × 剧」不是按集;finishRate = 后者 / 前者",
"finishEvents 是 watch_history 中 position_ms >= duration_ms 的完播事件数(流水口径,与上面两种口径维度不同)",
"coinIncome 取 coin_ledger 中 biz_type=UNLOCK 且命中本人分集的扣币合计;paidOrderAmount 需接入 Apple/Google 商店才有数据,当前为 null",
"daily(按天趋势)暂无数据源:creator_stat_daily 表已建但没有产出任务,需要按天聚合 watch_history / entitlement 的定时任务,当前阶段未接入",
"统计范围仅限本人作品(drama.creator_id = 当前创作者),不含他人数据"
);
}
}