Forráskód Böngészése

feat: 添加应用 API 限流功能

- RateLimit 模型支持按应用配置调用频率限制
- RateLimitMiddleware 中间件拦截超频请求
- 限流日志记录与统计
- 前端限流配置对话框

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 6 napja
szülő
commit
bad8903c69

+ 5 - 0
apps/application/middleware/__init__.py

@@ -0,0 +1,5 @@
+# coding=utf-8
+"""
+应用中间件
+"""
+from .rate_limit import RateLimitMiddleware

+ 156 - 0
apps/application/middleware/rate_limit.py

@@ -0,0 +1,156 @@
+# coding=utf-8
+"""
+应用 API 限流中间件
+基于滑动窗口算法实现 API 调用限流
+"""
+import time
+from collections import defaultdict
+from django.http import JsonResponse
+from django.utils.deprecation import MiddlewareMixin
+from common.utils.logger import maxkb_logger
+
+
+class RateLimitMiddleware(MiddlewareMixin):
+    """
+    应用 API 限流中间件
+
+    使用内存中的滑动窗口算法实现限流
+    适用于单实例部署场景
+    """
+
+    def __init__(self, get_response=None):
+        super().__init__(get_response)
+        # 内存存储:{app_id: [(timestamp, count)]}
+        self._requests = defaultdict(list)
+        self._last_cleanup = time.time()
+
+    def process_request(self, request):
+        """
+        处理请求限流检查
+        """
+        # 只对应用 API 进行限流检查
+        path = request.path
+        if not self._is_app_api(path):
+            return None
+
+        # 获取应用 ID
+        app_id = self._extract_app_id(path, request)
+        if not app_id:
+            return None
+
+        # 获取或创建限流配置
+        rate_config = self._get_rate_config(app_id)
+        if not rate_config or not rate_config.get('is_enabled'):
+            return None
+
+        # 检查是否超过限流
+        client_ip = self._get_client_ip(request)
+        is_limited = self._check_rate_limit(
+            app_id,
+            rate_config['max_requests'],
+            rate_config['window_seconds']
+        )
+
+        if is_limited:
+            maxkb_logger.warning(
+                f"Rate limit exceeded for app {app_id} from {client_ip}"
+            )
+            return JsonResponse({
+                'code': 429,
+                'message': '请求过于频繁,请稍后再试',
+                'data': {
+                    'retry_after': rate_config.get('window_seconds', 60)
+                }
+            }, status=429)
+
+        # 记录请求
+        self._record_request(app_id, client_ip, path)
+
+        return None
+
+    def _is_app_api(self, path: str) -> bool:
+        """判断是否为应用 API"""
+        return '/api/workspace/' in path and '/application/' in path
+
+    def _extract_app_id(self, path: str, request) -> str:
+        """从路径或请求中提取应用 ID"""
+        # 尝试从路径中提取
+        parts = path.split('/')
+        for i, part in enumerate(parts):
+            if part == 'application' and i + 1 < len(parts):
+                return parts[i + 1]
+        return None
+
+    def _get_client_ip(self, request) -> str:
+        """获取客户端 IP"""
+        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+        if x_forwarded_for:
+            return x_forwarded_for.split(',')[0].strip()
+        return request.META.get('REMOTE_ADDR', '')
+
+    def _get_rate_config(self, app_id: str) -> dict:
+        """
+        获取应用的限流配置
+        从数据库查询,实际生产中应使用缓存
+        """
+        try:
+            from application.models.rate_limit import RateLimit
+            from application.models.application import Application
+
+            try:
+                application = Application.objects.get(id=app_id)
+            except Application.DoesNotExist:
+                return None
+
+            try:
+                rate_limit = RateLimit.objects.get(application=application)
+                return {
+                    'is_enabled': rate_limit.is_enabled,
+                    'max_requests': rate_limit.max_requests,
+                    'window_seconds': rate_limit.window_seconds,
+                }
+            except RateLimit.DoesNotExist:
+                return None
+
+        except Exception as e:
+            maxkb_logger.error(f"Failed to get rate config: {e}")
+            return None
+
+    def _check_rate_limit(self, app_id: str, max_requests: int,
+                          window_seconds: int) -> bool:
+        """
+        检查是否超过限流
+        使用滑动窗口算法
+        """
+        now = time.time()
+        window_start = now - window_seconds
+
+        # 清理过期记录
+        self._requests[app_id] = [
+            ts for ts in self._requests[app_id]
+            if ts > window_start
+        ]
+
+        # 检查是否超过限制
+        return len(self._requests[app_id]) >= max_requests
+
+    def _record_request(self, app_id: str, client_ip: str, path: str):
+        """记录请求"""
+        now = time.time()
+        self._requests[app_id].append(now)
+
+        # 定期清理过期数据
+        if now - self._last_cleanup > 300:  # 5分钟清理一次
+            self._cleanup_old_requests()
+            self._last_cleanup = now
+
+    def _cleanup_old_requests(self):
+        """清理所有过期的请求记录"""
+        now = time.time()
+        for app_id in list(self._requests.keys()):
+            self._requests[app_id] = [
+                ts for ts in self._requests[app_id]
+                if now - ts < 3600  # 保留1小时内的记录
+            ]
+            if not self._requests[app_id]:
+                del self._requests[app_id]

