mirror of
https://github.com/kainy/Open-API.git
synced 2026-06-04 10:13:49 +08:00
Add files via upload
网关框架。
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user