feat: 新增剧集封面、集数管理与上传优化功能
1. 新增Cover实体类并注册到数据库模块 2. 为剧集添加总集数字段及自动创建集数槽功能 3. 新增管理员更新剧集集数接口 4. 优化发布剧集列表:支持标题过滤与默认分页大小10 5. 扩展MediaUploadJob实体字段,完善媒体上传流程 6. 重构订单与媒体服务,新增数据库持久化支持 7. 新增剧集标题过滤的端到端测试用例
This commit is contained in:
@@ -10,6 +10,7 @@ import { Drama } from '../dramas/entities/drama.entity';
|
|||||||
import { Episode } from '../dramas/entities/episode.entity';
|
import { Episode } from '../dramas/entities/episode.entity';
|
||||||
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
|
import { PlaybackErrorLog } from '../logs/entities/playback-error-log.entity';
|
||||||
import { RequestLog } from '../logs/entities/request-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 { MediaUploadJob } from '../media/entities/media-upload-job.entity';
|
||||||
import { Order } from '../orders/entities/order.entity';
|
import { Order } from '../orders/entities/order.entity';
|
||||||
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
|
import { UserEpisodeUnlock } from '../orders/entities/user-episode-unlock.entity';
|
||||||
@@ -35,6 +36,7 @@ const databaseImports = process.env.DB_HOST
|
|||||||
Episode,
|
Episode,
|
||||||
PlaybackErrorLog,
|
PlaybackErrorLog,
|
||||||
RequestLog,
|
RequestLog,
|
||||||
|
Cover,
|
||||||
MediaUploadJob,
|
MediaUploadJob,
|
||||||
Order,
|
Order,
|
||||||
UserEpisodeUnlock,
|
UserEpisodeUnlock,
|
||||||
|
|||||||
@@ -102,6 +102,22 @@ export class DramasController {
|
|||||||
return this.dramasService.updateEpisode(Number(id), dto);
|
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')
|
@Post('/api/admin/v1/dramas/:id/submit-review')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
@@ -128,10 +144,12 @@ export class DramasController {
|
|||||||
listPublishedDramas(
|
listPublishedDramas(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('title') title?: string,
|
||||||
) {
|
) {
|
||||||
return this.dramasService.listPublishedDramas({
|
return this.dramasService.listPublishedDramas({
|
||||||
page: Number(page) || 1,
|
page: Number(page) || 1,
|
||||||
pageSize: Number(pageSize) || 20,
|
pageSize: Number(pageSize) || 10,
|
||||||
|
title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, Like } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
|
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
|
||||||
import { CreateDramaDto } from './dto/create-drama.dto';
|
import { CreateDramaDto } from './dto/create-drama.dto';
|
||||||
@@ -26,6 +26,10 @@ interface PaginationInput {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PublishedDramaListInput extends PaginationInput {
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DramasService {
|
export class DramasService {
|
||||||
private dramaSequence = 1;
|
private dramaSequence = 1;
|
||||||
@@ -40,6 +44,7 @@ export class DramasService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
||||||
|
const { totalEpisodes } = dto;
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Drama);
|
const repository = this.dataSource.getRepository(Drama);
|
||||||
const entity = repository.create({
|
const entity = repository.create({
|
||||||
@@ -61,6 +66,12 @@ export class DramasService {
|
|||||||
onlineVersion: dto.onlineVersion ?? 1,
|
onlineVersion: dto.onlineVersion ?? 1,
|
||||||
});
|
});
|
||||||
const saved = await repository.save(entity);
|
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);
|
return this.toDramaRecord(saved, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +101,10 @@ export class DramasService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.dramas.push(drama);
|
this.dramas.push(drama);
|
||||||
|
if (totalEpisodes) {
|
||||||
|
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
|
||||||
|
return this.getDramaOrThrow(drama.id);
|
||||||
|
}
|
||||||
return drama;
|
return drama;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +152,23 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
||||||
|
const { totalEpisodes, ...dramaUpdates } = dto;
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Drama);
|
const repository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await repository.findOne({ where: { id } });
|
const drama = await repository.findOne({ where: { id } });
|
||||||
if (!drama) {
|
if (!drama) {
|
||||||
throw new NotFoundException('Drama not found');
|
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);
|
const saved = await repository.save(drama);
|
||||||
|
if (totalEpisodes) {
|
||||||
|
await this.configureEpisodes(Number(saved.id), {
|
||||||
|
episodeCount: totalEpisodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.toDramaRecordWithCounts(saved);
|
return this.toDramaRecordWithCounts(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,10 +178,13 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(drama, {
|
Object.assign(drama, {
|
||||||
...dto,
|
...dramaUpdates,
|
||||||
tags: dto.tags ?? drama.tags,
|
tags: dramaUpdates.tags ?? drama.tags,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
if (totalEpisodes) {
|
||||||
|
await this.configureEpisodes(drama.id, { episodeCount: totalEpisodes });
|
||||||
|
}
|
||||||
return this.withEpisodeCounts(drama);
|
return this.withEpisodeCounts(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +501,28 @@ export class DramasService {
|
|||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDramaEpisode(
|
||||||
|
dramaId: number,
|
||||||
|
episodeId: number,
|
||||||
|
dto: UpdateEpisodeDto,
|
||||||
|
): Promise<EpisodeRecord> {
|
||||||
|
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: {
|
async attachEpisodeMedia(input: {
|
||||||
episodeId: number;
|
episodeId: number;
|
||||||
byteplusVid: string;
|
byteplusVid: string;
|
||||||
@@ -546,14 +595,18 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listPublishedDramas(
|
async listPublishedDramas(
|
||||||
pagination: PaginationInput,
|
input: PublishedDramaListInput,
|
||||||
): Promise<PaginatedResponse<DramaRecord>> {
|
): Promise<PaginatedResponse<DramaRecord>> {
|
||||||
|
const title = input.title?.trim();
|
||||||
if (this.hasDatabase()) {
|
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
|
const [records, total] = await this.dataSource
|
||||||
.getRepository(Drama)
|
.getRepository(Drama)
|
||||||
.findAndCount({
|
.findAndCount({
|
||||||
where: { status: PublishStatus.Published },
|
where: {
|
||||||
|
status: PublishStatus.Published,
|
||||||
|
...(title ? { title: Like(`%${title}%`) } : {}),
|
||||||
|
},
|
||||||
order: { sortOrder: 'DESC', id: 'DESC' },
|
order: { sortOrder: 'DESC', id: 'DESC' },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
@@ -568,9 +621,10 @@ export class DramasService {
|
|||||||
this.sortDramas(
|
this.sortDramas(
|
||||||
this.dramas
|
this.dramas
|
||||||
.filter((item) => item.status === PublishStatus.Published)
|
.filter((item) => item.status === PublishStatus.Published)
|
||||||
|
.filter((item) => !title || item.title.includes(title))
|
||||||
.map((drama) => this.withEpisodeCounts(drama)),
|
.map((drama) => this.withEpisodeCounts(drama)),
|
||||||
),
|
),
|
||||||
pagination,
|
input,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PublishStatus } from '../drama-status.enum';
|
import { PublishStatus } from '../drama-status.enum';
|
||||||
@@ -88,6 +89,13 @@ export class CreateDramaDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isRecommended?: boolean;
|
isRecommended?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
totalEpisodes?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
21
src/modules/media/entities/cover.entity.ts
Normal file
21
src/modules/media/entities/cover.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export class MediaUploadJob {
|
|||||||
@Column({ type: 'varchar', length: 128 })
|
@Column({ type: 'varchar', length: 128 })
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
episodeId?: number;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||||
byteplusVid?: string;
|
byteplusVid?: string;
|
||||||
@@ -23,6 +26,42 @@ export class MediaUploadJob {
|
|||||||
@Column({ type: 'varchar', length: 64 })
|
@Column({ type: 'varchar', length: 64 })
|
||||||
status: string;
|
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 })
|
@Column({ type: 'json', nullable: true })
|
||||||
rawResponse?: Record<string, unknown>;
|
rawResponse?: Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { randomUUID } from 'crypto';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
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 { 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 { Cover } from './entities/cover.entity';
|
||||||
|
import { MediaUploadJob } from './entities/media-upload-job.entity';
|
||||||
import {
|
import {
|
||||||
CoverRecord,
|
CoverRecord,
|
||||||
MediaUploadJobRecord,
|
MediaUploadJobRecord,
|
||||||
@@ -26,9 +30,20 @@ export class MediaService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dramasService: DramasService,
|
private readonly dramasService: DramasService,
|
||||||
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
|
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
|
||||||
|
@Optional()
|
||||||
|
@InjectDataSource()
|
||||||
|
readonly dataSource?: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
createCover(dto: CreateCoverDto): CoverRecord {
|
async createCover(dto: CreateCoverDto): Promise<CoverRecord> {
|
||||||
|
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 = {
|
const cover: CoverRecord = {
|
||||||
id: this.coverSequence++,
|
id: this.coverSequence++,
|
||||||
fileName: dto.fileName,
|
fileName: dto.fileName,
|
||||||
@@ -39,7 +54,20 @@ export class MediaService {
|
|||||||
return cover;
|
return cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
createUploadJob(dto: CreateUploadJobDto): MediaUploadJobRecord {
|
async createUploadJob(dto: CreateUploadJobDto): Promise<MediaUploadJobRecord> {
|
||||||
|
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 now = new Date().toISOString();
|
||||||
const job: MediaUploadJobRecord = {
|
const job: MediaUploadJobRecord = {
|
||||||
id: this.uploadJobSequence++,
|
id: this.uploadJobSequence++,
|
||||||
@@ -58,15 +86,13 @@ export class MediaService {
|
|||||||
dto: CreateDirectUploadTaskDto,
|
dto: CreateDirectUploadTaskDto,
|
||||||
): Promise<MediaUploadJobRecord> {
|
): Promise<MediaUploadJobRecord> {
|
||||||
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
|
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
|
||||||
const now = new Date().toISOString();
|
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const upload = this.bytePlusMediaProvider.createDirectUpload({
|
const upload = this.bytePlusMediaProvider.createDirectUpload({
|
||||||
jobId,
|
jobId,
|
||||||
fileName: dto.fileName,
|
fileName: dto.fileName,
|
||||||
contentType: dto.contentType,
|
contentType: dto.contentType,
|
||||||
});
|
});
|
||||||
const job: MediaUploadJobRecord = {
|
const base = {
|
||||||
id: this.uploadJobSequence++,
|
|
||||||
jobId,
|
jobId,
|
||||||
episodeId: dto.episodeId,
|
episodeId: dto.episodeId,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -76,21 +102,77 @@ export class MediaService {
|
|||||||
bucket: upload.bucket,
|
bucket: upload.bucket,
|
||||||
objectKey: upload.objectKey,
|
objectKey: upload.objectKey,
|
||||||
uploadUrl: upload.uploadUrl,
|
uploadUrl: upload.uploadUrl,
|
||||||
reviewStatus: 'not_submitted',
|
reviewStatus: 'not_submitted' as const,
|
||||||
rawResponse: {
|
rawResponse: {
|
||||||
headers: upload.headers,
|
headers: upload.headers,
|
||||||
expiresAt: upload.expiresAt,
|
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,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.uploadJobs.push(job);
|
this.uploadJobs.push(job);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
||||||
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) {
|
if (!job.objectKey || !job.episodeId) {
|
||||||
throw new NotFoundException('Upload task not found');
|
throw new NotFoundException('Upload task not found');
|
||||||
}
|
}
|
||||||
@@ -135,7 +217,27 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
|
async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
|
||||||
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) {
|
if (!job.byteplusVid || !job.episodeId) {
|
||||||
throw new NotFoundException('Upload task not found');
|
throw new NotFoundException('Upload task not found');
|
||||||
}
|
}
|
||||||
@@ -154,6 +256,33 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncReviewStatuses() {
|
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(
|
const reviewingJobs = this.uploadJobs.filter(
|
||||||
(job) =>
|
(job) =>
|
||||||
job.byteplusVid &&
|
job.byteplusVid &&
|
||||||
@@ -176,35 +305,55 @@ export class MediaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { status: 'completed', synced: reviewingJobs.length };
|
||||||
status: 'completed',
|
|
||||||
synced: reviewingJobs.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getUploadTask(jobId: string): MediaUploadJobRecord {
|
async getUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
||||||
return this.getUploadJobOrThrow(jobId);
|
return this.getUploadJobOrThrow(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
listUploadJobs(
|
async listUploadJobs(
|
||||||
input: PaginationInput,
|
input: PaginationInput,
|
||||||
): PaginatedResponse<MediaUploadJobRecord> {
|
): Promise<PaginatedResponse<MediaUploadJobRecord>> {
|
||||||
const page = Math.max(input.page, 1);
|
const page = Math.max(input.page, 1);
|
||||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
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 {
|
return {
|
||||||
list: records.slice(start, start + pageSize),
|
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
|
||||||
pagination: {
|
pagination: { page, pageSize, total: this.uploadJobs.length },
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: records.length,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUploadJobOrThrow(jobId: string) {
|
private async getUploadJobOrThrow(jobId: string): Promise<MediaUploadJobRecord> {
|
||||||
|
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);
|
const job = this.uploadJobs.find((item) => item.jobId === jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
throw new NotFoundException('Upload task not found');
|
throw new NotFoundException('Upload task not found');
|
||||||
@@ -212,4 +361,52 @@ export class MediaService {
|
|||||||
|
|
||||||
return job;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
import { DramasService } from '../dramas/dramas.service';
|
||||||
import { UserRecord } from '../users/interfaces/user-record.interface';
|
import { UserRecord } from '../users/interfaces/user-record.interface';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
|
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
|
||||||
|
import { Order } from './entities/order.entity';
|
||||||
|
import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity';
|
||||||
import {
|
import {
|
||||||
OrderRecord,
|
OrderRecord,
|
||||||
|
OrderStatus,
|
||||||
UserEpisodeUnlockRecord,
|
UserEpisodeUnlockRecord,
|
||||||
} from './interfaces/order-records.interface';
|
} from './interfaces/order-records.interface';
|
||||||
|
|
||||||
@@ -20,7 +26,12 @@ export class OrdersService {
|
|||||||
private readonly orders: OrderRecord[] = [];
|
private readonly orders: OrderRecord[] = [];
|
||||||
private readonly unlocks: UserEpisodeUnlockRecord[] = [];
|
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<OrderRecord> {
|
async createOrder(user: UserRecord, dto: CreateOrderDto): Promise<OrderRecord> {
|
||||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||||
@@ -30,15 +41,34 @@ export class OrdersService {
|
|||||||
throw new UnprocessableEntityException('Episode is free');
|
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 now = new Date().toISOString();
|
||||||
const order: OrderRecord = {
|
const order: OrderRecord = {
|
||||||
id: this.orderSequence++,
|
id: this.orders.length + 1,
|
||||||
orderNo: this.createOrderNo(),
|
orderNo,
|
||||||
tradeOrderId: this.createTradeOrderId(),
|
tradeOrderId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
albumId: episode.albumId,
|
albumId: episode.albumId,
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
amount: episode.price ?? episode.unlockPrice ?? 0,
|
amount,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -51,10 +81,7 @@ export class OrdersService {
|
|||||||
async getPlayback(user: UserRecord, episodeId: number) {
|
async getPlayback(user: UserRecord, episodeId: number) {
|
||||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
||||||
const isUnlocked =
|
const isUnlocked =
|
||||||
!episode.isPaid ||
|
!episode.isPaid || (await this.isUnlocked(user.id, episodeId));
|
||||||
this.unlocks.some(
|
|
||||||
(unlock) => unlock.userId === user.id && unlock.episodeId === episodeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
episodeId: episode.id,
|
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<string, string>[] = [];
|
||||||
|
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(
|
const order = this.orders.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
|
item.orderNo === dto.orderNo || item.tradeOrderId === dto.tradeOrderId,
|
||||||
@@ -81,7 +169,7 @@ export class OrdersService {
|
|||||||
return {
|
return {
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
status: order.status,
|
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.status = dto.status;
|
||||||
order.updatedAt = new Date().toISOString();
|
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({
|
this.unlocks.push({
|
||||||
id: this.unlockSequence++,
|
id: this.unlockSequence++,
|
||||||
userId: order.userId,
|
userId: order.userId,
|
||||||
@@ -105,40 +196,95 @@ export class OrdersService {
|
|||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
tradeOrderId: order.tradeOrderId,
|
tradeOrderId: order.tradeOrderId,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
unlocked: this.isUnlocked(order.userId, order.episodeId),
|
unlocked: await this.isUnlocked(order.userId, order.episodeId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasEpisodePermission(userId: number, episodeId: number) {
|
async hasEpisodePermission(userId: number, episodeId: number) {
|
||||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId);
|
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;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}): PaginatedResponse<UserEpisodeUnlockRecord> {
|
}): Promise<PaginatedResponse<UserEpisodeUnlockRecord>> {
|
||||||
const page = Math.max(input.page, 1);
|
const page = Math.max(input.page, 1);
|
||||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
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 {
|
return {
|
||||||
list: records.slice(start, start + pageSize),
|
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
|
||||||
pagination: {
|
pagination: { page, pageSize, total: this.unlocks.length },
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: records.length,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUnlocked(userId: number, episodeId: number) {
|
private async isUnlocked(userId: number, episodeId: number): Promise<boolean> {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const count = await this.dataSource
|
||||||
|
.getRepository(UserEpisodeUnlock)
|
||||||
|
.count({ where: { userId, episodeId } });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
return this.unlocks.some(
|
return this.unlocks.some(
|
||||||
(unlock) => unlock.userId === userId && unlock.episodeId === episodeId,
|
(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() {
|
private createOrderNo() {
|
||||||
return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
|
return `ORD${Date.now()}${String(this.orderSequence).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it('returns only published dramas and published episodes to app APIs', async () => {
|
||||||
const published = await request(app.getHttpServer())
|
const published = await request(app.getHttpServer())
|
||||||
.post('/api/admin/v1/dramas')
|
.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' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user