feat(dramas): add play count field for drama records

1. 新增plays字段到DramaRecord接口和swagger/openapi文档
2. 在剧集创建时默认设置plays为0
3. 新增getPlayCountsByDramaIds方法聚合剧集播放量
4. 在后台和前台剧集列表中添加播放量数据
5. 更新测试用例适配新增的plays字段
This commit is contained in:
2026-07-02 18:25:58 +08:00
parent 03d3c2f9cc
commit 55e20f9f16
6 changed files with 142 additions and 44 deletions

View File

@@ -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"],

View File

@@ -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" }
}

View File

@@ -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<Map<number, number>> {
const playCounts = new Map<number, number>();
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<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);

View File

@@ -20,6 +20,7 @@ export interface DramaRecord {
onlineVersion: number;
totalEpisodes: number;
publishedEpisodes: number;
plays: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -8,6 +8,15 @@ import { UsersService } from '../src/modules/users/users.service';
function createRepository<T extends { id?: number }>() {
const records: T[] = [];
const matchesWhere = (item: T, where?: Partial<T>) => {
if (!where) {
return true;
}
return Object.entries(where).every(
([key, value]) => item[key as keyof T] === value,
);
};
return {
records,
create: jest.fn((input: Partial<T>) => input as T),
@@ -24,14 +33,18 @@ function createRepository<T extends { id?: number }>() {
return input;
}),
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
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<T>; 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<T> } = {}) =>
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<any>();
const episodeRepository = createRepository<any>();
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] },
);
});
});

View File

@@ -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,
}),
]),
);