提交 7f46d20d authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增log模块

上级 4b30ac7c
...@@ -2,12 +2,10 @@ ...@@ -2,12 +2,10 @@
node_modules/ node_modules/
# Environment variables # Environment variables
.env
.env.local .env.local
.env.*.local .env.*.local
# Logs # Logs
logs/
*.log *.log
npm-debug.log* npm-debug.log*
pm2_*.log pm2_*.log
......
# ezijing-node-server # ezijing-node-server
Modern Node.js API server for WeChat services. Modern Node.js API server for WeChat services and log collection.
## Quick Start ## Quick Start
...@@ -8,6 +8,9 @@ Modern Node.js API server for WeChat services. ...@@ -8,6 +8,9 @@ Modern Node.js API server for WeChat services.
# Install dependencies # Install dependencies
npm install npm install
# Start MongoDB (required)
mongod --dbpath /path/to/data
# Development (with hot reload) # Development (with hot reload)
npm run dev npm run dev
...@@ -23,23 +26,71 @@ src/ ...@@ -23,23 +26,71 @@ src/
├── app.js # Express app ├── app.js # Express app
├── config.js # Configuration ├── config.js # Configuration
├── lib/ # Shared utilities ├── lib/ # Shared utilities
│ ├── logger.js # Pino logger
│ ├── db.js # MongoDB connection
│ └── file.js # File utilities
├── middleware/ # Global middleware ├── middleware/ # Global middleware
└── modules/ # Feature modules └── modules/ # Feature modules
├── wechat/ # WeChat SDK ├── wechat/ # WeChat SDK
└── wx-chart/ # WeChat chart data ├── wx-chart/ # WeChat chart data
└── logs/ # Log collection
``` ```
## API Endpoints ## API Endpoints
### System
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/health` | Health check | | GET | `/health` | Health check |
### WeChat
| Method | Path | Description |
|--------|------|-------------|
| POST | `/share/getsignature` | Get WeChat JS-SDK signature | | POST | `/share/getsignature` | Get WeChat JS-SDK signature |
| POST | `/share/token` | Get share token | | POST | `/share/token` | Get share token |
| POST | `/getInfo` | Get WeChat user info | | POST | `/getInfo` | Get WeChat user info |
### Chart Data
| Method | Path | Description |
|--------|------|-------------|
| GET | `/get/wx-chart/:key` | Get chart value | | GET | `/get/wx-chart/:key` | Get chart value |
| GET | `/set/wx-chart/:key` | Set chart value | | GET | `/set/wx-chart/:key` | Set chart value |
### Logs
| Method | Path | Description |
|--------|------|-------------|
| POST | `/logs` | Create single log |
| POST | `/logs/batch` | Create batch logs |
| GET | `/logs` | Query logs |
| GET | `/logs/stats` | Get log statistics |
#### Log Request Body
```json
{
"level": "info",
"message": "User login",
"source": "web-app",
"userId": "user123",
"metadata": { "action": "login" }
}
```
#### Query Parameters
- `level` - Log level (debug, info, warn, error, fatal)
- `source` - Log source
- `userId` - User ID
- `startTime` - Start time (ISO 8601)
- `endTime` - End time (ISO 8601)
- `keyword` - Search keyword
- `page` - Page number (default: 1)
- `limit` - Page size (default: 50)
## Environment Variables ## Environment Variables
```env ```env
...@@ -47,6 +98,9 @@ NODE_ENV=development ...@@ -47,6 +98,9 @@ NODE_ENV=development
SERVER_PORT=4101 SERVER_PORT=4101
DATA_DIR=../node-server-data DATA_DIR=../node-server-data
# MongoDB
MONGODB_URI=mongodb://localhost:27017/ezijing-logs
# WeChat config (numbered format) # WeChat config (numbered format)
WX_APPID_1=your_appid WX_APPID_1=your_appid
WX_SECRET_1=your_secret WX_SECRET_1=your_secret
...@@ -58,5 +112,4 @@ WX_SECRET_1=your_secret ...@@ -58,5 +112,4 @@ WX_SECRET_1=your_secret
- `npm start` - Production - `npm start` - Production
- `npm run lint` - ESLint check - `npm run lint` - ESLint check
- `npm run lint:fix` - ESLint fix - `npm run lint:fix` - ESLint fix
- `npm run deploy:test` - Deploy to test - `npm run deploy` - Deploy to production
- `npm run deploy:prod` - Deploy to production
...@@ -16,7 +16,6 @@ export default [ ...@@ -16,7 +16,6 @@ export default [
rules: { rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off', 'no-console': 'off',
'comma-dangle': ['error', 'always-multiline'],
semi: ['error', 'never'], semi: ['error', 'never'],
quotes: ['error', 'single'], quotes: ['error', 'single'],
}, },
......
差异被折叠。
...@@ -28,7 +28,9 @@ ...@@ -28,7 +28,9 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"mongoose": "^8.8.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-http": "^10.3.0" "pino-http": "^10.3.0",
"ua-parser-js": "^2.0.6"
} }
} }
...@@ -5,6 +5,7 @@ import logger from './lib/logger.js' ...@@ -5,6 +5,7 @@ import logger from './lib/logger.js'
import { notFound, errorHandler } from './middleware/error.js' import { notFound, errorHandler } from './middleware/error.js'
import wechatRoutes from './modules/wechat/wechat.routes.js' import wechatRoutes from './modules/wechat/wechat.routes.js'
import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js' import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js'
import logsRoutes from './modules/logs/logs.routes.js'
const app = express() const app = express()
...@@ -25,6 +26,7 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now() ...@@ -25,6 +26,7 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now()
// Modules // Modules
app.use(wechatRoutes) app.use(wechatRoutes)
app.use(wxChartRoutes) app.use(wxChartRoutes)
app.use(logsRoutes)
// Error handling // Error handling
app.use(notFound) app.use(notFound)
......
...@@ -5,6 +5,9 @@ const config = { ...@@ -5,6 +5,9 @@ const config = {
env: process.env.NODE_ENV || 'development', env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101, port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101,
dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'), dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'),
mongodb: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/ezijing-logs',
},
wechat: { apps: {} }, wechat: { apps: {} },
} }
......
import app from './app.js' import app from './app.js'
import config from './config.js' import config from './config.js'
import logger from './lib/logger.js' import logger from './lib/logger.js'
import { connectDB, disconnectDB } from './lib/db.js'
const server = app.listen(config.port, () => { const start = async () => {
try {
await connectDB()
const server = app.listen(config.port, () => {
logger.info({ port: config.port, env: config.env }, 'Server started 🚀') logger.info({ port: config.port, env: config.env }, 'Server started 🚀')
}) })
// Graceful shutdown // Graceful shutdown
const shutdown = (signal) => { const shutdown = async (signal) => {
logger.info(`${signal} received, shutting down...`) logger.info(`${signal} received, shutting down...`)
server.close(() => { server.close(async () => {
await disconnectDB()
logger.info('Server closed') logger.info('Server closed')
process.exit(0) process.exit(0)
}) })
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
} catch (error) {
logger.error({ err: error }, 'Failed to start server')
process.exit(1)
}
} }
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('unhandledRejection', (err) => logger.error({ err }, 'Unhandled Rejection')) process.on('unhandledRejection', (err) => logger.error({ err }, 'Unhandled Rejection'))
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
logger.error({ err }, 'Uncaught Exception') logger.error({ err }, 'Uncaught Exception')
process.exit(1) process.exit(1)
}) })
start()
import mongoose from 'mongoose'
import config from '../config.js'
import logger from './logger.js'
let isConnected = false
export const connectDB = async () => {
if (isConnected) return
try {
await mongoose.connect(config.mongodb.uri)
isConnected = true
logger.info({ uri: config.mongodb.uri.replace(/\/\/.*@/, '//*****@') }, 'MongoDB connected')
} catch (error) {
logger.error({ err: error }, 'MongoDB connection failed')
throw error
}
}
mongoose.connection.on('disconnected', () => {
isConnected = false
logger.warn('MongoDB disconnected')
})
mongoose.connection.on('error', (err) => {
logger.error({ err }, 'MongoDB error')
})
export const disconnectDB = async () => {
if (!isConnected) return
await mongoose.disconnect()
isConnected = false
logger.info('MongoDB disconnected')
}
export default mongoose
/**
* 统一响应格式工具
*/
export const success = (res, data, status = 200) => {
res.status(status).json({ success: true, data })
}
export const error = (res, message, status = 400, extra = {}) => {
res.status(status).json({ success: false, error: { message, ...extra } })
}
import logger from '../lib/logger.js' import logger from '../lib/logger.js'
import { error } from '../lib/response.js'
export const notFound = (req, res) => { export const notFound = (req, res) => {
res.status(404).json({ error(res, '接口未定义', 404, { path: req.path, method: req.method })
success: false,
error: { message: '接口未定义', path: req.path, method: req.method },
})
} }
export const errorHandler = (err, req, res, _next) => { export const errorHandler = (err, req, res, _next) => {
const statusCode = err.statusCode || err.status || 500 const statusCode = err.statusCode || err.status || 500
const message = process.env.NODE_ENV === 'production' && statusCode === 500 ? 'Internal Server Error' : err.message
logger.error({ err, url: req.url, method: req.method }, 'Request error') logger.error({ err, url: req.url, method: req.method }, 'Request error')
res.status(statusCode).json({ error(res, message, statusCode)
success: false,
error: {
message: process.env.NODE_ENV === 'production' && statusCode === 500 ? 'Internal Server Error' : err.message,
},
})
} }
import { UAParser } from 'ua-parser-js'
import * as logsService from './logs.service.js'
import logger from '../../lib/logger.js'
import { success, error } from '../../lib/response.js'
// 解析 userAgent 获取设备信息
const parseDevice = (userAgent) => {
if (!userAgent) return null
const parser = new UAParser(userAgent)
const device = parser.getDevice()
const os = parser.getOS()
const browser = parser.getBrowser()
return {
type: device.type || 'desktop', // mobile | tablet | desktop
os: os.name && os.version ? `${os.name} ${os.version}` : os.name || null,
browser: browser.name && browser.version ? `${browser.name} ${browser.version}` : browser.name || null,
}
}
export const create = async (req, res, next) => {
try {
const userAgent = req.get('user-agent')
const logData = {
...req.body,
ip: req.ip,
userAgent,
device: parseDevice(userAgent),
}
const log = await logsService.create(logData)
success(res, log)
} catch (err) {
logger.error({ err }, 'Failed to create log')
next(err)
}
}
export const createBatch = async (req, res, next) => {
try {
const { logs } = req.body
if (!Array.isArray(logs) || logs.length === 0) {
return error(res, 'logs array is required')
}
const userAgent = req.get('user-agent')
const device = parseDevice(userAgent)
const enrichedLogs = logs.map((log) => ({
...log,
ip: req.ip,
userAgent,
device,
}))
const result = await logsService.createBatch(enrichedLogs)
success(res, { inserted: result.length })
} catch (err) {
logger.error({ err }, 'Failed to create batch logs')
next(err)
}
}
export const query = async (req, res, next) => {
try {
const { type, level, appName, userId, startTime, endTime, keyword, page, limit } = req.query
const result = await logsService.query(
{ type, level, appName, userId, startTime, endTime, keyword },
{ page: parseInt(page) || 1, limit: parseInt(limit) || 50 }
)
success(res, result)
} catch (err) {
logger.error({ err }, 'Failed to query logs')
next(err)
}
}
export const getStats = async (req, res, next) => {
try {
const { appName } = req.query
const stats = await logsService.getStats({ appName })
success(res, stats)
} catch (err) {
logger.error({ err }, 'Failed to get log stats')
next(err)
}
}
import mongoose from 'mongoose'
// 最大存储大小(字符数)
const MAX_BODY_SIZE = 2000
// 截断大数据,保留摘要
const truncateBody = (body) => {
if (body === null || body === undefined) return { data: null, size: 0, truncated: false }
const str = typeof body === 'string' ? body : JSON.stringify(body)
const size = str.length
if (size <= MAX_BODY_SIZE) {
return { data: body, size, truncated: false }
}
// 超过限制,截断并标记
const truncatedStr = str.slice(0, MAX_BODY_SIZE)
try {
// 尝试保持 JSON 格式(可能不完整)
return { data: truncatedStr + '...[truncated]', size, truncated: true }
} catch {
return { data: truncatedStr + '...[truncated]', size, truncated: true }
}
}
const logSchema = new mongoose.Schema(
{
type: {
type: String,
enum: ['api', 'error', 'event', 'behavior', 'custom'],
default: 'custom',
index: true,
},
level: {
type: String,
enum: ['debug', 'info', 'warn', 'error', 'fatal'],
default: 'info',
index: true,
},
message: {
type: String,
required: true,
},
appName: {
type: String,
index: true,
},
userId: {
type: String,
index: true,
},
// 环境/版本
env: {
type: String,
enum: ['development', 'staging', 'production'],
},
appVersion: String, // 应用版本号
// 设备信息(自动从 userAgent 解析)
device: {
type: { type: String }, // mobile | tablet | desktop
os: String, // iOS 17.1 | Android 14 | Windows 11
browser: String, // Chrome 120 | Safari 17
},
// API 请求专用字段
api: {
method: String,
url: String,
statusCode: Number,
duration: Number,
request: {
data: mongoose.Schema.Types.Mixed,
size: Number,
truncated: Boolean,
},
response: {
data: mongoose.Schema.Types.Mixed,
size: Number,
truncated: Boolean,
},
},
// 错误专用字段
error: {
name: String,
message: String,
stack: String,
componentStack: String,
},
metadata: {
type: mongoose.Schema.Types.Mixed,
},
userAgent: String,
ip: String,
url: String,
},
{
timestamps: true,
versionKey: false,
}
)
// 存储前自动截断大数据
logSchema.pre('save', function (next) {
if (this.api?.request?.data !== undefined) {
this.api.request = truncateBody(this.api.request.data)
}
if (this.api?.response?.data !== undefined) {
this.api.response = truncateBody(this.api.response.data)
}
next()
})
// 批量插入时也处理
logSchema.pre('insertMany', function (next, docs) {
docs.forEach((doc) => {
if (doc.api?.request?.data !== undefined) {
doc.api.request = truncateBody(doc.api.request.data)
}
if (doc.api?.response?.data !== undefined) {
doc.api.response = truncateBody(doc.api.response.data)
}
})
next()
})
// TTL index: auto delete logs after 360 days
logSchema.index({ createdAt: 1 }, { expireAfterSeconds: 360 * 24 * 60 * 60 })
// Compound index for common queries
logSchema.index({ type: 1, appName: 1, createdAt: -1 })
logSchema.index({ type: 1, level: 1, createdAt: -1 })
export default mongoose.model('Log', logSchema)
import { Router } from 'express'
import * as controller from './logs.controller.js'
const router = Router()
router.post('/logs', controller.create)
router.post('/logs/batch', controller.createBatch)
router.get('/logs', controller.query)
router.get('/logs/stats', controller.getStats)
export default router
import Log from './logs.model.js'
export const create = async (logData) => {
const log = new Log(logData)
return log.save()
}
export const createBatch = async (logs) => {
return Log.insertMany(logs, { ordered: false })
}
export const query = async (filters = {}, options = {}) => {
const { page = 1, limit = 50, sort = { createdAt: -1 } } = options
const skip = (page - 1) * limit
const query = {}
if (filters.type) query.type = filters.type
if (filters.level) query.level = filters.level
if (filters.appName) query.appName = filters.appName
if (filters.userId) query.userId = filters.userId
if (filters.startTime || filters.endTime) {
query.createdAt = {}
if (filters.startTime) query.createdAt.$gte = new Date(filters.startTime)
if (filters.endTime) query.createdAt.$lte = new Date(filters.endTime)
}
if (filters.keyword) {
query.message = { $regex: filters.keyword, $options: 'i' }
}
const [list, total] = await Promise.all([
Log.find(query).sort(sort).skip(skip).limit(limit).lean(),
Log.countDocuments(query),
])
return {
list,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
// 获取今日开始时间
const getTodayStart = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
}
// 获取昨日时间范围
const getYesterdayRange = () => {
const today = getTodayStart()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
return { start: yesterday, end: today }
}
export const getStats = async (filters = {}) => {
const todayStart = getTodayStart()
const { start: yesterdayStart, end: yesterdayEnd } = getYesterdayRange()
const baseMatch = {}
if (filters.appName) baseMatch.appName = filters.appName
// 并行查询所有统计数据
const [todayTotal, yesterdayTotal, todayErrors, yesterdayErrors, todayApps, yesterdayApps, hourlyTrend, recentLogs] =
await Promise.all([
// 今日日志总数
Log.countDocuments({ ...baseMatch, createdAt: { $gte: todayStart } }),
// 昨日日志总数
Log.countDocuments({ ...baseMatch, createdAt: { $gte: yesterdayStart, $lt: yesterdayEnd } }),
// 今日错误日志
Log.countDocuments({ ...baseMatch, createdAt: { $gte: todayStart }, level: { $in: ['error', 'fatal'] } }),
// 昨日错误日志
Log.countDocuments({
...baseMatch,
createdAt: { $gte: yesterdayStart, $lt: yesterdayEnd },
level: { $in: ['error', 'fatal'] },
}),
// 今日活跃应用数
Log.distinct('appName', { ...baseMatch, createdAt: { $gte: todayStart } }),
// 昨日活跃应用数
Log.distinct('appName', { ...baseMatch, createdAt: { $gte: yesterdayStart, $lt: yesterdayEnd } }),
// 按小时分组的趋势(今日)
Log.aggregate([
{ $match: { ...baseMatch, createdAt: { $gte: todayStart } } },
{
$group: {
_id: { $hour: '$createdAt' },
count: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
]),
// 最近日志
Log.find(baseMatch).sort({ createdAt: -1 }).limit(10).lean(),
])
// 计算变化率
const calcChange = (today, yesterday) => {
if (yesterday === 0) return today > 0 ? 100 : 0
return Number((((today - yesterday) / yesterday) * 100).toFixed(1))
}
// 填充24小时趋势数据
const trend = Array.from({ length: 24 }, (_, hour) => {
const found = hourlyTrend.find((h) => h._id === hour)
return { hour, count: found?.count || 0 }
})
return {
// 今日日志
todayTotal: {
count: todayTotal,
change: calcChange(todayTotal, yesterdayTotal),
},
// 错误日志
errorTotal: {
count: todayErrors,
change: calcChange(todayErrors, yesterdayErrors),
},
// 活跃应用
activeApps: {
count: todayApps.length,
change: todayApps.length - yesterdayApps.length,
},
// 按小时趋势
trend,
// 最近日志
recentLogs,
}
}
...@@ -7,23 +7,16 @@ export const getSignature = async (req, res, next) => { ...@@ -7,23 +7,16 @@ export const getSignature = async (req, res, next) => {
if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } }) if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } })
const ticket = await wechatService.getCachedTicket(appId) const ticket = await wechatService.getCachedTicket(appId)
const encodedUrl = encodeURIComponent(url || req.headers.referer || '') // 微信签名要求使用原始URL,去除hash部分
const signature = wechatService.generateSignature( const rawUrl = (url || req.headers.referer || '').split('#')[0]
ticket.jsapi_ticket, const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
ticket.noncestr,
ticket.timestamp,
encodedUrl
)
res.json({ res.json({
success: true, url: rawUrl,
data: {
url: encodedUrl,
ticket: ticket.jsapi_ticket, ticket: ticket.jsapi_ticket,
token: signature, token: signature,
noncestr: ticket.noncestr, noncestr: ticket.noncestr,
timestamp: ticket.timestamp, timestamp: ticket.timestamp,
},
}) })
} catch (error) { } catch (error) {
logger.error({ err: error }, 'getSignature error') logger.error({ err: error }, 'getSignature error')
...@@ -37,14 +30,11 @@ export const share = async (req, res, next) => { ...@@ -37,14 +30,11 @@ export const share = async (req, res, next) => {
if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } }) if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } })
const ticket = await wechatService.getCachedTicket(appId) const ticket = await wechatService.getCachedTicket(appId)
const signature = wechatService.generateSignature( // 微信签名要求使用原始URL,去除hash部分
ticket.jsapi_ticket, const rawUrl = (req.headers.referer || '').split('#')[0]
ticket.noncestr, const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
ticket.timestamp,
req.headers.referer || ''
)
res.json({ success: true, data: { token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp } }) res.json({ token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp })
} catch (error) { } catch (error) {
logger.error({ err: error }, 'share error') logger.error({ err: error }, 'share error')
next(error) next(error)
...@@ -58,7 +48,7 @@ export const getInfo = async (req, res, next) => { ...@@ -58,7 +48,7 @@ export const getInfo = async (req, res, next) => {
return res.status(400).json({ success: false, error: { message: 'appId and code are required' } }) return res.status(400).json({ success: false, error: { message: 'appId and code are required' } })
const userInfo = await wechatService.getUserInfo(appId, code) const userInfo = await wechatService.getUserInfo(appId, code)
res.json({ success: true, data: userInfo }) res.json(userInfo)
} catch (error) { } catch (error) {
logger.error({ err: error }, 'getInfo error') logger.error({ err: error }, 'getInfo error')
next(error) next(error)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论