diff --git a/.gitignore b/.gitignore index 9c27e8d..6704881 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ /frontend/.vscode/ /frontend/.idea/ /开发路径.md +**/__pycache__/ +**/*.pyc +**/logs/ +**/logs/*.log diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f6d7058 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +.env +.git +*.md +venv +.venv diff --git a/backend/.env.production b/backend/.env.production new file mode 100644 index 0000000..b288f0d --- /dev/null +++ b/backend/.env.production @@ -0,0 +1,28 @@ +# ACG Blog 生产环境配置 +# 复制此文件为 .env 并修改对应值 + +# 应用配置 +APP_NAME=ACG Blog +APP_VERSION=1.0.0 +DEBUG=false + +# 数据库配置 +DB_HOST=postgres +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=acg_blog + +# Redis 配置 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 + +# JWT 配置 (请修改为随机字符串) +SECRET_KEY=your-super-secret-key-change-this-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS 配置 (前端域名) +BACKEND_CORS_ORIGINS=http://localhost,https://your-domain.com diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..77a0fbc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +# FastAPI Backend +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装 Python 依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY . . + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..9d8928c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# App module diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..9c7f58e --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API module diff --git a/backend/app/api/api.py b/backend/app/api/api.py new file mode 100644 index 0000000..6252348 --- /dev/null +++ b/backend/app/api/api.py @@ -0,0 +1,15 @@ +""" +API 路由汇总 +""" +from fastapi import APIRouter +from app.api.endpoints import auth, users, posts, comments + +api_router = APIRouter(prefix="/api/v1") + +# 注册各模块路由 +api_router.include_router(auth.router) +api_router.include_router(users.router) +api_router.include_router(posts.router) +api_router.include_router(posts.category_router) +api_router.include_router(posts.tag_router) +api_router.include_router(comments.router) diff --git a/backend/app/api/endpoints/__init__.py b/backend/app/api/endpoints/__init__.py new file mode 100644 index 0000000..ab8cf4e --- /dev/null +++ b/backend/app/api/endpoints/__init__.py @@ -0,0 +1 @@ +# Endpoints module diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py new file mode 100644 index 0000000..18621f1 --- /dev/null +++ b/backend/app/api/endpoints/auth.py @@ -0,0 +1,109 @@ +""" +认证 API 接口 +""" +from fastapi import APIRouter, HTTPException, status +from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, RefreshTokenRequest +from app.schemas.user import UserPublic +from app.crud.user import user_crud +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, +) +from app.core.logger import app_logger + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +async def register(request: RegisterRequest): + """用户注册""" + # 检查用户名是否存在 + if await user_crud.get_by_username(request.username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="用户名已存在" + ) + + # 检查邮箱是否存在 + if await user_crud.get_by_email(request.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="邮箱已被注册" + ) + + # 创建用户 + user = await user_crud.create( + username=request.username, + email=request.email, + password=request.password + ) + + app_logger.info(f"New user registered: {user.username}") + return user + + +@router.post("/login", response_model=TokenResponse) +async def login(login_data: LoginRequest): + """用户登录""" + username_or_email = login_data.username + password = login_data.password + + # 验证用户 + user = await user_crud.authenticate(username_or_email, password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="用户已被禁用" + ) + + # 生成令牌 + token_data = {"sub": str(user.id), "username": user.username} + access_token = create_access_token(token_data) + refresh_token = create_refresh_token(token_data) + + app_logger.info(f"User logged in: {user.username}") + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token(request: RefreshTokenRequest): + """刷新令牌""" + payload = decode_token(request.refresh_token) + + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的刷新令牌" + ) + + user_id = payload.get("sub") + username = payload.get("username") + + # 生成新令牌 + token_data = {"sub": user_id, "username": username} + access_token = create_access_token(token_data) + refresh_token = create_refresh_token(token_data) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + + +@router.post("/logout") +async def logout(): + """用户登出(前端清除令牌即可)""" + return {"message": "登出成功"} diff --git a/backend/app/api/endpoints/comments.py b/backend/app/api/endpoints/comments.py new file mode 100644 index 0000000..6aa03e2 --- /dev/null +++ b/backend/app/api/endpoints/comments.py @@ -0,0 +1,140 @@ +""" +评论 API 接口 +""" +from fastapi import APIRouter, HTTPException, status, Depends, Query +from app.schemas.comment import ( + CommentCreate, + CommentUpdate, + CommentResponse, + CommentListResponse +) +from app.crud.comment import comment_crud +from app.crud.post import post_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/comments", tags=["评论"]) + + +@router.get("/post/{post_id}", response_model=CommentListResponse) +async def get_post_comments( + post_id: str, + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + approved_only: bool = Query(True, description="仅显示已审核评论") +): + """获取文章的所有评论""" + # 检查文章是否存在 + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + comments, total = await comment_crud.get_by_post( + post_id=post_id, + page=page, + page_size=page_size, + approved_only=approved_only + ) + + return CommentListResponse( + items=comments, + total=total, + page=page, + page_size=page_size + ) + + +@router.post("", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def create_comment( + comment_data: CommentCreate, + user_id: str = Depends(get_current_user_id) +): + """创建评论""" + # 检查文章是否存在 + post = await post_crud.get_by_id(comment_data.post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 如果是回复,检查父评论是否存在 + if comment_data.parent_id: + parent = await comment_crud.get_by_id(comment_data.parent_id) + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="父评论不存在" + ) + if str(parent.post_id) != comment_data.post_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="父评论不属于该文章" + ) + + comment = await comment_crud.create( + content=comment_data.content, + author_id=user_id, + post_id=comment_data.post_id, + parent_id=comment_data.parent_id + ) + + app_logger.info(f"Comment created on post {comment_data.post_id} by user {user_id}") + return comment + + +@router.put("/{comment_id}", response_model=CommentResponse) +async def update_comment( + comment_id: str, + comment_data: CommentUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新评论""" + comment = await comment_crud.get_by_id(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="评论不存在" + ) + + # 检查权限(只能修改自己的评论) + if str(comment.author_id) != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限修改此评论" + ) + + updated_comment = await comment_crud.update( + comment_id, + **comment_data.model_dump(exclude_unset=True) + ) + + app_logger.info(f"Comment updated: {comment_id}") + return updated_comment + + +@router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_comment( + comment_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除评论""" + comment = await comment_crud.get_by_id(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="评论不存在" + ) + + # 检查权限(只能删除自己的评论) + if str(comment.author_id) != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限删除此评论" + ) + + await comment_crud.delete(comment_id) + app_logger.info(f"Comment deleted: {comment_id}") diff --git a/backend/app/api/endpoints/posts.py b/backend/app/api/endpoints/posts.py new file mode 100644 index 0000000..d182447 --- /dev/null +++ b/backend/app/api/endpoints/posts.py @@ -0,0 +1,288 @@ +""" +文章 API 接口 +""" +import math +from typing import Optional +from fastapi import APIRouter, HTTPException, status, Depends, Query +from app.schemas.post import ( + PostCreate, + PostUpdate, + PostResponse, + PostListResponse, + TagCreate, + TagResponse, + CategoryCreate, + CategoryResponse +) +from app.schemas.post import AuthorResponse +from app.crud.post import post_crud +from app.crud.category import category_crud +from app.crud.tag import tag_crud +from app.crud.user import user_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/posts", tags=["文章"]) +category_router = APIRouter(prefix="/categories", tags=["分类"]) +tag_router = APIRouter(prefix="/tags", tags=["标签"]) + + +# ==================== 文章接口 ==================== + +@router.get("", response_model=PostListResponse) +async def get_posts( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query("published", description="文章状态"), + category_id: Optional[str] = Query(None, description="分类ID"), + tag_id: Optional[str] = Query(None, description="标签ID") +): + """获取文章列表""" + posts, total = await post_crud.get_all( + page=page, + page_size=page_size, + status=status, + category_id=category_id, + tag_id=tag_id + ) + + return PostListResponse( + items=posts, + total=total, + page=page, + page_size=page_size, + total_pages=math.ceil(total / page_size) if total > 0 else 0 + ) + + +@router.get("/hot", response_model=list[PostResponse]) +async def get_hot_posts(limit: int = Query(10, ge=1, le=50)): + """获取热门文章""" + posts = await post_crud.get_hot_posts(limit=limit) + return posts + + +@router.get("/recent", response_model=list[PostResponse]) +async def get_recent_posts(limit: int = Query(10, ge=1, le=50)): + """获取最新文章""" + posts = await post_crud.get_recent_posts(limit=limit) + return posts + + +@router.get("/{post_id}", response_model=PostResponse) +async def get_post(post_id: str): + """获取文章详情""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 增加浏览量 + await post_crud.increment_view_count(post_id) + + return post + + +@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED) +async def create_post( + post_data: PostCreate, + user_id: str = Depends(get_current_user_id) +): + """创建文章""" + # 检查 slug 是否已存在 + if await post_crud.get_by_slug(post_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + post = await post_crud.create( + author_id=user_id, + **post_data.model_dump() + ) + + app_logger.info(f"Post created: {post.title} by user {user_id}") + return post + + +@router.put("/{post_id}", response_model=PostResponse) +async def update_post( + post_id: str, + post_data: PostUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新文章""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 检查权限 + if str(post.author_id) != user_id: + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限修改此文章" + ) + + updated_post = await post_crud.update( + post_id, + **post_data.model_dump(exclude_unset=True) + ) + + app_logger.info(f"Post updated: {updated_post.title}") + return updated_post + + +@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post( + post_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除文章""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 检查权限 + if str(post.author_id) != user_id: + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限删除此文章" + ) + + await post_crud.delete(post_id) + app_logger.info(f"Post deleted: {post_id}") + + +# ==================== 分类接口 ==================== + +@category_router.get("", response_model=list[CategoryResponse]) +async def get_categories(): + """获取所有分类""" + return await category_crud.get_all() + + +@category_router.get("/{category_id}", response_model=CategoryResponse) +async def get_category(category_id: str): + """获取分类详情""" + category = await category_crud.get_by_id(category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="分类不存在" + ) + return category + + +@category_router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + category_data: CategoryCreate, + user_id: str = Depends(get_current_user_id) +): + """创建分类""" + # 检查权限 + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + # 检查 slug 是否已存在 + if await category_crud.get_by_slug(category_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + category = await category_crud.create(**category_data.model_dump()) + app_logger.info(f"Category created: {category.name}") + return category + + +@category_router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: str, + category_data: CategoryCreate, + user_id: str = Depends(get_current_user_id) +): + """更新分类""" + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + category = await category_crud.update(category_id, **category_data.model_dump()) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="分类不存在" + ) + return category + + +@category_router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除分类""" + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + await category_crud.delete(category_id) + + +# ==================== 标签接口 ==================== + +@tag_router.get("", response_model=list[TagResponse]) +async def get_tags(): + """获取所有标签""" + return await tag_crud.get_all() + + +@tag_router.get("/{tag_id}", response_model=TagResponse) +async def get_tag(tag_id: str): + """获取标签详情""" + tag = await tag_crud.get_by_id(tag_id) + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="标签不存在" + ) + return tag + + +@tag_router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED) +async def create_tag( + tag_data: TagCreate, + user_id: str = Depends(get_current_user_id) +): + """创建标签""" + # 检查 slug 是否已存在 + if await tag_crud.get_by_slug(tag_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + tag = await tag_crud.create(**tag_data.model_dump()) + app_logger.info(f"Tag created: {tag.name}") + return tag diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py new file mode 100644 index 0000000..9e0d284 --- /dev/null +++ b/backend/app/api/endpoints/users.py @@ -0,0 +1,50 @@ +""" +用户 API 接口 +""" +from fastapi import APIRouter, HTTPException, status, Depends +from app.schemas.user import UserPublic, UserUpdate +from app.crud.user import user_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/users", tags=["用户"]) + + +@router.get("/me", response_model=UserPublic) +async def get_current_user(user_id: str = Depends(get_current_user_id)): + """获取当前用户信息""" + user = await user_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + return user + + +@router.put("/me", response_model=UserPublic) +async def update_current_user( + user_update: UserUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新当前用户信息""" + user = await user_crud.update(user_id, **user_update.model_dump(exclude_unset=True)) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + app_logger.info(f"User updated: {user.username}") + return user + + +@router.get("/{user_id}", response_model=UserPublic) +async def get_user(user_id: str): + """获取指定用户信息""" + user = await user_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + return user diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..3e83c63 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core module diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e69de29..b5ff201 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -0,0 +1,67 @@ +""" +应用配置模块 +使用 Pydantic Settings 管理环境变量 +""" +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + """应用配置类""" + + # 应用基础配置 + APP_NAME: str = "ACG Blog" + APP_VERSION: str = "0.1.0" + DEBUG: bool = True + + # 数据库配置 + DB_HOST: str = "localhost" + DB_PORT: int = 5432 + DB_USER: str = "postgres" + DB_PASSWORD: str = "postgres" + DB_NAME: str = "acg_blog" + + # Redis 配置 + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + + # JWT 配置 + SECRET_KEY: str = "your-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS 配置 + BACKEND_CORS_ORIGINS: list[str] = [ + "http://localhost:5173", + "http://localhost:3000", + ] + + @property + def DATABASE_URL(self) -> str: + """生成数据库连接 URL""" + return f"postgres://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def DATABASE_URL_ASYNC(self) -> str: + """生成异步数据库连接 URL""" + return f"asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def REDIS_URL(self) -> str: + """生成 Redis 连接 URL""" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + class Config: + env_file = ".env" + case_sensitive = True + + +@lru_cache() +def get_settings() -> Settings: + """获取配置单例""" + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..aac05cc --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,39 @@ +""" +数据库模块 +Tortoise-ORM 初始化配置 +""" +from tortoise import Tortoise + +from app.core.config import settings +from app.core.logger import app_logger + + +async def init_db(): + """初始化数据库连接""" + app_logger.info("Initializing database connection...") + + await Tortoise.init( + db_url=settings.DATABASE_URL_ASYNC, + modules={ + "models": [ + "app.models.user", + "app.models.post", + "app.models.category", + "app.models.tag", + "app.models.comment", + ] + }, + use_tz=False, # 使用 UTC 时间 + timezone="Asia/Shanghai", # 使用上海时区 + ) + + # 生成 schema + await Tortoise.generate_schemas() + app_logger.info("Database connection established") + + +async def close_db(): + """关闭数据库连接""" + app_logger.info("Closing database connection...") + await Tortoise.close_connections() + app_logger.info("Database connection closed") diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 0000000..91e024c --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,42 @@ +""" +日志配置模块 +使用 Loguru 实现异步日志处理 +""" +import sys +from pathlib import Path +from loguru import logger + +# 日志目录 +LOG_DIR = Path(__file__).parent.parent.parent / "logs" +LOG_DIR.mkdir(exist_ok=True) + + +def setup_logger(): + """配置日志格式和输出""" + # 移除默认处理器 + logger.remove() + + # 控制台输出格式 + logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="INFO", + colorize=True, + ) + + # 文件输出格式 + logger.add( + LOG_DIR / "acg_blog_{time:YYYY-MM-DD}.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="DEBUG", + rotation="00:00", # 每天零点轮换 + retention="30 days", # 保留30天 + compression="zip", # 压缩旧日志 + encoding="utf-8", + ) + + return logger + + +# 初始化日志 +app_logger = setup_logger() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1dc6596 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,82 @@ +""" +安全模块 +包含 JWT 令牌生成、验证和密码哈希功能 +""" +from datetime import datetime, timedelta +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from app.core.config import settings + + +# 密码上下文(使用 bcrypt) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# OAuth2 方案 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """哈希密码""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建刷新令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> dict: + """解码令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str: + """从令牌中获取当前用户 ID""" + payload = decode_token(token) + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user_id diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 0000000..6fea1c8 --- /dev/null +++ b/backend/app/crud/__init__.py @@ -0,0 +1 @@ +# CRUD module diff --git a/backend/app/crud/category.py b/backend/app/crud/category.py new file mode 100644 index 0000000..f7d50c6 --- /dev/null +++ b/backend/app/crud/category.py @@ -0,0 +1,72 @@ +""" +分类 CRUD 操作 +""" +from typing import Optional, List +from app.models.category import Category + + +class CategoryCRUD: + """分类 CRUD 操作类""" + + @staticmethod + async def get_by_id(category_id: str) -> Optional[Category]: + """根据 ID 获取分类""" + return await Category.filter(id=category_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Category]: + """根据 slug 获取分类""" + return await Category.filter(slug=slug).first() + + @staticmethod + async def get_all() -> List[Category]: + """获取所有分类""" + return await Category.all() + + @staticmethod + async def create(name: str, slug: str, description: Optional[str] = None) -> Category: + """创建分类""" + category = await Category.create( + name=name, + slug=slug, + description=description + ) + return category + + @staticmethod + async def update(category_id: str, **kwargs) -> Optional[Category]: + """更新分类""" + category = await Category.filter(id=category_id).first() + if category: + for key, value in kwargs.items(): + if value is not None and hasattr(category, key): + setattr(category, key, value) + await category.save() + return category + + @staticmethod + async def delete(category_id: str) -> bool: + """删除分类""" + category = await Category.filter(id=category_id).first() + if category: + await category.delete() + return True + return False + + @staticmethod + async def get_with_post_count() -> List[dict]: + """获取分类及其文章数量""" + categories = await Category.all().prefetch_related("posts") + result = [] + for cat in categories: + result.append({ + "id": str(cat.id), + "name": cat.name, + "slug": cat.slug, + "description": cat.description, + "post_count": len(cat.posts) if cat.posts else 0 + }) + return result + + +category_crud = CategoryCRUD() diff --git a/backend/app/crud/comment.py b/backend/app/crud/comment.py new file mode 100644 index 0000000..1a1d56a --- /dev/null +++ b/backend/app/crud/comment.py @@ -0,0 +1,91 @@ +""" +评论 CRUD 操作 +""" +from typing import Optional, List, Tuple +from app.models.comment import Comment +from app.models.post import Post + + +class CommentCRUD: + """评论 CRUD 操作类""" + + @staticmethod + async def get_by_id(comment_id: str) -> Optional[Comment]: + """根据 ID 获取评论""" + return await Comment.filter(id=comment_id).first() + + @staticmethod + async def get_by_post( + post_id: str, + page: int = 1, + page_size: int = 20, + approved_only: bool = True + ) -> Tuple[List[Comment], int]: + """获取文章的所有评论(树形结构)""" + query = Comment.filter(post_id=post_id) + + if approved_only: + query = query.filter(is_approved=True) + + total = await query.count() + comments = await query \ + .prefetch_related("author", "replies__author") \ + .filter(parent_id=None) \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("created_at") + + return comments, total + + @staticmethod + async def create( + content: str, + author_id: str, + post_id: str, + parent_id: Optional[str] = None, + is_approved: bool = True + ) -> Comment: + """创建评论""" + comment = await Comment.create( + content=content, + author_id=author_id, + post_id=post_id, + parent_id=parent_id, + is_approved=is_approved + ) + + # 更新文章的评论数 + await Post.filter(id=post_id).update(comment_count=Post.comment_count + 1) + + return comment + + @staticmethod + async def update(comment_id: str, **kwargs) -> Optional[Comment]: + """更新评论""" + comment = await Comment.filter(id=comment_id).first() + if comment: + for key, value in kwargs.items(): + if value is not None and hasattr(comment, key): + setattr(comment, key, value) + await comment.save() + return comment + + @staticmethod + async def delete(comment_id: str) -> bool: + """删除评论""" + comment = await Comment.filter(id=comment_id).first() + if comment: + post_id = comment.post_id + await comment.delete() + # 更新文章的评论数 + await Post.filter(id=post_id).update(comment_count=Post.comment_count - 1) + return True + return False + + @staticmethod + async def approve(comment_id: str) -> Optional[Comment]: + """审核通过评论""" + return await CommentCRUD.update(comment_id, is_approved=True) + + +comment_crud = CommentCRUD() diff --git a/backend/app/crud/post.py b/backend/app/crud/post.py new file mode 100644 index 0000000..fbde7e1 --- /dev/null +++ b/backend/app/crud/post.py @@ -0,0 +1,163 @@ +""" +文章 CRUD 操作 +""" +from datetime import datetime +from typing import Optional, List, Tuple +from app.models.post import Post, PostTag +from app.models.user import User +from app.models.category import Category +from app.models.tag import Tag + + +class PostCRUD: + """文章 CRUD 操作类""" + + @staticmethod + async def get_by_id(post_id: str) -> Optional[Post]: + """根据 ID 获取文章""" + return await Post.filter(id=post_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Post]: + """根据 slug 获取文章""" + return await Post.filter(slug=slug).first() + + @staticmethod + async def get_all( + page: int = 1, + page_size: int = 10, + status: str = "published", + category_id: Optional[str] = None, + tag_id: Optional[str] = None + ) -> Tuple[List[Post], int]: + """获取文章列表(分页)""" + query = Post.all() + + if status: + query = query.filter(status=status) + + if category_id: + query = query.filter(category_id=category_id) + + if tag_id: + query = query.filter(tags__id=tag_id) + + total = await query.count() + posts = await query \ + .prefetch_related("author", "category", "tags") \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("-created_at") + + return posts, total + + @staticmethod + async def get_by_author( + author_id: str, + page: int = 1, + page_size: int = 10 + ) -> Tuple[List[Post], int]: + """获取指定作者的文章列表""" + query = Post.filter(author_id=author_id) + total = await query.count() + posts = await query \ + .prefetch_related("author", "category", "tags") \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("-created_at") + return posts, total + + @staticmethod + async def create( + title: str, + slug: str, + content: str, + author_id: str, + summary: Optional[str] = None, + cover_image: Optional[str] = None, + category_id: Optional[str] = None, + tag_ids: Optional[List[str]] = None, + status: str = "draft", + meta_title: Optional[str] = None, + meta_description: Optional[str] = None + ) -> Post: + """创建文章""" + post = await Post.create( + title=title, + slug=slug, + content=content, + author_id=author_id, + summary=summary, + cover_image=cover_image, + category_id=category_id, + status=status, + meta_title=meta_title, + meta_description=meta_description + ) + + # 添加标签 + if tag_ids: + for tag_id in tag_ids: + await PostTag.create(post_id=post.id, tag_id=tag_id) + + return post + + @staticmethod + async def update(post_id: str, **kwargs) -> Optional[Post]: + """更新文章""" + post = await Post.filter(id=post_id).first() + if not post: + return None + + # 处理标签更新 + if "tag_ids" in kwargs: + tag_ids = kwargs.pop("tag_ids") + # 删除旧标签关联 + await PostTag.filter(post_id=post_id).delete() + # 添加新标签关联 + for tag_id in tag_ids: + await PostTag.create(post_id=post_id, tag_id=tag_id) + + # 处理发布状态更新 + if kwargs.get("status") == "published" and not post.published_at: + kwargs["published_at"] = datetime.utcnow() + + for key, value in kwargs.items(): + if value is not None and hasattr(post, key): + setattr(post, key, value) + + await post.save() + return post + + @staticmethod + async def delete(post_id: str) -> bool: + """删除文章""" + post = await Post.filter(id=post_id).first() + if post: + await post.delete() + return True + return False + + @staticmethod + async def increment_view_count(post_id: str) -> None: + """增加浏览量""" + await Post.filter(id=post_id).update(view_count=Post.view_count + 1) + + @staticmethod + async def get_hot_posts(limit: int = 10) -> List[Post]: + """获取热门文章(按浏览量排序)""" + return await Post.filter(status="published") \ + .prefetch_related("author", "category") \ + .order_by("-view_count") \ + .limit(limit) + + @staticmethod + async def get_recent_posts(limit: int = 10) -> List[Post]: + """获取最新文章""" + return await Post.filter(status="published") \ + .prefetch_related("author", "category", "tags") \ + .order_by("-created_at") \ + .limit(limit) + + +post_crud = PostCRUD() diff --git a/backend/app/crud/tag.py b/backend/app/crud/tag.py new file mode 100644 index 0000000..ae49786 --- /dev/null +++ b/backend/app/crud/tag.py @@ -0,0 +1,66 @@ +""" +标签 CRUD 操作 +""" +from typing import Optional, List +from app.models.tag import Tag + + +class TagCRUD: + """标签 CRUD 操作类""" + + @staticmethod + async def get_by_id(tag_id: str) -> Optional[Tag]: + """根据 ID 获取标签""" + return await Tag.filter(id=tag_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Tag]: + """根据 slug 获取标签""" + return await Tag.filter(slug=slug).first() + + @staticmethod + async def get_by_name(name: str) -> Optional[Tag]: + """根据名称获取标签""" + return await Tag.filter(name=name).first() + + @staticmethod + async def get_all() -> List[Tag]: + """获取所有标签""" + return await Tag.all() + + @staticmethod + async def create(name: str, slug: str) -> Tag: + """创建标签""" + tag = await Tag.create(name=name, slug=slug) + return tag + + @staticmethod + async def update(tag_id: str, **kwargs) -> Optional[Tag]: + """更新标签""" + tag = await Tag.filter(id=tag_id).first() + if tag: + for key, value in kwargs.items(): + if value is not None and hasattr(tag, key): + setattr(tag, key, value) + await tag.save() + return tag + + @staticmethod + async def delete(tag_id: str) -> bool: + """删除标签""" + tag = await Tag.filter(id=tag_id).first() + if tag: + await tag.delete() + return True + return False + + @staticmethod + async def get_or_create(name: str, slug: str) -> Tag: + """获取或创建标签""" + tag = await TagCRUD.get_by_slug(slug) + if tag: + return tag + return await TagCRUD.create(name, slug) + + +tag_crud = TagCRUD() diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py new file mode 100644 index 0000000..bcfeb55 --- /dev/null +++ b/backend/app/crud/user.py @@ -0,0 +1,75 @@ +""" +用户 CRUD 操作 +""" +from typing import Optional +from app.models.user import User +from app.core.security import get_password_hash, verify_password + + +class UserCRUD: + """用户 CRUD 操作类""" + + @staticmethod + async def get_by_id(user_id: str) -> Optional[User]: + """根据 ID 获取用户""" + return await User.filter(id=user_id).first() + + @staticmethod + async def get_by_username(username: str) -> Optional[User]: + """根据用户名获取用户""" + return await User.filter(username=username).first() + + @staticmethod + async def get_by_email(email: str) -> Optional[User]: + """根据邮箱获取用户""" + return await User.filter(email=email).first() + + @staticmethod + async def get_by_username_or_email(username_or_email: str) -> Optional[User]: + """根据用户名或邮箱获取用户""" + return await User.filter( + username=username_or_email + ).first() or await User.filter( + email=username_or_email + ).first() + + @staticmethod + async def create(username: str, email: str, password: str) -> User: + """创建用户""" + password_hash = get_password_hash(password) + user = await User.create( + username=username, + email=email, + password_hash=password_hash + ) + return user + + @staticmethod + async def update(user_id: str, **kwargs) -> Optional[User]: + """更新用户""" + user = await User.filter(id=user_id).first() + if user: + for key, value in kwargs.items(): + if value is not None and hasattr(user, key): + setattr(user, key, value) + await user.save() + return user + + @staticmethod + async def authenticate(username_or_email: str, password: str) -> Optional[User]: + """验证用户登录""" + user = await UserCRUD.get_by_username_or_email(username_or_email) + if not user: + return None + if not verify_password(password, user.password_hash): + return None + return user + + @staticmethod + async def is_superuser(user_id: str) -> bool: + """检查用户是否为超级用户""" + user = await User.filter(id=user_id).first() + return user.is_superuser if user else False + + +user_crud = UserCRUD() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e2313c5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..6fa6bc5 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,22 @@ +""" +分类模型 +""" +import uuid +from tortoise import fields, models + + +class Category(models.Model): + """分类模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + name = fields.CharField(max_length=50, unique=True, description="分类名称") + slug = fields.CharField(max_length=50, unique=True, description="URL别名") + description = fields.TextField(null=True, description="分类描述") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "categories" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..908c959 --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,49 @@ +""" +评论模型 +""" +import uuid +from tortoise import fields, models + + +class Comment(models.Model): + """评论模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + content = fields.TextField(description="评论内容") + is_approved = fields.BooleanField(default=True, description="是否审核通过") + + # 关联用户(评论者) + author = fields.ForeignKeyField( + "models.User", + related_name="comments", + on_delete=fields.CASCADE, + description="评论者" + ) + + # 关联文章 + post = fields.ForeignKeyField( + "models.Post", + related_name="comments", + on_delete=fields.CASCADE, + description="所属文章" + ) + + # 自关联(回复) + parent = fields.ForeignKeyField( + "models.Comment", + related_name="replies", + on_delete=fields.CASCADE, + null=True, + description="父评论" + ) + + # 时间戳 + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "comments" + ordering = ["created_at"] + + def __str__(self): + return f"Comment by {self.author.username} on {self.post.title}" diff --git a/backend/app/models/post.py b/backend/app/models/post.py new file mode 100644 index 0000000..5860500 --- /dev/null +++ b/backend/app/models/post.py @@ -0,0 +1,94 @@ +""" +文章模型 +""" +import uuid +from datetime import datetime +from tortoise import fields, models + + +class Post(models.Model): + """文章模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + title = fields.CharField(max_length=200, description="文章标题") + slug = fields.CharField(max_length=200, unique=True, description="URL别名") + content = fields.TextField(description="文章内容(Markdown)") + summary = fields.TextField(null=True, description="文章摘要") + cover_image = fields.CharField(max_length=500, null=True, description="封面图片URL") + + # 关联用户(作者) + author = fields.ForeignKeyField( + "models.User", + related_name="posts", + on_delete=fields.CASCADE, + description="作者" + ) + + # 关联分类 + category = fields.ForeignKeyField( + "models.Category", + related_name="posts", + on_delete=fields.SET_NULL, + null=True, + description="分类" + ) + + # 标签(多对多) + tags = fields.ManyToManyField( + "models.Tag", + related_name="posts", + through="post_tags", + description="标签" + ) + + # 统计数据 + view_count = fields.IntField(default=0, description="浏览量") + like_count = fields.IntField(default=0, description="点赞数") + comment_count = fields.IntField(default=0, description="评论数") + + # 状态:draft(草稿), published(已发布), archived(已归档) + status = fields.CharField( + max_length=20, + default="draft", + description="文章状态" + ) + + # SEO + meta_title = fields.CharField(max_length=200, null=True, description="SEO标题") + meta_description = fields.TextField(null=True, description="SEO描述") + + # 时间戳 + published_at = fields.DatetimeField(null=True, description="发布时间") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "posts" + ordering = ["-created_at"] + indexes = [ + ("status", "published_at"), + ("author", "status"), + ] + + def __str__(self): + return self.title + + +class PostTag(models.Model): + """文章-标签关联表""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + post = fields.ForeignKeyField( + "models.Post", + on_delete=fields.CASCADE, + description="文章" + ) + tag = fields.ForeignKeyField( + "models.Tag", + on_delete=fields.CASCADE, + description="标签" + ) + + class Meta: + table = "post_tags" + unique_together = (("post", "tag"),) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 0000000..40ef96d --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,21 @@ +""" +标签模型 +""" +import uuid +from tortoise import fields, models + + +class Tag(models.Model): + """标签模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + name = fields.CharField(max_length=50, unique=True, description="标签名称") + slug = fields.CharField(max_length=50, unique=True, description="URL别名") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "tags" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..4f0ef59 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,28 @@ +""" +用户模型 +""" +import uuid +from datetime import datetime +from tortoise import fields, models + + +class User(models.Model): + """用户模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + username = fields.CharField(max_length=50, unique=True, description="用户名") + email = fields.CharField(max_length=255, unique=True, description="邮箱") + password_hash = fields.CharField(max_length=255, description="密码哈希") + avatar = fields.CharField(max_length=500, null=True, description="头像URL") + bio = fields.TextField(null=True, description="个人简介") + is_active = fields.BooleanField(default=True, description="是否激活") + is_superuser = fields.BooleanField(default=False, description="是否超级用户") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "users" + ordering = ["-created_at"] + + def __str__(self): + return self.username diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..fff1fac --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schemas module diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..04d58bd --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,29 @@ +""" +认证 Schema +""" +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + """登录请求 Schema""" + username: str = Field(..., description="用户名或邮箱") + password: str = Field(..., description="密码") + + +class RegisterRequest(BaseModel): + """注册请求 Schema""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + email: EmailStr = Field(..., description="邮箱") + password: str = Field(..., min_length=6, max_length=100, description="密码") + + +class TokenResponse(BaseModel): + """令牌响应 Schema""" + access_token: str = Field(..., description="访问令牌") + refresh_token: str = Field(..., description="刷新令牌") + token_type: str = Field(default="bearer", description="令牌类型") + + +class RefreshTokenRequest(BaseModel): + """刷新令牌请求 Schema""" + refresh_token: str = Field(..., description="刷新令牌") diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py new file mode 100644 index 0000000..a5ae3fd --- /dev/null +++ b/backend/app/schemas/comment.py @@ -0,0 +1,55 @@ +""" +评论 Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class CommentBase(BaseModel): + """评论基础 Schema""" + content: str = Field(..., min_length=1, description="评论内容") + + +class CommentCreate(CommentBase): + """评论创建 Schema""" + post_id: str = Field(..., description="文章ID") + parent_id: Optional[str] = Field(None, description="父评论ID") + + +class CommentUpdate(BaseModel): + """评论更新 Schema""" + content: Optional[str] = Field(None, min_length=1) + + +class CommentAuthor(BaseModel): + """评论作者 Schema""" + id: str + username: str + avatar: Optional[str] = None + + class Config: + from_attributes = True + + +class CommentResponse(CommentBase): + """评论响应 Schema""" + id: str + author: CommentAuthor + post_id: str + parent_id: Optional[str] = None + is_approved: bool + created_at: datetime + updated_at: datetime + replies: List["CommentResponse"] = [] + + class Config: + from_attributes = True + + +class CommentListResponse(BaseModel): + """评论列表响应 Schema""" + items: List[CommentResponse] + total: int + page: int + page_size: int diff --git a/backend/app/schemas/post.py b/backend/app/schemas/post.py new file mode 100644 index 0000000..6887747 --- /dev/null +++ b/backend/app/schemas/post.py @@ -0,0 +1,115 @@ +""" +文章 Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class TagBase(BaseModel): + """标签基础 Schema""" + name: str = Field(..., min_length=1, max_length=50, description="标签名") + slug: str = Field(..., min_length=1, max_length=50, description="URL别名") + + +class TagCreate(TagBase): + """标签创建 Schema""" + pass + + +class TagResponse(TagBase): + """标签响应 Schema""" + id: str + created_at: datetime + + class Config: + from_attributes = True + + +class CategoryBase(BaseModel): + """分类基础 Schema""" + name: str = Field(..., min_length=1, max_length=50, description="分类名") + slug: str = Field(..., min_length=1, max_length=50, description="URL别名") + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + """分类创建 Schema""" + pass + + +class CategoryResponse(CategoryBase): + """分类响应 Schema""" + id: str + created_at: datetime + + class Config: + from_attributes = True + + +class PostBase(BaseModel): + """文章基础 Schema""" + title: str = Field(..., min_length=1, max_length=200, description="标题") + slug: str = Field(..., min_length=1, max_length=200, description="URL别名") + content: str = Field(..., description="文章内容") + summary: Optional[str] = None + cover_image: Optional[str] = None + category_id: Optional[str] = None + status: str = Field(default="draft", description="状态: draft/published/archived") + + +class PostCreate(PostBase): + """文章创建 Schema""" + tag_ids: Optional[List[str]] = [] + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class PostUpdate(BaseModel): + """文章更新 Schema""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + slug: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = None + summary: Optional[str] = None + cover_image: Optional[str] = None + category_id: Optional[str] = None + tag_ids: Optional[List[str]] = None + status: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class AuthorResponse(BaseModel): + """作者响应 Schema""" + id: str + username: str + avatar: Optional[str] = None + + class Config: + from_attributes = True + + +class PostResponse(PostBase): + """文章响应 Schema""" + id: str + author: AuthorResponse + category: Optional[CategoryResponse] = None + tags: List[TagResponse] = [] + view_count: int + like_count: int + comment_count: int + published_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PostListResponse(BaseModel): + """文章列表响应 Schema""" + items: List[PostResponse] + total: int + page: int + page_size: int + total_pages: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..24a70c5 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,50 @@ +""" +用户 Schema +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + """用户基础 Schema""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + email: EmailStr = Field(..., description="邮箱") + + +class UserCreate(UserBase): + """用户创建 Schema""" + password: str = Field(..., min_length=6, max_length=100, description="密码") + + +class UserUpdate(BaseModel): + """用户更新 Schema""" + username: Optional[str] = Field(None, min_length=3, max_length=50) + email: Optional[EmailStr] = None + avatar: Optional[str] = None + bio: Optional[str] = None + + +class UserInDB(UserBase): + """用户数据库 Schema""" + id: str + avatar: Optional[str] = None + bio: Optional[str] = None + is_active: bool + is_superuser: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class UserPublic(UserBase): + """用户公开信息 Schema""" + id: str + avatar: Optional[str] = None + bio: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..0557eb6 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services module diff --git a/backend/main.py b/backend/main.py index 9149eef..be9972a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,72 @@ +""" +FastAPI 应用入口 +""" +from contextlib import asynccontextmanager from fastapi import FastAPI -from pydantic import BaseModel -app = FastAPI() -@app.get("/") -def read_root(): - return {"Hello": "World"} \ No newline at end of file +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.database import init_db, close_db +from app.core.logger import app_logger +from app.api.api import api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时 + app_logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}...") + + # 初始化数据库 + await init_db() + + yield + + # 关闭时 + app_logger.info("Shutting down...") + await close_db() + + +def create_app() -> FastAPI: + """创建 FastAPI 应用""" + app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="ACG 风格博客系统 API", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan + ) + + # 配置 CORS + app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 注册路由 + app.include_router(api_router) + + # 健康检查 + @app.get("/health") + async def health_check(): + return {"status": "healthy", "app": settings.APP_NAME} + + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..8092bf8 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,136 @@ +-- ACG Blog 数据库建表脚本 +-- PostgreSQL +-- 运行前请先创建数据库: CREATE DATABASE acg_blog; + +-- 启用 UUID 扩展 +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ==================== 用户表 ==================== +CREATE TABLE IF NOT EXISTS "users" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "username" VARCHAR(50) UNIQUE NOT NULL, + "email" VARCHAR(255) UNIQUE NOT NULL, + "password_hash" VARCHAR(255) NOT NULL, + "avatar" VARCHAR(500), + "bio" TEXT, + "is_active" BOOLEAN DEFAULT TRUE, + "is_superuser" BOOLEAN DEFAULT FALSE, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ==================== 分类表 ==================== +CREATE TABLE IF NOT EXISTS "categories" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "name" VARCHAR(50) UNIQUE NOT NULL, + "slug" VARCHAR(50) UNIQUE NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ==================== 标签表 ==================== +CREATE TABLE IF NOT EXISTS "tags" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "name" VARCHAR(50) UNIQUE NOT NULL, + "slug" VARCHAR(50) UNIQUE NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ==================== 文章表 ==================== +CREATE TABLE IF NOT EXISTS "posts" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "title" VARCHAR(200) NOT NULL, + "slug" VARCHAR(200) UNIQUE NOT NULL, + "content" TEXT NOT NULL, + "summary" TEXT, + "cover_image" VARCHAR(500), + + -- 外键关联 + "author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "category_id" UUID REFERENCES "categories"("id") ON DELETE SET NULL, + + -- 统计数据 + "view_count" INTEGER DEFAULT 0, + "like_count" INTEGER DEFAULT 0, + "comment_count" INTEGER DEFAULT 0, + + -- 状态: draft/published/archived + "status" VARCHAR(20) DEFAULT 'draft', + + -- SEO + "meta_title" VARCHAR(200), + "meta_description" TEXT, + + -- 时间戳 + "published_at" TIMESTAMP, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX IF NOT EXISTS "idx_posts_status_published_at" ON "posts"("status", "published_at"); +CREATE INDEX IF NOT EXISTS "idx_posts_author_status" ON "posts"("author_id", "status"); + +-- ==================== 文章标签关联表 ==================== +CREATE TABLE IF NOT EXISTS "post_tags" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE, + "tag_id" UUID NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE, + UNIQUE("post_id", "tag_id") +); + +-- ==================== 评论表 ==================== +CREATE TABLE IF NOT EXISTS "comments" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "content" TEXT NOT NULL, + "is_approved" BOOLEAN DEFAULT TRUE, + + -- 外键关联 + "author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE, + "parent_id" UUID REFERENCES "comments"("id") ON DELETE CASCADE, + + -- 时间戳 + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX IF NOT EXISTS "idx_comments_post_id" ON "comments"("post_id"); +CREATE INDEX IF NOT EXISTS "idx_comments_author_id" ON "comments"("author_id"); + +-- ==================== 初始化数据 ==================== + +-- 插入默认分类 +INSERT INTO "categories" ("name", "slug", "description") VALUES + ('动漫资讯', 'anime', '最新动漫新闻、番剧更新、业界动态'), + ('游戏攻略', 'game', '游戏通关指南、角色培养、剧情解析'), + ('二次元美图', 'pictures', '精选壁纸、Cosplay、插画作品'), + ('同人创作', 'fanwork', '同人小说、同人绘画、手办模型') +ON CONFLICT ("slug") DO NOTHING; + +-- 插入默认标签 +INSERT INTO "tags" ("name", "slug") VALUES + ('原神', 'genshin'), + ('崩坏星穹铁道', 'honkai-star-rail'), + ('我的世界', 'minecraft'), + ('EVA', 'evangelion'), + ('约定的梦幻岛', 'neverland'), + ('咒术回战', 'jujutsu-kaisen'), + ('Cosplay', 'cosplay'), + ('手办', 'figure') +ON CONFLICT ("slug") DO NOTHING; + +-- 插入管理员用户 (密码: admin123) +-- 密码哈希基于 bcrypt,使用前请替换为实际哈希值 +INSERT INTO "users" ("username", "email", "password_hash", "is_superuser") VALUES + ('admin', 'admin@acgblog.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.TZND60LMTUBu.K', TRUE) +ON CONFLICT ("username") DO NOTHING; + +-- ==================== 注释 ==================== +COMMENT ON TABLE "users" IS '用户表'; +COMMENT ON TABLE "categories" IS '文章分类表'; +COMMENT ON TABLE "tags" IS '文章标签表'; +COMMENT ON TABLE "posts" IS '文章表'; +COMMENT ON TABLE "post_tags" IS '文章标签关联表'; +COMMENT ON TABLE "comments" IS '评论表'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f90b3b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: '3.8' + +services: + # PostgreSQL 数据库 + postgres: + image: postgres:16-alpine + container_name: acg_blog_db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: acg_blog + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + ports: + - "5432:5432" + networks: + - acg_blog_network + + # Redis 缓存 + redis: + image: redis:7-alpine + container_name: acg_blog_redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - acg_blog_network + + # 后端 API + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: acg_blog_backend + restart: unless-stopped + environment: + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=postgres + - DB_PASSWORD=postgres + - DB_NAME=acg_blog + - REDIS_HOST=redis + - REDIS_PORT=6379 + - SECRET_KEY=your-secret-key-change-in-production + - DEBUG=false + ports: + - "8000:8000" + depends_on: + - postgres + - redis + networks: + - acg_blog_network + + # 前端 Nginx + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: acg_blog_frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + - backend + networks: + - acg_blog_network + +volumes: + postgres_data: + redis_data: + +networks: + acg_blog_network: + driver: bridge diff --git a/docker-deploy.md b/docker-deploy.md new file mode 100644 index 0000000..5687f28 --- /dev/null +++ b/docker-deploy.md @@ -0,0 +1,69 @@ +# Docker 部署指南 + +## 快速启动 + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f +``` + +## 服务地址 + +| 服务 | 地址 | +|------|------| +| 前端 | http://localhost | +| 后端 API | http://localhost:8000 | +| API 文档 | http://localhost:8000/docs | +| PostgreSQL | localhost:5432 | +| Redis | localhost:6379 | + +## 初始化数据库 + +首次启动时,数据库会自动创建表结构和初始数据。 + +管理员账户: +- 用户名: `admin` +- 邮箱: `admin@acgblog.com` +- 密码: `admin123` + +**重要**: 请在部署后修改管理员密码! + +## 常用命令 + +```bash +# 重新构建镜像 +docker-compose build --no-cache + +# 停止所有服务 +docker-compose down + +# 停止并删除数据卷 +docker-compose down -v + +# 进入后端容器 +docker exec -it acg_blog_backend sh + +# 进入数据库 +docker exec -it acg_blog_db psql -U postgres -d acg_blog +``` + +## 生产环境部署 + +1. 修改 `backend/.env.production` 中的配置: + - `SECRET_KEY` - 使用随机字符串 + - `BACKEND_CORS_ORIGINS` - 改为你的域名 + +2. 修改 `docker-compose.yml` 中的端口映射(移除端口暴露,仅通过 nginx 反向代理) + +3. 使用 Nginx 或 Traefik 等反向代理配置 HTTPS + +4. 定期备份数据库: + ```bash + docker exec acg_blog_db pg_dump -U postgres acg_blog > backup.sql + ``` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..96f8570 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +*.md +.vscode diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d1ca399 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Vue 3 Frontend +FROM node:20-alpine AS builder + +WORKDIR /app + +# 复制依赖文件 +COPY package*.json ./ + +# 安装依赖 +RUN npm ci + +# 复制源代码 +COPY . . + +# 构建生产版本 +RUN npm run build + +# ==================== Nginx 运行阶段 ==================== +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 Nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b6b719f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1000; + + # 前端路由(SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # API 代理 + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 静态资源缓存 + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json index 2bcf4d4..b3ec041 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@kangc/v-md-editor": "^2.3.18", "@vueuse/core": "^14.2.1", "axios": "^1.13.6", + "highlight.js": "^11.10.0", "naive-ui": "^2.44.1", "pinia": "^3.0.4", "vue": "^3.5.30", diff --git a/frontend/src/api/category.ts b/frontend/src/api/category.ts new file mode 100644 index 0000000..30a5e8c --- /dev/null +++ b/frontend/src/api/category.ts @@ -0,0 +1,14 @@ +import { http } from './index' +import type { Category } from '@/types' + +export const categoryApi = { + // 获取所有分类 + getAll() { + return http.get('/categories') + }, + + // 获取单个分类 + getDetail(id: string) { + return http.get(`/categories/${id}`) + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3355cd2..cf11d24 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,5 +1,4 @@ -import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' -import type { ApiResponse } from '@/types' +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' // 创建 axios 实例 const request: AxiosInstance = axios.create({ @@ -27,20 +26,22 @@ request.interceptors.request.use( // 响应拦截器 request.interceptors.response.use( - (response: AxiosResponse) => { - return response + (response) => { + // 成功时返回 response.data(即 ApiResponse) + return response.data }, (error: any) => { if (error.response) { - const { status } = error.response + const { status, data } = error.response - // 401 未授权,跳转登录页 + // 401 未授权,清除 token if (status === 401) { localStorage.removeItem('access_token') window.location.href = '/login' } - return Promise.reject(error.response.data) + // 返回错误信息 + return Promise.reject(data) } return Promise.reject(error) @@ -48,21 +49,22 @@ request.interceptors.response.use( ) // 封装请求方法 +// 返回 Promise>,调用者通过 .data 访问实际数据 export const http = { - get(url: string, config?: AxiosRequestConfig): Promise> { - return request.get(url, config) + get(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> { + return request.get(url, config).then(res => res.data) }, - post(url: string, data?: any, config?: AxiosRequestConfig): Promise> { - return request.post(url, data, config) + post(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> { + return request.post(url, data, config).then(res => res.data) }, - put(url: string, data?: any, config?: AxiosRequestConfig): Promise> { - return request.put(url, data, config) + put(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> { + return request.put(url, data, config).then(res => res.data) }, - delete(url: string, config?: AxiosRequestConfig): Promise> { - return request.delete(url, config) + delete(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> { + return request.delete(url, config).then(res => res.data) }, } diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue new file mode 100644 index 0000000..6de9e26 --- /dev/null +++ b/frontend/src/components/MarkdownEditor.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue index eb179f6..39e85e4 100644 --- a/frontend/src/components/Navbar.vue +++ b/frontend/src/components/Navbar.vue @@ -1,14 +1,18 @@