├── .gitignore ├── requirements.txt ├── .dockerignore ├── Dockerfile ├── .env.example ├── .github └── workflows │ └── docker-build.yml ├── docker-compose.yml ├── templates ├── error.html ├── admin_login.html ├── index.html ├── admin.html └── invite.html ├── README.md └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.venv 2 | .env 3 | test_add_user.py 4 | test_email_list.py 5 | test_invite.py 6 | auto_invite.py 7 | .claude/settings.local.json 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=3.0.0 2 | flask-session>=0.8.0 3 | python-dotenv>=1.0.0 4 | requests>=2.31.0 5 | redis>=5.0.0 6 | gunicorn>=21.0.0 7 | apscheduler>=3.10.0 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .env 4 | .env.* 5 | !.env.example 6 | __pycache__ 7 | *.pyc 8 | *.pyo 9 | .venv 10 | venv 11 | .idea 12 | .vscode 13 | *.md 14 | Dockerfile 15 | docker-compose*.yml 16 | .dockerignore 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # 安装系统依赖 6 | RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* 7 | 8 | # 安装 Python 依赖 9 | COPY requirements.txt . 10 | RUN pip install --no-cache-dir -r requirements.txt 11 | 12 | # 复制应用代码 13 | COPY . . 14 | 15 | # 暴露端口 16 | EXPOSE 5000 17 | 18 | # 启动命令 19 | CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "main:app"] 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Flask 2 | SECRET_KEY=your-secret-key-change-this 3 | 4 | # ChatGPT Team 配置 5 | AUTHORIZATION_TOKEN=Bearer xxx 6 | ACCOUNT_ID=your-account-id 7 | 8 | # Linux DO OAuth (在 https://connect.linux.do 申请) 9 | LINUXDO_CLIENT_ID=your-client-id 10 | LINUXDO_CLIENT_SECRET=your-client-secret 11 | LINUXDO_REDIRECT_URI=http://your-domain.com/callback 12 | 13 | # 邮箱平台配置 14 | EMAIL_API_AUTH=your-email-api-auth 15 | EMAIL_API_BASE=https://your-cloud-mail-api.com/api/public 16 | EMAIL_DOMAIN=your-email-domain.com 17 | EMAIL_ROLE=your-email-role 18 | 19 | # 最低信任等级要求 (0-4) 20 | MIN_TRUST_LEVEL=1 21 | 22 | # 后台管理密码 23 | ADMIN_PASSWORD=your-admin-password 24 | 25 | # Redis 配置 26 | REDIS_HOST=localhost 27 | REDIS_PORT=6379 28 | REDIS_PASSWORD= 29 | REDIS_DB=0 30 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - main 12 | workflow_dispatch: 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | packages: write 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Log in to Container Registry 33 | if: github.event_name != 'pull_request' 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Extract metadata (tags, labels) 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 45 | tags: | 46 | type=ref,event=branch 47 | type=ref,event=pr 48 | type=semver,pattern={{version}} 49 | type=semver,pattern={{major}}.{{minor}} 50 | type=sha,prefix= 51 | type=raw,value=latest,enable={{is_default_branch}} 52 | 53 | - name: Build and push Docker image 54 | uses: docker/build-push-action@v5 55 | with: 56 | context: . 57 | platforms: linux/amd64 58 | push: ${{ github.event_name != 'pull_request' }} 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} 61 | cache-from: type=gha 62 | cache-to: type=gha,mode=max 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | web: 5 | image: ghcr.io/james-6-23/team-invite-kfc:latest 6 | container_name: team-invite-web 7 | restart: unless-stopped 8 | ports: 9 | - "39001:5000" 10 | healthcheck: 11 | test: ["CMD", "curl", "-f", "http://localhost:5000/health"] 12 | interval: 30s 13 | timeout: 10s 14 | retries: 3 15 | start_period: 10s 16 | environment: 17 | - SECRET_KEY=${SECRET_KEY:-your-secret-key-here} 18 | # ChatGPT Team 19 | - AUTHORIZATION_TOKEN=${AUTHORIZATION_TOKEN} 20 | - ACCOUNT_ID=${ACCOUNT_ID} 21 | # Linux DO OAuth 22 | - LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID} 23 | - LINUXDO_CLIENT_SECRET=${LINUXDO_CLIENT_SECRET} 24 | - LINUXDO_REDIRECT_URI=${LINUXDO_REDIRECT_URI:-http://127.0.0.1:39001/callback} 25 | # 邮箱平台 26 | - EMAIL_API_AUTH=${EMAIL_API_AUTH} 27 | - EMAIL_API_BASE=${EMAIL_API_BASE} 28 | - EMAIL_DOMAIN=${EMAIL_DOMAIN} 29 | - EMAIL_ROLE=${EMAIL_ROLE:-gpt-team} 30 | # Redis 31 | - REDIS_HOST=redis 32 | - REDIS_PORT=6379 33 | - REDIS_PASSWORD=${REDIS_PASSWORD:-} 34 | - REDIS_DB=0 35 | # 其他配置 36 | - MIN_TRUST_LEVEL=${MIN_TRUST_LEVEL:-1} 37 | - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} 38 | depends_on: 39 | - redis 40 | networks: 41 | - team-invite-network 42 | 43 | redis: 44 | image: redis:7-alpine 45 | container_name: team-invite-redis 46 | restart: unless-stopped 47 | command: > 48 | sh -c "if [ -n \"$$REDIS_PASSWORD\" ]; then 49 | redis-server --appendonly yes --requirepass $$REDIS_PASSWORD; 50 | else 51 | redis-server --appendonly yes; 52 | fi" 53 | environment: 54 | - REDIS_PASSWORD=${REDIS_PASSWORD:-} 55 | volumes: 56 | - redis-data:/data 57 | networks: 58 | - team-invite-network 59 | 60 | volumes: 61 | redis-data: 62 | driver: local 63 | 64 | networks: 65 | team-invite-network: 66 | driver: bridge 67 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 错误 - Team 邀请助手 8 | 9 | 10 | 42 | 43 | 60 | 61 | 62 | 63 |
64 | 65 |
66 |
68 | 69 |
70 | 71 |

Something Went Wrong

72 | 73 |

{{ message }}

