refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载

为什么:
1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、
   模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*,
   这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts,
   数据库连接名上一轮已改名,保持不变。
2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、
   下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。
3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引,
   表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就
   按分区表定义,并验证脚本可重复执行。
This commit is contained in:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

No files matched your search

@@ -0,0 +1,19 @@
package com.fruitshorts.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
* 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
*/
/* 显式指定扫描根包:本类在 com.fruitshorts.app 下,默认扫不到 content/system/framework 模块的 Bean */
@SpringBootApplication(scanBasePackages = "com.fruitshorts")
@MapperScan("com.fruitshorts.**.dal.mapper")
public class AppApiApplication {
public static void main(String[] args) {
SpringApplication.run(AppApiApplication.class, args);
}
}
@@ -0,0 +1,61 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.vo.LoginReqVO;
import com.fruitshorts.user.vo.LogoutReqVO;
import com.fruitshorts.user.vo.OtpSendReqVO;
import com.fruitshorts.user.vo.RefreshReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
@RestController
@RequestMapping("/app/auth")
@RequiredArgsConstructor
public class AppAuthController {
private final AppOtpService otpService;
private final AppAuthService authService;
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */
@PostMapping("/otp")
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
otpService.send(req, WebUtils.getClientIp(request));
return Result.ok();
}
@PostMapping("/register")
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
HttpServletRequest request) {
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
}
@PostMapping("/login")
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
HttpServletRequest request) {
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
}
@PostMapping("/refresh")
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
return Result.ok(authService.refresh(req.getRefreshToken()));
}
/** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */
@PostMapping("/logout")
public Result<Void> logout(@RequestBody(required = false) LogoutReqVO req) {
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
return Result.ok();
}
}
@@ -0,0 +1,38 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.common.pojo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 客户端目录接口:首页与发现页各一次请求拿全量聚合数据。
*/
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppCatalogController {
private final AppCatalogService appCatalogService;
@GetMapping("/home/feed")
public Result<AppFeedVO> homeFeed() {
return Result.ok(appCatalogService.homeFeed());
}
@GetMapping("/discover/feed")
public Result<AppFeedVO> discoverFeed() {
return Result.ok(appCatalogService.discoverFeed());
}
@GetMapping("/categories")
public Result<List<AppCategoryVO>> categories() {
return Result.ok(appCatalogService.categories());
}
}
@@ -0,0 +1,96 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.DownloadService;
import com.fruitshorts.playback.vo.DownloadGrantReqVO;
import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
import com.fruitshorts.playback.vo.DownloadPolicyRespVO;
import com.fruitshorts.playback.vo.DownloadProgressReqVO;
import com.fruitshorts.playback.vo.DownloadSettingReqVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
import java.util.List;
/**
* 离线下载。
*
* <p>服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。
* 设备标识优先取请求体,其次取请求头 X-Device-Id。
*/
@RestController
@RequestMapping("/app/downloads")
@RequiredArgsConstructor
public class AppDownloadController {
private final DownloadService downloadService;
/** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */
@PostMapping
public Result<DownloadLicenseRespVO> grant(@Valid @RequestBody DownloadGrantReqVO req,
HttpServletRequest request) {
return Result.ok(downloadService.grant(SecurityUtils.getAppUserId(), req,
request.getHeader("X-Device-Id")));
}
@GetMapping
public Result<List<DownloadLicenseRespVO>> list(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId));
}
@GetMapping("/policy")
public Result<DownloadPolicyRespVO> policy(@RequestParam(required = false) String deviceId) {
return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId));
}
/** 仅 Wi-Fi 下载、清晰度上限等偏好。 */
@PutMapping("/settings")
public Result<Void> updateSettings(@RequestBody DownloadSettingReqVO req) {
downloadService.updateSettings(SecurityUtils.getAppUserId(), req);
return Result.ok();
}
/** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */
@GetMapping("/{episodeId}/url")
public Result<DownloadLicenseRespVO> refreshUrl(@PathVariable Long episodeId,
HttpServletRequest request) {
return Result.ok(downloadService.refreshUrl(SecurityUtils.getAppUserId(), episodeId,
request.getHeader("X-Device-Id")));
}
@PostMapping("/{episodeId}/progress")
public Result<DownloadLicenseRespVO> reportProgress(@PathVariable Long episodeId,
@RequestBody DownloadProgressReqVO req,
HttpServletRequest request) {
return Result.ok(downloadService.reportProgress(SecurityUtils.getAppUserId(), episodeId, req,
request.getHeader("X-Device-Id")));
}
@DeleteMapping("/{episodeId}")
public Result<Void> revoke(@PathVariable Long episodeId,
@RequestParam(required = false) String deviceId,
HttpServletRequest request) {
downloadService.revoke(SecurityUtils.getAppUserId(), episodeId,
deviceId != null ? deviceId : request.getHeader("X-Device-Id"));
return Result.ok();
}
/** 清空当前设备的全部下载授权。 */
@DeleteMapping
public Result<Integer> clear(@RequestParam(required = false) String deviceId,
HttpServletRequest request) {
return Result.ok(downloadService.clear(SecurityUtils.getAppUserId(),
deviceId != null ? deviceId : request.getHeader("X-Device-Id")));
}
}
@@ -0,0 +1,43 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import jakarta.servlet.http.HttpServletRequest;
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.RestController;
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppDramaController {
private final AppCatalogService appCatalogService;
private final PlaybackService playbackService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@GetMapping("/dramas/{id}")
public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
return Result.ok(appCatalogService.dramaDetail(id));
}
/**
* 播放票据:服务端判定权益后签发短时效播放地址。
* 未登录也能播免费集;付费集未解锁时只返回需要多少金币。
*/
@GetMapping("/episodes/{id}/play")
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
return Result.ok(playbackService.play(
SecurityUtils.getUserIdOrNull(),
id,
request.getHeader("X-Device-Id"),
WebUtils.getClientIp(request)));
}
}
@@ -0,0 +1,48 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 收藏。 */
@RestController
@RequestMapping("/app/favorites")
@RequiredArgsConstructor
public class AppFavoriteController {
private final FavoriteService favoriteService;
private final AppCatalogService appCatalogService;
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
@PostMapping("/toggle")
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
}
/** 收藏列表,卡片结构与首页一致。 */
@GetMapping
public Result<List<AppDramaCardVO>> list() {
return Result.ok(appCatalogService.toCards(
favoriteService.listDramas(SecurityUtils.getAppUserId())));
}
@DeleteMapping("/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) {
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
return Result.ok();
}
}
@@ -0,0 +1,63 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.user.dal.entity.AppUserDevice;
import com.fruitshorts.user.service.AppUserService;
import com.fruitshorts.user.vo.AppUserRespVO;
import com.fruitshorts.user.vo.DeactivateReqVO;
import com.fruitshorts.user.vo.ProfileUpdateReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** 客户端个人中心:资料、设备、注销、数据导出。 */
@RestController
@RequestMapping("/app/me")
@RequiredArgsConstructor
public class AppMeController {
private final AppUserService userService;
@GetMapping
public Result<AppUserRespVO> me() {
return Result.ok(userService.me(SecurityUtils.getAppUserId()));
}
@PutMapping
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) {
return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req));
}
@GetMapping("/devices")
public Result<List<AppUserDevice>> devices() {
return Result.ok(userService.devices(SecurityUtils.getAppUserId()));
}
/** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */
@GetMapping("/export")
public Result<Map<String, Object>> export() {
return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId()));
}
/** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */
@PostMapping("/deactivate")
public Result<LocalDateTime> deactivate(@RequestBody(required = false) DeactivateReqVO req) {
return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req));
}
@PostMapping("/deactivate/cancel")
public Result<Void> cancelDeactivate() {
userService.cancelDeactivation(SecurityUtils.getAppUserId());
return Result.ok();
}
}
@@ -0,0 +1,57 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 观看进度与观看记录。 */
@RestController
@RequestMapping("/app/watch")
@RequiredArgsConstructor
public class AppWatchController {
private final WatchProgressService progressService;
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
@PostMapping("/progress")
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
}
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
@GetMapping("/progress")
public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
}
@GetMapping("/recent")
public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) {
return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit));
}
@DeleteMapping("/progress/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) {
progressService.remove(SecurityUtils.getAppUserId(), dramaId);
return Result.ok();
}
@DeleteMapping("/progress")
public Result<Void> clear() {
progressService.clear(SecurityUtils.getAppUserId());
return Result.ok();
}
}
@@ -0,0 +1,203 @@
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppBannerVO;
import com.fruitshorts.app.vo.AppCategoryVO;
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.AppSectionVO;
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.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.content.vo.DramaCategoryRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。
* 只读取已上线内容,运营状态、审核信息等管理端字段不下发。
*/
@Service
@RequiredArgsConstructor
public class AppCatalogService {
private static final int HOME_SECTION_SIZE = 6;
private static final int DISCOVER_SECTION_SIZE = 10;
private static final int MAX_CATEGORY_SECTIONS = 4;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final BannerService bannerService;
private final DramaCategoryService dramaCategoryService;
private final PlaybackSigner playbackSigner;
public AppFeedVO homeFeed() {
Map<Long, String> categoryNames = categoryNameMap();
List<AppSectionVO> sections = new ArrayList<>();
addIfNotEmpty(sections, "hot", "热门推荐",
dramaService.listPublishedHot(HOME_SECTION_SIZE), categoryNames);
addIfNotEmpty(sections, "new", "新剧上线",
dramaService.listPublishedLatest(HOME_SECTION_SIZE), categoryNames);
sections.addAll(categorySections(HOME_SECTION_SIZE, categoryNames));
return new AppFeedVO(banners(BannerPosition.HOME_TOP.name()), sections);
}
public AppFeedVO discoverFeed() {
return new AppFeedVO(banners(BannerPosition.DISCOVER.name()),
categorySections(DISCOVER_SECTION_SIZE, categoryNameMap()));
}
public List<AppCategoryVO> categories() {
return dramaCategoryService.listBasic().stream()
.filter(category -> category.getStatus() == null || category.getStatus() == 1)
.map(category -> new AppCategoryVO(category.getId(), category.getName()))
.toList();
}
/**
* 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致,
* 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。
*/
public List<AppDramaCardVO> toCards(List<Drama> dramas) {
if (dramas == null || dramas.isEmpty()) {
return List.of();
}
return cards(dramas, categoryNameMap());
}
public AppDramaDetailVO dramaDetail(Long dramaId) {
Drama drama = dramaService.getPublished(dramaId);
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(drama.getId());
detail.setTitle(drama.getTitle());
detail.setSynopsis(drama.getSynopsis());
detail.setCover(drama.getCoverUrl());
detail.setGenre(drama.getGenre());
detail.setCategory(categoryName);
detail.setTotalEpisodes(drama.getTotalEpisodes());
detail.setFreeEpisodes(drama.getFreeEpisodes());
detail.setUnlockCoin(drama.getUnlockCoin());
detail.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
detail.setViewCount(drama.getViewCount());
detail.setEpisodes(episodes.stream().map(this::toEpisode).toList());
return detail;
}
private List<AppBannerVO> banners(String position) {
return bannerService.listActive(position).stream().map(this::toBanner).toList();
}
private List<AppSectionVO> categorySections(int size, Map<Long, String> categoryNames) {
List<AppSectionVO> sections = new ArrayList<>();
for (DramaCategoryRespVO category : dramaCategoryService.listBasic()) {
if (sections.size() >= MAX_CATEGORY_SECTIONS) {
break;
}
List<Drama> dramas = dramaService.listPublished(category.getId(), size);
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(
"category-" + category.getId(),
category.getName(),
cards(dramas, categoryNames)));
}
}
return sections;
}
private void addIfNotEmpty(List<AppSectionVO> sections, String code, String title,
List<Drama> dramas, Map<Long, String> categoryNames) {
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(code, title, cards(dramas, categoryNames)));
}
}
private AppBannerVO toBanner(ContentBanner banner) {
AppBannerVO vo = new AppBannerVO();
vo.setId(banner.getId());
vo.setImageUrl(banner.getImageUrl());
vo.setLinkType(banner.getLinkType());
vo.setLinkValue(banner.getLinkValue());
return vo;
}
/** 整屏卡片共用一次剧集查询,取每部剧的首个可播免费集作为预览。 */
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(
dramas.stream().map(Drama::getId).toList());
return dramas.stream()
.map(drama -> toCard(drama, categoryNames, previews.get(drama.getId())))
.toList();
}
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
DramaEpisode preview) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(drama.getId());
card.setTitle(drama.getTitle());
card.setCover(drama.getCoverUrl());
card.setSynopsis(shortSynopsis(drama.getSynopsis()));
card.setGenre(drama.getGenre());
card.setCategory(categoryNames.getOrDefault(drama.getCategoryId(), ""));
card.setTotalEpisodes(drama.getTotalEpisodes());
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
card.setViewCount(drama.getViewCount());
card.setPreview(preview == null ? null : toEpisode(preview));
return card;
}
private AppEpisodeVO toEpisode(DramaEpisode episode) {
AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId());
vo.setNumber(episode.getEpNo());
vo.setTitle(episode.getTitle());
vo.setDurationSec(episode.getDurationSec());
int seconds = episode.getDurationSec() == null ? 0 : episode.getDurationSec();
vo.setDuration(String.format("%02d:%02d", seconds / 60, seconds % 60));
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
vo.setIsFree(free);
vo.setLocked(!free);
vo.setUnlockCoin(episode.getUnlockCoin());
if (free) {
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
}
return vo;
}
/** 有 HLS 多码率就用 HLS,否则退回原文件。 */
private String playableSource(DramaEpisode episode) {
if (episode.getHlsUrl() != null && !episode.getHlsUrl().isBlank()) {
return episode.getHlsUrl();
}
return episode.getVideoUrl() == null ? "" : episode.getVideoUrl();
}
private Map<Long, String> categoryNameMap() {
Map<Long, String> names = new HashMap<>();
dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName()));
return names;
}
/** 卡片只带一句简介,避免列表响应体过大 */
private String shortSynopsis(String synopsis) {
if (synopsis == null) {
return "";
}
return synopsis.length() <= 60 ? synopsis : synopsis.substring(0, 60);
}
}
@@ -0,0 +1,14 @@
package com.fruitshorts.app.vo;
import lombok.Data;
@Data
public class AppBannerVO {
private Long id;
private String imageUrl;
/** NONE / DRAMA / URL */
private String linkType;
/** 短剧 ID 或跳转地址 */
private String linkValue;
}
@@ -0,0 +1,12 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AppCategoryVO {
private Long id;
private String name;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.app.vo;
import lombok.Data;
/** 列表卡片,用于首页/发现页/分类分区 */
@Data
public class AppDramaCardVO {
private Long id;
private String title;
private String cover;
private String synopsis;
private String genre;
private String category;
private Integer totalEpisodes;
private Boolean isHot;
private Long viewCount;
/**
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
*/
private AppEpisodeVO preview;
}
@@ -0,0 +1,22 @@
package com.fruitshorts.app.vo;
import lombok.Data;
import java.util.List;
@Data
public class AppDramaDetailVO {
private Long id;
private String title;
private String synopsis;
private String cover;
private String genre;
private String category;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
private Boolean isHot;
private Long viewCount;
private List<AppEpisodeVO> episodes;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.app.vo;
import lombok.Data;
@Data
public class AppEpisodeVO {
private Long id;
private Integer number;
private String title;
/** 展示用时长 mm:ss */
private String duration;
private Integer durationSec;
private Boolean isFree;
/** true 表示需要解锁后才能播放 */
private Boolean locked;
private Integer unlockCoin;
/**
* 可播放时的地址;锁定或不含片源时为空。
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
*/
private String playUrl;
}
@@ -0,0 +1,15 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 首页/发现页聚合响应:一次请求拿到轮播与所有分区,减少客户端往返 */
@Data
@AllArgsConstructor
public class AppFeedVO {
private List<AppBannerVO> banners;
private List<AppSectionVO> sections;
}
@@ -0,0 +1,17 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 首页/发现页的一个横滑分区 */
@Data
@AllArgsConstructor
public class AppSectionVO {
/** 分区标识:hot / new / category-{id},客户端可据此做埋点与样式 */
private String code;
private String title;
private List<AppDramaCardVO> dramas;
}
@@ -0,0 +1,49 @@
server:
port: 8081
spring:
application:
name: fruitshorts-app-api
autoconfigure:
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
username: ${DB_USER:fruitshorts}
password: ${DB_PASSWORD:fruitshorts}
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
fruitshorts:
download:
# 这些值会通过 /app/downloads/policy 下发给客户端,改这里不用发版
max-per-device: 20
license-days: 30
wifi-only-default: true
max-quality: AUTO
media:
root-dir: ./data/uploads
public-prefix: /files
chunk-size: 5242880
max-file-size: 2147483648
jwt:
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
admin-ttl-hours: 12
cors:
allowed-origins: "*"
logging:
level:
com.fruitshorts: info