config.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """DMS环境配置。"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from pathlib import Path
  6. from typing import Any
  7. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  8. import shutil
  9. from dms.storage.envelope import load_kek
  10. def _json_serializer(value: Any) -> str:
  11. """SQLAlchemy JSON 列序列化:原文中文(ensure_ascii=False),对齐达梦存量与 Java 版。"""
  12. return json.dumps(value, ensure_ascii=False)
  13. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  14. DEFAULT_STORAGE_ROOT = BACKEND_ROOT / "dms-storage"
  15. DEFAULT_UI_DICTIONARY_CONFIG_PATH = (
  16. BACKEND_ROOT / "dms" / "resources" / "ui-dictionaries.zh-CN.json"
  17. )
  18. DEFAULT_DOCUMENT_RELATION_PRESET_CONFIG_PATH = (
  19. BACKEND_ROOT / "dms" / "resources" / "document-relation-presets.zh-CN.json"
  20. )
  21. # 当前数据库:达梦 DM(dm+dmPython 方言,默认端口 5236,schema 由用户 DMS 决定)。
  22. # 原 MySQL 默认链接(保留备查,回切时恢复):
  23. # "mysql+pymysql://dms_app:change-me@127.0.0.1:3306/dms?charset=utf8mb4"
  24. DEFAULT_DATABASE_URL = "dm+dmPython://DMS:change-me@127.0.0.1:5236"
  25. def _positive_int(name: str, default: int) -> int:
  26. raw_value = os.environ.get(name)
  27. if raw_value is None or not raw_value.strip():
  28. return default
  29. try:
  30. value = int(raw_value)
  31. except ValueError as exc:
  32. raise ValueError(f"{name}必须是正整数") from exc
  33. if value <= 0:
  34. raise ValueError(f"{name}必须是正整数")
  35. return value
  36. def _non_negative_int(name: str, default: int) -> int:
  37. """非负整数(0 有语义:表示关闭该能力,如空闲登出)。"""
  38. raw_value = os.environ.get(name)
  39. if raw_value is None or not raw_value.strip():
  40. return default
  41. try:
  42. value = int(raw_value)
  43. except ValueError as exc:
  44. raise ValueError(f"{name}必须是非负整数") from exc
  45. if value < 0:
  46. raise ValueError(f"{name}必须是非负整数")
  47. return value
  48. def resolve_backup_config_path() -> Path:
  49. """备份配置文件路径:环境变量覆盖,缺省为工作目录(与 Java 版口径一致)。"""
  50. value = os.environ.get("DMS_BACKUP_CONFIG_PATH", "").strip()
  51. if value:
  52. return Path(value).expanduser().resolve()
  53. return Path("dms-backup-config.json").resolve()
  54. def resolve_env_path() -> Path:
  55. """.env 路径(备份 best-effort 副本用):环境变量覆盖,缺省为工作目录。"""
  56. value = os.environ.get("DMS_ENV_FILE", "").strip()
  57. if value:
  58. return Path(value).expanduser().resolve()
  59. return Path(".env").resolve()
  60. def load_dms_config() -> dict[str, Any]:
  61. """从环境变量加载DMS配置,不读取或覆盖现有AI配置。"""
  62. storage_value = os.environ.get("DMS_STORAGE_ROOT", "").strip()
  63. storage_root = Path(storage_value or DEFAULT_STORAGE_ROOT).expanduser()
  64. max_upload_size = _positive_int(
  65. "DMS_MAX_UPLOAD_SIZE", 100 * 1024 * 1024
  66. )
  67. max_file_size = _positive_int(
  68. "DMS_MAX_FILE_SIZE", min(50 * 1024 * 1024, max_upload_size)
  69. )
  70. if max_file_size > max_upload_size:
  71. raise ValueError("DMS_MAX_FILE_SIZE不得超过DMS_MAX_UPLOAD_SIZE")
  72. database_url = os.environ.get("DMS_DATABASE_URL", "").strip()
  73. ui_dictionary_config_value = os.environ.get(
  74. "DMS_UI_DICTIONARY_CONFIG_PATH", ""
  75. ).strip()
  76. ui_dictionary_config_path = Path(
  77. ui_dictionary_config_value or DEFAULT_UI_DICTIONARY_CONFIG_PATH
  78. ).expanduser()
  79. relation_preset_config_value = os.environ.get(
  80. "DMS_DOCUMENT_RELATION_PRESET_CONFIG_PATH", ""
  81. ).strip()
  82. relation_preset_config_path = Path(
  83. relation_preset_config_value
  84. or DEFAULT_DOCUMENT_RELATION_PRESET_CONFIG_PATH
  85. ).expanduser()
  86. business_timezone = (
  87. os.environ.get("DMS_BUSINESS_TIMEZONE", "").strip()
  88. or "Asia/Shanghai"
  89. )
  90. try:
  91. ZoneInfo(business_timezone)
  92. except (ZoneInfoNotFoundError, ValueError) as exc:
  93. raise ValueError("DMS_BUSINESS_TIMEZONE必须是有效的IANA时区") from exc
  94. office_preview_enabled = (
  95. os.environ.get("DMS_OFFICE_PREVIEW_ENABLED", "true").strip().lower()
  96. in {"1", "true", "yes", "on"}
  97. )
  98. libreoffice_executable = (
  99. os.environ.get("DMS_LIBREOFFICE_EXECUTABLE", "").strip()
  100. or shutil.which("soffice")
  101. or shutil.which("libreoffice")
  102. or ""
  103. )
  104. libreoffice_timeout_seconds = _positive_int(
  105. "DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60
  106. )
  107. libreoffice_max_concurrency = _positive_int(
  108. "DMS_LIBREOFFICE_MAX_CONCURRENCY", 2
  109. )
  110. resolved_database_url = database_url or DEFAULT_DATABASE_URL
  111. # init_command(SET time_zone)为 pymysql 专属参数,达梦 dmPython 不接受;
  112. # 仅在 MySQL 链接时下发。
  113. connect_args = (
  114. {"init_command": "SET time_zone = '+00:00'"}
  115. if resolved_database_url.startswith(("mysql", "jdbc:mysql"))
  116. else {}
  117. )
  118. engine_options: dict[str, Any] = {
  119. "isolation_level": "READ COMMITTED",
  120. "pool_pre_ping": True,
  121. "pool_recycle": 1800,
  122. "connect_args": connect_args,
  123. }
  124. # json_serializer 同为 MySQL/通用方言参数,达梦方言(DMDialect_dmPython)
  125. # 不接受该 kwarg(create_engine 直接报错);仅非达梦链接下发。
  126. if not resolved_database_url.startswith(("dm", "jdbc:dm")):
  127. # JSON 列统一原文中文序列化(ensure_ascii=False),与存量数据及
  128. # Java 版 Jackson 写入格式一致;否则 \uXXXX 转义会导致 LIKE 检索漏配。
  129. engine_options["json_serializer"] = _json_serializer
  130. return {
  131. "SQLALCHEMY_DATABASE_URI": resolved_database_url,
  132. "SQLALCHEMY_TRACK_MODIFICATIONS": False,
  133. "SQLALCHEMY_ENGINE_OPTIONS": engine_options,
  134. "DMS_STORAGE_ROOT": str(storage_root.resolve()),
  135. "DMS_MAX_UPLOAD_SIZE": max_upload_size,
  136. "DMS_MAX_FILE_SIZE": max_file_size,
  137. "MAX_CONTENT_LENGTH": max_upload_size,
  138. "DMS_BATCH_MAX_FILES": _positive_int("DMS_BATCH_MAX_FILES", 50),
  139. "DMS_BUSINESS_TIMEZONE": business_timezone,
  140. "DMS_JWT_SECRET": os.environ.get("DMS_JWT_SECRET", ""),
  141. "DMS_JWT_ISSUER": "dms",
  142. "DMS_JWT_EXPIRES_SECONDS": _positive_int(
  143. "DMS_JWT_EXPIRES_SECONDS", 3600
  144. ),
  145. "DMS_JWT_KEEP_SIGNED_IN_EXPIRES_SECONDS": 604800,
  146. # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
  147. "DMS_IDLE_LOGOUT_MINUTES": _non_negative_int(
  148. "DMS_IDLE_LOGOUT_MINUTES", 5
  149. ),
  150. # 契约1.21:应用层信封加密 KEK(base64 编码 32 字节);未配置则明文落盘
  151. "DMS_STORAGE_KEK_BYTES": load_kek(
  152. os.environ.get("DMS_STORAGE_KEK", "")
  153. ),
  154. "DMS_UI_DICTIONARY_CONFIG_PATH": str(
  155. ui_dictionary_config_path.resolve()
  156. ),
  157. "DMS_DOCUMENT_RELATION_PRESET_CONFIG_PATH": str(
  158. relation_preset_config_path.resolve()
  159. ),
  160. "DMS_OFFICE_PREVIEW_ENABLED": office_preview_enabled,
  161. "DMS_LIBREOFFICE_EXECUTABLE": libreoffice_executable,
  162. "DMS_LIBREOFFICE_TIMEOUT_SECONDS": libreoffice_timeout_seconds,
  163. "DMS_LIBREOFFICE_MAX_CONCURRENCY": libreoffice_max_concurrency,
  164. # 运行日志文件(契约1.23):默认 logs/dms-server.log,OFF/NONE 关闭
  165. "DMS_LOG_FILE": (
  166. os.environ.get("DMS_LOG_FILE", "").strip() or "logs/dms-server.log"
  167. ),
  168. # 资源采样(契约1.23):采样间隔秒数与保留天数
  169. "DMS_METRIC_INTERVAL_SECONDS": _positive_int(
  170. "DMS_METRIC_INTERVAL_SECONDS", 30
  171. ),
  172. "DMS_METRIC_RETENTION_DAYS": _positive_int(
  173. "DMS_METRIC_RETENTION_DAYS", 7
  174. ),
  175. # 系统管理页监控信息展示(契约1.26):false 时仅显示维护操作与备份管理
  176. "DMS_SYSTEM_MONITOR_ENABLED": (
  177. os.environ.get("DMS_SYSTEM_MONITOR_ENABLED", "false").strip().lower()
  178. in {"1", "true", "yes", "on"}
  179. ),
  180. # 登录图片验证码(安全需求4):缺省启用;关闭时登录回归三键请求体
  181. "DMS_CAPTCHA_ENABLED": (
  182. os.environ.get("DMS_CAPTCHA_ENABLED", "true").strip().lower()
  183. in {"1", "true", "yes", "on"}
  184. ),
  185. # 超级管理员登录开关(安全需求7):false 时内置admin禁止登录(报错与
  186. # 密码错误一致)、用户列表隐藏admin、创建同名用户报"不能创建该名称的用户"
  187. "DMS_ALLOW_ADMIN_LOGIN": (
  188. os.environ.get("DMS_ALLOW_ADMIN_LOGIN", "true").strip().lower()
  189. in {"1", "true", "yes", "on"}
  190. ),
  191. }