feat: 支持API导出

This commit is contained in:
digua
2026-03-26 20:38:29 +08:00
committed by digua
parent 6d90552b9b
commit 6d5e6f6e7a
21 changed files with 1604 additions and 28 deletions
+25
View File
@@ -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
}
}
+83
View File
@@ -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
}
+104
View File
@@ -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,
},
}
}
+146
View File
@@ -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()
}
+192
View File
@@ -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)
})
}
+87
View File
@@ -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' },
},
},
},
},
})
})
}
+37
View File
@@ -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
}