wxkf_callback_app.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """
  2. 企业微信回调服务端
  3. 功能:接收企业微信的回调验证和消息推送
  4. 作者:开发工程师
  5. 日期:2025年01月
  6. """
  7. import os
  8. import logging
  9. import json
  10. from urllib.parse import unquote
  11. from flask import Flask, request, abort
  12. from WXBizMsgCrypt import WXBizMsgCrypt
  13. from config import Config
  14. # 配置日志
  15. logging.basicConfig(
  16. level=logging.INFO,
  17. format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  18. )
  19. logger = logging.getLogger(__name__)
  20. # 创建Flask应用
  21. app = Flask(__name__)
  22. def get_wecom_config():
  23. """获取企业微信配置,优先从环境变量获取"""
  24. corp_id = Config.CORP_ID
  25. token = Config.TOKEN
  26. encoding_aes_key = Config.ENCODING_AES_KEY
  27. if not token:
  28. logger.error("缺少TOKEN配置,请设置环境变量TOKEN或修改代码中的TOKEN变量")
  29. return None, None, None
  30. if not encoding_aes_key:
  31. logger.error("缺少ENCODING_AES_KEY配置,请设置环境变量WECOM_ENCODING_AES_KEY或修改代码中的ENCODING_AES_KEY变量")
  32. return None, None, None
  33. return corp_id, token, encoding_aes_key
  34. @app.before_request
  35. def log_request_info():
  36. """记录请求信息"""
  37. # 记录请求的基本信息
  38. logger.info(f"=== 收到HTTP请求 ===")
  39. logger.info(f"请求方法: {request.method}")
  40. logger.info(f"请求URL: {request.url}")
  41. logger.info(f"请求路径: {request.path}")
  42. logger.info(f"客户端IP: {request.remote_addr}")
  43. # 记录请求头
  44. headers = dict(request.headers)
  45. logger.info(f"请求头: {json.dumps(headers, indent=2, ensure_ascii=False)}")
  46. # 记录查询参数
  47. if request.args:
  48. args = dict(request.args)
  49. logger.info(f"查询参数: {json.dumps(args, indent=2, ensure_ascii=False)}")
  50. # 记录请求体(仅对POST/PUT等方法)
  51. if request.method in ['POST', 'PUT', 'PATCH']:
  52. try:
  53. if request.content_type and 'application/json' in request.content_type:
  54. # JSON数据
  55. json_data = request.get_json()
  56. if json_data:
  57. logger.info(f"请求JSON: {json.dumps(json_data, indent=2, ensure_ascii=False)}")
  58. else:
  59. # 原始数据
  60. raw_data = request.get_data(as_text=True)
  61. if raw_data:
  62. logger.info(f"请求体: {raw_data[:500]}...") # 限制长度避免日志过长
  63. except Exception as e:
  64. logger.warning(f"读取请求体时出错: {e}")
  65. @app.after_request
  66. def log_response_info(response):
  67. """记录响应信息"""
  68. logger.info(f"=== HTTP响应 ===")
  69. logger.info(f"响应状态码: {response.status_code}")
  70. logger.info(f"响应状态: {response.status}")
  71. # 记录响应头
  72. headers = dict(response.headers)
  73. logger.info(f"响应头: {json.dumps(headers, indent=2, ensure_ascii=False)}")
  74. # 记录响应内容
  75. try:
  76. if response.content_type and 'application/json' in response.content_type:
  77. # JSON响应
  78. logger.info(f"响应JSON: {response.get_data(as_text=True)}")
  79. else:
  80. # 其他类型响应
  81. response_data = response.get_data(as_text=True)
  82. if response_data:
  83. if len(response_data) > 500:
  84. logger.info(f"响应内容: {response_data[:500]}...")
  85. else:
  86. logger.info(f"响应内容: {response_data}")
  87. else:
  88. logger.info("响应内容: 无内容")
  89. except Exception as e:
  90. logger.warning(f"读取响应内容时出错: {e}")
  91. logger.info(f"=== 请求处理完成 ===\n")
  92. return response
  93. def get_wxcrypt():
  94. """
  95. 获取企业微信加解密工具对象
  96. """
  97. corp_id, token, encoding_aes_key = get_wecom_config()
  98. if not all([corp_id, token, encoding_aes_key]):
  99. logger.error("企业微信配置不完整")
  100. abort(500)
  101. return WXBizMsgCrypt(token, encoding_aes_key, corp_id)
  102. @app.route('/callback/command', methods=['GET'])
  103. def wecom_callback_get():
  104. """
  105. 企业微信回调接口
  106. GET请求:用于验证回调URL的有效性
  107. """
  108. # 获取加解密工具对象
  109. wxcrypt = get_wxcrypt()
  110. # 获取URL参数
  111. msg_signature = request.args.get('msg_signature', '')
  112. timestamp = request.args.get('timestamp', '')
  113. nonce = request.args.get('nonce', '')
  114. logger.info(f"收到回调请求 - Method: {request.method}, msg_signature: {msg_signature}, timestamp: {timestamp}, nonce: {nonce}")
  115. # URL验证
  116. echostr = request.args.get('echostr', '')
  117. if not echostr:
  118. logger.error("GET请求缺少echostr参数")
  119. abort(400)
  120. if not all([msg_signature, timestamp, nonce]):
  121. logger.error("GET请求缺少必要参数")
  122. abort(400)
  123. try:
  124. # URL解码echostr参数
  125. echostr = unquote(echostr)
  126. logger.info(f"开始验证URL - echostr: {echostr[:50]}...")
  127. logger.info(f"echostr: {echostr}")
  128. # 验证URL并解密echostr
  129. result = wxcrypt.VerifyURL(msg_signature, timestamp, nonce, echostr)
  130. logger.info(f"验证URL结果: {result}")
  131. # 检查返回值类型
  132. if isinstance(result, tuple):
  133. ret, reply_echostr = result
  134. else:
  135. ret = result
  136. reply_echostr = None
  137. logger.info(f"验证结果 - 返回码: {ret}")
  138. if ret != 0:
  139. logger.error(f"URL验证失败,错误码: {ret}")
  140. if ret == -40001:
  141. logger.error("签名验证失败 - 请检查Token配置是否与企业微信后台一致")
  142. elif ret == -40002:
  143. logger.error("AES解密失败或CorpID不匹配 - 请检查EncodingAESKey和CorpID配置")
  144. else:
  145. logger.error(f"未知错误码: {ret}")
  146. abort(403)
  147. logger.info("URL验证成功")
  148. return reply_echostr
  149. except Exception as e:
  150. logger.error(f"URL验证过程发生错误: {str(e)}")
  151. import traceback
  152. logger.error(f"错误详情: {traceback.format_exc()}")
  153. abort(500)
  154. @app.route('/callback/command', methods=['POST'])
  155. def wecom_callback_post():
  156. """
  157. 企业微信回调接口
  158. POST请求:接收企业微信推送的消息和事件
  159. """
  160. # 获取加解密工具对象
  161. wxcrypt = get_wxcrypt()
  162. # 获取URL参数
  163. msg_signature = request.args.get('msg_signature', '')
  164. timestamp = request.args.get('timestamp', '')
  165. nonce = request.args.get('nonce', '')
  166. logger.info(f"收到回调请求 - Method: {request.method}, msg_signature: {msg_signature}, timestamp: {timestamp}, nonce: {nonce}")
  167. # 接收消息
  168. if not all([msg_signature, timestamp, nonce]):
  169. logger.error("POST请求缺少必要参数")
  170. abort(400)
  171. try:
  172. # 获取POST数据
  173. post_data = request.get_data(as_text=True)
  174. if not post_data:
  175. logger.error("POST请求体为空")
  176. abort(400)
  177. logger.info(f"收到POST消息: {post_data[:200]}...")
  178. # 解密消息
  179. ret, msg = wxcrypt.DecryptMsg(post_data, msg_signature, timestamp, nonce)
  180. if ret != 0:
  181. logger.error(f"消息解密失败,错误码: {ret}")
  182. abort(403)
  183. logger.info(f"消息解密成功: {msg}")
  184. # 处理消息(这里可以根据业务需求进行扩展)
  185. response_msg = handle_message(msg)
  186. if response_msg:
  187. # 加密响应消息
  188. ret, encrypted_msg = wxcrypt.EncryptMsg(response_msg, nonce, timestamp)
  189. if ret == 0:
  190. logger.info("响应消息加密成功")
  191. return encrypted_msg
  192. else:
  193. logger.error(f"响应消息加密失败,错误码: {ret}")
  194. # 返回空字符串表示成功接收但不回复
  195. return ''
  196. except Exception as e:
  197. logger.error(f"处理POST请求时发生错误: {str(e)}")
  198. abort(500)
  199. def handle_message(msg):
  200. """
  201. 处理解密后的消息
  202. Args:
  203. msg: 解密后的XML消息
  204. Returns:
  205. str: 要回复的消息(XML格式),如果不需要回复则返回None
  206. """
  207. try:
  208. import xml.etree.ElementTree as ET
  209. import time
  210. # 解析XML消息
  211. root = ET.fromstring(msg)
  212. msg_type = root.find('MsgType').text if root.find('MsgType') is not None else ''
  213. msg_id = root.find('MsgId').text if root.find('MsgId') is not None else ''
  214. logger.info(f"处理消息 - 类型: {msg_type}, 消息ID: {msg_id}")
  215. # 根据消息类型进行处理
  216. if msg_type == 'event':
  217. # 处理事件消息
  218. event = root.find('Event').text if root.find('Event') is not None else ''
  219. logger.info(f"收到事件: {event}")
  220. return process_event_message(event, root)
  221. # 对于其他类型的消息,记录日志但不回复
  222. logger.info(f"收到其他类型消息,暂不处理: {msg_type}")
  223. return None
  224. except Exception as e:
  225. logger.error(f"处理消息时发生错误: {str(e)}")
  226. import traceback
  227. logger.error(f"错误详情: {traceback.format_exc()}")
  228. return None
  229. def process_event_message(event, root):
  230. """
  231. 处理事件消息
  232. Args:
  233. event: 事件类型
  234. from_user: 发送者
  235. to_user: 接收者
  236. root: XML根节点
  237. Returns:
  238. str: 回复消息,如果不需要回复则返回None
  239. """
  240. if event == 'kf_msg_or_event':
  241. # 微信客服消息或事件
  242. logger.info("收到微信客服事件,开始处理客服消息")
  243. # 获取Token和OpenKfId
  244. token = root.find('Token').text if root.find('Token') is not None else ''
  245. open_kfid = root.find('OpenKfId').text if root.find('OpenKfId') is not None else ''
  246. if token and open_kfid:
  247. # 异步处理客服消息(避免阻塞回调响应)
  248. import threading
  249. def process_kf_messages():
  250. try:
  251. # 延迟导入避免循环导入
  252. from kf_message_handler import KfMessageHandler
  253. kf_handler = KfMessageHandler()
  254. kf_handler.process_kf_event(token, open_kfid)
  255. except Exception as e:
  256. logger.error(f"处理客服消息异常: {e}")
  257. # 启动后台线程处理
  258. thread = threading.Thread(target=process_kf_messages)
  259. thread.daemon = True
  260. thread.start()
  261. logger.info(f"已启动客服消息处理线程 - OpenKfId: {open_kfid}")
  262. else:
  263. logger.error("客服事件缺少必要参数 - Token或OpenKfId为空")
  264. # 客服事件不需要回复
  265. return None
  266. # 其他事件类型
  267. logger.info(f"收到其他事件类型: {event}")
  268. return None
  269. @app.route('/health', methods=['GET'])
  270. def health_check():
  271. """健康检查接口"""
  272. return {'status': 'ok', 'message': '企业微信回调服务运行正常'}
  273. @app.route('/', methods=['GET'])
  274. def index():
  275. return '''
  276. <h1>企业微信回调服务</h1>
  277. <p>服务正在运行...</p>
  278. '''
  279. if __name__ == '__main__':
  280. # 检查配置
  281. corp_id, token, encoding_aes_key = get_wecom_config()
  282. if not all([corp_id, token, encoding_aes_key]):
  283. logger.error("配置不完整,请检查企业微信相关配置")
  284. logger.info("请设置以下环境变量或修改代码中的配置:")
  285. logger.info("- WECOM_CORP_ID: 企业ID")
  286. logger.info("- WECOM_TOKEN: 应用Token")
  287. logger.info("- WECOM_ENCODING_AES_KEY: 应用EncodingAESKey")
  288. exit(1)
  289. logger.info("企业微信回调服务启动中...")
  290. logger.info(f"企业ID: {corp_id}")
  291. logger.info(f"Token: {token[:10]}..." if token else "Token: 未配置")
  292. logger.info(f"EncodingAESKey: {encoding_aes_key[:10]}..." if encoding_aes_key else "EncodingAESKey: 未配置")
  293. # 启动服务
  294. port = Config.PORT
  295. logger.info(f"启动服务 - 端口: {port}")
  296. host = Config.HOST
  297. logger.info(f"启动服务 - 主机: {host}")
  298. debug = Config.DEBUG
  299. logger.info(f"启动服务 - 调试模式: {debug}")
  300. app.run(host=host, port=port, debug=debug, threaded=True, processes=1)