摘要:本文基于「AI掘金头条」项目第三章新闻模块,结合真实后端代码与配套物料,完整讲解模块化路由、异步 ORM 配置、新闻分类/列表/详情三大接口,以及前后端分离场景下的 CORS 跨域处理。适合有一定 Python 基础、想上手 FastAPI 异步开发的同学。
一、写在前面
「AI掘金头条」是一个仿今日头条的新闻系统。整套工程通常包含:
| 物料 | 作用 |
|---|---|
| 项目后端设计说明文档 | 说明架构、模块划分、数据设计与接口规范 |
| 接口规范文档 | 约定请求方式、参数、响应结构 |
| 数据库 SQL 文件 | 一键建库建表并初始化数据 |
| 前端工程(Vue + Vite + Vant) | 页面结构、组件交互与接口调用 |
本篇只聚焦新闻模块,目标很明确:
- 搭建清晰的后端工程结构
- 用异步 SQLAlchemy 操作 MySQL
- 实现分类、分页列表、详情(含浏览量与相关推荐)
- 解决 Vue 前端与 FastAPI 后端的跨域问题
接口实现可以固定成一条流水线:
模块化路由(对照接口文档)→ 定义模型类(对照数据库表)→ CRUD 封装 → 路由里调用并返回统一 JSON
二、技术栈与工程结构
2.1 技术选型
- 后端框架:FastAPI(异步、自动文档、依赖注入)
- 数据库 :MySQL(
utf8mb4) - ORM :SQLAlchemy(asyncio)+
aiomysql - 前端:Vue 3 + Vite + Vant + Pinia + Axios
- 跨域 :FastAPI
CORSMiddleware
安装异步 ORM 依赖:
pip install "sqlalchemy[asyncio]" aiomysql
2.2 为什么要模块化路由?
如果所有接口都堆在 main.py,用户、新闻、收藏、历史混在一起,项目一大就会「爆炸」。
模块化路由的做法是:每个业务拆成独立路由文件,再在主应用里统一挂载。
优势:
main.py只负责启动和挂载,不堆业务代码- 接口按模块拆分,结构清晰
- 改新闻相关接口只动
routers/news.py,维护成本更低
推荐目录:
toutiao_backend/
├── config/ # 数据库、缓存等配置
├── models/ # SQLAlchemy 模型
├── crud/ # 数据库增删改查封装
├── routers/ # 按模块划分的 API 路由
├── schemas/ # Pydantic 数据校验(可扩展)
├── utils/ # 工具函数
└── main.py # 应用入口
快速上手写法:
# routers/news.py
from fastapi import APIRouter
router = APIRouter(prefix="/api/news", tags=["news"])
@router.get("/categories")
async def get_categories():
return {"msg": "获取分类成功"}
# main.py
from fastapi import FastAPI
from routers import news
app = FastAPI()
app.include_router(news.router)
小问 :如何注册路由?
答 :在 main.py 中调用 app.include_router(news.router)。
三、配置异步 ORM
3.1 建库导入 SQL
项目物料里的 database.sql 已包含表结构与初始化数据。可用 PyCharm Database 插件,或命令行导入:
mysql -u root -p < database.sql
新闻模块主要用到:
news_category:分类表news:新闻表(含标题、内容、封面、作者、浏览量、发布时间等)
整库还会预留 user、user_token、favorite、history 等表,方便后续扩展用户、收藏与浏览历史。
3.2 异步引擎与会话依赖
# config/db_conf.py
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine
# 生产环境请用环境变量,不要把密码写死在代码里
ASYNC_DATABASE_URL = "mysql+aiomysql://root:你的密码@localhost:3306/news_app?charset=utf8mb4"
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
echo=True, # 开发期可打印 SQL
pool_size=10, # 连接池常驻连接数
max_overflow=20 # 允许额外创建的连接数
)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
要点:
- URL 协议必须是
mysql+aiomysql://,才能走异步驱动 get_db用yield做 FastAPI 依赖注入,请求结束自动提交 / 回滚 / 关闭- 常用 ORM 操作:
select()查询、add()新增、update()更新、delete()删除
四、定义数据模型
模型类继承 DeclarativeBase,字段与数据库表一一对应。高频查询字段建议加索引(相当于给表加「目录」):
# models/news.py
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Index, Text, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间"
)
class Category(Base):
__tablename__ = "news_category"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="分类ID")
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="分类名称")
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="排序")
class News(Base):
__tablename__ = "news"
__table_args__ = (
Index("fk_news_category_idx", "category_id"), # 按分类筛列表
Index("idx_publish_time", "publish_time"), # 按发布时间排序
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="新闻ID")
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="新闻标题")
description: Mapped[Optional[str]] = mapped_column(String(500), comment="新闻简介")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="新闻内容")
image: Mapped[Optional[str]] = mapped_column(String(255), comment="封面图片URL")
author: Mapped[Optional[str]] = mapped_column(String(50), comment="作者")
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("news_category.id"), nullable=False)
views: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="浏览量")
publish_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="发布时间")
五、封装 CRUD
把 SQL 沉到 crud 层,路由只负责参数校验和组装响应:
# crud/news.py
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.news import Category, News
async def get_categories(db: AsyncSession, skip: int = 0, limit: int = 100):
stmt = select(Category).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def get_news_list(db: AsyncSession, category_id: int, skip: int = 0, limit: int = 10):
stmt = select(News).where(News.category_id == category_id).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def get_news_count(db: AsyncSession, category_id: int):
stmt = select(func.count(News.id)).where(News.category_id == category_id)
result = await db.execute(stmt)
return result.scalar_one()
async def get_news_detail(db: AsyncSession, news_id: int):
stmt = select(News).where(News.id == news_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def increase_news_views(db: AsyncSession, news_id: int):
stmt = update(News).where(News.id == news_id).values(views=News.views + 1)
result = await db.execute(stmt)
await db.commit()
return result.rowcount > 0 # 是否真的命中更新行
async def get_related_news(db: AsyncSession, news_id: int, category_id: int, limit: int = 5):
stmt = (
select(News)
.where(News.category_id == category_id, News.id != news_id)
.order_by(News.views.desc(), News.publish_time.desc())
.limit(limit)
)
result = await db.execute(stmt)
related = result.scalars().all()
return [{
"id": n.id,
"title": n.title,
"content": n.content,
"image": n.image,
"author": n.author,
"publishTime": n.publish_time,
"categoryId": n.category_id,
"views": n.views,
} for n in related]
常用写法对照:
| 场景 | 写法 |
|---|---|
| 查询 | select(模型) |
| 新增 | session.add(对象) |
| 更新 | update().where().values() |
| 删除 | delete() |
| 排序 | order_by(News.views.desc())(默认升序,desc() 降序) |
| 统计 | select(func.count(...)) |
六、实现三个核心接口
统一响应格式(与接口规范文档一致):
{
"code": 200,
"message": "success",
"data": {}
}
路由前缀统一为 /api/news。
6.1 获取新闻分类
对应接口:GET /api/news/categories
前端首页 Tab(头条、社会、科技......)就是靠它拉取的。
@router.get("/categories")
async def get_categories(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db)
):
categories = await news.get_categories(db, skip, limit)
return {"code": 200, "message": "获取新闻分类成功", "data": categories}
6.2 获取新闻列表(分页)
对应接口:GET /api/news/list
前端传参用驼峰(categoryId、pageSize),后端用 Query(..., alias=...) 映射:
@router.get("/list")
async def get_news_list(
category_id: int = Query(..., alias="categoryId"),
page: int = 1,
page_size: int = Query(10, alias="pageSize", le=100),
db: AsyncSession = Depends(get_db)
):
# 处理分页 → 查列表 → 算总量 → 算 hasMore
offset = (page - 1) * page_size
news_list = await news.get_news_list(db, category_id, offset, page_size)
total = await news.get_news_count(db, category_id)
has_more = (offset + len(news_list)) < total
return {
"code": 200,
"message": "获取新闻列表成功",
"data": {
"list": news_list,
"total": total,
"hasMore": has_more
}
}
分页公式记牢:
- 跳过条数:
offset = (page - 1) * pageSize - 是否还有更多:
(offset + 当前页条数) < total
请求示例:
GET /api/news/list?categoryId=1&page=1&pageSize=10
6.3 获取新闻详情
对应接口:GET /api/news/detail
详情接口一次做三件事:查正文 + 浏览量 +1 + 返回同分类相关新闻。
@router.get("/detail")
async def get_news_detail(
news_id: int = Query(..., alias="id"),
db: AsyncSession = Depends(get_db)
):
news_detail = await news.get_news_detail(db, news_id)
if not news_detail:
raise HTTPException(status_code=404, detail="新闻不存在")
ok = await news.increase_news_views(db, news_detail.id)
if not ok:
raise HTTPException(status_code=404, detail="新闻不存在")
related_news = await news.get_related_news(
db, news_detail.id, news_detail.category_id
)
return {
"code": 200,
"message": "success",
"data": {
"id": news_detail.id,
"title": news_detail.title,
"content": news_detail.content,
"image": news_detail.image,
"author": news_detail.author,
"publishTime": news_detail.publish_time,
"categoryId": news_detail.category_id,
"views": news_detail.views,
"relatedNews": related_news
}
}
相关新闻策略:同分类、排除当前稿件,再按浏览量和发布时间降序取前几条------实现简单,详情页「相关推荐」够用。
七、前后端联调:CORS 跨域
7.1 为什么会跨域?
前端开发服务器一般是 http://localhost:5173,后端是 http://127.0.0.1:8000。
同源三要素:协议、域名、端口。任一不同,浏览器就会拦截跨域请求。
CORS(跨域资源共享)是一种浏览器安全机制:让后端主动告诉浏览器「这个前端允许访问」。
7.2 FastAPI 挂载 CORS 中间件
# main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import news
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 开发可放开;生产请改成具体前端域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(news.router)
7.3 前端怎么对接?
前端 Axios 基础地址示例:
// src/config/api.js
export const apiConfig = {
baseURL: "http://127.0.0.1:8000",
}
Pinia 中请求分类接口示例:
const response = await axios.get(`${apiConfig.baseURL}/api/news/categories`)
if (response.data && response.data.code === 200) {
this.categories = response.data.data
}
首页用 Vant Tabs + List 做分类切换与无限滚动,列表接口则带上 categoryId、page、pageSize。
7.4 本地启动顺序
- 导入
database.sql,确认 MySQL 可用 - 启动后端:
uvicorn main:app --reload --port 8000 - 前端目录执行:
npm run dev - 浏览器打开 Vite 地址,验证分类、列表、详情
FastAPI 自带交互文档:访问 High Performance Web Crawler API - Swagger UI 可直接调试接口。
八、接口速查
| 方法 | 路径 | 主要参数 | 说明 |
|---|---|---|---|
| GET | /api/news/categories |
skip、limit |
分类列表 |
| GET | /api/news/list |
categoryId、page、pageSize |
分类下分页列表 |
| GET | /api/news/detail |
id |
详情 + 浏览量 + 相关新闻 |
认证类接口(用户、收藏、历史)一般在请求头携带:
Authorization: <token值>
令牌由登录接口发放,有效期可按业务设定(例如 7 天)。新闻浏览接口本身可以匿名访问,收藏 / 历史则需要登录态。
九、可继续优化的方向
新闻模块跑通后,可按同一分层继续扩展:
- 用户模块:注册、登录、Token、资料修改(密码用 bcrypt 加密)
- 收藏 / 历史 :关联
user_id与news_id,注意唯一约束避免重复收藏 - Redis 缓存 :如
news:detail:{id}、news:list:{category}:{page}:{size},减轻热点读压力 - Pydantic Schema:统一入参校验与出参序列化,避免路由里手写字典
- 生产 CORS :把
allow_origins=["*"]改成具体前端域名
十、小结
实现一个可联调的新闻模块,关键就四点:
- 模块化路由 :业务拆文件,
include_router统一挂载 - 异步 ORM:引擎 + 会话依赖 + 模型 / CRUD 分层
- 分页与详情逻辑 :
offset/limit+count+hasMore;详情顺带views+1与相关推荐 - CORS:打通 Vue 前端与 FastAPI 后端
掌握这条链路后,再去做用户、收藏、历史,基本是同一套套路的复用。希望这篇实战笔记能帮你更快把「AI掘金头条」新闻模块跑起来。