参考链接:/sufaith/p/python-wechat-dev.html
关于Centos的Redis 安装:/zuidongfeng/p/8032505.html
上一章我们结束了测试号的申请,也在公众平台页面控制台熟悉了一些重要的数据,接下来就是项目的编写。我个人很随意,用的编写环境是Win10,测试环境是自己租的阿里云服务器,Centos7,基本配置都已搞定,包括Python3 以及所需包,Redis数据库等。下面就简单说一下项目是实现哪些功能:
1、 接口验证,这是自己编写后台必要的环节;
2、回复功能,包括对客户发送的语音,文字的回复,关注事件回复;
3、自定义菜单功能,并对点击菜单的事件进行响应;
4、网页授权,这个是为了跳转自定义页面实现业务逻辑功能,因为我们必须进行验证拿到用户的唯一标识openid;
5、调用微信公众平台的接口实现开放功能,比如扫码,分享等等,当然本次项目我只实现airkiss智能配网,因为我是搞硬件的。
一、下面是整个项目的文件以及简易流程图:
二、下图是工程目录
三、文件简单描述
1、wxconfig.py 是我们存放基本配置信息的文件,包括AppId,AppSecret,一些自定义菜单所属url等等。
2、wxmenu.py 是我们自定义菜单的文件,它可以独立运行,仅需要运行一次即可,在菜单变动的时候我们可以选择手动执行,当然也可以放在一些文件中,让它定时执行或者触发执行,在本次工程中我让它在项目运行的时候执行一次。
3、wxcache.py 是基于redis的access_token和jsapi_ticket缓存控制文件,因为微信接口需要的access_token和jsapi_ticket会过期,需要定时刷新,并且获取它们的接口有调用次数限制,因此必须严格控制它们的存在,保证所有模块调用它们不会产生冲突。
4、wxtoken.py 是控制access_token和jsapi_ticket更新文件,就是中枢服务器。
5、wxreply.py 是处理客户行为数据的文件,包括对菜单的响应和留言的回复。
6、wxauthorize.py 是对用户请求自定义页面的拦截,主要是为了拿到用户的openid,所设的关卡,拿到之后进行放行。
7、wxsign.py 是对js-sdk接口调用前的签名,没有这个步骤是没办法进行调用微信开放功能的。
8、wxlogger.py 是日志模块 负责监控程序的运行状态
9、wxhandler.py 是路由指向的类所在模块,包括三个路由 配置和回复路由、页面请求路由和签名请求路由。
10、main.py 是程序的入口文件,对token的定时任务初始化 自定义菜单的创建 端口绑定以及路由声明等等。
11、html文件时页面文件
12、server.* 是https ssl证书文件,可以不使用签名证书 用http服务器也是可以的
四、上完整代码:
1、main.py
import syssys.path.append("./handler")# 将 handler 目录下的文件放到和main.py同级的目录import tornado.ioloopimport tornado.webfrom tornado import httpserverimport timefrom wxtoken import WxShedule # 维护 token 更新模块from wxmenu import WxMenuServer# 自定义菜单模块from wxhandler import pageHandler,wxStartHandler,getSignHandler # 路由模块def main():application = tornado.web.Application([(r'/start', wxStartHandler), # 验证配置接口和消息回复路由(r'/sign',getSignHandler),# 获取js-sdk签名路由(r'/page(.*)', pageHandler), # 请求微信自定义web页面],autoreload=False,debug=False)#application.listen(9000) #http服务器开启# 下面代码是配置https服务器,当然选用http服务器也是可以的server = httpserver.HTTPServer(application, ssl_options={"certfile":"server.crt","keyfile": "server.key",})# 执行定时任务,定时刷新获取 access_token和jsapi_ticketwx_shedule = WxShedule()wx_shedule.excute()# 给点时间去获取tokentime.sleep(3)# 初始化一次菜单wx_menu_server = WxMenuServer()wx_menu_server.create_menu()server.listen(9000)# 单进程开启tornado.ioloop.IOLoop.instance().start()if __name__ == "__main__":main()
2、wxtoken.py
import tornado.ioloopimport requestsimport jsonfrom wxconfig import WxConfigfrom wxcache import TokenCachefrom wxlogger import loggerclass WxShedule(object):"""负责access_token和jsapi_ticket的更新"""_token_cache = TokenCache() # 微信token缓存实例_expire_time_access_token = 7000 * 1000 # token过期时间def excute(self):"""执行定时器任务"""# IOLoop.instance().call_later(delay, callback, *args, **kwargs)# 延时delay秒之后,将callback加入到tornado的加入到的处理队列里面,异步调用只调用一次tornado.ioloop.IOLoop.instance().call_later(0, self.get_access_token)# tornado.ioloop.PeriodicCallback(callback, callback_time, io_loop=None)# callback设定定时调用的方法 callback_time设定每次调用之间的间隔,单位毫秒tornado.ioloop.PeriodicCallback(self.get_access_token, self._expire_time_access_token).start()def get_access_token(self):"""获取微信全局唯一票据access_token"""try:url = WxConfig.get_access_token_urlr = requests.get(url)if r.status_code == 200:d = json.loads(r.text)if 'access_token' in d.keys():access_token = d['access_token']# 添加至redis中self._token_cache.set_access_cache('access_token', access_token)# 获取JS_SDK权限签名的jsapi_ticketself.get_jsapi_ticket()else:errcode = d['errcode']# 出现错误10s之后调用一次,获取access_tokentornado.ioloop.IOLoop.instance().call_later(10, self.get_access_token)else:# 网络错误10s之后调用一次,获取access_tokentornado.ioloop.IOLoop.instance().call_later(10, self.get_access_token)except Exception as e:logger.error('wxtoken get_access_token'+str(e))def get_jsapi_ticket(self):"""获取JS_SDK权限签名的jsapi_ticket"""try:# 从redis中获取access_tokenaccess_token = self._token_cache.get_cache('access_token')if access_token:url = 'https://api./cgi-bin/ticket/getticket?access_token=%s&type=jsapi' % access_tokenr = requests.get(url)if r.status_code == 200:d = json.loads(r.text)errcode = d['errcode']if errcode == 0:jsapi_ticket = d['ticket']# 添加至redis中self._token_cache.set_js_cache('jsapi_ticket', jsapi_ticket)else:tornado.ioloop.IOLoop.instance().call_later(10, self.get_jsapi_ticket)else:# 网络错误 重新获取tornado.ioloop.IOLoop.instance().call_later(10, self.get_jsapi_ticket)else:# access_token已经过期 重新获取tornado.ioloop.IOLoop.instance().call_later(10, self.get_access_token)except Exception as e:logger.error('wxtoken get_jsapi_ticket'+str(e))
3、wxsign.py
import timeimport randomimport stringimport hashlibfrom wxcache import TokenCachefrom wxlogger import loggerdef get_js_sdk_sign(url):"""获取调用js-sdk必要的数据 nonceStr timestamp signature"""try:_token_cache = TokenCache() # 微信token缓存实例jsapi_ticket = _token_cache.get_cache('jsapi_ticket') # 从redis中提取jsapi_ticketif jsapi_ticket:nonceStr=''.join(random.choice(string.ascii_letters + string.digits) for _ in range(15))timestamp=int(time.time())url=urlret = {'nonceStr': nonceStr,'jsapi_ticket': jsapi_ticket,'timestamp': timestamp,'url': url}_string = '&'.join(['%s=%s' % (key.lower(), ret[key]) for key in sorted(ret)])ret['signature'] = hashlib.sha1(_string.encode('utf-8')).hexdigest()return retexcept Exception as e:logger.error('wxsign get_js_sdk_sign'+str(e))
4、wxreply.py
from wxlogger import logger"""这是一个处理客户发送信息的文件"""def reply_text(FromUserName, ToUserName, CreateTime, Content):"""回复文本消息模板"""textTpl = """<xml> <ToUserName><![CDATA[%s]]></ToUserName> <FromUserName><![CDATA[%s]]></FromUserName> <CreateTime>%s</CreateTime> <MsgType><![CDATA[%s]]></MsgType> <Content><![CDATA[%s]]></Content></xml>"""out = textTpl % (FromUserName, ToUserName, CreateTime, 'text', Content)return outdef receive_msg(msg):# 这是一个将疑问改成成熟句子的函数,例如:你好吗 公众号回复:你好if msg[-1] == u'吗':return msg[:len(msg)-1]elif len(msg)>2 and msg[-2] == u'吗' :return msg[:len(msg)-2]else:return "你说的话我好像不明白?"def receive_event(event,key):# 如果是关注公众号事件if event == 'subscribe':return "感谢关注!"# 如果是点击菜单拉取消息事件elif event == 'CLICK':# 接下来就是根据你点击不同的菜单拉去不同的消息啦# 我为了省事,不进行判断啦,如果需要判断请根据 key进行判断return "你点击了菜单"+key# 如果是点击菜单跳转Url事件,不做任何处理因为微信客户端会自行处理elif event == 'VIEW':return None
5、wxmenu.py
from wxconfig import WxConfigfrom wxcache import TokenCachefrom wxauthorize import WxAuthorServerfrom wxlogger import loggerimport requestsimport jsonclass WxMenuServer(object):"""这是一个创建自定义菜单的文件,当你需要更新菜单的时候执行这个文件"""token_cache = TokenCache() # 微信token缓存对象# 微信网页授权server,目的是为了重定向,类似关卡wx_author_server = WxAuthorServer() def create_menu(self):"""自定义菜单创建接口,这个非常灵活,我们可以设置权限,可以传入参数等等,我们这边就直接写死了"""try:access_token=self.token_cache.get_cache('access_token')if not access_token:logger.error('创建菜单 获取 token失败')return Noneurl = WxConfig.menu_create_url + access_tokendata = self.create_menu_data()r = requests.post(url, data.encode('utf-8'))if not r.status_code == 200:logger.error('创建菜单 网络错误')return Nonejson_res = json.loads(r.text)if 'errcode' in json_res.keys():errcode = json_res['errcode']return errcodeexcept Exception as e:logger.error('wxmenu create_menu'+str(e))def get_menu(self):"""自定义菜单查询接口"""try:access_token=self.token_cache.get_cache('access_token')if not access_token:return Noneurl = WxConfig.menu_get_url + access_tokenr = requests.get(url)if not r.status_code == 200:return Nonejson_res = json.loads(r.text)if 'errcode' in json_res.keys():errcode = json_res['errcode']logger.error('自定义菜单查询失败!')return errcodeexcept Exception as e:logger.error('wxmenu get_menu'+str(e))def delete_menu(self):"""自定义菜单删除接口"""try:access_token=self.token_cache.get_cache('access_token')if not access_token:return Noneurl = WxConfig.menu_delete_url + access_tokenr = requests.get(url)if not r.status_code == 200:return Nonejson_res = json.loads(r.text)if 'errcode' in json_res.keys():errcode = json_res['errcode']logger.error('自定义菜单删除失败')return errcodeexcept Exception as e:logger.error('wxmenu delete_menu'+str(e))def create_menu_data(self):"""创建菜单数据"""menu_data = {'button': []} # 大菜单menu_Index0 = {'type': 'click','name': '一级菜单',"key": "menu1"}menu_data['button'].append(menu_Index0)menu_Index1 = {"name": "二级菜单","sub_button":[{"type": "view","name": "test","url": self.wx_author_server.get_code_url('test')},{"type": "click","name": "click","key": "click"}]}menu_data['button'].append(menu_Index1)# 菜单三 我们让它请求页面,验证js-sdk权限menu_Index2 = {'type': 'view','name': 'airkiss',"url": self.wx_author_server.get_code_url('airkiss')}menu_data['button'].append(menu_Index2)menu_data = json.dumps(menu_data, ensure_ascii=False)return menu_dataif __name__ == '__main__':wx_menu_server = WxMenuServer()wx_menu_server.create_menu()
6、wxlogger.py
import loggingfrom logging import Loggerlog_path = './out.log'error_path = './error.log''''日志管理类 负责开发过程中的数据的追踪'''def init_logger(logger_name):if logger_name not in Logger.manager.loggerDict:# 创建一个loggerlogger = logging.getLogger(logger_name)logger.setLevel(logging.INFO) # 设置最低级别# 定义handler的输出格式df = '%Y-%m-%d %H:%M:%S'format_str = '[%(asctime)s]: %(name)s %(levelname)s %(lineno)s %(message)s'formatter = logging.Formatter(format_str, df)# 创建一个handler,用于写入日志文件fh = logging.FileHandler(log_path,encoding = 'utf-8') # 指定utf-8格式编码,避免输出的日志文本乱码fh.setLevel(logging.INFO)fh.setFormatter(formatter)# 创建一个handler,用于将日志输出到控制台ch = logging.StreamHandler()ch.setLevel(logging.INFO)ch.setFormatter(formatter)# 给logger添加handlerlogger.addHandler(fh)logger.addHandler(ch)# 创建一个handler,用于写入错误日志文件efh = logging.FileHandler(error_path,encoding = 'utf-8') # 指定utf-8格式编码,避免输出的日志文本乱码efh.setLevel(logging.ERROR)efh.setFormatter(formatter)# 创建一个handler,用于将错误日志输出到控制台ech = logging.StreamHandler()ech.setLevel(logging.ERROR)ech.setFormatter(formatter)# 给logger添加handlerlogger.addHandler(efh)logger.addHandler(ech)logger = logging.getLogger(logger_name)return loggerlogger = init_logger('wx_run_log')
7、wxhandler.py
import tornado.web# 这是python 标准库 用来处理 xml文件的import xml.etree.ElementTree as ETimport hashlibimport time# wxreply文件是我写关于处理回复的函数,我们导出来必要的函数from wxreply import receive_msg,receive_event,reply_textfrom wxconfig import WxConfigfrom wxcache import TokenCachefrom wxauthorize import WxAuthorServerfrom wxlogger import loggerfrom wxsign import get_js_sdk_signclass wxStartHandler(tornado.web.RequestHandler):"""微信服务器签名验证和消息回复check_signature: 校验signature是否正确"""def check_signature(self, signature, timestamp, nonce):"""校验token是否正确"""# 这个是token 和我们在微信公众平台配置接口填写一致token = 'iotbird'L = [timestamp, nonce, token]L.sort()s = L[0] + L[1] + L[2]sha1 = hashlib.sha1(s.encode('utf-8')).hexdigest()# 对于验证结果返回true or falsereturn sha1 == signature#def get(self):"""这是get请求,处理配置接口验证的"""try:# 获取参数signature = self.get_argument('signature')timestamp = self.get_argument('timestamp')nonce = self.get_argument('nonce')echostr = self.get_argument('echostr')# 调用验证函数result = self.check_signature(signature, timestamp, nonce)if result:self.write(echostr)else:logger.error('微信sign校验,---校验失败')except Exception as e:logger.error('wxhandler get'+str(e))def post(self):""" 这是post请求 接收消息,获取参数 """body = self.request.body# 返回的bodys是xml格式,通过ET转换为键值对格式,方便提取信息data = ET.fromstring(body)ToUserName = data.find('ToUserName').textFromUserName = data.find('FromUserName').textMsgType = data.find('MsgType').text# 如果发送的是消息请求,判断是文字还是语音,因为我们取发送的内容位置不一样if MsgType == 'text' or MsgType == 'voice':try:MsgId = data.find("MsgId").textif MsgType == 'text':Content = data.find('Content').text # 文本消息内容elif MsgType == 'voice':Content = data.find('Recognition').text # 语音识别结果,UTF8编码# 调用回复函数判断接受的信息,然后返回对应的内容reply_content=receive_msg(Content)CreateTime = int(time.time())# 调用回复信息封装函数,要指定用户,时间和回复内容out = reply_text(FromUserName, ToUserName, CreateTime, reply_content)self.write(out)except Exception as e:logger.error('wxStartHandler post'+str(e))# 如果接收的是事件,我们也要处理elif MsgType == 'event':try:Event = data.find('Event').textEvent_key = data.find('EventKey').textCreateTime = int(time.time())# 判断事件,并返回内容reply_content = receive_event(Event,Event_key)if reply_content:out = reply_text(FromUserName, ToUserName, CreateTime, reply_content)self.write(out)except Exception as e:logger.error('wxStartHandler post'+str(e))class pageHandler(tornado.web.RequestHandler):'''页面跳转控制路由'''wx_config = WxConfig()'''微信网页授权server'''wx_author_server = WxAuthorServer()def get(self, flag):try:if flag == '/wxauthor':'''微信网页授权'''code = self.get_argument('code')state = self.get_argument('state')# 获取重定向的urlredirect_url = self.wx_config.wx_menu_state_map[state]if code:# 通过code换取网页授权access_tokendata = self.wx_author_server.get_auth_access_token(code)openid = data['openid']if openid:# 跳到自己的业务界面self.redirect(redirect_url)else:# 获取不到openidlogger.error('获取不到openid')# 如果请求的是airkiss页面elif flag == '/airkiss':self.render('../page/airkiss.html')elif flag == '/test':self.render('../page/test.html')except Exception as e:logger.error('pageHandler post'+str(e))class getSignHandler(tornado.web.RequestHandler):"""返回js-sdk签名数据"""wx_config = WxConfig()wx_token_cache = TokenCache()def get(self):# 调用微信js-sdk接口功能 需要签名sign=get_js_sdk_sign('%s/wx/page/airkiss'% self.wx_config.AppHost)sign['appId']=self.wx_config.AppIDself.write(sign)
8、wxconfig.py
class WxConfig(object):"""微信开发--基础配置"""# 测试账号AppID = 'wxxxxxxxxxx'AppSecret = '6xxxxxxxxxxxxxxxxxxx'"""微信网页开发域名"""AppHost = 'https://www.f203.online''''获取access_token接口'''get_access_token_url = 'https://api./cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s' % (AppID, AppSecret)'''自定义菜单创建接口'''menu_create_url = 'https://api./cgi-bin/menu/create?access_token=''''自定义菜单查询接口'''menu_get_url = 'https://api./cgi-bin/menu/get?access_token=''''自定义菜单删除接口'''menu_delete_url = 'https://api./cgi-bin/menu/delete?access_token=''''微信公众号菜单映射页面,参数是page/后面的'''wx_menu_state_map = {'airkiss': '%s/wx/page/airkiss'% AppHost,'test': '%s/wx/page/test'% AppHost}
9、wxcache.py
# 缓存 access_token 和 jsapi_ticket,并且即使更新,防止过期# access_token是公众号的全局唯一票据,公众号调用各接口时都需使用access_token。# 开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时# jsapi_ticket是公众号用于调用微信JS接口的临时票据。正常情况下,jsapi_ticket的有效期为7200秒,通过access_token来获取# 我们用redis去存数据,并设置过期时间import redisfrom wxlogger import loggerclass BaseCache(object):"""缓存类父类"""_host = '127.0.0.1'_port = 6379_database = 0_password = ''@propertydef redis_ctl(self):"""redis控制句柄,就是连接对象"""redis_ctl = redis.Redis(host=self._host, port=self._port, db=self._database, password=self._password)return redis_ctlclass TokenCache(BaseCache):"""微信token缓存"""_expire_access_token = 7200 # 微信access_token过期时间, 2小时_expire_js_token = 7200 # 微信jsapi_ticket, 过期时间, 7200秒def set_access_cache(self, key, value):"""添加微信access_token验证相关redis"""self.redis_ctl.set(key, value)# 设置过期时间self.redis_ctl.expire(key, self._expire_access_token)logger.info('更新了 access_token')def set_js_cache(self, key, value):"""添加网页授权相关redis"""self.redis_ctl.set(key, value)# 设置过期时间self.redis_ctl.expire(key, self._expire_js_token)logger.info('更新了 js_token')def get_cache(self, key):"""获取redis"""try:v = (self.redis_ctl.get(key)).decode('utf-8')return vexcept Exception as e:logger.error('wxcache'+str(e))return None
10、wxauthorize.py
from wxconfig import WxConfigfrom urllib import parsefrom wxlogger import loggerimport requestsimport json""" 用于拦截 对页面的请求 提取用户信息"""class WxAuthorServer(object):"""微信网页授权server""""""对与请求连接进行重定向,获取用户信息进行网页授权"""redirect_uri = '%s/wx/page/wxauthor' % WxConfig.AppHost"""应用授权作用域snsapi_base (不弹出授权页面,直接跳转,只能获取用户openid)snsapi_userinfo (弹出授权页面,可通过openid拿到昵称、性别、所在地。并且,即使在未关注的情况下,只要用户授权,也能获取其信息)"""SCOPE = 'snsapi_base'"""通过code换取网页授权access_token"""get_access_token_url = 'https://api./sns/oauth2/access_token?'"""拉取用户信息"""get_userinfo_url = 'https://api./sns/userinfo?'def get_code_url(self, state):"""获取code的url"""_dict = {'redirect_uri': self.redirect_uri}redirect_uri = parse.urlencode(_dict)author_get_code_url = 'https://open./connect/oauth2/authorize?appid=%s&%s&response_type=code&scope=%s&state=%s#wechat_redirect' % (WxConfig.AppID, redirect_uri, self.SCOPE, state)return author_get_code_urldef get_auth_access_token(self, code):"""通过code换取网页授权access_token"""try:url = self.get_access_token_url + 'appid=%s&secret=%s&code=%s&grant_type=authorization_code' % (WxConfig.AppID, WxConfig.AppSecret, code)r = requests.get(url)if r.status_code == 200:json_res = json.loads(r.text)if 'access_token' in json_res.keys():return json_reselif 'errcode' in json_res.keys():errcode = json_res['errcode']logger.error('通过code换取网页授权access_token:'+errcode)except Exception as e:logger.error('get_auth_access_token:'+str(e))
11、test.html
<!DOCTYPE HTML><html><!-- 这是一个标准的app页面 --><head><meta charset="utf-8"><meta name="viewport" content="maximum-scale=1.0, minimum-scale=1.0, user-scalable=0, initial-scale=1.0, width=device-width" /><meta name="format-detection" content="telephone=no, email=no, date=no, address=no"><title>自定义页面-test</title><style></style></head><body><div>这是一个测试页</div></body><script type="text/javascript"></script></html>
12、airkiss.html
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="maximum-scale=1.0, minimum-scale=1.0, user-scalable=0, initial-scale=1.0, width=device-width" /><meta name="format-detection" content="telephone=no, email=no, date=no, address=no"><title>调用微信接口页面-airkiss</title><style></style></head><body><div>即将自动跳转。。。</div></body><script src="http://res2./open/js/jweixin-1.6.0.js"></script><script type="text/javascript">var xhr = new XMLHttpRequest();xhr.open('GET', 'https://www.f203.online/wx/sign');xhr.send(null);xhr.onload = function(e) {if (xhr.status === 200) {sign=JSON.parse(xhr.responseText)wx.config({beta: true, //开启内测接口调用,注入wx.invoke方法debug: false, //关闭调试模式appId: sign['appId'], //AppIDtimestamp: sign['timestamp'], //时间戳nonceStr: sign['nonceStr'], //随机串signature: sign['signature'], //js-sdk签名jsApiList: [// 所有要调用的 API 都要加到这个列表中'configWXDeviceWiFi']});wx.ready(function() {// 在这里调用 APIwx.invoke('configWXDeviceWiFi');});wx.error(function(res) {alert("配置出错");});} else {alert('请求签名失败!');}}xhr.onerror = function(e) {alert('请求失败'+e)}</script></html>
接下来就是分析了,奉上测试号供大家观看3月15号后会关闭
现在公众号可以用了我前几天更新了域名导致以前的域名没法使用有些网页打不开
在实际运行中我将网页授权去掉了 直接进行页面跳转