本文目录导读:
我将为您创建一个基于Python全栈框架(FastAPI + SQLAlchemy + Jinja2)的Markdown博客系统,这个系统包含完整的CRUD功能和Markdown渲染。
# requirements.txt
"""
fastapi==0.104.1
uvicorn==0.24.0
sqlalchemy==2.0.23
python-multipart==0.0.6
jinja2==3.1.2
markdown==3.5.1
pygments==2.17.2
bleach==6.1.0
python-dotenv==1.0.0
aiofiles==23.2.1
"""
# .env
"""
DATABASE_URL=sqlite:///./blog.db
SECRET_KEY=your-secret-key-change-this
"""
# config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str = "sqlite:///./blog.db"
secret_key: str = "default-secret-key"
class Config:
env_file = ".env"
@lru_cache()
def get_settings():
return Settings()
# database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# models.py
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean
from datetime import datetime
from database import Base
from sqlalchemy.sql import func
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True)= Column(String(200), nullable=False)
slug = Column(String(200), unique=True, index=True, nullable=False)
content = Column(Text, nullable=False)
summary = Column(String(500))
tags = Column(String(200))
is_published = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"slug": self.slug,
"content": self.content,
"summary": self.summary,
"tags": self.tags.split(",") if self.tags else [],
"is_published": self.is_published,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
# crud.py
from sqlalchemy.orm import Session
from models import Post
from typing import List, Optional
import re
from datetime import datetime
def create_slug(title: str) -> str:
"""生成URL友好的slug"""
slug = title.lower()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug[:200]
def get_posts(db: Session, skip: int = 0, limit: int = 10, published_only: bool = True) -> List[Post]:
query = db.query(Post)
if published_only:
query = query.filter(Post.is_published == True)
return query.order_by(Post.created_at.desc()).offset(skip).limit(limit).all()
def get_post(db: Session, post_id: int) -> Optional[Post]:
return db.query(Post).filter(Post.id == post_id).first()
def get_post_by_slug(db: Session, slug: str) -> Optional[Post]:
return db.query(Post).filter(Post.slug == slug).first()
def create_post(db: Session, title: str, content: str, summary: str = "", tags: str = "") -> Post:
slug = create_slug(title)
# 确保slug唯一
existing = db.query(Post).filter(Post.slug == slug).first()
if existing:
slug = f"{slug}-{datetime.now().timestamp()}"
db_post = Post(
title=title,
slug=slug,
content=content,
summary=summary,
tags=tags,
is_published=True
)
db.add(db_post)
db.commit()
db.refresh(db_post)
return db_post
def update_post(db: Session, post_id: int, title: str, content: str, summary: str = "", tags: str = "") -> Optional[Post]:
db_post = get_post(db, post_id)
if db_post:
db_post.title = title
db_post.content = content
db_post.summary = summary
db_post.tags = tags
db.commit()
db.refresh(db_post)
return db_post
def delete_post(db: Session, post_id: int) -> bool:
db_post = get_post(db, post_id)
if db_post:
db.delete(db_post)
db.commit()
return True
return False
def search_posts(db: Session, query: str) -> List[Post]:
return db.query(Post).filter(
(Post.title.contains(query)) |
(Post.content.contains(query)) |
(Post.tags.contains(query))
).filter(Post.is_published == True).all()
# main.py
from fastapi import FastAPI, Depends, HTTPException, Request, Form, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from typing import List, Optional
import markdown
import bleach
import os
from database import engine, get_db, Base
from models import Post
from crud import *
# 创建数据库表
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Markdown Blog System")
# 创建必要的目录
os.makedirs("static", exist_ok=True)
os.makedirs("templates", exist_ok=True)
# 挂载静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")
# 模板配置
templates = Jinja2Templates(directory="templates")
# Markdown配置
ALLOWED_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'ul', 'ol', 'li',
'pre', 'code', 'blockquote',
'a', 'img',
'strong', 'em', 'del', 'ins',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span',
'dl', 'dt', 'dd',
'abbr', 'acronym',
'sub', 'sup',
'details', 'summary'
]
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'code': ['class'],
'pre': ['class'],
'table': ['class'],
'th': ['align'],
'td': ['align'],
'div': ['class'],
'span': ['class'],
'abbr': ['title'],
'acronym': ['title']
}
def render_markdown(content: str) -> str:
"""将Markdown转换为安全的HTML"""
md = markdown.Markdown(extensions=[
'extra',
'codehilite',
'toc',
'tables',
'fenced_code',
'sane_lists',
'smarty'
])
html = md.convert(content)
# 清理不安全的HTML
clean_html = bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True
)
return clean_html
# 路由
@app.get("/", response_class=HTMLResponse)
async def home(
request: Request,
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
search: Optional[str] = Query(None)
):
per_page = 10
skip = (page - 1) * per_page
if search:
posts = search_posts(db, search)
total = len(posts)
else:
posts = get_posts(db, skip=skip, limit=per_page)
total = db.query(Post).filter(Post.is_published == True).count()
total_pages = (total + per_page - 1) // per_page
return templates.TemplateResponse(
"index.html",
{
"request": request,
"posts": posts,
"page": page,
"total_pages": total_pages,
"search": search,
"render_markdown": render_markdown
}
)
@app.get("/post/{slug}", response_class=HTMLResponse)
async def view_post(request: Request, slug: str, db: Session = Depends(get_db)):
post = get_post_by_slug(db, slug)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return templates.TemplateResponse(
"post.html",
{
"request": request,
"post": post,
"html_content": render_markdown(post.content)
}
)
@app.get("/admin", response_class=HTMLResponse)
async def admin_panel(request: Request, db: Session = Depends(get_db)):
posts = db.query(Post).order_by(Post.created_at.desc()).all()
return templates.TemplateResponse(
"admin.html",
{
"request": request,
"posts": posts
}
)
@app.get("/admin/new", response_class=HTMLResponse)
async def new_post_form(request: Request):
return templates.TemplateResponse("edit.html", {"request": request, "post": None})
@app.post("/admin/new")
async def create_new_post(
request: Request, str = Form(...),
content: str = Form(...),
summary: str = Form(""),
tags: str = Form(""),
db: Session = Depends(get_db)
):
post = create_post(db, title, content, summary, tags)
return RedirectResponse(url=f"/post/{post.slug}", status_code=303)
@app.get("/admin/edit/{post_id}", response_class=HTMLResponse)
async def edit_post_form(request: Request, post_id: int, db: Session = Depends(get_db)):
post = get_post(db, post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return templates.TemplateResponse("edit.html", {"request": request, "post": post})
@app.post("/admin/edit/{post_id}")
async def update_post_route(
request: Request,
post_id: int, str = Form(...),
content: str = Form(...),
summary: str = Form(""),
tags: str = Form(""),
db: Session = Depends(get_db)
):
post = update_post(db, post_id, title, content, summary, tags)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return RedirectResponse(url=f"/post/{post.slug}", status_code=303)
@app.post("/admin/delete/{post_id}")
async def delete_post_route(post_id: int, db: Session = Depends(get_db)):
if delete_post(db, post_id):
return RedirectResponse(url="/admin", status_code=303)
raise HTTPException(status_code=404, detail="Post not found")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
现在创建模板文件:
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">{% block title %}Markdown Blog{% endblock %}</title>
<style>
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--bg-color: #f8f9fa;
--text-color: #333;
--border-radius: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
}
nav {
background: var(--primary-color);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
nav .container {
display: flex;
justify-content: space-between;
align-items: center;
}
nav a {
color: white;
text-decoration: none;
margin-left: 20px;
}
.nav-links a:hover {
color: var(--secondary-color);
}
.search-form {
display: flex;
gap: 10px;
}
.search-form input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
}
.search-form button {
padding: 8px 16px;
background: var(--secondary-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
}
.card {
background: white;
border-radius: var(--border-radius);
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.post-title {
color: var(--primary-color);
margin-bottom: 10px;
}
.post-title a {
color: var(--primary-color);
text-decoration: none;
}
.post-title a:hover {
color: var(--secondary-color);
}
.post-meta {
color: #666;
font-size: 0.9rem;
margin-bottom: 10px;
}
.post-summary {
margin-bottom: 10px;
}
.tags {
margin-top: 10px;
}
.tag {
display: inline-block;
background: #e8f4f8;
color: var(--secondary-color);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
margin-right: 5px;
}
.read-more {
display: inline-block;
color: var(--secondary-color);
text-decoration: none;
font-weight: 500;
}
.btn {
display: inline-block;
padding: 10px 20px;
background: var(--secondary-color);
color: white;
text-decoration: none;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
font-size: 14px;
}
.btn:hover {
opacity: 0.9;
}
.btn-danger {
background: #e74c3c;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin: 20px 0;
}
.pagination a {
padding: 8px 16px;
background: white;
border: 1px solid #ddd;
border-radius: var(--border-radius);
text-decoration: none;
color: var(--primary-color);
}
.pagination a:hover {
background: var(--secondary-color);
color: white;
}
.content {
margin: 20px 0;
}
.content h1, .content h2, .content h3 {
margin-top: 20px;
margin-bottom: 10px;
}
.content p {
margin-bottom: 10px;
}
.content code {
background: #f4f4f4;
padding: 2px 4px;
border-radius: 3px;
}
.content pre {
background: #f4f4f4;
padding: 15px;
border-radius: var(--border-radius);
overflow-x: auto;
margin: 10px 0;
}
.content blockquote {
border-left: 4px solid var(--secondary-color);
padding-left: 15px;
margin: 10px 0;
color: #666;
}
.content table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
.content th, .content td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.content th {
background: #f4f4f4;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th, .admin-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
}
.form-group textarea {
min-height: 200px;
font-family: monospace;
}
footer {
text-align: center;
padding: 20px;
color: #666;
margin-top: 40px;
}
</style>
</head>
<body>
<nav>
<div class="container">
<a href="/" style="font-size: 1.5rem; font-weight: bold;">📝 Markdown Blog</a>
<div class="nav-links">
<form class="search-form" action="/" method="GET">
<input type="text" name="search" placeholder="搜索文章..." value="{{ search if search else '' }}">
<button type="submit">搜索</button>
</form>
<a href="/admin">管理</a>
</div>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer>
<p>Markdown Blog System © 2024</p>
</footer>
</body>
</html>
<!-- templates/index.html -->
{% extends "base.html" %}
{% block title %}首页 - Markdown Blog{% endblock %}
{% block content %}
<div style="margin: 20px 0;">
{% if search %}
<h2>搜索结果: "{{ search }}"</h2>
{% endif %}
{% if posts %}
{% for post in posts %}
<article class="card">
<h2 class="post-title">
<a href="/post/{{ post.slug }}">{{ post.title }}</a>
</h2>
<div class="post-meta">
<span>📅 {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
{% if post.tags %}
<div class="tags">
{% for tag in post.tags.split(',') %}
<span class="tag">{{ tag.strip() }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% if post.summary %}
<p class="post-summary">{{ post.summary }}</p>
{% endif %}
<a href="/post/{{ post.slug }}" class="read-more">阅读全文 →</a>
</article>
{% endfor %}
{% if not search %}
<div class="pagination">
{% if page > 1 %}
<a href="/?page={{ page - 1 }}">← 上一页</a>
{% endif %}
<span>第 {{ page }} / {{ total_pages }} 页</span>
{% if page < total_pages %}
<a href="/?page={{ page + 1 }}">下一页 →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="card" style="text-align: center; padding: 40px;">
<p>暂无文章</p>
<a href="/admin/new" class="btn" style="margin-top: 20px;">创建第一篇文章</a>
</div>
{% endif %}
</div>
{% endblock %}
<!-- templates/post.html -->
{% extends "base.html" %}
{% block title %}{{ post.title }} - Markdown Blog{% endblock %}
{% block content %}
<article class="card" style="margin-top: 20px;">
<h1 class="post-title">{{ post.title }}</h1>
<div class="post-meta">
<span>📅 {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
{% if post.updated_at %}
<span> | 更新于: {{ post.updated_at.strftime('%Y-%m-%d %H:%M') }}</span>
{% endif %}
{% if post.tags %}
<div class="tags">
{% for tag in post.tags.split(',') %}
<span class="tag">{{ tag.strip() }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="content">
{{ html_content|safe }}
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">
<a href="/" class="btn">← 返回首页</a>
<a href="/admin/edit/{{ post.id }}" class="btn" style="background: #27ae60;">编辑</a>
</div>
</article>
{% endblock %}
<!-- templates/admin.html -->
{% extends "base.html" %}
{% block title %}管理面板 - Markdown Blog{% endblock %}
{% block content %}
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>文章管理</h2>
<a href="/admin/new" class="btn">新建文章</a>
</div>
{% if posts %}
<div class="card">
<table class="admin-table">
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td>
<a href="/post/{{ post.slug }}" style="text-decoration: none; color: var(--primary-color);">
{{ post.title }}
</a>
</td>
<td>
{% if post.is_published %}
<span style="color: #27ae60;">已发布</span>
{% else %}
<span style="color: #e74c3c;">草稿</span>
{% endif %}
</td>
<td>{{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<a href="/admin/edit/{{ post.id }}" class="btn" style="padding: 5px 10px; font-size: 12px;">编辑</a>
<form method="POST" action="/admin/delete/{{ post.id }}" style="display: inline;">
<button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;"
onclick="return confirm('确定要删除这篇文章吗?')">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 40px;">
<p>还没有文章</p>
<a href="/admin/new" class="btn" style="margin-top: 20px;">创建第一篇文章</a>
</div>
{% endif %}
</div>
{% endblock %}
<!-- templates/edit.html -->
{% extends "base.html" %}
{% block title %}{% if post %}编辑文章{% else %}新建文章{% endif %} - Markdown Blog{% endblock %}
{% block content %}
<div style="margin: 20px 0;">
<h2 style="margin-bottom: 20px;">{% if post %}编辑文章{% else %}新建文章{% endif %}</h2>
<div class="card">
<form method="POST" action="/admin/{% if post %}edit/{{ post.id }}{% else %}new{% endif %}">
<div class="form-group">
<label for="title">标题</label>
<input type="text" id="title" name="title" value="{{ post.title if post else '' }}" required>
</div>
<div class="form-group">
<label for="summary">lt;/label>
<input type="text" id="summary" name="summary" value="{{ post.summary if post else '' }}"
placeholder="可选)">
</div>
<div class="form-group">
<label for="tags">标签</label>
<input type="text" id="tags" name="tags" value="{{ post.tags if post else '' }}"
placeholder="用逗号分隔, Python, Web, 教程">
</div>
<div class="form-group">
<label for="content">内容 (Markdown格式)</label>
<textarea id="content" name="content" required placeholder="在此编写 Markdown 内容...">{{ post.content if post else '' }}</textarea>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="btn">{% if post %}更新文章{% else %}发布文章{% endif %}</button>
<a href="/admin" class="btn" style="background: #95a5a6;">取消</a>
</div>
</form>
</div>
</div>
{% endblock %}
运行说明
-
安装依赖:
pip install fastapi uvicorn sqlalchemy jinja2 markdown pygments bleach python-multipart python-dotenv aiofiles pydantic-settings
-
启动服务器:
python main.py
-
访问博客:
- 前台首页:
http://localhost:8000 - 管理面板:
http://localhost:8000/admin
功能特性
- Markdown支持:完整Markdown渲染,支持代码高亮、表格、任务列表等
- 博客管理:完整的CRUD操作(创建、读取、更新、删除)
- SEO友好:URL使用slug,支持分页
- 搜索功能:支持文章标题、内容和标签搜索
- 标签系统:支持文章标签分类
- 安全防护:使用bleach库进行XSS防护
- 响应式设计:适配移动设备
- 文章时间戳:记录创建和更新时间
这个博客系统提供了一个完整的、可直接运行的Markdown博客解决方案,您可以根据需要进一步扩展功能。
标签: Markdown