Scaffold V2 backend foundation
This commit is contained in:
18
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
18
ai_knowledge_base_v2/apps/backend/.env.example
Normal 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=用户
|
||||
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
45
ai_knowledge_base_v2/apps/backend/README.md
Normal file
45
ai_knowledge_base_v2/apps/backend/README.md
Normal 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 黑名单。
|
||||
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal 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
|
||||
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
30
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal file
30
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal 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()
|
||||
94
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal file
94
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal 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"
|
||||
12
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal file
12
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal 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"})
|
||||
11
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal file
11
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal 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"])
|
||||
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal file
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal 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"))
|
||||
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
42
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal 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()
|
||||
26
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal file
26
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal 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)
|
||||
42
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal 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
|
||||
@@ -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
|
||||
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal file
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal 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}
|
||||
33
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal 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
|
||||
40
ai_knowledge_base_v2/apps/backend/app/main.py
Normal file
40
ai_knowledge_base_v2/apps/backend/app/main.py
Normal 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"}
|
||||
23
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal file
23
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal 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")
|
||||
43
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal file
43
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal 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)
|
||||
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal file
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal 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)
|
||||
40
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal file
40
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal 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")
|
||||
37
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal file
37
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal 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")
|
||||
43
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal file
43
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal 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)
|
||||
28
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal file
28
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal 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")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
22
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal file
22
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal 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
|
||||
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal 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
|
||||
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal file
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal 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
|
||||
@@ -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="手机号格式不正确")
|
||||
10
ai_knowledge_base_v2/apps/backend/requirements.txt
Normal file
10
ai_knowledge_base_v2/apps/backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user