从零落地「AI掘金头条」新闻模块:FastAPI + SQLAlchemy 异步实战

复制代码
摘要:本文基于「AI掘金头条」项目第三章新闻模块,结合真实后端代码与配套物料,完整讲解模块化路由、异步 ORM 配置、新闻分类/列表/详情三大接口,以及前后端分离场景下的 CORS 跨域处理。适合有一定 Python 基础、想上手 FastAPI 异步开发的同学。

一、写在前面

「AI掘金头条」是一个仿今日头条的新闻系统。整套工程通常包含:

物料 作用
项目后端设计说明文档 说明架构、模块划分、数据设计与接口规范
接口规范文档 约定请求方式、参数、响应结构
数据库 SQL 文件 一键建库建表并初始化数据
前端工程(Vue + Vite + Vant) 页面结构、组件交互与接口调用

本篇只聚焦新闻模块,目标很明确:

  1. 搭建清晰的后端工程结构
  2. 用异步 SQLAlchemy 操作 MySQL
  3. 实现分类、分页列表、详情(含浏览量与相关推荐)
  4. 解决 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,用户、新闻、收藏、历史混在一起,项目一大就会「爆炸」。

模块化路由的做法是:每个业务拆成独立路由文件,再在主应用里统一挂载。

优势:

  1. main.py 只负责启动和挂载,不堆业务代码
  2. 接口按模块拆分,结构清晰
  3. 改新闻相关接口只动 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:新闻表(含标题、内容、封面、作者、浏览量、发布时间等)

整库还会预留 useruser_tokenfavoritehistory 等表,方便后续扩展用户、收藏与浏览历史。

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_dbyield 做 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

前端传参用驼峰(categoryIdpageSize),后端用 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 做分类切换与无限滚动,列表接口则带上 categoryIdpagepageSize

7.4 本地启动顺序

  1. 导入 database.sql,确认 MySQL 可用
  2. 启动后端:uvicorn main:app --reload --port 8000
  3. 前端目录执行:npm run dev
  4. 浏览器打开 Vite 地址,验证分类、列表、详情

FastAPI 自带交互文档:访问 High Performance Web Crawler API - Swagger UI 可直接调试接口。

八、接口速查

方法 路径 主要参数 说明
GET /api/news/categories skiplimit 分类列表
GET /api/news/list categoryIdpagepageSize 分类下分页列表
GET /api/news/detail id 详情 + 浏览量 + 相关新闻

认证类接口(用户、收藏、历史)一般在请求头携带:

复制代码
Authorization: <token值>

令牌由登录接口发放,有效期可按业务设定(例如 7 天)。新闻浏览接口本身可以匿名访问,收藏 / 历史则需要登录态。

九、可继续优化的方向

新闻模块跑通后,可按同一分层继续扩展:

  1. 用户模块:注册、登录、Token、资料修改(密码用 bcrypt 加密)
  2. 收藏 / 历史 :关联 user_idnews_id,注意唯一约束避免重复收藏
  3. Redis 缓存 :如 news:detail:{id}news:list:{category}:{page}:{size},减轻热点读压力
  4. Pydantic Schema:统一入参校验与出参序列化,避免路由里手写字典
  5. 生产 CORS :把 allow_origins=["*"] 改成具体前端域名

十、小结

实现一个可联调的新闻模块,关键就四点:

  1. 模块化路由 :业务拆文件,include_router 统一挂载
  2. 异步 ORM:引擎 + 会话依赖 + 模型 / CRUD 分层
  3. 分页与详情逻辑offset/limit + count + hasMore;详情顺带 views+1 与相关推荐
  4. CORS:打通 Vue 前端与 FastAPI 后端

掌握这条链路后,再去做用户、收藏、历史,基本是同一套套路的复用。希望这篇实战笔记能帮你更快把「AI掘金头条」新闻模块跑起来。

相关推荐
萌动的小火苗2 小时前
1、python基础面试题
java·开发语言·python
sunywz2 小时前
【从零搭建物联网智能充电桩系统】2、自定义二进制协议:设备为什么不用 JSON?
python·物联网·json
小叮当爱咖啡2 小时前
Day3.参数+Prompt三板斧
开发语言·python·prompt
菜冻鱼2 小时前
Python-pandas-索引与筛选
开发语言·笔记·python·numpy·pandas·学习方法
青 春 记 忆3 小时前
LeetCode 53. 最大子数组和|Python 解法详解
python·算法·leetcode
阿童木写作3 小时前
跨境电商图片翻译工具推荐:批量AI翻译+视频字幕+智能抠图
大数据·人工智能·python·音视频
鹿角片ljp3 小时前
Java面试复盘(二):String不可变、字符串常量池与字符串拼接
开发语言·python
讲温控就好了3 小时前
光刻温控的产业链价值:从精度指标到半导体制造竞争力
python·制造
南极星10054 小时前
2026电赛E题有感
python·opencv·电赛