feat: 双模式集成并精简 API 文档/Token、设置页卡片自适应

- 源/仓库各自可在直连(FTP/SFTP、MySQL)与 Metrix 存储/数据库平台间独立选择,两侧互不依赖
- Metrix 模式下源走平台储存 API、仓库走平台导入 + run-script(single_session)、查看导出代理到平台
- 去掉对外 API 文档与 API Token(前后端 + auth/config 解耦),业务接口仅登录态可访问
- 授权默认到期日改为 2026-12-30
- 设置页卡片改横向自适应(宽屏并排、窄屏换行),处理历史保留卡片收窄
This commit is contained in:
2026-06-24 05:34:28 +08:00
parent f708d6947a
commit dab672621c
34 changed files with 1586 additions and 1842 deletions
-135
View File
@@ -1,135 +0,0 @@
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, delete_tokens, list_tokens, regenerate_token, update_token
router = APIRouter(tags=["api-tokens"])
def _require_login(request: Request) -> None:
if resolve_login_context(request) is None:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
def _bad_expiration_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
def _resolve_expires_at(payload: dict[str, Any]) -> str | None:
if bool(payload.get("permanent", False)):
return None
expires_at = str(payload.get("expires_at") or "").strip()
if not expires_at:
raise HTTPException(status_code=400, detail="请选择 Token 到期日期,或设置为永久有效")
return expires_at
@router.get("/api/tokens")
async def get_tokens(request: Request):
_require_login(request)
return {"success": True, "tokens": list_tokens()}
@router.post("/api/tokens/create")
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
name = str(payload.get("name", "")).strip()
enabled = bool(payload.get("enabled", True))
raw_expires_at = _resolve_expires_at(payload)
try:
raw_token, record = create_token(
name=name,
expires_at=raw_expires_at,
enabled=enabled,
)
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
return {
"success": True,
"message": "API Token 创建成功",
"token": raw_token,
"record": record,
}
@router.post("/api/tokens/update")
async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
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, **changes)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/regenerate")
async def regenerate_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
try:
raw_token, record = regenerate_token(token_id)
return {
"success": True,
"message": "API Token 已重新生成",
"token": raw_token,
"record": record,
}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/tokens/delete")
async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_id = str(payload.get("id", "")).strip()
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
delete_token(token_id)
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)
return {
"success": True,
"docs_url": "/api/docs-ui",
"openapi_url": "/api/openapi.json",
"token_header": "Authorization: Bearer <token>",
"alt_header": "X-API-Token: <token>",
"note": "API 文档仅登录后可访问。",
}
+41 -5
View File
@@ -7,8 +7,14 @@ from fastapi import APIRouter, Body, HTTPException, UploadFile, File
from fastapi.responses import Response
from app import state
from app.config import HistoryRetentionConfig, RJDataConfig, RemoteDataConfig
from app.services.api_tokens import export_tokens, import_tokens
from app.config import (
HistoryRetentionConfig,
MetrixConfig,
RJDataConfig,
RemoteDataConfig,
SOURCE_TYPES,
WAREHOUSE_TYPES,
)
router = APIRouter(tags=["config"])
@@ -50,6 +56,30 @@ async def update_remote_config(config: dict[str, Any] = Body(...)):
return {"success": True, "message": "远程数据配置已更新", "update": state.config.update}
@router.post("/api/config/backend")
async def update_backend(
source_type: str = Body(...),
warehouse_type: str = Body(...),
):
if source_type not in SOURCE_TYPES:
raise HTTPException(status_code=400, detail="不支持的源类型")
if warehouse_type not in WAREHOUSE_TYPES:
raise HTTPException(status_code=400, detail="不支持的仓库类型")
state.reload_config()
state.config.source_type = source_type
state.config.warehouse_type = warehouse_type
state.config.save()
return {"success": True, "message": "后端类型已更新", "update": state.config.update}
@router.post("/api/config/metrix")
async def update_metrix_config(config: dict[str, Any] = Body(...)):
state.reload_config()
state.config.metrix = MetrixConfig.from_dict(config)
state.config.save()
return {"success": True, "message": "Metrix 连接配置已更新", "update": state.config.update}
@router.post("/api/config/history-retention")
async def update_history_retention(config: dict[str, Any] = Body(...)):
state.reload_config()
@@ -79,7 +109,6 @@ async def download_config():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"Configure_{timestamp}.json"
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,
@@ -100,8 +129,6 @@ 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:
@@ -113,6 +140,15 @@ async def upload_config(file: UploadFile = File(...)):
def _apply_config_data(data: dict[str, Any]) -> None:
if data.get("SourceType") in SOURCE_TYPES:
state.config.source_type = data["SourceType"]
if data.get("WarehouseType") in WAREHOUSE_TYPES:
state.config.warehouse_type = data["WarehouseType"]
metrix_data = data.get("Metrix")
if isinstance(metrix_data, dict):
state.config.metrix = MetrixConfig.from_dict(metrix_data)
mysql_data = data.get("MySQL_DBInfo")
if isinstance(mysql_data, dict):
for key in ("host", "port", "user", "passwd", "dbname"):
+51 -13
View File
@@ -10,7 +10,9 @@ from starlette.background import BackgroundTask
from app import state
from app.config import CACHE_DIR
from app.database import DatabaseManager
from app.services.platform import make_client
from app.utils.files import remove_file_safely
from app.warehouse import make_warehouse
router = APIRouter(tags=["database"])
@@ -47,19 +49,20 @@ def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame:
return pd.DataFrame(result["data"], columns=columns)
def _db() -> DatabaseManager:
return DatabaseManager(state.current_config())
def _db():
"""Direct MySQL DatabaseManager, or a Metrix-backed warehouse with the same interface."""
return make_warehouse(state.current_config())
@router.post("/api/database/test")
async def test_database():
def test_database():
db = _db()
success, message = db.test_connection()
return {"success": success, "message": message}
@router.get("/api/database/info")
async def get_database_info():
def get_database_info():
db = _db()
try:
return {"success": True, **db.get_server_info()}
@@ -69,7 +72,7 @@ async def get_database_info():
@router.get("/api/database/tables")
@router.post("/api/database/tables")
async def get_tables():
def get_tables():
db = _db()
try:
return {"tables": db.get_tables()}
@@ -78,7 +81,7 @@ async def get_tables():
@router.post("/api/database/table/info")
async def get_table_info(table_name: str = Body(..., embed=True)):
def get_table_info(table_name: str = Body(..., embed=True)):
db = _db()
try:
return db.get_table_info(table_name)
@@ -87,7 +90,7 @@ async def get_table_info(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/data")
async def query_table_data(
def query_table_data(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
@@ -102,7 +105,7 @@ async def query_table_data(
@router.post("/api/database/table/query")
async def query_table_with_filter(
def query_table_with_filter(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
@@ -125,7 +128,7 @@ async def query_table_with_filter(
@router.post("/api/database/table/truncate")
async def truncate_table(table_name: str = Body(..., embed=True)):
def truncate_table(table_name: str = Body(..., embed=True)):
db = _db()
try:
db.truncate_table(table_name)
@@ -135,7 +138,7 @@ async def truncate_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop")
async def drop_table(table_name: str = Body(..., embed=True)):
def drop_table(table_name: str = Body(..., embed=True)):
db = _db()
try:
db.drop_table(table_name)
@@ -145,7 +148,7 @@ async def drop_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop-all")
async def drop_all_tables():
def drop_all_tables():
db = _db()
try:
result = db.drop_all_tables()
@@ -160,7 +163,7 @@ async def drop_all_tables():
@router.post("/api/database/execute")
async def execute_sql(sql: str = Body(..., embed=True)):
def execute_sql(sql: str = Body(..., embed=True)):
db = _db()
try:
success, result = db.execute_sql(sql)
@@ -173,8 +176,10 @@ async def execute_sql(sql: str = Body(..., embed=True)):
raise HTTPException(status_code=500, detail=str(exc)) from exc
# Sync def so FastAPI runs it in a threadpool: exporting large tables (and the Metrix
# export-job polling) is blocking and would otherwise freeze the single-worker event loop.
@router.post("/api/download")
async def download_table(
def download_table(
table_name: Optional[str] = Body(None, embed=True),
table_names: Optional[list[str]] = Body(None, embed=True),
file_format: str = Body("csv", alias="format"),
@@ -188,6 +193,10 @@ async def download_table(
if file_format == "csv" and len(requested_tables) != 1:
raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表")
config = state.current_config()
if config.warehouse_type == "metrix":
return _download_via_metrix(config, requested_tables, file_format)
db = _db()
try:
available_tables = set(db.get_tables())
@@ -230,3 +239,32 @@ async def download_table(
media_type=media_type,
background=BackgroundTask(remove_file_safely, filepath),
)
def _download_via_metrix(config, requested_tables: list[str], file_format: str) -> FileResponse:
"""Metrix 仓库模式:用平台导出任务生成文件后流式返回(避免分页上限丢行)。"""
metrix = config.metrix.normalized()
client = make_client(metrix)
try:
job_id = client.submit_export(metrix.database_conn_id, requested_tables, file_format, metrix.target_database)
job = client.wait_job(job_id)
if job.get("status") != "success":
raise HTTPException(status_code=500, detail=f"导出失败: {job.get('error_code') or job.get('status')}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename_prefix = requested_tables[0] if len(requested_tables) == 1 else "tables"
filename = f"{filename_prefix}_{timestamp}.{file_format}"
filepath = CACHE_DIR / filename
CACHE_DIR.mkdir(parents=True, exist_ok=True)
client.download_job_file(job_id, filepath)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
media_type = "text/csv" if file_format == "csv" else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return FileResponse(
path=str(filepath),
filename=filename,
media_type=media_type,
background=BackgroundTask(remove_file_safely, filepath),
)
+34 -17
View File
@@ -1,3 +1,4 @@
import time
from datetime import date, datetime
from pathlib import Path
from threading import Thread
@@ -14,6 +15,8 @@ from app.api.routers.task_runtime import (
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
from app.processor import DataProcessor, ProcessLogger
from app.services.license import LicenseError, check_processing_allowed
from app.services.platform import PlatformStorageDownloader, make_source_downloader
from app.services.pipeline import RESULT_TABLES, run_import_and_report
from app.services.remote_download import RemoteDataDownloader
@@ -22,12 +25,17 @@ router = APIRouter(tags=["remote"])
@router.post("/api/remote/test")
async def test_remote_connection(config: dict[str, Any] | None = Body(None)):
remote_config = RemoteDataConfig.from_dict(config) if config else state.current_config().remote_data
app_config = state.current_config()
try:
if app_config.source_type == "metrix":
PlatformStorageDownloader(app_config).test_connection()
return {"success": True, "message": "平台储存连接成功"}
# FTP/SFTP: test the posted form config if provided, else the saved one.
remote_config = RemoteDataConfig.from_dict(config) if config else app_config.remote_data
RemoteDataDownloader(remote_config).test_connection()
return {"success": True, "message": "远程服务器连接成功"}
except Exception as exc:
return {"success": False, "message": f"远程服务器连接失败: {exc}"}
return {"success": False, "message": f"连接失败: {exc}"}
@router.post("/api/remote/start")
@@ -123,28 +131,37 @@ def _run_remote_processing(
) -> None:
final_status = "failed"
try:
logger.info(
f"开始远程下载,协议: {remote_config.protocol.upper()},"
f"服务器: {remote_config.host}:{remote_config.port},目录: {remote_config.remote_dir}"
)
downloader = RemoteDataDownloader(remote_config, logger.info)
if app_config.source_type == "metrix":
logger.info(f"开始从平台储存下载,目录: {remote_config.remote_dir}")
else:
logger.info(
f"开始远程下载,协议: {remote_config.protocol.upper()},"
f"服务器: {remote_config.host}:{remote_config.port},目录: {remote_config.remote_dir}"
)
downloader = make_source_downloader(app_config, logger.info)
download_result = downloader.download_to(work_dir, target_dates=target_dates)
logger.success(
f"远程下载完成,共 {download_result.file_count} 个文件,"
f"下载完成,共 {download_result.file_count} 个文件,"
f"{_format_bytes(download_result.total_bytes)}"
)
if download_result.file_count == 0:
raise RuntimeError("远程目录中未下载到任何文件")
raise RuntimeError("源目录中未下载到任何文件")
state.history_manager.update(task_id, file_count=download_result.file_count)
logger.set_stage("license")
log_license_check(logger, check_processing_allowed(work_dir))
processor = DataProcessor(app_config, work_dir, logger)
result = processor.process()
status = "completed" if result.get("success") else "failed"
error = result.get("error")
if app_config.warehouse_type == "metrix":
started = time.time()
run_import_and_report(work_dir, app_config, logger)
status, error, elapsed = "completed", None, round(time.time() - started, 2)
else:
logger.set_stage("license")
log_license_check(logger, check_processing_allowed(work_dir))
processor = DataProcessor(app_config, work_dir, logger)
result = processor.process()
status = "completed" if result.get("success") else "failed"
error = result.get("error")
elapsed = result.get("elapsed_time", 0)
if status == "completed" and remote_config.auto_delete_source:
try:
deleted_count = downloader.delete_source_files(download_result.remote_files)
@@ -159,9 +176,9 @@ def _run_remote_processing(
state.history_manager.update(
task_id,
status=status,
elapsed_time=result.get("elapsed_time", 0),
elapsed_time=elapsed,
error=error,
result_tables=["4G_结果表", "5G_结果表"],
result_tables=RESULT_TABLES,
)
state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id),
+8 -4
View File
@@ -84,11 +84,15 @@ def _run_script(task_id: str, logger: ProcessLogger, logs: list[str], app_config
temp_work_dir: Path | None = None
try:
logger.info("开始执行 SQL 脚本...")
temp_work_dir = CACHE_DIR / task_id
temp_work_dir.mkdir(parents=True, exist_ok=True)
if app_config.warehouse_type == "metrix":
from app.services.pipeline import run_report_sql
processor = DataProcessor(app_config, temp_work_dir, logger)
processor._execute_sql_script()
run_report_sql(app_config, logger)
else:
temp_work_dir = CACHE_DIR / task_id
temp_work_dir.mkdir(parents=True, exist_ok=True)
processor = DataProcessor(app_config, temp_work_dir, logger)
processor._execute_sql_script()
logger.success("SQL 脚本执行完成")
set_task_stage(task_id, "completed", logs, status="completed")
except Exception as exc:
+18 -8
View File
@@ -168,19 +168,29 @@ def _task_finished(task_id: str) -> bool:
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
try:
logger.set_stage("license")
log_license_check(logger, check_processing_allowed(work_dir))
if app_config.warehouse_type == "metrix":
import time
processor = DataProcessor(app_config, work_dir, logger)
result = processor.process()
status = "completed" if result.get("success") else "failed"
error = result.get("error")
from app.services.pipeline import RESULT_TABLES, run_import_and_report
started = time.time()
run_import_and_report(work_dir, app_config, logger)
status, error, elapsed, result_tables = "completed", None, round(time.time() - started, 2), RESULT_TABLES
else:
logger.set_stage("license")
log_license_check(logger, check_processing_allowed(work_dir))
processor = DataProcessor(app_config, work_dir, logger)
result = processor.process()
status = "completed" if result.get("success") else "failed"
error = result.get("error")
elapsed = result.get("elapsed_time", 0)
result_tables = ["4G_结果表", "5G_结果表"]
state.history_manager.update(
task_id,
status=status,
elapsed_time=result.get("elapsed_time", 0),
elapsed_time=elapsed,
error=error,
result_tables=["4G_结果表", "5G_结果表"],
result_tables=result_tables,
)
state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id),