"""异步研究 Job API(Phase 4):提交 / 查询 / SSE 进度。 POST /api/jobs 创建 Job(BackgroundTasks 本地执行),立即返回 job_id GET /api/jobs/{id} 状态 + 结果(成功时内嵌 result) GET /api/jobs/{id}/events SSE 进度(queued→running→success|failed) """ from __future__ import annotations import asyncio import json from datetime import datetime from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi.responses import StreamingResponse from app.api.deps import DbSession, JobRepoDep, _engine_factory from app.application.services.job_executor import execute_job, new_id from app.domain.entities.research import ( BacktestResult, FactorTestReport, JobRecord, JobStatus, ResearchSpec, ) from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import ( SqlAlchemyExperimentRepository, SqlAlchemyJobRepository, ) from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import ( SqlAlchemyDailyBarRepository, SqlAlchemyStockRepository, ) from app.infrastructure.persistence.sqlalchemy.session import SessionLocal router = APIRouter(prefix="/jobs", tags=["jobs"]) def _bg_factories(): return { "session_factory": SessionLocal, "job_repo_factory": lambda s: SqlAlchemyJobRepository(s), "experiment_repo_factory": lambda s: SqlAlchemyExperimentRepository(s), "stock_repo_factory": lambda s: SqlAlchemyStockRepository(s), "daily_repo_factory": lambda s: SqlAlchemyDailyBarRepository(s), "engine": _engine_factory(), } def _decode_result(kind: str, result_json: str | None): if result_json is None: return None model = BacktestResult if kind == "backtest" else FactorTestReport return model.model_validate_json(result_json) def _job_view(job: JobRecord) -> dict: view = job.model_dump() view["result"] = _decode_result(job.kind, job.result_json) view.pop("result_json", None) view["spec"] = json.loads(job.spec_json) return view @router.post("", summary="创建异步研究 Job") def create_job( spec: ResearchSpec, background: BackgroundTasks, session: DbSession, job_repo: JobRepoDep, ) -> dict: job = JobRecord( id=new_id("JOB"), kind=spec.type, spec_json=spec.model_dump_json(), status=JobStatus.QUEUED, created_at=datetime.now(), ) job_repo.create(job) session.commit() background.add_task(execute_job, job.id, **_bg_factories()) return {"job_id": job.id, "status": job.status} @router.get("/{job_id}", summary="查询 Job 状态与结果") def get_job(job_id: str, job_repo: JobRepoDep) -> dict: job = job_repo.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Job {job_id} 不存在") return _job_view(job) @router.get("/{job_id}/events", summary="Job 进度 SSE") async def job_events(job_id: str) -> StreamingResponse: async def gen(): while True: with SessionLocal() as session: job = SqlAlchemyJobRepository(session).get(job_id) if job is None: yield "event: error\ndata: job not found\n\n" return payload = json.dumps( {"job_id": job.id, "status": job.status, "stage": job.stage}, ensure_ascii=False ) yield f"data: {payload}\n\n" if job.status in (JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.CANCELLED): return await asyncio.sleep(0.4) return StreamingResponse(gen(), media_type="text/event-stream")