一.课程详情页面CourseDetail.vue
<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.js
import CourseDetail from './views/CourseDetail.vue' { path: '/course/detail/:pk', name: 'course-detail', component: CourseDetail },
依赖:在luffycity目录下的命令
>: cnpm install vue-video-player
配置:main.js
// 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);
资源:图片放置assrts/img文件夹
""" enum.svg chapter-player.svg cart-yellow.svg """
Course.vue中的转跳链接:
<router-link :to="'/course/detail/'+course.id">{{course.name}}</router-link>
二.课程详情接口
路由course/urls.py:
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:
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:
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):
@property def level_name(self): return self.get_level_display()
三.订单模块
创建apps/order:
cd luffyapi/apps
python ../../manage.py startapp order
路由:
主: 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)
注:数据库迁移
四.支付宝应用开发
# 1、在沙箱环境下实名认证:https://openhome.alipay.com/platform/appDaily.htm?tab=info # 2、电脑网站支付API:https://docs.open.alipay.com/270/105898/ # 3、完成RSA密钥生成:https://docs.open.alipay.com/291/105971 # 4、在开发中心的沙箱应用下设置应用公钥:填入生成的公钥文件中的内容 # 5、Python支付宝开源框架:https://github.com/fzlee/alipay # >: pip install python-alipay-sdk --upgrade # 7、公钥私钥设置 """ # alipay_public_key.pem -----BEGIN PUBLIC KEY----- 支付宝公钥 -----END PUBLIC KEY----- # app_private_key.pem -----BEGIN RSA PRIVATE KEY----- 用户私钥 -----END RSA PRIVATE KEY----- """ # 8、支付宝链接 """ 开发: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 # 应用配置
setting.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
__init__.py
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 )
alipay_public_key.pem
-----BEGIN PUBLIC KEY-----
支付宝公钥
-----END PUBLIC KEY-----
app_private_key.pem
-----BEGIN RSA PRIVATE KEY-----
应用私钥
-----END RSA PRIVATE KEY-----
补充:dev.py
# 上线后必须换成官网地址 # 同步回调的接口(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')