浏览代码

Server: REST tasks API, OpenAPI docs, external access, CORS, Bearer auth

- 任务化 API: POST/GET /api/tasks, GET /api/tasks/<id>, POST /api/tasks/<id>/cancel
- 账号库 API: list/detail/cpa.json 下载/recheck
- /openapi.json: OpenAPI 3.1 规范(10 paths, 4 schemas, 4 tags)
- /docs: Swagger UI(CDN 加载,无需安装依赖)
- CORS: 自动 Allow-Origin/Methods/Headers + OPTIONS 预检
- Bearer token 鉴权(api_token 非空时启用,公开接口豁免)
- 监听 host/port 可配置;启动 banner 提示外网监听 + 文档 URL
- Web UI: 已注册账号库表格 + 详情/下载CPA/重新校验按钮 + API 配置区
chendeben 2 月之前
父节点
当前提交
a40776c5e2
共有 1 个文件被更改,包括 584 次插入13 次删除
  1. 584 13
      server.py

+ 584 - 13
server.py

@@ -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,'&quot;')}">${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: