diff --git a/docs/api-phase1.md b/docs/api-phase1.md index ede21ed..513bc59 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -289,7 +289,34 @@ POST /app/auth/register **定时发布**:`DramaPublishJob` 每分钟扫一次,把 `publish_at` 到点的 `approved` 短剧推上线, 走的是短剧状态机(不直接改状态字段)。上下架也可通过内容提交的 `OFFLINE` 审核触发。 -### 2.8 数据看板(下一批) +### 2.8 数据看板(已实现,只读) + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/dashboard/overview` | `dashboard:view` | 总览:用户 / 内容 / 播放 / 收入 / 成本 | +| GET | `/admin/dashboard/transcode` | `dashboard:view` | 转码队列积压与失败任务明细(含 `errorMsg` / `retryCount`) | + +**口径(与代码注释一致,前端 tooltip 可直接复用)** + +| 分组 | 指标 | 口径 | +| --- | --- | --- | +| 用户 | `total` / `newToday` / `new7d` | `app_user` 未删除总数;按 `created_at` 落在当天 / 近 7 天 | +| 用户 | `active7d` | `last_login_at` 在近 7 天内。**不是 DAU**,日活需要逐日登录快照,当前无产出 | +| 用户 | `banned` / `limited` / `deactivated` | 按 `status` 计数 | +| 内容 | `drama*` | 短剧按 `status` 计数(`reviewing` 为待审、`published` 为已上线) | +| 内容 | `episodeTotal` / `Published` / `WithMedia` | 剧集总数 / 已上线数 / `video_url` 非空数(均排除逻辑删除) | +| 内容 | `commentPending` / `reportPending` | 待审评论、待处理举报 | +| 播放 | `viewTotal` / `playTotal` | 短剧表累计计数列求和(累计口径,不限时间) | +| 播放 | `watchedDramas` / `finishedDramas` / `finishRate` | `watch_progress` 行数 / 其中 `finished=1` / 完播率 | +| 播放 | `playEvents7d` / `playEventsTotal` | `watch_history` 流水口径(与累计口径不可相加) | +| 收入 | `order*` / `paidAmount` | 订单按状态计数;金额为已支付+已交付合计(商店未接入前恒为 0) | +| 收入 | `coinRecharged` / `coinConsumed` / `coinBalance` | 取自 `coin_ledger`(RECHARGE / UNLOCK)与 `wallet` 余额合计 | +| 收入 | `entitlementActive` | 有效权益数 | +| 成本 | `transcode*` / `renditions` | `transcode_task` 按状态计数、`media_rendition` 产物数 | +| 成本 | `otpSent7d` | 近 7 天验证码发送条数(`app_user_otp`),作为短信成本近似 | +| 成本 | `cdnTrafficBytes` | **暂无数据源**,返回 `null` 并在 `notes` 说明 | + +取不到数据源的指标一律返回 `null` 并在 `notes` 里写明缺什么,**不用 0 冒充**。 | 方法 | 路径 | 说明 | | --- | --- | --- | @@ -322,6 +349,6 @@ POST /app/auth/register | 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 | | 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 | | 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) | -| 08 平台运营后台 | 已建 | 已冻结 | 用户管理、内容治理、订单与权益、**创作者与内容审核已实现**;数据看板下一批 | +| 08 平台运营后台 | 已建 | 已冻结 | 用户管理、内容治理、订单与权益、创作者与内容审核、**数据看板均已实现**;仅剩后台页面(3-4) | | 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) | | 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 | diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java new file mode 100644 index 0000000..4f929a6 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java @@ -0,0 +1,39 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.service.DashboardService; +import com.fruitshorts.common.pojo.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 运营大盘(只读)。 + * + * 指标口径写在 DashboardService 的类注释与 docs/api-phase1.md 里; + * 取不到数据源的指标返回 null 并在 notes 里说明,不用 0 冒充。 + */ +@RestController +@RequestMapping("/admin/dashboard") +@RequiredArgsConstructor +public class DashboardAdminController { + + private final DashboardService dashboardService; + + /** 总览:用户 / 内容 / 播放 / 收入 / 成本。 */ + @GetMapping("/overview") + @PreAuthorize("@ss.hasPermission('dashboard:view')") + public Result> overview() { + return Result.ok(dashboardService.overview()); + } + + /** 转码看板:队列积压与失败任务明细。 */ + @GetMapping("/transcode") + @PreAuthorize("@ss.hasPermission('dashboard:view')") + public Result> transcode() { + return Result.ok(dashboardService.transcode()); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java new file mode 100644 index 0000000..bbcf775 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java @@ -0,0 +1,252 @@ +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.entity.Orders; +import com.fruitshorts.commerce.dal.entity.Wallet; +import com.fruitshorts.commerce.dal.mapper.CoinLedgerMapper; +import com.fruitshorts.commerce.dal.mapper.EntitlementMapper; +import com.fruitshorts.commerce.dal.mapper.OrdersMapper; +import com.fruitshorts.commerce.dal.mapper.WalletMapper; +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.interaction.dal.entity.ContentComment; +import com.fruitshorts.interaction.dal.entity.ContentReport; +import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; +import com.fruitshorts.interaction.dal.mapper.ContentReportMapper; +import com.fruitshorts.media.service.TranscodeService; +import com.fruitshorts.playback.dal.entity.WatchHistory; +import com.fruitshorts.playback.dal.entity.WatchProgress; +import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; +import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.dal.mapper.AppUserOtpMapper; +import com.fruitshorts.user.dal.entity.AppUserOtp; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 运营大盘(只读聚合)。 + * + *

