feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能

1.  为后台剧集列表添加标题和推荐状态筛选参数
2.  新增前台推荐剧集列表接口和前置搜索接口
3.  添加可选布尔值解析工具方法
4.  新增对应接口的端到端测试用例
This commit is contained in:
2026-07-02 17:26:08 +08:00
parent f2b1043cbc
commit fca247c3f2
7 changed files with 1421 additions and 933 deletions

View File

@@ -28,10 +28,12 @@ export class DramasController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:read") @RequirePermissions("drama:read")
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) { listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
return this.dramasService.listAdminDramas({ return this.dramasService.listAdminDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 20,
title,
isRecommended: this.parseOptionalBoolean(isRecommended),
}); });
} }
@@ -111,11 +113,27 @@ export class DramasController {
} }
@Get("/api/app/v1/dramas") @Get("/api/app/v1/dramas")
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string) { listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listPublishedDramas({ return this.dramasService.listPublishedDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 10, pageSize: Number(pageSize) || 10,
title, });
}
@Get("/api/app/v1/dramas/recommended")
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listRecommendedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
});
}
@Get("/api/app/v1/dramas/presearch")
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchVideos({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
keyword,
}); });
} }
@@ -131,4 +149,14 @@ export class DramasController {
getPublishedDrama(@Param("id") id: string) { getPublishedDrama(@Param("id") id: string) {
return this.dramasService.getPublishedDramaOrThrow(Number(id)); return this.dramasService.getPublishedDramaOrThrow(Number(id));
} }
private parseOptionalBoolean(value?: string) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return undefined;
}
} }

View File

