feat(system): 增加ApiKey管理, resm paper接口需API Key或登录访问

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-06 10:35:39 +08:00
parent 93ec54a98f
commit cc6db5ffd9
6 changed files with 134 additions and 8 deletions

View File

@ -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 - `Paper`: stores DOI/OpenAlex metadata, OA flags, abstract/fulltext state, fetch status, failure reason, and local file save helpers
- `PaperAbstract`: separate abstract storage - `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: The paper fetch pipeline in `apps/resm/tasks.py` currently includes:
- metadata ingestion from OpenAlex - metadata ingestion from OpenAlex

View File

@ -1,18 +1,21 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated
from rest_framework.settings import api_settings
from .models import Paper, PaperAbstract from .models import Paper, PaperAbstract
from .serializers import PaperListSerializer from .serializers import PaperListSerializer
from .filters import PaperFilterSet from .filters import PaperFilterSet
from apps.utils.viewsets import CustomGenericViewSet, CustomListModelMixin from apps.utils.viewsets import CustomGenericViewSet, CustomListModelMixin
from apps.utils.mixins import CustomRetrieveModelMixin from apps.utils.mixins import CustomRetrieveModelMixin
from apps.utils.apikey_auth import ApiKeyAuthentication, HasApiKey
import os import os
@api_view(['GET']) @api_view(['GET'])
@permission_classes([AllowAny]) @authentication_classes([ApiKeyAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES)
@permission_classes([HasApiKey | IsAuthenticated])
def paper_pdf_view(request, pk): def paper_pdf_view(request, pk):
paper = get_object_or_404(Paper, pk=pk) paper = get_object_or_404(Paper, pk=pk)
if not paper.has_fulltext_pdf: if not paper.has_fulltext_pdf:
@ -41,11 +44,12 @@ class PaperViewSet(CustomGenericViewSet, CustomListModelMixin, CustomRetrieveMod
ordering = ["-publication_date", "-create_time"] ordering = ["-publication_date", "-create_time"]
def get_authenticators(self): def get_authenticators(self):
authenticators = super().get_authenticators()
if self.request.method == 'GET': if self.request.method == 'GET':
return [] return [ApiKeyAuthentication()] + authenticators
return super().get_authenticators() return authenticators
def get_permissions(self): def get_permissions(self):
if self.request.method == 'GET': if self.request.method == 'GET':
return [AllowAny()] return [(HasApiKey | IsAuthenticated)()]
return super().get_permissions() return super().get_permissions()

View File

@ -1,5 +1,5 @@
from django.contrib import admin 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. # Register your models here.
admin.site.register(User) admin.site.register(User)
admin.site.register(Dept) admin.site.register(Dept)
@ -8,3 +8,12 @@ admin.site.register(Permission)
admin.site.register(DictType) admin.site.register(DictType)
admin.site.register(Dictionary) admin.site.register(Dictionary)
admin.site.register(File) 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')

View File

@ -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密钥',
},
),
]

View File

@ -263,3 +263,33 @@ class MySchedule(CommonAModel):
IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True) IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True)
crontab = models.ForeignKey( crontab = models.ForeignKey(
CrontabSchedule, on_delete=models.PROTECT, null=True, blank=True) 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)

44
apps/utils/apikey_auth.py Normal file
View File

@ -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)