From b0fea1d237a4cbce776aa3cf78f051dfb11275c1 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 14:28:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2007=20=E5=88=9B=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=89=B9=204=EF=BC=88=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=EF=BC=8C=E5=8F=AA=E8=AF=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:创作者在后台能看到自己的作品与定价了,但看不到"有没有人看、有没有收入"。 本批补一个只读看板,数据全部按 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 金币未串过去)。 --- docs/api-phase1.md | 27 ++- .../controller/CreatorCenterController.java | 10 + .../service/CreatorDashboardService.java | 213 ++++++++++++++++++ sql/data-phase1.sql | 7 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/CreatorDashboardService.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 3036a5d..295977c 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -436,7 +436,32 @@ POST /app/auth/register 定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格); `dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。 -**进度**:批 4(创作者数据看板)、批 5(前端页面)未开始。 +**创作数据看板(批 4,只读)** + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/creator-center/dashboard` | `creator-center:stat:view` | 播放 / 完播 / 收藏 / 评论 / 解锁收入概览 | + +**口径**(与代码注释一致,前端 tooltip 可直接复用;统计范围只含 `drama.creator_id = 当前创作者`) + +| 分组 | 指标 | 口径 | +| --- | --- | --- | +| 作品 | `total` / `draft` / `reviewing` / `approved` / `published` / `offline` | 我的短剧按 `status` 计数(当前快照) | +| 作品 | `episodeTotal` / `episodeWithMedia` | 我的分集总数 / `video_url` 非空数 | +| 播放 | `viewTotal` / `playTotal` | 短剧表累计计数列求和(**累计口径,不限时间**) | +| 播放 | `watchedDramas` / `finishedDramas` / `finishRate` | `watch_progress` 是按「用户 × 剧」存的续看进度(唯一索引 `user_id + drama_id`),所以是**有多少人看过我的剧** / 其中最近一次标记完播的 / 完播率;**不是按集** | +| 播放 | `playEvents7d` / `playEventsTotal` | `watch_history` **流水口径**(近 7 天 / 全部),与累计口径不可相加 | +| 播放 | `finishEvents` | `watch_history` 中 `position_ms >= duration_ms` 的完播事件数(流水口径) | +| 互动 | `favoriteTotal` | `user_favorite` 指向我作品的行数 | +| 互动 | `commentTotal` / `commentPending` | `content_comment` 中属于我作品的评论数 / 其中待审数 | +| 收入 | `unlockCount` | `entitlement` 中 `source=ORDER` 且剧属于我的记录数(人工补发的 `ADMIN` 权益不计) | +| 收入 | `coinIncome` / `coinIncome7d` | `coin_ledger` 中 `biz_type=UNLOCK` 且 `biz_id` 命中我分集的扣币绝对值合计(全部 / 近 7 天) | +| 收入 | `paidOrderAmount` | **暂无数据源**,恒为 `null`:金币解锁不产生法币订单,真实金额要等 Apple/Google 商店接入 | +| 趋势 | `daily` | **暂无数据源**,恒为 `null`:`creator_stat_daily` 表已建但无产出任务 | + +取不到的一律 `null` 并在 `notes` 说明缺什么、怎么补,**不用 0 冒充**(与运营大盘同口径)。 + +**进度**:批 5(前端页面)未开始。 ## 3. 契约与实现状态对照 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java index 70839cf..2e0266b 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java @@ -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> notices() { return Result.ok(creatorCenterService.notices()); } + + /** 我的数据看板:播放 / 完播 / 收藏 / 评论 / 解锁收入概览(只读,只含自己的作品)。 */ + @GetMapping("/dashboard") + @PreAuthorize("@ss.hasPermission('creator-center:stat:view')") + public Result> dashboard() { + return Result.ok(creatorDashboardService.overview()); + } } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/CreatorDashboardService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/CreatorDashboardService.java new file mode 100644 index 0000000..a716253 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/CreatorDashboardService.java @@ -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; + +/** + * 创作者数据看板(只读聚合)。 + * + *

