Django+Vue实战:超市进销存仓储系统从建模到部署全指南

发布时间:2026/9/14 23:27:19
Django+Vue实战:超市进销存仓储系统从建模到部署全指南 最近花了三周多时间把一套基于 Django Vue 的超市进销存仓储系统从数据库设计到前后端联调完整跑通了。这套系统除了常规的商品管理、库存盘点之外重点做了供应商采购和前台收银两条链路供应商把货送到仓库仓库入库前台销售出库整个进销存的数据闭环被打通。完工后回头看真正值钱的地方不是某个炫技功能而是把“进销存”三个字拆成清晰的数据流再落到 Django 模型和 Vue 页面里。如果你也想做类似的超市、便利店或者中小仓储系统这篇文章应该能帮你少走不少弯路。我默认你已经有 Django 和 Vue 的基础但即使你只是学了语法、还没完整做过前后端分离项目下面这些建模思路、接口设计、联调坑位也一样有参考价值。我会把供应商管理、前台收银、库存流水、后台管理这几个核心模块怎么搭、为什么这么搭、踩过哪些坑都摊开来说。1. 做超市进销存先别急着写代码把业务流画明白1.1 进销存不是库存管理系统它覆盖供应商到前台的三条线很多人一听到“超市进销存”第一反应就是做个商品列表加一个库存数字再做个简单的前台收银页面。实际做下来会发现这种思路做到一半就会乱套。真正的进销存要管的是三条业务线进向供应商下采购单供应商送货仓库验收入库库存增加。销前台收银卖出商品生成销售订单库存同步减少。存库存当前有多少、存在哪个仓库、每个批次是什么时候进的、保质期还剩多久。这三条线不是独立存在的。采购单审核通过后要生成入库单入库单要写库存流水库存流水要更新商品库存前台销售订单提交后要扣减库存扣减记录也要落到库存流水里。也就是说每一次库存数字的变动都必须能找到对应的业务单据否则月底对账是对不上的。供应商在这个模型里是“进”的上游前台收银是“销”的下游。没有供应商管理采购单就是无源之水没有前台收银库存只进不出就成了死库存。所以标题里特别强调“供应商”和“前台”其实是在提醒我们进销存系统的核心是双向流动而不是一个静态的库存表。1.2 为什么选 Django Vue 这套组合技术选型上我一开始也纠结过要不要用纯 Django 模板毕竟 Django Admin 开箱即用能省不少事。但真正做下去发现超市前台的收银场景交互很频繁商品扫码、购物车加减、实时计算金额、会员折扣、找零提示这些如果用 Django 模板 jQuery 来做代码会越写越乱。Vue 的响应式状态管理做这类交互非常顺手尤其是购物车和收银台这种需要即时更新界面的场景。Django 负责的是稳定可靠的后端ORM 管理数据库模型Django Admin 给运营人员用DRF 提供 API 给 Vue 调用事务和锁机制保证库存扣减不超卖。所以选 Django Vue 不是跟风而是把各自擅长的部分用在合适的位置上。用到的核心依赖大概是这样后端Django 4.x、Django REST framework、django-cors-headers、simplejwt、mysqlclient、simpleui前端Vue 3、Vite、Vue Router、Pinia、Element Plus、Axios数据库MySQL 8.0用 InnoDB 引擎支持事务和行锁1.3 功能模块先划边界再拆任务动手写代码前我把系统分成了五个模块每个模块对应一组数据表和页面。这个边界划分很重要不然开发到一半很容易互相牵扯。模块核心业务主要数据表使用角色基础数据商品分类、商品档案、仓库信息Category / Product / Warehouse管理员供应商管理供应商档案、采购入库Supplier / PurchaseOrder / PurchaseItem采购员前台收银商品扫码、购物车、订单结算SaleOrder / SaleOrderItem收银员库存管理库存台账、库存流水、盘点StockProduct / StockFlow仓库管理员系统管理用户、权限、操作日志User / Group系统管理员这样的拆分让我在写 Django app 的时候也有了清晰边界goods管商品supplier管采购sale管前台订单stock管库存每个 app 只负责自己那一块的模型和接口避免一个 models.py 里堆几百行代码。2. Django 后端的数据建模供应商、商品、库存、订单怎么串起来2.1 商品分类与商品档案商品是进销存系统的基础所有采购、销售、库存操作最终都要落到具体的商品上。超市商品有个特点条码是天然的业务主键扫码枪扫出来就是条码字符串。所以Product模型里条码字段不仅唯一而且建议加索引因为前台收银的每一次操作都要按条码查商品。from django.db import models class Category(models.Model): name models.CharField(max_length50, uniqueTrue, verbose_name分类名称) parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE, verbose_name上级分类) sort_order models.IntegerField(default0, verbose_name排序) class Meta: verbose_name 商品分类 verbose_name_plural verbose_name def __str__(self): return self.name class Product(models.Model): barcode models.CharField(max_length64, uniqueTrue, db_indexTrue, verbose_name条码) name models.CharField(max_length200, db_indexTrue, verbose_name商品名称) category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name分类) unit models.CharField(max_length10, default瓶, verbose_name单位) purchase_price models.DecimalField(max_digits10, decimal_places2, verbose_name进价) sale_price models.DecimalField(max_digits10, decimal_places2, verbose_name售价) status models.BooleanField(defaultTrue, verbose_name上架状态) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) class Meta: verbose_name 商品档案 verbose_name_plural verbose_name def __str__(self): return f{self.name}({self.barcode})这里有个容易纠结的问题进价和售价直接存在商品表里那如果供应商涨价了怎么办实际业务中商品的“当前进价”和“采购时的进价”确实可能不一样。所以规范的建模思路是商品表里存“当前进价”而采购单明细里存“本次采购价”。下单时以采购单明细里的价格为准库存成本也可以按批次来算。这样报表里的毛利才是真实可靠的。2.2 供应商档案与采购单供应商表本身不复杂但要注意不能把“供应商联系人”和“供应商”做成一张表就完事。一个超市可能有几百个供应商一个供应商有多个联系人每次采购对接的人可能还不同。第一版我做成了单表后面加联系人和移动电话时才发现很别扭只能再拆表。所以建议从一开始就把 Supplier 和 SupplierContact 分开哪怕第一版联系人字段不多。class Supplier(models.Model): name models.CharField(max_length200, uniqueTrue, verbose_name供应商名称) contact_person models.CharField(max_length50, blankTrue, verbose_name默认联系人) phone models.CharField(max_length20, blankTrue, verbose_name联系电话) address models.CharField(max_length300, blankTrue, verbose_name地址) status models.BooleanField(defaultTrue, verbose_name合作状态) remark models.TextField(blankTrue, verbose_name备注) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) class Meta: verbose_name 供应商 verbose_name_plural verbose_name def __str__(self): return self.name class PurchaseOrder(models.Model): STATUS_CHOICES [ (pending, 待审核), (confirmed, 已确认), (received, 已入库), (cancelled, 已取消), ] order_no models.CharField(max_length32, uniqueTrue, verbose_name采购单号) supplier models.ForeignKey(Supplier, on_deletemodels.PROTECT, verbose_name供应商) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending, verbose_name状态) total_amount models.DecimalField(max_digits12, decimal_places2, default0, verbose_name采购总金额) created_by models.ForeignKey(auth.User, on_deletemodels.PROTECT, verbose_name制单人) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) received_at models.DateTimeField(nullTrue, blankTrue, verbose_name入库时间) class PurchaseOrderItem(models.Model): order models.ForeignKey(PurchaseOrder, related_nameitems, on_deletemodels.CASCADE, verbose_name采购单) product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) quantity models.IntegerField(default0, verbose_name采购数量) price models.DecimalField(max_digits10, decimal_places2, verbose_name采购单价) amount models.DecimalField(max_digits12, decimal_places2, verbose_name明细金额)采购单的状态流转也是容易忽略的地方。我采用了一个简单的状态机待审核 - 已确认 - 已入库或者取消。只有“已入库”这一步才真正影响库存前面的状态都只是业务审批流程。这样设计的好处是即使采购单确认了但货还没到库存数字不会被提前污染。2.3 库存台账与库存流水库存不能只用一个整数字段放在商品表里因为查流水、做盘点、对账都需要知道“这个数字是怎么变过来的”。所以我建了两张表StockProduct存当前库存StockFlow存每一次变动流水。class Warehouse(models.Model): name models.CharField(max_length100, uniqueTrue, verbose_name仓库名称) address models.CharField(max_length300, blankTrue, verbose_name仓库地址) class StockProduct(models.Model): warehouse models.ForeignKey(Warehouse, on_deletemodels.PROTECT, verbose_name仓库) product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) quantity models.IntegerField(default0, verbose_name当前库存) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) class Meta: unique_together (warehouse, product) verbose_name 库存台账 class StockFlow(models.Model): FLOW_TYPE_CHOICES [ (purchase_in, 采购入库), (sale_out, 销售出库), (adjust, 盘点调整), (return_in, 销售退货), ] flow_type models.CharField(max_length20, choicesFLOW_TYPE_CHOICES, verbose_name流水类型) product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) warehouse models.ForeignKey(Warehouse, on_deletemodels.PROTECT, verbose_name仓库) quantity_change models.IntegerField(verbose_name变动数量正数为入负数为出) before_quantity models.IntegerField(default0, verbose_name变动前库存) after_quantity models.IntegerField(default0, verbose_name变动后库存) biz_no models.CharField(max_length32, db_indexTrue, verbose_name关联单据号) created_at models.DateTimeField(auto_now_addTrue, verbose_name发生时间)每一条流水都记录变动前后的库存这个看起来不起眼但对排查问题帮助非常大。比如某个商品月底对账发现少了 3 件直接查 StockFlow 就能看出是采购入库没做还是前台退货没处理还是盘点调整错了。如果没有流水表等于所有历史操作全部不可追溯这是仓储系统的硬伤。2.4 前台销售订单与订单明细前台收银对应的是销售订单和采购单的结构很像但关注点不同销售订单要记录收银员、支付方式、优惠金额、应收金额、实收金额、找零金额。从收银角度这些字段缺一不可不然钱对不上。class SaleOrder(models.Model): PAY_TYPE_CHOICES [ (cash, 现金), (wechat, 微信), (alipay, 支付宝), (card, 银行卡), ] order_no models.CharField(max_length32, uniqueTrue, verbose_name销售单号) cashier models.ForeignKey(auth.User, on_deletemodels.PROTECT, verbose_name收银员) total_amount models.DecimalField(max_digits12, decimal_places2, verbose_name商品总额) discount_amount models.DecimalField(max_digits12, decimal_places2, default0, verbose_name优惠金额) payable_amount models.DecimalField(max_digits12, decimal_places2, verbose_name应收金额) received_amount models.DecimalField(max_digits12, decimal_places2, verbose_name实收金额) change_amount models.DecimalField(max_digits12, decimal_places2, default0, verbose_name找零金额) pay_type models.CharField(max_length20, choicesPAY_TYPE_CHOICES, verbose_name支付方式) created_at models.DateTimeField(auto_now_addTrue, verbose_name下单时间) class SaleOrderItem(models.Model): order models.ForeignKey(SaleOrder, related_nameitems, on_deletemodels.CASCADE, verbose_name销售单) product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) quantity models.IntegerField(verbose_name数量) price models.DecimalField(max_digits10, decimal_places2, verbose_name成交单价) amount models.DecimalField(max_digits12, decimal_places2, verbose_name明细金额)销售单号我习惯用“日期 随机数”生成例如S20250607150000123这样即使没有自增 id 也能从单号直接看出是哪天发生的业务打印小票、后续投诉对账都很方便。2.5 用事务和锁保证库存一致性数据模型建好之后最核心的问题来了采购入库和前台销售出库怎么保证库存不错乱我的做法是所有变更库存的操作必须放在同一个数据库事务里并且用select_for_update()锁住对应的库存台账行。比如前台提交订单的接口要完成三步创建 SaleOrder、扣减 StockProduct、写入 StockFlow。这三步要么全部成功要么全部失败绝不能出现订单建了但库存没扣的情况。from django.db import transaction from django.db.models import F transaction.atomic def create_sale_order(cashier, product_items, received_amount, pay_type): # 先锁定库存行防止并发超卖 stock_rows ( StockProduct.objects.select_for_update() .filter(warehouse_id1, product_id__in[item[product_id] for item in product_items]) ) stock_map {row.product_id: row for row in stock_rows} total_amount 0 order_items [] for item in product_items: product Product.objects.get(pkitem[product_id]) stock stock_map.get(product.id) if stock is None or stock.quantity item[quantity]: raise ValueError(f商品 {product.name} 库存不足) amount product.sale_price * item[quantity] total_amount amount # 扣减库存并记录流水 before stock.quantity stock.quantity - item[quantity] stock.save() StockFlow.objects.create( flow_typesale_out, product_idproduct.id, warehouse_idstock.warehouse_id, quantity_change-item[quantity], before_quantitybefore, after_quantitystock.quantity, biz_noorder_no, ) order_items.append(...) # 创建 SaleOrder 和 SaleOrderItem我是故意不用F()或者update(quantityF(quantity)-1)一步更新的因为在扣减前我需要拿到“变动前库存”来写流水。用select_for_update()锁住这些库存行之后并发请求会排队执行不会出现两个订单同时读到库存 5然后各自扣成 4 的问题。这一点在超市高峰期很关键。3. Django Admin 和 DRF 双通道后台管理效率与前台接口并存3.1 为什么保留 Django Admin而不是全部自己写页面很多前后端分离的项目会彻底抛弃 Django Admin全部用 Vue 重写。但进销存系统里管理员和运营人员需要的功能往往偏表单、偏查询、偏批量处理。比如财务要看采购单、仓库要做盘点、老板要看库存报表这些场景用 Django Admin 配合 simpleui 做界面效率比用 Vue 重新开发快得多。我保留 Django Admin 的核心原则是给运营人员用的复杂后台查询留在 Admin给收银台、供应商维护、移动端这类需要定制交互的页面交给 Vue。这样做至少省了三十个页面的前端开发量。3.2 Django Admin 的美化与快捷操作Django 自带的 Admin 界面有些简陋装一个django-simpleui就能切换成侧边栏风格按钮、表格、筛选器都比默认好看不少。更重要的是在 admin.py 里把常用字段和操作暴露出来让运营人员少点几次鼠标。from django.contrib import admin from .models import PurchaseOrder, PurchaseOrderItem class PurchaseOrderItemInline(admin.TabularInline): model PurchaseOrderItem extra 1 admin.register(PurchaseOrder) class PurchaseOrderAdmin(admin.ModelAdmin): list_display (order_no, supplier, status, total_amount, created_by, created_at) list_filter (status, supplier) search_fields (order_no, supplier__name) inlines [PurchaseOrderItemInline] actions [mark_as_received] admin.action(description标记为已入库) def mark_as_received(self, request, queryset): for order in queryset.filter(statusconfirmed): # 调用共用入库函数生成库存流水 receive_purchase_order(order.id) order.status received order.received_at timezone.now() order.save() self.message_user(request, 已处理入库)Admin 里最常用的是“批量入库”这个 action。采购员确认一批货到了之后勾选采购单点击“标记为已入库”后台就把采购单明细逐条加到库存里并写流水。如果不用 Admin action这个操作就得在 Vue 管理后台重新开发一整套入库页面性价比太低了。3.3 DRF 接口供应商、商品、下单和权限控制Vue 前端需要的接口集中在登录获取 token、商品列表/搜索、供应商 CRUD、前台提交订单、查询历史订单。用 DRF 的ModelViewSet可以很快把这些接口搭起来。from rest_framework import viewsets, permissions from rest_framework.response import Response from .models import Supplier, Product from .serializers import SupplierSerializer, ProductSerializer class SupplierViewSet(viewsets.ModelViewSet): queryset Supplier.objects.filter(statusTrue) serializer_class SupplierSerializer permission_classes [permissions.IsAuthenticated] class ProductViewSet(viewsets.ReadOnlyModelViewSet): queryset Product.objects.filter(statusTrue) serializer_class ProductSerializer permission_classes [permissions.IsAuthenticated] def get_queryset(self): queryset super().get_queryset() keyword self.request.query_params.get(keyword, ) if keyword: queryset queryset.filter(name__icontainskeyword) return queryset权限控制这里要注意不是所有登录用户都能访问所有接口。收银员只需要商品查询和下单不应该有供应商管理的权限。用 DRF 自带的DjangoModelPermissions或者 simplejwt 配合 Django Group可以在接口层做一次过滤。实际开发中我给采购组开放了 Supplier 和 PurchaseOrder 的写权限给收银组只开放 Product 读权限和 SaleOrder 写权限。3.4 导出报表时 StreamingHttpResponse 的正确姿势热搜词里反复出现StreamingHttpResponse的参数content_type和content-disposition这个点确实容易踩坑。老板经常要导出一份当前库存表或者销售流水数据量一大直接在 Django 里拼字符串返回很容易撑爆内存。用 StreamingHttpResponse 可以边生成边返回体验好很多。import csv from django.http import StreamingHttpResponse def export_stock_csv(request): def generate_rows(): yield [商品条码, 商品名称, 供应商, 当前库存] rows ( StockProduct.objects.select_related(product) .select_related(product__category) .values_list(product__barcode, product__name, product__category__name, quantity) ) for row in rows: yield list(row) response StreamingHttpResponse(generate_rows(), content_typetext/csv; charsetutf-8) response[Content-Disposition] attachment; filenamestock_export.csv return responsecontent_type决定了浏览器按什么 MIME 类型处理响应Content-Disposition里的attachment; filename...则让浏览器弹出下载而不是直接在页面里打开。如果中文文件名乱码可以把 filename 用filename*UTF-8%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.csv这种 URL 编码方式处理或者直接用纯英文文件名。4. Vue 前端前台收银和供应商管理两个关键页面如何落地4.1 项目创建、依赖安装与目录规划Vue 3 项目我直接用npm create vuelatest脚手架创建组合式 API Vite。Node 版本尽量用 18否则装依赖时容易遇到engines报错。安装 Element Plus 和 axios、pinia、vue-router 这几件套之后目录结构按模块划分src/ api/ # axios 接口封装 product.js supplier.js sale.js auth.js store/ # Pinia cart.js user.js views/ pos/ # 前台收银 supplier/ # 供应商管理 product/ # 商品查询 order/ # 订单查询 router/index.js App.vue这里有个容易被新手忽略的点不要把 axios 请求一股脑写进组件里。我一开始偷懒在每个页面里直接axios.get后面供应商页面要复用同一个接口时就不得不复制粘贴。统一在src/api里维护后改一个 baseURL 或者加个 token 拦截器全局生效省事很多。4.2 路由设计前台收银和后台管理分开路由设计上我分了两组布局一组是收银台的简洁布局要尽量全屏、方便扫码枪操作另一组是后台管理布局带侧边栏。这样进入系统后收银员在一个大屏收银界面供应商采购人员进的是另一个管理界面。import { createRouter, createWebHistory } from vue-router; const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: /login, component: () import(/views/Login.vue), }, { path: /pos, component: () import(/views/pos/PosLayout.vue), children: [ { path: , component: () import(/views/pos/PosHome.vue) }, { path: orders, component: () import(/views/pos/OrderList.vue) }, ], }, { path: /admin, component: () import(/views/admin/AdminLayout.vue), children: [ { path: suppliers, component: () import(/views/supplier/SupplierList.vue) }, { path: products, component: () import(/views/product/ProductList.vue) }, ], }, ], });使用createWebHistory是 Vue 前端常见的 history 模式URL 看起来干净但后面部署到 nginx 时记得要配置 try_files否则刷新会 404。这个坑后面专门说。4.3 Pinia 管理购物车商品扫描、加减数量、结算前台收银的核心就是购物车状态。Pinia 比 Vuex 更适合这个场景写法更简单每个 store 就是一个defineStore。购物车我维护一个商品列表和一个数量映射商品条码扫进来的时候按条码查询商品并加入购物车。// store/cart.js import { defineStore } from pinia; export const useCartStore defineStore(cart, { state: () ({ items: [], // [{ product, quantity }] }), getters: { totalAmount: (state) state.items.reduce((sum, item) sum item.product.sale_price * item.quantity, 0), }, actions: { addItem(product) { const found this.items.find((item) item.product.id product.id); if (found) { found.quantity 1; } else { this.items.push({ product, quantity: 1 }); } }, removeItem(productId) { this.items this.items.filter((item) item.product.id ! productId); }, clear() { this.items []; }, }, });收银台页面的逻辑是这样的一个搜索框扫描枪扫码后自动触发回车事件前端拿着条码调商品查询接口查到商品后调用cartStore.addItem。右侧显示购物车列表和总金额底部是“结算”按钮点击后弹出支付方式选择框输入实收金额后自动计算找零确认后调下单接口。这里有个使用体验上的细节扫码枪本质是键盘输入设备扫完码会自动发送一个回车键。所以搜索框的keyup.enter事件要处理得够快不能等用户手动点按钮。我一开始没做防抖结果扫码过快的时候接口请求顺序错乱后面给查询接口加了请求序号只有最后一次请求的结果才会写入购物车。4.4 供应商管理页Element Plus 表格、弹窗与表单校验供应商管理页面没有收银那么复杂但它代表了后台管理类页面的通用范式表格展示、新增/编辑弹窗、删除确认、分页加载。template div el-button typeprimary clickopenDialog()新增供应商/el-button el-table :datasuppliers v-loadingloading el-table-column propname label供应商名称 / el-table-column propcontact_person label联系人 / el-table-column propphone label电话 / el-table-column label状态 template #default{ row } el-tag :typerow.status ? success : danger {{ row.status ? 合作中 : 已停用 }} /el-tag /template /el-table-column el-table-column label操作 template #default{ row } el-button sizesmall clickopenDialog(row)编辑/el-button el-button sizesmall typedanger clickremoveSupplier(row.id)删除/el-button /template /el-table-column /el-table el-dialog v-modeldialogVisible :titleform.id ? 编辑供应商 : 新增供应商 el-form refformRef :modelform :rulesrules label-width90px el-form-item label名称 propname el-input v-modelform.name / /el-form-item el-form-item label联系人 propcontact_person el-input v-modelform.contact_person / /el-form-item el-form-item label电话 propphone el-input v-modelform.phone / /el-form-item /el-form template #footer el-button clickdialogVisible false取消/el-button el-button typeprimary clicksubmitForm确定/el-button /template /el-dialog /div /template表单校验规则里供应商名称必填电话用正则校验手机或座机格式。Element Plus 的el-form-item配prop提交时调用formRef.validate()做整体校验避免自己写一堆if (name )的判断。4.5 前端调接口的通用封装axios 封装是老生常谈但还是要强调一下 baseURL 和 token 的统一处理。我在src/api/request.js里创建了一个 axios 实例设置baseURL: /api然后通过请求拦截器从 Pinia 的 user store 里取 token 放进 Authorization 头响应拦截器遇到 401 就自动跳回登录页。import axios from axios; import { useUserStore } from /store/user; import router from /router; const request axios.create({ baseURL: /api, timeout: 10000, }); request.interceptors.request.use((config) { const userStore useUserStore(); if (userStore.token) { config.headers.Authorization Bearer ${userStore.token}; } return config; }); request.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { router.push(/login); } return Promise.reject(error); } ); export default request;这么封装之后页面里的接口调用就变得很干净。比如供应商列表import request from /api/request; export function fetchSuppliers(params) { return request.get(/suppliers/, { params }); }5. 联调与部署阶段最容易踩的五个坑5.1 跨域配置别把 CORS_ALLOW_ALL_ORIGINS 直接设成 True前后端分离联调第一步就是跨域。Django 后端默认不允许别的域名访问需要在settings.py里安装并配置django-cors-headers。INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, https://yourdomain.com, ]很多人图省事直接把CORS_ALLOW_ALL_ORIGINS True打开了开发时没问题一上线就等于把所有接口裸奔任何网站都能用用户浏览器发起请求。除非你做的系统完全不需要登录态否则我建议还是明确配置允许的来源列表。开发环境里如果 Vue 跑在 5173 端口Django 跑在 8000 端口就把http://localhost:5173加进去。5.2 Token 认证与请求拦截JWT 过期处理登录接口我用的是djangorestframework-simplejwt前端登录后拿到access和refresh两个 token。access token 有效期设短一些比如 2 小时refresh token 设长一些比如 7 天。前端刷新页面后从 localStorage 取 token 恢复登录态。但有个问题不能忽略token 过期了怎么办如果只在响应拦截器里遇到 401 就跳登录页用户很可能正在收银的时候突然被踢下线。我后来加了一个刷新逻辑401 时先尝试用 refresh token 换一个新的 access token换成功了就重放原来的请求换失败了才跳登录页。5.3 Vue history 刷新 404 与打包后布局异常Vue 项目打包部署到 nginx 之后如果用的是createWebHistory直接访问https://domain.com/admin/suppliers会得到 nginx 的 404因为 nginx 默认会去找服务器上对应的物理路径。解决办法是在 nginx 配置里加一个try_fileslocation / { root /opt/home/dist; index index.html; try_files $uri $uri/ /index.html; }这样前端路由的所有路径都会回退到 index.html由 Vue Router 自己接管。如果你访问的是https://domain.com但内容是白屏或者样式错乱多半是静态资源路径的问题。可以在vite.config.js里设base: ./让打包后的资源引用变成相对路径如果直接部署在域名根路径也可以保持默认的/。还有一个容易被忽略的点Django 的静态文件和 Vue 打包后的静态文件最好分开目录不要让 nginx 把/static/既指向前端又指向 Django Admin。我在服务器上把 Vue 的 dist 目录放在/opt/supermarket/distDjango 的静态文件放在/opt/supermarket/backend_staticnginx 配置两条location分别处理。5.4 mysqlclient 在 Windows 上安装失败mysqlclient是 Django 连 MySQL 最常用的驱动但 Windows 上装它经常报error: Microsoft Visual C 14.0 is required或者找不到mysql.h。这是因为 mysqlclient 需要编译原生扩展。最快的解决办法是去 PyPI 或者第三方 wheel 仓库下载对应的mysqlclientwheel 文件然后本地安装。如果你实在不想折腾可以直接改用pymysqlimport pymysql pymysql.install_as_MySQLdb()在settings.py同级的__init__.py里加上面两行Django 就可以继续用django.db.backends.mysql的方式访问 MySQL。用 pymysql 的好处是纯 Python 实现不需要编译缺点是大规模读写性能比 mysqlclient 稍微差一点但对于进销存这种并发量级完全够用。5.5 时间与时区不统一导致的问题Django 的settings.py里时区设置直接关系到订单时间、库存流水的记录准确性。我第一版把USE_TZ True开着MySQL 连接配置用了本地时区结果发现 Django 存到数据库的时间比本地时间慢了 8 小时前台小票上的下单时间完全不对。后来统一了方案后端USE_TZ TrueTIME_ZONE Asia/ShanghaiMySQL 的DATABASES配置里加上OPTIONS: {charset: utf8mb4}同时让 MySQL 连接的时区也保持为系统时区。前端展示时间时再把 UTC 时间转成本地时间。建议你在开发环境一开始就把时区定好不要等看到时间差再来改否则历史数据时间全是歪的。6. 实测效果与下一步可以继续加的功能6.1 用造数脚本跑了一遍性能瓶颈浮出水面我写了一个造数脚本造了 200 个商品、8 个供应商、2 个仓库、5000 条库存流水然后在 Vue 前台页面里连续模拟扫码下单。开发模式下接口响应基本在 80ms 到 150ms但商品搜索如果直接用name__icontains数据量到 1 万条时明显变慢原因很简单LIKE %关键词%扫全表索引用不上。解决办法是给常用的搜索字段加索引或者在商品表里增加一个专门用于搜索的字段比如search_text CharField写入时冗余保存“条码 名称 分类名”搜索时用icontains去查这个冗余字段能稍微缓解一点。数据量再大的话就该考虑接入全文检索中间件了但超市进销存一般几万条商品规模MySQL 加索引够用。6.2 高并发下单不超卖的关键在事务边界我在本地用多线程并发测同一个商品下单create_sale_order接口在加了select_for_update()后没有出现负库存。但如果把锁的范围扩大整个事务时间会变长前台收银可能感觉有点卡。所以锁的粒度一定要小只锁当前仓库里当前商品的StockProduct行不要锁整张表或者锁所有商品。如果以后要做秒杀或者大促光靠数据库行锁可能不够可以在 Redis 里先把库存预热下单时先扣 Redis再异步同步数据库。但对于超市日常收银这个数据库事务方案已经足够了。6.3 盘点、退货、多仓库这些扩展点当前系统已经跑通了采购入库、前台销售出库的基本流程但实际超市业务还有几个高频场景没覆盖盘点仓库管理员不会一个个数商品而是拿着手持终端扫码一个 SKU 扫到实际数量后和系统库存对比生成盈亏调整单。销售退货顾客拿小票退货需要原单反查生成退货单库存加回去。多仓库调拨连锁超市会有总仓和门店仓门店缺货从总仓调入涉及调拨单和双仓库存变动。保质期批次食品、饮料有批次和保质期商品表里要增加批次表销售出库时默认先进先出。这些扩展都可以基于现有的StockFlow流水机制往上加因为流水已经记录了变动前和变动后的库存新增一种业务单据只是新增一种flow_type不会破坏原有数据链路。6.4 给同样做进销存系统的朋友三个建议最后说三个我用真金白银换来的经验。采购入库和前台销售两个模块一定要先做而且一定要先定好库存流水的格式再动手。流水表是整个系统最容易返工的地方一开始没设计好后面所有模块都要跟着改。Django Admin 和 Vue 页面不是二选一的关系。给内部员工用的查询、批量处理、报表导出优先用 Admin给收银员、供应商这些需要良好交互的角色用 Vue。不要为了追求“前后端分离”而把 Admin 丢掉那是浪费现成的生产力。开发阶段尽量贴近真实业务模拟数据。我用脚本造了几百个商品和几千条流水后前端分页、搜索、订单列表的性能问题才真正暴露出来。空表状态下一切都是流畅的别等到上线后才发现报表加载要好几秒。