fix: harden residual flow boundaries and pending recovery
This commit is contained in:
@@ -1,623 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import ast
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def replace_once(text, old, new, label):
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"{label}: expected one match, got {count}")
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def replace_between(text, start, end, replacement, label):
|
||||
i = text.find(start)
|
||||
if i < 0:
|
||||
raise RuntimeError(f"{label}: start not found")
|
||||
j = text.find(end, i)
|
||||
if j < 0:
|
||||
raise RuntimeError(f"{label}: end not found")
|
||||
return text[:i] + replacement + text[j:]
|
||||
|
||||
|
||||
# 1) Harden the shared orchestration boundaries without changing registration steps.
|
||||
flow_path = ROOT / "registration_flow.py"
|
||||
flow = flow_path.read_text(encoding="utf-8")
|
||||
new_persist = r'''def persist_account_result(result, callbacks, ops):
|
||||
try:
|
||||
ops.persist_account_line(result.email, result.password, result.sso)
|
||||
saved = True
|
||||
save_error = ""
|
||||
pending_saved = False
|
||||
except Exception as exc:
|
||||
saved = False
|
||||
save_error = str(exc)
|
||||
try:
|
||||
pending_saved = bool(
|
||||
ops.queue_unsaved_result(
|
||||
{
|
||||
"email": result.email,
|
||||
"password": result.password,
|
||||
"sso": result.sso,
|
||||
"profile": result.profile,
|
||||
},
|
||||
save_error,
|
||||
)
|
||||
)
|
||||
except Exception as pending_exc:
|
||||
pending_saved = False
|
||||
callbacks.log(f"[!] pending 队列写入异常: {pending_exc}")
|
||||
callbacks.log(f"[!] 账号已注册但主结果文件保存失败: {save_error}")
|
||||
if pending_saved:
|
||||
callbacks.log("[!] 未保存账号已写入 pending 队列,等待人工重试")
|
||||
else:
|
||||
callbacks.log("[!] pending 队列也写入失败,请立即复制当前账号信息")
|
||||
|
||||
try:
|
||||
pools = ops.add_tokens(result.sso, result.email)
|
||||
if not isinstance(pools, dict):
|
||||
raise TypeError("token pool result must be a dict")
|
||||
except Exception as exc:
|
||||
callbacks.log(f"[!] token 入池后处理异常,账号结果已保留: {exc}")
|
||||
pools = {
|
||||
"internal": {
|
||||
"enabled": True,
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
}
|
||||
for name, state in pools.items():
|
||||
if isinstance(state, dict) and state.get("enabled") and not state.get("ok"):
|
||||
callbacks.log(f"[!] grok2api {name} 入池失败: {state.get('error')}")
|
||||
|
||||
try:
|
||||
cpa = ops.export_cpa(result.email, result.password, result.sso)
|
||||
if not isinstance(cpa, dict):
|
||||
raise TypeError("CPA result must be a dict")
|
||||
except Exception as exc:
|
||||
callbacks.log(f"[!] CPA 导出后处理异常,账号结果已保留: {exc}")
|
||||
cpa = {"ok": False, "skipped": False, "error": str(exc)}
|
||||
|
||||
return OutputResult(
|
||||
registered=True,
|
||||
saved=saved,
|
||||
pending_saved=pending_saved,
|
||||
save_error=save_error,
|
||||
pools=pools,
|
||||
cpa=cpa,
|
||||
)
|
||||
|
||||
|
||||
'''
|
||||
flow = replace_between(flow, "def persist_account_result(", "def _notify_observer", new_persist, "persist_account_result")
|
||||
new_tail = r'''def _notify_observer(observer, result, account, output, callbacks):
|
||||
try:
|
||||
observer(result, account, output)
|
||||
except Exception as exc:
|
||||
callbacks.log(f"[Debug] observer 执行失败: {exc}")
|
||||
|
||||
|
||||
def _run_cleanup_safely(ops, callbacks, reason):
|
||||
try:
|
||||
ops.cleanup(reason)
|
||||
return True
|
||||
except Exception as exc:
|
||||
callbacks.log(f"[!] 清理失败,已忽略且不影响账号统计: {reason}: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def _prepare_next_account(result, settings, callbacks, ops):
|
||||
if result.processed_count >= settings.count:
|
||||
return False
|
||||
if callbacks.cancelled():
|
||||
result.cancelled = True
|
||||
return False
|
||||
try:
|
||||
if ops.browser_missing():
|
||||
ops.start_browser()
|
||||
else:
|
||||
ops.restart_browser()
|
||||
ops.sleep(1)
|
||||
return True
|
||||
except ops.cancelled_exception:
|
||||
result.cancelled = True
|
||||
callbacks.log("[!] 已在账号间准备阶段停止")
|
||||
return False
|
||||
|
||||
|
||||
def run_batch(count, callbacks, observer, ops, enable_nsfw=True, cleanup_interval=5,
|
||||
max_slot_retry=3, max_mail_retry=3, settings=None):
|
||||
if settings is None:
|
||||
settings = RegistrationSettings(
|
||||
count=int(count),
|
||||
enable_nsfw=bool(enable_nsfw),
|
||||
cleanup_interval=int(cleanup_interval),
|
||||
max_slot_retry=int(max_slot_retry),
|
||||
max_mail_retry=int(max_mail_retry),
|
||||
)
|
||||
result = BatchResult()
|
||||
retry_count_for_slot = 0
|
||||
last_cleanup_success_count = 0
|
||||
try:
|
||||
ops.start_browser()
|
||||
callbacks.log("[*] 浏览器已启动")
|
||||
while result.processed_count < settings.count:
|
||||
if callbacks.cancelled():
|
||||
result.cancelled = True
|
||||
break
|
||||
callbacks.log(f"--- 开始第 {result.processed_count + 1}/{settings.count} 个账号 ---")
|
||||
account = None
|
||||
output = None
|
||||
continue_batch = True
|
||||
try:
|
||||
account = register_one_account(
|
||||
callbacks,
|
||||
ops,
|
||||
enable_nsfw=settings.enable_nsfw,
|
||||
max_mail_retry=settings.max_mail_retry,
|
||||
)
|
||||
output = persist_account_result(account, callbacks, ops)
|
||||
result.results.append({"registration": account, "output": output})
|
||||
retry_count_for_slot = 0
|
||||
result.processed_count += 1
|
||||
if output.saved:
|
||||
result.success_count += 1
|
||||
callbacks.log(f"[+] 注册并保存成功: {account.email}")
|
||||
if (
|
||||
settings.cleanup_interval > 0
|
||||
and result.success_count % settings.cleanup_interval == 0
|
||||
and result.success_count != last_cleanup_success_count
|
||||
and result.processed_count < settings.count
|
||||
):
|
||||
_run_cleanup_safely(
|
||||
ops,
|
||||
callbacks,
|
||||
f"已成功 {result.success_count} 个账号,执行定期清理",
|
||||
)
|
||||
last_cleanup_success_count = result.success_count
|
||||
else:
|
||||
result.fail_count += 1
|
||||
result.registered_unsaved_count += 1
|
||||
callbacks.log(f"[-] 注册成功但持久化未完成: {account.email}")
|
||||
pool_warning = any(
|
||||
isinstance(state, dict) and state.get("enabled") and not state.get("ok")
|
||||
for state in output.pools.values()
|
||||
)
|
||||
cpa_warning = bool(output.cpa and not output.cpa.get("ok") and not output.cpa.get("skipped"))
|
||||
if pool_warning or cpa_warning:
|
||||
result.postprocess_warning_count += 1
|
||||
except ops.cancelled_exception:
|
||||
result.cancelled = True
|
||||
callbacks.log("[!] 注册被停止")
|
||||
continue_batch = False
|
||||
except ops.retry_exception as exc:
|
||||
retry_count_for_slot += 1
|
||||
if retry_count_for_slot <= settings.max_slot_retry:
|
||||
callbacks.log(
|
||||
f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{settings.max_slot_retry} 次: {exc}"
|
||||
)
|
||||
else:
|
||||
result.fail_count += 1
|
||||
result.processed_count += 1
|
||||
retry_count_for_slot = 0
|
||||
callbacks.log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}")
|
||||
except Exception as exc:
|
||||
result.fail_count += 1
|
||||
result.processed_count += 1
|
||||
retry_count_for_slot = 0
|
||||
callbacks.log(f"[-] 注册失败: {exc}")
|
||||
finally:
|
||||
_notify_observer(observer, result, account, output, callbacks)
|
||||
|
||||
if not continue_batch or result.cancelled:
|
||||
break
|
||||
if not _prepare_next_account(result, settings, callbacks, ops):
|
||||
break
|
||||
finally:
|
||||
_run_cleanup_safely(ops, callbacks, "任务结束")
|
||||
return result
|
||||
'''
|
||||
flow = replace_between(flow, "def _notify_observer", " return result\n", new_tail, "flow tail")
|
||||
# Remove a possible duplicate return left by the marker replacement.
|
||||
flow = flow.replace("\n return result\n return result\n", "\n return result\n")
|
||||
ast.parse(flow)
|
||||
flow_path.write_text(flow, encoding="utf-8")
|
||||
|
||||
|
||||
# 2) Move account file and pending recovery operations to a focused module.
|
||||
outputs = r'''"""Account result persistence and pending recovery helpers."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
|
||||
def append_account_line(path, email, password, sso):
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"{email}----{password}----{sso}\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
def save_mail_credential(base_dir, email, credential):
|
||||
path = os.path.join(base_dir, "mail_credentials.txt")
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"{email}\t{credential}\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return True
|
||||
|
||||
|
||||
def queue_unsaved_account(path, payload, error):
|
||||
pending_path = path + ".pending.jsonl"
|
||||
record = dict(payload)
|
||||
record["save_error"] = str(error)
|
||||
record["queued_at"] = datetime.now(timezone.utc).isoformat()
|
||||
with open(pending_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
os.chmod(pending_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _existing_account_keys(target_path):
|
||||
keys = set()
|
||||
if not os.path.isfile(target_path):
|
||||
return keys
|
||||
with open(target_path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
for raw_line in handle:
|
||||
parts = raw_line.rstrip("\n").split("----", 2)
|
||||
if len(parts) == 3:
|
||||
keys.add((parts[0].strip(), parts[2].strip()))
|
||||
return keys
|
||||
|
||||
|
||||
def retry_pending_file(pending_path, output_path=None, log_callback=None):
|
||||
logger = log_callback or (lambda message: None)
|
||||
pending_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(pending_path))))
|
||||
if not os.path.isfile(pending_path):
|
||||
raise FileNotFoundError(f"pending 文件不存在: {pending_path}")
|
||||
suffix = ".pending.jsonl"
|
||||
if output_path:
|
||||
target_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(output_path))))
|
||||
elif pending_path.endswith(suffix):
|
||||
target_path = os.path.realpath(pending_path[:-len(suffix)])
|
||||
else:
|
||||
target_path = os.path.realpath(pending_path + ".recovered.txt")
|
||||
if os.path.normcase(pending_path) == os.path.normcase(target_path):
|
||||
raise ValueError("pending 输入文件与输出文件不能是同一个文件")
|
||||
|
||||
lock_path = pending_path + ".lock"
|
||||
with FileLock(lock_path, timeout=30):
|
||||
if not os.path.isfile(pending_path):
|
||||
return {"restored": 0, "remaining": 0, "output_path": target_path}
|
||||
with open(pending_path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.readlines()
|
||||
existing = _existing_account_keys(target_path)
|
||||
unresolved = []
|
||||
restored = 0
|
||||
for line_number, raw_line in enumerate(lines, 1):
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(stripped)
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError("record must be a JSON object")
|
||||
email = str(record.get("email") or "").strip()
|
||||
password = str(record.get("password") or "")
|
||||
sso = str(record.get("sso") or "").strip()
|
||||
if not email or not sso:
|
||||
raise ValueError("record missing email or sso")
|
||||
key = (email, sso)
|
||||
if key not in existing:
|
||||
append_account_line(target_path, email, password, sso)
|
||||
existing.add(key)
|
||||
restored += 1
|
||||
logger(f"[+] 已恢复 pending 账号: {email}")
|
||||
except Exception as exc:
|
||||
unresolved.append(raw_line if raw_line.endswith("\n") else raw_line + "\n")
|
||||
logger(f"[!] pending 第 {line_number} 行恢复失败: {exc}")
|
||||
|
||||
directory = os.path.dirname(pending_path) or "."
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".pending-retry-", suffix=".jsonl.tmp", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.writelines(unresolved)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if unresolved:
|
||||
os.replace(temp_path, pending_path)
|
||||
temp_path = None
|
||||
try:
|
||||
os.chmod(pending_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
os.unlink(temp_path)
|
||||
temp_path = None
|
||||
try:
|
||||
os.unlink(pending_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
return {"restored": restored, "remaining": len(unresolved), "output_path": target_path}
|
||||
'''
|
||||
outputs_path = ROOT / "account_outputs.py"
|
||||
ast.parse(outputs)
|
||||
outputs_path.write_text(outputs, encoding="utf-8")
|
||||
|
||||
|
||||
# 3) Split structural and runtime config validation; keep existing public validate_config.
|
||||
main_path = ROOT / "grok_register_ttk.py"
|
||||
main = main_path.read_text(encoding="utf-8")
|
||||
main = replace_once(main, "def validate_config(raw):", "def validate_config_structure(raw):", "rename structural validation")
|
||||
runtime_block = r'''def validate_run_requirements(cfg):
|
||||
cfg = validate_config_structure(cfg)
|
||||
provider = cfg["email_provider"]
|
||||
if provider == "cloudflare" and not cfg["cloudflare_api_base"]:
|
||||
raise ConfigError("Cloudflare 模式需要配置 cloudflare_api_base")
|
||||
if provider == "cloudmail":
|
||||
missing = [
|
||||
key for key in ("cloudmail_api_base", "cloudmail_public_token", "cloudmail_domains")
|
||||
if not cfg[key]
|
||||
]
|
||||
if missing:
|
||||
raise ConfigError("Cloud Mail 模式缺少必需配置: " + ", ".join(missing))
|
||||
if provider == "yyds" and not (cfg["yyds_api_key"] or cfg["yyds_jwt"]):
|
||||
raise ConfigError("YYDS 模式需要至少配置 yyds_api_key 或 yyds_jwt")
|
||||
if cfg["grok2api_auto_add_remote"]:
|
||||
missing = [
|
||||
key for key in ("grok2api_remote_base", "grok2api_remote_app_key")
|
||||
if not cfg[key]
|
||||
]
|
||||
if missing:
|
||||
raise ConfigError("远端 token 入池缺少必需配置: " + ", ".join(missing))
|
||||
if cfg["cpa_copy_to_hotload"] and not cfg["cpa_hotload_dir"]:
|
||||
raise ConfigError("启用 CPA 热加载复制时必须配置 cpa_hotload_dir")
|
||||
return cfg
|
||||
|
||||
|
||||
def validate_config(raw):
|
||||
"""Backward-compatible full validation used before a run or save."""
|
||||
return validate_run_requirements(raw)
|
||||
|
||||
|
||||
'''
|
||||
# Remove runtime dependency checks from structural validator.
|
||||
old_runtime = ''' provider = cfg["email_provider"]
|
||||
if provider == "cloudflare" and not cfg["cloudflare_api_base"]:
|
||||
raise ConfigError("Cloudflare 模式需要配置 cloudflare_api_base")
|
||||
if provider == "cloudmail":
|
||||
missing = [
|
||||
key for key in ("cloudmail_api_base", "cloudmail_public_token", "cloudmail_domains")
|
||||
if not cfg[key]
|
||||
]
|
||||
if missing:
|
||||
raise ConfigError("Cloud Mail 模式缺少必需配置: " + ", ".join(missing))
|
||||
if cfg["grok2api_auto_add_remote"]:
|
||||
missing = [
|
||||
key for key in ("grok2api_remote_base", "grok2api_remote_app_key")
|
||||
if not cfg[key]
|
||||
]
|
||||
if missing:
|
||||
raise ConfigError("远端 token 入池缺少必需配置: " + ", ".join(missing))
|
||||
if cfg["cpa_copy_to_hotload"] and not cfg["cpa_hotload_dir"]:
|
||||
raise ConfigError("启用 CPA 热加载复制时必须配置 cpa_hotload_dir")
|
||||
|
||||
'''
|
||||
main = replace_once(main, old_runtime, "", "remove runtime checks from structure")
|
||||
main = replace_once(main, " return cfg\n\n\ndef load_config():", " return cfg\n\n\n" + runtime_block + "def load_config():", "insert runtime validator")
|
||||
main = main.replace("config = validate_config(loaded)", "config = validate_config_structure(loaded)", 1)
|
||||
main = main.replace("config = validate_config(DEFAULT_CONFIG.copy())", "config = validate_config_structure(DEFAULT_CONFIG.copy())", 1)
|
||||
# Save permits incomplete editable GUI config but still enforces structure.
|
||||
main = main.replace("config = validate_config(config)\n config_dir", "config = validate_config_structure(config)\n config_dir", 1)
|
||||
|
||||
# Replace local output implementations with compatibility wrappers.
|
||||
start = main.find("def _save_mail_credential(")
|
||||
end = main.find("def run_registration_common(", start)
|
||||
if start < 0 or end < 0:
|
||||
raise RuntimeError("account output helper block not found")
|
||||
wrappers = r'''def _save_mail_credential(email, credential, log_callback=None):
|
||||
from account_outputs import save_mail_credential
|
||||
try:
|
||||
return save_mail_credential(os.path.dirname(__file__), email, credential)
|
||||
except Exception as exc:
|
||||
log_exception("保存邮箱凭据失败", exc, log_callback)
|
||||
return False
|
||||
|
||||
|
||||
def _append_account_line(path, email, password, sso):
|
||||
from account_outputs import append_account_line
|
||||
return append_account_line(path, email, password, sso)
|
||||
|
||||
|
||||
def _queue_unsaved_account(path, payload, error, log_callback=None):
|
||||
from account_outputs import queue_unsaved_account
|
||||
try:
|
||||
return queue_unsaved_account(path, payload, error)
|
||||
except Exception as exc:
|
||||
log_exception("写入账号 pending 队列失败", exc, log_callback)
|
||||
return False
|
||||
|
||||
|
||||
def retry_pending_file(pending_path, output_path=None, log_callback=None):
|
||||
from account_outputs import retry_pending_file as _retry_pending_file
|
||||
return _retry_pending_file(pending_path, output_path=output_path, log_callback=log_callback)
|
||||
|
||||
|
||||
'''
|
||||
main = main[:start] + wrappers + main[end:]
|
||||
|
||||
# GUI stats include all batch states.
|
||||
main = main.replace('self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0")', 'self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0 | 待恢复: 0 | 后处理警告: 0")', 1)
|
||||
main = main.replace('self.stats_var.set(f"成功: {event[1]} | 失败: {event[2]}")', 'self.stats_var.set(f"成功: {event[1]} | 失败: {event[2]} | 待恢复: {event[3]} | 后处理警告: {event[4]}")', 1)
|
||||
main = main.replace('self.ui_queue.put(("stats", self.success_count, self.fail_count))', 'self.ui_queue.put(("stats", self.success_count, self.fail_count, self.registered_unsaved_count, self.postprocess_warning_count))', 1)
|
||||
main = main.replace(' self.fail_count = 0\n self.results = []', ' self.fail_count = 0\n self.registered_unsaved_count = 0\n self.postprocess_warning_count = 0\n self.results = []', 1)
|
||||
# Initial GUI object counters.
|
||||
main = main.replace(' self.fail_count = 0\n self.results = []\n self.stop_requested', ' self.fail_count = 0\n self.registered_unsaved_count = 0\n self.postprocess_warning_count = 0\n self.results = []\n self.stop_requested', 1)
|
||||
|
||||
# Replace start_registration validation/save section to gather all values, validate once, save once.
|
||||
old_start_fragment = ''' try:
|
||||
save_config()
|
||||
except ConfigError as exc:
|
||||
self.log(f"[!] 配置保存失败: {exc}")
|
||||
return
|
||||
if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]:
|
||||
self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base")
|
||||
return
|
||||
if config["email_provider"] == "cloudmail":
|
||||
missing = []
|
||||
if not config["cloudmail_api_base"]:
|
||||
missing.append("API Base")
|
||||
if not config["cloudmail_public_token"]:
|
||||
missing.append("Public Token")
|
||||
if not config["cloudmail_domains"]:
|
||||
missing.append("域名")
|
||||
if missing:
|
||||
self.log(f"[!] Cloud Mail 模式缺少配置: {', '.join(missing)}")
|
||||
return
|
||||
try:
|
||||
count = int(self.count_var.get())
|
||||
except Exception:
|
||||
self.log("[!] 注册数量无效")
|
||||
return
|
||||
config["register_count"] = count
|
||||
try:
|
||||
save_config()
|
||||
except ConfigError as exc:
|
||||
self.log(f"[!] 配置保存失败: {exc}")
|
||||
return
|
||||
'''
|
||||
new_start_fragment = ''' try:
|
||||
count = int(self.count_var.get())
|
||||
config["register_count"] = count
|
||||
validated = validate_run_requirements(config)
|
||||
config.clear()
|
||||
config.update(validated)
|
||||
save_config()
|
||||
except (ValueError, ConfigError) as exc:
|
||||
self.log(f"[!] 配置无效或保存失败: {exc}")
|
||||
return
|
||||
'''
|
||||
main = replace_once(main, old_start_fragment, new_start_fragment, "GUI single validation/save")
|
||||
|
||||
# Observer and final GUI state expose new counters.
|
||||
main = main.replace(' self.fail_count = batch.fail_count\n if account is not None:', ' self.fail_count = batch.fail_count\n self.registered_unsaved_count = batch.registered_unsaved_count\n self.postprocess_warning_count = batch.postprocess_warning_count\n if account is not None:', 1)
|
||||
main = main.replace(' self.fail_count = batch.fail_count\n except Exception as exc:', ' self.fail_count = batch.fail_count\n self.registered_unsaved_count = batch.registered_unsaved_count\n self.postprocess_warning_count = batch.postprocess_warning_count\n self.update_stats()\n except Exception as exc:', 1)
|
||||
|
||||
# CLI stats include all states.
|
||||
main = main.replace('last_stats = {"success": 0, "fail": 0}', 'last_stats = {"success": 0, "fail": 0, "pending": 0, "warnings": 0}', 1)
|
||||
main = main.replace(' last_stats["fail"] = batch.fail_count\n cli_log(f"[*] 当前统计: 成功 {batch.success_count} | 失败 {batch.fail_count}")', ' last_stats["fail"] = batch.fail_count\n last_stats["pending"] = batch.registered_unsaved_count\n last_stats["warnings"] = batch.postprocess_warning_count\n cli_log(f"[*] 当前统计: 成功 {batch.success_count} | 失败 {batch.fail_count} | 待恢复 {batch.registered_unsaved_count} | 后处理警告 {batch.postprocess_warning_count}")', 1)
|
||||
main = main.replace(' last_stats["fail"] = batch.fail_count\n except KeyboardInterrupt:', ' last_stats["fail"] = batch.fail_count\n last_stats["pending"] = batch.registered_unsaved_count\n last_stats["warnings"] = batch.postprocess_warning_count\n except KeyboardInterrupt:', 1)
|
||||
main = main.replace("cli_log(f\"[*] 任务结束。成功 {last_stats['success']} | 失败 {last_stats['fail']}\")", "cli_log(f\"[*] 任务结束。成功 {last_stats['success']} | 失败 {last_stats['fail']} | 待恢复 {last_stats['pending']} | 后处理警告 {last_stats['warnings']}\")", 1)
|
||||
# CLI performs runtime validation after structural loading.
|
||||
main = main.replace(' count = int(config.get("register_count", 1) or 1)\n cli_log("[*] CLI 已加载配置")', ' try:\n validated = validate_run_requirements(config)\n config.clear()\n config.update(validated)\n except ConfigError as exc:\n cli_log(f"[!] {exc}")\n return\n count = int(config.get("register_count", 1) or 1)\n cli_log("[*] CLI 已加载配置")', 1)
|
||||
|
||||
ast.parse(main)
|
||||
main_path.write_text(main, encoding="utf-8")
|
||||
|
||||
|
||||
# 4) Focused regression tests for each new safety boundary.
|
||||
flow_tests_path = ROOT / "tests" / "test_registration_flow.py"
|
||||
flow_tests = flow_tests_path.read_text(encoding="utf-8")
|
||||
insert_tests = r'''
|
||||
def test_cleanup_failure_does_not_change_success_statistics(self):
|
||||
fake = FakeOps()
|
||||
ops = fake.operations()
|
||||
def cleanup(reason):
|
||||
if "已成功" in reason:
|
||||
raise RuntimeError("cleanup failed")
|
||||
fake.events.append(("cleanup", reason))
|
||||
ops.cleanup = cleanup
|
||||
batch = run_batch(2, self.callbacks(), lambda *args: None, ops, cleanup_interval=1)
|
||||
self.assertEqual(batch.success_count, 2)
|
||||
self.assertEqual(batch.fail_count, 0)
|
||||
self.assertEqual(batch.processed_count, 2)
|
||||
|
||||
def test_cancel_during_between_account_sleep_ends_normally(self):
|
||||
fake = FakeOps()
|
||||
ops = fake.operations()
|
||||
ops.sleep = lambda seconds: (_ for _ in ()).throw(Cancelled())
|
||||
batch = run_batch(2, self.callbacks(), lambda *args: None, ops)
|
||||
self.assertTrue(batch.cancelled)
|
||||
self.assertEqual(batch.success_count, 1)
|
||||
self.assertEqual(batch.processed_count, 1)
|
||||
|
||||
def test_final_cleanup_failure_does_not_hide_original_error(self):
|
||||
fake = FakeOps()
|
||||
ops = fake.operations()
|
||||
ops.start_browser = lambda: (_ for _ in ()).throw(RuntimeError("original start error"))
|
||||
ops.cleanup = lambda reason: (_ for _ in ()).throw(RuntimeError("cleanup error"))
|
||||
logs = []
|
||||
with self.assertRaisesRegex(RuntimeError, "original start error"):
|
||||
run_batch(1, self.callbacks(logs), lambda *args: None, ops)
|
||||
self.assertTrue(any("清理失败" in line for line in logs))
|
||||
|
||||
def test_postprocessing_exceptions_become_warnings(self):
|
||||
fake = FakeOps()
|
||||
ops = fake.operations()
|
||||
ops.add_tokens = lambda sso, email: (_ for _ in ()).throw(RuntimeError("pool down"))
|
||||
ops.export_cpa = lambda email, password, sso: (_ for _ in ()).throw(RuntimeError("cpa down"))
|
||||
batch = run_batch(1, self.callbacks(), lambda *args: None, ops)
|
||||
self.assertEqual(batch.success_count, 1)
|
||||
self.assertEqual(batch.fail_count, 0)
|
||||
self.assertEqual(batch.postprocess_warning_count, 1)
|
||||
'''
|
||||
flow_tests = flow_tests.replace('\n\nif __name__ == "__main__":', insert_tests + '\n\nif __name__ == "__main__":')
|
||||
ast.parse(flow_tests)
|
||||
flow_tests_path.write_text(flow_tests, encoding="utf-8")
|
||||
|
||||
pending_tests = r'''import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from account_outputs import retry_pending_file
|
||||
|
||||
|
||||
class PendingRecoveryTests(unittest.TestCase):
|
||||
def test_retry_is_idempotent_after_target_was_already_written(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pending = os.path.join(directory, "accounts.txt.pending.jsonl")
|
||||
target = os.path.join(directory, "accounts.txt")
|
||||
record = {"email": "a@example.com", "password": "pw", "sso": "token"}
|
||||
with open(pending, "w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record) + "\n")
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
handle.write("a@example.com----pw----token\n")
|
||||
summary = retry_pending_file(pending)
|
||||
self.assertEqual(summary["restored"], 1)
|
||||
with open(target, "r", encoding="utf-8") as handle:
|
||||
self.assertEqual(handle.readlines(), ["a@example.com----pw----token\n"])
|
||||
self.assertFalse(os.path.exists(pending))
|
||||
|
||||
def test_rejects_same_input_and_output_path(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pending = os.path.join(directory, "pending.jsonl")
|
||||
with open(pending, "w", encoding="utf-8") as handle:
|
||||
handle.write("{}\n")
|
||||
with self.assertRaises(ValueError):
|
||||
retry_pending_file(pending, output_path=pending)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
'''
|
||||
pending_path = ROOT / "tests" / "test_pending_recovery.py"
|
||||
ast.parse(pending_tests)
|
||||
pending_path.write_text(pending_tests, encoding="utf-8")
|
||||
|
||||
for path in (flow_path, outputs_path, main_path, flow_tests_path, pending_path):
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
print("residual safety and account outputs refactor applied")
|
||||
Reference in New Issue
Block a user