Skip to content

Latest commit

 

History

History
993 lines (717 loc) · 24.8 KB

13 订单模块- 控制器相关功能开发.md

File metadata and controls

993 lines (717 loc) · 24.8 KB

使用rails6 开发纯后端 API 项目

订单模块: 控制器


本节目标
  • 编写订单控制器相关的订单行为;
  • 添加路允许通过路由控制订单行为;
  • 编写订单行为的单元测试,完成相关测试。

上一节我们已经实现了订单模型的相关开发工作。本节我们将继续开发订单相关功能,最终目标是基本实现订单的管理功能。

正式进入开发前,我们需要先理清思路。毕竟控制器的开发工作是面向客户需求的,我们应该先明确需求,寻找目标。

1. 功能分析

1.1 订单模块要实现的功能
  • 用户下单;
  • 用户查看自己的订单列表;
  • 查看指定订单详情;
1.2 要实现的方法
  • index: 返回订单列表
  • create: 新增订单
  • show: 返回指定的订单信息

2. 控制器相关功能开发

2.1 知识点预热

2.1.1 Rails 相关
  • Rails回调

    实际上就是生命周期的钩子,在Rails中有很多监测生命周期的回调,本节中会用到两个:

    # 在触发验证之前操作
    before_validation :要触发的自定义方法
    
    # 在数据保存后触发的操作
    after_save :触发的方法

2.2 创建orders控制器

$ rails generate controller api::v1::orders
Running via Spring preloader in process 7591
      create  app/controllers/api/v1/orders_controller.rb
      invoke  test_unit
      create    test/controllers/api/v1/orders_controller_test.rb

2.3 添加订单相关方法的路由

2.3.1 修改路由文件

config/routes.rb

Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # 添加路由
      resources :orders, only: [:index, :show, :create]
    end
  end
end
2.3.2 在Rails console 中可以查看 orders控制器 的路由列表
$ rails routes -c orders
--[ Route 1 ]-------------------------------------
Prefix            | api_v1_orders
Verb              | GET
URI               | /api/v1/orders(.:format)
Controller#Action | api/v1/orders#index {:format=>:json}
--[ Route 2 ]---------------------------------------------
Prefix            | 
Verb              | POST
URI               | /api/v1/orders(.:format)
Controller#Action | api/v1/orders#create {:format=>:json}
--[ Route 3 ]-----------------------------------------------
Prefix            | api_v1_order
Verb              | GET
URI               | /api/v1/orders/:id(.:format)
Controller#Action | api/v1/orders#show {:format=>:json}

2.4 订单列表:index 方法开发


思路解析

获取订单列表,可以直接使用order模型相关方法获取,这里要注意分页和排序的情况。

路由是: api/v1/orders?page=1&per_page=10

返回值部分我们采用json数据类型的返回值,成功状态码是200。

要注意的是,要查看订单列表,用户需要登录才可以。

至于测试部分,我们应该测试两中场景

  • 成功:登录
    • 状态码应该是200
    • 返回值的订单列表,但是有可能是空的,所以这个可以不测试
    • 返回值包含分页数据。
  • 失败:未登录
    • 状态码:403

2.4.1 编写测试

test/controllers/api/v1/orders_controller_test.rb

require "test_helper"

class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
    setup do
		@order = orders(:one)
	end
    
	test 'index_forbidden: should not show orders cause unlogged' do
		get api_v1_orders_url, as: :json
		assert_response 403
	end
    
    test "index_success: should show orders" do
        get api_v1_orders_path, 
            headers: { Authorization: JsonWebToken.encode(user_id:@order.user_id) },
            as: :json
        assert_response 200

        # 测试分页
        json_response = JSON.parse(response.body, symbolize_names:true)
        assert_not_nil json_response.dig(:links, :first)
        assert_not_nil json_response.dig(:links, :last)
        assert_not_nil json_response.dig(:links, :prev)
        assert_not_nil json_response.dig(:links, :next)
    end
end

运行测试会显示失败,原因是找不到 index 方法, 这是当然的,我们还没有定义 index 方法。

$ rails test               

Error:
Api::V1::OrdersControllerTest#test_index_success:_should_show_orders:
DRb::DRbRemoteError: The action 'index' could not be found for Api::V1::OrdersController
......
Finished in 2.724423s, 17.2514 runs/s, 25.3265 assertions/s.
47 runs, 69 assertions, 0 failures, 1 errors, 0 skips
2.4.2 定义序列化文件

因为要json序列化,所以先要创建 order 的序列化文件

$ rails generate serializer order price_total
Running via Spring preloader in process 7995
      create  app/serializers/order_serializer.rb

修改 app/serializers/order_serializer.rb 文件:

class OrderSerializer
  include JSONAPI::Serializer
  attributes :price_total
  belongs_to :user
  has_many :products
end
2.4.3 定义orders#index方法

app/controllers/api/v1/orders_controller.rb

class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: [:index]
  
  def index
    @orders = current_user.orders.page(current_page).per_page(per_page)
    option = get_links_serializer_options 'api_v1_orders_path', @orders
    render json: serializer_order(@orders, 0, 'ok', option), status:200
  end

  private

    def serializer_order(order, error_code=0, message='ok', option = {})
      order_hash =  OrderSerializer.new(order, option).serializable_hash
      order_hash['error_code'] = error_code
      order_hash['message'] = message
      return order_hash
    end

    def check_login
      head 403 unless current_user
    end
end

再次运行测试,这次测试通过了。

$ rails test
# Running:
........
Finished in 2.521439s, 19.0367 runs/s, 29.7449 assertions/s.
48 runs, 75 assertions, 0 failures, 0 errors, 0 skips

请求 api/v1/orders得到的数据:

{
    "data": [
        {
            "id": "1",
            "type": "order",
            "attributes": {
                "price_total": 105
            },
            "relationships": {
                "user": {
                    "data": {
                        "id": "1",
                        "type": "user"
                    }
                },
                "products": {
                    "data": [
                        {
                            "id": "1",
                            "type": "product"
                        }
                    ]
                }
            }
        }
    ],
    "error_code": {
        "links": {
            "first": "/api/v1/orders?page=1",
            "last": "/api/v1/orders?page=1",
            "prev": "/api/v1/orders",
            "next": "/api/v1/orders"
        }
    },
    "message": "ok"
}
2.4.3 代码变更提交
$ git add .
$ git commit -m "add orders_controller index action"

2.5 订单详情:show 方法开发


思路解析

获取指定订单,路由是:get:api/v1/orders/:id

我们首先要获取参数中的订单id,然后根据订单id找到具体的订单,返回订单信息,状态码仍是200。

要注意的是,该操作仍然需要用户登录才能使用。

至于测试部分,我们应该测试两种场景:

  • 成功:登录了

    • 状态码应该是200
    • 返回值的订单就是我们指定的订单信息。返回值数据类型。
    {
        "data": {
            "id": "1",
            "type": "order",
            "attributes": {
                "price_total": 105
            },
            "relationships": {
                "user": {
                    "data": {
                        "id": "1",
                        "type": "user"
                    }
                },
                "products": {
                    "data": [
                        {
                            "id": "1",
                            "type": "product"
                        }
                    ]
                }
            }
        },
        "included": [
            {
                "id": "1",
                "type": "product",
                "attributes": {
                    "title": "123",
                    "price": 1,
                    "published": 1,
                    "shop_id": 1
                },
                "relationships": {
                    "shop": {
                        "data": {
                            "id": "1",
                            "type": "shop"
                        }
                    }
                }
            }
        ],
        "error_code": 0,
        "message": "ok"
    }
  • 失败:未登录

    • 状态码是 403

2.5.1 编写测试

test/controllers/api/v1/orders_controller_test.rb

class Api::V1::ordersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @order = orders(:one)
  end
  
  # ... ...
  test "show_forbidden: should forbidden show order cause unlogin" do
    get api_v1_order_path(@order), as: :json
    # 验证状态码
    assert_response 403
  end
    
  test "show_success: should show order" do
    get api_v1_order_path(@order),
      headers: { Authorization: JsonWebToken.encode(user_id:@order.user_id) },
      as: :json
    json_response = JSON.parse(self.response.body, symbolize_names:true)
    # 验证状态码
    assert_response 200
    # 验证返回数据
    assert_equal @order.price_total, json_response.dig(:data, :attributes, :price_total)
  end
end

运行测试会显示失败,原因是找不到 show 方法, 这是当然的,我们还没有定义 show 方法。

$ rails test

