项目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订阅
- 添加暗黑模式
恭喜!完成这个项目,你已经是一个合格的全栈开发者了! 🎉