Compare commits

...

8 Commits

Author SHA1 Message Date
2eeec11d51 feat: 添加频道管理功能,关联短剧与频道绑定逻辑
1. 新增频道模块,包含类型定义、实体、DTO、控制器和服务
2. 为短剧添加频道ID和频道名字段,支持关联频道
3. 新增短剧删除API和权限配置
4. 调整tsconfig和依赖配置,更新权限列表
5. 新增相关测试用例验证频道与短剧关联逻辑
2026-07-03 15:31:36 +08:00
4eff4d877e refactor: 完成项目整体目录重构与路径别名配置
1.  重构项目文件结构,统一文件存放位置与命名规则
2.  配置tsconfig路径别名,替换原有相对路径导入
3.  迁移drama-status枚举到dramas.types统一管理
4.  新增全局配置文件与工具类,补充完整注释与文档
5.  修复所有导入路径与依赖引用问题
2026-07-02 19:17:56 +08:00
e84351e8ed refactor: 重构项目目录与代码结构,清理冗余文件
本次提交进行了大规模的项目结构调整:
1. 调整文件目录归属,将模块代码按功能域重新组织
2. 删除多处冗余的DTO、实体类和模块文件
3. 统一了常量、装饰器、工具类的存放位置
4. 修复了`.gitignore`的日志目录匹配规则
5. 新增了健康检查、认证、用户、剧集、订单等核心业务模块
2026-07-02 19:17:43 +08:00
55e20f9f16 feat(dramas): add play count field for drama records
1. 新增plays字段到DramaRecord接口和swagger/openapi文档
2. 在剧集创建时默认设置plays为0
3. 新增getPlayCountsByDramaIds方法聚合剧集播放量
4. 在后台和前台剧集列表中添加播放量数据
5. 更新测试用例适配新增的plays字段
2026-07-02 18:25:58 +08:00
03d3c2f9cc refactor(dramas): 重构短剧预搜索功能,改为搜索短剧本身
1.  将预搜索接口从剧集维度改为短剧维度,移除剧集相关逻辑
2.  新增短剧列表按标题筛选的能力
3.  更新测试用例与接口文档,修正命名与逻辑
2026-07-02 17:52:35 +08:00
61a00286fb refactor(history): 重构播放历史模块,统一命名并迁移代码
将原playback-history模块重命名为history模块,统一所有相关文件的命名空间,移除冗余的重复代码,更新全局模块引用和测试用例接口路径
2026-07-02 17:28:57 +08:00
79aeac7f37 feat(playback-history): 添加播放历史模块功能
实现了播放历史的新增/更新、列表查询功能,支持JWT鉴权,同时兼容内存和数据库两种存储模式,关联了剧集和用户信息
2026-07-02 17:28:51 +08:00
fca247c3f2 feat(dramas): 新增剧集搜索、推荐接口与管理筛选功能
1.  为后台剧集列表添加标题和推荐状态筛选参数
2.  新增前台推荐剧集列表接口和前置搜索接口
3.  添加可选布尔值解析工具方法
4.  新增对应接口的端到端测试用例
2026-07-02 17:26:08 +08:00
147 changed files with 5329 additions and 2315 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Logs
logs
/logs/
*.log
npm-debug.log*
yarn-debug.log*

View File

@@ -0,0 +1,17 @@
/**
* Paginated response dto.
* @file 通用分页响应结构
* @module common/paginated-response.dto
* @author zhxiao1124
*/
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
}
export interface PaginatedResponse<T> {
list: T[];
pagination: PaginationMeta;
}

59
config/app.config.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* App config.
* @file 应用运行配置
* @module config/app.config
* @author zhxiao1124
*/
// 注意:本文件不主动加载 .env环境变量由 src/main.ts 顶部的 `import "dotenv/config"` 注入。
// e2e 测试不加载 .envDB_CONFIG.host 为空时应用自动退回内存模式(见 src/app/app.module.ts 的门控)。
const numberEnv = (key: string, fallback: number) => {
const value = Number(process.env[key]);
return Number.isFinite(value) ? value : fallback;
};
const boolEnv = (key: string, fallback: boolean) => {
const value = process.env[key];
if (value === undefined) return fallback;
return value === "true" || value === "1";
};
export const APP_CONFIG = {
APP_NAME: "cth-tk-backend",
PORT: numberEnv("PORT", 3000),
};
export const OPTIONS_CONFIG = {
cors: {
origin: process.env.CORS_ORIGIN?.split(","),
credentials: true,
},
};
export const DB_CONFIG = {
type: "mysql" as const,
host: process.env.DB_HOST,
port: numberEnv("DB_PORT", 3306),
username: process.env.DB_USERNAME ?? "root",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_DATABASE ?? "cth_tk",
synchronize: boolEnv("DB_SYNCHRONIZE", false),
};
export const AUTH_CONFIG = {
jwtTokenSecret: process.env.JWT_SECRET ?? "development-secret",
expiresIn: "30d" as const,
};
export const ADMIN_AUTH_CONFIG = {
jwtTokenSecret: process.env.ADMIN_JWT_SECRET ?? process.env.JWT_SECRET ?? "development-secret",
expiresIn: "12h" as const,
username: process.env.ADMIN_USERNAME ?? "admin",
password: process.env.ADMIN_PASSWORD ?? "admin123",
email: process.env.ADMIN_EMAIL ?? "admin@example.com",
};
export const BYTEPLUS_CONFIG = {
bucket: process.env.BYTEPLUS_BUCKET ?? "mock-short-drama",
};

View File

@@ -0,0 +1,8 @@
/**
* Text constant.
* @file 通用文案常量
* @module constants/text.constant
* @author zhxiao1124
*/
export const SUCCESS_MESSAGE = "success";

View File

@@ -0,0 +1,12 @@
/**
* ApiMessage decorator.
* @file 接口响应文案装饰器
* @module decorators/api-message.decorator
* @author zhxiao1124
*/
import { SetMetadata } from "@nestjs/common";
export const API_MESSAGE_METADATA = "api:message";
export const ApiMessage = (message: string) => SetMetadata(API_MESSAGE_METADATA, message);

View File

@@ -0,0 +1,15 @@
/**
* CurrentAdmin decorator.
* @file 当前登录管理员装饰器
* @module decorators/current-admin.decorator
* @author zhxiao1124
*/
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";
import { AdminUserRecord } from "@src/admin/admin.types";
export const CurrentAdmin = createParamDecorator((_data: unknown, ctx: ExecutionContext): AdminUserRecord | undefined => {
const request = ctx.switchToHttp().getRequest<Request & { adminUser?: AdminUserRecord }>();
return request.adminUser;
});

View File

@@ -0,0 +1,15 @@
/**
* CurrentUser decorator.
* @file 当前登录用户装饰器
* @module decorators/current-user.decorator
* @author zhxiao1124
*/
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";
import { UserRecord } from "@src/users/users.types";
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): UserRecord | undefined => {
const request = ctx.switchToHttp().getRequest<Request & { user?: UserRecord }>();
return request.user;
});

View File

@@ -0,0 +1,12 @@
/**
* RequirePermissions decorator.
* @file 接口权限声明装饰器
* @module decorators/require-permissions.decorator
* @author zhxiao1124
*/
import { SetMetadata } from "@nestjs/common";
export const REQUIRED_PERMISSIONS_METADATA = "admin:required-permissions";
export const RequirePermissions = (...permissions: string[]) => SetMetadata(REQUIRED_PERMISSIONS_METADATA, permissions);

View File

@@ -0,0 +1,561 @@
{
"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": "presearchAppDramasByTitle",
"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/AppDramaPage"
}
}
}
]
}
}
}
}
}
}
},
"/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", "plays", "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 },
"plays": { "type": "integer", "example": 12345 },
"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" }
}
},
"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,352 @@
{
"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 },
"plays": { "type": "integer", "example": 12345 },
"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

@@ -5,7 +5,22 @@ export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'test/**/*.ts'],
ignores: ['dist/**', 'node_modules/**', '.pnpm-store/**'],
},
{
files: [
'src/**/*.ts',
'test/**/*.ts',
'config/**/*.ts',
'constants/**/*.ts',
'decorators/**/*.ts',
'filters/**/*.ts',
'guards/**/*.ts',
'interceptor/**/*.ts',
'middleware/**/*.ts',
'utils/**/*.ts',
'common/**/*.ts',
],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',

71
filters/http.filter.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* HttpException filter.
* @file 全局异常过滤器
* @module filters/http.filter
* @author zhxiao1124
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
import { Request, Response } from "express";
import { LogsService } from "@src/logs/logs.service";
import { getRequestCost } from "@app/utils/request-context.util";
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logsService: LogsService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request & { requestId?: string; adminId?: number; user?: { id: number } }>();
const response = ctx.getResponse<Response>();
const statusCode = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const message = this.getMessage(exception, statusCode);
const requestId = request.requestId ?? "";
const costMs = getRequestCost(request);
this.logsService.recordRequest({
requestId,
method: request.method,
path: request.originalUrl ?? request.url,
query: request.query,
body: request.body,
statusCode,
responseCode: statusCode,
message,
costMs,
userId: request.user?.id,
adminId: request.adminId,
ip: request.ip,
userAgent: request.get("user-agent"),
errorStack: exception instanceof Error ? exception.stack : undefined,
});
response.status(statusCode).json({
code: statusCode,
data: {},
message,
timestamp: Date.now(),
requestId,
cost: `${costMs}ms`,
});
}
private getMessage(exception: unknown, statusCode: number): string {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === "string") {
return response;
}
if (response && typeof response === "object" && "message" in response) {
const message = (response as { message: string | string[] }).message;
return Array.isArray(message) ? message.join("; ") : message;
}
}
if (exception instanceof Error && statusCode === HttpStatus.INTERNAL_SERVER_ERROR) {
return exception.message || "Internal server error";
}
return "Internal server error";
}
}

