Remove deprecated files and scripts related to cron jobs and WeChat server functionality. Update README to include new server startup methods using uWSGI and Gunicorn. Ensure callback server configuration is loaded properly in production environments. Clean up unnecessary test scripts and example files to streamline the project structure.
This commit is contained in:
+26
-4
@@ -98,11 +98,33 @@ python test_wechat_server.py
|
||||
|
||||
### 3. 启动回调服务器
|
||||
|
||||
```bash
|
||||
# 方法1:使用专用启动脚本
|
||||
python start_wechat_server.py
|
||||
#### 方法1:使用 uWSGI(推荐生产环境)
|
||||
|
||||
# 方法2:使用主程序
|
||||
```bash
|
||||
# 使用启动脚本
|
||||
./start_uwsgi_server.sh
|
||||
|
||||
# 或直接启动
|
||||
uwsgi --ini uwsgi_wechat.ini
|
||||
```
|
||||
|
||||
#### 方法2:使用 Gunicorn
|
||||
|
||||
```bash
|
||||
# 使用启动脚本
|
||||
./start_wechat_server.sh
|
||||
|
||||
# 或直接启动
|
||||
gunicorn --bind 0.0.0.0:18001 app:app
|
||||
```
|
||||
|
||||
#### 方法3:开发环境启动
|
||||
|
||||
```bash
|
||||
# 使用专用启动脚本
|
||||
python wechat_server.py
|
||||
|
||||
# 或使用主程序
|
||||
python gx_gp_monitor/main.py wechat-server
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
企业微信回调服务器 WSGI 应用入口
|
||||
用于 uWSGI 等部署环境
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from gx_gp_monitor.wechat.callback_server import create_callback_app
|
||||
|
||||
# 创建 WSGI 应用对象
|
||||
app = create_callback_app()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"导入失败: {e}", file=sys.stderr)
|
||||
print("请确保已安装所有依赖: pip install -r gx_gp_monitor/requirements.txt", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"应用创建失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 定时爬取脚本 crontab 配置示例
|
||||
# 使用方法: crontab -e 然后复制粘贴以下内容
|
||||
|
||||
# 每小时执行一次(整点)
|
||||
0 * * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 每30分钟执行一次
|
||||
*/30 * * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 每天早上9点执行
|
||||
0 9 * * * cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 工作日(周一到周五)每小时执行一次
|
||||
0 * * * 1-5 cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
|
||||
# 周一到周五早上8:30和下午2:30执行
|
||||
30 8,14 * * 1-5 cd /home/v6ole/pyproject/GX-gp-notify && ./run_cron_crawl.sh >> /home/v6ole/pyproject/GX-gp-notify/logs/cron.log 2>&1
|
||||
Binary file not shown.
Binary file not shown.
@@ -261,5 +261,15 @@ def get_callback_server() -> WeChatCallbackServer:
|
||||
|
||||
def create_callback_app() -> Flask:
|
||||
"""创建回调应用(用于外部集成)"""
|
||||
# 确保配置已加载(用于uWSGI等部署环境)
|
||||
from ..core.config_manager import load_config, get_config
|
||||
|
||||
# 检查配置是否已加载
|
||||
try:
|
||||
config = get_config()
|
||||
except RuntimeError:
|
||||
# 配置未加载,尝试加载默认配置
|
||||
load_config()
|
||||
|
||||
server = get_callback_server()
|
||||
return server.app
|
||||
|
||||
+103
-4614
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
|
||||
Thu Jan 8 21:50:36 2026 - *** Starting uWSGI 2.0.31 (64bit) on [Thu Jan 8 21:50:36 2026] ***
|
||||
Thu Jan 8 21:50:36 2026 - compiled with version: 14.2.0 on 06 January 2026 09:35:56
|
||||
Thu Jan 8 21:50:36 2026 - os: Linux-6.14.0-37-generic #37-Ubuntu SMP PREEMPT_DYNAMIC Fri Nov 14 22:10:32 UTC 2025
|
||||
Thu Jan 8 21:50:36 2026 - nodename: ubserver
|
||||
Thu Jan 8 21:50:36 2026 - machine: x86_64
|
||||
Thu Jan 8 21:50:36 2026 - clock source: unix
|
||||
Thu Jan 8 21:50:36 2026 - pcre jit disabled
|
||||
Thu Jan 8 21:50:36 2026 - detected number of CPU cores: 12
|
||||
Thu Jan 8 21:50:36 2026 - current working directory: /home/v6ole/pyproject/GX-gp-notify
|
||||
Thu Jan 8 21:50:36 2026 - writing pidfile to ./uwsgi-wechat.pid
|
||||
Thu Jan 8 21:50:36 2026 - detected binary path: /home/v6ole/pyproject/GX-gp-notify/venv/bin/uwsgi
|
||||
Thu Jan 8 21:50:36 2026 - uWSGI running as root, you can use --uid/--gid/--chroot options
|
||||
Thu Jan 8 21:50:36 2026 - *** WARNING: you are running uWSGI as root !!! (use the --uid flag) ***
|
||||
Thu Jan 8 21:50:36 2026 - chdir() to /home/v6ole/pyproject/GX-gp-notify
|
||||
Thu Jan 8 21:50:36 2026 - your processes number limit is 61306
|
||||
Thu Jan 8 21:50:36 2026 - your memory page size is 4096 bytes
|
||||
Thu Jan 8 21:50:36 2026 - *** WARNING: you have enabled harakiri without post buffering. Slow upload could be rejected on post-unbuffered webservers ***
|
||||
Thu Jan 8 21:50:36 2026 - detected max file descriptor number: 1073741816
|
||||
Thu Jan 8 21:50:36 2026 - lock engine: pthread robust mutexes
|
||||
Thu Jan 8 21:50:36 2026 - thunder lock: disabled (you can enable it with --thunder-lock)
|
||||
Thu Jan 8 21:50:36 2026 - uWSGI http bound on 0.0.0.0:18001 # 指定 uWSGI 监听的 HTTP 地址和端口 fd 4
|
||||
Thu Jan 8 21:50:36 2026 - uwsgi socket 0 bound to TCP address 127.0.0.1:40181 (port auto-assigned) fd 3
|
||||
Thu Jan 8 21:50:36 2026 - uWSGI running as root, you can use --uid/--gid/--chroot options
|
||||
Thu Jan 8 21:50:36 2026 - *** WARNING: you are running uWSGI as root !!! (use the --uid flag) ***
|
||||
Thu Jan 8 21:50:36 2026 - Python version: 3.13.11 | packaged by Anaconda, Inc. | (main, Dec 10 2025, 21:39:52) [GCC 14.3.0]
|
||||
Thu Jan 8 21:50:36 2026 - PEP 405 virtualenv detected: /home/v6ole/pyproject/GX-gp-notify/venv
|
||||
Thu Jan 8 21:50:36 2026 - Set PythonHome to /home/v6ole/pyproject/GX-gp-notify/venv
|
||||
Thu Jan 8 21:50:36 2026 - Python main interpreter initialized at 0x7eb9b7352d10
|
||||
Thu Jan 8 21:50:36 2026 - uWSGI running as root, you can use --uid/--gid/--chroot options
|
||||
Thu Jan 8 21:50:36 2026 - *** WARNING: you are running uWSGI as root !!! (use the --uid flag) ***
|
||||
Thu Jan 8 21:50:36 2026 - python threads support enabled
|
||||
Thu Jan 8 21:50:36 2026 - your server socket listen backlog is limited to 100 connections
|
||||
Thu Jan 8 21:50:36 2026 - your mercy for graceful operations on workers is 60 seconds
|
||||
Thu Jan 8 21:50:36 2026 - mapped 250032 bytes (244 KB) for 4 cores
|
||||
Thu Jan 8 21:50:36 2026 - *** Operational MODE: preforking+threaded ***
|
||||
2026-01-08 21:50:36 - gx_gp_monitor.wechat.message_handler - [32mINFO[0m - [32m企业微信消息处理器初始化完成[0m
|
||||
2026-01-08 21:50:36 - gx_gp_monitor.wechat.callback_server - [32mINFO[0m - [32m企业微信回调服务器初始化完成[0m
|
||||
Thu Jan 8 21:50:36 2026 - WSGI app 0 (mountpoint='') ready in 0 seconds on interpreter 0x7eb9b7352d10 pid: 1560152 (default app)
|
||||
Thu Jan 8 21:50:36 2026 - uWSGI running as root, you can use --uid/--gid/--chroot options
|
||||
Thu Jan 8 21:50:36 2026 - *** WARNING: you are running uWSGI as root !!! (use the --uid flag) ***
|
||||
Thu Jan 8 21:50:36 2026 - *** uWSGI is running in multiple interpreter mode ***
|
||||
Thu Jan 8 21:50:36 2026 - spawned uWSGI master process (pid: 1560152)
|
||||
Thu Jan 8 21:50:36 2026 - spawned uWSGI worker 1 (pid: 1560188, cores: 2)
|
||||
Thu Jan 8 21:50:36 2026 - spawned uWSGI worker 2 (pid: 1560190, cores: 2)
|
||||
Thu Jan 8 21:50:36 2026 - Python auto-reloader enabled
|
||||
Thu Jan 8 21:50:36 2026 - spawned uWSGI http 1 (pid: 1560194)
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m✅ XML解析成功,提取的encrypt长度: 472[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m提取的encrypt前50字符: jiJ2j73W7wKovmd8M9YmBGuai+FUfQCGkPH1KYPM4mCA1q/3jr...[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m计算的签名: a62b23e0fce089e9ee78072a8467642588448e65[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m接收的签名: a62b23e0fce089e9ee78072a8467642588448e65[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m签名匹配: True[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [31mERROR[0m - [31m尝试使用默认token计算签名...[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [32mINFO[0m - [32m收到企业微信消息: 类型=text[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.callback_server - [32mINFO[0m - [32m处理文本消息: content=爬取 大化..., user=WeiJueSen[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.message_handler - [32mINFO[0m - [32m处理文本消息: 爬取 大化, user: WeiJueSen[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.wechat.message_handler - [32mINFO[0m - [32m用户 WeiJueSen 手动爬取关键词: ['大化'][0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.main - [32mINFO[0m - [32m=== 广西政府采购网公告监控系统启动 ===[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.main - [32mINFO[0m - [32m版本: 1.0.0[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.main - [32mINFO[0m - [32m配置文件: 默认配置[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.core.reliability - [32mINFO[0m - [32m健康检查通过[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.core.database - [32mINFO[0m - [32m开始初始化数据库表结构[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库表结构初始化完成[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.storage.postgresql - [32mINFO[0m - [32m存储初始化完成[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.main - [32mINFO[0m - [32m应用初始化完成[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.main - [32mINFO[0m - [32m开始执行爬取任务 (手动爬取: True)[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.crawler.spider - [32mINFO[0m - [32m开始爬取 13 个公告来源[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购公告[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor - [32mINFO[0m - [32m采购公告 爬取完成,共获取 100 条公告,耗时 0.49秒[0m
|
||||
2026-01-08 21:51:00 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 结果公告[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m结果公告 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 合同公告[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m合同公告 爬取完成,共获取 100 条公告,耗时 0.26秒[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 更正公告[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m更正公告 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 招标文件预公示[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.26秒[0m
|
||||
2026-01-08 21:51:01 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 单一来源公示[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m单一来源公示 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 电子卖场公示[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.39秒[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 履约验收公示[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m履约验收公示 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 工程类公告[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m工程类公告 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 21:51:02 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议征集公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.25秒[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议入围结果公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议成交结果汇总公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.19秒[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购意向公开[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor - [32mINFO[0m - [32m采购意向公开 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.crawler.spider - [32mINFO[0m - [32m爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.main - [32mINFO[0m - [32m爬取到 1300 条原始公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.main - [32mINFO[0m - [32m手动爬取模式:跳过数据库保存[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.filters.filters - [32mINFO[0m - [32m日期筛选: 1300 -> 537 条公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.filters.filters - [32mINFO[0m - [32m关键词筛选: 537 -> 2 条公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.filters.filters - [32mINFO[0m - [32m来源筛选: 2 -> 2 条公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.main - [32mINFO[0m - [32m筛选后剩余 2 条公告[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.main - [32mINFO[0m - [32m手动爬取模式:跳过筛选后公告的数据库保存[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.main - [32mINFO[0m - [32m爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 2, 'saved': 0, 'markdown_generated': False, 'notification_sent': True, 'filter_stats': {'keyword_filtered': 535, 'date_filtered': 763, 'duplicate_filtered': 0, 'source_filtered': 0}, 'filtered_announcements': [Announcement(id=None, title='广西壮族自治区大化公路养护中心2026年3月至4月政府采购意向', publish_date=datetime.datetime(2026, 1, 8, 16, 55, 31), purchase_name='广西壮族自治区大化公路养护中心', content_url='https://zfcg.gxzf.gov.cn/site/detail?parentId=66485&articleId=aMzhKRWZyOvYpGoEA4k/8g==', source_code='61-266648', source_name='采购意向公开', announcement_type=<AnnouncementType.INTENTION: 'intention'>, crawled_at=datetime.datetime(2026, 1, 8, 21, 51, 3, 530262), created_at=None, updated_at=None, content_hash='1895a00610c2dc0dfbcdb724260f4597', keyword_matched=True, date_filtered=True, is_new=True, is_today=True), Announcement(id=None, title='中国共产党大化瑶族自治县委员会组织部2026年2月至3月政府采购意向', publish_date=datetime.datetime(2026, 1, 8, 11, 41, 6), purchase_name='中国共产党大化瑶族自治县委员会组织部', content_url='https://zfcg.gxzf.gov.cn/site/detail?parentId=66485&articleId=Qgm7DlkoLsuw6Asij2HL8w==', source_code='61-266648', source_name='采购意向公开', announcement_type=<AnnouncementType.INTENTION: 'intention'>, crawled_at=datetime.datetime(2026, 1, 8, 21, 51, 3, 530262), created_at=None, updated_at=None, content_hash='4a57bc4330c2885de63b8d96b8f686bb', keyword_matched=True, date_filtered=True, is_new=True, is_today=True)]}[0m
|
||||
2026-01-08 21:51:03 - gx_gp_monitor.notification.wechat - [32mINFO[0m - [32m企业微信服务初始化完成[0m
|
||||
2026-01-08 21:51:04 - gx_gp_monitor.notification.wechat - [32mINFO[0m - [32m成功获取企业微信访问令牌[0m
|
||||
2026-01-08 21:51:04 - gx_gp_monitor.notification.wechat - [32mINFO[0m - [32mMarkdown消息发送成功[0m
|
||||
{address space usage: 241336320 bytes/230MB} {rss usage: 53772288 bytes/51MB} [pid: 1560190|app: 0|req: 1/1] 14.116.241.251 () {36 vars in 679 bytes} [Thu Jan 8 21:51:00 2026] POST /api/v1/wechat/callback?msg_signature=a62b23e0fce089e9ee78072a8467642588448e65×tamp=1767880260&nonce=1767803646 => generated 582 bytes in 4177 msecs (HTTP/1.1 200) 2 headers in 80 bytes (1 switches on core 0)
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试定时爬取脚本配置
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from gx_gp_monitor.core.config_manager import load_config
|
||||
from gx_gp_monitor.core.logger import init_logger
|
||||
from gx_gp_monitor.storage.postgresql import init_storage
|
||||
|
||||
def test_config():
|
||||
"""测试配置加载"""
|
||||
print("🔍 测试配置加载...")
|
||||
|
||||
config = load_config()
|
||||
if not config:
|
||||
print("❌ 配置加载失败")
|
||||
return False
|
||||
|
||||
print("✅ 配置加载成功")
|
||||
print(f" 关键词: {config.crawler.keyword}")
|
||||
print(f" 企业微信启用: {config.wechat_app.enabled}")
|
||||
print(f" 数据库启用: {config.database.enabled}")
|
||||
|
||||
return True
|
||||
|
||||
def test_logger():
|
||||
"""测试日志系统"""
|
||||
print("\n🔍 测试日志系统...")
|
||||
|
||||
try:
|
||||
init_logger()
|
||||
print("✅ 日志系统初始化成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 日志系统初始化失败: {e}")
|
||||
return False
|
||||
|
||||
def test_database():
|
||||
"""测试数据库连接"""
|
||||
print("\n🔍 测试数据库连接...")
|
||||
|
||||
try:
|
||||
init_storage()
|
||||
print("✅ 数据库连接成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 数据库连接失败: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 开始测试定时爬取脚本配置\n")
|
||||
|
||||
results = []
|
||||
results.append(("配置加载", test_config()))
|
||||
results.append(("日志系统", test_logger()))
|
||||
results.append(("数据库连接", test_database()))
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("📊 测试结果:")
|
||||
|
||||
all_passed = True
|
||||
for test_name, passed in results:
|
||||
status = "✅ 通过" if passed else "❌ 失败"
|
||||
print(f" {test_name}: {status}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print("\n" + "="*50)
|
||||
if all_passed:
|
||||
print("🎉 所有测试通过!可以安全使用定时爬取脚本。")
|
||||
print("\n💡 使用方法:")
|
||||
print(" 直接运行: python cron_crawl.py")
|
||||
print(" 定时运行: 参考 CRON_README.md 或 cron_example.txt")
|
||||
else:
|
||||
print("⚠️ 部分测试失败,请检查配置后再使用脚本。")
|
||||
|
||||
return all_passed
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入失败: {e}", file=sys.stderr)
|
||||
print("请确保已安装所有依赖: pip install -r gx_gp_monitor/requirements.txt", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1 @@
|
||||
1560152
|
||||
@@ -0,0 +1,28 @@
|
||||
[uwsgi]
|
||||
# 企业微信回调服务器 uWSGI 配置
|
||||
# 指定 WSGI 应用的入口模块和对象
|
||||
module = app:app
|
||||
|
||||
# 项目基本配置
|
||||
chdir = /home/v6ole/pyproject/GX-gp-notify
|
||||
virtualenv = /home/v6ole/pyproject/GX-gp-notify/venv
|
||||
http = 0.0.0.0:18001 # 指定 uWSGI 监听的 HTTP 地址和端口
|
||||
master = true # 启用主进程模式
|
||||
processes = 2 # 设置进程数
|
||||
threads = 2 # 设置每个进程的线程数
|
||||
enable-threads = true # 启用线程
|
||||
harakiri = 30 # 设置超时时间(秒)
|
||||
http-timeout = 30 # 设置HTTP请求超时
|
||||
max-requests = 5000 # 每个进程处理请求数达到此值后自动重启
|
||||
reload-on-rss = 200 # 当进程占用内存超过此值(MB)时自动重启
|
||||
# 指定日志文件的路径和PID文件的路径
|
||||
logto = ./logs/uwsgi-wechat.log
|
||||
pidfile = ./uwsgi-wechat.pid
|
||||
# 当 uWSGI 退出时,自动清理其创建的套接字和 PID 文件
|
||||
vacuum = true
|
||||
# 添加日期到日志
|
||||
log-date = true
|
||||
|
||||
# 自动重载和内存报告配置
|
||||
py-autoreload = 1
|
||||
memory-report = true
|
||||
@@ -1,53 +0,0 @@
|
||||
|
||||
# About
|
||||
weworkapi_python 是为了简化开发者对企业微信API接口的使用而设计的,API调用库系列之python版本
|
||||
本库仅做示范用,并不保证完全无bug;
|
||||
作者会不定期更新本库,但不保证与官方API接口文档同步,因此一切以[官方文档](https://work.weixin.qq.com/api/doc)为准。
|
||||
|
||||
更多来自个人开发者的其它语言的库推荐:
|
||||
python : https://github.com/sbzhu/weworkapi_python abelzhu@tencent.com(企业微信团队)
|
||||
ruby : https://github.com/mycolorway/wework MyColorway(个人开发者)
|
||||
php : https://github.com/sbzhu/weworkapi_php abelzhu@tencent.com(企业微信团队)
|
||||
golang : https://github.com/sbzhu/weworkapi_golang ryanjelin@tencent.com(企业微信团队)
|
||||
golang : https://github.com/doubliekill/EnterpriseWechatSDK 1006401052yh@gmail.com(个人开发者)
|
||||
|
||||
# Director
|
||||
├── api // API 接口
|
||||
│ ├── examples // API接口的测试用例
|
||||
│ ├── README.md
|
||||
│ └── src // API接口的关键逻辑
|
||||
├── callback // 加解密库,python2, xml格式)
|
||||
|
||||
├── callback_json // 加解密库,Python2, json格式, 仅适用于企业机器人/智能机器人
|
||||
|
||||
├── callback_json_python3 // 加解密库,Python3, json格式, 仅适用于企业机器人/智能机器人
|
||||
|
||||
├── callback_python3 // 加解密库,python2, xml格式
|
||||
|
||||
├── conf.py
|
||||
└── README.md
|
||||
|
||||
# Usage
|
||||
将本项目下载到你的目录,既可直接引用相关文件
|
||||
详细使用方法参考examples路径下的测试用例
|
||||
|
||||
# 关于token的缓存
|
||||
token是需要缓存的,不能每次调用都去获取token,[否则会中频率限制](https://work.weixin.qq.com/api/doc#10013/%E7%AC%AC%E5%9B%9B%E6%AD%A5%EF%BC%9A%E7%BC%93%E5%AD%98%E5%92%8C%E5%88%B7%E6%96%B0access_token)
|
||||
在本库的设计里,token是以类里的一个变量缓存的
|
||||
比如api/src/CorpApi.py 里的access_token变量
|
||||
在类的生命周期里,这个accessToken都是存在的, 当且仅当发现token过期,CorpAPI类会自动刷新token
|
||||
刷新机制在 api/src/AbstractApi.py
|
||||
所以,使用时,只需要全局实例化一个CorpAPI类,不要析构它,就可一直用它调函数,不用关心 token
|
||||
```
|
||||
api = CorpAPI(corpid, corpsecret);
|
||||
api.dosomething()
|
||||
api.dosomething()
|
||||
api.dosomething()
|
||||
....
|
||||
```
|
||||
当然,如果要更严格的做的话,建议自行修改,全局缓存token,比如存redis、存文件等,失效周期设置为2小时。
|
||||
|
||||
# Contact us
|
||||
abelzhu@tencent.com
|
||||
|
||||
#
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
chatid = "test210";
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_CREATE'],
|
||||
{
|
||||
'name' : 'appchat_test',
|
||||
'owner' : 'ZhuBiaoYi',
|
||||
'userlist' : ['LiShuang', 'ZhuShengBen', 'LinJianEn', 'ZhuBiaoYi', 'XuBin', 'yangpeiyi', 'HaLuoTeQu', 'lucky', 'raindong', 'simon', 'Wang', 'ZhaoDong', 'DengLinSheng', 'Li'],
|
||||
'chatid' : chatid,
|
||||
})
|
||||
print response
|
||||
chatid = response['chatid']
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_UPDATE'],
|
||||
{
|
||||
'chatid' : chatid,
|
||||
'name' : 'appchat_test_new_name',
|
||||
'owner' : 'ZhuShengBen',
|
||||
'add_user_list' : ['huqiqi', 'Wang']
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_UPDATE'],
|
||||
{
|
||||
'chatid' : chatid,
|
||||
'name' : '应用发消息测试',
|
||||
'owner' : 'ZhuBiaoYi',
|
||||
'del_user_list' : 'huqiqi',
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'text',
|
||||
'text' : {'content':'我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党'},
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'image',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'image' : {
|
||||
'media_id':'3A9Jo9CHit_5UTfOVE38_067dUJQlLs30mOa9FC0a4jEGeoQgpLCZgc7rEza6TbfB',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'file',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'file' : {
|
||||
'media_id':'35L7MmcpGdyFfqjbGhbECCkGcaNsUajaPQifGLJq_H5E',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'voice',
|
||||
'voice' : {
|
||||
'media_id':'3x1yb34061fDXjyUXy2rWNd-a-hWe-l8eTw2VKyh3bDQ',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'video',
|
||||
'video' : {
|
||||
'media_id':'3neA1ypnC3k5QnAZqvyVvCesFYUrXietU5F-Ipnj6ZobiD-PuFlXngzPplWXibw9r',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'news',
|
||||
"news" : {
|
||||
"articles" : [
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
]},
|
||||
'safe' : 1,
|
||||
},
|
||||
)
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'textcard',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'textcard' : {
|
||||
'title':'我是文本卡片消息',
|
||||
'description' : 'aaaaaaa',
|
||||
'url' : 'www.qq.com',
|
||||
'btntxt' : '更多',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
"msgtype" : "mpnews",
|
||||
"mpnews": {
|
||||
"articles" : [
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
]
|
||||
},
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['MESSAGE_SEND'],
|
||||
{
|
||||
"touser": "ZhuShengBen",
|
||||
"agentid": 1000002,
|
||||
'msgtype' : 'text',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'text' : {
|
||||
'content':'方法论',
|
||||
},
|
||||
'safe' : 0,
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['MINIPROGRAM_CODE_TO_SESSION_KEY'],
|
||||
{
|
||||
"js_code" : "sVqtL3itg0L30LTGJtZ_isKC0efG5FqGw470fVp8Dpw",
|
||||
"grant_type" : "authorization_code"
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceCorpTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from ServiceCorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
|
||||
## 第三方服务商接口的使用方法
|
||||
api = ServiceCorpApi(
|
||||
"SUITE_ID",
|
||||
"SUITE_SECRET",
|
||||
"SUITE_TICKET"
|
||||
);
|
||||
|
||||
try :
|
||||
pre_auth_code = api.httpCall(SERVICE_CORP_API_TYPE['GET_PRE_AUTH_CODE']).get('pre_auth_code')
|
||||
print pre_auth_code
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
|
||||
## 第三方服务商使用永久授权码调用企业接口的方法
|
||||
api = ServiceCorpApi(
|
||||
"SUITE_ID",
|
||||
"SUITE_SECRET",
|
||||
"SUITE_TICKET",
|
||||
'AUTH_CORPID',
|
||||
'PERMANENT_CODE'
|
||||
);
|
||||
try :
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_GET'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceProviderTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-26
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from ServiceProviderApi import *
|
||||
from TestConf import *
|
||||
|
||||
api = ServiceProviderApi('CORPID', 'PROVIDER_SECRET')
|
||||
|
||||
try :
|
||||
response = api.httpCall(
|
||||
SERVICE_PROVIDER_API_TYPE['GET_LOGIN_INFO'],
|
||||
{
|
||||
'auth_code' : 'XXXXXXX',
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File conf.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
# 请将下面参数改为自己的企业相关参数再进行测试
|
||||
|
||||
TestConf = {
|
||||
|
||||
# 企业的id,在管理端->"我的企业" 可以看到
|
||||
"CORP_ID" : "ww55ca070cb9b7eb22",
|
||||
|
||||
# "通讯录同步"应用的secret, 开启api接口同步后,可以在管理端->"通讯录同步"看到
|
||||
"CONTACT_SYNC_SECRET" : "ktmzrVIlUH0UW63zi7-JyzsgTL9NfwUhHde6or6zwQY",
|
||||
|
||||
# 某个自建应用的id及secret, 在管理端 -> 企业应用 -> 自建应用, 点进相应应用可以看到
|
||||
"APP_ID" : 1000002,
|
||||
"APP_SECRET" : "v1Z2KSw2WqPFECAwn2R0a1dFsanVF5sE4IE6X5ogveQ",
|
||||
|
||||
# 打卡应用的 id 及secrete, 在管理端 -> 企业应用 -> 基础应用 -> 打卡,
|
||||
# 点进去,有个"api"按钮,点开后,会看到
|
||||
"CHECKIN_APP_ID" : 3010011,
|
||||
"CHECKIN_APP_SECRET" : "3Qz2OGPvE1Eb6WKpEDfczvyQjL5Lr1CjrDTKn0RHdLE",
|
||||
|
||||
# 审批应用的 id 及secrete, 在管理端 -> 企业应用 -> 基础应用 -> 审批,
|
||||
# 点进去,有个"api"按钮,点开后,会看到
|
||||
"APPROVAL_APP_ID" : 3010040,
|
||||
"APPROVAL_APP_SECRET" : "1vrlwItWpz_5Qkud55aImQPCvpzi51H3F2j-1OQzhYE",
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['CONTACT_SYNC_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_CREATE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
'name' : 'zhangsanfeng',
|
||||
'mobile' : '131488888888',
|
||||
'email' : 'zhangsan@ipp.cas.cn',
|
||||
'department' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_GET'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_DELETE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_DELETE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File AbstractApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
import json
|
||||
import requests
|
||||
|
||||
sys.path.append("../../")
|
||||
|
||||
from conf import DEBUG
|
||||
|
||||
class ApiException(Exception) :
|
||||
def __init__(self, errCode, errMsg) :
|
||||
self.errCode = errCode
|
||||
self.errMsg = errMsg
|
||||
|
||||
class AbstractApi(object) :
|
||||
def __init__(self) :
|
||||
return
|
||||
|
||||
def getAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def getSuiteAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshSuiteAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def getProviderAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshProviderAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def httpCall(self, urlType, args=None) :
|
||||
shortUrl = urlType[0]
|
||||
method = urlType[1]
|
||||
response = {}
|
||||
for retryCnt in range(0, 3) :
|
||||
if 'POST' == method :
|
||||
url = self.__makeUrl(shortUrl)
|
||||
response = self.__httpPost(url, args)
|
||||
elif 'GET' == method :
|
||||
url = self.__makeUrl(shortUrl)
|
||||
url = self.__appendArgs(url, args)
|
||||
response = self.__httpGet(url)
|
||||
else :
|
||||
raise ApiException(-1, "unknown method type")
|
||||
|
||||
# check if token expired
|
||||
if self.__tokenExpired(response.get('errcode')) :
|
||||
self.__refreshToken(shortUrl)
|
||||
retryCnt += 1
|
||||
continue
|
||||
else :
|
||||
break
|
||||
|
||||
return self.__checkResponse(response)
|
||||
|
||||
@staticmethod
|
||||
def __appendArgs(url, args) :
|
||||
if args is None :
|
||||
return url
|
||||
|
||||
for key, value in args.items() :
|
||||
if '?' in url :
|
||||
url += ('&' + key + '=' + value)
|
||||
else :
|
||||
url += ('?' + key + '=' + value)
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def __makeUrl(shortUrl) :
|
||||
base = "https://qyapi.weixin.qq.com"
|
||||
if shortUrl[0] == '/' :
|
||||
return base + shortUrl
|
||||
else :
|
||||
return base + '/' + shortUrl
|
||||
|
||||
def __appendToken(self, url) :
|
||||
if 'SUITE_ACCESS_TOKEN' in url :
|
||||
return url.replace('SUITE_ACCESS_TOKEN', self.getSuiteAccessToken())
|
||||
elif 'PROVIDER_ACCESS_TOKEN' in url :
|
||||
return url.replace('PROVIDER_ACCESS_TOKEN', self.getProviderAccessToken())
|
||||
elif 'ACCESS_TOKEN' in url :
|
||||
return url.replace('ACCESS_TOKEN', self.getAccessToken())
|
||||
else :
|
||||
return url
|
||||
|
||||
def __httpPost(self, url, args) :
|
||||
realUrl = self.__appendToken(url)
|
||||
|
||||
if DEBUG is True :
|
||||
print realUrl, args
|
||||
|
||||
return requests.post(realUrl, data = json.dumps(args, ensure_ascii = False).encode('utf-8')).json()
|
||||
|
||||
def __httpGet(self, url) :
|
||||
realUrl = self.__appendToken(url)
|
||||
|
||||
if DEBUG is True :
|
||||
print realUrl
|
||||
|
||||
return requests.get(realUrl).json()
|
||||
|
||||
def __post_file(self, url, media_file):
|
||||
return requests.post(url, file=media_file).json()
|
||||
|
||||
@staticmethod
|
||||
def __checkResponse(response):
|
||||
errCode = response.get('errcode')
|
||||
errMsg = response.get('errmsg')
|
||||
|
||||
if errCode is 0:
|
||||
return response
|
||||
else:
|
||||
raise ApiException(errCode, errMsg)
|
||||
|
||||
@staticmethod
|
||||
def __tokenExpired(errCode) :
|
||||
if errCode == 40014 or errCode == 42001 or errCode == 42007 or errCode == 42009 :
|
||||
return True
|
||||
else :
|
||||
return False
|
||||
|
||||
def __refreshToken(self, url) :
|
||||
if 'SUITE_ACCESS_TOKEN' in url :
|
||||
self.refreshSuiteAccessToken()
|
||||
elif 'PROVIDER_ACCESS_TOKEN' in url :
|
||||
self.refreshProviderAccessToken()
|
||||
elif 'ACCESS_TOKEN' in url :
|
||||
self.refreshAccessToken()
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File CorpApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
from AbstractApi import *
|
||||
|
||||
CORP_API_TYPE = {
|
||||
'GET_ACCESS_TOKEN' : ['/cgi-bin/gettoken', 'GET'],
|
||||
'USER_CREATE' : ['/cgi-bin/user/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_GET' : ['/cgi-bin/user/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_UPDATE' : ['/cgi-bin/user/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_DELETE' : ['/cgi-bin/user/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_BATCH_DELETE': ['/cgi-bin/user/batchdelete?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_SIMPLE_LIST': ['/cgi-bin/user/simplelist?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_LIST' : ['/cgi-bin/user/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USERID_TO_OPENID' : ['/cgi-bin/user/convert_to_openid?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'OPENID_TO_USERID' : ['/cgi-bin/user/convert_to_userid?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_AUTH_SUCCESS': ['/cgi-bin/user/authsucc?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'DEPARTMENT_CREATE': ['/cgi-bin/department/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'DEPARTMENT_UPDATE': ['/cgi-bin/department/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'DEPARTMENT_DELETE': ['/cgi-bin/department/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'DEPARTMENT_LIST' : ['/cgi-bin/department/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'TAG_CREATE' : ['/cgi-bin/tag/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_UPDATE' : ['/cgi-bin/tag/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_DELETE' : ['/cgi-bin/tag/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'TAG_GET_USER' : ['/cgi-bin/tag/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'TAG_ADD_USER' : ['/cgi-bin/tag/addtagusers?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_DELETE_USER' : ['/cgi-bin/tag/deltagusers?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_GET_LIST' : ['/cgi-bin/tag/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'BATCH_JOB_GET_RESULT' : ['/cgi-bin/batch/getresult?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'BATCH_INVITE' : ['/cgi-bin/batch/invite?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'AGENT_GET' : ['/cgi-bin/agent/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'AGENT_SET' : ['/cgi-bin/agent/set?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'AGENT_GET_LIST' : ['/cgi-bin/agent/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'MENU_CREATE' : ['/cgi-bin/menu/create?access_token=ACCESS_TOKEN', 'POST'], ## TODO
|
||||
'MENU_GET' : ['/cgi-bin/menu/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'MENU_DELETE' : ['/cgi-bin/menu/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'MESSAGE_SEND' : ['/cgi-bin/message/send?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'MESSAGE_REVOKE' : ['/cgi-bin/message/revoke?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'MEDIA_GET' : ['/cgi-bin/media/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'GET_USER_INFO_BY_CODE' : ['/cgi-bin/user/getuserinfo?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'GET_USER_DETAIL' : ['/cgi-bin/user/getuserdetail?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'GET_TICKET' : ['/cgi-bin/ticket/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'GET_JSAPI_TICKET' : ['/cgi-bin/get_jsapi_ticket?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'GET_CHECKIN_OPTION' : ['/cgi-bin/checkin/getcheckinoption?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'GET_CHECKIN_DATA' : ['/cgi-bin/checkin/getcheckindata?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'GET_APPROVAL_DATA': ['/cgi-bin/corp/getapprovaldata?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'GET_INVOICE_INFO' : ['/cgi-bin/card/invoice/reimburse/getinvoiceinfo?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'UPDATE_INVOICE_STATUS' :
|
||||
['/cgi-bin/card/invoice/reimburse/updateinvoicestatus?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'BATCH_UPDATE_INVOICE_STATUS' :
|
||||
['/cgi-bin/card/invoice/reimburse/updatestatusbatch?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'BATCH_GET_INVOICE_INFO' :
|
||||
['/cgi-bin/card/invoice/reimburse/getinvoiceinfobatch?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'APP_CHAT_CREATE' : ['/cgi-bin/appchat/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'APP_CHAT_GET' : ['/cgi-bin/appchat/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'APP_CHAT_UPDATE' : ['/cgi-bin/appchat/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'APP_CHAT_SEND' : ['/cgi-bin/appchat/send?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'MINIPROGRAM_CODE_TO_SESSION_KEY' : ['/cgi-bin/miniprogram/jscode2session?access_token=ACCESS_TOKEN', 'GET'],
|
||||
}
|
||||
|
||||
class CorpApi(AbstractApi) :
|
||||
def __init__(self, corpid, secret) :
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
self.access_token = None
|
||||
|
||||
def getAccessToken(self) :
|
||||
if self.access_token is None :
|
||||
self.refreshAccessToken()
|
||||
return self.access_token
|
||||
|
||||
def refreshAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
CORP_API_TYPE['GET_ACCESS_TOKEN'],
|
||||
{
|
||||
'corpid' : self.corpid,
|
||||
'corpsecret': self.secret,
|
||||
})
|
||||
self.access_token = response.get('access_token')
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceCorp.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
from CorpApi import *
|
||||
|
||||
SERVICE_CORP_API_TYPE = {
|
||||
'GET_CORP_TOKEN' : ['/cgi-bin/service/get_corp_token?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_SUITE_TOKEN' : ['/cgi-bin/service/get_suite_token', 'POST'],
|
||||
'GET_PRE_AUTH_CODE' : ['/cgi-bin/service/get_pre_auth_code?suite_access_token=SUITE_ACCESS_TOKEN', 'GET'],
|
||||
'SET_SESSION_INFO' : ['/cgi-bin/service/set_session_info?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_PERMANENT_CODE': ['/cgi-bin/service/get_permanent_code?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_AUTH_INFO' : ['/cgi-bin/service/get_auth_info?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_ADMIN_LIST' : ['/cgi-bin/service/get_admin_list?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_USER_INFO_BY_3RD' : ['/cgi-bin/service/getuserinfo3rd?suite_access_token=SUITE_ACCESS_TOKEN', 'GET'],
|
||||
'GET_USER_DETAIL_BY_3RD' : ['/cgi-bin/service/getuserdetail3rd?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
}
|
||||
|
||||
class ServiceCorpApi(CorpApi) :
|
||||
def __init__(self, suite_id, suite_secret, suite_ticket, auth_corpid=None, permanent_code=None) :
|
||||
self.suite_id = suite_id
|
||||
self.suite_secret = suite_secret
|
||||
self.suite_ticket = suite_ticket
|
||||
|
||||
# 调用 CorpAPI 的function, 需要设置这两个参数
|
||||
self.auth_corpid = auth_corpid
|
||||
self.permanent_code = permanent_code
|
||||
|
||||
self.access_token = None
|
||||
self.suite_access_token = None
|
||||
|
||||
## override CorpApi 的 refreshAccessToken, 使用第三方服务商的方法
|
||||
def getAccessToken(self) :
|
||||
if self.access_token is None :
|
||||
self.refreshAccessToken()
|
||||
return self.access_token
|
||||
def refreshAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_CORP_API_TYPE['GET_CORP_TOKEN'],
|
||||
{
|
||||
"auth_corpid" : self.auth_corpid,
|
||||
"permanent_code": self.permanent_code,
|
||||
})
|
||||
self.access_token = response.get('access_token')
|
||||
|
||||
##
|
||||
def getSuiteAccessToken(self) :
|
||||
if self.suite_access_token is None :
|
||||
self.refreshSuiteAccessToken()
|
||||
return self.suite_access_token
|
||||
|
||||
def refreshSuiteAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_CORP_API_TYPE['GET_SUITE_TOKEN'],
|
||||
{
|
||||
"suite_id" : self.suite_id,
|
||||
"suite_secret" : self.suite_secret,
|
||||
"suite_ticket" : self.suite_ticket,
|
||||
})
|
||||
self.suite_access_token= response.get('suite_access_token')
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceProviderApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-26
|
||||
#
|
||||
#
|
||||
|
||||
from AbstractApi import *
|
||||
|
||||
SERVICE_PROVIDER_API_TYPE = {
|
||||
'GET_PROVIDER_TOKEN': ['/cgi-bin/service/get_provider_token', 'POST'],
|
||||
'GET_LOGIN_INFO' : ['/cgi-bin/service/get_login_info?access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'GET_REGISTER_CODE' : ['/cgi-bin/service/get_register_code?provider_access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'GET_REGISTER_INFO' : ['/cgi-bin/service/get_register_info?provider_access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'SET_AGENT_SCOPE' : ['/cgi-bin/agent/set_scope', 'POST'], ### TODO
|
||||
'SET_CONTACT_SYNC_SUCCESS' : ['/cgi-bin/sync/contact_sync_success', 'GET'],
|
||||
}
|
||||
|
||||
class ServiceProviderApi(AbstractApi) :
|
||||
def __init__(self, corpid, provider_secret) :
|
||||
self.corpid = corpid
|
||||
self.provider_secret = provider_secret
|
||||
|
||||
self.provider_access_token = None
|
||||
|
||||
def getProviderAccessToken(self) :
|
||||
if self.provider_access_token is None :
|
||||
self.refreshProviderAccessToken()
|
||||
return self.provider_access_token
|
||||
|
||||
def refreshProviderAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_PROVIDER_API_TYPE['GET_PROVIDER_TOKEN'],
|
||||
{
|
||||
'corpid' : self.corpid,
|
||||
'provider_secret': self.provider_secret,
|
||||
})
|
||||
self.provider_access_token = response.get('provider_access_token')
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类(Python3以及以上版本使用 WXBizMsgCrypt3.py),提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizMsgCrypt import WXBizMsgCrypt
|
||||
import xml.etree.cElementTree as ET
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
#sVerifyMsgSig=HttpUtils.ParseUrl("msg_signature")
|
||||
#ret = wxcpt.VerifyAESKey()
|
||||
#print ret
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
#sVerifyTimeStamp=HttpUtils.ParseUrl("timestamp")
|
||||
sVerifyTimeStamp="1476416373"
|
||||
#sVerifyNonce=HttpUitls.ParseUrl("nonce")
|
||||
sVerifyNonce="47744683"
|
||||
#sVerifyEchoStr=HttpUtils.ParseUrl("echostr")
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print "ERR: VerifyURL ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
#HttpUtils.SetResponse(sEchoStr)
|
||||
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
Content-Length: 613
|
||||
<xml> <ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName><Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt>
|
||||
<AgentID><![CDATA[218]]></AgentID>
|
||||
</xml>
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行xml解析,并将<Encrypt>标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
# sReqMsgSig = HttpUtils.ParseUrl("msg_signature")
|
||||
sReqMsgSig = "0c3914025cb4b4d68103f6bfc8db550f79dcf48e"
|
||||
sReqTimeStamp = "1476422779"
|
||||
sReqNonce = "1597212914"
|
||||
sReqData = "<xml><ToUserName><![CDATA[ww1436e0e65a779aee]]></ToUserName>\n<Encrypt><![CDATA[Kl7kjoSf6DMD1zh7rtrHjFaDapSCkaOnwu3bqLc5tAybhhMl9pFeK8NslNPVdMwmBQTNoW4mY7AIjeLvEl3NyeTkAgGzBhzTtRLNshw2AEew+kkYcD+Fq72Kt00fT0WnN87hGrW8SqGc+NcT3mu87Ha3dz1pSDi6GaUA6A0sqfde0VJPQbZ9U+3JWcoD4Z5jaU0y9GSh010wsHF8KZD24YhmZH4ch4Ka7ilEbjbfvhKkNL65HHL0J6EYJIZUC2pFrdkJ7MhmEbU2qARR4iQHE7wy24qy0cRX3Mfp6iELcDNfSsPGjUQVDGxQDCWjayJOpcwocugux082f49HKYg84EpHSGXAyh+/oxwaWbvL6aSDPOYuPDGOCI8jmnKiypE+]]></Encrypt>\n<AgentID><![CDATA[1000002]]></AgentID>\n</xml>"
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
print ret,sMsg
|
||||
if( ret!=0 ):
|
||||
print "ERR: DecryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
# 解密成功,sMsg即为xml格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# For example:
|
||||
xml_tree = ET.fromstring(sMsg)
|
||||
content = xml_tree.find("Content").text
|
||||
print content
|
||||
# ...
|
||||
# ...
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的xml串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
<xml>
|
||||
<ToUserName><![CDATA[mycreate]]></ToUserName>
|
||||
<FromUserName><![CDATA[wx5823bf96d3bd56c7]]></FromUserName>
|
||||
<CreateTime>1348831860</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[this is a test]]></Content>
|
||||
<MsgId>1234567890123456</MsgId>
|
||||
<AgentID>128</AgentID>
|
||||
</xml>
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成xml格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
sRespData = "<xml><ToUserName>ww1436e0e65a779aee</ToUserName><FromUserName>ChenJiaShun</FromUserName><CreateTime>1476422779</CreateTime><MsgType>text</MsgType><Content>你好</Content><MsgId>1456453720</MsgId><AgentID>1000002</AgentID></xml>"
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print "ERR: EncryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
#TODO:
|
||||
#HttpUitls.SetResponse(sEncryptMsg) #测试解密
|
||||
@@ -1,274 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import xml.etree.cElementTree as ET
|
||||
import sys
|
||||
import socket
|
||||
stdi,stdo,stde=sys.stdin,sys.stdout,sys.stderr
|
||||
reload(sys)
|
||||
sys.stdin,sys.stdout,sys.stderr=stdi,stdo,stde
|
||||
import ierror
|
||||
sys.setdefaultencoding('utf-8')
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error,None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self,text,receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = self.get_random_str() + struct.pack("I",socket.htonl(len(text))) + text + receiveid
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error,None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = ord(plain_text[-1])
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
xml_content = content[4 : xml_len+4]
|
||||
from_receiveid = content[xml_len+4:]
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
rule = string.letters + string.digits
|
||||
str = random.sample(rule, 16)
|
||||
return "".join(str)
|
||||
|
||||
class WXBizMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
xmlParse = XMLParse()
|
||||
return ret,xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret,encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,xml_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,xml_content
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -1,5 +0,0 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类,提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print "ERR: VerifyURL ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print "done VerifyURL"
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print "=============================="
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print "ERR: DecryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print sMsg
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print "=============================="
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print "ERR: EncryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print sEncryptMsg
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print "=============================="
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2020 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import sys
|
||||
import socket
|
||||
import json
|
||||
|
||||
reload(sys)
|
||||
import ierror
|
||||
sys.setdefaultencoding('utf-8')
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class JsonParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# json消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = '''{
|
||||
"encrypt": "%(msg_encrypt)s",
|
||||
"msgsignature": "%(msg_signaturet)s",
|
||||
"timestamp": "%(timestamp)s",
|
||||
"nonce": "%(nonce)s"
|
||||
}'''
|
||||
|
||||
def extract(self, jsontext):
|
||||
"""提取出json数据包中的加密消息
|
||||
@param jsontext: 待提取的json字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
json_dict = json.loads(jsontext)
|
||||
return ierror.WXBizMsgCrypt_OK, json_dict['encrypt']
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ParseJson_Error, None
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成json消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的json字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_json
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self,text,receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = self.get_random_str() + struct.pack("I",socket.htonl(len(text))) + text + receiveid
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error,None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = ord(plain_text[-1])
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
json_content = content[4 : json_len+4]
|
||||
from_receiveid = content[json_len+4:]
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
print "receiveid not match"
|
||||
print from_receiveid
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,json_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
rule = string.letters + string.digits
|
||||
str = random.sample(rule, 16)
|
||||
return "".join(str)
|
||||
|
||||
class WXBizJsonMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,json格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
jsonParse = JsonParse()
|
||||
return ret,jsonParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# json_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
jsonParse = JsonParse()
|
||||
ret,encrypt = jsonParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
print "signature not match"
|
||||
print signature
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,json_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,json_content
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
@@ -1,5 +0,0 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类,提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
import json
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print("ERR: VerifyURL ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("done VerifyURL")
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print("==============================")
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print("ERR: EncryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sEncryptMsg)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
对上面加密的包进行解密
|
||||
'''
|
||||
sReqMsgSig = json.loads(sEncryptMsg)['msgsignature']
|
||||
sReqTimeStamp = json.loads(sEncryptMsg)['timestamp']
|
||||
sReqNonce = json.loads(sEncryptMsg)['nonce']
|
||||
|
||||
ret,sMsg=wxcpt.DecryptMsg( sEncryptMsg, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
@@ -1,282 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2020 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import sys
|
||||
import socket
|
||||
import json
|
||||
|
||||
import ierror
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
# 确保所有输入都是字符串类型
|
||||
if isinstance(encrypt, bytes):
|
||||
encrypt = encrypt.decode('utf-8')
|
||||
|
||||
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode('utf-8'))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class JsonParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# json消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = '''{
|
||||
"encrypt": "%(msg_encrypt)s",
|
||||
"msgsignature": "%(msg_signaturet)s",
|
||||
"timestamp": "%(timestamp)s",
|
||||
"nonce": "%(nonce)s"
|
||||
}'''
|
||||
|
||||
def extract(self, jsontext):
|
||||
"""提取出json数据包中的加密消息
|
||||
@param jsontext: 待提取的json字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
json_dict = json.loads(jsontext)
|
||||
return ierror.WXBizMsgCrypt_OK, json_dict['encrypt']
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_ParseJson_Error, None
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成json消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的json字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_json
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文(bytes类型)
|
||||
@return: 补齐明文字符串(bytes类型)
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = bytes([amount_to_pad])
|
||||
# 确保text是bytes类型
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
json_content = content[4 : json_len+4].decode('utf-8')
|
||||
from_receiveid = content[json_len+4:].decode('utf-8')
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
print("receiveid not match", receiveid, from_receiveid)
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,json_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
class WXBizJsonMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,json格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf-8')
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
jsonParse = JsonParse()
|
||||
return ret,jsonParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# json_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
jsonParse = JsonParse()
|
||||
ret,encrypt = jsonParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
print("signature not match")
|
||||
print(signature)
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,json_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,json_content
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
@@ -1,5 +0,0 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类(Python3以及以上版本使用 WXBizMsgCrypt3.py),提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
import json
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print("ERR: VerifyURL ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("done VerifyURL")
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print("==============================")
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print("ERR: EncryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sEncryptMsg)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
对上面加密的包进行解密
|
||||
'''
|
||||
sReqMsgSig = json.loads(sEncryptMsg)['msgsignature']
|
||||
sReqTimeStamp = json.loads(sEncryptMsg)['timestamp']
|
||||
sReqNonce = json.loads(sEncryptMsg)['nonce']
|
||||
|
||||
ret,sMsg=wxcpt.DecryptMsg( sEncryptMsg, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
@@ -1,280 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
import logging
|
||||
import base64
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import xml.etree.cElementTree as ET
|
||||
import socket
|
||||
|
||||
import ierror
|
||||
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
|
||||
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode())
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error, None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt': encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp': timestamp,
|
||||
'nonce': nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + (pad * amount_to_pad).encode()
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self, key):
|
||||
|
||||
# self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
# pkcs7 = PKCS7Encoder()
|
||||
# plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0])
|
||||
xml_content = content[4: xml_len + 4]
|
||||
from_receiveid = content[xml_len + 4:]
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer, None
|
||||
|
||||
if from_receiveid.decode('utf8') != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return 0, xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WXBizMsgCrypt(object):
|
||||
# 构造函数
|
||||
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey + "=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
# 验证URL
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sEchoStr: 随机串,对应URL参数的echostr
|
||||
# @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
# @return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
|
||||
return ret, sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
|
||||
# 将企业回复用户的消息加密打包
|
||||
# @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
# @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
# @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
# sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
|
||||
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf8')
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
xmlParse = XMLParse()
|
||||
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret, encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
|
||||
return ret, xml_content
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File conf.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-23
|
||||
#
|
||||
#
|
||||
|
||||
## 设置为true会打印一些调试信息
|
||||
DEBUG = True
|
||||
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
加解密方案说明
|
||||
概述
|
||||
企业微信在推送消息给企业时,会对消息内容做AES加密,以XML格式POST到企业应用的URL上。
|
||||
企业在被动响应时,也需要对数据加密,以XML格式返回给企业微信。
|
||||
本章节即是对加解密方法的说明。
|
||||
阅读本章节前,需要了解以下术语:
|
||||
|
||||
msg_signature: 消息签名,用于验证请求是否来自企业微信(防止攻击者伪造)。
|
||||
EncodingAESKey:用于消息体的加密,长度固定为43个字符,从a-z, A-Z, 0-9共62个字符中选取,是AESKey的Base64编码。解码后即为32字节长的AESKey
|
||||
|
||||
AESKey=Base64_Decode(EncodingAESKey + “=”)
|
||||
AESKey:AES算法的密钥,长度为32字节。
|
||||
AES采用CBC模式,数据采用PKCS#7填充至32字节的倍数;IV初始向量大小为16字节,取AESKey前16字节,详见:http://tools.ietf.org/html/rfc2315
|
||||
msg:为消息体明文,格式为XML/JSON
|
||||
msg_encrypt:明文消息msg加密处理后的Base64编码。
|
||||
使用已有库
|
||||
鉴于加解密算法相对复杂,企业微信提供了算法库。
|
||||
目前已有c++/python/php/java/golang/c#等语言版本。均提供了解密、加密、验证URL三个接口,企业可根据自身需要下载,下载地址。
|
||||
|
||||
使用现有库,用户不必细究加解密原理。对于找不到相应语言库的用户,请阅读后文原理详解自行实现。欢迎大家分享~
|
||||
以c++为例,使用示例见下载的文件夹中的Sample.cpp, 此处做简单说明。
|
||||
|
||||
初始化加解密类
|
||||
回调xml示例:
|
||||
|
||||
WXBizMsgCrypt wxcpt(sToken,sEncodingAESKey,sReceiveId);
|
||||
回调json示例
|
||||
|
||||
WXBizJsonMsgCrypt wxcpt(sToken,sEncodingAESKey,sReceiveId);
|
||||
要求传参数sToken,sEncodingAESKey,sReceiveId。
|
||||
sToken,sEncodingAESKey即设置接收消息的参数章节所述配置的Token、EncodingAESKey。
|
||||
特别注意, sReceiveId 在不同场景下有不同含义,见附注。
|
||||
|
||||
验证URL函数
|
||||
本函数实现:
|
||||
|
||||
签名校验
|
||||
解密数据包,得到明文消息内容
|
||||
int VerifyURL(const string &sMsgSignature, const string &sTimeStamp, const string &sNonce, const string &sEchoStr, string &sReplyEchoStr);
|
||||
参数说明
|
||||
参数 必须 说明
|
||||
sMsgSignature 是 从接收消息的URL中获取的msg_signature参数
|
||||
sTimeStamp 是 从接收消息的URL中获取的timestamp参数
|
||||
sNonce 是 从接收消息的URL中获取的nonce参数
|
||||
sEchoStr 是 从接收消息的URL中获取的echostr参数。注意,此参数必须是urldecode后的值
|
||||
sReplyEchoStr 是 解密后的明文消息内容,用于回包。注意,必须原样返回,不要做加引号或其它处理
|
||||
|
||||
|
||||
解密函数
|
||||
本函数实现:
|
||||
|
||||
签名校验
|
||||
解密数据包,得到明文消息结构体
|
||||
int DecryptMsg(const string &sMsgSignature, const string &sTimeStamp, const string &sNonce, const string &sPostData, string &sMsg);
|
||||
参数说明
|
||||
参数 必须 说明
|
||||
sMsgSignature 是 从接收消息的URL中获取的msg_signature参数
|
||||
sTimeStamp 是 从接收消息的URL中获取的timestamp参数
|
||||
sNonce 是 从接收消息的URL中获取的nonce参数
|
||||
sPostData 是 从接收消息的URL中获取的整个post数据
|
||||
sMsg 是 用于返回解密后的msg,以xml组织,参见普通消息格式和事件消息格式
|
||||
|
||||
|
||||
加密函数
|
||||
本函数实现:
|
||||
|
||||
加密明文消息结构体
|
||||
生成签名
|
||||
构造被动响应包
|
||||
int EncryptMsg(const string &sReplyMsg, const string &sTimeStamp, const string &sNonce, string &sEncryptMsg);
|
||||
参数说明
|
||||
参数 必须 说明
|
||||
sReplyMsg 是 返回的消息体原文
|
||||
sTimeStamp 是 时间戳,调用方生成
|
||||
sNonce 是 随机数,调用方生成
|
||||
sEncryptMsg 是 用于返回的密文,以xml组织,参见被动回复消息格式
|
||||
|
||||
|
||||
原理详解
|
||||
目前官方已提供了php、python、c++等版本的加解密库,如果开发者需要进行别的语言的开发,需要自行根据加解密原理实现算法。
|
||||
|
||||
消息体签名校验
|
||||
为了让企业确认调用来自企业微信,企业微信在回调给接收消息url时会带上消息签名,以参数msg_signature标识,企业需要验证此参数的正确性后再解密。
|
||||
验证步骤如下:
|
||||
|
||||
计算签名
|
||||
dev_msg_signature=sha1(sort(token、timestamp、nonce、msg_encrypt))。
|
||||
|
||||
|
||||
sort的含义是将参数值按照字母字典排序,然后从小到大拼接成一个字符串
|
||||
sha1处理结果要编码为可见字符,编码的方式是把每字节散列值打印为%02x(即16进制,C printf语法)格式,全部小写
|
||||
比较dev_msg_signature和msg_signature是否相等,相等则表示验证通过
|
||||
在被动响应消息时,企业同样需要用如上方法生成签名并传给企业微信
|
||||
|
||||
|
||||
明文msg的加密过程
|
||||
拼接明文字符串
|
||||
rand_msg = random(16B) + msg_len(4B) + msg + receiveid
|
||||
|
||||
|
||||
明文字符串由16个字节的随机字符串、4个字节的msg长度、明文msg和receiveid拼接组成。其中msg_len为msg的字节数,网络字节序;sReceiveId 在不同场景下有不同含义,见附注
|
||||
明文字符串
|
||||
对明文字符串加密并Base64编码
|
||||
msg_encrypt = Base64_Encode(AES_Encrypt(rand_msg))
|
||||
|
||||
|
||||
将明文字符串AESKey加密后,再进行Base64编码,即获得密文msg_encrypt。
|
||||
密文解密得到msg的过程
|
||||
对密文BASE64解码
|
||||
|
||||
aes_msg=Base64_Decode(msg_encrypt)
|
||||
|
||||
使用AESKey做AES-256-CBC解密
|
||||
|
||||
rand_msg=AES_Decrypt(aes_msg)
|
||||
|
||||
去掉rand_msg头部的16个随机字节和4个字节的msg_len,截取msg_len长度的部分即为msg,剩下的为尾部的receiveid
|
||||
验证解密后的receiveid、msg_len。注意,receiveid在不同场景含义不同。
|
||||
举例说明
|
||||
假设在服务商管理端为某个套件有如下配置参数:
|
||||
|
||||
corpId = "wx5823bf96d3bd56c7"
|
||||
token = "QDG6eK"
|
||||
encodingAesKey = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C"
|
||||
收到来自企业微信的回调为:
|
||||
xml请求示例:
|
||||
|
||||
POST /cgi-bin/wxpush?msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
Content-Length: 603
|
||||
<xml>
|
||||
<ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName>
|
||||
<Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt>
|
||||
<AgentID><![CDATA[218]]></AgentID>
|
||||
</xml>
|
||||
json请求示例:
|
||||
注意这里的 tousername,encrypt,agentid均为小写
|
||||
|
||||
POST /cgi-bin/wxpush?msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
Content-Length: 364
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "No8isRLoXqFMhLlpe7R/DA7UbJ88DKJxDhJH/UVG3o1ib0Fhzdd3qWYHH/KL1mITv5qOCp2FbyILqfI7zazrp/ARgSHR177OCrv8O9UrMHWdnOaMXaz+mLd5X5VWm5r2J3Qpm+NdTQRPhHbce88frKF3wqTaZunKW7ae87bRZUfaq5tLFnyTsf6aiy0su3SsQ06dQGKPcHfYHY3upB881008Q9t9xeAZ/uqfXpYQgSLQfaX+fk/K/FQEl4QpLk94eD1YjluFY3uLnKp40zDyxgeWRAmgTtmx1eLwediVqZ8=",
|
||||
"agentid": "218"
|
||||
}
|
||||
第一步:准备相关参数
|
||||
|
||||
AESKey = Base64_Decode(EncodingAESKey + "=")
|
||||
signature = "477715d11cdb4164915debcba66cb864d751f3e6";
|
||||
timestamps = "1409659813";
|
||||
nonce = "1372623149";
|
||||
msg_encrypt = "RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==";
|
||||
第二步:校验签名
|
||||
|
||||
token、timestamp、nonce、msg_encrypt 这四个参数按照字典序排序
|
||||
|
||||
"1372623149"
|
||||
"1409659813"
|
||||
"QDG6eK"
|
||||
"RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q=="
|
||||
|
||||
拼接为一个字符串
|
||||
|
||||
sort_str = "13726231491409659813QDG6eKRypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q=="
|
||||
|
||||
对该字符串进行sha1计算得到签名
|
||||
|
||||
signature = sha1(sort_str) = "477715d11cdb4164915debcba66cb864d751f3e6"
|
||||
|
||||
对比从URL得到的签名,发现两者一致,签名通过,说明没被篡改,是安全的
|
||||
第三步: 解密消息
|
||||
|
||||
对密文base64解码
|
||||
|
||||
aes_msg = base64_decode(msg_encrypt)
|
||||
|
||||
使用AESKey做AES解密(注意,不是EncodingAESKey)
|
||||
|
||||
rand_msg = aes_decrypt(aes_msg, AESKey)
|
||||
|
||||
去掉rand_msg头部的16个随机字节和4个字节的msg_len,截取msg_len长度的部分即为msg,剩下的为尾部的receiveid
|
||||
下面为类似python的伪代码
|
||||
|
||||
content = rand_msg[16:] # 去掉前16随机字节
|
||||
msg_len = str_to_uint(content[0:4]) # 取出4字节的msg_len
|
||||
msg = content[4:msg_len+4] # 截取msg_len 长度的msg
|
||||
receiveid = content[msg_len+4:] = "wx5823bf96d3bd56c7" # 剩余字节为receiveid
|
||||
|
||||
|
||||
对于回调xml解密后得到明文为:
|
||||
|
||||
<xml>
|
||||
<ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName>
|
||||
<FromUserName><![CDATA[mycreate]]></FromUserName>
|
||||
<CreateTime>1409659813</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[hello]]></Content>
|
||||
<MsgId>4561255354251345929</MsgId>
|
||||
<AgentID>218</AgentID>
|
||||
</xml>
|
||||
|
||||
对于回调json解密后的明文为:
|
||||
{
|
||||
"ToUserName": "wx5823bf96d3bd56c7",
|
||||
"FromUserName": "mycreate",
|
||||
"CreateTime": "1409659813",
|
||||
"MsgType": "text",
|
||||
"Content": "hello",
|
||||
"MsgId": "4561255354251345929",
|
||||
"AgentID": "218"
|
||||
}
|
||||
|
||||
|
||||
根据明文中的MsgType可知,此为应用消息回调,因此receiveid应该为corpid,对比receiveid与corpid是否一致。
|
||||
附注:ReceiveId 含义
|
||||
加解密库里,ReceiveId 在各个场景的含义不同:
|
||||
|
||||
企业应用的回调,表示corpid
|
||||
第三方事件的回调,表示suiteid
|
||||
机器人场景的回调,是一个空字符串
|
||||
@@ -1,758 +0,0 @@
|
||||
# 请求
|
||||
```
|
||||
fetch("https://zfcg.gxzf.gov.cn/portal/category", {
|
||||
"headers": {
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"priority": "u=1, i",
|
||||
"sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": "\"Windows\"",
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
"cookie": "_trs_uv=m9aqvdae_3820_kpq5; Hm_lvt_a013af4793f2380a4bcf49ca1ce393eb=1756429654; _zcy_log_client_uuid=78eb45c0-e9e6-11f0-b5f2-d1d9e72e9760",
|
||||
"Referer": "https://zfcg.gxzf.gov.cn/site/category?parentId=66485&childrenCode=ZcyAnnouncement"
|
||||
},
|
||||
"body": "{\"pageNo\":1,\"pageSize\":15,\"categoryCode\":\"61-266648\",\"keyword\":\"大化\",\"leaf\":0,\"_t\":1767863440000}",
|
||||
"method": "POST"
|
||||
});
|
||||
```
|
||||
|
||||
# 响应
|
||||
```
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"data": {
|
||||
"total": 824,
|
||||
"data": [
|
||||
{
|
||||
"articleId": "aMzhKRWZyOvYpGoEA4k/8g==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "广西壮族自治区大化公路养护中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "广西壮族自治区大化公路养护中心2026年3月至4月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767862531000,
|
||||
"districtCode": "459900",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "广西壮族自治区本级",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "广西壮族自治区大化公路养护中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "Qgm7DlkoLsuw6Asij2HL8w==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "中国共产党大化瑶族自治县委员会组织部",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "中国共产党大化瑶族自治县委员会组织部2026年2月至3月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767843666000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "中国共产党大化瑶族自治县委员会组织部",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "JDbi2frSwfK/OJGRRBYN6Q==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县妇幼保健院",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县妇幼保健院2026年2月至3月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767666771000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县妇幼保健院",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "mZ0mKKlc20JqxFQSxDzdMQ==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县文化广电体育和旅游局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县文化广电体育和旅游局2026年1月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767056616000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县文化广电体育和旅游局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "LZL8vkUWl20xzvY1dojGEQ==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县住房和城乡建设局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县住房和城乡建设局2025年12月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767002657000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县住房和城乡建设局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "JHc0AeCHUU5tXuy5jH5JhA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县生态移民发展中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县生态移民发展中心2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766543697000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县生态移民发展中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "tCLjD9yQJ0Sr+UqFs98MEw==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县审计局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县审计局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766475431000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县审计局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "Pvh5bmZ7PVLjb+0mR4OiVw==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县卫生健康局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县卫生健康局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766366941000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县卫生健康局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "QyGeeDyfVOLSNSj93XTLaw==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县林业局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县林业局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1765533855000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县林业局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "9MlnLLCnZ2pqKmnnEH5DjA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县第二中学",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县第二中学2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1765358076000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县第二中学",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "+KoOxEQ9eAfbYnYjtLNn2w==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县机关事务服务中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县机关事务服务中心2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1764920713000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县机关事务服务中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "VMGX21N42Cs8XvjMV4+M6Q==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县统计局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县统计局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1764814295000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县统计局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "no/tJYjf/2/6DYX6cfQasw==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县住房和城乡建设局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "(更正)大化瑶族自治县住房和城乡建设局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1764585519000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县住房和城乡建设局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "UAEtMQnIQnS1iS9ReRmuzA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县住房和城乡建设局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县住房和城乡建设局2025年11月至12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1763948631000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县住房和城乡建设局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "h3KdvP35dD5/n6FSZSgs9A==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "大化瑶族自治县卫生健康局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "大化瑶族自治县卫生健康局2025年11月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1763947257000,
|
||||
"districtCode": "451229",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "大化瑶族自治县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "大化瑶族自治县卫生健康局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
}
|
||||
],
|
||||
"empty": false
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
@@ -1,756 +0,0 @@
|
||||
# 请求
|
||||
```
|
||||
fetch("https://zfcg.gxzf.gov.cn/portal/category", {
|
||||
"headers": {
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"priority": "u=1, i",
|
||||
"sec-ch-ua": "\"Microsoft Edge\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": "\"Windows\"",
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
"cookie": "_trs_uv=m9aqvdae_3820_kpq5; Hm_lvt_a013af4793f2380a4bcf49ca1ce393eb=1756429654; _zcy_log_client_uuid=78eb45c0-e9e6-11f0-b5f2-d1d9e72e9760",
|
||||
"Referer": "https://zfcg.gxzf.gov.cn/site/category?parentId=66485&childrenCode=ZcyAnnouncement"
|
||||
},
|
||||
"body": "{\"pageNo\":1,\"pageSize\":15,\"categoryCode\":\"61-266648\",\"keyword\":\"陆川\",\"leaf\":0,\"_t\":1767861990000}",
|
||||
"method": "POST"
|
||||
});
|
||||
```
|
||||
|
||||
# 响应结果
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"data": {
|
||||
"total": 1800,
|
||||
"data": [
|
||||
{
|
||||
"articleId": "PflN8ihVcOhd1HJB1BXpkA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县清湖镇人民政府",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县清湖镇人民政府2026年1月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767860888000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县清湖镇人民政府",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "o/QTfE7PuyK+ksmVfpC0gA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县九洲江灌区工程工作站",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县九洲江灌区工程工作站2026年2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767843293000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县九洲江灌区工程工作站",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "0L1gNd0LQf25SDBdrauQWg==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县农业农村局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县农业农村局2026年1月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767775873000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县农业农村局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "UJS9NxI3k+pan+QoCqCQGw==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县交通运输局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县交通运输局2026年1月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767772596000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县交通运输局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "P+b9RIrFDYXIWp9rxb/Qww==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县疾病预防控制中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县疾病预防控制中心2026年1月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767685903000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县疾病预防控制中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "+33cxWTYsC0Z7ocmJ/4Fig==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县残疾人联合会",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县残疾人联合会2026年1月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767601634000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县残疾人联合会",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "yL/vpb8Ps4xVWjjtAH2AQA==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县残疾人联合会",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县残疾人联合会2026年1月至2月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767601629000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县残疾人联合会",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "NPSwqE4wzAOBpij0aU87lQ==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县第三人民医院",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县第三人民医院2026年1月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767599791000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县第三人民医院",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "48ZBE5aH/eRD3XOTTnuryg==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县九洲江灌区工程工作站",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县九洲江灌区工程工作站2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1767145858000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县九洲江灌区工程工作站",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "VgXUUw+j1K2biJRNZIvyug==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "广西壮族自治区陆川公路养护中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "(更正)广西壮族自治区陆川公路养护中心2026年2月至3月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766999239000,
|
||||
"districtCode": "459900",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "广西壮族自治区本级",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "广西壮族自治区陆川公路养护中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "Btx7e3AG3jN4w8lcBubadg==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "广西壮族自治区陆川公路养护中心",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "广西壮族自治区陆川公路养护中心2025年12月广西壮族自治区陆川公政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766997277000,
|
||||
"districtCode": "459900",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "广西壮族自治区本级",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "广西壮族自治区陆川公路养护中心",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "ALN3v9yJMtWCsIWHF0aU8Q==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县清湖镇人民政府",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县清湖镇人民政府2026年1月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766742383000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县清湖镇人民政府",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "CZyAN1P8IcF41AE+RBi4Vg==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县农业农村局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县农业农村局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766113136000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县农业农村局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "IzdiLTWoCMCDbKFvTJVNrg==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县水利工程工作站",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县 2026年中央水利发展资金小型水库工程维修养护项目施工2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1766029957000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县水利工程工作站",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
},
|
||||
{
|
||||
"articleId": "aa4s0Xf/1t0FGX2Sk915Ng==",
|
||||
"annId": null,
|
||||
"siteId": 61,
|
||||
"firstCode": null,
|
||||
"parentId": null,
|
||||
"secondCode": null,
|
||||
"author": "陆川县农业农村局",
|
||||
"cover": null,
|
||||
"path": null,
|
||||
"pathName": "采购意向公开",
|
||||
"title": "陆川县农业农村局2025年12月政府采购意向",
|
||||
"content": null,
|
||||
"publishDate": 1765770671000,
|
||||
"districtCode": "450922",
|
||||
"gpCatalogCode": null,
|
||||
"gpCatalogName": null,
|
||||
"procurementMethodCode": null,
|
||||
"procurementMethod": null,
|
||||
"bidOpeningTime": null,
|
||||
"projectCode": null,
|
||||
"projectName": null,
|
||||
"districtName": "陆川县",
|
||||
"districtNameList": null,
|
||||
"purchaseName": "陆川县农业农村局",
|
||||
"rankCategoryName": null,
|
||||
"encryptId": null,
|
||||
"invalid": 0,
|
||||
"invalidDate": null,
|
||||
"isRenew": null,
|
||||
"announcementType": null,
|
||||
"ownerShotDepartmentName": null,
|
||||
"budgetPrice": null,
|
||||
"supplierName": null,
|
||||
"totalContractAmount": null,
|
||||
"isReformation": null,
|
||||
"isReformationFlag": null,
|
||||
"isReformationEnglishAnnouncement": null,
|
||||
"nonGovmentFlag": null,
|
||||
"supportEnglish": null,
|
||||
"year": null,
|
||||
"monitorAmount": null,
|
||||
"smallAmount": null,
|
||||
"smallPercent": null,
|
||||
"stickLevel": 7258089600000,
|
||||
"isStickLevel": false,
|
||||
"remark": null
|
||||
}
|
||||
],
|
||||
"empty": false
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
Reference in New Issue
Block a user