Production correct-credential login returned auth_unavailable 503 because Flask's SECRET_KEY was unset: wrong-password probes stopped at 401 before CSRF token issuance, while valid credentials reached _csrf_serializer() and crashed. App factory now rejects absent/short (<32 char) SECRET_KEY at startup, and docker_entrypoint.sh fails fast before migration/services. Bootstrap no longer passes ADMIN_PASSWORD in process arguments; env-only. Tests: app-factory + entrypoint regression (5 focused passed), full backend suite 204 passed. Independent review PASS.
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""
|
||
CrowdSight Backend - Flask应用工厂
|
||
"""
|
||
|
||
import os
|
||
import warnings
|
||
|
||
# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers)
|
||
# 需要在所有其他导入之前设置
|
||
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
||
|
||
from flask import Flask, jsonify, request
|
||
from flask_cors import CORS
|
||
from werkzeug.exceptions import HTTPException
|
||
|
||
from .config import Config
|
||
from .db import create_database_engine, create_session_factory
|
||
from .utils.api_errors import ApiError, internal_error_payload
|
||
from .utils.locale import t
|
||
from .utils.logger import setup_logger, get_logger
|
||
|
||
|
||
def create_app(config_class=Config):
|
||
"""Flask应用工厂函数"""
|
||
app = Flask(__name__)
|
||
app.config.from_object(config_class)
|
||
if not app.config.get("SECRET_KEY") or len(str(app.config.get("SECRET_KEY"))) < 32:
|
||
raise RuntimeError("secret_key_required")
|
||
if "*" in app.config.get("CORS_ALLOWED_ORIGINS", []):
|
||
raise RuntimeError("wildcard_cors_not_allowed")
|
||
|
||
database_engine = create_database_engine(os.environ.get("DATABASE_URL"))
|
||
app.extensions["crowdsight_database_engine"] = database_engine
|
||
app.extensions["crowdsight_session_factory"] = create_session_factory(database_engine)
|
||
|
||
# JSON config: keep Unicode characters readable in API responses.
|
||
# Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置
|
||
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
|
||
app.json.ensure_ascii = False
|
||
|
||
# Configure server-side logging before registering error handlers.
|
||
logger = setup_logger('crowdsight')
|
||
|
||
@app.errorhandler(ApiError)
|
||
def handle_api_error(error: ApiError):
|
||
return jsonify(error.to_payload(t)), error.status_code
|
||
|
||
@app.errorhandler(HTTPException)
|
||
def handle_http_error(error: HTTPException):
|
||
logger.warning("HTTP request failed: status=%s", error.code)
|
||
api_error = ApiError(
|
||
code=f"http_{error.code or 500}",
|
||
status_code=error.code or 500,
|
||
message_key="api.requestError",
|
||
)
|
||
return jsonify(api_error.to_payload(t)), error.code or 500
|
||
|
||
@app.errorhandler(Exception)
|
||
def handle_unexpected_error(error: Exception):
|
||
# Keep exception details in server logs only; never serialize them.
|
||
logger.exception("Unhandled request error: %s", type(error).__name__)
|
||
return jsonify(internal_error_payload(t)), 500
|
||
|
||
# Only startup state is logged; request bodies are intentionally excluded.
|
||
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||
debug_mode = app.config.get('DEBUG', False)
|
||
should_log_startup = not debug_mode or is_reloader_process
|
||
|
||
if should_log_startup:
|
||
logger.info("=" * 50)
|
||
logger.info("CrowdSight Backend 启动中...")
|
||
logger.info("=" * 50)
|
||
|
||
# Explicit allowlist only; wildcard CORS is incompatible with auth cookies.
|
||
CORS(
|
||
app,
|
||
resources={r"/api/*": {"origins": app.config.get("CORS_ALLOWED_ORIGINS", [])}},
|
||
supports_credentials=True,
|
||
)
|
||
|
||
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
|
||
from .services.simulation_runner import SimulationRunner
|
||
SimulationRunner.register_cleanup()
|
||
if should_log_startup:
|
||
logger.info("已注册模拟进程清理函数")
|
||
|
||
# 请求日志中间件
|
||
@app.before_request
|
||
def log_request():
|
||
logger = get_logger('crowdsight.request')
|
||
logger.debug(f"请求: {request.method} {request.path}")
|
||
if request.content_type and 'json' in request.content_type:
|
||
logger.debug("JSON request received: path=%s", request.path)
|
||
|
||
@app.after_request
|
||
def log_response(response):
|
||
logger = get_logger('crowdsight.request')
|
||
logger.debug(f"响应: {response.status_code}")
|
||
return response
|
||
|
||
# 注册蓝图
|
||
from .api import graph_bp, simulation_bp, report_bp
|
||
from .api.admin import admin_bp
|
||
from .api.auth import auth_bp
|
||
from .api.template import template_bp
|
||
from .api.agent_group import agent_group_bp
|
||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||
app.register_blueprint(admin_bp, url_prefix='/api/admin')
|
||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||
app.register_blueprint(template_bp, url_prefix='/api/template')
|
||
app.register_blueprint(agent_group_bp, url_prefix='/api/agent-group')
|
||
|
||
# 健康检查
|
||
@app.route('/health')
|
||
def health():
|
||
return {'status': 'ok', 'service': 'CrowdSight Backend'}
|
||
|
||
if should_log_startup:
|
||
logger.info("CrowdSight Backend 启动完成")
|
||
|
||
return app
|
||
|