零基础玩转 FastAPI_05:后端课程模块

阶段 05:后端课程模块

合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :后端 | 阶段 :5 / 12 | 前置阶段 :阶段 04:后端用户门店模块

大家好,我是小马不起床。

目录

  1. 一、阶段目标
  2. 二、本阶段开发顺序
  3. 三、接口规划
  4. 四、本阶段文件清单
  5. [五、步骤 1:创建课程 Schema](#五、步骤 1:创建课程 Schema)
  6. [六、步骤 2:创建课程 Service](#六、步骤 2:创建课程 Service)
  7. [七、步骤 3:创建课程 API 路由](#七、步骤 3:创建课程 API 路由)
  8. [八、步骤 4:注册课程路由](#八、步骤 4:注册课程路由)
  9. [九、步骤 5:修改路由注册测试](#九、步骤 5:修改路由注册测试)
  10. [十、步骤 6:启动服务验证](#十、步骤 6:启动服务验证)
  11. [十一、Swagger 验证样例](#十一、Swagger 验证样例)
  12. 十二、本阶段验收清单
  13. 十三、常见问题
  14. 十四、进入下一阶段的条件
  15. 十五、问题记录与修复方案

一、阶段目标

本阶段目标是在用户、门店、基于角色的访问控制(RBAC,Role-Based Access Control)权限已经完成的基础上,实现课程分类、课程主数据、用户课程收藏(Favorite)等后端接口。

完成本阶段后,后端应具备以下能力:

  1. 管理员可以维护课程分类。
  2. 管理员可以维护课程主数据。
  3. 管理员可以按分类、难度、状态、关键字筛选课程。
  4. 登录用户可以收藏课程。
  5. 登录用户可以取消收藏课程。
  6. 登录用户可以查看自己的课程收藏列表。
  7. 后台管理接口接入 course:* 权限。
  8. 用户收藏接口只要求登录,不额外要求后台权限。

二、本阶段开发顺序

text 复制代码
1. 创建课程 Schema
2. 创建课程 Service
3. 创建课程 API 路由
4. 注册课程路由
5. 创建课程路由测试
6. 启动服务并在 Swagger 验证接口

三、接口规划

方法 路径 权限 说明
GET /api/course-categories course:list 课程分类列表
POST /api/course-categories course:create 创建课程分类
PUT /api/course-categories/{category_id} course:update 修改课程分类
DELETE /api/course-categories/{category_id} course:delete 删除课程分类
GET /api/courses course:list 课程分页列表
POST /api/courses course:create 创建课程
GET /api/courses/{course_id} course:list 课程详情
PUT /api/courses/{course_id} course:update 修改课程
DELETE /api/courses/{course_id} course:delete 删除课程
POST /api/courses/{course_id}/favorite 登录 收藏课程
DELETE /api/courses/{course_id}/favorite 登录 取消收藏课程
GET /api/me/course-favorites 登录 当前用户课程收藏列表

四、本阶段文件清单

序号 相对路径 类型 说明
1 backend/app/schemas/course.py 新增 课程分类、课程、收藏 Schema
2 backend/app/services/course.py 新增 课程业务逻辑
3 backend/app/api/v1/course.py 新增 课程接口
4 backend/app/api/router.py 修改 注册课程路由
5 backend/tests/test_routes.py 修改 增加课程路由注册测试

五、步骤 1:创建课程 Schema

相对路径

text 复制代码
backend/app/schemas/course.py

完整代码

python 复制代码
from datetime import datetime

from pydantic import BaseModel, Field

from app.schemas.common import ORMBaseModel


class CourseCategoryCreate(BaseModel):
    category_name: str = Field(min_length=1, max_length=64, description="分类名称")
    category_url: str | None = Field(default=None, max_length=255, description="分类图片URL")
    description: str | None = Field(default=None, max_length=500, description="分类描述")
    status: int = Field(default=1, description="状态:1启用,0禁用")


class CourseCategoryUpdate(BaseModel):
    category_name: str | None = Field(default=None, min_length=1, max_length=64)
    category_url: str | None = Field(default=None, max_length=255)
    description: str | None = Field(default=None, max_length=500)
    status: int | None = None


class CourseCategoryRead(ORMBaseModel):
    category_id: int
    category_name: str
    category_url: str | None = None
    description: str | None = None
    status: int | None = None


class CourseCreate(BaseModel):
    course_name: str = Field(min_length=1, max_length=64, description="课程名称")
    category_id: int = Field(description="课程分类ID")
    course_difficulty: int | None = Field(default=None, description="课程难度")
    duration_minutes: int | None = Field(default=None, ge=1, description="课程时长")
    max_participants: int | None = Field(default=None, ge=1, description="最大参与人数")
    schedule_info: str | None = Field(default=None, description="排期信息")
    description: str | None = Field(default=None, description="课程描述")
    status: int = Field(default=1, description="状态:1启用,0禁用")


class CourseUpdate(BaseModel):
    course_name: str | None = Field(default=None, min_length=1, max_length=64)
    category_id: int | None = None
    course_difficulty: int | None = None
    duration_minutes: int | None = Field(default=None, ge=1)
    max_participants: int | None = Field(default=None, ge=1)
    schedule_info: str | None = None
    description: str | None = None
    status: int | None = None


class CourseRead(ORMBaseModel):
    course_id: int
    course_name: str
    category_id: int
    course_difficulty: int | None = None
    duration_minutes: int | None = None
    max_participants: int | None = None
    schedule_info: str | None = None
    description: str | None = None
    status: int | None = None


class CourseFavoriteRead(ORMBaseModel):
    favorite_id: int
    user_id: int
    course_id: int
    created_at: datetime | None = None

注意事项

  1. status 目前仍使用整数,建议后续统一补充枚举校验。
  2. course_difficulty 建议业务上限定为 1 到 5。
  3. schedule_info 暂时用字符串保存,后续可以升级成 JSON 字段或排课表。

六、步骤 2:创建课程 Service

相对路径

text 复制代码
backend/app/services/course.py

完整代码

python 复制代码
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.core.exceptions import BusinessException
from app.models.course import Course, CourseCategory, UserCourseFavorite
from app.schemas.course import CourseCategoryCreate, CourseCategoryUpdate, CourseCreate, CourseUpdate
from app.utils.pagination import paginate


def list_course_categories(db: Session) -> list[CourseCategory]:
    stmt = select(CourseCategory).order_by(CourseCategory.category_id.asc())
    return list(db.scalars(stmt).all())


def get_course_category_or_404(db: Session, category_id: int) -> CourseCategory:
    category = db.get(CourseCategory, category_id)
    if not category:
        raise BusinessException(message="course category not found", code=404)
    return category


def create_course_category(
    db: Session,
    category_in: CourseCategoryCreate,
) -> CourseCategory:
    category = CourseCategory(**category_in.model_dump())
    db.add(category)
    db.commit()
    db.refresh(category)
    return category


def update_course_category(
    db: Session,
    category_id: int,
    category_in: CourseCategoryUpdate,
) -> CourseCategory:
    category = get_course_category_or_404(db, category_id)
    update_data = category_in.model_dump(exclude_unset=True)

    for field, value in update_data.items():
        setattr(category, field, value)

    db.add(category)
    db.commit()
    db.refresh(category)
    return category


def delete_course_category(db: Session, category_id: int) -> None:
    category = get_course_category_or_404(db, category_id)
    course_count = db.scalar(
        select(func.count()).select_from(Course).where(Course.category_id == category_id)
    )
    if course_count:
        raise BusinessException(message="category has courses, cannot delete", code=400)

    db.delete(category)
    db.commit()


def list_courses(
    db: Session,
    page: int = 1,
    page_size: int = 10,
    keyword: str | None = None,
    category_id: int | None = None,
    course_difficulty: int | None = None,
    status: int | None = None,
):
    stmt = select(Course).order_by(Course.course_id.desc())

    if keyword:
        stmt = stmt.where(Course.course_name.like(f"%{keyword}%"))
    if category_id is not None:
        stmt = stmt.where(Course.category_id == category_id)
    if course_difficulty is not None:
        stmt = stmt.where(Course.course_difficulty == course_difficulty)
    if status is not None:
        stmt = stmt.where(Course.status == status)

    return paginate(db, stmt, page=page, page_size=page_size)


def get_course_or_404(db: Session, course_id: int) -> Course:
    course = db.get(Course, course_id)
    if not course:
        raise BusinessException(message="course not found", code=404)
    return course


def create_course(db: Session, course_in: CourseCreate) -> Course:
    get_course_category_or_404(db, course_in.category_id)

    course = Course(**course_in.model_dump())
    db.add(course)
    db.commit()
    db.refresh(course)
    return course


def update_course(
    db: Session,
    course_id: int,
    course_in: CourseUpdate,
) -> Course:
    course = get_course_or_404(db, course_id)
    update_data = course_in.model_dump(exclude_unset=True)

    if "category_id" in update_data:
        get_course_category_or_404(db, update_data["category_id"])

    for field, value in update_data.items():
        setattr(course, field, value)

    db.add(course)
    db.commit()
    db.refresh(course)
    return course


def delete_course(db: Session, course_id: int) -> None:
    course = get_course_or_404(db, course_id)
    favorite_count = db.scalar(
        select(func.count()).select_from(UserCourseFavorite).where(
            UserCourseFavorite.course_id == course_id
        )
    )
    if favorite_count:
        raise BusinessException(message="course has favorites, cannot delete", code=400)

    db.delete(course)
    db.commit()


def favorite_course(
    db: Session,
    user_id: int,
    course_id: int,
) -> UserCourseFavorite:
    get_course_or_404(db, course_id)

    favorite = db.scalar(
        select(UserCourseFavorite).where(
            UserCourseFavorite.user_id == user_id,
            UserCourseFavorite.course_id == course_id,
        )
    )
    if favorite:
        return favorite

    favorite = UserCourseFavorite(user_id=user_id, course_id=course_id)
    db.add(favorite)
    db.commit()
    db.refresh(favorite)
    return favorite


def unfavorite_course(
    db: Session,
    user_id: int,
    course_id: int,
) -> None:
    favorite = db.scalar(
        select(UserCourseFavorite).where(
            UserCourseFavorite.user_id == user_id,
            UserCourseFavorite.course_id == course_id,
        )
    )
    if not favorite:
        raise BusinessException(message="course favorite not found", code=404)

    db.delete(favorite)
    db.commit()


def list_my_course_favorites(db: Session, user_id: int) -> list[UserCourseFavorite]:
    stmt = (
        select(UserCourseFavorite)
        .where(UserCourseFavorite.user_id == user_id)
        .order_by(UserCourseFavorite.favorite_id.desc())
    )
    return list(db.scalars(stmt).all())

注意事项

  1. 删除分类前检查是否有课程。
  2. 删除课程前检查是否被收藏。
  3. 收藏接口重复调用不会重复插入,直接返回已有收藏。
  4. 取消收藏时如果不存在,返回业务错误。

七、步骤 3:创建课程 API 路由

相对路径

text 复制代码
backend/app/api/v1/course.py

完整代码

python 复制代码
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session

from app.api.deps import get_current_user, require_permission
from app.db.session import get_db
from app.models.course import Course, CourseCategory, UserCourseFavorite
from app.models.user import SysUser
from app.schemas.course import (
    CourseCategoryCreate,
    CourseCategoryUpdate,
    CourseCreate,
    CourseUpdate,
)
from app.services.course import (
    create_course,
    create_course_category,
    delete_course,
    delete_course_category,
    favorite_course,
    get_course_or_404,
    list_course_categories,
    list_courses,
    list_my_course_favorites,
    unfavorite_course,
    update_course,
    update_course_category,
)
from app.utils.response import success


router = APIRouter(tags=["课程管理"])


def category_to_dict(item: CourseCategory) -> dict:
    return {
        "category_id": item.category_id,
        "category_name": item.category_name,
        "category_url": item.category_url,
        "description": item.description,
        "status": item.status,
    }


def course_to_dict(item: Course) -> dict:
    return {
        "course_id": item.course_id,
        "course_name": item.course_name,
        "category_id": item.category_id,
        "course_difficulty": item.course_difficulty,
        "duration_minutes": item.duration_minutes,
        "max_participants": item.max_participants,
        "schedule_info": item.schedule_info,
        "description": item.description,
        "status": item.status,
    }


def favorite_to_dict(item: UserCourseFavorite) -> dict:
    return {
        "favorite_id": item.favorite_id,
        "user_id": item.user_id,
        "course_id": item.course_id,
        "created_at": item.created_at,
    }


@router.get("/course-categories")
def get_course_categories(
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:list")),
) -> dict:
    items = list_course_categories(db)
    return success(data=[category_to_dict(item) for item in items])


@router.post("/course-categories")
def add_course_category(
    payload: CourseCategoryCreate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:create")),
) -> dict:
    category = create_course_category(db, payload)
    return success(data=category_to_dict(category))


@router.put("/course-categories/{category_id}")
def edit_course_category(
    category_id: int,
    payload: CourseCategoryUpdate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:update")),
) -> dict:
    category = update_course_category(db, category_id, payload)
    return success(data=category_to_dict(category))


@router.delete("/course-categories/{category_id}")
def remove_course_category(
    category_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:delete")),
) -> dict:
    delete_course_category(db, category_id)
    return success(message="deleted")


@router.get("/courses")
def get_courses(
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=10, ge=1, le=100),
    keyword: str | None = Query(default=None),
    category_id: int | None = Query(default=None),
    course_difficulty: int | None = Query(default=None),
    status: int | None = Query(default=None),
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:list")),
) -> dict:
    result = list_courses(
        db,
        page=page,
        page_size=page_size,
        keyword=keyword,
        category_id=category_id,
        course_difficulty=course_difficulty,
        status=status,
    )
    return success(
        data={
            "items": [course_to_dict(item) for item in result.items],
            "total": result.total,
            "page": result.page,
            "page_size": result.page_size,
            "pages": result.pages,
        }
    )


@router.post("/courses")
def add_course(
    payload: CourseCreate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:create")),
) -> dict:
    course = create_course(db, payload)
    return success(data=course_to_dict(course))


@router.get("/courses/{course_id}")
def get_course_detail(
    course_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:list")),
) -> dict:
    course = get_course_or_404(db, course_id)
    return success(data=course_to_dict(course))


@router.put("/courses/{course_id}")
def edit_course(
    course_id: int,
    payload: CourseUpdate,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:update")),
) -> dict:
    course = update_course(db, course_id, payload)
    return success(data=course_to_dict(course))


@router.delete("/courses/{course_id}")
def remove_course(
    course_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(require_permission("course:delete")),
) -> dict:
    delete_course(db, course_id)
    return success(message="deleted")


@router.post("/courses/{course_id}/favorite")
def add_course_favorite(
    course_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(get_current_user),
) -> dict:
    favorite = favorite_course(db, current_user.user_id, course_id)
    return success(data=favorite_to_dict(favorite))


@router.delete("/courses/{course_id}/favorite")
def remove_course_favorite(
    course_id: int,
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(get_current_user),
) -> dict:
    unfavorite_course(db, current_user.user_id, course_id)
    return success(message="deleted")


@router.get("/me/course-favorites")
def get_my_course_favorites(
    db: Session = Depends(get_db),
    current_user: SysUser = Depends(get_current_user),
) -> dict:
    items = list_my_course_favorites(db, current_user.user_id)
    return success(data=[favorite_to_dict(item) for item in items])

注意事项

  1. 课程管理接口需要 course:* 权限。
  2. 收藏接口只要求用户登录。
  3. /api/me/course-favorites 放在同一个课程路由文件中,方便维护收藏逻辑。

八、步骤 4:注册课程路由

相对路径

text 复制代码
backend/app/api/router.py

完整代码

python 复制代码
from fastapi import APIRouter

from app.api.v1 import auth, course, health, store, system, user


api_router = APIRouter()

api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(system.router)
api_router.include_router(user.router)
api_router.include_router(store.router)
api_router.include_router(course.router)

九、步骤 5:修改路由注册测试

相对路径

text 复制代码
backend/tests/test_routes.py

完整代码

python 复制代码
from app.main import app


def test_user_and_store_routes_are_registered() -> None:
    paths = {route.path for route in app.routes}

    assert "/api/users" in paths
    assert "/api/users/{user_id}" in paths
    assert "/api/users/{user_id}/profile" in paths
    assert "/api/users/{user_id}/stores" in paths
    assert "/api/provinces" in paths
    assert "/api/provinces/{province_id}" in paths
    assert "/api/stores" in paths
    assert "/api/stores/{store_id}" in paths


def test_course_routes_are_registered() -> None:
    paths = {route.path for route in app.routes}

    assert "/api/course-categories" in paths
    assert "/api/course-categories/{category_id}" in paths
    assert "/api/courses" in paths
    assert "/api/courses/{course_id}" in paths
    assert "/api/courses/{course_id}/favorite" in paths
    assert "/api/me/course-favorites" in paths

十、步骤 6:启动服务验证

启动命令

当前目录必须为:

text 复制代码
FastAPI_Vue3_JoyFit/backend

执行:

bash 复制代码
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

Swagger 地址

text 复制代码
http://127.0.0.1:8000/docs

验证顺序

  1. 使用 /api/auth/token 获取访问令牌,并在 Swagger 完成授权。
  2. 创建课程分类。
  3. 创建课程。
  4. 查询课程列表。
  5. 查看课程详情。
  6. 收藏课程。
  7. 查看当前用户课程收藏列表。
  8. 取消收藏课程。

十一、Swagger 验证样例

1. 创建课程分类

接口:

text 复制代码
POST /api/course-categories

请求体:

json 复制代码
{
  "category_name": "力量训练",
  "category_url": "https://example.com/category-strength.png",
  "description": "基础力量与器械训练课程",
  "status": 1
}

2. 创建课程

接口:

text 复制代码
POST /api/courses

请求体:

json 复制代码
{
  "course_name": "深蹲基础课",
  "category_id": 1,
  "course_difficulty": 2,
  "duration_minutes": 60,
  "max_participants": 12,
  "schedule_info": "每周一、三、五 19:00-20:00",
  "description": "学习深蹲动作模式、呼吸、核心稳定和基础负重训练。",
  "status": 1
}

category_id 必须为数据库中实际存在的分类 ID。

3. 查询课程列表

接口:

text 复制代码
GET /api/courses?page=1&page_size=10

4. 收藏课程

接口:

text 复制代码
POST /api/courses/1/favorite

/api/courses/1/favorite 路径中的 1 必须为数据库中实际存在的 course_id。

5. 查看当前用户课程收藏

接口:

text 复制代码
GET /api/me/course-favorites

6. 取消收藏课程

接口:

text 复制代码
DELETE /api/courses/1/favorite

十二、本阶段验收清单

  • 课程分类列表:/api/course-categories 可访问
  • 创建课程分类:可以创建分类
  • 修改课程分类:可以修改分类
  • 删除课程分类:无课程关联时可以删除
  • 课程列表:/api/courses 可分页查询
  • 创建课程:课程可绑定分类
  • 修改课程:课程字段可修改
  • 删除课程:无收藏时可以删除
  • 收藏课程:登录用户可以收藏
  • 取消收藏:登录用户可以取消收藏
  • 收藏查询:可以查询当前用户收藏列表
  • 权限校验:管理接口无权限时返回 403

十三、常见问题

1. 创建课程提示 course category not found

原因:

传入的 category_id 不存在。

修复:

先调用:

text 复制代码
POST /api/course-categories

创建课程分类,再使用返回的 category_id 创建课程。

2. 删除课程分类提示 category has courses, cannot delete

原因:

该分类下已经有课程。

修复:

先删除或迁移该分类下的课程。

3. 删除课程提示 course has favorites, cannot delete

原因:

课程已经被用户收藏。

修复:

先取消相关收藏,或改为后台逻辑删除课程。

4. 收藏课程提示 course not found

原因:

路径中的 course_id 不存在。

修复:

先调用:

text 复制代码
GET /api/courses

获取数据库中实际存在的 course_id。

5. 后台课程接口返回 403 permission denied

原因:

当前用户角色没有 course:list、course:create、course:update 或 course:delete 权限。

修复:

使用管理员账号测试,或给当前角色分配课程权限。

十四、进入下一阶段的条件

满足以下条件后,可以进入下一阶段:阶段 06:后端动作库模块。

必须满足:

  1. 课程分类增删改查(CRUD,Create/Read/Update/Delete)正常。
  2. 课程 CRUD 正常。
  3. 用户课程收藏正常。
  4. 权限校验正常。
  5. pytest 通过。
  6. Swagger 验证通过。

十五、问题记录与修复方案

本阶段实践中遇到问题时,可将报错原文完整记录,便于按错误关键词检索与定位。记录内容按以下流程处理:

  1. 判断错误属于课程分类、课程、收藏、权限、路由注册还是序列化问题。
  2. 将修复办法追加到本文件底部。

问题追加格式

尖括号 <> 内容为占位符,使用时按实际环境替换。

text 复制代码
### 问题 N:问题标题

#### 报错现象

按下方模板记录报错信息。

#### 原因分析

这里说明为什么会报错。

#### 修复办法

这里给出具体修复步骤。

#### 需要修改的文件

- 相对路径:<文件相对路径>
- 修改说明:<修改说明>
- 完整代码或补丁:<完整代码或补丁>
相关推荐
pride.li5 小时前
第03章 Python 教程-基础语法:注释与变量
服务器·前端·后端·python·算法·计算机视觉
databook5 小时前
用 Pydantic AI 从零搭建一个能干活的 AI Agent
人工智能·python
康小旺5 小时前
把 markitdown 做成 Windows 桌面版:拖进文档就出 Markdown,附 233MB→64MB 打包裁剪全过程
python
启观川6 小时前
数据结构与算法-第 3 章 常用算法-贪心算法
数据结构·笔记·python·算法·贪心算法
南笙北梦6 小时前
本地开源大模型多轮对话显存泄漏排查与修复实录
python
小羊没烦恼!6 小时前
如何在IIS6,7中部署ASP.NET网站?
数据库·后端·python·web安全·asp.net
钱栈up7 小时前
扫完 3.5 万文件后,我发现了 521 组散落的同名文件
爬虫·python·数据分析
黄权光7 小时前
AI智能体项目实战记录(01):开发环境搭建之conda的安装与配置
python
Wokoo77 小时前
Selenium 核心 API 解析(三)
python·功能测试·selenium
在世修行7 小时前
智学文枢:技术选型
python·技术选型