一.課程詳情頁面CourseDetail.vuecss
<template> <div class="detail"> <Header/> <div class="main"> <div class="course-info"> <div class="wrap-left"> <videoPlayer class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)"> </videoPlayer> </div> <div class="wrap-right"> <h3 class="course-name">{{course_info.name}}</h3> <p class="data">{{course_info.students}}人在學 課程總時長:{{course_info.sections}}課時/{{course_info.pub_sections}}小時 難度:{{course_info.level_name}}</p> <div v-if="course_info.active_time>0"> <div class="sale-time"> <p class="sale-type">{{course_info.discount_type}}</p> <p class="expire">距離結束:僅剩{{day}}天 {{hour}}小時 {{minute}}分 <span class="second">{{second}}</span> 秒</p> </div> <p class="course-price"> <span>活動價</span> <span class="discount">¥{{course_info.real_price}}</span> <span class="original">¥{{course_info.price}}</span> </p> </div> <div v-else class="sale-time"> <p class="sale-type">價格 <span class="original_price">¥{{course_info.price}}</span></p> <p class="expire"></p> </div> <div class="buy"> <div class="buy-btn"> <button class="buy-now">當即購買</button> <button class="free">免費試學</button> </div> <!--<div class="add-cart" @click="add_cart(course_info.id)"><img src="@/assets/img/cart-yellow.svg"--> <!--alt="">加入購物車--> <!--</div>--> </div> </div> </div> <div class="course-tab"> <ul class="tab-list"> <li :class="tabIndex==1?'active':''" @click="tabIndex=1">詳情介紹</li> <li :class="tabIndex==2?'active':''" @click="tabIndex=2">課程章節 <span :class="tabIndex!=2?'free':''">(試學)</span> </li> <li :class="tabIndex==3?'active':''" @click="tabIndex=3">用戶評論</li> <li :class="tabIndex==4?'active':''" @click="tabIndex=4">常見問題</li> </ul> </div> <div class="course-content"> <div class="course-tab-list"> <div class="tab-item" v-if="tabIndex==1"> <div class="course-brief" v-html="course_info.brief_text"></div> </div> <div class="tab-item" v-if="tabIndex==2"> <div class="tab-item-title"> <p class="chapter">課程章節</p> <p class="chapter-length">共{{course_chapters.length}}章 {{course_info.sections}}個課時</p> </div> <div class="chapter-item" v-for="chapter in course_chapters" :key="chapter.name"> <p class="chapter-title"><img src="@/assets/img/enum.svg" alt="">第{{chapter.chapter}}章·{{chapter.name}} </p> <ul class="section-list"> <li class="section-item" v-for="section in chapter.coursesections" :key="section.name"> <p class="name"><span class="index">{{chapter.chapter}}-{{section.orders}}</span> {{section.name}}<span class="free" v-if="section.free_trail">免費</span></p> <p class="time">{{section.duration}} <img src="@/assets/img/chapter-player.svg"></p> <button class="try" v-if="section.free_trail">當即試學</button> <button class="try" v-else>當即購買</button> </li> </ul> </div> </div> <div class="tab-item" v-if="tabIndex==3"> 用戶評論 </div> <div class="tab-item" v-if="tabIndex==4"> 常見問題 </div> </div> <div class="course-side"> <div class="teacher-info"> <h4 class="side-title"><span>授課老師</span></h4> <div class="teacher-content"> <div class="cont1"> <img :src="course_info.teacher.image"> <div class="name"> <p class="teacher-name">{{course_info.teacher.name}} {{course_info.teacher.title}}</p> <p class="teacher-title">{{course_info.teacher.signature}}</p> </div> </div> <p class="narrative">{{course_info.teacher.brief}}</p> </div> </div> </div> </div> </div> <Footer/> </div> </template> <script> import Header from "@/components/Header" import Footer from "@/components/Footer" // 加載組件 import {videoPlayer} from 'vue-video-player'; export default { name: "Detail", data() { return { tabIndex: 2, // 當前選項卡顯示的下標 course_id: 0, // 當前課程信息的ID course_info: { teacher: {}, }, // 課程信息 course_chapters: [], // 課程的章節課時列表 playerOptions: { aspectRatio: '16:9', // 將播放器置於流暢模式,並在計算播放器的動態大小時使用該值。值應該表明一個比例 - 用冒號分隔的兩個數字(例如"16:9"或"4:3") sources: [{ // 播放資源和資源格式 type: "video/mp4", src: "http://img.ksbbs.com/asset/Mon_1703/05cacb4e02f9d9e.mp4" //你的視頻地址(必填) }], } } }, computed: { day() { let day = parseInt(this.course_info.active_time / (24 * 3600)); if (day < 10) { return '0' + day; } else { return day; } }, hour() { let rest = parseInt(this.course_info.active_time % (24 * 3600)); let hours = parseInt(rest / 3600); if (hours < 10) { return '0' + hours; } else { return hours; } }, minute() { let rest = parseInt(this.course_info.active_time % 3600); let minute = parseInt(rest / 60); if (minute < 10) { return '0' + minute; } else { return minute; } }, second() { let second = this.course_info.active_time % 60; if (second < 10) { return '0' + second; } else { return second; } } }, created() { this.get_course_id(); this.get_course_data(); this.get_chapter(); }, methods: { onPlayerPlay() { // 當視頻播放時,執行的方法 }, onPlayerPause() { // 當視頻暫停播放時,執行的方法 }, get_course_id() { // 獲取地址欄上面的課程ID this.course_id = this.$route.params.pk; if (this.course_id < 1) { let _this = this; _this.$alert("對不起,當前視頻不存在!", "警告", { callback() { _this.$router.go(-1); } }); } }, get_course_data() { // ajax請求課程信息 this.$axios.get(`${this.$settings.base_url}/course/${this.course_id}/`).then(response => { // window.console.log(response.data); this.course_info = response.data; }).catch(() => { this.$message({ message: "對不起,訪問頁面出錯!請聯繫客服工做人員!" }); }) }, get_chapter() { // 獲取當前課程對應的章節課時信息 // http://127.0.0.1:8000/course/chapters/?course=(pk) this.$axios.get(`${this.$settings.base_url}/course/chapters/`, { params: { "course": this.course_id, } }).then(response => { this.course_chapters = response.data; }).catch(error => { window.console.log(error.response); }) }, // add_cart(course_id) { // // 添加商品到購物車 // // 驗證用戶登陸狀態,若是登陸了則能夠添加商品到購物車,若是沒有登陸則跳轉到登陸界面,登陸完成之後,才能添加商品到購物車 // let token = localStorage.token || sessionStorage.token; // if (!token) { // this.$confirm("對不起,您還沒有登陸,請登陸之後再進行購物車").then(() => { // this.$router.push("/login/"); // }); // return false; // 阻止代碼往下執行 // } // // // 添加商品到購物車,由於購物車接口必須用戶是登陸的,因此咱們要在請求頭中設置 jwttoken // this.$axios.post(`${this.$settings.Host}/cart/`, { // "course_id": course_id, // }, { // headers: { // "Authorization": "jwt " + token, // } // }).then(response => { // this.$message({ // message: response.data.message, // }); // // 購物車中的商品數量 // let total = response.data.total; // this.$store.commit("change_total", total) // }).catch(error => { // this.$message({ // message: error.response.data // }) // }) // } }, components: { Header, Footer, videoPlayer, // 註冊組件 } } </script>
路由router.jshtml
import CourseDetail from './views/CourseDetail.vue' { path: '/course/detail/:pk', name: 'course-detail', component: CourseDetail },
>: cnpm install vue-video-player
// vue-video播放器 require('video.js/dist/video-js.css'); require('vue-video-player/src/custom-theme.css'); import VideoPlayer from 'vue-video-player' Vue.use(VideoPlayer);
""" enum.svg chapter-player.svg cart-yellow.svg """
<router-link :to="'/course/detail/'+course.id">{{course.name}}</router-link>
二.課程詳情接口vue
路由course/urls.py:python
from django.urls import path, re_path from . import views re_path('(?P<pk>\d+)/', views.CourseRetrieveAPIView.as_view()), path('chapters/', views.ChapterListAPIView.as_view()),
視圖views.py:ios
from rest_framework.generics import RetrieveAPIView class CourseRetrieveAPIView(RetrieveAPIView): queryset = models.Course.objects.filter(is_delete=False, is_show=True) serializer_class = serializers.CourseModelSerializer from .filters import ChapterFilterSet class ChapterListAPIView(ListAPIView): queryset = models.CourseChapter.objects.filter(is_delete=False, is_show=True) serializer_class = serializers.CourseChapterModelSerializer filter_backends = [DjangoFilterBackend] # filter_fields = ('course',) filter_class = ChapterFilterSet
序列化類serializers.py:git
class CourseModelSerializer(ModelSerializer): teacher = TeacherModelSerializer() class Meta: model = models.Course fields = ( 'id', 'name', 'course_img', 'brief', 'period', 'attachment_path', 'students', 'sections', 'pub_sections', 'price', 'teacher', 'section_list', 'level_name', ) class CourseSectionModelSerializer(ModelSerializer): class Meta: model = models.CourseSection fields = ('name', 'section_link', 'name', 'free_trail', 'orders') class CourseChapterModelSerializer(ModelSerializer): coursesections = CourseSectionModelSerializer(many=True) class Meta: model = models.CourseChapter fields = ('course', 'chapter', 'name', 'summary', 'coursesections')
添加難度字段level_name => models.py/class Course(BaseModel):github
@property def level_name(self): return self.get_level_display()
三.訂單模塊ajax
建立apps/order:數據庫
cd luffyapi/apps
python ../../manage.py startapp order
路由:npm
主: path('order/', include('order.urls')), 子: from django.urls import path from . import views urlpatterns = [ path('pay/', views.PayAPIView.as_view()), path('success/', views.SuccessAPIView.as_view()), ]
models.py:
""" 訂單:訂單號、流水號、價格、用戶 訂單詳情(自定義關係表):訂單、課程 """ from django.db import models from utils.model import BaseModel from user.models import User from course.models import Course class Order(BaseModel): """訂單模型""" status_choices = ( (0, '未支付'), (1, '已支付'), (2, '已取消'), (3, '超時取消'), ) pay_choices = ( (1, '支付寶'), (2, '微信支付'), ) subject = models.CharField(max_length=150, verbose_name="訂單標題") total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="訂單總價", default=0) out_trade_no = models.CharField(max_length=64, verbose_name="訂單號", unique=True) trade_no = models.CharField(max_length=64, null=True, verbose_name="流水號") order_status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="訂單狀態") pay_type = models.SmallIntegerField(choices=pay_choices, default=1, verbose_name="支付方式") pay_time = models.DateTimeField(null=True, verbose_name="支付時間") user = models.ForeignKey(User, related_name='user_orders', on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="下單用戶") # 多餘字段 orders = models.IntegerField(verbose_name='顯示順序', default=0) class Meta: db_table = "luffy_order" verbose_name = "訂單記錄" verbose_name_plural = "訂單記錄" def __str__(self): return "%s - ¥%s" % (self.subject, self.total_amount) @property def courses(self): data_list = [] for item in self.order_courses.all(): data_list.append({ "id": item.id, "course_name": item.course.name, "real_price": item.real_price, }) return data_list 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=6, decimal_places=2, verbose_name="課程原價") real_price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="課程實價") class Meta: db_table = "luffy_order_detail" verbose_name = "訂單詳情" verbose_name_plural = "訂單詳情" def __str__(self): return "%s訂單(%s)" % (self.course.name, self.order.order_number)
注:數據庫遷移
四.支付寶應用開發
# 一、在沙箱環境下實名認證:https://openhome.alipay.com/platform/appDaily.htm?tab=info # 二、電腦網站支付API:https://docs.open.alipay.com/270/105898/ # 三、完成RSA密鑰生成:https://docs.open.alipay.com/291/105971 # 四、在開發中心的沙箱應用下設置應用公鑰:填入生成的公鑰文件中的內容 # 五、Python支付寶開源框架:https://github.com/fzlee/alipay # >: pip install python-alipay-sdk --upgrade # 七、公鑰私鑰設置 """ # alipay_public_key.pem -----BEGIN PUBLIC KEY----- 支付寶公鑰 -----END PUBLIC KEY----- # app_private_key.pem -----BEGIN RSA PRIVATE KEY----- 用戶私鑰 -----END RSA PRIVATE KEY----- """ # 八、支付寶連接 """ 開發:https://openapi.alipay.com/gateway.do 沙箱:https://openapi.alipaydev.com/gateway.do """
RSA:
支付寶公鑰:
前臺後臺支付寶交互原理圖:
沙箱測試帳號:
五.alipay二次封裝包
>: pip install python-alipay-sdk --upgrade
libs
├── iPay # aliapy二次封裝包
│ ├── __init__.py # 包文件
│ ├── keys # 密鑰文件夾
│ │ ├── alipay_public_key.pem # 支付寶公鑰
│ │ └── app_private_key.pem # 應用私鑰
└── └── settings.py # 應用配置
import os # 支付寶應用id APP_ID = '2016093000631831' # 默認異步回調的地址,一般設置None就行 APP_NOTIFY_URL = None # 應用私鑰文件路徑 APP_PRIVATE_KEY_PATH = os.path.join(os.path.dirname(__file__), 'keys', 'app_private_key.pem') # 支付寶公鑰文件路徑 ALIPAY_PUBLIC_KEY_PATH = os.path.join(os.path.dirname(__file__), 'keys', 'alipay_public_key.pem') # 簽名方式 SIGN_TYPE = 'RSA2' # 是不是測試環境 DEBUG = True
from alipay import AliPay from .settings import * # 對外提供,放到本身的dev配置文件中 # from .settings import RETURN_URL, NOTIFY_URL # 對外提供支付對象 alipay = AliPay( appid=APP_ID, app_notify_url=APP_NOTIFY_URL, app_private_key_path=APP_PRIVATE_KEY_PATH, alipay_public_key_path=ALIPAY_PUBLIC_KEY_PATH, sign_type=SIGN_TYPE, debug=DEBUG )
-----BEGIN PUBLIC KEY-----
支付寶公鑰
-----END PUBLIC KEY-----
-----BEGIN RSA PRIVATE KEY-----
應用私鑰
-----END RSA PRIVATE KEY-----
# 上線後必須換成官網地址 # 同步回調的接口(get),先後臺分離時通常設置前臺頁面url RETURN_URL = 'http://127.0.0.1:8080/pay/success' # 異步回調的接口(post),必定設置爲後臺服務器接口 NOTIFY_URL = 'http://127.0.0.1:8000/order/success/'
六.訂單接口
訂單視圖views.py:
# 1)生成訂單 # 2)生成支付連接 # 3)第三方支付 # 4)修改訂單狀態 import time from rest_framework.views import APIView from utils.response import APIResponse from libs.iPay import alipay from . import authentications, serializers from rest_framework.permissions import IsAuthenticated from django.conf import settings # 獲取前臺 商品名、價格,產生 訂單、支付連接 class PayAPIView(APIView): authentication_classes = [authentications.JWTAuthentication] permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): # 前臺提供:商品名、總價、支付方式 request_data = request.data # 後臺產生:訂單號、用戶 out_trade_no = '%d' % time.time() * 2 request_data['out_trade_no'] = out_trade_no request_data['user'] = request.user.id # 反序列化數據,用於訂單生成前的校驗 order_ser = serializers.OrderModelSerializer(data=request_data) if order_ser.is_valid(): # 生成訂單,訂單默認狀態爲:未支付 order = order_ser.save() # 支付連接的參數 order_string = alipay.api_alipay_trade_page_pay( subject=order.subject, out_trade_no=order.out_trade_no, total_amount='%.2f' % order.total_amount, return_url=settings.RETURN_URL, notify_url=settings.NOTIFY_URL ) # 造成支付連接:alipay._gateway根據字符環境DEBUG配置信息,決定是沙箱仍是真實支付環境 pay_url = '%s?%s' % (alipay._gateway, order_string) return APIResponse(0, 'ok', pay_url=pay_url) return APIResponse(1, 'no ok', results=order_ser.errors)
用戶校驗須要認證authentications.py:
import jwt from rest_framework.exceptions import AuthenticationFailed from rest_framework_jwt.authentication import jwt_decode_handler from rest_framework_jwt.authentication import get_authorization_header from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication class JWTAuthentication(BaseJSONWebTokenAuthentication): def authenticate(self, request): # jwt_value = get_authorization_header(request) jwt_value = request.META.get('HTTP_AUTHORIZATION', b'') if not jwt_value: raise AuthenticationFailed('Authorization 字段是必須的') try: payload = jwt_decode_handler(jwt_value) except jwt.ExpiredSignature: raise AuthenticationFailed('簽名過時') except jwt.InvalidTokenError: raise AuthenticationFailed('非法用戶') user = self.authenticate_credentials(payload) return user, jwt_value
序列化類serializers.py:
from rest_framework import serializers from . import models class OrderModelSerializer(serializers.ModelSerializer): class Meta: model = models.Order fields = ('subject', 'total_amount', 'out_trade_no', 'pay_type', 'user') extra_kwargs = { 'pay_type': { 'required': True }, 'total_amount': { 'required': True }, } # 若是須要處理訂單詳情,前臺必定要提供 課程主鍵(一個或多個) # 須要重寫create方法:1)產生Order表對象 2)產生OrderDetail表對象 => 購物車邏輯 # 需求可拓展:UserCourse user course
七.前臺生成訂單
Course.vue連接跳轉支付:
<span class="buy-now" @click="pay_course(course)">當即購買</span> ...... methods: { // 購買課程 pay_course(course) { // 判斷登陸狀態 let token = this.$cookies.get('token'); if (!token) { this.$message.error('請先登陸'); return } this.$axios({ url: this.$settings.base_url + '/order/pay/', method: 'post', data: { 'subject': course.name, 'total_amount': course.price, // 若是有支付頁面:1 支付寶 2 微信 'pay_type': 1, }, headers: { Authorization: token } }).then(response => { // console.log(response.data) if (response.data.status == 0) { location.href = response.data.pay_url; } else { this.$message({ message: '生成訂單失敗' }) } }).catch(() => { this.$message({ message: '生成訂單失敗' }) }) }, ......
八.支付完成後同步回調連接給前臺渲染
router.js:
import PaySuccess from './views/PaySuccess.vue' Vue.use(Router); { path: '/pay/success', name: 'pay-success', component: PaySuccess },
PaySuccess.vue:
<template> <div class="pay-success"> <Header/> <div class="main"> <div class="title"> <div class="success-tips"> <p class="tips">您已成功購買 1 門課程!</p> </div> </div> <div class="order-info"> <p class="info"><b>訂單號:</b><span>{{ result.out_trade_no }}</span></p> <p class="info"><b>交易號:</b><span>{{ result.trade_no }}</span></p> <p class="info"><b>付款時間:</b><span><span>{{ result.timestamp }}</span></span></p> </div> <div class="study"> <span>當即學習</span> </div> </div> <Footer/> </div> </template> <script> import Header from "@/components/Header" import Footer from "@/components/Footer" export default { name: "Success", data() { return { result: {}, }; }, created() { // 判斷登陸狀態 let token = this.$cookies.get('token'); if (!token) { this.$message.error('非法請求'); this.$router.go(-1) } localStorage.this_nav = '/'; if (!location.search.length) return; let params = location.search.substring(1); let items = params.length ? params.split('&') : []; //逐個將每一項添加到args對象中 for (let i = 0; i < items.length; i++) { let k_v = items[i].split('='); //解碼操做,由於查詢字符串通過編碼的 let k = decodeURIComponent(k_v[0]); let v = decodeURIComponent(k_v[1]); this.result[k] = v; // this.result[k_v[0]] = k_v[1]; } // console.log(this.result); // 把地址欄上面的支付結果,轉發給後端 this.$axios({ url: this.$settings.base_url + '/order/success/' + location.search, method: 'patch', headers: { Authorization: token } }).then(response => { console.log(response.data); }).catch(() => { console.log('支付結果同步失敗'); }) }, components: { Header, Footer, } } </script> <style scoped> .main { padding: 60px 0; 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 .tips { font-size: 26px; color: #000; } .info 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; } .study { padding: 25px 40px; } .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; } </style>
頁面如圖:
九.同步回調到後端
視圖order/views.py:
from . import models from utils.logging import logger from rest_framework.response import Response class SuccessAPIView(APIView): # 不能認證,別人支付寶異步回調就進不來了 # authentication_classes = [authentications.JWTAuthentication] # permission_classes = [IsAuthenticated] def patch(self, request, *args, **kwargs): # 默認是QueryDict類型,不能使用pop方法 request_data = request.query_params.dict() # 必須將 sign、sign_type(內部有安全處理) 從數據中取出,拿sign與剩下的數據進行校驗 sign = request_data.pop('sign') result = alipay.verify(request_data, sign) if result: # 同步回調:修改訂單狀態 try: out_trade_no = request_data.get('out_trade_no') order = models.Order.objects.get(out_trade_no=out_trade_no) if order.order_status != 1: order.order_status = 1 order.save() except: pass return APIResponse(0, '支付成功') return APIResponse(1, '支付失敗') # 支付寶異步回調 def post(self, request, *args, **kwargs): # 默認是QueryDict類型,不能使用pop方法 request_data = request.data.dict() # 必須將 sign、sign_type(內部有安全處理) 從數據中取出,拿sign與剩下的數據進行校驗 sign = request_data.pop('sign') result = alipay.verify(request_data, sign) # 異步回調:修改訂單狀態 if result and request_data["trade_status"] in ("TRADE_SUCCESS", "TRADE_FINISHED" ): out_trade_no = request_data.get('out_trade_no') logger.critical('%s支付成功' % out_trade_no) try: order = models.Order.objects.get(out_trade_no=out_trade_no) if order.order_status != 1: order.order_status = 1 order.save() except: pass # 支付寶八次異步通知,訂單成功必定要返回 success return Response('success') return Response('failed')