Django 后端实战指南_16:动作分类、动作库、动作收藏查询接口

阶段 16:动作分类、动作库、动作收藏查询接口

合辑 :Django 系统阶段开发手册 | 阶段 :16 / 18 | 定位 :为动作库与用户动作收藏提供受登录保护的查询与收藏接口 | 建议学时:6--8 小时

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


阶段导语

本阶段在 course 模块(阶段 15)完成课程类查询接口之后展开,将同一套视图组织方式、序列化规范与登录保护机制迁移到动作领域。动作模块沿用 RBAC 的 login_required 装饰器与统一响应封装,接口契约与课程模块保持一致,前端无需针对领域差异编写额外的适配逻辑。

本阶段交付六个接口:动作分类列表、动作列表(支持多条件筛选)、动作详情、当前用户收藏列表、收藏动作、取消收藏。收藏数据与用户身份绑定,所有读写操作均以 JWT 解析出的当前用户为作用域边界。


学习目标

完成本阶段后,应能够:

  • 基于 t_action_category、t_action、y_user_action_favorite 三张表构建动作领域的查询接口
  • 为动作列表实现按分类、难度、适用门店类型筛选与名称模糊搜索
  • 在序列化函数中关联当前用户,输出 is_favorite 与 favorite_id 字段
  • 使用 get_or_create 实现幂等收藏,避免重复记录引发数据库异常
  • 将收藏记录 ID 与动作 ID 区分,取消收藏仅作用于当前用户的记录
  • 通过 exercise/urls.py 与项目总路由挂载 /api/exercise/ 路由前缀

前置知识

本阶段依赖阶段 15(课程类查询接口)的接口组织方式,以及阶段 12、阶段 13 的模型与数据初始化:

前置条目 具体要求
阶段 15:课程类查询接口 视图类结构、序列化函数、login_required 与 json_response 的使用方式
阶段 12:数据模型与迁移 exercise 应用的动作分类表、动作表、用户动作收藏表已迁移建表
阶段 13:业务数据初始化 init_business_data 命令已生成动作分类与动作测试数据
JWT 登录 能获取 access_token 并在请求头中携带

目录

  1. 阶段目标与数据模型
  2. 接口清单与响应结构
  3. 视图层实现
  4. 路由层配置
  5. 后端启动与接口测试
  6. 用户隔离与匿名请求验证
  7. 阶段验收与模块进度
  8. 阶段小结
  9. 常见问题与排查
  10. 下一阶段衔接

1. 阶段目标与数据模型

1.1 阶段目标

本阶段实现以下六项能力:

text 复制代码
1. 查询动作分类列表
2. 查询动作列表
3. 查询动作详情
4. 查询当前用户收藏的动作
5. 收藏动作
6. 取消收藏动作

1.2 数据模型基础

本阶段建立在前面阶段设计的三张表之上:

text 复制代码
t_action_category          动作分类表
t_action                   动作库条目表
y_user_action_favorite     用户动作收藏表

三张表的职责与关联关系如下:

表名 用途 关键字段与关联
t_action_category 动作分类主数据 category_id 作为分类主键
t_action 动作主数据 通过 category_id 关联动作分类
y_user_action_favorite 用户动作收藏关系 通过 user_id 关联用户、action_id 关联动作

要点:

  • 要点:动作模块的数据边界由三张表构成,分类与动作为一对多,收藏为用户与动作的多对多关系表。
  • 要点 :t_action 与 t_action_category 通过 category_id 建立外键关联,序列化时需 select_related 以减少查询次数。
  • 要点:收藏关系表的复合主体为「用户 + 动作」,所有收藏读写均以当前登录用户过滤。

2. 接口清单与响应结构

动作相关接口统一归入 /api/exercise/ 前缀,全部要求登录。

2.1 接口总览

