32 changed files with 1385 additions and 6 deletions

No files matched your search

+11
View File
@@ -73,6 +73,9 @@ services:
SERVER_PORT: "8081"
# 默认连编排里的 db;要连外部库就在 .env 里覆盖 DB_URL
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
ports:
- "8081:8081"
volumes:
@@ -81,6 +84,8 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/healthz || exit 1"]
interval: 30s
@@ -106,6 +111,8 @@ services:
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
FRUITSHORTS_MEDIA_PROTECTED_PREFIXES: ""
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
ports:
# docs/12-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到
- "8080:8080"
@@ -134,12 +141,16 @@ services:
env_file: .env
environment:
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
volumes:
# 与两个入口同一个媒资根,否则找不到源文件
- ./data/uploads:/data/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
# worker 不监听端口(server.port=0),用进程存活判断
test: ["CMD-SHELL", "pgrep -f app.jar >/dev/null || exit 1"]
@@ -0,0 +1,98 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.AppVersionService;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台 - App 版本管理。 */
@Tag(name = "后台-App 版本")
@RestController
@RequestMapping("/admin/app-versions")
@RequiredArgsConstructor
public class AppVersionAdminController {
private final AppVersionService appVersionService;
@Operation(summary = "版本分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
return Result.ok(appVersionService.page(req));
}
@Operation(summary = "版本详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
return Result.ok(appVersionService.detail(id));
}
@Operation(summary = "新建版本(默认发布)")
@PostMapping
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "新建版本")
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
return Result.ok(appVersionService.create(req));
}
@Operation(summary = "批量新建版本(一次事务)")
@PostMapping("/batch")
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "批量新建版本")
public Result<java.util.List<Long>> batchCreate(
@NotEmpty(message = "请至少传一条")
@Size(max = 16, message = "单次不超过 16 条")
@Valid @RequestBody java.util.List<@Valid AppVersionSaveReqVO> reqs) {
return Result.ok(appVersionService.batchCreate(reqs));
}
@Operation(summary = "编辑版本")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "编辑版本")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
appVersionService.update(id, req);
return Result.ok();
}
@Operation(summary = "发布 / 下架")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "版本上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
appVersionService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除版本")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:delete')")
@OperationLog(module = "版本管理", action = "删除版本")
public Result<Void> delete(@PathVariable Long id) {
appVersionService.delete(id);
return Result.ok();
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,4 +46,20 @@ public class DashboardAdminController {
public Result<TranscodeDashboardRespVO> transcode() {
return Result.ok(dashboardService.transcode());
}
/** 近 7 天每日趋势(用户注册 / 播放事件 / 转码任务)。 */
@Operation(summary = "大盘趋势(近 7 天每日数据,给折线图用)")
@GetMapping("/trend")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardTrendRespVO> trend() {
return Result.ok(dashboardService.trend());
}
/** 分布:短剧状态 / 转码状态 / 片源覆盖 / 待处理审核。 */
@Operation(summary = "大盘分布(给饼图用)")
@GetMapping("/distribution")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardDistributionRespVO> distribution() {
return Result.ok(dashboardService.distribution());
}
}
@@ -15,6 +15,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -41,6 +43,7 @@ public class MediaController {
private final MediaService mediaService;
private final MediaTranscodeProfileService profileService;
private final TranscodeBackfillAdminService backfillAdminService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init")
@@ -110,4 +113,14 @@ public class MediaController {
ImpersonationGuard.rejectWrite();
return Result.ok(profileService.save(profile));
}
@Operation(summary = "手动补齐转码:扫描缺产物/帧图的分集并入队")
@PostMapping("/transcode/backfill")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "后台管理", action = "补齐转码")
public Result<TranscodeBackfillRespVO> backfill(
@RequestParam(value = "batchSize", required = false) Integer batchSize) {
ImpersonationGuard.rejectWrite();
return Result.ok(backfillAdminService.runOnce(batchSize));
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.dal.entity.Entitlement;
@@ -44,6 +46,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 运营大盘(只读聚合)。
*
@@ -86,6 +90,7 @@ public class DashboardService {
private final CoinLedgerMapper coinLedgerMapper;
private final EntitlementMapper entitlementMapper;
private final TranscodeService transcodeService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> overview() {
LocalDateTime now = LocalDateTime.now();
@@ -117,6 +122,58 @@ public class DashboardService {
return data;
}
/** 近 7 天每日趋势,给折线图用。 */
public DashboardTrendRespVO trend() {
int days = 7;
LocalDate today = LocalDate.now();
List<String> dates = new ArrayList<>(days);
List<Long> newUsers = new ArrayList<>(days);
List<Long> playEvents = new ArrayList<>(days);
List<Long> newTranscodes = new ArrayList<>(days);
List<Long> transcodeSuccess = new ArrayList<>(days);
for (int i = days - 1; i >= 0; i--) {
LocalDate d = today.minusDays(i);
dates.add(d.toString());
newUsers.add(countForDate("SELECT COUNT(*) FROM app_user WHERE deleted = 0 AND DATE(created_at) = ?", d));
playEvents.add(countForDate("SELECT COUNT(*) FROM watch_history WHERE DATE(watched_at) = ?", d));
newTranscodes.add(countForDate("SELECT COUNT(*) FROM transcode_task WHERE DATE(created_at) = ?", d));
transcodeSuccess.add(countForDate("SELECT COUNT(*) FROM transcode_task WHERE status = 'SUCCESS' AND DATE(created_at) = ?", d));
}
return new DashboardTrendRespVO(dates, newUsers, playEvents, newTranscodes, transcodeSuccess);
}
/** 各域状态分布,给饼图用。 */
public DashboardDistributionRespVO distribution() {
DashboardDistributionRespVO vo = new DashboardDistributionRespVO();
vo.setDramaStatus(groupBy("SELECT status, COUNT(*) FROM drama WHERE deleted = 0 GROUP BY status"));
vo.setTranscodeStatus(groupBy("SELECT status, COUNT(*) FROM transcode_task GROUP BY status"));
vo.setEpisodeMedia(groupBy("""
SELECT CASE WHEN video_url IS NOT NULL AND video_url <> '' THEN '有片源' ELSE '无片源' END AS bucket,
COUNT(*) FROM drama_episode WHERE deleted = 0 GROUP BY bucket"""));
List<DashboardDistributionRespVO.Item> pending = new ArrayList<>();
pending.add(new DashboardDistributionRespVO.Item("评论待审", orZero(commentMapper.selectCount(
Wrappers.<ContentComment>lambdaQuery().eq(ContentComment::getStatus, "pending")))));
pending.add(new DashboardDistributionRespVO.Item("举报待处理", orZero(reportMapper.selectCount(
Wrappers.<ContentReport>lambdaQuery().eq(ContentReport::getStatus, "pending")))));
vo.setPendingModeration(pending);
return vo;
}
/** 按日期查计数,空结果返回 0。 */
private long countForDate(String sql, LocalDate date) {
Long count = jdbcTemplate.queryForObject(sql, Long.class, date);
return count == null ? 0L : count;
}
/** 两列 GROUP BY 查询返回 Map<key, count>。 */
private Map<String, Long> groupBy(String sql) {
Map<String, Long> result = new LinkedHashMap<>();
jdbcTemplate.query(sql, rs -> {
result.put(rs.getString(1), rs.getLong(2));
});
return result;
}
private Map<String, Object> userMetrics(LocalDateTime todayStart, LocalDateTime sevenDaysAgo) {
Map<String, Object> user = new LinkedHashMap<>();
user.put("total", countUser(Wrappers.<AppUser>lambdaQuery()));
@@ -0,0 +1,112 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 手动触发一轮转码补齐巡检:找出"有片源但缺少码率产物或缩略图"的分集并入队。
*
* <p>与 worker 的定时巡检(TranscodeBackfillScanner)共用同一条候选 SQL 与 trigger 语义;
* 区别在于手动触发允许重试最近一次 FAILED 的任务——运营点按钮就是要补齐,包括失败重跑。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TranscodeBackfillAdminService {
private static final int MIN_BATCH_SIZE = 1;
private static final int MAX_BATCH_SIZE = 100;
private static final int DEFAULT_BATCH_SIZE = 20;
private final DramaEpisodeMapper episodeMapper;
private final TranscodeService transcodeService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaTranscodeOutputService outputService;
private final MediaTranscodeProfileService profileService;
/**
* 扫描一批缺产物的分集并入队。
*
* @param requestedBatchSize 单轮上限,超出范围时收敛到默认值
* @return 入队结果与明细
*/
public TranscodeBackfillRespVO runOnce(Integer requestedBatchSize) {
int batchSize = normalizeBatchSize(requestedBatchSize);
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
0L, batchSize, framesRequired());
List<TranscodeBackfillRespVO.Item> items = new ArrayList<>();
if (candidates == null || candidates.isEmpty()) {
return new TranscodeBackfillRespVO(0, items);
}
for (DramaEpisode episode : candidates) {
triggerIfNeeded(episode, items);
}
log.info("后台手动补齐转码:候选 {} 个,实际入队 {} 个",
candidates.size(), items.size());
return new TranscodeBackfillRespVO(items.size(), items);
}
private void triggerIfNeeded(DramaEpisode episode,
List<TranscodeBackfillRespVO.Item> items) {
Long mediaAssetId = resolveMediaAssetId(episode);
if (mediaAssetId == null) {
log.warn("分集 {} 找不到对应媒资,跳过补齐转码", episode.getId());
return;
}
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && (TranscodeService.STATUS_QUEUED.equals(latest.getStatus())
|| TranscodeService.STATUS_RUNNING.equals(latest.getStatus()))) {
return;
}
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
log.warn("分集 {} 绑定的媒资 {} 不存在,跳过补齐转码",
episode.getId(), mediaAssetId);
return;
}
TranscodeTriggerRespVO result = transcodeService.trigger(
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
items.add(new TranscodeBackfillRespVO.Item(
String.valueOf(episode.getId()),
result == null ? "" : String.valueOf(result.getTaskId())));
}
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
private Long resolveMediaAssetId(DramaEpisode episode) {
Long videoAssetId = episode.getVideoAssetId();
if (videoAssetId != null && videoAssetId > 0) {
return videoAssetId;
}
return outputService.resolveMediaAssetId(episode.getVideoUrl());
}
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免误判重复转码。 */
private boolean framesRequired() {
MediaTranscodeProfile profile = profileService.currentProfile();
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
}
private int normalizeBatchSize(Integer requestedBatchSize) {
if (requestedBatchSize == null || requestedBatchSize <= 0) {
return DEFAULT_BATCH_SIZE;
}
return Math.min(requestedBatchSize, MAX_BATCH_SIZE);
}
}
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/** 运营大盘分布(给饼图用)。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DashboardDistributionRespVO {
/** 短剧状态分布:key = 状态码,value = 数量;含 total 汇总。 */
private Map<String, Long> dramaStatus;
/** 转码任务状态分布。 */
private Map<String, Long> transcodeStatus;
/** 有片源 / 无片源剧集分布。 */
private Map<String, Long> episodeMedia;
/** 互动待处理:评论待审、举报待审。 */
private List<Item> pendingModeration;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
private String label;
private Long value;
}
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** 运营大盘趋势(近 N 天每日数据,给折线图用)。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DashboardTrendRespVO {
/** 日期标签,格式 yyyy-MM-dd,从最早到最新。 */
private List<String> dates;
/** 每日新增用户数(与 dates 对齐)。 */
private List<Long> newUsers;
/** 每日播放事件数(watch_history 行数,与 dates 对齐)。 */
private List<Long> playEvents;
/** 每日新增转码任务数(transcode_task 行数,与 dates 对齐)。 */
private List<Long> newTranscodes;
/** 每日转码成功数(与 dates 对齐)。 */
private List<Long> transcodeSuccess;
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** 一轮转码补齐巡检的结果。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TranscodeBackfillRespVO {
/** 本轮实际补入队的分集数。 */
private int triggered;
/** 补入队的分集与任务明细。 */
private List<Item> items;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
private String episodeId;
private String taskId;
}
}
@@ -45,7 +45,9 @@ class MediaTranscodeProfileContractTest {
objectMapper = new ObjectMapper();
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
configService, objectMapper, new TranscodeProperties());
MediaController controller = new MediaController(mock(MediaService.class), profileService);
MediaController controller = new MediaController(
mock(MediaService.class), profileService,
mock(com.fruitshorts.admin.service.TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("");
@@ -2,6 +2,7 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
@@ -32,7 +33,8 @@ class MediaTranscodeTriggerContractTest {
void setUp() {
mediaService = mock(MediaService.class);
MediaController controller = new MediaController(
mediaService, mock(MediaTranscodeProfileService.class));
mediaService, mock(MediaTranscodeProfileService.class),
mock(TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
}
@@ -6,12 +6,15 @@ import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -56,4 +59,19 @@ public class AppCatalogController {
public Result<List<AppCategoryVO>> categories() {
return Result.ok(appCatalogService.categories());
}
@Operation(summary = "分类频道:按分类 ID 分页查短剧卡片")
@GetMapping("/categories/{id}/dramas")
public Result<PageResult<AppDramaCardVO>> categoryDramas(
@PathVariable Long id,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "20") int pageSize) {
return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize));
}
@Operation(summary = "剧单详情:剧单基本信息 + 该剧单下已上线短剧卡片")
@GetMapping("/playlists/{id}")
public Result<AppPlaylistDetailVO> playlistDetail(@PathVariable Long id) {
return Result.ok(appCatalogService.playlistDetail(id));
}
}
@@ -8,14 +8,18 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppEpisodeVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.ContentBanner;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.enums.BannerPosition;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
@@ -49,6 +53,8 @@ public class AppCatalogService {
private final EpisodeService episodeService;
private final BannerService bannerService;
private final DramaCategoryService dramaCategoryService;
private final DramaClassificationService dramaClassificationService;
private final DramaPlaylistCategoryService dramaPlaylistCategoryService;
private final PlaybackSigner playbackSigner;
private final CommentService commentService;
private final ThumbnailUrlResolver thumbnailUrlResolver;
@@ -69,13 +75,42 @@ public class AppCatalogService {
categorySections(DISCOVER_SECTION_SIZE, categoryNameMap()));
}
/** 分类频道:运营自定义的精选/新剧/榜单等(drama_classification)。 */
public List<AppCategoryVO> categories() {
return dramaCategoryService.listBasic().stream()
.filter(category -> category.getStatus() == null || category.getStatus() == 1)
.map(category -> new AppCategoryVO(category.getId(), category.getName()))
return dramaClassificationService.listEnabled().stream()
.map(c -> new AppCategoryVO(c.getId(), c.getName()))
.toList();
}
/** 分类频道:按分类分页查已上线短剧卡片(通过分类关联表过滤)。 */
public PageResult<AppDramaCardVO> categoryDramas(Long classificationId,
int pageIndex, int pageSize) {
dramaClassificationService.getEnabledById(classificationId); // 校验存在/启用
List<Long> allIds = dramaClassificationService.listDramaIdsByClassification(classificationId);
if (allIds.isEmpty()) {
return PageResult.empty();
}
// 只取已上线的,再按关联表原始顺序排
Map<Long, Integer> orderMap = new HashMap<>();
for (int i = 0; i < allIds.size(); i++) orderMap.put(allIds.get(i), i);
List<Drama> published = dramaService.listPublishedByIds(allIds);
List<Drama> ordered = published.stream()
.sorted((a, b) -> Integer.compare(
orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE),
orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE)))
.toList();
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, 50));
long total = ordered.size();
int fromIndex = (safeIndex - 1) * safeSize;
if (fromIndex >= ordered.size()) {
return new PageResult<>(total, List.of());
}
int toIndex = Math.min(fromIndex + safeSize, ordered.size());
List<Drama> pageRows = ordered.subList(fromIndex, toIndex);
return new PageResult<>(total, cards(pageRows, categoryNameMap()));
}
public CursorResult<AppDramaCardVO> feed(
String types, Long categoryId, Long cursor, Integer size) {
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
@@ -137,6 +172,33 @@ public class AppCatalogService {
return detail;
}
/** C 端剧单详情:剧单基本信息 + 该剧单下所有已上线短剧卡片。 */
public AppPlaylistDetailVO playlistDetail(Long playlistId) {
var category = dramaPlaylistCategoryService.getEnabledById(playlistId);
List<Long> allIds = dramaPlaylistCategoryService.listDramaIdsByCategory(playlistId);
List<Drama> published = dramaService.listPublishedByIds(allIds);
// 保留关联表原始顺序:按 allIds 位置重新排序
Map<Long, Integer> orderMap = new HashMap<>();
for (int i = 0; i < allIds.size(); i++) {
orderMap.put(allIds.get(i), i);
}
List<Drama> ordered = published.stream()
.sorted((a, b) -> {
Integer ia = orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE);
Integer ib = orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE);
return Integer.compare(ia, ib);
})
.toList();
AppPlaylistDetailVO vo = new AppPlaylistDetailVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setDescription(category.getDescription());
vo.setDramaCount(ordered.size());
vo.setList(cards(ordered, categoryNameMap()));
return vo;
}
private List<AppBannerVO> banners(String position) {
return bannerService.listActive(position).stream().map(this::toBanner).toList();
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.util.List;
/** C 端剧单详情:基本信息 + 该剧单下已上线的短剧卡片。 */
@Data
public class AppPlaylistDetailVO {
private Long id;
private String name;
private String description;
/** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */
private Integer dramaCount;
private List<AppDramaCardVO> list;
}
@@ -8,6 +8,8 @@ import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
@@ -45,12 +47,15 @@ class AppCatalogServiceTest {
episodeService = mock(EpisodeService.class);
BannerService bannerService = mock(BannerService.class);
dramaCategoryService = mock(DramaCategoryService.class);
DramaClassificationService dramaClassificationService = mock(DramaClassificationService.class);
DramaPlaylistCategoryService dramaPlaylistCategoryService = mock(DramaPlaylistCategoryService.class);
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
commentService = mock(CommentService.class);
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver);
dramaCategoryService, dramaClassificationService, dramaPlaylistCategoryService,
playbackSigner, commentService, thumbnailUrlResolver);
}
@Test
@@ -38,6 +38,7 @@ public class SecurityConfig {
"/app/discover/**",
"/app/feed",
"/app/categories/**",
"/app/playlists/**",
"/app/banners/**",
"/app/dramas/**",
"/app/search",
@@ -4,6 +4,46 @@ package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
/**
* 查询需要补转码的分集:有片源但没有可用码率产物,或缺少缩略图轴,且当前没有排队/执行中的任务。
*
* @param afterId 上一批处理到的分集 ID,用于游标轮转,避免固定前 N 条长期占用名额
* @param limit 单批上限
* @param requireFrames 是否把"缺缩略图"也视为缺失;关闭雪碧图时传 false
*/
@Select("""
<script>
SELECT e.id, e.drama_id, e.video_asset_id, e.video_url
FROM drama_episode e
WHERE e.deleted = 0
AND e.id &gt; #{afterId}
AND (e.video_asset_id &gt; 0
OR (e.video_url IS NOT NULL AND e.video_url &lt;&gt; ''))
AND NOT EXISTS (
SELECT 1 FROM transcode_task t
WHERE t.episode_id = e.id AND t.status IN ('QUEUED', 'RUNNING'))
AND (
NOT EXISTS (
SELECT 1 FROM media_rendition r
WHERE r.episode_id = e.id AND r.deleted = 0 AND r.status = 'ready')
<if test="requireFrames">
OR NOT EXISTS (
SELECT 1 FROM media_thumbnail m
WHERE m.episode_id = e.id AND m.deleted = 0)
</if>
)
ORDER BY e.id ASC
LIMIT #{limit}
</script>
""")
List<DramaEpisode> selectTranscodeBackfillCandidates(@Param("afterId") long afterId,
@Param("limit") int limit,
@Param("requireFrames") boolean requireFrames);
}
@@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念,
@@ -190,6 +191,37 @@ public class DramaClassificationService {
}
}
// ========== C 端只读视图 ==========
/** C 端:只返回已启用(status=1)的分类,按 sort 升序。 */
public List<DramaClassification> listEnabled() {
return classificationMapper.selectList(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getStatus, 1)
.orderByAsc(DramaClassification::getSort)
.orderByAsc(DramaClassification::getId));
}
/** C 端:校验分类存在且启用。 */
public DramaClassification getEnabledById(Long id) {
DramaClassification entity = getExisting(id);
if (entity.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在或已下架");
}
return entity;
}
/** C 端:分类下关联的全部 dramaId(按关联创建时间 desc)。 */
public List<Long> listDramaIdsByClassification(Long classificationId) {
return classificationDramaMapper.selectList(
Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, classificationId)
.orderByDesc(DramaClassificationDrama::getCreatedAt)
.orderByDesc(DramaClassificationDrama::getId))
.stream()
.map(DramaClassificationDrama::getDramaId)
.collect(Collectors.toList());
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
@@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 剧单分类服务:分类直接关联短剧,没有「剧单」这一层。
@@ -177,6 +178,26 @@ public class DramaPlaylistCategoryService {
return counts;
}
/** C 端用:只返回已启用(status=1)的剧单。 */
public DramaPlaylistCategory getEnabledById(Long id) {
DramaPlaylistCategory entity = getExisting(id);
if (entity.getStatus() == null || entity.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单不存在或已下架");
}
return entity;
}
/** C 端用:剧单下关联的全部 dramaId(按 created_at desc)。 */
public List<Long> listDramaIdsByCategory(Long categoryId) {
return categoryDramaMapper.selectList(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, categoryId)
.orderByDesc(DramaPlaylistCategoryDrama::getCreatedAt)
.orderByDesc(DramaPlaylistCategoryDrama::getId))
.stream()
.map(DramaPlaylistCategoryDrama::getDramaId)
.collect(Collectors.toList());
}
private DramaPlaylistCategory getExisting(Long id) {
DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id);
if (entity == null) {
@@ -226,6 +226,22 @@ public class DramaService {
return dramaMapper.selectPage(page, wrapper).getRecords();
}
/** C 端分类频道:按分类分页查已上线短剧,含 total。 */
public PageResult<Drama> pagePublishedByCategory(Long categoryId, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, 50));
Page<Drama> page = new Page<>(safeIndex, safeSize);
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
if (categoryId != null && categoryId > 0) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId));
}
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount)
.orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), result.getRecords());
}
/** 已上线短剧,按上线时间倒序 */
public List<Drama> listPublishedLatest(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
@@ -28,6 +28,15 @@ public class TranscodeProperties {
/** 队列空时的轮询间隔(毫秒)。 */
private long pollDelayMs = 5000L;
/** 是否自动巡检缺失码率产物/缩略图的分集并补入队(仅 worker 进程生效)。 */
private boolean backfillEnabled = true;
/** 自动补队的巡检间隔(毫秒)。 */
private long backfillDelayMs = 300000L;
/** 单轮巡检最多补入队的分集数。 */
private int backfillBatchSize = 20;
/** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */
private String ffmpegPath = "ffmpeg";
@@ -0,0 +1,57 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* App 版本(app_version)。
*
* <p>iOS / Android 共用一张表,由 {@code platform} 区分;同一平台下 {@code version_code}
* 作为整数比较用(C 端用它判断是否需要升级),{@code version_name} 是展示用字符串。
*/
@Data
@TableName("app_version")
public class AppVersion {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** ANDROID / IOS。 */
private String platform;
/** 整数版本号(比较用)。 */
private Integer versionCode;
/** 展示版本号,如 1.2.0。 */
private String versionName;
/** 下载链接(Android apk 或 iOS App Store)。 */
private String downloadUrl;
/** 更新说明。 */
private String updateContent;
/** 1=强制升级,0=可选升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底)。 */
private Integer minSupportedCode;
/** 1=已发布,0=已下架。 */
private Integer status;
private LocalDateTime releasedAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.system.dal.entity.AppVersion;
/** AppVersion Mapper。 */
public interface AppVersionMapper extends BaseMapper<AppVersion> {
}
@@ -0,0 +1,178 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.dal.entity.AppVersion;
import com.fruitshorts.system.dal.mapper.AppVersionMapper;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* App 版本管理:后台 CRUD + 按平台查询最新发布版。
*
* <p>同一平台下 {@code version_code} 唯一(按 (platform, version_code) 校验重复)。
* 发布(status=1)即对 C 端可见,下架后 C 端 latest 查询不再返回。
*/
@Service
@RequiredArgsConstructor
public class AppVersionService {
private static final List<String> PLATFORMS = List.of("ANDROID", "IOS");
private final AppVersionMapper versionMapper;
public PageResult<AppVersionRespVO> page(AppVersionQueryReqVO req) {
Page<AppVersion> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
String platform = normalizedPlatform(req.getPlatform());
IPage<AppVersion> result = versionMapper.selectPage(page, Wrappers.<AppVersion>lambdaQuery()
.eq(req.getStatus() != null, AppVersion::getStatus, req.getStatus())
.eq(platform != null, AppVersion::getPlatform, platform)
.like(StringUtils.hasText(req.getKeyword()), AppVersion::getVersionName, req.getKeyword())
.orderByDesc(AppVersion::getVersionCode)
.orderByDesc(AppVersion::getId));
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(AppVersionRespVO::of).toList());
}
public AppVersionRespVO detail(Long id) {
return AppVersionRespVO.of(getExisting(id));
}
@Transactional(rollbackFor = Exception.class)
public Long create(AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
checkDuplicate(platform, req.getVersionCode(), null);
AppVersion v = new AppVersion();
applyFields(v, req, platform);
v.setStatus(1);
v.setReleasedAt(LocalDateTime.now());
versionMapper.insert(v);
return v.getId();
}
/** 批量新建:一次事务全插或全回滚。 */
@Transactional(rollbackFor = Exception.class)
public List<Long> batchCreate(List<AppVersionSaveReqVO> reqs) {
if (reqs == null || reqs.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请至少传一条");
}
if (reqs.size() > 16) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "单次不超过 16 条");
}
LocalDateTime now = LocalDateTime.now();
List<Long> ids = new java.util.ArrayList<>();
for (AppVersionSaveReqVO req : reqs) {
String platform = normalizePlatform(req.getPlatform());
checkDuplicate(platform, req.getVersionCode(), null);
AppVersion v = new AppVersion();
applyFields(v, req, platform);
v.setStatus(1);
v.setReleasedAt(now);
versionMapper.insert(v);
ids.add(v.getId());
}
return ids;
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
AppVersion existing = getExisting(id);
checkDuplicate(platform, req.getVersionCode(), id);
applyFields(existing, req, platform);
versionMapper.updateById(existing);
}
/** 发布(1)/ 下架(0)。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(发布)");
}
AppVersion v = getExisting(id);
v.setStatus(status);
if (status == 1 && v.getReleasedAt() == null) {
v.setReleasedAt(LocalDateTime.now());
}
versionMapper.updateById(v);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
versionMapper.deleteById(id);
}
/** C 端用:某平台最新已发布版本(版本码最大者)。 */
public AppVersion latestFor(String platform) {
String normalized = normalizePlatform(platform);
return versionMapper.selectOne(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, normalized)
.eq(AppVersion::getStatus, 1)
.orderByDesc(AppVersion::getVersionCode)
.last("LIMIT 1"));
}
// ─────────── 内部 ───────────
private void applyFields(AppVersion v, AppVersionSaveReqVO req, String platform) {
v.setPlatform(platform);
v.setVersionCode(req.getVersionCode());
v.setVersionName(req.getVersionName().trim());
v.setDownloadUrl(req.getDownloadUrl().trim());
v.setUpdateContent(StringUtils.hasText(req.getUpdateContent()) ? req.getUpdateContent().trim() : "");
v.setForceUpdate(req.getForceUpdate() == null ? 0 : req.getForceUpdate());
v.setMinSupportedCode(req.getMinSupportedCode() == null ? 0 : req.getMinSupportedCode());
}
private String normalizePlatform(String platform) {
if (!StringUtils.hasText(platform)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择平台");
}
String normalized = platform.trim().toUpperCase();
if (!PLATFORMS.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"平台取值不合法:" + platform + ",可选 ANDROID / IOS");
}
return normalized;
}
private String normalizedPlatform(String platform) {
if (!StringUtils.hasText(platform)) return null;
return normalizePlatform(platform);
}
/** 同平台下 version_code 不可重复(update 时排除自身 id)。 */
private void checkDuplicate(String platform, Integer versionCode, Long excludeId) {
Long count = versionMapper.selectCount(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, platform)
.eq(AppVersion::getVersionCode, versionCode)
.ne(excludeId != null, AppVersion::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"该平台下 versionCode=" + versionCode + " 已存在");
}
}
private AppVersion getExisting(Long id) {
AppVersion v = id == null ? null : versionMapper.selectById(id);
if (v == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "版本不存在");
}
return v;
}
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** App 版本后台分页查询。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class AppVersionQueryReqVO extends PageParam {
/** ANDROID / IOS,空=全部。 */
private String platform;
/** 1=已发布 / 0=已下架,空=全部。 */
private Integer status;
/** 关键字(匹配 version_name)。 */
private String keyword;
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.system.dal.entity.AppVersion;
import lombok.Data;
import java.time.LocalDateTime;
/** App 版本后台响应。 */
@Data
public class AppVersionRespVO {
private Long id;
private String platform;
private String platformText;
private Integer versionCode;
private String versionName;
private String downloadUrl;
private String updateContent;
private Integer forceUpdate;
private Integer minSupportedCode;
private Integer status;
private String statusText;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
public static AppVersionRespVO of(AppVersion v) {
AppVersionRespVO vo = new AppVersionRespVO();
vo.setId(v.getId());
vo.setPlatform(v.getPlatform());
vo.setPlatformText(platformText(v.getPlatform()));
vo.setVersionCode(v.getVersionCode());
vo.setVersionName(v.getVersionName());
vo.setDownloadUrl(v.getDownloadUrl());
vo.setUpdateContent(v.getUpdateContent());
vo.setForceUpdate(v.getForceUpdate());
vo.setMinSupportedCode(v.getMinSupportedCode());
vo.setStatus(v.getStatus());
vo.setStatusText(v.getStatus() != null && v.getStatus() == 1 ? "已发布" : "已下架");
vo.setReleasedAt(v.getReleasedAt());
vo.setCreatedAt(v.getCreatedAt());
return vo;
}
private static String platformText(String p) {
return switch (p == null ? "" : p.toUpperCase()) {
case "ANDROID" -> "Android";
case "IOS" -> "iOS";
default -> p == null ? "-" : p;
};
}
}
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** App 版本新增/编辑。 */
@Data
public class AppVersionSaveReqVO {
/** ANDROID / IOS。 */
@NotBlank(message = "请选择平台")
private String platform;
@NotNull(message = "请填写整数版本号")
@Min(value = 1, message = "versionCode 必须大于 0")
@Max(value = 999999, message = "versionCode 过大")
private Integer versionCode;
@NotBlank(message = "请填写展示版本号")
@Size(max = 32, message = "versionName 过长")
private String versionName;
@NotBlank(message = "请填写下载地址")
@Size(max = 512, message = "下载地址过长")
private String downloadUrl;
@Size(max = 2000, message = "更新说明过长")
private String updateContent;
/** 0=可选(默认),1=强制升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底,默认 0 不开启)。 */
private Integer minSupportedCode;
}
@@ -0,0 +1,151 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 转码补齐巡检:定时找出"有片源但缺少码率产物或缩略图轴"的分集,自动补入转码队列。
*
* <p>与 worker 同进程、同开关(`worker-enabled`):在线入口不跑巡检,避免与播放链路抢资源。
* 已排队/执行中的分集不重复补队;最近一次任务已 FAILED 的分集不自动重试,
* 交给运营在后台处理,避免坏片源反复占用转码资源。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true")
public class TranscodeBackfillScanner {
private static final int MIN_BATCH_SIZE = 1;
private static final int MAX_BATCH_SIZE = 100;
private static final long CURSOR_START = 0L;
private final DramaEpisodeMapper episodeMapper;
private final TranscodeService transcodeService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaTranscodeOutputService outputService;
private final MediaTranscodeProfileService profileService;
private final TranscodeProperties properties;
/** 游标:记录上一批处理到的分集 ID,处理完一轮后回到起点。 */
private final AtomicLong cursor = new AtomicLong(CURSOR_START);
/** 只对缺少媒资的分集告警一次,避免每轮巡检刷日志。 */
private final Set<Long> loggedMissingAssetEpisodeIds = ConcurrentHashMap.newKeySet();
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.backfill-delay-ms:300000}")
public void scan() {
if (!properties.isBackfillEnabled()) {
return;
}
try {
int triggered = scanOnce();
if (triggered > 0) {
log.info("转码补齐巡检:本轮补入 {} 个分集", triggered);
}
} catch (Exception ex) {
// 巡检异常不能让调度线程终止,否则后续分集再也补不上
log.error("转码补齐巡检异常:{}", ex.getMessage(), ex);
}
}
/**
* 扫描一批分集并补队。
*
* @return 本轮实际入队的分集数
*/
public int scanOnce() {
int limit = batchSize();
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
cursor.get(), limit, framesRequired());
if (candidates == null || candidates.isEmpty()) {
cursor.set(CURSOR_START);
return 0;
}
cursor.set(candidates.size() < limit
? CURSOR_START : candidates.get(candidates.size() - 1).getId());
int triggered = 0;
for (DramaEpisode episode : candidates) {
if (triggerIfNeeded(episode)) {
triggered++;
}
}
return triggered;
}
private boolean triggerIfNeeded(DramaEpisode episode) {
Long mediaAssetId = resolveMediaAssetId(episode);
if (mediaAssetId == null) {
warnMissingAsset(episode.getId());
return false;
}
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && isInProgress(latest.getStatus())) {
return false;
}
if (latest != null && TranscodeService.STATUS_FAILED.equals(latest.getStatus())) {
return false;
}
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
warnMissingAsset(episode.getId());
return false;
}
TranscodeTriggerRespVO result = transcodeService.trigger(
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
log.info("分集 {} 缺少码率产物或缩略图,已自动入队 task={}",
episode.getId(), result == null ? null : result.getTaskId());
return true;
}
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
private Long resolveMediaAssetId(DramaEpisode episode) {
Long videoAssetId = episode.getVideoAssetId();
if (videoAssetId != null && videoAssetId > 0) {
return videoAssetId;
}
return outputService.resolveMediaAssetId(episode.getVideoUrl());
}
private boolean isInProgress(String status) {
return TranscodeService.STATUS_QUEUED.equals(status)
|| TranscodeService.STATUS_RUNNING.equals(status);
}
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免每轮误判重复转码。 */
private boolean framesRequired() {
MediaTranscodeProfile profile = profileService.currentProfile();
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
}
private int batchSize() {
return Math.max(MIN_BATCH_SIZE, Math.min(properties.getBackfillBatchSize(), MAX_BATCH_SIZE));
}
private void warnMissingAsset(Long episodeId) {
if (episodeId != null && loggedMissingAssetEpisodeIds.add(episodeId)) {
log.warn("分集 {} 找不到对应媒资,跳过自动转码", episodeId);
}
}
}
@@ -42,6 +42,10 @@ fruitshorts:
# 只有本进程打开转码(§8.5 隔离要求)
worker-enabled: true
poll-delay-ms: 3000
# 自动巡检缺失码率产物/缩略图的分集并补入队
backfill-enabled: true
backfill-delay-ms: 300000
backfill-batch-size: 20
ffmpeg-path: ${FFMPEG_PATH:ffmpeg}
ffprobe-path: ${FFPROBE_PATH:ffprobe}
segment-seconds: 6
@@ -0,0 +1,143 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* 缺失转码产物/缩略图的分集自动补队。
*/
class TranscodeBackfillScannerTest {
private static final long EPISODE_ID = 1L;
private static final long DRAMA_ID = 2L;
private static final long ASSET_ID = 9L;
private static final long RESOLVED_ASSET_ID = 77L;
private static final long TASK_ID = 55L;
private static final String SOURCE_URL = "/files/episode/video.mp4";
private static final String VIDEO_URL = "/files/episode/video.mp4";
private DramaEpisodeMapper episodeMapper;
private TranscodeService transcodeService;
private MediaAssetMapper mediaAssetMapper;
private MediaTranscodeOutputService outputService;
private MediaTranscodeProfileService profileService;
private TranscodeBackfillScanner scanner;
@BeforeEach
void setUp() {
episodeMapper = mock(DramaEpisodeMapper.class);
transcodeService = mock(TranscodeService.class);
mediaAssetMapper = mock(MediaAssetMapper.class);
outputService = mock(MediaTranscodeOutputService.class);
profileService = mock(MediaTranscodeProfileService.class);
TranscodeProperties properties = new TranscodeProperties();
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
scanner = new TranscodeBackfillScanner(episodeMapper, transcodeService,
mediaAssetMapper, outputService, profileService, properties);
}
@Test
void should_enqueue_task_when_episode_missing_rendition() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID)).willReturn(null);
given(mediaAssetMapper.selectById(ASSET_ID)).willReturn(asset(ASSET_ID));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
int triggered = scanner.scanOnce();
assertThat(triggered).isEqualTo(1);
verify(transcodeService).trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
}
@Test
void should_resolve_asset_by_video_url_when_episode_has_no_asset_id() {
givenCandidates(episode(null));
given(outputService.resolveMediaAssetId(VIDEO_URL)).willReturn(RESOLVED_ASSET_ID);
given(transcodeService.findLatestTaskByMediaAssetId(RESOLVED_ASSET_ID)).willReturn(null);
given(mediaAssetMapper.selectById(RESOLVED_ASSET_ID)).willReturn(asset(RESOLVED_ASSET_ID));
given(transcodeService.trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
int triggered = scanner.scanOnce();
assertThat(triggered).isEqualTo(1);
verify(transcodeService).trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
}
@Test
void should_skip_when_latest_task_failed() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
.willReturn(task(TranscodeService.STATUS_FAILED));
int triggered = scanner.scanOnce();
assertThat(triggered).isZero();
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
}
@Test
void should_skip_when_task_still_running() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
.willReturn(task(TranscodeService.STATUS_RUNNING));
int triggered = scanner.scanOnce();
assertThat(triggered).isZero();
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
}
private void givenCandidates(DramaEpisode episode) {
given(episodeMapper.selectTranscodeBackfillCandidates(anyLong(), anyInt(), anyBoolean()))
.willReturn(List.of(episode));
}
private DramaEpisode episode(Long videoAssetId) {
DramaEpisode episode = new DramaEpisode();
episode.setId(EPISODE_ID);
episode.setDramaId(DRAMA_ID);
episode.setVideoAssetId(videoAssetId);
episode.setVideoUrl(VIDEO_URL);
return episode;
}
private MediaAsset asset(Long id) {
MediaAsset asset = new MediaAsset();
asset.setId(id);
asset.setSourceUrl(SOURCE_URL);
return asset;
}
private TranscodeTask task(String status) {
TranscodeTask task = new TranscodeTask();
task.setStatus(status);
return task;
}
}
+32
View File
@@ -0,0 +1,32 @@
-- Author: fruit.com.cn
-- 修正 App 版本管理的菜单与权限:
-- 1) 删除误加的 BUTTON 类型菜单 40-44(项目其他模块都只在 permission_code 里挂权限点,
-- 不在 sys_menu 里建按钮行,否则前端菜单渲染会多出无用条目)
-- 2) 在 sys_permission 里加 app-version 完整权限点
-- 3) 给超级管理员(role_id=1)授权这些权限点
-- 1) 删除多余的 BUTTON 菜单
DELETE FROM sys_role_menu WHERE menu_id IN (40, 41, 42, 43, 44);
DELETE FROM sys_menu WHERE id IN (40, 41, 42, 43, 44);
-- 2) 权限点(code 唯一约束兜底)
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(50000101, 'app-version:list', '版本管理-查看', 'App 版本', 'BUTTON', 1),
(50000102, 'app-version:create', '版本管理-新增', 'App 版本', 'BUTTON', 2),
(50000103, 'app-version:update', '版本管理-编辑', 'App 版本', 'BUTTON', 3),
(50000104, 'app-version:delete', '版本管理-删除', 'App 版本', 'BUTTON', 4)
ON CONFLICT (code) DO NOTHING;
-- 3) 超管授权(id 按现有 MAX+1 顺延)
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50001, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:list'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50002, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:create'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50003, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:update'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50004, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:delete'
ON CONFLICT DO NOTHING;
+50
View File
@@ -0,0 +1,50 @@
-- Author: fruit.com.cn
-- App 版本管理:表 + 后台菜单 + 角色菜单授权
CREATE TABLE IF NOT EXISTS app_version (
id BIGINT PRIMARY KEY,
platform VARCHAR(16) NOT NULL, -- ANDROID / IOS
version_code INTEGER NOT NULL, -- 整数版本号(比较用)
version_name VARCHAR(32) NOT NULL, -- 展示版本号,如 1.2.0
download_url VARCHAR(512) NOT NULL,
update_content TEXT, -- 更新说明
force_update SMALLINT NOT NULL DEFAULT 0, -- 1=强更 0=可选
min_supported_code INTEGER NOT NULL DEFAULT 0, -- 低于此版本强制升级
status SMALLINT NOT NULL DEFAULT 1, -- 1=已发布 0=已下架
released_at TIMESTAMP(3),
created_by BIGINT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_app_version_platform ON app_version(platform, version_code DESC);
CREATE INDEX IF NOT EXISTS idx_app_version_status ON app_version(status, platform);
DROP TRIGGER IF EXISTS trg_app_version_updated_at ON app_version;
CREATE TRIGGER trg_app_version_updated_at
BEFORE UPDATE ON app_version
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- 菜单:后台管理下新增「版本管理」(id=39)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, permission_code, type, always_show, affix, sort, visible, status, created_at, updated_at, super_admin_visible)
VALUES (39, 6, '版本管理', 'app-versions', 'app-versions', 'views/systemManagement/AppVersionView', '', 'ep:download', 'app-version:list', 'MENU', 0, 0, 9, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 权限按钮
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, permission_code, type, sort, visible, status, created_at, updated_at, super_admin_visible) VALUES
(40, 39, '查看', '', '', '', 'app-version:list', 'BUTTON', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(41, 39, '新增', '', '', '', 'app-version:create', 'BUTTON', 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(42, 39, '编辑', '', '', '', 'app-version:update', 'BUTTON', 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(43, 39, '删除', '', '', '', 'app-version:delete', 'BUTTON', 4, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(44, 39, '上下架', '', '', '', 'app-version:update', 'BUTTON', 5, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 超管角色授权(sys_role_menu 需要手动 id,按现有 MAX(id) 顺延)
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT 5000031, 1, 39 UNION ALL
SELECT 5000032, 1, 40 UNION ALL
SELECT 5000033, 1, 41 UNION ALL
SELECT 5000034, 1, 42 UNION ALL
SELECT 5000035, 1, 43 UNION ALL
SELECT 5000036, 1, 44
ON CONFLICT DO NOTHING;