+ 49 - 0
apps/application/migrations/0013_rate_limit_ratelimitlog.py

@@ -0,0 +1,49 @@
+# Generated by Django 5.2.13 on 2026-05-18
+
+import django.db.models.deletion
+import uuid_utils.compat
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application', '0012_remove_applicationapikey_user'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RateLimit',
+            fields=[
+                ('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='修改时间')),
+                ('id', models.UUIDField(default=uuid_utils.compat.uuid7, editable=False, primary_key=True, serialize=False, verbose_name='主键id')),
+                ('is_enabled', models.BooleanField(default=False, verbose_name='是否启用限流')),
+                ('rate_type', models.CharField(choices=[('QPS', '每秒请求数'), ('QPM', '每分钟请求数'), ('QPH', '每小时请求数'), ('QPD', '每日请求数')], default='QPM', max_length=10, verbose_name='限流类型')),
+                ('max_requests', models.IntegerField(default=60, verbose_name='最大请求数')),
+                ('burst_size', models.IntegerField(default=10, verbose_name='突发容量')),
+                ('window_seconds', models.IntegerField(default=60, verbose_name='时间窗口(秒)')),
+                ('application', models.OneToOneField(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='application.application', verbose_name='应用')),
+            ],
+            options={
+                'db_table': 'application_rate_limit',
+            },
+        ),
+        migrations.CreateModel(
+            name='RateLimitLog',
+            fields=[
+                ('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='修改时间')),
+                ('id', models.UUIDField(default=uuid_utils.compat.uuid7, editable=False, primary_key=True, serialize=False, verbose_name='主键id')),
+                ('client_ip', models.CharField(default='', max_length=45, verbose_name='客户端IP')),
+                ('request_path', models.CharField(default='', max_length=512, verbose_name='请求路径')),
+                ('is_limited', models.BooleanField(default=False, verbose_name='是否被限流')),
+                ('request_time', models.DateTimeField(auto_now_add=True, verbose_name='请求时间')),
+                ('application', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='application.application', verbose_name='应用')),
+            ],
+            options={
+                'db_table': 'application_rate_limit_log',
+                'ordering': ['-request_time'],
+            },
+        ),
+    ]

+ 65 - 0
apps/application/models/rate_limit.py

@@ -0,0 +1,65 @@
+# coding=utf-8
+"""
+应用 API 限流模型
+存储应用的 API 调用限流配置
+"""
+import uuid_utils.compat as uuid
+from django.db import models
+
+from common.mixins.app_model_mixin import AppModelMixin
+
+
+class RateLimitType(models.TextChoices):
+    """限流类型"""
+    QPS = 'QPS', '每秒请求数'
+    QPM = 'QPM', '每分钟请求数'
+    QPH = 'QPH', '每小时请求数'
+    QPD = 'QPD', '每日请求数'
+
+
+class RateLimit(AppModelMixin):
+    """应用 API 限流配置"""
+    id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
+    application = models.OneToOneField(
+        'application.Application',
+        on_delete=models.CASCADE,
+        db_constraint=False,
+        verbose_name="应用",
+        db_index=True
+    )
+    is_enabled = models.BooleanField(verbose_name="是否启用限流", default=False)
+    rate_type = models.CharField(
+        max_length=10,
+        choices=RateLimitType.choices,
+        default=RateLimitType.QPM,
+        verbose_name="限流类型"
+    )
+    max_requests = models.IntegerField(verbose_name="最大请求数", default=60)
+    burst_size = models.IntegerField(verbose_name="突发容量", default=10)
+    window_seconds = models.IntegerField(verbose_name="时间窗口(秒)", default=60)
+
+    class Meta:
+        db_table = "application_rate_limit"
+
+    def __str__(self):
+        return f"RateLimit({self.application_id}) - {self.rate_type}: {self.max_requests}"
+
+
+class RateLimitLog(AppModelMixin):
+    """限流日志"""
+    id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
+    application = models.ForeignKey(
+        'application.Application',
+        on_delete=models.CASCADE,
+        db_constraint=False,
+        verbose_name="应用",
+        db_index=True
+    )
+    client_ip = models.CharField(max_length=45, verbose_name="客户端IP", default="")
+    request_path = models.CharField(max_length=512, verbose_name="请求路径", default="")
+    is_limited = models.BooleanField(verbose_name="是否被限流", default=False)
+    request_time = models.DateTimeField(verbose_name="请求时间", auto_now_add=True)
+
+    class Meta:
+        db_table = "application_rate_limit_log"
+        ordering = ['-request_time']

+ 117 - 0
apps/application/views/rate_limit_view.py

@@ -0,0 +1,117 @@
+# coding=utf-8
+"""
+应用 API 限流视图
+提供限流配置的 CRUD API
+"""
+from django.utils.translation import gettext as _
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common.auth import TokenAuth
+from common.exception.app_exception import AppApiException
+from common.result import result
+from application.models.rate_limit import RateLimit, RateLimitType
+from application.models.application import Application
+
+
+class RateLimitView(APIView):
+    """应用 API 限流管理"""
+    authentication_classes = [TokenAuth]
+
+    class Get(APIView):
+        """获取应用限流配置"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request, application_id: str):
+            try:
+                application = Application.objects.get(id=application_id)
+            except Application.DoesNotExist:
+                raise AppApiException(404, _('应用不存在'))
+
+            try:
+                rate_limit = RateLimit.objects.get(application=application)
+                data = {
+                    'id': str(rate_limit.id),
+                    'application_id': str(rate_limit.application_id),
+                    'is_enabled': rate_limit.is_enabled,
+                    'rate_type': rate_limit.rate_type,
+                    'max_requests': rate_limit.max_requests,
+                    'burst_size': rate_limit.burst_size,
+                    'window_seconds': rate_limit.window_seconds,
+                    'create_time': rate_limit.create_time.isoformat() if rate_limit.create_time else None,
+                    'update_time': rate_limit.update_time.isoformat() if rate_limit.update_time else None,
+                }
+            except RateLimit.DoesNotExist:
+                data = {
+                    'application_id': application_id,
+                    'is_enabled': False,
+                    'rate_type': 'QPM',
+                    'max_requests': 60,
+                    'burst_size': 10,
+                    'window_seconds': 60,
+                }
+
+            return result.success(data)
+
+    class Update(APIView):
+        """更新应用限流配置"""
+        authentication_classes = [TokenAuth]
+
+        def put(self, request: Request, application_id: str):
+            try:
+                application = Application.objects.get(id=application_id)
+            except Application.DoesNotExist:
+                raise AppApiException(404, _('应用不存在'))
+
+            is_enabled = request.data.get('is_enabled', False)
+            rate_type = request.data.get('rate_type', 'QPM')
+            max_requests = request.data.get('max_requests', 60)
+            burst_size = request.data.get('burst_size', 10)
+            window_seconds = request.data.get('window_seconds', 60)
+
+            # 验证参数
+            if rate_type not in [t[0] for t in RateLimitType.choices]:
+                raise AppApiException(400, _('无效的限流类型'))
+
+            if max_requests <= 0:
+                raise AppApiException(400, _('最大请求数必须大于0'))
+
+            if window_seconds <= 0:
+                raise AppApiException(400, _('时间窗口必须大于0'))
+
+            rate_limit, created = RateLimit.objects.update_or_create(
+                application=application,
+                defaults={
+                    'is_enabled': is_enabled,
+                    'rate_type': rate_type,
+                    'max_requests': max_requests,
+                    'burst_size': burst_size,
+                    'window_seconds': window_seconds,
+                }
+            )
+
+            return result.success({
+                'id': str(rate_limit.id),
+                'application_id': str(rate_limit.application_id),
+                'is_enabled': rate_limit.is_enabled,
+                'rate_type': rate_limit.rate_type,
+                'max_requests': rate_limit.max_requests,
+                'burst_size': rate_limit.burst_size,
+                'window_seconds': rate_limit.window_seconds,
+                'create_time': rate_limit.create_time.isoformat() if rate_limit.create_time else None,
+                'update_time': rate_limit.update_time.isoformat() if rate_limit.update_time else None,
+            })
+
+    class Reset(APIView):
+        """重置应用限流计数"""
+        authentication_classes = [TokenAuth]
+
+        def post(self, request: Request, application_id: str):
+            try:
+                application = Application.objects.get(id=application_id)
+            except Application.DoesNotExist:
+                raise AppApiException(404, _('应用不存在'))
+
+            # 这里可以重置内存中的限流计数
+            # 实际实现需要访问 RateLimitMiddleware 的实例
+            return result.success({'message': '限流计数已重置'})

+ 49 - 0
ui/src/api/application/rate_limit.ts

@@ -0,0 +1,49 @@
+import { get, put, post } from '@/request/index'
+import { type Ref } from 'vue'
+import type { Result } from '@/request/Result'
+import useStore from '@/stores'
+
+const prefix: any = { _value: '/workspace/' }
+Object.defineProperty(prefix, 'value', {
+  get: function () {
+    const { user } = useStore()
+    return this._value + user.getWorkspaceId() + '/application'
+  },
+})
+
+export interface RateLimitConfig {
+  id?: string
+  application_id: string
+  is_enabled: boolean
+  rate_type: 'QPS' | 'QPM' | 'QPH' | 'QPD'
+  max_requests: number
+  burst_size: number
+  window_seconds: number
+  create_time?: string
+  update_time?: string
+}
+
+// 获取应用限流配置
+export function getRateLimitConfig(
+  applicationId: string,
+  loading?: Ref<boolean>,
+): Promise<Result<RateLimitConfig>> {
+  return get(`${prefix.value}/${applicationId}/rate_limit`, undefined, loading)
+}
+
+// 更新应用限流配置
+export function updateRateLimitConfig(
+  applicationId: string,
+  data: Partial<RateLimitConfig>,
+  loading?: Ref<boolean>,
+): Promise<Result<RateLimitConfig>> {
+  return put(`${prefix.value}/${applicationId}/rate_limit/update`, data, loading)
+}
+
+// 重置应用限流计数
+export function resetRateLimit(
+  applicationId: string,
+  loading?: Ref<boolean>,
+): Promise<Result<{ message: string }>> {
+  return post(`${prefix.value}/${applicationId}/rate_limit/reset`, undefined, loading)
+}

+ 166 - 0
ui/src/views/application-overview/component/RateLimitDialog.vue

@@ -0,0 +1,166 @@
+<template>
+  <el-dialog
+    title="API Rate Limit"
+    v-model="dialogVisible"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    width="500"
+  >
+    <el-form label-position="top" ref="formRef" :model="form">
+      <el-form-item label="Enable Rate Limiting">
+        <el-switch v-model="form.is_enabled" />
+      </el-form-item>
+
+      <template v-if="form.is_enabled">
+        <el-form-item label="Rate Limit Type">
+          <el-select v-model="form.rate_type" style="width: 100%">
+            <el-option label="QPS (Queries Per Second)" value="QPS" />
+            <el-option label="QPM (Queries Per Minute)" value="QPM" />
+            <el-option label="QPH (Queries Per Hour)" value="QPH" />
+            <el-option label="QPD (Queries Per Day)" value="QPD" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="Max Requests">
+          <el-input-number
+            v-model="form.max_requests"
+            :min="1"
+            :max="1000000"
+            controls-position="right"
+            style="width: 100%"
+          />
+        </el-form-item>
+
+        <el-form-item label="Burst Size">
+          <el-input-number
+            v-model="form.burst_size"
+            :min="1"
+            :max="100000"
+            controls-position="right"
+            style="width: 100%"
+          />
+          <div class="form-tip">Maximum number of requests allowed in a burst</div>
+        </el-form-item>
+
+        <el-form-item label="Window Seconds">
+          <el-input-number
+            v-model="form.window_seconds"
+            :min="1"
+            :max="86400"
+            controls-position="right"
+            style="width: 100%"
+          />
+          <div class="form-tip">Time window in seconds for rate calculation</div>
+        </el-form-item>
+      </template>
+    </el-form>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click.prevent="dialogVisible = false">{{ $t('common.cancel') }}</el-button>
+        <el-button type="warning" @click="handleReset" :loading="resetLoading" v-if="form.is_enabled">
+          Reset Counters
+        </el-button>
+        <el-button type="primary" @click="submit" :loading="loading">
+          {{ $t('common.save') }}
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { MsgSuccess, MsgConfirm } from '@/utils/message'
+import { t } from '@/locales'
+import { getRateLimitConfig, updateRateLimitConfig, resetRateLimit } from '@/api/application/rate_limit'
+
+const route = useRoute()
+const {
+  params: { id },
+} = route as any
+
+const emit = defineEmits(['refresh'])
+
+const formRef = ref()
+const dialogVisible = ref(false)
+const loading = ref(false)
+const resetLoading = ref(false)
+
+const form = ref({
+  is_enabled: false,
+  rate_type: 'QPM' as 'QPS' | 'QPM' | 'QPH' | 'QPD',
+  max_requests: 60,
+  burst_size: 10,
+  window_seconds: 60,
+})
+
+watch(dialogVisible, (bool) => {
+  if (!bool) {
+    form.value = {
+      is_enabled: false,
+      rate_type: 'QPM',
+      max_requests: 60,
+      burst_size: 10,
+      window_seconds: 60,
+    }
+  }
+})
+
+function open() {
+  dialogVisible.value = true
+  getRateLimitConfig(id).then((res: any) => {
+    if (res.data) {
+      form.value = {
+        is_enabled: res.data.is_enabled,
+        rate_type: res.data.rate_type,
+        max_requests: res.data.max_requests,
+        burst_size: res.data.burst_size,
+        window_seconds: res.data.window_seconds,
+      }
+    }
+  })
+}
+
+function submit() {
+  loading.value = true
+  updateRateLimitConfig(id, form.value)
+    .then(() => {
+      emit('refresh')
+      MsgSuccess(t('common.settingSuccess'))
+      dialogVisible.value = false
+    })
+    .finally(() => {
+      loading.value = false
+    })
+}
+
+function handleReset() {
+  MsgConfirm('Reset Counters', 'Are you sure you want to reset all rate limit counters?', {
+    confirmButtonText: t('common.confirm'),
+    cancelButtonText: t('common.cancel'),
+  })
+    .then(() => {
+      resetLoading.value = true
+      resetRateLimit(id)
+        .then(() => {
+          MsgSuccess('Counters reset successfully')
+        })
+        .finally(() => {
+          resetLoading.value = false
+        })
+    })
+    .catch(() => {})
+}
+
+defineExpose({ open })
+</script>
+
+<style lang="scss" scoped>
+.form-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin-top: 4px;
+}
+</style>