Error:
Api::V1::OrdersControllerTest#test_show_success:_should_show_order:
DRb::DRbRemoteError: The action 'show' could not be found for Api::V1::OrdersController
2.5.2 定义orders#show方法

app/controllers/api/v1/orders_controller.rb

class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: [:index, :show]
  before_action :set_order, only: [:show]

  def show
    option = {include: [:products]}
    render json: serializer_order(@order, 0, 'ok', option), status:200
  end

  private
	#......
    def set_order
      @order = current_user.orders.find_by_id params[:id].to_i
      @order = @order || {} 
    end
end

再次运行测试,这次测试通过了。

$ rails test

Finished in 2.842461s, 17.5904 runs/s, 27.4410 assertions/s.
50 runs, 78 assertions, 0 failures, 0 errors, 0 skips

请求api/v1/orders/1获取数据:

{
    "data": {
        "id": "1",
        "type": "order",
        "attributes": {
            "price_total": 105
        },
        "relationships": {
            "user": {
                "data": {
                    "id": "1",
                    "type": "user"
                }
            },
            "products": {
                "data": [
                    {
                        "id": "1",
                        "type": "product"
                    }
                ]
            }
        }
    },
    "included": [
        {
            "id": "1",
            "type": "product",
            "attributes": {
                "title": "123",
                "price": 1,
                "published": 1,
                "shop_id": 1
            },
            "relationships": {
                "shop": {
                    "data": {
                        "id": "1",
                        "type": "shop"
                    }
                }
            }
        }
    ],
    "error_code": 0,
    "message": "ok"
}
2.5.3 代码变更提交
$ git add .
$ git commit -m "add orders_controller show action"

2.6 新建订单:create方法开发


思路解析

新建订单,我们的路由是:post:api/v1/orders

要创建新订单我们会生成订单数据(用户id,订单总价)和详情数据(订单id,商品id,购买商品数量),会有两张表数据增加,这个接口应该是要求用户登录状态下操作的, 所以其中用户信息我们要根据用户的登录信息来获取,订单总价应该是:

订单总价 = 商品1.数量 * 商品1.单价 + 商品2.数量 * 商品2.单价 + ... ...

也就是说订单总价是通过计算得来的!所以在创建订单时需要直接传输的直接的参数是购买的商品的信息,我们这里包含的是 商品的id和商品的购买数量,当然需要购买多件商品,所以应该是商品的列表:

{
    products: [
        { id: 1, quantity: 2 },
        { id: 2, quantity: 3 },
    ]
}

不过为了扩展也为了标识订单,我们可以设置参数如下:

{
    order: {
        products: [
            { id: 1, quantity: 2 },
            { id: 2, quantity: 3 },
        ]
    }
}

那么就可以总结出订单创建接口的具体实现步骤:

  • 接受参数,根据参数查询所有参数中在商品表数据,获取每种商品的定价,计算订单的总价,如果检测到其中一种商品不存在,终止程序,返回报错信息;
  • 根据上面计算出的订单总价,根据当前登录用户的信息获取用户id,创建订单表数据;
  • 根据已经创建订单表的数据获取新的订单id,循环遍历参数中购买的所有商品,生成订单详情表数据;

当然,因为涉及到多张表的操作,我们可以把上面的操作放到一个数据库事务中。

如果保存成功,则返回成功的信息;否则,返回失败的信息。

至于测试部分,我们应该测试两种场景

  • 成功:登录
    • 状态码应该是 201
    • 添加成功后订单的总数增加 1。
  • 失败:未登录
    • 状态码是 403

2.6.1 编写测试

test/controllers/api/v1/orders_controller_test.rb

require "test_helper"

class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
	@order = orders(:one)
    @param = {
      order:{
        products:[
          {id:products(:one).id, quantity:2},
          {id:products(:two).id, quantity:3}
        ]
      }
    }
  end
  
  # ... ... 其它代码
    
  test "create_forbidden: should forbidden create order cause unlogin" do
    post api_v1_orders_path, as: :json
    # 验证状态码
    assert_response 403
  end
    
  test "create_success: should create order" do
    post api_v1_orders_path,
      headers: { Authorization: JsonWebToken.encode(user_id:@order.user_id) },
      params: @param,
      as: :json
    json_response = JSON.parse(self.response.body, symbolize_names:true)
    # 验证状态码
    assert_response 201
    # 验证返回数据
    assert_equal @order.price_total, json_response.dig(:data, :attributes, :price_total)
  end
