diff --git a/src/app.module.ts b/src/app.module.ts index 20b4990..8b79a55 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AdminModule } from './modules/admin/admin.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 { HealthModule } from './modules/health/health.module'; import { LogsModule } from './modules/logs/logs.module'; @@ -14,10 +16,12 @@ import { PlayerModule } from './modules/player/player.module'; ConfigModule.forRoot({ isGlobal: true, }), + DatabaseModule, AdminModule, HealthModule, LogsModule, DramasModule, + DataModule, MediaModule, AuthModule, OrdersModule, diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts index 16dd557..1472c03 100644 --- a/src/modules/admin/admin.service.ts +++ b/src/modules/admin/admin.service.ts @@ -275,6 +275,8 @@ export class AdminService { ['episode:update', '更新剧集'], ['media:read', '查看媒体'], ['media:create', '创建媒体'], + ['data:read', '查看数据'], + ['data:sync', '同步数据'], ['payment:read', '查看支付记录'], ['log:read', '查看日志'], ]; diff --git a/src/modules/data/data.controller.ts b/src/modules/data/data.controller.ts new file mode 100644 index 0000000..28b9396 --- /dev/null +++ b/src/modules/data/data.controller.ts @@ -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, + }); + } +} diff --git a/src/modules/data/data.module.ts b/src/modules/data/data.module.ts new file mode 100644 index 0000000..ebf7285 --- /dev/null +++ b/src/modules/data/data.module.ts @@ -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 {} diff --git a/src/modules/data/data.service.ts b/src/modules/data/data.service.ts new file mode 100644 index 0000000..1c95c7b --- /dev/null +++ b/src/modules/data/data.service.ts @@ -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(); + private readonly episodeMetrics = new Map(); + 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 { + return this.paginate(Array.from(this.dramaMetrics.values()), input); + } + + listEpisodeMetrics( + input: PaginationInput & { dramaId?: number }, + ): PaginatedResponse { + const records = Array.from(this.episodeMetrics.values()).filter( + (item) => !input.dramaId || item.dramaId === input.dramaId, + ); + return this.paginate(records, input); + } + + listSyncJobs(input: PaginationInput): PaginatedResponse { + return this.paginate(this.syncJobs, input); + } + + private paginate(records: T[], input: PaginationInput): PaginatedResponse { + 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, + }, + }; + } +} diff --git a/src/modules/data/entities/data-sync-job.entity.ts b/src/modules/data/entities/data-sync-job.entity.ts new file mode 100644 index 0000000..6612128 --- /dev/null +++ b/src/modules/data/entities/data-sync-job.entity.ts @@ -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; +} diff --git a/src/modules/data/entities/drama-metric.entity.ts b/src/modules/data/entities/drama-metric.entity.ts new file mode 100644 index 0000000..ddeb4db --- /dev/null +++ b/src/modules/data/entities/drama-metric.entity.ts @@ -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; +} diff --git a/src/modules/data/entities/episode-metric.entity.ts b/src/modules/data/entities/episode-metric.entity.ts new file mode 100644 index 0000000..754b31b --- /dev/null +++ b/src/modules/data/entities/episode-metric.entity.ts @@ -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; +} diff --git a/src/modules/data/providers/tiktok-data.provider.ts b/src/modules/data/providers/tiktok-data.provider.ts new file mode 100644 index 0000000..f640d98 --- /dev/null +++ b/src/modules/data/providers/tiktok-data.provider.ts @@ -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), + }; + } +} diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts new file mode 100644 index 0000000..450a4ae --- /dev/null +++ b/src/modules/database/database.module.ts @@ -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 {} diff --git a/src/modules/dramas/dramas.controller.ts b/src/modules/dramas/dramas.controller.ts index e6f67aa..b514282 100644 --- a/src/modules/dramas/dramas.controller.ts +++ b/src/modules/dramas/dramas.controller.ts @@ -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)); + } } diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index ebe1fe9..c8ee28b 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -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 { - 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 { + 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 { + 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 { 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( records: T[], input: PaginationInput, diff --git a/src/modules/dramas/dto/configure-episodes.dto.ts b/src/modules/dramas/dto/configure-episodes.dto.ts new file mode 100644 index 0000000..b5d7df4 --- /dev/null +++ b/src/modules/dramas/dto/configure-episodes.dto.ts @@ -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; +} diff --git a/src/modules/dramas/dto/create-episode.dto.ts b/src/modules/dramas/dto/create-episode.dto.ts index a58ac27..699fee6 100644 --- a/src/modules/dramas/dto/create-episode.dto.ts +++ b/src/modules/dramas/dto/create-episode.dto.ts @@ -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() diff --git a/src/modules/dramas/dto/update-drama.dto.ts b/src/modules/dramas/dto/update-drama.dto.ts new file mode 100644 index 0000000..8a63aa1 --- /dev/null +++ b/src/modules/dramas/dto/update-drama.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateDramaDto } from './create-drama.dto'; + +export class UpdateDramaDto extends PartialType(CreateDramaDto) {} diff --git a/src/modules/dramas/dto/update-episode.dto.ts b/src/modules/dramas/dto/update-episode.dto.ts new file mode 100644 index 0000000..3c5eadf --- /dev/null +++ b/src/modules/dramas/dto/update-episode.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateEpisodeDto } from './create-episode.dto'; + +export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {} diff --git a/src/modules/dramas/interfaces/drama-records.interface.ts b/src/modules/dramas/interfaces/drama-records.interface.ts index 284e7ad..9e9b643 100644 --- a/src/modules/dramas/interfaces/drama-records.interface.ts +++ b/src/modules/dramas/interfaces/drama-records.interface.ts @@ -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; diff --git a/src/modules/media/dto/complete-direct-upload-task.dto.ts b/src/modules/media/dto/complete-direct-upload-task.dto.ts new file mode 100644 index 0000000..629003f --- /dev/null +++ b/src/modules/media/dto/complete-direct-upload-task.dto.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class CompleteDirectUploadTaskDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + providerUploadId?: string; +} diff --git a/src/modules/media/dto/create-direct-upload-task.dto.ts b/src/modules/media/dto/create-direct-upload-task.dto.ts new file mode 100644 index 0000000..d06cb84 --- /dev/null +++ b/src/modules/media/dto/create-direct-upload-task.dto.ts @@ -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; +} diff --git a/src/modules/media/interfaces/media-records.interface.ts b/src/modules/media/interfaces/media-records.interface.ts index 459ea63..94357f4 100644 --- a/src/modules/media/interfaces/media-records.interface.ts +++ b/src/modules/media/interfaces/media-records.interface.ts @@ -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; createdAt: string; updatedAt: string; diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index 72722fb..2e5ac05 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -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( diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts index a0aeefb..49afd7f 100644 --- a/src/modules/media/media.module.ts +++ b/src/modules/media/media.module.ts @@ -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 {} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index ba3875f..6b54971 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -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 { @@ -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; + } } diff --git a/src/modules/media/providers/byteplus-media.provider.ts b/src/modules/media/providers/byteplus-media.provider.ts new file mode 100644 index 0000000..5fdff53 --- /dev/null +++ b/src/modules/media/providers/byteplus-media.provider.ts @@ -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}`, + }; + } +} diff --git a/src/modules/player/player.controller.ts b/src/modules/player/player.controller.ts index bb3dd17..bbf442b 100644 --- a/src/modules/player/player.controller.ts +++ b/src/modules/player/player.controller.ts @@ -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)); + } +} diff --git a/src/modules/player/player.module.ts b/src/modules/player/player.module.ts index f7dad10..135a17b 100644 --- a/src/modules/player/player.module.ts +++ b/src/modules/player/player.module.ts @@ -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 {} diff --git a/test/data-media-flow.e2e-spec.ts b/test/data-media-flow.e2e-spec.ts new file mode 100644 index 0000000..54f15be --- /dev/null +++ b/test/data-media-flow.e2e-spec.ts @@ -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), + }), + ]); + }); +}); diff --git a/test/database-persistence.e2e-spec.ts b/test/database-persistence.e2e-spec.ts new file mode 100644 index 0000000..3ad7214 --- /dev/null +++ b/test/database-persistence.e2e-spec.ts @@ -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() { + const records: T[] = []; + return { + records, + create: jest.fn((input: Partial) => 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 }) => + 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) { + 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(); + }); +});