feat: 添加数据分析模块、媒体上传功能与剧集管理完善
本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { DataModule } from './modules/data/data.module';
|
||||||
|
import { DatabaseModule } from './modules/database/database.module';
|
||||||
import { DramasModule } from './modules/dramas/dramas.module';
|
import { DramasModule } from './modules/dramas/dramas.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { LogsModule } from './modules/logs/logs.module';
|
import { LogsModule } from './modules/logs/logs.module';
|
||||||
@@ -14,10 +16,12 @@ import { PlayerModule } from './modules/player/player.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
DatabaseModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
LogsModule,
|
LogsModule,
|
||||||
DramasModule,
|
DramasModule,
|
||||||
|
DataModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ export class AdminService {
|
|||||||
['episode:update', '更新剧集'],
|
['episode:update', '更新剧集'],
|
||||||
['media:read', '查看媒体'],
|
['media:read', '查看媒体'],
|
||||||
['media:create', '创建媒体'],
|
['media:create', '创建媒体'],
|
||||||
|
['data:read', '查看数据'],
|
||||||
|
['data:sync', '同步数据'],
|
||||||
['payment:read', '查看支付记录'],
|
['payment:read', '查看支付记录'],
|
||||||
['log:read', '查看日志'],
|
['log:read', '查看日志'],
|
||||||
];
|
];
|
||||||
|
|||||||
64
src/modules/data/data.controller.ts
Normal file
64
src/modules/data/data.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/data/data.module.ts
Normal file
13
src/modules/data/data.module.ts
Normal 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 {}
|
||||||
153
src/modules/data/data.service.ts
Normal file
153
src/modules/data/data.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/modules/data/entities/data-sync-job.entity.ts
Normal file
26
src/modules/data/entities/data-sync-job.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
48
src/modules/data/entities/drama-metric.entity.ts
Normal file
48
src/modules/data/entities/drama-metric.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/modules/data/entities/episode-metric.entity.ts
Normal file
55
src/modules/data/entities/episode-metric.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
29
src/modules/data/providers/tiktok-data.provider.ts
Normal file
29
src/modules/data/providers/tiktok-data.provider.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/modules/database/database.module.ts
Normal file
45
src/modules/database/database.module.ts
Normal 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 {}
|
||||||
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
|
||||||
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
|
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
|
||||||
import { PermissionsGuard } from '../admin/guards/permissions.guard';
|
import { PermissionsGuard } from '../admin/guards/permissions.guard';
|
||||||
|
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
|
||||||
import { CreateDramaDto } from './dto/create-drama.dto';
|
import { CreateDramaDto } from './dto/create-drama.dto';
|
||||||
import { CreateEpisodeDto } from './dto/create-episode.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 { DramasService } from './dramas.service';
|
||||||
import { PublishStatus } from './drama-status.enum';
|
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')
|
@Post('/api/admin/v1/episodes')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@@ -40,6 +94,14 @@ export class DramasController {
|
|||||||
return this.dramasService.createEpisode(dto);
|
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')
|
@Post('/api/admin/v1/dramas/:id/submit-review')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@@ -84,4 +146,9 @@ export class DramasController {
|
|||||||
pageSize: Number(pageSize) || 20,
|
pageSize: Number(pageSize) || 20,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/api/app/v1/dramas/:id')
|
||||||
|
getPublishedDrama(@Param('id') id: string) {
|
||||||
|
return this.dramasService.getPublishedDramaOrThrow(Number(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
|
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
|
||||||
import { CreateDramaDto } from './dto/create-drama.dto';
|
import { CreateDramaDto } from './dto/create-drama.dto';
|
||||||
import { CreateEpisodeDto } from './dto/create-episode.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 { PublishStatus } from './drama-status.enum';
|
||||||
import {
|
import {
|
||||||
DramaRecord,
|
DramaRecord,
|
||||||
@@ -44,6 +48,8 @@ export class DramasService {
|
|||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
isRecommended: dto.isRecommended ?? false,
|
isRecommended: dto.isRecommended ?? false,
|
||||||
onlineVersion: dto.onlineVersion ?? 1,
|
onlineVersion: dto.onlineVersion ?? 1,
|
||||||
|
totalEpisodes: 0,
|
||||||
|
publishedEpisodes: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -53,7 +59,30 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listAdminDramas(pagination: PaginationInput): PaginatedResponse<DramaRecord> {
|
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 {
|
createEpisode(dto: CreateEpisodeDto): EpisodeRecord {
|
||||||
@@ -79,6 +108,7 @@ export class DramasService {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
|
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
|
||||||
const price = dto.price ?? dto.unlockPrice ?? 0;
|
const price = dto.price ?? dto.unlockPrice ?? 0;
|
||||||
|
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
|
||||||
const episode: EpisodeRecord = {
|
const episode: EpisodeRecord = {
|
||||||
id: this.episodeSequence++,
|
id: this.episodeSequence++,
|
||||||
dramaId,
|
dramaId,
|
||||||
@@ -90,10 +120,10 @@ export class DramasService {
|
|||||||
description: dto.description,
|
description: dto.description,
|
||||||
byteplusVid: dto.byteplusVid,
|
byteplusVid: dto.byteplusVid,
|
||||||
coverId: dto.coverId,
|
coverId: dto.coverId,
|
||||||
coverUrl: dto.coverUrl,
|
coverUrl: dto.coverUrl ?? '',
|
||||||
videoUrl: dto.videoUrl,
|
videoUrl: dto.videoUrl,
|
||||||
trialDurationSeconds: dto.trialDurationSeconds,
|
trialDurationSeconds: dto.trialDurationSeconds,
|
||||||
durationSeconds: dto.durationSeconds,
|
durationSeconds: dto.durationSeconds ?? 0,
|
||||||
width: dto.width,
|
width: dto.width,
|
||||||
height: dto.height,
|
height: dto.height,
|
||||||
isPaid: freeType === 'paid',
|
isPaid: freeType === 'paid',
|
||||||
@@ -102,9 +132,11 @@ export class DramasService {
|
|||||||
price,
|
price,
|
||||||
publishAt: dto.publishAt,
|
publishAt: dto.publishAt,
|
||||||
nextReleaseAt: dto.nextReleaseAt,
|
nextReleaseAt: dto.nextReleaseAt,
|
||||||
status: dto.status ?? dto.publishStatus ?? PublishStatus.Draft,
|
status: publishStatus,
|
||||||
reviewStatus: dto.reviewStatus ?? 'pending',
|
reviewStatus:
|
||||||
publishStatus: dto.publishStatus ?? dto.status ?? PublishStatus.Draft,
|
dto.reviewStatus ??
|
||||||
|
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'),
|
||||||
|
publishStatus,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -113,17 +145,161 @@ export class DramasService {
|
|||||||
return episode;
|
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(
|
listPublishedDramas(
|
||||||
pagination: PaginationInput,
|
pagination: PaginationInput,
|
||||||
): PaginatedResponse<DramaRecord> {
|
): PaginatedResponse<DramaRecord> {
|
||||||
return this.paginate(
|
return this.paginate(
|
||||||
this.sortDramas(
|
this.sortDramas(
|
||||||
this.dramas.filter((item) => item.status === PublishStatus.Published),
|
this.dramas
|
||||||
|
.filter((item) => item.status === PublishStatus.Published)
|
||||||
|
.map((drama) => this.withEpisodeCounts(drama)),
|
||||||
),
|
),
|
||||||
pagination,
|
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(
|
listPublishedEpisodes(
|
||||||
dramaId: number,
|
dramaId: number,
|
||||||
pagination: PaginationInput,
|
pagination: PaginationInput,
|
||||||
@@ -138,6 +314,11 @@ export class DramasService {
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.dramaId === dramaId && item.status === PublishStatus.Published,
|
item.dramaId === dramaId && item.status === PublishStatus.Published,
|
||||||
)
|
)
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.publishStatus === PublishStatus.Published &&
|
||||||
|
item.reviewStatus === 'approved',
|
||||||
|
)
|
||||||
.sort((left, right) => left.episodeNo - right.episodeNo);
|
.sort((left, right) => left.episodeNo - right.episodeNo);
|
||||||
|
|
||||||
return this.paginate(episodes, pagination);
|
return this.paginate(episodes, pagination);
|
||||||
@@ -145,7 +326,12 @@ export class DramasService {
|
|||||||
|
|
||||||
getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord {
|
||||||
const episode = this.episodes.find((item) => item.id === episodeId);
|
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');
|
throw new NotFoundException('Episode not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,16 +361,25 @@ export class DramasService {
|
|||||||
this.episodes
|
this.episodes
|
||||||
.filter((episode) => episode.albumId === albumId)
|
.filter((episode) => episode.albumId === albumId)
|
||||||
.forEach((episode) => {
|
.forEach((episode) => {
|
||||||
episode.reviewStatus = 'pending';
|
episode.reviewStatus = 'approved';
|
||||||
|
episode.reviewMessage = 'Mock review approved';
|
||||||
episode.updatedAt = new Date().toISOString();
|
episode.updatedAt = new Date().toISOString();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
albumId,
|
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) {
|
updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) {
|
||||||
const episode = this.getEpisodeOrThrow(episodeId);
|
const episode = this.getEpisodeOrThrow(episodeId);
|
||||||
episode.publishStatus = publishStatus;
|
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>(
|
private paginate<T>(
|
||||||
records: T[],
|
records: T[],
|
||||||
input: PaginationInput,
|
input: PaginationInput,
|
||||||
|
|||||||
10
src/modules/dramas/dto/configure-episodes.dto.ts
Normal file
10
src/modules/dramas/dto/configure-episodes.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -131,10 +131,12 @@ export class CreateEpisodeDto {
|
|||||||
@IsEnum(PublishStatus)
|
@IsEnum(PublishStatus)
|
||||||
status?: PublishStatus;
|
status?: PublishStatus;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['pending', 'approved', 'rejected'] })
|
@ApiPropertyOptional({
|
||||||
|
enum: ['not_submitted', 'pending', 'reviewing', 'approved', 'rejected'],
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
reviewStatus?: 'pending' | 'approved' | 'rejected';
|
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: PublishStatus })
|
@ApiPropertyOptional({ enum: PublishStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
4
src/modules/dramas/dto/update-drama.dto.ts
Normal file
4
src/modules/dramas/dto/update-drama.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateDramaDto } from './create-drama.dto';
|
||||||
|
|
||||||
|
export class UpdateDramaDto extends PartialType(CreateDramaDto) {}
|
||||||
4
src/modules/dramas/dto/update-episode.dto.ts
Normal file
4
src/modules/dramas/dto/update-episode.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateEpisodeDto } from './create-episode.dto';
|
||||||
|
|
||||||
|
export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {}
|
||||||
@@ -18,6 +18,8 @@ export interface DramaRecord {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isRecommended: boolean;
|
isRecommended: boolean;
|
||||||
onlineVersion: number;
|
onlineVersion: number;
|
||||||
|
totalEpisodes: number;
|
||||||
|
publishedEpisodes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,8 @@ export interface EpisodeRecord {
|
|||||||
coverId?: number;
|
coverId?: number;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
|
storageBucket?: string;
|
||||||
|
objectKey?: string;
|
||||||
trialDurationSeconds?: number;
|
trialDurationSeconds?: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -46,7 +50,8 @@ export interface EpisodeRecord {
|
|||||||
publishAt?: string;
|
publishAt?: string;
|
||||||
nextReleaseAt?: string;
|
nextReleaseAt?: string;
|
||||||
status: PublishStatus;
|
status: PublishStatus;
|
||||||
reviewStatus: 'pending' | 'approved' | 'rejected';
|
reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||||
|
reviewMessage?: string;
|
||||||
publishStatus: PublishStatus;
|
publishStatus: PublishStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
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 {
|
export interface MediaUploadJobRecord {
|
||||||
id: number;
|
id: number;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
episodeId?: number;
|
||||||
byteplusVid?: string;
|
byteplusVid?: string;
|
||||||
status: 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>;
|
rawResponse?: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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 { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
|
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
|
||||||
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
|
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
|
||||||
import { PermissionsGuard } from "../admin/guards/permissions.guard";
|
import { PermissionsGuard } from "../admin/guards/permissions.guard";
|
||||||
import { CreateCoverDto } from "./dto/create-cover.dto";
|
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 { CreateUploadJobDto } from "./dto/create-upload-job.dto";
|
||||||
import { MediaService } from "./media.service";
|
import { MediaService } from "./media.service";
|
||||||
|
|
||||||
@@ -26,6 +36,36 @@ export class MediaController {
|
|||||||
return this.mediaService.createUploadJob(dto);
|
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")
|
@Get("/upload-jobs")
|
||||||
@RequirePermissions("media:read")
|
@RequirePermissions("media:read")
|
||||||
listUploadJobs(
|
listUploadJobs(
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
|
import { DramasModule } from '../dramas/dramas.module';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminModule],
|
imports: [AdminModule, DramasModule],
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: [MediaService],
|
providers: [MediaService, BytePlusMediaProvider],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
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 { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
|
import { DramasService } from '../dramas/dramas.service';
|
||||||
import { CreateCoverDto } from './dto/create-cover.dto';
|
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 { CreateUploadJobDto } from './dto/create-upload-job.dto';
|
||||||
import {
|
import {
|
||||||
CoverRecord,
|
CoverRecord,
|
||||||
MediaUploadJobRecord,
|
MediaUploadJobRecord,
|
||||||
} from './interfaces/media-records.interface';
|
} from './interfaces/media-records.interface';
|
||||||
|
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -19,6 +23,11 @@ export class MediaService {
|
|||||||
private readonly covers: CoverRecord[] = [];
|
private readonly covers: CoverRecord[] = [];
|
||||||
private readonly uploadJobs: MediaUploadJobRecord[] = [];
|
private readonly uploadJobs: MediaUploadJobRecord[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dramasService: DramasService,
|
||||||
|
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
createCover(dto: CreateCoverDto): CoverRecord {
|
createCover(dto: CreateCoverDto): CoverRecord {
|
||||||
const cover: CoverRecord = {
|
const cover: CoverRecord = {
|
||||||
id: this.coverSequence++,
|
id: this.coverSequence++,
|
||||||
@@ -45,6 +54,136 @@ export class MediaService {
|
|||||||
return job;
|
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(
|
listUploadJobs(
|
||||||
input: PaginationInput,
|
input: PaginationInput,
|
||||||
): PaginatedResponse<MediaUploadJobRecord> {
|
): 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,16 @@ export class PlayerController {
|
|||||||
return this.playerService.getEpisodePlayer(user, Number(id));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { DramasModule } from '../dramas/dramas.module';
|
import { DramasModule } from '../dramas/dramas.module';
|
||||||
import { OrdersModule } from '../orders/orders.module';
|
import { OrdersModule } from '../orders/orders.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { PlayerController } from './player.controller';
|
import { EpisodePlayerController, PlayerController } from './player.controller';
|
||||||
import { PlayerService } from './player.service';
|
import { PlayerService } from './player.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, DramasModule, OrdersModule, UsersModule],
|
imports: [AuthModule, DramasModule, OrdersModule, UsersModule],
|
||||||
controllers: [PlayerController],
|
controllers: [PlayerController, EpisodePlayerController],
|
||||||
providers: [PlayerService],
|
providers: [PlayerService],
|
||||||
})
|
})
|
||||||
export class PlayerModule {}
|
export class PlayerModule {}
|
||||||
|
|||||||
231
test/data-media-flow.e2e-spec.ts
Normal file
231
test/data-media-flow.e2e-spec.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { bootstrapApp } from '../src/bootstrap';
|
||||||
|
import { loginAdmin } from './test-helpers';
|
||||||
|
|
||||||
|
describe('Data management and media upload flow', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrapApp(app);
|
||||||
|
await app.init();
|
||||||
|
adminToken = await loginAdmin(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a drama, configures episode slots, completes a direct upload, and exposes approved published content to app APIs', async () => {
|
||||||
|
const drama = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Upload Flow Drama',
|
||||||
|
description: 'Drama created by admin before uploading episodes.',
|
||||||
|
coverUrl: 'https://cdn.example.com/upload-flow-cover.jpg',
|
||||||
|
type: 'romance',
|
||||||
|
region: 'TH',
|
||||||
|
language: 'th',
|
||||||
|
status: 'draft',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const dramaId = drama.body.data.id as number;
|
||||||
|
|
||||||
|
const slots = await request(app.getHttpServer())
|
||||||
|
.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ episodeCount: 3 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(slots.body.data.list).toHaveLength(3);
|
||||||
|
expect(slots.body.data.list[0]).toMatchObject({
|
||||||
|
dramaId,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: '第1集',
|
||||||
|
publishStatus: 'draft',
|
||||||
|
reviewStatus: 'not_submitted',
|
||||||
|
});
|
||||||
|
|
||||||
|
const episodeId = slots.body.data.list[0].id as number;
|
||||||
|
|
||||||
|
const uploadTask = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/media/upload-tasks')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
episodeId,
|
||||||
|
fileName: 'episode-1.mp4',
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
fileSize: 1024,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(uploadTask.body.data).toMatchObject({
|
||||||
|
jobId: expect.any(String),
|
||||||
|
uploadUrl: expect.stringContaining('mock-byteplus-upload'),
|
||||||
|
bucket: expect.any(String),
|
||||||
|
objectKey: expect.stringContaining('episode-1.mp4'),
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const completed = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/complete`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(completed.body.data).toMatchObject({
|
||||||
|
jobId: uploadTask.body.data.jobId,
|
||||||
|
episodeId,
|
||||||
|
status: 'reviewing',
|
||||||
|
byteplusVid: expect.stringMatching(/^mock_vid_/),
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
durationSeconds: 180,
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/review/retry`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/media/review-status/sync')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/admin/v1/episodes/${episodeId}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: '正式第1集',
|
||||||
|
coverUrl: 'https://cdn.example.com/episode-1-cover.jpg',
|
||||||
|
freeType: 'free',
|
||||||
|
publishStatus: 'published',
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/admin/v1/dramas/${dramaId}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ status: 'published' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const appDetail = await request(app.getHttpServer())
|
||||||
|
.get(`/api/app/v1/dramas/${dramaId}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(appDetail.body.data).toMatchObject({
|
||||||
|
id: dramaId,
|
||||||
|
title: 'Upload Flow Drama',
|
||||||
|
totalEpisodes: 3,
|
||||||
|
publishedEpisodes: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appEpisodes = await request(app.getHttpServer())
|
||||||
|
.get(`/api/app/v1/dramas/${dramaId}/episodes`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(appEpisodes.body.data.list).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: episodeId,
|
||||||
|
title: '正式第1集',
|
||||||
|
reviewStatus: 'approved',
|
||||||
|
publishStatus: 'published',
|
||||||
|
byteplusVid: completed.body.data.byteplusVid,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs TikTok mock analytics and returns overview plus drama and episode metrics', async () => {
|
||||||
|
const drama = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
title: 'Analytics Drama',
|
||||||
|
coverUrl: 'https://cdn.example.com/analytics-cover.jpg',
|
||||||
|
type: 'action',
|
||||||
|
status: 'published',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const episode = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/episodes')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'Analytics Episode',
|
||||||
|
coverUrl: 'https://cdn.example.com/analytics-episode.jpg',
|
||||||
|
videoUrl: 'https://cdn.example.com/analytics-episode.mp4',
|
||||||
|
byteplusVid: 'mock_vid_analytics',
|
||||||
|
durationSeconds: 180,
|
||||||
|
reviewStatus: 'approved',
|
||||||
|
publishStatus: 'published',
|
||||||
|
status: 'published',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const sync = await request(app.getHttpServer())
|
||||||
|
.post('/api/admin/v1/data/sync')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ dramaId: drama.body.data.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(sync.body.data).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
dramaCount: expect.any(Number),
|
||||||
|
episodeCount: expect.any(Number),
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = await request(app.getHttpServer())
|
||||||
|
.get('/api/admin/v1/data/overview')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(overview.body.data).toMatchObject({
|
||||||
|
totalPlays: expect.any(Number),
|
||||||
|
totalLikes: expect.any(Number),
|
||||||
|
totalShares: expect.any(Number),
|
||||||
|
totalComments: expect.any(Number),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dramaMetrics = await request(app.getHttpServer())
|
||||||
|
.get('/api/admin/v1/data/dramas')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(dramaMetrics.body.data.list).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
title: 'Analytics Drama',
|
||||||
|
plays: expect.any(Number),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodeMetrics = await request(app.getHttpServer())
|
||||||
|
.get(`/api/admin/v1/data/episodes?dramaId=${drama.body.data.id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(episodeMetrics.body.data.list).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
dramaId: drama.body.data.id,
|
||||||
|
episodeId: episode.body.data.id,
|
||||||
|
title: 'Analytics Episode',
|
||||||
|
plays: expect.any(Number),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
test/database-persistence.e2e-spec.ts
Normal file
142
test/database-persistence.e2e-spec.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { DataService } from '../src/modules/data/data.service';
|
||||||
|
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
|
||||||
|
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
|
||||||
|
import { DramasService } from '../src/modules/dramas/dramas.service';
|
||||||
|
import { LogsService } from '../src/modules/logs/logs.service';
|
||||||
|
import { UsersService } from '../src/modules/users/users.service';
|
||||||
|
|
||||||
|
function createRepository<T extends { id?: number }>() {
|
||||||
|
const records: T[] = [];
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
create: jest.fn((input: Partial<T>) => input as T),
|
||||||
|
save: jest.fn(async (input: T) => {
|
||||||
|
if (!input.id) {
|
||||||
|
input.id = records.length + 1;
|
||||||
|
}
|
||||||
|
const index = records.findIndex((item) => item.id === input.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
records[index] = input;
|
||||||
|
} else {
|
||||||
|
records.push(input);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}),
|
||||||
|
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
|
||||||
|
records.find((item) =>
|
||||||
|
Object.entries(where).every(
|
||||||
|
([key, value]) => item[key as keyof T] === value,
|
||||||
|
),
|
||||||
|
) ?? null,
|
||||||
|
),
|
||||||
|
find: jest.fn(async () => [...records]),
|
||||||
|
count: jest.fn(async () => records.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDataSource(repositories: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
isInitialized: true,
|
||||||
|
getRepository: jest.fn((entity: { name: string }) => {
|
||||||
|
const repository = repositories[entity.name];
|
||||||
|
if (!repository) {
|
||||||
|
throw new Error(`Missing repository for ${entity.name}`);
|
||||||
|
}
|
||||||
|
return repository;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('database-backed persistence', () => {
|
||||||
|
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
|
||||||
|
const userRepository = createRepository();
|
||||||
|
const dataSource = createDataSource({ User: userRepository });
|
||||||
|
const service = new UsersService(dataSource as never);
|
||||||
|
|
||||||
|
const result = await service.upsertTikTokUser({
|
||||||
|
tiktokOpenId: 'open-db-1',
|
||||||
|
nickname: 'DB User',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isNewUser).toBe(true);
|
||||||
|
expect(userRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tiktokOpenId: 'open-db-1',
|
||||||
|
nickname: 'DB User',
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists request logs and playback error logs through TypeORM repositories', async () => {
|
||||||
|
const requestLogRepository = createRepository();
|
||||||
|
const playbackErrorLogRepository = createRepository();
|
||||||
|
const dataSource = createDataSource({
|
||||||
|
RequestLog: requestLogRepository,
|
||||||
|
PlaybackErrorLog: playbackErrorLogRepository,
|
||||||
|
});
|
||||||
|
const service = new LogsService(dataSource as never);
|
||||||
|
|
||||||
|
await service.recordRequest({
|
||||||
|
requestId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/v1/logs',
|
||||||
|
statusCode: 200,
|
||||||
|
responseCode: 0,
|
||||||
|
message: 'success',
|
||||||
|
costMs: 3,
|
||||||
|
});
|
||||||
|
await service.createPlaybackError({
|
||||||
|
requestId: '00000000-0000-4000-8000-000000000002',
|
||||||
|
dramaId: 1,
|
||||||
|
episodeId: 2,
|
||||||
|
playUrl: 'https://cdn.example.com/video.mp4',
|
||||||
|
playerErrorCode: 'NETWORK',
|
||||||
|
message: 'network error',
|
||||||
|
occurredAt: '2026-07-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestLogRepository.save).toHaveBeenCalled();
|
||||||
|
expect(playbackErrorLogRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists synced drama and episode metrics through TypeORM repositories', async () => {
|
||||||
|
const dramaMetricRepository = createRepository();
|
||||||
|
const episodeMetricRepository = createRepository();
|
||||||
|
const syncJobRepository = createRepository();
|
||||||
|
const dataSource = createDataSource({
|
||||||
|
DramaMetric: dramaMetricRepository,
|
||||||
|
EpisodeMetric: episodeMetricRepository,
|
||||||
|
DataSyncJob: syncJobRepository,
|
||||||
|
});
|
||||||
|
const dramasService = new DramasService();
|
||||||
|
const tiktokDataProvider = new TikTokDataProvider();
|
||||||
|
const service = new DataService(
|
||||||
|
dramasService,
|
||||||
|
tiktokDataProvider,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const drama = dramasService.createDrama({
|
||||||
|
title: 'DB Metrics',
|
||||||
|
coverUrl: 'https://cdn.example.com/cover.jpg',
|
||||||
|
type: 'action',
|
||||||
|
status: PublishStatus.Published,
|
||||||
|
});
|
||||||
|
dramasService.createEpisode({
|
||||||
|
dramaId: drama.id,
|
||||||
|
episodeNo: 1,
|
||||||
|
title: 'DB Metrics Episode',
|
||||||
|
coverUrl: drama.coverUrl,
|
||||||
|
videoUrl: 'https://cdn.example.com/video.mp4',
|
||||||
|
durationSeconds: 180,
|
||||||
|
status: PublishStatus.Published,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sync({ dramaId: drama.id });
|
||||||
|
|
||||||
|
expect(dramaMetricRepository.save).toHaveBeenCalled();
|
||||||
|
expect(episodeMetricRepository.save).toHaveBeenCalled();
|
||||||
|
expect(syncJobRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user