方法 路径 参数 返回说明 是否需要登录
GET /api/exercise/categories/ 无 动作分类列表 需要
GET /api/exercise/actions/ category_id、action_difficulty、applicable_store_type、keyword(均可选) 动作列表,含当前用户收藏状态 需要
GET /api/exercise/actions/<action_id>/ 路径参数 action_id 单个动作详情 需要
GET /api/exercise/my-favorites/ 无 当前用户收藏的动作列表 需要
POST /api/exercise/favorites/ 请求体 action_id 收藏动作,返回收藏记录 需要
DELETE /api/exercise/favorites/<favorite_id>/ 路径参数 favorite_id 取消收藏动作 需要

2.2 动作分类列表

text 复制代码
GET /api/exercise/categories/

返回结构:

json 复制代码
{
  "code": 200,
  "message": "获取动作分类成功",
  "data": [
    {
      "category_id": 1,
      "category_name": "胸部",
      "category_image_url": "https://example.com/action-chest.jpg"
    }
  ]
}

2.3 动作列表

text 复制代码
GET /api/exercise/actions/

支持以下筛选参数:

参数 作用
category_id 按动作分类筛选
action_difficulty 按动作难度筛选
applicable_store_type 按适用门店类型筛选
keyword 按动作名称模糊搜索

请求示例:

text 复制代码
GET /api/exercise/actions/?category_id=1
GET /api/exercise/actions/?keyword=深蹲
GET /api/exercise/actions/?applicable_store_type=1

2.4 动作详情

text 复制代码
GET /api/exercise/actions/1/

返回指定动作的详细信息。

2.5 我的动作收藏

text 复制代码
GET /api/exercise/my-favorites/

后端根据 JWT 定位当前用户,返回该用户收藏的动作。

2.6 收藏动作

text 复制代码
POST /api/exercise/favorites/

请求体:

json 复制代码
{
  "action_id": 1
}

2.7 取消收藏动作

text 复制代码
DELETE /api/exercise/favorites/1/

路径中的 1 为收藏记录的 favorite_id,而非动作 ID。

要点:

  • 要点:动作列表接口以查询参数实现可选筛选,未传参数时返回全量数据。
  • 要点 :applicable_store_type 与门店类型枚举对应:1 为铁馆、2 为商业私教馆。
  • 要点 :收藏与取消收藏以 action_id(新增)和 favorite_id(删除)为入参,二者语义不同,不可混用。

3. 视图层实现

打开文件:

text 复制代码
backend/exercise/views.py

将内容替换为以下完整代码。

3.1 backend/exercise/views.py

python 复制代码
import json

from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator

from rbac.auth import login_required
from rbac.responses import json_response

from .models import (
    ActionCategory,
    Action,
    UserActionFavorite,
)


def get_json_body(request):
    """
    获取 JSON 请求体
    """

    try:
        body = request.body.decode("utf-8")

        if not body:
            return {}

        return json.loads(body)

    except json.JSONDecodeError:
        return None


def format_datetime_value(value):
    """
    datetime 转字符串
    """

    if value is None:
        return None

    return value.strftime("%Y-%m-%d %H:%M:%S")


def serialize_category(category):
    """
    动作分类对象转 JSON 字典
    """

    return {
        "category_id": category.category_id,
        "category_name": category.category_name,
        "category_image_url": category.category_image_url,
    }


def serialize_action(action, current_user=None):
    """
    动作对象转 JSON 字典
    """

    is_favorite = False
    favorite_id = None

    if current_user:
        favorite = UserActionFavorite.objects.filter(
            user=current_user,
            action=action
        ).first()

        if favorite:
            is_favorite = True
            favorite_id = favorite.favorite_id

    return {
        "action_id": action.action_id,
        "action_name": action.action_name,

        "category_id": action.category_id,
        "category_name": action.category.category_name,

        "action_difficulty": action.action_difficulty,
        "action_image_url": action.action_image_url,

        "action_steps": action.action_steps,
        "attention_points": action.attention_points,
        "applicable_equipment": action.applicable_equipment,

        "applicable_store_type": action.applicable_store_type,
        "applicable_store_type_text": (
            action.get_applicable_store_type_display()
            if action.applicable_store_type is not None
            else None
        ),

        "is_favorite": is_favorite,
        "favorite_id": favorite_id,
    }


