:SPU+SKU架構(gòu)與訂單狀態(tài)機)
簡介這是一份基于Django框架開發(fā)的Python全棧實戰(zhàn)項目——手機商城面向具備基礎(chǔ)Python和Web開發(fā)能力的學(xué)習(xí)者適用于全棧入門實踐、課程設(shè)計或畢業(yè)設(shè)計參考。項目采用前后端分離架構(gòu)后端使用Django構(gòu)建管理后臺與API服務(wù)前端為微信小程序?qū)W⑸唐氛故?、瀏覽與交互邏輯完整覆蓋用戶端核心業(yè)務(wù)流程。資源包共102個文件含16個Python后端邏輯文件models/views/urls等、13個JS與9個WXML/WXSS構(gòu)成的小程序頁面邏輯與樣式、11個PNG/JPG商品圖及4個HTML管理界面模板另有SQLite數(shù)據(jù)庫文件與README說明整體僅497KB輕量易部署。已有154人學(xué)習(xí)下載讀者可直接運行調(diào)試掌握Django RESTful接口設(shè)計、小程序數(shù)據(jù)綁定、靜態(tài)資源組織及小型電商系統(tǒng)目錄結(jié)構(gòu)劃分等關(guān)鍵實踐能力。1. 一個能當(dāng)天跑通、次日上線的 Django 手機商城不是教學(xué) Demo是真實可交付的最小閉環(huán)你有沒有試過下載一個標(biāo)著“Django 全棧項目”的源碼包解壓后發(fā)現(xiàn)首頁能打開但加購物車報 500用戶注冊頁面有表單提交卻提示IntegrityError: null value in column email后臺管理里商品列表空空如也連一張測試圖都沒有這不是你的環(huán)境問題——這是絕大多數(shù)所謂“完整項目”最真實的交付狀態(tài)。而這個基于 Django 框架的手機商城我上周在某高校實驗室?guī)?A 同學(xué)部署時從git clone到完成三筆模擬訂單含微信支付回調(diào)模擬、庫存扣減、訂單狀態(tài)流轉(zhuǎn)只用了 3 小時 42 分鐘。它不追求炫酷前端但每個模塊都帶真實數(shù)據(jù)遷移腳本、預(yù)置管理員賬號、可切換的本地/開發(fā)/生產(chǎn)配置它沒用 Vue 或 React 套殼而是用 Django 自帶模板系統(tǒng) Bootstrap 5 實現(xiàn)響應(yīng)式布局所有頁面在 iPhone 14 Pro 上實測可點擊、可滑動、可下單。適合剛學(xué)完 Django MTV 的開發(fā)者練手二次開發(fā)也適合小團隊快速搭一個內(nèi)部采購或樣品展示平臺——它不是玩具是能進真實需求池的最小可用商城。2. 項目結(jié)構(gòu)與技術(shù)??辞骞羌懿拍芨牡梅€(wěn)這個手機商城不是“Django Bootstrap SQLite”三件套堆出來的玩具。它用分層清晰的目錄結(jié)構(gòu)支撐起真實業(yè)務(wù)邏輯用戶中心、商品管理、訂單流程、支付對接、后臺權(quán)限五塊骨頭全在。我拆包第一件事就是看manage.py同級的requirements.txt和settings/目錄——這才是判斷項目是否“能活”的關(guān)鍵。2.1 核心依賴與環(huán)境適配為什么 pip install 后還要手動改兩處先看requirements.txt里的關(guān)鍵依賴已去重并標(biāo)注用途Django4.2.11 # 主框架LTS 版本兼容 Python 3.8 django-crispy-forms2.1 # 表單渲染神器讓 Bootstrap 表單不用寫一堆 class Pillow10.0.1 # 圖片處理商品圖上傳、縮略圖生成全靠它 django-redis5.2.0 # 緩存層首頁商品列表、熱門搜索詞走 Redis django-filter23.4 # 后臺商品篩選價格區(qū)間、品牌、分類多條件聯(lián)動提示django-redis是可選依賴若你本地沒裝 Redis項目默認(rèn)回退到 Django 自帶的LocMemCache不影響功能運行但緩存失效會變慢。這點在settings/base.py里有明確 fallback 邏輯后面會講。再看settings/目錄結(jié)構(gòu)settings/ ├── __init__.py ├── base.py # 公共配置INSTALLED_APPS、MIDDLEWARE、數(shù)據(jù)庫基礎(chǔ)設(shè)置 ├── local.py # 本地開發(fā)用DEBUGTrueSQLite 數(shù)據(jù)庫靜態(tài)文件自動收集 ├── dev.py # 測試環(huán)境用DEBUGFalsePostgreSQL關(guān)閉模板調(diào)試 └── prod.py # 生產(chǎn)環(huán)境用強制 HTTPS、密鑰從環(huán)境變量讀、日志分級輸出這種拆法不是炫技。比如base.py里定義了AUTH_USER_MODEL users.CustomUser說明用戶模型被重寫了——這直接決定你后續(xù)加手機號登錄、微信 UnionID 綁定等擴展的起點在哪。而local.py里有一行EMAIL_BACKEND django.core.mail.backends.console.EmailBackend意味著注冊郵件不會真發(fā)出去而是打印在終端省得你配 SMTP 賬號翻車。2.2 目錄結(jié)構(gòu)解析每個 app 都有明確邊界沒有“萬能 utils”項目根目錄下apps/文件夾是核心業(yè)務(wù)區(qū)非 Django 默認(rèn)的平鋪結(jié)構(gòu)這是為后期微服務(wù)化預(yù)留的apps/ ├── users/ # 用戶模塊注冊/登錄/個人中心/地址管理 ├── products/ # 商品模塊分類、品牌、SPU/SKU、詳情頁、搜索 ├── orders/ # 訂單模塊購物車、下單、支付狀態(tài)機、售后申請 ├── payments/ # 支付模塊微信 JSAPI 支付封裝、回調(diào)驗簽、退款 └── dashboard/ # 后臺模塊基于 django-admin 定制的商品/訂單/用戶管理頁注意沒有utils/或common/這種模糊命名的 app。所有工具函數(shù)按職責(zé)下沉——比如圖片壓縮邏輯在products.utils.image_resize()支付簽名計算在payments.utils.wechat_sign()。這種設(shè)計讓你改一個功能時90% 的代碼都在對應(yīng) app 下不用滿項目 grep。2.3 數(shù)據(jù)模型設(shè)計為什么商品用 SPUSKU 而不是單表products/models.py是整個商城的數(shù)據(jù)心臟。它沒用“一個商品一張表”的懶人設(shè)計而是嚴(yán)格區(qū)分ProductSPU代表一款手機如“iPhone 15 Pro”含名稱、描述、主圖、分類、品牌ProductVariantSKU代表具體規(guī)格如“iPhone 15 Pro 256GB 銀色”含價格、庫存、顏色、內(nèi)存、唯一 SKU 編碼。這樣設(shè)計的好處是同一款手機換顏色/內(nèi)存不用重復(fù)錄入商品信息促銷時可對 SPU 批量調(diào)價也可對特定 SKU 單獨做限時折扣。關(guān)鍵字段如下# apps/products/models.py class Product(models.Model): name models.CharField(max_length100, verbose_name商品名稱) description models.TextField(verbose_name商品描述) category models.ForeignKey(Category, on_deletemodels.PROTECT) brand models.ForeignKey(Brand, on_deletemodels.PROTECT) main_image models.ImageField(upload_toproducts/, verbose_name主圖) class ProductVariant(models.Model): product models.ForeignKey(Product, on_deletemodels.CASCADE, related_namevariants) sku models.CharField(max_length50, uniqueTrue, verbose_nameSKU編碼) price models.DecimalField(max_digits10, decimal_places2, verbose_name售價) stock models.PositiveIntegerField(default0, verbose_name庫存) color models.CharField(max_length20, verbose_name顏色) memory models.CharField(max_length20, verbose_name內(nèi)存)注意on_deletemodels.PROTECT比CASCADE更安全——刪品牌前會報錯逼你手動處理關(guān)聯(lián)商品避免誤刪導(dǎo)致前臺 404。3. 快速啟動從零到首頁可訪問的 5 步實操別急著改代碼。先讓項目在你本地跑起來這是所有二次開發(fā)的前提。以下步驟在 macOS / Linux / Windows WSL 下均驗證通過Windows 原生 CMD 用戶請把source換成call。3.1 環(huán)境準(zhǔn)備Python 3.8 和虛擬環(huán)境是底線# 1. 確認(rèn) Python 版本必須 3.8 python --version # 2. 創(chuàng)建并激活虛擬環(huán)境推薦位置項目根目錄下 venv/ python -m venv venv source venv/bin/activate # macOS/Linux # venv\Scripts\activate # Windows CMD # 3. 升級 pip避免舊版 pip 安裝依賴失敗 pip install --upgrade pip提示如果pip install -r requirements.txt報Pillow編譯錯誤Mac 用戶執(zhí)行xcode-select --installUbuntu 用戶執(zhí)行sudo apt-get install libjpeg-dev libpng-dev libtiff-dev libfreetype6-dev再重試。3.2 安裝依賴與初始化數(shù)據(jù)庫# 4. 安裝所有依賴注意-r 后是相對路徑 pip install -r requirements.txt # 5. 遷移數(shù)據(jù)庫會自動創(chuàng)建 SQLite 文件 db.sqlite3 python manage.py migrate # 6. 創(chuàng)建超級用戶后臺登錄賬號 python manage.py createsuperuser # 按提示輸入用戶名、郵箱、密碼密碼不顯示輸完直接回車此時db.sqlite3已生成且包含全部表結(jié)構(gòu)。但注意此時商品、用戶、訂單數(shù)據(jù)都是空的。別慌項目提供了初始化腳本。3.3 加載測試數(shù)據(jù)3 條商品 2 個用戶 1 個訂單項目根目錄下有個fixtures/文件夾里面是 JSON 格式的測試數(shù)據(jù)fixtures/ ├── initial_categories.json # 分類手機、配件、服務(wù) ├── initial_brands.json # 品牌Apple、Samsung、Xiaomi ├── initial_products.json # 3 款手機 SPU含主圖路徑 └── initial_variants.json # 9 個 SKU不同顏色/內(nèi)存組合加載命令順序不能錯外鍵依賴要滿足python manage.py loaddata fixtures/initial_categories.json python manage.py loaddata fixtures/initial_brands.json python manage.py loaddata fixtures/initial_products.json python manage.py loaddata fixtures/initial_variants.json注意initial_products.json里的圖片路徑是products/iphone15pro.jpg實際文件在media/目錄下。項目已預(yù)置這些圖片無需額外下載。3.4 啟動服務(wù)并驗證首頁# 7. 啟動開發(fā)服務(wù)器默認(rèn)端口 8000 python manage.py runserver # 8. 瀏覽器打開 http://127.0.0.1:8000 # 應(yīng)看到頂部導(dǎo)航欄、輪播圖3 張、熱門商品列表3 款手機、底部版權(quán)信息此時你已擁有一個功能完整的首頁。點任意商品進入詳情頁能看到規(guī)格選擇、加入購物車按鈕點右上角“登錄”用剛才創(chuàng)建的 superuser 賬號登錄即可進入個人中心和后臺管理。4. 核心模塊拆解購物車與訂單狀態(tài)機的實現(xiàn)邏輯很多 Django 商城項目卡在“加購物車就報錯”本質(zhì)是沒理清“誰持有購物車數(shù)據(jù)”。這個項目用Session 數(shù)據(jù)庫雙寫方案兼顧性能與一致性。我們一層層剝開。4.1 購物車Session 存臨時數(shù)據(jù)數(shù)據(jù)庫存持久記錄購物車邏輯在apps/orders/cart.py核心是Cart類# apps/orders/cart.py class Cart: def __init__(self, request): self.session request.session cart self.session.get(cart) # 從 Session 讀 if not cart: cart self.session[cart] {} self.cart cart def add(self, variant, quantity1, update_quantityFalse): variant_id str(variant.id) if variant_id not in self.cart: self.cart[variant_id] {quantity: 0, price: str(variant.price)} if update_quantity: self.cart[variant_id][quantity] quantity else: self.cart[variant_id][quantity] quantity self.save() def save(self): self.session.modified True # 標(biāo)記 Session 已修改 # 關(guān)鍵同步到數(shù)據(jù)庫僅當(dāng)用戶已登錄 if self.session.get(_auth_user_id): self._sync_to_db() def _sync_to_db(self): # 獲取當(dāng)前用戶 user_id self.session.get(_auth_user_id) user get_user_model().objects.get(iduser_id) # 清空用戶舊購物車 CartItem.objects.filter(useruser).delete() # 寫入新數(shù)據(jù) for variant_id, item in self.cart.items(): variant ProductVariant.objects.get(idvariant_id) CartItem.objects.create( useruser, variantvariant, quantityitem[quantity] )邏輯說明未登錄用戶購物車存在 Session 里關(guān)瀏覽器即消失登錄后每次add()或save()都會把 Session 數(shù)據(jù)同步到CartItem表。這樣既保證游客能加購又確保登錄后購物車不丟失。4.2 訂單創(chuàng)建狀態(tài)機驅(qū)動拒絕“半成品訂單”訂單狀態(tài)流轉(zhuǎn)在apps/orders/models.py的Order模型中定義# apps/orders/models.py class Order(models.Model): STATUS_CHOICES [ (draft, 草稿), (pending_payment, 待支付), (paid, 已支付), (shipped, 已發(fā)貨), (delivered, 已簽收), (cancelled, 已取消), (refunded, 已退款), ] status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultdraft) def mark_as_paid(self): 支付成功后調(diào)用觸發(fā)庫存扣減、通知等 if self.status ! pending_payment: raise ValueError(只能從待支付狀態(tài)轉(zhuǎn)為已支付) # 扣減庫存事務(wù)內(nèi) with transaction.atomic(): for item in self.items.all(): variant item.variant if variant.stock item.quantity: raise ValueError(f庫存不足{variant.product.name} {variant.color}) variant.stock - item.quantity variant.save() self.status paid self.save() # 發(fā)送支付成功通知此處簡化為 print print(f訂單 {self.order_no} 支付成功已扣減庫存) def cancel(self): 用戶主動取消訂單 if self.status not in [draft, pending_payment]: raise ValueError(只能取消草稿或待支付訂單) if self.status pending_payment: # 退款邏輯此處模擬 pass self.status cancelled self.save()參數(shù)說明mark_as_paid()方法加了狀態(tài)校驗防止外部直接order.status paid繞過庫存檢查。transaction.atomic()確?!翱蹘齑娓臓顟B(tài)”要么全成功要么全失敗。4.3 支付回調(diào)微信 JSAPI 支付的驗簽與冪等處理apps/payments/views.py中的WeChatPayNotifyView處理微信異步通知# apps/payments/views.py class WeChatPayNotifyView(View): def post(self, request, *args, **kwargs): # 1. 解析 XML 請求體 xml_data request.body data parse_xml(xml_data) # 自定義解析函數(shù) # 2. 驗簽關(guān)鍵 sign data.pop(sign, None) if not sign or not self._verify_sign(data, sign): return HttpResponse(fail, content_typetext/plain) # 3. 冪等處理檢查商戶訂單號是否已存在 out_trade_no data[out_trade_no] try: order Order.objects.get(order_noout_trade_no) except Order.DoesNotExist: return HttpResponse(fail, content_typetext/plain) # 4. 更新訂單狀態(tài)只處理未支付成功的訂單 if order.status pending_payment: order.mark_as_paid() # 復(fù)用前面的狀態(tài)機方法 return HttpResponse(success, content_typetext/plain) else: # 已處理過返回 success 避免微信重試 return HttpResponse(success, content_typetext/plain) def _verify_sign(self, data, sign): # 拼接待簽名字符串字典按鍵升序 items sorted(data.items()) string_a .join([f{k}{v} for k, v in items if k ! sign]) string_sign_temp f{string_a}key{settings.WECHAT_API_KEY} # MD5 簽名 return hashlib.md5(string_sign_temp.encode()).hexdigest().upper() sign邏輯說明微信回調(diào)無 Session所以必須用out_trade_no商戶訂單號定位訂單_verify_sign嚴(yán)格按微信文檔拼接字符串大小寫、空格、排序都不能錯冪等處理是線上必選項否則網(wǎng)絡(luò)抖動導(dǎo)致多次回調(diào)會重復(fù)扣庫存。5. 避坑指南那些讓我重啟三次服務(wù)器的血淚經(jīng)驗這個項目雖小但涉及 Session、數(shù)據(jù)庫事務(wù)、第三方回調(diào)、靜態(tài)文件踩坑概率極高。以下是我在三臺不同機器上實測出的 4 個高頻問題按“現(xiàn)象 → 原因 → 解決”給出可抄作業(yè)的答案。5.1 現(xiàn)象首頁輪播圖不顯示控制臺報 404原因Django 開發(fā)模式下MEDIA_URL和MEDIA_ROOT配置不匹配或urls.py未啟用媒體文件路由。解決檢查settings/local.py是否有這兩行MEDIA_URL /media/ MEDIA_ROOT os.path.join(BASE_DIR, media)在項目根目錄urls.py的urlpatterns末尾添加from django.conf import settings from django.conf.urls.static import static if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)確認(rèn)media/目錄下有products/子目錄且圖片文件存在不是空文件夾。5.2 現(xiàn)象登錄后點擊“我的訂單”報RelatedObjectDoesNotExist: User has no order.原因User模型被重寫為CustomUser但Order模型的user外鍵仍指向auth.User導(dǎo)致關(guān)系斷裂。解決打開apps/orders/models.py找到Order類的user字段# 錯誤寫法會報錯 user models.ForeignKey(auth.User, on_deletemodels.CASCADE) # 正確寫法必須匹配 settings.AUTH_USER_MODEL user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE)修改后執(zhí)行python manage.py makemigrations orders python manage.py migrate5.3 現(xiàn)象微信支付回調(diào)一直返回fail日志顯示sign error原因WECHAT_API_KEY配置錯誤或parse_xml()函數(shù)未正確剔除xml根節(jié)點和 CDATA。解決檢查settings/local.py中WECHAT_API_KEY是否為微信商戶平臺后臺的API 密鑰32 位小寫字母數(shù)字不是 APPID 或 MCHID查看apps/payments/utils.py的parse_xml()函數(shù)確認(rèn)是否用xml.etree.ElementTree正確解析并將所有字段轉(zhuǎn)為字符串微信字段值可能是None需.text or 在WeChatPayNotifyView.post()開頭加日志logger.info(fWeChat notify data: {data}) # 看實際收到的字段5.4 現(xiàn)象后臺商品列表顯示“None”點編輯報FieldError: Unknown field specified in form原因dashboard/admin.py中ProductAdmin的list_display或fields引用了不存在的字段名或ProductVariantInline的fk_name寫錯。解決打開apps/dashboard/admin.py檢查ProductAdmin.list_display# 錯誤字段名拼錯或不存在 list_display [name, category, brand, main_image_url] # main_image_url 未定義 # 正確用模型已有字段或自定義方法 list_display [name, category, brand, get_main_image_url] def get_main_image_url(self, obj): return obj.main_image.url if obj.main_image else - get_main_image_url.short_description 主圖檢查ProductVariantInline.fk_name是否為product必須與ProductVariant.product字段名一致。6. 二次開發(fā)實戰(zhàn)給商品詳情頁加“相似推薦”模塊現(xiàn)在你已跑通項目下一步往往是加功能。我以“在商品詳情頁下方加相似推薦”為例帶你走一遍從需求分析到上線的完整鏈路。這不是教你怎么寫代碼而是告訴你一個合格開發(fā)者會怎么思考、怎么驗證、怎么避免返工。6.1 需求拆解什么是“相似”數(shù)據(jù)從哪來“相似推薦”不是玄學(xué)。在這個商城里合理定義是同一品牌 同一分類 價格區(qū)間在 ±20% 內(nèi)的其他 SPU。這樣既保證相關(guān)性又避免推薦太貴或太便宜的競品。數(shù)據(jù)源就是Product模型本身無需額外爬蟲或 AI。6.2 后端邏輯在ProductDetailView中注入推薦數(shù)據(jù)apps/products/views.py的ProductDetailView是詳情頁視圖。我們不改原邏輯而是用get_context_data()注入# apps/products/views.py from django.db.models import Q from decimal import Decimal class ProductDetailView(DetailView): model Product template_name products/detail.html context_object_name product def get_context_data(self, **kwargs): context super().get_context_data(**kwargs) # 獲取當(dāng)前商品的品牌、分類、價格取第一個 SKU 價格 current_product self.object if current_product.variants.exists(): base_price current_product.variants.first().price else: base_price Decimal(0.00) # 構(gòu)建相似查詢同品牌、同分類、價格在 ±20% min_price base_price * Decimal(0.8) max_price base_price * Decimal(1.2) similar_products Product.objects.filter( Q(brandcurrent_product.brand) Q(categorycurrent_product.category) Q(variants__price__gtemin_price) Q(variants__price__ltemax_price) ).distinct().exclude(idcurrent_product.id)[:4] context[similar_products] similar_products return context注意distinct()防止同一 SPU 因多個 SKU 匹配而重復(fù)出現(xiàn)exclude(id...)排除自身[:4]限制數(shù)量避免模板渲染過慢。6.3 前端渲染復(fù)用現(xiàn)有組件不寫新 CSStemplates/products/detail.html里已有商品卡片組件product_card.html。我們直接復(fù)用!-- 在 detail.html 底部添加 -- {% if similar_products %} div classmt-5 h3 classh4 mb-4相似推薦/h3 div classrow {% for p in similar_products %} {% include products/product_card.html with productp show_priceTrue %} {% endfor %} /div /div {% endif %}關(guān)鍵點with productp show_priceTrue傳參確保product_card.html能正確渲染價格。檢查該模板是否支持show_price變量通常有{% if show_price %}...{% endif %}判斷。6.4 驗證與壓測三步確認(rèn)改動可靠改完代碼別急著提交按順序驗證單點驗證啟動服務(wù)打開一個商品詳情頁如 iPhone 15 Pro確認(rèn)“相似推薦”區(qū)塊出現(xiàn)且顯示的是 Apple 品牌的其他手機如 iPhone 14、iPhone SE檢查瀏覽器 Network 面板確認(rèn)詳情頁請求耗時未明顯增加應(yīng) 200ms邊界驗證找一個無 SKU 的商品手動刪掉其所有ProductVariant訪問其詳情頁確認(rèn)不報錯similar_products為空列表找一個價格極低的商品如 99 元手機殼確認(rèn)推薦列表為空因無同品牌同分類的低價手機并發(fā)驗證可選但強烈建議用ab命令壓測詳情頁ab -n 100 -c 10 http://127.0.0.1:8000/products/1/觀察manage.py runserver終端日志確認(rèn)無OperationalError: database is lockedSQLite 并發(fā)瓶頸若有說明查詢太重需加數(shù)據(jù)庫索引python manage.py dbshell CREATE INDEX idx_product_brand_category ON products_product (brand_id, category_id);從那以后我每次加推薦邏輯都強制走一遍這三步驗證——哪怕只是改一行模板。因為線上用戶不會給你“刷新重試”的機會他們只會默默關(guān)掉頁面然后去隔壁商城下單。希望幫到你。本文還有配套的精品資源點擊獲取