feat: 添加数据分析模块、媒体上传功能与剧集管理完善
本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
9
src/modules/media/dto/complete-direct-upload-task.dto.ts
Normal file
9
src/modules/media/dto/complete-direct-upload-task.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CompleteDirectUploadTaskDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
providerUploadId?: string;
|
||||
}
|
||||
22
src/modules/media/dto/create-direct-upload-task.dto.ts
Normal file
22
src/modules/media/dto/create-direct-upload-task.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsString, Min } from 'class-validator';
|
||||
|
||||
export class CreateDirectUploadTaskDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
episodeId: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
fileName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
contentType: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
fileSize: number;
|
||||
}
|
||||
@@ -8,8 +8,21 @@ export interface CoverRecord {
|
||||
export interface MediaUploadJobRecord {
|
||||
id: number;
|
||||
jobId: string;
|
||||
episodeId?: number;
|
||||
byteplusVid?: string;
|
||||
status: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
fileSize?: number;
|
||||
bucket?: string;
|
||||
objectKey?: string;
|
||||
uploadUrl?: string;
|
||||
videoUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||
reviewMessage?: string;
|
||||
rawResponse?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
|
||||
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
|
||||
import { PermissionsGuard } from "../admin/guards/permissions.guard";
|
||||
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 { MediaService } from "./media.service";
|
||||
|
||||
@@ -26,6 +36,36 @@ export class MediaController {
|
||||
return this.mediaService.createUploadJob(dto);
|
||||
}
|
||||
|
||||
@Post("/upload-tasks")
|
||||
@RequirePermissions("media:create")
|
||||
createDirectUploadTask(@Body() dto: CreateDirectUploadTaskDto) {
|
||||
return this.mediaService.createDirectUploadTask(dto);
|
||||
}
|
||||
|
||||
@Get("/upload-tasks/:jobId")
|
||||
@RequirePermissions("media:read")
|
||||
getUploadTask(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.getUploadTask(jobId);
|
||||
}
|
||||
|
||||
@Patch("/upload-tasks/:jobId/complete")
|
||||
@RequirePermissions("media:create")
|
||||
completeDirectUploadTask(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.completeDirectUploadTask(jobId);
|
||||
}
|
||||
|
||||
@Post("/upload-tasks/:jobId/review/retry")
|
||||
@RequirePermissions("media:create")
|
||||
retryReview(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.retryReview(jobId);
|
||||
}
|
||||
|
||||
@Post("/review-status/sync")
|
||||
@RequirePermissions("media:create")
|
||||
syncReviewStatuses() {
|
||||
return this.mediaService.syncReviewStatuses();
|
||||
}
|
||||
|
||||
@Get("/upload-jobs")
|
||||
@RequirePermissions("media:read")
|
||||
listUploadJobs(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { DramasModule } from '../dramas/dramas.module';
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
|
||||
|
||||
@Module({
|
||||
imports: [AdminModule],
|
||||
imports: [AdminModule, DramasModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
providers: [MediaService, BytePlusMediaProvider],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
58
src/modules/media/providers/byteplus-media.provider.ts
Normal file
58
src/modules/media/providers/byteplus-media.provider.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
interface CreateUploadInput {
|
||||
jobId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface CompleteUploadInput {
|
||||
jobId: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BytePlusMediaProvider {
|
||||
createDirectUpload(input: CreateUploadInput) {
|
||||
const bucket = process.env.BYTEPLUS_BUCKET ?? 'mock-short-drama';
|
||||
const objectKey = `videos/${input.jobId}/${input.fileName}`;
|
||||
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
uploadUrl: `https://mock-byteplus-upload.local/${bucket}/${objectKey}`,
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
},
|
||||
expiresAt: Date.now() + 15 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
completeUpload(input: CompleteUploadInput) {
|
||||
return {
|
||||
byteplusVid: `mock_vid_${input.jobId.replace(/-/g, '_')}`,
|
||||
videoUrl: `https://mock-byteplus-cdn.local/${input.objectKey}`,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationSeconds: 180,
|
||||
rawResponse: {
|
||||
provider: 'byteplus-mock',
|
||||
objectKey: input.objectKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
submitReview(byteplusVid: string) {
|
||||
return {
|
||||
reviewStatus: 'reviewing' as const,
|
||||
reviewMessage: `Mock review submitted for ${byteplusVid}`,
|
||||
};
|
||||
}
|
||||
|
||||
queryReviewStatus(byteplusVid: string) {
|
||||
return {
|
||||
reviewStatus: 'approved' as const,
|
||||
reviewMessage: `Mock review approved for ${byteplusVid}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user