Compare commits
3 Commits
55e20f9f16
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eeec11d51 | |||
| 4eff4d877e | |||
| e84351e8ed |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
/logs/
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
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);
|
||||||
@@ -5,7 +5,22 @@ export default tseslint.config(
|
|||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.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: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-unsafe-assignment': '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": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
|
"serve": "nest start --watch",
|
||||||
"start:dev": "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": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"lint": "eslint \"{src,test}/**/*.ts\""
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typeorm": "^0.3.0",
|
"typeorm": "^0.3.0",
|
||||||
"uuid": "^11.0.0"
|
"uuid": "^11.0.0"
|
||||||
},
|
},
|
||||||
@@ -52,7 +55,6 @@
|
|||||||
"ts-jest": "^29.0.0",
|
"ts-jest": "^29.0.0",
|
||||||
"ts-loader": "^9.5.0",
|
"ts-loader": "^9.5.0",
|
||||||
"ts-node": "^10.9.0",
|
"ts-node": "^10.9.0",
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"typescript-eslint": "^8.0.0"
|
"typescript-eslint": "^8.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -11,9 +11,6 @@ importers:
|
|||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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)
|
version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -62,6 +59,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.0
|
specifier: ^7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
tsconfig-paths:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.0
|
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))
|
version: 0.3.30(mysql2@3.22.5(@types/node@24.13.2))(ts-node@10.9.2(@types/node@24.13.2)(typescript@5.9.3))
|
||||||
@@ -126,9 +126,6 @@ importers:
|
|||||||
ts-node:
|
ts-node:
|
||||||
specifier: ^10.9.0
|
specifier: ^10.9.0
|
||||||
version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3)
|
version: 10.9.2(@types/node@24.13.2)(typescript@5.9.3)
|
||||||
tsconfig-paths:
|
|
||||||
specifier: ^4.2.0
|
|
||||||
version: 4.2.0
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -710,12 +707,6 @@ packages:
|
|||||||
class-validator:
|
class-validator:
|
||||||
optional: true
|
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':
|
'@nestjs/core@11.1.27':
|
||||||
resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==}
|
resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -1658,10 +1649,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
||||||
engines: {node: '>=0.3.1'}
|
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:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4112,14 +4099,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
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)
|
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -5063,10 +5042,6 @@ snapshots:
|
|||||||
|
|
||||||
diff@4.0.4: {}
|
diff@4.0.4: {}
|
||||||
|
|
||||||
dotenv-expand@12.0.3:
|
|
||||||
dependencies:
|
|
||||||
dotenv: 16.6.1
|
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dotenv@17.4.1: {}
|
dotenv@17.4.1: {}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentAdmin } from './decorators/current-admin.decorator';
|
import { CurrentAdmin } from '@app/decorators/current-admin.decorator';
|
||||||
import { RequirePermissions } from './decorators/require-permissions.decorator';
|
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
|
||||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
import { AdminLoginDto, ChangeAdminPasswordDto, CreateAdminUserDto, CreatePermissionDto, CreateRoleDto, UpdateAdminProfileDto, UpdateAdminRolesDto, UpdateAdminStatusDto, UpdateRolePermissionsDto } from './admin.dto';
|
||||||
import { ChangeAdminPasswordDto } from './dto/change-admin-password.dto';
|
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
|
||||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
import { PermissionsGuard } from '@app/guards/permissions.guard';
|
||||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
import { AdminUserRecord } from './admin.types';
|
||||||
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 { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@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 {}
|
||||||
@@ -11,21 +11,18 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { ADMIN_AUTH_CONFIG } from '@config/app.config';
|
||||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
import { AdminLoginDto, CreateAdminUserDto, CreatePermissionDto, CreateRoleDto, UpdateAdminProfileDto } from './admin.dto';
|
||||||
import { CreatePermissionDto } from './dto/create-permission.dto';
|
import { AdminUser } from './admin-user.entity';
|
||||||
import { CreateRoleDto } from './dto/create-role.dto';
|
import { Permission } from './permission.entity';
|
||||||
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
|
import { Role } from './role.entity';
|
||||||
import { AdminUser } from './entities/admin-user.entity';
|
|
||||||
import { Permission } from './entities/permission.entity';
|
|
||||||
import { Role } from './entities/role.entity';
|
|
||||||
import {
|
import {
|
||||||
AdminUserRecord,
|
AdminUserRecord,
|
||||||
AdminUserView,
|
AdminUserView,
|
||||||
PermissionRecord,
|
PermissionRecord,
|
||||||
RoleRecord,
|
RoleRecord,
|
||||||
} from './interfaces/admin-records.interface';
|
} from './admin.types';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -414,9 +411,14 @@ export class AdminService implements OnModuleInit {
|
|||||||
['admin-user:update', '更新人员'],
|
['admin-user:update', '更新人员'],
|
||||||
['user:read', '查看用户'],
|
['user:read', '查看用户'],
|
||||||
['user:update', '更新用户'],
|
['user:update', '更新用户'],
|
||||||
|
['channel:read', '查看频道'],
|
||||||
|
['channel:create', '创建频道'],
|
||||||
|
['channel:update', '更新频道'],
|
||||||
|
['channel:delete', '删除频道'],
|
||||||
['drama:read', '查看短剧'],
|
['drama:read', '查看短剧'],
|
||||||
['drama:create', '创建短剧'],
|
['drama:create', '创建短剧'],
|
||||||
['drama:update', '更新短剧'],
|
['drama:update', '更新短剧'],
|
||||||
|
['drama:delete', '删除短剧'],
|
||||||
['episode:create', '创建剧集'],
|
['episode:create', '创建剧集'],
|
||||||
['episode:update', '更新剧集'],
|
['episode:update', '更新剧集'],
|
||||||
['media:read', '查看媒体'],
|
['media:read', '查看媒体'],
|
||||||
@@ -434,10 +436,10 @@ export class AdminService implements OnModuleInit {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.admins.push({
|
this.admins.push({
|
||||||
id: this.adminSequence++,
|
id: this.adminSequence++,
|
||||||
username: process.env.ADMIN_USERNAME ?? 'admin',
|
username: ADMIN_AUTH_CONFIG.username,
|
||||||
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
|
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
|
||||||
nickname: 'Super Admin',
|
nickname: 'Super Admin',
|
||||||
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
email: ADMIN_AUTH_CONFIG.email,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isSuperAdmin: true,
|
isSuperAdmin: true,
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
@@ -466,15 +468,15 @@ export class AdminService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adminRepository = dataSource.getRepository(AdminUser);
|
const adminRepository = dataSource.getRepository(AdminUser);
|
||||||
const username = process.env.ADMIN_USERNAME ?? 'admin';
|
const username = ADMIN_AUTH_CONFIG.username;
|
||||||
const existingAdmin = await adminRepository.findOne({ where: { username } });
|
const existingAdmin = await adminRepository.findOne({ where: { username } });
|
||||||
if (!existingAdmin) {
|
if (!existingAdmin) {
|
||||||
await adminRepository.save(
|
await adminRepository.save(
|
||||||
adminRepository.create({
|
adminRepository.create({
|
||||||
username,
|
username,
|
||||||
passwordHash: bcrypt.hashSync(process.env.ADMIN_PASSWORD ?? 'admin123', 10),
|
passwordHash: bcrypt.hashSync(ADMIN_AUTH_CONFIG.password, 10),
|
||||||
nickname: 'Super Admin',
|
nickname: 'Super Admin',
|
||||||
email: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
email: ADMIN_AUTH_CONFIG.email,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isSuperAdmin: true,
|
isSuperAdmin: true,
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { ConfigModule } from "@nestjs/config";
|
|
||||||
import { AdminModule } from "./modules/admin/admin.module";
|
|
||||||
import { AuthModule } from "./modules/auth/auth.module";
|
|
||||||
import { DataModule } from "./modules/data/data.module";
|
|
||||||
import { DatabaseModule } from "./modules/database/database.module";
|
|
||||||
import { DramasModule } from "./modules/dramas/dramas.module";
|
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
|
||||||
import { LogsModule } from "./modules/logs/logs.module";
|
|
||||||
import { MediaModule } from "./modules/media/media.module";
|
|
||||||
import { OrdersModule } from "./modules/orders/orders.module";
|
|
||||||
import { PlayerModule } from "./modules/player/player.module";
|
|
||||||
import { HistoryModule } from "./modules/history/history.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule.forRoot({
|
|
||||||
isGlobal: true,
|
|
||||||
}),
|
|
||||||
DatabaseModule,
|
|
||||||
AdminModule,
|
|
||||||
HealthModule,
|
|
||||||
LogsModule,
|
|
||||||
DramasModule,
|
|
||||||
DataModule,
|
|
||||||
MediaModule,
|
|
||||||
AuthModule,
|
|
||||||
OrdersModule,
|
|
||||||
PlayerModule,
|
|
||||||
HistoryModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
18
src/app/app.controller.ts
Normal file
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 { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { TikTokLoginDto } from './dto/tiktok-login.dto';
|
import { TikTokLoginDto } from './auth.dto';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('/api/app/v1/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 { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '@src/users/users.service';
|
||||||
import { TikTokLoginDto } from './dto/tiktok-login.dto';
|
import { TikTokLoginDto } from './auth.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* App bootstrap.
|
||||||
|
* @file 应用全局装配(管道/过滤器/拦截器/文档),供 main 与 e2e 测试共用
|
||||||
|
* @module src/bootstrap
|
||||||
|
* @author zhxiao1124
|
||||||
|
*/
|
||||||
|
|
||||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@app/filters/http.filter";
|
||||||
import { RequestLoggingInterceptor } from "./common/interceptors/request-logging.interceptor";
|
import { LoggerInterceptor } from "@app/interceptor/logger.interceptor";
|
||||||
import { ResponseInterceptor } from "./common/interceptors/response.interceptor";
|
import { ResponseInterceptor } from "@app/interceptor/response.interceptor";
|
||||||
import { RequestIdMiddleware } from "./common/middleware/request-id.middleware";
|
import { RequestIdMiddleware } from "@app/middleware/request-id.middleware";
|
||||||
import { LogsService } from "./modules/logs/logs.service";
|
import { LogsService } from "@src/logs/logs.service";
|
||||||
|
|
||||||
export function bootstrapApp(app: INestApplication) {
|
export function bootstrapApp(app: INestApplication) {
|
||||||
app.enableCors({
|
|
||||||
origin: process.env.CORS_ORIGIN?.split(","),
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
app.use(RequestIdMiddleware);
|
app.use(RequestIdMiddleware);
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -24,11 +27,11 @@ export function bootstrapApp(app: INestApplication) {
|
|||||||
const logsService = app.get(LogsService);
|
const logsService = app.get(LogsService);
|
||||||
|
|
||||||
app.useGlobalFilters(new HttpExceptionFilter(logsService));
|
app.useGlobalFilters(new HttpExceptionFilter(logsService));
|
||||||
app.useGlobalInterceptors(new RequestLoggingInterceptor(logsService, app.get(Reflector)), new ResponseInterceptor());
|
app.useGlobalInterceptors(new LoggerInterceptor(logsService, app.get(Reflector)), new ResponseInterceptor());
|
||||||
|
|
||||||
const config = new DocumentBuilder().setTitle("TikTok Short Drama Backend").setDescription("RESTful API for TikTok short drama backend services.").setVersion("1.0").addBearerAuth().build();
|
const 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);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
|
||||||
SwaggerModule.setup("/api/docs", app, document);
|
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));
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { RequirePermissions } from '../admin/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '@app/decorators/require-permissions.decorator';
|
||||||
import { AdminJwtAuthGuard } from '../admin/guards/admin-jwt-auth.guard';
|
import { AdminJwtAuthGuard } from '@app/guards/admin-jwt-auth.guard';
|
||||||
import { PermissionsGuard } from '../admin/guards/permissions.guard';
|
import { PermissionsGuard } from '@app/guards/permissions.guard';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
@ApiTags('Data')
|
@ApiTags('Data')
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '@src/admin/admin.module';
|
||||||
import { DramasModule } from '../dramas/dramas.module';
|
import { DramasModule } from '@src/dramas/dramas.module';
|
||||||
import { DataController } from './data.controller';
|
import { DataController } from './data.controller';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
import { TikTokDataProvider } from './providers/tiktok-data.provider';
|
import { TikTokDataProvider } from './data.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminModule, DramasModule],
|
imports: [AdminModule, DramasModule],
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DramaRecord, EpisodeRecord } from '../../dramas/interfaces/drama-records.interface';
|
import { DramaRecord, EpisodeRecord } from '@src/dramas/dramas.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TikTokDataProvider {
|
export class TikTokDataProvider {
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Optional } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
import { DramasService } from '@src/dramas/dramas.service';
|
||||||
import { DataSyncJob } from './entities/data-sync-job.entity';
|
import { DataSyncJob } from './data-sync-job.entity';
|
||||||
import { DramaMetric } from './entities/drama-metric.entity';
|
import { DramaMetric } from './drama-metric.entity';
|
||||||
import { EpisodeMetric } from './entities/episode-metric.entity';
|
import { EpisodeMetric } from './episode-metric.entity';
|
||||||
import { TikTokDataProvider } from './providers/tiktok-data.provider';
|
import { TikTokDataProvider } from './data.provider';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { PublishStatus } from '../drama-status.enum';
|
import { PublishStatus } from './dramas.types';
|
||||||
|
|
||||||
@Entity('dramas')
|
@Entity('dramas')
|
||||||
export class Drama {
|
export class Drama {
|
||||||
@@ -24,6 +24,9 @@ export class Drama {
|
|||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
coverId?: number;
|
coverId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
channelId?: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 1024 })
|
@Column({ type: 'varchar', length: 1024 })
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ export class Drama {
|
|||||||
@Column({ type: 'varchar', length: 1024, nullable: true })
|
@Column({ type: 'varchar', length: 1024, nullable: true })
|
||||||
landscapeCoverUrl?: string;
|
landscapeCoverUrl?: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 128 })
|
@Column({ type: 'varchar', length: 128, default: '' })
|
||||||
type: string;
|
type: string;
|
||||||
|
|
||||||
@Column({ type: 'json', nullable: true })
|
@Column({ type: 'json', nullable: true })
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
|
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
|
||||||
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
|
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
|
||||||
import { PermissionsGuard } from "../admin/guards/permissions.guard";
|
import { PermissionsGuard } from "@app/guards/permissions.guard";
|
||||||
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
|
||||||
import { CreateDramaDto } from "./dto/create-drama.dto";
|
|
||||||
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
|
||||||
import { UpdateDramaDto } from "./dto/update-drama.dto";
|
|
||||||
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
|
|
||||||
import { DramasService } from "./dramas.service";
|
import { DramasService } from "./dramas.service";
|
||||||
import { PublishStatus } from "./drama-status.enum";
|
import { PublishStatus } from "./dramas.types";
|
||||||
|
|
||||||
@ApiTags("Dramas")
|
@ApiTags("Dramas")
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -53,6 +49,14 @@ export class DramasController {
|
|||||||
return this.dramasService.updateDrama(Number(id), dto);
|
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")
|
@Post("/api/admin/v1/dramas/:id/episodes/batch")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
@UseGuards(AdminJwtAuthGuard, PermissionsGuard)
|
||||||
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 { 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 { DramasController } from './dramas.controller';
|
||||||
import { DramasService } from './dramas.service';
|
import { DramasService } from './dramas.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminModule],
|
imports: [AdminModule, ChannelsModule],
|
||||||
controllers: [DramasController],
|
controllers: [DramasController],
|
||||||
providers: [DramasService],
|
providers: [DramasService],
|
||||||
exports: [DramasService],
|
exports: [DramasService],
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
|
import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException } from "@nestjs/common";
|
||||||
import { InjectDataSource } from "@nestjs/typeorm";
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource, Like } from "typeorm";
|
import { DataSource, Like } from "typeorm";
|
||||||
import { PaginatedResponse } from "../../common/dto/paginated-response.dto";
|
import { PaginatedResponse } from "@app/common/paginated-response.dto";
|
||||||
import { EpisodeMetric } from "../data/entities/episode-metric.entity";
|
import { ChannelsService } from "@src/channels/channels.service";
|
||||||
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto";
|
import { ChannelRecord } from "@src/channels/channels.types";
|
||||||
import { CreateDramaDto } from "./dto/create-drama.dto";
|
import { EpisodeMetric } from "@src/data/episode-metric.entity";
|
||||||
import { CreateEpisodeDto } from "./dto/create-episode.dto";
|
import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
|
||||||
import { UpdateDramaDto } from "./dto/update-drama.dto";
|
import { Drama } from "./drama.entity";
|
||||||
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
|
import { Episode } from "./episode.entity";
|
||||||
import { Drama } from "./entities/drama.entity";
|
import { DramaRecord, EpisodeRecord, PublishStatus } from "./dramas.types";
|
||||||
import { Episode } from "./entities/episode.entity";
|
|
||||||
import { PublishStatus } from "./drama-status.enum";
|
|
||||||
import { DramaRecord, EpisodeRecord } from "./interfaces/drama-records.interface";
|
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -31,6 +28,17 @@ interface DramaPresearchInput extends PaginationInput {
|
|||||||
|
|
||||||
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
|
type PublicDramaRecord = Omit<DramaRecord, "portraitCoverUrl" | "landscapeCoverUrl">;
|
||||||
|
|
||||||
|
const FALLBACK_DEFAULT_CHANNEL: ChannelRecord = {
|
||||||
|
id: 1,
|
||||||
|
name: "默认频道",
|
||||||
|
code: "default",
|
||||||
|
sortOrder: 0,
|
||||||
|
isDefault: true,
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DramasService {
|
export class DramasService {
|
||||||
private dramaSequence = 1;
|
private dramaSequence = 1;
|
||||||
@@ -42,10 +50,13 @@ export class DramasService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
readonly dataSource?: DataSource,
|
readonly dataSource?: DataSource,
|
||||||
|
@Optional()
|
||||||
|
private readonly channelsService?: ChannelsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
async createDrama(dto: CreateDramaDto): Promise<DramaRecord> {
|
||||||
const { totalEpisodes } = dto;
|
const { totalEpisodes } = dto;
|
||||||
|
const channel = await this.resolveDramaChannel(dto.channelId);
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Drama);
|
const repository = this.dataSource.getRepository(Drama);
|
||||||
const entity = repository.create({
|
const entity = repository.create({
|
||||||
@@ -53,10 +64,11 @@ export class DramasService {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
coverId: dto.coverId,
|
coverId: dto.coverId,
|
||||||
|
channelId: channel.id,
|
||||||
coverUrl: dto.coverUrl,
|
coverUrl: dto.coverUrl,
|
||||||
portraitCoverUrl: dto.portraitCoverUrl,
|
portraitCoverUrl: dto.portraitCoverUrl,
|
||||||
landscapeCoverUrl: dto.landscapeCoverUrl,
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
||||||
type: dto.type,
|
type: dto.type ?? "",
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
region: dto.region,
|
region: dto.region,
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
@@ -73,7 +85,7 @@ export class DramasService {
|
|||||||
});
|
});
|
||||||
return this.getDramaOrThrow(Number(saved.id));
|
return this.getDramaOrThrow(Number(saved.id));
|
||||||
}
|
}
|
||||||
return this.toDramaRecord(saved, 0, 0);
|
return this.withChannelName(this.toDramaRecord(saved, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -86,7 +98,7 @@ export class DramasService {
|
|||||||
coverUrl: dto.coverUrl,
|
coverUrl: dto.coverUrl,
|
||||||
portraitCoverUrl: dto.portraitCoverUrl,
|
portraitCoverUrl: dto.portraitCoverUrl,
|
||||||
landscapeCoverUrl: dto.landscapeCoverUrl,
|
landscapeCoverUrl: dto.landscapeCoverUrl,
|
||||||
type: dto.type,
|
type: dto.type ?? "",
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
region: dto.region,
|
region: dto.region,
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
@@ -95,6 +107,8 @@ export class DramasService {
|
|||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
isRecommended: dto.isRecommended ?? false,
|
isRecommended: dto.isRecommended ?? false,
|
||||||
onlineVersion: dto.onlineVersion ?? 1,
|
onlineVersion: dto.onlineVersion ?? 1,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
publishedEpisodes: 0,
|
publishedEpisodes: 0,
|
||||||
plays: 0,
|
plays: 0,
|
||||||
@@ -162,7 +176,8 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
async updateDrama(id: number, dto: UpdateDramaDto): Promise<DramaRecord> {
|
||||||
const { totalEpisodes, ...dramaUpdates } = dto;
|
const { totalEpisodes, channelId, ...dramaUpdates } = dto;
|
||||||
|
const channel = channelId === undefined ? undefined : await this.resolveDramaChannel(channelId);
|
||||||
if (this.hasDatabase()) {
|
if (this.hasDatabase()) {
|
||||||
const repository = this.dataSource.getRepository(Drama);
|
const repository = this.dataSource.getRepository(Drama);
|
||||||
const drama = await repository.findOne({ where: { id } });
|
const drama = await repository.findOne({ where: { id } });
|
||||||
@@ -171,6 +186,7 @@ export class DramasService {
|
|||||||
}
|
}
|
||||||
repository.merge(drama, {
|
repository.merge(drama, {
|
||||||
...dramaUpdates,
|
...dramaUpdates,
|
||||||
|
...(channel ? { channelId: channel.id } : {}),
|
||||||
tags: dramaUpdates.tags ?? drama.tags,
|
tags: dramaUpdates.tags ?? drama.tags,
|
||||||
});
|
});
|
||||||
const saved = await repository.save(drama);
|
const saved = await repository.save(drama);
|
||||||
@@ -189,6 +205,7 @@ export class DramasService {
|
|||||||
|
|
||||||
Object.assign(drama, {
|
Object.assign(drama, {
|
||||||
...dramaUpdates,
|
...dramaUpdates,
|
||||||
|
...(channel ? { channelId: channel.id, channelName: channel.name } : {}),
|
||||||
tags: dramaUpdates.tags ?? drama.tags,
|
tags: dramaUpdates.tags ?? drama.tags,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -198,6 +215,31 @@ export class DramasService {
|
|||||||
return this.withEpisodeCounts(drama);
|
return this.withEpisodeCounts(drama);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteDrama(id: number) {
|
||||||
|
if (this.hasDatabase()) {
|
||||||
|
const dramaRepository = this.dataSource.getRepository(Drama);
|
||||||
|
const drama = await dramaRepository.findOne({ where: { id } });
|
||||||
|
if (!drama) {
|
||||||
|
throw new NotFoundException("Drama not found");
|
||||||
|
}
|
||||||
|
await this.dataSource.getRepository(Episode).delete({ dramaId: id });
|
||||||
|
await dramaRepository.delete(id);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.dramas.findIndex((item) => item.id === id);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new NotFoundException("Drama not found");
|
||||||
|
}
|
||||||
|
this.dramas.splice(index, 1);
|
||||||
|
for (let episodeIndex = this.episodes.length - 1; episodeIndex >= 0; episodeIndex--) {
|
||||||
|
if (this.episodes[episodeIndex].dramaId === id) {
|
||||||
|
this.episodes.splice(episodeIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
|
async createEpisode(dto: CreateEpisodeDto): Promise<EpisodeRecord> {
|
||||||
const dramaId = dto.dramaId ?? dto.albumId;
|
const dramaId = dto.dramaId ?? dto.albumId;
|
||||||
const episodeNo = dto.episodeNo ?? dto.seq;
|
const episodeNo = dto.episodeNo ?? dto.seq;
|
||||||
@@ -813,7 +855,7 @@ export class DramasService {
|
|||||||
reviewStatus: "approved",
|
reviewStatus: "approved",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes);
|
return this.withChannelName(this.toDramaRecord(entity, totalEpisodes, publishedEpisodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
|
private toDramaRecord(entity: Drama, totalEpisodes: number, publishedEpisodes: number): DramaRecord {
|
||||||
@@ -823,6 +865,8 @@ export class DramasService {
|
|||||||
title: entity.title,
|
title: entity.title,
|
||||||
description: entity.description,
|
description: entity.description,
|
||||||
coverId: entity.coverId,
|
coverId: entity.coverId,
|
||||||
|
channelId: entity.channelId ?? FALLBACK_DEFAULT_CHANNEL.id,
|
||||||
|
channelName: entity.channelId === FALLBACK_DEFAULT_CHANNEL.id || !entity.channelId ? FALLBACK_DEFAULT_CHANNEL.name : undefined,
|
||||||
coverUrl: entity.coverUrl,
|
coverUrl: entity.coverUrl,
|
||||||
portraitCoverUrl: entity.portraitCoverUrl,
|
portraitCoverUrl: entity.portraitCoverUrl,
|
||||||
landscapeCoverUrl: entity.landscapeCoverUrl,
|
landscapeCoverUrl: entity.landscapeCoverUrl,
|
||||||
@@ -843,6 +887,32 @@ export class DramasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async withChannelName(record: DramaRecord): Promise<DramaRecord> {
|
||||||
|
if (!record.channelId) {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
channelId: FALLBACK_DEFAULT_CHANNEL.id,
|
||||||
|
channelName: FALLBACK_DEFAULT_CHANNEL.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.channelName) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.channelsService) {
|
||||||
|
return record.channelId === FALLBACK_DEFAULT_CHANNEL.id
|
||||||
|
? { ...record, channelName: FALLBACK_DEFAULT_CHANNEL.name }
|
||||||
|
: record;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await this.channelsService.getActiveChannelOrThrow(record.channelId);
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
channelName: channel.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
|
private toPublicDramaRecord(record: DramaRecord): PublicDramaRecord {
|
||||||
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
|
const { portraitCoverUrl, landscapeCoverUrl, ...publicRecord } = record;
|
||||||
void portraitCoverUrl;
|
void portraitCoverUrl;
|
||||||
@@ -923,9 +993,21 @@ export class DramasService {
|
|||||||
totalEpisodes: episodes.length,
|
totalEpisodes: episodes.length,
|
||||||
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
publishedEpisodes: episodes.filter((episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === "approved").length,
|
||||||
plays: drama.plays ?? 0,
|
plays: drama.plays ?? 0,
|
||||||
|
channelId: drama.channelId ?? FALLBACK_DEFAULT_CHANNEL.id,
|
||||||
|
channelName: drama.channelName ?? FALLBACK_DEFAULT_CHANNEL.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveDramaChannel(channelId?: number): Promise<ChannelRecord> {
|
||||||
|
if (this.channelsService) {
|
||||||
|
return channelId ? this.channelsService.getActiveChannelOrThrow(channelId) : this.channelsService.getDefaultChannel();
|
||||||
|
}
|
||||||
|
if (channelId && channelId !== FALLBACK_DEFAULT_CHANNEL.id) {
|
||||||
|
throw new NotFoundException("Channel not found");
|
||||||
|
}
|
||||||
|
return FALLBACK_DEFAULT_CHANNEL;
|
||||||
|
}
|
||||||
|
|
||||||
private async getPlayCountsByDramaIds(dramaIds: number[]): Promise<Map<number, number>> {
|
private async getPlayCountsByDramaIds(dramaIds: number[]): Promise<Map<number, number>> {
|
||||||
const playCounts = new Map<number, number>();
|
const playCounts = new Map<number, number>();
|
||||||
if (!this.hasDatabase() || dramaIds.length === 0) {
|
if (!this.hasDatabase() || dramaIds.length === 0) {
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { PublishStatus } from '../drama-status.enum';
|
import { PublishStatus } from './dramas.types';
|
||||||
|
|
||||||
@Entity('episodes')
|
@Entity('episodes')
|
||||||
@Index(['dramaId', 'episodeNo'], { unique: true })
|
@Index(['dramaId', 'episodeNo'], { unique: true })
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '@app/decorators/current-user.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@app/guards/jwt-auth.guard';
|
||||||
import { UserRecord } from '../users/interfaces/user-record.interface';
|
import { UserRecord } from '@src/users/users.types';
|
||||||
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
|
import { UpsertPlaybackHistoryDto } from './history.dto';
|
||||||
import { HistoryService } from './history.service';
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
@ApiTags('History')
|
@ApiTags('History')
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '@src/auth/auth.module';
|
||||||
import { DramasModule } from '../dramas/dramas.module';
|
import { DramasModule } from '@src/dramas/dramas.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '@src/users/users.module';
|
||||||
import { HistoryController } from './history.controller';
|
import { HistoryController } from './history.controller';
|
||||||
import { HistoryService } from './history.service';
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Injectable, Optional } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
import { DramasService } from '@src/dramas/dramas.service';
|
||||||
import {
|
import {
|
||||||
DramaRecord,
|
DramaRecord,
|
||||||
EpisodeRecord,
|
EpisodeRecord,
|
||||||
} from '../dramas/interfaces/drama-records.interface';
|
} from '@src/dramas/dramas.types';
|
||||||
import { UserRecord } from '../users/interfaces/user-record.interface';
|
import { UserRecord } from '@src/users/users.types';
|
||||||
import { UpsertPlaybackHistoryDto } from './dto/upsert-history.dto';
|
import { UpsertPlaybackHistoryDto } from './history.dto';
|
||||||
import { UserPlaybackHistory } from './entities/user-playback-history.entity';
|
import { UserPlaybackHistory } from './user-playback-history.entity';
|
||||||
import {
|
import {
|
||||||
PlaybackHistoryListItem,
|
PlaybackHistoryListItem,
|
||||||
PlaybackHistoryRecord,
|
PlaybackHistoryRecord,
|
||||||
} from './interfaces/history-record.interface';
|
} from './history.types';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
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;
|
||||||
|
}
|
||||||
21
src/main.ts
21
src/main.ts
@@ -1,13 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* App main.
|
||||||
|
* @file Index 入口文件
|
||||||
|
* @module src/main
|
||||||
|
* @author zhxiao1124
|
||||||
|
*/
|
||||||
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from "./app.module";
|
import { APP_CONFIG, OPTIONS_CONFIG } from "@config/app.config";
|
||||||
import { bootstrapApp } from "./bootstrap";
|
import { AppModule } from "@src/app/app.module";
|
||||||
|
import { bootstrapApp } from "@src/bootstrap";
|
||||||
|
|
||||||
|
const logger = new Logger("AppLoader");
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, OPTIONS_CONFIG);
|
||||||
bootstrapApp(app);
|
bootstrapApp(app);
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(APP_CONFIG.PORT);
|
||||||
console.log(`Application is running on: ${await app.getUrl()}`);
|
logger.log(`Application is running on: ${await app.getUrl()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { RequirePermissions } from "../admin/decorators/require-permissions.decorator";
|
import { RequirePermissions } from "@app/decorators/require-permissions.decorator";
|
||||||
import { AdminJwtAuthGuard } from "../admin/guards/admin-jwt-auth.guard";
|
import { AdminJwtAuthGuard } from "@app/guards/admin-jwt-auth.guard";
|
||||||
import { PermissionsGuard } from "../admin/guards/permissions.guard";
|
import { PermissionsGuard } from "@app/guards/permissions.guard";
|
||||||
import { CreateCoverDto } from "./dto/create-cover.dto";
|
import { CreateCoverDto, CreateDirectUploadTaskDto, CreateUploadJobDto } from "./media.dto";
|
||||||
import { CreateDirectUploadTaskDto } from "./dto/create-direct-upload-task.dto";
|
|
||||||
import { CreateUploadJobDto } from "./dto/create-upload-job.dto";
|
|
||||||
import { MediaService } from "./media.service";
|
import { MediaService } from "./media.service";
|
||||||
|
|
||||||
@ApiTags("Media")
|
@ApiTags("Media")
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '@src/admin/admin.module';
|
||||||
import { DramasModule } from '../dramas/dramas.module';
|
import { DramasModule } from '@src/dramas/dramas.module';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
|
import { BytePlusMediaProvider } from './media.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminModule, DramasModule],
|
imports: [AdminModule, DramasModule],
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { BYTEPLUS_CONFIG } from '@config/app.config';
|
||||||
|
|
||||||
interface CreateUploadInput {
|
interface CreateUploadInput {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@@ -14,7 +15,7 @@ interface CompleteUploadInput {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BytePlusMediaProvider {
|
export class BytePlusMediaProvider {
|
||||||
createDirectUpload(input: CreateUploadInput) {
|
createDirectUpload(input: CreateUploadInput) {
|
||||||
const bucket = process.env.BYTEPLUS_BUCKET ?? 'mock-short-drama';
|
const bucket = BYTEPLUS_CONFIG.bucket;
|
||||||
const objectKey = `videos/${input.jobId}/${input.fileName}`;
|
const objectKey = `videos/${input.jobId}/${input.fileName}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2,18 +2,16 @@ import { Injectable, NotFoundException, Optional } from '@nestjs/common';
|
|||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PaginatedResponse } from '../../common/dto/paginated-response.dto';
|
import { PaginatedResponse } from '@app/common/paginated-response.dto';
|
||||||
import { DramasService } from '../dramas/dramas.service';
|
import { DramasService } from '@src/dramas/dramas.service';
|
||||||
import { CreateCoverDto } from './dto/create-cover.dto';
|
import { CreateCoverDto, CreateDirectUploadTaskDto, CreateUploadJobDto } from './media.dto';
|
||||||
import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto';
|
import { Cover } from './cover.entity';
|
||||||
import { CreateUploadJobDto } from './dto/create-upload-job.dto';
|
import { MediaUploadJob } from './media-upload-job.entity';
|
||||||
import { Cover } from './entities/cover.entity';
|
|
||||||
import { MediaUploadJob } from './entities/media-upload-job.entity';
|
|
||||||
import {
|
import {
|
||||||
CoverRecord,
|
CoverRecord,
|
||||||
MediaUploadJobRecord,
|
MediaUploadJobRecord,
|
||||||
} from './interfaces/media-records.interface';
|
} from './media.types';
|
||||||
import { BytePlusMediaProvider } from './providers/byteplus-media.provider';
|
import { BytePlusMediaProvider } from './media.provider';
|
||||||
|
|
||||||
interface PaginationInput {
|
interface PaginationInput {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsArray, IsInt, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateAdminRolesDto {
|
|
||||||
@ApiProperty({ type: [Number] })
|
|
||||||
@IsArray()
|
|
||||||
@IsInt({ each: true })
|
|
||||||
@Min(1, { each: true })
|
|
||||||
roleIds: number[];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsIn } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateAdminStatusDto {
|
|
||||||
@ApiProperty({ enum: ['active', 'disabled'] })
|
|
||||||
@IsIn(['active', 'disabled'])
|
|
||||||
status: 'active' | 'disabled';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsArray, IsInt, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateRolePermissionsDto {
|
|
||||||
@ApiProperty({ type: [Number] })
|
|
||||||
@IsArray()
|
|
||||||
@IsInt({ each: true })
|
|
||||||
@Min(1, { each: true })
|
|
||||||
permissionIds: number[];
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { AdminUserRecord } from '../interfaces/admin-records.interface';
|
|
||||||
import { AdminService } from '../admin.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminJwtAuthGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly adminService: AdminService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<Request & { adminUser?: AdminUserRecord; adminId?: number }>();
|
|
||||||
const authHeader = request.get('authorization');
|
|
||||||
const token = authHeader?.startsWith('Bearer ')
|
|
||||||
? authHeader.slice('Bearer '.length)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('Admin not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await this.jwtService.verifyAsync<{
|
|
||||||
sub: number;
|
|
||||||
type: string;
|
|
||||||
}>(token);
|
|
||||||
if (payload.type !== 'admin') {
|
|
||||||
throw new UnauthorizedException('Admin not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUser = await this.adminService.findAdminById(payload.sub);
|
|
||||||
if (!adminUser || adminUser.status !== 'active') {
|
|
||||||
throw new UnauthorizedException('Admin not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
request.adminUser = adminUser;
|
|
||||||
request.adminId = adminUser.id;
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
throw new UnauthorizedException('Admin not authenticated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import {
|
|
||||||
REQUIRED_PERMISSIONS_METADATA,
|
|
||||||
} from '../decorators/require-permissions.decorator';
|
|
||||||
import { AdminUserRecord } from '../interfaces/admin-records.interface';
|
|
||||||
import { AdminService } from '../admin.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PermissionsGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly adminService: AdminService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const required =
|
|
||||||
this.reflector.getAllAndOverride<string[]>(
|
|
||||||
REQUIRED_PERMISSIONS_METADATA,
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
if (required.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<Request & { adminUser?: AdminUserRecord }>();
|
|
||||||
const adminUser = request.adminUser;
|
|
||||||
|
|
||||||
if (!adminUser) {
|
|
||||||
throw new ForbiddenException('No permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminUser.isSuperAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await this.adminService.getPermissionCodesForAdmin(
|
|
||||||
adminUser.id,
|
|
||||||
);
|
|
||||||
const allowed = required.every((permission) =>
|
|
||||||
permissions.includes(permission),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
throw new ForbiddenException('No permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
UsersModule,
|
|
||||||
JwtModule.register({
|
|
||||||
secret: process.env.JWT_SECRET ?? 'development-secret',
|
|
||||||
signOptions: { expiresIn: '30d' },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService, JwtAuthGuard],
|
|
||||||
exports: [JwtModule, JwtAuthGuard],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { UserRecord } from '../../users/interfaces/user-record.interface';
|
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
|
||||||
(_data: unknown, ctx: ExecutionContext): UserRecord | undefined => {
|
|
||||||
const request = ctx.switchToHttp().getRequest<Request & { user?: UserRecord }>();
|
|
||||||
return request.user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user