供应链开发第四天
# 支付宝
写了一个Django的小demo来单独实现支付宝的支付转账!! 后续再集成到供应链的项目中!
demo地址: https://github.com/OnePieceDC/alipay_demo
API与SDK的区别
1> API就是一堆http接口,写起来比较麻烦
2> SDK是集成工具包
- 基于这些 API/接口 封装了一些类和方法,基于开发语言封装的可以直接调用的功能(工具)集合,相对于直接使用API使用起来简便些.
- 厂商会提供了有不同语言java、python、go.的不同版本的sdk
- 正式环境
- 沙箱环境 https://open.alipay.com/develop/sandbox/app 支付宝账号开发平台,开通沙箱
- 沙箱应用
- 基本信息
- APPID
- 开发信息
- 接口加密方式 选系统默认密钥+公钥模式(已启用) 查看 应用公钥,应用私钥,支付宝公钥
- 支付宝的网关地址 https://openapi-sandbox.dl.alipaydev.com/gateway.do => 里面有dev表明是测试环境
- 应用网关地址、授权回调地址 线上环境才会用到.
- 沙箱账号
- 商家信息
- 买家信息
- 沙箱工具
- 支付宝客户端沙箱版 => 下载到手机上,登陆沙箱账号中的买家信息用于支付
- 关于文档 (官方文档SDK对于python支持非常不友好,所以我们使用官方的API)
- 支付,统一下单支付的接口文档 https://opendoc.alipay.com/open/59da99d0_alipay.trade.page.pay
- 转账 https://opendocs.alipay.com/open/029i78?scene=ca56bca529e64125a2786703c6192d41
- 异步通知 https://opendoc.alipay.com/open/270/105902/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
简单阐述下整体的逻辑(下述是支付 "前后端不分离下的流程"):
- 最重要的是如何用,代码中 构造的url参数的过程,加密解密的过程 都不必深究.. (可看代码注释,也能理解个七七八八)
PS:构造的url参数中包括商品信息以及 签名/加密 信息..
- return_url 用户支付成功后,支付宝向该地址发送GET请求. 可重定向. --> 告诉用户支付成功
- notify_url 用户支付成功后,支付宝向该地址发送POST请求. --> 告诉Django支付成功,后端会更新数据库中的订单状态.
根据官方文档,我们应该return HttpResponse("Success") 否则,支付宝会不断重发通知,4min、10min..
why? 为了防止客户扫码支付完成时,服务器不巧宕机了,订单状态未及时更新. 在服务器修复后,可以再次收到支付成功的回复,更新订单状态!
注:支付宝会不断重发通知,超过2天后,就不会发了. So,宕机超过了2天,服务器恢复后,需手动查询用户支付结果,以此更新订单状态!!
★ 不应该根据页面的跳转来判断支付是否成功
应该依据支付宝向我们发送的POST请求 or 手动的查询支付信息 来判断支付是否成功,进而更新订单状态!
2
3
4
5
6
7
8
9
10
# 我的钱包 - 前端界面
代码分为三部分来展示
前置工作: 创建walletView.vue、加入前端路由、写入左侧菜单中.
# 第一部分 - 我的资产
Card卡片 + el-row el-col布局 + Tooltip文字提示
# 第二部分 - 充值提现
el-row el-col布局 + card卡片 + el-form表单
# 第三部分 - 交易记录
交易记录的前端界面设计又分为三部分的知识, 搜索 + 表格 + 分页
# 交易记录 - 搜索
搜索部分使用的是行内表单, 包含了三类输入框, 时间的范围选择 + 下拉框 + 直接输入
# 日期国际化
选择日期时进行国际化的展示.. 添加两行代码即可!!
import './plugins/axios'
import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' // ★
import {installAxios} from "@/plugins/axios";
const app = createApp(App)
installAxios(app)
// ★ use(ElementPlus, {locale: zhCn})
app.use(store).use(router).use(ElementPlus, {locale: zhCn}).mount('#app')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 交易记录 - 表格
表格部分也挺有意思的,
在缩放浏览器宽高时,页面大小响应式的变化,前端报错 ResizeObserver loop completed with undelivered notifications.
谷歌后,https://github.com/vuejs/vue-cli/issues/7431
在vue.config.js添加了几行代码解决! 不必深究!
const {defineConfig} = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false,
// 添加了以下5行代码!!
devServer: {
client: {
overlay: false,
},
},
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 交易记录 - 分页
分页组件会根据总条数和每页多少条, 自动算出一共有多少页.
# 我的资产 - 初始化
当页面加载时/前端组件WalletView.vue加载时,向后台发送请求,获取当前登陆用户的资产数据并展示!!
关于"我的资产", 需知:
1> 总余额 = 可用余额 + 不可用余额;
2> 当用户进行提现操作时, 需要经过后台审核, 在审核期间, 提现的金额会从可用余额中扣除, 放到不可用余额中?!
3> 信用额度, 该功能项目中暂未开发, 因为涉及的比较多.. 它相当于透支, 大客户即使余额不够也可以用信用额度顶替!!
# 后端 - Api的设计
对于restful规范不要盲目的跟从, 可根据实际情况来进行优化调整!!
我们熟知restful规范
- http://127.0.0.1:8000/api/shipper/wallet/
GET 数据列表
POST 新建
- http://127.0.0.1:8000/api/shipper/wallet/1/
GET 单条数据
PUT 全部更新
PATCH 局部更新
DELETE 删除
代入,"页面加载时获取当前登陆用户的资产信息"这一需求中,我们很容易想到:
前端应该向,"http://127.0.0.1:8000/api/shipper/wallet/当前登陆用户ID/"该地址发送GET请求,获取单条数据!!
▲ 但此处我们不这样做,我们让前端向,"http://127.0.0.1:8000/api/shipper/wallet/"地址发送GET请求!!
后端需重写list方法!
在该list方法中,因为访问该接口必须得进行登陆认证,所以通过request.user获取到当前登陆用户的ID,进而返回该条用户的数据!
※※※
联想下前面 账号认证 功能模块的开发过程中,关于账号认证AB页面初始化时,我们探讨过url中传递的ID是什么?
当时我们提出两种方案来解决,方案1不携带id,方案2携带id,最后使用的是方案2!!方案1的具体如何解决当时是省略的!!
在这里,"我的资产初始化"这个需求不携带id的解决思路,可供 账号认证页面初始化的方案1 的具体实现进行参考!!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
现在我们的目标: 前端向后端发送的url中不携带ID并发送的是GET请求, 前端最终获取到单条数据!!
别急,我们从头一步步的来分析:
>--[先回顾一点def的知识]--<
当我们的视图类继承了ViewSetMixin,该类重写了as_view方法,路由的写法就变了!!
它将 get/post/put/delete 等方法映射到 list、create、retrieve、update、partial_update、destroy方法中
- http://127.0.0.1:8000/api/shipper/wallet/
GET 数据列表 <list>
POST 新建 <create>
- http://127.0.0.1:8000/api/shipper/wallet/1/
GET 单条数据 <retrive>
PUT 全部更新 <update>
PATCH 局部更新 <partial_update>
DELETE 删除 <destroy>
GenericViewSet = ViewSetMixin + GenericAPIView
drf的5个视图扩展类,CreateModelMixin、RetrieveModelMixin等 (这5个视图扩展类往往会跟GenericViewSet一起用)
drf的5个视图扩展类都把list、create等方法里的代码给我们写好啦!!
>--[结合我们的目标进行分析]--<
◆ 先明确一个前提: 我们后端使用的是自动生成路由!!所以,我们的视图类必须得继承 ViewSetMixin类或继承了该类的类!!
◆ 接着实现我们的目标,向"http://127.0.0.1:8000/api/shipper/wallet/"地址发送GET请求,获取单条数据
根据drf默认的规则,这样的请求到后端后,需要在list方法中写我们的业务逻辑!!
ok,我们在自己的视图类中写一个list方法,里面如何编写呢?
把视图扩展类ListModelMixin重新的list方法里的代码 or RetrieveModelMixin重写的retrive方法里的代码搬过来可以吗?
不可以!
前者返回的是多条数据,不符合我们的目标; 后者的源码中有代码 self.get_object,它干了四件事,其中一件需要url中pk值,否则报错!
解决方案:需要将两者糅合一下!!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 后端 - 分类维度
补充一点, 关于我们使用自动生成路由时, 业务代码的分类维度:
- 根据"业务功能"进行分类
router = SimpleRouter()
router.register('basic', basic.BasicView, basename='basic') # 基本信息
router.register('auth', auth.AuthView, basename='auth') # 账号认证
router.register('wallet', wallet.WalletView, basename='wallet') # 我的钱包
- 根据"表结构"进行分类
router = SimpleRouter()
# Company供应商表 -- 里面包含 基本信息和我的钱包的业务
router.register('company', company.CompanyView, basename='company')
# CompanyAuth供应商认证表 -- 里面包含 账号认证的业务
router.register('companyAuth', companyAuth.CompanyAuthView, basename='CompanyAuth')
# PS: 有些也会这样 同样是Company供应商表,但也进一步进行了拆分
# router.register('company/basic', basic.CompanyBasicView, basename='basic')
# router.register('company/wallet', basic.CompanyWalletView, basename='wallet')
那我们使用哪个分类维度呢?一般情况下我们会根据"业务功能"进行分类!!
why?因为这样分类,通过url的名字就可以很方便的找到相应的业务代码!!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 后端 - 功能初步实现
先完成功能为前提, 下一步会进行封装!!
url中不携带id + GET请求 + 自动添加路由, 决定了在视图类WalletView中得写list方法;
我们的目标是返回单条数据, 在list方法中使用了RetrieveModelMixin的retrieve方法的源码, 但必须得重写get_object方法!!
(因为源码的self.get_object,它干了四件事,其中一件需要url中pk值,否则报错!)
# 后端 - ListRetrieveModelMixin
对上面的代码进行改进和封装!!
ListRetrieveModelMixin 简单来说就是 "list的url,retrieve的功能 单条数据由登陆认证组件通过后的request.user指定" ..
# 前端 - 关键代码
写过很多次了, 不再赘述.
# Loading区域加载
初始化时,Loading区域加载
# Loading全屏加载
初始化时,全屏Loading加载
import { ElLoading } from 'element-plus' // ▲
function initRequest() {
const loadingInstance = ElLoading.service({ fullscreen: true }) // ▲ 全屏加载
proxy.$axios.get(`/api/shipper/wallet/`).then((res) => {
loadingInstance.close() // ▲ 请求到数据了,关闭全屏加载
if (res.data.code === 0) {
state.money = res.data.data
} else {
ElMessage.error(res.data.msg)
}
}).catch(error => {
console.error(error)
})
}
onMounted(() => {
initRequest()
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 充值
需明确, 这里的充值是指 用户通过支付宝支付后往平台里充值.
前后端分离, 支付宝支付流程如下:
Ps: 后端构建好支付链接后,前端跳转该链接,该链接还会自己重定向到二维码页面!
Ps: 图中"即证明是支付宝返回的信息" , 准确的说, 是保证 同步请求和异步请求都是支付宝发送过来的!!不是其他人伪造的!!
□ 流程想通了,开始编码实现,先思考几个问题:
Q1> 点击充值按钮后,浏览器需要向后端索要支付宝支付链接,向后端哪个API地址发送请求获取,发送什么请求?
A1> 很容易先思考restful规范,没有适合的!!
那么在router.register('wallet', wallet.WalletView, basename='wallet')对应的WalletView中
利用action添加一个路由!!其url_path='charge' --> /api/shipper/wallet/charge/
Q2> 用户支付完成后,支付宝会向return_url发送GET请求,向notify_url地址发送POST请求!
return_url、notify_url 是前端地址还是后端地址呢?
A2> 应该是后端的api地址,因为我们需要对其进行验证!!保证关于支付是否成功的信息是支付宝给我们的!!
Q3> 我们确认了return_url、notify_url是后端地址,那么这格外的url也通过action写在WalletView视图类中吗?
A3> No! 关于return_url、notify_url的请求是支付宝发送过来的,不是前端发送过来的,所以这两个请求不需要经过后端的认证组件认证!!
而WalletView视图类中写了认证组件,所以不合适!!
return_url、notify_url的值我们规定是 http://127.0.0.1:8000/api/shipper/wallet/charge/notify/
- 解决方案1:
router.register('wallet/charge', wallet.ChargeNotifyView, basename='walletCharge')
class ChargeNotifyView(ViewSet):
authentication_classes = [] # 剔除全局的认证组件
@action(methods=['GET', 'POST'], detail=False)
def notify(self, request):
if request.method == "GET":pass
if request.method == "POST":pass
- 解决方案2:
path("wallet/charge/notify/",wallet.ChargeNotifyView.as_view()),
class ChargeNotifyView(APIView):
authentication_classes = []
def get(self, request):
# 将 if request.method == "GET"判断体的代码写在这里
pass
def post(self, request):
# 将 if request.method == "POST" 判断体的代码写在这里
pass
该示例中我们采取的是方案1解决的,纵览"我的钱包"功能模块.目前一共有三个路由!
- /api/shipper/wallet/ 我的资产初始化
router.register('wallet', wallet.WalletView) + ListRetrieveModelMixin
- /api/shipper/wallet/charge/ 用户支付的链接地址
router.register('wallet', wallet.WalletView) + action其url_path='charge'
- /api/shipper/wallet/charge/notify/ 用户支付完成后,支付宝的信息反馈
router.register('wallet/charge', wallet.ChargeNotifyView) + action其url_path='notify'
PS:一点小思考,做了个小实验
path("test/",wallet.Test.as_view()),
from rest_framework.views import APIView
class Test(APIView):
def get(self,request):
return redirect("http://localhost:8080/account/wallet?pay=success")
在浏览器上直接输入http://127.0.0.1:8000/api/shipper/test/ 是能重定向到指定页面的!!
Q: 那么后端在接收到前端的axios请求后,让其redirect重定向到构建好的支付宝支付链接可以吗??
A: 不行,因为vue前端的axios.js文件里规定了前端发送axios请求,后端必须Response返回值!!
(也许你又会问了,那为啥后端接受return_url的GET请求那,最后redirect是成功的呢??
因为那是支付宝让浏览器向return_url发送GET请求,浏览器发送该请求时,并不是通过我们自己编写的代码axios发送的!!)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
按照上述的流程图, 一步步的来!!
To do: 前端对充值表单的校验; 后端拿到表单字段后对其校验..
# 后端配置支付SDK
在后端项目中加入根据支付宝官网api自己写的SDK
Step1: 在后端项目的根目录下创建file文件夹, 里面添加两个文件, 应用私钥.txt
、支付宝公钥.txt
Step2: 在后端项目的setting.py配置文件里添加以下代码
"""
支付宝相关的配置
"""
ALI_APP_PRI_KEY_PATH = os.path.join(BASE_DIR, 'files', '应用私钥.txt')
ALI_PUB_KEY_PATH = os.path.join(BASE_DIR, 'files', '支付宝公钥.txt')
ALI_APPID = "9021000131691684"
ALI_GATEWAY = "https://openapi-sandbox.dl.alipaydev.com/gateway.do" # 网关地址
# 异步通知地址 POST,不是公网IP所以没看到请求到来
ALI_NOTIFY_URL = "http://127.0.0.1:8000/api/shipper/wallet/charge/notify/"
# 跳转地址 GET,为什么GET能访问呀?是基于js做的跳转. location.href = 地址
ALI_RETURN_URL = "http://127.0.0.1:8000/api/shipper/wallet/charge/notify/"
2
3
4
5
6
7
8
9
10
11
12
13
Step3: 在后端项目中加入根据官网api自己写的SDK.
项目根目录下, server/alipayV1/alipay.py
、server/alipayV1/md5AndUid.py
(代码过多,不予粘贴)
# 跳转支付页面
实现目标,用户点击充值按钮后,页面跳转到支付宝的用户支付页面!!
业务逻辑代码如下:
你可以做个小实验!
拿到后端构建好的支付链接,复制粘贴到浏览器上,你会发现支付宝会自动重定向到指定的地址!
若你在复制粘贴时,哪怕修改了链接上的一个字符,你都会发现支付宝会告诉你不正确!!
2
3
# 支付成功后的通知
实现目标,用户通过支付宝支付完成后,需要进行充值提示,通知用户平台充值成功!!
业务逻辑代码如下:
# 后端 - 交易记录
点击充值按钮, 后端在生成支付链接时, 需在数据库中生成一条交易记录!! ( look前后端分离, 支付宝支付流程图
# 交易记录表
新增了两张表, 记得进行数据库的迁移!!
# 业务逻辑
在原来充值的逻辑上, 加上添加充值记录以及订单状态更新、供应商账户余额更改等操作!!
PS: 框起来的部分是相较于 上面"充值"这一小节, 新增的内容!!
- 在构建好支付链接后,需要在db中生成交易记录(默认待支付的状态)
- 理论上应该在接受到异步返回后,再进行订单状态的更改. 上面的示例代码临时在同步请求后进行的这一操作!
- 订单状态更改
- 该订单相关的供应商的余额进行更改
PS:
- 订单号的组成变了下,变成了时间+随机数
- 注意下 tran_object = models.TransactionRecord.objects.filter(trans_id=out_trade_no, pay_status=0).first()
查询条件,pay_status=0,保证了是对未支付的进行修改. (其实这里应该加个判断, if tran_object:..)
To do:
1> 像这种涉及到数据表的操作,最好加上事务加上锁!!
2> 再次支付!!
有可能出现后端构建好支付链接,前端也成功挑战该支付链接,后端此时已经生成一条未支付的订单记录.
用户关掉了该支付页面,后续可以在页面上有关交易记录的表格里 选择取消该订单 或者 继续支付!!
3> 取消订单
- 交易记录的那条数据 修改其订单状态
- 支付宝关闭该订单的交易接口
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 提现
提现是需要审核的, 但这里我们为了方便测试,采取的无需审核!
# 后端代码截图
注: 提现的后端代码逻辑详看代码截图中的注释!!
注意:
要知道,关于支付宝支付,支付宝主动向后端发送的同步和异步请求,需要对其进行验证,保证该请求是支付宝发送的!!
而关于支付宝转账,是后端直接向支付宝服务端发送请求,拿到返回的数据,是无需验证的. 你结合公钥私钥细细的品味.
TO DO:
1> 转账的手续费问题 转的时候扣除手续费即可
2> ★★★ 加上事务和锁后,出现异常会回滚,我们还需捕获该异常!!
加上事务,在哪加?涉及到数据库的操作,就得考虑包含在事务里!
2
3
4
5
6
7
8
# 前端代码截图
# 交易记录
获取表格数据、分页、搜索
# ListPageNumberModelMixin
后端先简单的实现 获取列表数据并分页!!
像表格的分页我们通常采用的是基本分页PageNumberPagination.
获取列表数据的逻辑 "集过 分实返" 看上面的代码截图可以分析个七七八八,就不再赘述.
(但要注意,上面代码截图中,使用的序列化器类就很简单的返回了所有的数据,没有做任何数据格式的处理!! 后续结合前端的展示会进一步修改!)
2
3
# 表格和分页
前端访问API,将数据在表格上进行展示!!
★ 每次点击前端分页器的页码,都会携带筛选条件向后端发送一次请求获取当前页的数据!!
最终效果图如下:
关键代码截图如下:
注: 序列化器类中写的是__all__
, ORM表中的字段都返回了, 但在前端表格中我们没有展示那么多字段罢了!
# 筛选和重置
实现"交易记录"部分, 筛选/即搜索 的功能!!
api中实现筛选,一般都是通过url来传递参数的.
/api/shipper/wallet/tran/?page=1&date_range_start=2024-01-19&date_range_end=2024-01-20&tran_type=1&trans_id=2
PS: 订单号中包含用户填的号码就行..
打开钱包页面,交易记录一开始会显示当前用户全部的交易记录并分页好.
我们假设全部记录有14页,充值类型的记录有4页.
当我们筛选订单类型“充值”后,并没有点击搜索按钮,而是点击 分页的页码"3",你会发现它都会携带着筛选条件向后端发送请求!
而且页码还是停留在"3"上,但总页码此时显示的是4页!!
继续分析
当我们筛选订单类型“充值”后,并没有点击搜索按钮,而是点击 分页的页码"7",同样的,它会携带着筛选条件向后端发送请求!
这就出现了一个问题!!
充值的记录只有4页啊!!所以 页码"7" 是无效页码!!
于是乎
后端在接收到 http://127.0.0.1:8000/api/shipper/wallet/tran/?page=7&tran_type=-1 请求后,
后端代码ListPageNumberModelMixin类中的这行代码page = self.paginate_queryset(queryset) 就会抛出异常!!
如何处理?看下方截图!!
PS:每次点击前端分页器的页码,都会携带筛选条件向后端发送一次请求获取当前页的数据!!
□ 思考一个问题:如果是无效页码咋整?
上面的这段话已经阐述了无效页码怎么出现的!!
我的处理是 => 并没有根据code值为2弹出信息提示框,而是直接定位到第一页!!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 按钮展示
此处的按钮展示关乎"充值"的操作, 若我点击了充值跳转了,但是我没有支付!! 此时, 该次"充值"操作会生成一条交易记录, 状态是"未支付", 还应显示两个按钮供客户继续操作.. <取消> 和 <重新支付> !!
上图做了这几个操作:
1> 在前端表格中
- 当满足 类型等于"充值" + 状态等于"未支付" 两个条件, 在该交易记录后面会出现 "取消"和"重新支付"这两个选项!!
- 金额那列的数据,若是充值显示成 "+ ¥10.00" ; 若是提现,应显示成 "- ¥10.00"
- 添加“审核状态”一列,并在该表头添加提示信息,充值无需审核,提现需审核!
PS:因为还未编写后台的代码,所以在充值的业务逻辑中,我们直接无需审核就充值成功了,所以此时“审核状态”一列的数据肯定都为null.
无需担心,后续编写了后台后,会进行完善!!
若你在效果截图中看到审核状态一列有数据,那是我为了测试,手动更改了数据库中的数据,Hhh.
2> 后端对应的,需要在序列化器类中进行相应的处理!!
2
3
4
5
6
7
8
9
# 重新支付
"重新支付"和"充值"的流程其实倒差不差的..
回顾下,充值过程是咋样的?点击充值按钮,向后端发送请求,后端生成支付宝支付链接返回给前端,前端跳转到该链接,用户开始支付.
那么,此处的"重新支付",流程基本一致,不同的在于:
- "充值"向后端发送的请求需携带金额;"重新支付"无需携带金额,但需要携带充值操作生成的那条交易记录的唯一标识!
- "充值"操作需生成一条交易记录;"重新支付"无需再次生成,使用原先的那条交易记录即可.
重新支付的流程如下:
1> 点击重新支付的按钮,携带这一行交易记录的唯一标识(ID、账单号)向后端发送请求
2> 后端拿到交易记录的ID,获取核心的两个字段信息 订单号和金额,再次生成 支付宝支付链接
3> 前端跳转该支付链接!
注意截图中用蓝色和红色背景标注的部分!!
2
3
4
5
6
7
8
9
10
# 取消支付
参考官方文档,补充对于接口的sdk.
先来看看效果
关键代码如下:
▲ 实现流程:
1> 与重新支付一样,点击取消按钮,携带ID发送请求
2> 后端拿到订单ID,获取订单信息中的 商户订单号/我们自己生成并传递给支付宝的订单号
- 注:除了商户订单号,还有支付订单号,我们通过支付宝的异步回调才能拿到这个支付订单号!!
3> 通过 取消订单的SDK 对该订单进行取消! SDK构造链接+直接访问 类似与转账,构造的不是跳转链接,而是直接访问的链接.
4> 后端根据访问后返回的信息得知是否取消订单成功,并进行相应数据库状态更新."未完成"-->"已取消"
▲ 在沙箱环境中,大抵会返回两种信息.
{
'alipay_trade_close_response': {
'msg': 'Business Failed',
'code': '40004',
'sub_msg': '交易不存在',
'sub_code': 'ACQ.TRADE_NOT_EXIST'
},
'sign': 'VC3vKorve/hfb...=='
}
- 当前交易状态不支持此操作.
沙箱里,重新支付后网络不好没跳转,就没有get回调,db中的订单状态不好改变,但事实上已经支付成功了.
此时在页面上点击该订单的取消,是不会成功的!!
- 交易不存在.
放在正式环境中是能成功的.
▲ 注意:
在支付宝沙箱环境里测试时,对于取消支付和查看历史订单的接口的效果是展示不了的!(哪怕应该成功的也会返回订单不存在这一类的信息.)
当移植到正式环境中是能展示相应效果的,无需担心!!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 其他
遇到相关需求根据官方文档自行补充
- 异步通知&宕机
将支付宝的交易查询接口编写成sdk,查询的json结果中有所有的交易记录,将其与数据库中的进行比对并进行更新
- 退款,原来返回.
2
3
# 附录:支付宝SDK
支付宝的python SDK如下:
server/alipayV1/alipay.py
"""
https://www.cnblogs.com/xingxia/p/alipay_trade_page.html
https://cloud.tencent.com/developer/article/1794013
https://opendocs.alipay.com/common/02khjm
"""
import base64
import json
from base64 import decodebytes
from datetime import datetime
from urllib.parse import quote_plus
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
class AliPay(object):
"""
支付宝支付接口(PC端支付接口)
"""
def __init__(self, appid, notify_url, app_private_key_path, alipay_public_key_path, return_url):
self.appid = appid
self.notify_url = notify_url
self.return_url = return_url
self.app_private_key_path = app_private_key_path # 应用私钥
with open(self.app_private_key_path) as fp:
self.app_private_key = RSA.importKey(fp.read()) # 处理成可用的私钥用于生成签名.
self.alipay_public_key_path = alipay_public_key_path # 支付宝公钥
with open(self.alipay_public_key_path) as fp:
self.alipay_public_key = RSA.importKey(fp.read()) # 处理成可用的公钥用于验证签名.
def direct_pay(self, subject, out_trade_no, total_amount):
"""支付相关 将构造的url参数返回便于拼接在支付宝网关地址后面 """
data = {
"app_id": self.appid, # 支付宝分配给开发者的应用ID
"method": "alipay.trade.page.pay", # 接口名称
"charset": "utf-8", # 请求使用的编码格式
"sign_type": "RSA2", # 商户生成签名字符串所使用的签名算法类型
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 发送请求的时间
"version": "1.0", # 调用的接口版本
# 请求参数的集合,值为字符串. json.dumps将字典转化为json格式的字符串.
"biz_content": json.dumps({
"subject": subject, # 订单标题
"out_trade_no": out_trade_no, # 商品订单号
"total_amount": total_amount, # 订单总金额
"product_code": "FAST_INSTANT_TRADE_PAY", # 销售产品码,电脑支付场景下固定为该值
}, separators=(',', ':'))
}
if self.return_url:
# 支付宝服务器主动通知商户服务器里指定的页面http/https路径
data["notify_url"] = self.notify_url
# HTTP/HTTPS开头字符串 支付完成之后,GET请求跳转到这个地址
data["return_url"] = self.return_url
return self.sign_data(data) # 拿着这一堆data数据进行 签名/加密!!
def transfer(self, out_biz_no, trans_amount, order_title, identity, identity_name):
"""转账相关"""
data = {
"app_id": self.appid,
"method": "alipay.fund.trans.uni.transfer",
"charset": "utf-8",
"sign_type": "RSA2",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version": "1.0",
"biz_content": json.dumps({
"out_biz_no": out_biz_no, # 订单号
"trans_amount": trans_amount, # 金额
"product_code": "TRANS_ACCOUNT_NO_PWD",
"biz_scene": "DIRECT_TRANSFER",
"order_title": order_title, # 标题
"remark": "备注信息",
"payee_info": json.dumps({
"identity_type": "ALIPAY_LOGON_ID",
"identity": identity, # 转账的目标账户的identity
"name": identity_name # 转账的目标账户的name
}, separators=(',', ':'))
}, separators=(',', ':'))
}
if self.return_url:
data["notify_url"] = self.notify_url
data["return_url"] = self.return_url
return self.sign_data(data)
def close_pay(self, out_trade_no):
"""取消订单相关"""
data = {
"app_id": self.appid,
"method": "alipay.trade.close",
"charset": "utf-8",
"sign_type": "RSA2",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version": "1.0",
"biz_content": json.dumps({
"out_trade_no": out_trade_no, # 商户订单号
"trade_no": None, # 支付订单号,得写,不写的话报错
}, separators=(',', ':'))
}
if self.return_url:
data["notify_url"] = self.notify_url
return self.sign_data(data)
def sign_data(self, data):
"""官网的加签原理,共三步 https://opendocs.alipay.com/common/02khjm """
# step1: 获取所有支付宝开放平台的post内容,不包括字节类型参数,如文件、字节流,剔除 sign 字段,剔除值为空的参数;
data.pop("sign", None)
# step2: 按照第一个字符的键值 ASCII 码递增排序(字母升序排序), 如果遇到相同字符则按照第二个字符的键值 ASCII 码递增排序,以此类推;
unsigned_items = self.ordered_data(data)
# step3: 将排序后的参数与其对应值, 组合成 参数=参数值 的格式, 并且把这些参数用 & 字符连接起来, 此时生成的字符串为待签名字符串.
# eg: app_id=..&biz_content=..&method=..
unsigned_string = "&".join("{0}={1}".format(k, v) for k, v in unsigned_items)
# 对处理后的数据,开始进行签名
sign = self.sign(unsigned_string.encode("utf-8"))
# quote_plus对冒号汉字等进行了url 转义.
quoted_string = "&".join("{0}={1}".format(k, quote_plus(v)) for k, v in unsigned_items)
# 将签名后的结果追加到最后,获得最终的订单信息字符串
signed_string = quoted_string + "&sign=" + quote_plus(sign)
return signed_string
def ordered_data(self, data):
"""它的作用是对字典中的数据进行处理,将其中值为字典类型的键值对转换为JSON格式的字符串,并按照键的字母顺序进行排序"""
complex_keys = []
for key, value in data.items():
if isinstance(value, dict):
complex_keys.append(key)
for key in complex_keys:
data[key] = json.dumps(data[key], separators=(',', ':'))
# 其实,该处示例中传进来的data数据的键值对中的值是没有字典的,所以直接执行的是下面这一句代码!!
return sorted([(k, v) for k, v in data.items()])
def sign(self, unsigned_string):
"""
利用应用私钥对待签名字符串进行 签名
:unsigned_string: 待签名data,格式bytes
:return: 生成的签名字符串
"""
""" 效果是一样的!!
from base64 import encodebytes
key = self.app_private_key # 应用私钥
signer = PKCS1_v1_5.new(key)
signature = signer.sign(SHA256.new(unsigned_string))
# base64 编码,转换为unicode表示并移除回车
sign = encodebytes(signature).decode("utf8").replace("\n", "")
return sign # 返回sign签名后的结果
"""
key = self.app_private_key # 应用私钥
signer = PKCS1_v1_5.new(key)
digest = SHA256.new()
digest.update(unsigned_string)
sign = signer.sign(digest)
signature = base64.b64encode(sign)
return signature.decode('utf-8').replace("\n", "")
def verify(self, data, signature):
"""对签名进行验证"""
# 先对支付宝回传的数据进行处理,跟上面的第一步第二步第三步一样,就是 官网加签原理的那三步!
# step1
if "sign_type" in data:
data.pop("sign_type")
# step2
unsigned_items = self.ordered_data(data)
# step3
message = "&".join(u"{}={}".format(k, v) for k, v in unsigned_items)
# 利用支付宝公钥对处理后的数据再进行验签,借此保证数据是支付宝传递过来的!!
return self._verify(message, signature)
def _verify(self, raw_content, signature):
key = self.alipay_public_key # 支付宝公钥
signer = PKCS1_v1_5.new(key)
digest = SHA256.new()
digest.update(raw_content.encode("utf8"))
# 比较,看是否一致
if signer.verify(digest, decodebytes(signature.encode("utf8"))):
return True
return False
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
server/alipayV1/md5AndUid.py
import hashlib
import uuid
import random
import datetime
from django.conf import settings
def md5(string):
""" MD5加密,签名算法需要用到 """
hash_object = hashlib.md5(settings.SECRET_KEY.encode('utf-8'))
hash_object.update(string.encode('utf-8'))
return hash_object.hexdigest()
def gen_random_id(string):
""" 基于uid随机生成一个订单号,方便往后查询订单信息 """
data = "{}-{}".format(str(uuid.uuid4()), string)
return md5(data)
def gen_random_oid():
""" 基于时间和一个随机数生成一个订单号,长度也是固定的 """
rand_number = random.randint(10000000, 99999999)
ctime = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
trans_id = "{}{}".format(ctime, rand_number)
return trans_id
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27