diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts index afdb43d..b97e7cd 100644 --- a/src/modules/database/database.module.ts +++ b/src/modules/database/database.module.ts @@ -10,6 +10,7 @@ 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 { Cover } from '../media/entities/cover.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'; @@ -35,6 +36,7 @@ const databaseImports = process.env.DB_HOST Episode, PlaybackErrorLog, RequestLog, + Cover, MediaUploadJob, Order, UserEpisodeUnlock, diff --git a/src/modules/dramas/dramas.controller.ts b/src/modules/dramas/dramas.controller.ts index b514282..ed84335 100644 --- a/src/modules/dramas/dramas.controller.ts +++ b/src/modules/dramas/dramas.controller.ts @@ -102,6 +102,22 @@ export class DramasController { return this.dramasService.updateEpisode(Number(id), dto); } + @Patch('/api/admin/v1/dramas/:dramaId/episodes/:episodeId') + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions('episode:update') + updateDramaEpisode( + @Param('dramaId') dramaId: string, + @Param('episodeId') episodeId: string, + @Body() dto: UpdateEpisodeDto, + ) { + return this.dramasService.updateDramaEpisode( + Number(dramaId), + Number(episodeId), + dto, + ); + } + @Post('/api/admin/v1/dramas/:id/submit-review') @ApiBearerAuth() @UseGuards(AdminJwtAuthGuard, PermissionsGuard) @@ -128,10 +144,12 @@ export class DramasController { listPublishedDramas( @Query('page') page?: string, @Query('pageSize') pageSize?: string, + @Query('title') title?: string, ) { return this.dramasService.listPublishedDramas({ page: Number(page) || 1, - pageSize: Number(pageSize) || 20, + pageSize: Number(pageSize) || 10, + title, }); } diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index bac3778..87d6806 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -6,7 +6,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; +import { DataSource, Like } from 'typeorm'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { ConfigureEpisodesDto } from './dto/configure-episodes.dto'; import { CreateDramaDto } from './dto/create-drama.dto'; @@ -26,6 +26,10 @@ interface PaginationInput { pageSize: number; } +interface PublishedDramaListInput extends PaginationInput { + title?: string; +} + @Injectable() export class DramasService { private dramaSequence = 1; @@ -40,6 +44,7 @@ export class DramasService { ) {} async createDrama(dto: CreateDramaDto): Promise { + const { totalEpisodes } = dto; if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const entity = repository.create({ @@ -61,6 +66,12 @@ export class DramasService { onlineVersion: dto.onlineVersion ?? 1, }); const saved = await repository.save(entity); + if (totalEpisodes) { + await this.configureEpisodes(Number(saved.id), { + episodeCount: totalEpisodes, + }); + return this.getDramaOrThrow(Number(saved.id)); + } return this.toDramaRecord(saved, 0, 0); } @@ -90,6 +101,10 @@ export class DramasService { }; this.dramas.push(drama); + if (totalEpisodes) { + await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes }); + return this.getDramaOrThrow(drama.id); + } return drama; } @@ -137,14 +152,23 @@ export class DramasService { } async updateDrama(id: number, dto: UpdateDramaDto): Promise { + const { totalEpisodes, ...dramaUpdates } = dto; if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const drama = await repository.findOne({ where: { id } }); if (!drama) { throw new NotFoundException('Drama not found'); } - repository.merge(drama, { ...dto, tags: dto.tags ?? drama.tags }); + repository.merge(drama, { + ...dramaUpdates, + tags: dramaUpdates.tags ?? drama.tags, + }); const saved = await repository.save(drama); + if (totalEpisodes) { + await this.configureEpisodes(Number(saved.id), { + episodeCount: totalEpisodes, + }); + } return this.toDramaRecordWithCounts(saved); } @@ -154,10 +178,13 @@ export class DramasService { } Object.assign(drama, { - ...dto, - tags: dto.tags ?? drama.tags, + ...dramaUpdates, + tags: dramaUpdates.tags ?? drama.tags, updatedAt: new Date().toISOString(), }); + if (totalEpisodes) { + await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes }); + } return this.withEpisodeCounts(drama); } @@ -474,6 +501,28 @@ export class DramasService { return episode; } + async updateDramaEpisode( + dramaId: number, + episodeId: number, + dto: UpdateEpisodeDto, + ): Promise { + if (this.hasDatabase()) { + const episode = await this.dataSource.getRepository(Episode).findOne({ + where: { id: episodeId, dramaId }, + }); + if (!episode) { + throw new NotFoundException('Episode not found'); + } + } else { + const episode = await this.getMemEpisodeOrThrow(episodeId); + if (episode.dramaId !== dramaId) { + throw new NotFoundException('Episode not found'); + } + } + + return this.updateEpisode(episodeId, { ...dto, dramaId }); + } + async attachEpisodeMedia(input: { episodeId: number; byteplusVid: string; @@ -546,14 +595,18 @@ export class DramasService { } async listPublishedDramas( - pagination: PaginationInput, + input: PublishedDramaListInput, ): Promise> { + const title = input.title?.trim(); if (this.hasDatabase()) { - const { page, pageSize, skip, take } = this.resolvePagination(pagination); + const { page, pageSize, skip, take } = this.resolvePagination(input); const [records, total] = await this.dataSource .getRepository(Drama) .findAndCount({ - where: { status: PublishStatus.Published }, + where: { + status: PublishStatus.Published, + ...(title ? { title: Like(`%${title}%`) } : {}), + }, order: { sortOrder: 'DESC', id: 'DESC' }, skip, take, @@ -568,9 +621,10 @@ export class DramasService { this.sortDramas( this.dramas .filter((item) => item.status === PublishStatus.Published) + .filter((item) => !title || item.title.includes(title)) .map((drama) => this.withEpisodeCounts(drama)), ), - pagination, + input, ); } diff --git a/src/modules/dramas/dto/create-drama.dto.ts b/src/modules/dramas/dto/create-drama.dto.ts index 90d885b..4c01cf6 100644 --- a/src/modules/dramas/dto/create-drama.dto.ts +++ b/src/modules/dramas/dto/create-drama.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsString, IsUrl, + Max, Min, } from 'class-validator'; import { PublishStatus } from '../drama-status.enum'; @@ -88,6 +89,13 @@ export class CreateDramaDto { @IsBoolean() isRecommended?: boolean; + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + @Max(500) + totalEpisodes?: number; + @ApiPropertyOptional() @IsOptional() @IsInt() diff --git a/src/modules/media/entities/cover.entity.ts b/src/modules/media/entities/cover.entity.ts new file mode 100644 index 0000000..1fda334 --- /dev/null +++ b/src/modules/media/entities/cover.entity.ts @@ -0,0 +1,21 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('covers') +export class Cover { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255 }) + fileName: string; + + @Column({ type: 'varchar', length: 1024 }) + url: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/media/entities/media-upload-job.entity.ts b/src/modules/media/entities/media-upload-job.entity.ts index 5484123..560207c 100644 --- a/src/modules/media/entities/media-upload-job.entity.ts +++ b/src/modules/media/entities/media-upload-job.entity.ts @@ -16,6 +16,9 @@ export class MediaUploadJob { @Column({ type: 'varchar', length: 128 }) jobId: string; + @Column({ type: 'int', nullable: true }) + episodeId?: number; + @Index() @Column({ type: 'varchar', length: 128, nullable: true }) byteplusVid?: string; @@ -23,6 +26,42 @@ export class MediaUploadJob { @Column({ type: 'varchar', length: 64 }) status: string; + @Column({ type: 'varchar', length: 255, nullable: true }) + fileName?: string; + + @Column({ type: 'varchar', length: 128, nullable: true }) + contentType?: string; + + @Column({ type: 'int', nullable: true }) + fileSize?: number; + + @Column({ type: 'varchar', length: 255, nullable: true }) + bucket?: string; + + @Column({ type: 'varchar', length: 512, nullable: true }) + objectKey?: string; + + @Column({ type: 'varchar', length: 1024, nullable: true }) + uploadUrl?: string; + + @Column({ type: 'varchar', length: 1024, nullable: true }) + videoUrl?: string; + + @Column({ type: 'int', nullable: true }) + width?: number; + + @Column({ type: 'int', nullable: true }) + height?: number; + + @Column({ type: 'int', nullable: true }) + durationSeconds?: number; + + @Column({ type: 'varchar', length: 32, nullable: true }) + reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected'; + + @Column({ type: 'varchar', length: 512, nullable: true }) + reviewMessage?: string; + @Column({ type: 'json', nullable: true }) rawResponse?: Record; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 9edff7c..ba906a5 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,10 +1,14 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Optional } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; 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 { Cover } from './entities/cover.entity'; +import { MediaUploadJob } from './entities/media-upload-job.entity'; import { CoverRecord, MediaUploadJobRecord, @@ -26,9 +30,20 @@ export class MediaService { constructor( private readonly dramasService: DramasService, private readonly bytePlusMediaProvider: BytePlusMediaProvider, + @Optional() + @InjectDataSource() + readonly dataSource?: DataSource, ) {} - createCover(dto: CreateCoverDto): CoverRecord { + async createCover(dto: CreateCoverDto): Promise { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(Cover); + const saved = await repository.save( + repository.create({ fileName: dto.fileName, url: dto.url }), + ); + return this.toCoverRecord(saved); + } + const cover: CoverRecord = { id: this.coverSequence++, fileName: dto.fileName, @@ -39,7 +54,20 @@ export class MediaService { return cover; } - createUploadJob(dto: CreateUploadJobDto): MediaUploadJobRecord { + async createUploadJob(dto: CreateUploadJobDto): Promise { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(MediaUploadJob); + const saved = await repository.save( + repository.create({ + jobId: dto.jobId, + byteplusVid: dto.byteplusVid, + status: dto.status, + rawResponse: dto.rawResponse, + }), + ); + return this.toJobRecord(saved); + } + const now = new Date().toISOString(); const job: MediaUploadJobRecord = { id: this.uploadJobSequence++, @@ -58,15 +86,13 @@ export class MediaService { dto: CreateDirectUploadTaskDto, ): Promise { await 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++, + const base = { jobId, episodeId: dto.episodeId, status: 'pending', @@ -76,21 +102,77 @@ export class MediaService { bucket: upload.bucket, objectKey: upload.objectKey, uploadUrl: upload.uploadUrl, - reviewStatus: 'not_submitted', + reviewStatus: 'not_submitted' as const, rawResponse: { headers: upload.headers, expiresAt: upload.expiresAt, }, + }; + + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(MediaUploadJob); + return this.toJobRecord(await repository.save(repository.create(base))); + } + + const now = new Date().toISOString(); + const job: MediaUploadJobRecord = { + id: this.uploadJobSequence++, + ...base, createdAt: now, updatedAt: now, }; - this.uploadJobs.push(job); return job; } async completeDirectUploadTask(jobId: string): Promise { - const job = this.getUploadJobOrThrow(jobId); + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(MediaUploadJob); + const job = await repository.findOne({ where: { jobId } }); + if (!job || !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, + ); + + job.byteplusVid = completed.byteplusVid; + job.videoUrl = completed.videoUrl; + job.width = completed.width; + job.height = completed.height; + job.durationSeconds = completed.durationSeconds; + job.status = review.reviewStatus; + job.reviewStatus = review.reviewStatus; + job.reviewMessage = review.reviewMessage; + job.rawResponse = { + ...(job.rawResponse ?? {}), + upload: completed.rawResponse, + review, + }; + await repository.save(job); + + await this.dramasService.attachEpisodeMedia({ + episodeId: Number(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 this.toJobRecord(job); + } + + const job = this.getMemJobOrThrow(jobId); if (!job.objectKey || !job.episodeId) { throw new NotFoundException('Upload task not found'); } @@ -135,7 +217,27 @@ export class MediaService { } async retryReview(jobId: string): Promise { - const job = this.getUploadJobOrThrow(jobId); + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(MediaUploadJob); + const job = await repository.findOne({ where: { jobId } }); + if (!job || !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; + await repository.save(job); + await this.dramasService.updateEpisodeReviewStatus( + Number(job.episodeId), + review.reviewStatus, + review.reviewMessage, + ); + return this.toJobRecord(job); + } + + const job = this.getMemJobOrThrow(jobId); if (!job.byteplusVid || !job.episodeId) { throw new NotFoundException('Upload task not found'); } @@ -154,6 +256,33 @@ export class MediaService { } async syncReviewStatuses() { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(MediaUploadJob); + const candidates = await repository.find({ + where: [{ reviewStatus: 'reviewing' }, { reviewStatus: 'pending' }], + }); + const reviewingJobs = candidates.filter( + (job) => job.byteplusVid && job.episodeId, + ); + + 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; + await repository.save(job); + await this.dramasService.updateEpisodeReviewStatus( + Number(job.episodeId), + review.reviewStatus, + review.reviewMessage, + ); + } + + return { status: 'completed', synced: reviewingJobs.length }; + } + const reviewingJobs = this.uploadJobs.filter( (job) => job.byteplusVid && @@ -176,35 +305,55 @@ export class MediaService { ); } - return { - status: 'completed', - synced: reviewingJobs.length, - }; + return { status: 'completed', synced: reviewingJobs.length }; } - getUploadTask(jobId: string): MediaUploadJobRecord { + async getUploadTask(jobId: string): Promise { return this.getUploadJobOrThrow(jobId); } - listUploadJobs( + async listUploadJobs( input: PaginationInput, - ): PaginatedResponse { + ): Promise> { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); - const start = (page - 1) * pageSize; - const records = [...this.uploadJobs].reverse(); + if (this.hasDatabase()) { + const [records, total] = await this.dataSource + .getRepository(MediaUploadJob) + .findAndCount({ + order: { id: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { + list: records.map((job) => this.toJobRecord(job)), + pagination: { page, pageSize, total }, + }; + } + + const records = [...this.uploadJobs].reverse(); return { - list: records.slice(start, start + pageSize), - pagination: { - page, - pageSize, - total: records.length, - }, + list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize), + pagination: { page, pageSize, total: this.uploadJobs.length }, }; } - private getUploadJobOrThrow(jobId: string) { + private async getUploadJobOrThrow(jobId: string): Promise { + if (this.hasDatabase()) { + const job = await this.dataSource + .getRepository(MediaUploadJob) + .findOne({ where: { jobId } }); + if (!job) { + throw new NotFoundException('Upload task not found'); + } + return this.toJobRecord(job); + } + + return this.getMemJobOrThrow(jobId); + } + + private getMemJobOrThrow(jobId: string): MediaUploadJobRecord { const job = this.uploadJobs.find((item) => item.jobId === jobId); if (!job) { throw new NotFoundException('Upload task not found'); @@ -212,4 +361,52 @@ export class MediaService { return job; } + + private hasDatabase(): this is this & { dataSource: DataSource } { + return Boolean(this.dataSource?.isInitialized); + } + + private toCoverRecord(cover: Cover): CoverRecord { + return { + id: Number(cover.id), + fileName: cover.fileName, + url: cover.url, + createdAt: this.toIso(cover.createdAt), + }; + } + + private toJobRecord(job: MediaUploadJob): MediaUploadJobRecord { + return { + id: Number(job.id), + jobId: job.jobId, + episodeId: + job.episodeId === null || job.episodeId === undefined + ? undefined + : Number(job.episodeId), + byteplusVid: job.byteplusVid, + status: job.status, + fileName: job.fileName, + contentType: job.contentType, + fileSize: job.fileSize, + bucket: job.bucket, + objectKey: job.objectKey, + uploadUrl: job.uploadUrl, + videoUrl: job.videoUrl, + width: job.width, + height: job.height, + durationSeconds: job.durationSeconds, + reviewStatus: job.reviewStatus, + reviewMessage: job.reviewMessage, + rawResponse: job.rawResponse, + createdAt: this.toIso(job.createdAt), + updatedAt: this.toIso(job.updatedAt), + }; + } + + private toIso(value?: Date | string): string { + if (!value) { + return new Date().toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + } } diff --git a/src/modules/orders/orders.service.ts b/src/modules/orders/orders.service.ts index bd835a7..84565e6 100644 --- a/src/modules/orders/orders.service.ts +++ b/src/modules/orders/orders.service.ts @@ -1,15 +1,21 @@ import { Injectable, NotFoundException, + Optional, UnprocessableEntityException, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { DramasService } from '../dramas/dramas.service'; import { UserRecord } from '../users/interfaces/user-record.interface'; import { CreateOrderDto } from './dto/create-order.dto'; import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto'; +import { Order } from './entities/order.entity'; +import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity'; import { OrderRecord, + OrderStatus, UserEpisodeUnlockRecord, } from './interfaces/order-records.interface'; @@ -20,7 +26,12 @@ export class OrdersService { private readonly orders: OrderRecord[] = []; private readonly unlocks: UserEpisodeUnlockRecord[] = []; - constructor(private readonly dramasService: DramasService) {} + constructor( + private readonly dramasService: DramasService, + @Optional() + @InjectDataSource() + readonly dataSource?: DataSource, + ) {} async createOrder(user: UserRecord, dto: CreateOrderDto): Promise { const episode = await this.dramasService.getPublishedEpisodeOrThrow( @@ -30,15 +41,34 @@ export class OrdersService { throw new UnprocessableEntityException('Episode is free'); } + const amount = episode.price ?? episode.unlockPrice ?? 0; + const orderNo = this.createOrderNo(); + const tradeOrderId = this.createTradeOrderId(); + this.orderSequence++; + + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(Order); + const entity = repository.create({ + orderNo, + tradeOrderId, + userId: user.id, + albumId: episode.albumId, + episodeId: episode.id, + amount, + status: 'pending', + }); + return this.toOrderRecord(await repository.save(entity)); + } + const now = new Date().toISOString(); const order: OrderRecord = { - id: this.orderSequence++, - orderNo: this.createOrderNo(), - tradeOrderId: this.createTradeOrderId(), + id: this.orders.length + 1, + orderNo, + tradeOrderId, userId: user.id, albumId: episode.albumId, episodeId: episode.id, - amount: episode.price ?? episode.unlockPrice ?? 0, + amount, status: 'pending', createdAt: now, updatedAt: now, @@ -51,10 +81,7 @@ export class OrdersService { async getPlayback(user: UserRecord, episodeId: number) { const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); const isUnlocked = - !episode.isPaid || - this.unlocks.some( - (unlock) => unlock.userId === user.id && unlock.episodeId === episodeId, - ); + !episode.isPaid || (await this.isUnlocked(user.id, episodeId)); return { episodeId: episode.id, @@ -68,7 +95,68 @@ export class OrdersService { }; } - handlePaymentWebhook(dto: TikTokPaymentWebhookDto) { + async handlePaymentWebhook(dto: TikTokPaymentWebhookDto) { + if (this.hasDatabase()) { + const orderRepository = this.dataSource.getRepository(Order); + const where: Record[] = []; + if (dto.orderNo) { + where.push({ orderNo: dto.orderNo }); + } + if (dto.tradeOrderId) { + where.push({ tradeOrderId: dto.tradeOrderId }); + } + const order = where.length + ? await orderRepository.findOne({ where }) + : null; + if (!order) { + throw new NotFoundException('Order not found'); + } + + if (order.status === 'paid') { + return { + orderNo: order.orderNo, + status: order.status, + unlocked: await this.isUnlocked( + Number(order.userId), + Number(order.episodeId), + ), + }; + } + + order.providerTransactionId = dto.providerTransactionId; + order.tiktokOrderId = dto.tiktokOrderId ?? order.tiktokOrderId; + order.status = dto.status; + await orderRepository.save(order); + + if ( + dto.status === 'paid' && + !(await this.isUnlocked( + Number(order.userId), + Number(order.episodeId), + )) + ) { + const unlockRepository = this.dataSource.getRepository(UserEpisodeUnlock); + await unlockRepository.save( + unlockRepository.create({ + userId: Number(order.userId), + episodeId: Number(order.episodeId), + source: 'payment', + orderNo: order.orderNo, + }), + ); + } + + return { + orderNo: order.orderNo, + tradeOrderId: order.tradeOrderId, + status: order.status, + unlocked: await this.isUnlocked( + Number(order.userId), + Number(order.episodeId), + ), + }; + } + const order = this.orders.find( (item) => item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId, @@ -81,7 +169,7 @@ export class OrdersService { return { orderNo: order.orderNo, status: order.status, - unlocked: this.isUnlocked(order.userId, order.episodeId), + unlocked: await this.isUnlocked(order.userId, order.episodeId), }; } @@ -90,7 +178,10 @@ export class OrdersService { order.status = dto.status; order.updatedAt = new Date().toISOString(); - if (dto.status === 'paid' && !this.isUnlocked(order.userId, order.episodeId)) { + if ( + dto.status === 'paid' && + !(await this.isUnlocked(order.userId, order.episodeId)) + ) { this.unlocks.push({ id: this.unlockSequence++, userId: order.userId, @@ -105,40 +196,95 @@ export class OrdersService { orderNo: order.orderNo, tradeOrderId: order.tradeOrderId, status: order.status, - unlocked: this.isUnlocked(order.userId, order.episodeId), + unlocked: await this.isUnlocked(order.userId, order.episodeId), }; } async hasEpisodePermission(userId: number, episodeId: number) { const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); - return !episode.isPaid || this.isUnlocked(userId, episodeId); + return !episode.isPaid || (await this.isUnlocked(userId, episodeId)); } - listUnlockRecords(input: { + async listUnlockRecords(input: { page: number; pageSize: number; - }): PaginatedResponse { + }): Promise> { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); - const start = (page - 1) * pageSize; - const records = [...this.unlocks].reverse(); + if (this.hasDatabase()) { + const [records, total] = await this.dataSource + .getRepository(UserEpisodeUnlock) + .findAndCount({ + order: { id: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { + list: records.map((record) => this.toUnlockRecord(record)), + pagination: { page, pageSize, total }, + }; + } + + const records = [...this.unlocks].reverse(); return { - list: records.slice(start, start + pageSize), - pagination: { - page, - pageSize, - total: records.length, - }, + list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize), + pagination: { page, pageSize, total: this.unlocks.length }, }; } - private isUnlocked(userId: number, episodeId: number) { + private async isUnlocked(userId: number, episodeId: number): Promise { + if (this.hasDatabase()) { + const count = await this.dataSource + .getRepository(UserEpisodeUnlock) + .count({ where: { userId, episodeId } }); + return count > 0; + } + return this.unlocks.some( (unlock) => unlock.userId === userId && unlock.episodeId === episodeId, ); } + private hasDatabase(): this is this & { dataSource: DataSource } { + return Boolean(this.dataSource?.isInitialized); + } + + private toOrderRecord(order: Order): OrderRecord { + return { + id: Number(order.id), + orderNo: order.orderNo, + tradeOrderId: order.tradeOrderId, + userId: Number(order.userId), + albumId: Number(order.albumId), + episodeId: Number(order.episodeId), + tiktokOrderId: order.tiktokOrderId, + amount: order.amount, + status: order.status as OrderStatus, + providerTransactionId: order.providerTransactionId, + createdAt: this.toIso(order.createdAt), + updatedAt: this.toIso(order.updatedAt), + }; + } + + private toUnlockRecord(unlock: UserEpisodeUnlock): UserEpisodeUnlockRecord { + return { + id: Number(unlock.id), + userId: Number(unlock.userId), + episodeId: Number(unlock.episodeId), + source: unlock.source as 'payment' | 'admin' | 'free', + orderNo: unlock.orderNo, + createdAt: this.toIso(unlock.createdAt), + }; + } + + private toIso(value?: Date | string): string { + if (!value) { + return new Date().toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + } + private createOrderNo() { return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`; } diff --git a/test/dramas.e2e-spec.ts b/test/dramas.e2e-spec.ts index 72fe532..e6c2002 100644 --- a/test/dramas.e2e-spec.ts +++ b/test/dramas.e2e-spec.ts @@ -122,6 +122,89 @@ describe('Dramas and episodes', () => { }); }); + it('creates episode slots when a new drama declares total episodes', async () => { + const drama = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Preconfigured Slots Drama', + coverUrl: 'https://cdn.example.com/preconfigured-slots.jpg', + type: 'romance', + status: 'draft', + totalEpisodes: 2, + }) + .expect(201); + + expect(drama.body.data).toMatchObject({ + title: 'Preconfigured Slots Drama', + totalEpisodes: 2, + publishedEpisodes: 0, + }); + + const episodes = await request(app.getHttpServer()) + .get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(episodes.body.data.list).toEqual([ + expect.objectContaining({ + dramaId: drama.body.data.id, + episodeNo: 1, + title: '第1集', + publishStatus: 'draft', + }), + expect.objectContaining({ + dramaId: drama.body.data.id, + episodeNo: 2, + title: '第2集', + publishStatus: 'draft', + }), + ]); + }); + + it('updates an episode from the drama detail episodes endpoint', async () => { + const drama = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Nested Episode Update Drama', + coverUrl: 'https://cdn.example.com/nested-update.jpg', + type: 'action', + status: 'draft', + totalEpisodes: 1, + }) + .expect(201); + + const episodes = await request(app.getHttpServer()) + .get(`/api/admin/v1/dramas/${drama.body.data.id}/episodes`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const episodeId = episodes.body.data.list[0].id; + + const updated = await request(app.getHttpServer()) + .patch(`/api/admin/v1/dramas/${drama.body.data.id}/episodes/${episodeId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Updated Nested Episode', + description: 'Updated from drama detail.', + durationSeconds: 240, + freeType: 'paid', + price: 199, + }) + .expect(200); + + expect(updated.body.data).toMatchObject({ + id: episodeId, + dramaId: drama.body.data.id, + title: 'Updated Nested Episode', + description: 'Updated from drama detail.', + durationSeconds: 240, + freeType: 'paid', + isPaid: true, + price: 199, + }); + }); + it('returns only published dramas and published episodes to app APIs', async () => { const published = await request(app.getHttpServer()) .post('/api/admin/v1/dramas') @@ -197,4 +280,45 @@ describe('Dramas and episodes', () => { }), ]); }); + + it('filters published dramas by title and defaults app page size to 10', async () => { + await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Searchable Moon Drama', + coverUrl: 'https://cdn.example.com/searchable-moon.jpg', + type: 'romance', + status: 'published', + }) + .expect(201); + + await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Hidden Sun Drama', + coverUrl: 'https://cdn.example.com/hidden-sun.jpg', + type: 'romance', + status: 'published', + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get('/api/app/v1/dramas') + .query({ title: 'Moon' }) + .expect(200); + + expect(response.body.data.pagination.pageSize).toBe(10); + expect(response.body.data.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Searchable Moon Drama' }), + ]), + ); + expect(response.body.data.list).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Hidden Sun Drama' }), + ]), + ); + }); });