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

@@ -18,6 +18,7 @@ from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog, OperationLog
from app.models.sso import SsoClient
from app.models.user import User
from app.api.pagination import page_result
from app.services.admin_service import OperationLogService
@@ -34,6 +35,8 @@ def chat_list(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
@@ -41,10 +44,19 @@ def chat_list(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
query = _chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
items = [_chat_row_dict(session, user, source_client) for session, user, source_client in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/chat/export")
@@ -52,26 +64,37 @@ def export_chats(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
rows = db.execute(
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
_chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
.order_by(ChatSession.updated_at.desc())
.limit(1000)
).all()
output = StringIO()
writer = csv.writer(output)
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user in rows:
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "来源", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user, source_client in rows:
writer.writerow(
[
session.id,
session.user_id,
user.phone if user else "",
user.name if user else "",
source_client.name if source_client else "千问千答直接访问",
session.title,
session.message_count,
session.last_message_at,
@@ -117,8 +140,9 @@ def chat_detail(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
session_row = db.execute(
select(ChatSession, User)
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
@@ -126,7 +150,7 @@ def chat_detail(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user = session_row
session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
messages = db.scalars(
@@ -161,7 +185,7 @@ def chat_detail(
)
return api_success(
{
"session": _chat_row_dict(session, user),
"session": _chat_row_dict(session, user, source_client),
"messages": [_message_dict(item) for item in messages],
"messagesPage": page_result(
[_message_dict(item) for item in messages],
@@ -305,16 +329,23 @@ def _chat_query(
keyword: str,
user_id: int | None,
status: str,
source_type: str,
source_client_id: int | None,
date_from: datetime | None,
date_to: datetime | None,
):
query = (
select(ChatSession, User)
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.is_deleted == 0)
)
if user_id is not None:
query = query.where(ChatSession.user_id == user_id)
if source_type:
query = query.where(ChatSession.source_type == source_type)
if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
if date_to is not None:
@@ -340,12 +371,15 @@ def _chat_query(
return query
def _chat_row_dict(session: ChatSession, user: User | None) -> dict:
def _chat_row_dict(session: ChatSession, user: User | None, source_client: SsoClient | None = None) -> dict:
return {
"id": session.id,
"userId": session.user_id,
"userPhone": user.phone if user else "",
"userName": user.name if user else "",
"sourceType": session.source_type,
"sourceClientId": session.source_client_id,
"sourceName": source_client.name if source_client else "千问千答直接访问",
"title": session.title,
"messageCount": session.message_count,
"lastMessageAt": session.last_message_at,

View File

@@ -13,6 +13,7 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
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 SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
@@ -80,11 +81,12 @@ def list_sso_clients(
.subquery()
)
rows = db.execute(
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0))
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0), EntitlementPlan.name)
.outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id)
.outerjoin(EntitlementPlan, EntitlementPlan.id == SsoClient.default_entitlement_plan_id)
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
).all()
return api_success([_client_item(client, int(identity_count)) for client, identity_count in rows])
return api_success([_client_item(client, int(identity_count), plan_name) for client, identity_count, plan_name in rows])
@router.post("/sso/client")
@@ -96,12 +98,15 @@ def create_sso_client(
app_id = payload.appId.strip()
if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在")
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
plaintext_secret = secrets.token_urlsafe(32)
client = SsoClient(
app_id=app_id,
name=payload.name.strip(),
client_secret=SecretService.encrypt(plaintext_secret),
redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False),
allow_auto_register=1 if payload.allowAutoRegister else 0,
default_entitlement_plan_id=payload.defaultEntitlementPlanId,
status=payload.status,
created_by=current_admin.id,
)
@@ -127,8 +132,11 @@ def update_sso_client(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
client = _require_client(db, client_id)
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
client.name = payload.name.strip()
client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False)
client.allow_auto_register = 1 if payload.allowAutoRegister else 0
client.default_entitlement_plan_id = payload.defaultEntitlementPlanId
client.status = payload.status
OperationLogService.write(
db,
@@ -141,7 +149,8 @@ def update_sso_client(
identity_count = db.scalar(
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
) or 0
return api_success(_client_item(client, identity_count))
plan_name = db.scalar(select(EntitlementPlan.name).where(EntitlementPlan.id == client.default_entitlement_plan_id))
return api_success(_client_item(client, identity_count, plan_name))
@router.post("/sso/client/{client_id}/secret/rotate")
@@ -260,12 +269,15 @@ def _require_client(db: Session, client_id: int) -> SsoClient:
return client
def _client_item(client: SsoClient, identity_count: int) -> dict:
def _client_item(client: SsoClient, identity_count: int, plan_name: str | None = None) -> dict:
return {
"id": client.id,
"appId": client.app_id,
"name": client.name,
"redirectUris": _json_list(client.redirect_uris),
"allowAutoRegister": bool(client.allow_auto_register),
"defaultEntitlementPlanId": client.default_entitlement_plan_id,
"defaultEntitlementPlanName": plan_name,
"status": client.status,
"identityCount": identity_count,
"lastUsedAt": client.last_used_at,
@@ -274,6 +286,14 @@ def _client_item(client: SsoClient, identity_count: int) -> dict:
}
def _validate_default_plan(db: Session, allow_auto_register: bool, plan_id: int | None) -> None:
if not allow_auto_register:
return
plan = db.get(EntitlementPlan, plan_id) if plan_id else None
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认权益版本不存在或已停用")
def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict:
return {
"id": identity.id,

View File

@@ -490,6 +490,8 @@ def _user_dict(user: User, entitlement: dict | None = None) -> dict:
"phone": user.phone,
"name": user.name,
"nickname": user.nickname,
"registrationSource": user.registration_source,
"registrationClientId": user.registration_client_id,
"status": user.status,
"dailyChatLimit": user.daily_chat_limit,
"dailyChatUsed": user.daily_chat_used,

View File

@@ -7,12 +7,15 @@ from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.auth_context import ChatAccessScope, UserAuthContext
from app.core.dependencies import get_current_user_context
from app.core.responses import api_success
from app.models.user import User
from app.models.chat import TopicSession
from app.models.growth import TopicSummary
from app.schemas.chat import (
ChatCompletionRequest,
ChatMessageRead,
@@ -40,14 +43,14 @@ logger = logging.getLogger(__name__)
@router.post("/session")
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
session = ChatService.create_session(db, current_user)
def create_session(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
session = ChatService.create_session(db, current.user, current.chat_scope)
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
@router.get("/session/list")
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
sessions = ChatService.list_sessions(db, current_user)
def list_sessions(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
sessions = ChatService.list_sessions(db, current.user, current.chat_scope)
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
@@ -55,9 +58,9 @@ def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(ge
def history(
sessionId: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
messages = ChatService.get_history(db, current_user, sessionId)
messages = ChatService.get_history(db, current.user, sessionId, current.chat_scope)
reasoning_visible = ReasoningPolicyService.is_visible(db)
result = []
for message in messages:
@@ -72,9 +75,9 @@ def history(
def update_title(
payload: UpdateSessionTitleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
session = ChatService.update_title(db, current.user, payload.sessionId, payload.title, current.chat_scope)
return api_success(ChatSessionRead.model_validate(session).model_dump())
@@ -82,9 +85,9 @@ def update_title(
def delete_session(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ChatService.delete_session(db, current_user, session_id)
ChatService.delete_session(db, current.user, session_id, current.chat_scope)
return api_success()
@@ -92,29 +95,37 @@ def delete_session(
def finish_topic(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
return api_success(GrowthProfileService.finish_active_topic(db, user=current.user, session=session))
@router.get("/topic/settlement/{summary_id}")
def topic_settlement(
summary_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success(GrowthProfileService.topic_settlement_result(db, user=current_user, summary_id=summary_id))
topic_session_id = db.scalar(
select(TopicSession.chat_session_id)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id)
.where(TopicSummary.id == summary_id, TopicSummary.user_id == current.user.id)
)
if topic_session_id is None:
raise HTTPException(status_code=404, detail="主题沉淀任务不存在")
ChatService._get_user_session(db, current.user, topic_session_id, current.chat_scope)
return api_success(GrowthProfileService.topic_settlement_result(db, user=current.user, summary_id=summary_id))
@router.post("/session/{session_id}/help-card")
def generate_help_card(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
card = HelpCardService.generate_for_session(db, user=current_user, session=session)
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
card = HelpCardService.generate_for_session(db, user=current.user, session=session)
return api_success(help_card_dict(card))
@@ -122,27 +133,33 @@ def generate_help_card(
def list_help_cards(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success([help_card_dict(card) for card in HelpCardService.list_user_cards(db, user=current_user, limit=limit)])
cards = HelpCardService.list_user_cards(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([help_card_dict(card) for card in cards])
@router.post("/help-card/{card_id}/copied")
def mark_help_card_copied(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id)))
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current.user, scope=current.chat_scope, card_id=card_id)))
@router.delete("/help-card/{card_id}")
def delete_help_card(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
HelpCardService.delete(db, user=current_user, card_id=card_id)
HelpCardService.delete(db, user=current.user, scope=current.chat_scope, card_id=card_id)
return api_success()
@@ -150,10 +167,10 @@ def delete_help_card(
def generate_share_draft(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
draft = ShareDraftService.generate_for_session(db, user=current_user, session=session)
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
draft = ShareDraftService.generate_for_session(db, user=current.user, session=session)
return api_success(share_draft_dict(draft))
@@ -161,27 +178,33 @@ def generate_share_draft(
def list_share_drafts(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success([share_draft_dict(draft) for draft in ShareDraftService.list_user_drafts(db, user=current_user, limit=limit)])
drafts = ShareDraftService.list_user_drafts(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([share_draft_dict(draft) for draft in drafts])
@router.post("/share-draft/{draft_id}/copied")
def mark_share_draft_copied(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current_user, draft_id=draft_id)))
return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)))
@router.delete("/share-draft/{draft_id}")
def delete_share_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ShareDraftService.delete(db, user=current_user, draft_id=draft_id)
ShareDraftService.delete(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)
return api_success()
@@ -189,10 +212,10 @@ def delete_share_draft(
def completions(
payload: ChatCompletionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> StreamingResponse:
return StreamingResponse(
_chat_stream(payload, db, current_user),
_chat_stream(payload, db, current),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
@@ -206,13 +229,17 @@ def completions(
def stop(
payload: StopChatRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ChatService.stop_generation(db, current_user, payload.sessionId)
ChatService.stop_generation(db, current.user, payload.sessionId, current.chat_scope)
return api_success()
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current: UserAuthContext) -> AsyncIterator[str]:
# Keep the internal stream helper compatible with direct service/test callers;
# HTTP requests always pass a fully validated UserAuthContext.
current_user = getattr(current, "user", current)
chat_scope = getattr(current, "chat_scope", ChatAccessScope.direct())
config = load_chat_queue_config(db)
queue_lease = await request_chat_slot(config)
queue_request = queue_lease.request
@@ -274,6 +301,7 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
payload.sessionId,
payload.message,
retry_failed_question=payload.retry,
scope=chat_scope,
)
async for segment in ReasoningPolicyService.iter_segments(chunks):
if segment.kind == "content":

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from app.models.user import User
@dataclass(frozen=True)
class ChatAccessScope:
source_type: str = "direct"
source_client_id: int | None = None
@classmethod
def direct(cls) -> "ChatAccessScope":
return cls()
@dataclass(frozen=True)
class UserAuthContext:
user: User
chat_scope: ChatAccessScope

View File

@@ -7,8 +7,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.auth_context import ChatAccessScope, UserAuthContext
from app.core.security import decode_access_token
from app.models.admin import Admin
from app.models.sso import SsoClient
from app.models.user import User
from app.services.auth_service import AuthService
@@ -22,10 +24,10 @@ def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depend
return payload
def get_current_user(
def get_current_user_context(
payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db),
) -> User:
) -> UserAuthContext:
if payload.get("type") != "user":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
@@ -40,7 +42,25 @@ def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
if user.expired_at and user.expired_at < now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
return user
auth_source = str(payload.get("auth_source") or "direct")
if auth_source == "direct":
scope = ChatAccessScope.direct()
elif auth_source == "sso":
try:
client_id = int(payload.get("sso_client_id"))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用登录态无效") from exc
client = db.get(SsoClient, client_id)
if client is None or client.status != 1:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="接入应用已停用")
scope = ChatAccessScope(source_type="sso", source_client_id=client_id)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录来源无效")
return UserAuthContext(user=user, chat_scope=scope)
def get_current_user(context: UserAuthContext = Depends(get_current_user_context)) -> User:
return context.user
def get_current_admin(

View File

@@ -12,7 +12,12 @@ from fastapi import HTTPException, status
from app.core.config import get_settings
def create_access_token(subject: str, token_type: str = "user") -> tuple[str, datetime]:
def create_access_token(
subject: str,
token_type: str = "user",
*,
extra_claims: dict | None = None,
) -> tuple[str, datetime]:
settings = get_settings()
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
@@ -22,6 +27,11 @@ def create_access_token(subject: str, token_type: str = "user") -> tuple[str, da
"exp": expires_at,
"iat": datetime.now(UTC),
}
if extra_claims:
protected_claims = {"sub", "type", "jti", "exp", "iat"}
if protected_claims.intersection(extra_claims):
raise ValueError("extra_claims cannot override protected token claims")
payload.update(extra_claims)
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return token, expires_at

View File

@@ -2,17 +2,32 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class ChatSession(Base, TimestampMixin):
__tablename__ = "sys_chat_session"
__table_args__ = (
Index(
"ix_chat_session_user_source_active_updated",
"user_id",
"source_type",
"source_client_id",
"is_deleted",
"updated_at",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
source_type: Mapped[str] = mapped_column(String(20), default="direct", nullable=False)
source_client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)

View File

@@ -20,6 +20,8 @@ class SsoClient(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
client_secret: Mapped[str] = mapped_column(Text, nullable=False)
redirect_uris: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
allow_auto_register: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
default_entitlement_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
status: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -8,13 +8,18 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "sys_user"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True)
registration_source: Mapped[str] = mapped_column(String(30), default="direct", nullable=False)
registration_client_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
status: Mapped[int] = mapped_column(default=1, nullable=False)
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
class SsoTicketRequest(BaseModel):
@@ -20,6 +20,8 @@ class SsoClientSaveRequest(BaseModel):
appId: str = Field(min_length=3, max_length=80, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]+$")
name: str = Field(min_length=1, max_length=100)
redirectUris: list[str] = Field(default_factory=list, max_length=20)
allowAutoRegister: bool = False
defaultEntitlementPlanId: int | None = Field(default=None, gt=0)
status: int = Field(default=1, ge=0, le=1)
@field_validator("redirectUris")
@@ -36,10 +38,18 @@ class SsoClientSaveRequest(BaseModel):
cleaned.append(value)
return cleaned
@model_validator(mode="after")
def validate_auto_register_plan(self):
if self.allowAutoRegister and self.defaultEntitlementPlanId is None:
raise ValueError("允许自动注册时必须选择默认权益版本")
return self
class SsoClientUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
redirectUris: list[str] = Field(default_factory=list, max_length=20)
allowAutoRegister: bool = False
defaultEntitlementPlanId: int | None = Field(default=None, gt=0)
status: int = Field(default=1, ge=0, le=1)
@field_validator("redirectUris")
@@ -47,6 +57,10 @@ class SsoClientUpdateRequest(BaseModel):
def validate_redirect_uris(cls, values: list[str]) -> list[str]:
return SsoClientSaveRequest.validate_redirect_uris(values)
@model_validator(mode="after")
def validate_auto_register_plan(self):
return SsoClientSaveRequest.validate_auto_register_plan(self)
class SsoPublicConfigRequest(BaseModel):
userClientUrl: str = Field(default="", max_length=1000)
@@ -65,6 +79,9 @@ class SsoClientItem(BaseModel):
appId: str
name: str
redirectUris: list[str]
allowAutoRegister: bool
defaultEntitlementPlanId: int | None = None
defaultEntitlementPlanName: str | None = None
status: int
identityCount: int
lastUsedAt: datetime | None = None

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