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:
2026-06-28 00:28:32 +08:00
parent 05be2ddb53
commit e85b812f0d
3 changed files with 344 additions and 1 deletions

View File

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

View File

@@ -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">

View 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 %}