feat: 添加频道管理功能,关联短剧与频道绑定逻辑
1. 新增频道模块,包含类型定义、实体、DTO、控制器和服务 2. 为短剧添加频道ID和频道名字段,支持关联频道 3. 新增短剧删除API和权限配置 4. 调整tsconfig和依赖配置,更新权限列表 5. 新增相关测试用例验证频道与短剧关联逻辑
This commit is contained in:
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -11,9 +11,6 @@ importers:
|
|||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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)
|
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:
|
rxjs:
|
||||||
specifier: ^7.8.0
|
specifier: ^7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
tsconfig-paths:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.0
|
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))
|
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:
|
ts-node:
|
||||||
specifier: ^10.9.0
|
specifier: ^10.9.0
|
||||||
version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3)
|
version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3)
|
||||||
tsconfig-paths:
|
|
||||||
specifier: ^4.2.0
|
|
||||||
version: 4.2.0
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -710,12 +707,6 @@ packages:
|
|||||||
class-validator:
|
class-validator:
|
||||||
optional: true
|
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':
|
'@nestjs/core@11.1.27':
|
||||||
resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==}
|
resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -1658,10 +1649,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
||||||
engines: {node: '>=0.3.1'}
|
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:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4112,14 +4099,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
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)
|
'@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: {}
|
diff@4.0.4: {}
|
||||||
|
|
||||||
dotenv-expand@12.0.3:
|
|
||||||
dependencies:
|
|
||||||
dotenv: 16.6.1
|
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dotenv@17.4.1: {}
|
dotenv@17.4.1: {}
|
||||||
|
|||||||
@@ -411,9 +411,14 @@ export class AdminService implements OnModuleInit {
|
|||||||
['admin-user:update', '更新人员'],
|
['admin-user:update', '更新人员'],
|
||||||
['user:read', '查看用户'],
|
['user:read', '查看用户'],
|
||||||
['user:update', '更新用户'],
|
['user:update', '更新用户'],
|
||||||
|
['channel:read', '查看频道'],
|
||||||
|
['channel:create', '创建频道'],
|
||||||
|
['channel:update', '更新频道'],
|
||||||
|
['channel:delete', '删除频道'],
|
||||||
['drama:read', '查看短剧'],
|
['drama:read', '查看短剧'],
|
||||||
['drama:create', '创建短剧'],
|
['drama:create', '创建短剧'],
|
||||||
['drama:update', '更新短剧'],
|
['drama:update', '更新短剧'],
|
||||||
|
['drama:delete', '删除短剧'],
|
||||||
['episode:create', '创建剧集'],
|
['episode:create', '创建剧集'],
|
||||||
['episode:update', '更新剧集'],
|
['episode:update', '更新剧集'],
|
||||||
['media:read', '查看媒体'],
|
['media:read', '查看媒体'],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { OrdersModule } from "@src/orders/orders.module";
|
|||||||
import { PlayerModule } from "@src/player/player.module";
|
import { PlayerModule } from "@src/player/player.module";
|
||||||
import { HistoryModule } from "@src/history/history.module";
|
import { HistoryModule } from "@src/history/history.module";
|
||||||
import { LogsModule } from "@src/logs/logs.module";
|
import { LogsModule } from "@src/logs/logs.module";
|
||||||
|
import { ChannelsModule } from "@src/channels/channels.module";
|
||||||
|
|
||||||
// 数据库实体
|
// 数据库实体
|
||||||
import { AdminUser } from "@src/admin/admin-user.entity";
|
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 { UserEpisodeUnlock } from "@src/orders/user-episode-unlock.entity";
|
||||||
import { UserPlaybackHistory } from "@src/history/user-playback-history.entity";
|
import { UserPlaybackHistory } from "@src/history/user-playback-history.entity";
|
||||||
import { User } from "@src/users/user.entity";
|
import { User } from "@src/users/user.entity";
|
||||||
|
import { Channel } from "@src/channels/channel.entity";
|
||||||
|
|
||||||
// 未配置 DB_HOST 时(如 e2e 测试)退回内存模式,不装配 TypeORM
|
// 未配置 DB_HOST 时(如 e2e 测试)退回内存模式,不装配 TypeORM
|
||||||
const databaseImports = DB_CONFIG.host
|
const databaseImports = DB_CONFIG.host
|
||||||
? [
|
? [
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
...DB_CONFIG,
|
...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, // 管理端(人员/角色/权限)
|
AdminModule, // 管理端(人员/角色/权限)
|
||||||
LogsModule, // 请求与播放错误日志
|
LogsModule, // 请求与播放错误日志
|
||||||
|
ChannelsModule, // 频道管理
|
||||||
DramasModule, // 短剧与剧集
|
DramasModule, // 短剧与剧集
|
||||||
DataModule, // 数据指标与同步
|
DataModule, // 数据指标与同步
|
||||||
MediaModule, // 媒体上传与封面
|
MediaModule, // 媒体上传与封面
|
||||||
|
|||||||
41
src/channels/channel.entity.ts
Normal file
41
src/channels/channel.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
48
src/channels/channels.controller.ts
Normal file
48
src/channels/channels.controller.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/channels/channels.dto.ts
Normal file
36
src/channels/channels.dto.ts
Normal file
@@ -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) {}
|
||||||
12
src/channels/channels.module.ts
Normal file
12
src/channels/channels.module.ts
Normal file
@@ -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 {}
|
||||||
316
src/channels/channels.service.ts
Normal file
316
src/channels/channels.service.ts
Normal file
@@ -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<PaginatedResponse<ChannelRecord>> {
|
||||||
|
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<ChannelRecord> {
|
||||||
|
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<ChannelRecord> {
|
||||||
|
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<ChannelRecord> {
|
||||||
|
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<ChannelRecord> {
|
||||||
|
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<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
||||||
|
const page = Math.max(input.page, 1);
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return {
|
||||||
|
list: records.slice(start, start + pageSize),
|
||||||
|
pagination: { page, pageSize, total: records.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/channels/channels.types.ts
Normal file
13
src/channels/channels.types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export class Drama {
|
|||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
coverId?: number;
|
coverId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
channelId?: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 1024 })
|
@Column({ type: 'varchar', length: 1024 })
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ export class Drama {
|
|||||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||||
landscapeCoverUrl?: string;
|
landscapeCoverUrl?: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 128 })
|
@Column({ type: 'varchar', length: 128, default: '' })
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@Column({ type: 'json', nullable: true })
|
@Column({ type: 'json', nullable: true })
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
|
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
|
||||||
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
|
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
|
||||||
@@ -49,6 +49,14 @@ export class DramasController {
|
|||||||
return this.dramasService.updateDrama(Number(id), dto);
|
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")
|
@Post("/api/admin/v1/dramas/:id/episodes/batch")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export class CreateDramaDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
coverId?: number;
|
coverId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
channelId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
@@ -39,9 +45,10 @@ export class CreateDramaDto {
|
|||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
landscapeCoverUrl?: string;
|
landscapeCoverUrl?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string;
|
type?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [String] })
|
@ApiPropertyOptional({ type: [String] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminModule } from '@src/admin/admin.module';
|
import { AdminModule } from '@src/admin/admin.module';
|
||||||
|
import { ChannelsModule } from '@src/channels/channels.module';
|
||||||
import { DramasController } from './dramas.controller';
|
import { DramasController } from './dramas.controller';
|
||||||
import { DramasService } from './dramas.service';
|
import { DramasService } from './dramas.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminModule],
|
imports: [AdminModule, ChannelsModule],
|
||||||
controllers: [DramasController],
|
controllers: [DramasController],
|
||||||
providers: [DramasService],
|
providers: [DramasService],
|
||||||
exports: [DramasService],
|
exports: [DramasService],
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ConflictException, Injectable, NotFoundException, Optional, Unprocessab
|
|||||||
import { InjectDataSource } from "@nestjs/typeorm";
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource, Like } from "typeorm";
|
import { DataSource, Like } from "typeorm";
|
||||||
import { PaginatedResponse } from "@app/common/paginated-response.dto";
|
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 { EpisodeMetric } from "@src/data/episode-metric.entity";
|
||||||
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
|
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
|
||||||
import { Drama } from "./drama.entity";
|
import { Drama } from "./drama.entity";
|
||||||
@@ -26,6 +28,17 @@ interface DramaPresearchInput extends PaginationInput {
|
|||||||
|
|
||||||
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
|
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
|
||||||
|
|
||||||
|
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()
|
@Injectable()
|
||||||
export class DramasService {
|
export class DramasService {
|
||||||
private dramaSequence = 1;
|
private dramaSequence = 1;
|
||||||
@@ -37,10 +50,13 @@ export class DramasService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
readonly dataSource?: DataSource,
|
readonly dataSource?: DataSource,
|
||||||
|
@Optional()
|
||||||
|
private readonly channelsService?: ChannelsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
||||||
const { totalEpisodes } = dto;
|
const { totalEpisodes } = dto;
|
||||||
|
const channel = await this.resolveDramaChannel(dto.channelId);
|
||||||
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({
|
||||||
@@ -48,10 +64,11 @@ export class DramasService {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
coverId: dto.coverId,
|
coverId: dto.coverId,
|
||||||
|
channelId: channel.id,
|
||||||
coverUrl: dto.coverUrl,
|
coverUrl: dto.coverUrl,
|
||||||
portraitCoverUrl: dto.portraitCoverUrl,
|
portraitCoverUrl: dto.portraitCoverUrl,
|
||||||
landscapeCoverUrl: dto.landscapeCoverUrl,
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
||||||
type: dto.type,
|
type: dto.type ?? "",
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
region: dto.region,
|
region: dto.region,
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
@@ -68,7 +85,7 @@ export class DramasService {
|
|||||||
});
|
});
|
||||||
return this.getDramaOrThrow(Number(saved.id));
|
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();
|
const now = new Date().toISOString();
|
||||||
@@ -81,7 +98,7 @@ export class DramasService {
|
|||||||
coverUrl: dto.coverUrl,
|
coverUrl: dto.coverUrl,
|
||||||
portraitCoverUrl: dto.portraitCoverUrl,
|
portraitCoverUrl: dto.portraitCoverUrl,
|
||||||
landscapeCoverUrl: dto.landscapeCoverUrl,
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
||||||
type: dto.type,
|
type: dto.type ?? "",
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
region: dto.region,
|
region: dto.region,
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
@@ -90,6 +107,8 @@ export class DramasService {
|
|||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
isRecommended: dto.isRecommended ?? false,
|
isRecommended: dto.isRecommended ?? false,
|
||||||
onlineVersion: dto.onlineVersion ?? 1,
|
onlineVersion: dto.onlineVersion ?? 1,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
publishedEpisodes: 0,
|
publishedEpisodes: 0,
|
||||||
plays: 0,
|
plays: 0,
|
||||||
@@ -157,7 +176,8 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
||||||
const { totalEpisodes, ...dramaUpdates } = dto;
|
const { totalEpisodes, channelId, ...dramaUpdates } = dto;
|
||||||
|
const channel = channelId === undefined ? undefined : await this.resolveDramaChannel(channelId);
|
||||||
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 } });
|
||||||
@@ -166,6 +186,7 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
repository.merge(drama, {
|
repository.merge(drama, {
|
||||||
...dramaUpdates,
|
...dramaUpdates,
|
||||||
|
...(channel ? { channelId: channel.id } : {}),
|
||||||
tags: dramaUpdates.tags ?? drama.tags,
|
tags: dramaUpdates.tags ?? drama.tags,
|
||||||
});
|
});
|
||||||
const saved = await repository.save(drama);
|
const saved = await repository.save(drama);
|
||||||
@@ -184,6 +205,7 @@ export class DramasService {
|
|||||||
|
|
||||||
Object.assign(drama, {
|
Object.assign(drama, {
|
||||||
...dramaUpdates,
|
...dramaUpdates,
|
||||||
|
...(channel ? { channelId: channel.id, channelName: channel.name } : {}),
|
||||||
tags: dramaUpdates.tags ?? drama.tags,
|
tags: dramaUpdates.tags ?? drama.tags,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -193,6 +215,31 @@ export class DramasService {
|
|||||||
return this.withEpisodeCounts(drama);
|
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<EpisodeRecord> {
|
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
|
||||||
const dramaId = dto.dramaId ?? dto.albumId;
|
const dramaId = dto.dramaId ?? dto.albumId;
|
||||||
const episodeNo = dto.episodeNo ?? dto.seq;
|
const episodeNo = dto.episodeNo ?? dto.seq;
|
||||||
@@ -808,7 +855,7 @@ export class DramasService {
|
|||||||
reviewStatus: "approved",
|
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 {
|
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
|
||||||
@@ -818,6 +865,8 @@ export class DramasService {
|
|||||||
title: entity.title,
|
title: entity.title,
|
||||||
description: entity.description,
|
description: entity.description,
|
||||||
coverId: entity.coverId,
|
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,
|
coverUrl: entity.coverUrl,
|
||||||
portraitCoverUrl: entity.portraitCoverUrl,
|
portraitCoverUrl: entity.portraitCoverUrl,
|
||||||
landscapeCoverUrl: entity.landscapeCoverUrl,
|
landscapeCoverUrl: entity.landscapeCoverUrl,
|
||||||
@@ -838,6 +887,32 @@ export class DramasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async withChannelName(record: DramaRecord): Promise<DramaRecord> {
|
||||||
|
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 {
|
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
|
||||||
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
|
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
|
||||||
void portraitCoverUrl;
|
void portraitCoverUrl;
|
||||||
@@ -918,9 +993,21 @@ export class DramasService {
|
|||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
||||||
plays: drama.plays ?? 0,
|
plays: drama.plays ?? 0,
|
||||||
|
channelId: drama.channelId ?? FALLBACK_DEFAULT_CHANNEL.id,
|
||||||
|
channelName: drama.channelName ?? FALLBACK_DEFAULT_CHANNEL.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveDramaChannel(channelId?: number): Promise<ChannelRecord> {
|
||||||
|
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<Map<number, number>> {
|
private async getPlayCountsByDramaIds(dramaIds: number[]): Promise<Map<number, number>> {
|
||||||
const playCounts = new Map<number, number>();
|
const playCounts = new Map<number, number>();
|
||||||
if (!this.hasDatabase() || dramaIds.length === 0) {
|
if (!this.hasDatabase() || dramaIds.length === 0) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface DramaRecord {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
coverId?: number;
|
coverId?: number;
|
||||||
|
channelId?: number;
|
||||||
|
channelName?: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
portraitCoverUrl?: string;
|
portraitCoverUrl?: string;
|
||||||
landscapeCoverUrl?: string;
|
landscapeCoverUrl?: string;
|
||||||
|
|||||||
@@ -24,6 +24,210 @@ describe('Dramas and episodes', () => {
|
|||||||
await app.close();
|
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 () => {
|
it('allows admin APIs to create a drama and episodes with release metadata', async () => {
|
||||||
const dramaResponse = await request(app.getHttpServer())
|
const dramaResponse = await request(app.getHttpServer())
|
||||||
.post('/api/admin/v1/dramas')
|
.post('/api/admin/v1/dramas')
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user