commit f99c163c57ab383135dec0a85d77eebf9b96ecc3 Author: Kainy Guo Date: Sat Feb 1 21:41:04 2025 +0800 Add files via upload 网关框架。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..db00e98 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# API Billing System + +一个基于 Koa.js 的高性能 API 计费系统,支持按月计费和按调用量计费。 + +## 主要特性 + +- 支持多种计费模式: + - 按月固定额度计费 + - 按实际使用量计费 +- 高性能设计: + - 使用 Redis 进行速率限制和临时数据缓存 + - MongoDB 用于持久化存储 + - 异步处理计费记录 +- 完善的错误处理和日志记录 +- RESTful API 设计 +- 实时使用量统计 +- 灵活的计费策略配置 + +## 系统要求 + +- Node.js >= 14 +- MongoDB >= 4.4 +- Redis >= 6.0 + +## 安装 + +```bash +# 克隆项目 +git clone [repository-url] + +# 安装依赖 +npm install + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 文件配置相关参数 + +# 启动服务 +npm start +``` + +## API 文档 + +### 认证 +所有 API 请求都需要在 header 中包含 `X-API-Key` + +### 计费相关接口 + +#### 获取月度使用统计 +``` +GET /billing/usage/:year/:month +``` + +#### 获取月度账单 +``` +GET /billing/bill/:year/:month +``` + +#### 更新计费方案 +``` +PUT /billing/plan +``` + +请求体示例: +```json +{ + "billingPlan": "monthly", + "monthlyQuota": 10000, + "usageRate": 0.01 +} +``` + +## 性能优化 + +1. 使用 Redis 进行速率限制和临时数据缓存 +2. MongoDB 索引优化 +3. 异步处理计费记录 +4. 批量处理和聚合查询 + +## 安全性考虑 + +1. API 密钥认证 +2. 速率限制 +3. 输入验证 +4. 错误处理 +5. 敏感信息加密 + +## 开发计划 + +- [ ] 支持多种货币 +- [ ] 添加计费预警功能 +- [ ] 支持自定义计费规则 +- [ ] 添加计费报表导出功能 +- [ ] 集成支付网关 + +## 许可证 + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9cfdb1 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "api-billing-system", + "version": "1.0.0", + "description": "A robust API billing system built with Koa.js", + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "nodemon src/app.js", + "test": "jest" + }, + "dependencies": { + "koa": "^2.14.2", + "koa-router": "^12.0.1", + "koa-bodyparser": "^4.4.1", + "mongoose": "^7.5.0", + "winston": "^3.10.0", + "joi": "^17.10.1", + "dotenv": "^16.3.1", + "moment": "^2.29.4", + "redis": "^4.6.8" + }, + "devDependencies": { + "jest": "^29.6.4", + "nodemon": "^3.0.1" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..4a1db98 --- /dev/null +++ b/src/app.js @@ -0,0 +1,35 @@ +require('dotenv').config(); +const Koa = require('koa'); +const Router = require('koa-router'); +const bodyParser = require('koa-bodyparser'); +const mongoose = require('mongoose'); +const { errorHandler } = require('./middleware/errorHandler'); +const { rateLimiter } = require('./middleware/rateLimiter'); +const { logger } = require('./utils/logger'); +const apiRoutes = require('./routes/api'); +const billingRoutes = require('./routes/billing'); + +const app = new Koa(); +const router = new Router(); + +// Database connection +mongoose.connect(process.env.MONGODB_URI) + .then(() => logger.info('Connected to MongoDB')) + .catch(err => logger.error('MongoDB connection error:', err)); + +// Middleware +app.use(errorHandler); +app.use(bodyParser()); +app.use(rateLimiter); + +// Routes +router.use('/api', apiRoutes.routes()); +router.use('/billing', billingRoutes.routes()); + +app.use(router.routes()); +app.use(router.allowedMethods()); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); +}); diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..8fe3c9c --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,21 @@ +const { logger } = require('../utils/logger'); + +exports.errorHandler = async (ctx, next) => { + try { + await next(); + } catch (err) { + logger.error('Request error:', err); + + ctx.status = err.status || 500; + ctx.body = { + error: { + message: err.message || 'Internal server error', + status: ctx.status, + timestamp: new Date().toISOString() + } + }; + + // Emit error for potential monitoring + ctx.app.emit('error', err, ctx); + } +}; diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js new file mode 100644 index 0000000..8b01fe6 --- /dev/null +++ b/src/middleware/rateLimiter.js @@ -0,0 +1,40 @@ +const { RedisClient } = require('../utils/redis'); +const { logger } = require('../utils/logger'); + +const RATE_LIMIT_WINDOW = 60; // 1 minute window +const MAX_REQUESTS = 100; // Maximum requests per window + +exports.rateLimiter = async (ctx, next) => { + const apiKey = ctx.get('X-API-Key'); + if (!apiKey) { + ctx.status = 401; + ctx.body = { error: 'API key required' }; + return; + } + + const key = `ratelimit:${apiKey}`; + + try { + const [requests] = await RedisClient + .multi() + .incr(key) + .expire(key, RATE_LIMIT_WINDOW) + .exec(); + + const requestCount = requests[1]; + + ctx.set('X-RateLimit-Limit', MAX_REQUESTS); + ctx.set('X-RateLimit-Remaining', Math.max(0, MAX_REQUESTS - requestCount)); + + if (requestCount > MAX_REQUESTS) { + ctx.status = 429; + ctx.body = { error: 'Rate limit exceeded' }; + return; + } + + await next(); + } catch (error) { + logger.error('Rate limiter error:', error); + await next(); // Proceed even if rate limiting fails + } +}; diff --git a/src/models/Usage.js b/src/models/Usage.js new file mode 100644 index 0000000..a268144 --- /dev/null +++ b/src/models/Usage.js @@ -0,0 +1,22 @@ +const mongoose = require('mongoose'); + +const usageSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + endpoint: { type: String, required: true }, + requestCount: { type: Number, default: 1 }, + cost: { type: Number, required: true }, + timestamp: { type: Date, default: Date.now }, + billingPeriod: { + year: { type: Number, required: true }, + month: { type: Number, required: true } + } +}); + +// Create compound index for efficient querying +usageSchema.index({ userId: 1, 'billingPeriod.year': 1, 'billingPeriod.month': 1 }); + +module.exports = mongoose.model('Usage', usageSchema); diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..50e9598 --- /dev/null +++ b/src/models/User.js @@ -0,0 +1,19 @@ +const mongoose = require('mongoose'); + +const userSchema = new mongoose.Schema({ + username: { type: String, required: true, unique: true }, + apiKey: { type: String, required: true, unique: true }, + billingPlan: { + type: String, + enum: ['monthly', 'usage'], + required: true + }, + monthlyQuota: { type: Number }, // For monthly plan + usageRate: { type: Number }, // For usage-based plan + balance: { type: Number, default: 0 }, + isActive: { type: Boolean, default: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('User', userSchema); diff --git a/src/routes/billing.js b/src/routes/billing.js new file mode 100644 index 0000000..2dfcdbb --- /dev/null +++ b/src/routes/billing.js @@ -0,0 +1,45 @@ +const Router = require('koa-router'); +const BillingService = require('../services/BillingService'); +const { validateApiKey } = require('../middleware/auth'); + +const router = new Router(); + +// Get monthly usage statistics +router.get('/usage/:year/:month', validateApiKey, async (ctx) => { + const { year, month } = ctx.params; + const usage = await BillingService.getMonthlyUsage( + ctx.state.user._id, + parseInt(year), + parseInt(month) + ); + ctx.body = { usage }; +}); + +// Get monthly bill +router.get('/bill/:year/:month', validateApiKey, async (ctx) => { + const { year, month } = ctx.params; + const bill = await BillingService.generateMonthlyBill( + ctx.state.user._id, + parseInt(year), + parseInt(month) + ); + ctx.body = { bill }; +}); + +// Update billing plan +router.put('/plan', validateApiKey, async (ctx) => { + const { billingPlan, monthlyQuota, usageRate } = ctx.request.body; + const user = await User.findByIdAndUpdate( + ctx.state.user._id, + { + billingPlan, + monthlyQuota, + usageRate, + updatedAt: new Date() + }, + { new: true } + ); + ctx.body = { user }; +}); + +module.exports = router; diff --git a/src/services/BillingService.js b/src/services/BillingService.js new file mode 100644 index 0000000..47597da --- /dev/null +++ b/src/services/BillingService.js @@ -0,0 +1,136 @@ +const moment = require('moment'); +const User = require('../models/User'); +const Usage = require('../models/Usage'); +const { logger } = require('../utils/logger'); +const { RedisClient } = require('../utils/redis'); + +class BillingService { + static async recordUsage(userId, endpoint) { + try { + const user = await User.findById(userId); + if (!user || !user.isActive) { + throw new Error('Invalid or inactive user'); + } + + const now = moment(); + const year = now.year(); + const month = now.month() + 1; + + // Calculate cost based on billing plan + let cost = 0; + if (user.billingPlan === 'usage') { + cost = user.usageRate; + } + + // Check monthly quota for monthly plan + if (user.billingPlan === 'monthly') { + const monthlyUsage = await Usage.aggregate([ + { + $match: { + userId: user._id, + 'billingPeriod.year': year, + 'billingPeriod.month': month + } + }, + { + $group: { + _id: null, + total: { $sum: '$requestCount' } + } + } + ]); + + const currentUsage = monthlyUsage[0]?.total || 0; + if (currentUsage >= user.monthlyQuota) { + throw new Error('Monthly quota exceeded'); + } + } + + // Record usage with Redis for performance + const usageKey = `usage:${userId}:${year}:${month}:${endpoint}`; + await RedisClient.hincrby(usageKey, 'count', 1); + await RedisClient.expire(usageKey, 60 * 60 * 24 * 7); // Expire after 7 days + + // Persist to MongoDB asynchronously + const usage = await Usage.findOneAndUpdate( + { + userId, + endpoint, + 'billingPeriod.year': year, + 'billingPeriod.month': month + }, + { + $inc: { requestCount: 1 }, + $set: { cost } + }, + { upsert: true, new: true } + ); + + // Update user balance for usage-based billing + if (user.billingPlan === 'usage') { + await User.findByIdAndUpdate(userId, { + $inc: { balance: -cost } + }); + } + + return usage; + } catch (error) { + logger.error('Error recording usage:', error); + throw error; + } + } + + static async getMonthlyUsage(userId, year, month) { + try { + const usage = await Usage.aggregate([ + { + $match: { + userId: mongoose.Types.ObjectId(userId), + 'billingPeriod.year': year, + 'billingPeriod.month': month + } + }, + { + $group: { + _id: '$endpoint', + totalRequests: { $sum: '$requestCount' }, + totalCost: { $sum: { $multiply: ['$requestCount', '$cost'] } } + } + } + ]); + + return usage; + } catch (error) { + logger.error('Error getting monthly usage:', error); + throw error; + } + } + + static async generateMonthlyBill(userId, year, month) { + try { + const user = await User.findById(userId); + const usage = await this.getMonthlyUsage(userId, year, month); + + let totalCost = 0; + if (user.billingPlan === 'monthly') { + totalCost = user.monthlyQuota > 0 ? user.usageRate : 0; + } else { + totalCost = usage.reduce((sum, item) => sum + item.totalCost, 0); + } + + return { + userId, + billingPlan: user.billingPlan, + year, + month, + usage, + totalCost + }; + } catch (error) { + logger.error('Error generating monthly bill:', error); + throw error; + } + } +} + +module.exports = BillingService; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..0978710 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,24 @@ +const winston = require('winston'); + +const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.File({ filename: 'error.log', level: 'error' }), + new winston.transports.File({ filename: 'combined.log' }) + ] +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + })); +} + +module.exports = { logger }; diff --git a/src/utils/redis.js b/src/utils/redis.js new file mode 100644 index 0000000..c66d2b9 --- /dev/null +++ b/src/utils/redis.js @@ -0,0 +1,50 @@ +const Redis = require('redis'); +const { logger } = require('./logger'); + +class RedisClient { + static client = null; + + static async connect() { + if (!this.client) { + this.client = Redis.createClient({ + url: process.env.REDIS_URL + }); + + this.client.on('error', (err) => { + logger.error('Redis Client Error:', err); + }); + + this.client.on('connect', () => { + logger.info('Connected to Redis'); + }); + + await this.client.connect(); + } + return this.client; + } + + static async get(key) { + const client = await this.connect(); + return client.get(key); + } + + static async set(key, value, expireSeconds = null) { + const client = await this.connect(); + if (expireSeconds) { + return client.setEx(key, expireSeconds, value); + } + return client.set(key, value); + } + + static async hincrby(key, field, increment) { + const client = await this.connect(); + return client.hIncrBy(key, field, increment); + } + + static async expire(key, seconds) { + const client = await this.connect(); + return client.expire(key, seconds); + } +} + +module.exports = { RedisClient };