Flask 微信卡券小项目从开发到上线

前言

博主上周接了一个小单子,是关于微信卡券,博主原来主要精力和工作内容属于前端开发,后来学习了Python后发现自己居然也会写后端了,于是一发不可收拾,自学Django和Flask之后发现还是写后端比较有意思,业余时间也接一些小的外包单子挣点零花。外包项目多数是跟微信相关,但是关于微信卡券,博主还真是第一次开发,遇到的坑不少,做此记录,也给初学Python web的萌新一些入门知识。

众所周知,微信官方的开发者文档写的非常的烂,至于有多烂,同学们可以自行谷歌微信文档的坑,如果说你打算完全参考官方的文档进行开发,可能你永远都无法开发出来你想要的项目,so?老老实实参考第三方的民间文档吧,以免浪费宝贵的开发时间。

说明

这个项目实际开发时间为1.5天,其中被微信文档坑掉的时间大概占到了三分之二,所以说经验很重要。博主之前做过比较多基于微信的项目,在微信授权和jssdk应用上面还是比较熟悉的,这个项目的功能非常简单:

微信授权获取用户信息保存到数据库,保存成功之后调用微信的jssdk唤起微信的卡券领取页面,用户领取成功之后更新数据库记录标示已领取。

早前博主使用的是PHP开发过微信授权,用了第三方写的一个库,生成各种签名都是简单调用封装好的方法,简单易用,但是这次使用Python开发,虽然Gayhub上有很多第三方开源的封装库,但是我这里只需要简单的微信授权和卡券功能,并不需要那么强大,所以还是自己动手写个比较方便,温故而知新,再复习一下旧的知识也是相当不错的。

开发

此项目前端页面使用了如下开源库:

vuejs 这个就不用说了,最近很火

weui.js 微信官方团队开源的微信生态UI组件库

axios.js vue推荐的前端ajax方案,使用起来很棒

后端:Flask(Flask, render_template, request, session, jsonify, redirect)

数据库:Mysql

由于项目很小,这里就没有使用前后完全分离开发,直接使用了Flask的Jinja2模板引擎进行页面的渲染,Jinja2的页面渲染识别符号是{{ }},由于vuejs也是使用的{{  }}作为识别符,为了避免冲突,我这里将Jinja2的识别符修改成了[[  ]],也是挺好看的。使用如下代码进行设置

app.jinja_env.variable_start_string = '[[ '
app.jinja_env.variable_end_string = ' ]]'
项目里面生成微信Token之类的需要跟微信的服务器进行交互,所以用到了 requests 库,生成签名用的是sha1算法,用的库是 hashlib,为了防止csrf攻击,生成随机字符串需要用到 random,对数据进行json转换的操作用 json 库,生成数据报表用xlwt库来操作excel文件,使用time库生成时间戳,微信签名也需要用到它,获取当前项目路径用sys库,os库用来判断文件或者路径是否存在。

Flask里面对Mysql数据库操作用的是Flask-Mysql,当然也可以使用Python-Mysql,这个取决于个人需要,使用方法都差不多,总结一下用到的库,大概就是下面这些了

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 就没法用。

为了方便对数据库的操作,我这里写了通用的类

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。关于项目的完整代码就不贴出来了,也没啥用,下面就说一些关键点吧。

首先用户进入首页,会进行微信的授权,代码如下

@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)

微信的授权是分步骤进行的,上面的代码是获取一个code,有了这个code才能去拿token,上面代码中需要注意的是 redirect_uri 这个变量一定要进行url编码,否则微信会报异常,csrf_state 变量是生成一个随机字符串,用于防止csrf跨站攻击,微信文章说不加也可以,为了安全起见,咱们还是加上去吧,谁叫咱们是搞安全的呢。

这个csrf_state会在获取到code后微信重定向回来时带在url后面,所以下一步是可以拿到这个参数的。

session["csfr_token"] = csrf_state 这个就很简单了,把这个随机字符串写入到session,这样在下一步操作的时候会去检查进行比对。

