feat: 添加数据分析模块、媒体上传功能与剧集管理完善

本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
2026-07-01 16:36:06 +08:00
parent 5fa8c5e0a9
commit 026665b2cc
28 changed files with 1437 additions and 20 deletions

View File

@@ -2,10 +2,14 @@ import {
ConflictException,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { ConfigureEpisodesDto } from './dto/configure-episodes.dto';
import { CreateDramaDto } from './dto/create-drama.dto';
import { CreateEpisodeDto } from './dto/create-episode.dto';
import { UpdateDramaDto } from './dto/update-drama.dto';
import { UpdateEpisodeDto } from './dto/update-episode.dto';
import { PublishStatus } from './drama-status.enum';
import {
DramaRecord,
@@ -44,6 +48,8 @@ export class DramasService {
sortOrder: dto.sortOrder ?? 0,
isRecommended: dto.isRecommended ?? false,
onlineVersion: dto.onlineVersion ?? 1,
totalEpisodes: 0,
publishedEpisodes: 0,
createdAt: now,
updatedAt: now,
};
@@ -53,7 +59,30 @@ export class DramasService {
}
listAdminDramas(pagination: PaginationInput): PaginatedResponse<DramaRecord> {
return this.paginate(this.sortDramas(this.dramas), pagination);
return this.paginate(this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination);
}
getDramaOrThrow(id: number): DramaRecord {
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
}
return this.withEpisodeCounts(drama);
}
updateDrama(id: number, dto: UpdateDramaDto): DramaRecord {
const drama = this.dramas.find((item) => item.id === id);
if (!drama) {
throw new NotFoundException('Drama not found');
}
Object.assign(drama, {
...dto,
tags: dto.tags ?? drama.tags,
updatedAt: new Date().toISOString(),
});
return this.withEpisodeCounts(drama);
}
createEpisode(dto: CreateEpisodeDto): EpisodeRecord {
@@ -79,6 +108,7 @@ export class DramasService {
const now = new Date().toISOString();
const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free');
const price = dto.price ?? dto.unlockPrice ?? 0;
const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft;
const episode: EpisodeRecord = {
id: this.episodeSequence++,
dramaId,
@@ -90,10 +120,10 @@ export class DramasService {
description: dto.description,
byteplusVid: dto.byteplusVid,
coverId: dto.coverId,
coverUrl: dto.coverUrl,
coverUrl: dto.coverUrl ?? '',
videoUrl: dto.videoUrl,
trialDurationSeconds: dto.trialDurationSeconds,
durationSeconds: dto.durationSeconds,
durationSeconds: dto.durationSeconds ?? 0,
width: dto.width,
height: dto.height,
isPaid: freeType === 'paid',
@@ -102,9 +132,11 @@ export class DramasService {
price,
publishAt: dto.publishAt,
nextReleaseAt: dto.nextReleaseAt,
status: dto.status ?? dto.publishStatus ?? PublishStatus.Draft,
reviewStatus: dto.reviewStatus ?? 'pending',
publishStatus: dto.publishStatus ?? dto.status ?? PublishStatus.Draft,
status: publishStatus,
reviewStatus:
dto.reviewStatus ??
(publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'),
publishStatus,
createdAt: now,
updatedAt: now,
};
@@ -113,17 +145,161 @@ export class DramasService {
return episode;
}
configureEpisodes(
dramaId: number,
dto: ConfigureEpisodesDto,
): PaginatedResponse<EpisodeRecord> {
const drama = this.dramas.find((item) => item.id === dramaId);
if (!drama) {
throw new NotFoundException('Drama not found');
}
const existing = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
if (dto.episodeCount < existing.length) {
const removable = existing.slice(dto.episodeCount);
const hasLockedEpisode = removable.some(
(episode) =>
episode.byteplusVid ||
episode.publishStatus !== PublishStatus.Draft ||
episode.reviewStatus !== 'not_submitted',
);
if (hasLockedEpisode) {
throw new UnprocessableEntityException(
'Only empty draft tail episodes can be removed',
);
}
for (const episode of removable) {
const index = this.episodes.findIndex((item) => item.id === episode.id);
if (index >= 0) {
this.episodes.splice(index, 1);
}
}
}
for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) {
this.createEpisode({
dramaId,
episodeNo,
title: `${episodeNo}`,
coverUrl: drama.coverUrl,
durationSeconds: 0,
freeType: 'free',
price: 0,
reviewStatus: 'not_submitted',
publishStatus: PublishStatus.Draft,
status: PublishStatus.Draft,
});
}
const list = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return {
list,
pagination: {
page: 1,
pageSize: list.length,
total: list.length,
},
};
}
listAdminEpisodes(
dramaId: number,
pagination: PaginationInput,
): PaginatedResponse<EpisodeRecord> {
this.getDramaOrThrow(dramaId);
const records = this.episodes
.filter((episode) => episode.dramaId === dramaId)
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(records, pagination);
}
updateEpisode(id: number, dto: UpdateEpisodeDto): EpisodeRecord {
const episode = this.getEpisodeOrThrow(id);
const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free');
Object.assign(episode, {
...dto,
dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId,
albumId: dto.albumId ?? dto.dramaId ?? episode.albumId,
episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo,
seq: dto.seq ?? dto.episodeNo ?? episode.seq,
freeType,
isPaid: freeType === 'paid',
price: dto.price ?? dto.unlockPrice ?? episode.price,
unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice,
status: dto.status ?? dto.publishStatus ?? episode.status,
publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus,
updatedAt: new Date().toISOString(),
});
return episode;
}
attachEpisodeMedia(input: {
episodeId: number;
byteplusVid: string;
videoUrl: string;
storageBucket: string;
objectKey: string;
durationSeconds: number;
width: number;
height: number;
reviewStatus: EpisodeRecord['reviewStatus'];
reviewMessage?: string;
}): EpisodeRecord {
const episode = this.getEpisodeOrThrow(input.episodeId);
episode.byteplusVid = input.byteplusVid;
episode.videoUrl = input.videoUrl;
episode.storageBucket = input.storageBucket;
episode.objectKey = input.objectKey;
episode.durationSeconds = input.durationSeconds;
episode.width = input.width;
episode.height = input.height;
episode.reviewStatus = input.reviewStatus;
episode.reviewMessage = input.reviewMessage;
episode.updatedAt = new Date().toISOString();
return episode;
}
updateEpisodeReviewStatus(
episodeId: number,
reviewStatus: EpisodeRecord['reviewStatus'],
reviewMessage?: string,
): EpisodeRecord {
const episode = this.getEpisodeOrThrow(episodeId);
episode.reviewStatus = reviewStatus;
episode.reviewMessage = reviewMessage;
episode.updatedAt = new Date().toISOString();
return episode;
}
listPublishedDramas(
pagination: PaginationInput,
): PaginatedResponse<DramaRecord> {
return this.paginate(
this.sortDramas(
this.dramas.filter((item) => item.status === PublishStatus.Published),
this.dramas
.filter((item) => item.status === PublishStatus.Published)
.map((drama) => this.withEpisodeCounts(drama)),
),
pagination,
);
}
getPublishedDramaOrThrow(id: number): DramaRecord {
const drama = this.dramas.find(
(item) => item.id === id && item.status === PublishStatus.Published,
);
if (!drama) {
throw new NotFoundException('Drama not found');
}
return this.withEpisodeCounts(drama);
}
listPublishedEpisodes(
dramaId: number,
pagination: PaginationInput,
@@ -138,6 +314,11 @@ export class DramasService {
(item) =>
item.dramaId === dramaId && item.status === PublishStatus.Published,
)
.filter(
(item) =>
item.publishStatus === PublishStatus.Published &&
item.reviewStatus === 'approved',
)
.sort((left, right) => left.episodeNo - right.episodeNo);
return this.paginate(episodes, pagination);
@@ -145,7 +326,12 @@ export class DramasService {
getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord {
const episode = this.episodes.find((item) => item.id === episodeId);
if (!episode || episode.status !== PublishStatus.Published) {
if (
!episode ||
episode.status !== PublishStatus.Published ||
episode.publishStatus !== PublishStatus.Published ||
episode.reviewStatus !== 'approved'
) {
throw new NotFoundException('Episode not found');
}
@@ -175,16 +361,25 @@ export class DramasService {
this.episodes
.filter((episode) => episode.albumId === albumId)
.forEach((episode) => {
episode.reviewStatus = 'pending';
episode.reviewStatus = 'approved';
episode.reviewMessage = 'Mock review approved';
episode.updatedAt = new Date().toISOString();
});
return {
albumId,
reviewStatus: 'pending',
reviewStatus: 'approved',
};
}
listAllDramas() {
return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama)));
}
listAllEpisodes() {
return [...this.episodes].sort((left, right) => left.id - right.id);
}
updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) {
const episode = this.getEpisodeOrThrow(episodeId);
episode.publishStatus = publishStatus;
@@ -203,6 +398,19 @@ export class DramasService {
});
}
private withEpisodeCounts(drama: DramaRecord): DramaRecord {
const episodes = this.episodes.filter((episode) => episode.dramaId === drama.id);
return {
...drama,
totalEpisodes: episodes.length,
publishedEpisodes: episodes.filter(
(episode) =>
episode.publishStatus === PublishStatus.Published &&
episode.reviewStatus === 'approved',
).length,
};
}
private paginate<T>(
records: T[],
input: PaginationInput,