本部分涉及订单的生成、并发处理、支付、评论等
关键:MySQL事务、并发处理的悲观锁/乐观锁、支付宝SDK 的使用......
仅作为个人笔记!
目录
2.创建订单
3.订单生成
3.1. MySQL事务
3.2. Django中使用事务
3.3. 订单并发问题
5.订单支付
5.1.支付宝开放平台
5.2.网站对接支付宝流程
订单
在购物车中点击去结算后,会将选中的商品id、数量、价格等传到订单页。实际上,只需要传用户要购买的商品的id即可,数量等会从redis数据库中获取。
订单页面大致如下:
表单中的checkbox只有被选中时值才会被提交。request.POST提交的类型为QueryDict,可以一个名字对应多个值;在视图中request.POST.getlist()来获。
前端注意对收货地址的默认选中:
- 寄送到:
{% for addr in addrs %}
- {{addr.addr}} ({{addr.receviver}} 收) {{addr.phone}}
{% endfor %}
用户点击提交订单时,创建订单。前端必须要传递的参数有:收货地址、支付方式、商品id等。
订单相关的MySQL表有:订单信息表:df_order_info、订单商品表:df_order_goods,二者为一对多的关系;
用户每下一个订单,就向df_order_info表中加入一条记录;用户的订单中有几个商品,就需要向df_order_goods表中加入几条记录。
提交订单使用Ajax提交,关键的js如下:
v
$('#order_btn').click(function() {
// 获取用户选择的地址id, 支付方式, 要购买的商品id字符串
addr_id = $('input[name="addr_id"]:checked').val() // 选择器获取被选中的checkbox的值(收货地址
pay_method = $('input[name="pay_style"]:checked').val()
sku_ids = $(this).attr('sku_ids')
csrf = $('input[name="csrfmiddlewaretoken"]').val()
params = {'addr_id':addr_id, 'pay_method':pay_method, 'sku_ids':sku_ids,
'csrfmiddlewaretoken':csrf}
// 发起ajax post请求,访问/order/commit, 传递的参数: addr_id pay_method, sku_ids
$.post('/order/commit', params, function (data) {
if (data.res==5){
alert('创建成功')
}
else {
alert(data.errmsg)
}
})
});
注意,Ajax使用cdrf的方法:在html中加上:{% csrf_token %};在js中加上:csrf = $('input[name="csrfmiddlewaretoken"]').val() ;
关键:用户每下一个订单,就向df_order_info表中加入一条记录;用户的订单中有几个商品,就需要向df_order_goods表中加入几条记录。
除了从前端和数据库中能直接获取的参数,还有些参数需要自己组织:订单id order_id(年月日时分秒+用户id)、总数total_count、总价total_price、运费transit_price等。
部分code如下,但下面的code还存在很多待完善的地方(库存判断等):
def post(self, request):
...
# 组织参数
# 订单id: 20171122181630+用户id (年月日时分秒+用户id)
order_id = datetime.now().strftime('%Y%m%d%H%M%S') + str(user.id)
transit_price = 10
# 总数目和总金额(先假设为0存进去,添加完df_order_goods后再更新
total_count = 0
total_price = 0
# todo: 向df_order_info表中添加一条记录
order = OrderInfo.objects.create(order_id=order_id, ...)
# todo: 用户的订单中有几个商品,需要向df_order_goods表中加入几条记录
conn = get_redis_connection('default')
cart_key = 'cart_%d'%user.id
sku_ids = sku_ids.split(',') # 将字符串转回列表,用于遍历
for sku_id in sku_ids:
try:
sku = GoodsSKU.objects.get(id=sku_id)
except:
return JsonResponse({'res': 4, 'errmsg': '商品不存在'})
# 从redis中获取用户所要购买的商品的数量
count = conn.hget(cart_key, sku_id)
# todo: 向df_order_goods表中添加一条记录
OrderGoods.objects.create(order=order, ...)
# 更新商品的库存和销量
sku.stock -= int(count)
sku.sales += int(count)
sku.save()
# 累加计算订单商品的总数量和总价格
amount = sku.price * int(count)
total_count += int(count)
total_price += amount
# 更新订单信息表中的商品的总数量和总价格
order.total_count = total_count
order.total_price = total_price
order.save()
# 从redis清除用户购物车中对应的记录(注意对列表拆包*sku_ids,如将[1,3]拆为 1,3 传递 )
conn.hdel(cart_key, *sku_ids)
# 返回应答
return JsonResponse({'res': 5, 'message': '创建成功'})
在上面创建订单的视图中,还可能存在库存不足,特别是当两个用户都将商品加入购物车,先后提交了订单,但后提交时库存不足的现象。这时,不应该再为后者创建订单相关的数据表。这是就可以使用MySQL的事务来处理。
mysql事务: 一组sql操作,要么都成功,要么都失败。即一组mysql语句,要么执行,要么全部不执行。创建订单的一系列操作,要么都成功,要么都失败。MySQL 事务 https://www.mysqlzh.com/
事务的特点
- 原子性:一组事务,要么成功;要么撤回。
- 稳定性 :有非法数据(外键约束之类),事务撤回。
- 隔离性:事务独立运行。一个事务处理后的结果,影响了其他事务,那么其他事务会撤回。事务的100%隔离,需要牺牲速度。
- 可靠性:软、硬件崩溃后,InnoDB数据表驱动会利用日志文件重构修改。可靠性和高速度不可兼得, innodb_flush_log_at_trx_commit 选项 决定什么时候吧事务保存到日志里。
事务控制语句
- BEGIN 或 START TRANSACTION;显式地开启一个事务;
- COMMIT:与COMMIT WORK 等价。COMMIT会提交事务,并使已对数据库进行的所有修改称为永久性的;
- ROLLBACK:与ROLLBACK WORK 等价。回滚会结束用户的务,并撤销正在进行的所有未提交的修改;
- SAVEPOINT identifier(SAVEPOINT 保存点名);SAVEPOINT允许在事务中创建一个保存点,一个事务中可以有多个SAVEPOINT;
- RELEASE SAVEPOINT identifier;删除一个事务的保存点,当没有指定的保存点时,执行该语句会抛出一个异常;
- ROLLBACK TO identifier;把事务回滚到标记点;
mysql事务隔离级别
SQL标准定义了4类隔离级别,包括了一些具体规则,用来限定事务内外的哪些改变是可见的,哪些是不可见的。低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。
Read Uncommitted(读取未提交内容)
在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。- Read Committed(读取提交内容)
这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别 也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果。- Repeatable Read(可重读)
这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。InnoDB和Falcon存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了该问题。- Serializable(可串行化)
这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。
这四种隔离级别采取不同的锁类型来实现,若读取的是同一个数据的话,就容易发生问题。例如:
- 脏读(Drity Read):某个事务已更新一份数据,另一个事务在此时读取了同一份数据,由于某些原因,前一个RollBack了操作,则后一个事务所读取的数据就会是不正确的。
- 不可重复读(Non-repeatable read):在一个事务的两次查询之中数据不一致,这可能是两次查询过程中间插入了一个事务更新的原有的数据。
- 幻读(Phantom Read):在一个事务的两次查询中数据笔数不一致,例如有一个事务查询了几行(Row)数据,而另一个事务却在此时插入了新的几行数据,先前的事务在接下来的查询中,就会发现有几行数据是它先前所没有的。
隔离级别 脏读 不可重复读 幻读 Read Uncommitted(读未提交) V V V Read Committed(读已提交) X V V Repeatable Read(可重读) X X V Serializable(可串行化) X X X 设置mysql事务的隔离级别
打开mysql配置文件: sudo vi /etc/mysql/mysql.conf.d/mysqld.cnf ,找到 skip-external-locking 可在其下面添加如下行,配置隔离级别:
transaction-isolation = READ-COMMITTED
然后保存配置文件,重启mysql服务:sudo service mysql restart
整个订单的创建对MySQL的操作应该放在一个事务中。
Django的相关文档: 数据库事务、显式控制事务、保存点
所以上面的视图中,只要对post函数使用 @transaction.atomic 装饰器即可使用事务
@transaction.atomic
def post(self, request):
...
设置保存点
保存点由 django.db.transaction
: 中的三个函数来控制:
sid
) 。sid
。自保存点被创建依赖执行的更改成为事务的一部分。sid
比如,可以在下面这些位置设置保存点:
@transaction.atomic
def post(self, request):
......
# 设置事务保存点
save_id = transaction.savepoint()
try:
# todo: 向df_order_info表中添加一条记录
order = OrderInfo.objects.create(order_id=order_id,
......)
# todo: 用户的订单中有几个商品,需要向df_order_goods表中加入几条记录
......
for sku_id in sku_ids:
# 获取商品的信息
try:
sku = GoodsSKU.objects.get(id=sku_id)
except:
transaction.savepoint_rollback(save_id) # 商品不存在,回滚
return JsonResponse({'res': 4, 'errmsg': '商品不存在'})
# 从redis中获取用户所要购买的商品的数量
count = conn.hget(cart_key, sku_id)
# 判断商品的库存
if int(count) > sku.stock:
transaction.savepoint_rollback(save_id) # 回滚
return JsonResponse({'res': 6, 'errmsg': '商品库存不足'})
# todo: 向df_order_goods表中添加一条记录
OrderGoods.objects.create(order=order,
......)
......
except Exception as e:
transaction.savepoint_rollback(save_id)
return JsonResponse({'res': 7, 'errmsg': '下单失败'})
# 提交事务
transaction.savepoint_commit(save_id)
在极短的时间内,有两个用户进行了下面的操作。因为进程间的调用时随机的,有可能在库存判断时,两个用户的判断都是有库存的,但接下来进程1先执行,向数据库添加记录并将库存更新为0。这时运行进程2,因为已经判断过库存了,所以也执行了相同的操作。这时,就会造成订单中的商品数已经超出了实际的库存数了。
进程1(用户1发起) | 进程2(用户2发起) |
向 df_order_info 添加一条记录; 查询sku_id=17的商品信息; 库存判断; |
向 df_order_info 添加一条记录; 查询sku_id=17的商品信息; 库存判断; |
向 df_order_goods 中添加记录; 下单后库存更新为0 ... |
向 df_order_goods 中添加记录; 下单后库存更新为0 ... |
悲观锁
悲观锁获取数据时对数据行了锁定,其他事务要想获取锁,必须等原事务结束。
进程1(用户1发起) | 进程2(用户2发起) |
向 df_order_info 添加一条记录; 加锁:select * from df_goods_sku where id=sku_id for update; 查询sku_id=17的商品信息; 库存判断; |
向 df_order_info 添加一条记录; 加锁:select * from df_goods_sku where id=sku_id for update; 查询sku_id=17的商品信息; 库存判断; |
向 df_order_goods 中添加记录; 下单后库存更新为0 事务结束,锁释放。 |
向 df_order_goods 中添加记录; 下单后库存更新为0 事务结束,锁释放。 |
在视图中,把获取商品信息的语句加上 select_for_update()
# select * from df_goods_sku where id=sku_id for update;
sku = GoodsSKU.objects.select_for_update().get(id=sku_id)
乐观锁
查询数据时不加锁,提交更改时再次进行判断。判断更新时的库存和之前查出的库存是否一致 (注:不一致不一定库存就为0,所以应该在加以判断。可以在外面套一个循环,使其尝试几次,几次都不一致则判断为无库存)。
update df_goods_sku set stock=0, sales=1 where id=17 and stock=1;
进程1(用户1发起) | 进程2(用户2发起) |
向 df_order_info 添加一条记录;
查询sku_id=17的商品信息; 库存判断; |
向 df_order_info 添加一条记录;
查询sku_id=17的商品信息; 库存判断; |
向 df_order_goods 中添加记录; 下单后库存更新为0 ... update df_goods_sku set stock=0, sales=1 where id=17 and stock=1; |
向 df_order_goods 中添加记录; 下单后库存更新为0 ... update df_goods_sku set stock=0, sales=1 where id=17 and stock=1; |
注意,应该先配置隔离级别为READ-COMMITTED。如果是Django2.x以后,这是默认的。
使用乐观锁:
class OrderCommitView(View):
"""订单创建"""
@transaction.atomic
def post(self, request):
......
for sku_id in sku_ids:
for i in range(3): # 使用乐观锁做判断时最多做3次判断
# 获取商品的信息
try:
sku = GoodsSKU.objects.get(id=sku_id) # 正常查,不加悲观锁
except:
transaction.savepoint_rollback(save_id) # 商品不存在,回滚
return JsonResponse({'res': 4, 'errmsg': '商品不存在'})
# 从redis中获取用户所要购买的商品的数量
count = conn.hget(cart_key, sku_id)
# 判断商品的库存
if int(count) > sku.stock:
transaction.savepoint_rollback(save_id)
return JsonResponse({'res': 6, 'errmsg': '商品库存不足'})
# 更新商品的库存和销量(使用乐观锁时)
orgin_stock = sku.stock
new_stock = orgin_stock - int(count)
new_sales = sku.sales + int(count)
# update df_goods_sku set stock=new_stock, sales=new_sales where id=sku_id and stock = orgin_stock
# 执行并返回受影响的行数
res = GoodsSKU.objects.filter(id=sku_id, stock=orgin_stock).update(stock=new_stock, sales=new_sales)
if res == 0:
if i == 2: # 第3次尝试,还是不一致则判为无库存下单失败
transaction.savepoint_rollback(save_id)
return JsonResponse({'res': 7, 'errmsg': '下单失败!!'})
continue
# todo: 向df_order_goods表中添加一条记录
OrderGoods.objects.create(order=order, ......)
# todo: 累加计算订单商品的总数量和总价格
......
# 跳出循环
break
......
总之:
在提交订单前端中,订单创建成功并返回后,Ajax的function会弹窗提醒,并在3秒后跳转用户中心的订单页面。
place_order.html
订单提交成功!
......
$('#order_btn').click(function() {
// 获取用户选择的地址id, 支付方式, 要购买的商品id字符串
addr_id = $('input[name="addr_id"]:checked').val() // 选择器获取被选中的checkbox的值(收货地址
pay_method = $('input[name="pay_style"]:checked').val()
sku_ids = $(this).attr('sku_ids')
csrf = $('input[name="csrfmiddlewaretoken"]').val()
// 组织参数
params = {'addr_id':addr_id, 'pay_method':pay_method, 'sku_ids':sku_ids,
'csrfmiddlewaretoken':csrf}
// 发起ajax post请求,访问/order/commit, 传递的参数: addr_id pay_method, sku_ids
$.post('/order/commit', params, function (data) {
if (data.res==5){
// 创建成功
localStorage.setItem('order_finish',2);
$('.popup_con').fadeIn('fast', function() {
setTimeout(function(){
$('.popup_con').fadeOut('fast',function(){
window.location.href = '{% url 'user:order' 1 %}';
});
},3000)
});
}
else {
alert(data.errmsg)
}
})
});
在用户中心的订单页面{% url 'user:order' %} 即 user_center_order.html 中。需要查出所有订单、分页等。
user/view.py的视图中:
class UserOrderView(LoginRequiredMixin, View):
"""用户中心-订单页"""
def get(self, request, page):
......
# 遍历获取订单商品的信息
for order in orders:
# 根据order_id查询订单商品信息
order_skus = OrderGoods.objects.filter(order_if=order.order_id).order_by('-create_time')
# 遍历order_skus计算商品的小计
for order_sku in order_skus:
amount = order_sku.price * order_sku.count
# 动态给order_sku增加属性amount,保存订单商品的小计
order_sku.amount = amount
# 动态给order增加属性,保存订单状态标题
order.status_name = OrderInfo.ORDER_STATUS[order.order_status]
# 动态给order增加属性,保存订单商品的信息
order.order_skus = order_skus
# 分页
......
context = {'order_page': order_page,
'pages': pages,
'page': 'order'}
return render(request, 'user_center_order.html', context)
分页参考列表页即可。
全部订单
{% for order in order_page %}
- {{ order.create_time }}
- 订单号:{{ order.order_id }}
- {{ order.order_status_name }}
{% for order_sku in order.order_skus %}
![]({{ order_sku.sku.image.url }})
- {{ order_sku.sku.name }}
{{ order_sku.price }}元/{{ order_sku.sku.unite }}
- {{ order_sku.amount }}
- 11.80元
{% endfor %}
{{ order.total_price|add:order.transit_price }}(含运费:{{ order.transit_price }})元
{{ order.order_status_name }}
去付款
{% endfor %}
订单支付以支付宝支付为例,使用支付宝开放平台。
支付宝开放平台:https://open.alipay.com/platform/home.htm ,可以使用自己的帐号登录。
实际开发中,在开发接入中创建自己需要的应用,接入支付宝开放平台。
这里,仅使用沙箱来测试支付业务,在开发者中心控制台中,找到研发服务的沙箱环境(根据下面的文档操作即可)。
沙箱相关文档:https://opendocs.alipay.com/open/200/105311
支付宝开发者各种文档:https://openhome.alipay.com/developmentDocument.htm
电脑网站支付:https://opendocs.alipay.com/open/270、快速接入
比较方便的是,支付宝为了帮助开发者调用开放接口,提供了开放平台服务端 SDK,包含 JAVA、PHP、NodeJS、Python 和 .NET 五种,封装了签名&验签、HTTP 接口请求等基础功能。请先下载对应语言版本的 SDK 并引入您的开发工程。
(1) 一个更方便的非官方SDK:https://github.com/fzlee/alipay/blob/master/README.zh-hans.md
# 命令安装python-alipay-sdk pip install python-alipay-sdk --upgrade
(2) python版的官方 Alipay SDK:PyPI项目依赖,即https://pypi.org/project/alipay-sdk-python/3.3.398/
将whl文件下载下来安装。或在你的环境中,使用命令:pip install alipay-sdk-python==3.3.398 安装。
openssl
OpenSSL> genrsa -out app_private_key.pem 2048 # 私钥
OpenSSL> rsa -in app_private_key.pem -pubout -out app_public_key.pem # 生成跟私钥对应的公钥
OpenSSL> exit
使用命令 cat app_publict_key.pem 查看公钥的内容:
将-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----中间的内容保存在支付宝的用户配置中,即秘钥设置中公钥加签模式处(沙箱或者正式应用)
复制支付宝提供的支付宝公钥。将其保存在项目的文件中,比如 /order/alipay_public_key.pem ,保存的格式如下。顺便将刚刚生成的电脑的私钥文件也拷贝到这个目录。(请求时用自己的私钥加密,收到信息时用支付宝公钥解密)
-----BEGIN PUBLIC KEY-----
这里粘贴上支付宝公钥
-----END PUBLIC KEY-----
# 前端ajax传递的参数:订单id(order_id)
# /order/pay
class OrderPayView(View):
"""支付宝订单创建"""
def post(self, request):
"""订单创建"""
......
with open(os.path.join(settings.BASE_DIR, 'apps/order/app_private_key.pem'), "r") as f:
app_private_key = f.read()
with open(os.path.join(settings.BASE_DIR, 'apps/order/alipay_public_key.pem'), "r") as f:
alipay_public_key = f.read()
# 业务处理:使用python sdk调用支付宝的支付接口
# 初始化
alipay = AliPay(
appid="2016102400753000", # 应用id
app_notify_url=None, # 默认回调url
app_private_key_string=app_private_key,
alipay_public_key_string=alipay_public_key,
# 支付宝的公钥,验证支付宝回传消息使用,不是你自己的公钥,
sign_type="RSA2", # RSA 或者 RSA2
debug=True # 默认False,改为True访问沙箱的地址
)
# 调用支付接口。电脑网站支付,需要跳转到https://openapi.alipaydev.com/gateway.do? + order_string
order_string = alipay.api_alipay_trade_page_pay(
out_trade_no=order_id, # 订单id
total_amount=total_pay, # 支付总金额
subject='天天生鲜%s' % order_id,
return_url=None,
notify_url=None # 可选, 不填则使用默认notify url
)
# 返回应答
pay_url = 'https://openapi.alipaydev.com/gateway.do?' + order_string
return JsonResponse({'res': 3, 'pay_url': pay_url}) # 要将用户引导到支付地址
...
去付款
...
# 前端ajax传递的参数:订单id(order_id)
# /order/check
class CheckPayView(View):
"""查看订单支付的结果"""
def post(self, request):
......
# 调用支付宝的交易查询接口
while True:
response = alipay.api_alipay_trade_query(order_id)
code = response.get('code')
if code == '10000' and response.get('trade_status') == 'TRADE_SUCCESS':
# 支付成功
# 获取支付宝交易号
trade_no = response.get('trade_no')
# 更新订单状态到数据库
order.trade_no = trade_no
order.trade_status = 4 # 待评价
order.save()
return JsonResponse({'res': 3, 'message': '支付成功'})
elif code == '10000' and response.get('trade_status') == 'WAIT_BUYER_PAY':
# 等待买家付款
# 业务处理失败,可能一会就会成功
import time
time.sleep(5)
continue
else:
# 支付出错
return JsonResponse({'res': 4, 'errmsg': '支付失败'})
评论的后台视图在order/views.py,关键是从前端或数据库获取对应的订单id,对其评论字段进行读写:
class CommentView(LoginRequiredMixin, View):
"""订单评论"""
def get(self, request, order_id):
"""提供评论页面"""
......
# 根据订单的状态获取订单的状态标题
order.status_name = OrderInfo.ORDER_STATUS[order.order_status]
# 获取订单商品信息
order_skus = OrderGoods.objects.filter(order_id=order_id)
for order_sku in order_skus:
amount = order_sku.count*order_sku.price
# 动态给order_sku增加属性amount,保存商品小计
order_sku.amount = amount
# 动态给order增加属性order_skus, 保存订单商品信息
order.order_skus = order_skus
# 使用模板
return render(request, "order_comment.html", {"order": order})
def post(self, request, order_id):
"""处理评论内容"""
......
try:
order = OrderInfo.objects.get(order_id=order_id, user=user)
except OrderInfo.DoesNotExist:
return redirect(reverse("user:order"))
# 获取评论条数
total_count = request.POST.get("total_count")
total_count = int(total_count)
# 循环获取订单中商品的评论内容
for i in range(1, total_count + 1):
sku_id = request.POST.get("sku_%d" % i) # sku_1 sku_2
# 获取评论的商品的内容
content = request.POST.get('content_%d' % i, '') # cotent_1 content_2 content_3
try:
order_goods = OrderGoods.objects.get(order=order, sku_id=sku_id)
except OrderGoods.DoesNotExist:
continue
order_goods.comment = content
order_goods.save()
order.order_status = 5 # 已完成
order.save()
return redirect(reverse("user:order", kwargs={"page": 1}))
支付完成后,用户中心订单页的“去支付”按钮应该改为“去评价”等相关功能。在js中:
在detail.html中,以下js使评论内容显示在“评论”而不是“详情”:
- 商品介绍
- 评论
- 商品详情:
{# 使用save过滤器关闭富文本转义#}
- {{ sku.goods.detail|safe }}
{% for order in sku_orders %}
- 评论时间:{{ order.update_time }} 用户名:{{ order.order.user.username }}
- 评论内容:{{ order.comment }}
{% endfor %}
......
01 框架、数据表设计、项目框架笔记
02 注册、登录、用户中心 (itsdangerous模块加密、celery异步、 Django 的验证系统、redis作为缓存等)
03 FastDFS文件存储-首页-详情页-列表页
04 搜索(搜索引擎、分词包的使用)、购物车
05 订单(Mysql事务、并发处理、支付宝支付、评论)
06 项目部署(uwsgi服务器、Nginx服务器)
-----end-----