mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
fix(logging): 完善服务端错误日志记录并支持设置页打开日志
- 为后端增加 5xx 请求与未处理异常日志记录 - 新增前端服务端错误上报工具与管理接口 - 在聊天导出、朋友圈信息加载、通用 API 请求中补充错误上报 - 设置页新增日志文件路径展示与一键打开能力 - 增加服务端错误日志相关测试
This commit is contained in:
@@ -167,6 +167,26 @@
|
||||
{{ desktopOutputDirError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">日志文件</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopLogFileText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopLogFileLoading || desktopLogFileOpening"
|
||||
@click="onOpenBackendLogFile"
|
||||
>
|
||||
{{ desktopLogFileOpening ? '打开中...' : '打开日志' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopLogFileError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopLogFileError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -271,8 +291,9 @@
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -331,6 +352,15 @@ const desktopOutputDirText = computed(() => {
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const desktopLogFilePath = ref('')
|
||||
const desktopLogFileLoading = ref(false)
|
||||
const desktopLogFileOpening = ref(false)
|
||||
const desktopLogFileError = ref('')
|
||||
const desktopLogFileText = computed(() => {
|
||||
const v = String(desktopLogFilePath.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const switchTrackClass = (enabled, disabled = false) => {
|
||||
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
@@ -376,6 +406,24 @@ const onEscKeydown = (event) => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const fetchAdminEndpoint = async (url, options = {}) => {
|
||||
const apiBase = useApiBase()
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
baseURL: apiBase,
|
||||
...options,
|
||||
})
|
||||
} catch (e) {
|
||||
await reportServerErrorFromError(e, {
|
||||
method: options?.method || 'GET',
|
||||
requestUrl: url,
|
||||
source: 'SettingsDialog',
|
||||
apiBase,
|
||||
})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
@@ -452,8 +500,7 @@ const refreshDesktopBackendPort = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = useApiBase()
|
||||
const resp = await $fetch('/admin/port', { baseURL: apiBase })
|
||||
const resp = await fetchAdminEndpoint('/admin/port')
|
||||
const n = Number(resp?.port)
|
||||
const d = Number(resp?.default_port)
|
||||
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
|
||||
@@ -510,6 +557,34 @@ const onDesktopOpenOutputDir = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshBackendLogFileInfo = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopLogFileLoading.value = true
|
||||
desktopLogFileError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/log-file')
|
||||
desktopLogFilePath.value = String(resp?.path || '').trim()
|
||||
} catch (e) {
|
||||
desktopLogFileError.value = e?.message || '读取日志文件失败'
|
||||
} finally {
|
||||
desktopLogFileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenBackendLogFile = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopLogFileOpening.value = true
|
||||
desktopLogFileError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/log-file/open', { method: 'POST' })
|
||||
if (resp?.path) desktopLogFilePath.value = String(resp.path || '').trim()
|
||||
} catch (e) {
|
||||
desktopLogFileError.value = e?.message || '打开日志文件失败'
|
||||
} finally {
|
||||
desktopLogFileOpening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyDesktopBackendPort = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const raw = String(desktopBackendPortInput.value || '').trim()
|
||||
@@ -526,10 +601,9 @@ const applyDesktopBackendPort = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const currentApiBase = useApiBase()
|
||||
let currentBackendPort = null
|
||||
try {
|
||||
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
|
||||
const info = await fetchAdminEndpoint('/admin/port')
|
||||
const p = Number(info?.port)
|
||||
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
|
||||
} catch {}
|
||||
@@ -540,8 +614,7 @@ const applyDesktopBackendPort = async () => {
|
||||
})()
|
||||
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
|
||||
|
||||
await $fetch('/admin/port', {
|
||||
baseURL: currentApiBase,
|
||||
await fetchAdminEndpoint('/admin/port', {
|
||||
method: 'POST',
|
||||
body: { port: n },
|
||||
})
|
||||
@@ -624,6 +697,11 @@ const onDesktopCheckUpdates = async () => {
|
||||
await desktopUpdate.manualCheck()
|
||||
}
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await refreshBackendLogFileInfo()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportServerError } from '~/utils/server-error-logging'
|
||||
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
const baseURL = useApiBase()
|
||||
@@ -8,10 +10,19 @@ export const useApi = () => {
|
||||
const response = await $fetch(url, {
|
||||
baseURL,
|
||||
...options,
|
||||
onResponseError({ response }) {
|
||||
async onResponseError({ response }) {
|
||||
if (response.status === 400) {
|
||||
throw new Error(response._data?.detail || '请求参数错误')
|
||||
} else if (response.status === 500) {
|
||||
} else if (response.status >= 500) {
|
||||
await reportServerError({
|
||||
status: response.status,
|
||||
method: options?.method || 'GET',
|
||||
requestUrl: url,
|
||||
message: '服务器错误,请稍后重试',
|
||||
backendDetail: response._data?.detail || '',
|
||||
source: 'useApi',
|
||||
apiBase: baseURL,
|
||||
})
|
||||
throw new Error('服务器错误,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2495,6 +2495,7 @@ definePageMeta({
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { reportServerErrorFromResponse } from '~/utils/server-error-logging'
|
||||
import { heatColor } from '~/utils/wrapped/heatmap'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
@@ -3578,6 +3579,12 @@ const saveExportToSelectedFolder = async (options = {}) => {
|
||||
try {
|
||||
const resp = await fetch(getExportDownloadUrl(exportId))
|
||||
if (!resp.ok) {
|
||||
await reportServerErrorFromResponse(resp, {
|
||||
method: 'GET',
|
||||
requestUrl: getExportDownloadUrl(exportId),
|
||||
message: `下载导出文件失败(${resp.status})`,
|
||||
source: 'chat.exportDownload',
|
||||
})
|
||||
throw new Error(`下载导出文件失败(${resp.status})`)
|
||||
}
|
||||
const blob = await resp.blob()
|
||||
|
||||
@@ -707,6 +707,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
@@ -1131,6 +1132,12 @@ const loadSelfInfo = async () => {
|
||||
selfInfo.value = resp
|
||||
}
|
||||
} catch (e) {
|
||||
await reportServerErrorFromError(e, {
|
||||
method: 'GET',
|
||||
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
|
||||
source: 'sns.loadSelfInfo',
|
||||
apiBase,
|
||||
})
|
||||
console.error('获取个人信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useApiBase } from '~/composables/useApiBase'
|
||||
|
||||
const FRONTEND_SERVER_ERROR_ENDPOINT = '/admin/log-frontend-server-error'
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
const n = Number(value)
|
||||
if (!Number.isInteger(n)) return 0
|
||||
return n
|
||||
}
|
||||
|
||||
const stringifyDetail = (value) => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value).trim()
|
||||
}
|
||||
}
|
||||
|
||||
const currentOrigin = () => {
|
||||
if (!process.client || typeof window === 'undefined') return ''
|
||||
try {
|
||||
return String(window.location?.origin || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBasePath = (apiBase) => {
|
||||
const raw = String(apiBase || '').trim()
|
||||
if (!raw) return '/api'
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
return u.pathname.replace(/\/+$/, '') || '/'
|
||||
} catch {
|
||||
return '/api'
|
||||
}
|
||||
}
|
||||
return raw.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
const normalizePathname = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return new URL(raw).pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
return raw.split(/[?#]/, 1)[0].replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
export const isServerErrorStatus = (status) => normalizeStatus(status) >= 500
|
||||
|
||||
export const resolveRequestUrl = (requestUrl, apiBase = '') => {
|
||||
const raw = String(requestUrl || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
|
||||
const origin = currentOrigin()
|
||||
if (!origin) return raw
|
||||
|
||||
if (raw.startsWith('/')) {
|
||||
const prefix = normalizeBasePath(apiBase)
|
||||
const combined = raw === prefix || raw.startsWith(`${prefix}/`) ? raw : `${prefix}${raw}`
|
||||
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
|
||||
try {
|
||||
const baseUrl = new URL(String(apiBase).trim())
|
||||
return new URL(combined, `${baseUrl.origin}/`).toString()
|
||||
} catch {
|
||||
return new URL(combined, origin).toString()
|
||||
}
|
||||
}
|
||||
return new URL(combined, origin).toString()
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
|
||||
try {
|
||||
const base = String(apiBase).trim()
|
||||
return new URL(raw, base.endsWith('/') ? base : `${base}/`).toString()
|
||||
} catch {
|
||||
return new URL(raw, origin).toString()
|
||||
}
|
||||
}
|
||||
|
||||
return new URL(raw, origin).toString()
|
||||
}
|
||||
|
||||
const isFrontendServerLogUrl = (requestUrl) => {
|
||||
const path = normalizePathname(requestUrl)
|
||||
return path.endsWith('/api/admin/log-frontend-server-error') || path.endsWith('/admin/log-frontend-server-error')
|
||||
}
|
||||
|
||||
const extractBackendDetail = (data) => {
|
||||
if (data == null) return ''
|
||||
if (typeof data === 'string') return data.trim()
|
||||
if (typeof data === 'object' && !Array.isArray(data) && Object.prototype.hasOwnProperty.call(data, 'detail')) {
|
||||
return stringifyDetail(data.detail)
|
||||
}
|
||||
return stringifyDetail(data)
|
||||
}
|
||||
|
||||
const resolveApiBase = (apiBase) => {
|
||||
const raw = String(apiBase || '').trim()
|
||||
if (raw) return raw
|
||||
if (!process.client) return ''
|
||||
try {
|
||||
return String(useApiBase() || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const extractServerErrorFromError = (error) => {
|
||||
const response = error?.response
|
||||
return {
|
||||
status: normalizeStatus(error?.status ?? response?.status),
|
||||
backendDetail: extractBackendDetail(response?._data ?? response?.data ?? error?.data),
|
||||
message: String(error?.message || '').trim(),
|
||||
requestUrl: String(response?.url || error?.request || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export const extractServerErrorDetailFromResponse = async (response) => {
|
||||
if (!response || typeof response.clone !== 'function') return ''
|
||||
try {
|
||||
const clone = response.clone()
|
||||
const contentType = String(clone.headers?.get?.('content-type') || '').toLowerCase()
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
const payload = await clone.json()
|
||||
return extractBackendDetail(payload)
|
||||
} catch {}
|
||||
}
|
||||
const text = String(await clone.text()).trim()
|
||||
if (!text) return ''
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
return extractBackendDetail(JSON.parse(text))
|
||||
} catch {}
|
||||
}
|
||||
return text
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const reportServerError = async (context = {}) => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
|
||||
const status = normalizeStatus(context.status)
|
||||
if (!isServerErrorStatus(status)) return false
|
||||
|
||||
const apiBase = resolveApiBase(context.apiBase)
|
||||
const requestUrl = resolveRequestUrl(context.requestUrl, apiBase)
|
||||
if (!requestUrl || isFrontendServerLogUrl(requestUrl)) return false
|
||||
|
||||
const endpointUrl = resolveRequestUrl(FRONTEND_SERVER_ERROR_ENDPOINT, apiBase)
|
||||
if (!endpointUrl) return false
|
||||
|
||||
const payload = {
|
||||
status,
|
||||
method: String(context.method || 'GET').trim().toUpperCase() || 'GET',
|
||||
request_url: requestUrl,
|
||||
message: String(context.message || '').trim(),
|
||||
backend_detail: String(context.backendDetail || '').trim(),
|
||||
source: String(context.source || '').trim(),
|
||||
page_url: String(window.location?.href || '').trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const reportServerErrorFromError = async (error, context = {}) => {
|
||||
const info = extractServerErrorFromError(error)
|
||||
return await reportServerError({
|
||||
...context,
|
||||
status: context.status ?? info.status,
|
||||
requestUrl: context.requestUrl || info.requestUrl,
|
||||
message: context.message || info.message,
|
||||
backendDetail: context.backendDetail || info.backendDetail,
|
||||
})
|
||||
}
|
||||
|
||||
export const reportServerErrorFromResponse = async (response, context = {}) => {
|
||||
const status = normalizeStatus(context.status ?? response?.status)
|
||||
if (!isServerErrorStatus(status)) return false
|
||||
const backendDetail = context.backendDetail || (await extractServerErrorDetailFromResponse(response))
|
||||
return await reportServerError({
|
||||
...context,
|
||||
status,
|
||||
requestUrl: context.requestUrl || response?.url || '',
|
||||
backendDetail,
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ from .logging_config import setup_logging, get_logger
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
request_logger = get_logger("wechat_decrypt_tool.request")
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .path_fix import PathFixRoute
|
||||
@@ -32,6 +33,7 @@ from .routers.sns import router as _sns_router
|
||||
from .routers.sns_export import router as _sns_export_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .routers.wrapped import router as _wrapped_router
|
||||
from .request_logging import log_server_errors_middleware
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
@@ -76,6 +78,11 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_server_errors(request: Request, call_next):
|
||||
return await log_server_errors_middleware(request_logger, request, call_next)
|
||||
|
||||
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
def _stringify_detail(detail: Any) -> str:
|
||||
if detail is None:
|
||||
return ""
|
||||
if isinstance(detail, str):
|
||||
return detail.strip()
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(detail).strip()
|
||||
|
||||
|
||||
def _extract_response_detail(response: Response) -> str:
|
||||
body = getattr(response, "body", None)
|
||||
if body is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
raw = body.tobytes() if isinstance(body, memoryview) else body
|
||||
except Exception:
|
||||
raw = body
|
||||
|
||||
if isinstance(raw, bytes):
|
||||
text = raw.decode("utf-8", errors="ignore").strip()
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
content_type = str(response.headers.get("content-type") or "").lower()
|
||||
if "json" not in content_type:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
return _stringify_detail(payload.get("detail"))
|
||||
|
||||
|
||||
async def _buffer_response_body(response: Response) -> tuple[Response, bytes]:
|
||||
body = getattr(response, "body", None)
|
||||
if body is not None:
|
||||
try:
|
||||
raw = body.tobytes() if isinstance(body, memoryview) else body
|
||||
except Exception:
|
||||
raw = body
|
||||
if isinstance(raw, bytes):
|
||||
return response, raw
|
||||
if isinstance(raw, str):
|
||||
return response, raw.encode("utf-8")
|
||||
return response, bytes(raw)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
body_iterator = getattr(response, "body_iterator", None)
|
||||
if body_iterator is not None:
|
||||
async for chunk in body_iterator:
|
||||
if isinstance(chunk, memoryview):
|
||||
chunks.append(chunk.tobytes())
|
||||
elif isinstance(chunk, bytes):
|
||||
chunks.append(chunk)
|
||||
else:
|
||||
chunks.append(str(chunk).encode("utf-8"))
|
||||
|
||||
body_bytes = b"".join(chunks)
|
||||
rebuilt = Response(
|
||||
content=body_bytes,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
background=response.background,
|
||||
)
|
||||
return rebuilt, body_bytes
|
||||
|
||||
|
||||
def _extract_response_detail_from_body(response: Response, body: bytes) -> str:
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
try:
|
||||
text = body.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
content_type = str(response.headers.get("content-type") or "").lower()
|
||||
if "json" not in content_type:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
return _stringify_detail(payload.get("detail"))
|
||||
|
||||
|
||||
async def log_server_errors_middleware(logger, request: Request, call_next):
|
||||
method = str(request.method or "").upper() or "GET"
|
||||
path = str(request.url.path or "").strip() or "/"
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc)
|
||||
raise
|
||||
|
||||
status = int(getattr(response, "status_code", 0) or 0)
|
||||
if status >= 500:
|
||||
response, body = await _buffer_response_body(response)
|
||||
detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response)
|
||||
if detail:
|
||||
logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail)
|
||||
else:
|
||||
logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path)
|
||||
|
||||
return response
|
||||
@@ -13,11 +13,13 @@ import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..logging_config import get_log_file_path, get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
|
||||
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_BACKEND_PORT = 10392
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
@@ -58,6 +60,36 @@ def _is_loopback_client(request: Request) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_current_log_file_path() -> Path:
|
||||
log_file = Path(get_log_file_path())
|
||||
try:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not log_file.exists():
|
||||
try:
|
||||
log_file.touch(exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return log_file
|
||||
|
||||
|
||||
def _open_path_with_default_app(path: Path) -> None:
|
||||
target = str(path)
|
||||
if os.name == "nt":
|
||||
opener = getattr(os, "startfile", None)
|
||||
if opener is None:
|
||||
raise RuntimeError("当前系统不支持默认打开文件")
|
||||
opener(target)
|
||||
return
|
||||
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", target])
|
||||
return
|
||||
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
|
||||
|
||||
def _is_port_available(port: int, host: str) -> bool:
|
||||
try:
|
||||
addr = (host, int(port))
|
||||
@@ -126,6 +158,54 @@ async def _exit_process_after(delay_s: float) -> None:
|
||||
os._exit(0) # noqa: S404
|
||||
|
||||
|
||||
@router.get("/api/admin/log-file", summary="获取当前后端日志文件路径")
|
||||
async def get_backend_log_file() -> dict:
|
||||
log_file = _get_current_log_file_path()
|
||||
return {"path": str(log_file), "exists": log_file.exists()}
|
||||
|
||||
|
||||
@router.post("/api/admin/log-file/open", summary="打开当前后端日志文件(仅允许本机访问)")
|
||||
async def open_backend_log_file(request: Request) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
log_file = _get_current_log_file_path()
|
||||
try:
|
||||
_open_path_with_default_app(log_file)
|
||||
except Exception as e:
|
||||
logger.error("open_backend_log_file failed path=%s err=%s", log_file, e)
|
||||
raise HTTPException(status_code=500, detail=f"打开日志文件失败:{e}")
|
||||
return {"success": True, "path": str(log_file)}
|
||||
|
||||
|
||||
@router.post("/api/admin/log-frontend-server-error", summary="记录前端感知到的服务器错误")
|
||||
async def log_frontend_server_error(payload: dict) -> dict:
|
||||
data = payload if isinstance(payload, dict) else {}
|
||||
try:
|
||||
status = int(data.get("status"))
|
||||
except Exception:
|
||||
status = 0
|
||||
|
||||
method = str(data.get("method") or "").strip().upper() or "GET"
|
||||
request_url = str(data.get("request_url") or "").strip()
|
||||
message = str(data.get("message") or "").strip()
|
||||
backend_detail = str(data.get("backend_detail") or "").strip()
|
||||
source = str(data.get("source") or "").strip()
|
||||
page_url = str(data.get("page_url") or "").strip()
|
||||
|
||||
logger.error(
|
||||
"[frontend-server-error] status=%s method=%s request_url=%s message=%s backend_detail=%s source=%s page_url=%s",
|
||||
status,
|
||||
method,
|
||||
request_url,
|
||||
message,
|
||||
backend_detail,
|
||||
source,
|
||||
page_url,
|
||||
)
|
||||
return {"success": True, "path": str(_get_current_log_file_path())}
|
||||
|
||||
|
||||
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
|
||||
async def get_backend_port() -> dict:
|
||||
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
import logging
|
||||
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for h in lg.handlers[:]:
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestAdminServerErrorLogging(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._td = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
import wechat_decrypt_tool.request_logging as request_logging
|
||||
import wechat_decrypt_tool.routers.admin as admin_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(logging_config)
|
||||
importlib.reload(request_logging)
|
||||
importlib.reload(admin_router)
|
||||
|
||||
self.logging_config = logging_config
|
||||
self.request_logging = request_logging
|
||||
self.admin_router = admin_router
|
||||
self.log_file = self.logging_config.setup_logging()
|
||||
|
||||
def tearDown(self):
|
||||
_close_logging_handlers()
|
||||
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
|
||||
self._td.cleanup()
|
||||
|
||||
def _read_log(self) -> str:
|
||||
return self.log_file.read_text(encoding="utf-8")
|
||||
|
||||
def _make_admin_app(self) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(self.admin_router.router)
|
||||
return app
|
||||
|
||||
def _make_logged_app(self) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_server_errors(request, call_next):
|
||||
return await self.request_logging.log_server_errors_middleware(
|
||||
self.logging_config.get_logger("tests.server_error_logging"),
|
||||
request,
|
||||
call_next,
|
||||
)
|
||||
|
||||
@app.get("/boom-http")
|
||||
async def _boom_http():
|
||||
raise HTTPException(status_code=500, detail="planned http failure")
|
||||
|
||||
@app.get("/boom-exception")
|
||||
async def _boom_exception():
|
||||
raise RuntimeError("planned unhandled failure")
|
||||
|
||||
return app
|
||||
|
||||
def test_get_log_file_returns_current_backend_log_path(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52000))
|
||||
|
||||
resp = client.get("/api/admin/log-file")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(Path(payload["path"]), self.log_file)
|
||||
self.assertTrue(payload["exists"])
|
||||
self.assertTrue(self.log_file.is_relative_to(Path(self._td.name) / "output" / "logs"))
|
||||
|
||||
def test_open_log_file_requires_loopback(self):
|
||||
client = TestClient(self._make_admin_app(), client=("203.0.113.8", 52001))
|
||||
|
||||
resp = client.post("/api/admin/log-file/open")
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_open_log_file_uses_default_opener_for_loopback(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52002))
|
||||
|
||||
with patch.object(self.admin_router, "_open_path_with_default_app") as mocked_open:
|
||||
resp = client.post("/api/admin/log-file/open")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
mocked_open.assert_called_once_with(self.log_file)
|
||||
self.assertEqual(resp.json()["path"], str(self.log_file))
|
||||
|
||||
def test_frontend_server_error_endpoint_writes_log(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52003))
|
||||
|
||||
resp = client.post(
|
||||
"/api/admin/log-frontend-server-error",
|
||||
json={
|
||||
"status": 503,
|
||||
"method": "GET",
|
||||
"request_url": "http://127.0.0.1:10392/api/chat/accounts",
|
||||
"message": "fetch failed",
|
||||
"backend_detail": "upstream timeout",
|
||||
"source": "useApi",
|
||||
"page_url": "http://127.0.0.1:10392/chat",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
text = self._read_log()
|
||||
self.assertIn("[frontend-server-error]", text)
|
||||
self.assertIn("status=503", text)
|
||||
self.assertIn("source=useApi", text)
|
||||
self.assertIn("upstream timeout", text)
|
||||
|
||||
def test_http_500_response_is_logged(self):
|
||||
client = TestClient(self._make_logged_app(), client=("127.0.0.1", 52004))
|
||||
|
||||
resp = client.get("/boom-http")
|
||||
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
text = self._read_log()
|
||||
self.assertIn("[server-5xx]", text)
|
||||
self.assertIn("status=500", text)
|
||||
self.assertIn("path=/boom-http", text)
|
||||
self.assertIn("planned http failure", text)
|
||||
|
||||
def test_unhandled_exception_is_logged_with_traceback(self):
|
||||
client = TestClient(
|
||||
self._make_logged_app(),
|
||||
client=("127.0.0.1", 52005),
|
||||
raise_server_exceptions=False,
|
||||
)
|
||||
|
||||
resp = client.get("/boom-exception")
|
||||
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
text = self._read_log()
|
||||
self.assertIn("[server-exception]", text)
|
||||
self.assertIn("path=/boom-exception", text)
|
||||
self.assertIn("planned unhandled failure", text)
|
||||
self.assertIn("Traceback", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user