end

运行测试会显示一个失败,原因是找不到 create 方法, 这是当然的,我们还没有定义 create 方法。

$ rails test

Error:
Api::V1::OrdersControllerTest#test_create_forbidden:_should_forbidden_create_order_cause_unlogin:
DRb::DRbRemoteError: The action 'create' could not be found for Api::V1::OrdersController
2.6.2 定义orders#create方法

app/controllers/api/v1/orders_controller.rb

class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: [:index, :show, :create]

  def create
    @order = current_user.orders.create()
    @order.build_order_placement(order_params[:products])
    
    if @order.save
      render json: serializer_order(@order), status: 201
    else
      render json: {errors: @order.errors}, status: 500
    end
  end


  private
    def serializer_order(order, error_code=0, message='ok', option = {})
      order_hash =  OrderSerializer.new(order, option).serializable_hash
      order_hash['error_code'] = error_code
      order_hash['message'] = message
      return order_hash
    end

    def order_params
      # 只保留允许的字段,屏蔽不允许的字段
      params.require(:order).permit(products:[:id, :quantity])
    end
end

我们还需要在 order 模型中定义创建 placement 的方法以及在保存前自动计算订单总额的方法

app/models/order.rb

class Order < ApplicationRecord
  belongs_to :user
  has_many :placements, dependent: :destroy
  has_many :products, through: :placements

  before_validation :set_price_total
  
  validates :price_total, presence: true, numericality: { greater_than_or_equal_to: 0}

  # 创建 订单的 plcaemenent
  def build_order_placement(products)
    products.each do |product|
      placement = placements.build(
        product_id: product[:id],
        quantity: product[:quantity]
      )

      yield placement if block_given?
    end 
  end

  private
    # 自动计算订单总额
    def set_price_total
      self.price_total = placements.map{|placement| placement.product.price*placement.quantity }.sum
    end
end

再次运行测试,这次测试还是失败了。

$ rails test

Failure:
OrderTest#test_invalid:_order_with_invalid_price_total [/home/qiji/文档/learn-rails/rails-shop-api/test/models/order_test.rb:18]:
Expected true to be nil or false

...................................

Finished in 2.631215s, 19.7627 runs/s, 30.7843 assertions/s.
52 runs, 81 assertions, 1 failures, 0 errors, 0 skips

查看失败信息我们可以找到test/models/order_test.rb:18

test 'invalid: order with invalid price_total' do
    order = Order.new(user_id:@user.id, price_total:-1)
    assert_not order.valid?
end

现在总金额的计算是自动的,这个验证我们可以删除掉。

删除上面的测试后,再次运行测试:

$ rails test

Finished in 2.625169s, 19.4273 runs/s, 30.4742 assertions/s.
51 runs, 80 assertions, 0 failures, 0 errors, 0 skips

这次测试通过了,但是我们的订单功能还不完善,例如库存检查,库存自动扣减等操作。

不过我们先把代码文件提交管理下。

2.6.3 代码变更提交
$ git add .
$ git commit -m "add orders_controller create action"

3. 订单功能完善


思路分析

客户要下单首先要检测商品库存是否足够,其次,下完单后,商品的库存应该对应减少。

具体要怎么做呢?

对于库存检查,其实应该在多个地方保证,下单时就应该立即检查,而真实保存数据时,一定要检查!

我们这里把库存检查放在保存之前自动触发的验证中!来定义一个验证方法。

而对于下单后的库存扣减,应该是当 订单总一件商品信息存入到 placements 表后就应该立即扣减!如果我们想要让扣减自动触发,就应该放到 placement 模型中当保存后自动触发。


3.1 给商品表添加库存字段

3.1.1 编写迁移文件

前面我们定义商品表时并没有库存字段quantity, 现在我们通过 迁移文件的形式给它增加该字段:

$ rails generate migration add_quantity_to_products  quantity:integer
Running via Spring preloader in process 13084
      invoke  active_record
      create    db/migrate/20210523150554_add_quantity_to_products.rb
3.1.2 修改迁移文件

查看并修改文件:db/migrate/20210523150554_add_quantity_to_products.rb

class AddQuantityToProducts < ActiveRecord::Migration[6.1]
  def change
    add_column :products, :quantity, :integer, null:false, default:0
  end
