feat: 添加数据分析模块、媒体上传功能与剧集管理完善
本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
231
test/data-media-flow.e2e-spec.ts
Normal file
231
test/data-media-flow.e2e-spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { bootstrapApp } from '../src/bootstrap';
|
||||
import { loginAdmin } from './test-helpers';
|
||||
|
||||
describe('Data management and media upload flow', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrapApp(app);
|
||||
await app.init();
|
||||
adminToken = await loginAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates a drama, configures episode slots, completes a direct upload, and exposes approved published content to app APIs', async () => {
|
||||
const drama = await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/dramas')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
title: 'Upload Flow Drama',
|
||||
description: 'Drama created by admin before uploading episodes.',
|
||||
coverUrl: 'https://cdn.example.com/upload-flow-cover.jpg',
|
||||
type: 'romance',
|
||||
region: 'TH',
|
||||
language: 'th',
|
||||
status: 'draft',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const dramaId = drama.body.data.id as number;
|
||||
|
||||
const slots = await request(app.getHttpServer())
|
||||
.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ episodeCount: 3 })
|
||||
.expect(201);
|
||||
|
||||
expect(slots.body.data.list).toHaveLength(3);
|
||||
expect(slots.body.data.list[0]).toMatchObject({
|
||||
dramaId,
|
||||
episodeNo: 1,
|
||||
title: '第1集',
|
||||
publishStatus: 'draft',
|
||||
reviewStatus: 'not_submitted',
|
||||
});
|
||||
|
||||
const episodeId = slots.body.data.list[0].id as number;
|
||||
|
||||
const uploadTask = await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/media/upload-tasks')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
episodeId,
|
||||
fileName: 'episode-1.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 1024,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(uploadTask.body.data).toMatchObject({
|
||||
jobId: expect.any(String),
|
||||
uploadUrl: expect.stringContaining('mock-byteplus-upload'),
|
||||
bucket: expect.any(String),
|
||||
objectKey: expect.stringContaining('episode-1.mp4'),
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const completed = await request(app.getHttpServer())
|
||||
.patch(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/complete`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({})
|
||||
.expect(200);
|
||||
|
||||
expect(completed.body.data).toMatchObject({
|
||||
jobId: uploadTask.body.data.jobId,
|
||||
episodeId,
|
||||
status: 'reviewing',
|
||||
byteplusVid: expect.stringMatching(/^mock_vid_/),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationSeconds: 180,
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/admin/v1/media/upload-tasks/${uploadTask.body.data.jobId}/review/retry`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/media/review-status/sync')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/admin/v1/episodes/${episodeId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
title: '正式第1集',
|
||||
coverUrl: 'https://cdn.example.com/episode-1-cover.jpg',
|
||||
freeType: 'free',
|
||||
publishStatus: 'published',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/admin/v1/dramas/${dramaId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ status: 'published' })
|
||||
.expect(200);
|
||||
|
||||
const appDetail = await request(app.getHttpServer())
|
||||
.get(`/api/app/v1/dramas/${dramaId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(appDetail.body.data).toMatchObject({
|
||||
id: dramaId,
|
||||
title: 'Upload Flow Drama',
|
||||
totalEpisodes: 3,
|
||||
publishedEpisodes: 1,
|
||||
});
|
||||
|
||||
const appEpisodes = await request(app.getHttpServer())
|
||||
.get(`/api/app/v1/dramas/${dramaId}/episodes`)
|
||||
.expect(200);
|
||||
|
||||
expect(appEpisodes.body.data.list).toEqual([
|
||||
expect.objectContaining({
|
||||
id: episodeId,
|
||||
title: '正式第1集',
|
||||
reviewStatus: 'approved',
|
||||
publishStatus: 'published',
|
||||
byteplusVid: completed.body.data.byteplusVid,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs TikTok mock analytics and returns overview plus drama and episode metrics', async () => {
|
||||
const drama = await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/dramas')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
title: 'Analytics Drama',
|
||||
coverUrl: 'https://cdn.example.com/analytics-cover.jpg',
|
||||
type: 'action',
|
||||
status: 'published',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const episode = await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/episodes')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
dramaId: drama.body.data.id,
|
||||
episodeNo: 1,
|
||||
title: 'Analytics Episode',
|
||||
coverUrl: 'https://cdn.example.com/analytics-episode.jpg',
|
||||
videoUrl: 'https://cdn.example.com/analytics-episode.mp4',
|
||||
byteplusVid: 'mock_vid_analytics',
|
||||
durationSeconds: 180,
|
||||
reviewStatus: 'approved',
|
||||
publishStatus: 'published',
|
||||
status: 'published',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const sync = await request(app.getHttpServer())
|
||||
.post('/api/admin/v1/data/sync')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ dramaId: drama.body.data.id })
|
||||
.expect(201);
|
||||
|
||||
expect(sync.body.data).toMatchObject({
|
||||
status: 'completed',
|
||||
dramaCount: expect.any(Number),
|
||||
episodeCount: expect.any(Number),
|
||||
});
|
||||
|
||||
const overview = await request(app.getHttpServer())
|
||||
.get('/api/admin/v1/data/overview')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(overview.body.data).toMatchObject({
|
||||
totalPlays: expect.any(Number),
|
||||
totalLikes: expect.any(Number),
|
||||
totalShares: expect.any(Number),
|
||||
totalComments: expect.any(Number),
|
||||
});
|
||||
|
||||
const dramaMetrics = await request(app.getHttpServer())
|
||||
.get('/api/admin/v1/data/dramas')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(dramaMetrics.body.data.list).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dramaId: drama.body.data.id,
|
||||
title: 'Analytics Drama',
|
||||
plays: expect.any(Number),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const episodeMetrics = await request(app.getHttpServer())
|
||||
.get(`/api/admin/v1/data/episodes?dramaId=${drama.body.data.id}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(episodeMetrics.body.data.list).toEqual([
|
||||
expect.objectContaining({
|
||||
dramaId: drama.body.data.id,
|
||||
episodeId: episode.body.data.id,
|
||||
title: 'Analytics Episode',
|
||||
plays: expect.any(Number),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
142
test/database-persistence.e2e-spec.ts
Normal file
142
test/database-persistence.e2e-spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { DataService } from '../src/modules/data/data.service';
|
||||
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider';
|
||||
import { PublishStatus } from '../src/modules/dramas/drama-status.enum';
|
||||
import { DramasService } from '../src/modules/dramas/dramas.service';
|
||||
import { LogsService } from '../src/modules/logs/logs.service';
|
||||
import { UsersService } from '../src/modules/users/users.service';
|
||||
|
||||
function createRepository<T extends { id?: number }>() {
|
||||
const records: T[] = [];
|
||||
return {
|
||||
records,
|
||||
create: jest.fn((input: Partial<T>) => input as T),
|
||||
save: jest.fn(async (input: T) => {
|
||||
if (!input.id) {
|
||||
input.id = records.length + 1;
|
||||
}
|
||||
const index = records.findIndex((item) => item.id === input.id);
|
||||
if (index >= 0) {
|
||||
records[index] = input;
|
||||
} else {
|
||||
records.push(input);
|
||||
}
|
||||
return input;
|
||||
}),
|
||||
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
|
||||
records.find((item) =>
|
||||
Object.entries(where).every(
|
||||
([key, value]) => item[key as keyof T] === value,
|
||||
),
|
||||
) ?? null,
|
||||
),
|
||||
find: jest.fn(async () => [...records]),
|
||||
count: jest.fn(async () => records.length),
|
||||
};
|
||||
}
|
||||
|
||||
function createDataSource(repositories: Record<string, unknown>) {
|
||||
return {
|
||||
isInitialized: true,
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
const repository = repositories[entity.name];
|
||||
if (!repository) {
|
||||
throw new Error(`Missing repository for ${entity.name}`);
|
||||
}
|
||||
return repository;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('database-backed persistence', () => {
|
||||
it('persists app users through TypeORM repositories when a DataSource is configured', async () => {
|
||||
const userRepository = createRepository();
|
||||
const dataSource = createDataSource({ User: userRepository });
|
||||
const service = new UsersService(dataSource as never);
|
||||
|
||||
const result = await service.upsertTikTokUser({
|
||||
tiktokOpenId: 'open-db-1',
|
||||
nickname: 'DB User',
|
||||
});
|
||||
|
||||
expect(result.isNewUser).toBe(true);
|
||||
expect(userRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tiktokOpenId: 'open-db-1',
|
||||
nickname: 'DB User',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists request logs and playback error logs through TypeORM repositories', async () => {
|
||||
const requestLogRepository = createRepository();
|
||||
const playbackErrorLogRepository = createRepository();
|
||||
const dataSource = createDataSource({
|
||||
RequestLog: requestLogRepository,
|
||||
PlaybackErrorLog: playbackErrorLogRepository,
|
||||
});
|
||||
const service = new LogsService(dataSource as never);
|
||||
|
||||
await service.recordRequest({
|
||||
requestId: '00000000-0000-4000-8000-000000000001',
|
||||
method: 'GET',
|
||||
path: '/api/admin/v1/logs',
|
||||
statusCode: 200,
|
||||
responseCode: 0,
|
||||
message: 'success',
|
||||
costMs: 3,
|
||||
});
|
||||
await service.createPlaybackError({
|
||||
requestId: '00000000-0000-4000-8000-000000000002',
|
||||
dramaId: 1,
|
||||
episodeId: 2,
|
||||
playUrl: 'https://cdn.example.com/video.mp4',
|
||||
playerErrorCode: 'NETWORK',
|
||||
message: 'network error',
|
||||
occurredAt: '2026-07-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(requestLogRepository.save).toHaveBeenCalled();
|
||||
expect(playbackErrorLogRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists synced drama and episode metrics through TypeORM repositories', async () => {
|
||||
const dramaMetricRepository = createRepository();
|
||||
const episodeMetricRepository = createRepository();
|
||||
const syncJobRepository = createRepository();
|
||||
const dataSource = createDataSource({
|
||||
DramaMetric: dramaMetricRepository,
|
||||
EpisodeMetric: episodeMetricRepository,
|
||||
DataSyncJob: syncJobRepository,
|
||||
});
|
||||
const dramasService = new DramasService();
|
||||
const tiktokDataProvider = new TikTokDataProvider();
|
||||
const service = new DataService(
|
||||
dramasService,
|
||||
tiktokDataProvider,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
const drama = dramasService.createDrama({
|
||||
title: 'DB Metrics',
|
||||
coverUrl: 'https://cdn.example.com/cover.jpg',
|
||||
type: 'action',
|
||||
status: PublishStatus.Published,
|
||||
});
|
||||
dramasService.createEpisode({
|
||||
dramaId: drama.id,
|
||||
episodeNo: 1,
|
||||
title: 'DB Metrics Episode',
|
||||
coverUrl: drama.coverUrl,
|
||||
videoUrl: 'https://cdn.example.com/video.mp4',
|
||||
durationSeconds: 180,
|
||||
status: PublishStatus.Published,
|
||||
});
|
||||
|
||||
await service.sync({ dramaId: drama.id });
|
||||
|
||||
expect(dramaMetricRepository.save).toHaveBeenCalled();
|
||||
expect(episodeMetricRepository.save).toHaveBeenCalled();
|
||||
expect(syncJobRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user