From 4971cc380fcd29ff3449edb68a36c03c7090c479 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Mon, 25 May 2026 12:23:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20API=20Token=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- app/api/routers/api_tokens.py | 28 +++- app/api/routers/config.py | 7 +- app/main.py | 18 ++- app/services/api_tokens.py | 83 ++++++++-- docs/project_context.md | 9 ++ frontend/src/components/ApiTokenManager.vue | 171 ++++++++++++++++++-- frontend/src/types.ts | 2 + 8 files changed, 279 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 7a8fc64..5010826 100644 --- a/README.md +++ b/README.md @@ -220,11 +220,11 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W - `ExtractField`:字段映射配置。 登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。 -API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用前后缀;完整 Token 只在创建或重生成时显示一次。该文件属于运行时数据,不应提交到版本库。 +API Token 保存在本地 `api_tokens.json`,包含 HMAC 哈希、显示用前后缀和完整 Token,登录后可在列表中重复复制。该文件属于运行时数据,不应提交到版本库。 ## API Token 与文档 -登录后在 `系统设置 > API Token` 可生成、停用、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。 +登录后在 `系统设置 > API Token` 可生成、复制、启用/停用、批量删除、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。 如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。 Token 调用方式: @@ -239,6 +239,7 @@ X-API-Token: API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。 桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token;Swagger 默认隐藏底部 Schemas 区域,接口分组、说明和常用请求示例使用中文。 +配置下载会在 JSON 中附带 `ApiTokens`,配置上传时如果包含该字段会同步恢复 API Token。 授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。 diff --git a/app/api/routers/api_tokens.py b/app/api/routers/api_tokens.py index 0ff28ff..fae4a9f 100644 --- a/app/api/routers/api_tokens.py +++ b/app/api/routers/api_tokens.py @@ -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) diff --git a/app/api/routers/config.py b/app/api/routers/config.py index fe1c538..fb29995 100644 --- a/app/api/routers/config.py +++ b/app/api/routers/config.py @@ -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: diff --git a/app/main.py b/app/main.py index 14140a6..4004569 100644 --- a/app/main.py +++ b/app/main.py @@ -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 地址。仅登录态可访问。"}, } diff --git a/app/services/api_tokens.py b/app/services/api_tokens.py index eaa45fd..9759e8a 100644 --- a/app/services/api_tokens.py +++ b/app/services/api_tokens.py @@ -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") diff --git a/docs/project_context.md b/docs/project_context.md index c6bbf53..fc5b23e 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -518,3 +518,12 @@ - `scripts/build.ps1` and `scripts/build.sh` now use `dist/.tmp/` for PyInstaller work output and copy final deliverables to `dist/server/`, `dist/desktop/`, and `dist/docker/`. Successful builds remove `dist/.tmp`, `frontend/dist`, `src-tauri/target`, and `src-tauri/binaries`. - Docker builds now include `Configure.json` in the image and also create a deployable `dist/docker/` bundle containing `capacity-report-app-latest.tar`, `docker-compose.yml`, `Configure.json`, `ReportScript.sql`, `mysql/`, `cache/`, and `logs/`. - Server Portable still includes `Configure.json` and `ReportScript.sql` inside `dist/server/CapacityReport-Server--x64/`. Tauri desktop still bundles both files through `src-tauri/tauri.conf.json` resources and copies them to app data on first run. + +## 2026-05-25: API Token management improvements + +- API Token records now persist the complete token value in `api_tokens.json` in addition to the HMAC hash, prefix, and suffix. Existing hash-only records remain readable but cannot expose the full token; the UI asks users to regenerate those tokens before copying. +- `app/services/api_tokens.py` now supports exporting/importing token records for configuration migration and batch deletion by token ID. Config download adds an `ApiTokens` block, and config upload restores it when present. +- Token update is a partial update path: callers may change only `name`, `enabled`, or expiration fields without accidentally changing unspecified fields. +- `frontend/src/components/ApiTokenManager.vue` now shows selectable token rows, a batch delete button, a compact per-row action dropdown, copy-token action, and enable/disable action. New token creation defaults to a specified expiration date one month after the current browser date, while permanent tokens remain available via the radio option. +- OpenAPI and README text were updated to describe repeatable token copying, token migration through config upload/download, and batch delete. +- Verification performed: `api_tokens` service create/list/verify/enable/disable/export/import/batch-delete test passed, HTTP endpoints for create/list/update/config download/batch-delete passed on local port `9081`, `.venv\Scripts\python.exe -m compileall app` passed, and `npm run build` passed with only the existing Vite large chunk warning. diff --git a/frontend/src/components/ApiTokenManager.vue b/frontend/src/components/ApiTokenManager.vue index ff8a971..a2ef67e 100644 --- a/frontend/src/components/ApiTokenManager.vue +++ b/frontend/src/components/ApiTokenManager.vue @@ -3,6 +3,16 @@
- API Token 用于内网程序直接调用业务接口。完整 Token 只会在生成或重生成时显示一次,请立即复制保存。 + API Token 用于内网程序直接调用业务接口。完整 Token 会保存到本地,可在列表中随时复制。
+
{{ token.name }} @@ -37,9 +52,16 @@
- 编辑 - 重生成 - 删除 + + + + 操作 + +
@@ -57,7 +79,7 @@ - + 永久有效 指定日期 @@ -93,8 +115,8 @@ title="Token 已生成" :style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }" > - - 这是唯一一次显示完整 Token,请立即复制并妥善保存。 + + 完整 Token 已保存,可在列表中随时复制。请注意只在可信内网环境中使用。 import { onMounted, reactive, ref } from 'vue'; -import { useDialog, useMessage } from 'naive-ui'; -import { AddOutline, RefreshOutline } from '@vicons/ionicons5'; +import { useDialog, useMessage, type DropdownOption } from 'naive-ui'; +import { AddOutline, ChevronDownOutline, RefreshOutline } from '@vicons/ionicons5'; import { apiGet, apiPost } from '../api/client'; import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types'; @@ -130,13 +152,15 @@ const dialog = useDialog(); const tokens = ref([]); const loadingTokens = ref(false); const savingToken = ref(false); +const batchDeleting = ref(false); const tokenDialogVisible = ref(false); const rawTokenVisible = ref(false); const rawToken = ref(''); const editingToken = ref(null); +const selectedTokenIds = ref([]); const tokenForm = reactive({ name: '', - permanent: true, + permanent: false, expires_at: '', enabled: true }); @@ -150,6 +174,8 @@ async function loadTokens() { try { const result = await apiGet('/api/tokens'); tokens.value = result.tokens; + const tokenIds = new Set(tokens.value.map(token => token.id)); + selectedTokenIds.value = selectedTokenIds.value.filter(id => tokenIds.has(id)); } catch (error) { message.error(error instanceof Error ? error.message : '加载 Token 失败'); } finally { @@ -160,8 +186,8 @@ async function loadTokens() { function openCreateDialog() { editingToken.value = null; tokenForm.name = ''; - tokenForm.permanent = true; - tokenForm.expires_at = ''; + tokenForm.permanent = false; + tokenForm.expires_at = defaultExpirationDate(); tokenForm.enabled = true; tokenDialogVisible.value = true; } @@ -170,17 +196,25 @@ function openEditDialog(token: ApiTokenRecord) { editingToken.value = token; tokenForm.name = token.name; tokenForm.permanent = !token.expires_at; - tokenForm.expires_at = token.expires_at?.slice(0, 10) || ''; + tokenForm.expires_at = token.expires_at?.slice(0, 10) || defaultExpirationDate(); tokenForm.enabled = token.enabled; tokenDialogVisible.value = true; } +function handlePermanentChange(value: boolean) { + tokenForm.permanent = value; + if (!value && !tokenForm.expires_at) { + tokenForm.expires_at = defaultExpirationDate(); + } +} + async function saveToken() { + const expiresAt = tokenForm.expires_at || defaultExpirationDate(); const payload = { id: editingToken.value?.id, name: tokenForm.name.trim(), permanent: tokenForm.permanent, - expires_at: tokenForm.permanent ? null : tokenForm.expires_at, + expires_at: tokenForm.permanent ? null : expiresAt, enabled: tokenForm.enabled }; if (!payload.name) { @@ -233,6 +267,22 @@ async function regenerateToken(token: ApiTokenRecord) { } } +async function toggleTokenEnabled(token: ApiTokenRecord) { + try { + const result = await apiPost('/api/tokens/update', { + id: token.id, + name: token.name, + permanent: !token.expires_at, + expires_at: token.expires_at?.slice(0, 10) || null, + enabled: !token.enabled + }); + message.success(result.message || (token.enabled ? 'Token 已停用' : 'Token 已启用')); + await loadTokens(); + } catch (error) { + message.error(error instanceof Error ? error.message : '更新 Token 状态失败'); + } +} + function confirmDelete(token: ApiTokenRecord) { dialog.error({ title: '删除 Token', @@ -253,11 +303,84 @@ async function deleteToken(token: ApiTokenRecord) { } } +function confirmBatchDelete() { + if (selectedTokenIds.value.length === 0) return; + dialog.error({ + title: '批量删除 Token', + content: `确认删除选中的 ${selectedTokenIds.value.length} 个 Token 吗?此操作不可恢复。`, + positiveText: '删除', + negativeText: '取消', + onPositiveClick: batchDeleteTokens + }); +} + +async function batchDeleteTokens() { + batchDeleting.value = true; + try { + const result = await apiPost('/api/tokens/batch-delete', { ids: selectedTokenIds.value }); + selectedTokenIds.value = []; + message.success(result.message || 'Token 已删除'); + await loadTokens(); + } catch (error) { + message.error(error instanceof Error ? error.message : '批量删除 Token 失败'); + } finally { + batchDeleting.value = false; + } +} + +async function copyToken(token: ApiTokenRecord) { + if (!token.token) { + message.warning('该 Token 是旧版本生成的,未保存完整值,请重生成后再复制'); + return; + } + await writeClipboard(token.token); + message.success('Token 已复制'); +} + async function copyRawToken() { await writeClipboard(rawToken.value); message.success('Token 已复制'); } +function tokenActionOptions(token: ApiTokenRecord): DropdownOption[] { + return [ + { label: '复制 Token', key: 'copy' }, + { label: '编辑', key: 'edit' }, + { label: token.enabled ? '停用' : '启用', key: 'toggle' }, + { label: '重生成', key: 'regenerate' }, + { label: '删除', key: 'delete' } + ]; +} + +function handleTokenAction(token: ApiTokenRecord, rawKey: string | number) { + const key = String(rawKey); + if (key === 'copy') { + void copyToken(token); + } else if (key === 'edit') { + openEditDialog(token); + } else if (key === 'toggle') { + void toggleTokenEnabled(token); + } else if (key === 'regenerate') { + confirmRegenerate(token); + } else if (key === 'delete') { + confirmDelete(token); + } +} + +function isTokenSelected(tokenId: string): boolean { + return selectedTokenIds.value.includes(tokenId); +} + +function setTokenSelected(tokenId: string, checked: boolean | string | number) { + if (Boolean(checked)) { + if (!selectedTokenIds.value.includes(tokenId)) { + selectedTokenIds.value.push(tokenId); + } + return; + } + selectedTokenIds.value = selectedTokenIds.value.filter(id => id !== tokenId); +} + async function writeClipboard(text: string) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); @@ -292,6 +415,19 @@ function formatDateTime(value?: string | null): string { if (!value) return '-'; return value.replace('T', ' ').slice(0, 19); } + +function defaultExpirationDate(): string { + const date = new Date(); + date.setMonth(date.getMonth() + 1); + return formatDateInput(date); +} + +function formatDateInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}