feat: isolate external application conversations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal file
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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