app.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """方案计划文档管理系统(DMS)Web 后端.
  2. 提供 Flask 服务:
  3. - /api/v1 业务接口(认证、组织用户、角色权限、分类、文档全生命周期、审计等);
  4. - /api/health 健康检查;
  5. - 前端 dist 静态托管(一体化分发)。
  6. 安全说明:Info Extractor 遗留 AI 接口(/api/upload、/api/definition*、
  7. /api/extract、/api/annotation)已下线——原实现无鉴权且 /api/extract 的
  8. base_url 参数存在 SSRF 风险,不再由本服务暴露。
  9. """
  10. from __future__ import annotations
  11. import logging
  12. import os
  13. import sys
  14. from logging.handlers import RotatingFileHandler
  15. from pathlib import Path
  16. from flask import Flask, jsonify, send_from_directory
  17. from werkzeug.exceptions import NotFound
  18. from dms import init_dms
  19. logging.basicConfig(
  20. level=logging.INFO,
  21. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  22. )
  23. logger = logging.getLogger(__name__)
  24. app = Flask(__name__)
  25. # CORS 白名单:默认不启用跨域(前端 dist 由本服务同源托管);
  26. # 开发或分域部署时通过 DMS_CORS_ORIGINS 指定允许的 Origin(逗号分隔)。
  27. _cors_origins = [
  28. item.strip()
  29. for item in os.environ.get("DMS_CORS_ORIGINS", "").split(",")
  30. if item.strip()
  31. ]
  32. if _cors_origins:
  33. from flask_cors import CORS
  34. CORS(app, origins=_cors_origins)
  35. logger.info("CORS allowed origins: %s", _cors_origins)
  36. # DMS业务使用独立的 /api/v1 Blueprint。初始化仅注册DMS基础设施。
  37. init_dms(app)
  38. def _setup_file_logging() -> None:
  39. """运行日志落盘(契约1.23):10MB 滚动×5 份;OFF/NONE 关闭。"""
  40. value = str(app.config.get("DMS_LOG_FILE", "")).strip()
  41. if not value or value.upper() in {"OFF", "NONE"}:
  42. return
  43. try:
  44. path = Path(value).expanduser()
  45. path.parent.mkdir(parents=True, exist_ok=True)
  46. handler = RotatingFileHandler(
  47. path,
  48. maxBytes=10 * 1024 * 1024,
  49. backupCount=5,
  50. encoding="utf-8",
  51. )
  52. handler.setFormatter(
  53. logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
  54. )
  55. logging.getLogger().addHandler(handler)
  56. logger.info("File logging enabled: %s", path)
  57. except OSError:
  58. logger.warning("运行日志文件启用失败,仅输出到控制台", exc_info=True)
  59. def _start_metric_sampler() -> None:
  60. """资源采样线程(契约1.23):30 秒间隔写库,保留 7 天。"""
  61. from dms.services.system_management_service import MetricSampler
  62. sampler = MetricSampler(
  63. app,
  64. interval_seconds=app.config.get("DMS_METRIC_INTERVAL_SECONDS", 30),
  65. retention_days=app.config.get("DMS_METRIC_RETENTION_DAYS", 7),
  66. )
  67. app.extensions["dms_metric_sampler"] = sampler
  68. sampler.start()
  69. # 测试导入本模块(conftest.flask_app)时不启动采样线程/日志文件,
  70. # 避免向开发库周期写入监控样本与在源码目录产生日志文件。
  71. if "pytest" not in sys.modules:
  72. _setup_file_logging()
  73. _start_metric_sampler()
  74. # ===== 前端静态托管(一体化分发用)=====
  75. def _resolve_frontend_dist() -> Path | None:
  76. """定位前端 dist 目录:优先环境变量,其次 PyInstaller 资源,最后开发路径"""
  77. env_path = os.environ.get("DMS_FRONTEND_DIST", "").strip()
  78. if env_path:
  79. p = Path(env_path)
  80. return p if p.exists() else None
  81. if hasattr(sys, "_MEIPASS"):
  82. p = Path(sys._MEIPASS) / "frontend_dist"
  83. return p if p.exists() else None
  84. dev = Path(__file__).resolve().parent.parent / "frontend" / "dist"
  85. return dev if dev.exists() else None
  86. FRONTEND_DIST = _resolve_frontend_dist()
  87. @app.route("/", defaults={"path": ""})
  88. @app.route("/<path:path>")
  89. def serve_spa(path: str):
  90. # /api/* 已由上方蓝图和显式路由优先匹配;漏网的 api 请求返回 404
  91. if path.startswith("api/"):
  92. # /api/v1 漏网请求抛出路由404,交给DMS统一错误处理器,
  93. # 保持错误结构与X-Request-Id响应头一致(一体化部署下同样生效)
  94. if path == "api/v1" or path.startswith("api/v1/"):
  95. raise NotFound()
  96. return jsonify({"error": "无权限访问"}), 404
  97. if FRONTEND_DIST is None:
  98. # API-only 模式(无前端资源):状态码与原"不注册路由"等价,统一 404
  99. return jsonify({"error": "无权限访问"}), 404
  100. candidate = FRONTEND_DIST / path
  101. if path and candidate.is_file():
  102. return send_from_directory(FRONTEND_DIST, path)
  103. # 前端无路径路由(仅 query 传参),SPA 回退只需覆盖根路径;
  104. # 合法 URL/API 之外的未知路径一律 404 且提示「无权限」(不泄露路径存在性,
  105. # 与登录前深链接行为统一:未登录无论输入合法非法路径都只见「无权限」)
  106. if not path:
  107. return send_from_directory(FRONTEND_DIST, "index.html")
  108. return jsonify({"error": "无权限访问"}), 404
  109. if FRONTEND_DIST is not None:
  110. logger.info("Frontend dist mounted at: %s", FRONTEND_DIST)
  111. else:
  112. logger.warning("Frontend dist not found; running in API-only mode")
  113. # ===== 前端静态托管 END =====
  114. @app.route("/api/health", methods=["GET"])
  115. def health():
  116. return jsonify({"status": "ok"})
  117. if __name__ == "__main__":
  118. app.run(
  119. host=os.environ.get("DMS_HOST", "0.0.0.0"),
  120. port=int(os.environ.get("DMS_PORT", "9345")),
  121. debug=False,
  122. )