Add files via upload

网关框架。
This commit is contained in:
Kainy Guo
2025-02-01 21:41:04 +08:00
committed by GitHub
commit f99c163c57
11 changed files with 516 additions and 0 deletions
+98
View File
@@ -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
+26
View File
@@ -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"
}
}
+35
View File
@@ -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}`);
});
+21
View File
@@ -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);
}
};
+40
View File
@@ -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
}
};
+22
View File
@@ -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);
+19
View File
@@ -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);
+45
View File
@@ -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;
+136
View File
@@ -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;
+24
View File
@@ -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 };
+50
View File
@@ -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 };