
先说结论用Python Vue做一套超市收银系统是非常合适的全栈练手项目也经常被选作毕业设计。前端用Vue搭收银台界面后端既可以用Django也可以用Flask开发环境用PyCharm基本是最省事的组合。文章从业务需求拆解、数据表设计、后端接口编写、前端页面实现、环境配置到联调排坑一路讲下来都是我实际调通之后沉淀下来的经验不同基础的同学都能按步骤复现一套能跑、能结账、能扣库存的收银系统。1. 为什么要做超市收银系统业务核心与技术选型的博弈1.1 收银系统到底在解决什么问题很多初学者一上来就关心用什么框架但我建议先想清楚业务。超市收银听起来简单实际上它是一个典型的、边界清晰的交易型系统收银员扫商品条码商品进购物车点击结账后系统计算总价、扣减库存、生成订单、记录支付流水。这一链条涉及前端交互、后端接口、数据库事务、并发控制几乎覆盖了信息管理系统项目的所有核心知识点。具体拆开看一个小型超市收银系统至少要包含这些功能商品管理商品分类、名称、条形码、进价、售价、库存、上下架状态。收银台操作按条码快速查找、商品加入购物车、修改数量、删除商品、清空购物车。结算逻辑计算总金额、支持折扣或会员价、生成订单号、扣减库存。订单管理查看历史订单、订单明细、按时间段统计销售额。会员模块可选会员积分、折扣价。所以在动手写代码之前先把这些功能列成列表再映射成数据表和接口整个项目就会非常清晰。这是我做这个项目最大的体会代码量其实不大难的是把业务规则翻译成表和接口。1.2 Django 还是 Flask两套方案的真实对比标题里同时出现了Django和Flask这说明很多人在选后端时是纠结的。我两套方案都写过直接给结论对比维度DjangoFlask重量级偏重自带ORM、Admin、认证、迁移轻量只做核心自由度极高上手速度稍慢概念多但体系完整很快一个小脚本就能跑通管理后台Django Admin 直接送适合维护商品和订单需要自己写页面或接第三方ORMDjango ORM 很成熟Flask-SQLAlchemy功能同样够用适合场景需要后台管理、权限控制的中型项目接口简单、追求灵活控制的小项目我的最终选择是Django Django REST Framework。原因很实在超市收银不仅需要收银台还需要店长维护商品资料、查询订单Django Admin 几乎零成本就能实现这个管理端而 Flask 要在同样的时间内把管理端、权限、迁移脚本都自己搭起来工作量会明显增加。那 Flask 是不是不能用当然不是。Flask Flask-SQLAlchemy Flask-CORS Marshmallow 这套组合也能写出同样功能的后端后面我会用一段对照代码说明怎么切换。关键是你得知道自己选它的理由是什么而不是因为某个教程用了什么就无脑抄。1.3 前端为什么选择 Vue纯后端项目很难感受到程序跑起来的成就感加一个前端就完全不同。我选 Vue 有三点理由第一Vue 的模板语法接近原生 HTML对于以 Python 为主、不常写前端的人非常友好。用v-for渲染商品列表、用v-model绑定输入框十分钟就能写出第一版收银台界面。第二Vue 的组件化适合收银台这种多区块交互的页面。商品区、购物车区、结算区、订单弹窗都可以拆成独立组件各自维护状态互不干扰。第三生态完整。Vue Router 处理页面跳转Pinia或 Vuex管理购物车数据Axios 负责和后端通信Vite 提供开发服务器的代理能力。这套组合在中小型管理系统里非常成熟参考资料也比比皆是。所以在架构上这是一个典型的前后端分离项目Vue 负责收银台和操作界面Django 提供 RESTful API两者通过 JSON 交换数据而 PyCharm 负责后端的编写、调试和运行。2. 后端开发数据模型设计、订单事务与库存扣减2.1 数据表设计从业务关系到表结构我建表时遵循一个原则先想清楚实体再想清楚实体之间的关系最后才写模型类。这个超市收银系统的实体并不复杂核心有五个Category商品分类。Product商品属于某个分类。Order订单。OrderItem订单明细属于某个订单指向某个商品。Payment支付流水属于某个订单。我用的数据库是 SQLite项目的常规场景完全够用后续要换 MySQL 也只需要改 Django 的数据库配置。核心模型的 Python 代码大概是这样的# product/models.py from django.db import models class Category(models.Model): name models.CharField(分类名称, max_length50, uniqueTrue) class Meta: verbose_name 商品分类 verbose_name_plural verbose_name def __str__(self): return self.name class Product(models.Model): barcode models.CharField(条形码, max_length30, uniqueTrue) name models.CharField(商品名称, max_length100) category models.ForeignKey(Category, on_deletemodels.PROTECT, related_nameproducts) price models.DecimalField(售价, max_digits10, decimal_places2) cost_price models.DecimalField(进价, max_digits10, decimal_places2) stock models.IntegerField(库存, default0) status models.BooleanField(上架状态, defaultTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 商品 verbose_name_plural verbose_name订单相关模型# order/models.py from django.db import models class Order(models.Model): order_no models.CharField(订单号, max_length32, uniqueTrue) total_amount models.DecimalField(订单总额, max_digits10, decimal_places2, default0) discount_amount models.DecimalField(优惠金额, max_digits10, decimal_places2, default0) pay_amount models.DecimalField(实付金额, max_digits10, decimal_places2, default0) status models.CharField(订单状态, max_length20, defaultPAID) created_at models.DateTimeField(auto_now_addTrue) class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems) product models.ForeignKey(product.Product, on_deletemodels.PROTECT) product_name models.CharField(商品名称快照, max_length100) price models.DecimalField(成交单价, max_digits10, decimal_places2) quantity models.IntegerField(数量, default1)这里有一个容易被忽略的设计细节OrderItem 里我保留了product_name和price字段这属于商品快照。为什么要快照因为商品名称和价格以后可能会改但订单一旦生成就必须保留当时的成交信息。如果不做快照三个月后商品的售价调整了历史订单里的金额就对不上了。这种经验新手很难意识到但在真实业务里非常重要。2.2 订单结算为什么必须用事务订单结算是整个系统最核心的逻辑它要一次性完成四件事根据购物车里的商品 ID 查出商品最新价格。检查每个商品的库存是否足够。扣减库存。生成订单主表和订单明细表同时记录支付信息。这里最大的隐患是并发如果两个收银台同时卖同一瓶可乐库存只剩 1 瓶两个订单都检查库存通过都执行扣减就会出现超卖。解决超卖的办法就是数据库层面的行锁。Django 的select_for_update()可以在事务内锁定被查询的行直到事务结束才释放。配合transaction.atomic()就能保证要么全部成功要么全部失败。伪代码是# order/views.py from django.db import transaction from django.db.models import F from django.http import JsonResponse from rest_framework.decorators import api_view transaction.atomic def create_order(items): total 0 for item in items: product Product.objects.select_for_update().get(iditem[product_id]) if product.stock item[quantity]: raise ValueError(f商品 {product.name} 库存不足) product.stock product.stock - item[quantity] product.save() total product.price * item[quantity] order Order.objects.create( order_nogenerate_order_no(), total_amounttotal, pay_amounttotal, ) for item in items: product Product.objects.get(iditem[product_id]) OrderItem.objects.create( orderorder, productproduct, product_nameproduct.name, priceproduct.price, quantityitem[quantity], ) return order这里我故意把生成订单和扣库存放在同一个事务里两者的成败完全绑定。如果把扣库存放在事务外面就会出现库存扣了但订单没生成、或者订单生成了但库存没扣的脏数据。这个坑我第一版就踩过后面排查数据不一致时花了很长时间。2.3 接口设计与 Django REST Framework 的接入后端只做 API 的情况下我推荐用 Django REST FrameworkDRF它能让接口开发速度快很多。我在这里定义了四个最核心的接口接口方法作用/api/products/GET商品列表支持分类和关键词过滤/api/products/{id}/GET商品详情/api/orders/POST提交订单入参为商品ID和数量列表/api/orders/history/GET历史订单列表DRF 的ModelViewSet可以帮我们省掉大量重复代码商品接口直接这样写# product/views.py from rest_framework import viewsets from .models import Product from .serializers import ProductSerializer class ProductViewSet(viewsets.ModelViewSet): queryset Product.objects.filter(statusTrue) serializer_class ProductSerializer如果切到 Flask同样功能可以这样实现from flask import Flask, jsonify from flask_sqlalchemy import SQLAlchemy app Flask(__name__) db SQLAlchemy(app) app.route(/api/products) def product_list(): products Product.query.filter_by(statusTrue).all() return jsonify([ {id: p.id, name: p.name, price: str(p.price), stock: p.stock} for p in products ])看到区别了吗Flask 需要自己写每个路由和序列化逻辑自由度很高但代码会更多Django 通过 ViewSet 和 Serializer 做了大量自动映射。这就是框架帮你做决定和你自己做决定的取舍谈不上谁更好只看你更适应哪种节奏。3. 前端 Vue 实现收银台交互、购物车状态与结算对接3.1 初始化 Vue 项目并规划页面结构前端我用 Vite 初始化的 Vue 3 项目按收银场景把页面拆成了三个视图收银台主页POS、订单历史页、商品管理页主要用后端 Admin这里只是留一个入口。项目的依赖包括vue-router路由管理。pinia购物车状态管理。axiosHTTP 请求。目录结构大致是这样的src/ views/ PosView.vue # 收银台 OrderHistoryView.vue store/ cart.js # 购物车状态 api/ request.js # axios 实例封装PosView 是整个前台的核心它在页面上分成上下两个区域上面是商品陈列区下面是购物车和结算区。商品区支持按分类筛选、按名称搜索还支持直接输入或扫描条形码快速定位商品这样收银员大部分操作可以不用鼠标直接敲键盘完成。3.2 购物车状态管理与结算流程购物车状态如果放在组件内部商品区、购物车区、结算按钮之间传参会非常痛苦所以我用 Pinia 统一管理// store/cart.js import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ items: [], // 格式: { product_id, name, price, quantity } }), getters: { totalAmount(state) { return state.items.reduce( (sum, item) sum item.price * item.quantity, 0 ) }, totalQuantity(state) { return state.items.reduce((sum, item) sum item.quantity, 0) }, }, actions: { addItem(product) { const existing this.items.find(item item.product_id product.id) if (existing) { existing.quantity 1 } else { this.items.push({ product_id: product.id, name: product.name, price: Number(product.price), quantity: 1, }) } }, removeItem(index) { this.items.splice(index, 1) }, clear() { this.items [] }, }, })结算按钮提交时前端要做两次确认第一次是弹窗展示商品明细和实付金额店员确认第二次是调用后端接口。调用成功后清空购物车并刷新商品库存这样下一个顾客进来时商品列表里显示的库存就是最新的。结算的核心逻辑在 PosView 里大概是这样的async function submitOrder() { const payload cart.items.map(item ({ product_id: item.product_id, quantity: item.quantity, })) try { const res await http.post(/orders/, { items: payload }) cart.clear() loadProducts() alert(订单 ${res.data.order_no} 结算成功实付 ¥${res.data.pay_amount}) } catch (err) { alert(err.response?.data?.message || 结算失败) } }这里有一个非常实用的细节提交的 payload 里只放 product_id 和 quantity绝不能把前端计算的金额传给后端。因为前端金额可以被修改或伪造后端必须自己根据数据库里的最新价格重新计算总价。这也是我做这个项目时学到的一个关键安全原则——后端永远不要信任前端传过来的金额。3.3 Axios 封装与接口对应关系我把 Axios 做了统一封装主要解决三件事统一 baseURL、统一处理响应、统一弹错误提示。// api/request.js import axios from axios const http axios.create({ baseURL: /api, timeout: 5000, }) http.interceptors.response.use( (response) response.data, (error) { const message error.response?.data?.message || 请求失败请重试 alert(message) return Promise.reject(error) } ) export default http这样做的好处是业务代码里不用每个请求都写 try/catch 和错误处理出错时页面会统一有反馈。比如后端返回商品库存不足时前端直接弹对应文案收银员能立刻明白原因。4. PyCharm 环境配置、前后端联调与高频坑位排查4.1 PyCharm 里配置 Python 解释器和 Django 运行环境很多人卡在第一步不是代码写不出来而是环境没配对。我的做法是从官网下载 Python 3.10 或 3.11安装时勾选Add Python to PATH。打开 PyCharm新建一个项目在 Settings - Project - Python Interpreter 里选择虚拟环境venv。在 PyCharm 的 Terminal 里执行pip install django djangorestframework django-cors-headers。安装完成后在 Settings - Project - Python Interpreter 里能看到这几个包说明环境没问题。PyCharm 最实用的功能是把 Run Configuration 配置好在运行配置里选择 Django 项目指定端口 8000然后点绿色三角就能启动后端服务。不用每次都在终端敲python manage.py runserver调试起来效率高很多。如果在 PyCharm 里装包慢可以临时换用国内镜像源比如在 pip 命令后追加-i https://pypi.tuna.tsinghua.edu.cn/simple这个技巧能省很多等待时间。4.2 Vue 开发环境与代理跨域Vue 项目默认跑在 5173 端口Django 跑在 8000 端口如果不做任何配置浏览器会直接拦截跨越端口的请求报出跨域错误。最简单的方案是在 Vite 里配置代理让前端把/api开头的请求转发到后端// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, }, }, }, })这样配置后前端请求的 URL 是/api/products/Vite 会自动转发给http://127.0.0.1:8000/api/products/浏览器层面不存在跨域问题开发和联调体验最顺畅。如果以后把 Vue 项目打包成静态文件部署到 Nginx同样在 Nginx 里配置一次反向代理即可思路完全一致。如果你不想用代理也可以在后端启用 django-cors-headers在 settings.py 里配置允许的跨域来源两种方式二选一。4.3 联调阶段我踩过的坑联调时遇到问题是最正常的我把高频问题整理成了一张排查表新项目照着查能省很多时间现象根因解决方案POST 请求返回 403Django CSRF 防护拦截使用 DRF 的APIView或api_view其默认不强制 CSRF前端能打开页面接口全部 404Vite 代理未配置或后端端口不对检查 vite.config.js 代理和后端 Run Configuration中文数据显示为乱码数据库/终端编码问题确保 settings.py 中LANGUAGE_CODE与USE_TZ配置正确提交订单后库存变成负数未使用select_for_update()行锁在事务内锁定商品行前端传的是productId后端读的是product_id前后端字段命名不一致统一字段命名规范为 snake_case结账成功后购物车金额没清零Pinia 状态未持久化刷新失效调用cart.clear()后还需确认当前组件已重置这里重点说一下 CSRF 的坑。Django 默认对所有 POST 请求做 CSRF 校验如果沿用 Django 传统的request.POST方式接收参数前端必须带 CSRF token。但使用 Django REST Framework 的api_view装饰器后它会走 DRF 的认证逻辑默认不检查 CSRF token所以联调时不会遇到这个 403 问题。很多教程里没写清楚这一点导致新手用旧版写法时莫名其妙卡在接口调不通。还有一个小坑是Decimal类型序列化的问题。Django 的DecimalField在 Python 里是Decimal对象直接json.dumps()会报类型错误DRF 的 Serializer 会自动转成字符串。前端拿到后建议Number(price)转成数字再计算避免购物车合计时因为字符串拼接出现5.5 5.5 5.55.5这种诡异结果。5. 项目实测与后续扩展从能跑到好用之间还差什么5.1 实测表现与并发验证我整个项目在本地跑通后用了一台普通 Windows 电脑做模拟测试后端 Django 开发服务器、前端 Vite 开发服务器、SQLite 数据库模拟同时有三个收银终端往同一个后端提交订单。实测下来商品列表接口响应在 50ms 以内订单提交接口在数据量不大时响应也在 200ms 以内对小型超市完全够用。要注意的是Django 自带的开发服务器是单线程的不适合做高并发生产环境但在学习阶段模拟多终端是完全没问题的。以后要真正部署上线可以用gunicorn启动 Django配合 Nginx 做静态文件服务和反向代理这套部署方案 Flask 同样适用。并发方面我做了一个简单压测用脚本并发提交两个订单都购买库存只有 1 的商品最终只有一个订单成功另一个返回库存不足。这说明select_for_update()的行锁是生效的。这个验证非常重要因为它直接决定系统在真实场景下会不会出超卖问题。5.2 下一阶段的扩展方向收银系统做到能结账只是第一步要变成真正可用的商用系统还有很多方向可以扩展热敏小票打印前端通过 WebSocket 或 HTTP 调用本地打印服务按 ESC/POS 协议生成小票内容这是收银台最常见的刚需。会员模块会员表、积分表、储值表结算时读取会员信息和折扣策略。销售统计看板按日、周、月统计销售额和商品销量可以给 Django Admin 增加统计页面也可以用 Vue 加 ECharts 画趋势图。库存预警当商品库存低于阈值时在后端任务中触发通知或者在管理端给出醒目标识。多终端适配收银台在平板和桌面浏览器上做响应式适配收银员用触屏也能顺畅操作。这些方向里我认为小票打印是最有价值的一步。因为收银系统如果在真实超市使用不能打印小票几乎等于不可用。实现时可以在前端把订单数据传给本地一个小工具小工具再调用操作系统打印接口完成打印逻辑不难但涉及很多细节比如小票宽度、字体、联动的打印条数这些坑只有实际对接过打印机才清楚。如果你做完基础版本还想继续深入从这一块切入最合适。5.3 给即将动手做类似项目的朋友的建议这样一个前后端分离项目做完我从业务梳理、技术选型到联调排坑走了一遍最大的体会是不要一开始就追求完美方案先用最简单的技术组合跑通再逐步补强。我做第一版时只用了 Django SQLite Vue Vite没有复杂缓存、没有消息队列但整套业务流程完全闭环这让后续优化有了明确的起点。另一个建议是每次启动项目前先把数据库迁移命令跑一遍python manage.py makemigrations和python manage.py migrate。很多新手改了模型却不执行迁移接口报no such column错误查了半天才发现是数据库表结构没更新。类似的低级问题往往比业务逻辑更消耗时间养成小步提交、及时验证的习惯会舒服很多。最后分享一个很实用的小技巧在 PyCharm 的 Run Configuration 里同时配置 Django 服务和前端启动命令或者直接用终端分两个面板运行前后端配合 PyCharm 的断点调试功能遇到接口异常时直接在 Python 代码里打断点前端看到报错后后端立刻能定位到具体行这种前后端联调的流畅体验是纯靠打印日志远远比不上的。