Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eeec11d51 | |||
| 4eff4d877e | |||
| e84351e8ed | |||
| 55e20f9f16 | |||
| 03d3c2f9cc | |||
| 61a00286fb | |||
| 79aeac7f37 | |||
| fca247c3f2 | |||
| f2b1043cbc | |||
| 0eeeea0c4d | |||
| f342312b92 | |||
| d3083bf16b | |||
| 026665b2cc | |||
| 5fa8c5e0a9 |
8
.env
8
.env
@@ -1,5 +1,8 @@
|
||||
PORT=3030
|
||||
JWT_SECRET=change-me
|
||||
ADMIN_JWT_SECRET=change-me-admin
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# MySQL settings for the production TypeORM layer.
|
||||
# The current scaffold defines entities and API boundaries; repository persistence can be enabled when the database is ready.
|
||||
@@ -8,3 +11,8 @@ DB_PORT=22246
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=zhxiao1124..
|
||||
DB_DATABASE=onion_tiktok
|
||||
|
||||
# CORS settings
|
||||
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
DB_SYNCHRONIZE = true
|
||||
|
||||
@@ -11,3 +11,8 @@ DB_PORT=3306
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=cth_tk_backend
|
||||
|
||||
# CORS settings
|
||||
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
DB_SYNCHRONIZE = true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 390,
|
||||
"eslintIntegration": true,
|
||||
"bracketSpacing": true,
|
||||
"stylelintIntegration": true
|
||||
}
|
||||
17
common/paginated-response.dto.ts
Normal file
17
common/paginated-response.dto.ts
Normal 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
59
config/app.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* App config.
|
||||
* @file 应用运行配置
|
||||
* @module config/app.config
|
||||
* @author zhxiao1124
|
||||
*/
|
||||
|
||||
// 注意:本文件不主动加载 .env,环境变量由 src/main.ts 顶部的 `import "dotenv/config"` 注入。
|
||||
// e2e 测试不加载 .env,DB_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",
|
||||
};
|
||||
8
constants/text.constant.ts
Normal file
8
constants/text.constant.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Text constant.
|
||||
* @file 通用文案常量
|
||||
* @module constants/text.constant
|
||||
* @author zhxiao1124
|
||||
*/
|
||||
|
||||
export const SUCCESS_MESSAGE = "success";
|
||||
12
decorators/api-message.decorator.ts
Normal file
12
decorators/api-message.decorator.ts
Normal 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);
|
||||
15
decorators/current-admin.decorator.ts
Normal file
15
decorators/current-admin.decorator.ts
Normal 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;
|
||||
});
|
||||
15
decorators/current-user.decorator.ts
Normal file
15
decorators/current-user.decorator.ts
Normal 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;
|
||||
});
|
||||
12
decorators/require-permissions.decorator.ts
Normal file
12
decorators/require-permissions.decorator.ts
Normal 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);
|
||||
561
docs/apifox-app-apis.openapi.json
Normal file
561
docs/apifox-app-apis.openapi.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
docs/apifox-app-apis.swagger.json
Normal file
352
docs/apifox-app-apis.swagger.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
71
filters/http.filter.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
51
guards/admin-jwt-auth.guard.ts
Normal file
51
guards/admin-jwt-auth.guard.ts
Normal 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
42
guards/jwt-auth.guard.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
49
guards/permissions.guard.ts
Normal file
49
guards/permissions.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
interceptor/logger.interceptor.ts
Normal file
51
interceptor/logger.interceptor.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
interceptor/response.interceptor.ts
Normal file
35
interceptor/response.interceptor.ts
Normal 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`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
middleware/request-id.middleware.ts
Normal file
19
middleware/request-id.middleware.ts
Normal 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();
|
||||
}
|
||||
@@ -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",
|
||||
@@ -23,11 +25,13 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "17.4.1",
|
||||
"mysql2": "^3.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"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"
|
||||
},
|
||||
@@ -51,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"
|
||||
}
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -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)
|
||||
@@ -44,6 +41,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.4
|
||||
dotenv:
|
||||
specifier: 17.4.1
|
||||
version: 17.4.1
|
||||
mysql2:
|
||||
specifier: ^3.0.0
|
||||
version: 3.22.5(@types/node@24.13.2)
|
||||
@@ -59,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))
|
||||
@@ -123,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
|
||||
@@ -707,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'}
|
||||
@@ -1655,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'}
|
||||
@@ -4109,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)
|
||||
@@ -5060,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: {}
|
||||
|
||||
@@ -22,6 +22,9 @@ export class AdminUser {
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
nickname?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'active' })
|
||||
status: string;
|
||||
|
||||
@@ -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
123
src/admin/admin.dto.ts
Normal 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
21
src/admin/admin.module.ts
Normal 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 {}
|
||||
603
src/admin/admin.service.ts
Normal file
603
src/admin/admin.service.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
Optional,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 './admin.types';
|
||||
|
||||
interface PaginationInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService implements OnModuleInit {
|
||||
private permissionSequence = 1;
|
||||
private roleSequence = 1;
|
||||
private adminSequence = 1;
|
||||
private readonly permissions: PermissionRecord[] = [];
|
||||
private readonly roles: RoleRecord[] = [];
|
||||
private readonly admins: AdminUserRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {
|
||||
if (!this.hasDatabase()) {
|
||||
this.seedMemory();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (this.hasDatabase()) {
|
||||
await this.seedDatabase();
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
}
|
||||
|
||||
async login(dto: AdminLoginDto) {
|
||||
const admin = this.admins.find((item) => item.username === dto.username);
|
||||
if (
|
||||
!admin ||
|
||||
admin.status !== 'active' ||
|
||||
!bcrypt.compareSync(dto.password, admin.passwordHash)
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: await this.jwtService.signAsync({
|
||||
sub: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
}),
|
||||
admin: this.toAdminView(admin),
|
||||
};
|
||||
}
|
||||
|
||||
async createPermission(dto: CreatePermissionDto): Promise<PermissionRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Permission);
|
||||
if (await repository.findOne({ where: { code: dto.code } })) {
|
||||
throw new ConflictException('Permission code already exists');
|
||||
}
|
||||
const saved = await repository.save(repository.create(dto));
|
||||
await this.loadDatabaseRecords();
|
||||
return this.toPermissionRecord(saved);
|
||||
}
|
||||
|
||||
if (this.permissions.some((item) => item.code === dto.code)) {
|
||||
throw new ConflictException('Permission code already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const permission: PermissionRecord = {
|
||||
id: this.permissionSequence++,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.permissions.push(permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
async listPermissions(
|
||||
pagination: PaginationInput,
|
||||
): Promise<PaginatedResponse<PermissionRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
|
||||
return this.paginate([...this.permissions], pagination);
|
||||
}
|
||||
|
||||
async createRole(dto: CreateRoleDto): Promise<RoleRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Role);
|
||||
if (await repository.findOne({ where: { code: dto.code } })) {
|
||||
throw new ConflictException('Role code already exists');
|
||||
}
|
||||
const saved = await repository.save(
|
||||
repository.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
permissionIds: dto.permissionIds ?? [],
|
||||
}),
|
||||
);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.roles.find((role) => role.id === Number(saved.id)) as RoleRecord;
|
||||
}
|
||||
|
||||
if (this.roles.some((item) => item.code === dto.code)) {
|
||||
throw new ConflictException('Role code already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const permissionIds = dto.permissionIds ?? [];
|
||||
const role: RoleRecord = {
|
||||
id: this.roleSequence++,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
permissionIds,
|
||||
permissions: this.permissions.filter((permission) =>
|
||||
permissionIds.includes(permission.id),
|
||||
),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.roles.push(role);
|
||||
return role;
|
||||
}
|
||||
|
||||
async listRoles(pagination: PaginationInput): Promise<PaginatedResponse<RoleRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
|
||||
return this.paginate([...this.roles], pagination);
|
||||
}
|
||||
|
||||
async updateRolePermissions(id: number, permissionIds: number[]): Promise<RoleRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Role);
|
||||
const role = await repository.findOne({ where: { id } });
|
||||
if (!role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
}
|
||||
role.permissionIds = [...new Set(permissionIds)];
|
||||
await repository.save(role);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.roles.find((item) => item.id === id) as RoleRecord;
|
||||
}
|
||||
|
||||
const role = this.roles.find((item) => item.id === id);
|
||||
if (!role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
}
|
||||
|
||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||
role.permissionIds = uniquePermissionIds;
|
||||
role.permissions = this.permissions.filter((permission) =>
|
||||
uniquePermissionIds.includes(permission.id),
|
||||
);
|
||||
role.updatedAt = new Date().toISOString();
|
||||
return role;
|
||||
}
|
||||
|
||||
async createAdminUser(dto: CreateAdminUserDto): Promise<AdminUserView> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(AdminUser);
|
||||
if (await repository.findOne({ where: { username: dto.username } })) {
|
||||
throw new ConflictException('Admin username already exists');
|
||||
}
|
||||
const saved = await repository.save(
|
||||
repository.create({
|
||||
username: dto.username,
|
||||
passwordHash: bcrypt.hashSync(dto.password, 10),
|
||||
nickname: dto.nickname,
|
||||
email: dto.email,
|
||||
status: 'active',
|
||||
isSuperAdmin: false,
|
||||
roleIds: dto.roleIds ?? [],
|
||||
}),
|
||||
);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.toAdminView(
|
||||
this.admins.find((admin) => admin.id === Number(saved.id)) as AdminUserRecord,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.admins.some((item) => item.username === dto.username)) {
|
||||
throw new ConflictException('Admin username already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const admin: AdminUserRecord = {
|
||||
id: this.adminSequence++,
|
||||
username: dto.username,
|
||||
passwordHash: bcrypt.hashSync(dto.password, 10),
|
||||
nickname: dto.nickname,
|
||||
email: dto.email,
|
||||
status: 'active',
|
||||
isSuperAdmin: false,
|
||||
roleIds: dto.roleIds ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.admins.push(admin);
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
async listAdminUsers(
|
||||
pagination: PaginationInput,
|
||||
): Promise<PaginatedResponse<AdminUserView>> {
|
||||
if (this.hasDatabase()) {
|
||||
await this.loadDatabaseRecords();
|
||||
}
|
||||
|
||||
return this.paginate(
|
||||
this.admins.map((admin) => this.toAdminView(admin)),
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
async updateAdminStatus(
|
||||
id: number,
|
||||
status: 'active' | 'disabled',
|
||||
): Promise<AdminUserView> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(AdminUser);
|
||||
const admin = await repository.findOne({ where: { id } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
admin.status = status;
|
||||
await repository.save(admin);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
|
||||
}
|
||||
|
||||
const admin = this.findAdminById(id);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.status = status;
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
async updateAdminRoles(id: number, roleIds: number[]): Promise<AdminUserView> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(AdminUser);
|
||||
const admin = await repository.findOne({ where: { id } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
admin.roleIds = [...new Set(roleIds)];
|
||||
await repository.save(admin);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.toAdminView(this.findAdminById(id) as AdminUserRecord);
|
||||
}
|
||||
|
||||
const admin = this.findAdminById(id);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.roleIds = [...new Set(roleIds)];
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
adminId: number,
|
||||
dto: UpdateAdminProfileDto,
|
||||
): Promise<AdminUserView> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(AdminUser);
|
||||
const admin = await repository.findOne({ where: { id: adminId } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
admin.nickname = dto.nickname;
|
||||
admin.email = dto.email;
|
||||
await repository.save(admin);
|
||||
await this.loadDatabaseRecords();
|
||||
return this.toAdminView(this.findAdminById(adminId) as AdminUserRecord);
|
||||
}
|
||||
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.nickname = dto.nickname;
|
||||
admin.email = dto.email;
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
adminId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<Record<string, never>> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(AdminUser);
|
||||
const admin = await repository.findOne({ where: { id: adminId } });
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
if (!bcrypt.compareSync(oldPassword, admin.passwordHash)) {
|
||||
throw new UnprocessableEntityException('Old password is incorrect');
|
||||
}
|
||||
admin.passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
await repository.save(admin);
|
||||
await this.loadDatabaseRecords();
|
||||
return {};
|
||||
}
|
||||
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(oldPassword, admin.passwordHash)) {
|
||||
throw new UnprocessableEntityException('Old password is incorrect');
|
||||
}
|
||||
|
||||
admin.passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return {};
|
||||
}
|
||||
|
||||
findAdminById(id: number) {
|
||||
return this.admins.find((admin) => admin.id === id);
|
||||
}
|
||||
|
||||
getPermissionCodesForAdmin(adminId: number) {
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (admin.isSuperAdmin) {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
return this.roles
|
||||
.filter((role) => admin.roleIds.includes(role.id))
|
||||
.flatMap((role) => role.permissions.map((permission) => permission.code));
|
||||
}
|
||||
|
||||
toAdminView(admin: AdminUserRecord): AdminUserView {
|
||||
const roles = this.roles.filter((role) => admin.roleIds.includes(role.id));
|
||||
const permissions: PermissionRecord[] | ['*'] = admin.isSuperAdmin
|
||||
? ['*']
|
||||
: roles.flatMap((role) => role.permissions);
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
nickname: admin.nickname,
|
||||
email: admin.email,
|
||||
status: admin.status,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
roles,
|
||||
permissions,
|
||||
createdAt: admin.createdAt,
|
||||
updatedAt: admin.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private seedMemory() {
|
||||
const codes = [
|
||||
['permission:read', '查看权限'],
|
||||
['permission:create', '创建权限'],
|
||||
['role:read', '查看角色'],
|
||||
['role:create', '创建角色'],
|
||||
['role:update', '更新角色'],
|
||||
['admin-user:read', '查看人员'],
|
||||
['admin-user:create', '创建人员'],
|
||||
['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', '查看媒体'],
|
||||
['media:create', '创建媒体'],
|
||||
['data:read', '查看数据'],
|
||||
['data:sync', '同步数据'],
|
||||
['payment:read', '查看支付记录'],
|
||||
['log:read', '查看日志'],
|
||||
];
|
||||
|
||||
for (const [code, name] of codes) {
|
||||
this.createPermission({ code, name });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
this.admins.push({
|
||||
id: this.adminSequence++,
|
||||
username: ADMIN_AUTH_CONFIG.username,
|
||||
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
|
||||
nickname: 'Super Admin',
|
||||
email: ADMIN_AUTH_CONFIG.email,
|
||||
status: 'active',
|
||||
isSuperAdmin: true,
|
||||
roleIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private async seedDatabase() {
|
||||
const dataSource = this.dataSource as DataSource;
|
||||
const permissionRepository = dataSource.getRepository(Permission);
|
||||
for (const [code, name] of this.permissionSeeds()) {
|
||||
const existing = await permissionRepository.findOne({ where: { code } });
|
||||
if (!existing) {
|
||||
await permissionRepository.save(
|
||||
permissionRepository.create({ code, name }),
|
||||
);
|
||||
} else if (existing.name !== name) {
|
||||
existing.name = name;
|
||||
await permissionRepository.save(existing);
|
||||
}
|
||||
}
|
||||
|
||||
const adminRepository = dataSource.getRepository(AdminUser);
|
||||
const username = ADMIN_AUTH_CONFIG.username;
|
||||
const existingAdmin = await adminRepository.findOne({ where: { username } });
|
||||
if (!existingAdmin) {
|
||||
await adminRepository.save(
|
||||
adminRepository.create({
|
||||
username,
|
||||
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
|
||||
nickname: 'Super Admin',
|
||||
email: ADMIN_AUTH_CONFIG.email,
|
||||
status: 'active',
|
||||
isSuperAdmin: true,
|
||||
roleIds: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDatabaseRecords() {
|
||||
const dataSource = this.dataSource as DataSource;
|
||||
const permissions = await dataSource.getRepository(Permission).find({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
this.permissions.splice(
|
||||
0,
|
||||
this.permissions.length,
|
||||
...permissions.map((permission) => this.toPermissionRecord(permission)),
|
||||
);
|
||||
|
||||
const roles = await dataSource.getRepository(Role).find({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
this.roles.splice(
|
||||
0,
|
||||
this.roles.length,
|
||||
...roles.map((role) => this.toRoleRecord(role)),
|
||||
);
|
||||
|
||||
const admins = await dataSource.getRepository(AdminUser).find({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
this.admins.splice(
|
||||
0,
|
||||
this.admins.length,
|
||||
...admins.map((admin) => this.toAdminRecord(admin)),
|
||||
);
|
||||
}
|
||||
|
||||
private permissionSeeds() {
|
||||
return [
|
||||
['permission:read', '查看权限'],
|
||||
['permission:create', '创建权限'],
|
||||
['role:read', '查看角色'],
|
||||
['role:create', '创建角色'],
|
||||
['role:update', '更新角色'],
|
||||
['admin-user:read', '查看人员'],
|
||||
['admin-user:create', '创建人员'],
|
||||
['admin-user:update', '更新人员'],
|
||||
['user:read', '查看用户'],
|
||||
['user:update', '更新用户'],
|
||||
['drama:read', '查看短剧'],
|
||||
['drama:create', '创建短剧'],
|
||||
['drama:update', '更新短剧'],
|
||||
['episode:create', '创建剧集'],
|
||||
['episode:update', '更新剧集'],
|
||||
['media:read', '查看媒体'],
|
||||
['media:create', '创建媒体'],
|
||||
['data:read', '查看数据'],
|
||||
['data:sync', '同步数据'],
|
||||
['payment:read', '查看支付记录'],
|
||||
['log:read', '查看日志'],
|
||||
] as const;
|
||||
}
|
||||
|
||||
private toPermissionRecord(permission: Permission): PermissionRecord {
|
||||
return {
|
||||
id: Number(permission.id),
|
||||
code: permission.code,
|
||||
name: permission.name,
|
||||
description: permission.description,
|
||||
createdAt: permission.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||
updatedAt: permission.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toRoleRecord(role: Role): RoleRecord {
|
||||
const permissionIds = role.permissionIds ?? [];
|
||||
return {
|
||||
id: Number(role.id),
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permissionIds,
|
||||
permissions: this.permissions.filter((permission) =>
|
||||
permissionIds.includes(permission.id),
|
||||
),
|
||||
createdAt: role.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||
updatedAt: role.updatedAt?.toISOString?.() ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toAdminRecord(admin: AdminUser): AdminUserRecord {
|
||||
return {
|
||||
id: Number(admin.id),
|
||||
username: admin.username,
|
||||
passwordHash: admin.passwordHash,
|
||||
nickname: admin.nickname,
|
||||
email: admin.email,
|
||||
status: admin.status as 'active' | 'disabled',
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
roleIds: admin.roleIds ?? [],
|
||||
createdAt: admin.createdAt?.toISOString?.() ?? new Date().toISOString(),
|
||||
updatedAt: admin.updatedAt?.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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +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 { DramasModule } from './modules/dramas/dramas.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { LogsModule } from './modules/logs/logs.module';
|
||||
import { MediaModule } from './modules/media/media.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { PlayerModule } from './modules/player/player.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
AdminModule,
|
||||
HealthModule,
|
||||
LogsModule,
|
||||
DramasModule,
|
||||
MediaModule,
|
||||
AuthModule,
|
||||
OrdersModule,
|
||||
PlayerModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
18
src/app/app.controller.ts
Normal file
18
src/app/app.controller.ts
Normal 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
73
src/app/app.module.ts
Normal 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 {}
|
||||
@@ -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
21
src/auth/auth.module.ts
Normal 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 {}
|
||||
@@ -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 {
|
||||
@@ -12,7 +12,7 @@ export class AuthService {
|
||||
|
||||
async loginWithTikTok(dto: TikTokLoginDto) {
|
||||
const tiktokOpenId = dto.mockOpenId ?? this.resolveOpenIdFromCode(dto.code);
|
||||
const result = this.usersService.upsertTikTokUser({
|
||||
const result = await this.usersService.upsertTikTokUser({
|
||||
tiktokOpenId,
|
||||
tiktokUnionId: dto.tiktokUnionId,
|
||||
nickname: dto.nickname,
|
||||
@@ -1,42 +1,37 @@
|
||||
/**
|
||||
* 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(',') ?? [
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
app.use(RequestIdMiddleware);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
app.use(RequestIdMiddleware);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const logsService = app.get(LogsService);
|
||||
app.useGlobalFilters(new HttpExceptionFilter(logsService));
|
||||
app.useGlobalInterceptors(
|
||||
new RequestLoggingInterceptor(logsService, app.get(Reflector)),
|
||||
new ResponseInterceptor(),
|
||||
);
|
||||
const logsService = app.get(LogsService);
|
||||
|
||||
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);
|
||||
app.useGlobalFilters(new HttpExceptionFilter(logsService));
|
||||
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);
|
||||
}
|
||||
|
||||
41
src/channels/channel.entity.ts
Normal file
41
src/channels/channel.entity.ts
Normal 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;
|
||||
}
|
||||
48
src/channels/channels.controller.ts
Normal file
48
src/channels/channels.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
36
src/channels/channels.dto.ts
Normal file
36
src/channels/channels.dto.ts
Normal 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) {}
|
||||
12
src/channels/channels.module.ts
Normal file
12
src/channels/channels.module.ts
Normal 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 {}
|
||||
316
src/channels/channels.service.ts
Normal file
316
src/channels/channels.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
13
src/channels/channels.types.ts
Normal file
13
src/channels/channels.types.ts
Normal 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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const SUCCESS_MESSAGE = 'success';
|
||||
@@ -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);
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
26
src/data/data-sync-job.entity.ts
Normal file
26
src/data/data-sync-job.entity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity('data_sync_jobs')
|
||||
export class DataSyncJob {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
jobId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32 })
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
dramaId?: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
dramaCount: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
episodeCount: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
64
src/data/data.controller.ts
Normal file
64
src/data/data.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Body, Controller, Get, 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 { DataService } from './data.service';
|
||||
|
||||
@ApiTags('Data')
|
||||
@Controller('/api/admin/v1/data')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
export class DataController {
|
||||
constructor(private readonly dataService: DataService) {}
|
||||
|
||||
@Post('/sync')
|
||||
@RequirePermissions('data:sync')
|
||||
sync(@Body() body: { dramaId?: number }) {
|
||||
return this.dataService.sync(body);
|
||||
}
|
||||
|
||||
@Get('/overview')
|
||||
@RequirePermissions('data:read')
|
||||
getOverview() {
|
||||
return this.dataService.getOverview();
|
||||
}
|
||||
|
||||
@Get('/dramas')
|
||||
@RequirePermissions('data:read')
|
||||
listDramaMetrics(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.dataService.listDramaMetrics({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/episodes')
|
||||
@RequirePermissions('data:read')
|
||||
listEpisodeMetrics(
|
||||
@Query('dramaId') dramaId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.dataService.listEpisodeMetrics({
|
||||
dramaId: dramaId ? Number(dramaId) : undefined,
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/sync-jobs')
|
||||
@RequirePermissions('data:read')
|
||||
listSyncJobs(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.dataService.listSyncJobs({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/data/data.module.ts
Normal file
13
src/data/data.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 './data.provider';
|
||||
|
||||
@Module({
|
||||
imports: [AdminModule, DramasModule],
|
||||
controllers: [DataController],
|
||||
providers: [DataService, TikTokDataProvider],
|
||||
})
|
||||
export class DataModule {}
|
||||
29
src/data/data.provider.ts
Normal file
29
src/data/data.provider.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DramaRecord, EpisodeRecord } from '@src/dramas/dramas.types';
|
||||
|
||||
@Injectable()
|
||||
export class TikTokDataProvider {
|
||||
getDramaMetrics(drama: DramaRecord) {
|
||||
const base = drama.id * 1000 + drama.totalEpisodes * 100;
|
||||
return {
|
||||
plays: base + 500,
|
||||
likes: base + 80,
|
||||
shares: drama.id * 13 + 20,
|
||||
comments: drama.id * 7 + 12,
|
||||
completionRate: 0.68,
|
||||
averageWatchSeconds: 96,
|
||||
};
|
||||
}
|
||||
|
||||
getEpisodeMetrics(episode: EpisodeRecord) {
|
||||
const base = episode.id * 500 + episode.episodeNo * 50;
|
||||
return {
|
||||
plays: base + 300,
|
||||
likes: base + 40,
|
||||
shares: episode.id * 5 + 8,
|
||||
comments: episode.id * 3 + 6,
|
||||
completionRate: 0.72,
|
||||
averageWatchSeconds: Math.min(episode.durationSeconds || 180, 120),
|
||||
};
|
||||
}
|
||||
}
|
||||
312
src/data/data.service.ts
Normal file
312
src/data/data.service.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||
import { DramasService } from '@src/dramas/dramas.service';
|
||||
import { 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;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface SyncInput {
|
||||
dramaId?: number;
|
||||
}
|
||||
|
||||
export interface DramaMetricRecord {
|
||||
dramaId: number;
|
||||
title: string;
|
||||
plays: number;
|
||||
likes: number;
|
||||
shares: number;
|
||||
comments: number;
|
||||
completionRate: number;
|
||||
averageWatchSeconds: number;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export interface EpisodeMetricRecord {
|
||||
dramaId: number;
|
||||
episodeId: number;
|
||||
title: string;
|
||||
episodeNo: number;
|
||||
plays: number;
|
||||
likes: number;
|
||||
shares: number;
|
||||
comments: number;
|
||||
completionRate: number;
|
||||
averageWatchSeconds: number;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export interface SyncJobRecord {
|
||||
id: number;
|
||||
jobId: string;
|
||||
status: 'completed' | 'failed';
|
||||
dramaId?: number;
|
||||
dramaCount: number;
|
||||
episodeCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DataService {
|
||||
private syncJobSequence = 1;
|
||||
private readonly dramaMetrics = new Map<number, DramaMetricRecord>();
|
||||
private readonly episodeMetrics = new Map<number, EpisodeMetricRecord>();
|
||||
private readonly syncJobs: SyncJobRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
private readonly tiktokDataProvider: TikTokDataProvider,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async sync(input: SyncInput = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const dramas = (await this.dramasService.listAllDramas()).filter(
|
||||
(drama) => !input.dramaId || drama.id === input.dramaId,
|
||||
);
|
||||
const dramaIds = new Set(dramas.map((drama) => drama.id));
|
||||
const episodes = (await this.dramasService.listAllEpisodes()).filter(
|
||||
(episode) => dramaIds.has(episode.dramaId),
|
||||
);
|
||||
|
||||
const dramaRecords = dramas.map((drama) => ({
|
||||
dramaId: drama.id,
|
||||
title: drama.title,
|
||||
...this.tiktokDataProvider.getDramaMetrics(drama),
|
||||
syncedAt: now,
|
||||
}));
|
||||
|
||||
const episodeRecords = episodes.map((episode) => ({
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
episodeNo: episode.episodeNo,
|
||||
...this.tiktokDataProvider.getEpisodeMetrics(episode),
|
||||
syncedAt: now,
|
||||
}));
|
||||
|
||||
for (const record of dramaRecords) {
|
||||
this.dramaMetrics.set(record.dramaId, record);
|
||||
}
|
||||
|
||||
for (const record of episodeRecords) {
|
||||
this.episodeMetrics.set(record.episodeId, record);
|
||||
}
|
||||
|
||||
const job: SyncJobRecord = {
|
||||
id: this.syncJobSequence++,
|
||||
jobId: `DATA_SYNC_${Date.now()}_${this.syncJobSequence}`,
|
||||
status: 'completed',
|
||||
dramaId: input.dramaId,
|
||||
dramaCount: dramas.length,
|
||||
episodeCount: episodes.length,
|
||||
createdAt: now,
|
||||
};
|
||||
this.syncJobs.unshift(job);
|
||||
|
||||
if (this.hasDatabase()) {
|
||||
const dramaMetricRepository = this.dataSource.getRepository(DramaMetric);
|
||||
const episodeMetricRepository = this.dataSource.getRepository(EpisodeMetric);
|
||||
const syncJobRepository = this.dataSource.getRepository(DataSyncJob);
|
||||
|
||||
for (const record of dramaRecords) {
|
||||
const existing = await dramaMetricRepository.findOne({
|
||||
where: { dramaId: record.dramaId },
|
||||
});
|
||||
await dramaMetricRepository.save(
|
||||
dramaMetricRepository.create({
|
||||
...existing,
|
||||
...record,
|
||||
syncedAt: new Date(record.syncedAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const record of episodeRecords) {
|
||||
const existing = await episodeMetricRepository.findOne({
|
||||
where: { episodeId: record.episodeId },
|
||||
});
|
||||
await episodeMetricRepository.save(
|
||||
episodeMetricRepository.create({
|
||||
...existing,
|
||||
...record,
|
||||
syncedAt: new Date(record.syncedAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await syncJobRepository.save(
|
||||
syncJobRepository.create({
|
||||
...job,
|
||||
createdAt: new Date(job.createdAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async getOverview() {
|
||||
const dramaRecords = this.hasDatabase()
|
||||
? (await this.dataSource.getRepository(DramaMetric).find()).map((record) =>
|
||||
this.toDramaMetricRecord(record),
|
||||
)
|
||||
: Array.from(this.dramaMetrics.values());
|
||||
const episodeRecords = this.hasDatabase()
|
||||
? (await this.dataSource.getRepository(EpisodeMetric).find()).map((record) =>
|
||||
this.toEpisodeMetricRecord(record),
|
||||
)
|
||||
: Array.from(this.episodeMetrics.values());
|
||||
const latestJob = this.hasDatabase()
|
||||
? (
|
||||
await this.dataSource.getRepository(DataSyncJob).find({
|
||||
order: { id: 'DESC' },
|
||||
take: 1,
|
||||
})
|
||||
)[0]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
totalDramas: dramaRecords.length,
|
||||
totalEpisodes: episodeRecords.length,
|
||||
totalPlays: episodeRecords.reduce((sum, item) => sum + item.plays, 0),
|
||||
totalLikes: episodeRecords.reduce((sum, item) => sum + item.likes, 0),
|
||||
totalShares: episodeRecords.reduce((sum, item) => sum + item.shares, 0),
|
||||
totalComments: episodeRecords.reduce((sum, item) => sum + item.comments, 0),
|
||||
latestSyncedAt: latestJob?.createdAt?.toISOString?.() ?? this.syncJobs[0]?.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listDramaMetrics(input: PaginationInput): Promise<PaginatedResponse<DramaMetricRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
const repository = this.dataSource.getRepository(DramaMetric);
|
||||
const [records, total] = await repository.findAndCount({
|
||||
order: { dramaId: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return {
|
||||
list: records.map((record) => this.toDramaMetricRecord(record)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
return this.paginate(Array.from(this.dramaMetrics.values()), input);
|
||||
}
|
||||
|
||||
async listEpisodeMetrics(
|
||||
input: PaginationInput & { dramaId?: number },
|
||||
): Promise<PaginatedResponse<EpisodeMetricRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
const repository = this.dataSource.getRepository(EpisodeMetric);
|
||||
const [records, total] = await repository.findAndCount({
|
||||
where: input.dramaId ? { dramaId: input.dramaId } : {},
|
||||
order: { episodeNo: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return {
|
||||
list: records.map((record) => this.toEpisodeMetricRecord(record)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
const records = Array.from(this.episodeMetrics.values()).filter(
|
||||
(item) => !input.dramaId || item.dramaId === input.dramaId,
|
||||
);
|
||||
return this.paginate(records, input);
|
||||
}
|
||||
|
||||
async listSyncJobs(input: PaginationInput): Promise<PaginatedResponse<SyncJobRecord>> {
|
||||
if (this.hasDatabase()) {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
const repository = this.dataSource.getRepository(DataSyncJob);
|
||||
const [records, total] = await repository.findAndCount({
|
||||
order: { id: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return {
|
||||
list: records.map((record) => this.toSyncJobRecord(record)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
return this.paginate(this.syncJobs, input);
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private toDramaMetricRecord(record: DramaMetric): DramaMetricRecord {
|
||||
return {
|
||||
dramaId: Number(record.dramaId),
|
||||
title: record.title,
|
||||
plays: Number(record.plays),
|
||||
likes: Number(record.likes),
|
||||
shares: Number(record.shares),
|
||||
comments: Number(record.comments),
|
||||
completionRate: record.completionRate,
|
||||
averageWatchSeconds: record.averageWatchSeconds,
|
||||
syncedAt: record.syncedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toEpisodeMetricRecord(record: EpisodeMetric): EpisodeMetricRecord {
|
||||
return {
|
||||
dramaId: Number(record.dramaId),
|
||||
episodeId: Number(record.episodeId),
|
||||
title: record.title,
|
||||
episodeNo: record.episodeNo,
|
||||
plays: Number(record.plays),
|
||||
likes: Number(record.likes),
|
||||
shares: Number(record.shares),
|
||||
comments: Number(record.comments),
|
||||
completionRate: record.completionRate,
|
||||
averageWatchSeconds: record.averageWatchSeconds,
|
||||
syncedAt: record.syncedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toSyncJobRecord(record: DataSyncJob): SyncJobRecord {
|
||||
return {
|
||||
id: Number(record.id),
|
||||
jobId: record.jobId,
|
||||
status: record.status as 'completed' | 'failed',
|
||||
dramaId: record.dramaId ? Number(record.dramaId) : undefined,
|
||||
dramaCount: record.dramaCount,
|
||||
episodeCount: record.episodeCount,
|
||||
createdAt: record.createdAt.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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
48
src/data/drama-metric.entity.ts
Normal file
48
src/data/drama-metric.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('drama_metrics')
|
||||
export class DramaMetric {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'bigint' })
|
||||
dramaId: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
plays: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
likes: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
shares: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
comments: number;
|
||||
|
||||
@Column({ type: 'float', default: 0 })
|
||||
completionRate: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
averageWatchSeconds: number;
|
||||
|
||||
@Column({ type: 'datetime' })
|
||||
syncedAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
55
src/data/episode-metric.entity.ts
Normal file
55
src/data/episode-metric.entity.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('episode_metrics')
|
||||
export class EpisodeMetric {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'bigint' })
|
||||
dramaId: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'bigint' })
|
||||
episodeId: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
episodeNo: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
plays: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
likes: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
shares: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
comments: number;
|
||||
|
||||
@Column({ type: 'float', default: 0 })
|
||||
completionRate: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
averageWatchSeconds: number;
|
||||
|
||||
@Column({ type: 'datetime' })
|
||||
syncedAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -5,19 +5,28 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { PublishStatus } from '../drama-status.enum';
|
||||
import { PublishStatus } from './dramas.types';
|
||||
|
||||
@Entity('dramas')
|
||||
export class Drama {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
tiktokAlbumId?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
coverId?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
channelId?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
coverUrl: string;
|
||||
|
||||
@@ -27,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 })
|
||||
@@ -51,6 +60,9 @@ export class Drama {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRecommended: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
onlineVersion: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
167
src/dramas/dramas.controller.ts
Normal file
167
src/dramas/dramas.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
|
||||
import { DramasService } from "./dramas.service";
|
||||
import { PublishStatus } from "./dramas.types";
|
||||
|
||||
@ApiTags("Dramas")
|
||||
@Controller()
|
||||
export class DramasController {
|
||||
constructor(private readonly dramasService: DramasService) {}
|
||||
|
||||
@Post("/api/admin/v1/dramas")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("drama:create")
|
||||
createDrama(@Body() dto: CreateDramaDto) {
|
||||
return this.dramasService.createDrama(dto);
|
||||
}
|
||||
|
||||
@Get("/api/admin/v1/dramas")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("drama:read")
|
||||
listAdminDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string, @Query("title") title?: string, @Query("isRecommended") isRecommended?: string) {
|
||||
return this.dramasService.listAdminDramas({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
title,
|
||||
isRecommended: this.parseOptionalBoolean(isRecommended),
|
||||
});
|
||||
}
|
||||
|
||||
@Get("/api/admin/v1/dramas/:id")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("drama:read")
|
||||
getDrama(@Param("id") id: string) {
|
||||
return this.dramasService.getDramaOrThrow(Number(id));
|
||||
}
|
||||
|
||||
@Patch("/api/admin/v1/dramas/:id")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("drama:update")
|
||||
updateDrama(@Param("id") id: string, @Body() dto: UpdateDramaDto) {
|
||||
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)
|
||||
@RequirePermissions("episode:create")
|
||||
configureEpisodes(@Param("id") id: string, @Body() dto: ConfigureEpisodesDto) {
|
||||
return this.dramasService.configureEpisodes(Number(id), dto);
|
||||
}
|
||||
|
||||
@Get("/api/admin/v1/dramas/:id/episodes")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("episode:update")
|
||||
listAdminEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.dramasService.listAdminEpisodes(Number(id), {
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("/api/admin/v1/episodes")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("episode:create")
|
||||
createEpisode(@Body() dto: CreateEpisodeDto) {
|
||||
return this.dramasService.createEpisode(dto);
|
||||
}
|
||||
|
||||
@Patch("/api/admin/v1/episodes/:id")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("episode:update")
|
||||
updateEpisode(@Param("id") id: string, @Body() dto: UpdateEpisodeDto) {
|
||||
return this.dramasService.updateEpisode(Number(id), dto);
|
||||
}
|
||||
|
||||
@Patch("/api/admin/v1/dramas/:dramaId/episodes/:episodeId")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("episode:update")
|
||||
updateDramaEpisode(@Param("dramaId") dramaId: string, @Param("episodeId") episodeId: string, @Body() dto: UpdateEpisodeDto) {
|
||||
return this.dramasService.updateDramaEpisode(Number(dramaId), Number(episodeId), dto);
|
||||
}
|
||||
|
||||
@Post("/api/admin/v1/dramas/:id/submit-review")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("drama:update")
|
||||
submitReview(@Param("id") id: string) {
|
||||
return this.dramasService.submitReview(Number(id));
|
||||
}
|
||||
|
||||
@Patch("/api/admin/v1/episodes/:id/publish-status")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
@RequirePermissions("episode:update")
|
||||
updatePublishStatus(@Param("id") id: string, @Body() dto: { publishStatus: PublishStatus }) {
|
||||
return this.dramasService.updateEpisodePublishStatus(Number(id), dto.publishStatus);
|
||||
}
|
||||
|
||||
@Get("/api/app/v1/dramas")
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("/api/app/v1/dramas/recommended")
|
||||
listRecommendedDramas(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.dramasService.listRecommendedDramas({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 10
|
||||
});
|
||||
}
|
||||
|
||||
@Get("/api/app/v1/dramas/presearch")
|
||||
presearchDramas(@Query("keyword") keyword?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.dramasService.presearchDramas({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 10,
|
||||
keyword,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("/api/app/v1/dramas/:id/episodes")
|
||||
listPublishedEpisodes(@Param("id") id: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.dramasService.listPublishedEpisodes(Number(id), {
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("/api/app/v1/dramas/:id")
|
||||
getPublishedDrama(@Param("id") id: string) {
|
||||
return this.dramasService.getPublishedDramaOrThrow(Number(id));
|
||||
}
|
||||
|
||||
private parseOptionalBoolean(value?: string) {
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
247
src/dramas/dramas.dto.ts
Normal file
247
src/dramas/dramas.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { AdminModule } from '@src/admin/admin.module';
|
||||
import { ChannelsModule } from '@src/channels/channels.module';
|
||||
import { DramasController } from './dramas.controller';
|
||||
import { DramasService } from './dramas.service';
|
||||
|
||||
@Module({
|
||||
imports: [AdminModule],
|
||||
imports: [AdminModule, ChannelsModule],
|
||||
controllers: [DramasController],
|
||||
providers: [DramasService],
|
||||
exports: [DramasService],
|
||||
1047
src/dramas/dramas.service.ts
Normal file
1047
src/dramas/dramas.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
src/dramas/dramas.types.ts
Normal file
66
src/dramas/dramas.types.ts
Normal 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;
|
||||
}
|
||||
97
src/dramas/episode.entity.ts
Normal file
97
src/dramas/episode.entity.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { PublishStatus } from './dramas.types';
|
||||
|
||||
@Entity('episodes')
|
||||
@Index(['dramaId', 'episodeNo'], { unique: true })
|
||||
export class Episode {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
dramaId: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
tiktokEpisodeId?: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
episodeNo: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
coverId?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
coverUrl: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||
videoUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
byteplusVid?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
storageBucket?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
objectKey?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
trialDurationSeconds?: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
durationSeconds: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
width?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
height?: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPaid: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 16, default: 'free' })
|
||||
freeType: 'free' | 'paid';
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
unlockPrice?: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
publishAt?: Date;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
nextReleaseAt?: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
|
||||
status: PublishStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: 'not_submitted' })
|
||||
reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
reviewMessage?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: PublishStatus.Draft })
|
||||
publishStatus: PublishStatus;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
37
src/history/history.controller.ts
Normal file
37
src/history/history.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@app/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@app/guards/jwt-auth.guard';
|
||||
import { UserRecord } from '@src/users/users.types';
|
||||
import { UpsertPlaybackHistoryDto } from './history.dto';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
@ApiTags('History')
|
||||
@Controller('/api/app/v1/history')
|
||||
export class HistoryController {
|
||||
constructor(private readonly historyService: HistoryService) {}
|
||||
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
upsertHistory(
|
||||
@CurrentUser() user: UserRecord,
|
||||
@Body() dto: UpsertPlaybackHistoryDto,
|
||||
) {
|
||||
return this.historyService.upsertHistory(user, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listHistory(
|
||||
@CurrentUser() user: UserRecord,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.historyService.listHistory(user, {
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
30
src/history/history.dto.ts
Normal file
30
src/history/history.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpsertPlaybackHistoryDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
episodeId: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
progressSeconds: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
completed?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
occurredAt?: string;
|
||||
}
|
||||
13
src/history/history.module.ts
Normal file
13
src/history/history.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '@src/auth/auth.module';
|
||||
import { DramasModule } from '@src/dramas/dramas.module';
|
||||
import { UsersModule } from '@src/users/users.module';
|
||||
import { HistoryController } from './history.controller';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DramasModule, UsersModule],
|
||||
controllers: [HistoryController],
|
||||
providers: [HistoryService],
|
||||
})
|
||||
export class HistoryModule {}
|
||||
223
src/history/history.service.ts
Normal file
223
src/history/history.service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||
import { DramasService } from '@src/dramas/dramas.service';
|
||||
import {
|
||||
DramaRecord,
|
||||
EpisodeRecord,
|
||||
} from '@src/dramas/dramas.types';
|
||||
import { UserRecord } from '@src/users/users.types';
|
||||
import { UpsertPlaybackHistoryDto } from './history.dto';
|
||||
import { UserPlaybackHistory } from './user-playback-history.entity';
|
||||
import {
|
||||
PlaybackHistoryListItem,
|
||||
PlaybackHistoryRecord,
|
||||
} from './history.types';
|
||||
|
||||
interface PaginationInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
private historySequence = 1;
|
||||
private readonly histories: PlaybackHistoryRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async upsertHistory(
|
||||
user: UserRecord,
|
||||
dto: UpsertPlaybackHistoryDto,
|
||||
): Promise<PlaybackHistoryRecord> {
|
||||
const episode = await this.dramasService.getPublishedEpisodeOrThrow(
|
||||
dto.episodeId,
|
||||
);
|
||||
const progressSeconds = this.resolveProgress(
|
||||
dto.progressSeconds,
|
||||
episode.durationSeconds,
|
||||
);
|
||||
const completed =
|
||||
Boolean(dto.completed) || progressSeconds >= episode.durationSeconds;
|
||||
const lastPlayedAt = dto.occurredAt
|
||||
? new Date(dto.occurredAt)
|
||||
: new Date();
|
||||
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||
const existing = await repository.findOne({
|
||||
where: { userId: user.id, episodeId: episode.id },
|
||||
});
|
||||
|
||||
const entity =
|
||||
existing ??
|
||||
repository.create({
|
||||
userId: user.id,
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
});
|
||||
|
||||
entity.dramaId = episode.dramaId;
|
||||
entity.progressSeconds = progressSeconds;
|
||||
entity.durationSeconds = episode.durationSeconds;
|
||||
entity.completed = completed;
|
||||
entity.lastPlayedAt = lastPlayedAt;
|
||||
|
||||
return this.toRecord(await repository.save(entity));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = this.histories.find(
|
||||
(item) => item.userId === user.id && item.episodeId === episode.id,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.dramaId = episode.dramaId;
|
||||
existing.progressSeconds = progressSeconds;
|
||||
existing.durationSeconds = episode.durationSeconds;
|
||||
existing.completed = completed;
|
||||
existing.lastPlayedAt = lastPlayedAt.toISOString();
|
||||
existing.updatedAt = now;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const record: PlaybackHistoryRecord = {
|
||||
id: this.historySequence++,
|
||||
userId: user.id,
|
||||
dramaId: episode.dramaId,
|
||||
episodeId: episode.id,
|
||||
progressSeconds,
|
||||
durationSeconds: episode.durationSeconds,
|
||||
completed,
|
||||
lastPlayedAt: lastPlayedAt.toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.histories.push(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async listHistory(
|
||||
user: UserRecord,
|
||||
pagination: PaginationInput,
|
||||
): Promise<PaginatedResponse<PlaybackHistoryListItem>> {
|
||||
if (this.hasDatabase()) {
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(pagination);
|
||||
const repository = this.dataSource.getRepository(UserPlaybackHistory);
|
||||
const [records, total] = await repository.findAndCount({
|
||||
where: { userId: user.id },
|
||||
order: { lastPlayedAt: 'DESC', id: 'DESC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
const list = await Promise.all(
|
||||
records.map((record) => this.toListItem(this.toRecord(record))),
|
||||
);
|
||||
return { list, pagination: { page, pageSize, total } };
|
||||
}
|
||||
|
||||
const page = this.paginate(
|
||||
this.histories
|
||||
.filter((item) => item.userId === user.id)
|
||||
.sort((left, right) => {
|
||||
const timeDiff =
|
||||
new Date(right.lastPlayedAt).getTime() -
|
||||
new Date(left.lastPlayedAt).getTime();
|
||||
return timeDiff || right.id - left.id;
|
||||
}),
|
||||
pagination,
|
||||
);
|
||||
|
||||
return {
|
||||
...page,
|
||||
list: await Promise.all(
|
||||
page.list.map((record) => this.toListItem(record)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async toListItem(
|
||||
record: PlaybackHistoryRecord,
|
||||
): Promise<PlaybackHistoryListItem> {
|
||||
const [drama, episode] = await Promise.all([
|
||||
this.dramasService.getPublishedDramaOrThrow(record.dramaId),
|
||||
this.dramasService.getPublishedEpisodeOrThrow(record.episodeId),
|
||||
]);
|
||||
return this.mergeListItem(record, drama, episode);
|
||||
}
|
||||
|
||||
private mergeListItem(
|
||||
record: PlaybackHistoryRecord,
|
||||
drama: DramaRecord,
|
||||
episode: EpisodeRecord,
|
||||
): PlaybackHistoryListItem {
|
||||
return {
|
||||
id: record.id,
|
||||
dramaId: record.dramaId,
|
||||
episodeId: record.episodeId,
|
||||
episodeNo: episode.episodeNo,
|
||||
title: episode.title,
|
||||
coverUrl: episode.coverUrl,
|
||||
progressSeconds: record.progressSeconds,
|
||||
durationSeconds: record.durationSeconds,
|
||||
completed: record.completed,
|
||||
lastPlayedAt: record.lastPlayedAt,
|
||||
dramaTitle: drama.title,
|
||||
dramaCoverUrl: drama.coverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveProgress(progressSeconds: number, durationSeconds: number) {
|
||||
return Math.min(Math.max(progressSeconds, 0), durationSeconds);
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private toRecord(entity: UserPlaybackHistory): PlaybackHistoryRecord {
|
||||
return {
|
||||
id: Number(entity.id),
|
||||
userId: Number(entity.userId),
|
||||
dramaId: Number(entity.dramaId),
|
||||
episodeId: Number(entity.episodeId),
|
||||
progressSeconds: entity.progressSeconds,
|
||||
durationSeconds: entity.durationSeconds,
|
||||
completed: entity.completed,
|
||||
lastPlayedAt: this.toIso(entity.lastPlayedAt),
|
||||
createdAt: this.toIso(entity.createdAt),
|
||||
updatedAt: this.toIso(entity.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toIso(value?: Date | string): string {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
private resolvePagination(input: PaginationInput) {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
||||
}
|
||||
|
||||
private paginate<T>(
|
||||
records: T[],
|
||||
input: PaginationInput,
|
||||
): PaginatedResponse<T> {
|
||||
const { page, pageSize, skip, take } = this.resolvePagination(input);
|
||||
return {
|
||||
list: records.slice(skip, skip + take),
|
||||
pagination: { page, pageSize, total: records.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
27
src/history/history.types.ts
Normal file
27
src/history/history.types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface PlaybackHistoryRecord {
|
||||
id: number;
|
||||
userId: number;
|
||||
dramaId: number;
|
||||
episodeId: number;
|
||||
progressSeconds: number;
|
||||
durationSeconds: number;
|
||||
completed: boolean;
|
||||
lastPlayedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaybackHistoryListItem {
|
||||
id: number;
|
||||
dramaId: number;
|
||||
episodeId: number;
|
||||
episodeNo: number;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
progressSeconds: number;
|
||||
durationSeconds: number;
|
||||
completed: boolean;
|
||||
lastPlayedAt: string;
|
||||
dramaTitle: string;
|
||||
dramaCoverUrl: string;
|
||||
}
|
||||
43
src/history/user-playback-history.entity.ts
Normal file
43
src/history/user-playback-history.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('user_playback_histories')
|
||||
@Index(['userId', 'episodeId'], { unique: true })
|
||||
@Index(['userId', 'lastPlayedAt'])
|
||||
export class UserPlaybackHistory {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
userId: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
dramaId: number;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
episodeId: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
progressSeconds: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
durationSeconds: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
completed: boolean;
|
||||
|
||||
@Column({ type: 'datetime' })
|
||||
lastPlayedAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
64
src/logs/logs.controller.ts
Normal file
64
src/logs/logs.controller.ts
Normal 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
62
src/logs/logs.dto.ts
Normal 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
12
src/logs/logs.module.ts
Normal 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
240
src/logs/logs.service.ts
Normal 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
34
src/logs/logs.types.ts
Normal 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;
|
||||
}
|
||||
50
src/logs/playback-error-log.entity.ts
Normal file
50
src/logs/playback-error-log.entity.ts
Normal 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;
|
||||
}
|
||||
59
src/logs/request-log.entity.ts
Normal file
59
src/logs/request-log.entity.ts
Normal 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;
|
||||
}
|
||||
25
src/main.ts
25
src/main.ts
@@ -1,11 +1,24 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { bootstrapApp } from './bootstrap';
|
||||
/**
|
||||
* App main.
|
||||
* @file Index 入口文件
|
||||
* @module src/main
|
||||
* @author zhxiao1124
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
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);
|
||||
bootstrapApp(app);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
const app = await NestFactory.create(AppModule, OPTIONS_CONFIG);
|
||||
bootstrapApp(app);
|
||||
await app.listen(APP_CONFIG.PORT);
|
||||
logger.log(`Application is running on: ${await app.getUrl()}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
21
src/media/cover.entity.ts
Normal file
21
src/media/cover.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('covers')
|
||||
export class Cover {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
fileName: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024 })
|
||||
url: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
73
src/media/media-upload-job.entity.ts
Normal file
73
src/media/media-upload-job.entity.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('media_upload_jobs')
|
||||
export class MediaUploadJob {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
jobId: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
episodeId?: number;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
byteplusVid?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
fileName?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
contentType?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
fileSize?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
bucket?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
objectKey?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||
uploadUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||
videoUrl?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
width?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
height?: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationSeconds?: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
reviewMessage?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
rawResponse?: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
66
src/media/media.controller.ts
Normal file
66
src/media/media.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Body, Controller, 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 { CreateCoverDto, CreateDirectUploadTaskDto, CreateUploadJobDto } from "./media.dto";
|
||||
import { MediaService } from "./media.service";
|
||||
|
||||
@ApiTags("Media")
|
||||
@Controller("/api/admin/v1/media")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@Post("/covers")
|
||||
@RequirePermissions("media:create")
|
||||
createCover(@Body() dto: CreateCoverDto) {
|
||||
return this.mediaService.createCover(dto);
|
||||
}
|
||||
|
||||
@Post("/upload-jobs")
|
||||
@RequirePermissions("media:create")
|
||||
createUploadJob(@Body() dto: CreateUploadJobDto) {
|
||||
return this.mediaService.createUploadJob(dto);
|
||||
}
|
||||
|
||||
@Post("/upload-tasks")
|
||||
@RequirePermissions("media:create")
|
||||
createDirectUploadTask(@Body() dto: CreateDirectUploadTaskDto) {
|
||||
return this.mediaService.createDirectUploadTask(dto);
|
||||
}
|
||||
|
||||
@Get("/upload-tasks/:jobId")
|
||||
@RequirePermissions("media:read")
|
||||
getUploadTask(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.getUploadTask(jobId);
|
||||
}
|
||||
|
||||
@Patch("/upload-tasks/:jobId/complete")
|
||||
@RequirePermissions("media:create")
|
||||
completeDirectUploadTask(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.completeDirectUploadTask(jobId);
|
||||
}
|
||||
|
||||
@Post("/upload-tasks/:jobId/review/retry")
|
||||
@RequirePermissions("media:create")
|
||||
retryReview(@Param("jobId") jobId: string) {
|
||||
return this.mediaService.retryReview(jobId);
|
||||
}
|
||||
|
||||
@Post("/review-status/sync")
|
||||
@RequirePermissions("media:create")
|
||||
syncReviewStatuses() {
|
||||
return this.mediaService.syncReviewStatuses();
|
||||
}
|
||||
|
||||
@Get("/upload-jobs")
|
||||
@RequirePermissions("media:read")
|
||||
listUploadJobs(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.mediaService.listUploadJobs({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
src/media/media.dto.ts
Normal file
59
src/media/media.dto.ts
Normal 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;
|
||||
}
|
||||
14
src/media/media.module.ts
Normal file
14
src/media/media.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 './media.provider';
|
||||
|
||||
@Module({
|
||||
imports: [AdminModule, DramasModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService, BytePlusMediaProvider],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
59
src/media/media.provider.ts
Normal file
59
src/media/media.provider.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BYTEPLUS_CONFIG } from '@config/app.config';
|
||||
|
||||
interface CreateUploadInput {
|
||||
jobId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface CompleteUploadInput {
|
||||
jobId: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BytePlusMediaProvider {
|
||||
createDirectUpload(input: CreateUploadInput) {
|
||||
const bucket = BYTEPLUS_CONFIG.bucket;
|
||||
const objectKey = `videos/${input.jobId}/${input.fileName}`;
|
||||
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
uploadUrl: `https://mock-byteplus-upload.local/${bucket}/${objectKey}`,
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
},
|
||||
expiresAt: Date.now() + 15 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
completeUpload(input: CompleteUploadInput) {
|
||||
return {
|
||||
byteplusVid: `mock_vid_${input.jobId.replace(/-/g, '_')}`,
|
||||
videoUrl: `https://mock-byteplus-cdn.local/${input.objectKey}`,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
durationSeconds: 180,
|
||||
rawResponse: {
|
||||
provider: 'byteplus-mock',
|
||||
objectKey: input.objectKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
submitReview(byteplusVid: string) {
|
||||
return {
|
||||
reviewStatus: 'reviewing' as const,
|
||||
reviewMessage: `Mock review submitted for ${byteplusVid}`,
|
||||
};
|
||||
}
|
||||
|
||||
queryReviewStatus(byteplusVid: string) {
|
||||
return {
|
||||
reviewStatus: 'approved' as const,
|
||||
reviewMessage: `Mock review approved for ${byteplusVid}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
410
src/media/media.service.ts
Normal file
410
src/media/media.service.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 './media.types';
|
||||
import { BytePlusMediaProvider } from './media.provider';
|
||||
|
||||
interface PaginationInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private coverSequence = 1;
|
||||
private uploadJobSequence = 1;
|
||||
private readonly covers: CoverRecord[] = [];
|
||||
private readonly uploadJobs: MediaUploadJobRecord[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dramasService: DramasService,
|
||||
private readonly bytePlusMediaProvider: BytePlusMediaProvider,
|
||||
@Optional()
|
||||
@InjectDataSource()
|
||||
readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
async createCover(dto: CreateCoverDto): Promise<CoverRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(Cover);
|
||||
const saved = await repository.save(
|
||||
repository.create({ fileName: dto.fileName, url: dto.url }),
|
||||
);
|
||||
return this.toCoverRecord(saved);
|
||||
}
|
||||
|
||||
const cover: CoverRecord = {
|
||||
id: this.coverSequence++,
|
||||
fileName: dto.fileName,
|
||||
url: dto.url,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.covers.push(cover);
|
||||
return cover;
|
||||
}
|
||||
|
||||
async createUploadJob(dto: CreateUploadJobDto): Promise<MediaUploadJobRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(MediaUploadJob);
|
||||
const saved = await repository.save(
|
||||
repository.create({
|
||||
jobId: dto.jobId,
|
||||
byteplusVid: dto.byteplusVid,
|
||||
status: dto.status,
|
||||
rawResponse: dto.rawResponse,
|
||||
}),
|
||||
);
|
||||
return this.toJobRecord(saved);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const job: MediaUploadJobRecord = {
|
||||
id: this.uploadJobSequence++,
|
||||
jobId: dto.jobId,
|
||||
byteplusVid: dto.byteplusVid,
|
||||
status: dto.status,
|
||||
rawResponse: dto.rawResponse,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.uploadJobs.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async createDirectUploadTask(
|
||||
dto: CreateDirectUploadTaskDto,
|
||||
): Promise<MediaUploadJobRecord> {
|
||||
await this.dramasService.getEpisodeOrThrow(dto.episodeId);
|
||||
const jobId = randomUUID();
|
||||
const upload = this.bytePlusMediaProvider.createDirectUpload({
|
||||
jobId,
|
||||
fileName: dto.fileName,
|
||||
contentType: dto.contentType,
|
||||
});
|
||||
const base = {
|
||||
jobId,
|
||||
episodeId: dto.episodeId,
|
||||
status: 'pending',
|
||||
fileName: dto.fileName,
|
||||
contentType: dto.contentType,
|
||||
fileSize: dto.fileSize,
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
uploadUrl: upload.uploadUrl,
|
||||
reviewStatus: 'not_submitted' as const,
|
||||
rawResponse: {
|
||||
headers: upload.headers,
|
||||
expiresAt: upload.expiresAt,
|
||||
},
|
||||
};
|
||||
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(MediaUploadJob);
|
||||
return this.toJobRecord(await repository.save(repository.create(base)));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const job: MediaUploadJobRecord = {
|
||||
id: this.uploadJobSequence++,
|
||||
...base,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.uploadJobs.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async completeDirectUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(MediaUploadJob);
|
||||
const job = await repository.findOne({ where: { jobId } });
|
||||
if (!job || !job.objectKey || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
|
||||
const completed = this.bytePlusMediaProvider.completeUpload({
|
||||
jobId: job.jobId,
|
||||
objectKey: job.objectKey,
|
||||
});
|
||||
const review = this.bytePlusMediaProvider.submitReview(
|
||||
completed.byteplusVid,
|
||||
);
|
||||
|
||||
job.byteplusVid = completed.byteplusVid;
|
||||
job.videoUrl = completed.videoUrl;
|
||||
job.width = completed.width;
|
||||
job.height = completed.height;
|
||||
job.durationSeconds = completed.durationSeconds;
|
||||
job.status = review.reviewStatus;
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
job.rawResponse = {
|
||||
...(job.rawResponse ?? {}),
|
||||
upload: completed.rawResponse,
|
||||
review,
|
||||
};
|
||||
await repository.save(job);
|
||||
|
||||
await this.dramasService.attachEpisodeMedia({
|
||||
episodeId: Number(job.episodeId),
|
||||
byteplusVid: completed.byteplusVid,
|
||||
videoUrl: completed.videoUrl,
|
||||
storageBucket: job.bucket ?? '',
|
||||
objectKey: job.objectKey,
|
||||
durationSeconds: completed.durationSeconds,
|
||||
width: completed.width,
|
||||
height: completed.height,
|
||||
reviewStatus: review.reviewStatus,
|
||||
reviewMessage: review.reviewMessage,
|
||||
});
|
||||
|
||||
return this.toJobRecord(job);
|
||||
}
|
||||
|
||||
const job = this.getMemJobOrThrow(jobId);
|
||||
if (!job.objectKey || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
|
||||
const completed = this.bytePlusMediaProvider.completeUpload({
|
||||
jobId: job.jobId,
|
||||
objectKey: job.objectKey,
|
||||
});
|
||||
const review = this.bytePlusMediaProvider.submitReview(completed.byteplusVid);
|
||||
|
||||
Object.assign(job, {
|
||||
byteplusVid: completed.byteplusVid,
|
||||
videoUrl: completed.videoUrl,
|
||||
width: completed.width,
|
||||
height: completed.height,
|
||||
durationSeconds: completed.durationSeconds,
|
||||
status: review.reviewStatus,
|
||||
reviewStatus: review.reviewStatus,
|
||||
reviewMessage: review.reviewMessage,
|
||||
rawResponse: {
|
||||
...job.rawResponse,
|
||||
upload: completed.rawResponse,
|
||||
review,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await this.dramasService.attachEpisodeMedia({
|
||||
episodeId: job.episodeId,
|
||||
byteplusVid: completed.byteplusVid,
|
||||
videoUrl: completed.videoUrl,
|
||||
storageBucket: job.bucket ?? '',
|
||||
objectKey: job.objectKey,
|
||||
durationSeconds: completed.durationSeconds,
|
||||
width: completed.width,
|
||||
height: completed.height,
|
||||
reviewStatus: review.reviewStatus,
|
||||
reviewMessage: review.reviewMessage,
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async retryReview(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(MediaUploadJob);
|
||||
const job = await repository.findOne({ where: { jobId } });
|
||||
if (!job || !job.byteplusVid || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
|
||||
const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid);
|
||||
job.status = review.reviewStatus;
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
await repository.save(job);
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
Number(job.episodeId),
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
);
|
||||
return this.toJobRecord(job);
|
||||
}
|
||||
|
||||
const job = this.getMemJobOrThrow(jobId);
|
||||
if (!job.byteplusVid || !job.episodeId) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
|
||||
const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid);
|
||||
job.status = review.reviewStatus;
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
job.updatedAt = new Date().toISOString();
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
job.episodeId,
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
async syncReviewStatuses() {
|
||||
if (this.hasDatabase()) {
|
||||
const repository = this.dataSource.getRepository(MediaUploadJob);
|
||||
const candidates = await repository.find({
|
||||
where: [{ reviewStatus: 'reviewing' }, { reviewStatus: 'pending' }],
|
||||
});
|
||||
const reviewingJobs = candidates.filter(
|
||||
(job) => job.byteplusVid && job.episodeId,
|
||||
);
|
||||
|
||||
for (const job of reviewingJobs) {
|
||||
const review = this.bytePlusMediaProvider.queryReviewStatus(
|
||||
job.byteplusVid as string,
|
||||
);
|
||||
job.status = review.reviewStatus;
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
await repository.save(job);
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
Number(job.episodeId),
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'completed', synced: reviewingJobs.length };
|
||||
}
|
||||
|
||||
const reviewingJobs = this.uploadJobs.filter(
|
||||
(job) =>
|
||||
job.byteplusVid &&
|
||||
job.episodeId &&
|
||||
(job.reviewStatus === 'reviewing' || job.reviewStatus === 'pending'),
|
||||
);
|
||||
|
||||
for (const job of reviewingJobs) {
|
||||
const review = this.bytePlusMediaProvider.queryReviewStatus(
|
||||
job.byteplusVid as string,
|
||||
);
|
||||
job.status = review.reviewStatus;
|
||||
job.reviewStatus = review.reviewStatus;
|
||||
job.reviewMessage = review.reviewMessage;
|
||||
job.updatedAt = new Date().toISOString();
|
||||
await this.dramasService.updateEpisodeReviewStatus(
|
||||
job.episodeId as number,
|
||||
review.reviewStatus,
|
||||
review.reviewMessage,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'completed', synced: reviewingJobs.length };
|
||||
}
|
||||
|
||||
async getUploadTask(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
return this.getUploadJobOrThrow(jobId);
|
||||
}
|
||||
|
||||
async listUploadJobs(
|
||||
input: PaginationInput,
|
||||
): Promise<PaginatedResponse<MediaUploadJobRecord>> {
|
||||
const page = Math.max(input.page, 1);
|
||||
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
|
||||
|
||||
if (this.hasDatabase()) {
|
||||
const [records, total] = await this.dataSource
|
||||
.getRepository(MediaUploadJob)
|
||||
.findAndCount({
|
||||
order: { id: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return {
|
||||
list: records.map((job) => this.toJobRecord(job)),
|
||||
pagination: { page, pageSize, total },
|
||||
};
|
||||
}
|
||||
|
||||
const records = [...this.uploadJobs].reverse();
|
||||
return {
|
||||
list: records.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize),
|
||||
pagination: { page, pageSize, total: this.uploadJobs.length },
|
||||
};
|
||||
}
|
||||
|
||||
private async getUploadJobOrThrow(jobId: string): Promise<MediaUploadJobRecord> {
|
||||
if (this.hasDatabase()) {
|
||||
const job = await this.dataSource
|
||||
.getRepository(MediaUploadJob)
|
||||
.findOne({ where: { jobId } });
|
||||
if (!job) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
return this.toJobRecord(job);
|
||||
}
|
||||
|
||||
return this.getMemJobOrThrow(jobId);
|
||||
}
|
||||
|
||||
private getMemJobOrThrow(jobId: string): MediaUploadJobRecord {
|
||||
const job = this.uploadJobs.find((item) => item.jobId === jobId);
|
||||
if (!job) {
|
||||
throw new NotFoundException('Upload task not found');
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private hasDatabase(): this is this & { dataSource: DataSource } {
|
||||
return Boolean(this.dataSource?.isInitialized);
|
||||
}
|
||||
|
||||
private toCoverRecord(cover: Cover): CoverRecord {
|
||||
return {
|
||||
id: Number(cover.id),
|
||||
fileName: cover.fileName,
|
||||
url: cover.url,
|
||||
createdAt: this.toIso(cover.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toJobRecord(job: MediaUploadJob): MediaUploadJobRecord {
|
||||
return {
|
||||
id: Number(job.id),
|
||||
jobId: job.jobId,
|
||||
episodeId:
|
||||
job.episodeId === null || job.episodeId === undefined
|
||||
? undefined
|
||||
: Number(job.episodeId),
|
||||
byteplusVid: job.byteplusVid,
|
||||
status: job.status,
|
||||
fileName: job.fileName,
|
||||
contentType: job.contentType,
|
||||
fileSize: job.fileSize,
|
||||
bucket: job.bucket,
|
||||
objectKey: job.objectKey,
|
||||
uploadUrl: job.uploadUrl,
|
||||
videoUrl: job.videoUrl,
|
||||
width: job.width,
|
||||
height: job.height,
|
||||
durationSeconds: job.durationSeconds,
|
||||
reviewStatus: job.reviewStatus,
|
||||
reviewMessage: job.reviewMessage,
|
||||
rawResponse: job.rawResponse,
|
||||
createdAt: this.toIso(job.createdAt),
|
||||
updatedAt: this.toIso(job.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toIso(value?: Date | string): string {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
}
|
||||
29
src/media/media.types.ts
Normal file
29
src/media/media.types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface CoverRecord {
|
||||
id: number;
|
||||
fileName: string;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MediaUploadJobRecord {
|
||||
id: number;
|
||||
jobId: string;
|
||||
episodeId?: number;
|
||||
byteplusVid?: string;
|
||||
status: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
fileSize?: number;
|
||||
bucket?: string;
|
||||
objectKey?: string;
|
||||
uploadUrl?: string;
|
||||
videoUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
reviewStatus?: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected';
|
||||
reviewMessage?: string;
|
||||
rawResponse?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -1,318 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
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 {
|
||||
AdminUserRecord,
|
||||
AdminUserView,
|
||||
PermissionRecord,
|
||||
RoleRecord,
|
||||
} from './interfaces/admin-records.interface';
|
||||
|
||||
interface PaginationInput {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private permissionSequence = 1;
|
||||
private roleSequence = 1;
|
||||
private adminSequence = 1;
|
||||
private readonly permissions: PermissionRecord[] = [];
|
||||
private readonly roles: RoleRecord[] = [];
|
||||
private readonly admins: AdminUserRecord[] = [];
|
||||
|
||||
constructor(private readonly jwtService: JwtService) {
|
||||
this.seed();
|
||||
}
|
||||
|
||||
async login(dto: AdminLoginDto) {
|
||||
const admin = this.admins.find((item) => item.username === dto.username);
|
||||
if (
|
||||
!admin ||
|
||||
admin.status !== 'active' ||
|
||||
!bcrypt.compareSync(dto.password, admin.passwordHash)
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: await this.jwtService.signAsync({
|
||||
sub: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
}),
|
||||
admin: this.toAdminView(admin),
|
||||
};
|
||||
}
|
||||
|
||||
createPermission(dto: CreatePermissionDto): PermissionRecord {
|
||||
if (this.permissions.some((item) => item.code === dto.code)) {
|
||||
throw new ConflictException('Permission code already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const permission: PermissionRecord = {
|
||||
id: this.permissionSequence++,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.permissions.push(permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
listPermissions(
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<PermissionRecord> {
|
||||
return this.paginate([...this.permissions], pagination);
|
||||
}
|
||||
|
||||
createRole(dto: CreateRoleDto): RoleRecord {
|
||||
if (this.roles.some((item) => item.code === dto.code)) {
|
||||
throw new ConflictException('Role code already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const permissionIds = dto.permissionIds ?? [];
|
||||
const role: RoleRecord = {
|
||||
id: this.roleSequence++,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
permissionIds,
|
||||
permissions: this.permissions.filter((permission) =>
|
||||
permissionIds.includes(permission.id),
|
||||
),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.roles.push(role);
|
||||
return role;
|
||||
}
|
||||
|
||||
listRoles(pagination: PaginationInput): PaginatedResponse<RoleRecord> {
|
||||
return this.paginate([...this.roles], pagination);
|
||||
}
|
||||
|
||||
updateRolePermissions(id: number, permissionIds: number[]): RoleRecord {
|
||||
const role = this.roles.find((item) => item.id === id);
|
||||
if (!role) {
|
||||
throw new NotFoundException('Role not found');
|
||||
}
|
||||
|
||||
const uniquePermissionIds = [...new Set(permissionIds)];
|
||||
role.permissionIds = uniquePermissionIds;
|
||||
role.permissions = this.permissions.filter((permission) =>
|
||||
uniquePermissionIds.includes(permission.id),
|
||||
);
|
||||
role.updatedAt = new Date().toISOString();
|
||||
return role;
|
||||
}
|
||||
|
||||
createAdminUser(dto: CreateAdminUserDto): AdminUserView {
|
||||
if (this.admins.some((item) => item.username === dto.username)) {
|
||||
throw new ConflictException('Admin username already exists');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const admin: AdminUserRecord = {
|
||||
id: this.adminSequence++,
|
||||
username: dto.username,
|
||||
passwordHash: bcrypt.hashSync(dto.password, 10),
|
||||
nickname: dto.nickname,
|
||||
email: dto.email,
|
||||
status: 'active',
|
||||
isSuperAdmin: false,
|
||||
roleIds: dto.roleIds ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.admins.push(admin);
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
listAdminUsers(
|
||||
pagination: PaginationInput,
|
||||
): PaginatedResponse<AdminUserView> {
|
||||
return this.paginate(
|
||||
this.admins.map((admin) => this.toAdminView(admin)),
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
updateAdminStatus(
|
||||
id: number,
|
||||
status: 'active' | 'disabled',
|
||||
): AdminUserView {
|
||||
const admin = this.findAdminById(id);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.status = status;
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
updateAdminRoles(id: number, roleIds: number[]): AdminUserView {
|
||||
const admin = this.findAdminById(id);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.roleIds = [...new Set(roleIds)];
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
updateProfile(
|
||||
adminId: number,
|
||||
dto: UpdateAdminProfileDto,
|
||||
): AdminUserView {
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
admin.nickname = dto.nickname;
|
||||
admin.email = dto.email;
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return this.toAdminView(admin);
|
||||
}
|
||||
|
||||
changePassword(
|
||||
adminId: number,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Record<string, never> {
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(oldPassword, admin.passwordHash)) {
|
||||
throw new UnprocessableEntityException('Old password is incorrect');
|
||||
}
|
||||
|
||||
admin.passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
admin.updatedAt = new Date().toISOString();
|
||||
return {};
|
||||
}
|
||||
|
||||
findAdminById(id: number) {
|
||||
return this.admins.find((admin) => admin.id === id);
|
||||
}
|
||||
|
||||
getPermissionCodesForAdmin(adminId: number) {
|
||||
const admin = this.findAdminById(adminId);
|
||||
if (!admin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (admin.isSuperAdmin) {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
return this.roles
|
||||
.filter((role) => admin.roleIds.includes(role.id))
|
||||
.flatMap((role) => role.permissions.map((permission) => permission.code));
|
||||
}
|
||||
|
||||
toAdminView(admin: AdminUserRecord): AdminUserView {
|
||||
const roles = this.roles.filter((role) => admin.roleIds.includes(role.id));
|
||||
const permissions: PermissionRecord[] | ['*'] = admin.isSuperAdmin
|
||||
? ['*']
|
||||
: roles.flatMap((role) => role.permissions);
|
||||
|
||||
return {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
nickname: admin.nickname,
|
||||
email: admin.email,
|
||||
status: admin.status,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
roles,
|
||||
permissions,
|
||||
createdAt: admin.createdAt,
|
||||
updatedAt: admin.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private seed() {
|
||||
const codes = [
|
||||
['permission:read', '查看权限'],
|
||||
['permission:create', '创建权限'],
|
||||
['role:read', '查看角色'],
|
||||
['role:create', '创建角色'],
|
||||
['role:update', '更新角色'],
|
||||
['admin-user:read', '查看人员'],
|
||||
['admin-user:create', '创建人员'],
|
||||
['admin-user:update', '更新人员'],
|
||||
['user:read', '查看用户'],
|
||||
['user:update', '更新用户'],
|
||||
['drama:read', '查看短剧'],
|
||||
['drama:create', '创建短剧'],
|
||||
['drama:update', '更新短剧'],
|
||||
['episode:create', '创建剧集'],
|
||||
['episode:update', '更新剧集'],
|
||||
['media:read', '查看媒体'],
|
||||
['media:create', '创建媒体'],
|
||||
['payment:read', '查看支付记录'],
|
||||
['log:read', '查看日志'],
|
||||
];
|
||||
|
||||
for (const [code, name] of codes) {
|
||||
this.createPermission({ code, name });
|
||||
}
|
||||
|
||||
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),
|
||||
nickname: 'Super Admin',
|
||||
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||
status: 'active',
|
||||
isSuperAdmin: true,
|
||||
roleIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user