feat: 添加数据分析模块、媒体上传功能与剧集管理完善
本次提交新增了完整的数据分析模块,包括数据同步、指标统计接口;实现了基于BytePlus的直传媒体上传流程,包含上传任务创建、完成、审核同步等功能;同时完善了剧集管理功能,新增了剧集配置、更新接口,扩展了审核状态枚举,补充了剧集与剧集指标的关联逻辑,还添加了播放器对外接口与相关测试用例。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user