跳转至

项目1 - 个人博客系统

目标:综合运用前后端知识,完成一个完整的博客系统

时间:2-3周

难度:⭐⭐⭐


🎯 项目概述

功能需求

Text Only
用户端:
- 浏览文章列表
- 阅读文章详情
- 按分类/标签筛选
- 搜索文章
- 评论功能

管理端:
- 登录/登出
- 发布文章(Markdown编辑器)
- 编辑/删除文章
- 管理分类和标签
- 查看评论

技术栈

Text Only
前端:
- HTML/CSS/JavaScript
- React或Vue
- Axios(HTTP请求)

后端:
- Python + FastAPI
- PostgreSQL数据库
- SQLAlchemy ORM
- JWT认证

部署:
- Docker
- Nginx

📁 项目结构

Text Only
blog-system/
├── backend/                 # 后端
│   ├── app/
│   │   ├── __init__.py
│   │   ├── main.py         # FastAPI入口
│   │   ├── models.py       # 数据库模型
│   │   ├── schemas.py      # Pydantic模型
│   │   ├── crud.py         # 数据库操作
│   │   ├── auth.py         # 认证相关
│   │   └── routers/        # API路由
│   │       ├── posts.py
│   │       ├── users.py
│   │       └── comments.py
│   ├── requirements.txt
│   └── Dockerfile
├── frontend/                # 前端
│   ├── public/
│   ├── src/
│   │   ├── components/     # 组件
│   │   ├── pages/          # 页面
│   │   ├── services/       # API服务
│   │   ├── store/          # 状态管理
│   │   └── App.js
│   ├── package.json
│   └── Dockerfile
├── docker-compose.yml
└── README.md

📝 开发步骤

第1周:后端开发

Day 1-2:数据库设计

Python
# backend/app/models.py
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Table
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base

# 文章-标签关联表
post_tag = Table(
    'post_tag',
    Base.metadata,
    Column('post_id', Integer, ForeignKey('posts.id')),
    Column('tag_id', Integer, ForeignKey('tags.id'))
)

# 用户模型:存储博客系统的用户信息
class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True, nullable=False)  # 用户名,唯一
    email = Column(String(100), unique=True, nullable=False)    # 邮箱,唯一
    hashed_password = Column(String(255), nullable=False)       # 密码哈希值(不存明文)
    is_admin = Column(Integer, default=0)                       # 是否管理员:0=普通用户,1=管理员
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))  # 注册时间

    posts = relationship("Post", back_populates="author")  # 一对多:用户拥有多篇文章

# 文章模型:博客核心内容
class Post(Base):
    __tablename__ = 'posts'

    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)         # 文章标题
    slug = Column(String(200), unique=True, nullable=False)  # URL友好的短标识
    content = Column(Text, nullable=False)               # 文章正文(Markdown格式)
    summary = Column(String(500))                        # 文章摘要
    cover_image = Column(String(500))                    # 封面图片URL
    is_published = Column(Integer, default=0)            # 发布状态:0=草稿,1=已发布
    view_count = Column(Integer, default=0)              # 浏览次数
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))  # lambda匿名函数:简洁的单行函数
    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

    # 外键关联
    author_id = Column(Integer, ForeignKey('users.id'))       # 作者ID
    category_id = Column(Integer, ForeignKey('categories.id'))  # 分类ID

    # 关系映射
    author = relationship("User", back_populates="posts")
    category = relationship("Category", back_populates="posts")
    tags = relationship("Tag", secondary=post_tag, back_populates="posts")  # 多对多:文章与标签
    comments = relationship("Comment", back_populates="post")  # 一对多:文章的评论

class Category(Base):
    __tablename__ = 'categories'

    id = Column(Integer, primary_key=True)
    name = Column(String(50), unique=True, nullable=False)
    slug = Column(String(50), unique=True, nullable=False)

    posts = relationship("Post", back_populates="category")

class Tag(Base):
    __tablename__ = 'tags'

    id = Column(Integer, primary_key=True)
    name = Column(String(50), unique=True, nullable=False)
    slug = Column(String(50), unique=True, nullable=False)

    posts = relationship("Post", secondary=post_tag, back_populates="tags")

# 评论模型:访客对文章的评论
class Comment(Base):
    __tablename__ = 'comments'

    id = Column(Integer, primary_key=True)
    content = Column(Text, nullable=False)                  # 评论内容
    author_name = Column(String(50), nullable=False)        # 评论者昵称
    author_email = Column(String(100), nullable=False)      # 评论者邮箱
    is_approved = Column(Integer, default=0)                # 审核状态:0=待审核,1=已通过
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

    post_id = Column(Integer, ForeignKey('posts.id'))       # 所属文章ID
    post = relationship("Post", back_populates="comments")

Day 3-4:API开发

Python
# backend/app/routers/posts.py
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
import schemas, crud
from database import get_db

router = APIRouter(prefix="/posts", tags=["posts"])

@router.get("/", response_model=list[schemas.PostList])
def list_posts(
    skip: int = 0,
    limit: int = 10,
    category: str | None = None,
    tag: str | None = None,
    search: str | None = None,
    db: Session = Depends(get_db)
):
    """获取文章列表"""
    posts = crud.get_posts(
        db, skip=skip, limit=limit,
        category=category, tag=tag, search=search
    )
    return posts

@router.get("/{slug}", response_model=schemas.PostDetail)
def get_post(slug: str, db: Session = Depends(get_db)):
    """获取文章详情"""
    post = crud.get_post_by_slug(db, slug=slug)
    if not post:
        raise HTTPException(status_code=404, detail="Post not found")
    # 增加浏览量
    crud.increment_view_count(db, post.id)
    return post

@router.post("/", response_model=schemas.PostDetail)
def create_post(
    post: schemas.PostCreate,
    db: Session = Depends(get_db),
    current_user: schemas.User = Depends(get_current_user)
):
    """创建文章(需要登录)"""
    return crud.create_post(db, post, author_id=current_user.id)

@router.put("/{post_id}", response_model=schemas.PostDetail)
def update_post(
    post_id: int,
    post: schemas.PostUpdate,
    db: Session = Depends(get_db),
    current_user: schemas.User = Depends(get_current_user)
):
    """更新文章"""
    db_post = crud.get_post(db, post_id)
    if not db_post:
        raise HTTPException(status_code=404, detail="Post not found")
    if db_post.author_id != current_user.id and not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Not authorized")
    return crud.update_post(db, post_id, post)

@router.delete("/{post_id}")
def delete_post(
    post_id: int,
    db: Session = Depends(get_db),
    current_user: schemas.User = Depends(get_current_user)
):
    """删除文章"""
    db_post = crud.get_post(db, post_id)
    if not db_post:
        raise HTTPException(status_code=404, detail="Post not found")
    if db_post.author_id != current_user.id and not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Not authorized")
    crud.delete_post(db, post_id)
    return {"message": "Post deleted"}

Day 5-7:认证和测试

Python
# backend/app/auth.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

# JWT配置常量
SECRET_KEY = "your-secret-key"          # 密钥(生产环境应使用环境变量)
ALGORITHM = "HS256"                      # JWT签名算法
ACCESS_TOKEN_EXPIRE_MINUTES = 30         # Token有效期(分钟)

# 密码加密上下文,使用bcrypt算法
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()

def verify_password(plain_password, hashed_password):
    """验证明文密码与哈希值是否匹配"""
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password):
    """将明文密码转换为bcrypt哈希值"""
    return pwd_context.hash(password)

def create_access_token(data: dict):
    """生成JWT访问令牌,包含用户信息和过期时间"""
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})  # 设置过期时间
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):  # async def定义异步函数;用await调用
    """从请求头中提取并验证JWT,返回当前登录用户"""
    token = credentials.credentials
    try:  # try/except捕获异常
        # 解码JWT并提取用户ID
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = payload.get("sub")
        if user_id is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        # 从数据库获取用户信息
        return get_user_by_id(int(user_id))
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

第2周:前端开发

Day 1-2:项目搭建和布局

JSX
// frontend/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './pages/Home';
import PostDetail from './pages/PostDetail';
import AdminLogin from './pages/admin/Login';
import AdminDashboard from './pages/admin/Dashboard';
import './App.css';

function App() {
  return (
    <Router>
      <div className="app">
        <Header />
        <main className="main-content">
          {/* 路由配置:根据URL路径渲染对应页面组件 */}
          <Routes>
            <Route path="/" element={<Home />} />                    {/* 首页:文章列表 */}
            <Route path="/post/:slug" element={<PostDetail />} />    {/* 文章详情页 */}
            <Route path="/admin/login" element={<AdminLogin />} />   {/* 管理员登录 */}
            <Route path="/admin/*" element={<AdminDashboard />} />   {/* 管理后台(需登录) */}
          </Routes>
        </main>
        <Footer />
      </div>
    </Router>
  );
}

export default App;

Day 3-4:文章列表和详情

JSX
// frontend/src/pages/Home.js
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getPosts } from '../services/api';
import './Home.css';

