✨ 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,176 @@
|
|||||||
|
"""垂直切片:会话字段与会话树、运行、幂等记录
|
||||||
|
|
||||||
|
Revision ID: 140fe7181a6e
|
||||||
|
Revises: e16a40566b45
|
||||||
|
Create Date: 2026-09-10 10:06:24.354536
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '140fe7181a6e'
|
||||||
|
down_revision: str | Sequence[str] | None = 'e16a40566b45'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('conversation',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('personal_domain_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_conversation_personal_domain'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_conversation'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_conversation_personal_domain_id'), 'conversation', ['personal_domain_id'], unique=False)
|
||||||
|
op.create_table('idempotency_record',
|
||||||
|
sa.Column('key', sa.String(), nullable=False),
|
||||||
|
sa.Column('owner_domain_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('route', sa.String(), nullable=False),
|
||||||
|
sa.Column('request_hash', sa.String(), nullable=False),
|
||||||
|
sa.Column('response_status', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('resource_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_domain_id'], ['personal_domain.id'], name=op.f('fk_idempotency_record_personal_domain'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('key', name=op.f('pk_idempotency_record'))
|
||||||
|
)
|
||||||
|
op.create_index('uq_idempotency_owner_route_key', 'idempotency_record', ['owner_domain_id', 'route', 'key'], unique=True)
|
||||||
|
op.create_table('branch',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('conversation_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_branch_conversation'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_branch'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_branch_conversation_id'), 'branch', ['conversation_id'], unique=False)
|
||||||
|
op.create_table('run',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('personal_domain_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('conversation_id', sa.String(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint("status IN ('queued', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled')", name=op.f('ck_run_status_values')),
|
||||||
|
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_run_conversation'), ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_run_personal_domain'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_run'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_run_conversation_id'), 'run', ['conversation_id'], unique=False)
|
||||||
|
op.create_index('ix_run_domain_status', 'run', ['personal_domain_id', 'status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_run_personal_domain_id'), 'run', ['personal_domain_id'], unique=False)
|
||||||
|
op.create_table('message',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('conversation_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('branch_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('role', sa.String(), nullable=False),
|
||||||
|
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.CheckConstraint("role IN ('user', 'assistant', 'system', 'tool')", name=op.f('ck_message_role_values')),
|
||||||
|
sa.ForeignKeyConstraint(['branch_id'], ['branch.id'], name=op.f('fk_message_branch'), ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], name=op.f('fk_message_conversation'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_message'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_message_branch_id'), 'message', ['branch_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_message_conversation_id'), 'message', ['conversation_id'], unique=False)
|
||||||
|
op.drop_table_comment(
|
||||||
|
'audit_event',
|
||||||
|
existing_comment='不可变审计链:与权威状态、Outbox 同事务提交',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_table_comment(
|
||||||
|
'outbox_event',
|
||||||
|
existing_comment='Transactional Outbox:已提交领域事实的可靠投递队列',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_table_comment(
|
||||||
|
'personal_domain',
|
||||||
|
existing_comment='个人数据域:数据归属与授权边界,绑定固定数据区域',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_table_comment(
|
||||||
|
'service_account',
|
||||||
|
existing_comment='服务账号:API key 明文只显示一次,只存哈希与前缀',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.add_column('session', sa.Column('token_hash', sa.String(), nullable=False))
|
||||||
|
op.add_column('session', sa.Column('csrf_token_hash', sa.String(), nullable=False))
|
||||||
|
op.create_unique_constraint(op.f('uq_session_token_hash'), 'session', ['token_hash'])
|
||||||
|
op.drop_table_comment(
|
||||||
|
'session',
|
||||||
|
existing_comment='Web 会话:ID 为 CSPRNG opaque,仅存校验值',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_table_comment(
|
||||||
|
'user',
|
||||||
|
existing_comment='用户(WP-01):多凭证与账户根',
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table_comment(
|
||||||
|
'user',
|
||||||
|
'用户(WP-01):多凭证与账户根',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.create_table_comment(
|
||||||
|
'session',
|
||||||
|
'Web 会话:ID 为 CSPRNG opaque,仅存校验值',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_constraint(op.f('uq_session_token_hash'), 'session', type_='unique')
|
||||||
|
op.drop_column('session', 'csrf_token_hash')
|
||||||
|
op.drop_column('session', 'token_hash')
|
||||||
|
op.create_table_comment(
|
||||||
|
'service_account',
|
||||||
|
'服务账号:API key 明文只显示一次,只存哈希与前缀',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.create_table_comment(
|
||||||
|
'personal_domain',
|
||||||
|
'个人数据域:数据归属与授权边界,绑定固定数据区域',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.create_table_comment(
|
||||||
|
'outbox_event',
|
||||||
|
'Transactional Outbox:已提交领域事实的可靠投递队列',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.create_table_comment(
|
||||||
|
'audit_event',
|
||||||
|
'不可变审计链:与权威状态、Outbox 同事务提交',
|
||||||
|
existing_comment=None,
|
||||||
|
schema=None
|
||||||
|
)
|
||||||
|
op.drop_index(op.f('ix_message_conversation_id'), table_name='message')
|
||||||
|
op.drop_index(op.f('ix_message_branch_id'), table_name='message')
|
||||||
|
op.drop_table('message')
|
||||||
|
op.drop_index(op.f('ix_run_personal_domain_id'), table_name='run')
|
||||||
|
op.drop_index('ix_run_domain_status', table_name='run')
|
||||||
|
op.drop_index(op.f('ix_run_conversation_id'), table_name='run')
|
||||||
|
op.drop_table('run')
|
||||||
|
op.drop_index(op.f('ix_branch_conversation_id'), table_name='branch')
|
||||||
|
op.drop_table('branch')
|
||||||
|
op.drop_index('uq_idempotency_owner_route_key', table_name='idempotency_record')
|
||||||
|
op.drop_table('idempotency_record')
|
||||||
|
op.drop_index(op.f('ix_conversation_personal_domain_id'), table_name='conversation')
|
||||||
|
op.drop_table('conversation')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""认证模块:Web 会话与依赖。"""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""认证依赖:当前用户解析与状态变更防护。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.service import SESSION_COOKIE, resolve_session
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> User:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if token is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录")
|
||||||
|
session = await resolve_session(db, token)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话无效或已过期")
|
||||||
|
user = await db.get(User, session.user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话主体不存在")
|
||||||
|
# 请求级上下文供后续依赖使用(不重复查询)
|
||||||
|
request.state.session_id = session.id
|
||||||
|
request.state.session_csrf_hash = session.csrf_token_hash
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_state_change(
|
||||||
|
request: Request,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
|
||||||
|
) -> User:
|
||||||
|
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。"""
|
||||||
|
if ALLOWED_ORIGINS:
|
||||||
|
origin = request.headers.get("Origin")
|
||||||
|
if origin is not None and origin not in ALLOWED_ORIGINS:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Origin 不被允许")
|
||||||
|
csrf_hash = getattr(request.state, "session_csrf_hash", None)
|
||||||
|
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_matches(expected_hash: str, provided: str | None) -> bool:
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
if provided is None:
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(expected_hash, hashlib.sha256(provided.encode()).hexdigest())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""认证路由:开发档登录/登出(Web Session Cookie)。
|
||||||
|
|
||||||
|
正式 OAuth 2.1 授权码流程(/oauth2/authorize、/oauth2/token、PKCE)属 WP-01
|
||||||
|
后续条目;本路由只建立 Session 边界骨架供垂直切片验证。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.auth.service import (
|
||||||
|
SESSION_COOKIE,
|
||||||
|
create_session,
|
||||||
|
revoke_session,
|
||||||
|
)
|
||||||
|
from app.db.session import DbSession
|
||||||
|
from app.models import PersonalDomain, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", status_code=201)
|
||||||
|
async def dev_login(
|
||||||
|
request: Request, response: Response, db: DbSession, body: dict[str, str]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""开发档登录:按 email 找到或创建用户与个人域,签发会话。"""
|
||||||
|
email = body.get("email", "").strip().lower()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "email 不合法")
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
(await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
user = User(email=email, display_name=email.split("@")[0])
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
domain = PersonalDomain(user_id=user.id, name="默认镜域")
|
||||||
|
db.add(domain)
|
||||||
|
else:
|
||||||
|
user = existing
|
||||||
|
_, token, csrf = await create_session(db, user.id)
|
||||||
|
await db.commit()
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return {"csrf_token": csrf, "user_id": user.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/current", status_code=204)
|
||||||
|
async def logout(request: Request, response: Response, db: DbSession) -> None:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if token is not None and await revoke_session(db, token):
|
||||||
|
await db.commit()
|
||||||
|
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Web 会话认证:签发、校验与撤销(契约 api-conventions 第 2 节)。
|
||||||
|
|
||||||
|
- Cookie 名 ``__Host-hunjing_session``:HttpOnly、Secure、SameSite=Lax、Path=/。
|
||||||
|
- Cookie 值为 CSPRNG opaque token;数据库只存 SHA-256 哈希(ADR-015)。
|
||||||
|
- 状态变更请求要求 Origin 校验与 ``Hunjing-CSRF-Token`` 双提交。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Session
|
||||||
|
|
||||||
|
SESSION_COOKIE = "__Host-hunjing_session"
|
||||||
|
SESSION_TTL = timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csrf_token() -> str:
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(db: AsyncSession, user_id: str) -> tuple[Session, str, str]:
|
||||||
|
"""签发会话:返回(会话记录,opaque token,CSRF token)。"""
|
||||||
|
token = generate_session_token()
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
session = Session(
|
||||||
|
id=f"web_{secrets.token_urlsafe(12)}",
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=_hash_token(token),
|
||||||
|
csrf_token_hash=_hash_token(csrf),
|
||||||
|
expires_at=datetime.now(UTC) + SESSION_TTL,
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
return session, token, csrf
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_session(db: AsyncSession, token: str) -> Session | None:
|
||||||
|
"""按 opaque token 查找有效(未过期、未撤销)会话。"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Session).where(
|
||||||
|
Session.token_hash == _hash_token(token),
|
||||||
|
Session.revoked_at.is_(None),
|
||||||
|
Session.expires_at > datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_session(db: AsyncSession, token: str) -> bool:
|
||||||
|
session = await resolve_session(db, token)
|
||||||
|
if session is None:
|
||||||
|
return False
|
||||||
|
session.revoked_at = datetime.now(UTC)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def session_is_csrf_valid(session: Session, csrf_header: str | None) -> bool:
|
||||||
|
if csrf_header is None:
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(session.csrf_token_hash, _hash_token(csrf_header))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""请求上下文中间件与结构化日志。
|
||||||
|
|
||||||
|
- `Hunjing-Request-ID`:合法则回显,缺失由服务端生成(UUIDv4,request_ 前缀)。
|
||||||
|
- `traceparent`:透传 W3C Trace Context;缺失生成新 trace-id(不与业务请求 ID 混用)。
|
||||||
|
- JSON 结构化日志:Authorization/Cookie/Set-Cookie 等敏感头脱敏。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.core.ids import new_id
|
||||||
|
|
||||||
|
REDACTED_HEADERS = {"authorization", "cookie", "set-cookie", "hunjing-csrf-token", "x-api-key"}
|
||||||
|
_TRACEPARENT_RE = re.compile(r"^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_headers(headers: Any) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
k: ("[REDACTED]" if k.lower() in REDACTED_HEADERS else v) for k, v in headers.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
for key in ("request_id", "trace_id", "method", "path", "status"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "info") -> None:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(level.upper())
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
||||||
|
request_id = request.headers.get("Hunjing-Request-ID") or new_id("request_", version=4)
|
||||||
|
traceparent = request.headers.get("traceparent")
|
||||||
|
if traceparent is None or not _TRACEPARENT_RE.match(traceparent):
|
||||||
|
traceparent = f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01"
|
||||||
|
trace_id = traceparent.split("-")[1]
|
||||||
|
request.state.request_id = request_id
|
||||||
|
request.state.trace_id = trace_id
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["Hunjing-Request-ID"] = request_id
|
||||||
|
response.headers["traceparent"] = traceparent
|
||||||
|
logging.getLogger("hunjing.access").info(
|
||||||
|
"request",
|
||||||
|
extra={
|
||||||
|
"request_id": request_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status": response.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["RequestContextMiddleware", "configure_logging", "_sanitize_headers"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""请求级数据库会话依赖与引擎生命周期。
|
||||||
|
|
||||||
|
引擎由应用实例持有(create_app 时创建):一个应用一个引擎与连接池,
|
||||||
|
避免模块级引擎跨事件循环复用连接。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from app.db.base import create_engine, create_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def init_engine(app: FastAPI) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""在 app.state 上初始化引擎与会话工厂(应用启动时调用一次)。"""
|
||||||
|
engine: AsyncEngine = create_engine()
|
||||||
|
app.state.engine = engine
|
||||||
|
factory = create_session_factory(engine)
|
||||||
|
app.state.session_factory = factory
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db(request: Request) -> AsyncIterator[AsyncSession]:
|
||||||
|
"""每请求一个会话:成功提交、异常回滚。"""
|
||||||
|
factory: async_sessionmaker[AsyncSession] = request.app.state.session_factory
|
||||||
|
async with factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.auth.routes import router as auth_router
|
||||||
|
from app.conversations.routes import router as conversations_router
|
||||||
|
from app.conversations.routes import run_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.errors import install_error_handlers
|
from app.core.errors import install_error_handlers
|
||||||
|
from app.core.middleware import RequestContextMiddleware, configure_logging
|
||||||
|
from app.db.session import init_engine
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
configure_logging(settings.log_level)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Hunjing API",
|
title="Hunjing API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
@@ -16,6 +22,11 @@ def create_app() -> FastAPI:
|
|||||||
openapi_url="/_dev/openapi.json",
|
openapi_url="/_dev/openapi.json",
|
||||||
)
|
)
|
||||||
install_error_handlers(app)
|
install_error_handlers(app)
|
||||||
|
init_engine(app)
|
||||||
|
app.add_middleware(RequestContextMiddleware)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(conversations_router)
|
||||||
|
app.include_router(run_router)
|
||||||
|
|
||||||
@app.get("/healthz", tags=["ops"])
|
@app.get("/healthz", tags=["ops"])
|
||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class Session(Base):
|
|||||||
user_id: Mapped[str] = mapped_column(
|
user_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("user.id", ondelete="cascade"), nullable=False, index=True
|
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(
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, index=True
|
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_status_occurred", "status", "occurred_at"),
|
||||||
Index("ix_outbox_event_aggregate", "aggregate_type", "aggregate_id"),
|
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),
|
||||||
|
)
|
||||||
@@ -24,3 +24,35 @@ async def db() -> async_sessionmaker[AsyncSession]:
|
|||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
yield factory
|
yield factory
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(db):
|
||||||
|
"""连接测试库的应用客户端(覆盖 get_db)。"""
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.db import session as db_session
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
async def override():
|
||||||
|
async with db() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
app.dependency_overrides[db_session.get_db] = override
|
||||||
|
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||||
|
async with AsyncClient(transport=transport, base_url="https://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
async def login(client, email: str) -> dict[str, str]:
|
||||||
|
"""开发档登录:返回需附加的请求头(Cookie 由客户端自动携带)。"""
|
||||||
|
resp = await client.post("/v1/auth/sessions", json={"email": email})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
from tests.conftest import login
|
||||||
|
|
||||||
|
MESSAGE_BODY = {"content": [{"type": "text", "text": "你好,魂镜"}], "start_run": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_conversation(client: AsyncClient, headers: dict) -> str:
|
||||||
|
resp = await client.post(
|
||||||
|
"/v1/conversations", json={"title": "切片"}, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str) -> object:
|
||||||
|
return await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages",
|
||||||
|
json=MESSAGE_BODY,
|
||||||
|
headers={**headers, "Idempotency-Key": key},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessagePipeline:
|
||||||
|
async def test_create_and_post_message(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "alice@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await _post_message(client, headers, cid, "idem-1")
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["message"]["id"].startswith("message_")
|
||||||
|
assert body["message"]["content"][0]["id"].startswith("block_")
|
||||||
|
assert body["run"]["status"] == "completed" # 空状态闭环
|
||||||
|
|
||||||
|
async def test_idempotent_replay_returns_original(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "bob@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
first = await _post_message(client, headers, cid, "idem-replay")
|
||||||
|
assert first.status_code == 201
|
||||||
|
second = await _post_message(client, headers, cid, "idem-replay")
|
||||||
|
assert second.status_code == 200 # 重放返回原资源
|
||||||
|
assert second.json()["message"]["id"] == first.json()["message"]["id"]
|
||||||
|
|
||||||
|
async def test_same_key_different_body_conflicts(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "carol@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
await _post_message(client, headers, cid, "idem-conflict")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages",
|
||||||
|
json={"content": [{"type": "text", "text": "不同内容"}], "start_run": True},
|
||||||
|
headers={**headers, "Idempotency-Key": "idem-conflict"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
async def test_missing_idempotency_key_rejected(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "dave@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages", json=MESSAGE_BODY, headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_unknown_block_type_rejected(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "erin@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/v1/conversations/{cid}/messages",
|
||||||
|
json={"content": [{"type": "image", "file_id": "x", "alt": "y"}]},
|
||||||
|
headers={**headers, "Idempotency-Key": "idem-bad"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
async def test_cross_domain_conversation_returns_404(self, client: AsyncClient) -> None:
|
||||||
|
alice = await login(client, "ida@example.com")
|
||||||
|
cid = await _create_conversation(client, alice)
|
||||||
|
mallory = await login(client, "mallory@example.com")
|
||||||
|
resp = await _post_message(client, mallory, cid, "idem-idor")
|
||||||
|
assert resp.status_code == 404 # fail-closed,不泄露存在性
|
||||||
|
|
||||||
|
async def test_csrf_token_required_for_state_change(self, client: AsyncClient) -> None:
|
||||||
|
await login(client, "frank@example.com")
|
||||||
|
resp = await client.post("/v1/conversations", json={"title": "无 CSRF"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_unauthenticated_rejected(self, client: AsyncClient) -> None:
|
||||||
|
resp = await client.post("/v1/conversations", json={"title": "匿名"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunSse:
|
||||||
|
async def test_sse_snapshot_and_terminal(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "grace@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await _post_message(client, headers, cid, "idem-sse")
|
||||||
|
run_id = resp.json()["run"]["id"]
|
||||||
|
sse = await client.get(f"/v1/runs/{run_id}/events")
|
||||||
|
assert sse.status_code == 200
|
||||||
|
assert sse.headers["content-type"].startswith("text/event-stream")
|
||||||
|
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
|
||||||
|
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
|
||||||
|
assert events[0]["type"] == "run.status.changed"
|
||||||
|
assert events[0]["data"]["status"] == "completed"
|
||||||
|
assert events[-1]["type"] == "message.completed"
|
||||||
|
|
||||||
|
async def test_cross_domain_run_returns_404(self, client: AsyncClient) -> None:
|
||||||
|
headers = await login(client, "heidi@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
resp = await _post_message(client, headers, cid, "idem-sse2")
|
||||||
|
run_id = resp.json()["run"]["id"]
|
||||||
|
await login(client, "eve@example.com")
|
||||||
|
sse = await client.get(f"/v1/runs/{run_id}/events")
|
||||||
|
assert sse.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtomicity:
|
||||||
|
async def test_failure_leaves_no_orphans(self, client: AsyncClient, db) -> None:
|
||||||
|
from app.conversations import service
|
||||||
|
|
||||||
|
headers = await login(client, "ivan@example.com")
|
||||||
|
cid = await _create_conversation(client, headers)
|
||||||
|
|
||||||
|
class BoomRun:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
raise RuntimeError("注入失败:Run 写入阶段崩溃")
|
||||||
|
|
||||||
|
original = service.Run
|
||||||
|
service.Run = BoomRun # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
resp = await _post_message(client, headers, cid, "idem-atomic")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
finally:
|
||||||
|
service.Run = original # type: ignore[assignment]
|
||||||
|
|
||||||
|
async with db() as session:
|
||||||
|
# 会话创建的合法审计/Outbox 保留;消息相关记录必须为零
|
||||||
|
assert (await session.scalars(select(models.Message))).all() == []
|
||||||
|
assert (await session.scalars(select(models.Run))).all() == []
|
||||||
|
audits = (await session.scalars(select(models.AuditEvent))).all()
|
||||||
|
assert [a for a in audits if a.aggregate_type == "message"] == []
|
||||||
|
outbox = (await session.scalars(select(models.OutboxEvent))).all()
|
||||||
|
assert [e for e in outbox if e.aggregate_type == "message"] == []
|
||||||
|
assert (await session.scalars(select(models.IdempotencyRecord))).all() == []
|
||||||
@@ -65,6 +65,8 @@ async def test_session_cascades_with_user(db: async_sessionmaker[AsyncSession])
|
|||||||
Session(
|
Session(
|
||||||
id="opaque-session-token",
|
id="opaque-session-token",
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
token_hash="sha256:opaque-session-token",
|
||||||
|
csrf_token_hash="sha256:csrf",
|
||||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user