def serialize_favorite(favorite):
    """
    用户动作收藏对象转 JSON 字典
    """

    action = favorite.action

    return {
        "favorite_id": favorite.favorite_id,
        "created_at": format_datetime_value(favorite.created_at),
        "action": serialize_action(action, favorite.user),
    }


class ActionCategoryListView(View):
    """
    动作分类列表接口

    GET /api/exercise/categories/
    """

    @login_required
    def get(self, request):
        categories = ActionCategory.objects.all().order_by("category_id")

        data = []

        for category in categories:
            data.append(serialize_category(category))

        return json_response(
            data=data,
            message="获取动作分类成功",
        )


class ActionListView(View):
    """
    动作列表接口

    GET /api/exercise/actions/

    可选查询参数:
    category_id
    action_difficulty
    applicable_store_type
    keyword
    """

    @login_required
    def get(self, request):
        current_user = request.current_user

        queryset = Action.objects.select_related("category").all().order_by("action_id")

        category_id = request.GET.get("category_id")
        action_difficulty = request.GET.get("action_difficulty")
        applicable_store_type = request.GET.get("applicable_store_type")
        keyword = request.GET.get("keyword")

        if category_id:
            queryset = queryset.filter(category_id=category_id)

        if action_difficulty:
            queryset = queryset.filter(action_difficulty=action_difficulty)

        if applicable_store_type:
            queryset = queryset.filter(applicable_store_type=applicable_store_type)

        if keyword:
            queryset = queryset.filter(action_name__icontains=keyword)

        data = []

        for action in queryset:
            data.append(serialize_action(action, current_user))

        return json_response(
            data=data,
            message="获取动作列表成功",
        )


class ActionDetailView(View):
    """
    动作详情接口

    GET /api/exercise/actions/<action_id>/
    """

    @login_required
    def get(self, request, action_id):
        current_user = request.current_user

        action = Action.objects.select_related("category").filter(
            action_id=action_id
        ).first()

        if action is None:
            return json_response(
                message="动作不存在",
                code=404,
                status=404,
            )

        return json_response(
            data=serialize_action(action, current_user),
            message="获取动作详情成功",
        )


class MyActionFavoriteListView(View):
    """
    当前用户动作收藏列表接口

    GET /api/exercise/my-favorites/
    """

    @login_required
    def get(self, request):
        current_user = request.current_user

        favorites = UserActionFavorite.objects.select_related(
            "user",
            "action",
            "action__category",
        ).filter(
            user=current_user
        ).order_by("-created_at")

        data = []

        for favorite in favorites:
            data.append(serialize_favorite(favorite))

        return json_response(
            data=data,
            message="获取我的动作收藏成功",
        )


@method_decorator(csrf_exempt, name="dispatch")
class ActionFavoriteCreateView(View):
    """
    收藏动作接口

    POST /api/exercise/favorites/

    请求体:
    {
        "action_id": 1
    }
    """

    @login_required
    def post(self, request):
        current_user = request.current_user

        data = get_json_body(request)

        if data is None:
            return json_response(
                message="请求体不是合法 JSON",
                code=400,
                status=400,
            )

        action_id = data.get("action_id")

        if not action_id:
            return json_response(
                message="action_id 不能为空",
                code=400,
                status=400,
            )

        action = Action.objects.select_related("category").filter(
            action_id=action_id
        ).first()

        if action is None:
            return json_response(
                message="动作不存在",
                code=404,
                status=404,
            )

        favorite, created = UserActionFavorite.objects.get_or_create(
            user=current_user,
            action=action,
        )

        if not created:
            return json_response(
                data=serialize_favorite(favorite),
                message="你已经收藏过该动作",
            )

        return json_response(
            data=serialize_favorite(favorite),
            message="收藏动作成功",
        )


@method_decorator(csrf_exempt, name="dispatch")
class ActionFavoriteDeleteView(View):
    """
    取消收藏动作接口

    DELETE /api/exercise/favorites/<favorite_id>/
    """

    @login_required
    def delete(self, request, favorite_id):
        current_user = request.current_user

        favorite = UserActionFavorite.objects.select_related(
            "user",
            "action",
            "action__category",
        ).filter(
            favorite_id=favorite_id,
            user=current_user,
        ).first()

        if favorite is None:
            return json_response(
                message="收藏记录不存在",
                code=404,
                status=404,
            )

        favorite.delete()

        return json_response(
            message="取消收藏成功",
        )

3.2 实现要点说明

序列化与视图类的设计遵循以下原则:

组件 设计要点 目的
serialize_action 接收可选 current_user,查询收藏关系后注入 is_favorite、favorite_id 使列表与详情自带收藏状态
ActionListView 以 select_related("category") 预取分类,icontains 实现模糊搜索 减少 N+1 查询、支持名称检索
ActionFavoriteCreateView 使用 get_or_create,命中已有记录时返回提示而非报错 保证收藏操作幂等
ActionFavoriteDeleteView 查询条件同时限定 favorite_id 与 user=current_user 仅允许删除本人收藏
写操作视图 以 @method_decorator(csrf_exempt, name="dispatch") 装饰 放行无 CSRF 令牌的 JSON 请求

要点:

  • 要点 :serialize_action 是收藏状态注入的核心,current_user 为空时输出 is_favorite=false、favorite_id=null。
  • 要点 :get_or_create 将「重复收藏」转化为正常返回,避免触发数据库唯一约束异常。
  • 要点 :取消收藏以 user=current_user 作为查询条件之一,天然阻断越权删除他人收藏。
  • 要点 :涉及请求体的 POST/DELETE 视图需免除 CSRF 校验,读取请求体统一经 get_json_body 处理。

4. 路由层配置

4.1 创建 exercise/urls.py

新建文件:

text 复制代码
backend/exercise/urls.py

写入:

python 复制代码
from django.urls import path

from .views import (
    ActionCategoryListView,
    ActionListView,
    ActionDetailView,
    MyActionFavoriteListView,
    ActionFavoriteCreateView,
    ActionFavoriteDeleteView,
)

urlpatterns = [
    path("categories/", ActionCategoryListView.as_view()),

    path("actions/", ActionListView.as_view()),
    path("actions/<int:action_id>/", ActionDetailView.as_view()),

    path("my-favorites/", MyActionFavoriteListView.as_view()),
    path("favorites/", ActionFavoriteCreateView.as_view()),
    path("favorites/<int:favorite_id>/", ActionFavoriteDeleteView.as_view()),
]

4.2 修改项目总路由

打开文件:

text 复制代码
backend/config/urls.py

修改前内容为:

python 复制代码
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),

    path("api/", include("rbac.urls")),
    path("api/store/", include("store.urls")),
    path("api/course/", include("course.urls")),
]

修改为:

python 复制代码
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),

    path("api/", include("rbac.urls")),
    path("api/store/", include("store.urls")),
    path("api/course/", include("course.urls")),
    path("api/exercise/", include("exercise.urls")),
]

动作接口的最终地址为:

text 复制代码
/api/exercise/categories/
/api/exercise/actions/
/api/exercise/actions/<action_id>/
/api/exercise/my-favorites/
/api/exercise/favorites/
/api/exercise/favorites/<favorite_id>/

