feat: 实现用户画像、占卜历史与后端用户管理模块

This commit is contained in:
ZL-Q
2026-04-06 01:28:10 +08:00
parent d87b2e1e3a
commit 8a18b3528b
77 changed files with 5850 additions and 2604 deletions
+53
View File
@@ -335,6 +335,59 @@ class AgentRepository:
return None
return str(latest_id)
async def get_latest_assistant_messages_by_user_sessions(
self,
*,
user_id: str,
visibility_mask: int | None = None,
session_limit: int = 50,
) -> list[dict[str, object]]:
try:
user_uuid = UUID(user_id)
except ValueError as exc:
raise ApiProblemError(
status_code=422,
code="AGENT_USER_ID_INVALID",
detail="Invalid user_id",
) from exc
safe_limit = max(int(session_limit), 1)
session_stmt = (
select(AgentChatSession.id)
.where(AgentChatSession.user_id == user_uuid)
.where(AgentChatSession.deleted_at.is_(None))
.order_by(AgentChatSession.last_activity_at.desc())
.limit(safe_limit)
)
session_ids = (await self._session.execute(session_stmt)).scalars().all()
if not session_ids:
return []
snapshots: list[dict[str, object]] = []
for session_id in session_ids:
message_stmt = (
select(AgentChatMessage)
.where(AgentChatMessage.session_id == session_id)
.where(AgentChatMessage.deleted_at.is_(None))
.where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT)
.order_by(AgentChatMessage.created_at.desc())
.limit(1)
)
message_stmt = self._apply_visibility_filter(
stmt=message_stmt,
visibility_mask=visibility_mask,
)
message = (await self._session.execute(message_stmt)).scalar_one_or_none()
if message is None:
continue
snapshots.append(await self._to_snapshot_message(message))
snapshots.sort(
key=lambda item: str(item.get("timestamp") or ""),
reverse=True,
)
return snapshots
async def get_system_agent_config(
self, *, agent_type: str
) -> dict[str, object] | None:
+29 -3
View File
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.ui_schema import UiSchemaRenderer
from schemas.domain.divination import DerivedDivinationData
class AgentRepositoryLike(Protocol):
@@ -31,6 +31,14 @@ class AgentRepositoryLike(Protocol):
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
async def get_latest_assistant_messages_by_user_sessions(
self,
*,
user_id: str,
visibility_mask: int | None = None,
session_limit: int = 50,
) -> list[dict[str, object]]: ...
async def persist_user_message(
self,
*,
@@ -187,13 +195,31 @@ class HistoryMessage(BaseModel):
default_factory=list,
description="Temporary signed URLs for user-attached images",
)
ui_schema: UiSchemaRenderer | None = Field(
agent_output: HistoryAgentOutput | None = Field(
default=None,
description="Compiled UI schema from worker ui_hints for frontend rendering",
description="Structured assistant output for history replay",
)
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
class HistoryAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["success", "failed"] | None = None
sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None
summary: str | None = None
conclusion: list[str] = Field(default_factory=list)
focus_points: list[str] = Field(default_factory=list)
advice: list[str] = Field(default_factory=list)
keywords: list[str] = Field(default_factory=list)
answer: str | None = None
key_points: list[str] = Field(default_factory=list)
result_type: str | None = None
suggested_actions: list[str] = Field(default_factory=list)
divination_derived: DerivedDivinationData | None = None
class HistorySnapshotResponse(BaseModel):
"""Response schema for GET /api/v1/agent/history"""
+29 -15
View File
@@ -641,23 +641,37 @@ class AgentService:
thread_id: str | None,
before: date | None,
) -> HistorySnapshotResponse:
target_thread_id = thread_id
if target_thread_id is None:
target_thread_id = await self._repository.get_latest_session_id_for_user(
user_id=str(current_user.id)
from schemas.domain.chat_message import AgentChatMessage
from v1.agent.utils import convert_message_to_history
from v1.agent.schemas import HistoryMessage
if thread_id is not None:
return await self.get_history_snapshot(
thread_id=thread_id,
before=before,
current_user=current_user,
)
if target_thread_id is None:
return HistorySnapshotResponse(
scope="history_day",
threadId=None,
day=None,
hasMore=False,
messages=[],
raw_messages = (
await self._repository.get_latest_assistant_messages_by_user_sessions(
user_id=str(current_user.id),
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
session_limit=50,
)
return await self.get_history_snapshot(
thread_id=target_thread_id,
before=before,
current_user=current_user,
)
messages: list[HistoryMessage] = []
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
converted = convert_message_to_history(msg)
messages.append(HistoryMessage.model_validate(converted))
return HistorySnapshotResponse(
scope="history_sessions_latest_assistant",
threadId=None,
day=None,
hasMore=False,
messages=messages,
)
def _validate_binary_signed_url(
+45 -22
View File
@@ -7,7 +7,7 @@
from collections.abc import Callable
from typing import Any
from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints
from schemas.agent.runtime_models import AgentOutput
from schemas.domain.chat_message import (
AgentChatMessage,
AgentChatMessageMetadata,
@@ -29,20 +29,20 @@ def convert_message_to_history(
转换规则:
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
- role=assistant: 读取 metadata.agent_output.ui_hints,编译成 ui_schema
- role=assistant: 读取 metadata.agent_output,输出受控 agent_output
"""
role = message.role
content = message.content
metadata = message.metadata
attachments: list[dict[str, str]] = []
ui_schema: dict[str, Any] | None = None
agent_output: dict[str, Any] | None = None
if role == "user":
attachments = _convert_user_attachments(metadata, get_signed_url_fn)
elif role == "assistant":
ui_schema = _compile_worker_ui_hints(metadata)
agent_output = _extract_worker_agent_output(metadata)
result: dict[str, Any] = {
"id": str(message.id),
@@ -55,8 +55,8 @@ def convert_message_to_history(
if attachments:
result["attachments"] = attachments
if ui_schema:
result["ui_schema"] = ui_schema
if agent_output:
result["agent_output"] = agent_output
return result
@@ -93,10 +93,10 @@ def _convert_user_attachments(
return signed_attachments
def _compile_worker_ui_hints(
def _extract_worker_agent_output(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
"""编译 assistant 消息的 agent ui_hints"""
"""提取 assistant 消息的结构化 agent_output。"""
if not metadata:
return None
@@ -106,29 +106,52 @@ def _compile_worker_ui_hints(
agent_output_data = metadata.get("agent_output")
if not agent_output_data:
return None
if isinstance(agent_output_data, dict):
raw_ui_schema = agent_output_data.get("ui_schema")
if isinstance(raw_ui_schema, dict):
return raw_ui_schema
from schemas.agent.runtime_models import AgentOutput
try:
agent_output = AgentOutput.model_validate(agent_output_data)
except Exception:
return None
normalized_payload = _normalize_agent_output_payload(agent_output_data)
try:
agent_output = AgentOutput.model_validate(normalized_payload)
except Exception:
return None
if not agent_output:
return None
ui_hints = agent_output.ui_hints
if not ui_hints:
return None
payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True)
payload.pop("ui_hints", None)
return payload or None
try:
compiled = compile_ui_hints(ui_hints)
return compiled
except Exception:
def _normalize_agent_output_payload(agent_output_data: Any) -> dict[str, Any] | None:
if not isinstance(agent_output_data, dict):
return None
normalized = dict(agent_output_data)
derived = normalized.get("divination_derived")
if isinstance(derived, dict):
normalized["divination_derived"] = _normalize_divination_derived(derived)
return normalized
def _normalize_divination_derived(value: Any) -> Any:
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
normalized_key = _snake_to_camel(key)
result[normalized_key] = _normalize_divination_derived(item)
return result
if isinstance(value, list):
return [_normalize_divination_derived(item) for item in value]
return value
def _snake_to_camel(value: str) -> str:
if "_" not in value:
return value
parts = value.split("_")
if not parts:
return value
return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])
def mime_to_suffix(mime_type: str) -> str:
+2
View File
@@ -5,9 +5,11 @@ from fastapi import APIRouter
from v1.agent.router import router as agent_router
from v1.auth.router import router as auth_router
from v1.points.router import router as points_router
from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(agent_router)
router.include_router(points_router)
router.include_router(users_router)
+6 -2
View File
@@ -11,6 +11,7 @@ from core.auth.models import CurrentUser
from core.db import get_db
from core.http.errors import ApiProblemError, problem_payload
from services.base.supabase import supabase_service
from v1.users.repository import SQLAlchemyUserRepository
from v1.users.service import UserService
@@ -53,5 +54,8 @@ def get_user_service(
session: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> UserService:
_ = session
return UserService(current_user=user)
return UserService(
current_user=user,
repository=SQLAlchemyUserRepository(session=session),
attachment_storage=supabase_service,
)
+28 -5
View File
@@ -3,12 +3,35 @@ from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.profile import Profile
@dataclass
class SQLAlchemyUserRepository:
session: object
session: AsyncSession
async def get_by_user_ids(self, user_ids: list[UUID]) -> dict[UUID, object]:
_ = self.session
_ = user_ids
return {}
async def get_by_user_ids(self, user_ids: list[UUID]) -> dict[UUID, Profile]:
if not user_ids:
return {}
stmt = (
select(Profile)
.where(Profile.id.in_(user_ids))
.where(Profile.deleted_at.is_(None))
)
rows = (await self.session.execute(stmt)).scalars().all()
return {row.id: row for row in rows}
async def get_profile_by_user_id(self, *, user_id: UUID) -> Profile | None:
stmt = (
select(Profile)
.where(Profile.id == user_id)
.where(Profile.deleted_at.is_(None))
.limit(1)
)
return (await self.session.execute(stmt)).scalar_one_or_none()
async def save(self) -> None:
await self.session.commit()
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, UploadFile
from v1.users.dependencies import get_user_service
from v1.users.schemas import (
AvatarUploadUrlRequest,
AvatarUploadUrlResponse,
ProfileResponse,
UpdateProfileRequest,
)
from v1.users.service import UserService
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me/profile", response_model=ProfileResponse)
async def get_my_profile(
service: UserService = Depends(get_user_service),
) -> ProfileResponse:
return await service.get_profile()
@router.patch("/me/profile", response_model=ProfileResponse)
async def update_my_profile(
payload: UpdateProfileRequest,
service: UserService = Depends(get_user_service),
) -> ProfileResponse:
return await service.update_profile(payload)
@router.post("/me/avatar/upload-url", response_model=AvatarUploadUrlResponse)
async def create_avatar_upload_url(
payload: AvatarUploadUrlRequest,
service: UserService = Depends(get_user_service),
) -> AvatarUploadUrlResponse:
raw = await service.create_avatar_upload_url(payload)
return AvatarUploadUrlResponse.model_validate(raw)
@router.post("/me/avatar", response_model=ProfileResponse)
async def upload_avatar(
file: UploadFile = File(...),
service: UserService = Depends(get_user_service),
) -> ProfileResponse:
return await service.upload_avatar(file)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ProfileResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: str
display_name: str
bio: str | None = None
avatar_path: str | None = None
avatar_url: str | None = None
settings: dict[str, Any] = Field(default_factory=dict)
updated_at: datetime
class UpdateProfileRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=30)
bio: str | None = Field(default=None, max_length=200)
avatar_path: str | None = None
class AvatarUploadUrlRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mime_type: str
file_size: int = Field(gt=0)
ext: str
class AvatarUploadUrlResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
bucket: str
path: str
upload_url: str
expires_in: int
+273 -4
View File
@@ -1,22 +1,291 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from uuid import uuid4
from fastapi import UploadFile
from structlog import get_logger
from core.config.settings import config
from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload
from services.base.supabase import SupabaseService
from schemas.shared.user import UserContext
from v1.users.repository import SQLAlchemyUserRepository
from v1.users.schemas import (
AvatarUploadUrlRequest,
ProfileResponse,
UpdateProfileRequest,
)
logger = get_logger("v1.users.service")
@dataclass
class UserService:
current_user: CurrentUser
repository: SQLAlchemyUserRepository
attachment_storage: SupabaseService
async def get_me(self) -> UserContext:
profile = await self.repository.get_profile_by_user_id(
user_id=self.current_user.id
)
user_id = str(self.current_user.id)
return UserContext(
id=user_id,
username=f"user_{user_id[:8]}",
username=profile.username if profile is not None else f"user_{user_id[:8]}",
email=self.current_user.email,
avatar_url=None,
bio=None,
settings=None,
avatar_url=profile.avatar_url if profile is not None else None,
bio=profile.bio if profile is not None else None,
settings=profile.settings if profile is not None else None,
)
async def get_profile(self) -> ProfileResponse:
profile = await self.repository.get_profile_by_user_id(
user_id=self.current_user.id
)
if profile is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PROFILE_NOT_FOUND",
detail="Profile not found",
),
)
avatar_url = await self._resolve_avatar_url(profile.avatar_url)
return ProfileResponse(
user_id=str(self.current_user.id),
display_name=profile.username,
bio=profile.bio,
avatar_path=profile.avatar_url,
avatar_url=avatar_url,
settings=profile.settings,
updated_at=profile.updated_at,
)
async def update_profile(self, payload: UpdateProfileRequest) -> ProfileResponse:
if (
payload.display_name is None
and payload.bio is None
and payload.avatar_path is None
):
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PROFILE_PAYLOAD_INVALID",
detail="At least one profile field must be provided",
),
)
profile = await self.repository.get_profile_by_user_id(
user_id=self.current_user.id
)
if profile is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PROFILE_NOT_FOUND",
detail="Profile not found",
),
)
if payload.display_name is not None:
next_name = payload.display_name.strip()
if not next_name:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PROFILE_PAYLOAD_INVALID",
detail="display_name cannot be empty",
),
)
profile.username = next_name
if payload.bio is not None:
profile.bio = payload.bio.strip() or None
if payload.avatar_path is not None:
expected_prefix = f"{config.storage.avatar.bucket}/{self.current_user.id}/"
if not payload.avatar_path.startswith(expected_prefix):
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_PATH_SCOPE_INVALID",
detail="Invalid avatar path scope",
),
)
profile.avatar_url = payload.avatar_path
await self.repository.save()
return await self.get_profile()
async def create_avatar_upload_url(
self, payload: AvatarUploadUrlRequest
) -> dict[str, str | int]:
max_bytes = config.storage.avatar.max_size_mb * 1024 * 1024
if payload.file_size > max_bytes:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar file size exceeds limit",
),
)
if payload.mime_type not in {"image/png", "image/jpeg", "image/webp"}:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar mime type not allowed",
),
)
ext = payload.ext.lower().strip()
if ext not in {"png", "jpg", "jpeg", "webp"}:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar extension not allowed",
),
)
bucket = config.storage.avatar.bucket
storage_path = f"{self.current_user.id}/{uuid4()}.{ext}"
try:
upload_url = await self.attachment_storage.create_signed_url(
bucket=bucket,
path=storage_path,
expires_in_seconds=config.storage.signed_url_ttl_seconds,
)
except Exception as exc:
raise ApiProblemError(
status_code=502,
detail=problem_payload(
code="AVATAR_SIGNED_URL_FAILED",
detail="Failed to generate avatar signed URL",
),
) from exc
return {
"bucket": bucket,
"path": f"{bucket}/{storage_path}",
"upload_url": upload_url,
"expires_in": config.storage.signed_url_ttl_seconds,
}
async def upload_avatar(self, upload: UploadFile) -> ProfileResponse:
profile = await self.repository.get_profile_by_user_id(
user_id=self.current_user.id
)
if profile is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PROFILE_NOT_FOUND",
detail="Profile not found",
),
)
filename = upload.filename or "avatar"
ext = Path(filename).suffix.lower().lstrip(".")
if ext not in {"png", "jpg", "jpeg", "webp"}:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar extension not allowed",
),
)
mime_type = (upload.content_type or "").lower().strip()
if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
if ext == "png":
mime_type = "image/png"
elif ext in {"jpg", "jpeg"}:
mime_type = "image/jpeg"
elif ext == "webp":
mime_type = "image/webp"
else:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar mime type not allowed",
),
)
content = await upload.read()
if not content:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar content is empty",
),
)
max_bytes = config.storage.avatar.max_size_mb * 1024 * 1024
if len(content) > max_bytes:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AVATAR_FILE_INVALID",
detail="Avatar file size exceeds limit",
),
)
bucket = config.storage.avatar.bucket
storage_path = f"{self.current_user.id}/{uuid4()}.{ext}"
try:
await self.attachment_storage.upload_bytes(
bucket=bucket,
path=storage_path,
content=content,
content_type=mime_type,
)
except Exception as exc:
logger.exception(
"Avatar upload to storage failed",
user_id=str(self.current_user.id),
bucket=bucket,
path=storage_path,
mime_type=mime_type,
size_bytes=len(content),
)
raise ApiProblemError(
status_code=502,
detail=problem_payload(
code="AVATAR_UPLOAD_FAILED",
detail="Failed to upload avatar",
),
) from exc
profile.avatar_url = f"{bucket}/{storage_path}"
await self.repository.save()
return await self.get_profile()
async def _resolve_avatar_url(self, avatar_path: str | None) -> str | None:
if avatar_path is None:
return None
normalized = avatar_path.strip()
if not normalized:
return None
parts = normalized.split("/", 1)
if len(parts) != 2:
return normalized
bucket, path = parts
if bucket != config.storage.avatar.bucket:
return normalized
try:
return await self.attachment_storage.create_signed_url(
bucket=bucket,
path=path,
expires_in_seconds=config.storage.signed_url_ttl_seconds,
)
except Exception:
return normalized