from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from dotenv import load_dotenv GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" GRAPH_SCOPE = "https://graph.microsoft.com/.default" TOKEN_ENDPOINT_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" def _env_bool(name: str, default: bool = False) -> bool: return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"} def _env_int(name: str, default: int) -> int: try: return int(os.getenv(name, str(default)).strip()) except ValueError: return default def _normalize_database_url(database_url: str, warnings: list[str]) -> str: normalized = database_url.strip() if not normalized: return "sqlite:///redemption.db" container_prefix = "sqlite:////app/" if normalized.startswith(container_prefix) and not Path("/.dockerenv").exists(): local_relative = normalized.removeprefix(container_prefix) project_root = Path(__file__).resolve().parent.parent local_path = (project_root / local_relative).resolve() warnings.append( f"DATABASE_URL 使用容器路径时,已自动映射到本地路径 {local_path}。" ) return f"sqlite:///{local_path}" return normalized @dataclass class Settings: app_name: str host: str port: int debug: bool session_secret: str auth_enabled: bool admin_username: str admin_password: str client_id: str tenant_id: str client_secret: str default_password: str default_domain: str default_usage_location: str default_license_sku: str license_assignment_required: bool force_change_password: bool graph_base_url: str token_endpoint: str scope: str database_url: str yaohuo_cookie: str yaohuo_message_url: str yaohuo_verification_enabled: bool yaohuo_verification_code_ttl_seconds: int default_page_size: int = 25 max_page_size: int = 100 validation_errors: tuple[str, ...] = field(default_factory=tuple) warnings: tuple[str, ...] = field(default_factory=tuple) @property def graph_ready(self) -> bool: return not self.validation_errors @property def effective_auth_enabled(self) -> bool: return self.auth_enabled and bool(self.admin_username and self.admin_password) def to_public_dict(self) -> dict: return { "appName": self.app_name, "graphFlavor": "Microsoft Graph Global", "graphReady": self.graph_ready, "validationErrors": list(self.validation_errors), "warnings": list(self.warnings), "authEnabled": self.effective_auth_enabled, "defaultDomain": self.default_domain, "defaultUsageLocation": self.default_usage_location, "defaultLicenseSku": self.default_license_sku, "licenseAssignmentRequired": self.license_assignment_required, "forceChangePassword": self.force_change_password, "pageSize": self.default_page_size, "maxPageSize": self.max_page_size, "yaohuoVerificationEnabled": self.yaohuo_verification_enabled, } def load_settings() -> Settings: load_dotenv() tenant_id = os.getenv("TENANT_ID", "").strip() graph_base_url = GRAPH_BASE_URL token_endpoint = TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id) if tenant_id else "" scope = GRAPH_SCOPE validation_errors: list[str] = [] warnings: list[str] = [] database_url = _normalize_database_url(os.getenv("DATABASE_URL", "sqlite:///redemption.db"), warnings) required_fields = { "CLIENT_ID": os.getenv("CLIENT_ID", "").strip(), "TENANT_ID": tenant_id, "CLIENT_SECRET": os.getenv("CLIENT_SECRET", "").strip(), "DEFAULT_PASSWORD": os.getenv("DEFAULT_PASSWORD", "").strip(), } for field_name, value in required_fields.items(): if not value: validation_errors.append(f"{field_name} 未配置") if not os.getenv("DEFAULT_DOMAIN", "").strip(): warnings.append("DEFAULT_DOMAIN 未配置,创建账号时必须填写完整 userPrincipalName。") auth_enabled = _env_bool("WEB_AUTH_ENABLED", True) admin_username = os.getenv("ADMIN_USERNAME", "").strip() admin_password = os.getenv("ADMIN_PASSWORD", "").strip() if auth_enabled and not (admin_username and admin_password): warnings.append("WEB_AUTH_ENABLED=true 但未配置后台登录账号,已自动退回为无登录保护模式。") return Settings( app_name=os.getenv("APP_NAME", "Office 365 Self Service").strip(), host=os.getenv("HOST", "0.0.0.0").strip(), port=_env_int("PORT", 8000), debug=_env_bool("DEBUG", False), session_secret=os.getenv("SESSION_SECRET", "office365-self-service-dev-secret").strip(), auth_enabled=auth_enabled, admin_username=admin_username, admin_password=admin_password, client_id=required_fields["CLIENT_ID"], tenant_id=required_fields["TENANT_ID"], client_secret=required_fields["CLIENT_SECRET"], default_password=required_fields["DEFAULT_PASSWORD"], default_domain=os.getenv("DEFAULT_DOMAIN", "").strip(), default_usage_location=os.getenv("DEFAULT_USAGE_LOCATION", "US").strip() or "US", default_license_sku=os.getenv("DEFAULT_LICENSE_SKU", "").strip(), license_assignment_required=_env_bool("LICENSE_ASSIGNMENT_REQUIRED", False), force_change_password=_env_bool("FORCE_CHANGE_PASSWORD", True), graph_base_url=graph_base_url, token_endpoint=token_endpoint, scope=scope, database_url=database_url, yaohuo_cookie=os.getenv("YAOHUO_COOKIE", "").strip(), yaohuo_message_url=os.getenv("YAOHUO_MESSAGE_URL", "https://www.yaohuo.me/bbs/messagelist_add.aspx").strip() or "https://www.yaohuo.me/bbs/messagelist_add.aspx", yaohuo_verification_enabled=_env_bool("YAOHUO_VERIFICATION_ENABLED", False), yaohuo_verification_code_ttl_seconds=min(max(_env_int("YAOHUO_VERIFICATION_CODE_TTL_SECONDS", 600), 60), 3600), default_page_size=min(max(_env_int("DEFAULT_PAGE_SIZE", 25), 1), 100), max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500), validation_errors=tuple(validation_errors), warnings=tuple(warnings), )