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:
1 parent
8b8ebf944f
commit
b0fea1d237
4 files changed
+253
-4
No files matched your search
+10
@@ -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());
|
||||
}
|
||||
}
|
||||
+213
@@ -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 = 当前创作者),不含他人数据"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user