refactor: 重构项目目录与代码结构,清理冗余文件
本次提交进行了大规模的项目结构调整: 1. 调整文件目录归属,将模块代码按功能域重新组织 2. 删除多处冗余的DTO、实体类和模块文件 3. 统一了常量、装饰器、工具类的存放位置 4. 修复了`.gitignore`的日志目录匹配规则 5. 新增了健康检查、认证、用户、剧集、订单等核心业务模块
This commit is contained in:
26
src/data/data-sync-job.entity.ts
Normal file
26
src/data/data-sync-job.entity.ts
Normal 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;
|
||||
}
|
||||
64
src/data/data.controller.ts
Normal file
64
src/data/data.controller.ts
Normal 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
13
src/data/data.module.ts
Normal 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
29
src/data/data.provider.ts
Normal 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
312
src/data/data.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
48
src/data/drama-metric.entity.ts
Normal file
48
src/data/drama-metric.entity.ts
Normal 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;
|
||||
}
|
||||
55
src/data/episode-metric.entity.ts
Normal file
55
src/data/episode-metric.entity.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user