feat: 添加 API Key 发布功能

- 新增 ApiKey 模型,支持 API 密钥认证
- 添加 require_api_key 装饰器实现 API 认证
- 实现 5 个站点管理 API 接口 (GET/POST/PUT/DELETE)
- 添加后台 API Key 管理界面
- 添加数据库迁移脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-03-23 22:50:03 +08:00
parent bcc6a7d874
commit 5cef0b94fd
5 changed files with 373 additions and 1 deletions

275
app.py
View File

@@ -8,9 +8,10 @@ from flask import Flask, render_template, redirect, url_for, request, flash, jso
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from wtforms import fields
from datetime import datetime, timedelta
from config import config
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection, ApiKey
from utils.website_fetcher import WebsiteFetcher
from utils.tag_generator import TagGenerator
from utils.news_searcher import NewsSearcher
@@ -477,6 +478,228 @@ def create_app(config_name='default'):
})
# ========== API Key 认证路由 ==========
def require_api_key(permissions=None):
"""API Key 认证装饰器
Args:
permissions: 必需的权限列表,如 ['site:read', 'site:write']
"""
def decorator(f):
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
# 从请求头获取 API Key
api_key = request.headers.get('X-API-Key')
if not api_key:
return jsonify({'success': False, 'message': '缺少 API Key'}), 401
# 查找 API Key
key_obj = ApiKey.query.filter_by(key=api_key).first()
if not key_obj:
return jsonify({'success': False, 'message': '无效的 API Key'}), 401
if not key_obj.is_active:
return jsonify({'success': False, 'message': 'API Key 已禁用'}), 401
# 检查权限
if permissions:
for perm in permissions:
if not key_obj.has_permission(perm):
return jsonify({'success': False, 'message': f'权限不足,需要 {perm} 权限'}), 403
# 更新使用记录
key_obj.last_used = datetime.now()
key_obj.usage_count = (key_obj.usage_count or 0) + 1
db.session.commit()
# 将 key_obj 传递给被装饰的函数
kwargs['api_key_obj'] = key_obj
return f(*args, **kwargs)
return decorated_function
return decorator
@app.route('/api/key/sites', methods=['GET'])
@require_api_key(['site:read'])
def list_sites_via_api(**kwargs):
"""通过 API Key 列出所有网站"""
try:
sites = Site.query.order_by(Site.created_at.desc()).all()
return jsonify({
'success': True,
'sites': [site.to_dict() for site in sites]
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/key/sites', methods=['POST'])
@require_api_key(['site:write'])
def create_site_via_api(**kwargs):
"""通过 API Key 创建网站"""
try:
data = request.get_json() or {}
# 必填字段验证
name = data.get('name', '').strip()
url = data.get('url', '').strip()
if not name:
return jsonify({'success': False, 'message': '请提供网站名称'}), 400
if not url:
return jsonify({'success': False, 'message': '请提供网站 URL'}), 400
# 创建网站
site = Site()
site.name = name
site.url = url
site.slug = data.get('slug', '').strip() or None
site.logo = data.get('logo', '').strip() or None
site.short_desc = data.get('short_desc', '').strip() or None
site.description = data.get('description', '').strip() or None
site.features = data.get('features', '').strip() or None
site.news_keywords = data.get('news_keywords', '').strip() or None
site.is_active = data.get('is_active', True)
site.is_recommended = data.get('is_recommended', False)
site.sort_order = data.get('sort_order', 0)
# 处理标签
tags_data = data.get('tags', [])
if tags_data:
for tag_name in tags_data:
if isinstance(tag_name, str):
tag_name = tag_name.strip()
if tag_name:
# 查找或创建标签
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
# 创建新标签简化处理slug 从 name 生成)
import re
from pypinyin import lazy_pinyin
tag_slug = ''.join(lazy_pinyin(tag_name)).lower()
tag_slug = re.sub(r'[^\w\s-]', '', tag_slug)
tag_slug = re.sub(r'[-\s]+', '-', tag_slug).strip('-')[:50]
tag = Tag(name=tag_name, slug=tag_slug)
db.session.add(tag)
if tag not in site.tags:
site.tags.append(tag)
db.session.add(site)
db.session.commit()
return jsonify({
'success': True,
'site': site.to_dict()
}), 201
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/key/sites/<code>', methods=['GET'])
@require_api_key(['site:read'])
def get_site_via_api(code, **kwargs):
"""通过 API Key 获取单个网站信息"""
site = Site.query.filter_by(code=code).first()
if not site:
return jsonify({'success': False, 'message': '网站不存在'}), 404
return jsonify({
'success': True,
'site': site.to_dict()
})
@app.route('/api/key/sites/<code>', methods=['PUT'])
@require_api_key(['site:write'])
def update_site_via_api(code, **kwargs):
"""通过 API Key 更新网站"""
site = Site.query.filter_by(code=code).first()
if not site:
return jsonify({'success': False, 'message': '网站不存在'}), 404
try:
data = request.get_json() or {}
# 更新字段
if 'name' in data:
site.name = data['name'].strip()
if 'url' in data:
site.url = data['url'].strip()
if 'slug' in data:
site.slug = data['slug'].strip() if data['slug'] else None
if 'logo' in data:
site.logo = data['logo'].strip() if data['logo'] else None
if 'short_desc' in data:
site.short_desc = data['short_desc'].strip() if data['short_desc'] else None
if 'description' in data:
site.description = data['description'].strip() if data['description'] else None
if 'features' in data:
site.features = data['features'].strip() if data['features'] else None
if 'news_keywords' in data:
site.news_keywords = data['news_keywords'].strip() if data['news_keywords'] else None
if 'is_active' in data:
site.is_active = data['is_active']
if 'is_recommended' in data:
site.is_recommended = data['is_recommended']
if 'sort_order' in data:
site.sort_order = data['sort_order']
# 处理标签
if 'tags' in data:
site.tags = []
tags_data = data['tags']
if tags_data:
import re
from pypinyin import lazy_pinyin
for tag_name in tags_data:
if isinstance(tag_name, str):
tag_name = tag_name.strip()
if tag_name:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag_slug = ''.join(lazy_pinyin(tag_name)).lower()
tag_slug = re.sub(r'[^\w\s-]', '', tag_slug)
tag_slug = re.sub(r'[-\s]+', '-', tag_slug).strip('-')[:50]
tag = Tag(name=tag_name, slug=tag_slug)
db.session.add(tag)
if tag not in site.tags:
site.tags.append(tag)
db.session.commit()
return jsonify({
'success': True,
'site': site.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/key/sites/<code>', methods=['DELETE'])
@require_api_key(['site:write'])
def delete_site_via_api(code, **kwargs):
"""通过 API Key 删除网站"""
site = Site.query.filter_by(code=code).first()
if not site:
return jsonify({'success': False, 'message': '网站不存在'}), 404
try:
db.session.delete(site)
db.session.commit()
return jsonify({
'success': True,
'message': '网站已删除'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': str(e)}), 500
# ========== 社媒营销路由 (v2.5新增) ==========
@app.route('/api/generate-social-share', methods=['POST'])
def generate_social_share():
@@ -2857,6 +3080,55 @@ Sitemap: {}sitemap.xml
return self.render('admin/index.html', stats=stats, recent_sites=recent_sites)
# API Key 管理视图
class ApiKeyAdmin(SecureModelView):
can_edit = True
can_delete = True
can_create = True
can_view_details = False
can_set_page_size = True
page_size = 20
column_display_actions = True
column_list = ['id', 'name', 'key', 'permissions', 'is_active', 'usage_count', 'last_used', 'created_at']
column_default_sort = ('created_at', True)
column_searchable_list = ['name', 'key']
column_filters = ['is_active']
column_labels = {
'id': 'ID',
'name': '密钥名称',
'key': 'API Key',
'permissions': '权限',
'is_active': '是否启用',
'usage_count': '使用次数',
'last_used': '最后使用',
'created_at': '创建时间'
}
# 表单配置
form_columns = ['name', 'permissions', 'is_active']
form_extra_fields = {
'new_key': fields.StringField('新密钥(留空则自动生成)')
}
def on_model_change(self, form, model, is_created):
"""创建时自动生成 API Key"""
if is_created:
# 生成 64 位随机密钥
model.key = secrets.token_urlsafe(32) # 生成约 43 字符,改用 hex
import secrets as sec
model.key = sec.token_hex(32) # 64 位十六进制字符串
def _form_validate(self, form):
"""表单验证"""
return super()._form_validate(form)
# 自定义列表模板,隐藏 key 字段但在创建时显示
list_template = 'admin/apikey/list.html'
create_template = 'admin/apikey/create.html'
# 网站管理视图
class SiteAdmin(SecureModelView):
# 自定义模板
@@ -3154,6 +3426,7 @@ Sitemap: {}sitemap.xml
)
admin.add_view(SiteAdmin(Site, db.session, name='网站管理'))
admin.add_view(ApiKeyAdmin(ApiKey, db.session, name='API密钥', endpoint='api_keys'))
admin.add_view(TagAdmin(Tag, db.session, name='标签管理'))
admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))