refactor: 完成项目整体目录重构与路径别名配置

1.  重构项目文件结构,统一文件存放位置与命名规则
2.  配置tsconfig路径别名,替换原有相对路径导入
3.  迁移drama-status枚举到dramas.types统一管理
4.  新增全局配置文件与工具类,补充完整注释与文档
5.  修复所有导入路径与依赖引用问题
This commit is contained in:
2026-07-02 19:17:56 +08:00
parent e84351e8ed
commit 4eff4d877e
71 changed files with 1216 additions and 764 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
} }

View File

@@ -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')

View File

@@ -1,13 +1,123 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, MinLength } from 'class-validator'; import { IsArray, IsEmail, IsIn, IsInt, IsOptional, IsString, Min, MinLength } from "class-validator";
export class AdminLoginDto { export class AdminLoginDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
username: string; username: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@MinLength(6) @MinLength(6)
password: string; 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[];
} }

View File

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

View File

@@ -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;
@@ -434,10 +431,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 +463,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: [],

View File

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

View File

@@ -1,33 +1,70 @@
/**
* App module.
* @file App 主模块
* @module src/app/module
* @author zhxiao1124
*/
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm";
import { AdminModule } from "./modules/admin/admin.module"; import { DB_CONFIG } from "@config/app.config";
import { AuthModule } from "./modules/auth/auth.module"; import { AppController } from "@src/app/app.controller";
import { DataModule } from "./modules/data/data.module";
import { DatabaseModule } from "./modules/database/database.module"; // 业务模块
import { DramasModule } from "./modules/dramas/dramas.module"; import { AdminModule } from "@src/admin/admin.module";
import { HealthModule } from "./modules/health/health.module"; import { AuthModule } from "@src/auth/auth.module";
import { LogsModule } from "./modules/logs/logs.module"; import { UsersModule } from "@src/users/users.module";
import { MediaModule } from "./modules/media/media.module"; import { DramasModule } from "@src/dramas/dramas.module";
import { OrdersModule } from "./modules/orders/orders.module"; import { DataModule } from "@src/data/data.module";
import { PlayerModule } from "./modules/player/player.module"; import { MediaModule } from "@src/media/media.module";
import { HistoryModule } from "./modules/history/history.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 { 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";
// 未配置 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],
}),
]
: [];
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ...databaseImports,
isGlobal: true,
}), AdminModule, // 管理端(人员/角色/权限)
DatabaseModule, LogsModule, // 请求与播放错误日志
AdminModule, DramasModule, // 短剧与剧集
HealthModule, DataModule, // 数据指标与同步
LogsModule, MediaModule, // 媒体上传与封面
DramasModule, AuthModule, // 用户端认证
DataModule, UsersModule, // 用户
MediaModule, OrdersModule, // 订单与解锁
AuthModule, PlayerModule, // 播放器
OrdersModule, HistoryModule, // 播放历史
PlayerModule,
HistoryModule,
], ],
controllers: [AppController],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -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')

View File

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

View File

@@ -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 {

View File

@@ -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);
} }

View File

@@ -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')

View File

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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -1,6 +0,0 @@
export enum PublishStatus {
Draft = 'draft',
Scheduled = 'scheduled',
Published = 'published',
Offline = 'offline',
}

View File

