feat: add self-service API key application page
- /api-key/apply — users can get a key by filling in name + optional email - Rate limited to 1 key per IP per minute - Key shown once, with usage instructions - Docs page updated with link to application Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -379,6 +379,62 @@ def api_docs_page():
|
||||
return render_template('api_docs.html')
|
||||
|
||||
|
||||
@api_service_bp.route('/api-key/apply', methods=['GET'])
|
||||
def apikey_apply_page():
|
||||
"""自助申请 API Key 页面"""
|
||||
return render_template('apikey_apply.html')
|
||||
|
||||
|
||||
@api_service_bp.route('/api/api-key/apply', methods=['POST'])
|
||||
def apikey_apply_api():
|
||||
"""自助申请 API Key(公开接口,有创建频率限制)"""
|
||||
# 独立的创建频率限制:每 IP 每分钟最多 1 次
|
||||
client_ip = request.remote_addr or 'unknown'
|
||||
create_key = f'create_{client_ip}'
|
||||
now = time.time()
|
||||
create_bucket = _rate_buckets.setdefault(create_key, [])
|
||||
create_bucket[:] = [ts for ts in create_bucket if now - ts < 60]
|
||||
if create_bucket:
|
||||
return jsonify({'code': 429, 'message': '每个IP每分钟只能申请1个密钥,请稍后再试'})
|
||||
create_bucket.append(now)
|
||||
|
||||
try:
|
||||
from src.flask_prompt_master.models.models import ApiKey
|
||||
from src.flask_prompt_master import db
|
||||
|
||||
payload = request.get_json() or {}
|
||||
name = (payload.get('name') or '').strip()
|
||||
email = (payload.get('email') or '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'code': 400, 'message': '请填写您的名称'})
|
||||
|
||||
label = name
|
||||
if email:
|
||||
label += f' ({email})'
|
||||
|
||||
# 生成 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 -> key=%s", label, key[:12] + '...')
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '申请成功',
|
||||
'data': {
|
||||
'key': key,
|
||||
'label': label,
|
||||
'docs_url': '/api-docs'
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("自助申请 API Key 失败")
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
# ===== API Key 管理页面 =====
|
||||
|
||||
@api_service_bp.route('/admin/apikeys', methods=['GET'])
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h2>获取 API Key</h2>
|
||||
<p style="margin-bottom:0.5rem;">前往<a href="/api-key/apply" style="color:#06b6d4;font-weight:600;">自助申请页面</a>,填写名称即可立即获取密钥,无需审核。</p>
|
||||
</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">
|
||||
|
||||
283
src/flask_prompt_master/templates/apikey_apply.html
Normal file
283
src/flask_prompt_master/templates/apikey_apply.html
Normal file
@@ -0,0 +1,283 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}申请 API Key — 提示词优化服务{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1><i class="fas fa-key"></i> 申请 API Key</h1>
|
||||
<p>免费申请,即时生效。用于调用提示词优化接口。</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step done">
|
||||
<div class="step-num">1</div>
|
||||
<span>填写信息</span>
|
||||
</div>
|
||||
<div class="step-connector" id="conn1"></div>
|
||||
<div class="step" id="step2">
|
||||
<div class="step-num">2</div>
|
||||
<span>获取密钥</span>
|
||||
</div>
|
||||
<div class="step-connector" id="conn2"></div>
|
||||
<div class="step" id="step3">
|
||||
<div class="step-num">3</div>
|
||||
<span>开始调用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="formArea">
|
||||
<form id="applyForm">
|
||||
<div class="form-group">
|
||||
<label for="name">名称 <span class="required">*</span></label>
|
||||
<input type="text" id="name" placeholder="例如:张三" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱 <span class="optional">(选填)</span></label>
|
||||
<input type="email" id="email" placeholder="用于接收服务通知">
|
||||
</div>
|
||||
<button type="submit" id="submitBtn">
|
||||
<i class="fas fa-bolt"></i> 立即申请
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="resultArea" style="display:none;">
|
||||
<div class="result-box">
|
||||
<h3><i class="fas fa-check-circle"></i> 申请成功</h3>
|
||||
<p class="warn">此密钥仅展示一次,请立即复制保存</p>
|
||||
|
||||
<div class="key-display" id="keyDisplay"></div>
|
||||
<button class="btn-copy" id="copyKeyBtn">
|
||||
<i class="fas fa-copy"></i> 复制密钥
|
||||
</button>
|
||||
|
||||
<div class="next-section">
|
||||
<h4>如何使用</h4>
|
||||
<pre class="code-block"><code>curl -X POST {{ request.host_url }}api/v1/prompt/optimize \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: <span class="placeholder">你的密钥</span>" \
|
||||
-d "{\"input_text\": \"写一个Python数据分析脚本\"}"</code></pre>
|
||||
<p class="next-link">详细文档 → <a href="/api-docs">/api-docs</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.layout {
|
||||
min-height: calc(100vh - 120px);
|
||||
padding: 3rem 1.5rem;
|
||||
background: linear-gradient(135deg, #f0fdfa 0%, #ecfeff 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.card-header { text-align: center; margin-bottom: 2rem; }
|
||||
.card-header h1 {
|
||||
font-size: 1.6rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
}
|
||||
.card-header h1 i { color: #06b6d4; }
|
||||
.card-header p { color: var(--text-light); margin-top: 0.25rem; font-size: 0.9rem; }
|
||||
|
||||
/* Steps */
|
||||
.steps {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 2rem; gap: 0;
|
||||
}
|
||||
.step {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
|
||||
opacity: 0.4; transition: opacity 0.5s;
|
||||
}
|
||||
.step.done, .step.current { opacity: 1; }
|
||||
.step-num {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: #e2e8f0; color: var(--text-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.85rem; transition: all 0.5s;
|
||||
}
|
||||
.step.done .step-num { background: #06b6d4; color: white; }
|
||||
.step.current .step-num { background: #06b6d4; color: white; box-shadow: 0 0 0 4px rgba(6,182,212,0.2); }
|
||||
.step span { font-size: 0.78rem; color: var(--text-secondary); font-weight: 500; }
|
||||
.step.done span, .step.current span { color: var(--text-color); }
|
||||
|
||||
.step-connector {
|
||||
width: 40px; height: 2px; background: #e2e8f0;
|
||||
margin: 0 0.25rem; transition: background 0.5s;
|
||||
}
|
||||
.step-connector.done { background: #06b6d4; }
|
||||
|
||||
/* Form */
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label {
|
||||
display: block; font-weight: 600; font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem; color: var(--text-color);
|
||||
}
|
||||
.required { color: #ef4444; }
|
||||
.optional { color: var(--text-muted); font-weight: 400; font-size: 0.78rem; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 0.7rem 0.85rem;
|
||||
border: 2px solid var(--border-color); border-radius: 10px;
|
||||
font-size: 0.95rem; transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: #06b6d4;
|
||||
box-shadow: 0 0 0 3px rgba(6,182,212,0.1);
|
||||
}
|
||||
button[type="submit"] {
|
||||
width: 100%; padding: 0.8rem;
|
||||
background: linear-gradient(135deg, #06b6d4, #3b82f6);
|
||||
color: white; border: none; border-radius: 10px;
|
||||
font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s; display: flex; align-items: center;
|
||||
justify-content: center; gap: 0.5rem;
|
||||
}
|
||||
button[type="submit"]:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(6,182,212,0.3); }
|
||||
button[type="submit"]:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* Result */
|
||||
.result-box {
|
||||
text-align: center;
|
||||
}
|
||||
.result-box h3 {
|
||||
font-size: 1.2rem; margin: 0 0 0.5rem 0;
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
}
|
||||
.result-box h3 i { color: #10b981; }
|
||||
.warn {
|
||||
color: #f59e0b; font-weight: 500; font-size: 0.85rem; margin: 0 0 1rem 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: 0.75rem;
|
||||
}
|
||||
.btn-copy {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.55rem 1.25rem; border-radius: 8px; border: none;
|
||||
background: #10b981; color: white; font-weight: 600; cursor: pointer;
|
||||
font-size: 0.9rem; margin-bottom: 2rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.btn-copy:hover { transform: translateY(-1px); }
|
||||
|
||||
.next-section {
|
||||
text-align: left;
|
||||
background: #f8fafc; border-radius: 10px; padding: 1.25rem;
|
||||
}
|
||||
.next-section h4 { font-size: 0.9rem; margin: 0 0 0.75rem 0; }
|
||||
|
||||
.code-block {
|
||||
background: #1e293b; color: #e2e8f0; border-radius: 8px;
|
||||
padding: 0.75rem 1rem; font-family: 'SF Mono', 'Monaco', monospace;
|
||||
font-size: 0.8rem; line-height: 1.6; overflow-x: auto;
|
||||
white-space: pre; margin-bottom: 0.75rem;
|
||||
}
|
||||
.code-block .placeholder { color: #f59e0b; }
|
||||
.next-link { font-size: 0.85rem; color: var(--text-light); margin: 0; }
|
||||
.next-link a { color: #06b6d4; font-weight: 500; }
|
||||
|
||||
/* Toast */
|
||||
.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.error { background: #ef4444; }
|
||||
.toast.success { background: #10b981; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout { padding: 1.5rem 1rem; }
|
||||
.card { padding: 1.5rem 1.25rem; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
var form = document.getElementById('applyForm');
|
||||
var formArea = document.getElementById('formArea');
|
||||
var resultArea = document.getElementById('resultArea');
|
||||
var submitBtn = document.getElementById('submitBtn');
|
||||
var step2 = document.getElementById('step2');
|
||||
var step3 = document.getElementById('step3');
|
||||
var conn1 = document.getElementById('conn1');
|
||||
var conn2 = document.getElementById('conn2');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var name = document.getElementById('name').value.trim();
|
||||
var email = document.getElementById('email').value.trim();
|
||||
if (!name) { showToast('请填写名称', 'error'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 申请中...';
|
||||
|
||||
fetch('/api/api-key/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-bolt"></i> 立即申请';
|
||||
|
||||
if (data.code === 200) {
|
||||
document.getElementById('keyDisplay').textContent = data.data.key;
|
||||
formArea.style.display = 'none';
|
||||
resultArea.style.display = '';
|
||||
|
||||
step2.classList.add('done');
|
||||
step3.classList.add('current');
|
||||
conn1.classList.add('done');
|
||||
conn2.classList.add('done');
|
||||
} else {
|
||||
showToast(data.message || '申请失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-bolt"></i> 立即申请';
|
||||
showToast('网络请求失败', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('copyKeyBtn').addEventListener('click', function() {
|
||||
var key = document.getElementById('keyDisplay').textContent;
|
||||
navigator.clipboard.writeText(key).then(function() {
|
||||
showToast('已复制到剪贴板', 'success');
|
||||
}).catch(function() {
|
||||
showToast('复制失败,请手动选择', 'error');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user