feat(api-potluck): 插件 - API 大锅饭 - 升级 V1.0.1

1. 新增用户凭证数据管理模块(user-data-manager.js),支持凭证关联、资源包计算和配置热更新
2. 实现资源包机制:每个健康凭证提供额外调用次数,支持有效期管理和自动过期清理
3. 新增系统配置API:支持动态调整默认限额、资源包次数和有效期
4. 新增批量操作API:批量应用限额和同步资源包状态到所有Key
5. 实现凭证健康检查:从主服务ProviderPoolManager同步凭证状态
6. 新增用户端API Key重置功能,支持数据自动迁移
7. 重构前端界面:采用GitHub风格深色主题,优化移动端响应式布局
8. 新增定时健康检查调度器,自动同步所有用户凭证状态
This commit is contained in:
leonai 2026-01-09 21:47:47 +08:00
parent bc4b0225e7
commit 7f6bf6f06b
10 changed files with 4185 additions and 1067 deletions

3
.gitignore vendored
View file

@ -11,4 +11,5 @@ token-store.json
usage-cache.json
*_oauth_creds.json
*-auth-token.json
api-potluck-keys.json
api-potluck-keys.json
api-potluck-data.json

View file

@ -0,0 +1,29 @@
{
"config": {
"defaultDailyLimit": 500,
"bonusPerCredential": 300,
"bonusValidityDays": 30,
"persistInterval": 5000
},
"users": {
"maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": {
"credentials": [
{
"id": "cred_0000000000000_xxxxxx",
"path": "configs/kiro/example_kiro-auth-token/example_kiro-auth-token.json",
"provider": "claude-kiro-oauth",
"authMethod": "refresh-token",
"addedAt": "2026-01-01T00:00:00.000Z"
}
],
"credentialBonuses": [
{
"credentialId": "cred_0000000000000_xxxxxx",
"grantedAt": "2026-01-01T00:00:00.000Z",
"usedCount": 0
}
],
"createdAt": "2026-01-01T00:00:00.000Z"
}
}
}

View file

@ -0,0 +1,16 @@
{
"keys": {
"maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": {
"id": "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"name": "示例用户",
"createdAt": "2026-01-01T00:00:00.000Z",
"dailyLimit": 500,
"todayUsage": 0,
"totalUsage": 0,
"lastResetDate": "2026-01-01",
"lastUsedAt": null,
"enabled": true,
"bonusRemaining": 0
}
}
}

View file

