refactor(dramas): 重构短剧预搜索功能,改为搜索短剧本身

1.  将预搜索接口从剧集维度改为短剧维度,移除剧集相关逻辑
2.  新增短剧列表按标题筛选的能力
3.  更新测试用例与接口文档,修正命名与逻辑
This commit is contained in:
2026-07-02 17:52:35 +08:00
parent 61a00286fb
commit 03d3c2f9cc
5 changed files with 1000 additions and 142 deletions

View File

@@ -0,0 +1,590 @@
{
"openapi": "3.0.3",
"info": {
"title": "CTH TK App APIs",
"description": "Apifox import file for mini app drama and history APIs.",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:3000",
"description": "Local backend"
}
],
"tags": [
{
"name": "App Dramas",
"description": "Mini app drama APIs"
},
{
"name": "App History",
"description": "Mini app playback history APIs"
}
],
"paths": {
"/api/app/v1/dramas": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧列表",
"operationId": "listAppDramas",
"parameters": [
{ "$ref": "#/components/parameters/Page" },
{ "$ref": "#/components/parameters/PageSize10" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AppDramaPage"
}
}
}
]
}
}
}
}
}
}
},
"/api/app/v1/dramas/recommended": {
"get": {
"tags": ["App Dramas"],
"summary": "推荐短剧列表",
"operationId": "listRecommendedAppDramas",
"parameters": [
{ "$ref": "#/components/parameters/Page" },
{ "$ref": "#/components/parameters/PageSize10" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AppDramaPage"
}
}
}
]
}
}
}
}
}
}
},
"/api/app/v1/dramas/presearch": {
"get": {
"tags": ["App Dramas"],
"summary": "关键字预查询视频列表",
"operationId": "presearchAppDramaVideos",
"parameters": [
{
"name": "keyword",
"in": "query",
"description": "用户输入关键字;空值返回空列表",
"required": false,
"schema": {
"type": "string"
},
"example": "skyline"
},
{ "$ref": "#/components/parameters/Page" },
{ "$ref": "#/components/parameters/PageSize10" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/VideoPresearchPage"
}
}
}
]
}
}
}
}
}
}
},
"/api/app/v1/dramas/{id}/episodes": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧剧集列表",
"operationId": "listAppDramaEpisodes",
"parameters": [
{ "$ref": "#/components/parameters/DramaIdPath" },
{ "$ref": "#/components/parameters/Page" },
{ "$ref": "#/components/parameters/PageSize20" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AppEpisodePage"
}
}
}
]
}
}
}
},
"404": { "$ref": "#/components/responses/ErrorResponse" }
}
}
},
"/api/app/v1/dramas/{id}": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧详情",
"operationId": "getAppDrama",
"parameters": [
{ "$ref": "#/components/parameters/DramaIdPath" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AppDramaDetail"
}
}
}
]
}
}
}
},
"404": { "$ref": "#/components/responses/ErrorResponse" }
}
}
},
"/api/app/v1/history": {
"get": {
"tags": ["App History"],
"summary": "播放历史列表",
"operationId": "listAppHistory",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{ "$ref": "#/components/parameters/Page" },
{ "$ref": "#/components/parameters/PageSize20" }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/PlaybackHistoryPage"
}
}
}
]
}
}
}
},
"401": { "$ref": "#/components/responses/ErrorResponse" }
}
},
"post": {
"tags": ["App History"],
"summary": "上报播放历史",
"operationId": "upsertAppHistory",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertPlaybackHistoryRequest"
},
"example": {
"episodeId": 123,
"progressSeconds": 86,
"completed": false,
"occurredAt": "2026-07-02T10:00:00.000Z"
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/PlaybackHistoryRecord"
}
}
}
]
}
}
}
},
"400": { "$ref": "#/components/responses/ErrorResponse" },
"401": { "$ref": "#/components/responses/ErrorResponse" },
"404": { "$ref": "#/components/responses/ErrorResponse" }
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"DramaIdPath": {
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"minimum": 1
},
"example": 1
},
"Page": {
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
"PageSize10": {
"name": "pageSize",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
}
},
"PageSize20": {
"name": "pageSize",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
},
"responses": {
"ErrorResponse": {
"description": "Error response",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponseBase" },
{
"type": "object",
"properties": {
"code": { "type": "integer", "example": 404 },
"data": { "type": "object", "example": {} },
"message": { "type": "string", "example": "Not found" }
}
}
]
}
}
}
}
},
"schemas": {
"ApiResponseBase": {
"type": "object",
"required": ["code", "data", "message", "timestamp", "requestId", "cost"],
"properties": {
"code": {
"type": "integer",
"description": "业务响应码;成功为 0错误为 HTTP 状态码",
"example": 0
},
"data": {
"type": "object"
},
"message": {
"type": "string",
"example": "success"
},
"timestamp": {
"type": "integer",
"format": "int64",
"example": 1782986400000
},
"requestId": {
"type": "string",
"example": "550e8400-e29b-41d4-a716-446655440000"
},
"cost": {
"type": "string",
"example": "12ms"
}
}
},
"Pagination": {
"type": "object",
"required": ["page", "pageSize", "total"],
"properties": {
"page": { "type": "integer", "example": 1 },
"pageSize": { "type": "integer", "example": 10 },
"total": { "type": "integer", "example": 100 }
}
},
"AppDrama": {
"type": "object",
"required": ["id", "title", "coverUrl", "type", "tags", "status", "sortOrder", "isRecommended", "onlineVersion", "totalEpisodes", "publishedEpisodes", "createdAt", "updatedAt"],
"properties": {
"id": { "type": "integer", "example": 1 },
"tiktokAlbumId": { "type": "string", "nullable": true },
"title": { "type": "string", "example": "Published Drama" },
"description": { "type": "string", "nullable": true },
"coverId": { "type": "integer", "nullable": true },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/cover.jpg" },
"type": { "type": "string", "example": "romance" },
"tags": { "type": "array", "items": { "type": "string" } },
"region": { "type": "string", "nullable": true },
"language": { "type": "string", "nullable": true },
"year": { "type": "integer", "nullable": true },
"status": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"sortOrder": { "type": "integer", "example": 0 },
"isRecommended": { "type": "boolean", "example": true },
"onlineVersion": { "type": "integer", "example": 1 },
"totalEpisodes": { "type": "integer", "example": 20 },
"publishedEpisodes": { "type": "integer", "example": 10 },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"AppDramaDetail": {
"allOf": [
{ "$ref": "#/components/schemas/AppDrama" },
{
"type": "object",
"properties": {
"portraitCoverUrl": { "type": "string", "nullable": true },
"landscapeCoverUrl": { "type": "string", "nullable": true }
}
}
]
},
"AppDramaPage": {
"type": "object",
"required": ["list", "pagination"],
"properties": {
"list": {
"type": "array",
"items": { "$ref": "#/components/schemas/AppDrama" }
},
"pagination": { "$ref": "#/components/schemas/Pagination" }
}
},
"AppEpisode": {
"type": "object",
"required": ["id", "dramaId", "albumId", "episodeNo", "seq", "title", "coverUrl", "durationSeconds", "isPaid", "freeType", "price", "status", "reviewStatus", "publishStatus", "createdAt", "updatedAt"],
"properties": {
"id": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"albumId": { "type": "integer", "example": 1 },
"tiktokEpisodeId": { "type": "string", "nullable": true },
"episodeNo": { "type": "integer", "example": 1 },
"seq": { "type": "integer", "example": 1 },
"title": { "type": "string", "example": "Episode 1" },
"description": { "type": "string", "nullable": true },
"byteplusVid": { "type": "string", "nullable": true },
"coverId": { "type": "integer", "nullable": true },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/episode.jpg" },
"videoUrl": { "type": "string", "nullable": true },
"storageBucket": { "type": "string", "nullable": true },
"objectKey": { "type": "string", "nullable": true },
"trialDurationSeconds": { "type": "integer", "nullable": true },
"durationSeconds": { "type": "integer", "example": 300 },
"width": { "type": "integer", "nullable": true },
"height": { "type": "integer", "nullable": true },
"isPaid": { "type": "boolean", "example": false },
"freeType": { "type": "string", "enum": ["free", "paid"], "example": "free" },
"unlockPrice": { "type": "integer", "nullable": true },
"price": { "type": "integer", "example": 0 },
"publishAt": { "type": "string", "format": "date-time", "nullable": true },
"nextReleaseAt": { "type": "string", "format": "date-time", "nullable": true },
"status": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"reviewStatus": { "type": "string", "enum": ["not_submitted", "pending", "reviewing", "approved", "rejected"], "example": "approved" },
"reviewMessage": { "type": "string", "nullable": true },
"publishStatus": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"AppEpisodePage": {
"type": "object",
"required": ["list", "pagination"],
"properties": {
"list": {
"type": "array",
"items": { "$ref": "#/components/schemas/AppEpisode" }
},
"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"],
"properties": {
"episodeId": { "type": "integer", "minimum": 1, "example": 123 },
"progressSeconds": { "type": "integer", "minimum": 0, "example": 86 },
"completed": { "type": "boolean", "example": false },
"occurredAt": { "type": "string", "format": "date-time", "example": "2026-07-02T10:00:00.000Z" }
}
},
"PlaybackHistoryRecord": {
"type": "object",
"required": ["id", "userId", "dramaId", "episodeId", "progressSeconds", "durationSeconds", "completed", "lastPlayedAt", "createdAt", "updatedAt"],
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"episodeId": { "type": "integer", "example": 123 },
"progressSeconds": { "type": "integer", "example": 86 },
"durationSeconds": { "type": "integer", "example": 300 },
"completed": { "type": "boolean", "example": false },
"lastPlayedAt": { "type": "string", "format": "date-time" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"PlaybackHistoryListItem": {
"type": "object",
"required": ["id", "dramaId", "episodeId", "episodeNo", "title", "coverUrl", "progressSeconds", "durationSeconds", "completed", "lastPlayedAt", "dramaTitle", "dramaCoverUrl"],
"properties": {
"id": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"episodeId": { "type": "integer", "example": 123 },
"episodeNo": { "type": "integer", "example": 1 },
"title": { "type": "string", "example": "History Episode" },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/history-episode.jpg" },
"progressSeconds": { "type": "integer", "example": 86 },
"durationSeconds": { "type": "integer", "example": 300 },
"completed": { "type": "boolean", "example": false },
"lastPlayedAt": { "type": "string", "format": "date-time" },
"dramaTitle": { "type": "string", "example": "History Drama" },
"dramaCoverUrl": { "type": "string", "example": "https://cdn.example.com/history-drama.jpg" }
}
},
"PlaybackHistoryPage": {
"type": "object",
"required": ["list", "pagination"],
"properties": {
"list": {
"type": "array",
"items": { "$ref": "#/components/schemas/PlaybackHistoryListItem" }
},
"pagination": { "$ref": "#/components/schemas/Pagination" }
}
}
}
}
}

View File

@@ -0,0 +1,351 @@
{
"swagger": "2.0",
"info": {
"title": "CTH TK App APIs",
"description": "Apifox import file for mini app drama and history APIs.",
"version": "1.0.0"
},
"host": "localhost:3000",
"basePath": "/",
"schemes": ["http"],
"consumes": ["application/json"],
"produces": ["application/json"],
"securityDefinitions": {
"Bearer": {
"type": "apiKey",
"name": "Authorization",
"in": "header",
"description": "Bearer <JWT>"
}
},
"tags": [
{ "name": "App Dramas", "description": "Mini app drama APIs" },
{ "name": "App History", "description": "Mini app playback history APIs" }
],
"paths": {
"/api/app/v1/dramas": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧列表",
"operationId": "listAppDramas",
"parameters": [
{ "name": "page", "in": "query", "type": "integer", "default": 1, "minimum": 1 },
{ "name": "pageSize", "in": "query", "type": "integer", "default": 10, "minimum": 1, "maximum": 100 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/AppDramaPageResponse" } }
}
}
},
"/api/app/v1/dramas/recommended": {
"get": {
"tags": ["App Dramas"],
"summary": "推荐短剧列表",
"operationId": "listRecommendedAppDramas",
"parameters": [
{ "name": "page", "in": "query", "type": "integer", "default": 1, "minimum": 1 },
{ "name": "pageSize", "in": "query", "type": "integer", "default": 10, "minimum": 1, "maximum": 100 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/AppDramaPageResponse" } }
}
}
},
"/api/app/v1/dramas/presearch": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧名称预查询",
"operationId": "presearchAppDramasByTitle",
"parameters": [
{ "name": "keyword", "in": "query", "type": "string", "description": "短剧名称关键字;空值返回空列表" },
{ "name": "page", "in": "query", "type": "integer", "default": 1, "minimum": 1 },
{ "name": "pageSize", "in": "query", "type": "integer", "default": 10, "minimum": 1, "maximum": 100 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/AppDramaPageResponse" } }
}
}
},
"/api/app/v1/dramas/{id}/episodes": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧剧集列表",
"operationId": "listAppDramaEpisodes",
"parameters": [
{ "name": "id", "in": "path", "required": true, "type": "integer", "minimum": 1 },
{ "name": "page", "in": "query", "type": "integer", "default": 1, "minimum": 1 },
{ "name": "pageSize", "in": "query", "type": "integer", "default": 20, "minimum": 1, "maximum": 100 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/AppEpisodePageResponse" } },
"404": { "description": "Not Found", "schema": { "$ref": "#/definitions/ErrorResponse" } }
}
}
},
"/api/app/v1/dramas/{id}": {
"get": {
"tags": ["App Dramas"],
"summary": "短剧详情",
"operationId": "getAppDrama",
"parameters": [
{ "name": "id", "in": "path", "required": true, "type": "integer", "minimum": 1 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/AppDramaDetailResponse" } },
"404": { "description": "Not Found", "schema": { "$ref": "#/definitions/ErrorResponse" } }
}
}
},
"/api/app/v1/history": {
"get": {
"tags": ["App History"],
"summary": "播放历史列表",
"operationId": "listAppHistory",
"security": [{ "Bearer": [] }],
"parameters": [
{ "name": "page", "in": "query", "type": "integer", "default": 1, "minimum": 1 },
{ "name": "pageSize", "in": "query", "type": "integer", "default": 20, "minimum": 1, "maximum": 100 }
],
"responses": {
"200": { "description": "OK", "schema": { "$ref": "#/definitions/PlaybackHistoryPageResponse" } },
"401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/ErrorResponse" } }
}
},
"post": {
"tags": ["App History"],
"summary": "上报播放历史",
"operationId": "upsertAppHistory",
"security": [{ "Bearer": [] }],
"parameters": [
{ "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/UpsertPlaybackHistoryRequest" } }
],
"responses": {
"201": { "description": "Created", "schema": { "$ref": "#/definitions/PlaybackHistoryRecordResponse" } },
"400": { "description": "Bad Request", "schema": { "$ref": "#/definitions/ErrorResponse" } },
"401": { "description": "Unauthorized", "schema": { "$ref": "#/definitions/ErrorResponse" } },
"404": { "description": "Not Found", "schema": { "$ref": "#/definitions/ErrorResponse" } }
}
}
}
},
"definitions": {
"ApiResponseBase": {
"type": "object",
"required": ["code", "data", "message", "timestamp", "requestId", "cost"],
"properties": {
"code": { "type": "integer", "description": "成功为 0错误为 HTTP 状态码", "example": 0 },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64", "example": 1782986400000 },
"requestId": { "type": "string", "example": "550e8400-e29b-41d4-a716-446655440000" },
"cost": { "type": "string", "example": "12ms" }
}
},
"Pagination": {
"type": "object",
"required": ["page", "pageSize", "total"],
"properties": {
"page": { "type": "integer", "example": 1 },
"pageSize": { "type": "integer", "example": 10 },
"total": { "type": "integer", "example": 100 }
}
},
"AppDrama": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"tiktokAlbumId": { "type": "string" },
"title": { "type": "string", "example": "Published Drama" },
"description": { "type": "string" },
"coverId": { "type": "integer" },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/cover.jpg" },
"type": { "type": "string", "example": "romance" },
"tags": { "type": "array", "items": { "type": "string" } },
"region": { "type": "string" },
"language": { "type": "string" },
"year": { "type": "integer" },
"status": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"sortOrder": { "type": "integer", "example": 0 },
"isRecommended": { "type": "boolean", "example": true },
"onlineVersion": { "type": "integer", "example": 1 },
"totalEpisodes": { "type": "integer", "example": 20 },
"publishedEpisodes": { "type": "integer", "example": 10 },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"AppDramaDetail": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"tiktokAlbumId": { "type": "string" },
"title": { "type": "string", "example": "Published Drama" },
"description": { "type": "string" },
"coverId": { "type": "integer" },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/cover.jpg" },
"portraitCoverUrl": { "type": "string" },
"landscapeCoverUrl": { "type": "string" },
"type": { "type": "string", "example": "romance" },
"tags": { "type": "array", "items": { "type": "string" } },
"region": { "type": "string" },
"language": { "type": "string" },
"year": { "type": "integer" },
"status": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"sortOrder": { "type": "integer", "example": 0 },
"isRecommended": { "type": "boolean", "example": true },
"onlineVersion": { "type": "integer", "example": 1 },
"totalEpisodes": { "type": "integer", "example": 20 },
"publishedEpisodes": { "type": "integer", "example": 10 },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"AppEpisode": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"albumId": { "type": "integer", "example": 1 },
"tiktokEpisodeId": { "type": "string" },
"episodeNo": { "type": "integer", "example": 1 },
"seq": { "type": "integer", "example": 1 },
"title": { "type": "string", "example": "Episode 1" },
"description": { "type": "string" },
"byteplusVid": { "type": "string" },
"coverId": { "type": "integer" },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/episode.jpg" },
"videoUrl": { "type": "string" },
"trialDurationSeconds": { "type": "integer" },
"durationSeconds": { "type": "integer", "example": 300 },
"isPaid": { "type": "boolean", "example": false },
"freeType": { "type": "string", "enum": ["free", "paid"], "example": "free" },
"unlockPrice": { "type": "integer" },
"price": { "type": "integer", "example": 0 },
"status": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"reviewStatus": { "type": "string", "enum": ["not_submitted", "pending", "reviewing", "approved", "rejected"], "example": "approved" },
"publishStatus": { "type": "string", "enum": ["draft", "scheduled", "published", "offline"], "example": "published" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"PlaybackHistoryListItem": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"episodeId": { "type": "integer", "example": 123 },
"episodeNo": { "type": "integer", "example": 1 },
"title": { "type": "string", "example": "History Episode" },
"coverUrl": { "type": "string", "example": "https://cdn.example.com/history-episode.jpg" },
"progressSeconds": { "type": "integer", "example": 86 },
"durationSeconds": { "type": "integer", "example": 300 },
"completed": { "type": "boolean", "example": false },
"lastPlayedAt": { "type": "string", "format": "date-time" },
"dramaTitle": { "type": "string", "example": "History Drama" },
"dramaCoverUrl": { "type": "string", "example": "https://cdn.example.com/history-drama.jpg" }
}
},
"PlaybackHistoryRecord": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"dramaId": { "type": "integer", "example": 1 },
"episodeId": { "type": "integer", "example": 123 },
"progressSeconds": { "type": "integer", "example": 86 },
"durationSeconds": { "type": "integer", "example": 300 },
"completed": { "type": "boolean", "example": false },
"lastPlayedAt": { "type": "string", "format": "date-time" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"UpsertPlaybackHistoryRequest": {
"type": "object",
"required": ["episodeId", "progressSeconds"],
"properties": {
"episodeId": { "type": "integer", "minimum": 1, "example": 123 },
"progressSeconds": { "type": "integer", "minimum": 0, "example": 86 },
"completed": { "type": "boolean", "example": false },
"occurredAt": { "type": "string", "format": "date-time", "example": "2026-07-02T10:00:00.000Z" }
}
},
"AppDramaPage": {
"type": "object",
"properties": { "list": { "type": "array", "items": { "$ref": "#/definitions/AppDrama" } }, "pagination": { "$ref": "#/definitions/Pagination" } }
},
"AppEpisodePage": {
"type": "object",
"properties": { "list": { "type": "array", "items": { "$ref": "#/definitions/AppEpisode" } }, "pagination": { "$ref": "#/definitions/Pagination" } }
},
"PlaybackHistoryPage": {
"type": "object",
"properties": { "list": { "type": "array", "items": { "$ref": "#/definitions/PlaybackHistoryListItem" } }, "pagination": { "$ref": "#/definitions/Pagination" } }
},
"AppDramaPageResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"data": { "$ref": "#/definitions/AppDramaPage" },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
},
"AppDramaDetailResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"data": { "$ref": "#/definitions/AppDramaDetail" },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
},
"AppEpisodePageResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"data": { "$ref": "#/definitions/AppEpisodePage" },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
},
"PlaybackHistoryPageResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"data": { "$ref": "#/definitions/PlaybackHistoryPage" },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
},
"PlaybackHistoryRecordResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"data": { "$ref": "#/definitions/PlaybackHistoryRecord" },
"message": { "type": "string", "example": "success" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 404 },
"data": { "type": "object" },
"message": { "type": "string", "example": "Not found" },
"timestamp": { "type": "integer", "format": "int64" },
"requestId": { "type": "string" },
"cost": { "type": "string", "example": "12ms" }
}
}
}
}

