feat: 初始化 Nest 服务骨架与多平台 Chat SSE 网关
- 新增 NestJS + Fastify 入口、配置模块与 Swagger 集成 - 划分 client-app / admin-app 与 shared-domain ai-gateway - 实现统一 SSE Chat 接口,支持千问、DeepSeek、火山引擎非流式上游与网关分片输出 - 补充项目方案与 JWT 最小实现文档 Made-with: Cursor
This commit is contained in:
32
src/app.module.ts
Normal file
32
src/app.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { ClientAppModule } from './apps/client-app/client-app.module';
|
||||
import { AdminAppModule } from './apps/admin-app/admin-app.module';
|
||||
import configuration from './config/configuration';
|
||||
import { validateEnv } from './config/validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
// validate: validateEnv,
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true, translateTime: 'SYS:standard' },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
ClientAppModule,
|
||||
AdminAppModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
7
src/apps/admin-app/admin-app.module.ts
Normal file
7
src/apps/admin-app/admin-app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
})
|
||||
export class AdminAppModule {}
|
||||
|
||||
3
src/apps/client-app/chat/application/chat.service.ts
Normal file
3
src/apps/client-app/chat/application/chat.service.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// 占位文件,避免 TS include 报错;当前逻辑已直接在 Controller 中调用 ProviderRouterService。
|
||||
export {};
|
||||
|
||||
13
src/apps/client-app/chat/chat.module.ts
Normal file
13
src/apps/client-app/chat/chat.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './controllers/chat.controller';
|
||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||
import { QwenProvider } from '@shared/ai-gateway/providers/qwen.provider';
|
||||
import { DeepseekProvider } from '@shared/ai-gateway/providers/deepseek.provider';
|
||||
import { VolcProvider } from '@shared/ai-gateway/providers/volc.provider';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ProviderRouterService, QwenProvider, DeepseekProvider, VolcProvider],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
52
src/apps/client-app/chat/controllers/chat.controller.ts
Normal file
52
src/apps/client-app/chat/controllers/chat.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Inject, Post, Res } from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||
|
||||
function formatSse(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@Controller('client/v1/chat')
|
||||
export class ChatController {
|
||||
constructor(
|
||||
@Inject(ProviderRouterService)
|
||||
private readonly router: ProviderRouterService,
|
||||
) {}
|
||||
|
||||
@Post('completions/stream')
|
||||
async streamChat(
|
||||
@Body() body: StreamChatRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const response = await this.router.routeAndStream(body);
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
reply.raw.write(
|
||||
formatSse('meta', {
|
||||
requestId: response.requestId,
|
||||
platform: response.providerCode,
|
||||
model: response.model,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const chunk of response.chunks) {
|
||||
reply.raw.write(formatSse('delta', { delta: chunk.content }));
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
reply.raw.write(formatSse('usage', response.usage));
|
||||
reply.raw.write(formatSse('done', { finishReason: 'stop' }));
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
8
src/apps/client-app/client-app.module.ts
Normal file
8
src/apps/client-app/client-app.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
})
|
||||
export class ClientAppModule {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { request } from 'undici';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class DeepseekProvider implements AiProvider {
|
||||
readonly code = 'deepseek';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return model.toLowerCase().includes('deepseek');
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
const baseUrl = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('DEEPSEEK_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'deepseek-chat';
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false,
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`DeepSeek 调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
const content: string =
|
||||
json.choices?.[0]?.message?.content ??
|
||||
'[DeepSeek] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ?? Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `deepseek_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: { promptTokens, completionTokens, totalTokens },
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StreamChatRequest, ProviderStreamResult } from '../types/chat.types';
|
||||
|
||||
export interface AiProvider {
|
||||
readonly code: string; // qwen | deepseek | volc | demo
|
||||
|
||||
supports(model?: string): boolean;
|
||||
|
||||
streamChat(req: StreamChatRequest): Promise<ProviderStreamResult>;
|
||||
}
|
||||
|
||||
92
src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts
Normal file
92
src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
import { request } from 'undici';
|
||||
|
||||
@Injectable()
|
||||
export class QwenProvider implements AiProvider {
|
||||
readonly code = 'qwen';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return model.toLowerCase().includes('qwen');
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.QWEN_API_KEY;
|
||||
const baseUrl =
|
||||
process.env.QWEN_BASE_URL ||
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('QWEN_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'qwen-plus';
|
||||
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false, // 先用非流式,统一在网关层拆分为 SSE
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`Qwen 调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
const content: string =
|
||||
choice?.message?.content ??
|
||||
'[Qwen] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ??
|
||||
Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `qwen_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
src/apps/shared-domain/ai-gateway/providers/volc.provider.ts
Normal file
82
src/apps/shared-domain/ai-gateway/providers/volc.provider.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { request } from 'undici';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class VolcProvider implements AiProvider {
|
||||
readonly code = 'volc';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return (
|
||||
model.toLowerCase().includes('volc') ||
|
||||
model.toLowerCase().includes('ark')
|
||||
);
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.VOLC_API_KEY;
|
||||
const baseUrl =
|
||||
process.env.VOLC_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('VOLC_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'ep-default';
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false,
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`火山引擎调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
const content: string =
|
||||
json.choices?.[0]?.message?.content ??
|
||||
'[Volc] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ?? Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `volc_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: { promptTokens, completionTokens, totalTokens },
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ProviderStreamResult,
|
||||
StreamChatRequest,
|
||||
} from '../types/chat.types';
|
||||
import { AiProvider } from '../providers/provider.interface';
|
||||
import { QwenProvider } from '../providers/qwen.provider';
|
||||
import { DeepseekProvider } from '../providers/deepseek.provider';
|
||||
import { VolcProvider } from '../providers/volc.provider';
|
||||
|
||||
@Injectable()
|
||||
export class ProviderRouterService {
|
||||
private readonly providers: AiProvider[];
|
||||
|
||||
constructor(
|
||||
@Inject(QwenProvider)
|
||||
private readonly qwen: QwenProvider,
|
||||
@Inject(DeepseekProvider)
|
||||
private readonly deepseek: DeepseekProvider,
|
||||
@Inject(VolcProvider)
|
||||
private readonly volc: VolcProvider,
|
||||
) {
|
||||
this.providers = [qwen, deepseek, volc];
|
||||
}
|
||||
|
||||
async routeAndStream(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const platform = (req.platform || 'auto').toLowerCase();
|
||||
|
||||
if (platform !== 'auto') {
|
||||
const target = this.providers.find((p) => p.code === platform);
|
||||
if (!target) {
|
||||
return this.buildFallback(req, `未知平台:${platform}`);
|
||||
}
|
||||
return target.streamChat(req);
|
||||
}
|
||||
|
||||
const candidate =
|
||||
this.providers.find((p) => p.supports(req.model)) || this.qwen;
|
||||
return candidate.streamChat(req);
|
||||
}
|
||||
|
||||
private async buildFallback(
|
||||
req: StreamChatRequest,
|
||||
reason: string,
|
||||
): Promise<ProviderStreamResult> {
|
||||
const lastUserMessage =
|
||||
[...(req.messages || [])].reverse().find((m) => m.role === 'user')
|
||||
?.content || '';
|
||||
|
||||
const text = `【路由降级】${reason}。直接返回 demo 内容:${lastUserMessage}`;
|
||||
const chunks = this.splitText(text, 12).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: `fallback_${Date.now()}`,
|
||||
providerCode: 'demo',
|
||||
model: req.model || 'demo-model',
|
||||
chunks,
|
||||
usage: {
|
||||
promptTokens: Math.max(1, lastUserMessage.length),
|
||||
completionTokens: Math.max(1, text.length),
|
||||
totalTokens: Math.max(2, lastUserMessage.length + text.length),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/apps/shared-domain/ai-gateway/types/chat.types.ts
Normal file
31
src/apps/shared-domain/ai-gateway/types/chat.types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamChatRequest {
|
||||
model?: string;
|
||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface ProviderStreamChunk {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ProviderUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ProviderStreamResult {
|
||||
requestId: string;
|
||||
providerCode: string;
|
||||
model: string;
|
||||
chunks: ProviderStreamChunk[];
|
||||
usage: ProviderUsage;
|
||||
}
|
||||
|
||||
68
src/config/configuration.ts
Normal file
68
src/config/configuration.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export interface JwtConfig {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessExpiresIn: string;
|
||||
refreshExpiresIn: string;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
db: number;
|
||||
keyPrefixClient: string;
|
||||
keyPrefixAdmin: string;
|
||||
}
|
||||
|
||||
export interface AiRouteConfig {
|
||||
retryTimes: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface AppConfiguration {
|
||||
app: AppConfig;
|
||||
jwt: JwtConfig;
|
||||
database: DatabaseConfig;
|
||||
redis: RedisConfig;
|
||||
aiRoute: AiRouteConfig;
|
||||
}
|
||||
|
||||
export default (): AppConfiguration => ({
|
||||
app: {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
appName: process.env.APP_NAME || 'chat-one-service',
|
||||
},
|
||||
jwt: {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET || 'change-me-access',
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'change-me-refresh',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
},
|
||||
database: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://postgres:postgres@127.0.0.1:5432/chat_one?schema=public',
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
db: Number(process.env.REDIS_DB || 0),
|
||||
keyPrefixClient: process.env.REDIS_KEY_PREFIX_CLIENT || 'chatone:client',
|
||||
keyPrefixAdmin: process.env.REDIS_KEY_PREFIX_ADMIN || 'chatone:admin',
|
||||
},
|
||||
aiRoute: {
|
||||
retryTimes: Number(process.env.AI_ROUTE_RETRY_TIMES || 1),
|
||||
timeoutMs: Number(process.env.AI_ROUTE_TIMEOUT_MS || 45000),
|
||||
},
|
||||
});
|
||||
|
||||
22
src/config/swagger.ts
Normal file
22
src/config/swagger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
export function setupSwagger(app: INestApplication) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('ChatOne Service')
|
||||
.setDescription('ChatOne API (client & admin)')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
'access-token',
|
||||
)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('/docs', app, document);
|
||||
}
|
||||
|
||||
64
src/config/validation.ts
Normal file
64
src/config/validation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
PORT?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
APP_NAME?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_ACCESS_SECRET?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_REFRESH_SECRET?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_URL?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
REDIS_HOST?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
REDIS_PORT?: number;
|
||||
}
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: true,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Environment validation failed', JSON.stringify(errors));
|
||||
throw new Error('Environment validation failed');
|
||||
}
|
||||
return validatedConfig;
|
||||
}
|
||||
|
||||
36
src/main.ts
Normal file
36
src/main.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { setupSwagger } from './config/swagger';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ logger: false }),
|
||||
);
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
|
||||
await app.register(helmet as any);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
setupSwagger(app);
|
||||
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
logger.log(`Application is running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Fatal bootstrap error', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
10
src/prisma/prisma.module.ts
Normal file
10
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
30
src/prisma/prisma.service.ts
Normal file
30
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
INestApplication,
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
// Prisma v7 默认导出 PrismaClient
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
this.$on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user