Compare commits

..

3 Commits

Author SHA1 Message Date
61a00286fb refactor(history): 重构播放历史模块,统一命名并迁移代码
将原playback-history模块重命名为history模块,统一所有相关文件的命名空间,移除冗余的重复代码,更新全局模块引用和测试用例接口路径
2026-07-02 17:28:57 +08:00
79aeac7f37 feat(playback-history): 添加播放历史模块功能
实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息
2026-07-02 17:28:51 +08:00
fca247c3f2 feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能
1.  为后台剧集列表添加标题和推荐状态筛选参数
2.  新增前台推荐剧集列表接口和前置搜索接口
3.  添加可选布尔值解析工具方法
4.  新增对应接口的端到端测试用例
2026-07-02 17:26:08 +08:00
12 changed files with 1698 additions and 933 deletions

View File

@@ -10,6 +10,7 @@ import { LogsModule } from "./modules/logs/logs.module";
import { MediaModule } from "./modules/media/media.module";
import { OrdersModule } from "./modules/orders/orders.module";
import { PlayerModule } from "./modules/player/player.module";
import { HistoryModule } from "./modules/history/history.module";
@Module({
imports: [
@@ -26,6 +27,7 @@ import { PlayerModule } from "./modules/player/player.module";
AuthModule,
OrdersModule,
PlayerModule,
HistoryModule,
],
})
export class AppModule {}

View File

