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,18 @@
APP_NAME=AI Knowledge Base V2 Backend
APP_ENV=local
DEBUG=true
API_PREFIX=/api
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
AUTO_CREATE_TABLES=false
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=43200
MOCK_SMS_ENABLED=true
MOCK_SMS_CODE=123456
SMS_CODE_EXPIRE_MINUTES=5
DEFAULT_DAILY_CHAT_LIMIT=100
DEFAULT_USER_NAME_PREFIX=用户

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@@ -0,0 +1,45 @@
# AI 知识库系统 V2 后端
这是 V2 的后端服务目录当前处于阶段二第一批建设后端基础工程、核心数据模型、mock 登录能力。
## 技术栈
- FastAPI
- SQLAlchemy
- Alembic
- MySQL 8.x
- Pydantic Settings
- PyJWT
## 当前已包含
- 应用入口和健康检查
- 统一 JSON 响应结构
- 配置读取和 `.env` 示例
- SQLAlchemy 数据库连接
- Alembic 迁移骨架
- 核心 `sys_*` 数据模型
- mock 短信验证码
- 用户手机号验证码登录
- 用户 Token 鉴权
- 当前用户信息接口
## 本地启动
```bash
cd ai_knowledge_base_v2/apps/backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --host 127.0.0.1 --port 8100
```
默认短信验证码为 `.env` 中的 `MOCK_SMS_CODE`,默认值是 `123456`
## 下一步
- 补 Alembic 初始迁移脚本。
- 补聊天会话和消息接口。
- 补管理员登录接口。
- 接入 Redis 替换内存短信验证码和 Token 黑名单。

