今天要來將昨天的購物車生成訂單,填寫訂購人資訊後串接第三方金流服務,直接結帳付款,事不宜遲,馬上就來大花錢吧!
在 orders 應用程式的 models.py 建立 Order 模型。因為多筆訂單可能會對應到多筆商品,這裡會使用到 ManyToManyField 關聯欄位。
status 欄位則是訂單的當前狀態,分為未付款、付款失敗、等待出貨、運送中、已取消,預設欄位是未付款。
order_id 是訂單編號,在 save 函數會判斷建立訂單時自動新增為特定的編號格式。
# orders/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _
# Create your models here.
class Order(models.Model):
'''
訂單
'''
order_id = models.CharField(_('Order Id'), max_length=20)
email = models.EmailField('E-mail', max_length=255)
product = models.ManyToManyField('products.Product', related_name='order_set', through='products.RelationalProduct')
name = models.CharField(_('Name'), max_length=50)
phone = models.CharField(_('Phone'), max_length=50)
district = models.CharField(_('District'), max_length=50)
zipcode = models.CharField(_('Zip Code'), max_length=50)
address = models.CharField(_('Address'), max_length=255)
total = models.PositiveIntegerField(_('Total'), default=0)
created = models.DateTimeField(_('Created Date'), auto_now_add=True)
modified = models.DateTimeField(_('Modified Date'), auto_now=True)
status = models.CharField(
_('Status'),
max_length=100,
choices=(("unpaid", _("Unpaid")), ("payment_fail", _("Payment Fail")), ("waiting_for_shipment", _("Waiting for shipment")), ("transporting", _("Transporting")), ("completed", _("Completed")), ("cancelled", _("Cancelled"))),
default="unpaid"
)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if not self.order_id:
self.order_id = f'ORDER{self.id:08}'
super().save(*args, **kwargs)
class Meta:
verbose_name = '訂單'
verbose_name_plural = '訂單'
def __str__(self):
return f'{self.order_id}'
在 Django 預設的 ManyToManyField 會自動生成中間表,這次我們要 products 應用程式的模型自定義中間表 RelationalProduct,為了加上商品數量欄位,並且使用 property 定義商品名稱、價格就能在管理介面快速查看。
# products/models.py
class RelationalProduct(models.Model):
product = models.ForeignKey('products.Product', on_delete=models.CASCADE, verbose_name='商品名稱')
order = models.ForeignKey('orders.Order', on_delete=models.CASCADE)
number = models.IntegerField('數量', default=1)
@property
def name(self):
return self.product.name
@property
def price(self):
return self.product.price
def __str__(self):
return ""
建立資料庫遷移檔案
docker exec --workdir /opt/app/web example_tenant_web \
python3.10 manage.py makemigrations
...
Migrations for 'orders':
orders/migrations/0001_initial.py
- Create model Order
orders/migrations/0002_order_product.py
- Add field product to order
Migrations for 'products':
products/migrations/0004_relationalproduct.py
- Create model RelationalProduct
執行資料庫遷移
docker exec --workdir /opt/app/web example_tenant_web \
python3.10 manage.py migrate
...
=== Starting migration
Operations to perform:
Apply all migrations: admin, auth, contenttypes, core, customers, django_q, epaper, orders, products, sessions, sites
Running migrations:
Applying orders.0001_initial...
OK
Applying products.0004_relationalproduct...
OK
Applying orders.0002_order_product...
OK
=== Starting migration
Operations to perform:
Apply all migrations: admin, auth, contenttypes, core, customers, django_q, epaper, orders, products, sessions, sites
Running migrations:
Applying orders.0001_initial...
OK
Applying products.0004_relationalproduct...
OK
Applying orders.0002_order_product...
OK
=== Starting migration
Operations to perform:
Apply all migrations: admin, auth, contenttypes, core, customers, django_q, epaper, orders, products, sessions, sites
Running migrations:
Applying orders.0001_initial...
OK
Applying products.0004_relationalproduct...
OK
Applying orders.0002_order_product...
OK
在 orders 應用程式的 admin.py 新增訂單管理介面 OrderAdmin 與內嵌商品列表管理介面 RelationalProductInline。
在內嵌商品列表管理介面會使用在 RelationalProduct 模型透過 property 定義的商品名稱、訂單總價格欄位,並且全部設定為只可查看不可修改的 readonly_fields 欄位。
# orders/admin.py
from django.contrib import admin
# Register your models here.
from orders.models import Order
from products.models import RelationalProduct
class RelationalProductInline(admin.TabularInline):
model = RelationalProduct
model = Order.product.through
verbose_name = '商品名稱'
extra = 2
fields = ('name', 'price', 'number')
readonly_fields = ('name', 'price', 'number')
def has_add_permission(self, request, obj):
return False
def has_delete_permission(self, request, obj=None):
return False
class OrderAdmin(admin.ModelAdmin):
model = Order
search_fields = ['order_id', 'name']
fields = ('order_id', 'name', 'email', 'phone', 'district', 'zipcode', 'address', 'total', 'status', 'created', 'modified')
list_display = ('order_id', 'name', 'email', 'total')
list_filter = ('status',)
readonly_fields = ('order_id', 'name', 'email', 'phone', 'district', 'zipcode', 'address', 'total', 'created', 'modified')
inlines = [RelationalProductInline,]
admin.site.register(Order, OrderAdmin)
我們將使用『裝潢大廳,套用 Template 版面』中的模板 aviato/checkout.html 來套用,會有左側的訂購人資訊表單與右側的購物車商品列表與訂單總金額。
結帳表單
從 Order 模型中取出欄位並生成結帳表單 ModelForm。
widgets 為根據套用的模板 form 定義的 class 屬性。
# orders/forms.py
from django import forms
from orders.models import Order
class OrderForm(forms.ModelForm):
class Meta:
model = Order
fields = (
'email',
'name',
'phone',
'district',
'zipcode',
'address',
)
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'address': forms.TextInput(attrs={'class': 'form-control'}),
'zipcode': forms.TextInput(attrs={'class': 'form-control'}),
'district': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.TextInput(attrs={'class': 'form-control'}),
'phone': forms.TextInput(attrs={'class': 'form-control'}),
}
結帳視圖
在 orders 應用程式的 views.py 繼承 FormView 來建立 CheckoutView 結帳視圖。
透過 cookie 取得 cart 資料來顯示右側的購物車資料與訂單總金額。
在表單通過驗證後,透過 cookie 取得 cart 資料儲存至訂單的 product 欄位,並計算訂單總金額儲存至 total 欄位,最後將訂單編號儲存至 context 傳遞至前往付款頁面。
# orders/views.py
# ...
class CheckoutView(FormView):
template_name = "orders/checkout.html" # 結帳頁面
form_class = OrderForm
success_url = 'orders/confirmation.html' # 前往付款頁面
def get_cart_cookie(self, request):
cart_str = request.COOKIES.get('cart', '')
product_dict = {}
if cart_str:
cart_bytes = cart_str.encode()
cart_bytes = base64.b64decode(cart_bytes)
cart_dict = pickle.loads(cart_bytes)
return cart_dict
def get(self, request, *args, **kwargs):
cart_dict = self.get_cart_cookie(request)
if cart_dict:
product_dict = cart_dict.copy()
total = 0
for product_id in cart_dict:
if product := Product.objects.filter(id=product_id):
product_dict[product_id]["product"] = product.first()
total += int(product_dict[product_id]["count"]) * int(product.first().price)
else:
del product_dict[product_id]
context = self.get_context_data(**kwargs)
context["product_dict"] = product_dict
context["total"] = total
return self.render_to_response(context)
def form_valid(self, form):
self.object = form.save(commit=False)
cart_dict = self.get_cart_cookie(self.request)
self.object.save()
if cart_dict:
total = 0
for product_id in cart_dict:
if product := Product.objects.filter(id=product_id):
RelationalProduct.objects.create(order=self.object, product=product.first(), number=cart_dict[product_id]["count"])
total += int(cart_dict[product_id]["count"]) * int(product.first().price)
self.object.total = total
self.object.save()
context = self.get_context_data(form=form)
context['order_id'] = self.object.order_id
return render(self.request, self.success_url, context=context)
新增 URLS 路由
checkout 對應 結帳視圖
以下為 urls.py:
# orders/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('cart/', views.CartView.as_view(), name='cart'),
path('addcart/<int:product_id>/', views.AddCartView.as_view(), name='add_cart'),
path('deletecart/<int:product_id>/', views.DeleteCartView.as_view(), name='delete_cart'),
path('checkout/', views.CheckoutView.as_view(), name='checkout'),
]
購物車頁面新增結帳頁面 url
調整原本的 checkout 按鈕
<!-- orders/templates/orders/cart.html -->
<!-- ... -->
<a href="{% url 'orders:checkout' %}" class="btn btn-main pull-right">Checkout</a>
<!-- ... -->
結帳頁面模板
使用結帳視圖傳遞過來的 form 參數生成表單,這裡因為要套用模板,所以逐個欄位進行套用。
<!-- orders/templates/orders/checkout.html -->
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block content %}
<section class="page-header">
<div class="container">
<div class="row">
<div class="col-md-6">
<ol class="breadcrumb">
<li><a href="{% url 'products:home' %}">{% translate "Home page" %}</a></li>
<li><a href="{% url 'orders:cart' %}">{% translate "Shopping cart" %}</a></li>
<li class="active">{% translate "Checkout" %}</li>
</ol>
</div>
</div>
</div>
</section>
<div class="page-wrapper">
<div class="checkout shopping">
<div class="container">
<div class="row">
<div class="col-md-8">
<div class="block billing-details">
<h4 class="widget-title">Billing Details</h4>
<form method="post" class="checkout-form">{% csrf_token %}
<div class="form-group">
<label for="name">{{ form.name.label }}</label>
{{ form.name }}
</div>
<div class="form-group">
<label for="email">{{ form.email.label }}</label>
{{ form.email }}
</div>
<div class="form-group">
<label for="phone">{{ form.phone.label }}</label>
{{ form.phone }}
</div>
<div class="checkout-country-code clearfix">
<div class="form-group">
<label for="zipcode">{{ form.zipcode.label }}</label>
{{ form.zipcode }}
</div>
<div class="form-group" >
<label for="district">{{ form.district.label }}</label>
{{ form.district }}
</div>
</div>
<div class="form-group">
<label for="address">{{ form.address.label }}</label>
{{ form.address }}
</div>
<input type="submit" class="btn btn-main mt-20" value="{% translate 'Place Order' %}">
</form>
</div>
</div>
<div class="col-md-4">
<div class="product-checkout-details">
<div class="block">
<h4 class="widget-title">Order Summary</h4>
{% for product_id, product in product_dict.items %}
<div class="media product-card">
<a class="pull-left" href="{% url 'products:detail' product_id %}">
{% if product.product.product_image_set.all %}
{% if product.product.product_image_set.all.0.image %}
<img class="media-object" src="{{ product.product.product_image_set.all.0.image.url }}" alt="Image" />
{% endif %}
{% endif %}
</a>
<div class="media-body">
<h4 class="media-heading"><a href="{% url 'products:detail' product_id %}">{{ product.product.name }}</a></h4>
<p class="price">{{ product.count }} x ${{ product.product.price }}</p>
<a href="javascript:void(0)" onclick="getAjax('{% url 'orders:delete_cart' product_id %}', '已移除商品', 'true');" class="remove" >Remove</a>
</div>
</div>
{% endfor %}
<ul class="summary-prices"></ul>
<div class="summary-total">
<span>Total</span>
<span>${{ total }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
接收 order_id 參數並將訂單傳遞給綠界科技金流表單(會在後面實作)
<!-- orders/templates/orders/confirmation.html -->
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block content %}
<!-- Page Wrapper -->
<section class="page-wrapper success-msg">
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="block text-center">
<i class="tf-ion-android-checkmark-circle"></i>
<h2 class="text-center">訂單已建立完成,請立即前往付款</h2>
<form method="post" action="{% url 'orders:ecpay' %}">
{% csrf_token %}
<input type="hidden" name="order_id" value="{{ order_id }}" />
<button type="submit" class="btn btn-main">前往付款</button>
</form>
</div>
</div>
</div>
</div>
</section><!-- /.page-warpper -->
{% endblock content %}
API 參考文件
https://www.ecpay.com.tw/Service/API_Dwnld
Python SDK
https://github.com/ECPay/ECPayAIO_PYTHON
下載 sdk 中的 ecpay_payment_sdk.py 後放入 orders 應用程式目錄。
Ngork 測試
因需要 https 才能通過,需使用 ngork 進行測試
docker run -it -e NGROK_AUTHTOKEN=<token> ngrok/ngrok http 8000
取得 ngork domain 後加入至 example01 租戶 domain,以租戶 example01 為例,將 domain 加入至以下租戶設定檔:
http://example01.localhost:8000/admin/customers/client/1/change/
新增 URLS 路由
ecpay 對應 綠界科技金流表單視圖
return 對應 綠界科技金流伺服器端回應視圖
orderresult 對應 綠界科技金流用戶端端回應視圖
order_success 對應 付款成功視圖
order_fail 對應 付款失敗視圖
以下為 urls.py:
# orders/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('cart/', views.CartView.as_view(), name='cart'),
path('addcart/<int:product_id>/', views.AddCartView.as_view(), name='add_cart'),
path('deletecart/<int:product_id>/', views.DeleteCartView.as_view(), name='delete_cart'),
path('checkout/', views.CheckoutView.as_view(), name='checkout'),
path('ecpay/', views.ECPayView.as_view(), name='ecpay'),
path('return/', views.ReturnView.as_view(), name='return'),
path('orderresult/', views.OrderResultView.as_view(), name='orderresult'),
path('order_success/', views.OrderSuccessView.as_view(), name='order_success'),
path('order_fail/', views.OrderFailView.as_view(), name='order_fail'),
]
綠界科技金流表單視圖
取得前往付款頁面傳遞來的 order_id 來生成訂單資訊。
order_params 為綠界科技金流的需求欄位,可參考 API 文件定義。
透過 API 最後會生成一個 HTML 表單,將此表單存入 ecpay_form 變數,並傳遞給綠界科技金流表單頁面。
# orders/views.py
# ...
class ECPayView(TemplateView):
template_name = "orders/ecpay.html"
def post(self, request, *args, **kwargs):
scheme = request.is_secure() and "https" or "http"
domain = request.META['HTTP_HOST']
order_id = request.POST.get("order_id")
order = Order.objects.get(order_id=order_id)
product_list = "#".join([product.name for product in order.product.all()])
order_params = {
'MerchantTradeNo': order.order_id,
'StoreID': '',
'MerchantTradeDate': datetime.now().strftime("%Y/%m/%d %H:%M:%S"),
'PaymentType': 'aio',
'TotalAmount': order.total,
'TradeDesc': order.order_id,
'ItemName': product_list,
'ReturnURL': f'{scheme}://{domain}/orders/return/', # ReturnURL為付款結果通知回傳網址,為特店server或主機的URL,用來接收綠界後端回傳的付款結果通知。
'ChoosePayment': 'ALL',
'ClientBackURL': f'{scheme}://{domain}/products/list/', # 消費者點選此按鈕後,會將頁面導回到此設定的網址(返回商店按鈕)
'ItemURL': f'{scheme}://{domain}/products/list/', # 商品銷售網址
'Remark': '交易備註',
'ChooseSubPayment': '',
'OrderResultURL': f'{scheme}://{domain}/orders/orderresult/', # 消費者付款完成後,綠界會將付款結果參數以POST方式回傳到到該網址
'NeedExtraPaidInfo': 'Y',
'DeviceSource': '',
'IgnorePayment': '',
'PlatformID': '',
'InvoiceMark': 'N',
'CustomField1': '',
'CustomField2': '',
'CustomField3': '',
'CustomField4': '',
'EncryptType': 1,
}
# 建立實體
ecpay_payment_sdk = module.ECPayPaymentSdk(
MerchantID='2000132',
HashKey='5294y06JbISpM5x9',
HashIV='v77hoKGq4kWxNNIS'
)
# 產生綠界訂單所需參數
final_order_params = ecpay_payment_sdk.create_order(order_params)
# 產生 html 的 form 格式
action_url = 'https://payment-stage.ecpay.com.tw/Cashier/AioCheckOut/V5' # 測試環境
# action_url = 'https://payment.ecpay.com.tw/Cashier/AioCheckOut/V5' # 正式環境
ecpay_form = ecpay_payment_sdk.gen_html_post_form(action_url, final_order_params)
context = self.get_context_data(**kwargs)
context['ecpay_form'] = ecpay_form
return self.render_to_response(context)
綠界科技金流表單頁面
使用 模板語法 {% autoescape off %}{% endautoescape %}
將要直接執行 HTML 包在其中,即可生成表單而不是純文字,綠界金流表單會自動送出後直接導轉至綠界科技金流付款頁面(第三方)。
<!-- orders/templates/orders/ecpay.html -->
{% autoescape off %}{{ecpay_form}}{% endautoescape %}
伺服器端回應視圖
訂購人付款後綠界科技金流會回傳一個 POST 回應至伺服器端,這裡要寫一個 ReturnView 視圖驗證檢查碼後回應 '1|OK' 代表成功。
# orders/views.py
# ...
class ReturnView(View):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super(ReturnView, self).dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
ecpay_payment_sdk = module.ECPayPaymentSdk(
MerchantID='2000132',
HashKey='5294y06JbISpM5x9',
HashIV='v77hoKGq4kWxNNIS'
)
res = request.POST.dict()
back_check_mac_value = request.POST.get('CheckMacValue')
check_mac_value = ecpay_payment_sdk.generate_check_value(res)
if check_mac_value == back_check_mac_value:
return HttpResponse('1|OK')
return HttpResponse('0|Fail')
用戶端回應視圖
訂購人付款後綠界科技金流會回傳一個 POST 回應至用戶端,這裡要寫一個 OrderResultView視圖驗證檢查碼、RtnMsg 付款結果訊息、RtnCode回應代碼,皆通過後導轉至付款成功頁面並將訂單狀態更新為等待出貨,付款失敗則導轉至付款失敗頁面。
# orders/views.py
# ...
class OrderResultView(View):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super(OrderResultView, self).dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
ecpay_payment_sdk = module.ECPayPaymentSdk(
MerchantID='2000132',
HashKey='5294y06JbISpM5x9',
HashIV='v77hoKGq4kWxNNIS'
)
res = request.POST.dict()
back_check_mac_value = request.POST.get('CheckMacValue')
order_id = request.POST.get('MerchantTradeNo')
rtnmsg = request.POST.get('RtnMsg')
rtncode = request.POST.get('RtnCode')
check_mac_value = ecpay_payment_sdk.generate_check_value(res)
if check_mac_value == back_check_mac_value and rtnmsg == 'Succeeded' and rtncode == '1':
order = Order.objects.get(order_id=order_id)
order.status = 'waiting_for_shipment'
order.save()
return HttpResponseRedirect('/orders/order_success/')
return HttpResponseRedirect('/orders/order_fail/')
付款成功視圖
只需呈現付款成功模板
# orders/views.py
# ...
class OrderSuccessView(TemplateView):
template_name = "orders/order_success.html"
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
付款成功頁面
顯示付款成功訊息
<!-- orders/templates/orders/order_success.html -->
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block content %}
<!-- Page Wrapper -->
<section class="page-wrapper success-msg">
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="block text-center">
<i class="tf-ion-android-checkmark-circle"></i>
<h2 class="text-center">付款成功</h2>
<a href="shop.html" class="btn btn-main mt-20">Continue Shopping</a>
</div>
</div>
</div>
</div>
</section><!-- /.page-warpper -->
{% endblock content %}
付款失敗視圖
只需呈現付款失敗模板
# orders/views.py
# ...
class OrderFailView(TemplateView):
template_name = "orders/order_fail.html"
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
付款失敗頁面
顯示付款失敗訊息
<!-- orders/templates/orders/order_fail.html -->
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block content %}
<!-- Page Wrapper -->
<section class="page-wrapper success-msg">
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="block text-center">
<i class="tf-ion-android-checkmark-circle"></i>
<h2 class="text-center">付款失敗,請重新下單</h2>
<a href="shop.html" class="btn btn-main mt-20">Continue Shopping</a>
</div>
</div>
</div>
</div>
</section><!-- /.page-warpper -->
{% endblock content %}
到這裡綠界科技金流就串接完成了!
購物車頁面,點選 checkout 進入結帳頁面。
結帳頁面左側為訂購人資訊,右側為購物清單。
訂單建立完成,前往付款頁面。
綠界金流付款頁面(第三方)。
綠界金流付款頁面(第三方)送出付款資料。
綠界金流交易安全驗證頁面(第三方)。
付款成功,導轉至付款成功頁面。
在訂單管理介面確認訂單。
完成!!
今天不只完成了訂單也實作了金流串接,最後一天將會介紹如何進行部署,下一回『改造完成,雲端部署 Django 多租戶架構電商網站』。