feat : 添加后端的统一日志系统

This commit is contained in:
肖应宇 2026-03-03 11:31:05 +08:00
parent c97e227685
commit a9b31f540f
8 changed files with 641 additions and 89 deletions

2
.gitignore vendored
View File

@ -11,7 +11,7 @@ node_modules
dist
dist-ssr
*.local
uploads
.env
# Editor directories and files

84
CLAUDE.md Normal file
View File

@ -0,0 +1,84 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
npm run dev # Start Vite dev server
npm run build # Type-check and build for production
npm run preview # Preview production build
```
## Architecture Overview
This is a Vue 3 + TypeScript AI chat UI application built with the following stack:
**Core:**
- Vue 3 (Composition API with `<script setup>`)
- Pinia for state management
- TypeScript with path alias `@/*``src/*`
- Vite for build tooling
**Styling:**
- TailwindCSS 4 + UnoCSS (dual setup)
- Custom SCSS for global styles
- Dark mode support via `.dark` class
**Key Dependencies:**
- `markstream-vue` - Streaming markdown rendering
- `lucide-vue-next` - Icon library
- `@vueuse/core` - Vue composables utilities
- `echarts`, `mermaid`, `shiki` - Visualization and code highlighting
## Project Structure
```
src/
├── components/ # Vue components organized by feature
│ ├── chat/ # Main chat interface (ChatMain, ChatHeader, MessageList, WelcomeScreen)
│ ├── message/ # Message rendering (MessageBubble, CodeBlock, ThinkingNode, etc.)
│ ├── input/ # Chat input and attachments
│ ├── sidebar/ # Conversation list sidebar
│ ├── modals/ # Settings and search modals
│ └── ui/ # Reusable form components
├── stores/ # Pinia stores
│ ├── chat.ts # Conversation and message management
│ └── settings.ts # App settings and theme
├── composables/ # Reusable composables (useKeyboard)
├── services/ # API layer (api.ts - chat streaming, file upload)
├── types/ # TypeScript type definitions
└── utils/ # Helper functions
```
## State Management
Two Pinia stores manage application state:
**`useChatStore`** (`src/stores/chat.ts`):
- Manages conversations (CRUD operations)
- Handles message streaming state
- Persists to localStorage
**`useSettingsStore`** (`src/stores/settings.ts`):
- App settings (theme, font size, preferences)
- Sidebar state
- Modal visibility controls
- Persists to localStorage
## API Layer
`src/services/api.ts` defines the API contract. The backend must implement these endpoints:
- `POST /api/chat-ui/chat` - Chat (streaming supported)
- `GET /api/chat-ui/conversations` - List conversations
- `POST /api/chat-ui/upload` - File upload
- `GET /api/chat-ui/models` - Model list
- `POST /api/chat-ui/stop/:id` - Stop generation
## Development Notes
- Base path configured as `/chat-ui/` in `vite.config.ts`
- Proxy configured for `/api/chat-ui``http://localhost:3000`
- UnoCSS shortcuts available: `flex-center`, `full`, and regex-based patterns
- Toast notifications available globally via `window.$toast(message, type)`

101
server/docs/logging.md Normal file
View File

@ -0,0 +1,101 @@
# 日志系统文档
## 概述
本项目实现了统一的后端日志系统,为所有接口提供了详细的日志记录功能,包括请求、响应、错误等状态。
## 日志系统结构
### 1. logger.js
- 统一日志管理模块
- 支持多种日志级别ERROR, WARN, INFO, DEBUG
- 支持控制台和文件双重输出
- 包含HTTP请求专用日志方法
### 2. 日志文件位置
- 存储在 `server/logs/` 目录
- 按日期命名:`server-YYYY-MM-DD.log`
- JSON格式便于查询和分析
## 日志级别
- `ERROR`: 错误和异常情况
- `WARN`: 警告和潜在问题
- `INFO`: 重要操作和状态信息
- `DEBUG`: 详细调试信息
## 接口日志详情
### 1. 对话接口 (`/api/chat-ui/chat`)
- 记录请求代理开始和结束
- 记录目标API响应状态
- 记录代理错误
### 2. 模型列表接口 (`/api/chat-ui/models`)
- 记录请求基本信息
- 记录返回的模型数量
### 3. 对话管理接口 (`/api/chat-ui/conversations/*`)
- 记录操作类型(获取/创建/删除)
- 记录对话ID
- 记录操作结果
### 4. 文件上传接口 (`/api/chat-ui/upload`)
- 记录上传文件信息(名称、大小、类型)
- 记录上传结果和生成的URL
### 5. 停止生成接口 (`/api/chat-ui/stop/*`)
- 记录停止请求的ID
- 记录操作结果
### 6. HTTP通用日志
- 记录所有请求的方法、URL、状态码
- 记录请求耗时
- 记录客户端IP和User-Agent
- 根据状态码自动分类日志级别
## 使用方法
### 环境变量
```bash
LOG_LEVEL=DEBUG # 设置日志级别默认为INFO
```
### 在代码中使用
```javascript
const { logger } = require('./logger');
logger.info('Some info message', { additional: 'data' });
logger.error('Error occurred', { error: error.message });
logger.debug('Debug info', { variable: value });
```
## 日志示例
### HTTP请求日志
```json
{
"timestamp": "2026-03-03T03:26:58.631Z",
"level": "INFO",
"message": "HTTP GET /api/chat-ui/models 200 15ms",
"method": "GET",
"url": "/api/chat-ui/models",
"statusCode": 200,
"duration": "15ms",
"userAgent": "Mozilla/5.0...",
"ip": "::1"
}
```
### 特定接口日志
```json
{
"timestamp": "2026-03-03T03:27:12.123Z",
"level": "INFO",
"message": "Successfully uploaded file",
"filename": "1642342342-file.jpg",
"originalName": "file.jpg",
"url": "http://localhost:3000/uploads/1642342342-file.jpg",
"size": 123456
}
```

96
server/logger.js Normal file
View File

@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
// 日志级别枚举
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
};
// 创建logs目录
const logsDir = path.join(__dirname, 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir);
}
// 日志记录器类
class Logger {
constructor(level = 'INFO') {
this.level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
this.logFilePath = path.join(logsDir, `server-${new Date().toISOString().split('T')[0]}.log`);
}
_shouldLog(level) {
return LOG_LEVELS[level.toUpperCase()] <= this.level;
}
_writeLog(level, message, meta = {}) {
if (!this._shouldLog(level)) return;
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level,
message,
...meta,
};
// 控制台输出
console.log(`[${timestamp}] [${level}] ${message}`, meta);
// 文件输出
try {
const logLine = JSON.stringify(logEntry) + '\n';
fs.appendFileSync(this.logFilePath, logLine);
} catch (err) {
console.error('Failed to write to log file:', err);
}
}
error(message, meta = {}) {
this._writeLog('ERROR', message, meta);
}
warn(message, meta = {}) {
this._writeLog('WARN', message, meta);
}
info(message, meta = {}) {
this._writeLog('INFO', message, meta);
}
debug(message, meta = {}) {
this._writeLog('DEBUG', message, meta);
}
// HTTP请求日志专用方法
http(req, res, startTime, error = null) {
const duration = Date.now() - startTime;
const logMeta = {
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration: `${duration}ms`,
userAgent: req.headers['user-agent'],
ip: req.ip || req.connection.remoteAddress,
...(error ? { error: error.message || error } : {})
};
const statusCategory = Math.floor(res.statusCode / 100);
let level = 'INFO';
if (error || statusCategory >= 5) {
level = 'ERROR';
} else if (statusCategory >= 4) {
level = 'WARN';
}
this._writeLog(level, `HTTP ${req.method} ${req.url} ${res.statusCode} ${duration}ms`, logMeta);
}
}
// 创建全局日志实例
const logger = new Logger(process.env.LOG_LEVEL || 'INFO');
module.exports = { logger, LOG_LEVELS };

View File

@ -8,23 +8,49 @@ const fs = require('fs');
const morgan = require('morgan');
require('dotenv').config();
// 引入自定义日志系统
const { logger } = require('./logger');
const app = express();
const PORT = process.env.PORT || 3000;
// 配置全局请求日志,可以在终端里看到每个到达 Node 端点的请求记录
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
// 配置全局请求日志中间件
app.use((req, res, next) => {
const startTime = Date.now();
// 保存原始的res.end方法
const originalEnd = res.end;
res.end = function(chunk, encoding) {
// 调用自定义日志记录
logger.http(req, res, startTime);
// 调用原始的res.end
originalEnd.call(this, chunk, encoding);
};
next();
});
// 配置 CORS允许前端项目的跨域请求
app.use(cors());
// --- 1. 流式对话请求代理配置 ---
// 在请求交给代理组件之前,拦截所有的 /api/chat-ui/chat 并强行给 Header 加上 Bearer Token
app.use('/api/chat-ui/chat', (req, res, next) => {
logger.info('Received chat request', {
method: req.method,
url: req.url,
headers: {
'content-type': req.headers['content-type'],
'content-length': req.headers['content-length']
}
});
const apiKey = process.env.ALIYUN_API_KEY;
if (!apiKey) {
console.error("【错误】发送代理请求前未配置 ALIYUN_API_KEY !");
logger.error("【错误】发送代理请求前未配置 ALIYUN_API_KEY !");
} else {
req.headers['authorization'] = `Bearer ${apiKey}`;
logger.debug('Added authorization header for chat request');
}
next();
});
@ -39,6 +65,28 @@ app.use(
// 去除路径前缀,确保发往阿里云的路径是纯正的 completions 路径
pathRewrite: {
'^/api/chat-ui/chat': '',
},
// 代理日志
onProxyReq: (proxyReq, req, res) => {
logger.info('Proxying chat request to DashScope API', {
target: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
method: req.method,
url: req.url
});
},
onProxyRes: (proxyRes, req, res) => {
logger.info('Received response from DashScope API', {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers
});
},
onError: (err, req, res) => {
logger.error('Error occurred during proxying chat request', {
error: err.message,
method: req.method,
url: req.url
});
res.status(500).json({ error: 'Proxy error: ' + err.message });
}
})
);
@ -46,9 +94,19 @@ app.use(
// --- 下面的路由专门走 Node.js 业务逻辑,因此需要解析 JSON Body ---
app.use(express.json());
// 内存中暂时存放对话数据用于 Mock
const conversationsDB = {};
// --- 2. 获取模型列表 ---
app.get('/api/chat-ui/models', (req, res) => {
res.json([
logger.info('Getting models list', {
method: req.method,
url: req.url,
ip: req.ip || req.connection.remoteAddress
});
try {
const models = [
{
id: "qwen-max",
name: "通义千问 Max",
@ -63,45 +121,115 @@ app.get('/api/chat-ui/models', (req, res) => {
maxTokens: 8192,
provider: "Aliyun"
}
]);
});
];
// 内存中暂时存放对话数据用于 Mock
const conversationsDB = {};
res.json(models);
logger.info('Successfully returned models list', { count: models.length });
} catch (error) {
logger.error('Error getting models list', { error: error.message });
res.status(500).json({ error: 'Internal server error' });
}
});
// --- 3. 获取所有对话历史 ---
app.get('/api/chat-ui/conversations', (req, res) => {
res.json(Object.values(conversationsDB));
logger.info('Getting all conversations', {
method: req.method,
url: req.url,
ip: req.ip || req.connection.remoteAddress
});
try {
const conversations = Object.values(conversationsDB);
res.json(conversations);
logger.info('Successfully returned conversations', { count: conversations.length });
} catch (error) {
logger.error('Error getting conversations', { error: error.message });
res.status(500).json({ error: 'Internal server error' });
}
});
// --- 4. 获取单个对话 ---
app.get('/api/chat-ui/conversations/:id', (req, res) => {
const { id } = req.params;
logger.info('Getting conversation by ID', {
method: req.method,
url: req.url,
conversationId: id,
ip: req.ip || req.connection.remoteAddress
});
try {
const conversation = conversationsDB[id];
if (conversation) {
res.json(conversation);
logger.info('Successfully returned conversation', { conversationId: id });
} else {
res.status(404).json({ error: '对话不存在' });
logger.warn('Conversation not found', { conversationId: id });
}
} catch (error) {
logger.error('Error getting conversation by ID', {
conversationId: id,
error: error.message
});
res.status(500).json({ error: 'Internal server error' });
}
});
// --- 5. 保存或更新对话 ---
// 前端可能会在 /api/chat-ui/conversations/:id 用 POST 或 PUT 更新? 也可以直接提供一个保存接口
app.post('/api/chat-ui/conversations', (req, res) => {
logger.info('Creating or updating conversation', {
method: req.method,
url: req.url,
body: {
hasId: !!req.body.id,
title: req.body.title
},
ip: req.ip || req.connection.remoteAddress
});
try {
const data = req.body;
if(!data.id) data.id = uuidv4();
if(!data.id) {
data.id = uuidv4();
logger.debug('Generated new conversation ID', { conversationId: data.id });
}
conversationsDB[data.id] = data;
res.json(data);
})
logger.info('Successfully saved conversation', { conversationId: data.id });
} catch (error) {
logger.error('Error saving conversation', { error: error.message });
res.status(500).json({ error: 'Internal server error' });
}
});
// --- 6. 删除对话 ---
app.delete('/api/chat-ui/conversations/:id', (req, res) => {
const { id } = req.params;
logger.info('Deleting conversation', {
method: req.method,
url: req.url,
conversationId: id,
ip: req.ip || req.connection.remoteAddress
});
try {
if (conversationsDB[id]) {
delete conversationsDB[id];
res.json({ success: true, message: "删除成功" });
logger.info('Successfully deleted conversation', { conversationId: id });
} else {
res.status(404).json({ error: '对话不存在' });
logger.warn('Attempted to delete non-existent conversation', { conversationId: id });
}
} catch (error) {
logger.error('Error deleting conversation', {
conversationId: id,
error: error.message
});
res.status(500).json({ error: 'Internal server error' });
}
});
@ -109,6 +237,7 @@ app.delete('/api/chat-ui/conversations/:id', (req, res) => {
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
logger.info('Created uploads directory', { path: uploadDir });
}
// 配置文件上传
@ -128,31 +257,87 @@ app.use('/uploads', express.static(uploadDir));
// --- 7. 上传文件 ---
app.post('/api/chat-ui/upload', upload.single('file'), (req, res) => {
logger.info('Processing file upload', {
method: req.method,
url: req.url,
originalFilename: req.file ? req.file.originalname : 'none',
mimetype: req.file ? req.file.mimetype : 'none',
size: req.file ? req.file.size : 'none',
ip: req.ip || req.connection.remoteAddress
});
if (!req.file) {
return res.status(400).json({ error: '没有文件上传' });
const errorMsg = '没有文件上传';
logger.warn(errorMsg, {
method: req.method,
url: req.url
});
return res.status(400).json({ error: errorMsg });
}
try {
// 返回供前端使用和访问的 URL
res.json({
const response = {
url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
name: req.file.originalname,
size: req.file.size,
mimeType: req.file.mimetype
};
res.json(response);
logger.info('Successfully uploaded file', {
filename: req.file.filename,
originalName: req.file.originalname,
url: response.url,
size: req.file.size
});
} catch (error) {
logger.error('Error processing file upload', {
originalFilename: req.file.originalname,
error: error.message
});
res.status(500).json({ error: 'File upload processing failed' });
}
});
// --- 8. 停止生成 ---
// 这个接口对于本地代理没有实际效果,因为流的断开是通过底层 AbortController 控制的,此处直接返回成功
app.post(['/api/chat-ui/stop', '/api/chat-ui/stop/:id'], (req, res) => {
const id = req.params.id;
logger.info('Stop generation request received', {
method: req.method,
url: req.url,
messageId: id,
ip: req.ip || req.connection.remoteAddress
});
try {
res.json({ success: true, message: "已发出停止指令" });
logger.info('Stop generation request processed', { messageId: id });
} catch (error) {
logger.error('Error processing stop generation request', {
messageId: id,
error: error.message
});
res.status(500).json({ error: 'Internal server error' });
}
});
// 其他所有路由返回404
app.use((req, res) => {
logger.warn('Route not found', {
method: req.method,
url: req.url,
ip: req.ip || req.connection.remoteAddress
});
res.status(404).json({ error: 'Endpoint not found' });
});
app.listen(PORT, () => {
logger.info('Server started successfully', {
port: PORT,
timestamp: new Date().toISOString()
});
console.log('====================================');
console.log(`本地代理服务器已启动,监听端口: ${PORT}`);
console.log('====================================');
@ -161,5 +346,6 @@ app.listen(PORT, () => {
console.log('请在 server/.env 中添加您的百炼 API Key。');
} else {
console.log('✅ 检测到了 API Key。');
logger.info('API Key detected in environment');
}
});

View File

@ -117,6 +117,33 @@ function handlePin() {
// - 使 API
async function handleSend(text: string, attachments: Attachment[]) {
//
const uploadingAttachments = attachments.filter(a => a.uploading);
if (uploadingAttachments.length > 0) {
//
const uploads = uploadingAttachments.map(async (attachment) => {
//
//
return new Promise<void>((resolve) => {
const checkUpload = () => {
const stillUploading = attachments.some(a => a.id === attachment.id && a.uploading);
if (!stillUploading) {
resolve();
} else {
setTimeout(checkUpload, 100); // 100ms
}
};
checkUpload();
});
});
try {
await Promise.all(uploads);
} catch (error) {
console.error('等待上传完成时发生错误:', error);
}
}
//
if (!currentConversation.value) {
chatStore.createConversation();
@ -144,10 +171,16 @@ async function handleSend(text: string, attachments: Attachment[]) {
// AbortController
abortController.value = new AbortController();
try {
// URLAPI
const imageUrls = attachments
.filter((a) => a.type === "image")
.map(a => a.url);
const stream = chatApi.streamChat(
{
message: text,
conversationId: currentConversation.value?.id || "",
images: imageUrls, // URL
model: settings.value.defaultModel,
stream: true,
},

View File

@ -155,6 +155,7 @@ import {
import AttachmentPreview from "./AttachmentPreview.vue";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
@ -338,31 +339,36 @@ async function addFileAsAttachment(
attachments.value.push(attachment);
//
simulateUpload(id);
//
await uploadFileToServer(id, file);
}
function simulateUpload(id: string) {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
//
async function uploadFileToServer(id: string, file: File) {
try {
const uploadResult = await chatApi.uploadFile(file);
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
// URLURL
attachment.url = uploadResult.url;
attachment.uploading = false;
attachment.progress = 100;
}
} catch (error) {
console.error('文件上传失败:', error);
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
attachment.uploading = false;
attachment.progress = 100;
//
removeAttachment(id);
}
} else {
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
attachment.progress = progress;
//
window.$toast && window.$toast('文件上传失败,请重试', 'error');
}
}
}, 200);
}
//
function removeAttachment(id: string) {

View File

@ -89,12 +89,34 @@ class ChatApi {
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator<string> {
// 构建消息数组,考虑是否包含图片
let userContent;
if (request.images && request.images.length > 0) {
// 如果有图片则构建内容数组针对阿里云DashScope API的格式
userContent = [
{ type: "text", text: request.message }
];
// 添加图片URL到内容中阿里云格式
request.images.forEach(imageUrl => {
userContent.push({
type: "image_url",
image_url: imageUrl // 注意:阿里云格式不需要嵌套对象
});
});
} else {
// 没有图片时,使用简单的文本
userContent = request.message;
}
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
const openAiRequest = {
model: request.model || "qwen-plus",
model: request.model || "qwen-plus", // 可能需要指定支持视觉的模型
messages: [
{ role: "system", content: request.systemPrompt || "你是一个有用的助手。" },
{ role: "user", content: request.message }
{ role: "system", content: request.systemPrompt || "你是一个支持视觉理解的助手。" },
{
role: "user",
content: userContent
}
],
stream: true,
temperature: request.temperature,
@ -158,12 +180,36 @@ class ChatApi {
*
*/
async chat(request: ChatRequest): Promise<ChatResponse> {
// 构建消息数组,考虑是否包含图片
let userContent;
if (request.images && request.images.length > 0) {
// 如果有图片,则构建内容数组
userContent = [
{ type: "text", text: request.message }
];
// 添加图片URL到内容中
request.images.forEach(imageUrl => {
userContent.push({
type: "image_url",
image_url: { url: imageUrl }
});
});
} else {
// 没有图片时,使用简单的文本
userContent = request.message;
}
const requestBody = {
...request,
message: userContent
};
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
body: JSON.stringify(requestBody),
});
if (!response.ok) {