路飞学城 六:下单支付&订单处理

六:下单支付&订单处理

下单结算

客户端展示下单结算界面展示

views/Cart.vue,代码:

<div class="li-3"><router-link to="/order" class="btn">去结算</router-link></div>

router/index.js,代码:

// 路由列表
const routes = [
    {
      meta:{
        title: "luffy2.0-首页",
        keepAlive: true
      },
      path:'/',         // uri访问地址
      component: ()=> import("../views/Home.vue")
    },{
      meta:{
        title: "登录",
        keepAlive: true
      },
      path: '/login',
      name: "Login",            // 路由名称
      component: ()=> import("../views/Login.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "项目课",
        keepAlive: true
      },
      path: '/project',
      name: "Course",            // 路由名称
      component: ()=> import("../views/Course.vue"),         // uri绑定的组件页面
    },{
      meta:{
        title: "项目课",
        keepAlive: true
      },
      path: '/project/:id',
      name: "Info",
      component: ()=> import("../views/Info.vue"),
    },{
      meta:{
        title: "购物车",
        keepAlive: true
      },
      path: '/cart',
      name: "Cart",
      component: ()=> import("../views/Cart.vue"),
    },{
      meta:{
        title: "确认下单",
        keepAlive: true
      },
      path: '/order',
      name: "Order",
      component: ()=> import("../views/Order.vue"),
    }
]

views/Order.vue,代码:

<template>
  <div class="cart">
    <Header/>
    <div class="cart-main">
      <div class="cart-header">
        <div class="cart-header-warp">
          <div class="cart-title left">
            <h1 class="left">确认订单</h1>
          </div>
          <div class="right">
            <div class="">
              <span class="left"><router-link class="myorder-history" to="/cart">返回购物车</router-link></span>
            </div>
          </div>
        </div>
      </div>
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title"><p class="item-1 l">课程信息</p></div>
        <div class="cart-body-table">
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
          <div class="item">
              <div class="item-2">
                  <a href="" class="img-box l"><img src="../assets/course-9.png"></a>
                  <dl class="l has-package">
                    <dt>【实战课程】3天Typescript精修 </dt>
                    <p class="package-item">减免价</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price"><em>¥</em><span>998.00</span></p>
                      <p class="original-price"><em>¥</em><span>800.00</span></p>
                  </div>
              </div>
          </div>
        </div>
        <div class="coupons-box">
          <div class="coupon-title-box">
            <p class="coupon-title">
              使用优惠券/积分
                <span v-if="state.use_coupon" @click="state.use_coupon=!state.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path></svg></span>
                <span v-else @click="state.use_coupon=!state.use_coupon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" data-v-394d1fd8=""><path fill="currentColor" d="m488.832 344.32-339.84 356.672a32 32 0 0 0 0 44.16l.384.384a29.44 29.44 0 0 0 42.688 0l320-335.872 319.872 335.872a29.44 29.44 0 0 0 42.688 0l.384-.384a32 32 0 0 0 0-44.16L535.168 344.32a32 32 0 0 0-46.336 0z"></path></svg></span>
<!--                <i :class="state.use_coupon?'el-icon-arrow-up':'el-icon-arrow-down'" @click="state.use_coupon=!state.use_coupon"></i>-->
            </p>
          </div>
          <transition name="el-zoom-in-top">
          <div class="coupon-del-box" v-if="state.use_coupon">
            <div class="coupon-switch-box">
              <div class="switch-btn ticket" :class="{'checked': state.discount_type===0}" @click="state.discount_type=0">优惠券 (4)<em><i class="imv2-check"></i></em></div>
              <div class="switch-btn code" :class="{'checked': state.discount_type===1}" @click="state.discount_type=1">积分<em><i class="imv2-check"></i></em></div>
            </div>
            <div class="coupon-content ticket" v-if="state.discount_type===0">
              <p class="no-coupons" v-if="state.coupon_list.length<1">暂无可用优惠券</p>
              <div class="coupons-box" v-else>
               <div class="content-box">
                <ul class="nouse-box">
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l select">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l wait-use">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="use-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
                <ul class="overdue-box">
                 <li class="l useing">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥100 </p>
                    <p class="use-inst l">满499可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                 <li class="l">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l"> ¥248 </p>
                    <p class="use-inst l">满999可用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:全部实战课程</div>
                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>
                   </div>
                  </div>
                 </li>
                </ul>
               </div>
              </div>
            </div>
            <div class="coupon-content code" v-else>
                <div class="input-box">
                  <el-input-number placeholder="10积分=1元" v-model="state.credit" :step="1" :min="0" :max="1000"></el-input-number>
                  <a class="convert-btn">兑换</a>
                </div>
                <div class="converted-box">
                  <p>使用积分:<span class="code-num">200</span></p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                </div>
                <p class="error-msg">本次订单最多可以使用1000积分,您当前拥有200积分。(10积分=1元)</p>
                <p class="tip">说明:每笔订单只能使用一次积分,并只有在部分允许使用积分兑换的课程中才能使用。</p>
              </div>
          </div>
          </transition>
        </div>
        <div class="pay-type">
          <p class="title">选择支付方式</p>
          <div class="list">
            <img :src="state.pay_type==0?'/src/assets/alipay2.png':'/src/assets/alipay1.png'" @click="state.pay_type=0" alt="支付宝">
            <img :src="state.pay_type==1?'/src/assets/wechat2.png':'/src/assets/wechat1.png'" @click="state.pay_type=1" alt="微信">
            <img :src="state.pay_type==2?'/src/assets/yue2.png':'/src/assets/yue1.png'"  @click="state.pay_type=2" alt="余额">
          </div>
        </div>
        <div class="pay-box" :class="{fixed:state.fixed}">
				  <div class="row-bottom">
            <div class="row">
              <div class="goods-total-price-box">
                <p class="r rw price-num"><em>¥</em><span>1811.00</span></p>
                <p class="r price-text"><span>共<span>5</span>件商品,</span>商品总金额:</p>
              </div>
            </div>
            <div class="coupons-discount-box">
              <p class="r rw price-num">-<em>¥</em><span>60.00</span></p>
              <p class="r price-text">优惠券/积分抵扣:</p>
            </div>
            <div class="pay-price-box clearfix">
              <p class="r rw price"><em>¥</em><span id="js-pay-price">1751.00</span></p>
              <p class="r price-text">应付:</p>
            </div>
            <span class="r btn btn-red submit-btn">提交订单</span>
					</div>
          <div class="pay-add-sign">
            <ul class="clearfix">
              <li>支持花呗</li>
              <li>可开发票</li>
              <li class="drawback">7天可退款</li>
            </ul>
          </div>
	      </div>
      </div>
    </div>
    <Footer/>
  </div>
</template>

<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";

let store = useStore()

let state = reactive({
  course_list: [],     // 购物车中的商品课程列表
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券
  credit: 0,           // 当前用户选择抵扣的积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
})