@@ -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 {

View File

@@ -1,15 +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 { 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()

View File

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

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '@src/admin/admin.module';
import { DramasController } from './dramas.controller'; import { DramasController } from './dramas.controller';
import { DramasService } from './dramas.service'; import { DramasService } from './dramas.service';

View File

@@ -1,17 +1,12 @@
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 { EpisodeMetric } from "@src/data/episode-metric.entity";
import { ConfigureEpisodesDto } from "./dto/configure-episodes.dto"; import { ConfigureEpisodesDto, CreateDramaDto, CreateEpisodeDto, UpdateDramaDto, UpdateEpisodeDto } from "./dramas.dto";
import { CreateDramaDto } from "./dto/create-drama.dto"; import { Drama } from "./drama.entity";
import { CreateEpisodeDto } from "./dto/create-episode.dto"; import { Episode } from "./episode.entity";
import { UpdateDramaDto } from "./dto/update-drama.dto"; import { DramaRecord, EpisodeRecord, PublishStatus } from "./dramas.types";
import { UpdateEpisodeDto } from "./dto/update-episode.dto";
import { Drama } from "./entities/drama.entity";
import { Episode } from "./entities/episode.entity";
import { PublishStatus } from "./drama-status.enum";
import { DramaRecord, EpisodeRecord } from "./interfaces/drama-records.interface";
interface PaginationInput { interface PaginationInput {
page: number; page: number;

View File

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

View File

@@ -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 })

View File

@@ -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')

View File

@@ -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';

View File

@@ -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;

View File

@@ -1,10 +1,10 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiMessage } from '../../common/decorators/api-message.decorator'; import { ApiMessage } from '@app/decorators/api-message.decorator';
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 { CreatePlaybackErrorLogDto } from './dto/create-playback-error-log.dto'; import { CreatePlaybackErrorLogDto } from './logs.dto';
import { LogsService } from './logs.service'; import { LogsService } from './logs.service';
@ApiTags('Logs') @ApiTags('Logs')

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '@src/admin/admin.module';
import { LogsController } from './logs.controller'; import { LogsController } from './logs.controller';
import { LogsService } from './logs.service'; import { LogsService } from './logs.service';

View File

@@ -1,14 +1,14 @@
import { Injectable, NotFoundException, Optional } from '@nestjs/common'; import { Injectable, NotFoundException, 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 { CreatePlaybackErrorLogDto } from './dto/create-playback-error-log.dto'; import { CreatePlaybackErrorLogDto } from './logs.dto';
import { PlaybackErrorLog } from './entities/playback-error-log.entity'; import { PlaybackErrorLog } from './playback-error-log.entity';
import { RequestLog } from './entities/request-log.entity'; import { RequestLog } from './request-log.entity';
import { import {
PlaybackErrorLogRecord, PlaybackErrorLogRecord,
RequestLogRecord, RequestLogRecord,
} from './interfaces/log-records.interface'; } from './logs.types';
interface PaginationInput { interface PaginationInput {
page: number; page: number;

View File

@@ -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();

View File

@@ -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")

View File

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

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { 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],

View File

@@ -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 {

View File

@@ -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;

View File

@@ -1,13 +1,12 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, 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 { 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 { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto, TikTokPaymentWebhookDto } from './orders.dto';
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
@ApiTags('Orders') @ApiTags('Orders')

View File

@@ -1,9 +1,39 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsInt, Min } from 'class-validator'; import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
export class CreateOrderDto { export class CreateOrderDto {
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@Min(1) @Min(1)
episodeId: number; episodeId: number;
}
export class TikTokPaymentWebhookDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
orderNo?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tradeOrderId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tiktokOrderId?: string;
@ApiProperty()
@IsString()
providerTransactionId: string;
@ApiProperty({ enum: ["paid", "failed"] })
@IsIn(["paid", "failed"])
status: "paid" | "failed";
@ApiPropertyOptional()
@IsOptional()
@IsString()
signature?: string;
} }

View File

@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '@src/auth/auth.module';
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 { UsersModule } from '../users/users.module'; import { UsersModule } from '@src/users/users.module';
import { OrdersController } from './orders.controller'; import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';

View File

@@ -6,18 +6,17 @@ import {
} from '@nestjs/common'; } 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 { UserRecord } from '../users/interfaces/user-record.interface'; import { UserRecord } from '@src/users/users.types';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto, TikTokPaymentWebhookDto } from './orders.dto';
import { TikTokPaymentWebhookDto } from './dto/tiktok-payment-webhook.dto'; import { Order } from './order.entity';
import { Order } from './entities/order.entity'; import { UserEpisodeUnlock } from './user-episode-unlock.entity';
import { UserEpisodeUnlock } from './entities/user-episode-unlock.entity';
import { import {
OrderRecord, OrderRecord,
OrderStatus, OrderStatus,
UserEpisodeUnlockRecord, UserEpisodeUnlockRecord,
} from './interfaces/order-records.interface'; } from './orders.types';
@Injectable() @Injectable()
export class OrdersService { export class OrdersService {

View File

@@ -1,8 +1,8 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, 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 { PlayerService } from './player.service'; import { PlayerService } from './player.service';
@ApiTags('Player') @ApiTags('Player')

View File

@@ -1,8 +1,8 @@
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 { OrdersModule } from '../orders/orders.module'; import { OrdersModule } from '@src/orders/orders.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '@src/users/users.module';
import { EpisodePlayerController, PlayerController } from './player.controller'; import { EpisodePlayerController, PlayerController } from './player.controller';
import { PlayerService } from './player.service'; import { PlayerService } from './player.service';

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DramasService } from '../dramas/dramas.service'; import { DramasService } from '@src/dramas/dramas.service';
import { OrdersService } from '../orders/orders.service'; import { OrdersService } from '@src/orders/orders.service';
import { UserRecord } from '../users/interfaces/user-record.interface'; import { UserRecord } from '@src/users/users.types';
@Injectable() @Injectable()
export class PlayerService { export class PlayerService {

View File

@@ -1,9 +1,9 @@
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, 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 { UpdateUserStatusDto } from './dto/update-user-status.dto'; import { UpdateUserStatusDto } from './users.dto';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@ApiTags('Users') @ApiTags('Users')

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '@src/admin/admin.module';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';

View File

@@ -1,9 +1,9 @@
import { Injectable, NotFoundException, Optional } from "@nestjs/common"; import { Injectable, NotFoundException, 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 { User } from "./entities/user.entity"; import { User } from "./user.entity";
import { UserRecord } from "./interfaces/user-record.interface"; import { UserRecord } from "./users.types";
interface UpsertTikTokUserInput { interface UpsertTikTokUserInput {
tiktokOpenId: string; tiktokOpenId: string;

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -2,7 +2,7 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { validate as validateUuid, version as uuidVersion } from 'uuid'; import { validate as validateUuid, version as uuidVersion } from 'uuid';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -1,10 +1,10 @@
import { AdminService } from '../src/modules/admin/admin.service'; import { AdminService } from '../src/admin/admin.service';
import { DataService } from '../src/modules/data/data.service'; import { DataService } from '../src/data/data.service';
import { TikTokDataProvider } from '../src/modules/data/providers/tiktok-data.provider'; import { TikTokDataProvider } from '../src/data/data.provider';
import { PublishStatus } from '../src/modules/dramas/drama-status.enum'; import { PublishStatus } from '../src/dramas/dramas.types';
import { DramasService } from '../src/modules/dramas/dramas.service'; import { DramasService } from '../src/dramas/dramas.service';
import { LogsService } from '../src/modules/logs/logs.service'; import { LogsService } from '../src/logs/logs.service';
import { UsersService } from '../src/modules/users/users.service'; import { UsersService } from '../src/users/users.service';
function createRepository<T extends { id?: number }>() { function createRepository<T extends { id?: number }>() {
const records: T[] = []; const records: T[] = [];

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -6,5 +6,11 @@
"watchman": false, "watchman": false,
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@src/(.*)$": "<rootDir>/src/$1",
"^@config/(.*)$": "<rootDir>/config/$1",
"^@constants/(.*)$": "<rootDir>/constants/$1",
"^@app/(.*)$": "<rootDir>/$1"
} }
} }

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -1,7 +1,7 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as request from 'supertest'; import * as request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app/app.module';
import { bootstrapApp } from '../src/bootstrap'; import { bootstrapApp } from '../src/bootstrap';
import { loginAdmin } from './test-helpers'; import { loginAdmin } from './test-helpers';

View File

@@ -14,6 +14,13 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"noImplicitAny": false "noImplicitAny": false,
} "paths": {
"@app/*": ["./*"],
"@src/*": ["./src/*"],
"@config/*": ["./config/*"],
"@constants/*": ["./constants/*"]
}
},
"exclude": ["node_modules", "dist"]
} }

View File

@@ -1,10 +1,17 @@
import { Request } from 'express'; /**
* Request context util.
* @file 请求上下文工具
* @module utils/request-context.util
* @author zhxiao1124
*/
import { Request } from "express";
export function getRequestCost(request: Request & { startTime?: bigint }) { export function getRequestCost(request: Request & { startTime?: bigint }) {
if (!request.startTime) { if (!request.startTime) {
return 0; return 0;
} }
const diff = process.hrtime.bigint() - request.startTime; const diff = process.hrtime.bigint() - request.startTime;
return Number(diff / BigInt(1_000_000)); return Number(diff / BigInt(1_000_000));
} }