@@ -1,33 +1,49 @@
import { import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
ConflictException, import { InjectDataSource } from "@nestjs/typeorm";
Injectable, import { DataSource, Like } from "typeorm";
NotFoundException, import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
Optional, import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
UnprocessableEntityException, import { CreateDramaDto } from "./dto/create-drama.dto";
} from '@nestjs/common'; import { CreateEpisodeDto } from "./dto/create-episode.dto";
import { InjectDataSource } from '@nestjs/typeorm'; import { UpdateDramaDto } from "./dto/update-drama.dto";
import { DataSource, Like } from 'typeorm'; import { UpdateEpisodeDto } from "./dto/update-episode.dto";
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { Drama } from "./entities/drama.entity";
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto'; import { Episode } from "./entities/episode.entity";
import { CreateDramaDto } from './dto/create-drama.dto'; import { PublishStatus } from "./drama-status.enum";
import { CreateEpisodeDto } from './dto/create-episode.dto'; import { DramaRecord, EpisodeRecord } from "./interfaces/drama-records.interface";
import { UpdateDramaDto } from './dto/update-drama.dto';
import { UpdateEpisodeDto } from './dto/update-episode.dto';
import { Drama } from './entities/drama.entity';
import { Episode } from './entities/episode.entity';
import { PublishStatus } from './drama-status.enum';
import {
DramaRecord,
EpisodeRecord,
} from './interfaces/drama-records.interface';
interface PaginationInput { interface PaginationInput {
page: number; page: number;
pageSize: number; pageSize: number;
} }
interface PublishedDramaListInput extends PaginationInput { interface DramaListInput extends PaginationInput {
title?: string; title?: string;
isRecommended?: boolean;
}
type PublishedDramaListInput = DramaListInput;
interface VideoPresearchInput extends PaginationInput {
keyword?: string;
}
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
export interface VideoPresearchRecord {
id: number;
dramaId: number;
episodeNo: number;
title: string;
description?: string;
coverUrl: string;
durationSeconds: number;
byteplusVid?: string;
videoUrl?: string;
freeType: "free" | "paid";
price: number;
dramaTitle: string;
dramaCoverUrl: string;
} }
@Injectable() @Injectable()
@@ -108,44 +124,46 @@ export class DramasService {
return drama; return drama;
} }
async listAdminDramas( async listAdminDramas(input: DramaListInput): Promise<PaginatedResponse<DramaRecord>> {
pagination: PaginationInput, const title = input.title?.trim();
): Promise<PaginatedResponse<DramaRecord>> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination); const { page, pageSize, skip, take } = this.resolvePagination(input);
const [records, total] = await this.dataSource const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
.getRepository(Drama) where: {
.findAndCount({ ...(title ? { title: Like(`%${title}%`) } : {}),
order: { sortOrder: 'DESC', id: 'DESC' }, ...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
},
order: { sortOrder: "DESC", id: "DESC" },
skip, skip,
take, take,
}); });
const list = await Promise.all( const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
records.map((drama) => this.toDramaRecordWithCounts(drama)),
);
return { list, pagination: { page, pageSize, total } }; return { list, pagination: { page, pageSize, total } };
} }
return this.paginate( return this.paginate(
this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), this.sortDramas(
pagination, this.dramas
.filter((item) => !title || item.title.includes(title))
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
.map((drama) => this.withEpisodeCounts(drama)),
),
input,
); );
} }
async getDramaOrThrow(id: number): Promise<DramaRecord> { async getDramaOrThrow(id: number): Promise<DramaRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const drama = await this.dataSource const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id } });
.getRepository(Drama)
.findOne({ where: { id } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
return this.toDramaRecordWithCounts(drama); return this.toDramaRecordWithCounts(drama);
} }
const drama = this.dramas.find((item) => item.id === id); const drama = this.dramas.find((item) => item.id === id);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
return this.withEpisodeCounts(drama); return this.withEpisodeCounts(drama);
@@ -157,7 +175,7 @@ export class DramasService {
const repository = this.dataSource.getRepository(Drama); const repository = this.dataSource.getRepository(Drama);
const drama = await repository.findOne({ where: { id } }); const drama = await repository.findOne({ where: { id } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
repository.merge(drama, { repository.merge(drama, {
...dramaUpdates, ...dramaUpdates,
@@ -174,7 +192,7 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === id); const drama = this.dramas.find((item) => item.id === id);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
Object.assign(drama, { Object.assign(drama, {
@@ -192,21 +210,19 @@ export class DramasService {
const dramaId = dto.dramaId ?? dto.albumId; const dramaId = dto.dramaId ?? dto.albumId;
const episodeNo = dto.episodeNo ?? dto.seq; const episodeNo = dto.episodeNo ?? dto.seq;
if (!dramaId || !episodeNo) { if (!dramaId || !episodeNo) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free'); const freeType = dto.freeType ?? (dto.isPaid ? "paid" : "free");
const price = dto.price ?? dto.unlockPrice ?? 0; const price = dto.price ?? dto.unlockPrice ?? 0;
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft; const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
const reviewStatus = const reviewStatus = dto.reviewStatus ?? (publishStatus === PublishStatus.Published ? "approved" : "not_submitted");
dto.reviewStatus ??
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted');
if (this.hasDatabase()) { if (this.hasDatabase()) {
const dramaRepository = this.dataSource.getRepository(Drama); const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: dramaId } }); const drama = await dramaRepository.findOne({ where: { id: dramaId } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const episodeRepository = this.dataSource.getRepository(Episode); const episodeRepository = this.dataSource.getRepository(Episode);
@@ -214,9 +230,7 @@ export class DramasService {
where: { dramaId, episodeNo }, where: { dramaId, episodeNo },
}); });
if (duplicate) { if (duplicate) {
throw new ConflictException( throw new ConflictException("Episode number already exists in this drama");
'Episode number already exists in this drama',
);
} }
const entity = episodeRepository.create({ const entity = episodeRepository.create({
@@ -227,13 +241,13 @@ export class DramasService {
description: dto.description, description: dto.description,
byteplusVid: dto.byteplusVid, byteplusVid: dto.byteplusVid,
coverId: dto.coverId, coverId: dto.coverId,
coverUrl: dto.coverUrl ?? '', coverUrl: dto.coverUrl ?? "",
videoUrl: dto.videoUrl, videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds, trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds ?? 0, durationSeconds: dto.durationSeconds ?? 0,
width: dto.width, width: dto.width,
height: dto.height, height: dto.height,
isPaid: freeType === 'paid', isPaid: freeType === "paid",
freeType, freeType,
unlockPrice: price, unlockPrice: price,
price, price,
@@ -249,14 +263,12 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const duplicate = this.episodes.some( const duplicate = this.episodes.some((item) => item.dramaId === dramaId && item.episodeNo === episodeNo);
(item) => item.dramaId === dramaId && item.episodeNo === episodeNo,
);
if (duplicate) { if (duplicate) {
throw new ConflictException('Episode number already exists in this drama'); throw new ConflictException("Episode number already exists in this drama");
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -271,13 +283,13 @@ export class DramasService {
description: dto.description, description: dto.description,
byteplusVid: dto.byteplusVid, byteplusVid: dto.byteplusVid,
coverId: dto.coverId, coverId: dto.coverId,
coverUrl: dto.coverUrl ?? '', coverUrl: dto.coverUrl ?? "",
videoUrl: dto.videoUrl, videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds, trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds ?? 0, durationSeconds: dto.durationSeconds ?? 0,
width: dto.width, width: dto.width,
height: dto.height, height: dto.height,
isPaid: freeType === 'paid', isPaid: freeType === "paid",
freeType, freeType,
unlockPrice: price, unlockPrice: price,
price, price,
@@ -294,53 +306,39 @@ export class DramasService {
return episode; return episode;
} }
async configureEpisodes( async configureEpisodes(dramaId: number, dto: ConfigureEpisodesDto): Promise<PaginatedResponse<EpisodeRecord>> {
dramaId: number,
dto: ConfigureEpisodesDto,
): Promise<PaginatedResponse<EpisodeRecord>> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const dramaRepository = this.dataSource.getRepository(Drama); const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: dramaId } }); const drama = await dramaRepository.findOne({ where: { id: dramaId } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const episodeRepository = this.dataSource.getRepository(Episode); const episodeRepository = this.dataSource.getRepository(Episode);
const existing = await episodeRepository.find({ const existing = await episodeRepository.find({
where: { dramaId }, where: { dramaId },
order: { episodeNo: 'ASC' }, order: { episodeNo: "ASC" },
}); });
if (dto.episodeCount < existing.length) { if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount); const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some( const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
if (hasLockedEpisode) { if (hasLockedEpisode) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
'Only empty draft tail episodes can be removed',
);
} }
await episodeRepository.remove(removable); await episodeRepository.remove(removable);
} }
for ( for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
let episodeNo = existing.length + 1;
episodeNo <= dto.episodeCount;
episodeNo++
) {
await this.createEpisode({ await this.createEpisode({
dramaId, dramaId,
episodeNo, episodeNo,
title: `${episodeNo}`, title: `${episodeNo}`,
coverUrl: drama.coverUrl, coverUrl: drama.coverUrl,
durationSeconds: 0, durationSeconds: 0,
freeType: 'free', freeType: "free",
price: 0, price: 0,
reviewStatus: 'not_submitted', reviewStatus: "not_submitted",
publishStatus: PublishStatus.Draft, publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft, status: PublishStatus.Draft,
}); });
@@ -348,7 +346,7 @@ export class DramasService {
const list = await episodeRepository.find({ const list = await episodeRepository.find({
where: { dramaId }, where: { dramaId },
order: { episodeNo: 'ASC' }, order: { episodeNo: "ASC" },
}); });
const records = list.map((episode) => this.toEpisodeRecord(episode)); const records = list.map((episode) => this.toEpisodeRecord(episode));
return { return {
@@ -359,24 +357,15 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const existing = this.episodes const existing = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
if (dto.episodeCount < existing.length) { if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount); const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some( const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
if (hasLockedEpisode) { if (hasLockedEpisode) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
'Only empty draft tail episodes can be removed',
);
} }
for (const episode of removable) { for (const episode of removable) {
@@ -387,47 +376,36 @@ export class DramasService {
} }
} }
for ( for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
let episodeNo = existing.length + 1;
episodeNo <= dto.episodeCount;
episodeNo++
) {
await this.createEpisode({ await this.createEpisode({
dramaId, dramaId,
episodeNo, episodeNo,
title: `${episodeNo}`, title: `${episodeNo}`,
coverUrl: drama.coverUrl, coverUrl: drama.coverUrl,
durationSeconds: 0, durationSeconds: 0,
freeType: 'free', freeType: "free",
price: 0, price: 0,
reviewStatus: 'not_submitted', reviewStatus: "not_submitted",
publishStatus: PublishStatus.Draft, publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft, status: PublishStatus.Draft,
}); });
} }
const list = this.episodes const list = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return { return {
list, list,
pagination: { page: 1, pageSize: list.length, total: list.length }, pagination: { page: 1, pageSize: list.length, total: list.length },
}; };
} }
async listAdminEpisodes( async listAdminEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
dramaId: number,
pagination: PaginationInput,
): Promise<PaginatedResponse<EpisodeRecord>> {
await this.getDramaOrThrow(dramaId); await this.getDramaOrThrow(dramaId);
if (this.hasDatabase()) { if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination); const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const [records, total] = await this.dataSource const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
.getRepository(Episode)
.findAndCount({
where: { dramaId }, where: { dramaId },
order: { episodeNo: 'ASC' }, order: { episodeNo: "ASC" },
skip, skip,
take, take,
}); });
@@ -437,9 +415,7 @@ export class DramasService {
}; };
} }
const records = this.episodes const records = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(records, pagination); return this.paginate(records, pagination);
} }
@@ -448,42 +424,28 @@ export class DramasService {
const repository = this.dataSource.getRepository(Episode); const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id } }); const episode = await repository.findOne({ where: { id } });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
const freeType = const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
dto.freeType ??
(dto.isPaid === undefined
? episode.freeType
: dto.isPaid
? 'paid'
: 'free');
repository.merge(episode, { repository.merge(episode, {
...dto, ...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId, dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo, episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
freeType, freeType,
isPaid: freeType === 'paid', isPaid: freeType === "paid",
price: dto.price ?? dto.unlockPrice ?? episode.price, price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice, unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status, status: dto.status ?? dto.publishStatus ?? episode.status,
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus, publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt, publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt,
nextReleaseAt: dto.nextReleaseAt nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : episode.nextReleaseAt,
? new Date(dto.nextReleaseAt)
: episode.nextReleaseAt,
}); });
const saved = await repository.save(episode); const saved = await repository.save(episode);
return this.toEpisodeRecord(saved); return this.toEpisodeRecord(saved);
} }
const episode = await this.getMemEpisodeOrThrow(id); const episode = await this.getMemEpisodeOrThrow(id);
const freeType = const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
dto.freeType ??
(dto.isPaid === undefined
? episode.freeType
: dto.isPaid
? 'paid'
: 'free');
Object.assign(episode, { Object.assign(episode, {
...dto, ...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId, dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
@@ -491,7 +453,7 @@ export class DramasService {
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo, episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
seq: dto.seq ?? dto.episodeNo ?? episode.seq, seq: dto.seq ?? dto.episodeNo ?? episode.seq,
freeType, freeType,
isPaid: freeType === 'paid', isPaid: freeType === "paid",
price: dto.price ?? dto.unlockPrice ?? episode.price, price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice, unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status, status: dto.status ?? dto.publishStatus ?? episode.status,
@@ -501,47 +463,32 @@ export class DramasService {
return episode; return episode;
} }
async updateDramaEpisode( async updateDramaEpisode(dramaId: number, episodeId: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
dramaId: number,
episodeId: number,
dto: UpdateEpisodeDto,
): Promise<EpisodeRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const episode = await this.dataSource.getRepository(Episode).findOne({ const episode = await this.dataSource.getRepository(Episode).findOne({
where: { id: episodeId, dramaId }, where: { id: episodeId, dramaId },
}); });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
} else { } else {
const episode = await this.getMemEpisodeOrThrow(episodeId); const episode = await this.getMemEpisodeOrThrow(episodeId);
if (episode.dramaId !== dramaId) { if (episode.dramaId !== dramaId) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
} }
return this.updateEpisode(episodeId, { ...dto, dramaId }); return this.updateEpisode(episodeId, { ...dto, dramaId });
} }
async attachEpisodeMedia(input: { async attachEpisodeMedia(input: { episodeId: number; byteplusVid: string; videoUrl: string; storageBucket: string; objectKey: string; durationSeconds: number; width: number; height: number; reviewStatus: EpisodeRecord["reviewStatus"]; reviewMessage?: string }): Promise<EpisodeRecord> {
episodeId: number;
byteplusVid: string;
videoUrl: string;
storageBucket: string;
objectKey: string;
durationSeconds: number;
width: number;
height: number;
reviewStatus: EpisodeRecord['reviewStatus'];
reviewMessage?: string;
}): Promise<EpisodeRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode); const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ const episode = await repository.findOne({
where: { id: input.episodeId }, where: { id: input.episodeId },
}); });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
episode.byteplusVid = input.byteplusVid; episode.byteplusVid = input.byteplusVid;
episode.videoUrl = input.videoUrl; episode.videoUrl = input.videoUrl;
@@ -570,16 +517,12 @@ export class DramasService {
return episode; return episode;
} }
async updateEpisodeReviewStatus( async updateEpisodeReviewStatus(episodeId: number, reviewStatus: EpisodeRecord["reviewStatus"], reviewMessage?: string): Promise<EpisodeRecord> {
episodeId: number,
reviewStatus: EpisodeRecord['reviewStatus'],
reviewMessage?: string,
): Promise<EpisodeRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode); const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id: episodeId } }); const episode = await repository.findOne({ where: { id: episodeId } });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
episode.reviewStatus = reviewStatus; episode.reviewStatus = reviewStatus;
episode.reviewMessage = reviewMessage; episode.reviewMessage = reviewMessage;
@@ -594,83 +537,168 @@ export class DramasService {
return episode; return episode;
} }
async listPublishedDramas( async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
input: PublishedDramaListInput,
): Promise<PaginatedResponse<DramaRecord>> {
const title = input.title?.trim(); const title = input.title?.trim();
if (this.hasDatabase()) { if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input); const { page, pageSize, skip, take } = this.resolvePagination(input);
const [records, total] = await this.dataSource const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
.getRepository(Drama)
.findAndCount({
where: { where: {
status: PublishStatus.Published, status: PublishStatus.Published,
...(title ? { title: Like(`%${title}%`) } : {}), ...(title ? { title: Like(`%${title}%`) } : {}),
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
}, },
order: { sortOrder: 'DESC', id: 'DESC' }, order: { sortOrder: "DESC", id: "DESC" },
skip, skip,
take, take,
}); });
const list = await Promise.all( const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama))));
records.map((drama) => this.toDramaRecordWithCounts(drama)),
);
return { list, pagination: { page, pageSize, total } }; return { list, pagination: { page, pageSize, total } };
} }
return this.paginate( const page = this.paginate(
this.sortDramas( this.sortDramas(
this.dramas this.dramas
.filter((item) => item.status === PublishStatus.Published) .filter((item) => item.status === PublishStatus.Published)
.filter((item) => !title || item.title.includes(title)) .filter((item) => !title || item.title.includes(title))
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
.map((drama) => this.withEpisodeCounts(drama)), .map((drama) => this.withEpisodeCounts(drama)),
), ),
input, input,
); );
return {
...page,
list: page.list.map((drama) => this.toPublicDramaRecord(drama)),
};
}
async listRecommendedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
return this.listPublishedDramas({
...input,
isRecommended: true,
});
}
async presearchVideos(input: VideoPresearchInput): Promise<PaginatedResponse<VideoPresearchRecord>> {
const keyword = input.keyword?.trim();
if (!keyword) {
const { page, pageSize } = this.resolvePagination(input);
return { list: [], pagination: { page, pageSize, total: 0 } };
}
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const likeKeyword = `%${keyword}%`;
const baseQuery = this.dataSource
.getRepository(Episode)
.createQueryBuilder("episode")
.innerJoin(Drama, "drama", "drama.id = episode.dramaId")
.where("drama.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.publishStatus = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.reviewStatus = :approved", {
approved: "approved",
})
.andWhere("(episode.title LIKE :keyword OR episode.description LIKE :keyword OR drama.title LIKE :keyword OR drama.description LIKE :keyword)", { keyword: likeKeyword });
const total = await baseQuery.clone().getCount();
const rows = await baseQuery
.clone()
.select([
"episode.id AS id",
"episode.dramaId AS dramaId",
"episode.episodeNo AS episodeNo",
"episode.title AS title",
"episode.description AS description",
"episode.coverUrl AS coverUrl",
"episode.durationSeconds AS durationSeconds",
"episode.byteplusVid AS byteplusVid",
"episode.videoUrl AS videoUrl",
"episode.freeType AS freeType",
"episode.price AS price",
"drama.title AS dramaTitle",
"drama.coverUrl AS dramaCoverUrl",
])
.orderBy("drama.sortOrder", "DESC")
.addOrderBy("drama.id", "DESC")
.addOrderBy("episode.episodeNo", "ASC")
.offset(skip)
.limit(take)
.getRawMany<VideoPresearchRecord>();
return {
list: rows.map((row) => ({
...row,
id: Number(row.id),
dramaId: Number(row.dramaId),
episodeNo: Number(row.episodeNo),
durationSeconds: Number(row.durationSeconds),
price: Number(row.price),
})),
pagination: { page, pageSize, total },
};
}
const normalizedKeyword = keyword.toLowerCase();
const records = this.episodes
.filter((episode) => episode.status === PublishStatus.Published && episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved")
.map((episode) => {
const drama = this.dramas.find((item) => item.id === episode.dramaId);
return { episode, drama };
})
.filter((item): item is { episode: EpisodeRecord; drama: DramaRecord } => Boolean(item.drama) && item.drama?.status === PublishStatus.Published)
.filter(({ episode, drama }) => [episode.title, episode.description, drama.title, drama.description].some((value) => value?.toLowerCase().includes(normalizedKeyword)))
.sort((left, right) => {
if (right.drama.sortOrder !== left.drama.sortOrder) {
return right.drama.sortOrder - left.drama.sortOrder;
}
if (right.drama.id !== left.drama.id) {
return right.drama.id - left.drama.id;
}
return left.episode.episodeNo - right.episode.episodeNo;
})
.map(({ episode, drama }) => this.toVideoPresearchRecord(episode, drama));
return this.paginate(records, input);
} }
async getPublishedDramaOrThrow(id: number): Promise<DramaRecord> { async getPublishedDramaOrThrow(id: number): Promise<DramaRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const drama = await this.dataSource const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id, status: PublishStatus.Published } });
.getRepository(Drama)
.findOne({ where: { id, status: PublishStatus.Published } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
return this.toDramaRecordWithCounts(drama); return this.toDramaRecordWithCounts(drama);
} }
const drama = this.dramas.find( const drama = this.dramas.find((item) => item.id === id && item.status === PublishStatus.Published);
(item) => item.id === id && item.status === PublishStatus.Published,
);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
return this.withEpisodeCounts(drama); return this.withEpisodeCounts(drama);
} }
async listPublishedEpisodes( async listPublishedEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
dramaId: number,
pagination: PaginationInput,
): Promise<PaginatedResponse<EpisodeRecord>> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const drama = await this.dataSource const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id: dramaId, status: PublishStatus.Published } });
.getRepository(Drama)
.findOne({ where: { id: dramaId, status: PublishStatus.Published } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const { page, pageSize, skip, take } = this.resolvePagination(pagination); const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const [records, total] = await this.dataSource const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
.getRepository(Episode)
.findAndCount({
where: { where: {
dramaId, dramaId,
status: PublishStatus.Published, status: PublishStatus.Published,
publishStatus: PublishStatus.Published, publishStatus: PublishStatus.Published,
reviewStatus: 'approved', reviewStatus: "approved",
}, },
order: { episodeNo: 'ASC' }, order: { episodeNo: "ASC" },
skip, skip,
take, take,
}); });
@@ -682,19 +710,12 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama || drama.status !== PublishStatus.Published) { if (!drama || drama.status !== PublishStatus.Published) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const episodes = this.episodes const episodes = this.episodes
.filter( .filter((item) => item.dramaId === dramaId && item.status === PublishStatus.Published)
(item) => .filter((item) => item.publishStatus === PublishStatus.Published && item.reviewStatus === "approved")
item.dramaId === dramaId && item.status === PublishStatus.Published,
)
.filter(
(item) =>
item.publishStatus === PublishStatus.Published &&
item.reviewStatus === 'approved',
)
.sort((left, right) => left.episodeNo - right.episodeNo); .sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(episodes, pagination); return this.paginate(episodes, pagination);
@@ -707,36 +728,29 @@ export class DramasService {
id: episodeId, id: episodeId,
status: PublishStatus.Published, status: PublishStatus.Published,
publishStatus: PublishStatus.Published, publishStatus: PublishStatus.Published,
reviewStatus: 'approved', reviewStatus: "approved",
}, },
}); });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
const drama = await this.dataSource const drama = await this.dataSource.getRepository(Drama).findOne({
.getRepository(Drama)
.findOne({
where: { id: episode.dramaId, status: PublishStatus.Published }, where: { id: episode.dramaId, status: PublishStatus.Published },
}); });
if (!drama) { if (!drama) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
return this.toEpisodeRecord(episode); return this.toEpisodeRecord(episode);
} }
const episode = this.episodes.find((item) => item.id === episodeId); const episode = this.episodes.find((item) => item.id === episodeId);
if ( if (!episode || episode.status !== PublishStatus.Published || episode.publishStatus !== PublishStatus.Published || episode.reviewStatus !== "approved") {
!episode || throw new NotFoundException("Episode not found");
episode.status !== PublishStatus.Published ||
episode.publishStatus !== PublishStatus.Published ||
episode.reviewStatus !== 'approved'
) {
throw new NotFoundException('Episode not found');
} }
const drama = this.dramas.find((item) => item.id === episode.dramaId); const drama = this.dramas.find((item) => item.id === episode.dramaId);
if (!drama || drama.status !== PublishStatus.Published) { if (!drama || drama.status !== PublishStatus.Published) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
return episode; return episode;
@@ -744,11 +758,9 @@ export class DramasService {
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> { async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const episode = await this.dataSource const episode = await this.dataSource.getRepository(Episode).findOne({ where: { id: episodeId } });
.getRepository(Episode)
.findOne({ where: { id: episodeId } });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
return this.toEpisodeRecord(episode); return this.toEpisodeRecord(episode);
} }
@@ -761,41 +773,39 @@ export class DramasService {
const dramaRepository = this.dataSource.getRepository(Drama); const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: albumId } }); const drama = await dramaRepository.findOne({ where: { id: albumId } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
const episodeRepository = this.dataSource.getRepository(Episode); const episodeRepository = this.dataSource.getRepository(Episode);
const episodes = await episodeRepository.find({ const episodes = await episodeRepository.find({
where: { dramaId: albumId }, where: { dramaId: albumId },
}); });
for (const episode of episodes) { for (const episode of episodes) {
episode.reviewStatus = 'approved'; episode.reviewStatus = "approved";
episode.reviewMessage = 'Mock review approved'; episode.reviewMessage = "Mock review approved";
} }
await episodeRepository.save(episodes); await episodeRepository.save(episodes);
return { albumId, reviewStatus: 'approved' }; return { albumId, reviewStatus: "approved" };
} }
const album = this.dramas.find((item) => item.id === albumId); const album = this.dramas.find((item) => item.id === albumId);
if (!album) { if (!album) {
throw new NotFoundException('Drama not found'); throw new NotFoundException("Drama not found");
} }
this.episodes this.episodes
.filter((episode) => episode.albumId === albumId) .filter((episode) => episode.albumId === albumId)
.forEach((episode) => { .forEach((episode) => {
episode.reviewStatus = 'approved'; episode.reviewStatus = "approved";
episode.reviewMessage = 'Mock review approved'; episode.reviewMessage = "Mock review approved";
episode.updatedAt = new Date().toISOString(); episode.updatedAt = new Date().toISOString();
}); });
return { albumId, reviewStatus: 'approved' }; return { albumId, reviewStatus: "approved" };
} }
async listAllDramas(): Promise<DramaRecord[]> { async listAllDramas(): Promise<DramaRecord[]> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const records = await this.dataSource const records = await this.dataSource.getRepository(Drama).find({ order: { sortOrder: "DESC", id: "DESC" } });
.getRepository(Drama)
.find({ order: { sortOrder: 'DESC', id: 'DESC' } });
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama))); return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
} }
@@ -804,24 +814,19 @@ export class DramasService {
async listAllEpisodes(): Promise<EpisodeRecord[]> { async listAllEpisodes(): Promise<EpisodeRecord[]> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const records = await this.dataSource const records = await this.dataSource.getRepository(Episode).find({ order: { id: "ASC" } });
.getRepository(Episode)
.find({ order: { id: 'ASC' } });
return records.map((episode) => this.toEpisodeRecord(episode)); return records.map((episode) => this.toEpisodeRecord(episode));
} }
return [...this.episodes].sort((left, right) => left.id - right.id); return [...this.episodes].sort((left, right) => left.id - right.id);
} }
async updateEpisodePublishStatus( async updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus): Promise<EpisodeRecord> {
episodeId: number,
publishStatus: PublishStatus,
): Promise<EpisodeRecord> {
if (this.hasDatabase()) { if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode); const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id: episodeId } }); const episode = await repository.findOne({ where: { id: episodeId } });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
episode.publishStatus = publishStatus; episode.publishStatus = publishStatus;
episode.status = publishStatus; episode.status = publishStatus;
@@ -843,7 +848,7 @@ export class DramasService {
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord { private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
const episode = this.episodes.find((item) => item.id === episodeId); const episode = this.episodes.find((item) => item.id === episodeId);
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException("Episode not found");
} }
return episode; return episode;
@@ -857,17 +862,13 @@ export class DramasService {
where: { where: {
dramaId, dramaId,
publishStatus: PublishStatus.Published, publishStatus: PublishStatus.Published,
reviewStatus: 'approved', reviewStatus: "approved",
}, },
}); });
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes); return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
} }
private toDramaRecord( private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
entity: Drama,
totalEpisodes: number,
publishedEpisodes: number,
): DramaRecord {
return { return {
id: Number(entity.id), id: Number(entity.id),
tiktokAlbumId: entity.tiktokAlbumId, tiktokAlbumId: entity.tiktokAlbumId,
@@ -893,6 +894,31 @@ export class DramasService {
}; };
} }
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
void portraitCoverUrl;
void landscapeCoverUrl;
return publicRecord;
}
private toVideoPresearchRecord(episode: EpisodeRecord, drama: DramaRecord): VideoPresearchRecord {
return {
id: episode.id,
dramaId: episode.dramaId,
episodeNo: episode.episodeNo,
title: episode.title,
description: episode.description,
coverUrl: episode.coverUrl,
durationSeconds: episode.durationSeconds,
byteplusVid: episode.byteplusVid,
videoUrl: episode.videoUrl,
freeType: episode.freeType,
price: episode.price,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private toEpisodeRecord(entity: Episode): EpisodeRecord { private toEpisodeRecord(entity: Episode): EpisodeRecord {
const dramaId = Number(entity.dramaId); const dramaId = Number(entity.dramaId);
return { return {
@@ -964,18 +990,11 @@ export class DramasService {
return { return {
...drama, ...drama,
totalEpisodes: episodes.length, totalEpisodes: episodes.length,
publishedEpisodes: episodes.filter( publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
(episode) =>
episode.publishStatus === PublishStatus.Published &&
episode.reviewStatus === 'approved',
).length,
}; };
} }
private paginate<T>( private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const page = Math.max(input.page, 1); const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpsertPlaybackHistoryDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsInt()
@Min(0)
progressSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
completed?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
occurredAt?: string;
}

View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_playback_histories')
@Index(['userId', 'episodeId'], { unique: true })
@Index(['userId', 'lastPlayedAt'])
export class UserPlaybackHistory {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint' })
userId: number;
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'int', default: 0 })
progressSeconds: number;
@Column({ type: 'int', default: 0 })
durationSeconds: number;
@Column({ type: 'boolean', default: false })
completed: boolean;
@Column({ type: 'datetime' })
lastPlayedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,27 @@
export interface PlaybackHistoryRecord {
id: number;
userId: number;
dramaId: number;
episodeId: number;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
createdAt: string;
updatedAt: string;
}
export interface PlaybackHistoryListItem {
id: number;
dramaId: number;
episodeId: number;
episodeNo: number;
title: string;
coverUrl: string;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
dramaTitle: string;
dramaCoverUrl: string;
}

View File

@@ -212,6 +212,8 @@ describe('Dramas and episodes', () => {
.send({ .send({
title: 'Published Drama', title: 'Published Drama',
coverUrl: 'https://cdn.example.com/pub.jpg', coverUrl: 'https://cdn.example.com/pub.jpg',
portraitCoverUrl: 'https://cdn.example.com/pub-portrait.jpg',
landscapeCoverUrl: 'https://cdn.example.com/pub-landscape.jpg',
type: 'action', type: 'action',
status: 'published', status: 'published',
}) })
@@ -265,6 +267,11 @@ describe('Dramas and episodes', () => {
expect.objectContaining({ title: 'Published Drama' }), expect.objectContaining({ title: 'Published Drama' }),
]), ]),
); );
const publishedDrama = dramas.body.data.list.find(
(item: { title: string }) => item.title === 'Published Drama',
);
expect(publishedDrama).not.toHaveProperty('portraitCoverUrl');
expect(publishedDrama).not.toHaveProperty('landscapeCoverUrl');
expect(dramas.body.data.list).not.toEqual( expect(dramas.body.data.list).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]), expect.arrayContaining([expect.objectContaining({ title: 'Draft Drama' })]),
); );
@@ -321,4 +328,178 @@ describe('Dramas and episodes', () => {
]), ]),
); );
}); });
it('filters admin dramas by title and recommended status', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Search Drama',
coverUrl: 'https://cdn.example.com/recommended-search.jpg',
type: 'romance',
status: 'draft',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Recommended Hidden Drama',
coverUrl: 'https://cdn.example.com/recommended-hidden.jpg',
type: 'romance',
status: 'draft',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.query({ title: 'Recommended', isRecommended: 'true' })
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Recommended Search Drama',
isRecommended: true,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Recommended Hidden Drama' }),
]),
);
});
it('returns a published recommended drama list to app APIs', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Recommended Drama',
coverUrl: 'https://cdn.example.com/published-recommended.jpg',
type: 'romance',
status: 'published',
isRecommended: true,
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Published Normal Drama',
coverUrl: 'https://cdn.example.com/published-normal.jpg',
type: 'romance',
status: 'published',
isRecommended: false,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/recommended')
.expect(200);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Published Recommended Drama',
isRecommended: true,
}),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Published Normal Drama' }),
]),
);
});
it('presearches published approved videos by keyword for mini app clients', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Skyline Keyword Saga',
description: 'A searchable drama description.',
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
description: 'The first skyline episode.',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 2,
title: 'Hidden Draft Video',
description: 'Contains skyline but is not published.',
coverUrl: 'https://cdn.example.com/skyline-ep2.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep2.mp4',
byteplusVid: 'vid_skyline_002',
durationSeconds: 180,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'approved',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: 'skyline' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
dramaTitle: 'Skyline Keyword Saga',
dramaCoverUrl: 'https://cdn.example.com/skyline-drama.jpg',
}),
]);
const emptyKeyword = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')
.query({ keyword: ' ' })
.expect(200);
expect(emptyKeyword.body.data).toEqual({
list: [],
pagination: { page: 1, pageSize: 10, total: 0 },
});
});
}); });

