feat(admin): 增加工作台指标和转码通知

This commit is contained in:
Zhou Weidong committed 2026-10-24 16:53:26 +08:00
1 parent 2367a5d12b
commit 1d34e47673
6 files changed
+320 -1

No files matched your search

@@ -0,0 +1,33 @@
// Author: fruit.com.cn
// 后台转码通知接口。
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeNoticeService;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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;
/** 后台转码通知。 */
@Tag(name = "后台-转码通知")
@RestController
@RequestMapping("/admin/notifications")
@RequiredArgsConstructor
public class TranscodeNoticeController {
private final TranscodeNoticeService transcodeNoticeService;
/** 转码中、成功、失败的角标和最近任务,含短剧与集数。 */
@Operation(summary = "转码通知(角标与最近任务)")
@GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<TranscodeNoticeRespVO> transcode() {
return Result.ok(transcodeNoticeService.notices());
}
}
@@ -58,7 +58,11 @@ import org.springframework.jdbc.core.JdbcTemplate;
* <ul>
* <li>用户:`total` = app_user 未删除总数;`newToday`/`new7d` = 按 created_at 落在当天 / 近 7 天;
* `active7d` = last_login_at 在近 7 天内(**不是 DAU**:真正的日活需要按天留存登录快照,
* 当前没有逐日快照数据源,见 notes);`banned`/`limited` = 按 status 计数。</li>
* 当前没有逐日快照数据源,见 notes);`androidActive7d`/`iosActive7d` =
* `app_user_device` 中状态为 active、近 7 天有心跳的去重设备数;
* `retention1d` = 昨日注册用户中今天仍登录的比例,分母为 0 时返回 null;
* `retention7d` = 8 至 14 天前注册的用户中,近 7 天仍登录的比例,分母为 0 时返回 null;
* `banned`/`limited` = 按 status 计数。</li>
* <li>内容:短剧按 status 计数(`reviewing` 视为待审、`published` 视为已上线);
* 剧集总数与已上线数;`episodeWithMedia` = video_url 非空的剧集数。</li>
* <li>播放:`viewTotal`/`playTotal` = 短剧表上累计计数列的求和(累计口径,不限定时间范围);
@@ -76,6 +80,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
public class DashboardService {
private static final int ACTIVE_WINDOW_DAYS = 7;
private static final int RETENTION_WINDOW_DAYS = 7;
private static final String PLATFORM_ANDROID = "ANDROID";
private static final String PLATFORM_IOS = "IOS";
private final AppUserMapper appUserMapper;
private final AppUserOtpMapper appUserOtpMapper;
@@ -187,9 +194,47 @@ public class DashboardService {
user.put("limited", countUser(Wrappers.<AppUser>lambdaQuery().eq(AppUser::getStatus, "limited")));
user.put("deactivated", countUser(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getStatus, "deactivated")));
putActiveDevices(user, sevenDaysAgo);
user.put("retention1d", retentionRate(todayStart.minusDays(1), todayStart, todayStart));
user.put("retention7d", retentionRate(
todayStart.minusDays(RETENTION_WINDOW_DAYS * 2L),
todayStart.minusDays(RETENTION_WINDOW_DAYS),
sevenDaysAgo));
return user;
}
/** 近 7 天仍活跃的安卓、iOS 设备,按设备号去重。 */
private void putActiveDevices(Map<String, Object> user, LocalDateTime since) {
Map<String, Long> counts = new LinkedHashMap<>();
jdbcTemplate.query("""
SELECT UPPER(platform), COUNT(DISTINCT device_id)
FROM app_user_device
WHERE deleted = 0 AND status = 'active' AND last_seen_at >= ?
GROUP BY UPPER(platform)
""", rs -> {
counts.put(rs.getString(1), rs.getLong(2));
}, since);
user.put("androidActive7d", counts.getOrDefault(PLATFORM_ANDROID, 0L));
user.put("iosActive7d", counts.getOrDefault(PLATFORM_IOS, 0L));
}
/** 队列留存率。注册队列为空时返回 null,避免把没有样本显示成 0%。 */
private Double retentionRate(LocalDateTime cohortStart, LocalDateTime cohortEnd, LocalDateTime returnedSince) {
Long cohort = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM app_user
WHERE deleted = 0 AND created_at >= ? AND created_at < ?
""", Long.class, cohortStart, cohortEnd);
if (cohort == null || cohort == 0L) {
return null;
}
Long returned = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM app_user
WHERE deleted = 0 AND created_at >= ? AND created_at < ? AND last_login_at >= ?
""", Long.class, cohortStart, cohortEnd, returnedSince);
long back = returned == null ? 0L : returned;
return Math.round(back * 1000.0 / cohort) / 1000.0;
}
private Map<String, Object> contentMetrics() {
Map<String, Object> content = new LinkedHashMap<>();
content.put("dramaTotal", dramaMapper.selectCount(null));
@@ -284,6 +329,8 @@ public class DashboardService {
List<String> notes = new ArrayList<>();
notes.add("DAU 暂无数据源:需要按天留存登录快照(ops_daily_stat 已建表但当前无产出),"
+ "现以 active7d(近 7 天登录过)近似");
notes.add("androidActive7d / iosActive7d 是近 7 天 last_seen_at 仍活跃的去重设备,不是安装量");
notes.add("retention1d 是昨日注册且今天仍登录的比例;retention7d 是 8 至 14 天前注册、近 7 天仍登录的比例;队列为空时为 null");
notes.add("CDN 流量与带宽成本暂无数据源:需接入 CDN 账单或访问日志,cdnTrafficBytes 恒为 null");
notes.add("收入金额在商店未接入前恒为 0:订单表无真实商店订单,金币解锁收入请参考 coinConsumed");
notes.add("viewTotal/playTotal 是短剧表上的累计计数,与 playEvents7d 的流水口径不可相加");
@@ -0,0 +1,84 @@
// Author: fruit.com.cn
// 转码通知:按状态计数,并带出短剧与集数。
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 转码通知。
* 转码中、成功、失败分别计数,列表带出短剧名和集数。
*/
@Service
public class TranscodeNoticeService {
private static final int NOTICE_LIMIT = 20;
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String UNKNOWN_DRAMA = "未关联短剧";
private final JdbcTemplate jdbcTemplate;
public TranscodeNoticeService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** 角标数量和最近 20 条转码中、成功、失败任务。 */
public TranscodeNoticeRespVO notices() {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
fillCounts(data);
data.setRunningList(latest(STATUS_RUNNING));
data.setSuccessList(latest(STATUS_SUCCESS));
data.setFailedList(latest(STATUS_FAILED));
return data;
}
private void fillCounts(TranscodeNoticeRespVO data) {
jdbcTemplate.query("""
SELECT status, COUNT(*)
FROM transcode_task
WHERE deleted = 0 AND status IN (?, ?, ?)
GROUP BY status
""", rs -> {
long count = rs.getLong(2);
switch (rs.getString(1)) {
case STATUS_RUNNING -> data.setRunning(count);
case STATUS_SUCCESS -> data.setSuccess(count);
case STATUS_FAILED -> data.setFailed(count);
default -> {
}
}
}, STATUS_RUNNING, STATUS_SUCCESS, STATUS_FAILED);
}
private List<TranscodeNoticeRespVO.Item> latest(String status) {
return jdbcTemplate.query("""
SELECT t.id, t.status, t.progress, t.error_msg, t.updated_at,
COALESCE(NULLIF(d.title, ''), NULLIF(d2.title, ''), ?) AS drama_title,
e.ep_no, e.title
FROM transcode_task t
LEFT JOIN drama_episode e ON e.id = t.episode_id AND e.deleted = 0
LEFT JOIN drama d ON d.id = t.drama_id AND d.deleted = 0
LEFT JOIN drama d2 ON d2.id = e.drama_id AND d2.deleted = 0
WHERE t.deleted = 0 AND t.status = ?
ORDER BY t.updated_at DESC
LIMIT ?
""", (rs, row) -> {
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setId(rs.getLong("id"));
item.setStatus(rs.getString("status"));
item.setProgress(rs.getInt("progress"));
item.setErrorMsg(rs.getString("error_msg"));
item.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
item.setDramaTitle(rs.getString("drama_title"));
item.setEpisodeNo((Integer) rs.getObject("ep_no"));
item.setEpisodeTitle(rs.getString("title"));
return item;
}, UNKNOWN_DRAMA, status, NOTICE_LIMIT);
}
}
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
// 转码通知响应:角标数量和最近任务。
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** 转码通知:角标数量和最近任务。 */
@Data
public class TranscodeNoticeRespVO {
private long running;
private long success;
private long failed;
private List<Item> runningList = new ArrayList<>();
private List<Item> successList = new ArrayList<>();
private List<Item> failedList = new ArrayList<>();
/** 一条转码通知,带短剧和集数。 */
@Data
public static class Item {
private Long id;
private String status;
private String dramaTitle;
private Integer episodeNo;
private String episodeTitle;
private Integer progress;
private String errorMsg;
private LocalDateTime updatedAt;
}
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeNoticeService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class TranscodeNoticeContractTest {
private TranscodeNoticeService transcodeNoticeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
transcodeNoticeService = mock(TranscodeNoticeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new TranscodeNoticeController(transcodeNoticeService));
}
@Test
void should_return_drama_and_episode_when_transcode_notice_is_requested() throws Exception {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
data.setRunning(1L);
data.setSuccess(2L);
data.setFailed(3L);
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setDramaTitle("重生");
item.setEpisodeNo(4);
item.setEpisodeTitle("第4集");
data.setFailedList(List.of(item));
given(transcodeNoticeService.notices()).willReturn(data);
mockMvc.perform(get("/admin/notifications/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.running").value(1))
.andExpect(jsonPath("$.data.success").value(2))
.andExpect(jsonPath("$.data.failed").value(3))
.andExpect(jsonPath("$.data.failedList[0].dramaTitle").value("重生"))
.andExpect(jsonPath("$.data.failedList[0].episodeNo").value(4))
.andExpect(jsonPath("$.data.failedList[0].episodeTitle").value("第4集"));
}
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TranscodeNoticeServiceTest {
@Test
void should_attach_drama_and_episode_when_failed_tasks_are_loaded() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
doAnswer(invocation -> {
assertTrue(invocation.getArgument(0, String.class).contains("?"));
return null;
}).when(jdbc).query(anyString(), any(RowCallbackHandler.class), eq("RUNNING"), eq("SUCCESS"), eq("FAILED"));
when(jdbc.query(anyString(), any(RowMapper.class), any(), anyString(), any()))
.thenAnswer(invocation -> {
String sql = invocation.getArgument(0, String.class);
String status = invocation.getArgument(3, String.class);
if (!"FAILED".equals(status)) {
return java.util.List.of();
}
assertTrue(sql.contains("drama_title"));
assertTrue(sql.contains("?"));
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = failedRow();
return java.util.List.of(mapper.mapRow(rs, 0));
});
TranscodeNoticeRespVO data = new TranscodeNoticeService(jdbc).notices();
assertEquals(1, data.getFailedList().size());
assertEquals("重生", data.getFailedList().get(0).getDramaTitle());
assertEquals(4, data.getFailedList().get(0).getEpisodeNo());
assertEquals("第4集", data.getFailedList().get(0).getEpisodeTitle());
}
private ResultSet failedRow() throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(9L);
when(rs.getString("status")).thenReturn("FAILED");
when(rs.getInt("progress")).thenReturn(0);
when(rs.getString("error_msg")).thenReturn("boom");
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 10, 24, 12, 0)));
when(rs.getString("drama_title")).thenReturn("重生");
when(rs.getObject("ep_no")).thenReturn(4);
when(rs.getString("title")).thenReturn("第4集");
return rs;
}
}