头条【vue+fastapi 】全栈教学项目系列之《03_分阶段分步实操指引分阶段分步实操指引》

本文是头条【vue+fastapi 】全栈 教学项目系列之《分阶段分步实操指引》
📎 配套开源项目(均已开源,欢迎 Star / Fork)

📖 分阶段分步实操指引

核心理念:一步一操作、一步一效果、一步一验收

零跳跃承诺:每一步都能独立运行验证,不跳过任何细节

预计时间:30-40小时(按每天2-3小时,约2-3周)


📚 本文档使用说明

如何使用本文档

复制代码
每个功能模块的格式:

┌─────────────────────────────────────────────┐
│  🎯 第X节:[功能名称]                        │
│  ─────────────────────────────────────────  │
│  📌 学习目标:本节要学会什么                  │
│  👁️ 效果预览:做完后是什么样子               │
│  ⏱️ 预计用时:大概需要多长时间                │
│                                             │
│  📝 操作步骤:                               │
│    步骤1:...                                │
│    步骤2:...                                │
│    ...                                       │
│                                             │
│  💻 完整代码:(可直接复制)                  │
│                                             │
│  🔍 逐行讲解:代码为什么这样写                │
│                                             │
│  ✅ 验收标准:如何确认做对了                  │
│                                             │
│  ⚠️ 易错点:新手容易犯的错误                  │
└─────────────────────────────────────────────┘

准备工作

在开始之前,请确保:

  • 已完成环境搭建(参考《02_环境搭建手册》)
  • 已创建项目数据库 toutiao_db
  • VS Code已打开项目文件夹

🗂️ 第一部分:后端开发(FastAPI)


🎯 第1节:创建后端项目结构

📌 学习目标

  • 理解后端项目的目录组织方式
  • 创建标准的FastAPI项目骨架
  • 能运行第一个Hello World程序

👁️ 效果预览

访问 http://localhost:8000/docs 显示Swagger API文档页面

⏱️ 预计用时:30分钟

📝 操作步骤

步骤1:创建项目根目录

Windows用户

bash 复制代码
# 打开CMD,切换到你想放项目的位置(比如D盘)
cd D:\

# 创建项目根目录
mkdir toutiao_project
cd toutiao_project

macOS用户

bash 复制代码
# 打开终端,切换到 home 目录
cd ~

# 创建项目根目录
mkdir toutiao_project
cd toutiao_project
步骤2:创建后端目录结构

执行以下命令创建所有必需的文件夹:

bash 复制代码
# Windows (CMD) 或 macOS (Terminal) 都适用

# 进入后端目录
mkdir backend
cd backend

# 创建子目录
mkdir config
mkdir models
mkdir schemas
mkdir crud
mkdir routers
mkdir utils

创建完成后,你的目录结构应该是这样的:

复制代码
toutiao_project/
└── backend/
    ├── config/      # 配置文件(数据库、Redis等)
    ├── models/      # 数据库模型
    ├── schemas/     # 数据验证模型
    ├── crud/        # 数据库操作
    ├── routers/     # API路由
    └── utils/       # 工具函数
步骤3:创建主程序文件 main.py

backend 文件夹中新建文件 main.py

VS Code操作方法

  1. 在左侧文件列表中,右键点击 backend 文件夹
  2. 选择 New File
  3. 输入文件名 main.py,回车
步骤4:编写第一个FastAPI程序

将以下代码完整复制到 main.py 中:

python 复制代码
"""
新闻头条后端服务 - 主程序入口
这是整个后端的启动文件,负责:
1. 创建FastAPI应用实例
2. 配置跨域访问(CORS)
3. 注册所有路由
4. 启动服务器
"""

# 导入FastAPI框架
from fastapi import FastAPI

# 导入中间件:用于处理跨域请求
from fastapi.middleware.cors import CORSMiddleware

# 创建FastAPI应用实例
# title: API文档标题
# description: API文档描述
# version: 版本号
app = FastAPI(
    title="新闻头条API",
    description="仿今日头条新闻资讯平台后端接口",
    version="1.0.0"
)

# 配置CORS(跨域资源共享)
# 什么是CORS?前端(localhost:5173)要访问后端(localhost:8000),
# 浏览器默认禁止这种跨域请求。我们需要配置允许。
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],           # 允许所有来源(开发阶段)
    allow_credentials=True,        # 允许携带Cookie
    allow_methods=["*"],           # 允许所有HTTP方法(GET/POST/PUT/DELETE等)
    allow_headers=["*"],           # 允许所有请求头
)

# 定义一个简单的测试接口
@app.get("/")
async def root():
    """
    根路径接口 - 用于测试服务器是否正常运行
    访问 http://localhost:8000/ 会返回这个消息
    """
    return {"message": "新闻头条API服务正在运行"}

# 定义健康检查接口
@app.get("/health")
async def health_check():
    """
    健康检查接口 - 用于监控服务状态
    返回服务状态信息
    """
    return {
        "status": "ok",
        "service": "toutiao-api",
        "version": "1.0.0"
    }


# ====== 程序入口 ======
if __name__ == "__main__":
    """
    当直接运行这个文件时(python main.py),启动服务器
    
    host: 监听地址,0.0.0.0表示所有网络接口都可以访问
    port: 端口号,8000是FastAPI的常用端口
    reload: 开启热重载,修改代码后自动重启服务器(开发时很有用!)
    """
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
步骤5:安装Python依赖包

backend 目录下创建 requirements.txt 文件:

txt 复制代码
# FastAPI核心框架
fastapi==0.138.0

# ASGI服务器(用于运行FastAPI应用)
uvicorn[standard]==0.48.0

# 跨域支持
starlette>=0.45.0

# Pydantic数据验证
pydantic==2.13.4
pydantic-settings==2.8.1

# SQLAlchemy ORM
sqlalchemy==2.0.51

# MySQL异步驱动
aiomysql==0.3.2

# Redis客户端
redis==5.2.1

# 密码加密
passlib[bcrypt]==1.7.4
bcrypt==5.0.0

# JWT认证
python-jose[cryptography]==3.3.0
python-multipart==0.0.20

# OpenAI SDK(AI功能用)
openai==2.48.0

# 时间处理
python-dateutil==2.9.0.post0

安装依赖包:

bash 复制代码
# 确保在backend目录下
cd backend

# 安装所有依赖
pip install -r requirements.txt

# 如果速度慢,使用国内镜像源:
# pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
步骤6:启动服务器测试
bash 复制代码
# 在backend目录下执行
python main.py

看到以下输出就说明成功了:

复制代码
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [xxxxx]
INFO:     Started server process [xxxxx]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

🔍 逐行讲解

代码 解释
from fastapi import FastAPI 从fastapi包导入主类FastAPI
app = FastAPI(...) 创建应用实例,相当于"创建一个Web服务器"
app.add_middleware(...) 添加中间件,这里配置了CORS跨域支持
@app.get("/") 装饰器,定义一个GET类型的API接口,路径是"/"
async def root(): 异步函数,处理请求并返回响应
uvicorn.run(app, ...) 启动服务器,监听8000端口

✅ 验收标准

  • 执行 python main.py 不报错
  • 终端显示 Running on http://0.0.0.0:8000
  • 浏览器访问 http://localhost:8000 显示 {"message":"新闻头条API服务正在运行"}
  • 浏览器访问 http://localhost:8000/docs 显示Swagger文档界面
  • 浏览器访问 http://localhost:8000/health 显示状态信息

⚠️ 易错点

错误现象 原因 解决方法
ModuleNotFoundError: No module named 'fastapi' 没有安装依赖 先执行 pip install -r requirements.txt
Address already in use 8000端口被占用 关闭占用端口的程序,或改用其他端口
PermissionError 权限不足(macOS) 不需要sudo,检查Python环境

🎯 第2节:配置数据库连接

📌 学习目标

  • 理解什么是ORM(对象关系映射)
  • 配置MySQL数据库连接
  • 实现异步数据库会话管理

👁️ 效果预览

程序能成功连接到MySQL数据库,并自动创建表结构

⏱️ 预计用时:1小时

📝 操作步骤

步骤1:创建数据库配置文件

config/ 目录下创建 db_conf.py

python 复制代码
"""
数据库配置模块
职责:
1. 定义数据库连接参数
2. 创建数据库引擎
3. 提供数据库会话(Session)
"""

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import os

# ====== 数据库连接配置 ======
# 这些值可以从环境变量读取,也可以写死(开发阶段)

# 数据库主机地址(本地就是localhost)
DB_HOST = os.getenv("DB_HOST", "localhost")

# 数据库端口(MySQL默认3306)
DB_PORT = os.getenv("DB_PORT", "3306")

# 数据库用户名(通常是root)
DB_USER = os.getenv("DB_USER", "root")

# 数据库密码(你安装MySQL时设置的密码)
DB_PASSWORD = os.getenv("DB_PASSWORD", "123456")

# 数据库名称(我们之前创建的)
DB_NAME = os.getenv("DB_NAME", "toutiao_db")

# 拼接完整的数据库URL
# 格式:mysql+aiomysql://用户名:密码@主机:端口/数据库名
DATABASE_URL = f"mysql+aiomysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"

# ====== 创建数据库引擎 ======
# 引擎是连接池的管理者,负责与数据库建立连接
# echo=True 会打印SQL语句,方便调试(生产环境建议关闭)
engine = create_async_engine(
    DATABASE_URL,
    echo=True,  # 开发阶段打印SQL,方便学习
    pool_size=10,  # 连接池大小
    max_overflow=20,  # 超出连接池大小后最多可以创建20个连接
    pool_recycle=3600,  # 连接回收时间(秒),防止MySQL断开空闲连接
)

# ====== 创建会话工厂 ======
# Session用于执行数据库操作(增删改查)
AsyncSessionLocal = sessionmaker(
    engine,
    class_=AsyncSession,  # 使用异步Session
    expire_on_commit=False,  # 提交后不过期对象属性
    autocommit=False,  # 不自动提交
    autoflush=False,  # 不自动刷新
)


# ====== 声明式基类 ======
# 所有数据库模型都要继承这个基类
class Base(DeclarativeBase):
    """
    SQLAlchemy声明式基类
    所有Model(模型)都继承自它
    它提供了元数据和表创建功能
    """
    pass


# ====== 获取数据库会话的依赖函数 ======
async def get_db():
    """
    生成器函数:提供数据库会话
    
    用法:在路由函数的参数中使用 Depends(get_db)
    
    示例:
        @app.get("/users")
        async def get_users(db: AsyncSession = Depends(get_db)):
            ...
    
    这个函数会:
    1. 创建一个新的数据库会话
    2. 把会话传递给路由函数使用
    3. 路由函数执行完毕后自动关闭会话
    """
    db = AsyncSessionLocal()
    try:
        yield db  # 产出会话给调用方使用
    finally:
        await db.close()  # 最终关闭会话,释放资源


# ====== 初始化数据库表的函数 ======
async def init_db():
    """
    创建所有数据库表
    在应用启动时调用一次即可
    """
    async with engine.begin() as conn:
        # metadata.create_all() 会根据所有继承Base的模型创建对应的表
        await conn.run_sync(Base.metadata.create_all)
