前言
先说清楚一个前提:标题里的「淘宝购物系统」指的不是去对接淘宝,而是照着电商的通用业务模型,写一个自己的模拟商城。真实的淘宝开放平台需要商家资质、应用审核和官方授权接口才能读写交易数据,个人项目里没法也不应该去模仿它。所以本文实现的是一个教学用的、跑在本地的电商核心:商品、购物车、库存、下单、支付、取消。
第二个需要纠正的常见说法是「金额用浮点数存就行,反正只保留两位小数」。这是电商系统里最贵的一个坑。IEEE 754 双精度浮点数无法精确表示 0.1 这类十进制小数,0.1 + 0.2的结果是0.30000000000000004。金额一旦参与累加、打折、拆分,误差会累积,对账时就会凭空多出几分钱。Python 标准库给出的答案是decimal.Decimal,它做的是十进制运算,并且可以精确控制舍入。
第三个误解是「订单就是一张表,插一条记录就完了」。实际上订单是不可变的快照:商品当时叫什么名、卖多少钱,必须定格在订单里。如果订单只存 SKU,那么商家后天改价,昨天的订单金额会跟着变,账目直接错乱。
本文用纯标准库把这套模型跑通,不依赖任何第三方库,代码复制进.py文件即可执行。
一、领域模型:商品、购物车、订单
电商的核心其实就是三个对象之间的流转:
| 对象 | 生命周期 | 是否可变 |
|---|
| 商品 Product | 长期存在,库存不断变化 | 可变(库存、价格) |
| 购物车 Cart | 会话级,随时增删 | 可变 |
| 订单 Order | 下单那一刻定格,之后只改状态 | 内容不可变,状态可变 |
关键设计是「购物车 → 订单」这一步要做一次深拷贝式的固化:把Product展开成OrderItem,把当时的单价和名称复制进去。这样即便原商品后续改价、改名、甚至下架,历史订单仍然自洽。
订单状态用enum.Enum表达,而不是裸字符串。字符串写错一个字母不会报错,只会在比较时静默为假;枚举成员写错名字会立刻AttributeError。
二、金额必须用 Decimal
Decimal有三个必须记住的规矩:
- 从字符串构造。
Decimal("19.90")得到精确的 19.90;Decimal(19.90)会先把 float 的二进制近似值原样搬进来,得到一长串奇怪的小数。 - 精度由上下文决定。默认上下文有 28 位有效数字,普通商品金额完全够用。
- 舍入方式要显式指定。
round()和Decimal.quantize()的默认舍入都是ROUND_HALF_EVEN(银行家舍入),遇到.005时可能舍到偶数那一侧。要按「四舍五入」处理,必须传rounding=ROUND_HALF_UP。
# 适用于 Python 3.8+
from decimal import Decimal, ROUND_HALF_UP
print(Decimal("0.1") + Decimal("0.2")) # 0.3
print(0.1 + 0.2) # 0.30000000000000004
print(round(Decimal("2.5")), round(Decimal("3.5"))) # 2 4(银行家舍入)
print(Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP)) # 3quantize的第一个参数是目标刻度,Decimal("0.01")表示保留到分。
三、库存扣减要「先全部校验,再统一扣减」
下单最容易写错的地方是库存。如果边校验边扣减,第一件商品扣完发现第二件缺货再抛异常,第一件的库存就永远回不来了。正确顺序是两趟扫描:第一趟只检查,只要有任意一件不满足就直接失败;第二趟才真正扣减。这样整个过程要么全成功,要么什么都没动。
真实的多用户环境里,这段逻辑还必须放进数据库事务,并用UPDATE ... WHERE stock >= ?这种带条件的更新语句来避免超卖。本文的对象模型演示的是同一套「先校验后提交」的思路。
四、完整实现
# 适用于 Python 3.8+
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
class OrderStatus(Enum):
CREATED = "created"
PAID = "paid"
CANCELLED = "cancelled"
@dataclass
class Product:
sku: str
name: str
price: Decimal
stock: int = 0
@dataclass
class CartItem:
product: Product
qty: int
@dataclass
class Cart:
items: dict = field(default_factory=dict) # sku -> CartItem
def add(self, product, qty=1):
if qty <= 0:
raise ValueError("数量必须为正整数")
item = self.items.get(product.sku)
if item is None:
self.items[product.sku] = CartItem(product, qty)
else:
item.qty += qty
def remove(self, sku, qty=None):
item = self.items.get(sku)
if item is None:
return
if qty is None or qty >= item.qty:
del self.items[sku]
else:
item.qty -= qty
def total(self):
return sum((i.product.price * i.qty for i in self.items.values()),
Decimal("0.00"))
def clear(self):
self.items.clear()
@dataclass
class OrderItem:
sku: str
name: str
unit_price: Decimal
qty: int
@property
def subtotal(self):
return self.unit_price * self.qty
@dataclass
class Order:
order_id: str
user: str
items: list = field(default_factory=list)
discount: Decimal = Decimal("0.00")
status: OrderStatus = OrderStatus.CREATED
@property
def total(self):
amount = sum((i.subtotal for i in self.items), Decimal("0.00"))
amount -= self.discount
if amount < 0:
amount = Decimal("0.00")
return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
class Shop:
def __init__(self):
self.products = {} # sku -> Product
self.orders = {} # order_id -> Order
self._seq = 0
def add_product(self, sku, name, price, stock):
if sku in self.products:
raise ValueError("SKU 已存在: " + sku)
self.products[sku] = Product(sku, name, Decimal(str(price)), stock)
def _next_order_id(self):
self._seq += 1
return "OD{:08d}".format(self._seq)
def checkout(self, user, cart, discount=Decimal("0.00")):
if not cart.items:
raise ValueError("购物车是空的")
# 第一趟:全部校验,任何一件不够就整体失败
for item in cart.items.values():
if item.qty > item.product.stock:
raise ValueError("库存不足: " + item.product.name)
# 第二趟:统一扣减,并固化订单快照
for item in cart.items.values():
item.product.stock -= item.qty
order = Order(
order_id=self._next_order_id(),
user=user,
items=[OrderItem(i.product.sku, i.product.name,
i.product.price, i.qty)
for i in cart.items.values()],
discount=discount,
)
self.orders[order.order_id] = order
cart.clear()
return order
def pay(self, order_id):
order = self.orders[order_id]
if order.status is not OrderStatus.CREATED:
raise ValueError("当前状态不能支付: " + order.status.value)
order.status = OrderStatus.PAID
return order
def cancel(self, order_id):
order = self.orders[order_id]
if order.status is not OrderStatus.CREATED:
raise ValueError("只有未支付订单可以取消")
for item in order.items: # 回滚库存
self.products[item.sku].stock += item.qty
order.status = OrderStatus.CANCELLED
return order
def main():
shop = Shop()
shop.add_product("A001", "机械键盘", "399.00", 5)
shop.add_product("A002", "无线鼠标", "129.50", 2)
cart = Cart()
cart.add(shop.products["A001"], 1)
cart.add(shop.products["A002"], 2)
print("购物车合计:", cart.total())
order = shop.checkout("alice", cart)
print("订单", order.order_id, "金额", order.total, "状态", order.status.value)
print("键盘剩余库存:", shop.products["A001"].stock)
shop.pay(order.order_id)
print("支付后状态:", order.status.value)
if __name__ == "__main__":
main()运行后会依次打印购物车合计 658.00、订单快照、剩余库存 4,以及支付后的状态paid。
常见坑点
1. 用float存金额
❌price = 19.9; total += price * 3→ 可能出现59.699999999999996✅price = Decimal("19.9"); total += price * 3→ 精确的59.7金额、利率、库存数量这类精确值一律走Decimal。
2. 用 float 构造 Decimal
❌Decimal(19.90)→ 得到19.89999999999999857891452847979962825775146484375✅Decimal("19.90")或Decimal(str(value))浮点字面量已经是近似值了,再转 Decimal 只是把误差搬了个家。
3.@dataclass里用可变默认值
❌@dataclass加items: dict = {}→ 抛ValueError: mutable default✅items: dict = field(default_factory=dict)就算普通类里写def __init__(self, items={}),所有实例也会共享同一个字典——这是经典的可变默认参数陷阱。
4. 订单里存 Product 的引用
❌Order(items=[cart.items[sku]])直接存CartItem(里面还握着Product) ✅ 展开成OrderItem,把名称和单价复制进去 否则商家改价后,昨天的订单金额会跟着变。
5. 边校验边扣库存
❌for item in ...: if stock < qty: raise; stock -= qty✅ 先整趟校验,再整趟扣减 扣到一半失败,前面已扣的库存就丢了。
6. 直接用round()做金额四舍五入
❌round(Decimal("2.5"))→2(ROUND_HALF_EVEN 银行家舍入) ✅Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP)→3round()的舍入规则是实现中立的五分取偶,做财务计算必须显式指定。
7. 用字符串表示订单状态
❌order.status = "payed"拼错了也照样跑 ✅ 用OrderStatus.PAID;成员名写错立刻报错,值写错由枚举约束 枚举之间用is比较是官方推荐写法,比==更明确。
8. 用is比较金额或字符串
❌if total is Decimal("0")永远是False✅ 数值比较用==;is只用于None、True、False和枚举成员is比的是对象身份,两个相等的 Decimal 是两个不同对象。
总结
| 关注点 | 错误做法 | 正确做法 |
|---|
| 金额类型 | float | Decimal,从字符串构造 |
| 舍入方式 | 直接用round() | quantize(..., rounding=ROUND_HALF_UP) |
| 订单内容 | 存商品引用 | 定格为OrderItem快照 |
| 库存扣减 | 边校验边扣 | 先整趟校验,再整趟扣减 |
| 订单状态 | 裸字符串 | enum.Enum |
| 可变默认值 | = {}/= [] | field(default_factory=...) |
购物系统的骨架就是「商品 → 购物车 → 订单」这条流水线,真正决定它是否可用的,是金额用不用Decimal、订单是不是快照、库存有没有原子扣减这几个工程细节。把这三点做到位,再往上面加优惠券、拆单、退款,才不会把地基挖塌。