Django 后端实战指南_14:门店、省份、用户门店关系查询接口

阶段 14:门店、省份、用户门店关系查询接口

合辑 :Django 系统阶段开发手册 | 阶段 :14 / 18 | 定位 :建立 store 模块只读查询接口(API)的开发模式,实现省份、门店、当前用户关联门店共 4 个受 JWT 登录保护的接口 | 建议学时:6--8 小时

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


阶段导语

阶段 13 完成后,三个业务模块已有可用的测试数据,接口开发阶段(阶段 14--16)正式开始。本阶段是接口阶段的第一个环节,选择 store 模块作为起点:表结构最简单、依赖关系最少,适合作为视图类(View)、序列化函数、应用路由与项目路由这一整套开发模式的样板。

本阶段基于 t_store_province、t_store、y_user_store 三张表实现 4 个查询接口,全部挂载在 /api/store/ 前缀下,全部要求登录。新增、修改、删除门店以及 Vue 页面、RBAC 菜单权限均不在本阶段范围内,留待后续阶段处理。


学习目标

完成本阶段后,应能够:

  • 说明 4 个门店查询接口与三张数据表之间的对应关系
  • 使用 Django 类视图(View)配合 login_required 装饰器实现受登录保护的只读接口
  • 编写对象序列化函数,将 Decimal、datetime 字段统一转换为可 JSON 序列化的字符串
  • 基于查询参数实现门店列表的条件筛选:省份、城市、门店类型、名称模糊搜索、营业状态
  • 创建应用路由 store/urls.py 并在项目总路由中挂载 /api/store/ 前缀
  • 使用 curl 完成接口的登录态测试、筛选测试、多用户隔离测试与未登录(401)验证

前置知识

本阶段依赖阶段 13(阶段 13:初始化门店、课程、动作测试数据.md)及其之前的成果:

前置条目 具体要求
阶段 12:表结构 t_store_province、t_store、y_user_store 已迁移到 MySQL;t_store 通过 province_id 关联省份表,y_user_store 通过 user_id、role_id、store_id 关联用户、角色和门店
阶段 13:测试数据 已执行 python manage.py init_business_data,两个省份、两家门店与两条用户门店关系存在
rbac 模块:认证能力 JWT 登录接口 /api/login/ 可用;rbac.auth.login_required 与 rbac.responses.json_response 可导入
测试用户 admin / 123456、zhangsan / 123456 可正常登录并取得 access_token

若业务数据缺失,先回到阶段 13 补齐,否则列表接口将返回空数据。


目录

  1. 阶段目标与接口范围
  2. 接口规划
  3. 接口效果说明
  4. 视图层实现
  5. 应用路由配置
  6. 项目总路由配置
  7. 启动后端与测试接口
  8. 多用户与未登录验证
  9. 完成标准与模块进度
  10. 阶段小结
  11. 常见问题与排查
  12. 下一阶段衔接

1. 阶段目标与接口范围

本阶段目标:

text 复制代码
1. 查询省份/区域列表
2. 查询门店列表
3. 查询门店详情
4. 查询当前登录用户关联的门店

实现基于以下三张表:

text 复制代码
t_store_province   门店所属省份/区域表
t_store            门店基础信息表
y_user_store       用户与门店关联表

三张表的结构来自已定稿的业务表设计:t_store 通过 province_id 关联省份表,y_user_store 通过 user_id、role_id、store_id 关联用户、角色和门店。

本阶段暂不实现的内容:

text 复制代码
新增门店
修改门店
删除门店
Vue 页面
RBAC 菜单权限

本章要点:

  • 要点:本阶段只做查询(只读)接口,写操作与前端页面均排除在外。
  • 要点:4 个接口分别落在省份表、门店表与用户门店关系表之上,覆盖三张表的全部典型查询形态。
  • 要点:本阶段形成的视图-序列化-路由三层模式将直接复用于阶段 15、16。

2. 接口规划

门店相关接口统一放在 /api/store/ 前缀下。最终接口如下:

方法 路径 参数 返回说明 是否需要登录
GET /api/store/provinces/ 无 省份/区域列表 需要
GET /api/store/stores/ 查询参数(均可选):province_id、city、store_type、keyword、is_operating 门店列表,支持条件筛选 需要
GET /api/store/stores/<store_id>/ 路径参数:store_id 单个门店详情;ID 不存在时返回 404 需要
GET /api/store/my-stores/ 无 当前登录用户关联的门店列表 需要

门店列表接口的筛选参数含义:

筛选参数 作用
province_id 按省份筛选
city 按城市筛选
store_type 按门店类型筛选
keyword 按门店名称模糊搜索
is_operating 按是否营业筛选

本章要点:

  • 要点:4 个接口均为 GET 只读接口,统一受登录保护。
  • 要点 :列表与详情的区分依靠路径中是否携带 <store_id> 参数。
  • 要点 :my-stores 不接收任何参数,用户身份完全由登录 token 决定。

3. 接口效果说明

3.1 省份列表

请求:

text 复制代码
GET /api/store/provinces/

返回:

json 复制代码
{
  "code": 200,
  "message": "获取省份列表成功",
  "data": [
    {
      "province_id": 1,
      "province_name": "广东省",
      "center_lng": "113.266530",
      "center_lat": "23.132191"
    }
  ]
}

3.2 门店列表

请求:

text 复制代码
GET /api/store/stores/

带筛选条件的请求示例:

text 复制代码
GET /api/store/stores/?province_id=1
GET /api/store/stores/?keyword=广州
GET /api/store/stores/?store_type=1

3.3 门店详情

请求:

text 复制代码
GET /api/store/stores/1/

返回单个门店的详细信息。

3.4 当前用户关联门店

请求:

text 复制代码
GET /api/store/my-stores/

后端根据 JWT token 找到当前用户,然后查询 y_user_store 表,返回该用户关联的门店。以阶段 13 的数据为例:

text 复制代码
admin -> 广州天河铁馆
zhangsan -> 上海静安私教馆

本章要点:

  • 要点 :所有响应统一为 code / message / data 三段结构,由 json_response 生成。
  • 要点:经纬度等 Decimal 字段在响应中以字符串形式出现,序列化阶段的处理方式见第 4 章。
  • 要点:多个筛选参数可叠加使用,条件之间为"与"关系。

4. 视图层实现

打开文件:

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

将内容替换为以下完整代码。文件内部按职责分为两部分:format_decimal_value、format_datetime_value 两个格式化工具函数与 serialize_province、serialize_store、serialize_user_store 三个序列化函数负责把模型对象转为 JSON 字典;4 个类视图各负责一个接口,均通过 @login_required 装饰器要求登录。

backend/store/views.py:

python 复制代码
from django.views import View

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

from .models import StoreProvince, Store, UserStore


def format_decimal_value(value):
    """
    Decimal 转字符串,避免 JSON 序列化问题。
    """

    if value is None:
        return None

    return str(value)


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

    if value is None:
        return None

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


def serialize_province(province):
    """
    省份对象转 JSON 字典。
    """

    return {
        "province_id": province.province_id,
        "province_name": province.province_name,
        "center_lng": format_decimal_value(province.center_lng),
        "center_lat": format_decimal_value(province.center_lat),
    }


def serialize_store(store):
    """
    门店对象转 JSON 字典。
    """

    return {
        "store_id": store.store_id,
        "store_name": store.store_name,
        "store_type": store.store_type,
        "store_type_text": store.get_store_type_display(),

        "province_id": store.province_id,
        "province_name": store.province_name or store.province.province_name,

        "city": store.city,
        "district": store.district,
        "address": store.address,

        "store_phone": store.store_phone,
        "store_image_url": store.store_image_url,
        "store_introduction": store.store_introduction,
        "business_hours": store.business_hours,

        "is_operating": store.is_operating,
        "is_operating_text": store.get_is_operating_display(),

        "store_lng": format_decimal_value(store.store_lng),
        "store_lat": format_decimal_value(store.store_lat),
    }


