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

@@ -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 })
jobId: string;
@Column({ type: 'int', nullable: true })
episodeId?: number;
@Index()
@Column({ type: 'varchar', length: 128, nullable: true })
byteplusVid?: string;
@@ -23,6 +26,42 @@ export class MediaUploadJob {
@Column({ type: 'varchar', length: 64 })
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 })
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 { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { CreateCoverDto } from './dto/create-cover.dto';
import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto';
import { CreateUploadJobDto } from './dto/create-upload-job.dto';
import { Cover } from './entities/cover.entity';
import { MediaUploadJob } from './entities/media-upload-job.entity';
import {
CoverRecord,
MediaUploadJobRecord,
@@ -26,9 +30,20 @@ export class MediaService {
constructor(
private readonly dramasService: DramasService,
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 = {
id: this.coverSequence++,
fileName: dto.fileName,
@@ -39,7 +54,20 @@ export class MediaService {
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 job: MediaUploadJobRecord = {
id: this.uploadJobSequence++,
@@ -58,15 +86,13 @@ export class MediaService {
dto: CreateDirectUploadTaskDto,
): Promise<MediaUploadJobRecord> {
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
const now = new Date().toISOString();
const jobId = randomUUID();
const upload = this.bytePlusMediaProvider.createDirectUpload({
jobId,
fileName: dto.fileName,
contentType: dto.contentType,
});
const job: MediaUploadJobRecord = {
id: this.uploadJobSequence++,
const base = {
jobId,
episodeId: dto.episodeId,
status: 'pending',
@@ -76,21 +102,77 @@ export class MediaService {
bucket: upload.bucket,
objectKey: upload.objectKey,
uploadUrl: upload.uploadUrl,
reviewStatus: 'not_submitted',
reviewStatus: 'not_submitted' as const,
rawResponse: {
headers: upload.headers,
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,
updatedAt: now,
};
this.uploadJobs.push(job);
return job;
}
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) {
throw new NotFoundException('Upload task not found');
}
@@ -135,7 +217,27 @@ export class MediaService {
}
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) {
throw new NotFoundException('Upload task not found');
}
@@ -154,6 +256,33 @@ export class MediaService {
}
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(
(job) =>
job.byteplusVid &&
@@ -176,35 +305,55 @@ export class MediaService {
);
}
return {
status: 'completed',
synced: reviewingJobs.length,
};
return { status: 'completed', synced: reviewingJobs.length };
}
getUploadTask(jobId: string): MediaUploadJobRecord {
async getUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
return this.getUploadJobOrThrow(jobId);
}
listUploadJobs(
async listUploadJobs(
input: PaginationInput,
): PaginatedResponse<MediaUploadJobRecord> {
): Promise<PaginatedResponse<MediaUploadJobRecord>> {
const page = Math.max(input.page, 1);
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 {
list: records.slice(start, start + pageSize),
pagination: {
page,
pageSize,
total: records.length,
},
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
pagination: { page, pageSize, total: this.uploadJobs.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);
if (!job) {
throw new NotFoundException('Upload task not found');
@@ -212,4 +361,52 @@ export class MediaService {
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();
}
}