fix: harden residual flow boundaries and pending recovery

This commit is contained in:
github-actions[bot]
2026-07-14 17:21:54 +00:00
parent 447b1253dc
commit 778d6822a2
7 changed files with 350 additions and 808 deletions
@@ -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")