fix(report): FE 问卷键名双侧兼容 + worker 持久事件循环(live E2E 两处修复)
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

FE↔后端联调实测暴露并修复:
1. 问卷键名三处错位(FE height_cm/weight_kg/psych_style vs 文档
   height/weight/psyche_style)——vision._bmi 多键取值、diagnosis 维度
   装配与规则上下文双键或运算,两侧谁改键名都不再炸
2. celery worker asyncio.run 每投递起新 loop + 模块级引擎缓存 →
   asyncpg 连接跨 loop(同进程第 3 个报告任务必现 RuntimeError:
   attached to a different loop;集成测试单循环掩盖)。新增
   apps/worker/loop.py 持久 loop,report/ping 两任务接入

E2E 终态: psyche_style/height_weight 恢复、BMI 体型确定值、ok 26/32、
真实 LLM 文案通过校验 degraded=False。
This commit is contained in:
xsl
2026-08-15 01:05:10 +08:00
parent da41e9ab0d
commit cb8a1e3c0f
5 changed files with 56 additions and 13 deletions
+26
View File
@@ -0,0 +1,26 @@
"""Celery worker 的持久事件循环。
``asyncio.run`` 每次投递都新建并随即关闭事件循环,而
``packages.shared.db`` 的引擎是模块级缓存——asyncpg 连接绑定在创建它的
loop 上,后续投递从池里拿到旧 loop 的连接就会
``RuntimeError: attached to a different loop``(live E2E 实测:同进程第
三个报告任务必现,集成测试单循环跑掩盖了它)。
worker 进程内复用一个 loop,让连接池与 loop 生命周期一致,问题从根上
消失。prefork 模型下每个子进程各有一份模块状态,互不影响。集成测试
不受影响:它们在 pytest 自己的 loop 里直调任务内芯,不经过本模块。
"""
import asyncio
from collections.abc import Coroutine
from typing import Any
_loop: asyncio.AbstractEventLoop | None = None
def run(coro: Coroutine[Any, Any, Any]) -> Any:
"""在 worker 进程唯一的持久 loop 上执行协程并返回结果。"""
global _loop
if _loop is None or _loop.is_closed():
_loop = asyncio.new_event_loop()
return _loop.run_until_complete(coro)
+4 -4
View File
@@ -14,15 +14,15 @@ worker 侧 LLM 网关装配:apps.api.deps 属 HTTP 层,worker 不得 import
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from typing import Any, cast
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from apps.worker import loop as worker_loop
from apps.worker.celery_app import celery_app
from packages.billing.service import BillingService
from packages.domain.task.state import TERMINAL_STATUSES, TaskStatus
@@ -279,7 +279,7 @@ def generate_report(self: Any, task_id: int) -> dict[str, Any]:
"""
bind_context(task_id=task_id)
try:
return asyncio.run(_generate_report(task_id))
return cast("dict[str, Any]", worker_loop.run(_generate_report(task_id)))
except StageFailureError as exc:
logger.warning(
"report_stage_failed",
@@ -293,5 +293,5 @@ def generate_report(self: Any, task_id: int) -> dict[str, Any]:
logger.warning("report_generate_retry", task_id=task_id, error=str(exc))
raise self.retry(exc=exc, countdown=10) from exc
logger.exception("report_generate_failed_after_retry", task_id=task_id)
asyncio.run(_fail_report(task_id, f"{type(exc).__name__}: {exc}"))
worker_loop.run(_fail_report(task_id, f"{type(exc).__name__}: {exc}"))
raise
+4 -4
View File
@@ -9,9 +9,9 @@ render) follow exactly this shape and additionally call
from __future__ import annotations
import asyncio
from typing import Any
from typing import Any, cast
from apps.worker import loop as worker_loop
from apps.worker.celery_app import celery_app
from packages.domain.task.state import TaskStatus
from packages.shared.context import bind_context
@@ -61,9 +61,9 @@ async def _fail_ping(task_id: int, error: str) -> None:
def ping_task(self: Any, task_id: int, echo: str | None = None) -> dict[str, Any]:
bind_context(task_id=task_id)
try:
return asyncio.run(_run_ping(task_id, echo))
return cast("dict[str, Any]", worker_loop.run(_run_ping(task_id, echo)))
except Exception as exc:
logger.exception("ping_task_failed", task_id=task_id)
# Real tasks additionally refund the quota reservation here (FR-BILL-05).
asyncio.run(_fail_ping(task_id, f"{type(exc).__name__}: {exc}"))
worker_loop.run(_fail_ping(task_id, f"{type(exc).__name__}: {exc}"))
raise