我的第一个python web开发框架(40)——后台日志与异常处理

  后台权限和底层框架的改造终于完成了,小白也终于可以放下紧悬着的心,可以轻松一下了。这不他为了感谢老菜,又找老菜聊了起来。

  小白:多谢老大的帮忙,系统终于改造完成了,可以好好放松一下了。

  老菜:呵呵,对于后台管理系统功能,你觉得已经完工了吗?没有什么遗漏的吗?

  小白:啊……权限管理完成后不就完了吗?还有功能要弄的吗?

  老菜:如果光从使用角度来说,也可能说完成了,但还有一些细节还需要处理的,比如说日志和异常。

  小白:前面不是做过日志处理了,将所有的异常都自动写到日志中,方便开发人员分析查看,还能自动发送异常通知邮件,另外对于客户端提交的所有数据,在bottle勾子那里也做了处理,都写入到日志中了,还有什么要处理的?

  老菜:对于日志来说可以分为两块:

  一是管理员的操作日志,因为后台管理操作涉及到数据安全,管理员的所有操作都需要记录下来,以便发生问题时可以找到关系人,同时有些业务系统交给相关人员使用以后,BOSS却不知道他们到底有没有登录使用,每天在系统做什么;

  二是系统的异常和关键数据的记录,这个属于系统底层的日志,将所有异常和与金钱相关的操作信息全部记录下来,有故障时开发人员可以根据日志快速定位,及时修复问题。这方面我们前面已经做一部分了,在前面底层很多地方都做了try…except…处理,这是很必要的,但你有没有发现,我们的代码在本地经常运行的好好的,而将代码更新上服务器后即经常爆500错误却不知道,想要排查异常时也很不方便,但查看uwsgi等多个系统日志才行,有些异常你查来查去都查不出来,非常浪费时间,你清楚这些异常主要是由什么引起的吗?有没有想过用什么方法也可以做到实时通过推送通知了解这些错误呢?当然对于异常的发生是很难避免的,但是我们可以通过一些手段,让这些异常发生后即时通过邮件或微信等方式,将异常详情通知我们,然后快速修复问题。如果你对系统非常熟悉的话,有可能用户还没反应过来,十几秒你就将故障修复了,做到人不知鬼不觉,哈哈。

  小白:是啊,异常问题是我最大痛的事情,很多时候明明本地调试的好好的,一到服务就挂了,找到找去也找不出问题所在,浪费了大量的时间。那么我们要怎么来进行改造呢?

  老菜:接下来你看我讲解就知道了,主要是对已有代码进行修改。

 

  在前面的数据结构设计时,我们有一个管理员操作日志表,接下来的改造主要是对这个表进行相关的操作。

  首先我们需要创建这个日志表的逻辑类,由于我们的ORM是用字典来进行增改操作的,所以需要先组合字段字典,然后再执行对应的方法,为了让操作简化,我们需要在日志表逻辑类中添加一个方法,通过传参的方式来进行日志的添加操作,这样就可以免去我们组合字典的操作了。

 1 #!/usr/bin/env python
 2 # coding=utf-8
 3 
 4 from logic import _logic_base
 5 from common.string_helper import string
 6 from config import db_config
 7 
 8 
 9 class ManagerOperationLogLogic(_logic_base.LogicBase):
10     """管理员操作日志管理表逻辑类"""
11 
12     def __init__(self):
13         # 表名称
14         self.__table_name = 'manager_operation_log'
15         # 初始化
16         _logic_base.LogicBase.__init__(self, db_config.DB, db_config.IS_OUTPUT_SQL, self.__table_name)
17 
18 
19     def add_operation_log(self, manager_id, manager_name, ip, remark):
20         """记录用户登录日志"""
21         # 组合要更新的字段内容
22         fields = {'manager_id':manager_id, 'manager_name':string(manager_name), 'ip':string(ip), 'remark':string(remark)}
23         # 新增记录
24         self.add_model(fields)

  从代码中可以看到,add_operation_log()方法,它其实就是将要更新到数据库的参数传进来,在方法里组合成字典,然后调用add_model()进行更新操作,调用时用下面代码就可以了

_manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '登陆成功')

 

  完成这个操作日志逻辑类和日志添加方法以后,要改造登录接口就简单多了,只需要在出错(密码错误、禁用)和成功时进行调用,记录到数据表就可以了,具体看代码。

  登录接口除了需要添加日志记录以外,还需要处理一个安全问题,我们没有对多次输出密码错误进行处理,如果有人想要登录系统写个密码劳举器,可能很容易后台就给人攻破了,所以我们需要对这个做一个限制,比如说同一ip在指定时间内只能出错多少次,每次出错时都记录一下出错次数,当出错次数超出限制时,则拒绝用户登录。具体自行查看代码,这里我就不再详细说明了。


  1 #!/usr/bin/env python
  2 # coding=utf-8
  3 
  4 from bottle import put
  5 from common import web_helper, encrypt_helper, security_helper
  6 from common.string_helper import string
  7 from logic import manager_logic, manager_operation_log_logic
  8 
  9 
 10 @put('/api/login/')
 11 def post_login():
 12     """用户登陆验证"""
 13     ##############################################################
 14     # 获取并验证客户端提交的参数
 15     ##############################################################
 16     username = web_helper.get_form('username', '帐号')
 17     password = web_helper.get_form('password', '密码')
 18     verify = web_helper.get_form('verify', '验证码')
 19     ip = web_helper.get_ip()
 20 
 21     ##############################################################
 22     # 从session中读取验证码信息
 23     ##############################################################
 24     s = web_helper.get_session()
 25     verify_code = s.get('verify_code')
 26     # 删除session中的验证码(验证码每提交一次就失效)
 27     if 'verify_code' in s:
 28         del s['verify_code']
 29         s.save()
 30     # 判断用户提交的验证码和存储在session中的验证码是否相同
 31     if verify.upper() != verify_code:
 32         return web_helper.return_msg(-1, '验证码错误')
 33 
 34     ##############################################################
 35     ### 判断用户登录失败次数,超出次做登录限制 ###
 36     # 获取管理员登录密码错误限制次数,0=无限制,x次/小时
 37     limit_login_count = 10
 38     # 获取操作出错限制值
 39     is_ok, msg, operation_times_key, error_count = security_helper.check_operation_times('login_error_count', limit_login_count, False)
 40     # 判断操作的出错次数是否已超出了限制
 41     if not is_ok:
 42         return web_helper.return_msg(-1, msg)
 43 
 44     ##############################################################
 45     ### 获取登录用户记录,并进行登录验证 ###
 46     ##############################################################
 47     # 初始化操作日志记录类
 48     _manager_operation_log_logic = manager_operation_log_logic.ManagerOperationLogLogic()
 49     # 初始化管理员逻辑类
 50     _manager_logic = manager_logic.ManagerLogic()
 51     # 从数据库中读取用户信息
 52     manager_result = _manager_logic.get_model_for_cache_of_where('login_name=' + string(username))
 53     # 判断用户记录是否存在
 54     if not manager_result:
 55         return web_helper.return_msg(-1, '账户不存在')
 56 
 57     # 获取管理员id
 58     manager_id =  manager_result.get('id', 0)
 59     # 获取管理员姓名
 60     manager_name = manager_result.get('name', '')
 61 
 62     ##############################################################
 63     ### 验证用户登录密码与状态 ###
 64     ##############################################################
 65     # 对客户端提交上来的验证进行md5加密将转为大写(为了密码的保密性,这里进行双重md5加密,加密时从第一次加密后的密串中提取一段字符串出来进行再次加密,提取的串大家可以自由设定)
 66     # pwd = encrypt_helper.md5(encrypt_helper.md5(password)[1:30]).upper()
 67     # 对客户端提交上来的验证进行md5加密将转为大写(只加密一次)
 68     pwd = encrypt_helper.md5(password).upper()
 69     # 检查登录密码输入是否正确
 70     if pwd != manager_result.get('login_password').upper():
 71         # 记录出错次数
 72         security_helper.add_operation_times(operation_times_key)
 73         # 记录日志
 74         _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '' + manager_name + '】输入的登录密码错误')
 75         return web_helper.return_msg(-1, '密码错误')
 76     # 检查该账号虽否禁用了
 77     if not manager_result.get('is_enabled'):
 78         # 记录出错次数
 79         security_helper.add_operation_times(operation_times_key)
 80         # 记录日志
 81         _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '' + manager_name + '】账号已被禁用,不能登录系统')
 82         return web_helper.return_msg(-1, '账号已被禁用')
 83 
 84     # 登录成功,清除登录错误记录
 85     security_helper.del_operation_times(operation_times_key)
 86 
 87     ##############################################################
 88     ### 把用户信息保存到session中 ###
 89     ##############################################################
 90     manager_id = manager_result.get('id')
 91     s['id'] = manager_id
 92     s['login_name'] = username
 93     s['name'] = manager_result.get('name')
 94     s['positions_id'] = manager_result.get('positions_id')
 95     s.save()
 96 
 97     ##############################################################
 98     ### 更新用户信息到数据库 ###
 99     ##############################################################
100     # 更新当前管理员最后登录时间、Ip与登录次数(字段说明,请看数据字典)
101     fields = {
102         'last_login_time': 'now()',
103         'last_login_ip': string(ip),
104         'login_count': 'login_count+1',
105     }
106     # 写入数据库
107     _manager_logic.edit_model(manager_id, fields)
108     # 记录日志
109     _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '' + manager_name + '】登陆成功')
110 
111     return web_helper.return_msg(0, '登录成功')

View Code

  security_helper.py代码


 1 #!/usr/bin/env python
 2 # coding=utf-8
 3 
 4 from common import cache_helper, convert_helper, encrypt_helper
 5 
 6 
 7 def check_operation_times(operation_name, limiting_frequency, ip, is_add=True):
 8     """
 9     检查操作次数
10     参数:
11     operation_name      操作名称
12     limiting_frequency  限制次数
13     is_add              是否累加
14     返回参数:
15     True    不限制
16     False   限制操作
17     """
18     if not operation_name or limiting_frequency is None:
19         return False, '参数错误,错误码:-400-001,请与管理员联系', '', 0
20 
21     # 如果限制次数为0时,默认不限制操作
22     if limiting_frequency <= 0:
23         return True, '', '', 0
24 
25     ##############################################################
26     ### 判断用户操作次数,超出次数限制执行 ###
27     # 获取当前用户已记录操作次数
28     operation_times_key = operation_name + '_' + encrypt_helper.md5(operation_name + ip)
29     operation_times = convert_helper.to_int0(cache_helper.get(operation_times_key))
30 
31     # 如果系统限制了出错次数,且当前用户已超出限制,则返回错误
32     if limiting_frequency and operation_times >= limiting_frequency:
33         return False, '您在10分钟内连续操作次数达到' + str(limiting_frequency) + '次,已超出限制,请稍候再试', operation_times_key, operation_times
34 
35     if is_add:
36         # 记录操作次数,默认在缓存中存储10分钟
37         cache_helper.set(operation_times_key, operation_times + 1, 600)
38 
39     return True, '', operation_times_key, operation_times
40 
41 
42 def add_operation_times(operation_times_key):
43     """
44     累加操作次数
45     参数:
46     operation_times_key 缓存key
47     """
48     # 获取当前用户已记录操作次数
49     get_operation_times = convert_helper.to_int0(cache_helper.get(operation_times_key))
50     # 记录获取次数
51     cache_helper.set(operation_times_key, get_operation_times + 1, 600)
52 
53 
54 def del_operation_times(operation_times_key):
55     """
56     清除操作次数
57     参数:
58     operation_times_key 缓存key
59     """
60     # 记录获取次数
61     cache_helper.delete(operation_times_key)
62 
63 
64 def check_login_power(id, k, t, sessionid):
65     """
66     检查拨号小信接口,验证用户是否有权限访问
67     :param id: 用户id
68     :param k:  32位长度的密钥串
69     :param t:  时间戳
70     :param sessionid: 当前用户的密钥
71     :return: False=验证失败,True=验证成功
72     """
73     if not sessionid:
74         return False
75 
76     return encrypt_helper.md5(str(id) + sessionid + str(t) + sessionid + str(id)) == k

