From 2eeec11d51836058ab0d7091ca282275ee7f22f7 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Fri, 3 Jul 2026 15:31:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A2=91=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=85=B3=E8=81=94?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E4=B8=8E=E9=A2=91=E9=81=93=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增频道模块,包含类型定义、实体、DTO、控制器和服务 2. 为短剧添加频道ID和频道名字段,支持关联频道 3. 新增短剧删除API和权限配置 4. 调整tsconfig和依赖配置,更新权限列表 5. 新增相关测试用例验证频道与短剧关联逻辑 --- pnpm-lock.yaml | 31 +-- src/admin/admin.service.ts | 5 + src/app/app.module.ts | 5 +- src/channels/channel.entity.ts | 41 ++++ src/channels/channels.controller.ts | 48 +++++ src/channels/channels.dto.ts | 36 ++++ src/channels/channels.module.ts | 12 ++ src/channels/channels.service.ts | 316 ++++++++++++++++++++++++++++ src/channels/channels.types.ts | 13 ++ src/dramas/drama.entity.ts | 5 +- src/dramas/dramas.controller.ts | 10 +- src/dramas/dramas.dto.ts | 11 +- src/dramas/dramas.module.ts | 3 +- src/dramas/dramas.service.ts | 97 ++++++++- src/dramas/dramas.types.ts | 2 + test/dramas.e2e-spec.ts | 204 ++++++++++++++++++ tsconfig.json | 1 - 17 files changed, 800 insertions(+), 40 deletions(-) create mode 100644 src/channels/channel.entity.ts create mode 100644 src/channels/channels.controller.ts create mode 100644 src/channels/channels.dto.ts create mode 100644 src/channels/channels.module.ts create mode 100644 src/channels/channels.service.ts create mode 100644 src/channels/channels.types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e52f72..4b54557 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@nestjs/common': specifier: ^11.0.0 version: 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/config': - specifier: ^4.0.0 - version: 4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -62,6 +59,9 @@ importers: rxjs: specifier: ^7.8.0 version: 7.8.2 + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 typeorm: specifier: ^0.3.0 version: 0.3.30(mysql2@3.22.5(@types/node@24.13.2))(ts-node@10.9.2(@types/node@24.13.2)(typescript@5.9.3)) @@ -126,9 +126,6 @@ importers: ts-node: specifier: ^10.9.0 version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -710,12 +707,6 @@ packages: class-validator: optional: true - '@nestjs/config@4.0.4': - resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} - peerDependencies: - '@nestjs/common': ^10.0.0 || ^11.0.0 - rxjs: ^7.1.0 - '@nestjs/core@11.1.27': resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==} engines: {node: '>= 20'} @@ -1658,10 +1649,6 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} - dotenv-expand@12.0.3: - resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} - engines: {node: '>=12'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -4112,14 +4099,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - dotenv: 17.4.1 - dotenv-expand: 12.0.3 - lodash: 4.18.1 - rxjs: 7.8.2 - '@nestjs/core@11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5063,10 +5042,6 @@ snapshots: diff@4.0.4: {} - dotenv-expand@12.0.3: - dependencies: - dotenv: 16.6.1 - dotenv@16.6.1: {} dotenv@17.4.1: {} diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index aa0e0d6..46b4a79 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -411,9 +411,14 @@ export class AdminService implements OnModuleInit { ['admin-user:update', '更新人员'], ['user:read', '查看用户'], ['user:update', '更新用户'], + ['channel:read', '查看频道'], + ['channel:create', '创建频道'], + ['channel:update', '更新频道'], + ['channel:delete', '删除频道'], ['drama:read', '查看短剧'], ['drama:create', '创建短剧'], ['drama:update', '更新短剧'], + ['drama:delete', '删除短剧'], ['episode:create', '创建剧集'], ['episode:update', '更新剧集'], ['media:read', '查看媒体'], diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 220c64a..b51cf5d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -21,6 +21,7 @@ import { OrdersModule } from "@src/orders/orders.module"; import { PlayerModule } from "@src/player/player.module"; import { HistoryModule } from "@src/history/history.module"; import { LogsModule } from "@src/logs/logs.module"; +import { ChannelsModule } from "@src/channels/channels.module"; // 数据库实体 import { AdminUser } from "@src/admin/admin-user.entity"; @@ -39,13 +40,14 @@ import { Order } from "@src/orders/order.entity"; import { UserEpisodeUnlock } from "@src/orders/user-episode-unlock.entity"; import { UserPlaybackHistory } from "@src/history/user-playback-history.entity"; import { User } from "@src/users/user.entity"; +import { Channel } from "@src/channels/channel.entity"; // 未配置 DB_HOST 时(如 e2e 测试)退回内存模式,不装配 TypeORM const databaseImports = DB_CONFIG.host ? [ TypeOrmModule.forRoot({ ...DB_CONFIG, - entities: [AdminUser, Permission, Role, DataSyncJob, DramaMetric, EpisodeMetric, Drama, Episode, PlaybackErrorLog, RequestLog, Cover, MediaUploadJob, Order, UserEpisodeUnlock, UserPlaybackHistory, User], + entities: [AdminUser, Permission, Role, DataSyncJob, DramaMetric, EpisodeMetric, Drama, Episode, PlaybackErrorLog, RequestLog, Cover, MediaUploadJob, Order, UserEpisodeUnlock, UserPlaybackHistory, User, Channel], }), ] : []; @@ -56,6 +58,7 @@ const databaseImports = DB_CONFIG.host AdminModule, // 管理端(人员/角色/权限) LogsModule, // 请求与播放错误日志 + ChannelsModule, // 频道管理 DramasModule, // 短剧与剧集 DataModule, // 数据指标与同步 MediaModule, // 媒体上传与封面 diff --git a/src/channels/channel.entity.ts b/src/channels/channel.entity.ts new file mode 100644 index 0000000..279bdac --- /dev/null +++ b/src/channels/channel.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type ChannelStatus = 'active' | 'disabled'; + +@Entity('channels') +export class Channel { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Index({ unique: true }) + @Column({ type: 'varchar', length: 128 }) + code: string; + + @Column({ type: 'varchar', length: 512, nullable: true }) + description?: string; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @Column({ type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ type: 'varchar', length: 32, default: 'active' }) + status: ChannelStatus; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts new file mode 100644 index 0000000..960969d --- /dev/null +++ b/src/channels/channels.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermissions } from '@app/decorators/require-permissions.decorator'; +import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard'; +import { PermissionsGuard } from '@app/guards/permissions.guard'; +import { CreateChannelDto, UpdateChannelDto } from './channels.dto'; +import { ChannelsService } from './channels.service'; + +@ApiTags('Channels') +@Controller('/api/admin/v1/channels') +export class ChannelsController { + constructor(private readonly channelsService: ChannelsService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions('channel:read') + listChannels(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.channelsService.listChannels({ + page: Number(page) || 1, + pageSize: Number(pageSize) || 100, + }); + } + + @Post() + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions('channel:create') + createChannel(@Body() dto: CreateChannelDto) { + return this.channelsService.createChannel(dto); + } + + @Patch('/:id') + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions('channel:update') + updateChannel(@Param('id') id: string, @Body() dto: UpdateChannelDto) { + return this.channelsService.updateChannel(Number(id), dto); + } + + @Delete('/:id') + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions('channel:delete') + deleteChannel(@Param('id') id: string) { + return this.channelsService.deleteChannel(Number(id)); + } +} diff --git a/src/channels/channels.dto.ts b/src/channels/channels.dto.ts new file mode 100644 index 0000000..8e6a326 --- /dev/null +++ b/src/channels/channels.dto.ts @@ -0,0 +1,36 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator'; +import { ChannelStatus } from './channels.types'; + +export class CreateChannelDto { + @ApiProperty() + @IsString() + name: string; + + @ApiProperty() + @IsString() + code: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + sortOrder?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ enum: ['active', 'disabled'] }) + @IsOptional() + @IsIn(['active', 'disabled']) + status?: ChannelStatus; +} + +export class UpdateChannelDto extends PartialType(CreateChannelDto) {} diff --git a/src/channels/channels.module.ts b/src/channels/channels.module.ts new file mode 100644 index 0000000..71c3f40 --- /dev/null +++ b/src/channels/channels.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AdminModule } from '@src/admin/admin.module'; +import { ChannelsController } from './channels.controller'; +import { ChannelsService } from './channels.service'; + +@Module({ + imports: [AdminModule], + controllers: [ChannelsController], + providers: [ChannelsService], + exports: [ChannelsService], +}) +export class ChannelsModule {} diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts new file mode 100644 index 0000000..73b7fc0 --- /dev/null +++ b/src/channels/channels.service.ts @@ -0,0 +1,316 @@ +import { + ConflictException, + Injectable, + NotFoundException, + OnModuleInit, + Optional, + UnprocessableEntityException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { PaginatedResponse } from '@app/common/paginated-response.dto'; +import { Drama } from '@src/dramas/drama.entity'; +import { Channel } from './channel.entity'; +import { CreateChannelDto, UpdateChannelDto } from './channels.dto'; +import { ChannelRecord } from './channels.types'; + +interface PaginationInput { + page: number; + pageSize: number; +} + +const DEFAULT_CHANNEL = { + id: 1, + name: '默认频道', + code: 'default', + sortOrder: 0, + isDefault: true, + status: 'active' as const, +}; + +@Injectable() +export class ChannelsService implements OnModuleInit { + private channelSequence = 1; + private readonly channels: ChannelRecord[] = []; + + constructor( + @Optional() + @InjectDataSource() + readonly dataSource?: DataSource, + ) { + if (!this.hasDatabase()) { + this.seedMemoryDefaultChannel(); + } + } + + async onModuleInit() { + if (this.hasDatabase()) { + await this.seedDatabaseDefaultChannel(); + } + } + + async listChannels(input: PaginationInput): Promise> { + if (this.hasDatabase()) { + const page = Math.max(input.page, 1); + const pageSize = Math.min(Math.max(input.pageSize, 1), 100); + const [records, total] = await this.dataSource.getRepository(Channel).findAndCount({ + order: { sortOrder: 'DESC', id: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { + list: records.map((record) => this.toChannelRecord(record)), + pagination: { page, pageSize, total }, + }; + } + + return this.paginate(this.sortChannels(this.channels), input); + } + + async createChannel(dto: CreateChannelDto): Promise { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(Channel); + if (await repository.findOne({ where: { code: dto.code } })) { + throw new ConflictException('Channel code already exists'); + } + if (dto.isDefault) { + await repository.update({ isDefault: true }, { isDefault: false }); + } + const saved = await repository.save( + repository.create({ + name: dto.name, + code: dto.code, + description: dto.description, + sortOrder: dto.sortOrder ?? 0, + isDefault: dto.isDefault ?? false, + status: dto.status ?? 'active', + }), + ); + return this.toChannelRecord(saved); + } + + if (this.channels.some((channel) => channel.code === dto.code)) { + throw new ConflictException('Channel code already exists'); + } + if (dto.isDefault) { + this.channels.forEach((channel) => { + channel.isDefault = false; + channel.updatedAt = new Date().toISOString(); + }); + } + const now = new Date().toISOString(); + const channel: ChannelRecord = { + id: ++this.channelSequence, + name: dto.name, + code: dto.code, + description: dto.description, + sortOrder: dto.sortOrder ?? 0, + isDefault: dto.isDefault ?? false, + status: dto.status ?? 'active', + createdAt: now, + updatedAt: now, + }; + this.channels.push(channel); + return channel; + } + + async updateChannel(id: number, dto: UpdateChannelDto): Promise { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(Channel); + const channel = await repository.findOne({ where: { id } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + this.assertDefaultChannelCanChange(channel, dto); + if (dto.code && dto.code !== channel.code && (await repository.findOne({ where: { code: dto.code } }))) { + throw new ConflictException('Channel code already exists'); + } + if (dto.isDefault) { + await repository.update({ isDefault: true }, { isDefault: false }); + } + repository.merge(channel, { + ...dto, + sortOrder: dto.sortOrder ?? channel.sortOrder, + status: dto.status ?? channel.status, + }); + return this.toChannelRecord(await repository.save(channel)); + } + + const channel = this.channels.find((item) => item.id === id); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + this.assertDefaultChannelCanChange(channel, dto); + if (dto.code && dto.code !== channel.code && this.channels.some((item) => item.code === dto.code)) { + throw new ConflictException('Channel code already exists'); + } + if (dto.isDefault) { + this.channels.forEach((item) => { + item.isDefault = false; + item.updatedAt = new Date().toISOString(); + }); + } + Object.assign(channel, { + ...dto, + sortOrder: dto.sortOrder ?? channel.sortOrder, + status: dto.status ?? channel.status, + updatedAt: new Date().toISOString(), + }); + return channel; + } + + async deleteChannel(id: number) { + if (this.hasDatabase()) { + const repository = this.dataSource.getRepository(Channel); + const channel = await repository.findOne({ where: { id } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + this.assertChannelCanBeDeleted(channel); + const dramaCount = await this.dataSource.getRepository(Drama).count({ + where: { channelId: id }, + }); + if (dramaCount > 0) { + throw new UnprocessableEntityException('Channel is used by dramas'); + } + await repository.delete(id); + return { id }; + } + + const index = this.channels.findIndex((channel) => channel.id === id); + if (index < 0) { + throw new NotFoundException('Channel not found'); + } + this.assertChannelCanBeDeleted(this.channels[index]); + this.channels.splice(index, 1); + return { id }; + } + + + async getDefaultChannel(): Promise { + if (this.hasDatabase()) { + await this.seedDatabaseDefaultChannel(); + const channel = await this.dataSource.getRepository(Channel).findOne({ + where: { isDefault: true }, + }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + return this.toChannelRecord(channel); + } + + this.seedMemoryDefaultChannel(); + return this.channels.find((channel) => channel.isDefault) as ChannelRecord; + } + + async getActiveChannelOrThrow(id: number): Promise { + if (this.hasDatabase()) { + const channel = await this.dataSource.getRepository(Channel).findOne({ + where: { id, status: 'active' }, + }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + return this.toChannelRecord(channel); + } + + const channel = this.channels.find((item) => item.id === id && item.status === 'active'); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + return channel; + } + + private hasDatabase(): this is this & { dataSource: DataSource } { + return Boolean(this.dataSource?.isInitialized); + } + + private seedMemoryDefaultChannel() { + if (this.channels.some((channel) => channel.code === DEFAULT_CHANNEL.code)) { + return; + } + const now = new Date().toISOString(); + this.channels.push({ + ...DEFAULT_CHANNEL, + createdAt: now, + updatedAt: now, + }); + this.channelSequence = DEFAULT_CHANNEL.id; + } + + private async seedDatabaseDefaultChannel() { + const repository = this.dataSource!.getRepository(Channel); + const existing = await repository.findOne({ where: { code: DEFAULT_CHANNEL.code } }); + if (!existing) { + await repository.save(repository.create(DEFAULT_CHANNEL)); + return; + } + let changed = false; + if (!existing.isDefault) { + existing.isDefault = true; + changed = true; + } + if (existing.status !== 'active') { + existing.status = 'active'; + changed = true; + } + if (changed) { + await repository.save(existing); + } + } + + private assertDefaultChannelCanChange(channel: Channel | ChannelRecord, dto: UpdateChannelDto) { + if (channel.isDefault && dto.status === 'disabled') { + throw new UnprocessableEntityException('Default channel cannot be disabled'); + } + if (channel.isDefault && dto.isDefault === false) { + throw new UnprocessableEntityException('Default channel cannot be unset'); + } + } + + private assertChannelCanBeDeleted(channel: Channel | ChannelRecord) { + if (channel.isDefault) { + throw new UnprocessableEntityException('Default channel cannot be deleted'); + } + } + + private toChannelRecord(channel: Channel): ChannelRecord { + return { + id: Number(channel.id), + name: channel.name, + code: channel.code, + description: channel.description, + sortOrder: channel.sortOrder, + isDefault: channel.isDefault, + status: channel.status, + createdAt: this.toIso(channel.createdAt), + updatedAt: this.toIso(channel.updatedAt), + }; + } + + private toIso(value?: Date | string): string { + if (!value) { + return new Date().toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + } + + private sortChannels(records: ChannelRecord[]) { + return [...records].sort((left, right) => { + if (right.sortOrder !== left.sortOrder) { + return right.sortOrder - left.sortOrder; + } + return left.id - right.id; + }); + } + + 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/channels/channels.types.ts b/src/channels/channels.types.ts new file mode 100644 index 0000000..87059d1 --- /dev/null +++ b/src/channels/channels.types.ts @@ -0,0 +1,13 @@ +export type ChannelStatus = 'active' | 'disabled'; + +export interface ChannelRecord { + id: number; + name: string; + code: string; + description?: string; + sortOrder: number; + isDefault: boolean; + status: ChannelStatus; + createdAt: string; + updatedAt: string; +} diff --git a/src/dramas/drama.entity.ts b/src/dramas/drama.entity.ts index 72aa9e5..c616409 100644 --- a/src/dramas/drama.entity.ts +++ b/src/dramas/drama.entity.ts @@ -24,6 +24,9 @@ export class Drama { @Column({ type: 'int', nullable: true }) coverId?: number; + @Column({ type: 'int', nullable: true }) + channelId?: number; + @Column({ type: 'varchar', length: 1024 }) coverUrl: string; @@ -33,7 +36,7 @@ export class Drama { @Column({ type: 'varchar', length: 1024, nullable: true }) landscapeCoverUrl?: string; - @Column({ type: 'varchar', length: 128 }) + @Column({ type: 'varchar', length: 128, default: '' }) type: string; @Column({ type: 'json', nullable: true }) diff --git a/src/dramas/dramas.controller.ts b/src/dramas/dramas.controller.ts index 29bb9f7..f87c897 100644 --- a/src/dramas/dramas.controller.ts +++ b/src/dramas/dramas.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { RequirePermissions } from "@app/decorators/require-permissions.decorator"; import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard"; @@ -49,6 +49,14 @@ export class DramasController { return this.dramasService.updateDrama(Number(id), dto); } + @Delete("/api/admin/v1/dramas/:id") + @ApiBearerAuth() + @UseGuards(AdminJwtAuthGuard, PermissionsGuard) + @RequirePermissions("drama:delete") + deleteDrama(@Param("id") id: string) { + return this.dramasService.deleteDrama(Number(id)); + } + @Post("/api/admin/v1/dramas/:id/episodes/batch") @ApiBearerAuth() @UseGuards(AdminJwtAuthGuard, PermissionsGuard) diff --git a/src/dramas/dramas.dto.ts b/src/dramas/dramas.dto.ts index 61d3b00..faf27b0 100644 --- a/src/dramas/dramas.dto.ts +++ b/src/dramas/dramas.dto.ts @@ -29,6 +29,12 @@ export class CreateDramaDto { @Min(1) coverId?: number; + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1) + channelId?: number; + @ApiPropertyOptional() @IsOptional() @IsUrl({ require_tld: false }) @@ -39,9 +45,10 @@ export class CreateDramaDto { @IsUrl({ require_tld: false }) landscapeCoverUrl?: string; - @ApiProperty() + @ApiPropertyOptional() + @IsOptional() @IsString() - type: string; + type?: string; @ApiPropertyOptional({ type: [String] }) @IsOptional() diff --git a/src/dramas/dramas.module.ts b/src/dramas/dramas.module.ts index f6d2a38..660682b 100644 --- a/src/dramas/dramas.module.ts +++ b/src/dramas/dramas.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AdminModule } from '@src/admin/admin.module'; +import { ChannelsModule } from '@src/channels/channels.module'; import { DramasController } from './dramas.controller'; import { DramasService } from './dramas.service'; @Module({ - imports: [AdminModule], + imports: [AdminModule, ChannelsModule], controllers: [DramasController], providers: [DramasService], exports: [DramasService], diff --git a/src/dramas/dramas.service.ts b/src/dramas/dramas.service.ts index 61f8958..b0f3faf 100644 --- a/src/dramas/dramas.service.ts +++ b/src/dramas/dramas.service.ts @@ -2,6 +2,8 @@ import { ConflictException, Injectable, NotFoundException, Optional, Unprocessab import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, Like } from "typeorm"; import { PaginatedResponse } from "@app/common/paginated-response.dto"; +import { ChannelsService } from "@src/channels/channels.service"; +import { ChannelRecord } from "@src/channels/channels.types"; import { EpisodeMetric } from "@src/data/episode-metric.entity"; import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto"; import { Drama } from "./drama.entity"; @@ -26,6 +28,17 @@ interface DramaPresearchInput extends PaginationInput { type PublicDramaRecord = Omit; +const FALLBACK_DEFAULT_CHANNEL: ChannelRecord = { + id: 1, + name: "默认频道", + code: "default", + sortOrder: 0, + isDefault: true, + status: "active", + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), +}; + @Injectable() export class DramasService { private dramaSequence = 1; @@ -37,10 +50,13 @@ export class DramasService { @Optional() @InjectDataSource() readonly dataSource?: DataSource, + @Optional() + private readonly channelsService?: ChannelsService, ) {} async createDrama(dto: CreateDramaDto): Promise { const { totalEpisodes } = dto; + const channel = await this.resolveDramaChannel(dto.channelId); if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const entity = repository.create({ @@ -48,10 +64,11 @@ export class DramasService { title: dto.title, description: dto.description, coverId: dto.coverId, + channelId: channel.id, coverUrl: dto.coverUrl, portraitCoverUrl: dto.portraitCoverUrl, landscapeCoverUrl: dto.landscapeCoverUrl, - type: dto.type, + type: dto.type ?? "", tags: dto.tags ?? [], region: dto.region, language: dto.language, @@ -68,7 +85,7 @@ export class DramasService { }); return this.getDramaOrThrow(Number(saved.id)); } - return this.toDramaRecord(saved, 0, 0); + return this.withChannelName(this.toDramaRecord(saved, 0, 0)); } const now = new Date().toISOString(); @@ -81,7 +98,7 @@ export class DramasService { coverUrl: dto.coverUrl, portraitCoverUrl: dto.portraitCoverUrl, landscapeCoverUrl: dto.landscapeCoverUrl, - type: dto.type, + type: dto.type ?? "", tags: dto.tags ?? [], region: dto.region, language: dto.language, @@ -90,6 +107,8 @@ export class DramasService { sortOrder: dto.sortOrder ?? 0, isRecommended: dto.isRecommended ?? false, onlineVersion: dto.onlineVersion ?? 1, + channelId: channel.id, + channelName: channel.name, totalEpisodes: 0, publishedEpisodes: 0, plays: 0, @@ -157,7 +176,8 @@ export class DramasService { } async updateDrama(id: number, dto: UpdateDramaDto): Promise { - const { totalEpisodes, ...dramaUpdates } = dto; + const { totalEpisodes, channelId, ...dramaUpdates } = dto; + const channel = channelId === undefined ? undefined : await this.resolveDramaChannel(channelId); if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const drama = await repository.findOne({ where: { id } }); @@ -166,6 +186,7 @@ export class DramasService { } repository.merge(drama, { ...dramaUpdates, + ...(channel ? { channelId: channel.id } : {}), tags: dramaUpdates.tags ?? drama.tags, }); const saved = await repository.save(drama); @@ -184,6 +205,7 @@ export class DramasService { Object.assign(drama, { ...dramaUpdates, + ...(channel ? { channelId: channel.id, channelName: channel.name } : {}), tags: dramaUpdates.tags ?? drama.tags, updatedAt: new Date().toISOString(), }); @@ -193,6 +215,31 @@ export class DramasService { return this.withEpisodeCounts(drama); } + async deleteDrama(id: number) { + if (this.hasDatabase()) { + const dramaRepository = this.dataSource.getRepository(Drama); + const drama = await dramaRepository.findOne({ where: { id } }); + if (!drama) { + throw new NotFoundException("Drama not found"); + } + await this.dataSource.getRepository(Episode).delete({ dramaId: id }); + await dramaRepository.delete(id); + return { id }; + } + + const index = this.dramas.findIndex((item) => item.id === id); + if (index < 0) { + throw new NotFoundException("Drama not found"); + } + this.dramas.splice(index, 1); + for (let episodeIndex = this.episodes.length - 1; episodeIndex >= 0; episodeIndex--) { + if (this.episodes[episodeIndex].dramaId === id) { + this.episodes.splice(episodeIndex, 1); + } + } + return { id }; + } + async createEpisode(dto: CreateEpisodeDto): Promise { const dramaId = dto.dramaId ?? dto.albumId; const episodeNo = dto.episodeNo ?? dto.seq; @@ -808,7 +855,7 @@ export class DramasService { reviewStatus: "approved", }, }); - return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes); + return this.withChannelName(this.toDramaRecord(entity, totalEpisodes, publishedEpisodes)); } private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord { @@ -818,6 +865,8 @@ export class DramasService { title: entity.title, description: entity.description, coverId: entity.coverId, + channelId: entity.channelId ?? FALLBACK_DEFAULT_CHANNEL.id, + channelName: entity.channelId === FALLBACK_DEFAULT_CHANNEL.id || !entity.channelId ? FALLBACK_DEFAULT_CHANNEL.name : undefined, coverUrl: entity.coverUrl, portraitCoverUrl: entity.portraitCoverUrl, landscapeCoverUrl: entity.landscapeCoverUrl, @@ -838,6 +887,32 @@ export class DramasService { }; } + private async withChannelName(record: DramaRecord): Promise { + if (!record.channelId) { + return { + ...record, + channelId: FALLBACK_DEFAULT_CHANNEL.id, + channelName: FALLBACK_DEFAULT_CHANNEL.name, + }; + } + + if (record.channelName) { + return record; + } + + if (!this.channelsService) { + return record.channelId === FALLBACK_DEFAULT_CHANNEL.id + ? { ...record, channelName: FALLBACK_DEFAULT_CHANNEL.name } + : record; + } + + const channel = await this.channelsService.getActiveChannelOrThrow(record.channelId); + return { + ...record, + channelName: channel.name, + }; + } + private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord { const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record; void portraitCoverUrl; @@ -918,9 +993,21 @@ export class DramasService { totalEpisodes: episodes.length, publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length, plays: drama.plays ?? 0, + channelId: drama.channelId ?? FALLBACK_DEFAULT_CHANNEL.id, + channelName: drama.channelName ?? FALLBACK_DEFAULT_CHANNEL.name, }; } + private async resolveDramaChannel(channelId?: number): Promise { + if (this.channelsService) { + return channelId ? this.channelsService.getActiveChannelOrThrow(channelId) : this.channelsService.getDefaultChannel(); + } + if (channelId && channelId !== FALLBACK_DEFAULT_CHANNEL.id) { + throw new NotFoundException("Channel not found"); + } + return FALLBACK_DEFAULT_CHANNEL; + } + private async getPlayCountsByDramaIds(dramaIds: number[]): Promise> { const playCounts = new Map(); if (!this.hasDatabase() || dramaIds.length === 0) { diff --git a/src/dramas/dramas.types.ts b/src/dramas/dramas.types.ts index f3b76c7..1ccecb5 100644 --- a/src/dramas/dramas.types.ts +++ b/src/dramas/dramas.types.ts @@ -11,6 +11,8 @@ export interface DramaRecord { title: string; description?: string; coverId?: number; + channelId?: number; + channelName?: string; coverUrl: string; portraitCoverUrl?: string; landscapeCoverUrl?: string; diff --git a/test/dramas.e2e-spec.ts b/test/dramas.e2e-spec.ts index 5bf2028..b1d0484 100644 --- a/test/dramas.e2e-spec.ts +++ b/test/dramas.e2e-spec.ts @@ -24,6 +24,210 @@ describe('Dramas and episodes', () => { await app.close(); }); + it('lists the default channel for admin selectors', async () => { + const response = await request(app.getHttpServer()) + .get('/api/admin/v1/channels') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(response.body.data.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + name: '默认频道', + code: 'default', + isDefault: true, + status: 'active', + }), + ]), + ); + }); + + it('assigns dramas to the default channel when no channel is selected', async () => { + const dramaResponse = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Default Channel Drama', + coverUrl: 'https://cdn.example.com/default-channel.jpg', + type: 'romance', + status: 'draft', + }) + .expect(201); + + expect(dramaResponse.body.data).toMatchObject({ + title: 'Default Channel Drama', + channelId: expect.any(Number), + channelName: '默认频道', + }); + + const detail = await request(app.getHttpServer()) + .get(`/api/admin/v1/dramas/${dramaResponse.body.data.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(detail.body.data).toMatchObject({ + id: dramaResponse.body.data.id, + channelId: dramaResponse.body.data.channelId, + channelName: '默认频道', + }); + }); + + it('allows creating a drama without a type', async () => { + const response = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'No Type Drama', + coverUrl: 'https://cdn.example.com/no-type.jpg', + status: 'draft', + }) + .expect(201); + + expect(response.body.data).toMatchObject({ + title: 'No Type Drama', + type: '', + channelName: '默认频道', + }); + }); + + it('deletes a drama and its episodes from admin APIs', async () => { + const drama = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Delete Drama Test', + coverUrl: 'https://cdn.example.com/delete-drama.jpg', + type: 'action', + status: 'draft', + }) + .expect(201); + + await request(app.getHttpServer()) + .post('/api/admin/v1/episodes') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + dramaId: drama.body.data.id, + episodeNo: 1, + title: 'Delete Episode', + coverUrl: 'https://cdn.example.com/delete-episode.jpg', + durationSeconds: 300, + status: 'draft', + }) + .expect(201); + + await request(app.getHttpServer()) + .delete(`/api/admin/v1/dramas/${drama.body.data.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + await request(app.getHttpServer()) + .get(`/api/admin/v1/dramas/${drama.body.data.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(404); + + const list = await request(app.getHttpServer()) + .get('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .query({ title: 'Delete Drama Test' }) + .expect(200); + + expect(list.body.data.list).toEqual([]); + }); + + it('supports assigning a drama to a selected channel and rejects missing channels', async () => { + const channelResponse = await request(app.getHttpServer()) + .post('/api/admin/v1/channels') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: '测试频道', + code: 'test-channel', + sortOrder: 10, + }) + .expect(201); + + const dramaResponse = await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Selected Channel Drama', + coverUrl: 'https://cdn.example.com/selected-channel.jpg', + type: 'action', + status: 'draft', + channelId: channelResponse.body.data.id, + }) + .expect(201); + + expect(dramaResponse.body.data).toMatchObject({ + title: 'Selected Channel Drama', + channelId: channelResponse.body.data.id, + channelName: '测试频道', + }); + + await request(app.getHttpServer()) + .post('/api/admin/v1/dramas') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Missing Channel Drama', + coverUrl: 'https://cdn.example.com/missing-channel.jpg', + type: 'action', + status: 'draft', + channelId: 999999, + }) + .expect(404); + }); + + it('updates and deletes non-default channels but keeps the default channel protected', async () => { + const created = await request(app.getHttpServer()) + .post('/api/admin/v1/channels') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: '待编辑频道', + code: 'editable-channel', + sortOrder: 1, + }) + .expect(201); + + const updated = await request(app.getHttpServer()) + .patch(`/api/admin/v1/channels/${created.body.data.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: '已编辑频道', + sortOrder: 2, + }) + .expect(200); + + expect(updated.body.data).toMatchObject({ + id: created.body.data.id, + name: '已编辑频道', + sortOrder: 2, + }); + + await request(app.getHttpServer()) + .delete(`/api/admin/v1/channels/${created.body.data.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + const channels = await request(app.getHttpServer()) + .get('/api/admin/v1/channels') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(channels.body.data.list).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'editable-channel' }), + ]), + ); + + const defaultChannel = channels.body.data.list.find( + (channel: { code: string }) => channel.code === 'default', + ); + await request(app.getHttpServer()) + .delete(`/api/admin/v1/channels/${defaultChannel.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(422); + }); + it('allows admin APIs to create a drama and episodes with release metadata', async () => { const dramaResponse = await request(app.getHttpServer()) .post('/api/admin/v1/dramas') diff --git a/tsconfig.json b/tsconfig.json index a66eb30..65b5f37 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,6 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "strict": true, "skipLibCheck": true,