feat: 初始化短剧服务端(管理后台 API + 客户端 API)
Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
commit
a4aa749cc6
152 files changed
+8027
No files matched your search
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-app-api</artifactId>
|
||||
<name>reelshort-app-api</name>
|
||||
<description>客户端接入层:/app/**</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-content</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-media</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.reelshort.app;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
|
||||
* 与 reelshort-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
|
||||
*/
|
||||
/* 显式指定扫描根包:本类在 com.reelshort.app 下,默认扫不到 content/system/framework 模块的 Bean */
|
||||
@SpringBootApplication(scanBasePackages = "com.reelshort")
|
||||
@MapperScan("com.reelshort.**.dal.mapper")
|
||||
public class AppApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AppApiApplication.class, args);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.reelshort.app.controller;
|
||||
|
||||
import com.reelshort.app.service.AppCatalogService;
|
||||
import com.reelshort.app.vo.AppCategoryVO;
|
||||
import com.reelshort.app.vo.AppFeedVO;
|
||||
import com.reelshort.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,31 @@
|
||||
package com.reelshort.app.controller;
|
||||
|
||||
import com.reelshort.app.service.AppCatalogService;
|
||||
import com.reelshort.app.vo.AppDramaDetailVO;
|
||||
import com.reelshort.app.vo.AppPlayTicketVO;
|
||||
import com.reelshort.common.pojo.Result;
|
||||
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;
|
||||
|
||||
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
|
||||
@GetMapping("/dramas/{id}")
|
||||
public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
|
||||
return Result.ok(appCatalogService.dramaDetail(id));
|
||||
}
|
||||
|
||||
/** 播放票据:返回可访问性与播放地址,未解锁时只返回需要多少金币 */
|
||||
@GetMapping("/episodes/{id}/play")
|
||||
public Result<AppPlayTicketVO> play(@PathVariable Long id) {
|
||||
return Result.ok(appCatalogService.playTicket(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.reelshort.app.service;
|
||||
|
||||
import com.reelshort.app.vo.AppBannerVO;
|
||||
import com.reelshort.app.vo.AppCategoryVO;
|
||||
import com.reelshort.app.vo.AppDramaCardVO;
|
||||
import com.reelshort.app.vo.AppDramaDetailVO;
|
||||
import com.reelshort.app.vo.AppEpisodeVO;
|
||||
import com.reelshort.app.vo.AppFeedVO;
|
||||
import com.reelshort.app.vo.AppPlayTicketVO;
|
||||
import com.reelshort.app.vo.AppSectionVO;
|
||||
import com.reelshort.content.dal.entity.ContentBanner;
|
||||
import com.reelshort.content.dal.entity.Drama;
|
||||
import com.reelshort.content.dal.entity.DramaEpisode;
|
||||
import com.reelshort.content.enums.BannerPosition;
|
||||
import com.reelshort.content.service.BannerService;
|
||||
import com.reelshort.content.service.DramaCategoryService;
|
||||
import com.reelshort.content.service.DramaService;
|
||||
import com.reelshort.content.service.EpisodeService;
|
||||
import com.reelshort.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;
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
/** 播放票据:目前免费集直接给地址,未解锁只返回需要多少金币 */
|
||||
public AppPlayTicketVO playTicket(Long episodeId) {
|
||||
DramaEpisode episode = episodeService.getPublished(episodeId);
|
||||
dramaService.getPublished(episode.getDramaId());
|
||||
|
||||
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
|
||||
boolean hasMedia = episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank();
|
||||
|
||||
AppPlayTicketVO ticket = new AppPlayTicketVO();
|
||||
ticket.setEpisodeId(episode.getId());
|
||||
ticket.setDramaId(episode.getDramaId());
|
||||
ticket.setEpNo(episode.getEpNo());
|
||||
ticket.setTitle(episode.getTitle());
|
||||
ticket.setDurationSec(episode.getDurationSec());
|
||||
ticket.setUnlockCoin(episode.getUnlockCoin());
|
||||
if (!free) {
|
||||
ticket.setAccessible(false);
|
||||
ticket.setAccessType("COIN_REQUIRED");
|
||||
} else if (!hasMedia) {
|
||||
ticket.setAccessible(false);
|
||||
ticket.setAccessType("NO_MEDIA");
|
||||
} else {
|
||||
ticket.setAccessible(true);
|
||||
ticket.setAccessType("FREE");
|
||||
ticket.setPlayUrl(episode.getVideoUrl());
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
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 && episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank()) {
|
||||
vo.setPlayUrl(episode.getVideoUrl());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
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.reelshort.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.reelshort.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.reelshort.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.reelshort.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.reelshort.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.reelshort.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,20 @@
|
||||
package com.reelshort.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 播放票据:客户端据此决定直接播放还是弹解锁 */
|
||||
@Data
|
||||
public class AppPlayTicketVO {
|
||||
|
||||
private Long episodeId;
|
||||
private Long dramaId;
|
||||
private Integer epNo;
|
||||
private String title;
|
||||
private Integer durationSec;
|
||||
private Boolean accessible;
|
||||
/** FREE / COIN_REQUIRED / VIP_REQUIRED / NO_MEDIA */
|
||||
private String accessType;
|
||||
private Integer unlockCoin;
|
||||
/** 可访问时的播放地址(相对路径,客户端拼上服务地址) */
|
||||
private String playUrl;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.reelshort.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,43 @@
|
||||
server:
|
||||
port: 8081
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: reelshort-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/reelshort
|
||||
username: ${DB_USER:reelshort}
|
||||
password: ${DB_PASSWORD:reelshort}
|
||||
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
|
||||
|
||||
reelshort:
|
||||
media:
|
||||
root-dir: ./data/uploads
|
||||
public-prefix: /files
|
||||
chunk-size: 5242880
|
||||
max-file-size: 2147483648
|
||||
jwt:
|
||||
admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-ttl-hours: 12
|
||||
cors:
|
||||
allowed-origins: "*"
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.reelshort: info
|
||||
Reference in new issue
Block a user