View File

@@ -0,0 +1,51 @@
/**
* AdminJwtAuth guard.
* @file 管理端 JWT 认证守卫
* @module guards/admin-jwt-auth.guard
* @author zhxiao1124
*/
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { AdminUserRecord } from "@src/admin/admin.types";
import { AdminService } from "@src/admin/admin.service";
@Injectable()
export class AdminJwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly adminService: AdminService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { adminUser?: AdminUserRecord; adminId?: number }>();
const authHeader = request.get("authorization");
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
if (!token) {
throw new UnauthorizedException("Admin not authenticated");
}
try {
const payload = await this.jwtService.verifyAsync<{
sub: number;
type: string;
}>(token);
if (payload.type !== "admin") {
throw new UnauthorizedException("Admin not authenticated");
}
const adminUser = await this.adminService.findAdminById(payload.sub);
if (!adminUser || adminUser.status !== "active") {
throw new UnauthorizedException("Admin not authenticated");
}
request.adminUser = adminUser;
request.adminId = adminUser.id;
return true;
} catch {
throw new UnauthorizedException("Admin not authenticated");
}
}
}

42
guards/jwt-auth.guard.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* JwtAuth guard.
* @file 用户端 JWT 认证守卫
* @module guards/jwt-auth.guard
* @author zhxiao1124
*/
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { UsersService } from "@src/users/users.service";
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user?: unknown }>();
const authHeader = request.get("authorization");
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
if (!token) {
throw new UnauthorizedException("Not authenticated");
}
try {
const payload = await this.jwtService.verifyAsync<{ sub: number }>(token);
const user = await this.usersService.findById(payload.sub);
if (!user || user.status !== "active") {
throw new UnauthorizedException("Not authenticated");
}
request.user = user;
return true;
} catch {
throw new UnauthorizedException("Not authenticated");
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* Permissions guard.
* @file 管理端权限校验守卫
* @module guards/permissions.guard
* @author zhxiao1124
*/
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { REQUIRED_PERMISSIONS_METADATA } from "@app/decorators/require-permissions.decorator";
import { AdminUserRecord } from "@src/admin/admin.types";
import { AdminService } from "@src/admin/admin.service";
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly adminService: AdminService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<string[]>(REQUIRED_PERMISSIONS_METADATA, [context.getHandler(), context.getClass()]) ?? [];
if (required.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<Request & { adminUser?: AdminUserRecord }>();
const adminUser = request.adminUser;
if (!adminUser) {
throw new ForbiddenException("No permission");
}
if (adminUser.isSuperAdmin) {
return true;
}
const permissions = await this.adminService.getPermissionCodesForAdmin(adminUser.id);
const allowed = required.every((permission) => permissions.includes(permission));
if (!allowed) {
throw new ForbiddenException("No permission");
}
return true;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Logger interceptor.
* @file 请求日志拦截器
* @module interceptor/logger.interceptor
* @author zhxiao1124
*/
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request, Response } from "express";
import { Observable, tap } from "rxjs";
import { API_MESSAGE_METADATA } from "@app/decorators/api-message.decorator";
import { SUCCESS_MESSAGE } from "@constants/text.constant";
import { getRequestCost } from "@app/utils/request-context.util";
import { LogsService } from "@src/logs/logs.service";
@Injectable()
export class LoggerInterceptor implements NestInterceptor {
constructor(
private readonly logsService: LogsService,
private readonly reflector: Reflector,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<Request & { requestId?: string; adminId?: number; user?: { id: number } }>();
const response = http.getResponse<Response>();
const message = this.reflector.get<string>(API_MESSAGE_METADATA, context.getHandler()) ?? SUCCESS_MESSAGE;
return next.handle().pipe(
tap(() => {
this.logsService.recordRequest({
requestId: request.requestId ?? "",
method: request.method,
path: request.originalUrl ?? request.url,
query: request.query,
body: request.body,
statusCode: response.statusCode,
responseCode: 0,
message,
costMs: getRequestCost(request),
userId: request.user?.id,
adminId: request.adminId,
ip: request.ip,
userAgent: request.get("user-agent"),
errorStack: undefined,
});
}),
);
}
}

View File

@@ -0,0 +1,35 @@
/**
* Response interceptor.
* @file 统一响应结构拦截器
* @module interceptor/response.interceptor
* @author zhxiao1124
*/
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { map, Observable } from "rxjs";
import { SUCCESS_MESSAGE } from "@constants/text.constant";
import { API_MESSAGE_METADATA } from "@app/decorators/api-message.decorator";
import { getRequestCost } from "@app/utils/request-context.util";
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
constructor(private readonly reflector = new Reflector()) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request & { requestId?: string }>();
const message = this.reflector.get<string>(API_MESSAGE_METADATA, context.getHandler()) ?? SUCCESS_MESSAGE;
return next.handle().pipe(
map((data) => ({
code: 0,
data: data ?? {},
message,
timestamp: Date.now(),
requestId: request.requestId,
cost: `${getRequestCost(request)}ms`,
})),
);
}
}

View File

@@ -0,0 +1,19 @@
/**
* RequestId middleware.
* @file 请求链路 ID 中间件
* @module middleware/request-id.middleware
* @author zhxiao1124
*/
import { NextFunction, Request, Response } from "express";
import { validate as validateUuid, version as uuidVersion, v4 as uuidv4 } from "uuid";
export function RequestIdMiddleware(request: Request & { requestId?: string; startTime?: bigint }, response: Response, next: NextFunction) {
const incomingRequestId = request.get("x-request-id");
const requestId = incomingRequestId && validateUuid(incomingRequestId) && uuidVersion(incomingRequestId) === 4 ? incomingRequestId : uuidv4();
request.requestId = requestId;
request.startTime = process.hrtime.bigint();
response.setHeader("X-Request-Id", requestId);
next();
}

View File

@@ -5,14 +5,16 @@
"scripts": {
"build": "nest build",
"start": "nest start",
"serve": "nest start --watch",
"start:dev": "nest start --watch",
"debug": "nest start --debug --watch",
"prod": "TS_NODE_BASEURL=./dist node -r tsconfig-paths/register dist/src/main",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"lint": "eslint \"{src,test}/**/*.ts\""
"lint": "eslint ."
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
@@ -29,6 +31,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.0",
"uuid": "^11.0.0"
},
@@ -52,7 +55,6 @@
"ts-jest": "^29.0.0",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.0",
"typescript-eslint": "^8.0.0"
}

31
pnpm-lock.yaml generated
View File

@@ -11,9 +11,6 @@ importers:
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^4.0.0
version: 4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.0.0
version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -62,6 +59,9 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
typeorm:
specifier: ^0.3.0
version: 0.3.30(mysql2@3.22.5(@types/node@24.13.2))(ts-node@10.9.2(@types/node@24.13.2)(typescript@5.9.3))
@@ -126,9 +126,6 @@ importers:
ts-node:
specifier: ^10.9.0
version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
typescript:
specifier: ^5.8.0
version: 5.9.3
@@ -710,12 +707,6 @@ packages:
class-validator:
optional: true
'@nestjs/config@4.0.4':
resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
rxjs: ^7.1.0
'@nestjs/core@11.1.27':
resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==}
engines: {node: '>= 20'}
@@ -1658,10 +1649,6 @@ packages:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -4112,14 +4099,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/config@4.0.4(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
dotenv: 17.4.1
dotenv-expand: 12.0.3
lodash: 4.18.1
rxjs: 7.8.2
'@nestjs/core@11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -5063,10 +5042,6 @@ snapshots:
diff@4.0.4: {}
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
dotenv@16.6.1: {}
dotenv@17.4.1: {}

View File

@@ -1,19 +1,11 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentAdmin } from './decorators/current-admin.decorator';
import { RequirePermissions } from './decorators/require-permissions.decorator';
import { AdminLoginDto } from './dto/admin-login.dto';
import { ChangeAdminPasswordDto } from './dto/change-admin-password.dto';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
import { CreatePermissionDto } from './dto/create-permission.dto';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
import { UpdateAdminRolesDto } from './dto/update-admin-roles.dto';
import { UpdateAdminStatusDto } from './dto/update-admin-status.dto';
import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto';
import { AdminJwtAuthGuard } from './guards/admin-jwt-auth.guard';
import { PermissionsGuard } from './guards/permissions.guard';
import { AdminUserRecord } from './interfaces/admin-records.interface';
import { CurrentAdmin } from '@app/decorators/current-admin.decorator';
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
import { AdminLoginDto, ChangeAdminPasswordDto, CreateAdminUserDto, CreatePermissionDto, CreateRoleDto, UpdateAdminProfileDto, UpdateAdminRolesDto, UpdateAdminStatusDto, UpdateRolePermissionsDto } from './admin.dto';
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '@app/guards/permissions.guard';
import { AdminUserRecord } from './admin.types';
import { AdminService } from './admin.service';
@ApiTags('Admin')

123
src/admin/admin.dto.ts Normal file
View File

@@ -0,0 +1,123 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsEmail, IsIn, IsInt, IsOptional, IsString, Min, MinLength } from "class-validator";
export class AdminLoginDto {
@ApiProperty()
@IsString()
username: string;
@ApiProperty()
@IsString()
@MinLength(6)
password: string;
}
export class ChangeAdminPasswordDto {
@ApiProperty()
@IsString()
oldPassword: string;
@ApiProperty()
@IsString()
@MinLength(6)
newPassword: string;
}
export class CreateAdminUserDto {
@ApiProperty()
@IsString()
username: string;
@ApiProperty()
@IsString()
@MinLength(6)
password: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[];
}
export class CreatePermissionDto {
@ApiProperty()
@IsString()
code: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}
export class CreateRoleDto {
@ApiProperty()
@IsString()
code: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[];
}
export class UpdateAdminProfileDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
}
export class UpdateAdminRolesDto {
@ApiProperty({ type: [Number] })
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds: number[];
}
export class UpdateAdminStatusDto {
@ApiProperty({ enum: ["active", "disabled"] })
@IsIn(["active", "disabled"])
status: "active" | "disabled";
}
export class UpdateRolePermissionsDto {
@ApiProperty({ type: [Number] })
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds: number[];
}