def serialize_user_store(user_store):
    """
    用户门店关系对象转 JSON 字典。
    """

    store = user_store.store
    user = user_store.user
    role = user_store.role

    return {
        "id": user_store.id,

        "user_id": user.user_id,
        "username": user.username,
        "real_name": user.real_name,

        "role_id": role.id,
        "role_code": role.role_code,
        "role_name": role.role_name,

        "store": serialize_store(store),

        "created_at": format_datetime_value(user_store.created_at),
    }


class StoreProvinceListView(View):
    """
    省份/区域列表接口

    GET /api/store/provinces/
    """

    @login_required
    def get(self, request):
        provinces = StoreProvince.objects.all().order_by("province_id")

        data = []

        for province in provinces:
            data.append(serialize_province(province))

        return json_response(
            data=data,
            message="获取省份列表成功",
        )


class StoreListView(View):
    """
    门店列表接口

    GET /api/store/stores/

    可选查询参数:
    province_id
    city
    store_type
    keyword
    is_operating
    """

    @login_required
    def get(self, request):
        queryset = Store.objects.select_related("province").all().order_by("store_id")

        province_id = request.GET.get("province_id")
        city = request.GET.get("city")
        store_type = request.GET.get("store_type")
        keyword = request.GET.get("keyword")
        is_operating = request.GET.get("is_operating")

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

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

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

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

        if is_operating in ["0", "1"]:
            queryset = queryset.filter(is_operating=int(is_operating))

        data = []

        for store in queryset:
            data.append(serialize_store(store))

        return json_response(
            data=data,
            message="获取门店列表成功",
        )


class StoreDetailView(View):
    """
    门店详情接口

    GET /api/store/stores/<store_id>/
    """

    @login_required
    def get(self, request, store_id):
        store = Store.objects.select_related("province").filter(
            store_id=store_id
        ).first()

        if store is None:
            return json_response(
                message="门店不存在",
                code=404,
                status=404,
            )

        return json_response(
            data=serialize_store(store),
            message="获取门店详情成功",
        )


class MyStoreListView(View):
    """
    当前登录用户关联门店接口

    GET /api/store/my-stores/
    """

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

        user_stores = UserStore.objects.select_related(
            "user",
            "role",
            "store",
            "store__province",
        ).filter(
            user=current_user
        ).order_by("-created_at")

        data = []

        for item in user_stores:
            data.append(serialize_user_store(item))

        return json_response(
            data=data,
            message="获取当前用户关联门店成功",
        )

本章要点:

  • 要点 :Decimal 与 datetime 字段无法直接被 JSON 序列化,统一在 format_decimal_value、format_datetime_value 中转为字符串。
  • 要点 :serialize_store 通过 get_store_type_display()、get_is_operating_display() 附带枚举值的中文文本,前端无需重复维护映射。
  • 要点 :列表查询使用 select_related 预加载关联对象,避免序列化阶段逐条触发额外查询。
  • 要点 :my-stores 从 request.current_user 取得当前用户,身份来源是 JWT token,不信任任何请求参数。
  • 要点:详情接口的"不存在"分支返回 code=404 且 HTTP 状态码同为 404,与成功响应结构保持一致。

5. 应用路由配置

新建文件:

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

写入:

python 复制代码
from django.urls import path

from .views import (
    StoreProvinceListView,
    StoreListView,
    StoreDetailView,
    MyStoreListView,
)

urlpatterns = [
    path("provinces/", StoreProvinceListView.as_view()),
    path("stores/", StoreListView.as_view()),
    path("stores/<int:store_id>/", StoreDetailView.as_view()),
    path("my-stores/", MyStoreListView.as_view()),
]

<int:store_id> 转换器限定该段必须为整数,非整数的详情请求不会命中该路由。

本章要点:

  • 要点 :应用路由只写相对路径,/api/store/ 前缀由项目总路由提供。
  • 要点 :类视图通过 .as_view() 转换为可被 path 接收的函数。
  • 要点 :路径参数使用 int 转换器,天然过滤非法 ID 格式。

6. 项目总路由配置

打开文件:

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")),
]

修改为:

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")),
]

最终接口地址为:

text 复制代码
/api/store/provinces/
/api/store/stores/
/api/store/stores/<store_id>/
/api/store/my-stores/

