Flask-爱家租房项目ihome-09-订单模块

预定页面

用户在房屋信息页面点击'即刻预定', 就进入了预定页面, 在该页面填写入住的起止时间, 再点击'提交订单'按钮, 生成订单记录

image-20200911075812123

预定页面后端逻辑编写

进入预定页面后, 首先前端需要发送获取预定房屋信息的请求, 后端编写对应的接口, 返回房屋信息, 编写房屋模块的视图文件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='该房屋已被预定')

注:

  1. 一共住几晚和总价格这两个值不需要从前端传入, 因为后端可以自己计算, 并且如果前端给的值可能是错误的

  2. 下单的时候需要限制房东不能预定自己的房屋

  3. 如果房屋在预定时间段内已经被预定了, 则报错已经被订购. 限制时间的逻辑为判断该房屋是否存在已经预定且未结束的订单, 并且该订单的起止时间与这次填写的起止时间存在交集.

  4. 大体业务逻辑为, 查询出订购的房屋信息, 校验房屋和预定时间以及订购者, 校验成功后, 创建订单数据, 最后更新房屋信息, 将房屋的订购次数字段house.order_count加一, 提交至数据库后结束.

  5. 因为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);
            }
        }
    })
});

我的订单页面

用户在预定页面成功预定后, 会自动跳转到''我的订单''页面.该页面以创建时间倒序显示我创建过的订单.

image-20200911102118524

我的订单页面后端逻辑编写

在创建订单模型的时候, 就预定义了订单的状态改变, 本项目只是模拟的最简单的几种状态.

  • 刚创建时为WAIT_ACCEPT, 等待房东接单或者拒绝
  • 若拒单, 则状态改为REJECTED, 订单生命周期结束
  • 若接单, 则状态改为WAIT_PAYMENT, 等待用户付款
  • 用户付款后, 状态改为WAIT_COMMENT, 等待用户评价
  • 用户评价后, 状态改为COMPLETED, 订单生命周期结束
  • 用户在订单处于WAIT_ACCEPTWAIT_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)

注:

  1. 根据url上的参数role, 如果为空或者my, 则返回我的订单数据, 如果为其他值则返回客户的订单数据

  2. 具体的订单信息字段在订单模型类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);
        }
    })
});

注:

  1. 根据订单不同的状态, 需要展示订单不同的操作按钮, 在我的订单页面中主要有三个按钮, "取消"/"支付"/"评论", 这里先把按钮的前端发送ajax请求的代码写好, 当然也可以后面逐步编写
  2. 注意三个按钮的点击事件都必须写在最开始获取订单数据的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>

注:

  1. 这里的评论和取消按钮使用的是Bootstrap 的模态框(Modal)插件, 需要设置data-toggledata-target属性

  2. 按钮点击后会出现对应的选择框, 如:

    image-20200911110558500image-20200911110616197

客户订单页面

客户订单页面和我的订单页面类似, 都是显示订单信息

image-20200911110843158

只是对应的订单操作按钮只有"接单"和"拒单"两种, 编写对应的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)

注:

  1. "接单"和"拒单"都是对同一种状态WAIT_ACCEPT的订单进行状态的变更操作, 如果是拒单的话还需要填写拒单理由, 所以两个按钮可以公用一个后台接口, 根据参数action判断是拒绝还是接收
  2. 由于是对订单的局部字段进行修改, 因此这里使用的http请求方式为PATCH
  3. 校验订单时除了校验订单是否存在外, 还需要校验当前用户是否可以进行想做的操作, 还有需要校验当前订单的状态是否符合要求
原文地址:https://www.cnblogs.com/gcxblogs/p/13653482.html