first commit

This commit is contained in:
小贺
2025-09-05 21:07:34 +08:00
commit c1a493222c
31 changed files with 1580 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Dependencies
node_modules/
frontend/node_modules/
package-lock.json
frontend/package-lock.json
# Build outputs
dist/
frontend/dist/
# Environment files
.env
.env.local
.env.*.local
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
# Database
prisma/dev.db
prisma/dev.db-journal
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Coverage
coverage/
*.lcov
# Temporary files
tmp/
temp/

175
README.md Normal file
View File

@@ -0,0 +1,175 @@
# SiliconFlow API Key 验证器
这是一个全栈应用程序,用于验证和管理 SiliconFlow API Key实时监控账户余额状态。
## ✨ 功能特性
- 🔐 **API Key 管理**: 安全地存储和管理多个API Key
-**实时验证**: 批量或单独验证API Key有效性
- 💰 **余额监控**: 查看实时余额信息
- 📊 **统计面板**: 一目了然的账户状态统计
- 🎨 **现代界面**: 美观易用的用户界面
- 🔒 **数据安全**: 本地数据库存储,确保数据私密性
## 🚀 快速开始
### 1⃣ 克隆和安装依赖
```bash
# 克隆仓库
git clone [your-repo-url]
cd siliconflow-api-key-validator
# 安装后端依赖
npm install
# 安装前端依赖
cd frontend
npm install
cd ..
```
### 2⃣ 数据库初始化
```bash
# 创建数据库
npx prisma generate
npx prisma migrate dev --name init
```
### 3⃣ 启动应用
#### 开发模式
```bash
# 同时启动后端和前端开发服务器
npm run dev
# 或者分别启动
npm run server:dev # 后端 (端口3000)
cd frontend && npm run dev # 前端 (端口3001)
```
#### 生产模式
```bash
# 构建
npm run build
# 启动生产服务器
npm start
```
## 🌐 访问应用
- **首页**: http://localhost:3001
- **API 地址**: http://localhost:3000/api
- **健康检查**: http://localhost:3000/health
## 📋 API 端点
### API Keys 管理
- `GET /api/api-keys` - 获取所有API Key
- `POST /api/api-keys` - 添加新API Key
- `POST /api/api-keys/:id/validate` - 验证单个API Key
- `POST /api/api-keys/validate-all` - 批量验证所有API Key
- `DELETE /api/api-keys/:id` - 删除API Key
## 🛠️ 技术栈
### 后端
- **Node.js** + **Express**
- **TypeScript** - 类型安全
- **Prisma** - 数据库ORM
- **SQLite** - 轻量级数据库
- **Axios** - HTTP客户端
- **CORS** + **Helmet** + **Compression** - 安全与性能优化
### 前端
- **React 18** + **TypeScript**
- **Vite** - 现代构建工具
- **TailwindCSS** - 样式框架
- **Axios** - HTTP客户端
- **Lucide React** - 图标库
## 📁 项目结构
```
siliconflow-api-key-validator/
├── src/
│ ├── config/
│ │ └── siliconflow.ts # SiliconFlow API 配置和验证逻辑
│ ├── services/
│ │ └── apiKeyService.ts # 业务逻辑服务
│ ├── routes/
│ │ └── apiKeys.ts # API 路由定义
│ ├── utils/
│ │ └── errors.ts # 自定义错误类
│ └── server.ts # Express 服务器入口
├── frontend/
│ ├── src/
│ │ ├── components/ # React 组件
│ │ ├── services/ # 前端 API 服务
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── utils/ # 工具函数
│ │ └── App.tsx # 主应用组件
│ ├── package.json
│ ├── vite.config.ts
│ └── tailwind.config.js
├── prisma/
│ └── schema.prisma # 数据模型定义
└── README.md
```
## 🔧 环境变量配置
### .env
```env
PORT=3000
NODE_ENV=development
API_TIMEOUT=10000
```
## 📝 使用说明
### 添加API Key
1. 在主页的"添加新API Key"表单中输入:
- API Key名称如"生产环境"
- 实际的API Key
- 可选描述信息
### 验证API Key
- **单独验证**: 点击卡片右上角的刷新图标
- **批量验证**: 使用页面顶部"批量验证"按钮
### 查看账户信息
- **状态**: 绿色表示有效,红色表示无效
- **余额**: 显示实时账户余额
- **用户信息**: 显示关联的账户邮箱和名称
- **验证统计**: 显示验证次数和最后验证时间
## 🐛 故障排除
### 常见问题
1. **端口占用**: 确保3000和3001端口未被占用
2. **数据库连接**: 检查`prisma/dev.db`文件是否存在
3. **构建错误**: 确保所有依赖已安装: `npm install``cd frontend && npm install`
4. **API验证失败**: 检查网络连接和API Key格式
### 重置数据
```bash
# 删除数据库重新初始化
rm prisma/dev.db
npx prisma migrate dev --name init
```
## 🤝 贡献
欢迎提交Issue和Pull Request
## 📄 许可证
MIT License - 详见 LICENSE 文件

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SiliconFlow API Key 验证器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "siliconflow-api-key-validator-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.4.19"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