步骤2:更新 main.py 支持数据库初始化

修改 main.py,添加启动事件:

python 复制代码
"""
新闻头条后端服务 - 主程序入口
"""

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager

# 导入数据库配置
from config.db_conf import engine, Base

# ====== 应用生命周期管理 ======
@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    应用生命周期管理器
    处理应用启动和关闭时的事件
    
    启动时:初始化数据库表
    关闭时:清理资源(如关闭连接池)
    """
    # ========== 应用启动时执行 ==========
    print("🚀 正在启动服务...")
    
    # 初始化数据库表
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
        print("✅ 数据库表创建/检查完成")
    
    yield  # 这里等待应用运行
    
    # ========== 应用关闭时执行 ==========
    print("👋 服务正在关闭...")
    await engine.dispose()  # 关闭数据库连接池
    print("✅ 资源清理完成")


# 创建FastAPI应用实例(传入生命周期管理器)
app = FastAPI(
    title="新闻头条API",
    description="仿今日头条新闻资讯平台后端接口",
    version="1.0.0",
    lifespan=lifespan,  # 注册生命周期管理
)

# 配置CORS(跨域资源共享)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# 测试接口
@app.get("/")
async def root():
    return {"message": "新闻头条API服务正在运行", "status": "ok"}


@app.get("/health")
async def health_check():
    return {"status": "ok", "service": "toutiao-api", "version": "1.0.0"}


# 程序入口
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
步骤3:测试数据库连接

重新启动服务器:

bash 复制代码
python main.py

观察终端输出,应该能看到:

复制代码
🚀 正在启动服务...
INFO:     CREATE TABLE ... (一堆建表语句)
✅ 数据库表创建/检查完成
INFO:     Application startup complete.

注意:现在还没有定义任何模型,所以不会创建实际的表。下一节我们会创建用户模型。

🔍 逐行讲解

代码 解释
create_async_engine(...) 创建异步数据库引擎,用于管理数据库连接
AsyncSessionLocal = sessionmaker(...) 创建会话工厂,每次需要操作数据库时就创建一个Session
async def get_db() 异步生成器,为每个请求提供独立的数据库会话
yield db Python语法,临时把db"借"给调用者,用完自动收回
@asynccontextmanager 生命周期管理器,在应用启动/关闭时执行特定代码
Base.metadata.create_all 根据模型定义自动创建数据库表

✅ 验收标准

  • 启动时不报错
  • 终端显示 ✅ 数据库表创建/检查完成
  • 访问 http://localhost:8000/health 返回正常

⚠️ 易错点

错误现象 原因 解决方法
Access denied for user 'root' MySQL密码错误 检查 .env 或代码中的 DB_PASSWORD
Unknown database 'toutiao_db' 数据库没创建 先登录MySQL执行 CREATE DATABASE toutiao_db
Can't connect to MySQL server MySQL服务没启动 启动MySQL服务

🎯 第3节:实现用户注册功能

📌 学习目标

  • 设计用户数据表结构
  • 使用Pydantic进行数据验证
  • 实现用户注册API接口

👁️ 效果预览

通过POST请求 /api/user/register 能成功注册新用户,数据库user表新增一条记录

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:创建用户数据模型

models/ 目录下创建 users.py

python 复制代码
"""
用户数据模型
定义user表的结构(有哪些字段、字段类型、约束等)
"""

# 导入我们在db_conf.py中定义的基类
from config.db_conf import Base
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean
from sqlalchemy.sql import func


class User(Base):
    """
    用户模型 - 对应数据库中的 user 表
    
    每个属性对应表中的一个列(column)
    """
    __tablename__ = "user"  # 对应的数据库表名
    
    # 主键ID,自增长
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    
    # 用户名,唯一(不能重复),最大长度50字符
    username = Column(String(50), unique=True, nullable=False, comment="用户名")
    
    # 加密后的密码(明文密码会被hash后再存储)
    hashed_password = Column(String(255), nullable=False, comment="加密密码")
    
    # 昵称(可以重复)
    nickname = Column(String(100), nullable=True, comment="昵称")
    
    # 头像URL地址
    avatar_url = Column(String(500), nullable=True, comment="头像地址")
    
    # 手机号
    phone = Column(String(20), nullable=True, comment="手机号")
    
    # 个人简介
    bio = Column(Text, nullable=True, comment="个人简介")
    
    # 是否激活(可用于封禁用户)
    is_active = Column(Boolean, default=True, comment="是否激活")
    
    # 创建时间,默认为当前时间
    created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
    
    # 更新时间,每次修改记录时自动更新
    updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")


class UserToken(Base):
    """
    用户Token模型 - 存储用户的登录令牌
    
    为什么需要这个表?
    - 可以让用户在多设备同时登录
    - 可以强制让某设备下线(删除对应token)
    - 可以查看用户的登录历史
    """
    __tablename__ = "user_token"
    
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    
    # 关联的用户ID
    user_id = Column(Integer, nullable=False, comment="用户ID")
    
    # Token字符串(JWT令牌)
    token = Column(String(500), unique=True, nullable=False, comment="令牌")
    
    # 过期时间
    expire_time = Column(DateTime, nullable=False, comment="过期时间")
    
    # 创建时间
    created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
步骤2:创建用户Schema(数据验证)

schemas/ 目录下创建 users.py

python 复制代码
"""
用户相关的Pydantic Schema(数据验证模型)
用途:
1. 验证前端提交的数据是否符合要求
2. 自动生成API文档中的请求/响应示例
3. 数据类型转换和格式化
"""

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional


# ====== 请求模型(接收前端数据)======

class UserRegisterRequest(BaseModel):
    """
    用户注册请求模型
    前端注册时必须提交这些字段
    """
    username: str = Field(
        ..., 
        min_length=3, 
        max_length=50, 
        description="用户名,3-50个字符"
    )
    password: str = Field(
        ..., 
        min_length=6, 
        max_length=100, 
        description="密码,6-100个字符"
    )
    phone: Optional[str] = Field(
        None, 
        pattern=r"^1[3-9]\d{9}$", 
        description="手机号(可选)"
    )


class UserLoginRequest(BaseModel):
    """
    用户登录请求模型
    """
    username: str = Field(..., description="用户名")
    password: str = Field(..., description="密码")


class UserUpdateRequest(BaseModel):
    """
    用户信息更新请求模型
    所有字段都是可选的,只更新提交的字段
    """
    nickname: Optional[str] = Field(None, max_length=100, description="昵称")
    avatar_url: Optional[str] = Field(None, max_length=500, description="头像URL")
    phone: Optional[str] = Field(None, description="手机号")
    bio: Optional[str] = Field(None, description="个人简介")


class ChangePasswordRequest(BaseModel):
    """
    修改密码请求模型
    """
    old_password: str = Field(..., description="旧密码")
    new_password: str = Field(..., min_length=6, max_length=100, description="新密码")


# ====== 响应模型(返回给前端的数据)======

class UserResponse(BaseModel):
    """
    用户信息响应模型
    注意:绝不返回密码字段!
    """
    id: int
    username: str
    nickname: Optional[str] = None
    avatar_url: Optional[str] = None
    phone: Optional[str] = None
    bio: Optional[str] = None
    is_active: bool = True
    created_at: datetime
    
    class Config:
        """配置:支持从ORM模型转换"""
        from_attributes = True


class LoginResponse(BaseModel):
    """
    登录成功响应模型
    包含用户信息和Token
    """
    access_token: str  # JWT令牌
    token_type: str = "bearer"  # 令牌类型
    user: UserResponse  # 用户信息


class UserInfoResponse(BaseModel):
    """
    获取用户信息响应
    """
    code: int = 0
    message: str = "success"
    data: UserResponse
步骤3:创建密码加密工具

utils/ 目录下创建 security.py

python 复制代码
"""
密码加密工具
职责:
1. 将明文密码加密为哈希值
2. 验证明文密码是否匹配哈希值

为什么不能存明文密码?
- 数据库泄露时,黑客直接拿到所有密码
- 很多用户多个网站用相同密码
- 加密后即使泄露,也无法还原出原始密码
"""

# passlib是一个密码哈希库,支持多种加密算法
from passlib.context import CryptContext

# 创建密码上下文
# schemes: 使用的加密算法(bcrypt是目前最安全的之一)
# deprecated: 是否弃用("auto"表示自动处理旧版本)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    """
    加密密码
    输入:明文密码(如 "123456")
    输出:哈希字符串(如 "$2b$12$xxxxxxxxxxxx...")
    
    特点:
    - 同一密码每次加密结果不同(加盐机制)
    - 无法逆向解密(单向哈希)
    """
    return pwd_context.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    验证密码
    输入:明文密码 + 数据库中存储的哈希密码
    输出:True(匹配)/ False(不匹配)
    """
    return pwd_context.verify(plain_password, hashed_password)
步骤4:创建用户CRUD操作

crud/ 目录下创建 users.py

python 复制代码
"""
用户CRUD操作(Create, Read, Update, Delete)
职责:封装所有对user表的数据库操作
"""

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models.users import User, UserToken
from utils.security import hash_password, verify_password
from datetime import datetime, timedelta
from typing import Optional


async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
    """
    根据用户名查询用户
    返回User对象或None(如果不存在)
    """
    result = await db.execute(
        select(User).where(User.username == username)
    )
    return result.scalar_one_or_none()


async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
    """
    根据ID查询用户
    """
    result = await db.execute(
        select(User).where(User.id == user_id)
    )
    return result.scalar_one_or_none()


async def create_user(db: AsyncSession, username: str, password: str, phone: str = None) -> User:
    """
    创建新用户(注册)
    
    流程:
    1. 将明文密码加密
    2. 创建User对象
    3. 保存到数据库
    4. 返回创建的用户对象
    """
    # 1. 加密密码(绝对不能存明文!)
    hashed = hash_password(password)
    
    # 2. 创建用户对象
    db_user = User(
        username=username,
        hashed_password=hashed,
        phone=phone,
        nickname=username,  # 默认昵称等于用户名
    )
    
    # 3. 添加到会话并提交
    db.add(db_user)
    await db.commit()
    await db.refresh(db_user)  # 刷新以获取数据库生成的id和created_at
    
    return db_user


async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[User]:
    """
    用户认证(登录验证)
    
    流程:
    1. 根据用户名查找用户
    2. 如果用户不存在,返回None
    3. 如果存在,验证密码
    4. 密码正确则返回用户对象
    """
    # 1. 查找用户
    user = await get_user_by_username(db, username)
    
    # 2. 用户不存在
    if not user:
        return None
    
    # 3. 验证密码
    if not verify_password(password, user.hashed_password):
        return None
    
    # 4. 验证通过,返回用户
    return user


async def update_user(db: AsyncSession, user_id: int, **kwargs) -> Optional[User]:
    """
    更新用户信息
    kwargs: 要更新的字段,如 nickname="新昵称"
    """
    user = await get_user_by_id(db, user_id)
    if not user:
        return None
    
    # 更新指定字段
    for key, value in kwargs.items():
        if hasattr(user, key) and value is not None:
            setattr(user, key, value)
    
    user.updated_at = datetime.now()
    await db.commit()
    await db.refresh(user)
    
    return user


