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:
@@ -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"],
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface DramaRecord {
|
||||
onlineVersion: number;
|
||||
totalEpisodes: number;
|
||||
publishedEpisodes: number;
|
||||
plays: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user