一家粮油店
去家附近的一家粮油店买米, 店里面的东西挺多的,各种品牌的柴米油盐样样不少,转了一圈,挑了一袋 50 斤 的洞庭湖1号大米, 告诉老板自己的住址,给老板付了钱,然后我就回家了,回到家不一会儿,老板就把米送过来了, 这速度 还真快,给个赞。晚上用新买的米做了顿饭,吃起来松软可口,再给个赞,并决定下次还去这家店买米。
这是人们日常生活中的一个很普通的场景,而正是通过这样一个普通的场景,我们可以抽象出大多数电商系统使用的常用数据结构。
把这个粮油店搬到线上
粮油店的老板是一个比较有想法的人,他想弄一个网店,这样他的顾客们可以在这个网店上选购自己需要的大米和食油,在网上支付,并填好送货地址,而他接到单后 会主动把顾客购买的货物送到顾客家中, 于是他的顾客就不用自己到店里去挑选货物,并且更远的顾客也可以在他的线上粮油店里购买货物。
一. 粮油店的产品
以我购买的洞庭湖1号大米为例,洞庭湖1号大米又分 10公斤,15公斤,25公斤以及30公斤4个不同级别的包装类型。以粮油店这种规模,我们 只需要设计 products 和 variants 两个表就能满足相关的需求, 其中 variants 表用来存储产品的涉及到具体型号的数据,比如重量,体积,大小,价格等。
表: products
Column | Type | 注释
----------------------+-----------------------------+----------------
id | integer | 产品主 ID
name | character varying(255) | 产品名字
description | character varying(2048) | 产品的描述
products 这个表很简单(目前很简单,如果粮油店的规模扩大了,这个表也会变的复杂), 只有 name, description 等信息,一些重要的信息比如 价格,重点,sku 等我们都放在了 variants 表里了。这里提到了 sku, 那么在了解 variants 表之前我们先了解下 sku 是什么。
什么是 sku?
SKU 是 Stock Keeping Unit 的简称, 即库存进出计量的单元, 也可以说是最小库存单元,不能再细分,用以区分物品, 例如洞庭湖1号大米下就有 4 个 sku,分别是:
- 洞庭湖1号大米 10 公斤
- 洞庭湖1号大米 15 公斤
- 洞庭湖1号大米 25 公斤
- 洞庭湖1号大米 30 公斤
在电商系统中我们会给 sku 一个合适的编码, 这个编码是唯一的, 因此我们也可以认为 sku 就是商品的编码, 比如我们可以给洞庭湖1号大米的 4 个 sku 做如下编码:
- 洞庭湖1号大米 10 公斤, sku 是 dongtignhu-1-10k1
- 洞庭湖1号大米 15 公斤, sku 是 dongtignhu-1-15kg
- 洞庭湖1号大米 25 公斤, sku 是 dongtignhu-1-25kg
- 洞庭湖1号大米 30 公斤, sku 是 dongtignhu-1-30kg
表: variants
Column | Type | 注释
-------------------------+-----------------------------+--------------
id | integer | 主键
product_id | integer | 关联产品
sku | character varying(255) | 产品编码
price | numeric(12,2) | 零售价格
weight | numeric(12,2) | 重量(以克为单位)
height | numeric(12,2) | 高度
width | numeric(12,2) | 长度
cost_price | numeric(12,2) | 成本价
二. 粮油店的第一个线上订单
如同在实体店里,顾客通过线上商店挑选了一袋 25 公斤的洞庭湖1号大米,两瓶 500ml 加加酱油, 然后顾客会去结算。 那么线上粮油店的结算过程会是什么样的呢? 我们回到文章的开头,我在粮油店挑了一袋 25 公斤的洞庭湖1号大米, 我指了指我的货物, 然后我告诉老板我的住址,老板会根据我住址的远近人肉计算是否需要收取一定的送货服务费,最后我把总的费用支付给老板,这样就完成了一次 结算。 在这次结算中涉及到的对象有 顾客, 商品,地址,支付,订单等。
订单是电商系统里的核心数据结构,顾客,商品,地址,支付,货运都是围绕订单运转的。
表: users
Column | Type | 注释
------------------------+-----------------------------+-------------------
id | integer | 用户主键
email | character varying(255) | 邮箱
login | character varying(255) | 登录时使用的帐户名
在这里 users 表只列出了可以表识出用户的字段,在实际的项目中, users 表拥有的字段会很多。
表: addresses
Table "public.addresses"
Column | Type | 注释
-------------------+-----------------------------+---------------------------------------------
id | integer | 主键
firstname | character varying | 地址主人的名(比如收货人的名)
lastname | character varying | 地址主人的姓(比如收货人的姓)
address1 | character varying | 详细地址1, 这个地址可以精确到路名或者街道地址,名牌号等
address2 | character varying | 补充地址2,备用
city | character varying | 城市
zipcode | character varying | 邮编
phone | character varying | 联系电话
state_name | character varying | 省或者州
alternative_phone | character varying | 备用电话
company | character varying | 公司
state_id | integer | 省或者州的关联 id
country_id | integer | 国家的关联 id, 如果支持海外货运的化需要这个
created_at | timestamp without time zone | 创建时间
updated_at | timestamp without time zone | 更新时间
首先对于每一个用户我们需要维护一个收货地址列表,这样用户在结算时可以从此地址列表中选择一个合适的地址作为其 收货地址,其次每一个订单都会有一个收货地址。对于这样一个过程,我们可以做如下设计:
- 用户从收货地址列表里选择了一个合适的地址 A1 (如果没有合适的地址,用户会编辑现有的地址或者增加新的地址)作为收货地址;
- 创建订单后, 此订单会和地址 A1 的一个拷贝关联起来, 这样即使用户以后修改 A1 也不会影响到以完成订单的收货地址;
用户的收货地址列表
我们需要一个中间表来关联 users 表和 addresses 表, 按 Rails 的约定可以将此中间表命名为 ship_addresses_users,
Column | Type | 注释
------------+-----------------------------+------------
id | integer |
address_id | integer |
user_id | integer |
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
用户的订单
- 订单属于某一个用户
- 订单有一个收货地址
- 订单包含若干商品
- 订单有一个总的金额(包括商品价格, 税, 运费等其他费用)
- 订单有一个商品的总金额(只含商品)
- 订单有一个调整的总金额(除商品外,只含税,运费等其他费用)
- 订单有一个实际支付的总金额
- 订单有一个自身的状态
- 订单有一个货运状态
- 订单有一个支付状态
- 订单有一个订单号
依据上面的 11 条逻辑,我们可以构造一个比较完整的订单的数据结构, 同时我们也参考 spree table: orders 关于表 orders 的设计,
Table "public.orders"
Column | Type | 注释
----------------------+-----------------------------+-------------------------
id | integer | 主键
number | character varying(15) | 订单号
item_total | numeric(8,2) | 商品总金额
total | numeric(8,2) | 总金额(包括商品价格, 税, 运费等其他费用)
state | character varying | 订单自身的状态
adjustment_total | numeric(8,2) | 调整的总金额(除商品外,只含税,运费等其他费用)
user_id | integer | 用户关联 id
completed_at | timestamp without time zone | 订单完成时间
ship_address_id | integer | 收货地址关联 id
payment_total | numeric(8,2) | 实际支付的总金额
shipment_state | character varying | 货运状态
payment_state | character varying | 支付状态
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
订单的状态
订单的状态我们分三种情况来分析,
- 订单自身的状态, 对应字段 state;
- 订单的支付状态, 对应字段 payment_state;
- 订单的货运状态, 对应字段 shipment_state;
参考 spree 对订单的设计, 一般来说订单自身的状态可设置为,:cart, :address, :delivery, :payment, :confirm, :complete
支付状态可以设置为,:balance_due, :paid, :credit_owed, :failed, :void
货运状态可以设置为,:ready, :pending, :partial, :shipped, :backorder, :canceled
当然这些状态到底怎么设置,不是绝对不变的,还是需要根据项目本身的实际情况去思考和设计, 根据实际情况设置符合项目要求的状态值。
订单包含的商品
怎样将订单和商品关联起来呢? 为此我们需要建立一个 line_items 表作为订单和商品之间的关联表, 这里我们可以参考 spree table: line_items 关于表 line_items 的设计,
spree_demo_development=# \d line_items;
Table "public.line_items"
Column | Type | 注释
------------+-----------------------------+---------------------------------------------------------
id | integer | 主键
variant_id | integer | 商品关联 id
order_id | integer | 订单关联 id
quantity | integer | 商品数量
price | numeric(8,2) | 商品单价(购买时的单价)
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
三. 支付
在电商系统中支付是一个非常重要的环节,支付的过程的流畅和安全直接影响到客户的购买体验,很多时候客户就是因为支付过程的不友好而放弃购买,同时支付也是一个比较复杂的过程。
支付开始的时候一般首先需要选择支付方式, 由此我们需要为支付方式建立相关的表: payment_methods, 这个表的设计可以参考 spree table: payment_methods,
表: payment_methods
Table "public.payment_methods"
Column | Type | 注释
-------------+-----------------------------+--------------------------------------------------------------
id | integer | 主键
type | character varying | 支付方法类型: 比如 cash, credit, transfer 等
name | character varying | 支付方法名,比如: 微信支付,财付通支付, 支付宝支付, 银联支付,银行转帐,货到付款等
description | text | 支付方法的描述
active | boolean | 是否激活此支付方法
environment | character varying | 支付方法所处的环境,比如 production, staging, development 等
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
用户选择好支付方法后,就会进入到一个实质的支付过程,如果我们使用外部的支付网关,比如微信支付,都会涉及到一个外部网关的请求,记录和处理外部网关的响应,更新支付状态等等环节, 为了处理好这些环节我们需要建立一个或多个模型,而这些模型背后的数据结构就是 payments, 对 payments 的设计我们同样可以参考 spree table: payments,
表: payments
Table "public.payments"
Column | Type | 注释
-------------------+-----------------------------+-------------------------------------------------------
id | integer | 主键
amount | numeric(8,2) | 支付金额
order_id | integer | 关联订单 id
payment_method_id | integer | 关联支付方法 id
state | character varying | 支付状态
response_code | character varying | 网关响应码
avs_response | character varying | 网关响应体
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
payment 的状态可以设置为,:checkout, :pending, :processing, :failed, :completed
同样这些状态也不是绝对的,可能需要根据自身的项目要求做适当的裁减。
四. 发货
虽然我们的粮油店可能只有一种物流方式: 店老板自己人力送货上门,但是说不定哪天粮油店规模扩大,会需要更多的物流方式,为此我们为了系统的可扩展性需要建立物流方法表: shpping_methods, 这个表我们可以参考 spree table: shpping_methods 来构建,
表: shipping_methods
Table "public.shipping_methods"
Column | Type | 注释
----------------------+-----------------------------+---------------------------------------------------------------
id | integer | 主键
name | character varying | 物流方法名
zone_id | integer | 物流方法关联的区域 id
deleted_at | timestamp without time zone | 假删除,记录删除日期
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
同样我们也需要某个模型来抽象发货这一业务,发货业务涉及到发货的状态, 订单,仓库,费用,货运单号,物流方法, 收货地址,发货时间,快递跟踪单号等一系列数据。我们把这个模型叫作 Shipmment, 同样需要为其建立 相关的表: shipments, 我们可以参考 spree talbe: shipments,
表: shipments
Table "public.shipments"
Column | Type | 注释
--------------------+-----------------------------+-----------------------------------------
id | integer | 主键
tracking | character varying | 快递跟踪单号
number | character varying | 货运单号
cost | numeric(8,2) | 实际运费
shipped_at | timestamp without time zone | 发货时间
order_id | integer | 关联订单 id
shipping_method_id | integer | 关联物流方法 id
address_id | integer | 收获地址关联 id
state | character varying | 物流状态
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
shipment 的状态可以设置为
:ready, :pending, :assemble, :cancelled, :shipped
五. 退货退款
虽然我们不希望用户退货退款,但是如果用户不能够合理地退货退款,那么她永远也不会再来我们的线上商店购买东西了,所以退货退款的功能非常重要。
为了支持退货,退款,首先我们需要建立模型: ReturnAuthorization, 这个模型的意思是退货退款授权, 这个模型能处理下面的数据和业务,
是哪个订单需要退款;
- 退款金额;
- 退款理由;
- 退款状态;
- 操作员;
ReturnAuthorization 模型也需要相关的表: return_authorizations 做支撑,我们参考 spree table: return_authorizations 的实现,
表: return_authorizations
Table "public.return_authorizations"
Column | Type | 注释
------------+-----------------------------+------------------------------------------------
id | integer | 主键
number | character varying | 退款授权编号
state | character varying | 退款授权状态: 'authorized', 'canceled'
amount | numeric(8,2) | 退款金额
order_id | integer | 退款关联订单 id
reason | text | 退款理由
enter_by | integer | 操作员
enter_at | timestamp without time zone | 操作时间
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
一个订单可能包括多个商品,用户退货可能只退其中的一部分,所以我们还需要一个 inventory_units 的表来记录用户申请退货的具体商品,我们可以参考 spree table: inventory_units,
表: inventory_units
Table "public.inventory_units"
Column | Type | 注释
-------------------------+-----------------------------+--------------------------------------------------------------
id | integer | 主键
lock_version | integer | 锁定版本号
state | character varying | 状态: 'backordered', 'on_hand', 'shipped', 'returned'
variant_id | integer | 退款商品关联 id
order_id | integer | 退款订单关联 id
shipment_id | integer | 退款货运物流关联 id
return_authorization_id | integer | 退款授权关联 id
created_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
当商品退回时,即 inventory_unit 的 state 为 returned 时,我们应该把钱款退给用户,为此我们可以建立一个 Chargeback 模型来处理这一事务,同样 Chargeback 需要表 chargebacks 作支撑。 chargebacks 可以作如下设计,
表 chargebacks
Table "public.chargebacks"
Column | Type | 注释
-------------+-----------------------------+-----------------------------------------------------------------
id | integer | 主键
state | character varying(20) | 状态
order_id | integer | 关联订单 id
operator_id | integer | 操作员 id
amount | numeric(12,2) | 实际退款金额
created_at | timestamp without time zone |
updated_at | timestamp without time zone |