View Code

 

  想要记录用户的每一个操作记录,有两种方法,一是在每个接口那里添加日志记录,这样可以更详细的编写自定义日志说明,不过这样做的话工作量会比较大,也容易在复制粘贴中出错;还有就是,每一个后台接口都会调用权限判断方法,我们也可以在这个方法中直接添加日志记录,缺点就是每个访问操作想要说明的很细致很难做到,这里我们通过各种判断与组合方式,来写入对应的接口日志访问记录,难免会出现记录重复或记录说明不正确的情况。

  下面是后台权限检查方法(_common_logic.py)

 1 #!/usr/bin/env python
 2 # coding=utf-8
 3 
 4 from bottle import request
 5 from common import web_helper, string_helper
 6 from logic import menu_info_logic, positions_logic, manager_operation_log_logic
 7 
 8 def check_user_power():
 9     """检查当前用户是否有访问当前接口的权限"""
10     # 读取session
11     session = web_helper.get_session()
12     # session不存在则表示登录失效了
13     if not session:
14         web_helper.return_raise(web_helper.return_msg(-404, "您的登录已失效,请重新登录"))
15 
16     # 获取当前页面原始路由
17     rule = request.route.rule
18     # 获取当前访问接口方式(get/post/put/delete)
19     method = request.method.lower()
20     # 获取当前访问的url地址
21     url = string_helper.filter_str(request.url, '<|>|%|\'')
22 
23     # 初始化日志相关变量
24     _manager_operation_log_logic = manager_operation_log_logic.ManagerOperationLogLogic()
25     ip = web_helper.get_ip()
26     manager_id = session.get('id')
27     manager_name = session.get('name')
28     # 设置访问日志信息
29     if method == 'get':
30         method_name = '访问'
31     else:
32         method_name = '进行'
33 
34     # 获取来路url
35     http_referer = request.environ.get('HTTP_REFERER')
36     if http_referer:
37         # 提取页面url地址
38         index = http_referer.find('?')
39         if index == -1:
40             web_name = http_referer[http_referer.find('/', 8) + 1:]
41         else:
42             web_name = http_referer[http_referer.find('/', 8) + 1: index]
43     else:
44         web_name = ''
45 
46     # 组合当前接口访问的缓存key值
47     key = web_name + method + '(' + rule + ')'
48     # 从菜单权限缓存中读取对应的菜单实体
49     _menu_info_logic = menu_info_logic.MenuInfoLogic()
50     model = _menu_info_logic.get_model_for_url(key)
51     if not model:
52         # 添加访问失败日志
53         _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '用户访问[%s]接口地址时,检测没有操作权限' % (url))
54         web_helper.return_raise(web_helper.return_msg(-1, "您没有访问权限1" + key))
55 
56     # 初始化菜单名称
57     menu_name = model.get('name')
58     if model.get('parent_id') > 0:
59         # 读取父级菜单实体
60         parent_model = _menu_info_logic.get_model_for_cache(model.get('parent_id'))
61         if parent_model:
62             menu_name = parent_model.get('name').replace('列表', '').replace('管理', '') + menu_name
63 
64     # 从session中获取当前用户登录时所存储的职位id
65     positions = positions_logic.PositionsLogic()
66     page_power = positions.get_page_power(session.get('positions_id'))
67     # 从菜单实体中提取菜单id,与职位权限进行比较,判断当前用户是否拥有访问该接口的权限
68     if page_power.find(',' + str(model.get('id', -1)) + ',') == -1:
69         # 添加访问失败日志
70         _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '用户%s[%s]操作检测没有权限' % (method_name, menu_name))
71         web_helper.return_raise(web_helper.return_msg(-1, "您没有访问权限2"))
72 
73     if not (method == 'get' and model.get('name') in ('添加', '编辑')):
74         # 添加访问日志
75         _manager_operation_log_logic.add_operation_log(manager_id, manager_name, ip, '用户%s[%s]操作' % (method_name, menu_name))

  这里记录的日志与菜单管理记录相关,如果菜单项的命名或树列表不规范,则记录的日志可能就会偏差比较大。当然如果你有强迫症追求完美的话,可自行对它进行改造。比如说在菜单管理中添加一个字段,用来编写日志说明的,访问这个页面时直接将说明更新到操作日志表中就可以了,简单方便。而如果对操作内容想要更细致的,也可以在日志表中添加一个字段,将客户端提交的参数全部写入到字段里记录,这样对用户的操作就会更清晰了,当然如果用户更新新闻或文章类内容时,字段值也会比较大。大家可以根据需要来进行对应改造。

  下图为操作日志表记录内容

  后台管理还需要做个日志查看的页面,接口代码很简单,具体直接看源码,这里也不详细说明了

 

  对于异常处理,大家其实都知道使用try…except…进行捕捉,然后记录异常信息或作对应处理。

  而在接口发生500错误时,由于程序在服务器端执行,服务器环境与本地的开发环境有所不同,就很难直观的判断是什么原因引起的,可能是少上传了某个调用文件,也可能是新引用的包没有安装,又或者是代码中写错了代码,也有可能是变量为空引起的异常,反正可能情况非常之多,当接口非常多时,这些异常通过很隐蔽,只有等到该接口被调用时才能发现,如果处理不好,开发人员可能会花费不少时间在这上面。

  当然也有办法是,所有的接口代码都放在try…except…里面执行,这样发生500的情况会大大减少,但代码看起来层级多了也不美观。对于这种简单重复统一的代码,python有一个非常好用的工具,那就是装饰器,我们可以编写一个装饰器方法给接口使用,从而实现我们想要的目的。

  装饰器实现的原理就是,通过在函数头部引用装饰器,从而使程序执行代码时,先执行装饰器里面的代码,然后再调用引用装饰器的函数,最后再返回装饰器执行剩下的代码。简单的理解就是,原有A函数和装饰器B函数,当A函数引用装饰器B函数以后,A函数其实就变成B函数中被调用的一个方法,即B函数在执行过程中会调用A函数,执行完成A函数后返回想要的结果再继续执行后面的代码

  先上代码,我们在异常操作包中(except_helper.py),添加下面方法:

 1 def exception_handling(func):
 2     """接口异常处理装饰器"""
 3     def wrapper(*args, **kwargs):
 4         try:
 5             # 执行接口方法
 6             return func(*args, **kwargs)
 7         except Exception as e:
 8             # 捕捉异常,如果是中断无返回类型操作,则再执行一次
 9             if isinstance(e, HTTPResponse):