View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_user",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("nickname", sa.String(length=50), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("daily_chat_limit", sa.Integer(), nullable=False),
sa.Column("daily_chat_used", sa.Integer(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_phone", "sys_user", ["phone"], unique=True)
op.create_table(
"sys_role",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_role_code", "sys_role", ["code"], unique=True)
op.create_table(
"sys_admin",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("password", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("role_id", sa.BigInteger(), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["role_id"], ["sys_role.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_admin_username", "sys_admin", ["username"], unique=True)
op.create_table(
"sys_knowledge",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("feishu_space_id", sa.String(length=100), nullable=False),
sa.Column("feishu_node_id", sa.String(length=100), nullable=False),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_user_kb",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_kb_user_id", "sys_user_kb", ["user_id"])
op.create_index("ix_sys_user_kb_knowledge_id", "sys_user_kb", ["knowledge_id"])
op.create_table(
"sys_model",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_url", sa.String(length=255), nullable=False),
sa.Column("api_key", sa.Text(), nullable=False),
sa.Column("temperature", sa.Numeric(4, 2), nullable=True),
sa.Column("max_token", sa.Integer(), nullable=True),
sa.Column("timeout_second", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_prompt",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("prompt_content", sa.Text(), nullable=False),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_system_config",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("config_key", sa.String(length=100), nullable=False),
sa.Column("config_value", sa.Text(), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_system_config_config_key", "sys_system_config", ["config_key"], unique=True)
op.create_table(
"sys_chat_session",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(length=100), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=False),
sa.Column("last_message_at", sa.DateTime(), nullable=True),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_session_user_id", "sys_chat_session", ["user_id"])
op.create_table(
"sys_chat_message",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("message_status", sa.String(length=20), nullable=False),
sa.Column("token_input", sa.Integer(), nullable=True),
sa.Column("token_output", sa.Integer(), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("model_id", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["model_id"], ["sys_model.id"]),
sa.ForeignKeyConstraint(["session_id"], ["sys_chat_session.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_message_session_id", "sys_chat_message", ["session_id"])
op.create_index("ix_sys_chat_message_user_id", "sys_chat_message", ["user_id"])
op.create_table(
"sys_ai_request_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=True),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),
sa.Column("prompt", sa.Text(), nullable=True),
sa.Column("knowledge_ids", sa.String(length=500), nullable=True),
sa.Column("retrieve_count", sa.Integer(), nullable=False),
sa.Column("input_token", sa.Integer(), nullable=True),
sa.Column("output_token", sa.Integer(), nullable=True),
sa.Column("total_token", sa.Integer(), nullable=True),
sa.Column("cost_ms", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_ai_request_log_session_id", "sys_ai_request_log", ["session_id"])
op.create_index("ix_sys_ai_request_log_message_id", "sys_ai_request_log", ["message_id"])
op.create_index("ix_sys_ai_request_log_user_id", "sys_ai_request_log", ["user_id"])
op.create_table(
"sys_operation_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("admin_id", sa.BigInteger(), nullable=True),
sa.Column("module", sa.String(length=50), nullable=False),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("target_id", sa.BigInteger(), nullable=True),
sa.Column("before_json", sa.Text(), nullable=True),
sa.Column("after_json", sa.Text(), nullable=True),
sa.Column("ip", sa.String(length=50), nullable=True),
sa.Column("result", sa.String(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_operation_log_admin_id", "sys_operation_log", ["admin_id"])
def downgrade() -> None:
op.drop_index("ix_sys_operation_log_admin_id", table_name="sys_operation_log")
op.drop_table("sys_operation_log")
op.drop_index("ix_sys_ai_request_log_user_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_message_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_session_id", table_name="sys_ai_request_log")
op.drop_table("sys_ai_request_log")
op.drop_index("ix_sys_chat_message_user_id", table_name="sys_chat_message")
op.drop_index("ix_sys_chat_message_session_id", table_name="sys_chat_message")
op.drop_table("sys_chat_message")
op.drop_index("ix_sys_chat_session_user_id", table_name="sys_chat_session")
op.drop_table("sys_chat_session")
op.drop_index("ix_sys_system_config_config_key", table_name="sys_system_config")
op.drop_table("sys_system_config")
op.drop_table("sys_prompt")
op.drop_table("sys_model")
op.drop_index("ix_sys_user_kb_knowledge_id", table_name="sys_user_kb")
op.drop_index("ix_sys_user_kb_user_id", table_name="sys_user_kb")
op.drop_table("sys_user_kb")
op.drop_table("sys_knowledge")
op.drop_index("ix_sys_admin_username", table_name="sys_admin")
op.drop_table("sys_admin")
op.drop_index("ix_sys_role_code", table_name="sys_role")
op.drop_table("sys_role")
op.drop_index("ix_sys_user_phone", table_name="sys_user")
op.drop_table("sys_user")

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_token_payload
from app.core.responses import api_success
from app.schemas.auth import LoginRequest, LoginResponse, SendSmsRequest
from app.services.auth_service import AuthService
router = APIRouter()
@router.post("/sms/send")
def send_sms(payload: SendSmsRequest) -> dict:
AuthService.send_sms_code(payload.phone)
return api_success(message="发送成功")
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
result = AuthService.login_with_sms(db, payload.phone, payload.code)
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
@router.post("/logout")
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
AuthService.logout(token_payload)
return api_success()

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.chat import (
ChatCompletionRequest,
ChatMessageRead,
ChatSessionRead,
CreateSessionResponse,
StopChatRequest,
UpdateSessionTitleRequest,
)
from app.services.chat_service import ChatService
router = APIRouter()
@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)
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)
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
@router.get("/history")
def history(
sessionId: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
messages = ChatService.get_history(db, current_user, sessionId)
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
@router.put("/session/title")
def update_title(
payload: UpdateSessionTitleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
return api_success(ChatSessionRead.model_validate(session).model_dump())
@router.delete("/session/{session_id}")
def delete_session(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
ChatService.delete_session(db, current_user, session_id)
return api_success()
@router.post("/completions")
def completions(
payload: ChatCompletionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
answer = ChatService.create_mock_answer(db, current_user, payload.sessionId, payload.message)
return StreamingResponse(_sse_chunks(answer), media_type="text/event-stream")
@router.post("/stop")
def stop(
payload: StopChatRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
ChatService.stop_generation(db, current_user, payload.sessionId)
return api_success()
def _sse_chunks(answer: str) -> Iterator[str]:
chunk_size = 12
for index in range(0, len(answer), chunk_size):
chunk = answer[index : index + chunk_size]
yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.responses import api_success
router = APIRouter()
@router.get("/health")
def health() -> dict:
return api_success({"status": "ok"})

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api import auth, chat, health, user
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.user import UserProfile
router = APIRouter()
@router.get("/profile")
def profile(current_user: User = Depends(get_current_user)) -> dict:
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "AI Knowledge Base V2 Backend"
app_env: str = "local"
debug: bool = True
api_prefix: str = "/api"
cors_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
)
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
auto_create_tables: bool = False
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 30
mock_sms_enabled: bool = True
mock_sms_code: str = "123456"
sms_code_expire_minutes: int = 5
default_daily_chat_limit: int = 100
default_user_name_prefix: str = "用户"
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.models import Base
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_tables() -> None:
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
from app.services.auth_service import AuthService
bearer_scheme = HTTPBearer(auto_error=True)
def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
payload = decode_access_token(credentials.credentials)
if AuthService.is_token_revoked(payload):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已退出")
return payload
def get_current_user(
payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db),
) -> User:
if payload.get("type") != "user":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
user_id = payload.get("sub")
user = db.get(User, int(user_id)) if user_id else None
if user is None or user.is_deleted:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
if user.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
now = datetime.now(UTC).replace(tzinfo=None)
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="账号已过期")
return user

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.responses import api_error
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=api_error(_code_from_status(exc.status_code), str(exc.detail)))
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
def _code_from_status(status_code: int) -> int:
if status_code in {401, 403}:
return 20000 if status_code == 401 else 40000
if status_code == 400:
return 10000
return 50000

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
return {"code": 0, "message": message, "data": data}
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
return {"code": code, "message": message, "data": data}

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import jwt
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]:
settings = get_settings()
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": subject,
"type": token_type,
"jti": uuid4().hex,
"exp": expires_at,
"iat": datetime.now(UTC),
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return token, expires_at
def decode_access_token(token: str) -> dict:
settings = get_settings()
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if settings.auto_create_tables:
create_tables()
yield
settings = get_settings()
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
register_exception_handlers(app)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
@app.get("/")
def root() -> dict[str, str]:
return {"name": settings.app_name, "status": "ok"}

View File

@@ -0,0 +1,23 @@
from app.models.admin import Admin, Role
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import Knowledge, UserKnowledgePermission
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
__all__ = [
"Admin",
"AiRequestLog",
"Base",
"ChatMessage",
"ChatSession",
"Knowledge",
"ModelConfig",
"OperationLog",
"Prompt",
"Role",
"SystemConfig",
"User",
"UserKnowledgePermission",
]

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Role(Base, TimestampMixin):
__tablename__ = "sys_role"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
class Admin(Base, TimestampMixin):
__tablename__ = "sys_admin"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
password: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
status: Mapped[int] = mapped_column(default=1, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
role: Mapped[Role | None] = relationship("Role", back_populates="admins")

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Prompt(Base):
__tablename__ = "sys_prompt"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class ModelConfig(Base):
__tablename__ = "sys_model"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
api_url: Mapped[str] = mapped_column(String(255), nullable=False)
api_key: Mapped[str] = mapped_column(Text, nullable=False)
temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
max_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
enabled: Mapped[int] = mapped_column(default=0, nullable=False)
class SystemConfig(Base):
__tablename__ = "sys_system_config"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
config_value: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class SoftDeleteMixin:
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ChatSession(Base, TimestampMixin):
__tablename__ = "sys_chat_session"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(100), nullable=False)
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="chat_sessions")
messages: Mapped[list["ChatMessage"]] = relationship("ChatMessage", back_populates="session")
class ChatMessage(Base):
__tablename__ = "sys_chat_message"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
message_status: Mapped[str] = mapped_column(String(20), default="FINISHED", nullable=False)
token_input: Mapped[int | None] = mapped_column(Integer, nullable=True)
token_output: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
model_id: Mapped[int | None] = mapped_column(ForeignKey("sys_model.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Knowledge(Base, TimestampMixin):
__tablename__ = "sys_knowledge"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_space_id: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[int] = mapped_column(default=1, nullable=False)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
user_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
"UserKnowledgePermission", back_populates="knowledge"
)
class UserKnowledgePermission(Base, TimestampMixin):
__tablename__ = "sys_user_kb"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
user: Mapped["User"] = relationship("User", back_populates="knowledge_permissions")
knowledge: Mapped[Knowledge] = relationship("Knowledge", back_populates="user_permissions")

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class AiRequestLog(Base):
__tablename__ = "sys_ai_request_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
input_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class OperationLog(Base):
__tablename__ = "sys_operation_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
module: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
before_json: Mapped[str | None] = mapped_column(Text, nullable=True)
after_json: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "sys_user"
id: Mapped[int] = mapped_column(BigInteger, 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)
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)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
knowledge_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
"UserKnowledgePermission", back_populates="user"
)
chat_sessions: Mapped[list["ChatSession"]] = relationship("ChatSession", back_populates="user")

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserProfile
class SendSmsRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
class LoginRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
code: str = Field(min_length=4, max_length=8)
class LoginResponse(BaseModel):
token: str
expiredAt: datetime
user: UserProfile

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import ORMModel
class CreateSessionResponse(BaseModel):
sessionId: int
class ChatSessionRead(ORMModel):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: int
title: str
messageCount: int = Field(alias="message_count")
updatedAt: datetime = Field(alias="updated_at")
class ChatMessageRead(ORMModel):
id: int
role: str
content: str
message_status: str
created_at: datetime
class UpdateSessionTitleRequest(BaseModel):
sessionId: int
title: str = Field(min_length=1, max_length=100)
class ChatCompletionRequest(BaseModel):
sessionId: int
message: str = Field(min_length=1, max_length=4000)
class StopChatRequest(BaseModel):
sessionId: int

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from pydantic import Field
from app.schemas.common import ORMModel
class UserProfile(ORMModel):
id: int
phone: str
name: str
nickname: str | None = None
dailyLimit: int = Field(alias="daily_chat_limit")
todayUsed: int = Field(alias="daily_chat_used")
status: int
effective_at: datetime | None = None
expired_at: datetime | None = None
last_login_at: datetime | None = None

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

View File

@@ -0,0 +1,10 @@
fastapi>=0.116.0
uvicorn[standard]>=0.35.0
SQLAlchemy>=2.0.40
alembic>=1.16.0
pydantic>=2.11.0
pydantic-settings>=2.10.0
python-dotenv>=1.1.0
PyJWT>=2.10.0
PyMySQL>=1.1.1
cryptography>=45.0.0