阶段 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. 阶段目标与接口范围
本阶段目标:
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)豁免处理。