74 | 75 | 77 | 78 | Back to Home 79 | 80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Linux.do ChatGPT Team 邀请助手 2 | 3 |
4 | 5 | **Linux.do 社区 ChatGPT Team 自动邀请系统** 6 | 7 | [![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker)](https://ghcr.io/james-6-23/team-invite-kfc) 8 | [![Python](https://img.shields.io/badge/Python-3.10+-green?logo=python)](https://python.org) 9 | [![Flask](https://img.shields.io/badge/Flask-3.0+-red?logo=flask)](https://flask.palletsprojects.com) 10 | [![Redis](https://img.shields.io/badge/Redis-7+-orange?logo=redis)](https://redis.io) 11 | 12 |
13 | 14 | --- 15 | 16 | ## 📖 项目简介 17 | 18 | 这是一个专为 Linux.do 社区定制的 ChatGPT Team 自动邀请系统。它集成了 Linux DO OAuth 登录,并利用开源项目 [Cloud Mail](https://github.com/maillab/cloud-mail) 作为邮件服务后端,实现了从邮箱生成到邀请发送的全自动化流程。 19 | 20 | ## ✨ 功能特性 21 | 22 | - 🔐 **Linux DO OAuth 登录** - 安全的第三方认证,支持信任等级验证 23 | - 📧 **智能邮箱分配** - 集成 [Cloud Mail](https://github.com/maillab/cloud-mail),自动生成临时邮箱 24 | - 🎫 **自动邀请流程** - 一键发送 ChatGPT Team 邀请 25 | - 🔢 **验证码自动获取** - 自动从邮件系统提取验证码 26 | - 🛡️ **并发控制** - Redis 分布式锁机制防止超卖和并发问题 27 | - 📊 **后台管理** - 完整的邀请记录、统计面板及成员管理 28 | - 💾 **可靠存储** - Redis 持久化数据存储和 Session 管理 29 | - 🔄 **自动维护** - 后台定时任务自动刷新缓存和处理过期邀请 30 | - 🌓 **现代化 UI** - 支持深色/浅色主题切换 31 | 32 | --- 33 | 34 | ## 🏗️ 技术栈 35 | 36 | | 组件 | 技术 | 37 | |------|------| 38 | | **后端框架** | Flask 3.0+ | 39 | | **邮件服务** | [Cloud Mail](https://github.com/maillab/cloud-mail) (Cloudflare Workers) | 40 | | **Session 存储** | Flask-Session + Redis | 41 | | **数据持久化** | Redis 7+ | 42 | | **定时任务** | APScheduler | 43 | | **容器化** | Docker + Docker Compose | 44 | | **部署** | Gunicorn | 45 | 46 | --- 47 | 48 | ## 🚀 快速部署 49 | 50 | ### 前置准备 51 | 52 | 本项目依赖 [Cloud Mail](https://github.com/maillab/cloud-mail) 作为邮件后端。请先参考 Cloud Mail 文档部署您自己的邮件服务,并获取 API 地址和鉴权信息。 53 | 54 | ### 方式一:Docker Compose 部署(推荐) 55 | 56 | #### 1. 克隆仓库 57 | 58 | ```bash 59 | git clone https://github.com/james-6-23/team-invite-kfc.git 60 | cd team-invite-kfc 61 | ``` 62 | 63 | #### 2. 配置环境变量 64 | 65 | ```bash 66 | cp .env.example .env 67 | # 编辑 .env 填写配置,特别是邮件服务相关的配置 68 | ``` 69 | 70 | #### 3. 启动服务 71 | 72 | ```bash 73 | docker-compose up -d 74 | ``` 75 | 76 | #### 4. 访问应用 77 | 78 | 打开浏览器访问 `http://localhost:39001` 79 | 80 | ### 方式二:本地开发运行 81 | 82 | #### 1. 环境准备 83 | 84 | ```bash 85 | python -m venv .venv 86 | source .venv/bin/activate # Linux/macOS 87 | # .venv\Scripts\activate # Windows 88 | 89 | pip install -r requirements.txt 90 | ``` 91 | 92 | #### 2. 启动 Redis 93 | 94 | 需确保本地安装并运行 Redis (默认端口 6379)。 95 | 96 | #### 3. 运行应用 97 | 98 | ```bash 99 | cp .env.example .env 100 | # 填写配置 101 | python main.py 102 | ``` 103 | 104 | --- 105 | 106 | ## ⚙️ 配置说明 107 | 108 | ### 核心配置 109 | 110 | | 变量 | 说明 | 必填 | 默认值 | 111 | |------|------|:----:|--------| 112 | | `SECRET_KEY` | Flask 密钥(生产环境请修改) | ✅ | `dev_secret_key` | 113 | | `AUTHORIZATION_TOKEN` | ChatGPT Team 邀请者 Token | ✅ | - | 114 | | `ACCOUNT_ID` | ChatGPT Team 账户 ID | ✅ | - | 115 | 116 | ### Linux DO OAuth 配置 117 | 118 | | 变量 | 说明 | 必填 | 默认值 | 119 | |------|------|:----:|--------| 120 | | `LINUXDO_CLIENT_ID` | OAuth Client ID | ✅ | - | 121 | | `LINUXDO_CLIENT_SECRET` | OAuth Client Secret | ✅ | - | 122 | | `LINUXDO_REDIRECT_URI` | OAuth 回调地址 | ✅ | `http://127.0.0.1:39001/callback` | 123 | 124 | > 💡 在 [connect.linux.do](https://connect.linux.do) 申请 OAuth 应用 125 | 126 | ### 邮箱平台配置 (Cloud Mail) 127 | 128 | | 变量 | 说明 | 必填 | 默认值 | 129 | |------|------|:----:|--------| 130 | | `EMAIL_API_AUTH` | Cloud Mail API 密钥 | ✅ | - | 131 | | `EMAIL_API_BASE` | Cloud Mail API 地址 | ❌ | `https://your-cloud-mail.com/api/public` | 132 | | `EMAIL_DOMAIN` | 邮箱域名 | ❌ | `your-domain.com` | 133 | | `EMAIL_ROLE` | 邮箱角色标识 | ❌ | `gpt-team` | 134 | 135 | ### 其他配置 136 | 137 | | 变量 | 说明 | 必填 | 默认值 | 138 | |------|------|:----:|--------| 139 | | `ADMIN_PASSWORD` | 后台管理密码 | ❌ | `admin123` | 140 | | `MIN_TRUST_LEVEL` | 最低信任等级要求 (0-4) | ❌ | `1` | 141 | | `REDIS_HOST` | Redis 主机地址 | ❌ | `localhost` | 142 | 143 | --- 144 | 145 | ## 🔗 相关链接 146 | 147 | - 📧 邮件后端: [Cloud Mail (Open Source)](https://github.com/maillab/cloud-mail) 148 | - 💬 Linux DO: [https://linux.do/](https://linux.do/) 149 | - 🤖 ChatGPT: [https://chatgpt.com/](https://chatgpt.com/) 150 | - 🔑 OAuth 申请: [https://connect.linux.do/](https://connect.linux.do/) 151 | 152 | --- 153 | 154 | ## 📝 更新日志 155 | 156 | ### v1.0.0 157 | - ✅ Linux DO OAuth 登录集成 158 | - ✅ 集成 Cloud Mail 邮件服务 159 | - ✅ 自动邀请与验证码提取 160 | - ✅ 后台管理面板与数据统计 161 | - ✅ Redis 分布式锁与持久化 162 | - ✅ Docker 容器化支持 163 | 164 | --- 165 | 166 | ## 📄 许可证 167 | 168 | MIT License 169 | 170 | --- 171 | 172 |
173 | 174 | Made with ❤️ for Linux.do Community 175 | 176 |
177 | -------------------------------------------------------------------------------- /templates/admin_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 后台登录 - Team 邀请助手 8 | 9 | 10 | 11 | 52 | 53 | 112 | 113 | 114 | 115 |
116 | 117 | 118 |
120 | 121 | 122 |
124 | 125 |
127 |
128 | 129 | 130 |
131 |
133 | 134 |
135 |

管理后台

136 |

SYSTEM ADMINISTRATION

137 |
138 | 139 | 140 |
141 |
142 |
143 |
145 | 146 |
147 |
148 |
149 | 150 | 151 |
152 |
153 | 154 | SECURE GATEWAY v2.0 155 |
156 |
157 |
158 | 159 | 160 |
161 |
162 |
163 |

身份验证

164 |

请输入管理员访问密码以继续

165 |
166 | 167 |
168 | {% if error %} 169 |
171 | 172 | {{ error }} 173 |
174 | {% endif %} 175 | 176 |
177 | 179 |
180 | 182 | 184 |
185 |
186 | 187 | 192 | 193 | 200 |
201 |
202 |
203 |
204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ChatGPT Team KFC 8 | 9 | 10 | 11 | 52 | 53 | 160 | 161 | 162 | 163 |
164 | 165 | 166 | 177 | 178 | 179 |
180 |
181 |
182 |
183 |

正在检查车况...

184 |
185 |
186 | 187 | 188 | 386 | 387 | 532 | 533 | 534 | -------------------------------------------------------------------------------- /templates/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 后台管理 - Team 邀请助手 8 | 9 | 10 | 41 | 42 | 125 | 126 | 127 | 128 |
129 | 130 |
131 | 132 |
133 |
134 |
136 | 137 |
138 |
139 |

管理后台

140 |

Team 邀请管理系统

141 |
142 |
143 | 153 |
154 | 155 | 156 |
157 |
158 |
159 |
已上车
160 | 162 |
163 |
- 164 |
165 |
166 |
167 |
168 |
待上车
169 | 171 |
172 |
-
174 |
175 |
176 |
177 |
总车位
178 | 180 |
181 |
- 182 |
183 |
184 |
185 |
186 |
剩余名额
187 | 189 |
190 |
-
192 |
193 |
194 | 195 | 196 |
197 | 204 | 211 | 216 |
217 | 218 | 219 | 220 | 221 |
222 |
223 |
224 |

225 | 226 | 待处理邀请列表 227 |

228 | 232 |
233 | 234 |
235 | 236 | 237 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 250 | 251 | 252 |
邮箱地址角色邀请时间ID
248 | 正在加载... 249 |
253 | 254 | 261 |
262 | 263 | 264 | 283 |
284 |
285 | 286 | 287 | 352 | 353 | 354 | 422 |
423 | 424 | 711 | 712 | 713 | -------------------------------------------------------------------------------- /templates/invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | Team -获取验证码 9 | 10 | 13 | 48 | 49 | 428 | 429 | 430 | 431 |
432 | 433 | 434 |
436 |
437 | 438 | 439 |
440 | 441 |
442 |
443 | KFC-GPT-TEAM 444 |
445 |
446 | 447 | 451 |
453 | 通行证 454 |
455 |
456 |
457 | 458 | 459 |
460 | 461 |
462 | 463 | 466 | 467 | 468 |
469 |
470 | 471 | avatar 474 |
475 |
476 | 477 | 478 |
480 | 481 | 等级 482 | 3 483 |
484 |
485 | 486 | 487 |

488 | Shane User 489 |

490 | 491 | 492 |

493 | 494 | @shane 495 |

496 | 497 | 498 |
500 | 已认证会员 501 |
502 |
503 | 504 | 505 |
506 |
507 |
508 | 系统状态 509 | 510 | 连接中... 511 | 512 |
513 |
514 | 516 | 安全退出 517 | 518 |
519 |
520 | 521 | 522 | 523 | 524 | 525 |
526 | 527 | 528 |
529 | 530 | 544 | 545 | 546 |
547 | 548 | 549 |
550 | 551 |
552 |
专属邮箱
553 |
554 |
556 | shane@kyx03.de 557 |
558 | 562 |
563 |
564 | 565 | 566 |
567 |
登录密码
568 |
569 |
kfcvivo50
571 | 575 |
576 |
577 |
578 | 579 | 580 |
581 |
582 |
583 | 584 |
验证码
585 | 586 |
588 | --- --- 589 |
590 | 591 |
点击下方按钮获取
592 | 593 | 594 | 598 | 599 | 600 | 606 |
607 | 608 | 609 | 614 |
615 | 616 | 617 |
618 | 安全加密连接 619 | 用户: shane 620 |
621 |
622 |
623 | 624 | 625 | 1056 | 1057 | 1058 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify, redirect, url_for, session 2 | from flask_session import Session 3 | import requests 4 | import os 5 | import re 6 | import secrets 7 | import time 8 | import json 9 | import redis 10 | import atexit 11 | from dotenv import load_dotenv 12 | import logging 13 | from datetime import datetime, timedelta, timezone 14 | from apscheduler.schedulers.background import BackgroundScheduler 15 | 16 | load_dotenv() 17 | 18 | app = Flask(__name__) 19 | app.secret_key = os.getenv("SECRET_KEY", "dev_secret_key") 20 | 21 | # Session 配置 - 使用 Redis 存储 22 | app.config['SESSION_TYPE'] = 'redis' 23 | app.config['SESSION_PERMANENT'] = True 24 | app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) 25 | app.config['SESSION_KEY_PREFIX'] = 'team_invite:session:' 26 | app.config['SESSION_USE_SIGNER'] = True 27 | 28 | # ==================== 日志配置 ==================== 29 | logging.getLogger("werkzeug").setLevel(logging.ERROR) 30 | 31 | # 自定义日志格式 32 | log_formatter = logging.Formatter( 33 | '[%(asctime)s] %(levelname)s | %(message)s', 34 | datefmt='%Y-%m-%d %H:%M:%S' 35 | ) 36 | 37 | # 配置应用日志 38 | app.logger.setLevel(logging.INFO) 39 | for handler in app.logger.handlers: 40 | handler.setFormatter(log_formatter) 41 | 42 | # 如果没有 handler,添加一个 43 | if not app.logger.handlers: 44 | stream_handler = logging.StreamHandler() 45 | stream_handler.setFormatter(log_formatter) 46 | app.logger.addHandler(stream_handler) 47 | 48 | 49 | class No404Filter(logging.Filter): 50 | def filter(self, record): 51 | return not (getattr(record, "status_code", None) == 404) 52 | 53 | 54 | logging.getLogger("werkzeug").addFilter(No404Filter()) 55 | 56 | 57 | def log_info(module: str, action: str, message: str = "", **kwargs): 58 | """统一 INFO 日志格式""" 59 | extra = " | ".join(f"{k}={v}" for k, v in kwargs.items()) if kwargs else "" 60 | log_msg = f"[{module}] {action}" 61 | if message: 62 | log_msg += f" - {message}" 63 | if extra: 64 | log_msg += f" | {extra}" 65 | app.logger.info(log_msg) 66 | 67 | 68 | def log_error(module: str, action: str, message: str = "", **kwargs): 69 | """统一 ERROR 日志格式""" 70 | extra = " | ".join(f"{k}={v}" for k, v in kwargs.items()) if kwargs else "" 71 | log_msg = f"[{module}] {action}" 72 | if message: 73 | log_msg += f" - {message}" 74 | if extra: 75 | log_msg += f" | {extra}" 76 | app.logger.error(log_msg) 77 | 78 | 79 | def log_warn(module: str, action: str, message: str = "", **kwargs): 80 | """统一 WARNING 日志格式""" 81 | extra = " | ".join(f"{k}={v}" for k, v in kwargs.items()) if kwargs else "" 82 | log_msg = f"[{module}] {action}" 83 | if message: 84 | log_msg += f" - {message}" 85 | if extra: 86 | log_msg += f" | {extra}" 87 | app.logger.warning(log_msg) 88 | 89 | # ChatGPT Team 配置 90 | _token = os.getenv("AUTHORIZATION_TOKEN", "") 91 | AUTHORIZATION_TOKEN = _token if _token.startswith("Bearer ") else f"Bearer {_token}" 92 | ACCOUNT_ID = os.getenv("ACCOUNT_ID") 93 | 94 | # Cloudflare Turnstile (保留兼容) 95 | CF_TURNSTILE_SECRET_KEY = os.getenv("CF_TURNSTILE_SECRET_KEY") 96 | CF_TURNSTILE_SITE_KEY = os.getenv("CF_TURNSTILE_SITE_KEY") 97 | 98 | # Linux DO OAuth 配置 99 | LINUXDO_CLIENT_ID = os.getenv("LINUXDO_CLIENT_ID") 100 | LINUXDO_CLIENT_SECRET = os.getenv("LINUXDO_CLIENT_SECRET") 101 | LINUXDO_REDIRECT_URI = os.getenv("LINUXDO_REDIRECT_URI", "http://127.0.0.1:39001/callback") 102 | LINUXDO_AUTHORIZE_URL = "https://connect.linux.do/oauth2/authorize" 103 | LINUXDO_TOKEN_URL = "https://connect.linux.do/oauth2/token" 104 | LINUXDO_USER_URL = "https://connect.linux.do/api/user" 105 | 106 | # 邮箱平台配置 107 | EMAIL_API_AUTH = os.getenv("EMAIL_API_AUTH") 108 | EMAIL_API_BASE = os.getenv("EMAIL_API_BASE") 109 | EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN") 110 | EMAIL_ROLE = os.getenv("EMAIL_ROLE", "gpt-team") 111 | # Derive web URL from API base (assumes API is at /api/public or similar, trims path) 112 | EMAIL_WEB_URL = os.getenv("EMAIL_WEB_URL") 113 | if not EMAIL_WEB_URL and EMAIL_API_BASE: 114 | from urllib.parse import urlparse 115 | parsed = urlparse(EMAIL_API_BASE) 116 | EMAIL_WEB_URL = f"{parsed.scheme}://{parsed.netloc}" 117 | 118 | 119 | # 信任等级要求 120 | MIN_TRUST_LEVEL = int(os.getenv("MIN_TRUST_LEVEL", "1")) 121 | 122 | # 缓存配置 123 | STATS_CACHE_TTL = 120 # 统计数据缓存时间(秒) 124 | STATS_REFRESH_INTERVAL = 60 # 后台刷新间隔(秒) 125 | 126 | # Redis 配置 127 | REDIS_HOST = os.getenv("REDIS_HOST", "localhost") 128 | REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) 129 | REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") 130 | REDIS_DB = int(os.getenv("REDIS_DB", "0")) 131 | 132 | # Redis 连接池 133 | redis_pool = redis.ConnectionPool( 134 | host=REDIS_HOST, 135 | port=REDIS_PORT, 136 | password=REDIS_PASSWORD or None, 137 | db=REDIS_DB, 138 | decode_responses=True, 139 | max_connections=50, # 增加连接数以支持高并发 140 | socket_connect_timeout=5, 141 | socket_timeout=5, 142 | retry_on_timeout=True 143 | ) 144 | redis_client = redis.Redis(connection_pool=redis_pool) 145 | 146 | # Session 使用 Redis 存储(需要单独的连接,不使用 decode_responses) 147 | session_redis = redis.Redis( 148 | host=REDIS_HOST, 149 | port=REDIS_PORT, 150 | password=REDIS_PASSWORD or None, 151 | db=REDIS_DB 152 | ) 153 | app.config['SESSION_REDIS'] = session_redis 154 | Session(app) 155 | 156 | # Redis Key 157 | INVITE_RECORDS_KEY = "team_invite:records" 158 | INVITE_COUNTER_KEY = "team_invite:counter" 159 | INVITE_LOCK_KEY = "team_invite:lock" 160 | STATS_CACHE_KEY = "team_invite:stats_cache" 161 | PENDING_INVITES_CACHE_KEY = "team_invite:pending_invites" 162 | RATE_LIMIT_KEY = "team_invite:rate_limit" 163 | 164 | # 后台管理密码 165 | ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") 166 | 167 | # 统一密码 168 | DEFAULT_PASSWORD = "kfcvivo50" 169 | 170 | # 限流配置 171 | RATE_LIMIT_MAX_REQUESTS = 5 # 每个 IP 每分钟最大请求数 172 | RATE_LIMIT_WINDOW = 60 # 限流窗口(秒) 173 | 174 | 175 | def get_client_ip_address(): 176 | if "CF-Connecting-IP" in request.headers: 177 | return request.headers["CF-Connecting-IP"] 178 | if "X-Forwarded-For" in request.headers: 179 | return request.headers["X-Forwarded-For"].split(",")[0].strip() 180 | return request.remote_addr or "unknown" 181 | 182 | 183 | def check_rate_limit(identifier=None): 184 | """检查请求限流 185 | 186 | Args: 187 | identifier: 限流标识,默认使用客户端 IP 188 | 189 | Returns: 190 | tuple: (is_allowed, remaining, reset_time) 191 | """ 192 | if identifier is None: 193 | identifier = get_client_ip_address() 194 | 195 | key = f"{RATE_LIMIT_KEY}:{identifier}" 196 | 197 | try: 198 | # 使用 Redis pipeline 实现原子操作 199 | pipe = redis_client.pipeline() 200 | pipe.incr(key) 201 | pipe.ttl(key) 202 | results = pipe.execute() 203 | 204 | current_count = results[0] 205 | ttl = results[1] 206 | 207 | # 如果是新 key,设置过期时间 208 | if ttl == -1: 209 | redis_client.expire(key, RATE_LIMIT_WINDOW) 210 | ttl = RATE_LIMIT_WINDOW 211 | 212 | remaining = max(0, RATE_LIMIT_MAX_REQUESTS - current_count) 213 | is_allowed = current_count <= RATE_LIMIT_MAX_REQUESTS 214 | 215 | return is_allowed, remaining, ttl 216 | except Exception as e: 217 | log_error("RateLimit", "限流检查失败", str(e)) 218 | return True, RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW # 出错时放行 219 | 220 | 221 | def build_base_headers(): 222 | return { 223 | "accept": "*/*", 224 | "accept-language": "zh-CN,zh;q=0.9", 225 | "authorization": AUTHORIZATION_TOKEN, 226 | "chatgpt-account-id": ACCOUNT_ID, 227 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", 228 | } 229 | 230 | 231 | def build_invite_headers(): 232 | headers = build_base_headers() 233 | headers.update( 234 | { 235 | "content-type": "application/json", 236 | "origin": "https://chatgpt.com", 237 | "referer": "https://chatgpt.com/", 238 | 'sec-ch-ua': '"Chromium";v="135", "Not)A;Brand";v="99", "Google Chrome";v="135"', 239 | "sec-ch-ua-mobile": "?0", 240 | "sec-ch-ua-platform": '"Windows"', 241 | } 242 | ) 243 | return headers 244 | 245 | 246 | def parse_emails(raw_emails): 247 | if not raw_emails: 248 | return [], [] 249 | parts = raw_emails.replace("\n", ",").split(",") 250 | emails = [p.strip() for p in parts if p.strip()] 251 | valid = [e for e in emails if e.count("@") == 1] 252 | return emails, valid 253 | 254 | 255 | def validate_turnstile(turnstile_response): 256 | if not turnstile_response: 257 | return False 258 | data = { 259 | "secret": CF_TURNSTILE_SECRET_KEY, 260 | "response": turnstile_response, 261 | "remoteip": get_client_ip_address(), 262 | } 263 | try: 264 | response = requests.post( 265 | "https://challenges.cloudflare.com/turnstile/v0/siteverify", 266 | data=data, 267 | timeout=10, 268 | ) 269 | result = response.json() 270 | return result.get("success", False) 271 | except Exception: 272 | return False 273 | 274 | 275 | class TeamBannedException(Exception): 276 | """Team 账号被封禁异常""" 277 | pass 278 | 279 | 280 | def get_cached_stats(): 281 | """从 Redis 获取缓存的统计数据""" 282 | try: 283 | cached = redis_client.get(STATS_CACHE_KEY) 284 | if cached: 285 | return json.loads(cached) 286 | except Exception as e: 287 | log_error("Cache", "读取统计缓存失败", str(e)) 288 | return None 289 | 290 | 291 | def set_cached_stats(stats_data): 292 | """将统计数据存入 Redis 缓存""" 293 | try: 294 | cache_obj = { 295 | "data": stats_data, 296 | "timestamp": time.time(), 297 | "updated_at": datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S") 298 | } 299 | redis_client.setex(STATS_CACHE_KEY, STATS_CACHE_TTL, json.dumps(cache_obj)) 300 | except Exception as e: 301 | log_error("Cache", "写入统计缓存失败", str(e)) 302 | 303 | 304 | def get_cached_pending_invites(): 305 | """从 Redis 获取缓存的待处理邀请""" 306 | try: 307 | cached = redis_client.get(PENDING_INVITES_CACHE_KEY) 308 | if cached: 309 | return json.loads(cached) 310 | except Exception as e: 311 | log_error("Cache", "读取待处理邀请缓存失败", str(e)) 312 | return None 313 | 314 | 315 | def set_cached_pending_invites(items, total): 316 | """将待处理邀请存入 Redis 缓存""" 317 | try: 318 | cache_obj = { 319 | "items": items, 320 | "total": total, 321 | "timestamp": time.time() 322 | } 323 | redis_client.setex(PENDING_INVITES_CACHE_KEY, STATS_CACHE_TTL, json.dumps(cache_obj)) 324 | except Exception as e: 325 | log_error("Cache", "写入待处理邀请缓存失败", str(e)) 326 | 327 | 328 | def fetch_stats_from_api(): 329 | """从 API 获取最新统计数据(内部使用)""" 330 | base_headers = build_base_headers() 331 | subs_url = f"https://chatgpt.com/backend-api/subscriptions?account_id={ACCOUNT_ID}" 332 | invites_url = f"https://chatgpt.com/backend-api/accounts/{ACCOUNT_ID}/invites?offset=0&limit=1&query=" 333 | 334 | subs_resp = requests.get(subs_url, headers=base_headers, timeout=10) 335 | 336 | # 检查是否被封禁 (401/403 表示账号异常) 337 | if subs_resp.status_code in [401, 403]: 338 | log_error("Stats", "账号异常", "Team account banned or unauthorized", status=subs_resp.status_code) 339 | raise TeamBannedException("Team 账号状态异常") 340 | subs_resp.raise_for_status() 341 | subs_data = subs_resp.json() 342 | 343 | invites_resp = requests.get(invites_url, headers=base_headers, timeout=10) 344 | if invites_resp.status_code in [401, 403]: 345 | log_error("Stats", "账号异常", "Team account banned or unauthorized", status=invites_resp.status_code) 346 | raise TeamBannedException("Team 账号状态异常") 347 | invites_resp.raise_for_status() 348 | invites_data = invites_resp.json() 349 | 350 | return { 351 | "seats_in_use": subs_data.get("seats_in_use"), 352 | "seats_entitled": subs_data.get("seats_entitled"), 353 | "pending_invites": invites_data.get("total"), 354 | "plan_type": subs_data.get("plan_type"), 355 | "active_start": subs_data.get("active_start"), 356 | "active_until": subs_data.get("active_until"), 357 | "billing_period": subs_data.get("billing_period"), 358 | "billing_currency": subs_data.get("billing_currency"), 359 | "will_renew": subs_data.get("will_renew"), 360 | "is_delinquent": subs_data.get("is_delinquent"), 361 | } 362 | 363 | 364 | def refresh_stats(force=False): 365 | """获取统计数据(优先从缓存读取)""" 366 | if not force: 367 | cached = get_cached_stats() 368 | if cached: 369 | return cached["data"], cached.get("updated_at") 370 | 371 | # 缓存不存在或强制刷新,从 API 获取 372 | stats = fetch_stats_from_api() 373 | set_cached_stats(stats) 374 | updated_at = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S") 375 | log_info("Stats", "统计数据已刷新", seats_in_use=stats.get("seats_in_use"), pending=stats.get("pending_invites")) 376 | return stats, updated_at 377 | 378 | 379 | def background_refresh_stats(): 380 | """后台刷新统计数据(由定时任务调用)""" 381 | try: 382 | stats = fetch_stats_from_api() 383 | set_cached_stats(stats) 384 | log_info("Background", "统计数据刷新完成", seats=stats.get("seats_in_use"), pending=stats.get("pending_invites")) 385 | except TeamBannedException: 386 | log_error("Background", "统计刷新失败", "账号被封禁") 387 | except Exception as e: 388 | log_error("Background", "统计刷新失败", str(e)) 389 | 390 | 391 | # ==================== Linux DO OAuth 相关函数 ==================== 392 | 393 | def generate_email_for_user(username): 394 | """根据用户信息生成邮箱地址: {username}kfc@domain""" 395 | safe_username = re.sub(r'[^a-zA-Z0-9]', '', username.lower())[:20] 396 | return f"{safe_username}kfc@{EMAIL_DOMAIN}" 397 | 398 | 399 | def generate_password(): 400 | """返回统一密码""" 401 | return DEFAULT_PASSWORD 402 | 403 | 404 | def create_email_user(email, password, role_name): 405 | """在邮箱平台创建用户""" 406 | url = f"{EMAIL_API_BASE}/addUser" 407 | headers = { 408 | "Authorization": EMAIL_API_AUTH, 409 | "Content-Type": "application/json" 410 | } 411 | payload = { 412 | "list": [{"email": email, "password": password, "roleName": role_name}] 413 | } 414 | try: 415 | log_info("Email", "创建邮箱", email=email, role=role_name) 416 | response = requests.post(url, headers=headers, json=payload, timeout=10) 417 | data = response.json() 418 | success = data.get("code") == 200 419 | msg = data.get("message", "Unknown error") 420 | if success: 421 | log_info("Email", "创建成功", email=email) 422 | else: 423 | log_warn("Email", "创建失败", msg, email=email, code=data.get("code")) 424 | return success, msg 425 | except Exception as e: 426 | log_error("Email", "创建异常", str(e), email=email) 427 | return False, str(e) 428 | 429 | 430 | def fetch_pending_invites_from_api(limit=1000): 431 | """从 API 获取待处理邀请列表(内部使用) 432 | 433 | 支持分页获取所有数据 434 | """ 435 | headers = build_base_headers() 436 | all_items = [] 437 | offset = 0 438 | page_size = 100 # API 单次最大返回数 439 | total = 0 440 | 441 | try: 442 | while True: 443 | url = f"https://chatgpt.com/backend-api/accounts/{ACCOUNT_ID}/invites?offset={offset}&limit={page_size}&query=" 444 | response = requests.get(url, headers=headers, timeout=10) 445 | 446 | if response.status_code != 200: 447 | log_error("Invite", "获取待处理邀请失败", status=response.status_code) 448 | break 449 | 450 | data = response.json() 451 | items = data.get("items", []) 452 | total = data.get("total", 0) 453 | all_items.extend(items) 454 | 455 | # 如果获取的数据少于 page_size,说明已经是最后一页 456 | if len(items) < page_size or len(all_items) >= total or len(all_items) >= limit: 457 | break 458 | 459 | offset += page_size 460 | 461 | return all_items[:limit], total 462 | except Exception as e: 463 | log_error("Invite", "获取待处理邀请异常", str(e)) 464 | return [], 0 465 | 466 | 467 | def get_pending_invites(force=False): 468 | """获取待处理邀请列表(优先从缓存读取)""" 469 | if not force: 470 | cached = get_cached_pending_invites() 471 | if cached: 472 | return cached["items"], cached["total"] 473 | 474 | # 缓存不存在或强制刷新 475 | items, total = fetch_pending_invites_from_api(100) 476 | set_cached_pending_invites(items, total) 477 | return items, total 478 | 479 | 480 | def check_invite_pending(email): 481 | """检查邮箱是否在待处理邀请列表中(实时查询,不使用缓存)""" 482 | items, _ = fetch_pending_invites_from_api(100) 483 | for item in items: 484 | if item.get("email_address", "").lower() == email.lower(): 485 | return True 486 | return False 487 | 488 | 489 | def check_user_already_invited(email): 490 | """检查用户是否已经被邀请过(在待处理邀请列表中) 491 | 492 | 优先使用缓存,避免登录时阻塞太久 493 | """ 494 | # 先检查缓存 495 | cached = get_cached_pending_invites() 496 | if cached: 497 | items = cached.get("items", []) 498 | for item in items: 499 | if item.get("email_address", "").lower() == email.lower(): 500 | return True 501 | 502 | # 缓存没有,实时查询一次 503 | items, _ = fetch_pending_invites_from_api(100) 504 | for item in items: 505 | if item.get("email_address", "").lower() == email.lower(): 506 | return True 507 | return False 508 | 509 | 510 | def fetch_space_members_from_api(limit=1000): 511 | """从 API 获取空间成员列表 512 | 513 | 支持分页获取所有数据 514 | """ 515 | headers = build_base_headers() 516 | all_items = [] 517 | offset = 0 518 | page_size = 100 # API 单次最大返回数 519 | total = 0 520 | 521 | try: 522 | while True: 523 | url = f"https://chatgpt.com/backend-api/accounts/{ACCOUNT_ID}/users?offset={offset}&limit={page_size}&query=" 524 | response = requests.get(url, headers=headers, timeout=10) 525 | 526 | if response.status_code != 200: 527 | log_error("Members", "获取空间成员失败", status=response.status_code) 528 | break 529 | 530 | data = response.json() 531 | items = data.get("items", []) 532 | total = data.get("total", 0) 533 | all_items.extend(items) 534 | 535 | # 如果获取的数据少于 page_size,说明已经是最后一页 536 | if len(items) < page_size or len(all_items) >= total or len(all_items) >= limit: 537 | break 538 | 539 | offset += page_size 540 | 541 | return all_items[:limit], total 542 | except Exception as e: 543 | log_error("Members", "获取空间成员异常", str(e)) 544 | return [], 0 545 | 546 | 547 | def check_user_in_space(email): 548 | """检查用户是否已经在空间中(已接受邀请成为成员) 549 | 550 | Returns: 551 | bool: True 表示已在空间中 552 | """ 553 | items, _ = fetch_space_members_from_api(100) 554 | for item in items: 555 | member_email = item.get("email", "").lower() 556 | if member_email == email.lower(): 557 | return True 558 | return False 559 | 560 | 561 | def get_user_invite_status(email): 562 | """获取用户的邀请状态 563 | 564 | Returns: 565 | str: 'in_space' - 已在空间中 566 | 'pending' - 有待处理邀请 567 | 'new' - 新用户,需要邀请 568 | """ 569 | # 1. 先检查是否已在空间中 570 | if check_user_in_space(email): 571 | return 'in_space' 572 | 573 | # 2. 检查是否有待处理邀请 574 | if check_user_already_invited(email): 575 | return 'pending' 576 | 577 | # 3. 新用户 578 | return 'new' 579 | 580 | 581 | def background_refresh_pending_invites(): 582 | """后台刷新待处理邀请(由定时任务调用)""" 583 | try: 584 | items, total = fetch_pending_invites_from_api(100) 585 | set_cached_pending_invites(items, total) 586 | log_info("Background", "待处理邀请刷新完成", total=total) 587 | except Exception as e: 588 | log_error("Background", "待处理邀请刷新失败", str(e)) 589 | 590 | 591 | def send_chatgpt_invite(email): 592 | """发送 ChatGPT Team 邀请""" 593 | url = f"https://chatgpt.com/backend-api/accounts/{ACCOUNT_ID}/invites" 594 | headers = build_invite_headers() 595 | payload = { 596 | "email_addresses": [email], 597 | "role": "standard-user", 598 | "resend_emails": True 599 | } 600 | 601 | log_info("Invite", "发送邀请", email=email, account_id=ACCOUNT_ID[:8] + "..." if ACCOUNT_ID else "None") 602 | 603 | try: 604 | response = requests.post(url, headers=headers, json=payload, timeout=10) 605 | if response.status_code == 200: 606 | # 验证邀请是否真的发送成功 607 | if check_invite_pending(email): 608 | log_info("Invite", "邀请成功(已验证)", email=email) 609 | return True, "success" 610 | else: 611 | log_warn("Invite", "邀请状态不确定", "API返回200但未在待处理列表中找到", email=email) 612 | return True, "success" # 仍然返回成功,可能有延迟 613 | else: 614 | log_error("Invite", "邀请失败", response.text[:200], email=email, status=response.status_code) 615 | return False, f"HTTP {response.status_code}: {response.text[:200]}" 616 | except Exception as e: 617 | log_error("Invite", "邀请异常", str(e), email=email) 618 | return False, str(e) 619 | 620 | 621 | def get_verification_code(email, max_retries=10, interval=3): 622 | """从邮箱获取验证码 623 | 624 | Returns: 625 | tuple: (code, error, email_time) - 验证码、错误信息、邮件时间 626 | """ 627 | url = f"{EMAIL_API_BASE}/emailList" 628 | headers = { 629 | "Authorization": EMAIL_API_AUTH, 630 | "Content-Type": "application/json" 631 | } 632 | payload = {"toEmail": email} 633 | 634 | log_info("Code", "获取验证码", email=email, max_retries=max_retries) 635 | 636 | for i in range(max_retries): 637 | try: 638 | response = requests.post(url, headers=headers, json=payload, timeout=10) 639 | data = response.json() 640 | 641 | if data.get("code") == 200: 642 | emails = data.get("data", []) 643 | if emails: 644 | latest_email = emails[0] 645 | subject = latest_email.get("subject", "") 646 | email_time = latest_email.get("createTime", "") # 获取邮件时间 647 | match = re.search(r"代码为\s*(\d{6})", subject) 648 | if match: 649 | code = match.group(1) 650 | log_info("Code", "验证码获取成功", email=email, code=code, attempt=i+1) 651 | return code, None, email_time 652 | except Exception as e: 653 | log_error("Code", "获取异常", str(e), email=email, attempt=i+1) 654 | 655 | if i < max_retries - 1: 656 | time.sleep(interval) 657 | 658 | log_warn("Code", "验证码获取失败", email=email, attempts=max_retries) 659 | return None, "未能获取验证码", None 660 | 661 | 662 | def is_logged_in(): 663 | """检查用户是否已登录""" 664 | return "user" in session 665 | 666 | 667 | def get_current_user(): 668 | """获取当前登录用户""" 669 | return session.get("user") 670 | 671 | 672 | def check_seats_available(force_refresh=False): 673 | """检查是否还有可用名额 674 | 675 | Args: 676 | force_refresh: 是否强制刷新数据(高并发场景下应该为 True) 677 | """ 678 | try: 679 | data, _ = refresh_stats(force=force_refresh) 680 | if not data: 681 | return False, None # 无法获取数据时默认拒绝(更安全) 682 | seats_in_use = data.get("seats_in_use", 0) 683 | seats_entitled = data.get("seats_entitled", 0) 684 | pending = data.get("pending_invites", 0) 685 | available = seats_entitled - seats_in_use - pending 686 | return available > 0, data 687 | except TeamBannedException: 688 | raise # 传递给上层处理 689 | except Exception: 690 | return False, None # 出错时默认拒绝 691 | 692 | 693 | def acquire_invite_lock(user_id, timeout=30): 694 | """获取邀请分布式锁,防止并发超卖 695 | 696 | Args: 697 | user_id: 用户ID,用于锁的唯一标识 698 | timeout: 锁超时时间(秒) 699 | 700 | Returns: 701 | lock_token: 锁令牌,用于释放锁;None 表示获取失败 702 | """ 703 | lock_key = f"{INVITE_LOCK_KEY}:{user_id}" 704 | lock_token = secrets.token_hex(8) 705 | 706 | # 使用 SET NX EX 原子操作获取锁 707 | acquired = redis_client.set(lock_key, lock_token, nx=True, ex=timeout) 708 | return lock_token if acquired else None 709 | 710 | 711 | def release_invite_lock(user_id, lock_token): 712 | """释放邀请锁 713 | 714 | 使用 Lua 脚本确保只有持有锁的人才能释放 715 | """ 716 | lock_key = f"{INVITE_LOCK_KEY}:{user_id}" 717 | lua_script = """ 718 | if redis.call("get", KEYS[1]) == ARGV[1] then 719 | return redis.call("del", KEYS[1]) 720 | else 721 | return 0 722 | end 723 | """ 724 | try: 725 | redis_client.eval(lua_script, 1, lock_key, lock_token) 726 | except Exception: 727 | pass # 释放锁失败不影响主流程 728 | 729 | 730 | def acquire_global_invite_lock(timeout=30): 731 | """获取全局邀请锁,用于检查和发送邀请的原子操作 732 | 733 | timeout 设置为 30 秒,足够完成创建邮箱和发送邀请的操作 734 | """ 735 | lock_token = secrets.token_hex(8) 736 | acquired = redis_client.set(INVITE_LOCK_KEY, lock_token, nx=True, ex=timeout) 737 | return lock_token if acquired else None 738 | 739 | 740 | def release_global_invite_lock(lock_token): 741 | """释放全局邀请锁""" 742 | lua_script = """ 743 | if redis.call("get", KEYS[1]) == ARGV[1] then 744 | return redis.call("del", KEYS[1]) 745 | else 746 | return 0 747 | end 748 | """ 749 | try: 750 | redis_client.eval(lua_script, 1, INVITE_LOCK_KEY, lock_token) 751 | except Exception: 752 | pass 753 | 754 | 755 | # ==================== 并发信号量控制 ==================== 756 | SEMAPHORE_KEY = "team_invite:semaphore" 757 | SEMAPHORE_MAX_CONCURRENT = 20 # 最大同时处理 20 个邀请请求 758 | SEMAPHORE_TIMEOUT = 60 # 信号量超时时间(秒) 759 | 760 | 761 | def acquire_semaphore(timeout=SEMAPHORE_TIMEOUT): 762 | """获取信号量槽位,允许有限并发 763 | 764 | 使用 Redis Sorted Set 实现分布式信号量 765 | - score: 过期时间戳 766 | - member: 唯一令牌 767 | 768 | Returns: 769 | str: 令牌,用于释放;None 表示获取失败 770 | """ 771 | token = secrets.token_hex(8) 772 | now = time.time() 773 | expire_at = now + timeout 774 | 775 | try: 776 | # Lua 脚本:原子性地清理过期项并尝试获取槽位 777 | lua_script = """ 778 | local key = KEYS[1] 779 | local max_concurrent = tonumber(ARGV[1]) 780 | local now = tonumber(ARGV[2]) 781 | local expire_at = tonumber(ARGV[3]) 782 | local token = ARGV[4] 783 | 784 | -- 清理过期的槽位 785 | redis.call('ZREMRANGEBYSCORE', key, '-inf', now) 786 | 787 | -- 检查当前占用数 788 | local current = redis.call('ZCARD', key) 789 | 790 | if current < max_concurrent then 791 | -- 有空闲槽位,获取 792 | redis.call('ZADD', key, expire_at, token) 793 | return 1 794 | else 795 | return 0 796 | end 797 | """ 798 | result = redis_client.eval( 799 | lua_script, 1, SEMAPHORE_KEY, 800 | SEMAPHORE_MAX_CONCURRENT, now, expire_at, token 801 | ) 802 | 803 | if result == 1: 804 | return token 805 | return None 806 | except Exception as e: 807 | log_error("Semaphore", "获取信号量失败", str(e)) 808 | return None 809 | 810 | 811 | def release_semaphore(token): 812 | """释放信号量槽位""" 813 | try: 814 | redis_client.zrem(SEMAPHORE_KEY, token) 815 | except Exception: 816 | pass 817 | 818 | 819 | def get_semaphore_status(): 820 | """获取信号量状态""" 821 | try: 822 | now = time.time() 823 | # 清理过期项 824 | redis_client.zremrangebyscore(SEMAPHORE_KEY, '-inf', now) 825 | # 获取当前占用数 826 | current = redis_client.zcard(SEMAPHORE_KEY) 827 | return { 828 | "current": current, 829 | "max": SEMAPHORE_MAX_CONCURRENT, 830 | "available": SEMAPHORE_MAX_CONCURRENT - current 831 | } 832 | except Exception: 833 | return {"current": 0, "max": SEMAPHORE_MAX_CONCURRENT, "available": SEMAPHORE_MAX_CONCURRENT} 834 | 835 | 836 | def add_invite_record(user, email, password, success, message=""): 837 | """添加邀请记录到 Redis""" 838 | try: 839 | record_id = redis_client.incr(INVITE_COUNTER_KEY) 840 | record = { 841 | "id": record_id, 842 | "linuxdo_id": user.get("id"), 843 | "linuxdo_username": user.get("username"), 844 | "linuxdo_name": user.get("name"), 845 | "trust_level": user.get("trust_level"), 846 | "email": email, 847 | "password": password, 848 | "success": success, 849 | "message": message, 850 | "created_at": datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S"), 851 | "ip": get_client_ip_address() 852 | } 853 | redis_client.lpush(INVITE_RECORDS_KEY, json.dumps(record)) 854 | return record 855 | except Exception as e: 856 | log_error("Redis", "保存记录失败", str(e)) 857 | return None 858 | 859 | 860 | def get_invite_records(limit=100): 861 | """从 Redis 获取邀请记录""" 862 | try: 863 | records = redis_client.lrange(INVITE_RECORDS_KEY, 0, limit - 1) 864 | return [json.loads(r) for r in records] 865 | except Exception as e: 866 | log_error("Redis", "获取记录失败", str(e)) 867 | return [] 868 | 869 | 870 | def get_invite_stats(): 871 | """获取邀请统计""" 872 | try: 873 | records = get_invite_records(1000) 874 | total = len(records) 875 | success_count = sum(1 for r in records if r.get("success")) 876 | return { 877 | "total_invites": total, 878 | "success_count": success_count, 879 | "fail_count": total - success_count 880 | } 881 | except Exception: 882 | return {"total_invites": 0, "success_count": 0, "fail_count": 0} 883 | 884 | 885 | # ==================== 自动邀请处理 ==================== 886 | 887 | def process_auto_invite(user): 888 | """处理自动邀请流程(登录后自动调用) 889 | 890 | Returns: 891 | dict: 邀请结果,包含 success, email, password, message 等字段 892 | """ 893 | user_id = user.get("id") 894 | username = user.get("username") 895 | client_ip = get_client_ip_address() 896 | 897 | result = { 898 | "success": False, 899 | "email": None, 900 | "password": None, 901 | "message": "", 902 | "code": None, 903 | "seats_full": False, 904 | "banned": False 905 | } 906 | 907 | # 1. 获取用户锁(防止同一用户重复提交) 908 | user_lock = acquire_invite_lock(user_id, timeout=60) 909 | if not user_lock: 910 | result["message"] = "您有一个邀请正在处理中,请稍后再试" 911 | return result 912 | 913 | try: 914 | # 2. 获取全局锁(防止并发超卖) 915 | global_lock = None 916 | for _ in range(3): 917 | global_lock = acquire_global_invite_lock(timeout=15) 918 | if global_lock: 919 | break 920 | time.sleep(0.5) 921 | 922 | if not global_lock: 923 | result["message"] = "系统繁忙,请稍后再试" 924 | return result 925 | 926 | try: 927 | # 3. 检查名额 928 | try: 929 | available, stats_data = check_seats_available(force_refresh=True) 930 | except TeamBannedException: 931 | result["message"] = "车已翻 - Team 账号状态异常" 932 | result["banned"] = True 933 | return result 934 | 935 | if not available: 936 | result["message"] = "车门已焊死,名额已满" 937 | result["seats_full"] = True 938 | return result 939 | 940 | # 4. 生成邮箱和密码 941 | email = generate_email_for_user(username) 942 | password = generate_password() 943 | result["email"] = email 944 | result["password"] = password 945 | 946 | log_info("Invite", "开始自动邀请流程", username=username, email=email, ip=client_ip) 947 | 948 | # 5. 创建邮箱用户 949 | success, msg = create_email_user(email, password, EMAIL_ROLE) 950 | if not success and "已存在" not in msg: 951 | result["message"] = f"创建邮箱失败: {msg}" 952 | add_invite_record(user, email, password, False, result["message"]) 953 | return result 954 | 955 | # 6. 发送 ChatGPT 邀请 956 | success, msg = send_chatgpt_invite(email) 957 | if not success: 958 | result["message"] = f"发送邀请失败: {msg}" 959 | add_invite_record(user, email, password, False, result["message"]) 960 | return result 961 | 962 | # 7. 邀请成功,保存待处理邀请信息用于轮询验证码 963 | session["pending_invite"] = { 964 | "email": email, 965 | "password": password, 966 | "created_at": time.time() 967 | } 968 | 969 | result["success"] = True 970 | result["message"] = "邀请发送成功,正在等待验证码" 971 | add_invite_record(user, email, password, True, "邀请发送成功") 972 | 973 | return result 974 | 975 | finally: 976 | if global_lock: 977 | release_global_invite_lock(global_lock) 978 | finally: 979 | release_invite_lock(user_id, user_lock) 980 | 981 | 982 | # ==================== OAuth 路由 ==================== 983 | 984 | @app.route("/login") 985 | def login(): 986 | """重定向到 Linux DO OAuth 授权页面""" 987 | state = secrets.token_urlsafe(16) 988 | session["oauth_state"] = state 989 | 990 | params = { 991 | "client_id": LINUXDO_CLIENT_ID, 992 | "response_type": "code", 993 | "redirect_uri": LINUXDO_REDIRECT_URI, 994 | "state": state, 995 | } 996 | auth_url = f"{LINUXDO_AUTHORIZE_URL}?{'&'.join(f'{k}={v}' for k, v in params.items())}" 997 | return redirect(auth_url) 998 | 999 | 1000 | @app.route("/callback") 1001 | def callback(): 1002 | """OAuth 回调处理""" 1003 | error = request.args.get("error") 1004 | if error: 1005 | return render_template("error.html", message=f"授权失败: {error}"), 400 1006 | 1007 | code = request.args.get("code") 1008 | state = request.args.get("state") 1009 | 1010 | if not code: 1011 | return render_template("error.html", message="未收到授权码"), 400 1012 | 1013 | if state != session.get("oauth_state"): 1014 | return render_template("error.html", message="状态验证失败,请重试"), 400 1015 | 1016 | # 用授权码换取 access_token 1017 | token_data = { 1018 | "grant_type": "authorization_code", 1019 | "code": code, 1020 | "redirect_uri": LINUXDO_REDIRECT_URI, 1021 | "client_id": LINUXDO_CLIENT_ID, 1022 | "client_secret": LINUXDO_CLIENT_SECRET, 1023 | } 1024 | 1025 | try: 1026 | token_resp = requests.post(LINUXDO_TOKEN_URL, data=token_data, timeout=10) 1027 | token_json = token_resp.json() 1028 | 1029 | if "access_token" not in token_json: 1030 | log_error("Auth", "Token exchange failed", str(token_json)) 1031 | return render_template("error.html", message="获取令牌失败"), 400 1032 | 1033 | access_token = token_json["access_token"] 1034 | 1035 | # 获取用户信息 1036 | user_resp = requests.get( 1037 | LINUXDO_USER_URL, 1038 | headers={"Authorization": f"Bearer {access_token}"}, 1039 | timeout=10 1040 | ) 1041 | user_info = user_resp.json() 1042 | 1043 | # 检查信任等级 1044 | trust_level = user_info.get("trust_level", 0) 1045 | if trust_level < MIN_TRUST_LEVEL: 1046 | return render_template( 1047 | "error.html", 1048 | message=f"您的信任等级为 {trust_level},需要达到 {MIN_TRUST_LEVEL} 级才能使用此服务" 1049 | ), 403 1050 | 1051 | # 保存用户信息到 session 1052 | user = { 1053 | "id": user_info.get("id"), 1054 | "username": user_info.get("username"), 1055 | "name": user_info.get("name"), 1056 | "avatar_template": user_info.get("avatar_template"), 1057 | "trust_level": trust_level, 1058 | "active": user_info.get("active"), 1059 | } 1060 | session["user"] = user 1061 | 1062 | log_info("Auth", "用户登录", username=user_info.get("username"), trust_level=trust_level) 1063 | session.permanent = True # 启用持久化 Session 1064 | 1065 | # 检查用户邀请状态(三种状态) 1066 | user_email = generate_email_for_user(user["username"]) 1067 | invite_status = get_user_invite_status(user_email) 1068 | 1069 | # 保存用户邮箱信息 1070 | session["user_email"] = user_email 1071 | session["user_password"] = generate_password() 1072 | 1073 | if invite_status == 'in_space': 1074 | # 用户已在空间中,不需要邀请也不需要验证码 1075 | log_info("Auth", "用户已在空间中", username=user["username"], email=user_email) 1076 | session["invite_status"] = "in_space" 1077 | session["need_invite"] = False 1078 | session["pending_invite"] = None 1079 | elif invite_status == 'pending': 1080 | # 用户有待处理邀请,可以获取验证码 1081 | log_info("Auth", "用户有待处理邀请", username=user["username"], email=user_email) 1082 | session["invite_status"] = "pending" 1083 | session["need_invite"] = False 1084 | session["pending_invite"] = { 1085 | "email": user_email, 1086 | "password": generate_password(), 1087 | "created_at": time.time() 1088 | } 1089 | else: 1090 | # 新用户,需要发送邀请 1091 | log_info("Auth", "新用户,需要邀请", username=user["username"], email=user_email) 1092 | session["invite_status"] = "new" 1093 | session["need_invite"] = True 1094 | session["pending_invite"] = None 1095 | 1096 | return redirect(url_for("invite_page")) 1097 | 1098 | except Exception as e: 1099 | log_error("Auth", "OAuth callback error", str(e)) 1100 | return render_template("error.html", message=f"认证过程出错: {str(e)}"), 500 1101 | 1102 | 1103 | @app.route("/logout") 1104 | def logout(): 1105 | """登出""" 1106 | session.clear() 1107 | return redirect(url_for("index")) 1108 | 1109 | 1110 | @app.route("/invite") 1111 | def invite_page(): 1112 | """邀请页面 - 需要登录""" 1113 | if not is_logged_in(): 1114 | return redirect(url_for("login")) 1115 | user = get_current_user() 1116 | # 检查是否需要执行邀请(新登录用户) 1117 | need_invite = session.pop("need_invite", False) 1118 | invite_result = session.get("invite_result", {}) 1119 | # 获取待处理邀请信息(用于已邀请用户直接获取验证码) 1120 | pending_invite = session.get("pending_invite") or {} 1121 | # 获取邀请状态:in_space / pending / new 1122 | invite_status = session.get("invite_status", "new") 1123 | # 获取用户邮箱信息 1124 | user_email = session.get("user_email", "") 1125 | user_password = session.get("user_password", DEFAULT_PASSWORD) 1126 | return render_template( 1127 | "invite.html", 1128 | user=user, 1129 | invite_result=invite_result, 1130 | need_invite=need_invite, 1131 | pending_invite=pending_invite, 1132 | invite_status=invite_status, 1133 | user_email=user_email, 1134 | user_password=user_password, 1135 | email_web_url=EMAIL_WEB_URL, 1136 | email_domain=EMAIL_DOMAIN 1137 | ) 1138 | 1139 | 1140 | @app.route("/api/auto-invite", methods=["POST"]) 1141 | def auto_invite(): 1142 | """自动邀请 API - 执行完整的邀请流程(支持高并发) 1143 | 1144 | 并发控制策略: 1145 | 1. IP 限流 - 防止单 IP 刷请求 1146 | 2. 用户锁 - 防止同一用户重复提交 1147 | 3. 信号量 - 限制同时处理的请求数(最多 20 个并发) 1148 | 4. 短暂全局锁 - 仅在检查名额时使用,快速释放 1149 | """ 1150 | if not is_logged_in(): 1151 | return jsonify({"success": False, "message": "请先登录"}), 401 1152 | 1153 | user = get_current_user() 1154 | user_id = user.get("id") 1155 | client_ip = get_client_ip_address() 1156 | 1157 | # 0. IP 限流检查 1158 | is_allowed, remaining, reset_time = check_rate_limit(client_ip) 1159 | if not is_allowed: 1160 | log_warn("RateLimit", "请求被限流", ip=client_ip, reset_in=reset_time) 1161 | return jsonify({ 1162 | "success": False, 1163 | "message": f"请求过于频繁,请 {reset_time} 秒后再试", 1164 | "retry_after": reset_time, 1165 | "rate_limited": True 1166 | }), 429 1167 | 1168 | # 1. 获取用户锁(防止同一用户重复提交) 1169 | user_lock = acquire_invite_lock(user_id, timeout=60) 1170 | if not user_lock: 1171 | return jsonify({ 1172 | "success": False, 1173 | "message": "您有一个邀请正在处理中,请稍后再试", 1174 | "retry_after": 10 1175 | }), 429 1176 | 1177 | semaphore_token = None 1178 | try: 1179 | # 2. 获取信号量槽位(限制并发数,最多 20 个同时处理) 1180 | semaphore_token = acquire_semaphore(timeout=60) 1181 | if not semaphore_token: 1182 | sem_status = get_semaphore_status() 1183 | log_warn("Semaphore", "系统繁忙", current=sem_status["current"], max=sem_status["max"]) 1184 | return jsonify({ 1185 | "success": False, 1186 | "message": f"系统繁忙,当前有 {sem_status['current']} 个请求正在处理,请稍后再试", 1187 | "retry_after": 5, 1188 | "queue_full": True 1189 | }), 503 1190 | 1191 | # 3. 短暂获取全局锁,仅用于检查名额(快速释放) 1192 | global_lock = None 1193 | for _ in range(5): # 重试5次获取全局锁 1194 | global_lock = acquire_global_invite_lock(timeout=5) # 短超时 1195 | if global_lock: 1196 | break 1197 | time.sleep(0.2) 1198 | 1199 | if not global_lock: 1200 | return jsonify({ 1201 | "success": False, 1202 | "message": "系统繁忙,请稍后再试", 1203 | "retry_after": 3 1204 | }), 503 1205 | 1206 | # 4. 在锁内快速检查名额 1207 | try: 1208 | available, stats_data = check_seats_available(force_refresh=True) 1209 | except TeamBannedException: 1210 | release_global_invite_lock(global_lock) 1211 | return jsonify({ 1212 | "success": False, 1213 | "message": "车已翻 - Team 账号状态异常", 1214 | "banned": True 1215 | }), 503 1216 | 1217 | # 立即释放全局锁,后续操作不需要锁 1218 | release_global_invite_lock(global_lock) 1219 | global_lock = None 1220 | 1221 | if not available: 1222 | return jsonify({ 1223 | "success": False, 1224 | "message": "车门已焊死,名额已满", 1225 | "seats_full": True, 1226 | "stats": stats_data 1227 | }) 1228 | 1229 | # 5. 名额充足,开始邀请流程(无需全局锁,信号量已限制并发) 1230 | email = generate_email_for_user(user["username"]) 1231 | password = generate_password() 1232 | 1233 | log_info("Invite", "开始自动邀请流程", username=user["username"], email=email, ip=client_ip) 1234 | 1235 | result = { 1236 | "email": email, 1237 | "password": password, 1238 | "steps": [] 1239 | } 1240 | 1241 | # 步骤1: 创建邮箱用户(并发执行,无锁) 1242 | success, msg = create_email_user(email, password, EMAIL_ROLE) 1243 | result["steps"].append({ 1244 | "step": 1, 1245 | "name": "创建邮箱用户", 1246 | "success": success, 1247 | "message": msg if not success else "成功" 1248 | }) 1249 | if not success and "已存在" not in msg: 1250 | add_invite_record(user, email, password, False, f"创建邮箱失败: {msg}") 1251 | return jsonify({"success": False, "message": f"创建邮箱失败: {msg}", "result": result}) 1252 | 1253 | # 步骤2: 发送邀请(并发执行,无锁) 1254 | success, msg = send_chatgpt_invite(email) 1255 | result["steps"].append({ 1256 | "step": 2, 1257 | "name": "发送 ChatGPT 邀请", 1258 | "success": success, 1259 | "message": "成功" if success else msg 1260 | }) 1261 | if not success: 1262 | add_invite_record(user, email, password, False, f"发送邀请失败: {msg}") 1263 | return jsonify({"success": False, "message": f"发送邀请失败: {msg}", "result": result}) 1264 | 1265 | # 步骤3: 获取验证码 (异步方式,先返回,让前端轮询) 1266 | result["steps"].append({ 1267 | "step": 3, 1268 | "name": "等待验证码", 1269 | "success": True, 1270 | "message": "邀请已发送,请稍后获取验证码" 1271 | }) 1272 | 1273 | session["pending_invite"] = { 1274 | "email": email, 1275 | "password": password, 1276 | "created_at": time.time() 1277 | } 1278 | 1279 | # 记录成功邀请 1280 | add_invite_record(user, email, password, True, "邀请发送成功") 1281 | 1282 | return jsonify({ 1283 | "success": True, 1284 | "message": "邀请流程已启动", 1285 | "result": result, 1286 | "next": "poll_code" 1287 | }) 1288 | 1289 | finally: 1290 | # 释放信号量 1291 | if semaphore_token: 1292 | release_semaphore(semaphore_token) 1293 | # 释放用户锁 1294 | release_invite_lock(user_id, user_lock) 1295 | 1296 | 1297 | @app.route("/api/poll-code", methods=["GET"]) 1298 | def poll_code(): 1299 | """轮询获取验证码""" 1300 | if not is_logged_in(): 1301 | return jsonify({"success": False, "message": "请先登录"}), 401 1302 | 1303 | pending = session.get("pending_invite") 1304 | if not pending: 1305 | return jsonify({"success": False, "found": False, "message": "没有待处理的邀请"}) 1306 | 1307 | email = pending["email"] 1308 | code, error, email_time = get_verification_code(email, max_retries=1, interval=0) 1309 | 1310 | if code: 1311 | return jsonify({ 1312 | "success": True, 1313 | "found": True, 1314 | "code": code, 1315 | "email": email, 1316 | "password": pending["password"], 1317 | "email_time": email_time # 返回邮件时间 1318 | }) 1319 | else: 1320 | return jsonify({ 1321 | "success": True, 1322 | "found": False, 1323 | "message": "暂无验证码邮件" 1324 | }) 1325 | 1326 | 1327 | @app.route("/") 1328 | def index(): 1329 | # 如果用户已登录,直接跳转到邀请页面 1330 | if is_logged_in(): 1331 | return redirect(url_for("invite_page")) 1332 | return render_template( 1333 | "index.html", 1334 | site_key=CF_TURNSTILE_SITE_KEY, 1335 | email_web_url=EMAIL_WEB_URL 1336 | ) 1337 | 1338 | 1339 | @app.route("/send-invites", methods=["POST"]) 1340 | def send_invites(): 1341 | client_ip = get_client_ip_address() 1342 | 1343 | raw_emails = request.form.get("emails", "").strip() 1344 | email_list, valid_emails = parse_emails(raw_emails) 1345 | 1346 | cf_turnstile_response = request.form.get("cf-turnstile-response") 1347 | turnstile_valid = validate_turnstile(cf_turnstile_response) 1348 | 1349 | if not turnstile_valid: 1350 | log_warn("API", "CAPTCHA验证失败", ip=client_ip) 1351 | return jsonify({"success": False, "message": "CAPTCHA verification failed. Please try again."}) 1352 | 1353 | if not email_list: 1354 | return jsonify({"success": False, "message": "Please enter at least one email address."}) 1355 | 1356 | if not valid_emails: 1357 | return jsonify({"success": False, "message": "Email addresses are not valid. Please check and try again."}) 1358 | 1359 | headers = build_invite_headers() 1360 | payload = {"email_addresses": valid_emails, "role": "standard-user", "resend_emails": True} 1361 | invite_url = f"https://chatgpt.com/backend-api/accounts/{ACCOUNT_ID}/invites" 1362 | 1363 | try: 1364 | resp = requests.post(invite_url, headers=headers, json=payload, timeout=10) 1365 | if resp.status_code == 200: 1366 | log_info("API", "批量邀请成功", count=len(valid_emails), ip=client_ip) 1367 | return jsonify( 1368 | { 1369 | "success": True, 1370 | "message": f"Successfully sent invitations for: {', '.join(valid_emails)}", 1371 | } 1372 | ) 1373 | else: 1374 | log_error("API", "批量邀请失败", status=resp.status_code, ip=client_ip) 1375 | return jsonify( 1376 | { 1377 | "success": False, 1378 | "message": "Failed to send invitations.", 1379 | "details": {"status_code": resp.status_code, "body": resp.text}, 1380 | } 1381 | ) 1382 | except Exception as e: 1383 | log_error("API", "批量邀请异常", str(e), ip=client_ip) 1384 | return jsonify({"success": False, "message": f"Error: {str(e)}"}) 1385 | 1386 | 1387 | @app.route("/stats") 1388 | def stats(): 1389 | force_refresh = request.args.get("refresh") == "1" 1390 | 1391 | try: 1392 | data, updated_at = refresh_stats(force=force_refresh) 1393 | 1394 | return jsonify( 1395 | { 1396 | "success": True, 1397 | "data": data, 1398 | "updated_at": updated_at, 1399 | "cached": not force_refresh 1400 | } 1401 | ) 1402 | except TeamBannedException: 1403 | log_error("Stats", "账号异常", "Team account banned") 1404 | return jsonify({ 1405 | "success": False, 1406 | "banned": True, 1407 | "message": "车已翻 - Team 账号状态异常" 1408 | }), 503 1409 | except Exception as e: 1410 | log_error("Stats", "获取统计失败", str(e)) 1411 | return jsonify({"success": False, "message": f"Error fetching stats: {str(e)}"}), 500 1412 | 1413 | 1414 | # ==================== 后台管理路由 ==================== 1415 | 1416 | @app.route("/admin") 1417 | def admin_page(): 1418 | """后台管理页面""" 1419 | if not session.get("admin_logged_in"): 1420 | return render_template("admin_login.html") 1421 | return render_template("admin.html") 1422 | 1423 | 1424 | @app.route("/admin/login", methods=["POST"]) 1425 | def admin_login(): 1426 | """后台登录""" 1427 | password = request.form.get("password", "") 1428 | if password == ADMIN_PASSWORD: 1429 | session["admin_logged_in"] = True 1430 | session.permanent = True # 启用持久化 Session 1431 | return redirect(url_for("admin_page")) 1432 | return render_template("admin_login.html", error="密码错误") 1433 | 1434 | 1435 | @app.route("/admin/logout") 1436 | def admin_logout(): 1437 | """后台登出""" 1438 | session.pop("admin_logged_in", None) 1439 | return redirect(url_for("admin_page")) 1440 | 1441 | 1442 | @app.route("/api/admin/records") 1443 | def admin_records(): 1444 | """获取邀请记录""" 1445 | if not session.get("admin_logged_in"): 1446 | return jsonify({"success": False, "message": "未授权"}), 401 1447 | 1448 | # 从 Redis 获取记录(已按时间倒序) 1449 | records = get_invite_records(200) 1450 | return jsonify({ 1451 | "success": True, 1452 | "records": records, 1453 | "total": len(records) 1454 | }) 1455 | 1456 | 1457 | @app.route("/api/admin/stats") 1458 | def admin_stats(): 1459 | """获取统计概览""" 1460 | if not session.get("admin_logged_in"): 1461 | return jsonify({"success": False, "message": "未授权"}), 401 1462 | 1463 | stats = get_invite_stats() 1464 | return jsonify({ 1465 | "success": True, 1466 | "stats": stats 1467 | }) 1468 | 1469 | 1470 | @app.route("/api/admin/pending-invites") 1471 | def admin_pending_invites(): 1472 | """获取 ChatGPT Team 待处理邀请列表""" 1473 | if not session.get("admin_logged_in"): 1474 | return jsonify({"success": False, "message": "未授权"}), 401 1475 | 1476 | # 强制从 API 获取最新数据并更新缓存 1477 | items, total = fetch_pending_invites_from_api(1000) 1478 | set_cached_pending_invites(items, total) 1479 | log_info("Admin", "查询待处理邀请", total=total, count=len(items)) 1480 | return jsonify({ 1481 | "success": True, 1482 | "items": items, 1483 | "total": total 1484 | }) 1485 | 1486 | 1487 | @app.route("/api/admin/members") 1488 | def admin_members(): 1489 | """获取 ChatGPT Team 空间成员列表""" 1490 | if not session.get("admin_logged_in"): 1491 | return jsonify({"success": False, "message": "未授权"}), 401 1492 | 1493 | # 强制从 API 获取最新数据(分页获取所有) 1494 | items, total = fetch_space_members_from_api(1000) 1495 | log_info("Admin", "查询空间成员", total=total, count=len(items)) 1496 | return jsonify({ 1497 | "success": True, 1498 | "items": items, 1499 | "total": total 1500 | }) 1501 | 1502 | 1503 | # ==================== 健康检查 ==================== 1504 | 1505 | @app.route("/health") 1506 | def health_check(): 1507 | """健康检查端点""" 1508 | status = {"status": "healthy", "redis": False, "scheduler": False} 1509 | try: 1510 | redis_client.ping() 1511 | status["redis"] = True 1512 | except Exception: 1513 | status["status"] = "degraded" 1514 | 1515 | if scheduler and scheduler.running: 1516 | status["scheduler"] = True 1517 | 1518 | return jsonify(status) 1519 | 1520 | 1521 | @app.errorhandler(404) 1522 | def not_found(e): 1523 | return jsonify({"error": "Not found"}), 404 1524 | 1525 | 1526 | # ==================== 后台定时任务 ==================== 1527 | 1528 | def init_scheduler(): 1529 | """初始化后台定时任务""" 1530 | global scheduler 1531 | scheduler = BackgroundScheduler(daemon=True) 1532 | 1533 | # 每 60 秒刷新统计数据 1534 | scheduler.add_job( 1535 | func=background_refresh_stats, 1536 | trigger="interval", 1537 | seconds=STATS_REFRESH_INTERVAL, 1538 | id="refresh_stats", 1539 | name="刷新统计数据", 1540 | replace_existing=True 1541 | ) 1542 | 1543 | # 每 60 秒刷新待处理邀请 1544 | scheduler.add_job( 1545 | func=background_refresh_pending_invites, 1546 | trigger="interval", 1547 | seconds=STATS_REFRESH_INTERVAL, 1548 | id="refresh_pending_invites", 1549 | name="刷新待处理邀请", 1550 | replace_existing=True 1551 | ) 1552 | 1553 | scheduler.start() 1554 | log_info("Scheduler", "后台定时任务已启动", interval=f"{STATS_REFRESH_INTERVAL}s") 1555 | 1556 | # 注册退出时关闭调度器 1557 | atexit.register(lambda: scheduler.shutdown()) 1558 | 1559 | 1560 | # 全局调度器变量 1561 | scheduler = None 1562 | 1563 | # 初始化调度器(仅在非 reloader 进程中运行) 1564 | if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not app.debug: 1565 | init_scheduler() 1566 | 1567 | 1568 | if __name__ == "__main__": 1569 | app.run(debug=True, host="127.0.0.1", port=39001) 1570 | --------------------------------------------------------------------------------