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
- `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

View File

@ -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()
return [(HasApiKey | IsAuthenticated)()]
return super().get_permissions()

View File

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

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

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)