mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
1164 lines
40 KiB
TypeScript
1164 lines
40 KiB
TypeScript
import crypto from 'crypto'
|
|
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
|
import { app } from 'electron'
|
|
import {
|
|
isReady,
|
|
listContacts,
|
|
listContactsAsync,
|
|
listMessages,
|
|
getGroupSnapshot,
|
|
listRecentChat,
|
|
resolveMd5
|
|
} from './services/chat-service'
|
|
import { exportGroupReport } from './group-report-service'
|
|
import { GroupReportExportRequest } from '../shared/group-report'
|
|
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
|
import { scheduledReportService } from './services/scheduled-report-service'
|
|
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
|
|
import {
|
|
ScheduledReportApiError,
|
|
ScheduledReportApiService,
|
|
type ScheduledReportApiDependencies
|
|
} from './services/scheduled-report-api-service'
|
|
import type {
|
|
ScheduledReportApiCreateRequest,
|
|
ScheduledReportApiUpdateRequest
|
|
} from '../shared/scheduled-report-api'
|
|
import { agentHubService } from './services/agent-hub-service'
|
|
import { safeError, safeLog, safeWarn } from './safe-log'
|
|
import { apiTokenStore } from './api-token-store'
|
|
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
|
import { LocalQueryApiService } from './services/local-query-api-service'
|
|
import { automationRuleStore, AutomationRulePersistenceError } from './services/automation-rule-store'
|
|
import { automationExecutionLogService } from './services/automation-execution-log-service'
|
|
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
|
import { LocalAgentApiError, LocalAgentApiService } from './services/local-agent-api-service'
|
|
import type { GroupStatsService } from './services/group-stats-service'
|
|
|
|
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
|
export const DEFAULT_HTTP_PORT = 6131
|
|
|
|
export interface HttpServerHandle {
|
|
host: string
|
|
port: number
|
|
close(): Promise<void>
|
|
}
|
|
|
|
interface RouteContext {
|
|
req: IncomingMessage
|
|
res: ServerResponse
|
|
url: URL
|
|
body?: unknown
|
|
}
|
|
|
|
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE'
|
|
type RouteHandler = ((ctx: RouteContext) => void | Promise<void>) & {
|
|
allowedMethods: readonly HttpMethod[]
|
|
}
|
|
|
|
export interface HttpServerOptions {
|
|
tokenProvider?: () => string | null
|
|
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
|
scheduledReportService?: ScheduledReportApiDependencies['service']
|
|
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
|
|
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
|
|
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
|
scheduledReportPlatform?: NodeJS.Platform
|
|
queryApiService?: LocalQueryApiService
|
|
agentApiService?: LocalAgentApiService
|
|
groupStatsService?: Pick<GroupStatsService, 'getMemberStats'>
|
|
appVersionProvider?: () => string
|
|
}
|
|
|
|
let configuredQueryApiService: LocalQueryApiService | undefined
|
|
let configuredGroupStatsService: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
|
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
|
configuredQueryApiService = service
|
|
}
|
|
|
|
export function setLocalGroupStatsService(
|
|
service: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
|
): void {
|
|
configuredGroupStatsService = service
|
|
}
|
|
|
|
const MAX_JSON_BODY_BYTES = 1024 * 1024
|
|
|
|
class RequestBodyTooLargeError extends Error {
|
|
constructor() {
|
|
super('Request body exceeds the maximum size')
|
|
this.name = 'RequestBodyTooLargeError'
|
|
}
|
|
}
|
|
|
|
function withMethods(
|
|
methods: readonly HttpMethod[],
|
|
handler: (ctx: RouteContext) => void | Promise<void>
|
|
): RouteHandler {
|
|
return Object.assign(handler, { allowedMethods: methods })
|
|
}
|
|
|
|
function sendMethodNotAllowed(res: ServerResponse, methods: readonly HttpMethod[]): void {
|
|
res.setHeader('Allow', methods.join(', '))
|
|
sendError(res, 405, `请求方法不受支持;允许的方法:${methods.join(', ')}`)
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
const requestId = String(res.getHeader('X-Request-Id') || '')
|
|
let responsePayload = payload
|
|
if (status >= 400 && payload && typeof payload === 'object' && !('requestId' in payload)) {
|
|
responsePayload = { ...(payload as Record<string, unknown>), requestId }
|
|
}
|
|
const body = JSON.stringify(responsePayload, null, 2)
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'Cache-Control': 'no-store',
|
|
...(requestId ? { 'X-Request-Id': requestId } : {})
|
|
})
|
|
res.end(body)
|
|
}
|
|
|
|
function isAllowedCorsOrigin(origin: string): boolean {
|
|
if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false
|
|
try {
|
|
const parsed = new URL(origin)
|
|
if (parsed.protocol !== 'http:') return false
|
|
if (parsed.username || parsed.password) return false
|
|
return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase())
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
|
const origin = req.headers.origin
|
|
if (!origin) return true
|
|
if (!isAllowedCorsOrigin(origin)) return false
|
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
res.setHeader('Vary', 'Origin')
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, PATCH, DELETE, OPTIONS')
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
|
|
return true
|
|
}
|
|
|
|
function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean {
|
|
const header = req.headers.authorization
|
|
const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null
|
|
if (!match || !expectedToken) return false
|
|
const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest()
|
|
const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest()
|
|
return crypto.timingSafeEqual(actualDigest, expectedDigest)
|
|
}
|
|
|
|
function sendUnauthorized(res: ServerResponse): void {
|
|
sendJson(res, 401, {
|
|
error: 'unauthorized',
|
|
message: 'Valid API token required'
|
|
})
|
|
}
|
|
|
|
function sendAgentHttpError(
|
|
res: ServerResponse,
|
|
status: number,
|
|
code: string,
|
|
message: string,
|
|
details?: unknown
|
|
): void {
|
|
const requestId = String(res.getHeader('X-Request-Id') || '')
|
|
sendJson(res, status, {
|
|
error: { code, message, ...(details !== undefined ? { details } : {}) },
|
|
requestId
|
|
})
|
|
}
|
|
|
|
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
|
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
|
}
|
|
|
|
function sendBinary(res: ServerResponse, status: number, result: HttpImageResult): void {
|
|
res.writeHead(status, {
|
|
'Content-Type': result.mimeType,
|
|
'Content-Length': result.buffer.length,
|
|
'Cache-Control': 'private, no-store',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Request-Id': String(res.getHeader('X-Request-Id') || '')
|
|
})
|
|
res.end(result.buffer)
|
|
}
|
|
|
|
function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string, unknown> {
|
|
const contentData = message.contentData
|
|
if (!contentData || typeof contentData !== 'object' || !('aeskey' in contentData)) {
|
|
return message
|
|
}
|
|
const safeContentData = { ...(contentData as Record<string, unknown>) }
|
|
delete safeContentData.aeskey
|
|
return { ...message, contentData: safeContentData }
|
|
}
|
|
|
|
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const contentLength = Number(req.headers['content-length'])
|
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
req.pause()
|
|
reject(new RequestBodyTooLargeError())
|
|
return
|
|
}
|
|
const chunks: Buffer[] = []
|
|
let size = 0
|
|
req.on('data', (chunk: Buffer) => {
|
|
size += chunk.length
|
|
if (size > maxBytes) {
|
|
chunks.length = 0
|
|
req.pause()
|
|
reject(new RequestBodyTooLargeError())
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
})
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
function rangeToSec(input: string, endOfUnit = false): number | null {
|
|
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
|
|
if (!m) return null
|
|
const [, y, mo, d, hStr, miStr] = m
|
|
const hasTime = hStr !== undefined
|
|
|
|
let hh: number, mi: number, ss: number, ms: number
|
|
if (hasTime) {
|
|
hh = Number(hStr)
|
|
mi = Number(miStr)
|
|
ss = endOfUnit ? 59 : 0
|
|
ms = endOfUnit ? 999 : 0
|
|
} else if (endOfUnit) {
|
|
hh = 23
|
|
mi = 59
|
|
ss = 59
|
|
ms = 999
|
|
} else {
|
|
hh = 0
|
|
mi = 0
|
|
ss = 0
|
|
ms = 0
|
|
}
|
|
|
|
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
|
|
return Math.floor(date.getTime() / 1000)
|
|
}
|
|
|
|
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
|
|
if (!value) return {}
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return {}
|
|
|
|
if (/^\d{10,13}$/.test(trimmed)) {
|
|
const n = Number(trimmed)
|
|
if (!Number.isFinite(n)) return {}
|
|
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
|
|
}
|
|
|
|
if (trimmed.includes('~')) {
|
|
const [a, b] = trimmed.split('~').map((s) => s.trim())
|
|
const start = rangeToSec(a, false)
|
|
const end = rangeToSec(b, true)
|
|
return {
|
|
startTime: start ?? undefined,
|
|
endTime: end ?? undefined
|
|
}
|
|
}
|
|
|
|
const start = rangeToSec(trimmed, false)
|
|
const end = rangeToSec(trimmed, true)
|
|
return {
|
|
startTime: start ?? undefined,
|
|
endTime: end ?? undefined
|
|
}
|
|
}
|
|
|
|
function parseNumeric(value: string | null, fallback: number): number {
|
|
if (!value) return fallback
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|
|
|
|
function getApplicationVersion(): string {
|
|
try {
|
|
return typeof app.getVersion === 'function' ? app.getVersion() : 'unknown'
|
|
} catch {
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
const routes: Record<string, RouteHandler> = {
|
|
'/api/v1/health': withMethods(['GET'], ({ res }) => {
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
ready: isReady(),
|
|
service: 'TraceMemo Reader',
|
|
version: getApplicationVersion(),
|
|
timestamp: new Date().toISOString()
|
|
})
|
|
}),
|
|
|
|
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
|
|
const now = new Date()
|
|
sendJson(res, 200, {
|
|
time: now.toISOString(),
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
timestamp: Math.floor(now.getTime() / 1000),
|
|
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
|
now.getDate()
|
|
).padStart(2, '0')}`
|
|
})
|
|
}),
|
|
|
|
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const filter = url.searchParams.get('filter') || undefined
|
|
const type = url.searchParams.get('type') || undefined
|
|
// Use the hydrated data source: on macOS the session cache only carries raw
|
|
// ids until display names / contact identities are hydrated, so the sync
|
|
// `listContacts` would miss nickname and remark matches (Issue #51).
|
|
let contacts = await listContactsAsync(filter)
|
|
if (type === 'user' || type === 'group') {
|
|
contacts = contacts.filter((c) => c.type === type)
|
|
}
|
|
sendJson(res, 200, { count: contacts.length, contacts })
|
|
}),
|
|
|
|
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const keyword = url.searchParams.get('keyword') || ''
|
|
// Same hydration requirement as /api/v1/contact: group display names are
|
|
// exactly the fields that stay un-hydrated on macOS.
|
|
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
|
|
if (keyword) {
|
|
const lower = keyword.toLowerCase()
|
|
groups = groups.filter(
|
|
(c) =>
|
|
c.m_nsNickName.toLowerCase().includes(lower) ||
|
|
c.m_nsUsrName.toLowerCase().includes(lower)
|
|
)
|
|
}
|
|
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
|
}),
|
|
|
|
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
|
const items = listRecentChat(limit)
|
|
sendJson(res, 200, { count: items.length, items })
|
|
}),
|
|
|
|
'/api/v1/chatlog': withMethods(['GET'], ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const talker = url.searchParams.get('talker')
|
|
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
|
|
|
const resolved = resolveMd5(talker)
|
|
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
|
|
|
|
const timeParam = url.searchParams.get('time')
|
|
const startParam = url.searchParams.get('startTime')
|
|
const endParam = url.searchParams.get('endTime')
|
|
|
|
let startTime: number | undefined
|
|
let endTime: number | undefined
|
|
if (timeParam) {
|
|
const range = parseTimeRange(timeParam)
|
|
startTime = range.startTime
|
|
endTime = range.endTime
|
|
} else {
|
|
if (startParam) {
|
|
const r = parseTimeRange(startParam)
|
|
startTime = r.startTime
|
|
}
|
|
if (endParam) {
|
|
const r = parseTimeRange(endParam)
|
|
endTime = r.endTime
|
|
}
|
|
}
|
|
|
|
const messages = listMessages(resolved.md5, startTime, endTime)
|
|
sendJson(res, 200, {
|
|
contact: resolved,
|
|
query: { talker, time: timeParam, startTime, endTime },
|
|
count: messages.length,
|
|
messages: messages.map((message) =>
|
|
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
|
|
)
|
|
})
|
|
}),
|
|
|
|
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const md5 = url.searchParams.get('md5')
|
|
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
|
const snapshot = getGroupSnapshot(md5)
|
|
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
|
sendJson(res, 200, snapshot)
|
|
}),
|
|
|
|
'/api/v1/resolve': withMethods(['GET'], ({ res, url }) => {
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
const q = url.searchParams.get('q')
|
|
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
|
const contact = resolveMd5(q)
|
|
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
|
sendJson(res, 200, contact)
|
|
}),
|
|
|
|
'/api/v1/report': withMethods(['POST'], async ({ req, res, body }) => {
|
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
if (typeof body !== 'string' || !body.trim()) {
|
|
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
|
}
|
|
let request: GroupReportExportRequest
|
|
try {
|
|
request = JSON.parse(body) as GroupReportExportRequest
|
|
} catch (error) {
|
|
return sendError(
|
|
res,
|
|
400,
|
|
'请求体 JSON 解析失败',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
if (!request?.report || !request?.metadata) {
|
|
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
|
}
|
|
if (request.templateRef !== undefined) {
|
|
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
|
|
code: 'external_template_unsupported'
|
|
})
|
|
}
|
|
const result = await exportGroupReport(request)
|
|
sendJson(res, result.success ? 200 : 500, result)
|
|
}),
|
|
|
|
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
|
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
|
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
|
try {
|
|
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
|
} catch {
|
|
return sendError(res, 400, '请求体 JSON 解析失败')
|
|
}
|
|
const result = await generateAgentGroupReport({
|
|
group: request.group || '',
|
|
range: request.range
|
|
})
|
|
sendJson(res, result.success ? 200 : 400, result)
|
|
}),
|
|
|
|
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
|
|
const status = agentHubService.getStatus()
|
|
sendJson(res, 200, {
|
|
ok: status.hub === 'online' && status.connector === 'online',
|
|
hub: status.hub,
|
|
connector: status.connector,
|
|
dataApi: status.dataApi,
|
|
databaseReady: status.databaseReady,
|
|
accountId: status.accountId
|
|
})
|
|
}),
|
|
|
|
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
|
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
|
let request: { to?: string; text?: string; media_url?: string }
|
|
try {
|
|
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
|
} catch {
|
|
return sendError(res, 400, '请求体 JSON 解析失败')
|
|
}
|
|
const result = await agentHubService.testSend({
|
|
to: request.to,
|
|
text: request.text,
|
|
mediaUrl: request.media_url
|
|
})
|
|
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
|
})
|
|
}
|
|
|
|
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
|
const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability'
|
|
|
|
function parseJsonBody(body: unknown): unknown {
|
|
if (typeof body !== 'string' || !body.trim()) {
|
|
throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空')
|
|
}
|
|
try {
|
|
return JSON.parse(body)
|
|
} catch {
|
|
throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败')
|
|
}
|
|
}
|
|
|
|
function sendScheduledError(res: ServerResponse, error: unknown): void {
|
|
if (error instanceof ScheduledReportApiError) {
|
|
sendJson(res, error.status, {
|
|
error: error.code,
|
|
message: error.message,
|
|
...(error.details !== undefined ? { details: error.details } : {})
|
|
})
|
|
return
|
|
}
|
|
safeError('[HttpServer] scheduled report request failed:', error)
|
|
sendJson(res, 500, {
|
|
error: 'internal_error',
|
|
message: '定时日报 API 执行失败'
|
|
})
|
|
}
|
|
|
|
function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService {
|
|
return new ScheduledReportApiService({
|
|
service: options.scheduledReportService || scheduledReportService,
|
|
getCapability:
|
|
options.scheduledReportCapabilityProvider ||
|
|
(() => personalWechatCapabilityService.getPersonalWechatSendCapability()),
|
|
listContacts: options.scheduledReportContactsProvider || listContacts,
|
|
isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady,
|
|
platform: options.scheduledReportPlatform
|
|
})
|
|
}
|
|
|
|
function createScheduledReportRoute(
|
|
pathname: string,
|
|
api: ScheduledReportApiService
|
|
): RouteHandler | undefined {
|
|
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
|
return withMethods(['GET'], async ({ req, res }) => {
|
|
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
|
try {
|
|
sendJson(res, 200, { capability: await api.getCapability() })
|
|
} catch (error) {
|
|
sendScheduledError(res, error)
|
|
}
|
|
})
|
|
}
|
|
|
|
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
|
return withMethods(['GET', 'POST'], async ({ req, res, body }) => {
|
|
try {
|
|
if (req.method === 'GET') {
|
|
const tasks = await api.list()
|
|
sendJson(res, 200, { count: tasks.length, tasks })
|
|
return
|
|
}
|
|
if (req.method !== 'POST') {
|
|
sendError(res, 405, '需要 GET 或 POST 请求')
|
|
return
|
|
}
|
|
const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest)
|
|
sendJson(res, 201, { created: true, task })
|
|
} catch (error) {
|
|
sendScheduledError(res, error)
|
|
}
|
|
})
|
|
}
|
|
|
|
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
|
if (pathname.startsWith(retryPrefix)) {
|
|
const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean)
|
|
if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined
|
|
let executionId: string
|
|
try {
|
|
executionId = decodeURIComponent(segments[0])
|
|
} catch {
|
|
return undefined
|
|
}
|
|
return withMethods(['POST'], async ({ req, res }) => {
|
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
|
try {
|
|
const execution = await api.retrySend(executionId)
|
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
|
} catch (error) {
|
|
sendScheduledError(res, error)
|
|
}
|
|
})
|
|
}
|
|
|
|
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
|
if (!pathname.startsWith(prefix)) return undefined
|
|
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
|
if (!segments.length || segments.length > 2) return undefined
|
|
let taskId: string
|
|
try {
|
|
taskId = decodeURIComponent(segments[0])
|
|
} catch {
|
|
return undefined
|
|
}
|
|
const action = segments[1]
|
|
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
|
|
|
|
const methods: HttpMethod[] = !action
|
|
? ['GET', 'PATCH', 'DELETE']
|
|
: action === 'executions'
|
|
? ['GET']
|
|
: ['POST']
|
|
|
|
return withMethods(methods, async ({ req, res, body }) => {
|
|
try {
|
|
if (!action && req.method === 'GET') {
|
|
sendJson(res, 200, { task: await api.get(taskId) })
|
|
return
|
|
}
|
|
if (!action && req.method === 'PATCH') {
|
|
const task = await api.update(
|
|
taskId,
|
|
parseJsonBody(body) as ScheduledReportApiUpdateRequest
|
|
)
|
|
sendJson(res, 200, { updated: true, task })
|
|
return
|
|
}
|
|
if (!action && req.method === 'DELETE') {
|
|
sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) })
|
|
return
|
|
}
|
|
if (action === 'enable' && req.method === 'POST') {
|
|
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) })
|
|
return
|
|
}
|
|
if (action === 'disable' && req.method === 'POST') {
|
|
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) })
|
|
return
|
|
}
|
|
if (action === 'run' && req.method === 'POST') {
|
|
const execution = await api.run(taskId)
|
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
|
return
|
|
}
|
|
if (action === 'executions' && req.method === 'GET') {
|
|
const executions = await api.executions(taskId)
|
|
sendJson(res, 200, { count: executions.length, executions })
|
|
return
|
|
}
|
|
sendError(res, 405, '请求方法或定时日报操作不受支持')
|
|
} catch (error) {
|
|
sendScheduledError(res, error)
|
|
}
|
|
})
|
|
}
|
|
|
|
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
|
const QUERY_ROUTE_PREFIX = '/api/v1/query/'
|
|
|
|
function queryStatusCode(status: string): number {
|
|
if (status === 'completed') return 200
|
|
if (status === 'contact_not_found') return 404
|
|
if (status === 'ambiguous_contact') return 409
|
|
if (status === 'knowledge_unavailable') return 503
|
|
if (status === 'retrieval_incomplete') return 206
|
|
return 400
|
|
}
|
|
|
|
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
|
|
if (pathname === '/api/v1/query/capabilities') {
|
|
return withMethods(['GET'], ({ res }) => {
|
|
return sendJson(res, 200, api.capabilities())
|
|
})
|
|
}
|
|
type QueryOperationResult =
|
|
| Awaited<ReturnType<LocalQueryApiService['messages']>>
|
|
| Awaited<ReturnType<LocalQueryApiService['search']>>
|
|
| Awaited<ReturnType<LocalQueryApiService['context']>>
|
|
| Awaited<ReturnType<LocalQueryApiService['overview']>>
|
|
const operations: Record<string, (payload: unknown) => Promise<QueryOperationResult>> = {
|
|
'/api/v1/query/messages': (payload) =>
|
|
api.messages(payload as Parameters<LocalQueryApiService['messages']>[0]),
|
|
'/api/v1/query/search': (payload) =>
|
|
api.search(payload as Parameters<LocalQueryApiService['search']>[0]),
|
|
'/api/v1/query/message-context': (payload) =>
|
|
api.context(payload as Parameters<LocalQueryApiService['context']>[0]),
|
|
'/api/v1/query/conversation-overview': (payload) =>
|
|
api.overview(payload as Parameters<LocalQueryApiService['overview']>[0])
|
|
}
|
|
const operation = operations[pathname]
|
|
if (!operation) return undefined
|
|
return withMethods(['POST'], async ({ res, body }) => {
|
|
let payload: unknown
|
|
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
|
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
|
try {
|
|
const result = await operation(payload)
|
|
return sendJson(res, queryStatusCode(result.status), result)
|
|
} catch (error) {
|
|
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
|
}
|
|
})
|
|
}
|
|
|
|
function createMediaRoute(
|
|
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
|
): RouteHandler {
|
|
return withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
|
|
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
|
|
let messageId: string
|
|
try {
|
|
messageId = decodeURIComponent(encodedMessageId)
|
|
} catch {
|
|
return sendError(res, 422, 'messageId 格式无效')
|
|
}
|
|
if (!messageId || messageId.includes('/') || messageId.includes('\\')) {
|
|
return sendError(res, 422, 'messageId 格式无效')
|
|
}
|
|
try {
|
|
const result = await mediaProvider(messageId)
|
|
if (req.method === 'HEAD') {
|
|
res.writeHead(200, {
|
|
'Content-Type': result.mimeType,
|
|
'Content-Length': result.buffer.length,
|
|
'Cache-Control': 'private, no-store',
|
|
'X-Content-Type-Options': 'nosniff'
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
sendBinary(res, 200, result)
|
|
} catch (error) {
|
|
if (error instanceof HttpMediaError) {
|
|
const status =
|
|
error.code === 'NOT_READY'
|
|
? 503
|
|
: error.code === 'NOT_IMAGE'
|
|
? 422
|
|
: error.code === 'NOT_FOUND'
|
|
? 404
|
|
: 500
|
|
return sendError(res, status, error.message)
|
|
}
|
|
safeError('[HttpServer] media request failed:', error)
|
|
return sendError(res, 500, '图片读取失败')
|
|
}
|
|
})
|
|
}
|
|
|
|
function createAgentApiService(options: HttpServerOptions): LocalAgentApiService {
|
|
const groupStats = options.groupStatsService || configuredGroupStatsService
|
|
return options.agentApiService || new LocalAgentApiService({
|
|
automationRuleStore,
|
|
automationExecutionLogService,
|
|
listContacts: listContactsAsync,
|
|
isDatabaseReady: isReady,
|
|
getVersion: options.appVersionProvider || getApplicationVersion,
|
|
getPersonalWechatCapability: () =>
|
|
personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
|
getAgentHubStatus: () => agentHubService.getStatus(),
|
|
getGroupExitMonitorState: () => {
|
|
const state = groupExitMonitorService.getState()
|
|
return { ...state, monitoredRoomIds: state.monitoredRoomIds || [] }
|
|
},
|
|
configureGroupExitMonitor: (configuration) =>
|
|
groupExitMonitorService.configure(configuration),
|
|
setGroupExitMonitorRoomIds: (roomIds) => groupExitMonitorService.setMonitoredRoomIds(roomIds),
|
|
setGroupExitMonitorEnabled: (enabled) => groupExitMonitorService.setEnabled(enabled),
|
|
listGroupExitMonitorEvents: (query) => groupExitMonitorService.listEvents(query),
|
|
...(groupStats ? { getGroupMemberStats: (query) => groupStats.getMemberStats(query) } : {})
|
|
})
|
|
}
|
|
|
|
function sendAgentApiError(res: ServerResponse, error: unknown): void {
|
|
const requestId = String(res.getHeader('X-Request-Id') || '')
|
|
if (error instanceof LocalAgentApiError) {
|
|
sendAgentHttpError(res, error.status, error.code, error.message, error.details)
|
|
return
|
|
}
|
|
if (error instanceof AutomationRulePersistenceError) {
|
|
sendAgentHttpError(res, 500, 'PERSISTENCE_FAILED', '自动化规则未能保存到本地')
|
|
return
|
|
}
|
|
safeError(`[HttpServer requestId=${requestId}] Agent API request failed:`, error)
|
|
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
|
}
|
|
|
|
async function handleAgentApi<T>(
|
|
res: ServerResponse,
|
|
operation: () => Promise<T> | T,
|
|
respond: (value: T) => void
|
|
): Promise<void> {
|
|
try {
|
|
respond(await operation())
|
|
} catch (error) {
|
|
sendAgentApiError(res, error)
|
|
}
|
|
}
|
|
|
|
function parseAgentJson(body: unknown): unknown {
|
|
if (typeof body !== 'string' || !body.trim()) {
|
|
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体不能为空')
|
|
}
|
|
try {
|
|
return JSON.parse(body)
|
|
} catch {
|
|
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体 JSON 格式无效')
|
|
}
|
|
}
|
|
|
|
function createAgentApiRoute(
|
|
pathname: string,
|
|
api: LocalAgentApiService
|
|
): RouteHandler | undefined {
|
|
if (pathname === '/api/v1/capabilities') {
|
|
return withMethods(['GET'], ({ res }) =>
|
|
void handleAgentApi(res, () => api.getCapabilities(), (capabilities) =>
|
|
sendJson(res, 200, capabilities)
|
|
)
|
|
)
|
|
}
|
|
|
|
const groupExitMonitorPath = '/api/v1/monitors/group-exits'
|
|
if (pathname === groupExitMonitorPath) {
|
|
return withMethods(['GET', 'PATCH'], async ({ req, res, body }) => {
|
|
if (req.method === 'GET') {
|
|
await handleAgentApi(res, () => api.getGroupExitMonitorState(), (state) =>
|
|
sendJson(res, 200, state)
|
|
)
|
|
return
|
|
}
|
|
await handleAgentApi(res, () => api.updateGroupExitMonitor(parseAgentJson(body)), (state) =>
|
|
sendJson(res, 200, state)
|
|
)
|
|
})
|
|
}
|
|
|
|
if (pathname === `${groupExitMonitorPath}/events`) {
|
|
return withMethods(['GET'], ({ res, url }) =>
|
|
void handleAgentApi(res, () => api.listGroupExitMonitorEvents(url.searchParams), (result) =>
|
|
sendJson(res, 200, result)
|
|
)
|
|
)
|
|
}
|
|
|
|
const groupStatsPrefix = '/api/v1/groups/'
|
|
if (pathname.startsWith(groupStatsPrefix)) {
|
|
const segments = pathname.slice(groupStatsPrefix.length).split('/')
|
|
if (segments.length === 2 && segments[1] === 'member-stats' && segments[0]) {
|
|
let conversationId: string
|
|
try {
|
|
conversationId = decodeURIComponent(segments[0])
|
|
} catch {
|
|
return undefined
|
|
}
|
|
return withMethods(['GET'], ({ res, url }) =>
|
|
void handleAgentApi(
|
|
res,
|
|
() => api.getGroupMemberStats(conversationId, url.searchParams),
|
|
(result) => sendJson(res, 200, result)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
const automationCollection = '/api/v1/automations'
|
|
if (pathname === automationCollection) {
|
|
return withMethods(['GET', 'POST'], async ({ req, res, url, body }) => {
|
|
if (req.method === 'GET') {
|
|
await handleAgentApi(
|
|
res,
|
|
() => api.listAutomations({
|
|
type: url.searchParams.get('type'),
|
|
enabled: url.searchParams.get('enabled')
|
|
}),
|
|
(rules) => sendJson(res, 200, { count: rules.length, rules })
|
|
)
|
|
return
|
|
}
|
|
await handleAgentApi(res, () => api.createAutomation(parseAgentJson(body)), (rule) =>
|
|
sendJson(res, 201, { created: true, rule })
|
|
)
|
|
})
|
|
}
|
|
|
|
if (pathname === `${automationCollection}/validate`) {
|
|
return withMethods(['POST'], async ({ res, body }) => {
|
|
await handleAgentApi(res, () => api.validateAutomation(parseAgentJson(body)), (result) =>
|
|
sendJson(res, 200, result)
|
|
)
|
|
})
|
|
}
|
|
|
|
if (pathname === `${automationCollection}/executions`) {
|
|
return withMethods(['GET'], ({ res, url }) =>
|
|
void handleAgentApi(res, () => api.listExecutions(url.searchParams), (result) =>
|
|
sendJson(res, 200, result)
|
|
)
|
|
)
|
|
}
|
|
|
|
const prefix = `${automationCollection}/`
|
|
if (!pathname.startsWith(prefix)) return undefined
|
|
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
|
if (segments.length < 1 || segments.length > 2) return undefined
|
|
let id: string
|
|
try {
|
|
id = decodeURIComponent(segments[0])
|
|
} catch {
|
|
return undefined
|
|
}
|
|
if (!id || id.includes('/') || id.includes('\\')) return undefined
|
|
const action = segments[1]
|
|
if (action && action !== 'enable' && action !== 'disable') return undefined
|
|
|
|
if (action) {
|
|
return withMethods(['POST'], ({ res }) =>
|
|
void handleAgentApi(res, () => api.setAutomationEnabled(id, action === 'enable'), (rule) =>
|
|
sendJson(res, 200, { updated: true, rule })
|
|
)
|
|
)
|
|
}
|
|
|
|
return withMethods(['GET', 'PATCH', 'DELETE'], async ({ req, res, body }) => {
|
|
if (req.method === 'GET') {
|
|
await handleAgentApi(res, () => api.getAutomation(id), (rule) =>
|
|
sendJson(res, 200, { rule })
|
|
)
|
|
return
|
|
}
|
|
if (req.method === 'PATCH') {
|
|
await handleAgentApi(
|
|
res,
|
|
() => api.updateAutomation(id, parseAgentJson(body)),
|
|
(rule) => sendJson(res, 200, { updated: true, rule })
|
|
)
|
|
return
|
|
}
|
|
await handleAgentApi(res, () => api.deleteAutomation(id), (result) =>
|
|
sendJson(res, 200, { deleted: true, ...result })
|
|
)
|
|
})
|
|
}
|
|
|
|
function requestIdFor(req: IncomingMessage): string {
|
|
const incoming = req.headers['x-request-id']
|
|
return typeof incoming === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incoming)
|
|
? incoming
|
|
: crypto.randomUUID()
|
|
}
|
|
|
|
function isAgentApiPath(pathname: string): boolean {
|
|
return pathname === '/api/v1/capabilities' ||
|
|
pathname === '/api/v1/monitors/group-exits' ||
|
|
pathname.startsWith('/api/v1/monitors/group-exits/') ||
|
|
pathname.startsWith('/api/v1/groups/') ||
|
|
pathname === '/api/v1/automations' ||
|
|
pathname.startsWith('/api/v1/automations/')
|
|
}
|
|
|
|
export function startHttpServer(
|
|
host: string = DEFAULT_HTTP_HOST,
|
|
port: number = DEFAULT_HTTP_PORT,
|
|
options: HttpServerOptions = {}
|
|
): Promise<HttpServerHandle> {
|
|
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
|
const mediaProvider = options.mediaProvider || readImageMedia
|
|
const scheduledReportApi = createScheduledReportApi(options)
|
|
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
|
const agentApi = createAgentApiService(options)
|
|
return new Promise((resolve, reject) => {
|
|
const server: Server = http.createServer(async (req, res) => {
|
|
const requestId = requestIdFor(req)
|
|
res.setHeader('X-Request-Id', requestId)
|
|
let agentApiRequest = false
|
|
try {
|
|
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
|
agentApiRequest = isAgentApiPath(url.pathname)
|
|
if (!applyCorsHeaders(req, res)) {
|
|
if (agentApiRequest) {
|
|
return sendAgentHttpError(res, 403, 'FORBIDDEN', 'Origin 不允许访问本地 API')
|
|
}
|
|
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
|
}
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204)
|
|
return res.end()
|
|
}
|
|
const handler =
|
|
routes[url.pathname] ||
|
|
createAgentApiRoute(url.pathname, agentApi) ||
|
|
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
|
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
|
|
? createQueryRoute(url.pathname, queryApi)
|
|
: undefined) ||
|
|
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
|
? createMediaRoute(mediaProvider)
|
|
: undefined)
|
|
if (!handler) {
|
|
if (agentApiRequest) {
|
|
return sendAgentHttpError(res, 404, 'NOT_FOUND', `端点不存在: ${url.pathname}`)
|
|
}
|
|
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
|
}
|
|
if (!handler.allowedMethods.includes(req.method as HttpMethod)) {
|
|
if (agentApiRequest) {
|
|
res.setHeader('Allow', handler.allowedMethods.join(', '))
|
|
return sendAgentHttpError(
|
|
res,
|
|
405,
|
|
'METHOD_NOT_ALLOWED',
|
|
`请求方法不受支持;允许的方法:${handler.allowedMethods.join(', ')}`,
|
|
{ allowedMethods: handler.allowedMethods }
|
|
)
|
|
}
|
|
return sendMethodNotAllowed(res, handler.allowedMethods)
|
|
}
|
|
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
|
if (agentApiRequest) {
|
|
return sendAgentHttpError(res, 401, 'UNAUTHORIZED', 'Valid API token required')
|
|
}
|
|
return sendUnauthorized(res)
|
|
}
|
|
let body: string | undefined
|
|
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
body = await readBody(req)
|
|
}
|
|
const ctx: RouteContext = { req, res, url, body }
|
|
await handler(ctx)
|
|
} catch (error) {
|
|
if (error instanceof RequestBodyTooLargeError) {
|
|
res.setHeader('Connection', 'close')
|
|
res.shouldKeepAlive = false
|
|
if (agentApiRequest) {
|
|
sendAgentHttpError(res, 413, 'PAYLOAD_TOO_LARGE', '请求体不能超过 1 MiB')
|
|
return
|
|
}
|
|
sendError(res, 413, '请求体不能超过 1 MiB')
|
|
return
|
|
}
|
|
safeError(`[HttpServer requestId=${requestId}] 请求处理失败:`, error)
|
|
if (!res.headersSent) {
|
|
if (agentApiRequest) {
|
|
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
|
return
|
|
}
|
|
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
|
}
|
|
}
|
|
})
|
|
|
|
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
const message =
|
|
error.code === 'EADDRINUSE'
|
|
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
|
|
: error.message
|
|
reject(Object.assign(error, { friendlyMessage: message }))
|
|
})
|
|
server.listen(port, host, () => {
|
|
server.off('error', () => undefined)
|
|
const actualPort = (server.address() as { port: number } | null)?.port ?? port
|
|
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
|
|
resolve({
|
|
host,
|
|
port: actualPort,
|
|
close: () =>
|
|
new Promise<void>((res) => {
|
|
server.close(() => res())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
export interface ApiServerState {
|
|
running: boolean
|
|
host: string
|
|
port: number
|
|
error?: string
|
|
}
|
|
|
|
let singleton: HttpServerHandle | null = null
|
|
let singletonState: ApiServerState = {
|
|
running: false,
|
|
host: DEFAULT_HTTP_HOST,
|
|
port: DEFAULT_HTTP_PORT
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
export const apiServer = {
|
|
isRunning(): boolean {
|
|
return singleton !== null
|
|
},
|
|
|
|
getState(): ApiServerState {
|
|
return { ...singletonState }
|
|
},
|
|
|
|
async start(
|
|
host: string = DEFAULT_HTTP_HOST,
|
|
port: number = DEFAULT_HTTP_PORT
|
|
): Promise<ApiServerState> {
|
|
if (singleton) {
|
|
return this.getState()
|
|
}
|
|
|
|
const token = apiTokenStore.ensureToken()
|
|
if (!token.success) {
|
|
singletonState = {
|
|
running: false,
|
|
host,
|
|
port,
|
|
error: token.error || 'API Token 安全存储不可用'
|
|
}
|
|
return { ...singletonState }
|
|
}
|
|
|
|
const maxAttempts = 4
|
|
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
singleton = await startHttpServer(host, port, {
|
|
tokenProvider: () => apiTokenStore.getTokenForAuthentication()
|
|
})
|
|
singletonState = {
|
|
running: true,
|
|
host: singleton.host,
|
|
port: singleton.port
|
|
}
|
|
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
|
|
return { ...singletonState }
|
|
} catch (error) {
|
|
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
|
|
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
|
|
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
|
|
await sleep(400 * attempt)
|
|
}
|
|
}
|
|
|
|
const message =
|
|
lastError?.friendlyMessage ||
|
|
(lastError instanceof Error ? lastError.message : String(lastError)) ||
|
|
'API 启动失败'
|
|
singletonState = {
|
|
running: false,
|
|
host,
|
|
port,
|
|
error: message
|
|
}
|
|
safeError('[ApiServer] start failed:', message)
|
|
return { ...singletonState }
|
|
},
|
|
|
|
async stop(): Promise<ApiServerState> {
|
|
if (!singleton) {
|
|
return this.getState()
|
|
}
|
|
try {
|
|
await singleton.close()
|
|
} catch (error) {
|
|
safeWarn('[ApiServer] close failed:', error)
|
|
}
|
|
singleton = null
|
|
singletonState = { ...singletonState, running: false }
|
|
safeLog('[ApiServer] stopped')
|
|
return { ...singletonState }
|
|
}
|
|
}
|