AutoTeam-F / src /autoteam /register_failures.py
ZRainbow's picture
feat(round-12 S4): register dual-path with mail provider fallback
19162c0
Raw
History Blame Contribute Delete
6.67 kB
"""注册失败明细日志(持久化到 register_failures.json)。
用户要求:失败账号不能污染账号列表,但失败原因必须能追溯 —— 比如 add-phone 触发了几次、
哪些临时邮箱在 OAuth 阶段挂了、哪些被判 duplicate。本模块单独存这类明细,不与 accounts.json 混。
记录只保留最近 N 条(RECORD_LIMIT),避免长期运行后文件膨胀。
并发:`_cmd_fill_personal` 等任务跑在 ThreadPoolExecutor 里,多个 worker 会同时命中
record_failure —— 无锁的读-改-写会互相覆盖导致丢记录。全部写入走 _LOCK 串行化。
"""
import json
import logging
import os
import threading
import time
from pathlib import Path
from autoteam.textio import read_text, write_text
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.parent
FAILURES_FILE = PROJECT_ROOT / "register_failures.json"
FAILURES_FILE_MODE = 0o666
RECORD_LIMIT = 500
_LOCK = threading.Lock()
# Round 8 — Master Team 订阅降级 + OAuth Personal Workspace 显式选择
# 详见 prompts/0426/spec/shared/master-subscription-health.md §7 + oauth-workspace-selection.md §2.3
MASTER_SUBSCRIPTION_DEGRADED = "master_subscription_degraded"
"""母号 ChatGPT Team 订阅 cancel(eligible_for_auto_reactivation=true)。M-T1 / M-T2 fail-fast。"""
OAUTH_WS_NO_PERSONAL = "oauth_workspace_select_no_personal"
"""workspaces[] 中找不到 personal 项 — user 在后端事实上只属于 Team。fail-fast,不重试。"""
OAUTH_WS_ENDPOINT_ERROR = "oauth_workspace_select_endpoint_error"
"""POST /api/accounts/workspace/select 4xx/5xx 且 UI fallback 失败。端点变更 / 反爬 / DOM 漂移。"""
OAUTH_PLAN_DRIFT_PERSISTENT = "oauth_plan_drift_persistent"
"""workspace/select 成功但 5 次 OAuth retry 后 bundle.plan_type 仍非 free — 后端最终一致性失败。"""
# Round 12 S4 — 注册收尾双路径:mail provider rotation 触发分类(详见
# `src/autoteam/mail/register_dual_path.py`)。三类失败会触发 RegisterPathRotator
# 切换到下一个 provider 重试,失败明细写入 register_failures.json,**extra 中带
# provider_chain_history 字段记录每个 provider 的尝试结果。
MAIL_OTP_TIMEOUT = "mail_otp_timeout"
"""注册时等待 OTP / 验证邮件超时 — 多见于 alias forwarding 链路转发延迟或 reader 失联。"""
MAIL_INVITE_LINK_MISSING = "mail_invite_link_missing"
"""邀请邮件已到但 extract_invite_link 抓不到链接 — 邮件模板变体 / HTML 损坏 / sender 异常。"""
MAIL_DOMAIN_REJECTED = "mail_domain_rejected"
"""OpenAI 拒绝当前 mail provider 的域名(disposable / not allowed) — 触发整体切到下一 provider。"""
def _load():
if not FAILURES_FILE.exists():
return []
try:
raw = read_text(FAILURES_FILE).strip()
if not raw:
return []
data = json.loads(raw)
return data if isinstance(data, list) else []
except Exception as exc:
# 不静默吞:文件损坏时保留原件便于人工排查,返回空让新一轮写入继续。
corrupt_path = FAILURES_FILE.with_suffix(f".corrupt-{int(time.time())}.json")
try:
FAILURES_FILE.rename(corrupt_path)
logger.error("[register_failures] 解析失败, 已保留原文件为 %s: %s", corrupt_path.name, exc)
except Exception as rename_exc:
logger.error("[register_failures] 解析失败且无法重命名 (%s): %s", exc, rename_exc)
return []
def _save(records):
records = records[-RECORD_LIMIT:]
target = FAILURES_FILE.resolve()
write_text(target, json.dumps(records, indent=2, ensure_ascii=False))
try:
os.chmod(target, FAILURES_FILE_MODE)
except Exception:
pass
def record_failure(email, category, reason="", **extra):
"""追加一条失败记录。
category(原有):
'phone_blocked' / 'duplicate_exhausted' / 'register_failed' / 'oauth_failed'
/ 'kick_failed' / 'team_oauth_failed' / 'exception'
category(SPEC-2 新增 — 注册/邀请生命周期相关):
'oauth_phone_blocked' OAuth 阶段触发 add-phone / duplicate(invite.RegisterBlocked)
'plan_unsupported' plan_type 不在白名单(team/free/plus/pro)
'no_quota_assigned' OAuth 成功但后端没发配额(primary_total=0 或 rate_limit 全空)
'plan_drift' reinvite 后 plan_type 漂移到非 team
'auth_error_at_oauth' post-register quota 探测返回 401/403
'quota_probe_network_error' post-register quota 探测网络错误(允许下次重试)
category(Round 8 SPEC-2 v1.5 新增 — Master 订阅降级 / OAuth Workspace 显式选择):
'master_subscription_degraded' 母号 Team 订阅已 cancel(M-T1 / M-T2 fail-fast)
'oauth_workspace_select_no_personal' OAuth session workspaces[] 中无 personal 项
'oauth_workspace_select_endpoint_error' workspace/select 端点 + UI fallback 都失败
'oauth_plan_drift_persistent' 5 次 OAuth retry 后 bundle.plan 仍非 free
category(Round 12 S4 新增 — 注册双路径 mail provider rotation):
'mail_otp_timeout' OTP/验证邮件等待超时 → 切下一 provider
'mail_invite_link_missing' 邀请链接抓不到 → 切下一 provider
'mail_domain_rejected' OpenAI 拒绝当前 provider 域名 → 切下一 provider
reason: 面向人的简短描述,显示在日志和面板。可留空,从 extra.detail 取代。
extra: 任意附加字段(attempts, duplicate_swaps, step, url, stage, detail ...)
Round 12 S4 约定 extra 中可带:
provider_chain_history: list[{provider, error_type, ts[, error]}]
旧记录无此字段,读路径用 `r.get("provider_chain_history", [])` 兼容。
"""
with _LOCK:
records = _load()
records.append(
{
"timestamp": time.time(),
"email": email or "",
"category": category,
"reason": reason or extra.get("detail", "") or "",
**extra,
}
)
_save(records)
def list_failures(limit=50):
with _LOCK:
records = _load()
return records[-limit:][::-1]
def count_by_category(since_ts=0):
with _LOCK:
records = _load()
counts = {}
for r in records:
if r.get("timestamp", 0) < since_ts:
continue
cat = r.get("category", "unknown")
counts[cat] = counts.get(cat, 0) + 1
return counts