为什么放在 admin-api:和运营大盘({@link DashboardService})同样的理由 —— 要跨内容、播放、互动、交易四个域取数, + * 建模块只会把依赖摊开一层;而且它**只读**,放接入层聚合改动面最小。归属由 + * {@link CreatorCenterService#requireCurrentCreator()} 解析,**接口不接收 creatorId**, + * 所有查询都先收敛到"我的作品 id 集合",因此天然只返回自己的数据。

+ * + *

指标口径(逐个写清楚,前端 tooltip 可直接复用): + *

+ * 取不到的一律 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 overview() { + Creator creator = creatorCenterService.requireCurrentCreator(); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime sevenDaysAgo = now.minusDays(RECENT_DAYS); + + List dramas = dramaMapper.selectList(Wrappers.lambdaQuery() + .eq(Drama::getCreatorId, creator.getId()) + .orderByDesc(Drama::getId)); + List dramaIds = dramas.stream().map(Drama::getId).toList(); + List episodes = dramaIds.isEmpty() ? List.of() + : dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .in(DramaEpisode::getDramaId, dramaIds) + .orderByAsc(DramaEpisode::getDramaId) + .orderByAsc(DramaEpisode::getEpNo)); + List episodeIds = episodes.stream().map(DramaEpisode::getId).toList(); + + Map 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 dramaMetrics(List dramas, List episodes) { + Map 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 playbackMetrics(List dramas, List dramaIds, + LocalDateTime sevenDaysAgo) { + Map 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.lambdaQuery() + .in(WatchProgress::getDramaId, dramaIds))); + long finished = dramaIds.isEmpty() ? 0L + : orZero(watchProgressMapper.selectCount(Wrappers.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.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds) + .ge(WatchHistory::getWatchedAt, sevenDaysAgo)))); + playback.put("playEventsTotal", dramaIds.isEmpty() ? 0L + : orZero(watchHistoryMapper.selectCount(Wrappers.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds)))); + playback.put("finishEvents", dramaIds.isEmpty() ? 0L + : orZero(watchHistoryMapper.selectCount(Wrappers.lambdaQuery() + .in(WatchHistory::getDramaId, dramaIds) + .gt(WatchHistory::getDurationMs, 0L) + .apply("position_ms >= duration_ms")))); + return playback; + } + + private Map interactionMetrics(List dramaIds) { + Map interaction = new LinkedHashMap<>(); + interaction.put("favoriteTotal", dramaIds.isEmpty() ? 0L + : orZero(userFavoriteMapper.selectCount(Wrappers.lambdaQuery() + .in(UserFavorite::getDramaId, dramaIds)))); + interaction.put("commentTotal", dramaIds.isEmpty() ? 0L + : orZero(commentMapper.selectCount(Wrappers.lambdaQuery() + .in(ContentComment::getDramaId, dramaIds)))); + interaction.put("commentPending", dramaIds.isEmpty() ? 0L + : orZero(commentMapper.selectCount(Wrappers.lambdaQuery() + .in(ContentComment::getDramaId, dramaIds) + .eq(ContentComment::getStatus, "pending")))); + return interaction; + } + + private Map revenueMetrics(List dramaIds, List episodeIds, + LocalDateTime sevenDaysAgo) { + Map revenue = new LinkedHashMap<>(); + revenue.put("unlockCount", dramaIds.isEmpty() ? 0L + : orZero(entitlementMapper.selectCount(Wrappers.lambdaQuery() + .in(Entitlement::getDramaId, dramaIds) + .eq(Entitlement::getSource, "ORDER")))); + + List 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 episodeIdText, LocalDateTime since) { + List ledger = coinLedgerMapper.selectList(Wrappers.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 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 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 = 当前创作者),不含他人数据" + ); + } +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 071986c..b8bcaab 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -181,7 +181,8 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES ('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150), ('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151), ('creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152), - ('creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153) + ('creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153), + ('creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154) ON CONFLICT (code) DO NOTHING; -- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限 @@ -191,7 +192,7 @@ FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'creator' AND p.code IN ('creator-center:view', 'creator-center:drama:edit', - 'creator-center:upload', 'creator-center:price:update') + 'creator-center:upload', 'creator-center:price:update', 'creator-center:stat:view') ON CONFLICT DO NOTHING; -- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡) @@ -201,7 +202,7 @@ FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin' AND p.code IN ('creator-center:view', 'creator-center:drama:edit', - 'creator-center:upload', 'creator-center:price:update') + 'creator-center:upload', 'creator-center:price:update', 'creator-center:stat:view') ON CONFLICT DO NOTHING; -- 媒资分片上传是复用后台已有的 /admin/media/upload/*,所以创作者角色要用现成的上传权限点,