diff --git a/.gitignore b/.gitignore index a182268..79907e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea .env .agent +api/data/ diff --git a/Dockerfile b/Dockerfile index a456bdf..425297e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,6 @@ # 1. Build Vue Frontend ################################ FROM node:24-alpine AS frontend-builder -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" WORKDIR /app/web COPY web/. ./ @@ -15,14 +13,13 @@ RUN npm install && npm run build ################################ FROM python:3.13-slim -WORKDIR /app +WORKDIR /api # 拷贝后端 COPY api/ ./ -ENV PATH="/app/.venv/bin:$PATH" +ENV PATH="/api/.venv/bin:$PATH" -# 拷贝数据目录(确保 sqlite 文件目录存在) 在容器中的数据保存目录为 /app/data # 安装后端依赖 RUN mkdir -p data && pip install uv && uv sync diff --git a/README.md b/README.md index 5cb3c9f..ab2eb6f 100644 --- a/README.md +++ b/README.md @@ -50,70 +50,32 @@ docker-compose up -d ## 💻 本地开发 -### 前置要求 - -- Python 3.13 -- Node.js 24 -- npm - -### 环境配置 - -创建 `api/.env` 文件配置环境变量: - -```env -# 数据库配置 -DATABASE_URL=sqlite+aiosqlite:///./data/app.db -# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname - -# JWT 配置 -SECRET_KEY=your-secret-key-here -ACCESS_TOKEN_EXPIRE_MINUTES=30 -REFRESH_TOKEN_EXPIRE_DAYS=7 -ALGORITHM=HS256 +```sh +# 启动(带日志) +docker compose -f docker-compose.dev.yml up ``` -### 后端设置 +更多命令 -```bash -cd api +```sh +# 后台启动 +docker compose -f docker-compose.dev.yml up -d -# 创建虚拟环境 -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate +# 查看日志 +docker compose -f docker-compose.dev.yml logs -f -# 安装依赖 -pip install uv -uv sync - -# 运行数据库迁移 -alembic upgrade head +# 停止 +docker compose -f docker-compose.dev.yml down -# 启动开发服务器 -python main.py +# 重新构建(如果修改了依赖) +docker compose -f docker-compose.dev.yml up --build ``` -后端将在 http://localhost:8000 运行 - -### 前端设置 - -```bash -cd web - -# 安装依赖 -npm install - -# 启动开发服务器 -npm run dev -``` - -前端将在 http://localhost:5173 运行 - -## 📖 API 文档 +前端开发服务器:http://localhost:5173 -启动后端服务后,访问以下地址查看自动生成的 API 文档: +后端 API:http://localhost:8000 -- Swagger UI: http://localhost:8000/docs -- ReDoc: http://localhost:8000/redoc +API 文档:http://localhost:8000/docs ## 📚 数据库迁移 diff --git a/api/.env.example b/api/.env.example index d5ceacf..355a8fc 100644 --- a/api/.env.example +++ b/api/.env.example @@ -5,5 +5,4 @@ SECRET_KEY=change-me-generate-a-secure-random-string ACCESS_TOKEN_EXPIRE_MINUTES=60 REFRESH_TOKEN_EXPIRE_DAYS=7 JWT_ALGORITHM=HS256 -# DATABASE_URL 非必填,如果未配置 DATABASE_URL,默认使用 sqlite # DATABASE_URL=postgres://postgres:password@localhost:5432/postgres diff --git a/api/alembic/versions/3cc16d4f8bbb_Make_workspace_id_required_and_update_foreign_key_constraints.py b/api/alembic/versions/3cc16d4f8bbb_Make_workspace_id_required_and_update_foreign_key_constraints.py new file mode 100644 index 0000000..3a2f076 --- /dev/null +++ b/api/alembic/versions/3cc16d4f8bbb_Make_workspace_id_required_and_update_foreign_key_constraints.py @@ -0,0 +1,75 @@ +"""Make_workspace_id_required_and_update_foreign_key_constraints + +Revision ID: 3cc16d4f8bbb +Revises: 54ffc5a9b833 +Create Date: 2026-02-01 12:50:58.707755 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3cc16d4f8bbb" +down_revision: Union[str, Sequence[str], None] = "54ffc5a9b833" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "files", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=False + ) + op.alter_column( + "folders", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=False + ) + op.alter_column( + "notes", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=False + ) + op.alter_column( + "workspace_user_association", + "created_at", + existing_type=postgresql.TIMESTAMP(), + nullable=True, + ) + op.drop_constraint( + op.f("workspaces_created_by_fkey"), "workspaces", type_="foreignkey" + ) + op.create_foreign_key(None, "workspaces", "users", ["created_by"], ["id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "workspaces", type_="foreignkey") + op.create_foreign_key( + op.f("workspaces_created_by_fkey"), + "workspaces", + "users", + ["created_by"], + ["id"], + ondelete="SET NULL", + ) + op.alter_column( + "workspace_user_association", + "created_at", + existing_type=postgresql.TIMESTAMP(), + nullable=False, + ) + op.alter_column( + "notes", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=True + ) + op.alter_column( + "folders", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=True + ) + op.alter_column( + "files", "workspace_id", existing_type=sa.VARCHAR(length=36), nullable=True + ) + # ### end Alembic commands ### diff --git a/api/alembic/versions/54ffc5a9b833_add_system_admin_table_and_remove_user_.py b/api/alembic/versions/54ffc5a9b833_add_system_admin_table_and_remove_user_.py index 3764a9a..007ff06 100644 --- a/api/alembic/versions/54ffc5a9b833_add_system_admin_table_and_remove_user_.py +++ b/api/alembic/versions/54ffc5a9b833_add_system_admin_table_and_remove_user_.py @@ -71,7 +71,6 @@ def upgrade() -> None: ) # 删除 users 表的 role 列 - # PostgreSQL 支持直接删除列,SQLite 需要使用 batch 模式 with op.batch_alter_table("users", schema=None) as batch_op: batch_op.drop_column("role") diff --git a/api/alembic/versions/58ec203bd381_add_storage_backends_table.py b/api/alembic/versions/58ec203bd381_add_storage_backends_table.py index 7581064..c80b51d 100644 --- a/api/alembic/versions/58ec203bd381_add_storage_backends_table.py +++ b/api/alembic/versions/58ec203bd381_add_storage_backends_table.py @@ -61,7 +61,6 @@ def upgrade() -> None: def downgrade() -> None: """删除存储后端配置表""" - # 使用 batch mode 来支持 SQLite with op.batch_alter_table("files", schema=None) as batch_op: batch_op.drop_constraint("fk_files_storage_backend_id", type_="foreignkey") batch_op.drop_column("storage_backend_id") diff --git a/api/app/app.py b/api/app/app.py index 3cbd1fd..3b1a8bc 100644 --- a/api/app/app.py +++ b/api/app/app.py @@ -3,6 +3,7 @@ from pathlib import Path from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -29,6 +30,15 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) +# 配置 CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(auth.router) app.include_router(users.router) app.include_router(workspaces.router) diff --git a/api/app/database.py b/api/app/database.py index 5b0b2b8..be46577 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -15,9 +15,6 @@ load_dotenv() -# 默认使用异步 sqlite 驱动 aiosqlite -_DEFAULT_SQLITE = "sqlite+aiosqlite:///./data/app.sqlite" - # 支持通过环境变量 `DATABASE_URL` 切换为 Postgres(推荐带 asyncpg 驱动) # 如果用户提供常见的 postgres URI(postgres:// 或 postgresql://), # 会自动把 scheme 转换为 `postgresql+asyncpg://` 以使用 asyncpg @@ -25,52 +22,42 @@ connect_args = {} if raw_db_url: - # 如果是 sqlite,直接使用,不进行后续的 URL 重组(避免 urlunparse 丢失 /// 问题) - if raw_db_url.startswith("sqlite"): - DATABASE_URL = raw_db_url - else: - if raw_db_url.startswith("postgres://"): - raw_db_url = raw_db_url.replace("postgres://", "postgresql+asyncpg://", 1) - elif raw_db_url.startswith("postgresql://"): - raw_db_url = raw_db_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - # 解析 URL 处理 asyncpg 不支持的参数 - parsed = urlparse(raw_db_url) - query_params = parse_qs(parsed.query) - - # 处理 sslmode - if "sslmode" in query_params: - ssl_mode = query_params.pop("sslmode")[0] - if ssl_mode == "require": - connect_args["ssl"] = "require" - elif ssl_mode == "disable": - connect_args["ssl"] = False - - # 处理 channel_binding (asyncpg 不支持此参数作为 kwarg,移除以避免报错) - if "channel_binding" in query_params: - query_params.pop("channel_binding") - - # 重组 URL - new_query = urlencode(query_params, doseq=True) - parsed = parsed._replace(query=new_query) - DATABASE_URL = urlunparse(parsed) -else: - DATABASE_URL = _DEFAULT_SQLITE - -# 对 sqlite 使用特定 connect_args(aiosqlite 的 check_same_thread) -if DATABASE_URL.startswith("sqlite"): - engine = create_async_engine( - DATABASE_URL, connect_args={"check_same_thread": False} - ) + if raw_db_url.startswith("postgres://"): + raw_db_url = raw_db_url.replace("postgres://", "postgresql+asyncpg://", 1) + elif raw_db_url.startswith("postgresql://"): + raw_db_url = raw_db_url.replace("postgresql://", "postgresql+asyncpg://", 1) + + # 解析 URL 处理 asyncpg 不支持的参数 + parsed = urlparse(raw_db_url) + query_params = parse_qs(parsed.query) + + # 处理 sslmode + if "sslmode" in query_params: + ssl_mode = query_params.pop("sslmode")[0] + if ssl_mode == "require": + connect_args["ssl"] = "require" + elif ssl_mode == "disable": + connect_args["ssl"] = False + + # 处理 channel_binding (asyncpg 不支持此参数作为 kwarg,移除以避免报错) + if "channel_binding" in query_params: + query_params.pop("channel_binding") + + # 重组 URL + new_query = urlencode(query_params, doseq=True) + parsed = parsed._replace(query=new_query) + DATABASE_URL = urlunparse(parsed) else: - engine = create_async_engine( - DATABASE_URL, - connect_args=connect_args, - pool_size=20, # 增加连接池大小 - max_overflow=40, # 允许超出连接池的额外连接数 - pool_pre_ping=True, # 连接前ping确保连接有效 - pool_recycle=3600, # 1小时后回收连接 - ) + raise ValueError("DATABASE_URL environment variable is required") + +engine = create_async_engine( + DATABASE_URL, + connect_args=connect_args, + pool_size=20, # 增加连接池大小 + max_overflow=40, # 允许超出连接池的额外连接数 + pool_pre_ping=True, # 连接前ping确保连接有效 + pool_recycle=3600, # 1小时后回收连接 +) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) diff --git a/api/app/models.py b/api/app/models.py index 74f7f29..b212561 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -246,7 +246,7 @@ class StorageBackendConfig(Base): id = Column( String(36), primary_key=True, index=True, default=lambda: str(uuid.uuid4()) ) - workspace_id = Column(String(36), ForeignKey("workspaces.id"), nullable=False) + workspace_id = Column(String(36), ForeignKey("workspaces.id"), nullable=True) name: Mapped[str] = mapped_column(String, unique=True, nullable=False, index=True) backend_type: Mapped[str] = mapped_column(String, nullable=False) # local or s3 is_active: Mapped[bool] = mapped_column(Integer, default=0) # 0: False, 1: True diff --git a/api/pyproject.toml b/api/pyproject.toml index ee56edc..2894dd1 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -5,7 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "aiosqlite>=0.21.0", "fastapi>=0.123.4", "pydantic>=2.12.5", "python-multipart>=0.0.20", diff --git a/api/uv.lock b/api/uv.lock index 2602111..98a5786 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -6,18 +6,6 @@ resolution-markers = [ "python_full_version < '3.14'", ] -[[package]] -name = "aiosqlite" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, -] - [[package]] name = "alembic" version = "1.17.2" @@ -67,7 +55,6 @@ name = "api" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "aiosqlite" }, { name = "alembic" }, { name = "asyncpg" }, { name = "boto3" }, @@ -84,7 +71,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.17.2" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "boto3", specifier = ">=1.35.0" }, @@ -360,7 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -371,7 +356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..cb9d980 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,60 @@ +services: + # 后端服务 + backend: + image: python:3.13-slim + container_name: archivenote-backend-dev + working_dir: /app + ports: + - "2601:2601" + volumes: + - ./api:/app + environment: + - SECRET_KEY=dev-secret-key-change-in-production + - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/archivenote + command: > + bash -c "pip install uv && + uv sync && + uv run python -m alembic upgrade head && + uv run python -m uvicorn app.app:app --host 0.0.0.0 --port 2601 --reload" + restart: unless-stopped + networks: + - archivenote-network + + # 前端服务 + frontend: + image: node:24-alpine + container_name: archivenote-frontend-dev + working_dir: /app + ports: + - "5173:5173" + volumes: + - ./web:/app + - /app/node_modules + environment: + - VITE_API_BASE_URL=http://localhost:2601/api + command: sh -c "npm install && npm run dev -- --host" + restart: unless-stopped + networks: + - archivenote-network + + postgres: + image: postgres:17 + container_name: archivenote-postgres + restart: always + ports: + - 5432:5432 + environment: + POSTGRES_DB: archivenote + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: [ 'CMD', 'pg_isready', '-U', 'postgres', '-d', 'archivenote' ] + interval: 5s + timeout: 10s + retries: 5 + networks: + - archivenote-network + +networks: + archivenote-network: + driver: bridge