feat: 08 数据看板落地(总览 + 转码看板,只读)
为什么: 1) 前面几块把用户、内容、播放、交易、媒资的数据都落库了,运营需要一个地方一次看完, 否则每个指标都要去查库或翻后台列表。 2) 看板最容易出的问题是"数字看起来对但口径不清",所以每个指标都在代码注释和 docs/api-phase1.md 里写死口径(时间范围、统计维度),前端 tooltip 直接复用。 3) 取不到数据源的指标(DAU、CDN 流量成本、商店订单金额)一律返回 null 并在 notes 里 说明缺什么、怎么补 —— 不用 0 冒充,避免运营拿假数字做判断。 4) 站在接入层做只读聚合而不是新建模块:它跨六个域取数,建模块只会把依赖再摊开一层, 而它只读不写,放在 admin-api 改动面最小。
This commit is contained in:
1 parent
1fb8427f28
commit
d67d9daf8c
5 files changed
+359
-3
No files matched your search
+39
@@ -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<Map<String, Object>> overview() {
|
||||
return Result.ok(dashboardService.overview());
|
||||
}
|
||||
|
||||
/** 转码看板:队列积压与失败任务明细。 */
|
||||
@GetMapping("/transcode")
|
||||
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||
public Result<Map<String, Object>> transcode() {
|
||||
return Result.ok(dashboardService.transcode());
|
||||
}
|
||||
}
|
||||
+252
@@ -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;
|
||||
|
||||
/**
|
||||
* 运营大盘(只读聚合)。
|
||||
*
|
||||
* <p><b>为什么放在 admin-api 而不是建一个新模块</b>:它要跨用户/内容/播放/互动/交易/媒资六个域取数,
|
||||
* 建模块只会把依赖再摊开一层;而它**只读、不写**,放在接入层做聚合,改动面最小。
|
||||
*
|
||||
* <p><b>指标口径(每个指标都在注释里写清楚,前端 tooltip 直接复用)</b>:
|
||||
* <ul>
|
||||
* <li>用户:`total` = app_user 未删除总数;`newToday`/`new7d` = 按 created_at 落在当天 / 近 7 天;
|
||||
* `active7d` = last_login_at 在近 7 天内(**不是 DAU**:真正的日活需要按天留存登录快照,
|
||||
* 当前没有逐日快照数据源,见 notes);`banned`/`limited` = 按 status 计数。</li>
|
||||
* <li>内容:短剧按 status 计数(`reviewing` 视为待审、`published` 视为已上线);
|
||||
* 剧集总数与已上线数;`episodeWithMedia` = video_url 非空的剧集数。</li>
|
||||
* <li>播放:`viewTotal`/`playTotal` = 短剧表上累计计数列的求和(累计口径,不限定时间范围);
|
||||
* `watchedDramas` = watch_progress 行数(去重后的"看过的剧");`finishedDramas` = 其中 finished=1 的;
|
||||
* `playEvents7d` = watch_history 近 7 天事件数(**流水口径**,与上面的累计口径不同,不要相加)。</li>
|
||||
* <li>收入:订单按状态计数;`paidAmount` = 已支付/已交付订单金额合计(商店未接入前恒为 0);
|
||||
* `coinRecharged`/`coinConsumed` 取自 coin_ledger(充值按 RECHARGE、消耗按 UNLOCK 的绝对值);
|
||||
* `coinBalance` = wallet 余额合计;`entitlementActive` = 有效权益数。</li>
|
||||
* <li>成本:转码队列与产物数取自 transcode_task / media_rendition;
|
||||
* **CDN 流量与带宽成本暂无数据源**(需要接 CDN 账单或访问日志),短信条数用 app_user_otp 近似。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> overview() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
|
||||
LocalDateTime sevenDaysAgo = now.minusDays(ACTIVE_WINDOW_DAYS);
|
||||
|
||||
Map<String, Object> 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<String, Object> transcode() {
|
||||
Map<String, Object> 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<String, Object> userMetrics(LocalDateTime todayStart, LocalDateTime sevenDaysAgo) {
|
||||
Map<String, Object> user = new LinkedHashMap<>();
|
||||
user.put("total", countUser(Wrappers.<AppUser>lambdaQuery()));
|
||||
user.put("newToday", countUser(Wrappers.<AppUser>lambdaQuery()
|
||||
.ge(AppUser::getCreatedAt, todayStart)));
|
||||
user.put("new7d", countUser(Wrappers.<AppUser>lambdaQuery()
|
||||
.ge(AppUser::getCreatedAt, sevenDaysAgo)));
|
||||
user.put("active7d", countUser(Wrappers.<AppUser>lambdaQuery()
|
||||
.ge(AppUser::getLastLoginAt, sevenDaysAgo)));
|
||||
user.put("banned", countUser(Wrappers.<AppUser>lambdaQuery().eq(AppUser::getStatus, "banned")));
|
||||
user.put("limited", countUser(Wrappers.<AppUser>lambdaQuery().eq(AppUser::getStatus, "limited")));
|
||||
user.put("deactivated", countUser(Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(AppUser::getStatus, "deactivated")));
|
||||
return user;
|
||||
}
|
||||
|
||||
private Map<String, Object> contentMetrics() {
|
||||
Map<String, Object> 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.<DramaEpisode>lambdaQuery()
|
||||
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())));
|
||||
content.put("episodeWithMedia", dramaEpisodeMapper.selectCount(
|
||||
Wrappers.<DramaEpisode>lambdaQuery()
|
||||
.isNotNull(DramaEpisode::getVideoUrl)
|
||||
.ne(DramaEpisode::getVideoUrl, "")));
|
||||
content.put("commentPending", commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
|
||||
.eq(ContentComment::getStatus, "pending")));
|
||||
content.put("reportPending", reportMapper.selectCount(Wrappers.<ContentReport>lambdaQuery()
|
||||
.eq(ContentReport::getStatus, "pending")));
|
||||
return content;
|
||||
}
|
||||
|
||||
private Map<String, Object> playbackMetrics(LocalDateTime sevenDaysAgo) {
|
||||
Map<String, Object> playback = new LinkedHashMap<>();
|
||||
long viewTotal = 0L;
|
||||
long playTotal = 0L;
|
||||
for (Drama drama : dramaMapper.selectList(
|
||||
Wrappers.<Drama>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.<WatchProgress>lambdaQuery()));
|
||||
long finished = orZero(watchProgressMapper.selectCount(Wrappers.<WatchProgress>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.<WatchHistory>lambdaQuery().ge(WatchHistory::getWatchedAt, sevenDaysAgo))));
|
||||
playback.put("playEventsTotal", orZero(watchHistoryMapper.selectCount(
|
||||
Wrappers.<WatchHistory>lambdaQuery())));
|
||||
return playback;
|
||||
}
|
||||
|
||||
private Map<String, Object> revenueMetrics() {
|
||||
Map<String, Object> revenue = new LinkedHashMap<>();
|
||||
revenue.put("orderTotal", ordersMapper.selectCount(null));
|
||||
revenue.put("orderDelivered", ordersMapper.selectCount(Wrappers.<Orders>lambdaQuery()
|
||||
.eq(Orders::getStatus, "DELIVERED")));
|
||||
revenue.put("orderRefunded", ordersMapper.selectCount(Wrappers.<Orders>lambdaQuery()
|
||||
.eq(Orders::getStatus, "REFUNDED")));
|
||||
BigDecimal paidAmount = BigDecimal.ZERO;
|
||||
for (Orders order : ordersMapper.selectList(Wrappers.<Orders>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.<Wallet>lambdaQuery().select(Wallet::getCoinBalance))) {
|
||||
balance += wallet.getCoinBalance() == null ? 0L : wallet.getCoinBalance();
|
||||
}
|
||||
revenue.put("coinBalance", balance);
|
||||
revenue.put("entitlementActive", entitlementMapper.selectCount(
|
||||
Wrappers.<Entitlement>lambdaQuery().eq(Entitlement::getStatus, "ACTIVE")));
|
||||
return revenue;
|
||||
}
|
||||
|
||||
private Map<String, Object> costMetrics(LocalDateTime sevenDaysAgo) {
|
||||
Map<String, Object> cost = new LinkedHashMap<>();
|
||||
Map<String, Object> 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.<AppUserOtp>lambdaQuery()
|
||||
.ge(AppUserOtp::getCreatedAt, sevenDaysAgo)));
|
||||
cost.put("cdnTrafficBytes", null);
|
||||
return cost;
|
||||
}
|
||||
|
||||
/** 明确写出哪些指标当前没有数据源,避免前端把 null 当成 0 展示。 */
|
||||
private List<String> notes() {
|
||||
List<String> 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<AppUser> wrapper) {
|
||||
return appUserMapper.selectCount(wrapper);
|
||||
}
|
||||
|
||||
private Long countDrama(String status) {
|
||||
return StringUtils.hasText(status)
|
||||
? dramaMapper.selectCount(Wrappers.<Drama>lambdaQuery().eq(Drama::getStatus, status))
|
||||
: dramaMapper.selectCount(null);
|
||||
}
|
||||
|
||||
private long sumLedger(String bizType) {
|
||||
long sum = 0L;
|
||||
for (CoinLedger ledger : coinLedgerMapper.selectList(Wrappers.<CoinLedger>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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user