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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 阶段二记录:后端基础工程第一批
|
||||||
|
|
||||||
|
日期:2026-07-06
|
||||||
|
|
||||||
|
## 本次目标
|
||||||
|
|
||||||
|
按照阶段二规划,先建立 V2 后端基础工程,并跑通用户端第一批 P0 接口的后端 mock 链路。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
### 后端工程骨架
|
||||||
|
|
||||||
|
- 新建 `ai_knowledge_base_v2/apps/backend/`。
|
||||||
|
- 新增 FastAPI 应用入口。
|
||||||
|
- 新增健康检查接口:`GET /api/health`。
|
||||||
|
- 新增统一 JSON 响应结构:`code`、`message`、`data`。
|
||||||
|
- 新增统一异常处理:HTTP 异常和参数校验异常都会返回统一结构。
|
||||||
|
- 新增 `.env.example`、`requirements.txt`、后端 `.gitignore`。
|
||||||
|
|
||||||
|
### 数据库和迁移
|
||||||
|
|
||||||
|
- 新增 SQLAlchemy 数据库连接。
|
||||||
|
- 新增 Alembic 配置。
|
||||||
|
- 新增第一版初始化迁移:`0001_initial_schema.py`。
|
||||||
|
- 按字段字典建立核心表:
|
||||||
|
- `sys_user`
|
||||||
|
- `sys_admin`
|
||||||
|
- `sys_role`
|
||||||
|
- `sys_knowledge`
|
||||||
|
- `sys_user_kb`
|
||||||
|
- `sys_chat_session`
|
||||||
|
- `sys_chat_message`
|
||||||
|
- `sys_prompt`
|
||||||
|
- `sys_model`
|
||||||
|
- `sys_system_config`
|
||||||
|
- `sys_ai_request_log`
|
||||||
|
- `sys_operation_log`
|
||||||
|
|
||||||
|
### 用户登录
|
||||||
|
|
||||||
|
- 新增 mock 短信验证码服务。
|
||||||
|
- 新增用户手机号验证码登录。
|
||||||
|
- 首次登录自动创建用户。
|
||||||
|
- 新增 JWT Token 签发。
|
||||||
|
- 新增退出登录 Token 黑名单。
|
||||||
|
- 新增当前用户接口:`GET /api/user/profile`。
|
||||||
|
|
||||||
|
### 聊天 mock 主链路
|
||||||
|
|
||||||
|
- 新增创建会话:`POST /api/chat/session`。
|
||||||
|
- 新增会话列表:`GET /api/chat/session/list`。
|
||||||
|
- 新增聊天历史:`GET /api/chat/history`。
|
||||||
|
- 新增修改标题:`PUT /api/chat/session/title`。
|
||||||
|
- 新增删除会话:`DELETE /api/chat/session/{id}`。
|
||||||
|
- 新增 mock SSE 问答:`POST /api/chat/completions`。
|
||||||
|
- 新增停止生成占位接口:`POST /api/chat/stop`。
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 短信仍是 mock,默认验证码为 `123456`。
|
||||||
|
- 飞书实时检索尚未接入。
|
||||||
|
- 大模型尚未接入,`/chat/completions` 当前返回 mock SSE。
|
||||||
|
- 管理员登录接口尚未实现。
|
||||||
|
- Token 黑名单当前是内存实现,后续需要替换为 Redis。
|
||||||
|
|
||||||
|
## 已验证
|
||||||
|
|
||||||
|
- Python 编译检查通过:`python3 -m compileall -q ai_knowledge_base_v2/apps/backend/app ai_knowledge_base_v2/apps/backend/alembic`。
|
||||||
|
- 本地虚拟环境依赖安装通过:`pip install -r requirements.txt`。
|
||||||
|
- FastAPI 应用导入和 OpenAPI 生成通过,当前生成 13 条路径。
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
1. 补管理员登录和基础角色权限。
|
||||||
|
2. 补聊天接口的数据库级测试或最小接口测试。
|
||||||
|
3. 准备 MySQL 本地 Docker Compose,跑 Alembic 迁移验证。
|
||||||
|
4. 开始 Vue 3 H5 用户端工程,先接 mock 登录和聊天接口。
|
||||||
@@ -25,3 +25,4 @@
|
|||||||
| `2026-07-06-open-questions.md` | 待确认问题清单。 |
|
| `2026-07-06-open-questions.md` | 待确认问题清单。 |
|
||||||
| `2026-07-06-tech-decisions.md` | 技术决策记录。 |
|
| `2026-07-06-tech-decisions.md` | 技术决策记录。 |
|
||||||
| `2026-07-06-decision-register.md` | 开发决策登记表,集中记录用户明确决策和默认工程决策。 |
|
| `2026-07-06-decision-register.md` | 开发决策登记表,集中记录用户明确决策和默认工程决策。 |
|
||||||
|
| `2026-07-06-phase2-backend-foundation.md` | 阶段二后端基础工程第一批记录。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user