refactor: 重构项目支持TypeORM数据库持久化
新增数据库实体类与服务实现,将内存存储扩展为支持数据库的完整实现,包括: 1. 新增Drama和Episode实体类,添加多个业务字段 2. 重构DramasService实现数据库CRUD与业务逻辑 3. 升级相关服务类为异步方法适配数据库操作 4. 更新测试用例适配异步接口变更 5. 完善分页、数据转换等辅助工具方法
This commit is contained in:
@@ -70,13 +70,13 @@ export class DataService {
|
||||
|
||||
async sync(input: SyncInput = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const dramas = this.dramasService
|
||||
.listAllDramas()
|
||||
.filter((drama) => !input.dramaId || drama.id === input.dramaId);
|
||||
const dramas = (await this.dramasService.listAllDramas()).filter(
|
||||
(drama) => !input.dramaId || drama.id === input.dramaId,
|
||||
);
|
||||
const dramaIds = new Set(dramas.map((drama) => drama.id));
|
||||
const episodes = this.dramasService
|
||||
.listAllEpisodes()
|
||||
.filter((episode) => dramaIds.has(episode.dramaId));
|
||||
const episodes = (await this.dramasService.listAllEpisodes()).filter(
|
||||
(episode) => dramaIds.has(episode.dramaId),
|
||||
);
|
||||
|
||||
const dramaRecords = dramas.map((drama) => ({
|
||||
dramaId: drama.id,
|
||||
|
||||
@@ -2,14 +2,19 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } 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,
|
||||
@@ -28,7 +33,37 @@ export class DramasService {
|
||||
private readonly dramas: DramaRecord[] = [];
|
||||
private readonly episodes: EpisodeRecord[] = [];
|
||||
|
||||
createDrama(dto: CreateDramaDto): DramaRecord {
|
||||
constructor(
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Drama);
|
||||
const entity = repository.create({
|
||||
tiktokAlbumId: dto.tiktokAlbumId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
coverId: dto.coverId,
|
||||
coverUrl: dto.coverUrl,
|
||||
portraitCoverUrl: dto.portraitCoverUrl,
|
||||
landscapeCoverUrl: dto.landscapeCoverUrl,
|
||||
type: dto.type,
|
||||
tags: dto.tags ?? [],
|
||||
region: dto.region,
|
||||
language: dto.language,
|
||||
year: dto.year,
|
||||
status: dto.status ?? PublishStatus.Draft,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
isRecommended: dto.isRecommended ?? false,
|
||||
onlineVersion: dto.onlineVersion ?? 1,
|
||||
});
|
||||
const saved = await repository.save(entity);
|
||||
return this.toDramaRecord(saved, 0, 0);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const drama: DramaRecord = {
|
||||
id: this.dramaSequence++,
|
||||
@@ -58,11 +93,41 @@ export class DramasService {
|
||||
return drama;
|
||||
}
|
||||
|
||||
listAdminDramas(pagination: PaginationInput): PaginatedResponse<DramaRecord> {
|
||||
return this.paginate(this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination);
|
||||
async listAdminDramas(
|
||||
pagination: PaginationInput,
|
||||
): Promise<PaginatedResponse<DramaRecord>> {
|
||||
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' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
async getDramaOrThrow(id: number): Promise<DramaRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const drama = await this.dataSource
|
||||
.getRepository(Drama)
|
||||
.findOne({ where: { id } });
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
return this.toDramaRecordWithCounts(drama);
|
||||
}
|
||||
|
||||
getDramaOrThrow(id: number): DramaRecord {
|
||||
const drama = this.dramas.find((item) => item.id === id);
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
@@ -71,7 +136,18 @@ export class DramasService {
|
||||
return this.withEpisodeCounts(drama);
|
||||
}
|
||||
|
||||
updateDrama(id: number, dto: UpdateDramaDto): DramaRecord {
|
||||
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Drama);
|
||||
const drama = await repository.findOne({ where: { id } });
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
repository.merge(drama, { ...dto, tags: dto.tags ?? drama.tags });
|
||||
const saved = await repository.save(drama);
|
||||
return this.toDramaRecordWithCounts(saved);
|
||||
}
|
||||
|
||||
const drama = this.dramas.find((item) => item.id === id);
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
@@ -85,30 +161,78 @@ export class DramasService {
|
||||
return this.withEpisodeCounts(drama);
|
||||
}
|
||||
|
||||
createEpisode(dto: CreateEpisodeDto): EpisodeRecord {
|
||||
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
|
||||
const dramaId = dto.dramaId ?? dto.albumId;
|
||||
const episodeNo = dto.episodeNo ?? dto.seq;
|
||||
if (!dramaId || !episodeNo) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const episodeRepository = this.dataSource.getRepository(Episode);
|
||||
const duplicate = await episodeRepository.findOne({
|
||||
where: { dramaId, episodeNo },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
'Episode number already exists in this drama',
|
||||
);
|
||||
}
|
||||
|
||||
const entity = episodeRepository.create({
|
||||
dramaId,
|
||||
tiktokEpisodeId: dto.tiktokEpisodeId,
|
||||
episodeNo,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
byteplusVid: dto.byteplusVid,
|
||||
coverId: dto.coverId,
|
||||
coverUrl: dto.coverUrl ?? '',
|
||||
videoUrl: dto.videoUrl,
|
||||
trialDurationSeconds: dto.trialDurationSeconds,
|
||||
durationSeconds: dto.durationSeconds ?? 0,
|
||||
width: dto.width,
|
||||
height: dto.height,
|
||||
isPaid: freeType === 'paid',
|
||||
freeType,
|
||||
unlockPrice: price,
|
||||
price,
|
||||
publishAt: dto.publishAt ? new Date(dto.publishAt) : undefined,
|
||||
nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : undefined,
|
||||
status: publishStatus,
|
||||
reviewStatus,
|
||||
publishStatus,
|
||||
});
|
||||
const saved = await episodeRepository.save(entity);
|
||||
return this.toEpisodeRecord(saved);
|
||||
}
|
||||
|
||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
|
||||
const duplicate = this.episodes.some(
|
||||
(item) =>
|
||||
item.dramaId === dramaId && item.episodeNo === episodeNo,
|
||||
(item) => item.dramaId === dramaId && item.episodeNo === episodeNo,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new ConflictException('Episode number already exists in this drama');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
|
||||
const price = dto.price ?? dto.unlockPrice ?? 0;
|
||||
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
|
||||
const episode: EpisodeRecord = {
|
||||
id: this.episodeSequence++,
|
||||
dramaId,
|
||||
@@ -133,9 +257,7 @@ export class DramasService {
|
||||
publishAt: dto.publishAt,
|
||||
nextReleaseAt: dto.nextReleaseAt,
|
||||
status: publishStatus,
|
||||
reviewStatus:
|
||||
dto.reviewStatus ??
|
||||
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'),
|
||||
reviewStatus,
|
||||
publishStatus,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -145,10 +267,69 @@ export class DramasService {
|
||||
return episode;
|
||||
}
|
||||
|
||||
configureEpisodes(
|
||||
async configureEpisodes(
|
||||
dramaId: number,
|
||||
dto: ConfigureEpisodesDto,
|
||||
): PaginatedResponse<EpisodeRecord> {
|
||||
): 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');
|
||||
}
|
||||
|
||||
const episodeRepository = this.dataSource.getRepository(Episode);
|
||||
const existing = await episodeRepository.find({
|
||||
where: { dramaId },
|
||||
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',
|
||||
);
|
||||
if (hasLockedEpisode) {
|
||||
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++
|
||||
) {
|
||||
await this.createEpisode({
|
||||
dramaId,
|
||||
episodeNo,
|
||||
title: `第${episodeNo}集`,
|
||||
coverUrl: drama.coverUrl,
|
||||
durationSeconds: 0,
|
||||
freeType: 'free',
|
||||
price: 0,
|
||||
reviewStatus: 'not_submitted',
|
||||
publishStatus: PublishStatus.Draft,
|
||||
status: PublishStatus.Draft,
|
||||
});
|
||||
}
|
||||
|
||||
const list = await episodeRepository.find({
|
||||
where: { dramaId },
|
||||
order: { episodeNo: 'ASC' },
|
||||
});
|
||||
const records = list.map((episode) => this.toEpisodeRecord(episode));
|
||||
return {
|
||||
list: records,
|
||||
pagination: { page: 1, pageSize: records.length, total: records.length },
|
||||
};
|
||||
}
|
||||
|
||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
@@ -179,8 +360,12 @@ export class DramasService {
|
||||
}
|
||||
}
|
||||
|
||||
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
|
||||
this.createEpisode({
|
||||
for (
|
||||
let episodeNo = existing.length + 1;
|
||||
episodeNo <= dto.episodeCount;
|
||||
episodeNo++
|
||||
) {
|
||||
await this.createEpisode({
|
||||
dramaId,
|
||||
episodeNo,
|
||||
title: `第${episodeNo}集`,
|
||||
@@ -199,28 +384,79 @@ export class DramasService {
|
||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: list.length,
|
||||
total: list.length,
|
||||
},
|
||||
pagination: { page: 1, pageSize: list.length, total: list.length },
|
||||
};
|
||||
}
|
||||
|
||||
listAdminEpisodes(
|
||||
async listAdminEpisodes(
|
||||
dramaId: number,
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<EpisodeRecord> {
|
||||
this.getDramaOrThrow(dramaId);
|
||||
): 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({
|
||||
where: { dramaId },
|
||||
order: { episodeNo: 'ASC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return {
|
||||
list: records.map((episode) => this.toEpisodeRecord(episode)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
const records = this.episodes
|
||||
.filter((episode) => episode.dramaId === dramaId)
|
||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
||||
return this.paginate(records, pagination);
|
||||
}
|
||||
|
||||
updateEpisode(id: number, dto: UpdateEpisodeDto): EpisodeRecord {
|
||||
const episode = this.getEpisodeOrThrow(id);
|
||||
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free');
|
||||
async updateEpisode(id: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Episode);
|
||||
const episode = await repository.findOne({ where: { id } });
|
||||
if (!episode) {
|
||||
throw new NotFoundException('Episode not found');
|
||||
}
|
||||
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',
|
||||
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,
|
||||
});
|
||||
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');
|
||||
Object.assign(episode, {
|
||||
...dto,
|
||||
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
|
||||
@@ -238,7 +474,7 @@ export class DramasService {
|
||||
return episode;
|
||||
}
|
||||
|
||||
attachEpisodeMedia(input: {
|
||||
async attachEpisodeMedia(input: {
|
||||
episodeId: number;
|
||||
byteplusVid: string;
|
||||
videoUrl: string;
|
||||
@@ -249,8 +485,29 @@ export class DramasService {
|
||||
height: number;
|
||||
reviewStatus: EpisodeRecord['reviewStatus'];
|
||||
reviewMessage?: string;
|
||||
}): EpisodeRecord {
|
||||
const episode = this.getEpisodeOrThrow(input.episodeId);
|
||||
}): 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');
|
||||
}
|
||||
episode.byteplusVid = input.byteplusVid;
|
||||
episode.videoUrl = input.videoUrl;
|
||||
episode.storageBucket = input.storageBucket;
|
||||
episode.objectKey = input.objectKey;
|
||||
episode.durationSeconds = input.durationSeconds;
|
||||
episode.width = input.width;
|
||||
episode.height = input.height;
|
||||
episode.reviewStatus = input.reviewStatus;
|
||||
episode.reviewMessage = input.reviewMessage;
|
||||
const saved = await repository.save(episode);
|
||||
return this.toEpisodeRecord(saved);
|
||||
}
|
||||
|
||||
const episode = await this.getMemEpisodeOrThrow(input.episodeId);
|
||||
episode.byteplusVid = input.byteplusVid;
|
||||
episode.videoUrl = input.videoUrl;
|
||||
episode.storageBucket = input.storageBucket;
|
||||
@@ -264,21 +521,49 @@ export class DramasService {
|
||||
return episode;
|
||||
}
|
||||
|
||||
updateEpisodeReviewStatus(
|
||||
async updateEpisodeReviewStatus(
|
||||
episodeId: number,
|
||||
reviewStatus: EpisodeRecord['reviewStatus'],
|
||||
reviewMessage?: string,
|
||||
): EpisodeRecord {
|
||||
const episode = this.getEpisodeOrThrow(episodeId);
|
||||
): 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');
|
||||
}
|
||||
episode.reviewStatus = reviewStatus;
|
||||
episode.reviewMessage = reviewMessage;
|
||||
const saved = await repository.save(episode);
|
||||
return this.toEpisodeRecord(saved);
|
||||
}
|
||||
|
||||
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
||||
episode.reviewStatus = reviewStatus;
|
||||
episode.reviewMessage = reviewMessage;
|
||||
episode.updatedAt = new Date().toISOString();
|
||||
return episode;
|
||||
}
|
||||
|
||||
listPublishedDramas(
|
||||
async listPublishedDramas(
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<DramaRecord> {
|
||||
): Promise<PaginatedResponse<DramaRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||
const [records, total] = await this.dataSource
|
||||
.getRepository(Drama)
|
||||
.findAndCount({
|
||||
where: { status: PublishStatus.Published },
|
||||
order: { sortOrder: 'DESC', id: 'DESC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
const list = await Promise.all(
|
||||
records.map((drama) => this.toDramaRecordWithCounts(drama)),
|
||||
);
|
||||
return { list, pagination: { page, pageSize, total } };
|
||||
}
|
||||
|
||||
return this.paginate(
|
||||
this.sortDramas(
|
||||
this.dramas
|
||||
@@ -289,7 +574,17 @@ export class DramasService {
|
||||
);
|
||||
}
|
||||
|
||||
getPublishedDramaOrThrow(id: number): DramaRecord {
|
||||
async getPublishedDramaOrThrow(id: number): Promise<DramaRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const drama = await this.dataSource
|
||||
.getRepository(Drama)
|
||||
.findOne({ where: { id, status: PublishStatus.Published } });
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
return this.toDramaRecordWithCounts(drama);
|
||||
}
|
||||
|
||||
const drama = this.dramas.find(
|
||||
(item) => item.id === id && item.status === PublishStatus.Published,
|
||||
);
|
||||
@@ -300,10 +595,37 @@ export class DramasService {
|
||||
return this.withEpisodeCounts(drama);
|
||||
}
|
||||
|
||||
listPublishedEpisodes(
|
||||
async listPublishedEpisodes(
|
||||
dramaId: number,
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<EpisodeRecord> {
|
||||
): Promise<PaginatedResponse<EpisodeRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
const drama = await this.dataSource
|
||||
.getRepository(Drama)
|
||||
.findOne({ where: { id: dramaId, status: PublishStatus.Published } });
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
}
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||
const [records, total] = await this.dataSource
|
||||
.getRepository(Episode)
|
||||
.findAndCount({
|
||||
where: {
|
||||
dramaId,
|
||||
status: PublishStatus.Published,
|
||||
publishStatus: PublishStatus.Published,
|
||||
reviewStatus: 'approved',
|
||||
},
|
||||
order: { episodeNo: 'ASC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return {
|
||||
list: records.map((episode) => this.toEpisodeRecord(episode)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
const drama = this.dramas.find((item) => item.id === dramaId);
|
||||
if (!drama || drama.status !== PublishStatus.Published) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
@@ -324,7 +646,30 @@ export class DramasService {
|
||||
return this.paginate(episodes, pagination);
|
||||
}
|
||||
|
||||
getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
||||
async getPublishedEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const episode = await this.dataSource.getRepository(Episode).findOne({
|
||||
where: {
|
||||
id: episodeId,
|
||||
status: PublishStatus.Published,
|
||||
publishStatus: PublishStatus.Published,
|
||||
reviewStatus: 'approved',
|
||||
},
|
||||
});
|
||||
if (!episode) {
|
||||
throw new NotFoundException('Episode not found');
|
||||
}
|
||||
const drama = await this.dataSource
|
||||
.getRepository(Drama)
|
||||
.findOne({
|
||||
where: { id: episode.dramaId, status: PublishStatus.Published },
|
||||
});
|
||||
if (!drama) {
|
||||
throw new NotFoundException('Episode not found');
|
||||
}
|
||||
return this.toEpisodeRecord(episode);
|
||||
}
|
||||
|
||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
||||
if (
|
||||
!episode ||
|
||||
@@ -343,16 +688,39 @@ export class DramasService {
|
||||
return episode;
|
||||
}
|
||||
|
||||
getEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
||||
async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const episode = await this.dataSource
|
||||
.getRepository(Episode)
|
||||
.findOne({ where: { id: episodeId } });
|
||||
if (!episode) {
|
||||
throw new NotFoundException('Episode not found');
|
||||
}
|
||||
|
||||
return episode;
|
||||
return this.toEpisodeRecord(episode);
|
||||
}
|
||||
|
||||
return this.getMemEpisodeOrThrow(episodeId);
|
||||
}
|
||||
|
||||
async submitReview(albumId: number) {
|
||||
if (this.hasDatabase()) {
|
||||
const dramaRepository = this.dataSource.getRepository(Drama);
|
||||
const drama = await dramaRepository.findOne({ where: { id: albumId } });
|
||||
if (!drama) {
|
||||
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';
|
||||
}
|
||||
await episodeRepository.save(episodes);
|
||||
return { albumId, reviewStatus: 'approved' };
|
||||
}
|
||||
|
||||
submitReview(albumId: number) {
|
||||
const album = this.dramas.find((item) => item.id === albumId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Drama not found');
|
||||
@@ -366,28 +734,167 @@ export class DramasService {
|
||||
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' } });
|
||||
return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
|
||||
}
|
||||
|
||||
listAllDramas() {
|
||||
return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama)));
|
||||
}
|
||||
|
||||
listAllEpisodes() {
|
||||
async listAllEpisodes(): Promise<EpisodeRecord[]> {
|
||||
if (this.hasDatabase()) {
|
||||
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);
|
||||
}
|
||||
|
||||
updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) {
|
||||
const episode = this.getEpisodeOrThrow(episodeId);
|
||||
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');
|
||||
}
|
||||
episode.publishStatus = publishStatus;
|
||||
episode.status = publishStatus;
|
||||
const saved = await repository.save(episode);
|
||||
return this.toEpisodeRecord(saved);
|
||||
}
|
||||
|
||||
const episode = await this.getMemEpisodeOrThrow(episodeId);
|
||||
episode.publishStatus = publishStatus;
|
||||
episode.status = publishStatus;
|
||||
episode.updatedAt = new Date().toISOString();
|
||||
return episode;
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
||||
if (!episode) {
|
||||
throw new NotFoundException('Episode not found');
|
||||
}
|
||||
|
||||
return episode;
|
||||
}
|
||||
|
||||
private async toDramaRecordWithCounts(entity: Drama): Promise<DramaRecord> {
|
||||
const episodeRepository = this.dataSource!.getRepository(Episode);
|
||||
const dramaId = Number(entity.id);
|
||||
const totalEpisodes = await episodeRepository.count({ where: { dramaId } });
|
||||
const publishedEpisodes = await episodeRepository.count({
|
||||
where: {
|
||||
dramaId,
|
||||
publishStatus: PublishStatus.Published,
|
||||
reviewStatus: 'approved',
|
||||
},
|
||||
});
|
||||
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
|
||||
}
|
||||
|
||||
private toDramaRecord(
|
||||
entity: Drama,
|
||||
totalEpisodes: number,
|
||||
publishedEpisodes: number,
|
||||
): DramaRecord {
|
||||
return {
|
||||
id: Number(entity.id),
|
||||
tiktokAlbumId: entity.tiktokAlbumId,
|
||||
title: entity.title,
|
||||
description: entity.description,
|
||||
coverId: entity.coverId,
|
||||
coverUrl: entity.coverUrl,
|
||||
portraitCoverUrl: entity.portraitCoverUrl,
|
||||
landscapeCoverUrl: entity.landscapeCoverUrl,
|
||||
type: entity.type,
|
||||
tags: entity.tags ?? [],
|
||||
region: entity.region,
|
||||
language: entity.language,
|
||||
year: entity.year,
|
||||
status: entity.status,
|
||||
sortOrder: entity.sortOrder,
|
||||
isRecommended: entity.isRecommended,
|
||||
onlineVersion: entity.onlineVersion,
|
||||
totalEpisodes,
|
||||
publishedEpisodes,
|
||||
createdAt: this.toIso(entity.createdAt),
|
||||
updatedAt: this.toIso(entity.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toEpisodeRecord(entity: Episode): EpisodeRecord {
|
||||
const dramaId = Number(entity.dramaId);
|
||||
return {
|
||||
id: Number(entity.id),
|
||||
dramaId,
|
||||
albumId: dramaId,
|
||||
tiktokEpisodeId: entity.tiktokEpisodeId,
|
||||
episodeNo: entity.episodeNo,
|
||||
seq: entity.episodeNo,
|
||||
title: entity.title,
|
||||
description: entity.description,
|
||||
byteplusVid: entity.byteplusVid,
|
||||
coverId: entity.coverId,
|
||||
coverUrl: entity.coverUrl,
|
||||
videoUrl: entity.videoUrl,
|
||||
storageBucket: entity.storageBucket,
|
||||
objectKey: entity.objectKey,
|
||||
trialDurationSeconds: entity.trialDurationSeconds,
|
||||
durationSeconds: entity.durationSeconds,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
isPaid: entity.isPaid,
|
||||
freeType: entity.freeType,
|
||||
unlockPrice: entity.unlockPrice,
|
||||
price: entity.price,
|
||||
publishAt: this.toIsoOrUndefined(entity.publishAt),
|
||||
nextReleaseAt: this.toIsoOrUndefined(entity.nextReleaseAt),
|
||||
status: entity.status,
|
||||
reviewStatus: entity.reviewStatus,
|
||||
reviewMessage: entity.reviewMessage,
|
||||
publishStatus: entity.publishStatus,
|
||||
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 toIsoOrUndefined(value?: Date | string): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
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 sortDramas(records: DramaRecord[]) {
|
||||
return [...records].sort((left, right) => {
|
||||
if (right.sortOrder !== left.sortOrder) {
|
||||
|
||||
@@ -12,12 +12,18 @@ export class Drama {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
tiktokAlbumId?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
coverId?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
coverUrl: string;
|
||||
|
||||
@@ -51,6 +57,9 @@ export class Drama {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRecommended: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
onlineVersion: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export class Episode {
|
||||
@Column({ type: 'bigint' })
|
||||
dramaId: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
tiktokEpisodeId?: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
episodeNo: number;
|
||||
|
||||
@@ -26,16 +29,28 @@ export class Episode {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
coverId?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
coverUrl: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
videoUrl: string;
|
||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||
videoUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
byteplusVid?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
storageBucket?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
objectKey?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
trialDurationSeconds?: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
@Column({ type: 'int', default: 0 })
|
||||
durationSeconds: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@@ -47,9 +62,15 @@ export class Episode {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPaid: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 16, default: 'free' })
|
||||
freeType: 'free' | 'paid';
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
unlockPrice?: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
publishAt?: Date;
|
||||
|
||||
@@ -59,6 +80,15 @@ export class Episode {
|
||||
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
|
||||
status: PublishStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'not_submitted' })
|
||||
reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
reviewMessage?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
|
||||
publishStatus: PublishStatus;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -54,8 +54,10 @@ export class MediaService {
|
||||
return job;
|
||||
}
|
||||
|
||||
createDirectUploadTask(dto: CreateDirectUploadTaskDto): MediaUploadJobRecord {
|
||||
this.dramasService.getEpisodeOrThrow(dto.episodeId);
|
||||
async createDirectUploadTask(
|
||||
dto: CreateDirectUploadTaskDto,
|
||||
): Promise<MediaUploadJobRecord> {
|
||||
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
|
||||
const now = new Date().toISOString();
|
||||
const jobId = randomUUID();
|
||||
const upload = this.bytePlusMediaProvider.createDirectUpload({
|
||||
@@ -87,7 +89,7 @@ export class MediaService {
|
||||
return job;
|
||||
}
|
||||
|
||||
completeDirectUploadTask(jobId: string): MediaUploadJobRecord {
|
||||
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
const job = this.getUploadJobOrThrow(jobId);
|
||||
if (!job.objectKey || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
@@ -116,7 +118,7 @@ export class MediaService {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.dramasService.attachEpisodeMedia({
|
||||
await this.dramasService.attachEpisodeMedia({
|
||||
episodeId: job.episodeId,
|
||||
byteplusVid: completed.byteplusVid,
|
||||
videoUrl: completed.videoUrl,
|
||||
@@ -132,7 +134,7 @@ export class MediaService {
|
||||
return job;
|
||||
}
|
||||
|
||||
retryReview(jobId: string): MediaUploadJobRecord {
|
||||
async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
const job = this.getUploadJobOrThrow(jobId);
|
||||
if (!job.byteplusVid || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
@@ -143,7 +145,7 @@ export class MediaService {
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
job.updatedAt = new Date().toISOString();
|
||||
this.dramasService.updateEpisodeReviewStatus(
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
job.episodeId,
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
@@ -151,7 +153,7 @@ export class MediaService {
|
||||
return job;
|
||||
}
|
||||
|
||||
syncReviewStatuses() {
|
||||
async syncReviewStatuses() {
|
||||
const reviewingJobs = this.uploadJobs.filter(
|
||||
(job) =>
|
||||
job.byteplusVid &&
|
||||
@@ -167,7 +169,7 @@ export class MediaService {
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
job.updatedAt = new Date().toISOString();
|
||||
this.dramasService.updateEpisodeReviewStatus(
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
job.episodeId as number,
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
|
||||
@@ -22,8 +22,10 @@ export class OrdersService {
|
||||
|
||||
constructor(private readonly dramasService: DramasService) {}
|
||||
|
||||
createOrder(user: UserRecord, dto: CreateOrderDto): OrderRecord {
|
||||
const episode = this.dramasService.getPublishedEpisodeOrThrow(dto.episodeId);
|
||||
async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||
dto.episodeId,
|
||||
);
|
||||
if (!episode.isPaid) {
|
||||
throw new UnprocessableEntityException('Episode is free');
|
||||
}
|
||||
@@ -46,8 +48,8 @@ export class OrdersService {
|
||||
return order;
|
||||
}
|
||||
|
||||
getPlayback(user: UserRecord, episodeId: number) {
|
||||
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
async getPlayback(user: UserRecord, episodeId: number) {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
const isUnlocked =
|
||||
!episode.isPaid ||
|
||||
this.unlocks.some(
|
||||
@@ -107,8 +109,8 @@ export class OrdersService {
|
||||
};
|
||||
}
|
||||
|
||||
hasEpisodePermission(userId: number, episodeId: number) {
|
||||
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
async hasEpisodePermission(userId: number, episodeId: number) {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
return !episode.isPaid || this.isUnlocked(userId, episodeId);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ export class PlayerService {
|
||||
private readonly ordersService: OrdersService,
|
||||
) {}
|
||||
|
||||
getEpisodePlayer(user: UserRecord, episodeId: number) {
|
||||
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
const hasPermission = this.ordersService.hasEpisodePermission(
|
||||
async getEpisodePlayer(user: UserRecord, episodeId: number) {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||
const hasPermission = await this.ordersService.hasEpisodePermission(
|
||||
user.id,
|
||||
episode.id,
|
||||
);
|
||||
|
||||
@@ -160,13 +160,13 @@ describe('database-backed persistence', () => {
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const drama = dramasService.createDrama({
|
||||
const drama = await dramasService.createDrama({
|
||||
title: 'DB Metrics',
|
||||
coverUrl: 'https://cdn.example.com/cover.jpg',
|
||||
type: 'action',
|
||||
status: PublishStatus.Published,
|
||||
});
|
||||
dramasService.createEpisode({
|
||||
await dramasService.createEpisode({
|
||||
dramaId: drama.id,
|
||||
episodeNo: 1,
|
||||
title: 'DB Metrics Episode',
|
||||
|
||||
Reference in New Issue
Block a user