From 45ad399141b1851a98369af12e5128c5180b341a Mon Sep 17 00:00:00 2001 From: Nixevol Date: Mon, 25 May 2026 11:37:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20API=20Token=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E6=96=87=E6=A1=A3=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- app/main.py | 359 +++++++++++++++++- docs/project_context.md | 8 + frontend/src/AppShell.vue | 2 +- frontend/src/components/ApiDocs.vue | 202 ++++++++++ .../{ApiCenter.vue => ApiTokenManager.vue} | 260 ++++--------- frontend/src/components/LoginView.vue | 5 +- frontend/src/components/SettingsPanel.vue | 7 + frontend/src/router.ts | 4 +- frontend/src/styles.css | 6 + 10 files changed, 653 insertions(+), 208 deletions(-) create mode 100644 frontend/src/components/ApiDocs.vue rename frontend/src/components/{ApiCenter.vue => ApiTokenManager.vue} (61%) diff --git a/README.md b/README.md index c02c058..7a8fc64 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的 - MySQL 数据表查看、清空、删除、CSV/XLSX 导出。 - SQL 脚本在线查看、保存和执行。 - 处理历史、日志查看、历史原始数据打包下载。 -- API Token 管理和登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。 +- 系统设置内置 API Token 管理,左侧提供登录后可见的离线 API 文档,便于内网系统直接调用上传、远程处理、查表和 SQL 执行接口。 - 按 ZIP 文件名数据日期校验本地授权期限,过期后可输入激活码顺延。 -- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、历史保留、密码修改。 +- 系统设置:数据库、远程数据源、Sheet 过滤、字段映射、API Token、历史保留、密码修改。 - 发行形态:Server Portable、Tauri 桌面版、Docker 服务端版。 ## 技术栈 @@ -224,7 +224,7 @@ API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用 ## API Token 与文档 -登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。 +登录后在 `系统设置 > API Token` 可生成、停用、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。 如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。 Token 调用方式: @@ -238,7 +238,7 @@ X-API-Token: ``` API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。 -桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token。 +桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token;Swagger 默认隐藏底部 Schemas 区域,接口分组、说明和常用请求示例使用中文。 授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。 diff --git a/app/main.py b/app/main.py index 17e24e8..14140a6 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,270 @@ LOGIN_ONLY_API_PREFIXES = ( "/api/tokens", ) LOGIN_ONLY_API_PATHS = {"/api/openapi.json", "/api/docs-ui", "/api/docs-info"} +TAG_LABELS = { + "auth": "认证", + "upload": "数据处理", + "remote": "远程数据", + "tasks": "任务状态", + "history": "处理历史", + "database": "数据库", + "script": "脚本", + "config": "系统配置", + "cache": "缓存", + "license": "授权", + "api-tokens": "API Token", + "health": "健康检查", +} +OPENAPI_TAGS = [ + {"name": "认证", "description": "登录、修改密码等登录态接口。"}, + {"name": "数据处理", "description": "上传源数据并启动容量报表处理流程。"}, + {"name": "远程数据", "description": "测试 FTP/SFTP 连接,并从远程目录下载后自动处理。"}, + {"name": "任务状态", "description": "查看当前任务、处理进度和日志。"}, + {"name": "处理历史", "description": "查询、下载和清理历史处理记录及原始数据。"}, + {"name": "数据库", "description": "列出表、查询表、导出表和执行 SQL。API Token 可调用这些业务接口。"}, + {"name": "脚本", "description": "查看、保存和手动执行报表 SQL 脚本。"}, + {"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"}, + {"name": "缓存", "description": "查看服务端缓存占用。"}, + {"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"}, + {"name": "API Token", "description": "生成、编辑、重生成和删除 API Token。仅登录态可调用。"}, + {"name": "健康检查", "description": "服务健康状态检查。"}, +] +OPENAPI_OPERATION_DOCS = { + ("post", "/api/login"): { + "summary": "登录系统", + "description": "使用系统账号密码登录,成功后返回登录 JWT。", + "example": {"username": "root", "password": "capacity"}, + }, + ("post", "/api/change-password"): { + "summary": "修改登录密码", + "description": "修改当前登录用户密码,需要登录 JWT,不支持 API Token 调用。", + "example": {"current_password": "capacity", "new_password": "new-password"}, + }, + ("get", "/health"): {"summary": "健康检查", "description": "返回服务进程是否可用。"}, + ("post", "/api/upload/create"): { + "summary": "创建上传会话", + "description": "创建一个待上传的数据处理会话,返回 session_id。通常用于分批上传文件。", + }, + ("post", "/api/upload"): { + "summary": "上传源数据文件", + "description": "上传 ZIP、CSV 或 Excel 数据文件。可传 session_id 追加到已有上传会话;不传则自动创建并锁定任务。", + "request_description": "multipart/form-data,files 为一个或多个文件,session_id 可选。", + }, + ("post", "/api/upload/complete/{session_id}"): { + "summary": "完成上传会话", + "description": "标记上传会话文件数量,用于前端展示。", + "parameters": {"session_id": "上传会话 ID。"}, + }, + ("post", "/api/process/start"): { + "summary": "开始处理已上传数据", + "description": "对指定上传会话目录执行解压、入库和报表 SQL 脚本。", + "example": {"task_id": "20260519_172457"}, + }, + ("post", "/api/process/status"): { + "summary": "查询处理任务状态", + "description": "返回任务阶段、状态、日志和错误详情。", + "example": {"task_id": "20260519_172457"}, + }, + ("get", "/api/process/active"): { + "summary": "查询当前活跃任务", + "description": "返回当前是否有上传、远程下载、处理或脚本任务正在执行。", + }, + ("get", "/api/task/status"): { + "summary": "查询全局任务锁", + "description": "返回全局任务锁状态、任务 ID、阶段和最近日志。", + }, + ("post", "/api/task/lock"): { + "summary": "锁定任务", + "description": "内部接口:手动占用全局任务锁。", + "example": {"task_id": "manual-task"}, + }, + ("post", "/api/task/unlock"): { + "summary": "释放任务锁", + "description": "内部接口:释放全局任务锁。传 task_id 时只释放匹配的任务。", + "example": {"task_id": "manual-task"}, + }, + ("post", "/api/remote/test"): { + "summary": "测试远程数据源", + "description": "测试 FTP/SFTP 连接。请求体为空时使用系统设置中的远程数据源配置。", + "example": { + "protocol": "sftp", + "host": "127.0.0.1", + "port": 22, + "user": "user", + "passwd": "your-password", + "remote_dir": "/CapacityReportData", + "passive": True, + "timeout": 30, + "auto_delete_source": False, + }, + }, + ("post", "/api/remote/start"): { + "summary": "远程下载并处理", + "description": "从已配置的 FTP/SFTP 目录递归下载源数据,然后自动执行完整处理流程。", + }, + ("post", "/api/history"): { + "summary": "查询处理历史", + "description": "按最近时间返回处理历史记录。", + "example": {"limit": 50}, + }, + ("post", "/api/history/detail"): { + "summary": "查询历史详情", + "description": "返回指定历史记录的基础信息和完整日志。", + "example": {"record_id": "20260519_172457"}, + }, + ("post", "/api/history/download"): { + "summary": "下载历史原始数据", + "description": "将历史记录对应工作目录压缩为 ZIP 后下载,下载响应完成后自动清理临时压缩包。", + "example": {"record_id": "20260519_172457"}, + }, + ("post", "/api/history/size"): { + "summary": "查询历史目录大小", + "description": "统计指定历史记录工作目录的文件数和占用空间。", + "example": {"record_id": "20260519_172457"}, + }, + ("post", "/api/history/delete"): { + "summary": "删除历史记录", + "description": "删除指定处理历史及其本地缓存数据。", + "example": {"record_id": "20260519_172457"}, + }, + ("post", "/api/history/clear"): {"summary": "清空处理历史", "description": "删除全部处理历史及其缓存数据。"}, + ("get", "/api/database/info"): { + "summary": "查询数据库信息", + "description": "返回 MySQL 版本、LOAD DATA INFILE 可用性等诊断信息。", + }, + ("post", "/api/database/test"): {"summary": "测试数据库连接", "description": "测试当前数据库配置是否可连接。"}, + ("get", "/api/database/tables"): {"summary": "列出所有数据表", "description": "返回当前数据库中的全部表名。"}, + ("post", "/api/database/tables"): {"summary": "列出所有数据表", "description": "返回当前数据库中的全部表名。"}, + ("post", "/api/database/table/info"): { + "summary": "查询数据表结构", + "description": "返回指定表的字段结构和行数。", + "example": {"table_name": "4G_结果表"}, + }, + ("post", "/api/database/table/data"): { + "summary": "分页查询数据表", + "description": "按页读取指定表数据,可指定排序字段和排序方向。", + "example": { + "table_name": "4G_结果表", + "page": 1, + "page_size": 50, + "order_by": "日均流量(GB)", + "order_dir": "DESC", + }, + }, + ("post", "/api/database/table/query"): { + "summary": "按条件查询数据表", + "description": "支持分页、排序和字段模糊查询。filters 的 key 为字段名,value 为模糊匹配值。", + "example": { + "table_name": "4G_结果表", + "page": 1, + "page_size": 50, + "filters": {"小区名称": "广州"}, + "order_by": "日均流量(GB)", + "order_dir": "DESC", + }, + }, + ("post", "/api/database/table/truncate"): { + "summary": "清空数据表", + "description": "保留表结构,删除指定表的全部数据。", + "example": {"table_name": "4G_UD"}, + }, + ("post", "/api/database/table/drop"): { + "summary": "删除数据表", + "description": "删除指定数据表。", + "example": {"table_name": "4G_UD"}, + }, + ("post", "/api/database/table/drop-all"): {"summary": "删除全部数据表", "description": "删除当前数据库中的全部表。"}, + ("post", "/api/database/execute"): { + "summary": "执行自定义 SQL", + "description": "执行任意 SQL,包括 SELECT、UPDATE、INSERT、DROP 等。请仅在可信内网环境使用。", + "example": {"sql": "SELECT * FROM `4G_结果表` LIMIT 10"}, + }, + ("post", "/api/download"): { + "summary": "导出数据表", + "description": "导出 CSV 或 XLSX。CSV 每次只能导出一张表,XLSX 可选择多张表并按表名分 sheet。", + "example": {"format": "xlsx", "table_names": ["4G_结果表", "5G_结果表"]}, + }, + ("get", "/api/script/content"): {"summary": "读取 SQL 脚本", "description": "读取当前 ReportScript.sql 内容和修改时间。"}, + ("post", "/api/script/save"): { + "summary": "保存 SQL 脚本", + "description": "覆盖保存 ReportScript.sql 内容。", + "example": {"content": "SELECT 1;"}, + }, + ("post", "/api/script/execute"): {"summary": "手动执行 SQL 脚本", "description": "直接执行当前 ReportScript.sql,并返回脚本任务 ID。"}, + ("get", "/api/config"): {"summary": "读取基础配置", "description": "读取当前系统基础配置。仅登录态可访问。"}, + ("get", "/api/config/full"): {"summary": "读取完整配置", "description": "读取数据库、远程数据源、历史保留、过滤规则和字段映射配置。"}, + ("post", "/api/config/mysql"): { + "summary": "保存数据库配置", + "description": "更新 MySQL 连接配置。", + "example": {"host": "capacity-mysql", "port": 3306, "user": "root", "passwd": "your-password", "dbname": "CapacityReport"}, + }, + ("post", "/api/config/remote"): { + "summary": "保存远程数据源配置", + "description": "更新 FTP/SFTP 自动下载配置。", + "example": { + "enabled": True, + "protocol": "sftp", + "host": "127.0.0.1", + "port": 22, + "user": "user", + "passwd": "your-password", + "remote_dir": "/CapacityReportData", + "passive": True, + "timeout": 30, + "auto_delete_source": False, + }, + }, + ("post", "/api/config/history-retention"): { + "summary": "保存历史保留配置", + "description": "设置处理历史是否自动清理,以及保留最近多少次记录。", + "example": {"enabled": True, "keep_count": 20}, + }, + ("post", "/api/config/sheet-filter"): { + "summary": "保存 Sheet 过滤规则", + "description": "设置需要跳过处理的 Sheet 关键字列表。", + "example": ["指标(计数器)", "Template"], + }, + ("post", "/api/config/extract-fields"): { + "summary": "保存字段映射配置", + "description": "设置 Excel/CSV 源字段到数据库字段的映射规则。", + "example": [{"Field": "日期时间", "Extract": ["开始时间"], "Type": "datetime"}], + }, + ("get", "/api/config/download"): {"summary": "下载配置文件", "description": "下载当前 Configure.json。"}, + ("post", "/api/config/upload"): { + "summary": "上传配置文件", + "description": "上传并应用 Configure.json。仅登录态可访问。", + "request_description": "multipart/form-data,file 为 Configure.json 文件。", + }, + ("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"}, + ("get", "/api/license/status"): {"summary": "查询授权状态", "description": "返回当前授权到期日期和激活 key 标签。"}, + ("post", "/api/license/activate"): { + "summary": "激活授权延期", + "description": "提交激活码,将授权到期日期延长 30 天。", + "example": {"code": "sha256-value"}, + }, + ("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 的脱敏列表。仅登录态可访问。"}, + ("post", "/api/tokens/create"): { + "summary": "生成 API Token", + "description": "创建新的 API Token。完整 Token 只在本次响应中返回一次。", + "example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True}, + }, + ("post", "/api/tokens/update"): { + "summary": "编辑 API Token", + "description": "修改 Token 名称、启停状态和有效期。", + "example": {"id": "token-id", "name": "外部系统接入", "permanent": False, "expires_at": "2026-12-31", "enabled": True}, + }, + ("post", "/api/tokens/regenerate"): { + "summary": "重生成 API Token", + "description": "重生成完整 Token,旧 Token 立即失效。", + "example": {"id": "token-id"}, + }, + ("post", "/api/tokens/delete"): { + "summary": "删除 API Token", + "description": "删除指定 Token。", + "example": {"id": "token-id"}, + }, + ("get", "/api/docs-info"): {"summary": "查询 API 文档入口", "description": "返回 API 文档和 OpenAPI JSON 地址。仅登录态可访问。"}, +} def create_app() -> FastAPI: @@ -123,7 +387,7 @@ def register_frontend(app: FastAPI) -> None: async def serve_docs_ui(request: Request): if resolve_login_context(request) is None: return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"}) - return RedirectResponse(url="/api-center", status_code=302) + return RedirectResponse(url="/api-docs", status_code=302) @app.get("/", include_in_schema=False) @app.get("/{path:path}", include_in_schema=False) @@ -172,7 +436,16 @@ def custom_openapi(app: FastAPI) -> dict: if app.openapi_schema: return app.openapi_schema - schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes) + schema = get_openapi( + title="CapacityReport API", + version=app.version, + description=( + "容量报表数据处理系统接口文档。业务接口支持登录 JWT 或 API Token;" + "系统配置、授权、Token 管理和文档本身仅支持登录态访问。" + ), + routes=app.routes, + ) + schema["tags"] = OPENAPI_TAGS components = schema.setdefault("components", {}) security_schemes = components.setdefault("securitySchemes", {}) security_schemes["BearerAuth"] = { @@ -189,17 +462,87 @@ def custom_openapi(app: FastAPI) -> dict: } for path, methods in schema.get("paths", {}).items(): - if not path.startswith("/api/") or path in {"/api/login"}: - continue - security = [{"BearerAuth": []}] if _is_login_only_api(path) else [{"BearerAuth": []}, {"ApiTokenHeader": []}] - for operation in methods.values(): - if isinstance(operation, dict): - operation["security"] = security + for method, operation in methods.items(): + if not isinstance(operation, dict): + continue + + operation["tags"] = [TAG_LABELS.get(tag, tag) for tag in operation.get("tags", [])] + operation["operationId"] = _make_operation_id(method, path) + + if path.startswith("/api/") and path not in {"/api/login"}: + operation["security"] = ( + [{"BearerAuth": []}] + if _is_login_only_api(path) + else [{"BearerAuth": []}, {"ApiTokenHeader": []}] + ) + + _apply_operation_doc(operation, OPENAPI_OPERATION_DOCS.get((method.lower(), path), {})) app.openapi_schema = schema return schema +def _make_operation_id(method: str, path: str) -> str: + normalized_path = ( + path.strip("/") + .replace("/", "_") + .replace("-", "_") + .replace("{", "") + .replace("}", "") + ) + return f"{method.lower()}_{normalized_path or 'root'}" + + +def _apply_operation_doc(operation: dict, doc: dict) -> None: + if not doc: + return + + for key in ("summary", "description"): + value = doc.get(key) + if value: + operation[key] = value + + request_description = doc.get("request_description") + if request_description and isinstance(operation.get("requestBody"), dict): + operation["requestBody"]["description"] = request_description + + if "example" in doc: + _set_request_example(operation, doc["example"]) + + parameter_descriptions = doc.get("parameters") + if isinstance(parameter_descriptions, dict): + _set_parameter_descriptions(operation, parameter_descriptions) + + +def _set_request_example(operation: dict, example: object) -> None: + request_body = operation.get("requestBody") + if not isinstance(request_body, dict): + return + + content = request_body.get("content") + if not isinstance(content, dict): + return + + media = content.get("application/json") + if not isinstance(media, dict): + media = next((value for value in content.values() if isinstance(value, dict)), None) + if isinstance(media, dict): + media["example"] = example + + +def _set_parameter_descriptions(operation: dict, descriptions: dict[str, str]) -> None: + parameters = operation.get("parameters") + if not isinstance(parameters, list): + return + + for parameter in parameters: + if not isinstance(parameter, dict): + continue + name = parameter.get("name") + if isinstance(name, str) and name in descriptions: + parameter["description"] = descriptions[name] + + app = create_app() diff --git a/docs/project_context.md b/docs/project_context.md index 3491c7b..8950d06 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -1,5 +1,13 @@ # 项目上下文记录 +## 2026-05-25:拆分 API Token 管理和 API 文档 + +- 前端删除旧 `frontend/src/components/ApiCenter.vue`,拆为 `ApiTokenManager.vue` 和 `ApiDocs.vue`:API Token 管理迁入 `系统设置 > API Token` 独立分页,左侧菜单“API 中心”改为“API 文档”,只展示 Swagger 文档。 +- API 文档正式路由为 `/api-docs`,旧 `/api-center` 保留为前端兼容别名;后端 `/api/docs-ui` 改为跳转 `/api-docs`。Swagger UI 仍从登录后可见的 `/api/openapi.json` 加载,Token 传递示例指向系统设置中的 API Token 分页。 +- `app/main.py` 的 OpenAPI 后处理增加中文 tag、接口 summary/description、常用请求示例和稳定 operationId;业务 API 声明登录 JWT 或 API Token 鉴权,配置/授权/Token 管理/文档接口仍只声明登录 JWT。Swagger 前端隐藏底部 Schemas 区域,减少噪音。 +- 修复登录页按钮点击不触发登录的问题:登录按钮改为显式调用 `submit()`,保留密码框回车提交,并在 loading 时防止重复提交。 +- 已验证:`.venv\Scripts\python.exe -m compileall app`、`npm run build` 通过;真实 HTTP 检查未登录 `/api/openapi.json` 返回 401、登录后返回 `CapacityReport API`;浏览器实测点击登录按钮可登录,左侧显示 `API 文档`,系统设置显示 `API Token` 分页,API 文档页可见中文 Swagger 分组。 + ## 2026-05-23:新增 API Token 和离线 API 文档 - API Token 验收补强:非永久 Token 必须明确传入到期日期,非法或缺失到期日期返回 400;到期、停用、重生成旧 Token 均会拒绝业务 API。已实测 API Token 可执行 `/api/database/execute` 和 `/api/database/table/query`,但不能访问 `/api/tokens` 管理接口。 - `frontend/src/components/ApiCenter.vue` 的 Swagger UI 改为通过 `apiUrl('/api/openapi.json')` 加载文档,并在请求拦截器中只在未手动填写 Authorization 时补登录 JWT;桌面端或配置 `VITE_API_BASE` 时,Try it out 请求会自动补全后端基址,避免相对 `/api/*` 请求打到错误 origin。 diff --git a/frontend/src/AppShell.vue b/frontend/src/AppShell.vue index 0c147cb..d7a4802 100644 --- a/frontend/src/AppShell.vue +++ b/frontend/src/AppShell.vue @@ -183,7 +183,7 @@ const menuOptions: MenuOption[] = [ { label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) }, { label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) }, { label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) }, - { label: 'API 中心', key: 'api-center', icon: renderIcon(CodeSlashOutline) }, + { label: 'API 文档', key: 'api-center', icon: renderIcon(CodeSlashOutline) }, { label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) } ]; diff --git a/frontend/src/components/ApiDocs.vue b/frontend/src/components/ApiDocs.vue new file mode 100644 index 0000000..9a15641 --- /dev/null +++ b/frontend/src/components/ApiDocs.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/frontend/src/components/ApiCenter.vue b/frontend/src/components/ApiTokenManager.vue similarity index 61% rename from frontend/src/components/ApiCenter.vue rename to frontend/src/components/ApiTokenManager.vue index a583756..caf45b1 100644 --- a/frontend/src/components/ApiCenter.vue +++ b/frontend/src/components/ApiTokenManager.vue @@ -1,8 +1,8 @@