From 03d3c2f9ccb1b6b8037df15b408c97f6fcc9ef6a Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Thu, 2 Jul 2026 17:52:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(dramas):=20=E9=87=8D=E6=9E=84=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E9=A2=84=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=90=9C=E7=B4=A2=E7=9F=AD=E5=89=A7=E6=9C=AC?= =?UTF-8?q?=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将预搜索接口从剧集维度改为短剧维度,移除剧集相关逻辑 2. 新增短剧列表按标题筛选的能力 3. 更新测试用例与接口文档,修正命名与逻辑 --- docs/apifox-app-apis.openapi.json | 590 ++++++++++++++++++++++++ docs/apifox-app-apis.swagger.json | 351 ++++++++++++++ src/modules/dramas/dramas.controller.ts | 9 +- src/modules/dramas/dramas.service.ts | 136 +----- test/dramas.e2e-spec.ts | 56 +-- 5 files changed, 1000 insertions(+), 142 deletions(-) create mode 100644 docs/apifox-app-apis.openapi.json create mode 100644 docs/apifox-app-apis.swagger.json diff --git a/docs/apifox-app-apis.openapi.json b/docs/apifox-app-apis.openapi.json new file mode 100644 index 0000000..db13078 --- /dev/null +++ b/docs/apifox-app-apis.openapi.json @@ -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" } + } + } + } + } +} diff --git a/docs/apifox-app-apis.swagger.json b/docs/apifox-app-apis.swagger.json new file mode 100644 index 0000000..ae7b325 --- /dev/null +++ b/docs/apifox-app-apis.swagger.json @@ -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 " + } + }, + "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" } + } + } + } +} diff --git a/src/modules/dramas/dramas.controller.ts b/src/modules/dramas/dramas.controller.ts index 275f996..7ccdde2 100644 --- a/src/modules/dramas/dramas.controller.ts +++ b/src/modules/dramas/dramas.controller.ts @@ -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, diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index 51543cd..7fe77f2 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -24,28 +24,12 @@ interface DramaListInput extends PaginationInput { type PublishedDramaListInput = DramaListInput; -interface VideoPresearchInput extends PaginationInput { +interface DramaPresearchInput extends PaginationInput { keyword?: string; } type PublicDramaRecord = Omit; -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> { - const title = input.title?.trim(); + async listPublishedDramas(data: PublishedDramaListInput): Promise> { + 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> { + async presearchDramas(input: DramaPresearchInput): Promise> { 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(); + 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 { diff --git a/test/dramas.e2e-spec.ts b/test/dramas.e2e-spec.ts index b94d33a..fcdf88b 100644 --- a/test/dramas.e2e-spec.ts +++ b/test/dramas.e2e-spec.ts @@ -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')