├── image-1.png ├── image-2.png ├── image.png ├── requirements.txt ├── .gitignore ├── start.sh ├── static ├── js │ └── main.js └── css │ └── style.css ├── README.md ├── templates ├── login.html ├── change_password.html ├── settings.html └── index.html ├── scheduler.py ├── notifier.py ├── fix_config.py ├── user_lister.py ├── checker.py ├── user_creator.py ├── app.py ├── user_activation.py └── config_manager.py /image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaokun567/office365/HEAD/image-1.png -------------------------------------------------------------------------------- /image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaokun567/office365/HEAD/image-2.png -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiaokun567/office365/HEAD/image.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.0 2 | APScheduler==3.10.4 3 | requests==2.31.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | ENV/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # 配置文件(包含敏感信息) 27 | config.json 28 | config.json.bak 29 | config.json.tmp 30 | config.json.backup.* 31 | 32 | # 日志文件 33 | *.log 34 | 35 | # IDE 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *.swo 40 | *~ 41 | 42 | # macOS 43 | .DS_Store 44 | 45 | # 测试 46 | .pytest_cache/ 47 | .coverage 48 | htmlcov/ 49 | 50 | # 临时文件 51 | *.tmp 52 | *.temp 53 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 获取脚本所在目录 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | # 切换到脚本目录 7 | cd "$SCRIPT_DIR" 8 | 9 | echo "======================================" 10 | echo "Office 365 订阅监控系统" 11 | echo "======================================" 12 | echo "" 13 | echo "工作目录: $SCRIPT_DIR" 14 | echo "" 15 | echo "正在启动..." 16 | echo "" 17 | 18 | # 检查 Python 是否安装 19 | if ! command -v python3 &> /dev/null; then 20 | echo "❌ 错误: 未找到 Python3" 21 | echo "请先安装 Python 3.7 或更高版本" 22 | exit 1 23 | fi 24 | 25 | # 检查依赖是否安装 26 | if ! python3 -c "import flask" &> /dev/null; then 27 | echo "📦 正在安装依赖..." 28 | pip3 install -r requirements.txt 29 | fi 30 | 31 | echo "✅ 依赖检查完成" 32 | echo "" 33 | echo "🚀 启动服务..." 34 | echo "" 35 | echo "访问地址: http://localhost:5000" 36 | echo "默认密码: xiaokun567" 37 | echo "" 38 | echo "按 Ctrl+C 停止服务" 39 | echo "" 40 | 41 | # 启动应用 42 | python3 app.py 43 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // API 调用工具函数 2 | const API = { 3 | async getSubscriptions() { 4 | const response = await fetch('/api/subscriptions'); 5 | return await response.json(); 6 | }, 7 | 8 | async createSubscription(data) { 9 | const response = await fetch('/api/subscriptions', { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify(data) 15 | }); 16 | return await response.json(); 17 | }, 18 | 19 | async updateSubscription(id, data) { 20 | const response = await fetch(`/api/subscriptions/${id}`, { 21 | method: 'PUT', 22 | headers: { 23 | 'Content-Type': 'application/json' 24 | }, 25 | body: JSON.stringify(data) 26 | }); 27 | return await response.json(); 28 | }, 29 | 30 | async deleteSubscription(id) { 31 | const response = await fetch(`/api/subscriptions/${id}`, { 32 | method: 'DELETE' 33 | }); 34 | return await response.json(); 35 | }, 36 | 37 | async checkSubscription(id) { 38 | const response = await fetch(`/api/subscriptions/${id}/check`, { 39 | method: 'POST' 40 | }); 41 | return await response.json(); 42 | } 43 | }; 44 | 45 | // 工具函数 46 | const Utils = { 47 | formatDate(dateString) { 48 | if (!dateString) return '未知'; 49 | return new Date(dateString).toLocaleDateString('zh-CN'); 50 | }, 51 | 52 | formatDateTime(dateString) { 53 | if (!dateString) return '从未检测'; 54 | return new Date(dateString).toLocaleString('zh-CN'); 55 | }, 56 | 57 | showLoading(element) { 58 | element.disabled = true; 59 | element.dataset.originalText = element.textContent; 60 | element.textContent = '处理中...'; 61 | }, 62 | 63 | hideLoading(element) { 64 | element.disabled = false; 65 | element.textContent = element.dataset.originalText || '确定'; 66 | }, 67 | 68 | showAlert(message, type = 'info') { 69 | alert(message); 70 | } 71 | }; 72 | 73 | // 导出供 HTML 使用 74 | window.API = API; 75 | window.Utils = Utils; 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Office 365 订阅监控系统 2 | 3 | 一个轻量级的 Office 365 订阅状态监控系统,支持自动检测、手动检测和异常通知。 4 | 5 | ## 使用方法 6 | 7 | ### 查看许可 8 | 9 | 1、获取抓包数据-输入后到仪表盘检测进行更新许可数量。 10 | 11 | ### 添加用户 12 | 13 | 2、获取抓包数据-输入后到仪表盘选择许可添加用户。(需要设置填写 curl 值才能添加用户) 14 | 15 | ## 主要功能 16 | 17 | - 📊 订阅状态监控 18 | - 🔔 自定义 Webhook 通知 19 | - 👤 用户管理 20 | - 📱 设备激活查询 21 | - ⏰ 定时检测(每24小时) 22 | 23 | ## 通知触发条件 24 | 25 | 系统会在以下情况自动发送通知: 26 | 27 | 1. **订阅即将到期** - 剩余天数 ≤ 30天 28 | 2. **订阅已失效** - 订阅状态为失效 29 | 3. **Cookie 失效** - 认证失败,需要更新 Cookie 30 | 4. **登录密码错误** - 有人尝试使用错误密码登录 31 | 32 | ## 安装步骤 33 | 34 | 1. 安装依赖: 35 | ```bash 36 | pip install -r requirements.txt 37 | ``` 38 | 39 | 2. 运行程序: 40 | ```bash 41 | python app.py 42 | ``` 43 | 44 | 3. 访问系统: 45 | ``` 46 | http://localhost:5000 47 | ``` 48 | 49 | ## 默认登录信息 50 | 51 | - **默认密码**: `xiaokun567` 52 | - **首次登录后会强制要求修改密码** 53 | 54 | ## Webhook 配置 55 | 56 | 在设置页面配置 Webhook 通知: 57 | 58 | ### 配置示例 59 | 60 | **Webhook 地址:** 61 | ``` 62 | https://your-webhook-url.com/api/notify 63 | ``` 64 | 65 | **请求体 JSON 模板:** 66 | ```json 67 | { 68 | "title": "{title}", 69 | "text": "{通知消息}" 70 | } 71 | ``` 72 | 73 | ### 支持的变量 74 | 75 | - `{title}` - 标题(固定为"订阅监控通知") 76 | - `{content}` 或 `{通知消息}` - 通知内容 77 | 78 | ### 示例配置 79 | 80 | #### 1. 简单格式 81 | ```json 82 | { 83 | "title": "{title}", 84 | "text": "{content}" 85 | } 86 | ``` 87 | 88 | ## 订阅管理 89 | 90 | 获取 Curl 命令 91 | ### 许可证 92 | 1. 打开浏览器,访问 Microsoft 365 管理中心-许可证-许可点进去(多许可证就抓包这个搜索subscriptions)-管理订阅详细信息(单许可抓包这个搜索getSubscription) 93 | 2. 打开开发者工具(F12) 94 | 3. 切换到 Network(网络)标签 95 | 4. 刷新订阅页面,找到 `getSubscription - 单订阅模式` 请求或者subscriptions 请求 - 多许可证支持 96 | 5. 右键点击请求,选择 "Copy" -> "复制 curl (bash)格式" 97 | 6. 将复制的命令粘贴到添加订阅表单中 98 | ### 用户(可实现 web 界面快捷添加用户) 99 | 100 | 1.用户的 curl 抓包,到 Microsoft 365 管理中心 添加用户,配置用户名,勾选手动设置密码,勾选许可证。 101 | 2. 打开开发者工具(F12) 102 | 3. 切换到 Network(网络)标签 103 | 4. 刷新订阅页面,找到 ` user` 请求 104 | 5. 右键点击请求,选择 "Copy" -> "复制 curl (bash)格式" 105 | 6. 将复制的命令粘贴到添加订阅表单中 106 | 107 | ## 系统展示 108 | ![添加订阅和用户后](image.png) 109 | 110 | ![查询用户](image-1.png) 111 | 112 | ![查询用户激活设备](image-2.png) 113 | 114 | 其他就不展示了。 115 | ## 注意事项 116 | 117 | - Cookie 会过期,需要定期更新 118 | - 建议配置 Webhook 以便及时收到通知 119 | - 首次登录后请立即修改默认密码 120 | - 定时任务每24小时自动检测一次 121 | 122 | ## 技术栈 123 | 124 | - Flask - Web 框架 125 | - APScheduler - 定时任务 126 | - Requests - HTTP 请求 127 | - Bootstrap 5 - 前端UI 128 | 129 | ## 许可证 130 | 131 | MIT License 132 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 登录 - Office 365 订阅监控 7 | 8 | 70 | 71 | 72 |
73 |
74 |
🔒
75 |

Office 365 监控

76 |

请输入密码访问系统

77 |
78 | 79 | {% if error %} 80 | 83 | {% endif %} 84 | 85 |
86 |
87 | 88 | 90 |
91 | 92 |
93 | 94 |
95 | 默认密码: xiaokun567 96 |
97 |
98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /scheduler.py: -------------------------------------------------------------------------------- 1 | from apscheduler.schedulers.background import BackgroundScheduler 2 | from datetime import datetime 3 | 4 | 5 | class TaskScheduler: 6 | """定时任务调度器""" 7 | 8 | def __init__(self, checker, config_manager, notifier): 9 | self.checker = checker 10 | self.config_manager = config_manager 11 | self.notifier = notifier 12 | self.scheduler = BackgroundScheduler() 13 | 14 | def start(self): 15 | """启动定时任务""" 16 | # 获取检测间隔 17 | check_interval = self.config_manager.get_check_interval_hours() 18 | 19 | # 按配置的间隔执行检测 20 | self.scheduler.add_job( 21 | self.run_daily_check, 22 | 'interval', 23 | hours=check_interval, 24 | id='subscription_check', 25 | name=f'订阅检测(每{check_interval}小时)' 26 | ) 27 | self.scheduler.start() 28 | print(f"定时任务已启动,将每{check_interval}小时执行一次检测") 29 | 30 | def stop(self): 31 | """停止定时任务""" 32 | self.scheduler.shutdown() 33 | print("定时任务已停止") 34 | 35 | def run_daily_check(self): 36 | """执行每日检测""" 37 | print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 开始执行定时检测...") 38 | 39 | subscriptions = self.config_manager.get_all_subscriptions() 40 | 41 | if not subscriptions: 42 | print("没有配置的订阅项") 43 | return 44 | 45 | for sub in subscriptions: 46 | print(f"检测订阅: {sub['name']}") 47 | result = self.checker.check_subscription(sub['id']) 48 | 49 | if not result['success']: 50 | # 检测失败,发送通知 51 | error_type = result.get('error', '') 52 | error_message = result.get('message', '未知错误') 53 | 54 | if error_type == 'auth_failure': 55 | print(f" ❌ 认证失败") 56 | self.notifier.notify_auth_failure(sub['name']) 57 | elif error_type == 'network_error': 58 | print(f" ❌ 网络错误: {error_message}") 59 | # 网络错误通常是 Cookie 过期导致的 JSON 解析失败 60 | self.notifier.notify_auth_failure(sub['name']) 61 | elif error_type == 'timeout': 62 | print(f" ❌ 请求超时") 63 | # 超时也可能是认证问题 64 | self.notifier.notify_auth_failure(sub['name']) 65 | else: 66 | print(f" ❌ 检测失败: {error_message}") 67 | # 其他错误也发送通知 68 | self.notifier.notify_auth_failure(sub['name']) 69 | else: 70 | # 检测成功 71 | status = result.get('status', '') 72 | data = result.get('data', {}) 73 | 74 | if status == 'expired': 75 | print(f" ❌ 订阅已失效") 76 | self.notifier.notify_subscription_expired(sub['name']) 77 | else: 78 | # 检查是否即将到期 79 | expiration_date = data.get('expirationDate', '') 80 | if expiration_date: 81 | days_remaining = self.checker.calculate_days_remaining(expiration_date) 82 | 83 | # 获取自定义的到期提醒天数 84 | notification_config = self.config_manager.get_notification_config() 85 | warning_days = notification_config.get('expiration_warning_days', 30) 86 | 87 | if 0 < days_remaining <= warning_days: 88 | print(f" ⏰ 即将到期,剩余 {days_remaining} 天") 89 | self.notifier.notify_expiration_warning(sub['name'], days_remaining) 90 | else: 91 | print(f" ✅ 状态正常,剩余 {days_remaining} 天") 92 | else: 93 | print(f" ✅ 状态正常") 94 | 95 | print("定时检测完成\n") 96 | -------------------------------------------------------------------------------- /notifier.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from typing import Dict 4 | 5 | 6 | class Notifier: 7 | """通知服务 - 支持自定义 Webhook""" 8 | 9 | def __init__(self, config: Dict): 10 | self.webhook_url = config.get('webhook_url', '') 11 | self.webhook_json = config.get('webhook_json', '') 12 | 13 | def send_notification(self, message: str) -> bool: 14 | """发送通知""" 15 | if not self.webhook_url: 16 | print(f"[通知] Webhook 未配置,跳过发送") 17 | return False 18 | 19 | try: 20 | # 解析 JSON 模板 21 | if self.webhook_json and self.webhook_json.strip(): 22 | try: 23 | # 检查是否为空字符串 24 | if self.webhook_json.strip() == '': 25 | raise json.JSONDecodeError("Empty string", "", 0) 26 | 27 | # 先尝试直接解析 28 | payload = json.loads(self.webhook_json) 29 | 30 | # 替换消息变量 31 | payload_str = json.dumps(payload, ensure_ascii=False) 32 | payload_str = payload_str.replace('{title}', '订阅监控通知') 33 | payload_str = payload_str.replace('{content}', message) 34 | payload_str = payload_str.replace('{通知消息}', message) 35 | payload = json.loads(payload_str) 36 | 37 | except (json.JSONDecodeError, ValueError) as e: 38 | print(f"[通知] JSON 模板解析失败: {e}") 39 | print(f"[通知] 模板内容: {repr(self.webhook_json[:100] if len(self.webhook_json) > 100 else self.webhook_json)}") 40 | # 使用默认格式继续发送 41 | payload = { 42 | "title": "订阅监控通知", 43 | "text": message 44 | } 45 | else: 46 | # 默认格式 47 | payload = { 48 | "title": "订阅监控通知", 49 | "text": message 50 | } 51 | 52 | response = requests.post( 53 | self.webhook_url, 54 | json=payload, 55 | headers={'Content-Type': 'application/json'}, 56 | timeout=10 57 | ) 58 | 59 | if response.status_code == 200: 60 | print(f"[通知] 发送成功: {message[:50]}...") 61 | return True 62 | else: 63 | print(f"[通知] 发送失败,状态码: {response.status_code}") 64 | print(f"[通知] 响应: {response.text[:200]}") 65 | return False 66 | 67 | except Exception as e: 68 | print(f"[通知] 发送异常: {str(e)}") 69 | import traceback 70 | traceback.print_exc() 71 | return False 72 | 73 | def notify_auth_failure(self, subscription_name: str) -> bool: 74 | """通知认证失败(Cookie 过期)""" 75 | content = ( 76 | f"⚠️ Office 365 订阅监控告警\n\n" 77 | f"订阅名称: {subscription_name}\n" 78 | f"状态: Cookie 已失效\n" 79 | f"原因: 认证失败,请更新 Cookie 信息" 80 | ) 81 | return self.send_notification(content) 82 | 83 | def notify_subscription_expired(self, subscription_name: str) -> bool: 84 | """通知订阅失效""" 85 | content = ( 86 | f"❌ Office 365 订阅监控告警\n\n" 87 | f"订阅名称: {subscription_name}\n" 88 | f"状态: 订阅已失效\n" 89 | f"请及时处理" 90 | ) 91 | return self.send_notification(content) 92 | 93 | def notify_expiration_warning(self, subscription_name: str, days_remaining: int) -> bool: 94 | """通知即将到期""" 95 | content = ( 96 | f"⏰ Office 365 订阅监控提醒\n\n" 97 | f"订阅名称: {subscription_name}\n" 98 | f"状态: 即将到期\n" 99 | f"剩余天数: {days_remaining} 天\n" 100 | f"请及时续费" 101 | ) 102 | return self.send_notification(content) 103 | -------------------------------------------------------------------------------- /templates/change_password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 修改密码 - Office 365 订阅监控 7 | 8 | 70 | 71 | 72 |
73 |
74 |
🔑
75 |

