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

@@ -275,6 +275,8 @@ export class AdminService {
['episode:update', '更新剧集'],
['media:read', '查看媒体'],
['media:create', '创建媒体'],
['data:read', '查看数据'],
['data:sync', '同步数据'],
['payment:read', '查看支付记录'],
['log:read', '查看日志'],
];

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, 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 { DataService } from './data.service';
@ApiTags('Data')
@Controller('/api/admin/v1/data')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
export class DataController {
constructor(private readonly dataService: DataService) {}
@Post('/sync')
@RequirePermissions('data:sync')
sync(@Body() body: { dramaId?: number }) {
return this.dataService.sync(body);
}
@Get('/overview')
@RequirePermissions('data:read')
getOverview() {
return this.dataService.getOverview();
}
@Get('/dramas')
@RequirePermissions('data:read')
listDramaMetrics(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listDramaMetrics({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/episodes')
@RequirePermissions('data:read')
listEpisodeMetrics(
@Query('dramaId') dramaId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listEpisodeMetrics({
dramaId: dramaId ? Number(dramaId) : undefined,
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/sync-jobs')
@RequirePermissions('data:read')
listSyncJobs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listSyncJobs({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { DataController } from './data.controller';
import { DataService } from './data.service';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
@Module({
imports: [AdminModule, DramasModule],
controllers: [DataController],
providers: [DataService, TikTokDataProvider],
})
export class DataModule {}

View File

@@ -0,0 +1,153 @@
import { Injectable } from '@nestjs/common';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
interface PaginationInput {
page: number;
pageSize: number;
}
interface SyncInput {
dramaId?: number;
}
export interface DramaMetricRecord {
dramaId: number;
title: string;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface EpisodeMetricRecord {
dramaId: number;
episodeId: number;
title: string;
episodeNo: number;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface SyncJobRecord {
id: number;
jobId: string;
status: 'completed' | 'failed';
dramaId?: number;
dramaCount: number;
episodeCount: number;
createdAt: string;
}
@Injectable()
export class DataService {
private syncJobSequence = 1;
private readonly dramaMetrics = new Map<number, DramaMetricRecord>();
private readonly episodeMetrics = new Map<number, EpisodeMetricRecord>();
private readonly syncJobs: SyncJobRecord[] = [];
constructor(
private readonly dramasService: DramasService,
private readonly tiktokDataProvider: TikTokDataProvider,
) {}
sync(input: SyncInput = {}) {
const now = new Date().toISOString();
const dramas = this.dramasService
.listAllDramas()
.filter((drama) => !input.dramaId || drama.id === input.dramaId);
const dramaIds = new Set(dramas.map((drama) => drama.id));
const episodes = this.dramasService
.listAllEpisodes()
.filter((episode) => dramaIds.has(episode.dramaId));
for (const drama of dramas) {
this.dramaMetrics.set(drama.id, {
dramaId: drama.id,
title: drama.title,
...this.tiktokDataProvider.getDramaMetrics(drama),
syncedAt: now,
});
}
for (const episode of episodes) {
this.episodeMetrics.set(episode.id, {
dramaId: episode.dramaId,
episodeId: episode.id,
title: episode.title,
episodeNo: episode.episodeNo,
...this.tiktokDataProvider.getEpisodeMetrics(episode),
syncedAt: now,
});
}
const job: SyncJobRecord = {
id: this.syncJobSequence++,
jobId: `DATA_SYNC_${Date.now()}_${this.syncJobSequence}`,
status: 'completed',
dramaId: input.dramaId,
dramaCount: dramas.length,
episodeCount: episodes.length,
createdAt: now,
};
this.syncJobs.unshift(job);
return job;
}
getOverview() {
const dramaRecords = Array.from(this.dramaMetrics.values());
const episodeRecords = Array.from(this.episodeMetrics.values());
return {
totalDramas: dramaRecords.length,
totalEpisodes: episodeRecords.length,
totalPlays: episodeRecords.reduce((sum, item) => sum + item.plays, 0),
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
latestSyncedAt: this.syncJobs[0]?.createdAt,
};
}
listDramaMetrics(input: PaginationInput): PaginatedResponse<DramaMetricRecord> {
return this.paginate(Array.from(this.dramaMetrics.values()), input);
}
listEpisodeMetrics(
input: PaginationInput & { dramaId?: number },
): PaginatedResponse<EpisodeMetricRecord> {
const records = Array.from(this.episodeMetrics.values()).filter(
(item) => !input.dramaId || item.dramaId === input.dramaId,
);
return this.paginate(records, input);
}
listSyncJobs(input: PaginationInput): PaginatedResponse<SyncJobRecord> {
return this.paginate(this.syncJobs, input);
}
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
return {
list: records.slice(start, start + pageSize),
pagination: {
page,
pageSize,
total: records.length,
},
};
}
}

View File

@@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('data_sync_jobs')
export class DataSyncJob {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 128 })
jobId: string;
@Column({ type: 'varchar', length: 32 })
status: string;
@Column({ type: 'bigint', nullable: true })
dramaId?: number;
@Column({ type: 'int', default: 0 })
dramaCount: number;
@Column({ type: 'int', default: 0 })
episodeCount: number;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('drama_metrics')
export class DramaMetric {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('episode_metrics')
export class EpisodeMetric {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column({ type: 'bigint' })
dramaId: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'int' })
episodeNo: number;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { DramaRecord, EpisodeRecord } from '../../dramas/interfaces/drama-records.interface';
@Injectable()
export class TikTokDataProvider {
getDramaMetrics(drama: DramaRecord) {
const base = drama.id * 1000 + drama.totalEpisodes * 100;
return {
plays: base + 500,
likes: base + 80,
shares: drama.id * 13 + 20,
comments: drama.id * 7 + 12,
completionRate: 0.68,
averageWatchSeconds: 96,
};
}
getEpisodeMetrics(episode: EpisodeRecord) {
const base = episode.id * 500 + episode.episodeNo * 50;
return {
plays: base + 300,
likes: base + 40,
shares: episode.id * 5 + 8,
comments: episode.id * 3 + 6,
completionRate: 0.72,
averageWatchSeconds: Math.min(episode.durationSeconds || 180, 120),
};
}
}

View File

@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminUser } from '../admin/entities/admin-user.entity';
import { Permission } from '../admin/entities/permission.entity';
import { Role } from '../admin/entities/role.entity';
import { Drama } from '../dramas/entities/drama.entity';
import { Episode } from '../dramas/entities/episode.entity';
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
import { RequestLog } from '../logs/entities/request-log.entity';
import { MediaUploadJob } from '../media/entities/media-upload-job.entity';
import { Order } from '../orders/entities/order.entity';
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
import { User } from '../users/entities/user.entity';
const databaseImports = process.env.DB_HOST
? [
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT) || 3306,
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_DATABASE ?? 'cth_tk',
entities: [
AdminUser,
Permission,
Role,
Drama,
Episode,
PlaybackErrorLog,
RequestLog,
MediaUploadJob,
Order,
UserEpisodeUnlock,
User,
],
synchronize: process.env.DB_SYNCHRONIZE === 'true',
}),
]
: [];
@Module({
imports: databaseImports,
})
export class DatabaseModule {}

View File

@@ -1,10 +1,22 @@
import { Body, Controller, Get, Param, Patch, 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 { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
import { CreateDramaDto } from './dto/create-drama.dto';
import { CreateEpisodeDto } from './dto/create-episode.dto';
import { UpdateDramaDto } from './dto/update-drama.dto';
import { UpdateEpisodeDto } from './dto/update-episode.dto';
import { DramasService } from './dramas.service';
import { PublishStatus } from './drama-status.enum';
@@ -32,6 +44,48 @@ export class DramasController {
});
}
@Get('/api/admin/v1/dramas/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('drama:read')
getDrama(@Param('id') id: string) {
return this.dramasService.getDramaOrThrow(Number(id));
}
@Patch('/api/admin/v1/dramas/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('drama:update')
updateDrama(@Param('id') id: string, @Body() dto: UpdateDramaDto) {
return this.dramasService.updateDrama(Number(id), dto);
}
@Post('/api/admin/v1/dramas/:id/episodes/batch')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:create')
configureEpisodes(
@Param('id') id: string,
@Body() dto: ConfigureEpisodesDto,
) {
return this.dramasService.configureEpisodes(Number(id), dto);
}
@Get('/api/admin/v1/dramas/:id/episodes')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:update')
listAdminEpisodes(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dramasService.listAdminEpisodes(Number(id), {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Post('/api/admin/v1/episodes')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@@ -40,6 +94,14 @@ export class DramasController {
return this.dramasService.createEpisode(dto);
}
@Patch('/api/admin/v1/episodes/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('episode:update')
updateEpisode(@Param('id') id: string, @Body() dto: UpdateEpisodeDto) {
return this.dramasService.updateEpisode(Number(id), dto);
}
@Post('/api/admin/v1/dramas/:id/submit-review')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@@ -84,4 +146,9 @@ export class DramasController {
pageSize: Number(pageSize) || 20,
});
}
@Get('/api/app/v1/dramas/:id')
getPublishedDrama(@Param('id') id: string) {
return this.dramasService.getPublishedDramaOrThrow(Number(id));
}
}

View File

@@ -2,10 +2,14 @@ import {
ConflictException,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
import { CreateDramaDto } from './dto/create-drama.dto';
import { CreateEpisodeDto } from './dto/create-episode.dto';
import { UpdateDramaDto } from './dto/update-drama.dto';
import { UpdateEpisodeDto } from './dto/update-episode.dto';
import { PublishStatus } from './drama-status.enum';
import {
DramaRecord,
@@ -44,6 +48,8 @@ export class DramasService {
sortOrder: dto.sortOrder ?? 0,
isRecommended: dto.isRecommended ?? false,
onlineVersion: dto.onlineVersion ?? 1,
totalEpisodes: 0,
publishedEpisodes: 0,
createdAt: now,
updatedAt: now,
};
@@ -53,7 +59,30 @@ export class DramasService {
}
listAdminDramas(pagination: PaginationInput): PaginatedResponse<DramaRecord> {
return this.paginate(this.sortDramas(this.dramas), pagination);
return this.paginate(this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination);
}
getDramaOrThrow(id: number): DramaRecord {
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
}
return this.withEpisodeCounts(drama);
}
updateDrama(id: number, dto: UpdateDramaDto): DramaRecord {
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
}
Object.assign(drama, {
...dto,
tags: dto.tags ?? drama.tags,
updatedAt: new Date().toISOString(),
});
return this.withEpisodeCounts(drama);
}
createEpisode(dto: CreateEpisodeDto): EpisodeRecord {
@@ -79,6 +108,7 @@ export class DramasService {
const now = new Date().toISOString();
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
const price = dto.price ?? dto.unlockPrice ?? 0;
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
const episode: EpisodeRecord = {
id: this.episodeSequence++,
dramaId,
@@ -90,10 +120,10 @@ export class DramasService {
description: dto.description,
byteplusVid: dto.byteplusVid,
coverId: dto.coverId,
coverUrl: dto.coverUrl,
coverUrl: dto.coverUrl ?? '',
videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds,
durationSeconds: dto.durationSeconds ?? 0,
width: dto.width,
height: dto.height,
isPaid: freeType === 'paid',
@@ -102,9 +132,11 @@ export class DramasService {
price,
publishAt: dto.publishAt,
nextReleaseAt: dto.nextReleaseAt,
status: dto.status ?? dto.publishStatus ?? PublishStatus.Draft,
reviewStatus: dto.reviewStatus ?? 'pending',
publishStatus: dto.publishStatus ?? dto.status ?? PublishStatus.Draft,
status: publishStatus,
reviewStatus:
dto.reviewStatus ??
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'),
publishStatus,
createdAt: now,
updatedAt: now,
};
@@ -113,17 +145,161 @@ export class DramasService {
return episode;
}
configureEpisodes(
dramaId: number,
dto: ConfigureEpisodesDto,
): PaginatedResponse<EpisodeRecord> {
const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) {
throw new NotFoundException('Drama not found');
}
const existing = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some(
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
if (hasLockedEpisode) {
throw new UnprocessableEntityException(
'Only empty draft tail episodes can be removed',
);
}
for (const episode of removable) {
const index = this.episodes.findIndex((item) => item.id === episode.id);
if (index >= 0) {
this.episodes.splice(index, 1);
}
}
}
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
this.createEpisode({
dramaId,
episodeNo,
title: `${episodeNo}`,
coverUrl: drama.coverUrl,
durationSeconds: 0,
freeType: 'free',
price: 0,
reviewStatus: 'not_submitted',
publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft,
});
}
const list = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return {
list,
pagination: {
page: 1,
pageSize: list.length,
total: list.length,
},
};
}
listAdminEpisodes(
dramaId: number,
pagination: PaginationInput,
): PaginatedResponse<EpisodeRecord> {
this.getDramaOrThrow(dramaId);
const records = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(records, pagination);
}
updateEpisode(id: number, dto: UpdateEpisodeDto): EpisodeRecord {
const episode = this.getEpisodeOrThrow(id);
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free');
Object.assign(episode, {
...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
albumId: dto.albumId ?? dto.dramaId ?? episode.albumId,
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
freeType,
isPaid: freeType === 'paid',
price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status,
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
updatedAt: new Date().toISOString(),
});
return episode;
}
attachEpisodeMedia(input: {
episodeId: number;
byteplusVid: string;
videoUrl: string;
storageBucket: string;
objectKey: string;
durationSeconds: number;
width: number;
height: number;
reviewStatus: EpisodeRecord['reviewStatus'];
reviewMessage?: string;
}): EpisodeRecord {
const episode = this.getEpisodeOrThrow(input.episodeId);
episode.byteplusVid = input.byteplusVid;
episode.videoUrl = input.videoUrl;
episode.storageBucket = input.storageBucket;
episode.objectKey = input.objectKey;
episode.durationSeconds = input.durationSeconds;
episode.width = input.width;
episode.height = input.height;
episode.reviewStatus = input.reviewStatus;
episode.reviewMessage = input.reviewMessage;
episode.updatedAt = new Date().toISOString();
return episode;
}
updateEpisodeReviewStatus(
episodeId: number,
reviewStatus: EpisodeRecord['reviewStatus'],
reviewMessage?: string,
): EpisodeRecord {
const episode = this.getEpisodeOrThrow(episodeId);
episode.reviewStatus = reviewStatus;
episode.reviewMessage = reviewMessage;
episode.updatedAt = new Date().toISOString();
return episode;
}
listPublishedDramas(
pagination: PaginationInput,
): PaginatedResponse<DramaRecord> {
return this.paginate(
this.sortDramas(
this.dramas.filter((item) => item.status === PublishStatus.Published),
this.dramas
.filter((item) => item.status === PublishStatus.Published)
.map((drama) => this.withEpisodeCounts(drama)),
),
pagination,
);
}
getPublishedDramaOrThrow(id: number): DramaRecord {
const drama = this.dramas.find(
(item) => item.id === id && item.status === PublishStatus.Published,
);
if (!drama) {
throw new NotFoundException('Drama not found');
}
return this.withEpisodeCounts(drama);
}
listPublishedEpisodes(
dramaId: number,
pagination: PaginationInput,
@@ -138,6 +314,11 @@ export class DramasService {
(item) =>
item.dramaId === dramaId && item.status === PublishStatus.Published,
)
.filter(
(item) =>
item.publishStatus === PublishStatus.Published &&
item.reviewStatus === 'approved',
)
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(episodes, pagination);
@@ -145,7 +326,12 @@ export class DramasService {
getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord {
const episode = this.episodes.find((item) => item.id === episodeId);
if (!episode || episode.status !== PublishStatus.Published) {
if (
!episode ||
episode.status !== PublishStatus.Published ||
episode.publishStatus !== PublishStatus.Published ||
episode.reviewStatus !== 'approved'
) {
throw new NotFoundException('Episode not found');
}
@@ -175,16 +361,25 @@ export class DramasService {
this.episodes
.filter((episode) => episode.albumId === albumId)
.forEach((episode) => {
episode.reviewStatus = 'pending';
episode.reviewStatus = 'approved';
episode.reviewMessage = 'Mock review approved';
episode.updatedAt = new Date().toISOString();
});
return {
albumId,
reviewStatus: 'pending',
reviewStatus: 'approved',
};
}
listAllDramas() {
return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama)));
}
listAllEpisodes() {
return [...this.episodes].sort((left, right) => left.id - right.id);
}
updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) {
const episode = this.getEpisodeOrThrow(episodeId);
episode.publishStatus = publishStatus;
@@ -203,6 +398,19 @@ export class DramasService {
});
}
private withEpisodeCounts(drama: DramaRecord): DramaRecord {
const episodes = this.episodes.filter((episode) => episode.dramaId === drama.id);
return {
...drama,
totalEpisodes: episodes.length,
publishedEpisodes: episodes.filter(
(episode) =>
episode.publishStatus === PublishStatus.Published &&
episode.reviewStatus === 'approved',
).length,
};
}
private paginate<T>(
records: T[],
input: PaginationInput,

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Max, Min } from 'class-validator';
export class ConfigureEpisodesDto {
@ApiProperty()
@IsInt()
@Min(1)
@Max(500)
episodeCount: number;
}

View File

@@ -131,10 +131,12 @@ export class CreateEpisodeDto {
@IsEnum(PublishStatus)
status?: PublishStatus;
@ApiPropertyOptional({ enum: ['pending', 'approved', 'rejected'] })
@ApiPropertyOptional({
enum: ['not_submitted', 'pending', 'reviewing', 'approved', 'rejected'],
})
@IsOptional()
@IsString()
reviewStatus?: 'pending' | 'approved' | 'rejected';
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
@ApiPropertyOptional({ enum: PublishStatus })
@IsOptional()

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDramaDto } from './create-drama.dto';
export class UpdateDramaDto extends PartialType(CreateDramaDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateEpisodeDto } from './create-episode.dto';
export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {}

View File

@@ -18,6 +18,8 @@ export interface DramaRecord {
sortOrder: number;
isRecommended: boolean;
onlineVersion: number;
totalEpisodes: number;
publishedEpisodes: number;
createdAt: string;
updatedAt: string;
}
@@ -35,6 +37,8 @@ export interface EpisodeRecord {
coverId?: number;
coverUrl: string;
videoUrl?: string;
storageBucket?: string;
objectKey?: string;
trialDurationSeconds?: number;
durationSeconds: number;
width?: number;
@@ -46,7 +50,8 @@ export interface EpisodeRecord {
publishAt?: string;
nextReleaseAt?: string;
status: PublishStatus;
reviewStatus: 'pending' | 'approved' | 'rejected';
reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
reviewMessage?: string;
publishStatus: PublishStatus;
createdAt: string;
updatedAt: string;

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

View File

@@ -17,3 +17,16 @@ export class PlayerController {
return this.playerService.getEpisodePlayer(user, Number(id));
}
}
@ApiTags('Player')
@Controller('/api/app/v1/episodes')
export class EpisodePlayerController {
constructor(private readonly playerService: PlayerService) {}
@Get('/:id/player')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
getEpisodePlayer(@CurrentUser() user: UserRecord, @Param('id') id: string) {
return this.playerService.getEpisodePlayer(user, Number(id));
}
}

View File

@@ -3,12 +3,12 @@ import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module';
import { OrdersModule } from '../orders/orders.module';
import { UsersModule } from '../users/users.module';
import { PlayerController } from './player.controller';
import { EpisodePlayerController, PlayerController } from './player.controller';
import { PlayerService } from './player.service';
@Module({
imports: [AuthModule, DramasModule, OrdersModule, UsersModule],
controllers: [PlayerController],
controllers: [PlayerController, EpisodePlayerController],
providers: [PlayerService],
})
export class PlayerModule {}