// 监听用户选择的支付方式
watch(
    ()=>state.pay_type,
    ()=>{
      console.log(state.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  state.fixed = offsetY < maxY
}
</script>

<style scoped>
.cart-header {
	height: 160px;
	background-color: #e3e6e9;
	background: url("/src/assets/cart-header-bg.jpeg") repeat-x;
	background-size: 38%;
}

.cart-header .cart-header-warp {
	width: 1500px;
	height: 120px;
	line-height: 120px;
	margin-left: auto;
	margin-right: auto;
	font-size: 14px
}

.cart-header .cart-header-warp .myorder-history {
	font-weight: 200
}

.cart-header .left {
	float: left
}

.cart-header .right {
	float: right
}

.cart-header .cart-title {
	color: #4d555d;
	font-weight: 200;
	font-size: 14px
}

.cart-header .cart-title h1 {
	font-size: 32px;
	line-height: 115px;
	margin-right: 25px;
	color: #07111b;
	font-weight: 200
}

.cart-header .cart-title span {
	margin: 0 4px
}

.l {
  float: left;
}
.r {
  float: right;
}
.cart-body {
	width: 1500px;
	padding: 0 36px 32px;
	background-color: #fff;
	margin-top: -40px;
	margin-left: auto;
	margin-right: auto;
	box-shadow: 0 8px 16px 0 rgba(7,17,27, .1);
	border-radius: 8px;
	box-sizing: border-box
}

.cart-body .left {
	float: left!important
}

.cart-body .right {
	float: right!important
}

.cart-body .cart-body-title {
	min-height: 88px;
	line-height: 88px;
	border-bottom: 1px solid #b7bbbf;
	box-sizing: border-box
}

body {
	background: #f8fafc
}

.cart-body .cart-body-title span {
	font-size: 14px
}

.cart-body .cart-body-title .item-1>span,
.cart-body .cart-body-title .item-2>span,
.cart-body .cart-body-title .item-3>span{
	display: inline-block;
	font-size: 14px;
	line-height: 24px;
	color: #4d555d
}

.cart-body .cart-body-title .item-1>span {
	color: #93999f
}

.cart-body .cart-body-title .item-2>span {
	margin-left: 40px
}

.cart-body .item {
	height: 88px;
	padding: 24px 0;
	background: #f3f5f7;
}
.cart-body .cart-body-table {
    padding-bottom: 36px;
    border-bottom: 1px solid #d9dde1;
}
.cart-body .item>div {
	float: left
}

.cart-body .item .item-1 {
	padding-top: 34px;
	position: relative;
	z-index: 1
}

.cart-body .item:last-child>.item-1::after {
	display: none
}

.cart-body .item-1 {
	width: 120px
}

.cart-body .item-1 i {
	margin-left: 12px;
	margin-right: 8px;
	font-size: 24px
}

.cart-body .item-2 {
	width: 1020px;
  position:relative;
}
.cart-body .item-2>span{
  line-height: 88px;
}
.cart-body .item-2 dl {
	width: 464px;
	margin-left: 24px;
	padding-top: 12px
}

.cart-body .item-2 dl a {
	display: block;
}

.cart-body .item-2 dl.has-package {
	padding-top: 4px;
}

.cart-body .item-2 dl.has-package .package-item {
	display: inline-block;
	padding: 0 12px;
	margin-top: 4px;
	font-size: 12px;
	color: rgba(240,20,20, .6);
	line-height: 24px;
	background: rgba(240,20,20, .08);
	border-radius: 12px;
	cursor: pointer
}

.cart-body .item-2 dl.has-package .package-item:hover {
	color: #fff;
	background: rgba(240,20,20, .2)
}

.cart-body .item-2 dt {
	font-size: 16px;
	color: #07111b;
	line-height: 24px;
	margin-bottom: 4px
}

.cart-body .item-2 .img-box {
	display: block;
  margin-left: 42px;
}
.cart-body .item-2 .img-box img{
  height: 94px;
}
.cart-body .item-2 dd {
	font-size: 12px;
	color: #93999f;
	line-height: 24px;
	font-weight: 200
}

.cart-body .item-2 dd a {
	display: inline-block;
	margin-left: 12px;
	color: rgba(240,20,20, .4)
}

.cart-body .item-2 dd a:hover {
	color: #f01414
}

.cart-body .item-3 {
	width: 280px;
	margin-left: 48px;
  position: relative;
}

.cart-body .item-3 .price {
	display: inline-block;
	height: 46px;
	width: 96px;
  padding-top: 24px;
  padding-bottom: 24px;
  color: #f01414;
}
.cart-body .item-3 .price em,
.cart-body .item-3 .price span{
  font-size: 18px;
}
.cart-body .item-3 .price .original-price em,
.cart-body .item-3 .price .original-price span{
  font-size: 15px;
  color: #aaa;
  text-decoration: line-through;
}

.cart-body .cart-body-bot li {
	float: left
}

.cart-body .cart-body-bot .li-1 em,
.cart-body .cart-body-bot .li-3 em {
	font-style: normal;
	color: red
}

.cart-body .cart-body-bot .li-2 .price {
	font-size: 16px;
	color: #f01414;
	line-height: 24px;
	font-weight: 700
}

.coupons-box::after{
  display: block;
  content: "";
  overflow: hidden;
  clear: both;
}
.coupons-box .coupon-title-box {
	margin: 27px 0 0 12px
}

.coupons-box .coupon-title-box .coupon-title {
	color: #07111b;
	font-size: 16px;
	line-height: 34px
}

.coupons-box .coupon-title-box .coupon-title svg {
	position: relative;
    width: 26px;
    height: 26px;
	top: 5px;
	margin-left: 12px;
	font-size: 24px;
	color: #999;
	cursor: pointer
}


.coupons-box .coupon-del-box {
	width: 100%;
	padding-top: 24px;
	box-sizing: border-box
}

.coupons-box .coupon-del-box .coupon-switch-box {
	margin-bottom: 16px
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn {
	position: relative;
	display: inline-block;
	width: 138px;
	height: 58px;
	line-height: 20px;
	border: 1px solid #d9dde1;
	border-radius: 8px;
	padding: 18px 0;
	color: #1c1f21;
	text-align: center;
	font-size: 16px;
	margin-right: 16px;
	box-sizing: border-box;
	cursor: pointer
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn em {
	display: none;
	position: absolute;
	bottom: 0;
	right: 0;
	width: 0;
	height: 0;
	line-height: 54px;
	border-left-width: 20px;
	border-left-style: solid;
	border-left-color: transparent;
	border-bottom-width: 20px;
	border-bottom-style: solid;
	border-bottom-color: #f01414
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn em i {
	color: #fff;
	position: absolute;
	bottom: -20px;
	right: 0;
	font-size: 12px
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn.checked {
	border: 2px solid #f01414
}

.coupons-box .coupon-del-box .coupon-switch-box .switch-btn.checked em {
	display: block
}

.coupons-box .coupon-del-box .coupon-content {
	position: relative;
	background: #f3f5f7;
	border-radius: 8px;
	padding: 24px
}

.coupons-box .coupon-del-box .coupon-content:before {
	content: "";
	display: block;
	position: absolute;
	top: -7px;
	left: 62px;
	border-left: 12px solid transparent;
	border-right: 12px solid transparent;
	border-bottom: 7px solid #f3f5f7
}

.coupons-box .coupon-del-box .coupon-content.ticket li {
	padding-top: 8px;
	box-sizing: border-box;
	width: 320px;
	background-color: #fff6f0;
	cursor: pointer;
	margin: 12px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .more-del-box {
	padding: 16px 22px 24px 22px;
	width: 100%;
	box-sizing: border-box;
	background-repeat: no-repeat
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box {
	height: 32px;
	line-height: 32px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .price {
	font-size: 30px;
	margin-right: 4px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .price sub {
	font-size: 24px;
	letter-spacing: -5px
}

.coupons-box .coupon-del-box .coupon-content.ticket li .price-box .use-inst {
	font-size: 12px;
	margin-top: 5px;
}

.coupons-box .coupon-del-box .coupon-content.ticket .active .price,
.coupons-box .coupon-del-box .coupon-content.ticket .active .use-inst {
	color: #fff
}

.coupons-box .coupon-del-box .coupon-content.ticket .active i {
	position: absolute;
	top: 12px;
	right: 12px;
	color: #fff;
	font-size: 24px
}

.coupons-box .coupon-del-box .coupon-content.ticket .no-coupons {
	font-size: 14px;
	color: #4d555d;
	line-height: 14px
}

.coupons-box .coupon-del-box .coupon-content.code {
	padding-left: 38px
}

.coupons-box .coupon-del-box .coupon-content.code:before {
	left: 216px
}

.coupons-box .coupon-del-box .coupon-content.code .input-box {
	position: relative;
	left: -12px;
	margin-top: 12px
}

.coupons-box .coupon-del-box .coupon-content.code .input-box .convert-input {
	background: #fff;
	border: 1px solid #9199a1;
	width: 356px;
	height: 48px;
	border-radius: 8px;
	font-size: 16px;
	font-weight: 600;
	color: #07111b;
	letter-spacing: 2px;
	line-height: 24px;
	padding: 12px 16px;
	box-sizing: border-box;
	vertical-align: middle
}

.coupons-box .coupon-del-box .coupon-content.code .input-box .convert-btn {
	display: inline-block;
	width: 124px;
	height: 48px;
	line-height: 22px;
	font-size: 16px;
	color: #fff;
	padding: 12px;
	background: #f01414;
	border-radius: 8px;
	margin-left: 24px;
	box-sizing: border-box;
	text-align: center;
	cursor: pointer
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box p {
	line-height: 24px;
	font-size: 16px;
	color: #07111b;
  margin-top: 10px;
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .c_name,
.coupons-box .coupon-del-box .coupon-content.code .converted-box .code-num {
	padding-left: 8px
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .cancel-btn {
	background: #fff;
	border: 1px solid #d9dde1;
	line-height: 20px;
	padding: 2px 12px;
	text-align: center;
	border-radius: 4px;
	color: #f01414;
	font-size: 14px;
	margin-left: 16px;
	cursor: pointer
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .course-title {
	font-size: 14px;
	color: #07111b;
	font-weight: 600;
	margin-top: 12px
}

.coupons-box .coupon-del-box .coupon-content.code .converted-box .course-title .discount-cash {
	margin-left: 12px;
	color: #f01414
}

.coupons-box .coupon-del-box .coupon-content.code .error-msg {
	font-size: 14px;
	color: #f01414;
	margin-top: 8px;
	line-height: 20px;
	height: 20px
}

.coupons-box .coupon-del-box .coupon-content.code .tip {
	font-size: 14px;
	color: #93999f;
	margin-top: 8px;
	line-height: 20px
}


.coupons-box .content-box ul {
	width: 100%
}
.coupons-box .content-box .nouse-box::after,
.coupons-box .content-box .overdue-box::after,
.coupons-box .content-box .use-box::after {
  display: block;
  content: "";
  overflow: hidden;
  clear: both;
}
.coupons-box .content-box .nouse-box li,
.coupons-box .content-box .overdue-box li,
.coupons-box .content-box .use-box li {
	position: relative;
	padding: 24px 32px;
	margin-right: 16px;
	margin-bottom: 16px;
	width: 320px;
	height: 144px;
	border-radius: 8px;
	box-sizing: border-box;
	background-color: #fff;
	box-shadow: 0 8px 16px 0 rgba(7,17,27, .2);
	background-repeat: no-repeat;
	background-size: 320px 144px;
}
.coupons-box .content-box .nouse-box li.select{
  background-color: orangered;
}
.coupons-box .content-box .nouse-box li .detail-box,
.coupons-box .content-box .overdue-box li .detail-box,
.coupons-box .content-box .use-box li .detail-box {
	width: 100%;
	height: 100%
}

.coupons-box .content-box .nouse-box li .detail-box .price-box,
.coupons-box .content-box .overdue-box li .detail-box .price-box,
.coupons-box .content-box .use-box li .detail-box .price-box {
	margin-bottom: 8px;
	height: 40px;
	color: #93999f;
	line-height: 40px;
	font-weight: 700
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .coupon-price,
.coupons-box .content-box .overdue-box li .detail-box .price-box .coupon-price,
.coupons-box .content-box .use-box li .detail-box .price-box .coupon-price {
	margin-right: 12px;
	font-size: 36px;
  margin-top: 5px;
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .use-inst,
.coupons-box .content-box .overdue-box li .detail-box .price-box .use-inst,
.coupons-box .content-box .use-box li .detail-box .price-box .use-inst {
	font-size: 14px
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box,
.coupons-box .content-box .use-box li .detail-box .use-detail-box {
	font-size: 12px;
	color: #93999f;
	line-height: 24px
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box {
	position: relative
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box i,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box i,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box i {
	position: relative;
	top: 3px;
	left: 0;
	font-size: 16px;
	color: #93999f;
	line-height: 24px;
	cursor: pointer
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a {
	padding: 16px 0;
	width: 100%;
	display: block;
	font-size: 12px;
	color: #4d555d;
	line-height: 20px;
	border-bottom: 1px solid #d9dde1;
	box-sizing: border-box
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a:hover {
	color: #07111b
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child,
.coupons-box .content-box .overdue-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child,
.coupons-box .content-box .use-box li .detail-box .use-detail-box .use-ajust-box .use-course a:last-child {
	border-bottom: none
}

.coupons-box .content-box li {
	background-image: url(/src/assets/coupons_bg.png)
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .coupon-price {
	color: #f01414
}

.coupons-box .content-box .nouse-box li .detail-box .price-box .use-inst {
	color: #f01414
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box {
	color: #07111b
}

.coupons-box .content-box .nouse-box li .detail-box .use-detail-box .use-ajust-box i {
	color: #4d555d
}

.coupons-box .content-box .nouse-box li.wait-use {
	background-image: url(/src/assets/coupon_start_bg.png)
}

.coupons-box .content-box .use-box li {
	background-image: url(/src/assets/coupons_used_bg.png)
}

.coupons-box .content-box .use-box li.useing {
	background-image: url(/src/assets/coupon_useing_bg.png)
}

.coupons-box .content-box .overdue-box li {
	background-image: url(/src/assets/coupons_overdue.png)
}

.tip-box ol {
	margin-top: 16px;
	width: 100%;
	list-style: decimal;
	margin-left: 14px;
	box-sizing: border-box
}

.tip-box ol li {
	font-size: 12px
}

.pay-box {
	margin-top: 36px;
	position: relative
}

.pay-box::after,
.goods-total-price-box::after,
.package-discount-box::after,
.pay-price-box::after,
.coupons-discount-box::after{
  display: block;
  content: "";
  clear: both;
  overflow: hidden;
}

.pay-box .rw {
	width: 140px;
	box-sizing: border-box;
	text-align: right
}

.pay-box .bargain-discount-box,.pay-box .coupons-discount-box,.pay-box .goods-total-price-box,.pay-box .package-discount-box,.pay-box .redpackage-discount-box,.pay-box .student-discount-box {
	margin-bottom: 12px;
	line-height: 26px
}

.pay-box .bargain-discount-box .price-num,.pay-box .coupons-discount-box .price-num,.pay-box .goods-total-price-box .price-num,.pay-box .package-discount-box .price-num,.pay-box .redpackage-discount-box .price-num,.pay-box .student-discount-box .price-num {
	position: relative;
	font-size: 14px;
	color: #07111b
}

.pay-box .bargain-discount-box .price-text,.pay-box .coupons-discount-box .price-text,.pay-box .goods-total-price-box .price-text,.pay-box .package-discount-box .price-text,.pay-box .redpackage-discount-box .price-text,.pay-box .student-discount-box .price-text {
	text-align: right;
	font-size: 14px;
	color: #07111b
}

.pay-box .bargain-discount-box .price-text span,.pay-box .coupons-discount-box .price-text span,.pay-box .goods-total-price-box .price-text span,.pay-box .package-discount-box .price-text span,.pay-box .redpackage-discount-box .price-text span,.pay-box .student-discount-box .price-text span {
	margin-left: 4px;
	margin-right: 4px
}

.pay-box .pay-add-sign {
	text-align: right;
	position: absolute;
	top: -10px
}

.pay-box .pay-add-sign li {
	float: left;
	padding: 0 12px;
	height: 26px;
	line-height: 26px;
	border: 1px solid #f01414;
	border-radius: 18px;
	font-size: 12px;
	color: #f01414;
	margin-right: 15px
}

.pay-box .pay-add-sign li.drawback {
	position: relative
}

.pay-box .pay-add-sign li.drawback .imv2-ques {
	position: absolute;
	top: -4px;
	right: -2px;
	background: #fff;
	color: #d7dbdf;
	font-size: 14px;
	display: inline-block;
	width: 14px;
	height: 14px;
	cursor: pointer
}

.pay-box .pay-add-sign li.drawback .imv2-ques:hover {
	color: #f20d0d
}

.pay-box .pay-add-sign a.checkbackbtn {
	display: none;
	color: #fff;
	font-size: 12px;
	text-align: center;
	border-radius: 8px;
	vertical-align: top;
	position: absolute;
	left: 100%;
	top: -12px;
	background: rgba(28,31,33,.25);
	width: 100px;
	height: 26px;
	line-height: 26px;
	margin-left: 8px
}

.pay-box .pay-add-sign a.checkbackbtn i.arrow {
	width: 0;
	height: 0;
	border-top: 5px solid transparent;
	border-right: 5px solid;
	border-bottom: 5px solid transparent;
	position: absolute;
	left: -5px;
	top: 8px;
	border-right-color: rgba(28,31,33,.25)
}

.pay-box .pay-price-box {
	color: #07111b
}

.pay-box .pay-price-box .price {
	position: relative;
	color: #f01414;
	font-size: 24px;
	font-weight: 700;
  line-height: 36px;
  height: 36px;
}
.pay-box .pay-price-box .price-text{
  line-height: 36px;
  height: 36px;
}
.pay-box .pay-price-box .price span {
	float: none;
	font-weight: 700
}

.pay-box .pay-account {
	font-size: 12px;
	color: #93999f;
	line-height: 24px;
	margin-bottom: 20px;
	margin-top: 15px
}

.pay-box .submit-btn {
	padding: 0;
	width: 140px;
	height: 40px;
	margin-top: 12px;
	text-align: center;
	font-size: 14px;
	line-height: 40px;
	border-radius: 24px
}

.pay-box .disabled {
	background: #ccc;
	cursor: not-allowed;
	border: none
}

.pay-box .presale-wrap {
	text-align: right
}

.pay-box .presale-wrap .submit-btn {
	margin-top: 24px
}

.pay-box .presale-box {
	display: inline-block;
	font-size: 0;
	text-align: left
}

.pay-box .presale-box .step {
	width: 213px;
	padding-bottom: 10px;
	position: relative
}

.pay-box .presale-box .step .title {
	font-size: 14px;
	color: #07111b;
	line-height: 26px
}

.pay-box .presale-box .step .title .price {
	color: #93999f;
	float: right
}

.pay-box .presale-box .step .title .price.active {
	color: #f01414
}

.pay-box .presale-box .step .desc {
	font-size: 12px;
	color: #93999f;
	line-height: 16px
}

.pay-box .presale-box .step:nth-child(3) .price {
	color: #f01414;
	font-size: 24px;
	font-weight: 700
}

.pay-box .presale-box .step .step-line {
	position: absolute;
	top: 8px;
	left: -16px;
	width: 9px;
	display: flex;
	flex-direction: column;
	align-items: center
}

.pay-box .presale-box .step .step-line .circle {
	width: 9px;
	height: 9px;
	border-radius: 50%;
	background: rgba(147,153,159,.3)
}

.pay-box .presale-box .step .step-line .circle.active {
	background: #f01414
}

.pay-box .presale-box .step .step-line .line {
	height: 43px;
	border-left: 1px dashed rgba(147,153,159,.3)
}

.pay-box .presale-box .step .step-line .line.short {
	height: 27px
}

.pay-box.fixed {
	position: fixed;
	bottom: 0;
	left: 0;
	width: 100%;
	height: 80px;
	line-height: 80px;
	background-color: #fff;
	z-index: 300;
	box-shadow: 10px -2px 12px rgba(7,17,27,.2);
  padding-top: 10px;
}

.pay-box.fixed .row-bottom {
	max-width: 1500px;
	position: relative;
	margin: 0 auto;
}

.pay-box.fixed .row-bottom .row {
	float: left
}

.pay-box.fixed .row-bottom .bargain-discount-box,.pay-box.fixed .row-bottom .coupons-discount-box,.pay-box.fixed .row-bottom .js-total-hide,.pay-box.fixed .row-bottom .package-discount-box {
	display: none
}

.pay-box.fixed .bargain-discount-box,.pay-box.fixed .coupons-discount-box,.pay-box.fixed .goods-total-price-box,.pay-box.fixed .package-discount-box,.pay-box.fixed .pay-add-sign,.pay-box.fixed .pay-price-box,.pay-box.fixed .redpackage-discount-box {
	float: left;
	margin-bottom: 0
}

.pay-box.fixed .coupons-discount-box,.pay-box.fixed .package-discount-box,.pay-box.fixed .redpackage-discount-box {
	margin-left: 20px
}

.pay-box.fixed .goods-total-price-box {
	width: auto
}

.pay-box.fixed .rw {
	text-align: left;
	width: auto
}

.pay-box.fixed .price,.pay-box.fixed .price-num,.pay-box.fixed .price-text {
	line-height: 80px
}

.pay-box.fixed .pay-add-sign {
	position: static!important;
	margin-left: 20px
}

.pay-box.fixed .pay-add-sign li {
	float: left;
	padding: 0 12px;
	height: 26px;
	line-height: 26px;
	border: 1px solid #f01414;
	border-radius: 18px;
	font-size: 12px;
	color: #f01414;
	margin: 27px 20px 27px 0
}

.pay-box.fixed .pay-price-box {
	width: auto;
	margin-left: 20px
}

.pay-box.fixed .submit-btn {
	margin-top: 16px;
	width: 148px;
	height: 48px;
	line-height: 48px;
	font-size: 16px;
	border-radius: 24px
}

.pay-box.fixed .presale-wrap {
	float: left;
	text-align: left
}

.pay-box.fixed .presale-wrap .presale-box {
	height: 80px;
	display: flex;
	align-items: center
}

.pay-box.fixed .presale-wrap .presale-box .step {
	padding-right: 38px;
	padding-bottom: 0;
	width: auto;
	min-width: 118px;
	height: 45px
}

.pay-box.fixed .presale-wrap .presale-box .step:nth-child(3) {
	height: auto
}

.pay-box.fixed .presale-wrap .presale-box .step .title {
	float: none;
	background: #fff
}

.pay-box.fixed .presale-wrap .presale-box .step .title .price {
	line-height: 26px;
	float: none
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line {
	flex-direction: row;
	width: 100%;
	left: -14px
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line .line {
	border-left: none;
	border-top: 1px dashed rgba(147,153,159,.3);
	width: 30px;
	height: 1px;
	position: absolute;
	right: 5px
}

.pay-box.fixed .presale-wrap .presale-box .step .step-line .circle:nth-child(3) {
	position: absolute;
	right: -10px
}

.btn {
  position: relative;
  display: inline-block;
  margin-bottom: 0;
  text-align: center;
  vertical-align: middle;
  touch-action: manipulation;
  text-decoration: none;
  box-sizing: border-box;
  background-image: none;
  -webkit-appearance: none;
  white-space: nowrap;
  outline: none;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  border-style: solid;
  border-width: 1px;
  cursor: pointer;
  transition: all .3s;
  color: #545c63;
  background-color: transparent;
  border-color: #9199a1;
  opacity: 1;
  padding: 7px 16px;
  font-size: 14px;
  line-height: 1.42857143;
  border-radius: 18px;
}

.btn-red {
  border-style: solid;
  border-width: 1px;
  cursor: pointer;
  -moz-transition: all .3s;
  transition: all .3s;
  color: #fff;
  background-color: #f20d0d;
  border-color: #f20d0d;
  opacity: 1;
}
.btn-red:hover {
  color: #fff;
  border-color: #c20a0a;
  background: #c20a0a;
  opacity: 1;
}
.pay-type {
  margin-top: 28px;
  margin-left: 12px;
}
.pay-type .title {
  margin-top: 28px;
}
.pay-type .list {
  padding-top: 20px;
}

.pay-type .list img {
  margin-right: 10px;
}
</style>

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端展示下单结算页面"
git push

展示购物车勾选商品列表

服务端实现购物车勾选商品列表的api接口

cart/views,视图,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course


# .... 中间代码省略

class CartOrderAPIView(APIView):
    """购物车确认下单接口"""
    # 保证用户必须是登录状态才能调用当前视图
    permission_classes = [IsAuthenticated]

    def get(self,request):
        """获取勾选商品列表"""
        # 查询购物车中的商品课程ID列表
        user_id = request.user.id
        redis = get_redis_connection("cart")
        cart_hash = redis.hgetall(f"cart_{user_id}")
        """
        cart_hash = {
            # b'商品课程ID': b'勾选状态', 
            b'2': b'1', 
            b'4': b'1', 
            b'5': b'1'
        }
        """
        if len(cart_hash) < 1:
            return Response({"errmsg": "购物车没有任何商品。"}, status=status.HTTP_204_NO_CONTENT)

        # 把redis中的购物车勾选课程ID信息转换成普通列表
        cart_list = [int(course_id.decode()) for course_id, selected in cart_hash.items() if selected == b'1']

        course_list = Course.objects.filter(pk__in=cart_list, is_deleted=False, is_show=True).all()

        # 把course_list进行遍历,提取课程中的信息组成列表
        data = []
        for course in course_list:
            data.append({
                "id": course.id,
                "name": course.name,
                "course_cover": course.course_cover.url,
                "price": float(course.price),
                "discount": course.discount,
                "course_type": course.get_course_type_display(),
            })

        # 返回客户端
        return Response({"errmsg": "ok!", "cart": data})

cart/urls.py,路由,代码:

from django.urls import path
from . import views
urlpatterns = [
    path("", views.CartAPIView.as_view()),
    path("order/", views.CartOrderAPIView.as_view()),
]

客户端获取购物车勾选商品的数据

api/cart.js,代码:

import http from "../utils/http";
import {reactive, ref} from "vue"

const cart = reactive({
    // ... 中间代码省略
    select_course_list: [], // 购物车中被勾选的商品磕碜列表
    // ... 中间代码省略
    get_select_course(token){
        // 获取购物车中被勾选的商品列表
        return http.get("/cart/order/", {
            headers:{
                Authorization: "jwt " + token,
            }
        })
    }
})

export default cart;

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
})

export default order;

views/Order.vue,代码:

<template>
  ....
      <div class="cart-body" id="cartBody">
        <div class="cart-body-title"><p class="item-1 l">课程信息</p></div>
        <div class="cart-body-table">
          <div class="item" v-for="course_info in cart.select_course_list">
              <div class="item-2">
                  <router-link :to="`/project/${course_info.id}`" class="img-box l"><img :src="course_info.course_cover"></router-link>
                  <dl class="l has-package">
                    <dt>【{{course_info.course_type}}】{{course_info.name}} </dt>
                    <p class="package-item" v-if="course_info.discount.type">{{course_info.discount.type}}</p>
                  </dl>
              </div>
              <div class="item-3">
                  <div class="price">
                      <p class="discount-price" v-if="course_info.discount.price>=0"><em>¥</em><span>{{course_info.discount.price.toFixed(2)}}</span></p>
                      <p :class="{'original-price': course_info.discount.price>=0}"><em>¥</em><span>{{course_info.price.toFixed(2)}}</span></p>
                  </div>
              </div>
      .....
</template>
<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";

// let store = useStore()

const get_select_course = ()=>{
    // 获取购物车中的勾选商品列表
    let token = sessionStorage.token || localStorage.token;
    cart.get_select_course(token).then(response=>{
        cart.select_course_list = response.data.cart
    })
}

get_select_course();


// 监听用户选择的支付方式
watch(
    ()=>order.pay_type,
    ()=>{
      console.log(order.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  order.fixed = offsetY < maxY
}
</script>

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 确认下单页面中展示购物车勾选商品列表"
git push

订单生成

创建订单子应用

完成了勾选商品列表展示以后,因为优惠券或积分属于增值业务,所以可以先把优惠券功能和积分功能延后处理,先完成主流程中的订单生成功能。同时,为了方便以后项目的代码管理和维护,我们再次创建子应用orders来完成接下来的订单功能。

# 确认前面功能已经开发完整,review代码结束,向公司申请合并分支,开发合并分支
git checkout master
git merge feature/cart
# 查看线上本地所有的分支列表,可以看到本地的feature/user分支已经删除,但是线上的依然存在。
git branch --all
git branch -d feature/cart
# 本地删除了分支以后,线上分支也要同步一下。
git push origin --delete feature/cart
# 因为属于一个较大功能的开发合并,往往项目中都会打一个标签
git tag v0.0.4
# 提交标签版本
git push --tag
# git push origin v0.0.4

# 后续的功能属于购物流程里面的订单生成部分了
git checkout -b feature/order

# 创建订单子应用
cd luffycityapi/luffycityapi/apps
python ../../manage.py startapp orders

注册子应用,settings/dev.py,代码:

INSTALLED_APPS = [
    # 子应用
	。。。
    
    'orders',
]

子路由,orders/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [
    
]

总路由,luffycityapi/urls.py,代码:

    path("orders/", include("orders.urls")),

订单模型

订单相关的模型分析:

订单基本信息:订单ID,支付方式,订单状态,支付时间,订单总价格,实付价格,订单标题,订单号,用户ID等等
订单项详情(订单与商品的关系):商品ID,商品原价、商品实价,优惠方式,订单ID等等

用户课程(用户与课程的关系):用户ID,课程ID,学习总时长等等
用户学习课程的进度跟踪记录(用户与课时的关系):用户ID,课时ID,课程ID,章节ID,学习进度(视频进度),学习时间等等

优惠券:优惠券标题、优惠券面额、优惠券优惠方式、优惠类型、领取方式(用户领取,系统发放)、起用时间、过期时间等等
用户的优惠券(用户与优惠券的关系):  用户ID,优惠券ID,领取时间等等。(我们采用redis来记录)
优惠券的使用记录(用户的优惠券与订单的关系):用户ID,优惠券ID、使用状态、订单ID等等。
积分流水:操作方式、积分面值、用户ID、订单ID等等。
余额流水:操作方式、货币面值、用户ID、订单ID等等。

为什么有订单号?

原因是支付平台需要记录每一个商家的资金流水,所以需要我们这边提供一个足够复杂的流水号和支付平台保持一致。
所以订单号是支付平台那边强制要求在支付时提供给平台的。用于对账。

orders/models.py,订单模型,代码:

from models import BaseModel,models
from users.models import User
from courses.models import Course
# Create your models here.


class Order(BaseModel):
    """订单基本信息模型"""
    status_choices = (
        # 模型对象.<字段名>                   获取元组的第一个成员
        # 模型对象.get_<字段名>_display()     获取元组的第二个成员
        (0, '未支付'),
        (1, '已支付'),
        (2, '已取消'),
        (3, '超时取消'),
    )
    pay_choices = (
        (0, '支付宝'),
        (1, '微信'),
        (2, '余额'),
    )

    total_price = models.DecimalField(default=0, max_digits=10, decimal_places=2, verbose_name="订单总价")
    real_price = models.DecimalField(default=0, max_digits=10, decimal_places=2, verbose_name="实付金额")
    order_number = models.CharField(max_length=64, verbose_name="订单号")
    order_status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="订单状态")
    pay_type = models.SmallIntegerField(choices=pay_choices, default=1, verbose_name="支付方式")
    order_desc = models.TextField(null=True, blank=True, max_length=500, verbose_name="订单描述")
    pay_time = models.DateTimeField(null=True, blank=True, verbose_name="支付时间")
    user = models.ForeignKey(User, related_name='user_orders', on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="下单用户")

    class Meta:
        db_table = "ly_order"
        verbose_name = "订单记录"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s,总价: %s,实付: %s" % (self.name, self.total_price, self.real_price)


class OrderDetail(BaseModel):
    """
    订单详情
    """
    order = models.ForeignKey(Order, related_name='order_courses', on_delete=models.CASCADE, db_constraint=False, verbose_name="订单")
    course = models.ForeignKey(Course, related_name='course_orders', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程")
    price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程原价")
    real_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程实价")
    discount_name = models.CharField(max_length=120,default="",verbose_name="优惠类型")

    class Meta:
        db_table = "ly_order_course"
        verbose_name = "订单详情"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.course.name


数据迁移:

cd ../../
python manage.py makemigrations
python manage.py migrate

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature:订单子应用创建以及订单信息和订单项模型的创建"
git push --set-upstream origin feature/order

把订单子应用相关的模型注册到admin管理站点

orders/admin.py,代码:

from django.contrib import admin
from .models import Order, OrderDetail


# class OrderDetailInLine(admin.StackedInline):
class OrderDetailInLine(admin.TabularInline):
    """订单项的内嵌类"""
    model = OrderDetail
    fields = ["course", "price", "real_price", "discount_name"]
    # readonly_fields = ["discount_name"]


class OrderModelAdmin(admin.ModelAdmin):
    """订单信息的模型管理器"""
    list_display = ["id","order_number","user","total_price","total_price","order_status"]
    inlines = [OrderDetailInLine, ]


admin.site.register(Order, OrderModelAdmin)


orders/apps.py,代码:

from django.apps import AppConfig

class OrdersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'orders'
    verbose_name = "订单管理"
    verbose_name_plural = verbose_name

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature:把订单子应用相关的模型注册到admin管理站点"
git push

服务端提供创建订单的api接口

orders/views.py,代码:

from rest_framework.generics import CreateAPIView
from .models import Order
from .serializers import OrderModelSerializer
from rest_framework.permissions import IsAuthenticated


# Create your views here.
class OrderCreateAPIView(CreateAPIView):
    """创建订单"""
    permission_classes = [IsAuthenticated]
    queryset = Order.objects.all()
    serializer_class = OrderModelSerializer


子路由,orders/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [
    path("", views.OrderCreateAPIView.as_view()),
]

序列化器,orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from .models import Order, OrderDetail, Course


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 创建订单记录
        order = Order.objects.create(
            name="购买课程",  # 订单标题
            user_id=user_id,  # 当前下单的用户ID
            # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
            order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
            pay_type=validated_data.get("pay_type"),  # 支付方式
        )

        # 记录本次下单的商品列表
        cart_hash = redis.hgetall(f"cart_{user_id}")
        if len(cart_hash) < 1:
            raise serializers.ValidationError(detail="购物车没有要下单的商品")

        # 提取购物车中所有勾选状态为b'1'的商品
        course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

        # 添加订单与课程的关系
        course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
        detail_list = []
        total_price = 0 # 本次订单的总价格
        real_price = 0  # 本次订单的实付总价

        for course in course_list:
            discount_price = float(course.discount.get("price", 0)) # 获取课程原价
            discount_name = course.discount.get("type", "")
            detail_list.append(OrderDetail(
                order=order,
                course=course,
                name=course.name,
                price=course.price,
                real_price=discount_price,
                discount_name=discount_name,
            ))

            # 统计订单的总价和实付总价
            total_price += float(course.price)
            real_price += discount_price if discount_price > 0 else float(course.price)

        # 一次性批量添加本次下单的商品记录
        OrderDetail.objects.bulk_create(detail_list)

        # 保存订单的总价格和实付价格
        order.total_price = total_price
        order.real_price = real_price
        order.save()

        # todo 支付链接地址[后面实现支付功能的时候,再做]
        order.pay_link = ""
        return order


生成订单时,在序列化器中要接收客户端用户的user_id

用户ID在序列化器中接收到视图中的数据,那么在序列化器初始化的时候,其实有3个参数可以填写:
   1. instance 模型对象,数据模型,
   2. data     字典,客户端提交数据,
   3. context  字典,额外参数[执行上下文],如果要自定义参数,可以直接通过字典格式声明,然后到context
   
   OrderModerSerializer(instance="模型对象",data="客户端数据", context={})
    
利用序列化器初始化时提供的第三个参数就可以调用到视图类的
   context的属性          描述                       序列化器中的调用代码
       request    本次客户端的请求对象			self.context["request"]
       format     本次服务器响应的数据格式		   self.context["format"]
       view       调用当前序列化器的视图类          self.context["view"]
   
因此,我们要在序列化器中提取用户的id,代码如下:
   user_id = self.context["request"].user.id

1662863444522

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature:服务端提供创建订单的API接口"
git push

上面我们完成了订单信息的添加,但是下单不是一个数据记录而已,而是多张表记录的同时添加操作。所以针对这种多个记录或者多张表连贯进行的操作,为了保证数据的完整性和一致性以及原子性,我们要使用数据库的事务(Transaction)来完成,当然我们这个项目中不需要使用到数据库原生的事务语句,而是使用django的ORM提供的事务模块即可。

事务(Transaction),是以功能或业务作为逻辑单位,把一条或多条SQL语句组成一个不可分割的操作序列来执行的数据库特性。
在完成一个整体功能时,操作到了多个表数据,或者同一个表的多条记录,如果要保证这些SQL语句操作作为一个整体保存到数据库中,那么可以使用事务(transaction),保证这些操作作为不可分割的整体,要么一起成功,要么一起失败。

事务具有4个特性(ACID),5个隔离等级
  
  四个特性:一致性,原子性,隔离性,持久性
  # 隔离性:两个事务的隔离性,隔离性的修改可以通过数据库的配置文件mysqld.cnf进行修改,默认mysql是属于可重复级别
  五个隔离级别(从高到低): 串行隔离,可重复读,已提交读,未提交读,没有隔离
    原子性(Atomicity)
    一致性(Consistency)
    隔离性(Isolation)[事务隔离级别->幻读,脏读, 不可重复读]
    持久性(Durability)

  在mysql中有专门的SQl语句来完成事务的操作,事务的代码操作一般有3个步骤:
     设置事务开始  begin;
	 事务的处理[mysql:增删改]
         redis.sadd()
         事务的处理[mysql:增删改]
     设置事务的回滚或者提交 rollback / commit;  # 这个事务过程中,事务无法对mysql数据库以外的其他类型的数据库操作进行管理和回滚
mysql中底层的事务是如何实现事务的回滚操作:undo.log重做日志
在ORM框架一般都会实现了事务操作封装,所以我们可以直接使用ORM框架即可完成事务的操作

django框架本身就提供了2种事务操作的写法,主要都是通过 django.db.transaction模块完成的。

启用事务写法1:基于装饰器对函数或方法进行事务管理:

from django.db import transaction
from rest_framework.views import APIView
class OrderAPIView(APIView):
    @transaction.atomic          # 开启事务,当函数/方法执行完成以后,自动提交事务
    def post(self,request):      # 不一定是视图方法,也可以是其他函数方法。
        ....  # 在整个函数或者方法中,进行的所有SQL数据写操作[增删改],都属于同一个事务操作

启用事务写法2,基于with上下文管理器进行事务管理:

from django.db import transaction
from rest_framework.views import APIView
class OrderAPIView(APIView):
    def post(self,request):
        .... # 事务以外的,其他的SQL数据操作
        with transation.atomic(): # 开启事务,当with语句执行完成以后,自动提交事务
            # 数据库操作【DML增删改】
            
        .... # with语句以外的其他的SQL数据操作,无法被上面事务管理

在使用事务过程中, 有时候会出现异常,当出现异常时我们需要回滚事务。

from django.db import transaction
from rest_framework.generics import CreateAPIView
class OrderCreateAPIView(CreateAPIView):
    def post(self,request):
        ....
        with transaction.atomic():
            # 1、设置事务回滚的标记点【一个事物中可以设置多个回滚标记】
            sid1 = transaction.savepoint()
            try:
                .... # 增删改等数据库操作
                ....
            except:
                transaction.savepoint_rallback(sid1)

        .... # 数据库操作,注意,如果这里被执行,因为没有在with里面,所以是不会被上面的事务操作影响。

django的事务操作是支持嵌套事务的,但是mysql本身不支持嵌套事务。

from django.db import transaction
from rest_framework.generics import CreateAPIView
class OrderCreateAPIView(CreateAPIView):
    def post(self,request):
        ....
        with transaction.atomic():
            # 1、设置事务回滚的标记点【一个事物中可以设置多个回滚标记】
            sid1 = transaction.savepoint()

            try:
                .... # 增删改等数据库操作
                ....
                with transaction.atomic():
                    # 2. 设置回滚点
                    sid2 = transaction.savepoint()
                    try:
                        .... # 其他内部数据库处理
                        ....
                    except:
                        transaction.savepoint_rallback(sid2)
            except:
                transaction.savepoint_rallback(sid1)
            
        .... # 数据库操作,注意,如果这里被执行,因为没有在with里面,所以是不会被上面的事务操作影响。

使用Django的ORM提供的mysql事务操作保证下单过程中的数据原子性

orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
import logging

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                order = Order.objects.create(
                    name="购买课程",  # 订单标题
                    user_id=user_id,  # 当前下单的用户ID
                    # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
                    order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
                    pay_type=validated_data.get("pay_type"),  # 支付方式
                )

                # 记录本次下单的商品列表
                cart_hash = redis.hgetall(f"cart_{user_id}")
                if len(cart_hash) < 1:
                    raise serializers.ValidationError(detail="购物车没有要下单的商品")

                # 提取购物车中所有勾选状态为b'1'的商品
                course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

                # 添加订单与课程的关系
                course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
                detail_list = []
                total_price = 0 # 本次订单的总价格
                real_price = 0  # 本次订单的实付总价

                for course in course_list:
                    discount_price = float(course.discount.get("price", 0)) # 获取课程原价
                    discount_name = course.discount.get("type", "")
                    detail_list.append(OrderDetail(
                        order=order,
                        course=course,
                        name=course.name,
                        price=course.price,
                        real_price=discount_price,
                        discount_name=discount_name,
                    ))

                    # 统计订单的总价和实付总价
                    total_price += float(course.price)
                    real_price += discount_price if discount_price > 0 else float(course.price)

                # 一次性批量添加本次下单的商品记录
                OrderDetail.objects.bulk_create(detail_list)

                # 保存订单的总价格和实付价格
                order.total_price = total_price
                order.real_price = real_price
                order.save()

                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""
                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")


购物车中选中的商品被记录到了订单中,那么购物车中原来的勾选商品是否要删除?

如果不删除,那么订单中的商品与购物车中就重复了,所以要删除,购物车中只需要保留没有勾选过的商品。

orders/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
import logging

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                order = Order.objects.create(
                    name="购买课程",  # 订单标题
                    user_id=user_id,  # 当前下单的用户ID
                    # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
                    order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
                    pay_type=validated_data.get("pay_type"),  # 支付方式
                )

                # 记录本次下单的商品列表
                cart_hash = redis.hgetall(f"cart_{user_id}")
                if len(cart_hash) < 1:
                    raise serializers.ValidationError(detail="购物车没有要下单的商品")

                # 提取购物车中所有勾选状态为b'1'的商品
                course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

                # 添加订单与课程的关系
                course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
                detail_list = []
                total_price = 0 # 本次订单的总价格
                real_price = 0  # 本次订单的实付总价

                for course in course_list:
                    discount_price = float(course.discount.get("price", 0)) # 获取课程原价
                    discount_name = course.discount.get("type", "")
                    detail_list.append(OrderDetail(
                        order=order,
                        course=course,
                        name=course.name,
                        price=course.price,
                        real_price=discount_price,
                        discount_name=discount_name,
                    ))

                    # 统计订单的总价和实付总价
                    total_price += float(course.price)
                    real_price += discount_price if discount_price > 0 else float(course.price)

                # 一次性批量添加本次下单的商品记录
                OrderDetail.objects.bulk_create(detail_list)

                # 保存订单的总价格和实付价格
                order.total_price = total_price
                order.real_price = real_price
                order.save()

                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""

                # 删除购物车中被勾选的商品,保留没有被勾选的商品信息
                cart = {key: value for key, value in cart_hash.items() if value == b'0'}
                pipe = redis.pipeline()
                pipe.multi()
                # 删除原来的购物车
                pipe.delete(f"cart_{user_id}")
                # 重新把未勾选的商品记录到购物车中
                pipe.hmset(f"cart_{user_id}", cart)  # hset 在新版本的redis中实际上hmset已经被废弃了,改用hset替代hmset
                pipe.execute()

                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")


提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature:服务端基于事务保证订单生成操作的原子性"
git push

客户端请求生成订单

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  use_coupon: false,   // 用户是否使用优惠
  discount_type: 0,    // 0表示优惠券,1表示积分
  coupon_list:[1,2,3], // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  create_order(token){
    // 生成订单
    return http.post("/orders/",{
        pay_type: this.pay_type
    },{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  }
})

export default order;

views/Order.vue,代码:

<span class="r btn btn-red submit-btn" @click="commit_order">提交订单</span>

<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import {useRouter} from 'vue-router'

const router = useRouter()

// let store = useStore()

const get_select_course = ()=>{
        // 获取购物车中的勾选商品列表
        let token = sessionStorage.token || localStorage.token;
        cart.get_select_course(token).then(response=>{
            cart.select_course_list = response.data.cart

            if(response.data.cart.length === 0){
                ElMessage.error('你好像没有选择商品哦,还要支付吗?');
                // router.push('/cart')效果等同于back
                router.back();
            }
        }).catch(error=>{
            if(error?.response?.status===400){
                ElMessage.error("登录超时啦!请重新登录后再继续操作哦客官~");
                router.push('/login')
            }
    })
    }

get_select_course();


const commit_order = ()=>{
        // 提交生成订单
        let token=sessionStorage.token || localStorage.token;
        order.create_order(token).then(response=>{
            console.log('订单号',response.data.order_number)  // todo 订单号
            console.log('支付链接',response.data.pay_link)      // todo 支付链接
            // 成功提示
            ElMessage.success("下单成功啦!马上跳转到支付页面,请稍候客官~")
            // 扣除掉被下单的商品数量,更新购物车中的商品数量
            store.commit("set_cart_total", store.state.cart_total - cart.select_course_list.length);
        }).catch(error=>{
            if(error?.response?.status===400){
                ElMessage.error("登录超时啦!请重新登录后再继续操作哦客官~");
                router.push('/login')
            }
        })
    }


// 监听用户选择的支付方式
watch(
    ()=>order.pay_type,
    ()=>{
      console.log(order.pay_type)
    }
)

// 底部订单总价信息固定浮动效果
window.onscroll = ()=>{
  let cart_body_table = document.querySelector(".cart-body-table")
  let offsetY = window.scrollY
  let maxY = cart_body_table.offsetTop+cart_body_table.offsetHeight
  order.fixed = offsetY < maxY
}
</script>

这里同时也将之前Cart.vue也优化一下

import {useRouter} from 'vue-router'
    
const router = useRouter()
    
const get_cart = ()=>{
      ....
            watch(
                [...cart.course_list],  // watch多个数据必须是数组结构,但是cart.course_list是由我们通过vue.reactive装饰成响应式对象了,所以需要转换
                ()=>{
                    get_cart_total();
                },
            ).catch(error => {
                ElMessage.error('登录超时啦!请重新登录后再继续操作哦客官~')
                router.push('/login')
            })
        })
    }

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature:客户端请求生成订单"
git push

优惠券

创建优惠券子应用

创建coupon子应用

git checkout master
git merge feature/order
git push 

git checkout -b feature/coupon
cd luffycityapi/apps
python ../../manage.py startapp coupon

注册子应用,settings/dev.py,代码:

INSTALLED_APPS = [
 
    # 子应用
	。。。
    'coupon',
]

子路由,coupon/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [
    
]

总路由,luffycityapi/urls.py,代码:

path("coupon/", include("coupon.urls")),

优惠券模型

模型分析:

image-20211129085657537

coupon/models.py,模型创建,代码:

from models import BaseModel, models
from courses.models import CourseDirection, CourseCategory, Course
from users.models import User
from orders.models import Order


# Create your models here.
class Coupon(BaseModel):
    discount_choices = (
        (1, '减免'),
        (2, '折扣'),
    )
    type_choices = (
        (0, '通用类型'),
        (1, '指定方向'),
        (2, '指定分类'),
        (3, '指定课程'),
    )
    get_choices = (
        (0, "系统赠送"),
        (1, "自行领取"),
    )
    discount = models.SmallIntegerField(choices=discount_choices, default=1, verbose_name="优惠方式")
    coupon_type = models.SmallIntegerField(choices=type_choices, default=0, verbose_name="优惠券类型")
    total = models.IntegerField(blank=True, default=100, verbose_name="发放数量")
    has_total = models.IntegerField(blank=True, default=100, verbose_name="剩余数量")
    start_time = models.DateTimeField(verbose_name="启用时间")
    end_time = models.DateTimeField(verbose_name="过期时间")
    get_type = models.SmallIntegerField(choices=get_choices, default=0, verbose_name="领取方式")
    condition = models.IntegerField(blank=True, default=0, verbose_name="满足使用优惠券的价格条件")
    per_limit = models.SmallIntegerField(default=1, verbose_name="每人限制领取数量")
    sale = models.TextField(verbose_name="优惠公式", help_text="""
            *号开头表示折扣价,例如*0.82表示八二折;<br>
            -号开头表示减免价,例如-10表示在总价基础上减免10元<br>   
            """)

    class Meta:
        db_table = "ly_coupon"
        verbose_name = "优惠券"
        verbose_name_plural = verbose_name


class CouponDirection(models.Model):
    direction = models.ForeignKey(CourseDirection, on_delete=models.CASCADE, related_name="to_coupon", verbose_name="学习方向", db_constraint=False)
    coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name="to_direction", verbose_name="优惠券", db_constraint=False)
    created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")

    class Meta:
        db_table = "ly_coupon_course_direction"
        verbose_name = "优惠券与学习方向"
        verbose_name_plural = verbose_name


class CouponCourseCat(models.Model):
    category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name="to_coupon", verbose_name="课程分类", db_constraint=False)
    coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name="to_category", verbose_name="优惠券", db_constraint=False)
    created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")

    class Meta:
        db_table = "ly_coupon_course_category"
        verbose_name = "优惠券与课程分类"
        verbose_name_plural = verbose_name


class CouponCourse(models.Model):
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="to_coupon", verbose_name="课程", db_constraint=False)
    coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name="to_course", verbose_name="优惠券", db_constraint=False)
    created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")

    class Meta:
        db_table = "ly_coupon_course"
        verbose_name = "优惠券与课程信息"
        verbose_name_plural = verbose_name


class CouponLog(BaseModel):
    use_choices = (
        (0, "未使用"),
        (1, "已使用"),
        (2, "已过期"),
    )
    name = models.CharField(null=True, blank=True, max_length=100, verbose_name="名称/标题")
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="to_coupon", verbose_name="用户",
                             db_constraint=False)
    coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name="to_user", verbose_name="优惠券",
                               db_constraint=False)
    order = models.ForeignKey(Order, null=True, blank=True, default=None, on_delete=models.CASCADE,
                              related_name="to_coupon", verbose_name="订单", db_constraint=False)
    use_time = models.DateTimeField(null=True, blank=True, verbose_name="使用时间")
    use_status = models.SmallIntegerField(choices=use_choices, null=True, blank=True, default=0, verbose_name="使用状态")

    class Meta:
        db_table = "ly_coupon_log"
        verbose_name = "优惠券发放和使用日志"
        verbose_name_plural = verbose_name


数据迁移,终端下执行:

cd ../..
python manage.py makemigrations
python manage.py migrate 

把当前子应用注册到Admin管理站点

coupon/apps.py,代码:

from django.apps import AppConfig

class CouponConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'coupon'
    verbose_name = "优惠券管理"
    verbose_name_plural = verbose_name

coupon/admin.py,代码:

from django.contrib import admin
from .models import Coupon, CouponDirection, CouponCourseCat, CouponCourse, CouponLog


# Register your models here.
class CouponDirectionInLine(admin.TabularInline):  # admin.StackedInline
    """学习方向的内嵌类"""
    model = CouponDirection
    fields = ["id", "direction"]


class CouponCourseCatInLine(admin.TabularInline):  # admin.StackedInline
    """课程分类的内嵌类"""
    model = CouponCourseCat
    fields = ["id", "category"]


class CouponCourseInLine(admin.TabularInline):  # admin.StackedInline
    """课程信息的内嵌类"""
    model = CouponCourse
    fields = ["id", "course"]


class CouponModelAdmin(admin.ModelAdmin):
    """优惠券的模型管理器"""
    list_display = ["id", "name", "start_time", "end_time", "total", "has_total", "coupon_type", "get_type", ]
    inlines = [CouponDirectionInLine, CouponCourseCatInLine, CouponCourseInLine]


admin.site.register(Coupon, CouponModelAdmin)


class CouponLogModelAdmin(admin.ModelAdmin):
    """优惠券发放和使用日志"""
    list_display = ["id", "user", "coupon", "order", "use_time", "use_status"]


admin.site.register(CouponLog, CouponLogModelAdmin)

提交代码版本

/home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 创建优惠券子应用并设计优惠券的存储数据模型"
git push --set-upstream origin feature/coupon


实现后台管理员给用户分发优惠券时自动记录到redis中。

settings/dev.py,代码:

# 设置redis缓存
CACHES = {
    # 。。。
    # 提供存储优惠券
    "coupon": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/5",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
}



coupon/admin.py,代码:

from django.contrib import admin
from django_redis import get_redis_connection
from .models import Coupon,CouponDirection,CouponCourseCat,CouponCourse,CouponLog
from django.utils.timezone import datetime
import json

...

class CouponLogModelAdmin(admin.ModelAdmin):
    """优惠券发放和使用记录"""
    list_display = ["id","user","coupon","order","use_time","use_status"]
    def save_model(self,  request, obj, form, change):
        """
        保存或更新记录时自动执行的钩子
        request: 本次客户端提交的请求对象
        obj: 本次操作的模型实例对象
        form: 本次客户端提交的表单数据
        change: 值为True,表示更新数据,值为False,表示添加数据
        """
        obj.save()
        # 同步记录到redis中
        redis = get_redis_connection("coupon")
        # print(obj.use_status , obj.use_time)
        if obj.use_status == 0 and obj.use_time == None:
            # 记录优惠券信息到redis中
            pipe = redis.pipeline()
            pipe.multi()
            pipe.hset(f"{obj.user.id}:{obj.id}","coupon_id", obj.coupon.id)
            pipe.hset(f"{obj.user.id}:{obj.id}","name", obj.coupon.name)
            pipe.hset(f"{obj.user.id}:{obj.id}","discount", obj.coupon.discount)
            pipe.hset(f"{obj.user.id}:{obj.id}","get_discount_display", obj.coupon.get_discount_display())
            pipe.hset(f"{obj.user.id}:{obj.id}","coupon_type", obj.coupon.coupon_type)
            pipe.hset(f"{obj.user.id}:{obj.id}","get_coupon_type_display", obj.coupon.get_coupon_type_display())
            pipe.hset(f"{obj.user.id}:{obj.id}","start_time", obj.coupon.start_time.strftime("%Y-%m-%d %H:%M:%S"))
            pipe.hset(f"{obj.user.id}:{obj.id}","end_time", obj.coupon.end_time.strftime("%Y-%m-%d %H:%M:%S"))
            pipe.hset(f"{obj.user.id}:{obj.id}","get_type", obj.coupon.get_type)
            pipe.hset(f"{obj.user.id}:{obj.id}","get_get_type_display", obj.coupon.get_get_type_display())
            pipe.hset(f"{obj.user.id}:{obj.id}","condition", obj.coupon.condition)
            pipe.hset(f"{obj.user.id}:{obj.id}","sale", obj.coupon.sale)
            pipe.hset(f"{obj.user.id}:{obj.id}","to_direction", json.dumps(list(obj.coupon.to_direction.values("direction__id","direction__name"))))
            pipe.hset(f"{obj.user.id}:{obj.id}","to_category", json.dumps(list(obj.coupon.to_category.values("category__id","category__name"))))
            pipe.hset(f"{obj.user.id}:{obj.id}","to_course", json.dumps(list(obj.coupon.to_course.values("course__id","course__name"))))
            # 设置当前优惠券的有效期
            pipe.expire(f"{obj.user.id}:{obj.id}", int(obj.coupon.end_time.timestamp() - datetime.now().timestamp()))
            pipe.execute()
        else:
            redis.delete(f"{obj.user.id}:{obj.id}")

    def delete_model(self, request, obj):
        """删除记录时自动执行的钩子"""
        # 如果系统后台管理员删除当前优惠券记录,则redis中的对应记录也被删除
        print(obj, "详情页中删除一个记录")
        redis = get_redis_connection("coupon")
        redis.delete(f"{obj.user.id}:{obj.id}")
        obj.delete()

    def delete_queryset(self, request, queryset):
        """在列表页中进行删除优惠券记录时,也要同步删除容redis中的记录"""
        print(queryset, "列表页中删除多个记录")
        redis = get_redis_connection("coupon")
        for obj in queryset:
            redis.delete(f"{obj.user.id}:{obj.id}")
        queryset.delete()

admin.site.register(CouponLog, CouponLogModelAdmin)


添加测试数据,代码:

-- 优惠券测试数据
truncate table ly_coupon;
INSERT INTO ly_coupon (id, name, is_deleted, orders, is_show, created_time, updated_time, discount, coupon_type, total, has_total, start_time, end_time, get_type, `condition`, per_limit, sale) VALUES (1, '30元通用优惠券', 0, 1, 1, '2022-05-04 10:35:40.569417', '2022-06-30 10:25:00.353212', 1, 0, 10000, 10000, '2022-05-04 10:35:00', '2023-01-02 10:35:00', 0, 100, 1, '-30'),(2, '前端学习通用优惠券', 0, 1, 1, '2022-05-04 10:36:58.401527', '2022-05-04 10:36:58.401556', 1, 1, 100, 100, '2022-05-04 10:36:00', '2022-08-04 10:36:00', 0, 0, 1, '-50'),(3, 'Typescript课程专用券', 0, 1, 1, '2022-05-04 10:38:36.134581', '2022-05-04 10:38:36.134624', 2, 3, 1000, 1000, '2022-05-04 10:38:00', '2022-08-04 10:38:00', 0, 0, 1, '*0.88'),(4, 'python七夕专用券', 0, 1, 1, '2022-05-04 10:40:08.022904', '2022-06-30 10:25:46.949197', 1, 2, 200, 200, '2022-05-04 10:39:00', '2022-11-15 10:39:00', 1, 0, 1, '-99'),(5, '算法学习优惠券', 0, 1, 1, '2021-08-05 10:05:07.837008', '2022-06-30 10:26:12.133812', 2, 2, 1000, 1000, '2022-08-05 10:04:00', '2022-12-25 10:04:00', 0, 200, 1, '*0.85');

-- 优惠券与学习方向的关系测试数据
truncate table ly_coupon_course_direction;
INSERT INTO ly_coupon_course_direction (id, created_time, coupon_id, direction_id) VALUES (1, '2022-05-04 10:36:58.414461', 2, 1);

-- 优惠券与课程分类的关系测试数据
truncate table ly_coupon_course_category;
INSERT INTO ly_coupon_course_category (id, created_time, category_id, coupon_id) VALUES (1, '2022-05-04 10:40:08.029505', 20, 4),(2, '2022-05-04 10:40:08.042891', 21, 4),(3, '2021-08-05 10:05:07.966221', 33, 5);

-- 优惠券与课程信息的关系测试数据
truncate table ly_coupon_course;
INSERT INTO ly_coupon_course (id, created_time, coupon_id, course_id) VALUES (1, '2022-05-04 10:38:36.140929', 3, 1),(2, '2022-05-04 10:38:36.143166', 3, 2);

-- 优惠券的发放和使用日志的测试数据
truncate table ly_coupon_log;
INSERT INTO ly_coupon_log (id, is_deleted, orders, is_show, created_time, updated_time, name, use_time, use_status, coupon_id, order_id, user_id) VALUES (5, 0, 1, 1, '2022-05-04 12:00:25.051976', '2022-06-30 10:25:17.681298', '30元通用优惠券222', null, 0, 1, null, 1),(8, 0, 1, 1, '2022-05-04 12:03:24.331024', '2022-06-30 10:22:45.834401', '前端学习通用优惠券', null, 0, 2, null, 1),(9, 0, 1, 1, '2022-05-04 12:03:31.692397', '2022-06-30 10:23:41.492205', 'Typescript课程专用券', null, 0, 3, null, 1),(10, 0, 1, 1, '2022-05-04 12:03:38.225438', '2022-06-30 10:25:49.797318', 'python七夕专用券', null, 0, 4, null, 1),(11, 0, 1, 1, '2022-05-04 12:09:25.406437', '2022-06-30 10:23:55.832262', '前端学习通用优惠券', null, 0, 2, null, 1),(12, 0, 1, 1, '2021-08-05 10:06:06.036230', '2022-06-30 10:26:20.723668', '算法学习优惠券', null, 0, 5, null, 1);


---------------------------------------------------在SQL工具栏中操作-------------------------------------------------------------------

truncate table ly_coupon;
INSERT INTO luffycity.ly_coupon (id, name, is_deleted, orders, is_show, created_time, updated_time, discount, coupon_type, total, has_total, start_time, end_time, get_type, `condition`, per_limit, sale) VALUES (1, '30元通用优惠券', 0, 1, 1, '2022-05-04 10:35:40.569417', '2022-06-30 10:25:00.353212', 1, 0, 10000, 10000, '2022-05-04 10:35:00', '2023-01-02 10:35:00', 0, 100, 1, '-30'),(2, '前端学习通用优惠券', 0, 1, 1, '2022-05-04 10:36:58.401527', '2022-05-04 10:36:58.401556', 1, 1, 100, 100, '2022-05-04 10:36:00', '2022-08-04 10:36:00', 0, 0, 1, '-50'),(3, 'Typescript课程专用券', 0, 1, 1, '2022-05-04 10:38:36.134581', '2022-05-04 10:38:36.134624', 2, 3, 1000, 1000, '2022-05-04 10:38:00', '2022-08-04 10:38:00', 0, 0, 1, '*0.88'),(4, 'python七夕专用券', 0, 1, 1, '2022-05-04 10:40:08.022904', '2022-06-30 10:25:46.949197', 1, 2, 200, 200, '2022-05-04 10:39:00', '2022-11-15 10:39:00', 1, 0, 1, '-99'),(5, '算法学习优惠券', 0, 1, 1, '2021-08-05 10:05:07.837008', '2022-06-30 10:26:12.133812', 2, 2, 1000, 1000, '2022-08-05 10:04:00', '2022-12-25 10:04:00', 0, 200, 1, '*0.85');

truncate table ly_coupon_course_direction;
INSERT INTO luffycity.ly_coupon_course_direction (id, created_time, coupon_id, direction_id) VALUES (1, '2022-05-04 10:36:58.414461', 2, 1);

truncate table ly_coupon_course_category;
INSERT INTO luffycity.ly_coupon_course_category (id, created_time, category_id, coupon_id) VALUES (1, '2022-05-04 10:40:08.029505', 20, 4),(2, '2022-05-04 10:40:08.042891', 21, 4),(3, '2021-08-05 10:05:07.966221', 33, 5);

truncate table ly_coupon_course;
INSERT INTO luffycity.ly_coupon_course (id, created_time, coupon_id, course_id) VALUES (1, '2022-05-04 10:38:36.140929', 3, 1),(2, '2022-05-04 10:38:36.143166', 3, 2);

truncate table ly_coupon_log;
INSERT INTO luffycity.ly_coupon_log (id, is_deleted, orders, is_show, created_time, updated_time, name, use_time, use_status, coupon_id, order_id, user_id) VALUES (5, 0, 1, 1, '2022-05-04 12:00:25.051976', '2022-06-30 10:25:17.681298', '30元通用优惠券222', null, 0, 1, null, 1),(8, 0, 1, 1, '2022-05-04 12:03:24.331024', '2022-06-30 10:22:45.834401', '前端学习通用优惠券', null, 0, 2, null, 1),(9, 0, 1, 1, '2022-05-04 12:03:31.692397', '2022-06-30 10:23:41.492205', 'Typescript课程专用券', null, 0, 3, null, 1),(10, 0, 1, 1, '2022-05-04 12:03:38.225438', '2022-06-30 10:25:49.797318', 'python七夕专用券', null, 0, 4, null, 1),(11, 0, 1, 1, '2022-05-04 12:09:25.406437', '2022-06-30 10:23:55.832262', '前端学习通用优惠券', null, 0, 2, null, 1),(12, 0, 1, 1, '2021-08-05 10:06:06.036230', '2022-06-30 10:26:20.723668', '算法学习优惠券', null, 0, 5, null, 1);

注意:添加测试数据完成以后,因为是通过SQL语句来添加的。务必在Admin站点中对优惠券的发放和使用日志这功能中每一条数据进行一次的更新操作,打开数据详情页不需要修改任何数据,保存即可,这样才能让用户的优惠券信息同步到redis中!!!注意:如果是已经过期的优惠券,则不会被同步到redis中。

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 实现后台管理员给用户分发优惠券时自动记录到redis中"
git push

获取用户本次下单的可用优惠券

封装工具函数,获取当前用户拥有的所有优惠券以及本次下单的可用优惠券列表,coupon/services.py,代码:

import json
from django_redis import get_redis_connection
from courses.models import Course


def get_user_coupon_list(user_id):
    """获取指定用户拥有的所有优惠券列表"""
    redis = get_redis_connection("coupon")
    coupon_list = redis.keys(f"{user_id}:*")
    
    try:
        coupon_id_list = [item.decode() for item in coupon_list]
    except:
        coupon_id_list = []
    coupon_data = []
    # 遍历redis中所有的优惠券数据并转换数据格式
    for coupon_key in coupon_id_list:
        coupon_item = {"user_coupon_id": int(coupon_key.split(":")[-1])}
        coupon_hash = redis.hgetall(coupon_key)
        for key, value in coupon_hash.items():
            key = key.decode()
            value = value.decode()
            if key in ["to_course", "to_category", "to_direction"]:
                value = json.loads(value)
            coupon_item[key] = value
        coupon_data.append(coupon_item)

    return coupon_data


def get_user_enable_coupon_list(user_id):
    """
    获取指定用户本次下单的可用优惠券列表
    # 根据当前本次客户端购买商品课程进行比较,获取用户的当前可用优惠券。
    """
    redis = get_redis_connection("cart")

    # 先获取所有的优惠券列表
    coupon_data = get_user_coupon_list(user_id)

    # 获取指定用户的购物车中的勾选商品[与优惠券的适用范围进行比对,找出能用的优惠券]
    cart_hash = redis.hgetall(f"cart_{user_id}")

    # 获取被勾选的商品课程的ID列表
    course_id_list = {int(key.decode()) for key, value in cart_hash.items() if value == b'1'}

    # 获取被勾选的商品课程的模型对象列表
    course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()

    category_id_list = set()
    direction_id_list = set()
    for course in course_list:
        # 获取被勾选的商品课程的父类课程分类id列表,并保证去重
        category_id_list.add(int(course.category.id))
        # # 获取被勾选的商品课程的父类学习方向id列表,并保证去重
        direction_id_list.add(int(course.direction.id))

    # 创建一个列表用于保存所有的可用优惠券
    enable_coupon_list = []
    for item in coupon_data:
        coupon_type = int(item.get("coupon_type"))

        if coupon_type == 0:
            # 通用类型优惠券
            item["enable_course"] = "__all__"
            enable_coupon_list.append(item)

        elif coupon_type == 3:
            # 指定课程优惠券
            coupon_course = {int(course_item["course__id"]) for course_item in item.get("to_course")}
            # 并集处理
            ret = course_id_list & coupon_course
            if len(ret) > 0:
                item["enable_course"] = {int(course.id) for course in course_list if course.id in ret}
                enable_coupon_list.append(item)

        elif coupon_type == 2:
            # 指定课程分配优惠券
            coupon_category = {int(category_item["category__id"]) for category_item in item.get("to_category")}
            # 并集处理
            ret = category_id_list & coupon_category

            if len(ret) > 0:
                item["enable_course"] = {int(course.id) for course in course_list if course.category.id in ret}
                enable_coupon_list.append(item)

        elif coupon_type == 1:
            # 指定学习方向的优惠券
            coupon_direction = {int(direction_item["direction__id"]) for direction_item in item.get("to_direction")}
            # 并集处理
            ret = direction_id_list & coupon_direction

            if len(ret) > 0:
                item["enable_course"] = {int(course.id) for course in course_list if course.direction.id in ret}
                enable_coupon_list.append(item)

    return enable_coupon_list



coupon/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .services import get_user_coupon_list, get_user_enable_coupon_list


class CouponListAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        """获取用户拥有的所有优惠券"""
        user_id = request.user.id
        coupon_data = get_user_coupon_list(user_id)
        return Response(coupon_data)


class EnableCouponListAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        """获取用户本次拥有的本次下单可用所有优惠券"""
        user_id = request.user.id
        coupon_data = get_user_enable_coupon_list(user_id)
        return Response(coupon_data)


coupon/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [
    path("", views.CouponListAPIView.as_view()),
    path("enable/", views.EnableCouponListAPIView.as_view()),
]


1662896622654

1662896607464

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端实现获取用户所有优惠券与本次下单的可用优惠券列表"
git push

客户端展示用户拥有的可用优惠券

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  discount_price: 0,   // 本次下单的优惠抵扣价格
  discount_type: 0,    // 0表示优惠券,1表示积分
  use_coupon: false,   // 用户是否使用优惠
  coupon_list:[],      // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  create_order(token){
    // 生成订单
    return http.post("/orders/",{
        pay_type: this.pay_type
    },{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  },
  get_enable_coupon_list(token){
    // 获取本次下单的可用优惠券列表
    return http.get("/coupon/enable/",{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  }
})

export default order;


views/Order.vue,代码:

<transition name="el-zoom-in-top">
          <div class="coupon-del-box" v-if="order.use_coupon">
            <div class="coupon-switch-box">
              <div class="switch-btn ticket" :class="{'checked': order.discount_type===0}" @click="order.discount_type=0">优惠券 (4)<em><i class="imv2-check"></i></em></div>
              <div class="switch-btn code" :class="{'checked': order.discount_type===1}" @click="order.discount_type=1">积分<em><i class="imv2-check"></i></em></div>
            </div>
            <div class="coupon-content ticket" v-if="order.discount_type===0">
              <p class="no-coupons" v-if="order.coupon_list.length<1">暂无可用优惠券</p>
              <div class="coupons-box" v-else>
               <div class="content-box">
                <ul class="nouse-box">
                 <li class="l" :class="{select: order.select === key}" @click="order.select = (order.select === key?-1:key)" v-for="(coupon,key) in order.coupon_list" :key="key">
                  <div class="detail-box more-del-box">
                   <div class="price-box">
                    <p class="coupon-price l" v-if="coupon.discount === '1'"> ¥{{Math.abs(coupon.sale)}} </p>
                    <p class="coupon-price l" v-if="coupon.discount === '2'"> {{coupon.sale.replace("*0.","")}}折 </p>
                    <p class="use-inst l" v-if="coupon.condition>0">满{{coupon.condition}}元可用</p>
                    <p class="use-inst l" v-else>任意使用</p>
                   </div>
                   <div class="use-detail-box">
                    <div class="use-ajust-box">适用于:{{coupon.name}}</div>
                    <div class="use-ajust-box">有效期:{{coupon.start_time.split(" ")[0].replaceAll("-",".")}}-{{coupon.end_time.split(" ")[0].replaceAll("-",".")}}</div>
                   </div>
                  </div>
                 </li>
                </ul>
<!--                <ul class="use-box">-->
<!--                 <li class="l useing">-->
<!--                  <div class="detail-box more-del-box">-->
<!--                   <div class="price-box">-->
<!--                    <p class="coupon-price l"> ¥100 </p>-->
<!--                    <p class="use-inst l">满499可用</p>-->
<!--                   </div>-->
<!--                   <div class="use-detail-box">-->
<!--                    <div class="use-ajust-box">适用于:全部实战课程</div>-->
<!--                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>-->
<!--                   </div>-->
<!--                  </div>-->
<!--                 </li>-->
<!--                 <li class="l">-->
<!--                  <div class="detail-box more-del-box">-->
<!--                   <div class="price-box">-->
<!--                    <p class="coupon-price l"> ¥248 </p>-->
<!--                    <p class="use-inst l">满999可用</p>-->
<!--                   </div>-->
<!--                   <div class="use-detail-box">-->
<!--                    <div class="use-ajust-box">适用于:全部实战课程</div>-->
<!--                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>-->
<!--                   </div>-->
<!--                  </div>-->
<!--                 </li>-->
<!--                </ul>-->
<!--                <ul class="overdue-box">-->
<!--                 <li class="l useing">-->
<!--                  <div class="detail-box more-del-box">-->
<!--                   <div class="price-box">-->
<!--                    <p class="coupon-price l"> ¥100 </p>-->
<!--                    <p class="use-inst l">满499可用</p>-->
<!--                   </div>-->
<!--                   <div class="use-detail-box">-->
<!--                    <div class="use-ajust-box">适用于:全部实战课程</div>-->
<!--                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>-->
<!--                   </div>-->
<!--                  </div>-->
<!--                 </li>-->
<!--                 <li class="l">-->
<!--                  <div class="detail-box more-del-box">-->
<!--                   <div class="price-box">-->
<!--                    <p class="coupon-price l"> ¥248 </p>-->
<!--                    <p class="use-inst l">满999可用</p>-->
<!--                   </div>-->
<!--                   <div class="use-detail-box">-->
<!--                    <div class="use-ajust-box">适用于:全部实战课程</div>-->
<!--                    <div class="use-ajust-box">有效期:2021.06.01-2021.06.18</div>-->
<!--                   </div>-->
<!--                  </div>-->
<!--                 </li>-->
<!--                </ul>-->
               </div>
              </div>
            </div>
            <div class="coupon-content code" v-else>
                <div class="input-box">
                  <el-input-number placeholder="10积分=1元" v-model="order.credit" :step="1" :min="0" :max="1000"></el-input-number>
                  <a class="convert-btn">兑换</a>
                </div>
                <div class="converted-box">
                  <p>使用积分:<span class="code-num">200</span></p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                  <p class="course-title">课程:<span class="c_name">3天JavaScript入门</span>
                    <span class="discount-cash">100积分抵扣:<em>10</em>元</span>
                  </p>
                </div>
                <p class="error-msg">本次订单最多可以使用1000积分,您当前拥有200积分。(10积分=1元)</p>
                <p class="tip">说明:每笔订单只能使用一次积分,并只有在部分允许使用积分兑换的课程中才能使用。</p>
              </div>
          </div>
          </transition>


<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import router from "../router";

...

// 获取本次下单的可用优惠券
const get_enable_coupon_list = ()=>{
    let token = sessionStorage.token || localStorage.token;
    order.get_enable_coupon_list(token).then(response=>{
        order.coupon_list = response.data
    })
}
get_enable_coupon_list()


...
</script>


用户勾选优惠券后调整订单实付价格

<div class="pay-box" :class="{fixed:order.fixed}">
				  <div class="row-bottom">
            <div class="row">
              <div class="goods-total-price-box">
                <p class="r rw price-num"><em>¥</em><span>{{cart.total_price.toFixed(2)}}</span></p>
                <p class="r price-text"><span>共<span>{{cart.select_course_list?.length}}</span>件商品,</span>商品总金额:</p>
              </div>
            </div>
            <div class="coupons-discount-box">
              <p class="r rw price-num">-<em>¥</em><span>{{order.discount_price.toFixed(2)}}</span></p>
              <p class="r price-text">优惠券/积分抵扣:</p>
            </div>
            <div class="pay-price-box clearfix">
              <p class="r rw price"><em>¥</em><span id="js-pay-price">{{ (cart.total_price-order.discount_price).toFixed(2)}}</span></p>
              <p class="r price-text">应付:</p>
            </div>
            <span class="r btn btn-red submit-btn" @click="commit_order">提交订单</span>
					</div>
          <div class="pay-add-sign">
            <ul class="clearfix">
              <li>支持花呗</li>
              <li>可开发票</li>
              <li class="drawback">7天可退款</li>
            </ul>
          </div>
	      </div>


<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import router from "../router";

// let store = useStore()

const get_select_course = ()=>{
    // 获取购物车中的勾选商品列表
    let token = sessionStorage.token || localStorage.token;
    cart.get_select_course(token).then(response=>{
        cart.select_course_list = response.data.cart
        if(response.data.cart.length === 0){
          ElMessage.error("当前购物车中没有下单的商品!请重新重新选择购物车中要购买的商品~");
          router.back();
        }

        // 计算本次下单的总价格
        let sum = 0
        response.data.cart?.forEach((course,key)=>{
            if(course.discount.price > 0 || course.discount.price === 0){
              sum+=course.discount.price
            }else{
              sum+=course.price
            }
        })
        cart.total_price = sum;

    }).catch(error=>{
    if(error?.response?.status===400){
      ElMessage.error("登录超时!请重新登录后再继续操作~");
    }
  })
}

get_select_course();


...

// 监听用户选择的优惠券
watch(
    ()=>order.select,
    ()=>{
      order.discount_price = 0;
      // 如果没有选择任何的优惠券,则select 为-1,那么不用进行计算优惠券折扣的价格了
      if (order.select === -1) {
        return // 阻止代码继续往下执行
      }

      // 根据下标select,获取当前选中的优惠券信息
      let current_coupon = order.coupon_list[order.select]
      console.log(current_coupon);

      // 针对折扣优惠券,找到最大优惠的课程
      let max_discount = -1;
      for(let course of cart.select_course_list) {  // 循环本次下单的勾选商品
        // 找到当前优惠券的可用课程
        if(current_coupon.enable_course === "__all__") { // 如果当前优惠券是通用优惠券
          if(max_discount !== -1){
            if(course.price > max_discount.price){  // 在每次循环中,那当前循环的课程的价格与之前循环中得到的最大优惠课程的价格进行比较
              max_discount = course
            }
          }else{
            max_discount = course
          }
        }else if((current_coupon.enable_course.indexOf(course.id) > -1) && (course.price >= parseFloat(current_coupon.condition))){
          // 判断 当前优惠券如果包含了当前课程, 并 课程的价格 > 当前优惠券的使用门槛
          // 只允许没有参与其他优惠券活动的课程使用优惠券,基本所有的平台都不存在折上折的。
          if( course.discount.price === undefined ) {
            if(max_discount !== -1){
              if(course.price > max_discount.price){
                max_discount = course
              }
            }else{
              max_discount = course
            }
          }
        }
      }

      if(max_discount !== -1){
        if(current_coupon.discount === '1') { // 抵扣优惠券[抵扣的价格就是当前优惠券的价格]
          order.discount_price = parseFloat( Math.abs(current_coupon.sale) )
        }else if(current_coupon.discount === '2') { // 折扣优惠券]抵扣的价格就是(1-折扣百分比) * 课程原价]
          order.discount_price = parseFloat(max_discount.price * (1-parseFloat(current_coupon.sale.replace("*",""))) )
        }
      }else{
        order.select = -1
        order.discount_price = 0
        ElMessage.error("当前课程商品已经参与了其他优惠活动,无法再次使用当前优惠券!")
      }

})

....
</script>


提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端展示用户本次下单的可用优惠券并重新调整价格"
git push

客户端发送请求附带优惠券记录ID

客户端下单以后,本次请求附带使用的 用户优惠券记录ID到服务端,服务端进行验证计算,得到正确的实付价格,并从redis中删除用户使用的优惠券。

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  discount_price: 0,   // 本次下单的优惠抵扣价格
  discount_type: 0,    // 0表示优惠券,1表示积分
  use_coupon: false,   // 用户是否使用优惠
  coupon_list:[],      // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  create_order(user_coupon_id, token){
    // 生成订单
    return http.post("/orders/",{
        pay_type: this.pay_type,
        user_coupon_id,
    },{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  },
  get_enable_coupon_list(token){
    // 获取本次下单的可用优惠券列表
    return http.get("/coupon/enable/",{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  }
})

export default order;


views/Order.vue,代码:

<script setup>

 // 中间代码省略....

const commit_order = ()=>{
    // 生成订单
    let token = sessionStorage.token || localStorage.token;
    
    // 当用户选择了优惠券,则需要获取当前选择的优惠券发放记录的id
    let user_coupon_id = -1;
    if(order.select !== -1){
        user_coupon_id = order.coupon_list[order.select].user_coupon_id;
    }

    order.create_order(user_coupon_id, token).then(response=>{
    console.log(response.data.order_number)  // todo 订单号
    console.log(response.data.pay_link)      // todo 支付链接
    // 成功提示
    ElMessage.success("下单成功!马上跳转到支付页面,请稍候~")
    // 扣除掉被下单的商品数量,更新购物车中的商品数量
    store.commit("set_cart_total", store.state.cart_total - cart.select_course_list.length);
  }).catch(error=>{
    if(error?.response?.status===400){
          ElMessage.success("登录超时!请重新登录后再继续操作~");
    }
  })
}

 // 中间代码省略....

</script>


服务端接收并验证优惠券发送记录ID再重新计算本次下单的实付价格

order/serializers.py,代码:

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
from coupon.models import CouponLog
import logging

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)
    user_coupon_id = serializers.IntegerField(write_only=True, default=-1)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link", "user_coupon_id"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user_id = self.context["request"].user.id  # 1

        # 判断用户如果使用了优惠券,则优惠券需要判断验证
        user_coupon_id = validated_data.get("user_coupon_id")
        # 本次下单时,用户使用的优惠券
        user_coupon = None
        if user_coupon_id != -1:
            user_coupon = CouponLog.objects.filter(pk=user_coupon_id, user_id=user_id).first()

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                order = Order.objects.create(
                    name="购买课程",  # 订单标题
                    user_id=user_id,  # 当前下单的用户ID
                    # order_number = datetime.now().strftime("%Y%m%d%H%M%S") + ("%08d" % user_id) + "%08d" % random.randint(1,99999999) # 基于随机数生成唯一订单号
                    order_number=datetime.now().strftime("%Y%m%d") + ("%08d" % user_id) + "%08d" % redis.incr("order_number"), # 基于redis生成分布式唯一订单号
                    pay_type=validated_data.get("pay_type"),  # 支付方式
                )

                # 记录本次下单的商品列表
                cart_hash = redis.hgetall(f"cart_{user_id}")
                if len(cart_hash) < 1:
                    raise serializers.ValidationError(detail="购物车没有要下单的商品")

                # 提取购物车中所有勾选状态为b'1'的商品
                course_id_list = [int(key.decode()) for key, value in cart_hash.items() if value == b'1']

                # 添加订单与课程的关系
                course_list = Course.objects.filter(pk__in=course_id_list, is_deleted=False, is_show=True).all()
                detail_list = []
                total_price = 0 # 本次订单的总价格
                real_price = 0  # 本次订单的实付总价

                # 用户使用优惠券或积分以后,需要在服务端计算本次使用优惠券或积分的最大优惠额度
                total_discount_price = 0    # 总优惠价格
                max_discount_course = None  # 享受最大优惠的课程

                for course in course_list:
                    discount_price = course.discount.get("price", None)  # 获取课程原价
                    if discount_price is not None:
                        discount_price = float(discount_price)
                    discount_name = course.discount.get("type", "")
                    detail_list.append(OrderDetail(
                        order=order,
                        course=course,
                        name=course.name,
                        price=course.price,
                        real_price=course.price if discount_price is None else discount_price,
                        discount_name=discount_name,
                    ))

                    # 统计订单的总价和实付总价
                    total_price += float(course.price)
                    real_price += float(course.price if discount_price is None else discount_price)

                    # 在用户使用了优惠券,并且当前课程没有参与其他优惠活动时,找到最佳优惠课程
                    if user_coupon and discount_price is None:
                        if max_discount_course is None:
                            max_discount_course = course
                        else:
                            if course.price >= max_discount_course.price:
                                max_discount_course = course

                # 在用户使用了优惠券以后,根据循环中得到的最佳优惠课程进行计算最终抵扣金额
                if user_coupon:
                    # 优惠公式
                    sale = float(user_coupon.coupon.sale[1:])
                    if user_coupon.coupon.discount == 1:
                        """减免优惠券"""
                        total_discount_price = sale
                    elif user_coupon.coupon.discount == 2:
                        """折扣优惠券"""
                        total_discount_price = float(max_discount_course.price) * (1 - sale)

                # 一次性批量添加本次下单的商品记录
                OrderDetail.objects.bulk_create(detail_list)

                # 保存订单的总价格和实付价格
                order.total_price = real_price
                order.real_price =  float(real_price - total_discount_price)
                order.save()

                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""

                # 删除购物车中被勾选的商品,保留没有被勾选的商品信息
                cart = {key: value for key, value in cart_hash.items() if value == b'0'}
                pipe = redis.pipeline()
                pipe.multi()
                # 删除原来的购物车
                pipe.delete(f"cart_{user_id}")
                # 重新把未勾选的商品记录到购物车中
                if cart:  # 判断如果是空购物,则不需要再次添加cart购物车数据了。
                    pipe.hmset(f"cart_{user_id}", cart)
                pipe.execute()

                # 如果有使用了优惠券,则把优惠券和当前订单进行绑定
                if user_coupon:
                    user_coupon.order = order
                    user_coupon.save()
                    # 把优惠券从redis中移除
                    print(f"{user_id}:{user_coupon_id}")
                    redis = get_redis_connection("coupon")
                    redis.delete(f"{user_id}:{user_coupon_id}")

                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")



提交代码版本

/home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端在用户选择优惠券以后重新计算订单实付价格"
git push


积分

实现积分功能,必须具备以下条件:
1. 用户模型中必须有积分字段credit[积分不会过期]
2. 在服务端必须有一个常量配置,表示积分与现金的换算比例
3. 订单模型中新增一个积分字段, 用于记录积分的消费和积分折算的价格
4. 新增一个积分流水模型, 用于记录积分的收支记录
   operation  操作类型
   number     积分数量
	 user     用户ID


我们之前在自定义用户模型的时候,已经声明了积分字段,所以此处为了方便后面开发积分功能的时候,能够在admin管理站点中进行积分的调整使用,所以我们此处在users/admin.py后台站点配置文件中,配置user用户模型的模型管理器。

先新增积分流水模型

users/models.py,代码:

from django.db import models
from django.contrib.auth.models import AbstractUser
from stdimage import StdImageField
from django.utils.safestring import mark_safe
from models import BaseModel


# Create your models here.


class User(AbstractUser):
    mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号')
    money = models.DecimalField(max_digits=9, default=0.0, decimal_places=2, verbose_name="钱包余额")
    credit = models.IntegerField(default=0, verbose_name="积分")
	....


class Credit(BaseModel):
    """积分流水"""
    opera_choices = (
        (0, "业务增值"),
        (1, "购物消费"),
        (2, "系统赠送"),
    )
    operation = models.SmallIntegerField(choices=opera_choices, default=1, verbose_name="积分操作类型")
    number = models.IntegerField(default=0, verbose_name="积分数量", help_text="如果是扣除积分则需要设置积分为负数,如果消费10积分,则填写-10,<br>如果是添加积分则需要设置积分为正数,如果获得10积分,则填写10。")
    user = models.ForeignKey(User, related_name='user_credits', on_delete=models.CASCADE, db_constraint=False, verbose_name="用户")
    remark = models.CharField(max_length=500, null=True, blank=True, verbose_name="备注信息")

    class Meta:
        db_table = 'ly_credit'
        verbose_name = '积分流水'
        verbose_name_plural = verbose_name

    def __str__(self):
        if self.number > 0:
            oper_text = "获得"
        else:
            oper_text = "减少"
        return "[%s] %s 用户%s %s %s积分" % (self.get_operation_display(),self.created_time.strftime("%Y-%m-%d %H:%M:%S"), self.user.username, oper_text, abs(self.number))




订单模型新增积分字段,orders/models.py,代码:

class Order(BaseModel):
    """订单基本信息模型"""
    ...
    credit = models.IntegerField(default=0, null=True, blank=True, verbose_name="积分")
    ...


数据迁移

cd /home/moluo/Desktop/luffycity/luffycityapi
python manage.py makemigrations
python manage.py migrate


当管理员在admin运营后台中, 给用户新增积分时,需要自动生成对应的流水记录。

users/admin.py,代码:

from django.contrib import admin
from .models import User,Credit
# Register your models here.
class UserModelAdmin(admin.ModelAdmin):
    """用户的模型管理器"""
    list_display = ["id","username","avatar_image","money","credit"]
    list_editable = ["credit"]

    def save_model(self, request, obj, form, change):
        if change:
            """更新数据"""
            user = User.objects.get(pk=obj.id)
            has_credit = user.credit # 原来用户的积分数据
            new_credit = obj.credit  # 更新后用户的积分数据

            Credit.objects.create(
                user=user,
                number=int(new_credit - has_credit),
                operation=2,
            )

        obj.save()

        if not change:
            """新增数据"""
            Credit.objects.create(
                user=obj.id,
                number=obj.credit,
                operation=2,
            )


admin.site.register(User, UserModelAdmin)

class CreditModelAdmin(admin.ModelAdmin):
    """积分流水的模型管理器"""
    list_display = ["id","user","number","__str__"]

admin.site.register(Credit,CreditModelAdmin)

课程模型新增积分字段,courses/models.py,代码:

class Course(BaseModel):
    # ....省略
    price = models.DecimalField(blank=True, null=True, max_digits=10, decimal_places=2, default=0, verbose_name="课程原价")
    credit= models.IntegerField(blank=True, null=True, default=0, verbose_name="积分")

数据迁移

python manage.py makemigrations
python manage.py migrate

接下来,在课程详情展示页面中新增显示当前课程可以抵扣的积分数量。courses/serializers.py,代码:

class CourseRetrieveModelSerializer(serializers.ModelSerializer):
    """课程详情的序列化器"""
...

    class Meta:
        model = Course
        fields = [
            "name", "course_cover", "course_video", "level", "get_level_display",
            "description", "pub_date", "status", "get_status_display", "students","discount", "credit",
            "lessons", "pub_lessons", "price", "diretion", "diretion_name", "category", "category_name", "teacher","can_free_study"
        ]

因为课程模型新增了credit字段在elasticsearch搜索引擎中是没有对应的。所以我们需要在es索引模型文件新增credit字段,并在终端下手动重建索引。

apps/courses/search_indexes.py,代码:

from haystack import indexes
from .models import Course


class CourseIndex(indexes.SearchIndex, indexes.Indexable):
   # 中间字段声明省略
    price = indexes.DecimalField(model_attr="price")
    credit = indexes.IntegerField(model_attr="credit")  # 新增积分字段
   # 中间字段声明省略


重建es索引

python manage.py rebuild_index


接下来,我们就可以直接在admin管理站点中对课程的抵扣积分进行设置了。

客户端中展示积分相关信息,views/Info.vue,代码:

            <p class="course-price" v-if="course.info.discount.price >= 0">
              <span>活动价</span>
              <span class="discount">¥{{parseFloat(course.info.discount.price).toFixed(2)}}</span>
              <span class="original">¥{{parseFloat(course.info.price).toFixed(2)}}</span>
            </p>
            <p class="course-price" v-if="course.info.credit>0">
              <span>抵扣积分</span>
              <span class="discount">{{course.info.credit}}</span>
            </p>


效果:

image-20220630205437762

在购物车和确定订单页面中,服务端返回的购物车商品列表的数据以及勾选商品列表数据中增加返回credit积分字段。

cart/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course


# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated]  # 保证用户必须时登录状态才能调用当前视图
    def get(self,request):
        """获取购物车中的商品列表"""
     
       ....
        for course in course_list:
            data.append({
                "id": course.id,
                "name": course.name,
                "course_cover": course.course_cover.url,
                "price": float(course.price),
                "credit": course.credit,
         
......
   
  
class CartOrderAPIView(APIView):
    """购物车确认下单接口"""
    # 保证用户必须是登录状态才能调用当前视图
    permission_classes = [IsAuthenticated]

    def get(self,request):
        """获取勾选商品列表"""
        ....
        # 把course_list进行遍历,提取课程中的信息组成列表
        data = []
        for course in course_list:
            data.append({
                "id": course.id,
                "name": course.name,
                "course_cover": course.course_cover.url,
                "price": float(course.price),
                "credit": course.credit,
    ...


客户端购物车与确认订单页面中的商品列表展示当前可以使用的积分数量.

views/Cart.vue,和 views/Order.vue,代码:

              <div class="item-2">
                  <router-link :to="`/project/${course_info.id}`" class="img-box l">
                    <img :src="course_info.course_cover">
                  </router-link>
                  <dl class="l has-package">
                    <dt>【{{course_info.course_type}}】 {{course_info.name}}</dt>
                    <p class="package-item" v-if="course_info.discount.type">{{ course_info.discount.type }}</p>
                    <p class="package-item" v-if="course_info.credit>0">{{course_info.credit}}积分抵扣</p>
                  </dl>
              </div>


1662970533119

1662970476719

服务端返回积分抵扣现金的数据。

utils/constants.py,代码:

# 积分抵扣现金的比例,n积分:1元
CREDIT_TO_MONEY = 10


coupon/views.py,代码:

import constants
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .services import get_user_coupon_list, get_user_enable_coupon_list


....
class EnableCouponListAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        """获取用户本次拥有的本次下单可用所有优惠券"""
        user_id = request.user.id
        coupon_data = get_user_enable_coupon_list(user_id)
        return Response({
            "errmsg":"ok",
            'has_credit': request.user.credit,
            'credit_to_money': constants.CREDIT_TO_MONEY,
            "coupon_list": coupon_data
        })



客户端获取当前用户本地下单时可用优惠券列表并获取当前用户拥有的积分。

api/order.js,新增属性,credit_to_moneyhas_credit,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  discount_price: 0,   // 本次下单的优惠抵扣价格
  discount_type: 0,    // 0表示优惠券,1表示积分
  use_coupon: false,   // 用户是否使用优惠
  coupon_list:[],      // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  credit_to_money: 0,  // 积分兑换现金的比例
  has_credit: 0,       // 用户拥有的积分


export default order;


views/Order.vue,代码:

<script setup>
// ... 代码省略
// 获取本次下单的可用优惠券
const get_enable_coupon_list = ()=>{
    let token = sessionStorage.token || localStorage.token;
    order.get_enable_coupon_list(token).then(response=>{
        order.coupon_list = response.data.coupon_list;
        // 获取积分相关信息
        order.credit_to_money = response.data.credit_to_money;
        order.has_credit      = response.data.has_credit;
    })
}
get_enable_coupon_list()

// ... 代码省略 
</script>


提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 积分功能实现-上"
git push

在确认订单页面中,查询当前本次购买可使用积分抵扣的商品列表以及最大抵扣积分数量。

获取用户本次下单能使用的最大抵扣积分,需要考虑当前用户拥有的积分数量。

1. 当用户积分 > 本次下单可使用积分抵扣总数量:
   用户最高可使用积分=本次下单的可使用积分数量

2. 当用户积分 < 本次购课可使用积分抵扣总数量:
  用户最高可使用积分=用户拥有的所有积分


客户端切换不同的优惠类型时,重置积分和优惠券的选择信息,同时当用户选择了积分抵扣时,发送积分数量到服务端。

views/Order.vue,代码:

<div class="coupon-content code" v-else>
                <div class="input-box">
                  <el-input-number v-model="order.credit" :step="1" :min="0" :max="order.max_use_credit"></el-input-number>
                  <a class="convert-btn" @click="conver_credit">兑换</a>
                  <a class="convert-btn" @click="max_conver_credit">最大积分兑换</a>
                </div>
                <div class="converted-box">
                  <p class="course-title" v-for="course in order.credit_course_list">
                    课程:<span class="c_name">{{course.name}}</span>
                    <span class="discount-cash">{{course.credit}}积分抵扣:<em>{{ (course.credit/order.credit_to_money).toFixed(2) }}</em>元</span>
                  </p>
                </div>
                <p class="error-msg">本次订单最多可以使用{{order.max_use_credit}}积分,您当前拥有{{order.has_credit}}积分。({{order.credit_to_money}}积分=1元)</p>
                <p class="tip">说明:每笔订单只能使用一次积分,并只有在部分允许使用积分兑换的课程中才能使用。</p>
              </div>


<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import router from "../router";

// let store = useStore()

const get_select_course = ()=>{
    // 获取购物车中的勾选商品列表
    let token = sessionStorage.token || localStorage.token;
    cart.get_select_course(token).then(response=>{
        cart.select_course_list = response.data.cart
        if(response.data.cart.length === 0){
          ElMessage.error("当前购物车中没有下单的商品!请重新重新选择购物车中要购买的商品~");
          router.back();
        }

        // 计算本次下单的总价格
        let sum = 0
        let credit_course_list= [] // 可使用积分抵扣的课程列表
        let max_use_credit = 0     // 本次下单最多可以用于抵扣的积分
        response.data.cart?.forEach((course,key)=>{
            if(course.discount.price > 0 || course.discount.price === 0){
              sum+=course.discount.price
            }else{
              sum+=course.price
            }

           if(course.credit > 0){
              max_use_credit = max_use_credit + course.credit
              credit_course_list.push(course)
            }

        })
        cart.total_price = sum;
        order.credit_course_list = credit_course_list
        order.max_use_credit = max_use_credit // 本次下单最多可以用于抵扣的积分
        console.log(`order.max_use_credit=${order.max_use_credit}`);
        // 本次订单最多可以使用的积分数量
        // 如果用户积分不足,则最多只能用完自己的积分
        if(order.max_use_credit > order.has_credit){
          order.max_use_credit = order.has_credit
        }
    }).catch(error=>{
    if(error?.response?.status===400){
      ElMessage.error("登录超时!请重新登录后再继续操作~");
    }
  })
}

get_select_course();


....


// 积分兑换抵扣
const conver_credit = ()=>{
  order.discount_price = parseFloat( (order.credit / order.credit_to_money).toFixed(2) )
}

// 本次下单的最大兑换积分
const max_conver_credit = ()=>{
  order.credit=order.max_use_credit
  conver_credit();
}

...


// 在切换不同的优惠类型,重置积分和优惠券信息
watch(
    ()=>order.discount_type,
    ()=>{
        order.select = -1
        order.credit = 0
        order.discount_price = 0
    }
)
....


</script>


src/api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  discount_price: 0,   // 本次下单的优惠抵扣价格
  discount_type: 0,    // 0表示优惠券,1表示积分
  use_coupon: false,   // 用户是否使用优惠
  coupon_list:[],      // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  credit_to_money: 0,  // 积分兑换现金的比例
  has_credit: 0,       // 用户拥有的积分
  max_use_credit: 0,   // 当前用户本次下单可用最大积分数量
  credit_course_list:[], // 可使用积分抵扣的课程列表
  create_order(user_coupon_id, token){
    // 生成订单
    return http.post("/orders/",{
        pay_type: this.pay_type,
        user_coupon_id,
        credit: this.credit,
    },{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  },
  ...
})

export default order;


1662979237242

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 积分功能实现-中"
git push


服务端在下单时 如果用户使用积分,则重新计算最终实付价格

序列化器,orders/serializers.py,代码:

import logging
import constants

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
from coupon.models import CouponLog

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    pay_link = serializers.CharField(read_only=True)
    user_coupon_id = serializers.IntegerField(write_only=True, default=-1)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "pay_link", "user_coupon_id", "credit"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
            'credit': {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user = self.context["request"].user
        user_id = user.id

        ...

        # 本次下单时使用的积分数量
        use_credit = validated_data.get("credit", 0)
        if use_credit > 0 and use_credit > user.credit:
            raise serializers.ValidationError(detail="您拥有的积分不足以抵扣本次下单的积分,请重新下单!")

        # 开启事务操作,保证下单过程中的所有数据库的原子性
        with transaction.atomic():
            # 设置事务的回滚点标记
            t1 = transaction.savepoint()
            try:
                # 创建订单记录
                ...
                # 本次下单最多可以抵扣的积分
                max_use_credit = 0

                for course in course_list:
                    discount_price = course.discount.get("price", None)  # 获取课程原价
                    ...

                    # 添加每个课程的可用积分
                    if use_credit > 0 and course.credit > 0:
                        max_use_credit += course.credit

            .....

                if use_credit > 0:
                    if max_use_credit < use_credit:
                        raise serializers.ValidationError(detail="本次使用的抵扣积分数额超过了限制!")

                    # 当前订单添加积分抵扣的数量
                    order.credit = use_credit
                    total_discount_price = float(use_credit / constants.CREDIT_TO_MONEY)

                    # todo 扣除用户拥有的积分,后续在订单超时未支付,则返还订单中对应数量的积分给用户。如果订单成功支付,则添加一个积分流水记录。
                    user.credit = user.credit - use_credit
                    user.save()

                ....
                # todo 支付链接地址[后面实现支付功能的时候,再做]
                order.pay_link = ""

                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")


关于积分扣除和优惠券的使用问题!
我们下单的时候就要扣除积分或者记录优惠券和订单的关系,在用户如果取消订单或者订单超时以后,我们则返还扣除的积分或清除优惠券使用记录的订单号,如果结算支付成功,则记录积分的流水或者优惠券使用记录的状态。


Order.vue,客户端展示剩余积分:

const commit_order = ()=>{
        // 提交生成订单
      ....
            ElMessage.success("下单成功啦!马上跳转到支付页面,请稍候客官~")
            // 扣除掉被下单的商品数量,更新购物车中的商品数量
            store.commit("set_cart_total", store.state.cart_total - cart.select_course_list.length);

            // 订单生成以后先临时扣除用户积分
            order.has_credit = order.has_credit - order.credit
        }).catch(error=>{
            if(error?.response?.status===400){
                ElMessage.error("登录超时啦!请重新登录后再继续操作哦客官~");
                router.push('/login')
            }
        })
    }

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 积分功能实现-下"
git push


支付的实现一般我们开发中都是通过第三方支付平台来实现的!

目前国内外比较常见的第三方支付平台:

小额支付
1. 国内
   微信支付,支付宝,京东支付,百度钱包,贝宝[paypal中文版]
2. 国外
   apple pay,paypal[贝宝国际版],万事达信用卡
   
大额支付
   银联支付

支付宝

支付宝开发平台登录

官网:https://open.alipay.com/platform/home.htm

公司以企业账号进行支付签约:https://b.alipay.com/signing/productDetailV2.htm?productId=I1011000290000001000

目前我们是作为开发者给项目测试支付功能,所以我们先采用支付平台提供的测试服务端使用测试账号进行功能测试。将来等企业账号申请支付签约通过以后,则可以直接修改代码中的配置信息,就可以在线上运营使用了。

image-20210809091621044

沙箱环境

真实的支付宝网关: https://openapi.alipay.com/gateway.do
沙箱的支付宝网关: https://openapi.alipaydev.com/gateway.do

支付宝开发者文档

电脑网站支付产品介绍https://opendocs.alipay.com/open/270

电脑网站支付流程的时序图:

电脑网站支付流程图

支付宝支付流程:

1663029312892

开发支付功能

终端下创建新的git分支,并创建新的子应用。

cd /home/moluo/Desktop/luffycity
git checkout -b feature/payments

cd luffycityapi/luffycityapi/apps
python ../../manage.py startapp payments

注册子应用,settings/dev.py,代码:

INSTALLED_APPS = [
	。。。。
    'payments',
]

子应用路由,payments/urls.py,代码:

from django.urls import path,re_path
from . import views
urlpatterns = [

]

总路由,luffycityapi/urls.py,代码:

    path("payments/", include("payments.urls")),

配置秘钥

RSA2算法:非对称加密算法。一般加密的密钥是成对出现的。
私钥用于解密,自己保存,
公钥用于加密,提供给别人。
目前所有的第三方支付基本都是使用RSA2算法加密。所以要开发支付宝这种第三方支付平台的支付接口基本都有2对密钥。
支付宝密钥对:支付宝公钥,支付宝密钥。
商户(应用)密钥对:应用公钥,应用私钥。

往往我们需要从支付宝的官网上获取支付宝的公钥,把自己的应用公钥填写到支付宝官网。

1. 生成应用的私钥和公钥

支付宝公钥

获取支付宝公钥:https://openhome.alipay.com/platform/appDaily.htm?tab=info

第一次需要启用RSA2秘钥( 公钥模式 )

image-20210928173730216

点击启用了以后,就可以新窗口下看到支付宝提供的公钥。

image-20210928173916579

得到了公钥以后,复制保存到luffycityapi/apps/payments/keys/alipay_public_key.pem,这个pem文件一般就直接保存到当前子应用目录下的keys目录下。

内容格式如下:

-----BEGIN PUBLIC KEY-----
支付宝公钥信息[不能手动调整,复制下来就原样不动]
-----END PUBLIC KEY-----

效果如下:

image-20210928174342691

应用私钥

应用的公钥和私钥,支付宝本身已经提供了。所以,我们直接复制到本地保存起来即可。

注意:我们使用的是非java语言,所以不要选错了!

image-20220712113817599

luffycityapi/apps/payments/keys/app_private_key.pem,代码:

-----BEGIN RSA PRIVATE KEY-----
应用私钥信息[不能手动调整,复制下来就原样不动]
-----END RSA PRIVATE KEY-----


效果如下:

image-20210928180618251

如果要保留公钥到本地将来作为备份,或者用着其他的支付方式,可以保存,但是不推荐。

luffycityapi/apps/payments/keys/app_public_key.pem,代码:

-----BEGIN PUBLIC KEY-----
应用公钥信息[不能手动调整,复制下来就原样不动]
-----END PUBLIC KEY-----


效果:

image-20210928180832892

4. 使用支付宝的sdk开发支付接口

SDK:https://opendocs.alipay.com/open/54/103419

python版本的支付宝SDK文档:https://github.com/fzlee/alipay/blob/master/README.zh-hans.md

安装命令:

pip install python-alipay-sdk --upgrade


使用文档:https://github.com/fzlee/alipay/blob/master/docs/apis_new.zh-hans.md

调整原来下单的序列化器,不在序列化器中返回支付信息,而是由客户端根据订单号请求服务端生成支付信息。

orders/serializers.py,代码:

class OrderModelSerializer(serializers.ModelSerializer):
    # pay_link = serializers.CharField(read_only=True)
    user_coupon_id = serializers.IntegerField(write_only=True, default=-1)
    
     class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number",  "user_coupon_id", 'credit']
        
        
       def create(self, validated_data):
                # # todo 支付链接地址[后面实现支付功能的时候,再做]
                # order.pay_link = ""

后端提供发起支付的api接口

payments/views.py,代码:

from django.conf import settings
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from orders.models import Order
from alipay import AliPay
from alipay.utils import AliPayConfig


# Create your views here.
class AlipayAPIViewSet(ViewSet):
    """支付宝接口"""

    def link(self, request, order_number):
        """生成支付宝支付链接信息"""
        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 0:
                return Response({"message": "对不起,当前订单不能重复支付或订单已超时!"})
        except Order.DoesNotExist:
            return Response({"message": "对不起,当前订单不存在!"})

        # 读取支付宝公钥与商户私钥
        app_private_key_string = open(settings.ALIPAY["app_private_key_path"]).read()
        alipay_public_key_string = open(settings.ALIPAY["alipay_public_key_path"]).read()

        # 创建alipay SDK操作对象
        alipay = AliPay(
            appid=settings.ALIPAY["appid"],
            app_notify_url=settings.ALIPAY["notify_url"],  # 默认全局回调 url
            app_private_key_string=app_private_key_string,
            # 支付宝的公钥,验证支付宝回传消息使用,不是你自己的公钥,
            alipay_public_key_string=alipay_public_key_string,
            sign_type=settings.ALIPAY["sign_type"],  # RSA2
            debug=settings.ALIPAY["debug"],  # 默认 False,沙箱模式下必须设置为True
            verbose=settings.ALIPAY["verbose"],  # 输出调试数据
            config=AliPayConfig(timeout=settings.ALIPAY["timeout"])  # 可选,请求超时时间,单位:秒
        )

        # 生成支付信息
        order_string = alipay.client_api(
            "alipay.trade.page.pay",  # 接口名称
            biz_content={
                "out_trade_no": order_number,  # 订单号
                "total_amount": float(order.real_price),  # 订单金额 单位:元
                "subject": order.name,  # 订单标题
                "product_code": "FAST_INSTANT_TRADE_PAY",  # 产品码,目前只能支持 FAST_INSTANT_TRADE_PAY
            },
            return_url=settings.ALIPAY["return_url"],  # 可选,同步回调地址,必须填写客户端的路径
            notify_url=settings.ALIPAY["notify_url"]   # 可选,不填则使用采用全局默认notify_url,必须填写服务端的路径
        )

        # 拼接完整的支付链接
        link = f"{settings.ALIPAY['gateway']}?{order_string}"

        return Response({
            "pay_type": 0, # 支付类型
            "get_pay_type_display": "支付宝", # 支付类型的提示
            "link": link  # 支付连接地址
        })




在配置文件中编辑支付宝的配置信息[实际的值根据自己的账号而定]

setttins/dev.py,代码:

# 支付宝相关配置
ALIPAY = {
    # 'gateway': 'https://openapi.alipay.com/gateway.do',   # 真实网关地址
    'gateway': 'https://openapi.alipaydev.com/gateway.do',  # 沙箱网关地址
    'appid': '2016091600523592',  # 支付应用ID
    'sign_type': 'RSA2',  # 签证的加密算法
    'debug': True,  # 沙箱模式下必须设置为True
    'verbose': True,  # 是否在调试模式下输出调试数据
    'timeout': 15,  # 请求超时时间,单位:秒
    "app_private_key_path": BASE_DIR / "apps/payments/keys/app_private_key.pem",  # 应用私钥路径
    "alipay_public_key_path": BASE_DIR / "apps/payments/keys/alipay_public_key.pem",  # 支付宝公钥路径
    "return_url": "http://www.luffycity.cn:3000/alipay",  # 同步回调结果通知地址
    "notify_url": "http://api.luffycity.cn:8000/payments/alipay/notify",  # 异步回调结果通知地址
}



payments.urls,代码:

from django.urls import path,re_path
from . import views
urlpatterns = [
    re_path("^alipay/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"link"})),
]


若直接从postman测试拿到的链接地址跳转会发生错误如下,此时我们可以从代码print打印出地址进行测试

1663055751312

提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端提供生成支付宝支付链接的api接口"
git push --set-upstream origin feature/payments

前端生成订单以后发起支付

views/Order.vue,代码:

<script setup>
    import {useStore} from "vuex";
        let store = useStore()
// ... 代码省略
    
 const commit_order = ()=>{
        // 提交生成订单
        let token=sessionStorage.token || localStorage.token;

        // 当用户选择了优惠券,则需要获取当前选择的优惠券发放记录的id
        let user_coupon_id = -1;
        if(order.select !== -1){
            user_coupon_id = order.coupon_list[order.select].user_coupon_id;
        }

        order.create_order(user_coupon_id, token).then(response=>{

            // 成功提示
            ElMessage.success("下单成功啦!马上跳转到支付页面,请稍候客官~")
            // 扣除掉被下单的商品数量,更新购物车中的商品数量
            store.commit("cart_total", store.state.cart_total - cart.select_course_list.length);

            // 订单生成以后先临时扣除用户积分
            order.has_credit = order.has_credit - order.credit

            // 根据订单号到服务端获取支付链接,并打开支付页面。
            console.log('订单号',response.data.order_number)  // todo 订单号
            console.log('支付链接',response.data.link)      // todo 支付链接
            order.alipay_page_pay(response.data.order_number).then(response=>{
                window.open(response.data.link,"_blank");
            })

        }).catch(error=>{
            if(error?.response?.status===400){
                ElMessage.error("登录超时啦!请重新登录后再继续操作哦客官~");
                router.push('/login')
            }
        })
    }
// ... 代码省略

</script>


src/api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
    // ... 代码省略
    alipay_page_pay(order_number){
        // 获取订单的支付宝支付链接信息
        return http.get(`/payments/alipay/${order_number}`)
    }
});

export default order;


完成了上面的功能以后,我们就可以在沙箱环境中进行支付宝的付款了,我们会接受到支付宝界面那边通过前端js跳转回来的同步通知支付结果,跳转回到我们的客户端页面,

支付结果参数说明:https://docs.open.alipay.com/api_1/alipay.trade.page.pay

http://www.luffycity.cn:3000/alipay?charset=utf-8&out_trade_no=202207120000000100000022&method=alipay.trade.page.pay.return&total_amount=1579.00&sign=YqSmTxOPfaXV%2BTSMv8Lg1dOx71JulfaYL6Ab34LSy57Y%2BCvVAb895jPrxpqMeODxiLi65DRLLAJYK%2FQO1m6ykWuTbeQf1FpPhqTkH5LeipJ1LfPy3efj0KJFJFLVJ9pkIGs3tTD7tg%2FL9X70EVmzCxXruWtlM5pAh%2B2%2FsUVbZ4l1tMwDAt4%2FhNoPlc3jvQ07X1r7B17PPBa8Qk%2FF9PbXbIQBsoOkFa78l%2Fs5GBpLB7OTDoOCv16ijV7vTegqi9riucbJkxbk%2F%2FNR7yvLysKUkPMbkcY6uvXz9LD%2F6DQ%2BNKCz694fe0NLXgovVlhyA8l8FA9cSCYunWNELNK0MF%2FPMQ%3D%3D&trade_no=2022071222001439880503951070&auth_app_id=2016091600523592&version=1.0&app_id=2016091600523592&sign_type=RSA2&seller_id=2088102175868026&timestamp=2022-07-12+12%3A50%3A02


http://127.0.0.1:5173/alipay?charset=utf-8&out_trade_no=202209150000000100000050&method=alipay.trade.page.pay.return&total_amount=2100.00&sign=M7%2F0HjCeGbMHst3%2BOcDugzZ%2F4764Ho78AJn%2FUg0ILgwTI1eR2g7gogdBmioXsgVhgzydCv1F9x9gwHfUmW4qzn4w93gOfUwfFehN9vGWeBT52hLIyHTn9WZL4YIn8JeSvJI6QTPZDdLG0s5tJb3pgCjY4GL9tjkLCKA41eItZ66W9W6%2B1rbqR%2Fn%2F%2BUyBpSnks0UwJeAhhrZlInOnFtRnSDAswOiGZkizgYm2%2BmcuzoRTMzO5EjMAFE2%2B0Tm7rAspxN3NN0XXma6%2FpF0z4z9U2p8k4pR%2BFPQiXM5h2kkh4vPDxvhoRbdtlXqXmJ2es%2FMppp%2F%2FfTwZ5k5VosB6Bz9pRA%3D%3D&trade_no=2022091522001436060501571080&auth_app_id=2021000121665256&version=1.0&app_id=2021000121665256&sign_type=RSA2&seller_id=2088621993188746&timestamp=2022-09-15+08%3A43%3A23


支付成功的模板

提供一个接受支付结果的页面,展示支付成功的信息。

views/AliPaySuccess.vue,代码:

<template>
  <div class="success" v-if="order.is_show">
    <Header/>
    <div class="main">
        <div class="title">
          <i class="el-icon-chat-dot-round"></i>
          <div class="success-tips">
              <p class="tips1">您已成功购买 1 门课程!</p>
              <p class="tips2">你还可以加入QQ群 <span>747556033</span> 学习交流</p>
          </div>
        </div>
        <div class="order-info">
            <p class="info1"><b>付款时间:</b><span>2019/04/02 10:27</span></p>
            <p class="info2"><b>付款金额:</b><span >0</span></p>
            <p class="info3"><b>课程信息:</b><span><span>《Pycharm使用秘籍》</span></span></p>
        </div>
        <div class="wechat-code">
          <img src="../assets/wechat.jpg" alt="" class="er">
          <p><i class="el-icon-warning"></i>重要!微信扫码关注获得学习通知&amp;课程更新提醒!否则将严重影响学习进度和课程体验!</p>
        </div>
        <div class="study">
          <span>立即学习</span>
        </div>
    </div>
    <Footer/>
  </div>
</template>

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {ElMessage} from "element-plus";
import order from "../api/order";

</script>
<style scoped>
.success{
  padding-top: 80px;
}
.main{
    height: 100%;
    padding-top: 25px;
    padding-bottom: 25px;
    margin: 0 auto;
    width: 1200px;
    background: #fff;
}
.main .title{
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    padding: 25px 40px;
    border-bottom: 1px solid #f2f2f2;
}
.main .title .success-tips{
    box-sizing: border-box;
}
.title img{
    vertical-align: middle;
    width: 60px;
    height: 60px;
    margin-right: 40px;
}
.title .success-tips{
    box-sizing: border-box;
}
.title .tips1{
    font-size: 22px;
    color: #000;
}
.title .tips2{
    font-size: 16px;
    color: #4a4a4a;
    letter-spacing: 0;
    text-align: center;
    margin-top: 10px;
}
.title .tips2 span{
    color: #ec6730;
}
.order-info{
    padding: 25px 48px;
    padding-bottom: 15px;
    border-bottom: 1px solid #f2f2f2;
}
.order-info p{
    display: -ms-flexbox;
    display: flex;
    margin-bottom: 10px;
    font-size: 16px;
}
.order-info p b{
  font-weight: 400;
  color: #9d9d9d;
  white-space: nowrap;
}
.wechat-code{
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    padding: 25px 40px;
    border-bottom: 1px solid #f2f2f2;
}
.wechat-code>img{
    width: 180px;
    height: 180px;
    margin-right: 15px;
}
.wechat-code p{
    font-size: 14px;
    color: #d0021b;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
}
.wechat-code p>img{
    width: 16px;
    height: 16px;
    margin-right: 10px;
}
.study{
  padding: 25px 52px;
}
.study span{
  display: block;
  width: 140px;
  height: 42px;
  text-align: center;
  line-height: 42px;
  cursor: pointer;
  background: #ffc210;
  border-radius: 6px;
  font-size: 16px;
  color: #fff;
}
.el-icon-warning{
  font-size: 22px;
  margin-right: 5px;
}
.el-icon-chat-dot-round{
  font-size: 122px;
  margin-right: 10px;
}
</style>


routers/index.js,路由代码:

,
    {
      meta:{
        title: "支付成功",
        keepAlive: true
      },
      path: '/alipay',
      name: "PaySuccess",
      component: ()=> import("../views/AliPaySuccess.vue"),
    },


src/api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
    // ... 代码省略
    // ... 代码省略
    // ... 代码省略
    course_list: [],     // 本次购买的商品课程列表
    real_price: 0,       // 付款金额
    pay_time: undefined, // 付款时间
    is_show: false,      // 是否展示支付成功的内容
    // ... 代码省略
    // ... 代码省略
    // ... 代码省略
    relay_alipay_result(query_string){
        // 把地址栏中的查询字符串(支付成功以后的同步回调通知)转发给服务端
        return http.get(`/payments/alipay/result/${query_string}`)
    }
});

export default order;


转发支付结果到服务端。views/AliPaySuccess.vue,代码:

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {ElMessage} from "element-plus";
import order from "../api/order";

let query_string = location.search; // 获取查询字符串的支付结果参数
order.relay_alipay_result(query_string).then(response=>{
  order.is_show = true;
  order.course_list = response.data.course_list;
  order.real_price  = response.data.real_price;
  order.pay_time    = response.data.pay_time;
}).catch(error=>{
  console.log(error);
})

</script>


提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端实现发起支付并转换同步支付结果到服务端"
git push


服务端接收并处理同步通知支付结果

因为我们需要多次对支付接口进行调用,所以我们可以在缩减重复代码的情况下,对原有代码进行封装。

luffycityapi/utils/alipaysdk.py,代码:

from django.conf import settings
from alipay import AliPay
from alipay.utils import AliPayConfig
from datetime import datetime


class AliPaySDK(AliPay):
    """支付宝接口sdk工具类"""
    def __init__(self, config=None):
        if config is None:
            self.config = settings.ALIPAY
        else:
            self.config = config

        # 读取支付宝公钥与商户私钥
        app_private_key_string = open(self.config["app_private_key_path"]).read()
        alipay_public_key_string = open(self.config["alipay_public_key_path"]).read()

        # 创建alipay SDK操作对象
        super().__init__(
            appid=self.config["appid"],
            app_notify_url=self.config["notify_url"],  # 默认全局回调 url
            app_private_key_string=app_private_key_string,
            # 支付宝的公钥,验证支付宝回传消息使用,不是你自己的公钥,
            alipay_public_key_string=alipay_public_key_string,
            sign_type=self.config["sign_type"],  # RSA 或者 RSA2
            debug=self.config["debug"],  # 默认 False,沙箱模式下必须设置为True
            verbose=self.config["verbose"],  # 输出调试数据
            config=AliPayConfig(timeout=self.config["timeout"])  # 可选,请求超时时间,单位:秒
        )

    def page_pay(self,order_number,real_price,order_name):
        """
        生成支付链接
        @parmas order_number: 商户订单号
        @parmas real_price: 订单金额
        @parmas order_name: 订单标题
        @return 支付链接
        """
        order_string = self.client_api(
            "alipay.trade.page.pay",
            biz_content={
                "out_trade_no": order_number,  # 订单号
                "total_amount": float(real_price),  # 订单金额
                "subject": order_name,  # 订单标题
                "product_code": "FAST_INSTANT_TRADE_PAY",
            },
            return_url=self.config["return_url"],  # 可选,同步回调地址,必须填写客户端的路径
            notify_url=self.config["notify_url"]  # 可选,不填则使用采用全局默认notify_url,必须填写服务端的路径
        )

        return f"{self.config['gateway']}?{order_string}"

    def check_sign(self, data):
        """
        验证返回的支付结果中的签证信息
        @params data: 支付平台返回的支付结果,字典格式
        """
        signature = data.pop("sign")
        success = self.verify(data, signature)
        return success

    def query(self,order_number):
        """
        根据订单号查询订单状态
        @params order_number: 订单号
        """
        return self.server_api(
            "alipay.trade.query",
            biz_content={
                "out_trade_no": order_number
            }
        )

    def refund(self,order_number, real_price):
        """
        原路退款
        @params order_number: 退款的订单号
        @params real_price: 退款的订单金额
        """
        self.server_api(
            "alipay.rade.refund",
            biz_content={
                "out_trade_no": order_number,
                "refund_amount": real_price
            }
        )

    def transfer(self, account,amount):
        """
        转账给个人
        @params account: 收款人的支付宝账号
        @params amount: 转账金额
        """
        return self.server_api(
            "alipay.fund.trans.toaccount.transfer",
            biz_content = {
                "out_biz_no": datetime.now().strftime("%Y%m%d%H%M%S"),
                "payee_type": "ALIPAY_LOGONID/ALIPAY_USERID",
                "payee_account": account,
                "amount": amount
            }
        )

payments/views.py,代码:

from  datetime import datetime
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status

from courses.serializers import CourseInfoModelSerializer
from orders.models import Order
from alipaysdk import AliPaySDK

# Create your views here.
class AlipayAPIViewSet(ViewSet):
    """支付宝接口"""

    def link(self, request, order_number):
        """生成支付宝支付链接信息"""
        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 0:
                return Response({"message": "对不起,当前订单不能重复支付或订单已超时!"})
        except Order.DoesNotExist:
            return Response({"message": "对不起,当前订单不存在!"})

        # # 读取支付宝公钥与商户私钥
        # app_private_key_string = open(settings.ALIPAY["app_private_key_path"]).read()
        # alipay_public_key_string = open(settings.ALIPAY["alipay_public_key_path"]).read()
        #
        # # 创建alipay SDK操作对象
        # alipay = AliPay(
        #     appid=settings.ALIPAY["appid"],
        #     app_notify_url=settings.ALIPAY["notify_url"],  # 默认全局回调 url
        #     app_private_key_string=app_private_key_string,
        #     # 支付宝的公钥,验证支付宝回传消息使用,不是你自己的公钥,
        #     alipay_public_key_string=alipay_public_key_string,
        #     sign_type=settings.ALIPAY["sign_type"],  # RSA2
        #     debug=settings.ALIPAY["debug"],  # 默认 False,沙箱模式下必须设置为True
        #     verbose=settings.ALIPAY["verbose"],  # 输出调试数据
        #     config=AliPayConfig(timeout=settings.ALIPAY["timeout"])  # 可选,请求超时时间,单位:秒
        # )

        # 生成支付信息
        # order_string = alipay.client_api(
        #     "alipay.trade.page.pay",  # 接口名称
        #     biz_content={
        #         "out_trade_no": order_number,  # 订单号
        #         "total_amount": float(order.real_price),  # 订单金额 单位:元
        #         "subject": order.name,  # 订单标题
        #         "product_code": "FAST_INSTANT_TRADE_PAY",  # 产品码,目前只能支持 FAST_INSTANT_TRADE_PAY
        #     },
        #     return_url=settings.ALIPAY["return_url"],  # 可选,同步回调地址,必须填写客户端的路径
        #     notify_url=settings.ALIPAY["notify_url"]   # 可选,不填则使用采用全局默认notify_url,必须填写服务端的路径
        # )
        #
        # # 拼接完整的支付链接
        # link = f"{settings.ALIPAY['gateway']}?{order_string}"

        alipay = AliPaySDK()
        link = alipay.page_pay(order_number, order.real_price, order.name)
        print(link)

        return Response({
            "pay_type": 0, # 支付类型
            "get_pay_type_display": "支付宝", # 支付类型的提示
            "link": link  # 支付连接地址
        })

    def return_result(self, request):
        """支付宝支付结果的同步通知处理"""
        data = request.query_params.dict()  # QueryDict
        alipay = AliPaySDK()
        success = alipay.check_sign(data)
        if not success:
            return Response({"errmsg": "通知通知结果不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        order_number = data.get("out_trade_no")
        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 1:
                return Response({"errmsg": "订单超时或已取消!"}, status=status.HTTP_400_BAD_REQUEST)
        except Order.DoesNotExist:
            return Response({"errmsg": "订单不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        # 获取当前订单相关的课程信息,用于返回给客户端
        order_courses = order.order_courses.all()
        course_list = [item.course for item in order_courses]

        if order.order_status == 0:
            result = alipay.query(order_number)

            print(f"result-{result}")
            if result.get("trade_status", None) in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
                """支付成功"""
                # todo 1. 修改订单状态
                order.pay_time = datetime.now()
                order.order_status = 1
                order.save()
                # todo 2. 记录扣除个人积分的流水信息,补充个人的优惠券使用记录
                # todo 3. 用户和课程的关系绑定
                # todo 4. 取消订单超时

        # 返回客户端结果
        serializer = CourseInfoModelSerializer(course_list, many=True)
        return Response({
            "pay_time": order.pay_time.strftime("%Y-%m-%d %H:%M:%S"),
            "real_price": float(order.real_price),
            "course_list": serializer.data
        })


payments/urls.py,代码:

from django.urls import path,re_path
from . import views
urlpatterns = [
    re_path("^alipay/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"link"})),
    path("alipay/result/", views.AlipayAPIViewSet.as_view({"get":"return_result"}))
]


1663204678537

1663204713810

提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端接收客户端转发的同步支付结果并验证修改订单状态"
git push


服务端更新用户购买商品课程的记录

user/models.py,模型代码:

from django.contrib.auth.models import AbstractUser
from django.utils.safestring import mark_safe
from luffycityapi.utils.models import BaseModel,models
from luffycityapi.settings import constants


...

from courses.models import Course,CourseChapter,CourseLesson
class UserCourse(BaseModel):
    """用户的课程"""
    user   = models.ForeignKey(User, related_name='user_courses', on_delete=models.CASCADE,verbose_name="用户", db_constraint=False)
    course = models.ForeignKey(Course, related_name='course_users', on_delete=models.CASCADE, verbose_name="课程名称", db_constraint=False)
    chapter = models.ForeignKey(CourseChapter, related_name="user_chapter", on_delete=models.DO_NOTHING, null=True, blank=True, verbose_name="章节信息", db_constraint=False)
    lesson = models.ForeignKey(CourseLesson, related_name="user_lesson", on_delete=models.DO_NOTHING, null=True, blank=True, verbose_name="课时信息", db_constraint=False)
    study_time = models.IntegerField(default=0, verbose_name="学习时长")

    class Meta:
        db_table = 'ly_user_course'
        verbose_name = '用户课程购买记录'
        verbose_name_plural = verbose_name


数据迁移

/home/moluo/Desktop/luffycity/luffycityapi
python manage.py makemigrations
python manage.py migrate


在订单结果处理的视图中把用户购买课程逻辑代码加上。

payments/views.py,代码:

import logging
from datetime import datetime
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status
from django.db import transaction

from orders.models import Order
from alipaysdk import AliPaySDK
from users.models import Credit, UserCourse
from courses.serializers import CourseInfoModelSerializer
from coupon.models import CouponLog

logger = logging.getLogger("django")


# Create your views here.
class AlipayAPIViewSet(ViewSet):
    """支付宝接口"""

....
    def return_result(self, request):
        """支付宝支付结果的同步通知处理"""
        data = request.query_params.dict()  # QueryDict
        alipay = AliPaySDK()
        success = alipay.check_sign(data)
        if not success:
            return Response({"errmsg": "通知通知结果不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        order_number = data.get("out_trade_no")
        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 1:
                return Response({"errmsg": "订单超时或已取消!"}, status=status.HTTP_400_BAD_REQUEST)
        except Order.DoesNotExist:
            return Response({"errmsg": "订单不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        # 获取当前订单相关的课程信息,用于返回给客户端
        order_courses = order.order_courses.all()
        course_list = [item.course for item in order_courses]

        if order.order_status == 0:
            # 请求支付宝,查询订单的支付结果
            result = alipay.query(order_number)
            print(f"result-{result}")
            if result.get("trade_status", None) in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
                """支付成功"""
                with transaction.atomic():
                    save_id = transaction.savepoint()
                    try:
                        now_time = datetime.now()
                        # 1. 修改订单状态
                        order.pay_time = now_time
                        order.order_status = 1
                        order.save()
                        # 2.1 记录扣除个人积分的流水信息
                        if order.credit > 0:
                            Credit.objects.create(operation=1, number=order.credit, user=order.user)

                        # 2.2 补充个人的优惠券使用记录
                        coupon_log = CouponLog.objects.filter(order=order).first()
                        if coupon_log:
                            coupon_log.use_time = now_time
                            coupon_log.use_status = 1  # 1 表示已使用
                            coupon_log.save()

                        # 3. 用户和课程的关系绑定
                        user_course_list = []
                        for course in course_list:
                            user_course_list.append(UserCourse(course=course, user=order.user))
                        UserCourse.objects.bulk_create(user_course_list)

                        # todo 4. 取消订单超时

                    except Exception as e:
                        logger.error(f"订单支付处理同步结果发生未知错误:{e}")
                        transaction.savepoint_rollback(save_id)
                        return Response({"errmsg": "当前订单支付未完成!请联系客服工作人员!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

        # 返回客户端结果
        serializer = CourseInfoModelSerializer(course_list, many=True)
        return Response({
            "pay_time": order.pay_time.strftime("%Y-%m-%d %H:%M:%S"),
            "real_price": float(order.real_price),
            "course_list": serializer.data
        })



因为在处理同步通知结果的代码中,还进行了一次向支付宝查询订单支付结果的操作,所以有时候网络延迟的话,就会出现服务端成功,但是客户端因为超时等待而导致无法获取结果报错,此时应该设置axios的timeout为5~10秒,原来项目搭建时我们设置的是2.5秒。

utils/http.js,代码:

import axios from "axios"

const http = axios.create({
    // timeout: 2500,                          // 请求超时,有大文件上传需要关闭这个配置
    baseURL: "http://api.luffycity.cn:8000",   // 设置api服务端的默认地址[如果基于服务端实现的跨域,这里可以填写api服务端的地址,如果基于nodejs客户端测试服务器实现的跨域,则这里不能填写api服务端地址]
    withCredentials: false,                    // 是否允许客户端ajax请求时携带cookie
})

// 后续代码省略....


客户端展示支付处理后的结果。views/AliPaySuccess.vue,代码:

<template>
  <div class="success" v-if="order.is_show">
    <Header/>
    <div class="main">
        <div class="title">
          <i class="el-icon-chat-dot-round"></i>
          <div class="success-tips">
             <p class="tips1">您已成功购买 {{order.course_list?.length}} 门课程!</p>
              <p class="tips2">你还可以加入QQ群 <span>747556033</span> 学习交流</p>
          </div>
        </div>
        <div class="order-info">
            <p class="info1"><b>付款时间:</b><span>{{order.pay_time}}</span></p>
            <p class="info2"><b>付款金额:</b><span >{{order.real_price?.toFixed(2)}}</span></p>
            <p class="info3"><b>课程信息:</b>
              <span v-for="course in order.course_list">《{{course.name}}》</span>
            </p>
        </div>
        <div class="wechat-code">
          <img src="../assets/wechat.jpg" alt="" class="er">
          <p><i class="el-icon-warning"></i>重要!微信扫码关注获得学习通知&amp;课程更新提醒!否则将严重影响学习进度和课程体验!</p>
        </div>
        <div class="study">
          <router-link to="/user/study"><span>立即学习</span></router-link>
        </div>
    </div>
    <Footer/>
  </div>
</template>


<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {ElMessage} from "element-plus";
import order from "../api/order";
import {useRouter} from "vue-router";
const router = useRouter()

let query_string = location.search; // 获取查询字符串的支付结果参数
order.relay_alipay_result(query_string).then(response=>{
  order.is_show = true;
  order.course_list = response.data.course_list;
  order.real_price  = response.data.real_price;
  order.pay_time    = response.data.pay_time;
}).catch(error=>{
  ElMessage.error(error.response.data.errmsg);
  router.push("/");
})

</script>


我们当前完成的项目具有一定特殊性,和传统卖实物商品不一样的是,我们卖的是虚拟商品,所以不存在多次购买同一款商品的,所以后续用户添加商品到购物车时, 要判断用户是否曾经购买了当前商品课程,如果在UserCourse中查询到购买记录,则不能添加商品到购物车!!cart/views.py,代码:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django_redis import get_redis_connection
from courses.models import Course
from users.models import UserCourse


# Create your views here.
class CartAPIView(APIView):
    permission_classes = [IsAuthenticated]  # 保证用户必须时登录状态才能调用当前视图

    def post(self, request):
        """添加课程商品到购物车中"""
        # 1. 接受客户端提交的商品信息:用户ID,课程ID,勾选状态
        # 用户ID 可以通过self.request.user.id 或 request.user.id 来获取
        user_id = request.user.id
        course_id = request.data.get("course_id", None)
        selected = 1  # 默认商品是勾选状态的
        print(f"user_id={user_id},course_id={course_id}")

        try:
            # 判断课程是否存在
            course = Course.objects.get(is_show=True, is_deleted=False, pk=course_id)
        except:
            return Response({"errmsg": "当前课程不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        try:
            # 判断用户是否已经购买了
            UserCourse.objects.get(user_id=user_id, course_id=course_id)
            return Response({"errmsg": "对不起,您已经购买过当前课程!不需要重新购买了."}, status=status.HTTP_400_BAD_REQUEST)
        except:
            pass

   

    # ... 代码省略


前端views/Course.vue,代码:

...
// 添加课程到购物车
    const add_cart = (course_info)=>{
        // 从本地存储中获取jwt token
        let token = sessionStorage.token || localStorage.token;

        cart.add_course_to_cart(course_info.id, token).then(response=>{
            store.commit("cart_total", response.data.cart_total)
            ElMessage.success(response.data.errmsg)
        }).catch(error=>{
            if(error.response.status === 401){
                store.commit("logout");
                ElMessage.error("您尚未登录或已登录超时,请登录后继续操作!");
            }else{
                // ElMessage.error("添加商品到购物车失败!");
                ElMessage.error(error.response.data.errmsg)
            }
        })
    }
    ....

1663208195008

提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端更新用户购买的课程记录以及的订单支付成功后的积分流水记录与优惠券的使用状态记录,最后在购物车中防止用户重复购买商品"
git push


上面的支付结果处理都是基于支付平台返回的同步结果,是支付宝那边通过js页面跳转来完成支付结果通知的。在项目运营过程中,很容易出现跳转页面过程中无法跳转或者跳转过程中被用户手动关闭页面的情况,这些情况都会导致服务端没法完成同步通知代码的执行,也就是说,我们不能单纯等待用户付款完成以后支付宝通过页面跳转的同步通知结果,还要结合用户的行为操作和支付宝的异步通知结果处理,才能防止订单状态的丢失

提供支付倒计时功能

客户端订单页面中添加一个倒计时的遮罩层,当用户关闭当前遮罩层则发送ajax轮询请求查询当前订单的支付结果。

views/Order.vue,代码

    <div class="loadding" v-if="order.loading" @click="check_order">
      <div class="box">
          <p class="time">{{fill0(parseInt(order.timeout/60))}}:{{ fill0(order.timeout%60)}}</p>
          <i class="el-icon-loading"></i><br>
          <p>支付完成!点击关闭当前页面</p>
      </div>
    </div>  <!-- 这里添加到Footer上方即可 -->
    <Footer/>


<script setup>
import {reactive,watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import {useStore} from "vuex";
import cart from "../api/cart"
import order from "../api/order";
import {ElMessage} from "element-plus";
import {useRouter} from "vue-router";
import {fill0} from "../utils/func";

let store = useStore()
let router = useRouter()

.....


const commit_order = ()=>{
....

    order.create_order(user_coupon_id, token).then(response=>{
      // 支付倒计时提示
      order.order_number = response.data.order_number  // 订单号
      order.loading = true                             // 显示遮罩层
      order.timeout = response.data.order_timeout      // 订单超时的时间,为15分钟
      clearInterval(order.timer)  // 先清除原有定时器,保证整个页面中timer对应的定时器是唯一的。
      order.timer = setInterval(() => {
        if(order.timeout > 1){
            order.timeout=order.timeout - 1;
        }else{
            ElMessage.error("订单超时!如果您已经支付成功!请点击关闭当前弹窗!当前页面15秒后关闭!");
            clearInterval(order.timer);
            // 发送一个订单查询
            check_order();
            // 关闭页面
            setTimeout(()=>{
                // 跳转到用户的订单用心
                router.push("/user/order");
            }, 1500);
        }
      }, 3000);

 .......
// 查询订单状态
const check_order = ()=>{
  let token = sessionStorage.token || localStorage.token;
  order.query_order(token).then(response=>{
    order.loading = false;
    router.push("/user/order");
  }).catch(error=>{
    console.log(error);
    ElMessage.error(error.response.data.errmsg);
  })
}



.....

</script>


<style>
.loadding{
  width: 100%;
  height: 100%;
  margin: auto;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 999;
  background-color: rgba(0,0,0,.7);
}
.box{
  width: 300px;
  height: 150px;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
  font-size: 40px;
  text-align: center;
  padding-top: 50px;
  color: #fff;
}
.box .time{
  font-size: 22px;
}
</style>


api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  total_price: 0,      // 勾选商品的总价格
  discount_price: 0,   // 本次下单的优惠抵扣价格
  discount_type: 0,    // 0表示优惠券,1表示积分
  use_coupon: false,   // 用户是否使用优惠
  coupon_list:[],      // 用户拥有的可用优惠券列表
  select: -1,          // 当前用户选中的优惠券下标,-1表示没有选择
  credit: 0,           // 当前用户选择抵扣的积分,0表示没有使用积分
  fixed: true,         // 底部订单总价是否固定浮动
  pay_type: 0,         // 支付方式
  credit_to_money: 0,  // 积分兑换现金的比例
  has_credit: 0,       // 用户拥有的积分
  max_use_credit: 0,   // 当前用户本次下单可用最大积分数量
  credit_course_list:[], // 可使用积分抵扣的课程列表
  course_list: [],     // 本次购买的商品课程列表
  real_price: 0,       // 付款金额
  pay_time: undefined, // 付款时间
  order_number: null,  // 订单号
  is_show: false,      // 是否展示支付成功的内容[接收到支付宝的同步处理结果以后,先把结果转发给后端验证成功以后,才把前端的页面内容展示处理]
  loading: false,      // 订单支付时的倒计时背景遮罩层
  timeout: 0,          // 订单支付超时倒计时
  timer: 0,            // 订单支付倒计时定时器的标记符
    
 ....
  query_order(token){
    // 查询订单支付结果
    return http.get(`/payments/alipay/query/${this.order_number}`,{
      headers:{
        Authorization: "jwt " + token,
      }
    })
  }
})

export default order;


服务端下单成功返回订单号时同时返回服务端配置的订单超时时间。

order/serializers.py,代码:

import logging
import constants

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
from coupon.models import CouponLog

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    user_coupon_id = serializers.IntegerField(write_only=True, default=-1)
    order_timeout = serializers.IntegerField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "user_coupon_id", "credit", "order_timeout"]
         ....

    def create(self, validated_data):
        """创建订单"""
        redis = get_redis_connection("cart")
        user = self.context["request"].user
        user_id = user.id


                 ....

          
                # 返回订单超时时间
                order.order_timeout = constants.ORDER_TIMEOUT
                return order
            except Exception as e:
                # 1. 记录日志
                logger.error(f"订单创建失败:{e}")
                # 2. 事务回滚
                transaction.savepoint_rollback(t1)
                # 3. 抛出异常,通知视图返回错误提示
                raise serializers.ValidationError(detail="订单创建失败!")



utils/constants.py,代码:

# 订单超时的时间(单位:秒)
ORDER_TIMEOUT = 15 * 60


服务端提供查询订单的api接口

payments/urls.py,代码:

from django.urls import path,re_path
from . import views
urlpatterns = [
    re_path("^alipay/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"link"})),
    path("alipay/result/", views.AlipayAPIViewSet.as_view({"get":"return_result"})),
    re_path("^alipay/query/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"query"})),
]


payments/views.py,代码:

import logging
from datetime import datetime
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status
from django.db import transaction

from orders.models import Order
from alipaysdk import AliPaySDK
from users.models import Credit, UserCourse
from courses.serializers import CourseInfoModelSerializer
from coupon.models import CouponLog

logger = logging.getLogger("django")


# Create your views here.
class AlipayAPIViewSet(ViewSet):
    """支付宝接口"""

    # // ... 代码省略
    # // ... 代码省略
    # // ... 代码省略


    def query(self, request, order_number):
        """主动查询订单支付的支付结果"""
        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 1:
                return Response({"errmsg": "订单超时或已取消!"}, status=status.HTTP_400_BAD_REQUEST)
        except Order.DoesNotExist:
            return Response({"errmsg": "订单不存在!"}, status=status.HTTP_400_BAD_REQUEST)

        # 获取当前订单相关的课程信息,用于返回给客户端
        order_courses = order.order_courses.all()
        course_list = [item.course for item in order_courses]
        courses_list = []
        for course in course_list:
            courses_list.append(UserCourse(course=course, user=order.user))

        if order.order_status == 0:
            # 请求支付宝,查询订单的支付结果
            alipay = AliPaySDK()
            result = alipay.query(order_number)
            print(f"result-{result}")
            if result.get("trade_status", None) in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
                """支付成功"""
                with transaction.atomic():
                    save_id = transaction.savepoint()
                    try:
                        now_time = datetime.now()
                        # 1. 修改订单状态
                        order.pay_time = now_time
                        order.order_status = 1
                        order.save()
                        # 2.1 记录扣除个人积分的流水信息
                        if order.credit > 0:
                            Credit.objects.create(operation=1, number=order.credit, user=order.user)

                        # 2.2 补充个人的优惠券使用记录
                        coupon_log = CouponLog.objects.filter(order=order).first()
                        if coupon_log:
                            coupon_log.use_time = now_time
                            coupon_log.use_status = 1  # 1 表示已使用
                            coupon_log.save()

                        # 3. 用户和课程的关系绑定
                        user_course_list = []
                        for course in course_list:
                            user_course_list.append(UserCourse(course=course, user=order.user))
                        UserCourse.objects.bulk_create(user_course_list)

                        # todo 4. 取消订单超时

                    except Exception as e:
                        logger.error(f"订单支付处理同步结果发生未知错误:{e}")
                        transaction.savepoint_rollback(save_id)
                        return Response({"errmsg": "当前订单支付未完成!请联系客服工作人员!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
            else:
                """当前订单未支付"""
                return Response({"errmsg": "当前订单未支付!"}, status=status.HTTP_400_BAD_REQUEST)

        return Response({"errmsg":"当前订单已支付!"})



提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端实现支付倒计时功能"
git push


虽然上面提供了支付倒计时进一步确认用户是否支付了。但是还是会存在用户不点击页面而是通过页面刷新来避开了倒计时。这样的话,我们还是存存在订单状态丢失的情况。

接受支付宝异步发送支付结果

支付宝异步通知结果相关文档:https://opendocs.alipay.com/open/270/105902

注意:程序执行完后必须打印输出“success”(不包含引号)。如果商家反馈给支付宝的字符不是 success 这7个字符,支付宝服务器会不断重发通知,直到超过 24 小时 22 分钟。一般情况下,25 小时以内完成 8 次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)。

payments/views.py,代码:

import logging
from datetime import datetime
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status
from django.db import transaction
from django.http.response import HttpResponse

from orders.models import Order
from alipaysdk import AliPaySDK
from users.models import Credit, UserCourse
from courses.serializers import CourseInfoModelSerializer
from coupon.models import CouponLog

logger = logging.getLogger("django")


# Create your views here.
class AlipayAPIViewSet(ViewSet):
    """支付宝接口"""

    def link(self, request, order_number):
        """生成支付宝支付链接信息"""
     。。。

    def return_result(self, request):
        """支付宝支付结果的同步通知处理"""
  	。。。

    def notify_result(self, request):
        """支付宝支付结果的异步通知处理"""
        # drf中接收POST参数需要使用request.data
        data = request.data
        alipay = AliPaySDK()
        success = alipay.check_sign(data)
        if not success:
            # 因为是属于异步处理,这个过程无法通过终端调试,因此,需要把支付发送过来的结果,记录到日志中。
            logger.error(f"[支付宝]>> 异步通知结果验证失败:{data}")
            return HttpResponse("fail")

        if data.get("trade_status") not in ["TRADE_FINISHED", "TRADE_SUCCESS"]:
            return HttpResponse("fail")

        # 基于支付包异步请求的支付结果中提取订单号
        order_number = data.get("out_trade_no")

        try:
            order = Order.objects.get(order_number=order_number)
            if order.order_status > 1:
                return HttpResponse("fail")
        except Order.DoesNotExist:
            return HttpResponse("fail")

        # 如果已经支付完成,则不需要继续往下处理
        if order.order_status == 1:
            return HttpResponse("success")

        # 获取本次下单的商品课程列表
        order_courses = order.order_courses.all()
        course_list = [item.course for item in order_courses]
        courses_list = []

        for course in course_list:
            courses_list.append(UserCourse(course=course, user=order.user))

        """支付成功"""
        with transaction.atomic():
            save_id = transaction.savepoint()
            try:
                now_time = datetime.now()
                # 1. 修改订单状态
                order.pay_time = now_time
                order.order_status = 1
                order.save()
                # 2.1 记录扣除个人积分的流水信息
                if order.credit > 0:
                    Credit.objects.create(operation=1, number=order.credit, user=order.user)

                # 2.2 补充个人的优惠券使用记录
                coupon_log = CouponLog.objects.filter(order=order).first()
                if coupon_log:
                    coupon_log.use_time = now_time
                    coupon_log.use_status = 1  # 1 表示已使用
                    coupon_log.save()

                # 3. 用户和课程的关系绑定
                user_course_list = []
                for course in course_list:
                    user_course_list.append(UserCourse(course=course, user=order.user))
                UserCourse.objects.bulk_create(user_course_list)

                # todo 4. 取消订单超时

            except Exception as e:
                logger.error(f"订单支付处理同步结果发生未知错误:{e}")
                transaction.savepoint_rollback(save_id)
                return HttpResponse("fail")

        return HttpResponse("success")

    def query(self, request, order_number):
        """主动查询订单支付的支付结果"""
        。。。


路由,payments/urls.py,代码:

from django.urls import path,re_path
from . import views
urlpatterns = [
    re_path("^alipay/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"link"})),
    path("alipay/result/", views.AlipayAPIViewSet.as_view({"get":"return_result"})),
    re_path("^alipay/query/(?P<order_number>[0-9]+)/$", views.AlipayAPIViewSet.as_view({"get":"query"})),
    path("alipay/notify", views.AlipayAPIViewSet.as_view({"post": "notify_result"})),
]


补充:异步支付结果的处理代码的验证,只能放在线上服务器中通过支付在日志中检验。无法在本地开发中校验。因为异步通知结果是需要支付宝能访问到我们的当前站点,但是现在属于本地开发。

提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端接收并处理支付宝异步通知结果"
git push


订单管理

image-20210929180934491

上面的效果,可以使用vue-router的嵌套路由来实现,根据不同的子路径,来指定部分内容改变,不会切换公共部分内容。

个人中心

订单列表位于用户中心页面,所以我们接下来先完善头部子组件中的跳转链接。

src/components/Header.vue,代码:

              <el-dropdown>
                <span class="el-dropdown-link">
                  <router-link  to="/user">
                  <el-avatar class="avatar" size="50" src="https://fuguangapi.oss-cn-beijing.aliyuncs.com/avatar.jpg"></el-avatar>
                  </router-link>
                </span>
                <template #dropdown>
                  <el-dropdown-menu>
                    <el-dropdown-item :icon="UserFilled"><router-link to="/user">个人中心</router-link></el-dropdown-item>
                    <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
                    <el-dropdown-item :icon="Setting">个人设置</el-dropdown-item>
                    <el-dropdown-item :icon="Position" @click="logout">注销登录</el-dropdown-item>
                  </el-dropdown-menu>
                </template>
              </el-dropdown>

个人中心页面效果展示,个人信息页面:

image-20210810032254734

我的订单页面:

image-20210810032322132

个人中心主页面,src/views/User.vue,代码:

<template>
  <Header></Header>
  <main class="clearfix">
    <div class="bg-other user-head-info">
      <div class="user-info clearfix">
        <div class="user-pic" data-is-fans="0" data-is-follows="">
            <div class="user-pic-bg"><img class="img" :src="store.state.user.avatar" alt=""></div>
        </div>
        <div class="user-info-right">
          <h3 class="user-name clearfix"><span>墨落</span></h3>
          <p class="about-info">
            <span>男</span>
            <span>CG影视动画师</span>
          </p>
        </div>
        <div class="user-sign hide">
          <p class="user-desc">这位同学很懒,木有签名的说~</p>
        </div>
        <div class="study-info clearfix">
          <div class="item follows">
            <div class="u-info-learn" title="学习时长16分" style="cursor:pointer;">
              <em>0.28h</em>
              <span>学习时长 </span>
            </div>
          </div>
          <div class="item follows">
            <router-link to="/u/index/credit"><em>0</em></router-link>
            <span>积分</span>
          </div>
          <div class="item follows">
            <router-link to="/u/index/follows"><em>12</em></router-link>
            <span>关注</span>
          </div>
          <div class="item follows">
            <router-link to="/user/setbindsns" class="set-btn"><i class="icon-set"></i>个人设置</router-link>
          </div>
        </div>
      </div>
    </div>
    <div class="main clearfix">
      <div class="slider l">
        <h1>个人中心</h1>
        <ul class="nav-menu">
          <li class="clearfix" :class="{active:route.path === '/user'}">
            <router-link to="/user">
              <p class="nav-name l">个人信息</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
          <li class="clearfix" :class="{active:route.path === '/user/course'}">
            <router-link to="/user/course">
              <p class="nav-name l">我的课程</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
          <li class="clearfix" :class="{active:route.path === '/user/order'}">
            <router-link to="/user/order">
              <p class="nav-name l">我的订单</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
          <li class="clearfix" :class="{active:route.path === '/user/balance'}">
            <router-link to="/user/balance">
              <p class="nav-name l">我的余额</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
          <li class="clearfix" :class="{active:route.path === '/user/coupon'}">
            <router-link to="/user/coupon">
              <p class="nav-name l">我的优惠券</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
          <li class="clearfix" :class="{active:route.path === '/user/bill'}">
            <router-link to="/user/bill">
              <p class="nav-name l">我的消费记录</p>
              <span class="el-icon-caret-right r"></span>
            </router-link>
          </li>
        </ul>
      </div>
      <!-- 嵌套路由,也是依靠router-view来加载不同的子页面内容 -->
      <router-view></router-view>
    </div>
  </main>
  <Footer></Footer>
</template>

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import settings from "../settings";
import {useStore} from "vuex"
import {useRoute} from "vue-router"
const store = useStore()
const route = useRoute()

</script>

<style scoped>
main{
  margin-bottom: 40px;
}
.bg-other {
	background: url("../assets/user_bg.png") no-repeat center top #000;
	background-size: cover;
}
.user-head-info{
  min-height: 200px;
}
.user-head-info .user-info {
	position: relative;
  width: 1500px;
	margin: 0 auto;
  min-height: 200px;
}

.user-head-info .user-info .user-pic {
	float: left;
	width: 148px;
	height: 148px
}

.user-head-info .user-info .user-pic .user-pic-bg {
	border: 4px solid #fff;
	box-shadow: 0 4px 8px 0 rgba(7,17,27,.1);
	width: 140px;
	height: 140px;
	position: relative;
	border-radius: 50%;
	background: #fff;
	top: 24px
}

.user-head-info .user-info .user-pic .user-pic-bg .img {
	text-align: center;
	width: 140px;
	height: 140px;
	border-radius: 50%
}

.user-head-info .user-info .user-info-right {
	float: right;
	width: 1330px;
}

.user-head-info .user-info .user-name {
	font-weight: 600;
	text-align: left;
	font-size: 24px;
	color: #fff;
	line-height: 28px;
	margin-top: 48px;
  margin-bottom: 10px;
}

.user-head-info .user-info .about-info {
	font-size: 14px;
	color: #fff;
	line-height: 20px;
	text-align: left;
	margin-top: 6px;
  display: block;
}

.user-head-info .user-info .about-info span {
	display: inline-block;
	margin-right: 10px;
	font-size: 14px;
	color: #fff;
	line-height: 20px
}

.user-head-info .user-info .user-sign {
	font-size: 14px;
	color: #fff;
	line-height: 24px;
	width: 440px;
	overflow: hidden;
	word-break: break-all;
	word-wrap: break-word
}

.user-head-info .user-info .user-desc {
	font-size: 14px;
	line-height: 24px;
	color: #fff;
	text-align: left;
	margin-top: 20px;
	word-break: break-all;
	word-wrap: break-word;
	opacity: .8;
	margin-left: 24px
}

.user-head-info .study-info {
	position: absolute;
	top: 48px;
	right: 10px;
	min-width: 200px;
	text-align: right
}

.user-head-info .study-info .item {
	line-height: 48px;
	vertical-align: middle;
	height: 48px;
	float: left
}

.user-head-info .study-info .item em {
	display: block;
	text-align: center;
	font-weight: 700;
	font-size: 24px;
	color: rgba(255,255,255,.8);
	line-height: 28px
}

.user-head-info .study-info .item span {
	display: block;
	text-align: center;
	font-size: 14px;
	color: rgba(255,255,255,.8);
	line-height: 20px;
	margin-top: 4px
}

.user-head-info .study-info .follows {
	margin-right: 24px
}

.user-head-info .study-info .set-btn {
	padding: 8px 16px;
	border: 1px solid rgba(255,255,255,.4);
	border-radius: 18px;
	font-size: 14px;
	color: rgba(255,255,255,.8);
	line-height: 20px;
	height: 20px
}

.user-head-info .study-info .set-btn i {
	font-size: 16px;
	display: inline-block;
	margin-right: 4px
}

.user-head-info .study-info .set-btn:hover {
	color: #fff;
	border-color: #fff
}

.l {
    float: left;
}
.r {
    float: right;
}

.clearfix:after {
    content: '\0020';
    display: block;
    height: 0;
    clear: both;
    visibility: hidden;
}

.main{
  width: 1500px;
  margin: 36px auto;
}

.slider {
	margin-right: 32px;
	width: 180px;
	box-sizing: border-box
}

.slider h1 {
	padding-bottom: 16px;
	font-size: 14px;
	color: #4d555d;
	line-height: 32px;
	border-bottom: 1px solid #d9dde1
}

.slider .nav-menu {
	width: 100%
}

.slider .nav-menu li {
	margin-top: 16px;
	width: 100%;
	height: 32px;
	line-height: 32px;
	box-sizing: border-box;
	cursor: pointer;
	font-size: 14px;
	color: #4d555d
}

.slider .nav-menu li a {
	color: #07111b
}

.slider .nav-menu li a:hover {
	color: #f01414
}

.slider .nav-menu li .nav-name {
	font-size: 14px
}

.slider .nav-menu li .el-icon-caret-right {
	font-size: 16px;
	line-height: 32px
}

.slider .nav-menu li:hover {
	color: #07111b
}

.slider .nav-menu li:hover a {
	color: #07111b
}

.slider .nav-menu li:hover .el-icon-caret-right {
	color: #07111b
}

.slider .nav-menu li.active {
	color: #f01414
}

.slider .nav-menu li.active a {
	color: #f01414
}

.slider .nav-menu li.active a:hover {
	color: #f01414
}

.slider .nav-menu li.active .el-icon-caret-right {
	color: #f01414
}
</style>

个人信息页面效果展示

src/components/user/Info.vue,代码:

<template>
  <div class="setting-right">
   <div class="setting-right-wrap wrap-boxes settings">
    <div class="formBox">
     <div id="setting-profile" class="setting-wrap setting-profile">
      <div class="common-title">
        个人信息
       <a href="javascript: void(0);" class="pull-right js-edit-info"><i class="el-icon-edit"></i>编辑</a>
      </div>
      <div class="line"></div>
      <div class="info-wapper">
       <div class="info-box clearfix">
        <label class="pull-left">昵称</label>
        <div class="pull-left">墨落</div>
       </div>
       <div class="info-box clearfix">
        <label class="pull-left">职位</label>
        <div class="pull-left">CG影视动画师</div>
       </div>
       <div class="info-box clearfix">
        <label class="pull-left">城市</label>
        <div class="pull-left">未设置</div>
       </div>
       <div class="info-box clearfix">
        <label class="pull-left">性别</label>
        <div class="pull-left">男</div>
       </div>
       <div class="info-box clearfix">
        <label class="pull-left">个性签名</label>
        <div class="pull-left">未设置</div>
       </div>
      </div>
     </div>
    </div>
   </div>
  </div>
</template>

<script setup>

</script>

<style scoped>
.clearfix:after {
	content: '\0020';
	display: block;
	height: 0;
	clear: both;
	visibility: hidden;
}

.setting-right {
	float: left;
	width: 1284px;
	box-sizing: border-box;
	background-color: #fff
}

.setting-right-wrap {
	min-height: 550px
}

.pull-left {
	float: left;
}

.pull-right {
	float: right;
}

.common-title {
	line-height: 32px;
	font-size: 16px;
	font-weight: 700;
}

.common-title a {
	color: #93999f;
	font-weight: 400;
}

.common-title a:hover {
	color: #008cc8;
}

.common-title a i {
	color: #008cc8;
	margin-right: 4px;
	vertical-align: middle;
}

.line {
	height: 1px;
	background-color: #d0d6d9;
	margin-top: 12px;
}

.setting-profile {
	padding: 0!important
}

.setting-profile .info-wapper {
	margin: 24px auto 24px 40px
}

.setting-profile .info-box {
	margin-bottom: 12px
}

.setting-profile .info-box label {
	width: 180px;
	line-height: 20px;
	padding: 20px 0;
	text-align: center;
	background-color: #f3f5f7;
	color: #07111b;
	font-weight: 700
}

.setting-profile .info-box div {
	width: 1034px;
	margin-left: 8px;
	line-height: 20px;
	padding: 20px 0 20px 22px;
	border-bottom: 1px solid #d9dde1
}

.edit-info .wlfg-wrap textarea {
	height: 70px
}

.edit-info .wlfg-wrap input {
	font-size: 14px
}

</style>

我的订单页面展示

src/components/user/Order.vue,代码:

<template>
      <div class="right-container l">
        <div class="right-title">
          <h2>我的订单</h2>
          <ul>
            <li class="action"><router-link to="/user/order">全部<i class="js-all-num">3</i></router-link></li>
            <li><router-link to="/user/order?type=unpaid">未支付</router-link></li>
            <li><router-link to="/user/order?type=paid">已完成</router-link></li>
            <li><router-link to="/user/order?type=invalid">已废弃</router-link></li>
          </ul>
        </div>
        <div class="myOrder">
          <ul class="myOrder-list">
            <li data-flag="2107312249236254">
              <p class="myOrder-number">
                <i class="imv2-receipt"></i>订单编号:2107312249236254
                <span class="date">2021-07-31 22:49:23</span>
                <i class="imv2-delete js-order-del" title="删除订单"></i>
                <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
              </p>
              <div class="myOrder-course clearfix">
                <dl class="course-del l">
                  <dd class="clearfix">
                    <router-link to="" class="l"><img class="l" src="" width="160" height="90"></router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/525"><p class="course-name">晋级TypeScript高手,成为抢手的前端开发人才</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥358.00</span>
                      </p>
                    </div>
                  </dd>
                  <dd class="clearfix">
                    <router-link to="" class="l"><img class="l" src="" width="160" height="90"></router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/525"><p class="course-name">晋级TypeScript高手,成为抢手的前端开发人才</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥358.00</span>
                      </p>
                    </div>
                  </dd>
                </dl>
                <!-- 使用优惠券 -->
                <div class="course-money l pt15">
                  <div class="wrap">
                    <div class="type-box clearfix mb10">
                      <p class="type-text l">原价</p>
                      <p class="type-price l line-though"><span class="RMB">¥</span>399.00</p>
                    </div>
                    <div class="type-box clearfix mb10">
                      <p class="type-text l">折扣</p>
                      <p class="type-price l">-<span class="RMB">¥</span>41.00</p>
                    </div>
                    <div class="total-box clearfix">
                      <p class="type-text l">实付</p>
                      <p class="type-price l"><span class="RMB">¥</span>358.00</p>
                    </div>
                  </div>
                </div>
                <div class="course-action l">
                  <a class="pay-now" href="/pay/cashier?trade_number=2108100232047715">立即支付</a>
                  <a class="order-cancel" href="javascript:void(0);">取消订单</a>
                </div>
              </div>
            </li>
            <li data-flag="2107312108465190">
              <p class="myOrder-number">
                <i class="imv2-receipt"></i>订单编号:2107312108465190
                <span class="date">2021-07-31 21:08:46</span>
                <i class="imv2-delete js-order-del" title="删除订单"></i>
                <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
              </p>
              <div class="myOrder-course clearfix">
                <dl class="course-del l">
                  <dd class="clearfix">
                    <router-link to="/course/301" class="l">
                      <img class="l" src="" width="160" height="90">
                    </router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/301"><p class="course-name">Hadoop 系统入门+核心精讲</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥288.00</span>
                      </p>
                    </div>
                  </dd>
                  <dd class="clearfix">
                    <router-link to="/course/464" class="l">
                      <img class="l" src="" width="160" height="90">
                    </router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/464"><p class="course-name">Kubernetes 入门到进阶实战,系统性掌握 K8s 生产实践</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥299.00</span>
                      </p>
                    </div>
                  </dd>
                  <dd class="clearfix">
                    <router-link to="/course/501" class="l">
                      <img class="l" src="" width="160" height="90">
                    </router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/501"><p class="course-name">2021必修  CSS架构系统精讲 理论+实战玩转蘑菇街</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥288.00</span>
                      </p>
                    </div>
                  </dd>
                  <dd class="clearfix">
                    <router-link to="/course/503" class="l">
                      <img class="l" src="" width="160" height="90">
                    </router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/503"><p class="course-name">Vue3开发企业级音乐Web App 明星讲师带你学习大厂高质量代码</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥448.00</span>
                      </p>
                    </div>
                  </dd>
                  <dd class="clearfix">
                    <router-link to="/course/522" class="l">
                      <img class="l" src="" width="160" height="90">
                    </router-link>
                    <div class="del-box l">
                      <!-- type为类型 1实战购买 2实战续费 4就业班购买 5就业班续费 -->
                      <!-- cate 订单类型 0无优惠 1组合套餐 2学生优惠 -->
                      <router-link to="/course/522"><p class="course-name"> Spring Cloud / Alibaba 微服务架构实战,从架构设计到开发实践,手把手实现</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥428.00</span>
                      </p>
                    </div>
                  </dd>
                </dl>
                <!-- 使用优惠券 -->
                <div class="course-money l pt15">
                  <div class="wrap">
                    <div class="type-box clearfix mb10">
                      <p class="type-text l">原价</p>
                      <p class="type-price l line-though">
                        <span class="RMB">¥</span>
                        1811.00
                      </p>
                    </div>
                    <div class="type-box clearfix mb10">
                      <p class="type-text l">折扣</p>
                      <p class="type-price l">
                        -
                        <span class="RMB">¥</span>
                        60.00
                      </p>
                    </div>
                    <div class="total-box clearfix">
                      <p class="type-text l">实付</p>
                      <p class="type-price l">
                        <span class="RMB">¥</span>
                        1751.00
                      </p>
                    </div>
                  </div>
                </div>
                <div class="course-action l">
                  <p class="order-close">已过期</p>
                </div>
              </div>
            </li>
          </ul>
        </div>
        <div class="page" style="text-align: center">
          <el-pagination background layout="prev, pager, next" :total="1000"></el-pagination>
        </div>
      </div>
</template>

<script setup>

</script>

<style scoped>

.l {
    float: left;
}
.r {
    float: right;
}

.clearfix:after {
    content: '\0020';
    display: block;
    height: 0;
    clear: both;
    visibility: hidden;
}

/*****/
.right-container {
	width: 1284px;
}

.right-container .right-title {
	margin-bottom: 24px
}

.right-container .right-title::after {
	content: '';
	clear: both;
	display: block
}

.right-container .right-title h2 {
	margin-right: 24px;
	float: left;
	font-size: 16px;
	color: #07111b;
	line-height: 32px;
	font-weight: 700
}

.right-container .right-title ul {
	float: left
}

.right-container .right-title ul:before {
	float: left;
	margin-top: 2px;
	margin-right: 20px;
	content: "|";
	color: #d9dde1
}

.right-container .right-title ul li {
	float: left;
	width: 95px;
	line-height: 32px;
	text-align: center;
	font-size: 14px
}

.right-container .right-title ul li.action {
	background: #4d555d;
	border-radius: 16px
}

.right-container .right-title ul li.action a {
	color: #fff
}

.right-container .right-title ul li i {
	padding-left: 5px;
	font-style: normal
}

.right-container .right-title span {
	position: relative;
	float: right;
	color: #93999f;
	font-size: 14px;
	cursor: pointer;
	width: 128px;
	line-height: 32px
}

.right-container .right-title span i {
	float: left;
	margin-top: 8px;
	margin-left: 28px;
	margin-right: 4px;
	font-size: 16px
}

.right-container .right-title span a {
	display: block
}

.right-container .right-title span.action {
	background: #4d555d;
	border-radius: 16px
}

.right-container .right-title span.action a {
	color: #fff
}

.myOrder {
	width: 100%
}

.myOrder-list li {
	padding: 32px;
	padding-top: 0;
	box-shadow: 0 2px 8px 2px rgba(0,0,0,.1);
	margin-bottom: 24px;
	background: #fff;
	border-radius: 8px;
	position: relative
}

.myOrder-list li dd {
	margin-top: 24px;
	padding-top: 24px;
	position: relative;
	box-sizing: border-box;
	border-top: 1px solid #d9dde1
}

.myOrder-list li dd a {
	display: block
}

.myOrder-list li dd:first-child {
	border-top: none;
	margin-top: 0;
	padding-top: 0
}

.myOrder-list li:hover {
	-webkit-box-shadow: 0 2px 16px 2px rgba(0,0,0,.1);
	-moz-box-shadow: 0 2px 16px 2px rgba(0,0,0,.1);
	box-shadow: 0 2px 16px 2px rgba(0,0,0,.1)
}

.myOrder-list li:hover .myOrder-number a,.myOrder-list li:hover i.imv2-delete {
	display: block
}

.del-box {
	margin-left: 16px;
	width: 510px
}

.del-box .course-name {
	word-break: break-word;
	color: #07111b;
	font-size: 16px;
	margin-bottom: 8px;
	line-height: 22px
}

.del-box .price-btn-box {
	font-size: 14px;
	line-height: 14px
}

.del-box .price-btn-box .truepay-text {
	color: #93999f;
	margin-right: 5px
}

.del-box .price-btn-box .course-little-price {
	color: #f01414
}

.myOrder-number {
	padding: 28px 0 19px;
	font-weight: 700;
	color: #4d555d;
	border-bottom: 1px solid #b7bbbf;
	font-size: 14px;
	line-height: 14px;
	box-sizing: border-box
}

.myOrder-number a,.myOrder-number span {
	color: #93999f;
	font-weight: 500;
	margin-left: 24px
}

.myOrder-number a {
	display: none
}

.myOrder-number a:hover {
	color: #4d555d
}

.myOrder-number i.imv2-delete,.myOrder-number i.imv2-receipt {
	float: left;
	margin-top: -2px;
	margin-right: 10px;
	font-size: 16px;
	color: #f01414
}

.myOrder-number i.imv2-delete {
	float: right;
	margin-left: 28px;
	color: #93999f;
	cursor: pointer;
	display: none
}

.myOrder-number i.imv2-delete:hover {
	color: #4d555d
}

.myOrder-course {
	position: relative;
	margin-top: 25px
}

.course-money {
	width: 250px;
	height: 100%;
	text-align: center;
	color: #93999f;
	font-size: 16px;
	box-sizing: border-box;
	line-height: 16px
}

.course-money .wrap {
	display: inline-block
}

.course-money .RMB {
	font-size: 14px;
	vertical-align: top;
	line-height: 14px
}

.course-money .type-box {
	line-height: 14px;
	text-align: left
}

.course-money .type-box .type-price,.course-money .type-box .type-text {
	font-size: 16px;
	color: #93999f
}

.course-money .type-box .type-price .RMB,.course-money .type-box .type-text .RMB {
	font-size: 14px;
	display: inline-block;
	position: relative;
	top: -1px;
	vertical-align: top;
	line-height: 14px
}

.course-money .type-box .line-though {
	text-decoration: line-through
}

.course-money .type-box .type-text {
	margin-right: 5px
}

.course-money .total-box .type-text {
	font-size: 14px;
	color: #93999f;
	margin-right: 5px
}

.course-money .total-box .type-price {
	color: #f01414
}

.course-money .mb10 {
	margin-bottom: 10px
}

.course-money.presale .type-box {
	line-height: 18px;
	margin-bottom: 4px
}

.course-money.presale .type-box .type-text {
	color: #1c1f21
}

.course-money.presale .type-box .type-price .RMB {
	vertical-align: baseline
}

.course-action {
	position: absolute;
	top: 0;
	width: 180px;
	height: 100%;
	border-left: 1px solid #d9dde1;
	right: 0;
	text-align: center
}

.course-action .pay-now {
	margin: 12px auto;
	display: block;
	width: 120px;
	height: 36px;
	color: #fff;
	background: rgba(240,20,20,.8);
	border-radius: 18px;
	line-height: 36px
}

.course-action .pay-now:hover {
	background-color: #f01414
}

.course-action .order-cancel {
	color: #93999f;
	display: block;
	font-size: 14px;
	line-height: 14px
}

.course-action .order-cancel:hover {
	color: #4d555d
}

.course-action .order-close {
	color: #93999f;
	margin-top: 36px;
	line-height: 14px
}

.course-action.order-recover .order-close {
	margin-top: 22px
}

.course-del {
	width: 740px;
	border-right: 1px solid #d9dde1;
	position: relative
}

</style>

路由,src/router/index.js,代码:

import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
import store from "../store";

// 路由列表
const routes = [
  {
    meta:{
        title: "luffy2.0-站点首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "luffy2.0-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  },
  {
      meta:{
        title: "luffy2.0-用户注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
  },
  {
    meta:{
        title: "luffy2.0-个人中心",
        keepAlive: true,
        authorization: true,
    },
    path: '/user',
    name: "User",
    component: ()=> import("../views/User.vue"),
    children: [
        {
          meta:{
            title: "luffy2.0-个人信息",
            keepAlive: true,
            authorization: true,
          },
          path: '',
          name: "UserInfo",
          component: ()=> import("../components/user/Info.vue"),
        },
        {
          meta:{
            title: "luffy2.0--我的订单",
            keepAlive: true,
            authorization: true,
          },
          path: 'order',
          name: "UserOrder",
          component: ()=> import("../components/user/Order.vue"),
        },
      ]
  },
  {
    meta:{
        title: "luffy2.0-课程列表",
        keepAlive: true,
    },
    path: '/project',
    name: "Course",
    component: ()=> import("../views/Course.vue"),
  },
  {
    meta:{
        title: "luffy2.0-课程详情",
        keepAlive: true
    },
    path: '/project/:id',     // :id vue的路径参数,代表了课程的ID
    name: "Info",
    component: ()=> import("../views/Info.vue"),
  },
  {
      meta:{
        title: "luffy2.0-购物车",
        keepAlive: true
      },
      path: '/cart',
      name: "Cart",
      component: ()=> import("../views/Cart.vue"),
  },{
      meta:{
        title: "确认下单",
        keepAlive: true
      },
      path: '/order',
      name: "Order",
      component: ()=> import("../views/Order.vue"),
  },
  {
    meta:{
      title: "支付成功",
      keepAlive: true
    },
    path: '/alipay',
    name: "PaySuccess",
    component: ()=> import("../views/AliPaySuccess.vue"),
  },
]


// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});

// 导航守卫
router.beforeEach((to, from, next)=>{
  document.title=to.meta.title
  // 登录状态验证
  if (to.meta.authorization && !store.getters.getUserInfo) {
    next({"name": "Login"})
  }else{
    next()
  }
})


// 暴露路由对象
export default router

我的订单

服务端提供当前用户的订单列表api接口

orders/views.py,代码:

from rest_framework.generics import CreateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.generics import ListAPIView
from .serializers import OrderListModelSerializer
from .paginations import OrderListPageNumberPagination

from .models import Order
from .serializers import OrderModelSerializer

# 中间代码省略。。。。
# 中间代码省略。。。。

class OrderPayChoicesAPIView(APIView):
    def get(self,request):
        """订单过滤过滤选项"""
        return Response(Order.status_choices)


class OrderListAPIView(ListAPIView):
    """当前登录用户的订单列表"""
    permission_classes = [IsAuthenticated]
    serializer_class = OrderListModelSerializer
    pagination_class = OrderListPageNumberPagination

    def get_queryset(self):
        user = self.request.user  # 获取当前登录用户
        query = Order.objects.filter(user=user, is_deleted=False, is_show=True)
        order_status = int(self.request.query_params.get("status", -1))
        status_list = [item[0] for item in Order.status_choices]
        if order_status in status_list:
            query = query.filter(order_status=order_status)
        return query.order_by("-id").all()


orders/paginations.py,代码:

from rest_framework.pagination import PageNumberPagination


class OrderListPageNumberPagination(PageNumberPagination):
    """订单列表分页器"""
    page_size = 5  # 每一页显示数据量
    page_size_query_param = "size"  # 地址栏上的页码
    max_page_size = 20  # 允许客户端通过size参数修改的每页最大数据量
    page_query_param = "page" # 地址栏上的页面参数名

orders/serializers.py,代码:

class OrderDetailMdoelSerializer(serializers.ModelSerializer):
    """订单详情序列化器"""
    # 通过source修改数据源,可以把需要调用的部分外键字段提取到当前序列化器中
    course_id = serializers.IntegerField(source="course.id")
    course_name = serializers.CharField(source="course.name")
    course_cover = serializers.ImageField(source="course.course_cover")

    class Meta:
        model = OrderDetail
        fields = ["id", "price", "real_price", "discount_name", "course_id", "course_name", "course_cover"]


class OrderListModelSerializer(serializers.ModelSerializer):
    """订单列表序列化器"""
    order_courses = OrderDetailMdoelSerializer(many=True)
    
    class Meta:
        model = Order
        fields = ["id", "order_number", "total_price", "real_price", "pay_time", "created_time", "credit", "coupon",
                  "pay_type", "order_status", "order_courses"]


orders/models.py,代码:

class Order(BaseModel):
    """订单基本信息模型"""
    # //.... 中间代码省略
    # //.... 中间代码省略
    # //.... 中间代码省略
    def coupon(self):
        """当前订单关联的优惠券信息"""
        coupon_related = self.to_coupon.first()
        if coupon_related:
            return {
                "id": coupon_related.coupon.id,
                "name": coupon_related.coupon.name,
                "sale": coupon_related.coupon.sale,
                "discount": coupon_related.coupon.discount,
                "condition": coupon_related.coupon.condition,
            }
        return {}



orders/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [
    path("", views.OrderCreateAPIView.as_view()),
    path("pay/choices/", views.OrderPayChoicesAPIView.as_view()),
    path("list/", views.OrderListAPIView.as_view()),
]


客户端展示订单列表

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
    // ... 中间代码省略
    // ... 中间代码省略
    // ... 中间代码省略
    order_status: -1,    // 个人中心的默认显示的订单状态选项
    order_status_chioces:[], // 个人中心的订单支付状态选项
    page: 1,                 // 个人中心的订单列表对应的页码
    size: 5,                 // 个人中心的订单列表对应的单页数据量
    order_list:[],           // 个人中心的订单列表
    count: 0,                // 个人中心的订单列表的总数据量
    // ... 中间代码省略
    // ... 中间代码省略
    // ... 中间代码省略
    get_order_status(){
        // 获取订单状态选项
        return http.get('/orders/pay/choices/')
    },
    get_order_list(token){
        // 获取当前登录用户的订单列表[分页显示]
        return http.get('/orders/list/', {
            params: {
                page: this.page,
                size: this.size,
                status: this.order_status,
            },
            headers: {
                Authorization: "jwt " + token,
            }
        })
    }
});

export default order;


src/components/user/Order.vue,代码:

<template>
      <div class="right-container l">
        <div class="right-title">
          <h2>我的订单</h2>
          <ul>
            <li :class="{action: order.order_status===-1}"><a href="" @click.prevent="order.order_status=-1">全部<i class="js-all-num" v-if="order.order_status===-1">{{order.count}}</i></a></li>
            <li :class="{action: order.order_status===status[0]}" v-for="status in order.order_status_chioces">
              <a href="" @click.prevent="order.order_status=status[0]">{{status[1]}}<i class="js-all-num" v-if="order.order_status===status[0]">{{order.count}}</i></a>
            </li>
          </ul>
        </div>
        <div class="myOrder">
          <ul class="myOrder-list">
            <li v-for="order_info in order.order_list">
              <p class="myOrder-number">
                <i class="imv2-receipt"></i>订单编号:{{order_info.order_number}}
                <span class="date">{{order_info.created_time.replace("T", " ").split(".")[0]}}</span>
                <span class="imv2-delete js-order-del">删除订单</span>
                <router-link to="/user/help" target="_blank" class="myfeedback r">售后帮助</router-link>
              </p>
              <div class="myOrder-course clearfix">
                <dl class="course-del l"  v-for="course_info in order_info.order_courses">
                  <dd class="clearfix">
                    <router-link :to="`/project/${course_info.course_id}`" class="l"><img class="l" :src="course_info.course_cover" width="160" height="90"></router-link>
                    <div class="del-box l">
                      <router-link :to="`/project/${course_info.course_id}`"><p class="course-name">{{course_info.course_name}}</p></router-link>
                      <p class="price-btn-box clearfix">
                        <!-- 如果有优惠券 -->
                        <span class="l truepay-text" v-if="course_info.price > course_info.real_price">原价</span>
                        <span class="l line-though clearfix" style="float: none" v-if="course_info.price > course_info.real_price">¥{{course_info.price}}</span>
                        <span class="l truepay-text" v-if="course_info.price > course_info.real_price">折扣</span>
                        <span class="l line-though clearfix" style="float: none" v-if="course_info.price > course_info.real_price">¥{{parseFloat(course_info.price - course_info.real_price).toFixed(2)}}</span>
                        <span class="l truepay-text">实付</span>
                        <span class="l course-little-price">¥{{course_info.real_price}}</span>
                      </p>
                    </div>
                  </dd>
                </dl>
                <!-- 使用优惠券 -->
                <div class="course-money l pt15">
                  <div class="wrap">
                    <div class="type-box clearfix mb10">
                      <p class="type-text l">订单总价</p>
                      <p class="type-price l line-though"><span class="RMB">¥</span>{{order_info.total_price}}</p>
                    </div>
                    <div class="type-box clearfix mb10" v-if="order_info.total_price > order_info.real_price">
                      <p class="type-text l" v-if="order_info.credit>0">积分折扣</p>
                      <p class="type-text l" v-if="order_info.coupon.id">优惠券折扣</p>
                      <p class="type-price l">-<span class="RMB">¥</span>{{parseFloat(order_info.total_price - order_info.real_price).toFixed(2)}}</p>
                    </div>
                    <div class="total-box clearfix">
                      <p class="type-text l">订单实付</p>
                      <p class="type-price l"><span class="RMB">¥</span>{{order_info.real_price}}</p>
                    </div>
                  </div>
                </div>
                <div class="course-action l" v-if="order_info.order_status === 0">
                  <a class="pay-now" href="" @click.prevent="pay_now(order_info)">立即支付</a>
                  <a class="order-cancel" href="" @click.prevent="pay_cancel(order_info)">取消订单</a>
                </div>
                <div class="course-action l" v-else-if="order_info.order_status === 1">
                  <a class="pay-now" href="" @click.prevent="evaluate_now(order_info)">立即评价</a>
                  <a class="order-cancel" href="" @click.prevent="order_refund(order_info)">申请退款</a>
                </div>
                <div class="course-action l" v-else-if="order_info.order_status === 2">
                  <a class="pay-now" href="" @click.prevent="delete_order(order_info)">删除订单</a>
                </div>
                <div class="course-action l" v-else-if="order_info.order_status === 3">
                  <a class="pay-now" href="" @click.prevent="recovery_now(order_info)">订单恢复</a>
                  <a class="pay-now" href="" @click.prevent="delete_order(order_info)">删除订单</a>
                </div>
              </div>
            </li>
          </ul>
        </div>
        <div class="page" style="text-align: center">
          <el-pagination
              background
              layout="sizes, prev, pager, next, jumper"
              :total="order.count"
              :page-sizes="[5, 10, 15, 20]"
              :page-size="order.size"
              @current-change="current_page"
              @size-change="current_size"
          ></el-pagination>
        </div>
      </div>
</template>


<script setup>
import {watch} from "vue";
import order from "../../api/order"

const getOrderStatus = ()=>{
    // 获取订单状态选项
    order.get_order_status().then(response=>{
        order.order_status_chioces = response.data;
    })
}
getOrderStatus()


const getOrderList = ()=>{
    // 获取当前登录用户的订单列表
    let token = sessionStorage.token || localStorage.token;
    order.get_order_list(token).then(response=>{
    order.order_list = response.data.results
    order.count = response.data.count
  })
}
getOrderList()

let pay_now = (order_info)=>{
  // 订单继续支付
}
let pay_cancel = (order_info)=>{
  // 取消订单
}

let evaluate_now = (order_info)=>{
  // 订单评价
}

let order_refund = (order_info)=>{
  // 申请退款
}

let delete_order = (order_info)=>{
  // 删除订单
}

let recovery_now = (order)=>{
  // 恢复订单
}

// 切换页码
let current_page = (page)=>{
  order.page = page;
}

// 切换分页数据量
let current_size = (size)=>{
  order.size = size;
}

// 监听页码
watch(
    ()=>order.page,
    ()=>{
      getOrderList()
    }
)

// 监听页面数据量大小
watch(
    ()=>order.size,
    ()=>{
      order.page = 1;
      getOrderList()
    }
)

// 监听订单状态选项
watch(
    ()=>order.order_status,
    ()=>{
      order.page = 1;
      getOrderList()
    }
)

</script>


提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端展示用户中心并显示当前用户的订单列表"
git push


订单状态切换

取消订单

服务端提供取消订单的API接口

orders/views.py,代码:

import logging

from rest_framework.generics import CreateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.generics import ListAPIView
from rest_framework.viewsets import ViewSet
from rest_framework import status
from django.db import transaction

from .serializers import OrderListModelSerializer
from .paginations import OrderListPageNumberPagination
from .models import Order
from .serializers import OrderModelSerializer
from coupon.services import add_coupon_to_redis

logger = logging.getLogger("django")

# Create your views here.
。。。

class OrderViewSet(ViewSet):
    permission_classes = [IsAuthenticated]

    def pay_cancel(self, request, pk):
        """取消订单"""
        try:
            order = Order.objects.get(pk=pk, order_status=0)
        except:
            return Response({"eremsg": "当前订单记录不存在或不能取消!"}, status=status.HTTP_400_BAD_REQUEST)

        with transaction.atomic():
            save_id = transaction.savepoint()
            try:
                # 1. 查询当前订单是否使用了积分,如果有则恢复
                if order.credit > 0:
                    order.user.credit += order.credit
                    order.user.save()

                # 2. 查询当前订单是否使用了优惠券,如果有则恢复
                obj = order.to_coupon.first()
                if obj:
                    add_coupon_to_redis(obj)

                # 3. 切换当前订单为取消状态
                order.order_status = 2
                order.save()

                return Response({"error": "当前订单已取消!"})

            except Exception as e:
                transaction.savepoint_rollback(save_id)
                logger.error(f"订单无法取消!发生未知错误!{e}")
                return Response({"errmsg": "当前订单无法取消,请联系客服工作人员!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)



coupon/services.py,代码:

import json
from datetime import datetime
from django_redis import get_redis_connection
from courses.models import Course

.....

def add_coupon_to_redis(obj):
    """
    添加优惠券使用记录到redis中
    """
    redis = get_redis_connection("coupon")
    # 记录优惠券信息到redis中
    pipe = redis.pipeline()
    pipe.multi()
    pipe.hset(f"{obj.user.id}:{obj.id}", "coupon_id", obj.coupon.id)
    pipe.hset(f"{obj.user.id}:{obj.id}", "name", obj.coupon.name)
    pipe.hset(f"{obj.user.id}:{obj.id}", "discount", obj.coupon.discount)
    pipe.hset(f"{obj.user.id}:{obj.id}", "get_discount_display", obj.coupon.get_discount_display())

    pipe.hset(f"{obj.user.id}:{obj.id}", "coupon_type", obj.coupon.coupon_type)
    pipe.hset(f"{obj.user.id}:{obj.id}", "get_coupon_type_display", obj.coupon.get_coupon_type_display())

    pipe.hset(f"{obj.user.id}:{obj.id}", "start_time", obj.coupon.start_time.strftime("%Y-%m-%d %H:%M:%S"))
    pipe.hset(f"{obj.user.id}:{obj.id}", "end_time", obj.coupon.end_time.strftime("%Y-%m-%d %H:%M:%S"))

    pipe.hset(f"{obj.user.id}:{obj.id}", "get_type", obj.coupon.get_type)
    pipe.hset(f"{obj.user.id}:{obj.id}", "get_get_type_display", obj.coupon.get_get_type_display())

    pipe.hset(f"{obj.user.id}:{obj.id}", "condition", obj.coupon.condition)
    pipe.hset(f"{obj.user.id}:{obj.id}", "sale", obj.coupon.sale)

    pipe.hset(f"{obj.user.id}:{obj.id}", "to_direction",
              json.dumps(list(obj.coupon.to_direction.values("direction__id", "direction__name"))))
    pipe.hset(f"{obj.user.id}:{obj.id}", "to_category",
              json.dumps(list(obj.coupon.to_category.values("category__id", "category__name"))))
    pipe.hset(f"{obj.user.id}:{obj.id}", "to_course",
              json.dumps(list(obj.coupon.to_course.values("course__id", "course__name"))))

    # 设置当前优惠券的有效期
    pipe.expire(f"{obj.user.id}:{obj.id}", int(obj.coupon.end_time.timestamp() - datetime.now().timestamp()))
    pipe.execute()



对于之前在admin站点中保存优惠券使用都redis中的操作,也可以调用上面的代码,coupon/admin.py,代码:

import json
from datetime import datetime
from django_redis import get_redis_connection
from django.contrib import admin
from .models import Coupon, CouponDirection, CouponCourseCat, CouponCourse, CouponLog
from .services import add_coupon_to_redis

....


class CouponLogModelAdmin(admin.ModelAdmin):
    """优惠券发放和使用日志"""
    list_display = ["id", "user", "coupon", "order", "use_time", "use_status"]

    def save_model(self, request, obj, form, change):
        """
        保存或更新记录时自动执行的钩子
        request: 本次客户端提交的请求对象
        obj: 本次操作的模型实例对象
        form: 本次客户端提交的表单数据
        change: 值为True,表示更新数据,值为False,表示添加数据
        """
        obj.save()

        # 同步记录到redis中
        redis = get_redis_connection("coupon")

        # print(obj.use_status , obj.use_time)
        if obj.use_status == 0 and obj.use_time == None:
            # 记录优惠券信息到redis中
            add_coupon_to_redis(obj)
        else:
            redis.delete(f"{obj.user.id}:{obj.id}")
.....



order/urls.py,代码:

from django.urls import path,re_path
from . import views

urlpatterns = [
    path("", views.OrderCreateAPIView.as_view()),
    path("pay/choices/", views.OrderPayChoicesAPIView.as_view()),
    path("list/", views.OrderListAPIView.as_view()),
    re_path("^(?P<pk>\d+)/$", views.OrderViewSet.as_view({"put": "pay_cancel"})),
]



客户端实现取消订单功能

api/order.js,代码:

import http from "../utils/http";
import {reactive} from "vue";

const order = reactive({
  // 中间代码省略....
  // 中间代码省略....
  order_cancel(order_id,token){
    // 取消订单操作
    return http.put(`/orders/${order_id}/`, {},{
        headers:{
            Authorization: "jwt " + token,
        }
    })
  }
})

export default order;


components/user/Order.vue,代码:

<script setup>
import {watch} from "vue";
import order from "../../api/order"

....

let pay_now = (order_info)=>{
  // 订单继续支付
}
let pay_cancel = (order_info)=>{
    // 取消订单
    let token = sessionStorage.token || localStorage.token;
    order.order_cancel(order_info.id,token).then(response=>{
      order_info.order_status = 2;
    })
}

....

</script>


再次支付

components/user/Order.vue,代码:

<script setup>
import {watch} from "vue";
import order from "../../api/order"

....

let pay_now = (order_info)=>{
  // 订单继续支付
  order.order_number = order_info.order_number;
  let token = sessionStorage.token || localStorage.token;
  if (order.pay_type === 0) {
    // 如果当前订单的支付方式属于支付宝,发起支付宝支付
    order.alipay_page_pay(order_info.order_number, token).then(response => {
      // 新开浏览器窗口,跳转到支付页面
      window.open(response.data.link, "_blank");
      // 新建定时器,每隔5秒到服务端查询一次当前订单的支付结果
      let max_query_timer = 180;
      clearInterval(order.timer);
      order.timer = setInterval(() => {
          max_query_timer--;
          if(max_query_timer > 0){
            order.query_order(token).then(response => {
                order_info.order_status = 1;
                clearInterval(order.timer);
            })
          }else{
            clearInterval(order.timer);
          }
      }, 5000);
    })
  }
}
....
</script>


提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 订单状态切换-取消订单与再次支付"
git push


订单超时

用户下单在15分钟以后自动判断订单状态如果是0, 则直接改成3,恢复当前订单的优惠券和用户积分。

使用Celery的定时任务来完成订单超时功能
定时任务[async_tasks],主要是依靠操作系统的计划任务或者第三方软件的定时执行
定时任务的常见场景:
   1. 订单超时取消
   2. 生日邮件[例如,每天凌晨检查当天有没有用户生日,有则发送一份祝福邮件]
   3. 财务统计[例如,每个月的1号,把当月的订单进行统计,生成一个财务记录,保存到数据库中]
   4. 页面缓存[例如,把首页设置为每隔5分钟生成一次缓存]

在django中要实现订单的超时取消,有以下2种类型,4种方式:
   1. 通过计划任务来实现定时多次
      计划任务,是celery提供给开发者设置周期任务的,可以定时多次,例如:每周一次,每分钟一次
      1.1 Celery本身提供了计划任务的schedules执行
      1.2 安装并配置django的第三方模块django-crontab[依靠系统本身的计划任务来完成,与celery无关]
   2. 通过定时任务来实现定时一次
      2.1 celery提供的apply_async来完成
      2.2 redis值空间值事件,实际上就是基于redis的发布订阅的特性来完成


在实现订单超时的定时任务之前,我们需要先简单使用一下定时任务。orders/tasks.py,代码:

import logging
from celery import shared_task

logger = logging.getLogger("django")

@shared_task(name="order_cancel")
def order_cancel(order_id):
    print(order_id)
    return True


终端下重启celery。并进入django内置的终端进行异步定时任务的测试。

# 第一个终端
celery -A luffycityapi worker -l INFO    # Windows环境下得加上-P eventlet
# 第二个终端
# 进入虚拟环境
conda activate luffycity
# 进入项目根目录
cd ~/Desktop/luffycity/luffycityapi
python manage.py shell
from orders.tasks import order_timeout
ret = order_timeout.apply_async(kwargs={"order_id": 3}, countdown=15)  # countdown为定时时间,单位:秒


效果:

image-20210811124024579

在此之前,我们已经在文件utils/constants.py中,对定时任务的定时时间设置了一个常量

# 订单超时的时间(单位:秒)
ORDER_TIMEOUT = 15 * 60


在用户下单成功时,设置订单超时的定时任务。

orders/tasks.py,代码:

from celery import shared_task
from django.db import transaction
from .models import Order
from coupon.services import add_coupon_to_redis

import logging
logger = logging.getLogger("django")


@shared_task(name="order_timeout")
def order_timeout(order_id):
    print(f"要超时取消的订单ID={order_id}")
    try:
        order = Order.objects.get(pk=order_id)
    except Exception as e:
        logger.warning(f"订单不存在!order_id:{order_id}: {e}")
        return

    if order.order_status == 0:
        """只针对未支付的订单进行超时取消"""
        with transaction.atomic():
            save_id = transaction.savepoint()
            try:
                # 1. 查询当前订单是否使用了积分,如果有则恢复
                if order.credit > 0:
                    order.user.credit += order.credit
                    order.user.save()

                # 2. 查询当前订单是否使用了优惠券,如果有则恢复
                obj = order.to_coupon.first()
                if obj:
                    add_coupon_to_redis(obj)

                # 3. 切换当前订单为取消状态
                order.order_status = 3
                order.save()

                return {"order_id": order.id, "status": True, "errmsg": f"订单超时取消成功!"}

            except Exception as e:
                transaction.savepoint_rollback(save_id)
                logger.warning(f"过期订单无法处理!order_id:{order_id}: {e}")
                return {"order_id": order.id, "status": False, "errmsg": f"{e}"}



在用户下单的时候,设置定时任务,orders/serializers.py的create创建订单时,代码:

import logging
import constants

from datetime import datetime
from rest_framework import serializers
from django_redis import get_redis_connection
from django.db import transaction
from .models import Order, OrderDetail, Course
from coupon.models import CouponLog
from .tasks import order_timeout

logger = logging.getLogger("django")


class OrderModelSerializer(serializers.ModelSerializer):
    user_coupon_id = serializers.IntegerField(write_only=True, default=-1)
    order_timeout = serializers.IntegerField(read_only=True)

    class Meta:
        model = Order
        fields = ["pay_type", "id", "order_number", "user_coupon_id", "credit", "order_timeout"]
        read_only_fields = ["id", "order_number"]
        extra_kwargs = {
            "pay_type": {"write_only": True},
            "credit": {"write_only": True},
        }

    def create(self, validated_data):
        """创建订单"""
       。。。。。。
                # 如果有使用了优惠券,则把优惠券和当前订单进行绑定
                if user_coupon:
                    user_coupon.order = order
                    user_coupon.save()
                    # 把优惠券从redis中移除
                    redis = get_redis_connection("coupon")
                    redis.delete(f"{user_id}:{user_coupon_id}")

                # 将来订单状态发生改变,再修改优惠券的使用状态,如果订单过期,则再次还原优惠券到redis中
                order_timeout.apply_async(kwargs={"order_id": order.id}, countdown=constants.ORDER_TIMEOUT)

                order.order_timeout = constants.ORDER_TIMEOUT
                return order
          。。。。。

接下来,我们就可以重启Celery即可。运行celery

cd /home/moluo/Desktop/luffycity/luffycityapi
celery -A luffycityapi worker -l INFO -P eventlet


1663230406103

提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 订单状态切换-订单超时处理"
git push


关于celery在运行过程中, 默认情况下是无法在关机以后自动重启的。所以我们一般开发中会使用supervisor进程监控来对celery程序进行运行监控!当celery没有启动的情况下,supervisor会自动启动celery,所以我们需要安装supervisor并且编写一个supervisor的控制脚本,在脚本中编写对celery进行启动的命令即可。

安装和启动celery任务监控器

针对celery中的任务执行过程,我们也可以安装一个flower的工具来进行监控。

pip install flower
cd /home/moluo/Desktop/luffycity/luffycityapi
# 保证celery在启动中
celery -A luffycityapi worker -l INFO
# 再启动celery-flower
celery -A luffycityapi flower --port=5555


image-20220915205808618

supervisor启动celery&flower

Supervisor是用Python开发的一套通用的进程管理程序,能将一个普通的命令行进程变为系统守护进程daemon,并监控进程状态,异常退出时能自动重启。

pip install supervisor
# 注意:如果supervisor是安装在虚拟环境的,则每次使用supervisor务必在虚拟环境中进行后面所有的操作
# conda activate luffycity


supervisor配置文档:http://supervisord.org/configuration.html

对Supervisor初始化配置

# 在项目根目录下创建存储supervisor配置目录,在luffycityapi创建scripts目录,已经创建则忽略
conda activate luffycity
cd /home/moluo/Desktop/luffycity/luffycityapi
mkdir -p scripts && cd scripts
# 生成初始化supervisor核心配置文件,echo_supervisord_conf是supervisor安装成功以后,自动附带的。
echo_supervisord_conf > supervisord.conf
# 可以通过 ls 查看scripts下是否多了supervisord.conf这个文件,表示初始化配置生成了。
# 在编辑器中打开supervisord.conf,并去掉最后一行的注释分号。
# 修改如下,表示让supervisor自动加载当前supervisord.conf所在目录下所有ini配置文件


supervisord/conf.py,主要修改文件中的39, 40,75,76,169,170行去掉左边注释,其中170修改成当前目录。配置代码:

; Sample supervisor config file.
;
; For more information on the config file, please see:
; http://supervisord.org/configuration.html
;
; Notes:
;  - Shell expansion ("~" or "$HOME") is not supported.  Environment
;    variables can be expanded using this syntax: "%(ENV_HOME)s".
;  - Quotes around values are not supported, except in the case of
;    the environment= options as shown below.
;  - Comments must have a leading space: "a=b ;comment" not "a=b;comment".
;  - Command will be truncated if it looks like a config file comment, e.g.
;    "command=bash -c 'foo ; bar'" will truncate to "command=bash -c 'foo ".
;
; Warning:
;  Paths throughout this example file use /tmp because it is available on most
;  systems.  You will likely need to change these to locations more appropriate
;  for your system.  Some systems periodically delete older files in /tmp.
;  Notably, if the socket file defined in the [unix_http_server] section below
;  is deleted, supervisorctl will be unable to connect to supervisord.

[unix_http_server]
file=/tmp/supervisor.sock   ; the path to the socket file
;chmod=0700                 ; socket file mode (default 0700)
;chown=nobody:nogroup       ; socket file uid:gid owner
;username=user              ; default is no username (open server)
;password=123               ; default is no password (open server)

; Security Warning:
;  The inet HTTP server is not enabled by default.  The inet HTTP server is
;  enabled by uncommenting the [inet_http_server] section below.  The inet
;  HTTP server is intended for use within a trusted environment only.  It
;  should only be bound to localhost or only accessible from within an
;  isolated, trusted network.  The inet HTTP server does not support any
;  form of encryption.  The inet HTTP server does not use authentication
;  by default (see the username= and password= options to add authentication).
;  Never expose the inet HTTP server to the public internet.

[inet_http_server]         ; inet (TCP) server disabled by default
port=127.0.0.1:9001        ; ip_address:port specifier, *:port for all iface
;username=user              ; default is no username (open server)
;password=123               ; default is no password (open server)

[supervisord]
logfile=/tmp/supervisord.log ; main log file; default $CWD/supervisord.log
logfile_maxbytes=50MB        ; max main logfile bytes b4 rotation; default 50MB
logfile_backups=10           ; # of main logfile backups; 0 means none, default 10
loglevel=info                ; log level; default info; others: debug,warn,trace
pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid
nodaemon=false               ; start in foreground if true; default false
silent=false                 ; no logs to stdout if true; default false
minfds=1024                  ; min. avail startup file descriptors; default 1024
minprocs=200                 ; min. avail process descriptors;default 200
;umask=022                   ; process file creation umask; default 022
;user=supervisord            ; setuid to this UNIX account at startup; recommended if root
;identifier=supervisor       ; supervisord identifier, default is 'supervisor'
;directory=/tmp              ; default is not to cd during start
;nocleanup=true              ; don't clean up tempfiles at start; default false
;childlogdir=/tmp            ; 'AUTO' child log dir, default $TEMP
;environment=KEY="value"     ; key value pairs to add to environment
;strip_ansi=false            ; strip ansi escape codes in logs; def. false

; The rpcinterface:supervisor section must remain in the config file for
; RPC (supervisorctl/web interface) to work.  Additional interfaces may be
; added by defining them in separate [rpcinterface:x] sections.

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

; The supervisorctl section configures how supervisorctl will connect to
; supervisord.  configure it match the settings in either the unix_http_server
; or inet_http_server section.

[supervisorctl]
; serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL  for a unix socket
serverurl=http://127.0.0.1:9001 ; use an http:// url to specify an inet socket
;username=chris              ; should be same as in [*_http_server] if set
;password=123                ; should be same as in [*_http_server] if set
;prompt=mysupervisor         ; cmd line prompt (default "supervisor")
;history_file=~/.sc_history  ; use readline history if available

; The sample program section below shows all possible program subsection values.
; Create one or more 'real' program: sections to be able to control them under
; supervisor.

;[program:theprogramname]
;command=/bin/cat              ; the program (relative uses PATH, can take args)
;process_name=%(program_name)s ; process_name expr (default %(program_name)s)
;numprocs=1                    ; number of processes copies to start (def 1)
;directory=/tmp                ; directory to cwd to before exec (def no cwd)
;umask=022                     ; umask for process (default None)
;priority=999                  ; the relative start priority (default 999)
;autostart=true                ; start at supervisord start (default: true)
;startsecs=1                   ; # of secs prog must stay up to be running (def. 1)
;startretries=3                ; max # of serial start failures when starting (default 3)
;autorestart=unexpected        ; when to restart if exited after running (def: unexpected)
;exitcodes=0                   ; 'expected' exit codes used with autorestart (default 0)
;stopsignal=QUIT               ; signal used to kill process (default TERM)
;stopwaitsecs=10               ; max num secs to wait b4 SIGKILL (default 10)
;stopasgroup=false             ; send stop signal to the UNIX process group (default false)
;killasgroup=false             ; SIGKILL the UNIX process group (def false)
;user=chrism                   ; setuid to this UNIX account to run the program
;redirect_stderr=true          ; redirect proc stderr to stdout (default false)
;stdout_logfile=/a/path        ; stdout log path, NONE for none; default AUTO
;stdout_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stdout_logfile_backups=10     ; # of stdout logfile backups (0 means none, default 10)
;stdout_capture_maxbytes=1MB   ; number of bytes in 'capturemode' (default 0)
;stdout_events_enabled=false   ; emit events on stdout writes (default false)
;stdout_syslog=false           ; send stdout to syslog with process name (default false)
;stderr_logfile=/a/path        ; stderr log path, NONE for none; default AUTO
;stderr_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stderr_logfile_backups=10     ; # of stderr logfile backups (0 means none, default 10)
;stderr_capture_maxbytes=1MB   ; number of bytes in 'capturemode' (default 0)
;stderr_events_enabled=false   ; emit events on stderr writes (default false)
;stderr_syslog=false           ; send stderr to syslog with process name (default false)
;environment=A="1",B="2"       ; process environment additions (def no adds)
;serverurl=AUTO                ; override serverurl computation (childutils)

; The sample eventlistener section below shows all possible eventlistener
; subsection values.  Create one or more 'real' eventlistener: sections to be
; able to handle event notifications sent by supervisord.

;[eventlistener:theeventlistenername]
;command=/bin/eventlistener    ; the program (relative uses PATH, can take args)
;process_name=%(program_name)s ; process_name expr (default %(program_name)s)
;numprocs=1                    ; number of processes copies to start (def 1)
;events=EVENT                  ; event notif. types to subscribe to (req'd)
;buffer_size=10                ; event buffer queue size (default 10)
;directory=/tmp                ; directory to cwd to before exec (def no cwd)
;umask=022                     ; umask for process (default None)
;priority=-1                   ; the relative start priority (default -1)
;autostart=true                ; start at supervisord start (default: true)
;startsecs=1                   ; # of secs prog must stay up to be running (def. 1)
;startretries=3                ; max # of serial start failures when starting (default 3)
;autorestart=unexpected        ; autorestart if exited after running (def: unexpected)
;exitcodes=0                   ; 'expected' exit codes used with autorestart (default 0)
;stopsignal=QUIT               ; signal used to kill process (default TERM)
;stopwaitsecs=10               ; max num secs to wait b4 SIGKILL (default 10)
;stopasgroup=false             ; send stop signal to the UNIX process group (default false)
;killasgroup=false             ; SIGKILL the UNIX process group (def false)
;user=chrism                   ; setuid to this UNIX account to run the program
;redirect_stderr=false         ; redirect_stderr=true is not allowed for eventlisteners
;stdout_logfile=/a/path        ; stdout log path, NONE for none; default AUTO
;stdout_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stdout_logfile_backups=10     ; # of stdout logfile backups (0 means none, default 10)
;stdout_events_enabled=false   ; emit events on stdout writes (default false)
;stdout_syslog=false           ; send stdout to syslog with process name (default false)
;stderr_logfile=/a/path        ; stderr log path, NONE for none; default AUTO
;stderr_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
;stderr_logfile_backups=10     ; # of stderr logfile backups (0 means none, default 10)
;stderr_events_enabled=false   ; emit events on stderr writes (default false)
;stderr_syslog=false           ; send stderr to syslog with process name (default false)
;environment=A="1",B="2"       ; process environment additions
;serverurl=AUTO                ; override serverurl computation (childutils)

; The sample group section below shows all possible group values.  Create one
; or more 'real' group: sections to create "heterogeneous" process groups.

;[group:thegroupname]
;programs=progname1,progname2  ; each refers to 'x' in [program:x] definitions
;priority=999                  ; the relative start priority (default 999)

; The [include] section can just contain the "files" setting.  This
; setting can list multiple files (separated by whitespace or
; newlines).  It can also contain wildcards.  The filenames are
; interpreted as relative to this file.  Included files *cannot*
; include files themselves.

[include]
files = *.ini



创建luffycity_celery_worker.ini文件,启动我们项目worker主进程

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_worker.ini


[program:luffycity_celery_worker]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi worker -l info -n worker1
# 项目根目录的绝对路径[manage.py所在目录路径],通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 运行日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.worker.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.worker.error.log
# 自动启动,开机自启
autostart=true
# 启动当前命令的用户名
user=moluo
# 重启
autorestart=true
# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10
# 进程结束后60秒才被认定结束
stopwatisecs=60
# 优先级,值小的优先启动
priority=990



创建luffycity_celery_beat.ini文件,来触发我们的beat定时计划任务

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_beat.ini

[program:luffycity_celery_beat]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi  beat -l info
# 项目根目录的绝对路径,通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 运行日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.beat.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.beat.error.log
# 自动启动,开机自启
autostart=true
# 重启
autorestart=true

# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10

# 进程结束后60秒才被认定结束
stopwatisecs=60

# 优先级,值小的优先启动
priority=998


创建luffycity_celery_flower.ini文件,来启动我们的celery监控管理工具

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch luffycity_celery_flower.ini


[program:luffycity_celery_flower]
# 启动命令 conda env list
command=/home/moluo/anaconda3/envs/luffycity/bin/celery -A luffycityapi flower --port=5555
# 项目根目录的绝对路径,通过pwd查看
directory=/home/moluo/Desktop/luffycity/luffycityapi
# 项目虚拟环境
enviroment=PATH="/home/moluo/anaconda3/envs/luffycity/bin"
# 输出日志绝对路径
stdout_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.flower.info.log
# 错误日志绝对路径
stderr_logfile=/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.flower.error.log
# 自动启动,开机自启
autostart=true
# 重启
autorestart=true

# 进程启动后跑了几秒钟,才被认定为成功启动,默认1
startsecs=10

# 进程结束后60秒才被认定结束
stopwatisecs=60

# 优先级
priority=999


启动supervisor,确保此时你在项目路径下

cd ~/Desktop/luffycity/luffycityapi
supervisord -c scripts/supervisord.conf


通过浏览器访问http://127.0.0.1:9001

常用操作

命令 描述
supervisorctl stop program 停止某一个进程,program 就是进程名称,例如在ini文件首行定义的[program:进程名称]
supervisorctl stop all 停止全部进程
supervisorctl start program 启动某个进程,program同上,也支持启动所有的进程
supervisorctl restart program 重启某个进程,program同上,也支持重启所有的进程
supervisorctl reload 载入最新的配置文件,停止原有进程并按新的配置启动、管理所有进程
注意:start、restart、stop 等都不会载入最新的配置文件
supervisorctl update 根据最新的配置文件,启动新配置或有改动的进程,配置没有改动的进程不会受影响而重启
ps aux | grep supervisord 查看supervisor是否启动

把supervisor注册到ubuntu系统服务中并设置开机自启

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
touch supervisor.service


supervisor.service,配置内容,并保存。需要通过conda env list 查看当前的虚拟环境路径

[Unit]
Description=supervisor
After=network.target

[Service]
Type=forking
ExecStart=/home/moluo/anaconda3/envs/luffycity/bin/supervisord -n -c /home/moluo/Desktop/luffycity/luffycityapi/scripts/supervisord.conf
ExecStop=/home/moluo/anaconda3/envs/luffycity/bin/supervisorctl $OPTIONS shutdown
ExecReload=/home/moluo/anaconda3/envs/luffycity/bin/supervisorctl $OPTIONS reload
KillMode=process
Restart=on-failure
RestartSec=42s

[Install]
WantedBy=multi-user.target


设置开机自启

# 创建日志文件
sudo chmod 766 /tmp/supervisord.log
cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
# 赋予权限
chmod 766 supervisor.service
# 复制到系统开启服务目录下
sudo cp supervisor.service /lib/systemd/system/
# 设置允许开机自启
systemctl enable supervisor.service
# 判断是否已经设置为开机自启了
systemctl is-enabled  supervisor.service
# 通过systemctl查看supervisor运行状态
systemctl status supervisor.service
# 如果查看服务状态时无法启动,则可以通过重启linux系统来测试是否因为前面的终端已经运行了supervisor导致的。当然,也可以手动关闭supervisor以及相关的服务。
# supervisorctl stop all
# ps aux | grep supervisord
# kill -9 51564  # 注意: 9068是举例的,具体看上一行的查询结果


提交代码版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 使用supervisor启动并管理celery相关进程"
git push


posted @ 2022-09-27 20:24  凫弥  阅读(34)  评论(0编辑  收藏  举报