feat(api-potluck): 添加 API 大锅饭插件功能(彩蛋)
1. 新增 api-potluck 模块,支持 API 密钥认证和用量记录 2. 在 request-handler.js 中集成大锅饭路由和认证中间件 3. 在 common.js 中添加用量记录调用逻辑 4. 新增 potluck.html 静态页面和配置文件 api-potluck-keys.json
This commit is contained in:
parent
deb261d068
commit
dbc98dae74
8 changed files with 1068 additions and 4 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,4 +9,5 @@ input_system_prompt.txt
|
|||
token-store.json
|
||||
usage-cache.json
|
||||
*_oauth_creds.json
|
||||
*-auth-token.json
|
||||
*-auth-token.json
|
||||
api-potluck-keys.json
|
||||
277
src/api-potluck/api-routes.js
Normal file
277
src/api-potluck/api-routes.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* API 大锅饭 - 管理 API 路由
|
||||
* 提供 Key 管理的 RESTful API
|
||||
*/
|
||||
|
||||
import {
|
||||
createKey,
|
||||
listKeys,
|
||||
getKey,
|
||||
deleteKey,
|
||||
updateKeyLimit,
|
||||
resetKeyUsage,
|
||||
toggleKey,
|
||||
updateKeyName,
|
||||
getStats
|
||||
} from './key-manager.js';
|
||||
|
||||
/**
|
||||
* 解析请求体
|
||||
* @param {http.IncomingMessage} req
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
function parseRequestBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (error) {
|
||||
reject(new Error('Invalid JSON format'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
* @param {http.ServerResponse} res
|
||||
* @param {number} statusCode
|
||||
* @param {Object} data
|
||||
*/
|
||||
function sendJson(res, statusCode, data) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理员 Token
|
||||
* @param {http.IncomingMessage} req
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function checkAdminAuth(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 动态导入 ui-manager 中的 token 验证逻辑
|
||||
try {
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
const { promises: fs } = await import('fs');
|
||||
const path = await import('path');
|
||||
|
||||
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
||||
|
||||
if (!existsSync(TOKEN_STORE_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = readFileSync(TOKEN_STORE_FILE, 'utf8');
|
||||
const tokenStore = JSON.parse(content);
|
||||
const token = authHeader.substring(7);
|
||||
const tokenInfo = tokenStore.tokens[token];
|
||||
|
||||
if (!tokenInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > tokenInfo.expiryTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[API Potluck] Auth check error:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Potluck 管理 API 请求
|
||||
* @param {string} method - HTTP 方法
|
||||
* @param {string} path - 请求路径
|
||||
* @param {http.IncomingMessage} req - HTTP 请求对象
|
||||
* @param {http.ServerResponse} res - HTTP 响应对象
|
||||
* @returns {Promise<boolean>} - 是否处理了请求
|
||||
*/
|
||||
export async function handlePotluckApiRoutes(method, path, req, res) {
|
||||
// 只处理 /api/potluck 开头的请求
|
||||
if (!path.startsWith('/api/potluck')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证管理员权限
|
||||
const isAuthed = await checkAdminAuth(req);
|
||||
if (!isAuthed) {
|
||||
sendJson(res, 401, {
|
||||
success: false,
|
||||
error: { message: 'Unauthorized: Please login first', code: 'UNAUTHORIZED' }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// GET /api/potluck/stats - 获取统计信息
|
||||
if (method === 'GET' && path === '/api/potluck/stats') {
|
||||
const stats = await getStats();
|
||||
sendJson(res, 200, { success: true, data: stats });
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/potluck/keys - 获取所有 Key 列表
|
||||
if (method === 'GET' && path === '/api/potluck/keys') {
|
||||
const keys = await listKeys();
|
||||
const stats = await getStats();
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
keys,
|
||||
stats
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/potluck/keys - 创建新 Key
|
||||
if (method === 'POST' && path === '/api/potluck/keys') {
|
||||
const body = await parseRequestBody(req);
|
||||
const { name, dailyLimit } = body;
|
||||
const keyData = await createKey(name, dailyLimit);
|
||||
sendJson(res, 201, {
|
||||
success: true,
|
||||
message: 'API Key created successfully',
|
||||
data: keyData
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理带 keyId 的路由
|
||||
const keyIdMatch = path.match(/^\/api\/potluck\/keys\/([^\/]+)(\/.*)?$/);
|
||||
if (keyIdMatch) {
|
||||
const keyId = decodeURIComponent(keyIdMatch[1]);
|
||||
const subPath = keyIdMatch[2] || '';
|
||||
|
||||
// GET /api/potluck/keys/:keyId - 获取单个 Key 详情
|
||||
if (method === 'GET' && !subPath) {
|
||||
const keyData = await getKey(keyId);
|
||||
if (!keyData) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true, data: keyData });
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/potluck/keys/:keyId - 删除 Key
|
||||
if (method === 'DELETE' && !subPath) {
|
||||
const deleted = await deleteKey(keyId);
|
||||
if (!deleted) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true, message: 'Key deleted successfully' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// PUT /api/potluck/keys/:keyId/limit - 更新每日限额
|
||||
if (method === 'PUT' && subPath === '/limit') {
|
||||
const body = await parseRequestBody(req);
|
||||
const { dailyLimit } = body;
|
||||
|
||||
if (typeof dailyLimit !== 'number' || dailyLimit < 0) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
error: { message: 'Invalid dailyLimit value' }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const keyData = await updateKeyLimit(keyId, dailyLimit);
|
||||
if (!keyData) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
message: 'Daily limit updated successfully',
|
||||
data: keyData
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/potluck/keys/:keyId/reset - 重置当天调用次数
|
||||
if (method === 'POST' && subPath === '/reset') {
|
||||
const keyData = await resetKeyUsage(keyId);
|
||||
if (!keyData) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
message: 'Usage reset successfully',
|
||||
data: keyData
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态
|
||||
if (method === 'POST' && subPath === '/toggle') {
|
||||
const keyData = await toggleKey(keyId);
|
||||
if (!keyData) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
message: `Key ${keyData.enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
data: keyData
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// PUT /api/potluck/keys/:keyId/name - 更新 Key 名称
|
||||
if (method === 'PUT' && subPath === '/name') {
|
||||
const body = await parseRequestBody(req);
|
||||
const { name } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
error: { message: 'Invalid name value' }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const keyData = await updateKeyName(keyId, name);
|
||||
if (!keyData) {
|
||||
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
message: 'Name updated successfully',
|
||||
data: keyData
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 未匹配的 potluck 路由
|
||||
sendJson(res, 404, { success: false, error: { message: 'Potluck API endpoint not found' } });
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[API Potluck] API error:', error);
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
error: { message: error.message || 'Internal server error' }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
33
src/api-potluck/index.js
Normal file
33
src/api-potluck/index.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* API 大锅饭 - 模块入口
|
||||
* 导出所有功能供外部使用
|
||||
*/
|
||||
|
||||
// Key 管理
|
||||
export {
|
||||
createKey,
|
||||
listKeys,
|
||||
getKey,
|
||||
deleteKey,
|
||||
updateKeyLimit,
|
||||
resetKeyUsage,
|
||||
toggleKey,
|
||||
updateKeyName,
|
||||
validateKey,
|
||||
incrementUsage,
|
||||
getStats,
|
||||
KEY_PREFIX,
|
||||
DEFAULT_DAILY_LIMIT
|
||||
} from './key-manager.js';
|
||||
|
||||
// 中间件
|
||||
export {
|
||||
extractPotluckKey,
|
||||
isPotluckRequest,
|
||||
potluckAuthMiddleware,
|
||||
recordPotluckUsage,
|
||||
sendPotluckError
|
||||
} from './middleware.js';
|
||||
|
||||
// API 路由
|
||||
export { handlePotluckApiRoutes } from './api-routes.js';
|
||||
297
src/api-potluck/key-manager.js
Normal file
297
src/api-potluck/key-manager.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* API 大锅饭 - Key 管理模块
|
||||
* 使用内存缓存 + 写锁 + 定期持久化,解决并发安全问题
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// 配置
|
||||
const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json');
|
||||
const KEY_PREFIX = 'maki_';
|
||||
const DEFAULT_DAILY_LIMIT = 1000;
|
||||
const PERSIST_INTERVAL = 5000; // 5秒持久化一次
|
||||
|
||||
// 内存缓存
|
||||
let keyStore = null;
|
||||
let isDirty = false;
|
||||
let isWriting = false;
|
||||
let persistTimer = null;
|
||||
|
||||
/**
|
||||
* 初始化:从文件加载数据到内存
|
||||
*/
|
||||
function ensureLoaded() {
|
||||
if (keyStore !== null) return;
|
||||
try {
|
||||
if (existsSync(KEYS_STORE_FILE)) {
|
||||
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
|
||||
keyStore = JSON.parse(content);
|
||||
} else {
|
||||
keyStore = { keys: {} };
|
||||
syncWriteToFile();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[API Potluck] Failed to load key store:', error.message);
|
||||
keyStore = { keys: {} };
|
||||
}
|
||||
// 启动定期持久化
|
||||
if (!persistTimer) {
|
||||
persistTimer = setInterval(persistIfDirty, PERSIST_INTERVAL);
|
||||
// 进程退出时保存
|
||||
process.on('beforeExit', () => persistIfDirty());
|
||||
process.on('SIGINT', () => { persistIfDirty(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { persistIfDirty(); process.exit(0); });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入文件(仅初始化时使用)
|
||||
*/
|
||||
function syncWriteToFile() {
|
||||
try {
|
||||
const dir = path.dirname(KEYS_STORE_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
require('fs').mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('[API Potluck] Sync write failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步持久化(带写锁)
|
||||
*/
|
||||
async function persistIfDirty() {
|
||||
if (!isDirty || isWriting || keyStore === null) return;
|
||||
isWriting = true;
|
||||
try {
|
||||
const dir = path.dirname(KEYS_STORE_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
// 写入临时文件再重命名,防止写入中断导致文件损坏
|
||||
const tempFile = KEYS_STORE_FILE + '.tmp';
|
||||
await fs.writeFile(tempFile, JSON.stringify(keyStore, null, 2), 'utf8');
|
||||
await fs.rename(tempFile, KEYS_STORE_FILE);
|
||||
isDirty = false;
|
||||
} catch (error) {
|
||||
console.error('[API Potluck] Persist failed:', error.message);
|
||||
} finally {
|
||||
isWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记数据已修改
|
||||
*/
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今天的日期字符串 (YYYY-MM-DD)
|
||||
*/
|
||||
function getTodayDateString() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并重置过期的每日计数
|
||||
*/
|
||||
function checkAndResetDailyCount(keyData) {
|
||||
const today = getTodayDateString();
|
||||
if (keyData.lastResetDate !== today) {
|
||||
keyData.todayUsage = 0;
|
||||
keyData.lastResetDate = today;
|
||||
}
|
||||
return keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
*/
|
||||
export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) {
|
||||
ensureLoaded();
|
||||
const apiKey = generateApiKey();
|
||||
const now = new Date().toISOString();
|
||||
const today = getTodayDateString();
|
||||
|
||||
const keyData = {
|
||||
id: apiKey,
|
||||
name: name || `Key-${Object.keys(keyStore.keys).length + 1}`,
|
||||
createdAt: now,
|
||||
dailyLimit,
|
||||
todayUsage: 0,
|
||||
totalUsage: 0,
|
||||
lastResetDate: today,
|
||||
lastUsedAt: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
keyStore.keys[apiKey] = keyData;
|
||||
markDirty();
|
||||
await persistIfDirty(); // 创建操作立即持久化
|
||||
|
||||
console.log(`[API Potluck] Created key: ${apiKey.substring(0, 12)}...`);
|
||||
return keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Key 列表
|
||||
*/
|
||||
export async function listKeys() {
|
||||
ensureLoaded();
|
||||
const keys = [];
|
||||
for (const [keyId, keyData] of Object.entries(keyStore.keys)) {
|
||||
const updated = checkAndResetDailyCount({ ...keyData });
|
||||
keys.push({
|
||||
...updated,
|
||||
maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}`
|
||||
});
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 Key 详情
|
||||
*/
|
||||
export async function getKey(keyId) {
|
||||
ensureLoaded();
|
||||
const keyData = keyStore.keys[keyId];
|
||||
if (!keyData) return null;
|
||||
return checkAndResetDailyCount({ ...keyData });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Key
|
||||
*/
|
||||
export async function deleteKey(keyId) {
|
||||
ensureLoaded();
|
||||
if (!keyStore.keys[keyId]) return false;
|
||||
delete keyStore.keys[keyId];
|
||||
markDirty();
|
||||
await persistIfDirty(); // 删除操作立即持久化
|
||||
console.log(`[API Potluck] Deleted key: ${keyId.substring(0, 12)}...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Key 的每日限额
|
||||
*/
|
||||
export async function updateKeyLimit(keyId, newLimit) {
|
||||
ensureLoaded();
|
||||
if (!keyStore.keys[keyId]) return null;
|
||||
keyStore.keys[keyId].dailyLimit = newLimit;
|
||||
markDirty();
|
||||
return keyStore.keys[keyId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 Key 的当天调用次数
|
||||
*/
|
||||
export async function resetKeyUsage(keyId) {
|
||||
ensureLoaded();
|
||||
if (!keyStore.keys[keyId]) return null;
|
||||
keyStore.keys[keyId].todayUsage = 0;
|
||||
keyStore.keys[keyId].lastResetDate = getTodayDateString();
|
||||
markDirty();
|
||||
return keyStore.keys[keyId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 Key 的启用/禁用状态
|
||||
*/
|
||||
export async function toggleKey(keyId) {
|
||||
ensureLoaded();
|
||||
if (!keyStore.keys[keyId]) return null;
|
||||
keyStore.keys[keyId].enabled = !keyStore.keys[keyId].enabled;
|
||||
markDirty();
|
||||
return keyStore.keys[keyId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Key 名称
|
||||
*/
|
||||
export async function updateKeyName(keyId, newName) {
|
||||
ensureLoaded();
|
||||
if (!keyStore.keys[keyId]) return null;
|
||||
keyStore.keys[keyId].name = newName;
|
||||
markDirty();
|
||||
return keyStore.keys[keyId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key 是否有效且有配额
|
||||
*/
|
||||
export async function validateKey(apiKey) {
|
||||
ensureLoaded();
|
||||
if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) {
|
||||
return { valid: false, reason: 'invalid_format' };
|
||||
}
|
||||
const keyData = keyStore.keys[apiKey];
|
||||
if (!keyData) return { valid: false, reason: 'not_found' };
|
||||
if (!keyData.enabled) return { valid: false, reason: 'disabled' };
|
||||
|
||||
// 直接在内存中检查和重置
|
||||
checkAndResetDailyCount(keyData);
|
||||
if (keyData.todayUsage >= keyData.dailyLimit) {
|
||||
return { valid: false, reason: 'quota_exceeded', keyData };
|
||||
}
|
||||
return { valid: true, keyData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加 Key 的使用次数(原子操作,直接修改内存)
|
||||
*/
|
||||
export async function incrementUsage(apiKey) {
|
||||
ensureLoaded();
|
||||
const keyData = keyStore.keys[apiKey];
|
||||
if (!keyData) return null;
|
||||
|
||||
checkAndResetDailyCount(keyData);
|
||||
keyData.todayUsage += 1;
|
||||
keyData.totalUsage += 1;
|
||||
keyData.lastUsedAt = new Date().toISOString();
|
||||
markDirty();
|
||||
// 不立即持久化,由定时器批量写入
|
||||
return keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*/
|
||||
export async function getStats() {
|
||||
ensureLoaded();
|
||||
const keys = Object.values(keyStore.keys);
|
||||
let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
checkAndResetDailyCount(key);
|
||||
if (key.enabled) enabledKeys++;
|
||||
todayTotalUsage += key.todayUsage;
|
||||
totalUsage += key.totalUsage;
|
||||
}
|
||||
|
||||
return {
|
||||
totalKeys: keys.length,
|
||||
enabledKeys,
|
||||
disabledKeys: keys.length - enabledKeys,
|
||||
todayTotalUsage,
|
||||
totalUsage
|
||||
};
|
||||
}
|
||||
|
||||
// 导出常量
|
||||
export { KEY_PREFIX, DEFAULT_DAILY_LIMIT };
|
||||
152
src/api-potluck/middleware.js
Normal file
152
src/api-potluck/middleware.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* API 大锅饭 - 中间件模块
|
||||
* 负责请求拦截和配额检查
|
||||
*/
|
||||
|
||||
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
|
||||
|
||||
/**
|
||||
* 从请求中提取 Potluck API Key
|
||||
* 支持多种认证方式:
|
||||
* 1. Authorization: Bearer maki_xxx
|
||||
* 2. x-api-key: maki_xxx
|
||||
* 3. x-goog-api-key: maki_xxx
|
||||
* 4. URL query: ?key=maki_xxx
|
||||
*
|
||||
* @param {http.IncomingMessage} req - HTTP 请求对象
|
||||
* @param {URL} requestUrl - 解析后的 URL 对象
|
||||
* @returns {string|null} 提取到的 API Key,如果不是 potluck key 则返回 null
|
||||
*/
|
||||
export function extractPotluckKey(req, requestUrl) {
|
||||
// 1. 检查 Authorization header
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
if (token.startsWith(KEY_PREFIX)) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 x-api-key header (Claude style)
|
||||
const xApiKey = req.headers['x-api-key'];
|
||||
if (xApiKey && xApiKey.startsWith(KEY_PREFIX)) {
|
||||
return xApiKey;
|
||||
}
|
||||
|
||||
// 3. 检查 x-goog-api-key header (Gemini style)
|
||||
const googApiKey = req.headers['x-goog-api-key'];
|
||||
if (googApiKey && googApiKey.startsWith(KEY_PREFIX)) {
|
||||
return googApiKey;
|
||||
}
|
||||
|
||||
// 4. 检查 URL query parameter
|
||||
const queryKey = requestUrl.searchParams.get('key');
|
||||
if (queryKey && queryKey.startsWith(KEY_PREFIX)) {
|
||||
return queryKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求是否使用 Potluck Key
|
||||
* @param {http.IncomingMessage} req - HTTP 请求对象
|
||||
* @param {URL} requestUrl - 解析后的 URL 对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPotluckRequest(req, requestUrl) {
|
||||
return extractPotluckKey(req, requestUrl) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Potluck 认证中间件
|
||||
* 验证 Potluck API Key 并检查配额
|
||||
*
|
||||
* @param {http.IncomingMessage} req - HTTP 请求对象
|
||||
* @param {URL} requestUrl - 解析后的 URL 对象
|
||||
* @returns {Promise<{authorized: boolean, error?: Object, keyData?: Object, apiKey?: string}>}
|
||||
*/
|
||||
export async function potluckAuthMiddleware(req, requestUrl) {
|
||||
const apiKey = extractPotluckKey(req, requestUrl);
|
||||
|
||||
if (!apiKey) {
|
||||
// 不是 potluck 请求,返回 null 让原有逻辑处理
|
||||
return { authorized: null };
|
||||
}
|
||||
|
||||
// 验证 Key
|
||||
const validation = await validateKey(apiKey);
|
||||
|
||||
if (!validation.valid) {
|
||||
const errorMessages = {
|
||||
'invalid_format': 'Invalid API key format',
|
||||
'not_found': 'API key not found',
|
||||
'disabled': 'API key has been disabled',
|
||||
'quota_exceeded': 'Daily quota exceeded for this API key'
|
||||
};
|
||||
|
||||
const statusCodes = {
|
||||
'invalid_format': 401,
|
||||
'not_found': 401,
|
||||
'disabled': 403,
|
||||
'quota_exceeded': 429
|
||||
};
|
||||
|
||||
return {
|
||||
authorized: false,
|
||||
error: {
|
||||
statusCode: statusCodes[validation.reason] || 401,
|
||||
message: errorMessages[validation.reason] || 'Authentication failed',
|
||||
code: validation.reason,
|
||||
keyData: validation.keyData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: true,
|
||||
keyData: validation.keyData,
|
||||
apiKey: apiKey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Potluck 请求使用
|
||||
* 在请求成功处理后调用
|
||||
*
|
||||
* @param {string} apiKey - API Key
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function recordPotluckUsage(apiKey) {
|
||||
if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return incrementUsage(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Potluck 错误响应
|
||||
* @param {http.ServerResponse} res - HTTP 响应对象
|
||||
* @param {Object} error - 错误信息
|
||||
*/
|
||||
export function sendPotluckError(res, error) {
|
||||
const response = {
|
||||
error: {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
type: 'potluck_error'
|
||||
}
|
||||
};
|
||||
|
||||
// 如果是配额超限,添加额外信息
|
||||
if (error.code === 'quota_exceeded' && error.keyData) {
|
||||
response.error.quota = {
|
||||
used: error.keyData.todayUsage,
|
||||
limit: error.keyData.dailyLimit,
|
||||
resetDate: error.keyData.lastResetDate
|
||||
};
|
||||
}
|
||||
|
||||
res.writeHead(error.statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
}
|
||||
|
|
@ -490,6 +490,15 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
|
|||
} else {
|
||||
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName);
|
||||
}
|
||||
|
||||
// ============== API 大锅饭插件 - 开始 ==============
|
||||
if (CONFIG.potluckApiKey) {
|
||||
try {
|
||||
const { recordPotluckUsage } = await import('./api-potluck/index.js');
|
||||
await recordPotluckUsage(CONFIG.potluckApiKey);
|
||||
} catch (e) { /* 静默失败,不影响主流程 */ }
|
||||
}
|
||||
// ============== API 大锅饭插件 - 结束 ==============
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { MODEL_PROVIDER } from './common.js';
|
|||
import { PROMPT_LOG_FILENAME } from './config-manager.js';
|
||||
import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js';
|
||||
|
||||
// ============== API 大锅饭插件 - 开始 ==============
|
||||
import { handlePotluckApiRoutes, potluckAuthMiddleware, sendPotluckError } from './api-potluck/index.js';
|
||||
// ============== API 大锅饭插件 - 结束 ==============
|
||||
|
||||
/**
|
||||
* Parse request body as JSON
|
||||
*/
|
||||
|
|
@ -54,7 +58,8 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
}
|
||||
|
||||
// Serve static files for UI (除了登录页面需要认证)
|
||||
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html') {
|
||||
// ============== API 大锅饭插件: 添加 /potluck.html ==============
|
||||
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html' || path === '/potluck.html') {
|
||||
const served = await serveStaticFiles(path, res);
|
||||
if (served) return;
|
||||
}
|
||||
|
|
@ -62,6 +67,11 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||
if (uiHandled) return;
|
||||
|
||||
// ============== API 大锅饭插件 - 开始 ==============
|
||||
const potluckRouteHandled = await handlePotluckApiRoutes(method, path, req, res);
|
||||
if (potluckRouteHandled) return;
|
||||
// ============== API 大锅饭插件 - 结束 ==============
|
||||
|
||||
// Ollama show endpoint with model name
|
||||
if (method === 'POST' && path === '/ollama/api/show') {
|
||||
await handleOllamaShow(req, res);
|
||||
|
|
@ -144,7 +154,16 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
}
|
||||
|
||||
// Check authentication for API requests (before Ollama handling to allow unauthenticated Ollama endpoints)
|
||||
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
|
||||
// ============== API 大锅饭插件 - 开始 ==============
|
||||
const potluckAuth = await potluckAuthMiddleware(req, requestUrl);
|
||||
if (potluckAuth.authorized === false) {
|
||||
sendPotluckError(res, potluckAuth.error);
|
||||
return;
|
||||
} else if (potluckAuth.authorized === true) {
|
||||
currentConfig.potluckApiKey = potluckAuth.apiKey;
|
||||
console.log(`[API Potluck] Authorized with key: ${potluckAuth.apiKey.substring(0, 12)}...`);
|
||||
} else if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
|
||||
// ============== API 大锅饭插件 - 结束 ==============
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
return;
|
||||
|
|
@ -217,4 +236,4 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
276
static/potluck.html
Normal file
276
static/potluck.html
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 大锅饭 - Key 管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; color: #e0e0e0; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 20px 0; border-bottom: 1px solid #333; flex-wrap: wrap; gap: 10px; }
|
||||
h1 { font-size: 24px; color: #fff; display: flex; align-items: center; gap: 10px; }
|
||||
h1 span { font-size: 28px; }
|
||||
.stats-bar { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
|
||||
.stat-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 15px 25px; min-width: 150px; }
|
||||
.stat-card .label { font-size: 12px; color: #888; margin-bottom: 5px; }
|
||||
.stat-card .value { font-size: 24px; font-weight: bold; color: #4ade80; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
|
||||
.btn-primary { background: #4ade80; color: #000; font-weight: 600; }
|
||||
.btn-primary:hover { background: #22c55e; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.2); }
|
||||
.btn-danger { background: #ef4444; color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
.keys-section { margin-top: 30px; }
|
||||
.keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
|
||||
.keys-header h2 { font-size: 18px; color: #fff; }
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.search-box { padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; width: 200px; }
|
||||
.search-box:focus { outline: none; border-color: #4ade80; }
|
||||
.sort-select { padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; cursor: pointer; }
|
||||
.sort-select:focus { outline: none; border-color: #4ade80; }
|
||||
.keys-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.key-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
|
||||
.key-card.disabled { opacity: 0.5; }
|
||||
.key-info { flex: 1; min-width: 200px; }
|
||||
.key-name { font-size: 16px; font-weight: 600; color: #fff; margin-bottom: 5px; }
|
||||
.key-id { font-family: monospace; font-size: 13px; color: #888; display: flex; align-items: center; gap: 8px; }
|
||||
.btn-copy { background: none; border: none; cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 4px; transition: background 0.2s; }
|
||||
.btn-copy:hover { background: rgba(255,255,255,0.1); }
|
||||
.key-stats { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.key-stat { text-align: center; min-width: 80px; }
|
||||
.key-stat .label { font-size: 11px; color: #666; }
|
||||
.key-stat .value { font-size: 14px; font-weight: 600; }
|
||||
.key-stat .value.warning { color: #fbbf24; }
|
||||
.key-stat .value.danger { color: #ef4444; }
|
||||
.key-stat .value.muted { color: #666; font-size: 12px; }
|
||||
.key-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.progress-bar { width: 80px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; margin-top: 5px; }
|
||||
.progress-bar .fill { height: 100%; background: #4ade80; transition: width 0.3s; }
|
||||
.progress-bar .fill.warning { background: #fbbf24; }
|
||||
.progress-bar .fill.danger { background: #ef4444; }
|
||||
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000; }
|
||||
.modal.active { display: flex; }
|
||||
.modal-content { background: #1e293b; border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { color: #fff; font-size: 18px; }
|
||||
.modal-close { background: none; border: none; color: #888; font-size: 24px; cursor: pointer; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; margin-bottom: 5px; color: #888; font-size: 13px; }
|
||||
.form-group input { width: 100%; padding: 12px; border: 1px solid #333; border-radius: 8px; background: rgba(255,255,255,0.05); color: #fff; font-size: 14px; }
|
||||
.form-group input:focus { outline: none; border-color: #4ade80; }
|
||||
.key-display { background: #0f172a; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 14px; color: #4ade80; word-break: break-all; margin: 15px 0; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #666; }
|
||||
.empty-state p { margin-bottom: 20px; }
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 15px 25px; background: #1e293b; border-radius: 8px; color: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transform: translateY(100px); opacity: 0; transition: all 0.3s; z-index: 2000; }
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.success { border-left: 4px solid #4ade80; }
|
||||
.toast.error { border-left: 4px solid #ef4444; }
|
||||
.no-results { text-align: center; padding: 40px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1><span>🍲</span> API 大锅饭</h1>
|
||||
<button id="createKeyBtn" class="btn btn-primary">+ 生成新 Key</button>
|
||||
</header>
|
||||
<div class="stats-bar">
|
||||
<div class="stat-card"><div class="label">总 Key 数</div><div class="value" id="totalKeys">0</div></div>
|
||||
<div class="stat-card"><div class="label">已启用</div><div class="value" id="enabledKeys">0</div></div>
|
||||
<div class="stat-card"><div class="label">今日总调用</div><div class="value" id="todayUsage">0</div></div>
|
||||
<div class="stat-card"><div class="label">累计调用</div><div class="value" id="totalUsage">0</div></div>
|
||||
</div>
|
||||
<div class="keys-section">
|
||||
<div class="keys-header">
|
||||
<h2>Key 列表</h2>
|
||||
<div class="toolbar">
|
||||
<input type="text" id="searchBox" class="search-box" placeholder="搜索名称或 Key...">
|
||||
<select id="sortSelect" class="sort-select">
|
||||
<option value="name-asc">名称 A-Z</option>
|
||||
<option value="name-desc">名称 Z-A</option>
|
||||
<option value="usage-desc">今日用量 ↓</option>
|
||||
<option value="usage-asc">今日用量 ↑</option>
|
||||
<option value="total-desc">累计用量 ↓</option>
|
||||
<option value="lastUsed-desc">最近使用 ↓</option>
|
||||
<option value="created-desc">创建时间 ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="keysList" class="keys-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="createModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h3>生成新 API Key</h3><button class="modal-close" onclick="closeModal('createModal')">×</button></div>
|
||||
<div class="form-group"><label>Key 名称 (可选)</label><input type="text" id="keyName" placeholder="例如:测试用户1"></div>
|
||||
<div class="form-group"><label>每日调用限额</label><input type="number" id="keyLimit" value="1000" min="1"></div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="createKey()">生成 Key</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="showKeyModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h3>🎉 Key 已生成</h3><button class="modal-close" onclick="closeModal('showKeyModal')">×</button></div>
|
||||
<p style="color:#888;margin-bottom:10px">请妥善保存此 Key,关闭后将无法再次查看完整内容:</p>
|
||||
<div class="key-display" id="newKeyDisplay"></div>
|
||||
<button class="btn btn-secondary" style="width:100%" onclick="copyKey()">📋 复制 Key</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editLimitModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h3>修改每日限额</h3><button class="modal-close" onclick="closeModal('editLimitModal')">×</button></div>
|
||||
<div class="form-group"><label>新的每日限额</label><input type="number" id="newLimit" min="1"></div>
|
||||
<input type="hidden" id="editKeyId">
|
||||
<button class="btn btn-primary" style="width:100%" onclick="updateLimit()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
let currentNewKey = '';
|
||||
let allKeys = [];
|
||||
const API_BASE = '/api/potluck';
|
||||
function getToken() { return localStorage.getItem('authToken'); }
|
||||
async function apiRequest(url, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const data = await response.json();
|
||||
if (response.status === 401) { window.location.href = '/login.html'; return null; }
|
||||
return data;
|
||||
}
|
||||
async function loadData() {
|
||||
const result = await apiRequest(`${API_BASE}/keys`);
|
||||
if (!result || !result.success) { showToast('加载失败', 'error'); return; }
|
||||
const { keys, stats } = result.data;
|
||||
document.getElementById('totalKeys').textContent = stats.totalKeys;
|
||||
document.getElementById('enabledKeys').textContent = stats.enabledKeys;
|
||||
document.getElementById('todayUsage').textContent = stats.todayTotalUsage;
|
||||
document.getElementById('totalUsage').textContent = stats.totalUsage;
|
||||
allKeys = keys;
|
||||
applyFilterAndSort();
|
||||
}
|
||||
function applyFilterAndSort() {
|
||||
const searchTerm = document.getElementById('searchBox').value.toLowerCase().trim();
|
||||
const sortValue = document.getElementById('sortSelect').value;
|
||||
let filtered = allKeys;
|
||||
if (searchTerm) {
|
||||
filtered = allKeys.filter(k => k.name.toLowerCase().includes(searchTerm) || k.id.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
const [field, order] = sortValue.split('-');
|
||||
filtered.sort((a, b) => {
|
||||
let va, vb;
|
||||
if (field === 'name') { va = a.name.toLowerCase(); vb = b.name.toLowerCase(); }
|
||||
else if (field === 'usage') { va = a.todayUsage; vb = b.todayUsage; }
|
||||
else if (field === 'total') { va = a.totalUsage; vb = b.totalUsage; }
|
||||
else if (field === 'lastUsed') { va = a.lastUsedAt || ''; vb = b.lastUsedAt || ''; }
|
||||
else if (field === 'created') { va = a.createdAt || ''; vb = b.createdAt || ''; }
|
||||
if (va < vb) return order === 'asc' ? -1 : 1;
|
||||
if (va > vb) return order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
renderKeys(filtered);
|
||||
}
|
||||
function formatTime(isoStr) {
|
||||
if (!isoStr) return '从未';
|
||||
const d = new Date(isoStr);
|
||||
const now = new Date();
|
||||
const diff = now - d;
|
||||
if (diff < 60000) return '刚刚';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
|
||||
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前';
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
function renderKeys(keys) {
|
||||
const container = document.getElementById('keysList');
|
||||
if (allKeys.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><p>还没有任何 API Key</p><button class="btn btn-primary" onclick="openModal(\'createModal\')">生成第一个 Key</button></div>';
|
||||
return;
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
container.innerHTML = '<div class="no-results">没有匹配的结果</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = keys.map(key => {
|
||||
const usagePercent = key.dailyLimit > 0 ? (key.todayUsage / key.dailyLimit * 100) : 0;
|
||||
const progressClass = usagePercent >= 90 ? 'danger' : usagePercent >= 70 ? 'warning' : '';
|
||||
const valueClass = usagePercent >= 90 ? 'danger' : usagePercent >= 70 ? 'warning' : '';
|
||||
return `<div class="key-card ${key.enabled ? '' : 'disabled'}">
|
||||
<div class="key-info"><div class="key-name">${escapeHtml(key.name)}</div><div class="key-id">${key.maskedKey} <button class="btn-copy" onclick="copyToClipboard('${key.id}')" title="复制完整 Key">📋</button></div></div>
|
||||
<div class="key-stats">
|
||||
<div class="key-stat"><div class="label">今日/限额</div><div class="value ${valueClass}">${key.todayUsage}/${key.dailyLimit}</div><div class="progress-bar"><div class="fill ${progressClass}" style="width:${Math.min(usagePercent, 100)}%"></div></div></div>
|
||||
<div class="key-stat"><div class="label">累计</div><div class="value">${key.totalUsage}</div></div>
|
||||
<div class="key-stat"><div class="label">最后调用</div><div class="value muted">${formatTime(key.lastUsedAt)}</div></div>
|
||||
<div class="key-stat"><div class="label">状态</div><div class="value" style="color:${key.enabled ? '#4ade80' : '#ef4444'}">${key.enabled ? '启用' : '禁用'}</div></div>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="resetUsage('${key.id}')">重置</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="openEditLimit('${key.id}', ${key.dailyLimit})">限额</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="toggleKey('${key.id}')">${key.enabled ? '禁用' : '启用'}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteKey('${key.id}')">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
async function createKey() {
|
||||
const name = document.getElementById('keyName').value;
|
||||
const dailyLimit = parseInt(document.getElementById('keyLimit').value) || 1000;
|
||||
const result = await apiRequest(`${API_BASE}/keys`, { method: 'POST', body: JSON.stringify({ name, dailyLimit }) });
|
||||
if (result && result.success) {
|
||||
currentNewKey = result.data.id;
|
||||
document.getElementById('newKeyDisplay').textContent = currentNewKey;
|
||||
closeModal('createModal'); openModal('showKeyModal'); loadData();
|
||||
document.getElementById('keyName').value = ''; document.getElementById('keyLimit').value = '1000';
|
||||
} else { showToast(result?.error?.message || '创建失败', 'error'); }
|
||||
}
|
||||
function copyKey() { navigator.clipboard.writeText(currentNewKey).then(() => showToast('已复制到剪贴板', 'success')); }
|
||||
function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => showToast('Key 已复制', 'success')); }
|
||||
async function resetUsage(keyId) {
|
||||
if (!confirm('确定要重置该 Key 的今日调用次数吗?')) return;
|
||||
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/reset`, { method: 'POST' });
|
||||
if (result && result.success) { showToast('已重置', 'success'); loadData(); }
|
||||
else { showToast(result?.error?.message || '操作失败', 'error'); }
|
||||
}
|
||||
function openEditLimit(keyId, currentLimit) {
|
||||
document.getElementById('editKeyId').value = keyId;
|
||||
document.getElementById('newLimit').value = currentLimit;
|
||||
openModal('editLimitModal');
|
||||
}
|
||||
async function updateLimit() {
|
||||
const keyId = document.getElementById('editKeyId').value;
|
||||
const dailyLimit = parseInt(document.getElementById('newLimit').value);
|
||||
if (!dailyLimit || dailyLimit < 1) { showToast('请输入有效的限额', 'error'); return; }
|
||||
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/limit`, { method: 'PUT', body: JSON.stringify({ dailyLimit }) });
|
||||
if (result && result.success) { showToast('限额已更新', 'success'); closeModal('editLimitModal'); loadData(); }
|
||||
else { showToast(result?.error?.message || '操作失败', 'error'); }
|
||||
}
|
||||
async function toggleKey(keyId) {
|
||||
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/toggle`, { method: 'POST' });
|
||||
if (result && result.success) { showToast(result.message, 'success'); loadData(); }
|
||||
else { showToast(result?.error?.message || '操作失败', 'error'); }
|
||||
}
|
||||
async function deleteKey(keyId) {
|
||||
if (!confirm('确定要删除该 Key 吗?此操作不可恢复。')) return;
|
||||
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
|
||||
if (result && result.success) { showToast('已删除', 'success'); loadData(); }
|
||||
else { showToast(result?.error?.message || '删除失败', 'error'); }
|
||||
}
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message; toast.className = `toast ${type} show`;
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; }
|
||||
document.getElementById('createKeyBtn').addEventListener('click', () => openModal('createModal'));
|
||||
document.getElementById('searchBox').addEventListener('input', applyFilterAndSort);
|
||||
document.getElementById('sortSelect').addEventListener('change', applyFilterAndSort);
|
||||
if (!getToken()) { window.location.href = '/login.html'; } else { loadData(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue