作者:KBdancer
來源:https://www.92ez.com/
博主上周接了一個小單子,是關(guān)于微信卡券,博主原來主要精力和工作內(nèi)容屬于前端開發(fā),后來學習了Python后發(fā)現(xiàn)自己居然也會寫后端了,于是一發(fā)不可收拾,自學Django和Flask之后發(fā)現(xiàn)還是寫后端比較有意思,業(yè)余時間也接一些小的外包單子掙點零花。外包項目多數(shù)是跟微信相關(guān),但是關(guān)于微信卡券,博主還真是第一次開發(fā),遇到的坑不少,做此記錄,也給初學Python web的萌新一些入門知識。
眾所周知,微信官方的開發(fā)者文檔寫的非常的爛,至于有多爛,同學們可以自行谷歌微信文檔的坑,如果說你打算完全參考官方的文檔進行開發(fā),可能你永遠都無法開發(fā)出來你想要的項目,so?老老實實參考第三方的民間文檔吧,以免浪費寶貴的開發(fā)時間。
這個項目實際開發(fā)時間為1.5天,其中被微信文檔坑掉的時間大概占到了三分之二,所以說經(jīng)驗很重要。博主之前做過比較多基于微信的項目,在微信授權(quán)和jssdk應用上面還是比較熟悉的,這個項目的功能非常簡單:
微信授權(quán)獲取用戶信息保存到數(shù)據(jù)庫,保存成功之后調(diào)用微信的jssdk喚起微信的卡券領(lǐng)取頁面,用戶領(lǐng)取成功之后更新數(shù)據(jù)庫記錄標示已領(lǐng)取。
早前博主使用的是PHP開發(fā)過微信授權(quán),用了第三方寫的一個庫,生成各種簽名都是簡單調(diào)用封裝好的方法,簡單易用,但是這次使用Python開發(fā),雖然Gayhub上有很多第三方開源的封裝庫,但是我這里只需要簡單的微信授權(quán)和卡券功能,并不需要那么強大,所以還是自己動手寫個比較方便,溫故而知新,再復習一下舊的知識也是相當不錯的。
此項目前端頁面使用了如下開源庫:
vuejs 這個就不用說了,最近很火
weui.js 微信官方團隊開源的微信生態(tài)UI組件庫
axios.js vue推薦的前端ajax方案,使用起來很棒
后端:Flask(Flask, render_template, request, session, jsonify, redirect)
數(shù)據(jù)庫:Mysql
由于項目很小,這里就沒有使用前后完全分離開發(fā),直接使用了Flask的Jinja2模板引擎進行頁面的渲染,Jinja2的頁面渲染識別符號是{{ }},由于vuejs也是使用的{{ }}作為識別符,為了避免沖突,我這里將Jinja2的識別符修改成了[[ ]],也是挺好看的。使用如下代碼進行設置
app.jinja_env.variable_start_string = '[[ '
app.jinja_env.variable_end_string = ' ]]'
項目里面生成微信Token之類的需要跟微信的服務器進行交互,所以用到了 requests 庫,生成簽名用的是sha1算法,用的庫是 hashlib,為了防止csrf攻擊,生成隨機字符串需要用到 random,對數(shù)據(jù)進行json轉(zhuǎn)換的操作用 json 庫,生成數(shù)據(jù)報表用xlwt庫來操作excel文件,使用time庫生成時間戳,微信簽名也需要用到它,獲取當前項目路徑用sys庫,os庫用來判斷文件或者路徑是否存在。
Flask里面對Mysql數(shù)據(jù)庫操作用的是Flask-Mysql,當然也可以使用Python-Mysql,這個取決于個人需要,使用方法都差不多,總結(jié)一下用到的庫,大概就是下面這些了
from flask import Flask, render_template, request, session, jsonify, redirect
from flaskext.mysql import MySQL
import requests
import hashlib
import random
import json
import xlwt
import time
import sys
import os
Flask項目里面使用session的話需要有一段隨機字符串作為安全密鑰,比如說我這里寫的是
app.secret_key = 'A0Zr98j/3yX R~XHH!jmNT'
這個自己生成就好了,如果沒有這玩意 session 就沒法用。
為了方便對數(shù)據(jù)庫的操作,我這里寫了通用的類
mysql_object = MySQL()
app.config['MYSQL_DATABASE_USER'] = 'user'
app.config['MYSQL_DATABASE_PASSWORD'] = 'sadjni3u8asnd3'
app.config['MYSQL_DATABASE_DB'] = 'wanshang_coupon'
app.config['MYSQL_DATABASE_HOST'] = '127.0.0.1'
mysql_object.init_app(app)
class Database:
def __init__(self):
self.connection = mysql_object.connect()
self.cursor = self.connection.cursor()
def insert(self, query, params):
try:
self.cursor.execute(query, params)
self.connection.commit()
except Exception as e:
print(e)
self.connection.rollback()
def query(self, query, params):
self.cursor.execute(query, params)
return self.cursor.fetchall()
def __del__(self):
self.connection.close()
項目比較小,query和insert夠用了,insert也可用于update。關(guān)于項目的完整代碼就不貼出來了,也沒啥用,下面就說一些關(guān)鍵點吧。
首先用戶進入首頁,會進行微信的授權(quán),代碼如下
@app.route('/')
def index():
appid = 'wx*************'
redirect_uri = 'http%3A%2F%2Fcoupon.abc.com%2Fauthorization'
csrf_state = str(random.randint(0, 10000)) + ''.join(random.sample('abcdefghijk', 5))
session['csfr_token'] = csrf_state
wx_code_url = 'https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect' % (appid, redirect_uri, csrf_state)
return redirect(wx_code_url)
微信的授權(quán)是分步驟進行的,上面的代碼是獲取一個code,有了這個code才能去拿token,上面代碼中需要注意的是 redirecturi 這個變量一定要進行url編碼,否則微信會報異常,csrfstate 變量是生成一個隨機字符串,用于防止csrf跨站攻擊,微信文章說不加也可以,為了安全起見,咱們還是加上去吧,誰叫咱們是搞安全的呢。
這個csrf_state會在獲取到code后微信重定向回來時帶在url后面,所以下一步是可以拿到這個參數(shù)的。
session['csfrtoken'] = csrfstate 這個就很簡單了,把這個隨機字符串寫入到session,這樣在下一步操作的時候會去檢查進行比對。
這里我要說明一下為何需要這個 csrf_state 參數(shù),微信授權(quán)在獲取code的時候,請求是通過前端的URL進行跳轉(zhuǎn),url跳轉(zhuǎn)很明顯的一個問題就是明文傳輸,明文傳輸?shù)臅r候arp攻擊是很容易拿到請求鏈接的,如果說我們實現(xiàn)已經(jīng)在服務器上的session里面存儲了那個隨機字符串,微信回調(diào)回來的時候服務器拿到參數(shù)進行比對,如果這個參數(shù)值跟session里面的一致,則認為是正常的請求,否則這個請求就是偽造的,比如典型的請求重放攻擊,在這里就用不了了,就算被arp拿到了鏈接,發(fā)過去服務器也會認為請求非法所以我還是建議加上這個參數(shù)。
Flask跳轉(zhuǎn)使用redirect方法。
下面是關(guān)鍵的授權(quán)部分,獲取授權(quán)Token的回調(diào)鏈接在上一步已經(jīng)給出,微信會自動回調(diào)到這個頁面,相關(guān)注釋我直接在代碼里面寫了
@app.route('/authorization')
def authorization():
page_status = '1'
wx_code = request.args.get('code') # Flask 獲取 get請求的值用request.args,獲取post請求用request.form,獲取前端ajax的json數(shù)據(jù)用request.data,這里的code是微信回調(diào)的時候加上去的
csrf_state = request.args.get('state') # 這個state是微信回調(diào)回來的時候帶上去的,是之前我們自己生成的隨機字符串
if session['csfr_token'] != csrf_state: # 隨機字符串的校驗,校驗失敗就跳轉(zhuǎn)首頁
return redirect('http://coupon.abc.com')
else:
appid = 'wx****************'
app_secret = '*********************'
wx_login_token_url = 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code' % (appid, app_secret, wx_code)
try:
wx_login_token = requests.get(url=wx_login_token_url, timeout=15).json() # 獲取一個token
session['openid'] = wx_login_token['openid'] # 微信返回的openid,同時返回了一個授權(quán)用的Token,這個授權(quán)token是沒有次數(shù)限制的,區(qū)別于基礎(chǔ)功能的access_token,這里把openid作為session來全局判斷登錄狀態(tài)
access_token = get_access_token(appid, app_secret) # 單獨寫了一個獲取基礎(chǔ)功能access_tokende 的方法,這個是有次數(shù)限制的,每天2000次,用完就gg了,所以需要做特殊的緩存操作,下面會詳細說
jsapi = get_ticket(access_token['access_token'], 'jsapi') # 這個單獨的方法是獲取通用jssdk需要的ticket方法,ticket的使用也有次數(shù)限制,所以也需要做緩存
jsapi_ticket = jsapi['ticket']
jsapi_timestamp = int(time.time())
jsapi_current_url = request.url
jsapi_noncestr = createNonceStr()
before_string = 'jsapi_ticket=%s&noncestr=%s×tamp=%s&url=%s' % (jsapi_ticket, jsapi_noncestr, jsapi_timestamp, jsapi_current_url)
sha = hashlib.sha1(before_string.encode('utf8'))
jsapi_signature = sha.hexdigest()
jssdk_data = {
'appId': appid,
'timestamp': str(jsapi_timestamp),
'nonceStr': jsapi_noncestr,
'signature': jsapi_signature,
}
# 以上這一段是生成jssdk用的簽名等參數(shù),這些參數(shù)用于基礎(chǔ)jssdk的各種功能調(diào)用
card_id = 'p1NnvjhhcMvSHx××××××××××××××'
card_ticket = get_ticket(access_token['access_token'], 'wx_card') # 這個單獨的方法是獲取卡券專用ticket方法,這個跟上面那個ticket是有區(qū)別的,官方文檔也有詳細說明原因,此ticket的使用也有次數(shù)限制,所以也需要做緩存
cardapi_ticket = card_ticket['ticket']
card_timestamp = str(int(time.time()))
card_noncestr = createNonceStr()
fuck_dic_list = sorted([cardapi_ticket, card_timestamp, card_id, card_noncestr])
card_string = '%s%s%s%s' % (fuck_dic_list[0], fuck_dic_list[1], fuck_dic_list[2], fuck_dic_list[3])
sha_card = hashlib.sha1(card_string.encode('utf8'))
card_signature = sha_card.hexdigest()
card_data = {
'timestamp': card_timestamp,
'nonce_str': card_noncestr,
'signature': card_signature
}
# 以上這一段是生成卡券用的簽名等參數(shù),這些參數(shù)用于卡券的功能調(diào)用
# 獲取用戶信息接口,注意,此接口使用的是微信登錄所拿到的token,微信還有個獲取用戶信息的接口,可以獲取微信是否關(guān)注公眾號,那個接口使用的是基礎(chǔ)access_token,有次數(shù)限制,具體使用哪個要看個人業(yè)務需求
# 最開始的時候客戶是要根據(jù)用戶是否關(guān)注公眾號來判斷用戶是否可以參與活動,后面跟我說不要這個需求了,所以下面的變量寫了follow就沒有改
wx_userinfo_url = 'https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s' % (wx_login_token['access_token'], session['openid'])
follow_response = json.loads(requests.get(url=wx_userinfo_url, timeout=15).content.decode('utf8'))
wx_openid = session['openid']
wx_nickname = follow_response['nickname']
wx_headimgurl = follow_response['headimgurl']
wx_sex = follow_response['sex']
wx_hostip = request.headers['Remote-Host'] # 獲取一個請求的IP,不一定準確,需要配合下面的參數(shù)使用
wx_xip = request.headers['X-Forwarded-For'] # flask 獲取用戶的真實IP,有時候這個地方有兩個值,一個是wifi的ip,一個是4G的ip,但是都有可能被用戶偽造
mysql_db = Database()
query_sql = 'SELECT addcard FROM wx_users WHERE `openid`=%s'
query_result = mysql_db.query(query_sql, [session['openid']])
if len(query_result) > 0:
if query_result[0][0] == 0:
page_status = '1'
else:
page_status = '2'
else:
insert_sql = 'INSERT INTO wx_users (`openid`,`nickname`,`headimgurl`,`sex`,`hostip`,`x-for-ip`) VALUES (%s,%s,%s,%s,%s,%s)'
mysql_db.insert(insert_sql, [wx_openid, wx_nickname, wx_headimgurl, wx_sex, wx_hostip, wx_xip])
page_status = '1'
return render_template('index.html', status=page_status, jssdk=jssdk_data, cardId=card_id, cardExt=card_data)
except Exception as e:
return redirect('http://coupon.abc.com')
代碼里面涉及到的兩個單獨方法如下,第一個獲取基礎(chǔ)access_token
def get_access_token(appid, app_secret):
access_token_file = sys.path[0] + '/access_token.json' # 將獲取到的token緩存到本地文件,上面說過這個token是有使用次數(shù)限制的
wx_access_token_url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s' % (appid, app_secret)
try:
if os.path.exists(access_token_file): # 緩存文件存在的時候就直接讀緩存
access_token = open(access_token_file).readline()
token_string = access_token.split('|')[0]
last_time = int(access_token.split('|')[1])
current_time = int(time.time())
if current_time > last_time: # 判斷是否達到了過期時間,原本過期時間為7200秒,我寫了6000,提前一點重新獲取
token_string = requests.get(url=wx_access_token_url, timeout=15).content.decode('utf8')
signa_expire_time = str(int(time.time() + 6000))
with open(access_token_file, 'wb') as f:
f.write(str.encode(token_string + '|' + signa_expire_time))
f.flush()
f.close()
else: # 沒有緩存文件的時候就去微信服務器獲取
token_string = requests.get(url=wx_access_token_url, timeout=15).content.decode('utf8')
signa_expire_time = str(int(time.time() + 6000))
with open(access_token_file, 'wb') as f:
f.write(str.encode(token_string + '|' + signa_expire_time))
f.flush()
f.close()
return json.loads(token_string)
except Exception as e:
return jsonify({'code': -1, 'msg': str(e)}) # jsonify 方法可以直接轉(zhuǎn)換成json很方便,也可以用json庫進行轉(zhuǎn)換,常用的有json.loads(str)和json.dump(json)方法
第二個單獨方法是獲取ticket,請求的方式一樣,唯獨參數(shù)值不一樣
def get_ticket(access_token, ticket_type):
if ticket_type == 'wx_card':
ticket_file = sys.path[0] + '/jsapi_signa_ticket.json'
else: # jsapi
ticket_file = sys.path[0] + '/jsapi_ticket.json'
wx_ticket_url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=%s' % (access_token, ticket_type)
try:
if os.path.exists(ticket_file):
ticket = open(ticket_file).readline()
ticket_string = ticket.split('|')[0]
last_time = int(ticket.split('|')[1])
current_time = int(time.time())
if current_time > last_time:
ticket_string = requests.get(url=wx_ticket_url, timeout=15).content.decode('utf8')
signa_expire_time = str(int(time.time() + 6000))
with open(ticket_file, 'wb') as f:
f.write(str.encode(ticket_string + '|' + signa_expire_time))
f.flush()
f.close()
else:
ticket_string = requests.get(url=wx_ticket_url, timeout=15).content.decode('utf8')
signa_expire_time = str(int(time.time() + 6000))
with open(ticket_file, 'wb') as f:
f.write(str.encode(ticket_string + '|' + signa_expire_time))
f.flush()
f.close()
return json.loads(ticket_string)
except Exception as e:
return jsonify({'code': -1, 'msg': str(e)})
這兩個方法獲取回來的數(shù)據(jù)都需要進行緩存。
前端頁面在加載的時候需要初始化微信分享和卡券的相關(guān)簽名參數(shù)等,所以需要把前面生成的簽名數(shù)據(jù)傳過去,通過render就可以輕松傳到前端,render這個東西不要覺得有多神秘,搞過nodejs和thinkphp的估計也都見過,如下代碼
return render_template('index.html', status=page_status, jssdk=jssdk_data, cardId=card_id, cardExt=card_data)
前端拿數(shù)據(jù)也是非常簡單,如下
<> class='mainBody'>
{% if status == '1' %}
<> class='page_index'>
<> class='page_info'>
<> class='formbox'>
<> href='javascript:;' @click='postData'><> src='/static/img/button-save.png'>
{% elif status == '2' %}
<> class='page_finish'><> src='/static/img/page4.png'>
{% endif %}
JInja2使用了{% %}識別邏輯判斷等,看起來是不是很像jsp或者thinkphp?其實寫起來都差不多,直接輸出數(shù)據(jù)如下
wx.config({
debug: false,
appId: '[[ jssdk.appId ]]',
timestamp: '[[ jssdk.timestamp ]]',
nonceStr: '[[ jssdk.nonceStr ]]',
signature: '[[ jssdk.signature ]]',
jsApiList: ['onMenuShareTimeline','onMenuShareAppMessage','addCard']
});
wx.ready(function () {
wxSetShare();
});
function wxSetShare(){
//朋友圈分享
wx.onMenuShareTimeline({
title: wx_shareTitle,
link: wx_shareurl,
imgUrl: wx_imgUrl,
success: function () {},
cancel: function () {console.log('share cancel')}
});
//發(fā)送給朋友
wx.onMenuShareAppMessage({
title: wx_shareTitle,
desc: wx_shareContent,
link: wx_shareurl,
imgUrl: wx_imgUrl,
type: 'link',
dataUrl: '',
success: function () {},
cancel: function () {console.log('share cancel')}
});
}
var cardExtString = '{'timestamp':'[[ cardExt.timestamp ]]','nonce_str':'[[ cardExt.nonce_str ]]','signature':'[[ cardExt.signature ]]'}';
function addCoupon() {
wx.addCard({
cardList: [{
cardId: '[[ cardId ]]',
cardExt: cardExtString
}],
success: function (res) {
axios.post('/addCard').then(function (response) {
if(response.data.code === 0){
window.location.reload(true)
}else{
weui.alert(response.data.msg);
}
}).catch(function (error) {
weui.alert(error);
});
}
});
}
在[[ ]]里面直接就輸出了數(shù)據(jù),這里有個大坑,看下面這段代碼
var cardExtString = '{'timestamp':'[[ cardExt.timestamp ]]','nonce_str':'[[ cardExt.nonce_str ]]','signature':'[[ cardExt.signature ]]'}';
function addCoupon() {
wx.addCard({
cardList: [{
cardId: '[[ cardId ]]',
cardExt: cardExtString
}],
success: function (res) {
axios.post('/addCard').then(function (response) {
if(response.data.code === 0){
window.location.reload(true)
}else{
weui.alert(response.data.msg);
}
}).catch(function (error) {
weui.alert(error);
});
}
});
}
里面有個
cardExt: cardExtString
微信官方的寫法是
wx.addCard({
cardList: [{
cardId: '[[ cardId ]]',
cardExt: {
timestamp:'×××××',
nonce_str:'×××××',
signature:'×××××'
}
}],
success: function (res) {
axios.post('/addCard').then(function (response) {
if(response.data.code === 0){
window.location.reload(true)
}else{
weui.alert(response.data.msg);
}
}).catch(function (error) {
weui.alert(error);
});
}
});
如果替換掉Jinja2的識別符應該就是
wx.addCard({
cardList: [{
cardId: '[[ cardId ]]',
cardExt: {
timestamp:'[[ cardExt.timestamp ]]',
nonce_str:'[[ cardExt.nonce_str ]]',
signature:'[[ cardExt.signature ]]'
}
}],
success: function (res) {
axios.post('/addCard').then(function (response) {
if(response.data.code === 0){
window.location.reload(true)
}else{
weui.alert(response.data.msg);
}
}).catch(function (error) {
weui.alert(error);
});
}
});
在自己安卓手機上測試通過,發(fā)給客戶,客戶一直說他的手機不行,提示簽名錯誤,我就郁悶了,我這里測試很多遍都可以啊,要求客戶發(fā)截圖,截圖發(fā)來看看確實簽名錯誤,神奇了。仔細一看,客戶用的是IPhone,難道是安卓可以ios不行,經(jīng)過長達4個小時的不斷嘗試,終于給解決問題了,開啟微信jssdk的調(diào)試模式后在安卓和ios上都會彈窗提示普通jssdk的簽名正確,安卓上面領(lǐng)取優(yōu)惠券成功后會顯示優(yōu)惠券相關(guān)信息,仔細一看發(fā)現(xiàn)雙引號被轉(zhuǎn)義各種斜杠,但是是成功的,然而iphone不行,此時我才想起這個坑我之前就遇到過,因為ios解析json的時候認為{}里面的是一個json對象,會把一些字符轉(zhuǎn)義,導致簽名字符串不一致,所以簽名錯誤,而安卓則認為{}是一個字符串,正常的處理,就不會報錯。
于是就有了上面那個
var cardExtString = '{'timestamp':'[[ cardExt.timestamp ]]','nonce_str':'[[ cardExt.nonce_str ]]','signature':'[[ cardExt.signature ]]'}';
直接定義成一個字符串,這樣安卓和ios識別就會一致的認為這個變量為字符串類型,不會做特殊處理,至此BUG解決。
這里順便說下axios,使用方法如下代碼
axios.post('/addCard').then(function (response) {
if(response.data.code === 0){
window.location.reload(true)
}else{
weui.alert(response.data.msg);
}
}).catch(function (error) {
weui.alert(error);
});
簡單易用,通俗易懂。
項目上線部署到阿里云上,使用的是系統(tǒng)是CentOS 7,Python3 + virtualenv + supervisor + gunicorn + nginx 具體部署方法我之前也專門寫文章講過,關(guān)于部署這塊如果有同學不懂或者不清楚部署方法的話可以郵箱聯(lián)系我。
遇到問題多谷歌,經(jīng)驗真的很重要?。。?!
題圖:pexels,CC0 授權(quán)。