Scaffold V2 backend foundation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import create_access_token
|
||||
from app.models.user import User
|
||||
from app.services.sms_code_service import SmsCodeService
|
||||
|
||||
|
||||
class AuthService:
|
||||
_revoked_token_jti: set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def send_sms_code(cls, phone: str) -> None:
|
||||
SmsCodeService.send_code(phone)
|
||||
|
||||
@classmethod
|
||||
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
|
||||
SmsCodeService.verify_code(phone, code)
|
||||
user = cls._get_or_create_user(db, phone)
|
||||
cls._ensure_user_can_login(user)
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
user.last_login_at = now
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token, expired_at = create_access_token(str(user.id), "user")
|
||||
return {"token": token, "expiredAt": expired_at, "user": user}
|
||||
|
||||
@classmethod
|
||||
def logout(cls, token_payload: dict) -> None:
|
||||
jti = token_payload.get("jti")
|
||||
if jti:
|
||||
cls._revoked_token_jti.add(str(jti))
|
||||
|
||||
@classmethod
|
||||
def is_token_revoked(cls, token_payload: dict) -> bool:
|
||||
jti = token_payload.get("jti")
|
||||
return bool(jti and str(jti) in cls._revoked_token_jti)
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_user(cls, db: Session, phone: str) -> User:
|
||||
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
settings = get_settings()
|
||||
user = User(
|
||||
phone=phone,
|
||||
name=f"{settings.default_user_name_prefix}{phone[-4:]}",
|
||||
daily_chat_limit=settings.default_daily_chat_limit,
|
||||
daily_chat_used=0,
|
||||
status=1,
|
||||
is_deleted=0,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_can_login(user: User) -> None:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
if user.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
if user.effective_at and user.effective_at > now:
|
||||
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="账号已过期")
|
||||
116
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
116
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class ChatService:
|
||||
@staticmethod
|
||||
def create_session(db: Session, user: User) -> ChatSession:
|
||||
now = _now()
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
title="新聊天",
|
||||
message_count=0,
|
||||
last_message_at=now,
|
||||
is_deleted=0,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(db: Session, user: User) -> list[ChatSession]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ChatSession)
|
||||
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)
|
||||
.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)
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == session.id, ChatMessage.user_id == user.id)
|
||||
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session.title = title.strip()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def delete_session(db: Session, user: User, session_id: int) -> None:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
session.is_deleted = 1
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def create_mock_answer(db: Session, user: User, session_id: int, question: str) -> str:
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
now = _now()
|
||||
answer = (
|
||||
"这是 mock AI 回复。当前阶段已经跑通后端聊天链路,后续会替换为:权限过滤、飞书实时检索、"
|
||||
"Prompt 组装和真实大模型 SSE 输出。"
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=question.strip(),
|
||||
message_status="FINISHED",
|
||||
created_at=now,
|
||||
)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
message_status="FINISHED",
|
||||
created_at=now,
|
||||
)
|
||||
session.message_count += 2
|
||||
session.last_message_at = now
|
||||
if session.title == "新聊天":
|
||||
session.title = _title_from_question(question)
|
||||
db.add_all([user_message, assistant_message, session])
|
||||
db.commit()
|
||||
return answer
|
||||
|
||||
@staticmethod
|
||||
def stop_generation(db: Session, user: User, session_id: int) -> None:
|
||||
ChatService._get_user_session(db, user, session_id)
|
||||
|
||||
@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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
|
||||
return session
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _title_from_question(question: str) -> str:
|
||||
title = question.strip().replace("\n", " ")
|
||||
return title[:20] if title else "新聊天"
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from re import fullmatch
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmsCodeRecord:
|
||||
code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class SmsCodeService:
|
||||
_codes: dict[str, SmsCodeRecord] = {}
|
||||
|
||||
@classmethod
|
||||
def send_code(cls, phone: str) -> None:
|
||||
cls._validate_phone(phone)
|
||||
settings = get_settings()
|
||||
if not settings.mock_sms_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="真实短信服务尚未接入")
|
||||
cls._codes[phone] = SmsCodeRecord(
|
||||
code=settings.mock_sms_code,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=settings.sms_code_expire_minutes),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def verify_code(cls, phone: str, code: str) -> None:
|
||||
cls._validate_phone(phone)
|
||||
record = cls._codes.get(phone)
|
||||
settings = get_settings()
|
||||
if settings.mock_sms_enabled and record is None and code == settings.mock_sms_code:
|
||||
return
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码不存在或已过期")
|
||||
if record.expires_at < datetime.now(UTC):
|
||||
cls._codes.pop(phone, None)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期")
|
||||
if record.code != code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||
cls._codes.pop(phone, None)
|
||||
|
||||
@staticmethod
|
||||
def _validate_phone(phone: str) -> None:
|
||||
if fullmatch(r"1[3-9]\d{9}", phone) is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号格式不正确")
|
||||
Reference in New Issue
Block a user