AutoTeam-F / src /autoteam /manager.py
Looknicemm's picture
feat: cmd_oauth_fill + team-independent oauth-fill trigger
23e6307 verified
Raw
History Blame Contribute Delete
366 kB
#!/usr/bin/env python3
import autoteam.display # noqa: F401 — 自动设置虚拟显示器
"""
账号轮转管理器
功能:
- 检查所有活跃账号的 Codex 额度
- 额度用完的账号移出 Team,放入 standby
- 从 standby 中选额度恢复的旧账号重新邀请
- 无可用旧账号时才创建新账号
- 自动完成注册并保存 Codex 认证文件
用法:
python manager.py check # 检查所有活跃账号额度
python manager.py rotate # 执行一次轮转(检查 + 替换)
python manager.py add # 手动添加一个新账号
python manager.py status # 查看所有账号状态
"""
import getpass
import json
import logging
import os
import sys
import threading
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
from autoteam.account_ops import delete_managed_account, delete_team_invite, fetch_team_state, team_invite_email
from autoteam.accounts import (
STATUS_ACTIVE,
STATUS_AUTH_INVALID,
STATUS_AUTH_PENDING,
STATUS_EXHAUSTED,
STATUS_ORPHAN,
STATUS_PENDING,
STATUS_PERSONAL,
STATUS_STANDBY,
add_account,
delete_account,
find_account,
get_standby_accounts,
is_account_disabled,
is_supported_plan,
load_accounts,
save_accounts,
update_account,
)
from autoteam.admin_state import get_admin_email, get_admin_state_summary, get_chatgpt_account_id
from autoteam.chatgpt_api import ChatGPTTeamAPI
from autoteam.cloudmail import CloudMailClient
from autoteam.codex_auth import (
MainCodexSyncFlow,
_click_primary_auth_button,
_is_google_redirect,
check_codex_quota,
get_quota_exhausted_info,
get_saved_main_auth_file,
is_token_pair_invalidated,
login_codex_via_browser,
quota_result_quota_info,
quota_result_resets_at,
refresh_access_token,
refresh_main_auth_file,
save_auth_file,
)
from autoteam.config import get_playwright_context_options, get_playwright_launch_options
from autoteam.cpa_sync import sync_from_cpa
from autoteam.identity import random_birthday, random_password
from autoteam.invite import RegisterBlocked # SPEC-2 shared/add-phone-detection §5 — 5 处 OAuth 调用方 catch
from autoteam.playwright_lifecycle import close_playwright_objects
from autoteam.register_failures import MASTER_SUBSCRIPTION_DEGRADED, record_failure
from autoteam.signup_profile import SignupProfile, generate_signup_profile
from autoteam.sync_targets import (
sync_account_to_configured_targets,
)
from autoteam.sync_targets import (
sync_main_codex_to_configured_targets as sync_main_codex_to_cpa,
)
from autoteam.sync_targets import (
sync_to_configured_targets as sync_to_cpa,
)
from autoteam.textio import parse_env_value, read_text, write_text
logger = logging.getLogger(__name__)
MAIL_TIMEOUT = int(os.environ.get("MAIL_TIMEOUT", "180"))
TEAM_SEATS_MIN = 2
TEAM_SEATS_MAX = 3
ROTATE_NEW_ACCOUNT_MODE_DEFAULT = "domain_auto_join_first"
ROTATE_NEW_ACCOUNT_MODES = {"domain_auto_join_first", "invite_first", "direct_first", "oauth_first"}
# Round 11 二轮收尾 — OAuth 子进程默认超时(秒)。任何 subprocess 包裹 login_codex_via_browser
# (probe 脚本 / 异步 worker / CLI 工具) 必须用此常量,不再硬编码 60s。
# 实测 P95 < 180s(参考 P1 报告 fd3b5ccae1 实测 71.3s headless OAuth 完成),
# 200s 为 safety margin。生产路径(_run_post_register_oauth)在主线程内同步执行,
# 由 _pw_executor.run 默认 300s 包裹,不走本常量。
OAUTH_SUBPROCESS_TIMEOUT_S = int(os.environ.get("OAUTH_SUBPROCESS_TIMEOUT_S", "200"))
# Round 7 P2.5 — 把 wham/usage 原始 rate_limit 子树序列化为字符串,
# 供 record_failure(no_quota_assigned, raw_rate_limit=...) 落 register_failures.json,
# 便于事后排查 OpenAI 协议变化。截断 2000 字符防止 register_failures.json 膨胀(NFR-6)。
_RAW_RATE_LIMIT_MAX_CHARS = 2000
def _extract_raw_rate_limit_str(quota_info) -> str:
"""从 check_codex_quota 返回的 quota_info / exhausted_info 中提取原始 rate_limit 子树并 JSON 序列化。
输入既可能是 ok 形态的 quota_info(顶层 raw_rate_limit / primary_window),
也可能是 exhausted/no_quota 形态的 exhausted_info(raw_rate_limit 顶层 + 子层 quota_info)。
序列化失败时返回空串,不阻塞 record_failure 主流程(R5 缓解)。
"""
if not isinstance(quota_info, dict):
return ""
raw = (
quota_info.get("raw_rate_limit")
or (quota_info.get("quota_info") or {}).get("raw_rate_limit")
or quota_info.get("primary_window")
or (quota_info.get("quota_info") or {}).get("primary_window")
)
if not raw:
return ""
try:
return json.dumps(raw, ensure_ascii=False)[:_RAW_RATE_LIMIT_MAX_CHARS]
except Exception:
return ""
def _normalized_email(value: str | None) -> str:
return (value or "").strip().lower()
def _normalized_domain(value: str | None) -> str:
return str(value or "").strip().lower().lstrip("@").rstrip(".")
def _runtime_env_value(name: str, default: str = "") -> str:
return str(parse_env_value(os.environ.get(name, default)) or "").strip()
def _runtime_bool_env(name: str, default: bool) -> bool:
raw = _runtime_env_value(name, "")
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on", "enabled", "y", "t"}
def _runtime_new_account_mode() -> str:
mode = _runtime_env_value("ROTATE_NEW_ACCOUNT_MODE", ROTATE_NEW_ACCOUNT_MODE_DEFAULT).lower()
if mode in ROTATE_NEW_ACCOUNT_MODES:
return mode
logger.warning(
"[创建] ROTATE_NEW_ACCOUNT_MODE=%s 无效,回退到 %s",
mode or "<empty>",
ROTATE_NEW_ACCOUNT_MODE_DEFAULT,
)
return ROTATE_NEW_ACCOUNT_MODE_DEFAULT
def _domain_auto_join_fallback_invite_enabled() -> bool:
return _runtime_bool_env("ROTATE_DOMAIN_AUTO_JOIN_FALLBACK_INVITE", True)
def _configured_mail_domains(mail_client=None) -> set[str]:
domains: set[str] = set()
for value in (
get_mail_domain(),
os.environ.get("CLOUDMAIL_DOMAIN", ""),
os.environ.get("MAILLAB_DOMAIN", ""),
os.environ.get("CF_TEMP_EMAIL_DOMAIN", ""),
os.environ.get("ADDY_IO_DOMAIN", ""),
getattr(mail_client, "domain", ""),
getattr(mail_client, "default_domain", ""),
):
domain = _normalized_domain(value)
if domain:
domains.add(domain)
return domains
def _auto_join_domain_allowlist(mail_client=None) -> tuple[set[str], bool]:
raw = _runtime_env_value("AUTOTEAM_AUTO_JOIN_DOMAINS", "auto")
if not raw or raw.lower() == "auto":
return _configured_mail_domains(mail_client), False
values = {_normalized_domain(item) for item in raw.split(",")}
values.discard("")
if "*" in values:
return set(), True
return values, False
def _mail_domain_auto_join_allowed(mail_client=None) -> bool:
allowlist, wildcard = _auto_join_domain_allowlist(mail_client)
if wildcard:
return True
configured = _configured_mail_domains(mail_client)
return bool(configured and allowlist and configured.intersection(allowlist))
def _is_main_account_email(email: str | None) -> bool:
return bool(_normalized_email(email)) and _normalized_email(email) == _normalized_email(get_admin_email())
_GOOGLE_AUTO_REUSE_DOMAINS = {"gmail.com", "googlemail.com"}
def _clamp_team_target_seats(value, *, minimum: int = TEAM_SEATS_MIN) -> int:
"""Clamp Team target seats to the hard 1 owner + 2 child contract."""
try:
parsed = int(value or minimum)
except Exception:
parsed = minimum
return max(int(minimum), min(TEAM_SEATS_MAX, parsed))
def _get_account_login_provider(acc: dict | None) -> str:
acc = acc or {}
for key in ("login_provider", "auth_provider", "oauth_provider"):
provider = (acc.get(key) or "").strip().lower()
if provider:
return provider
email = _normalized_email(acc.get("email"))
if "@" in email and email.rsplit("@", 1)[-1] in _GOOGLE_AUTO_REUSE_DOMAINS:
return "google"
return ""
def _auto_reuse_skip_reason(acc: dict | None) -> str | None:
provider = _get_account_login_provider(acc)
if provider == "google":
return "Google 登录账号暂不支持自动复用"
return None
# --------------------------------------------------------------------------
# Round 12 S3 — cherry-pick from upstream `.upstream/manager.py` (3137 行)
# 详见 `.trellis/tasks/05-11-s0-upstream-team-rotate-diff/research/upstream-diff.md`
# --------------------------------------------------------------------------
# 失败类型常量(上游 `.upstream/manager.py:96`)。Hard failure → 立即暂停 + 释放席位。
AUTH_REPAIR_HARD_FAILURE_TYPES = frozenset({"human_verification"})
AUTH_REPAIR_SINGLE_ATTEMPT_FAILURE_TYPES = frozenset(
{
"add_phone",
"human_verification",
"email_verification",
"login_state_lost",
"account_selection",
"no_valid_organizations",
}
)
AUTH_REPAIR_RELEASE_AFTER_RETRY_TYPES = frozenset({"email_verification"})
AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES = frozenset(
{
"login_state_lost",
"account_selection",
"no_valid_organizations",
"missing_auth_file",
"auth_error_discard",
}
)
AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES = frozenset(
{
"add_phone",
"human_verification",
"email_verification",
"workspace_selection",
"account_selection",
"login_state_lost",
"no_valid_organizations",
"missing_auth_file",
"auth_error_discard",
"unsupported_region",
"oauth_timeout",
"site_unavailable",
"token_exchange_failed",
"non_team_plan",
"auth_code_missing",
"login_failed",
"exception",
}
)
def _chatgpt_session_ready(chatgpt_api) -> bool:
"""判断 chatgpt_api 是否处于可用 Team API session 状态。
`CHATGPT_API_TRANSPORT=auto` 时可能只有 HTTP transport 而没有 browser,
因此生命周期判断必须优先使用 `is_started()`,再回退到旧的 browser 字段。
"""
if not chatgpt_api:
return False
def _declared_attr(name: str):
# Avoid MagicMock/duck objects becoming "ready" via dynamic __getattr__.
try:
attrs = vars(chatgpt_api)
except TypeError:
attrs = {}
if name in attrs:
return attrs[name]
if hasattr(type(chatgpt_api), name):
return getattr(chatgpt_api, name, None)
return None
is_started = _declared_attr("is_started")
if callable(is_started):
try:
return bool(is_started())
except Exception:
pass
return bool(_declared_attr("browser") or _declared_attr("http_transport"))
def _run_post_task_sync(stage_label: str, sync_func) -> None:
"""Run final remote sync after the Playwright-bound task has completed."""
try:
logger.info("%s 后台远端同步开始...", stage_label)
result = sync_func()
logger.info("%s 后台远端同步完成: %s", stage_label, result)
except Exception as exc:
logger.warning("%s 后台远端同步失败,保留本地轮转结果: %s", stage_label, exc)
def _schedule_post_task_sync(stage_label: str = "[轮转]", sync_func=None) -> threading.Thread:
runner = sync_func or sync_to_cpa
thread = threading.Thread(
target=_run_post_task_sync,
args=(stage_label, runner),
name="autoteam-post-task-sync",
daemon=True,
)
thread.start()
return thread
def _has_auth_file(acc: dict | None) -> bool:
"""本地 acc 是否有可用 auth_file(上游 `.upstream/manager.py:153`)。"""
acc = acc or {}
auth_file = (acc.get("auth_file") or "").strip()
return bool(auth_file) and _resolve_auth_file_path(auth_file).exists()
def get_mail_domain() -> str:
"""Return the configured mail domain used by registration/auth repair flows."""
from autoteam.runtime_config import get_register_domain
return get_register_domain()
def get_mail_provider_name() -> str:
"""Return the configured mail provider name used by registration/auth repair flows."""
return (os.environ.get("MAIL_PROVIDER") or "cf_temp_email").strip().lower()
def _mail_provider_name_for_client(mail_client) -> str:
provider_name = str(getattr(mail_client, "provider_name", "") or "").strip().lower()
if not provider_name:
try:
current = getattr(mail_client, "current_provider_name", None)
if callable(current):
provider_name = str(current() or "").strip().lower()
except Exception:
provider_name = ""
if provider_name in {"maillab", "addy_io", "simplelogin"}:
return provider_name
if provider_name in {"cf_temp_email", "cloudflare_temp_email", "cloudmail"}:
return "cf_temp_email"
return get_mail_provider_name()
def _auth_search_dirs() -> tuple[Path, ...]:
"""Candidate auth directories for host and container path compatibility."""
candidates: list[Path] = []
try:
from autoteam.auth_storage import AUTH_DIR
candidates.append(AUTH_DIR)
except Exception:
pass
project_root = Path(__file__).resolve().parents[2]
candidates.extend((project_root / "data" / "auths", project_root / "auths"))
seen: set[str] = set()
unique: list[Path] = []
for path in candidates:
key = str(path)
if key not in seen:
seen.add(key)
unique.append(path)
return tuple(unique)
def _resolve_auth_file_path(value: str | None) -> Path:
"""Resolve auth paths saved as host paths, `/app/...`, `data/auths/...`, or bare names."""
text = (value or "").strip()
if not text:
return Path("")
direct = Path(text)
if direct.exists():
return direct
project_root = Path(__file__).resolve().parents[2]
candidates: list[Path] = []
normalized = text.replace("\\", "/")
if normalized.startswith("/app/"):
candidates.append(project_root / normalized.removeprefix("/app/"))
elif normalized.startswith("data/") or normalized.startswith("auths/"):
candidates.append(project_root / normalized)
name = Path(normalized).name
if name:
candidates.extend(auth_dir / name for auth_dir in _auth_search_dirs())
for candidate in candidates:
if candidate.exists():
return candidate
return direct
def _has_account_mail_binding(acc: dict | None) -> bool:
acc = acc or {}
return acc.get("mail_account_id") is not None or acc.get("cloudmail_account_id") is not None
def _is_protected_local_credential_seat(acc: dict | None) -> bool:
if not acc or acc.get("disabled") is True or _is_main_account_email(acc.get("email")):
return False
has_auth = _has_auth_file(acc)
if acc.get("status") == STATUS_EXHAUSTED:
return False
if acc.get("protect_team_seat") is True:
return has_auth
if not has_auth:
return False
if acc.get("status") in (STATUS_STANDBY, STATUS_AUTH_INVALID):
return True
return not _has_account_mail_binding(acc)
def _can_replace_protected_managed_auth_failure(
acc: dict | None,
*,
error_type: str | None = None,
reason: str | None = None,
missing_auth: bool = False,
) -> bool:
"""Allow AutoTeam-managed child seats to be evicted despite stale protection flags."""
acc = acc or {}
if _is_main_account_email(acc.get("email")) or not _has_account_mail_binding(acc):
return False
signals = {
str(value or "").strip()
for value in (
error_type,
reason,
acc.get("auth_last_error"),
"missing_auth_file" if missing_auth else None,
)
if str(value or "").strip()
}
if acc.get("auth_retry_paused"):
signals.add("auth_retry_paused")
if acc.get("auth_retry_after"):
signals.add("auth_retry_after")
return bool(
signals
& (
AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES
| AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES
| {
"auth_error",
"auth_retry_paused",
"auth_retry_after",
"auth_retry_after_invalid",
}
)
)
def _is_auth_repair_pending_status(status: str | None) -> bool:
"""Accept current persisted auth-invalid alias and legacy target auth_pending literal."""
return status in {STATUS_AUTH_PENDING, "auth_pending"}
def _can_attempt_auth_repair(acc: dict | None, mail_domain_suffix: str = "") -> bool:
acc = acc or {}
if (
bool(acc.get("mail_provider"))
or acc.get("mail_account_id") is not None
or acc.get("cloudmail_account_id") is not None
):
return True
email = _normalized_email(acc.get("email"))
return bool(mail_domain_suffix and mail_domain_suffix in email)
def _sync_ready_credential_to_targets(email: str, auth_file: str | None, *, stage_label: str) -> dict:
if not auth_file:
logger.warning("%s 新凭证已就绪但缺少 auth_file,跳过即时同步: %s", stage_label, email)
return {"ok": False, "skipped": True, "reason": "missing_auth_file"}
try:
result = sync_account_to_configured_targets(email, str(auth_file))
except Exception as exc:
logger.warning("%s 新凭证即时同步失败,保留本地结果: %s (%s)", stage_label, email, exc)
return {"ok": False, "error": str(exc)}
if isinstance(result, dict) and result.get("ok") and not result.get("skipped"):
logger.info("%s 新凭证已即时同步: %s (%s)", stage_label, email, Path(auth_file).name)
elif isinstance(result, dict) and result.get("ok") and result.get("skipped"):
logger.info("%s 新凭证即时同步跳过: %s (%s)", stage_label, email, result.get("reason"))
else:
logger.warning("%s 新凭证即时同步未完全成功,保留本地结果: %s (%s)", stage_label, email, result)
return result if isinstance(result, dict) else {"ok": False, "result": result}
def _ensure_account_ipv6_proxy(email: str | None) -> tuple[str, str]:
email = _normalized_email(email)
if not email:
return "", ""
required = False
try:
from autoteam import config as runtime_config
from autoteam.ipv6_pool import ipv6_pool
required = bool(getattr(runtime_config, "AUTOTEAM_IPV6_POOL_REQUIRED", False))
proxy_url = ipv6_pool.ensure(email) or ""
local_proxy_url = ipv6_pool.get_local_proxy_url(email) or proxy_url
if local_proxy_url:
logger.info("[IPv6Pool] account %s using proxy %s", email, local_proxy_url)
return proxy_url, local_proxy_url
if required:
raise RuntimeError("IPv6 pool is required but no account proxy was assigned")
return "", ""
except Exception as exc:
if required:
logger.error("[IPv6Pool] account proxy required for %s but unavailable: %s", email, exc)
raise
logger.warning("[IPv6Pool] account proxy unavailable for %s, falling back to direct: %s", email, exc)
return "", ""
def _release_account_ipv6_proxy(email: str | None) -> None:
email = _normalized_email(email)
if not email:
return
try:
from autoteam.ipv6_pool import ipv6_pool
if ipv6_pool.release(email):
logger.info("[IPv6Pool] released account proxy: %s", email)
except Exception as exc:
logger.warning("[IPv6Pool] release failed for %s: %s", email, exc)
def _discard_auth_repair_failed_account_record(
email: str,
reason: str,
*,
status: str = STATUS_STANDBY,
now: float | None = None,
) -> None:
"""Mark a released auth-repair failure as non-reusable while keeping local evidence."""
update_account(
email,
status=status,
disabled=True,
reuse_disabled=True,
retired_at=time.time() if now is None else now,
retired_reason=reason,
)
_release_account_ipv6_proxy(email)
def _attach_account_proxy_to_bundle(email: str | None, bundle: dict | None, proxy_url: str | None = None) -> None:
if not isinstance(bundle, dict):
return
proxy_url = (proxy_url or "").strip()
if not proxy_url:
proxy_url, _local_proxy_url = _ensure_account_ipv6_proxy(email)
if proxy_url:
bundle["proxy_url"] = proxy_url
def _login_codex_via_browser_with_proxy(*args, playwright_proxy_url: str | None = None, **kwargs):
try:
return login_codex_via_browser(*args, playwright_proxy_url=playwright_proxy_url, **kwargs)
except TypeError as exc:
if "playwright_proxy_url" not in str(exc):
raise
return login_codex_via_browser(*args, **kwargs)
def _pool_active_target(team_target: int) -> int:
"""除去主号后的"子号 active 池"目标(上游 `.upstream/manager.py:159`)。
主号占 1 席,target_seats=3 时子号 active 目标 = 2。轮转主循环终止条件用此值。
"""
return max(0, int(team_target) - 1)
def _count_pool_active_accounts(accounts: list[dict] | None = None, *, require_auth: bool = False) -> int:
"""统计非主号 + status=ACTIVE 的账号数(上游 `.upstream/manager.py:163`)。
require_auth=True 时还要求本地有可用 auth_file,用于"双指标终止条件"
防止"Team 满员但本地都是 standby/auth_invalid → 实际可用 0"假满足.
"""
accounts = accounts if accounts is not None else load_accounts()
count = 0
for acc in accounts:
if require_auth:
if not _is_pool_active_account_usable(acc, require_auth=True):
continue
elif _is_main_account_email(acc.get("email")) or is_account_disabled(acc) or acc.get("status") != STATUS_ACTIVE:
continue
count += 1
return count
def _account_auth_state_blocks_pool_use(acc: dict | None, *, now: float | None = None) -> bool:
"""Return True when saved Codex auth is known or likely unusable."""
acc = acc or {}
if acc.get("auth_retry_paused"):
return True
if acc.get("auth_last_error"):
return True
retry_after = acc.get("auth_retry_after")
if retry_after:
try:
return float(retry_after) > (time.time() if now is None else now)
except (TypeError, ValueError):
return True
return False
def _is_pool_active_account_usable(acc: dict | None, *, require_auth: bool = True) -> bool:
"""Check whether a child account should count as an actually usable pool seat."""
acc = acc or {}
if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
return False
if acc.get("status") != STATUS_ACTIVE:
return False
if require_auth and not _has_auth_file(acc):
return False
if require_auth and _account_auth_state_blocks_pool_use(acc):
return False
return True
def _replaceable_pool_blocker_reason(acc: dict | None, *, missing_auth: bool = False) -> str | None:
"""Return the concrete reason a child seat blocks the usable pool but may be replaced."""
acc = acc or {}
if _is_main_account_email(acc.get("email")) or is_account_disabled(acc):
return None
if _is_protected_local_credential_seat(acc) and not _can_replace_protected_managed_auth_failure(
acc,
missing_auth=missing_auth,
):
return None
status = acc.get("status")
if status == STATUS_EXHAUSTED:
return "quota_exhausted"
if status == STATUS_AUTH_INVALID:
return "auth_invalid"
if status == STATUS_ORPHAN:
return "orphan"
if status != STATUS_ACTIVE:
return None
if missing_auth:
return "auth_error"
if not _has_auth_file(acc):
return "missing_auth"
if acc.get("auth_retry_paused"):
return "auth_retry_paused"
if acc.get("auth_last_error"):
return "auth_error"
retry_after = acc.get("auth_retry_after")
if retry_after:
try:
if float(retry_after) > time.time():
return "auth_retry_after"
except (TypeError, ValueError):
return "auth_retry_after_invalid"
return None
def _is_replaceable_pool_blocker(acc: dict | None, *, missing_auth: bool = False) -> bool:
return _replaceable_pool_blocker_reason(acc, missing_auth=missing_auth) is not None
def _count_local_team_seat_accounts(accounts: list[dict] | None = None) -> int:
"""统计本地"占着 Team 席位"的非主号账号(上游 `.upstream/manager.py:175`).
席位状态包含 ACTIVE / EXHAUSTED / AUTH_INVALID(等价上游 STATUS_AUTH_PENDING):
本地 STATUS_AUTH_INVALID 与状态机 AccountState.AUTH_PENDING 同 literal "auth_invalid".
"""
accounts = accounts if accounts is not None else load_accounts()
seat_statuses = {STATUS_ACTIVE, STATUS_EXHAUSTED, STATUS_AUTH_INVALID}
return sum(
1
for acc in accounts
if not _is_main_account_email(acc.get("email"))
and not is_account_disabled(acc)
and acc.get("status") in seat_statuses
)
def _estimate_local_team_member_count(team_target: int, accounts: list[dict] | None = None) -> int:
"""估算 Team 实际成员数(含主号)(上游 `.upstream/manager.py:183`).
用于 cmd_rotate API 拿不到 member_count 时的更精确兜底,替代旧的
`local_active = sum(... STATUS_ACTIVE)`(后者会漏掉 EXHAUSTED / AUTH_INVALID).
"""
accounts = accounts if accounts is not None else load_accounts()
reserved_main = 1 if int(team_target) > 0 else 0
return _count_local_team_seat_accounts(accounts) + reserved_main
def _auth_repair_reset_fields() -> dict:
"""auth_repair 状态字段清零模板(上游 `.upstream/manager.py:197`).
一次性把所有 auth_retry_* 字段写空,适用于:
- 手动巡检成功后重置
- 注册成功后重置
- status 从 AUTH_INVALID/STANDBY 恢复 ACTIVE 时重置
"""
return {
"auth_retry_count": 0,
"auth_last_error": None,
"auth_last_error_detail": None,
"auth_last_failed_at": None,
"auth_retry_after": None,
"auth_retry_paused": False,
}
def _auth_repair_retry_delays() -> tuple[int, int, int]:
"""衰退式 retry_after 三档延迟(上游 `.upstream/manager.py:208`).
返回 (2x, 4x, 6x) * AUTO_CHECK_INTERVAL,常用 5min 间隔时为 (10min, 20min, 30min).
"""
from autoteam.config import AUTO_CHECK_INTERVAL
interval = AUTO_CHECK_INTERVAL
try:
from autoteam.api import _auto_check_config
interval = int(_auto_check_config.get("interval", interval) or interval)
except Exception:
pass
interval = max(60, int(interval))
return (interval * 2, interval * 4, interval * 6)
def _auth_repair_retry_add_phone_enabled() -> bool:
"""add_phone 软重试开关(上游 `.upstream/manager.py:223`).
True → 命中 add_phone 时按指数退避重试 N 次再放弃; False → 命中即 hard fail.
"""
from autoteam.config import AUTO_CHECK_RETRY_ADD_PHONE
enabled = AUTO_CHECK_RETRY_ADD_PHONE
try:
from autoteam.api import _auto_check_config
enabled = bool(_auto_check_config.get("retry_add_phone", enabled))
except Exception:
pass
return bool(enabled)
def _auth_repair_add_phone_max_retries() -> int:
"""add_phone 最大重试次数(上游 `.upstream/manager.py:237`).
超过此次数(从 1 起算) → next_count > max_retries → 暂停 + 释放席位.
"""
from autoteam.config import AUTO_CHECK_ADD_PHONE_MAX_RETRIES
retries = AUTO_CHECK_ADD_PHONE_MAX_RETRIES
try:
from autoteam.api import _auto_check_config
retries = int(_auto_check_config.get("add_phone_max_retries", retries) or retries)
except Exception:
pass
return max(1, int(retries))
def _auth_repair_add_phone_retry_delays(max_retries: int | None = None) -> tuple[int, ...]:
"""add_phone 指数退避延迟序列(上游 `.upstream/manager.py:251`).
返回 (interval*2^0, interval*2^1, ...) 长度 = max_retries.
"""
from autoteam.config import AUTO_CHECK_INTERVAL
interval = AUTO_CHECK_INTERVAL
try:
from autoteam.api import _auto_check_config
interval = int(_auto_check_config.get("interval", interval) or interval)
except Exception:
pass
retries = _auth_repair_add_phone_max_retries() if max_retries is None else max_retries
interval = max(60, int(interval))
retries = max(1, int(retries))
return tuple(interval * (2**idx) for idx in range(retries))
def _auth_repair_error_label(error_type: str | None) -> str:
"""error_type → 中文用户可读标签(上游 `.upstream/manager.py:268`).
UI / 日志用,未识别的 error_type 原样返回.
"""
mapping = {
"add_phone": "手机号验证",
"human_verification": "人机验证",
"email_verification": "邮箱验证码页卡住",
"workspace_selection": "workspace 选择未完成",
"account_selection": "账号选择页未完成",
"login_state_lost": "登录态丢失",
"missing_auth_file": "缺少本地 Codex 凭证",
"auth_error_discard": "认证失效后一次性丢弃",
"unsupported_region": "出口地区不被 OAuth 接受",
"oauth_timeout": "OAuth 授权页超时",
"site_unavailable": "站点不可用/代理异常",
"token_exchange_failed": "token 交换失败",
"non_team_plan": "未进入 Team workspace",
"auth_code_missing": "未获取到 auth code",
"login_failed": "登录失败",
"exception": "登录异常",
}
return mapping.get(error_type or "", error_type or "未知错误")
def _oauth_retry_delay_seconds(error_type: str | None) -> int:
"""Short same-round retry delays for transient OAuth organization/region pages."""
mapping = {
"unsupported_region": 20,
"account_selection": 6,
"no_valid_organizations": 10,
"oauth_timeout": 8,
}
return mapping.get(str(error_type or "").strip(), 0)
def _auth_repair_state_suffix(state: dict | None) -> str:
"""根据 auth_retry_after / auth_retry_paused 字段拼后缀(上游 `.upstream/manager.py:285`)."""
state = state or {}
if state.get("auth_retry_paused"):
return ",已暂停自动修复"
retry_after = state.get("auth_retry_after")
if retry_after:
mins = max(1, int((retry_after - time.time() + 59) // 60))
return f",约 {mins} 分钟后重试"
return ""
def _auth_repair_reset(email: str) -> None:
"""注册/复用成功后清空 auth_repair 状态字段(上游 `.upstream/manager.py:296`).
不修改 status — 调用方在调用本 helper 前已经把 status 写为 ACTIVE.
"""
update_account(email, **_auth_repair_reset_fields())
def _release_auth_repair_team_seat(email: str, *, chatgpt_api=None) -> str:
"""复用或新建 ChatGPTTeamAPI 移除受损账号的 Team 席位(上游 `.upstream/manager.py:300`).
返回 "removed" / "already_absent" / "failed".
给 _record_auth_repair_failure 在 hard failure / add_phone 超限时用.
"""
managed_chatgpt = chatgpt_api
started_here = False
try:
if managed_chatgpt is None:
managed_chatgpt = ChatGPTTeamAPI()
if not _chatgpt_session_ready(managed_chatgpt):
managed_chatgpt.start()
started_here = True
return str(remove_from_team(managed_chatgpt, email, return_status=True))
except Exception as exc:
logger.warning("[认证修复] 释放 %s 的 Team 席位失败: %s", email, exc)
return "failed"
finally:
if started_here and _chatgpt_session_ready(managed_chatgpt):
managed_chatgpt.stop()
def _auth_repair_result_suffix(result: dict | None) -> str:
"""补充"已释放 Team 席位"语义到日志后缀(上游 `.upstream/manager.py:321`)."""
result = result or {}
suffix = _auth_repair_state_suffix(result)
if result.get("seat_released"):
return f"{suffix},已释放 Team 席位"
if result.get("release_attempted") and result.get("remove_status") == "failed":
return f"{suffix},释放 Team 席位失败"
return suffix
def _auth_repair_skip_reason(acc: dict | None, *, force: bool = False, now: float | None = None) -> str | None:
"""判断是否应跳过自动修复(上游 `.upstream/manager.py:331`).
返回 None → 可走修复; 非 None → 中文跳过原因(冷却中 / 已暂停).
force=True 永远不跳过(给 cmd_check force_auth_repair=True 用).
"""
if force or not acc:
return None
if acc.get("auth_retry_paused"):
label = _auth_repair_error_label(acc.get("auth_last_error"))
return f"已暂停自动修复({label})"
retry_after = acc.get("auth_retry_after")
now = time.time() if now is None else now
if retry_after and retry_after > now:
remain_secs = max(0, int(retry_after - now))
remain_mins = max(1, (remain_secs + 59) // 60)
label = _auth_repair_error_label(acc.get("auth_last_error"))
return f"自动修复冷却中({label},约 {remain_mins} 分钟后重试)"
return None
def _should_aggressively_release_auth_failure(error_type: str | None, *, discard_failed_repair: bool) -> bool:
"""Return whether one-shot rotation should release a child seat for this login/link failure."""
if not discard_failed_repair:
return False
normalized = str(error_type or "").strip()
if not normalized:
normalized = "login_failed"
return normalized in AUTH_REPAIR_AGGRESSIVE_RELEASE_TYPES
def _record_auth_repair_failure(
email: str,
error_type: str | None = None,
error_detail: str | None = None,
*,
chatgpt_api=None,
) -> dict:
"""OAuth 修复失败 → 写衰退式 retry_after 状态 + 决定是否释放 Team 席位.
上游 `.upstream/manager.py:349`. 三分支:
1. add_phone + 软重试开 + 未超限 → 指数退避 retry_after, paused=False
2. add_phone(超限) | hard_failure → paused=True + 释放席位
3. 普通错误 → 衰退式三档 retry_after, paused=False
本地适配:
- 上游 STATUS_AUTH_PENDING → 本地 STATUS_AUTH_INVALID
(literal 同为 "auth_invalid", AccountState.AUTH_PENDING 等价)
- 所有 status / 字段写入走 update_account → default_machine.transition
自动校验合法性 + 写 state_log + 发事件.
"""
now = time.time()
acc = find_account(load_accounts(), email) or {"email": email}
error_type = error_type or "login_failed"
error_detail = error_detail or _auth_repair_error_label(error_type)
retry_delays = _auth_repair_retry_delays()
release_team_seat = False
missing_auth_file = not _has_auth_file(acc)
team_blocking_status = acc.get("status") in (STATUS_STANDBY, STATUS_AUTH_INVALID)
protected_local_credential = _is_protected_local_credential_seat(acc)
try:
from autoteam.config import ROTATE_SKIP_REUSE as discard_failed_repair
except Exception:
discard_failed_repair = True
if _should_aggressively_release_auth_failure(error_type, discard_failed_repair=discard_failed_repair):
prev_count = int(acc.get("auth_retry_count") or 0)
state = {
"auth_retry_count": prev_count + 1,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": None,
"auth_retry_paused": True,
}
release_team_seat = True
elif error_type == "add_phone" and _auth_repair_retry_add_phone_enabled():
prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0
next_count = prev_count + 1
max_retries = _auth_repair_add_phone_max_retries()
add_phone_delays = _auth_repair_add_phone_retry_delays(max_retries)
if next_count > max_retries:
state = {
"auth_retry_count": next_count,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": None,
"auth_retry_paused": True,
}
release_team_seat = True
else:
state = {
"auth_retry_count": next_count,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": now + add_phone_delays[next_count - 1],
"auth_retry_paused": False,
}
elif error_type in AUTH_REPAIR_HARD_FAILURE_TYPES or error_type == "add_phone":
retry_count = max(int(acc.get("auth_retry_count") or 0), len(retry_delays))
state = {
"auth_retry_count": retry_count,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": None,
"auth_retry_paused": True,
}
release_team_seat = True
elif error_type in AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES and (
missing_auth_file or team_blocking_status or discard_failed_repair
):
prev_count = int(acc.get("auth_retry_count") or 0)
state = {
"auth_retry_count": prev_count + 1,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": None,
"auth_retry_paused": True,
}
release_team_seat = True
else:
prev_count = int(acc.get("auth_retry_count") or 0)
next_count = min(prev_count + 1, len(retry_delays))
delay = retry_delays[max(0, next_count - 1)]
retry_after = now + delay
state = {
"auth_retry_count": next_count,
"auth_last_error": error_type,
"auth_last_error_detail": error_detail,
"auth_last_failed_at": now,
"auth_retry_after": retry_after,
"auth_retry_paused": False,
}
if error_type in AUTH_REPAIR_RELEASE_AFTER_RETRY_TYPES and next_count >= len(retry_delays):
state["auth_retry_after"] = None
state["auth_retry_paused"] = True
release_team_seat = True
protected_replacement_override = _can_replace_protected_managed_auth_failure(acc, error_type=error_type)
if release_team_seat and protected_local_credential and not protected_replacement_override:
logger.warning("[认证修复] 保留受保护的本地凭证席位: %s", email)
release_team_seat = False
update_account(email, **state)
is_team_member = _is_email_in_team(email)
if not is_team_member and acc.get("status") in (STATUS_ACTIVE, STATUS_EXHAUSTED, STATUS_AUTH_INVALID):
is_team_member = True
release_attempted = False
remove_status = None
seat_released = False
if release_team_seat and is_team_member:
release_attempted = True
remove_status = _release_auth_repair_team_seat(email, chatgpt_api=chatgpt_api)
seat_released = remove_status in ("removed", "already_absent")
# 上游用 STATUS_AUTH_PENDING; 本地 STATUS_AUTH_INVALID 同 literal "auth_invalid",
# 走 default_machine.transition 时映射到 AccountState.AUTH_PENDING.
final_status = STATUS_STANDBY if seat_released or not is_team_member else STATUS_AUTH_INVALID
update_account(email, status=final_status, _reason=f"auth_repair:{error_type}")
if discard_failed_repair and final_status == STATUS_STANDBY and (
not protected_local_credential or protected_replacement_override
):
_discard_auth_repair_failed_account_record(
email,
f"auth_repair_failed:{error_type}",
status=STATUS_STANDBY,
now=now,
)
return {
**state,
"status": final_status,
"seat_released": seat_released,
"release_attempted": release_attempted,
"remove_status": remove_status,
"protected_local_credential": protected_local_credential,
"protected_replacement_override": protected_replacement_override,
}
def _login_codex_with_result(
email: str,
password: str,
*,
mail_client=None,
max_attempts: int = 3,
signup_profile: SignupProfile | None = None,
pre_signed_in_cookies: list | None = None,
playwright_proxy_url: str | None = None,
) -> dict:
"""Run Codex OAuth with the explicit result wrapper used by auth repair.
This helper is intentionally isolated from `cmd_check` seat-release policy.
It normalizes old bundle/None callers, current `return_result=True` callers,
and rejected non-Team bundles into one retryable result shape.
"""
max_attempts = max(1, int(max_attempts))
def _reject_non_team(bundle: dict | None) -> dict | None:
if not isinstance(bundle, dict) or not bundle:
return None
plan_type = str(bundle.get("plan_type") or "").lower()
if plan_type == "team":
return None
return {
"ok": False,
"bundle": None,
"error_type": "non_team_plan",
"error_detail": f"登录后 plan={plan_type or 'unknown'},未进入 Team workspace",
"retryable": True,
}
def _result_from_legacy_bundle(bundle) -> dict:
non_team = _reject_non_team(bundle if isinstance(bundle, dict) else None)
if non_team:
return non_team
return {
"ok": bool(bundle),
"bundle": bundle if bundle else None,
"error_type": None if bundle else "login_failed",
"error_detail": None if bundle else "登录失败",
"retryable": False if bundle else True,
}
def _call_login(use_cookies: list | None) -> dict:
kwargs = {"mail_client": mail_client, "return_result": True}
if signup_profile is not None:
kwargs["signup_profile"] = signup_profile
if use_cookies is not None:
kwargs["pre_signed_in_cookies"] = use_cookies
if playwright_proxy_url is not None:
kwargs["playwright_proxy_url"] = playwright_proxy_url
try:
result = login_codex_via_browser(email, password, **kwargs)
except TypeError:
# Keep test doubles and older call signatures usable while current
# production code accepts the extended kwargs above.
result = login_codex_via_browser(
email,
password,
mail_client=mail_client,
return_result=True,
)
except Exception as exc:
return {
"ok": False,
"bundle": None,
"error_type": "exception",
"error_detail": str(exc),
"retryable": True,
}
if isinstance(result, dict) and "ok" in result:
non_team = _reject_non_team(result.get("bundle"))
if result.get("ok") and non_team:
return non_team
return result
return _result_from_legacy_bundle(result)
last_result = None
for attempt in range(1, max_attempts + 1):
# Captured ChatGPT/Auth cookies are a first-attempt shortcut. If they
# fail, later attempts fall back to the full login/OAuth path.
use_cookies = pre_signed_in_cookies if attempt == 1 else None
result = _call_login(use_cookies)
result["attempts"] = attempt
if result.get("ok"):
return result
last_result = result
error_type = result.get("error_type")
retryable = bool(result.get("retryable"))
if attempt >= max_attempts or not retryable or error_type in AUTH_REPAIR_SINGLE_ATTEMPT_FAILURE_TYPES:
return result
logger.warning(
"[Codex] %s 登录未完成(%s),准备在本轮重试第 %d/%d 次",
email,
_auth_repair_error_label(error_type),
attempt + 1,
max_attempts,
)
retry_delay = _oauth_retry_delay_seconds(error_type)
if retry_delay > 0:
logger.info(
"[Codex] %s 命中 %s,等待 %d 秒后重试以规避瞬时组织/出口抖动",
email,
_auth_repair_error_label(error_type),
retry_delay,
)
time.sleep(retry_delay)
return last_result or {
"ok": False,
"bundle": None,
"error_type": "login_failed",
"error_detail": "登录失败",
"retryable": True,
"attempts": max_attempts,
}
def invite_to_team(chatgpt_api, email, seat_type="default"):
"""邀请账号加入 Team(上游 `.upstream/manager.py:1209`,直接回贴).
旧账号默认走 default(ChatGPT+Codex 双席); 失败自动 fallback usage_based(仅 Codex).
返回 bool。本地 chatgpt_api.invite_member 内部已自带 default→usage_based fallback
+ errored_emails 处理 + 重试退避,但本 helper 仍提供:
- 模块级 helper(可被 reinvite_account 直接调用,不必走完整 OAuth 链路重邀);
- 简化的 bool 返回(调用方不关心 errored_emails 细节时).
"""
status, data = chatgpt_api.invite_member(email, seat_type=seat_type)
if status == 200 and isinstance(data, dict):
errored = data.get("errored_emails", [])
if errored:
err_msg = errored[0].get("error", "unknown") if isinstance(errored[0], dict) else "unknown"
logger.warning("[Team] 邀请 %s 被拒绝: %s", email, err_msg)
# default 失败则尝试 usage_based(本地 invite_member 已自带,但保持上游兜底语义)
if seat_type == "default":
logger.info("[Team] 尝试 usage_based 方式...")
return invite_to_team(chatgpt_api, email, seat_type="usage_based")
return False
return status == 200
# --------------------------------------------------------------------------
# end Round 12 S3 cherry-pick block
# --------------------------------------------------------------------------
# Team 子账号(非主号)硬上限。主号 + 2 子号 = 3 席,与 cmd_rotate / cmd_fill 默认 target=3 一致。
# 超过这个数说明有"假 standby / 假 personal"在 Team 里占席位(同步延迟或历史 bug 遗留),
# _reconcile_team_members 会按优先级 kick 多余者,永不让 Team 超出 2 子号。
TEAM_SUB_ACCOUNT_HARD_CAP = 2
def _find_team_auth_file(email):
"""在 auths 目录里找 codex-{email}-team-*.json。找到返回字符串路径,否则 None。
严格只接 -team-*.json:personal/plus/free 席位 auth 不能用于 Team 子号,
用错 plan 的 bundle 会被 OAuth 拒收(参考 codex-oauth personal 模式回退)。
"""
candidates: list[Path] = []
for auth_dir in _auth_search_dirs():
if auth_dir.exists():
candidates.extend(sorted(auth_dir.glob(f"codex-{email}-team-*.json")))
return str(candidates[0]) if candidates else None
def _is_quota_exhausted_snapshot(acc):
"""本地 last_quota 表明 5h 和周额度均满(pct=100)→ 耗尽未抛弃。"""
lq = acc.get("last_quota") or {}
if not lq:
return False
try:
return int(lq.get("primary_pct", 0)) >= 100 and int(lq.get("weekly_pct", 0)) >= 100
except (TypeError, ValueError):
return False
def _check_and_mark_exhausted(acc, email, _safe_update, result):
"""若本地 last_quota 显示耗尽,则标 EXHAUSTED + quota_exhausted_at,返回 True。
抽出来给两条 auth 补齐路径(STANDBY 错位 / ACTIVE 缺 auth)在补 auth 后调用,
防止 quota 已满的成员补完 auth 又被当成正常 active 留下,等到下一轮 cmd_check
才发现耗尽。
"""
if not _is_quota_exhausted_snapshot(acc):
return False
logger.warning(
"[对账] %s 补齐 auth 后 last_quota 显示耗尽,改标 EXHAUSTED(不立即 kick)",
email,
)
_safe_update(
acc.get("email"),
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
)
result["exhausted_marked"].append(email)
return True
def _reconcile_team_members(chatgpt_api=None, *, dry_run=False):
"""对账:Team 实际成员 vs 本地 accounts.json,修复一切不一致。
触发原因:历史 bug(OpenAI /users 同步延迟 → remove_from_team already_absent 误判 →
DELETE 被跳过)在 Team 里留下"假 standby""假 personal"遗留成员,占子号席位。
处理矩阵(第一轮):
Team里 + 本地 active + auth_file 存在 → 正常
Team里 + 本地 active + auth_file 缺失 → **残废**
RECONCILE_KICK_ORPHAN=true(默认): KICK
RECONCILE_KICK_ORPHAN=false: 标 STATUS_ORPHAN 等人工
Team里 + 本地 active + last_quota 5h/周 均满 → **耗尽未抛弃**
标 STATUS_EXHAUSTED + quota_exhausted_at=now,**不立即 kick**
(避免 token_revoked 风控,让 cmd_replace 走正常流程)
Team里 + 本地 pending → 升 active
Team里 + 本地 standby → **错位**。修正本地 active,
校验 / 补齐 auth_file 指向 auths/codex-{email}-team-*.json
Team里 + 本地 exhausted → 假 exhausted,KICK
Team里 + 本地 personal → fill-personal 本应踢,KICK
Team里 + 本地 auth_invalid → token 失效,KICK
Team里 + 本地 orphan → 已标记,保留原状
Team里 + 本地无记录 → **ghost**
RECONCILE_KICK_GHOST=true(默认): KICK;否则留给 sync_account_states
之后若 Team 非主号子账号仍 > TEAM_SUB_ACCOUNT_HARD_CAP,按 orphan → auth_invalid →
exhausted → personal → standby → 额度最低 active 顺序 kick 到 TEAM_SUB_ACCOUNT_HARD_CAP 为止。
dry_run=True 只诊断不动账户,用于 cmd_reconcile_dry_run。
"""
from autoteam.config import RECONCILE_KICK_GHOST, RECONCILE_KICK_ORPHAN
result = {
"kicked": [],
"flipped_to_active": [],
"orphan_kicked": [],
"orphan_marked": [],
"misaligned_fixed": [],
"exhausted_marked": [],
"ghost_kicked": [],
"ghost_seen": [],
"over_cap_kicked": [],
"dry_run": bool(dry_run),
}
account_id = get_chatgpt_account_id()
if not account_id:
logger.warning("[对账] account_id 为空,跳过对账")
return result
need_stop = False
if not chatgpt_api or not _chatgpt_session_ready(chatgpt_api):
try:
chatgpt_api = ChatGPTTeamAPI()
chatgpt_api.start()
need_stop = True
except Exception as exc:
logger.warning("[对账] 无法启动 ChatGPTTeamAPI,跳过对账: %s", exc)
return result
def _safe_kick(email_to_kick):
if dry_run:
logger.info("[对账/dry-run] 将会 KICK %s(未执行)", email_to_kick)
return "dry_run"
try:
return remove_from_team(chatgpt_api, email_to_kick, return_status=True)
except Exception as exc:
logger.error("[对账] KICK %s 抛异常: %s", email_to_kick, exc)
return "error"
def _safe_update(email_to_update, **fields):
if dry_run:
logger.info("[对账/dry-run] update_account(%s, %s)(未执行)", email_to_update, fields)
return
update_account(email_to_update, **fields)
try:
path = f"/backend-api/accounts/{account_id}/users"
resp = chatgpt_api._api_fetch("GET", path)
if resp.get("status") != 200:
logger.warning("[对账] /users 返回 status=%s,跳过", resp.get("status"))
return result
try:
data = json.loads(resp.get("body") or "{}")
except Exception as exc:
logger.warning("[对账] 解析 /users body 失败: %s", exc)
return result
members = data.get("items", data.get("users", data.get("members", [])))
accounts = load_accounts()
by_email = {(a.get("email") or "").lower(): a for a in accounts}
# 收集 Team 里非主号成员
team_subs = []
for m in members:
email = (m.get("email") or "").lower()
if not email or _is_main_account_email(email):
continue
team_subs.append((email, m))
# 第一轮:按状态对账
for email, _m in team_subs:
acc = by_email.get(email)
if not acc:
# ghost: workspace 有 + 本地完全无记录
result["ghost_seen"].append(email)
if RECONCILE_KICK_GHOST:
logger.warning("[对账] ghost 成员 %s(本地无记录),KICK", email)
rs = _safe_kick(email)
if rs in ("removed", "already_absent", "dry_run"):
result["ghost_kicked"].append(email)
else:
logger.info(
"[对账] ghost 成员 %s,RECONCILE_KICK_GHOST=false,留给 sync 补录",
email,
)
continue
status = acc.get("status")
if status == STATUS_PENDING:
logger.info("[对账] %s pending → active(Team 里已存在)", email)
# 同步当前 workspace 指纹,防止下轮 sync_account_states 把它误打 standby
_safe_update(acc.get("email"), status=STATUS_ACTIVE, workspace_account_id=account_id)
result["flipped_to_active"].append(email)
continue
if status == STATUS_STANDBY:
# 错位:workspace 是事实来源,本地 standby 是陈旧状态
logger.warning("[对账] %s 错位(workspace=active 本地=standby),修正 active", email)
auth_path = acc.get("auth_file")
if not auth_path or not Path(auth_path).exists():
found = _find_team_auth_file(email)
if found:
logger.info("[对账] %s 补齐 auth_file=%s", email, found)
_safe_update(
acc.get("email"),
status=STATUS_ACTIVE,
auth_file=found,
workspace_account_id=account_id,
)
result["misaligned_fixed"].append(email)
# fallthrough:补齐 auth 后仍要做 quota 耗尽检查,
# 否则刚补完的 active 成员若 last_quota=0/0 会被漏标 EXHAUSTED
_check_and_mark_exhausted(acc, email, _safe_update, result)
else:
# 错位且找不到 auth → 实为残废,降级
logger.warning("[对账] %s 错位但无 auth 文件,降级为残废分支", email)
if RECONCILE_KICK_ORPHAN:
rs = _safe_kick(email)
if rs in ("removed", "already_absent", "dry_run"):
result["orphan_kicked"].append(email)
# KICK 成功后必须同步本地状态,否则下次 fill 仍按 active 计数(回归 bug)
_safe_update(acc.get("email"), status=STATUS_AUTH_INVALID)
else:
_safe_update(acc.get("email"), status=STATUS_ORPHAN)
result["orphan_marked"].append(email)
else:
_safe_update(acc.get("email"), status=STATUS_ACTIVE, workspace_account_id=account_id)
result["misaligned_fixed"].append(email)
_check_and_mark_exhausted(acc, email, _safe_update, result)
continue
if status == STATUS_ACTIVE:
# 残废检查:workspace + 本地 active 但 auth_file 缺失
auth_path = acc.get("auth_file")
if not auth_path or not Path(auth_path).exists():
found = _find_team_auth_file(email)
if found:
logger.info("[对账] %s active + auth_file=null,发现 %s,补上", email, found)
_safe_update(acc.get("email"), auth_file=found)
# fallthrough:补 auth 后仍要 quota 耗尽检查,避免漏标 EXHAUSTED
_check_and_mark_exhausted(acc, email, _safe_update, result)
continue
if RECONCILE_KICK_ORPHAN:
logger.warning("[对账] 残废 %s(workspace 有 + 本地 auth 缺失),KICK", email)
rs = _safe_kick(email)
if rs in ("removed", "already_absent", "dry_run"):
result["orphan_kicked"].append(email)
# KICK 成功后必须同步本地状态,否则下次 fill 仍按 active 计数(回归 bug)
_safe_update(acc.get("email"), status=STATUS_AUTH_INVALID)
else:
logger.warning("[对账] 残废 %s,RECONCILE_KICK_ORPHAN=false,标 STATUS_ORPHAN", email)
_safe_update(acc.get("email"), status=STATUS_ORPHAN)
result["orphan_marked"].append(email)
continue
# 耗尽未抛弃: last_quota 5h/周 均 100% → 标 EXHAUSTED,**不**立即 kick
if _is_quota_exhausted_snapshot(acc):
logger.warning(
"[对账] %s active + last_quota 0/0(耗尽未抛弃),标 EXHAUSTED(不立即 kick)",
email,
)
_safe_update(
acc.get("email"),
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
)
result["exhausted_marked"].append(email)
continue
# 正常 active
continue
if status == STATUS_ORPHAN:
# 上一轮已经标 orphan,等人工补 auth,不反复 kick
logger.debug("[对账] %s 已标 STATUS_ORPHAN,跳过", email)
continue
if status in (STATUS_EXHAUSTED, STATUS_PERSONAL, STATUS_AUTH_INVALID):
logger.warning("[对账] %s 本地=%s 但 Team 里仍挂着,KICK", email, status)
rs = _safe_kick(acc.get("email"))
if rs in ("removed", "already_absent", "dry_run"):
# standby/exhausted 保留原状态,personal 也保留(下次 fill-personal 才真处理)
result["kicked"].append(email)
elif rs != "error":
logger.error("[对账] KICK %s 失败: status=%s", email, rs)
continue
# 第二轮:硬上限 TEAM_SUB_ACCOUNT_HARD_CAP 子号。
# 非 dry_run:kick 完第一轮再 GET /users 拿最新数;
# dry_run:**不**重新 GET /users —— 否则刚"假装 KICK"的 ghost 仍在 workspace 真实
# 成员里,会被算进 remaining_subs,over_cap 数量被高估。改用第一轮 team_subs
# 减去本轮已被标 KICK 的 email,模拟 dry_run 后的 remaining。
if dry_run:
kicked_in_round_one = set(result["kicked"] + result["orphan_kicked"] + result["ghost_kicked"])
remaining_subs = [email for email, _m in team_subs if email not in kicked_in_round_one]
else:
resp2 = chatgpt_api._api_fetch("GET", path)
if resp2.get("status") == 200:
try:
data2 = json.loads(resp2.get("body") or "{}")
members2 = data2.get("items", data2.get("users", data2.get("members", [])))
except Exception:
members2 = members
else:
members2 = members
remaining_subs = [
(m.get("email") or "").lower()
for m in members2
if (m.get("email") or "") and not _is_main_account_email(m.get("email"))
]
excess = len(remaining_subs) - TEAM_SUB_ACCOUNT_HARD_CAP
if excess > 0:
logger.warning(
"[对账%s] Team 子号 %d > 硬上限 %d,按优先级 kick %d 个",
"/dry-run" if dry_run else "",
len(remaining_subs),
TEAM_SUB_ACCOUNT_HARD_CAP,
excess,
)
# dry_run 下复用第一轮 by_email,不再读 accounts.json,保持只读纯净
if dry_run:
acc_map = by_email
else:
accounts_now = load_accounts()
acc_map = {(a.get("email") or "").lower(): a for a in accounts_now}
def _priority(email):
# 优先级越小越先 kick
a = acc_map.get(email)
if not a:
# ghost(本地无记录):仅当 KICK_GHOST=True 才优先 kick;
# 关闭时排到最后,避免绕过 RECONCILE_KICK_GHOST 开关
return (0, 0) if RECONCILE_KICK_GHOST else (99, 0)
st = a.get("status")
if st == STATUS_ORPHAN:
return (1, 0)
if st == STATUS_AUTH_INVALID:
return (1, 1)
if st == STATUS_EXHAUSTED:
return (2, 0)
if st == STATUS_PERSONAL:
return (3, 0)
if st == STATUS_STANDBY:
return (4, 0)
if st == STATUS_ACTIVE:
# active 按额度剩余从低到高 kick
lq = a.get("last_quota") or {}
p_remain = 100 - lq.get("primary_pct", 0)
return (5, p_remain)
return (6, 0)
victims = sorted(remaining_subs, key=_priority)[:excess]
if dry_run:
# 只预测,不调 _safe_kick(它内部 dry_run 也只是 log),不写 acc 状态
for email in victims:
logger.info(
"[对账/dry-run] 超员预测 kick %s (priority=%s)",
email,
_priority(email),
)
result["over_cap_kicked"].append(email)
else:
for email in victims:
try:
remove_status = remove_from_team(chatgpt_api, email, return_status=True)
if remove_status in ("removed", "already_absent"):
acc = acc_map.get(email)
if acc and acc.get("status") == STATUS_ACTIVE:
update_account(acc.get("email"), status=STATUS_STANDBY)
result["over_cap_kicked"].append(email)
logger.info("[对账] 超员 kick %s (priority=%s)", email, _priority(email))
else:
logger.error("[对账] 超员 kick %s 失败: status=%s", email, remove_status)
except Exception as exc:
logger.error("[对账] 超员 kick %s 抛异常: %s", email, exc)
# Round 9 RT-3 — 复用 chatgpt_api 调 retroactive helper(走 5min cache,失败仅 warning)。
# spec/shared/master-subscription-health.md v1.1 §11.3。
try:
from autoteam.master_health import _apply_master_degraded_classification
retro = _apply_master_degraded_classification(chatgpt_api=chatgpt_api, dry_run=dry_run)
result["master_degraded_retroactive"] = retro
except Exception as exc:
logger.warning("[对账] retroactive helper 异常: %s", exc)
result["master_degraded_retroactive"] = {"errors": [{"stage": "rt3", "error": str(exc)}]}
finally:
if need_stop:
try:
chatgpt_api.stop()
except Exception:
pass
return result
def _probe_kicked_account(acc):
"""SPEC-2 FR-E1 — 单账号探测:wham/usage 401/403 → 被踢;ok / 其他 → 自然待机/未确认.
返回 status_str(check_codex_quota 5 分类之一)或 None(无法探测)。
任何异常吞掉返回 None,让上游降级 STANDBY 旧行为。
NB: 该函数会从 auth_file 读 access_token,探测时间长(网络往返 + 5s 超时),
上游必须用 ThreadPoolExecutor 并发调用,否则 N 个账号串行会让 sync 超 30s。
"""
auth_file = acc.get("auth_file")
if not auth_file:
return None
try:
path_obj = Path(auth_file)
if not path_obj.exists():
return None
data = json.loads(path_obj.read_text(encoding="utf-8"))
access_token = data.get("access_token") or (data.get("tokens") or {}).get("access_token")
if not access_token:
return None
status, _ = check_codex_quota(access_token)
return status
except Exception as exc:
logger.debug("[同步] _probe_kicked_account(%s) 异常: %s", acc.get("email"), exc)
return None
def sync_account_states(chatgpt_api=None):
"""根据 Team 实际成员列表同步本地账号状态"""
account_id = get_chatgpt_account_id()
if not account_id:
return
accounts = load_accounts()
team_emails = set()
# 获取 Team 实际成员
need_stop = False
if not chatgpt_api or not _chatgpt_session_ready(chatgpt_api):
try:
chatgpt_api = ChatGPTTeamAPI()
chatgpt_api.start()
need_stop = True
except Exception:
# Playwright 不可用(event loop 冲突等),跳过同步
return
try:
path = f"/backend-api/accounts/{account_id}/users"
result = chatgpt_api._api_fetch("GET", path)
if result["status"] != 200:
return
data = json.loads(result["body"])
members = data.get("items", data.get("users", data.get("members", [])))
team_emails = {m.get("email", "").lower() for m in members}
finally:
if need_stop:
chatgpt_api.stop()
# 对照更新状态
from autoteam.config import CLOUDMAIL_DOMAIN
domain_suffix = CLOUDMAIL_DOMAIN.lstrip("@") if CLOUDMAIL_DOMAIN else ""
changed = False
local_email_set = {a["email"].lower() for a in accounts}
# Round 12 wire-up (M1) — 把"直写 acc['status']=X 之后批量 save_accounts"改成
# 实时 update_account → default_machine.transition. 收益:
# * state_log.jsonl 落每条变更
# * 事件总线广播 → F2 SSE 推送 sync 引起的 transition
# * IllegalTransition 合法性兜底
# 注: update_account 是 RMW + 锁内 save,O(N) 次 IO,但 sync 不在 hot path
# 而是 cmd_rotate 的 1/5 阶段,延迟开销可接受.
def _transition_status(email: str, new_status: str, **extra) -> None:
"""走状态机的 update_account 包装. 出错降级 warning,不打断 sync."""
try:
update_account(email, status=new_status, **extra)
except Exception as exc:
logger.warning(
"[同步] update_account(%s -> %s) 抛异常(忽略): %s",
email, new_status, exc,
)
# SPEC-2 FR-E1~E4 — 区分人工踢出 vs 自然待机:
# 不在 Team 的 active 号 → wham/usage 401/403 → AUTH_INVALID(被踢);ok / network → STANDBY(自然)。
# 探测有并发上限 5 + 单调用 5s + 整体 30s + 30 分钟 last_quota_check_at 去重。
from autoteam.runtime_config import get_sync_probe_concurrency, get_sync_probe_cooldown_minutes
cooldown_secs = max(60, int(get_sync_probe_cooldown_minutes()) * 60)
concurrency = max(1, int(get_sync_probe_concurrency()))
need_probe = []
now_ts = time.time()
for acc in accounts:
email = acc["email"].lower()
in_team = email in team_emails
if in_team and acc["status"] in (STATUS_STANDBY, STATUS_PENDING, "auth_pending"):
# Round 12 wire-up M1 — go through state machine (default_machine.transition).
ws_id = account_id or None
protect_team_seat = _has_auth_file(acc)
_transition_status(
acc["email"], STATUS_ACTIVE,
workspace_account_id=ws_id,
protect_team_seat=protect_team_seat,
remote_seen_at=now_ts,
_reason="sync_account_states:in_team",
)
# 内存里也同步,后续 need_probe / domain_suffix 分支基于刷新后的状态判断
acc["status"] = STATUS_ACTIVE
acc["remote_seen_at"] = now_ts
if protect_team_seat:
acc["protect_team_seat"] = True
if account_id:
acc["workspace_account_id"] = account_id
changed = True
elif not in_team and acc["status"] == STATUS_ACTIVE:
# 守卫(Bug 4A):账号记录的 workspace_account_id 与当前 workspace 不一致 →
# 这是母号切换造成的"前母号留下号",不是真的被踢出。保留原 active,
# 不要无脑刷成 standby(否则 sync_to_cpa 会把还可用的 token 文件抹掉)。
# 仅在两个 ID 都存在且不同时跳过 flip;字段缺失/legacy 记录走原行为。
acc_ws = acc.get("workspace_account_id")
if acc_ws and account_id and acc_ws != account_id:
logger.warning(
"[同步] %s 在当前 workspace 不可见,但其 workspace_account_id=%s ≠ 当前 %s(母号切换遗留),保留 active 不 flip",
acc["email"],
acc_ws,
account_id,
)
continue
# FR-E3 探测去重:30 分钟内不重复探测,直接降级 STANDBY 走旧行为
last_check = float(acc.get("last_quota_check_at") or 0)
if (now_ts - last_check) < cooldown_secs:
_transition_status(
acc["email"], STATUS_STANDBY,
_reason="sync_account_states:probe_cooldown",
)
acc["status"] = STATUS_STANDBY
changed = True
continue
# 收集到批量待探测列表,后面用 ThreadPoolExecutor 并发跑
if acc.get("auth_file"):
need_probe.append(acc)
else:
# 没 auth_file 无法探测 → 直接降级 STANDBY(等用户重 OAuth)
_transition_status(
acc["email"], STATUS_STANDBY,
_reason="sync_account_states:no_auth_file",
)
acc["status"] = STATUS_STANDBY
changed = True
# FR-E2 并发探测被踢识别(只对 need_probe 中的账号)
if need_probe:
import concurrent.futures
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
future_map = {ex.submit(_probe_kicked_account, acc): acc for acc in need_probe}
done, not_done = concurrent.futures.wait(
future_map.keys(), timeout=30.0,
return_when=concurrent.futures.ALL_COMPLETED,
)
for fut in done:
acc = future_map[fut]
try:
status_str = fut.result(timeout=0.1)
except Exception:
status_str = None
finalize_ts = time.time()
acc["last_quota_check_at"] = finalize_ts
if status_str == "auth_error":
_transition_status(
acc["email"], STATUS_AUTH_INVALID,
last_kicked_at=finalize_ts,
last_quota_check_at=finalize_ts,
_reason="sync_account_states:probe_auth_error",
)
acc["status"] = STATUS_AUTH_INVALID
acc["last_kicked_at"] = finalize_ts
logger.warning(
"[同步] %s wham 401/403 → AUTH_INVALID(判定人工踢出)",
acc["email"],
)
elif status_str == "no_quota":
# 不会自然恢复
_transition_status(
acc["email"], STATUS_AUTH_INVALID,
last_quota_check_at=finalize_ts,
_reason="sync_account_states:probe_no_quota",
)
acc["status"] = STATUS_AUTH_INVALID
logger.warning("[同步] %s wham no_quota → AUTH_INVALID", acc["email"])
else:
# ok / exhausted / network_error / None → 自然待机
_transition_status(
acc["email"], STATUS_STANDBY,
last_quota_check_at=finalize_ts,
_reason="sync_account_states:probe_natural_standby",
)
acc["status"] = STATUS_STANDBY
changed = True
# 超时未完成的:保持原 ACTIVE,等下轮(避免误标)
for fut in not_done:
acc = future_map[fut]
logger.warning("[同步] %s 探测超时,保留 ACTIVE 等下轮", acc["email"])
fut.cancel()
except Exception as exc:
logger.warning("[同步] 探测段抛异常,降级所有 need_probe 为 STANDBY: %s", exc)
for acc in need_probe:
_transition_status(
acc["email"], STATUS_STANDBY,
_reason="sync_account_states:probe_exception",
)
acc["status"] = STATUS_STANDBY
changed = True
# Team 中有我们域名但本地无记录的成员 → 自动添加
if domain_suffix:
for email in team_emails:
if _is_main_account_email(email):
continue
if domain_suffix in email and email not in local_email_set:
accounts.append(
{
"email": email,
"password": "",
"cloudmail_account_id": None,
"status": STATUS_ACTIVE,
"workspace_account_id": account_id or None,
"auth_file": None,
"quota_exhausted_at": None,
"quota_resets_at": None,
"created_at": time.time(),
"last_active_at": None,
}
)
changed = True
logger.info("[同步] 发现 Team 中新成员: %s(已添加到本地)", email)
# auths 目录中有认证文件但本地无记录的 → 自动添加为 standby
from autoteam.codex_auth import AUTH_DIR
local_email_set = {a["email"].lower() for a in accounts} # 刷新一下
if AUTH_DIR.exists():
for auth_file in AUTH_DIR.glob("codex-*.json"):
try:
auth_data = json.loads(read_text(auth_file))
email = auth_data.get("email", "").lower()
if not email or email in local_email_set or _is_main_account_email(email):
continue
# 判断是否在 Team 中
in_team = email in team_emails
status = STATUS_ACTIVE if in_team else STATUS_STANDBY
recovered = {
"email": email,
"password": "",
"cloudmail_account_id": None,
"status": status,
"auth_file": str(auth_file),
"quota_exhausted_at": None,
"quota_resets_at": None,
"created_at": time.time(),
"last_active_at": None,
}
if in_team:
recovered["protect_team_seat"] = True
if account_id:
recovered["workspace_account_id"] = account_id
accounts.append(recovered)
local_email_set.add(email)
changed = True
logger.info("[同步] 从 auths 目录恢复账号: %s(%s)", email, status)
except Exception:
continue
if changed:
save_accounts(accounts)
# Round 9 RT-4 — sync 收尾跑一次 retroactive helper(走 5min cache,不发 HTTP)。
# spec/shared/master-subscription-health.md v1.1 §11.3。失败仅 warning 不影响 sync。
try:
from autoteam.master_health import _apply_master_degraded_classification
retro = _apply_master_degraded_classification()
if retro and (retro.get("marked_grace") or retro.get("marked_standby") or retro.get("reverted_active")):
logger.info(
"[同步] retroactive: GRACE %d / STANDBY %d / 撤回 ACTIVE %d",
len(retro.get("marked_grace") or []),
len(retro.get("marked_standby") or []),
len(retro.get("reverted_active") or []),
)
except Exception as exc:
logger.warning("[同步] retroactive helper 异常(不影响 sync 主流程): %s", exc)
def _print_status_table(accounts, quota_cache=None):
"""打印账号状态表格(使用 rich)"""
from rich.console import Console
from rich.table import Table
from rich.text import Text
if quota_cache is None:
quota_cache = {}
console = Console(width=120)
table = Table(
title="AutoTeam 账号状态",
show_header=True,
header_style="bold cyan",
border_style="dim",
title_style="bold white",
padding=(0, 1),
expand=True,
)
table.add_column("#", style="dim", width=3, justify="right")
table.add_column("邮箱", style="white", no_wrap=True)
table.add_column("状态", justify="center", width=10)
table.add_column("5h 剩余", justify="right", width=8)
table.add_column("周 剩余", justify="right", width=8)
table.add_column("5h 重置", justify="center", width=12)
table.add_column("周 重置", justify="center", width=12)
STATUS_STYLE = {
STATUS_ACTIVE: ("bold green", "● active"),
STATUS_EXHAUSTED: ("bold red", "✗ used up"),
STATUS_STANDBY: ("yellow", "○ standby"),
STATUS_PENDING: ("dim", "… pending"),
}
for idx, acc in enumerate(accounts, 1):
email = acc["email"]
qi = quota_cache.get(email) or acc.get("last_quota")
status = acc["status"]
style, status_label = STATUS_STYLE.get(status, ("dim", status))
status_text = Text(status_label, style=style)
if qi:
p_val = 100 - qi.get("primary_pct", 0)
w_val = 100 - qi.get("weekly_pct", 0)
p_pct = Text(f"{p_val}%", style="green" if p_val > 30 else "yellow" if p_val > 0 else "red")
w_pct = Text(f"{w_val}%", style="green" if w_val > 30 else "yellow" if w_val > 0 else "red")
p_reset = (
time.strftime("%m-%d %H:%M", time.localtime(qi["primary_resets_at"]))
if qi.get("primary_resets_at")
else "-"
)
w_reset = (
time.strftime("%m-%d %H:%M", time.localtime(qi["weekly_resets_at"]))
if qi.get("weekly_resets_at")
else "-"
)
else:
p_pct = Text("-", style="dim")
w_pct = Text("-", style="dim")
p_reset = "-"
w_reset = "-"
table.add_row(
str(idx),
email,
status_text,
p_pct,
w_pct,
Text(p_reset, style="dim"),
Text(w_reset, style="dim"),
)
console.print()
console.print(table)
# 统计摘要
active = sum(1 for a in accounts if a["status"] == STATUS_ACTIVE)
standby = sum(1 for a in accounts if a["status"] == STATUS_STANDBY)
exhausted = sum(1 for a in accounts if a["status"] == STATUS_EXHAUSTED)
console.print(
f" [green]● 活跃 {active}[/] "
f"[yellow]○ 待命 {standby}[/] "
f"[red]✗ 用完 {exhausted}[/] "
f"[dim]总计 {len(accounts)}[/]",
)
def cmd_status():
"""显示所有账号状态(先同步 Team 实际状态,active 账号实时查询额度)"""
logger.info("[状态] 同步 Team 实际状态...")
sync_account_states()
accounts = load_accounts()
if not accounts:
logger.info("[状态] 暂无账号")
return
# active 账号实时查询额度
quota_cache = {}
active_count = sum(
1 for a in accounts if a["status"] == STATUS_ACTIVE and a.get("auth_file") and Path(a["auth_file"]).exists()
)
if active_count:
logger.info("[状态] 查询 %d 个 active 账号额度...", active_count)
for acc in accounts:
if acc["status"] == STATUS_ACTIVE and acc.get("auth_file") and Path(acc["auth_file"]).exists():
auth_data = json.loads(read_text(Path(acc["auth_file"])))
access_token = auth_data.get("access_token")
if access_token:
status, info = check_codex_quota(access_token)
if status == "ok" and isinstance(info, dict):
quota_cache[acc["email"]] = info
elif status == "exhausted":
quota_info = quota_result_quota_info(info)
if quota_info:
quota_cache[acc["email"]] = quota_info
_print_status_table(accounts, quota_cache)
def _check_and_refresh(acc):
"""检查单个账号额度,401 时自动刷新 token。返回 (status_str, info)
info: exhausted 时为 exhausted_info,ok 时为 quota_info dict
使用 auth_file 里保存的 account_id 查询 —— Team/Personal/Free 号各自绑定的
account_id 不同,不能一律 fallback 到主号 team id,否则 Personal 号查到的会是
主号 Team 的额度(不准确,且被踢出 Team 后还会 401)。
"""
email = acc["email"]
auth_file = acc.get("auth_file")
if not auth_file or not Path(auth_file).exists():
return "no_auth", None
auth_data = json.loads(read_text(Path(auth_file)))
access_token = auth_data.get("access_token")
rt = auth_data.get("refresh_token")
acc_id = auth_data.get("account_id") or None
if not access_token:
return "no_auth", None
status, info = check_codex_quota(access_token, account_id=acc_id)
# token 过期,尝试刷新
if status == "auth_error" and rt:
logger.info("[%s] token 过期,尝试刷新...", email)
new_tokens = refresh_access_token(rt)
if new_tokens:
auth_data["access_token"] = new_tokens["access_token"]
auth_data["refresh_token"] = new_tokens.get("refresh_token", rt)
auth_data["last_refresh"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
write_text(Path(auth_file), json.dumps(auth_data, indent=2))
logger.info("[%s] token 已刷新,重新检查额度...", email)
status, info = check_codex_quota(new_tokens["access_token"], account_id=acc_id)
else:
logger.error("[%s] token 刷新失败", email)
return status, info
STANDBY_PROBE_INTERVAL_SEC = 1.5 # 每个 standby 账号探测间隔,限速避免群访 OpenAI 触发风控
STANDBY_PROBE_DEDUP_SEC = 24 * 3600 # 24h 内已探测过的 standby 跳过
def cmd_check(
include_standby: bool = False,
*,
force_auth_repair: bool = False,
preserve_low_active: bool = False,
preserved_low_accounts=None,
):
"""检查 active 账号的额度,无认证文件或 auth_error 的自动重新登录 Codex。
参数:
include_standby: True 时额外探测 standby 池每个账号的 quota(限速 + 24h 去重),
401/403 的标记为 STATUS_AUTH_INVALID。默认 False 保持向后兼容。
"""
from autoteam.config import AUTO_CHECK_THRESHOLD
# API 运行时配置优先(前端可修改)
try:
from autoteam.api import _auto_check_config
threshold = _auto_check_config.get("threshold", AUTO_CHECK_THRESHOLD)
except ImportError:
threshold = AUTO_CHECK_THRESHOLD
def _check_personal_accounts(threshold):
"""Personal 号只拍快照,不动状态:它不参与轮转,但用户希望能在 UI 看到剩余额度。
与 active 分支的差异:
- 不写 STATUS_EXHAUSTED(Personal 额度用完只影响 Codex 使用,不触发轮换)
- 不自动重新登录(personal OAuth 是人工触发,没有可靠的自动补救路径)
- auth_error 时仅记录日志,保留旧的 last_quota 供 UI 显示(别抹掉历史数据)
"""
from autoteam.accounts import load_accounts as _reload
personal_accs = [
a for a in _reload() if a["status"] == STATUS_PERSONAL and not _is_main_account_email(a.get("email"))
]
personal_with_auth = [a for a in personal_accs if a.get("auth_file") and Path(a["auth_file"]).exists()]
if not personal_with_auth:
return
logger.info("[检查] 检查 %d 个 personal 账号的额度...", len(personal_with_auth))
for acc in personal_with_auth:
email = acc["email"]
try:
status_str, info = _check_and_refresh(acc)
except Exception as exc:
logger.warning("[%s] personal 额度查询异常: %s", email, exc)
continue
if status_str == "ok" and isinstance(info, dict):
update_account(email, last_quota=info)
p_remain = 100 - info.get("primary_pct", 0)
w_remain = 100 - info.get("weekly_pct", 0)
p_reset = info.get("primary_resets_at", 0)
p_time = time.strftime("%m-%d %H:%M", time.localtime(p_reset)) if p_reset else "?"
logger.info(
"[%s] (personal) 5h剩余 %d%% (重置 %s) | 周剩余 %d%%",
email,
p_remain,
p_time,
w_remain,
)
elif status_str == "exhausted":
quota_info = quota_result_quota_info(info) or {}
if quota_info:
update_account(email, last_quota=quota_info)
window = info.get("window") if isinstance(info, dict) else ""
logger.warning(
"[%s] (personal) %s额度已用完",
email,
"周" if window == "weekly" else "5h和周" if window == "combined" else "5h",
)
elif status_str == "auth_error":
logger.warning(
"[%s] (personal) token 失效或账号无权访问 wham/usage(伪 personal 号被踢出 Team 后常见),保留旧快照",
email,
)
elif status_str == "network_error":
logger.warning("[%s] (personal) 额度查询遇到临时网络错误,保留旧快照,等下一轮", email)
# status_str == "no_auth" 已在 _check_and_refresh 里被 auth_file 判空挡掉
# 入口先跑一次对账:凡是"Team 里挂着但本地 standby/exhausted/personal"的遗留成员,
# 统一 kick。顺便把 Team 子号硬压到 TEAM_SUB_ACCOUNT_HARD_CAP 以内。
# 这里失败不影响后续额度检查(已有 try/except 包裹),避免对账异常把整个 check 打挂。
try:
recon = _reconcile_team_members()
if recon.get("kicked") or recon.get("over_cap_kicked") or recon.get("flipped_to_active"):
logger.info(
"[检查] 对账结果:kicked=%d, over_cap_kicked=%d, flipped_to_active=%d",
len(recon.get("kicked", [])),
len(recon.get("over_cap_kicked", [])),
len(recon.get("flipped_to_active", [])),
)
except Exception as exc:
logger.warning("[检查] 对账阶段抛异常(跳过,不影响额度检查): %s", exc)
accounts = load_accounts()
pending_accounts = [a for a in accounts if a["status"] == STATUS_PENDING]
if pending_accounts:
logger.info("[检查] 对账 %d 个 pending 账号...", len(pending_accounts))
chatgpt = None
mail_client = None
deleted_pending = 0
try:
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
members, invites = fetch_team_state(chatgpt)
team_emails = {(m.get("email", "") or "").lower() for m in members}
invite_emails = {(inv.get("email_address") or inv.get("email") or "").lower() for inv in invites}
for acc in pending_accounts:
email = acc["email"]
email_l = email.lower()
if email_l in team_emails:
logger.info("[检查] pending 账号已在 Team 中,转为 active: %s", email)
update_account(email, status=STATUS_ACTIVE, workspace_account_id=get_chatgpt_account_id() or None)
continue
if email_l in invite_emails:
logger.info("[检查] pending 账号仍存在远端邀请,保留: %s", email)
continue
logger.warning("[检查] pending 账号为失败孤儿,删除: %s", email)
if mail_client is None:
mail_client = CloudMailClient()
mail_client.login()
delete_managed_account(
email,
remove_remote=True,
remove_cloudmail=True,
sync_cpa_after=False,
chatgpt_api=chatgpt,
mail_client=mail_client,
remote_state=(members, invites),
)
deleted_pending += 1
except Exception as exc:
logger.warning("[检查] pending 对账失败,跳过本轮清理: %s", exc)
finally:
if chatgpt:
chatgpt.stop()
if deleted_pending:
logger.info("[检查] 已删除 %d 个失败 pending 账号", deleted_pending)
sync_to_cpa()
accounts = load_accounts()
all_active = [
a
for a in accounts
if a["status"] == STATUS_ACTIVE and not _is_main_account_email(a.get("email")) and not is_account_disabled(a)
]
auth_pending_accounts = [
a
for a in accounts
if _is_auth_repair_pending_status(a.get("status"))
and not _is_main_account_email(a.get("email"))
and not is_account_disabled(a)
]
# 区分:有认证文件的 vs 无认证文件的
active_with_auth = []
no_auth_list = []
skipped_repairs = []
mail_domain = get_mail_domain()
mail_domain_suffix = mail_domain.lstrip("@") if mail_domain else ""
for a in all_active:
if _has_auth_file(a):
active_with_auth.append(a)
else:
if _can_attempt_auth_repair(a, mail_domain_suffix):
skip_reason = _auth_repair_skip_reason(a, force=force_auth_repair)
if skip_reason:
skipped_repairs.append((a["email"], skip_reason))
continue
no_auth_list.append(a)
for a in auth_pending_accounts:
if _has_auth_file(a):
active_with_auth.append(a)
elif _can_attempt_auth_repair(a, mail_domain_suffix):
skip_reason = _auth_repair_skip_reason(a, force=force_auth_repair)
if skip_reason:
skipped_repairs.append((a["email"], skip_reason))
continue
no_auth_list.append(a)
if skipped_repairs:
logger.info("[检查] 跳过 %d 个处于冷却/暂停中的认证修复账号:", len(skipped_repairs))
for email, reason in skipped_repairs:
logger.info("[检查] %s(%s)", email, reason)
if not active_with_auth and not no_auth_list:
logger.info("[检查] 没有可检查或可修复的账号")
return []
# 检查有认证文件的账号额度
exhausted_list = []
auth_error_list = []
if active_with_auth:
logger.info("[检查] 检查 %d 个 active 账号的额度...", len(active_with_auth))
for acc in active_with_auth:
email = acc["email"]
was_auth_pending = _is_auth_repair_pending_status(acc.get("status"))
status_str, info = _check_and_refresh(acc)
if status_str == "ok":
if isinstance(info, dict):
p_remain = 100 - info.get("primary_pct", 0)
w_remain = 100 - info.get("weekly_pct", 0)
p_reset = info.get("primary_resets_at", 0)
w_reset = info.get("weekly_resets_at", 0)
p_time = time.strftime("%m-%d %H:%M", time.localtime(p_reset)) if p_reset else "?"
w_time = time.strftime("%m-%d %H:%M", time.localtime(w_reset)) if w_reset else "?"
# 保存最新额度快照,供 status 离线展示
update_account(email, last_quota=info)
# 低于阈值视为用完
if p_remain < threshold:
if preserve_low_active and not was_auth_pending:
if preserved_low_accounts is not None:
preserved_low_accounts.append(
{
"email": email,
"remaining": p_remain,
"quota": info,
}
)
logger.warning(
"[%s] 5h剩余 %d%% < %d%%,先移后补模式暂不标记 exhausted (重置 %s)",
email,
p_remain,
threshold,
p_time,
)
continue
resets_at = p_reset or (time.time() + 18000)
logger.warning(
"[%s] 5h剩余 %d%% < %d%%,标记为 exhausted (重置 %s)", email, p_remain, threshold, p_time
)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
else:
_auth_repair_reset(email)
if was_auth_pending:
update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
logger.info(
"[%s] 认证已恢复 - 5h剩余: %d%% (重置 %s) | 周剩余: %d%% (重置 %s)",
email,
p_remain,
p_time,
w_remain,
w_time,
)
continue
logger.info(
"[%s] 额度可用 - 5h剩余: %d%% (重置 %s) | 周剩余: %d%% (重置 %s)",
email,
p_remain,
p_time,
w_remain,
w_time,
)
else:
_auth_repair_reset(email)
logger.info("[%s] 额度可用", email)
elif status_str == "exhausted":
quota_info = quota_result_quota_info(info) or {}
resets_at = quota_result_resets_at(info) or int(time.time() + 18000)
if quota_info:
update_account(email, last_quota=quota_info)
p_remain = max(0, 100 - quota_info.get("primary_pct", 0))
w_remain = max(0, 100 - quota_info.get("weekly_pct", 0))
window = info.get("window") if isinstance(info, dict) else ""
logger.warning(
"[%s] %s额度已用完 - 5h剩余: %d%% | 周剩余: %d%%",
email,
"周" if window == "weekly" else "5h和周" if window == "combined" else "5h",
p_remain,
w_remain,
)
else:
logger.warning("[%s] 额度已用完", email)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
elif status_str == "auth_error":
# token 失效,先看历史额度(重置时间已过的不算)
lq = acc.get("last_quota")
if lq:
exhausted_info = _pending_historical_exhausted_info(lq)
if exhausted_info:
resets_at = quota_result_resets_at(exhausted_info) or int(time.time() + 18000)
window_label = _quota_window_label(exhausted_info.get("window"))
logger.warning("[%s] token 失效,但历史%s额度未恢复,直接标记 exhausted", email, window_label)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
continue
p_resets = lq.get("primary_resets_at", 0)
if not (p_resets and time.time() >= p_resets):
# 重置时间未过,历史数据有效
p_remain = 100 - lq.get("primary_pct", 0)
if p_remain < threshold:
resets_at = p_resets or (time.time() + 18000)
logger.warning(
"[%s] token 失效,历史额度 %d%% < %d%%,直接标记 exhausted", email, p_remain, threshold
)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
continue
else:
logger.info("[%s] token 失效但 5h 重置时间已过,需重新登录验证", email)
logger.warning("[%s] 认证失败,需要重新登录 Codex", email)
skip_reason = _auth_repair_skip_reason(acc, force=force_auth_repair)
if skip_reason:
skipped_repairs.append((email, skip_reason))
else:
auth_error_list.append(acc)
elif status_str == "no_auth":
skip_reason = _auth_repair_skip_reason(acc, force=force_auth_repair)
if skip_reason:
skipped_repairs.append((email, skip_reason))
else:
auth_error_list.append(acc)
elif status_str == "network_error":
historical_low = _historical_low_quota_info(acc, threshold)
if historical_low:
quota_info = quota_result_quota_info(historical_low) or acc.get("last_quota")
remaining = int(historical_low.get("remaining", 0) or 0)
resets_at = quota_result_resets_at(historical_low) or int(time.time() + 18000)
reset_time = time.strftime("%m-%d %H:%M", time.localtime(resets_at)) if resets_at else "?"
if preserve_low_active and not was_auth_pending:
if preserved_low_accounts is not None:
preserved_low_accounts.append(
{
"email": email,
"remaining": remaining,
"quota": quota_info,
}
)
logger.warning(
"[%s] 额度接口暂时不可达,但历史 5h 剩余 %d%% < %d%%,先移后补模式纳入轮换候选 (重置 %s)",
email,
remaining,
threshold,
reset_time,
)
continue
logger.warning(
"[%s] 额度接口暂时不可达,但历史 5h 剩余 %d%% < %d%%,标记为 exhausted (重置 %s)",
email,
remaining,
threshold,
reset_time,
)
update_account(
email,
status=STATUS_EXHAUSTED,
last_quota=quota_info,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
continue
logger.warning("[%s] 额度接口暂时不可达,保留当前凭证和席位状态,等待下一轮复查", email)
# 无认证文件的 Team 内账号也需要重新登录
if no_auth_list:
logger.info("[检查] 发现 %d 个 Team 内账号无认证文件,需要登录 Codex:", len(no_auth_list))
for a in no_auth_list:
logger.info("[检查] %s", a["email"])
auth_error_list.extend(no_auth_list)
# auth_error + 无认证文件的统一重新登录 Codex
if auth_error_list:
# Round 11 V7 — 双失效预筛:access_token + refresh_token 同时被 server-side invalidate
# 的号无法靠重登 / refresh 救活(必须 fresh password login,且子号还在 Team 内时
# 注册都救不回来),直接标 STATUS_AUTH_INVALID + stamp 时间戳,跳过昂贵的 Playwright OAuth。
survivable_auth_errors = []
for acc in auth_error_list:
email = acc["email"]
auth_file = acc.get("auth_file")
try:
token_pair_dead = is_token_pair_invalidated(auth_file)
except Exception as exc: # noqa: BLE001 — 探活函数本身吞,这层兜底防御
logger.warning("[%s] 双失效探活异常,按可救处理: %s", email, exc)
token_pair_dead = False
if token_pair_dead:
logger.error(
"[%s] access_token + refresh_token 同时被 server-side invalidate,标 AUTH_INVALID 跳过重登",
email,
)
update_account(
email,
status=STATUS_AUTH_INVALID,
last_token_pair_invalidated_at=time.time(),
)
else:
survivable_auth_errors.append(acc)
auth_error_list = survivable_auth_errors
if auth_error_list:
logger.info("[检查] 重新登录 %d 个认证失效/待修复的账号...", len(auth_error_list))
mail_clients = {}
for acc in auth_error_list:
email = acc["email"]
password = acc.get("password", "")
logger.info("[%s] 重新 Codex 登录...", email)
provider = (acc.get("mail_provider") or get_mail_provider_name()).strip().lower()
mail_client = mail_clients.get(provider)
if mail_client is None:
mail_client = _get_account_mail_client(acc)
mail_clients[provider] = mail_client
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
login_kwargs = {"mail_client": mail_client}
if playwright_proxy_url:
login_kwargs["playwright_proxy_url"] = playwright_proxy_url
login_result = _login_codex_with_result(email, password, **login_kwargs)
bundle = login_result.get("bundle")
if login_result.get("ok") and bundle:
_attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
auth_file = save_auth_file(bundle)
update_account(email, auth_file=auth_file)
_auth_repair_reset(email)
logger.info("[%s] token 已更新", email)
# 重新检查额度
status_str, info = _check_and_refresh(find_account(load_accounts(), email))
if status_str == "exhausted":
quota_info = quota_result_quota_info(info)
if quota_info:
update_account(email, last_quota=quota_info)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=quota_result_resets_at(info) or int(time.time() + 18000),
)
exhausted_list.append(acc)
logger.warning("[%s] 额度已用完", email)
elif status_str == "ok" and isinstance(info, dict):
p_remain = 100 - info.get("primary_pct", 0)
update_account(email, last_quota=info)
if p_remain < threshold:
resets_at = info.get("primary_resets_at") or (time.time() + 18000)
logger.warning("[%s] 5h剩余 %d%% < %d%%,标记为 exhausted", email, p_remain, threshold)
update_account(
email,
status=STATUS_EXHAUSTED,
quota_exhausted_at=time.time(),
quota_resets_at=resets_at,
)
exhausted_list.append(acc)
else:
_auth_repair_reset(email)
update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
logger.info("[%s] 额度可用 (%d%%)", email, p_remain)
elif status_str == "ok":
_auth_repair_reset(email)
update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
logger.info("[%s] 额度可用", email)
elif status_str == "auth_error":
result = _record_auth_repair_failure(
email,
login_result.get("error_type") or "non_team_plan",
login_result.get("error_detail") or "重新登录后仍无法查询额度",
)
extra = _auth_repair_result_suffix(result)
logger.warning(
"[%s] 重新登录后仍无法查询额度(可能未选中 Team workspace),标记为 %s%s",
email,
result.get("status"),
extra,
)
elif status_str == "network_error":
_auth_repair_reset(email)
update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
logger.warning("[%s] 重新登录成功,但额度接口暂时不可达;保留 active 并等待下一轮复查", email)
else:
result = _record_auth_repair_failure(
email,
login_result.get("error_type"),
login_result.get("error_detail"),
)
extra = _auth_repair_result_suffix(result)
logger.error(
"[%s] Codex 登录失败,标记为 %s(%s%s)",
email,
result.get("status"),
_auth_repair_error_label(result.get("auth_last_error")),
extra,
)
# 已 exhausted 但 5h 重置时间已过 → 复测,真的回血则 promote 回 active,避免轮转
# 多走一遍"kick → standby → re-invite"。token 仍在 Team 里时这个直接 promote 路径
# 节省一次 Playwright + remove_from_team + invite + OAuth 的开销。
accounts_now = load_accounts()
exhausted_to_probe = [
a
for a in accounts_now
if a.get("status") == STATUS_EXHAUSTED
and not _is_main_account_email(a.get("email"))
and a.get("auth_file")
and Path(a["auth_file"]).exists()
and a.get("quota_resets_at")
and time.time() >= a["quota_resets_at"]
]
if exhausted_to_probe:
logger.info("[检查] 复测 %d 个重置时间已过的 exhausted 账号...", len(exhausted_to_probe))
for acc in exhausted_to_probe:
email = acc["email"]
try:
status_str, info = _check_and_refresh(acc)
except Exception as exc:
logger.warning("[%s] exhausted 复测异常,跳过: %s", email, exc)
continue
if status_str == "ok" and isinstance(info, dict):
p_remain = 100 - info.get("primary_pct", 0)
if p_remain >= threshold:
update_account(
email,
status=STATUS_ACTIVE,
last_quota=info,
quota_exhausted_at=None,
quota_resets_at=None,
workspace_account_id=get_chatgpt_account_id() or None,
)
logger.info(
"[%s] 5h 已重置(剩余 %d%%),从 exhausted 回血到 active",
email,
p_remain,
)
else:
update_account(email, last_quota=info)
logger.info(
"[%s] 复测后剩余 %d%% < %d%%,保留 exhausted",
email,
p_remain,
threshold,
)
elif status_str == "exhausted":
quota_info = quota_result_quota_info(info) or {}
new_resets = quota_result_resets_at(info) or acc.get("quota_resets_at")
if quota_info:
update_account(email, last_quota=quota_info, quota_resets_at=new_resets)
logger.info("[%s] 复测后仍 exhausted,resets_at 已刷新", email)
elif status_str == "auth_error":
logger.warning("[%s] 复测时 token 失效,改标 AUTH_INVALID 等 reconcile 处理", email)
update_account(email, status=STATUS_AUTH_INVALID)
elif status_str == "network_error":
logger.info("[%s] 复测遇到临时网络错误,保留 exhausted,下一轮再试", email)
# Personal 号独立扫描(不参与轮转,但用户需要看到额度)
try:
_check_personal_accounts(threshold)
except Exception as exc:
logger.warning("[检查] personal 分支异常(不影响 active 结果): %s", exc)
# Standby 池额度探测(可选):修复"standby 永远无 quota 数据 → _quota_recovered 失真
# → fill 时盲选踩雷"的问题。限速 + 24h 去重,探到 401/403 标 STATUS_AUTH_INVALID。
if include_standby:
try:
_probe_standby_quota()
except Exception as exc:
logger.warning("[检查] standby 探测分支异常(不影响 active/personal 结果): %s", exc)
# Round 9 RT-3 兜底 — 即便 _reconcile_team_members 因 chatgpt_api 启动失败提前 return,
# 这里仍能跑一次 retroactive(走 5min cache,自起 chatgpt_api)。
try:
from autoteam.master_health import _apply_master_degraded_classification
retro = _apply_master_degraded_classification()
if retro and (retro.get("marked_grace") or retro.get("marked_standby") or retro.get("reverted_active")):
logger.info(
"[检查] retroactive: GRACE %d / STANDBY %d / 撤回 ACTIVE %d",
len(retro.get("marked_grace") or []),
len(retro.get("marked_standby") or []),
len(retro.get("reverted_active") or []),
)
except Exception as exc:
logger.warning("[检查] retroactive helper 异常(不影响 cmd_check): %s", exc)
return exhausted_list
def _probe_standby_quota():
"""遍历 standby 池,探测每个账号的 quota。
- 限速:每账号之间 sleep STANDBY_PROBE_INTERVAL_SEC,避免群访 OpenAI wham/usage 触发风控
- 去重:last_quota_check_at 在 STANDBY_PROBE_DEDUP_SEC 秒内的跳过
- auth_error(**仅** 401/403):标 STATUS_AUTH_INVALID,等 reconcile 处置
- network_error(DNS/timeout/SSL/5xx/429/JSON 解析失败/其他临时错误):
**不写 last_quota_check_at**(允许下一轮立刻重试),**不改 status**,只 log warning。
这条修复的就是"网络抖动一次,18 个号一起被误标 AUTH_INVALID 然后被 reconcile 全删"事故。
- exhausted:刷新 quota_exhausted_at / quota_resets_at(修正过期快照),维持 standby
- ok:仅写回 last_quota + last_quota_check_at,不改 status(standby 的 status 由 fill/rotate 决定)
- 未知 status:防御分支只 log,不写时间戳,避免去重逻辑卡住未来真正的探测
"""
standby = get_standby_accounts()
if not standby:
logger.info("[检查] standby 池为空,跳过探测")
return
now = time.time()
to_probe = []
skipped = 0
no_auth = 0
for acc in standby:
auth_file = acc.get("auth_file")
if not auth_file or not Path(auth_file).exists():
no_auth += 1
continue
last_check = acc.get("last_quota_check_at") or 0
if last_check and (now - last_check) < STANDBY_PROBE_DEDUP_SEC:
skipped += 1
continue
to_probe.append(acc)
if not to_probe:
logger.info(
"[检查] standby 池共 %d 个,全部在 24h 内已探测或无 auth_file(skipped=%d,no_auth=%d),跳过",
len(standby),
skipped,
no_auth,
)
return
logger.info(
"[检查] 探测 %d 个 standby 账号的额度(总 %d,跳过 %d 近期已探测,%d 无 auth_file,间隔 %.1fs)...",
len(to_probe),
len(standby),
skipped,
no_auth,
STANDBY_PROBE_INTERVAL_SEC,
)
for idx, acc in enumerate(to_probe):
email = acc["email"]
if idx > 0:
time.sleep(STANDBY_PROBE_INTERVAL_SEC)
try:
status_str, info = _check_and_refresh(acc)
except Exception as exc:
logger.warning("[%s] (standby) 探测异常,跳过: %s", email, exc)
continue
probe_ts = time.time()
if status_str == "ok" and isinstance(info, dict):
update_account(email, last_quota=info, last_quota_check_at=probe_ts)
p_remain = 100 - info.get("primary_pct", 0)
w_remain = 100 - info.get("weekly_pct", 0)
logger.info("[%s] (standby) 探测成功 5h剩余 %d%% | 周剩余 %d%%", email, p_remain, w_remain)
elif status_str == "exhausted":
quota_info = quota_result_quota_info(info) or {}
resets_at = quota_result_resets_at(info) or int(probe_ts + 18000)
payload = {
"last_quota_check_at": probe_ts,
"quota_exhausted_at": probe_ts,
"quota_resets_at": resets_at,
}
if quota_info:
payload["last_quota"] = quota_info
update_account(email, **payload)
window = info.get("window") if isinstance(info, dict) else ""
logger.warning(
"[%s] (standby) %s额度仍未恢复,刷新重置时间",
email,
"周" if window == "weekly" else "5h",
)
elif status_str == "auth_error":
update_account(email, status=STATUS_AUTH_INVALID, last_quota_check_at=probe_ts)
logger.warning("[%s] (standby) auth_file 已失效(401/403),标记 %s", email, STATUS_AUTH_INVALID)
elif status_str == "network_error":
# 网络抖动 / 5xx / 429 / JSON 解析失败 — 临时性故障。
# 关键约束:不写 last_quota_check_at(允许下一轮立刻重试)、不改 status,只 log。
# 否则一次大规模网络故障会让整批 standby 号在 24h 内不再被探测,且如果以前
# 错误归到 auth_error 还会被批量误标 AUTH_INVALID(就是事故根因)。
logger.warning("[%s] (standby) 探测遇到临时网络错误,本轮跳过,不更新状态/时间戳", email)
elif status_str == "no_auth":
# 理论上入口已过滤,这里兜底:记时间戳避免下一轮重复命中
update_account(email, last_quota_check_at=probe_ts)
logger.info("[%s] (standby) auth_file 缺失,跳过", email)
else:
# 未知 status 防御分支:同样不写时间戳。如果误吃了未来新加的 status,
# 写 last_quota_check_at 会让账号在 24h 内不再被探测,屏蔽问题。
logger.warning("[%s] (standby) 未知探测结果 %s,本轮跳过,不更新时间戳", email, status_str)
def remove_from_team(chatgpt_api, email, *, return_status=False, lookup_retries=3, retry_interval=3.0):
"""将账号从 Team 中移除。
OpenAI 的 /backend-api/accounts/{id}/users 对"刚加入 Team 的新成员"存在同步
延迟(注册进 Team 后立刻 GET 可能拿不到新成员)。如果第一次在 members 列表里
没找到 target_user_id 就直接判定 already_absent、跳过 DELETE,新号就会被遗留
在 Team 里 —— 这正是 fill-personal "实际没踢出但本地记录 PERSONAL" 的真根因。
为此找不到时会重试 `lookup_retries` 次,每次间隔 `retry_interval` 秒。只有
连续多轮都查不到才判定真的 already_absent。这样对于确实已不在 Team 的历史
账号,最多多耗 ~lookup_retries*retry_interval 秒(可接受),换来对新加入号
踢出流程的可靠性。
"""
if _is_main_account_email(email):
logger.warning("[Team] 跳过移除主号: %s", email)
return "failed" if return_status else False
account_id = get_chatgpt_account_id()
if not account_id:
logger.error("[Team] account_id 为空,无法移除 %s", email)
return "failed" if return_status else False
email_lc = (email or "").lower()
target_user_id = None
total_attempts = max(1, int(lookup_retries) + 1)
for attempt in range(total_attempts):
path = f"/backend-api/accounts/{account_id}/users"
result = chatgpt_api._api_fetch("GET", path)
status = result.get("status")
body_excerpt = (result.get("body") or "")[:200].replace("\n", " ")
if status != 200:
logger.error(
"[Team] 获取成员列表失败(第 %d/%d 次): status=%s body=%s",
attempt + 1,
total_attempts,
status,
body_excerpt,
)
# 401/403 是 session/权限类错误,重试也不会变好,快速失败
if status in (401, 403):
return "failed" if return_status else False
if attempt < total_attempts - 1:
time.sleep(retry_interval)
continue
return "failed" if return_status else False
try:
data = json.loads(result["body"])
members = data.get("items", data.get("users", data.get("members", [])))
except Exception as exc:
logger.error(
"[Team] 解析成员列表失败(第 %d/%d 次): %s body=%s", attempt + 1, total_attempts, exc, body_excerpt
)
if attempt < total_attempts - 1:
time.sleep(retry_interval)
continue
return "failed" if return_status else False
for member in members:
if (member.get("email", "") or "").lower() == email_lc:
target_user_id = member.get("user_id") or member.get("id")
break
if target_user_id:
if attempt > 0:
logger.info("[Team] 第 %d 次查询命中 %s → user_id=%s", attempt + 1, email, target_user_id)
break
if attempt < total_attempts - 1:
logger.info(
"[Team] 成员列表里暂无 %s(共 %d 个成员),可能 OpenAI 同步延迟,%.1fs 后重试 (%d/%d)",
email,
len(members),
retry_interval,
attempt + 1,
total_attempts - 1,
)
time.sleep(retry_interval)
if not target_user_id:
logger.info(
"[Team] 重试 %d 次后仍未在成员列表中找到 %s,判定为已不在 Team",
total_attempts,
email,
)
return "already_absent" if return_status else True
delete_path = f"/backend-api/accounts/{account_id}/users/{target_user_id}"
result = chatgpt_api._api_fetch("DELETE", delete_path)
if result["status"] in (200, 204):
logger.info("[Team] 已将 %s 移出 Team (user_id=%s)", email, target_user_id)
return "removed" if return_status else True
else:
body_excerpt = (result.get("body") or "")[:200].replace("\n", " ")
logger.error(
"[Team] 移除 %s 失败: status=%s body=%s (user_id=%s)",
email,
result["status"],
body_excerpt,
target_user_id,
)
return "failed" if return_status else False
def _wait_for_remote_capacity_after_removal(
chatgpt_api,
*,
target: int,
removed_email: str,
timeout: int = 24,
poll_interval: float = 3.0,
stage_label: str = "[Team]",
) -> tuple[int, bool]:
"""Poll Team member count after a removal before creating a replacement."""
deadline = time.time() + max(1, int(timeout))
latest_count = -1
while time.time() < deadline:
latest_count = get_team_member_count(chatgpt_api)
if latest_count >= 0 and latest_count < target:
logger.info("%s 已释放 %s 的席位,当前成员 %d/%d", stage_label, removed_email, latest_count, target)
return latest_count, True
logger.info(
"%s 等待 %s 的远端席位释放,当前成员=%s/%d",
stage_label,
removed_email,
latest_count if latest_count >= 0 else "unknown",
target,
)
time.sleep(max(0.5, float(poll_interval)))
return latest_count, False
def _get_remote_team_member(email: str | None, chatgpt_api=None) -> dict | None:
"""Return remote Team member by email, or None when absent/unknown."""
email_l = _normalized_email(email)
if not email_l:
return None
owns_api = chatgpt_api is None
api = chatgpt_api or ChatGPTTeamAPI()
try:
if owns_api or not _chatgpt_session_ready(api):
api.start()
members, _invites = fetch_team_state(api)
for member in members:
if _normalized_email(member.get("email")) == email_l:
return member
return None
finally:
if owns_api:
try:
api.stop()
except Exception:
pass
def _wait_for_email_in_team(
email: str | None,
*,
chatgpt_api=None,
timeout: int = 60,
stage_label: str = "[Team]",
) -> dict | None:
target = _normalized_email(email)
if not target:
return None
deadline = time.time() + max(1, int(timeout))
last_member_count = -1
while time.time() < deadline:
member = _get_remote_team_member(target, chatgpt_api=chatgpt_api)
if member:
logger.info("%s 远端已确认 Team 成员: %s", stage_label, target)
return member
try:
if chatgpt_api is not None and _chatgpt_session_ready(chatgpt_api):
members, _invites = fetch_team_state(chatgpt_api)
last_member_count = len(members)
except Exception:
pass
logger.info("%s 等待远端成员出现: %s(当前成员数=%s)", stage_label, target, last_member_count)
time.sleep(3)
logger.warning("%s 超时仍未在远端 Team 成员列表看到: %s", stage_label, target)
return None
def _remote_team_occupancy(chatgpt_api) -> tuple[int, int, int]:
members, invites = fetch_team_state(chatgpt_api)
return len(members), len(invites), len(members) + len(invites)
def _has_remote_capacity_for_new_seat(chatgpt_api, *, stage_label: str = "[Team]") -> bool:
members_count, invites_count, occupancy = _remote_team_occupancy(chatgpt_api)
logger.info(
"%s 远端席位占用: members=%d invites=%d total=%d/%d",
stage_label,
members_count,
invites_count,
occupancy,
TEAM_SEATS_MAX,
)
if occupancy >= TEAM_SEATS_MAX:
logger.warning(
"%s 远端席位已满或有 pending invite 占位,停止添加新账号,避免超过 %d 人",
stage_label,
TEAM_SEATS_MAX,
)
return False
return True
def _prepare_remote_capacity_for_new_seat(chatgpt_api, *, stage_label: str = "[创建]") -> bool:
if chatgpt_api is None:
return True
if not _chatgpt_session_ready(chatgpt_api):
chatgpt_api.start()
if not _chatgpt_session_ready(chatgpt_api):
logger.warning("%s 无可用 Team API session,跳过远端席位预检", stage_label)
return True
if _has_remote_capacity_for_new_seat(chatgpt_api, stage_label=stage_label):
return True
_cancel_stale_pending_invites_for_capacity(chatgpt_api, stage_label=stage_label)
return _has_remote_capacity_for_new_seat(chatgpt_api, stage_label=stage_label)
def _pending_invite_id(invite: dict | None):
if not isinstance(invite, dict):
return None
for key in ("id", "invite_id", "account_invite_id"):
value = invite.get(key)
if value:
return value
return None
def _email_matches_current_mail_domain(email: str | None) -> bool:
email = _normalized_email(email)
domains = _configured_mail_domains()
return bool(email and domains and email.rsplit("@", 1)[-1] in domains)
def _can_cancel_pending_invite(email: str, acc: dict | None) -> tuple[bool, str]:
if not email:
return False, "missing_email"
if _is_main_account_email(email):
return False, "main_account"
if acc and _has_auth_file(acc):
return False, "local_auth_protected"
if _find_team_auth_file(email) is not None:
return False, "auth_file_protected"
if acc:
return True, "local_managed_pending"
if _email_matches_current_mail_domain(email):
return True, "managed_domain_pending"
return False, "external_or_manual_pending"
def _wait_for_pending_invite_absent(chatgpt_api, email: str, *, timeout: int = 30) -> bool:
target = _normalized_email(email)
deadline = time.time() + max(1, int(timeout))
while time.time() < deadline:
try:
_members, invites = fetch_team_state(chatgpt_api)
if not any(team_invite_email(invite) == target for invite in invites):
return True
except Exception:
pass
time.sleep(2)
return False
def _cancel_stale_pending_invites_for_capacity(chatgpt_api, *, stage_label: str = "[Team]", mail_client=None) -> list[str]:
"""Free capacity by cancelling stale AutoTeam-owned pending invites only."""
members, invites = fetch_team_state(chatgpt_api)
occupancy = len(members) + len(invites)
if occupancy < TEAM_SEATS_MAX or not invites:
return []
accounts = load_accounts()
accounts_by_email = {
_normalized_email(acc.get("email")): acc
for acc in accounts
if _normalized_email(acc.get("email"))
}
account_id = get_chatgpt_account_id()
cancelled: list[str] = []
logger.info(
"%s 远端席位满占用且存在 pending invite,尝试取消 AutoTeam 遗留邀请: members=%d invites=%d total=%d/%d",
stage_label,
len(members),
len(invites),
occupancy,
TEAM_SEATS_MAX,
)
for invite in invites:
email = team_invite_email(invite)
invite_id = _pending_invite_id(invite)
acc = accounts_by_email.get(email)
can_cancel, reason = _can_cancel_pending_invite(email, acc)
if not can_cancel:
logger.warning("%s 保留 pending invite: %s(%s)", stage_label, email or "unknown", reason)
continue
if not invite_id:
logger.warning("%s 无法取消 pending invite: %s(缺少 invite id)", stage_label, email)
continue
result = delete_team_invite(chatgpt_api, account_id, invite, invite_id=invite_id, email=email)
if result.get("status") not in (200, 204):
logger.warning("%s 取消 pending invite 失败: %s HTTP %s", stage_label, email, result.get("status"))
continue
cancelled.append(email)
logger.info("%s 已取消 stale pending invite: %s", stage_label, email)
if not _wait_for_pending_invite_absent(chatgpt_api, email):
logger.warning("%s pending invite 已提交取消但远端仍暂未消失: %s", stage_label, email)
if acc:
try:
delete_managed_account(
email,
remove_remote=False,
remove_cloudmail=True,
sync_cpa_after=False,
chatgpt_api=chatgpt_api,
mail_client=mail_client,
)
except Exception as exc:
logger.warning("%s 清理 pending invite 本地账号失败: %s (%s)", stage_label, email, exc)
try:
_discard_auth_repair_failed_account_record(
email,
"stale_pending_invite_cancelled",
status=STATUS_STANDBY,
)
except Exception:
pass
if len(members) + len(invites) - len(cancelled) < TEAM_SEATS_MAX:
break
if cancelled:
logger.info("%s 已释放 %d 个 pending invite 占位,下一步可创建新号", stage_label, len(cancelled))
return cancelled
def _validate_managed_account_operational(
email: str,
*,
threshold: int,
stage_label: str = "[轮转验收]",
chatgpt_api=None,
) -> bool:
"""Validate a newly managed child is remotely present and has usable Codex auth/quota."""
acc = find_account(load_accounts(), email)
if not acc:
logger.warning("%s %s 本地账号不存在", stage_label, email)
return False
if is_account_disabled(acc):
logger.warning("%s %s 已禁用,不能计入可用 child", stage_label, email)
return False
if acc.get("status") != STATUS_ACTIVE:
logger.warning("%s %s status=%s,不能计入可用 child", stage_label, email, acc.get("status"))
return False
if not _has_auth_file(acc):
logger.warning("%s %s 缺少可用 auth_file", stage_label, email)
return False
if chatgpt_api is not None:
try:
member = _get_remote_team_member(email, chatgpt_api=chatgpt_api)
except Exception as exc:
logger.warning("%s 查询远端成员失败: %s (%s)", stage_label, email, exc)
member = None
if not member:
logger.warning("%s %s 未出现在远端 Team 成员列表", stage_label, email)
return False
try:
auth_data = json.loads(read_text(Path(acc["auth_file"])))
access_token = auth_data.get("access_token")
if not access_token:
logger.warning("%s %s auth_file 缺少 access_token", stage_label, email)
return False
status_str, info = check_codex_quota(access_token)
except Exception as exc:
logger.warning("%s %s quota 验证异常: %s", stage_label, email, exc)
return False
if status_str != "ok" or not isinstance(info, dict):
logger.warning("%s %s quota 验证失败: %s", stage_label, email, status_str)
return False
primary_remaining = 100 - int(info.get("primary_pct", 100) or 100)
if primary_remaining < int(threshold):
logger.warning("%s %s quota 剩余 %d%% < %d%%", stage_label, email, primary_remaining, threshold)
return False
update_account(email, last_quota=info, last_active_at=time.time())
logger.info("%s %s 运行态验收通过,5h 剩余 %d%%", stage_label, email, primary_remaining)
return True
def _kick_team_seat_after_oauth_failure(email: str, *, reason: str) -> None:
"""OAuth 失败时同步 KICK ws,消除"workspace 有 + 本地 auth 缺失"残废延迟。
Round 11 — 之前 OAuth 失败只标本地 status=AUTH_INVALID,workspace 中的 Team 席位
要等 reconcile 5min 后异步清理。本 helper 在每个失败位点同步 KICK,把延迟降到 0。
Args:
email: 失败子号 email
reason: 失败原因短文案(如 "register_blocked_phone" / "plan_unsupported" /
"bundle_missing"),写进 log 让事后排查能定位失败位点。
异常处理:任何异常吞掉只 logger.warning(reconcile 兜底),不让 KICK 失败传播到
OAuth 失败处理流(避免 update_account 之后的状态不一致)。
"""
try:
cleanup_api = ChatGPTTeamAPI()
try:
cleanup_api.start()
kick_status = remove_from_team(cleanup_api, email, return_status=True)
logger.info(
"[注册] OAuth 失败(%s) → kick Team 残留席位 %s status=%s",
reason, email, kick_status,
)
finally:
try:
cleanup_api.stop()
except Exception:
pass
except Exception as kick_exc:
logger.warning(
"[注册] OAuth 失败(%s) 后 kick %s 抛异常(留给下次对账): %s",
reason, email, kick_exc,
)
def _run_post_register_oauth(
email,
password,
mail_client,
leave_workspace=False,
out_outcome=None,
chatgpt_session_token=None,
signup_profile: SignupProfile | None = None,
auth_proxy_url: str | None = None,
playwright_proxy_url: str | None = None,
):
"""
注册(加入 Team)成功后统一的收尾流程:
- leave_workspace=False: 直接跑 Team 模式 Codex OAuth,状态置为 ACTIVE
- leave_workspace=True: 主号 API 踢出子账号 → 走 personal 模式 OAuth → 保存 free plan 认证,状态置为 PERSONAL
返回 email 表示账号已入账号池;None 表示流程失败。
out_outcome: 可选 dict,函数内会写入 `{status, email, reason, ...}` 供上游统计/汇总。
chatgpt_session_token: 注册阶段从 chatgpt.com 抽出的 __Secure-next-auth.session-token,
personal OAuth 时注入到 auth.openai.com 跳过 /log-in 卡死。
signup_profile: 注册阶段填写过的身份快照;OAuth about-you 必须复用同一份快照。
"""
def _record_outcome(status, **extra):
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(status=status, email=email, **extra)
if leave_workspace:
# Round 8 — SPEC-2 v1.5 §3.7 + shared/master-subscription-health.md M-T1:
# personal OAuth 入口先验证母号订阅健康度。母号 cancel 时 personal 子号必拿 plan_type=team,
# 跑完 OAuth 也只是积累一条 plan_drift,所以 fail-fast 不进 OAuth。
try:
from autoteam.master_health import is_master_subscription_healthy
temp_probe_api = ChatGPTTeamAPI()
try:
temp_probe_api.start()
healthy, master_reason, master_evidence = is_master_subscription_healthy(temp_probe_api)
finally:
try:
temp_probe_api.stop()
except Exception:
pass
except Exception as probe_exc:
logger.warning("[注册] master health probe 异常 %s,按既有逻辑放行", probe_exc)
healthy, master_reason, master_evidence = True, "active", {"detail": str(probe_exc)[:200]}
if not healthy and master_reason == "subscription_cancelled":
logger.error(
"[注册] master 母号订阅已取消(eligible_for_auto_reactivation=true),fail-fast 不进 personal OAuth: %s",
email,
)
record_failure(
email, MASTER_SUBSCRIPTION_DEGRADED,
"master subscription cancelled,personal OAuth 必拿 plan_type=team",
stage="run_post_register_oauth_personal_precheck",
master_account_id=master_evidence.get("account_id"),
master_role=master_evidence.get("current_user_role"),
)
update_account(email, status=STATUS_STANDBY)
_record_outcome("master_degraded", reason="master subscription cancelled")
return None
if not healthy and master_reason in ("network_error", "auth_invalid"):
logger.warning(
"[注册] master health probe 不确定 (%s),按既有逻辑放行,失败由 plan_drift 兜底",
master_reason,
)
elif not healthy and master_reason in ("workspace_missing", "role_not_owner"):
logger.warning(
"[注册] master 异常 (%s, role=%s),仍尝试 personal OAuth,reconcile 后续接管",
master_reason, master_evidence.get("current_user_role"),
)
# 退出 Team 必须用主号权限,临时起一个 ChatGPTTeamAPI 实例完成 DELETE
logger.info("[注册] leave_workspace=True,先将 %s 从 Team 中移出...", email)
temp_api = ChatGPTTeamAPI()
remove_status = "failed" # 防御:start() 抛异常时 finally 走完仍有确定值,避免 NameError
try:
temp_api.start()
remove_status = remove_from_team(temp_api, email, return_status=True)
except Exception as exc:
logger.error("[注册] 启动主号 API 或移出 Team 时出错: %s", exc)
finally:
temp_api.stop()
if remove_status not in ("removed", "already_absent"):
logger.error("[注册] 无法将 %s 移出 Team(status=%s),放弃 personal OAuth", email, remove_status)
# 没能踢出 → 账号还在 Team 里,保留为 standby 由下次轮转接手
update_account(email, status=STATUS_STANDBY)
record_failure(email, "kick_failed", f"remove_from_team status={remove_status}")
_record_outcome("kick_failed", reason=f"主号踢出失败 status={remove_status}")
return None
# Round 8 — SPEC-2 v1.5 §3.4.5 + shared/oauth-workspace-selection.md §4.3:
# 删除原 time.sleep(8)。kick 成功不等同于 auth.openai.com 把 default_workspace_id 从
# Team 切回 Personal — research/sticky-rejoin §1.2-1.3 实测 default 不会自动 unset。
# sticky 根因是 default 没切,等多久都没用。改用 oauth_workspace.ensure_personal_workspace_selected
# 在 OAuth 内主动 POST /api/accounts/workspace/select 切到 personal,5 次外层重试触发后端最终一致性。
# Round 8 — 5 次 personal OAuth 重试外层(spec §3.4 退避表 ±20% jitter)
# 触发条件:bundle.plan_type != "free" 但 workspace/select 主路径成功 →
# 后端最终一致性短暂滞后,重试可触发刷新(openai/codex#1977 ejntaylor 实证)
import random as _random
from autoteam.register_failures import (
OAUTH_PLAN_DRIFT_PERSISTENT as _OAUTH_PLAN_DRIFT_PERSISTENT,
)
bundle = None
max_retries = 5
retry_backoff = (0, 5, 10, 20, 30)
plan_drift_history = [] # 收集每次拿到的非-free plan_type,fail-fast 时一起 record_failure
for attempt in range(max_retries):
if attempt > 0:
base_delay = retry_backoff[attempt] if attempt < len(retry_backoff) else 30
jitter = base_delay * _random.uniform(-0.2, 0.2)
sleep_secs = max(0, base_delay + jitter)
logger.info(
"[注册] personal OAuth 第 %d/%d 次重试,先退避 %.1fs",
attempt + 1, max_retries, sleep_secs,
)
if sleep_secs > 0:
time.sleep(sleep_secs)
# SPEC-2 §3.1.3 — personal 分支 catch RegisterBlocked + plan_supported 检查
# Round 11 四轮 — 把注册阶段的 chatgpt.com session_token 透给 OAuth,
# 让 login_codex_via_browser 注入 auth.openai.com cookie,跳过 /log-in 表单
# (实测刚踢出 Team 的新号在 /log-in 页 Continue 按钮变灰禁用,无法登录)。
try:
bundle = _login_codex_via_browser_with_proxy(
email,
password,
mail_client=mail_client,
use_personal=True,
chatgpt_session_token=chatgpt_session_token,
signup_profile=signup_profile,
playwright_proxy_url=playwright_proxy_url,
)
except RegisterBlocked as blocked:
# add-phone / duplicate 等异常是 terminal,不重试
if blocked.is_phone:
logger.error(
"[注册] %s personal OAuth 触发 add-phone (step=%s),从账号池删除",
email, blocked.step,
)
record_failure(
email,
"oauth_phone_blocked",
f"personal OAuth 阶段触发 add-phone (step={blocked.step})",
step=blocked.step,
stage="run_post_register_oauth_personal",
attempt=attempt + 1,
)
delete_account(email)
_record_outcome("oauth_phone_blocked", reason="personal OAuth 触发 add-phone")
return None
logger.error("[注册] %s personal OAuth RegisterBlocked: %s", email, blocked.reason)
record_failure(
email, "exception",
f"personal OAuth RegisterBlocked: {blocked.reason}",
stage="run_post_register_oauth_personal",
attempt=attempt + 1,
)
delete_account(email)
_record_outcome("oauth_failed", reason=f"unexpected RegisterBlocked: {blocked.reason}")
return None
if not bundle:
# Round 11 hotfix — bundle=None 可能原因:
# 1. workspace_select 完全失败(auth_code 缺失)— codex_auth.py:1023-1025
# 2. plan_type != free 被 codex_auth 拒收 — codex_auth.py:1037-1045
# 两种都属于后端最终一致性滞后,W-I9 spec 要求外层 5 次重试,而非 break。
# 旧实现 break 在第 1 次 bundle=None 时直接放弃 → 实测发现:codex_auth 拒收 plan=team
# 的 bundle 时(第 1 batch)永远没机会触发"5 次重试触发后端最终一致性"。
logger.warning(
"[注册] %s personal OAuth 第 %d/%d 次未返回 bundle,继续重试(等后端最终一致性同步)",
email, attempt + 1, max_retries,
)
plan_drift_history.append({
"attempt": attempt + 1,
"plan_type": "unknown",
"plan_type_raw": None,
"account_id": None,
"reason": "bundle_none", # 区分 plan_drift 和 bundle_none
})
continue
bundle_plan = (bundle.get("plan_type") or "").lower()
if bundle_plan == "free":
# 拿到 free,直接出循环走后续 plan_supported / quota probe / save_auth_file
logger.info("[注册] %s 第 %d 次 personal OAuth 拿到 plan=free,出重试循环",
email, attempt + 1)
break
# plan_type != free → 后端最终一致性滞后,记一笔继续重试
plan_drift_history.append({
"attempt": attempt + 1,
"plan_type": bundle_plan,
"plan_type_raw": bundle.get("plan_type_raw"),
"account_id": bundle.get("account_id"),
})
logger.warning(
"[注册] %s 第 %d/%d 次 personal OAuth 拿到 plan=%s(非 free),继续重试",
email, attempt + 1, max_retries, bundle_plan,
)
bundle = None # 清掉这次的 bundle,不让 fall-through 误用
# 退出循环:bundle 要么 None(所有 attempt 都 abort 或 plan_drift),要么 plan==free
if not bundle:
logger.error(
"[注册] %s 5 次 personal OAuth 后仍未拿到 plan=free,fail-fast 删除账号",
email,
)
record_failure(
email, _OAUTH_PLAN_DRIFT_PERSISTENT,
f"5 次 personal OAuth 后 plan_type 仍非 free (drift_history={len(plan_drift_history)} 条)",
stage="run_post_register_oauth_personal",
attempts=max_retries,
drift_history=plan_drift_history,
)
delete_account(email)
_record_outcome("oauth_failed", reason="5 次 personal OAuth plan_drift 持续")
return None
# bundle.plan_type=="free" 已在循环内验证,此处仍走完整白名单 + quota 流程保留对称
# SPEC-2 shared/plan-type-whitelist §5.2 — plan_supported=False 直接拒,personal 已 leave_workspace 本地无价值
plan_supported = bundle.get(
"plan_supported", is_supported_plan(bundle.get("plan_type", ""))
)
if not plan_supported:
logger.error(
"[注册] %s personal OAuth 拿到 plan_type=%s 不在白名单,从账号池删除",
email, bundle.get("plan_type_raw") or bundle.get("plan_type"),
)
record_failure(
email, "plan_unsupported",
f"personal OAuth bundle plan_type={bundle.get('plan_type_raw')} 不在白名单",
plan_type=bundle.get("plan_type"),
plan_type_raw=bundle.get("plan_type_raw"),
stage="run_post_register_oauth_personal",
)
delete_account(email)
_record_outcome("plan_unsupported", plan=bundle.get("plan_type"))
return None
_attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
auth_file = save_auth_file(bundle)
update_fields = {
"status": STATUS_PERSONAL,
"seat_type": "codex",
"auth_file": auth_file,
"last_active_at": time.time(),
"plan_type_raw": bundle.get("plan_type_raw"),
}
# Round 11 V8 — codex_auth.login_codex_via_browser 拿到的 personal_workspace_id
# 透传到 accounts.json,下次 OAuth 复用(避免重复 fetch)。
if bundle.get("personal_workspace_id"):
update_fields["personal_workspace_id"] = bundle["personal_workspace_id"]
# SPEC-2 FR-D3 — personal 分支也 quota probe(对称设计):free plan 也可能"未分配 codex 配额"
access_token = bundle.get("access_token")
if access_token:
try:
quota_status, quota_info = check_codex_quota(access_token, account_id=bundle.get("account_id"))
if quota_status == "ok" and isinstance(quota_info, dict):
update_fields["last_quota"] = quota_info
elif quota_status == "no_quota":
# personal free 无配额 — 记一笔但仍保留 PERSONAL,让用户决定删不删
record_failure(
email, "no_quota_assigned",
"personal free plan 无 codex 配额",
stage="run_post_register_oauth_personal",
raw_rate_limit=_extract_raw_rate_limit_str(quota_info),
)
except Exception as exc:
record_failure(
email, "quota_probe_network_error",
f"personal quota probe exception: {exc}",
stage="run_post_register_oauth_personal",
)
# personal 分支:已主动退出 Team,bundle 是个人 free/plus plan,算 codex 席位
update_account(email, **update_fields)
if update_fields["status"] == STATUS_ACTIVE:
_sync_ready_credential_to_targets(email, auth_file, stage_label="[注册]")
logger.info("[注册] 免费号就绪: %s (plan=%s, attempts=%d)",
email, bundle.get("plan_type"), len(plan_drift_history) + 1)
_record_outcome("success", plan=bundle.get("plan_type"))
return email
# Round 8 — SPEC-2 v1.5 §3.7 表第 2 行 + shared/master-subscription-health.md M-T2:
# Team 分支也需对称 master probe。母号 cancel 时 Team invite 同样必拿 plan_type=free,
# 跑完 OAuth 只是堆 plan_drift(实测已观测 28 条)。fail-fast 不进 OAuth 节省 2 分钟。
# 与 personal 分支差异:Team 已成功 invite,席位占着 → 标 AUTH_INVALID + 主动 kick 释放席位
# (不能 delete_account,席位还在 Team)。
try:
from autoteam.master_health import is_master_subscription_healthy
team_probe_api = ChatGPTTeamAPI()
try:
team_probe_api.start()
t_healthy, t_master_reason, t_master_evidence = is_master_subscription_healthy(team_probe_api)
finally:
try:
team_probe_api.stop()
except Exception:
pass
except Exception as probe_exc:
logger.warning("[注册] Team master health probe 异常 %s,按既有逻辑放行", probe_exc)
t_healthy, t_master_reason, t_master_evidence = True, "active", {"detail": str(probe_exc)[:200]}
if not t_healthy and t_master_reason == "subscription_cancelled":
logger.error(
"[注册] master 母号订阅已取消(eligible_for_auto_reactivation=true),fail-fast 不进 Team OAuth: %s",
email,
)
record_failure(
email, MASTER_SUBSCRIPTION_DEGRADED,
"master subscription cancelled,Team OAuth 必拿 plan_type=free",
stage="run_post_register_oauth_team_precheck",
master_account_id=t_master_evidence.get("account_id"),
master_role=t_master_evidence.get("current_user_role"),
)
# Team 已 invite,不能 delete_account → 标 AUTH_INVALID + 主动 kick 释放席位
# (避免 28 条 plan_drift 子号长期占着 Team 名额)
update_account(
email,
status=STATUS_AUTH_INVALID,
workspace_account_id=get_chatgpt_account_id() or None,
)
_kick_team_seat_after_oauth_failure(email, reason="master_degraded")
_record_outcome("master_degraded", reason="master subscription cancelled (Team)")
return None
# 原有 Team 流程 — SPEC-2 §3.1.2 改造:catch RegisterBlocked + plan_supported 检查 + quota probe
try:
bundle = _login_codex_via_browser_with_proxy(
email,
password,
mail_client=mail_client,
signup_profile=signup_profile,
playwright_proxy_url=playwright_proxy_url,
)
except RegisterBlocked as blocked:
if blocked.is_phone:
logger.error(
"[注册] %s Team OAuth 触发 add-phone (step=%s),账号已入 Team 席位标 AUTH_INVALID 待 reconcile",
email, blocked.step,
)
record_failure(
email, "oauth_phone_blocked",
f"Team OAuth 阶段触发 add-phone (step={blocked.step})",
step=blocked.step,
stage="run_post_register_oauth_team",
)
# Team 模式下账号已成功 invite,不能 delete_account(席位仍占着);标 AUTH_INVALID 让 reconcile 接管
update_account(
email,
status=STATUS_AUTH_INVALID,
workspace_account_id=get_chatgpt_account_id() or None,
)
_kick_team_seat_after_oauth_failure(email, reason="register_blocked_phone")
_record_outcome("oauth_phone_blocked", reason="OAuth 阶段触发 add-phone")
return None
record_failure(email, "exception", f"Team OAuth RegisterBlocked: {blocked.reason}",
stage="run_post_register_oauth_team")
update_account(email, status=STATUS_AUTH_INVALID,
workspace_account_id=get_chatgpt_account_id() or None)
_kick_team_seat_after_oauth_failure(email, reason="register_blocked_unexpected")
_record_outcome("oauth_failed", reason=f"unexpected RegisterBlocked: {blocked.reason}")
return None
if bundle:
# SPEC-2 shared/plan-type-whitelist §5 — plan_supported=False:account 已入 Team 但无法用,
# 标 AUTH_INVALID + 保留 auth_file 供调试,reconcile 会按 auth_invalid 流程接管
plan_supported = bundle.get(
"plan_supported", is_supported_plan(bundle.get("plan_type", ""))
)
auth_file = save_auth_file(bundle)
bundle_plan = bundle.get("plan_type", "unknown") # 已被 _exchange_auth_code 归一化为小写
if not plan_supported:
logger.error(
"[注册] %s Team OAuth 拿到 plan_type=%s 不在白名单 → AUTH_INVALID",
email, bundle.get("plan_type_raw") or bundle_plan,
)
record_failure(
email, "plan_unsupported",
f"Team OAuth bundle plan_type={bundle.get('plan_type_raw')} 不在白名单",
plan_type=bundle_plan,
plan_type_raw=bundle.get("plan_type_raw"),
stage="run_post_register_oauth_team",
)
update_account(
email,
status=STATUS_AUTH_INVALID,
seat_type="codex",
auth_file=auth_file,
plan_type_raw=bundle.get("plan_type_raw"),
workspace_account_id=get_chatgpt_account_id() or None,
)
_kick_team_seat_after_oauth_failure(email, reason="plan_unsupported")
_record_outcome("plan_unsupported", plan=bundle_plan)
return None
# SPEC-2 FR-D1~D4 — quota probe(与 manual_account._finalize_account 对称)
seat_label = "chatgpt" if bundle_plan == "team" else "codex"
access_token = bundle.get("access_token")
account_id = bundle.get("account_id")
update_fields = {
"status": STATUS_ACTIVE,
"seat_type": seat_label,
"auth_file": auth_file,
"last_active_at": time.time(),
"workspace_account_id": get_chatgpt_account_id() or None,
"plan_type_raw": bundle.get("plan_type_raw"),
}
if access_token:
try:
quota_status, quota_info = check_codex_quota(access_token, account_id=account_id)
except Exception as exc:
# SPEC-2 FR-D4: probe 异常吞,降级 ACTIVE 但记一笔
record_failure(email, "quota_probe_network_error",
f"quota probe exception: {exc}",
stage="run_post_register_oauth_team")
quota_status, quota_info = "network_error", None
if quota_status == "ok" and isinstance(quota_info, dict):
update_fields["last_quota"] = quota_info
elif quota_status == "exhausted":
snapshot = quota_info.get("quota_info") if isinstance(quota_info, dict) else None
if snapshot:
update_fields["last_quota"] = snapshot
update_fields["status"] = STATUS_EXHAUSTED
update_fields["quota_exhausted_at"] = time.time()
update_fields["quota_resets_at"] = (
quota_info.get("resets_at") if isinstance(quota_info, dict) else int(time.time() + 18000)
)
elif quota_status == "no_quota":
snapshot = quota_info.get("quota_info") if isinstance(quota_info, dict) else None
if snapshot:
update_fields["last_quota"] = snapshot
update_fields["status"] = STATUS_AUTH_INVALID
record_failure(email, "no_quota_assigned",
"wham/usage 返回 no_quota(workspace 未分配 codex 配额)",
plan_type=bundle_plan,
stage="run_post_register_oauth_team",
raw_rate_limit=_extract_raw_rate_limit_str(quota_info))
elif quota_status == "auth_error":
update_fields["status"] = STATUS_AUTH_INVALID
record_failure(email, "auth_error_at_oauth",
"wham/usage 返回 401/403,token 失效",
stage="run_post_register_oauth_team")
elif quota_status == "network_error":
# 保留 ACTIVE,等下轮 cmd_check 校准
record_failure(email, "quota_probe_network_error",
"wham/usage 网络异常,ACTIVE 状态由下轮 cmd_check 校准",
stage="run_post_register_oauth_team")
update_account(email, **update_fields)
if update_fields["status"] == STATUS_ACTIVE:
_sync_ready_credential_to_targets(email, auth_file, stage_label="[注册]")
if update_fields["status"] == STATUS_ACTIVE:
logger.info("[注册] 账号就绪: %s (seat=%s)", email, seat_label)
_record_outcome("success", plan=bundle_plan)
return email
else:
logger.warning("[注册] %s 入池但状态=%s,需要后续处理", email, update_fields["status"])
_record_outcome("quota_issue", plan=bundle_plan, status=update_fields["status"])
return None
# Round 11 — OAuth bundle 缺失分支,与同函数其他失败路径保持 status 一致(都是 AUTH_INVALID)。
# 之前误用 STATUS_ACTIVE 让 auth_file 缺失的"半残账号"被 active 计数器忽略(api.py:_auto_check_loop
# 过滤 auth_file 存在),导致 cmd_rotate 每 30 分钟重复触发 fill 累积僵尸账号。
# 改 STATUS_AUTH_INVALID 后 reconcile 会按 auth_invalid 接管(KICK Team workspace + 清本地)。
# Registration success is not enough: callers must see failure when the
# account has no ready Codex credential bundle, otherwise fill/rotate can
# count a half-registered Team seat as usable.
update_account(email, status=STATUS_AUTH_INVALID, workspace_account_id=get_chatgpt_account_id() or None)
_kick_team_seat_after_oauth_failure(email, reason="bundle_missing")
logger.warning("[注册] 账号已加入 Team 但 Codex 登录失败,标 AUTH_INVALID 待 reconcile: %s", email)
_record_outcome("team_auth_missing", reason="已入 Team 席位但 Codex OAuth 未返回 bundle,需要补登录")
return None
def _get_mail_client_for_account(acc):
"""模块级 mail client 路由(Round 12 S4):按账号字段返回合适的 mail provider。
与 `cmd_rotate.ensure_account_mail` 等价,但**无 cache**(每次调用都构造新实例),
供 `_complete_registration` / `create_account_direct` / `create_new_account` 等
入口在没有外部 mail_client 时使用。
路由优先级(Round 12 wire-up M4 — 现在真正读 acc 字段了):
1. acc.mail_provider 显式指定 → 用 mail._resolve_provider_factory(name) 构造单 provider
(绕开 MAIL_PROVIDER_CHAIN 的 fallback dispatch,保证账号锁定到原 provider)
2. 否则走 get_mail_client() 默认(MAIL_PROVIDER_CHAIN 优先,然后 MAIL_PROVIDER)
无 acc 或 acc 无 mail_provider 字段 → 等价于直接 get_mail_client()。
本 helper **不抛 MailProviderUnavailable**;构造失败时让异常上抛,由调用方决定
是否启用 fallback 链 / 切到 RegisterPathRotator。
"""
from autoteam.mail import get_mail_client
provider_name = ((acc or {}).get("mail_provider") or "").strip().lower() if acc else ""
client = None
if provider_name:
# Round 12 wire-up M4 — 显式 acc 级 provider 锁定。
# _resolve_provider_factory 可能没有 export,使用 dynamic getattr 兜底回退默认.
try:
from autoteam import mail as _mail_pkg
resolver = getattr(_mail_pkg, "_resolve_provider_factory", None)
if callable(resolver):
factory = resolver(provider_name)
if callable(factory):
client = factory()
except Exception as exc:
logger.warning(
"[mail-route] 账号 %s mail_provider=%s resolve 失败,回退默认: %s",
(acc or {}).get("email"),
provider_name,
exc,
)
client = None
if client is None:
client = get_mail_client()
login = getattr(client, "login", None)
if callable(login):
try:
login()
except Exception as exc:
logger.warning(
"[mail-route] 账号 %s mail provider login 失败(将由上层决定是否切换): %s",
(acc or {}).get("email") if acc else None,
exc,
)
raise
return client
def _get_account_mail_client(acc: dict | None):
"""Compatibility wrapper for auth-repair paths that route mail per account."""
return _get_mail_client_for_account(acc)
def _resolve_mail_client_or_default(mail_client, acc=None):
"""统一处理 mail_client=None 入参:按 acc 路由或走全局默认。
Round 12 S4 — `_complete_registration` / `create_account_direct` /
`create_new_account` 的入口适配:旧调用方继续传 mail_client(完全兼容),新调用方
可省略 mail_client 让本 helper 按 acc / env 自动构造。
"""
if mail_client is not None:
return mail_client
return _get_mail_client_for_account(acc)
def _complete_registration(
email,
password,
invite_link,
mail_client=None,
*,
leave_workspace=False,
out_outcome=None,
acc=None,
auth_proxy_url: str | None = None,
playwright_proxy_url: str | None = None,
):
"""完成注册 + Codex 登录(从已有邀请链接继续)。out_outcome 透传给 _run_post_register_oauth。
Round 12 S3 cherry-pick (上游 `.upstream/manager.py:1225`): 一次性生成
SignupProfile 并透传给 register_with_invite,确保注册 about-you 与后续
Codex OAuth about-you 拿到完全一致的姓名/生日/年龄(降低 OpenAI 风控触发率).
Round 12 S4: `mail_client` 改为可选(默认 None) — None 时按 `acc`(可选)
通过 `_get_mail_client_for_account(acc)` 路由;旧调用方显式传 mail_client 时
完全保留旧行为(向后兼容)。
"""
from autoteam.invite import register_with_invite
from autoteam.signup_profile import generate_signup_profile
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
signup_profile = generate_signup_profile()
if auth_proxy_url is None and playwright_proxy_url is None:
try:
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
except Exception as exc:
logger.warning("[注册] IPv6 代理为必需但不可用,停止注册账号 %s: %s", email, exc)
if out_outcome is not None:
out_outcome["status"] = "ipv6_proxy_unavailable"
out_outcome["reason"] = str(exc)
out_outcome["last_email"] = email
return None
logger.info("[注册] 开始注册 %s...", email)
with sync_playwright() as p:
browser = None
context = None
page = None
try:
try:
launch_kwargs = get_playwright_launch_options(proxy_url=playwright_proxy_url)
except TypeError as exc:
if "proxy_url" not in str(exc):
raise
launch_kwargs = get_playwright_launch_options()
browser = p.chromium.launch(**launch_kwargs)
context = browser.new_context(**get_playwright_context_options())
page = context.new_page()
result, password = register_with_invite(
page,
invite_link,
email,
mail_client,
password=password,
signup_profile=signup_profile,
)
finally:
close_playwright_objects(page, context, browser, logger=logger, label="complete-registration")
if not result:
logger.error("[注册] 注册 %s 失败", email)
_release_account_ipv6_proxy(email)
if out_outcome is not None:
out_outcome["status"] = "register_failed"
out_outcome["reason"] = "invite 注册链路失败(register_with_invite 返回 False)"
out_outcome["last_email"] = email
return None
post_result = _run_post_register_oauth(
email,
password,
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
signup_profile=signup_profile,
auth_proxy_url=auth_proxy_url,
playwright_proxy_url=playwright_proxy_url,
)
if not post_result:
_release_account_ipv6_proxy(email)
return post_result
def _check_pending_invites(chatgpt_api, mail_client, *, leave_workspace=False, out_outcome=None):
"""
检查 pending invites 中是否有已收到邮件的邀请,有则继续完成注册。
leave_workspace: 注册成功后是否自动退出 Team 走 personal OAuth。
out_outcome: 透传给 _complete_registration / _run_post_register_oauth,
让上游(_cmd_fill_personal)能拿到 kick_failed / oauth_failed 的分类。
返回成功完成的邮箱列表。
"""
account_id = get_chatgpt_account_id()
result = chatgpt_api._api_fetch("GET", f"/backend-api/accounts/{account_id}/invites")
if result["status"] != 200:
return []
inv_data = json.loads(result["body"])
invites = inv_data if isinstance(inv_data, list) else inv_data.get("invites", inv_data.get("account_invites", []))
if not invites:
return []
logger.info("[Pending] 发现 %d 个待处理邀请", len(invites))
completed = []
for inv in invites:
inv_email = inv.get("email_address", "")
logger.info("[Pending] 检查 %s 是否已收到邮件...", inv_email)
# 从 CloudMail 搜索该邮箱的邀请邮件
emails = mail_client.search_emails_by_recipient(inv_email, size=5)
invite_link = None
for em in emails:
sender = em.get("sendEmail", "").lower()
if "openai" in sender:
invite_link = mail_client.extract_invite_link(em)
if invite_link:
break
if not invite_link:
logger.info("[Pending] %s 未收到邮件,跳过", inv_email)
continue
logger.info("[Pending] %s 已收到邀请邮件,继续注册流程...", inv_email)
# 确保本地有账号记录
acc = find_account(load_accounts(), inv_email)
if acc:
password = acc.get("password") or random_password()
else:
password = random_password()
add_account(
inv_email,
password,
workspace_account_id=get_chatgpt_account_id() or None,
mail_provider=_mail_provider_name_for_client(mail_client),
)
# 关闭 ChatGPT 浏览器再注册
chatgpt_api.stop()
email = _complete_registration(
inv_email,
password,
invite_link,
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
)
if email:
completed.append(email)
return completed
def _is_email_in_team(email):
"""检查邮箱是否已实际进入 Team。"""
chatgpt = None
try:
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
members, _ = fetch_team_state(chatgpt)
return any((m.get("email", "") or "").lower() == email.lower() for m in members)
except Exception as exc:
logger.warning("[直接注册] 检查 Team 成员失败: %s", exc)
return False
finally:
if chatgpt:
chatgpt.stop()
def _wait_for_invite_link(mail_client, email: str, *, mail_account_id=None, timeout: int | None = None) -> str | None:
timeout = MAIL_TIMEOUT if timeout is None else max(1, int(timeout))
deadline = time.time() + timeout
while time.time() < deadline:
try:
emails = mail_client.search_emails_by_recipient(email, size=10, account_id=mail_account_id)
except TypeError:
emails = mail_client.search_emails_by_recipient(email, size=10)
except Exception as exc:
logger.warning("[邀请创建] 读取邀请邮件失败: %s", exc)
emails = []
for email_data in emails:
invite_link = mail_client.extract_invite_link(email_data)
if invite_link:
return invite_link
elapsed = max(0, int(timeout - (deadline - time.time())))
logger.info("[邀请创建] 等待邀请邮件: %s (%ds/%ds)", email, elapsed, timeout)
time.sleep(3)
return None
def _cleanup_failed_created_account(
email: str | None,
mail_account_id,
mail_client,
reason: str,
*,
chatgpt_api=None,
) -> None:
email = _normalized_email(email)
if not email:
return
logger.warning("[创建] 丢弃失败新账号: %s(%s)", email, reason)
try:
_discard_auth_repair_failed_account_record(email, reason, status=STATUS_STANDBY)
except Exception:
pass
try:
if chatgpt_api is not None and not _chatgpt_session_ready(chatgpt_api):
chatgpt_api.start()
delete_managed_account(
email,
remove_remote=True,
remove_cloudmail=True,
sync_cpa_after=False,
chatgpt_api=chatgpt_api,
mail_client=mail_client,
)
return
except Exception as exc:
logger.warning("[创建] delete_managed_account 清理失败: %s", exc)
if mail_account_id is not None:
try:
mail_client.delete_account(mail_account_id)
except Exception as exc:
logger.warning("[创建] 删除临时邮箱失败: %s", exc)
_release_account_ipv6_proxy(email)
def create_account_via_invite(
chatgpt_api,
mail_client=None,
*,
leave_workspace=False,
out_outcome=None,
acc=None,
):
"""显式远端邀请 -> 邀请链接注册 -> 远端成员确认 -> Codex 凭证。"""
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
if not _chatgpt_session_ready(chatgpt_api):
chatgpt_api.start()
if not _prepare_remote_capacity_for_new_seat(chatgpt_api, stage_label="[邀请创建]"):
return None
mail_account_id = None
email = None
try:
mail_account_id, email = mail_client.create_temp_email()
except Exception as exc:
logger.warning("[邀请创建] 创建临时邮箱失败: %s", exc)
return None
password = random_password()
provider_name = _mail_provider_name_for_client(mail_client)
add_account(
email,
password,
cloudmail_account_id=mail_account_id if provider_name == "cloudmail" else None,
workspace_account_id=get_chatgpt_account_id() or None,
mail_provider=provider_name,
mail_account_id=mail_account_id,
)
try:
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
except Exception as exc:
logger.warning("[邀请创建] IPv6 代理为必需但不可用,停止创建账号 %s: %s", email, exc)
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"ipv6_proxy_required_unavailable",
chatgpt_api=chatgpt_api,
)
return None
try:
if not _has_remote_capacity_for_new_seat(chatgpt_api, stage_label="[邀请创建]"):
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"remote_capacity_full_before_invite",
chatgpt_api=chatgpt_api,
)
return None
if not invite_to_team(chatgpt_api, email, seat_type="usage_based"):
logger.warning("[邀请创建] 发送 Team 邀请失败或被拒绝: %s", email)
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"invite_rejected",
chatgpt_api=chatgpt_api,
)
return None
invite_link = _wait_for_invite_link(mail_client, email, mail_account_id=mail_account_id)
if not invite_link:
logger.warning("[邀请创建] 未收到邀请链接: %s", email)
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"invite_email_timeout",
chatgpt_api=chatgpt_api,
)
return None
if _chatgpt_session_ready(chatgpt_api):
chatgpt_api.stop()
completed_email = _complete_registration(
email,
password,
invite_link,
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
acc=acc,
auth_proxy_url=auth_proxy_url,
playwright_proxy_url=playwright_proxy_url,
)
if not completed_email:
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"invite_registration_failed",
chatgpt_api=chatgpt_api,
)
return None
if not leave_workspace:
if not _chatgpt_session_ready(chatgpt_api):
chatgpt_api.start()
if not _wait_for_email_in_team(email, chatgpt_api=chatgpt_api, timeout=75, stage_label="[邀请创建]"):
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"remote_member_not_visible",
chatgpt_api=chatgpt_api,
)
return None
logger.info("[邀请创建] 新账号已完成邀请注册: %s", email)
ready_acc = find_account(load_accounts(), email)
_sync_ready_credential_to_targets(email, (ready_acc or {}).get("auth_file"), stage_label="[邀请创建]")
return email
except Exception as exc:
logger.warning("[邀请创建] 新账号创建异常: %s", exc)
_cleanup_failed_created_account(
email,
mail_account_id,
mail_client,
"invite_create_exception",
chatgpt_api=chatgpt_api,
)
return None
_DIRECT_EMAIL_SELECTORS = (
'input[name="email"], input[type="email"], input[id="email"], '
'input[autocomplete="email"], input[autocomplete="username"], '
'input[placeholder*="email" i], input[placeholder*="Email" i]'
)
_DIRECT_PASSWORD_SELECTORS = 'input[name="password"], input[type="password"]'
_DIRECT_CODE_SELECTORS = 'input[name="code"], input[placeholder*="验证码"], input[placeholder*="code" i]'
_DIRECT_MULTI_CODE_SELECTOR = 'input[maxlength="1"]'
_DIRECT_CODE_RENDER_TIMEOUT = 25
def _safe_invite_screenshot(page, name):
from autoteam.invite import screenshot
try:
screenshot(page, name)
except Exception as exc:
logger.debug("[直接注册] 截图失败 %s: %s", name, exc)
def _page_excerpt(page, limit=240):
try:
return page.locator("body").inner_text(timeout=1500)[:limit].replace("\n", " ")
except Exception:
return ""
def _quota_window_label(window: str | None) -> str:
if window == "weekly":
return "周"
if window == "combined":
return "5h和周"
if window == "primary":
return "5h"
return "额度"
def _pending_historical_exhausted_info(quota_info, now=None):
"""仅当历史额度快照对应的耗尽窗口尚未重置时,才返回耗尽详情。"""
exhausted_info = get_quota_exhausted_info(quota_info)
if not exhausted_info:
return None
current_ts = time.time() if now is None else now
resets_at = quota_result_resets_at(exhausted_info)
if resets_at and current_ts >= resets_at:
return None
return exhausted_info
def _historical_low_quota_info(acc, threshold, now=None):
"""Return a low-quota decision from saved quota when live quota is unavailable."""
quota_info = acc.get("last_quota") if isinstance(acc, dict) else None
if not isinstance(quota_info, dict):
return None
current_ts = time.time() if now is None else now
exhausted_info = _pending_historical_exhausted_info(quota_info, now=current_ts)
if exhausted_info:
result = dict(exhausted_info)
result["remaining"] = 0
result["historical_low"] = True
return result
try:
primary_pct = int(quota_info.get("primary_pct", 0) or 0)
threshold_int = int(threshold)
except Exception:
return None
remaining = max(0, 100 - primary_pct)
if remaining >= threshold_int:
return None
try:
primary_resets_at = int(quota_info.get("primary_resets_at", 0) or 0)
except Exception:
primary_resets_at = 0
if primary_resets_at and current_ts >= primary_resets_at:
return None
return {
"window": "primary",
"resets_at": primary_resets_at or int(current_ts + 18000),
"quota_info": quota_info,
"remaining": remaining,
"historical_low": True,
}
def _first_visible_editable_locator(page, selectors, timeout=800):
candidates = selectors if isinstance(selectors, (list, tuple)) else [selectors]
for selector in candidates:
try:
locator = page.locator(selector).first
if not locator.is_visible(timeout=timeout):
continue
if locator.is_editable(timeout=timeout):
return locator
except Exception:
continue
return None
def _visible_single_char_code_inputs(page, timeout=300):
try:
visible_inputs = []
for locator in page.locator(_DIRECT_MULTI_CODE_SELECTOR).all():
try:
if locator.is_visible(timeout=timeout) and locator.is_editable(timeout=timeout):
visible_inputs.append(locator)
except Exception:
continue
if len(visible_inputs) >= 4:
return visible_inputs
except Exception:
pass
return []
def _wait_for_direct_code_target(page, timeout=_DIRECT_CODE_RENDER_TIMEOUT):
deadline = time.time() + max(0.0, float(timeout))
while time.time() < deadline:
split_inputs = _visible_single_char_code_inputs(page, timeout=300)
if split_inputs:
return {"mode": "split", "target": split_inputs}
code_input = _first_visible_editable_locator(page, _DIRECT_CODE_SELECTORS, timeout=300)
if code_input:
return {"mode": "single", "target": code_input}
step = _detect_direct_register_step(page)
if step != "code":
return {"mode": "advanced", "step": step}
time.sleep(0.5)
step = _detect_direct_register_step(page)
if step != "code":
return {"mode": "advanced", "step": step}
return {"mode": "timeout", "step": "code"}
def _submit_direct_verification_code(page, code_target, verification_code):
mode = (code_target or {}).get("mode")
target = (code_target or {}).get("target")
submit_field = None
current_step = _detect_direct_register_step(page)
if mode == "split" and isinstance(target, list):
for index, char in enumerate(verification_code):
if index >= len(target):
break
target[index].fill(char)
time.sleep(0.1)
if target:
submit_field = target[0]
elif mode == "single" and target:
target.fill(verification_code)
submit_field = target
else:
return _detect_direct_register_step(page)
time.sleep(0.5)
if submit_field is not None:
_click_primary_auth_button(page, submit_field, ["Continue", "继续", "Verify"])
return _wait_for_direct_step_change(page, current_step, timeout=20)
def _collect_date_spinbutton_meta(page):
try:
return page.evaluate(
"""() => {
const byIdsText = (rawIds) => {
return (rawIds || '')
.split(/\\s+/)
.filter(Boolean)
.map(id => {
const el = document.getElementById(id);
return el ? (el.textContent || '').trim() : '';
})
.filter(Boolean)
.join(' ');
};
return Array.from(document.querySelectorAll('[role="spinbutton"]')).map((el, index) => ({
index,
text: (el.textContent || '').trim(),
ariaLabel: el.getAttribute('aria-label') || '',
ariaValueText: el.getAttribute('aria-valuetext') || '',
ariaValueMin: el.getAttribute('aria-valuemin') || '',
ariaValueMax: el.getAttribute('aria-valuemax') || '',
placeholder: el.getAttribute('placeholder') || '',
dataType: el.getAttribute('data-type') || el.dataset?.type || '',
labelledText: byIdsText(el.getAttribute('aria-labelledby')),
describedText: byIdsText(el.getAttribute('aria-describedby')),
}));
}"""
)
except Exception:
return []
def _infer_date_spinbutton_kind(meta):
text_parts = [
meta.get("text", ""),
meta.get("ariaLabel", ""),
meta.get("ariaValueText", ""),
meta.get("placeholder", ""),
meta.get("dataType", ""),
meta.get("labelledText", ""),
meta.get("describedText", ""),
]
lowered = " ".join(part for part in text_parts if part).lower()
def _to_int(value):
try:
return int(str(value).strip())
except Exception:
return None
max_val = _to_int(meta.get("ariaValueMax"))
if any(token in lowered for token in ("year", "yyyy", "yy", "年")):
return "year"
if any(token in lowered for token in ("month", "mm", "月")):
return "month"
if any(token in lowered for token in ("day", "dd", "日")):
return "day"
if max_val is not None:
if max_val > 31:
return "year"
if max_val == 12:
return "month"
if max_val <= 31:
return "day"
return None
def _fill_about_you_birthday_by_meta(page, desired=None):
metas = _collect_date_spinbutton_meta(page)
if len(metas) < 3:
return False
if not desired:
desired = random_birthday()
kind_to_meta = {}
for meta in metas:
kind = _infer_date_spinbutton_kind(meta)
if kind and kind not in kind_to_meta:
kind_to_meta[kind] = meta
if not all(kind in kind_to_meta for kind in desired):
logger.info("[直接注册] 无法可靠识别生日字段顺序,降级为位置猜测")
return False
try:
for kind in ("year", "month", "day"):
meta = kind_to_meta[kind]
sb = page.locator('[role="spinbutton"]').nth(meta["index"])
sb.click(force=True)
time.sleep(0.2)
try:
page.keyboard.press("ControlOrMeta+A")
time.sleep(0.1)
except Exception:
pass
page.keyboard.type(desired[kind], delay=80)
time.sleep(0.3)
logger.info(
"[直接注册] 已按字段识别填入生日: year=%s month=%s day=%s | order=%s",
desired["year"],
desired["month"],
desired["day"],
{kind: kind_to_meta[kind]["index"] for kind in ("year", "month", "day")},
)
return True
except Exception as exc:
logger.warning("[直接注册] 按字段填写生日失败,降级为位置猜测: %s", exc)
return False
def _detect_direct_register_step(page):
url = (page.url or "").lower()
if _is_google_redirect(page):
return "google"
if "/api/auth/error" in url or url.endswith("/auth/error"):
return "error"
if "email-verification" in url:
return "code"
if "about-you" in url:
return "profile"
if "create-account/password" in url or url.endswith("/password"):
return "password"
if "chatgpt.com" in url and "auth" not in url:
return "completed"
try:
if _first_visible_editable_locator(page, _DIRECT_PASSWORD_SELECTORS, timeout=300):
return "password"
except Exception:
pass
try:
if _first_visible_editable_locator(page, _DIRECT_CODE_SELECTORS, timeout=300):
return "code"
except Exception:
pass
try:
if page.locator('input[name="name"], [role="spinbutton"]').first.is_visible(timeout=300):
return "profile"
except Exception:
pass
try:
if _first_visible_editable_locator(page, _DIRECT_EMAIL_SELECTORS, timeout=300):
return "email"
except Exception:
pass
if "log-in-or-create-account" in url or url.endswith("/auth/login"):
return "email"
if "create-account" in url or "password" in url:
return "password"
return "unknown"
def _wait_for_direct_register_step(page, allowed_steps, timeout=15):
deadline = time.time() + timeout
while time.time() < deadline:
step = _detect_direct_register_step(page)
if step == "error":
return step
if step in allowed_steps:
return step
time.sleep(0.5)
return _detect_direct_register_step(page)
def _wait_for_direct_step_change(page, current_step, timeout=15):
deadline = time.time() + timeout
while time.time() < deadline:
step = _detect_direct_register_step(page)
if step != current_step:
return step
time.sleep(0.5)
return _detect_direct_register_step(page)
def _complete_direct_about_you(page, signup_profile: SignupProfile | None = None):
"""尽量完成 about-you 页面,兼容不同生日字段顺序。"""
if "about-you" not in (page.url or "").lower():
return True
# 本账号整个注册周期内固定一份身份数据,避免多次点提交导致生日漂移
signup_profile = signup_profile or generate_signup_profile()
identity_bday = dict(signup_profile.birthday or {})
identity_name = signup_profile.full_name
identity_age = signup_profile.age_text
birthday_orders = signup_profile.positional_birthday_orders()
for attempt, values in enumerate(birthday_orders, 1):
if "about-you" not in (page.url or "").lower():
return True
try:
name_input = page.locator('input[name="name"]').first
if name_input.is_visible(timeout=2000):
try:
if name_input.is_editable(timeout=500):
name_input.fill(identity_name)
logger.info("[直接注册] 填入姓名: %s", identity_name)
time.sleep(0.3)
except Exception:
pass
except Exception:
name_input = None
spinbuttons = []
try:
spinbuttons = page.locator('[role="spinbutton"]').all()
except Exception:
spinbuttons = []
if len(spinbuttons) >= 3:
filled = _fill_about_you_birthday_by_meta(page, desired=identity_bday)
if not filled:
for label_sel in ("text=生日日期", "text=Date of birth"):
try:
page.locator(label_sel).first.click(timeout=1000)
time.sleep(0.3)
break
except Exception:
continue
try:
for sb, val in zip(spinbuttons[:3], values):
sb.click(force=True)
time.sleep(0.2)
try:
page.keyboard.press("ControlOrMeta+A")
time.sleep(0.1)
except Exception:
pass
page.keyboard.type(val, delay=80)
time.sleep(0.3)
logger.info("[直接注册] 尝试按位置填入生日(第 %d 次): %s/%s/%s", attempt, *values)
except Exception as exc:
logger.warning("[直接注册] 生日字段填写失败(第 %d 次): %s", attempt, exc)
else:
try:
age_input = page.locator(
'input[name="age"], input[placeholder*="年龄"], input[placeholder*="Age"]'
).first
if age_input.is_visible(timeout=2000) and age_input.is_editable(timeout=500):
age_input.fill(identity_age)
logger.info("[直接注册] 填入年龄: %s", identity_age)
except Exception:
pass
submitted = False
for btn_selector in (
'button:has-text("完成帐户创建")',
'button:has-text("Create account")',
'button:has-text("Continue")',
'button:has-text("继续")',
'button[type="submit"]',
):
try:
btn = page.locator(btn_selector).first
if btn.is_visible(timeout=1000):
btn.click()
submitted = True
break
except Exception:
continue
if not submitted:
try:
page.keyboard.press("Enter")
except Exception:
pass
next_step = _wait_for_direct_register_step(
page,
{"profile", "completed", "code", "password", "email", "google"},
timeout=12,
)
logger.info("[直接注册] 提交资料后状态: %s | URL: %s", next_step, page.url)
# 提交 about-you 后最容易撞 add-phone:这里直接检测并 raise,让上层放弃账号
from autoteam.invite import assert_not_blocked # 局部导入避开循环
assert_not_blocked(page, "about_you_submit")
if next_step != "profile":
return True
logger.warning("[直接注册] about-you 页面仍未完成 | URL: %s | body=%s", page.url, _page_excerpt(page))
return False
def _register_direct_once(
mail_client,
email,
password,
cloudmail_account_id=None,
signup_profile=None,
playwright_proxy_url: str | None = None,
):
"""执行一次直接注册,返回是否完成注册并进入 Team。
在邮箱/密码/验证码/about-you 四个提交节点调用 assert_not_blocked,
一旦命中 add-phone / duplicate 就抛 RegisterBlocked,由 create_account_direct 分流处理。
"""
from autoteam.invite import RegisterBlocked, assert_not_blocked
logger.info("[直接注册] %s", email)
signup_url = "https://chatgpt.com/auth/login"
with sync_playwright() as p:
browser = None
context = None
page = None
cleanup_done = False
def cleanup_direct_register() -> None:
nonlocal cleanup_done
if cleanup_done:
return
cleanup_done = True
close_playwright_objects(page, context, browser, logger=logger, label="direct-register")
try:
try:
launch_kwargs = get_playwright_launch_options(proxy_url=playwright_proxy_url)
except TypeError as exc:
if "proxy_url" not in str(exc):
raise
launch_kwargs = get_playwright_launch_options()
if sys.platform.startswith("win"):
launch_kwargs["slow_mo"] = 100
browser = p.chromium.launch(**launch_kwargs)
context = browser.new_context(**get_playwright_context_options())
page = context.new_page()
page.goto(signup_url, wait_until="domcontentloaded", timeout=60000)
time.sleep(5)
for i in range(12):
html = page.content()[:2000].lower()
if "verify you are human" not in html and "challenge" not in page.url:
break
logger.info("[直接注册] 等待 Cloudflare... (%ds)", i * 5)
time.sleep(5)
_safe_invite_screenshot(page, "direct_01_login_page.png")
# OpenAI 首页有多种 A/B 测试变体,需要逐步找到邮箱输入框
try:
email_visible = page.locator(_DIRECT_EMAIL_SELECTORS).first.is_visible(timeout=3000)
if not email_visible:
# 尝试按优先级点击各种按钮来展开/跳转到邮箱输入
for sel, desc in [
('button:has-text("More options")', "More options"),
('button:has-text("更多选项")', "更多选项"),
('a:has-text("Sign up for free")', "Sign up for free"),
('button:has-text("Sign up for free")', "Sign up for free"),
('a:has-text("Sign up")', "Sign up"),
('button:has-text("Sign up")', "Sign up"),
('a:has-text("注册")', "注册"),
('button:has-text("注册")', "注册"),
('a:has-text("Log in")', "Log in"),
('button:has-text("Log in")', "Log in"),
]:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=1000):
logger.info("[直接注册] 点击: %s", desc)
btn.click()
time.sleep(2)
# 检查邮箱输入框是否出现了
step = _wait_for_direct_register_step(
page,
{"email", "password", "code", "profile", "completed", "google"},
timeout=10,
)
if step != "unknown":
break
except Exception:
continue
except Exception:
pass
_safe_invite_screenshot(page, "direct_02_signup.png")
logger.info("[直接注册] 输入邮箱: %s", email)
email_step = _wait_for_direct_register_step(
page,
{"email", "password", "code", "profile", "completed", "google"},
timeout=15,
)
logger.info("[直接注册] 邮箱步骤初始状态: %s | URL: %s", email_step, page.url)
if email_step == "google":
logger.warning("[直接注册] 邮箱步骤误跳转到 Google 登录页")
cleanup_direct_register()
return False, None
if email_step == "unknown":
logger.warning("[直接注册] 未识别到邮箱步骤 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
try:
for attempt in range(3):
step = _detect_direct_register_step(page)
if step != "email":
break
email_input = _first_visible_editable_locator(page, _DIRECT_EMAIL_SELECTORS, timeout=1500)
if not email_input:
logger.info("[直接注册] 邮箱输入框不可编辑,等待页面继续跳转...")
next_step = _wait_for_direct_step_change(page, "email", timeout=10)
if next_step != "email":
break
logger.warning("[直接注册] 邮箱输入框仍不可编辑,继续重试 | URL: %s", page.url)
continue
email_input.fill(email)
time.sleep(0.5)
logger.info("[直接注册] 邮箱已填入,点击 Continue... (attempt %d)", attempt + 1)
_safe_invite_screenshot(page, f"direct_02b_email_filled_{attempt}.png")
_click_primary_auth_button(page, email_input, ["Continue", "继续"])
next_step = _wait_for_direct_step_change(page, "email", timeout=15)
logger.info("[直接注册] 点击 Continue 后状态: %s | URL: %s", next_step, page.url)
_safe_invite_screenshot(page, f"direct_02c_after_continue_{attempt}.png")
if next_step == "google":
_safe_invite_screenshot(page, f"direct_03_google_redirect_attempt{attempt + 1}.png")
logger.warning("[直接注册] 邮箱步骤误跳转到 Google 登录,返回重试... (attempt %d)", attempt + 1)
page.go_back(wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
continue
if next_step != "email":
break
email_input = _first_visible_editable_locator(page, _DIRECT_EMAIL_SELECTORS, timeout=600)
if not email_input:
logger.info("[直接注册] 邮箱框已只读/跳转中,额外等待页面推进...")
next_step = _wait_for_direct_step_change(page, "email", timeout=10)
logger.info("[直接注册] 额外等待后状态: %s | URL: %s", next_step, page.url)
if next_step != "email":
break
logger.warning(
"[直接注册] 点击 Continue 后仍停留在邮箱步骤,准备重试... | URL: %s | body=%s",
page.url,
_page_excerpt(page),
)
except Exception as exc:
logger.warning("[直接注册] 邮箱步骤异常: %s | URL: %s", exc, page.url)
_safe_invite_screenshot(page, "direct_03_after_email.png")
current_step = _detect_direct_register_step(page)
logger.info("[直接注册] 邮箱步骤结束状态: %s | URL: %s", current_step, page.url)
if current_step == "google":
logger.warning("[直接注册] 邮箱步骤仍停留在 Google 登录页")
cleanup_direct_register()
return False, None
if current_step == "error":
logger.warning("[直接注册] 邮箱步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
if current_step == "unknown":
logger.warning("[直接注册] 邮箱步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
if current_step == "email":
logger.warning("[直接注册] 邮箱步骤未推进 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
try:
assert_not_blocked(page, "email_submit")
except RegisterBlocked:
cleanup_direct_register()
raise
# 等待页面跳转完成(可能跳到 create-account/password)
password_step = _wait_for_direct_register_step(
page,
{"password", "code", "profile", "completed", "google", "email", "error"},
timeout=15,
)
logger.info("[直接注册] 密码页检测状态: %s | URL: %s", password_step, page.url)
_safe_invite_screenshot(page, "direct_03b_before_password.png")
if password_step == "error":
logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
if password_step == "unknown":
logger.warning("[直接注册] 无法识别密码/验证码步骤 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
try:
for attempt in range(2):
if _detect_direct_register_step(page) != "password":
logger.info("[直接注册] 未检测到密码输入框,跳过")
break
pwd_input = _first_visible_editable_locator(page, _DIRECT_PASSWORD_SELECTORS, timeout=1500)
if not pwd_input:
logger.info("[直接注册] 密码输入框不可编辑,等待页面继续跳转...")
next_step = _wait_for_direct_step_change(page, "password", timeout=10)
if next_step != "password":
break
logger.warning("[直接注册] 密码输入框仍不可编辑,继续重试 | URL: %s", page.url)
continue
logger.info("[直接注册] 设置密码")
pwd_input.fill(password)
time.sleep(0.5)
_click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"])
next_step = _wait_for_direct_step_change(page, "password", timeout=15)
logger.info("[直接注册] 提交密码后状态: %s | URL: %s", next_step, page.url)
if next_step == "google":
_safe_invite_screenshot(page, f"direct_04_google_redirect_attempt{attempt + 1}.png")
logger.warning("[直接注册] 密码步骤误跳转到 Google 登录,返回重试... (attempt %d)", attempt + 1)
page.go_back(wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
continue
if next_step != "password":
break
pwd_input = _first_visible_editable_locator(page, _DIRECT_PASSWORD_SELECTORS, timeout=600)
if not pwd_input:
logger.info("[直接注册] 密码框已只读/跳转中,额外等待页面推进...")
next_step = _wait_for_direct_step_change(page, "password", timeout=10)
logger.info("[直接注册] 额外等待后状态: %s | URL: %s", next_step, page.url)
if next_step != "password":
break
except Exception as exc:
logger.warning("[直接注册] 密码步骤异常: %s | URL: %s", exc, page.url)
_safe_invite_screenshot(page, "direct_04_after_password.png")
current_step = _detect_direct_register_step(page)
if current_step == "google":
logger.warning("[直接注册] 密码步骤仍停留在 Google 登录页")
cleanup_direct_register()
return False, None
if current_step == "error":
logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
if current_step == "unknown":
logger.warning("[直接注册] 密码步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
if current_step == "email":
logger.warning("[直接注册] 提交密码前流程回退到邮箱页 | URL: %s | body=%s", page.url, _page_excerpt(page))
cleanup_direct_register()
return False, None
try:
assert_not_blocked(page, "password_submit")
except RegisterBlocked:
cleanup_direct_register()
raise
code_step = _detect_direct_register_step(page)
code_target = None
if code_step == "code":
logger.info("[直接注册] 等待验证码输入框渲染...")
code_target_result = _wait_for_direct_code_target(page, timeout=_DIRECT_CODE_RENDER_TIMEOUT)
mode = code_target_result.get("mode")
if mode in {"single", "split"}:
code_target = code_target_result
logger.info("[直接注册] 验证码输入框已就绪(mode=%s)", mode)
elif mode == "advanced":
logger.info(
"[直接注册] 验证码页等待期间流程已推进到 %s | URL: %s",
code_target_result.get("step"),
page.url,
)
else:
logger.warning(
"[直接注册] 已停留在 email-verification 但 %ss 内未就绪验证码输入框 | URL: %s",
_DIRECT_CODE_RENDER_TIMEOUT,
page.url,
)
if code_target:
logger.info("[直接注册] 等待验证码...")
verification_code = None
start_t = time.time()
while time.time() - start_t < MAIL_TIMEOUT:
emails = mail_client.search_emails_by_recipient(email, size=10, account_id=cloudmail_account_id)
for em in emails:
verification_code = mail_client.extract_verification_code(em)
if verification_code:
break
if verification_code:
break
elapsed = int(time.time() - start_t)
print(f"\r 等待验证码... ({elapsed}s)", end="", flush=True)
time.sleep(3)
if verification_code:
logger.info("[直接注册] 输入验证码: %s", verification_code)
next_step = _submit_direct_verification_code(page, code_target, verification_code)
logger.info("[直接注册] 验证码提交后状态: %s | URL: %s", next_step, page.url)
else:
logger.error("[直接注册] 未收到验证码")
cleanup_direct_register()
return False, None
_safe_invite_screenshot(page, "direct_05_after_code.png")
logger.info("[直接注册] 当前 URL: %s", page.url)
try:
assert_not_blocked(page, "code_submit")
except RegisterBlocked:
cleanup_direct_register()
raise
try:
_complete_direct_about_you(page, signup_profile=signup_profile)
except RegisterBlocked:
# add-phone / duplicate 必须穿透给 create_account_direct 处理
cleanup_direct_register()
raise
except Exception as exc:
logger.warning("[直接注册] about-you 步骤异常: %s | URL: %s", exc, page.url)
_safe_invite_screenshot(page, "direct_06_after_profile.png")
logger.info("[直接注册] 当前 URL: %s", page.url)
try:
join_btn = page.locator('button:has-text("Accept"), button:has-text("Join"), button:has-text("加入")').first
if join_btn.is_visible(timeout=5000):
join_btn.click()
time.sleep(5)
except Exception:
pass
_safe_invite_screenshot(page, "direct_07_final.png")
current_url = page.url
success = "chatgpt.com" in current_url and "auth" not in current_url and not _is_google_redirect(page)
if success:
logger.info("[直接注册] 注册成功并已加入 workspace!")
else:
logger.warning("[直接注册] 注册可能未完成,URL: %s", current_url)
# Round 11 四轮 — 注册成功后从 chatgpt.com context 抽 __Secure-next-auth.session-token,
# 透给后续 personal OAuth 用,跳过 auth.openai.com /log-in 页(实测刚踢出 Team 的新号
# 在 /log-in 页 Continue 按钮变灰 → 卡死)。SessionCodexAuthFlow._inject_auth_cookies
# 是同样的注入模式(主号专用),这里把模式扩展给 personal 子号。
session_token = _extract_session_token_from_context(context) if success else None
cleanup_direct_register()
return success, session_token
finally:
cleanup_direct_register()
def _extract_session_token_from_context(context):
"""从 Playwright BrowserContext 的 cookies 里抽 __Secure-next-auth.session-token。
chatgpt.com 大 token 会被切成 .0 / .1 chunked 形式写入,需按 suffix 排序拼回。
返回拼接后的 session_token(str)或 None。
"""
try:
cookies = context.cookies()
except Exception as exc:
logger.warning("[直接注册] 抽 session_token 时读取 cookies 异常: %s", exc)
return None
session_parts = {}
session_token = None
for cookie in cookies:
name = cookie.get("name", "")
if name == "__Secure-next-auth.session-token":
session_token = cookie.get("value", "")
elif name.startswith("__Secure-next-auth.session-token."):
suffix = name.rsplit(".", 1)[-1]
session_parts[suffix] = cookie.get("value", "")
if not session_token and session_parts:
session_token = "".join(session_parts[k] for k in sorted(session_parts))
if session_token:
logger.info("[直接注册] 已抽出 session_token (len=%d) 用于 personal OAuth 注入", len(session_token))
else:
logger.warning("[直接注册] cookies 中未发现 __Secure-next-auth.session-token,personal OAuth 仍走 /log-in")
return session_token or None
def _direct_register_float_env(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except (TypeError, ValueError):
return default
def _direct_register_int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except (TypeError, ValueError):
return default
def _cap_direct_register_parallel(requested: int) -> int:
requested = max(1, min(4, int(requested or 1)))
if requested <= 1:
return 1
try:
from autoteam.runtime_resources import collect_runtime_resource_snapshot
snapshot = collect_runtime_resource_snapshot()
except Exception as exc:
logger.debug("[直接注册] 读取资源快照失败,保留并行=%d: %s", requested, exc)
return requested
memory_ratio = snapshot.get("cgroup_memory_usage_ratio")
browser_live = int(snapshot.get("browser_process_live") or 0)
warn_ratio = _direct_register_float_env("AUTOTEAM_REGISTER_PARALLEL_MEMORY_WARN_RATIO", 0.72)
max_browser_live = _direct_register_int_env("AUTOTEAM_REGISTER_PARALLEL_MAX_BROWSER_LIVE", 4)
if memory_ratio is not None and float(memory_ratio) >= warn_ratio:
logger.warning(
"[直接注册] 内存占用 %.1f%% >= %.1f%%,并行注册从 %d 降级为 1,避免 OOM",
float(memory_ratio) * 100,
warn_ratio * 100,
requested,
)
return 1
if browser_live >= max_browser_live:
logger.warning(
"[直接注册] 浏览器进程数 %d >= %d,并行注册从 %d 降级为 1,避免进程堆积",
browser_live,
max_browser_live,
requested,
)
return 1
return requested
def _direct_register_parallel_size() -> int:
"""读取并行尝试数(DIRECT_REGISTER_PARALLEL),范围 [1, 4],再按本机资源降级。"""
try:
from autoteam.config import DIRECT_REGISTER_PARALLEL
raw = int(DIRECT_REGISTER_PARALLEL)
except Exception:
try:
raw = int(os.environ.get("DIRECT_REGISTER_PARALLEL", "1"))
except Exception:
raw = 1
return _cap_direct_register_parallel(raw)
def _attempt_chatgpt_signup_only(mail_client, *, acc=None, out_outcome=None) -> dict:
"""Run direct ChatGPT signup only; caller decides whether to persist/OAuth the winner."""
from autoteam.invite import RegisterBlocked
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
account_id, email = mail_client.create_temp_email()
password = random_password()
signup_profile = generate_signup_profile()
auth_proxy_url = ""
playwright_proxy_url = ""
local_outcome: dict = {}
def _record_outcome(status, **extra):
local_outcome.clear()
local_outcome.update(
status=status,
last_email=email,
register_attempts=register_attempts,
duplicate_swaps=duplicate_swaps,
**extra,
)
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(local_outcome)
def _discard_email(reason):
try:
mail_client.delete_account(account_id)
except Exception as exc:
logger.warning("[直接注册] 删除 %s 的临时邮箱失败(%s): %s", reason, email, exc)
_release_account_ipv6_proxy(email)
def _assign_proxy_or_fail(reason):
nonlocal auth_proxy_url, playwright_proxy_url
try:
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
return True
except Exception as exc:
logger.warning("[直接注册] IPv6 代理为必需但不可用,删除临时邮箱 %s: %s", email, exc)
_discard_email(reason)
record_failure(
email,
"ipv6_proxy_unavailable",
f"IPv6 proxy unavailable: {exc}",
register_attempts=register_attempts,
duplicate_swaps=duplicate_swaps,
)
_record_outcome("ipv6_proxy_unavailable", reason=str(exc))
return False
def _build_result(success_value: bool) -> dict:
return {
"success": bool(success_value),
"email": email,
"password": password,
"account_id": account_id,
"session_token": session_token,
"signup_profile": signup_profile,
"auth_proxy_url": auth_proxy_url,
"playwright_proxy_url": playwright_proxy_url,
"mail_client": mail_client,
"outcome": dict(local_outcome),
}
# 注册失败(非 duplicate)最多重试 3 次;duplicate 额外独立上限,防止 CloudMail 异常导致无限换邮箱
success = False
session_token = None # Round 11 四轮 — 注册成功后从 chatgpt.com 抽出来,透给 personal OAuth 跳过 /log-in
MAX_REGISTER_ATTEMPTS = 3
MAX_DUPLICATE_SWAPS = 5
register_attempts = 0
duplicate_swaps = 0
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable"):
return _build_result(False)
while register_attempts < MAX_REGISTER_ATTEMPTS:
logger.info(
"[直接注册] 开始注册尝试: %s(已试 %d/%d,duplicate 换邮箱 %d/%d)",
email,
register_attempts,
MAX_REGISTER_ATTEMPTS,
duplicate_swaps,
MAX_DUPLICATE_SWAPS,
)
try:
try:
success, session_token = _register_direct_once(
mail_client,
email,
password,
cloudmail_account_id=account_id,
signup_profile=signup_profile,
playwright_proxy_url=playwright_proxy_url,
)
except TypeError as exc:
if "playwright_proxy_url" not in str(exc):
raise
success, session_token = _register_direct_once(
mail_client,
email,
password,
cloudmail_account_id=account_id,
signup_profile=signup_profile,
)
except RegisterBlocked as blocked:
logger.error("[直接注册] %s 被阻断: %s", email, blocked)
if blocked.is_phone:
# 用户明确要求:不绕 add-phone,直接放弃本账号
_discard_email("phone_block")
record_failure(
email,
"phone_blocked",
f"add-phone 手机验证(step={blocked.step})",
step=blocked.step,
register_attempts=register_attempts,
duplicate_swaps=duplicate_swaps,
)
_record_outcome("phone_blocked", reason=f"add-phone 手机验证 step={blocked.step}", step=blocked.step)
return _build_result(False)
if blocked.is_duplicate:
# 邮箱重复 → 换一个全新的临时邮箱再来,不计入 register_attempts
duplicate_swaps += 1
if duplicate_swaps > MAX_DUPLICATE_SWAPS:
logger.error("[直接注册] duplicate 换邮箱已达上限 %d,放弃", MAX_DUPLICATE_SWAPS)
_discard_email("duplicate_exhausted")
record_failure(
email,
"duplicate_exhausted",
f"duplicate 换邮箱已达上限 {MAX_DUPLICATE_SWAPS}",
duplicate_swaps=duplicate_swaps,
)
_record_outcome(
"duplicate_exhausted",
reason=f"duplicate 换邮箱 {duplicate_swaps} 次仍失败",
)
return _build_result(False)
_discard_email("duplicate")
account_id, email = mail_client.create_temp_email()
password = random_password()
signup_profile = generate_signup_profile()
logger.info("[直接注册] 已换新临时邮箱: %s", email)
if not _assign_proxy_or_fail("ipv6_proxy_required_unavailable_after_duplicate"):
return _build_result(False)
continue
# 其他阻断按普通失败处理
success = False
session_token = None
except Exception as exc:
# Playwright 崩溃 / 网络异常等:不清理邮箱会让 CloudMail 积压,必须补一刀 discard 再抛。
logger.error(
"[直接注册] %s 注册时发生未分类异常,discard 邮箱后向上抛: %s",
email,
exc,
)
_discard_email("exception")
record_failure(
email,
"exception",
f"_register_direct_once 抛非 RegisterBlocked 异常: {exc}",
register_attempts=register_attempts,
duplicate_swaps=duplicate_swaps,
)
_record_outcome("exception", reason=f"未分类异常: {exc}")
raise
# 只有真正走完 _register_direct_once 的一次(无论成功失败)才消耗 register_attempts
register_attempts += 1
if success:
break
if _is_email_in_team(email):
logger.info("[直接注册] 远端确认账号已在 Team 中,视为注册成功: %s", email)
success = True
break
if register_attempts < MAX_REGISTER_ATTEMPTS:
logger.warning("[直接注册] 注册失败且账号不在 Team 中,60 秒后重试: %s", email)
time.sleep(60)
if not success:
logger.error(
"[直接注册] %s 多次注册失败(register_attempts=%d, duplicate_swaps=%d),删除临时账号",
email,
register_attempts,
duplicate_swaps,
)
_discard_email("register_failed")
record_failure(
email,
"register_failed",
f"连续 {register_attempts} 次注册尝试均未进入 Team",
register_attempts=register_attempts,
duplicate_swaps=duplicate_swaps,
)
_record_outcome("register_failed", reason=f"注册 {register_attempts} 次均未进入 Team")
return _build_result(False)
_record_outcome("success", reason="")
return _build_result(True)
def _direct_signup_mail_client_factory(base_mail_client, acc=None):
def _factory(idx: int):
if idx <= 0:
return base_mail_client
try:
client = type(base_mail_client)()
except Exception:
client = _resolve_mail_client_or_default(None, acc=acc)
login = getattr(client, "login", None)
if callable(login):
login()
return client
return _factory
def _discard_direct_signup_loser(outcome: dict) -> None:
email = outcome.get("email")
account_id = outcome.get("account_id")
mail_client = outcome.get("mail_client")
if email:
chatgpt = None
try:
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
remove_from_team(chatgpt, email, return_status=True)
except Exception as exc:
logger.warning("[直接注册] 清理并行 loser Team 席位失败: %s (%s)", email, exc)
finally:
if chatgpt is not None:
try:
chatgpt.stop()
except Exception:
pass
_release_account_ipv6_proxy(email)
if account_id is not None and mail_client is not None:
try:
mail_client.delete_account(account_id)
logger.info("[直接注册] 已丢弃并行 loser 临时邮箱: %s", email)
except Exception as exc:
logger.warning("[直接注册] 丢弃并行 loser 临时邮箱失败: %s (%s)", email, exc)
def _race_chatgpt_signup(mail_client_factory, *, parallel: int, acc=None, out_outcome=None) -> dict:
"""Run direct signup race and return the first successful signup-only outcome."""
import concurrent.futures
parallel = _cap_direct_register_parallel(parallel)
if parallel <= 1:
return _attempt_chatgpt_signup_only(mail_client_factory(0), acc=acc, out_outcome=out_outcome)
logger.info("[直接注册] 启动 %d 个并行注册尝试", parallel)
winner: dict | None = None
failures: list[dict] = []
losers: list[dict] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel, thread_name_prefix="direct-signup") as pool:
future_map = {
pool.submit(
_attempt_chatgpt_signup_only,
mail_client_factory(idx),
acc=acc,
): idx
for idx in range(parallel)
}
for future in concurrent.futures.as_completed(future_map):
try:
outcome = future.result()
except Exception as exc:
logger.warning("[直接注册] 并行 worker 异常: %s", exc)
failures.append({"success": False, "outcome": {"status": "exception", "reason": str(exc)}})
continue
if outcome.get("success") and winner is None:
winner = outcome
logger.info("[直接注册] 并行获胜: %s", outcome.get("email"))
elif outcome.get("success"):
losers.append(outcome)
else:
failures.append(outcome)
for loser in losers:
_discard_direct_signup_loser(loser)
selected = winner or (failures[-1] if failures else {"success": False, "outcome": {"status": "register_failed"}})
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(selected.get("outcome") or {})
out_outcome["direct_register_parallel"] = parallel
out_outcome["direct_register_failures"] = len(failures)
out_outcome["direct_register_losers"] = len(losers)
return selected
def create_account_direct(
mail_client=None,
*,
leave_workspace=False,
out_outcome=None,
acc=None,
path_rotator=None,
parallel: int | None = None,
):
"""
直接注册模式(域名已配置自动加入 workspace,不需要邀请)。
流程:创建邮箱 → 注册 ChatGPT → 自动加入 workspace → Codex 登录
leave_workspace: 加入 workspace 后是否立即退出,转为 personal 模式跑 OAuth。
out_outcome: 可选 dict,函数会把最终结局(success/phone_blocked/duplicate_exhausted/register_failed/...)
+ 统计信息(register_attempts / duplicate_swaps / last_email / reason)写入,供上游汇总。
parallel: direct signup race 并行度;None 时读取 DIRECT_REGISTER_PARALLEL 并按资源预算降级。
捕获 RegisterBlocked:
- is_phone=True: 当前邮箱已暴露给 OpenAI,立即删邮箱、整个账号放弃(return None)
- is_duplicate=True: 换个临时邮箱继续尝试,独立计数不消耗 register_attempts
- 其他异常: 归入现有 retry 计数
Round 12 S4: `mail_client` 改为可选(默认 None) — None 时按 `acc`(可选)走
`_get_mail_client_for_account(acc)` 路由;旧调用方显式传 mail_client 时完全
保留旧行为(向后兼容)。register-level 多 provider rotation 由 `MAIL_PROVIDER_CHAIN`
env 驱动:配置 chain 时 `get_mail_client()` 返回 `FallbackMailProvider`,
自动在 mail-API 级失败时降级;邮箱-粒度的 provider 切换通过 `RegisterPathRotator`
包装(参见 `autoteam.mail.register_dual_path`),业务调用方可自行编排。
Round 12 wire-up (M3): `path_rotator` 可选 — 传入 `RegisterPathRotator` 实例时
启用邮箱-粒度的 provider 切换(OTP_TIMEOUT / DOMAIN_REJECTED / INVITE_LINK_MISSING
自动切下一 provider 重试整个注册流程);None 时走旧逻辑(单 provider + retry 3 次).
"""
# Round 12 wire-up M3 — 邮箱-粒度 provider 切换。
if path_rotator is not None:
return _create_account_direct_via_rotator(
path_rotator,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
acc=acc,
parallel=parallel,
)
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
if parallel is None:
parallel = _direct_register_parallel_size()
else:
parallel = _cap_direct_register_parallel(parallel)
if parallel <= 1:
signup = _attempt_chatgpt_signup_only(mail_client, acc=acc, out_outcome=out_outcome)
else:
signup = _race_chatgpt_signup(
_direct_signup_mail_client_factory(mail_client, acc=acc),
parallel=parallel,
acc=acc,
out_outcome=out_outcome,
)
if not signup.get("success"):
return None
email = signup["email"]
password = signup["password"]
account_id = signup["account_id"]
session_token = signup.get("session_token")
signup_profile = signup.get("signup_profile")
auth_proxy_url = signup.get("auth_proxy_url") or ""
playwright_proxy_url = signup.get("playwright_proxy_url") or ""
winner_mail_client = signup.get("mail_client") or mail_client
add_account(
email,
password,
cloudmail_account_id=account_id,
workspace_account_id=get_chatgpt_account_id() or None,
mail_provider=_mail_provider_name_for_client(winner_mail_client),
mail_account_id=account_id,
)
post_result = _run_post_register_oauth(
email,
password,
winner_mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
chatgpt_session_token=session_token,
signup_profile=signup_profile,
auth_proxy_url=auth_proxy_url,
playwright_proxy_url=playwright_proxy_url,
)
if not post_result:
_release_account_ipv6_proxy(email)
return post_result
def _create_account_direct_via_rotator(
path_rotator, *, leave_workspace=False, out_outcome=None, acc=None, parallel: int | None = None
):
"""Round 12 wire-up (M3) — 用 RegisterPathRotator 编排邮箱-粒度的 provider 切换。
流程:
1. rotator.try_each(action) 内,对每个 strategy 调 factory() 拿一个 mail provider,
action 调用 create_account_direct(client=that_provider) 跑一遍注册。
2. 若 action 抛 OTP_TIMEOUT / INVITE_LINK_MISSING / DOMAIN_REJECTED, rotator
记账后切下一个 provider 重试整个流程(包括创建新邮箱)。
3. 全失败 → 抛 RegisterPathExhausted, 调用方决定如何写 out_outcome / 上报失败.
与 `create_account_direct(mail_client=...)` 串行路径的关键差异:rotator 路径
把"创建邮箱"与"注册账号"绑在同一个 strategy 内, OTP 超时不再死循环单 provider,
而是切到下一个 provider 重新创建邮箱.
"""
from autoteam.mail.register_dual_path import (
InviteLinkMissingError, # noqa: F401 — 仅供 docstring 引用,实际由调用方抛
RegisterPathExhausted,
classify_register_failure,
)
def _action(mail_client, provider_name, ctx):
# 在 action 内部执行注册.失败时 action 必须把 RegisterBlocked /
# TimeoutError / InviteLinkMissingError 原样抛, rotator 内的
# classify_register_failure 决定是否切下一 provider.
local_outcome = {}
result_email = create_account_direct(
mail_client=mail_client,
leave_workspace=leave_workspace,
out_outcome=local_outcome,
acc=acc,
path_rotator=None, # 避免递归
parallel=parallel,
)
if result_email is None:
status = local_outcome.get("status") or "register_failed"
reason = local_outcome.get("reason") or "create_account_direct returned None"
# 把外层 outcome 透传给 caller(最后一次 strategy 的细节也保留)
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(local_outcome)
out_outcome["provider"] = provider_name
out_outcome["provider_chain_history"] = list(ctx.get("history") or [])
# 抛一个携带 status/reason 的 ValueError, rotator 用 classify_register_failure
# 决定是否切换. OTP / domain / invite_missing 文本会被识别为切换信号.
raise RuntimeError(f"create_account_direct failed: {status}{reason}")
# 成功 → 透传 outcome
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(local_outcome)
out_outcome["provider"] = provider_name
out_outcome["provider_chain_history"] = list(ctx.get("history") or [])
return result_email
try:
return path_rotator.try_each(_action)
except RegisterPathExhausted as exhausted:
logger.error(
"[直接注册-rotator] 所有 provider 均失败: %s (history=%s)",
exhausted, exhausted.history,
)
if out_outcome is not None:
out_outcome.setdefault("status", "register_failed")
out_outcome["reason"] = (
f"register path exhausted: {len(exhausted.history)} provider(s) tried"
)
out_outcome["provider_chain_history"] = list(exhausted.history)
return None
except Exception as exc:
# rotator 对 OTHER 类直接 raise(spec §Q2);上层调用方决定如何处理
ftype = classify_register_failure(exc)
logger.warning(
"[直接注册-rotator] non-rotating failure (%s): %s",
ftype.value, exc,
)
if out_outcome is not None:
out_outcome.setdefault("status", "register_failed")
out_outcome["reason"] = f"unrecoverable failure ({ftype.value}): {exc}"
return None
def create_account_via_oauth(
mail_client=None,
*,
out_outcome=None,
acc=None,
):
"""OAuth-first 建号:跳过 chatgpt.com 注册与 Team 邀请,直接在 auth.openai.com
OAuth 流程内创建个人免费号(plan=free),token 回写 CPA / 同步目标。
流程(对应用户描述):
临时邮箱 → login_codex_via_browser(use_personal=True, 无 session_token)
直接 goto /oauth/authorize → 命中 log-in-or-create-account →
设密码 → 邮箱 OTP(_complete_oauth_login_challenge 自动收取) →
about-you(姓名/生日) → consent → bundle → 保存 PERSONAL + 同步远端。
与 invite/direct 路径的关键区别:完全不碰 chatgpt.com/auth/login(/api/auth/error
根因),也不发 Team 邀请(No active subscription / OTP 永不到根因)。账号是个人
free,不占 Team 席位。codex_auth.py:2317 已确认 use_personal+无 session_token
会跳过 step-0 直接走 auth_url,新邮箱在该页被创建。
"""
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
def _record_outcome(status, email=None, **extra):
if out_outcome is not None:
out_outcome.clear()
out_outcome.update(status=status, email=email, **extra)
# 1. 临时邮箱
mail_account_id = None
email = None
try:
mail_account_id, email = mail_client.create_temp_email()
except Exception as exc:
logger.warning("[OAuth建号] 创建临时邮箱失败: %s", exc)
_record_outcome("register_failed", reason=f"create_temp_email failed: {exc}")
return None
password = random_password()
signup_profile = generate_signup_profile()
provider_name = _mail_provider_name_for_client(mail_client)
add_account(
email,
password,
cloudmail_account_id=mail_account_id if provider_name == "cloudmail" else None,
mail_provider=provider_name,
mail_account_id=mail_account_id,
)
try:
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
except Exception as exc:
logger.warning("[OAuth建号] IPv6 代理为必需但不可用,停止创建账号 %s: %s", email, exc)
_cleanup_failed_created_account(email, mail_account_id, mail_client, "ipv6_proxy_required_unavailable")
_record_outcome("register_failed", email=email, reason="ipv6 proxy required unavailable")
return None
logger.info("[OAuth建号] 开始: %s(跳过 chatgpt 注册/邀请,直接 OAuth 建个人免费号)", email)
# 2. OAuth 建号(账号在 auth.openai.com 流程内被创建)
try:
bundle = _login_codex_via_browser_with_proxy(
email,
password,
mail_client=mail_client,
use_personal=True,
signup_profile=signup_profile,
playwright_proxy_url=playwright_proxy_url,
)
except RegisterBlocked as blocked:
if blocked.is_phone:
logger.error("[OAuth建号] %s 触发 add-phone (step=%s),放弃", email, blocked.step)
record_failure(
email, "oauth_phone_blocked",
f"OAuth 建号触发 add-phone (step={blocked.step})",
step=blocked.step, stage="create_account_via_oauth",
)
_cleanup_failed_created_account(email, mail_account_id, mail_client, "oauth_phone_blocked")
_record_outcome("oauth_phone_blocked", email=email, reason="触发 add-phone")
return None
logger.error("[OAuth建号] %s RegisterBlocked: %s", email, blocked.reason)
record_failure(
email, "exception",
f"OAuth 建号 RegisterBlocked: {blocked.reason}",
stage="create_account_via_oauth",
)
_cleanup_failed_created_account(email, mail_account_id, mail_client, "oauth_register_blocked")
_record_outcome("oauth_failed", email=email, reason=f"RegisterBlocked: {blocked.reason}")
return None
except Exception as exc:
logger.warning("[OAuth建号] %s OAuth 异常: %s", email, exc)
record_failure(
email, "exception",
f"OAuth 建号异常: {exc}", stage="create_account_via_oauth",
)
_cleanup_failed_created_account(email, mail_account_id, mail_client, "oauth_create_exception")
_record_outcome("oauth_failed", email=email, reason=f"oauth exception: {exc}")
return None
if not bundle:
logger.warning("[OAuth建号] %s 未拿到 bundle(建号/OTP/consent 任一步失败)", email)
record_failure(email, "oauth_failed", "OAuth 建号未返回 bundle", stage="create_account_via_oauth")
_cleanup_failed_created_account(email, mail_account_id, mail_client, "oauth_no_bundle")
_record_outcome("oauth_failed", email=email, reason="no bundle")
return None
# 3. plan 白名单 + 保存 + 同步(复用 personal 收尾逻辑,但无 Team kick)
plan_supported = bundle.get("plan_supported", is_supported_plan(bundle.get("plan_type", "")))
if not plan_supported:
logger.error(
"[OAuth建号] %s plan_type=%s 不在白名单,删除",
email, bundle.get("plan_type_raw") or bundle.get("plan_type"),
)
record_failure(
email, "plan_unsupported",
f"OAuth 建号 plan_type={bundle.get('plan_type_raw')} 不在白名单",
plan_type=bundle.get("plan_type"), plan_type_raw=bundle.get("plan_type_raw"),
stage="create_account_via_oauth",
)
_cleanup_failed_created_account(email, mail_account_id, mail_client, "plan_unsupported")
_record_outcome("plan_unsupported", email=email, plan=bundle.get("plan_type"))
return None
_attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
auth_file = save_auth_file(bundle)
update_fields = {
"status": STATUS_PERSONAL,
"seat_type": "codex",
"auth_file": auth_file,
"last_active_at": time.time(),
"plan_type_raw": bundle.get("plan_type_raw"),
}
if bundle.get("personal_workspace_id"):
update_fields["personal_workspace_id"] = bundle["personal_workspace_id"]
access_token = bundle.get("access_token")
if access_token:
try:
quota_status, quota_info = check_codex_quota(access_token, account_id=bundle.get("account_id"))
if quota_status == "ok" and isinstance(quota_info, dict):
update_fields["last_quota"] = quota_info
elif quota_status == "no_quota":
record_failure(
email, "no_quota_assigned",
"OAuth 建号个人 free plan 无 codex 配额",
stage="create_account_via_oauth",
raw_rate_limit=_extract_raw_rate_limit_str(quota_info),
)
except Exception as exc:
record_failure(
email, "quota_probe_network_error",
f"OAuth 建号 quota probe exception: {exc}",
stage="create_account_via_oauth",
)
update_account(email, **update_fields)
# 个人免费号也要回写 CPA / 同步目标(personal 不像 ACTIVE 自动同步,这里显式触发)
_sync_ready_credential_to_targets(email, auth_file, stage_label="[OAuth建号]")
logger.info("[OAuth建号] 免费号就绪并已同步: %s (plan=%s)", email, bundle.get("plan_type"))
_record_outcome("success", email=email, plan=bundle.get("plan_type"))
return email
def cmd_oauth_fill(target=2, **kwargs):
"""OAuth-first 批量建个人免费号到目标数(不依赖 Team / admin 会话)。
与 cmd_rotate/cmd_fill 的关键区别:完全不碰 Team 状态同步、不需要母号 admin 会话、
不做 master 订阅探测。只数本地已有可用个人号,补差额,每个走 create_account_via_oauth
(auth.openai.com OAuth 内建号 → token 回写 CPA)。
target: 期望的可用个人免费号数量。幂等 —— 已达标则不创建(防止反复触发时 runaway)。
"""
from autoteam import cancel_signal
target = max(1, int(target or 1))
accounts = load_accounts()
have = 0
for acc in accounts:
if is_account_disabled(acc) or _is_main_account_email(acc.get("email")):
continue
status = acc.get("status")
auth_file = acc.get("auth_file")
if status in (STATUS_PERSONAL, STATUS_ACTIVE) and auth_file and Path(auth_file).exists():
have += 1
shortage = max(0, target - have)
if shortage == 0:
logger.info("[OAuth补号] 已有 %d 个可用个人号,达到目标 %d,无需创建", have, target)
return []
logger.info("[OAuth补号] 当前 %d/%d,创建 %d 个新个人免费号...", have, target, shortage)
created = []
for i in range(shortage):
if cancel_signal.is_cancelled():
logger.info("[OAuth补号] 收到取消信号,停止创建")
break
logger.info("[OAuth补号] 创建第 %d/%d 个...", i + 1, shortage)
outcome = {}
email = create_account_via_oauth(out_outcome=outcome)
if email:
created.append(email)
logger.info("[OAuth补号] 第 %d 个成功: %s", i + 1, email)
else:
logger.warning("[OAuth补号] 第 %d 个失败: %s", i + 1, (outcome or {}).get("reason"))
logger.info("[OAuth补号] 完成: 本轮成功 %d/%d", len(created), shortage)
return created
def create_new_account(
chatgpt_api,
mail_client=None,
*,
leave_workspace=False,
out_outcome=None,
acc=None,
path_rotator=None,
parallel: int | None = None,
):
"""
创建新账号。优先用直接注册模式(域名自动加入 workspace)。
chatgpt_api 可为 None(直接注册不需要)。
leave_workspace: 注册成功后是否退出 Team 走 personal OAuth。
out_outcome: 透传给 create_account_direct 的可选统计容器。
Round 12 S4: `mail_client` 改为可选 — 缺省时按 acc 路由 / 走 get_mail_client()。
"""
mail_client = _resolve_mail_client_or_default(mail_client, acc=acc)
try:
from autoteam.config import ROTATE_SKIP_REUSE
except Exception:
ROTATE_SKIP_REUSE = False
mode = _runtime_new_account_mode()
# oauth_first:不碰 chatgpt.com 注册和 Team 邀请,直接 OAuth 建个人免费号回写 CPA。
# 这是为"母号无订阅 / chatgpt 注册 /api/auth/error / OTP 永不到"场景设计的旁路,
# 产出 personal free,不占 Team 席位,因此不走下面的 invite/direct 回退链。
if mode == "oauth_first":
logger.info("[创建] path=oauth_first:跳过 chatgpt 注册/Team 邀请,直接 OAuth 建个人免费号")
return create_account_via_oauth(mail_client, out_outcome=out_outcome, acc=acc)
fallback_invite = _domain_auto_join_fallback_invite_enabled()
direct_attempted = False
# 先检查 pending invites
if chatgpt_api and _chatgpt_session_ready(chatgpt_api) and not ROTATE_SKIP_REUSE:
logger.info("[创建] 先检查 pending invites...")
completed = _check_pending_invites(
chatgpt_api,
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
)
if completed:
logger.info("[创建] 从 pending invites 完成了 %d 个账号", len(completed))
return completed[0]
elif chatgpt_api and _chatgpt_session_ready(chatgpt_api):
logger.info("[创建] ROTATE_SKIP_REUSE 启用:跳过历史 pending invite,只创建新账号")
auto_join_allowed = mode == "direct_first" or (
mode == "domain_auto_join_first" and _mail_domain_auto_join_allowed(mail_client)
)
if mode == "domain_auto_join_first" and not auto_join_allowed:
logger.warning(
"[创建] path=domain_auto_join 跳过:当前邮箱域名不在 AUTOTEAM_AUTO_JOIN_DOMAINS 中,改用邀请路径"
)
if auto_join_allowed:
logger.info(
"[创建] path=domain_auto_join mode=%s fallback_invite=%s:跳过邀请邮件,直接注册并等待远端成员确认",
mode,
fallback_invite,
)
if chatgpt_api is not None:
if not _prepare_remote_capacity_for_new_seat(chatgpt_api, stage_label="[直接注册]"):
logger.warning("[创建] path=domain_auto_join 远端席位不可用,停止直接注册")
return None
if _chatgpt_session_ready(chatgpt_api):
chatgpt_api.stop()
direct_attempted = True
direct_email = create_account_direct(
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
acc=acc,
path_rotator=path_rotator,
parallel=parallel,
)
if direct_email:
return direct_email
if not fallback_invite:
logger.warning("[创建] path=domain_auto_join 失败且 fallback invite 已关闭")
return None
logger.warning("[创建] path=domain_auto_join 失败,尝试 path=invite_fallback")
if chatgpt_api is not None and (auto_join_allowed or mode == "invite_first" or _chatgpt_session_ready(chatgpt_api)):
invite_path = "invite_fallback" if auto_join_allowed else "invite_legacy"
logger.info(
"[创建] path=%s 使用远端邀请注册模式(members+invites 先验容量,注册后确认远端成员)...",
invite_path,
)
invited_email = create_account_via_invite(
chatgpt_api,
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
acc=acc,
)
if invited_email:
return invited_email
if direct_attempted:
logger.warning("[创建] path=invite_fallback 失败,direct 注册已经尝试过,停止本轮新号创建")
return None
logger.warning("[创建] 远端邀请注册模式失败,尝试直接注册兜底(仍要求远端成员确认)...")
if not _prepare_remote_capacity_for_new_seat(chatgpt_api, stage_label="[创建兜底]"):
logger.warning("[创建] 远端席位已满或有 pending invite 占位,跳过直接注册兜底")
return None
logger.info("[创建] path=direct_fallback 使用直接注册兜底模式...")
if chatgpt_api and _chatgpt_session_ready(chatgpt_api):
chatgpt_api.stop()
return create_account_direct(
mail_client,
leave_workspace=leave_workspace,
out_outcome=out_outcome,
acc=acc,
path_rotator=path_rotator,
parallel=parallel,
)
def reinvite_account(chatgpt_api, mail_client, acc):
"""
恢复 standby 账号 — 复用统一的 Codex OAuth 登录流程。
只有拿到 team plan 的认证结果,才视为恢复成功。
OAuth 失败(bundle=None)或 plan_type != team 时,必须立刻 kick 残留 Team 成员:
reinvite 链路(invite → OAuth)的 invite 阶段往往已成功,只有 OAuth 这一步掉队,
如果不 kick,账号就留在 Team 里占席位,本地却写 standby —— 这正是"假 standby"
的典型成因。不 kick 的话,下一轮 rotate [4/5] 又会从 standby 选中它 reinvite,
同样失败,死循环占席位。
"""
email = acc["email"]
password = acc.get("password", "")
logger.info("[轮转] 恢复旧账号: %s(统一 OAuth 登录)", email)
# 关闭已有 Team API session 避免 OAuth 浏览器冲突;auto transport 下可能没有 browser 但仍 started。
if chatgpt_api and _chatgpt_session_ready(chatgpt_api):
chatgpt_api.stop()
def _cleanup_team_leftover(reason):
"""OAuth 失败/plan 不对时,兜底 kick 账号,避免假 standby。"""
try:
if not _chatgpt_session_ready(chatgpt_api):
chatgpt_api.start()
kick_status = remove_from_team(chatgpt_api, email, return_status=True)
if kick_status == "removed":
logger.info("[轮转] OAuth 失败(%s),已 kick 残留 Team 成员: %s", reason, email)
elif kick_status == "already_absent":
logger.info("[轮转] OAuth 失败(%s),确认 %s 不在 Team", reason, email)
else:
logger.warning("[轮转] OAuth 失败(%s)后 kick %s 返回 status=%s", reason, email, kick_status)
except Exception as exc:
logger.warning("[轮转] OAuth 失败后 kick %s 抛异常(留给下次对账兜底): %s", email, exc)
try:
auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
bundle = _login_codex_via_browser_with_proxy(
email,
password,
mail_client=mail_client,
playwright_proxy_url=playwright_proxy_url,
)
except RegisterBlocked as exc:
# OAuth 阶段触发 add-phone / 双重验证 / 重复账号风控 — 不可恢复,锁 AUTH_INVALID
# 而非 STANDBY,避免下一轮 rotate 又选中它死循环。
logger.warning("[轮转] %s reinvite OAuth 被 add-phone/duplicate 阻断: %s", email, exc)
_cleanup_team_leftover("oauth_phone_blocked")
update_account(email, status=STATUS_AUTH_INVALID, auth_file=None)
# Round 12 wire-up (C1) — 记录 auth_retry_* 状态字段(衰退式 retry / pause).
# 注意调用顺序: 已经 _cleanup_team_leftover 过, _record_auth_repair_failure 内
# 的 _release_auth_repair_team_seat 走 already_absent 路径, 不会重复 kick.
try:
_record_auth_repair_failure(
email, error_type="add_phone",
error_detail=str(exc),
chatgpt_api=chatgpt_api,
)
except Exception as repair_exc:
logger.warning(
"[轮转] %s _record_auth_repair_failure 抛异常(忽略): %s",
email, repair_exc,
)
try:
from autoteam.register_failures import record_failure
record_failure(email, "oauth_phone_blocked", stage="reinvite_account", detail=str(exc))
except Exception:
pass
return False
if not bundle:
logger.warning("[轮转] 旧账号 OAuth 登录失败,保持 standby: %s", email)
_cleanup_team_leftover("no_bundle")
update_account(email, status=STATUS_STANDBY)
# Round 12 wire-up (C1) — bundle 缺失走衰退式 retry, 而非死循环.
try:
_record_auth_repair_failure(
email, error_type="login_failed",
error_detail="oauth bundle missing",
chatgpt_api=chatgpt_api,
)
except Exception as repair_exc:
logger.warning(
"[轮转] %s _record_auth_repair_failure 抛异常(忽略): %s",
email, repair_exc,
)
return False
plan_type_raw = bundle.get("plan_type_raw") or bundle.get("plan_type") or ""
plan_type = (bundle.get("plan_type") or "").lower()
if not is_supported_plan(plan_type_raw):
# plan_type 不在白名单(self_serve_business_usage_based / enterprise / unknown 等)
# → 这种账号即使 OAuth 成功也无法稳定调用 Codex,锁 AUTH_INVALID 而非 standby。
logger.warning("[轮转] %s reinvite 后 plan_type=%s 不被支持,标 AUTH_INVALID", email, plan_type_raw)
_cleanup_team_leftover(f"plan_unsupported={plan_type_raw}")
update_account(email, status=STATUS_AUTH_INVALID, auth_file=None)
# Round 12 wire-up (C1) — plan_unsupported 为永久失败, 走衰退式 retry 不
# 立刻无限循环(实际更可能等下次 OAuth 拿到正确 plan).
try:
_record_auth_repair_failure(
email, error_type="non_team_plan",
error_detail=f"plan_type={plan_type_raw}",
chatgpt_api=chatgpt_api,
)
except Exception as repair_exc:
logger.warning(
"[轮转] %s _record_auth_repair_failure 抛异常(忽略): %s",
email, repair_exc,
)
try:
from autoteam.register_failures import record_failure
record_failure(email, "plan_unsupported", stage="reinvite_account", detail=f"plan_type={plan_type_raw}")
except Exception:
pass
return False
if plan_type != "team":
# plan 漂移(白名单内但不是 team,例如 free) — 邀请阶段成功的 Team 席位但 OAuth 拿到的
# 是个人 plan,反复 reinvite 也不会变 team。锁 AUTH_INVALID 让下游清账,不再死循环。
logger.warning("[轮转] %s reinvite 后 plan=%s 漂移,不是 team,标 AUTH_INVALID", email, plan_type or "unknown")
_cleanup_team_leftover(f"plan_drift={plan_type or 'unknown'}")
update_account(email, status=STATUS_AUTH_INVALID, auth_file=None)
# Round 12 wire-up (C1) — 同 plan_unsupported, 走衰退式 retry.
try:
_record_auth_repair_failure(
email, error_type="non_team_plan",
error_detail=f"plan_drift plan_type={plan_type or 'unknown'}",
chatgpt_api=chatgpt_api,
)
except Exception as repair_exc:
logger.warning(
"[轮转] %s _record_auth_repair_failure 抛异常(忽略): %s",
email, repair_exc,
)
try:
from autoteam.register_failures import record_failure
record_failure(email, "plan_drift", stage="reinvite_account", detail=f"plan_type={plan_type or 'unknown'}")
except Exception:
pass
return False
_attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
auth_file = save_auth_file(bundle)
# OAuth 成功 plan=team 不等于"账号真的活了"。存在一类竞态:刚 kick 的账号在 OpenAI
# 端处于 soft-removed/缓存未刷新状态,OAuth 仍能短暂拿到 team workspace token,但配额
# 本身没被重置(仍是之前耗尽的 5h)。如果不验,就会把 0% 账号塞回 Team,auto-check 下
# 一轮立刻再 kick,反复洗同一批耗尽账号。这里用新 token 实测一次 wham,只有确认 ok 且
# 剩余 >= threshold 才算真复用成功;否则判定"假恢复",kick 掉让 Team 席位交给新号。
access_token = bundle.get("access_token")
quota_verified = False
# fake_recovery 的原因要分清:
# "exhausted" → quota 真用完,锁 5h 等自然恢复
# "auth_error"/"exception" → token 被 OpenAI 风控 revoke(短时间内反复 invite/kick
# 触发的) —— 锁 5h 完全没意义,token revoke 不会等就好,
# 只能下次重新走完整 OAuth 拿新 token。锁 5h 反而让账号
# 无法被任何流程选中,死锁在 standby。
fail_reason = "no_attempt"
if access_token:
try:
try:
from autoteam.api import _auto_check_config
from autoteam.config import AUTO_CHECK_THRESHOLD
threshold = _auto_check_config.get("threshold", AUTO_CHECK_THRESHOLD)
except Exception:
threshold = 10
status_str, info = check_codex_quota(access_token)
if status_str == "ok" and isinstance(info, dict):
# 不论真假恢复,都写一份最新 last_quota:UI 上看到的额度必须是最新事实,
# 否则用户/下游看到的还是上次成功时的旧值(比如 0% 剩 100%)误判可用,
# 这正是之前"01544b9745 last_quota.primary_pct=0 但 status=standby 死锁"
# 的根因(假恢复分支静默吞了实测结果)。
update_account(email, last_quota=info)
p_remain = 100 - info.get("primary_pct", 0)
if p_remain >= threshold:
quota_verified = True
else:
fail_reason = "quota_low"
logger.warning(
"[轮转] %s OAuth 成功但实测 5h 剩余 %d%% < %d%%,判定假恢复",
email,
p_remain,
threshold,
)
elif status_str == "exhausted":
# exhausted 路径 check_codex_quota 在 info 里塞了 quota_info 子结构,
# 拆出来写本地 last_quota。
quota_info = quota_result_quota_info(info) or {}
if quota_info:
update_account(email, last_quota=quota_info)
fail_reason = "exhausted"
logger.warning("[轮转] %s OAuth 成功但实测 exhausted,判定假恢复", email)
elif status_str == "no_quota":
# 没有发放任何配额(primary_total=0 或 rate_limit 字段全空),不是耗尽。
# 锁 5h 没意义,直接 AUTH_INVALID 让下游清账,避免反复假恢复。
fail_reason = "no_quota_assigned"
logger.warning("[轮转] %s reinvite 后未分配配额(no_quota),判定 AUTH_INVALID", email)
elif status_str == "network_error":
# 网络错误不是 token 风控也不是 quota 用尽。当作"未验证",走 exception 分支
# 同款处置:不锁 5h(token 还活着),让下一轮自然重试。
fail_reason = "network_error"
logger.warning("[轮转] %s 额度验证遇到临时网络错误,本轮判定未验证", email)
else:
# auth_error/其他 — token 风控类,wham 401 token_revoked 落这里
fail_reason = "auth_error"
logger.warning("[轮转] %s OAuth 成功但额度验证返回 status=%s,判定 token 风控", email, status_str)
except Exception as exc:
fail_reason = "exception"
logger.warning("[轮转] %s 额度验证抛异常,判定 token 风控: %s", email, exc)
if not quota_verified:
# 把这个"假恢复"的账号从 Team 里 kick 掉,避免占席位
_cleanup_team_leftover(f"fake_recovery_{fail_reason}")
now_ts = time.time()
if fail_reason in ("exhausted", "quota_low"):
# 真的 quota 不足 → 锁 5h 等自然恢复
update_account(
email,
status=STATUS_STANDBY,
auth_file=auth_file,
quota_exhausted_at=now_ts,
quota_resets_at=now_ts + 18000,
)
elif fail_reason == "no_quota_assigned":
# 后端没发配额 —— 不是耗尽,锁 5h 没用,锁 AUTH_INVALID 让下游清账。
update_account(
email,
status=STATUS_AUTH_INVALID,
auth_file=None,
quota_exhausted_at=None,
quota_resets_at=None,
)
try:
from autoteam.register_failures import record_failure
record_failure(email, "no_quota_assigned", stage="reinvite_account",
detail="primary_total=0 or rate_limit_empty",
raw_rate_limit=_extract_raw_rate_limit_str(info))
except Exception:
pass
else:
# token 风控/异常 —— 锁 5h 没用,token revoke 等不来。降级到 standby 但
# 不写 quota_exhausted_at/resets_at,让下次有机会重新尝试 OAuth(说不定
# 风控窗口已过)。同时清掉旧 last_quota 里的"剩余 100%"幻觉,免得下游
# 看着 last_quota 把它当可用号反复选中。
update_account(
email,
status=STATUS_STANDBY,
auth_file=auth_file,
quota_exhausted_at=None,
quota_resets_at=None,
)
return False
update_account(
email,
status=STATUS_ACTIVE,
last_active_at=time.time(),
auth_file=auth_file,
workspace_account_id=get_chatgpt_account_id() or None,
)
# Round 12 wire-up (C1) — 注册/复用成功后清空 auth_repair 状态字段,
# 避免历史 auth_retry_* 累积影响下次 cmd_rotate 跳过判断.
try:
_auth_repair_reset(email)
except Exception as repair_exc:
logger.warning(
"[轮转] %s _auth_repair_reset 抛异常(忽略): %s",
email, repair_exc,
)
_sync_ready_credential_to_targets(email, auth_file, stage_label="[轮转]")
logger.info("[轮转] 旧账号已恢复: %s", email)
return True
def _replace_single(chatgpt, mail_client, email, reason=""):
"""定点替换一个失效子号(内部实现,复用外部传入的 chatgpt_api + mail_client)。
流程:kick 目标 → 补一个(优先 standby 复用,否则新号)。补位后若 Team 子号已达
TEAM_SUB_ACCOUNT_HARD_CAP 则停止,不会超员。
返回 dict: {kicked: bool, filled_by: email|None, method: "reuse"|"new"|None, error: str|None}
"""
outcome = {"kicked": False, "filled_by": None, "method": None, "error": None}
if _is_main_account_email(email):
outcome["error"] = "skip_main"
logger.warning("[替换] 跳过主号: %s", email)
return outcome
# 1. kick 失效账号(新版带 retry,already_absent 也算成功)
logger.info("[替换] kick %s (reason=%s)", email, reason or "unspecified")
try:
kick_status = remove_from_team(chatgpt, email, return_status=True)
except Exception as exc:
outcome["error"] = f"kick_exception: {exc}"
logger.error("[替换] kick %s 抛异常: %s", email, exc)
return outcome
if kick_status not in ("removed", "already_absent"):
outcome["error"] = f"kick_failed: {kick_status}"
logger.error("[替换] kick %s 失败 status=%s,不补位", email, kick_status)
return outcome
outcome["kicked"] = True
update_account(email, status=STATUS_STANDBY)
# 2. 确认当前 Team 非主号子号数,判断是否还有空位
try:
current_total = get_team_member_count(chatgpt)
except Exception as exc:
logger.warning("[替换] 获取 Team 成员数抛异常: %s,跳过补位", exc)
outcome["error"] = f"count_exception: {exc}"
return outcome
if current_total < 0:
outcome["error"] = "count_failed"
return outcome
sub_count = current_total - 1 # 减主号
if sub_count >= TEAM_SUB_ACCOUNT_HARD_CAP:
logger.info("[替换] Team 子号已达 %d/%d,无需补位", sub_count, TEAM_SUB_ACCOUNT_HARD_CAP)
return outcome
# 3. 优先从 standby 复用,排除刚 kick 的同一 email 防止自环
email_lc = (email or "").lower()
standby_list = [
a
for a in get_standby_accounts()
if a.get("_quota_recovered")
and not _is_main_account_email(a.get("email"))
and (a.get("email") or "").lower() != email_lc
]
for acc in standby_list:
skip_reason = _auto_reuse_skip_reason(acc)
if skip_reason:
logger.info("[替换] 跳过 %s(%s)", acc.get("email"), skip_reason)
continue
cand_email = acc.get("email")
# 额度二次验证:不能只信 get_standby_accounts() 的 _quota_recovered(它只看
# quota_resets_at 这种粗估时间)。之前有 bug 就是把还在 exhausted 窗口的
# standby 反复 reinvite 进 Team,账号一进来就 0% 立马被 kick,把同一批号
# 来回洗,席位始终干空。这里直接拿 auth_file 的 access_token 打一次 wham,
# 只有 API 确认 "ok 且剩余 >= threshold" 才允许复用。
try:
from autoteam.config import AUTO_CHECK_THRESHOLD
try:
from autoteam.api import _auto_check_config
threshold = _auto_check_config.get("threshold", AUTO_CHECK_THRESHOLD)
except ImportError:
threshold = AUTO_CHECK_THRESHOLD
except Exception:
threshold = 10
auth_file = acc.get("auth_file")
quota_ok = False
if auth_file and Path(auth_file).exists():
try:
auth_data = json.loads(read_text(Path(auth_file)))
access_token = auth_data.get("access_token")
if access_token:
status_str, info = check_codex_quota(access_token)
if status_str == "ok" and isinstance(info, dict):
# 实测结果统一刷新 last_quota,避免 UI/下游看到陈旧数据
update_account(cand_email, last_quota=info)
p_remain = 100 - info.get("primary_pct", 0)
if p_remain >= threshold:
quota_ok = True
else:
logger.info("[替换] 跳过 %s(实测 5h 剩余 %d%% < %d%%)", cand_email, p_remain, threshold)
continue
elif status_str == "exhausted":
quota_info = quota_result_quota_info(info) or {}
if quota_info:
update_account(cand_email, last_quota=quota_info)
logger.info("[替换] 跳过 %s(实测 exhausted)", cand_email)
continue
# auth_error:token 失效,不是"额度真恢复"的证据,跳过
elif status_str == "auth_error":
logger.info("[替换] 跳过 %s(token auth_error,无法验证额度)", cand_email)
continue
# network_error:临时网络故障,不能当"额度恢复"凭证,本轮不复用,
# 等下一轮再试(不动 acc 状态)
elif status_str == "network_error":
logger.info("[替换] 跳过 %s(临时网络错误,本轮无法验证额度)", cand_email)
continue
except Exception as exc:
logger.info("[替换] %s 额度验证抛异常(跳过): %s", cand_email, exc)
continue
if not quota_ok:
# 没 auth_file 或验证没通过都跳过,宁可去创建新号也别把 0% 账号塞回 Team
logger.info("[替换] 跳过 %s(无 auth_file 或额度未通过验证)", cand_email)
continue
logger.info("[替换] 尝试复用 standby: %s", cand_email)
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
if reinvite_account(chatgpt, mail_client, acc):
outcome["filled_by"] = cand_email
outcome["method"] = "reuse"
logger.info("[替换] 补位成功(复用): %s → %s", email, cand_email)
return outcome
# reinvite_account 内部失败已 cleanup,继续下一个候选
# 4. 无可复用 standby → 创建新号
logger.info("[替换] 无可复用 standby,创建新号补位...")
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
try:
new_email = create_new_account(chatgpt, mail_client)
except Exception as exc:
outcome["error"] = f"create_exception: {exc}"
logger.error("[替换] 创建新号抛异常: %s", exc)
return outcome
if new_email:
outcome["filled_by"] = new_email
outcome["method"] = "new"
logger.info("[替换] 补位成功(新号): %s → %s", email, new_email)
else:
outcome["error"] = "create_failed"
logger.error("[替换] 新号创建失败,席位暂缺")
return outcome
def cmd_replace_one(email, reason=""):
"""立即替换一个失效 Team 子号(外部入口,自建 chatgpt + mail)。
相比 cmd_rotate 全量走一遍 check + 批量补位,这里只针对单个席位做 kick+补一个,
响应更快。适合 auto-check 巡检发现失效立即逐个替换的场景。
"""
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
mail_client = CloudMailClient()
mail_client.login()
try:
return _replace_single(chatgpt, mail_client, email, reason=reason)
finally:
if chatgpt:
chatgpt.stop()
try:
sync_to_cpa()
except Exception as exc:
logger.warning("[替换] sync_to_cpa 抛异常(忽略): %s", exc)
def cmd_replace_batch(emails, trigger=""):
"""批量立即替换:逐个 kick+补一个,复用同一个 ChatGPT/mail 实例(省浏览器启停)。
串行执行,失败不阻塞后续。返回 outcome 列表。
用于 auto-check 同轮发现多个失效时一次性处理。
"""
if not emails:
return []
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
mail_client = CloudMailClient()
mail_client.login()
outcomes = []
try:
for email in emails:
try:
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
out = _replace_single(chatgpt, mail_client, email, reason=trigger or "batch")
outcomes.append({"email": email, **out})
except Exception as exc:
logger.error("[替换] %s 单个替换抛异常: %s", email, exc)
outcomes.append({"email": email, "kicked": False, "filled_by": None, "error": f"exception: {exc}"})
finally:
if chatgpt:
chatgpt.stop()
try:
sync_to_cpa()
except Exception as exc:
logger.warning("[替换] sync_to_cpa 抛异常(忽略): %s", exc)
ok = sum(1 for o in outcomes if o.get("filled_by"))
logger.info("[替换] 批量完成 %d/%d 个补位成功(trigger=%s)", ok, len(outcomes), trigger or "-")
return outcomes
# ---------------------------------------------------------------------------
# Round 12 S6 — standby 复用单元(可被 ThreadPoolExecutor 并发调度).
#
# 把原 cmd_rotate 内 for 循环里"额度校验 → reinvite_account"两段抽出来,
# 让 ROTATE_CONCURRENCY > 1 时多个席位的 mail wait 可并行(主要 IO 瓶颈).
#
# ⚠️ Round 12 wire-up (C3 fix) — chatgpt browser 调用 (reinvite_account 内的
# chatgpt.invite_member / remove_from_team / fetch_team_state) 走 Playwright
# sync_api,该 API **非线程安全**:多个 worker 共享同一 BrowserContext 会触发
# `greenlet.error: cannot switch to a different thread` / `Connection closed`.
# 因此 cmd_rotate 在 ROTATE_CONCURRENCY > 1 时会自动降级为串行,只让 mail
# wait + auth file IO 单线程跑.要真正实现 per-worker browser context 需要
# 重构 chatgpt_api 的 lifecycle (round-13 backlog).
# ---------------------------------------------------------------------------
_STANDBY_REUSE_RESULTS = frozenset({"reused", "skipped_quota", "skipped_auto", "failed"})
def _reuse_one_standby(
acc: dict,
threshold: int,
*,
chatgpt_provider,
mail_provider,
reinvite_fn=None,
quota_fn=None,
now=None,
) -> dict:
"""Process one standby account end-to-end.
Parameters
----------
acc:
Standby account dict (already filtered for non-main + STATUS_STANDBY).
threshold:
AUTO_CHECK_THRESHOLD percent — quota below this is treated as "not recovered".
chatgpt_provider:
Zero-arg callable returning a started :class:`ChatGPTTeamAPI` instance.
mail_provider:
``acc -> mail_client`` callable (per-acc routed, S3 ensure_account_mail).
reinvite_fn:
Override for :func:`reinvite_account` (tests pass a mock).
quota_fn:
Override for :func:`check_codex_quota` (tests pass a mock).
now:
Override for ``time.time()`` (tests inject deterministic time).
Returns
-------
dict with keys ``email`` / ``result`` (one of ``_STANDBY_REUSE_RESULTS``) /
``error`` (None on success, str on caught exception). Never raises —
failures land in ``result="failed"`` so concurrent map can aggregate.
"""
reinvite_callable = reinvite_fn or reinvite_account
quota_callable = quota_fn or check_codex_quota
current_ts = now if now is not None else time.time()
email = (acc or {}).get("email") or "<unknown>"
try:
skip_reason = _auto_reuse_skip_reason(acc)
if skip_reason:
logger.info("[4/5] 跳过 %s(%s)", email, skip_reason)
return {"email": email, "result": "skipped_auto", "error": None}
# Round 12 wire-up (C1) — auth_repair 冷却 / 已暂停账号跳过本轮复用.
# _auth_repair_skip_reason 返回非空字符串 = 跳过(包含中文标签便于日志).
repair_skip = _auth_repair_skip_reason(acc)
if repair_skip:
logger.info("[4/5] 跳过 %s(%s)", email, repair_skip)
return {"email": email, "result": "skipped_auto", "error": None}
auth_file = acc.get("auth_file")
quota_ok = False
if auth_file and Path(auth_file).exists():
try:
auth_data = json.loads(read_text(Path(auth_file)))
access_token = auth_data.get("access_token")
if access_token:
status_str, info = quota_callable(access_token)
if status_str == "exhausted":
quota_info = quota_result_quota_info(info)
if quota_info:
update_account(email, last_quota=quota_info)
logger.info("[4/5] 跳过 %s(额度未恢复)", email)
return {"email": email, "result": "skipped_quota", "error": None}
if status_str == "ok" and isinstance(info, dict):
p_remain = 100 - info.get("primary_pct", 0)
if p_remain < threshold:
logger.info("[4/5] 跳过 %s(剩余 %d%% < %d%%)", email, p_remain, threshold)
return {"email": email, "result": "skipped_quota", "error": None}
quota_ok = True
if status_str == "network_error":
logger.info("[4/5] 跳过 %s(临时网络错误,本轮无法验证额度)", email)
return {"email": email, "result": "skipped_quota", "error": None}
if status_str == "auth_error":
lq = acc.get("last_quota")
if lq:
exhausted_info = _pending_historical_exhausted_info(lq)
if exhausted_info:
window_label = _quota_window_label(exhausted_info.get("window"))
logger.info("[4/5] 跳过 %s(%s额度未恢复)", email, window_label)
return {"email": email, "result": "skipped_quota", "error": None}
p_resets = lq.get("primary_resets_at", 0)
if p_resets and current_ts >= p_resets:
logger.info("[4/5] %s 的 5h 重置时间已过,视为额度已恢复", email)
quota_ok = True
else:
p_remain = 100 - lq.get("primary_pct", 0)
if p_remain < threshold:
logger.info("[4/5] 跳过 %s(上次额度 %d%% < %d%%)", email, p_remain, threshold)
return {"email": email, "result": "skipped_quota", "error": None}
quota_ok = True
except Exception:
pass
if not quota_ok:
lq = acc.get("last_quota")
if lq:
exhausted_info = _pending_historical_exhausted_info(lq)
if exhausted_info:
window_label = _quota_window_label(exhausted_info.get("window"))
logger.info("[4/5] 跳过 %s(%s额度未恢复)", email, window_label)
return {"email": email, "result": "skipped_quota", "error": None}
p_resets = lq.get("primary_resets_at", 0)
if p_resets and current_ts >= p_resets:
logger.info("[4/5] %s 的 5h 重置时间已过,视为额度已恢复", email)
else:
p_remain = 100 - lq.get("primary_pct", 0)
if p_remain < threshold:
logger.info("[4/5] 跳过 %s(历史额度 %d%% < %d%%)", email, p_remain, threshold)
return {"email": email, "result": "skipped_quota", "error": None}
else:
resets_at = acc.get("quota_resets_at")
if resets_at and current_ts < resets_at:
mins = max(0, int((resets_at - current_ts) / 60))
logger.info("[4/5] 跳过 %s(%d 分钟后恢复)", email, mins)
return {"email": email, "result": "skipped_quota", "error": None}
logger.info("[4/5] 复用: %s", email)
chatgpt = chatgpt_provider()
mail_client = mail_provider(acc)
ok = reinvite_callable(chatgpt, mail_client, acc)
if ok:
return {"email": email, "result": "reused", "error": None}
return {"email": email, "result": "failed", "error": "reinvite_account returned False"}
except Exception as exc:
# 关键: 并发模式下任何席位异常都不能波及其他席位,聚合层做 failed 计数.
logger.exception("[4/5] %s 复用流程抛异常,标记 failed: %s", email, exc)
return {"email": email, "result": "failed", "error": str(exc)}
def cmd_rotate(target_seats=3, force_auth_repair=False, background_post_sync=False):
"""
智能轮转 - 保持 Team 始终有 target_seats 个可用成员,尽量少创建新账号。
逻辑:
1. 检查所有账号额度,更新状态
2. 将额度用完的 active 账号移出 Team → standby
3. 统计当前 Team 空缺数
4. 优先从 standby 中选额度已恢复的旧账号填补
5. 仅当所有旧账号都不可用时,才创建新账号
Round 12 S3 cherry-pick:
- vacancy 兜底改用 _estimate_local_team_member_count(替代旧 STATUS_ACTIVE 单字段统计).
- ensure_account_mail(acc) 按账号 mail_provider 路由 mail client(S2 多 provider 必备).
- 双指标终止条件: current_count >= TARGET AND pool_active >= ACTIVE_TARGET
(避免 "Team 满员但本地全是 standby/auth_invalid" 假满足).
"""
TARGET = _clamp_team_target_seats(target_seats)
ACTIVE_TARGET = _pool_active_target(TARGET)
started_at = time.time()
from autoteam.config import AUTO_CHECK_THRESHOLD, ROTATE_MAX_DURATION, ROTATE_SKIP_REUSE
rotate_deadline = started_at + float(ROTATE_MAX_DURATION)
try:
from autoteam.api import _auto_check_config
threshold = _auto_check_config.get("threshold", AUTO_CHECK_THRESHOLD)
except ImportError:
threshold = AUTO_CHECK_THRESHOLD
def _bump(stage: str) -> None:
try:
from autoteam.api import bump_task_progress
bump_task_progress(stage)
except Exception:
pass
def _deadline_exceeded(stage: str) -> bool:
if time.time() < rotate_deadline:
return False
logger.warning(
"[轮转] 总时长熔断触发 (%s),已用 %ds,优雅退出本轮 rotate",
stage,
int(time.time() - started_at),
)
return True
skip_reuse = bool(ROTATE_SKIP_REUSE)
if skip_reuse:
logger.info("[轮转] ROTATE_SKIP_REUSE 启用:跳过旧号复用,优先释放不可用占席子号后创建新号")
chatgpt = None
mail_client = None
# 按账号 mail_provider 复用 mail client(同 provider 只 login 一次,避免 N 个号 N 次握手)
reuse_mail_clients: dict[str, object] = {}
def ensure_chatgpt():
nonlocal chatgpt
if not chatgpt or not _chatgpt_session_ready(chatgpt):
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
return chatgpt
def ensure_mail():
"""全局默认 mail client(给"创建新号"等不绑定具体 acc 的路径用).
旧路径仍走 CloudMailClient()(等价 get_mail_client(),由 MAIL_PROVIDER /
MAIL_PROVIDER_CHAIN 决定). 已绑定 mail_provider 的旧 acc 复用走
ensure_account_mail(acc).
"""
nonlocal mail_client
if not mail_client:
mail_client = CloudMailClient()
mail_client.login()
return mail_client
def ensure_account_mail(acc):
"""按账号 mail_provider 路由 mail client(上游 `.upstream/manager.py:132` _get_account_mail_client 等价).
- 已绑定 mail_provider / mail_account_id / cloudmail_account_id 的 acc → 走对应 provider
(从 reuse_mail_clients 缓存复用).
- 未绑定 → fallback 全局默认 ensure_mail()(向后兼容旧 CloudMail 主路径).
S2 多 provider 接入(addy_io / simplelogin / maillab / cf_temp_email)的关键基础设施.
"""
acc = acc or {}
provider_name = (acc.get("mail_provider") or "").strip().lower()
has_explicit_binding = bool(provider_name) or acc.get("mail_account_id") is not None
has_legacy_binding = acc.get("cloudmail_account_id") is not None
if not (has_explicit_binding or has_legacy_binding):
return ensure_mail()
# 缓存键: provider 名优先,其次 account_id 单 provider 模式
cache_key = provider_name or f"_legacy_cloudmail:{acc.get('cloudmail_account_id')}"
cached = reuse_mail_clients.get(cache_key)
if cached is not None:
return cached
try:
from autoteam.mail import get_mail_client
client = get_mail_client()
login = getattr(client, "login", None)
if callable(login):
login()
reuse_mail_clients[cache_key] = client
return client
except Exception as exc:
logger.warning(
"[轮转] 账号 %s 指定 mail_provider=%s 路由失败,回退默认: %s",
acc.get("email"),
provider_name or "<legacy>",
exc,
)
return ensure_mail()
logger.info("[1/5] 同步 Team 状态...")
_bump("rotate:sync_team")
sync_account_states()
logger.info("[2/5] 检查额度...")
_bump("rotate:check_quota")
cmd_check()
# Round 12 S5 — 预测式抢先替换(可选,默认关).
# 仅当 PREDICTIVE_ENABLED=true 时执行: 遍历 ACTIVE 子号,基于 quota_history
# 时序拟合预测耗尽时刻,在 PREDICTIVE_LEAD_MIN 分钟内的主动 kick → STANDBY,
# 让后续 [3/5] vacancy 兜底 + [5/5] 新号填补流程自动接管.
try:
from autoteam.config import PREDICTIVE_ENABLED, PREDICTIVE_LEAD_MIN
except ImportError:
PREDICTIVE_ENABLED = False
PREDICTIVE_LEAD_MIN = 15
if PREDICTIVE_ENABLED:
try:
from autoteam.quota_predictor import default_predictor
preempt_candidates = []
for acc in load_accounts():
if _is_main_account_email(acc.get("email")) or acc.get("status") != STATUS_ACTIVE:
continue
# 先记录当前 last_quota → 累积历史(用 acc.last_quota,避免重复 API 调用)
lq = acc.get("last_quota") or {}
p_remain = 100 - lq.get("primary_pct", 0) if "primary_pct" in lq else None
if p_remain is not None:
default_predictor.record(acc["email"], p_remain, lq.get("primary_resets_at"))
if default_predictor.should_preempt(acc["email"], PREDICTIVE_LEAD_MIN):
preempt_candidates.append(acc)
if preempt_candidates:
logger.info("[2.5/5] 预测式抢先替换 %d 个即将耗尽的账号...", len(preempt_candidates))
if not chatgpt or not _chatgpt_session_ready(chatgpt):
ensure_chatgpt()
for acc in preempt_candidates:
email = acc["email"]
remove_status = remove_from_team(chatgpt, email, return_status=True)
if remove_status in ("removed", "already_absent"):
update_account(email, status=STATUS_STANDBY, _reason="predictive_preempt")
logger.info("[2.5/5] %s → standby(预测式抢先,lead=%dmin)", email, PREDICTIVE_LEAD_MIN)
else:
logger.debug("[2.5/5] 预测式: 无 ACTIVE 子号需要抢先替换")
except Exception as exc:
logger.warning("[2.5/5] 预测式抢先替换异常(忽略,走旧路径): %s", exc)
try:
# 移出所有 exhausted 账号(包括之前已标记的)
all_accounts = load_accounts()
all_exhausted = [
a
for a in all_accounts
if _is_replaceable_pool_blocker(a)
]
initial_api_count = -1
removed_now = 0
already_absent_count = 0
if all_exhausted:
logger.info("[3/5] 移出 %d 个不可用占席账号...", len(all_exhausted))
ensure_chatgpt()
initial_api_count = get_team_member_count(chatgpt)
for acc in all_exhausted:
if _deadline_exceeded("rotate:remove_blockers"):
break
email = acc["email"]
reason = _replaceable_pool_blocker_reason(acc) or "replaceable_pool_blocker"
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
remove_status = remove_from_team(chatgpt, email, return_status=True)
if remove_status in ("removed", "already_absent"):
update_account(email, status=STATUS_STANDBY, _reason=reason)
if remove_status == "removed":
removed_now += 1
logger.info("[3/5] %s → standby(已从 Team 移出)| reason=%s", email, reason)
_wait_for_remote_capacity_after_removal(
chatgpt,
target=TARGET,
removed_email=email,
timeout=24,
stage_label="[3/5]",
)
else:
already_absent_count += 1
logger.info("[3/5] %s → standby(远端已不存在)| reason=%s", email, reason)
else:
logger.info("[3/5] 无需移出账号")
if not chatgpt or not _chatgpt_session_ready(chatgpt):
ensure_chatgpt()
api_count = get_team_member_count(chatgpt)
logger.info(
"[4/5] API 返回成员数: %d(实际移出: %d,远端已缺席: %d)",
api_count,
removed_now,
already_absent_count,
)
if api_count <= 0:
# API 返回异常,用 _estimate_local_team_member_count 兜底
# (上游 `.upstream/manager.py:183` 等价: ACTIVE+EXHAUSTED+AUTH_INVALID 全算席位).
local_estimate = _estimate_local_team_member_count(TARGET)
logger.warning(
"[4/5] API 成员数异常 (%d),使用本地估算: %d (含 ACTIVE/EXHAUSTED/AUTH_INVALID + 主号)",
api_count,
local_estimate,
)
current_count = local_estimate
else:
# 保守估算当前成员数:
# - api_count 是移除后的最新观察值
# - initial_api_count - removed_now 是基于移除前人数的理论下界
# 若远端成员本就不存在(already_absent),不能再从 api_count 里额外扣减,否则会少算人数。
estimates = [api_count]
if initial_api_count > 0 and removed_now > 0:
estimates.append(max(0, initial_api_count - removed_now))
current_count = min(estimates)
if len(estimates) > 1 and current_count != api_count:
logger.info(
"[4/5] 成员数保守估算: %d(初始=%d,移出=%d)", current_count, initial_api_count, removed_now
)
vacancies = TARGET - current_count
if vacancies <= 0:
excess = current_count - TARGET
if excess > 0:
logger.info("[4/5] Team 超员 (%d/%d),清理 %d 个多余成员...", current_count, TARGET, excess)
# 只移除本地管理的账号,优先移除额度最低的
all_accs = load_accounts()
local_active = [
a
for a in all_accs
if a["status"] == STATUS_ACTIVE
and not _is_main_account_email(a.get("email"))
and not is_account_disabled(a)
and not _is_protected_local_credential_seat(a)
]
# 按额度排序,额度低的优先移除
local_active.sort(key=lambda a: 100 - (a.get("last_quota") or {}).get("primary_pct", 0))
removed = 0
for acc in local_active:
if removed >= excess:
break
email = acc["email"]
if remove_from_team(chatgpt, email):
update_account(email, status=STATUS_STANDBY)
logger.info("[4/5] 超员清理: %s → standby", email)
removed += 1
if removed:
logger.info("[4/5] 已清理 %d 个多余成员", removed)
else:
logger.info("[4/5] Team 已满 (%d/%d)", current_count, TARGET)
return
logger.info("[4/5] 填补 %d 个空缺 (当前 %d/%d)...", vacancies, current_count, TARGET)
# 优先复用旧账号(先验证额度是否真的恢复了)
# Round 12 S6 — ROTATE_CONCURRENCY > 1 时用 ThreadPoolExecutor 并发,
# 否则保持串行(向后兼容老行为). 每席位独立 try/except 在 _reuse_one_standby
# 内已收敛 → result ∈ {reused / skipped_quota / skipped_auto / failed}.
filled = 0
standby_list = [a for a in get_standby_accounts() if not _is_main_account_email(a.get("email"))]
quota_skipped: list[dict] = []
auto_reuse_skipped: list[dict] = []
from autoteam import cancel_signal
from autoteam.config import ROTATE_CONCURRENCY
# 注: 不截断到 vacancies — 旧行为会迭代全部 standby,遇到不可复用的(skipped_auto/
# skipped_quota)就继续看下一个,直到 filled >= vacancies 才 break. 截断会导致
# 第一个候选若 skipped 则放弃后续可用候选,破坏 test_cmd_rotate_skips_google_accounts.
if skip_reuse:
logger.info("[4/5] ROTATE_SKIP_REUSE 启用:跳过 standby 复用,直接创建新账号补位")
candidates = []
else:
candidates = list(standby_list)
def _chatgpt_provider():
if not chatgpt or not _chatgpt_session_ready(chatgpt):
ensure_chatgpt()
return chatgpt
def _process(acc):
return _reuse_one_standby(
acc,
threshold,
chatgpt_provider=_chatgpt_provider,
mail_provider=ensure_account_mail,
)
outcomes: list[dict] = []
# Round 12 wire-up (C3) — Playwright sync_api is not thread-safe;
# multiple workers sharing the same chatgpt BrowserContext crash
# with greenlet.error. Until per-worker browser lifecycle is added
# (round-13 backlog), force serial when reinvite_account is the
# action (it touches chatgpt browser). Allow opt-out via
# ROTATE_CONCURRENCY_ALLOW_BROWSER_RACE=1 for advanced users who
# mock chatgpt away (only tests do that).
_allow_browser_race = os.environ.get(
"ROTATE_CONCURRENCY_ALLOW_BROWSER_RACE", "0"
).strip().lower() in ("1", "true", "yes")
effective_concurrency = ROTATE_CONCURRENCY
if ROTATE_CONCURRENCY > 1 and not _allow_browser_race:
logger.warning(
"[4/5] ROTATE_CONCURRENCY=%d 已配置,但 reinvite_account 走 "
"Playwright sync_api(非线程安全),自动降级为串行以避免 "
"BrowserContext 跨线程崩溃 (C3). 如已通过 mock 绕开 browser "
"调用,可设 ROTATE_CONCURRENCY_ALLOW_BROWSER_RACE=1 强制并发.",
ROTATE_CONCURRENCY,
)
effective_concurrency = 1
if effective_concurrency <= 1 or len(candidates) <= 1:
# 串行: 完全等同改造前的 for 循环行为(测试可复现)
for acc in candidates:
if cancel_signal.is_cancelled():
logger.warning("[轮转] 收到取消请求,中止 standby 复用阶段")
break
if _deadline_exceeded("rotate:standby_reuse"):
break
if filled >= vacancies:
break
result = _process(acc)
outcomes.append(result)
if result["result"] == "reused":
filled += 1
current_count += 1
else:
# 并发: 邮件 wait + OTP 提取是主要 IO 瓶颈,ThreadPoolExecutor 显著缩短
# 轮转总时长. 注意 — 并发模式会同时处理至多 vacancies + concurrency 个候选,
# 比串行多做一些"探测",换取吞吐. 已 reused 数量达到 vacancies 后停止 submit.
import concurrent.futures
workers = max(1, min(effective_concurrency, len(candidates), vacancies))
logger.info("[4/5] 并发复用 standby(候选 %d,max_workers=%d)...", len(candidates), workers)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
future_map: dict = {}
# 先 submit 一批(数量 = workers),后续按 future 完成情况补 submit.
pending = iter(candidates)
for _ in range(workers):
nxt = next(pending, None)
if nxt is None:
break
future_map[pool.submit(_process, nxt)] = nxt
while future_map and filled < vacancies:
done, _not_done = concurrent.futures.wait(
future_map, return_when=concurrent.futures.FIRST_COMPLETED
)
for future in done:
future_map.pop(future, None)
try:
result = future.result()
except Exception as exc:
logger.exception("[4/5] 并发任务异常: %s", exc)
result = {"email": "<unknown>", "result": "failed", "error": str(exc)}
outcomes.append(result)
if result["result"] == "reused":
filled += 1
current_count += 1
# 还能补 submit 且未达 vacancies → 拉新候选
if filled < vacancies and not cancel_signal.is_cancelled():
nxt = next(pending, None)
if nxt is not None:
future_map[pool.submit(_process, nxt)] = nxt
# cancel pending(已 submit 但还未完成的会跑完),不再 submit 新 task
if cancel_signal.is_cancelled():
logger.warning("[轮转] 收到取消请求,等待 in-flight 完成后中止")
# 收尾: 等剩余 in-flight 完成(filled 已够了仍要收 result 避免线程泄漏)
for future in concurrent.futures.as_completed(list(future_map)):
try:
outcomes.append(future.result())
except Exception as exc:
outcomes.append({"email": "<unknown>", "result": "failed", "error": str(exc)})
# 聚合分类: 并发模式 outcomes 顺序与候选不同,但分类计数稳定
for outcome in outcomes:
email = outcome["email"]
acc = next((a for a in candidates if a.get("email") == email), {"email": email})
res = outcome["result"]
if res == "skipped_auto":
auto_reuse_skipped.append(acc)
elif res == "skipped_quota" or res == "failed":
quota_skipped.append(acc)
if quota_skipped:
logger.info("[4/5] 跳过 %d 个额度未恢复或复用失败的旧号", len(quota_skipped))
if auto_reuse_skipped:
logger.info("[4/5] 跳过 %d 个暂不支持自动复用的旧号", len(auto_reuse_skipped))
remaining = TARGET - current_count
if remaining <= 0:
logger.info("[4/5] 已用旧账号填满空缺")
else:
# 必须创建新号
logger.info("[5/5] 创建 %d 个新账号...", remaining)
for i in range(remaining):
if cancel_signal.is_cancelled():
logger.warning("[轮转] 收到取消请求,已创建 %d/%d 个新号", i, remaining)
break
if _deadline_exceeded("rotate:create_new"):
break
logger.info("[5/5] 创建第 %d/%d 个...", i + 1, remaining)
if not chatgpt or not _chatgpt_session_ready(chatgpt):
ensure_chatgpt()
created_email = create_new_account(chatgpt, ensure_mail())
if created_email and (
not isinstance(created_email, str)
or _validate_managed_account_operational(
created_email,
threshold=threshold,
stage_label="[轮转验收]",
chatgpt_api=chatgpt,
)
):
current_count += 1
elif created_email:
logger.warning("[5/5] 新账号未通过运行验收,释放席位并丢弃: %s", created_email)
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
remove_status = remove_from_team(chatgpt, created_email, return_status=True)
if remove_status in ("removed", "already_absent"):
update_account(created_email, status=STATUS_STANDBY, _reason="new_account_not_ready")
if not chatgpt or not _chatgpt_session_ready(chatgpt):
ensure_chatgpt()
final_count = get_team_member_count(chatgpt)
# 双指标终止条件 (Round 12 S3 cherry-pick, 上游 `.upstream/manager.py` 终止条件):
# final_count >= TARGET 且 pool_active >= ACTIVE_TARGET 才算"真满员".
# 避免"Team 满员但本地全是 standby/auth_invalid → 实际可用 0"假正常状态.
final_pool_active = _count_pool_active_accounts()
logger.info(
"[轮转] 最终 Team 成员数: %d(目标: %d),子号 pool_active=%d(目标: %d)",
final_count,
TARGET,
final_pool_active,
ACTIVE_TARGET,
)
if final_count > TARGET:
logger.warning("[轮转] 最终 Team 成员数超出目标,后续将按清理逻辑修正")
elif 0 <= final_count < TARGET:
logger.warning("[轮转] 最终 Team 成员数仍低于目标 (%d/%d)", final_count, TARGET)
elif final_pool_active < ACTIVE_TARGET:
logger.warning(
"[轮转] Team 满员但本地子号 pool_active 不足 (%d/%d) — 子号多为 standby/auth_invalid,"
"下一轮会继续修复或补号",
final_pool_active,
ACTIVE_TARGET,
)
finally:
if chatgpt:
chatgpt.stop()
# 所有操作完成后统一同步远端,避免中途同步导致远端状态不一致。
logger.info("[轮转] 轮转完成,同步已启用远端...")
if background_post_sync:
_schedule_post_task_sync("[轮转]")
logger.info("[轮转] 完成,远端同步已转入后台;使用 status 命令查看最新状态")
else:
sync_to_cpa()
logger.info("[轮转] 完成,使用 status 命令查看最新状态")
# Round 9 RT-5 — cmd_rotate 收尾跑一次 retroactive(走 5min cache,失败仅 warning)。
# spec/shared/master-subscription-health.md v1.1 §11.3。
try:
from autoteam.master_health import _apply_master_degraded_classification
retro = _apply_master_degraded_classification()
if retro and (retro.get("marked_grace") or retro.get("marked_standby") or retro.get("reverted_active")):
logger.info(
"[轮转] retroactive: GRACE %d / STANDBY %d / 撤回 ACTIVE %d",
len(retro.get("marked_grace") or []),
len(retro.get("marked_standby") or []),
len(retro.get("reverted_active") or []),
)
except Exception as exc:
logger.warning("[轮转] retroactive helper 异常: %s", exc)
def cmd_add():
"""手动添加一个新账号"""
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
mail_client = CloudMailClient()
mail_client.login()
try:
result = create_new_account(chatgpt, mail_client) # 内部会 stop chatgpt
if result:
logger.info("[添加] 新账号添加成功: %s", result)
sync_to_cpa()
else:
logger.error("[添加] 添加失败")
finally:
if chatgpt:
chatgpt.stop()
def cmd_manual_add():
"""手动添加账号:优先自动接收 localhost 回调,失败时再手动粘贴回调 URL。"""
from autoteam.manual_account import ManualAccountFlow
flow = ManualAccountFlow()
try:
result = flow.start()
logger.info("[手动添加] 打开以下链接完成 OAuth 登录:\n%s", result["auth_url"])
if result.get("auto_callback_available"):
logger.info("[手动添加] 已启动本地回调服务 http://localhost:1455/auth/callback,可自动完成认证")
else:
logger.warning("[手动添加] 本地自动回调不可用:%s", result.get("auto_callback_error") or "未知错误")
callback_url = input("登录成功后:若自动完成则直接回车;否则粘贴回调 URL(留空取消): ").strip()
if callback_url:
result = flow.submit_callback(callback_url)
else:
result = flow.status()
if result.get("status") != "completed":
logger.warning("[手动添加] 未检测到自动回调,已取消")
return None
account = result.get("account") or {}
logger.info(
"[手动添加] 完成: %s (plan=%s, status=%s)",
account.get("email") or "?",
account.get("plan_type") or "?",
account.get("status") or "?",
)
return result
finally:
flow.stop()
def _refresh_main_auth_after_admin_login():
try:
info = refresh_main_auth_file()
logger.info("[管理员登录] 已保存主号认证文件: %s", info.get("auth_file"))
return info
except Exception as exc:
logger.warning("[管理员登录] 主号认证文件生成失败: %s", exc)
return None
def cmd_admin_login(email=None):
"""交互式完成管理员登录并保存到 state.json。"""
email = (email or "").strip()
if not email:
email = input("管理员邮箱: ").strip()
if not email:
logger.error("[管理员登录] 邮箱不能为空")
return None
chatgpt = ChatGPTTeamAPI()
try:
logger.info("[管理员登录] 开始: %s", email)
result = chatgpt.begin_admin_login(email)
step = result.get("step")
while True:
if step == "completed":
info = chatgpt.complete_admin_login()
chatgpt.stop()
_refresh_main_auth_after_admin_login()
logger.info("[管理员登录] 登录完成: %s", info.get("email") or email)
if info.get("account_id"):
logger.info("[管理员登录] Workspace ID: %s", info["account_id"])
if info.get("workspace_name"):
logger.info("[管理员登录] Workspace 名称: %s", info["workspace_name"])
return info
if step == "password_required":
password = getpass.getpass("管理员密码(留空取消): ")
if not password:
logger.warning("[管理员登录] 已取消")
return None
result = chatgpt.submit_admin_password(password)
step = result.get("step")
continue
if step == "code_required":
code = input("邮箱验证码(留空取消): ").strip()
if not code:
logger.warning("[管理员登录] 已取消")
return None
result = chatgpt.submit_admin_code(code)
step = result.get("step")
continue
if step == "workspace_required":
options = chatgpt.list_workspace_options()
if not options:
raise RuntimeError("当前需要选择组织,但未获取到可选项")
logger.info("[管理员登录] 请选择要进入的 workspace:")
for idx, option in enumerate(options, 1):
suffix = " [推荐]" if option.get("kind") == "preferred" else ""
logger.info("[管理员登录] %d. %s%s", idx, option["label"], suffix)
choice = input("选择序号(留空取消): ").strip()
if not choice:
logger.warning("[管理员登录] 已取消")
return None
if not choice.isdigit():
raise RuntimeError(f"无效的序号: {choice}")
selected_index = int(choice) - 1
if selected_index < 0 or selected_index >= len(options):
raise RuntimeError(f"序号超出范围: {choice}")
result = chatgpt.select_workspace_option(options[selected_index]["id"])
step = result.get("step")
continue
detail = result.get("detail") or "无法识别管理员登录步骤"
raise RuntimeError(detail)
except KeyboardInterrupt:
logger.warning("[管理员登录] 已中断")
return None
finally:
chatgpt.stop()
def cmd_admin_session(email=None):
"""手动导入管理员 session_token 并保存到 state.json。"""
email = (email or "").strip()
if not email:
email = input("管理员邮箱: ").strip()
if not email:
logger.error("[管理员登录] 邮箱不能为空")
return None
session_token = getpass.getpass("session_token(留空取消): ").strip()
if not session_token:
logger.warning("[管理员登录] 已取消")
return None
chatgpt = ChatGPTTeamAPI()
try:
logger.info("[管理员登录] 开始导入 session_token: %s", email)
info = chatgpt.import_admin_session(email, session_token)
chatgpt.stop()
_refresh_main_auth_after_admin_login()
logger.info("[管理员登录] session_token 导入完成: %s", info.get("email") or email)
if info.get("account_id"):
logger.info("[管理员登录] Workspace ID: %s", info["account_id"])
if info.get("workspace_name"):
logger.info("[管理员登录] Workspace 名称: %s", info["workspace_name"])
return info
finally:
chatgpt.stop()
def cmd_main_codex_sync():
"""交互式同步主号 Codex 认证到 CPA。"""
state = get_admin_state_summary()
if not state.get("session_present") or not state.get("email"):
logger.error("[主号 Codex] 缺少管理员登录态,请先执行 admin-login")
return None
saved_auth_file = get_saved_main_auth_file()
if saved_auth_file:
sync_main_codex_to_cpa(saved_auth_file)
logger.info("[主号 Codex] 已直接同步现有认证文件: %s", saved_auth_file)
return {"auth_file": saved_auth_file}
flow = MainCodexSyncFlow()
try:
logger.info("[主号 Codex] 开始同步: %s", state.get("email"))
result = flow.start()
step = result.get("step")
while True:
if step == "completed":
info = flow.complete()
logger.info("[主号 Codex] 同步完成: %s", info.get("email") or state.get("email"))
if info.get("plan_type"):
logger.info("[主号 Codex] Plan: %s", info["plan_type"])
if info.get("auth_file"):
logger.info("[主号 Codex] Auth 文件: %s", info["auth_file"])
return info
if step == "password_required":
password = getpass.getpass("主号密码(留空取消): ")
if not password:
logger.warning("[主号 Codex] 已取消")
return None
result = flow.submit_password(password)
step = result.get("step")
continue
if step == "code_required":
code = input("主号验证码(留空取消): ").strip()
if not code:
logger.warning("[主号 Codex] 已取消")
return None
result = flow.submit_code(code)
step = result.get("step")
continue
detail = result.get("detail") or "无法识别主号 Codex 登录步骤"
raise RuntimeError(detail)
except KeyboardInterrupt:
logger.warning("[主号 Codex] 已中断")
return None
finally:
flow.stop()
def get_team_member_count(chatgpt_api):
"""获取当前 Team 成员数"""
account_id = get_chatgpt_account_id()
if not account_id:
logger.error("[Team] account_id 为空,无法查询成员数")
return -1
path = f"/backend-api/accounts/{account_id}/users"
result = chatgpt_api._api_fetch("GET", path)
if result["status"] != 200:
logger.error("[Team] 获取成员列表失败: %d %s", result["status"], result["body"][:200])
return -1
data = json.loads(result["body"])
members = data.get("items", data.get("users", data.get("members", [])))
return len(members)
def cmd_fill(target=3, leave_workspace=False, *, post_sync=True, print_status=True, direct_parallel: int | None = None):
"""
补位流程。
leave_workspace=False: 补满 Team 席位到 target(原行为),优先复用 standby 旧号
leave_workspace=True: 按 target 作为"要生产的免费号数量",每个账号注册后立刻退出 Team、走 personal OAuth
post_sync=False: 调度器批量执行时跳过每个 worker 内部 CPA 同步,由父任务收敛同步
print_status=False: 调度器批量执行时跳过每个 worker 内部状态表输出
"""
if leave_workspace:
return _cmd_fill_personal(target)
target = _clamp_team_target_seats(target)
from autoteam.config import AUTO_CHECK_THRESHOLD, ROTATE_SKIP_REUSE
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
mail_client = CloudMailClient()
mail_client.login()
try:
current = get_team_member_count(chatgpt)
if current < 0:
logger.error("[填充] 获取成员列表失败")
return
logger.info("[填充] 当前 Team 成员数: %d,目标: %d", current, target)
need = target - current
if need <= 0:
logger.info("[填充] 成员数已满足(%d >= %d),无需添加", current, target)
return
logger.info("[填充] 需要添加 %d 个账号", need)
if ROTATE_SKIP_REUSE:
logger.info("[填充] ROTATE_SKIP_REUSE 启用:不复用旧账号,只创建新账号补位")
standby_list = []
else:
standby_list = [
a
for a in get_standby_accounts()
if a.get("_quota_recovered")
and not _is_main_account_email(a.get("email"))
and not is_account_disabled(a)
]
standby_index = 0
from autoteam import cancel_signal
for i in range(need):
if cancel_signal.is_cancelled():
logger.warning("[填充] 收到取消请求,已完成 %d/%d", i, need)
break
logger.info("[填充] 添加第 %d/%d 个账号...", i + 1, need)
# 优先复用 standby 中额度已恢复的旧账号
added = False
while standby_index < len(standby_list):
reusable = standby_list[standby_index]
standby_index += 1
email = reusable["email"]
skip_reason = _auto_reuse_skip_reason(reusable)
if skip_reason:
logger.info("[填充] 跳过旧账号: %s(%s)", email, skip_reason)
continue
logger.info("[填充] 复用旧账号: %s", email)
# 确保 Team API session 可用;auto transport 不一定有 browser。
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
added = reinvite_account(chatgpt, mail_client, reusable)
if added:
break
logger.warning("[填充] 复用旧账号失败,尝试下一个旧账号: %s", email)
if not added:
# 创建新账号
logger.info("[填充] 创建新账号...")
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
if direct_parallel is None:
created_email = create_new_account(chatgpt, mail_client)
else:
created_email = create_new_account(chatgpt, mail_client, parallel=direct_parallel)
if created_email and (
not isinstance(created_email, str)
or _validate_managed_account_operational(
created_email,
threshold=AUTO_CHECK_THRESHOLD,
stage_label="[填充验收]",
chatgpt_api=chatgpt,
)
):
added = created_email
elif created_email:
logger.warning("[填充] 新账号未通过运行验收,释放席位并丢弃: %s", created_email)
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
remove_status = remove_from_team(chatgpt, created_email, return_status=True)
if remove_status in ("removed", "already_absent"):
update_account(created_email, status=STATUS_STANDBY, _reason="fill_new_account_not_ready")
if not added:
logger.warning("[填充] 本轮补位失败,第 %d/%d 个空缺仍未填上", i + 1, need)
# 验证成员数
if not _chatgpt_session_ready(chatgpt):
chatgpt.start()
new_count = get_team_member_count(chatgpt)
if new_count >= 0:
logger.info("[填充] 当前成员数: %d/%d", new_count, target)
logger.info("[填充] 填充完成")
if post_sync:
sync_to_cpa()
if print_status:
cmd_status()
finally:
if chatgpt:
chatgpt.stop()
def _summarize_outcomes(outcomes):
"""把 outcome dict 列表按 status 聚合,返回 {status: count} 的 OrderedDict。"""
from collections import OrderedDict
counts = OrderedDict()
for o in outcomes:
st = (o or {}).get("status") or "unknown"
counts[st] = counts.get(st, 0) + 1
return counts
def _fetch_team_non_master_emails(chatgpt_api):
"""
一次性快照 Team 当前的非主号成员邮箱集合。返回 (ok, emails_set)。
ok=False 表示鉴权失败或网络问题,调用方可自行决定是重试还是放弃。
失败时主动 log 具体 status + body 前 200 字,方便用户直接看到根因
(401="session 失效"、0="playwright JS 抛错网络挂了"等)。
"""
master_email = _normalized_email(get_admin_email())
account_id = get_chatgpt_account_id()
if not account_id:
logger.error("[免费号] account_id 为空,无法确认席位")
return False, set()
try:
result = chatgpt_api._api_fetch("GET", f"/backend-api/accounts/{account_id}/users")
except Exception as exc:
# Playwright 页面崩溃/context 被关掉等底层错误——不是 JS fetch 异常,JS 的 try/catch 接不住
logger.error("[免费号] 拉取 Team 成员列表抛异常(playwright 层): %s", exc)
return False, set()
status = result.get("status")
if status != 200:
body_excerpt = (result.get("body") or "")[:200].replace("\n", " ")
logger.error(
"[免费号] 拉取 Team 成员列表失败 status=%s body=%s "
"(可用 POST /api/admin/fix-account-id 自动修正 account_id,或重新导入 session_token)",
status,
body_excerpt,
)
return False, set()
try:
data = json.loads(result["body"])
except Exception as exc:
logger.error("[免费号] 成员列表 JSON 解析失败: %s body=%s", exc, (result.get("body") or "")[:200])
return False, set()
members = data.get("items", data.get("users", data.get("members", [])))
emails = {_normalized_email(m.get("email", "")) for m in members if m.get("email")}
emails.discard(master_email)
emails.discard("")
return True, emails
def _wait_team_new_members_cleared(chatgpt_api, baseline_emails, max_wait=180, poll_interval=6):
"""
等待"不在 baseline 里的新成员"全部被踢出。baseline 是进入 fill-personal 前就已经存在的
非主号成员(比如 Team fill 创建的真实 Team 子号,用户明确要求保留它们)。
返回 True: 新增成员已清空(可能还有 baseline 成员在,但那不归本任务管)。
返回 False: 超时仍有新增成员;或连续 401/403 鉴权失败。
风控背景:OpenAI 对批量邀请/踢人敏感,每批免费号(注册→主号踢出)完成后等后台真正
同步完成再开始下一批,避免短时间内大量操作触发风控。
"""
from autoteam import cancel_signal
baseline_emails = {e for e in baseline_emails if e}
master_email = _normalized_email(get_admin_email())
deadline = time.time() + max_wait
last_count = None
# 401 累计计数:管理员 session_token 实际无 admin 权限时,401 会一直不变,
# 与其傻等 180s 再超时,不如连续 3 次 401 就判定 session 失效,早停并给出可诊断信息
unauthorized_hits = 0
forbidden_hits = 0
while time.time() < deadline:
# 即使在等待清空,也允许用户点"停止任务"让流程尽早退出,不要硬等 180s
if cancel_signal.is_cancelled():
logger.warning("[免费号] 等待新成员清空期间收到取消请求,提前退出")
return False
account_id = get_chatgpt_account_id()
if not account_id:
logger.error("[免费号] account_id 为空,无法确认席位")
return False
path = f"/backend-api/accounts/{account_id}/users"
result = chatgpt_api._api_fetch("GET", path)
status = result["status"]
if status != 200:
body_excerpt = (result.get("body") or "")[:220].replace("\n", " ")
logger.warning(
"[免费号] 成员列表拉取失败: %d,body=%s,继续等待",
status,
body_excerpt,
)
# OpenAI 对 Team admin 接口:401=session 未认证,403=认证了但非 admin
# 两种都不是"再等等就好"的状态,快速 fail-fast 比傻等 180s 更有信息量
if status == 401:
unauthorized_hits += 1
if unauthorized_hits >= 3:
logger.error(
"[免费号] 连续 %d 次 401 鉴权失败,session_token 已失效或权限不足,"
"请在「设置」页重新导入管理员 session_token",
unauthorized_hits,
)
return False
elif status == 403:
forbidden_hits += 1
if forbidden_hits >= 3:
logger.error(
"[免费号] 连续 %d 次 403,当前账号非 workspace admin,"
"生成免费号需要管理员在 Team 工作区里踢人的能力",
forbidden_hits,
)
return False
time.sleep(poll_interval)
continue
try:
data = json.loads(result["body"])
members = data.get("items", data.get("users", data.get("members", [])))
except Exception as exc:
logger.warning("[免费号] 成员列表解析失败: %s", exc)
time.sleep(poll_interval)
continue
emails_in_team = {_normalized_email(m.get("email", "")) for m in members if m.get("email")}
emails_in_team.discard(master_email)
emails_in_team.discard("")
# 只关心"新增"(不在 baseline 里的),baseline 的成员是用户希望保留的 Team 席位
new_members = emails_in_team - baseline_emails
if not new_members:
baseline_still = emails_in_team & baseline_emails
logger.info(
"[免费号] 新增成员已清空(baseline 保留 %d 个: %s)",
len(baseline_still),
sorted(baseline_still)[:6] or ["-"],
)
return True
if last_count != len(new_members):
logger.info(
"[免费号] Team 仍有 %d 个未被踢出的新号: %s,等待清空...",
len(new_members),
sorted(new_members)[:6],
)
last_count = len(new_members)
time.sleep(poll_interval)
logger.error("[免费号] 等待新增成员清空超时(%ss),新号未被踢干净", max_wait)
return False
def _cmd_fill_personal(count):
"""
生产 count 个免费号:注册 → 主号踢出 → personal OAuth → 状态置 PERSONAL。
风控策略(用户明确要求):
1. 一个主号同时最多 TEAM_SUB_ACCOUNT_HARD_CAP 个子号在 Team 里
→ 每批限制 this_round 不超过当前可用子号席位
2. 不强制清空 Team 现有席位:进入时把非主号成员邮箱快照为 baseline(可能是 Team fill
创建的真实 Team 子号,用户希望保留)。每批结束后只等"本批注册的新号"被踢干净,
不管 baseline 成员是否还在。
3. 每个账号之间随机 sleep 8-20s,每批之间 30-60s,避免节奏单一被识别
4. chatgpt_api 在整个 fill 流程里懒加载一次,避免反复 start/stop 产生可疑痕迹
"""
import random
count = max(0, int(count or 0))
if count <= 0:
logger.info("[免费号] 数量为 0,跳过")
return
BATCH_SIZE = TEAM_SUB_ACCOUNT_HARD_CAP
WAIT_TEAM_EMPTY_TIMEOUT = 180
mail_client = CloudMailClient()
mail_client.login()
# 懒加载 chatgpt_api:只在需要查席位时启动
chatgpt = [None]
def _ensure_chatgpt():
if not chatgpt[0] or not _chatgpt_session_ready(chatgpt[0]):
chatgpt[0] = ChatGPTTeamAPI()
chatgpt[0].start()
return chatgpt[0]
def _stop_chatgpt():
if chatgpt[0]:
try:
chatgpt[0].stop()
except Exception as exc:
logger.debug("[免费号] 关闭 chatgpt_api 异常: %s", exc)
chatgpt[0] = None
logger.info("[免费号] 目标 %d 个免费号,每批 %d 个", count, BATCH_SIZE)
# 启动时快照:记录进入时已经在 Team 里的非主号成员,他们不归本任务管
# (可能是 Team fill 创建的真实 Team 子号,用户希望保留)
try:
api_snap = _ensure_chatgpt()
ok, baseline_emails = _fetch_team_non_master_emails(api_snap)
if not ok:
logger.error(
"[免费号] 启动时无法拉取 Team 成员列表,鉴权失败或 session_token 无效。"
"请先用 /api/admin/fix-account-id 或重新导入 session_token。"
)
_stop_chatgpt()
return
logger.info(
"[免费号] baseline 非主号成员 %d 个: %s (这些席位不会被清空)",
len(baseline_emails),
sorted(baseline_emails)[:6] or ["-"],
)
finally:
_stop_chatgpt()
# 队列化拒绝(Solution C):Team 子号已满 TEAM_SUB_ACCOUNT_HARD_CAP 时直接拒绝,
# 不强制踢健康账号腾席位。这样最小化风控暴露面 —— 只在自然 exhausted 或手动腾位置
# 后才生产免费号。
cap = TEAM_SUB_ACCOUNT_HARD_CAP
if len(baseline_emails) >= cap:
logger.warning(
"[免费号] Team 子号已满 %d/%d,fill-personal 拒绝执行。"
"请先等子号自然 exhausted 释放席位,或手动 kick/ replace 腾位置后再试。",
len(baseline_emails),
cap,
)
return
# 把本轮目标压到 (cap - baseline) 以内,防止任何批次超员
quota_for_run = cap - len(baseline_emails)
if count > quota_for_run:
logger.warning(
"[免费号] 目标 %d 超过当前可用席位 %d (Team 已占 %d/%d),自动压到 %d 个",
count,
quota_for_run,
len(baseline_emails),
cap,
quota_for_run,
)
count = quota_for_run
produced = 0
remaining = count
batch_idx = 0
# 整轮生产的所有 outcome(每个子号一个 dict),批次末 + 结束时做分类统计
outcomes = []
from autoteam import cancel_signal
try:
while remaining > 0:
if cancel_signal.is_cancelled():
logger.warning("[免费号] 收到取消请求,停止后续批次")
break
batch_idx += 1
# Team 席位总上限 TEAM_SUB_ACCOUNT_HARD_CAP:baseline 已占了一部分,
# 本批最多再加 (cap - baseline) 个,严格不超员。若 baseline 已占满,
# 入口处已经拒绝并 return,这里不会走到。
max_new_this_batch = TEAM_SUB_ACCOUNT_HARD_CAP - len(baseline_emails)
this_round = min(BATCH_SIZE, remaining, max_new_this_batch)
if this_round <= 0:
logger.warning(
"[免费号] 第 %d 批可用席位已耗尽(baseline %d/%d),停止生产",
batch_idx,
len(baseline_emails),
TEAM_SUB_ACCOUNT_HARD_CAP,
)
break
logger.info(
"[免费号] === 第 %d 批开始(本批 %d 个,剩余 %d,baseline %d 个) ===",
batch_idx,
this_round,
remaining,
len(baseline_emails),
)
# 第一批进入时 Team 就是 baseline 状态,不需要等;从第二批开始等"上一批新号"被踢干净
if batch_idx > 1:
try:
api = _ensure_chatgpt()
ok = _wait_team_new_members_cleared(api, baseline_emails, max_wait=WAIT_TEAM_EMPTY_TIMEOUT)
if not ok:
logger.error(
"[免费号] 第 %d 批开始前上一批新号未踢干净,停止生产避免触发风控",
batch_idx,
)
break
finally:
# 释放浏览器,让每个子号注册时拿到干净的 playwright 环境
_stop_chatgpt()
batch_produced = 0
batch_outcomes = []
for i in range(this_round):
if cancel_signal.is_cancelled():
logger.warning("[免费号] 收到取消请求,跳出本批剩余账号")
break
seq = produced + batch_produced + 1
logger.info("[免费号] 第 %d 批 第 %d/%d 个(累计 %d/%d)", batch_idx, i + 1, this_round, seq, count)
# 单个账号内部的任何异常都不能终止整批(否则外层 finally 后的 sync_to_cpa 会丢失已产出的账号)
outcome = {}
try:
email = create_new_account(None, mail_client, leave_workspace=True, out_outcome=outcome)
except Exception as exc:
logger.error(
"[免费号] 第 %d 批 第 %d 个 create_new_account 异常,跳过: %s",
batch_idx,
i + 1,
exc,
)
email = None
outcome = {"status": "exception", "reason": f"未捕获异常: {exc}"}
record_failure("", "exception", f"_cmd_fill_personal 里 create_new_account 抛异常: {exc}")
if not outcome.get("status"):
# 例如从 _check_pending_invites 路径成功回来,outcome 没被 create_account_direct 填
outcome["status"] = "success" if email else "unknown_failure"
batch_outcomes.append(outcome)
outcomes.append(outcome)
if email:
batch_produced += 1
logger.info(
"[免费号] 第 %d 批 第 %d 个完成: %s (status=%s)",
batch_idx,
i + 1,
email,
outcome.get("status"),
)
else:
logger.warning(
"[免费号] 第 %d 批 第 %d 个生产失败:status=%s, reason=%s, last_email=%s",
batch_idx,
i + 1,
outcome.get("status"),
outcome.get("reason"),
outcome.get("last_email") or outcome.get("email"),
)
# 账号间随机抖动
if i < this_round - 1:
gap = random.uniform(8, 20)
logger.info("[免费号] 账号间间隔 %.1fs", gap)
time.sleep(gap)
produced += batch_produced
remaining = count - produced
batch_stats = _summarize_outcomes(batch_outcomes)
logger.info(
"[免费号] === 第 %d 批完成:本批成功 %d / %d,累计 %d/%d,剩余 %d ===",
batch_idx,
batch_produced,
this_round,
produced,
count,
remaining,
)
logger.info("[免费号] 第 %d 批分类统计: %s", batch_idx, batch_stats)
# 批次结束后:等本批注册的新号都被踢出(回到 baseline),否则停下
if remaining > 0:
try:
api = _ensure_chatgpt()
ok = _wait_team_new_members_cleared(api, baseline_emails, max_wait=WAIT_TEAM_EMPTY_TIMEOUT)
if not ok:
logger.error("[免费号] 第 %d 批结束后新号未踢干净,停止继续生产", batch_idx)
break
finally:
_stop_chatgpt()
cool_down = random.uniform(30, 60)
logger.info("[免费号] 批次间冷却 %.1fs", cool_down)
time.sleep(cool_down)
# === 末批兜底清理 ===
# 即使每个子号内部的 remove_from_team 报告成功,OpenAI 的 /users API
# 对新加入成员存在同步延迟,首次 GET 可能没列出该成员 → 代码误判 already_absent
# 直接跳过 DELETE。结果:账号本地 status=PERSONAL 认证也拿到了,但 Team 席位里
# 还挂着 Member(截图里用户看到的正是这种情况)。
# 不信任内部 kick 报告,以 Team 真实成员列表为权威,强清所有不在 baseline 的新号。
# 即使某些账号已被踢成功,DELETE 一个不存在的 user_id 只会返回 4xx,副作用可控。
try:
api_final = _ensure_chatgpt()
ok_final, current_non_master = _fetch_team_non_master_emails(api_final)
if not ok_final:
logger.warning("[免费号] 末批兜底:无法拉取 Team 成员列表,跳过强制清理")
else:
stragglers = sorted(current_non_master - baseline_emails)
if not stragglers:
logger.info(
"[免费号] 末批兜底:Team 已回到 baseline(%d 个非主号成员),无需清理",
len(baseline_emails),
)
else:
logger.warning(
"[免费号] 末批兜底:Team 仍残留 %d 个新号未被踢出,强制清理: %s",
len(stragglers),
stragglers[:10],
)
cleaned = 0
for stray_email in stragglers:
try:
st = remove_from_team(api_final, stray_email, return_status=True, lookup_retries=1)
logger.info("[免费号] 末批兜底 kick %s → %s", stray_email, st)
if st == "removed":
cleaned += 1
except Exception as exc:
logger.error("[免费号] 末批兜底 kick %s 抛异常: %s", stray_email, exc)
logger.info(
"[免费号] 末批兜底清理完成:实际移除 %d / %d 个,剩余由用户手动处理",
cleaned,
len(stragglers),
)
except Exception as exc:
logger.error("[免费号] 末批兜底清理出错(不影响已生产账号): %s", exc)
finally:
_stop_chatgpt()
finally:
_stop_chatgpt()
# 无论主循环以何种方式退出(完成 / 被阻断 / 异常),都汇总一次 + 把已生产的账号同步进 CPA
total_stats = _summarize_outcomes(outcomes)
logger.info(
"[免费号汇总] 目标 %d,尝试 %d,成功 %d,失败 %d(共 %d 批)",
count,
len(outcomes),
produced,
len(outcomes) - produced,
batch_idx,
)
logger.info("[免费号汇总] 各类分布: %s", total_stats)
# 把每个失败账号的 last_email + status + reason 再打一条,方便直接定位
for o in outcomes:
if o.get("status") != "success":
logger.info(
"[免费号汇总] FAIL email=%s status=%s reason=%s",
o.get("last_email") or o.get("email") or "",
o.get("status"),
o.get("reason"),
)
try:
sync_to_cpa()
except Exception as exc:
logger.error("[免费号] sync_to_cpa 异常(已生产账号本地已入池,可稍后手动同步): %s", exc)
try:
cmd_status()
except Exception as exc:
logger.error("[免费号] cmd_status 异常: %s", exc)
def cmd_cleanup(max_seats=None):
"""清理多余的 Team 成员,只移除本地 accounts.json 中管理的账号"""
account_id = get_chatgpt_account_id()
accounts = load_accounts()
local_emails = {a["email"].lower() for a in accounts if not _is_main_account_email(a.get("email"))}
if not local_emails:
logger.info("[清理] 本地无管理的账号,无需清理")
return
chatgpt = ChatGPTTeamAPI()
chatgpt.start()
try:
# 获取当前成员列表
path = f"/backend-api/accounts/{account_id}/users"
result = chatgpt._api_fetch("GET", path)
if result["status"] != 200:
logger.error("[清理] 获取成员列表失败: %d", result["status"])
return
data = json.loads(result["body"])
members = data.get("items", data.get("users", data.get("members", [])))
total = len(members)
logger.info("[清理] 当前 Team 成员数: %d", total)
# 区分:本地管理的 vs 手动添加的
local_members = []
external_members = []
for m in members:
email = m.get("email", "").lower()
if email in local_emails:
local_members.append(m)
else:
external_members.append(m)
logger.info("[清理] 手动添加的成员: %d", len(external_members))
for m in external_members:
logger.info("[清理] %s (%s)", m.get("email"), m.get("role"))
logger.info("[清理] 本地管理的成员: %d", len(local_members))
for m in local_members:
logger.info("[清理] %s (%s)", m.get("email"), m.get("role"))
# 确定要移除的数量
if max_seats is None:
max_seats = 5
logger.info("[清理] 未指定上限,使用默认总人数: %d", max_seats)
to_remove_count = total - max_seats
if to_remove_count <= 0:
logger.info("[清理] 成员数 %d 未超过上限 %d,无需清理", total, max_seats)
return
# 从本地管理的账号中选择要移除的(优先移除额度已用完的)
removable = sorted(
local_members,
key=lambda m: (
# 额度用完的优先移除
0
if find_account(accounts, m.get("email", ""))
and find_account(accounts, m.get("email", "")).get("status") == STATUS_EXHAUSTED
else 1,
# 其次按创建时间,旧的优先
find_account(accounts, m.get("email", "")).get("created_at", 0)
if find_account(accounts, m.get("email", ""))
else 0,
),
)
to_remove = removable[:to_remove_count]
logger.info("[清理] 需要移除 %d 个本地账号:", len(to_remove))
for m in to_remove:
logger.info("[清理] %s", m.get("email"))
# 执行移除
for m in to_remove:
email = m.get("email", "")
user_id = m.get("user_id") or m.get("id")
delete_path = f"/backend-api/accounts/{account_id}/users/{user_id}"
result = chatgpt._api_fetch("DELETE", delete_path)
if result["status"] in (200, 204):
logger.info("[清理] 已移除 %s", email)
update_account(email, status=STATUS_STANDBY)
else:
logger.error("[清理] 移除 %s 失败: %d", email, result["status"])
# 取消 pending invites 中本地管理的
inv_result = chatgpt._api_fetch("GET", f"/backend-api/accounts/{account_id}/invites")
if inv_result["status"] == 200:
inv_data = json.loads(inv_result["body"])
invites = (
inv_data if isinstance(inv_data, list) else inv_data.get("invites", inv_data.get("account_invites", []))
)
for inv in invites:
inv_email = inv.get("email_address", "").lower()
inv_id = inv.get("id")
if inv_email in local_emails and inv_id:
del_result = chatgpt._api_fetch("DELETE", f"/backend-api/accounts/{account_id}/invites/{inv_id}")
if del_result["status"] in (200, 204):
logger.info("[清理] 已取消邀请 %s", inv_email)
logger.info("[清理] 清理完成")
sync_to_cpa()
finally:
chatgpt.stop()
def cmd_pull_cpa():
"""从 CPA 反向同步认证文件到本地。"""
result = sync_from_cpa()
logger.info(
"[CPA] 拉取完成: 新增文件 %d, 更新文件 %d, 新增账号 %d, 更新账号 %d, 跳过 %d",
result.get("downloaded", 0),
result.get("updated", 0),
result.get("accounts_added", 0),
result.get("accounts_updated", 0),
result.get("skipped", 0),
)
return result
def cmd_reset_quota_recovery():
"""清空所有托管非主号账号的本地额度恢复记录。"""
accounts = load_accounts()
if not accounts:
summary = {
"total_accounts": 0,
"updated_accounts": 0,
"rearmed_exhausted_to_active": 0,
"rearmed_exhausted_to_auth_pending": 0,
}
logger.info("[额度重置] 本地无账号记录")
return summary
total_accounts = 0
updated_accounts = 0
rearmed_to_active = 0
rearmed_to_auth_pending = 0
for acc in accounts:
email = acc.get("email", "")
if _is_main_account_email(email):
continue
total_accounts += 1
changed = False
for key in ("last_quota", "quota_resets_at", "quota_exhausted_at", "quota_window"):
if acc.get(key) is not None:
acc[key] = None
changed = True
if acc.get("status") == STATUS_EXHAUSTED:
desired_status = STATUS_ACTIVE if _has_auth_file(acc) else STATUS_AUTH_PENDING
if acc.get("status") != desired_status:
acc["status"] = desired_status
changed = True
if desired_status == STATUS_ACTIVE:
rearmed_to_active += 1
else:
rearmed_to_auth_pending += 1
if changed:
updated_accounts += 1
if updated_accounts:
save_accounts(accounts)
summary = {
"total_accounts": total_accounts,
"updated_accounts": updated_accounts,
"rearmed_exhausted_to_active": rearmed_to_active,
"rearmed_exhausted_to_auth_pending": rearmed_to_auth_pending,
}
logger.info(
"[额度重置] 完成: 扫描 %d 个账号,更新 %d 个,恢复 exhausted -> active %d 个,exhausted -> auth_pending %d 个",
total_accounts,
updated_accounts,
rearmed_to_active,
rearmed_to_auth_pending,
)
return summary
def _reconcile_master_degraded_subaccounts(*, dry_run: bool = False, chatgpt_api=None):
"""Round 8 — SPEC-2 v1.5 §3.5.3:reconcile retroactive 清理。
Round 9 SPEC v2.0 — 改为 _apply_master_degraded_classification helper 的薄 wrapper,
保留旧返回结构(degraded_marked / skipped_reason)以兼容既有 cmd_reconcile 调用方。
新加 marked_grace / reverted_active / errors 字段透传,便于 audit。
若母号订阅 cancelled 且子号 JWT grace_until 仍未过期 → DEGRADED_GRACE
若 grace 已过期 / JWT 解析失败 → STANDBY
若母号已恢复 active → DEGRADED_GRACE 撤回为 ACTIVE
"""
from autoteam.master_health import _apply_master_degraded_classification
raw = _apply_master_degraded_classification(
chatgpt_api=chatgpt_api, dry_run=dry_run,
)
# 旧返回字段映射:degraded_marked = grace + standby (统一视为"被重分类")
degraded_marked = list(raw.get("marked_grace") or []) + list(raw.get("marked_standby") or [])
return {
"degraded_marked": degraded_marked,
"marked_grace": raw.get("marked_grace") or [],
"marked_standby": raw.get("marked_standby") or [],
"reverted_active": raw.get("reverted_active") or [],
"skipped_reason": raw.get("skipped_reason"),
"errors": raw.get("errors") or [],
"dry_run": raw.get("dry_run", bool(dry_run)),
}
def cmd_reconcile(dry_run: bool = False):
"""独立运行一次对账,修正残废 / 错位 / 耗尽未抛弃 / ghost 成员。
与 cmd_check 内部的 `_reconcile_team_members` 共享同一套逻辑,但:
- 不做额度检查、不触发 Codex 登录,纯做状态对齐
- 入口日志更友好,返回结构化 result 便于 API / 脚本消费
- dry_run=True 等价于 cmd_reconcile_dry_run,只输出诊断不动账户
Round 8 — 在常规 reconcile 后额外做 master-degraded retroactive cleanup。
"""
logger.info("[对账] 开始独立对账 dry_run=%s", dry_run)
recon = _reconcile_team_members(dry_run=dry_run)
# 汇总日志,方便看出哪些分支命中
summary_keys = [
"kicked",
"flipped_to_active",
"orphan_kicked",
"orphan_marked",
"misaligned_fixed",
"exhausted_marked",
"ghost_kicked",
"ghost_seen",
"over_cap_kicked",
]
parts = [f"{k}={len(recon.get(k) or [])}" for k in summary_keys]
logger.info("[对账] %s结果: %s", "(dry-run)" if dry_run else "", ", ".join(parts))
# 具体列表只在非空时打,避免日志噪声
for k in summary_keys:
items = recon.get(k) or []
if items:
logger.info("[对账] %s → %s", k, items)
# Round 8 — retroactive cleanup
try:
retro = _reconcile_master_degraded_subaccounts(dry_run=dry_run)
recon["master_degraded_retroactive"] = retro
if retro.get("degraded_marked"):
logger.info(
"[对账-retroactive] %s标 standby %d 个: %s",
"(dry-run) " if dry_run else "",
len(retro["degraded_marked"]),
retro["degraded_marked"],
)
except Exception as exc:
logger.warning("[对账-retroactive] 异常未阻塞主对账: %s", exc)
recon["master_degraded_retroactive"] = {
"degraded_marked": [], "skipped_reason": f"exception:{exc}",
}
return recon
def cmd_reconcile_dry_run():
"""诊断模式:只输出报告,不 kick 任何账号、不写 accounts.json。"""
return cmd_reconcile(dry_run=True)
def main():
import argparse
parser = argparse.ArgumentParser(
prog="manager.py",
description="ChatGPT Team 账号轮转管理器",
)
sub = parser.add_subparsers(dest="command", help="可用命令")
sub.add_parser("status", help="查看所有账号状态")
check_p = sub.add_parser("check", help="检查活跃账号 Codex 额度")
check_p.add_argument(
"--include-standby",
action="store_true",
help="同时探测 standby 池的 quota(限速+24h 去重,会对每个 standby 账号打一次 wham/usage)",
)
rotate_p = sub.add_parser("rotate", help="智能轮转(检查额度 → 移出 → 复用旧号 → 万不得已才创建新号)")
rotate_p.add_argument("target", type=int, nargs="?", default=3, help="目标成员数(默认 3,最多 3)")
sub.add_parser("add", help="手动添加一个新账号")
sub.add_parser("manual-add", help="手动 OAuth 添加账号(打开链接登录后粘贴回调 URL)")
admin_login_p = sub.add_parser("admin-login", help="交互式完成管理员主号登录")
admin_login_p.add_argument("--email", help="管理员邮箱;不传则运行时交互输入")
admin_session_p = sub.add_parser("admin-session", help="手动输入 session_token 导入管理员登录态")
admin_session_p.add_argument("--email", help="管理员邮箱;不传则运行时交互输入")
sub.add_parser("main-codex-sync", help="交互式同步主号 Codex 到 CPA")
fill_p = sub.add_parser("fill", help="补满 Team 成员到指定数量")
fill_p.add_argument("target", type=int, nargs="?", default=3, help="目标成员数(默认 3,最多 3)")
cleanup_p = sub.add_parser("cleanup", help="清理多余成员(只移除本地管理的)")
cleanup_p.add_argument("max_seats", type=int, nargs="?", default=None, help="最大席位数")
sub.add_parser("reset-quota", help="清空本地额度恢复记录,并把 exhausted 账号恢复为可检查状态")
sub.add_parser("sync", help="手动同步认证文件到 CPA")
sub.add_parser("pull-cpa", help="从 CPA 反向同步认证文件到本地")
reconcile_p = sub.add_parser(
"reconcile",
help="对账 Team 实际成员 vs 本地状态,修复残废 / 错位 / 耗尽未抛弃 / ghost",
)
reconcile_p.add_argument(
"--dry-run",
action="store_true",
help="只输出诊断报告,不 kick、不改 accounts.json",
)
api_p = sub.add_parser("api", help="启动 HTTP API 服务器")
api_p.add_argument("--host", default="0.0.0.0", help="监听地址(默认 0.0.0.0)")
api_p.add_argument("--port", type=int, default=8787, help="监听端口(默认 8787)")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
# 首次启动检查必填配置(api 命令在 start_server 里单独处理)
if args.command not in ("api",):
from autoteam.setup_wizard import check_and_setup
check_and_setup(interactive=True)
try:
from autoteam.auth_storage import ensure_auth_file_permissions
ensure_auth_file_permissions()
except Exception:
pass
if args.command == "status":
cmd_status()
elif args.command == "check":
cmd_check(include_standby=getattr(args, "include_standby", False))
elif args.command == "rotate":
cmd_rotate(args.target)
elif args.command == "add":
cmd_add()
elif args.command == "manual-add":
cmd_manual_add()
elif args.command == "admin-login":
cmd_admin_login(args.email)
elif args.command == "admin-session":
cmd_admin_session(args.email)
elif args.command == "main-codex-sync":
cmd_main_codex_sync()
elif args.command == "fill":
cmd_fill(args.target)
elif args.command == "cleanup":
cmd_cleanup(args.max_seats)
elif args.command == "reset-quota":
cmd_reset_quota_recovery()
elif args.command == "sync":
sync_to_cpa()
elif args.command == "pull-cpa":
cmd_pull_cpa()
elif args.command == "reconcile":
cmd_reconcile(dry_run=getattr(args, "dry_run", False))
elif args.command == "api":
from autoteam.api import start_server
start_server(host=args.host, port=args.port)
if __name__ == "__main__":
main()