修改密码

76 |

检测到您正在使用默认密码,请立即修改

77 |
78 | 79 | {% if error %} 80 | 83 | {% endif %} 84 | 85 | {% if success %} 86 | 90 | {% else %} 91 |
92 |
93 | 94 | 96 |
97 |
98 | 99 | 101 |
102 | 103 |
104 | {% endif %} 105 |
106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* 全局样式 */ 2 | body { 3 | background-color: #f8f9fa; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 5 | } 6 | 7 | /* 订阅卡片样式 */ 8 | .subscription-card { 9 | transition: all 0.3s ease; 10 | border: none; 11 | border-radius: 16px; 12 | overflow: hidden; 13 | } 14 | 15 | .subscription-card:hover { 16 | transform: translateY(-8px); 17 | box-shadow: 0 12px 24px rgba(102, 126, 234, 0.2) !important; 18 | } 19 | 20 | /* 进度条样式 */ 21 | .progress { 22 | border-radius: 12px; 23 | overflow: hidden; 24 | background-color: #e9ecef; 25 | } 26 | 27 | .progress-bar { 28 | font-weight: bold; 29 | font-size: 0.85rem; 30 | transition: width 0.6s ease; 31 | } 32 | 33 | /* 状态徽章 */ 34 | .badge { 35 | font-size: 0.85rem; 36 | padding: 0.35em 0.65em; 37 | } 38 | 39 | /* 卡片标题 */ 40 | .card-title { 41 | font-size: 1.1rem; 42 | font-weight: 600; 43 | } 44 | 45 | /* 按钮样式 */ 46 | .btn { 47 | border-radius: 8px; 48 | font-weight: 500; 49 | transition: all 0.3s ease; 50 | } 51 | 52 | .btn-primary { 53 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 54 | border: none; 55 | } 56 | 57 | .btn-primary:hover { 58 | transform: translateY(-2px); 59 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); 60 | } 61 | 62 | /* 导航栏 */ 63 | .navbar { 64 | box-shadow: 0 4px 12px rgba(0,0,0,0.1); 65 | } 66 | 67 | /* 空状态 */ 68 | #emptyState { 69 | background-color: white; 70 | border-radius: 8px; 71 | padding: 3rem; 72 | } 73 | 74 | /* 模态框 */ 75 | .modal-content { 76 | border-radius: 12px; 77 | border: none; 78 | } 79 | 80 | .modal-header { 81 | border-bottom: 1px solid #e9ecef; 82 | background-color: #f8f9fa; 83 | } 84 | 85 | /* 表单样式 */ 86 | .form-control { 87 | border-radius: 6px; 88 | } 89 | 90 | .form-control:focus { 91 | border-color: #0d6efd; 92 | box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); 93 | } 94 | 95 | /* 动画 */ 96 | @keyframes spin { 97 | from { 98 | transform: rotate(0deg); 99 | } 100 | to { 101 | transform: rotate(360deg); 102 | } 103 | } 104 | 105 | /* 响应式调整 */ 106 | @media (max-width: 768px) { 107 | .container { 108 | padding-left: 15px; 109 | padding-right: 15px; 110 | } 111 | 112 | .subscription-card { 113 | margin-bottom: 1rem; 114 | } 115 | } 116 | 117 | /* 文本颜色 */ 118 | .text-warning { 119 | color: #ffc107 !important; 120 | } 121 | 122 | .text-danger { 123 | color: #dc3545 !important; 124 | } 125 | 126 | .text-success { 127 | color: #198754 !important; 128 | } 129 | 130 | /* 卡片内容间距 */ 131 | .card-body h6 { 132 | font-size: 0.9rem; 133 | font-weight: 600; 134 | color: #6c757d; 135 | margin-bottom: 0.5rem; 136 | } 137 | 138 | .card-body p { 139 | font-size: 0.95rem; 140 | } 141 | 142 | /* 进度条高亮 */ 143 | .progress-bar.bg-danger { 144 | background-color: #dc3545 !important; 145 | } 146 | 147 | .progress-bar.bg-primary { 148 | background-color: #0d6efd !important; 149 | } 150 | 151 | /* 多许可证卡片样式 */ 152 | .subscription-card.border-info { 153 | border: 2px solid #0dcaf0 !important; 154 | box-shadow: 0 4px 12px rgba(13, 202, 240, 0.15); 155 | } 156 | 157 | .subscription-card.border-info:hover { 158 | box-shadow: 0 12px 24px rgba(13, 202, 240, 0.3) !important; 159 | } 160 | 161 | /* 多许可证徽章 */ 162 | .badge.bg-info { 163 | background-color: #0dcaf0 !important; 164 | color: #000; 165 | font-weight: 600; 166 | } 167 | 168 | /* 多许可证详情区域 */ 169 | .collapse .card-body { 170 | padding: 0.75rem; 171 | } 172 | 173 | .collapse .card-body .bg-white { 174 | transition: all 0.2s ease; 175 | } 176 | 177 | .collapse .card-body .bg-white:hover { 178 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 179 | transform: translateX(4px); 180 | } 181 | 182 | /* 多许可证信息文本 */ 183 | .text-info { 184 | color: #0dcaf0 !important; 185 | } 186 | 187 | .text-info strong { 188 | font-weight: 600; 189 | } 190 | 191 | /* 订阅详情按钮 */ 192 | .btn-outline-info { 193 | border-color: #0dcaf0; 194 | color: #0dcaf0; 195 | } 196 | 197 | .btn-outline-info:hover { 198 | background-color: #0dcaf0; 199 | border-color: #0dcaf0; 200 | color: #000; 201 | } 202 | 203 | /* 订阅ID徽章 */ 204 | .badge.bg-secondary { 205 | background-color: #6c757d !important; 206 | font-family: 'Courier New', monospace; 207 | } 208 | -------------------------------------------------------------------------------- /fix_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 配置文件修复工具 4 | 用于修复或重建损坏的 config.json 文件 5 | """ 6 | 7 | import json 8 | import os 9 | import sys 10 | from datetime import datetime 11 | 12 | def backup_config(config_path='config.json'): 13 | """备份现有配置文件""" 14 | if os.path.exists(config_path): 15 | backup_path = f"{config_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" 16 | try: 17 | import shutil 18 | shutil.copy(config_path, backup_path) 19 | print(f"✅ 已备份现有配置到: {backup_path}") 20 | return backup_path 21 | except Exception as e: 22 | print(f"⚠️ 备份失败: {e}") 23 | return None 24 | return None 25 | 26 | def create_default_config(config_path='config.json'): 27 | """创建默认配置文件""" 28 | default_config = { 29 | "subscriptions": [], 30 | "notification": { 31 | "webhook_url": "", 32 | "webhook_json": "", 33 | "expiration_warning_days": 30 34 | }, 35 | "login_password": "xiaokun567" 36 | } 37 | 38 | try: 39 | with open(config_path, 'w', encoding='utf-8') as f: 40 | json.dump(default_config, f, ensure_ascii=False, indent=2) 41 | print(f"✅ 已创建默认配置文件: {config_path}") 42 | return True 43 | except Exception as e: 44 | print(f"❌ 创建配置文件失败: {e}") 45 | return False 46 | 47 | def validate_config(config_path='config.json'): 48 | """验证配置文件""" 49 | try: 50 | with open(config_path, 'r', encoding='utf-8') as f: 51 | content = f.read().strip() 52 | 53 | if not content: 54 | print("❌ 配置文件为空") 55 | return False 56 | 57 | config = json.loads(content) 58 | 59 | # 检查必要字段 60 | required_fields = ['subscriptions', 'notification', 'login_password'] 61 | missing_fields = [field for field in required_fields if field not in config] 62 | 63 | if missing_fields: 64 | print(f"⚠️ 缺少必要字段: {', '.join(missing_fields)}") 65 | return False 66 | 67 | # 检查 notification 字段 68 | notification_fields = ['webhook_url', 'webhook_json', 'expiration_warning_days'] 69 | missing_notification = [field for field in notification_fields 70 | if field not in config.get('notification', {})] 71 | 72 | if missing_notification: 73 | print(f"⚠️ notification 缺少字段: {', '.join(missing_notification)}") 74 | return False 75 | 76 | print("✅ 配置文件格式正确") 77 | return True 78 | 79 | except FileNotFoundError: 80 | print("❌ 配置文件不存在") 81 | return False 82 | except json.JSONDecodeError as e: 83 | print(f"❌ JSON 格式错误: {e}") 84 | return False 85 | except Exception as e: 86 | print(f"❌ 验证失败: {e}") 87 | return False 88 | 89 | def fix_config(config_path='config.json'): 90 | """修复配置文件""" 91 | print("=" * 60) 92 | print("配置文件修复工具") 93 | print("=" * 60) 94 | print() 95 | 96 | # 检查配置文件 97 | if not os.path.exists(config_path): 98 | print(f"📝 配置文件不存在: {config_path}") 99 | print("正在创建默认配置...") 100 | if create_default_config(config_path): 101 | print() 102 | print("✅ 修复完成!") 103 | return True 104 | else: 105 | print() 106 | print("❌ 修复失败") 107 | return False 108 | 109 | # 验证配置文件 110 | print(f"🔍 检查配置文件: {config_path}") 111 | if validate_config(config_path): 112 | print() 113 | print("✅ 配置文件正常,无需修复") 114 | return True 115 | 116 | # 需要修复 117 | print() 118 | print("⚠️ 配置文件需要修复") 119 | 120 | # 询问用户 121 | response = input("是否备份并重建配置文件?(y/n): ").strip().lower() 122 | 123 | if response != 'y': 124 | print("❌ 用户取消操作") 125 | return False 126 | 127 | # 备份 128 | print() 129 | print("📦 备份现有配置...") 130 | backup_path = backup_config(config_path) 131 | 132 | # 重建 133 | print() 134 | print("📝 创建新的默认配置...") 135 | if create_default_config(config_path): 136 | print() 137 | print("=" * 60) 138 | print("✅ 修复完成!") 139 | print("=" * 60) 140 | print() 141 | print("注意事项:") 142 | print("1. 默认密码: xiaokun567") 143 | print("2. 需要重新配置 Webhook") 144 | print("3. 需要重新添加订阅") 145 | if backup_path: 146 | print(f"4. 旧配置已备份到: {backup_path}") 147 | print() 148 | return True 149 | else: 150 | print() 151 | print("❌ 修复失败") 152 | return False 153 | 154 | def main(): 155 | """主函数""" 156 | config_path = 'config.json' 157 | 158 | # 检查是否在正确的目录 159 | if not os.path.exists('app.py'): 160 | print("⚠️ 警告: 当前目录下没有 app.py 文件") 161 | print("请确保在项目根目录下运行此脚本") 162 | print() 163 | 164 | success = fix_config(config_path) 165 | sys.exit(0 if success else 1) 166 | 167 | if __name__ == '__main__': 168 | main() 169 | -------------------------------------------------------------------------------- /user_lister.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from typing import Dict, List 4 | 5 | 6 | class UserLister: 7 | """Office 365 用户查询器""" 8 | 9 | def __init__(self, config_manager): 10 | self.config_manager = config_manager 11 | 12 | def list_users(self, subscription_id: str) -> Dict: 13 | """查询 Office 365 用户列表""" 14 | subscription = self.config_manager.get_subscription(subscription_id) 15 | if not subscription: 16 | return { 17 | 'success': False, 18 | 'error': '订阅不存在' 19 | } 20 | 21 | # 检查是否配置了用户创建配置(使用相同的cookie) 22 | user_create_config = subscription.get('user_create_config') 23 | if not user_create_config: 24 | return { 25 | 'success': False, 26 | 'error': '该订阅未配置用户创建功能,无法查询用户' 27 | } 28 | 29 | try: 30 | # 使用用户创建配置中的认证信息 31 | headers = user_create_config['headers'].copy() 32 | headers['content-type'] = 'application/json' 33 | 34 | cookies_str = user_create_config['cookies'] 35 | 36 | # 如果用户创建配置的 cookie 为空,尝试使用订阅配置的 cookie 37 | if not cookies_str or cookies_str.strip() == '': 38 | cookies_str = subscription.get('cookies', '') 39 | if cookies_str: 40 | print(f"[查询用户] 用户创建配置的 cookie 为空,使用订阅配置的 cookie") 41 | else: 42 | return { 43 | 'success': False, 44 | 'error': '未找到有效的 Cookie 配置' 45 | } 46 | 47 | # 将 cookies 字符串转换为字典 48 | cookies = {} 49 | if cookies_str: 50 | for cookie in cookies_str.split('; '): 51 | if '=' in cookie: 52 | key, value = cookie.split('=', 1) 53 | cookies[key] = value 54 | 55 | # 构建查询用户的 API URL 56 | api_url = user_create_config['api_url'] 57 | parts = api_url.split('/') 58 | base_url = f"{parts[0]}//{parts[2]}" 59 | list_users_url = f"{base_url}/admin/api/Users/ListUsers" 60 | 61 | print(f"[查询用户] API URL: {list_users_url}") 62 | 63 | # 构建请求体 64 | payload = { 65 | "ListAction": -1, 66 | "SortDirection": 0, 67 | "ListContext": None, 68 | "SortPropertyName": "DisplayName", 69 | "SearchText": "", 70 | "SelectedView": "", 71 | "SelectedViewType": "", 72 | "ServerContext": None, 73 | "MSGraphFilter": { 74 | "skuIds": [], 75 | "locations": [], 76 | "domains": [] 77 | } 78 | } 79 | 80 | print(f"[查询用户] 发送请求...") 81 | 82 | # 发送请求 83 | response = requests.post( 84 | list_users_url, 85 | headers=headers, 86 | cookies=cookies, 87 | json=payload, 88 | timeout=30 89 | ) 90 | 91 | print(f"[查询用户] 响应状态码: {response.status_code}") 92 | 93 | # 检查响应状态 94 | if response.status_code == 401 or response.status_code == 403: 95 | return { 96 | 'success': False, 97 | 'error': '认证失败,Cookie 可能已过期' 98 | } 99 | 100 | if response.status_code not in [200, 201]: 101 | return { 102 | 'success': False, 103 | 'error': f'API 返回错误状态码: {response.status_code}', 104 | 'details': response.text[:500] 105 | } 106 | 107 | # 解析响应 108 | data = response.json() 109 | 110 | users = data.get('Users', []) 111 | metadata = data.get('MetaData', {}) 112 | 113 | print(f"[查询用户] 查询到 {len(users)} 个用户") 114 | 115 | # 提取用户信息 116 | user_list = [] 117 | for user in users: 118 | user_list.append({ 119 | 'object_id': user.get('ObjectId', ''), 120 | 'display_name': user.get('DisplayName', ''), 121 | 'user_principal_name': user.get('UserPrincipalName', ''), 122 | 'email': user.get('Mail', ''), 123 | 'licenses': user.get('Licenses', ''), 124 | 'has_license': user.get('HasLicense', False), 125 | 'signin_status': user.get('SigninStatus', ''), 126 | 'created_time': user.get('CreatedTime', ''), 127 | 'usage_location': user.get('UsageLocation', ''), 128 | 'first_name': user.get('FirstName', ''), 129 | 'last_name': user.get('LastName', ''), 130 | 'job_title': user.get('JobTitle', ''), 131 | 'department': user.get('Department', ''), 132 | 'mobile_phone': user.get('MobilePhone', ''), 133 | 'business_phones': user.get('BusinessPhones', '') 134 | }) 135 | 136 | return { 137 | 'success': True, 138 | 'data': { 139 | 'users': user_list, 140 | 'total_count': metadata.get('DataCount', len(users)), 141 | 'is_last_page': metadata.get('IsLastPage', True) 142 | } 143 | } 144 | 145 | except requests.exceptions.Timeout: 146 | return { 147 | 'success': False, 148 | 'error': '请求超时' 149 | } 150 | except requests.exceptions.RequestException as e: 151 | return { 152 | 'success': False, 153 | 'error': f'网络错误: {str(e)}' 154 | } 155 | except Exception as e: 156 | return { 157 | 'success': False, 158 | 'error': f'未知错误: {str(e)}' 159 | } 160 | -------------------------------------------------------------------------------- /checker.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime 3 | from typing import Dict, Optional 4 | 5 | 6 | class SubscriptionChecker: 7 | """订阅检测器""" 8 | 9 | def __init__(self, config_manager): 10 | self.config_manager = config_manager 11 | 12 | def check_subscription(self, subscription_id: str) -> Dict: 13 | """检测订阅状态""" 14 | subscription = self.config_manager.get_subscription(subscription_id) 15 | if not subscription: 16 | return { 17 | 'success': False, 18 | 'error': '订阅不存在' 19 | } 20 | 21 | try: 22 | # 准备请求 23 | headers = subscription['headers'] 24 | cookies_str = subscription['cookies'] 25 | 26 | # 如果订阅配置的 cookie 为空,尝试使用用户创建配置的 cookie 27 | if not cookies_str or cookies_str.strip() == '': 28 | user_create_config = subscription.get('user_create_config') 29 | if user_create_config and user_create_config.get('cookies'): 30 | cookies_str = user_create_config['cookies'] 31 | print(f"[检测订阅] 使用用户创建配置的 cookie") 32 | 33 | # 将 cookies 字符串转换为字典 34 | cookies = {} 35 | if cookies_str: 36 | for cookie in cookies_str.split('; '): 37 | if '=' in cookie: 38 | key, value = cookie.split('=', 1) 39 | cookies[key] = value 40 | 41 | # 发送请求 42 | response = requests.get( 43 | subscription['api_url'], 44 | headers=headers, 45 | cookies=cookies, 46 | timeout=30 47 | ) 48 | 49 | # 检查响应状态 50 | if response.status_code == 401 or response.status_code == 403: 51 | self.config_manager.update_subscription_status( 52 | subscription_id, 53 | 'error', 54 | None, 55 | 'auth_failure' 56 | ) 57 | return { 58 | 'success': False, 59 | 'error': 'auth_failure', 60 | 'message': '认证失败,Cookie 可能已过期' 61 | } 62 | 63 | if response.status_code != 200: 64 | self.config_manager.update_subscription_status( 65 | subscription_id, 66 | 'error', 67 | None, 68 | 'api_error' 69 | ) 70 | return { 71 | 'success': False, 72 | 'error': 'api_error', 73 | 'message': f'API 返回错误状态码: {response.status_code}' 74 | } 75 | 76 | # 解析响应 77 | data = response.json() 78 | parsed_data = self.parse_response(data) 79 | 80 | # 判断订阅状态 81 | status = 'active' if parsed_data.get('state') == 'Active' else 'expired' 82 | 83 | # 更新配置 84 | self.config_manager.update_subscription_status( 85 | subscription_id, 86 | status, 87 | parsed_data 88 | ) 89 | 90 | return { 91 | 'success': True, 92 | 'data': parsed_data, 93 | 'status': status 94 | } 95 | 96 | except requests.exceptions.Timeout: 97 | self.config_manager.update_subscription_status( 98 | subscription_id, 99 | 'error', 100 | None, 101 | 'timeout' 102 | ) 103 | return { 104 | 'success': False, 105 | 'error': 'timeout', 106 | 'message': '请求超时' 107 | } 108 | except requests.exceptions.RequestException as e: 109 | self.config_manager.update_subscription_status( 110 | subscription_id, 111 | 'error', 112 | None, 113 | 'network_error' 114 | ) 115 | return { 116 | 'success': False, 117 | 'error': 'network_error', 118 | 'message': f'网络错误: {str(e)}' 119 | } 120 | except Exception as e: 121 | self.config_manager.update_subscription_status( 122 | subscription_id, 123 | 'error', 124 | None, 125 | 'unknown_error' 126 | ) 127 | return { 128 | 'success': False, 129 | 'error': 'unknown_error', 130 | 'message': f'未知错误: {str(e)}' 131 | } 132 | 133 | def parse_response(self, response_json: Dict) -> Dict: 134 | """解析 API 响应 - 兼容新旧两种接口""" 135 | # 检查是否是订阅列表响应(新接口 - 多许可证支持) 136 | if '@odata.context' in response_json and 'value' in response_json: 137 | return self.parse_subscriptions_list(response_json) 138 | 139 | # 单个订阅响应(旧接口) 140 | parsed = { 141 | 'name': response_json.get('name', ''), 142 | 'totalLicenses': 0, 143 | 'consumedUnits': 0, 144 | 'expirationDate': response_json.get('expirationDate', ''), 145 | 'state': response_json.get('state', ''), 146 | 'skuPartNumber': '', 147 | 'is_multi_license': False, 148 | 'api_type': 'single' # 标记为旧接口 149 | } 150 | 151 | # 获取许可证信息 152 | parsed['totalLicenses'] = response_json.get('totalLicenses', 0) 153 | 154 | # 从 subscribedSku 获取已使用数量 155 | subscribed_sku = response_json.get('subscribedSku', {}) 156 | if subscribed_sku: 157 | parsed['consumedUnits'] = subscribed_sku.get('consumedUnits', 0) 158 | parsed['skuPartNumber'] = subscribed_sku.get('skuPartNumber', '') 159 | 160 | return parsed 161 | 162 | def parse_subscriptions_list(self, response_json: Dict) -> Dict: 163 | """解析订阅列表响应 - 处理多许可证情况(新接口)""" 164 | subscriptions = response_json.get('value', []) 165 | 166 | if not subscriptions: 167 | return { 168 | 'name': '', 169 | 'totalLicenses': 0, 170 | 'consumedUnits': 0, 171 | 'expirationDate': '', 172 | 'state': '', 173 | 'skuPartNumber': '', 174 | 'is_multi_license': False, 175 | 'api_type': 'list' 176 | } 177 | 178 | # 按 skuId 分组订阅 179 | sku_groups = {} 180 | for sub in subscriptions: 181 | # 只处理活跃状态的订阅 182 | if sub.get('state') != 'Active': 183 | continue 184 | 185 | subscribed_sku = sub.get('subscribedSku') 186 | if not subscribed_sku: 187 | continue 188 | 189 | sku_id = subscribed_sku.get('skuId') 190 | if not sku_id: 191 | continue 192 | 193 | if sku_id not in sku_groups: 194 | sku_groups[sku_id] = [] 195 | sku_groups[sku_id].append(sub) 196 | 197 | # 如果没有活跃订阅,检查是否有非活跃订阅 198 | if not sku_groups: 199 | # 尝试获取第一个订阅的信息 200 | first_sub = subscriptions[0] if subscriptions else {} 201 | return { 202 | 'name': first_sub.get('name', ''), 203 | 'totalLicenses': first_sub.get('totalLicenses', 0), 204 | 'consumedUnits': 0, 205 | 'expirationDate': first_sub.get('expirationDate', ''), 206 | 'state': first_sub.get('state', 'Inactive'), 207 | 'skuPartNumber': first_sub.get('subscribedSku', {}).get('skuPartNumber', '') if first_sub.get('subscribedSku') else '', 208 | 'is_multi_license': False, 209 | 'api_type': 'list' 210 | } 211 | 212 | # 选择许可证数量最多的 SKU 组 213 | main_sku_id = max(sku_groups.keys(), key=lambda k: len(sku_groups[k])) 214 | main_subscriptions = sku_groups[main_sku_id] 215 | 216 | # 判断是否为多许可证 217 | is_multi = len(main_subscriptions) > 1 218 | 219 | # 聚合数据 220 | first_sub = main_subscriptions[0] 221 | subscribed_sku = first_sub.get('subscribedSku', {}) 222 | 223 | # 计算总许可证数(每个订阅的许可证数) 224 | total_licenses_per_sub = first_sub.get('totalLicenses', 0) 225 | total_subscriptions = len(main_subscriptions) 226 | 227 | # 从 subscribedSku 获取实际的总许可证数和消费数 228 | actual_total = subscribed_sku.get('prepaidUnits', {}).get('enabled', 0) 229 | consumed_units = subscribed_sku.get('consumedUnits', 0) 230 | 231 | # 找到最晚的到期日期 232 | expiration_dates = [sub.get('expirationDate', '') for sub in main_subscriptions if sub.get('expirationDate')] 233 | latest_expiration = max(expiration_dates) if expiration_dates else '' 234 | 235 | parsed = { 236 | 'name': first_sub.get('name', ''), 237 | 'totalLicenses': actual_total, # 使用实际总数 238 | 'consumedUnits': consumed_units, 239 | 'expirationDate': latest_expiration, 240 | 'state': 'Active', 241 | 'skuPartNumber': subscribed_sku.get('skuPartNumber', ''), 242 | 'is_multi_license': is_multi, 243 | 'api_type': 'list' # 标记为新接口 244 | } 245 | 246 | # 如果是多许可证,添加详细信息 247 | if is_multi: 248 | parsed['multi_license_info'] = { 249 | 'subscription_count': total_subscriptions, 250 | 'licenses_per_subscription': total_licenses_per_sub, 251 | 'subscriptions': [ 252 | { 253 | 'id': sub.get('id'), 254 | 'order_id': sub.get('orderId'), 255 | 'licenses': sub.get('totalLicenses', 0), 256 | 'expiration_date': sub.get('expirationDate', ''), 257 | 'start_date': sub.get('startDate', '') 258 | } 259 | for sub in main_subscriptions 260 | ] 261 | } 262 | 263 | return parsed 264 | 265 | def calculate_days_remaining(self, expiration_date: str) -> int: 266 | """计算剩余天数""" 267 | if not expiration_date: 268 | return 0 269 | 270 | try: 271 | exp_date = datetime.fromisoformat(expiration_date.replace('Z', '+00:00')) 272 | now = datetime.now(exp_date.tzinfo) 273 | delta = exp_date - now 274 | return max(0, delta.days) 275 | except Exception: 276 | return 0 277 | 278 | def calculate_usage_percentage(self, consumed: int, total: int) -> float: 279 | """计算使用百分比""" 280 | if total == 0: 281 | return 0.0 282 | return round((consumed / total) * 100, 1) 283 | -------------------------------------------------------------------------------- /user_creator.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from typing import Dict, Optional 4 | 5 | 6 | class UserCreator: 7 | """Office 365 用户创建器""" 8 | 9 | def __init__(self, config_manager): 10 | self.config_manager = config_manager 11 | 12 | def _assign_license(self, base_url: str, headers: dict, cookies: dict, 13 | object_id: str, subscription: dict) -> Dict: 14 | """为用户分配许可证""" 15 | try: 16 | # 从订阅数据中获取可用的许可证 17 | subscription_data = subscription.get('subscription_data', {}) 18 | 19 | # 查找有可用许可证的产品 20 | available_licenses = [] 21 | for product in subscription_data.get('Skus', []): 22 | sku_id = product.get('SkuId') 23 | available = product.get('Available', 0) 24 | 25 | if sku_id and available > 0: 26 | available_licenses.append({ 27 | 'SkuId': sku_id, 28 | 'SkuPartNumber': product.get('SkuPartNumber', ''), 29 | 'Available': available 30 | }) 31 | 32 | if not available_licenses: 33 | return { 34 | 'success': False, 35 | 'message': '没有可用的许可证' 36 | } 37 | 38 | # 使用第一个可用的许可证 39 | license_to_assign = available_licenses[0] 40 | print(f"[分配许可证] 使用许可证: {license_to_assign['SkuPartNumber']} (SKU: {license_to_assign['SkuId']})") 41 | 42 | # 构建分配许可证的 API URL 43 | assign_license_url = f"{base_url}/admin/api/users/{object_id}/assignlicense" 44 | 45 | # 构建请求体 46 | payload = { 47 | "AddLicenses": [{ 48 | "SkuId": license_to_assign['SkuId'], 49 | "DisabledServicePlans": [] 50 | }], 51 | "RemoveLicenses": [] 52 | } 53 | 54 | print(f"[分配许可证] API URL: {assign_license_url}") 55 | print(f"[分配许可证] Payload: {json.dumps(payload, ensure_ascii=False)}") 56 | 57 | # 发送请求 58 | response = requests.post( 59 | assign_license_url, 60 | headers=headers, 61 | cookies=cookies, 62 | json=payload, 63 | timeout=30 64 | ) 65 | 66 | print(f"[分配许可证] 响应状态码: {response.status_code}") 67 | print(f"[分配许可证] 响应内容: {response.text[:500]}") 68 | 69 | if response.status_code in [200, 201, 204]: 70 | return { 71 | 'success': True, 72 | 'message': f'已分配许可证: {license_to_assign["SkuPartNumber"]}' 73 | } 74 | else: 75 | return { 76 | 'success': False, 77 | 'message': f'分配许可证失败 (状态码: {response.status_code})' 78 | } 79 | 80 | except Exception as e: 81 | print(f"[分配许可证] 错误: {str(e)}") 82 | return { 83 | 'success': False, 84 | 'message': f'分配许可证时出错: {str(e)}' 85 | } 86 | 87 | def create_user(self, subscription_id: str, username: str, password: str) -> Dict: 88 | """创建 Office 365 用户""" 89 | subscription = self.config_manager.get_subscription(subscription_id) 90 | if not subscription: 91 | return { 92 | 'success': False, 93 | 'error': '订阅不存在' 94 | } 95 | 96 | # 检查是否配置了用户创建配置 97 | user_create_config = subscription.get('user_create_config') 98 | if not user_create_config: 99 | return { 100 | 'success': False, 101 | 'error': '该订阅未配置用户创建功能,请在设置中添加用户创建配置' 102 | } 103 | 104 | try: 105 | # 使用用户创建配置中的信息 106 | headers = user_create_config['headers'].copy() 107 | headers['content-type'] = 'application/json' 108 | 109 | cookies_str = user_create_config['cookies'] 110 | 111 | # 如果用户创建配置的 cookie 为空,尝试使用订阅配置的 cookie 112 | if not cookies_str or cookies_str.strip() == '': 113 | cookies_str = subscription.get('cookies', '') 114 | if cookies_str: 115 | print(f"[创建用户] 用户创建配置的 cookie 为空,使用订阅配置的 cookie") 116 | else: 117 | return { 118 | 'success': False, 119 | 'error': '未找到有效的 Cookie 配置,请检查订阅配置或用户创建配置' 120 | } 121 | 122 | # 将 cookies 字符串转换为字典 123 | cookies = {} 124 | if cookies_str: 125 | for cookie in cookies_str.split('; '): 126 | if '=' in cookie: 127 | key, value = cookie.split('=', 1) 128 | cookies[key] = value 129 | 130 | # 使用用户创建配置中的 API URL 131 | create_user_url = user_create_config['api_url'] 132 | 133 | # 提取基础 URL 134 | parts = create_user_url.split('/') 135 | base_url = f"{parts[0]}//{parts[2]}" 136 | 137 | print(f"[创建用户] API URL: {create_user_url}") 138 | 139 | # 从用户创建配置的原始 curl 命令中提取请求体 140 | user_create_curl = subscription.get('user_create_curl', '') 141 | 142 | if not user_create_curl or user_create_curl.strip() == '': 143 | print(f"[创建用户] 错误: 未配置用户创建 curl 命令") 144 | return { 145 | 'success': False, 146 | 'error': '该订阅未配置用户创建 curl 命令,请在设置中添加完整的用户创建 curl 命令(包含 --data-raw 参数)' 147 | } 148 | 149 | # 提取 --data-raw 后的 JSON 数据 150 | import re 151 | 152 | print(f"[创建用户] curl 命令长度: {len(user_create_curl)}") 153 | print(f"[创建用户] curl 命令前100字符: {user_create_curl[:100]}") 154 | 155 | # 尝试多种模式匹配 156 | data_match = re.search(r'--data-raw\s+\$?\'(.+?)\'(?:\s+-|$)', user_create_curl, re.DOTALL) 157 | if not data_match: 158 | data_match = re.search(r'--data-raw\s+\$?"(.+?)"(?:\s+-|$)', user_create_curl, re.DOTALL) 159 | if not data_match: 160 | data_match = re.search(r'--data-raw\s+(.+?)(?:\s+-H|\s+--|\s+$)', user_create_curl, re.DOTALL) 161 | 162 | if not data_match: 163 | print(f"[创建用户] 错误: 无法从 curl 命令中提取请求体") 164 | print(f"[创建用户] 提示: 请确保 curl 命令包含 --data-raw 参数") 165 | return { 166 | 'success': False, 167 | 'error': '无法从用户创建 curl 命令中提取请求体数据,请确保 curl 命令包含 --data-raw 参数' 168 | } 169 | 170 | # 解析原始请求体 171 | raw_data = data_match.group(1).strip() 172 | print(f"[创建用户] 提取到的数据长度: {len(raw_data)}") 173 | 174 | # 处理转义字符 - 将 \r\n 等转义序列替换为实际字符 175 | # 但保留 JSON 中的合法转义(如 \") 176 | try: 177 | # 先尝试直接解析 178 | template_data = json.loads(raw_data) 179 | print(f"[创建用户] 成功解析模板数据") 180 | except json.JSONDecodeError as e: 181 | # 如果失败,尝试处理转义字符 182 | print(f"[创建用户] 首次解析失败,尝试处理转义字符") 183 | try: 184 | # 使用 Python 的字符串字面量解析来处理转义 185 | # 将字符串用引号包裹后用 ast.literal_eval 解析 186 | import codecs 187 | decoded_data = codecs.decode(raw_data, 'unicode_escape') 188 | template_data = json.loads(decoded_data) 189 | print(f"[创建用户] 处理转义后成功解析") 190 | except Exception as e2: 191 | print(f"[创建用户] 错误: 解析 JSON 失败 - {str(e)}") 192 | print(f"[创建用户] 原始数据前200字符: {raw_data[:200]}") 193 | print(f"[创建用户] 错误位置附近: {raw_data[max(0, e.pos-50):min(len(raw_data), e.pos+50)]}") 194 | return { 195 | 'success': False, 196 | 'error': f'用户创建配置中的请求体格式不正确: {str(e)}' 197 | } 198 | 199 | # 从模板中提取域名 200 | template_upn = template_data.get('UserPrincipalName', '') 201 | if '@' in template_upn: 202 | domain = template_upn.split('@')[1] 203 | else: 204 | domain = "dfem.net" # 默认域名 205 | 206 | user_principal_name = f"{username}@{domain}" 207 | 208 | print(f"[创建用户] 用户邮箱: {user_principal_name}") 209 | print(f"[创建用户] 使用域名: {domain}") 210 | 211 | # 使用模板中的 Products 和 AdminRoles 212 | products = template_data.get('Products', []) 213 | admin_roles = template_data.get('AdminRoles', []) 214 | 215 | # 构建请求体 - 使用模板结构 216 | payload = { 217 | "FirstName": template_data.get('FirstName', ''), 218 | "JobTitle": template_data.get('JobTitle', ''), 219 | "LastName": template_data.get('LastName', ''), 220 | "DisplayName": username, 221 | "UserPrincipalName": user_principal_name, 222 | "Office": template_data.get('Office', ''), 223 | "OfficePhone": template_data.get('OfficePhone', ''), 224 | "MobilePhone": template_data.get('MobilePhone', ''), 225 | "FaxNumber": template_data.get('FaxNumber', ''), 226 | "City": template_data.get('City', ''), 227 | "CountryRegion": template_data.get('CountryRegion', ''), 228 | "StateProvince": template_data.get('StateProvince', ''), 229 | "Department": template_data.get('Department', ''), 230 | "StreetAddress": template_data.get('StreetAddress', ''), 231 | "ZipOrPostalCode": template_data.get('ZipOrPostalCode', ''), 232 | "ForceChangePassword": True, 233 | "SendPasswordEmail": False, 234 | "Password": password, 235 | "AdminRoles": admin_roles, 236 | "UsageLocation": template_data.get('UsageLocation', 'CN'), 237 | "Products": products, 238 | "CreateUserWithNoLicense": template_data.get('CreateUserWithNoLicense', False) 239 | } 240 | 241 | if products: 242 | product_names = [p.get('SkuPartNumber', p.get('ProductSkuId', '')) for p in products if isinstance(p, dict)] 243 | print(f"[创建用户] 将分配许可证: {product_names}") 244 | 245 | print(f"[创建用户] 发送请求...") 246 | print(f"[创建用户] Payload: {json.dumps(payload, ensure_ascii=False)[:200]}...") 247 | 248 | # 发送请求 249 | response = requests.post( 250 | create_user_url, 251 | headers=headers, 252 | cookies=cookies, 253 | json=payload, 254 | timeout=30 255 | ) 256 | 257 | print(f"[创建用户] 响应状态码: {response.status_code}") 258 | print(f"[创建用户] 响应内容: {response.text[:500]}") 259 | 260 | # 检查响应状态 261 | if response.status_code == 401 or response.status_code == 403: 262 | return { 263 | 'success': False, 264 | 'error': 'auth_failure', 265 | 'message': '认证失败,Cookie 可能已过期' 266 | } 267 | 268 | # 200 和 201 都是成功状态码 269 | if response.status_code not in [200, 201]: 270 | try: 271 | error_data = response.json() 272 | error_message = error_data.get('Message', '') or f"Code: {error_data.get('Code', 'unknown')}" 273 | return { 274 | 'success': False, 275 | 'error': 'api_error', 276 | 'message': f'API 返回错误状态码: {response.status_code}', 277 | 'details': error_message, 278 | 'response_data': error_data 279 | } 280 | except: 281 | return { 282 | 'success': False, 283 | 'error': 'api_error', 284 | 'message': f'API 返回错误状态码: {response.status_code}', 285 | 'details': response.text[:500] 286 | } 287 | 288 | # 解析响应 289 | data = response.json() 290 | 291 | print(f"[创建用户] 响应数据: {json.dumps(data, ensure_ascii=False)}") 292 | 293 | # 检查是否创建成功 294 | if data.get('Status') == 0: # 0 表示成功 295 | user_info = data.get('UserInfo', {}) 296 | object_id = user_info.get('ObjectId', '') 297 | 298 | # 提取许可证名称 299 | license_names = [] 300 | if products: 301 | for p in products: 302 | if isinstance(p, dict): 303 | license_names.append(p.get('SkuPartNumber', p.get('ProductSkuId', ''))) 304 | 305 | return { 306 | 'success': True, 307 | 'data': { 308 | 'username': username, 309 | 'user_principal_name': user_principal_name, 310 | 'display_name': user_info.get('DisplayName', username), 311 | 'object_id': object_id, 312 | 'password': password, 313 | 'licenses': license_names, 314 | 'licenses_info': user_info.get('Licenses', '') 315 | } 316 | } 317 | else: 318 | # Status != 0 表示失败 319 | error_code = data.get('Code', 'unknown') 320 | error_message = data.get('Message', '') or f'错误代码: {error_code}' 321 | 322 | # 406 通常表示请求参数问题 323 | if error_code == '406': 324 | error_message = '请求参数不正确或缺少必要信息(错误代码 406)' 325 | 326 | return { 327 | 'success': False, 328 | 'error': 'creation_failed', 329 | 'message': error_message, 330 | 'code': error_code, 331 | 'status': data.get('Status') 332 | } 333 | 334 | except requests.exceptions.Timeout: 335 | return { 336 | 'success': False, 337 | 'error': 'timeout', 338 | 'message': '请求超时' 339 | } 340 | except requests.exceptions.RequestException as e: 341 | return { 342 | 'success': False, 343 | 'error': 'network_error', 344 | 'message': f'网络错误: {str(e)}' 345 | } 346 | except Exception as e: 347 | return { 348 | 'success': False, 349 | 'error': 'unknown_error', 350 | 'message': f'未知错误: {str(e)}' 351 | } 352 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify, session, redirect, url_for 2 | from config_manager import ConfigManager 3 | from checker import SubscriptionChecker 4 | from notifier import Notifier 5 | from scheduler import TaskScheduler 6 | from user_creator import UserCreator 7 | from user_lister import UserLister 8 | from user_activation import UserActivationService 9 | import atexit 10 | from functools import wraps 11 | from datetime import datetime 12 | import hashlib 13 | 14 | app = Flask(__name__) 15 | app.secret_key = 'office365_monitor_secret_key_2024' 16 | 17 | # 默认登录密码 18 | DEFAULT_PASSWORD = 'xiaokun567' 19 | 20 | # 初始化组件 21 | config_manager = ConfigManager('config.json') 22 | checker = SubscriptionChecker(config_manager) 23 | user_creator = UserCreator(config_manager) 24 | user_lister = UserLister(config_manager) 25 | user_activation = UserActivationService(config_manager) 26 | 27 | # 获取通知配置 28 | notification_config = config_manager.get_notification_config() 29 | notifier = Notifier(notification_config) 30 | 31 | # 初始化定时任务 32 | scheduler = TaskScheduler(checker, config_manager, notifier) 33 | scheduler.start() 34 | 35 | # 确保应用退出时停止定时任务 36 | atexit.register(lambda: scheduler.stop()) 37 | 38 | 39 | # ============ 登录验证装饰器 ============ 40 | 41 | def login_required(f): 42 | @wraps(f) 43 | def decorated_function(*args, **kwargs): 44 | if not session.get('logged_in'): 45 | return redirect(url_for('login')) 46 | return f(*args, **kwargs) 47 | return decorated_function 48 | 49 | 50 | # ============ 登录路由 ============ 51 | 52 | @app.route('/login', methods=['GET', 'POST']) 53 | def login(): 54 | """登录页面""" 55 | if request.method == 'POST': 56 | password = request.form.get('password') 57 | current_password = config_manager.get_login_password() 58 | 59 | if password == current_password: 60 | session['logged_in'] = True 61 | # 检查是否是默认密码 62 | if current_password == DEFAULT_PASSWORD: 63 | session['need_change_password'] = True 64 | return redirect(url_for('change_password')) 65 | return redirect(url_for('index')) 66 | else: 67 | # 登录失败,发送通知 68 | notifier.send_notification( 69 | f"⚠️ Office 365 监控系统登录失败\n\n" 70 | f"尝试密码: {password}\n" 71 | f"IP地址: {request.remote_addr}\n" 72 | f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" 73 | ) 74 | return render_template('login.html', error='密码错误') 75 | return render_template('login.html') 76 | 77 | 78 | @app.route('/change-password', methods=['GET', 'POST']) 79 | @login_required 80 | def change_password(): 81 | """修改密码页面""" 82 | if request.method == 'POST': 83 | new_password = request.form.get('new_password') 84 | confirm_password = request.form.get('confirm_password') 85 | 86 | if not new_password or len(new_password) < 6: 87 | return render_template('change_password.html', error='密码长度至少6位') 88 | 89 | if new_password != confirm_password: 90 | return render_template('change_password.html', error='两次输入的密码不一致') 91 | 92 | # 更新密码 93 | config_manager.update_login_password(new_password) 94 | session.pop('need_change_password', None) 95 | 96 | return render_template('change_password.html', success=True) 97 | 98 | return render_template('change_password.html') 99 | 100 | 101 | @app.route('/logout') 102 | def logout(): 103 | """登出""" 104 | session.clear() 105 | return redirect(url_for('login')) 106 | 107 | 108 | # ============ 页面路由 ============ 109 | 110 | @app.route('/') 111 | @login_required 112 | def index(): 113 | """仪表板页面""" 114 | # 如果需要修改密码,重定向 115 | if session.get('need_change_password'): 116 | return redirect(url_for('change_password')) 117 | return render_template('index.html') 118 | 119 | 120 | @app.route('/settings') 121 | @login_required 122 | def settings(): 123 | """设置页面""" 124 | if session.get('need_change_password'): 125 | return redirect(url_for('change_password')) 126 | return render_template('settings.html') 127 | 128 | 129 | # ============ API 路由 ============ 130 | 131 | @app.route('/api/subscriptions', methods=['GET']) 132 | @login_required 133 | def get_subscriptions(): 134 | """获取所有订阅""" 135 | subscriptions = config_manager.get_all_subscriptions() 136 | 137 | # 为每个订阅计算额外信息 138 | for sub in subscriptions: 139 | if sub.get('subscription_data'): 140 | data = sub['subscription_data'] 141 | if data.get('expirationDate'): 142 | sub['days_remaining'] = checker.calculate_days_remaining(data['expirationDate']) 143 | else: 144 | sub['days_remaining'] = 0 145 | 146 | sub['usage_percentage'] = checker.calculate_usage_percentage( 147 | data.get('consumedUnits', 0), 148 | data.get('totalLicenses', 0) 149 | ) 150 | 151 | return jsonify({ 152 | 'success': True, 153 | 'data': subscriptions 154 | }) 155 | 156 | 157 | @app.route('/api/subscriptions', methods=['POST']) 158 | @login_required 159 | def create_subscription(): 160 | """创建新订阅""" 161 | data = request.json 162 | 163 | if not data.get('name') or not data.get('curl_command'): 164 | return jsonify({ 165 | 'success': False, 166 | 'error': '缺少必要参数' 167 | }), 400 168 | 169 | try: 170 | order = data.get('order') 171 | user_create_curl = data.get('user_create_curl') 172 | subscription = config_manager.add_subscription( 173 | data['name'], 174 | data['curl_command'], 175 | order, 176 | user_create_curl 177 | ) 178 | return jsonify({ 179 | 'success': True, 180 | 'data': subscription 181 | }) 182 | except Exception as e: 183 | return jsonify({ 184 | 'success': False, 185 | 'error': str(e) 186 | }), 500 187 | 188 | 189 | @app.route('/api/subscriptions/', methods=['PUT']) 190 | @login_required 191 | def update_subscription(sub_id): 192 | """更新订阅""" 193 | data = request.json 194 | 195 | subscription = config_manager.update_subscription(sub_id, data) 196 | 197 | if subscription: 198 | return jsonify({ 199 | 'success': True, 200 | 'data': subscription 201 | }) 202 | else: 203 | return jsonify({ 204 | 'success': False, 205 | 'error': '订阅不存在' 206 | }), 404 207 | 208 | 209 | @app.route('/api/subscriptions/', methods=['DELETE']) 210 | @login_required 211 | def delete_subscription(sub_id): 212 | """删除订阅""" 213 | success = config_manager.delete_subscription(sub_id) 214 | 215 | if success: 216 | return jsonify({ 217 | 'success': True 218 | }) 219 | else: 220 | return jsonify({ 221 | 'success': False, 222 | 'error': '订阅不存在' 223 | }), 404 224 | 225 | 226 | @app.route('/api/subscriptions//check', methods=['POST']) 227 | @login_required 228 | def check_subscription(sub_id): 229 | """手动检测订阅""" 230 | result = checker.check_subscription(sub_id) 231 | 232 | if result['success']: 233 | status = result.get('status', '') 234 | data = result.get('data', {}) 235 | subscription = config_manager.get_subscription(sub_id) 236 | 237 | # 获取自定义的到期提醒天数 238 | notification_config = config_manager.get_notification_config() 239 | warning_days = notification_config.get('expiration_warning_days', 30) 240 | 241 | if status == 'expired': 242 | notifier.notify_subscription_expired(subscription['name']) 243 | elif status == 'active': 244 | expiration_date = data.get('expirationDate', '') 245 | if expiration_date: 246 | days_remaining = checker.calculate_days_remaining(expiration_date) 247 | if 0 < days_remaining <= warning_days: 248 | notifier.notify_expiration_warning(subscription['name'], days_remaining) 249 | 250 | if data.get('expirationDate'): 251 | result['days_remaining'] = checker.calculate_days_remaining(data['expirationDate']) 252 | result['usage_percentage'] = checker.calculate_usage_percentage( 253 | data.get('consumedUnits', 0), 254 | data.get('totalLicenses', 0) 255 | ) 256 | 257 | return jsonify(result) 258 | else: 259 | error_type = result.get('error', '') 260 | subscription = config_manager.get_subscription(sub_id) 261 | 262 | if error_type == 'auth_failure' and subscription: 263 | notifier.notify_auth_failure(subscription['name']) 264 | 265 | return jsonify(result), 400 266 | 267 | 268 | @app.route('/api/users/create', methods=['POST']) 269 | @login_required 270 | def create_user_api(): 271 | """Web界面创建用户API""" 272 | try: 273 | data = request.get_json() 274 | subscription_id = data.get('subscription_id') 275 | username = data.get('username') 276 | password = data.get('password') 277 | 278 | if not subscription_id or not username: 279 | return jsonify({ 280 | 'success': False, 281 | 'error': '缺少必要参数' 282 | }), 400 283 | 284 | if not password: 285 | import random 286 | import string 287 | password_chars = ( 288 | random.choices(string.ascii_uppercase, k=3) + 289 | random.choices(string.ascii_lowercase, k=3) + 290 | random.choices(string.digits, k=3) + 291 | random.choices('!@#$%', k=3) 292 | ) 293 | random.shuffle(password_chars) 294 | password = ''.join(password_chars) 295 | 296 | result = user_creator.create_user(subscription_id, username, password) 297 | 298 | if result['success']: 299 | return jsonify(result) 300 | else: 301 | return jsonify(result), 400 302 | 303 | except Exception as e: 304 | return jsonify({ 305 | 'success': False, 306 | 'error': f'创建用户失败: {str(e)}' 307 | }), 500 308 | 309 | 310 | @app.route('/api/users/list/', methods=['GET']) 311 | @login_required 312 | def list_users_api(sub_id): 313 | """Web界面查询用户列表API""" 314 | try: 315 | result = user_lister.list_users(sub_id) 316 | 317 | if result['success']: 318 | return jsonify(result) 319 | else: 320 | return jsonify(result), 400 321 | 322 | except Exception as e: 323 | return jsonify({ 324 | 'success': False, 325 | 'error': f'查询用户失败: {str(e)}' 326 | }), 500 327 | 328 | 329 | @app.route('/api/users/activation//', methods=['GET']) 330 | @login_required 331 | def query_user_activation_api(sub_id, username): 332 | """Web界面查询用户激活信息API""" 333 | try: 334 | result = user_activation.query_user_activation(sub_id, username) 335 | 336 | if result['success']: 337 | return jsonify(result) 338 | else: 339 | return jsonify(result), 400 340 | 341 | except Exception as e: 342 | return jsonify({ 343 | 'success': False, 344 | 'error': f'查询激活信息失败: {str(e)}' 345 | }), 500 346 | 347 | 348 | @app.route('/api/users/activation/all/', methods=['GET']) 349 | @login_required 350 | def query_all_users_activation_api(sub_id): 351 | """Web界面查询所有用户激活信息API""" 352 | try: 353 | result = user_activation.query_all_users_activation(sub_id) 354 | 355 | if result['success']: 356 | return jsonify(result) 357 | else: 358 | return jsonify(result), 400 359 | 360 | except Exception as e: 361 | return jsonify({ 362 | 'success': False, 363 | 'error': f'批量查询激活信息失败: {str(e)}' 364 | }), 500 365 | 366 | 367 | # ============ Webhook 配置 API ============ 368 | 369 | @app.route('/api/webhook-config', methods=['GET']) 370 | @login_required 371 | def get_webhook_config(): 372 | """获取 Webhook 配置""" 373 | config = config_manager.get_notification_config() 374 | return jsonify({ 375 | 'success': True, 376 | 'data': config 377 | }) 378 | 379 | 380 | @app.route('/api/webhook-config', methods=['POST']) 381 | @login_required 382 | def update_webhook_config(): 383 | """更新 Webhook 配置""" 384 | data = request.json 385 | 386 | webhook_url = data.get('webhook_url', '') 387 | webhook_json = data.get('webhook_json', '') 388 | expiration_warning_days = data.get('expiration_warning_days', 30) 389 | 390 | # 验证天数 391 | try: 392 | expiration_warning_days = int(expiration_warning_days) 393 | if expiration_warning_days < 1 or expiration_warning_days > 365: 394 | return jsonify({ 395 | 'success': False, 396 | 'error': '到期提醒天数必须在 1-365 之间' 397 | }), 400 398 | except (ValueError, TypeError): 399 | expiration_warning_days = 30 400 | 401 | config_manager.update_notification_config(webhook_url, webhook_json, expiration_warning_days) 402 | 403 | # 重新初始化 notifier 404 | global notifier 405 | notification_config = config_manager.get_notification_config() 406 | notifier = Notifier(notification_config) 407 | 408 | return jsonify({ 409 | 'success': True, 410 | 'message': 'Webhook 配置已更新' 411 | }) 412 | 413 | 414 | @app.route('/api/webhook-test', methods=['POST']) 415 | @login_required 416 | def test_webhook(): 417 | """测试 Webhook 通知""" 418 | try: 419 | # 发送测试消息 420 | test_message = "🧪 这是一条测试通知\n\nOffice 365 订阅监控系统\n测试时间: " + datetime.now().strftime('%Y-%m-%d %H:%M:%S') 421 | success = notifier.send_notification(test_message) 422 | 423 | if success: 424 | return jsonify({ 425 | 'success': True, 426 | 'message': '测试通知已发送' 427 | }) 428 | else: 429 | return jsonify({ 430 | 'success': False, 431 | 'error': '通知发送失败,请检查 Webhook 配置' 432 | }), 400 433 | except Exception as e: 434 | return jsonify({ 435 | 'success': False, 436 | 'error': f'发送失败: {str(e)}' 437 | }), 500 438 | 439 | 440 | @app.route('/api/check-interval', methods=['GET']) 441 | @login_required 442 | def get_check_interval(): 443 | """获取检测间隔""" 444 | interval = config_manager.get_check_interval_hours() 445 | return jsonify({ 446 | 'success': True, 447 | 'data': { 448 | 'check_interval_hours': interval 449 | } 450 | }) 451 | 452 | 453 | @app.route('/api/check-interval', methods=['POST']) 454 | @login_required 455 | def update_check_interval(): 456 | """更新检测间隔""" 457 | data = request.json 458 | hours = data.get('check_interval_hours', 12) 459 | 460 | try: 461 | hours = int(hours) 462 | if hours < 1 or hours > 168: # 1小时到7天 463 | return jsonify({ 464 | 'success': False, 465 | 'error': '检测间隔必须在 1-168 小时之间' 466 | }), 400 467 | except (ValueError, TypeError): 468 | return jsonify({ 469 | 'success': False, 470 | 'error': '无效的小时数' 471 | }), 400 472 | 473 | config_manager.update_check_interval_hours(hours) 474 | 475 | # 重启定时任务 476 | global scheduler 477 | scheduler.stop() 478 | scheduler = TaskScheduler(checker, config_manager, notifier) 479 | scheduler.start() 480 | 481 | return jsonify({ 482 | 'success': True, 483 | 'message': f'检测间隔已更新为 {hours} 小时' 484 | }) 485 | 486 | 487 | if __name__ == '__main__': 488 | print("Office 365 订阅监控系统启动中...") 489 | print("访问地址: http://localhost:5000") 490 | app.run(host='0.0.0.0', port=5000, debug=True) 491 | -------------------------------------------------------------------------------- /user_activation.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from typing import Dict, Optional, List 4 | from datetime import datetime 5 | 6 | 7 | class UserActivationService: 8 | """Office 365 用户激活信息查询服务""" 9 | 10 | def __init__(self, config_manager): 11 | self.config_manager = config_manager 12 | 13 | def query_all_users_activation(self, subscription_id: str) -> Dict: 14 | """查询订阅下所有用户的 Office 365 激活信息""" 15 | subscription = self.config_manager.get_subscription(subscription_id) 16 | if not subscription: 17 | return { 18 | 'success': False, 19 | 'error': '订阅不存在' 20 | } 21 | 22 | user_create_config = subscription.get('user_create_config') 23 | if not user_create_config: 24 | return { 25 | 'success': False, 26 | 'error': '该订阅未配置用户管理功能,无法查询激活信息' 27 | } 28 | 29 | try: 30 | # 导入 UserLister 来获取用户列表 31 | from user_lister import UserLister 32 | user_lister = UserLister(self.config_manager) 33 | 34 | # 获取所有用户 35 | users_result = user_lister.list_users(subscription_id) 36 | if not users_result['success']: 37 | return users_result 38 | 39 | users = users_result['data']['users'] 40 | print(f"[批量激活查询] 找到 {len(users)} 个用户") 41 | 42 | # 查询每个用户的激活信息 43 | all_activations = [] 44 | for user in users: 45 | object_id = user.get('object_id') 46 | if not object_id: 47 | # 如果用户列表中没有 object_id,跳过 48 | continue 49 | 50 | print(f"[批量激活查询] 查询用户: {user['display_name']}") 51 | 52 | # 获取激活数据 53 | activation_result = self.fetch_activation_data(subscription_id, object_id) 54 | if activation_result['success']: 55 | activation_data = self.parse_activation_response(activation_result['data']) 56 | 57 | # 只添加有激活设备的用户 58 | machines = activation_data.get('machines', []) 59 | active_computers = activation_data.get('active_computers', 0) 60 | active_devices = activation_data.get('active_devices', 0) 61 | 62 | # 如果有激活的电脑或设备,或者有设备列表,则添加 63 | if machines or active_computers > 0 or active_devices > 0: 64 | all_activations.append({ 65 | 'user_info': { 66 | 'display_name': user['display_name'], 67 | 'user_principal_name': user['user_principal_name'], 68 | 'email': user.get('email', ''), 69 | 'object_id': object_id 70 | }, 71 | 'activation_info': activation_data 72 | }) 73 | print(f"[批量激活查询] ✅ {user['display_name']} 有激活设备") 74 | else: 75 | print(f"[批量激活查询] ⏭️ {user['display_name']} 无激活设备,跳过") 76 | 77 | print(f"[批量激活查询] 完成,共 {len(all_activations)} 个用户有激活设备") 78 | 79 | return { 80 | 'success': True, 81 | 'data': { 82 | 'total_users': len(users), 83 | 'users_with_activation_count': len(all_activations), 84 | 'users_with_activation': all_activations 85 | } 86 | } 87 | 88 | except Exception as e: 89 | return { 90 | 'success': False, 91 | 'error': f'批量查询激活信息失败: {str(e)}' 92 | } 93 | 94 | def query_user_activation(self, subscription_id: str, username: str) -> Dict: 95 | """查询用户的 Office 365 激活信息""" 96 | subscription = self.config_manager.get_subscription(subscription_id) 97 | if not subscription: 98 | return { 99 | 'success': False, 100 | 'error': '订阅不存在' 101 | } 102 | 103 | user_create_config = subscription.get('user_create_config') 104 | if not user_create_config: 105 | return { 106 | 'success': False, 107 | 'error': '该订阅未配置用户管理功能,无法查询激活信息' 108 | } 109 | 110 | try: 111 | # 步骤1: 获取用户的 ObjectId 112 | object_id_result = self.get_user_object_id(subscription_id, username) 113 | if not object_id_result['success']: 114 | return object_id_result 115 | 116 | user_info = object_id_result['user_info'] 117 | object_id = user_info['object_id'] 118 | 119 | print(f"[激活查询] 找到用户: {user_info['display_name']} (ObjectId: {object_id})") 120 | 121 | # 步骤2: 获取激活数据 122 | activation_result = self.fetch_activation_data(subscription_id, object_id) 123 | if not activation_result['success']: 124 | return activation_result 125 | 126 | # 步骤3: 解析激活数据 127 | activation_data = self.parse_activation_response(activation_result['data']) 128 | 129 | return { 130 | 'success': True, 131 | 'data': { 132 | 'user_info': user_info, 133 | 'activation_info': activation_data 134 | } 135 | } 136 | 137 | except Exception as e: 138 | return { 139 | 'success': False, 140 | 'error': f'查询激活信息失败: {str(e)}' 141 | } 142 | 143 | def get_user_object_id(self, subscription_id: str, username: str) -> Dict: 144 | """通过用户列表API查找用户的ObjectId""" 145 | subscription = self.config_manager.get_subscription(subscription_id) 146 | user_create_config = subscription.get('user_create_config') 147 | 148 | try: 149 | headers = user_create_config['headers'].copy() 150 | headers['content-type'] = 'application/json' 151 | 152 | cookies_str = user_create_config['cookies'] 153 | 154 | # 如果用户创建配置的 cookie 为空,尝试使用订阅配置的 cookie 155 | if not cookies_str or cookies_str.strip() == '': 156 | cookies_str = subscription.get('cookies', '') 157 | if cookies_str: 158 | print(f"[查询激活] 用户创建配置的 cookie 为空,使用订阅配置的 cookie") 159 | 160 | cookies = {} 161 | if cookies_str: 162 | for cookie in cookies_str.split('; '): 163 | if '=' in cookie: 164 | key, value = cookie.split('=', 1) 165 | cookies[key] = value 166 | 167 | api_url = user_create_config['api_url'] 168 | parts = api_url.split('/') 169 | base_url = f"{parts[0]}//{parts[2]}" 170 | list_users_url = f"{base_url}/admin/api/Users/ListUsers" 171 | 172 | payload = { 173 | "ListAction": -1, 174 | "SortDirection": 0, 175 | "ListContext": None, 176 | "SortPropertyName": "DisplayName", 177 | "SearchText": username, 178 | "SelectedView": "", 179 | "SelectedViewType": "", 180 | "ServerContext": None, 181 | "MSGraphFilter": { 182 | "skuIds": [], 183 | "locations": [], 184 | "domains": [] 185 | } 186 | } 187 | 188 | print(f"[激活查询] 搜索用户: {username}") 189 | 190 | response = requests.post( 191 | list_users_url, 192 | headers=headers, 193 | cookies=cookies, 194 | json=payload, 195 | timeout=30 196 | ) 197 | 198 | if response.status_code == 401 or response.status_code == 403: 199 | return { 200 | 'success': False, 201 | 'error': '认证失败,Cookie 可能已过期' 202 | } 203 | 204 | if response.status_code not in [200, 201]: 205 | return { 206 | 'success': False, 207 | 'error': f'API 返回错误状态码: {response.status_code}' 208 | } 209 | 210 | data = response.json() 211 | users = data.get('Users', []) 212 | 213 | matched_user = None 214 | for user in users: 215 | user_principal_name = user.get('UserPrincipalName', '') 216 | display_name = user.get('DisplayName', '') 217 | 218 | if (username.lower() in user_principal_name.lower() or 219 | username.lower() in display_name.lower()): 220 | matched_user = user 221 | break 222 | 223 | if not matched_user: 224 | return { 225 | 'success': False, 226 | 'error': f'未找到用户: {username}' 227 | } 228 | 229 | return { 230 | 'success': True, 231 | 'user_info': { 232 | 'object_id': matched_user.get('ObjectId', ''), 233 | 'display_name': matched_user.get('DisplayName', ''), 234 | 'user_principal_name': matched_user.get('UserPrincipalName', ''), 235 | 'email': matched_user.get('Mail', '') 236 | } 237 | } 238 | 239 | except Exception as e: 240 | return { 241 | 'success': False, 242 | 'error': f'查找用户失败: {str(e)}' 243 | } 244 | 245 | def fetch_activation_data(self, subscription_id: str, object_id: str) -> Dict: 246 | """调用officeInstalls API获取激活数据""" 247 | subscription = self.config_manager.get_subscription(subscription_id) 248 | user_create_config = subscription.get('user_create_config') 249 | 250 | try: 251 | headers = user_create_config['headers'].copy() 252 | 253 | cookies_str = user_create_config['cookies'] 254 | 255 | # 如果用户创建配置的 cookie 为空,尝试使用订阅配置的 cookie 256 | if not cookies_str or cookies_str.strip() == '': 257 | cookies_str = subscription.get('cookies', '') 258 | if cookies_str: 259 | print(f"[获取激活数据] 用户创建配置的 cookie 为空,使用订阅配置的 cookie") 260 | 261 | cookies = {} 262 | if cookies_str: 263 | for cookie in cookies_str.split('; '): 264 | if '=' in cookie: 265 | key, value = cookie.split('=', 1) 266 | cookies[key] = value 267 | 268 | api_url = user_create_config['api_url'] 269 | parts = api_url.split('/') 270 | base_url = f"{parts[0]}//{parts[2]}" 271 | activation_url = f"{base_url}/admin/api/users/{object_id}/officeInstalls" 272 | 273 | print(f"[激活查询] 查询激活信息: {activation_url}") 274 | 275 | response = requests.get( 276 | activation_url, 277 | headers=headers, 278 | cookies=cookies, 279 | timeout=30 280 | ) 281 | 282 | if response.status_code == 401 or response.status_code == 403: 283 | return { 284 | 'success': False, 285 | 'error': '认证失败,Cookie 可能已过期' 286 | } 287 | 288 | if response.status_code not in [200, 201]: 289 | return { 290 | 'success': False, 291 | 'error': f'API 返回错误状态码: {response.status_code}' 292 | } 293 | 294 | data = response.json() 295 | 296 | return { 297 | 'success': True, 298 | 'data': data 299 | } 300 | 301 | except Exception as e: 302 | return { 303 | 'success': False, 304 | 'error': f'获取激活数据失败: {str(e)}' 305 | } 306 | 307 | def parse_activation_response(self, response: Dict) -> Dict: 308 | """解析API响应,提取设备和激活信息""" 309 | try: 310 | software_machine_details = response.get('SoftwareMachineDetails', []) 311 | 312 | if not software_machine_details: 313 | return { 314 | 'active_computers': 0, 315 | 'total_computers': 0, 316 | 'active_devices': 0, 317 | 'total_devices': 0, 318 | 'machines': [] 319 | } 320 | 321 | machine_details = software_machine_details[0].get('MachineDetails', {}) 322 | machines_list = machine_details.get('Machines', []) 323 | 324 | parsed_machines = [] 325 | for machine in machines_list: 326 | machine_type_map = { 327 | 1: 'Windows', 328 | 2: 'Mac', 329 | 3: 'Mobile', 330 | 4: 'Tablet', 331 | 5: 'iOS', 332 | 6: 'Android' 333 | } 334 | 335 | license_status_map = { 336 | 0: '未激活', 337 | 1: '已激活' 338 | } 339 | 340 | machine_type = machine.get('MachineType', 0) 341 | license_status = machine.get('LicenseStatus', 0) 342 | 343 | parsed_machines.append({ 344 | 'machine_name': machine.get('MachineName', '未知'), 345 | 'machine_os': machine.get('MachineOs', '未知'), 346 | 'machine_type': machine_type_map.get(machine_type, '未知'), 347 | 'machine_type_code': machine_type, 348 | 'license_status': license_status_map.get(license_status, '未知'), 349 | 'license_status_code': license_status, 350 | 'last_license_requested': machine.get('LastLicenseRequestedDate', ''), 351 | 'office_version': machine.get('OfficeMajorVersion', 0) 352 | }) 353 | 354 | return { 355 | 'active_computers': machine_details.get('ActiveComputers', 0), 356 | 'total_computers': machine_details.get('TotalComputers', 0), 357 | 'active_devices': machine_details.get('ActiveDevices', 0), 358 | 'total_devices': machine_details.get('TotalDevices', 0), 359 | 'machines': parsed_machines 360 | } 361 | 362 | except Exception as e: 363 | print(f"[激活查询] 解析响应失败: {str(e)}") 364 | return { 365 | 'active_computers': 0, 366 | 'total_computers': 0, 367 | 'active_devices': 0, 368 | 'total_devices': 0, 369 | 'machines': [] 370 | } 371 | 372 | def format_activation_message(self, user_info: Dict, activation_data: Dict) -> str: 373 | """格式化输出消息(用于微信通知)""" 374 | machines = activation_data.get('machines', []) 375 | active_computers = activation_data.get('active_computers', 0) 376 | total_computers = activation_data.get('total_computers', 0) 377 | active_devices = activation_data.get('active_devices', 0) 378 | total_devices = activation_data.get('total_devices', 0) 379 | 380 | message_lines = [ 381 | "📱 Office 365 激活信息\n", 382 | f"👤 用户: {user_info['display_name']}", 383 | f"📧 邮箱: {user_info['user_principal_name']}\n", 384 | "💻 设备激活情况", 385 | f"已激活: {active_computers} / {total_computers} 台电脑", 386 | f"已激活: {active_devices} / {total_devices} 台移动设备\n" 387 | ] 388 | 389 | if machines: 390 | message_lines.append("📋 设备列表:") 391 | for i, machine in enumerate(machines, 1): 392 | status_icon = '✅' if machine['license_status_code'] == 1 else '❌' 393 | last_requested = machine['last_license_requested'] 394 | 395 | if last_requested: 396 | try: 397 | dt = datetime.fromisoformat(last_requested.replace('Z', '+00:00')) 398 | formatted_time = dt.strftime('%Y-%m-%d %H:%M') 399 | except: 400 | formatted_time = last_requested[:16].replace('T', ' ') 401 | else: 402 | formatted_time = '未知' 403 | 404 | message_lines.append( 405 | f"{i}. {machine['machine_name']}\n" 406 | f" {machine['machine_os']}\n" 407 | f" {status_icon} {machine['license_status']}\n" 408 | f" 最后请求: {formatted_time}" 409 | ) 410 | else: 411 | message_lines.append("暂无激活设备") 412 | 413 | return "\n".join(message_lines) 414 | -------------------------------------------------------------------------------- /config_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | import re 4 | from datetime import datetime 5 | from typing import Dict, List, Optional 6 | 7 | 8 | class ConfigManager: 9 | """配置文件管理器""" 10 | 11 | def __init__(self, config_path='config.json'): 12 | self.config_path = config_path 13 | self.config = self.load_config() 14 | 15 | def load_config(self) -> Dict: 16 | """加载配置文件""" 17 | default_config = { 18 | "subscriptions": [], 19 | "notification": { 20 | "webhook_url": "", 21 | "webhook_json": "", 22 | "expiration_warning_days": 30 23 | }, 24 | "login_password": "xiaokun567", 25 | "check_interval_hours": 12 26 | } 27 | 28 | try: 29 | needs_save = False 30 | config = None 31 | 32 | # 读取和解析配置 33 | with open(self.config_path, 'r', encoding='utf-8') as f: 34 | content = f.read().strip() 35 | 36 | # 检查文件是否为空 37 | if not content: 38 | print(f"⚠️ 配置文件为空,创建默认配置") 39 | # 文件句柄已关闭,可以安全保存 40 | config = default_config 41 | needs_save = True 42 | else: 43 | # 尝试解析 JSON 44 | config = json.loads(content) 45 | 46 | # 验证配置结构,确保必要的字段存在 47 | if 'subscriptions' not in config: 48 | config['subscriptions'] = [] 49 | needs_save = True 50 | 51 | if 'notification' not in config: 52 | config['notification'] = default_config['notification'] 53 | needs_save = True 54 | else: 55 | # 确保 notification 中有所有必要字段 56 | if 'webhook_url' not in config['notification']: 57 | config['notification']['webhook_url'] = "" 58 | needs_save = True 59 | if 'webhook_json' not in config['notification']: 60 | config['notification']['webhook_json'] = "" 61 | needs_save = True 62 | if 'expiration_warning_days' not in config['notification']: 63 | config['notification']['expiration_warning_days'] = 30 64 | needs_save = True 65 | 66 | if 'login_password' not in config: 67 | config['login_password'] = "xiaokun567" 68 | needs_save = True 69 | 70 | if 'check_interval_hours' not in config: 71 | config['check_interval_hours'] = 12 72 | needs_save = True 73 | 74 | # ✅ with 块已结束,文件句柄已关闭,现在可以安全保存 75 | if needs_save: 76 | self.save_config(config) 77 | 78 | return config 79 | 80 | except FileNotFoundError: 81 | # 如果文件不存在,创建默认配置 82 | print(f"📝 配置文件不存在,创建默认配置: {self.config_path}") 83 | self.save_config(default_config) 84 | return default_config 85 | 86 | except json.JSONDecodeError as e: 87 | # 如果 JSON 格式错误,备份旧文件并创建新配置 88 | print(f"❌ 配置文件 JSON 格式错误: {e}") 89 | 90 | # 备份损坏的配置文件 91 | import shutil 92 | from datetime import datetime 93 | backup_path = f"{self.config_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" 94 | try: 95 | shutil.copy(self.config_path, backup_path) 96 | print(f"📦 已备份损坏的配置到: {backup_path}") 97 | except Exception as backup_error: 98 | print(f"⚠️ 备份失败: {backup_error}") 99 | 100 | # 创建新的默认配置 101 | print(f"📝 创建新的默认配置") 102 | self.save_config(default_config) 103 | return default_config 104 | 105 | except Exception as e: 106 | # 其他错误 107 | print(f"❌ 加载配置文件时出错: {e}") 108 | print(f"📝 使用默认配置") 109 | return default_config 110 | 111 | def save_config(self, config: Optional[Dict] = None) -> None: 112 | """保存配置文件""" 113 | if config is None: 114 | config = self.config 115 | 116 | import os 117 | import shutil 118 | import time 119 | 120 | temp_path = f"{self.config_path}.tmp" 121 | 122 | try: 123 | # 先写入临时文件 124 | with open(temp_path, 'w', encoding='utf-8') as f: 125 | json.dump(config, f, ensure_ascii=False, indent=2) 126 | 127 | # 验证写入的文件是否有效 128 | with open(temp_path, 'r', encoding='utf-8') as f: 129 | json.load(f) # 尝试读取验证 130 | 131 | # 如果验证成功,替换原文件 132 | if os.path.exists(self.config_path): 133 | # 备份当前配置 134 | backup_path = f"{self.config_path}.bak" 135 | try: 136 | shutil.copy(self.config_path, backup_path) 137 | except Exception as backup_error: 138 | print(f"⚠️ 备份配置失败: {backup_error}") 139 | 140 | # 替换为新配置(添加重试机制,解决 Windows 文件锁定问题) 141 | max_retries = 5 142 | retry_delay = 0.1 143 | 144 | for attempt in range(max_retries): 145 | try: 146 | # 在 Windows 上,如果文件被占用,os.replace 会失败 147 | os.replace(temp_path, self.config_path) 148 | break # 成功则退出循环 149 | except PermissionError as perm_error: 150 | if attempt < max_retries - 1: 151 | # 等待文件句柄释放 152 | time.sleep(retry_delay) 153 | retry_delay *= 2 # 指数退避 154 | else: 155 | # 最后一次尝试失败,抛出异常 156 | raise PermissionError( 157 | f"无法保存配置文件,文件可能被其他程序占用。" 158 | f"已重试 {max_retries} 次。原始错误: {perm_error}" 159 | ) 160 | except Exception as replace_error: 161 | # 其他错误直接抛出 162 | raise replace_error 163 | 164 | except Exception as e: 165 | print(f"❌ 保存配置文件失败: {e}") 166 | # 清理临时文件 167 | try: 168 | if os.path.exists(temp_path): 169 | os.remove(temp_path) 170 | except Exception as cleanup_error: 171 | print(f"⚠️ 清理临时文件失败: {cleanup_error}") 172 | raise 173 | 174 | def parse_curl_command(self, curl_command: str) -> Dict: 175 | """解析 curl 命令,提取 URL、headers 和 cookies""" 176 | result = { 177 | 'url': '', 178 | 'headers': {}, 179 | 'cookies': '' 180 | } 181 | 182 | # 提取 URL 183 | url_match = re.search(r"curl\s+'([^']+)'", curl_command) 184 | if url_match: 185 | result['url'] = url_match.group(1) 186 | 187 | # 提取所有 -H 参数(headers) 188 | header_pattern = r"-H\s+'([^:]+):\s*([^']+)'" 189 | headers = re.findall(header_pattern, curl_command) 190 | for key, value in headers: 191 | result['headers'][key] = value 192 | 193 | # 提取 -b 参数(cookies) 194 | cookie_match = re.search(r"-b\s+'([^']+)'", curl_command) 195 | if cookie_match: 196 | result['cookies'] = cookie_match.group(1) 197 | 198 | return result 199 | 200 | def generate_user_create_config(self, cookies: str) -> Dict: 201 | """从许可证查询的 Cookie 自动生成用户创建配置""" 202 | # 从 Cookie 中提取 ajaxsessionkey 203 | ajaxsessionkey = '' 204 | ajax_match = re.search(r's\.AjaxSessionKey=([^;]+)', cookies) 205 | if ajax_match: 206 | # URL 解码 207 | import urllib.parse 208 | ajaxsessionkey = urllib.parse.unquote(ajax_match.group(1)) 209 | 210 | # 构建用户创建配置 211 | user_create_config = { 212 | 'api_url': 'https://admin.cloud.microsoft/admin/api/users', 213 | 'headers': { 214 | 'accept': 'application/json, text/plain, */*', 215 | 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', 216 | 'ajaxsessionkey': ajaxsessionkey, 217 | 'content-type': 'application/json', 218 | 'origin': 'https://admin.cloud.microsoft', 219 | 'priority': 'u=1, i', 220 | 'referer': 'https://admin.cloud.microsoft/?', 221 | 'sec-ch-ua': '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"', 222 | 'sec-ch-ua-mobile': '?0', 223 | 'sec-ch-ua-platform': '"macOS"', 224 | 'sec-fetch-dest': 'empty', 225 | 'sec-fetch-mode': 'cors', 226 | 'sec-fetch-site': 'same-origin', 227 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 228 | 'x-adminapp-request': '/users/:/adduser', 229 | 'x-ms-mac-appid': '1f5f6b98-4e5f-486f-a0af-099e5eeb474f', 230 | 'x-ms-mac-hostingapp': 'M365AdminPortal', 231 | 'x-ms-mac-target-app': 'MAC', 232 | 'x-ms-mac-version': 'host-mac_2025.11.6.2' 233 | }, 234 | 'cookies': cookies 235 | } 236 | 237 | return user_create_config 238 | 239 | def add_subscription(self, name: str, curl_command: str, order: Optional[int] = None, 240 | user_create_curl: Optional[str] = None, auto_generate_user_config: bool = True) -> Dict: 241 | """添加新订阅 242 | 243 | Args: 244 | name: 订阅名称 245 | curl_command: 许可证查询的 curl 命令 246 | order: 编号(可选) 247 | user_create_curl: 用户创建的 curl 命令(可选) 248 | auto_generate_user_config: 是否自动生成用户创建配置(默认 True) 249 | """ 250 | parsed = self.parse_curl_command(curl_command) 251 | 252 | # 从 URL 中提取 subscription_id 253 | subscription_id = '' 254 | id_match = re.search(r'id=([a-f0-9\-]+)', parsed['url']) 255 | if id_match: 256 | subscription_id = id_match.group(1) 257 | 258 | # 如果没有指定编号,自动分配 259 | if order is None: 260 | existing_orders = [sub.get('order', 0) for sub in self.config['subscriptions']] 261 | order = max(existing_orders) + 1 if existing_orders else 1 262 | 263 | subscription = { 264 | 'id': str(uuid.uuid4()), 265 | 'order': order, 266 | 'name': name, 267 | 'subscription_id': subscription_id, 268 | 'api_url': parsed['url'], 269 | 'headers': parsed['headers'], 270 | 'cookies': parsed['cookies'], 271 | 'status': 'unknown', 272 | 'last_check_time': None, 273 | 'subscription_data': None 274 | } 275 | 276 | # 如果提供了用户创建配置,解析并保存 277 | if user_create_curl: 278 | user_create_parsed = self.parse_curl_command(user_create_curl) 279 | subscription['user_create_config'] = { 280 | 'api_url': user_create_parsed['url'], 281 | 'headers': user_create_parsed['headers'], 282 | 'cookies': user_create_parsed['cookies'] 283 | } 284 | subscription['user_create_curl'] = user_create_curl 285 | elif auto_generate_user_config and parsed['cookies']: 286 | # 自动生成用户创建配置 287 | subscription['user_create_config'] = self.generate_user_create_config(parsed['cookies']) 288 | print(f"✅ 已自动生成用户创建配置(订阅:{name})") 289 | 290 | self.config['subscriptions'].append(subscription) 291 | # 按编号排序 292 | self.config['subscriptions'].sort(key=lambda x: x.get('order', 999)) 293 | self.save_config() 294 | return subscription 295 | 296 | def update_subscription(self, sub_id: str, data: Dict) -> Optional[Dict]: 297 | """更新订阅""" 298 | for i, sub in enumerate(self.config['subscriptions']): 299 | if sub['id'] == sub_id: 300 | # 如果提供了 curl_command,重新解析 301 | if 'curl_command' in data: 302 | parsed = self.parse_curl_command(data['curl_command']) 303 | sub['api_url'] = parsed['url'] 304 | sub['headers'] = parsed['headers'] 305 | sub['cookies'] = parsed['cookies'] 306 | 307 | # 更新 subscription_id 308 | id_match = re.search(r'id=([a-f0-9\-]+)', parsed['url']) 309 | if id_match: 310 | sub['subscription_id'] = id_match.group(1) 311 | 312 | # 自动生成或更新用户创建配置(迁移旧格式) 313 | if parsed['cookies']: 314 | had_old_curl = 'user_create_curl' in sub 315 | had_config = 'user_create_config' in sub 316 | 317 | # 删除旧的手动配置标记,统一使用自动生成 318 | if had_old_curl: 319 | sub.pop('user_create_curl', None) 320 | print(f"🔄 已将订阅 {sub['name']} 迁移到自动生成模式") 321 | 322 | # 自动生成/更新用户创建配置 323 | sub['user_create_config'] = self.generate_user_create_config(parsed['cookies']) 324 | 325 | if not had_config and not had_old_curl: 326 | print(f"✅ 已自动生成用户创建配置(订阅:{sub['name']})") 327 | elif had_config or had_old_curl: 328 | print(f"✅ 已自动更新用户创建配置的 Cookie(订阅:{sub['name']})") 329 | 330 | # 更新名称 331 | if 'name' in data: 332 | sub['name'] = data['name'] 333 | 334 | # 更新编号 335 | if 'order' in data: 336 | sub['order'] = int(data['order']) 337 | 338 | # 更新用户创建配置 339 | if 'user_create_curl' in data: 340 | user_create_curl = data['user_create_curl'] 341 | if user_create_curl: 342 | user_create_parsed = self.parse_curl_command(user_create_curl) 343 | sub['user_create_config'] = { 344 | 'api_url': user_create_parsed['url'], 345 | 'headers': user_create_parsed['headers'], 346 | 'cookies': user_create_parsed['cookies'] 347 | } 348 | sub['user_create_curl'] = user_create_curl 349 | else: 350 | # 如果为空,删除配置 351 | sub.pop('user_create_config', None) 352 | sub.pop('user_create_curl', None) 353 | 354 | self.config['subscriptions'][i] = sub 355 | # 按编号排序 356 | self.config['subscriptions'].sort(key=lambda x: x.get('order', 999)) 357 | self.save_config() 358 | return sub 359 | return None 360 | 361 | def delete_subscription(self, sub_id: str) -> bool: 362 | """删除订阅""" 363 | original_length = len(self.config['subscriptions']) 364 | self.config['subscriptions'] = [ 365 | sub for sub in self.config['subscriptions'] 366 | if sub['id'] != sub_id 367 | ] 368 | if len(self.config['subscriptions']) < original_length: 369 | self.save_config() 370 | return True 371 | return False 372 | 373 | def get_subscription(self, sub_id: str) -> Optional[Dict]: 374 | """获取单个订阅""" 375 | for sub in self.config['subscriptions']: 376 | if sub['id'] == sub_id: 377 | return sub 378 | return None 379 | 380 | def get_all_subscriptions(self) -> List[Dict]: 381 | """获取所有订阅(按编号排序)""" 382 | subscriptions = self.config['subscriptions'] 383 | # 确保按编号排序 384 | subscriptions.sort(key=lambda x: x.get('order', 999)) 385 | return subscriptions 386 | 387 | def get_subscription_by_order(self, order: int) -> Optional[Dict]: 388 | """根据编号获取订阅""" 389 | for sub in self.config['subscriptions']: 390 | if sub.get('order') == order: 391 | return sub 392 | return None 393 | 394 | def update_subscription_status(self, sub_id: str, status: str, data: Optional[Dict] = None, error_type: Optional[str] = None) -> None: 395 | """更新订阅状态""" 396 | for sub in self.config['subscriptions']: 397 | if sub['id'] == sub_id: 398 | sub['status'] = status 399 | sub['last_check_time'] = datetime.now().isoformat() 400 | if data: 401 | sub['subscription_data'] = data 402 | # 保存错误类型(如果有) 403 | if error_type: 404 | sub['error_type'] = error_type 405 | elif 'error_type' in sub: 406 | # 如果检测成功,清除之前的错误类型 407 | del sub['error_type'] 408 | self.save_config() 409 | break 410 | 411 | def get_notification_config(self) -> Dict: 412 | """获取通知配置""" 413 | notification = self.config.get('notification', { 414 | 'webhook_url': '', 415 | 'webhook_json': '', 416 | 'expiration_warning_days': 30 417 | }) 418 | # 确保有默认值 419 | if 'expiration_warning_days' not in notification: 420 | notification['expiration_warning_days'] = 30 421 | return notification 422 | 423 | def update_notification_config(self, webhook_url: str, webhook_json: str, expiration_warning_days: int = 30) -> None: 424 | """更新通知配置""" 425 | self.config['notification'] = { 426 | 'webhook_url': webhook_url, 427 | 'webhook_json': webhook_json, 428 | 'expiration_warning_days': expiration_warning_days 429 | } 430 | self.save_config() 431 | 432 | def get_login_password(self) -> str: 433 | """获取登录密码""" 434 | return self.config.get('login_password', 'xiaokun567') 435 | 436 | def update_login_password(self, new_password: str) -> None: 437 | """更新登录密码""" 438 | self.config['login_password'] = new_password 439 | self.save_config() 440 | 441 | def get_check_interval_hours(self) -> int: 442 | """获取检测间隔(小时)""" 443 | return self.config.get('check_interval_hours', 12) 444 | 445 | def update_check_interval_hours(self, hours: int) -> None: 446 | """更新检测间隔(小时)""" 447 | self.config['check_interval_hours'] = hours 448 | self.save_config() 449 | -------------------------------------------------------------------------------- /templates/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 设置 - Office 365 订阅监控 7 | 8 | 9 | 10 | 11 | 29 | 30 |
31 | 32 |
33 |

📋 订阅管理

34 | 37 |
38 | 39 |
40 | 41 |
42 | 43 | 46 | 47 | 48 |
49 |
50 |
🔔 Webhook 通知配置
51 |
52 |
53 |
54 |
55 | 56 | 58 |
通知将发送到此地址
59 |
60 |
61 | 62 | 64 |
65 | 支持的变量:
66 | • {title} - 标题(固定为"订阅监控通知")
67 | • {content}{通知消息} - 通知内容 68 |
69 |
70 |
71 | 72 | 74 |
75 | 当订阅剩余天数少于或等于此天数时,将发送到期提醒通知(默认30天) 76 |
77 |
78 |
79 | 80 | 82 |
83 | 系统自动检测订阅状态的时间间隔,范围 1-168 小时(默认12小时) 84 |
85 |
86 |
87 | 💡 通知触发条件: 88 |
    89 |
  • 订阅即将到期(剩余天数 ≤ 设置的提醒天数)
  • 90 |
  • 订阅已失效
  • 91 |
  • Cookie 失效(认证失败)
  • 92 |
  • 登录密码错误
  • 93 |
94 |
95 | 98 | 101 |
102 |
103 |
104 |
105 | 106 | 107 | 158 | 159 | 160 | 209 | 210 | 211 | 569 | 570 | 571 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Office 365 订阅监控 7 | 8 | 9 | 10 | 11 | 29 | 30 |
31 |
32 |
33 |

📋 订阅状态

34 |

实时监控您的 Office 365 订阅

35 |
36 |
37 | 40 | 43 |
44 |
45 | 46 |
47 | 48 |
49 | 50 | 53 |
54 | 55 | 56 | 99 | 100 | 101 | 152 | 153 | 154 | 216 | 217 | 218 | 265 | 266 | 267 | 283 | 284 | 285 | 350 | 351 | 352 | 353 | 1219 | 1220 | 1221 | --------------------------------------------------------------------------------