diff --git a/docs/apifox-app-apis.openapi.json b/docs/apifox-app-apis.openapi.json index db13078..8f16a4e 100644 --- a/docs/apifox-app-apis.openapi.json +++ b/docs/apifox-app-apis.openapi.json @@ -91,13 +91,13 @@ "/api/app/v1/dramas/presearch": { "get": { "tags": ["App Dramas"], - "summary": "关键字预查询视频列表", - "operationId": "presearchAppDramaVideos", + "summary": "短剧名称预查询", + "operationId": "presearchAppDramasByTitle", "parameters": [ { "name": "keyword", "in": "query", - "description": "用户输入关键字;空值返回空列表", + "description": "短剧名称关键字;空值返回空列表", "required": false, "schema": { "type": "string" @@ -119,7 +119,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/VideoPresearchPage" + "$ref": "#/components/schemas/AppDramaPage" } } } @@ -407,7 +407,7 @@ }, "AppDrama": { "type": "object", - "required": ["id", "title", "coverUrl", "type", "tags", "status", "sortOrder", "isRecommended", "onlineVersion", "totalEpisodes", "publishedEpisodes", "createdAt", "updatedAt"], + "required": ["id", "title", "coverUrl", "type", "tags", "status", "sortOrder", "isRecommended", "onlineVersion", "totalEpisodes", "publishedEpisodes", "plays", "createdAt", "updatedAt"], "properties": { "id": { "type": "integer", "example": 1 }, "tiktokAlbumId": { "type": "string", "nullable": true }, @@ -426,6 +426,7 @@ "onlineVersion": { "type": "integer", "example": 1 }, "totalEpisodes": { "type": "integer", "example": 20 }, "publishedEpisodes": { "type": "integer", "example": 10 }, + "plays": { "type": "integer", "example": 12345 }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } } @@ -500,36 +501,6 @@ "pagination": { "$ref": "#/components/schemas/Pagination" } } }, - "VideoPresearchItem": { - "type": "object", - "required": ["id", "dramaId", "episodeNo", "title", "coverUrl", "durationSeconds", "freeType", "price", "dramaTitle", "dramaCoverUrl"], - "properties": { - "id": { "type": "integer", "example": 1 }, - "dramaId": { "type": "integer", "example": 1 }, - "episodeNo": { "type": "integer", "example": 1 }, - "title": { "type": "string", "example": "Pilot Video Match" }, - "description": { "type": "string", "nullable": true }, - "coverUrl": { "type": "string", "example": "https://cdn.example.com/episode.jpg" }, - "durationSeconds": { "type": "integer", "example": 180 }, - "byteplusVid": { "type": "string", "nullable": true }, - "videoUrl": { "type": "string", "nullable": true }, - "freeType": { "type": "string", "enum": ["free", "paid"], "example": "free" }, - "price": { "type": "integer", "example": 0 }, - "dramaTitle": { "type": "string", "example": "Skyline Keyword Saga" }, - "dramaCoverUrl": { "type": "string", "example": "https://cdn.example.com/drama.jpg" } - } - }, - "VideoPresearchPage": { - "type": "object", - "required": ["list", "pagination"], - "properties": { - "list": { - "type": "array", - "items": { "$ref": "#/components/schemas/VideoPresearchItem" } - }, - "pagination": { "$ref": "#/components/schemas/Pagination" } - } - }, "UpsertPlaybackHistoryRequest": { "type": "object", "required": ["episodeId", "progressSeconds"], diff --git a/docs/apifox-app-apis.swagger.json b/docs/apifox-app-apis.swagger.json index ae7b325..e4e0b20 100644 --- a/docs/apifox-app-apis.swagger.json +++ b/docs/apifox-app-apis.swagger.json @@ -169,6 +169,7 @@ "onlineVersion": { "type": "integer", "example": 1 }, "totalEpisodes": { "type": "integer", "example": 20 }, "publishedEpisodes": { "type": "integer", "example": 10 }, + "plays": { "type": "integer", "example": 12345 }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } } diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index 7fe77f2..0b58582 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException, Optional, Unprocessab import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, Like } from "typeorm"; import { PaginatedResponse } from "../../common/dto/paginated-response.dto"; +import { EpisodeMetric } from "../data/entities/episode-metric.entity"; import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto"; import { CreateDramaDto } from "./dto/create-drama.dto"; import { CreateEpisodeDto } from "./dto/create-episode.dto"; @@ -96,6 +97,7 @@ export class DramasService { onlineVersion: dto.onlineVersion ?? 1, totalEpisodes: 0, publishedEpisodes: 0, + plays: 0, createdAt: now, updatedAt: now, }; @@ -121,7 +123,13 @@ export class DramasService { skip, take, }); - const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama))); + const playCounts = await this.getPlayCountsByDramaIds(records.map((drama) => Number(drama.id))); + const list = await Promise.all( + records.map(async (drama) => ({ + ...(await this.toDramaRecordWithCounts(drama)), + plays: playCounts.get(Number(drama.id)) ?? 0, + })), + ); return { list, pagination: { page, pageSize, total } }; } @@ -535,7 +543,15 @@ export class DramasService { skip, take, }); - const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama)))); + const playCounts = await this.getPlayCountsByDramaIds(records.map((drama) => Number(drama.id))); + const list = await Promise.all( + records.map(async (drama) => + this.toPublicDramaRecord({ + ...(await this.toDramaRecordWithCounts(drama)), + plays: playCounts.get(Number(drama.id)) ?? 0, + }), + ), + ); return { list, pagination: { page, pageSize, total } }; } @@ -821,6 +837,7 @@ export class DramasService { onlineVersion: entity.onlineVersion, totalEpisodes, publishedEpisodes, + plays: 0, createdAt: this.toIso(entity.createdAt), updatedAt: this.toIso(entity.updatedAt), }; @@ -905,9 +922,32 @@ export class DramasService { ...drama, totalEpisodes: episodes.length, publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length, + plays: drama.plays ?? 0, }; } + private async getPlayCountsByDramaIds(dramaIds: number[]): Promise> { + const playCounts = new Map(); + if (!this.hasDatabase() || dramaIds.length === 0) { + return playCounts; + } + + const rows = await this.dataSource + .getRepository(EpisodeMetric) + .createQueryBuilder("metric") + .select("metric.dramaId", "dramaId") + .addSelect("COALESCE(SUM(metric.plays), 0)", "plays") + .where("metric.dramaId IN (:...dramaIds)", { dramaIds }) + .groupBy("metric.dramaId") + .getRawMany<{ dramaId: string | number; plays: string | number }>(); + + rows.forEach((row) => { + playCounts.set(Number(row.dramaId), Number(row.plays)); + }); + + return playCounts; + } + private paginate(records: T[], input: PaginationInput): PaginatedResponse { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); diff --git a/src/modules/dramas/interfaces/drama-records.interface.ts b/src/modules/dramas/interfaces/drama-records.interface.ts index 9e9b643..f7817ea 100644 --- a/src/modules/dramas/interfaces/drama-records.interface.ts +++ b/src/modules/dramas/interfaces/drama-records.interface.ts @@ -20,6 +20,7 @@ export interface DramaRecord { onlineVersion: number; totalEpisodes: number; publishedEpisodes: number; + plays: number; createdAt: string; updatedAt: string; } diff --git a/test/database-persistence.e2e-spec.ts b/test/database-persistence.e2e-spec.ts index 9708943..2f1bd62 100644 --- a/test/database-persistence.e2e-spec.ts +++ b/test/database-persistence.e2e-spec.ts @@ -8,6 +8,15 @@ import { UsersService } from '../src/modules/users/users.service'; function createRepository() { const records: T[] = []; + const matchesWhere = (item: T, where?: Partial) => { + if (!where) { + return true; + } + return Object.entries(where).every( + ([key, value]) => item[key as keyof T] === value, + ); + }; + return { records, create: jest.fn((input: Partial) => input as T), @@ -24,14 +33,18 @@ function createRepository() { return input; }), findOne: jest.fn(async ({ where }: { where: Partial }) => - records.find((item) => - Object.entries(where).every( - ([key, value]) => item[key as keyof T] === value, - ), - ) ?? null, + records.find((item) => matchesWhere(item, where)) ?? null, ), find: jest.fn(async () => [...records]), - count: jest.fn(async () => records.length), + findAndCount: jest.fn( + async ({ where, skip = 0, take = records.length }: { where?: Partial; skip?: number; take?: number }) => { + const filtered = records.filter((item) => matchesWhere(item, where)); + return [filtered.slice(skip, skip + take), filtered.length]; + }, + ), + count: jest.fn(async ({ where }: { where?: Partial } = {}) => + records.filter((item) => matchesWhere(item, where)).length, + ), }; } @@ -182,4 +195,72 @@ describe('database-backed persistence', () => { expect(episodeMetricRepository.save).toHaveBeenCalled(); expect(syncJobRepository.save).toHaveBeenCalled(); }); + + it('aggregates episode metric plays for database-backed drama lists', async () => { + const now = new Date('2026-07-02T00:00:00.000Z'); + const dramaRepository = createRepository(); + const episodeRepository = createRepository(); + const queryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(async () => [{ dramaId: '1', plays: '321' }]), + }; + const episodeMetricRepository = { + createQueryBuilder: jest.fn(() => queryBuilder), + }; + const dataSource = createDataSource({ + Drama: dramaRepository, + Episode: episodeRepository, + EpisodeMetric: episodeMetricRepository, + }); + const service = new DramasService(dataSource as never); + + await dramaRepository.save({ + id: 1, + title: 'DB Plays Drama', + coverUrl: 'https://cdn.example.com/db-plays.jpg', + type: 'action', + tags: [], + status: PublishStatus.Published, + sortOrder: 0, + isRecommended: false, + onlineVersion: 1, + createdAt: now, + updatedAt: now, + }); + await episodeRepository.save({ + id: 1, + dramaId: 1, + episodeNo: 1, + title: 'Episode 1', + coverUrl: 'https://cdn.example.com/ep1.jpg', + durationSeconds: 180, + isPaid: false, + freeType: 'free', + price: 0, + status: PublishStatus.Published, + publishStatus: PublishStatus.Published, + reviewStatus: 'approved', + createdAt: now, + updatedAt: now, + }); + + const adminList = await service.listAdminDramas({ page: 1, pageSize: 10 }); + const appList = await service.listPublishedDramas({ page: 1, pageSize: 10 }); + + expect(adminList.list[0]).toMatchObject({ + title: 'DB Plays Drama', + plays: 321, + }); + expect(appList.list[0]).toMatchObject({ + title: 'DB Plays Drama', + plays: 321, + }); + expect(queryBuilder.where).toHaveBeenCalledWith( + 'metric.dramaId IN (:...dramaIds)', + { dramaIds: [1] }, + ); + }); }); diff --git a/test/dramas.e2e-spec.ts b/test/dramas.e2e-spec.ts index fcdf88b..496613b 100644 --- a/test/dramas.e2e-spec.ts +++ b/test/dramas.e2e-spec.ts @@ -264,7 +264,10 @@ describe('Dramas and episodes', () => { expect(dramas.body.data.list).toEqual( expect.arrayContaining([ - expect.objectContaining({ title: 'Published Drama' }), + expect.objectContaining({ + title: 'Published Drama', + plays: 0, + }), ]), ); const publishedDrama = dramas.body.data.list.find( @@ -365,6 +368,7 @@ describe('Dramas and episodes', () => { expect.objectContaining({ title: 'Recommended Search Drama', isRecommended: true, + plays: 0, }), ]), );