Compare commits

..

5 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
146 changed files with 3747 additions and 1498 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,33 +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";
import { HistoryModule } from "./modules/history/history.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
DatabaseModule,
AdminModule,
HealthModule,
LogsModule,
DramasModule,
DataModule,
MediaModule,
AuthModule,
OrdersModule,
PlayerModule,
HistoryModule,
],
})
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()
@@ -53,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)
@@ -113,10 +117,11 @@ export class DramasController {
}
@Get("/api/app/v1/dramas")
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
listPublishedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string) {
return this.dramasService.listPublishedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
title,
});
}
@@ -124,13 +129,13 @@ export class DramasController {
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.listRecommendedDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
pageSize: Number(pageSize) || 10
});
}
@Get("/api/app/v1/dramas/presearch")
presearchVideos(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchVideos({
presearchDramas(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.dramasService.presearchDramas({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
keyword,

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],

View File

@@ -1,16 +1,14 @@
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, Like } from "typeorm";
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
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 { Drama } from "./entities/drama.entity";
import { Episode } from "./entities/episode.entity";
import { PublishStatus } from "./drama-status.enum";
import { DramaRecord, EpisodeRecord } from "./interfaces/drama-records.interface";
import { PaginatedResponse } from "@app/common/paginated-response.dto";
import { ChannelsService } from "@src/channels/channels.service";
import { ChannelRecord } from "@src/channels/channels.types";
import { EpisodeMetric } from "@src/data/episode-metric.entity";
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
import { Drama } from "./drama.entity";
import { Episode } from "./episode.entity";
import { DramaRecord, EpisodeRecord, PublishStatus } from "./dramas.types";
interface PaginationInput {
page: number;
@@ -24,27 +22,22 @@ interface DramaListInput extends PaginationInput {
type PublishedDramaListInput = DramaListInput;
interface VideoPresearchInput extends PaginationInput {
interface DramaPresearchInput extends PaginationInput {
keyword?: string;
}
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
export interface VideoPresearchRecord {
id: number;
dramaId: number;
episodeNo: number;
title: string;
description?: string;
coverUrl: string;
durationSeconds: number;
byteplusVid?: string;
videoUrl?: string;
freeType: "free" | "paid";
price: number;
dramaTitle: string;
dramaCoverUrl: string;
}
const FALLBACK_DEFAULT_CHANNEL: ChannelRecord = {
id: 1,
name: "默认频道",
code: "default",
sortOrder: 0,
isDefault: true,
status: "active",
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
};
@Injectable()
export class DramasService {
@@ -57,10 +50,13 @@ export class DramasService {
@Optional()
@InjectDataSource()
readonly dataSource?: DataSource,
@Optional()
private readonly channelsService?: ChannelsService,
) {}
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
const { totalEpisodes } = dto;
const channel = await this.resolveDramaChannel(dto.channelId);
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Drama);
const entity = repository.create({
@@ -68,10 +64,11 @@ export class DramasService {
title: dto.title,
description: dto.description,
coverId: dto.coverId,
channelId: channel.id,
coverUrl: dto.coverUrl,
portraitCoverUrl: dto.portraitCoverUrl,
landscapeCoverUrl: dto.landscapeCoverUrl,
type: dto.type,
type: dto.type ?? "",
tags: dto.tags ?? [],
region: dto.region,
language: dto.language,
@@ -88,7 +85,7 @@ export class DramasService {
});
return this.getDramaOrThrow(Number(saved.id));
}
return this.toDramaRecord(saved, 0, 0);
return this.withChannelName(this.toDramaRecord(saved, 0, 0));
}
const now = new Date().toISOString();
@@ -101,7 +98,7 @@ export class DramasService {
coverUrl: dto.coverUrl,
portraitCoverUrl: dto.portraitCoverUrl,
landscapeCoverUrl: dto.landscapeCoverUrl,
type: dto.type,
type: dto.type ?? "",
tags: dto.tags ?? [],
region: dto.region,
language: dto.language,
@@ -110,8 +107,11 @@ export class DramasService {
sortOrder: dto.sortOrder ?? 0,
isRecommended: dto.isRecommended ?? false,
onlineVersion: dto.onlineVersion ?? 1,
channelId: channel.id,
channelName: channel.name,
totalEpisodes: 0,
publishedEpisodes: 0,
plays: 0,
createdAt: now,
updatedAt: now,
};
@@ -137,7 +137,13 @@ export class DramasService {
skip,
take,
});
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
const playCounts = await this.getPlayCountsByDramaIds(records.map((drama) => Number(drama.id)));
const list = await Promise.all(
records.map(async (drama) => ({
...(await this.toDramaRecordWithCounts(drama)),
plays: playCounts.get(Number(drama.id)) ?? 0,
})),
);
return { list, pagination: { page, pageSize, total } };
}
@@ -170,7 +176,8 @@ export class DramasService {
}
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
const { totalEpisodes, ...dramaUpdates } = dto;
const { totalEpisodes, channelId, ...dramaUpdates } = dto;
const channel = channelId === undefined ? undefined : await this.resolveDramaChannel(channelId);
if (this.hasDatabase()) {
const repository = this.dataSource.getRepository(Drama);
const drama = await repository.findOne({ where: { id } });
@@ -179,6 +186,7 @@ export class DramasService {
}
repository.merge(drama, {
...dramaUpdates,
...(channel ? { channelId: channel.id } : {}),
tags: dramaUpdates.tags ?? drama.tags,
});
const saved = await repository.save(drama);
@@ -197,6 +205,7 @@ export class DramasService {
Object.assign(drama, {
...dramaUpdates,
...(channel ? { channelId: channel.id, channelName: channel.name } : {}),
tags: dramaUpdates.tags ?? drama.tags,
updatedAt: new Date().toISOString(),
});
@@ -206,6 +215,31 @@ export class DramasService {
return this.withEpisodeCounts(drama);
}
async deleteDrama(id: number) {
if (this.hasDatabase()) {
const dramaRepository = this.dataSource.getRepository(Drama);
const drama = await dramaRepository.findOne({ where: { id } });
if (!drama) {
throw new NotFoundException("Drama not found");
}
await this.dataSource.getRepository(Episode).delete({ dramaId: id });
await dramaRepository.delete(id);
return { id };
}
const index = this.dramas.findIndex((item) => item.id === id);
if (index < 0) {
throw new NotFoundException("Drama not found");
}
this.dramas.splice(index, 1);
for (let episodeIndex = this.episodes.length - 1; episodeIndex >= 0; episodeIndex--) {
if (this.episodes[episodeIndex].dramaId === id) {
this.episodes.splice(episodeIndex, 1);
}
}
return { id };
}
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
const dramaId = dto.dramaId ?? dto.albumId;
const episodeNo = dto.episodeNo ?? dto.seq;
@@ -537,21 +571,29 @@ export class DramasService {
return episode;
}
async listPublishedDramas(input: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const title = input.title?.trim();
async listPublishedDramas(data: PublishedDramaListInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const title = data.title?.trim();
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const { page, pageSize, skip, take } = this.resolvePagination(data);
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
status: PublishStatus.Published,
...(title ? { title: Like(`%${title}%`) } : {}),
...(input.isRecommended !== undefined ? { isRecommended: input.isRecommended } : {}),
...(data.isRecommended !== undefined ? { isRecommended: data.isRecommended } : {}),
},
order: { sortOrder: "DESC", id: "DESC" },
skip,
take,
});
const list = await Promise.all(records.map(async (drama) => this.toPublicDramaRecord(await this.toDramaRecordWithCounts(drama))));
const playCounts = await this.getPlayCountsByDramaIds(records.map((drama) => Number(drama.id)));
const list = await Promise.all(
records.map(async (drama) =>
this.toPublicDramaRecord({
...(await this.toDramaRecordWithCounts(drama)),
plays: playCounts.get(Number(drama.id)) ?? 0,
}),
),
);
return { list, pagination: { page, pageSize, total } };
}
@@ -560,10 +602,10 @@ export class DramasService {
this.dramas
.filter((item) => item.status === PublishStatus.Published)
.filter((item) => !title || item.title.includes(title))
.filter((item) => input.isRecommended === undefined || item.isRecommended === input.isRecommended)
.filter((item) => data.isRecommended === undefined || item.isRecommended === data.isRecommended)
.map((drama) => this.withEpisodeCounts(drama)),
),
input,
data,
);
return {
...page,
@@ -578,7 +620,7 @@ export class DramasService {
});
}
async presearchVideos(input: VideoPresearchInput): Promise<PaginatedResponse<VideoPresearchRecord>> {
async presearchDramas(input: DramaPresearchInput): Promise<PaginatedResponse<PublicDramaRecord>> {
const keyword = input.keyword?.trim();
if (!keyword) {
const { page, pageSize } = this.resolvePagination(input);
@@ -587,82 +629,30 @@ export class DramasService {
if (this.hasDatabase()) {
const { page, pageSize, skip, take } = this.resolvePagination(input);
const likeKeyword = `%${keyword}%`;
const baseQuery = this.dataSource
.getRepository(Episode)
.createQueryBuilder("episode")
.innerJoin(Drama, "drama", "drama.id = episode.dramaId")
.where("drama.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.status = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.publishStatus = :published", {
published: PublishStatus.Published,
})
.andWhere("episode.reviewStatus = :approved", {
approved: "approved",
})
.andWhere("(episode.title LIKE :keyword OR episode.description LIKE :keyword OR drama.title LIKE :keyword OR drama.description LIKE :keyword)", { keyword: likeKeyword });
const total = await baseQuery.clone().getCount();
const rows = await baseQuery
.clone()
.select([
"episode.id AS id",
"episode.dramaId AS dramaId",
"episode.episodeNo AS episodeNo",
"episode.title AS title",
"episode.description AS description",
"episode.coverUrl AS coverUrl",
"episode.durationSeconds AS durationSeconds",
"episode.byteplusVid AS byteplusVid",
"episode.videoUrl AS videoUrl",
"episode.freeType AS freeType",
"episode.price AS price",
"drama.title AS dramaTitle",
"drama.coverUrl AS dramaCoverUrl",
])
.orderBy("drama.sortOrder", "DESC")
.addOrderBy("drama.id", "DESC")
.addOrderBy("episode.episodeNo", "ASC")
.offset(skip)
.limit(take)
.getRawMany<VideoPresearchRecord>();
const [records, total] = await this.dataSource.getRepository(Drama).findAndCount({
where: {
status: PublishStatus.Published,
title: Like(`%${keyword}%`),
},
order: { sortOrder: "DESC", id: "DESC" },
skip,
take,
});
const list = await Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama)));
return {
list: rows.map((row) => ({
...row,
id: Number(row.id),
dramaId: Number(row.dramaId),
episodeNo: Number(row.episodeNo),
durationSeconds: Number(row.durationSeconds),
price: Number(row.price),
})),
list: list.map((drama) => this.toPublicDramaRecord(drama)),
pagination: { page, pageSize, total },
};
}
const normalizedKeyword = keyword.toLowerCase();
const records = this.episodes
.filter((episode) => episode.status === PublishStatus.Published && episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved")
.map((episode) => {
const drama = this.dramas.find((item) => item.id === episode.dramaId);
return { episode, drama };
})
.filter((item): item is { episode: EpisodeRecord; drama: DramaRecord } => Boolean(item.drama) && item.drama?.status === PublishStatus.Published)
.filter(({ episode, drama }) => [episode.title, episode.description, drama.title, drama.description].some((value) => value?.toLowerCase().includes(normalizedKeyword)))
.sort((left, right) => {
if (right.drama.sortOrder !== left.drama.sortOrder) {
return right.drama.sortOrder - left.drama.sortOrder;
}
if (right.drama.id !== left.drama.id) {
return right.drama.id - left.drama.id;
}
return left.episode.episodeNo - right.episode.episodeNo;
})
.map(({ episode, drama }) => this.toVideoPresearchRecord(episode, drama));
const records = this.sortDramas(
this.dramas
.filter((drama) => drama.status === PublishStatus.Published)
.filter((drama) => drama.title.toLowerCase().includes(normalizedKeyword))
.map((drama) => this.withEpisodeCounts(drama)),
).map((drama) => this.toPublicDramaRecord(drama));
return this.paginate(records, input);
}
@@ -865,7 +855,7 @@ export class DramasService {
reviewStatus: "approved",
},
});
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
return this.withChannelName(this.toDramaRecord(entity, totalEpisodes, publishedEpisodes));
}
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
@@ -875,6 +865,8 @@ export class DramasService {
title: entity.title,
description: entity.description,
coverId: entity.coverId,
channelId: entity.channelId ?? FALLBACK_DEFAULT_CHANNEL.id,
channelName: entity.channelId === FALLBACK_DEFAULT_CHANNEL.id || !entity.channelId ? FALLBACK_DEFAULT_CHANNEL.name : undefined,
coverUrl: entity.coverUrl,
portraitCoverUrl: entity.portraitCoverUrl,
landscapeCoverUrl: entity.landscapeCoverUrl,
@@ -889,11 +881,38 @@ export class DramasService {
onlineVersion: entity.onlineVersion,
totalEpisodes,
publishedEpisodes,
plays: 0,
createdAt: this.toIso(entity.createdAt),
updatedAt: this.toIso(entity.updatedAt),
};
}
private async withChannelName(record: DramaRecord): Promise<DramaRecord> {
if (!record.channelId) {
return {
...record,
channelId: FALLBACK_DEFAULT_CHANNEL.id,
channelName: FALLBACK_DEFAULT_CHANNEL.name,
};
}
if (record.channelName) {
return record;
}
if (!this.channelsService) {
return record.channelId === FALLBACK_DEFAULT_CHANNEL.id
? { ...record, channelName: FALLBACK_DEFAULT_CHANNEL.name }
: record;
}
const channel = await this.channelsService.getActiveChannelOrThrow(record.channelId);
return {
...record,
channelName: channel.name,
};
}
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
void portraitCoverUrl;
@@ -901,24 +920,6 @@ export class DramasService {
return publicRecord;
}
private toVideoPresearchRecord(episode: EpisodeRecord, drama: DramaRecord): VideoPresearchRecord {
return {
id: episode.id,
dramaId: episode.dramaId,
episodeNo: episode.episodeNo,
title: episode.title,
description: episode.description,
coverUrl: episode.coverUrl,
durationSeconds: episode.durationSeconds,
byteplusVid: episode.byteplusVid,
videoUrl: episode.videoUrl,
freeType: episode.freeType,
price: episode.price,
dramaTitle: drama.title,
dramaCoverUrl: drama.coverUrl,
};
}
private toEpisodeRecord(entity: Episode): EpisodeRecord {
const dramaId = Number(entity.dramaId);
return {
@@ -991,9 +992,44 @@ export class DramasService {
...drama,
totalEpisodes: episodes.length,
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
plays: drama.plays ?? 0,
channelId: drama.channelId ?? FALLBACK_DEFAULT_CHANNEL.id,
channelName: drama.channelName ?? FALLBACK_DEFAULT_CHANNEL.name,
};
}
private async resolveDramaChannel(channelId?: number): Promise<ChannelRecord> {
if (this.channelsService) {
return channelId ? this.channelsService.getActiveChannelOrThrow(channelId) : this.channelsService.getDefaultChannel();
}
if (channelId && channelId !== FALLBACK_DEFAULT_CHANNEL.id) {
throw new NotFoundException("Channel not found");
}
return FALLBACK_DEFAULT_CHANNEL;
}
private async getPlayCountsByDramaIds(dramaIds: number[]): Promise<Map<number, number>> {
const playCounts = new Map<number, number>();
if (!this.hasDatabase() || dramaIds.length === 0) {
return playCounts;
}
const rows = await this.dataSource
.getRepository(EpisodeMetric)
.createQueryBuilder("metric")
.select("metric.dramaId", "dramaId")
.addSelect("COALESCE(SUM(metric.plays), 0)", "plays")
.where("metric.dramaId IN (:...dramaIds)", { dramaIds })
.groupBy("metric.dramaId")
.getRawMany<{ dramaId: string | number; plays: string | number }>();
rows.forEach((row) => {
playCounts.set(Number(row.dramaId), Number(row.plays));
});
return playCounts;
}
private paginate<T>(records: T[], input: PaginationInput): PaginatedResponse<T> {
const page = Math.max(input.page, 1);
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);

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

@@ -1,9 +1,9 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
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')

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DramasModule } from '../dramas/dramas.module';
import { UsersModule } from '../users/users.module';
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';

View File

@@ -1,19 +1,19 @@
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 { PaginatedResponse } from '@app/common/paginated-response.dto';
import { DramasService } from '@src/dramas/dramas.service';
import {
DramaRecord,
EpisodeRecord,
} from '../dramas/interfaces/drama-records.interface';
import { UserRecord } from '../users/interfaces/user-record.interface';
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
} 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 './interfaces/history-record.interface';
} from './history.types';
interface PaginationInput {
page: number;

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