145
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,145 @@
import React, { useState, useEffect } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { ApiKeyForm } from './components/ApiKeyForm';
import { ApiKeyCard } from './components/ApiKeyCard';
import { Stats } from './components/Stats';
import { apiKeyService } from './services/api';
import type { ApiKey } from './types/api';
function App() {
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isValidatingAll, setIsValidatingAll] = useState(false);
const loadApiKeys = async () => {
setIsLoading(true);
setError(null);
try {
const data = await apiKeyService.getAllApiKeys();
setApiKeys(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadApiKeys();
}, []);
const handleAddSuccess = async () => {
await loadApiKeys();
};
const handleUpdate = (id: string, updated: ApiKey) => {
setApiKeys(prev => prev.map(key => key.id === id ? updated : key));
};
const handleDelete = (id: string) => {
setApiKeys(prev => prev.filter(key => key.id !== id));
};
const handleValidateAll = async () => {
setIsValidatingAll(true);
try {
const results = await apiKeyService.validateAllApiKeys();
setApiKeys(results);
} catch (err) {
setError(err instanceof Error ? err.message : '批量验证失败');
} finally {
setIsValidatingAll(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
SiliconFlow API Key
</h1>
<p className="text-gray-600">
SiliconFlow API Key
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<p className="mt-2 text-gray-600">...</p>
</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 mr-3" />
<div className="text-sm text-red-700">
<p className="font-medium"></p>
<p>{error}</p>
</div>
</div>
</div>
) : (
<>
<Stats apiKeys={apiKeys} />
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900">
API Keys ({apiKeys.length})
</h2>
{apiKeys.length > 0 && (
<button
onClick={handleValidateAll}
disabled={isValidatingAll}
className="flex items-center px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isValidatingAll ? 'animate-spin' : ''}`} />
</button>
)}
</div>
{apiKeys.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg className="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> API Key</h3>
<p className="text-gray-600 mb-4">API Key开始使用</p>
</div>
) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{apiKeys.map((apiKey) => (
<ApiKeyCard
key={apiKey.id}
apiKey={apiKey}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</>
)}
<div className="mt-8">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
API Key
</h3>
<ApiKeyForm onSuccess={handleAddSuccess} />
</div>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,147 @@
import React, { useState } from 'react';
import { CheckCircle2, XCircle, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { apiKeyService } from '../services/api';
import type { ApiKey } from '../types/api';
interface ApiKeyCardProps {
apiKey: ApiKey;
onUpdate: (id: string, updated: ApiKey) => void;
onDelete: (id: string) => void;
}
export function ApiKeyCard({ apiKey, onUpdate, onDelete }: ApiKeyCardProps) {
const [isValidating, setIsValidating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleValidate = async () => {
setIsValidating(true);
try {
const updated = await apiKeyService.validateApiKey(apiKey.id);
onUpdate(apiKey.id, updated);
} catch (error) {
console.error('验证失败:', error);
} finally {
setIsValidating(false);
}
};
const handleDelete = async () => {
if (!confirm("确定要删除这个API Key吗此操作不可恢复。")) {
return;
}
setIsDeleting(true);
try {
await apiKeyService.deleteApiKey(apiKey.id);
onDelete(apiKey.id);
} catch (error) {
console.error('删除失败:', error);
} finally {
setIsDeleting(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN');
};
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{apiKey.name}
</h3>
{apiKey.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{apiKey.description}
</p>
)}
</div>
<div className="flex items-center ml-4">
<button
onClick={handleValidate}
disabled={isValidating}
className="p-2 mr-2 text-blue-600 hover:bg-blue-50 rounded-md disabled:opacity-50"
title="验证API Key"
>
<RefreshCw className={`w-4 h-4 ${isValidating ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-2 text-red-600 hover:bg-red-50 rounded-md disabled:opacity-50"
title="删除API Key"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex items-center mb-3">
{apiKey.isValid ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle2 className="w-3 h-3 mr-1" />
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<XCircle className="w-3 h-3 mr-1" />
</span>
)}
</div>
{/* 直接显示完整的API Key */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">API Key:</label>
<div className="p-2 bg-gray-50 border border-gray-200 rounded-md text-xs font-mono text-gray-600 break-all">
{apiKey.key}
</div>
</div>
{apiKey.error ? (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{apiKey.error}</p>
</div>
) : null}
{apiKey.balance !== undefined && (
<div className="mb-3 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-600 mb-1">
:
<span className="font-semibold text-green-600 ml-1">
{formatCurrency(apiKey.balance)}
</span>
</p>
{apiKey.user && (
<p className="text-sm text-gray-500">
: {apiKey.user.name} ({apiKey.user.email})
</p>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
: {formatDate(apiKey.lastChecked)}
</span>
<span>
: {apiKey.checkCount}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
import { apiKeyService } from '../services/api';
import { cn } from '../utils/cn';
interface ApiKeyFormProps {
onSuccess: () => void;
}
export function ApiKeyForm({ onSuccess }: ApiKeyFormProps) {
const [formData, setFormData] = useState({
name: '',
key: '',
description: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await apiKeyService.createApiKey(formData);
setFormData({ name: '', key: '', description: '' });
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
API Key *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="例如: 生产环境 API"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
API Key *
</label>
<input
type="password"
required
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder="sk-... 或sf-xxxxx"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
()
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={2}
placeholder="用于管理此API Key的描述信息"
/>
</div>
{error && (
<div className="px-3 py-2 bg-red-100 border border-red-300 rounded-md text-red-700 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className={cn(
"w-full flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
API Key
</>
)}
</button>
</form>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { CheckCircle, XCircle, DollarSign, Zap } from 'lucide-react';
import type { ApiKey } from '../types/api';
interface StatsProps {
apiKeys: ApiKey[];
}
export function Stats({ apiKeys }: StatsProps) {
const totalCount = apiKeys.length;
const validCount = apiKeys.filter(key => key.isValid).length;
const invalidCount = totalCount - validCount;
const totalBalance = apiKeys.reduce((sum, key) => sum + (key.balance || 0), 0);
const stats = [
{
name: '总 API Key',
value: totalCount,
icon: Zap,
color: 'text-gray-600',
bgColor: 'bg-gray-50',
},
{
name: '有效',
value: validCount,
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-50',
},
{
name: '无效',
value: invalidCount,
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-50',
},
{
name: '总余额',
value: `$${totalBalance.toFixed(2)}`,
icon: DollarSign,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="relative overflow-hidden rounded-lg bg-white px-4 py-5 shadow border-l-4 hover:shadow-md transition-shadow"
style={{ borderColor: stat.color.replace('text-', '').split('-')[1] ? `#${stat.color.replace('text-', '').split('-')[1]}-500` : 'gray' }}
>
<dt>
<div className={`absolute rounded-md p-3 ${stat.bgColor}`}>
<Icon className={`h-6 w-6 ${stat.color}`} aria-hidden="true" />
</div>
<p className="ml-16 truncate text-sm font-medium text-gray-500">
{stat.name}
</p>
</dt>
<dd className="ml-16 flex items-baseline">
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
</dd>
</div>
);
})}
</div>
);
}

53
frontend/src/index.css Normal file
View File

@@ -0,0 +1,53 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,56 @@
import axios from 'axios';
import type { ApiKey, ApiResponse, CreateApiKeyRequest } from '../types/api';
const api = axios.create({
baseURL: '/api',
timeout: 10000,
});
api.interceptors.response.use(
(response) => response,
(error) => {
const message = error.response?.data?.error || error.message || '未知错误';
return Promise.reject(new Error(message));
}
);
export const apiKeyService = {
async getAllApiKeys(): Promise<ApiKey[]> {
const response = await api.get<ApiResponse<ApiKey[]>>('/api-keys');
if (!response.data.success) {
throw new Error(response.data.error || '获取API Key失败');
}
return response.data.data || [];
},
async createApiKey(data: CreateApiKeyRequest): Promise<ApiKey> {
const response = await api.post<ApiResponse<ApiKey>>('/api-keys', data);
if (!response.data.success) {
throw new Error(response.data.error || '创建API Key失败');
}
return response.data.data!;
},
async validateApiKey(id: string): Promise<ApiKey> {
const response = await api.post<ApiResponse<ApiKey>>(`/api-keys/${id}/validate`);
if (!response.data.success) {
throw new Error(response.data.error || '验证API Key失败');
}
return response.data.data!;
},
async validateAllApiKeys(): Promise<ApiKey[]> {
const response = await api.post<ApiResponse<ApiKey[]>>('/api-keys/validate-all');
if (!response.data.success) {
throw new Error(response.data.error || '批量验证失败');
}
return response.data.data || [];
},
async deleteApiKey(id: string): Promise<void> {
const response = await api.delete<ApiResponse<never>>(`/api-keys/${id}`);
if (!response.data.success) {
throw new Error(response.data.error || '删除API Key失败');
}
},
};

29
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,29 @@
export interface ApiKey {
id: string;
name: string;
key: string;
description?: string;
isValid: boolean;
balance?: number;
user?: {
id: string;
email: string;
name: string;
};
lastChecked: string;
checkCount: number;
error?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface CreateApiKeyRequest {
name: string;
key: string;
description?: string;
}

6
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist'
}
})

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "siliconflow-api-key-validator",
"version": "1.0.0",
"description": "A full-stack application to validate and manage SiliconFlow API keys",
"main": "dist/server.js",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
"server:dev": "npx tsx src/server.ts",
"client:dev": "cd frontend && npm run dev",
"build": "npm run build:server && npm run build:client",
"build:server": "tsc -p tsconfig.server.json",
"build:client": "cd frontend && npm run build",
"start": "node dist/server.js",
"migrate": "prisma migrate deploy"
},
"keywords": [
"api-key",
"validator",
"siliconflow"
],
"author": "",
"license": "MIT",
"dependencies": {
"@prisma/client": "^6.15.0",
"axios": "^1.6.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.1.0",
"http-status-codes": "^2.3.0"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"concurrently": "^8.2.2",
"nodemon": "^3.0.2",
"prisma": "^6.15.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "api_keys" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"key" TEXT NOT NULL,
"description" TEXT,
"isValid" BOOLEAN NOT NULL DEFAULT true,
"balance" REAL,
"currency" TEXT,
"lastChecked" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "api_keys_key_key" ON "api_keys"("key");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "api_keys" DROP COLUMN "currency";

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

27
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,27 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model ApiKey {
id String @id @default(cuid())
name String
key String @unique
description String?
isValid Boolean @default(true)
balance Float? // 当前余额
// 删除 currency 字段
lastChecked DateTime @default(now())
checkCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("api_keys")
}

22
quick-start.bat Normal file
View File

@@ -0,0 +1,22 @@
@echo off
echo 🚀 SiliconFlow API Key 验证器快速启动...
echo.
REM 启动后端
start cmd /k "echo 启动后端服务器... && npx tsx src/server.ts"
REM 等待2秒让后端启动
timeout /t 2 /nobreak >nul
REM 启动前端
cd frontend
start cmd /k "echo 启动前端服务器... && npm run dev"
echo.
echo 🌐 应用已启动:
echo Frontend: http://localhost:3001
echo Backend API: http://localhost:3000/api
echo Health Check: http://localhost:3000/health
echo.
echo 按任意键退出...
pause >nul

115
src/config/siliconflow.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface SiliconFlowResponse {
code: number;
message: string;
status: boolean;
data: {
id: string;
name: string;
image: string;
email: string;
isAdmin: boolean;
balance: string;
status: string;
introduction: string;
role: string;
chargeBalance: string;
totalBalance: string;
};
}
export interface ValidationResult {
isValid: boolean;
balance?: number | null;
user?: {
id: string;
email: string;
name: string;
} | null;
error?: string;
}
export class SiliconFlowValidator {
private static readonly BASE_URL = 'https://api.siliconflow.cn/v1';
private static readonly ENDPOINT = '/user/info';
static async validateApiKey(apiKey: string): Promise<ValidationResult> {
try {
const response = await fetch(`${this.BASE_URL}${this.ENDPOINT}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401) {
return {
isValid: false,
error: '无效的 API Key'
};
}
const errorText = await response.text();
return {
isValid: false,
error: `API 错误: ${response.status} - ${errorText}`
};
}
const responseData = await response.json() as SiliconFlowResponse;
// 验证响应结构
if (!responseData || typeof responseData !== 'object') {
return {
isValid: false,
error: '无效的响应格式'
};
}
// 检查 API 返回状态
if (responseData.code !== 20000 || !responseData.status) {
return {
isValid: false,
error: responseData.message || '验证失败'
};
}
// 验证数据结构
if (!responseData.data || typeof responseData.data !== 'object') {
return {
isValid: false,
error: '缺失用户数据'
};
}
const { data } = responseData;
// 将字符串余额转换为数字
const balance = parseFloat(data.balance);
if (isNaN(balance)) {
return {
isValid: false,
error: '无效的用户余额格式'
};
}
return {
isValid: true,
balance: balance,
user: {
id: data.id,
email: data.email,
name: data.name,
}
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
return {
isValid: false,
error: `网络连接失败: ${errorMessage}`
};
}
}
}

113
src/routes/apiKeys.ts Normal file
View File

@@ -0,0 +1,113 @@
import { Router } from 'express';
import { StatusCodes } from 'http-status-codes';
import { ApiKeyService } from '../services/apiKeyService.js';
import { ApiValidationError } from '../utils/errors.js';
const router = Router();
// 获取所有API Keys
router.get('/api-keys', async (req, res) => {
try {
const apiKeys = await ApiKeyService.getAllApiKeys();
res.json({
success: true,
data: apiKeys,
});
} catch (error) {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
success: false,
error: error instanceof Error ? error.message : '获取API Key列表失败',
});
}
});
// 创建新的API Key
router.post('/api-keys', async (req, res) => {
try {
const { name, key, description } = req.body;
if (!name || !key) {
throw new ApiValidationError('名称和API Key是必需字段');
}
const apiKey = await ApiKeyService.createApiKey({
name,
key,
description,
});
res.status(StatusCodes.CREATED).json({
success: true,
data: apiKey,
});
} catch (error) {
if (error instanceof ApiValidationError) {
res.status(StatusCodes.BAD_REQUEST).json({
success: false,
error: error.message,
});
} else {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
success: false,
error: error instanceof Error ? error.message : '创建API Key失败',
});
}
}
});
// 验证单个API Key
router.post('/api-keys/:id/validate', async (req, res) => {
try {
const { id } = req.params;
const apiKey = await ApiKeyService.validateApiKey(id);
res.json({
success: true,
data: apiKey,
});
} catch (error) {
res.status(error instanceof Error && error.message === 'API Key 未找到'
? StatusCodes.NOT_FOUND
: StatusCodes.INTERNAL_SERVER_ERROR).json({
success: false,
error: error instanceof Error ? error.message : '验证API Key失败',
});
}
});
// 验证所有API Keys
router.post('/api-keys/validate-all', async (req, res) => {
try {
const results = await ApiKeyService.validateAll();
res.json({
success: true,
data: results,
});
} catch (error) {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
success: false,
error: error instanceof Error ? error.message : '批量验证失败',
});
}
});
// 删除API Key
router.delete('/api-keys/:id', async (req, res) => {
try {
const { id } = req.params;
await ApiKeyService.deleteApiKey(id);
res.json({
success: true,
message: 'API Key 删除成功',
});
} catch (error) {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
success: false,
error: error instanceof Error ? error.message : '删除API Key失败',
});
}
});
export default router;

55
src/server.ts Normal file
View File

@@ -0,0 +1,55 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import apiKeysRouter from './routes/apiKeys.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API路由
app.use('/api', apiKeysRouter);
// 健康检查
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// 静态文件服务 (用于生产环境)
if (process.env.NODE_ENV === 'production') {
app.use(express.static(join(__dirname, '../frontend/dist')));
app.get('*', (req, res) => {
res.sendFile(join(__dirname, '../frontend/dist/index.html'));
});
}
// 全局错误处理
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({
success: false,
error: '服务器内部错误',
});
});
app.listen(PORT, () => {
console.log(`🚀 服务器运行在端口 ${PORT}`);
console.log(`📡 API地址: http://localhost:${PORT}/api`);
console.log(`🔍 健康检查: http://localhost:${PORT}/health`);
});

View File

@@ -0,0 +1,140 @@
import { PrismaClient } from '@prisma/client';
import { SiliconFlowValidator } from '../config/siliconflow.js';
const prisma = new PrismaClient();
export interface ApiKeyInput {
name: string;
key: string;
description?: string;
}
export interface ApiKeyWithValidation extends ApiKeyInput {
id: string;
isValid: boolean;
balance?: number | null;
user?: {
id: string;
email: string;
name: string;
} | null;
lastChecked: Date;
checkCount: number;
error?: string;
}
export class ApiKeyService {
static async createApiKey(input: ApiKeyInput): Promise<ApiKeyWithValidation> {
// 验证API Key
const validation = await SiliconFlowValidator.validateApiKey(input.key);
const apiKey = await prisma.apiKey.create({
data: {
name: input.name,
key: input.key,
description: input.description ?? '',
isValid: validation.isValid,
balance: validation.balance ?? 0,
lastChecked: new Date(),
checkCount: 1,
},
});
return {
...apiKey,
key: apiKey.key, // 直接传输真实key
error: validation.error || undefined,
user: validation.user || null,
};
}
static async getAllApiKeys(): Promise<ApiKeyWithValidation[]> {
const apiKeys = await prisma.apiKey.findMany({
orderBy: { updatedAt: 'desc' },
});
// 直接传输原始key值不再隐藏
return apiKeys.map((key) => ({
id: key.id,
name: key.name,
description: key.description,
isValid: key.isValid,
balance: key.balance,
lastChecked: key.lastChecked,
checkCount: key.checkCount,
key: key.key, // 直接传输真实key
}));
}
static async validateApiKey(id: string): Promise<ApiKeyWithValidation> {
const apiKey = await prisma.apiKey.findUnique({
where: { id },
});
if (!apiKey) {
throw new Error('API Key 未找到');
}
const validation = await SiliconFlowValidator.validateApiKey(apiKey.key);
const updated = await prisma.apiKey.update({
where: { id },
data: {
isValid: validation.isValid,
balance: validation.balance ?? 0,
lastChecked: new Date(),
checkCount: { increment: 1 },
},
});
return {
...updated,
key: updated.key, // 直接传输真实key
error: validation.error || undefined,
user: validation.user || null,
};
}
static async deleteApiKey(id: string): Promise<void> {
await prisma.apiKey.delete({
where: { id },
});
}
static async validateAll(): Promise<ApiKeyWithValidation[]> {
const apiKeys = await prisma.apiKey.findMany();
const results = await Promise.all(
apiKeys.map(async (key) => {
const validation = await SiliconFlowValidator.validateApiKey(key.key);
const updated = await prisma.apiKey.update({
where: { id: key.id },
data: {
isValid: validation.isValid,
balance: validation.balance ?? 0,
lastChecked: new Date(),
checkCount: { increment: 1 },
},
});
return {
...updated,
key: updated.key, // 直接传输真实key
error: validation.error || undefined,
user: validation.user || null,
};
})
);
return results;
}
}

13
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,13 @@
export class ApiValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ApiValidationError';
}
}
export class ApiNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'ApiNotFoundError';
}
}

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"checkJs": false,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "frontend"]
}

9
tsconfig.server.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": false
},
"include": ["src/**/*"],
"exclude": ["frontend/**/*"]
}