refactor: 拆分 API Token 管理和文档页面
This commit is contained in:
+351
-8
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user