From 79aeac7f372dd5245560ac17a5d57fda7ad9ce79 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Thu, 2 Jul 2026 17:28:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(playback-history):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=8E=86=E5=8F=B2=E6=A8=A1=E5=9D=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息 --- .../playback-history.controller.ts | 37 +++ .../playback-history.module.ts | 12 + .../playback-history.service.ts | 223 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 src/modules/playback-history/playback-history.controller.ts create mode 100644 src/modules/playback-history/playback-history.module.ts create mode 100644 src/modules/playback-history/playback-history.service.ts diff --git a/src/modules/playback-history/playback-history.controller.ts b/src/modules/playback-history/playback-history.controller.ts new file mode 100644 index 0000000..73789bf --- /dev/null +++ b/src/modules/playback-history/playback-history.controller.ts @@ -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, + }); + } +} diff --git a/src/modules/playback-history/playback-history.module.ts b/src/modules/playback-history/playback-history.module.ts new file mode 100644 index 0000000..766d817 --- /dev/null +++ b/src/modules/playback-history/playback-history.module.ts @@ -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 {} diff --git a/src/modules/playback-history/playback-history.service.ts b/src/modules/playback-history/playback-history.service.ts new file mode 100644 index 0000000..50a543f --- /dev/null +++ b/src/modules/playback-history/playback-history.service.ts @@ -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 { + 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> { + 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 { + 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( + records: T[], + input: PaginationInput, + ): PaginatedResponse { + const { page, pageSize, skip, take } = this.resolvePagination(input); + return { + list: records.slice(skip, skip + take), + pagination: { page, pageSize, total: records.length }, + }; + } +}