feat: SaaS foundation for CrowdSight

Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
This commit is contained in:
Kunthawat Greethong
2026-08-31 13:05:21 +07:00
parent 89d04e795b
commit 8b84378fe1
165 changed files with 15884 additions and 4001 deletions

View File

@@ -9,10 +9,14 @@ import warnings
# 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request
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
@@ -20,16 +24,42 @@ def create_app(config_class=Config):
"""Flask应用工厂函数"""
app = Flask(__name__)
app.config.from_object(config_class)
# 设置JSON编码确保中文直接显示而不是 \uXXXX 格式)
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')
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
@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
@@ -39,8 +69,12 @@ def create_app(config_class=Config):
logger.info("CrowdSight Backend 启动中...")
logger.info("=" * 50)
# 启用CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 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
@@ -54,7 +88,7 @@ def create_app(config_class=Config):
logger = get_logger('crowdsight.request')
logger.debug(f"请求: {request.method} {request.path}")
if request.content_type and 'json' in request.content_type:
logger.debug(f"请求体: {request.get_json(silent=True)}")
logger.debug("JSON request received: path=%s", request.path)
@app.after_request
def log_response(response):
@@ -64,9 +98,13 @@ def create_app(config_class=Config):
# 注册蓝图
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')