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": {
|
"/api/app/v1/dramas/presearch": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["App Dramas"],
|
"tags": ["App Dramas"],
|
||||||
"summary": "关键字预查询视频列表",
|
"summary": "短剧名称预查询",
|
||||||
"operationId": "presearchAppDramaVideos",
|
"operationId": "presearchAppDramasByTitle",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "keyword",
|
"name": "keyword",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "用户输入关键字;空值返回空列表",
|
"description": "短剧名称关键字;空值返回空列表",
|
||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
"$ref": "#/components/schemas/VideoPresearchPage"
|
"$ref": "#/components/schemas/AppDramaPage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
},
|
},
|
||||||
"AppDrama": {
|
"AppDrama": {
|
||||||
"type": "object",
|
"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": {
|
"properties": {
|
||||||
"id": { "type": "integer", "example": 1 },
|
"id": { "type": "integer", "example": 1 },
|
||||||
"tiktokAlbumId": { "type": "string", "nullable": true },
|
"tiktokAlbumId": { "type": "string", "nullable": true },
|
||||||
@@ -426,6 +426,7 @@
|
|||||||
"onlineVersion": { "type": "integer", "example": 1 },
|
"onlineVersion": { "type": "integer", "example": 1 },
|
||||||
"totalEpisodes": { "type": "integer", "example": 20 },
|
"totalEpisodes": { "type": "integer", "example": 20 },
|
||||||
"publishedEpisodes": { "type": "integer", "example": 10 },
|
"publishedEpisodes": { "type": "integer", "example": 10 },
|
||||||
|
"plays": { "type": "integer", "example": 12345 },
|
||||||
"createdAt": { "type": "string", "format": "date-time" },
|
"createdAt": { "type": "string", "format": "date-time" },
|
||||||
"updatedAt": { "type": "string", "format": "date-time" }
|
"updatedAt": { "type": "string", "format": "date-time" }
|
||||||
}
|
}
|
||||||
@@ -500,36 +501,6 @@
|
|||||||
"pagination": { "$ref": "#/components/schemas/Pagination" }
|
"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": {
|
"UpsertPlaybackHistoryRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["episodeId", "progressSeconds"],
|
"required": ["episodeId", "progressSeconds"],
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
"onlineVersion": { "type": "integer", "example": 1 },
|
"onlineVersion": { "type": "integer", "example": 1 },
|
||||||
"totalEpisodes": { "type": "integer", "example": 20 },
|
"totalEpisodes": { "type": "integer", "example": 20 },
|
||||||
"publishedEpisodes": { "type": "integer", "example": 10 },
|
"publishedEpisodes": { "type": "integer", "example": 10 },
|
||||||
|
"plays": { "type": "integer", "example": 12345 },
|
||||||
"createdAt": { "type": "string", "format": "date-time" },
|
"createdAt": { "type": "string", "format": "date-time" },
|
||||||
"updatedAt": { "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 { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource, Like } from "typeorm";
|
import { DataSource, Like } from "typeorm";
|
||||||
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
|
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
|
||||||
|
import { EpisodeMetric } from "../data/entities/episode-metric.entity";
|
||||||
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
||||||
import { CreateDramaDto } from "./dto/create-drama.dto";
|
import { CreateDramaDto } from "./dto/create-drama.dto";
|
||||||
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
||||||
@@ -96,6 +97,7 @@ export class DramasService {
|
|||||||
onlineVersion: dto.onlineVersion ?? 1,
|
onlineVersion: dto.onlineVersion ?? 1,
|
||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
publishedEpisodes: 0,
|
publishedEpisodes: 0,
|
||||||
|
plays: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -121,7 +123,13 @@ export class DramasService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
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 } };
|
return { list, pagination: { page, pageSize, total } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +543,15 @@ export class DramasService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
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 } };
|
return { list, pagination: { page, pageSize, total } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,6 +837,7 @@ export class DramasService {
|
|||||||
onlineVersion: entity.onlineVersion,
|
onlineVersion: entity.onlineVersion,
|
||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
publishedEpisodes,
|
publishedEpisodes,
|
||||||
|
plays: 0,
|
||||||
createdAt: this.toIso(entity.createdAt),
|
createdAt: this.toIso(entity.createdAt),
|
||||||
updatedAt: this.toIso(entity.updatedAt),
|
updatedAt: this.toIso(entity.updatedAt),
|
||||||
};
|
};
|
||||||
@@ -905,9 +922,32 @@ export class DramasService {
|
|||||||
...drama,
|
...drama,
|
||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").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> {
|
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
|
||||||
const page = Math.max(input.page, 1);
|
const page = Math.max(input.page, 1);
|
||||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface DramaRecord {
|
|||||||
onlineVersion: number;
|
onlineVersion: number;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
publishedEpisodes: number;
|
publishedEpisodes: number;
|
||||||
|
plays: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { UsersService } from '../src/modules/users/users.service';
|
|||||||
|
|
||||||
function createRepository<T extends { id?: number }>() {
|
function createRepository<T extends { id?: number }>() {
|
||||||
const records: T[] = [];
|
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 {
|
return {
|
||||||
records,
|
records,
|
||||||
create: jest.fn((input: Partial<T>) => input as T),
|
create: jest.fn((input: Partial<T>) => input as T),
|
||||||
@@ -24,14 +33,18 @@ function createRepository<T extends { id?: number }>() {
|
|||||||
return input;
|
return input;
|
||||||
}),
|
}),
|
||||||
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
|
findOne: jest.fn(async ({ where }: { where: Partial<T> }) =>
|
||||||
records.find((item) =>
|
records.find((item) => matchesWhere(item, where)) ?? null,
|
||||||
Object.entries(where).every(
|
|
||||||
([key, value]) => item[key as keyof T] === value,
|
|
||||||
),
|
|
||||||
) ?? null,
|
|
||||||
),
|
),
|
||||||
find: jest.fn(async () => [...records]),
|
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(episodeMetricRepository.save).toHaveBeenCalled();
|
||||||
expect(syncJobRepository.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(dramas.body.data.list).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ title: 'Published Drama' }),
|
expect.objectContaining({
|
||||||
|
title: 'Published Drama',
|
||||||
|
plays: 0,
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const publishedDrama = dramas.body.data.list.find(
|
const publishedDrama = dramas.body.data.list.find(
|
||||||
@@ -365,6 +368,7 @@ describe('Dramas and episodes', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: 'Recommended Search Drama',
|
title: 'Recommended Search Drama',
|
||||||
isRecommended: true,
|
isRecommended: true,
|
||||||
|
plays: 0,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user