一家粮油店

去家附近的一家粮油店买米, 店里面的东西挺多的,各种品牌的柴米油盐样样不少,转了一圈,挑了一袋 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. 洞庭湖1号大米 10 公斤
  2. 洞庭湖1号大米 15 公斤
  3. 洞庭湖1号大米 25 公斤
  4. 洞庭湖1号大米 30 公斤

在电商系统中我们会给 sku 一个合适的编码, 这个编码是唯一的, 因此我们也可以认为 sku 就是商品的编码, 比如我们可以给洞庭湖1号大米的 4 个 sku 做如下编码:

  1. 洞庭湖1号大米 10 公斤, sku 是 dongtignhu-1-10k1
  2. 洞庭湖1号大米 15 公斤, sku 是 dongtignhu-1-15kg
  3. 洞庭湖1号大米 25 公斤, sku 是 dongtignhu-1-25kg
  4. 洞庭湖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)               |  成本价

products 和 variants 关系图

products_and_variants

二. 粮油店的第一个线上订单

如同在实体店里,顾客通过线上商店挑选了一袋 25 公斤的洞庭湖1号大米,两瓶 500ml 加加酱油, 然后顾客会去结算。 那么线上粮油店的结算过程会是什么样的呢? 我们回到文章的开头,我在粮油店挑了一袋 25 公斤的洞庭湖1号大米, 我指了指我的货物, 然后我告诉老板我的住址,老板会根据我住址的远近人肉计算是否需要收取一定的送货服务费,最后我把总的费用支付给老板,这样就完成了一次 结算。 在这次结算中涉及到的对象有 顾客, 商品地址支付订单等。

订单是电商系统里的核心数据结构,顾客,商品,地址,支付,货运都是围绕订单运转的。

表: users

         Column         |            Type             |       注释                     
------------------------+-----------------------------+-------------------
 id                     | integer                     |  用户主键
 email                  | character varying(255)      |  邮箱
 login                  | character varying(255)      |  登录时使用的帐户名

在这里 users 表只列出了可以表识出用户的字段,在实际的项目中, users 表拥有的字段会很多。

表: addresses

首先对于每一个用户我们需要维护一个收货地址列表,这样用户在结算时可以从此地址列表中选择一个合适的地址作为其 收货地址,其次每一个订单都会有一个收货地址。对于这样一个过程,我们可以做如下设计:

  1. 用户从收货地址列表里选择了一个合适的地址 A1 (如果没有合适的地址,用户会编辑现有的地址或者增加新的地址)作为收货地址;

  2. 创建订单后, 此订单会和地址 A1 的一个拷贝关联起来, 这样即使用户以后修改 A1 也不会影响到以完成订单的收货地址;

我们可以参考开源项目 spree 关于表 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 |  更新时间

用户的收货地址列表

我们需要一个中间表来关联 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

这样我们可以通过 ship_addresses_users 中间表检索出用户的收货地址列表:

  select * from users u
  join ship_addresses_users ship_addr_u on ship_addr_u.user_id = u.id
  join addresses ship_addr on ship_addr.id = ship_addr_u.address_id
  where u.id = xxx

用户和收货地址列表的关系图

users_ship_addresses

用户的订单

  1. 订单属于某一个用户
  2. 订单有一个收货地址
  3. 订单包含若干商品
  4. 订单有一个总的金额(包括商品价格, 税, 运费等其他费用)
  5. 订单有一个商品的总金额(只含商品)
  6. 订单有一个调整的总金额(除商品外,只含税,运费等其他费用)
  7. 订单有一个实际支付的总金额
  8. 订单有一个自身的状态
  9. 订单有一个货运状态
  10. 订单有一个支付状态
  11. 订单有一个订单号

依据上面的 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

订单的状态

订单的状态我们分三种情况来分析,

  1. 订单自身的状态, 对应字段 state;
  2. 订单的支付状态, 对应字段 payment_state;
  3. 订单的货运状态, 对应字段 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

Order, Product, Variant, LineItem 之间的关系图

order-product-variant-lineitem

三. 支付

在电商系统中支付是一个非常重要的环节,支付的过程的流畅和安全直接影响到客户的购买体验,很多时候客户就是因为支付过程的不友好而放弃购买,同时支付也是一个比较复杂的过程。

支付开始的时候一般首先需要选择支付方式, 由此我们需要为支付方式建立相关的表: 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

同样这些状态也不是绝对的,可能需要根据自身的项目要求做适当的裁减。

支付和订单的关系图

order_payment

四. 发货

虽然我们的粮油店可能只有一种物流方式: 店老板自己人力送货上门,但是说不定哪天粮油店规模扩大,会需要更多的物流方式,为此我们为了系统的可扩展性需要建立物流方法表: 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

发货和订单的关系图

shipments-orders

五. 退货退款

虽然我们不希望用户退货退款,但是如果用户不能够合理地退货退款,那么她永远也不会再来我们的线上商店购买东西了,所以退货退款的功能非常重要。

为了支持退货,退款,首先我们需要建立模型: ReturnAuthorization, 这个模型的意思是退货退款授权, 这个模型能处理下面的数据和业务,

  1. 是哪个订单需要退款;
  2. 退款金额;
  3. 退款理由;
  4. 退款状态;
  5. 操作员;

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 | 

退货退款和订单的关系图

reutrn-order