async def change_password(db: AsyncSession, user_id: int, old_password: str, new_password: str) -> bool:
    """
    修改密码
    返回:True(成功)/ False(旧密码错误)
    """
    user = await get_user_by_id(db, user_id)
    if not user:
        return False
    
    # 验证旧密码
    if not verify_password(old_password, user.hashed_password):
        return False
    
    # 设置新密码(加密后存储)
    user.hashed_password = hash_password(new_password)
    user.updated_at = datetime.now()
    await db.commit()
    
    return True
步骤5:创建用户路由

routers/ 目录下创建 users.py

python 复制代码
"""
用户相关API路由
包含:注册、登录、获取信息、更新信息、修改密码
"""

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional

# 导入我们之前创建的模块
from config.db_conf import get_db
from schemas.users import (
    UserRegisterRequest, UserLoginRequest, UserUpdateRequest,
    ChangePasswordRequest, UserResponse, LoginResponse, UserInfoResponse
)
from crud.users import (
    get_user_by_username, get_user_by_id, create_user,
    authenticate_user, update_user, change_password
)

# 创建路由器
# prefix: URL前缀,所有路由都会加上 /api/user
# tags: Swagger文档中的分组标签
router = APIRouter(prefix="/api/user", tags=["用户管理"])


# ====== 用户注册 ======

@router.post("/register", response_model=UserInfoResponse)
async def register(
    request: UserRegisterRequest,
    db: AsyncSession = Depends(get_db)
):
    """
    用户注册接口
    
    请求方式:POST
    请求路径:/api/user/register
    请求体:
    {
        "username": "zhangsan",   // 用户名(必填,3-50字符)
        "password": "123456",     // 密码(必填,6-100字符)
        "phone": "13800138000"    // 手机号(可选)
    }
    
    成功响应:
    {
        "code": 0,
        "message": "success",
        "data": { 用户信息对象 }
    }
    """
    # 1. 检查用户名是否已存在
    existing_user = await get_user_by_username(db, request.username)
    if existing_user:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="用户名已被注册"
        )
    
    # 2. 创建新用户
    user = await create_user(
        db,
        username=request.username,
        password=request.password,
        phone=request.phone
    )
    
    # 3. 返回成功响应
    return UserInfoResponse(
        code=0,
        message="注册成功",
        data=UserResponse.model_validate(user)
    )


# ====== 用户登录 ======

@router.post("/login", response_model=LoginResponse)
async def login(
    request: UserLoginRequest,
    db: AsyncSession = Depends(get_db)
):
    """
    用户登录接口
    
    请求方式:POST
    请求路径:/api/user/login
    请求体:
    {
        "username": "zhangsan",
        "password": "123456"
    }
    
    成功响应:
    {
        "access_token": "eyJhbGciOi...",  // JWT令牌
        "token_type": "bearer",
        "user": { 用户信息 }
    }
    """
    # 1. 验证用户名和密码
    user = await authenticate_user(db, request.username, request.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误"
        )
    
    # 2. 生成JWT Token(暂时用简单方式,后面完善)
    # TODO: 下一节实现完整的JWT认证
    token = f"simple_token_for_{user.id}"
    
    # 3. 返回登录成功响应
    return LoginResponse(
        access_token=token,
        user=UserResponse.model_validate(user)
    )


# ====== 获取当前用户信息 ======

@router.get("/info", response_model=UserInfoResponse)
async def get_user_info(
    user_id: int = 1,  # TODO: 后面从JWT中获取
    db: AsyncSession = Depends(get_db)
):
    """
    获取当前登录用户的信息
    
    请求方式:GET
    请求路径:/api/user/info
    需要认证:是(Header中携带Token)
    """
    user = await get_user_by_id(db, user_id)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="用户不存在"
        )
    
    return UserInfoResponse(
        code=0,
        message="success",
        data=UserResponse.model_validate(user)
    )


# ====== 更新用户信息 ======

@router.put("/update", response_model=UserInfoResponse)
async def update_user_info(
    request: UserUpdateRequest,
    user_id: int = 1,  # TODO: 后面从JWT中获取
    db: AsyncSession = Depends(get_db)
):
    """
    更新用户个人信息
    
    请求方式:PUT
    请求路径:/api/user/update
    请求体(所有字段可选):
    {
        "nickname": "新昵称",
        "avatar_url": "https://...",
        "phone": "13800138000",
        "bio": "这是我的简介"
    }
    """
    user = await update_user(
        db,
        user_id=user_id,
        **request.model_dump(exclude_unset=True)
    )
    
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="用户不存在"
        )
    
    return UserInfoResponse(
        code=0,
        message="更新成功",
        data=UserResponse.model_validate(user)
    )


# ====== 修改密码 ======

@router.post("/password")
async def change_pwd(
    request: ChangePasswordRequest,
    user_id: int = 1,  # TODO: 后面从JWT中获取
    db: AsyncSession = Depends(get_db)
):
    """
    修改密码接口
    
    请求方式:POST
    请求路径:/api/user/password
    请求体:
    {
        "old_password": "旧密码",
        "new_password": "新密码"
    }
    """
    success = await change_password(
        db,
        user_id=user_id,
        old_password=request.old_password,
        new_password=request.new_password
    )
    
    if not success:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="旧密码错误"
        )
    
    return {"code": 0, "message": "密码修改成功"}
步骤6:注册路由到主应用

更新 main.py,导入并注册用户路由:

python 复制代码
# 在文件顶部添加导入
from routers.users import router as user_router

# 在 lifespan 函数之后、测试接口之前添加:
# 注册用户路由
app.include_router(user_router)
步骤7:测试注册接口
  1. 重启后端服务:python main.py
  2. 打开浏览器访问:http://localhost:8000/docs
  3. 找到 POST /api/user/register 接口
  4. 点击 Try it outExecute
  5. 输入测试数据:
json 复制代码
{
  "username": "testuser",
  "password": "123456",
  "phone": "13800138000"
}
  1. 点击执行,应该返回:
json 复制代码
{
  "code": 0,
  "message": "注册成功",
  "data": {
    "id": 1,
    "username": "testuser",
    "nickname": "testuser",
    ...
  }
}

🔍 逐行讲解

关键概念解释

概念 解释 类比
Model(模型) 对应数据库的一张表 Excel表格的定义(有哪些列)
Schema(模式) 验证数据的格式和规则 表单验证规则(必填、长度等)
CRUD Create创建、Read读取、Update更新、Delete删除 增删改查
Router(路由) 定义URL和处理函数的映射 门牌号和房间的对应关系

✅ 验收标准

  • 启动服务后终端显示建表语句(创建了user表和user_token表)
  • 访问 /docs 能看到用户管理的5个接口
  • POST /api/user/register 能注册新用户
  • 重复注册同一用户名返回"用户名已被注册"
  • 登录MySQL查看数据:SELECT * FROM user; 能看到注册的用户

⚠️ 易错点

错误现象 原因 解决方法
Table 'user' already exists 重复创建表 忽略即可,表已存在不会覆盖数据
1146: Table doesn't exist 模型没被导入 确保 models/users.py 被 import 了
密码字段是明文 没有调用hash_password 检查CRUD代码是否调用了加密函数

🎯 第4节:实现JWT认证系统

📌 学习目标

  • 理解JWT(JSON Web Token)认证原理
  • 实现Token生成和验证
  • 保护需要登录才能访问的接口

👁️ 效果预览

登录后获得Token,携带Token才能访问受保护的接口

⏱️ 预计用时:1.5小时

📝 操作步骤

步骤1:创建JWT工具

utils/ 目录下创建 auth.py

python 复制代码
"""
JWT认证工具
职责:
1. 生成JWT Token(用户登录成功后)
2. 验证JWT Token(用户访问受保护接口时)

什么是JWT?
JWT (JSON Web Token) 是一种轻量级的身份认证方案
由三部分组成:Header.Payload.Signature

示例:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
"""

from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional

# 导入数据库配置和用户CRUD
from config.db_conf import get_db
from crud.users import get_user_by_id

# ====== JWT配置 ======
# 密钥(生产环境应该从环境变量读取,并且足够复杂)
SECRET_KEY = "your-secret-key-change-in-production-2024"

# 加密算法
ALGORITHM = "HS256"

# Token过期时间(24小时)
ACCESS_TOKEN_EXPIRE_HOURS = 24

# 创建HTTP Bearer安全方案
# 这会让Swagger文档自动添加"Authorize"按钮
security = HTTPBearer()


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    """
    创建JWT Token
    
    参数:
        data: 要嵌入Token的数据(通常包含user_id)
        expires_delta: 自定义过期时间(可选)
    
    返回:
        编码后的JWT字符串
    """
    # 复制数据,避免修改原字典
    to_encode = data.copy()
    
    # 设置过期时间
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
    
    # 添加过期时间声明
    to_encode.update({"exp": expire})
    
    # 编码生成Token
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    
    return encoded_jwt


async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
    db: AsyncSession = Depends(get_db)
):
    """
    获取当前登录用户的依赖函数
    
    用法:在路由函数参数中使用
        async def some_route(current_user = Depends(get_current_user)):
            ...
    
    工作流程:
    1. 从请求头提取Bearer Token
    2. 解码Token,获取user_id
    3. 根据user_id查询用户
    4. 返回用户对象(认证失败则抛出401异常)
    """
    # 1. 提取Token
    token = credentials.credentials
    
    # 2. 定义异常信息
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="无效的登录凭证",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    try:
        # 3. 解码Token
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: str = payload.get("sub")  # sub是subject的缩放,通常存用户ID
        
        if user_id is None:
            raise credentials_exception
            
    except JWTError:
        raise credentials_exception
    
    # 4. 查询用户
    user = await get_user_by_id(db, int(user_id))
    if user is None:
        raise credentials_exception
    
    # 5. 返回用户对象
    return user


def get_current_user_optional(credentials: HTTPAuthorizationCredentials = Depends(security)):
    """
    可选认证:尝试获取用户,但Token无效也不报错
    用于一些可有可无的用户相关功能
    """
    try:
        token = credentials.credentials
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload.get("sub")
    except:
        return None
步骤2:更新登录接口返回真实JWT

修改 routers/users.py 的登录接口:

python 复制代码
# 在文件顶部添加导入
from utils.auth import create_access_token

# 修改login函数的实现
@router.post("/login", response_model=LoginResponse)
async def login(
    request: UserLoginRequest,
    db: AsyncSession = Depends(get_db)
):
    # 1. 验证用户名和密码
    user = await authenticate_user(db, request.username, request.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误"
        )
    
    # 2. 生成JWT Token(包含用户ID)
    access_token = create_access_token(data={"sub": str(user.id)})
    
    # 3. 返回登录成功响应
    return LoginResponse(
        access_token=access_token,
        user=UserResponse.model_validate(user)
    )
步骤3:保护需要认证的接口

修改 routers/users.py,给需要登录的接口添加认证:

python 复制代码
# 添加导入
from utils.auth import get_current_user

# 修改获取用户信息接口
@router.get("/info", response_model=UserInfoResponse)
async def get_user_info(
    current_user = Depends(get_current_user),  # 添加这行
    db: AsyncSession = Depends(get_db)
):
    """
    获取当前登录用户的信息
    需要在请求头携带:Authorization: Bearer <token>
    """
    return UserInfoResponse(
        code=0,
        message="success",
        data=UserResponse.model_validate(current_user)
    )

# 修改更新用户信息接口
@router.put("/update", response_model=UserInfoResponse)
async def update_user_info(
    request: UserUpdateRequest,
    current_user = Depends(get_current_user),  # 添加这行
    db: AsyncSession = Depends(get_db)
):
    user = await update_user(
        db,
        user_id=current_user.id,  # 使用当前登录用户的ID
        **request.model_dump(exclude_unset=True)
    )
    
    return UserInfoResponse(
        code=0,
        message="更新成功",
        data=UserResponse.model_validate(user)
    )

# 修改密码接口
@router.post("/password")
async def change_pwd(
    request: ChangePasswordRequest,
    current_user = Depends(get_current_user),  # 添加这行
    db: AsyncSession = Depends(get_db)
):
    success = await change_password(
        db,
        user_id=current_user.id,  # 使用当前登录用户的ID
        old_password=request.old_password,
        new_password=request.new_password
    )
    
    if not success:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="旧密码错误"
        )
    
    return {"code": 0, "message": "密码修改成功"}
步骤4:测试JWT认证流程

测试1:先登录获取Token

  1. 访问 http://localhost:8000/docs
  2. 调用 POST /api/user/login
  3. 输入用户名密码,获取返回的 access_token

测试2:不带Token访问受保护接口

  1. 调用 GET /api/user/info
  2. 不填写Token,直接执行
  3. 应该返回 401 无效的登录凭证

测试3:带Token访问

  1. 点击页面右上角的 Authorize 按钮
  2. 输入:Bearer <刚才获取的token>(注意Bearer后面有个空格)
  3. 再次调用 GET /api/user/info
  4. 应该成功返回用户信息

🔍 逐行讲解

代码 解释
jwt.encode(...) 将数据编码成JWT字符串
jwt.decode(token, ...) 解码JWT,提取里面的数据
Depends(security) FastAPI依赖注入,自动从请求头提取Bearer Token
HTTPException(status_code=401) 抛出HTTP 401未授权异常
credentials.credentials 提取到的Token字符串(去掉"Bearer "前缀)

✅ 验收标准

  • 登录接口返回的 access_token 是一长串JWT字符串
  • 不带Token访问 /api/user/info 返回401错误
  • 带正确的Token访问返回用户信息
  • Token过期后(24小时后)访问返回401错误

⚠️ 易错点

错误现象 原因 解决方法
Signature verification failed SECRET_KEY不一致 前后端使用相同的密钥
Token has expired Token已过期 重新登录获取新Token
Missing Authorization Header 没有携带Token 在请求头添加 Authorization: Bearer <token>

🎯 第5节:实现新闻分类与列表功能

📌 学习目标

  • 设计新闻和分类的数据模型
  • 实现分页查询接口
  • 实现按分类筛选功能

👁️ 效果预览

能获取新闻分类列表、按页获取新闻列表、按分类筛选新闻

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:创建新闻数据模型

models/ 目录下创建 news.py

python 复制代码
"""
新闻数据模型
包含:分类表(category)和新闻表(news)
"""

from config.db_conf import Base
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func


class Category(Base):
    """
    新闻分类模型
    如:头条、社会、国内、国际、娱乐、体育、军事、科技、财经
    """
    __tablename__ = "category"
    
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    
    # 分类名称,唯一
    name = Column(String(50), unique=True, nullable=False, comment="分类名称")
    
    # 分类图标(可选)
    icon = Column(String(100), nullable=True, comment="分类图标")
    
    # 排序序号(数字越小越靠前)
    sort_order = Column(Integer, default=0, comment="排序")
    
    # 创建时间
    created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
    
    # 关联的新闻(一对多关系)
    news = relationship("News", back_populates="category")


class News(Base):
    """
    新闻文章模型
    """
    __tablename__ = "news"
    
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    
    # 关联的分类ID
    category_id = Column(Integer, ForeignKey("category.id"), nullable=False, comment="分类ID")
    
    # 新闻标题
    title = Column(String(200), nullable=False, comment="标题")
    
    # 新闻摘要
    summary = Column(Text, nullable=True, comment="摘要")
    
    # 封面图片URL
    cover_image = Column(String(500), nullable=True, comment="封面图")
    
    # 新闻正文(支持Markdown格式)
    content = Column(Text, nullable=True, comment="正文内容(Markdown)")
    
    # 作者ID
    author_id = Column(Integer, nullable=True, comment="作者ID")
    
    # 作者名称(冗余存储,避免联表查询)
    author_name = Column(String(100), nullable=True, comment="作者名称")
    
    # 来源
    source = Column(String(100), nullable=True, comment="来源")
    
    # 阅读次数
    view_count = Column(Integer, default=0, comment="阅读次数")
    
    # 点赞数
    like_count = Column(Integer, default=0, comment="点赞数")
    
    # 评论数
    comment_count = Column(Integer, default=0, comment="评论数")
    
    # 是否置顶
    is_top = Column(Boolean, default=False, comment="是否置顶")
    
    # 是否发布
    is_published = Column(Boolean, default=True, comment="是否发布")
    
    # 发布时间
    published_at = Column(DateTime, server_default=func.now(), comment="发布时间")
    
    # 创建时间
    created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
    
    # 更新时间
    updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
    
    # 关联的分类(多对一关系)
    category = relationship("Category", back_populates="news")
步骤2:创建新闻Schema

schemas/ 目录下创建 news.py

python 复制代码
"""
新闻相关的Pydantic Schema
"""

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, List


class CategoryResponse(BaseModel):
    """分类响应模型"""
    id: int
    name: str
    icon: Optional[str] = None
    sort_order: int = 0
    
    class Config:
        from_attributes = True


class NewsListResponse(BaseModel):
    """新闻列表项响应(简洁版,用于列表展示)"""
    id: int
    category_id: int
    title: str
    summary: Optional[str] = None
    cover_image: Optional[str] = None
    author_name: Optional[str] = None
    view_count: int = 0
    like_count: int = 0
    comment_count: int = 0
    is_top: bool = False
    published_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True


class NewsDetailResponse(BaseModel):
    """新闻详情响应(完整版)"""
    id: int
    category_id: int
    category: Optional[CategoryResponse] = None
    title: str
    summary: Optional[str] = None
    cover_image: Optional[str] = None
    content: Optional[str] = None
    author_id: Optional[int] = None
    author_name: Optional[str] = None
    source: Optional[str] = None
    view_count: int = 0
    like_count: int = 0
    comment_count: int = 0
    published_at: Optional[datetime] = None
    created_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True


class NewsListResult(BaseModel):
    """新闻列表分页响应"""
    code: int = 0
    message: str = "success"
    data: List[NewsListResponse]
    total: int  # 总记录数
    page: int   # 当前页码
    page_size: int  # 每页数量


class PublishNewsRequest(BaseModel):
    """发布新闻请求"""
    category_id: int = Field(..., description="分类ID")
    title: str = Field(..., min_length=1, max_length=200, description="标题")
    summary: Optional[str] = Field(None, description="摘要")
    cover_image: Optional[str] = Field(None, description="封面图URL")
    content: Optional[str] = Field(None, description="正文内容(Markdown)")
    source: Optional[str] = Field(None, description="来源")
步骤3:创建新闻CRUD

crud/ 目录下创建 news.py

python 复制代码
"""
新闻CRUD操作
"""

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from models.news import News, Category
from typing import Optional, List, Tuple


async def get_categories(db: AsyncSession) -> List[Category]:
    """获取所有分类(按排序字段升序)"""
    result = await db.execute(
        select(Category).order_by(Category.sort_order.asc())
    )
    return result.scalars().all()


async def get_news_list(
    db: AsyncSession,
    page: int = 1,
    page_size: int = 20,
    category_id: Optional[int] = None,
    keyword: Optional[str] = None
) -> Tuple[List[News], int]:
    """
    获取新闻列表(分页)
    
    参数:
        page: 页码(从1开始)
        page_size: 每页数量
        category_id: 分类筛选(可选)
        keyword: 关键词搜索(可选)
    
    返回:(新闻列表, 总数)
    """
    # 构建基础查询
    query = select(News).where(News.is_published == True)
    count_query = select(func.count()).select_from(News).where(News.is_published == True)
    
    # 分类筛选
    if category_id:
        query = query.where(News.category_id == category_id)
        count_query = count_query.where(News.category_id == category_id)
    
    # 关键词搜索
    if keyword:
        query = query.where(News.title.contains(keyword))
        count_query = count_query.where(News.title.contains(keyword))
    
    # 查询总数
    total_result = await db.execute(count_query)
    total = total_result.scalar()
    
    # 分页查询(置顶优先,然后按发布时间倒序)
    query = query.order_by(desc(News.is_top), desc(News.published_at))
    query = query.offset((page - 1) * page_size).limit(page_size)
    
    result = await db.execute(query)
    news_list = result.scalars().all()
    
    return list(news_list), total or 0


async def get_news_by_id(db: AsyncSession, news_id: int) -> Optional[News]:
    """根据ID获取新闻详情(同时增加阅读量)"""
    result = await db.execute(
        select(News).where(News.id == news_id, News.is_published == True)
    )
    news = result.scalar_one_or_none()
    
    if news:
        # 增加阅读量
        news.view_count += 1
        await db.commit()
        await db.refresh(news)
    
    return news


async def create_news(db: AsyncSession, **kwargs) -> News:
    """创建新闻"""
    db_news = News(**kwargs)
    db.add(db_news)
    await db.commit()
    await db.refresh(db_news)
    return db_news


async def update_news(db: AsyncSession, news_id: int, **kwargs) -> Optional[News]:
    """更新新闻"""
    news = await get_news_by_id(db, news_id)
    if not news:
        return None
    
    for key, value in kwargs.items():
        if hasattr(news, key) and value is not None:
            setattr(news, key, value)
    
    news.updated_at = func.now()
    await db.commit()
    await db.refresh(news)
    return news


async def delete_news(db: AsyncSession, news_id: int) -> bool:
    """删除新闻(软删除:设置为未发布状态)"""
    news = await get_news_by_id(db, news_id)
    if not news:
        return False
    
    news.is_published = False
    await db.commit()
    return True
步骤4:创建新闻路由

routers/ 目录下创建 news.py

python 复制代码
"""
新闻相关API路由
"""

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional

from config.db_conf import get_db
from schemas.news import (
    CategoryResponse, NewsListResponse, NewsDetailResponse,
    NewsListResult, PublishNewsRequest
)
from crud.news import (
    get_categories, get_news_list, get_news_by_id,
    create_news, update_news, delete_news
)
from utils.auth import get_current_user
from models.users import User

