From 0de4c9832c975a592ad1c928e470c76fd615cc97 Mon Sep 17 00:00:00 2001 From: wangxiaogang <1433729587@qq.com> Date: Mon, 8 Jun 2026 14:28:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=20?= =?UTF-8?q?+=20=E5=89=8D=E7=AB=AF=E5=9F=8B=E7=82=B9=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 logger 中间件,记录所有请求到 logs/ 目录 - 新增 /api/track 接口接收前端埋点数据 前端: - 新增 tracker 工具,支持页面访问/操作/错误埋点 - request.ts 添加 API 错误自动埋点 - App.vue 初始化全局错误追踪 文档: - CLAUDE.md 更新项目结构 - DEV.md 添加埋点和日志说明 --- CLAUDE.md | 8 +- DEV.md | 12 +++ client/src/App.vue | 6 ++ client/src/stores/user.ts | 2 +- client/src/utils/request.ts | 5 ++ client/src/utils/tracker.ts | 140 ++++++++++++++++++++++++++++++++ server/src/index.ts | 6 ++ server/src/middleware/logger.ts | 84 +++++++++++++++++++ server/src/routes/track.ts | 30 +++++++ 9 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 client/src/utils/tracker.ts create mode 100644 server/src/middleware/logger.ts create mode 100644 server/src/routes/track.ts diff --git a/CLAUDE.md b/CLAUDE.md index a0fe622..a3566e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,13 +110,16 @@ client/src/ │ ├── format.ts # 金额/日期格式化 │ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试) │ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight) -│ └── app-ready.ts # App 启动就绪机制 (waitForReady) +│ ├── app-ready.ts # App 启动就绪机制 (waitForReady) +│ └── tracker.ts # 前端埋点 (页面访问/操作/错误) ├── config.ts # 全局配置 (API_BASE) └── static/icons/ # tabBar 图标 (PNG) server/src/ ├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册) -├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual) +├── middleware/ +│ ├── auth.ts # HMAC token 验证 (timingSafeEqual) +│ └── logger.ts # 请求日志 (记录到 logs/ 目录) ├── routes/ │ ├── auth.ts # 登录 / token 签发 (返回用户信息) │ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换) @@ -130,6 +133,7 @@ server/src/ │ ├── feedback.ts # 用户反馈 (提交/查询/处理) │ ├── config.ts # 系统配置 (公开读/管理员写) │ ├── admin.ts # 管理后台 (仪表盘/用户管理) +│ ├── track.ts # 埋点数据接收 │ └── backup.ts # 备份 API ├── utils/ │ ├── backup.ts # Node.js 数据库备份 (gzip) diff --git a/DEV.md b/DEV.md index 8c6ebd3..4650af6 100644 --- a/DEV.md +++ b/DEV.md @@ -75,6 +75,18 @@ res.status(500).json({ code: 50000, message: '服务器错误' }) **原因**:前端 `request` 函数检查 `data.code === 0` 判断成功,不返回标准格式会导致前端误判为失败。 +### 埋点和日志 + +**后端请求日志**:所有请求自动记录到 `logs/YYYY-MM-DD.log`,包含请求方法、URL、状态码、耗时、IP、用户ID。 + +**前端埋点**:使用 `@/utils/tracker` 记录关键操作: +```typescript +import { trackAction, trackError, trackApiError } from '@/utils/tracker' + +trackAction('save_transaction', { amount: 100 }) // 用户操作 +trackError(new Error('xxx'), { context: 'xxx' }) // 错误上报 +``` + ### 页面数据加载模式 ```typescript diff --git a/client/src/App.vue b/client/src/App.vue index 529eba9..a2140e1 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -5,6 +5,7 @@ import { useGroupStore } from '@/stores/group' import { useConfigStore } from '@/stores/config' import { saveLoginResult } from '@/utils/request' import { markReady } from '@/utils/app-ready' +import { setupErrorTracking, trackAction, trackError } from '@/utils/tracker' /** 登录重试(最多3次) */ async function loginWithRetry(loginFn: () => Promise, retries = 3): Promise { @@ -15,6 +16,7 @@ async function loginWithRetry(loginFn: () => Promise, retries = 3): Promise return true } catch (e) { console.error(`[Auth] Login attempt ${i + 1} failed:`, e) + trackError(e as Error, { context: 'login', attempt: i + 1 }) if (i < retries - 1) { // 等待1秒后重试 await new Promise(resolve => setTimeout(resolve, 1000)) @@ -25,6 +27,10 @@ async function loginWithRetry(loginFn: () => Promise, retries = 3): Promise } onLaunch(() => { + // 初始化错误追踪 + setupErrorTracking() + trackAction('app_launch') + const groupStore = useGroupStore() const configStore = useConfigStore() groupStore.init() diff --git a/client/src/stores/user.ts b/client/src/stores/user.ts index 0498c3a..2cc4224 100644 --- a/client/src/stores/user.ts +++ b/client/src/stores/user.ts @@ -11,7 +11,7 @@ export const useUserStore = defineStore('user', () => { }) const slogan = computed(() => { - return userInfo.value?.slogan || '记账小能手' + return userInfo.value?.slogan }) const avatarUrl = computed(() => { diff --git a/client/src/utils/request.ts b/client/src/utils/request.ts index ec077b9..a9fa67f 100644 --- a/client/src/utils/request.ts +++ b/client/src/utils/request.ts @@ -1,5 +1,6 @@ import { API_BASE } from '@/config' import type { LoginResult } from '@/api/auth' +import { trackApiError } from './tracker' interface RequestOptions { url: string @@ -88,6 +89,8 @@ export function request(options: RequestOptions): Promise { if (data.code === 0) { resolve(data.data) } else { + // 埋点:API 业务错误 + trackApiError(options.url, statusCode, data.message || '请求失败') // "用户不存在" 不弹 toast(首次登录竞态) if (data.code !== 40400 || data.message !== '用户不存在') { uni.showToast({ title: data.message || '请求失败', icon: 'none' }) @@ -96,6 +99,8 @@ export function request(options: RequestOptions): Promise { } }, fail: (err: any) => { + // 埋点:网络错误 + trackApiError(options.url, 0, '网络错误') uni.showToast({ title: '网络错误', icon: 'none' }) reject(err) } diff --git a/client/src/utils/tracker.ts b/client/src/utils/tracker.ts new file mode 100644 index 0000000..ba34f50 --- /dev/null +++ b/client/src/utils/tracker.ts @@ -0,0 +1,140 @@ +/** + * 前端埋点工具 + * 记录用户关键操作和错误信息 + */ + +import { request } from './request' + +interface TrackEvent { + event: string + page?: string + data?: Record +} + +/** 上报队列(批量上报,减少请求) */ +let eventQueue: TrackEvent[] = [] +let flushTimer: number | null = null +const FLUSH_INTERVAL = 5000 // 5秒上报一次 +const MAX_QUEUE_SIZE = 20 + +/** 获取当前页面路径 */ +function getCurrentPage(): string { + const pages = getCurrentPages() + if (pages.length > 0) { + return `/${pages[pages.length - 1].route}` + } + return '/unknown' +} + +/** 批量上报事件 */ +async function flushEvents() { + if (eventQueue.length === 0) return + + const events = [...eventQueue] + eventQueue = [] + + try { + await request({ + url: '/track', + method: 'POST', + data: { events } + }) + } catch { + // 上报失败不处理,避免影响用户体验 + } +} + +/** 添加事件到队列 */ +function addToQueue(event: TrackEvent) { + eventQueue.push(event) + + // 队列满时立即上报 + if (eventQueue.length >= MAX_QUEUE_SIZE) { + flushEvents() + return + } + + // 设置定时上报 + if (!flushTimer) { + flushTimer = setTimeout(() => { + flushTimer = null + flushEvents() + }, FLUSH_INTERVAL) as unknown as number + } +} + +/** + * 追踪页面访问 + */ +export function trackPageView(pagePath?: string) { + addToQueue({ + event: 'page_view', + page: pagePath || getCurrentPage() + }) +} + +/** + * 追踪用户操作 + * @param action 操作名称,如 'click_save', 'submit_form' + * @param data 附加数据 + */ +export function trackAction(action: string, data?: Record) { + addToQueue({ + event: 'action', + page: getCurrentPage(), + data: { action, ...data } + }) +} + +/** + * 追踪错误 + */ +export function trackError(error: Error | string, extra?: Record) { + const errorInfo = typeof error === 'string' + ? { message: error } + : { message: error.message, stack: error.stack?.slice(0, 500) } + + addToQueue({ + event: 'error', + page: getCurrentPage(), + data: { ...errorInfo, ...extra } + }) +} + +/** + * 追踪 API 请求失败 + */ +export function trackApiError(url: string, status: number, message: string) { + addToQueue({ + event: 'api_error', + page: getCurrentPage(), + data: { url, status, message } + }) +} + +/** + * 设置全局错误监听 + */ +export function setupErrorTracking() { + // Vue 错误处理 + uni.onError((err: string) => { + trackError(err, { type: 'global' }) + }) + + // 未处理的 Promise rejection + // #ifdef H5 + if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', (event) => { + trackError(String(event.reason), { type: 'unhandledrejection' }) + }) + } + // #endif +} + +export default { + trackPageView, + trackAction, + trackError, + trackApiError, + setupErrorTracking +} diff --git a/server/src/index.ts b/server/src/index.ts index 410d34f..69fb9fa 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit' import path from 'path' import fs from 'fs' import { authMiddleware } from './middleware/auth' +import { requestLogger } from './middleware/logger' import { initDatabase } from './db/init' import pool from './db/connection' import authRoutes from './routes/auth' @@ -20,6 +21,7 @@ import filterRoutes from './routes/filter' import adminRoutes from './routes/admin' import feedbackRoutes from './routes/feedback' import configRoutes from './routes/config' +import trackRoutes from './routes/track' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback @@ -48,6 +50,9 @@ app.use(cors({ })) app.use(express.json({ limit: '10kb' })) +// 请求日志 +app.use(requestLogger) + // 限流:登录接口 — 每分钟最多 10 次 const authLimiter = rateLimit({ windowMs: 60 * 1000, @@ -153,6 +158,7 @@ app.use('/api/filters', apiLimiter, filterRoutes) app.use('/api/admin', apiLimiter, adminRoutes) app.use('/api/feedback', apiLimiter, feedbackRoutes) app.use('/api/config', apiLimiter, configRoutes) +app.use('/api/track', trackRoutes) app.get('/api/health', async (_req, res) => { try { diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts new file mode 100644 index 0000000..4849e0e --- /dev/null +++ b/server/src/middleware/logger.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from 'express' +import fs from 'fs' +import path from 'path' + +// 日志目录 +const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs') +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }) +} + +/** 获取当前日期字符串 YYYY-MM-DD */ +function getDateStr(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** 格式化时间 HH:mm:ss.SSS */ +function formatTime(date: Date): string { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}` +} + +/** 写入日志文件 */ +function writeLog(level: string, message: string, meta?: Record) { + const dateStr = getDateStr() + const logFile = path.join(LOG_DIR, `${dateStr}.log`) + const time = formatTime(new Date()) + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const line = `[${time}] ${level} ${message}${metaStr}\n` + + fs.appendFile(logFile, line, (err) => { + if (err) console.error('[Logger] Write error:', err) + }) +} + +/** 请求日志中间件 */ +export function requestLogger(req: Request, res: Response, next: NextFunction) { + const startTime = Date.now() + const { method, url, ip } = req + + // 提取用户 ID(如果有 auth 中间件设置) + const userId = (req as any).userId + + // 响应结束时记录日志 + res.on('finish', () => { + const duration = Date.now() - startTime + const { statusCode } = res + + const meta = { + method, + url, + status: statusCode, + duration: `${duration}ms`, + ip: ip || req.headers['x-forwarded-for'] || 'unknown', + userId: userId || null, + userAgent: req.headers['user-agent']?.slice(0, 100) || '' + } + + if (statusCode >= 400) { + writeLog('ERROR', `${method} ${url} ${statusCode} ${duration}ms`, meta) + } else if (duration > 1000) { + writeLog('WARN', `Slow request: ${method} ${url} ${duration}ms`, meta) + } else { + writeLog('INFO', `${method} ${url} ${statusCode} ${duration}ms`, meta) + } + }) + + next() +} + +/** 错误日志 */ +export function logError(message: string, error: Error | any, meta?: Record) { + writeLog('ERROR', message, { + ...meta, + error: error?.message || String(error), + stack: error?.stack?.slice(0, 500) || '' + }) +} + +/** 业务日志 */ +export function logInfo(message: string, meta?: Record) { + writeLog('INFO', message, meta) +} + +export default { requestLogger, logError, logInfo } diff --git a/server/src/routes/track.ts b/server/src/routes/track.ts new file mode 100644 index 0000000..a94fde2 --- /dev/null +++ b/server/src/routes/track.ts @@ -0,0 +1,30 @@ +import { Router } from 'express' +import { logInfo } from '../middleware/logger' + +const router = Router() + +/** 接收前端埋点数据 */ +router.post('/', (req, res) => { + try { + const { events } = req.body + + if (!Array.isArray(events)) { + return res.status(400).json({ code: 40001, message: '无效的埋点数据' }) + } + + // 记录埋点事件 + for (const event of events) { + logInfo(`[Track] ${event.event}`, { + page: event.page, + data: event.data, + userId: (req as any).userId || null + }) + } + + res.json({ code: 0 }) + } catch { + res.json({ code: 0 }) // 埋点失败不影响业务 + } +}) + +export default router