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

feat: 新增log模块

上级 4b30ac7c
......@@ -2,12 +2,10 @@
node_modules/
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
pm2_*.log
......
# ezijing-node-server
Modern Node.js API server for WeChat services.
Modern Node.js API server for WeChat services and log collection.
## Quick Start
......@@ -8,6 +8,9 @@ Modern Node.js API server for WeChat services.
# Install dependencies
npm install
# Start MongoDB (required)
mongod --dbpath /path/to/data
# Development (with hot reload)
npm run dev
......@@ -23,23 +26,71 @@ src/
├── app.js # Express app
├── config.js # Configuration
├── lib/ # Shared utilities
│ ├── logger.js # Pino logger
│ ├── db.js # MongoDB connection
│ └── file.js # File utilities
├── middleware/ # Global middleware
└── modules/ # Feature modules
├── wechat/ # WeChat SDK
└── wx-chart/ # WeChat chart data
├── wx-chart/ # WeChat chart data
└── logs/ # Log collection
```
## API Endpoints
### System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Health check |
### WeChat
| Method | Path | Description |
|--------|------|-------------|
| POST | `/share/getsignature` | Get WeChat JS-SDK signature |
| POST | `/share/token` | Get share token |
| POST | `/getInfo` | Get WeChat user info |
### Chart Data
| Method | Path | Description |
|--------|------|-------------|
| GET | `/get/wx-chart/:key` | Get 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
```env
......@@ -47,6 +98,9 @@ NODE_ENV=development
SERVER_PORT=4101
DATA_DIR=../node-server-data
# MongoDB
MONGODB_URI=mongodb://localhost:27017/ezijing-logs
# WeChat config (numbered format)
WX_APPID_1=your_appid
WX_SECRET_1=your_secret
......@@ -58,5 +112,4 @@ WX_SECRET_1=your_secret
- `npm start` - Production
- `npm run lint` - ESLint check
- `npm run lint:fix` - ESLint fix
- `npm run deploy:test` - Deploy to test
- `npm run deploy:prod` - Deploy to production
- `npm run deploy` - Deploy to production
......@@ -16,7 +16,6 @@ export default [
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off',
'comma-dangle': ['error', 'always-multiline'],
semi: ['error', 'never'],
quotes: ['error', 'single'],
},
......
......@@ -14,8 +14,10 @@
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mongoose": "^8.8.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0"
"pino-http": "^10.3.0",
"ua-parser-js": "^2.0.6"
},
"devDependencies": {
"eslint": "^9.15.0",
......@@ -285,6 +287,15 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
"integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
......@@ -305,6 +316,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
......@@ -324,6 +350,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
......@@ -451,6 +478,15 @@
"concat-map": "0.0.1"
}
},
"node_modules/bson": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
......@@ -675,6 +711,26 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-europe-js": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz",
"integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
......@@ -782,6 +838,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
......@@ -1534,6 +1591,26 @@
"node": ">=0.10.0"
}
},
"node_modules/is-standalone-pwa": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz",
"integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
......@@ -1583,6 +1660,15 @@
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
"dev": true
},
"node_modules/kareem": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
......@@ -1647,6 +1733,12 @@
"node": ">= 0.6"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
......@@ -1720,6 +1812,134 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mongodb": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongoose": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.20.1.tgz",
"integrity": "sha512-G+n3maddlqkQrP1nXxsI0q20144OSo+pe+HzRRGqaC4yK3FLYKqejqB9cbIi+SX7eoRsnG23LHGYNp8n7mWL2Q==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.4",
"kareem": "2.6.3",
"mongodb": "~6.20.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mquery": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
"integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
"license": "MIT",
"dependencies": {
"debug": "4.x"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/mquery/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mquery/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
......@@ -2035,7 +2255,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
......@@ -2289,6 +2508,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sift": {
"version": "17.1.3",
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
......@@ -2298,6 +2523,15 @@
"atomic-sleep": "^1.0.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
......@@ -2347,6 +2581,18 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
......@@ -2372,6 +2618,57 @@
"node": ">= 0.6"
}
},
"node_modules/ua-is-frozen": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz",
"integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
},
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT"
},
"node_modules/ua-parser-js": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.6.tgz",
"integrity": "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "AGPL-3.0-or-later",
"dependencies": {
"detect-europe-js": "^0.1.2",
"is-standalone-pwa": "^0.1.1",
"ua-is-frozen": "^0.1.2"
},
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
......@@ -2407,6 +2704,28 @@
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
......
......@@ -28,7 +28,9 @@
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mongoose": "^8.8.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'
import { notFound, errorHandler } from './middleware/error.js'
import wechatRoutes from './modules/wechat/wechat.routes.js'
import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js'
import logsRoutes from './modules/logs/logs.routes.js'
const app = express()
......@@ -25,6 +26,7 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now()
// Modules
app.use(wechatRoutes)
app.use(wxChartRoutes)
app.use(logsRoutes)
// Error handling
app.use(notFound)
......
......@@ -5,6 +5,9 @@ const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101,
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: {} },
}
......
import app from './app.js'
import config from './config.js'
import logger from './lib/logger.js'
import { connectDB, disconnectDB } from './lib/db.js'
const server = app.listen(config.port, () => {
logger.info({ port: config.port, env: config.env }, 'Server started 🚀')
})
const start = async () => {
try {
await connectDB()
const server = app.listen(config.port, () => {
logger.info({ port: config.port, env: config.env }, 'Server started 🚀')
})
// Graceful shutdown
const shutdown = (signal) => {
logger.info(`${signal} received, shutting down...`)
server.close(() => {
logger.info('Server closed')
process.exit(0)
})
// Graceful shutdown
const shutdown = async (signal) => {
logger.info(`${signal} received, shutting down...`)
server.close(async () => {
await disconnectDB()
logger.info('Server closed')
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('uncaughtException', (err) => {
logger.error({ err }, 'Uncaught Exception')
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 { error } from '../lib/response.js'
export const notFound = (req, res) => {
res.status(404).json({
success: false,
error: { message: '接口未定义', path: req.path, method: req.method },
})
error(res, '接口未定义', 404, { path: req.path, method: req.method })
}
export const errorHandler = (err, req, res, _next) => {
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')
res.status(statusCode).json({
success: false,
error: {
message: process.env.NODE_ENV === 'production' && statusCode === 500 ? 'Internal Server Error' : err.message,
},
})
error(res, message, statusCode)
}
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) => {
if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } })
const ticket = await wechatService.getCachedTicket(appId)
const encodedUrl = encodeURIComponent(url || req.headers.referer || '')
const signature = wechatService.generateSignature(
ticket.jsapi_ticket,
ticket.noncestr,
ticket.timestamp,
encodedUrl
)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (url || req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
res.json({
success: true,
data: {
url: encodedUrl,
ticket: ticket.jsapi_ticket,
token: signature,
noncestr: ticket.noncestr,
timestamp: ticket.timestamp,
},
url: rawUrl,
ticket: ticket.jsapi_ticket,
token: signature,
noncestr: ticket.noncestr,
timestamp: ticket.timestamp,
})
} catch (error) {
logger.error({ err: error }, 'getSignature error')
......@@ -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' } })
const ticket = await wechatService.getCachedTicket(appId)
const signature = wechatService.generateSignature(
ticket.jsapi_ticket,
ticket.noncestr,
ticket.timestamp,
req.headers.referer || ''
)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
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) {
logger.error({ err: error }, 'share error')
next(error)
......@@ -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' } })
const userInfo = await wechatService.getUserInfo(appId, code)
res.json({ success: true, data: userInfo })
res.json(userInfo)
} catch (error) {
logger.error({ err: error }, 'getInfo error')
next(error)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论