View File

@@ -113,10 +113,11 @@ export class DramasController {
}
@Get("/api/app/v1/dramas")
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string) {
return this.dramasService.listPublishedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
title,
});
}
@@ -124,13 +125,13 @@ export class DramasController {
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listRecommendedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
pageSize: Number(pageSize) || 10
});
}
@Get("/api/app/v1/dramas/presearch")
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchVideos({
presearchDramas(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
keyword,

View File

@@ -24,28 +24,12 @@ interface DramaListInput extends PaginationInput {
type PublishedDramaListInput = DramaListInput;
interface VideoPresearchInput extends PaginationInput {
interface DramaPresearchInput extends PaginationInput {
keyword?: string;
}
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
export interface VideoPresearchRecord {
id: number;
dramaId: number;
episodeNo: number;
title: string;
description?: string;
coverUrl: string;
durationSeconds: number;
byteplusVid?: string;
videoUrl?: string;
freeType: "free" | "paid";
price: number;
dramaTitle: string;
dramaCoverUrl: string;
}
@Injectable()
export class DramasService {
private dramaSequence = 1;
@@ -537,15 +521,15 @@ export class DramasService {
return episode;
}
async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const title = input.title?.trim();
async listPublishedDramas(data: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const title = data.title?.trim();
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const { page, pageSize, skip, take } = this.resolvePagination(data);
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
status: PublishStatus.Published,
...(title ? { title: Like(`%${title}%`) } : {}),
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
...(data.isRecommended !== undefined ? { isRecommended: data.isRecommended } : {}),
},
order: { sortOrder: "DESC", id: "DESC" },
skip,
@@ -560,10 +544,10 @@ export class DramasService {
this.dramas
.filter((item) => item.status === PublishStatus.Published)
.filter((item) => !title || item.title.includes(title))
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
.filter((item) => data.isRecommended === undefined || item.isRecommended === data.isRecommended)
.map((drama) => this.withEpisodeCounts(drama)),
),
input,
data,
);
return {
...page,
@@ -578,7 +562,7 @@ export class DramasService {
});
}
async presearchVideos(input: VideoPresearchInput): Promise<PaginatedResponse<VideoPresearchRecord>> {
async presearchDramas(input: DramaPresearchInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const keyword = input.keyword?.trim();
if (!keyword) {
const { page, pageSize } = this.resolvePagination(input);
@@ -587,82 +571,30 @@ export class DramasService {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const likeKeyword = `%${keyword}%`;
const baseQuery = this.dataSource
.getRepository(Episode)
.createQueryBuilder("episode")
.innerJoin(Drama, "drama", "drama.id = episode.dramaId")
.where("drama.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.publishStatus = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.reviewStatus = :approved", {
approved: "approved",
})
.andWhere("(episode.title LIKE :keyword OR episode.description LIKE :keyword OR drama.title LIKE :keyword OR drama.description LIKE :keyword)", { keyword: likeKeyword });
const total = await baseQuery.clone().getCount();
const rows = await baseQuery
.clone()
.select([
"episode.id AS id",
"episode.dramaId AS dramaId",
"episode.episodeNo AS episodeNo",
"episode.title AS title",
"episode.description AS description",
"episode.coverUrl AS coverUrl",
"episode.durationSeconds AS durationSeconds",
"episode.byteplusVid AS byteplusVid",
"episode.videoUrl AS videoUrl",
"episode.freeType AS freeType",
"episode.price AS price",
"drama.title AS dramaTitle",
"drama.coverUrl AS dramaCoverUrl",
])
.orderBy("drama.sortOrder", "DESC")
.addOrderBy("drama.id", "DESC")
.addOrderBy("episode.episodeNo", "ASC")
.offset(skip)
.limit(take)
.getRawMany<VideoPresearchRecord>();
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
status: PublishStatus.Published,
title: Like(`%${keyword}%`),
},
order: { sortOrder: "DESC", id: "DESC" },
skip,
take,
});
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
return {
list: rows.map((row) => ({
...row,
id: Number(row.id),
dramaId: Number(row.dramaId),
episodeNo: Number(row.episodeNo),
durationSeconds: Number(row.durationSeconds),
price: Number(row.price),
})),
list: list.map((drama) => this.toPublicDramaRecord(drama)),
pagination: { page, pageSize, total },
};
}
const normalizedKeyword = keyword.toLowerCase();
const records = this.episodes
.filter((episode) => episode.status === PublishStatus.Published && episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved")
.map((episode) => {
const drama = this.dramas.find((item) => item.id === episode.dramaId);
return { episode, drama };
})
.filter((item): item is { episode: EpisodeRecord; drama: DramaRecord } => Boolean(item.drama) && item.drama?.status === PublishStatus.Published)
.filter(({ episode, drama }) => [episode.title, episode.description, drama.title, drama.description].some((value) => value?.toLowerCase().includes(normalizedKeyword)))
.sort((left, right) => {
if (right.drama.sortOrder !== left.drama.sortOrder) {
return right.drama.sortOrder - left.drama.sortOrder;
}
if (right.drama.id !== left.drama.id) {
return right.drama.id - left.drama.id;
}
return left.episode.episodeNo - right.episode.episodeNo;
})
.map(({ episode, drama }) => this.toVideoPresearchRecord(episode, drama));
const records = this.sortDramas(
this.dramas
.filter((drama) => drama.status === PublishStatus.Published)
.filter((drama) => drama.title.toLowerCase().includes(normalizedKeyword))
.map((drama) => this.withEpisodeCounts(drama)),
).map((drama) => this.toPublicDramaRecord(drama));
return this.paginate(records, input);
}
@@ -901,24 +833,6 @@ export class DramasService {
return publicRecord;
}
private toVideoPresearchRecord(episode: EpisodeRecord, drama: DramaRecord): VideoPresearchRecord {
return {
id: episode.id,
dramaId: episode.dramaId,
episodeNo: episode.episodeNo,
title: episode.title,
description: episode.description,
coverUrl: episode.coverUrl,
durationSeconds: episode.durationSeconds,
byteplusVid: episode.byteplusVid,
videoUrl: episode.videoUrl,
freeType: episode.freeType,
price: episode.price,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private toEpisodeRecord(entity: Episode): EpisodeRecord {
const dramaId = Number(entity.dramaId);
return {

View File

@@ -419,8 +419,8 @@ describe('Dramas and episodes', () => {
);
});
it('presearches published approved videos by keyword for mini app clients', async () => {
const drama = await request(app.getHttpServer())
it('presearches published dramas by drama title for mini app clients', async () => {
await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
@@ -432,14 +432,25 @@ describe('Dramas and episodes', () => {
})
.expect(201);
const episodeOnlyDrama = await request(app.getHttpServer())
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
title: 'Episode Only Drama',
coverUrl: 'https://cdn.example.com/episode-only-drama.jpg',
type: 'romance',
status: 'published',
})
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
dramaId: episodeOnlyDrama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
description: 'The first skyline episode.',
title: 'Skyline Episode Title Only',
description: 'This should not make the drama match.',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
@@ -453,20 +464,13 @@ describe('Dramas and episodes', () => {
.expect(201);
await request(app.getHttpServer())
.post('/api/admin/v1/episodes')
.post('/api/admin/v1/dramas')
.set('Authorization', `Bearer ${adminToken}`)
.send({
dramaId: drama.body.data.id,
episodeNo: 2,
title: 'Hidden Draft Video',
description: 'Contains skyline but is not published.',
coverUrl: 'https://cdn.example.com/skyline-ep2.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep2.mp4',
byteplusVid: 'vid_skyline_002',
durationSeconds: 180,
title: 'Skyline Draft Drama',
coverUrl: 'https://cdn.example.com/skyline-draft-drama.jpg',
type: 'romance',
status: 'draft',
publishStatus: 'draft',
reviewStatus: 'approved',
})
.expect(201);
@@ -478,19 +482,17 @@ describe('Dramas and episodes', () => {
expect(response.body.data.pagination.pageSize).toBe(10);
expect(response.body.data.list).toEqual([
expect.objectContaining({
dramaId: drama.body.data.id,
episodeNo: 1,
title: 'Pilot Video Match',
coverUrl: 'https://cdn.example.com/skyline-ep1.jpg',
videoUrl: 'https://cdn.example.com/skyline-ep1.mp4',
byteplusVid: 'vid_skyline_001',
durationSeconds: 180,
freeType: 'free',
price: 0,
dramaTitle: 'Skyline Keyword Saga',
dramaCoverUrl: 'https://cdn.example.com/skyline-drama.jpg',
title: 'Skyline Keyword Saga',
coverUrl: 'https://cdn.example.com/skyline-drama.jpg',
status: 'published',
}),
]);
expect(response.body.data.list).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Episode Only Drama' }),
expect.objectContaining({ title: 'Skyline Draft Drama' }),
]),
);
const emptyKeyword = await request(app.getHttpServer())
.get('/api/app/v1/dramas/presearch')