1. 接外贸服装商城需求时先搞清楚它和国内商城差在哪上个月有位做外贸服装的朋友找过来说他们一直靠展会、微信群和手工表格接单旺季消息一多就漏单、记错尺码想让我帮忙做一个python基于vue的外贸服装售卖商城系统。我第一反应是国内商城项目随便改改就能用但真把需求聊开之后才发现外贸服装的商城跟普通国内B2C商城完全是两套逻辑。项目最终方案是 Django Vue开发工具用 PyCharm后端接口平台选 Django REST Framework前端用 Vue 3 做单页应用。这套选型没有太激进也没有为了追新框架而上复杂度跑完整条链路之后回头看我认为它对这种中小体量的外贸业务项目非常合适。这篇文章就是把这个项目的完整设计过程、数据表拆分思路、前后端联调的关键链路以及我在开发中踩过的坑原原本本讲一遍。1.1 外贸服装商城要解决的业务问题比能下单多得多普通服装商城只要把商品挂上去用户加购物车、下单、付款就结束。但外贸服装商城的用户群体里有批发商、海外零售商也有零散终端客户三类角色期望的报价方式完全不同。批发商要看阶梯价比如单款买 50 件是一个价、买 200 件是另一个价海外零售商更关心美元或欧元报价并且需要先看到每个尺码的库存而不是只看到有货终端客户才关心详情页好不好看、模特图够不够多。尺码体系也是个大麻烦。国内服装通常用 S/M/L但欧美市场常用 6/8/10 这种数字码有些还要兼容美码、欧码的相互转换。同一件衣服到了同一个 SKU 粒度就可能拆出 十几种颜色尺码组合。库存必须精确到红色 L 码剩 3 件这种级别否则后台库存表和实际仓库永远对不上。另一个隐藏需求是物流和地址。海外客户的收货地址字段跟国内完全不一样国家、州省、城市、邮编、街道门牌要分开存有些国家还有单独的税号或清关申报要求。订单状态也不能简单用待付款/已发货/已完成外贸订单经常要经历付款、备货、出运、清关、妥投这一串状态中途还可能拆成多个包裹。把这些需求做了一次业务梳理之后我基本确定这个项目不能只做一个商品展示加购物车的壳核心难点其实在价格体系、库存粒度和订单状态机这三件事上。想清楚这一点后面设计数据表才会顺手。1.2 Django 和 Flask 的选型取舍为什么最终选 Django标题里同时出现了 Django 和 Flask说明很多人在做同类项目时都会纠结。我这次选择 Django理由很实际。招聘和团队熟悉度先不谈单从项目本身看商城系统的后台管理非常重商品要维护图片、尺码表、多币种价格订单要处理各种状态流转。Django 自带 Admin 后台、ORM、迁移机制和认证权限体系这些是商城系统的基本盘直接用能省掉大量造轮子的工作。Flask 的优势是轻量、灵活适合那种接口数量少、逻辑简单、团队想完全掌控代码结构的项目。但在这个外贸服装项目里需要的东西太多了多语言、多币种、图片上传、权限分组、订单状态机、接口限流如果全部用 Flask 自己搭开发周期至少要翻一倍。下面是当时我做选型对比时用的表格可以给后来人参考对比维度DjangoFlask自带后台有完整 Admin没有需要自己写ORM 与迁移内置迁移机制成熟需要搭配扩展DRF 生态衔接自然扩展多需要手动集成项目成熟度适合业务系统适合轻量接口学习曲线偏陡但收益稳定上手快但后期要补的模块多我还在项目里用了 DRF 的 ViewSet 和 Router它可以把商品列表、SKU 详情、购物车合并这类接口的重复代码压到极低。PyCharm 在整个开发过程中也起了不小作用尤其是虚拟环境配置、数据库调试面板和 Django 模板语法提示这些在命令行为主的工作流里不是必需品但开发效率确实高不少。2. 数据表设计外贸服装 SKU 背后的价格与库存逻辑商城系统的数据表设计最忌讳的是直接照搬国内电商项目的表结构。外贸服装业务的特性在于一件商品有多个 SKU每个 SKU 在不同币种下有不同售价订单一旦产生就必须永久保留当时的成交价格和商品信息。这三条需求直接决定表怎么拆。2.1 商品、SKU、库存把款式和规格拆成两张表我在很多刚入门的项目里看到过一种做法把颜色和尺码直接写成商品表的两个字段比如 color红色, sizeL。这种做法在演示项目里没问题一旦出现红色 L 码和红色 M 码库存不同就会立刻卡壳。正确的做法是把商品信息放一张表把 SKU 信息放另一张表SKU 通过外键指向商品。下面是项目里商品和 SKU 两个核心模型的实际写法class Product(models.Model): title models.CharField(max_length255, verbose_name商品标题) gender models.CharField(max_length20, choices( (women, 女装), (men, 男装), (unisex, 中性), ), defaultwomen) description models.TextField(blankTrue, verbose_name商品描述) cover_image models.ImageField(upload_toproducts/%Y/%m/, blankTrue) is_active models.BooleanField(defaultTrue, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table product verbose_name 商品 class ProductSku(models.Model): product models.ForeignKey(Product, on_deletemodels.CASCADE, related_nameskus) sku_code models.CharField(max_length64, uniqueTrue, verbose_nameSKU编码) color models.CharField(max_length50, verbose_name颜色) size models.CharField(max_length50, verbose_name尺码) barcode models.CharField(max_length128, blankTrue, verbose_name条形码) stock models.PositiveIntegerField(default0, verbose_name库存) is_active models.BooleanField(defaultTrue) sort_order models.IntegerField(default0) class Meta: db_table product_sku unique_together ((product, color, size),) verbose_name 商品SKU这里的关键是unique_together约束。同一件商品下颜色和尺码组合必须唯一这是从数据库层面防止管理员重复录入 SKU。库存字段必须放在 SKU 这张表上因为减库存、锁库存的操作都发生在这个粒度。还有一个容易被忽略的字段是sort_order服装详情页里通常要按固定顺序展示尺码如果没有排序字段前端拿到 SKU 列表后顺序就会乱掉。2.2 多币种价格、阶梯价和汇率外贸订单的表结构核心价格体系是这个项目里最需要花心思的地方。我见过一种偷懒方案在 SKU 表里直接加price_usd、price_eur、price_gbp三个字段看起来方便实际上非常僵硬。万一以后要接日元、澳元、加元就要改表结构万一客户要按批发价和零售价分两套报价字段会爆炸式增长。当时我把价格设计成了一张独立的 SKU 价格表核心思路是用一个价格记录表示某个 SKU 在某个价格体系下的某种币种价格。这样新增币种只是多插入几条数据新增价格体系也只需要加一个类型标识。class PriceLevel(models.TextChoices): RETAIL retail, 零售价 WHOLESALE_L1 wholesale_1, 批发一档 WHOLESALE_L2 wholesale_2, 批发二档 class SkuPrice(models.Model): sku models.ForeignKey(ProductSku, on_deletemodels.CASCADE, related_nameprices) currency models.CharField(max_length10, db_indexTrue) price_level models.CharField(max_length20, choicesPriceLevel.choices, defaultPriceLevel.RETAIL) price models.DecimalField(max_digits10, decimal_places2, verbose_name价格) updated_at models.DateTimeField(auto_nowTrue) class Meta: db_table sku_price unique_together ((sku, currency, price_level),)价格字段必须用DecimalField这是为了避开浮点数计算误差。外贸商城一天下来可能有几百张订单每张订单金额在小数点后两位上差一分钱月初对账就会发疯。汇率表我单独建了一张不是每次换算都去外部接口实时查。原因是订单形成之后需要可追溯如果汇率随时在变前面的订单和后面的订单对不上账。表结构很简单基础币种、目标币种、汇率值、生效日期。每天定时或者管理员手动更新一次订单下单时读取当天汇率做快照这样审计记录就是完整可查的。2.3 订单与订单快照下单那一刻的信息必须永久留存订单表设计是这次项目里我最想强调的部分。很多商城系统开发到后期会出现一个诡异现象历史订单里查不到商品名、下单时价格已经改了或者商品后来下架了订单详情页直接变成空白。问题根源在于订单表只存了商品外键没有把下单时的商品信息和价格冗余进去。所以我在订单模块里设计了快照机制。订单主表记录顾客、币种、总金额、运费、订单状态、物流信息订单明细表单独保存一份快照包括商品标题、SKU 规格文案、成交单价、数量、币种。class Order(models.Model): order_no models.CharField(max_length32, uniqueTrue, verbose_name订单号) customer models.ForeignKey(Customer, on_deletemodels.PROTECT, related_nameorders) currency models.CharField(max_length10, verbose_name结算币种) total_amount models.DecimalField(max_digits12, decimal_places2, verbose_name订单总额) shipping_fee models.DecimalField(max_digits12, decimal_places2, default0, verbose_name运费) discount_amount models.DecimalField(max_digits12, decimal_places2, default0, verbose_name优惠金额) receiver_name models.CharField(max_length100, verbose_name收货人) country models.CharField(max_length100) state models.CharField(max_length100, blankTrue) city models.CharField(max_length100) address_line models.CharField(max_length255) postal_code models.CharField(max_length30, blankTrue) phone models.CharField(max_length30, blankTrue) status models.CharField(max_length20, choicesOrderStatus.choices, defaultOrderStatus.PENDING, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue) class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems) sku_code models.CharField(max_length64, verbose_name下单SKU编码) product_title models.CharField(max_length255, verbose_name商品标题备份) sku_spec_text models.CharField(max_length120, verbose_name规格文本备份) unit_price models.DecimalField(max_digits10, decimal_places2, verbose_name成交单价) currency models.CharField(max_length10) quantity models.PositiveIntegerField(default1)想象一下买家下单时选了红色 L 码成交价 19.99 美元后来卖家把这款下架了。如果没做快照订单详情页的商品名会变成空价格也会指向已被删除或修改的价格记录。做了快照之后只要订单还在任何时候都能还原出下单那一刻的完整信息。这个思路跟拍照片的原理一样——先拍下来存档后面底片怎么改都不影响照片内容。3. 用 Vue 搭前端、用 Django 出接口前后端这样配才是完整开发流业务表结构设计好之后我开始搭建工程。项目采用前后端分离结构PyCharm 打开的是一个总目录里面分成backend和frontend两个子目录。这样做的好处是前后端可以独立启动、独立部署开发时又能放在同一个 IDE 工程里方便管理。3.1 PyCharm 工程配置与 Django 项目初始化开始时我先把 Python 虚拟环境创建在项目根目录下然后在 PyCharm 的 Settings 里把 Project Interpreter 指向这个虚拟环境。很多人在这一步会踩坑PyCharm 里明明安装了 Django命令行也装过但运行时却提示模块找不到。这种问题八成就是 IDE 用的解释器还是系统自带的跟虚拟环境不是同一个。解决方法是进入 File Settings Project Python Interpreter把环境切到项目虚拟环境再在 Packages 面板里确认 djangorestframework、django-cors-headers 都已经安装。Django 工程建在backend目录下实际使用的命令很简单cd backend python -m venv venv source venv/bin/activate pip install django djangorestframework django-cors-headers django-admin startproject config . python manage.py startapp products python manage.py startapp orders python manage.py startapp customers这里我把项目配置放在config业务模块拆成 products、orders、customers 三个 app。外贸易商城模块不算多按业务域拆分就够用。后面又加了 payments 模块用来处理支付流水因为支付记录和订单记录要分开追责如果一个 app 里既是订单又是支付数据库字段会越来越乱。3.2 Vue 前端工程搭建与代理配置前端项目我用 Vue 3 加 Vite 创建UI 组件库选的是 Element Plus。创建命令很简单关键是后面的代理配置要写对。开发阶段前后端是两套服务前端跑在 5173 端口Django 跑在 8000 端口直接访问会遇跨域问题。有两个方法解决一种是后端装 django-cors-headers配置允许前端源跨域访问另一种是前端在 Vite 里配代理把/api请求转发到后端地址。我两种都配了。开发阶段用 Vite 代理让前端代码里写相对路径/api/products不需要关心后端地址部署阶段由 Nginx 统一反代前端和后端各走各的地址再用 CORS 配置兜底。Vite 配置参考如下// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })这样配置之后前端里所有以/api开头的请求都会被转发到 Django联调阶段基本不会碰到跨域报错。前端页面结构我分成几大块首页、商品列表、商品详情、购物车、结算页、订单列表和管理后台入口。零售客户走前台页面后台运营人员直接进 Django Admin不是把 Admin 搬进 Vue这样省掉一大块工作量。3.3 购物车到订单提交的前后端协作链路购物车是这次项目里前前后后改了好几次的功能。外贸商城的买家很多不是注册用户第一次来可能就只是看看把几件衣服临时丢进购物车等销售员在线确认运费或者报价之后再决定下单。如果强制要求先注册再使用购物车流失率会非常高。所以我给购物车做了两套存储方案用户状态购物车存储位置说明未登录localStorage屡次刷新、关闭浏览器不丢失下单前提示登录合并已登录后端购物车接口存数据库支持多端同步未登录状态下的购物车数据存在 localStorage数据结构是一个数组每个元素包含sku_id、quantity、selected三个字段。用户登录后前端先把本地购物车发送到后端接口后端按用户 ID 做合并相同 SKU 数量相加然后前端清空 localStorage。这样既保留了匿名体验又实现了用户登录后的购物车同步。提交订单时前端请求的格式非常简单。购物车数据不要自己动手算总价只提交 SKU 列表和数量由后端在事务里重新读取价格和库存并返回订单号。核心原因已经说过前端算的价格不可信必须由后端以数据库价格为准。4. 多币种结算、库存并发与支付回调用到的几个关键实现这条链路是整个系统里最容易出问题的部分我单独拿出来写。不是因为代码有多难而是细节顺序错了就会产生线上事故。尤其是多币种金额运算、并发扣库存、支付回调重复通知这三件事几乎每个商城项目都会遇到。4.1 金额精度与汇率计算的实现细节金额计算必须用Decimal这是 Django 项目里一条铁律。数据库里所有价格字段都用DecimalFieldPython 业务代码里也必须从decimal模块导入Decimal来运算。如果哪一步不小心用了 float后面四舍五入、累计求和、对账会全盘崩掉。汇率换算的逻辑我是这样写的订单创建时先读取当天生效的汇率记录把商品价格从报价币种换算成客户选择的结算币种。例如某件 SKU 的美元标价是 49.90当天美元兑人民币汇率为 7.2那换算成人民币就是 49.90 乘以 7.2结果保留两位小数359.28。整个过程不能依赖前端传过来的换算结果后端必须自己用数据库里的固定汇率再算一次。运费计算是另一个容易出问题的点。外贸服装包裹的运费经常不是按件数简单的倍数关系算的国际快递要同时看实重和体积重哪个大按哪个算。这个算法我没放在订单表里而是抽了一个ShippingEstimator服务类将来要接真实渠道报价时只需要替换服务类内部实现订单接口不用变。4.2 库存扣减的事务边界与并发安全库存扣减必须和订单创建放在同一个数据库事务里而且要用行级锁。我最初在低并发测试时一切正常但用脚本模拟两个买家同时下单买同一件 SKU 的最后一库存时发现出现了超卖。问题根源是先查库存、再判断、再更新三步之间没有锁保护。先用 Django ORM 实现时我用select_for_update()对 SKU 行加锁然后重新读最新库存做判断再执行扣减。核心逻辑如下from django.db import transaction from django.db.models import F transaction.atomic def create_order_with_stock_lock(customer, sku_items): sku_ids [item[sku_id] for item in sku_items] skus ProductSku.objects.select_for_update().filter( id__insku_ids, is_activeTrue ) sku_map {sku.id: sku for sku in skus} total_amount Decimal(0.00) for item in sku_items: sku sku_map.get(item[sku_id]) if not sku or sku.stock item[quantity]: raise ValueError(库存不足) sku.stock F(stock) - item[quantity] sku.save() # 这里继续创建订单、订单明细、生成订单号这里有个细节F(stock) - item[quantity]实际上是让数据库在 SQL 层完成减法避免先读后写带来的竞态。但为了判断库存是否充足select_for_update()锁住行之后我重新读了当前库存做校验。两个动作都要做一个是安全锁一个是原子更新。4.3 支付回调的幂等处理支付回调必须幂等意思是同一个支付结果通知来了三次订单状态也只能被更新一次。现实中支付渠道会对回调做重试有时候间隔几分钟有时候隔一天。如果回调处理逻辑不设防订单就可能被重复更新甚至出现重复发货。我在支付模块里设了一张PaymentTransaction表transaction_id字段设成唯一索引。回调进来时先按 transaction_id 查记录如果存在且已经处理过直接返回成功响应不再继续执行。订单状态更新前还会加一个判断只有当前状态等于待支付时才允许改成已支付。这一层判断虽然简单但能挡住绝大多数重复回调的问题。还有一个小技巧是回调处理全程用transaction.atomic包裹任何一步异常就整体回滚不会留下订单已支付但流水未生成的半截状态。5. 上线前后踩过的几个坑PyCharm、图片托管和后台性能项目从能跑到能上线之间还隔了一堆细碎问题。这些问题单独拿出来都不难但串在一起特别消耗时间。我把这次项目里最值得记录的几个坑放在最后算是一份排雷清单。5.1 PyCharm 运行配置与虚拟环境问题第一个坑是运行配置里的当前目录没设置对。PyCharm 里配置 Django Server 或 Python 运行配置时有一个 Working directory 选项如果不指向backend目录manage.py会提示找不到settings模块。原因很简单Django 启动时会基于当前目录去定位项目配置。所以我每次新建项目都会手动检查这个配置绝不默认。第二个坑是环境变量。我把数据库连接、密钥等敏感信息放在了项目根目录的.env文件里本地开发时通过 PyCharm 的 EnvFile 插件加载线上部署时用系统环境变量注入。这样做的好处是两个环境的配置互相独立代码仓库里也不会出现真实密码。还有一个跟我之前提到的 interpreter 相关换了电脑之后重新拉取代码一定要先重建虚拟环境再让 PyCharm 重新识别。我吃过一次亏代码拉下来后直接点运行结果一堆依赖缺失排查半天才发现 PyCharm 还指向旧电脑的 Python 路径。5.2 服装图片多文件上传与访问路径怎么设计服装商城跟图书或电子商品不同每件衣服恨不得传七八张图主图、模特图、细节图、尺码图图片总量一多访问速度就会受影响。开发阶段我用 Django 的MEDIA_ROOT本地存储在settings.py里配置好路径即可前端拼图片地址时要注意拼接规则。线上部署时我建议把图片存到对象存储服务而不是放在 Django 服务器本地。原因很现实服装图片多且大本地磁盘空间会迅速吃紧而且 Django 进程直接处理图片文件请求会拖慢接口响应。迁移到云存储之后只需要写一个自定义文件存储类或者更简单一点在后台录入商品时直接填写云存储返回的图片 URL完全绕开 Django 处理文件上传的逻辑。图片上传还有个容易被忽略的点必须在models.ImageField的参数里控制上传目录。我用的upload_toproducts/%Y/%m/会自动按年月分目录避免一个目录下文件过多导致文件系统变慢。Vue 前端展示时建议先加载封面图详情页的图片用懒加载移动端访问的体验会好很多。5.3 Admin 后台变慢先查 SQL 再考虑换技术栈项目开发到后期运营同事开始往 Django Admin 里录商品数据一天录几百个 SKU。结果列表页打开越来越慢有时候要转好几秒。我当时第一反应是要不要换个后台框架但冷静下来先查了 SQL 日志发现问题根本不在于框架而是商品列表页把每个 SKU 的价格和库存都即时查了一遍产生了 N1 查询。解决办法是在 Admin 的get_queryset里加prefetch_related把相关数据一次性加载出来。商品列表接口也一样用select_related和prefetch_related可以把数据库查询次数从几十次降到几次。DRF 的分页配置也要做默认全量返回商品列表会让前端首屏渲染非常吃力。class ProductViewSet(viewsets.ReadOnlyModelViewSet): queryset Product.objects.filter(is_activeTrue).prefetch_related( skus, skus__prices ) serializer_class ProductSerializer pagination_class CustomPageNumberPagination这是一个性价比极高的优化改了不到十行代码后台列表响应时间从几千毫秒降到了几百毫秒。所以遇到性能问题不要急着换框架先定位是不是 SQL 查询次数的问题。对大多数管理后台来说N1 查询才是真正的幕后黑手。项目做到最后我最大的体会是外贸服装商城的复杂度不在页面多漂亮而在价格、库存、物流、状态这些业务边界搞清楚没有。只要数据库设计和事务边界想透了Django 和 Vue 这套组合可以把开发周期压得很短。后面如果再让我做类似的外贸订单系统我会先把汇率、库存锁、支付回调幂等这三件事放进需求评审的第一页而不是等上线前再来补课。