零基础玩转 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:问题标题

#### 报错现象

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

#### 原因分析

这里说明为什么会报错。

#### 修复办法

这里给出具体修复步骤。

#### 需要修改的文件

- 相对路径:<文件相对路径>
- 修改说明:<修改说明>
- 完整代码或补丁:<完整代码或补丁>
相关推荐
前端精髓13 小时前
langchain 短期记忆(状态)动态运行时上下文
python·langchain
最强小杰13 小时前
DeepSeek V4 Flash + Codex CLI 接入教程:原生 Responses API 适配,从配置到跑通收藏这篇就够了(2026)
java·前端·python·ai
DeepVisionary14 小时前
为拿并网许可,OpenAI在佐治亚新园区同意高峰期断电三成
python·自动化
VIP_CQCRE15 小时前
内容自动化越跑越乱?先补上状态、去重和失败恢复
python·ai·工作流·幂等设计·内容自动化
我喜欢就喜欢15 小时前
RAG 重排序 Rerank 实战:用 bge-reranker 把 Top-K 命中率拉回来
python·大模型·向量检索·rag·rerank
专注仿真15 小时前
卷积神经网络(CNN)算法详解
人工智能·python·算法·代码·大模型训练
isfox15 小时前
Python3 协程:异步编程的核心机制与实战
python
用户2986985301415 小时前
Python 将 Markdown 批量转为 HTML:单文件与目录遍历两种实现
python·html·markdown
miofly15 小时前
GitHub 今日推荐|auto-ontology:给 Agent 装上企业数据的语义索引
python·开源·github