mirror of
https://github.com/kainy/Open-API.git
synced 2026-06-14 20:41:11 +08:00
Add files via upload
网关框架。
This commit is contained in:
+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