10                 func(*args, **kwargs)
11             # 否则写入异常日志,并返回错误提示
12             else:
13                 log_helper.error(str(e.args))
14                 return web_helper.return_msg(-1, "操作失败")
15     return wrapper

  func就是注入到装饰器方法中的其他方法,由于我们的装饰器是给接口使用,所以执行过程中直接返回结果(见第6行代码),由于我们的代码在执行过程,有时会调用raise来中断代码执行,这样的话接口方法是没有返回值的,如果使用return来调用方法就会出现异常,所以在第9到10行,会调用方法重新执行一次接口方法,所以在开发时要注意,只有对那些出错时需要马上中断的地方,才使用raise这样保证重复执行接口方法不会造成数据错误。

  当接口方法执行出现异常要抛出500时,这个装饰器就会捕捉到,然后通过调用log_helper.error()方法,将异常写入日志,并发送异常通知邮件通知开发人员。对于异常通知,如果你注册了微信企业号,你可以编写对应的代码与企业号进行对接,让你和相关人员在微信上可以实时接收到异常推送消息,方便即时发现问题然后处理问题。

 

  下面是调用方法:

 1 @get('/api/system/department/<id:int>/')
 2 @exception_handling
 3 def callback(id):
 4     """
 5     获取指定记录
 6     """
 7     # 检查用户权限
 8     _common_logic.check_user_power()
 9 
10     _department_logic = department_logic.DepartmentLogic()
11     # 读取记录
12     result = _department_logic.get_model_for_cache(id)
13     if result:
14         return web_helper.return_msg(0, '成功', result)
15     else:
16         return web_helper.return_msg(-1, "查询失败")

  只需要在接口路由和接口方法之间,添加@exception_handling就可以实现接口500时,接收异常邮件推送了。非常方便好用。

 

  本文对应的源码下载 

 

版权声明:本文原创发表于 博客园,作者为 AllEmpty 本文欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则视为侵权。

python开发QQ群:669058475    作者博客:http://www.cnblogs.com/EmptyFS/