✨ 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
@@ -56,6 +56,8 @@ class Session(Base):
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("user.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||
csrf_token_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
@@ -138,3 +140,111 @@ class OutboxEvent(Base):
|
||||
Index("ix_outbox_event_status_occurred", "status", "occurred_at"),
|
||||
Index("ix_outbox_event_aggregate", "aggregate_type", "aggregate_id"),
|
||||
)
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
"""对话容器:归属个人域(垂直切片 WP-03 最小集)。"""
|
||||
|
||||
__tablename__ = "conversation"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: new_id("conversation_")
|
||||
)
|
||||
personal_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False, default="未命名对话")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
"""对话分支:创建会话时生成默认主分支。"""
|
||||
|
||||
__tablename__ = "branch"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("branch_"))
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("conversation.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False, default="主脉")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""消息:content 为 canonical ContentBlock JSON(本切片为 Text/Markdown 子集)。"""
|
||||
|
||||
__tablename__ = "message"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("message_"))
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("conversation.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
branch_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("branch.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False)
|
||||
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("role IN ('user', 'assistant', 'system', 'tool')", name="role_values"),
|
||||
)
|
||||
|
||||
|
||||
class Run(Base):
|
||||
"""Agent 执行:状态机见 Agent 运行时第 3 节;本切片空转即完成。"""
|
||||
|
||||
__tablename__ = "run"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("run_"))
|
||||
personal_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
conversation_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("conversation.id", ondelete="cascade"), nullable=True, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="queued")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('queued', 'running', 'waiting_approval', "
|
||||
"'completed', 'failed', 'cancelled')",
|
||||
name="status_values",
|
||||
),
|
||||
Index("ix_run_domain_status", "personal_domain_id", "status"),
|
||||
)
|
||||
|
||||
|
||||
class IdempotencyRecord(Base):
|
||||
"""幂等键记录:按 认证主体+路由+键 隔离,保存请求哈希与结果引用(契约第 6 节)。"""
|
||||
|
||||
__tablename__ = "idempotency_record"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
owner_domain_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
|
||||
)
|
||||
route: Mapped[str] = mapped_column(String, nullable=False)
|
||||
request_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||
response_status: Mapped[int] = mapped_column(nullable=False)
|
||||
resource_ids: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("uq_idempotency_owner_route_key", "owner_domain_id", "route", "key", unique=True),
|
||||
)
|
||||
Reference in new issue
Block a user