feat: 新增剧集封面、集数管理与上传优化功能

1. 新增Cover实体类并注册到数据库模块
2. 为剧集添加总集数字段及自动创建集数槽功能
3. 新增管理员更新剧集集数接口
4. 优化发布剧集列表:支持标题过滤与默认分页大小10
5. 扩展MediaUploadJob实体字段,完善媒体上传流程
6. 重构订单与媒体服务,新增数据库持久化支持
7. 新增剧集标题过滤的端到端测试用例
This commit is contained in:
2026-07-02 15:46:29 +08:00
parent f342312b92
commit 0eeeea0c4d
9 changed files with 669 additions and 60 deletions

View File

@@ -10,6 +10,7 @@ import { Drama } from '../dramas/entities/drama.entity';
import { Episode } from '../dramas/entities/episode.entity'; import { Episode } from '../dramas/entities/episode.entity';
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity'; import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
import { RequestLog } from '../logs/entities/request-log.entity'; import { RequestLog } from '../logs/entities/request-log.entity';
import { Cover } from '../media/entities/cover.entity';
import { MediaUploadJob } from '../media/entities/media-upload-job.entity'; import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
import { Order } from '../orders/entities/order.entity'; import { Order } from '../orders/entities/order.entity';
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity'; import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
@@ -35,6 +36,7 @@ const databaseImports = process.env.DB_HOST
Episode, Episode,
PlaybackErrorLog, PlaybackErrorLog,
RequestLog, RequestLog,
Cover,
MediaUploadJob, MediaUploadJob,
Order, Order,
UserEpisodeUnlock, UserEpisodeUnlock,

View File

