✨ 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:
1 parent
28127949db
commit
ba502297ac
16 files changed
+1165
No files matched your search
@@ -0,0 +1 @@
|
||||
"""对话域:schema、幂等原子事务服务与路由。"""
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""对话域 schema:ContentBlock 判别联合(垂直切片子集)。
|
||||
|
||||
完整 21 类型随 WP-03 落地;本切片登记 text/markdown 并拒绝未知类型,
|
||||
契约真源为 openapi/openapi.yaml。
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BlockBase(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str | None = None # 创建请求可省略,由服务端生成
|
||||
status: Literal["pending", "streaming", "completed", "failed", "cancelled"] | None = None
|
||||
|
||||
|
||||
class TextBlock(BlockBase):
|
||||
type: Literal["text"]
|
||||
text: str
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class MarkdownBlock(BlockBase):
|
||||
type: Literal["markdown"]
|
||||
text: str
|
||||
flavor: Literal["hunjing-gfm-v1"] = "hunjing-gfm-v1"
|
||||
|
||||
|
||||
ContentBlock = Annotated[TextBlock | MarkdownBlock, Field(discriminator="type")]
|
||||
|
||||
|
||||
class MessageIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: list[ContentBlock] = Field(min_length=1, max_length=64)
|
||||
start_run: bool = True
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
branch_id: str
|
||||
role: str
|
||||
content: list[dict[str, object]]
|
||||
created_at: str
|
||||
|
||||
|
||||
class RunOut(BaseModel):
|
||||
id: str
|
||||
conversation_id: str | None
|
||||
status: str
|
||||
created_at: str
|
||||
completed_at: str | None = None
|
||||
|
||||
|
||||
class MessageCreatedOut(BaseModel):
|
||||
message: MessageOut
|
||||
run: RunOut | None
|
||||
@@ -0,0 +1,182 @@
|
||||
"""对话域服务:幂等、原子事务与越权防护。
|
||||
|
||||
不变量(契约 4.3/4.4 节):Message 与可选 Run 在同一事务创建;权威状态、
|
||||
AuditEvent 与 OutboxEvent 同事务提交;跨域对象按 404 fail-closed。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.conversations.schemas import MessageIn
|
||||
from app.core.ids import new_id
|
||||
from app.models import (
|
||||
AuditEvent,
|
||||
Branch,
|
||||
Conversation,
|
||||
IdempotencyRecord,
|
||||
Message,
|
||||
OutboxEvent,
|
||||
Run,
|
||||
)
|
||||
|
||||
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
|
||||
|
||||
|
||||
def _request_hash(body: MessageIn) -> str:
|
||||
canonical = json.dumps(
|
||||
{
|
||||
"content": [b.model_dump(exclude_none=True) for b in body.content],
|
||||
"start_run": body.start_run,
|
||||
},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _require_conversation(
|
||||
db: AsyncSession, domain_id: str, conversation_id: str
|
||||
) -> Conversation:
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
# 跨域对象统一 404,不泄露存在性(契约第 7 节)
|
||||
if conversation is None or conversation.personal_domain_id != domain_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "对话不存在")
|
||||
return conversation
|
||||
|
||||
|
||||
async def _get_default_branch(db: AsyncSession, conversation_id: str) -> Branch:
|
||||
result = await db.execute(
|
||||
select(Branch).where(Branch.conversation_id == conversation_id).limit(1)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
assert branch is not None, "会话必须存在默认分支(创建事务保证)"
|
||||
return branch
|
||||
|
||||
|
||||
async def create_conversation(
|
||||
db: AsyncSession, domain_id: str, title: str, trace_id: str | None
|
||||
) -> Conversation:
|
||||
conversation = Conversation(personal_domain_id=domain_id, title=title)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
branch = Branch(conversation_id=conversation.id, title="主脉")
|
||||
db.add(branch)
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user",
|
||||
actor_id=domain_id,
|
||||
action="conversation.created",
|
||||
aggregate_type="conversation",
|
||||
aggregate_id=conversation.id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
OutboxEvent(
|
||||
aggregate_type="conversation",
|
||||
aggregate_id=conversation.id,
|
||||
event_type="conversation.created",
|
||||
payload={"conversation_id": conversation.id, "title": title},
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
async def replay_or_execute_message(
|
||||
db: AsyncSession,
|
||||
domain_id: str,
|
||||
conversation_id: str,
|
||||
idempotency_key: str,
|
||||
body: MessageIn,
|
||||
trace_id: str | None,
|
||||
) -> tuple[Message, Run | None, bool]:
|
||||
"""返回(消息,Run,是否为重放)。幂等:相同键+相同请求返回原资源。"""
|
||||
route = MESSAGE_ROUTE
|
||||
existing = await db.execute(
|
||||
select(IdempotencyRecord).where(
|
||||
IdempotencyRecord.owner_domain_id == domain_id,
|
||||
IdempotencyRecord.route == route,
|
||||
IdempotencyRecord.key == idempotency_key,
|
||||
)
|
||||
)
|
||||
record = existing.scalar_one_or_none()
|
||||
request_hash = _request_hash(body)
|
||||
if record is not None:
|
||||
if record.request_hash != request_hash:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "相同幂等键对应不同请求体")
|
||||
message = await db.get(Message, record.resource_ids.get("message_id"))
|
||||
run = None
|
||||
run_id = record.resource_ids.get("run_id")
|
||||
if run_id is not None:
|
||||
run = await db.get(Run, run_id)
|
||||
assert message is not None
|
||||
return message, run, True
|
||||
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
branch = await _get_default_branch(db, conversation.id)
|
||||
|
||||
blocks: list[dict[str, Any]] = [
|
||||
{**b.model_dump(exclude_none=True), "id": new_id("block_"), "status": "completed"}
|
||||
for b in body.content
|
||||
]
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
branch_id=branch.id,
|
||||
role="user",
|
||||
content=blocks,
|
||||
)
|
||||
db.add(message)
|
||||
await db.flush()
|
||||
|
||||
run = None
|
||||
if body.start_run:
|
||||
# 垂直切片空状态闭环:不调用模型,Run 原子创建并直接进入终态 completed。
|
||||
run = Run(
|
||||
personal_domain_id=domain_id,
|
||||
conversation_id=conversation.id,
|
||||
status="completed",
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
|
||||
resource_ids: dict[str, Any] = {"message_id": message.id}
|
||||
if run is not None:
|
||||
resource_ids["run_id"] = run.id
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user",
|
||||
actor_id=domain_id,
|
||||
action="message.created",
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
OutboxEvent(
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
event_type="message.created",
|
||||
payload={"message_id": message.id, "conversation_id": conversation.id},
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
IdempotencyRecord(
|
||||
key=idempotency_key,
|
||||
owner_domain_id=domain_id,
|
||||
route=route,
|
||||
request_hash=request_hash,
|
||||
response_status=201,
|
||||
resource_ids=resource_ids,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return message, run, False
|
||||
Reference in new issue
Block a user