refactor: 重构项目支持TypeORM数据库持久化

新增数据库实体类与服务实现,将内存存储扩展为支持数据库的完整实现,包括:
1. 新增Drama和Episode实体类,添加多个业务字段
2. 重构DramasService实现数据库CRUD与业务逻辑
3. 升级相关服务类为异步方法适配数据库操作
4. 更新测试用例适配异步接口变更
5. 完善分页、数据转换等辅助工具方法
This commit is contained in:
2026-07-01 21:49:36 +08:00
parent d3083bf16b
commit f342312b92
8 changed files with 633 additions and 83 deletions

View File

@@ -70,13 +70,13 @@ export class DataService {
async sync(input: SyncInput = {}) { async sync(input: SyncInput = {}) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const dramas = this.dramasService const dramas = (await this.dramasService.listAllDramas()).filter(
.listAllDramas() (drama) => !input.dramaId || drama.id === input.dramaId,
.filter((drama) => !input.dramaId || drama.id === input.dramaId); );
const dramaIds = new Set(dramas.map((drama) => drama.id)); const dramaIds = new Set(dramas.map((drama) => drama.id));
const episodes = this.dramasService const episodes = (await this.dramasService.listAllEpisodes()).filter(
.listAllEpisodes() (episode) => dramaIds.has(episode.dramaId),
.filter((episode) => dramaIds.has(episode.dramaId)); );
const dramaRecords = dramas.map((drama) => ({ const dramaRecords = dramas.map((drama) => ({
dramaId: drama.id, dramaId: drama.id,

View File

@@ -2,14 +2,19 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
Optional,
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto'; import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
import { CreateDramaDto } from './dto/create-drama.dto'; import { CreateDramaDto } from './dto/create-drama.dto';
import { CreateEpisodeDto } from './dto/create-episode.dto'; import { CreateEpisodeDto } from './dto/create-episode.dto';
import { UpdateDramaDto } from './dto/update-drama.dto'; import { UpdateDramaDto } from './dto/update-drama.dto';
import { UpdateEpisodeDto } from './dto/update-episode.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 { PublishStatus } from './drama-status.enum';
import { import {
DramaRecord, DramaRecord,
@@ -28,7 +33,37 @@ export class DramasService {
private readonly dramas: DramaRecord[] = []; private readonly dramas: DramaRecord[] = [];
private readonly episodes: EpisodeRecord[] = []; 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 now = new Date().toISOString();
const drama: DramaRecord = { const drama: DramaRecord = {
id: this.dramaSequence++, id: this.dramaSequence++,
@@ -58,11 +93,41 @@ export class DramasService {
return drama; return drama;
} }
listAdminDramas(pagination: PaginationInput): PaginatedResponse<DramaRecord> { async listAdminDramas(
return this.paginate(this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination); 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); const drama = this.dramas.find((item) => item.id === id);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
@@ -71,7 +136,18 @@ export class DramasService {
return this.withEpisodeCounts(drama); 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); const drama = this.dramas.find((item) => item.id === id);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
@@ -85,30 +161,78 @@ export class DramasService {
return this.withEpisodeCounts(drama); return this.withEpisodeCounts(drama);
} }
createEpisode(dto: CreateEpisodeDto): EpisodeRecord { async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
const dramaId = dto.dramaId ?? dto.albumId; const dramaId = dto.dramaId ?? dto.albumId;
const episodeNo = dto.episodeNo ?? dto.seq; const episodeNo = dto.episodeNo ?? dto.seq;
if (!dramaId || !episodeNo) { if (!dramaId || !episodeNo) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
} }
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
const 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); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
} }
const duplicate = this.episodes.some( const duplicate = this.episodes.some(
(item) => (item) => item.dramaId === dramaId && item.episodeNo === episodeNo,
item.dramaId === dramaId && item.episodeNo === episodeNo,
); );
if (duplicate) { if (duplicate) {
throw new ConflictException('Episode number already exists in this drama'); throw new ConflictException('Episode number already exists in this drama');
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
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 = { const episode: EpisodeRecord = {
id: this.episodeSequence++, id: this.episodeSequence++,
dramaId, dramaId,
@@ -133,9 +257,7 @@ export class DramasService {
publishAt: dto.publishAt, publishAt: dto.publishAt,
nextReleaseAt: dto.nextReleaseAt, nextReleaseAt: dto.nextReleaseAt,
status: publishStatus, status: publishStatus,
reviewStatus: reviewStatus,
dto.reviewStatus ??
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'),
publishStatus, publishStatus,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -145,10 +267,69 @@ export class DramasService {
return episode; return episode;
} }
configureEpisodes( async configureEpisodes(
dramaId: number, dramaId: number,
dto: ConfigureEpisodesDto, 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); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
@@ -179,8 +360,12 @@ export class DramasService {
} }
} }
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) { for (
this.createEpisode({ let episodeNo = existing.length + 1;
episodeNo <= dto.episodeCount;
episodeNo++
) {
await this.createEpisode({
dramaId, dramaId,
episodeNo, episodeNo,
title: `${episodeNo}`, title: `${episodeNo}`,
@@ -199,28 +384,79 @@ export class DramasService {
.sort((left, right) => left.episodeNo - right.episodeNo); .sort((left, right) => left.episodeNo - right.episodeNo);
return { return {
list, list,
pagination: { pagination: { page: 1, pageSize: list.length, total: list.length },
page: 1,
pageSize: list.length,
total: list.length,
},
}; };
} }
listAdminEpisodes( async listAdminEpisodes(
dramaId: number, dramaId: number,
pagination: PaginationInput, pagination: PaginationInput,
): PaginatedResponse<EpisodeRecord> { ): Promise<PaginatedResponse<EpisodeRecord>> {
this.getDramaOrThrow(dramaId); 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 const records = this.episodes
.filter((episode) => episode.dramaId === dramaId) .filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo); .sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(records, pagination); return this.paginate(records, pagination);
} }
updateEpisode(id: number, dto: UpdateEpisodeDto): EpisodeRecord { async updateEpisode(id: number, dto: UpdateEpisodeDto): Promise<EpisodeRecord> {
const episode = this.getEpisodeOrThrow(id); if (this.hasDatabase()) {
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free'); 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, { Object.assign(episode, {
...dto, ...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId, dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
@@ -238,7 +474,7 @@ export class DramasService {
return episode; return episode;
} }
attachEpisodeMedia(input: { async attachEpisodeMedia(input: {
episodeId: number; episodeId: number;
byteplusVid: string; byteplusVid: string;
videoUrl: string; videoUrl: string;
@@ -249,8 +485,29 @@ export class DramasService {
height: number; height: number;
reviewStatus: EpisodeRecord['reviewStatus']; reviewStatus: EpisodeRecord['reviewStatus'];
reviewMessage?: string; reviewMessage?: string;
}): EpisodeRecord { }): Promise<EpisodeRecord> {
const episode = this.getEpisodeOrThrow(input.episodeId); 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.byteplusVid = input.byteplusVid;
episode.videoUrl = input.videoUrl; episode.videoUrl = input.videoUrl;
episode.storageBucket = input.storageBucket; episode.storageBucket = input.storageBucket;
@@ -264,21 +521,49 @@ export class DramasService {
return episode; return episode;
} }
updateEpisodeReviewStatus( async updateEpisodeReviewStatus(
episodeId: number, episodeId: number,
reviewStatus: EpisodeRecord['reviewStatus'], reviewStatus: EpisodeRecord['reviewStatus'],
reviewMessage?: string, reviewMessage?: string,
): EpisodeRecord { ): Promise<EpisodeRecord> {
const episode = this.getEpisodeOrThrow(episodeId); 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.reviewStatus = reviewStatus;
episode.reviewMessage = reviewMessage; episode.reviewMessage = reviewMessage;
episode.updatedAt = new Date().toISOString(); episode.updatedAt = new Date().toISOString();
return episode; return episode;
} }
listPublishedDramas( async listPublishedDramas(
pagination: PaginationInput, 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( return this.paginate(
this.sortDramas( this.sortDramas(
this.dramas 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( const drama = this.dramas.find(
(item) => item.id === id && item.status === PublishStatus.Published, (item) => item.id === id && item.status === PublishStatus.Published,
); );
@@ -300,10 +595,37 @@ export class DramasService {
return this.withEpisodeCounts(drama); return this.withEpisodeCounts(drama);
} }
listPublishedEpisodes( async listPublishedEpisodes(
dramaId: number, dramaId: number,
pagination: PaginationInput, 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); const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama || drama.status !== PublishStatus.Published) { if (!drama || drama.status !== PublishStatus.Published) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
@@ -324,7 +646,30 @@ export class DramasService {
return this.paginate(episodes, pagination); 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); const episode = this.episodes.find((item) => item.id === episodeId);
if ( if (
!episode || !episode ||
@@ -343,16 +688,39 @@ export class DramasService {
return episode; return episode;
} }
getEpisodeOrThrow(episodeId: number): EpisodeRecord { async getEpisodeOrThrow(episodeId: number): Promise<EpisodeRecord> {
const episode = this.episodes.find((item) => item.id === episodeId); if (this.hasDatabase()) {
const episode = await this.dataSource
.getRepository(Episode)
.findOne({ where: { id: episodeId } });
if (!episode) { if (!episode) {
throw new NotFoundException('Episode not found'); throw new NotFoundException('Episode not found');
} }
return this.toEpisodeRecord(episode);
return 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); const album = this.dramas.find((item) => item.id === albumId);
if (!album) { if (!album) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
@@ -366,28 +734,167 @@ export class DramasService {
episode.updatedAt = new Date().toISOString(); episode.updatedAt = new Date().toISOString();
}); });
return { return { albumId, reviewStatus: 'approved' };
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))); 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); return [...this.episodes].sort((left, right) => left.id - right.id);
} }
updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) { async updateEpisodePublishStatus(
const episode = this.getEpisodeOrThrow(episodeId); 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.publishStatus = publishStatus;
episode.status = publishStatus; episode.status = publishStatus;
episode.updatedAt = new Date().toISOString(); episode.updatedAt = new Date().toISOString();
return episode; 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[]) { private sortDramas(records: DramaRecord[]) {
return [...records].sort((left, right) => { return [...records].sort((left, right) => {
if (right.sortOrder !== left.sortOrder) { if (right.sortOrder !== left.sortOrder) {

View File

@@ -12,12 +12,18 @@ export class Drama {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Column({ type: 'varchar', length: 128, nullable: true })
tiktokAlbumId?: string;
@Column({ type: 'varchar', length: 255 }) @Column({ type: 'varchar', length: 255 })
title: string; title: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description?: string; description?: string;
@Column({ type: 'int', nullable: true })
coverId?: number;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024 })
coverUrl: string; coverUrl: string;
@@ -51,6 +57,9 @@ export class Drama {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isRecommended: boolean; isRecommended: boolean;
@Column({ type: 'int', default: 1 })
onlineVersion: number;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@@ -17,6 +17,9 @@ export class Episode {
@Column({ type: 'bigint' }) @Column({ type: 'bigint' })
dramaId: number; dramaId: number;
@Column({ type: 'varchar', length: 128, nullable: true })
tiktokEpisodeId?: string;
@Column({ type: 'int' }) @Column({ type: 'int' })
episodeNo: number; episodeNo: number;
@@ -26,16 +29,28 @@ export class Episode {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description?: string; description?: string;
@Column({ type: 'int', nullable: true })
coverId?: number;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024 })
coverUrl: string; coverUrl: string;
@Column({ type: 'varchar', length: 1024 }) @Column({ type: 'varchar', length: 1024, nullable: true })
videoUrl: string; 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 }) @Column({ type: 'int', nullable: true })
trialDurationSeconds?: number; trialDurationSeconds?: number;
@Column({ type: 'int' }) @Column({ type: 'int', default: 0 })
durationSeconds: number; durationSeconds: number;
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
@@ -47,9 +62,15 @@ export class Episode {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isPaid: boolean; isPaid: boolean;
@Column({ type: 'varchar', length: 16, default: 'free' })
freeType: 'free' | 'paid';
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
unlockPrice?: number; unlockPrice?: number;
@Column({ type: 'int', default: 0 })
price: number;
@Column({ type: 'datetime', nullable: true }) @Column({ type: 'datetime', nullable: true })
publishAt?: Date; publishAt?: Date;
@@ -59,6 +80,15 @@ export class Episode {
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft }) @Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
status: PublishStatus; 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() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View File

@@ -54,8 +54,10 @@ export class MediaService {
return job; return job;
} }
createDirectUploadTask(dto: CreateDirectUploadTaskDto): MediaUploadJobRecord { async createDirectUploadTask(
this.dramasService.getEpisodeOrThrow(dto.episodeId); dto: CreateDirectUploadTaskDto,
): Promise<MediaUploadJobRecord> {
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
const now = new Date().toISOString(); const now = new Date().toISOString();
const jobId = randomUUID(); const jobId = randomUUID();
const upload = this.bytePlusMediaProvider.createDirectUpload({ const upload = this.bytePlusMediaProvider.createDirectUpload({
@@ -87,7 +89,7 @@ export class MediaService {
return job; return job;
} }
completeDirectUploadTask(jobId: string): MediaUploadJobRecord { async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
const job = this.getUploadJobOrThrow(jobId); const job = this.getUploadJobOrThrow(jobId);
if (!job.objectKey || !job.episodeId) { if (!job.objectKey || !job.episodeId) {
throw new NotFoundException('Upload task not found'); throw new NotFoundException('Upload task not found');
@@ -116,7 +118,7 @@ export class MediaService {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
this.dramasService.attachEpisodeMedia({ await this.dramasService.attachEpisodeMedia({
episodeId: job.episodeId, episodeId: job.episodeId,
byteplusVid: completed.byteplusVid, byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl, videoUrl: completed.videoUrl,
@@ -132,7 +134,7 @@ export class MediaService {
return job; return job;
} }
retryReview(jobId: string): MediaUploadJobRecord { async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
const job = this.getUploadJobOrThrow(jobId); const job = this.getUploadJobOrThrow(jobId);
if (!job.byteplusVid || !job.episodeId) { if (!job.byteplusVid || !job.episodeId) {
throw new NotFoundException('Upload task not found'); throw new NotFoundException('Upload task not found');
@@ -143,7 +145,7 @@ export class MediaService {
job.reviewStatus = review.reviewStatus; job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage; job.reviewMessage = review.reviewMessage;
job.updatedAt = new Date().toISOString(); job.updatedAt = new Date().toISOString();
this.dramasService.updateEpisodeReviewStatus( await this.dramasService.updateEpisodeReviewStatus(
job.episodeId, job.episodeId,
review.reviewStatus, review.reviewStatus,
review.reviewMessage, review.reviewMessage,
@@ -151,7 +153,7 @@ export class MediaService {
return job; return job;
} }
syncReviewStatuses() { async syncReviewStatuses() {
const reviewingJobs = this.uploadJobs.filter( const reviewingJobs = this.uploadJobs.filter(
(job) => (job) =>
job.byteplusVid && job.byteplusVid &&
@@ -167,7 +169,7 @@ export class MediaService {
job.reviewStatus = review.reviewStatus; job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage; job.reviewMessage = review.reviewMessage;
job.updatedAt = new Date().toISOString(); job.updatedAt = new Date().toISOString();
this.dramasService.updateEpisodeReviewStatus( await this.dramasService.updateEpisodeReviewStatus(
job.episodeId as number, job.episodeId as number,
review.reviewStatus, review.reviewStatus,
review.reviewMessage, review.reviewMessage,

View File

@@ -22,8 +22,10 @@ export class OrdersService {
constructor(private readonly dramasService: DramasService) {} constructor(private readonly dramasService: DramasService) {}
createOrder(user: UserRecord, dto: CreateOrderDto): OrderRecord { async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
const episode = this.dramasService.getPublishedEpisodeOrThrow(dto.episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
if (!episode.isPaid) { if (!episode.isPaid) {
throw new UnprocessableEntityException('Episode is free'); throw new UnprocessableEntityException('Episode is free');
} }
@@ -46,8 +48,8 @@ export class OrdersService {
return order; return order;
} }
getPlayback(user: UserRecord, episodeId: number) { async getPlayback(user: UserRecord, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const isUnlocked = const isUnlocked =
!episode.isPaid || !episode.isPaid ||
this.unlocks.some( this.unlocks.some(
@@ -107,8 +109,8 @@ export class OrdersService {
}; };
} }
hasEpisodePermission(userId: number, episodeId: number) { async hasEpisodePermission(userId: number, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
return !episode.isPaid || this.isUnlocked(userId, episodeId); return !episode.isPaid || this.isUnlocked(userId, episodeId);
} }

View File

@@ -10,9 +10,9 @@ export class PlayerService {
private readonly ordersService: OrdersService, private readonly ordersService: OrdersService,
) {} ) {}
getEpisodePlayer(user: UserRecord, episodeId: number) { async getEpisodePlayer(user: UserRecord, episodeId: number) {
const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const hasPermission = this.ordersService.hasEpisodePermission( const hasPermission = await this.ordersService.hasEpisodePermission(
user.id, user.id,
episode.id, episode.id,
); );

View File

@@ -160,13 +160,13 @@ describe('database-backed persistence', () => {
dataSource as never, dataSource as never,
); );
const drama = dramasService.createDrama({ const drama = await dramasService.createDrama({
title: 'DB Metrics', title: 'DB Metrics',
coverUrl: 'https://cdn.example.com/cover.jpg', coverUrl: 'https://cdn.example.com/cover.jpg',
type: 'action', type: 'action',
status: PublishStatus.Published, status: PublishStatus.Published,
}); });
dramasService.createEpisode({ await dramasService.createEpisode({
dramaId: drama.id, dramaId: drama.id,
episodeNo: 1, episodeNo: 1,
title: 'DB Metrics Episode', title: 'DB Metrics Episode',