View File

@@ -128,4 +128,164 @@ describe('Auth, orders, and playback', () => {
videoUrl: 'https://cdn.example.com/paid-ep.mp4', videoUrl: 'https://cdn.example.com/paid-ep.mp4',
}); });
}); });
it('records, updates, and isolates mini app playback history', async () => {
const firstLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-1',
mockOpenId: 'history-user-1',
nickname: 'History User 1',
})
.expect(201);
const firstToken = firstLogin.body.data.accessToken;
const secondLogin = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-2',
mockOpenId: 'history-user-2',
nickname: 'History User 2',
})
.expect(201);
const secondToken = secondLogin.body.data.accessToken;
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'History Drama',
coverUrl: 'https://cdn.example.com/history-drama.jpg',
type: 'history',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
videoUrl: 'https://cdn.example.com/history-episode.mp4',
durationSeconds: 300,
status: 'published',
publishStatus: 'published',
reviewStatus: 'approved',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/playback-history')
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(401);
await request(app.getHttpServer())
.post('/api/app/v1/playback-history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 86,
completed: false,
occurredAt: '2026-07-02T10:00:00.000Z',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/playback-history')
.set('Authorization', `Bearer ${firstToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 301,
completed: false,
occurredAt: '2026-07-02T10:05:00.000Z',
})
.expect(201);
const firstHistory = await request(app.getHttpServer())
.get('/api/app/v1/playback-history')
.set('Authorization', `Bearer ${firstToken}`)
.expect(200);
expect(firstHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 1 },
list: [
{
dramaId: drama.body.data.id,
episodeId: episode.body.data.id,
episodeNo: 1,
title: 'History Episode',
coverUrl: 'https://cdn.example.com/history-episode.jpg',
progressSeconds: 300,
durationSeconds: 300,
completed: true,
lastPlayedAt: '2026-07-02T10:05:00.000Z',
dramaTitle: 'History Drama',
dramaCoverUrl: 'https://cdn.example.com/history-drama.jpg',
},
],
});
const secondHistory = await request(app.getHttpServer())
.get('/api/app/v1/playback-history')
.set('Authorization', `Bearer ${secondToken}`)
.expect(200);
expect(secondHistory.body.data).toMatchObject({
pagination: { page: 1, pageSize: 20, total: 0 },
list: [],
});
});
it('rejects playback history for unavailable episodes', async () => {
const login = await request(app.getHttpServer())
.post('/api/app/v1/auth/tiktok-login')
.send({
code: 'history-user-code-3',
mockOpenId: 'history-user-3',
})
.expect(201);
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Draft History Drama',
coverUrl: 'https://cdn.example.com/draft-history-drama.jpg',
type: 'history',
status: 'published',
})
.expect(201);
const episode = await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Draft History Episode',
coverUrl: 'https://cdn.example.com/draft-history-episode.jpg',
durationSeconds: 120,
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'not_submitted',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/app/v1/playback-history')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 10,
})
.expect(404);
});
}); });