为什么放在 admin-api 而不是建一个新模块:它要跨用户/内容/播放/互动/交易/媒资六个域取数, + * 建模块只会把依赖再摊开一层;而它**只读、不写**,放在接入层做聚合,改动面最小。 + * + *

指标口径(每个指标都在注释里写清楚,前端 tooltip 直接复用): + *

+ */ +@Service +@RequiredArgsConstructor +public class DashboardService { + + private static final int ACTIVE_WINDOW_DAYS = 7; + + private final AppUserMapper appUserMapper; + private final AppUserOtpMapper appUserOtpMapper; + private final DramaMapper dramaMapper; + private final DramaEpisodeMapper dramaEpisodeMapper; + private final WatchProgressMapper watchProgressMapper; + private final WatchHistoryMapper watchHistoryMapper; + private final ContentCommentMapper commentMapper; + private final ContentReportMapper reportMapper; + private final OrdersMapper ordersMapper; + private final WalletMapper walletMapper; + private final CoinLedgerMapper coinLedgerMapper; + private final EntitlementMapper entitlementMapper; + private final TranscodeService transcodeService; + + public Map overview() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime sevenDaysAgo = now.minusDays(ACTIVE_WINDOW_DAYS); + + Map data = new LinkedHashMap<>(); + data.put("generatedAt", now); + data.put("user", userMetrics(todayStart, sevenDaysAgo)); + data.put("content", contentMetrics()); + data.put("playback", playbackMetrics(sevenDaysAgo)); + data.put("revenue", revenueMetrics()); + data.put("cost", costMetrics(sevenDaysAgo)); + data.put("notes", notes()); + return data; + } + + /** 转码看板:队列积压与失败明细。 */ + public Map transcode() { + Map data = new LinkedHashMap<>(); + data.put("generatedAt", LocalDateTime.now()); + data.put("queue", transcodeService.stats()); + data.put("failedTasks", transcodeService.failedTasks(20)); + data.put("notes", List.of( + "queue 为累计口径(不限时间),反映当前积压与历史成败分布", + "failedTasks 最多返回 20 条,按 id 倒序")); + return data; + } + + private Map userMetrics(LocalDateTime todayStart, LocalDateTime sevenDaysAgo) { + Map user = new LinkedHashMap<>(); + user.put("total", countUser(Wrappers.lambdaQuery())); + user.put("newToday", countUser(Wrappers.lambdaQuery() + .ge(AppUser::getCreatedAt, todayStart))); + user.put("new7d", countUser(Wrappers.lambdaQuery() + .ge(AppUser::getCreatedAt, sevenDaysAgo))); + user.put("active7d", countUser(Wrappers.lambdaQuery() + .ge(AppUser::getLastLoginAt, sevenDaysAgo))); + user.put("banned", countUser(Wrappers.lambdaQuery().eq(AppUser::getStatus, "banned"))); + user.put("limited", countUser(Wrappers.lambdaQuery().eq(AppUser::getStatus, "limited"))); + user.put("deactivated", countUser(Wrappers.lambdaQuery() + .eq(AppUser::getStatus, "deactivated"))); + return user; + } + + private Map contentMetrics() { + Map content = new LinkedHashMap<>(); + content.put("dramaTotal", dramaMapper.selectCount(null)); + content.put("dramaPublished", countDrama(ContentStatus.PUBLISHED.getCode())); + content.put("dramaReviewing", countDrama(ContentStatus.REVIEWING.getCode())); + content.put("dramaDraft", countDrama(ContentStatus.DRAFT.getCode())); + content.put("dramaOffline", countDrama(ContentStatus.OFFLINE.getCode())); + content.put("episodeTotal", dramaEpisodeMapper.selectCount(null)); + content.put("episodePublished", dramaEpisodeMapper.selectCount( + Wrappers.lambdaQuery() + .eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode()))); + content.put("episodeWithMedia", dramaEpisodeMapper.selectCount( + Wrappers.lambdaQuery() + .isNotNull(DramaEpisode::getVideoUrl) + .ne(DramaEpisode::getVideoUrl, ""))); + content.put("commentPending", commentMapper.selectCount(Wrappers.lambdaQuery() + .eq(ContentComment::getStatus, "pending"))); + content.put("reportPending", reportMapper.selectCount(Wrappers.lambdaQuery() + .eq(ContentReport::getStatus, "pending"))); + return content; + } + + private Map playbackMetrics(LocalDateTime sevenDaysAgo) { + Map playback = new LinkedHashMap<>(); + long viewTotal = 0L; + long playTotal = 0L; + for (Drama drama : dramaMapper.selectList( + Wrappers.lambdaQuery().select(Drama::getViewCount, Drama::getPlayCount))) { + viewTotal += drama.getViewCount() == null ? 0L : drama.getViewCount(); + playTotal += drama.getPlayCount() == null ? 0L : drama.getPlayCount(); + } + playback.put("viewTotal", viewTotal); + playback.put("playTotal", playTotal); + long watched = orZero(watchProgressMapper.selectCount(Wrappers.lambdaQuery())); + long finished = orZero(watchProgressMapper.selectCount(Wrappers.lambdaQuery() + .eq(WatchProgress::getFinished, 1))); + 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", orZero(watchHistoryMapper.selectCount( + Wrappers.lambdaQuery().ge(WatchHistory::getWatchedAt, sevenDaysAgo)))); + playback.put("playEventsTotal", orZero(watchHistoryMapper.selectCount( + Wrappers.lambdaQuery()))); + return playback; + } + + private Map revenueMetrics() { + Map revenue = new LinkedHashMap<>(); + revenue.put("orderTotal", ordersMapper.selectCount(null)); + revenue.put("orderDelivered", ordersMapper.selectCount(Wrappers.lambdaQuery() + .eq(Orders::getStatus, "DELIVERED"))); + revenue.put("orderRefunded", ordersMapper.selectCount(Wrappers.lambdaQuery() + .eq(Orders::getStatus, "REFUNDED"))); + BigDecimal paidAmount = BigDecimal.ZERO; + for (Orders order : ordersMapper.selectList(Wrappers.lambdaQuery() + .select(Orders::getStatus, Orders::getAmount) + .in(Orders::getStatus, List.of("PAID", "DELIVERED")))) { + if (order.getAmount() != null) { + paidAmount = paidAmount.add(order.getAmount()); + } + } + revenue.put("paidAmount", paidAmount); + revenue.put("coinRecharged", sumLedger("RECHARGE")); + revenue.put("coinConsumed", Math.abs(sumLedger("UNLOCK"))); + long balance = 0L; + for (Wallet wallet : walletMapper.selectList( + Wrappers.lambdaQuery().select(Wallet::getCoinBalance))) { + balance += wallet.getCoinBalance() == null ? 0L : wallet.getCoinBalance(); + } + revenue.put("coinBalance", balance); + revenue.put("entitlementActive", entitlementMapper.selectCount( + Wrappers.lambdaQuery().eq(Entitlement::getStatus, "ACTIVE"))); + return revenue; + } + + private Map costMetrics(LocalDateTime sevenDaysAgo) { + Map cost = new LinkedHashMap<>(); + Map queue = transcodeService.stats(); + cost.put("transcodeQueued", queue.get("queued")); + cost.put("transcodeRunning", queue.get("running")); + cost.put("transcodeFailed", queue.get("failed")); + cost.put("transcodeSuccess", queue.get("success")); + cost.put("renditions", queue.get("renditions")); + cost.put("otpSent7d", appUserOtpMapper.selectCount(Wrappers.lambdaQuery() + .ge(AppUserOtp::getCreatedAt, sevenDaysAgo))); + cost.put("cdnTrafficBytes", null); + return cost; + } + + /** 明确写出哪些指标当前没有数据源,避免前端把 null 当成 0 展示。 */ + private List notes() { + List notes = new ArrayList<>(); + notes.add("DAU 暂无数据源:需要按天留存登录快照(ops_daily_stat 已建表但当前无产出)," + + "现以 active7d(近 7 天登录过)近似"); + notes.add("CDN 流量与带宽成本暂无数据源:需接入 CDN 账单或访问日志,cdnTrafficBytes 恒为 null"); + notes.add("收入金额在商店未接入前恒为 0:订单表无真实商店订单,金币解锁收入请参考 coinConsumed"); + notes.add("viewTotal/playTotal 是短剧表上的累计计数,与 playEvents7d 的流水口径不可相加"); + return notes; + } + + private Long countUser(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper) { + return appUserMapper.selectCount(wrapper); + } + + private Long countDrama(String status) { + return StringUtils.hasText(status) + ? dramaMapper.selectCount(Wrappers.lambdaQuery().eq(Drama::getStatus, status)) + : dramaMapper.selectCount(null); + } + + private long sumLedger(String bizType) { + long sum = 0L; + for (CoinLedger ledger : coinLedgerMapper.selectList(Wrappers.lambdaQuery() + .select(CoinLedger::getChangeAmount) + .eq(CoinLedger::getBizType, bizType))) { + sum += ledger.getChangeAmount() == null ? 0L : ledger.getChangeAmount(); + } + return sum; + } + + private long orZero(Long value) { + return value == null ? 0L : value; + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java index a96553d..59f2484 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java @@ -12,7 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 转码任务编排。 @@ -112,6 +114,37 @@ public class TranscodeService { .orderByAsc(MediaRendition::getVideoBitrate)); } + /** + * 转码看板数据。 + * + * 口径:按 transcode_task.status 全量计数(不含逻辑删除),不限定时间范围 —— + * 队列类指标看的是"当前积压",用累计值才有意义。 + * 产物数取自 media_rendition,反映已经生成多少路码率文件。 + */ + public Map stats() { + Map data = new LinkedHashMap<>(); + data.put("queued", countByStatus(STATUS_QUEUED)); + data.put("running", countByStatus(STATUS_RUNNING)); + data.put("success", countByStatus(STATUS_SUCCESS)); + data.put("failed", countByStatus(STATUS_FAILED)); + data.put("canceled", countByStatus("CANCELED")); + data.put("renditions", mediaRenditionMapper.selectCount(null)); + return data; + } + + /** 失败任务明细:带 error_msg 与 retry_count,便于运营判断是重试还是排查片源。 */ + public List failedTasks(int limit) { + return transcodeTaskMapper.selectList(Wrappers.lambdaQuery() + .eq(TranscodeTask::getStatus, STATUS_FAILED) + .orderByDesc(TranscodeTask::getId) + .last("limit " + (limit <= 0 || limit > 100 ? 20 : limit))); + } + + private Long countByStatus(String status) { + return transcodeTaskMapper.selectCount(Wrappers.lambdaQuery() + .eq(TranscodeTask::getStatus, status)); + } + private TranscodeTask getExisting(Long id) { TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id); if (task == null) { diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 19f64e9..0f2a4e8 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -63,6 +63,11 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES ('price:update', '配置分集价格', '内容管理', 'BUTTON', 135) ON CONFLICT (code) DO NOTHING; +-- 看板权限:菜单在 3-4 补页面时一起加,避免菜单指向不存在的组件 +INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES + ('dashboard:view', '查看运营大盘', '数据看板', 'BUTTON', 141) +ON CONFLICT (code) DO NOTHING; + -- 超管默认拥有全部权限点 INSERT INTO sys_role_permission (role_id, permission_id) SELECT r.id, p.id @@ -75,7 +80,7 @@ WHERE r.code = 'super_admin' 'order:list', 'order:update', 'entitlement:list', 'entitlement:update', 'wallet:update', 'reconcile:run', 'creator:list', 'creator:audit', 'submission:list', 'submission:audit', - 'price:update') + 'price:update', 'dashboard:view') ON CONFLICT DO NOTHING; -- ---------- 菜单:创作者管理 ----------