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

@@ -0,0 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class CompleteDirectUploadTaskDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
providerUploadId?: string;
}

View 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;
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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 {}

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;
}
}

View 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}`,
};
}
}