这里我要说明一下为何需要这个 csrf_state 参数,微信授权在获取code的时候,请求是通过前端的URL进行跳转,url跳转很明显的一个问题就是明文传输,明文传输的时候arp攻击是很容易拿到请求链接的,如果说我们实现已经在服务器上的session里面存储了那个随机字符串,微信回调回来的时候服务器拿到参数进行比对,如果这个参数值跟session里面的一致,则认为是正常的请求,否则这个请求就是伪造的,比如典型的请求重放攻击,在这里就用不了了,就算被arp拿到了链接,发过去服务器也会认为请求非法所以我还是建议加上这个参数。

Flask跳转使用redirect方法。

下面是关键的授权部分,获取授权Token的回调链接在上一步已经给出,微信会自动回调到这个页面,相关注释我直接在代码里面写了

@app.route("/authorization")
def authorization():
    page_status = "1"
    wx_code = request.args.get("code")  # Flask 获取 get请求的值用request.args,获取post请求用request.form,获取前端ajax的json数据用request.data,这里的code是微信回调的时候加上去的
    csrf_state = request.args.get("state") # 这个state是微信回调回来的时候带上去的,是之前我们自己生成的随机字符串
    if session["csfr_token"] != csrf_state: # 随机字符串的校验,校验失败就跳转首页
        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,同时返回了一个授权用的Token,这个授权token是没有次数限制的,区别于基础功能的access_token,这里把openid作为session来全局判断登录状态

            access_token = get_access_token(appid, app_secret)  # 单独写了一个获取基础功能access_tokende 的方法,这个是有次数限制的,每天2000次,用完就gg了,所以需要做特殊的缓存操作,下面会详细说
            jsapi = get_ticket(access_token['access_token'], "jsapi")  # 这个单独的方法是获取通用jssdk需要的ticket方法,ticket的使用也有次数限制,所以也需要做缓存

            jsapi_ticket = jsapi['ticket']
            jsapi_timestamp = int(time.time())
            jsapi_current_url = request.url
            jsapi_noncestr = createNonceStr()

            before_string = "jsapi_ticket=%s&noncestr=%s&timestamp=%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用的签名等参数,这些参数用于基础jssdk的各种功能调用

            card_id = "p1NnvjhhcMvSHx××××××××××××××"
            card_ticket = get_ticket(access_token['access_token'], "wx_card") # 这个单独的方法是获取卡券专用ticket方法,这个跟上面那个ticket是有区别的,官方文档也有详细说明原因,此ticket的使用也有次数限制,所以也需要做缓存
            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
            }
            # 以上这一段是生成卡券用的签名等参数,这些参数用于卡券的功能调用


            # 获取用户信息接口,注意,此接口使用的是微信登录所拿到的token,微信还有个获取用户信息的接口,可以获取微信是否关注公众号,那个接口使用的是基础access_token,有次数限制,具体使用哪个要看个人业务需求
            # 最开始的时候客户是要根据用户是否关注公众号来判断用户是否可以参与活动,后面跟我说不要这个需求了,所以下面的变量写了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,不一定准确,需要配合下面的参数使用
            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")
代码里面涉及到的两个单独方法如下,第一个获取基础access_token
def get_access_token(appid, app_secret):
    access_token_file = sys.path[0] + '/access_token.json' # 将获取到的token缓存到本地文件,上面说过这个token是有使用次数限制的
    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 方法可以直接转换成json很方便,也可以用json库进行转换,常用的有json.loads(str)和json.dump(json)方法
第二个单独方法是获取ticket,请求的方式一样,唯独参数值不一样
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)})
这两个方法获取回来的数据都需要进行缓存。

前端页面在加载的时候需要初始化微信分享和卡券的相关签名参数等,所以需要把前面生成的签名数据传过去,通过render就可以轻松传到前端,render这个东西不要觉得有多神秘,搞过nodejs和thinkphp的估计也都见过,如下代码

