Flask-愛家租房專案ihome-09-訂單模組
預定頁面
使用者在房屋資訊頁面點選'即刻預定', 就進入了預定頁面, 在該頁面填寫入住的起止時間, 再點選'提交訂單'按鈕, 生成訂單記錄
預定頁面後端邏輯編寫
進入預定頁面後, 首先前端需要傳送獲取預定房屋資訊的請求, 後端編寫對應的介面, 返回房屋資訊, 編寫房屋模組的檢視檔案houses.py
, 新增返回訂單房屋的資訊
# ihome/api_1_0/houses.py @api.route('/booking/houses/<int:house_id>') @login_required def get_order_house(house_id): # 查詢房屋資訊 try: house = Houses.query.get(house_id) except Exception as e: current_app.logger.error(e) return jsonify(errno=RET.DBERR, errmsg='獲取房屋資訊異常') if not house: return jsonify(errno=RET.PARAMERR, errmsg='房屋ID不存在') # 獲取房屋資訊 house_info = house.get_booking_info() return jsonify(errno=RET.OK, data=house_info)
建立訂單模組, 在ihome/api_1_0
下建立訂單模組的檢視檔案orders.py
, 並在藍圖api
中匯入該檔案
# ihome/api_1_0/__init__.py
from flask import Blueprint
# 建立藍圖
api = Blueprint('api_1_0', __name__, url_prefix='/api/v1.0')
# 匯入藍圖的檢視
from . import users, verify_codes, houses, orders
使用者點選提交訂單後呼叫建立訂單的介面, 前端傳入的引數為房屋ID和起止時間, url為/api/v1.0/orders
, method為POST
orders.py
# ihome/api_1_0/orders.py @api.route('/orders', methods=['POST']) @login_required def create_order(): """建立訂單""" # 接收資料 data_dict = request.get_json() if not data_dict: return parameter_error() # 提取資料 house_id = data_dict.get('house_id') start_date = data_dict.get('start_date') end_date = data_dict.get('end_date') # 校驗資料 if not all([house_id, start_date, end_date]): return parameter_error() # 校驗日期 try: start_date = datetime.strptime(start_date, '%Y-%m-%d') end_date = datetime.strptime(end_date, '%Y-%m-%d') except Exception as e: current_app.logger.error(e) return jsonify(errno=RET.PARAMERR, errmsg='日期格式錯誤') # 計算共幾晚 days = (end_date - start_date).days if days < 0: return jsonify(errno=RET.PARAMERR, errmsg='起始日期不能超過結束日期') for i in range(5): # 校驗house_id try: # .with_for_update新增悲觀鎖 # house = Houses.query.filter_by(id=house_id).with_for_update().first() # 樂觀鎖方式 house = Houses.query.get(house_id) # print(f'{g.user.name}-a-{house.order_count}') # time.sleep(3) except Exception as e: current_app.logger.error(e) return jsonify(errno=RET.DBERR, errmsg='獲取房屋資訊異常') if not house: return jsonify(errno=RET.PARAMERR, errmsg='房屋ID不存在') # 校驗房東不能訂購自己的房屋 if g.user.id == house.user_id: return jsonify(errno=RET.PARAMERR, errmsg='不能預定自己的房屋') # 儲存老的房屋訂單數量 old_order_count = house.order_count # 計算總價 amount = days * house.price # 檢視該房屋該時間段是否有預定 try: count = Orders.query.filter(Orders.house_id == house_id, start_date <= Orders.end_date, Orders.start_date <= end_date, Orders.status.in_(['WAIT_ACCEPT', 'WAIT_PAYMENT'])).count() except Exception as e: current_app.logger.error(e) return jsonify(errno=RET.DBERR, errmsg='獲取訂單資訊異常') if count > 0: return jsonify(errno=RET.DATAEXIST, errmsg='該時間段已被訂購') # 建立訂單 order = Orders(user=g.user, house=house, start_date=start_date, end_date=end_date, days=days, price=house.price, amount=amount) db.session.add(order) # 房屋訂單數量加1, 避免同時下單, 使用樂觀鎖 new_house = Houses.query.filter(Houses.id == house_id, Houses.order_count == old_order_count).update( {Houses.order_count: old_order_count + 1}) # house.order_count += 1 if new_house: db.session.add(house) # time.sleep(3) # print(f'{g.user.name}-b-{house.order_count}') try: db.session.commit() # time.sleep(3) # print(f'{g.user.name}-c-{house.order_count}') except Exception as e: db.session.rollback() current_app.logger.error(e) return jsonify(errno=RET.DBERR, errmsg='建立訂單異常') return jsonify(errno=RET.OK) else: db.session.rollback() return jsonify(errno=RET.DBERR, errmsg='該房屋已被預定')
注:
-
一共住幾晚和總價格這兩個值不需要從前端傳入, 因為後端可以自己計算, 並且如果前端給的值可能是錯誤的
-
下單的時候需要限制房東不能預定自己的房屋
-
如果房屋在預定時間段內已經被預定了, 則報錯已經被訂購. 限制時間的邏輯為判斷該房屋是否存在已經預定且未結束的訂單, 並且該訂單的起止時間與這次填寫的起止時間存在交集.
-
大體業務邏輯為, 查詢出訂購的房屋資訊, 校驗房屋和預定時間以及訂購者, 校驗成功後, 建立訂單資料, 最後更新房屋資訊, 將房屋的訂購次數字段
house.order_count
加一, 提交至資料庫後結束. -
因為python的GIL鎖機制, 一段時間內只可能有一個執行緒在交給CPU執行, 所以如果兩個人同時預定同一時間段的同一房屋的話, 可能出現CPU先執行A使用者下單前的校驗, 發現檢驗通過, 然後暫停A使用者的執行, 轉去執行B使用者的下單校驗, 也發現校驗通過, 然後暫停B, 回去執行A的建立訂單和後續操作, 完成後再回去B執行建立訂單和後續操作, 這樣就造成了A和B都能對同一房屋的同一時間段內下單成功.
因此需要新增鎖機制, 有樂觀鎖和悲觀鎖兩種
-
悲觀鎖: 悲觀的認為每次下單都可能與其他人同時預定成功, 考慮的是最壞的情況. 因此每次下單的時候都需要先把資料查詢鎖定, 完成完整的下單流程後, 再進行解鎖. 鎖定期間其他操作這一條記錄的程序或執行緒都需要等待解鎖.
悲觀鎖優點是邏輯編寫簡單, 只需要加鎖和解鎖, 缺點是每次下單都要加鎖和解鎖, 操作鎖的開銷也很大, 適用的場景為有較高可能性發生衝突的情況, 比如搶購.
具體做法是:
# 在查詢房屋資訊時就用 .with_for_update() 方法給資料加鎖 house = Houses.query.filter_by(id=house_id).with_for_update().first() # 最後更新了房屋的訂購次數後提交或回滾session就會給資料解鎖 db.session.add(house) db.session.commit()
-
樂觀鎖: 樂觀的認為每次下單都不會與其他人同時預定成功, 考慮的是最好的情況, 因此每次下單的時候並不會進行加鎖, 而是先在查詢的時候記錄下來某個欄位的值(比如這裡的
old_order_count = house.order_count
), 再在後面更新的時候加上該值的限制條件Houses.query.filter(Houses.id == house_id, Houses.order_count == old_order_count)
如果依舊能夠查詢到房屋物件, 那麼說明從查詢房屋到更新房屋這段時間內沒有其他人也預定了該房間, 那麼這個預定就是成功的.
如果查不到房屋物件, 說明這期間也有其他人預定了該房間, 那麼就迴圈回去重新進行查詢, 拿到最新的
order_count
, 再次建立訂單並更新房屋資訊, 更新時也要繼續加上order_count
的限制.設定這樣迴圈三到五次, 如果5次都失敗了, 那就判定這次點選預定按鈕的結果是失敗的, 需要重新點選預定.
樂觀鎖的優點是不會每次都給記錄上鎖, 省去了操作鎖的開銷, 而是增加了迴圈判斷, 使用的場景為有較低可能性發生衝突的情況, 比如普通的購買
具體做法是:
# 將 開始查詢房屋資訊到建立訂單到最後的更新房屋資訊 都放在一個迴圈中 for i in range(5): # 查詢房屋資訊, 記錄開始的order_count house = Houses.query.get(house_id) old_order_count = house.order_count # 建立訂單 order = Orders(user=g.user, house=house, start_date=start_date, end_date=end_date.....) db.session.add(order) # 重新查詢並更新房屋, 加上order_count的限制 new_house = Houses.query.filter(Houses.id == house_id, Houses.order_count == old_order_count).update({Houses.order_count: old_order_count + 1}) # 如果更新成功, 說明沒有衝突, 提交資料庫, 返回介面結果, 退出迴圈 if new_house: db.session.add(house) db.session.commit() return jsonify(errno=RET.OK) # 如果更新失敗, 則說明有衝突, 回滾資料庫, 並進入下一個迴圈 else: db.session.rollback() #如果迴圈了5次還是失敗則返回報錯 return jsonify(errno=RET.DBERR, errmsg='該房屋已被預定')
-
預定頁面前端邏輯編寫
編寫預定頁面對應的js檔案booking.js
, 添加發送獲取房屋資訊的ajax請求和建立訂單的ajax請求
//獲取url中的房屋ID
var houseId = decodeQuery()['id'];
//傳送ajax請求, 獲取房屋資料
$.get('api/v1.0/booking/houses/'+houseId, function (resp) {
if (resp.errno == '0'){
//展示房屋資訊
$('.house-info img').attr('src', resp.data.img_url);
$('.house-text h3').html(resp.data.title);
$('.house-text span').html(resp.data.price);
}else {
alert(resp.errmsg);
}
}, 'json')
//設定表單自定義提交
$('.submit-btn').click(function (e) {
//阻止預設的form表單提交
e.preventDefault();
//自定義提交
var startDate = $('#start-date').val();
var endDate = $('#end-date').val();
var data = JSON.stringify({house_id: houseId, start_date: startDate, end_date: endDate});
//提交ajax請求
$.ajax({
url: 'api/v1.0/orders',
type: 'POST',
data: data,
contentType: 'application/json',
headers: {'X-CSRFToken': getCookie('csrf_token')},
dataType: 'json',
success: function (resp) {
if (resp.errno == '0'){
//建立成功,進入我的訂單頁面
location.href='/orders.html';
}else {
alert(resp.errmsg);
}
}
})
});
我的訂單頁面
使用者在預定頁面成功預定後, 會自動跳轉到''我的訂單''頁面.該頁面以建立時間倒序顯示我建立過的訂單.
我的訂單頁面後端邏輯編寫
在建立訂單模型的時候, 就預定義了訂單的狀態改變, 本專案只是模擬的最簡單的幾種狀態.
- 剛建立時為
WAIT_ACCEPT
, 等待房東接單或者拒絕 - 若拒單, 則狀態改為
REJECTED
, 訂單生命週期結束 - 若接單, 則狀態改為
WAIT_PAYMENT
, 等待使用者付款 - 使用者付款後, 狀態改為
WAIT_COMMENT
, 等待使用者評價 - 使用者評價後, 狀態改為
COMPLETED
, 訂單生命週期結束 - 使用者在訂單處於
WAIT_ACCEPT
和WAIT_PAYMENT
, 即剛建立訂單或付款之前, 都可以點選訂單的取消按鈕取消訂單, 取消後訂單狀態改為CANCELLED
, 訂單生命週期結束
由於''我的訂單''頁面和''客戶訂單''頁面都是展示相應的訂單資訊, ''我的訂單''展示的是當前使用者下訂購的歷史訂單, ''客戶訂單''展示的是以當前使用者為房東, 其客戶下的歷史訂單. 兩個頁面展示的訂單資訊是一樣的, 所以我們把兩個頁面的訂單介面做成一個公用的介面, 只是在url上新增引數role
區別是我的訂單還是客戶訂單. 編寫orders.py
檔案, 新增訂單資訊介面, url為/api/v1.0/orders
, method為GET
@api.route('/orders', methods=['GET'])
@login_required
def get_orders():
"""查詢訂單"""
# 獲取url引數, 當前角色
role = request.args.get('role', 'my')
# 獲取訂單資料
try:
if role == 'my':
# 我的訂單
orders = Orders.query.filter_by(user=g.user).order_by(Orders.id.desc()).all()
else:
# 客戶訂單
# 獲取我的房屋
house_ids = [house.id for house in g.user.houses]
# 獲取我的房屋下的訂單
orders = Orders.query.filter(Orders.house_id.in_(house_ids)).order_by(Orders.id.desc()).all()
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='獲取訂單異常')
# 獲取返回的訂單資訊
order_info = [order.get_list_info() for order in orders]
return jsonify(errno=RET.OK, data=order_info)
注:
-
根據url上的引數
role
, 如果為空或者my
, 則返回我的訂單資料, 如果為其他值則返回客戶的訂單資料 -
具體的訂單資訊欄位在訂單模型類
Orders
中的get_list_info()
中獲取# 獲取訂單列表展示的資訊 def get_list_info(self): return { 'order_id': self.id, 'status': self.status, 'status_info': ORDER_STATUS.get(self.status), 'img_url': self.house.default_image_url, 'house_id': self.house.id, 'title': self.house.title, 'ctime': datetime.strftime(self.created_date, '%Y-%m-%d %H:%M:%S'), 'start_date': datetime.strftime(self.start_date, '%Y-%m-%d'), 'end_date': datetime.strftime(self.end_date, '%Y-%m-%d'), 'amount': self.amount, 'days': self.days, 'comment': self.comment, }
其中即返回了訂單狀態的程式碼
status
, 也返回了狀態的中文名稱status_info
, 具體對應關係通過ORDER_STATUS
字典維護.返回的時間格式也是轉為了字串格式.
我的訂單頁面前端邏輯編寫
我的訂單可能存在多條資料, 所以也是通過前端模板art-template
編寫會更加方便, 編寫我的訂單頁面對應的js檔案orders.js
$(document).ready(function(){
$('.modal').on('show.bs.modal', centerModals); //當模態框出現的時候
$(window).on('resize', centerModals);
//傳送ajax請求獲取訂單
$.get('api/v1.0/orders', function (resp) {
if (resp.errno == '0'){
//填充頁面內容
$('.orders-list').html(template('orders-list-tmpl', {orders:resp.data}));
//房屋圖片點選事件
$('img').click(function () {
var house_id = $(this).attr('house-id');
location.href = "detail.html?id="+house_id;
});
//取消按鈕
$(".order-cancel").on("click", function(){
var orderId = $(this).parents("li").attr("order-id");
$(".modal-cancel").attr("order-id", orderId);
console.log(orderId)
});
//確定取消按鈕
$('.modal-cancel').on("click", function () {
var orderId = $(this).attr("order-id");
$.ajax({
url: '/api/v1.0/orders/cancel/'+orderId,
type: 'PATCH',
contentType: 'application/json',
headers: {'X-CSRFToken': getCookie('csrf_token')},
dataType: 'json',
success: function (resp) {
if (resp.errno == '0'){
//更新成功, 重新整理頁面
location.reload();
}else {
alert(resp.errmsg)
}
}
});
});
//去支付按鈕
$('.order-pay').on("click", function () {
var orderId = $(this).parents("li").attr("order-id");
//傳送ajax請求獲取支付頁面url
$.ajax({
url: '/api/v1.0/orders/alipay',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({order_id: orderId}),
headers: {'X-CSRFToken': getCookie('csrf_token')},
dataType: 'json',
success: function (resp) {
if (resp.errno == '0'){
//成功, 新視窗開啟支付寶連結
location.href = resp.data.url;
}else {
alert(resp.errmsg);
}
}
})
});
//評論按鈕
$(".order-comment").on("click", function(){
var orderId = $(this).parents("li").attr("order-id");
$(".modal-comment").attr("order-id", orderId);
});
//去評論按鈕
$('.modal-comment').on('click', function () {
var orderId = $(this).attr('order-id');
var comment = $('#comment').val()
$.ajax({
url: '/api/v1.0/orders/comment/'+orderId,
type: 'PATCH',
data: JSON.stringify({comment: comment}),
contentType: 'application/json',
headers: {'X-CSRFToken': getCookie('csrf_token')},
dataType: 'json',
success: function (resp) {
if (resp.errno == '0'){
//更新成功, 重新整理頁面
location.reload();
}else {
alert(resp.errmsg)
}
}
});
});
}else {
alert(resp.errmsg);
}
})
});
注:
- 根據訂單不同的狀態, 需要展示訂單不同的操作按鈕, 在我的訂單頁面中主要有三個按鈕, "取消"/"支付"/"評論", 這裡先把按鈕的前端傳送ajax請求的程式碼寫好, 當然也可以後面逐步編寫
- 注意三個按鈕的點選事件都必須寫在最開始獲取訂單資料的ajax的success方法中, 因為需要先等
template
方法將html填充上才能獲取到這些按鈕和資料
編寫我的訂單頁面對應html檔案orders.html
<ul class="orders-list">
<script id="orders-list-tmpl" type="text/html">
{{if orders.length != 0}}
{{each orders as order}}
<li order-id={{order.order_id}}>
<div class="order-title">
<h3>訂單編號:{{order.order_id}}</h3>
{{ if order.status == 'WAIT_ACCEPT' }}
<div class="fr order-operate">
<button type="button" class="btn btn-success order-cancel" data-toggle="modal" data-target="#cancel-modal">取消</button>
</div>
{{ else if order.status == 'WAIT_PAYMENT' }}
<div class="fr order-operate">
<button type="button" class="btn btn-success order-cancel" data-toggle="modal" data-target="#cancel-modal">取消</button>
<button type="button" class="btn btn-success order-pay">去支付</button>
</div>
{{ else if order.status == 'WAIT_COMMENT' }}
<div class="fr order-operate">
<button type="button" class="btn btn-success order-comment" data-toggle="modal" data-target="#comment-modal">發表評價</button>
</div>
{{/if}}
</div>
<div class="order-content">
<img src="{{order.img_url}}" house-id="{{ order.house_id }}">
<div class="order-text">
<a href="detail.html?id={{ order.house_id }}">{{order.title}}</a>
<ul>
<li>建立時間:{{order.ctime}}</li>
<li>入住日期:{{order.start_date}}</li>
<li>離開日期:{{order.end_date}}</li>
<li>合計金額:¥{{(order.amount).toFixed(0)}}(共{{order.days}}晚)</li>
<li>訂單狀態:
<span>
{{order.status_info}}
</span>
</li>
{{if 'COMPLETED' == order.status}}
<li>我的評價: {{order.comment}}</li>
{{else if 'REJECTED' == order.status}}
<li>拒單原因: {{order.comment}}</li>
{{/if}}
</ul>
</div>
</div>
</li>
{{/each}}
{{else}}
暫時沒有訂單。
{{/if}}
</script>
</ul>
注:
-
這裡的評論和取消按鈕使用的是Bootstrap 的模態框(Modal)外掛, 需要設定
data-toggle
和data-target
屬性 -
按鈕點選後會出現對應的選擇框, 如:
客戶訂單頁面
客戶訂單頁面和我的訂單頁面類似, 都是顯示訂單資訊
只是對應的訂單操作按鈕只有"接單"和"拒單"兩種, 編寫對應的js檔案lorders.js
//接單或拒單按鈕傳送ajax請求
function updateOrder(orderId, data){
var data_json=JSON.stringify(data);
$.ajax({
url: 'api/v1.0/orders/accept/'+orderId,
type: 'PATCH',
contentType: 'application/json',
data: data_json,
headers: {'X-CSRFToken': getCookie('csrf_token')},
dataType: 'json',
success: function (resp) {
if (resp.errno == '0'){
//接收成功, 重新整理頁面
location.reload();
}else {
alert(resp.errmsg);
}
}
})
}
$(document).ready(function(){
$('.modal').on('show.bs.modal', centerModals); //當模態框出現的時候
$(window).on('resize', centerModals);
//傳送ajax請求, 獲取訂單資料
$.get('api/v1.0/orders?role=lorder', function (resp) {
if (resp.errno == '0'){
$('.orders-list').html(template('orders-info', {orders: resp.data}));
//圖片點選事件
$('img').click(function () {
var houseId = $(this).attr('house-id');
location.href="detail.html?id="+houseId;
})
//接單按鈕
$(".order-accept").on("click", function(){
var orderId = $(this).parents("li").attr("order-id");
$(".modal-accept").attr("order-id", orderId);
});
//確定接單按鈕
$('.modal-accept').on("click", function () {
var orderId = $(this).attr("order-id");
//更新狀態
var data = {action: 'accept'};
updateOrder(orderId, data);
})
//拒單按鈕
$(".order-reject").on("click", function(){
var orderId = $(this).parents("li").attr("order-id");
$(".modal-reject").attr("order-id", orderId);
});
//確定拒單按鈕
$('.modal-reject').on("click", function () {
var orderId = $(this).attr("order-id");
var reason = $('#reject-reason').val();
//更新狀態
var data = {acction: 'reject', comment: reason};
updateOrder(orderId, data);
})
}else{
alert(resp.errmsg);
}
})
});
訂單按鈕操作
房客可以對訂單進行"取消"/"支付"/"評論"操作, 房東可以對訂單進行"接單"/"拒單"操作, 這些按鈕除了"支付"比較複雜外, 其他按鈕都是修改訂單的狀態或者評論欄位, 只是校驗訂單時邏輯稍微不太一樣, 這裡就只舉例"接單"/"拒單"的後端介面, 其他介面實現方法類似
@api.route('/orders/accept/<int:order_id>', methods=['PATCH'])
@login_required
def accept_order(order_id):
"""接收/拒絕訂單"""
# 接收資料
data_dict = request.get_json()
if not data_dict:
return parameter_error()
# 獲取對應的操作和評論資訊
action = data_dict.get('action')
comment = data_dict.get('comment')
if not action:
return parameter_error()
# 更新狀態, 限制該訂單狀態為'待接單', 訂單的房源為當前登入使用者的房源
try:
order = Orders.query.get(order_id)
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='獲取訂單異常')
# 校驗訂單
if not order:
return jsonify(errno=RET.PARAMERR, errmsg='訂單ID不存在')
if order.status != 'WAIT_ACCEPT':
return jsonify(errno=RET.PARAMERR, errmsg='訂單狀態不為"待接單"')
if order.house.user != g.user:
return jsonify(errno=RET.PARAMERR, errmsg='訂單房屋不屬於當前使用者')
# 判斷狀態
if action == 'accept':
status = 'WAIT_PAYMENT'
elif action == 'reject':
status = 'REJECTED'
else:
return jsonify(errno=RET.PARAMERR, errmsg='無效的操作')
# 更新訂單
order.status = status
order.comment = comment
try:
db.session.add(order)
db.session.commit()
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='更新訂單異常')
return jsonify(errno=RET.OK)
注:
- "接單"和"拒單"都是對同一種狀態
WAIT_ACCEPT
的訂單進行狀態的變更操作, 如果是拒單的話還需要填寫拒單理由, 所以兩個按鈕可以公用一個後臺介面, 根據引數action
判斷是拒絕還是接收 - 由於是對訂單的區域性欄位進行修改, 因此這裡使用的http請求方式為
PATCH
- 校驗訂單時除了校驗訂單是否存在外, 還需要校驗當前使用者是否可以進行想做的操作, 還有需要校驗當前訂單的狀態是否符合要求