@ -12,16 +12,32 @@ import {
resetKeyUsage,
toggleKey,
updateKeyName,
regenerateKey,
getStats,
validateKey,
KEY_PREFIX
KEY_PREFIX,
setConfigGetter,
updateBonusRemaining,
applyDailyLimitToAllKeys,
getAllKeyIds
} from './key-manager.js';
import {
getUserCredentials,
addUserCredential,
migrateUserCredentials,
getAllUsersCredentials,
syncCredentialBonuses,
getBonusDetails,
getConfig,
updateConfig,
getAllUserApiKeys
} from './user-data-manager.js';
import path from 'path';
import { existsSync } from 'fs';
import { promises as fs } from 'fs';
import multer from 'multer';
import { batchImportKiroRefreshTokensStream, importAwsCredentials } from '../../oauth-handlers.js';
import { handleUploadOAuthCredentials } from '../../ui-manager.js';
import { autoLinkProviderConfigs } from '../../service-manager.js';
import { autoLinkProviderConfigs, getProviderPoolManager } from '../../service-manager.js';
import { CONFIG } from '../../config-manager.js';
/**
@ -138,16 +154,116 @@ export async function handlePotluckApiRoutes(method, path, req, res) {
if (method === 'GET' && path === '/api/potluck/keys') {
const keys = await listKeys();
const stats = await getStats();
const config = getConfig();
sendJson(res, 200, {
success: true,
data: {
keys,
stats
stats,
config
}
});
return true;
}
// GET /api/potluck/config - 获取配置
if (method === 'GET' && path === '/api/potluck/config') {
const config = getConfig();
sendJson(res, 200, {
success: true,
data: config
});
return true;
}
// PUT /api/potluck/config - 更新配置
if (method === 'PUT' && path === '/api/potluck/config') {
const body = await parseRequestBody(req);
const { defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval } = body;
// 验证参数
if (defaultDailyLimit !== undefined && (typeof defaultDailyLimit !== 'number' || defaultDailyLimit < 1)) {
sendJson(res, 400, { success: false, error: { message: 'defaultDailyLimit must be a positive number' } });
return true;
}
if (bonusPerCredential !== undefined && (typeof bonusPerCredential !== 'number' || bonusPerCredential < 0)) {
sendJson(res, 400, { success: false, error: { message: 'bonusPerCredential must be a non-negative number' } });
return true;
}
if (bonusValidityDays !== undefined && (typeof bonusValidityDays !== 'number' || bonusValidityDays < 1)) {
sendJson(res, 400, { success: false, error: { message: 'bonusValidityDays must be a positive number' } });
return true;
}
if (persistInterval !== undefined && (typeof persistInterval !== 'number' || persistInterval < 1000)) {
sendJson(res, 400, { success: false, error: { message: 'persistInterval must be at least 1000ms' } });
return true;
}
const newConfig = await updateConfig({ defaultDailyLimit, bonusPerCredential, bonusValidityDays, persistInterval });
sendJson(res, 200, {
success: true,
message: 'Config updated successfully',
data: newConfig
});
return true;
}
// POST /api/potluck/keys/apply-limit - 批量应用每日限额到所有 Key
if (method === 'POST' && path === '/api/potluck/keys/apply-limit') {
const config = getConfig();
const result = await applyDailyLimitToAllKeys(config.defaultDailyLimit);
sendJson(res, 200, {
success: true,
message: `已将每日限额 ${config.defaultDailyLimit} 应用到 ${result.updated}/${result.total} 个 Key`,
data: result
});
return true;
}
// POST /api/potluck/keys/apply-bonus - 批量同步所有用户的资源包
if (method === 'POST' && path === '/api/potluck/keys/apply-bonus') {
const allKeyIds = getAllKeyIds();
let totalSynced = 0;
let totalBonusUpdated = 0;
for (const apiKey of allKeyIds) {
try {
// 获取用户凭据并检查健康状态
const credentials = getUserCredentials(apiKey);
if (credentials.length === 0) continue;
// 构建带健康状态的凭证列表(从主服务同步)
const credentialsWithHealth = [];
for (const cred of credentials) {
const healthResult = await syncCredentialHealthFromPool(apiKey, cred);
credentialsWithHealth.push({
id: cred.id,
isHealthy: healthResult.isHealthy,
addedAt: cred.addedAt
});
}
// 同步资源包
const bonusSync = await syncCredentialBonuses(apiKey, credentialsWithHealth);
await updateBonusRemaining(apiKey, bonusSync.bonusRemaining);
totalSynced++;
if (bonusSync.added > 0 || bonusSync.removed > 0) {
totalBonusUpdated++;
}
} catch (error) {
console.warn(`[API Potluck] Failed to sync bonus for ${apiKey.substring(0, 12)}...:`, error.message);
}
}
sendJson(res, 200, {
success: true,
message: `已同步 ${totalSynced} 个用户的资源包,${totalBonusUpdated} 个有变更`,
data: { totalKeys: allKeyIds.length, synced: totalSynced, updated: totalBonusUpdated }
});
return true;
}
// POST /api/potluck/keys - 创建新 Key
if (method === 'POST' && path === '/api/potluck/keys') {
const body = await parseRequestBody(req);
@ -270,6 +386,25 @@ export async function handlePotluckApiRoutes(method, path, req, res) {
});
return true;
}
// POST /api/potluck/keys/:keyId/regenerate - 重新生成 Key
if (method === 'POST' && subPath === '/regenerate') {
const result = await regenerateKey(keyId);
if (!result) {
sendJson(res, 404, { success: false, error: { message: 'Key not found' } });
return true;
}
sendJson(res, 200, {
success: true,
message: 'Key regenerated successfully',
data: {
oldKey: result.oldKey,
newKey: result.newKey,
keyData: result.keyData
}
});
return true;
}
}
// 未匹配的 potluck 路由
@ -377,6 +512,11 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) {
? Math.round((keyData.todayUsage / keyData.dailyLimit) * 100)
: 0;
// 获取资源包详情
const bonusDetails = getBonusDetails(apiKey);
const bonusTotal = bonusDetails.bonuses.length * bonusDetails.bonusPerCredential;
const bonusUsed = bonusDetails.bonuses.reduce((sum, b) => sum + b.usedCount, 0);
// 返回用户友好的使用量信息(隐藏敏感信息)
sendJson(res, 200, {
success: true,
@ -390,6 +530,9 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) {
percent: usagePercent,
resetDate: keyData.lastResetDate
},
bonusRemaining: keyData.bonusRemaining || 0,
bonusTotal: bonusTotal,
bonusUsed: bonusUsed,
total: keyData.totalUsage,
lastUsedAt: keyData.lastUsedAt,
createdAt: keyData.createdAt,
@ -405,6 +548,31 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) {
return await handleUserUpload(req, res, apiKey);
}
// POST /api/potluckuser/regenerate-key - 用户重置自己的 API Key
if (method === 'POST' && path === '/api/potluckuser/regenerate-key') {
const result = await regenerateKey(apiKey);
if (!result) {
sendJson(res, 404, {
success: false,
error: { message: 'Key not found' }
});
return true;
}
// 同时迁移用户的凭据数据到新 Key
await migrateUserCredentials(apiKey, result.newKey);
sendJson(res, 200, {
success: true,
message: 'API Key regenerated successfully',
data: {
newKey: result.newKey,
maskedNewKey: `${result.newKey.substring(0, 12)}...${result.newKey.substring(result.newKey.length - 4)}`
}
});
return true;
}
// POST /api/potluckuser/kiro/batch-import-tokens - 批量导入 Kiro refresh token
if (method === 'POST' && path === '/api/potluckuser/kiro/batch-import-tokens') {
return await handleKiroBatchImportTokens(req, res, apiKey);
@ -415,6 +583,77 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) {
return await handleKiroImportAwsCredentials(req, res, apiKey);
}
// GET /api/potluckuser/credentials - 获取用户的凭据列表
if (method === 'GET' && path === '/api/potluckuser/credentials') {
const credentials = getUserCredentials(apiKey);
const bonusDetails = getBonusDetails(apiKey);
// 将资源包信息附加到对应凭证
const credentialsWithBonus = credentials.map(cred => {
const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id);
return {
...cred,
bonus: bonus ? {
usedCount: bonus.usedCount,
remaining: bonus.remaining,
total: bonusDetails.bonusPerCredential,
expiresAt: bonus.expiresAt
} : null
};
});
sendJson(res, 200, {
success: true,
data: credentialsWithBonus
});
return true;
}
// POST /api/potluckuser/credentials/check-all - 批量检查所有凭据健康状态
if (method === 'POST' && path === '/api/potluckuser/credentials/check-all') {
const results = await checkUserCredentialsHealth(apiKey);
const credentials = getUserCredentials(apiKey);
const bonusDetails = getBonusDetails(apiKey);
// 将资源包信息附加到对应凭证
const credentialsWithBonus = credentials.map(cred => {
const healthResult = results.find(r => r.id === cred.id);
const bonus = bonusDetails.bonuses.find(b => b.credentialId === cred.id);
return {
...cred,
isHealthy: healthResult?.isHealthy,
healthMessage: healthResult?.message,
bonus: bonus ? {
usedCount: bonus.usedCount,
remaining: bonus.remaining,
total: bonusDetails.bonusPerCredential,
expiresAt: bonus.expiresAt
} : null
};
});
sendJson(res, 200, {
success: true,
data: {
results,
credentials: credentialsWithBonus
}
});
return true;
}
// 处理凭据相关的路由
const credentialMatch = path.match(/^\/api\/potluckuser\/credentials\/([^\/]+)(\/.*)?$/);
if (credentialMatch) {
const credentialId = decodeURIComponent(credentialMatch[1]);
const subPath = credentialMatch[2] || '';
// POST /api/potluckuser/credentials/:id/health - 检查凭据健康状态
if (method === 'POST' && subPath === '/health') {
return await handleCredentialHealthCheck(req, res, apiKey, credentialId);
}
}
// 未匹配的用户端路由
sendJson(res, 404, {
success: false,
@ -483,56 +722,88 @@ const userUpload = multer({
});
/**
* 处理用户上传授权文件带自动绑定功能
* 处理用户上传授权文件带自动绑定和凭据关联功能
* @param {http.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {string} apiKey - 用户的 API Key
* @returns {Promise<boolean>}
*/
async function handleUserUpload(req, res, apiKey) {
// 创建一个包装的响应对象来捕获上传结果
let uploadResult = null;
const originalEnd = res.end.bind(res);
const originalWriteHead = res.writeHead.bind(res);
let statusCode = 200;
// 拦截响应以获取上传结果
res.writeHead = function(code, headers) {
statusCode = code;
return originalWriteHead(code, headers);
};
res.end = function(data) {
if (statusCode === 200 && data) {
try {
uploadResult = JSON.parse(data);
} catch (e) {
// 忽略解析错误
return new Promise((resolve) => {
userUpload.single('file')(req, res, async (err) => {
if (err) {
console.error('[API Potluck User] File upload error:', err.message);
sendJson(res, 400, { success: false, error: err.message });
resolve(true);
return;
}
}
return originalEnd(data);
};
// 执行文件上传
const handled = await handleUploadOAuthCredentials(req, res, {
providerMap: providerMap,
logPrefix: '[API Potluck User]',
userInfo: `user: ${apiKey.substring(0, 12)}...`,
customUpload: userUpload
if (!req.file) {
sendJson(res, 400, { success: false, error: 'No file uploaded' });
resolve(true);
return;
}
try {
const providerType = req.body?.provider || 'common';
const provider = providerMap[providerType] || providerType;
const tempFilePath = req.file.path;
// 根据 provider 确定目标目录
let targetDir = path.join(process.cwd(), 'configs', provider);
// kiro 类型需要子文件夹
if (provider === 'kiro') {
const timestamp = Date.now();
const originalNameWithoutExt = path.parse(req.file.originalname).name;
const subFolder = `${timestamp}_${originalNameWithoutExt}`;
targetDir = path.join(targetDir, subFolder);
}
await fs.mkdir(targetDir, { recursive: true });
const targetFilePath = path.join(targetDir, req.file.filename);
await fs.rename(tempFilePath, targetFilePath);
const relativePath = path.relative(process.cwd(), targetFilePath).replace(/\\/g, '/');
// 将凭据关联到用户
const credentialInfo = {
path: relativePath,
provider: providerType,
authMethod: 'file-upload'
};
const credential = await addUserCredential(apiKey, credentialInfo);
// 自动从主服务同步健康状态
const healthResult = await syncCredentialHealthFromPool(apiKey, credential);
// 触发自动绑定
try {
await autoLinkProviderConfigs(CONFIG);
} catch (linkError) {
console.warn('[API Potluck User] Auto-link failed:', linkError.message);
}
console.log(`[API Potluck User] File uploaded, linked and health checked: ${relativePath} (provider: ${providerType}, health: ${healthResult.message})`);
sendJson(res, 200, {
success: true,
message: 'File uploaded successfully',
filePath: relativePath,
originalName: req.file.originalname,
provider: provider,
health: healthResult
});
resolve(true);
} catch (error) {
console.error('[API Potluck User] File processing error:', error);
sendJson(res, 500, { success: false, error: error.message });
resolve(true);
}
});
});
// 如果上传成功,调用自动绑定功能扫描并绑定新上传的配置文件
if (uploadResult && uploadResult.success && uploadResult.filePath) {
try {
console.log(`[API Potluck User] Triggering auto-link for uploaded file: ${uploadResult.filePath}`);
await autoLinkProviderConfigs(CONFIG);
} catch (linkError) {
// 自动绑定失败不影响上传结果,只记录日志
console.warn(`[API Potluck User] Auto-link failed:`, linkError.message);
}
}
return handled;
}
/**
@ -577,9 +848,27 @@ async function handleKiroBatchImportTokens(req, res, apiKey) {
const result = await batchImportKiroRefreshTokensStream(
refreshTokens,
region || 'us-east-1',
(progress) => {
async (progress) => {
// 每处理完一个 token 发送进度更新
sendSSE('progress', progress);
// 成功的凭据关联到用户并执行健康检查
if (progress.current && progress.current.success && progress.current.path) {
try {
const credentialInfo = {
path: progress.current.path.replace(/\\/g, '/'),
provider: 'claude-kiro-oauth',
authMethod: 'refresh-token'
};
const credential = await addUserCredential(apiKey, credentialInfo);
// 自动从主服务同步健康状态
await syncCredentialHealthFromPool(apiKey, credential);
console.log(`[API Potluck User] Credential linked and health synced: ${credentialInfo.path}`);
} catch (linkError) {
console.warn('[API Potluck User] Failed to link/check credential:', linkError.message);
}
}
}
);
@ -653,10 +942,24 @@ async function handleKiroImportAwsCredentials(req, res, apiKey) {
if (result.success) {
console.log(`[API Potluck User] Successfully imported credentials to: ${result.path}`);
// 将凭据路径关联到用户
const credentialInfo = {
path: result.path,
provider: 'claude-kiro-oauth',
authMethod: credentials.authMethod || 'builder-id'
};
const credential = await addUserCredential(apiKey, credentialInfo);
// 自动从主服务同步健康状态
const healthResult = await syncCredentialHealthFromPool(apiKey, credential);
console.log(`[API Potluck User] Health sync result: ${healthResult.message}`);
sendJson(res, 200, {
success: true,
path: result.path,
message: 'AWS credentials imported successfully'
message: 'AWS credentials imported successfully',
health: healthResult
});
} else {
const statusCode = result.error === 'duplicate' ? 409 : 500;
@ -677,3 +980,222 @@ async function handleKiroImportAwsCredentials(req, res, apiKey) {
return true;
}
}
/**
* 从主服务同步凭据健康状态不触发实际检查不存储到本地
* @param {string} apiKey - 用户的 API Key保留参数以兼容调用
* @param {Object} credential - 凭据对象
* @returns {Promise<{isHealthy: boolean|null, message: string}>}
*/
async function syncCredentialHealthFromPool(apiKey, credential) {
const fullPath = path.join(process.cwd(), credential.path);
// 检查文件是否存在
if (!existsSync(fullPath)) {
return { isHealthy: false, message: '凭据文件不存在' };
}
// 从 ProviderPoolManager 获取该凭据对应的 provider 状态
const poolManager = getProviderPoolManager();
if (poolManager && credential.provider) {
// 在 providerStatus 中查找匹配的配置
const providerPool = poolManager.providerStatus[credential.provider];
if (providerPool && providerPool.length > 0) {
// 通过凭据路径匹配 provider 配置
const normalizedCredPath = credential.path.replace(/\\/g, '/');
const matchedProvider = providerPool.find(p => {
const configPath = p.config.kiroOAuthCredsFile || p.config.oauthCredsFile || '';
const normalizedConfigPath = configPath.replace(/\\/g, '/');
return normalizedConfigPath === normalizedCredPath ||
normalizedConfigPath.endsWith(normalizedCredPath) ||
normalizedCredPath.endsWith(normalizedConfigPath);
});
if (matchedProvider) {
const config = matchedProvider.config;
const isHealthy = config.isHealthy && !config.isDisabled;
let message = '健康检查:正常';
if (config.isDisabled) {
message = '已禁用';
} else if (!config.isHealthy) {
message = config.lastErrorMessage || '健康检查:异常';
}
return { isHealthy, message };
}
}
}
// 未在主服务中找到匹配的配置,检查文件有效性
try {
const content = await fs.readFile(fullPath, 'utf8');
const credData = JSON.parse(content);
// 检查 expiresAt 字段
if (credData.expiresAt) {
const expiresAt = new Date(credData.expiresAt);
const now = new Date();
if (expiresAt < now) {
return { isHealthy: false, message: '凭据已过期' };
}
}
// 文件存在且未过期,但未在主服务中注册
return { isHealthy: null, message: '未注册到服务' };
} catch (parseError) {
return { isHealthy: false, message: '凭据文件格式错误' };
}
}
/**
* 处理凭据健康检查
* @param {http.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {string} apiKey - 用户的 API Key
* @param {string} credentialId - 凭据 ID
*/
async function handleCredentialHealthCheck(req, res, apiKey, credentialId) {
try {
const credentials = getUserCredentials(apiKey);
const credential = credentials.find(c => c.id === credentialId);
if (!credential) {
sendJson(res, 404, {
success: false,
error: { message: 'Credential not found' }
});
return true;
}
console.log(`[API Potluck User] Syncing health for credential: ${credential.path}`);
const result = await syncCredentialHealthFromPool(apiKey, credential);
sendJson(res, 200, {
success: true,
data: result
});
return true;
} catch (error) {
console.error('[API Potluck User] Health check error:', error);
sendJson(res, 500, {
success: false,
error: error.message
});
return true;
}
}
// ============ 定时健康检查 ============
const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000; // 5 分钟
let healthCheckTimer = null;
/**
* 批量同步所有用户的凭据健康状态从主服务同步
* @returns {Promise<{total: number, checked: number, healthy: number, unhealthy: number}>}
*/
async function checkAllCredentialsHealth() {
const allUsers = getAllUsersCredentials();
let total = 0, checked = 0, healthy = 0, unhealthy = 0;
for (const { apiKey, credentials } of allUsers) {
for (const credential of credentials) {
total++;
try {
const result = await syncCredentialHealthFromPool(apiKey, credential);
checked++;
if (result.isHealthy) {
healthy++;
} else if (result.isHealthy === false) {
unhealthy++;
}
// isHealthy === null 表示未注册到服务,不计入健康/不健康
} catch (error) {
console.warn(`[API Potluck] Health sync failed for ${credential.path}:`, error.message);
}
}
}
return { total, checked, healthy, unhealthy };
}
/**
* 同步单个用户的所有凭据健康状态从主服务同步
* 同时更新资源包状态和 Key bonusRemaining
* @param {string} apiKey - 用户的 API Key
* @returns {Promise<Array<{id: string, isHealthy: boolean, message: string}>>}
*/
async function checkUserCredentialsHealth(apiKey) {
const credentials = getUserCredentials(apiKey);
const results = [];
for (const credential of credentials) {
try {
const result = await syncCredentialHealthFromPool(apiKey, credential);
results.push({
id: credential.id,
isHealthy: result.isHealthy,
message: result.message,
addedAt: credential.addedAt // 传递 addedAt 用于资源包初始化
});
} catch (error) {
results.push({
id: credential.id,
isHealthy: null,
message: '同步失败: ' + error.message,
addedAt: credential.addedAt
});
}
}
// 同步资源包状态并更新 Key 的 bonusRemaining
const bonusSync = await syncCredentialBonuses(apiKey, results);
await updateBonusRemaining(apiKey, bonusSync.bonusRemaining);
return results;
}
/**
* 启动定时健康检查
*/
export function startHealthCheckScheduler() {
if (healthCheckTimer) {
clearInterval(healthCheckTimer);
}
// 启动后延迟 30 秒执行第一次同步
setTimeout(async () => {
console.log('[API Potluck] Running initial health sync from pool...');
const result = await checkAllCredentialsHealth();
console.log(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`);
}, 30000);
// 定时同步
healthCheckTimer = setInterval(async () => {
console.log('[API Potluck] Running scheduled health sync from pool...');
const result = await checkAllCredentialsHealth();
console.log(`[API Potluck] Health sync complete: ${result.healthy}/${result.total} healthy`);
}, HEALTH_CHECK_INTERVAL);
console.log(`[API Potluck] Health sync scheduler started (interval: ${HEALTH_CHECK_INTERVAL / 1000}s)`);
}
/**
* 停止定时健康检查
*/
export function stopHealthCheckScheduler() {
if (healthCheckTimer) {
clearInterval(healthCheckTimer);
healthCheckTimer = null;
console.log('[API Potluck] Health sync scheduler stopped');
}
}
// 导出批量检查函数供 API 使用
export { checkUserCredentialsHealth };

View file

@ -21,7 +21,7 @@ import {
incrementUsage,
getStats,
KEY_PREFIX,
DEFAULT_DAILY_LIMIT
setConfigGetter
} from './key-manager.js';
import {
@ -30,14 +30,16 @@ import {
sendPotluckError
} from './middleware.js';
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js';
import { consumeBonus, getConfig } from './user-data-manager.js';
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes, startHealthCheckScheduler, stopHealthCheckScheduler } from './api-routes.js';
/**
* 插件定义
*/
const apiPotluckPlugin = {
name: 'api-potluck',
version: '1.0.0',
version: '1.0.1',
description: 'API 大锅饭 - Key 管理和用量统计插件',
// 插件类型:认证插件
@ -52,7 +54,10 @@ const apiPotluckPlugin = {
*/
async init(config) {
console.log('[API Potluck Plugin] Initializing...');
// 插件初始化逻辑(如果需要)
// 注入配置获取函数
setConfigGetter(getConfig);
// 启动定时健康检查
startHealthCheckScheduler();
},
/**
@ -60,7 +65,8 @@ const apiPotluckPlugin = {
*/
async destroy() {
console.log('[API Potluck Plugin] Destroying...');
// 清理逻辑(如果需要)
// 停止定时健康检查
stopHealthCheckScheduler();
},
/**
@ -108,7 +114,7 @@ const apiPotluckPlugin = {
'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'
'quota_exceeded': 'Quota exceeded for this API key'
};
const statusCodes = {
@ -153,7 +159,10 @@ const apiPotluckPlugin = {
async onContentGenerated(config) {
if (config.potluckApiKey) {
try {
await incrementUsage(config.potluckApiKey);
// 传入资源包消耗回调
await incrementUsage(config.potluckApiKey, async (apiKey) => {
await consumeBonus(apiKey);
});
} catch (e) {
// 静默失败,不影响主流程
console.error('[API Potluck Plugin] Failed to record usage:', e.message);
@ -176,7 +185,7 @@ const apiPotluckPlugin = {
incrementUsage,
getStats,
KEY_PREFIX,
DEFAULT_DAILY_LIMIT,
getConfig,
extractPotluckKey,
isPotluckRequest
}
@ -198,7 +207,7 @@ export {
incrementUsage,
getStats,
KEY_PREFIX,
DEFAULT_DAILY_LIMIT,
getConfig,
extractPotluckKey,
isPotluckRequest
};

View file

@ -8,17 +8,43 @@ 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秒持久化一次
// 默认配置(会被 user-data-manager 的配置覆盖)
const DEFAULT_CONFIG = {
defaultDailyLimit: 500,
persistInterval: 5000
};
// 配置获取函数(由外部注入)
let configGetter = null;
/**
* 设置配置获取函数
* @param {Function} getter - 返回配置对象的函数
*/
export function setConfigGetter(getter) {
configGetter = getter;
}
/**
* 获取当前配置
*/
function getConfig() {
if (configGetter) {
return configGetter();
}
return DEFAULT_CONFIG;
}
// 内存缓存
let keyStore = null;
let isDirty = false;
let isWriting = false;
let persistTimer = null;
let currentPersistInterval = DEFAULT_CONFIG.persistInterval;
/**
* 初始化从文件加载数据到内存
@ -29,6 +55,18 @@ function ensureLoaded() {
if (existsSync(KEYS_STORE_FILE)) {
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
keyStore = JSON.parse(content);
// 兼容历史数据:为旧 Key 添加 bonusRemaining 字段
let needsMigration = false;
for (const keyData of Object.values(keyStore.keys)) {
if (keyData.bonusRemaining === undefined) {
keyData.bonusRemaining = 0;
needsMigration = true;
}
}
if (needsMigration) {
console.log('[API Potluck] Migrated legacy keys: added bonusRemaining field');
markDirty();
}
} else {
keyStore = { keys: {} };
syncWriteToFile();
@ -37,9 +75,14 @@ function ensureLoaded() {
console.error('[API Potluck] Failed to load key store:', error.message);
keyStore = { keys: {} };
}
// 获取配置的持久化间隔
const config = getConfig();
currentPersistInterval = config.persistInterval || DEFAULT_CONFIG.persistInterval;
// 启动定期持久化
if (!persistTimer) {
persistTimer = setInterval(persistIfDirty, PERSIST_INTERVAL);
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
// 进程退出时保存
process.on('beforeExit', () => persistIfDirty());
process.on('SIGINT', () => { persistIfDirty(); process.exit(0); });
@ -93,10 +136,23 @@ function markDirty() {
}
/**
* 生成随机 API Key
* 生成随机 API Key确保不重复
*/
function generateApiKey() {
return `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
ensureLoaded();
let apiKey;
let attempts = 0;
const maxAttempts = 10;
do {
apiKey = `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
attempts++;
if (attempts >= maxAttempts) {
throw new Error('Failed to generate unique API key after multiple attempts');
}
} while (keyStore.keys[apiKey]);
return apiKey;
}
/**
@ -121,9 +177,14 @@ function checkAndResetDailyCount(keyData) {
/**
* 创建新的 API Key
* @param {string} name - Key 名称
* @param {number} [dailyLimit] - 每日限额不传则使用配置的默认值
*/
export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) {
export async function createKey(name = '', dailyLimit = null) {
ensureLoaded();
const config = getConfig();
const actualDailyLimit = dailyLimit ?? config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit;
const apiKey = generateApiKey();
const now = new Date().toISOString();
const today = getTodayDateString();
@ -132,12 +193,13 @@ export async function createKey(name = '', dailyLimit = DEFAULT_DAILY_LIMIT) {
id: apiKey,
name: name || `Key-${Object.keys(keyStore.keys).length + 1}`,
createdAt: now,
dailyLimit,
dailyLimit: actualDailyLimit,
todayUsage: 0,
totalUsage: 0,
lastResetDate: today,
lastUsedAt: null,
enabled: true
enabled: true,
bonusRemaining: 0 // 剩余资源包总次数(由同步检查更新)
};
keyStore.keys[apiKey] = keyData;
@ -233,7 +295,44 @@ export async function updateKeyName(keyId, newName) {
}
/**
* 验证 API Key 是否有效且有配额
* 重新生成 API Key保留原有数据更换 Key ID
* @param {string} oldKeyId - Key ID
* @returns {Promise<{oldKey: string, newKey: string, keyData: Object}|null>}
*/
export async function regenerateKey(oldKeyId) {
ensureLoaded();
const oldKeyData = keyStore.keys[oldKeyId];
if (!oldKeyData) return null;
// 生成新的唯一 Key
const newKeyId = generateApiKey();
// 复制数据到新 Key
const newKeyData = {
...oldKeyData,
id: newKeyId,
regeneratedAt: new Date().toISOString(),
regeneratedFrom: oldKeyId.substring(0, 12) + '...'
};
// 删除旧 Key添加新 Key
delete keyStore.keys[oldKeyId];
keyStore.keys[newKeyId] = newKeyData;
markDirty();
await persistIfDirty(); // 立即持久化
console.log(`[API Potluck] Regenerated key: ${oldKeyId.substring(0, 12)}... -> ${newKeyId.substring(0, 12)}...`);
return {
oldKey: oldKeyId,
newKey: newKeyId,
keyData: newKeyData
};
}
/**
* 验证 API Key 是否有效且有配额每日限额 + 资源包
*/
export async function validateKey(apiKey) {
ensureLoaded();
@ -246,27 +345,65 @@ export async function validateKey(apiKey) {
// 直接在内存中检查和重置
checkAndResetDailyCount(keyData);
if (keyData.todayUsage >= keyData.dailyLimit) {
return { valid: false, reason: 'quota_exceeded', keyData };
// 检查每日限额
if (keyData.todayUsage < keyData.dailyLimit) {
return { valid: true, keyData, useBonus: false };
}
return { valid: true, keyData };
// 每日限额用尽,检查资源包
const bonusRemaining = keyData.bonusRemaining || 0;
if (bonusRemaining > 0) {
return { valid: true, keyData, useBonus: true, bonusRemaining };
}
return { valid: false, reason: 'quota_exceeded', keyData };
}
/**
* 增加 Key 的使用次数原子操作直接修改内存
* 优先消耗每日限额用尽后消耗资源包
* @param {string} apiKey - API Key
* @param {Function} [onBonusUsed] - 资源包消耗回调用于更新 data 中的 usedCount
*/
export async function incrementUsage(apiKey) {
export async function incrementUsage(apiKey, onBonusUsed = null) {
ensureLoaded();
const keyData = keyStore.keys[apiKey];
if (!keyData) return null;
checkAndResetDailyCount(keyData);
keyData.todayUsage += 1;
let usedBonus = false;
// 优先消耗每日限额
if (keyData.todayUsage < keyData.dailyLimit) {
keyData.todayUsage += 1;
} else {
// 每日限额用尽,消耗资源包
const bonusRemaining = keyData.bonusRemaining || 0;
if (bonusRemaining > 0) {
keyData.bonusRemaining = bonusRemaining - 1;
usedBonus = true;
// 触发回调更新 data 中的 usedCount
if (onBonusUsed) {
await onBonusUsed(apiKey);
}
} else {
// 无可用配额
return null;
}
}
keyData.totalUsage += 1;
keyData.lastUsedAt = new Date().toISOString();
markDirty();
// 不立即持久化,由定时器批量写入
return keyData;
return {
...keyData,
usedBonus
};
}
/**
@ -293,5 +430,79 @@ export async function getStats() {
};
}
// ============ 凭证资源包管理 ============
/**
* 更新 Key 的剩余资源包次数由同步检查调用
* @param {string} keyId - Key ID
* @param {number} bonusRemaining - 剩余资源包总次数
* @returns {Promise<boolean>}
*/
export async function updateBonusRemaining(keyId, bonusRemaining) {
ensureLoaded();
const keyData = keyStore.keys[keyId];
if (!keyData) return false;
keyData.bonusRemaining = Math.max(0, bonusRemaining);
markDirty();
return true;
}
/**
* 获取 Key 的资源包信息
* @param {string} keyId - Key ID
* @param {Function} getConfigFn - 获取配置的函数 user-data-manager 传入
* @returns {Promise<Object|null>}
*/
export async function getBonusInfo(keyId, getConfigFn = null) {
ensureLoaded();
const keyData = keyStore.keys[keyId];
if (!keyData) return null;
// 从 user-data-manager 获取配置
const config = getConfigFn ? getConfigFn() : { bonusPerCredential: 300, bonusValidityDays: 30 };
return {
bonusRemaining: keyData.bonusRemaining || 0,
bonusPerCredential: config.bonusPerCredential,
validityDays: config.bonusValidityDays
};
}
/**
* 批量更新所有 Key 的每日限额
* @param {number} newLimit - 新的每日限额
* @returns {Promise<{total: number, updated: number}>}
*/
export async function applyDailyLimitToAllKeys(newLimit) {
ensureLoaded();
const keys = Object.values(keyStore.keys);
let updated = 0;
for (const keyData of keys) {
if (keyData.dailyLimit !== newLimit) {
keyData.dailyLimit = newLimit;
updated++;
}
}
if (updated > 0) {
markDirty();
await persistIfDirty();
}
console.log(`[API Potluck] Applied daily limit ${newLimit} to ${updated}/${keys.length} keys`);
return { total: keys.length, updated };
}
/**
* 获取所有 Key ID 列表
* @returns {string[]}
*/
export function getAllKeyIds() {
ensureLoaded();
return Object.keys(keyStore.keys);
}
// 导出常量
export { KEY_PREFIX, DEFAULT_DAILY_LIMIT };
export { KEY_PREFIX };

View file

@ -82,7 +82,7 @@ export async function potluckAuthMiddleware(req, requestUrl) {
'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'
'quota_exceeded': 'Quota exceeded for this API key'
};
const statusCodes = {

View file

@ -0,0 +1,721 @@
/**
* API 大锅饭 - 用户数据管理模块
* 管理用户关联的凭据文件路径和资源包
* 使用 Mutex 解决并发问题
*/
import { promises as fs } from 'fs';
import { existsSync, readFileSync, writeFileSync, watch } from 'fs';
import path from 'path';
// 配置文件路径
const USER_DATA_FILE = path.join(process.cwd(), 'configs', 'api-potluck-data.json');
// 默认配置值
const DEFAULT_CONFIG = {
defaultDailyLimit: 500,
bonusPerCredential: 300,
bonusValidityDays: 30,
persistInterval: 5000
};
// 内存缓存
let userDataStore = null;
let isDirty = false;
let isWriting = false;
let persistTimer = null;
let fileWatcher = null;
let currentPersistInterval = DEFAULT_CONFIG.persistInterval;
// ============ 简易 Mutex 实现 ============
class SimpleMutex {
constructor() {
this._locked = false;
this._waiting = [];
}
async acquire() {
return new Promise((resolve) => {
if (!this._locked) {
this._locked = true;
resolve();
} else {
this._waiting.push(resolve);
}
});
}
release() {
if (this._waiting.length > 0) {
const next = this._waiting.shift();
next();
} else {
this._locked = false;
}
}
async runExclusive(fn) {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}
// 全局锁:用于资源包消耗操作
const bonusMutex = new SimpleMutex();
// ============ 配置管理 ============
/**
* 获取完整配置支持热更新
*/
function getFullConfig() {
ensureLoaded();
const config = userDataStore.config || {};
return {
defaultDailyLimit: config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit,
bonusPerCredential: config.bonusPerCredential ?? DEFAULT_CONFIG.bonusPerCredential,
bonusValidityDays: config.bonusValidityDays ?? DEFAULT_CONFIG.bonusValidityDays,
persistInterval: config.persistInterval ?? DEFAULT_CONFIG.persistInterval
};
}
/**
* 获取资源包配置兼容旧接口
*/
function getBonusConfig() {
const config = getFullConfig();
return {
bonusPerCredential: config.bonusPerCredential,
bonusValidityDays: config.bonusValidityDays
};
}
/**
* 更新配置
* @param {Object} newConfig - 新配置
* @returns {Object} 更新后的完整配置
*/
export async function updateConfig(newConfig) {
ensureLoaded();
if (!userDataStore.config) {
userDataStore.config = {};
}
// 验证并更新各配置项
if (typeof newConfig.defaultDailyLimit === 'number' && newConfig.defaultDailyLimit > 0) {
userDataStore.config.defaultDailyLimit = newConfig.defaultDailyLimit;
}
if (typeof newConfig.bonusPerCredential === 'number' && newConfig.bonusPerCredential >= 0) {
userDataStore.config.bonusPerCredential = newConfig.bonusPerCredential;
}
if (typeof newConfig.bonusValidityDays === 'number' && newConfig.bonusValidityDays > 0) {
userDataStore.config.bonusValidityDays = newConfig.bonusValidityDays;
}
if (typeof newConfig.persistInterval === 'number' && newConfig.persistInterval >= 1000) {
userDataStore.config.persistInterval = newConfig.persistInterval;
// 更新持久化定时器
updatePersistTimer(newConfig.persistInterval);
}
markDirty();
await persistIfDirty();
const updatedConfig = getFullConfig();
console.log(`[API Potluck UserData] Config updated:`, updatedConfig);
return updatedConfig;
}
/**
* 更新持久化定时器间隔
*/
function updatePersistTimer(newInterval) {
if (newInterval === currentPersistInterval) return;
currentPersistInterval = newInterval;
if (persistTimer) {
clearInterval(persistTimer);
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
console.log(`[API Potluck UserData] Persist interval updated to ${currentPersistInterval}ms`);
}
}
/**
* 获取当前配置对外暴露
*/
export function getConfig() {
return getFullConfig();
}
/**
* 兼容旧接口更新资源包配置
*/
export async function updateBonusConfig(newConfig) {
return updateConfig(newConfig);
}
/**
* 初始化从文件加载数据到内存
*/
function ensureLoaded() {
if (userDataStore !== null) return;
try {
if (existsSync(USER_DATA_FILE)) {
const content = readFileSync(USER_DATA_FILE, 'utf8');
userDataStore = JSON.parse(content);
// 兼容旧数据:确保 config 和 users 存在
if (!userDataStore.config) {
userDataStore.config = {};
}
if (!userDataStore.users) {
userDataStore.users = {};
}
} else {
userDataStore = { config: {}, users: {} };
syncWriteToFile();
}
} catch (error) {
console.error('[API Potluck UserData] Failed to load user data:', error.message);
userDataStore = { config: {}, users: {} };
}
// 获取配置的持久化间隔
const config = userDataStore.config || {};
currentPersistInterval = config.persistInterval ?? DEFAULT_CONFIG.persistInterval;
// 启动定期持久化
if (!persistTimer) {
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
}
// 启动文件监听(热更新)
startFileWatcher();
}
/**
* 同步写入文件仅初始化时使用
*/
function syncWriteToFile() {
try {
const dir = path.dirname(USER_DATA_FILE);
if (!existsSync(dir)) {
require('fs').mkdirSync(dir, { recursive: true });
}
writeFileSync(USER_DATA_FILE, JSON.stringify(userDataStore, null, 2), 'utf8');
} catch (error) {
console.error('[API Potluck UserData] Sync write failed:', error.message);
}
}
/**
* 异步持久化带写锁
*/
async function persistIfDirty() {
if (!isDirty || isWriting || userDataStore === null) return;
isWriting = true;
try {
const dir = path.dirname(USER_DATA_FILE);
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true });
}
const tempFile = USER_DATA_FILE + '.tmp';
await fs.writeFile(tempFile, JSON.stringify(userDataStore, null, 2), 'utf8');
await fs.rename(tempFile, USER_DATA_FILE);
isDirty = false;
} catch (error) {
console.error('[API Potluck UserData] Persist failed:', error.message);
} finally {
isWriting = false;
}
}
/**
* 标记数据已修改
*/
function markDirty() {
isDirty = true;
}
/**
* 启动文件监听热更新配置
*/
let lastReloadTime = 0;
function startFileWatcher() {
if (fileWatcher) return;
try {
fileWatcher = watch(USER_DATA_FILE, { persistent: false }, (eventType) => {
if (eventType !== 'change') return;
// 防抖:忽略自己写入触发的事件
const now = Date.now();
if (now - lastReloadTime < 1000 || isWriting) return;
lastReloadTime = now;
// 重新加载配置部分
try {
const content = readFileSync(USER_DATA_FILE, 'utf8');
const newData = JSON.parse(content);
// 只热更新 config 部分,不覆盖内存中的 users 数据
if (newData.config) {
const oldConfig = userDataStore.config || {};
const newConfig = newData.config;
// 检查配置是否有变化
if (JSON.stringify(oldConfig) !== JSON.stringify(newConfig)) {
userDataStore.config = newConfig;
console.log('[API Potluck UserData] Config hot-reloaded:', getBonusConfig());
}
}
} catch (error) {
console.error('[API Potluck UserData] Hot-reload failed:', error.message);
}
});
console.log('[API Potluck UserData] File watcher started for config hot-reload');
} catch (error) {
console.error('[API Potluck UserData] Failed to start file watcher:', error.message);
}
}
/**
* 停止文件监听
*/
export function stopFileWatcher() {
if (fileWatcher) {
fileWatcher.close();
fileWatcher = null;
}
}
/**
* 获取用户数据
* @param {string} apiKey - 用户的 API Key
* @returns {Object|null}
*/
export function getUserData(apiKey) {
ensureLoaded();
return userDataStore.users[apiKey] || null;
}
/**
* 初始化用户数据如果不存在
* @param {string} apiKey - 用户的 API Key
* @returns {Object}
*/
export function ensureUserData(apiKey) {
ensureLoaded();
if (!userDataStore.users[apiKey]) {
userDataStore.users[apiKey] = {
credentials: [],
credentialBonuses: [],
createdAt: new Date().toISOString()
};
markDirty();
}
// 兼容旧数据:添加 credentialBonuses 数组
if (!userDataStore.users[apiKey].credentialBonuses) {
userDataStore.users[apiKey].credentialBonuses = [];
markDirty();
}
return userDataStore.users[apiKey];
}
/**
* 添加凭据路径到用户
* @param {string} apiKey - 用户的 API Key
* @param {Object} credentialInfo - 凭据信息
* @param {string} credentialInfo.path - 凭据文件路径
* @param {string} credentialInfo.provider - 提供商类型 ( 'claude-kiro-oauth')
* @param {string} [credentialInfo.authMethod] - 认证方式 ( 'builder-id', 'google', 'github')
* @returns {Object} 添加的凭据信息
*/
export async function addUserCredential(apiKey, credentialInfo) {
ensureLoaded();
const userData = ensureUserData(apiKey);
// 检查是否已存在相同路径
const existingIndex = userData.credentials.findIndex(c => c.path === credentialInfo.path);
// 只保留核心字段,健康状态从主服务实时获取
const credential = {
id: `cred_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
path: credentialInfo.path,
provider: credentialInfo.provider || 'claude-kiro-oauth',
authMethod: credentialInfo.authMethod || 'unknown',
addedAt: new Date().toISOString()
};
if (existingIndex >= 0) {
// 更新已存在的凭据,保留原有 id 和 addedAt
credential.id = userData.credentials[existingIndex].id;
credential.addedAt = userData.credentials[existingIndex].addedAt;
userData.credentials[existingIndex] = credential;
} else {
userData.credentials.push(credential);
}
markDirty();
await persistIfDirty();
return credential;
}
/**
* 移除用户凭据
* @param {string} apiKey - 用户的 API Key
* @param {string} credentialId - 凭据 ID
* @returns {boolean}
*/
export async function removeUserCredential(apiKey, credentialId) {
ensureLoaded();
const userData = userDataStore.users[apiKey];
if (!userData) return false;
const index = userData.credentials.findIndex(c => c.id === credentialId);
if (index === -1) return false;
userData.credentials.splice(index, 1);
markDirty();
await persistIfDirty();
return true;
}
/**
* 获取用户的所有凭据
* @param {string} apiKey - 用户的 API Key
* @returns {Array}
*/
export function getUserCredentials(apiKey) {
ensureLoaded();
const userData = userDataStore.users[apiKey];
return userData ? userData.credentials : [];
}
/**
* 通过路径查找凭据
* @param {string} apiKey - 用户的 API Key
* @param {string} credPath - 凭据文件路径
* @returns {Object|null}
*/
export function findCredentialByPath(apiKey, credPath) {
ensureLoaded();
const userData = userDataStore.users[apiKey];
if (!userData) return null;
return userData.credentials.find(c => c.path === credPath) || null;
}
/**
* 检查凭据路径是否已被任何用户使用
* @param {string} credPath - 凭据文件路径
* @returns {{exists: boolean, apiKey?: string}}
*/
export function isCredentialPathUsed(credPath) {
ensureLoaded();
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
const found = userData.credentials.find(c => c.path === credPath);
if (found) {
return { exists: true, apiKey };
}
}
return { exists: false };
}
/**
* 迁移用户凭据到新 Key用于 Key 重置时
* @param {string} oldApiKey - API Key
* @param {string} newApiKey - API Key
* @returns {Promise<boolean>}
*/
export async function migrateUserCredentials(oldApiKey, newApiKey) {
ensureLoaded();
const oldUserData = userDataStore.users[oldApiKey];
if (!oldUserData) return false;
// 将旧用户数据迁移到新 Key
userDataStore.users[newApiKey] = {
...oldUserData,
migratedFrom: oldApiKey.substring(0, 12) + '...',
migratedAt: new Date().toISOString()
};
// 删除旧用户数据
delete userDataStore.users[oldApiKey];
markDirty();
await persistIfDirty();
console.log(`[API Potluck UserData] Migrated credentials from ${oldApiKey.substring(0, 12)}... to ${newApiKey.substring(0, 12)}...`);
return true;
}
/**
* 获取所有用户及其凭据用于批量健康检查
* @returns {Array<{apiKey: string, credentials: Array}>}
*/
export function getAllUsersCredentials() {
ensureLoaded();
const result = [];
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
if (userData.credentials && userData.credentials.length > 0) {
result.push({
apiKey,
credentials: userData.credentials
});
}
}
return result;
}
// ============ 凭证资源包管理 ============
/**
* 计算资源包过期时间使用动态配置
* @param {string} grantedAt - 授予时间
* @returns {Date}
*/
function calculateExpiresAt(grantedAt) {
const { bonusValidityDays } = getBonusConfig();
const granted = new Date(grantedAt);
return new Date(granted.getTime() + bonusValidityDays * 24 * 60 * 60 * 1000);
}
/**
* 检查资源包是否过期
* @param {Object} bonus - 资源包对象
* @returns {boolean}
*/
function isBonusExpired(bonus) {
const expiresAt = calculateExpiresAt(bonus.grantedAt);
return new Date() > expiresAt;
}
/**
* 为凭证添加资源包凭证健康时调用
* @param {string} apiKey - 用户的 API Key
* @param {string} credentialId - 凭证 ID
* @returns {Object|null} 添加的资源包信息
*/
export async function addCredentialBonus(apiKey, credentialId) {
ensureLoaded();
const userData = ensureUserData(apiKey);
// 检查是否已存在
const existing = userData.credentialBonuses.find(b => b.credentialId === credentialId);
if (existing) {
return existing;
}
const bonus = {
credentialId,
grantedAt: new Date().toISOString(),
usedCount: 0
};
userData.credentialBonuses.push(bonus);
markDirty();
console.log(`[API Potluck UserData] Added bonus for credential: ${credentialId}`);
return bonus;
}
/**
* 移除凭证资源包凭证失效时调用
* @param {string} apiKey - 用户的 API Key
* @param {string} credentialId - 凭证 ID
* @returns {boolean}
*/
export async function removeCredentialBonus(apiKey, credentialId) {
ensureLoaded();
const userData = userDataStore.users[apiKey];
if (!userData || !userData.credentialBonuses) return false;
const index = userData.credentialBonuses.findIndex(b => b.credentialId === credentialId);
if (index === -1) return false;
userData.credentialBonuses.splice(index, 1);
markDirty();
console.log(`[API Potluck UserData] Removed bonus for credential: ${credentialId}`);
return true;
}
/**
* 消耗资源包次数FIFO 顺序使用 Mutex 保证并发安全
* @param {string} apiKey - 用户的 API Key
* @returns {boolean} 是否成功消耗
*/
export async function consumeBonus(apiKey) {
// 使用 Mutex 保证并发安全
return bonusMutex.runExclusive(async () => {
ensureLoaded();
const userData = userDataStore.users[apiKey];
if (!userData || !userData.credentialBonuses) return false;
const { bonusPerCredential } = getBonusConfig();
// 按 grantedAt 排序FIFO
const sortedBonuses = userData.credentialBonuses
.filter(b => !isBonusExpired(b))
.sort((a, b) => new Date(a.grantedAt) - new Date(b.grantedAt));
// 找到第一个有剩余次数的资源包
for (const bonus of sortedBonuses) {
const remaining = bonusPerCredential - bonus.usedCount;
if (remaining > 0) {
bonus.usedCount += 1;
markDirty();
return true;
}
}
return false;
});
}
/**
* 计算用户的剩余资源包总次数
* @param {string} apiKey - 用户的 API Key
* @param {Set<string>} [healthyCredentialIds] - 健康凭证 ID 集合可选用于过滤
* @returns {number}
*/
export function calculateBonusRemaining(apiKey, healthyCredentialIds = null) {
ensureLoaded();
const userData = userDataStore.users[apiKey];
if (!userData || !userData.credentialBonuses) return 0;
const { bonusPerCredential } = getBonusConfig();
let total = 0;
for (const bonus of userData.credentialBonuses) {
// 检查是否过期
if (isBonusExpired(bonus)) continue;
// 如果提供了健康凭证集合,检查凭证是否健康
if (healthyCredentialIds && !healthyCredentialIds.has(bonus.credentialId)) continue;
const remaining = bonusPerCredential - bonus.usedCount;
if (remaining > 0) {
total += remaining;
}
}
return total;
}
/**
* 同步资源包状态根据健康凭证列表
* 兼容历史数据为已有健康凭证创建资源包使用凭证的 addedAt 作为 grantedAt
* @param {string} apiKey - 用户的 API Key
* @param {Array<{id: string, isHealthy: boolean, addedAt?: string}>} credentialsWithHealth - 带健康状态的凭证列表
* @returns {{added: number, removed: number, bonusRemaining: number}}
*/
export async function syncCredentialBonuses(apiKey, credentialsWithHealth) {
ensureLoaded();
const userData = ensureUserData(apiKey);
let added = 0, removed = 0;
// 获取健康凭证 ID 集合
const healthyIds = new Set(
credentialsWithHealth
.filter(c => c.isHealthy === true)
.map(c => c.id)
);
// 为新的健康凭证添加资源包
for (const cred of credentialsWithHealth) {
if (cred.isHealthy !== true) continue;
const exists = userData.credentialBonuses.some(b => b.credentialId === cred.id);
if (!exists) {
// 使用凭证的 addedAt 作为资源包授予时间(兼容历史数据)
const grantedAt = cred.addedAt || new Date().toISOString();
userData.credentialBonuses.push({
credentialId: cred.id,
grantedAt: grantedAt,
usedCount: 0
});
added++;
console.log(`[API Potluck UserData] Created bonus for credential ${cred.id}, grantedAt: ${grantedAt}`);
}
}
// 移除失效凭证的资源包
const toRemove = userData.credentialBonuses.filter(b => !healthyIds.has(b.credentialId));
for (const bonus of toRemove) {
const idx = userData.credentialBonuses.indexOf(bonus);
if (idx !== -1) {
userData.credentialBonuses.splice(idx, 1);
removed++;
}
}
// 清理过期资源包
const expiredCount = userData.credentialBonuses.filter(b => isBonusExpired(b)).length;
userData.credentialBonuses = userData.credentialBonuses.filter(b => !isBonusExpired(b));
if (added > 0 || removed > 0 || expiredCount > 0) {
markDirty();
}
// 计算剩余资源包次数
const bonusRemaining = calculateBonusRemaining(apiKey, healthyIds);
return { added, removed, bonusRemaining };
}
/**
* 获取用户的资源包详情
* @param {string} apiKey - 用户的 API Key
* @returns {Object}
*/
export function getBonusDetails(apiKey) {
ensureLoaded();
const { bonusPerCredential, bonusValidityDays } = getBonusConfig();
const userData = userDataStore.users[apiKey];
if (!userData) {
return {
bonuses: [],
totalRemaining: 0,
bonusPerCredential,
validityDays: bonusValidityDays
};
}
const bonuses = (userData.credentialBonuses || [])
.filter(b => !isBonusExpired(b))
.map(b => ({
credentialId: b.credentialId,
grantedAt: b.grantedAt,
expiresAt: calculateExpiresAt(b.grantedAt).toISOString(),
usedCount: b.usedCount,
remaining: bonusPerCredential - b.usedCount
}));
const totalRemaining = bonuses.reduce((sum, b) => sum + Math.max(0, b.remaining), 0);
return {
bonuses,
totalRemaining,
bonusPerCredential,
validityDays: bonusValidityDays
};
}
/**
* 获取所有用户的 API Key 列表
* @returns {string[]}
*/
export function getAllUserApiKeys() {
ensureLoaded();
return Object.keys(userDataStore.users);
}
export { USER_DATA_FILE };

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff