feat(playback-history): 添加播放历史模块功能
实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息
This commit is contained in:
37
src/modules/playback-history/playback-history.controller.ts
Normal file
37
src/modules/playback-history/playback-history.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UserRecord } from '../users/interfaces/user-record.interface';
|
||||
import { UpsertPlaybackHistoryDto } from './dto/upsert-playback-history.dto';
|
||||
import { PlaybackHistoryService } from './playback-history.service';
|
||||
|
||||
@ApiTags('PlaybackHistory')
|
||||
@Controller('/api/app/v1/playback-history')
|
||||
export class PlaybackHistoryController {
|
||||
constructor(private readonly playbackHistoryService: PlaybackHistoryService) {}
|
||||
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
upsertHistory(
|
||||
@CurrentUser() user: UserRecord,
|
||||
@Body() dto: UpsertPlaybackHistoryDto,
|
||||
) {
|
||||
return this.playbackHistoryService.upsertHistory(user, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listHistory(
|
||||
@CurrentUser() user: UserRecord,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.playbackHistoryService.listHistory(user, {
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
src/modules/playback-history/playback-history.module.ts
Normal file
12
src/modules/playback-history/playback-history.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DramasModule } from '../dramas/dramas.module';
|
||||
import { PlaybackHistoryController } from './playback-history.controller';
|
||||
import { PlaybackHistoryService } from './playback-history.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DramasModule],
|
||||
controllers: [PlaybackHistoryController],
|
||||
providers: [PlaybackHistoryService],
|
||||
})
|
||||
export class PlaybackHistoryModule {}
|
||||
223
src/modules/playback-history/playback-history.service.ts
Normal file
223
src/modules/playback-history/playback-history.service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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 {
|
||||
DramaRecord,
|
||||
EpisodeRecord,
|
||||
} from '../dramas/interfaces/drama-records.interface';
|
||||
import { UserRecord } from '../users/interfaces/user-record.interface';
|
||||
import { UpsertPlaybackHistoryDto } from './dto/upsert-playback-history.dto';
|
||||
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
|
||||
import {
|
||||
PlaybackHistoryListItem,
|
||||
PlaybackHistoryRecord,
|
||||
} from './interfaces/playback-history-record.interface';
|
||||
|
||||
interface PaginationInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlaybackHistoryService {
|
||||
private historySequence = 1;
|
||||
private readonly histories: PlaybackHistoryRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async upsertHistory(
|
||||
user: UserRecord,
|
||||
dto: UpsertPlaybackHistoryDto,
|
||||
): Promise<PlaybackHistoryRecord> {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||
dto.episodeId,
|
||||
);
|
||||
const progressSeconds = this.resolveProgress(
|
||||
dto.progressSeconds,
|
||||
episode.durationSeconds,
|
||||
);
|
||||
const completed =
|
||||
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
|
||||
const lastPlayedAt = dto.occurredAt
|
||||
? new Date(dto.occurredAt)
|
||||
: new Date();
|
||||
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||
const existing = await repository.findOne({
|
||||
where: { userId: user.id, episodeId: episode.id },
|
||||
});
|
||||
|
||||
const entity =
|
||||
existing ??
|
||||
repository.create({
|
||||
userId: user.id,
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
});
|
||||
|
||||
entity.dramaId = episode.dramaId;
|
||||
entity.progressSeconds = progressSeconds;
|
||||
entity.durationSeconds = episode.durationSeconds;
|
||||
entity.completed = completed;
|
||||
entity.lastPlayedAt = lastPlayedAt;
|
||||
|
||||
return this.toRecord(await repository.save(entity));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = this.histories.find(
|
||||
(item) => item.userId === user.id && item.episodeId === episode.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.dramaId = episode.dramaId;
|
||||
existing.progressSeconds = progressSeconds;
|
||||
existing.durationSeconds = episode.durationSeconds;
|
||||
existing.completed = completed;
|
||||
existing.lastPlayedAt = lastPlayedAt.toISOString();
|
||||
existing.updatedAt = now;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const record: PlaybackHistoryRecord = {
|
||||
id: this.historySequence++,
|
||||
userId: user.id,
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
progressSeconds,
|
||||
durationSeconds: episode.durationSeconds,
|
||||
completed,
|
||||
lastPlayedAt: lastPlayedAt.toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.histories.push(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async listHistory(
|
||||
user: UserRecord,
|
||||
pagination: PaginationInput,
|
||||
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
|
||||
if (this.hasDatabase()) {
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||
const [records, total] = await repository.findAndCount({
|
||||
where: { userId: user.id },
|
||||
order: { lastPlayedAt: 'DESC', id: 'DESC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
const list = await Promise.all(
|
||||
records.map((record) => this.toListItem(this.toRecord(record))),
|
||||
);
|
||||
return { list, pagination: { page, pageSize, total } };
|
||||
}
|
||||
|
||||
const page = this.paginate(
|
||||
this.histories
|
||||
.filter((item) => item.userId === user.id)
|
||||
.sort((left, right) => {
|
||||
const timeDiff =
|
||||
new Date(right.lastPlayedAt).getTime() -
|
||||
new Date(left.lastPlayedAt).getTime();
|
||||
return timeDiff || right.id - left.id;
|
||||
}),
|
||||
pagination,
|
||||
);
|
||||
|
||||
return {
|
||||
...page,
|
||||
list: await Promise.all(
|
||||
page.list.map((record) => this.toListItem(record)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async toListItem(
|
||||
record: PlaybackHistoryRecord,
|
||||
): Promise<PlaybackHistoryListItem> {
|
||||
const [drama, episode] = await Promise.all([
|
||||
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
|
||||
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
|
||||
]);
|
||||
return this.mergeListItem(record, drama, episode);
|
||||
}
|
||||
|
||||
private mergeListItem(
|
||||
record: PlaybackHistoryRecord,
|
||||
drama: DramaRecord,
|
||||
episode: EpisodeRecord,
|
||||
): PlaybackHistoryListItem {
|
||||
return {
|
||||
id: record.id,
|
||||
dramaId: record.dramaId,
|
||||
episodeId: record.episodeId,
|
||||
episodeNo: episode.episodeNo,
|
||||
title: episode.title,
|
||||
coverUrl: episode.coverUrl,
|
||||
progressSeconds: record.progressSeconds,
|
||||
durationSeconds: record.durationSeconds,
|
||||
completed: record.completed,
|
||||
lastPlayedAt: record.lastPlayedAt,
|
||||
dramaTitle: drama.title,
|
||||
dramaCoverUrl: drama.coverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveProgress(progressSeconds: number, durationSeconds: number) {
|
||||
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
|
||||
return {
|
||||
id: Number(entity.id),
|
||||
userId: Number(entity.userId),
|
||||
dramaId: Number(entity.dramaId),
|
||||
episodeId: Number(entity.episodeId),
|
||||
progressSeconds: entity.progressSeconds,
|
||||
durationSeconds: entity.durationSeconds,
|
||||
completed: entity.completed,
|
||||
lastPlayedAt: this.toIso(entity.lastPlayedAt),
|
||||
createdAt: this.toIso(entity.createdAt),
|
||||
updatedAt: this.toIso(entity.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toIso(value?: Date | string): string {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
private resolvePagination(input: PaginationInput) {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
||||
}
|
||||
|
||||
private paginate<T>(
|
||||
records: T[],
|
||||
input: PaginationInput,
|
||||
): PaginatedResponse<T> {
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||
return {
|
||||
list: records.slice(skip, skip + take),
|
||||
pagination: { page, pageSize, total: records.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user