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/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..fc45964 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..282357f Binary files /dev/null and b/backend/app/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/api.cpython-312.pyc b/backend/app/api/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..bbb1133 Binary files /dev/null and b/backend/app/api/__pycache__/api.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9924643 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..553affb Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc new file mode 100644 index 0000000..e036b18 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc new file mode 100644 index 0000000..318a68c Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc new file mode 100644 index 0000000..9d1e4fa Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4e0132b Binary files /dev/null and b/backend/app/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..45b44d9 Binary files /dev/null and b/backend/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/database.cpython-312.pyc b/backend/app/core/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..539a79a Binary files /dev/null and b/backend/app/core/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/logger.cpython-312.pyc b/backend/app/core/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000..6212234 Binary files /dev/null and b/backend/app/core/__pycache__/logger.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/security.cpython-312.pyc b/backend/app/core/__pycache__/security.cpython-312.pyc new file mode 100644 index 0000000..0adb690 Binary files /dev/null and b/backend/app/core/__pycache__/security.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/crud/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..79186f9 Binary files /dev/null and b/backend/app/crud/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/crud/__pycache__/category.cpython-312.pyc b/backend/app/crud/__pycache__/category.cpython-312.pyc new file mode 100644 index 0000000..ba8cc07 Binary files /dev/null and b/backend/app/crud/__pycache__/category.cpython-312.pyc differ diff --git a/backend/app/crud/__pycache__/comment.cpython-312.pyc b/backend/app/crud/__pycache__/comment.cpython-312.pyc new file mode 100644 index 0000000..7661459 Binary files /dev/null and b/backend/app/crud/__pycache__/comment.cpython-312.pyc differ diff --git a/backend/app/crud/__pycache__/post.cpython-312.pyc b/backend/app/crud/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000..33e33ef Binary files /dev/null and b/backend/app/crud/__pycache__/post.cpython-312.pyc differ diff --git a/backend/app/crud/__pycache__/tag.cpython-312.pyc b/backend/app/crud/__pycache__/tag.cpython-312.pyc new file mode 100644 index 0000000..3afbb15 Binary files /dev/null and b/backend/app/crud/__pycache__/tag.cpython-312.pyc differ diff --git a/backend/app/crud/__pycache__/user.cpython-312.pyc b/backend/app/crud/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..ad533ca Binary files /dev/null and b/backend/app/crud/__pycache__/user.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a5a60ca Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/category.cpython-312.pyc b/backend/app/models/__pycache__/category.cpython-312.pyc new file mode 100644 index 0000000..9582933 Binary files /dev/null and b/backend/app/models/__pycache__/category.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/comment.cpython-312.pyc b/backend/app/models/__pycache__/comment.cpython-312.pyc new file mode 100644 index 0000000..df0babd Binary files /dev/null and b/backend/app/models/__pycache__/comment.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/post.cpython-312.pyc b/backend/app/models/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000..f436491 Binary files /dev/null and b/backend/app/models/__pycache__/post.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/tag.cpython-312.pyc b/backend/app/models/__pycache__/tag.cpython-312.pyc new file mode 100644 index 0000000..308e115 Binary files /dev/null and b/backend/app/models/__pycache__/tag.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/user.cpython-312.pyc b/backend/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..d99bef4 Binary files /dev/null and b/backend/app/models/__pycache__/user.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..27a4d27 Binary files /dev/null and b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/auth.cpython-312.pyc b/backend/app/schemas/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..124b465 Binary files /dev/null and b/backend/app/schemas/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/comment.cpython-312.pyc b/backend/app/schemas/__pycache__/comment.cpython-312.pyc new file mode 100644 index 0000000..95fe3d5 Binary files /dev/null and b/backend/app/schemas/__pycache__/comment.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/post.cpython-312.pyc b/backend/app/schemas/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000..de5d2aa Binary files /dev/null and b/backend/app/schemas/__pycache__/post.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/user.cpython-312.pyc b/backend/app/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..467ec8c Binary files /dev/null and b/backend/app/schemas/__pycache__/user.cpython-312.pyc differ 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/logs/acg_blog_2026-03-28.log b/backend/logs/acg_blog_2026-03-28.log new file mode 100644 index 0000000..e69de29 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" + )