feat(export): add card stats diagnostics panel and log export

This commit is contained in:
tisonhuang
2026-03-03 10:05:23 +08:00
parent e9971aa6c4
commit 84b54e43aa
7 changed files with 1750 additions and 73 deletions

View File

@@ -19,6 +19,7 @@ import {
ExportContentSessionStatsEntry,
ExportContentStatsCacheService
} from './exportContentStatsCacheService'
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { LRUCache } from '../utils/LRUCache.js'
@@ -908,15 +909,32 @@ class ChatService {
*/
async getSessionMessageCounts(
sessionIds: string[],
options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }
options?: { preferHintCache?: boolean; bypassSessionCache?: boolean; traceId?: string }
): Promise<{
success: boolean
counts?: Record<string, number>
error?: string
}> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-get-session-message-counts',
stepName: 'ChatService.getSessionMessageCounts',
message: '开始批量读取会话消息总数',
data: {
requestedSessions: Array.isArray(sessionIds) ? sessionIds.length : 0,
preferHintCache: options?.preferHintCache !== false,
bypassSessionCache: options?.bypassSessionCache === true
}
})
let success = false
let errorMessage = ''
let returnedCounts = 0
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
errorMessage = connectResult.error || '数据库未连接'
return { success: false, error: connectResult.error || '数据库未连接' }
}
@@ -928,6 +946,7 @@ class ChatService {
)
)
if (normalizedSessionIds.length === 0) {
success = true
return { success: true, counts: {} }
}
@@ -966,6 +985,18 @@ class ChatService {
const batchSize = 320
for (let i = 0; i < pendingSessionIds.length; i += batchSize) {
const batch = pendingSessionIds.slice(i, i + batchSize)
this.logExportDiag({
traceId,
level: 'debug',
source: 'backend',
stepId: 'backend-get-session-message-counts-batch',
stepName: '会话消息总数批次查询',
status: 'running',
message: `开始查询批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(pendingSessionIds.length / batchSize) || 1}`,
data: {
batchSize: batch.length
}
})
let batchCounts: Record<string, number> = {}
try {
const result = await wcdbService.getMessageCounts(batch)
@@ -988,10 +1019,23 @@ class ChatService {
}
}
returnedCounts = Object.keys(counts).length
success = true
return { success: true, counts }
} catch (e) {
console.error('ChatService: 批量获取会话消息总数失败:', e)
errorMessage = String(e)
return { success: false, error: String(e) }
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-get-session-message-counts',
stepName: 'ChatService.getSessionMessageCounts',
startedAt: stepStartedAt,
success,
message: success ? '批量会话消息总数读取完成' : '批量会话消息总数读取失败',
data: success ? { returnedCounts } : { error: errorMessage || '未知错误' }
})
}
}
@@ -1628,6 +1672,82 @@ class ChatService {
await Promise.all(runners)
}
private normalizeExportDiagTraceId(traceId?: string): string {
const normalized = String(traceId || '').trim()
return normalized
}
private logExportDiag(input: {
traceId?: string
source?: 'backend' | 'main' | 'frontend' | 'worker'
level?: 'debug' | 'info' | 'warn' | 'error'
message: string
stepId?: string
stepName?: string
status?: 'running' | 'done' | 'failed' | 'timeout'
durationMs?: number
data?: Record<string, unknown>
}): void {
const traceId = this.normalizeExportDiagTraceId(input.traceId)
if (!traceId) return
exportCardDiagnosticsService.log({
traceId,
source: input.source || 'backend',
level: input.level || 'info',
message: input.message,
stepId: input.stepId,
stepName: input.stepName,
status: input.status,
durationMs: input.durationMs,
data: input.data
})
}
private startExportDiagStep(input: {
traceId?: string
stepId: string
stepName: string
message: string
data?: Record<string, unknown>
}): number {
const startedAt = Date.now()
const traceId = this.normalizeExportDiagTraceId(input.traceId)
if (traceId) {
exportCardDiagnosticsService.stepStart({
traceId,
stepId: input.stepId,
stepName: input.stepName,
source: 'backend',
message: input.message,
data: input.data
})
}
return startedAt
}
private endExportDiagStep(input: {
traceId?: string
stepId: string
stepName: string
startedAt: number
success: boolean
message?: string
data?: Record<string, unknown>
}): void {
const traceId = this.normalizeExportDiagTraceId(input.traceId)
if (!traceId) return
exportCardDiagnosticsService.stepEnd({
traceId,
stepId: input.stepId,
stepName: input.stepName,
source: 'backend',
status: input.success ? 'done' : 'failed',
message: input.message || (input.success ? `${input.stepName} 完成` : `${input.stepName} 失败`),
durationMs: Math.max(0, Date.now() - input.startedAt),
data: input.data
})
}
private refreshSessionMessageCountCacheScope(): void {
const dbPath = String(this.configService.get('dbPath') || '')
const myWxid = String(this.configService.get('myWxid') || '')
@@ -1688,35 +1808,75 @@ class ChatService {
this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry)
}
private async listExportContentScopeSessionIds(force = false): Promise<string[]> {
private async listExportContentScopeSessionIds(force = false, traceId?: string): Promise<string[]> {
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
message: '开始获取导出内容统计范围会话',
data: { force }
})
let success = false
let loadedCount = 0
let errorMessage = ''
const now = Date.now()
if (
!force &&
this.exportContentScopeSessionIdsCache &&
now - this.exportContentScopeSessionIdsCache.updatedAt <= this.exportContentScopeSessionIdsCacheTtlMs
) {
success = true
loadedCount = this.exportContentScopeSessionIdsCache.ids.length
this.endExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
startedAt: stepStartedAt,
success,
message: '命中会话范围缓存',
data: { count: loadedCount, fromCache: true }
})
return this.exportContentScopeSessionIdsCache.ids
}
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
return []
}
try {
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
errorMessage = sessionsResult.error || '获取会话失败'
return []
}
const ids = Array.from(
new Set(
sessionsResult.sessions
.map((session) => String(session.username || '').trim())
.filter(Boolean)
.filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_'))
const ids = Array.from(
new Set(
sessionsResult.sessions
.map((session) => String(session.username || '').trim())
.filter(Boolean)
.filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_'))
)
)
)
this.exportContentScopeSessionIdsCache = {
ids,
updatedAt: now
this.exportContentScopeSessionIdsCache = {
ids,
updatedAt: now
}
success = true
loadedCount = ids.length
return ids
} catch (error) {
errorMessage = String(error)
return []
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-list-export-content-session-ids',
stepName: '列出导出内容会话范围',
startedAt: stepStartedAt,
success,
message: success ? '导出内容会话范围获取完成' : '导出内容会话范围获取失败',
data: success ? { count: loadedCount, fromCache: false } : { error: errorMessage || '未知错误' }
})
}
return ids
}
private createDefaultExportContentEntry(): ExportContentSessionStatsEntry {
@@ -1735,10 +1895,28 @@ class ChatService {
return this.exportContentStatsDirtySessionIds.has(sessionId)
}
private async collectExportContentEntry(sessionId: string): Promise<ExportContentSessionStatsEntry> {
private async collectExportContentEntry(sessionId: string, traceId?: string): Promise<ExportContentSessionStatsEntry> {
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
message: '开始扫描会话内容类型',
data: { sessionId }
})
let fallback = false
const entry = this.createDefaultExportContentEntry()
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 400, false, 0, 0)
if (!cursorResult.success || !cursorResult.cursor) {
fallback = true
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: false,
message: '会话内容扫描失败,游标未创建',
data: { sessionId, error: cursorResult.error || 'openMessageCursorLite failed' }
})
return {
...entry,
updatedAt: Date.now(),
@@ -1748,53 +1926,111 @@ class ChatService {
const cursor = cursorResult.cursor
try {
let done = false
while (!done) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
break
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) {
entry.hasAny = true
const localType = this.getRowInt(
row,
['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'],
1
)
if (localType === 34) entry.hasVoice = true
if (localType === 3) entry.hasImage = true
if (localType === 43) entry.hasVideo = true
if (localType === 47) entry.hasEmoji = true
try {
let done = false
while (!done) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
break
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) {
entry.hasAny = true
const localType = this.getRowInt(
row,
['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'],
1
)
if (localType === 34) entry.hasVoice = true
if (localType === 3) entry.hasImage = true
if (localType === 43) entry.hasVideo = true
if (localType === 47) entry.hasEmoji = true
if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) {
done = true
if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) {
done = true
break
}
}
if (!batch.hasMore || rows.length === 0) {
break
}
}
if (!batch.hasMore || rows.length === 0) {
break
}
} catch (error) {
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: false,
message: '会话内容扫描异常',
data: { sessionId, error: String(error) }
})
throw error
}
entry.mediaReady = true
entry.updatedAt = Date.now()
if (!fallback) {
this.endExportDiagStep({
traceId,
stepId: 'backend-collect-export-content-entry',
stepName: '扫描单会话内容类型',
startedAt: stepStartedAt,
success: true,
message: '会话内容扫描完成',
data: {
sessionId,
hasAny: entry.hasAny,
hasVoice: entry.hasVoice,
hasImage: entry.hasImage,
hasVideo: entry.hasVideo,
hasEmoji: entry.hasEmoji
}
})
}
return entry
} finally {
await wcdbService.closeMessageCursor(cursor)
}
entry.mediaReady = true
entry.updatedAt = Date.now()
return entry
}
private async startExportContentStatsRefresh(force = false): Promise<void> {
private async startExportContentStatsRefresh(force = false, traceId?: string): Promise<void> {
const refreshStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
message: '开始后台刷新导出内容会话统计',
data: { force }
})
if (this.exportContentStatsRefreshPromise) {
this.exportContentStatsRefreshQueued = true
this.exportContentStatsRefreshForceQueued = this.exportContentStatsRefreshForceQueued || force
this.logExportDiag({
traceId,
level: 'debug',
source: 'backend',
message: '已有刷新任务在执行,已加入队列',
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
status: 'running',
data: { forceQueued: this.exportContentStatsRefreshForceQueued }
})
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: true,
message: '复用进行中的后台刷新任务',
data: { queued: true, forceQueued: this.exportContentStatsRefreshForceQueued }
})
return this.exportContentStatsRefreshPromise
}
const task = (async () => {
const sessionIds = await this.listExportContentScopeSessionIds(force)
const sessionIds = await this.listExportContentScopeSessionIds(force, traceId)
const sessionIdSet = new Set(sessionIds)
const targets: string[] = []
@@ -1806,9 +2042,22 @@ class ChatService {
}
if (targets.length > 0) {
this.logExportDiag({
traceId,
source: 'backend',
level: 'info',
message: '准备刷新导出内容会话统计目标',
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
status: 'running',
data: {
totalSessions: sessionIds.length,
targetSessions: targets.length
}
})
await this.forEachWithConcurrency(targets, 3, async (sessionId) => {
try {
const nextEntry = await this.collectExportContentEntry(sessionId)
const nextEntry = await this.collectExportContentEntry(sessionId, traceId)
this.exportContentStatsMemory.set(sessionId, nextEntry)
if (nextEntry.mediaReady) {
this.exportContentStatsDirtySessionIds.delete(sessionId)
@@ -1836,13 +2085,35 @@ class ChatService {
this.exportContentStatsRefreshPromise = task
try {
await task
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: true,
message: '后台刷新导出内容统计完成',
data: {
force
}
})
} catch (error) {
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-stats',
stepName: '后台刷新导出内容统计',
startedAt: refreshStartedAt,
success: false,
message: '后台刷新导出内容统计失败',
data: { error: String(error) }
})
throw error
} finally {
this.exportContentStatsRefreshPromise = null
if (this.exportContentStatsRefreshQueued) {
const rerunForce = this.exportContentStatsRefreshForceQueued
this.exportContentStatsRefreshQueued = false
this.exportContentStatsRefreshForceQueued = false
void this.startExportContentStatsRefresh(rerunForce)
void this.startExportContentStatsRefresh(rerunForce, traceId)
}
}
}
@@ -1850,17 +2121,34 @@ class ChatService {
async getExportContentSessionCounts(options?: {
triggerRefresh?: boolean
forceRefresh?: boolean
traceId?: string
}): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
message: '开始计算导出卡片统计',
data: {
triggerRefresh: options?.triggerRefresh !== false,
forceRefresh: options?.forceRefresh === true
}
})
let stepSuccess = false
let stepError = ''
let stepResult: ExportContentSessionCounts | undefined
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
stepError = connectResult.error || '数据库未连接'
return { success: false, error: connectResult.error || '数据库未连接' }
}
this.refreshSessionMessageCountCacheScope()
const forceRefresh = options?.forceRefresh === true
const triggerRefresh = options?.triggerRefresh !== false
const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh)
const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId)
const sessionIdSet = new Set(sessionIds)
for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) {
@@ -1906,10 +2194,34 @@ class ChatService {
}
if (missingTextCountSessionIds.length > 0) {
const textCountStepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
message: '开始补全文本会话计数',
data: { missingSessions: missingTextCountSessionIds.length }
})
const textCountStallTimer = setTimeout(() => {
this.logExportDiag({
traceId,
source: 'backend',
level: 'warn',
message: '补全文本会话计数耗时较长',
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
status: 'running',
data: {
elapsedMs: Date.now() - textCountStepStartedAt,
missingSessions: missingTextCountSessionIds.length
}
})
}, 3000)
const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, {
preferHintCache: false,
bypassSessionCache: true
bypassSessionCache: true,
traceId
})
clearTimeout(textCountStallTimer)
if (textCountResult.success && textCountResult.counts) {
const now = Date.now()
for (const sessionId of missingTextCountSessionIds) {
@@ -1927,47 +2239,109 @@ class ChatService {
}
}
this.persistExportContentStatsScope(sessionIdSet)
this.endExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
startedAt: textCountStepStartedAt,
success: true,
message: '文本会话计数补全完成',
data: { updatedSessions: missingTextCountSessionIds.length }
})
} else {
this.endExportDiagStep({
traceId,
stepId: 'backend-fill-text-counts',
stepName: '补全文本会话计数',
startedAt: textCountStepStartedAt,
success: false,
message: '文本会话计数补全失败',
data: { error: textCountResult.error || '未知错误' }
})
}
}
if (forceRefresh && triggerRefresh) {
void this.startExportContentStatsRefresh(true)
void this.startExportContentStatsRefresh(true, traceId)
} else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) {
void this.startExportContentStatsRefresh(false)
void this.startExportContentStatsRefresh(false, traceId)
}
stepResult = {
totalSessions: sessionIds.length,
textSessions,
voiceSessions,
imageSessions,
videoSessions,
emojiSessions,
pendingMediaSessions: pendingMediaSessionSet.size,
updatedAt: this.exportContentStatsScopeUpdatedAt,
refreshing: this.exportContentStatsRefreshPromise !== null
}
stepSuccess = true
return {
success: true,
data: {
totalSessions: sessionIds.length,
textSessions,
voiceSessions,
imageSessions,
videoSessions,
emojiSessions,
pendingMediaSessions: pendingMediaSessionSet.size,
updatedAt: this.exportContentStatsScopeUpdatedAt,
refreshing: this.exportContentStatsRefreshPromise !== null
}
data: stepResult
}
} catch (e) {
console.error('ChatService: 获取导出内容会话统计失败:', e)
stepError = String(e)
return { success: false, error: String(e) }
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-get-export-content-session-counts',
stepName: '获取导出卡片统计',
startedAt: stepStartedAt,
success: stepSuccess,
message: stepSuccess ? '导出卡片统计计算完成' : '导出卡片统计计算失败',
data: stepSuccess
? {
totalSessions: stepResult?.totalSessions || 0,
pendingMediaSessions: stepResult?.pendingMediaSessions || 0,
refreshing: stepResult?.refreshing === true
}
: { error: stepError || '未知错误' }
})
}
}
async refreshExportContentSessionCounts(options?: { forceRefresh?: boolean }): Promise<{ success: boolean; error?: string }> {
async refreshExportContentSessionCounts(options?: { forceRefresh?: boolean; traceId?: string }): Promise<{ success: boolean; error?: string }> {
const traceId = this.normalizeExportDiagTraceId(options?.traceId)
const stepStartedAt = this.startExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-session-counts',
stepName: '刷新导出卡片统计',
message: '开始刷新导出卡片统计',
data: { forceRefresh: options?.forceRefresh === true }
})
let stepSuccess = false
let stepError = ''
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
stepError = connectResult.error || '数据库未连接'
return { success: false, error: connectResult.error || '数据库未连接' }
}
this.refreshSessionMessageCountCacheScope()
await this.startExportContentStatsRefresh(options?.forceRefresh === true)
await this.startExportContentStatsRefresh(options?.forceRefresh === true, traceId)
stepSuccess = true
return { success: true }
} catch (e) {
console.error('ChatService: 刷新导出内容会话统计失败:', e)
stepError = String(e)
return { success: false, error: String(e) }
} finally {
this.endExportDiagStep({
traceId,
stepId: 'backend-refresh-export-content-session-counts',
stepName: '刷新导出卡片统计',
startedAt: stepStartedAt,
success: stepSuccess,
message: stepSuccess ? '刷新导出卡片统计完成' : '刷新导出卡片统计失败',
data: stepSuccess ? undefined : { error: stepError || '未知错误' }
})
}
}

