Phase 6: API Platform - Add authentication to existing endpoints and frontend API Key management UI
This commit is contained in:
119
STATUS.md
119
STATUS.md
@@ -1,17 +1,16 @@
|
|||||||
# InsightFlow 开发状态
|
# InsightFlow 开发状态
|
||||||
|
|
||||||
**最后更新**: 2026-02-21 06:05
|
**最后更新**: 2026-02-21 18:10
|
||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
Phase 5: 高级功能 - **已完成 ✅**
|
Phase 6: API 开放平台 - **已完成 ✅**
|
||||||
Phase 6: 企业级功能 - **规划中 📋**
|
|
||||||
|
|
||||||
## 部署状态
|
## 部署状态
|
||||||
|
|
||||||
- **服务器**: 122.51.127.111:18000 ✅ 运行中
|
- **服务器**: 122.51.127.111:18000 ✅ 运行中
|
||||||
- **Neo4j**: 122.51.127.111:7474 (HTTP), 122.51.127.111:7687 (Bolt) ⏸️ 待部署
|
- **Neo4j**: 122.51.127.111:7474 (HTTP), 122.51.127.111:7687 (Bolt) ✅ 运行中
|
||||||
- **Git 版本**: f38e060 - Phase 5: Enhance Neo4j graph visualization
|
- **Git 版本**: 已推送
|
||||||
|
|
||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
@@ -118,8 +117,6 @@ Phase 6: 企业级功能 - **规划中 📋**
|
|||||||
- ✅ 实体提及和关系事件可视化
|
- ✅ 实体提及和关系事件可视化
|
||||||
- ✅ 实体筛选功能
|
- ✅ 实体筛选功能
|
||||||
|
|
||||||
## 待完成
|
|
||||||
|
|
||||||
### Phase 5 - Neo4j 图数据库集成 (已完成 ✅)
|
### Phase 5 - Neo4j 图数据库集成 (已完成 ✅)
|
||||||
- [x] 创建 neo4j_manager.py - Neo4j 管理模块
|
- [x] 创建 neo4j_manager.py - Neo4j 管理模块
|
||||||
- 数据同步到 Neo4j(实体、关系、项目)
|
- 数据同步到 Neo4j(实体、关系、项目)
|
||||||
@@ -166,33 +163,73 @@ Phase 6: 企业级功能 - **规划中 📋**
|
|||||||
- 点击社区可以聚焦显示该社区的子图
|
- 点击社区可以聚焦显示该社区的子图
|
||||||
- 社区内节点连线显示内部关联
|
- 社区内节点连线显示内部关联
|
||||||
|
|
||||||
### Phase 4 - Neo4j 集成 (可选)
|
### Phase 5 - 导出功能 (已完成 ✅)
|
||||||
- [ ] 将图谱数据同步到 Neo4j
|
- ✅ 创建 export_manager.py 导出管理模块
|
||||||
- [ ] 支持复杂图查询
|
- ✅ 知识图谱导出 SVG/PNG (支持矢量图和图片格式)
|
||||||
|
- ✅ 实体数据导出 Excel/CSV (包含所有自定义属性)
|
||||||
|
- ✅ 关系数据导出 CSV
|
||||||
|
- ✅ 项目报告导出 PDF (包含统计、实体列表、关系列表)
|
||||||
|
- ✅ 转录文本导出 Markdown (带实体标注)
|
||||||
|
- ✅ 项目完整数据导出 JSON (备份/迁移用)
|
||||||
|
- ✅ 前端知识库面板添加导出入口
|
||||||
|
|
||||||
### Phase 5 - 高级功能 (进行中 🚧)
|
### Phase 6 - API 开放平台 (已完成 ✅)
|
||||||
- [x] 知识推理与问答增强 ✅ (2026-02-19 完成)
|
- ✅ 创建 api_key_manager.py - API Key 管理模块
|
||||||
- [x] 实体属性扩展 ✅ (2026-02-20 完成)
|
- 数据库表设计 (api_keys, api_call_logs, api_call_stats)
|
||||||
- [x] 时间线视图 ✅ (2026-02-19 完成)
|
- API Key 生成(ak_live_ 前缀,48位随机字符串)
|
||||||
- [x] 导出功能 ✅ (2026-02-20 完成)
|
- API Key 验证(SHA256 哈希存储)
|
||||||
- 知识图谱导出 PNG/SVG
|
- API Key 撤销功能
|
||||||
- 项目报告导出 PDF
|
- 权限管理(read, write, delete)
|
||||||
- 实体数据导出 Excel/CSV
|
- 自定义限流配置
|
||||||
- 关系数据导出 CSV
|
- 调用日志记录
|
||||||
- 转录文本导出 Markdown
|
- 调用统计汇总
|
||||||
- 项目完整数据导出 JSON
|
- ✅ 创建 rate_limiter.py - 限流模块
|
||||||
- [ ] 协作功能
|
- 滑动窗口计数器实现
|
||||||
- 多用户支持
|
- 基于内存的限流存储
|
||||||
- 项目权限管理
|
- 可配置的限流参数
|
||||||
- 评论和批注
|
- 限流头信息(X-RateLimit-*)
|
||||||
- 变更历史追踪
|
- ✅ 集成 Swagger/OpenAPI 文档
|
||||||
|
- FastAPI 元数据配置
|
||||||
|
- API 端点分类标签
|
||||||
|
- 请求/响应模型定义
|
||||||
|
- 认证说明文档
|
||||||
|
- ✅ 实现 API 限流中间件
|
||||||
|
- 基于 API Key 的限流
|
||||||
|
- IP 限流(未认证用户)
|
||||||
|
- Master Key 高限流配额
|
||||||
|
- 429 响应处理
|
||||||
|
- ✅ 实现 API Key 管理端点
|
||||||
|
- `POST /api/v1/api-keys` - 创建 API Key
|
||||||
|
- `GET /api/v1/api-keys` - 列出 API Keys
|
||||||
|
- `GET /api/v1/api-keys/{id}` - 获取 API Key 详情
|
||||||
|
- `PATCH /api/v1/api-keys/{id}` - 更新 API Key
|
||||||
|
- `DELETE /api/v1/api-keys/{id}` - 撤销 API Key
|
||||||
|
- `GET /api/v1/api-keys/{id}/stats` - 调用统计
|
||||||
|
- `GET /api/v1/api-keys/{id}/logs` - 调用日志
|
||||||
|
- `GET /api/v1/rate-limit/status` - 限流状态
|
||||||
|
- ✅ 系统信息端点
|
||||||
|
- `GET /api/v1/health` - 健康检查
|
||||||
|
- `GET /api/v1/status` - 系统状态
|
||||||
|
- ✅ 为现有 API 端点添加认证依赖
|
||||||
|
- 所有数据操作端点需要 API Key 认证
|
||||||
|
- 公开端点(/health, /status, /docs)保持开放
|
||||||
|
- ✅ 前端 API Key 管理界面
|
||||||
|
- API Key 列表展示
|
||||||
|
- 创建 API Key
|
||||||
|
- 查看调用统计
|
||||||
|
- 撤销 API Key
|
||||||
|
- 统计卡片展示
|
||||||
|
|
||||||
|
## 待完成
|
||||||
|
|
||||||
|
无 - Phase 6 已完成
|
||||||
|
|
||||||
## 技术债务
|
## 技术债务
|
||||||
|
|
||||||
- 听悟 SDK fallback 到 mock 需要更好的错误处理
|
- 听悟 SDK fallback 到 mock 需要更好的错误处理
|
||||||
- 实体相似度匹配目前只是简单字符串包含,需要 embedding 方案
|
- 实体相似度匹配目前只是简单字符串包含,需要 embedding 方案
|
||||||
- 前端需要状态管理(目前使用全局变量)
|
- 前端需要状态管理(目前使用全局变量)
|
||||||
- 需要添加 API 文档 (OpenAPI/Swagger)
|
- ~~需要添加 API 文档 (OpenAPI/Swagger)~~ ✅ 已完成
|
||||||
|
|
||||||
## 部署信息
|
## 部署信息
|
||||||
|
|
||||||
@@ -202,11 +239,29 @@ Phase 6: 企业级功能 - **规划中 📋**
|
|||||||
|
|
||||||
## 最近更新
|
## 最近更新
|
||||||
|
|
||||||
### 2026-02-21 (早间) - Cron 自动部署
|
### 2026-02-21 (晚间)
|
||||||
- 代码更新到最新版本 (f38e060)
|
- 完成 Phase 6: API 开放平台
|
||||||
- InsightFlow 服务已启动 (122.51.127.111:18000) ✅
|
- 为现有 API 端点添加认证依赖
|
||||||
- Neo4j 依赖已安装 (neo4j==5.15.0)
|
- 前端 API Key 管理界面实现
|
||||||
- Neo4j 服务待部署 (需要 Docker 或外部 Neo4j 实例)
|
- 测试和验证完成
|
||||||
|
- 代码提交并部署
|
||||||
|
|
||||||
|
### 2026-02-21 (午间)
|
||||||
|
- 开始 Phase 6: API 开放平台
|
||||||
|
- 创建 api_key_manager.py - API Key 管理模块
|
||||||
|
- 数据库表:api_keys, api_call_logs, api_call_stats
|
||||||
|
- API Key 生成、验证、撤销功能
|
||||||
|
- 权限管理和自定义限流
|
||||||
|
- 调用日志和统计
|
||||||
|
- 创建 rate_limiter.py - 限流模块
|
||||||
|
- 滑动窗口计数器
|
||||||
|
- 可配置限流参数
|
||||||
|
- 更新 main.py
|
||||||
|
- 集成 Swagger/OpenAPI 文档
|
||||||
|
- 添加 API Key 认证依赖
|
||||||
|
- 实现限流中间件
|
||||||
|
- 新增 API Key 管理端点
|
||||||
|
- 新增系统信息端点
|
||||||
|
|
||||||
### 2026-02-20 (晚间)
|
### 2026-02-20 (晚间)
|
||||||
- 完成 Phase 5 前端图分析面板
|
- 完成 Phase 5 前端图分析面板
|
||||||
|
|||||||
BIN
backend/__pycache__/api_key_manager.cpython-312.pyc
Normal file
BIN
backend/__pycache__/api_key_manager.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/export_manager.cpython-312.pyc
Normal file
BIN
backend/__pycache__/export_manager.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/neo4j_manager.cpython-312.pyc
Normal file
BIN
backend/__pycache__/neo4j_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/rate_limiter.cpython-312.pyc
Normal file
BIN
backend/__pycache__/rate_limiter.cpython-312.pyc
Normal file
Binary file not shown.
529
backend/api_key_manager.py
Normal file
529
backend/api_key_manager.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
InsightFlow API Key Manager - Phase 6
|
||||||
|
API Key 管理模块:生成、验证、撤销
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyStatus(Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApiKey:
|
||||||
|
id: str
|
||||||
|
key_hash: str # 存储哈希值,不存储原始 key
|
||||||
|
key_preview: str # 前8位预览,如 "ak_live_abc..."
|
||||||
|
name: str # 密钥名称/描述
|
||||||
|
owner_id: Optional[str] # 所有者ID(预留多用户支持)
|
||||||
|
permissions: List[str] # 权限列表,如 ["read", "write"]
|
||||||
|
rate_limit: int # 每分钟请求限制
|
||||||
|
status: str # active, revoked, expired
|
||||||
|
created_at: str
|
||||||
|
expires_at: Optional[str]
|
||||||
|
last_used_at: Optional[str]
|
||||||
|
revoked_at: Optional[str]
|
||||||
|
revoked_reason: Optional[str]
|
||||||
|
total_calls: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyManager:
|
||||||
|
"""API Key 管理器"""
|
||||||
|
|
||||||
|
# Key 前缀
|
||||||
|
KEY_PREFIX = "ak_live_"
|
||||||
|
KEY_LENGTH = 48 # 总长度: 前缀(8) + 随机部分(40)
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = DB_PATH):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""初始化数据库表"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
-- API Keys 表
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_hash TEXT UNIQUE NOT NULL,
|
||||||
|
key_preview TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id TEXT,
|
||||||
|
permissions TEXT NOT NULL DEFAULT '["read"]',
|
||||||
|
rate_limit INTEGER DEFAULT 60,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
total_calls INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- API 调用日志表
|
||||||
|
CREATE TABLE IF NOT EXISTS api_call_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
api_key_id TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
status_code INTEGER,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (api_key_id) REFERENCES api_keys(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- API 调用统计表(按天汇总)
|
||||||
|
CREATE TABLE IF NOT EXISTS api_call_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
api_key_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
total_calls INTEGER DEFAULT 0,
|
||||||
|
success_calls INTEGER DEFAULT 0,
|
||||||
|
error_calls INTEGER DEFAULT 0,
|
||||||
|
avg_response_time_ms INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (api_key_id) REFERENCES api_keys(id),
|
||||||
|
UNIQUE(api_key_id, date, endpoint, method)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_logs_key_id ON api_call_logs(api_key_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_logs_created ON api_call_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_stats_key_date ON api_call_stats(api_key_id, date);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _generate_key(self) -> str:
|
||||||
|
"""生成新的 API Key"""
|
||||||
|
# 生成 40 字符的随机字符串
|
||||||
|
random_part = secrets.token_urlsafe(30)[:40]
|
||||||
|
return f"{self.KEY_PREFIX}{random_part}"
|
||||||
|
|
||||||
|
def _hash_key(self, key: str) -> str:
|
||||||
|
"""对 API Key 进行哈希"""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _get_preview(self, key: str) -> str:
|
||||||
|
"""获取 Key 的预览(前16位)"""
|
||||||
|
return f"{key[:16]}..."
|
||||||
|
|
||||||
|
def create_key(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
owner_id: Optional[str] = None,
|
||||||
|
permissions: List[str] = None,
|
||||||
|
rate_limit: int = 60,
|
||||||
|
expires_days: Optional[int] = None
|
||||||
|
) -> tuple[str, ApiKey]:
|
||||||
|
"""
|
||||||
|
创建新的 API Key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (原始key(仅返回一次), ApiKey对象)
|
||||||
|
"""
|
||||||
|
if permissions is None:
|
||||||
|
permissions = ["read"]
|
||||||
|
|
||||||
|
key_id = secrets.token_hex(16)
|
||||||
|
raw_key = self._generate_key()
|
||||||
|
key_hash = self._hash_key(raw_key)
|
||||||
|
key_preview = self._get_preview(raw_key)
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if expires_days:
|
||||||
|
expires_at = (datetime.now() + timedelta(days=expires_days)).isoformat()
|
||||||
|
|
||||||
|
api_key = ApiKey(
|
||||||
|
id=key_id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_preview=key_preview,
|
||||||
|
name=name,
|
||||||
|
owner_id=owner_id,
|
||||||
|
permissions=permissions,
|
||||||
|
rate_limit=rate_limit,
|
||||||
|
status=ApiKeyStatus.ACTIVE.value,
|
||||||
|
created_at=datetime.now().isoformat(),
|
||||||
|
expires_at=expires_at,
|
||||||
|
last_used_at=None,
|
||||||
|
revoked_at=None,
|
||||||
|
revoked_reason=None,
|
||||||
|
total_calls=0
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO api_keys (
|
||||||
|
id, key_hash, key_preview, name, owner_id, permissions,
|
||||||
|
rate_limit, status, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
api_key.id, api_key.key_hash, api_key.key_preview,
|
||||||
|
api_key.name, api_key.owner_id, json.dumps(api_key.permissions),
|
||||||
|
api_key.rate_limit, api_key.status, api_key.created_at,
|
||||||
|
api_key.expires_at
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return raw_key, api_key
|
||||||
|
|
||||||
|
def validate_key(self, key: str) -> Optional[ApiKey]:
|
||||||
|
"""
|
||||||
|
验证 API Key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiKey if valid, None otherwise
|
||||||
|
"""
|
||||||
|
key_hash = self._hash_key(key)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM api_keys WHERE key_hash = ?",
|
||||||
|
(key_hash,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_key = self._row_to_api_key(row)
|
||||||
|
|
||||||
|
# 检查状态
|
||||||
|
if api_key.status != ApiKeyStatus.ACTIVE.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查是否过期
|
||||||
|
if api_key.expires_at:
|
||||||
|
expires = datetime.fromisoformat(api_key.expires_at)
|
||||||
|
if datetime.now() > expires:
|
||||||
|
# 更新状态为过期
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_keys SET status = ? WHERE id = ?",
|
||||||
|
(ApiKeyStatus.EXPIRED.value, api_key.id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
def revoke_key(
|
||||||
|
self,
|
||||||
|
key_id: str,
|
||||||
|
reason: str = "",
|
||||||
|
owner_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""撤销 API Key"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
# 验证所有权(如果提供了 owner_id)
|
||||||
|
if owner_id:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT owner_id FROM api_keys WHERE id = ?",
|
||||||
|
(key_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row or row[0] != owner_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cursor = conn.execute("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET status = ?, revoked_at = ?, revoked_reason = ?
|
||||||
|
WHERE id = ? AND status = ?
|
||||||
|
""", (
|
||||||
|
ApiKeyStatus.REVOKED.value,
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
reason,
|
||||||
|
key_id,
|
||||||
|
ApiKeyStatus.ACTIVE.value
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_key_by_id(self, key_id: str, owner_id: Optional[str] = None) -> Optional[ApiKey]:
|
||||||
|
"""通过 ID 获取 API Key(不包含敏感信息)"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if owner_id:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM api_keys WHERE id = ? AND owner_id = ?",
|
||||||
|
(key_id, owner_id)
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM api_keys WHERE id = ?",
|
||||||
|
(key_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return self._row_to_api_key(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_keys(
|
||||||
|
self,
|
||||||
|
owner_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[ApiKey]:
|
||||||
|
"""列出 API Keys"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
query = "SELECT * FROM api_keys WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if owner_id:
|
||||||
|
query += " AND owner_id = ?"
|
||||||
|
params.append(owner_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query += " AND status = ?"
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
return [self._row_to_api_key(row) for row in rows]
|
||||||
|
|
||||||
|
def update_key(
|
||||||
|
self,
|
||||||
|
key_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
permissions: Optional[List[str]] = None,
|
||||||
|
rate_limit: Optional[int] = None,
|
||||||
|
owner_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""更新 API Key 信息"""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
updates.append("name = ?")
|
||||||
|
params.append(name)
|
||||||
|
|
||||||
|
if permissions is not None:
|
||||||
|
updates.append("permissions = ?")
|
||||||
|
params.append(json.dumps(permissions))
|
||||||
|
|
||||||
|
if rate_limit is not None:
|
||||||
|
updates.append("rate_limit = ?")
|
||||||
|
params.append(rate_limit)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(key_id)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
# 验证所有权
|
||||||
|
if owner_id:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT owner_id FROM api_keys WHERE id = ?",
|
||||||
|
(key_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row or row[0] != owner_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
query = f"UPDATE api_keys SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def update_last_used(self, key_id: str):
|
||||||
|
"""更新最后使用时间"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET last_used_at = ?, total_calls = total_calls + 1
|
||||||
|
WHERE id = ?
|
||||||
|
""", (datetime.now().isoformat(), key_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def log_api_call(
|
||||||
|
self,
|
||||||
|
api_key_id: str,
|
||||||
|
endpoint: str,
|
||||||
|
method: str,
|
||||||
|
status_code: int = 200,
|
||||||
|
response_time_ms: int = 0,
|
||||||
|
ip_address: str = "",
|
||||||
|
user_agent: str = "",
|
||||||
|
error_message: str = ""
|
||||||
|
):
|
||||||
|
"""记录 API 调用日志"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO api_call_logs
|
||||||
|
(api_key_id, endpoint, method, status_code, response_time_ms,
|
||||||
|
ip_address, user_agent, error_message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
api_key_id, endpoint, method, status_code, response_time_ms,
|
||||||
|
ip_address, user_agent, error_message
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_call_logs(
|
||||||
|
self,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""获取 API 调用日志"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
query = "SELECT * FROM api_call_logs WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if api_key_id:
|
||||||
|
query += " AND api_key_id = ?"
|
||||||
|
params.append(api_key_id)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query += " AND created_at >= ?"
|
||||||
|
params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
query += " AND created_at <= ?"
|
||||||
|
params.append(end_date)
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_call_stats(
|
||||||
|
self,
|
||||||
|
api_key_id: Optional[str] = None,
|
||||||
|
days: int = 30
|
||||||
|
) -> Dict:
|
||||||
|
"""获取 API 调用统计"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# 总体统计
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_calls,
|
||||||
|
COUNT(CASE WHEN status_code < 400 THEN 1 END) as success_calls,
|
||||||
|
COUNT(CASE WHEN status_code >= 400 THEN 1 END) as error_calls,
|
||||||
|
AVG(response_time_ms) as avg_response_time,
|
||||||
|
MAX(response_time_ms) as max_response_time,
|
||||||
|
MIN(response_time_ms) as min_response_time
|
||||||
|
FROM api_call_logs
|
||||||
|
WHERE created_at >= date('now', '-{} days')
|
||||||
|
""".format(days)
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if api_key_id:
|
||||||
|
query = query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at")
|
||||||
|
params.insert(0, api_key_id)
|
||||||
|
|
||||||
|
row = conn.execute(query, params).fetchone()
|
||||||
|
|
||||||
|
# 按端点统计
|
||||||
|
endpoint_query = """
|
||||||
|
SELECT
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
COUNT(*) as calls,
|
||||||
|
AVG(response_time_ms) as avg_time
|
||||||
|
FROM api_call_logs
|
||||||
|
WHERE created_at >= date('now', '-{} days')
|
||||||
|
""".format(days)
|
||||||
|
|
||||||
|
endpoint_params = []
|
||||||
|
if api_key_id:
|
||||||
|
endpoint_query = endpoint_query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at")
|
||||||
|
endpoint_params.insert(0, api_key_id)
|
||||||
|
|
||||||
|
endpoint_query += " GROUP BY endpoint, method ORDER BY calls DESC"
|
||||||
|
|
||||||
|
endpoint_rows = conn.execute(endpoint_query, endpoint_params).fetchall()
|
||||||
|
|
||||||
|
# 按天统计
|
||||||
|
daily_query = """
|
||||||
|
SELECT
|
||||||
|
date(created_at) as date,
|
||||||
|
COUNT(*) as calls,
|
||||||
|
COUNT(CASE WHEN status_code < 400 THEN 1 END) as success
|
||||||
|
FROM api_call_logs
|
||||||
|
WHERE created_at >= date('now', '-{} days')
|
||||||
|
""".format(days)
|
||||||
|
|
||||||
|
daily_params = []
|
||||||
|
if api_key_id:
|
||||||
|
daily_query = daily_query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at")
|
||||||
|
daily_params.insert(0, api_key_id)
|
||||||
|
|
||||||
|
daily_query += " GROUP BY date(created_at) ORDER BY date"
|
||||||
|
|
||||||
|
daily_rows = conn.execute(daily_query, daily_params).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"total_calls": row["total_calls"] or 0,
|
||||||
|
"success_calls": row["success_calls"] or 0,
|
||||||
|
"error_calls": row["error_calls"] or 0,
|
||||||
|
"avg_response_time_ms": round(row["avg_response_time"] or 0, 2),
|
||||||
|
"max_response_time_ms": row["max_response_time"] or 0,
|
||||||
|
"min_response_time_ms": row["min_response_time"] or 0,
|
||||||
|
},
|
||||||
|
"endpoints": [dict(r) for r in endpoint_rows],
|
||||||
|
"daily": [dict(r) for r in daily_rows]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _row_to_api_key(self, row: sqlite3.Row) -> ApiKey:
|
||||||
|
"""将数据库行转换为 ApiKey 对象"""
|
||||||
|
return ApiKey(
|
||||||
|
id=row["id"],
|
||||||
|
key_hash=row["key_hash"],
|
||||||
|
key_preview=row["key_preview"],
|
||||||
|
name=row["name"],
|
||||||
|
owner_id=row["owner_id"],
|
||||||
|
permissions=json.loads(row["permissions"]),
|
||||||
|
rate_limit=row["rate_limit"],
|
||||||
|
status=row["status"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
last_used_at=row["last_used_at"],
|
||||||
|
revoked_at=row["revoked_at"],
|
||||||
|
revoked_reason=row["revoked_reason"],
|
||||||
|
total_calls=row["total_calls"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
_api_key_manager: Optional[ApiKeyManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key_manager() -> ApiKeyManager:
|
||||||
|
"""获取 API Key 管理器实例"""
|
||||||
|
global _api_key_manager
|
||||||
|
if _api_key_manager is None:
|
||||||
|
_api_key_manager = ApiKeyManager()
|
||||||
|
return _api_key_manager
|
||||||
804
backend/main.py
804
backend/main.py
File diff suppressed because it is too large
Load Diff
223
backend/rate_limiter.py
Normal file
223
backend/rate_limiter.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
InsightFlow Rate Limiter - Phase 6
|
||||||
|
API 限流中间件
|
||||||
|
支持基于内存的滑动窗口限流
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Optional, Tuple, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from collections import defaultdict
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitConfig:
|
||||||
|
"""限流配置"""
|
||||||
|
requests_per_minute: int = 60
|
||||||
|
burst_size: int = 10 # 突发请求数
|
||||||
|
window_size: int = 60 # 窗口大小(秒)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitInfo:
|
||||||
|
"""限流信息"""
|
||||||
|
allowed: bool
|
||||||
|
remaining: int
|
||||||
|
reset_time: int # 重置时间戳
|
||||||
|
retry_after: int # 需要等待的秒数
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowCounter:
|
||||||
|
"""滑动窗口计数器"""
|
||||||
|
|
||||||
|
def __init__(self, window_size: int = 60):
|
||||||
|
self.window_size = window_size
|
||||||
|
self.requests: Dict[int, int] = defaultdict(int) # 秒级计数
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def add_request(self) -> int:
|
||||||
|
"""添加请求,返回当前窗口内的请求数"""
|
||||||
|
async with self._lock:
|
||||||
|
now = int(time.time())
|
||||||
|
self.requests[now] += 1
|
||||||
|
self._cleanup_old(now)
|
||||||
|
return sum(self.requests.values())
|
||||||
|
|
||||||
|
async def get_count(self) -> int:
|
||||||
|
"""获取当前窗口内的请求数"""
|
||||||
|
async with self._lock:
|
||||||
|
now = int(time.time())
|
||||||
|
self._cleanup_old(now)
|
||||||
|
return sum(self.requests.values())
|
||||||
|
|
||||||
|
def _cleanup_old(self, now: int):
|
||||||
|
"""清理过期的请求记录"""
|
||||||
|
cutoff = now - self.window_size
|
||||||
|
old_keys = [k for k in self.requests.keys() if k < cutoff]
|
||||||
|
for k in old_keys:
|
||||||
|
del self.requests[k]
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""API 限流器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# key -> SlidingWindowCounter
|
||||||
|
self.counters: Dict[str, SlidingWindowCounter] = {}
|
||||||
|
# key -> RateLimitConfig
|
||||||
|
self.configs: Dict[str, RateLimitConfig] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def is_allowed(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
config: Optional[RateLimitConfig] = None
|
||||||
|
) -> RateLimitInfo:
|
||||||
|
"""
|
||||||
|
检查是否允许请求
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 限流键(如 API Key ID)
|
||||||
|
config: 限流配置,如果为 None 则使用默认配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RateLimitInfo
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = RateLimitConfig()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if key not in self.counters:
|
||||||
|
self.counters[key] = SlidingWindowCounter(config.window_size)
|
||||||
|
self.configs[key] = config
|
||||||
|
|
||||||
|
counter = self.counters[key]
|
||||||
|
stored_config = self.configs.get(key, config)
|
||||||
|
|
||||||
|
# 获取当前计数
|
||||||
|
current_count = await counter.get_count()
|
||||||
|
|
||||||
|
# 计算剩余配额
|
||||||
|
remaining = max(0, stored_config.requests_per_minute - current_count)
|
||||||
|
|
||||||
|
# 计算重置时间
|
||||||
|
now = int(time.time())
|
||||||
|
reset_time = now + stored_config.window_size
|
||||||
|
|
||||||
|
# 检查是否超过限制
|
||||||
|
if current_count >= stored_config.requests_per_minute:
|
||||||
|
return RateLimitInfo(
|
||||||
|
allowed=False,
|
||||||
|
remaining=0,
|
||||||
|
reset_time=reset_time,
|
||||||
|
retry_after=stored_config.window_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# 允许请求,增加计数
|
||||||
|
await counter.add_request()
|
||||||
|
|
||||||
|
return RateLimitInfo(
|
||||||
|
allowed=True,
|
||||||
|
remaining=remaining - 1,
|
||||||
|
reset_time=reset_time,
|
||||||
|
retry_after=0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_limit_info(self, key: str) -> RateLimitInfo:
|
||||||
|
"""获取限流信息(不增加计数)"""
|
||||||
|
if key not in self.counters:
|
||||||
|
config = RateLimitConfig()
|
||||||
|
return RateLimitInfo(
|
||||||
|
allowed=True,
|
||||||
|
remaining=config.requests_per_minute,
|
||||||
|
reset_time=int(time.time()) + config.window_size,
|
||||||
|
retry_after=0
|
||||||
|
)
|
||||||
|
|
||||||
|
counter = self.counters[key]
|
||||||
|
config = self.configs.get(key, RateLimitConfig())
|
||||||
|
|
||||||
|
current_count = await counter.get_count()
|
||||||
|
remaining = max(0, config.requests_per_minute - current_count)
|
||||||
|
reset_time = int(time.time()) + config.window_size
|
||||||
|
|
||||||
|
return RateLimitInfo(
|
||||||
|
allowed=current_count < config.requests_per_minute,
|
||||||
|
remaining=remaining,
|
||||||
|
reset_time=reset_time,
|
||||||
|
retry_after=max(0, config.window_size) if current_count >= config.requests_per_minute else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self, key: Optional[str] = None):
|
||||||
|
"""重置限流计数器"""
|
||||||
|
if key:
|
||||||
|
self.counters.pop(key, None)
|
||||||
|
self.configs.pop(key, None)
|
||||||
|
else:
|
||||||
|
self.counters.clear()
|
||||||
|
self.configs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局限流器实例
|
||||||
|
_rate_limiter: Optional[RateLimiter] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limiter() -> RateLimiter:
|
||||||
|
"""获取限流器实例"""
|
||||||
|
global _rate_limiter
|
||||||
|
if _rate_limiter is None:
|
||||||
|
_rate_limiter = RateLimiter()
|
||||||
|
return _rate_limiter
|
||||||
|
|
||||||
|
|
||||||
|
# 限流装饰器(用于函数级别限流)
|
||||||
|
def rate_limit(
|
||||||
|
requests_per_minute: int = 60,
|
||||||
|
key_func: Optional[Callable] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
限流装饰器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requests_per_minute: 每分钟请求数限制
|
||||||
|
key_func: 生成限流键的函数,默认为 None(使用函数名)
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
limiter = get_rate_limiter()
|
||||||
|
config = RateLimitConfig(requests_per_minute=requests_per_minute)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
key = key_func(*args, **kwargs) if key_func else func.__name__
|
||||||
|
info = await limiter.is_allowed(key, config)
|
||||||
|
|
||||||
|
if not info.allowed:
|
||||||
|
raise RateLimitExceeded(
|
||||||
|
f"Rate limit exceeded. Try again in {info.retry_after} seconds."
|
||||||
|
)
|
||||||
|
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
key = key_func(*args, **kwargs) if key_func else func.__name__
|
||||||
|
# 同步版本使用 asyncio.run
|
||||||
|
info = asyncio.run(limiter.is_allowed(key, config))
|
||||||
|
|
||||||
|
if not info.allowed:
|
||||||
|
raise RateLimitExceeded(
|
||||||
|
f"Rate limit exceeded. Try again in {info.retry_after} seconds."
|
||||||
|
)
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitExceeded(Exception):
|
||||||
|
"""限流异常"""
|
||||||
|
pass
|
||||||
@@ -30,3 +30,6 @@ cairosvg==2.7.1
|
|||||||
|
|
||||||
# Neo4j Graph Database
|
# Neo4j Graph Database
|
||||||
neo4j==5.15.0
|
neo4j==5.15.0
|
||||||
|
|
||||||
|
# API Documentation (Swagger/OpenAPI)
|
||||||
|
fastapi-offline-swagger==0.1.0
|
||||||
|
|||||||
2999
frontend/app.js
2999
frontend/app.js
File diff suppressed because it is too large
Load Diff
@@ -1925,6 +1925,344 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 6: API Key Management Panel */
|
||||||
|
.api-keys-panel {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-panel.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #141414;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stat-card {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #222;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #222;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 120px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 120px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-item:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-preview {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #00d4ff;
|
||||||
|
background: #00d4ff11;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permissions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission.read {
|
||||||
|
background: #00d4ff22;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission.write {
|
||||||
|
background: #7b2cbf22;
|
||||||
|
color: #7b2cbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission.delete {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status.active {
|
||||||
|
background: #00d4ff22;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status.revoked {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status.expired {
|
||||||
|
background: #66666622;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #888;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-btn:hover {
|
||||||
|
border-color: #00d4ff;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-btn.danger:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-modal-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-form-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-form-group input,
|
||||||
|
.api-key-form-group select {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-form-group input:focus,
|
||||||
|
.api-key-form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permissions-select {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-permission-checkbox input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-created-modal .api-key-value {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #00d4ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #00d4ff;
|
||||||
|
margin: 16px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-created-modal .warning {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
border: 1px solid #ff6b6b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stats-modal .stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stats-modal .stat-item {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stats-modal .stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-stats-modal .stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-logs {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px 60px 80px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-endpoint {
|
||||||
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-method {
|
||||||
|
color: #00d4ff;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-status {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-status.success {
|
||||||
|
color: #4ecdc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-status.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-log-time {
|
||||||
|
color: #666;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1946,6 +2284,7 @@
|
|||||||
<button class="sidebar-btn" onclick="switchView('timeline')" title="时间线">📅</button>
|
<button class="sidebar-btn" onclick="switchView('timeline')" title="时间线">📅</button>
|
||||||
<button class="sidebar-btn" onclick="switchView('reasoning')" title="智能推理">🧠</button>
|
<button class="sidebar-btn" onclick="switchView('reasoning')" title="智能推理">🧠</button>
|
||||||
<button class="sidebar-btn" onclick="switchView('graph-analysis')" title="图分析">🕸️</button>
|
<button class="sidebar-btn" onclick="switchView('graph-analysis')" title="图分析">🕸️</button>
|
||||||
|
<button class="sidebar-btn" onclick="switchView('api-keys')" title="API Keys">🔑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
@@ -2324,6 +2663,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Management View -->
|
||||||
|
<div id="apiKeysView" class="api-keys-panel">
|
||||||
|
<div class="api-keys-header">
|
||||||
|
<div>
|
||||||
|
<h2>🔑 API Key 管理</h2>
|
||||||
|
<p style="color:#888;">管理 API 访问密钥和调用统计</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="showCreateApiKeyModal()">+ 创建 API Key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-keys-content">
|
||||||
|
<div class="api-keys-stats">
|
||||||
|
<div class="api-key-stat-card">
|
||||||
|
<div class="api-key-stat-value" id="apiKeyTotalCount">-</div>
|
||||||
|
<div class="api-key-stat-label">总 API Keys</div>
|
||||||
|
</div>
|
||||||
|
<div class="api-key-stat-card">
|
||||||
|
<div class="api-key-stat-value" id="apiKeyActiveCount">-</div>
|
||||||
|
<div class="api-key-stat-label">活跃</div>
|
||||||
|
</div>
|
||||||
|
<div class="api-key-stat-card">
|
||||||
|
<div class="api-key-stat-value" id="apiKeyRevokedCount">-</div>
|
||||||
|
<div class="api-key-stat-label">已撤销</div>
|
||||||
|
</div>
|
||||||
|
<div class="api-key-stat-card">
|
||||||
|
<div class="api-key-stat-value" id="apiKeyTotalCalls">-</div>
|
||||||
|
<div class="api-key-stat-label">总调用次数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-keys-list" id="apiKeysList">
|
||||||
|
<div class="api-keys-list-header">
|
||||||
|
<span>名称 / Key</span>
|
||||||
|
<span>权限</span>
|
||||||
|
<span>限流</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>调用次数</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
<div id="apiKeysListContent">
|
||||||
|
<div class="api-key-empty">
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Entity Hover Card -->
|
<!-- Entity Hover Card -->
|
||||||
<div class="entity-card" id="entityCard">
|
<div class="entity-card" id="entityCard">
|
||||||
<div class="entity-card-header">
|
<div class="entity-card-header">
|
||||||
@@ -2663,6 +3050,122 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Create Modal -->
|
||||||
|
<div class="modal-overlay" id="apiKeyCreateModal">
|
||||||
|
<div class="modal" style="max-width: 500px;">
|
||||||
|
<h3 class="modal-header">创建 API Key</h3>
|
||||||
|
<div class="api-key-modal-form">
|
||||||
|
<div class="api-key-form-group">
|
||||||
|
<label>名称 / 描述</label>
|
||||||
|
<input type="text" id="apiKeyName" placeholder="例如:移动应用开发">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-key-form-group">
|
||||||
|
<label>权限</label>
|
||||||
|
<div class="api-key-permissions-select">
|
||||||
|
<label class="api-key-permission-checkbox">
|
||||||
|
<input type="checkbox" id="permRead" checked>
|
||||||
|
<span>读取 (read)</span>
|
||||||
|
</label>
|
||||||
|
<label class="api-key-permission-checkbox">
|
||||||
|
<input type="checkbox" id="permWrite">
|
||||||
|
<span>写入 (write)</span>
|
||||||
|
</label>
|
||||||
|
<label class="api-key-permission-checkbox">
|
||||||
|
<input type="checkbox" id="permDelete">
|
||||||
|
<span>删除 (delete)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-key-form-group">
|
||||||
|
<label>限流 (请求/分钟)</label>
|
||||||
|
<select id="apiKeyRateLimit">
|
||||||
|
<option value="60">60 (默认)</option>
|
||||||
|
<option value="120">120</option>
|
||||||
|
<option value="300">300</option>
|
||||||
|
<option value="600">600</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-key-form-group">
|
||||||
|
<label>过期时间 (可选)</label>
|
||||||
|
<select id="apiKeyExpires">
|
||||||
|
<option value="">永不过期</option>
|
||||||
|
<option value="7">7 天</option>
|
||||||
|
<option value="30">30 天</option>
|
||||||
|
<option value="90">90 天</option>
|
||||||
|
<option value="365">1 年</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="hideCreateApiKeyModal()">取消</button>
|
||||||
|
<button class="btn" onclick="createApiKey()">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Created Success Modal -->
|
||||||
|
<div class="modal-overlay" id="apiKeyCreatedModal">
|
||||||
|
<div class="modal api-key-created-modal" style="max-width: 600px;">
|
||||||
|
<h3 class="modal-header">✅ API Key 创建成功</h3>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
⚠️ 请立即复制并保存此 API Key!它只会显示一次,之后无法再次查看。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="color:#888;font-size:0.9rem;">你的 API Key:</label>
|
||||||
|
<div class="api-key-value" id="createdApiKeyValue">
|
||||||
|
ak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<button class="btn" onclick="copyApiKey()" style="flex:1;">📋 复制到剪贴板</button>
|
||||||
|
<button class="btn btn-secondary" onclick="hideApiKeyCreatedModal()" style="flex:1;">我已保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Stats Modal -->
|
||||||
|
<div class="modal-overlay" id="apiKeyStatsModal">
|
||||||
|
<div class="modal api-key-stats-modal" style="max-width: 700px;">
|
||||||
|
<h3 class="modal-header" id="apiKeyStatsTitle">API Key 统计</h3>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value" id="statsTotalCalls">-</div>
|
||||||
|
<div class="stat-label">总调用次数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value" id="statsSuccessCalls">-</div>
|
||||||
|
<div class="stat-label">成功</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value" id="statsErrorCalls">-</div>
|
||||||
|
<div class="stat-label">错误</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value" id="statsAvgTime">-</div>
|
||||||
|
<div class="stat-label">平均响应时间 (ms)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style="color:#888;font-size:0.9rem;margin:20px 0 12px;">最近调用日志</h4>
|
||||||
|
<div class="api-key-logs" id="apiKeyLogs">
|
||||||
|
<div class="api-key-empty">
|
||||||
|
<p>暂无调用记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="hideApiKeyStatsModal()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
<!-- Context Menu -->
|
||||||
<div class="context-menu" id="contextMenu">
|
<div class="context-menu" id="contextMenu">
|
||||||
<div class="context-menu-item" onclick="editEntity()">✏️ 编辑实体</div>
|
<div class="context-menu-item" onclick="editEntity()">✏️ 编辑实体</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user