在线商城 MVP — Django 终极实战
🕐 预计用时:3-4 小时 | 🎯 目标:用 Django 构建完整的在线商城(商品展示 + 购物车 + 订单 + 用户中心)
📖 今日目录
1. 项目需求分析
2. 项目结构
shop/├── shop/│ ├── __init__.py│ ├── settings.py│ ├── urls.py│ └── wsgi.py├── apps/│ ├── products/ # 商品模块│ │ ├── models.py│ │ ├── views.py│ │ ├── urls.py│ │ └── admin.py│ ├── cart/ # 购物车模块│ │ ├── cart.py # 购物车类│ │ ├── views.py│ │ └── urls.py│ ├── orders/ # 订单模块│ │ ├── models.py│ │ ├── views.py│ │ └── urls.py│ └── users/ # 用户模块│ ├── views.py│ └── urls.py├── templates/├── static/├── media/├── manage.py└── requirements.txt
3. 数据模型设计
# apps/products/models.pyfrom django.db import modelsclass Category(models.Model): name = models.CharField('分类名称', max_length=100) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children', verbose_name='父分类') icon = models.CharField('图标', max_length=50, blank=True) order = models.IntegerField('排序', default=0) class Meta: verbose_name = '分类' ordering = ['order'] def __str__(self): return self.nameclass Product(models.Model): name = models.CharField('商品名称', max_length=200) slug = models.SlugField('URL别名', unique=True) category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='products') description = models.TextField('商品描述', blank=True) price = models.DecimalField('价格', max_digits=10, decimal_places=2) original_price = models.DecimalField('原价', max_digits=10, decimal_places=2, null=True, blank=True) stock = models.PositiveIntegerField('库存', default=0) sales = models.PositiveIntegerField('销量', default=0) image = models.ImageField('商品图片', upload_to='products/', blank=True) is_active = models.BooleanField('上架', default=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: verbose_name = '商品' ordering = ['-created_at'] def __str__(self): return self.name @property def is_in_stock(self): return self.stock > 0
# apps/orders/models.pyfrom django.db import modelsfrom django.conf import settingsclass Order(models.Model): STATUS_CHOICES = [ ('pending', '待付款'), ('paid', '已付款'), ('shipped', '已发货'), ('delivered', '已收货'), ('cancelled', '已取消'), ] user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders') order_no = models.CharField('订单号', max_length=64, unique=True) status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='pending') total_amount = models.DecimalField('总金额', max_digits=10, decimal_places=2) # 收货信息 receiver_name = models.CharField('收货人', max_length=50) receiver_phone = models.CharField('手机号', max_length=20) receiver_address = models.CharField('收货地址', max_length=300) created_at = models.DateTimeField('创建时间', auto_now_add=True) paid_at = models.DateTimeField('付款时间', null=True, blank=True) class Meta: verbose_name = '订单' ordering = ['-created_at'] def __str__(self): return f'订单 {self.order_no}'class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') product = models.ForeignKey('products.Product', on_delete=models.SET_NULL, null=True) product_name = models.CharField('商品名称', max_length=200) price = models.DecimalField('单价', max_digits=10, decimal_places=2) quantity = models.IntegerField('数量', default=1) @property def subtotal(self): return self.price * self.quantity
# apps/users/models.pyclass Address(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='addresses') receiver_name = models.CharField('收货人', max_length=50) phone = models.CharField('手机号', max_length=20) province = models.CharField('省', max_length=50) city = models.CharField('市', max_length=50) district = models.CharField('区', max_length=50) detail = models.CharField('详细地址', max_length=200) is_default = models.BooleanField('默认地址', default=False) class Meta: verbose_name = '收货地址' ordering = ['-is_default']
4. 商品展示模块
# apps/products/views.pyfrom django.views.generic import ListView, DetailViewfrom .models import Product, Categoryclass ProductListView(ListView): model = Product template_name = 'products/product_list.html' context_object_name = 'products' paginate_by = 12 def get_queryset(self): queryset = super().get_queryset().filter(is_active=True) # 分类筛选 category_id = self.kwargs.get('category_id') if category_id: category = Category.objects.get(id=category_id) # 包含子分类 categories = category.get_descendants() queryset = queryset.filter(category__in=categories) # 关键词搜索 q = self.request.GET.get('q') if q: queryset = queryset.filter(name__icontains=q) # 排序 sort = self.request.GET.get('sort', '-created_at') if sort == 'price': queryset = queryset.order_by('price') elif sort == '-price': queryset = queryset.order_by('-price') elif sort == 'sales': queryset = queryset.order_by('-sales') return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['categories'] = Category.objects.filter(parent=None) context['current_sort'] = self.request.GET.get('sort', '-created_at') context['query'] = self.request.GET.get('q', '') return contextclass ProductDetailView(DetailView): model = Product template_name = 'products/product_detail.html' context_object_name = 'product' def get_queryset(self): return super().get_queryset().filter(is_active=True)
5. 购物车模块
# apps/cart/cart.pyfrom decimal import Decimalclass Cart: """Session 购物车""" def __init__(self, request): self.session = request.session cart = self.session.get('cart') if not cart: cart = self.session['cart'] = {} self.cart = cart def add(self, product, quantity=1): """添加商品""" product_id = str(product.id) if product_id not in self.cart: self.cart[product_id] = { 'quantity': 0, 'price': str(product.price), 'name': product.name, } self.cart[product_id]['quantity'] += quantity self.save() def remove(self, product_id): """删除商品""" product_id = str(product_id) if product_id in self.cart: del self.cart[product_id] self.save() def update(self, product_id, quantity): """更新数量""" product_id = str(product_id) if product_id in self.cart: self.cart[product_id]['quantity'] = quantity if quantity <= 0: self.remove(product_id) else: self.save() def save(self): self.session.modified = True def __iter__(self): """遍历购物车商品""" for item in self.cart.values(): item['total_price'] = Decimal(item['price']) * item['quantity'] yield item def __len__(self): """商品总数""" return sum(item['quantity'] for item in self.cart.values()) def get_total_price(self): return sum(Decimal(item['price']) * item['quantity'] for item in self.cart.values()) def clear(self): del self.session['cart'] self.save()
# apps/cart/views.pyfrom django.shortcuts import render, redirect, get_object_or_404from django.contrib.auth.decorators import login_requiredfrom apps.products.models import Productfrom .cart import Cartdef cart_detail(request): cart = Cart(request) return render(request, 'cart/detail.html', {'cart': cart})def cart_add(request, product_id): product = get_object_or_404(Product, id=product_id) cart = Cart(request) quantity = int(request.POST.get('quantity', 1)) cart.add(product, quantity) return redirect('cart:detail')def cart_remove(request, product_id): cart = Cart(request) cart.remove(product_id) return redirect('cart:detail')def cart_update(request, product_id): cart = Cart(request) quantity = int(request.POST.get('quantity', 1)) cart.update(product_id, quantity) return redirect('cart:detail')
6. 订单模块
# apps/orders/views.pyimport uuidfrom django.shortcuts import render, redirectfrom django.contrib.auth.decorators import login_requiredfrom django.db import transactionfrom django.utils import timezonefrom .models import Order, OrderItemfrom apps.cart.cart import Cart@login_requireddef order_create(request): """创建订单""" cart = Cart(request) if len(cart) == 0: return redirect('cart:detail') if request.method == 'POST': with transaction.atomic(): # 数据库事务 # 创建订单 order = Order.objects.create( user=request.user, order_no=f'{timezone.now().strftime("%Y%m%d%H%M%S")}{uuid.uuid4().hex[:8]}', total_amount=cart.get_total_price(), receiver_name=request.POST['receiver_name'], receiver_phone=request.POST['receiver_phone'], receiver_address=request.POST['receiver_address'], ) # 创建订单项 + 扣减库存 for item in cart: product = Product.objects.select_for_update().get(name=item['name']) if product.stock < item['quantity']: transaction.rollback() return render(request, 'cart/detail.html', { 'cart': cart, 'error': f'{product.name} 库存不足' }) OrderItem.objects.create( order=order, product=product, product_name=product.name, price=Decimal(item['price']), quantity=item['quantity'], ) product.stock -= item['quantity'] product.sales += item['quantity'] product.save() cart.clear() # 清空购物车 return redirect('orders:detail', order_no=order.order_no) return render(request, 'orders/create.html', {'cart': cart})@login_requireddef order_list(request): """订单列表""" orders = Order.objects.filter(user=request.user).prefetch_related('items') return render(request, 'orders/list.html', {'orders': orders})@login_requireddef order_detail(request, order_no): """订单详情""" order = Order.objects.prefetch_related('items').get( user=request.user, order_no=order_no ) return render(request, 'orders/detail.html', {'order': order})@login_requireddef order_cancel(request, order_no): """取消订单""" order = Order.objects.get(user=request.user, order_no=order_no, status='pending') with transaction.atomic(): for item in order.items.all(): if item.product: item.product.stock += item.quantity item.product.sales -= item.quantity item.product.save() order.status = 'cancelled' order.save() return redirect('orders:detail', order_no=order.order_no)
7. 用户中心
# apps/users/views.pyfrom django.shortcuts import render, redirectfrom django.contrib.auth.decorators import login_requiredfrom django.contrib.auth import update_session_auth_hashfrom .models import Addressfrom .forms import AddressForm@login_requireddef profile(request): """个人中心""" orders = request.user.orders.all()[:5] # 最近 5 个订单 return render(request, 'users/profile.html', {'orders': orders})@login_requireddef address_list(request): """收货地址列表""" addresses = request.user.addresses.all() return render(request, 'users/address_list.html', {'addresses': addresses})@login_requireddef address_create(request): """添加地址""" if request.method == 'POST': form = AddressForm(request.POST) if form.is_valid(): address = form.save(commit=False) address.user = request.user address.save() return redirect('users:address_list') else: form = AddressForm() return render(request, 'users/address_form.html', {'form': form})@login_requireddef change_password(request): """修改密码""" if request.method == 'POST': old_pw = request.POST.get('old_password') new_pw = request.POST.get('new_password') if request.user.check_password(old_pw): request.user.set_password(new_pw) request.user.save() update_session_auth_hash(request, request.user) return redirect('users:profile') return render(request, 'users/change_password.html')
8. 模板设计
<!-- templates/products/product_list.html -->{% extends "base.html" %}{% load static %}{% block content %}<div class="shop-layout"> {# 分类侧边栏 #} <aside class="sidebar"> <h3>商品分类</h3> <ul> {% for cat in categories %} <li> <a href="{% url 'products:list' cat.id %}">{{ cat.name }}</a> {% if cat.children.all %} <ul> {% for child in cat.children.all %} <li><a href="{% url 'products:list' child.id %}">{{ child.name }}</a></li> {% endfor %} </ul> {% endif %} </li> {% endfor %} </ul> </aside> {# 商品列表 #} <main class="product-grid"> {# 搜索栏 #} <form method="GET" class="search-bar"> <input type="text" name="q" value="{{ query }}" placeholder="搜索商品..."> <button type="submit">搜索</button> </form> {# 排序 #} <div class="sort-bar"> <a href="?sort=-created_at" {% if current_sort == '-created_at' %}class="active"{% endif %}>最新</a> <a href="?sort=price" {% if current_sort == 'price' %}class="active"{% endif %}>价格↑</a> <a href="?sort=-price" {% if current_sort == '-price' %}class="active"{% endif %}>价格↓</a> <a href="?sort=sales" {% if current_sort == 'sales' %}class="active"{% endif %}>销量</a> </div> {# 商品卡片 #} <div class="products"> {% for product in products %} <div class="product-card"> <a href="{% url 'products:detail' product.pk %}"> <img src="{{ product.image.url }}" alt="{{ product.name }}"> <h4>{{ product.name }}</h4> <p class="price">¥{{ product.price }}</p> {% if product.original_price %} <p class="original-price">¥{{ product.original_price }}</p> {% endif %} <p class="sales">已售 {{ product.sales }}</p> </a> </div> {% endfor %} </div> {# 分页 #} {% include "includes/pagination.html" %} </main></div>{% endblock %}
<!-- templates/cart/detail.html -->{% extends "base.html" %}{% block content %}<h1>🛒 购物车</h1>{% if error %} <div class="alert alert-danger">{{ error }}</div>{% endif %}{% if cart|length > 0 %}<table class="cart-table"> <tr> <th>商品</th><th>单价</th><th>数量</th><th>小计</th><th>操作</th> </tr> {% for item in cart %} <tr> <td>{{ item.name }}</td> <td>¥{{ item.price }}</td> <td> <form method="POST" action="{% url 'cart:update' item.product_id %}" style="display:inline"> {% csrf_token %} <input type="number" name="quantity" value="{{ item.quantity }}" min="1"> <button type="submit">更新</button> </form> </td> <td>¥{{ item.total_price }}</td> <td><a href="{% url 'cart:remove' item.product_id %}">删除</a></td> </tr> {% endfor %}</table><div class="cart-total"> <p>共 {{ cart|length }} 件商品,合计:<strong>¥{{ cart.get_total_price }}</strong></p> <a href="{% url 'orders:create' %}" class="btn">去结算</a></div>{% else %} <p>购物车是空的,<a href="{% url 'products:list' %}">去逛逛</a></p>{% endif %}{% endblock %}
9. 完整代码
📄 urls.py
# shop/urls.pyfrom django.contrib import adminfrom django.urls import path, includefrom django.conf import settingsfrom django.conf.urls.static import staticurlpatterns = [ path('admin/', admin.site.urls), path('', include('apps.products.urls')), path('cart/', include('apps.cart.urls')), path('orders/', include('apps.orders.urls')), path('user/', include('apps.users.urls')), path('accounts/', include('django.contrib.auth.urls')),]if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# apps/products/urls.pyfrom django.urls import pathfrom . import viewsapp_name = 'products'urlpatterns = [ path('', views.ProductListView.as_view(), name='list'), path('category/<int:category_id>/', views.ProductListView.as_view(), name='category_list'), path('product/<int:pk>/', views.ProductDetailView.as_view(), name='detail'),]# apps/cart/urls.pyapp_name = 'cart'urlpatterns = [ path('', views.cart_detail, name='detail'), path('add/<int:product_id>/', views.cart_add, name='add'), path('remove/<int:product_id>/', views.cart_remove, name='remove'), path('update/<int:product_id>/', views.cart_update, name='update'),]# apps/orders/urls.pyapp_name = 'orders'urlpatterns = [ path('', views.order_list, name='list'), path('create/', views.order_create, name='create'), path('<str:order_no>/', views.order_detail, name='detail'), path('<str:order_no>/cancel/', views.order_cancel, name='cancel'),]
10. 今日小结
| |
|---|
| |
| |
| |
| 数据库事务(transaction.atomic)+ 库存扣减 |
| |
| @login_required,只能操作自己的数据 |
🎉 Django 阶段完结!Day 59-66 你已经掌握了 Django 的全部核心:✅ MVT 架构 · 路由 · Admin · 模型 · 视图 · 模板✅ 表单 · 认证 · DRF API · 部署 · 综合项目接下来进入前端基础(Day 67-70)—— HTML、CSS、JavaScript,让你的全栈技能闭环!