├── .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 | [](https://ghcr.io/james-6-23/team-invite-kfc)
8 | [](https://python.org)
9 | [](https://flask.palletsprojects.com)
10 | [](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 |
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 |
167 |
168 |
🚗💥
169 |
车已翻
170 |
Team 账户状态异常,服务暂时不可用
171 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
正在检查车况...
184 |
185 |
186 |
187 |
188 |
190 |
191 |
192 |
193 |
195 |
196 |
198 |
199 |
200 |
201 |
203 |
204 |
205 |
206 |
207 |
209 |

211 |
212 |
213 |
214 |
216 | ChatGPT Team
217 |
218 |
219 |
221 |
222 |
223 |
Linux.do 社区专属通道
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
251 |
252 |
253 |
255 |
257 |
258 |
259 |
260 |
262 |
263 |
264 |
265 |
剩余名额
266 |
267 |
--
269 |
270 |
271 |
272 |
279 |
--%
280 |
281 |
282 |
283 |
284 |
285 |
286 |
288 |
289 |
290 |
291 | TEAM-STATUS
292 |
293 |
294 |
295 |
296 |
297 | 有效期至
298 |
299 |
300 |
301 |
302 |
303 | 更新时间
304 | --
305 |
306 |
307 |
308 |
309 |
310 |
311 |
327 |
328 |
329 |
330 |
385 |
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 |
163 |
-
164 |
165 |
166 |
175 |
176 |
181 |
-
182 |
183 |
184 |
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 | ID |
243 |
244 |
245 |
246 |
247 | |
248 | 正在加载...
249 | |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
没有待处理邀请
259 |
所有邀请已被处理完毕
260 |
261 |
262 |
263 |
264 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 | 空间成员列表
293 |
294 |
298 |
299 |
300 |
301 |
302 |
303 |
305 | | 用户名 |
306 | 邮箱地址 |
307 | 角色 |
308 | 加入时间 |
309 | 状态 |
310 |
311 |
312 |
313 |
314 | |
315 | 正在加载...
316 | |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
暂无成员
326 |
空间内还没有成员
327 |
328 |
329 |
330 |
331 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 | 近期活动记录
360 |
361 |
365 |
366 |
367 |
368 |
369 |
370 |
372 | | ID |
373 | 用户 |
374 | 信任等级 |
375 | 分配邮箱 |
376 | 密码 |
377 | 状态 |
378 | 时间 |
379 | IP 地址 |
380 |
381 |
382 |
383 |
384 | |
385 | 正在加载数据...
386 | |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
暂无记录
396 |
所有新的邀请记录将显示在这里
397 |
398 |
399 |
400 |
401 |
420 |
421 |
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 |
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 |
564 |
565 |
566 |
577 |
578 |
579 |
580 |
581 |
582 |
583 |
584 |
验证码
585 |
586 |
588 | --- ---
589 |
590 |
591 |
点击下方按钮获取
592 |
593 |
594 |
595 |
596 | --
597 |
598 |
599 |
600 |
606 |
607 |
608 |
609 |
614 |
615 |
616 |
617 |
618 | 安全加密连接
619 |
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 |
--------------------------------------------------------------------------------