fix(bioflow): 部署期 P1 双缺陷修复——context 值导入脱钩 init_context 重绑定(路由层动态取模块全局)+BFF 反代非 SSE 透传 bytes 500(改纯透传 Response)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 07:22:01 +08:00
co-authored by Claude Fable 5
parent 0c98b54b6a
commit 35bf6889a4
5 changed files with 34 additions and 25 deletions
+1
View File
@@ -16,3 +16,4 @@ coverage/
.DS_Store
tooling/build/
bioflow_data
+10 -9
View File
@@ -25,7 +25,8 @@ from fastapi import APIRouter, Query
from fastapi.responses import FileResponse, JSONResponse
from ..config import get_settings
from ..context import Context, context, load_record
from .. import context as context_mod
from ..context import Context, context, load_record # context 保留供 conftest monkeypatch
from ..schemas import ArchiveCreateRequest, ArchiveRunRequest
from ..services.bioflow import store
from ..services.bioflow.archive import (
@@ -89,7 +90,7 @@ async def reports_register(payload: dict[str, Any]) -> dict[str, Any]:
async def create_archive(body: ArchiveCreateRequest) -> dict[str, Any]:
"""存档(FR-BIO-020/021):打包任务目录 + 落 bio_archives 行。"""
record = await job_or_404(body.job_id)
ctx: Context = context
ctx: Context = context_mod.context
name = normalize_archive_name(body.name)
# 重名 409(TC-BIO-API-098 两态断言取拒绝态)
existing = await store.find_archive_by_name(ctx.db, name)
@@ -137,14 +138,14 @@ async def list_archives(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> dict[str, Any]:
ctx: Context = context
ctx: Context = context_mod.context
items, total = await store.list_archives(ctx.db, page=page, page_size=page_size)
return {"ok": True, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.get("/archives/{archive_id}")
async def get_archive(archive_id: str) -> dict[str, Any]:
ctx: Context = context
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
return _err("NOT_FOUND", "存档不存在", 404)
@@ -153,7 +154,7 @@ async def get_archive(archive_id: str) -> dict[str, Any]:
@router.delete("/archives/{archive_id}", status_code=204)
async def delete_archive(archive_id: str) -> JSONResponse:
ctx: Context = context
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
return _err("NOT_FOUND", "存档不存在", 404)
@@ -166,7 +167,7 @@ async def delete_archive(archive_id: str) -> JSONResponse:
@router.post("/archives/{archive_id}/run", status_code=201)
async def archive_run(archive_id: str, body: ArchiveRunRequest) -> dict[str, Any]:
"""复用发起(FR-BIO-023):装载阶段链+参数 → 新建任务(表单预填存档值)。"""
ctx: Context = context
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
return _err("ARCHIVE_NOT_FOUND", f"存档不存在: {archive_id}", 404)
@@ -191,7 +192,7 @@ async def archive_run(archive_id: str, body: ArchiveRunRequest) -> dict[str, Any
@router.post("/archives/run", status_code=201)
async def archive_run_by_name(body: ArchiveRunRequest) -> dict[str, Any]:
"""按名称匹配复用(FR-BIO-023):精确优先;模糊给候选;无匹配 404。"""
ctx: Context = context
ctx: Context = context_mod.context
name = (body.name or "").strip()
if not name:
return _err("VALIDATION", "缺少归档名 name", 422)
@@ -206,7 +207,7 @@ async def archive_run_by_name(body: ArchiveRunRequest) -> dict[str, Any]:
@router.get("/archives/{archive_id}/download")
async def archive_download(archive_id: str) -> FileResponse:
"""归档打包下载(zip;TC-BIO-API-141)。"""
ctx: Context = context
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
return _err("NOT_FOUND", "存档不存在", 404)
@@ -225,7 +226,7 @@ async def archive_download(archive_id: str) -> FileResponse:
@router.get("/archives/{archive_id}/report")
async def archive_report(archive_id: str) -> FileResponse:
"""归档报告单独取(HTML;FR-BIO-022 详情回看)。"""
ctx: Context = context
ctx: Context = context_mod.context
archive = await store.get_archive(ctx.db, archive_id)
if archive is None:
return _err("NOT_FOUND", "存档不存在", 404)
+7 -2
View File
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from ..config import get_settings
from ..context import Context, context, load_record
from ..context import Context, context, load_record # context 保留供 conftest monkeypatch
from ..db import Database, get_db
from ..schemas import ChatStreamRequest, FlowAskRequest
from ..services.bioflow import store
@@ -45,7 +45,12 @@ def _err(code: str, message: str, status: int) -> JSONResponse:
def _ctx() -> Context:
return context
# 动态取模块全局:init_context() 用「重绑定」更新全局,模块加载时的
# 值导入会拿到旧 Context(catalog=None → 端点 500)。测试的 monkeypatch
# 是属性替换所以没暴露;这里按属性读取与两者兼容。
from .. import context as context_mod
return context_mod.context
# ---------------------------------------------------------------------------
+12 -11
View File
@@ -30,7 +30,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Upl
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from ..config import get_settings
from ..context import Context, context, load_record
from .. import context as context_mod
from ..context import Context, context, load_record # context 保留供 conftest monkeypatch
from ..db import Database, get_db
from ..schemas import (
ArchiveRunRequest,
@@ -75,7 +76,7 @@ def _err(code: str, message: str, status: int) -> JSONResponse:
@router.post("/jobs", status_code=201)
async def create_job(body: JobCreateRequest) -> dict[str, Any]:
"""创建任务(FR-BIO-004;未知阶段 422;复用归档 TC-BIO-API-029)。"""
ctx: Context = context
ctx: Context = context_mod.context
params: dict[str, dict[str, Any]] = dict(body.params or {})
archive_id = body.archive_id
if body.archive_id:
@@ -130,7 +131,7 @@ async def list_jobs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> dict[str, Any]:
ctx: Context = context
ctx: Context = context_mod.context
items, total = await store.list_jobs(
ctx.db, session_id=session_id, status=status, page=page, page_size=page_size,
)
@@ -158,7 +159,7 @@ async def delete_job(job_id: str) -> JSONResponse:
return _err("NOT_FOUND", "任务不存在", 404)
if record.status in (JOB_RUNNING,):
return _err("JOB_RUNNING", "任务执行中不可删除", 409)
ctx: Context = context
ctx: Context = context_mod.context
await store.delete_job(ctx.db, job_id) # bio_stages 级联删除
ctx.records.pop(job_id, None)
return JSONResponse(status_code=204, content=None)
@@ -185,7 +186,7 @@ async def upload_file(
大文件超时单独调大:本端点自身逐块流式写盘(不整读内存,TC-BIO-API-041)。
"""
record = await job_or_404(job_id)
ctx: Context = context
ctx: Context = context_mod.context
settings = get_settings()
input_dir = record.input_dir
input_dir.mkdir(parents=True, exist_ok=True)
@@ -293,7 +294,7 @@ def _stage_exists(record: Any, stage: str) -> None:
async def confirm_stage(job_id: str, stage: str, body: StageConfirmRequest) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context
ctx: Context = context_mod.context
try:
await ctx.runner.confirm_stage(record, stage, dict(body.params or {}))
except RunnerError as exc:
@@ -310,7 +311,7 @@ async def confirm_stage(job_id: str, stage: str, body: StageConfirmRequest) -> d
async def rerun_stage(job_id: str, stage: str, body: StageRerunRequest) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context
ctx: Context = context_mod.context
try:
await ctx.runner.rerun_stage(record, stage, dict(body.params) if body.params else None)
except RunnerError as exc:
@@ -326,7 +327,7 @@ async def rerun_stage(job_id: str, stage: str, body: StageRerunRequest) -> dict[
async def skip_stage(job_id: str, stage: str) -> dict[str, Any]:
record = await job_or_404(job_id)
_stage_exists(record, stage)
ctx: Context = context
ctx: Context = context_mod.context
try:
await ctx.runner.skip_stage(record, stage)
except RunnerError as exc:
@@ -340,7 +341,7 @@ async def skip_stage(job_id: str, stage: str) -> dict[str, Any]:
@router.post("/jobs/{job_id}/pause")
async def pause_job(job_id: str) -> dict[str, Any]:
record = await job_or_404(job_id)
ctx: Context = context
ctx: Context = context_mod.context
try:
await ctx.runner.pause_job(record)
except RunnerError as exc:
@@ -351,7 +352,7 @@ async def pause_job(job_id: str) -> dict[str, Any]:
@router.post("/jobs/{job_id}/resume")
async def resume_job(job_id: str) -> dict[str, Any]:
record = await job_or_404(job_id)
ctx: Context = context
ctx: Context = context_mod.context
try:
await ctx.runner.resume_job(record)
except RunnerError as exc:
@@ -405,7 +406,7 @@ async def job_events(
TC-BIO-API-081:任务终态(done/failed/archived)且回放完成后服务端正常关闭连接。
"""
record = await job_or_404(job_id)
ctx: Context = context
ctx: Context = context_mod.context
async def gen():
# 1. 快照首帧(TC-BIO-SSE-022)
+4 -3
View File
@@ -18,7 +18,7 @@ import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import JSONResponse, Response, StreamingResponse
from ..config import get_settings
from ..db import Database, get_db
@@ -96,9 +96,10 @@ async def _proxy(request: Request, path: str) -> Any:
payload, status = envelope("UPSTREAM_DOWN", message="生信服务响应中断",
degraded=True, status=502)
return JSONResponse(status_code=status, content=payload)
# 非 SSE:纯透传(content 是 bytes;不能 JSONResponse——bytes 序列化会 500)
media_type = upstream.headers.get("content-type") or "application/json"
return JSONResponse(status_code=upstream.status_code, content=content,
media_type=media_type)
return Response(status_code=upstream.status_code, content=content,
media_type=media_type)
# ---------------------------------------------------------------------------