return render_template("index.html", status=page_status, jssdk=jssdk_data, cardId=card_id, cardExt=card_data)
前端拿数据也是非常简单,如下
<div class="mainBody">
        {% if status == '1' %}
            <div class="page_index"></div>
            <div class="page_info">
                <div class="formbox">
                    <div><img src="/static/img/text-index.png"></div>
                    <div><img src="/static/img/input-name.png"><input type="text" v-model="username" maxlength="30"></div>
                    <div><img src="/static/img/input-carnum.png"><input type="text" v-model="carnumber" maxlength="8"></div>
                    <div><img src="/static/img/input-carmod.png"><input type="text" v-model="carmodal" maxlength="30"></div>
                    <div><img src="/static/img/input-phone.png"><input type="tel" v-model="userphone" maxlength="11"></div>
                    <a href="javascript:;" @click="postData"><img src="/static/img/button-save.png"></a>
                </div>
            </div>
        {% elif status == '2' %}
            <div class="page_finish"><img src="/static/img/page4.png"></div>
        {% endif %}
JInja2使用了{%  %}识别逻辑判断等,看起来是不是很像jsp或者thinkphp?其实写起来都差不多,直接输出数据如下
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')}
            });
            //发送给朋友
            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);
                    });
                }
            });
        }
在[[  ]]里面直接就输出了数据,这里有个大坑,看下面这段代码
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);
	    });
	}
});
在自己安卓手机上测试通过,发给客户,客户一直说他的手机不行,提示签名错误,我就郁闷了,我这里测试很多遍都可以啊,要求客户发截图,截图发来看看确实签名错误,神奇了。仔细一看,客户用的是IPhone,难道是安卓可以ios不行,经过长达4个小时的不断尝试,终于给解决问题了,开启微信jssdk的调试模式后在安卓和ios上都会弹窗提示普通jssdk的签名正确,安卓上面领取优惠券成功后会显示优惠券相关信息,仔细一看发现双引号被转义各种斜杠,但是是成功的,然而iphone不行,此时我才想起这个坑我之前就遇到过,因为ios解析json的时候认为{}里面的是一个json对象,会把一些字符转义,导致签名字符串不一致,所以签名错误,而安卓则认为{}是一个字符串,正常的处理,就不会报错。

于是就有了上面那个

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);
                    });
简单易用,通俗易懂。

部署

项目上线部署到阿里云上,使用的是系统是CentOS 7,Python3 + virtualenv + supervisor +  gunicorn + nginx 具体部署方法我之前也专门写文章讲过,关于部署这块如果有同学不懂或者不清楚部署方法的话可以邮箱联系我。

后记

遇到问题多谷歌,经验真的很重要啊!!!

关于项目代码或者对微信相关功能开发的问题,可以发到我邮箱。

本文链接:https://www.92ez.com/?action=show&id=23465
!!! 转载请先联系non3gov@gmail.com授权并在显著位置注明作者和原文链接 !!! 小黑屋
提示:技术文章有一定的时效性,请先确认是否适用你当前的系统环境。

上一篇: 一个电信劫持案例的简要分析
下一篇: 总结2017计划2018

访客评论
#1
回复 JUN 2017-12-25, 11:40 AM
不讲完整代码放出来让我学学吗?
回复 KBdancer 2017-12-25, 6:50 PM
@JUN: 涉及到一些项目的信息还是不放了,也没啥关键知识点
#2
回复 finallyeva 2017-12-25, 11:51 AM
请问有gayhub项目地址吗?求一个
回复 KBdancer 2017-12-25, 6:50 PM
@finallyeva: 这个我没有放到Gayhub上
#3
回复 fafa 2018-01-01, 1:47 PM
楼主做过关于python 接口并发处理吗?
回复 KBdancer 2018-01-23, 11:16 AM
@fafa: 你是指的多线程吗?
#4
回复 蓦然轻叹 2018-01-22, 5:17 PM
楼主 我也想做一个 博客网站 模仿一下 你这个 可以吗
回复 KBdancer 2018-01-23, 11:16 AM
@蓦然轻叹: 可以的没问题
发表评论

评论内容 (必填):