From 17cba7cf61bf8c2fa8231b72becc77b26fccc61f Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Mon, 6 Jul 2026 17:25:06 +0800 Subject: [PATCH] Scaffold V2 backend foundation --- .../apps/backend/.env.example | 18 ++ ai_knowledge_base_v2/apps/backend/.gitignore | 7 + ai_knowledge_base_v2/apps/backend/README.md | 45 ++++ ai_knowledge_base_v2/apps/backend/alembic.ini | 37 +++ .../apps/backend/alembic/env.py | 51 ++++ .../apps/backend/alembic/versions/.gitkeep | 1 + .../alembic/versions/0001_initial_schema.py | 226 ++++++++++++++++++ .../apps/backend/app/__init__.py | 1 + .../apps/backend/app/api/__init__.py | 1 + .../apps/backend/app/api/auth.py | 30 +++ .../apps/backend/app/api/chat.py | 94 ++++++++ .../apps/backend/app/api/health.py | 12 + .../apps/backend/app/api/router.py | 11 + .../apps/backend/app/api/user.py | 15 ++ .../apps/backend/app/core/__init__.py | 1 + .../apps/backend/app/core/config.py | 42 ++++ .../apps/backend/app/core/database.py | 26 ++ .../apps/backend/app/core/dependencies.py | 42 ++++ .../backend/app/core/exception_handlers.py | 25 ++ .../apps/backend/app/core/responses.py | 11 + .../apps/backend/app/core/security.py | 33 +++ ai_knowledge_base_v2/apps/backend/app/main.py | 40 ++++ .../apps/backend/app/models/__init__.py | 23 ++ .../apps/backend/app/models/admin.py | 33 +++ .../apps/backend/app/models/ai_config.py | 43 ++++ .../apps/backend/app/models/base.py | 19 ++ .../apps/backend/app/models/chat.py | 40 ++++ .../apps/backend/app/models/knowledge.py | 37 +++ .../apps/backend/app/models/logs.py | 43 ++++ .../apps/backend/app/models/user.py | 28 +++ .../apps/backend/app/schemas/__init__.py | 1 + .../apps/backend/app/schemas/auth.py | 22 ++ .../apps/backend/app/schemas/chat.py | 42 ++++ .../apps/backend/app/schemas/common.py | 7 + .../apps/backend/app/schemas/user.py | 20 ++ .../apps/backend/app/services/__init__.py | 1 + .../apps/backend/app/services/auth_service.py | 76 ++++++ .../apps/backend/app/services/chat_service.py | 116 +++++++++ .../backend/app/services/sms_code_service.py | 51 ++++ .../apps/backend/requirements.txt | 10 + .../2026-07-06-phase2-backend-foundation.md | 77 ++++++ .../development_records/README.md | 1 + 42 files changed, 1459 insertions(+) create mode 100644 ai_knowledge_base_v2/apps/backend/.env.example create mode 100644 ai_knowledge_base_v2/apps/backend/.gitignore create mode 100644 ai_knowledge_base_v2/apps/backend/README.md create mode 100644 ai_knowledge_base_v2/apps/backend/alembic.ini create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/env.py create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/.gitkeep create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0001_initial_schema.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/auth.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/chat.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/health.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/router.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/user.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/config.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/database.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/dependencies.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/exception_handlers.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/responses.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/security.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/main.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/admin.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/ai_config.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/base.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/chat.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/knowledge.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/logs.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/user.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/schemas/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/schemas/auth.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/schemas/chat.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/schemas/common.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/schemas/user.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/__init__.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/auth_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/chat_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/requirements.txt create mode 100644 ai_knowledge_base_v2/development_records/2026-07-06-phase2-backend-foundation.md diff --git a/ai_knowledge_base_v2/apps/backend/.env.example b/ai_knowledge_base_v2/apps/backend/.env.example new file mode 100644 index 0000000..266184e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/.env.example @@ -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=用户 diff --git a/ai_knowledge_base_v2/apps/backend/.gitignore b/ai_knowledge_base_v2/apps/backend/.gitignore new file mode 100644 index 0000000..cfa92cf --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/ai_knowledge_base_v2/apps/backend/README.md b/ai_knowledge_base_v2/apps/backend/README.md new file mode 100644 index 0000000..96b5960 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/README.md @@ -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 黑名单。 diff --git a/ai_knowledge_base_v2/apps/backend/alembic.ini b/ai_knowledge_base_v2/apps/backend/alembic.ini new file mode 100644 index 0000000..4c3f71b --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic.ini @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/alembic/env.py b/ai_knowledge_base_v2/apps/backend/alembic/env.py new file mode 100644 index 0000000..c86325a --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/env.py @@ -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() diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/.gitkeep b/ai_knowledge_base_v2/apps/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0001_initial_schema.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..e64120b --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0001_initial_schema.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/__init__.py b/ai_knowledge_base_v2/apps/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/app/api/__init__.py b/ai_knowledge_base_v2/apps/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/app/api/auth.py b/ai_knowledge_base_v2/apps/backend/app/api/auth.py new file mode 100644 index 0000000..316c57a --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/auth.py @@ -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() diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py new file mode 100644 index 0000000..02e9302 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -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" diff --git a/ai_knowledge_base_v2/apps/backend/app/api/health.py b/ai_knowledge_base_v2/apps/backend/app/api/health.py new file mode 100644 index 0000000..bb6e9a1 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/health.py @@ -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"}) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/router.py b/ai_knowledge_base_v2/apps/backend/app/api/router.py new file mode 100644 index 0000000..33d21d6 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/router.py @@ -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"]) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/user.py b/ai_knowledge_base_v2/apps/backend/app/api/user.py new file mode 100644 index 0000000..92538c7 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/user.py @@ -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")) diff --git a/ai_knowledge_base_v2/apps/backend/app/core/__init__.py b/ai_knowledge_base_v2/apps/backend/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py new file mode 100644 index 0000000..af6804c --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -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() diff --git a/ai_knowledge_base_v2/apps/backend/app/core/database.py b/ai_knowledge_base_v2/apps/backend/app/core/database.py new file mode 100644 index 0000000..87384b1 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/database.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py new file mode 100644 index 0000000..e9fc539 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/core/exception_handlers.py b/ai_knowledge_base_v2/apps/backend/app/core/exception_handlers.py new file mode 100644 index 0000000..5ac4f15 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/exception_handlers.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/core/responses.py b/ai_knowledge_base_v2/apps/backend/app/core/responses.py new file mode 100644 index 0000000..7746cf1 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/responses.py @@ -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} diff --git a/ai_knowledge_base_v2/apps/backend/app/core/security.py b/ai_knowledge_base_v2/apps/backend/app/core/security.py new file mode 100644 index 0000000..eb278af --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/security.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/main.py b/ai_knowledge_base_v2/apps/backend/app/main.py new file mode 100644 index 0000000..37d16da --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/main.py @@ -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"} diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py new file mode 100644 index 0000000..cce13e5 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -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", +] diff --git a/ai_knowledge_base_v2/apps/backend/app/models/admin.py b/ai_knowledge_base_v2/apps/backend/app/models/admin.py new file mode 100644 index 0000000..579b7fc --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/admin.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py new file mode 100644 index 0000000..0cfdf0e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/base.py b/ai_knowledge_base_v2/apps/backend/app/models/base.py new file mode 100644 index 0000000..d9eeb62 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/base.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/chat.py b/ai_knowledge_base_v2/apps/backend/app/models/chat.py new file mode 100644 index 0000000..997782e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/chat.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py new file mode 100644 index 0000000..2d68930 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/models/logs.py b/ai_knowledge_base_v2/apps/backend/app/models/logs.py new file mode 100644 index 0000000..2075bcd --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/logs.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/user.py b/ai_knowledge_base_v2/apps/backend/app/models/user.py new file mode 100644 index 0000000..778ba68 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/user.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/__init__.py b/ai_knowledge_base_v2/apps/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py b/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..55e146d --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/auth.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py new file mode 100644 index 0000000..046cf8c --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/common.py b/ai_knowledge_base_v2/apps/backend/app/schemas/common.py new file mode 100644 index 0000000..ed01871 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/common.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/user.py b/ai_knowledge_base_v2/apps/backend/app/schemas/user.py new file mode 100644 index 0000000..de38e86 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/user.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/app/services/__init__.py b/ai_knowledge_base_v2/apps/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py b/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py new file mode 100644 index 0000000..d40b174 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/auth_service.py @@ -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="账号已过期") diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py new file mode 100644 index 0000000..b12887e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py @@ -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 "新聊天" diff --git a/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py new file mode 100644 index 0000000..893df6f --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/sms_code_service.py @@ -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="手机号格式不正确") diff --git a/ai_knowledge_base_v2/apps/backend/requirements.txt b/ai_knowledge_base_v2/apps/backend/requirements.txt new file mode 100644 index 0000000..4f0fcb4 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/requirements.txt @@ -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 diff --git a/ai_knowledge_base_v2/development_records/2026-07-06-phase2-backend-foundation.md b/ai_knowledge_base_v2/development_records/2026-07-06-phase2-backend-foundation.md new file mode 100644 index 0000000..266db2a --- /dev/null +++ b/ai_knowledge_base_v2/development_records/2026-07-06-phase2-backend-foundation.md @@ -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 登录和聊天接口。 diff --git a/ai_knowledge_base_v2/development_records/README.md b/ai_knowledge_base_v2/development_records/README.md index 1e93f1e..66d1425 100644 --- a/ai_knowledge_base_v2/development_records/README.md +++ b/ai_knowledge_base_v2/development_records/README.md @@ -25,3 +25,4 @@ | `2026-07-06-open-questions.md` | 待确认问题清单。 | | `2026-07-06-tech-decisions.md` | 技术决策记录。 | | `2026-07-06-decision-register.md` | 开发决策登记表,集中记录用户明确决策和默认工程决策。 | +| `2026-07-06-phase2-backend-foundation.md` | 阶段二后端基础工程第一批记录。 |