✨ feat: 阶段 0 收尾——认证边界、请求上下文与垂直切片(WP-01 第 5-7 步)

- 认证:Web Session(__Host- Cookie、HttpOnly/Secure/SameSite=Lax、CSPRNG opaque token 数据库只存哈希)、CSRF 双提交、Origin 校验、dev 登录/登出端点;未认证 401、缺 CSRF 403
- 中间件:Hunjing-Request-ID 回显/生成(request_ UUIDv4)+ W3C traceparent 校验透传;JSON 结构化日志与敏感头脱敏
- 迁移 2:conversation/branch/message/run/idempotency_record 五表(含检查约束与复合索引);Session 补 token/csrf 哈希列
- 垂直切片:POST /v1/conversations/{id}/messages 按 Idempotency-Key 幂等(同键同体 200 重放、异体 409),同事务原子写 Message+Run+Audit+Outbox+幂等记录;ContentBlock text/markdown 判别联合(未知类型 422)
- SSE:GET /v1/runs/{id}/events 返回快照与终止状态事件 envelope
- 测试 26 项全绿:IDOR 跨域 404 fail-closed、失败注入回滚无孤儿(Run 崩溃后 message/run/audit/outbox/幂等全零)、SSE 事件序、auth 边界;ruff/mypy strict 干净
- 修复真实问题:asyncpg 跨事件循环(anyio/pytest-asyncio 双 loop、模块级 engine 改为 app 实例持有)、httpx 拒存 http 下 Secure Cookie
This commit is contained in:
chenxin committed 2026-09-10 10:13:45 +08:00
1 parent 28127949db
commit ba502297ac
16 files changed
+1165

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
"""对话域路由:创建会话、发送消息(幂等原子)与 Run SSE。"""
import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations import service
from app.conversations.schemas import MessageCreatedOut, MessageIn, MessageOut, RunOut
from app.db.session import DbSession
from app.models import PersonalDomain, Run, User
router = APIRouter(prefix="/v1/conversations", tags=["conversations"])
async def _domain_of(db: DbSession, user: User) -> PersonalDomain:
result = await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status.HTTP_409_CONFLICT, "用户缺少个人域")
return domain
@router.post("", status_code=201)
async def create_conversation(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, str]:
domain = await _domain_of(db, user)
conversation = await service.create_conversation(
db, domain.id, body.get("title", "未命名对话"), getattr(request.state, "trace_id", None)
)
return {"id": conversation.id, "title": conversation.title}
@router.post(
"/{conversation_id}/messages",
status_code=201,
response_model=None,
)
async def post_message(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: MessageIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
) -> MessageCreatedOut | JSONResponse:
if not idempotency_key or len(idempotency_key) > 128:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Idempotency-Key 不合法")
domain = await _domain_of(db, user)
message, run, replayed = await service.replay_or_execute_message(
db,
domain.id,
conversation_id,
idempotency_key,
body,
getattr(request.state, "trace_id", None),
)
payload = MessageCreatedOut(
message=MessageOut(
id=message.id,
conversation_id=message.conversation_id,
branch_id=message.branch_id,
role=message.role,
content=message.content,
created_at=message.created_at.isoformat(),
),
run=(
RunOut(
id=run.id,
conversation_id=run.conversation_id,
status=run.status,
created_at=run.created_at.isoformat(),
completed_at=run.completed_at.isoformat() if run.completed_at else None,
)
if run is not None
else None
),
)
if replayed:
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
return payload
run_router = APIRouter(prefix="/v1/runs", tags=["runs"])
@run_router.get("/{run_id}/events")
async def run_events(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> StreamingResponse:
"""Run SSE:返回当前快照与终止状态(垂直切片空状态闭环)。"""
result = await db.execute(select(Run).where(Run.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
domain = await _domain_of(db, user)
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
terminal = run.status in {"completed", "failed", "cancelled"}
async def event_stream() -> AsyncIterator[str]:
def envelope(event_type: str, data: dict[str, object], sequence: int) -> str:
payload = json.dumps(
{"type": event_type, "data": data, "sequence": sequence}, ensure_ascii=False
)
return f"data: {payload}\n\n"
yield envelope("run.status.changed", {"run_id": run.id, "status": run.status}, 0)
if terminal:
yield envelope(
"message.completed",
{
"run_id": run.id,
"message_ids": [],
"final_status": run.status,
},
1,
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store"},
)