feat: 完善 API Token 管理功能
This commit is contained in:
@@ -220,11 +220,11 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
|
|||||||
- `ExtractField`:字段映射配置。
|
- `ExtractField`:字段映射配置。
|
||||||
|
|
||||||
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
|
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
|
||||||
API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用前后缀;完整 Token 只在创建或重生成时显示一次。该文件属于运行时数据,不应提交到版本库。
|
API Token 保存在本地 `api_tokens.json`,包含 HMAC 哈希、显示用前后缀和完整 Token,登录后可在列表中重复复制。该文件属于运行时数据,不应提交到版本库。
|
||||||
|
|
||||||
## API 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 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。
|
||||||
|
|
||||||
Token 调用方式:
|
Token 调用方式:
|
||||||
@@ -239,6 +239,7 @@ X-API-Token: <token>
|
|||||||
|
|
||||||
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
|
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
|
||||||
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token;Swagger 默认隐藏底部 Schemas 区域,接口分组、说明和常用请求示例使用中文。
|
桌面端和配置了 `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 次,可主动打开授权延期窗口。
|
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
|
|
||||||
from app.auth import resolve_login_context
|
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"])
|
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:
|
if not token_id:
|
||||||
raise HTTPException(status_code=400, detail="缺少 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:
|
try:
|
||||||
record = update_token(
|
record = update_token(token_id, **changes)
|
||||||
token_id,
|
|
||||||
name=payload.get("name"),
|
|
||||||
enabled=payload.get("enabled"),
|
|
||||||
expires_at=_resolve_expires_at(payload),
|
|
||||||
)
|
|
||||||
return {"success": True, "message": "API Token 已更新", "record": record}
|
return {"success": True, "message": "API Token 已更新", "record": record}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _bad_expiration_error(exc) from 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 已删除"}
|
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")
|
@router.get("/api/docs-info")
|
||||||
async def docs_info(request: Request):
|
async def docs_info(request: Request):
|
||||||
_require_login(request)
|
_require_login(request)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi.responses import Response
|
|||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
from app.config import HistoryRetentionConfig, RemoteDataConfig
|
from app.config import HistoryRetentionConfig, RemoteDataConfig
|
||||||
|
from app.services.api_tokens import export_tokens, import_tokens
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["config"])
|
router = APIRouter(tags=["config"])
|
||||||
@@ -77,7 +78,9 @@ async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
|
|||||||
async def download_config():
|
async def download_config():
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"Configure_{timestamp}.json"
|
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(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
@@ -97,6 +100,8 @@ async def upload_config(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
state.reload_config()
|
state.reload_config()
|
||||||
_apply_config_data(data)
|
_apply_config_data(data)
|
||||||
|
if "ApiTokens" in data:
|
||||||
|
import_tokens(data["ApiTokens"])
|
||||||
state.config.save()
|
state.config.save()
|
||||||
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
|
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
|
|||||||
+13
-5
@@ -63,7 +63,7 @@ OPENAPI_TAGS = [
|
|||||||
{"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"},
|
{"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"},
|
||||||
{"name": "缓存", "description": "查看服务端缓存占用。"},
|
{"name": "缓存", "description": "查看服务端缓存占用。"},
|
||||||
{"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"},
|
{"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"},
|
||||||
{"name": "API Token", "description": "生成、编辑、重生成和删除 API Token。仅登录态可调用。"},
|
{"name": "API Token", "description": "生成、复制、启停、编辑、重生成和批量删除 API Token。仅登录态可调用。"},
|
||||||
{"name": "健康检查", "description": "服务健康状态检查。"},
|
{"name": "健康检查", "description": "服务健康状态检查。"},
|
||||||
]
|
]
|
||||||
OPENAPI_OPERATION_DOCS = {
|
OPENAPI_OPERATION_DOCS = {
|
||||||
@@ -266,10 +266,13 @@ OPENAPI_OPERATION_DOCS = {
|
|||||||
"description": "设置 Excel/CSV 源字段到数据库字段的映射规则。",
|
"description": "设置 Excel/CSV 源字段到数据库字段的映射规则。",
|
||||||
"example": [{"Field": "日期时间", "Extract": ["开始时间"], "Type": "datetime"}],
|
"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"): {
|
("post", "/api/config/upload"): {
|
||||||
"summary": "上传配置文件",
|
"summary": "上传配置文件",
|
||||||
"description": "上传并应用 Configure.json。仅登录态可访问。",
|
"description": "上传并应用 Configure.json。文件中包含 ApiTokens 时会同步恢复 API Token。仅登录态可访问。",
|
||||||
"request_description": "multipart/form-data,file 为 Configure.json 文件。",
|
"request_description": "multipart/form-data,file 为 Configure.json 文件。",
|
||||||
},
|
},
|
||||||
("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"},
|
("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"},
|
||||||
@@ -279,10 +282,10 @@ OPENAPI_OPERATION_DOCS = {
|
|||||||
"description": "提交激活码,将授权到期日期延长 30 天。",
|
"description": "提交激活码,将授权到期日期延长 30 天。",
|
||||||
"example": {"code": "sha256-value"},
|
"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"): {
|
("post", "/api/tokens/create"): {
|
||||||
"summary": "生成 API Token",
|
"summary": "生成 API Token",
|
||||||
"description": "创建新的 API Token。完整 Token 只在本次响应中返回一次。",
|
"description": "创建新的 API Token。完整 Token 会保存到本地,后续可在列表中重复复制。",
|
||||||
"example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True},
|
"example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True},
|
||||||
},
|
},
|
||||||
("post", "/api/tokens/update"): {
|
("post", "/api/tokens/update"): {
|
||||||
@@ -300,6 +303,11 @@ OPENAPI_OPERATION_DOCS = {
|
|||||||
"description": "删除指定 Token。",
|
"description": "删除指定 Token。",
|
||||||
"example": {"id": "token-id"},
|
"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 地址。仅登录态可访问。"},
|
("get", "/api/docs-info"): {"summary": "查询 API 文档入口", "description": "返回 API 文档和 OpenAPI JSON 地址。仅登录态可访问。"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-14
@@ -29,6 +29,7 @@ class ApiTokenRecord:
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
last_used_at: str | None = None
|
last_used_at: str | None = None
|
||||||
last_used_from: str | None = None
|
last_used_from: str | None = None
|
||||||
|
token: str | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return asdict(self)
|
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()]
|
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(
|
def create_token(
|
||||||
name: str,
|
name: str,
|
||||||
expires_in_days: int | None = None,
|
expires_in_days: int | None = None,
|
||||||
@@ -68,6 +91,7 @@ def create_token(
|
|||||||
created_at=utc_now(),
|
created_at=utc_now(),
|
||||||
expires_at=resolved_expires_at,
|
expires_at=resolved_expires_at,
|
||||||
enabled=bool(enabled),
|
enabled=bool(enabled),
|
||||||
|
token=raw_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
with _STORE_LOCK:
|
with _STORE_LOCK:
|
||||||
@@ -105,6 +129,18 @@ def delete_token(token_id: str) -> None:
|
|||||||
_save_records(records)
|
_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]]:
|
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
|
||||||
with _STORE_LOCK:
|
with _STORE_LOCK:
|
||||||
records = _load_records()
|
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.token_hash = hash_token(raw_token)
|
||||||
record.prefix = raw_token[:12]
|
record.prefix = raw_token[:12]
|
||||||
record.suffix = raw_token[-12:]
|
record.suffix = raw_token[-12:]
|
||||||
|
record.token = raw_token
|
||||||
record.created_at = utc_now()
|
record.created_at = utc_now()
|
||||||
record.last_used_at = None
|
record.last_used_at = None
|
||||||
record.last_used_from = 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_at": record.last_used_at,
|
||||||
"last_used_from": record.last_used_from,
|
"last_used_from": record.last_used_from,
|
||||||
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||||
|
"token": record.token,
|
||||||
|
"token_available": bool(record.token),
|
||||||
}
|
}
|
||||||
if include_hash:
|
if include_hash:
|
||||||
data["token_hash"] = record.token_hash
|
data["token_hash"] = record.token_hash
|
||||||
@@ -250,25 +289,41 @@ def _load_records() -> list[ApiTokenRecord]:
|
|||||||
for item in tokens:
|
for item in tokens:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
records.append(
|
record = _record_from_dict(item)
|
||||||
ApiTokenRecord(
|
if record.token_hash:
|
||||||
id=str(item.get("id", "")) or secrets.token_hex(8),
|
records.append(record)
|
||||||
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"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
records.sort(key=lambda record: record.created_at, reverse=True)
|
records.sort(key=lambda record: record.created_at, reverse=True)
|
||||||
return records
|
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:
|
def _save_records(records: list[ApiTokenRecord]) -> None:
|
||||||
payload = {"tokens": [record.to_dict() for record in records]}
|
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")
|
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -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`.
|
- `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/`.
|
- 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-<platform>-x64/`. Tauri desktop still bundles both files through `src-tauri/tauri.conf.json` resources and copies them to app data on first run.
|
- Server Portable still includes `Configure.json` and `ReportScript.sql` inside `dist/server/CapacityReport-Server-<platform>-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.
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
<n-card size="small" class="work-card api-token-card" title="API Token">
|
<n-card size="small" class="work-card api-token-card" title="API Token">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-space size="small">
|
<n-space size="small">
|
||||||
|
<n-button
|
||||||
|
v-if="selectedTokenIds.length > 0"
|
||||||
|
size="small"
|
||||||
|
tertiary
|
||||||
|
type="error"
|
||||||
|
:loading="batchDeleting"
|
||||||
|
@click="confirmBatchDelete"
|
||||||
|
>
|
||||||
|
批量删除 {{ selectedTokenIds.length }}
|
||||||
|
</n-button>
|
||||||
<n-button size="small" type="primary" @click="openCreateDialog">
|
<n-button size="small" type="primary" @click="openCreateDialog">
|
||||||
<template #icon><n-icon><AddOutline /></n-icon></template>
|
<template #icon><n-icon><AddOutline /></n-icon></template>
|
||||||
生成 Token
|
生成 Token
|
||||||
@@ -15,13 +25,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="api-token-intro">
|
<div class="api-token-intro">
|
||||||
API Token 用于内网程序直接调用业务接口。完整 Token 只会在生成或重生成时显示一次,请立即复制保存。
|
API Token 用于内网程序直接调用业务接口。完整 Token 会保存到本地,可在列表中随时复制。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-spin v-if="loadingTokens" class="api-token-loading" />
|
<n-spin v-if="loadingTokens" class="api-token-loading" />
|
||||||
<n-empty v-else-if="tokens.length === 0" description="暂无 API Token" />
|
<n-empty v-else-if="tokens.length === 0" description="暂无 API Token" />
|
||||||
<n-scrollbar v-else class="api-token-list">
|
<n-scrollbar v-else class="api-token-list">
|
||||||
<div v-for="token in tokens" :key="token.id" class="api-token-item">
|
<div v-for="token in tokens" :key="token.id" class="api-token-item">
|
||||||
|
<n-checkbox
|
||||||
|
class="api-token-select"
|
||||||
|
:checked="isTokenSelected(token.id)"
|
||||||
|
@update:checked="setTokenSelected(token.id, $event)"
|
||||||
|
/>
|
||||||
<div class="api-token-main">
|
<div class="api-token-main">
|
||||||
<div class="api-token-title-row">
|
<div class="api-token-title-row">
|
||||||
<span class="api-token-name">{{ token.name }}</span>
|
<span class="api-token-name">{{ token.name }}</span>
|
||||||
@@ -37,9 +52,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="api-token-actions">
|
<div class="api-token-actions">
|
||||||
<n-button size="tiny" tertiary @click="openEditDialog(token)">编辑</n-button>
|
<n-dropdown
|
||||||
<n-button size="tiny" tertiary type="warning" @click="confirmRegenerate(token)">重生成</n-button>
|
trigger="click"
|
||||||
<n-button size="tiny" tertiary type="error" @click="confirmDelete(token)">删除</n-button>
|
:options="tokenActionOptions(token)"
|
||||||
|
@select="handleTokenAction(token, $event)"
|
||||||
|
>
|
||||||
|
<n-button size="tiny" tertiary>
|
||||||
|
<template #icon><n-icon><ChevronDownOutline /></n-icon></template>
|
||||||
|
操作
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
@@ -57,7 +79,7 @@
|
|||||||
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
|
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="有效期">
|
<n-form-item label="有效期">
|
||||||
<n-radio-group v-model:value="tokenForm.permanent">
|
<n-radio-group v-model:value="tokenForm.permanent" @update:value="handlePermanentChange">
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-radio :value="true">永久有效</n-radio>
|
<n-radio :value="true">永久有效</n-radio>
|
||||||
<n-radio :value="false">指定日期</n-radio>
|
<n-radio :value="false">指定日期</n-radio>
|
||||||
@@ -93,8 +115,8 @@
|
|||||||
title="Token 已生成"
|
title="Token 已生成"
|
||||||
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
|
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
|
||||||
>
|
>
|
||||||
<n-alert type="warning" :bordered="false">
|
<n-alert type="success" :bordered="false">
|
||||||
这是唯一一次显示完整 Token,请立即复制并妥善保存。
|
完整 Token 已保存,可在列表中随时复制。请注意只在可信内网环境中使用。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-input
|
<n-input
|
||||||
class="raw-token-input"
|
class="raw-token-input"
|
||||||
@@ -115,8 +137,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
import { useDialog, useMessage, type DropdownOption } from 'naive-ui';
|
||||||
import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
|
import { AddOutline, ChevronDownOutline, RefreshOutline } from '@vicons/ionicons5';
|
||||||
|
|
||||||
import { apiGet, apiPost } from '../api/client';
|
import { apiGet, apiPost } from '../api/client';
|
||||||
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
|
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
|
||||||
@@ -130,13 +152,15 @@ const dialog = useDialog();
|
|||||||
const tokens = ref<ApiTokenRecord[]>([]);
|
const tokens = ref<ApiTokenRecord[]>([]);
|
||||||
const loadingTokens = ref(false);
|
const loadingTokens = ref(false);
|
||||||
const savingToken = ref(false);
|
const savingToken = ref(false);
|
||||||
|
const batchDeleting = ref(false);
|
||||||
const tokenDialogVisible = ref(false);
|
const tokenDialogVisible = ref(false);
|
||||||
const rawTokenVisible = ref(false);
|
const rawTokenVisible = ref(false);
|
||||||
const rawToken = ref('');
|
const rawToken = ref('');
|
||||||
const editingToken = ref<ApiTokenRecord | null>(null);
|
const editingToken = ref<ApiTokenRecord | null>(null);
|
||||||
|
const selectedTokenIds = ref<string[]>([]);
|
||||||
const tokenForm = reactive({
|
const tokenForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
permanent: true,
|
permanent: false,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
@@ -150,6 +174,8 @@ async function loadTokens() {
|
|||||||
try {
|
try {
|
||||||
const result = await apiGet<ApiTokenListResponse>('/api/tokens');
|
const result = await apiGet<ApiTokenListResponse>('/api/tokens');
|
||||||
tokens.value = result.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) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '加载 Token 失败');
|
message.error(error instanceof Error ? error.message : '加载 Token 失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -160,8 +186,8 @@ async function loadTokens() {
|
|||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
editingToken.value = null;
|
editingToken.value = null;
|
||||||
tokenForm.name = '';
|
tokenForm.name = '';
|
||||||
tokenForm.permanent = true;
|
tokenForm.permanent = false;
|
||||||
tokenForm.expires_at = '';
|
tokenForm.expires_at = defaultExpirationDate();
|
||||||
tokenForm.enabled = true;
|
tokenForm.enabled = true;
|
||||||
tokenDialogVisible.value = true;
|
tokenDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -170,17 +196,25 @@ function openEditDialog(token: ApiTokenRecord) {
|
|||||||
editingToken.value = token;
|
editingToken.value = token;
|
||||||
tokenForm.name = token.name;
|
tokenForm.name = token.name;
|
||||||
tokenForm.permanent = !token.expires_at;
|
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;
|
tokenForm.enabled = token.enabled;
|
||||||
tokenDialogVisible.value = true;
|
tokenDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePermanentChange(value: boolean) {
|
||||||
|
tokenForm.permanent = value;
|
||||||
|
if (!value && !tokenForm.expires_at) {
|
||||||
|
tokenForm.expires_at = defaultExpirationDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveToken() {
|
async function saveToken() {
|
||||||
|
const expiresAt = tokenForm.expires_at || defaultExpirationDate();
|
||||||
const payload = {
|
const payload = {
|
||||||
id: editingToken.value?.id,
|
id: editingToken.value?.id,
|
||||||
name: tokenForm.name.trim(),
|
name: tokenForm.name.trim(),
|
||||||
permanent: tokenForm.permanent,
|
permanent: tokenForm.permanent,
|
||||||
expires_at: tokenForm.permanent ? null : tokenForm.expires_at,
|
expires_at: tokenForm.permanent ? null : expiresAt,
|
||||||
enabled: tokenForm.enabled
|
enabled: tokenForm.enabled
|
||||||
};
|
};
|
||||||
if (!payload.name) {
|
if (!payload.name) {
|
||||||
@@ -233,6 +267,22 @@ async function regenerateToken(token: ApiTokenRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleTokenEnabled(token: ApiTokenRecord) {
|
||||||
|
try {
|
||||||
|
const result = await apiPost<ApiTokenMutationResponse>('/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) {
|
function confirmDelete(token: ApiTokenRecord) {
|
||||||
dialog.error({
|
dialog.error({
|
||||||
title: '删除 Token',
|
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<ApiMessage>('/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() {
|
async function copyRawToken() {
|
||||||
await writeClipboard(rawToken.value);
|
await writeClipboard(rawToken.value);
|
||||||
message.success('Token 已复制');
|
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) {
|
async function writeClipboard(text: string) {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -292,6 +415,19 @@ function formatDateTime(value?: string | null): string {
|
|||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
return value.replace('T', ' ').slice(0, 19);
|
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}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -342,6 +478,11 @@ function formatDateTime(value?: string | null): string {
|
|||||||
border-bottom: 1px solid var(--td-border-color-light);
|
border-bottom: 1px solid var(--td-border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-token-select {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.api-token-main {
|
.api-token-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -382,7 +523,7 @@ function formatDateTime(value?: string | null): string {
|
|||||||
.api-token-actions {
|
.api-token-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export interface ApiTokenRecord {
|
|||||||
name: string;
|
name: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
suffix: string;
|
suffix: string;
|
||||||
|
token?: string | null;
|
||||||
|
token_available?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user