feat: 初始化短剧服务端(管理后台 API + 客户端 API)

Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
Vicky committed 2026-10-14 18:02:47 +08:00
commit a4aa749cc6
152 files changed
+8027

No files matched your search

+36
View File
@@ -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);
}
}
@@ -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