├── .gitignore
├── extensions.py
├── png
└── 获取doscordtoken.png
├── requirements.txt
├── .dockerignore
├── docker-compose.yml
├── 自动刷新token推送到newapi
├── config.json
├── README.md
└── zai_token.py
├── Dockerfile
├── migrate_stream_config.py
├── README.md
├── models.py
├── static
├── login.html
└── manage.html
├── services.py
├── zai_token.py
└── app.py
/.gitignore:
--------------------------------------------------------------------------------
1 | instance/zai2api.db
2 | *.pyc
3 |
--------------------------------------------------------------------------------
/extensions.py:
--------------------------------------------------------------------------------
1 | from flask_sqlalchemy import SQLAlchemy
2 |
3 | db = SQLAlchemy()
4 |
5 |
--------------------------------------------------------------------------------
/png/获取doscordtoken.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Futureppo/zai.is2api/HEAD/png/获取doscordtoken.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | flask
2 | flask-login
3 | flask-sqlalchemy
4 | requests
5 | apscheduler
6 | pyjwt
7 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | *.pyc
3 | .git
4 | .gitignore
5 | instance/
6 | *.db
7 | docker-compose.yml
8 | Dockerfile
9 | README.md
10 | venv/
11 | env/
12 |
13 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | zai2api:
5 | build: .
6 | container_name: zai2api
7 | restart: unless-stopped
8 | ports:
9 | - "5000:5000"
10 | environment:
11 | - DATABASE_URI=sqlite:////app/instance/zai2api.db
12 | - SECRET_KEY=change_this_to_a_random_string
13 | - TZ=Asia/Shanghai
14 | volumes:
15 | - ./instance:/app/instance
16 |
17 |
--------------------------------------------------------------------------------
/自动刷新token推送到newapi/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "discord_tokens": [
3 | "discord_token1",
4 | "discord_token2"
5 | ],
6 | "discord_token_file": "discord_tokens.txt",
7 | "zai_url": "https://zai.is",
8 | "newapi_base": "https://aaa.aaa.cn",
9 | "newapi_key": "aaa",
10 | "system_token": "aaa",
11 | "newapi_user_id": "1",
12 | "newapi_channel_id": "1",
13 | "expires_in": 3600,
14 | "update_interval": 3600
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim
2 |
3 | WORKDIR /app
4 |
5 | # Install system dependencies (if any needed for pysqlite3 or others)
6 | # RUN apt-get update && apt-get install -y --no-install-recommends gcc libffi-dev && rm -rf /var/lib/apt/lists/*
7 |
8 | COPY requirements.txt .
9 | RUN pip install --no-cache-dir -r requirements.txt
10 |
11 | COPY . .
12 |
13 | # Environment variables
14 | ENV PYTHONUNBUFFERED=1
15 | ENV FLASK_APP=app.py
16 |
17 | # Create instance directory for volume mount
18 | RUN mkdir -p instance
19 |
20 | EXPOSE 5000
21 |
22 | CMD ["python", "app.py"]
23 |
24 |
--------------------------------------------------------------------------------
/migrate_stream_config.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | import os
3 |
4 | DB_PATH = os.path.join('instance', 'zai2api.db')
5 |
6 | def migrate():
7 | if not os.path.exists(DB_PATH):
8 | print("Database not found. Skipping migration.")
9 | return
10 |
11 | conn = sqlite3.connect(DB_PATH)
12 | cursor = conn.cursor()
13 |
14 | try:
15 | # Check if column exists
16 | cursor.execute("SELECT stream_conversion_enabled FROM system_config LIMIT 1")
17 | except sqlite3.OperationalError:
18 | print("Adding stream_conversion_enabled column...")
19 | cursor.execute("ALTER TABLE system_config ADD COLUMN stream_conversion_enabled BOOLEAN DEFAULT 0")
20 | conn.commit()
21 | print("Migration done.")
22 | except Exception as e:
23 | print(f"Error checking column: {e}")
24 | finally:
25 | conn.close()
26 |
27 | if __name__ == "__main__":
28 | migrate()
29 |
30 |
--------------------------------------------------------------------------------
/自动刷新token推送到newapi/README.md:
--------------------------------------------------------------------------------
1 | # 自动刷新 Token 推送到 NewAPI
2 |
3 | 轻量化版本,定期获取 zAI 平台的访问令牌,并将其推送到 NewAPI 中。
4 |
5 | ## 配置文件结构
6 |
7 | ```json
8 | {
9 | "discord_tokens": [
10 | "discord_token1",
11 | "discord_token2"
12 | ],
13 | "discord_token_file": "discord_tokens.txt",
14 | "zai_url": "https://zai.is",
15 | "newapi_base": "https://aaa.aaa.cn",
16 | "newapi_key": "aaa",
17 | "system_token": "aaa",
18 | "newapi_user_id": "1",
19 | "newapi_channel_id": "1",
20 | "expires_in": 3600,
21 | "update_interval": 3600
22 | }
23 |
24 |
25 | ```
26 |
27 | ### 配置项详细说明
28 |
29 | #### 1. NewAPI 相关配置
30 |
31 | - **`newapi_url`**:NewAPI 地址
32 | - 示例:`https://api.yoursite.com`
33 |
34 | - **`newapi_key`**:NewAPI 管理员密钥
35 |
36 |
37 | #### 2. Discord 账号配置
38 |
39 | - **`discord_accounts`**:Discord 账号token列表,可以配置多个账号
40 |
41 | #### 3. 运行配置
42 |
43 | - **`refresh_interval`**:刷新间隔(单位:秒)
44 | - 默认值:`3600`(1小时)
45 | - 说明:每隔多少秒刷新一次 Token
46 |
47 | ### 配置示例
48 |
49 | 完整的配置文件示例:
50 |
51 | ```json
52 | {
53 | "newapi_url": "https://api.example.com",
54 | "newapi_key": "sk-xxxxxxxxxxxxxxxxxxxxx",
55 | "discord_accounts": [
56 | {
57 | "email": "user1@example.com",
58 | "password": "password123",
59 | "channel_id": "1"
60 | },
61 | {
62 | "email": "user2@example.com",
63 | "password": "password456",
64 | "channel_id": "2"
65 | }
66 | ],
67 | "refresh_interval": 3600,
68 | "run_once": false
69 | }
70 | ```
71 |
72 | ## 使用方法
73 |
74 | 配置完成后,在项目文件夹中打开命令行工具,运行:
75 |
76 | ```bash
77 | python zai_token.py run-loop --config config.json
78 | ```
79 |
80 | ## 📄 许可证
81 |
82 | 本项目仅供学习和研究使用,请遵守相关服务条款。
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zai2API
2 |
3 | Zai2API 是一个功能完整的 OpenAI 兼容 API 服务网关。它允许你管理 Discord Token,自动将其转换为 zai.is 的访问凭证,并提供标准的 OpenAI 接口供第三方客户端调用。
4 |
5 | ## 轻量化版本
6 |
7 | 如果你只需要“自动刷新 Discord Token 并推送到NewAPI”这一精简能力,可以使用仓库内的 `自动刷新token推送到newapi` 目录:
8 |
9 | - `自动刷新token推送到newapi` 为轻量化版本,专注于 Token 自动刷新与推送,适合资源受限或仅需 Token 分发的场景;
10 |
11 | ## 功能特性
12 |
13 | * **多 Token 管理**:支持批量添加、删除、禁用 Discord Token。
14 | * **自动保活**:后台调度器自动检测并刷新过期的 Zai Token。
15 | * **OpenAI 兼容**:提供 `/v1/chat/completions` 和 `/v1/models` 接口。
16 | * **负载均衡**:API 请求会自动轮询使用当前活跃的 Token。
17 | * **WebUI 面板**:
18 | * **Token 列表**:实时查看 Token 状态、剩余有效期。
19 | * **系统配置**:修改管理员密码、API Key、代理设置、错误重试策略等。
20 | * **请求日志**:详细记录 API 调用的耗时、状态码和使用的 Token。
21 | * **Docker 部署**:提供 Dockerfile 和 docker-compose.yml,一键部署。
22 |
23 | ## 快速开始
24 |
25 | ### 获取discord token
26 |
27 | 随便在一个群组中发消息,复制其中的Authorization作为discord token
28 | 
29 |
30 | ### 方式一:Docker Compose 部署(推荐)
31 |
32 | 1. 克隆或下载本项目代码。
33 | 2. 确保已安装 Docker 和 Docker Compose。
34 | 3. 在项目根目录下运行:
35 |
36 | ```bash
37 | git clone https://github.com/Futureppo/zai.is2api.git && cd zai.is2api
38 | ```
39 |
40 | ```bash
41 | docker-compose up -d
42 | ```
43 |
44 | 4. 服务启动后,访问 `http://localhost:5000` 进入管理后台。
45 |
46 | ### 方式二:源码部署
47 |
48 | 1. 确保已安装 Python 3.10+。
49 | 2. 安装依赖:
50 |
51 | ```bash
52 | pip install -r requirements.txt
53 | ```
54 |
55 | 3. 启动服务:
56 |
57 | ```bash
58 | python app.py
59 | ```
60 |
61 |
62 | ## 配置说明
63 |
64 |
65 |
66 | ### 环境变量
67 |
68 | | 变量名 | 默认值 | 说明 |
69 | | :--- | :--- | :--- |
70 | | `DATABASE_URI` | `sqlite:////app/instance/zai2api.db` | 数据库连接字符串 |
71 | | `SECRET_KEY` | `your-secret-key...` | Flask Session 密钥,建议修改 |
72 | | `TZ` | `Asia/Shanghai` | 容器时区 |
73 |
74 | ## API 调用
75 |
76 | ### 聊天
77 |
78 | **Endpoint**: `http://localhost:5000/v1/chat/completions`
79 |
80 | **示例 (curl)**:
81 |
82 | ```bash
83 | curl http://localhost:5000/v1/chat/completions \
84 | -H "Content-Type: application/json" \
85 | -H "Authorization: Bearer sk-default-key" \
86 | -d '{
87 | "model": "gpt-4",
88 | "messages": [{"role": "user", "content": "Hello!"}],
89 | "stream": true
90 | }'
91 | ```
92 |
93 |
94 | ## 管理面板功能
95 |
96 | 1. **Token 管理**:
97 | * 点击“新增 Token”输入 Discord Token (Session Token)。
98 | * 系统会自动尝试获取 Zai Token。
99 | * 点击“一键刷新 ZaiToken”可强制刷新所有 Token。
100 | 2. **系统配置**:
101 | * 调整“错误封禁阈值”和“错误重试次数”以优化稳定性。
102 | * 调整 Token 刷新间隔。
103 | 3. **请求日志**:
104 | * 查看最近的 API 请求记录。
105 |
106 | ## Star History
107 |
108 | [](https://www.star-history.com/#Futureppo/zai.is2api&type=date&legend=top-left)
109 |
110 | ## 免责声明
111 |
112 | 本项目仅供逆向学习和研究使用。使用者应自行承担使用本工具产生的所有风险和责任。请遵守相关服务条款。
113 |
--------------------------------------------------------------------------------
/models.py:
--------------------------------------------------------------------------------
1 | from extensions import db
2 | from datetime import datetime
3 |
4 | class SystemConfig(db.Model):
5 | id = db.Column(db.Integer, primary_key=True)
6 | admin_username = db.Column(db.String(64), default='admin')
7 | admin_password_hash = db.Column(db.String(128)) # Store hash
8 | api_key = db.Column(db.String(128), default='sk-default-key')
9 | error_ban_threshold = db.Column(db.Integer, default=3)
10 | error_retry_count = db.Column(db.Integer, default=3) # New field
11 | debug_enabled = db.Column(db.Boolean, default=False)
12 | at_auto_refresh_enabled = db.Column(db.Boolean, default=True)
13 | token_refresh_interval = db.Column(db.Integer, default=3600)
14 | stream_conversion_enabled = db.Column(db.Boolean, default=False)
15 |
16 | # Proxy Config
17 | proxy_enabled = db.Column(db.Boolean, default=False)
18 | proxy_url = db.Column(db.String(256), nullable=True)
19 |
20 | # Cache Config
21 | cache_enabled = db.Column(db.Boolean, default=False)
22 | cache_timeout = db.Column(db.Integer, default=7200)
23 | cache_base_url = db.Column(db.String(256), nullable=True)
24 |
25 | # Generation Timeout Config
26 | image_timeout = db.Column(db.Integer, default=300)
27 | video_timeout = db.Column(db.Integer, default=1500)
28 |
29 | class Token(db.Model):
30 | id = db.Column(db.Integer, primary_key=True)
31 | email = db.Column(db.String(120), nullable=True) # Got from Zai
32 | discord_token = db.Column(db.String(512), nullable=False) # ST
33 | zai_token = db.Column(db.Text, nullable=True) # AT (JWT)
34 | at_expires = db.Column(db.DateTime, nullable=True)
35 |
36 | is_active = db.Column(db.Boolean, default=True)
37 | remark = db.Column(db.String(256), nullable=True)
38 |
39 | # Capabilities & Limits
40 | image_enabled = db.Column(db.Boolean, default=True)
41 | video_enabled = db.Column(db.Boolean, default=True)
42 | image_concurrency = db.Column(db.Integer, default=-1)
43 | video_concurrency = db.Column(db.Integer, default=-1)
44 |
45 | # Zai Account Info
46 | credits = db.Column(db.String(64), default='0')
47 | user_paygate_tier = db.Column(db.String(64), nullable=True)
48 | current_project_id = db.Column(db.String(64), nullable=True)
49 | current_project_name = db.Column(db.String(128), nullable=True)
50 |
51 | # Sora2 Info (from frontend JS logic)
52 | sora2_supported = db.Column(db.Boolean, default=False)
53 | sora2_total_count = db.Column(db.Integer, default=0)
54 | sora2_redeemed_count = db.Column(db.Integer, default=0)
55 | sora2_remaining_count = db.Column(db.Integer, default=0)
56 | sora2_invite_code = db.Column(db.String(64), nullable=True)
57 |
58 | # Stats
59 | error_count = db.Column(db.Integer, default=0)
60 | image_count = db.Column(db.Integer, default=0)
61 | video_count = db.Column(db.Integer, default=0)
62 |
63 | created_at = db.Column(db.DateTime, default=datetime.now)
64 | updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
65 |
66 | class RequestLog(db.Model):
67 | id = db.Column(db.Integer, primary_key=True)
68 | operation = db.Column(db.String(64)) # e.g. "chat/completions", "refresh"
69 | token_email = db.Column(db.String(120), nullable=True)
70 | discord_token = db.Column(db.Text, nullable=True)
71 | zai_token = db.Column(db.Text, nullable=True)
72 | status_code = db.Column(db.Integer)
73 | duration = db.Column(db.Float)
74 | created_at = db.Column(db.DateTime, default=datetime.now)
75 |
--------------------------------------------------------------------------------
/static/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 登录 - Zai2API
7 |
8 |
12 |
15 |
16 |
17 |
18 |
19 |
Zai2API
20 |
管理员登录
21 |
22 |
35 |
36 |
37 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/services.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import time
3 | from datetime import datetime, timedelta
4 | from extensions import db
5 | from models import Token, SystemConfig, RequestLog
6 | from zai_token import DiscordOAuthHandler
7 | import jwt # pyjwt
8 | from flask import current_app
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 | def get_zai_handler():
13 | # Assume we are in app context so we can query SystemConfig
14 | config = SystemConfig.query.first()
15 | handler = DiscordOAuthHandler()
16 | if config and config.proxy_enabled and config.proxy_url:
17 | handler.session.proxies = {
18 | 'http': config.proxy_url,
19 | 'https': config.proxy_url
20 | }
21 | return handler
22 |
23 | def update_token_info(token_id, use_oauth=False):
24 | # Caller must ensure app context
25 | token = db.session.get(Token, token_id)
26 | if not token:
27 | return False, "Token not found"
28 |
29 | handler = get_zai_handler()
30 |
31 | # 如果使用 OAuth 登录
32 | if use_oauth:
33 | print(f"[*] Token {token_id} 使用 OAuth 登录更新...")
34 | result = handler.oauth_login_with_browser()
35 | source = result.get('source', 'oauth')
36 | else:
37 | # 使用后端登录
38 | result = handler.backend_login(token.discord_token)
39 | source = 'backend'
40 |
41 | if 'error' in result:
42 | token.error_count += 1
43 | token.remark = f"Refresh failed: {result['error']}"
44 | db.session.commit()
45 | return False, result['error']
46 |
47 | at = result.get('token')
48 | user_info = result.get('user_info', {})
49 |
50 | if at == 'SESSION_AUTH':
51 | token.email = user_info.get('email') or user_info.get('name') or token.email
52 | token.is_active = True
53 | token.error_count = 0
54 | token.zai_token = "SESSION_AUTH_COOKIE"
55 | token.remark = f"Updated via {source} (Session Auth)"
56 | # For SESSION_AUTH, set expiry based on system config
57 | config = SystemConfig.query.first()
58 | refresh_interval = config.token_refresh_interval if config else 3600
59 | token.at_expires = datetime.now() + timedelta(seconds=refresh_interval)
60 | db.session.commit()
61 | return True, f"Session Auth Active ({source})"
62 |
63 | token.zai_token = at
64 | token.error_count = 0
65 | token.remark = f"Updated via {source}"
66 |
67 | # Get system config for fallback expiry
68 | config = SystemConfig.query.first()
69 | refresh_interval = config.token_refresh_interval if config else 3600
70 | now = datetime.now()
71 | desired_exp = now + timedelta(seconds=refresh_interval)
72 | jwt_exp_dt = None
73 |
74 | # Decode JWT to get expiry and email
75 | try:
76 | decoded = jwt.decode(at, options={"verify_signature": False})
77 | if 'exp' in decoded:
78 | jwt_exp_dt = datetime.fromtimestamp(decoded['exp'])
79 | if 'email' in decoded:
80 | token.email = decoded['email']
81 | except Exception as e:
82 | logger.warning(f"Failed to decode JWT: {e}")
83 |
84 | # Use the earlier of JWT exp and configured refresh interval to keep UI倒计时一致
85 | token.at_expires = min(jwt_exp_dt, desired_exp) if jwt_exp_dt else desired_exp
86 |
87 | db.session.commit()
88 | return True, f"Success ({source})"
89 |
90 | def create_or_update_token_from_oauth():
91 | """通过 OAuth 登录创建或更新 Token"""
92 | handler = get_zai_handler()
93 |
94 | # 执行 OAuth 登录
95 | result = handler.oauth_login_with_browser()
96 |
97 | if 'error' in result:
98 | return {'success': False, 'error': result['error']}
99 |
100 | # 获取 token 信息
101 | zai_token = result.get('token')
102 | user_info = result.get('user_info', {})
103 | source = result.get('source', 'unknown')
104 |
105 | if not zai_token:
106 | return {'success': False, 'error': '未能获取到有效的 Token'}
107 |
108 | # 解析 JWT 获取用户信息
109 | email = None
110 | at_expires = None
111 |
112 | if zai_token != 'SESSION_AUTH':
113 | try:
114 | decoded = jwt.decode(zai_token, options={"verify_signature": False})
115 | email = decoded.get('email')
116 | if 'exp' in decoded:
117 | at_expires = datetime.fromtimestamp(decoded['exp'])
118 | except Exception as e:
119 | logger.warning(f"Failed to decode JWT: {e}")
120 | else:
121 | # Session auth 情况
122 | email = user_info.get('email') or user_info.get('name')
123 |
124 | # 如果没有 email,从 user_info 获取
125 | if not email and user_info:
126 | email = user_info.get('email') or user_info.get('name')
127 |
128 | if not email:
129 | email = f"oauth_user_{int(time.time())}"
130 |
131 | # 查找或创建 Token
132 | token = Token.query.filter_by(email=email).first()
133 |
134 | if not token:
135 | # 创建新 token
136 | token = Token(
137 | email=email,
138 | discord_token="OAUTH_LOGIN", # 占位符
139 | is_active=True
140 | )
141 | db.session.add(token)
142 |
143 | # 更新 token 信息
144 | token.zai_token = zai_token if zai_token != 'SESSION_AUTH' else "SESSION_AUTH_COOKIE"
145 | token.is_active = True
146 | token.error_count = 0
147 | token.remark = f"Updated via OAuth login ({source})"
148 |
149 | # 设置过期时间(与配置刷新间隔对齐)
150 | config = SystemConfig.query.first()
151 | refresh_interval = config.token_refresh_interval if config else 3600
152 | now = datetime.now()
153 | desired_exp = now + timedelta(seconds=refresh_interval)
154 | token.at_expires = min(at_expires, desired_exp) if at_expires else desired_exp
155 |
156 | db.session.commit()
157 |
158 | return {
159 | 'success': True,
160 | 'token': token.zai_token,
161 | 'email': token.email,
162 | 'source': source,
163 | 'expires': token.at_expires.isoformat() if token.at_expires else None
164 | }
165 |
166 | def refresh_all_tokens(force=False):
167 | # Caller must ensure app context
168 | tokens = Token.query.filter_by(is_active=True).all()
169 | for token in tokens:
170 | if not force and token.at_expires and token.at_expires > datetime.now() + timedelta(minutes=10):
171 | continue
172 |
173 | try:
174 | success, msg = update_token_info(token.id)
175 | logger.info(f"Refreshed token {token.id}: {msg}")
176 | except Exception as e:
177 | logger.error(f"Error refreshing token {token.id}: {e}")
178 |
--------------------------------------------------------------------------------
/zai_token.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | """
4 | zAI Token 获取工具
5 | 纯后端 Discord OAuth 登录
6 | 命令行用法示例:python zai_token.py backend-login --discord-token "你的discord token"
7 | """
8 |
9 | import base64
10 | import json
11 | import argparse
12 | import requests
13 | import re
14 | from typing import Optional, Dict, Any
15 | from urllib.parse import urlparse, parse_qs
16 | import webbrowser
17 | import time
18 | import threading
19 |
20 | class DiscordOAuthHandler:
21 | """Discord OAuth 登录处理器"""
22 |
23 | # Discord API 端点
24 | DISCORD_API_BASE = "https://discord.com/api/v9"
25 |
26 | def __init__(self, base_url: str = "https://zai.is"):
27 | self.base_url = base_url
28 | self.session = requests.Session()
29 | self.session.headers.update({
30 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
31 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
32 | 'Accept-Language': 'en-US,en;q=0.9',
33 | 'Referer': f'{base_url}/auth',
34 | 'Origin': base_url,
35 | 'Connection': 'keep-alive',
36 | 'Upgrade-Insecure-Requests': '1',
37 | 'Sec-Fetch-Dest': 'document',
38 | 'Sec-Fetch-Mode': 'navigate',
39 | 'Sec-Fetch-Site': 'same-origin',
40 | 'Sec-Fetch-User': '?1',
41 | })
42 |
43 | def get_oauth_login_url(self) -> str:
44 | """获取 Discord OAuth 登录 URL"""
45 | return f"{self.base_url}/oauth/discord/login"
46 |
47 | def backend_login(self, discord_token: str) -> Dict[str, Any]:
48 | """
49 | 纯后端 Discord OAuth 登录
50 |
51 | Args:
52 | discord_token: Discord 账号的 token
53 |
54 | Returns:
55 | 包含 zai.is JWT token 的字典
56 | """
57 | if not discord_token or len(discord_token) < 20:
58 | return {'error': '无效的 Discord Token'}
59 |
60 | print("\n[*] 开始后端 OAuth 登录流程...")
61 | print(f"[*] Discord Token: {discord_token[:20]}...{discord_token[-10:]}")
62 |
63 | try:
64 | # Step 1: 访问 OAuth 登录入口,获取 Discord 授权 URL
65 | print("[1/5] 获取 Discord 授权 URL...")
66 | oauth_info = self._get_discord_authorize_url()
67 | if 'error' in oauth_info:
68 | return oauth_info
69 |
70 | authorize_url = oauth_info['authorize_url']
71 | client_id = oauth_info['client_id']
72 | redirect_uri = oauth_info['redirect_uri']
73 | state = oauth_info.get('state', '')
74 | scope = oauth_info.get('scope', 'identify email')
75 |
76 | print(f" Client ID: {client_id}")
77 | print(f" Redirect URI: {redirect_uri}")
78 | print(f" Scope: {scope}")
79 |
80 | # Step 2: 使用 Discord token 授权应用
81 | print("[2/5] 授权应用...")
82 | auth_result = self._authorize_discord_app(
83 | discord_token, client_id, redirect_uri, scope, state
84 | )
85 | if 'error' in auth_result:
86 | return auth_result
87 |
88 | callback_url = auth_result['callback_url']
89 | print(f" 获取到回调 URL")
90 |
91 | # Step 3: 访问回调 URL 获取 token
92 | print("[3/5] 处理 OAuth 回调...")
93 | token_result = self._handle_oauth_callback(callback_url)
94 | if 'error' in token_result:
95 | return token_result
96 |
97 | print(f"[4/5] 成功获取 JWT Token!")
98 |
99 | return token_result
100 |
101 | except Exception as e:
102 | return {'error': f'登录过程出错: {str(e)}'}
103 |
104 | def _get_discord_authorize_url(self) -> Dict[str, Any]:
105 | """获取 Discord 授权 URL 和参数"""
106 | try:
107 | response = self.session.get(
108 | self.get_oauth_login_url(),
109 | allow_redirects=False
110 | )
111 |
112 | if response.status_code in [301, 302, 303, 307, 308]:
113 | location = response.headers.get('Location', '')
114 | if 'discord.com' in location:
115 | parsed = urlparse(location)
116 | params = parse_qs(parsed.query)
117 | return {
118 | 'authorize_url': location,
119 | 'client_id': params.get('client_id', [''])[0],
120 | 'redirect_uri': params.get('redirect_uri', [''])[0],
121 | 'scope': params.get('scope', ['identify email'])[0],
122 | 'state': params.get('state', [''])[0]
123 | }
124 | return {'error': f'无法获取授权 URL,状态码: {response.status_code}'}
125 | except Exception as e:
126 | return {'error': f'获取授权 URL 失败: {str(e)}'}
127 |
128 | def _authorize_discord_app(self, discord_token, client_id, redirect_uri, scope, state) -> Dict[str, Any]:
129 | """使用 Discord token 授权应用"""
130 | try:
131 | authorize_url = f"{self.DISCORD_API_BASE}/oauth2/authorize"
132 |
133 | # 构建 super properties
134 | super_properties = base64.b64encode(json.dumps({
135 | "os": "Windows",
136 | "browser": "Chrome",
137 | "device": "",
138 | "browser_user_agent": self.session.headers['User-Agent'],
139 | }).encode()).decode()
140 |
141 | headers = {
142 | 'Authorization': discord_token,
143 | 'Content-Type': 'application/json',
144 | 'X-Super-Properties': super_properties,
145 | }
146 |
147 | params = {
148 | 'client_id': client_id,
149 | 'response_type': 'code',
150 | 'redirect_uri': redirect_uri,
151 | 'scope': scope,
152 | }
153 | if state:
154 | params['state'] = state
155 |
156 | payload = {
157 | 'permissions': '0',
158 | 'authorize': True,
159 | 'integration_type': 0
160 | }
161 |
162 | response = self.session.post(
163 | authorize_url,
164 | headers=headers,
165 | params=params,
166 | json=payload
167 | )
168 |
169 | if response.status_code == 200:
170 | try:
171 | data = response.json()
172 | location = data.get('location', '')
173 | if location:
174 | if location.startswith('/'):
175 | location = f"{self.base_url}{location}"
176 | return {'callback_url': location}
177 | except:
178 | pass
179 |
180 | return {'error': f'授权失败 (状态码: {response.status_code})'}
181 |
182 | except Exception as e:
183 | return {'error': f'授权过程出错: {str(e)}'}
184 |
185 | def _handle_oauth_callback(self, callback_url: str) -> Dict[str, Any]:
186 | """处理 OAuth 回调,获取 JWT token"""
187 | try:
188 | print(f" 回调 URL: {callback_url[:80]}...")
189 |
190 | response = self.session.get(callback_url, allow_redirects=False)
191 |
192 | max_redirects = 10
193 | for i in range(max_redirects):
194 | print(f" 重定向 {i+1}: 状态码 {response.status_code}")
195 |
196 | if response.status_code not in [301, 302, 303, 307, 308]:
197 | break
198 |
199 | location = response.headers.get('Location', '')
200 | print(f" Location: {location[:100]}...")
201 |
202 | # Check for token in URL
203 | token = self._extract_token(location)
204 | if token: return {'token': token}
205 |
206 | if location.startswith('/'):
207 | location = f"{self.base_url}{location}"
208 |
209 | response = self.session.get(location, allow_redirects=False)
210 |
211 | # Final check in URL
212 | final_url = response.url if hasattr(response, 'url') else ''
213 | print(f" 最终 URL: {final_url}")
214 | print(f" 最终状态码: {response.status_code}")
215 |
216 | token = self._extract_token(final_url)
217 | if token: return {'token': token}
218 |
219 | # Check Cookies
220 | print(f" 检查 Cookies...")
221 | has_session = False
222 | for cookie in self.session.cookies:
223 | print(f" {cookie.name}: {str(cookie.value)[:50]}...")
224 | if cookie.name == 'token':
225 | return {'token': cookie.value}
226 | if any(x in cookie.name.lower() for x in ['session', 'auth', 'id', 'user']):
227 | has_session = True
228 |
229 | # Session Fallback
230 | if has_session:
231 | print(f" [!] 尝试 Session 验证...")
232 | user_info = self._verify_session()
233 | print(f" [*] Session 验证结果: {user_info}")
234 | if user_info and not user_info.get('error'):
235 | print(f" [+] Session 验证成功!用户: {user_info.get('name', 'Unknown')}")
236 | return {'token': 'SESSION_AUTH', 'user_info': user_info}
237 | else:
238 | print(f" [-] Session 验证失败或没有找到有效的session")
239 |
240 | return {'error': '未能从回调中获取 token'}
241 |
242 | except Exception as e:
243 | return {'error': f'处理回调失败: {str(e)}'}
244 |
245 | def _extract_token(self, input_str: str) -> Optional[str]:
246 | if '#token=' in input_str:
247 | match = re.search(r'#token=([^&\s]+)', input_str)
248 | if match: return match.group(1)
249 | if '?token=' in input_str:
250 | match = re.search(r'[?&]token=([^&\s]+)', input_str)
251 | if match: return match.group(1)
252 | return None
253 |
254 | def oauth_login_with_browser(self) -> Dict[str, Any]:
255 | """
256 | 通过浏览器进行 OAuth 登录
257 |
258 | Returns:
259 | 包含 zai.is JWT token 的字典
260 | """
261 | print("\n[*] 开始 OAuth 浏览器登录流程...")
262 |
263 | try:
264 | # Step 1: 获取 OAuth 登录 URL
265 | print("[1/3] 获取 Discord 授权 URL...")
266 | oauth_info = self._get_discord_authorize_url()
267 | if 'error' in oauth_info:
268 | return oauth_info
269 |
270 | authorize_url = oauth_info['authorize_url']
271 | print(f" 授权 URL: {authorize_url[:80]}...")
272 |
273 | # Step 2: 在浏览器中打开授权 URL
274 | print("[2/3] 在浏览器中打开授权页面...")
275 | print(" 请在浏览器中完成 Discord 登录授权")
276 | webbrowser.open(authorize_url)
277 |
278 | # Step 3: 等待用户完成授权并检查结果
279 | print("[3/3] 等待授权完成...")
280 | print(" 请在浏览器中完成授权,系统将自动检测...")
281 |
282 | # 创建一个标志来停止检查
283 | stop_checking = threading.Event()
284 | result = {'error': '授权超时'}
285 |
286 | def check_auth_status():
287 | nonlocal result
288 | start_time = time.time()
289 | check_interval = 2 # 每2秒检查一次
290 | max_wait = 120 # 最多等待120秒
291 |
292 | while not stop_checking.is_set() and (time.time() - start_time) < max_wait:
293 | # 检查 session 状态
294 | user_info = self._verify_session()
295 | if user_info and not user_info.get('error'):
296 | print(f"\n[+] 授权成功!用户: {user_info.get('name', 'Unknown')}")
297 | result = {
298 | 'token': 'SESSION_AUTH',
299 | 'user_info': user_info,
300 | 'source': 'oauth_browser'
301 | }
302 | stop_checking.set()
303 | break
304 |
305 | # 检查是否有 token cookie
306 | for cookie in self.session.cookies:
307 | if cookie.name == 'token':
308 | print(f"\n[+] 获取到 JWT Token!")
309 | result = {
310 | 'token': cookie.value,
311 | 'source': 'oauth_browser'
312 | }
313 | stop_checking.set()
314 | break
315 |
316 | time.sleep(check_interval)
317 |
318 | if not stop_checking.is_set():
319 | print("\n[-] 授权超时,请重试")
320 |
321 | # 在后台线程中检查授权状态
322 | check_thread = threading.Thread(target=check_auth_status)
323 | check_thread.start()
324 |
325 | # 等待检查完成
326 | check_thread.join()
327 | stop_checking.set()
328 |
329 | return result
330 |
331 | except Exception as e:
332 | return {'error': f'OAuth 浏览器登录出错: {str(e)}'}
333 |
334 | def _verify_session(self) -> Optional[Dict]:
335 | try:
336 | print(f" [*] 调用 API: {self.base_url}/api/v1/auths/")
337 | resp = self.session.get(
338 | f"{self.base_url}/api/v1/auths/",
339 | headers={'Accept': 'application/json'},
340 | timeout=10 # 添加超时设置
341 | )
342 | print(f" [*] API 响应状态码: {resp.status_code}")
343 |
344 | if resp.status_code == 200:
345 | data = resp.json()
346 | print(f" [*] API 响应数据: {data}")
347 | return data
348 | else:
349 | print(f" [-] API 响应内容: {resp.text[:500]}...")
350 | return None
351 | except requests.exceptions.Timeout as e:
352 | print(f" [!] API 请求超时: {str(e)}")
353 | return None
354 | except Exception as e:
355 | print(f" [!] Session 验证出错: {str(e)}")
356 | return None
357 |
358 | def main():
359 | parser = argparse.ArgumentParser(description='zAI Token 获取工具')
360 | subparsers = parser.add_subparsers(dest='command')
361 |
362 | # Only keep backend-login
363 | backend_parser = subparsers.add_parser('backend-login', help='后端登录')
364 | backend_parser.add_argument('--discord-token', required=True, help='Discord Token')
365 | backend_parser.add_argument('--url', default='https://zai.is', help='Base URL')
366 |
367 | args = parser.parse_args()
368 |
369 | if args.command == 'backend-login':
370 | handler = DiscordOAuthHandler(args.url)
371 | result = handler.backend_login(args.discord_token)
372 |
373 | if 'error' in result:
374 | print(f"\n[!] 登录失败: {result['error']}")
375 | else:
376 | print(f"\n[+] 登录成功!\n")
377 |
378 | token = result.get('token')
379 | if token == 'SESSION_AUTH':
380 | # Try to extract a real token from user_info if present, else just show a message
381 | user_info = result.get('user_info', {})
382 | print(f"\n[Session Cookie Authentication Active]")
383 | print(f"User: {user_info.get('name')} ({user_info.get('email')})")
384 | print(f"ID: {user_info.get('id')}")
385 | else:
386 | print(f"\n{token}\n")
387 |
388 | if __name__ == '__main__':
389 | main()
--------------------------------------------------------------------------------
/自动刷新token推送到newapi/zai_token.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | """
4 | zAI Token 获取并推送到newapi
5 | 纯后端 Discord OAuth 登录
6 | 命令行用法示例:python zai_token.py run-loop --config config.json
7 | """
8 |
9 | import base64
10 | import json
11 | import argparse
12 | import os
13 | import requests
14 | import re
15 | import time
16 | from typing import Optional, Dict, Any, List
17 | from urllib.parse import urlparse, parse_qs
18 |
19 | class DiscordOAuthHandler:
20 | """Discord OAuth 登录处理"""
21 |
22 | # Discord API 端点
23 | DISCORD_API_BASE = "https://discord.com/api/v9"
24 |
25 | def __init__(self, base_url: str = "https://zai.is"):
26 | self.base_url = base_url
27 | self.session = requests.Session()
28 | self.session.headers.update({
29 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
30 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
31 | 'Accept-Language': 'en-US,en;q=0.9',
32 | 'Referer': f'{base_url}/auth',
33 | 'Origin': base_url,
34 | 'Connection': 'keep-alive',
35 | 'Upgrade-Insecure-Requests': '1',
36 | 'Sec-Fetch-Dest': 'document',
37 | 'Sec-Fetch-Mode': 'navigate',
38 | 'Sec-Fetch-Site': 'same-origin',
39 | 'Sec-Fetch-User': '?1',
40 | })
41 |
42 | def get_oauth_login_url(self) -> str:
43 | """获取 Discord OAuth 登录 URL"""
44 | return f"{self.base_url}/oauth/discord/login"
45 |
46 | def backend_login(self, discord_token: str) -> Dict[str, Any]:
47 | """
48 | 纯后端 Discord OAuth 登录
49 |
50 | Args:
51 | discord_token: Discord 账号的 token
52 |
53 | Returns:
54 | zai.is JWT token
55 | """
56 | if not discord_token or len(discord_token) < 20:
57 | return {'error': '无效的 Discord Token'}
58 |
59 | print("\n[*] 开始后端 OAuth 登录流程...")
60 | print(f"[*] Discord Token: {discord_token[:20]}...{discord_token[-10:]}")
61 |
62 | try:
63 | # Step 1: 访问 OAuth 登录入口,获取 Discord 授权 URL
64 | print("[1/5] 获取 Discord 授权 URL...")
65 | oauth_info = self._get_discord_authorize_url()
66 | if 'error' in oauth_info:
67 | return oauth_info
68 |
69 | authorize_url = oauth_info['authorize_url']
70 | client_id = oauth_info['client_id']
71 | redirect_uri = oauth_info['redirect_uri']
72 | state = oauth_info.get('state', '')
73 | scope = oauth_info.get('scope', 'identify email')
74 |
75 | print(f" Client ID: {client_id}")
76 | print(f" Redirect URI: {redirect_uri}")
77 | print(f" Scope: {scope}")
78 |
79 | # Step 2: 使用 Discord token 授权应用
80 | print("[2/5] 授权应用...")
81 | auth_result = self._authorize_discord_app(
82 | discord_token, client_id, redirect_uri, scope, state
83 | )
84 | if 'error' in auth_result:
85 | return auth_result
86 |
87 | callback_url = auth_result['callback_url']
88 | print(f" 获取到回调 URL")
89 |
90 | # Step 3: 访问回调 URL 获取 token
91 | print("[3/5] 处理 OAuth 回调...")
92 | token_result = self._handle_oauth_callback(callback_url)
93 | if 'error' in token_result:
94 | return token_result
95 |
96 | print(f"[4/5] 成功获取 JWT Token!")
97 |
98 | return token_result
99 |
100 | except Exception as e:
101 | return {'error': f'登录过程出错: {str(e)}'}
102 |
103 | def _get_discord_authorize_url(self) -> Dict[str, Any]:
104 | """获取 Discord 授权 URL 和参数"""
105 | try:
106 | response = self.session.get(
107 | self.get_oauth_login_url(),
108 | allow_redirects=False
109 | )
110 |
111 | if response.status_code in [301, 302, 303, 307, 308]:
112 | location = response.headers.get('Location', '')
113 | if 'discord.com' in location:
114 | parsed = urlparse(location)
115 | params = parse_qs(parsed.query)
116 | return {
117 | 'authorize_url': location,
118 | 'client_id': params.get('client_id', [''])[0],
119 | 'redirect_uri': params.get('redirect_uri', [''])[0],
120 | 'scope': params.get('scope', ['identify email'])[0],
121 | 'state': params.get('state', [''])[0]
122 | }
123 | return {'error': f'无法获取授权 URL,状态码: {response.status_code}'}
124 | except Exception as e:
125 | return {'error': f'获取授权 URL 失败: {str(e)}'}
126 |
127 | def _authorize_discord_app(self, discord_token, client_id, redirect_uri, scope, state) -> Dict[str, Any]:
128 | """使用 Discord token 授权应用"""
129 | try:
130 | authorize_url = f"{self.DISCORD_API_BASE}/oauth2/authorize"
131 |
132 | # 构建 super properties
133 | super_properties = base64.b64encode(json.dumps({
134 | "os": "Windows",
135 | "browser": "Chrome",
136 | "device": "",
137 | "browser_user_agent": self.session.headers['User-Agent'],
138 | }).encode()).decode()
139 |
140 | headers = {
141 | 'Authorization': discord_token,
142 | 'Content-Type': 'application/json',
143 | 'X-Super-Properties': super_properties,
144 | }
145 |
146 | params = {
147 | 'client_id': client_id,
148 | 'response_type': 'code',
149 | 'redirect_uri': redirect_uri,
150 | 'scope': scope,
151 | }
152 | if state:
153 | params['state'] = state
154 |
155 | payload = {
156 | 'permissions': '0',
157 | 'authorize': True,
158 | 'integration_type': 0
159 | }
160 |
161 | response = self.session.post(
162 | authorize_url,
163 | headers=headers,
164 | params=params,
165 | json=payload
166 | )
167 |
168 | if response.status_code == 200:
169 | try:
170 | data = response.json()
171 | location = data.get('location', '')
172 | if location:
173 | if location.startswith('/'):
174 | location = f"{self.base_url}{location}"
175 | return {'callback_url': location}
176 | except:
177 | pass
178 |
179 | return {'error': f'授权失败 (状态码: {response.status_code})'}
180 |
181 | except Exception as e:
182 | return {'error': f'授权过程出错: {str(e)}'}
183 |
184 | def _handle_oauth_callback(self, callback_url: str) -> Dict[str, Any]:
185 | """处理 OAuth 回调,获取 JWT token"""
186 | try:
187 | print(f" 回调 URL: {callback_url[:80]}...")
188 |
189 | response = self.session.get(callback_url, allow_redirects=False)
190 |
191 | max_redirects = 10
192 | for i in range(max_redirects):
193 | print(f" 重定向 {i+1}: 状态码 {response.status_code}")
194 |
195 | if response.status_code not in [301, 302, 303, 307, 308]:
196 | break
197 |
198 | location = response.headers.get('Location', '')
199 | print(f" Location: {location[:100]}...")
200 |
201 | # Check for token in URL
202 | token = self._extract_token(location)
203 | if token: return {'token': token}
204 |
205 | if location.startswith('/'):
206 | location = f"{self.base_url}{location}"
207 |
208 | response = self.session.get(location, allow_redirects=False)
209 |
210 | # Final check in URL
211 | final_url = response.url if hasattr(response, 'url') else ''
212 | print(f" 最终 URL: {final_url}")
213 | print(f" 最终状态码: {response.status_code}")
214 |
215 | token = self._extract_token(final_url)
216 | if token: return {'token': token}
217 |
218 | # Check Cookies
219 | print(f" 检查 Cookies...")
220 | has_session = False
221 | for cookie in self.session.cookies:
222 | print(f" {cookie.name}: {str(cookie.value)[:50]}...")
223 | if cookie.name == 'token':
224 | return {'token': cookie.value}
225 | if any(x in cookie.name.lower() for x in ['session', 'auth', 'id', 'user']):
226 | has_session = True
227 |
228 | # Session Fallback
229 | if has_session:
230 | print(f" [!] 尝试 Session 验证...")
231 | user_info = self._verify_session()
232 | if user_info and not user_info.get('error'):
233 | print(f" [+] Session 验证成功!用户: {user_info.get('name', 'Unknown')}")
234 | return {'token': 'SESSION_AUTH', 'user_info': user_info}
235 |
236 | return {'error': '未能从回调中获取 token'}
237 |
238 | except Exception as e:
239 | return {'error': f'处理回调失败: {str(e)}'}
240 |
241 | def _extract_token(self, input_str: str) -> Optional[str]:
242 | if '#token=' in input_str:
243 | match = re.search(r'#token=([^&\s]+)', input_str)
244 | if match: return match.group(1)
245 | if '?token=' in input_str:
246 | match = re.search(r'[?&]token=([^&\s]+)', input_str)
247 | if match: return match.group(1)
248 | return None
249 |
250 | def _verify_session(self) -> Optional[Dict]:
251 | try:
252 | resp = self.session.get(f"{self.base_url}/api/v1/auths/", headers={'Accept': 'application/json'})
253 | if resp.status_code == 200: return resp.json()
254 | except: pass
255 | return None
256 |
257 |
258 | class NewAPITokenManager:
259 | """
260 | NewAPI 渠道 token 管理
261 | 参考接口文档: https://apifox.newapi.ai/llms.txt
262 | 接口路径: /api/channel/
263 | """
264 |
265 | def __init__(self, base_url: str, api_key: str, user_id: str = "1"):
266 | self.base_url = base_url.rstrip("/")
267 | self.api_key = api_key
268 | self.user_id = user_id
269 | self.session = requests.Session()
270 | self.session.headers.update({
271 | "New-Api-User": str(user_id),
272 | "Authorization": f"Bearer {api_key}",
273 | "Content-Type": "application/json",
274 | "Accept": "application/json",
275 | })
276 |
277 | def get_channel(self, channel_id: str) -> Optional[Dict[str, Any]]:
278 | """获取渠道信息 GET /api/channel/{id}"""
279 | url = f"{self.base_url}/api/channel/{channel_id}"
280 | resp = self.session.get(url)
281 | if resp.status_code == 200:
282 | try:
283 | data = resp.json()
284 | if isinstance(data, dict):
285 | if "data" in data:
286 | return data.get("data")
287 | return data
288 | except Exception as exc:
289 | print(f"[NewAPI] 解析渠道信息失败: {exc}")
290 | else:
291 | print(f"[NewAPI] 获取渠道失败: {resp.status_code} {resp.text}")
292 | return None
293 |
294 | def update_channel(self, channel_data: Dict[str, Any]) -> bool:
295 | """更新渠道 PUT /api/channel/"""
296 | url = f"{self.base_url}/api/channel/"
297 | resp = self.session.put(url, json=channel_data)
298 | if resp.status_code in (200, 201):
299 | print(f"[NewAPI] 更新渠道成功")
300 | return True
301 | print(f"[NewAPI] 更新渠道失败: {resp.status_code} {resp.text}")
302 | return False
303 |
304 | def get_channel_keys(self, channel_id: str) -> List[str]:
305 | """获取渠道当前的 key 列表"""
306 | channel = self.get_channel(channel_id)
307 | if not channel:
308 | return []
309 | key_str = channel.get("key", "")
310 | if not key_str:
311 | return []
312 | return [k.strip() for k in key_str.split("\n") if k.strip()]
313 |
314 | def clear_channel_tokens(self, channel_id: str) -> None:
315 | """清空渠道的所有 key"""
316 | channel = self.get_channel(channel_id)
317 | if not channel:
318 | print("[NewAPI] 无法获取渠道信息,跳过清理")
319 | return
320 |
321 | old_keys = channel.get("key", "")
322 | if not old_keys or not old_keys.strip():
323 | print("[NewAPI] 渠道当前无 key")
324 | return
325 |
326 | key_count = len([k for k in old_keys.split("\n") if k.strip()])
327 | print(f"[NewAPI] 准备清空渠道 {key_count} 个旧 key ...")
328 |
329 | channel["key"] = ""
330 | if self.update_channel(channel):
331 | print(f"[NewAPI] 已清空渠道旧 key")
332 | else:
333 | print(f"[NewAPI] 清空渠道旧 key 失败")
334 |
335 | def push_tokens(self, channel_id: str, tokens: List[str]) -> bool:
336 | """推送多个 token 到渠道(替换原有 key)"""
337 | channel = self.get_channel(channel_id)
338 | if not channel:
339 | print("[NewAPI] 无法获取渠道信息,推送失败")
340 | return False
341 |
342 | channel["key"] = "\n".join(tokens)
343 | if self.update_channel(channel):
344 | print(f"[NewAPI] 推送 {len(tokens)} 个 token 成功")
345 | return True
346 | return False
347 |
348 | def create_token(self, channel_id: str, token: str, expires_in: int = 10800) -> bool:
349 | """添加单个 token(追加到现有 key)"""
350 | channel = self.get_channel(channel_id)
351 | if not channel:
352 | print("[NewAPI] 无法获取渠道信息,推送失败")
353 | return False
354 |
355 | old_key = channel.get("key", "") or ""
356 | # 追加新 token
357 | if old_key.strip():
358 | channel["key"] = old_key.strip() + "\n" + token
359 | else:
360 | channel["key"] = token
361 |
362 | if self.update_channel(channel):
363 | print(f"[NewAPI] 推送 token 成功: {token[:8]}...{token[-6:]}")
364 | return True
365 | print(f"[NewAPI] 推送 token 失败")
366 | return False
367 |
368 |
369 | def _load_discord_tokens(single_token: Optional[str] = None,
370 | token_file: Optional[str] = None,
371 | token_list: Optional[List[str]] = None) -> List[str]:
372 | tokens: List[str] = []
373 | if token_list:
374 | for t in token_list:
375 | if t:
376 | tokens.append(t.strip())
377 | if single_token:
378 | tokens.append(single_token.strip())
379 | if token_file and os.path.exists(token_file):
380 | with open(token_file, "r", encoding="utf-8") as f:
381 | for line in f:
382 | line = line.strip()
383 | if line:
384 | tokens.append(line)
385 | uniq: List[str] = []
386 | seen = set()
387 | for t in tokens:
388 | if len(t) < 20:
389 | continue
390 | if t in seen:
391 | continue
392 | uniq.append(t)
393 | seen.add(t)
394 | return uniq
395 |
396 |
397 | def _load_config(config_path: str) -> Dict[str, Any]:
398 | if not os.path.exists(config_path):
399 | print(f"[!] 未找到配置文件: {config_path}")
400 | return {}
401 | try:
402 | with open(config_path, "r", encoding="utf-8") as f:
403 | return json.load(f)
404 | except Exception as exc:
405 | print(f"[!] 读取配置失败: {exc}")
406 | return {}
407 |
408 |
409 | def convert_and_push(discord_tokens: List[str], zai_url: str, newapi_base: str, newapi_key: str, channel_id: str, expires_in: int, user_id: str = "1") -> None:
410 | if not discord_tokens:
411 | print("[!] 未提供 Discord Token,跳过本轮")
412 | return
413 |
414 | print(f"[*] 将处理 {len(discord_tokens)} 个 Discord Token")
415 | handler = DiscordOAuthHandler(zai_url)
416 |
417 | zai_tokens: List[str] = []
418 | for idx, d_token in enumerate(discord_tokens, start=1):
419 | print(f"\n==== {idx}/{len(discord_tokens)} 开始转换 ====")
420 | res = handler.backend_login(d_token)
421 | if res.get('error'):
422 | print(f"[!] 转换失败: {res['error']}")
423 | continue
424 | token_val = res.get('token')
425 | if not token_val or token_val == 'SESSION_AUTH':
426 | print("[!] 未获取到有效的 zAI token,已跳过")
427 | continue
428 | zai_tokens.append(token_val)
429 | print(f"[+] 转换成功,获得 token: {token_val[:12]}...{token_val[-8:]}")
430 |
431 | if not zai_tokens:
432 | print("[!] 所有 Discord Token 均转换失败,停止推送")
433 | return
434 |
435 | manager = NewAPITokenManager(newapi_base, newapi_key, user_id)
436 | print(f"\n[*] 开始推送 {len(zai_tokens)} 个新 token 到渠道 {channel_id} (将替换旧 key)")
437 |
438 | # 一次性替换渠道的所有 key
439 | if manager.push_tokens(channel_id, zai_tokens):
440 | print(f"\n[+] 推送完成,成功 {len(zai_tokens)}/{len(zai_tokens)}")
441 | else:
442 | print(f"\n[!] 推送失败")
443 |
444 | def main():
445 | parser = argparse.ArgumentParser(description='zAI Token 获取工具')
446 | subparsers = parser.add_subparsers(dest='command')
447 | backend_parser = subparsers.add_parser('backend-login', help='后端登录')
448 | backend_parser.add_argument('--discord-token', required=True, help='Discord Token')
449 | backend_parser.add_argument('--url', default='https://zai.is', help='Base URL')
450 | batch_parser = subparsers.add_parser('batch-push', help='批量转换 Discord token 并推送到 NewAPI 渠道')
451 | batch_parser.add_argument('--discord-token', action='append', help='Discord Token,可重复')
452 | batch_parser.add_argument('--discord-token-file', help='包含 Discord Token 的文件,一行一个')
453 | batch_parser.add_argument('--url', default='https://zai.is', help='zAI Base URL')
454 | batch_parser.add_argument('--newapi-base', default='https://api.newapi.ai', help='NewAPI 基础 URL')
455 | batch_parser.add_argument('--newapi-key', required=True, help='NewAPI 管理密钥 (Bearer)')
456 | batch_parser.add_argument('--newapi-channel-id', required=True, help='NewAPI 渠道 ID')
457 | batch_parser.add_argument('--expires-in', type=int, default=10800, help='新 token 有效期(秒),默认 10800 秒=3 小时')
458 |
459 | # 读取 JSON 配置并循环运行
460 | loop_parser = subparsers.add_parser('run-loop', help='读取 JSON 配置并循环转换+推送')
461 | loop_parser.add_argument('--config', default='config.json', help='配置文件路径,JSON 格式')
462 |
463 | args = parser.parse_args()
464 |
465 | if args.command == 'backend-login':
466 | handler = DiscordOAuthHandler(args.url)
467 | result = handler.backend_login(args.discord_token)
468 |
469 | if 'error' in result:
470 | print(f"\n[!] 登录失败: {result['error']}")
471 | else:
472 | print(f"\n[+] 登录成功!\n")
473 |
474 | token = result.get('token')
475 | if token == 'SESSION_AUTH':
476 | user_info = result.get('user_info', {})
477 | print(f"\n[Session Cookie Authentication Active]")
478 | print(f"User: {user_info.get('name')} ({user_info.get('email')})")
479 | print(f"ID: {user_info.get('id')}")
480 | else:
481 | print(f"\n{token}\n")
482 | elif args.command == 'batch-push':
483 | discord_tokens = _load_discord_tokens(args.discord_token, args.discord_token_file)
484 | if not discord_tokens:
485 | print("[!] 未提供有效的 Discord Token,请使用 --discord-token 或 --discord-token-file")
486 | return
487 |
488 | convert_and_push(
489 | discord_tokens=discord_tokens,
490 | zai_url=args.url,
491 | newapi_base=args.newapi_base,
492 | newapi_key=args.newapi_key,
493 | channel_id=args.newapi_channel_id,
494 | expires_in=args.expires_in
495 | )
496 | elif args.command == 'run-loop':
497 | config_path = args.config
498 | print(f"[*] 使用配置文件循环运行: {config_path}")
499 | try:
500 | while True:
501 | cfg = _load_config(config_path)
502 | if not cfg:
503 | break
504 |
505 | discord_tokens = _load_discord_tokens(
506 | single_token=None,
507 | token_file=cfg.get("discord_token_file"),
508 | token_list=cfg.get("discord_tokens") or cfg.get("discord_token")
509 | )
510 | zai_url = cfg.get("zai_url") or cfg.get("zai_base_url") or "https://zai.is"
511 | newapi_base = cfg.get("newapi_base") or cfg.get("newapi_base_url") or "https://91vip.futureppo.top"
512 | newapi_key = cfg.get("newapi_key") or cfg.get("system_token") or cfg.get("access_token")
513 | channel_id = cfg.get("newapi_channel_id") or cfg.get("channel_id")
514 | user_id = str(cfg.get("newapi_user_id") or cfg.get("user_id") or "1")
515 | expires_in = int(cfg.get("expires_in", 10800))
516 | interval = int(cfg.get("update_interval", cfg.get("interval", 3600)))
517 |
518 | if not newapi_key or not channel_id:
519 | print("[!] 配置缺少 newapi_key 或 newapi_channel_id,退出")
520 | break
521 |
522 | convert_and_push(
523 | discord_tokens=discord_tokens,
524 | zai_url=zai_url,
525 | newapi_base=newapi_base,
526 | newapi_key=newapi_key,
527 | channel_id=channel_id,
528 | expires_in=expires_in,
529 | user_id=user_id
530 | )
531 | print(f"\n[*] 本轮结束,{interval} 秒后再次执行 ...\n")
532 | time.sleep(max(1, interval))
533 | except KeyboardInterrupt:
534 | print("\n[!] 已停止循环运行")
535 | else:
536 | parser.print_help()
537 |
538 | if __name__ == '__main__':
539 | main()
--------------------------------------------------------------------------------
/static/manage.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 管理控制台 - Zai2API
7 |
8 |
16 |
19 |
20 |
21 |
22 |
23 |
24 |
Zai2API
25 |
26 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
Token 总数
47 |
-
48 |
49 |
50 |
活跃 Token
51 |
-
52 |
53 |
54 |
55 |
56 |
57 |
61 |
64 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | | discordtoken |
76 | 状态 |
77 | zaitoken |
78 | 剩余刷新时间 |
79 | 错误 |
80 | 刷新状态 (悬停查看完整内容) |
81 | 操作 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
安全配置
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
API 密钥配置
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
错误处理配置
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
调试与刷新配置
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
流式转换配置
157 |
158 |
159 |
160 |
161 |
162 | zai.is 强制要求以 stream=true 调用。开启后:当客户端发来非流式请求时,本服务会自动转为流式请求并聚合为非流式响应返回。
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | | discordtoken |
179 | zaitoken |
180 | 状态码 |
181 | 耗时(秒) |
182 | 时间 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
添加 Token
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
506 |
507 |
508 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import logging
4 | import json
5 | import hashlib
6 | import sqlite3
7 | from datetime import datetime
8 | from threading import Lock
9 | from flask import Flask, request, jsonify, render_template, send_from_directory, Response, stream_with_context
10 | from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
11 | from werkzeug.security import generate_password_hash, check_password_hash
12 | from apscheduler.schedulers.background import BackgroundScheduler
13 | import requests
14 |
15 | from extensions import db
16 | from models import SystemConfig, Token, RequestLog
17 | import services
18 |
19 | # Initialize App
20 | app = Flask(__name__, static_folder='static', template_folder='static')
21 | # NOTE: Flask-SQLAlchemy 会将相对 sqlite 路径自动解析到 app.instance_path 下;
22 | # 默认使用 sqlite:///zai2api.db 即会落到 ./instance/zai2api.db(与仓库结构一致)。
23 | app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URI', 'sqlite:///zai2api.db')
24 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
25 | app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key-change-me')
26 |
27 | # Initialize DB
28 | db.init_app(app)
29 |
30 | # Logging Setup
31 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
32 | logger = logging.getLogger(__name__)
33 |
34 | # Login Manager
35 | login_manager = LoginManager()
36 | login_manager.init_app(app)
37 | login_manager.login_view = 'login_page'
38 |
39 | class User(UserMixin):
40 | def __init__(self, id, username):
41 | self.id = id
42 | self.username = username
43 |
44 | @login_manager.user_loader
45 | def load_user(user_id):
46 | # We only have one admin user
47 | config = SystemConfig.query.first()
48 | if config and str(config.id) == user_id:
49 | return User(id=str(config.id), username=config.admin_username)
50 | return None
51 |
52 | # --- SQLite schema migration (lightweight, no Alembic) ---
53 |
54 | def _sqlite_path_from_uri(uri: str) -> str | None:
55 | if not uri:
56 | return None
57 | if uri.startswith('sqlite:///:memory:'):
58 | return None
59 | if uri.startswith('sqlite:///'):
60 | # Works for both relative (sqlite:///instance/db.sqlite) and absolute (sqlite:////app/instance/db.sqlite)
61 | return uri[len('sqlite:///'):]
62 | return None
63 |
64 | def _sqlite_table_columns(cursor, table_name: str) -> set[str]:
65 | cursor.execute(f"PRAGMA table_info({table_name})")
66 | return {row[1] for row in cursor.fetchall()}
67 |
68 | def migrate_sqlite_schema():
69 | # Prefer the *resolved* sqlite file path used by Flask-SQLAlchemy (usually under app.instance_path).
70 | path = None
71 | try:
72 | engine = db.engine # requires app_context
73 | if getattr(engine.url, 'drivername', None) == 'sqlite':
74 | path = engine.url.database
75 | except Exception:
76 | path = None
77 |
78 | if not path:
79 | uri = app.config.get('SQLALCHEMY_DATABASE_URI')
80 | path = _sqlite_path_from_uri(uri)
81 | if not path:
82 | return
83 |
84 | # Ensure directory exists for relative sqlite paths
85 | dir_name = os.path.dirname(path)
86 | if dir_name:
87 | os.makedirs(dir_name, exist_ok=True)
88 |
89 | conn = sqlite3.connect(path)
90 | cur = conn.cursor()
91 | try:
92 | # system_config: add missing columns safely
93 | sc_cols = _sqlite_table_columns(cur, 'system_config')
94 | if sc_cols:
95 | if 'error_retry_count' not in sc_cols:
96 | cur.execute("ALTER TABLE system_config ADD COLUMN error_retry_count INTEGER DEFAULT 3")
97 | if 'token_refresh_interval' not in sc_cols:
98 | cur.execute("ALTER TABLE system_config ADD COLUMN token_refresh_interval INTEGER DEFAULT 3600")
99 | if 'stream_conversion_enabled' not in sc_cols:
100 | cur.execute("ALTER TABLE system_config ADD COLUMN stream_conversion_enabled BOOLEAN DEFAULT 0")
101 |
102 | # request_log: add missing columns for UI display
103 | rl_cols = _sqlite_table_columns(cur, 'request_log')
104 | if rl_cols:
105 | if 'discord_token' not in rl_cols:
106 | cur.execute("ALTER TABLE request_log ADD COLUMN discord_token TEXT")
107 | if 'zai_token' not in rl_cols:
108 | cur.execute("ALTER TABLE request_log ADD COLUMN zai_token TEXT")
109 |
110 | conn.commit()
111 | finally:
112 | conn.close()
113 |
114 | def _mask_token(value: str | None, head: int = 12, tail: int = 6) -> str | None:
115 | if not value:
116 | return None
117 | if len(value) <= head + tail:
118 | return value
119 | return f"{value[:head]}...{value[-tail:]}"
120 |
121 | def _dt_iso(dt):
122 | """统一的时间序列化,去掉微秒,便于前端解析显示。"""
123 | return dt.replace(microsecond=0).isoformat() if dt else None
124 |
125 | # Database Initialization
126 | def init_db():
127 | with app.app_context():
128 | db.create_all()
129 | # Make sure old sqlite DBs get new columns before ORM queries start
130 | migrate_sqlite_schema()
131 | db.create_all()
132 | config = SystemConfig.query.first()
133 | if not config:
134 | # Default Admin: admin / admin
135 | # Use pbkdf2:sha256 which is default in generate_password_hash
136 | config = SystemConfig(
137 | admin_username='admin',
138 | admin_password_hash=generate_password_hash('admin')
139 | )
140 | db.session.add(config)
141 | db.session.commit()
142 | print("Initialized default admin/admin")
143 |
144 | # Ensure scheduler interval reflects persisted config (survives restart)
145 | try:
146 | seconds = int(getattr(config, 'token_refresh_interval', 3600) or 3600)
147 | scheduler.reschedule_job('token_refresher', trigger='interval', seconds=seconds)
148 | except Exception as e:
149 | logger.error(f"Failed to apply token_refresh_interval on startup: {e}")
150 |
151 | # Scheduler
152 | def scheduled_refresh():
153 | with app.app_context():
154 | services.refresh_all_tokens()
155 |
156 | scheduler = BackgroundScheduler()
157 | scheduler.add_job(scheduled_refresh, 'interval', seconds=3600, id='token_refresher')
158 | scheduler.start()
159 |
160 | # --- Routes: Pages ---
161 |
162 | @app.route('/login')
163 | def login_page():
164 | return send_from_directory('static', 'login.html')
165 |
166 | @app.route('/manage')
167 | def manage_page():
168 | return send_from_directory('static', 'manage.html')
169 |
170 | @app.route('/')
171 | def index():
172 | return send_from_directory('static', 'login.html')
173 |
174 | # --- Routes: Auth API ---
175 |
176 | @app.route('/api/login', methods=['POST'])
177 | def api_login():
178 | data = request.json
179 | username = data.get('username')
180 | password = data.get('password')
181 |
182 | config = SystemConfig.query.first()
183 | if config and config.admin_username == username and check_password_hash(config.admin_password_hash, password):
184 | user = User(id=str(config.id), username=config.admin_username)
185 | login_user(user)
186 | import jwt
187 | token = jwt.encode({'user_id': str(config.id), 'exp': datetime.utcnow().timestamp() + 86400}, app.config['SECRET_KEY'], algorithm='HS256')
188 | return jsonify({'success': True, 'token': token})
189 |
190 | return jsonify({'success': False, 'message': 'Invalid credentials'}), 401
191 |
192 | # Middleware for Bearer Token Auth
193 | def check_auth_token():
194 | auth_header = request.headers.get('Authorization')
195 | if auth_header and auth_header.startswith('Bearer '):
196 | token = auth_header.split(' ')[1]
197 | import jwt
198 | try:
199 | payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
200 | return payload.get('user_id')
201 | except:
202 | return None
203 | return None
204 |
205 | # Wrapper for API routes requiring auth
206 | def api_auth_required(f):
207 | from functools import wraps
208 | @wraps(f)
209 | def decorated_function(*args, **kwargs):
210 | if request.method == 'OPTIONS':
211 | return f(*args, **kwargs)
212 | user_id = check_auth_token()
213 | if not user_id:
214 | return jsonify({'error': 'Unauthorized'}), 401
215 | return f(*args, **kwargs)
216 | return decorated_function
217 |
218 | # --- Routes: Admin API ---
219 |
220 | @app.route('/api/stats', methods=['GET'])
221 | @api_auth_required
222 | def api_stats():
223 | total_tokens = Token.query.count()
224 | active_tokens = Token.query.filter_by(is_active=True).count()
225 | # Mocking today stats for now or deriving from logs if detailed
226 | total_images = Token.query.with_entities(db.func.sum(Token.image_count)).scalar() or 0
227 | total_videos = Token.query.with_entities(db.func.sum(Token.video_count)).scalar() or 0
228 | total_errors = Token.query.with_entities(db.func.sum(Token.error_count)).scalar() or 0
229 |
230 | return jsonify({
231 | 'total_tokens': total_tokens,
232 | 'active_tokens': active_tokens,
233 | 'today_images': 0, # Implement daily stats if needed
234 | 'total_images': total_images,
235 | 'today_videos': 0,
236 | 'total_videos': total_videos,
237 | 'today_errors': 0,
238 | 'total_errors': total_errors
239 | })
240 |
241 | @app.route('/api/tokens', methods=['GET'])
242 | @api_auth_required
243 | def get_tokens():
244 | tokens = Token.query.all()
245 | config = SystemConfig.query.first()
246 | result = []
247 | for t in tokens:
248 | result.append({
249 | 'id': t.id,
250 | 'email': t.email,
251 | 'is_active': t.is_active,
252 | 'at_expires': _dt_iso(t.at_expires),
253 | 'credits': t.credits,
254 | 'user_paygate_tier': t.user_paygate_tier,
255 | 'current_project_name': t.current_project_name,
256 | 'current_project_id': t.current_project_id,
257 | 'image_count': t.image_count,
258 | 'video_count': t.video_count,
259 | 'error_count': t.error_count,
260 | 'remark': t.remark,
261 | 'image_enabled': t.image_enabled,
262 | 'video_enabled': t.video_enabled,
263 | 'image_concurrency': t.image_concurrency,
264 | 'video_concurrency': t.video_concurrency,
265 | 'zai_token': t.zai_token,
266 | 'st': t.discord_token[:10] + '...' # Masked for security? Frontend uses it for edit.
267 | # Ideally return full ST for edit, or handle separately. Frontend calls edit and pre-fills ST.
268 | # Let's return full ST for now as admin panel.
269 | })
270 | # Add full ST if requested or for admin
271 | result[-1]['st'] = t.discord_token
272 | # Add system config for frontend use (token refresh interval)
273 | response_data = {
274 | 'tokens': result,
275 | 'config': {
276 | 'token_refresh_interval': config.token_refresh_interval if config else 3600
277 | }
278 | }
279 | return jsonify(response_data)
280 |
281 | @app.route('/api/tokens', methods=['POST'])
282 | @api_auth_required
283 | def add_token():
284 | data = request.json
285 | st = data.get('st')
286 | if not st:
287 | return jsonify({'success': False, 'message': 'Missing Discord Token'}), 400
288 |
289 | token = Token(
290 | discord_token=st,
291 | remark=data.get('remark'),
292 | current_project_id=data.get('project_id'),
293 | current_project_name=data.get('project_name'),
294 | image_enabled=data.get('image_enabled', True),
295 | video_enabled=data.get('video_enabled', True),
296 | image_concurrency=data.get('image_concurrency', -1),
297 | video_concurrency=data.get('video_concurrency', -1)
298 | )
299 | db.session.add(token)
300 | db.session.commit()
301 |
302 | # Initial refresh
303 | success, msg = services.update_token_info(token.id)
304 | if not success:
305 | token.remark = f"Initial refresh failed: {msg}"
306 | db.session.commit()
307 | return jsonify({'success': True, 'message': 'Token added but refresh failed: ' + msg})
308 |
309 | return jsonify({'success': True})
310 |
311 | @app.route('/api/tokens/', methods=['PUT'])
312 | @api_auth_required
313 | def update_token(id):
314 | token = Token.query.get_or_404(id)
315 | data = request.json
316 |
317 | if 'st' in data: token.discord_token = data['st']
318 | if 'remark' in data: token.remark = data['remark']
319 | if 'project_id' in data: token.current_project_id = data['project_id']
320 | if 'project_name' in data: token.current_project_name = data['project_name']
321 | if 'image_enabled' in data: token.image_enabled = data['image_enabled']
322 | if 'video_enabled' in data: token.video_enabled = data['video_enabled']
323 | if 'image_concurrency' in data: token.image_concurrency = data['image_concurrency']
324 | if 'video_concurrency' in data: token.video_concurrency = data['video_concurrency']
325 |
326 | db.session.commit()
327 | return jsonify({'success': True})
328 |
329 | @app.route('/api/tokens/', methods=['DELETE'])
330 | @api_auth_required
331 | def delete_token(id):
332 | token = Token.query.get_or_404(id)
333 | db.session.delete(token)
334 | db.session.commit()
335 | return jsonify({'success': True})
336 |
337 | @app.route('/api/tokens/refresh-all', methods=['POST'])
338 | @api_auth_required
339 | def refresh_all_tokens_endpoint():
340 | try:
341 | services.refresh_all_tokens(force=True)
342 | return jsonify({'success': True, 'message': '所有 Token 刷新请求已发送'})
343 | except Exception as e:
344 | logger.error(f"Manual refresh failed: {e}")
345 | return jsonify({'success': False, 'message': str(e)})
346 |
347 | @app.route('/api/tokens//refresh-at', methods=['POST'])
348 | @api_auth_required
349 | def refresh_token_at(id):
350 | success, msg = services.update_token_info(id)
351 | if success:
352 | token = db.session.get(Token, id)
353 | return jsonify({'success': True, 'token': {'at_expires': _dt_iso(token.at_expires)}})
354 | return jsonify({'success': False, 'detail': msg})
355 |
356 | @app.route('/api/tokens//refresh-credits', methods=['POST'])
357 | @api_auth_required
358 | def refresh_token_credits(id):
359 | # This requires an API call to Zai using the AT
360 | # services.update_token_info gets the AT. We need another function to fetch credits.
361 | # For now, let's reuse update_token_info as it fetches account info if we implemented it fully.
362 | # But currently update_token_info only does login.
363 | # We need to implement credit fetching.
364 | # For now, stub it or just call update_token_info.
365 | success, msg = services.update_token_info(id)
366 | if success:
367 | token = db.session.get(Token, id)
368 | return jsonify({'success': True, 'credits': token.credits})
369 | return jsonify({'success': False, 'detail': msg})
370 |
371 | @app.route('/api/tokens/st2at', methods=['POST'])
372 | @api_auth_required
373 | def st2at():
374 | data = request.json
375 | st = data.get('st')
376 | handler = services.get_zai_handler()
377 | result = handler.backend_login(st)
378 | if 'error' in result:
379 | return jsonify({'success': False, 'message': result['error']})
380 | return jsonify({'success': True, 'access_token': result.get('token')})
381 |
382 | @app.route('/api/tokens//enable', methods=['POST'])
383 | @api_auth_required
384 | def enable_token(id):
385 | token = Token.query.get_or_404(id)
386 | token.is_active = True
387 | db.session.commit()
388 | return jsonify({'success': True})
389 |
390 | @app.route('/api/tokens//disable', methods=['POST'])
391 | @api_auth_required
392 | def disable_token(id):
393 | token = Token.query.get_or_404(id)
394 | token.is_active = False
395 | db.session.commit()
396 | return jsonify({'success': True})
397 |
398 | # --- Admin Config Routes ---
399 |
400 | @app.route('/api/admin/config', methods=['GET', 'POST'])
401 | @api_auth_required
402 | def admin_config():
403 | config = SystemConfig.query.first()
404 | if request.method == 'GET':
405 | return jsonify({
406 | 'error_ban_threshold': config.error_ban_threshold,
407 | 'error_retry_count': config.error_retry_count,
408 | 'admin_username': config.admin_username,
409 | 'api_key': config.api_key,
410 | 'debug_enabled': config.debug_enabled,
411 | 'token_refresh_interval': config.token_refresh_interval,
412 | 'stream_conversion_enabled': getattr(config, 'stream_conversion_enabled', False)
413 | })
414 | else:
415 | data = request.json
416 | if 'error_ban_threshold' in data: config.error_ban_threshold = data['error_ban_threshold']
417 | if 'error_retry_count' in data: config.error_retry_count = data['error_retry_count']
418 | if 'stream_conversion_enabled' in data: config.stream_conversion_enabled = bool(data['stream_conversion_enabled'])
419 |
420 | db.session.commit()
421 | return jsonify({'success': True})
422 |
423 | @app.route('/api/admin/apikey', methods=['POST'])
424 | @api_auth_required
425 | def update_apikey():
426 | data = request.json
427 | new_key = data.get('new_api_key')
428 | if not new_key:
429 | return jsonify({'success': False, 'detail': 'API Key 不能为空'}), 400
430 |
431 | config = SystemConfig.query.first()
432 | config.api_key = new_key
433 | db.session.commit()
434 | return jsonify({'success': True})
435 |
436 | @app.route('/api/admin/password', methods=['POST'])
437 | @api_auth_required
438 | def update_password():
439 | data = request.json
440 | username = data.get('username')
441 | old_password = data.get('old_password')
442 | new_password = data.get('new_password')
443 |
444 | config = SystemConfig.query.first()
445 |
446 | # Verify old password
447 | if config.admin_username != username or not check_password_hash(config.admin_password_hash, old_password):
448 | return jsonify({'success': False, 'detail': '旧密码错误'}), 400
449 |
450 | config.admin_password_hash = generate_password_hash(new_password)
451 | if username:
452 | config.admin_username = username
453 | db.session.commit()
454 | return jsonify({'success': True})
455 |
456 | @app.route('/api/admin/debug', methods=['POST'])
457 | @api_auth_required
458 | def admin_debug():
459 | data = request.json
460 | config = SystemConfig.query.first()
461 | if 'enabled' in data: config.debug_enabled = data.get('enabled')
462 | if 'token_refresh_interval' in data:
463 | config.token_refresh_interval = data.get('token_refresh_interval')
464 | try:
465 | scheduler.reschedule_job('token_refresher', trigger='interval', seconds=config.token_refresh_interval)
466 | except Exception as e:
467 | logger.error(f"Failed to reschedule job: {e}")
468 |
469 | db.session.commit()
470 | return jsonify({'success': True})
471 |
472 | @app.route('/api/proxy/config', methods=['GET', 'POST'])
473 | @api_auth_required
474 | def proxy_config():
475 | config = SystemConfig.query.first()
476 | if request.method == 'GET':
477 | return jsonify({
478 | 'proxy_enabled': config.proxy_enabled,
479 | 'proxy_url': config.proxy_url
480 | })
481 | else:
482 | data = request.json
483 | if 'proxy_enabled' in data: config.proxy_enabled = data['proxy_enabled']
484 | if 'proxy_url' in data: config.proxy_url = data['proxy_url']
485 | db.session.commit()
486 | return jsonify({'success': True})
487 |
488 | @app.route('/update_token_info', methods=['POST'])
489 | def update_token_info():
490 | """更新 Zai Token 信息(通过 OAuth 登录)"""
491 | try:
492 | # 使用新的 OAuth 登录函数
493 | result = services.create_or_update_token_from_oauth()
494 |
495 | if result.get('success'):
496 | return jsonify({
497 | 'success': True,
498 | 'message': f"Token 更新成功!类型: {result.get('source', 'unknown')}",
499 | 'email': result.get('email'),
500 | 'expires': result.get('expires')
501 | })
502 | else:
503 | return jsonify({'error': result.get('error', '未知错误')}), 400
504 | except Exception as e:
505 | import traceback
506 | traceback.print_exc()
507 | return jsonify({'error': str(e)}), 500
508 |
509 | @app.route('/api/logs', methods=['GET'])
510 | @api_auth_required
511 | def get_logs():
512 | limit = request.args.get('limit', 100, type=int)
513 | logs = RequestLog.query.order_by(RequestLog.created_at.desc()).limit(limit).all()
514 | return jsonify([{
515 | 'operation': l.operation,
516 | 'token_email': l.token_email,
517 | 'discord_token': getattr(l, 'discord_token', None),
518 | 'zai_token': getattr(l, 'zai_token', None),
519 | 'status_code': l.status_code,
520 | 'duration': l.duration,
521 | 'created_at': l.created_at.isoformat()
522 | } for l in logs])
523 |
524 | @app.route('/api/cache/config', methods=['GET', 'POST'])
525 | @api_auth_required
526 | def cache_config():
527 | config = SystemConfig.query.first()
528 | if request.method == 'GET':
529 | return jsonify({'success': True, 'config': {
530 | 'enabled': config.cache_enabled,
531 | 'timeout': config.cache_timeout,
532 | 'base_url': config.cache_base_url,
533 | 'effective_base_url': config.cache_base_url or request.host_url
534 | }})
535 | else:
536 | # The frontend calls separate endpoints for enabled/timeout/base-url
537 | data = request.json
538 | if 'timeout' in data: config.cache_timeout = data['timeout']
539 | db.session.commit()
540 | return jsonify({'success': True})
541 |
542 | @app.route('/api/cache/enabled', methods=['POST'])
543 | @api_auth_required
544 | def cache_enabled():
545 | data = request.json
546 | config = SystemConfig.query.first()
547 | config.cache_enabled = data.get('enabled')
548 | db.session.commit()
549 | return jsonify({'success': True})
550 |
551 | @app.route('/api/cache/base-url', methods=['POST'])
552 | @api_auth_required
553 | def cache_base_url():
554 | data = request.json
555 | config = SystemConfig.query.first()
556 | config.cache_base_url = data.get('base_url')
557 | db.session.commit()
558 | return jsonify({'success': True})
559 |
560 | @app.route('/api/generation/timeout', methods=['GET', 'POST'])
561 | @api_auth_required
562 | def generation_timeout():
563 | config = SystemConfig.query.first()
564 | if request.method == 'GET':
565 | return jsonify({'success': True, 'config': {
566 | 'image_timeout': config.image_timeout,
567 | 'video_timeout': config.video_timeout
568 | }})
569 | else:
570 | data = request.json
571 | config.image_timeout = data.get('image_timeout')
572 | config.video_timeout = data.get('video_timeout')
573 | db.session.commit()
574 | return jsonify({'success': True})
575 |
576 | @app.route('/api/token-refresh/config', methods=['GET'])
577 | @api_auth_required
578 | def token_refresh_config():
579 | config = SystemConfig.query.first()
580 | return jsonify({'success': True, 'config': {
581 | 'at_auto_refresh_enabled': config.at_auto_refresh_enabled
582 | }})
583 |
584 | @app.route('/api/token-refresh/enabled', methods=['POST'])
585 | @api_auth_required
586 | def token_refresh_enabled():
587 | data = request.json
588 | config = SystemConfig.query.first()
589 | config.at_auto_refresh_enabled = data.get('enabled')
590 | db.session.commit()
591 | return jsonify({'success': True})
592 |
593 | @app.route('/api/tokens/import', methods=['POST'])
594 | @api_auth_required
595 | def import_tokens():
596 | data = request.json
597 | tokens_data = data.get('tokens', [])
598 | added = 0
599 | updated = 0
600 |
601 | for t_data in tokens_data:
602 | st = t_data.get('session_token')
603 | if not st: continue
604 |
605 | token = Token.query.filter_by(discord_token=st).first()
606 | if token:
607 | # Update
608 | token.email = t_data.get('email', token.email)
609 | token.zai_token = t_data.get('access_token', token.zai_token)
610 | token.is_active = t_data.get('is_active', token.is_active)
611 | token.image_enabled = t_data.get('image_enabled', True)
612 | token.video_enabled = t_data.get('video_enabled', True)
613 | updated += 1
614 | else:
615 | # Add
616 | token = Token(
617 | discord_token=st,
618 | email=t_data.get('email'),
619 | zai_token=t_data.get('access_token'),
620 | is_active=t_data.get('is_active', True),
621 | image_enabled=t_data.get('image_enabled', True),
622 | video_enabled=t_data.get('video_enabled', True),
623 | image_concurrency=t_data.get('image_concurrency', -1),
624 | video_concurrency=t_data.get('video_concurrency', -1)
625 | )
626 | db.session.add(token)
627 | added += 1
628 |
629 | db.session.commit()
630 | return jsonify({'success': True, 'added': added, 'updated': updated})
631 |
632 | @app.route('/api/tokens//test', methods=['POST'])
633 | @api_auth_required
634 | def test_token(id):
635 | # Test by refreshing
636 | success, msg = services.update_token_info(id)
637 | token = db.session.get(Token, id)
638 | if success:
639 | return jsonify({
640 | 'success': True,
641 | 'status': 'success',
642 | 'email': token.email,
643 | 'sora2_supported': token.sora2_supported,
644 | 'sora2_total_count': token.sora2_total_count,
645 | 'sora2_redeemed_count': token.sora2_redeemed_count,
646 | 'sora2_remaining_count': token.sora2_remaining_count
647 | })
648 | return jsonify({'success': False, 'message': msg})
649 |
650 | @app.route('/api/tokens//sora2/activate', methods=['POST'])
651 | @api_auth_required
652 | def activate_sora2(id):
653 | # Not supported by zai_token.py yet
654 | return jsonify({'success': False, 'message': 'Not implemented in backend'})
655 |
656 | # --- OpenAI Compatible Proxy ---
657 |
658 | _rr_lock = Lock()
659 | _rr_index = 0
660 |
661 | def _get_token_candidates():
662 | """多号轮询:每个请求从上一次的下一个 token 开始顺序尝试。"""
663 | global _rr_index
664 | tokens = Token.query.filter_by(is_active=True).order_by(Token.id.asc()).all()
665 | valid_tokens = [t for t in tokens if t.zai_token and not str(t.zai_token).startswith('SESSION')]
666 | if not valid_tokens:
667 | return []
668 | with _rr_lock:
669 | start = _rr_index % len(valid_tokens)
670 | _rr_index = (start + 1) % len(valid_tokens)
671 | return valid_tokens[start:] + valid_tokens[:start]
672 |
673 | def _mark_token_error(token: Token, config: SystemConfig, reason: str):
674 | token.error_count = int(token.error_count or 0) + 1
675 | token.remark = (reason or '')[:1000]
676 | threshold = int(getattr(config, 'error_ban_threshold', 3) or 3)
677 | if token.error_count >= threshold:
678 | token.is_active = False
679 | token.remark = f"Auto-banned due to errors: {(reason or '')[:950]}"
680 | db.session.commit()
681 |
682 | def _mark_token_success(token: Token):
683 | if token.error_count:
684 | token.error_count = 0
685 | db.session.commit()
686 |
687 | def _filter_stream_headers(hdrs):
688 | out = {}
689 | for k in ('Content-Type', 'Cache-Control'):
690 | if k in hdrs:
691 | out[k] = hdrs[k]
692 | out.setdefault('Content-Type', 'text/event-stream')
693 | return out
694 |
695 | def _aggregate_sse_to_nonstream(resp, fallback_model: str | None = None):
696 | first_chunk = None
697 | usage = None
698 | role_by_index: dict[int, str] = {}
699 | content_by_index: dict[int, list[str]] = {}
700 | finish_by_index: dict[int, str] = {}
701 |
702 | for line in resp.iter_lines(decode_unicode=True):
703 | if not line:
704 | continue
705 | if not line.startswith('data:'):
706 | continue
707 | data = line[5:].strip()
708 | if not data:
709 | continue
710 | if data == '[DONE]':
711 | break
712 | try:
713 | chunk = json.loads(data)
714 | except Exception:
715 | continue
716 | if first_chunk is None:
717 | first_chunk = chunk
718 | if isinstance(chunk, dict) and chunk.get('usage'):
719 | usage = chunk.get('usage')
720 | for choice in (chunk.get('choices') or []):
721 | idx = int(choice.get('index', 0))
722 | delta = choice.get('delta') or {}
723 | if delta.get('role'):
724 | role_by_index[idx] = delta['role']
725 | if 'content' in delta and delta['content'] is not None:
726 | content_by_index.setdefault(idx, []).append(delta['content'])
727 | if choice.get('finish_reason') is not None:
728 | finish_by_index[idx] = choice.get('finish_reason')
729 |
730 | created = (first_chunk or {}).get('created') or int(time.time())
731 | model = (first_chunk or {}).get('model') or fallback_model or 'unknown'
732 | rid = (first_chunk or {}).get('id') or f"chatcmpl-{int(time.time()*1000)}"
733 |
734 | indexes = sorted(content_by_index.keys()) if content_by_index else [0]
735 | choices_out = []
736 | for idx in indexes:
737 | choices_out.append({
738 | 'index': idx,
739 | 'message': {
740 | 'role': role_by_index.get(idx, 'assistant'),
741 | 'content': ''.join(content_by_index.get(idx, []))
742 | },
743 | 'finish_reason': finish_by_index.get(idx, 'stop')
744 | })
745 |
746 | out = {
747 | 'id': rid,
748 | 'object': 'chat.completion',
749 | 'created': created,
750 | 'model': model,
751 | 'choices': choices_out
752 | }
753 | if usage is not None:
754 | out['usage'] = usage
755 | return out
756 |
757 | @app.route('/v1/chat/completions', methods=['POST'])
758 | def proxy_chat_completions():
759 | start_time = time.time()
760 |
761 | # Verify API Key
762 | config = SystemConfig.query.first()
763 | auth_header = request.headers.get('Authorization')
764 | if not auth_header or not auth_header.startswith('Bearer ') or auth_header.split(' ')[1] != config.api_key:
765 | return jsonify({'error': 'Invalid API Key'}), 401
766 |
767 | payload = request.get_json(silent=True)
768 | if not isinstance(payload, dict):
769 | return jsonify({'error': 'Invalid JSON body'}), 400
770 |
771 | client_stream = bool(payload.get('stream'))
772 | stream_conversion_enabled = bool(getattr(config, 'stream_conversion_enabled', False))
773 | should_convert = (not client_stream) and stream_conversion_enabled
774 | zai_stream = client_stream or should_convert
775 |
776 | candidates = _get_token_candidates()
777 | if not candidates:
778 | return jsonify({'error': 'No active tokens available'}), 503
779 |
780 | max_attempts = max(1, int(getattr(config, 'error_retry_count', 1) or 1))
781 | attempts = 0
782 | last_response = None
783 |
784 | for token in candidates:
785 | if attempts >= max_attempts:
786 | break
787 | attempts += 1
788 |
789 | zai_url = "https://zai.is/api/v1/chat/completions"
790 | headers = {
791 | "Authorization": f"Bearer {token.zai_token}",
792 | "Content-Type": "application/json"
793 | }
794 |
795 | zai_payload = dict(payload)
796 | if zai_stream:
797 | zai_payload['stream'] = True
798 |
799 | try:
800 | resp = requests.post(zai_url, json=zai_payload, headers=headers, stream=zai_stream, timeout=600)
801 | except Exception as e:
802 | _mark_token_error(token, config, f"Request error: {e}")
803 | last_response = jsonify({'error': str(e)})
804 | last_response.status_code = 502
805 | continue
806 |
807 | # Log request (UI 展示用,写入脱敏 token)
808 | duration = time.time() - start_time
809 | log = RequestLog(
810 | operation="chat/completions",
811 | token_email=token.email,
812 | discord_token=_mask_token(token.discord_token),
813 | zai_token=_mask_token(token.zai_token),
814 | status_code=resp.status_code,
815 | duration=duration
816 | )
817 | db.session.add(log)
818 | db.session.commit()
819 |
820 | if resp.status_code >= 400:
821 | try:
822 | detail = resp.text
823 | except Exception:
824 | detail = ''
825 | # 429 (Too Many Requests) 是速率限制,不计入错误,只尝试下一个token
826 | if resp.status_code != 429:
827 | _mark_token_error(token, config, f"HTTP {resp.status_code}: {detail[:200]}")
828 | else:
829 | logger.info(f"Token {token.id} hit rate limit (429), trying next token")
830 | last_response = Response(resp.content, status=resp.status_code, mimetype=resp.headers.get('Content-Type', 'application/json'))
831 | continue
832 |
833 | _mark_token_success(token)
834 |
835 | if client_stream:
836 | def generate():
837 | for chunk in resp.iter_content(chunk_size=1024):
838 | if chunk:
839 | yield chunk
840 | return Response(stream_with_context(generate()), status=resp.status_code, headers=_filter_stream_headers(resp.headers))
841 |
842 | if should_convert:
843 | aggregated = _aggregate_sse_to_nonstream(resp, fallback_model=payload.get('model'))
844 | return jsonify(aggregated)
845 |
846 | return Response(resp.content, status=resp.status_code, mimetype=resp.headers.get('Content-Type', 'application/json'))
847 |
848 | if last_response is not None:
849 | return last_response
850 | return jsonify({'error': 'No active tokens available'}), 503
851 |
852 | @app.route('/v1/models', methods=['GET'])
853 | def proxy_models():
854 | # Proxy or return static
855 | # Verify API Key
856 | config = SystemConfig.query.first()
857 | auth_header = request.headers.get('Authorization')
858 | if not auth_header or not auth_header.startswith('Bearer ') or auth_header.split(' ')[1] != config.api_key:
859 | return jsonify({'error': 'Invalid API Key'}), 401
860 |
861 | start_time = time.time()
862 |
863 | candidates = _get_token_candidates()
864 | if not candidates: # If no token, maybe we can't fetch models? Or just return default list.
865 | # Fallback list
866 | return jsonify({
867 | "object": "list",
868 | "data": [
869 | {"id": "gpt-4", "object": "model", "created": 1687882411, "owned_by": "openai"},
870 | {"id": "gpt-3.5-turbo", "object": "model", "created": 1677610602, "owned_by": "openai"}
871 | ]
872 | })
873 |
874 | max_attempts = max(1, int(getattr(config, 'error_retry_count', 1) or 1))
875 | attempts = 0
876 | last_response = None
877 |
878 | for token in candidates:
879 | if attempts >= max_attempts:
880 | break
881 | attempts += 1
882 |
883 | zai_url = "https://zai.is/api/v1/models"
884 | headers = {"Authorization": f"Bearer {token.zai_token}"}
885 |
886 | try:
887 | resp = requests.get(zai_url, headers=headers, timeout=60)
888 | except Exception as e:
889 | _mark_token_error(token, config, f"Request error: {e}")
890 | last_response = jsonify({"error": "Failed to fetch models", "detail": str(e)})
891 | last_response.status_code = 502
892 | continue
893 |
894 | duration = time.time() - start_time
895 | log = RequestLog(
896 | operation="models",
897 | token_email=token.email,
898 | discord_token=_mask_token(token.discord_token),
899 | zai_token=_mask_token(token.zai_token),
900 | status_code=resp.status_code,
901 | duration=duration
902 | )
903 | db.session.add(log)
904 | db.session.commit()
905 |
906 | if resp.status_code >= 400:
907 | try:
908 | detail = resp.text
909 | except Exception:
910 | detail = ''
911 | # 429 (Too Many Requests) 是速率限制,不计入错误,只尝试下一个token
912 | if resp.status_code != 429:
913 | _mark_token_error(token, config, f"HTTP {resp.status_code}: {detail[:200]}")
914 | else:
915 | logger.info(f"Token {token.id} hit rate limit (429), trying next token")
916 | last_response = Response(resp.content, status=resp.status_code, mimetype=resp.headers.get('Content-Type', 'application/json'))
917 | continue
918 |
919 | _mark_token_success(token)
920 | return Response(resp.content, status=resp.status_code, mimetype='application/json')
921 |
922 | if last_response is not None:
923 | return last_response
924 | return jsonify({"error": "Failed to fetch models"}), 500
925 |
926 | if __name__ == '__main__':
927 | init_db()
928 | app.run(host='0.0.0.0', port=5000, debug=True, use_reloader=False) # use_reloader=False for scheduler
929 |
--------------------------------------------------------------------------------