mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 21:30:28 +08:00
feat: 支持API导出
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ChatLab API Bearer Token 认证中间件
|
||||
*/
|
||||
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { loadConfig } from './config'
|
||||
import { unauthorized, errorResponse } from './errors'
|
||||
|
||||
export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
const err = unauthorized()
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
const config = loadConfig()
|
||||
|
||||
if (!config.token || token !== config.token) {
|
||||
const err = unauthorized()
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ChatLab API 配置管理
|
||||
* 持久化存储在 userData/settings/api-server.json
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
import { getSettingsDir, ensureDir } from '../paths'
|
||||
|
||||
const CONFIG_FILE = 'api-server.json'
|
||||
|
||||
export interface ApiServerConfig {
|
||||
enabled: boolean
|
||||
port: number
|
||||
token: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ApiServerConfig = {
|
||||
enabled: false,
|
||||
port: 5200,
|
||||
token: '',
|
||||
createdAt: 0,
|
||||
}
|
||||
|
||||
function getConfigPath(): string {
|
||||
return path.join(getSettingsDir(), CONFIG_FILE)
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return `clb_${crypto.randomBytes(32).toString('hex')}`
|
||||
}
|
||||
|
||||
export function loadConfig(): ApiServerConfig {
|
||||
try {
|
||||
const filePath = getConfigPath()
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as Partial<ApiServerConfig>
|
||||
return { ...DEFAULT_CONFIG, ...parsed }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ApiConfig] Failed to load config:', err)
|
||||
}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
export function saveConfig(config: ApiServerConfig): void {
|
||||
try {
|
||||
ensureDir(getSettingsDir())
|
||||
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')
|
||||
} catch (err) {
|
||||
console.error('[ApiConfig] Failed to save config:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfig(partial: Partial<ApiServerConfig>): ApiServerConfig {
|
||||
const current = loadConfig()
|
||||
const updated = { ...current, ...partial }
|
||||
saveConfig(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Token 存在(首次启用时自动生成)
|
||||
*/
|
||||
export function ensureToken(config: ApiServerConfig): ApiServerConfig {
|
||||
if (!config.token) {
|
||||
config.token = generateToken()
|
||||
config.createdAt = Math.floor(Date.now() / 1000)
|
||||
saveConfig(config)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function regenerateToken(): ApiServerConfig {
|
||||
const config = loadConfig()
|
||||
config.token = generateToken()
|
||||
config.createdAt = Math.floor(Date.now() / 1000)
|
||||
saveConfig(config)
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ChatLab API 错误码与错误工厂
|
||||
*/
|
||||
|
||||
export enum ApiErrorCode {
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
|
||||
INVALID_FORMAT = 'INVALID_FORMAT',
|
||||
SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION',
|
||||
SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR',
|
||||
EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE',
|
||||
BODY_TOO_LARGE = 'BODY_TOO_LARGE',
|
||||
IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS',
|
||||
IMPORT_FAILED = 'IMPORT_FAILED',
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
}
|
||||
|
||||
const HTTP_STATUS: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.UNAUTHORIZED]: 401,
|
||||
[ApiErrorCode.SESSION_NOT_FOUND]: 404,
|
||||
[ApiErrorCode.INVALID_FORMAT]: 400,
|
||||
[ApiErrorCode.SQL_READONLY_VIOLATION]: 400,
|
||||
[ApiErrorCode.SQL_EXECUTION_ERROR]: 400,
|
||||
[ApiErrorCode.EXPORT_TOO_LARGE]: 400,
|
||||
[ApiErrorCode.BODY_TOO_LARGE]: 413,
|
||||
[ApiErrorCode.IMPORT_IN_PROGRESS]: 409,
|
||||
[ApiErrorCode.IMPORT_FAILED]: 500,
|
||||
[ApiErrorCode.SERVER_ERROR]: 500,
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: ApiErrorCode
|
||||
statusCode: number
|
||||
|
||||
constructor(code: ApiErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.statusCode = HTTP_STATUS[code]
|
||||
}
|
||||
}
|
||||
|
||||
export function unauthorized(message = 'Token 无效或缺失'): ApiError {
|
||||
return new ApiError(ApiErrorCode.UNAUTHORIZED, message)
|
||||
}
|
||||
|
||||
export function sessionNotFound(id: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `会话不存在: ${id}`)
|
||||
}
|
||||
|
||||
export function invalidFormat(message: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.INVALID_FORMAT, message)
|
||||
}
|
||||
|
||||
export function sqlReadonlyViolation(): ApiError {
|
||||
return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, '仅允许 SELECT 查询')
|
||||
}
|
||||
|
||||
export function sqlExecutionError(message: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message)
|
||||
}
|
||||
|
||||
export function exportTooLarge(count: number, limit: number): ApiError {
|
||||
return new ApiError(
|
||||
ApiErrorCode.EXPORT_TOO_LARGE,
|
||||
`消息数 ${count} 超过导出上限 ${limit},请使用分页 /messages API`
|
||||
)
|
||||
}
|
||||
|
||||
export function importInProgress(): ApiError {
|
||||
return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, '当前有导入任务正在执行')
|
||||
}
|
||||
|
||||
export function importFailed(message: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.IMPORT_FAILED, message)
|
||||
}
|
||||
|
||||
export function serverError(message = '服务内部错误'): ApiError {
|
||||
return new ApiError(ApiErrorCode.SERVER_ERROR, message)
|
||||
}
|
||||
|
||||
/** 构建统一的成功响应 */
|
||||
export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
|
||||
return {
|
||||
success: true as const,
|
||||
data,
|
||||
meta: {
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
version: '0.0.2',
|
||||
...meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建统一的错误响应 */
|
||||
export function errorResponse(error: ApiError) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* ChatLab API — 服务管理器
|
||||
* 负责 fastify 服务生命周期管理
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createServer } from './server'
|
||||
import { loadConfig, saveConfig, ensureToken, type ApiServerConfig } from './config'
|
||||
import { registerSystemRoutes } from './routes/system'
|
||||
import { registerSessionRoutes } from './routes/sessions'
|
||||
|
||||
let server: FastifyInstance | null = null
|
||||
let startedAt: number | null = null
|
||||
let lastError: string | null = null
|
||||
|
||||
export interface ApiServerStatus {
|
||||
running: boolean
|
||||
port: number | null
|
||||
startedAt: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function getStatus(): ApiServerStatus {
|
||||
return {
|
||||
running: server !== null && startedAt !== null,
|
||||
port: server !== null && startedAt !== null ? loadConfig().port : null,
|
||||
startedAt,
|
||||
error: lastError,
|
||||
}
|
||||
}
|
||||
|
||||
export async function start(): Promise<void> {
|
||||
if (server) {
|
||||
console.log('[ChatLab API] Server already running')
|
||||
return
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
ensureToken(config)
|
||||
lastError = null
|
||||
|
||||
try {
|
||||
server = createServer()
|
||||
registerSystemRoutes(server)
|
||||
registerSessionRoutes(server)
|
||||
|
||||
await server.listen({ port: config.port, host: '127.0.0.1' })
|
||||
startedAt = Math.floor(Date.now() / 1000)
|
||||
console.log(`[ChatLab API] Server started on http://127.0.0.1:${config.port}`)
|
||||
} catch (err: any) {
|
||||
server = null
|
||||
startedAt = null
|
||||
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
lastError = `PORT_IN_USE:${config.port}`
|
||||
console.warn(`[ChatLab API] Port ${config.port} is already in use`)
|
||||
} else {
|
||||
lastError = err.message || 'Unknown error'
|
||||
console.error('[ChatLab API] Failed to start:', err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} catch (err) {
|
||||
console.error('[ChatLab API] Error closing server:', err)
|
||||
} finally {
|
||||
server = null
|
||||
startedAt = null
|
||||
lastError = null
|
||||
console.log('[ChatLab API] Server stopped')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restart(): Promise<void> {
|
||||
await stop()
|
||||
await start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时自动恢复:若配置为 enabled 则尝试启动
|
||||
* 失败则静默记录(不影响应用正常使用)
|
||||
*/
|
||||
export async function autoStart(): Promise<void> {
|
||||
const config = loadConfig()
|
||||
if (!config.enabled) return
|
||||
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// 静默失败,lastError 已记录
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启用状态(持久化)
|
||||
*/
|
||||
export async function setEnabled(enabled: boolean): Promise<ApiServerStatus> {
|
||||
const config = loadConfig()
|
||||
config.enabled = enabled
|
||||
saveConfig(config)
|
||||
|
||||
if (enabled) {
|
||||
ensureToken(config)
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// lastError 已记录
|
||||
}
|
||||
} else {
|
||||
await stop()
|
||||
}
|
||||
|
||||
return getStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置端口(持久化,需要重启服务)
|
||||
*/
|
||||
export async function setPort(port: number): Promise<ApiServerStatus> {
|
||||
const config = loadConfig()
|
||||
const wasRunning = server !== null
|
||||
|
||||
config.port = port
|
||||
saveConfig(config)
|
||||
|
||||
if (wasRunning) {
|
||||
await stop()
|
||||
try {
|
||||
await start()
|
||||
} catch {
|
||||
// lastError 已记录
|
||||
}
|
||||
}
|
||||
|
||||
return getStatus()
|
||||
}
|
||||
|
||||
export function getConfig(): ApiServerConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* ChatLab API — 会话与导出路由
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import * as worker from '../../worker/workerManager'
|
||||
import { successResponse, sessionNotFound, exportTooLarge, sqlExecutionError, ApiError, errorResponse } from '../errors'
|
||||
|
||||
const EXPORT_MESSAGE_LIMIT = 100_000
|
||||
|
||||
async function ensureSession(sessionId: string) {
|
||||
const session = await worker.getSession(sessionId)
|
||||
if (!session) throw sessionNotFound(sessionId)
|
||||
return session
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(server: FastifyInstance): void {
|
||||
// GET /api/v1/sessions — 会话列表
|
||||
server.get('/api/v1/sessions', async () => {
|
||||
const sessions = await worker.getAllSessions()
|
||||
return successResponse(sessions)
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id — 单个会话详情
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => {
|
||||
const session = await ensureSession(request.params.id)
|
||||
return successResponse(session)
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/messages — 查询消息(分页)
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: {
|
||||
page?: string
|
||||
limit?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
keyword?: string
|
||||
senderId?: string
|
||||
type?: string
|
||||
}
|
||||
}>('/api/v1/sessions/:id/messages', async (request) => {
|
||||
const { id } = request.params
|
||||
await ensureSession(id)
|
||||
|
||||
const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1)
|
||||
const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100))
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const { startTime, endTime, keyword, senderId } = request.query
|
||||
|
||||
const filter: any = {}
|
||||
if (startTime) filter.startTs = parseInt(startTime, 10)
|
||||
if (endTime) filter.endTs = parseInt(endTime, 10)
|
||||
const hasFilter = filter.startTs || filter.endTs
|
||||
|
||||
const keywords = keyword ? [keyword] : []
|
||||
const senderIdNum = senderId ? parseInt(senderId, 10) : undefined
|
||||
|
||||
const result = await worker.searchMessages(
|
||||
id,
|
||||
keywords,
|
||||
hasFilter ? filter : undefined,
|
||||
limit,
|
||||
offset,
|
||||
senderIdNum
|
||||
)
|
||||
|
||||
return successResponse(
|
||||
{
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/members — 成员列表
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => {
|
||||
await ensureSession(request.params.id)
|
||||
const members = await worker.getMembers(request.params.id)
|
||||
return successResponse(members)
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/stats/overview — 概览统计
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => {
|
||||
const { id } = request.params
|
||||
const session = await ensureSession(id)
|
||||
|
||||
const [timeRange, memberActivity, typeDistribution] = await Promise.all([
|
||||
worker.getTimeRange(id),
|
||||
worker.getMemberActivity(id),
|
||||
worker.getMessageTypeDistribution(id),
|
||||
])
|
||||
|
||||
const typeMap: Record<string, number> = {}
|
||||
for (const item of typeDistribution) {
|
||||
typeMap[String(item.type)] = item.count
|
||||
}
|
||||
|
||||
const topMembers = memberActivity.slice(0, 10).map((m: any) => ({
|
||||
platformId: m.platformId,
|
||||
name: m.name,
|
||||
messageCount: m.messageCount,
|
||||
percentage: m.percentage,
|
||||
}))
|
||||
|
||||
return successResponse({
|
||||
messageCount: session.messageCount,
|
||||
memberCount: session.memberCount,
|
||||
timeRange: timeRange || { start: 0, end: 0 },
|
||||
messageTypeDistribution: typeMap,
|
||||
topMembers,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/v1/sessions/:id/sql — 执行 SQL(只读)
|
||||
server.post<{ Params: { id: string }; Body: { sql: string } }>(
|
||||
'/api/v1/sessions/:id/sql',
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
await ensureSession(id)
|
||||
|
||||
const { sql } = request.body || {}
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
const err = sqlExecutionError('缺少 sql 参数')
|
||||
return reply.code(err.statusCode).send(errorResponse(err))
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await worker.executeRawSQL(id, sql)
|
||||
return successResponse(result)
|
||||
} catch (err: any) {
|
||||
const message = err.message || 'SQL 执行错误'
|
||||
if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) {
|
||||
const apiErr = new ApiError('SQL_READONLY_VIOLATION' as any, message)
|
||||
apiErr.statusCode = 400
|
||||
return reply.code(400).send(errorResponse(apiErr))
|
||||
}
|
||||
const apiErr = sqlExecutionError(message)
|
||||
return reply.code(apiErr.statusCode).send(errorResponse(apiErr))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// GET /api/v1/sessions/:id/export — 导出 ChatLab Format JSON
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const session = await ensureSession(id)
|
||||
|
||||
if (session.messageCount > EXPORT_MESSAGE_LIMIT) {
|
||||
const err = exportTooLarge(session.messageCount, EXPORT_MESSAGE_LIMIT)
|
||||
return reply.code(err.statusCode).send(errorResponse(err))
|
||||
}
|
||||
|
||||
const [members, messagesResult] = await Promise.all([
|
||||
worker.getMembers(id),
|
||||
worker.searchMessages(id, [], undefined, EXPORT_MESSAGE_LIMIT, 0),
|
||||
])
|
||||
|
||||
const chatLabFormat = {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'ChatLab API',
|
||||
},
|
||||
meta: {
|
||||
name: session.name,
|
||||
platform: session.platform,
|
||||
type: session.type,
|
||||
groupId: session.groupId || undefined,
|
||||
},
|
||||
members: members.map((m: any) => ({
|
||||
platformId: m.platformId,
|
||||
accountName: m.accountName || m.platformId,
|
||||
groupNickname: m.groupNickname || undefined,
|
||||
aliases: Array.isArray(m.aliases) && m.aliases.length > 0 ? m.aliases : undefined,
|
||||
})),
|
||||
messages: messagesResult.messages.map((msg: any) => ({
|
||||
sender: msg.senderPlatformId,
|
||||
accountName: msg.senderName || undefined,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content || null,
|
||||
})),
|
||||
}
|
||||
|
||||
return successResponse(chatLabFormat)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* ChatLab API — 系统路由
|
||||
* GET /api/v1/status 服务状态
|
||||
* GET /api/v1/schema ChatLab Format JSON Schema
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { app } from 'electron'
|
||||
import { successResponse } from '../errors'
|
||||
import * as worker from '../../worker/workerManager'
|
||||
|
||||
export function registerSystemRoutes(server: FastifyInstance): void {
|
||||
server.get('/api/v1/status', async () => {
|
||||
let sessionCount = 0
|
||||
try {
|
||||
const sessions = await worker.getAllSessions()
|
||||
sessionCount = sessions.length
|
||||
} catch {
|
||||
// Worker 未就绪时忽略
|
||||
}
|
||||
|
||||
return successResponse({
|
||||
name: 'ChatLab API',
|
||||
version: app.getVersion(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
sessionCount,
|
||||
})
|
||||
})
|
||||
|
||||
server.get('/api/v1/schema', async () => {
|
||||
return successResponse({
|
||||
format: 'ChatLab Format',
|
||||
version: '0.0.2',
|
||||
spec: {
|
||||
chatlab: {
|
||||
type: 'object',
|
||||
required: ['version'],
|
||||
properties: {
|
||||
version: { type: 'string' },
|
||||
exportedAt: { type: 'number' },
|
||||
generator: { type: 'string' },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
required: ['name', 'platform', 'type'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
platform: { type: 'string', enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'] },
|
||||
type: { type: 'string', enum: ['group', 'private'] },
|
||||
groupId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
members: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['platformId', 'accountName'],
|
||||
properties: {
|
||||
platformId: { type: 'string' },
|
||||
accountName: { type: 'string' },
|
||||
groupNickname: { type: 'string' },
|
||||
avatar: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['sender', 'timestamp', 'type'],
|
||||
properties: {
|
||||
platformMessageId: { type: 'string' },
|
||||
sender: { type: 'string' },
|
||||
accountName: { type: 'string' },
|
||||
groupNickname: { type: 'string' },
|
||||
timestamp: { type: 'number' },
|
||||
type: { type: 'number' },
|
||||
content: { type: ['string', 'null'] },
|
||||
replyToMessageId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* ChatLab API — fastify 服务器实例
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
|
||||
import { authHook } from './auth'
|
||||
import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors'
|
||||
|
||||
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
export function createServer(): FastifyInstance {
|
||||
const server = Fastify({
|
||||
logger: false,
|
||||
bodyLimit: JSON_BODY_LIMIT,
|
||||
})
|
||||
|
||||
server.addHook('onRequest', authHook)
|
||||
|
||||
server.setErrorHandler((error: FastifyError, _request, reply) => {
|
||||
if (error instanceof ApiError) {
|
||||
reply.code(error.statusCode).send(errorResponse(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (error.statusCode === 413) {
|
||||
const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, '请求体超过 50MB 上限')
|
||||
reply.code(413).send(errorResponse(bodyErr))
|
||||
return
|
||||
}
|
||||
|
||||
console.error('[ChatLab API] Unhandled error:', error)
|
||||
const err = serverError(error.message)
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
Reference in New Issue
Block a user