要点:

  • 要点 :exercise/urls.py 内的路径不含 /api/exercise/ 前缀,前缀由项目总路由的 include 提供。
  • 要点 :<int:action_id>、<int:favorite_id> 约束路径参数为整数,非法值在路由匹配阶段即被拒绝。
  • 要点 :项目总路由新增一行 path("api/exercise/", include("exercise.urls")) 即可挂载全部动作接口。

5. 后端启动与接口测试

5.1 启动后端

确认当前工作目录位于:

text 复制代码
rbac_project/backend

启动后端:

bash 复制代码
python manage.py runserver

5.2 登录 admin 获取 token

macOS / Linux:

bash 复制代码
curl -X POST http://127.0.0.1:8000/api/login/ \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

Windows PowerShell:

powershell 复制代码
curl -Method POST "http://127.0.0.1:8000/api/login/" `
  -Headers @{"Content-Type"="application/json"} `
  -Body '{"username":"admin","password":"123456"}'

从返回结果中复制 access_token,后续请求以该值替换占位符 你的admin_access_token。

5.3 查询动作分类

macOS / Linux:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/categories/ \
  -H "Authorization: Bearer 你的admin_access_token"

Windows PowerShell:

powershell 复制代码
curl -Method GET "http://127.0.0.1:8000/api/exercise/categories/" `
  -Headers @{"Authorization"="Bearer 你的admin_access_token"}

预期返回包含:

text 复制代码
胸部
腿部
核心

5.4 查询动作列表

macOS / Linux:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/actions/ \
  -H "Authorization: Bearer 你的admin_access_token"

Windows PowerShell:

powershell 复制代码
curl -Method GET "http://127.0.0.1:8000/api/exercise/actions/" `
  -Headers @{"Authorization"="Bearer 你的admin_access_token"}

预期返回包含:

text 复制代码
俯卧撑
深蹲
平板支撑

5.5 按关键词搜索动作

bash 复制代码
curl -X GET "http://127.0.0.1:8000/api/exercise/actions/?keyword=深蹲" \
  -H "Authorization: Bearer 你的admin_access_token"

预期仅返回:

text 复制代码
深蹲

5.6 按分类筛选动作

以 胸部 的 category_id 为 1 为例:

bash 复制代码
curl -X GET "http://127.0.0.1:8000/api/exercise/actions/?category_id=1" \
  -H "Authorization: Bearer 你的admin_access_token"

预期返回胸部分类下的动作:

text 复制代码
俯卧撑

5.7 按门店类型筛选动作

门店类型取值:

取值 含义
1 铁馆
2 商业私教馆

请求:

bash 复制代码
curl -X GET "http://127.0.0.1:8000/api/exercise/actions/?applicable_store_type=1" \
  -H "Authorization: Bearer 你的admin_access_token"

预期返回适合铁馆的动作。

5.8 查询动作详情

以动作 ID 为 1 为例:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/actions/1/ \
  -H "Authorization: Bearer 你的admin_access_token"

正常返回:

json 复制代码
{
  "code": 200,
  "message": "获取动作详情成功",
  "data": {
    "action_id": 1,
    "action_name": "俯卧撑",
    "category_id": 1,
    "category_name": "胸部",
    "action_difficulty": 1,
    "action_steps": "双手撑地,身体保持直线,屈肘下降后推起。",
    "attention_points": "腰背保持稳定,不要塌腰。",
    "applicable_equipment": "徒手",
    "applicable_store_type": 1,
    "applicable_store_type_text": "铁馆",
    "is_favorite": true,
    "favorite_id": 1
  }
}

5.9 查询我的动作收藏

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/my-favorites/ \
  -H "Authorization: Bearer 你的admin_access_token"

admin 正常应看到:

text 复制代码
俯卧撑

5.10 收藏动作

以动作 ID 为 2 为例。

macOS / Linux:

bash 复制代码
curl -X POST http://127.0.0.1:8000/api/exercise/favorites/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer 你的admin_access_token" \
  -d '{"action_id":2}'

