feat: isolate external application conversations

This commit is contained in:
2026-08-03 18:57:19 +08:00
parent 26109648af
commit cd3c875106
26 changed files with 843 additions and 110 deletions

View File

@@ -73,7 +73,11 @@ class AuthService:
db.commit()
db.refresh(user)
token, expired_at = create_access_token(str(user.id), "user")
token, expired_at = create_access_token(
str(user.id),
"user",
extra_claims={"auth_source": "direct"},
)
return {"token": token, "expiredAt": expired_at, "user": user}
@classmethod

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.ai_request_log_service import AiRequestLogService
from app.services.entitlement_service import EntitlementService, entitlement_prompt_context
from app.services.external_errors import ExternalServiceError
@@ -23,10 +24,17 @@ from app.services.topic_session_service import TopicSessionService
class ChatService:
@staticmethod
def create_session(db: Session, user: User) -> ChatSession:
def create_session(
db: Session,
user: User,
scope: ChatAccessScope | None = None,
) -> ChatSession:
scope = scope or ChatAccessScope.direct()
now = _now()
session = ChatSession(
user_id=user.id,
source_type=scope.source_type,
source_client_id=scope.source_client_id,
title="新聊天",
message_count=0,
last_message_at=now,
@@ -38,18 +46,32 @@ class ChatService:
return session
@staticmethod
def list_sessions(db: Session, user: User) -> list[ChatSession]:
def list_sessions(
db: Session,
user: User,
scope: ChatAccessScope | None = None,
) -> list[ChatSession]:
scope = scope or ChatAccessScope.direct()
return list(
db.scalars(
select(ChatSession)
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)
.where(
ChatSession.user_id == user.id,
ChatSession.is_deleted == 0,
*chat_scope_filters(scope),
)
.order_by(ChatSession.updated_at.desc())
)
)
@staticmethod
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]:
session = ChatService._get_user_session(db, user, session_id)
def get_history(
db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> list[ChatMessage]:
session = ChatService._get_user_session(db, user, session_id, scope)
return list(
db.scalars(
select(ChatMessage)
@@ -59,8 +81,14 @@ class ChatService:
)
@staticmethod
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
session = ChatService._get_user_session(db, user, session_id)
def update_title(
db: Session,
user: User,
session_id: int,
title: str,
scope: ChatAccessScope | None = None,
) -> ChatSession:
session = ChatService._get_user_session(db, user, session_id, scope)
session.title = title.strip()
db.add(session)
db.commit()
@@ -68,16 +96,27 @@ class ChatService:
return session
@staticmethod
def delete_session(db: Session, user: User, session_id: int) -> None:
session = ChatService._get_user_session(db, user, session_id)
def delete_session(
db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> None:
session = ChatService._get_user_session(db, user, session_id, scope)
session.is_deleted = 1
db.add(session)
db.commit()
@staticmethod
def create_answer(db: Session, user: User, session_id: int, question: str) -> str:
def create_answer(
db: Session,
user: User,
session_id: int,
question: str,
scope: ChatAccessScope | None = None,
) -> str:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement(
db,
@@ -253,13 +292,31 @@ class ChatService:
return completion.answer
@staticmethod
def stop_generation(db: Session, user: User, session_id: int) -> None:
ChatService._get_user_session(db, user, session_id)
def stop_generation(
db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> None:
ChatService._get_user_session(db, user, session_id, scope)
@staticmethod
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession:
session = db.get(ChatSession, session_id)
if session is None or session.user_id != user.id or session.is_deleted:
def _get_user_session(
db: Session,
user: User,
session_id: int,
scope: ChatAccessScope | None = None,
) -> ChatSession:
scope = scope or ChatAccessScope.direct()
session = db.scalar(
select(ChatSession).where(
ChatSession.id == session_id,
ChatSession.user_id == user.id,
ChatSession.is_deleted == 0,
*chat_scope_filters(scope),
)
)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
return session
@@ -309,3 +366,15 @@ def _now() -> datetime:
def _title_from_question(question: str) -> str:
title = question.strip().replace("\n", " ")
return title[:20] if title else "新聊天"
def chat_scope_filters(scope: ChatAccessScope) -> tuple:
if scope.source_type == "direct":
return (
ChatSession.source_type == "direct",
ChatSession.source_client_id.is_(None),
)
return (
ChatSession.source_type == "sso",
ChatSession.source_client_id == scope.source_client_id,
)

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, TopicSession
from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService
@@ -29,9 +30,15 @@ from app.services.topic_session_service import TopicSessionService
class ChatStreamService:
@staticmethod
def stream_answer(db: Session, user: User, session_id: int, question: str) -> Iterator[str]:
def stream_answer(
db: Session,
user: User,
session_id: int,
question: str,
scope: ChatAccessScope | None = None,
) -> Iterator[str]:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement(
db,
@@ -219,9 +226,10 @@ class ChatStreamService:
question: str,
*,
retry_failed_question: bool = False,
scope: ChatAccessScope | None = None,
) -> AsyncIterator[str]:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
session = ChatService._get_user_session(db, user, session_id, scope)
ChatService._ensure_quota(user)
entitlement = EntitlementService.active_entitlement(
db,

View File

@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession
from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.content_generation_config_service import ContentGenerationConfigService
from app.services.chat_service import chat_scope_filters
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
@@ -46,19 +48,34 @@ class HelpCardService:
return card
@staticmethod
def list_user_cards(db: Session, *, user: User, limit: int = 20) -> list[TeacherHelpCard]:
def list_user_cards(
db: Session,
*,
user: User,
scope: ChatAccessScope | None = None,
limit: int = 20,
) -> list[TeacherHelpCard]:
scope = scope or ChatAccessScope.direct()
return list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user.id)
.join(TopicSession, TopicSession.id == TeacherHelpCard.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(TeacherHelpCard.user_id == user.id, *chat_scope_filters(scope))
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit)
)
)
@staticmethod
def mark_copied(db: Session, *, user: User, card_id: int) -> TeacherHelpCard:
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
def mark_copied(
db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None = None,
) -> TeacherHelpCard:
card = _user_card(db, user=user, card_id=card_id, scope=scope)
if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
card.copied = 1
@@ -69,8 +86,14 @@ class HelpCardService:
return card
@staticmethod
def delete(db: Session, *, user: User, card_id: int) -> None:
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
def delete(
db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None = None,
) -> None:
card = _user_card(db, user=user, card_id=card_id, scope=scope)
if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
db.delete(card)
@@ -122,3 +145,23 @@ def _format_time(value: datetime | None) -> str:
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _user_card(
db: Session,
*,
user: User,
card_id: int,
scope: ChatAccessScope | None,
) -> TeacherHelpCard | None:
scope = scope or ChatAccessScope.direct()
return db.scalar(
select(TeacherHelpCard)
.join(TopicSession, TopicSession.id == TeacherHelpCard.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(
TeacherHelpCard.id == card_id,
TeacherHelpCard.user_id == user.id,
*chat_scope_filters(scope),
)
)

View File

@@ -9,7 +9,9 @@ from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession
from app.models.growth import ShareDraft, TopicSummary
from app.models.user import User
from app.core.auth_context import ChatAccessScope
from app.services.content_generation_config_service import ContentGenerationConfigService
from app.services.chat_service import chat_scope_filters
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
@@ -46,19 +48,34 @@ class ShareDraftService:
return draft
@staticmethod
def list_user_drafts(db: Session, *, user: User, limit: int = 20) -> list[ShareDraft]:
def list_user_drafts(
db: Session,
*,
user: User,
scope: ChatAccessScope | None = None,
limit: int = 20,
) -> list[ShareDraft]:
scope = scope or ChatAccessScope.direct()
return list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.user_id == user.id)
.join(TopicSession, TopicSession.id == ShareDraft.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(ShareDraft.user_id == user.id, *chat_scope_filters(scope))
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
.limit(limit)
)
)
@staticmethod
def mark_copied(db: Session, *, user: User, draft_id: int) -> ShareDraft:
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id))
def mark_copied(
db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None = None,
) -> ShareDraft:
draft = _user_draft(db, user=user, draft_id=draft_id, scope=scope)
if draft is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
draft.copied = 1
@@ -69,8 +86,14 @@ class ShareDraftService:
return draft
@staticmethod
def delete(db: Session, *, user: User, draft_id: int) -> None:
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id))
def delete(
db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None = None,
) -> None:
draft = _user_draft(db, user=user, draft_id=draft_id, scope=scope)
if draft is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
db.delete(draft)
@@ -113,3 +136,23 @@ def _share_draft_values(*, topic: TopicSession, summary: TopicSummary) -> dict[s
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _user_draft(
db: Session,
*,
user: User,
draft_id: int,
scope: ChatAccessScope | None,
) -> ShareDraft | None:
scope = scope or ChatAccessScope.direct()
return db.scalar(
select(ShareDraft)
.join(TopicSession, TopicSession.id == ShareDraft.topic_session_id)
.join(ChatSession, ChatSession.id == TopicSession.chat_session_id)
.where(
ShareDraft.id == draft_id,
ShareDraft.user_id == user.id,
*chat_scope_filters(scope),
)
)

View File

@@ -10,14 +10,17 @@ from urllib.parse import quote
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.security import create_access_token
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User
from app.schemas.sso import SsoTicketRequest
from app.services.auth_service import AuthService
from app.services.entitlement_service import EntitlementService
from app.services.redis_client import get_sync_redis_client
from app.services.security_state_service import SecurityStateService
from app.services.secret_service import SecretService
@@ -166,7 +169,11 @@ class SsoService:
)
db.commit()
db.refresh(user)
token, expired_at = create_access_token(str(user.id), "user")
token, expired_at = create_access_token(
str(user.id),
"user",
extra_claims={"auth_source": "sso", "sso_client_id": client.id},
)
return {
"token": token,
"expiredAt": expired_at,
@@ -271,11 +278,47 @@ class SsoService:
if not payload.verifiedPhone:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="首次登录必须提供已验证手机号")
user = db.scalar(
select(User).where(User.phone == payload.verifiedPhone, User.is_deleted == 0)
)
phone = payload.verifiedPhone.strip()
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0).with_for_update())
created = False
if user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
if not client.allow_auto_register:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中")
display_name = (payload.displayName or "").strip()
if not display_name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="首次自动注册必须提供姓名")
plan = db.get(EntitlementPlan, client.default_entitlement_plan_id)
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="接入应用未配置可用的默认权益")
candidate = User(
phone=phone,
name=display_name[:50],
nickname=display_name[:50],
registration_source="sso",
registration_client_id=client.id,
status=1,
daily_chat_limit=_default_daily_limit(db),
daily_chat_used=0,
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
user = candidate
created = True
except IntegrityError:
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
if user is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="学员账号创建冲突,请重试")
if created:
EntitlementService.assign_user_plan(
db,
user=user,
plan_id=plan.id,
operated_by=None,
remark=f"接入应用 {client.name} 自动注册分配",
)
existing_user_binding = db.scalar(
select(UserExternalIdentity).where(
UserExternalIdentity.client_id == client.id,
@@ -385,3 +428,18 @@ def _int_value(value) -> int:
return int(value)
except (TypeError, ValueError):
return 0
def _default_daily_limit(db: Session) -> int:
value = db.scalar(select(SystemConfig.config_value).where(SystemConfig.config_key == "daily_chat_limit"))
if value and value.strip():
try:
return max(0, min(100000, int(float(value.strip()))))
except ValueError:
pass
try:
from app.core.config import get_settings
return get_settings().default_daily_chat_limit
except Exception:
return 100