function Home() {
  const [posts, setPosts] = useState([]);      // 文章列表数据
  const [loading, setLoading] = useState(true); // 加载状态
  const [page, setPage] = useState(1);          // 当前页码

  // 页码变化时重新获取文章列表
  useEffect(() => {
    fetchPosts();
  }, [page]);

  // 从后端API获取文章列表
  const fetchPosts = async () => {
    try {
      const response = await getPosts({ page, limit: 10 });
      setPosts(response.data);
    } catch (error) {
      console.error('Failed to fetch posts:', error);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div className="home">
      <div className="posts-grid">
        {posts.map(post => (  // map转换每个元素;filter筛选;reduce累积
          <article key={post.id} className="post-card">
            {post.cover_image && (
              <img src={post.cover_image} alt={post.title} />
            )}
            <div className="post-content">
              <h2>
                <Link to={`/post/${post.slug}`}>{post.title}</Link>
              </h2>
              <p className="post-summary">{post.summary}</p>
              <div className="post-meta">
                <span>{post.author.username}</span>
                <span>{new Date(post.created_at).toLocaleDateString()}</span>
                <span>{post.view_count} 阅读</span>
              </div>
              <div className="post-tags">
                {post.tags.map(tag => (
                  <span key={tag.id} className="tag">{tag.name}</span>
                ))}
              </div>
            </div>
          </article>
        ))}
      </div>

      <div className="pagination">
        <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
          上一页
        </button>
        <span> {page} </span>
        <button onClick={() => setPage(p => p + 1)}>下一页</button>
      </div>
    </div>
  );
}

export default Home;

Day 5-7:管理后台

JSX
// frontend/src/pages/admin/PostEditor.js
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { getPost, createPost, updatePost } from '../../services/api';
import './PostEditor.css';

function PostEditor() {
  const { id } = useParams();           // 从URL获取文章ID(编辑模式)
  const navigate = useNavigate();  // const不可重新赋值;let块级作用域变量
  // 文章表单数据
  const [post, setPost] = useState({  // 解构赋值:从对象/数组提取值
    title: '',
    content: '',
    summary: '',
    category_id: '',
    tags: [],
    is_published: false
  });
  const [preview, setPreview] = useState(false);  // 是否显示Markdown预览

  // 编辑模式下,加载已有文章数据
  useEffect(() => {  // 箭头函数:简洁的函数语法
    if (id) {
      fetchPost();
    }
  }, [id]);

  const fetchPost = async () => {  // async定义异步函数;await等待Promise完成
    const response = await getPost(id);  // await等待异步操作完成
    setPost(response.data);
  };

  // 提交表单:根据是否有ID决定创建或更新
  const handleSubmit = async (e) => {
    e.preventDefault();
    try {  // try/catch捕获异常
      if (id) {
        await updatePost(id, post);    // 更新已有文章
      } else {
        await createPost(post);         // 创建新文章
      }
      navigate('/admin/posts');          // 保存后跳转到文章列表
    } catch (error) {
      alert('保存失败:' + error.message);
    }
  };

  return (
    <div className="post-editor">
      <h1>{id ? '编辑文章' : '新建文章'}</h1>

      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>标题</label>
          <input
            type="text"
            value={post.title}
            onChange={e => setPost({...post, title: e.target.value})}  // ...展开运算符展开数组/对象
            required
          />
        </div>

        <div className="editor-container">
          <div className="editor-pane">
            <label>内容 (Markdown)</label>
            <textarea
              value={post.content}
              onChange={e => setPost({...post, content: e.target.value})}
              rows={20}
              required
            />
          </div>

          {preview && (
            <div className="preview-pane">
              <label>预览</label>
              <div className="markdown-preview">
                <ReactMarkdown>{post.content}</ReactMarkdown>
              </div>
            </div>
          )}
        </div>

        <div className="form-actions">
          <button type="button" onClick={() => setPreview(!preview)}>
            {preview ? '隐藏预览' : '显示预览'}
          </button>
          <button type="submit">保存</button>
        </div>
      </form>
    </div>
  );
}

export default PostEditor;

第3周:部署

Docker配置

Docker
# backend/Dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]  # CMD容器启动时执行的默认命令
Docker
# frontend/Dockerfile
FROM node:20-alpine as build  # FROM指定基础镜像

WORKDIR /app  # WORKDIR设置工作目录
COPY package*.json ./  # COPY将文件复制到镜像中
RUN npm install  # RUN在构建时执行命令
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
YAML
# docker-compose.yml — 博客系统容器编排配置
services:  # services定义各个服务容器
  # 数据库服务:PostgreSQL
  db:
    image: postgres:15
    environment:
      POSTGRES_DB: blog              # 数据库名称
      POSTGRES_USER: bloguser        # 数据库用户名
      POSTGRES_PASSWORD: blogpass    # 数据库密码(生产环境应使用secrets)
    volumes:
      - postgres_data:/var/lib/postgresql/data  # 持久化存储

  # 后端API服务
  backend:
    build: ./backend
    ports:
      - "8000:8000"                  # 映射API端口
    environment:
      DATABASE_URL: postgresql://bloguser:blogpass@db/blog  # 数据库连接串
    depends_on:
      - db                           # 依赖数据库先启动

  # 前端服务(Nginx托管静态文件)
  frontend:
    build: ./frontend
    ports:
      - "80:80"                      # 映射HTTP端口
    depends_on:
      - backend                      # 依赖后端先启动

volumes:
  postgres_data:                     # 声明持久化卷

✅ 项目检查点

完成后检查:

  • 能浏览文章列表和详情
  • 能按分类和标签筛选
  • 能搜索文章
  • 能发表评论
  • 管理员能登录后台
  • 管理员能发布/编辑/删除文章
  • 项目能用Docker部署

加分项:

  • 文章支持代码高亮
  • 实现文章草稿功能
  • 添加网站访问统计
  • 实现RSS订阅
  • 添加暗黑模式

恭喜!完成这个项目,你已经是一个合格的全栈开发者了! 🎉