21
src/admin/admin.module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Global, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ADMIN_AUTH_CONFIG } from "@config/app.config";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from "@app/guards/permissions.guard";
@Global()
@Module({
imports: [
JwtModule.register({
secret: ADMIN_AUTH_CONFIG.jwtTokenSecret,
signOptions: { expiresIn: ADMIN_AUTH_CONFIG.expiresIn },
}),
],
controllers: [AdminController],
providers: [AdminService, AdminJwtAuthGuard, PermissionsGuard],
exports: [JwtModule, AdminService, AdminJwtAuthGuard, PermissionsGuard],
})
export class AdminModule {}

View File

@@ -11,21 +11,18 @@ import { JwtService } from '@nestjs/jwt';
import { InjectDataSource } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { AdminLoginDto } from './dto/admin-login.dto';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
import { CreatePermissionDto } from './dto/create-permission.dto';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
import { AdminUser } from './entities/admin-user.entity';
import { Permission } from './entities/permission.entity';
import { Role } from './entities/role.entity';
import { ADMIN_AUTH_CONFIG } from '@config/app.config';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { AdminLoginDto, CreateAdminUserDto, CreatePermissionDto, CreateRoleDto, UpdateAdminProfileDto } from './admin.dto';
import { AdminUser } from './admin-user.entity';
import { Permission } from './permission.entity';
import { Role } from './role.entity';
import {
AdminUserRecord,
AdminUserView,
PermissionRecord,
RoleRecord,
} from './interfaces/admin-records.interface';
} from './admin.types';
interface PaginationInput {
page: number;
@@ -414,9 +411,14 @@ export class AdminService implements OnModuleInit {
['admin-user:update', '更新人员'],
['user:read', '查看用户'],
['user:update', '更新用户'],
['channel:read', '查看频道'],
['channel:create', '创建频道'],
['channel:update', '更新频道'],
['channel:delete', '删除频道'],
['drama:read', '查看短剧'],
['drama:create', '创建短剧'],
['drama:update', '更新短剧'],
['drama:delete', '删除短剧'],
['episode:create', '创建剧集'],
['episode:update', '更新剧集'],
['media:read', '查看媒体'],
@@ -434,10 +436,10 @@ export class AdminService implements OnModuleInit {
const now = new Date().toISOString();
this.admins.push({
id: this.adminSequence++,
username: process.env.ADMIN_USERNAME ?? 'admin',
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
username: ADMIN_AUTH_CONFIG.username,
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
nickname: 'Super Admin',
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
email: ADMIN_AUTH_CONFIG.email,
status: 'active',
isSuperAdmin: true,
roleIds: [],
@@ -466,15 +468,15 @@ export class AdminService implements OnModuleInit {
}
const adminRepository = dataSource.getRepository(AdminUser);
const username = process.env.ADMIN_USERNAME ?? 'admin';
const username = ADMIN_AUTH_CONFIG.username;
const existingAdmin = await adminRepository.findOne({ where: { username } });
if (!existingAdmin) {
await adminRepository.save(
adminRepository.create({
username,
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
nickname: 'Super Admin',
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
email: ADMIN_AUTH_CONFIG.email,
status: 'active',
isSuperAdmin: true,
roleIds: [],

View File

@@ -1,31 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AdminModule } from "./modules/admin/admin.module";
import { AuthModule } from "./modules/auth/auth.module";
import { DataModule } from "./modules/data/data.module";
import { DatabaseModule } from "./modules/database/database.module";
import { DramasModule } from "./modules/dramas/dramas.module";
import { HealthModule } from "./modules/health/health.module";
import { LogsModule } from "./modules/logs/logs.module";
import { MediaModule } from "./modules/media/media.module";
import { OrdersModule } from "./modules/orders/orders.module";
import { PlayerModule } from "./modules/player/player.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
DatabaseModule,
AdminModule,
HealthModule,
LogsModule,
DramasModule,
DataModule,
MediaModule,
AuthModule,
OrdersModule,
PlayerModule,
],
})
export class AppModule {}

18
src/app/app.controller.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* App controller.
* @file App 根控制器(健康检查)
* @module src/app/controller
* @author zhxiao1124
*/
import { Controller, Get } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
@ApiTags("Health")
@Controller("/api/app/v1/health")
export class AppController {
@Get()
getHealth() {
return { status: "ok" };
}
}

73
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* App module.
* @file App 主模块
* @module src/app/module
* @author zhxiao1124
*/
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { DB_CONFIG } from "@config/app.config";
import { AppController } from "@src/app/app.controller";
// 业务模块
import { AdminModule } from "@src/admin/admin.module";
import { AuthModule } from "@src/auth/auth.module";
import { UsersModule } from "@src/users/users.module";
import { DramasModule } from "@src/dramas/dramas.module";
import { DataModule } from "@src/data/data.module";
import { MediaModule } from "@src/media/media.module";
import { OrdersModule } from "@src/orders/orders.module";
import { PlayerModule } from "@src/player/player.module";
import { HistoryModule } from "@src/history/history.module";
import { LogsModule } from "@src/logs/logs.module";
import { ChannelsModule } from "@src/channels/channels.module";
// 数据库实体
import { AdminUser } from "@src/admin/admin-user.entity";
import { Permission } from "@src/admin/permission.entity";
import { Role } from "@src/admin/role.entity";
import { DataSyncJob } from "@src/data/data-sync-job.entity";
import { DramaMetric } from "@src/data/drama-metric.entity";
import { EpisodeMetric } from "@src/data/episode-metric.entity";
import { Drama } from "@src/dramas/drama.entity";
import { Episode } from "@src/dramas/episode.entity";
import { PlaybackErrorLog } from "@src/logs/playback-error-log.entity";
import { RequestLog } from "@src/logs/request-log.entity";
import { Cover } from "@src/media/cover.entity";
import { MediaUploadJob } from "@src/media/media-upload-job.entity";
import { Order } from "@src/orders/order.entity";
import { UserEpisodeUnlock } from "@src/orders/user-episode-unlock.entity";
import { UserPlaybackHistory } from "@src/history/user-playback-history.entity";
import { User } from "@src/users/user.entity";
import { Channel } from "@src/channels/channel.entity";
// 未配置 DB_HOST 时(如 e2e 测试)退回内存模式,不装配 TypeORM
const databaseImports = DB_CONFIG.host
? [
TypeOrmModule.forRoot({
...DB_CONFIG,
entities: [AdminUser, Permission, Role, DataSyncJob, DramaMetric, EpisodeMetric, Drama, Episode, PlaybackErrorLog, RequestLog, Cover, MediaUploadJob, Order, UserEpisodeUnlock, UserPlaybackHistory, User, Channel],
}),
]
: [];
@Module({
imports: [
...databaseImports,
AdminModule, // 管理端(人员/角色/权限)
LogsModule, // 请求与播放错误日志
ChannelsModule, // 频道管理
DramasModule, // 短剧与剧集
DataModule, // 数据指标与同步
MediaModule, // 媒体上传与封面
AuthModule, // 用户端认证
UsersModule, // 用户
OrdersModule, // 订单与解锁
PlayerModule, // 播放器
HistoryModule, // 播放历史
],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { TikTokLoginDto } from './dto/tiktok-login.dto';
import { TikTokLoginDto } from './auth.dto';
@ApiTags('Auth')
@Controller('/api/app/v1/auth')

21
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { AUTH_CONFIG } from "@config/app.config";
import { UsersModule } from "@src/users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "@app/guards/jwt-auth.guard";
@Module({
imports: [
UsersModule,
JwtModule.register({
secret: AUTH_CONFIG.jwtTokenSecret,
signOptions: { expiresIn: AUTH_CONFIG.expiresIn },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [JwtModule, JwtAuthGuard],
})
export class AuthModule {}

View File

@@ -1,7 +1,7 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { TikTokLoginDto } from './dto/tiktok-login.dto';
import { UsersService } from '@src/users/users.service';
import { TikTokLoginDto } from './auth.dto';
@Injectable()
export class AuthService {

View File

@@ -1,17 +1,20 @@
/**
* App bootstrap.
* @file 应用全局装配(管道/过滤器/拦截器/文档),供 main 与 e2e 测试共用
* @module src/bootstrap
* @author zhxiao1124
*/
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { RequestLoggingInterceptor } from "./common/interceptors/request-logging.interceptor";
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
import { RequestIdMiddleware } from "./common/middleware/request-id.middleware";
import { LogsService } from "./modules/logs/logs.service";
import { HttpExceptionFilter } from "@app/filters/http.filter";
import { LoggerInterceptor } from "@app/interceptor/logger.interceptor";
import { ResponseInterceptor } from "@app/interceptor/response.interceptor";
import { RequestIdMiddleware } from "@app/middleware/request-id.middleware";
import { LogsService } from "@src/logs/logs.service";
export function bootstrapApp(app: INestApplication) {
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(","),
credentials: true,
});
app.use(RequestIdMiddleware);
app.useGlobalPipes(
new ValidationPipe({
@@ -24,11 +27,11 @@ export function bootstrapApp(app: INestApplication) {
const logsService = app.get(LogsService);
app.useGlobalFilters(new HttpExceptionFilter(logsService));
app.useGlobalInterceptors(new RequestLoggingInterceptor(logsService, app.get(Reflector)), new ResponseInterceptor());
app.useGlobalInterceptors(new LoggerInterceptor(logsService, app.get(Reflector)), new ResponseInterceptor());
const config = new DocumentBuilder().setTitle("TikTok Short Drama Backend").setDescription("RESTful API for TikTok short drama backend services.").setVersion("1.0").addBearerAuth().build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/api/docs", app, document);
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type ChannelStatus = 'active' | 'disabled';
@Entity('channels')
export class Channel {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 128 })
name: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 128 })
code: string;
@Column({ type: 'varchar', length: 512, nullable: true })
description?: string;
@Column({ type: 'int', default: 0 })
sortOrder: number;
@Column({ type: 'boolean', default: false })
isDefault: boolean;
@Column({ type: 'varchar', length: 32, default: 'active' })
status: ChannelStatus;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,48 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '@app/guards/permissions.guard';
import { CreateChannelDto, UpdateChannelDto } from './channels.dto';
import { ChannelsService } from './channels.service';
@ApiTags('Channels')
@Controller('/api/admin/v1/channels')
export class ChannelsController {
constructor(private readonly channelsService: ChannelsService) {}
@Get()
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('channel:read')
listChannels(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.channelsService.listChannels({
page: Number(page) || 1,
pageSize: Number(pageSize) || 100,
});
}
@Post()
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('channel:create')
createChannel(@Body() dto: CreateChannelDto) {
return this.channelsService.createChannel(dto);
}
@Patch('/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('channel:update')
updateChannel(@Param('id') id: string, @Body() dto: UpdateChannelDto) {
return this.channelsService.updateChannel(Number(id), dto);
}
@Delete('/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('channel:delete')
deleteChannel(@Param('id') id: string) {
return this.channelsService.deleteChannel(Number(id));
}
}

View File

@@ -0,0 +1,36 @@
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { ChannelStatus } from './channels.types';
export class CreateChannelDto {
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
code: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ enum: ['active', 'disabled'] })
@IsOptional()
@IsIn(['active', 'disabled'])
status?: ChannelStatus;
}
export class UpdateChannelDto extends PartialType(CreateChannelDto) {}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '@src/admin/admin.module';
import { ChannelsController } from './channels.controller';
import { ChannelsService } from './channels.service';
@Module({
imports: [AdminModule],
controllers: [ChannelsController],
providers: [ChannelsService],
exports: [ChannelsService],
})
export class ChannelsModule {}

View File

@@ -0,0 +1,316 @@
import {
ConflictException,
Injectable,
NotFoundException,
OnModuleInit,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { Drama } from '@src/dramas/drama.entity';
import { Channel } from './channel.entity';
import { CreateChannelDto, UpdateChannelDto } from './channels.dto';
import { ChannelRecord } from './channels.types';
interface PaginationInput {
page: number;
pageSize: number;
}
const DEFAULT_CHANNEL = {
id: 1,
name: '默认频道',
code: 'default',
sortOrder: 0,
isDefault: true,
status: 'active' as const,
};
@Injectable()
export class ChannelsService implements OnModuleInit {
private channelSequence = 1;
private readonly channels: ChannelRecord[] = [];
constructor(
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {
if (!this.hasDatabase()) {
this.seedMemoryDefaultChannel();
}
}
async onModuleInit() {
if (this.hasDatabase()) {
await this.seedDatabaseDefaultChannel();
}
}
async listChannels(input: PaginationInput): Promise<PaginatedResponse<ChannelRecord>> {
if (this.hasDatabase()) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const [records, total] = await this.dataSource.getRepository(Channel).findAndCount({
order: { sortOrder: 'DESC', id: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toChannelRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate(this.sortChannels(this.channels), input);
}
async createChannel(dto: CreateChannelDto): Promise<ChannelRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Channel);
if (await repository.findOne({ where: { code: dto.code } })) {
throw new ConflictException('Channel code already exists');
}
if (dto.isDefault) {
await repository.update({ isDefault: true }, { isDefault: false });
}
const saved = await repository.save(
repository.create({
name: dto.name,
code: dto.code,
description: dto.description,
sortOrder: dto.sortOrder ?? 0,
isDefault: dto.isDefault ?? false,
status: dto.status ?? 'active',
}),
);
return this.toChannelRecord(saved);
}
if (this.channels.some((channel) => channel.code === dto.code)) {
throw new ConflictException('Channel code already exists');
}
if (dto.isDefault) {
this.channels.forEach((channel) => {
channel.isDefault = false;
channel.updatedAt = new Date().toISOString();
});
}
const now = new Date().toISOString();
const channel: ChannelRecord = {
id: ++this.channelSequence,
name: dto.name,
code: dto.code,
description: dto.description,
sortOrder: dto.sortOrder ?? 0,
isDefault: dto.isDefault ?? false,
status: dto.status ?? 'active',
createdAt: now,
updatedAt: now,
};
this.channels.push(channel);
return channel;
}
async updateChannel(id: number, dto: UpdateChannelDto): Promise<ChannelRecord> {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Channel);
const channel = await repository.findOne({ where: { id } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
this.assertDefaultChannelCanChange(channel, dto);
if (dto.code && dto.code !== channel.code && (await repository.findOne({ where: { code: dto.code } }))) {
throw new ConflictException('Channel code already exists');
}
if (dto.isDefault) {
await repository.update({ isDefault: true }, { isDefault: false });
}
repository.merge(channel, {
...dto,
sortOrder: dto.sortOrder ?? channel.sortOrder,
status: dto.status ?? channel.status,
});
return this.toChannelRecord(await repository.save(channel));
}
const channel = this.channels.find((item) => item.id === id);
if (!channel) {
throw new NotFoundException('Channel not found');
}
this.assertDefaultChannelCanChange(channel, dto);
if (dto.code && dto.code !== channel.code && this.channels.some((item) => item.code === dto.code)) {
throw new ConflictException('Channel code already exists');
}
if (dto.isDefault) {
this.channels.forEach((item) => {
item.isDefault = false;
item.updatedAt = new Date().toISOString();
});
}
Object.assign(channel, {
...dto,
sortOrder: dto.sortOrder ?? channel.sortOrder,
status: dto.status ?? channel.status,
updatedAt: new Date().toISOString(),
});
return channel;
}
async deleteChannel(id: number) {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Channel);
const channel = await repository.findOne({ where: { id } });
if (!channel) {
throw new NotFoundException('Channel not found');
}
this.assertChannelCanBeDeleted(channel);
const dramaCount = await this.dataSource.getRepository(Drama).count({
where: { channelId: id },
});
if (dramaCount > 0) {
throw new UnprocessableEntityException('Channel is used by dramas');
}
await repository.delete(id);
return { id };
}
const index = this.channels.findIndex((channel) => channel.id === id);
if (index < 0) {
throw new NotFoundException('Channel not found');
}
this.assertChannelCanBeDeleted(this.channels[index]);
this.channels.splice(index, 1);
return { id };
}
async getDefaultChannel(): Promise<ChannelRecord> {
if (this.hasDatabase()) {
await this.seedDatabaseDefaultChannel();
const channel = await this.dataSource.getRepository(Channel).findOne({
where: { isDefault: true },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
return this.toChannelRecord(channel);
}
this.seedMemoryDefaultChannel();
return this.channels.find((channel) => channel.isDefault) as ChannelRecord;
}
async getActiveChannelOrThrow(id: number): Promise<ChannelRecord> {
if (this.hasDatabase()) {
const channel = await this.dataSource.getRepository(Channel).findOne({
where: { id, status: 'active' },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
return this.toChannelRecord(channel);
}
const channel = this.channels.find((item) => item.id === id && item.status === 'active');
if (!channel) {
throw new NotFoundException('Channel not found');
}
return channel;
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private seedMemoryDefaultChannel() {
if (this.channels.some((channel) => channel.code === DEFAULT_CHANNEL.code)) {
return;
}
const now = new Date().toISOString();
this.channels.push({
...DEFAULT_CHANNEL,
createdAt: now,
updatedAt: now,
});
this.channelSequence = DEFAULT_CHANNEL.id;
}
private async seedDatabaseDefaultChannel() {
const repository = this.dataSource!.getRepository(Channel);
const existing = await repository.findOne({ where: { code: DEFAULT_CHANNEL.code } });
if (!existing) {
await repository.save(repository.create(DEFAULT_CHANNEL));
return;
}
let changed = false;
if (!existing.isDefault) {
existing.isDefault = true;
changed = true;
}
if (existing.status !== 'active') {
existing.status = 'active';
changed = true;
}
if (changed) {
await repository.save(existing);
}
}
private assertDefaultChannelCanChange(channel: Channel | ChannelRecord, dto: UpdateChannelDto) {
if (channel.isDefault && dto.status === 'disabled') {
throw new UnprocessableEntityException('Default channel cannot be disabled');
}
if (channel.isDefault && dto.isDefault === false) {
throw new UnprocessableEntityException('Default channel cannot be unset');
}
}
private assertChannelCanBeDeleted(channel: Channel | ChannelRecord) {
if (channel.isDefault) {
throw new UnprocessableEntityException('Default channel cannot be deleted');
}
}
private toChannelRecord(channel: Channel): ChannelRecord {
return {
id: Number(channel.id),
name: channel.name,
code: channel.code,
description: channel.description,
sortOrder: channel.sortOrder,
isDefault: channel.isDefault,
status: channel.status,
createdAt: this.toIso(channel.createdAt),
updatedAt: this.toIso(channel.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private sortChannels(records: ChannelRecord[]) {
return [...records].sort((left, right) => {
if (right.sortOrder !== left.sortOrder) {
return right.sortOrder - left.sortOrder;
}
return left.id - right.id;
});
}
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
return {
list: records.slice(start, start + pageSize),
pagination: { page, pageSize, total: records.length },
};
}
}

View File

@@ -0,0 +1,13 @@
export type ChannelStatus = 'active' | 'disabled';
export interface ChannelRecord {
id: number;
name: string;
code: string;
description?: string;
sortOrder: number;
isDefault: boolean;
status: ChannelStatus;
createdAt: string;
updatedAt: string;
}

View File

@@ -1 +0,0 @@
export const SUCCESS_MESSAGE = 'success';

View File

@@ -1,6 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const API_MESSAGE_METADATA = 'api:message';
export const ApiMessage = (message: string) =>
SetMetadata(API_MESSAGE_METADATA, message);

View File

@@ -1,10 +0,0 @@
export interface PaginationMeta {
page: number;
pageSize: number;
total: number;
}
export interface PaginatedResponse<T> {
list: T[];
pagination: PaginationMeta;
}

View File

@@ -1,79 +0,0 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { LogsService } from '../../modules/logs/logs.service';
import { getRequestCost } from '../utils/request-context.util';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logsService: LogsService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<
Request & { requestId?: string; adminId?: number; user?: { id: number } }
>();
const response = ctx.getResponse<Response>();
const statusCode =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = this.getMessage(exception, statusCode);
const requestId = request.requestId ?? '';
const costMs = getRequestCost(request);
this.logsService.recordRequest({
requestId,
method: request.method,
path: request.originalUrl ?? request.url,
query: request.query,
body: request.body,
statusCode,
responseCode: statusCode,
message,
costMs,
userId: request.user?.id,
adminId: request.adminId,
ip: request.ip,
userAgent: request.get('user-agent'),
errorStack: exception instanceof Error ? exception.stack : undefined,
});
response.status(statusCode).json({
code: statusCode,
data: {},
message,
timestamp: Date.now(),
requestId,
cost: `${costMs}ms`,
});
}
private getMessage(exception: unknown, statusCode: number): string {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === 'string') {
return response;
}
if (
response &&
typeof response === 'object' &&
'message' in response
) {
const message = (response as { message: string | string[] }).message;
return Array.isArray(message) ? message.join('; ') : message;
}
}
if (exception instanceof Error && statusCode === HttpStatus.INTERNAL_SERVER_ERROR) {
return exception.message || 'Internal server error';
}
return 'Internal server error';
}
}

View File

@@ -1,55 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
import {
API_MESSAGE_METADATA,
} from '../decorators/api-message.decorator';
import { SUCCESS_MESSAGE } from '../constants/response-message.constants';
import { getRequestCost } from '../utils/request-context.util';
import { LogsService } from '../../modules/logs/logs.service';
@Injectable()
export class RequestLoggingInterceptor implements NestInterceptor {
constructor(
private readonly logsService: LogsService,
private readonly reflector: Reflector,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<
Request & { requestId?: string; adminId?: number; user?: { id: number } }
>();
const response = http.getResponse<Response>();
const message =
this.reflector.get<string>(API_MESSAGE_METADATA, context.getHandler()) ??
SUCCESS_MESSAGE;
return next.handle().pipe(
tap(() => {
this.logsService.recordRequest({
requestId: request.requestId ?? '',
method: request.method,
path: request.originalUrl ?? request.url,
query: request.query,
body: request.body,
statusCode: response.statusCode,
responseCode: 0,
message,
costMs: getRequestCost(request),
userId: request.user?.id,
adminId: request.adminId,
ip: request.ip,
userAgent: request.get('user-agent'),
errorStack: undefined,
});
}),
);
}
}

View File

@@ -1,39 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { map, Observable } from 'rxjs';
import { SUCCESS_MESSAGE } from '../constants/response-message.constants';
import {
API_MESSAGE_METADATA,
} from '../decorators/api-message.decorator';
import { getRequestCost } from '../utils/request-context.util';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
constructor(private readonly reflector = new Reflector()) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context
.switchToHttp()
.getRequest<Request & { requestId?: string }>();
const message =
this.reflector.get<string>(API_MESSAGE_METADATA, context.getHandler()) ??
SUCCESS_MESSAGE;
return next.handle().pipe(
map((data) => ({
code: 0,
data: data ?? {},
message,
timestamp: Date.now(),
requestId: request.requestId,
cost: `${getRequestCost(request)}ms`,
})),
);
}
}

View File

@@ -1,21 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { validate as validateUuid, version as uuidVersion, v4 as uuidv4 } from 'uuid';
export function RequestIdMiddleware(
request: Request & { requestId?: string; startTime?: bigint },
response: Response,
next: NextFunction,
) {
const incomingRequestId = request.get('x-request-id');
const requestId =
incomingRequestId &&
validateUuid(incomingRequestId) &&
uuidVersion(incomingRequestId) === 4
? incomingRequestId
: uuidv4();
request.requestId = requestId;
request.startTime = process.hrtime.bigint();
response.setHeader('X-Request-Id', requestId);
next();
}

View File

@@ -1,10 +0,0 @@
import { Request } from 'express';
export function getRequestCost(request: Request & { startTime?: bigint }) {
if (!request.startTime) {
return 0;
}
const diff = process.hrtime.bigint() - request.startTime;
return Number(diff / BigInt(1_000_000));
}

View File

@@ -1,8 +1,8 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '../admin/guards/permissions.guard';
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '@app/guards/permissions.guard';
import { DataService } from './data.service';
@ApiTags('Data')

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { AdminModule } from '@src/admin/admin.module';
import { DramasModule } from '@src/dramas/dramas.module';
import { DataController } from './data.controller';
import { DataService } from './data.service';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
import { TikTokDataProvider } from './data.provider';
@Module({
imports: [AdminModule, DramasModule],

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DramaRecord, EpisodeRecord } from '../../dramas/interfaces/drama-records.interface';
import { DramaRecord, EpisodeRecord } from '@src/dramas/dramas.types';
@Injectable()
export class TikTokDataProvider {

View File

@@ -1,12 +1,12 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { DataSyncJob } from './entities/data-sync-job.entity';
import { DramaMetric } from './entities/drama-metric.entity';
import { EpisodeMetric } from './entities/episode-metric.entity';
import { TikTokDataProvider } from './providers/tiktok-data.provider';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { DramasService } from '@src/dramas/dramas.service';
import { DataSyncJob } from './data-sync-job.entity';
import { DramaMetric } from './drama-metric.entity';
import { EpisodeMetric } from './episode-metric.entity';
import { TikTokDataProvider } from './data.provider';
interface PaginationInput {
page: number;

View File

@@ -5,7 +5,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { PublishStatus } from '../drama-status.enum';
import { PublishStatus } from './dramas.types';
@Entity('dramas')
export class Drama {
@@ -24,6 +24,9 @@ export class Drama {
@Column({ type: 'int', nullable: true })
coverId?: number;
@Column({ type: 'int', nullable: true })
channelId?: number;
@Column({ type: 'varchar', length: 1024 })
coverUrl: string;
@@ -33,7 +36,7 @@ export class Drama {
@Column({ type: 'varchar', length: 1024, nullable: true })
landscapeCoverUrl?: string;
@Column({ type: 'varchar', length: 128 })
@Column({ type: 'varchar', length: 128, default: '' })
type: string;
@Column({ type: 'json', nullable: true })

View File

@@ -1,15 +1,11 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from "../admin/guards/permissions.guard";
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
import { CreateDramaDto } from "./dto/create-drama.dto";
import { CreateEpisodeDto } from "./dto/create-episode.dto";
import { UpdateDramaDto } from "./dto/update-drama.dto";
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from "@app/guards/permissions.guard";
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
import { DramasService } from "./dramas.service";
import { PublishStatus } from "./drama-status.enum";
import { PublishStatus } from "./dramas.types";
@ApiTags("Dramas")
@Controller()
@@ -28,10 +24,12 @@ export class DramasController {
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:read")
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
return this.dramasService.listAdminDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
title,
isRecommended: this.parseOptionalBoolean(isRecommended),
});
}
@@ -51,6 +49,14 @@ export class DramasController {
return this.dramasService.updateDrama(Number(id), dto);
}
@Delete("/api/admin/v1/dramas/:id")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions("drama:delete")
deleteDrama(@Param("id") id: string) {
return this.dramasService.deleteDrama(Number(id));
}
@Post("/api/admin/v1/dramas/:id/episodes/batch")
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@@ -119,6 +125,23 @@ export class DramasController {
});
}
@Get("/api/app/v1/dramas/recommended")
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listRecommendedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10
});
}
@Get("/api/app/v1/dramas/presearch")
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,
});
}
@Get("/api/app/v1/dramas/:id/episodes")
listPublishedEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listPublishedEpisodes(Number(id), {
@@ -131,4 +154,14 @@ export class DramasController {
getPublishedDrama(@Param("id") id: string) {
return this.dramasService.getPublishedDramaOrThrow(Number(id));
}
private parseOptionalBoolean(value?: string) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return undefined;
}
}

247
src/dramas/dramas.dto.ts Normal file
View File

@@ -0,0 +1,247 @@
import { PartialType } from "@nestjs/mapped-types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUrl, Max, Min } from "class-validator";
import { PublishStatus } from "./dramas.types";
export class CreateDramaDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
tiktokAlbumId?: string;
@ApiProperty()
@IsString()
title: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiProperty()
@IsOptional()
@IsUrl({ require_tld: false })
coverUrl: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
coverId?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
channelId?: number;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_tld: false })
portraitCoverUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_tld: false })
landscapeCoverUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
type?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
region?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1900)
year?: number;
@ApiPropertyOptional({ enum: PublishStatus })
@IsOptional()
@IsEnum(PublishStatus)
status?: PublishStatus;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
sortOrder?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isRecommended?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
totalEpisodes?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
onlineVersion?: number;
}
export class UpdateDramaDto extends PartialType(CreateDramaDto) {}
export class CreateEpisodeDto {
@ApiProperty()
@IsOptional()
@IsInt()
@Min(1)
dramaId?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
albumId?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tiktokEpisodeId?: string;
@ApiProperty()
@IsOptional()
@IsInt()
@Min(1)
episodeNo?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
seq?: number;
@ApiProperty()
@IsString()
title: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiProperty()
@IsOptional()
@IsUrl({ require_tld: false })
coverUrl: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
coverId?: number;
@ApiProperty()
@IsOptional()
@IsUrl({ require_tld: false })
videoUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
byteplusVid?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
trialDurationSeconds?: number;
@ApiProperty()
@IsInt()
@Min(1)
durationSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
width?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
height?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isPaid?: boolean;
@ApiPropertyOptional({ enum: ["free", "paid"] })
@IsOptional()
@IsString()
freeType?: "free" | "paid";
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
unlockPrice?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
price?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
publishAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
nextReleaseAt?: string;
@ApiPropertyOptional({ enum: PublishStatus })
@IsOptional()
@IsEnum(PublishStatus)
status?: PublishStatus;
@ApiPropertyOptional({
enum: ["not_submitted", "pending", "reviewing", "approved", "rejected"],
})
@IsOptional()
@IsString()
reviewStatus?: "not_submitted" | "pending" | "reviewing" | "approved" | "rejected";
@ApiPropertyOptional({ enum: PublishStatus })
@IsOptional()
@IsEnum(PublishStatus)
publishStatus?: PublishStatus;
}
export class UpdateEpisodeDto extends PartialType(CreateEpisodeDto) {}
export class ConfigureEpisodesDto {
@ApiProperty()
@IsInt()
@Min(1)
@Max(500)
episodeCount: number;
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { AdminModule } from '@src/admin/admin.module';
import { ChannelsModule } from '@src/channels/channels.module';
import { DramasController } from './dramas.controller';
import { DramasService } from './dramas.service';
@Module({
imports: [AdminModule],
imports: [AdminModule, ChannelsModule],
controllers: [DramasController],
providers: [DramasService],
exports: [DramasService],

1047
src/dramas/dramas.service.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
export enum PublishStatus {
Draft = "draft",
Scheduled = "scheduled",
Published = "published",
Offline = "offline",
}
export interface DramaRecord {
id: number;
tiktokAlbumId?: string;
title: string;
description?: string;
coverId?: number;
channelId?: number;
channelName?: string;
coverUrl: string;
portraitCoverUrl?: string;
landscapeCoverUrl?: string;
type: string;
tags: string[];
region?: string;
language?: string;
year?: number;
status: PublishStatus;
sortOrder: number;
isRecommended: boolean;
onlineVersion: number;
totalEpisodes: number;
publishedEpisodes: number;
plays: number;
createdAt: string;
updatedAt: string;
}
export interface EpisodeRecord {
id: number;
dramaId: number;
albumId: number;
tiktokEpisodeId?: string;
episodeNo: number;
seq: number;
title: string;
description?: string;
byteplusVid?: string;
coverId?: number;
coverUrl: string;
videoUrl?: string;
storageBucket?: string;
objectKey?: string;
trialDurationSeconds?: number;
durationSeconds: number;
width?: number;
height?: number;
isPaid: boolean;
freeType: "free" | "paid";
unlockPrice?: number;
price: number;
publishAt?: string;
nextReleaseAt?: string;
status: PublishStatus;
reviewStatus: "not_submitted" | "pending" | "reviewing" | "approved" | "rejected";
reviewMessage?: string;
publishStatus: PublishStatus;
createdAt: string;
updatedAt: string;
}

View File

@@ -6,7 +6,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { PublishStatus } from '../drama-status.enum';
import { PublishStatus } from './dramas.types';
@Entity('episodes')
@Index(['dramaId', 'episodeNo'], { unique: true })

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@app/decorators/current-user.decorator';
import { JwtAuthGuard } from '@app/guards/jwt-auth.guard';
import { UserRecord } from '@src/users/users.types';
import { UpsertPlaybackHistoryDto } from './history.dto';
import { HistoryService } from './history.service';
@ApiTags('History')
@Controller('/api/app/v1/history')
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
@Post()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
upsertHistory(
@CurrentUser() user: UserRecord,
@Body() dto: UpsertPlaybackHistoryDto,
) {
return this.historyService.upsertHistory(user, dto);
}
@Get()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
listHistory(
@CurrentUser() user: UserRecord,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.historyService.listHistory(user, {
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpsertPlaybackHistoryDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsInt()
@Min(0)
progressSeconds: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
completed?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
occurredAt?: string;
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '@src/auth/auth.module';
import { DramasModule } from '@src/dramas/dramas.module';
import { UsersModule } from '@src/users/users.module';
import { HistoryController } from './history.controller';
import { HistoryService } from './history.service';
@Module({
imports: [AuthModule, DramasModule, UsersModule],
controllers: [HistoryController],
providers: [HistoryService],
})
export class HistoryModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { DramasService } from '@src/dramas/dramas.service';
import {
DramaRecord,
EpisodeRecord,
} from '@src/dramas/dramas.types';
import { UserRecord } from '@src/users/users.types';
import { UpsertPlaybackHistoryDto } from './history.dto';
import { UserPlaybackHistory } from './user-playback-history.entity';
import {
PlaybackHistoryListItem,
PlaybackHistoryRecord,
} from './history.types';
interface PaginationInput {
page: number;
pageSize: number;
}
@Injectable()
export class HistoryService {
private historySequence = 1;
private readonly histories: PlaybackHistoryRecord[] = [];
constructor(
private readonly dramasService: DramasService,
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async upsertHistory(
user: UserRecord,
dto: UpsertPlaybackHistoryDto,
): Promise<PlaybackHistoryRecord> {
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
dto.episodeId,
);
const progressSeconds = this.resolveProgress(
dto.progressSeconds,
episode.durationSeconds,
);
const completed =
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
const lastPlayedAt = dto.occurredAt
? new Date(dto.occurredAt)
: new Date();
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const existing = await repository.findOne({
where: { userId: user.id, episodeId: episode.id },
});
const entity =
existing ??
repository.create({
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
});
entity.dramaId = episode.dramaId;
entity.progressSeconds = progressSeconds;
entity.durationSeconds = episode.durationSeconds;
entity.completed = completed;
entity.lastPlayedAt = lastPlayedAt;
return this.toRecord(await repository.save(entity));
}
const now = new Date().toISOString();
const existing = this.histories.find(
(item) => item.userId === user.id && item.episodeId === episode.id,
);
if (existing) {
existing.dramaId = episode.dramaId;
existing.progressSeconds = progressSeconds;
existing.durationSeconds = episode.durationSeconds;
existing.completed = completed;
existing.lastPlayedAt = lastPlayedAt.toISOString();
existing.updatedAt = now;
return existing;
}
const record: PlaybackHistoryRecord = {
id: this.historySequence++,
userId: user.id,
dramaId: episode.dramaId,
episodeId: episode.id,
progressSeconds,
durationSeconds: episode.durationSeconds,
completed,
lastPlayedAt: lastPlayedAt.toISOString(),
createdAt: now,
updatedAt: now,
};
this.histories.push(record);
return record;
}
async listHistory(
user: UserRecord,
pagination: PaginationInput,
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
const repository = this.dataSource.getRepository(UserPlaybackHistory);
const [records, total] = await repository.findAndCount({
where: { userId: user.id },
order: { lastPlayedAt: 'DESC', id: 'DESC' },
skip,
take,
});
const list = await Promise.all(
records.map((record) => this.toListItem(this.toRecord(record))),
);
return { list, pagination: { page, pageSize, total } };
}
const page = this.paginate(
this.histories
.filter((item) => item.userId === user.id)
.sort((left, right) => {
const timeDiff =
new Date(right.lastPlayedAt).getTime() -
new Date(left.lastPlayedAt).getTime();
return timeDiff || right.id - left.id;
}),
pagination,
);
return {
...page,
list: await Promise.all(
page.list.map((record) => this.toListItem(record)),
),
};
}
private async toListItem(
record: PlaybackHistoryRecord,
): Promise<PlaybackHistoryListItem> {
const [drama, episode] = await Promise.all([
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
]);
return this.mergeListItem(record, drama, episode);
}
private mergeListItem(
record: PlaybackHistoryRecord,
drama: DramaRecord,
episode: EpisodeRecord,
): PlaybackHistoryListItem {
return {
id: record.id,
dramaId: record.dramaId,
episodeId: record.episodeId,
episodeNo: episode.episodeNo,
title: episode.title,
coverUrl: episode.coverUrl,
progressSeconds: record.progressSeconds,
durationSeconds: record.durationSeconds,
completed: record.completed,
lastPlayedAt: record.lastPlayedAt,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private resolveProgress(progressSeconds: number, durationSeconds: number) {
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
return {
id: Number(entity.id),
userId: Number(entity.userId),
dramaId: Number(entity.dramaId),
episodeId: Number(entity.episodeId),
progressSeconds: entity.progressSeconds,
durationSeconds: entity.durationSeconds,
completed: entity.completed,
lastPlayedAt: this.toIso(entity.lastPlayedAt),
createdAt: this.toIso(entity.createdAt),
updatedAt: this.toIso(entity.updatedAt),
};
}
private toIso(value?: Date | string): string {
if (!value) {
return new Date().toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
private resolvePagination(input: PaginationInput) {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const { page, pageSize, skip, take } = this.resolvePagination(input);
return {
list: records.slice(skip, skip + take),
pagination: { page, pageSize, total: records.length },
};
}
}

View File

@@ -0,0 +1,27 @@
export interface PlaybackHistoryRecord {
id: number;
userId: number;
dramaId: number;
episodeId: number;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
createdAt: string;
updatedAt: string;
}
export interface PlaybackHistoryListItem {
id: number;
dramaId: number;
episodeId: number;
episodeNo: number;
title: string;
coverUrl: string;
progressSeconds: number;
durationSeconds: number;
completed: boolean;
lastPlayedAt: string;
dramaTitle: string;
dramaCoverUrl: string;
}

View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_playback_histories')
@Index(['userId', 'episodeId'], { unique: true })
@Index(['userId', 'lastPlayedAt'])
export class UserPlaybackHistory {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'bigint' })
userId: number;
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'int', default: 0 })
progressSeconds: number;
@Column({ type: 'int', default: 0 })
durationSeconds: number;
@Column({ type: 'boolean', default: false })
completed: boolean;
@Column({ type: 'datetime' })
lastPlayedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiMessage } from '@app/decorators/api-message.decorator';
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
import { PermissionsGuard } from '@app/guards/permissions.guard';
import { CreatePlaybackErrorLogDto } from './logs.dto';
import { LogsService } from './logs.service';
@ApiTags('Logs')
@Controller()
export class LogsController {
constructor(private readonly logsService: LogsService) {}
@Get('/api/admin/v1/logs/requests')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('log:read')
listRequestLogs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.logsService.listRequestLogs({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/api/admin/v1/logs/requests/:requestId')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('log:read')
getRequestLog(@Param('requestId') requestId: string) {
return this.logsService.getRequestLogOrThrow(requestId);
}
@Post('/api/app/v1/logs/playback-errors')
@ApiMessage('success')
createPlaybackError(@Body() dto: CreatePlaybackErrorLogDto) {
return this.logsService.createPlaybackError(dto);
}
@Get('/api/admin/v1/logs/playback-errors')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('log:read')
listPlaybackErrors(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.logsService.listPlaybackErrors({
page: Number(page) || 1,
pageSize: Number(pageSize) || 20,
});
}
@Get('/api/admin/v1/logs/playback-errors/:id')
@ApiBearerAuth()
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
@RequirePermissions('log:read')
getPlaybackError(@Param('id') id: string) {
return this.logsService.getPlaybackErrorOrThrow(Number(id));
}
}

62
src/logs/logs.dto.ts Normal file
View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUrl,
IsUUID,
Min,
} from 'class-validator';
export class CreatePlaybackErrorLogDto {
@ApiProperty()
@IsUUID(4)
requestId: string;
@ApiProperty()
@IsInt()
@Min(1)
dramaId: number;
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsUrl({ require_tld: false })
playUrl: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
playerErrorCode: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
message: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
progressSeconds?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
networkType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsObject()
device?: Record<string, unknown>;
@ApiProperty()
@IsDateString()
occurredAt: string;
}

12
src/logs/logs.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '@src/admin/admin.module';
import { LogsController } from './logs.controller';
import { LogsService } from './logs.service';
@Module({
imports: [AdminModule],
controllers: [LogsController],
providers: [LogsService],
exports: [LogsService],
})
export class LogsModule {}

240
src/logs/logs.service.ts Normal file
View File

@@ -0,0 +1,240 @@
import { Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { CreatePlaybackErrorLogDto } from './logs.dto';
import { PlaybackErrorLog } from './playback-error-log.entity';
import { RequestLog } from './request-log.entity';
import {
PlaybackErrorLogRecord,
RequestLogRecord,
} from './logs.types';
interface PaginationInput {
page: number;
pageSize: number;
}
@Injectable()
export class LogsService {
private requestLogSequence = 1;
private playbackErrorSequence = 1;
private readonly requestLogs = new Map<string, RequestLogRecord>();
private readonly playbackErrors: PlaybackErrorLogRecord[] = [];
constructor(
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
) {}
async recordRequest(record: Omit<RequestLogRecord, 'id' | 'createdAt'>) {
if (!record.requestId) {
return;
}
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(RequestLog);
const existing = await repository.findOne({
where: { requestId: record.requestId },
});
await repository.save(
repository.create({
...existing,
...record,
}),
);
return;
}
this.requestLogs.set(record.requestId, {
...record,
id: this.requestLogs.get(record.requestId)?.id ?? this.requestLogSequence++,
createdAt:
this.requestLogs.get(record.requestId)?.createdAt ??
new Date().toISOString(),
});
}
async listRequestLogs(
pagination: PaginationInput,
): Promise<PaginatedResponse<RequestLogRecord>> {
if (this.hasDatabase()) {
const page = Math.max(pagination.page, 1);
const pageSize = Math.min(Math.max(pagination.pageSize, 1), 100);
const repository = this.dataSource.getRepository(RequestLog);
const [records, total] = await repository.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toRequestLogRecord(record)),
pagination: { page, pageSize, total },
};
}
const list = Array.from(this.requestLogs.values()).sort(
(left, right) => (right.id ?? 0) - (left.id ?? 0),
);
return this.paginate(list, pagination);
}
async getRequestLogOrThrow(requestId: string) {
if (this.hasDatabase()) {
const log = await this.dataSource
.getRepository(RequestLog)
.findOne({ where: { requestId } });
if (!log) {
throw new NotFoundException('Request log not found');
}
return this.toRequestLogRecord(log);
}
const log = this.requestLogs.get(requestId);
if (!log) {
throw new NotFoundException('Request log not found');
}
return log;
}
async createPlaybackError(dto: CreatePlaybackErrorLogDto) {
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(PlaybackErrorLog);
const log = await repository.save(
repository.create({
...dto,
occurredAt: new Date(dto.occurredAt),
}),
);
return {
id: Number(log.id),
requestId: log.requestId,
};
}
const log: PlaybackErrorLogRecord = {
id: this.playbackErrorSequence++,
requestId: dto.requestId,
dramaId: dto.dramaId,
episodeId: dto.episodeId,
playUrl: dto.playUrl,
playerErrorCode: dto.playerErrorCode,
message: dto.message,
progressSeconds: dto.progressSeconds,
networkType: dto.networkType,
device: dto.device,
occurredAt: dto.occurredAt,
createdAt: new Date().toISOString(),
};
this.playbackErrors.push(log);
return {
id: log.id,
requestId: log.requestId,
};
}
async listPlaybackErrors(
pagination: PaginationInput,
): Promise<PaginatedResponse<PlaybackErrorLogRecord>> {
if (this.hasDatabase()) {
const page = Math.max(pagination.page, 1);
const pageSize = Math.min(Math.max(pagination.pageSize, 1), 100);
const repository = this.dataSource.getRepository(PlaybackErrorLog);
const [records, total] = await repository.findAndCount({
order: { id: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
list: records.map((record) => this.toPlaybackErrorLogRecord(record)),
pagination: { page, pageSize, total },
};
}
return this.paginate([...this.playbackErrors].reverse(), pagination);
}
async getPlaybackErrorOrThrow(id: number) {
if (this.hasDatabase()) {
const log = await this.dataSource
.getRepository(PlaybackErrorLog)
.findOne({ where: { id } });
if (!log) {
throw new NotFoundException('Playback error log not found');
}
return this.toPlaybackErrorLogRecord(log);
}
const log = this.playbackErrors.find((item) => item.id === id);
if (!log) {
throw new NotFoundException('Playback error log not found');
}
return log;
}
private hasDatabase(): this is this & { dataSource: DataSource } {
return Boolean(this.dataSource?.isInitialized);
}
private toRequestLogRecord(log: RequestLog): RequestLogRecord {
return {
id: Number(log.id),
requestId: log.requestId,
method: log.method,
path: log.path,
query: log.query,
body: log.body,
statusCode: log.statusCode,
responseCode: log.responseCode,
message: log.message,
costMs: log.costMs,
userId: log.userId ? Number(log.userId) : undefined,
adminId: log.adminId ? Number(log.adminId) : undefined,
ip: log.ip,
userAgent: log.userAgent,
errorStack: log.errorStack,
createdAt: log.createdAt?.toISOString?.(),
};
}
private toPlaybackErrorLogRecord(log: PlaybackErrorLog): PlaybackErrorLogRecord {
return {
id: Number(log.id),
requestId: log.requestId,
userId: log.userId ? Number(log.userId) : undefined,
dramaId: Number(log.dramaId),
episodeId: Number(log.episodeId),
playUrl: log.playUrl,
playerErrorCode: log.playerErrorCode,
message: log.message,
progressSeconds: log.progressSeconds,
networkType: log.networkType,
device: log.device,
occurredAt: log.occurredAt.toISOString(),
createdAt: log.createdAt?.toISOString?.() ?? new Date().toISOString(),
};
}
private paginate<T>(
records: T[],
input: PaginationInput,
): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const start = (page - 1) * pageSize;
return {
list: records.slice(start, start + pageSize),
pagination: {
page,
pageSize,
total: records.length,
},
};
}
}

34
src/logs/logs.types.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface RequestLogRecord {
id?: number;
requestId: string;
method: string;
path: string;
query?: unknown;
body?: unknown;
statusCode: number;
responseCode: number;
message: string;
costMs: number;
userId?: number;
adminId?: number;
ip?: string;
userAgent?: string;
errorStack?: string;
createdAt?: string;
}
export interface PlaybackErrorLogRecord {
id: number;
requestId: string;
userId?: number;
dramaId: number;
episodeId: number;
playUrl: string;
playerErrorCode: string;
message: string;
progressSeconds?: number;
networkType?: string;
device?: Record<string, unknown>;
occurredAt: string;
createdAt: string;
}

View File

@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('playback_error_logs')
export class PlaybackErrorLog {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column({ type: 'char', length: 36 })
requestId: string;
@Column({ type: 'bigint', nullable: true })
userId?: number;
@Column({ type: 'bigint' })
dramaId: number;
@Column({ type: 'bigint' })
episodeId: number;
@Column({ type: 'varchar', length: 1024 })
playUrl: string;
@Column({ type: 'varchar', length: 128 })
playerErrorCode: string;
@Column({ type: 'varchar', length: 1024 })
message: string;
@Column({ type: 'int', nullable: true })
progressSeconds?: number;
@Column({ type: 'varchar', length: 64, nullable: true })
networkType?: string;
@Column({ type: 'json', nullable: true })
device?: Record<string, unknown>;
@Column({ type: 'datetime' })
occurredAt: Date;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('request_logs')
export class RequestLog {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'char', length: 36 })
requestId: string;
@Column({ type: 'varchar', length: 16 })
method: string;
@Column({ type: 'varchar', length: 1024 })
path: string;
@Column({ type: 'json', nullable: true })
query?: unknown;
@Column({ type: 'json', nullable: true })
body?: unknown;
@Column({ type: 'int' })
statusCode: number;
@Column({ type: 'int' })
responseCode: number;
@Column({ type: 'varchar', length: 512 })
message: string;
@Column({ type: 'int' })
costMs: number;
@Column({ type: 'bigint', nullable: true })
userId?: number;
@Column({ type: 'bigint', nullable: true })
adminId?: number;
@Column({ type: 'varchar', length: 128, nullable: true })
ip?: string;
@Column({ type: 'varchar', length: 512, nullable: true })
userAgent?: string;
@Column({ type: 'text', nullable: true })
errorStack?: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -1,13 +1,24 @@
/**
* App main.
* @file Index 入口文件
* @module src/main
* @author zhxiao1124
*/
import "dotenv/config";
import { Logger } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { bootstrapApp } from "./bootstrap";
import { APP_CONFIG, OPTIONS_CONFIG } from "@config/app.config";
import { AppModule } from "@src/app/app.module";
import { bootstrapApp } from "@src/bootstrap";
const logger = new Logger("AppLoader");
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, OPTIONS_CONFIG);
bootstrapApp(app);
await app.listen(process.env.PORT ?? 3000);
console.log(`Application is running on: ${await app.getUrl()}`);
await app.listen(APP_CONFIG.PORT);
logger.log(`Application is running on: ${await app.getUrl()}`);
}
void bootstrap();

View File

@@ -1,11 +1,9 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from "../admin/guards/permissions.guard";
import { CreateCoverDto } from "./dto/create-cover.dto";
import { CreateDirectUploadTaskDto } from "./dto/create-direct-upload-task.dto";
import { CreateUploadJobDto } from "./dto/create-upload-job.dto";
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
import { PermissionsGuard } from "@app/guards/permissions.guard";
import { CreateCoverDto, CreateDirectUploadTaskDto, CreateUploadJobDto } from "./media.dto";
import { MediaService } from "./media.service";
@ApiTags("Media")

59
src/media/media.dto.ts Normal file
View File

@@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsInt, IsObject, IsOptional, IsString, IsUrl, Min } from "class-validator";
export class CreateUploadJobDto {
@ApiProperty()
@IsString()
jobId: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
byteplusVid?: string;
@ApiProperty()
@IsString()
status: string;
@ApiPropertyOptional()
@IsOptional()
@IsObject()
rawResponse?: Record<string, unknown>;
}
export class CreateCoverDto {
@ApiProperty()
@IsString()
fileName: string;
@ApiProperty()
@IsUrl({ require_tld: false })
url: string;
}
export class CreateDirectUploadTaskDto {
@ApiProperty()
@IsInt()
@Min(1)
episodeId: number;
@ApiProperty()
@IsString()
fileName: string;
@ApiProperty()
@IsString()
contentType: string;
@ApiProperty()
@IsInt()
@Min(1)
fileSize: number;
}
export class CompleteDirectUploadTaskDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
providerUploadId?: string;
}

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module';
import { DramasModule } from '../dramas/dramas.module';
import { AdminModule } from '@src/admin/admin.module';
import { DramasModule } from '@src/dramas/dramas.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
import { BytePlusMediaProvider } from './media.provider';
@Module({
imports: [AdminModule, DramasModule],

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { BYTEPLUS_CONFIG } from '@config/app.config';
interface CreateUploadInput {
jobId: string;
@@ -14,7 +15,7 @@ interface CompleteUploadInput {
@Injectable()
export class BytePlusMediaProvider {
createDirectUpload(input: CreateUploadInput) {
const bucket = process.env.BYTEPLUS_BUCKET ?? 'mock-short-drama';
const bucket = BYTEPLUS_CONFIG.bucket;
const objectKey = `videos/${input.jobId}/${input.fileName}`;
return {

View File

@@ -2,18 +2,16 @@ import { Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
import { DramasService } from '../dramas/dramas.service';
import { CreateCoverDto } from './dto/create-cover.dto';
import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto';
import { CreateUploadJobDto } from './dto/create-upload-job.dto';
import { Cover } from './entities/cover.entity';
import { MediaUploadJob } from './entities/media-upload-job.entity';
import { PaginatedResponse } from '@app/common/paginated-response.dto';
import { DramasService } from '@src/dramas/dramas.service';
import { CreateCoverDto, CreateDirectUploadTaskDto, CreateUploadJobDto } from './media.dto';
import { Cover } from './cover.entity';
import { MediaUploadJob } from './media-upload-job.entity';
import {
CoverRecord,
MediaUploadJobRecord,
} from './interfaces/media-records.interface';
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
} from './media.types';
import { BytePlusMediaProvider } from './media.provider';
interface PaginationInput {
page: number;

View File

@@ -1,20 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminJwtAuthGuard } from './guards/admin-jwt-auth.guard';
import { PermissionsGuard } from './guards/permissions.guard';
@Global()
@Module({
imports: [
JwtModule.register({
secret: process.env.ADMIN_JWT_SECRET ?? process.env.JWT_SECRET ?? 'development-secret',
signOptions: { expiresIn: '12h' },
}),
],
controllers: [AdminController],
providers: [AdminService, AdminJwtAuthGuard, PermissionsGuard],
exports: [JwtModule, AdminService, AdminJwtAuthGuard, PermissionsGuard],
})
export class AdminModule {}

View File

@@ -1,12 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { AdminUserRecord } from '../interfaces/admin-records.interface';
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AdminUserRecord | undefined => {
const request = ctx
.switchToHttp()
.getRequest<Request & { adminUser?: AdminUserRecord }>();
return request.adminUser;
},
);

View File

@@ -1,6 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRED_PERMISSIONS_METADATA = 'admin:required-permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(REQUIRED_PERMISSIONS_METADATA, permissions);

View File

@@ -1,13 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class AdminLoginDto {
@ApiProperty()
@IsString()
username: string;
@ApiProperty()
@IsString()
@MinLength(6)
password: string;
}

View File

@@ -1,13 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class ChangeAdminPasswordDto {
@ApiProperty()
@IsString()
oldPassword: string;
@ApiProperty()
@IsString()
@MinLength(6)
newPassword: string;
}

View File

@@ -1,38 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsEmail,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
export class CreateAdminUserDto {
@ApiProperty()
@IsString()
username: string;
@ApiProperty()
@IsString()
@MinLength(6)
password: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[];
}

View File

@@ -1,17 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class CreatePermissionDto {
@ApiProperty()
@IsString()
code: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -1,30 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateRoleDto {
@ApiProperty()
@IsString()
code: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[];
}

View File

@@ -1,14 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class UpdateAdminProfileDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
}

View File

@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsInt, Min } from 'class-validator';
export class UpdateAdminRolesDto {
@ApiProperty({ type: [Number] })
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds: number[];
}

View File

@@ -1,8 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
export class UpdateAdminStatusDto {
@ApiProperty({ enum: ['active', 'disabled'] })
@IsIn(['active', 'disabled'])
status: 'active' | 'disabled';
}

View File

@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsInt, Min } from 'class-validator';
export class UpdateRolePermissionsDto {
@ApiProperty({ type: [Number] })
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds: number[];
}

View File

@@ -1,53 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { AdminUserRecord } from '../interfaces/admin-records.interface';
import { AdminService } from '../admin.service';
@Injectable()
export class AdminJwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly adminService: AdminService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { adminUser?: AdminUserRecord; adminId?: number }>();
const authHeader = request.get('authorization');
const token = authHeader?.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: undefined;
if (!token) {
throw new UnauthorizedException('Admin not authenticated');
}
try {
const payload = await this.jwtService.verifyAsync<{
sub: number;
type: string;
}>(token);
if (payload.type !== 'admin') {
throw new UnauthorizedException('Admin not authenticated');
}
const adminUser = await this.adminService.findAdminById(payload.sub);
if (!adminUser || adminUser.status !== 'active') {
throw new UnauthorizedException('Admin not authenticated');
}
request.adminUser = adminUser;
request.adminId = adminUser.id;
return true;
} catch {
throw new UnauthorizedException('Admin not authenticated');
}
}
}

View File

@@ -1,54 +0,0 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import {
REQUIRED_PERMISSIONS_METADATA,
} from '../decorators/require-permissions.decorator';
import { AdminUserRecord } from '../interfaces/admin-records.interface';
import { AdminService } from '../admin.service';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly adminService: AdminService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required =
this.reflector.getAllAndOverride<string[]>(
REQUIRED_PERMISSIONS_METADATA,
[context.getHandler(), context.getClass()],
) ?? [];
if (required.length === 0) {
return true;
}
const request = context
.switchToHttp()
.getRequest<Request & { adminUser?: AdminUserRecord }>();
const adminUser = request.adminUser;
if (!adminUser) {
throw new ForbiddenException('No permission');
}
if (adminUser.isSuperAdmin) {
return true;
}
const permissions = await this.adminService.getPermissionCodesForAdmin(
adminUser.id,
);
const allowed = required.every((permission) =>
permissions.includes(permission),
);
if (!allowed) {
throw new ForbiddenException('No permission');
}
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More