router = APIRouter(prefix="/api/news", tags=["新闻管理"])


# ====== 获取分类列表 ======

@router.get("/categories", response_model=dict)
async def get_categories_api():
    """
    获取所有新闻分类
    GET /api/news/categories
    """
    # 注意:这里简化了,实际应该注入db
    # 完整版本会在后续章节完善
    return {
        "code": 0,
        "message": "success",
        "data": [
            {"id": 1, "name": "头条", "icon": "fire-o", "sort_order": 0},
            {"id": 2, "name": "社会", "icon": "users", "sort_order": 1},
            {"id": 3, "name": "国内", "icon": "home", "sort_order": 2},
            {"id": 4, "name": "国际", "icon": "globe", "sort_order": 3},
            {"id": 5, "name": "娱乐", "icon": "smile-o", "sort_order": 4},
            {"id": 6, "name": "体育", "icon": "trophy", "sort_order": 5},
            {"id": 7, "name": "军事", "icon": "shield", "sort_order": 6},
            {"id": 8, "name": "科技", "icon": "desktop", "sort_order": 7},
            {"id": 9, "name": "财经", "icon": "yen", "sort_order": 8},
        ]
    }


# ====== 获取新闻列表(分页)======

@router.get("/list", response_model=NewsListResult)
async def get_news_list_api(
    page: int = Query(1, ge=1, description="页码"),
    page_size: int = Query(20, ge=1, le=100, description="每页数量"),
    category_id: Optional[int] = Query(None, description="分类ID"),
    keyword: Optional[str] = Query(None, description="搜索关键词"),
    db: AsyncSession = Depends(get_db)
):
    """
    获取新闻列表(支持分页、分类筛选、关键词搜索)
    GET /api/news/list?page=1&page_size=20&category_id=1
    """
    news_list, total = await get_news_list(
        db,
        page=page,
        page_size=page_size,
        category_id=category_id,
        keyword=keyword
    )
    
    return NewsListResult(
        code=0,
        message="success",
        data=news_list,
        total=total,
        page=page,
        page_size=page_size
    )


# ====== 获取新闻详情 ======

@router.get("/detail/{news_id}", response_model=dict)
async def get_news_detail_api(
    news_id: int,
    db: AsyncSession = Depends(get_db)
):
    """
    获取新闻详情(同时增加阅读量)
    GET /api/news/detail/1
    """
    news = await get_news_by_id(db, news_id)
    
    if not news:
        raise HTTPException(status_code=404, detail="新闻不存在")
    
    return {
        "code": 0,
        "message": "success",
        "data": {
            "id": news.id,
            "title": news.title,
            "summary": news.summary,
            "cover_image": news.cover_image,
            "content": news.content,
            "author_name": news.author_name,
            "source": news.source,
            "view_count": news.view_count,
            "like_count": news.like_count,
            "comment_count": news.comment_count,
            "published_at": news.published_at.isoformat() if news.published_at else None,
        }
    }


# ====== 发布新闻(需登录)======

