feat: add external API service with API Key management

- POST /api/v1/prompt/optimize — core endpoint with X-API-Key auth
- Rate limiting: 20 requests per 60s per key
- API docs page: /api-docs with cURL/Python/JS examples
- Key management: /admin/apikeys — create, toggle, delete keys
- ApiKey model with usage tracking (request_count, last_used_at)
- Keys stored in DB with env var API_KEYS fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:22:01 +08:00
parent 83c541bb8e
commit 05be2ddb53
6 changed files with 956 additions and 1 deletions

View File

@@ -112,6 +112,10 @@ def create_app(config_class=None):
from src.flask_prompt_master.routes.expert_generate_6 import expert_generate_6_bp
app.register_blueprint(expert_generate_6_bp)
# 注册对外 API 服务(基于6号专家的提示词优化能力,含鉴权和限流)
from src.flask_prompt_master.routes.api_service import api_service_bp
app.register_blueprint(api_service_bp)
# 注册 Android 工程师专区(Crash 解读、依赖冲突分析等)
from src.flask_prompt_master.routes.android_tools import android_tools_bp
app.register_blueprint(android_tools_bp)

View File

@@ -165,4 +165,16 @@ class ResumeOptimization(db.Model):
optimized_content = db.Column(db.Text, nullable=False) # 优化后内容(Markdown)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User', backref='resume_optimizations')
user = db.relationship('User', backref='resume_optimizations')
class ApiKey(db.Model):
"""API 密钥管理表"""
__tablename__ = 'api_key'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64), unique=True, nullable=False, index=True)
label = db.Column(db.String(100), nullable=False) # 备注名(如:张三/前端团队)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_used_at = db.Column(db.DateTime, nullable=True)
request_count = db.Column(db.Integer, default=0) # 累计调用次数

View File