@@ -102,6 +102,22 @@ export class DramasController {
return this.dramasService.updateEpisode(Number(id), dto); return this.dramasService.updateEpisode(Number(id), dto);
} }
@Patch('/api/admin/v1/dramas/:dramaId/episodes/:episodeId')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:update')
updateDramaEpisode(
@Param('dramaId') dramaId: string,
@Param('episodeId') episodeId: string,
@Body() dto: UpdateEpisodeDto,
) {
return this.dramasService.updateDramaEpisode(
Number(dramaId),
Number(episodeId),
dto,
);
}
@Post('/api/admin/v1/dramas/:id/submit-review') @Post('/api/admin/v1/dramas/:id/submit-review')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard) @UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@@ -128,10 +144,12 @@ export class DramasController {
listPublishedDramas( listPublishedDramas(
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
@Query('title') title?: string,
) { ) {
return this.dramasService.listPublishedDramas({ return this.dramasService.listPublishedDramas({
page: Number(page) || 1, page: Number(page) || 1,
pageSize: Number(pageSize) || 20, pageSize: Number(pageSize) || 10,
title,
}); });
} }

View File

@@ -6,7 +6,7 @@ import {
UnprocessableEntityException, UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource, Like } 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';
@@ -26,6 +26,10 @@ interface PaginationInput {
pageSize: number; pageSize: number;
} }
interface PublishedDramaListInput extends PaginationInput {
title?: string;
}
@Injectable() @Injectable()
export class DramasService { export class DramasService {
private dramaSequence = 1; private dramaSequence = 1;
@@ -40,6 +44,7 @@ export class DramasService {
) {} ) {}
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> { async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
const { totalEpisodes } = dto;
if (this.hasDatabase()) { if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Drama); const repository = this.dataSource.getRepository(Drama);
const entity = repository.create({ const entity = repository.create({
@@ -61,6 +66,12 @@ export class DramasService {
onlineVersion: dto.onlineVersion ?? 1, onlineVersion: dto.onlineVersion ?? 1,
}); });
const saved = await repository.save(entity); const saved = await repository.save(entity);
if (totalEpisodes) {
await this.configureEpisodes(Number(saved.id), {
episodeCount: totalEpisodes,
});
return this.getDramaOrThrow(Number(saved.id));
}
return this.toDramaRecord(saved, 0, 0); return this.toDramaRecord(saved, 0, 0);
} }
@@ -90,6 +101,10 @@ export class DramasService {
}; };
this.dramas.push(drama); this.dramas.push(drama);
if (totalEpisodes) {
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
return this.getDramaOrThrow(drama.id);
}
return drama; return drama;
} }
@@ -137,14 +152,23 @@ export class DramasService {
} }
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> { async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
const { totalEpisodes, ...dramaUpdates } = dto;
if (this.hasDatabase()) { if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Drama); const repository = this.dataSource.getRepository(Drama);
const drama = await repository.findOne({ where: { id } }); const drama = await repository.findOne({ where: { id } });
if (!drama) { if (!drama) {
throw new NotFoundException('Drama not found'); throw new NotFoundException('Drama not found');
} }
repository.merge(drama, { ...dto, tags: dto.tags ?? drama.tags }); repository.merge(drama, {
...dramaUpdates,
tags: dramaUpdates.tags ?? drama.tags,
});
const saved = await repository.save(drama); const saved = await repository.save(drama);
if (totalEpisodes) {
await this.configureEpisodes(Number(saved.id), {
episodeCount: totalEpisodes,
});
}
return this.toDramaRecordWithCounts(saved); return this.toDramaRecordWithCounts(saved);
} }
@@ -154,10 +178,13 @@ export class DramasService {
} }
Object.assign(drama, { Object.assign(drama, {
...dto, ...dramaUpdates,
tags: dto.tags ?? drama.tags, tags: dramaUpdates.tags ?? drama.tags,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
if (totalEpisodes) {
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
}
return this.withEpisodeCounts(drama); return this.withEpisodeCounts(drama);
} }
@@ -474,6 +501,28 @@ export class DramasService {
return episode; return episode;
} }
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');
}
} else {
const episode = await this.getMemEpisodeOrThrow(episodeId);
if (episode.dramaId !== dramaId) {
throw new NotFoundException('Episode not found');
}
}
return this.updateEpisode(episodeId, { ...dto, dramaId });
}
async attachEpisodeMedia(input: { async attachEpisodeMedia(input: {
episodeId: number; episodeId: number;
byteplusVid: string; byteplusVid: string;
@@ -546,14 +595,18 @@ export class DramasService {
} }
async listPublishedDramas( async listPublishedDramas(
pagination: PaginationInput, input: PublishedDramaListInput,
): Promise<PaginatedResponse<DramaRecord>> { ): Promise<PaginatedResponse<DramaRecord>> {
const title = input.title?.trim();
if (this.hasDatabase()) { if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination); const { page, pageSize, skip, take } = this.resolvePagination(input);
const [records, total] = await this.dataSource const [records, total] = await this.dataSource
.getRepository(Drama) .getRepository(Drama)
.findAndCount({ .findAndCount({
where: { status: PublishStatus.Published }, where: {
status: PublishStatus.Published,
...(title ? { title: Like(`%${title}%`) } : {}),
},
order: { sortOrder: 'DESC', id: 'DESC' }, order: { sortOrder: 'DESC', id: 'DESC' },
skip, skip,
take, take,
@@ -568,9 +621,10 @@ export class DramasService {
this.sortDramas( this.sortDramas(
this.dramas this.dramas
.filter((item) => item.status === PublishStatus.Published) .filter((item) => item.status === PublishStatus.Published)
.filter((item) => !title || item.title.includes(title))
.map((drama) => this.withEpisodeCounts(drama)), .map((drama) => this.withEpisodeCounts(drama)),
), ),
pagination, input,
); );
} }

View File

@@ -7,6 +7,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { PublishStatus } from '../drama-status.enum'; import { PublishStatus } from '../drama-status.enum';
@@ -88,6 +89,13 @@ export class CreateDramaDto {
@IsBoolean() @IsBoolean()
isRecommended?: boolean; isRecommended?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
totalEpisodes?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsInt() @IsInt()

View File

@@ -0,0 +1,21 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('covers')
export class Cover {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 255 })
fileName: string;
@Column({ type: 'varchar', length: 1024 })
url: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -16,6 +16,9 @@ export class MediaUploadJob {
@Column({ type: 'varchar', length: 128 }) @Column({ type: 'varchar', length: 128 })
jobId: string; jobId: string;
@Column({ type: 'int', nullable: true })
episodeId?: number;
@Index() @Index()
@Column({ type: 'varchar', length: 128, nullable: true }) @Column({ type: 'varchar', length: 128, nullable: true })
byteplusVid?: string; byteplusVid?: string;
@@ -23,6 +26,42 @@ export class MediaUploadJob {
@Column({ type: 'varchar', length: 64 }) @Column({ type: 'varchar', length: 64 })
status: string; status: string;
@Column({ type: 'varchar', length: 255, nullable: true })
fileName?: string;
@Column({ type: 'varchar', length: 128, nullable: true })
contentType?: string;
@Column({ type: 'int', nullable: true })
fileSize?: number;
@Column({ type: 'varchar', length: 255, nullable: true })
bucket?: string;
@Column({ type: 'varchar', length: 512, nullable: true })
objectKey?: string;
@Column({ type: 'varchar', length: 1024, nullable: true })
uploadUrl?: string;
@Column({ type: 'varchar', length: 1024, nullable: true })
videoUrl?: string;
@Column({ type: 'int', nullable: true })
width?: number;
@Column({ type: 'int', nullable: true })
height?: number;
@Column({ type: 'int', nullable: true })
durationSeconds?: number;
@Column({ type: 'varchar', length: 32, nullable: true })
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
@Column({ type: 'varchar', length: 512, nullable: true })
reviewMessage?: string;
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
rawResponse?: Record<string, unknown>; rawResponse?: Record<string, unknown>;

View File

@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service'; import { DramasService } from '../dramas/dramas.service';
import { CreateCoverDto } from './dto/create-cover.dto'; import { CreateCoverDto } from './dto/create-cover.dto';
import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto'; import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto';
import { CreateUploadJobDto } from './dto/create-upload-job.dto'; import { CreateUploadJobDto } from './dto/create-upload-job.dto';
import { Cover } from './entities/cover.entity';
import { MediaUploadJob } from './entities/media-upload-job.entity';
import { import {
CoverRecord, CoverRecord,
MediaUploadJobRecord, MediaUploadJobRecord,
@@ -26,9 +30,20 @@ export class MediaService {
constructor( constructor(
private readonly dramasService: DramasService, private readonly dramasService: DramasService,
private readonly bytePlusMediaProvider: BytePlusMediaProvider, private readonly bytePlusMediaProvider: BytePlusMediaProvider,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {} ) {}
createCover(dto: CreateCoverDto): CoverRecord { async createCover(dto: CreateCoverDto): Promise<CoverRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Cover);
const saved = await repository.save(
repository.create({ fileName: dto.fileName, url: dto.url }),
);
return this.toCoverRecord(saved);
}
const cover: CoverRecord = { const cover: CoverRecord = {
id: this.coverSequence++, id: this.coverSequence++,
fileName: dto.fileName, fileName: dto.fileName,
@@ -39,7 +54,20 @@ export class MediaService {
return cover; return cover;
} }
createUploadJob(dto: CreateUploadJobDto): MediaUploadJobRecord { async createUploadJob(dto: CreateUploadJobDto): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const saved = await repository.save(
repository.create({
jobId: dto.jobId,
byteplusVid: dto.byteplusVid,
status: dto.status,
rawResponse: dto.rawResponse,
}),
);
return this.toJobRecord(saved);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const job: MediaUploadJobRecord = { const job: MediaUploadJobRecord = {
id: this.uploadJobSequence++, id: this.uploadJobSequence++,
@@ -58,15 +86,13 @@ export class MediaService {
dto: CreateDirectUploadTaskDto, dto: CreateDirectUploadTaskDto,
): Promise<MediaUploadJobRecord> { ): Promise<MediaUploadJobRecord> {
await this.dramasService.getEpisodeOrThrow(dto.episodeId); await this.dramasService.getEpisodeOrThrow(dto.episodeId);
const now = new Date().toISOString();
const jobId = randomUUID(); const jobId = randomUUID();
const upload = this.bytePlusMediaProvider.createDirectUpload({ const upload = this.bytePlusMediaProvider.createDirectUpload({
jobId, jobId,
fileName: dto.fileName, fileName: dto.fileName,
contentType: dto.contentType, contentType: dto.contentType,
}); });
const job: MediaUploadJobRecord = { const base = {
id: this.uploadJobSequence++,
jobId, jobId,
episodeId: dto.episodeId, episodeId: dto.episodeId,
status: 'pending', status: 'pending',
@@ -76,21 +102,77 @@ export class MediaService {
bucket: upload.bucket, bucket: upload.bucket,
objectKey: upload.objectKey, objectKey: upload.objectKey,
uploadUrl: upload.uploadUrl, uploadUrl: upload.uploadUrl,
reviewStatus: 'not_submitted', reviewStatus: 'not_submitted' as const,
rawResponse: { rawResponse: {
headers: upload.headers, headers: upload.headers,
expiresAt: upload.expiresAt, expiresAt: upload.expiresAt,
}, },
};
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
return this.toJobRecord(await repository.save(repository.create(base)));
}
const now = new Date().toISOString();
const job: MediaUploadJobRecord = {
id: this.uploadJobSequence++,
...base,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
this.uploadJobs.push(job); this.uploadJobs.push(job);
return job; return job;
} }
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> { async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
const job = this.getUploadJobOrThrow(jobId); if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const job = await repository.findOne({ where: { jobId } });
if (!job || !job.objectKey || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const completed = this.bytePlusMediaProvider.completeUpload({
jobId: job.jobId,
objectKey: job.objectKey,
});
const review = this.bytePlusMediaProvider.submitReview(
completed.byteplusVid,
);
job.byteplusVid = completed.byteplusVid;
job.videoUrl = completed.videoUrl;
job.width = completed.width;
job.height = completed.height;
job.durationSeconds = completed.durationSeconds;
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
job.rawResponse = {
...(job.rawResponse ?? {}),
upload: completed.rawResponse,
review,
};
await repository.save(job);
await this.dramasService.attachEpisodeMedia({
episodeId: Number(job.episodeId),
byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl,
storageBucket: job.bucket ?? '',
objectKey: job.objectKey,
durationSeconds: completed.durationSeconds,
width: completed.width,
height: completed.height,
reviewStatus: review.reviewStatus,
reviewMessage: review.reviewMessage,
});
return this.toJobRecord(job);
}
const job = this.getMemJobOrThrow(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');
} }
@@ -135,7 +217,27 @@ export class MediaService {
} }
async retryReview(jobId: string): Promise<MediaUploadJobRecord> { async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
const job = this.getUploadJobOrThrow(jobId); if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const job = await repository.findOne({ where: { jobId } });
if (!job || !job.byteplusVid || !job.episodeId) {
throw new NotFoundException('Upload task not found');
}
const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
await repository.save(job);
await this.dramasService.updateEpisodeReviewStatus(
Number(job.episodeId),
review.reviewStatus,
review.reviewMessage,
);
return this.toJobRecord(job);
}
const job = this.getMemJobOrThrow(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');
} }
@@ -154,6 +256,33 @@ export class MediaService {
} }
async syncReviewStatuses() { async syncReviewStatuses() {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(MediaUploadJob);
const candidates = await repository.find({
where: [{ reviewStatus: 'reviewing' }, { reviewStatus: 'pending' }],
});
const reviewingJobs = candidates.filter(
(job) => job.byteplusVid && job.episodeId,
);
for (const job of reviewingJobs) {
const review = this.bytePlusMediaProvider.queryReviewStatus(
job.byteplusVid as string,
);
job.status = review.reviewStatus;
job.reviewStatus = review.reviewStatus;
job.reviewMessage = review.reviewMessage;
await repository.save(job);
await this.dramasService.updateEpisodeReviewStatus(
Number(job.episodeId),
review.reviewStatus,
review.reviewMessage,
);
}
return { status: 'completed', synced: reviewingJobs.length };
}
const reviewingJobs = this.uploadJobs.filter( const reviewingJobs = this.uploadJobs.filter(
(job) => (job) =>
job.byteplusVid && job.byteplusVid &&
@@ -176,35 +305,55 @@ export class MediaService {
); );
} }
return { return { status: 'completed', synced: reviewingJobs.length };
status: 'completed',
synced: reviewingJobs.length,
};
} }
getUploadTask(jobId: string): MediaUploadJobRecord { async getUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
return this.getUploadJobOrThrow(jobId); return this.getUploadJobOrThrow(jobId);
} }
listUploadJobs( async listUploadJobs(
input: PaginationInput, input: PaginationInput,
): PaginatedResponse<MediaUploadJobRecord> { ): Promise<PaginatedResponse<MediaUploadJobRecord>> {
const page = Math.max(input.page, 1); const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
const records = [...this.uploadJobs].reverse();
if (this.hasDatabase()) {
const [records, total] = await this.dataSource
.getRepository(MediaUploadJob)
.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((job) => this.toJobRecord(job)),
pagination: { page, pageSize, total },
};
}
const records = [...this.uploadJobs].reverse();
return { return {
list: records.slice(start, start + pageSize), list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { pagination: { page, pageSize, total: this.uploadJobs.length },
page,
pageSize,
total: records.length,
},
}; };
} }
private getUploadJobOrThrow(jobId: string) { private async getUploadJobOrThrow(jobId: string): Promise<MediaUploadJobRecord> {
if (this.hasDatabase()) {
const job = await this.dataSource
.getRepository(MediaUploadJob)
.findOne({ where: { jobId } });
if (!job) {
throw new NotFoundException('Upload task not found');
}
return this.toJobRecord(job);
}
return this.getMemJobOrThrow(jobId);
}
private getMemJobOrThrow(jobId: string): MediaUploadJobRecord {
const job = this.uploadJobs.find((item) => item.jobId === jobId); const job = this.uploadJobs.find((item) => item.jobId === jobId);
if (!job) { if (!job) {
throw new NotFoundException('Upload task not found'); throw new NotFoundException('Upload task not found');
@@ -212,4 +361,52 @@ export class MediaService {
return job; return job;
} }
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toCoverRecord(cover: Cover): CoverRecord {
return {
id: Number(cover.id),
fileName: cover.fileName,
url: cover.url,
createdAt: this.toIso(cover.createdAt),
};
}
private toJobRecord(job: MediaUploadJob): MediaUploadJobRecord {
return {
id: Number(job.id),
jobId: job.jobId,
episodeId:
job.episodeId === null || job.episodeId === undefined
? undefined
: Number(job.episodeId),
byteplusVid: job.byteplusVid,
status: job.status,
fileName: job.fileName,
contentType: job.contentType,
fileSize: job.fileSize,
bucket: job.bucket,
objectKey: job.objectKey,
uploadUrl: job.uploadUrl,
videoUrl: job.videoUrl,
width: job.width,
height: job.height,
durationSeconds: job.durationSeconds,
reviewStatus: job.reviewStatus,
reviewMessage: job.reviewMessage,
rawResponse: job.rawResponse,
createdAt: this.toIso(job.createdAt),
updatedAt: this.toIso(job.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
} }

View File

@@ -1,15 +1,21 @@
import { import {
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 { DramasService } from '../dramas/dramas.service'; import { DramasService } from '../dramas/dramas.service';
import { UserRecord } from '../users/interfaces/user-record.interface'; import { UserRecord } from '../users/interfaces/user-record.interface';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto'; import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
import { Order } from './entities/order.entity';
import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity';
import { import {
OrderRecord, OrderRecord,
OrderStatus,
UserEpisodeUnlockRecord, UserEpisodeUnlockRecord,
} from './interfaces/order-records.interface'; } from './interfaces/order-records.interface';
@@ -20,7 +26,12 @@ export class OrdersService {
private readonly orders: OrderRecord[] = []; private readonly orders: OrderRecord[] = [];
private readonly unlocks: UserEpisodeUnlockRecord[] = []; private readonly unlocks: UserEpisodeUnlockRecord[] = [];
constructor(private readonly dramasService: DramasService) {} constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> { async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow( const episode = await this.dramasService.getPublishedEpisodeOrThrow(
@@ -30,15 +41,34 @@ export class OrdersService {
throw new UnprocessableEntityException('Episode is free'); throw new UnprocessableEntityException('Episode is free');
} }
const amount = episode.price ?? episode.unlockPrice ?? 0;
const orderNo = this.createOrderNo();
const tradeOrderId = this.createTradeOrderId();
this.orderSequence++;
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Order);
const entity = repository.create({
orderNo,
tradeOrderId,
userId: user.id,
albumId: episode.albumId,
episodeId: episode.id,
amount,
status: 'pending',
});
return this.toOrderRecord(await repository.save(entity));
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const order: OrderRecord = { const order: OrderRecord = {
id: this.orderSequence++, id: this.orders.length + 1,
orderNo: this.createOrderNo(), orderNo,
tradeOrderId: this.createTradeOrderId(), tradeOrderId,
userId: user.id, userId: user.id,
albumId: episode.albumId, albumId: episode.albumId,
episodeId: episode.id, episodeId: episode.id,
amount: episode.price ?? episode.unlockPrice ?? 0, amount,
status: 'pending', status: 'pending',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -51,10 +81,7 @@ export class OrdersService {
async getPlayback(user: UserRecord, episodeId: number) { async getPlayback(user: UserRecord, episodeId: number) {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
const isUnlocked = const isUnlocked =
!episode.isPaid || !episode.isPaid || (await this.isUnlocked(user.id, episodeId));
this.unlocks.some(
(unlock) => unlock.userId === user.id && unlock.episodeId === episodeId,
);
return { return {
episodeId: episode.id, episodeId: episode.id,
@@ -68,7 +95,68 @@ export class OrdersService {
}; };
} }
handlePaymentWebhook(dto: TikTokPaymentWebhookDto) { async handlePaymentWebhook(dto: TikTokPaymentWebhookDto) {
if (this.hasDatabase()) {
const orderRepository = this.dataSource.getRepository(Order);
const where: Record<string, string>[] = [];
if (dto.orderNo) {
where.push({ orderNo: dto.orderNo });
}
if (dto.tradeOrderId) {
where.push({ tradeOrderId: dto.tradeOrderId });
}
const order = where.length
? await orderRepository.findOne({ where })
: null;
if (!order) {
throw new NotFoundException('Order not found');
}
if (order.status === 'paid') {
return {
orderNo: order.orderNo,
status: order.status,
unlocked: await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
),
};
}
order.providerTransactionId = dto.providerTransactionId;
order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId;
order.status = dto.status;
await orderRepository.save(order);
if (
dto.status === 'paid' &&
!(await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
))
) {
const unlockRepository = this.dataSource.getRepository(UserEpisodeUnlock);
await unlockRepository.save(
unlockRepository.create({
userId: Number(order.userId),
episodeId: Number(order.episodeId),
source: 'payment',
orderNo: order.orderNo,
}),
);
}
return {
orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId,
status: order.status,
unlocked: await this.isUnlocked(
Number(order.userId),
Number(order.episodeId),
),
};
}
const order = this.orders.find( const order = this.orders.find(
(item) => (item) =>
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId, item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
@@ -81,7 +169,7 @@ export class OrdersService {
return { return {
orderNo: order.orderNo, orderNo: order.orderNo,
status: order.status, status: order.status,
unlocked: this.isUnlocked(order.userId, order.episodeId), unlocked: await this.isUnlocked(order.userId, order.episodeId),
}; };
} }
@@ -90,7 +178,10 @@ export class OrdersService {
order.status = dto.status; order.status = dto.status;
order.updatedAt = new Date().toISOString(); order.updatedAt = new Date().toISOString();
if (dto.status === 'paid' && !this.isUnlocked(order.userId, order.episodeId)) { if (
dto.status === 'paid' &&
!(await this.isUnlocked(order.userId, order.episodeId))
) {
this.unlocks.push({ this.unlocks.push({
id: this.unlockSequence++, id: this.unlockSequence++,
userId: order.userId, userId: order.userId,
@@ -105,40 +196,95 @@ export class OrdersService {
orderNo: order.orderNo, orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId, tradeOrderId: order.tradeOrderId,
status: order.status, status: order.status,
unlocked: this.isUnlocked(order.userId, order.episodeId), unlocked: await this.isUnlocked(order.userId, order.episodeId),
}; };
} }
async hasEpisodePermission(userId: number, episodeId: number) { async hasEpisodePermission(userId: number, episodeId: number) {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
return !episode.isPaid || this.isUnlocked(userId, episodeId); return !episode.isPaid || (await this.isUnlocked(userId, episodeId));
} }
listUnlockRecords(input: { async listUnlockRecords(input: {
page: number; page: number;
pageSize: number; pageSize: number;
}): PaginatedResponse<UserEpisodeUnlockRecord> { }): Promise<PaginatedResponse<UserEpisodeUnlockRecord>> {
const page = Math.max(input.page, 1); const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
const records = [...this.unlocks].reverse();
if (this.hasDatabase()) {
const [records, total] = await this.dataSource
.getRepository(UserEpisodeUnlock)
.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toUnlockRecord(record)),
pagination: { page, pageSize, total },
};
}
const records = [...this.unlocks].reverse();
return { return {
list: records.slice(start, start + pageSize), list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { pagination: { page, pageSize, total: this.unlocks.length },
page,
pageSize,
total: records.length,
},
}; };
} }
private isUnlocked(userId: number, episodeId: number) { private async isUnlocked(userId: number, episodeId: number): Promise<boolean> {
if (this.hasDatabase()) {
const count = await this.dataSource
.getRepository(UserEpisodeUnlock)
.count({ where: { userId, episodeId } });
return count > 0;
}
return this.unlocks.some( return this.unlocks.some(
(unlock) => unlock.userId === userId && unlock.episodeId === episodeId, (unlock) => unlock.userId === userId && unlock.episodeId === episodeId,
); );
} }
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toOrderRecord(order: Order): OrderRecord {
return {
id: Number(order.id),
orderNo: order.orderNo,
tradeOrderId: order.tradeOrderId,
userId: Number(order.userId),
albumId: Number(order.albumId),
episodeId: Number(order.episodeId),
tiktokOrderId: order.tiktokOrderId,
amount: order.amount,
status: order.status as OrderStatus,
providerTransactionId: order.providerTransactionId,
createdAt: this.toIso(order.createdAt),
updatedAt: this.toIso(order.updatedAt),
};
}
private toUnlockRecord(unlock: UserEpisodeUnlock): UserEpisodeUnlockRecord {
return {
id: Number(unlock.id),
userId: Number(unlock.userId),
episodeId: Number(unlock.episodeId),
source: unlock.source as 'payment' | 'admin' | 'free',
orderNo: unlock.orderNo,
createdAt: this.toIso(unlock.createdAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private createOrderNo() { private createOrderNo() {
return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`; return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
} }

View File

@@ -122,6 +122,89 @@ describe('Dramas and episodes', () => {
}); });
}); });
it('creates episode slots when a new drama declares total episodes', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Preconfigured Slots Drama',
coverUrl: 'https://cdn.example.com/preconfigured-slots.jpg',
type: 'romance',
status: 'draft',
totalEpisodes: 2,
})
.expect(201);
expect(drama.body.data).toMatchObject({
title: 'Preconfigured Slots Drama',
totalEpisodes: 2,
publishedEpisodes: 0,
});
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(episodes.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: '第1集',
publishStatus: 'draft',
}),
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 2,
title: '第2集',
publishStatus: 'draft',
}),
]);
});
it('updates an episode from the drama detail episodes endpoint', async () => {
const drama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Nested Episode Update Drama',
coverUrl: 'https://cdn.example.com/nested-update.jpg',
type: 'action',
status: 'draft',
totalEpisodes: 1,
})
.expect(201);
const episodes = await request(app.getHttpServer())
.get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const episodeId = episodes.body.data.list[0].id;
const updated = await request(app.getHttpServer())
.patch(`/api/admin/v1/dramas/${drama.body.data.id}/episodes/${episodeId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
price: 199,
})
.expect(200);
expect(updated.body.data).toMatchObject({
id: episodeId,
dramaId: drama.body.data.id,
title: 'Updated Nested Episode',
description: 'Updated from drama detail.',
durationSeconds: 240,
freeType: 'paid',
isPaid: true,
price: 199,
});
});
it('returns only published dramas and published episodes to app APIs', async () => { it('returns only published dramas and published episodes to app APIs', async () => {
const published = await request(app.getHttpServer()) const published = await request(app.getHttpServer())
.post('/api/admin/v1/dramas') .post('/api/admin/v1/dramas')
@@ -197,4 +280,45 @@ describe('Dramas and episodes', () => {
}), }),
]); ]);
}); });
it('filters published dramas by title and defaults app page size to 10', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Searchable Moon Drama',
coverUrl: 'https://cdn.example.com/searchable-moon.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Hidden Sun Drama',
coverUrl: 'https://cdn.example.com/hidden-sun.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/api/app/v1/dramas')
.query({ title: 'Moon' })
.expect(200);
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Searchable Moon Drama' }),
]),
);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Hidden Sun Drama' }),
]),
);
});
}); });