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_login import LoginManager, login_user, logout_user, login_required, current_user
from flask_admin import Admin, AdminIndexView, expose from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView from flask_admin.contrib.sqla import ModelView
from wtforms import fields
from datetime import datetime, timedelta from datetime import datetime, timedelta
from config import config 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.website_fetcher import WebsiteFetcher
from utils.tag_generator import TagGenerator from utils.tag_generator import TagGenerator
from utils.news_searcher import NewsSearcher 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新增) ========== # ========== 社媒营销路由 (v2.5新增) ==========
@app.route('/api/generate-social-share', methods=['POST']) @app.route('/api/generate-social-share', methods=['POST'])
def generate_social_share(): def generate_social_share():
@@ -2857,6 +3080,55 @@ Sitemap: {}sitemap.xml
return self.render('admin/index.html', stats=stats, recent_sites=recent_sites) 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): class SiteAdmin(SecureModelView):
# 自定义模板 # 自定义模板
@@ -3154,6 +3426,7 @@ Sitemap: {}sitemap.xml
) )
admin.add_view(SiteAdmin(Site, db.session, name='网站管理')) 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(TagAdmin(Tag, db.session, name='标签管理'))
admin.add_view(NewsAdmin(News, db.session, name='新闻管理')) admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理')) admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))

View File

@@ -0,0 +1,27 @@
"""
API Keys 表迁移脚本
执行方式: python migrations/add_api_keys_table.py
"""
import os
import sys
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from models import ApiKey
def create_api_keys_table():
"""创建 api_keys 表"""
app = create_app('development')
with app.app_context():
# 直接创建表
db.create_all()
print('api_keys 表创建成功')
if __name__ == '__main__':
create_api_keys_table()

View File

@@ -155,6 +155,46 @@ class Admin(UserMixin, db.Model):
def __repr__(self): def __repr__(self):
return f'<Admin {self.username}>' return f'<Admin {self.username}>'
class ApiKey(db.Model):
"""API密钥模型"""
__tablename__ = 'api_keys'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64), unique=True, nullable=False, comment='API密钥')
name = db.Column(db.String(50), nullable=False, comment='密钥名称')
permissions = db.Column(db.String(200), default='site:read,site:write', comment='权限列表')
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
last_used = db.Column(db.DateTime, comment='最后使用时间')
usage_count = db.Column(db.Integer, default=0, comment='使用次数')
def __repr__(self):
return f'<ApiKey {self.name}>'
def has_permission(self, permission):
"""检查是否拥有指定权限"""
if not self.is_active:
return False
perms = self.permissions.split(',') if self.permissions else []
return permission.strip() in [p.strip() for p in perms]
def to_dict(self, include_key=False):
"""转换为字典"""
result = {
'id': self.id,
'name': self.name,
'permissions': self.permissions,
'is_active': self.is_active,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'last_used': self.last_used.strftime('%Y-%m-%d %H:%M:%S') if self.last_used else None,
'usage_count': self.usage_count
}
if include_key:
result['key'] = self.key
return result
class PromptTemplate(db.Model): class PromptTemplate(db.Model):
"""AI提示词模板模型""" """AI提示词模板模型"""
__tablename__ = 'prompt_templates' __tablename__ = 'prompt_templates'

View File

@@ -0,0 +1,15 @@
{% extends 'admin/model/create.html' %}
{% block tail %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// 创建成功后显示 API Key
{% if created_at and model and model.key %}
setTimeout(function() {
alert('API Key 已创建:\n\n{{ model.key }}\n\n请妥善保存此密钥只会显示一次');
}, 500);
{% endif %}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'admin/model/list.html' %}
{% block tail %}
{{ super() }}
<style>
.api-key-cell {
font-family: monospace;
font-size: 12px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.api-key-masked {
color: #6c757d;
}
</style>
{% endblock %}