View File

@@ -0,0 +1,354 @@
import { mkdir, writeFile } from 'fs/promises'
import { basename, dirname, extname, join } from 'path'
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
export interface ExportCardDiagLogEntry {
id: string
ts: number
source: ExportCardDiagSource
level: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
interface ActiveStepState {
key: string
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
startedAt: number
lastUpdatedAt: number
message?: string
}
interface StepStartInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
}
interface StepEndInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
durationMs?: number
}
interface LogInput {
ts?: number
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
export interface ExportCardDiagSnapshot {
logs: ExportCardDiagLogEntry[]
activeSteps: Array<{
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
elapsedMs: number
stallMs: number
startedAt: number
lastUpdatedAt: number
message?: string
}>
summary: {
totalLogs: number
activeStepCount: number
errorCount: number
warnCount: number
timeoutCount: number
lastUpdatedAt: number
}
}
export class ExportCardDiagnosticsService {
private readonly maxLogs = 6000
private logs: ExportCardDiagLogEntry[] = []
private activeSteps = new Map<string, ActiveStepState>()
private seq = 0
private nextId(ts: number): string {
this.seq += 1
return `export-card-diag-${ts}-${this.seq}`
}
private trimLogs() {
if (this.logs.length <= this.maxLogs) return
const drop = this.logs.length - this.maxLogs
this.logs.splice(0, drop)
}
log(input: LogInput): ExportCardDiagLogEntry {
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
const entry: ExportCardDiagLogEntry = {
id: this.nextId(ts),
ts,
source: input.source,
level: input.level || 'info',
message: input.message,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status,
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
data: input.data
}
this.logs.push(entry)
this.trimLogs()
if (entry.traceId && entry.stepId && entry.stepName) {
const key = `${entry.traceId}::${entry.stepId}`
if (entry.status === 'running') {
const previous = this.activeSteps.get(key)
this.activeSteps.set(key, {
key,
traceId: entry.traceId,
stepId: entry.stepId,
stepName: entry.stepName,
source: entry.source,
startedAt: previous?.startedAt || entry.ts,
lastUpdatedAt: entry.ts,
message: entry.message
})
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
this.activeSteps.delete(key)
}
}
return entry
}
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || 'info',
message: input.message || `${input.stepName} 开始`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: 'running',
data: input.data
})
}
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status || 'done',
durationMs: input.durationMs,
data: input.data
})
}
clear() {
this.logs = []
this.activeSteps.clear()
}
snapshot(limit = 1200): ExportCardDiagSnapshot {
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
const logs = this.logs.slice(-capped)
const now = Date.now()
const activeSteps = Array.from(this.activeSteps.values())
.map(step => ({
traceId: step.traceId,
stepId: step.stepId,
stepName: step.stepName,
source: step.source,
startedAt: step.startedAt,
lastUpdatedAt: step.lastUpdatedAt,
elapsedMs: Math.max(0, now - step.startedAt),
stallMs: Math.max(0, now - step.lastUpdatedAt),
message: step.message
}))
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
}
return {
logs,
activeSteps,
summary: {
totalLogs: this.logs.length,
activeStepCount: activeSteps.length,
errorCount,
warnCount,
timeoutCount,
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
}
}
}
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
const result: ExportCardDiagLogEntry[] = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const tsRaw = row.ts ?? row.timestamp
const tsNum = Number(tsRaw)
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
const sourceRaw = String(row.source || 'frontend')
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
? sourceRaw
: 'frontend'
const levelRaw = String(row.level || 'info')
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
? levelRaw
: 'info'
const statusRaw = String(row.status || '')
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
? statusRaw
: undefined
const durationRaw = Number(row.durationMs)
result.push({
id: String(row.id || this.nextId(ts)),
ts,
source,
level,
message: String(row.message || ''),
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
status,
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
})
}
return result
}
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
return JSON.stringify(log)
}
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
const total = logs.length
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
let frontendCount = 0
let backendCount = 0
let mainCount = 0
let workerCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
if (item.source === 'frontend') frontendCount += 1
if (item.source === 'backend') backendCount += 1
if (item.source === 'main') mainCount += 1
if (item.source === 'worker') workerCount += 1
}
const lines: string[] = []
lines.push('WeFlow 导出卡片诊断摘要')
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push(`日志总数: ${total}`)
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
lines.push(`当前活跃步骤: ${activeSteps.length}`)
if (activeSteps.length > 0) {
lines.push('')
lines.push('活跃步骤:')
for (const step of activeSteps.slice(0, 12)) {
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
}
}
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
if (latestErrors.length > 0) {
lines.push('')
lines.push('最近异常:')
for (const item of latestErrors) {
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
}
}
return lines.join('\n')
}
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
success: boolean
filePath?: string
summaryPath?: string
count?: number
error?: string
}> {
try {
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
const merged = [...this.logs, ...normalizedFrontend]
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
const ext = extname(filePath)
const baseName = ext ? basename(filePath, ext) : basename(filePath)
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
const snapshot = this.snapshot(1500)
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
await writeFile(summaryPath, summaryText, 'utf8')
return {
success: true,
filePath,
summaryPath,
count: merged.length
}
} catch (error) {
return {
success: false,
error: String(error)
}
}
}
}
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()