@@ -14,6 +14,7 @@ import { Cover } from '../media/entities/cover.entity';
import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
import { Order } from '../orders/entities/order.entity';
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
import { UserPlaybackHistory } from '../history/entities/user-playback-history.entity';
import { User } from '../users/entities/user.entity';
const databaseImports = process.env.DB_HOST
@@ -40,6 +41,7 @@ const databaseImports = process.env.DB_HOST
MediaUploadJob,
Order,
UserEpisodeUnlock,
UserPlaybackHistory,
User,
],
synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -28,10 +28,12 @@ export class DramasController {
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@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({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
title,
isRecommended: this.parseOptionalBoolean(isRecommended),
});
}
@@ -111,11 +113,27 @@ export class DramasController {
}
@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({
page: Number(page) || 1,
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) {
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 {
ConflictException,
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, Like } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
import { CreateDramaDto } from './dto/create-drama.dto';
import { CreateEpisodeDto } from './dto/create-episode.dto';
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';
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, Like } from "typeorm";
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
import { CreateDramaDto } from "./dto/create-drama.dto";
import { CreateEpisodeDto } from "./dto/create-episode.dto";
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 {
page: number;
pageSize: number;
}
interface PublishedDramaListInput extends PaginationInput {
interface DramaListInput extends PaginationInput {
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()
@@ -108,44 +124,46 @@ export class DramasService {
return drama;
}
async listAdminDramas(
pagination: PaginationInput,
): Promise<PaginatedResponse<DramaRecord>> {
async listAdminDramas(input: DramaListInput): Promise<PaginatedResponse<DramaRecord>> {
const title = input.title?.trim();
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const [records, total] = await this.dataSource
.getRepository(Drama)
.findAndCount({
order: { sortOrder: 'DESC', id: 'DESC' },
const { page, pageSize, skip, take } = this.resolvePagination(input);
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
...(title ? { title: Like(`%${title}%`) } : {}),
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
},
order: { sortOrder: "DESC", id: "DESC" },
skip,
take,
});
const list = await Promise.all(
records.map((drama) => this.toDramaRecordWithCounts(drama)),
);
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
return { list, pagination: { page, pageSize, total } };
}
return this.paginate(
this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))),
pagination,
this.sortDramas(
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> {
if (this.hasDatabase()) {
const drama = await this.dataSource
.getRepository(Drama)
.findOne({ where: { id } });
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
return this.toDramaRecordWithCounts(drama);
}
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
return this.withEpisodeCounts(drama);
@@ -157,7 +175,7 @@ export class DramasService {
const repository = this.dataSource.getRepository(Drama);
const drama = await repository.findOne({ where: { id } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
repository.merge(drama, {
...dramaUpdates,
@@ -174,7 +192,7 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
Object.assign(drama, {
@@ -192,21 +210,19 @@ export class DramasService {
const dramaId = dto.dramaId ?? dto.albumId;
const episodeNo = dto.episodeNo ?? dto.seq;
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 publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
const reviewStatus =
dto.reviewStatus ??
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted');
const reviewStatus = dto.reviewStatus ?? (publishStatus === PublishStatus.Published ? "approved" : "not_submitted");
if (this.hasDatabase()) {
const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const episodeRepository = this.dataSource.getRepository(Episode);
@@ -214,9 +230,7 @@ export class DramasService {
where: { dramaId, episodeNo },
});
if (duplicate) {
throw new ConflictException(
'Episode number already exists in this drama',
);
throw new ConflictException("Episode number already exists in this drama");
}
const entity = episodeRepository.create({
@@ -227,13 +241,13 @@ export class DramasService {
description: dto.description,
byteplusVid: dto.byteplusVid,
coverId: dto.coverId,
coverUrl: dto.coverUrl ?? '',
coverUrl: dto.coverUrl ?? "",
videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds ?? 0,
width: dto.width,
height: dto.height,
isPaid: freeType === 'paid',
isPaid: freeType === "paid",
freeType,
unlockPrice: price,
price,
@@ -249,14 +263,12 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const duplicate = this.episodes.some(
(item) => item.dramaId === dramaId && item.episodeNo === episodeNo,
);
const duplicate = this.episodes.some((item) => item.dramaId === dramaId && item.episodeNo === episodeNo);
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();
@@ -271,13 +283,13 @@ export class DramasService {
description: dto.description,
byteplusVid: dto.byteplusVid,
coverId: dto.coverId,
coverUrl: dto.coverUrl ?? '',
coverUrl: dto.coverUrl ?? "",
videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds ?? 0,
width: dto.width,
height: dto.height,
isPaid: freeType === 'paid',
isPaid: freeType === "paid",
freeType,
unlockPrice: price,
price,
@@ -294,53 +306,39 @@ export class DramasService {
return episode;
}
async configureEpisodes(
dramaId: number,
dto: ConfigureEpisodesDto,
): Promise<PaginatedResponse<EpisodeRecord>> {
async configureEpisodes(dramaId: number, dto: ConfigureEpisodesDto): Promise<PaginatedResponse<EpisodeRecord>> {
if (this.hasDatabase()) {
const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: dramaId } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const episodeRepository = this.dataSource.getRepository(Episode);
const existing = await episodeRepository.find({
where: { dramaId },
order: { episodeNo: 'ASC' },
order: { episodeNo: "ASC" },
});
if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some(
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
if (hasLockedEpisode) {
throw new UnprocessableEntityException(
'Only empty draft tail episodes can be removed',
);
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
}
await episodeRepository.remove(removable);
}
for (
let episodeNo = existing.length + 1;
episodeNo <= dto.episodeCount;
episodeNo++
) {
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
await this.createEpisode({
dramaId,
episodeNo,
title: `${episodeNo}`,
coverUrl: drama.coverUrl,
durationSeconds: 0,
freeType: 'free',
freeType: "free",
price: 0,
reviewStatus: 'not_submitted',
reviewStatus: "not_submitted",
publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft,
});
@@ -348,7 +346,7 @@ export class DramasService {
const list = await episodeRepository.find({
where: { dramaId },
order: { episodeNo: 'ASC' },
order: { episodeNo: "ASC" },
});
const records = list.map((episode) => this.toEpisodeRecord(episode));
return {
@@ -359,24 +357,15 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const existing = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
const existing = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some(
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
const hasLockedEpisode = removable.some((episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== "not_submitted");
if (hasLockedEpisode) {
throw new UnprocessableEntityException(
'Only empty draft tail episodes can be removed',
);
throw new UnprocessableEntityException("Only empty draft tail episodes can be removed");
}
for (const episode of removable) {
@@ -387,47 +376,36 @@ export class DramasService {
}
}
for (
let episodeNo = existing.length + 1;
episodeNo <= dto.episodeCount;
episodeNo++
) {
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
await this.createEpisode({
dramaId,
episodeNo,
title: `${episodeNo}`,
coverUrl: drama.coverUrl,
durationSeconds: 0,
freeType: 'free',
freeType: "free",
price: 0,
reviewStatus: 'not_submitted',
reviewStatus: "not_submitted",
publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft,
});
}
const list = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
const list = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
return {
list,
pagination: { page: 1, pageSize: list.length, total: list.length },
};
}
async listAdminEpisodes(
dramaId: number,
pagination: PaginationInput,
): Promise<PaginatedResponse<EpisodeRecord>> {
async listAdminEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
await this.getDramaOrThrow(dramaId);
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const [records, total] = await this.dataSource
.getRepository(Episode)
.findAndCount({
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
where: { dramaId },
order: { episodeNo: 'ASC' },
order: { episodeNo: "ASC" },
skip,
take,
});
@@ -437,9 +415,7 @@ export class DramasService {
};
}
const records = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
const records = this.episodes.filter((episode) => episode.dramaId === dramaId).sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(records, pagination);
}
@@ -448,42 +424,28 @@ export class DramasService {
const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id } });
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
const freeType =
dto.freeType ??
(dto.isPaid === undefined
? episode.freeType
: dto.isPaid
? 'paid'
: 'free');
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
repository.merge(episode, {
...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
freeType,
isPaid: freeType === 'paid',
isPaid: freeType === "paid",
price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status,
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt,
nextReleaseAt: dto.nextReleaseAt
? new Date(dto.nextReleaseAt)
: episode.nextReleaseAt,
nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : episode.nextReleaseAt,
});
const saved = await repository.save(episode);
return this.toEpisodeRecord(saved);
}
const episode = await this.getMemEpisodeOrThrow(id);
const freeType =
dto.freeType ??
(dto.isPaid === undefined
? episode.freeType
: dto.isPaid
? 'paid'
: 'free');
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? "paid" : "free");
Object.assign(episode, {
...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
@@ -491,7 +453,7 @@ export class DramasService {
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
freeType,
isPaid: freeType === 'paid',
isPaid: freeType === "paid",
price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status,
@@ -501,47 +463,32 @@ export class DramasService {
return episode;
}
async updateDramaEpisode(
dramaId: number,
episodeId: number,
dto: UpdateEpisodeDto,
): Promise<EpisodeRecord> {
async updateDramaEpisode(dramaId: number, episodeId: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
if (this.hasDatabase()) {
const episode = await this.dataSource.getRepository(Episode).findOne({
where: { id: episodeId, dramaId },
});
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
} else {
const episode = await this.getMemEpisodeOrThrow(episodeId);
if (episode.dramaId !== dramaId) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
}
return this.updateEpisode(episodeId, { ...dto, dramaId });
}
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> {
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> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({
where: { id: input.episodeId },
});
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
episode.byteplusVid = input.byteplusVid;
episode.videoUrl = input.videoUrl;
@@ -570,16 +517,12 @@ export class DramasService {
return episode;
}
async updateEpisodeReviewStatus(
episodeId: number,
reviewStatus: EpisodeRecord['reviewStatus'],
reviewMessage?: string,
): Promise<EpisodeRecord> {
async updateEpisodeReviewStatus(episodeId: number, reviewStatus: EpisodeRecord["reviewStatus"], reviewMessage?: string): Promise<EpisodeRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id: episodeId } });
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
episode.reviewStatus = reviewStatus;
episode.reviewMessage = reviewMessage;
@@ -594,83 +537,168 @@ export class DramasService {
return episode;
}
async listPublishedDramas(
input: PublishedDramaListInput,
): Promise<PaginatedResponse<DramaRecord>> {
async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const title = input.title?.trim();
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const [records, total] = await this.dataSource
.getRepository(Drama)
.findAndCount({
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
status: PublishStatus.Published,
...(title ? { title: Like(`%${title}%`) } : {}),
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
},
order: { sortOrder: 'DESC', id: 'DESC' },
order: { sortOrder: "DESC", id: "DESC" },
skip,
take,
});
const list = await Promise.all(
records.map((drama) => this.toDramaRecordWithCounts(drama)),
);
const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama))));
return { list, pagination: { page, pageSize, total } };
}
return this.paginate(
const page = this.paginate(
this.sortDramas(
this.dramas
.filter((item) => item.status === PublishStatus.Published)
.filter((item) => !title || item.title.includes(title))
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
.map((drama) => this.withEpisodeCounts(drama)),
),
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> {
if (this.hasDatabase()) {
const drama = await this.dataSource
.getRepository(Drama)
.findOne({ where: { id, status: PublishStatus.Published } });
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id, status: PublishStatus.Published } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
return this.toDramaRecordWithCounts(drama);
}
const drama = this.dramas.find(
(item) => item.id === id && item.status === PublishStatus.Published,
);
const drama = this.dramas.find((item) => item.id === id && item.status === PublishStatus.Published);
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
return this.withEpisodeCounts(drama);
}
async listPublishedEpisodes(
dramaId: number,
pagination: PaginationInput,
): Promise<PaginatedResponse<EpisodeRecord>> {
async listPublishedEpisodes(dramaId: number, pagination: PaginationInput): Promise<PaginatedResponse<EpisodeRecord>> {
if (this.hasDatabase()) {
const drama = await this.dataSource
.getRepository(Drama)
.findOne({ where: { id: dramaId, status: PublishStatus.Published } });
const drama = await this.dataSource.getRepository(Drama).findOne({ where: { id: dramaId, status: PublishStatus.Published } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const [records, total] = await this.dataSource
.getRepository(Episode)
.findAndCount({
const [records, total] = await this.dataSource.getRepository(Episode).findAndCount({
where: {
dramaId,
status: PublishStatus.Published,
publishStatus: PublishStatus.Published,
reviewStatus: 'approved',
reviewStatus: "approved",
},
order: { episodeNo: 'ASC' },
order: { episodeNo: "ASC" },
skip,
take,
});
@@ -682,19 +710,12 @@ export class DramasService {
const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama || drama.status !== PublishStatus.Published) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const episodes = this.episodes
.filter(
(item) =>
item.dramaId === dramaId && item.status === PublishStatus.Published,
)
.filter(
(item) =>
item.publishStatus === PublishStatus.Published &&
item.reviewStatus === 'approved',
)
.filter((item) => item.dramaId === dramaId && item.status === PublishStatus.Published)
.filter((item) => item.publishStatus === PublishStatus.Published && item.reviewStatus === "approved")
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(episodes, pagination);
@@ -707,36 +728,29 @@ export class DramasService {
id: episodeId,
status: PublishStatus.Published,
publishStatus: PublishStatus.Published,
reviewStatus: 'approved',
reviewStatus: "approved",
},
});
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
const drama = await this.dataSource
.getRepository(Drama)
.findOne({
const drama = await this.dataSource.getRepository(Drama).findOne({
where: { id: episode.dramaId, status: PublishStatus.Published },
});
if (!drama) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
return this.toEpisodeRecord(episode);
}
const episode = this.episodes.find((item) => item.id === episodeId);
if (
!episode ||
episode.status !== PublishStatus.Published ||
episode.publishStatus !== PublishStatus.Published ||
episode.reviewStatus !== 'approved'
) {
throw new NotFoundException('Episode not found');
if (!episode || 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);
if (!drama || drama.status !== PublishStatus.Published) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
return episode;
@@ -744,11 +758,9 @@ export class DramasService {
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
if (this.hasDatabase()) {
const episode = await this.dataSource
.getRepository(Episode)
.findOne({ where: { id: episodeId } });
const episode = await this.dataSource.getRepository(Episode).findOne({ where: { id: episodeId } });
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
return this.toEpisodeRecord(episode);
}
@@ -761,41 +773,39 @@ export class DramasService {
const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id: albumId } });
if (!drama) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
const episodeRepository = this.dataSource.getRepository(Episode);
const episodes = await episodeRepository.find({
where: { dramaId: albumId },
});
for (const episode of episodes) {
episode.reviewStatus = 'approved';
episode.reviewMessage = 'Mock review approved';
episode.reviewStatus = "approved";
episode.reviewMessage = "Mock review approved";
}
await episodeRepository.save(episodes);
return { albumId, reviewStatus: 'approved' };
return { albumId, reviewStatus: "approved" };
}
const album = this.dramas.find((item) => item.id === albumId);
if (!album) {
throw new NotFoundException('Drama not found');
throw new NotFoundException("Drama not found");
}
this.episodes
.filter((episode) => episode.albumId === albumId)
.forEach((episode) => {
episode.reviewStatus = 'approved';
episode.reviewMessage = 'Mock review approved';
episode.reviewStatus = "approved";
episode.reviewMessage = "Mock review approved";
episode.updatedAt = new Date().toISOString();
});
return { albumId, reviewStatus: 'approved' };
return { albumId, reviewStatus: "approved" };
}
async listAllDramas(): Promise<DramaRecord[]> {
if (this.hasDatabase()) {
const records = await this.dataSource
.getRepository(Drama)
.find({ order: { sortOrder: 'DESC', id: 'DESC' } });
const records = await this.dataSource.getRepository(Drama).find({ order: { sortOrder: "DESC", id: "DESC" } });
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
}
@@ -804,24 +814,19 @@ export class DramasService {
async listAllEpisodes(): Promise<EpisodeRecord[]> {
if (this.hasDatabase()) {
const records = await this.dataSource
.getRepository(Episode)
.find({ order: { id: 'ASC' } });
const records = await this.dataSource.getRepository(Episode).find({ order: { id: "ASC" } });
return records.map((episode) => this.toEpisodeRecord(episode));
}
return [...this.episodes].sort((left, right) => left.id - right.id);
}
async updateEpisodePublishStatus(
episodeId: number,
publishStatus: PublishStatus,
): Promise<EpisodeRecord> {
async updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus): Promise<EpisodeRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Episode);
const episode = await repository.findOne({ where: { id: episodeId } });
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
episode.publishStatus = publishStatus;
episode.status = publishStatus;
@@ -843,7 +848,7 @@ export class DramasService {
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
const episode = this.episodes.find((item) => item.id === episodeId);
if (!episode) {
throw new NotFoundException('Episode not found');
throw new NotFoundException("Episode not found");
}
return episode;
@@ -857,17 +862,13 @@ export class DramasService {
where: {
dramaId,
publishStatus: PublishStatus.Published,
reviewStatus: 'approved',
reviewStatus: "approved",
},
});
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
}
private toDramaRecord(
entity: Drama,
totalEpisodes: number,
publishedEpisodes: number,
): DramaRecord {
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
return {
id: Number(entity.id),
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 {
const dramaId = Number(entity.dramaId);
return {
@@ -964,18 +990,11 @@ export class DramasService {
return {
...drama,
totalEpisodes: episodes.length,
publishedEpisodes: episodes.filter(
(episode) =>
episode.publishStatus === PublishStatus.Published &&
episode.reviewStatus === 'approved',
).length,
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
};
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
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,37 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { HistoryService } from './history.service';
@ApiTags('History')
@Controller('/api/app/v1/history')
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
@Post()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
upsertHistory(
@CurrentUser() user: UserRecord,
@Body() dto: UpsertPlaybackHistoryDto,
) {
return this.historyService.upsertHistory(user, dto);
}
@Get()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
listHistory(
@CurrentUser() user: UserRecord,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.historyService.listHistory(user, {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module';
import { UsersModule } from '../users/users.module';
import { HistoryController } from './history.controller';
import { HistoryService } from './history.service';
@Module({
imports: [AuthModule, DramasModule, UsersModule],
controllers: [HistoryController],
providers: [HistoryService],
})
export class HistoryModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import {
DramaRecord,
EpisodeRecord,
} from '../dramas/interfaces/drama-records.interface';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
import {
PlaybackHistoryListItem,
PlaybackHistoryRecord,
} from './interfaces/history-record.interface';
interface PaginationInput {
page: number;
pageSize: number;
}
@Injectable()
export class HistoryService {
private historySequence = 1;
private readonly histories: PlaybackHistoryRecord[] = [];
constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async upsertHistory(
user: UserRecord,
dto: UpsertPlaybackHistoryDto,
): Promise<PlaybackHistoryRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
const progressSeconds = this.resolveProgress(
dto.progressSeconds,
episode.durationSeconds,
);
const completed =
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
const lastPlayedAt = dto.occurredAt
? new Date(dto.occurredAt)
: new Date();
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const existing = await repository.findOne({
where: { userId: user.id, episodeId: episode.id },
});
const entity =
existing ??
repository.create({
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
});
entity.dramaId = episode.dramaId;
entity.progressSeconds = progressSeconds;
entity.durationSeconds = episode.durationSeconds;
entity.completed = completed;
entity.lastPlayedAt = lastPlayedAt;
return this.toRecord(await repository.save(entity));
}
const now = new Date().toISOString();
const existing = this.histories.find(
(item) => item.userId === user.id && item.episodeId === episode.id,
);
if (existing) {
existing.dramaId = episode.dramaId;
existing.progressSeconds = progressSeconds;
existing.durationSeconds = episode.durationSeconds;
existing.completed = completed;
existing.lastPlayedAt = lastPlayedAt.toISOString();
existing.updatedAt = now;
return existing;
}
const record: PlaybackHistoryRecord = {
id: this.historySequence++,
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
progressSeconds,
durationSeconds: episode.durationSeconds,
completed,
lastPlayedAt: lastPlayedAt.toISOString(),
createdAt: now,
updatedAt: now,
};
this.histories.push(record);
return record;
}
async listHistory(
user: UserRecord,
pagination: PaginationInput,
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const [records, total] = await repository.findAndCount({
where: { userId: user.id },
order: { lastPlayedAt: 'DESC', id: 'DESC' },
skip,
take,
});
const list = await Promise.all(
records.map((record) => this.toListItem(this.toRecord(record))),
);
return { list, pagination: { page, pageSize, total } };
}
const page = this.paginate(
this.histories
.filter((item) => item.userId === user.id)
.sort((left, right) => {
const timeDiff =
new Date(right.lastPlayedAt).getTime() -
new Date(left.lastPlayedAt).getTime();
return timeDiff || right.id - left.id;
}),
pagination,
);
return {
...page,
list: await Promise.all(
page.list.map((record) => this.toListItem(record)),
),
};
}
private async toListItem(
record: PlaybackHistoryRecord,
): Promise<PlaybackHistoryListItem> {
const [drama, episode] = await Promise.all([
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
]);
return this.mergeListItem(record, drama, episode);
}
private mergeListItem(
record: PlaybackHistoryRecord,
drama: DramaRecord,
episode: EpisodeRecord,
): PlaybackHistoryListItem {
return {
id: record.id,
dramaId: record.dramaId,
episodeId: record.episodeId,
episodeNo: episode.episodeNo,
title: episode.title,
coverUrl: episode.coverUrl,
progressSeconds: record.progressSeconds,
durationSeconds: record.durationSeconds,
completed: record.completed,
lastPlayedAt: record.lastPlayedAt,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private resolveProgress(progressSeconds: number, durationSeconds: number) {
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
return {
id: Number(entity.id),
userId: Number(entity.userId),
dramaId: Number(entity.dramaId),
episodeId: Number(entity.episodeId),
progressSeconds: entity.progressSeconds,
durationSeconds: entity.durationSeconds,
completed: entity.completed,
lastPlayedAt: this.toIso(entity.lastPlayedAt),
createdAt: this.toIso(entity.createdAt),
updatedAt: this.toIso(entity.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private resolvePagination(input: PaginationInput) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const { page, pageSize, skip, take } = this.resolvePagination(input);
return {
list: records.slice(skip, skip + take),
pagination: { page, pageSize, total: records.length },
};
}
}

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({
title: 'Published Drama',
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',
status: 'published',
})
@@ -265,6 +267,11 @@ describe('Dramas and episodes', () => {
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.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',
});
});
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/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/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/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/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/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/history')
.set('Authorization', `Bearer ${login.body.data.accessToken}`)
.send({
episodeId: episode.body.data.id,
progressSeconds: 10,
})
.expect(404);
});
});