feat: 完善 API Token 管理功能

This commit is contained in:
2026-05-25 12:23:49 +08:00
parent 190ffa34f9
commit 4971cc380f
8 changed files with 279 additions and 44 deletions
+21 -7
View File
@@ -3,7 +3,7 @@ from typing import Any
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth import resolve_login_context
from app.services.api_tokens import create_token, delete_token, list_tokens, regenerate_token, update_token
from app.services.api_tokens import create_token, delete_token, delete_tokens, list_tokens, regenerate_token, update_token
router = APIRouter(tags=["api-tokens"])
@@ -64,13 +64,16 @@ async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
changes: dict[str, Any] = {}
if "name" in payload:
changes["name"] = payload.get("name")
if "enabled" in payload:
changes["enabled"] = payload.get("enabled")
if "permanent" in payload or "expires_at" in payload:
changes["expires_at"] = _resolve_expires_at(payload)
try:
record = update_token(
token_id,
name=payload.get("name"),
enabled=payload.get("enabled"),
expires_at=_resolve_expires_at(payload),
)
record = update_token(token_id, **changes)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
@@ -108,6 +111,17 @@ async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)
return {"success": True, "message": "API Token 已删除"}
@router.post("/api/tokens/batch-delete")
async def batch_delete_api_tokens(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_ids = payload.get("ids", [])
if not isinstance(token_ids, list) or not token_ids:
raise HTTPException(status_code=400, detail="请选择要删除的 Token")
deleted_count = delete_tokens([str(token_id) for token_id in token_ids])
return {"success": True, "message": f"已删除 {deleted_count} 个 API Token", "deleted_count": deleted_count}
@router.get("/api/docs-info")
async def docs_info(request: Request):
_require_login(request)
+6 -1
View File
@@ -8,6 +8,7 @@ from fastapi.responses import Response
from app import state
from app.config import HistoryRetentionConfig, RemoteDataConfig
from app.services.api_tokens import export_tokens, import_tokens
router = APIRouter(tags=["config"])
@@ -77,7 +78,9 @@ async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
async def download_config():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"Configure_{timestamp}.json"
content = json.dumps(state.current_config().to_file_dict(), ensure_ascii=False, indent=2)
config_data = state.current_config().to_file_dict()
config_data["ApiTokens"] = export_tokens()
content = json.dumps(config_data, ensure_ascii=False, indent=2)
return Response(
content=content,
media_type="application/json",
@@ -97,6 +100,8 @@ async def upload_config(file: UploadFile = File(...)):
state.reload_config()
_apply_config_data(data)
if "ApiTokens" in data:
import_tokens(data["ApiTokens"])
state.config.save()
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
except json.JSONDecodeError as exc:
+13 -5
View File
@@ -63,7 +63,7 @@ OPENAPI_TAGS = [
{"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"},
{"name": "缓存", "description": "查看服务端缓存占用。"},
{"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"},
{"name": "API Token", "description": "生成、编辑、重生成和删除 API Token。仅登录态可调用。"},
{"name": "API Token", "description": "生成、复制、启停、编辑、重生成和批量删除 API Token。仅登录态可调用。"},
{"name": "健康检查", "description": "服务健康状态检查。"},
]
OPENAPI_OPERATION_DOCS = {
@@ -266,10 +266,13 @@ OPENAPI_OPERATION_DOCS = {
"description": "设置 Excel/CSV 源字段到数据库字段的映射规则。",
"example": [{"Field": "日期时间", "Extract": ["开始时间"], "Type": "datetime"}],
},
("get", "/api/config/download"): {"summary": "下载配置文件", "description": "下载当前 Configure.json。"},
("get", "/api/config/download"): {
"summary": "下载配置文件",
"description": "下载当前 Configure.json,并附带 ApiTokens,用于迁移或恢复 API Token 配置。",
},
("post", "/api/config/upload"): {
"summary": "上传配置文件",
"description": "上传并应用 Configure.json。仅登录态可访问。",
"description": "上传并应用 Configure.json。文件中包含 ApiTokens 时会同步恢复 API Token。仅登录态可访问。",
"request_description": "multipart/form-data,file 为 Configure.json 文件。",
},
("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"},
@@ -279,10 +282,10 @@ OPENAPI_OPERATION_DOCS = {
"description": "提交激活码,将授权到期日期延长 30 天。",
"example": {"code": "sha256-value"},
},
("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 的脱敏列表。仅登录态可访问。"},
("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 列表,包含可复制的完整 Token。仅登录态可访问。"},
("post", "/api/tokens/create"): {
"summary": "生成 API Token",
"description": "创建新的 API Token。完整 Token 只在本次响应中返回一次。",
"description": "创建新的 API Token。完整 Token 会保存到本地,后续可在列表中重复复制。",
"example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True},
},
("post", "/api/tokens/update"): {
@@ -300,6 +303,11 @@ OPENAPI_OPERATION_DOCS = {
"description": "删除指定 Token。",
"example": {"id": "token-id"},
},
("post", "/api/tokens/batch-delete"): {
"summary": "批量删除 API Token",
"description": "按 ID 批量删除 Token。",
"example": {"ids": ["token-id-1", "token-id-2"]},
},
("get", "/api/docs-info"): {"summary": "查询 API 文档入口", "description": "返回 API 文档和 OpenAPI JSON 地址。仅登录态可访问。"},
}
+69 -14
View File
@@ -29,6 +29,7 @@ class ApiTokenRecord:
enabled: bool
last_used_at: str | None = None
last_used_from: str | None = None
token: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -48,6 +49,28 @@ def list_tokens() -> list[dict[str, Any]]:
return [record_to_public_dict(record) for record in _load_records()]
def export_tokens() -> list[dict[str, Any]]:
with _STORE_LOCK:
return [record.to_dict() for record in _load_records()]
def import_tokens(items: Any) -> int:
if not isinstance(items, list):
return 0
records: list[ApiTokenRecord] = []
for item in items:
if not isinstance(item, dict):
continue
record = _record_from_dict(item)
if record.token_hash:
records.append(record)
with _STORE_LOCK:
_save_records(records)
return len(records)
def create_token(
name: str,
expires_in_days: int | None = None,
@@ -68,6 +91,7 @@ def create_token(
created_at=utc_now(),
expires_at=resolved_expires_at,
enabled=bool(enabled),
token=raw_token,
)
with _STORE_LOCK:
@@ -105,6 +129,18 @@ def delete_token(token_id: str) -> None:
_save_records(records)
def delete_tokens(token_ids: list[str]) -> int:
token_id_set = {str(token_id).strip() for token_id in token_ids if str(token_id).strip()}
if not token_id_set:
return 0
with _STORE_LOCK:
records = _load_records()
kept_records = [record for record in records if record.id not in token_id_set]
_save_records(kept_records)
return len(records) - len(kept_records)
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
with _STORE_LOCK:
records = _load_records()
@@ -116,6 +152,7 @@ def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
record.token_hash = hash_token(raw_token)
record.prefix = raw_token[:12]
record.suffix = raw_token[-12:]
record.token = raw_token
record.created_at = utc_now()
record.last_used_at = None
record.last_used_from = None
@@ -222,6 +259,8 @@ def record_to_public_dict(record: ApiTokenRecord, include_hash: bool = False) ->
"last_used_at": record.last_used_at,
"last_used_from": record.last_used_from,
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
"token": record.token,
"token_available": bool(record.token),
}
if include_hash:
data["token_hash"] = record.token_hash
@@ -250,25 +289,41 @@ def _load_records() -> list[ApiTokenRecord]:
for item in tokens:
if not isinstance(item, dict):
continue
records.append(
ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=str(item.get("token_hash", "")),
prefix=str(item.get("prefix", "")),
suffix=str(item.get("suffix", "")),
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
)
)
record = _record_from_dict(item)
if record.token_hash:
records.append(record)
records.sort(key=lambda record: record.created_at, reverse=True)
return records
def _record_from_dict(item: dict[str, Any]) -> ApiTokenRecord:
raw_token = str(item.get("token") or "").strip() or None
token_hash = str(item.get("token_hash") or "").strip()
if raw_token and not token_hash:
token_hash = hash_token(raw_token)
prefix = str(item.get("prefix") or "")
suffix = str(item.get("suffix") or "")
if raw_token:
prefix = prefix or raw_token[:12]
suffix = suffix or raw_token[-12:]
return ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=token_hash,
prefix=prefix,
suffix=suffix,
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
token=raw_token,
)
def _save_records(records: list[ApiTokenRecord]) -> None:
payload = {"tokens": [record.to_dict() for record in records]}
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")