做美妆类免税商品选购系统,听起来就是个标准的前后端分离电商项目,但真正落地的时候,细节一点都不少。技术栈选的是Python系后端django/flask,配合vue做前端,这套组合在中小型业务系统里非常常见,尤其是需要快速上线、后期好维护的项目,几乎可以无脑用。这个系统要解决的痛点很明确:免税商品的SKU多、批次有效期敏感、价格随汇率波动、库存和海关额度需要校验,普通电商框架要么太重,要么太死,用django做主体业务、flask做轻量辅助服务、vue做交互层,反而能各取所长。
这篇文章我会把整个系统从选型、建模、接口实现到部署踩坑完整捋一遍,适合正在做类似“python后端+vue前端”项目的同学参考,尤其是刚接触django/flask、想搞懂前后端怎么配合的新手,以及准备把项目部署到windows服务器上的人。我会直接讲项目里真实会遇到的细节,包括模型字段怎么定、ORM删除对象要注意什么、vue路由参数怎么传、static文件为什么加载不出来、waitress+nginx部署的坑,尽量把能提前避开的坑都写出来。
1. 项目定位与技术栈选型:为什么是django/flask+vue
1.1 美妆免税选购的业务特殊性
做美妆免税商品选购系统,不能直接套普通电商的思路。普通电商关注的是SKU、价格、库存,但免税美妆多了一层“监管+批次”的属性。同一款雅诗兰黛小棕瓶,不同批次的有效期不同,采购价格也不同;免税渠道还涉及每人每年的免税额度、限购件数,这些都要在系统里控制。所以数据模型不能只建简单的商品表、订单表,还得有批次库存、额度校验、汇率换算这些模块。
我实际建模时,把商品和SKU分开,商品表存基础信息,SKU表存规格、价格、库存批次。每个SKU关联一个或多个批次,批次里有有效期和剩余数量,下单时优先扣“临期批次”而不是随意扣。这个逻辑虽然初期实现多花了点功夫,但后面盘库存、处理临期商品时省了巨大的麻烦。如果一开始图省事只做一张commodity表,后面要加批次、加汇率,就要频繁改表结构,非常痛苦。
1.2 django与flask如何分工
标题里同时出现django和flask,很多人第一反应是“二选一”,但实际项目里两者完全可以共存。django自带ORM、Admin后台、迁移工具,非常适合做主业务系统——商品管理、订单、用户、额度校验,这些需要规范建模和事务处理的功能,交给django很稳。flask更轻,适合做独立的辅助服务,比如汇率定时拉取服务、库存预警推送、或者暴露给移动端的轻量接口。
我这边实际的分工是:django负责所有核心业务接口,包括商品列表、购物车、下单、支付回调、订单查询;flask单独跑了一个“汇率与价格同步服务”,每天定时去拉取最新的免税价格和汇率,计算完写入Redis,再通过一个简单的接口输出给django使用。这样做的好处是,汇率同步逻辑挂了不影响主服务下单,两个进程能独立重启、独立部署。如果硬把定时任务写进django的app里,一旦同步逻辑出问题,主进程就可能被拖垮。
1.3 vue在项目里的角色
前端用vue,带来最大的改变是页面不再整页刷新。商品的筛选、排序、加入购物车、切换规格,这些操作如果都走传统的form提交,用户体验会卡顿,尤其是在商品图片多、SKU选择器复杂的美妆商城场景下。vue组件化之后,商品卡片、规格选择器、购物车列表都被拆成独立组件,改一个组件不会影响到其他页面。
组件化也有个容易被忽视的好处:多端复用。同一套vue代码,稍作调整就能打包成Web端和管理后台端,甚至配合uni-app可以复用大部分逻辑到小程序。我当时没直接上nuxt做SSR,核心原因是这个系统更偏内部业务工具+商城,对SEO要求不高,纯SPA足够。如果你后续要做面向公网的内容推广,那再考虑SSR也不迟,初期不必过度设计。
2. 后端核心模型与MTV模式落地
2.1 三张核心数据表的设计
一个电商选购系统,最核心的数据表就是商品、库存/批次、订单,外加用户和额度记录。我的设计是这样的:
- Commodity(商品表):id、name、brand、category、image、description、status、created_at
- Sku(规格库存表):id、commodity_id、spec、price、currency、quantity、batch_no、expiry_date
- Order(订单表):id、user_id、order_no、total_amount、status、created_at
- OrderItem(订单明细表):id、order_id、sku_id、quantity、price
- UserQuota(免税额度表):user_id、total_quota、used_quota、year
这里有个容易踩的坑:金额字段不要用Float,要用DecimalField。美妆免税价格可能带小数点,Float在Python里会有精度问题,比如0.1+0.2不等于0.3,金额计算一旦出现这种偏差,对账的时候会非常头疼。django的models.DecimalField(max_digits=10, decimal_places=2)能保证计算精度,虽然ORM层面的性能稍微有点损耗,但做金额计算完全值得。
2.2 django创建app与模型迁移
创建django项目的流程不复杂,但新手容易在“该建几个app”这个问题上纠结。我的建议是按业务域拆,不要一个app写到底。我当时拆成了goods(商品)、trade(交易)、users(用户)、quota(额度)四个app,每个app只负责自己的模型和视图,代码清晰,后期维护成本低。
执行python manage.py startapp goods之后,把模型写进models.py,然后两步走:
python manage.py makemigrations python manage.py migratemakemigrations是生成迁移文件,migrate才是真正把表建到数据库里。很多新手只执行migrate不执行makemigrations,系统会提示“No changes detected”,然后就一脸懵。记住这个流程:改模型 → makemigrations → migrate,再改再重复。
2.3 执行查询与删除对象:ORM操作细节
django的ORM让开发者不用写原生SQL,但很多人对“查询对象”和“删除对象”的理解停留在表面,实际用的时候容易出问题。
先说查询。Model.objects.get(id=1)返回的是单个对象,如果查询结果不存在或存在多个,会直接抛异常,所以get适合用来查唯一记录,比如按order_no查订单。而Model.objects.filter(status=1)返回的是一个QuerySet,即使结果只有一条,返回的也是集合,要用.first()才能拿到对象。我之前在项目里见过一个bug,用filter取结果后直接访问字段,报错'QuerySet' object has no attribute 'xxx',就是这个原因。
再说删除。删除对象有两种方式:
# 方式一:删除单个对象 obj = Commodity.objects.get(id=1) obj.delete() # 方式二:批量删除 Commodity.objects.filter(status=0).delete()方式一适合删除前要做一些校验或日志记录的场景,删除后会返回一个元组(1, {'goods.Commodity': 1}),第一个数字是删除的记录总数。方式二适合清理脏数据,但要注意:批量删除不会触发模型里重写的delete()方法,如果被删除对象有外键关联且没有设置on_delete=models.CASCADE,会报完整性错误。所以删除前一定要想清楚外键关系,尤其是订单删除了,订单明细应该怎么办。
2.4 用flask做轻量辅助接口
flask在这个项目里承担的是辅助服务的角色。我单独建了一个price_sync_service目录,里面是一个完整的flask应用,结构很简单:
from flask import Flask, jsonify import redis app = Flask(__name__) @app.route('/api/price/latest') def latest_prices(): r = redis.Redis(host='localhost', port=6379, db=0) data = r.get('latest_prices') return jsonify({'code': 0, 'data': data})有同学问“flask如何绑定到网页元素”,其实flask后端不直接绑定网页元素,它只负责提供接口,网页元素是vue去绑定的。比如vue里的<div @click="addToCart(skuId)">,点击后调用axios请求flask或django的API,拿到返回数据后再更新页面。这个交互链路是:DOM事件 -> vue方法 -> axios请求 -> 后端接口 -> 数据库 -> 响应 -> vue更新DOM,把“绑定元素”理解成vue的@click、:class这些指令就好。
flask里还有一个实用技巧:查看从客户端获取的变量数据类型。很多新手从request.args.get('id')拿到值后,直接和int比较,结果怎么都不对,就是因为忘了它返回的是字符串。排查时可以这样:
from flask import request val = request.args.get('id') print(type(val)) # <class 'str'>拿到字符串之后,记得int(val)转换,或者用参数转换器@app.route('/api/goods/<int:goods_id>'),让flask自动帮你转类型。
3. vue前端实现与联调要点
3.1 环境准备:vue安装及环境配置
vue的环境配置说简单也简单,说坑也坑。官方推荐用npm安装vue,流程是:
npm install -g @vue/cli vue create frontend cd frontend npm install npm run serve但很多人卡在第一步:npm安装速度极慢或直接失败。原因多半是默认源在国外,换成国内镜像就好:
npm config set registry https://registry.npmmirror.com还有个问题是node版本。vue3要求node版本在16以上,如果本机node版本太老,vue create会失败。建议直接用nvm管理node版本,需要哪个切哪个,避免版本冲突。
创建项目之后,进入src目录,核心文件是main.js、App.vue和router/index.js。main.js负责挂载Vue实例,router负责路由,App.vue是根组件。第一次跑起来看到默认的HelloWorld页面,说明环境没问题。
3.2 商品列表页与路由参数传递
商品列表页是商城系统的门面,我用了vue-router做页面跳转,商品详情页的URL设计成/goods/detail?skuId=1001这种查询参数形式。从列表页跳详情页有两种写法,我比较推荐用编程式导航:
this.$router.push({ path: '/goods/detail', query: { skuId: this.currentSku.id } })在详情页接收参数:
const skuId = this.$route.query.skuId这里有个很典型的坑:用query传参,刷新页面后参数还在,因为参数在URL里;但有人图省事用params传参且不写路径,刷新后参数就丢了。所以传递商品ID这种需要刷新后依然有效的参数,务必用query。另外,从列表到详情,性能优化上可以用<router-link>配合v-for,但商品数量大时,v-for里大量DOM节点会导致渲染卡顿,这时候要做分页或虚拟滚动,不要一次渲染一千个商品卡片。
3.3 axios请求封装与接口对接
vue项目里请求后端接口,我习惯在src/utils/request.js里做一个axios实例封装。为什么要封装?因为统一处理baseURL、超时时间、token注入、错误提示,比在每个组件里重复写一遍要干净得多。
import axios from 'axios' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || 'http://127.0.0.1:8000/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Token ' + token } return config }) service.interceptors.response.use( response => response.data, error => { console.error('接口请求失败:', error) return Promise.reject(error) } ) export default service接口对接时最常见的问题是跨域。django后端默认不允许别的端口访问,vue跑在8080,django跑在8000,两者直接通信会报CORS错误。解决方式有两个:一是后端装django-cors-headers,配置允许的域名;二是前端在vue.config.js里配proxy代理,开发环境把请求转发到8000端口。我用的是后者,因为生产环境会用nginx统一转发,开发环境的proxy代理能模拟这个场景。
3.4 产品视频播放的m3u8处理
美妆商品详情页经常要放产品使用视频,而且为了方便加载,视频源经常是m3u8格式的流媒体文件。vue里直接放<video>标签播放m3u8是不行的,需要借助hls.js这个库。
安装:
npm install hls.js在组件里播放:
import Hls from 'hls.js' export default { mounted() { const video = this.$refs.video if (Hls.isSupported()) { const hls = new Hls() hls.loadSource('https://cdn.example.com/goods/123.m3u8') hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()) } } }这里有两个要点:一是m3u8的URL需要后端动态生成或从接口获取,不要硬编码到前端代码里;二是m3u8通常涉及跨域,视频所在的服务端需要配置允许跨域访问,否则hls.js加载视频会失败。如果视频文件是自己的,建议直接用mp4格式配video标签就行,别为了“看起来专业”硬上m3u8,流媒体协议适合长视频和直播场景,短视频用mp4反而简单。
3.5 vue样式与devtools调试
vue的样式默认写在.vue文件的<style scoped>中,加了scoped后样式只会作用于当前组件,不会污染全局。但有一个坑:如果用了子组件,父组件里的scoped样式无法穿透到子组件内部,需要用到deep选择器,也就是:deep(.child-class)。我当时给商品详情页的规格选择器写样式,折腾了半天,最后发现是scoped隔离的问题,换成:deep()就生效了。
vue-devtools是调试神器,可以在浏览器里直观看到组件的data、props、vuex状态。下载时要注意:vue2和vue3的devtools已经不兼容了,从chrome应用商店下载时要选对版本。装好后如果面板不显示,检查是否开启了“允许访问文件网址”,并且开发模式要用npm run serve而不是直接打开本地html文件。
4. 核心功能全流程实现:从商品浏览到下单结算
4.1 商品列表与筛选API
商品列表接口要支持分类筛选、关键词搜索、排序。django里配合django-filter可以快速实现,但如果不引入额外库,手写也不难。核心思路是接收request参数,动态拼filter条件:
def goods_list(request): queryset = Commodity.objects.filter(status=1) category = request.GET.get('category') keyword = request.GET.get('keyword') sort = request.GET.get('sort', 'default') if category: queryset = queryset.filter(category=category) if keyword: queryset = queryset.filter(name__icontains=keyword) if sort == 'price_asc': queryset = queryset.order_by('price') elif sort == 'price_desc': queryset = queryset.order_by('-price')要注意模糊查询用name__icontains而不是name__contains,前者忽略大小写,用户体验更好。分页用django自带的Paginator,每页返回10条或20条,同时把总条数、当前页、是否有下一页一起返回,前端才好做分页组件。
4.2 购物车与库存扣减
购物车我用的方案是存数据库,而不是存在localStorage。虽然localStorage实现简单,但换设备后数据不同步,而且无法在服务端做库存预校验。购物车表至少要有:user_id、sku_id、quantity、selected这几个字段。加入购物车时,后端要先查询SKU的库存,如果库存小于请求数量,直接返回“库存不足”。
真正容易出问题的在下单时的库存扣减。高并发场景下,如果先查库存再扣减,两个用户同时下单就可能超卖。正确做法是先扣库存再创建订单,并且扣减时用原子操作:
from django.db.models import F updated = Sku.objects.filter(id=sku_id, quantity__gte=need_qty).update(quantity=F('quantity') - need_qty) if updated == 0: return JsonResponse({'code': 1, 'msg': '库存不足'})F('quantity') - need_qty是在数据库层面执行减法,避免了先select再update的时间差。filter(id=sku_id, quantity__gte=need_qty)这个条件保证只有库存足够时才更新成功,返回的行数updated为0就说明库存不够。这个写法对比“先查后改”的写法,直接把超卖风险降为零。
4.3 订单创建与支付回调
订单创建要考虑事务。一个订单包含主表和明细表,主表写入了,明细表写入失败,就会产生脏数据。django里可以用transaction.atomic()包裹:
from django.db import transaction with transaction.atomic(): order = Order.objects.create(...) for item in cart_items: OrderItem.objects.create(order=order, ...) cart_items.delete()订单状态我设计成几个值:pending_payment(待支付)、paid(已支付)、shipped(已发货)、completed(已完成)、cancelled(已取消)。支付回调是另一个难点,第三方支付接口回调时会通知你支付结果,回调里要做三件事:验签、更新订单状态、处理幂等。幂等是指同一笔订单的支付回调可能会收到多次,处理方式是在更新状态前先检查订单当前状态,如果已经是paid,直接忽略本次回调。
4.4 前后端联调怎么测
前后端联调阶段,我的经验是先定接口文档,再用mock数据跑通前端页面,最后接真实后端接口。没有接口文档,联调就是灾难,谁改什么都不说,前端等后端,后端等前端。
推荐用apifox或swagger这类工具管理接口。django用drf-spectacular可以自动生成swagger文档,flask可以用flasgger。接口文档里要写清楚:请求地址、请求方法、请求参数、返回示例、错误码。联调时前端照着文档对接,后端照着文档自测,能省至少一半的沟通时间。
5. 部署实战与问题排查
5.1 windows下waitress+nginx部署
项目最后要部署到windows服务器上,这个场景我踩过的坑不少。django自带的开发服务器runserver只能用于开发,生产环境绝对不能直接用,性能太差且不安全。在windows上,我推荐用waitress,它是纯Python的WSGI服务器,安装简单,性能也够用。
pip install waitress waitress-serve --listen=127.0.0.1:8000 myproject.wsgi:application注意这里监听的是127.0.0.1:8000,也就是说waitress只在本机提供Web服务,外部不能直接访问。外部请求统一由nginx转发,nginx监听80端口,匹配到/api/前缀就转发给waitress,匹配到静态文件直接返回文件。
nginx的配置差不多是这样:
server { listen 80; server_name your_domain.com; location /static/ { alias C:/path/to/project/static/; } location /media/ { alias C:/path/to/project/media/; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root C:/path/to/vue_dist; index index.html; try_files $uri $uri/ /index.html; } }这里有个关键点:vue项目要执行npm run build,把打包出来的dist目录交给nginx托管。vue-router如果用的是history模式,try_files $uri $uri/ /index.html这行必须写,否则刷新路由时nginx会返回404。
5.2 静态文件与附件路径的坑
在django里加载图片,很多人写过这样的代码:
<img src="{% static 'images/logo.png' %}" />然后图片就是不显示,F12看到404。这种情况多半是静态文件目录配错了。django项目的settings里要确保:
STATIC_URL = '/static/' STATICFILES_DIRS = [BASE_DIR / 'static']STATIC_URL是URL访问前缀,STATICFILES_DIRS是静态文件的实际存放路径。两个都配置正确,{% static %}标签才能正常工作。开发环境下django会自动找STATICFILES_DIRS下的文件,但生产环境下要执行python manage.py collectstatic把静态文件统一收集到一个目录,再由nginx托管。
附件路径的问题是另一类:图片上传成功,数据库里有路径,但前端访问不到。原因往往是settings里MEDIA_ROOT和MEDIA_URL配错,或者django没把media目录交给nginx。而且windows环境下路径分隔符是反斜杠,URL里要用正斜杠,处理时要统一替换。flask项目部署后附件路径错误,和django类似,基本都是因为app.config里的UPLOAD_FOLDER写死成了绝对路径,换服务器后忘记改。我建议所有上传路径都用相对路径拼接,部署时通过环境变量指定根目录,不要硬编码。
5.3 CORS跨域与csrf
部署阶段最容易遇到的两个问题就是跨域和csrf。开发环境配了vue的proxy代理,跨域问题不会暴露,但一部署,前端在http://your_domain.com,接口也在同一个域名下的/api/路径,理论上不走跨域。然而如果你非要把接口域名分离成api.your_domain.com,跨域就来了,nginx里要配:
add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods POST, GET, OPTIONS; add_header Access-Control-Allow-Headers Authorization, Content-Type;另外django默认开启了csrf防护,POST请求不带csrf token会被拒绝,返回403。前后端分离项目中,最简单的方式是在django的settings里对API视图使用@csrf_exempt装饰器,或者设置MIDDLEWARE里注释掉CsrfViewMiddleware,再用token做认证。更严谨的做法是前端从cookie里读csrf token,在axios请求头里带X-CSRFToken。后一种更安全,但配置麻烦一些。自己项目里可以根据安全等级来选,如果是内部工具系统,@csrf_exempt也够用。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| django模型改了但表没变化 | 忘记执行makemigrations和migrate | 按顺序执行两个命令 |
| vue页面空白,控制台报错 | 路由history模式刷新404或JS报错 | nginx配置try_files,检查console错误 |
| 图片403或404 | 静态文件路径配置错误或media未托管 | 检查STATICFILES_DIRS,nginx配置location /media/ |
| 跨域报错 | 前后端不同端口/域名 | 开发环境用proxy代理,生产用nginx统一域名或配CORS头 |
| CSRF验证失败 | django默认csrf防护拦截POST | 对接口使用@csrf_exempt或配置X-CSRFToken |
| 下单提示库存不足但库存明明有 | 数据库事务隔离级别或并发问题 | 使用filter+update原子操作扣库存 |
| 上传文件后访问404 | 附件存储路径错误 | 检查MEDIA_ROOT,统一路径分隔符 |
| waitress启动后外网访问不了 | waitress监听127.0.0.1 | 改用0.0.0.0或由nginx转发 |
这些坑都是实际项目里踩过的。有些问题可能你一周后才遇到,提前收藏这篇,到时候直接照着排查,能省下不少熬夜时间。另外说个我个人的习惯:每次部署前都会把前端build产物、后端代码、nginx配置、数据库备份四样东西分别打包标记版本号,出问题能快速回滚。这个习惯救过我很多次,尤其是在windows服务器上,改错了配置有时候连备份都没有,只能重装环境,那才是真正的灾难。
最后再分享一个小技巧:django的Admin后台不要浪费,商品录入、订单查询这种管理操作,用Admin后台几分钟就能搭出来。我当时给运营同学配了只读权限,他们自己查订单、改商品状态,完全不用我写管理页面。这一点看起来不起眼,但对一个系统的实际使用体验来说,帮助真的非常大。