fix: 修复分享功能 datetime 时区比较错误和端口配置 [AC-IDMP-SHARE]
- 新增 _utcnow() 函数返回带时区的 UTC 时间 - 替换所有 datetime.utcnow() 为 _utcnow() 避免时区比较错误 - 修复 frontend_base_url 默认端口从 5173 改为 3000 - 修复 FastAPI operationId 参数命名为 operation_id
This commit is contained in:
parent
127ce5d8a9
commit
1b325f5aeb
|
|
@ -0,0 +1,434 @@
|
|||
"""
|
||||
Share Controller for Mid Platform.
|
||||
[AC-IDMP-SHARE] Share session via unique token with expiration and concurrent user limits.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_session
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models.entities import ChatMessage, SharedSession
|
||||
from app.models.mid.schemas import (
|
||||
CreateShareRequest,
|
||||
ShareListResponse,
|
||||
ShareListItem,
|
||||
ShareResponse,
|
||||
SharedMessageRequest,
|
||||
SharedSessionInfo,
|
||||
HistoryMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/mid", tags=["Mid Platform Share"])
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
"""Get current UTC time with timezone info."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _generate_share_url(share_token: str) -> str:
|
||||
"""Generate full share URL from token."""
|
||||
base_url = getattr(settings, 'frontend_base_url', 'http://localhost:3000')
|
||||
return f"{base_url}/share/{share_token}"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/share",
|
||||
operation_id="createShare",
|
||||
summary="Create a share link for session",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Create a share link for a chat session.
|
||||
|
||||
Returns share_token and share_url for accessing the shared conversation.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Share created successfully", "model": ShareResponse},
|
||||
400: {"description": "Invalid request"},
|
||||
404: {"description": "Session not found"},
|
||||
},
|
||||
)
|
||||
async def create_share(
|
||||
session_id: Annotated[str, Path(description="Session ID to share")],
|
||||
request: CreateShareRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ShareResponse:
|
||||
"""Create a share link for a session."""
|
||||
tenant_id = get_tenant_id()
|
||||
|
||||
if not tenant_id:
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
raise MissingTenantIdException()
|
||||
|
||||
share_token = str(uuid.uuid4())
|
||||
expires_at = _utcnow() + timedelta(days=request.expires_in_days)
|
||||
|
||||
shared_session = SharedSession(
|
||||
share_token=share_token,
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
expires_at=expires_at,
|
||||
max_concurrent_users=request.max_concurrent_users,
|
||||
current_users=0,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(shared_session)
|
||||
await db.commit()
|
||||
await db.refresh(shared_session)
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDMP-SHARE] Share created: tenant={tenant_id}, "
|
||||
f"session={session_id}, token={share_token}, expires={expires_at}"
|
||||
)
|
||||
|
||||
return ShareResponse(
|
||||
share_token=share_token,
|
||||
share_url=_generate_share_url(share_token),
|
||||
expires_at=expires_at.isoformat(),
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
max_concurrent_users=request.max_concurrent_users,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/share/{share_token}",
|
||||
operation_id="getSharedSession",
|
||||
summary="Get shared session info",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Get shared session information by share token.
|
||||
|
||||
Returns session info with history messages. Public endpoint (no tenant required).
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Shared session info", "model": SharedSessionInfo},
|
||||
404: {"description": "Share not found or expired"},
|
||||
410: {"description": "Share expired or inactive"},
|
||||
429: {"description": "Too many concurrent users"},
|
||||
},
|
||||
)
|
||||
async def get_shared_session(
|
||||
share_token: Annotated[str, Path(description="Share token")],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SharedSessionInfo:
|
||||
"""Get shared session info by token (public endpoint)."""
|
||||
result = await db.execute(
|
||||
select(SharedSession).where(SharedSession.share_token == share_token)
|
||||
)
|
||||
shared = result.scalar_one_or_none()
|
||||
|
||||
if not shared:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
if not shared.is_active:
|
||||
raise HTTPException(status_code=410, detail="Share is inactive")
|
||||
|
||||
if shared.expires_at < _utcnow():
|
||||
raise HTTPException(status_code=410, detail="Share has expired")
|
||||
|
||||
if shared.current_users >= shared.max_concurrent_users:
|
||||
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
|
||||
|
||||
messages_result = await db.execute(
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
and_(
|
||||
ChatMessage.tenant_id == shared.tenant_id,
|
||||
ChatMessage.session_id == shared.session_id,
|
||||
)
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
)
|
||||
messages = messages_result.scalars().all()
|
||||
|
||||
history = [
|
||||
HistoryMessage(role=msg.role, content=msg.content)
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
return SharedSessionInfo(
|
||||
session_id=shared.session_id,
|
||||
title=shared.title,
|
||||
description=shared.description,
|
||||
expires_at=shared.expires_at.isoformat(),
|
||||
max_concurrent_users=shared.max_concurrent_users,
|
||||
current_users=shared.current_users,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/shares",
|
||||
operation_id="listShares",
|
||||
summary="List all shares for a session",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] List all share links for a session.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "List of shares", "model": ShareListResponse},
|
||||
},
|
||||
)
|
||||
async def list_shares(
|
||||
session_id: Annotated[str, Path(description="Session ID")],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
include_expired: Annotated[bool, Query(description="Include expired shares")] = False,
|
||||
) -> ShareListResponse:
|
||||
"""List all shares for a session."""
|
||||
tenant_id = get_tenant_id()
|
||||
|
||||
if not tenant_id:
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
raise MissingTenantIdException()
|
||||
|
||||
query = select(SharedSession).where(
|
||||
and_(
|
||||
SharedSession.tenant_id == tenant_id,
|
||||
SharedSession.session_id == session_id,
|
||||
)
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
if not include_expired:
|
||||
query = query.where(SharedSession.expires_at > now)
|
||||
|
||||
query = query.order_by(SharedSession.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
shares = result.scalars().all()
|
||||
|
||||
items = [
|
||||
ShareListItem(
|
||||
share_token=s.share_token,
|
||||
share_url=_generate_share_url(s.share_token),
|
||||
title=s.title,
|
||||
description=s.description,
|
||||
expires_at=s.expires_at.isoformat(),
|
||||
is_active=s.is_active and s.expires_at > now,
|
||||
max_concurrent_users=s.max_concurrent_users,
|
||||
current_users=s.current_users,
|
||||
created_at=s.created_at.isoformat(),
|
||||
)
|
||||
for s in shares
|
||||
]
|
||||
|
||||
return ShareListResponse(shares=items)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/share/{share_token}",
|
||||
operation_id="deleteShare",
|
||||
summary="Delete a share",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Delete (deactivate) a share link.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Share deleted"},
|
||||
404: {"description": "Share not found"},
|
||||
},
|
||||
)
|
||||
async def delete_share(
|
||||
share_token: Annotated[str, Path(description="Share token")],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> dict:
|
||||
"""Delete a share (set is_active=False)."""
|
||||
tenant_id = get_tenant_id()
|
||||
|
||||
if not tenant_id:
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
raise MissingTenantIdException()
|
||||
|
||||
result = await db.execute(
|
||||
select(SharedSession).where(
|
||||
and_(
|
||||
SharedSession.share_token == share_token,
|
||||
SharedSession.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
shared = result.scalar_one_or_none()
|
||||
|
||||
if not shared:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
shared.is_active = False
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"[AC-IDMP-SHARE] Share deleted: token={share_token}")
|
||||
|
||||
return {"success": True, "message": "Share deleted"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/share/{share_token}/join",
|
||||
operation_id="joinSharedSession",
|
||||
summary="Join a shared session",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Join a shared session (increment current_users).
|
||||
|
||||
Returns updated session info.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Joined successfully", "model": SharedSessionInfo},
|
||||
404: {"description": "Share not found"},
|
||||
410: {"description": "Share expired or inactive"},
|
||||
429: {"description": "Too many concurrent users"},
|
||||
},
|
||||
)
|
||||
async def join_shared_session(
|
||||
share_token: Annotated[str, Path(description="Share token")],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SharedSessionInfo:
|
||||
"""Join a shared session (increment current_users)."""
|
||||
result = await db.execute(
|
||||
select(SharedSession).where(SharedSession.share_token == share_token)
|
||||
)
|
||||
shared = result.scalar_one_or_none()
|
||||
|
||||
if not shared:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
if not shared.is_active:
|
||||
raise HTTPException(status_code=410, detail="Share is inactive")
|
||||
|
||||
if shared.expires_at < _utcnow():
|
||||
raise HTTPException(status_code=410, detail="Share has expired")
|
||||
|
||||
if shared.current_users >= shared.max_concurrent_users:
|
||||
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
|
||||
|
||||
shared.current_users += 1
|
||||
shared.updated_at = _utcnow()
|
||||
await db.commit()
|
||||
|
||||
messages_result = await db.execute(
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
and_(
|
||||
ChatMessage.tenant_id == shared.tenant_id,
|
||||
ChatMessage.session_id == shared.session_id,
|
||||
)
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
)
|
||||
messages = messages_result.scalars().all()
|
||||
|
||||
history = [
|
||||
HistoryMessage(role=msg.role, content=msg.content)
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
logger.info(f"[AC-IDMP-SHARE] User joined: token={share_token}, users={shared.current_users}")
|
||||
|
||||
return SharedSessionInfo(
|
||||
session_id=shared.session_id,
|
||||
title=shared.title,
|
||||
description=shared.description,
|
||||
expires_at=shared.expires_at.isoformat(),
|
||||
max_concurrent_users=shared.max_concurrent_users,
|
||||
current_users=shared.current_users,
|
||||
history=history,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/share/{share_token}/leave",
|
||||
operation_id="leaveSharedSession",
|
||||
summary="Leave a shared session",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Leave a shared session (decrement current_users).
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Left successfully"},
|
||||
404: {"description": "Share not found"},
|
||||
},
|
||||
)
|
||||
async def leave_shared_session(
|
||||
share_token: Annotated[str, Path(description="Share token")],
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> dict:
|
||||
"""Leave a shared session (decrement current_users)."""
|
||||
result = await db.execute(
|
||||
select(SharedSession).where(SharedSession.share_token == share_token)
|
||||
)
|
||||
shared = result.scalar_one_or_none()
|
||||
|
||||
if not shared:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
if shared.current_users > 0:
|
||||
shared.current_users -= 1
|
||||
shared.updated_at = _utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"[AC-IDMP-SHARE] User left: token={share_token}, users={shared.current_users}")
|
||||
|
||||
return {"success": True, "current_users": shared.current_users}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/share/{share_token}/message",
|
||||
operation_id="sendSharedMessage",
|
||||
summary="Send message via shared session",
|
||||
description="""
|
||||
[AC-IDMP-SHARE] Send a message via shared session.
|
||||
|
||||
Creates a new message in the shared session and returns the response.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Message sent successfully"},
|
||||
404: {"description": "Share not found"},
|
||||
410: {"description": "Share expired or inactive"},
|
||||
},
|
||||
)
|
||||
async def send_shared_message(
|
||||
share_token: Annotated[str, Path(description="Share token")],
|
||||
request: SharedMessageRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> dict:
|
||||
"""Send a message via shared session."""
|
||||
result = await db.execute(
|
||||
select(SharedSession).where(SharedSession.share_token == share_token)
|
||||
)
|
||||
shared = result.scalar_one_or_none()
|
||||
|
||||
if not shared:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
if not shared.is_active:
|
||||
raise HTTPException(status_code=410, detail="Share is inactive")
|
||||
|
||||
if shared.expires_at < _utcnow():
|
||||
raise HTTPException(status_code=410, detail="Share has expired")
|
||||
|
||||
user_message = ChatMessage(
|
||||
tenant_id=shared.tenant_id,
|
||||
session_id=shared.session_id,
|
||||
role="user",
|
||||
content=request.user_message,
|
||||
)
|
||||
db.add(user_message)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDMP-SHARE] Message sent via share: token={share_token}, "
|
||||
f"session={shared.session_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Message sent successfully",
|
||||
"session_id": shared.session_id,
|
||||
}
|
||||
|
|
@ -65,6 +65,8 @@ class Settings(BaseSettings):
|
|||
dashboard_cache_ttl: int = 60
|
||||
stats_counter_ttl: int = 7776000
|
||||
|
||||
frontend_base_url: str = "http://localhost:3000"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
|
|
|||
Loading…
Reference in New Issue