refactor: 重构项目目录与代码结构,清理冗余文件

本次提交进行了大规模的项目结构调整:
1. 调整文件目录归属,将模块代码按功能域重新组织
2. 删除多处冗余的DTO、实体类和模块文件
3. 统一了常量、装饰器、工具类的存放位置
4. 修复了`.gitignore`的日志目录匹配规则
5. 新增了健康检查、认证、用户、剧集、订单等核心业务模块
This commit is contained in:
2026-07-02 19:17:43 +08:00
parent 55e20f9f16
commit e84351e8ed
99 changed files with 522 additions and 441 deletions

View File

@@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('data_sync_jobs')
export class DataSyncJob {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 128 })
jobId: string;
@Column({ type: 'varchar', length: 32 })
status: string;
@Column({ type: 'bigint', nullable: true })
dramaId?: number;
@Column({ type: 'int', default: 0 })
dramaCount: number;
@Column({ type: 'int', default: 0 })
episodeCount: number;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '../admin/guards/permissions.guard';
import { DataService } from './data.service';
@ApiTags('Data')
@Controller('/api/admin/v1/data')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
export class DataController {
constructor(private readonly dataService: DataService) {}
@Post('/sync')
@RequirePermissions('data:sync')
sync(@Body() body: { dramaId?: number }) {
return this.dataService.sync(body);
}
@Get('/overview')
@RequirePermissions('data:read')
getOverview() {
return this.dataService.getOverview();
}
@Get('/dramas')
@RequirePermissions('data:read')
listDramaMetrics(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listDramaMetrics({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/episodes')
@RequirePermissions('data:read')
listEpisodeMetrics(
@Query('dramaId') dramaId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listEpisodeMetrics({
dramaId: dramaId ? Number(dramaId) : undefined,
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/sync-jobs')
@RequirePermissions('data:read')
listSyncJobs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dataService.listSyncJobs({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

13
src/data/data.module.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { DataController } from './data.controller';
import { DataService } from './data.service';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
@Module({
imports: [AdminModule, DramasModule],
controllers: [DataController],
providers: [DataService, TikTokDataProvider],
})
export class DataModule {}

29
src/data/data.provider.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { DramaRecord, EpisodeRecord } from '../../dramas/interfaces/drama-records.interface';
@Injectable()
export class TikTokDataProvider {
getDramaMetrics(drama: DramaRecord) {
const base = drama.id * 1000 + drama.totalEpisodes * 100;
return {
plays: base + 500,
likes: base + 80,
shares: drama.id * 13 + 20,
comments: drama.id * 7 + 12,
completionRate: 0.68,
averageWatchSeconds: 96,
};
}
getEpisodeMetrics(episode: EpisodeRecord) {
const base = episode.id * 500 + episode.episodeNo * 50;
return {
plays: base + 300,
likes: base + 40,
shares: episode.id * 5 + 8,
comments: episode.id * 3 + 6,
completionRate: 0.72,
averageWatchSeconds: Math.min(episode.durationSeconds || 180, 120),
};
}
}

312
src/data/data.service.ts Normal file
View File

@@ -0,0 +1,312 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { DataSyncJob } from './entities/data-sync-job.entity';
import { DramaMetric } from './entities/drama-metric.entity';
import { EpisodeMetric } from './entities/episode-metric.entity';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
interface PaginationInput {
page: number;
pageSize: number;
}
interface SyncInput {
dramaId?: number;
}
export interface DramaMetricRecord {
dramaId: number;
title: string;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface EpisodeMetricRecord {
dramaId: number;
episodeId: number;
title: string;
episodeNo: number;
plays: number;
likes: number;
shares: number;
comments: number;
completionRate: number;
averageWatchSeconds: number;
syncedAt: string;
}
export interface SyncJobRecord {
id: number;
jobId: string;
status: 'completed' | 'failed';
dramaId?: number;
dramaCount: number;
episodeCount: number;
createdAt: string;
}
@Injectable()
export class DataService {
private syncJobSequence = 1;
private readonly dramaMetrics = new Map<number, DramaMetricRecord>();
private readonly episodeMetrics = new Map<number, EpisodeMetricRecord>();
private readonly syncJobs: SyncJobRecord[] = [];
constructor(
private readonly dramasService: DramasService,
private readonly tiktokDataProvider: TikTokDataProvider,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async sync(input: SyncInput = {}) {
const now = new Date().toISOString();
const dramas = (await this.dramasService.listAllDramas()).filter(
(drama) => !input.dramaId || drama.id === input.dramaId,
);
const dramaIds = new Set(dramas.map((drama) => drama.id));
const episodes = (await this.dramasService.listAllEpisodes()).filter(
(episode) => dramaIds.has(episode.dramaId),
);
const dramaRecords = dramas.map((drama) => ({
dramaId: drama.id,
title: drama.title,
...this.tiktokDataProvider.getDramaMetrics(drama),
syncedAt: now,
}));
const episodeRecords = episodes.map((episode) => ({
dramaId: episode.dramaId,
episodeId: episode.id,
title: episode.title,
episodeNo: episode.episodeNo,
...this.tiktokDataProvider.getEpisodeMetrics(episode),
syncedAt: now,
}));
for (const record of dramaRecords) {
this.dramaMetrics.set(record.dramaId, record);
}
for (const record of episodeRecords) {
this.episodeMetrics.set(record.episodeId, record);
}
const job: SyncJobRecord = {
id: this.syncJobSequence++,
jobId: `DATA_SYNC_${Date.now()}_${this.syncJobSequence}`,
status: 'completed',
dramaId: input.dramaId,
dramaCount: dramas.length,
episodeCount: episodes.length,
createdAt: now,
};
this.syncJobs.unshift(job);
if (this.hasDatabase()) {
const dramaMetricRepository = this.dataSource.getRepository(DramaMetric);
const episodeMetricRepository = this.dataSource.getRepository(EpisodeMetric);
const syncJobRepository = this.dataSource.getRepository(DataSyncJob);
for (const record of dramaRecords) {
const existing = await dramaMetricRepository.findOne({
where: { dramaId: record.dramaId },
});
await dramaMetricRepository.save(
dramaMetricRepository.create({
...existing,
...record,
syncedAt: new Date(record.syncedAt),
}),
);
}
for (const record of episodeRecords) {
const existing = await episodeMetricRepository.findOne({
where: { episodeId: record.episodeId },
});
await episodeMetricRepository.save(
episodeMetricRepository.create({
...existing,
...record,
syncedAt: new Date(record.syncedAt),
}),
);
}
await syncJobRepository.save(
syncJobRepository.create({
...job,
createdAt: new Date(job.createdAt),
}),
);
}
return job;
}
async getOverview() {
const dramaRecords = this.hasDatabase()
? (await this.dataSource.getRepository(DramaMetric).find()).map((record) =>
this.toDramaMetricRecord(record),
)
: Array.from(this.dramaMetrics.values());
const episodeRecords = this.hasDatabase()
? (await this.dataSource.getRepository(EpisodeMetric).find()).map((record) =>
this.toEpisodeMetricRecord(record),
)
: Array.from(this.episodeMetrics.values());
const latestJob = this.hasDatabase()
? (
await this.dataSource.getRepository(DataSyncJob).find({
order: { id: 'DESC' },
take: 1,
})
)[0]
: undefined;
return {
totalDramas: dramaRecords.length,
totalEpisodes: episodeRecords.length,
totalPlays: episodeRecords.reduce((sum, item) => sum + item.plays, 0),
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
latestSyncedAt: latestJob?.createdAt?.toISOString?.() ?? this.syncJobs[0]?.createdAt,
};
}
async listDramaMetrics(input: PaginationInput): Promise<PaginatedResponse<DramaMetricRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(DramaMetric);
const [records, total] = await repository.findAndCount({
order: { dramaId: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toDramaMetricRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate(Array.from(this.dramaMetrics.values()), input);
}
async listEpisodeMetrics(
input: PaginationInput & { dramaId?: number },
): Promise<PaginatedResponse<EpisodeMetricRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(EpisodeMetric);
const [records, total] = await repository.findAndCount({
where: input.dramaId ? { dramaId: input.dramaId } : {},
order: { episodeNo: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toEpisodeMetricRecord(record)),
pagination: { page, pageSize, total },
};
}
const records = Array.from(this.episodeMetrics.values()).filter(
(item) => !input.dramaId || item.dramaId === input.dramaId,
);
return this.paginate(records, input);
}
async listSyncJobs(input: PaginationInput): Promise<PaginatedResponse<SyncJobRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const repository = this.dataSource.getRepository(DataSyncJob);
const [records, total] = await repository.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toSyncJobRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate(this.syncJobs, input);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toDramaMetricRecord(record: DramaMetric): DramaMetricRecord {
return {
dramaId: Number(record.dramaId),
title: record.title,
plays: Number(record.plays),
likes: Number(record.likes),
shares: Number(record.shares),
comments: Number(record.comments),
completionRate: record.completionRate,
averageWatchSeconds: record.averageWatchSeconds,
syncedAt: record.syncedAt.toISOString(),
};
}
private toEpisodeMetricRecord(record: EpisodeMetric): EpisodeMetricRecord {
return {
dramaId: Number(record.dramaId),
episodeId: Number(record.episodeId),
title: record.title,
episodeNo: record.episodeNo,
plays: Number(record.plays),
likes: Number(record.likes),
shares: Number(record.shares),
comments: Number(record.comments),
completionRate: record.completionRate,
averageWatchSeconds: record.averageWatchSeconds,
syncedAt: record.syncedAt.toISOString(),
};
}
private toSyncJobRecord(record: DataSyncJob): SyncJobRecord {
return {
id: Number(record.id),
jobId: record.jobId,
status: record.status as 'completed' | 'failed',
dramaId: record.dramaId ? Number(record.dramaId) : undefined,
dramaCount: record.dramaCount,
episodeCount: record.episodeCount,
createdAt: record.createdAt.toISOString(),
};
}
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,
},
};
}
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('drama_metrics')
export class DramaMetric {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('episode_metrics')
export class EpisodeMetric {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column({ type: 'bigint' })
dramaId: number;
@Index({ unique: true })
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'int' })
episodeNo: number;
@Column({ type: 'bigint', default: 0 })
plays: number;
@Column({ type: 'bigint', default: 0 })
likes: number;
@Column({ type: 'bigint', default: 0 })
shares: number;
@Column({ type: 'bigint', default: 0 })
comments: number;
@Column({ type: 'float', default: 0 })
completionRate: number;
@Column({ type: 'int', default: 0 })
averageWatchSeconds: number;
@Column({ type: 'datetime' })
syncedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}