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:
parent
bc4b0225e7
commit
7f6bf6f06b
10 changed files with 4185 additions and 1067 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
29
configs/api-potluck-data.json.example
Normal file
29
configs/api-potluck-data.json.example
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
configs/api-potluck-keys.json.example
Normal file
16
configs/api-potluck-keys.json.example
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
721
src/plugins/api-potluck/user-data-manager.js
Normal file
721
src/plugins/api-potluck/user-data-manager.js
Normal 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
Loading…
Reference in a new issue