From cc6db5ffd9ba90422a062dc643264e5741f82c35 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 6 Jul 2026 10:35:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E5=A2=9E=E5=8A=A0ApiKey?= =?UTF-8?q?=E7=AE=A1=E7=90=86,=20resm=20paper=E6=8E=A5=E5=8F=A3=E9=9C=80AP?= =?UTF-8?q?I=20Key=E6=88=96=E7=99=BB=E5=BD=95=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiKey 模型, key 保存时自动生成, Django admin 中管理和查看 - 新增 ApiKeyAuthentication/HasApiKey, 支持 X-API-Key 请求头和 api_key 查询参数 - PaperViewSet GET 与 paper_pdf_view 由 AllowAny 改为 HasApiKey | IsAuthenticated Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 ++ apps/resm/views.py | 18 ++++++----- apps/system/admin.py | 11 ++++++- apps/system/migrations/0009_apikey.py | 37 ++++++++++++++++++++++ apps/system/models.py | 30 ++++++++++++++++++ apps/utils/apikey_auth.py | 44 +++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/system/migrations/0009_apikey.py create mode 100644 apps/utils/apikey_auth.py diff --git a/CLAUDE.md b/CLAUDE.md index 34642ec..086a2dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,8 @@ Key model: - `Paper`: stores DOI/OpenAlex metadata, OA flags, abstract/fulltext state, fetch status, failure reason, and local file save helpers - `PaperAbstract`: separate abstract storage +Read access to paper APIs (`GET api/resm/paper/` and the PDF view) requires either a valid API Key or a logged-in JWT user. API Keys live in `apps.system.models.ApiKey` (plaintext `key` auto-generated on save, managed via Django admin) and are validated by `apps.utils.apikey_auth.ApiKeyAuthentication` from the `X-API-Key` header or `?api_key=` query param. + The paper fetch pipeline in `apps/resm/tasks.py` currently includes: - metadata ingestion from OpenAlex diff --git a/apps/resm/views.py b/apps/resm/views.py index 74462f5..9f8d7d1 100644 --- a/apps/resm/views.py +++ b/apps/resm/views.py @@ -1,18 +1,21 @@ from django.shortcuts import get_object_or_404 from django.http import FileResponse, Http404 from rest_framework.response import Response -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import AllowAny +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.settings import api_settings from .models import Paper, PaperAbstract from .serializers import PaperListSerializer from .filters import PaperFilterSet from apps.utils.viewsets import CustomGenericViewSet, CustomListModelMixin from apps.utils.mixins import CustomRetrieveModelMixin +from apps.utils.apikey_auth import ApiKeyAuthentication, HasApiKey import os @api_view(['GET']) -@permission_classes([AllowAny]) +@authentication_classes([ApiKeyAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES) +@permission_classes([HasApiKey | IsAuthenticated]) def paper_pdf_view(request, pk): paper = get_object_or_404(Paper, pk=pk) if not paper.has_fulltext_pdf: @@ -41,11 +44,12 @@ class PaperViewSet(CustomGenericViewSet, CustomListModelMixin, CustomRetrieveMod ordering = ["-publication_date", "-create_time"] def get_authenticators(self): + authenticators = super().get_authenticators() if self.request.method == 'GET': - return [] - return super().get_authenticators() + return [ApiKeyAuthentication()] + authenticators + return authenticators def get_permissions(self): if self.request.method == 'GET': - return [AllowAny()] - return super().get_permissions() \ No newline at end of file + return [(HasApiKey | IsAuthenticated)()] + return super().get_permissions() diff --git a/apps/system/admin.py b/apps/system/admin.py index 249639f..65630ad 100755 --- a/apps/system/admin.py +++ b/apps/system/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import User, Dept, Role, Permission, DictType, Dictionary, File +from .models import User, Dept, Role, Permission, DictType, Dictionary, File, ApiKey # Register your models here. admin.site.register(User) admin.site.register(Dept) @@ -8,3 +8,12 @@ admin.site.register(Permission) admin.site.register(DictType) admin.site.register(Dictionary) admin.site.register(File) + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ('name', 'key', 'is_active', 'expires_at', 'last_used_at', 'create_time') + list_filter = ('is_active',) + search_fields = ('name', 'key') + readonly_fields = ('key', 'last_used_at') + fields = ('name', 'key', 'is_active', 'expires_at', 'scopes', 'last_used_at') diff --git a/apps/system/migrations/0009_apikey.py b/apps/system/migrations/0009_apikey.py new file mode 100644 index 0000000..d70baf5 --- /dev/null +++ b/apps/system/migrations/0009_apikey.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.27 on 2026-07-06 02:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0008_merge_20260116_1509'), + ] + + operations = [ + migrations.CreateModel( + name='ApiKey', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(help_text='调用方标识', max_length=100, verbose_name='名称')), + ('key', models.CharField(db_index=True, editable=False, max_length=64, unique=True, verbose_name='密钥')), + ('is_active', models.BooleanField(default=True, verbose_name='启用')), + ('expires_at', models.DateTimeField(blank=True, help_text='留空则永不过期', null=True, verbose_name='过期时间')), + ('scopes', models.JSONField(blank=True, default=list, help_text='预留, 如 ["resm.paper"]', verbose_name='权限范围')), + ('last_used_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='最后使用')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': 'API密钥', + 'verbose_name_plural': 'API密钥', + }, + ), + ] diff --git a/apps/system/models.py b/apps/system/models.py index 182c113..614b05b 100755 --- a/apps/system/models.py +++ b/apps/system/models.py @@ -263,3 +263,33 @@ class MySchedule(CommonAModel): IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True) crontab = models.ForeignKey( CrontabSchedule, on_delete=models.PROTECT, null=True, blank=True) + + +class ApiKey(CommonAModel): + """ + 外部程序调用的API Key + key保存时自动生成, 明文存库, admin中可直接查看 + """ + name = models.CharField('名称', max_length=100, help_text='调用方标识') + key = models.CharField('密钥', max_length=64, unique=True, db_index=True, editable=False) + is_active = models.BooleanField('启用', default=True) + expires_at = models.DateTimeField('过期时间', null=True, blank=True, help_text='留空则永不过期') + scopes = models.JSONField('权限范围', default=list, blank=True, help_text='预留, 如 ["resm.paper"]') + last_used_at = models.DateTimeField('最后使用', null=True, blank=True, editable=False) + + class Meta: + verbose_name = 'API密钥' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + @staticmethod + def generate_key() -> str: + import secrets + return 'pk_' + secrets.token_urlsafe(36) + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super().save(*args, **kwargs) diff --git a/apps/utils/apikey_auth.py b/apps/utils/apikey_auth.py new file mode 100644 index 0000000..2270595 --- /dev/null +++ b/apps/utils/apikey_auth.py @@ -0,0 +1,44 @@ +from datetime import timedelta + +from django.contrib.auth.models import AnonymousUser +from django.utils import timezone +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication +from rest_framework.permissions import BasePermission + +from apps.system.models import ApiKey + + +class ApiKeyAuthentication(BaseAuthentication): + """ + API Key认证: 取请求头 X-API-Key 或查询参数 api_key + 通过后 request.auth 为 ApiKey 实例, request.user 为匿名用户 + 未携带Key时返回None, 交给后续认证类处理 + """ + + def authenticate(self, request): + raw_key = request.META.get('HTTP_X_API_KEY') or request.GET.get('api_key') + if not raw_key: + return None + try: + apikey = ApiKey.objects.get(key=raw_key) + except ApiKey.DoesNotExist: + raise exceptions.AuthenticationFailed('无效的API Key') + if not apikey.is_active: + raise exceptions.AuthenticationFailed('API Key已禁用') + if apikey.expires_at and apikey.expires_at <= timezone.now(): + raise exceptions.AuthenticationFailed('API Key已过期') + now = timezone.now() + # last_used_at节流更新, 避免每次请求都写库 + if apikey.last_used_at is None or now - apikey.last_used_at > timedelta(minutes=5): + ApiKey.objects.filter(pk=apikey.pk).update(last_used_at=now) + return (AnonymousUser(), apikey) + + +class HasApiKey(BasePermission): + """ + 持有效API Key即放行 + """ + + def has_permission(self, request, view): + return isinstance(request.auth, ApiKey)