├── .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 | ![获取discord token](png/获取doscordtoken.png) 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 | [![Star History Chart](https://api.star-history.com/svg?repos=Futureppo/zai.is2api&type=date&legend=top-left)](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 |
23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 | 34 |
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 |
27 | GitHub 28 | 29 |
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 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
discordtoken状态zaitoken剩余刷新时间错误刷新状态 (悬停查看完整内容)操作
88 |
89 |
90 |
91 | 92 | 93 | 167 | 168 | 169 | 192 |
193 | 194 | 195 | 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 | --------------------------------------------------------------------------------