Windows PowerShell:

powershell 复制代码
curl -Method POST "http://127.0.0.1:8000/api/exercise/favorites/" `
  -Headers @{
    "Content-Type"="application/json"
    "Authorization"="Bearer 你的admin_access_token"
  } `
  -Body '{"action_id":2}'

正常返回:

json 复制代码
{
  "code": 200,
  "message": "收藏动作成功",
  "data": {
    "favorite_id": 3
  }
}

若已收藏过,返回:

json 复制代码
{
  "code": 200,
  "message": "你已经收藏过该动作"
}

5.11 取消收藏动作

以收藏记录 ID 为 3 为例:

bash 复制代码
curl -X DELETE http://127.0.0.1:8000/api/exercise/favorites/3/ \
  -H "Authorization: Bearer 你的admin_access_token"

正常返回:

json 复制代码
{
  "code": 200,
  "message": "取消收藏成功",
  "data": null
}

取消收藏仅作用于当前登录用户自身的收藏记录,无法删除他人的收藏。

要点:

  • 要点:测试顺序为登录取 token → 读接口 → 写接口 → 删除接口,写删除依赖读接口返回的 ID。
  • 要点 :收藏使用 action_id,取消收藏使用 favorite_id,二者来源不同不可互换。
  • 要点:重复收藏返回 200 与提示文案,而非数据库错误。

6. 用户隔离与匿名请求验证

6.1 测试 zhangsan

先登录 zhangsan:

bash 复制代码
curl -X POST http://127.0.0.1:8000/api/login/ \
  -H "Content-Type: application/json" \
  -d '{"username":"zhangsan","password":"123456"}'

复制 zhangsan 的 access_token。

查询收藏:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/my-favorites/ \
  -H "Authorization: Bearer 你的zhangsan_access_token"

zhangsan 正常应看到:

text 复制代码
深蹲

收藏动作:

bash 复制代码
curl -X POST http://127.0.0.1:8000/api/exercise/favorites/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer 你的zhangsan_access_token" \
  -d '{"action_id":1}'

该请求使 zhangsan 收藏:

text 复制代码
俯卧撑

6.2 不带 token 的请求

直接请求:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/exercise/actions/

应返回:

json 复制代码
{
  "code": 401,
  "message": "未提供 access_token",
  "data": null
}

该结果表明动作接口已受登录保护。

要点:

  • 要点 :my-favorites 依据 token 解析用户身份,不同账号返回各自的收藏集合。
  • 要点:admin 与 zhangsan 的收藏数据相互隔离,越权访问他人收藏在查询层即被过滤。
  • 要点 :缺失 token 的请求统一返回 401,由 login_required 拦截器处理。

7. 阶段验收与模块进度

7.1 阶段 16 完成标准

需确认以下各项:

text 复制代码
1. backend/exercise/views.py 已编写完成
2. backend/exercise/urls.py 已创建
3. backend/config/urls.py 已引入 exercise.urls
4. GET /api/exercise/categories/ 可返回动作分类
5. GET /api/exercise/actions/ 可返回动作列表
6. GET /api/exercise/actions/<id>/ 可返回动作详情
7. GET /api/exercise/my-favorites/ 可返回当前用户收藏动作
8. POST /api/exercise/favorites/ 可收藏动作
9. DELETE /api/exercise/favorites/<favorite_id>/ 可取消收藏动作
10. 不带 token 请求动作接口返回 401

7.2 当前模块进度

text 复制代码
rbac/
已完成:
- 用户
- 角色
- 权限
- JWT 登录
- RBAC 权限拦截
- 用户档案接口

store/
已完成:
- 省份表
- 门店表
- 用户门店关系表
- 省份查询接口
- 门店查询接口
- 当前用户门店查询接口

