编写后端

This commit is contained in:
2026-03-28 22:18:43 +08:00
parent f2528fbc87
commit f5d26949c4
63 changed files with 1841 additions and 5 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# App module

Binary file not shown.

View File

@@ -0,0 +1 @@
# API module

Binary file not shown.

Binary file not shown.

15
backend/app/api/api.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1 @@
# Endpoints module

View File

@@ -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": "登出成功"}

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# Core module

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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()

View File

@@ -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")

View File

@@ -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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
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()

View File

@@ -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

View File

@@ -0,0 +1 @@
# CRUD module

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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()

View File

@@ -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()

163
backend/app/crud/post.py Normal file
View File

@@ -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()

66
backend/app/crud/tag.py Normal file
View File

@@ -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()

75
backend/app/crud/user.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1 @@
# Models module

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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}"

View File

@@ -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"),)

21
backend/app/models/tag.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# Schemas module

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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="刷新令牌")

View File

@@ -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

115
backend/app/schemas/post.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# Services module

View File

View File

@@ -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"}
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"
)