@@ -0,0 +1,479 @@
# -*- coding: utf-8 -*-
"""
对外 API 服务 — 基于6号专家的提示词优化能力
POST /api/v1/prompt/optimize
Header: X-API-Key: <key>
Body: {"input_text": "..."}
"""
import hashlib
import json
import logging
import os
import secrets
import time
from datetime import datetime
from functools import wraps
from flask import Blueprint, request, jsonify, render_template
from openai import OpenAI
logger = logging.getLogger(__name__)
api_service_bp = Blueprint('api_service', __name__)
# ===== API 密钥管理 =====
# 优先从数据库加载,环境变量 API_KEYS 作为补充
_env_keys = os.environ.get('API_KEYS', '')
_env_key_set = set(k.strip() for k in _env_keys.split(',') if k.strip())
def _get_active_keys():
"""获取所有活跃的 API 密钥(数据库 + 环境变量)"""
keys = set(_env_key_set)
try:
from src.flask_prompt_master.models.models import ApiKey
for k in ApiKey.query.filter_by(is_active=True).all():
keys.add(k.key)
except Exception as e:
logger.debug("从数据库加载 API 密钥失败: %s", e)
if not keys:
keys.add('sk-test-demo-key')
logger.warning("未配置任何 API 密钥,使用默认测试密钥: sk-test-demo-key")
return keys
def _record_usage(api_key):
"""记录 API 密钥使用"""
try:
from src.flask_prompt_master.models.models import ApiKey
from src.flask_prompt_master import db
k = ApiKey.query.filter_by(key=api_key, is_active=True).first()
if k:
k.request_count = (k.request_count or 0) + 1
k.last_used_at = datetime.utcnow()
db.session.commit()
except Exception:
pass
# ===== 限流 =====
_rate_window = 60 # 窗口(秒)
_rate_limit = 20 # 每窗口最大请求数
_rate_buckets = {} # {api_key: [(timestamp,), ...]}
# ===== LLM 客户端 =====
client = OpenAI(
api_key=os.environ.get('LLM_API_KEY') or 'sk-fdf7cc1c73504e628ec0119b7e11b8cc',
base_url=os.environ.get('LLM_API_URL') or 'https://api.deepseek.com/v1'
)
# 阶段1:消歧意图分析
INTENT_PROMPT_V4 = """你是一位资深的意图分析专家。你的任务是精确理解用户需求,尤其要处理中文多义词的歧义。
## 消歧规则(优先级从高到低)
### 规则1:"设计"的多义性
| 上下文 | 判定 |
|--------|------|
| 系统设计、架构设计、数据库设计、API设计、模块设计、技术方案设计 | **技术** |
| UI设计、海报设计、品牌设计、视觉设计、创意设计、艺术设计 | **创意** |
| 流程设计、组织设计、商业模式设计、制度设计 | **咨询** |
### 规则2:"方案"的多义性
| 上下文 | 判定 |
|--------|------|
| 技术方案、架构方案、实施方案、部署方案 | **技术** |
| 营销方案、活动方案、传播方案、内容方案 | **创意** |
| 管理方案、策略方案、优化方案、治理方案 | **咨询** |
### 规则3:"分析"的多义性
| 上下文 | 判定 |
|--------|------|
| 数据分析、日志分析、性能分析、安全分析 | **分析** |
| 需求分析、竞品分析、市场分析 | **咨询** |
| 色彩分析、构图分析、文案分析 | **创意** |
### 规则4:技术关键词强信号
看到以下词时,优先判为技术:
Agent、API、SDK、架构、微服务、数据库、后端、前端、部署、DevOps、容器化、分布式、协议、接口、中间件、算法、模型训练、推理、向量、RAG、Pipeline、CI/CD、Kubernetes、Docker、Git
## 判定流程
1. 提取核心名词+动词组合
2. 查询上述消歧表
3. 判断用户最终要产出什么
4. 给出判定 + 置信度
## 输出格式
严格返回以下JSON,不要任何额外内容:
{
"core_intent": "技术",
"sub_category": "多Agent协作系统设计",
"domain": "二级精确领域",
"confidence": 0.92,
"disambiguation_note": "判定依据简述",
"alternative_intent": "咨询",
"key_requirements": ["需求1", "需求2", "需求3"],
"expected_output": "精确的预期产出描述",
"constraints": ["约束1", "约束2"],
"keywords": ["关键词1", "关键词2", "关键词3"]
}
"""
# 阶段2:生成优化提示词
PROMPT_GENERATOR_TEMPLATES = {
"技术": """你是一位资深提示词工程师,专精于技术领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的技术方案。
意图分析:
{analysis}
请生成一个完整的提示词,必须包含以下要素:
1. **角色设定**:明确LLM扮演什么技术专家角色(架构师/开发者/技术顾问),给出具体的专业背景
2. **任务描述**:清晰陈述要完成的技术任务,拆解为2-4个可执行步骤
3. **输出结构**:定义输出的章节和每章要包含的具体内容(不要只列标题,要说明每章怎么写)
4. **质量标准**:给出具体的质量要求(如"每个API必须给出字段级规范"而非"详细描述")
5. **约束条件**:明确禁止什么、必须包含什么
关键原则:
- 你生成的是提示词,不是最终答案
- 提示词必须自包含——用户复制后可以直接在任何LLM中使用
- 使用具体的技术术语和可验证的标准
- 每个章节指引都应该是"如何写"而非"写什么"
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
"创意": """你是一位资深提示词工程师,专精于创意领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的创意方案。
意图分析:
{analysis}
请生成一个完整的提示词,必须包含以下要素:
1. **角色设定**:明确LLM扮演什么创意角色(创意总监/文案策划/品牌顾问),给出具体的风格偏好
2. **任务描述**:清晰陈述创意任务,包括目标受众、品牌调性、传播目标
3. **输出结构**:定义输出的章节和每章要包含的具体内容——给出框架但不能限制创意发挥
4. **质量标准**:给出具体的创意质量要求(如"每个视觉描述必须包含色彩/形状/情绪三个维度")
5. **灵感框架**:提供1-2个创作维度的引导,但不预设具体答案
关键原则:
- 你生成的是提示词,不是最终文案/设计稿
- 提示词必须引导LLM发挥创意,同时给出足够结构约束避免空泛
- 拒绝"高端大气上档次"这类空话——给出可触摸的质量标准
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
"分析": """你是一位资深提示词工程师,专精于数据分析领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的数据分析报告。
意图分析:
{analysis}
请生成一个完整的提示词,必须包含以下要素:
1. **角色设定**:明确LLM扮演什么分析角色(数据分析师/商业分析师/研究员),给出分析方法论倾向
2. **任务描述**:清晰陈述分析目标、数据范围、要回答的核心问题
3. **输出结构**:定义输出的章节——包括分析框架、指标定义、可视化建议、洞察和局限性
4. **质量标准**:给出具体的分析质量标准(如"所有结论必须附数据支撑""指标必须给出计算口径")
5. **方法论指引**:建议使用的分析思维(MECE/假设驱动/探索式),但不强制
关键原则:
- 你生成的是提示词,不是最终分析报告
- 提示词必须要求LLM展示推理过程,而非只给结论
- 要求LLM诚实标注数据局限性和置信度
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
"咨询": """你是一位资深提示词工程师,专精于管理咨询领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的咨询建议。
意图分析:
{analysis}
请生成一个完整的提示词,必须包含以下要素:
1. **角色设定**:明确LLM扮演什么咨询角色(管理顾问/策略顾问/组织顾问),给出咨询方法论倾向
2. **任务描述**:清晰陈述咨询问题、客户背景、决策情境
3. **输出结构**:定义输出的章节——诊断→方案矩阵→推荐→路线图→风险→ROI
4. **质量标准**:给出具体的咨询质量标准(如"方案必须可比较""风险必须有概率×影响评估")
5. **决策导向**:提示词应引导LLM最终输出可执行的决策建议,而非泛泛分析
关键原则:
- 你生成的是提示词,不是最终咨询报告
- 提示词必须要求LLM给出带权重的选项,而非单一建议
- 要求LLM明确标注假设前提
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。"""
}
def _check_rate_limit(api_key):
"""简单滑动窗口限流,返回 (是否允许, 剩余次数)"""
now = time.time()
window = _rate_window
limit = _rate_limit
bucket = _rate_buckets.setdefault(api_key, [])
# 清理过期记录
bucket[:] = [ts for ts in bucket if now - ts < window]
if len(bucket) >= limit:
return False, 0
bucket.append(now)
# 定期清理旧 bucket 防止内存泄漏
if len(_rate_buckets) > 1000:
_rate_buckets.clear()
return True, limit - len(bucket)
def require_api_key(f):
"""API Key 鉴权装饰器"""
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
if not api_key:
return jsonify({'code': 401, 'message': '缺少 X-API-Key,请提供 API 密钥', 'data': None}), 401
if api_key not in _get_active_keys():
return jsonify({'code': 403, 'message': '无效的 API 密钥', 'data': None}), 403
allowed, remaining = _check_rate_limit(api_key)
if not allowed:
return jsonify({
'code': 429,
'message': f'请求过于频繁,每{_rate_window}秒限{_rate_limit}次',
'data': None
}), 429
_record_usage(api_key)
resp = f(*args, **kwargs)
if isinstance(resp, tuple):
body, status = resp
else:
body, status = resp, 200
data = body.get_json() if hasattr(body, 'get_json') else body
if isinstance(data, dict):
data.setdefault('rate_limit', {
'remaining': remaining,
'limit': _rate_limit,
'window_seconds': _rate_window
})
return jsonify(data), status
return decorated
def _run_pipeline(input_text):
"""两阶段提示词优化管线,返回 (intent_data, optimized_prompt) 或抛出异常"""
# 阶段1
resp1 = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": INTENT_PROMPT_V4},
{"role": "user", "content": input_text}
],
temperature=0.05,
timeout=60
)
intent_raw = (resp1.choices[0].message.content or "").strip()
intent_raw = intent_raw.replace('```json', '').replace('```', '').strip()
intent_data = json.loads(intent_raw)
for f in ['core_intent', 'domain', 'key_requirements', 'expected_output', 'constraints', 'keywords']:
if f not in intent_data:
raise ValueError(f"缺少字段: {f}")
if intent_data['core_intent'] not in ('技术', '创意', '分析', '咨询'):
intent_data['core_intent'] = '技术'
for arr_f in ['key_requirements', 'constraints', 'keywords']:
v = intent_data.get(arr_f)
if not isinstance(v, list) or len(v) == 0:
intent_data[arr_f] = ['未指定']
intent_data.setdefault('sub_category', intent_data['domain'])
intent_data.setdefault('confidence', 0.75)
intent_data.setdefault('disambiguation_note', '')
intent_data.setdefault('alternative_intent', '')
# 阶段2
core_intent = intent_data['core_intent']
meta_prompt = PROMPT_GENERATOR_TEMPLATES[core_intent]
analysis_str = json.dumps(intent_data, ensure_ascii=False, indent=2)
resp2 = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": meta_prompt.format(analysis=analysis_str)},
{"role": "user", "content": f"用户需求:{input_text}\n\n请基于上述意图分析,生成一个高质量的提示词。"}
],
temperature=0.5,
max_tokens=1500,
timeout=90
)
optimized_prompt = (resp2.choices[0].message.content or "").strip()
if not optimized_prompt:
raise RuntimeError("提示词生成失败")
return intent_data, optimized_prompt
# ===== API 路由 =====
@api_service_bp.route('/api/v1/prompt/optimize', methods=['POST'])
@require_api_key
def optimize_prompt():
"""
优化提示词 API
POST /api/v1/prompt/optimize
Header: X-API-Key: <key>
Body: {"input_text": "需求描述或原始提示词"}
"""
try:
if not request.is_json:
return jsonify({'code': 400, 'message': '请求必须是 JSON 格式', 'data': None}), 400
payload = request.get_json() or {}
input_text = (payload.get('input_text') or '').strip()
if not input_text:
return jsonify({'code': 400, 'message': '缺少 input_text 字段', 'data': None}), 400
if len(input_text) < 10:
return jsonify({'code': 400, 'message': 'input_text 至少需要 10 个字符', 'data': None}), 400
intent_data, optimized_prompt = _run_pipeline(input_text)
return jsonify({
'code': 200,
'message': 'success',
'data': {
'intent_analysis': intent_data,
'optimized_prompt': optimized_prompt,
'usage': {
'api_calls': 2,
'prompt_length': len(optimized_prompt)
}
}
})
except json.JSONDecodeError:
logger.exception("API JSON解析失败")
return jsonify({'code': 500, 'message': '意图分析格式有误,请重试', 'data': None}), 500
except Exception as e:
logger.exception("API 生成失败")
return jsonify({'code': 500, 'message': str(e) or '服务内部错误', 'data': None}), 500
@api_service_bp.route('/api/v1/prompt/optimize', methods=['GET'])
def api_info():
"""API 信息"""
return jsonify({
'service': '提示词优化 API v1',
'endpoint': '/api/v1/prompt/optimize',
'method': 'POST',
'auth': 'Header: X-API-Key',
'body': {'input_text': '需求描述或原始提示词(至少10字符)'},
'response': {
'code': 200,
'data': {
'intent_analysis': '消歧分析结果',
'optimized_prompt': '优化后的提示词'
}
},
'rate_limit': f'每{_rate_window}秒 {_rate_limit} 次/密钥',
'docs': '/api-docs'
})
@api_service_bp.route('/api-docs', methods=['GET'])
def api_docs_page():
"""API 文档页面"""
return render_template('api_docs.html')
# ===== API Key 管理页面 =====
@api_service_bp.route('/admin/apikeys', methods=['GET'])
def apikey_manage_page():
"""API 密钥管理页面"""
return render_template('apikey_manage.html')
@api_service_bp.route('/api/admin/apikeys', methods=['GET'])
def apikey_list():
"""获取所有密钥"""
try:
from src.flask_prompt_master.models.models import ApiKey
keys = ApiKey.query.order_by(ApiKey.created_at.desc()).all()
return jsonify({
'code': 200,
'data': [{
'id': k.id,
'label': k.label,
'key': k.key[:8] + '...' + k.key[-4:], # 只展示部分
'is_active': k.is_active,
'created_at': k.created_at.strftime('%Y-%m-%d %H:%M') if k.created_at else '',
'last_used_at': k.last_used_at.strftime('%Y-%m-%d %H:%M') if k.last_used_at else '从未使用',
'request_count': k.request_count or 0
} for k in keys]
})
except Exception as e:
logger.exception("获取密钥列表失败")
return jsonify({'code': 500, 'message': str(e)})
@api_service_bp.route('/api/admin/apikeys', methods=['POST'])
def apikey_create():
"""创建新密钥"""
try:
from src.flask_prompt_master.models.models import ApiKey
from src.flask_prompt_master import db
payload = request.get_json() or {}
label = (payload.get('label') or '').strip() or '未备注'
# 生成随机 key
key = 'sk-' + secrets.token_hex(24)
k = ApiKey(key=key, label=label)
db.session.add(k)
db.session.commit()
logger.info("创建 API Key: %s (%s)", key[:12] + '...', label)
return jsonify({
'code': 200,
'message': '创建成功',
'data': {'id': k.id, 'label': k.label, 'key': key}
})
except Exception as e:
logger.exception("创建密钥失败")
return jsonify({'code': 500, 'message': str(e)})
@api_service_bp.route('/api/admin/apikeys/<int:key_id>/toggle', methods=['POST'])
def apikey_toggle(key_id):
"""启用/禁用密钥"""
try:
from src.flask_prompt_master.models.models import ApiKey
from src.flask_prompt_master import db
k = ApiKey.query.get(key_id)
if not k:
return jsonify({'code': 404, 'message': '密钥不存在'})
k.is_active = not k.is_active
db.session.commit()
action = '启用' if k.is_active else '禁用'
logger.info("%s API Key: %s (%s)", action, k.key[:12] + '...', k.label)
return jsonify({'code': 200, 'message': f'已{action}', 'data': {'is_active': k.is_active}})
except Exception as e:
return jsonify({'code': 500, 'message': str(e)})
@api_service_bp.route('/api/admin/apikeys/<int:key_id>', methods=['DELETE'])
def apikey_delete(key_id):
"""删除密钥"""
try:
from src.flask_prompt_master.models.models import ApiKey
from src.flask_prompt_master import db
k = ApiKey.query.get(key_id)
if not k:
return jsonify({'code': 404, 'message': '密钥不存在'})
db.session.delete(k)
db.session.commit()
logger.info("删除 API Key: %s", k.key[:12] + '...')
return jsonify({'code': 200, 'message': '已删除'})
except Exception as e:
return jsonify({'code': 500, 'message': str(e)})

View File

@@ -0,0 +1,199 @@
{% extends "base.html" %}
{% block title %}API 文档 — 提示词优化服务{% endblock %}
{% block content %}
<div class="docs-layout">
<div class="docs-container">
<div class="docs-header">
<h1><i class="fas fa-code"></i> 提示词优化 API</h1>
<p>基于6号专家的两阶段管线,将需求描述或粗糙提示词转化为高质量、可复用的优化提示词。</p>
</div>
<div class="docs-section">
<h2>接口信息</h2>
<div class="endpoint-card">
<div class="method-badge post">POST</div>
<code class="endpoint-url">/api/v1/prompt/optimize</code>
<p class="endpoint-desc">提交输入文本,返回消歧分析结果和优化后的提示词</p>
</div>
</div>
<div class="docs-section">
<h2>认证方式</h2>
<p>在请求头中携带 <code>X-API-Key</code>:</p>
<pre class="code-block"><code>X-API-Key: sk-your-api-key</code></pre>
<p>联系管理员获取 API 密钥。</p>
</div>
<div class="docs-section">
<h2>请求示例</h2>
<h3>cURL</h3>
<pre class="code-block"><code>curl -X POST {{ request.host_url }}api/v1/prompt/optimize \
-H "Content-Type: application/json" \
-H "X-API-Key: sk-your-api-key" \
-d '{"input_text": "写一个Python数据分析脚本,处理销售CSV并生成月度汇总报告"}'</code></pre>
<h3>Python</h3>
<pre class="code-block"><code>import requests
resp = requests.post(
"{{ request.host_url }}api/v1/prompt/optimize",
headers={
"Content-Type": "application/json",
"X-API-Key": "sk-your-api-key"
},
json={"input_text": "写一篇关于agent协助的设计方案"}
)
result = resp.json()
print(result["data"]["optimized_prompt"])</code></pre>
<h3>JavaScript</h3>
<pre class="code-block"><code>const resp = await fetch("{{ request.host_url }}api/v1/prompt/optimize", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "sk-your-api-key"
},
body: JSON.stringify({ input_text: "设计一个SaaS官网的UI/UX方案" })
});
const { data } = await resp.json();
console.log(data.optimized_prompt);</code></pre>
</div>
<div class="docs-section">
<h2>请求参数</h2>
<table class="param-table">
<thead>
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>input_text</td><td>string</td><td>是</td><td>需求描述或原始提示词,至少10个字符</td></tr>
</tbody>
</table>
</div>
<div class="docs-section">
<h2>响应格式</h2>
<pre class="code-block"><code>{
"code": 200,
"message": "success",
"data": {
"intent_analysis": {
"core_intent": "技术",
"sub_category": "Agent协作系统设计",
"domain": "人工智能与智能体系统",
"confidence": 0.95,
"disambiguation_note": "判定依据",
"alternative_intent": "咨询",
"key_requirements": ["需求1", "需求2"],
"expected_output": "预期产出描述",
"constraints": ["约束1"],
"keywords": ["agent", "协作", "设计方案"]
},
"optimized_prompt": "# 优化后的完整提示词...",
"usage": {
"api_calls": 2,
"prompt_length": 1785
}
},
"rate_limit": {
"remaining": 19,
"limit": 20,
"window_seconds": 60
}
}</code></pre>
<h3>字段说明</h3>
<table class="param-table">
<thead>
<tr><th>字段</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td><code>intent_analysis.core_intent</code></td><td>核心意图分类:技术 / 创意 / 分析 / 咨询</td></tr>
<tr><td><code>intent_analysis.confidence</code></td><td>置信度 0-1,≥0.85 为高置信</td></tr>
<tr><td><code>intent_analysis.disambiguation_note</code></td><td>消歧判定依据</td></tr>
<tr><td><code>optimized_prompt</code></td><td>优化后的提示词,可直接复制到任意 LLM 使用</td></tr>
<tr><td><code>usage.api_calls</code></td><td>本次消耗的 API 调用次数(固定 2)</td></tr>
</tbody>
</table>
</div>
<div class="docs-section">
<h2>限流</h2>
<p>每密钥每60秒最多 <strong>20</strong> 次请求。响应头中会返回限流状态:</p>
<pre class="code-block"><code>"rate_limit": {
"remaining": 19,
"limit": 20,
"window_seconds": 60
}</code></pre>
</div>
<div class="docs-section">
<h2>错误码</h2>
<table class="param-table">
<thead>
<tr><th>状态码</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>200</td><td>成功</td></tr>
<tr><td>400</td><td>参数错误(缺少 input_text / 长度不足 / 非 JSON)</td></tr>
<tr><td>401</td><td>缺少 X-API-Key</td></tr>
<tr><td>403</td><td>无效的 API 密钥</td></tr>
<tr><td>429</td><td>触发限流</td></tr>
<tr><td>500</td><td>服务内部错误</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.docs-layout { min-height: calc(100vh - 120px); padding: 2rem 1.5rem; background: #f8fafc; }
.docs-container { max-width: 860px; margin: 0 auto; }
.docs-header { margin-bottom: 2rem; }
.docs-header h1 { font-size: 2rem; font-weight: 700; display: flex; align-items: center; gap: 0.75rem; }
.docs-header h1 i { color: #06b6d4; }
.docs-header p { color: var(--text-light); font-size: 0.95rem; margin-top: 0.5rem; }
.docs-section { margin-bottom: 2rem; }
.docs-section h2 {
font-size: 1.2rem; font-weight: 600; margin-bottom: 1rem;
padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color);
}
.docs-section h3 { font-size: 0.95rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.endpoint-card {
background: white; border-radius: 10px; padding: 1.25rem;
border: 1px solid var(--border-color); display: flex; align-items: center; gap: 1rem;
}
.method-badge {
padding: 0.25rem 0.75rem; border-radius: 4px;
font-weight: 700; font-size: 0.8rem; color: white; white-space: nowrap;
}
.method-badge.post { background: #10b981; }
.endpoint-url { font-size: 1.1rem; font-weight: 600; color: var(--text-color); }
.endpoint-desc { color: var(--text-light); font-size: 0.85rem; margin: 0; }
.code-block {
background: #1e293b; color: #e2e8f0; border-radius: 10px; padding: 1rem 1.25rem;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.85rem;
line-height: 1.6; overflow-x: auto; white-space: pre;
}
.param-table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color); }
.param-table th { background: #f1f5f9; padding: 0.75rem 1rem; text-align: left; font-size: 0.85rem; font-weight: 600; }
.param-table td { padding: 0.75rem 1rem; border-top: 1px solid var(--border-color); font-size: 0.9rem; }
.param-table code { background: #f1f5f9; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
@media (max-width: 768px) {
.docs-layout { padding: 1rem; }
.endpoint-card { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
}
</style>
{% endblock %}

View File

@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}API Key 管理{% endblock %}
{% block content %}
<div class="layout">
<div class="container">
<div class="header">
<h1><i class="fas fa-key"></i> API Key 管理</h1>
<p>为外部调用者创建和管理 API 密钥</p>
</div>
<div class="toolbar">
<button class="btn-create" id="createBtn">
<i class="fas fa-plus"></i> 创建新密钥
</button>
<span class="hint">创建后将一次性展示完整密钥,请提醒对方保存</span>
</div>
<div id="keysTable" class="table-wrap">
<div class="loading-text">加载中...</div>
</div>
</div>
</div>
<!-- 创建弹窗 -->
<div class="modal-overlay" id="createModal" style="display:none;">
<div class="modal-card">
<h3>创建 API Key</h3>
<div class="form-group">
<label for="keyLabel">备注名</label>
<input type="text" id="keyLabel" placeholder="例如:张三、前端团队、测试环境">
</div>
<div class="modal-actions">
<button class="btn-cancel" id="modalCancel">取消</button>
<button class="btn-confirm" id="modalConfirm">创建</button>
</div>
</div>
</div>
<!-- 密钥展示弹窗 -->
<div class="modal-overlay" id="showModal" style="display:none;">
<div class="modal-card">
<h3><i class="fas fa-check-circle" style="color:#10b981;"></i> 密钥已创建</h3>
<p class="warn-text">以下密钥只展示一次,请立即复制</p>
<div class="key-display" id="newKeyDisplay"></div>
<div class="modal-actions">
<button class="btn-copy-key" id="copyKeyBtn"><i class="fas fa-copy"></i> 复制</button>
<button class="btn-cancel" id="showClose">关闭</button>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
{% endblock %}
{% block extra_css %}
<style>
.layout { min-height: calc(100vh - 120px); padding: 2rem 1.5rem; background: #f8fafc; }
.container { max-width: 860px; margin: 0 auto; }
.header { margin-bottom: 1.5rem; }
.header h1 { font-size: 1.6rem; font-weight: 700; display: flex; align-items: center; gap: 0.75rem; }
.header p { color: var(--text-light); margin-top: 0.25rem; }
.toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.btn-create {
padding: 0.6rem 1.25rem; border: none; border-radius: 8px;
background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
display: flex; align-items: center; gap: 0.5rem;
transition: transform 0.2s;
}
.btn-create:hover { transform: translateY(-1px); }
.hint { font-size: 0.8rem; color: var(--text-muted); }
.table-wrap { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.loading-text { text-align: center; padding: 3rem; color: var(--text-muted); }
table { width: 100%; border-collapse: collapse; }
th { background: #f1f5f9; padding: 0.75rem 1rem; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 0.75rem 1rem; border-top: 1px solid var(--border-color); font-size: 0.9rem; }
tr:hover td { background: #f8fafc; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 500; }
.badge-active { background: #d1fae5; color: #065f46; }
.badge-inactive { background: #fee2e2; color: #991b1b; }
.btn-sm {
padding: 0.25rem 0.6rem; border-radius: 6px; border: 1px solid var(--border-color);
background: white; cursor: pointer; font-size: 0.78rem; transition: all 0.2s;
}
.btn-sm:hover { background: #f1f5f9; }
.btn-sm.danger { color: #ef4444; border-color: #fecaca; }
.btn-sm.danger:hover { background: #fef2f2; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; display: flex; align-items: center; justify-content: center; }
.modal-card { background: white; border-radius: 14px; padding: 2rem; width: 420px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-card h3 { margin: 0 0 1rem 0; font-size: 1.1rem; }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.4rem; }
.form-group input {
width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.9rem;
}
.form-group input:focus { outline: none; border-color: #06b6d4; }
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
.btn-cancel { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--border-color); background: white; cursor: pointer; }
.btn-confirm { padding: 0.5rem 1.25rem; border-radius: 8px; border: none; background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white; font-weight: 600; cursor: pointer; }
.btn-confirm:disabled { opacity: 0.6; cursor: not-allowed; }
.warn-text { color: #f59e0b; font-weight: 500; font-size: 0.85rem; margin: 0 0 0.75rem 0; }
.key-display {
background: #1e293b; color: #10b981; border-radius: 10px; padding: 1rem;
font-family: 'SF Mono', 'Monaco', monospace; font-size: 0.95rem;
word-break: break-all; margin-bottom: 1rem;
}
.btn-copy-key {
padding: 0.5rem 1rem; border-radius: 8px; border: none;
background: #10b981; color: white; font-weight: 600; cursor: pointer;
display: flex; align-items: center; gap: 0.4rem;
}
.empty-row { text-align: center; padding: 3rem; color: var(--text-muted); }
.toast-container { position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; }
.toast { padding: 0.75rem 1.25rem; border-radius: 8px; color: white; font-size: 0.875rem; font-weight: 500; animation: slideIn 0.3s ease; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
{% endblock %}
{% block scripts %}
<script>
(function() {
var createBtn = document.getElementById('createBtn');
var createModal = document.getElementById('createModal');
var showModal = document.getElementById('showModal');
var createdKey = null;
function showToast(msg, type) {
var c = document.getElementById('toastContainer');
var t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
c.appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'all 0.3s ease'; setTimeout(function() { t.remove(); }, 300); }, 3000);
}
function loadKeys() {
fetch('/api/admin/apikeys')
.then(function(r) { return r.json(); })
.then(function(resp) {
var wrap = document.getElementById('keysTable');
if (resp.code !== 200) { wrap.innerHTML = '<div class="loading-text">加载失败</div>'; return; }
var keys = resp.data;
if (!keys.length) {
wrap.innerHTML = '<div class="empty-row"><i class="fas fa-inbox" style="font-size:2rem;display:block;margin-bottom:0.5rem;"></i>还没有密钥,点击上方按钮创建</div>';
return;
}
var html = '<table><thead><tr><th>备注</th><th>密钥</th><th>状态</th><th>调用次数</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr></thead><tbody>';
keys.forEach(function(k) {
html += '<tr>';
html += '<td><strong>' + escapeHtml(k.label) + '</strong></td>';
html += '<td><code>' + k.key + '</code></td>';
html += '<td><span class="badge ' + (k.is_active ? 'badge-active' : 'badge-inactive') + '">' + (k.is_active ? '启用' : '禁用') + '</span></td>';
html += '<td>' + k.request_count + '</td>';
html += '<td>' + k.last_used_at + '</td>';
html += '<td>' + k.created_at + '</td>';
html += '<td>';
html += '<button class="btn-sm" onclick="toggleKey(' + k.id + ')">' + (k.is_active ? '禁用' : '启用') + '</button> ';
html += '<button class="btn-sm danger" onclick="deleteKey(' + k.id + ')">删除</button>';
html += '</td></tr>';
});
html += '</tbody></table>';
wrap.innerHTML = html;
});
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Create modal
createBtn.addEventListener('click', function() {
document.getElementById('keyLabel').value = '';
createModal.style.display = '';
});
document.getElementById('modalCancel').addEventListener('click', function() {
createModal.style.display = 'none';
});
document.getElementById('modalConfirm').addEventListener('click', function() {
var label = document.getElementById('keyLabel').value.trim() || '未备注';
var btn = this;
btn.disabled = true;
btn.textContent = '创建中...';
fetch('/api/admin/apikeys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: label })
}).then(function(r) { return r.json(); })
.then(function(resp) {
btn.disabled = false;
btn.textContent = '创建';
if (resp.code === 200) {
createModal.style.display = 'none';
document.getElementById('newKeyDisplay').textContent = resp.data.key;
createdKey = resp.data;
showModal.style.display = '';
} else {
showToast(resp.message || '创建失败', 'error');
}
});
});
document.getElementById('showClose').addEventListener('click', function() {
showModal.style.display = 'none';
loadKeys();
});
// Copy key
document.getElementById('copyKeyBtn').addEventListener('click', function() {
if (!createdKey) return;
navigator.clipboard.writeText(createdKey.key).then(function() {
showToast('已复制到剪贴板', 'success');
});
});
// Toggle
window.toggleKey = function(id) {
fetch('/api/admin/apikeys/' + id + '/toggle', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(resp) {
if (resp.code === 200) { showToast(resp.message, 'success'); loadKeys(); }
else { showToast(resp.message, 'error'); }
});
};
// Delete
window.deleteKey = function(id) {
if (!confirm('确定删除此密钥?删除后立即失效。')) return;
fetch('/api/admin/apikeys/' + id, { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(resp) {
if (resp.code === 200) { showToast(resp.message, 'success'); loadKeys(); }
else { showToast(resp.message, 'error'); }
});
};
// Load on init
loadKeys();
})();
</script>
{% endblock %}

View File

@@ -17,6 +17,9 @@
<a href="{{ url_for('main.expert_generate') }}" class="btn-expert">
<i class="fas fa-brain"></i> 专家模式
</a>
<a href="/admin/apikeys" class="btn-expert" style="background:#64748b;">
<i class="fas fa-key"></i> API Key
</a>
</div>
</div>