feat: 请求日志 + 前端埋点系统
后端: - 新增 logger 中间件,记录所有请求到 logs/ 目录 - 新增 /api/track 接口接收前端埋点数据 前端: - 新增 tracker 工具,支持页面访问/操作/错误埋点 - request.ts 添加 API 错误自动埋点 - App.vue 初始化全局错误追踪 文档: - CLAUDE.md 更新项目结构 - DEV.md 添加埋点和日志说明
This commit is contained in:
@@ -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)
|
||||
|
||||
12
DEV.md
12
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
|
||||
|
||||
@@ -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<any>, retries = 3): Promise<boolean> {
|
||||
@@ -15,6 +16,7 @@ async function loginWithRetry(loginFn: () => Promise<any>, 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<any>, retries = 3): Promise
|
||||
}
|
||||
|
||||
onLaunch(() => {
|
||||
// 初始化错误追踪
|
||||
setupErrorTracking()
|
||||
trackAction('app_launch')
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const configStore = useConfigStore()
|
||||
groupStore.init()
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
})
|
||||
|
||||
const slogan = computed(() => {
|
||||
return userInfo.value?.slogan || '记账小能手'
|
||||
return userInfo.value?.slogan
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
|
||||
@@ -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<T = any>(options: RequestOptions): Promise<T> {
|
||||
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<T = any>(options: RequestOptions): Promise<T> {
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
// 埋点:网络错误
|
||||
trackApiError(options.url, 0, '网络错误')
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
reject(err)
|
||||
}
|
||||
|
||||
140
client/src/utils/tracker.ts
Normal file
140
client/src/utils/tracker.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 前端埋点工具
|
||||
* 记录用户关键操作和错误信息
|
||||
*/
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
interface TrackEvent {
|
||||
event: string
|
||||
page?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
/** 上报队列(批量上报,减少请求) */
|
||||
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<string, any>) {
|
||||
addToQueue({
|
||||
event: 'action',
|
||||
page: getCurrentPage(),
|
||||
data: { action, ...data }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪错误
|
||||
*/
|
||||
export function trackError(error: Error | string, extra?: Record<string, any>) {
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
84
server/src/middleware/logger.ts
Normal file
84
server/src/middleware/logger.ts
Normal file
@@ -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<string, any>) {
|
||||
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<string, any>) {
|
||||
writeLog('ERROR', message, {
|
||||
...meta,
|
||||
error: error?.message || String(error),
|
||||
stack: error?.stack?.slice(0, 500) || ''
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务日志 */
|
||||
export function logInfo(message: string, meta?: Record<string, any>) {
|
||||
writeLog('INFO', message, meta)
|
||||
}
|
||||
|
||||
export default { requestLogger, logError, logInfo }
|
||||
30
server/src/routes/track.ts
Normal file
30
server/src/routes/track.ts
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user