|
|
@@ -11,10 +11,20 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
from chatgpt_flow import FullRunContext, run_full
|
|
|
from config import AppConfig
|
|
|
from cpa_uploader import build_cpa_auth_payload
|
|
|
-from storage import get_account, init_db, list_accounts, list_events
|
|
|
-
|
|
|
-
|
|
|
-HOST = "127.0.0.1"
|
|
|
+from recheck import recheck_account
|
|
|
+from storage import (
|
|
|
+ create_task,
|
|
|
+ get_account,
|
|
|
+ get_task,
|
|
|
+ init_db,
|
|
|
+ list_accounts,
|
|
|
+ list_events,
|
|
|
+ list_tasks,
|
|
|
+)
|
|
|
+from task_runner import get_runner, make_task_id
|
|
|
+
|
|
|
+
|
|
|
+HOST = "127.0.0.1" # main() 会读 cfg.api_host 覆盖
|
|
|
PORT = 7791
|
|
|
|
|
|
|
|
|
@@ -120,9 +130,23 @@ INDEX_HTML = r"""<!doctype html>
|
|
|
.chip.gray { background:#eee; color:#444; }
|
|
|
.chip.blue { background:#e7f0ff; color:#1d4ed8; }
|
|
|
pre.log { height:340px; overflow:auto; background:#0b0b10; color:#d6d6dc; padding:12px; border-radius:12px; font-size:12px; line-height:1.5; white-space:pre-wrap; word-break:break-all; }
|
|
|
- table { width:100%; border-collapse: collapse; font-size:13px; }
|
|
|
- th, td { padding:8px 10px; border-bottom:1px solid #eee; text-align:left; vertical-align:top; }
|
|
|
+ table { width:100%; border-collapse: collapse; font-size:13px; table-layout: fixed; }
|
|
|
+ th, td { padding:8px 10px; border-bottom:1px solid #eee; text-align:left; vertical-align:top; word-break: break-word; }
|
|
|
th { background:#fafafa; font-weight:600; color:#333; }
|
|
|
+ /* 已注册账号库表 */
|
|
|
+ #dbTable th.col-email, #dbTable td.col-email { width: 24%; }
|
|
|
+ #dbTable th.col-plan, #dbTable td.col-plan { width: 60px; }
|
|
|
+ #dbTable th.col-stat, #dbTable td.col-stat { width: 110px; }
|
|
|
+ #dbTable th.col-cpa, #dbTable td.col-cpa { width: 22%; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
|
|
+ #dbTable th.col-time, #dbTable td.col-time { width: 130px; white-space: nowrap; }
|
|
|
+ #dbTable th.col-err, #dbTable td.col-err { width: 14%; color:#a40000; }
|
|
|
+ #dbTable th.col-act, #dbTable td.col-act { width: 150px; white-space: nowrap; text-align: right; }
|
|
|
+ #dbTable td.col-act .action-btn { display:inline-block; padding:4px 10px; border-radius:8px; font-weight:600; font-size:12px; text-decoration:none; white-space:nowrap; margin-left:6px; cursor:pointer; border:0; }
|
|
|
+ #dbTable td.col-act .action-btn.detail { background:#e9e9ec; color:#111; }
|
|
|
+ #dbTable td.col-act .action-btn.download { background:#e6f7ec; color:#0f5f22; }
|
|
|
+ #dbTable td.col-act .action-btn.recheck { background:#fff4d6; color:#7a4f00; }
|
|
|
+ #dbTable td.col-act .action-btn:disabled { opacity:.6; cursor:not-allowed; }
|
|
|
+ #dbTable td.col-email code { font-size: 12px; word-break: break-all; }
|
|
|
.stage-box { padding:10px 14px; border-radius:12px; background:#fffaf0; border:1px solid #ffe2a8; color:#7a4f00; font-size:13px; min-height: 22px; }
|
|
|
</style>
|
|
|
</head>
|
|
|
@@ -202,6 +226,41 @@ INDEX_HTML = r"""<!doctype html>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ <h2 style="margin-top:18px">外网 API 接入</h2>
|
|
|
+ <div class="grid-3">
|
|
|
+ <div>
|
|
|
+ <label>API 监听 host</label>
|
|
|
+ <input id="cfg_api_host" placeholder="127.0.0.1 或 0.0.0.0" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label>API 端口</label>
|
|
|
+ <input id="cfg_api_port" type="number" min="1" max="65535" placeholder="7791" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label>API Token(外网必填)</label>
|
|
|
+ <input id="cfg_api_token" placeholder="留空 = 不校验" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label>CORS Allow-Origin</label>
|
|
|
+ <input id="cfg_api_cors_origin" placeholder="* 或 https://your-frontend.com" />
|
|
|
+ </div>
|
|
|
+ <p class="muted" style="margin-top:6px">
|
|
|
+ 在线 API 文档:<a href="/docs" target="_blank" rel="noopener">/docs</a> ·
|
|
|
+ OpenAPI 规范:<a href="/openapi.json" target="_blank" rel="noopener">/openapi.json</a>
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div class="grid">
|
|
|
+ <div>
|
|
|
+ <label>全局代理(ChatGPT 注册 / 长链 / 默认浏览器;留空则直连)</label>
|
|
|
+ <input id="cfg_proxy_url" placeholder="http://user:pass@host:port (留空 = ChatGPT 注册直连)" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label>PayPal 独立代理(仅 PayPal 阶段;留空则继承全局)</label>
|
|
|
+ <input id="cfg_paypal_only_proxy" placeholder="http://user:pass@host:port" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
<div class="row">
|
|
|
<button id="save">保存配置</button>
|
|
|
<button id="go">开始全自动</button>
|
|
|
@@ -241,7 +300,16 @@ INDEX_HTML = r"""<!doctype html>
|
|
|
<span class="muted">数据库:<code>data/accounts.db</code></span>
|
|
|
</div>
|
|
|
<table id="dbTable">
|
|
|
- <thead><tr><th>邮箱</th><th>plan</th><th>状态</th><th>CPA 文件</th><th>注册时间</th><th>更新时间</th><th>错误</th><th>动作</th></tr></thead>
|
|
|
+ <thead><tr>
|
|
|
+ <th class="col-email">邮箱</th>
|
|
|
+ <th class="col-plan">plan</th>
|
|
|
+ <th class="col-stat">状态</th>
|
|
|
+ <th class="col-cpa">CPA 文件</th>
|
|
|
+ <th class="col-time">注册时间</th>
|
|
|
+ <th class="col-time">更新时间</th>
|
|
|
+ <th class="col-err">错误</th>
|
|
|
+ <th class="col-act">动作</th>
|
|
|
+ </tr></thead>
|
|
|
<tbody></tbody>
|
|
|
</table>
|
|
|
<details style="margin-top:10px">
|
|
|
@@ -271,6 +339,12 @@ const FIELDS = [
|
|
|
['cfg_sms_api_url','sms_api_url','str'],
|
|
|
['cfg_cpa_url','cpa_url','str'],
|
|
|
['cfg_cpa_management_key','cpa_management_key','str'],
|
|
|
+ ['cfg_proxy_url','proxy_url','str'],
|
|
|
+ ['cfg_paypal_only_proxy','paypal_only_proxy','str'],
|
|
|
+ ['cfg_api_host','api_host','str'],
|
|
|
+ ['cfg_api_port','api_port','int'],
|
|
|
+ ['cfg_api_token','api_token','str'],
|
|
|
+ ['cfg_api_cors_origin','api_cors_origin','str'],
|
|
|
];
|
|
|
|
|
|
function fillForm(cfg) {
|
|
|
@@ -371,7 +445,8 @@ function fmtTime(ms) {
|
|
|
const d = new Date(Number(ms));
|
|
|
if (isNaN(d.getTime())) return '';
|
|
|
const pad = n => String(n).padStart(2,'0');
|
|
|
- return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
+ // 紧凑成两行:MM-DD\n HH:MM:SS(避免一行被挤断)
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
}
|
|
|
|
|
|
async function loadAccounts() {
|
|
|
@@ -384,16 +459,27 @@ async function loadAccounts() {
|
|
|
tbody.innerHTML = '';
|
|
|
(data.accounts || []).forEach(a => {
|
|
|
const tr = document.createElement('tr');
|
|
|
- const detailBtn = `<button class="secondary" data-email="${a.email}" data-action="detail" style="padding:4px 10px">详情</button>`;
|
|
|
+ const detailBtn = `<button class="action-btn detail" data-email="${a.email}" data-action="detail">详情</button>`;
|
|
|
const dlBtn = a.cpa_file_name
|
|
|
- ? ` <a href="/api/account/${encodeURIComponent(a.email)}/cpa.json" target="_blank" rel="noopener" style="padding:4px 10px;border-radius:8px;background:#e6f7ec;color:#0f5f22;text-decoration:none;font-weight:600;font-size:12px">下载 CPA</a>`
|
|
|
+ ? `<a class="action-btn download" href="/api/account/${encodeURIComponent(a.email)}/cpa.json" target="_blank" rel="noopener" download>下载 CPA</a>`
|
|
|
+ : '';
|
|
|
+ // 失败类状态可重新校验:plus_check_failed / cpa_failed / failed / cpa_skipped
|
|
|
+ const failedStatuses = ['plus_check_failed','cpa_failed','failed','cpa_skipped','registered','paid'];
|
|
|
+ const recheckBtn = failedStatuses.includes(a.final_status||'')
|
|
|
+ ? `<button class="action-btn recheck" data-email="${a.email}" data-action="recheck">重新校验</button>`
|
|
|
: '';
|
|
|
- tr.innerHTML = `<td><code>${a.email||''}</code></td><td>${a.plan_type||''}</td><td>${a.final_status||''}</td><td>${a.cpa_file_name||''}</td><td>${fmtTime(a.created_at)}</td><td>${fmtTime(a.updated_at)}</td><td style="color:#a40000">${a.last_error||''}</td><td>${detailBtn}${dlBtn}</td>`;
|
|
|
+ const emailHtml = `<code title="${a.email||''}">${a.email||''}</code>`;
|
|
|
+ const cpaHtml = a.cpa_file_name ? `<span title="${a.cpa_file_name}">${a.cpa_file_name}</span>` : '';
|
|
|
+ const errHtml = a.last_error ? `<span title="${(a.last_error||'').replace(/"/g,'"')}">${a.last_error}</span>` : '';
|
|
|
+ tr.innerHTML = `<td class="col-email">${emailHtml}</td><td class="col-plan">${a.plan_type||''}</td><td class="col-stat">${a.final_status||''}</td><td class="col-cpa">${cpaHtml}</td><td class="col-time">${fmtTime(a.created_at)}</td><td class="col-time">${fmtTime(a.updated_at)}</td><td class="col-err">${errHtml}</td><td class="col-act">${detailBtn}${recheckBtn}${dlBtn}</td>`;
|
|
|
tbody.appendChild(tr);
|
|
|
});
|
|
|
tbody.querySelectorAll('button[data-action="detail"]').forEach(btn => {
|
|
|
btn.addEventListener('click', () => loadAccountDetail(btn.dataset.email));
|
|
|
});
|
|
|
+ tbody.querySelectorAll('button[data-action="recheck"]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => triggerRecheck(btn));
|
|
|
+ });
|
|
|
} catch (e) {
|
|
|
console.error(e);
|
|
|
}
|
|
|
@@ -409,6 +495,35 @@ async function loadAccountDetail(email) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function triggerRecheck(btn) {
|
|
|
+ const email = btn.dataset.email;
|
|
|
+ if (!email) return;
|
|
|
+ const orig = btn.textContent;
|
|
|
+ btn.disabled = true;
|
|
|
+ btn.textContent = '校验中...';
|
|
|
+ try {
|
|
|
+ const r = await fetch('/api/account/' + encodeURIComponent(email) + '/recheck', {method:'POST'});
|
|
|
+ const data = await r.json();
|
|
|
+ if (data.ok) {
|
|
|
+ const action = data.action || '';
|
|
|
+ const tip = action === 'cpa_uploaded' ? '已上传 CPA'
|
|
|
+ : action === 'plus_no_cpa_config' ? '已 Plus(未配置 CPA)'
|
|
|
+ : '已 Plus';
|
|
|
+ btn.textContent = '✓ ' + tip;
|
|
|
+ } else if (data.action === 'still_not_plus') {
|
|
|
+ btn.textContent = `仍非 plus(${data.planType||'?'})`;
|
|
|
+ } else {
|
|
|
+ btn.textContent = '✗ 失败';
|
|
|
+ console.warn('recheck error', data);
|
|
|
+ }
|
|
|
+ setTimeout(() => loadAccounts(), 1200);
|
|
|
+ } catch (e) {
|
|
|
+ alert(e.message || String(e));
|
|
|
+ btn.disabled = false;
|
|
|
+ btn.textContent = orig;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
$('refreshAccounts').addEventListener('click', loadAccounts);
|
|
|
$('accFilter').addEventListener('change', loadAccounts);
|
|
|
|
|
|
@@ -472,6 +587,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
if path in ("/", "/index.html"):
|
|
|
self._send(200, INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8")
|
|
|
return
|
|
|
+ if path in ("/docs", "/docs/"):
|
|
|
+ self._send(200, SWAGGER_HTML.encode("utf-8"), "text/html; charset=utf-8")
|
|
|
+ return
|
|
|
+ if path == "/openapi.json":
|
|
|
+ self._send_json(200, _build_openapi_spec())
|
|
|
+ return
|
|
|
+ if not self._check_auth():
|
|
|
+ return
|
|
|
if path == "/api/status":
|
|
|
self._send_json(200, JOB.status())
|
|
|
return
|
|
|
@@ -481,6 +604,27 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
if path == "/api/log":
|
|
|
self._stream_log()
|
|
|
return
|
|
|
+ # ===== 任务化 API(GET)=====
|
|
|
+ if path == "/api/tasks":
|
|
|
+ from urllib.parse import parse_qs
|
|
|
+ q = parse_qs(query)
|
|
|
+ status = (q.get("status") or [""])[0] or None
|
|
|
+ limit = int((q.get("limit") or ["100"])[0])
|
|
|
+ try:
|
|
|
+ tasks = list_tasks(limit=limit, status=status)
|
|
|
+ self._send_json(200, {"tasks": tasks})
|
|
|
+ except Exception as exc:
|
|
|
+ self._send_json(500, {"error": str(exc)})
|
|
|
+ return
|
|
|
+ if path.startswith("/api/tasks/"):
|
|
|
+ from urllib.parse import unquote
|
|
|
+ task_id = unquote(path[len("/api/tasks/"):])
|
|
|
+ t = get_task(task_id)
|
|
|
+ if not t:
|
|
|
+ self._send_json(404, {"error": "task not found"})
|
|
|
+ return
|
|
|
+ self._send_json(200, {"task": t})
|
|
|
+ return
|
|
|
if path == "/api/accounts":
|
|
|
try:
|
|
|
from urllib.parse import parse_qs
|
|
|
@@ -540,6 +684,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
|
def do_POST(self):
|
|
|
path = self.path.split("?", 1)[0]
|
|
|
+ if not self._check_auth():
|
|
|
+ return
|
|
|
if path == "/api/config":
|
|
|
try:
|
|
|
body = _read_json(self)
|
|
|
@@ -549,6 +695,43 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
self._send_json(500, {"error": str(exc)})
|
|
|
return
|
|
|
|
|
|
+ # ===== 任务化 API =====
|
|
|
+ if path == "/api/tasks":
|
|
|
+ try:
|
|
|
+ body = _read_json(self) or {}
|
|
|
+ mode = (body.get("mode") or "full").strip().lower()
|
|
|
+ if mode not in ("full", "pay_only"):
|
|
|
+ self._send_json(400, {"error": "mode 必须是 full 或 pay_only"})
|
|
|
+ return
|
|
|
+ params = body.get("params") or {}
|
|
|
+ if mode == "pay_only":
|
|
|
+ sess = params.get("session")
|
|
|
+ if not isinstance(sess, dict) or not sess.get("accessToken"):
|
|
|
+ self._send_json(400, {"error": "pay_only 需要 params.session 是 JSON 且包含 accessToken"})
|
|
|
+ return
|
|
|
+ max_attempts = int(body.get("max_attempts") or 3)
|
|
|
+ max_attempts = max(1, min(10, max_attempts))
|
|
|
+ task_id = make_task_id()
|
|
|
+ t = create_task(task_id, mode, params, max_attempts=max_attempts)
|
|
|
+ # 启动 runner(幂等)
|
|
|
+ get_runner(log=lambda m: JOB._log(m))
|
|
|
+ self._send_json(200, {"task_id": task_id, "task": t})
|
|
|
+ except Exception as exc:
|
|
|
+ self._send_json(500, {"error": str(exc)})
|
|
|
+ return
|
|
|
+
|
|
|
+ if path.startswith("/api/tasks/") and path.endswith("/cancel"):
|
|
|
+ from urllib.parse import unquote
|
|
|
+ task_id = unquote(path[len("/api/tasks/"):-len("/cancel")])
|
|
|
+ t = get_task(task_id)
|
|
|
+ if not t:
|
|
|
+ self._send_json(404, {"error": "task not found"})
|
|
|
+ return
|
|
|
+ runner = get_runner(log=lambda m: JOB._log(m))
|
|
|
+ runner.cancel(task_id)
|
|
|
+ self._send_json(200, {"ok": True, "task_id": task_id})
|
|
|
+ return
|
|
|
+
|
|
|
if path == "/api/start":
|
|
|
try:
|
|
|
cfg = AppConfig.load()
|
|
|
@@ -561,6 +744,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
self._send_json(500, {"error": str(exc)})
|
|
|
return
|
|
|
|
|
|
+ if path.startswith("/api/account/") and path.endswith("/recheck"):
|
|
|
+ from urllib.parse import unquote
|
|
|
+ email = unquote(path[len("/api/account/"):-len("/recheck")])
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ try:
|
|
|
+ result = recheck_account(
|
|
|
+ email,
|
|
|
+ cpa_url=cfg.cpa_url,
|
|
|
+ cpa_management_key=cfg.cpa_management_key,
|
|
|
+ log=lambda msg: JOB._log(f"[acc:{email[:24]}] {msg}"),
|
|
|
+ )
|
|
|
+ self._send_json(200, result)
|
|
|
+ except Exception as exc:
|
|
|
+ self._send_json(500, {"error": str(exc)})
|
|
|
+ return
|
|
|
+
|
|
|
if path == "/api/stop":
|
|
|
JOB.stop()
|
|
|
self._send_json(200, {"ok": True})
|
|
|
@@ -601,9 +800,58 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
self.send_header("Content-Type", content_type)
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
self.send_header("Content-Length", str(len(content)))
|
|
|
+ # CORS(仅 /api/* 需要时由调用方决定,但统一发也无害)
|
|
|
+ try:
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ origin = (cfg.api_cors_origin or "*").strip()
|
|
|
+ self.send_header("Access-Control-Allow-Origin", origin)
|
|
|
+ self.send_header("Access-Control-Allow-Credentials", "true")
|
|
|
+ except Exception:
|
|
|
+ self.send_header("Access-Control-Allow-Origin", "*")
|
|
|
self.end_headers()
|
|
|
self.wfile.write(content)
|
|
|
|
|
|
+ def do_OPTIONS(self):
|
|
|
+ # CORS preflight
|
|
|
+ self.send_response(204)
|
|
|
+ try:
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ origin = (cfg.api_cors_origin or "*").strip()
|
|
|
+ except Exception:
|
|
|
+ origin = "*"
|
|
|
+ self.send_header("Access-Control-Allow-Origin", origin)
|
|
|
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
|
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
|
+ self.send_header("Access-Control-Max-Age", "86400")
|
|
|
+ self.send_header("Access-Control-Allow-Credentials", "true")
|
|
|
+ self.end_headers()
|
|
|
+
|
|
|
+ def _check_auth(self) -> bool:
|
|
|
+ """非空 api_token 时校验 Authorization: Bearer。返回 True 表示放行。"""
|
|
|
+ try:
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ token = (cfg.api_token or "").strip()
|
|
|
+ except Exception:
|
|
|
+ token = ""
|
|
|
+ if not token:
|
|
|
+ return True
|
|
|
+ # 公开接口豁免:根页面、OpenAPI 文档、Swagger UI、static 静态
|
|
|
+ path = self.path.split("?", 1)[0]
|
|
|
+ public = ("/", "/index.html", "/docs", "/docs/", "/openapi.json", "/openapi.yaml")
|
|
|
+ if path in public:
|
|
|
+ return True
|
|
|
+ auth = self.headers.get("Authorization", "")
|
|
|
+ if auth == f"Bearer {token}":
|
|
|
+ return True
|
|
|
+ # 也支持 ?token=xxx
|
|
|
+ if "token=" in (self.path.split("?", 1)[1] if "?" in self.path else ""):
|
|
|
+ from urllib.parse import parse_qs
|
|
|
+ q = parse_qs(self.path.split("?", 1)[1])
|
|
|
+ if (q.get("token") or [""])[0] == token:
|
|
|
+ return True
|
|
|
+ self._send_json(401, {"error": "missing or invalid Bearer token"})
|
|
|
+ return False
|
|
|
+
|
|
|
def log_message(self, fmt, *args):
|
|
|
return
|
|
|
|
|
|
@@ -615,6 +863,317 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
self.close_connection = True
|
|
|
|
|
|
|
|
|
+SWAGGER_HTML = r"""<!doctype html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+<meta charset="utf-8" />
|
|
|
+<title>API 文档 · ChatGPT Plus 自动化</title>
|
|
|
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css" />
|
|
|
+<style>body{margin:0}#swagger-ui{max-width:1280px;margin:0 auto}</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<div id="swagger-ui"></div>
|
|
|
+<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
|
|
|
+<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
|
|
|
+<script>
|
|
|
+window.onload = () => {
|
|
|
+ window.ui = SwaggerUIBundle({
|
|
|
+ url: '/openapi.json',
|
|
|
+ dom_id: '#swagger-ui',
|
|
|
+ deepLinking: true,
|
|
|
+ presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
|
+ layout: 'StandaloneLayout',
|
|
|
+ persistAuthorization: true,
|
|
|
+ tryItOutEnabled: true,
|
|
|
+ });
|
|
|
+};
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>"""
|
|
|
+
|
|
|
+
|
|
|
+def _build_openapi_spec() -> dict:
|
|
|
+ """生成 OpenAPI 3.1 规范。"""
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ return {
|
|
|
+ "openapi": "3.1.0",
|
|
|
+ "info": {
|
|
|
+ "title": "ChatGPT Plus 自动化 API",
|
|
|
+ "version": "1.0.0",
|
|
|
+ "description": (
|
|
|
+ "ChatGPT Plus 全自动注册 + PayPal 付款 + CPA 上传。\n\n"
|
|
|
+ "**两种模式**:\n"
|
|
|
+ "- `full` — 全自动注册新 ChatGPT 账号,注册→付款→上传 CPA\n"
|
|
|
+ "- `pay_only` — 传入已有 session JSON,跳过注册直接付款→上传 CPA\n\n"
|
|
|
+ "**调用流程**:\n"
|
|
|
+ "1. POST `/api/tasks` 创建任务,立即拿到 `task_id`\n"
|
|
|
+ "2. 轮询 GET `/api/tasks/{task_id}` 看 `status` 和 `stage`\n"
|
|
|
+ "3. `status` 变 `success` 时可调 GET `/api/account/{email}/cpa.json` 下载 CPA 文件\n\n"
|
|
|
+ "**重试**:每个任务整体失败会重试 `max_attempts` 次(默认 3)。"
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "servers": [
|
|
|
+ {"url": f"http://{cfg.api_host or '127.0.0.1'}:{cfg.api_port or 7791}", "description": "当前实例"},
|
|
|
+ ],
|
|
|
+ "components": {
|
|
|
+ "securitySchemes": {
|
|
|
+ "BearerAuth": {
|
|
|
+ "type": "http",
|
|
|
+ "scheme": "bearer",
|
|
|
+ "description": "如果配置了 `api_token`,所有 /api/* 请求需带 `Authorization: Bearer <token>`。也支持 `?token=xxx` 查询参数。",
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "schemas": {
|
|
|
+ "Task": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "task_id": {"type": "string", "example": "t-1779470219-65e44d55"},
|
|
|
+ "mode": {"type": "string", "enum": ["full", "pay_only"]},
|
|
|
+ "status": {"type": "string", "enum": ["queued", "running", "success", "failed", "cancelled"]},
|
|
|
+ "stage": {"type": "string", "description": "当前阶段描述"},
|
|
|
+ "attempts": {"type": "integer"},
|
|
|
+ "max_attempts": {"type": "integer"},
|
|
|
+ "params": {"type": "object", "description": "创建任务时传入的参数(脱敏后)"},
|
|
|
+ "result": {"type": "object", "nullable": True, "description": "成功时的结果(含 CPA 文件名等)"},
|
|
|
+ "last_error": {"type": "string", "nullable": True},
|
|
|
+ "email": {"type": "string", "nullable": True, "description": "注册成功的 ChatGPT 邮箱"},
|
|
|
+ "plan_type": {"type": "string", "nullable": True, "example": "plus"},
|
|
|
+ "cpa_file_name": {"type": "string", "nullable": True, "example": "codex-foo@example.com-plus.json"},
|
|
|
+ "created_at": {"type": "integer", "description": "毫秒时间戳"},
|
|
|
+ "updated_at": {"type": "integer"},
|
|
|
+ "started_at": {"type": "integer", "nullable": True},
|
|
|
+ "finished_at": {"type": "integer", "nullable": True},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "CreateTaskRequest": {
|
|
|
+ "type": "object",
|
|
|
+ "required": ["mode"],
|
|
|
+ "properties": {
|
|
|
+ "mode": {"type": "string", "enum": ["full", "pay_only"]},
|
|
|
+ "max_attempts": {"type": "integer", "default": 3, "minimum": 1, "maximum": 10},
|
|
|
+ "params": {
|
|
|
+ "type": "object",
|
|
|
+ "description": "可覆盖全局配置;pay_only 模式必须包含 session 字段",
|
|
|
+ "properties": {
|
|
|
+ "session": {
|
|
|
+ "type": "object",
|
|
|
+ "description": "ChatGPT /api/auth/session 完整 JSON(仅 pay_only 模式必填)",
|
|
|
+ "properties": {
|
|
|
+ "accessToken": {"type": "string"},
|
|
|
+ "user": {"type": "object"},
|
|
|
+ "account": {"type": "object"},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "headless": {"type": "boolean"},
|
|
|
+ "use_promo": {"type": "boolean"},
|
|
|
+ "phone_e164": {"type": "string", "example": "+15822201173"},
|
|
|
+ "sms_api_url": {"type": "string"},
|
|
|
+ "cpa_url": {"type": "string"},
|
|
|
+ "cpa_management_key": {"type": "string"},
|
|
|
+ "proxy_url": {"type": "string"},
|
|
|
+ "paypal_only_proxy": {"type": "string"},
|
|
|
+ "mail_helper_url": {"type": "string"},
|
|
|
+ "mail_domain": {"type": "string"},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "Account": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "email": {"type": "string"},
|
|
|
+ "plan_type": {"type": "string", "nullable": True},
|
|
|
+ "final_status": {"type": "string"},
|
|
|
+ "cpa_file_name": {"type": "string", "nullable": True},
|
|
|
+ "long_link": {"type": "string", "nullable": True},
|
|
|
+ "last_error": {"type": "string", "nullable": True},
|
|
|
+ "created_at": {"type": "integer"},
|
|
|
+ "updated_at": {"type": "integer"},
|
|
|
+ "cpa_uploaded_at": {"type": "integer", "nullable": True},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "Error": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {"error": {"type": "string"}},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "security": [{"BearerAuth": []}] if cfg.api_token else [],
|
|
|
+ "paths": {
|
|
|
+ "/api/tasks": {
|
|
|
+ "post": {
|
|
|
+ "tags": ["Tasks"],
|
|
|
+ "summary": "创建任务",
|
|
|
+ "description": "创建一个 full 或 pay_only 任务,立即返回 task_id,任务异步执行。",
|
|
|
+ "requestBody": {
|
|
|
+ "required": True,
|
|
|
+ "content": {
|
|
|
+ "application/json": {
|
|
|
+ "schema": {"$ref": "#/components/schemas/CreateTaskRequest"},
|
|
|
+ "examples": {
|
|
|
+ "full": {
|
|
|
+ "summary": "全自动注册",
|
|
|
+ "value": {
|
|
|
+ "mode": "full",
|
|
|
+ "max_attempts": 3,
|
|
|
+ "params": {},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "pay_only": {
|
|
|
+ "summary": "传入 session 直接付款",
|
|
|
+ "value": {
|
|
|
+ "mode": "pay_only",
|
|
|
+ "max_attempts": 3,
|
|
|
+ "params": {
|
|
|
+ "session": {
|
|
|
+ "accessToken": "eyJxxx...",
|
|
|
+ "user": {"email": "user@example.com"},
|
|
|
+ "account": {"planType": "free"},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "responses": {
|
|
|
+ "200": {
|
|
|
+ "description": "任务已创建",
|
|
|
+ "content": {
|
|
|
+ "application/json": {
|
|
|
+ "schema": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "task_id": {"type": "string"},
|
|
|
+ "task": {"$ref": "#/components/schemas/Task"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "400": {"description": "参数错误", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
|
|
|
+ "401": {"description": "Bearer token 缺失或无效"},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "get": {
|
|
|
+ "tags": ["Tasks"],
|
|
|
+ "summary": "列出任务",
|
|
|
+ "parameters": [
|
|
|
+ {"name": "status", "in": "query", "schema": {"type": "string", "enum": ["queued", "running", "success", "failed", "cancelled"]}},
|
|
|
+ {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 100}},
|
|
|
+ ],
|
|
|
+ "responses": {
|
|
|
+ "200": {
|
|
|
+ "content": {
|
|
|
+ "application/json": {
|
|
|
+ "schema": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {"tasks": {"type": "array", "items": {"$ref": "#/components/schemas/Task"}}},
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "/api/tasks/{task_id}": {
|
|
|
+ "get": {
|
|
|
+ "tags": ["Tasks"],
|
|
|
+ "summary": "查询任务进度",
|
|
|
+ "description": "轮询此接口查任务实时 status 和 stage。建议 5-10 秒间隔。",
|
|
|
+ "parameters": [{"name": "task_id", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
|
+ "responses": {
|
|
|
+ "200": {"content": {"application/json": {"schema": {"type": "object", "properties": {"task": {"$ref": "#/components/schemas/Task"}}}}}},
|
|
|
+ "404": {"description": "任务不存在"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/tasks/{task_id}/cancel": {
|
|
|
+ "post": {
|
|
|
+ "tags": ["Tasks"],
|
|
|
+ "summary": "取消任务",
|
|
|
+ "description": "请求取消任务。如果任务已经在跑,会在下一个 stop 检查点退出。",
|
|
|
+ "parameters": [{"name": "task_id", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
|
+ "responses": {"200": {"description": "已请求取消"}, "404": {"description": "任务不存在"}},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/accounts": {
|
|
|
+ "get": {
|
|
|
+ "tags": ["Accounts"],
|
|
|
+ "summary": "列出已注册账号",
|
|
|
+ "parameters": [
|
|
|
+ {"name": "status", "in": "query", "schema": {"type": "string"}, "description": "如 cpa_uploaded / plus_check_failed"},
|
|
|
+ {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 200}},
|
|
|
+ ],
|
|
|
+ "responses": {
|
|
|
+ "200": {
|
|
|
+ "content": {
|
|
|
+ "application/json": {
|
|
|
+ "schema": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {"accounts": {"type": "array", "items": {"$ref": "#/components/schemas/Account"}}},
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/account/{email}": {
|
|
|
+ "get": {
|
|
|
+ "tags": ["Accounts"],
|
|
|
+ "summary": "查询账号详情(含完整 session 和事件流)",
|
|
|
+ "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
|
+ "responses": {"200": {"description": "OK"}, "404": {"description": "账号不存在"}},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/account/{email}/cpa.json": {
|
|
|
+ "get": {
|
|
|
+ "tags": ["Accounts"],
|
|
|
+ "summary": "下载 CPA codex auth JSON",
|
|
|
+ "description": "返回该账号当时上传给 CPA 的完整 codex auth JSON 文件。带 Content-Disposition 头,浏览器会自动下载。",
|
|
|
+ "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
|
+ "responses": {
|
|
|
+ "200": {"description": "OK", "content": {"application/json": {}}},
|
|
|
+ "404": {"description": "账号或 session 不存在"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/account/{email}/recheck": {
|
|
|
+ "post": {
|
|
|
+ "tags": ["Accounts"],
|
|
|
+ "summary": "对失败账号补救",
|
|
|
+ "description": "用 DB 里存的 access_token 调 backend-api/me,若已 plus 则自动重传 CPA。",
|
|
|
+ "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
|
|
|
+ "responses": {"200": {"description": "OK"}, "404": {"description": "账号不存在"}},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "/api/config": {
|
|
|
+ "get": {"tags": ["Config"], "summary": "读取当前配置", "responses": {"200": {"description": "OK"}}},
|
|
|
+ "post": {
|
|
|
+ "tags": ["Config"],
|
|
|
+ "summary": "更新配置",
|
|
|
+ "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}},
|
|
|
+ "responses": {"200": {"description": "OK"}},
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "/api/status": {
|
|
|
+ "get": {"tags": ["Misc"], "summary": "(旧)读取 UI 任务状态", "responses": {"200": {"description": "OK"}}}
|
|
|
+ },
|
|
|
+ "/api/log": {
|
|
|
+ "get": {"tags": ["Misc"], "summary": "实时日志(Server-Sent Events)", "responses": {"200": {"description": "text/event-stream"}}}
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "tags": [
|
|
|
+ {"name": "Tasks", "description": "任务化 API(推荐用法)"},
|
|
|
+ {"name": "Accounts", "description": "账号库"},
|
|
|
+ {"name": "Config", "description": "服务配置"},
|
|
|
+ {"name": "Misc", "description": "其他"},
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
def _silence_threading_excepthook():
|
|
|
"""ThreadingHTTPServer 在 worker 线程里仍可能抛 ConnectionResetError;接住它。"""
|
|
|
import threading
|
|
|
@@ -632,8 +1191,20 @@ def _silence_threading_excepthook():
|
|
|
def main():
|
|
|
init_db()
|
|
|
_silence_threading_excepthook()
|
|
|
- server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
|
- print(f"ChatGPT Plus Auto Console: http://{HOST}:{PORT}/")
|
|
|
+ # 启动后台任务 worker(幂等)
|
|
|
+ get_runner(log=lambda m: JOB._log(m))
|
|
|
+ cfg = AppConfig.load()
|
|
|
+ host = (cfg.api_host or HOST).strip() or HOST
|
|
|
+ port = int(cfg.api_port or PORT)
|
|
|
+ server = ThreadingHTTPServer((host, port), Handler)
|
|
|
+ print(f"ChatGPT Plus Auto Console:")
|
|
|
+ print(f" Web UI: http://{host}:{port}/")
|
|
|
+ print(f" Docs: http://{host}:{port}/docs")
|
|
|
+ print(f" OpenAPI: http://{host}:{port}/openapi.json")
|
|
|
+ if cfg.api_token:
|
|
|
+ print(f" Auth: Bearer <token>(已启用)")
|
|
|
+ if host == "0.0.0.0":
|
|
|
+ print(f" ⚠️ 当前监听所有网卡,外网可访问。建议设置 api_token。")
|
|
|
try:
|
|
|
server.serve_forever()
|
|
|
except KeyboardInterrupt:
|