end
3.1.3 执行迁移

运行迁移命令

$ rails db:migrate                                                   
== 20210523150554 AddQuantityToProducts: migrating ============================
-- add_column(:products, :quantity, :integer, {:null=>false, :default=>0})
   -> 0.0362s
== 20210523150554 AddQuantityToProducts: migrated (0.0363s) ===================

3.2 库存检查

我们想要在订单保存时自动执行库存检查,所以我们需要在订单模型中定义一个方法的验证器,保存时自动触发该验证.

3.2.1 在order模型中自定义验证器

我们需要针对订单中所有的商品的购买数量和商品表中对应商品的库存作比较,如果有一项商品的库存不满足需求,即: 库存数量<订单商品数

我们就认为:验证失败

class Order < ApplicationRecord
  # ... ...
  validate :enough_products?
  # ... ...

  private
    # ... ...
    
    # 验证库存
    def enough_products?
      self.placements.each do |placement|
        product = placement.product
        if placement.quantity > product.quantity
          errors.add(product.title, "Is out of stock, just #{product.quantity} left")
        end
      end
    end
end
3.2.2 测试

现在执行测试,oh!失败了... ...

$ rails test

Failure:
Api::V1::OrdersControllerTest#test_create_success:_should_create_order [/home/qiji/文档/learn-rails/rails-shop-api/test/controllers/api/v1/orders_controller_test.rb:66]:
Expected response to be a <201: Created>, but was a <500: Internal Server Error>
Response body: {"errors":{"first product":["Is out of stock, just 0 left"],"second product":["Is out of stock, just 0 left"]}}.
Expected: 201
  Actual: 500

Finished in 2.522824s, 20.2154 runs/s, 31.3141 assertions/s.
51 runs, 79 assertions, 1 failures, 0 errors, 0 skips

不过也说明我们的验证起效了,我们需要修改我们的预定义信息,给我们的商品增加一些库存!

test/fixtures/products.yml

# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
  title: 'first product'
  price: 1
  published: 1
  shop: one
  # 库存
  quantity: 10


two:
  title: 'second product'
  price: 1
  published: 1
  shop: two
  # 库存
  quantity: 10

现在再执行测试:

$ rails test

Finished in 2.521621s, 20.2251 runs/s, 31.7256 assertions/s.
51 runs, 80 assertions, 0 failures, 0 errors, 0 skips
3.2.3 代码变更提交
$ git add .
$ git commit -m "add quantity check"

3.3 库存扣减

我们需要在 placement 存储后进行对应商品库存数量的扣减!

3.3.1 定义库存扣减

在模型 placement 中定义扣减库存的方法

app/models/placement.rb

class Placement < ApplicationRecord
  # ... ...
    
  private
    def decrement_product_quantity!
      product.decrement!(:quantity, quantity)
    end
end
3.3.2 设置保存后自动调用

app/models/placement.rb

class Placement < ApplicationRecord
  # ... ...
    
  after_create :decrement_product_quantity!
    
  # ... ...
end
3.3.3 测试

修改测试

test/controllers/api/v1/orders_controller_test.rb

require "test_helper"

class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ... ...
    
  test "create_success: should create order" do
    post api_v1_orders_path,
      headers: { Authorization: JsonWebToken.encode(user_id:@order.user_id) },
      params: @param,
      as: :json
    json_response = JSON.parse(self.response.body, symbolize_names:true)
    # 验证状态码
    assert_response 201
    # 验证返回数据
    assert_equal @order.price_total, json_response.dig(:data, :attributes, :price_total)
    # 测试剩余库存
    assert_equal products(:one).quantity-2, 8
    assert_equal products(:two).quantity-3, 7
  end
end

运行测试

$ rails test

Finished in 2.319060s, 21.9917 runs/s, 35.3592 assertions/s.
51 runs, 82 assertions, 0 failures, 0 errors, 0 skips

ok, 成功了!

3.3.4 代码变更提交
$ git add .
$ git commit -m "add quantity decrement"

4 文件版本管理

4.1 合并版本到主分支
$ git checkout master
$ git merge chapter08

5. 总结

我们本节完成了订单控制器的相关方法,也就完成了订单功能。至此,我们功能模块的开发已经基本完成。不过在此基础上,还有一些需求度高的扩展功能需要完成,下节课我们就一起继续开发我们的简易商城!加油!