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:
2026-04-17 02:27:08 +08:00
parent e5f90078ce
commit 0fa6617341
23 changed files with 3961 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
@Module({
imports: [],
})
export class AdminAppModule {}

View File

@@ -0,0 +1,3 @@
// 占位文件,避免 TS include 报错;当前逻辑已直接在 Controller 中调用 ProviderRouterService。
export {};

View 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 {}

View 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();
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ChatModule } from './chat/chat.module';
@Module({
imports: [ChatModule],
})
export class ClientAppModule {}

View File

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

View File

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

View 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;
}
}

View 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;
}
}

View File

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

View 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;
}