本章要点:

  • 要点 :新增模块接口只需在项目总路由中追加一行 include,应用内部路由无需改动。
  • 要点 :api/store/ 前缀与 store/urls.py 中的相对路径拼接,构成完整地址。

7. 启动后端与测试接口

7.1 启动后端

工作目录确认在:

text 复制代码
rbac_project/backend

执行:

bash 复制代码
python manage.py runserver

7.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 的值,后续测试将其填入命令中的 token 占位位置。

7.3 查询省份列表

macOS / Linux:

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

Windows PowerShell:

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

正常返回:

json 复制代码
{
  "code": 200,
  "message": "获取省份列表成功",
  "data": [
    {
      "province_id": 1,
      "province_name": "广东省",
      "center_lng": "113.266530",
      "center_lat": "23.132191"
    },
    {
      "province_id": 2,
      "province_name": "上海市",
      "center_lng": "121.473701",
      "center_lat": "31.230416"
    }
  ]
}

7.4 查询门店列表

macOS / Linux:

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

Windows PowerShell:

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

正常返回:

text 复制代码
广州天河铁馆
上海静安私教馆

7.5 按关键词搜索门店

macOS / Linux:

bash 复制代码
curl -X GET "http://127.0.0.1:8000/api/store/stores/?keyword=广州" \
  -H "Authorization: Bearer 你的admin_access_token"

Windows PowerShell:

powershell 复制代码
curl -Method GET "http://127.0.0.1:8000/api/store/stores/?keyword=广州" `
  -Headers @{"Authorization"="Bearer 你的admin_access_token"}

应只返回:

text 复制代码
广州天河铁馆

7.6 按门店类型筛选

门店类型取值:

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

请求:

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

应返回铁馆类型的门店。

7.7 查询门店详情

以门店 ID 1 为例:

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

正常返回:

json 复制代码
{
  "code": 200,
  "message": "获取门店详情成功",
  "data": {
    "store_id": 1,
    "store_name": "广州天河铁馆",
    "store_type": 1,
    "store_type_text": "铁馆",
    "province_id": 1,
    "province_name": "广东省",
    "city": "广州市",
    "district": "天河区",
    "address": "广州市天河区体育西路 100 号"
  }
}

7.8 查询当前用户关联门店

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

admin 正常应看到:

text 复制代码
广州天河铁馆

本章要点:

  • 要点:测试顺序为"先登录取 token,再带 Authorization 头请求各接口"。
  • 要点 :关键词搜索命中名称包含关键词的门店,验证 icontains 模糊匹配生效。
  • 要点 :详情响应中的 store_type_text、province_name 等字段由序列化函数补充,验证了字段映射逻辑。

8. 多用户与未登录验证

8.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/store/my-stores/ \
  -H "Authorization: Bearer 你的zhangsan_access_token"

正常应看到:

text 复制代码
上海静安私教馆

与 admin 的结果对比,说明 my-stores 按当前登录用户隔离数据。

8.2 不带 token 的请求

直接请求:

bash 复制代码
curl -X GET http://127.0.0.1:8000/api/store/stores/

应返回:

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

说明接口已被登录保护。

本章要点:

  • 要点 :不同用户的 my-stores 结果不同,用户身份完全来自 token。
  • 要点:缺少 Authorization 头时统一返回 401,而非空列表或错误页。

9. 完成标准与模块进度

9.1 完成标准

本阶段结束时,以下 10 项应全部满足:

  • backend/store/views.py 已写好
  • backend/store/urls.py 已创建
  • backend/config/urls.py 已引入 store.urls
  • GET /api/store/provinces/ 可返回省份列表
  • GET /api/store/stores/ 可返回门店列表
  • GET /api/store/stores/<id>/ 可返回门店详情
  • GET /api/store/my-stores/ 可返回当前用户关联门店
  • 不带 token 请求这些接口返回 401
  • admin 查询 my-stores 返回广州天河铁馆
  • zhangsan 查询 my-stores 返回上海静安私教馆

9.2 当前模块进度

完成本阶段后,项目各模块进度如下:

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

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

course/
已完成:
- 表结构
- 测试数据

exercise/
已完成:
- 表结构
- 测试数据

本章要点:

  • 要点:完成标准覆盖代码文件、路由挂载、4 个接口行为与认证边界四类检查。
  • 要点:store 模块至此具备完整的只读查询能力,course 与 exercise 仍停留在数据层。

阶段小结

环节 核心内容 在项目中的作用
接口规划 4 个 GET 接口统一挂载 /api/store/,全部要求登录 确立门店模块的只读查询能力
序列化层 Decimal、datetime 转字符串;get_FOO_display() 附中文文本 消除 JSON 序列化障碍,统一响应字段
视图层 类视图 + login_required + select_related + 条件筛选 4 个接口的具体实现
路由层 store/urls.py 相对路径 + config/urls.py 前缀挂载 完整地址的组装方式
测试 curl 登录取 token,覆盖列表、筛选、详情、my-stores、401 验证接口行为与数据隔离

本阶段同时沉淀了一套可复制的模式:格式化工具函数、序列化函数、类视图、应用路由、总路由挂载。阶段 15、16 的课程与动作接口将按同一模式展开。


常见问题与排查

现象 原因 处理
请求返回 {"code": 401, "message": "未提供 access_token"} 未携带 Authorization 请求头,接口受 login_required 保护 先调用 /api/login/ 获取 access_token,以 Bearer <token> 形式加入请求头
门店详情返回 门店不存在(404) 路径中的 store_id 在 t_store 中无对应记录 通过门店列表接口或 t_store 表确认有效 ID
keyword 筛选无结果 门店名称中不含该关键词;icontains 为子串匹配 换用更短的关键词重试,如用"广州"代替完整门店名
is_operating 筛选不生效 后端仅当取值为 "0" 或 "1" 时才应用该过滤条件 该参数只传 0 或 1
经纬度字段返回为字符串 Decimal 值在序列化函数中统一转为字符串,避免 JSON 序列化问题 属接口约定,接收方按字符串解析
my-stores 返回空列表 阶段 13 的 y_user_store 关系数据未初始化 执行 python manage.py init_business_data 后核对用户门店关系

下一阶段衔接

本阶段确立的"工具函数 + 序列化函数 + 类视图 + 应用路由 + 总路由挂载"模式将原样应用于课程模块。阶段 15(阶段 15:课程分类、课程、课程收藏、门店课程排期查询接口.md)将在 course 模块开发以下接口:

text 复制代码
GET    /api/course/categories/        课程分类列表
GET    /api/course/courses/           课程列表
GET    /api/course/courses/<id>/      课程详情
GET    /api/course/my-favorites/      我的课程收藏
POST   /api/course/favorites/         收藏课程
DELETE /api/course/favorites/<id>/    取消收藏
GET    /api/course/schedules/         门店课程排期

与本项目不同的是,阶段 15 将首次引入 POST 与 DELETE 两类写操作接口,涉及 JSON 请求体解析与跨站请求伪造(CSRF)豁免处理。

相关推荐
我星期八休息1 小时前
Redis 的原理和使用(2)
前端·数据库·redis·python·mysql·缓存·bootstrap
betazhou1 小时前
Apache Seatunnel 抽取SQL server实时同步到Oracle(四)
数据库·oracle·apache·sql server·seatunnel
l1t1 小时前
DeepSeek总结的PostgreSQL 的 MVCC 很糟。其他所有的也一样。第1部分
数据库·postgresql
川石课堂软件测试1 小时前
JMeter异步接口测试实战
数据库·python·jmeter·mysql·kubernetes·测试用例·prometheus
下页、再停留1 小时前
【C#桌面客户端系列学习-14】SQLite数据库之增删改查完整示例
数据库·sqlite
她的男孩1 小时前
JAR 全对仍 checksum mismatch:Forge Admin 把 Flyway 从 filesystem 改成 classpath
java·数据库·spring boot
小马不起床1 小时前
Django 后端实战指南_16:动作分类、动作库、动作收藏查询接口
数据库·python·django
ycydynq1 小时前
django搜索功能
数据库·python·django
呆萌很1 小时前
DISTINCT 和 GROUP BY 核心区别
数据库·sql