course/
已完成:
- 课程分类表
- 课程表
- 用户课程收藏表
- 门店课程排期表
- 课程分类查询接口
- 课程查询接口
- 课程详情接口
- 我的课程收藏接口
- 收藏 / 取消收藏课程接口
- 门店课程排期查询接口

exercise/
已完成:
- 动作分类表
- 动作表
- 用户动作收藏表
- 动作分类查询接口
- 动作查询接口
- 动作详情接口
- 我的动作收藏接口
- 收藏 / 取消收藏动作接口

要点:

  • 要点:完成标准覆盖视图、路由、总路由挂载与六类接口的行为验证。
  • 要点 :exercise 模块补齐后,后端四大业务领域(rbac、store、course、exercise)查询接口全部就绪。
  • 要点:后端接口层已完整,下一阶段转入前端对接。

阶段小结

环节 核心内容 在系统中的作用
数据模型 动作分类表、动作表、用户动作收藏表 支撑动作查询与收藏读写
视图层 serialize_action 注入收藏状态、六类视图类 统一响应结构、隔离用户数据
幂等收藏 get_or_create 处理重复收藏 避免数据库唯一约束异常
取消收藏 查询限定 favorite_id 与 user 阻止越权删除他人收藏
路由配置 exercise/urls.py + 总路由 include 挂载 /api/exercise/ 接口
接口测试 登录取 token、读接口、写接口、删除接口 验证接口契约与登录保护

常见问题与排查

现象 原因 处理方式
报错 No module named exercise.urls 未创建 backend/exercise/urls.py,或 config/urls.py 中 include("exercise.urls") 书写有误 补建 exercise/urls.py,核对 path("api/exercise/", include("exercise.urls"))
报错 Table 'rbac_db.t_action' doesn't exist 阶段 12 的迁移未执行成功 执行 python manage.py makemigrations exercise 与 python manage.py migrate,再执行 python manage.py init_business_data
动作列表为空 阶段 13 的业务测试数据未初始化 执行 python manage.py init_business_data
收藏动作报重复错误 正常流程不会触发数据库错误,接口已用 get_or_create 若已收藏会返回「你已经收藏过该动作」,无需额外处理
取消收藏返回 404 favorite_id 错误、收藏非本人记录,或记录已被删除 先请求 GET /api/exercise/my-favorites/ 取得正确的 favorite_id 再删除

下一阶段衔接

阶段 16 完成后,store、course、exercise 三大业务领域的查询与收藏接口已在后端全部就绪。这些接口目前仅以 curl 验证,尚无页面承载。

阶段 17(阶段 17:Vue3 对接门店、课程、动作接口.md)将在前端以 Vue3 建立门店列表、课程列表、动作列表与我的收藏页面,通过封装的请求工具调用本阶段的动作接口,把后端数据呈现为可交互界面。

相关推荐
ycydynq1 小时前
django搜索功能
数据库·python·django
呆萌很1 小时前
DISTINCT 和 GROUP BY 核心区别
数据库·sql
Zella折耳根1 小时前
字符田字格绘制
python
小蒜学长1 小时前
基于数据挖掘的京东商城商品推荐系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·推荐系统·个性化推荐
SEO_juper1 小时前
手写 llms.txt 验证器:让 AI 更愿意引用你的网站(GEO)
开发语言·人工智能·python·聚类·seo·独立站
小小AK1 小时前
金蝶其他入库到MES送检签收:库存同步策略实战教程
数据库·制造
LuminousCPP1 小时前
数据库学习笔记(二):MySQL 基础操作入门|在 CentOS Stream 10 上连接、建库与建表
数据库·经验分享·笔记·学习·mysql·centos
运维小斌1 小时前
麒麟 V10 ARM64 离线部署 OceanBase:8 步实施手册
linux·数据库·oceanbase·麒麟
小小张说故事1 小时前
Python 的 list append 为什么是均摊 O(1)?拆开过度分配那张表,看容量翻倍背后的暗账
python