Scaffold V2 backend foundation

This commit is contained in:
2026-07-06 17:25:06 +08:00
parent 44af745828
commit 17cba7cf61
42 changed files with 1459 additions and 0 deletions

View File

@@ -0,0 +1 @@

View File

@@ -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="账号已过期")

View 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 "新聊天"

View File

@@ -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="手机号格式不正确")