零基础入门python36:用 Django Session 实现购物车

一、上一篇课后练习讲解
订单详情对象级权限应使用 get_queryset().filter(user=self.request.user),而不是先 get(pk=id) 再在模板中判断,否则错误对象可能已经泄露。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:认证视图用 authenticate/login/logout,权限矩阵区分游客、普通用户、staff;会话安全与 CSRF 不靠关闭中间件解决。
文件:shop/auth_views.py
完整参考答案文件
完整文件:shop/auth_views.py
python
import json
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
def login_view(request):
data = json.loads(request.body or "{}")
user = authenticate(request, username=data.get("username"), password=data.get("password"))
if user is None: return JsonResponse({"error": "invalid_credentials"}, status=401)
login(request, user); return JsonResponse({"id": user.id})
def logout_view(request):
logout(request); return JsonResponse({"status": "logged_out"})
完整参考答案文件
本篇对应的交付源码完整文件:django-shop/shop/views.py
python
from rest_framework import generics, permissions, status, viewsets
from rest_framework.response import Response
from .models import Category, Order, Product
from .serializers import CategorySerializer, CheckoutSerializer, OrderSerializer, ProductSerializer
from .services import create_order
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.order_by('name')
serializer_class = CategorySerializer
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ProductSerializer
def get_queryset(self):
query = Product.objects.filter(is_active=True).select_related('category')
if category := self.request.query_params.get('category'): query = query.filter(category_id=category)
if keyword := self.request.query_params.get('q'): query = query.filter(name__icontains=keyword)
return query.order_by('-created_at')
class CheckoutView(generics.GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = CheckoutSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = create_order(request.user, serializer.validated_data['items'])
return Response(OrderSerializer(order).data, status=201)
class MyOrderViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.filter(user=self.request.user).prefetch_related('items').order_by('-id')
class CartView(generics.GenericAPIView):
def get(self, request):
return Response(request.session.get('cart', {}))
def post(self, request):
product = generics.get_object_or_404(Product, id=request.data.get('product_id'), is_active=True)
quantity = int(request.data.get('quantity', 1))
if quantity < 1 or quantity > product.stock:
return Response({'message': '数量必须大于0且不能超过库存'}, status=status.HTTP_400_BAD_REQUEST)
cart = request.session.setdefault('cart', {})
cart[str(product.id)] = quantity
request.session.modified = True
return Response(cart, status=status.HTTP_201_CREATED)
def delete(self, request):
cart = request.session.get('cart', {})
cart.pop(str(request.data.get('product_id')), None)
request.session.modified = True
return Response(cart)
验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇完成什么
购物车保存商品 id 和数量,但不保存价格。价格和库存属于数据库事实,下单时必须重新读取。

三、Session购物车代码
python
class CartView(generics.GenericAPIView):
def post(self, request):
product = generics.get_object_or_404(Product, id=request.data.get('product_id'), is_active=True)
quantity = int(request.data.get('quantity', 1))
if quantity < 1 or quantity > product.stock:
return Response({'message': '数量必须大于0且不能超过库存'}, status=400)
cart = request.session.setdefault('cart', {})
cart[str(product.id)] = quantity
request.session.modified = True
return Response(cart, status=201)
request.session.modified = True 告诉 Django 嵌套字典发生了变化。购物车只是临时意图,不是订单;用户关闭浏览器或 Session 过期后丢失是可接受的。
四、验收
加入库存 5 的商品 2 件返回 201;加入 9 件返回 400;删除后 Session 中没有商品 id。课后练习:实现购物车数量增加/减少接口,并拒绝减少到 0 以下。
项目增量:Session 购物车的边界
购物车暂不写数据库,使用 Session 保存 product_id 到 quantity 的映射;价格和库存永远从数据库重新读取,不能相信 Session 中的旧价格。
python
cart = request.session.get('cart', {})
cart[str(product.id)] = cart.get(str(product.id), 0) + quantity
request.session['cart'] = cart
request.session.modified = True
数量必须限制上限,商品下架或库存不足时结算重新校验。课后练习实现删除购物车商品和过期商品清理,下一篇创建订单快照。
五、上一篇练习讲解:Session 不是数据库
Session 里只保存商品 id 和数量,例如 {"3": 2}。它适合保存短期、可丢失的草稿,不适合保存价格、库存和支付状态。商品价格变化后,购物车页面应实时读取 Product.objects.filter(id__in=ids, is_active=True),缺失的商品从 Session 清理。
python
def clean_cart(request):
cart = request.session.get("cart", {})
valid = Product.objects.filter(id__in=cart.keys(), is_active=True)
valid_ids = {str(p.id) for p in valid}
new_cart = {pid: qty for pid, qty in cart.items() if pid in valid_ids and qty > 0}
if new_cart != cart:
request.session["cart"] = new_cart
request.session.modified = True
return new_cart
六、数量增减接口和边界
python
def change_cart(request, product_id):
if request.method not in {"POST", "PATCH"}:
return JsonResponse({"error": "method_not_allowed"}, status=405)
product = get_object_or_404(Product, id=product_id, is_active=True)
quantity = int((json.loads(request.body) or {}).get("quantity", 0))
if quantity < 0 or quantity > 99:
return JsonResponse({"error": "quantity_invalid"}, status=400)
cart = request.session.get("cart", {})
if quantity == 0:
cart.pop(str(product.id), None)
else:
cart[str(product.id)] = quantity
request.session["cart"] = cart
request.session.modified = True
return JsonResponse({"cart": cart})
这里先验证商品仍在售,再修改 Session;结账时还要再次锁库存。quantity=0 代表删除,客户端不需要额外设计一个删除请求。不能信任 Session 中的数量,服务端要限制单品上限并在结账时重新检查总量。
七、并发和 Session 覆盖
两个请求同时修改同一 Session 可能发生"后写覆盖前写"。如果购物车需要跨设备同步,应把购物车放到数据库并按用户加版本号;课程项目先接受 Session 的短期语义,并在文档中明确刷新页面可能丢失并发更新。
八、运行验收、排错与本篇练习
powershell
python manage.py test shop.tests.CartTests -v 2
预期覆盖新增、增加、减少到 0、下架商品和数量超过 99。SessionInterrupted 或 Session 不保存时,检查 SessionMiddleware;购物车返回字符串 id 却查不到商品,通常是把整数和字符串 key 混用。
本篇练习:实现删除接口和过期商品清理,并在测试中先下架商品再访问购物车,断言它被自动移除。下一篇开始创建订单和价格快照。
十、购物车展示的查询优化
购物车页面先把 Session 中的字符串 id 转成整数集合,再一次性 filter(id__in=ids) 查询商品,最后按 Session 中的顺序组装响应。不要在循环里逐个 Product.objects.get,否则购物车 50 项就产生 50 次 SQL。缺失或下架商品从结果中剔除并同步修改 Session,用户看到的数量才与下一次结算一致。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 django-shop/shop/views.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
python
from rest_framework import generics, permissions, status, viewsets
from rest_framework.response import Response
from .models import Category, Order, Product
from .serializers import CategorySerializer, CheckoutSerializer, OrderSerializer, ProductSerializer
from .services import create_order
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.order_by('name')
serializer_class = CategorySerializer
class ProductViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ProductSerializer
def get_queryset(self):
query = Product.objects.filter(is_active=True).select_related('category')
if category := self.request.query_params.get('category'): query = query.filter(category_id=category)
if keyword := self.request.query_params.get('q'): query = query.filter(name__icontains=keyword)
return query.order_by('-created_at')
class CheckoutView(generics.GenericAPIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = CheckoutSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = create_order(request.user, serializer.validated_data['items'])
return Response(OrderSerializer(order).data, status=201)
class MyOrderViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.filter(user=self.request.user).prefetch_related('items').order_by('-id')
class CartView(generics.GenericAPIView):
def get(self, request):
return Response(request.session.get('cart', {}))
def post(self, request):
product = generics.get_object_or_404(Product, id=request.data.get('product_id'), is_active=True)
quantity = int(request.data.get('quantity', 1))
if quantity < 1 or quantity > product.stock:
return Response({'message': '数量必须大于0且不能超过库存'}, status=status.HTTP_400_BAD_REQUEST)
cart = request.session.setdefault('cart', {})
cart[str(product.id)] = quantity
request.session.modified = True
return Response(cart, status=status.HTTP_201_CREATED)
def delete(self, request):
cart = request.session.get('cart', {})
cart.pop(str(request.data.get('product_id')), None)
request.session.modified = True
return Response(cart)