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:
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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+96
@@ -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")));
|
||||
}
|
||||
}
|
||||
+43
@@ -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)));
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+57
@@ -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
|
||||
Reference in new issue
Block a user