@router.post("/publish", response_model=dict)
async def publish_news_api(
    request: PublishNewsRequest,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    发布新闻(需要登录)
    POST /api/news/publish
    """
    news = await create_news(
        db,
        category_id=request.category_id,
        title=request.title,
        summary=request.summary,
        cover_image=request.cover_image,
        content=request.content,
        author_id=current_user.id,
        author_name=current_user.nickname or current_user.username,
        source=request.source
    )
    
    return {"code": 0, "message": "发布成功", "data": {"id": news.id}}


# ====== 更新新闻(需登录)======

@router.put("/update/{news_id}", response_model=dict)
async def update_news_api(
    news_id: int,
    request: PublishNewsRequest,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """更新新闻"""
    news = await update_news(db, news_id, **request.model_dump(exclude_unset=True))
    
    if not news:
        raise HTTPException(status_code=404, detail="新闻不存在")
    
    return {"code": 0, "message": "更新成功"}


# ====== 删除新闻(需登录)======

@router.delete("/delete/{news_id}", response_model=dict)
async def delete_news_api(
    news_id: int,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """删除新闻"""
    success = await delete_news(db, news_id)
    
    if not success:
        raise HTTPException(status_code=404, detail="新闻不存在")
    
    return {"code": 0, "message": "删除成功"}
步骤5:注册新闻路由

更新 main.py

python 复制代码
# 添加导入
from routers.news import router as news_router

# 注册路由
app.include_router(news_router)
步骤6:插入测试数据

创建一个临时的数据初始化脚本 init_data.py(放在backend目录下,只用一次):

python 复制代码
"""
测试数据初始化脚本
执行:python init_data.py
"""
import asyncio
from config.db_conf import engine, Base
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker

async def init_test_data():
    # 创建表
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    # 创建会话
    AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    
    async with AsyncSessionLocal() as db:
        from models.news import Category, News
        
        # 插入分类
        categories = [
            Category(name="头条", sort_order=0),
            Category(name="社会", sort_order=1),
            Category(name="国内", sort_order=2),
            Category(name="国际", sort_order=3),
            Category(name="娱乐", sort_order=4),
            Category(name="体育", sort_order=5),
            Category(name="军事", sort_order=6),
            Category(name="科技", sort_order=7),
            Category(name="财经", sort_order=8),
        ]
        
        for cat in categories:
            db.add(cat)
        
        # 插入测试新闻
        test_news = []
        for i in range(25):  # 插入25条新闻,测试分页
            test_news.append(News(
                category_id=(i % 9) + 1,
                title=f"测试新闻标题第{i+1}条:这是一条重要的新闻内容",
                summary=f"这是第{i+1}条新闻的摘要内容,用于测试列表展示效果...",
                cover_image=f"https://picsum.photos/400/300?random={i}",
                author_name=f"作者{chr(65 + i % 26)}",
                source="测试来源",
                view_count=i * 100,
                like_count=i * 10,
                comment_count=i,
                is_top=(i < 3),  # 前3条置顶
            ))
        
        for news in test_news:
            db.add(news)
        
        await db.commit()
        print(f"✅ 初始化完成!插入了{len(categories)}个分类,{len(test_news)}条新闻")

if __name__ == "__main__":
    asyncio.run(init_test_data())

执行脚本:

bash 复制代码
python init_data.py

✅ 验收标准

  • 访问 GET /api/news/categories 返回9个分类
  • 访问 GET /api/news/list?page=1&page_size=10 返回10条新闻
  • 访问 GET /api/news/list?category_id=2 只返回"社会"分类的新闻
  • 访问 GET /api/news/detail/1 返回新闻详情,且view_count增加了1
  • 第2页的数据和第1页不同(验证分页生效)

🎯 第6节:实现收藏和历史功能

📌 学习目标

  • 实现用户收藏功能
  • 实现浏览历史记录
  • 理解用户关联数据的查询

⏱️ 预计用时:1.5小时

📝 操作步骤(精简版)

步骤1:创建收藏和历史模型

models/favorite.py

python 复制代码
from config.db_conf import Base
from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.sql import func

class Favorite(Base):
    __tablename__ = "favorite"
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    user_id = Column(Integer, nullable=False, index=True, comment="用户ID")
    news_id = Column(Integer, nullable=False, index=True, comment="新闻ID")
    created_at = Column(DateTime, server_default=func.now(), comment="收藏时间")

class History(Base):
    __tablename__ = "history"
    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    user_id = Column(Integer, nullable=False, index=True, comment="用户ID")
    news_id = Column(Integer, nullable=False, index=True, comment="新闻ID")
    created_at = Column(DateTime, server_default=func.now(), comment="浏览时间")
步骤2:创建CRUD和路由

按照之前的模式,分别创建:

  • crud/favorite.py - 收藏的增删查
  • crud/history.py - 历史的增删查
  • routers/favorite.py - 收藏API
  • routers/history.py - 历史API
步骤3:核心API设计

收藏模块

复制代码
POST   /api/favorite/add      - 添加收藏(参数:news_id)
DELETE /api/favorite/remove   - 取消收藏(参数:news_id)
GET    /api/favorite/list     - 收藏列表(分页)
POST   /api/favorite/clear    - 清空收藏
GET    /api/favorite/check    - 检查是否已收藏(参数:news_id)

历史模块

复制代码
POST /api/history/add         - 添加历史记录(参数:news_id)
GET  /api/history/list        - 历史列表(分页)
POST /api/history/clear       - 清空历史

✅ 验收标准

  • 能添加和取消收藏
  • 收藏列表只显示当前用户的收藏
  • 查看新闻详情后自动添加历史记录
  • 能清空收藏和历史

🎯 第7节:实现AI智能问答功能

📌 学习目标

  • 集成OpenAI API
  • 实现流式响应(SSE)
  • 处理AI对话上下文

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:安装OpenAI SDK
bash 复制代码
pip install openai
步骤2:创建AI配置

config/ai_conf.py

python 复制代码
import os

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "your-api-key")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-3.5-turbo")
步骤3:创建AI路由

routers/aichat.py

python 复制代码
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from openai import OpenAI
from config.ai_conf import OPENAI_API_KEY, OPENAI_BASE_URL, MODEL_NAME

router = APIRouter(prefix="/api/aichat", tags=["AI问答"])

client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL)

class ChatMessage(BaseModel):
    role: str  # "user" 或 "assistant"
    content: str

class ChatRequest(BaseModel):
    messages: List[ChatMessage]

@router.post("/chat")
async def chat(request: ChatRequest):
    """AI问答接口"""
    try:
        # 转换消息格式
        messages = [{"role": m.role, "content": m.content} for m in request.messages]
        
        # 调用OpenAI API
        response = client.chat.completions.create(
            model=MODEL_NAME,
            messages=messages,
            stream=False
        )
        
        # 返回AI回复
        reply = response.choices[0].message.content
        
        return {
            "code": 0,
            "message": "success",
            "data": {
                "reply": reply
            }
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"AI服务异常:{str(e)}")

✅ 验收标准

  • 发送消息能收到AI回复
  • 多轮对话能保持上下文
  • API Key无效时给出友好提示

🗂️ 第二部分:前端开发(Vue 3 + Vite)


🎯 第8节:创建Vue3前端项目

📌 学习目标

  • 使用Vite创建Vue3项目
  • 理解项目结构和配置
  • 能启动开发服务器

⏱️ 预计用时:40分钟

📝 操作步骤

步骤1:创建项目
bash 复制代码
# 回到项目根目录
cd ..

# 使用Vite创建Vue3项目
npm create vite@latest frontend -- --template vue

# 进入项目目录
cd frontend
步骤2:安装核心依赖
bash 复制代码
# 安装Vant组件库(移动端UI组件库)
npm install vant@4

# 安装Vue Router(路由管理)
npm install vue-router@4

# 安装Pinia(状态管理)
npm install pinia

# 安装Axios(HTTP客户端)
npm install axios

# 安装国际化插件
npm install vue-i18n@9

# 安装Markdown渲染库
npm install marked dompurify

# 安装持久化存储插件
npm install pinia-plugin-persistedstate

# ⚠️ 显式声明 vue(Vite 模板通常已包含;若 package.json 的
# dependencies 中没有 "vue",需补装,避免依赖提升产生的"幽灵依赖")
npm install vue@^3.5.25
步骤3:配置Vite

更新 vite.config.js

javascript 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],

  // 开发服务器配置
  server: {
    port: 5173,          // 前端端口
    open: true,          // 自动打开浏览器
    proxy: {
      // 代理API请求到后端(解决跨域问题)
      '/api': {
        target: 'http://localhost:8000',  // 后端地址
        changeOrigin: true,
      },
      '/docs': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      }
    }
  },

  // 生产构建优化:清除 console/debugger,减小包体并避免泄露调试信息
  build: {
    esbuild: {
      drop: ['console', 'debugger'],
    },
  },
})
步骤4:启动项目
bash 复制代码
npm run dev

浏览器自动打开 http://localhost:5173,显示Vite默认页面。

✅ 验收标准

  • npm run dev 成功启动
  • 浏览器显示Vue欢迎页面
  • 修改代码后浏览器自动刷新(热更新)

🎯 第9节:配置路由和布局

📌 学习目标

  • 配置Vue Router
  • 创建基础布局组件
  • 实现底部TabBar导航

⏱️ 预计用时:1.5小时

📝 操作步骤

步骤1:创建路由配置

src/router/index.js

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'

// 路由配置
const routes = [
  {
    path: '/',
    component: () => import('../views/Home.vue'),
    meta: { title: '首页', showTabBar: true }
  },
  {
    path: '/category',
    component: () => import('../views/Category.vue'),
    meta: { title: '分类', showTabBar: false }
  },
  {
    path: '/news/:id',
    component: () => import('../views/NewsDetail.vue'),
    meta: { title: '新闻详情', showTabBar: false }
  },
  {
    path: '/favorite',
    component: () => import('../views/Favorite.vue'),
    meta: { title: '我的收藏', showTabBar: true }
  },
  {
    path: '/history',
    component: () => import('../views/History.vue'),
    meta: { title: '浏览历史', showTabBar: true }
  },
  {
    path: '/aichat',
    component: () => import('../views/AIChat.vue'),
    meta: { title: 'AI问答', showTabBar: true }
  },
  {
    path: '/my',
    component: () => import('../views/My.vue'),
    meta: { title: '我的', showTabBar: true }
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
    meta: { title: '登录', showTabBar: false }
  },
  {
    path: '/register',
    component: () => import('../views/Register.vue'),
    meta: { title: '注册', showTabBar: false }
  },
  {
    path: '/profile',
    component: () => import('../views/Profile.vue'),
    meta: { title: '编辑资料', showTabBar: false }
  },
  {
    path: '/settings',
    component: () => import('../views/Settings.vue'),
    meta: { title: '设置', showTabBar: false }
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫:设置页面标题
router.beforeEach((to, from, next) => {
  document.title = to.meta.title || '新闻头条'
  next()
})

export default router
步骤2:创建底部导航栏组件

src/components/TabBar.vue

vue 复制代码
<template>
  <!-- 底部TabBar导航栏 -->
  <van-tabbar v-model="active" route>
    <van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
    <van-tabbar-item to="/category" icon="search">分类</van-tabbar-item>
    <van-tabbar-item to="/aichat" icon="chat-o">AI问答</van-tabbar-item>
    <van-tabbar-item to="/my" icon="user-o">我的</van-tabbar-item>
  </van-tabbar>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const active = ref(0)

// 根据当前路由高亮对应的tab
watch(() => route.path, (path) => {
  const tabMap = { '/': 0, '/category': 1, '/aichat': 2, '/my': 3 }
  active.value = tabMap[path] ?? 0
})
</script>
步骤3:更新App.vue使用路由和TabBar
vue 复制代码
<template>
  <div :class="['app-container', themeClass]">
    <router-view />
    <!-- 只在需要显示的页面显示TabBar -->
    <TabBar v-if="showTabBar" />
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import TabBar from './components/TabBar.vue'
import { useThemeStore } from './store/theme'

const route = useRoute()
const themeStore = useThemeStore()

// 是否显示底部导航
const showTabBar = computed(() => route.meta.showTabBar)

// 主题类名
const themeClass = computed(() => themeStore.isDark ? 'dark-theme' : 'light-theme')
</script>

<style>
/* 全局样式稍后补充 */
</style>

✅ 验收标准

  • 底部显示4个tab:首页、分类、AI问答、我的
  • 点击tab能切换页面
  • 当前页面对应的tab高亮显示

🎯 第10节:实现首页(新闻列表)

📌 学习目标

  • 实现分类标签栏
  • 实现新闻列表展示
  • 实现下拉刷新和上拉加载更多

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:创建首页组件

src/views/Home.vue(关键代码框架):

vue 复制代码
<template>
  <div class="home-page">
    <!-- 顶部导航 -->
    <van-nav-bar title="新闻资讯" :fixed="true" :placeholder="true">
      <template #right>
        <van-icon name="search" @click="$router.push('/category')" />
      </template>
    </van-nav-bar>

    <!-- 分类标签栏 -->
    <van-tabs v-model:active="activeCategory" @change="onCategoryChange" sticky swipeable>
      <van-tab v-for="cat in categories" :key="cat.id" :title="cat.name" :name="cat.id" />
    </van-tabs>

    <!-- 新闻列表 -->
    <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
      <van-list
        v-model:loading="loading"
        :finished="finished"
        finished-text="没有更多了"
        @load="onLoad"
      >
        <NewsItem
          v-for="item in newsList"
          :key="item.id"
          :news="item"
          @click="goDetail(item.id)"
        />
      </van-list>
    </van-pull-refresh>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import NewsItem from '../components/NewsItem.vue'
import { useNewsStore } from '../store/modules/news'

const router = useRouter()
const newsStore = useNewsStore()

// 分类数据
const categories = ref([
  { id: 0, name: '推荐' },
  { id: 1, name: '头条' },
  { id: 2, name: '社会' },
  // ... 更多分类
])

// 当前选中的分类
const activeCategory = ref(0)

// 新闻列表数据
const newsList = ref([])

// 分页状态
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = 20

// 加载新闻列表
const onLoad = async () => {
  try {
    const res = await newsStore.fetchNewsList({
      page: page.value,
      page_size: pageSize,
      category_id: activeCategory.value || undefined
    })
    
    newsList.value.push(...res.data)
    page.value++
    
    if (newsList.value.length >= res.total) {
      finished.value = true
    }
  } catch (error) {
    showToast('加载失败')
  } finally {
    loading.value = false
    refreshing.value = false
  }
}

// 下拉刷新
const onRefresh = async () => {
  newsList.value = []
  page.value = 1
  finished.value = false
  await onLoad()
}

// 切换分类
const onCategoryChange = (name) => {
  newsList.value = []
  page.value = 1
  finished.value = false
  onLoad()
}

// 跳转详情
const goDetail = (id) => {
  router.push(`/news/${id}`)
}

// 页面加载时获取数据
onMounted(() => {
  onLoad()
  // 同时获取分类列表
  newsStore.fetchCategories()
})
</script>

✅ 验收标准

  • 首页显示分类标签栏(可左右滑动)
  • 显示新闻列表(封面图+标题+摘要)
  • 下拉触发刷新
  • 滚到底部自动加载下一页
  • 点击分类切换对应新闻

🎯 第11节:实现登录注册页面

📌 学习目标

  • 创建登录表单
  • 创建注册表单
  • 实现表单验证
  • 调用后端API完成登录注册

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:创建登录页面

src/views/Login.vue

vue 复制代码
<template>
  <div class="login-page">
    <van-nav-bar title="登录" left-arrow @click-left="$router.back()" />
    
    <div class="login-form">
      <!-- Logo区域 -->
      <div class="logo">
        <h1>新闻头条</h1>
        <p>随时随地,看新闻</p>
      </div>
      
      <!-- 登录表单 -->
      <van-form @submit="handleLogin">
        <van-cell-group inset>
          <van-field
            v-model="form.username"
            name="username"
            label="用户名"
            placeholder="请输入用户名"
            :rules="[{ required: true, message: '请输入用户名' }]"
          />
          <van-field
            v-model="form.password"
            type="password"
            name="password"
            label="密码"
            placeholder="请输入密码"
            :rules="[{ required: true, message: '请输入密码' }]"
          />
        </van-cell-group>
        
        <div style="margin: 16px;">
          <van-button round block type="primary" native-type="submit" :loading="loading">
            登录
          </van-button>
        </div>
      </van-form>
      
      <!-- 注册链接 -->
      <div class="register-link">
        还没有账号?<router-link to="/register">立即注册</router-link>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { useUserStore } from '../store/user'

const router = useRouter()
const userStore = useUserStore()

const form = ref({
  username: '',
  password: ''
})

const loading = ref(false)

const handleLogin = async () => {
  loading.value = true
  
  try {
    await userStore.login(form.value)
    showSuccessToast('登录成功')
    router.replace('/')
  } catch (error) {
    showToast(error.message || '登录失败')
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-page {
  min-height: 100vh;
  background: #f7f8fa;
}

.logo {
  text-align: center;
  padding: 60px 0 40px;
}

.logo h1 {
  font-size: 28px;
  color: #1989fa;
  margin-bottom: 8px;
}

.logo p {
  color: #999;
  font-size: 14px;
}

.register-link {
  text-align: center;
  margin-top: 16px;
  color: #666;
}

.register-link a {
  color: #1989fa;
}
</style>
步骤2:创建用户Store

src/store/user.js

javascript 复制代码
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '../api/request'  // 统一请求实例(自带 token 拦截器)
import router from '../router'

export const useUserStore = defineStore('user', () => {
  // 状态(token 仅存内存;会话内由 persist 写入 sessionStorage)
  const token = ref('')
  const userInfo = ref({})
  const isLogin = ref(false)

  // 登录
  const login = async (formData) => {
    const res = await request.post('/api/user/login', formData)

    // 保存到内存状态即可,Token 由拦截器自动附加,无需手动 setItem
    token.value = res.data.access_token
    userInfo.value = res.data.user
    isLogin.value = true
    // 持久化交给下方 persist 配置(sessionStorage)

    return res
  }

  // 注册
  const register = async (formData) => {
    const res = await request.post('/api/user/register', formData)
    return res
  }

  // 退出登录
  const logout = () => {
    token.value = ''
    userInfo.value = {}
    isLogin.value = false
    // token 仅存内存,置空即失效;持久化数据由 Pinia 下次写入覆盖
    router.push('/login')
  }

  // 检查是否已登录
  const isLoggedIn = () => {
    return !!token.value
  }

  return {
    token,
    userInfo,
    isLogin,
    login,
    register,
    logout,
    isLoggedIn
  }
}, {
  // 持久化配置(pinia-plugin-persistedstate v4 语法)
  persist: {
    key: 'user-store',
    storage: sessionStorage,             // 会话级存储:关闭标签页即清除,避免 token 明文常驻
    pick: ['userInfo', 'isLogin', 'token'],
  }
})

✅ 验收标准

  • 登录页面显示正常
  • 表单验证:空提交时提示必填
  • 输入正确账号密码能登录成功
  • 登录成功后跳转到首页
  • 注册链接能跳转到注册页

🎯 第12节:实现新闻详情页

📌 学习目标

  • 展示新闻完整内容
  • Markdown内容渲染
  • 实现收藏功能
  • 实现评论功能

⏱️ 预计用时:2小时

📝 操作步骤

步骤1:创建新闻详情页

src/views/NewsDetail.vue

vue 复制代码
<template>
  <div class="detail-page">
    <van-nav-bar
      :title="news.title || '新闻详情'"
      left-arrow
      fixed
      placeholder
      @click-left="$router.back()"
    >
      <template #right>
        <van-icon
          :name="isFavorited ? 'star' : 'star-o'"
          :color="isFavorited ? '#ff976a' ? undefined"
          @click="toggleFavorite"
        />
      </template>
    </van-nav-bar>

    <!-- 新闻内容 -->
    <div class="news-content" v-if="news.id">
      <h1 class="news-title">{{ news.title }}</h1>
      
      <div class="news-meta">
        <span>{{ news.author_name }}</span>
        <span>{{ news.source }}</span>
        <span>{{ formatTime(news.published_at) }}</span>
        <span>阅读 {{ news.view_count }}</span>
      </div>

      <!-- 封面图 -->
      <img v-if="news.cover_image" :src="news.cover_image" class="cover-image" />

      <!-- 正文内容(Markdown渲染)-->
      <div class="markdown-body" v-html="renderedContent"></div>

      <!-- 底部互动栏 -->
      <div class="action-bar">
        <van-button size="small" icon="like-o">点赞 {{ news.like_count }}</van-button>
        <van-button size="small" icon="comment-o">评论 {{ news.comment_count }}</van-button>
        <van-button size="small" icon="share-o">分享</van-button>
      </div>
    </div>

    <!-- 评论区域 -->
    <div class="comment-section">
      <h3>评论 ({{ comments.length }})</h3>
      
      <div class="comment-input">
        <van-field
          v-model="commentText"
          placeholder="写下你的评论..."
          @keyup.enter="submitComment"
        >
          <template #button>
            <van-button size="small" type="primary" @click="submitComment">发送</van-button>
          </template>
        </van-field>
      </div>

      <div class="comment-list">
        <div v-for="comment in comments" :key="comment.id" class="comment-item">
          <div class="comment-header">
            <strong>{{ comment.user_nickname }}</strong>
            <span>{{ formatTime(comment.created_at) }}</span>
          </div>
          <p>{{ comment.content }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import request from '../api/request'
import { useFavoriteStore } from '../store/modules/favorite'
import { useHistoryStore } from '../store/modules/history'

const route = useRoute()
const favoriteStore = useFavoriteStore()
const historyStore = useHistoryStore()

// 新闻数据
const news = ref({})
const comments = ref([])
const commentText = ref('')
const isFavorited = ref(false)

// Markdown渲染(防XSS攻击)
const renderedContent = computed(() => {
  if (!news.value.content) return ''
  const rawHtml = marked(news.value.content)
  return DOMPurify.sanitize(rawHtml)
})

// 获取新闻详情
const fetchNewsDetail = async () => {
  const newsId = route.params.id
  
  try {
    const res = await request.get(`/api/news/detail/${newsId}`)
    news.value = res.data.data
    
    // 添加到浏览历史
    await historyStore.addHistory(newsId)
    
    // 检查收藏状态
    isFavorited.value = await favoriteStore.checkFavorite(newsId)
    
    // 获取评论列表
    fetchComments()
  } catch (error) {
    showToast('获取新闻详情失败')
  }
}

// 切换收藏
const toggleFavorite = async () => {
  if (isFavorited.value) {
    await favoriteStore.removeFavorite(route.params.id)
    showSuccessToast('取消收藏')
  } else {
    await favoriteStore.addFavorite(route.params.id)
    showSuccessToast('收藏成功')
  }
  isFavorited.value = !isFavorited.value
}

// 提交评论
const submitComment = async () => {
  if (!commentText.value.trim()) return
  
  try {
    await request.post('/api/comment/add', {
      news_id: route.params.id,
      content: commentText.value
    })
    commentText.value = ''
    showSuccessToast('评论成功')
    fetchComments()
  } catch (error) {
    showToast('评论失败')
  }
}

// 获取评论列表
const fetchComments = async () => {
  try {
    const res = await request.get('/api/comment/list', {
      params: { news_id: route.params.id }
    })
    comments.value = res.data.data || []
  } catch (error) {
    // 评论加载失败不影响主体
  }
}

// 时间格式化
const formatTime = (timeStr) => {
  if (!timeStr) return ''
  const date = new Date(timeStr)
  const now = new Date()
  const diff = now - date
  
  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  return `${Math.floor(diff / 86400000)}天前`
}

onMounted(() => {
  fetchNewsDetail()
})
</script>

<style scoped>
.news-content {
  padding: 16px;
  background: #fff;
}

.news-title {
  font-size: 20px;
  font-weight: bold;
  line-height: 1.4;
  margin-bottom: 12px;
}

.news-meta {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #999;
  margin-bottom: 16px;
  flex-wrap: wrap;
}

.cover-image {
  width: 100%;
  border-radius: 8px;
  margin-bottom: 16px;
}

.markdown-body {
  line-height: 1.8;
  font-size: 15px;
  color: #333;
}

.markdown-body :deep(img) {
  max-width: 100%;
  border-radius: 8px;
}

.action-bar {
  display: flex;
  gap: 12px;
  padding: 16px 0;
  margin-top: 20px;
  border-top: 1px solid #eee;
}

.comment-section {
  padding: 16px;
  background: #fff;
  margin-top: 10px;
}

.comment-item {
  padding: 12px 0;
  border-bottom: 1px solid #f5f5f5;
}

.comment-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 6px;
}
</style>

✅ 验收标准

  • 详情页显示新闻标题、作者、时间、阅读量
  • 封面图正确显示
  • Markdown正文正确渲染(标题、列表、代码块等)
  • 点击星标能收藏/取消收藏
  • 能发表评论并显示在列表中

⚠️ 常见陷阱:API 失败时页面永久显示"加载中"(Bug #4)

如果使用 v-if="news.id"v-else 来控制加载/内容切换,当 API 请求失败时,news 始终为空对象,v-else 永远显示"加载中..."而无法退出。

正确做法是使用三态渲染------区分加载中、加载失败、有内容三种状态:

javascript 复制代码
// Store 层必须添加错误状态
state: () => ({
  detailLoading: false,
  detailError: false,
  newsDetail: {}
})

async getNewsDetail(id) {
  this.detailLoading = true
  this.detailError = false
  try {
    const res = await request.get(`/api/news/detail?id=${id}`)
    if (res.data.code === 200) {
      this.newsDetail = res.data.data
    } else {
      this.detailError = true  // ← 关键:标记失败
    }
  } catch (error) {
    this.detailError = true    // ← 关键:标记失败
  } finally {
    this.detailLoading = false
  }
}

模板中按优先级展示三个状态:

vue 复制代码
<van-loading v-if="store.detailLoading" />          <!-- 1. 加载中 -->
<van-empty v-else-if="store.detailError" />          <!-- 2. 加载失败 + 重试按钮 -->
<div v-else-if="store.newsDetail.id">...内容...</div> <!-- 3. 正常展示 -->
<van-empty v-else description="暂无内容" />           <!-- 4. 兜底 -->

影响文件store/modules/news.jsviews/NewsDetail.vue


🎯 第13节:实现个人中心和相关页面

📌 学习目标

  • 实现"我的"页面
  • 实现收藏列表页
  • 实现历史记录页
  • 实现设置页(主题/语言切换)

⏱️ 预计用时:2.5小时

📝 操作步骤

页面清单
页面文件 功能要点
My.vue 显示用户头像、昵称、功能入口列表
Favorite.vue 收藏的新闻列表,可取消收藏
History.vue 浏览历史列表,可清空
Settings.vue 主题切换开关、语言切换按钮
Profile.vue 编辑头像、昵称、简介等
My.vue 核心代码片段
vue 复制代码
<template>
  <div class="my-page">
    <!-- 用户信息卡片 -->
    <div class="user-card" @click="goLoginOrProfile">
      <van-image
        round
        width="60"
        height="60"
        :src="userInfo.avatar_url || 'default-avatar.png'"
      />
      <div class="user-info">
        <h3 v-if="isLoggedIn">{{ userInfo.nickname || userInfo.username }}</h3>
        <h3 v-else>点击登录</h3>
        <p v-if="isLoggedIn">{{ userInfo.bio || '这个人很懒,什么都没写' }}</p>
      </div>
      <van-icon name="arrow" />
    </div>

    <!-- 功能列表 -->
    <van-cell-group>
      <van-cell title="我的收藏" icon="star-o" is-link to="/favorite" :badge="favoriteCount" />
      <van-cell title="浏览历史" icon="clock-o" is-link to="/history" :badge="historyCount" />
      <van-cell title="AI问答" icon="chat-o" is-link to="/aichat" />
    </van-cell-group>

    <van-cell-group>
      <van-cell title="设置" icon="setting-o" is-link to="/settings" />
    </van-cell-group>

    <!-- 退出登录按钮 -->
    <van-button v-if="isLoggedIn" block type="danger" class="logout-btn" @click="handleLogout">
      退出登录
    </van-button>
  </div>
</template>

✅ 验收标准

  • 我的页面显示用户信息(登录后)
  • 未登录时显示"点击登录"
  • 收藏/历史/设置入口正常跳转
  • 退出登录功能正常

🎯 第14节:实现主题切换功能

📌 学习目标

  • 理解CSS变量实现主题
  • 实现亮色/暗色模式切换
  • 主题持久化存储

⏱️ 预计用时:1小时

📝 操作步骤

步骤1:创建主题Store

src/store/theme.js

javascript 复制代码
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useThemeStore = defineStore('theme', () => {
  // 是否暗色模式
  const isDark = ref(localStorage.getItem('theme') === 'dark')

  // 切换主题
  const toggleTheme = () => {
    isDark.value = !isDark.value
    applyTheme()
  }

  // 应用主题(修改CSS变量和localStorage)
  const applyTheme = () => {
    if (isDark.value) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('theme', 'light')
    }
  }

  // 主题类名(用于绑定到根元素)
  const themeClass = computed(() => isDark.value ? 'dark-theme' : 'light-theme')

  // 初始化时应用主题
  applyTheme()

  return {
    isDark,
    toggleTheme,
    themeClass
  }
}, {
  persist: true
})
步骤2:定义CSS变量

在全局样式文件中:

css 复制代码
/* 亮色主题 */
:root {
  --bg-color: #f7f8fa;
  --card-bg: #ffffff;
  --text-primary: #323233;
  --text-secondary: #969799;
  --border-color: #ebedf0;
  --primary-color: #1989fa;
}

/* 暗色主题 */
.dark {
  --bg-color: #1a1a1a;
  --card-bg: #2c2c2c;
  --text-primary: #f5f5f5;
  --text-secondary: #aaa;
  --border-color: #3a3a3a;
  --primary-color: #1989fa;
}

/* 使用CSS变量 */
.app-container {
  background-color: var(--bg-color);
  color: var(--text-primary);
}

.van-card {
  background-color: var(--card-bg);
}

✅ 验收标准

  • 设置页有主题切换开关
  • 切换后整体配色变化
  • 刷新页面后保持选择的主题

🎯 第15节:实现国际化(中英文切换)

📌 学习目标

  • 配置vue-i18n
  • 创建中英文语言包
  • 实现语言切换功能

⏱️ 预计用时:1.5小时

📝 操作步骤

步骤1:配置i18n

src/i18n/index.js

javascript 复制代码
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'

const i18n = createI18n({
  legacy: false,  // 使用Composition API模式
  locale: localStorage.getItem('locale') || 'zh-CN',  // 默认中文
  fallbackLocale: 'zh-CN',  // 回退语言
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS
  }
})

export default i18n
步骤2:创建中文语言包

src/i18n/locales/zh-CN.js

javascript 复制代码
export default {
  // 通用
  common: {
    confirm: '确认',
    cancel: '取消',
    loading: '加载中...',
    success: '操作成功',
    fail: '操作失败',
    noData: '暂无数据'
  },
  // TabBar
  tabBar: {
    home: '首页',
    category: '分类',
    aichat: 'AI问答',
    my: '我的'
  },
  // 首页
  home: {
    title: '新闻资讯',
    refresh: '刷新成功',
    loadMore: '上拉加载更多',
    noMore: '没有更多了'
  },
  // 登录
  login: {
    title: '登录',
    username: '用户名',
    password: '密码',
    submit: '登录',
    registerLink: '还没有账号?立即注册',
    success: '登录成功'
  },
  // 注册
  register: {
    title: '注册',
    username: '用户名',
    password: '密码',
    confirmPassword: '确认密码',
    phone: '手机号',
    submit: '注册',
    loginLink: '已有账号?去登录',
    success: '注册成功'
  },
  // 我的
  my: {
    title: '我的',
    favorites: '我的收藏',
    history: '浏览历史',
    settings: '设置',
    logout: '退出登录',
    editProfile: '编辑资料'
  },
  // 设置
  settings: {
    title: '设置',
    theme: '深色模式',
    language: '语言',
    zh: '中文',
    en: 'English'
  }
}
步骤3:创建英文语言包

src/i18n/locales/en-US.js

javascript 复制代码
export default {
  common: {
    confirm: 'Confirm',
    cancel: 'Cancel',
    loading: 'Loading...',
    success: 'Success',
    fail: 'Failed',
    noData: 'No Data'
  },
  tabBar: {
    home: 'Home',
    category: 'Category',
    aichat: 'AI Chat',
    my: 'My'
  },
  home: {
    title: 'News',
    refresh: 'Refreshed',
    loadMore: 'Load more',
    noMore: 'No more data'
  },
  // ... 其他翻译
  login: {
    title: 'Login',
    username: 'Username',
    password: 'Password',
    submit: 'Sign In',
    registerLink: "Don't have an account? Sign up",
    success: 'Login successful'
  },
  register: {
    title: 'Register',
    username: 'Username',
    password: 'Password',
    confirmPassword: 'Confirm Password',
    phone: 'Phone',
    submit: 'Sign Up',
    loginLink: 'Already have an account? Sign in',
    success: 'Registration successful'
  },
  my: {
    title: 'My Profile',
    favorites: 'Favorites',
    history: 'History',
    settings: 'Settings',
    logout: 'Logout',
    editProfile: 'Edit Profile'
  },
  settings: {
    title: 'Settings',
    theme: 'Dark Mode',
    language: 'Language',
    zh: '中文',
    en: 'English'
  }
}
步骤4:在组件中使用
vue 复制代码
<template>
  <van-nav-bar :title="$t('home.title')" />
  <button @click="switchLanguage">{{ $t('settings.language') }}</button>
</template>

<script setup>
import { useI18n } from 'vue-i18n'

const { locale } = useI18n()

const switchLanguage = () => {
  locale.value = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
  localStorage.setItem('locale', locale.value)
}
</script>

✅ 验收标准

  • 设置页能切换中英文
  • 切换后所有文本变成对应语言
  • 刷新页面后保持语言选择

⚠️ 常见陷阱:不要在切换语言后调用 window.location.reload()

vue-i18n 的 locale 是响应式的,修改 locale.value 后,所有 $t() 绑定会自动重新渲染。全页刷新不仅体验差(页面闪烁),还可能导致临时状态丢失。需要做的只是更新 <html lang> 属性以确保 SEO 友好。

javascript 复制代码
// ❌ 错误做法(Bug #8)
locale.value = 'en-US'
window.location.reload()  // 多余的全页刷新!

// ✅ 正确做法
locale.value = 'en-US'
document.querySelector('html').setAttribute('lang', 'en-US')

🎯 第16节:前后端联调与优化

📌 学习目标

  • 统一处理API错误
  • 优化加载状态
  • 添加请求/响应拦截器

⏱️ 预计用时:1.5小时

📝 操作步骤

步骤1:配置后端地址与统一请求层

在实际项目中,我们把"基础地址"和"带拦截器的请求实例"拆成两个文件,职责更清晰。

(1)src/config/api.js --- 后端基础地址

javascript 复制代码
// 从环境变量读取后端地址:开发走 Vite 的 /api 代理,
// 生产/部署通过 VITE_API_BASE 指定真实地址
export const apiConfig = {
  baseURL: import.meta.env.VITE_API_BASE || ''  // 默认空串,配合 Vite 代理转发到后端
}

(2)src/api/request.js --- 统一请求实例 + 拦截器

javascript 复制代码
import axios from 'axios'
import { showToast } from 'vant'
import { useUserStore } from '../store/user'
import router from '../router'
import { apiConfig } from '../config/api'

// 创建axios实例(baseURL 来自环境变量)
const api = axios.create({
  baseURL: apiConfig.baseURL,
  timeout: 15000,  // 15秒超时
})

// 请求拦截器:自动携带Token
api.interceptors.request.use(
  (config) => {
    // 从用户 Store 读取当前 Token(不再手动读 localStorage)
    const userStore = useUserStore()
    const token = userStore.token
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一处理错误
api.interceptors.response.use(
  (response) => {
    const res = response.data
    // 业务层面的错误(后端返回code !== 0)
    if (res.code && res.code !== 0) {
      showToast(res.message || '请求失败')
      return Promise.reject(new Error(res.message))
    }
    return response
  },
  (error) => {
    // HTTP层面的错误
    if (error.response) {
      const { status } = error.response

      switch (status) {
        case 401:
          showToast('登录已过期,请重新登录')
          // 清除登录状态并跳转登录页(token 仅存内存,置空即可)
          const userStore = useUserStore()
          userStore.token = ''
          userStore.isLogin = false
          router.push('/login')
          break
        case 403:
          showToast('没有权限')
          break
        case 404:
          showToast('请求的资源不存在')
          break
        case 500:
          showToast('服务器内部错误')
          break
        default:
          showToast(error.response.data?.detail || '请求失败')
      }
    } else if (error.message.includes('timeout')) {
      showToast('请求超时,请检查网络')
    } else {
      showToast('网络异常,请检查网络连接')
    }

    return Promise.reject(error)
  }
)

export default api

💡 之后所有 Store 都 import request from '../api/request' 调用,不再手写 Authorization 头,也不在 config/api.js 里堆拦截器。

✅ 验收标准

  • Token过期后自动跳转登录页
  • 网络错误时显示友好提示
  • 所有API请求自动携带Token

🎯 第17节:打包构建与部署准备

📌 学习目标

  • 生产环境构建配置
  • 前端打包优化
  • 后端生产配置

⏱️ 预计用时:1小时

📝 操作步骤

步骤1:前端打包
bash 复制代码
# 安装生产依赖
npm install

# 构建(生成dist目录)
npm run build

构建产物在 dist/ 目录,可以直接部署到任意静态服务器。

步骤2:后端生产配置

创建 .env.production

env 复制代码
# 生产环境配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your-production-password
DB_NAME=toutiao_db

REDIS_HOST=localhost
REDIS_PORT=6379

# 关闭调试模式
DEBUG=false

✅ 验收标准

  • npm run build 无报错
  • dist目录包含index.html和静态资源
  • 后端能使用生产配置启动

🎯 第18节:项目总结与复盘

📌 学习目标

  • 回顾整个项目的技术栈
  • 总结各模块的核心知识点
  • 明确后续学习方向

⏱️ 预计用时:30分钟

📝 项目技术总结

模块 技术点 掌握程度
后端基础 FastAPI、路由、请求处理 ⭐⭐⭐⭐⭐
数据库 MySQL、SQLAlchemy ORM ⭐⭐⭐⭐
认证鉴权 JWT、密码加密 ⭐⭐⭐⭐
API设计 RESTful规范、分页 ⭐⭐⭐⭐
前端基础 Vue3组件化、Composition API ⭐⭐⭐⭐⭐
状态管理 Pinia Store ⭐⭐⭐⭐
路由 Vue Router ⭐⭐⭐⭐
UI组件 Vant移动端组件 ⭐⭐⭐⭐
HTTP请求 Axios封装 ⭐⭐⭐⭐
主题系统 CSS变量动态切换 ⭐⭐⭐
国际化 vue-i18n ⭐⭐⭐

🎉 恭喜完成!

你已经完成了一个完整的全栈项目!接下来可以:

  1. 查看 《04_项目架构与文件结构说明书》 深入理解架构
  2. 查看 《05_源码注释与核心知识点手册》 研读完整源码
  3. 查看 《07_功能测试与学生验收清单》 进行自我检验
  4. 查看 《08_项目部署与进阶拓展指南》 学习部署上线

文档版本 :v1.0

更新日期 :2026年7月

适用项目:toutiao_heima 新闻头条全栈项目

相关推荐
满栀5852 小时前
vue动态路由效果
前端·javascript·vue.js·前端框架·vue
心运软件3 小时前
SpringBoot+ Vue校园社团管理平台的完整架构设计
vue.js·后端
jjw_zyfx3 小时前
css vue vite实现闪烁的呼吸效果
javascript·css·vue.js
浅水壁虎5 小时前
vue基础(第四章 Pinia)
前端·javascript·vue.js
学习星球6 小时前
Vue 3 实战:拆解 RealWorld 项目
前端·vue.js
卷无止境6 小时前
用 FastAPI 撑起大文件的上传下载:从流式处理到断点续传的完整实践
后端·python·fastapi
大家的林语冰8 小时前
👉 尤雨溪再次成立新公司,同时官宣 Pinia 4 正式发布!
前端·javascript·vue.js
栀鸢ouo9 小时前
解决Element Plus表格展开行横向溢出、滚动截断问题(项目实战方案)
前端·vue.js
碧水澜庭9 小时前
头条【vue+fastapi 】全栈教学项目系列之《04_项目架构与文件结构说明书》
vue.js·架构·fastapi
今天吃了嘛o10 小时前
Vue3中封装高德地图使用
前端·vue.js·typescript