fix: 访问日志的 user 字段恒为 null —— 改用 request.state 跨 task 传递

上一提交(4454047)的 RequestContextMiddleware 通过 contextvar 读取 user,
但 Starlette 的 BaseHTTPMiddleware 用 anyio start_soon 把下游放进新 task
执行,而 asyncio 每个 Task 创建时会复制 context —— 路由内 set 的
contextvar 不会回流到中间件,导致访问日志的 user 永远是 null。

修复:
- get_current_user 同时写 contextvar(供请求任务内业务日志用)与
  request.state(由 ASGI scope 承载,跨 task 可见),并带上
  display_name / role 备用。
- 中间件 _log_access 改为优先读 request.state.audit_user。

验证(TestClient + 解析最终 JSON 输出,而非读 record 属性):
- track.access 日志 user=zhangsan01(经 request.state)
- 请求任务内 track.service 日志 user=zhangsan01(经 contextvar)
- 两者 request_id 一致;X-Request-ID 透传正常
This commit is contained in:
openhands
2026-09-21 02:11:41 +00:00
parent 4454047ce3
commit 04eb87b091
2 changed files with 19 additions and 5 deletions

View File

@ -54,6 +54,10 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
path = request.url.path
duration_ms = round((time.perf_counter() - started) * 1000, 1)
# user 必须从 request.state 取:本中间件在独立 task 中执行,路由内
# 写入的 contextvar 不会回流到这里(详见 get_current_user 的说明)。
user = getattr(request.state, "audit_user", None) or user_var.get()
if status_code >= 500:
level = logging.ERROR
elif status_code >= 400:
@ -77,7 +81,7 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
"status": status_code,
"duration_ms": duration_ms,
"client": request.client.host if request.client else None,
"user": user_var.get(),
"user": user,
}
},
)

View File

@ -1,5 +1,5 @@
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT双 Token 架构)"""
from fastapi import HTTPException, status, Depends
from fastapi import HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from werkzeug.security import check_password_hash
@ -113,6 +113,7 @@ def refresh_access_token(refresh_token: str) -> dict:
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(仅接受 Access Token"""
@ -130,9 +131,18 @@ async def get_current_user(
detail="请使用 Access Token 访问 APIRefresh Token 仅用于刷新",
)
# 注入日志上下文:此后本请求的所有日志都会自动带上操作人。
# username 即 assignee_id 口径,排查「谁干了什么」时比数字 id 直观得多。
user_var.set(payload.get("username") or user_id)
# 操作人身份要写两处,用途不同,缺一不可:
# 1) contextvar —— 供本请求任务内的业务/service 日志使用;
# 2) request.state —— 中间件在独立 task 中执行Starlette 的
# BaseHTTPMiddleware 用 anyio start_soon 起新 task而 asyncio
# 每个 Task 会复制 context因此中间件读不到路由内改的
# contextvar只能通过 ASGI scope 承载的 state 拿到。
# username 即 assignee_id 口径,比数字 id 直观得多。
user_label = payload.get("username") or user_id
user_var.set(user_label)
request.state.audit_user = user_label
request.state.audit_display_name = payload.get("display_name") or ""
request.state.audit_role = payload.get("role") or ""
return payload
except JWTError: