一家粮油店
去家附近的一家粮油店买米, 店里面的东西挺多的,各种品牌的柴米油盐样样不少,转了一圈,挑了一袋 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 nullpayment 的状态可以设置为,: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 nullshipment 的状态可以设置为
: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 |