Spaces:
Paused
Paused
| """CPA (CLIProxyAPI) 认证文件同步 - 保持本地 codex 认证文件与 CPA 一致""" | |
| import base64 | |
| import json | |
| import logging | |
| import time | |
| from datetime import datetime | |
| from hashlib import md5 | |
| from pathlib import Path | |
| import requests | |
| from autoteam.auth_storage import AUTH_DIR, ensure_auth_dir, ensure_auth_file_permissions | |
| from autoteam.config import AUTO_CHECK_TARGET_SEATS, CPA_KEY, CPA_URL | |
| from autoteam.textio import write_text | |
| logger = logging.getLogger(__name__) | |
| def _headers(): | |
| return {"Authorization": f"Bearer {CPA_KEY}"} | |
| def list_cpa_files(): | |
| """获取 CPA 中所有认证文件。远端异常必须显式失败,不能伪装成空列表。""" | |
| try: | |
| resp = requests.get(f"{CPA_URL}/v0/management/auth-files", headers=_headers(), timeout=10) | |
| except requests.RequestException as exc: | |
| logger.error("[CPA] 获取文件列表失败: %s", exc) | |
| raise RuntimeError(f"[CPA] auth-files list request failed: {exc}") from exc | |
| if resp.status_code != 200: | |
| logger.error("[CPA] 获取文件列表失败: %d %s", resp.status_code, resp.text[:200]) | |
| raise RuntimeError(f"[CPA] auth-files list failed: HTTP {resp.status_code}") | |
| try: | |
| data = resp.json() | |
| except ValueError as exc: | |
| logger.error("[CPA] 获取文件列表返回非 JSON 内容: %s", resp.text[:200]) | |
| raise RuntimeError("[CPA] auth-files list returned non-JSON response") from exc | |
| files = data.get("files", []) | |
| if not isinstance(files, list): | |
| raise RuntimeError("[CPA] auth-files list response missing files list") | |
| return files | |
| def upload_to_cpa(filepath): | |
| """上传认证文件到 CPA""" | |
| filepath = Path(filepath) | |
| if not filepath.exists(): | |
| logger.warning("[CPA] 文件不存在: %s", filepath) | |
| return False | |
| with open(filepath, "rb") as f: | |
| resp = requests.post( | |
| f"{CPA_URL}/v0/management/auth-files", | |
| headers=_headers(), | |
| files={"file": (filepath.name, f, "application/json")}, | |
| timeout=10, | |
| ) | |
| if resp.status_code == 200: | |
| logger.info("[CPA] 已上传: %s", filepath.name) | |
| return True | |
| else: | |
| logger.error("[CPA] 上传失败: %d %s", resp.status_code, resp.text[:200]) | |
| return False | |
| def delete_from_cpa(name): | |
| """从 CPA 删除认证文件""" | |
| resp = requests.delete( | |
| f"{CPA_URL}/v0/management/auth-files", | |
| headers=_headers(), | |
| params={"name": name}, | |
| timeout=10, | |
| ) | |
| if resp.status_code == 200: | |
| logger.info("[CPA] 已删除: %s", name) | |
| return True | |
| else: | |
| logger.error("[CPA] 删除失败: %d %s", resp.status_code, resp.text[:200]) | |
| return False | |
| def download_from_cpa(name): | |
| """从 CPA 下载认证文件内容。""" | |
| resp = requests.get( | |
| f"{CPA_URL}/v0/management/auth-files/download", | |
| headers=_headers(), | |
| params={"name": name}, | |
| timeout=10, | |
| ) | |
| if resp.status_code == 200: | |
| return resp.text | |
| logger.error("[CPA] 下载失败: %s -> %d %s", name, resp.status_code, resp.text[:200]) | |
| return None | |
| def _parse_expired_timestamp(value): | |
| if isinstance(value, (int, float)): | |
| return float(value) | |
| if not value: | |
| return time.time() + 3600 | |
| text = str(value).strip() | |
| try: | |
| if text.endswith("Z"): | |
| return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() | |
| return datetime.fromisoformat(text).timestamp() | |
| except Exception: | |
| return time.time() + 3600 | |
| def _parse_optional_timestamp(value): | |
| if isinstance(value, (int, float)): | |
| return float(value) | |
| if not value: | |
| return 0.0 | |
| text = str(value).strip() | |
| try: | |
| if text.endswith("Z"): | |
| return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() | |
| return datetime.fromisoformat(text).timestamp() | |
| except Exception: | |
| return 0.0 | |
| def _parse_jwt_payload(token): | |
| parts = token.split(".") | |
| if len(parts) < 2: | |
| return {} | |
| payload = parts[1] | |
| payload += "=" * (-len(payload) % 4) | |
| try: | |
| return json.loads(base64.urlsafe_b64decode(payload)) | |
| except Exception: | |
| return {} | |
| def _bundle_from_auth_data(auth_data, fallback_name=""): | |
| id_token = auth_data.get("id_token", "") | |
| claims = _parse_jwt_payload(id_token) if id_token else {} | |
| auth_claims = claims.get("https://api.openai.com/auth", {}) if isinstance(claims, dict) else {} | |
| plan_type = auth_claims.get("chatgpt_plan_type", "") | |
| if not plan_type and "-team" in fallback_name: | |
| plan_type = "team" | |
| if not plan_type and "-plus" in fallback_name: | |
| plan_type = "plus" | |
| if not plan_type and "-free" in fallback_name: | |
| plan_type = "free" | |
| if not plan_type: | |
| plan_type = "unknown" | |
| return { | |
| "id_token": id_token, | |
| "access_token": auth_data.get("access_token", ""), | |
| "refresh_token": auth_data.get("refresh_token", ""), | |
| "account_id": auth_data.get("account_id", ""), | |
| "email": auth_data.get("email", ""), | |
| "plan_type": plan_type, | |
| "expired": _parse_expired_timestamp(auth_data.get("expired")), | |
| "last_refresh_ts": _parse_optional_timestamp(auth_data.get("last_refresh")), | |
| } | |
| def _refresh_account_proxy_url_for_upload(acc: dict, path: Path) -> None: | |
| email = str(acc.get("email") or "").strip().lower() | |
| if not email or path.name.startswith("codex-main-"): | |
| return | |
| try: | |
| from autoteam.admin_state import get_admin_email | |
| if email == (get_admin_email() or "").strip().lower(): | |
| return | |
| except Exception: | |
| pass | |
| 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 "" | |
| if not proxy_url: | |
| if required: | |
| raise RuntimeError("IPv6 pool is required but no account proxy was assigned") | |
| return | |
| try: | |
| auth_data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception as exc: | |
| if required: | |
| raise RuntimeError(f"cannot read auth file for required IPv6 proxy: {path.name}") from exc | |
| logger.warning("[CPA] 无法读取 auth_file 以刷新 proxy_url,继续原样上传: %s (%s)", path.name, exc) | |
| return | |
| if auth_data.get("proxy_url") == proxy_url: | |
| return | |
| auth_data["proxy_url"] = proxy_url | |
| write_text(path, json.dumps(auth_data, indent=2, ensure_ascii=False)) | |
| logger.info("[CPA] 已刷新待同步凭证 proxy_url: %s", email) | |
| except Exception as exc: | |
| if required: | |
| logger.error("[CPA] IPv6 proxy_url 为必需但刷新失败: %s (%s)", email, exc) | |
| raise | |
| logger.warning("[CPA] IPv6 proxy_url 刷新失败,继续上传原凭证: %s (%s)", email, exc) | |
| def _active_auth_publish_decision(acc: dict, path: Path) -> str: | |
| """Return publish/delete_remote/keep_remote for a local active Codex auth file.""" | |
| if not path.exists(): | |
| return "delete_remote" | |
| try: | |
| auth_data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception as exc: | |
| logger.warning("[CPA] 跳过无法读取的 active 凭证 %s: %s", path.name, exc) | |
| return "delete_remote" | |
| access_token = auth_data.get("access_token") | |
| if not access_token: | |
| logger.warning("[CPA] 跳过缺少 access_token 的 active 凭证: %s", path.name) | |
| return "delete_remote" | |
| try: | |
| from autoteam.codex_auth import check_codex_quota | |
| quota_status, _info = check_codex_quota(access_token, timeout=8) | |
| except Exception as exc: | |
| logger.warning("[CPA] active 凭证实时验证异常,保留远端副本等待下轮: %s (%s)", path.name, exc) | |
| return "keep_remote" | |
| if quota_status == "network_error": | |
| logger.warning("[CPA] active 凭证实时验证网络异常,保留远端副本等待下轮: %s", path.name) | |
| return "keep_remote" | |
| if quota_status == "ok": | |
| try: | |
| from autoteam.ipv6_pool import ipv6_pool | |
| email = (acc.get("email") or auth_data.get("email") or "").strip().lower() | |
| proxy_url = ipv6_pool.ensure(email) or "" | |
| if proxy_url and auth_data.get("proxy_url") != proxy_url: | |
| auth_data["proxy_url"] = proxy_url | |
| write_text(path, json.dumps(auth_data, indent=2, ensure_ascii=False)) | |
| logger.info("[CPA] 已刷新 active 凭证 proxy_url: %s", email) | |
| except Exception as exc: | |
| logger.warning("[CPA] active 凭证 IPv6 proxy_url 刷新失败,继续上传原凭证: %s", exc) | |
| return "publish" | |
| logger.warning("[CPA] 跳过实时验证失败的 active 凭证: %s (%s)", path.name, quota_status) | |
| return "delete_remote" | |
| def _normalized_auth_path(bundle, main=False): | |
| email = bundle.get("email", "") | |
| account_id = bundle.get("account_id", "") | |
| if main: | |
| suffix = account_id or md5(email.encode()).hexdigest()[:8] | |
| return AUTH_DIR / f"codex-main-{suffix}.json" | |
| plan_type = bundle.get("plan_type", "unknown") | |
| hash_id = md5(account_id.encode()).hexdigest()[:8] if account_id else "unknown" | |
| return AUTH_DIR / f"codex-{email}-{plan_type}-{hash_id}.json" | |
| def _auth_identity(bundle, main=False): | |
| if main: | |
| return ("main", bundle.get("account_id") or bundle.get("email") or "") | |
| return ("codex", (bundle.get("email") or "").lower(), bundle.get("account_id") or "") | |
| def _candidate_score(auth_data, bundle, name, main=False): | |
| canonical_name = _normalized_auth_path(bundle, main=main).name | |
| return ( | |
| 1 if name == canonical_name else 0, | |
| bundle.get("last_refresh_ts", _parse_optional_timestamp(auth_data.get("last_refresh"))), | |
| _parse_expired_timestamp(auth_data.get("expired")), | |
| len(auth_data.get("refresh_token") or ""), | |
| ) | |
| def _write_auth_file(filepath, bundle): | |
| ensure_auth_dir() | |
| auth_data = { | |
| "type": "codex", | |
| "id_token": bundle.get("id_token", ""), | |
| "access_token": bundle.get("access_token", ""), | |
| "refresh_token": bundle.get("refresh_token", ""), | |
| "account_id": bundle.get("account_id", ""), | |
| "email": bundle.get("email", ""), | |
| "expired": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(bundle.get("expired", 0))), | |
| "last_refresh": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(bundle.get("last_refresh_ts", time.time()))), | |
| } | |
| write_text(filepath, json.dumps(auth_data, indent=2)) | |
| ensure_auth_file_permissions(filepath) | |
| return filepath | |
| def _save_normalized_auth_file(bundle, main=False): | |
| filepath = _normalized_auth_path(bundle, main=main) | |
| if main: | |
| for old in AUTH_DIR.glob("codex-main-*.json"): | |
| if old != filepath and old.exists(): | |
| old.unlink() | |
| else: | |
| email = bundle.get("email", "") | |
| for old in AUTH_DIR.glob(f"codex-{email}-*.json"): | |
| if old != filepath and old.exists(): | |
| old.unlink() | |
| return _write_auth_file(filepath, bundle) | |
| def _load_local_best_candidate(identity_key): | |
| """读取本地同 identity 的最佳候选认证文件。""" | |
| best = None | |
| for path in AUTH_DIR.glob("codex-*.json"): | |
| if not path.is_file(): | |
| continue | |
| try: | |
| auth_data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: | |
| continue | |
| if auth_data.get("type") != "codex": | |
| continue | |
| main = path.name.startswith("codex-main-") | |
| bundle = _bundle_from_auth_data(auth_data, fallback_name=path.name) | |
| if _auth_identity(bundle, main=main) != identity_key: | |
| continue | |
| candidate = { | |
| "path": path, | |
| "auth_data": auth_data, | |
| "bundle": bundle, | |
| "main": main, | |
| } | |
| if best is None or _candidate_score( | |
| candidate["auth_data"], candidate["bundle"], candidate["path"].name, candidate["main"] | |
| ) > _candidate_score(best["auth_data"], best["bundle"], best["path"].name, best["main"]): | |
| best = candidate | |
| return best | |
| def _cleanup_local_duplicates(accounts=None): | |
| """清理本地同账号重复认证文件,只保留一个规范文件。""" | |
| grouped = {} | |
| for path in AUTH_DIR.glob("codex-*.json"): | |
| if not path.is_file(): | |
| continue | |
| try: | |
| auth_data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: | |
| continue | |
| if auth_data.get("type") != "codex": | |
| continue | |
| main = path.name.startswith("codex-main-") | |
| bundle = _bundle_from_auth_data(auth_data, fallback_name=path.name) | |
| key = _auth_identity(bundle, main=main) | |
| grouped.setdefault(key, []).append( | |
| { | |
| "path": path, | |
| "auth_data": auth_data, | |
| "bundle": bundle, | |
| "main": main, | |
| } | |
| ) | |
| canonical_map = {} | |
| removed = 0 | |
| for items in grouped.values(): | |
| if not items: | |
| continue | |
| winner = max( | |
| items, key=lambda item: _candidate_score(item["auth_data"], item["bundle"], item["path"].name, item["main"]) | |
| ) | |
| canonical_path = Path(_save_normalized_auth_file(winner["bundle"], main=winner["main"])) | |
| canonical_map[_auth_identity(winner["bundle"], main=winner["main"])] = canonical_path | |
| for item in items: | |
| if item["path"] != canonical_path and item["path"].exists(): | |
| item["path"].unlink() | |
| removed += 1 | |
| if accounts is not None: | |
| changed = False | |
| for acc in accounts: | |
| auth_path = acc.get("auth_file") | |
| if not auth_path: | |
| continue | |
| try: | |
| path = Path(auth_path) | |
| if not path.exists(): | |
| continue | |
| auth_data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: | |
| continue | |
| bundle = _bundle_from_auth_data(auth_data, fallback_name=path.name) | |
| canonical_path = canonical_map.get(_auth_identity(bundle, main=False)) | |
| if canonical_path and acc.get("auth_file") != str(canonical_path.resolve()): | |
| acc["auth_file"] = str(canonical_path.resolve()) | |
| changed = True | |
| return removed, changed | |
| return removed, False | |
| def sync_from_cpa(): | |
| """ | |
| 从 CPA 反向同步认证文件到本地。 | |
| 规则: | |
| - 下载 CPA 中所有 codex 认证文件到本地 auths/ | |
| - 非主号文件会导入/修复到 accounts.json,默认状态为 standby(保守导入) | |
| - 不删除本地账号记录,仅补充/更新 auth_file | |
| """ | |
| from autoteam.accounts import ( | |
| STATUS_STANDBY, | |
| add_account, | |
| find_account, | |
| load_accounts, | |
| save_accounts, | |
| update_account, | |
| ) | |
| from autoteam.mail import infer_mail_provider_from_email | |
| AUTH_DIR.mkdir(exist_ok=True) | |
| accounts = load_accounts() | |
| changed_accounts = False | |
| imported_files = 0 | |
| updated_files = 0 | |
| added_accounts = 0 | |
| updated_accounts = 0 | |
| skipped = 0 | |
| cpa_duplicates_deleted = 0 | |
| local_kept_newer = 0 | |
| local_duplicates_deleted, accounts_path_repaired = _cleanup_local_duplicates(accounts) | |
| if accounts_path_repaired: | |
| save_accounts(accounts) | |
| cpa_files = list_cpa_files() | |
| if not cpa_files: | |
| logger.info("[CPA] 未发现可反向同步的认证文件") | |
| return { | |
| "downloaded": 0, | |
| "updated": 0, | |
| "accounts_added": 0, | |
| "accounts_updated": 0, | |
| "skipped": 0, | |
| "cpa_duplicates_deleted": 0, | |
| "local_duplicates_deleted": local_duplicates_deleted, | |
| "local_kept_newer": 0, | |
| "total": 0, | |
| } | |
| candidates = [] | |
| for item in cpa_files: | |
| name = (item.get("name") or "").strip() | |
| if not name or not name.endswith(".json") or not name.startswith("codex-"): | |
| skipped += 1 | |
| continue | |
| content = download_from_cpa(name) | |
| if not content: | |
| skipped += 1 | |
| continue | |
| try: | |
| auth_data = json.loads(content) | |
| except Exception: | |
| logger.warning("[CPA] 跳过无效 JSON: %s", name) | |
| skipped += 1 | |
| continue | |
| if auth_data.get("type") != "codex": | |
| logger.info("[CPA] 跳过非 codex 文件: %s", name) | |
| skipped += 1 | |
| continue | |
| bundle = _bundle_from_auth_data(auth_data, fallback_name=name) | |
| email = (bundle.get("email") or item.get("email") or "").lower().strip() | |
| bundle["email"] = email | |
| if not email and not name.startswith("codex-main-"): | |
| logger.info("[CPA] 跳过缺少邮箱的文件: %s", name) | |
| continue | |
| candidates.append( | |
| { | |
| "name": name, | |
| "auth_data": auth_data, | |
| "bundle": bundle, | |
| "main": name.startswith("codex-main-"), | |
| } | |
| ) | |
| grouped = {} | |
| for item in candidates: | |
| grouped.setdefault(_auth_identity(item["bundle"], main=item["main"]), []).append(item) | |
| for items in grouped.values(): | |
| winner = max( | |
| items, | |
| key=lambda item: _candidate_score(item["auth_data"], item["bundle"], item["name"], main=item["main"]), | |
| ) | |
| for item in items: | |
| if item is winner: | |
| continue | |
| if delete_from_cpa(item["name"]): | |
| cpa_duplicates_deleted += 1 | |
| name = winner["name"] | |
| bundle = winner["bundle"] | |
| email = bundle.get("email", "") | |
| identity_key = _auth_identity(bundle, main=winner["main"]) | |
| local_best = _load_local_best_candidate(identity_key) | |
| cpa_score = _candidate_score(winner["auth_data"], bundle, name, main=winner["main"]) | |
| local_score = None | |
| if local_best: | |
| local_score = _candidate_score( | |
| local_best["auth_data"], local_best["bundle"], local_best["path"].name, main=local_best["main"] | |
| ) | |
| if winner["main"]: | |
| if local_best and local_score >= cpa_score: | |
| local_kept_newer += 1 | |
| normalized_path = local_best["path"] | |
| else: | |
| normalized_path = _normalized_auth_path(bundle, main=True) | |
| existed = normalized_path.exists() | |
| previous = None | |
| if existed: | |
| try: | |
| previous = normalized_path.read_text(encoding="utf-8") | |
| except Exception: | |
| previous = None | |
| normalized_path = Path(_save_normalized_auth_file(bundle, main=True)) | |
| current = normalized_path.read_text(encoding="utf-8") | |
| if not existed: | |
| imported_files += 1 | |
| elif previous != current: | |
| updated_files += 1 | |
| if normalized_path.name != name: | |
| old_path = AUTH_DIR / name | |
| if old_path.exists() and old_path != normalized_path: | |
| old_path.unlink() | |
| continue | |
| if local_best and local_score >= cpa_score: | |
| local_kept_newer += 1 | |
| normalized_path = local_best["path"] | |
| else: | |
| normalized_path = _normalized_auth_path(bundle) | |
| existed = normalized_path.exists() | |
| previous = None | |
| if existed: | |
| try: | |
| previous = normalized_path.read_text(encoding="utf-8") | |
| except Exception: | |
| previous = None | |
| normalized_path = Path(_save_normalized_auth_file(bundle)) | |
| current = normalized_path.read_text(encoding="utf-8") | |
| if not existed: | |
| imported_files += 1 | |
| elif previous != current: | |
| updated_files += 1 | |
| acc = find_account(accounts, email) | |
| resolved_path = str(normalized_path.resolve()) | |
| inferred_provider = infer_mail_provider_from_email(email) | |
| if acc: | |
| acc_changed = False | |
| if acc.get("auth_file") != resolved_path: | |
| acc["auth_file"] = resolved_path | |
| acc_changed = True | |
| if inferred_provider and not acc.get("mail_provider"): | |
| acc["mail_provider"] = inferred_provider | |
| acc_changed = True | |
| if acc_changed: | |
| changed_accounts = True | |
| updated_accounts += 1 | |
| else: | |
| # Round 12 wire-up (M2) — 改用 add_account API 触发 None→PENDING transition, | |
| # 紧接着 update_account → STANDBY 一次性带上 auth_file. 这样状态机能落每条 | |
| # state_log.jsonl + F2 SSE 推送, 不再静默直 append 旁路. | |
| try: | |
| add_account(email, "", cloudmail_account_id=None, mail_provider=inferred_provider or None) | |
| update_account( | |
| email, | |
| status=STATUS_STANDBY, | |
| auth_file=resolved_path, | |
| quota_exhausted_at=None, | |
| quota_resets_at=None, | |
| _reason="cpa_sync:import_unknown", | |
| ) | |
| accounts = load_accounts() # 刷新 in-memory snapshot 给后续 _cleanup_local_duplicates 用 | |
| except Exception as exc: | |
| logger.warning( | |
| "[CPA] 反向同步 add_account/transition 抛异常,回退直 append: %s (%s)", | |
| email, exc, | |
| ) | |
| accounts.append( | |
| { | |
| "email": email, | |
| "password": "", | |
| "cloudmail_account_id": None, | |
| "mail_provider": inferred_provider or "", | |
| "status": STATUS_STANDBY, | |
| "auth_file": resolved_path, | |
| "quota_exhausted_at": None, | |
| "quota_resets_at": None, | |
| "created_at": time.time(), | |
| "last_active_at": None, | |
| } | |
| ) | |
| changed_accounts = True | |
| added_accounts += 1 | |
| if changed_accounts: | |
| save_accounts(accounts) | |
| local_duplicates_deleted_after, accounts_path_repaired = _cleanup_local_duplicates(accounts) | |
| local_duplicates_deleted += local_duplicates_deleted_after | |
| if accounts_path_repaired: | |
| save_accounts(accounts) | |
| logger.info( | |
| "[CPA] 反向同步完成: 新增文件 %d, 更新文件 %d, 新增账号 %d, 更新账号 %d, 保留本地较新 %d, CPA去重 %d, 本地去重 %d, 跳过 %d", | |
| imported_files, | |
| updated_files, | |
| added_accounts, | |
| updated_accounts, | |
| local_kept_newer, | |
| cpa_duplicates_deleted, | |
| local_duplicates_deleted, | |
| skipped, | |
| ) | |
| return { | |
| "downloaded": imported_files, | |
| "updated": updated_files, | |
| "accounts_added": added_accounts, | |
| "accounts_updated": updated_accounts, | |
| "skipped": skipped, | |
| "local_kept_newer": local_kept_newer, | |
| "cpa_duplicates_deleted": cpa_duplicates_deleted, | |
| "local_duplicates_deleted": local_duplicates_deleted, | |
| "total": len(cpa_files), | |
| } | |
| def sync_to_cpa(): | |
| """ | |
| 同步本地认证文件到 CPA。同步范围:STATUS_ACTIVE(Team 席位)+ STATUS_PERSONAL(免费号)。 | |
| - active / personal 有 auth_file → 上传(覆盖) | |
| - CPA 有但本地账号状态已不在上述两种(standby / exhausted / pending 等)→ 从 CPA 删除 | |
| - 仅清理本地 accounts.json 管理过的邮箱,主号和 CPA 手动上传文件不会被删 | |
| """ | |
| from autoteam.accounts import STATUS_ACTIVE, STATUS_PERSONAL, is_account_disabled, load_accounts, save_accounts | |
| accounts = load_accounts() | |
| local_emails = { | |
| str(a.get("email") or "").strip().lower() | |
| for a in accounts | |
| if str(a.get("email") or "").strip() | |
| } | |
| local_duplicates_deleted, accounts_path_repaired = _cleanup_local_duplicates(accounts) | |
| if accounts_path_repaired: | |
| save_accounts(accounts) | |
| # 修复断裂的 auth_file 路径 | |
| changed = False | |
| for acc in accounts: | |
| auth_path = acc.get("auth_file") | |
| if auth_path and not Path(auth_path).exists(): | |
| matches = list(AUTH_DIR.glob(f"codex-{acc['email']}-*.json")) | |
| if matches: | |
| acc["auth_file"] = str(matches[0].resolve()) | |
| changed = True | |
| if changed: | |
| save_accounts(accounts) | |
| # 需要同步到 CPA 的账号:active(Team 席位)和 personal(免费号)都要覆盖 | |
| # 两种状态在 CPA 端共存但相互隔离:文件名不同、email 域可能不同,Team/Personal 互不干扰 | |
| files_to_sync = {} | |
| synced_active = 0 | |
| synced_personal = 0 | |
| disabled_skipped = 0 | |
| active_publish_skipped = 0 | |
| active_publish_kept_remote = 0 | |
| active_publish_delete_remote = 0 | |
| for acc in accounts: | |
| if is_account_disabled(acc): | |
| disabled_skipped += 1 | |
| continue | |
| status = acc.get("status") | |
| if status not in (STATUS_ACTIVE, STATUS_PERSONAL): | |
| continue | |
| auth_path = acc.get("auth_file") | |
| if not auth_path: | |
| continue | |
| path = Path(auth_path) | |
| if not path.exists(): | |
| continue | |
| if status == STATUS_ACTIVE: | |
| decision = _active_auth_publish_decision(acc, path) | |
| if decision == "keep_remote": | |
| active_publish_kept_remote += 1 | |
| continue | |
| if decision == "delete_remote": | |
| active_publish_delete_remote += 1 | |
| continue | |
| if decision != "publish": | |
| active_publish_skipped += 1 | |
| continue | |
| _refresh_account_proxy_url_for_upload(acc, path) | |
| files_to_sync[path.name] = path | |
| if status == STATUS_ACTIVE: | |
| synced_active += 1 | |
| else: | |
| synced_personal += 1 | |
| # CPA 认证文件 | |
| cpa_files = list_cpa_files() | |
| cpa_names = {f["name"]: f for f in cpa_files if f.get("name")} | |
| min_active_for_remote_delete = max(1, int(AUTO_CHECK_TARGET_SEATS) - 1) | |
| allow_remote_delete = synced_active >= min_active_for_remote_delete | |
| try: | |
| from autoteam.manager import _is_protected_local_credential_seat | |
| except Exception: | |
| _is_protected_local_credential_seat = None | |
| protected_remote_names = set() | |
| protected_remote_emails = set() | |
| if _is_protected_local_credential_seat is not None: | |
| for acc in accounts: | |
| if is_account_disabled(acc): | |
| continue | |
| email = str(acc.get("email") or "").strip().lower() | |
| auth_path_value = acc.get("auth_file") | |
| if not email: | |
| continue | |
| try: | |
| if _is_protected_local_credential_seat(acc): | |
| protected_remote_emails.add(email) | |
| if auth_path_value: | |
| protected_remote_names.add(Path(auth_path_value).name) | |
| except Exception as exc: | |
| logger.warning("[CPA] 判断受保护凭证失败,保留远端副本: %s (%s)", email, exc) | |
| protected_remote_emails.add(email) | |
| if auth_path_value: | |
| protected_remote_names.add(Path(auth_path_value).name) | |
| logger.info( | |
| "[CPA] 待同步认证文件: %d (Team=%d, Personal=%d), CPA 现有: %d", | |
| len(files_to_sync), | |
| synced_active, | |
| synced_personal, | |
| len(cpa_files), | |
| ) | |
| if not allow_remote_delete: | |
| logger.warning( | |
| "[CPA] active 凭证不足,跳过本轮远端删除: %d/%d", | |
| synced_active, | |
| min_active_for_remote_delete, | |
| ) | |
| # 上传:所有 active + personal 认证文件(覆盖同名文件,确保 token 最新) | |
| uploaded = 0 | |
| for name, path in files_to_sync.items(): | |
| logger.info("[CPA] 上传: %s", name) | |
| if upload_to_cpa(path): | |
| uploaded += 1 | |
| deleted = 0 | |
| skipped_remote_delete = 0 | |
| skipped_protected = 0 | |
| for name, cpa_file in cpa_names.items(): | |
| email = cpa_file.get("email", "").lower() | |
| if email in local_emails and name not in files_to_sync: | |
| if name in protected_remote_names or email in protected_remote_emails: | |
| logger.info("[CPA] 保留受保护本地凭证远端副本: %s (%s)", name, email) | |
| skipped_protected += 1 | |
| continue | |
| if not allow_remote_delete: | |
| skipped_remote_delete += 1 | |
| continue | |
| logger.info("[CPA] 删除非 active/personal 文件: %s (%s)", name, email) | |
| if delete_from_cpa(name): | |
| deleted += 1 | |
| if skipped_protected: | |
| logger.info("[CPA] 守卫保留 %d 个 CPA 文件(本地仍持有,避免误删 token)", skipped_protected) | |
| if skipped_remote_delete: | |
| logger.warning("[CPA] 本轮因 active 凭证不足保留远端非 active 文件: %d", skipped_remote_delete) | |
| if disabled_skipped: | |
| logger.info("[CPA] 跳过 %d 个本地禁用账号", disabled_skipped) | |
| logger.info("[CPA] 同步完成: 上传 %d, 删除 %d, 本地去重 %d", uploaded, deleted, local_duplicates_deleted) | |
| # 最终状态 | |
| final_cpa = list_cpa_files() | |
| final_local_managed = [f for f in final_cpa if f.get("email", "").lower() in local_emails] | |
| logger.info( | |
| "[CPA] CPA 中本地管理: %d, 本地待同步 (Team+Personal): %d", | |
| len(final_local_managed), | |
| len(files_to_sync), | |
| ) | |
| return { | |
| "ok": True, | |
| "uploaded": uploaded, | |
| "deleted": deleted, | |
| "remote_count_before": len(cpa_files), | |
| "remote_managed_after": len(final_local_managed), | |
| "synced_active": synced_active, | |
| "synced_personal": synced_personal, | |
| "disabled_skipped": disabled_skipped, | |
| "active_publish": { | |
| "skipped_unknown": active_publish_skipped, | |
| "kept_remote": active_publish_kept_remote, | |
| "delete_remote": active_publish_delete_remote, | |
| }, | |
| "local_duplicates_deleted": local_duplicates_deleted, | |
| "delete_guard": { | |
| "allow_remote_delete": allow_remote_delete, | |
| "min_active_for_remote_delete": min_active_for_remote_delete, | |
| "skipped_remote_delete": skipped_remote_delete, | |
| "skipped_protected": skipped_protected, | |
| "skipped_protected_delete": skipped_protected, | |
| }, | |
| } | |
| def sync_main_codex_to_cpa(filepath): | |
| """同步主号 Codex 认证文件到 CPA。""" | |
| filepath = Path(filepath) | |
| if not filepath.exists(): | |
| raise FileNotFoundError(f"主号认证文件不存在: {filepath}") | |
| name = filepath.name | |
| existing = {item.get("name"): item for item in list_cpa_files()} | |
| for old_name in existing: | |
| if old_name and old_name.startswith("codex-main-"): | |
| logger.info("[CPA] 删除旧主号文件: %s", old_name) | |
| delete_from_cpa(old_name) | |
| if not upload_to_cpa(filepath): | |
| raise RuntimeError(f"上传主号认证文件失败: {name}") | |
| # Round 12 S7 — 主号同步成功后,记录当前 active workspace,便于上游观测 | |
| try: | |
| active = _get_active_workspace_summary() | |
| if active: | |
| logger.info( | |
| "[CPA] 主号 Codex 已同步 (active workspace: id=%s admin=%s account_id=%s)", | |
| active.get("id"), active.get("admin_email"), active.get("account_id"), | |
| ) | |
| else: | |
| logger.info("[CPA] 主号 Codex 已同步: %s", name) | |
| except Exception: | |
| logger.info("[CPA] 主号 Codex 已同步: %s", name) | |
| return {"uploaded": name} | |
| def _get_active_workspace_summary(): | |
| """Round 12 S7 — return the current active workspace (id/admin/account_id) or None. | |
| Best-effort: never raises; pool unavailable returns None. | |
| """ | |
| try: | |
| from autoteam.workspace_pool import default_pool | |
| active = default_pool.get_active() | |
| if not active: | |
| return None | |
| return { | |
| "id": active.get("id"), | |
| "admin_email": active.get("admin_email"), | |
| "account_id": active.get("account_id"), | |
| } | |
| except Exception: | |
| return None | |
| def get_active_sync_target(): | |
| """Round 12 S7 — public helper: which workspace identity should CPA sync to? | |
| Returns dict({id, admin_email, account_id}) or None when single-workspace | |
| mode (caller falls back to legacy admin_state). | |
| """ | |
| return _get_active_workspace_summary() | |