feat: 添加数据分析模块、媒体上传功能与剧集管理完善

本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
2026-07-01 16:36:06 +08:00
parent 5fa8c5e0a9
commit 026665b2cc
28 changed files with 1437 additions and 20 deletions

View File

@@ -1,11 +1,15 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'crypto';
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 {
CoverRecord,
MediaUploadJobRecord,
} from './interfaces/media-records.interface';
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
interface PaginationInput {
page: number;
@@ -19,6 +23,11 @@ export class MediaService {
private readonly covers: CoverRecord[] = [];
private readonly uploadJobs: MediaUploadJobRecord[] = [];
constructor(
private readonly dramasService: DramasService,
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
) {}
createCover(dto: CreateCoverDto): CoverRecord {
const cover: CoverRecord = {
id: this.coverSequence++,
@@ -45,6 +54,136 @@ export class MediaService {
return job;
}
createDirectUploadTask(dto: CreateDirectUploadTaskDto): MediaUploadJobRecord {
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++,
jobId,
episodeId: dto.episodeId,
status: 'pending',
fileName: dto.fileName,
contentType: dto.contentType,
fileSize: dto.fileSize,
bucket: upload.bucket,
objectKey: upload.objectKey,
uploadUrl: upload.uploadUrl,
reviewStatus: 'not_submitted',
rawResponse: {
headers: upload.headers,
expiresAt: upload.expiresAt,
},
createdAt: now,
updatedAt: now,
};
this.uploadJobs.push(job);
return job;
}
completeDirectUploadTask(jobId: string): MediaUploadJobRecord {
const job = this.getUploadJobOrThrow(jobId);
if (!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);
Object.assign(job, {
byteplusVid: completed.byteplusVid,
videoUrl: completed.videoUrl,
width: completed.width,
height: completed.height,
durationSeconds: completed.durationSeconds,
status: review.reviewStatus,
reviewStatus: review.reviewStatus,
reviewMessage: review.reviewMessage,
rawResponse: {
...job.rawResponse,
upload: completed.rawResponse,
review,
},
updatedAt: new Date().toISOString(),
});
this.dramasService.attachEpisodeMedia({
episodeId: 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 job;
}
retryReview(jobId: string): MediaUploadJobRecord {
const job = this.getUploadJobOrThrow(jobId);
if (!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;
job.updatedAt = new Date().toISOString();
this.dramasService.updateEpisodeReviewStatus(
job.episodeId,
review.reviewStatus,
review.reviewMessage,
);
return job;
}
syncReviewStatuses() {
const reviewingJobs = this.uploadJobs.filter(
(job) =>
job.byteplusVid &&
job.episodeId &&
(job.reviewStatus === 'reviewing' || job.reviewStatus === 'pending'),
);
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;
job.updatedAt = new Date().toISOString();
this.dramasService.updateEpisodeReviewStatus(
job.episodeId as number,
review.reviewStatus,
review.reviewMessage,
);
}
return {
status: 'completed',
synced: reviewingJobs.length,
};
}
getUploadTask(jobId: string): MediaUploadJobRecord {
return this.getUploadJobOrThrow(jobId);
}
listUploadJobs(
input: PaginationInput,
): PaginatedResponse<MediaUploadJobRecord> {
@@ -62,4 +201,13 @@ export class MediaService {
},
};
}
private getUploadJobOrThrow(jobId: string) {
const job = this.uploadJobs.find((item) => item.jobId === jobId);
if (!job) {
throw new NotFoundException('Upload task not found');
}
return job;
}
}