# -*- coding: utf-8 -*- """FastAPI 应用入口(接口层组装)。 启动方式(项目根目录): uv run uvicorn app.main:app --host 127.0.0.1 --port 8090 或使用脚本:``uv run python -m app`` """ from __future__ import annotations import asyncio import base64 import logging import os import secrets from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response from app import config from app.infrastructure.repositories import Database logger = logging.getLogger(__name__) from app.api.routers import agents as agents_router from app.api.routers import auth as auth_router from app.api.routers import bootstrap as bootstrap_router from app.api.routers import rbac_admin as rbac_admin_router from app.api.routers import rbac_hall as rbac_hall_router from app.api.routers import rbac_service_market as rbac_service_market_router from app.api.routers import rbac_pages as rbac_pages_router from app.api.routers import rbac_opc as rbac_opc_router from app.api.routers import rbac_operator as rbac_operator_router from app.api.routers import rbac_agents as rbac_agents_router from app.api.routers import rbac_public as rbac_public_router from app.api.routers import rbac_org as rbac_org_router from app.api.routers import rbac_permissions as rbac_permissions_router from app.api.routers import rbac_portals as rbac_portals_router from app.api.routers import rbac_certifications as rbac_certifications_router from app.api.routers import rbac_training as rbac_training_router from app.api.routers import rbac_credit as rbac_credit_router from app.api.routers import rbac_ecosystem as rbac_ecosystem_router from app.api.routers import rbac_enterprise as rbac_enterprise_router from app.api.routers import rbac_ent_onboard as rbac_ent_onboard_router from app.api.routers import rbac_teams as rbac_teams_router from app.api.routers import rbac_desktop_updates as rbac_desktop_updates_router from app.api.routers import rbac_moderation as rbac_moderation_router from app.api.routers import templates as templates_router from app.api.routers import relay as relay_router from app.pay import routers as pay_router from app.market import routers as market_router from app.incubator import routers as incubator_router from app.api.routers import invite as invite_router from app.api.routers import rbac_compute_pricing as compute_pricing_router from app.api.routers import rbac_compute_assets as compute_assets_router from app.api.routers import compute_internal as compute_internal_router from app.api.routers import agent_gate as agent_gate_router from app.im import router as im_router APP_NAME = "云南省超级个体服务平台" async def _startup_sync_compute_users(db: Database) -> None: """启动时异步全量对账:把平台全部用户补建到算力引擎(幂等)。 只处理 compute_provisioned=False 的用户,避免重复调用;失败静默。 """ try: from app.services import compute_client users = await db.users.list() pending = [u for u in users if not u.get("compute_provisioned", False) and u.get("username")] if not pending: return logger.info("[startup] 检测到 %d 个未接入算力引擎的用户,开始补建…", len(pending)) synced = 0 for u in pending: try: await compute_client.ensure_user(u["username"]) toks = await compute_client.list_user_tokens(u["username"]) if not toks: await compute_client.issue_pat(u["username"]) bal = await compute_client.user_balance(u["username"]) await db.users.set_compute_mirror( u["id"], provisioned=True, username=u["username"], quota=int(bal.get("quota", 0) or 0), used_quota=int(bal.get("used_quota", 0) or 0), ) synced += 1 except Exception as exc: # noqa: BLE001 logger.warning("[startup] 算力账号补建失败 username=%s: %s", u.get("username"), exc) logger.info("[startup] 算力用户对账完成:补建 %d / %d", synced, len(pending)) except Exception as exc: # noqa: BLE001 logger.warning("[startup] 算力用户对账任务异常: %s", exc) @asynccontextmanager async def lifespan(app: FastAPI): """启动时初始化数据库(建表 + 幂等种子)+ Redis 缓存连接。 若 ``app.state.db`` 已由外部注入(如测试夹具),则直接复用, 不重新创建,避免覆盖调用方准备的数据库实例。 """ from .infrastructure.cache import cache as redis_cache db = getattr(app.state, "db", None) if db is None: db = Database() await db.initialize() app.state.db = db # 连接 Redis(失败自动降级为无缓存) await redis_cache.connect() app.state.cache = redis_cache # 启动时异步全量对账算力引擎用户(补建缺失账号,不阻断启动) asyncio.create_task(_startup_sync_compute_users(db)) yield # 关闭 Redis await redis_cache.close() if db is not None and db._owns_session: await db.close() # 释放引擎连接池,避免连接残留导致下次启动 initialize() 被锁挂起/连接泄漏 engine = getattr(db, "_engine", None) if engine is not None: try: await engine.dispose() except Exception: # noqa: BLE001 pass app = FastAPI( title=APP_NAME, description=( "云超服核心服务端:身份/平台 API + 培训子应用(/api/*),统一对外 opc.pinesound.cn。" "异步四层架构(接口/业务/领域/基础设施)。" ), version="0.1.0", lifespan=lifespan, ) # 演示服务允许任意来源(实际生产由主后端网关收敛)。 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # 文档页面访问控制(HTTP Basic Auth) # 环境变量: # DOCS_USERNAME 用户名,默认 admin # DOCS_PASSWORD 密码;为空时文档端点返回 404(隐藏存在),生产必须设置 # 保护路径:/docs、/redoc、/openapi.json(含 /docs/、/redoc/ 子路径) # --------------------------------------------------------------------------- _DOCS_USERNAME = os.getenv("DOCS_USERNAME", "admin").strip() _DOCS_PASSWORD = os.getenv("DOCS_PASSWORD", "").strip() _DOCS_REALM = "OPC Docs" def _is_docs_path(path: str) -> bool: return ( path == "/openapi.json" or path == "/docs" or path.startswith("/docs/") or path == "/redoc" or path.startswith("/redoc/") ) @app.middleware("http") async def _docs_basic_auth(request: Request, call_next): if _is_docs_path(request.url.path): if not _DOCS_PASSWORD: # 未配置密码:隐藏文档端点(不暴露存在) return Response(status_code=404) auth = request.headers.get("authorization", "") if not auth.startswith("Basic "): return Response( status_code=401, headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'}, ) try: raw = base64.b64decode(auth[6:].strip()).decode("utf-8") user, pwd = raw.split(":", 1) except Exception: # noqa: BLE001 return Response( status_code=401, headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'}, ) if user != _DOCS_USERNAME or not secrets.compare_digest(pwd, _DOCS_PASSWORD): return Response( status_code=401, headers={"WWW-Authenticate": f'Basic realm="{_DOCS_REALM}"'}, ) return await call_next(request) app.include_router(auth_router.router) app.include_router(agents_router.router) app.include_router(templates_router.router) app.include_router(bootstrap_router.router) app.include_router(rbac_admin_router.router) app.include_router(rbac_opc_router.router) app.include_router(rbac_operator_router.router) app.include_router(rbac_agents_router.router) app.include_router(rbac_public_router.router) app.include_router(rbac_org_router.router) app.include_router(rbac_permissions_router.router) app.include_router(rbac_portals_router.router) app.include_router(rbac_hall_router.router) app.include_router(rbac_service_market_router.router) app.include_router(rbac_pages_router.router) app.include_router(rbac_certifications_router.router) app.include_router(rbac_training_router.router) app.include_router(rbac_credit_router.router) app.include_router(rbac_ecosystem_router.router) app.include_router(rbac_enterprise_router.router) app.include_router(rbac_ent_onboard_router.router) app.include_router(rbac_teams_router.router) app.include_router(rbac_desktop_updates_router.router) app.include_router(rbac_desktop_updates_router.public_router) app.include_router(rbac_hall_router.community_router) app.include_router(rbac_moderation_router.router) app.include_router(rbac_moderation_router.report_router) app.include_router(rbac_hall_router.dm_router) # compute_pricing / compute_internal 必须在 relay_router 之前注册:relay 有 /{path:path} catch-all, # 会吞掉 /api/compute/* 导致全部 404。 app.include_router(compute_pricing_router.router) app.include_router(compute_assets_router.router) app.include_router(compute_internal_router.router) app.include_router(relay_router.router) app.include_router(pay_router.router) app.include_router(market_router.router) app.include_router(market_router.admin_router) app.include_router(incubator_router.router) app.include_router(invite_router.router) app.include_router(agent_gate_router.router) app.include_router(agent_gate_router.internal_router) app.include_router(im_router.router) @app.get("/health", tags=["meta"], summary="健康检查") async def health(): return { "status": "ok", "service": "pineagents-demo-server", "auth_enabled": config.AUTH_ENABLED, } @app.get("/", tags=["meta"], include_in_schema=False) async def root(): return { "service": APP_NAME, "docs": "/docs", "health": "/health", }