feat: isolate external application conversations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user