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