阶段 05:后端课程模块
合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :后端 | 阶段 :5 / 12 | 前置阶段 :阶段 04:后端用户门店模块
大家好,我是小马不起床。
目录
- 一、阶段目标
- 二、本阶段开发顺序
- 三、接口规划
- 四、本阶段文件清单
- [五、步骤 1:创建课程 Schema](#五、步骤 1:创建课程 Schema)
- [六、步骤 2:创建课程 Service](#六、步骤 2:创建课程 Service)
- [七、步骤 3:创建课程 API 路由](#七、步骤 3:创建课程 API 路由)
- [八、步骤 4:注册课程路由](#八、步骤 4:注册课程路由)
- [九、步骤 5:修改路由注册测试](#九、步骤 5:修改路由注册测试)
- [十、步骤 6:启动服务验证](#十、步骤 6:启动服务验证)
- [十一、Swagger 验证样例](#十一、Swagger 验证样例)
- 十二、本阶段验收清单
- 十三、常见问题
- 十四、进入下一阶段的条件
- 十五、问题记录与修复方案
一、阶段目标
本阶段目标是在用户、门店、基于角色的访问控制(RBAC,Role-Based Access Control)权限已经完成的基础上,实现课程分类、课程主数据、用户课程收藏(Favorite)等后端接口。
完成本阶段后,后端应具备以下能力:
- 管理员可以维护课程分类。
- 管理员可以维护课程主数据。
- 管理员可以按分类、难度、状态、关键字筛选课程。
- 登录用户可以收藏课程。
- 登录用户可以取消收藏课程。
- 登录用户可以查看自己的课程收藏列表。
- 后台管理接口接入
course:*权限。 - 用户收藏接口只要求登录,不额外要求后台权限。
二、本阶段开发顺序
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
注意事项
status目前仍使用整数,建议后续统一补充枚举校验。course_difficulty建议业务上限定为 1 到 5。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())
注意事项
- 删除分类前检查是否有课程。
- 删除课程前检查是否被收藏。
- 收藏接口重复调用不会重复插入,直接返回已有收藏。
- 取消收藏时如果不存在,返回业务错误。
七、步骤 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])
注意事项
- 课程管理接口需要
course:*权限。 - 收藏接口只要求用户登录。
/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
验证顺序
- 使用
/api/auth/token获取访问令牌,并在 Swagger 完成授权。 - 创建课程分类。
- 创建课程。
- 查询课程列表。
- 查看课程详情。
- 收藏课程。
- 查看当前用户课程收藏列表。
- 取消收藏课程。
十一、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:后端动作库模块。
必须满足:
- 课程分类增删改查(CRUD,Create/Read/Update/Delete)正常。
- 课程 CRUD 正常。
- 用户课程收藏正常。
- 权限校验正常。
pytest通过。- Swagger 验证通过。
十五、问题记录与修复方案
本阶段实践中遇到问题时,可将报错原文完整记录,便于按错误关键词检索与定位。记录内容按以下流程处理:
- 判断错误属于课程分类、课程、收藏、权限、路由注册还是序列化问题。
- 将修复办法追加到本文件底部。
问题追加格式
尖括号 <> 内容为占位符,使用时按实际环境替换。
text
### 问题 N:问题标题
#### 报错现象
按下方模板记录报错信息。
#### 原因分析
这里说明为什么会报错。
#### 修复办法
这里给出具体修复步骤。
#### 需要修改的文件
- 相对路径:<文件相对路径>
- 修改说明:<修改说明>
- 完整代码或补丁:<完整代码或补丁>