first commit
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal 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
175
README.md
Normal 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
13
frontend/index.html
Normal 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
31
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
145
frontend/src/App.tsx
Normal file
145
frontend/src/App.tsx
Normal 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;
|
||||
147
frontend/src/components/ApiKeyCard.tsx
Normal file
147
frontend/src/components/ApiKeyCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/ApiKeyForm.tsx
Normal file
105
frontend/src/components/ApiKeyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/Stats.tsx
Normal file
72
frontend/src/components/Stats.tsx
Normal 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
53
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
56
frontend/src/services/api.ts
Normal file
56
frontend/src/services/api.ts
Normal 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
29
frontend/src/types/api.ts
Normal 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
6
frontend/src/utils/cn.ts
Normal 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));
|
||||
}
|
||||
45
frontend/tailwind.config.js
Normal file
45
frontend/tailwind.config.js
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
19
frontend/vite.config.ts
Normal 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
45
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
prisma/migrations/20250905094912_init/migration.sql
Normal file
17
prisma/migrations/20250905094912_init/migration.sql
Normal 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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "api_keys" DROP COLUMN "currency";
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
27
prisma/schema.prisma
Normal 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
22
quick-start.bat
Normal 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
115
src/config/siliconflow.ts
Normal 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
113
src/routes/apiKeys.ts
Normal 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
55
src/server.ts
Normal 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`);
|
||||
});
|
||||
140
src/services/apiKeyService.ts
Normal file
140
src/services/apiKeyService.ts
Normal 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
13
src/utils/errors.ts
Normal 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
26
tsconfig.json
Normal 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
9
tsconfig.server.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["frontend/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user