- 编写订单控制器相关的订单行为;
- 添加路允许通过路由控制订单行为;
- 编写订单行为的单元测试,完成相关测试。
上一节我们已经实现了订单模型的相关开发工作。本节我们将继续开发订单相关功能,最终目标是基本实现订单的管理功能。
正式进入开发前,我们需要先理清思路。毕竟控制器的开发工作是面向客户需求的,我们应该先明确需求,寻找目标。
- 用户下单;
- 用户查看自己的订单列表;
- 查看指定订单详情;
- index: 返回订单列表
- create: 新增订单
- show: 返回指定的订单信息
-
Rails回调
实际上就是生命周期的钩子,在Rails中有很多监测生命周期的回调,本节中会用到两个:
# 在触发验证之前操作 before_validation :要触发的自定义方法 # 在数据保存后触发的操作 after_save :触发的方法
$ 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
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
$ 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}
思路解析
获取订单列表,可以直接使用order模型相关方法获取,这里要注意分页和排序的情况。
路由是: api/v1/orders?page=1&per_page=10
返回值部分我们采用json数据类型的返回值,成功状态码是200。
要注意的是,要查看订单列表,用户需要登录才可以。
至于测试部分,我们应该测试两中场景
- 成功:登录
- 状态码应该是200
- 返回值的订单列表,但是有可能是空的,所以这个可以不测试
- 返回值包含分页数据。
- 失败:未登录
- 状态码:403
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
因为要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
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"
}
$ git add .
$ git commit -m "add orders_controller index action"
思路解析
获取指定订单,路由是: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
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
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"
}
$ git add .
$ git commit -m "add orders_controller show action"
思路解析
新建订单,我们的路由是: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
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
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
这次测试通过了,但是我们的订单功能还不完善,例如库存检查,库存自动扣减等操作。
不过我们先把代码文件提交管理下。
$ git add .
$ git commit -m "add orders_controller create action"
思路分析
客户要下单首先要检测商品库存是否足够,其次,下完单后,商品的库存应该对应减少。
具体要怎么做呢?
对于库存检查,其实应该在多个地方保证,下单时就应该立即检查,而真实保存数据时,一定要检查!
我们这里把库存检查放在保存之前自动触发的验证中!来定义一个验证方法。
而对于下单后的库存扣减,应该是当 订单总一件商品信息存入到 placements
表后就应该立即扣减!如果我们想要让扣减自动触发,就应该放到 placement
模型中当保存后自动触发。
前面我们定义商品表时并没有库存字段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
查看并修改文件: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
运行迁移命令
$ rails db:migrate
== 20210523150554 AddQuantityToProducts: migrating ============================
-- add_column(:products, :quantity, :integer, {:null=>false, :default=>0})
-> 0.0362s
== 20210523150554 AddQuantityToProducts: migrated (0.0363s) ===================
我们想要在订单保存时自动执行库存检查,所以我们需要在订单模型中定义一个方法的验证器,保存时自动触发该验证.
我们需要针对订单中所有的商品的购买数量和商品表中对应商品的库存作比较,如果有一项商品的库存不满足需求,即: 库存数量<订单商品数
我们就认为:验证失败
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
现在执行测试,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
$ git add .
$ git commit -m "add quantity check"
我们需要在 placement
存储后进行对应商品库存数量的扣减!
在模型 placement
中定义扣减库存的方法
app/models/placement.rb
class Placement < ApplicationRecord
# ... ...
private
def decrement_product_quantity!
product.decrement!(:quantity, quantity)
end
end
app/models/placement.rb
class Placement < ApplicationRecord
# ... ...
after_create :decrement_product_quantity!
# ... ...
end
修改测试
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, 成功了!
$ git add .
$ git commit -m "add quantity decrement"
$ git checkout master
$ git merge chapter08
我们本节完成了订单控制器的相关方法,也就完成了订单功能。至此,我们功能模块的开发已经基本完成。不过在此基础上,还有一些需求度高的扩展功能需要完成,下节课我们就一起继续开发我们的简易商城!加油!