| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- """方案计划文档管理系统(DMS)Web 后端.
- 提供 Flask 服务:
- - /api/v1 业务接口(认证、组织用户、角色权限、分类、文档全生命周期、审计等);
- - /api/health 健康检查;
- - 前端 dist 静态托管(一体化分发)。
- 安全说明:Info Extractor 遗留 AI 接口(/api/upload、/api/definition*、
- /api/extract、/api/annotation)已下线——原实现无鉴权且 /api/extract 的
- base_url 参数存在 SSRF 风险,不再由本服务暴露。
- """
- from __future__ import annotations
- import logging
- import os
- import sys
- from logging.handlers import RotatingFileHandler
- from pathlib import Path
- from flask import Flask, jsonify, send_from_directory
- from werkzeug.exceptions import NotFound
- from dms import init_dms
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
- )
- logger = logging.getLogger(__name__)
- app = Flask(__name__)
- # CORS 白名单:默认不启用跨域(前端 dist 由本服务同源托管);
- # 开发或分域部署时通过 DMS_CORS_ORIGINS 指定允许的 Origin(逗号分隔)。
- _cors_origins = [
- item.strip()
- for item in os.environ.get("DMS_CORS_ORIGINS", "").split(",")
- if item.strip()
- ]
- if _cors_origins:
- from flask_cors import CORS
- CORS(app, origins=_cors_origins)
- logger.info("CORS allowed origins: %s", _cors_origins)
- # DMS业务使用独立的 /api/v1 Blueprint。初始化仅注册DMS基础设施。
- init_dms(app)
- def _setup_file_logging() -> None:
- """运行日志落盘(契约1.23):10MB 滚动×5 份;OFF/NONE 关闭。"""
- value = str(app.config.get("DMS_LOG_FILE", "")).strip()
- if not value or value.upper() in {"OFF", "NONE"}:
- return
- try:
- path = Path(value).expanduser()
- path.parent.mkdir(parents=True, exist_ok=True)
- handler = RotatingFileHandler(
- path,
- maxBytes=10 * 1024 * 1024,
- backupCount=5,
- encoding="utf-8",
- )
- handler.setFormatter(
- logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
- )
- logging.getLogger().addHandler(handler)
- logger.info("File logging enabled: %s", path)
- except OSError:
- logger.warning("运行日志文件启用失败,仅输出到控制台", exc_info=True)
- def _start_metric_sampler() -> None:
- """资源采样线程(契约1.23):30 秒间隔写库,保留 7 天。"""
- from dms.services.system_management_service import MetricSampler
- sampler = MetricSampler(
- app,
- interval_seconds=app.config.get("DMS_METRIC_INTERVAL_SECONDS", 30),
- retention_days=app.config.get("DMS_METRIC_RETENTION_DAYS", 7),
- )
- app.extensions["dms_metric_sampler"] = sampler
- sampler.start()
- # 测试导入本模块(conftest.flask_app)时不启动采样线程/日志文件,
- # 避免向开发库周期写入监控样本与在源码目录产生日志文件。
- if "pytest" not in sys.modules:
- _setup_file_logging()
- _start_metric_sampler()
- # ===== 前端静态托管(一体化分发用)=====
- def _resolve_frontend_dist() -> Path | None:
- """定位前端 dist 目录:优先环境变量,其次 PyInstaller 资源,最后开发路径"""
- env_path = os.environ.get("DMS_FRONTEND_DIST", "").strip()
- if env_path:
- p = Path(env_path)
- return p if p.exists() else None
- if hasattr(sys, "_MEIPASS"):
- p = Path(sys._MEIPASS) / "frontend_dist"
- return p if p.exists() else None
- dev = Path(__file__).resolve().parent.parent / "frontend" / "dist"
- return dev if dev.exists() else None
- FRONTEND_DIST = _resolve_frontend_dist()
- @app.route("/", defaults={"path": ""})
- @app.route("/<path:path>")
- def serve_spa(path: str):
- # /api/* 已由上方蓝图和显式路由优先匹配;漏网的 api 请求返回 404
- if path.startswith("api/"):
- # /api/v1 漏网请求抛出路由404,交给DMS统一错误处理器,
- # 保持错误结构与X-Request-Id响应头一致(一体化部署下同样生效)
- if path == "api/v1" or path.startswith("api/v1/"):
- raise NotFound()
- return jsonify({"error": "无权限访问"}), 404
- if FRONTEND_DIST is None:
- # API-only 模式(无前端资源):状态码与原"不注册路由"等价,统一 404
- return jsonify({"error": "无权限访问"}), 404
- candidate = FRONTEND_DIST / path
- if path and candidate.is_file():
- return send_from_directory(FRONTEND_DIST, path)
- # 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径;
- # 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性,
- # 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」)
- if not path:
- return send_from_directory(FRONTEND_DIST, "index.html")
- return jsonify({"error": "无权限访问"}), 404
- if FRONTEND_DIST is not None:
- logger.info("Frontend dist mounted at: %s", FRONTEND_DIST)
- else:
- logger.warning("Frontend dist not found; running in API-only mode")
- # ===== 前端静态托管 END =====
- @app.route("/api/health", methods=["GET"])
- def health():
- return jsonify({"status": "ok"})
- if __name__ == "__main__":
- app.run(
- host=os.environ.get("DMS_HOST", "0.0.0.0"),
- port=int(os.environ.get("DMS_PORT", "9345")),
- debug=False,
- )
|