diff --git a/api/config.py b/api/config.py index ca485c0..fcd1809 100644 --- a/api/config.py +++ b/api/config.py @@ -6,6 +6,8 @@ from services.mail_imports import MailImportExecuteRequest, MailImportSnapshotRe router = APIRouter(prefix="/config", tags=["config"]) CONFIG_KEYS = [ + "email_domain_rule_enabled", + "email_domain_level_count", "laoudo_auth", "laoudo_email", "laoudo_account_id", @@ -100,6 +102,9 @@ CONFIG_KEYS = [ "contribution_enabled", "contribution_server_url", "contribution_key", + "contribution_mode", + "custom_contribution_url", + "custom_contribution_token", ] @@ -137,8 +142,16 @@ def get_config(): all_cfg["contribution_enabled"] = "0" if not all_cfg.get("contribution_server_url"): all_cfg["contribution_server_url"] = "http://new.xem8k5.top:7317/" + if not all_cfg.get("contribution_mode"): + all_cfg["contribution_mode"] = "codex" + if not all_cfg.get("custom_contribution_url"): + all_cfg["custom_contribution_url"] = "http://127.0.0.1:5000" if not all_cfg.get("external_apps_update_mode"): all_cfg["external_apps_update_mode"] = "tag" + if not str(all_cfg.get("email_domain_rule_enabled", "") or "").strip(): + all_cfg["email_domain_rule_enabled"] = "0" + if not str(all_cfg.get("email_domain_level_count", "") or "").strip(): + all_cfg["email_domain_level_count"] = "2" # 只返回已知 key,未设置的返回空字符串 return {k: all_cfg.get(k, "") for k in CONFIG_KEYS} @@ -149,6 +162,19 @@ def update_config(body: ConfigUpdate): safe = {k: v for k, v in body.data.items() if k in CONFIG_KEYS} if safe.get("mail_provider") == "outlook": safe["mail_provider"] = "microsoft" + if "email_domain_rule_enabled" in safe: + enabled = str(safe.get("email_domain_rule_enabled", "")).strip().lower() + safe["email_domain_rule_enabled"] = ( + "1" if enabled in {"1", "true", "yes", "on"} else "0" + ) + if "email_domain_level_count" in safe: + try: + level_count = int(str(safe.get("email_domain_level_count", "")).strip()) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="域名级数必须是整数") from exc + if level_count < 2: + raise HTTPException(status_code=400, detail="域名级数不能小于 2") + safe["email_domain_level_count"] = str(level_count) config_store.set_many(safe) return {"ok": True, "updated": list(safe.keys())} diff --git a/api/proxies.py b/api/proxies.py index eb73cff..7b2daeb 100644 --- a/api/proxies.py +++ b/api/proxies.py @@ -18,6 +18,10 @@ class ProxyBulkCreate(BaseModel): region: str = "" +class ProxyBatchDelete(BaseModel): + ids: list[int] + + @router.get("") def list_proxies(session: Session = Depends(get_session)): items = session.exec(select(ProxyModel)).all() @@ -61,6 +65,27 @@ def delete_proxy(proxy_id: int, session: Session = Depends(get_session)): return {"ok": True} +@router.post("/batch-delete") +def batch_delete_proxies(body: ProxyBatchDelete, session: Session = Depends(get_session)): + if not body.ids: + raise HTTPException(400, "代理 ID 列表不能为空") + ids = list(dict.fromkeys(int(i) for i in body.ids)) + if len(ids) > 1000: + raise HTTPException(400, "单次最多删除 1000 条代理") + + proxies = session.exec(select(ProxyModel).where(ProxyModel.id.in_(ids))).all() + found_ids = {p.id for p in proxies if p.id is not None} + for p in proxies: + session.delete(p) + session.commit() + + return { + "deleted": len(found_ids), + "not_found": [pid for pid in ids if pid not in found_ids], + "total_requested": len(ids), + } + + @router.patch("/{proxy_id}/toggle") def toggle_proxy(proxy_id: int, session: Session = Depends(get_session)): p = session.get(ProxyModel, proxy_id) diff --git a/api/tasks.py b/api/tasks.py index 8c15faf..f6fbf57 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -4,7 +4,8 @@ from pydantic import BaseModel, Field from sqlmodel import Session, select from typing import Optional from copy import deepcopy -from core.db import TaskLog, engine +from datetime import datetime, timezone +from core.db import TaskLog, TaskRunModel, engine from core.task_runtime import ( AttemptOutcome, AttemptResult, @@ -42,18 +43,229 @@ class TaskLogBatchDeleteRequest(BaseModel): ids: list[int] -def _ensure_task_exists(task_id: str) -> None: +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _json_dumps(value, fallback): + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return json.dumps(fallback, ensure_ascii=False) + + +def _json_loads(raw: str, fallback): + try: + return json.loads(raw or "") + except Exception: + return fallback + + +def _to_epoch_seconds(value) -> float: + if isinstance(value, datetime): + return value.timestamp() + try: + return float(value or 0) + except Exception: + return 0.0 + + +def _to_datetime(value) -> datetime: + try: + ts = float(value or 0) + if ts > 1_000_000_000_000: + ts /= 1000 + if ts <= 0: + return _utcnow() + return datetime.fromtimestamp(ts, tz=timezone.utc) + except Exception: + return _utcnow() + + +def _normalize_snapshot(snapshot: dict) -> dict: + return { + "id": str(snapshot.get("id") or ""), + "status": str(snapshot.get("status") or "pending"), + "platform": str(snapshot.get("platform") or ""), + "source": str(snapshot.get("source") or "manual"), + "meta": snapshot.get("meta") if isinstance(snapshot.get("meta"), dict) else {}, + "total": int(snapshot.get("total") or 0), + "progress": str(snapshot.get("progress") or "0/0"), + "logs": snapshot.get("logs") if isinstance(snapshot.get("logs"), list) else [], + "success": int(snapshot.get("success") or 0), + "registered": int(snapshot.get("registered") or 0), + "skipped": int(snapshot.get("skipped") or 0), + "errors": snapshot.get("errors") if isinstance(snapshot.get("errors"), list) else [], + "control": snapshot.get("control") if isinstance(snapshot.get("control"), dict) else {}, + "cashier_urls": snapshot.get("cashier_urls") if isinstance(snapshot.get("cashier_urls"), list) else [], + "error": str(snapshot.get("error") or ""), + "created_at": _to_epoch_seconds(snapshot.get("created_at")), + "updated_at": _to_epoch_seconds(snapshot.get("updated_at")), + } + + +def _task_run_to_snapshot(row: TaskRunModel) -> dict: + return _normalize_snapshot( + { + "id": row.id, + "status": row.status, + "platform": row.platform, + "source": row.source, + "meta": _json_loads(row.meta_json, {}), + "total": row.total, + "progress": row.progress, + "logs": _json_loads(row.logs_json, []), + "success": row.success, + "registered": row.registered, + "skipped": row.skipped, + "errors": _json_loads(row.errors_json, []), + "control": _json_loads(row.control_json, {}), + "cashier_urls": _json_loads(row.cashier_urls_json, []), + "error": row.error, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) + + +def _upsert_task_run(snapshot: dict) -> None: + normalized = _normalize_snapshot(snapshot) + if not normalized["id"]: + return + with Session(engine) as s: + row = s.get(TaskRunModel, normalized["id"]) + if row is None: + row = TaskRunModel( + id=normalized["id"], + platform=normalized["platform"], + source=normalized["source"], + status=normalized["status"], + total=normalized["total"], + progress=normalized["progress"], + success=normalized["success"], + registered=normalized["registered"], + skipped=normalized["skipped"], + error=normalized["error"], + meta_json=_json_dumps(normalized["meta"], {}), + logs_json=_json_dumps(normalized["logs"], []), + errors_json=_json_dumps(normalized["errors"], []), + cashier_urls_json=_json_dumps(normalized["cashier_urls"], []), + control_json=_json_dumps(normalized["control"], {}), + created_at=_to_datetime(normalized["created_at"]), + updated_at=_to_datetime(normalized["updated_at"]), + ) + s.add(row) + else: + row.platform = normalized["platform"] + row.source = normalized["source"] + row.status = normalized["status"] + row.total = normalized["total"] + row.progress = normalized["progress"] + row.success = normalized["success"] + row.registered = normalized["registered"] + row.skipped = normalized["skipped"] + row.error = normalized["error"] + row.meta_json = _json_dumps(normalized["meta"], {}) + row.logs_json = _json_dumps(normalized["logs"], []) + row.errors_json = _json_dumps(normalized["errors"], []) + row.cashier_urls_json = _json_dumps(normalized["cashier_urls"], []) + row.control_json = _json_dumps(normalized["control"], {}) + if row.created_at is None: + row.created_at = _to_datetime(normalized["created_at"]) + row.updated_at = _to_datetime(normalized["updated_at"]) + s.add(row) + s.commit() + + +def _persist_task_snapshot(task_id: str) -> None: if not _task_store.exists(task_id): + return + try: + snapshot = _task_store.snapshot(task_id) + except Exception: + return + _upsert_task_run(snapshot) + + +def _get_persisted_task(task_id: str) -> Optional[dict]: + with Session(engine) as s: + row = s.get(TaskRunModel, task_id) + if row is None: + return None + return _task_run_to_snapshot(row) + + +def _list_persisted_tasks() -> list[dict]: + with Session(engine) as s: + rows = s.exec(select(TaskRunModel)).all() + snapshots = [_task_run_to_snapshot(row) for row in rows] + snapshots.sort( + key=lambda item: ( + {"running": 0, "pending": 1, "done": 2, "failed": 3, "stopped": 4}.get( + str(item.get("status") or ""), + 9, + ), + -_to_epoch_seconds(item.get("created_at")), + ) + ) + return snapshots + + +def _finalize_orphan_tasks() -> None: + with Session(engine) as s: + rows = s.exec( + select(TaskRunModel).where(TaskRunModel.status.in_(["pending", "running"])) + ).all() + if not rows: + return + changed = False + for row in rows: + if _task_store.exists(row.id): + continue + row.status = "stopped" + row.error = row.error or "任务因服务重启中断" + logs = _json_loads(row.logs_json, []) + tip = "[SYSTEM] 任务因服务重启中断,已自动标记为已停止" + if tip not in logs: + ts = datetime.now().strftime("%H:%M:%S") + logs.append(f"[{ts}] {tip}") + row.logs_json = _json_dumps(logs, []) + row.updated_at = _utcnow() + s.add(row) + changed = True + if changed: + s.commit() + + +def _ensure_task_exists(task_id: str) -> None: + if _task_store.exists(task_id): + return + if _get_persisted_task(task_id) is None: raise HTTPException(404, "任务不存在") def _ensure_task_mutable(task_id: str) -> None: _ensure_task_exists(task_id) - snapshot = _task_store.snapshot(task_id) + if _task_store.exists(task_id): + snapshot = _task_store.snapshot(task_id) + else: + snapshot = _get_persisted_task(task_id) or {} if snapshot.get("status") in {"done", "failed", "stopped"}: raise HTTPException(409, "任务已结束,无法再执行控制操作") +def _get_task_snapshot(task_id: str) -> dict: + _ensure_task_exists(task_id) + if _task_store.exists(task_id): + _persist_task_snapshot(task_id) + snapshot = _get_persisted_task(task_id) + if snapshot is None and _task_store.exists(task_id): + snapshot = _normalize_snapshot(_task_store.snapshot(task_id)) + if snapshot is None: + raise HTTPException(404, "任务不存在") + return snapshot + + def _prepare_register_request(req: RegisterTaskRequest) -> RegisterTaskRequest: from core.config_store import config_store @@ -91,6 +303,7 @@ def _create_task_record( source=source, meta=meta, ) + _persist_task_snapshot(task_id) def enqueue_register_task( @@ -124,37 +337,42 @@ def _log(task_id: str, msg: str): ts = time.strftime("%H:%M:%S") entry = f"[{ts}] {msg}" _task_store.append_log(task_id, entry) + _persist_task_snapshot(task_id) print(entry) def _save_task_log( platform: str, email: str, status: str, error: str = "", detail: dict = None ): - """Write a TaskLog record to the database.""" - with Session(engine) as s: - log = TaskLog( - platform=platform, - email=email, - status=status, - error=error, - detail_json=json.dumps(detail or {}, ensure_ascii=False), - ) - s.add(log) - s.commit() + """Write a TaskLog record to the database (fire-and-forget, non-blocking).""" + def _write(): + with Session(engine) as s: + log = TaskLog( + platform=platform, + email=email, + status=status, + error=error, + detail_json=json.dumps(detail or {}, ensure_ascii=False), + ) + s.add(log) + s.commit() + threading.Thread(target=_write, daemon=True).start() def _auto_upload_integrations(task_id: str, account): - """注册成功后自动导入外部系统。""" - try: - from services.external_sync import sync_account + """注册成功后自动导入外部系统(后台线程,不阻塞注册流程)。""" + def _run(): + try: + from services.external_sync import sync_account - for result in sync_account(account): - name = result.get("name", "Auto Upload") - ok = bool(result.get("ok")) - msg = result.get("msg", "") - _log(task_id, f" [{name}] {'[OK] ' + msg if ok else '[FAIL] ' + msg}") - except Exception as e: - _log(task_id, f" [Auto Upload] 自动导入异常: {e}") + for result in sync_account(account): + name = result.get("name", "Auto Upload") + ok = bool(result.get("ok")) + msg = result.get("msg", "") + _log(task_id, f" [{name}] {'[OK] ' + msg if ok else '[FAIL] ' + msg}") + except Exception as e: + _log(task_id, f" [Auto Upload] 自动导入异常: {e}") + threading.Thread(target=_run, daemon=True).start() def _run_register(task_id: str, req: RegisterTaskRequest): @@ -166,6 +384,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): control = _task_store.control_for(task_id) _task_store.mark_running(task_id) + _persist_task_snapshot(task_id) success = 0 skipped = 0 errors = [] @@ -187,35 +406,53 @@ def _run_register(task_id: str, req: RegisterTaskRequest): try: PlatformCls = get(req.platform) - def _build_mailbox(proxy: Optional[str]): - from core.config_store import config_store + # 预先计算 merged_extra,所有线程共享只读副本,避免每线程重复调用 config_store + from core.config_store import config_store as _cs + _base_extra = _cs.get_all().copy() + _base_extra.update( + {k: v for k, v in req.extra.items() if v is not None and v != ""} + ) - merged_extra = config_store.get_all().copy() - merged_extra.update( - {k: v for k, v in req.extra.items() if v is not None and v != ""} - ) + # 批量预取代理(无固定代理时),减少每线程单独查 DB + from core.proxy_pool import proxy_pool as _proxy_pool + _prefetched_proxies: list[str] = [] + _prefetch_lock = threading.Lock() + if not req.proxy and req.count > 1: + with Session(engine) as _s: + from core.db import ProxyModel + from sqlmodel import select as _sel + _active = _s.exec( + _sel(ProxyModel).where(ProxyModel.is_active == True) + ).all() + _prefetched_proxies = [p.url for p in _active if p.url] + + def _get_proxy() -> Optional[str]: + if req.proxy: + return req.proxy + if _prefetched_proxies: + with _prefetch_lock: + if _prefetched_proxies: + import random + return random.choice(_prefetched_proxies) + return _proxy_pool.get_next() + + def _build_mailbox(proxy: Optional[str]): return create_mailbox( - provider=merged_extra.get("mail_provider", "luckmail"), - extra=merged_extra, + provider=_base_extra.get("mail_provider", "luckmail"), + extra=_base_extra, proxy=proxy, ) def _do_one(i: int): nonlocal next_start_time - proxy_pool = None _proxy = None current_email = req.email or "" attempt_id: int | None = None try: - from core.proxy_pool import proxy_pool - control.checkpoint() attempt_id = control.start_attempt() control.checkpoint(attempt_id=attempt_id) - _proxy = req.proxy - if not _proxy: - _proxy = proxy_pool.get_next() - _proxy = normalize_proxy_url(_proxy) + _proxy = normalize_proxy_url(_get_proxy()) if req.register_delay_seconds > 0: with start_gate_lock: control.checkpoint(attempt_id=attempt_id) @@ -232,12 +469,8 @@ def _run_register(task_id: str, req: RegisterTaskRequest): ) next_start_time = time.time() + req.register_delay_seconds control.checkpoint(attempt_id=attempt_id) - from core.config_store import config_store - merged_extra = config_store.get_all().copy() - merged_extra.update( - {k: v for k, v in req.extra.items() if v is not None and v != ""} - ) + merged_extra = _base_extra _config = RegisterConfig( executor_type=req.executor_type, @@ -254,6 +487,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): _platform.mailbox._task_attempt_token = attempt_id _platform.mailbox._log_fn = _platform._log_fn _task_store.set_progress(task_id, f"{i + 1}/{req.count}") + _persist_task_snapshot(task_id) _log(task_id, f"开始注册第 {i + 1}/{req.count} 个账号") if _proxy: _log(task_id, f"使用代理: {_proxy}") @@ -262,6 +496,20 @@ def _run_register(task_id: str, req: RegisterTaskRequest): password=req.password, ) current_email = account.email or current_email + if str(merged_extra.get("mail_provider", "")).strip() == "cfworker": + from core.email_domain_policy import validate_email_domain_policy + + validate_email_domain_policy( + account.email, + { + "email_domain_rule_enabled": merged_extra.get( + "email_domain_rule_enabled", "0" + ), + "email_domain_level_count": merged_extra.get( + "email_domain_level_count", "2" + ), + }, + ) if isinstance(account.extra, dict): mail_provider = merged_extra.get("mail_provider", "") if mail_provider: @@ -291,7 +539,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): ) saved_account = save_account(account) if _proxy: - proxy_pool.report_success(_proxy) + _proxy_pool.report_success(_proxy) _log(task_id, f"[OK] 注册成功: {account.email}") _save_task_log(req.platform, account.email, "success") _auto_upload_integrations(task_id, saved_account or account) @@ -299,6 +547,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): if cashier_url: _log(task_id, f" [升级链接] {cashier_url}") _task_store.add_cashier_url(task_id, cashier_url) + _persist_task_snapshot(task_id) return AttemptResult.success() except SkipCurrentAttemptRequested as e: _log(task_id, f"[SKIP] 已跳过当前账号: {e}") @@ -313,8 +562,8 @@ def _run_register(task_id: str, req: RegisterTaskRequest): _log(task_id, f"[STOP] {e}") return AttemptResult.stopped(str(e)) except Exception as e: - if _proxy and proxy_pool is not None: - proxy_pool.report_fail(_proxy) + if _proxy: + _proxy_pool.report_fail(_proxy) _log(task_id, f"[FAIL] 注册失败: {e}") _save_task_log( req.platform, @@ -328,7 +577,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed - max_workers = min(req.concurrency, req.count, 5) + max_workers = min(req.concurrency, req.count) stopped = False with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = [pool.submit(_do_one, i) for i in range(req.count)] @@ -354,6 +603,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): success=success, registered=success + skipped + len(errors), ) + _persist_task_snapshot(task_id) if stopped or control.is_stop_requested(): stopped = True for pending in futures: @@ -370,6 +620,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): errors=errors, error=str(e), ) + _persist_task_snapshot(task_id) _task_store.cleanup() return @@ -389,6 +640,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): skipped=skipped, errors=errors, ) + _persist_task_snapshot(task_id) _task_store.cleanup() @@ -403,7 +655,10 @@ def create_register_task( @router.post("/{task_id}/skip-current") def skip_current_account(task_id: str): + _finalize_orphan_tasks() _ensure_task_mutable(task_id) + if not _task_store.exists(task_id): + raise HTTPException(409, "任务已结束或服务已重启,无法跳过当前账号") control = _task_store.request_skip_current(task_id) _log(task_id, "收到手动跳过当前账号请求") return {"ok": True, "task_id": task_id, "control": control} @@ -411,7 +666,10 @@ def skip_current_account(task_id: str): @router.post("/{task_id}/stop") def stop_task(task_id: str): + _finalize_orphan_tasks() _ensure_task_mutable(task_id) + if not _task_store.exists(task_id): + raise HTTPException(409, "任务已结束或服务已重启,无法停止") control = _task_store.request_stop(task_id) _log(task_id, "收到手动停止任务请求") return {"ok": True, "task_id": task_id, "control": control} @@ -465,13 +723,21 @@ def batch_delete_logs(body: TaskLogBatchDeleteRequest): @router.get("/{task_id}/logs/stream") async def stream_logs(task_id: str, since: int = 0): """SSE 实时日志流""" + _finalize_orphan_tasks() _ensure_task_exists(task_id) async def event_generator(): sent = since + use_memory = _task_store.exists(task_id) while True: - logs, status = _task_store.log_state(task_id) - snapshot = _task_store.snapshot(task_id) + if use_memory: + logs, status = _task_store.log_state(task_id) + snapshot = _task_store.snapshot(task_id) + _persist_task_snapshot(task_id) + else: + snapshot = _get_persisted_task(task_id) or {} + logs = snapshot.get("logs") or [] + status = snapshot.get("status") or "failed" counters = { "success": int(snapshot.get("success") or 0), "registered": int(snapshot.get("registered") or 0), @@ -483,6 +749,10 @@ async def stream_logs(task_id: str, since: int = 0): if status in ("done", "failed", "stopped"): yield f"data: {json.dumps({'done': True, 'status': status, **counters})}\n\n" break + if not use_memory: + # 非内存任务仅提供持久化快照,不进入无限轮询 + yield f"data: {json.dumps({'done': True, 'status': 'stopped', **counters})}\n\n" + break await asyncio.sleep(0.5) return StreamingResponse( @@ -497,10 +767,27 @@ async def stream_logs(task_id: str, since: int = 0): @router.get("/{task_id}") def get_task(task_id: str): - _ensure_task_exists(task_id) - return _task_store.snapshot(task_id) + _finalize_orphan_tasks() + return _get_task_snapshot(task_id) @router.get("") def list_tasks(): - return _task_store.list_snapshots() + _finalize_orphan_tasks() + # 以 DB 为主返回,避免进程重启导致列表丢失 + return _list_persisted_tasks() + + +@router.delete("/{task_id}") +def delete_task(task_id: str): + _finalize_orphan_tasks() + snapshot = _get_task_snapshot(task_id) + status = str(snapshot.get("status") or "") + if status in {"pending", "running"}: + raise HTTPException(409, "运行中的任务不允许删除,请先停止任务") + with Session(engine) as s: + row = s.get(TaskRunModel, task_id) + if row is not None: + s.delete(row) + s.commit() + return {"ok": True, "task_id": task_id} diff --git a/core/base_mailbox.py b/core/base_mailbox.py index c5b0520..cb7dc8c 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -315,6 +315,7 @@ def create_mailbox( domains=extra.get("cfworker_domains", ""), enabled_domains=extra.get("cfworker_enabled_domains", ""), subdomain=extra.get("cfworker_subdomain", ""), + domain_level_count=extra.get("email_domain_level_count", 2), random_subdomain=extra.get("cfworker_random_subdomain", False), random_name_subdomain=extra.get("cfworker_random_name_subdomain", False), fingerprint=extra.get("cfworker_fingerprint", ""), @@ -2284,6 +2285,7 @@ class CFWorkerMailbox(BaseMailbox): domains: Any = None, enabled_domains: Any = None, subdomain: str = "", + domain_level_count: Any = 2, random_subdomain: Any = False, random_name_subdomain: Any = False, fingerprint: str = "", @@ -2302,6 +2304,7 @@ class CFWorkerMailbox(BaseMailbox): else: self.enabled_domains = raw_enabled_domains self.subdomain = self._normalize_subdomain(subdomain) + self.domain_level_count = self._parse_domain_level_count(domain_level_count) self.random_subdomain = self._to_bool(random_subdomain) self.random_name_subdomain = self._to_bool(random_name_subdomain) self.fingerprint = fingerprint @@ -2405,6 +2408,14 @@ class CFWorkerMailbox(BaseMailbox): text = str(value or "").strip().lower() return text in {"1", "true", "yes", "on"} + @staticmethod + def _parse_domain_level_count(value: Any) -> int: + try: + parsed = int(str(value or "").strip() or "2") + except (TypeError, ValueError): + return 2 + return parsed if parsed >= 2 else 2 + @classmethod def _parse_domains(cls, value: Any) -> list[str]: if not value: @@ -2473,6 +2484,13 @@ class CFWorkerMailbox(BaseMailbox): if self.subdomain: sub_parts.append(self.subdomain) + base_level_count = len([part for part in domain.split(".") if part]) + expected_total_levels = max(self.domain_level_count, 2) + missing_levels = max(expected_total_levels - (base_level_count + len(sub_parts)), 0) + if missing_levels > 0: + fillers = [self._generate_subdomain_label() for _ in range(missing_levels)] + sub_parts = fillers + sub_parts + if not sub_parts: return domain return f"{'.'.join(sub_parts)}.{domain}" @@ -3256,14 +3274,43 @@ class OutlookGraphMailboxBackend(OutlookMailboxBackend): ) seen: set[str] = set() for folder in self.mailbox._graph_folder_names: - messages = self.mailbox._graph_list_messages( - access_token=access_token, - folder=folder, - ) - for message in messages: - message_id = str(message.get("id") or "").strip() - if message_id: - seen.add(f"{folder}:{message_id}") + try: + messages = self.mailbox._graph_list_messages( + access_token=access_token, + folder=folder, + ) + for message in messages: + message_id = str(message.get("id") or "").strip() + if message_id: + seen.add(f"{folder}:{message_id}") + except RuntimeError as exc: + if "HTTP 401" in str(exc): + # 401 → token 失效,强制刷新后重试一次 + self.mailbox._log( + f"[微软邮箱][Graph] get_current_ids folder={folder} 遇到 401,强制刷新 token" + ) + _cache = (account.extra or {}).get("_oauth_token_cache") + if isinstance(_cache, dict): + _cache.pop( + self.mailbox._normalize_backend_name(self.backend_name), None + ) + access_token = self.mailbox._get_oauth_access_token( + account, + preferred_backend=self.backend_name, + ) + try: + messages = self.mailbox._graph_list_messages( + access_token=access_token, + folder=folder, + ) + for message in messages: + message_id = str(message.get("id") or "").strip() + if message_id: + seen.add(f"{folder}:{message_id}") + except Exception: + pass + else: + raise return seen def wait_for_code( @@ -3283,7 +3330,23 @@ class OutlookGraphMailboxBackend(OutlookMailboxBackend): } keyword_lower = str(keyword or "").strip().lower() + # 标记是否已做过一次 401 强制刷 token,避免无限循环 + _token_refreshed = False + + def _force_refresh_token() -> str: + """清除 OAuth 缓存,强制重新获取 access token。""" + _cache = (account.extra or {}).get("_oauth_token_cache") + if isinstance(_cache, dict): + _cache.pop( + self.mailbox._normalize_backend_name(self.backend_name), None + ) + return self.mailbox._get_oauth_access_token( + account, + preferred_backend=self.backend_name, + ) + def poll_once() -> Optional[str]: + nonlocal _token_refreshed access_token = self.mailbox._get_oauth_access_token( account, preferred_backend=self.backend_name, @@ -3342,6 +3405,52 @@ class OutlookGraphMailboxBackend(OutlookMailboxBackend): ) return code except Exception as exc: + exc_str = str(exc) + # 401 → token 失效,强制刷新后重试一次 + if "HTTP 401" in exc_str and not _token_refreshed: + _token_refreshed = True + self.mailbox._log( + f"[微软邮箱][Graph] folder={folder} 遇到 401,强制刷新 token 后重试" + ) + try: + access_token = _force_refresh_token() + messages = self.mailbox._graph_list_messages( + access_token=access_token, + folder=folder, + ) + new_messages = [] + for message in messages: + message_id = str(message.get("id") or "").strip() + seen_key = f"{folder}:{message_id}" + if not message_id or seen_key in seen: + continue + seen.add(seen_key) + new_messages.append(message) + for message in new_messages: + subject = str(message.get("subject") or "").strip() + text = self.mailbox._graph_message_text(message) + if keyword_lower and keyword_lower not in text.lower(): + continue + code = self.mailbox._safe_extract(text, code_pattern) + if not code: + mid = str(message.get("id") or "").strip() + if mid: + detail = self.mailbox._graph_get_message( + access_token=access_token, + message_id=mid, + ) + text = self.mailbox._graph_message_text(detail) + code = self.mailbox._safe_extract(text, code_pattern) + if code and code not in exclude_codes: + self.mailbox._log( + f"[微软邮箱][Graph] folder={folder} 刷新 token 后验证码提取成功: {code}" + ) + return code + except Exception as retry_exc: + self.mailbox._log( + f"[微软邮箱][Graph] folder={folder} 刷新 token 后仍然失败: {retry_exc}" + ) + continue self.mailbox._log( f"[微软邮箱][Graph] folder={folder} 查询异常: {exc}" ) @@ -3358,6 +3467,9 @@ class OutlookGraphMailboxBackend(OutlookMailboxBackend): class OutlookMailbox(BaseMailbox): """微软邮箱(Outlook / Hotmail)本地账号池(Graph / IMAP 策略)""" + # 类级别锁:保证多线程并发时取号互斥,防止多个实例取到同一个邮箱 + _pop_lock = threading.Lock() + def __init__( self, imap_server: str = "", @@ -3414,7 +3526,7 @@ class OutlookMailbox(BaseMailbox): from sqlmodel import Session, select from core.db import engine, OutlookAccountModel - with self._lock: + with OutlookMailbox._pop_lock: with Session(engine) as session: account = ( session.exec( @@ -3695,7 +3807,7 @@ class OutlookMailbox(BaseMailbox): "endpoint": probe.get("endpoint", ""), } self._log(f"[微软邮箱] OAuth token 获取失败,回退密码登录: {email}") - return {} + return {"reason": str(probe.get("reason") or "")} def _fetch_oauth_token( self, @@ -3744,7 +3856,9 @@ class OutlookMailbox(BaseMailbox): ) access_token = str(bundle.get("access_token") or "").strip() if not access_token: - raise RuntimeError("微软邮箱 OAuth access token 获取失败") + reason = bundle.get("reason", "") + suffix = f" [{reason}]" if reason else "" + raise RuntimeError(f"微软邮箱 OAuth access token 获取失败{suffix}") expires_in = bundle.get("expires_in") try: diff --git a/core/db.py b/core/db.py index d36db07..39abd8e 100644 --- a/core/db.py +++ b/core/db.py @@ -49,6 +49,28 @@ class TaskLog(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +class TaskRunModel(SQLModel, table=True): + __tablename__ = "task_runs" + + id: str = Field(primary_key=True) + platform: str = Field(index=True) + source: str = Field(default="manual", index=True) + status: str = Field(default="pending", index=True) + total: int = 0 + progress: str = "0/0" + success: int = 0 + registered: int = 0 + skipped: int = 0 + error: str = "" + meta_json: str = "{}" + logs_json: str = "[]" + errors_json: str = "[]" + cashier_urls_json: str = "[]" + control_json: str = "{}" + created_at: datetime = Field(default_factory=_utcnow, index=True) + updated_at: datetime = Field(default_factory=_utcnow, index=True) + + class OutlookAccountModel(SQLModel, table=True): __tablename__ = "outlook_accounts" diff --git a/core/email_domain_policy.py b/core/email_domain_policy.py new file mode 100644 index 0000000..1e5a082 --- /dev/null +++ b/core/email_domain_policy.py @@ -0,0 +1,53 @@ +"""邮箱域名全局策略校验。""" + +from __future__ import annotations + +import re +from typing import Any + + +def _to_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + return text in {"1", "true", "yes", "on"} + + +def _required_level_count(value: Any) -> int: + text = str(value or "").strip() + if not text: + return 2 + try: + level_count = int(text) + except (TypeError, ValueError) as exc: + raise ValueError("域名级数必须是整数") from exc + if level_count < 2: + raise ValueError("域名级数不能小于 2") + return level_count + + +def validate_email_domain_policy(email: str, config: dict[str, Any] | None = None) -> None: + cfg = config or {} + if not _to_bool(cfg.get("email_domain_rule_enabled")): + return + + address = str(email or "").strip().lower() + if "@" not in address: + raise ValueError("邮箱格式无效,缺少域名") + + _, domain = address.rsplit("@", 1) + domain = domain.strip().strip(".") + if not domain: + raise ValueError("邮箱格式无效,缺少域名") + + levels = [part for part in domain.split(".") if part] + required_levels = _required_level_count(cfg.get("email_domain_level_count")) + if len(levels) < required_levels: + raise ValueError( + f"邮箱域名不满足要求:当前 {len(levels)} 级,至少需要 {required_levels} 级" + ) + + letters = len(re.findall(r"[a-z]", domain)) + digits = len(re.findall(r"\d", domain)) + if letters < 2 or digits < 2: + raise ValueError("邮箱域名不满足要求:域名至少包含 2 个英文字母和 2 个数字") diff --git a/core/task_runtime.py b/core/task_runtime.py index 16cca85..09ed094 100644 --- a/core/task_runtime.py +++ b/core/task_runtime.py @@ -164,6 +164,8 @@ class RegisterTaskRecord: "skipped": self.skipped, "errors": list(self.errors), "control": self.control.snapshot(), + "created_at": self.created_at, + "updated_at": self.updated_at, } if self.cashier_urls: data["cashier_urls"] = list(self.cashier_urls) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f32196..f658aee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { SunOutlined, MoonOutlined, LogoutOutlined, + PlayCircleOutlined, } from '@ant-design/icons' import zhCN from 'antd/locale/zh_CN' import Dashboard from '@/pages/Dashboard' @@ -18,6 +19,7 @@ import RegisterTaskPage from '@/pages/RegisterTaskPage' import Proxies from '@/pages/Proxies' import Settings from '@/pages/Settings' import TaskHistory from '@/pages/TaskHistory' +import RunningTasks from '@/pages/RunningTasks' import Login from '@/pages/Login' import { darkTheme, lightTheme } from './theme' import { apiFetch, clearToken, getToken } from '@/lib/utils' @@ -94,6 +96,7 @@ function AppContent() { if (path === '/history') return ['/history'] if (path === '/proxies') return ['/proxies'] if (path === '/settings') return ['/settings'] + if (path === '/running-tasks') return ['/running-tasks'] return ['/'] } @@ -103,14 +106,21 @@ function AppContent() { icon: , label: '仪表盘', }, + { + key: '/running-tasks', + icon: , + label: '任务运行', + }, { key: '/accounts', icon: , label: '平台管理', - children: platforms.map(p => ({ - key: `/accounts/${p.key}`, - label: p.label, - })), + children: [ + ...platforms.map(p => ({ + key: `/accounts/${p.key}`, + label: p.label, + })), + ], }, { key: '/history', @@ -230,6 +240,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 811261d..ae8a663 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -524,6 +524,8 @@ export default function Accounts() { const [accounts, setAccounts] = useState([]) const [platformActions, setPlatformActions] = useState([]) const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(50) const [loading, setLoading] = useState(false) const [search, setSearch] = useState('') const [filterStatus, setFilterStatus] = useState('') @@ -571,7 +573,7 @@ export default function Accounts() { setLoading(true) try { - const params = new URLSearchParams({ platform: currentPlatform, page: '1', page_size: '100' }) + const params = new URLSearchParams({ platform: currentPlatform, page: String(page), page_size: String(pageSize) }) if (search) params.set('email', search) if (filterStatus) params.set('status', filterStatus) if (createdAtStart) params.set('created_at_start', createdAtStart) @@ -582,7 +584,7 @@ export default function Accounts() { } finally { setLoading(false) } - }, [currentPlatform, search, filterStatus, createdAtStart, createdAtEnd]) + }, [currentPlatform, search, filterStatus, createdAtStart, createdAtEnd, page, pageSize]) useEffect(() => { load() @@ -1217,7 +1219,13 @@ export default function Accounts() { - handleDelete(record.id)}> + handleDelete(record.id)} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > @@ -1254,14 +1262,14 @@ export default function Accounts() { { setPage(1); setSearch(v) }} style={{ width: 200 }} /> - + diff --git a/frontend/src/pages/Proxies.tsx b/frontend/src/pages/Proxies.tsx index 3c4c9cf..7c91a82 100644 --- a/frontend/src/pages/Proxies.tsx +++ b/frontend/src/pages/Proxies.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Card, Table, Button, Input, Tag, Space, Popconfirm, message } from 'antd' +import { useEffect, useState, type Key } from 'react' +import { Card, Table, Button, Input, Tag, Space, Popconfirm, message, Modal } from 'antd' import { PlusOutlined, DeleteOutlined, @@ -17,6 +17,7 @@ export default function Proxies() { const [region, setRegion] = useState('') const [checking, setChecking] = useState(false) const [loading, setLoading] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) const load = async () => { setLoading(true) @@ -57,9 +58,47 @@ export default function Proxies() { } const del = async (id: number) => { - await apiFetch(`/proxies/${id}`, { method: 'DELETE' }) - message.success('删除成功') - load() + try { + await apiFetch(`/proxies/${id}`, { method: 'DELETE' }) + message.success('删除成功') + setSelectedRowKeys((prev) => prev.filter((key) => key !== id)) + load() + } catch (e: any) { + message.error(`删除失败: ${e.message || '未知错误'}`) + } + } + + const batchDel = async () => { + if (selectedRowKeys.length === 0) return + const ids = selectedRowKeys.map((key) => Number(key)).filter((v) => Number.isFinite(v)) + try { + const result = await apiFetch('/proxies/batch-delete', { + method: 'POST', + body: JSON.stringify({ ids }), + }) as { deleted: number; not_found?: number[]; total_requested?: number } + setSelectedRowKeys([]) + load() + + const notFound = (result.not_found || []) as number[] + Modal.success({ + title: '批量删除结果', + okText: '知道了', + content: ( +
+
请求删除:{result.total_requested ?? ids.length} 条
+
成功删除:{result.deleted ?? 0} 条
+
未找到:{notFound.length} 条
+ {notFound.length > 0 && ( +
+ {notFound.join(', ')} +
+ )} +
+ ), + }) + } catch (e: any) { + message.error(`批量删除失败: ${e.message || '未知错误'}`) + } } const toggle = async (id: number) => { @@ -121,7 +160,13 @@ export default function Proxies() { icon={record.is_active ? : } onClick={() => toggle(record.id)} /> - del(record.id)}> + del(record.id)} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + setSelectedRowKeys(keys), + }} pagination={false} /> diff --git a/frontend/src/pages/RegisterTaskPage.tsx b/frontend/src/pages/RegisterTaskPage.tsx index cc7769a..8da6a87 100644 --- a/frontend/src/pages/RegisterTaskPage.tsx +++ b/frontend/src/pages/RegisterTaskPage.tsx @@ -288,7 +288,7 @@ export default function RegisterTaskPage() { - + diff --git a/frontend/src/pages/RunningTasks.tsx b/frontend/src/pages/RunningTasks.tsx new file mode 100644 index 0000000..22f0b87 --- /dev/null +++ b/frontend/src/pages/RunningTasks.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Badge, + Button, + Card, + Col, + Drawer, + Empty, + message, + Popconfirm, + Progress, + Row, + Space, + Tag, + Typography, +} from 'antd' +import { + DeleteOutlined, + FileTextOutlined, + LoadingOutlined, + ReloadOutlined, +} from '@ant-design/icons' +import { apiFetch } from '@/lib/utils' +import { TaskLogPanel } from '@/components/TaskLogPanel' + +const { Text, Title } = Typography + +interface TaskSnapshot { + id: string + platform: string + source: string + status: 'pending' | 'running' | 'done' | 'failed' | 'stopped' + total: number + progress: string + success: number + registered: number + skipped: number + errors: string[] + created_at: number | string | null + updated_at: number | string | null + control: { stop_requested: boolean } +} + +const PLATFORM_LABELS: Record = { + chatgpt: 'ChatGPT', + trae: 'Trae', + cursor: 'Cursor', + grok: 'Grok', + kiro: 'Kiro', + tavily: 'Tavily', + openblocklabs: 'OpenBlock Labs', +} + +const SOURCE_LABELS: Record = { + manual: '手动', + api: 'API', + schedule: '调度', +} + +const STATUS_CONFIG: Record = { + pending: { color: 'default', label: '等待中', icon: }, + running: { color: 'processing', label: '运行中', icon: }, + done: { color: 'success', label: '已完成' }, + failed: { color: 'error', label: '失败' }, + stopped: { color: 'warning', label: '已停止' }, +} + +function toUnixSeconds(value: unknown): number | null { + if (value === null || value === undefined) return null + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + const maybeNum = Number(trimmed) + if (Number.isFinite(maybeNum)) { + return maybeNum > 1_000_000_000_000 ? maybeNum / 1000 : maybeNum + } + const parsed = Date.parse(trimmed) + if (Number.isFinite(parsed)) return parsed / 1000 + return null + } + if (typeof value !== 'number' || !Number.isFinite(value)) return null + // 兼容毫秒时间戳 + return value > 1_000_000_000_000 ? value / 1000 : value +} + +function formatDuration(startTs: unknown, endTs?: unknown): string { + const start = toUnixSeconds(startTs) + const end = toUnixSeconds(endTs) ?? (Date.now() / 1000) + if (start === null || !Number.isFinite(end)) return '-' + const seconds = Math.max(0, Math.floor(end - start)) + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return `${h}h ${m}m` +} + +export default function RunningTasks() { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(false) + const [logTaskId, setLogTaskId] = useState(null) + const [now, setNow] = useState(() => Date.now() / 1000) + + const load = useCallback(async () => { + setLoading(true) + try { + const data = (await apiFetch('/tasks')) as TaskSnapshot[] + // sort: running first, then pending, then finished (newest first) + const order = { running: 0, pending: 1, done: 2, failed: 3, stopped: 4 } + const sorted = [...(data || [])].sort((a, b) => { + const oa = order[a.status] ?? 9 + const ob = order[b.status] ?? 9 + if (oa !== ob) return oa - ob + const ta = toUnixSeconds(a.created_at) ?? 0 + const tb = toUnixSeconds(b.created_at) ?? 0 + return tb - ta + }) + setTasks(sorted) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + const poll = setInterval(() => { + load() + setNow(Date.now() / 1000) + }, 2500) + // tick every second for live duration + const tick = setInterval(() => setNow(Date.now() / 1000), 1000) + return () => { + clearInterval(poll) + clearInterval(tick) + } + }, [load]) + + const isActive = (t: TaskSnapshot) => t.status === 'running' || t.status === 'pending' + const activeTasks = tasks.filter(isActive) + const finishedTasks = tasks.filter((t) => !isActive(t)) + + const handleDelete = async (taskId: string) => { + try { + await apiFetch(`/tasks/${taskId}`, { method: 'DELETE' }) + if (logTaskId === taskId) setLogTaskId(null) + setTasks((prev) => prev.filter((t) => t.id !== taskId)) + message.success('任务已删除') + } catch (error) { + const detail = error instanceof Error ? error.message : '删除失败' + message.error(detail) + } + } + + const renderTask = (task: TaskSnapshot) => { + const cfg = STATUS_CONFIG[task.status] || { color: 'default', label: task.status } + const failed = task.errors?.length ?? 0 + const totalRaw = Number(task.total) + const doneRaw = Number(task.registered) + const success = Number.isFinite(Number(task.success)) ? Number(task.success) : 0 + const skipped = Number.isFinite(Number(task.skipped)) ? Number(task.skipped) : 0 + const total = Number.isFinite(totalRaw) && totalRaw > 0 ? Math.floor(totalRaw) : 0 + const done = Number.isFinite(doneRaw) && doneRaw > 0 ? Math.floor(doneRaw) : 0 + const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0 + + const duration = isActive(task) + ? formatDuration(task.created_at, now) + : formatDuration(task.created_at, task.updated_at) + + return ( + + + {/* Task ID + platform */} + + + + {task.id} + + + + {PLATFORM_LABELS[task.platform] || task.platform} + + + {SOURCE_LABELS[task.source] || task.source || '-'} + + + + + + {/* Status */} + + + + + {/* Duration */} + + + ⏱ {duration} + + + + {/* Progress bar */} + + + `${done}/${total}`} + /> + + + ✓ 成功 {success} + + {failed > 0 && ( + + ✗ 失败 {failed} + + )} + {skipped > 0 && ( + + → 跳过 {skipped} + + )} + + + + + {/* Log button */} + + + + {!isActive(task) && ( + handleDelete(task.id)} + > + + + )} + + + + + ) + } + + return ( +
+
+ + 任务运行 + + +
+ + {/* Active tasks */} + {activeTasks.length > 0 && ( +
+ + 进行中 ({activeTasks.length}) + + {activeTasks.map(renderTask)} +
+ )} + + {/* Finished tasks */} + {finishedTasks.length > 0 && ( +
+ + 已完成 ({finishedTasks.length}) + + {finishedTasks.map(renderTask)} +
+ )} + + {tasks.length === 0 && !loading && ( + + )} + + {/* Log drawer */} + + + 任务日志 + {logTaskId && ( + + {logTaskId} + + )} + + } + open={!!logTaskId} + onClose={() => setLogTaskId(null)} + width={720} + destroyOnClose + bodyStyle={{ padding: 16 }} + > + {logTaskId && } + +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7f6de91..89fa707 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -221,6 +221,8 @@ const TAB_ITEMS = [ { key: 'cfworker_admin_token', label: '管理员 Token', secret: true }, { key: 'cfworker_custom_auth', label: '站点密码', secret: true }, { key: 'cfworker_subdomain', label: '固定子域名', placeholder: 'mail / pool-a' }, + { key: 'email_domain_rule_enabled', label: '启用域名规则', type: 'boolean' }, + { key: 'email_domain_level_count', label: '域名级数(N 级)', placeholder: '例如 2 / 3 / 4' }, { key: 'cfworker_random_subdomain', label: '随机子域名', type: 'boolean' }, { key: 'cfworker_random_name_subdomain', label: '随机姓名子域名', type: 'boolean' }, { key: 'cfworker_fingerprint', label: 'Fingerprint', placeholder: '6703363b...' }, @@ -570,6 +572,10 @@ function ConfigField({ field }: { field: FieldConfig }) { const helpText = field.key === 'default_executor' ? '仅对支持的平台生效;ChatGPT、Cursor、Grok、Kiro、Tavily、Trae 支持浏览器模式,OpenBlockLabs 仅支持纯协议。' + : field.key === 'email_domain_rule_enabled' + ? '仅 CF Worker 生效:开启后会校验域名级数,以及域名至少包含 2 个字母和 2 个数字。' + : field.key === 'email_domain_level_count' + ? '例如 2=example.com,3=a.example.com,4=a.b.example.com。' : undefined return ( @@ -1088,10 +1094,20 @@ function ContributionPanel({ const [statsResponse, setStatsResponse] = useState | null>(null) const [redeemResponse, setRedeemResponse] = useState | null>(null) const [statsError, setStatsError] = useState('') + const [bindingCustom, setBindingCustom] = useState(false) + const [customEmail, setCustomEmail] = useState('') + const [customStatsResponse, setCustomStatsResponse] = useState | null>(null) + const [customBalanceResponse, setCustomBalanceResponse] = useState | null>(null) + const [loadingCustomStats, setLoadingCustomStats] = useState(false) const contributionEnabled = Form.useWatch('contribution_enabled', form) + const contributionMode = String(Form.useWatch('contribution_mode', form) || 'codex').trim() const contributionServerUrl = String(Form.useWatch('contribution_server_url', form) || '').trim() const contributionKey = String(Form.useWatch('contribution_key', form) || '').trim() + const customContributionUrl = String(Form.useWatch('custom_contribution_url', form) || '').trim() + const customContributionToken = String(Form.useWatch('custom_contribution_token', form) || '').trim() + + const isCustomMode = contributionMode === 'custom' const rawData = asRecord(statsResponse?.['data']) const serverInfo = pickRecord(rawData, ['server_info', 'server', 'server_stats', 'stats']) || rawData @@ -1235,6 +1251,68 @@ function ContributionPanel({ } } + const doBindCustom = async () => { + if (!customEmail.trim()) { + message.error('请输入邮箱') + return + } + if (!customContributionUrl) { + message.error('请先填写自定义服务器地址') + return + } + setBindingCustom(true) + try { + const data = await apiFetch('/contribution/custom/bind', { + method: 'POST', + body: JSON.stringify({ + email: customEmail.trim(), + server_url: customContributionUrl, + }), + }) + const token = pickString(asRecord(data), ['token']) + if (token) { + form.setFieldValue('custom_contribution_token', token) + message.success('绑定成功!token 已自动填充,请点击保存配置') + setCustomEmail('') + } else { + message.success('绑定成功') + } + } catch (e: any) { + message.error(String(e?.message || '绑定失败')) + } finally { + setBindingCustom(false) + } + } + + const fetchCustomStats = async () => { + if (!contributionEnabled) { + message.warning('请先开启贡献功能') + return + } + if (!customContributionUrl) { + message.error('请先填写自定义服务器地址') + return + } + if (!customContributionToken) { + message.error('请先绑定邮箱获取 token') + return + } + setLoadingCustomStats(true) + try { + const [status, balance] = await Promise.all([ + apiFetch(`/contribution/custom/status?server_url=${encodeURIComponent(customContributionUrl)}&token=${encodeURIComponent(customContributionToken)}`), + apiFetch(`/contribution/custom/balance?server_url=${encodeURIComponent(customContributionUrl)}&token=${encodeURIComponent(customContributionToken)}`), + ]) + setCustomStatsResponse(asRecord(status)) + setCustomBalanceResponse(asRecord(balance)) + message.success('信息已刷新') + } catch (e: any) { + message.error(String(e?.message || '获取信息失败')) + } finally { + setLoadingCustomStats(false) + } + } + return (
@@ -1255,104 +1333,178 @@ function ContributionPanel({ - - - - - { void doGenerateKey() }} - style={{ paddingInline: 0 }} - > - 没有key?请求新建 - - )} - /> + + + + {!isCustomMode ? ( + <> + + + + + { void doGenerateKey() }} + style={{ paddingInline: 0 }} + > + 没有key?请求新建 + + )} + /> + + + ) : ( + <> + + + + + + setCustomEmail(e.target.value)} + onPressEnter={() => { void doBindCustom() }} + /> + + + + + + + + )} + - { void fetchStats() }}> - 刷新信息 - - )} - > - {!contributionEnabled ? ( - - ) : ( - - {statsError ? : null} -
- 服务器信息 -
- 账号数: {formatDisplayNumber(serverQuotaAccountCount)} - 总额度: {formatDisplayNumber(serverQuotaTotal)} - 已用额度: {formatDisplayNumber(serverQuotaUsed)} - 剩余额度: {formatDisplayNumber(serverQuotaRemaining)} - 已用占比: {formatDisplayPercent(serverQuotaUsedPercent)} - 剩余占比: {formatDisplayPercent(serverQuotaRemainingPercent)} - 折算账号数: {formatDisplayNumber(serverQuotaRemainingAccounts, 2)} -
-
-
- API Key - - - {keyFromStats || '-'} - + {!isCustomMode ? ( + <> + { void fetchStats() }}> + 刷新信息 + + )} + > + {!contributionEnabled ? ( + + ) : ( + + {statsError ? : null} +
+ 服务器信息 +
+ 账号数: {formatDisplayNumber(serverQuotaAccountCount)} + 总额度: {formatDisplayNumber(serverQuotaTotal)} + 已用额度: {formatDisplayNumber(serverQuotaUsed)} + 剩余额度: {formatDisplayNumber(serverQuotaRemaining)} + 已用占比: {formatDisplayPercent(serverQuotaUsedPercent)} + 剩余占比: {formatDisplayPercent(serverQuotaRemainingPercent)} + 折算账号数: {formatDisplayNumber(serverQuotaRemainingAccounts, 2)} +
+
+
+ API Key + + + {keyFromStats || '-'} + + +
+
+ key 信息 +
+ 余额: {keyBalance ?? '-'} + 来源: {keySource} + 绑定账号数: {boundAccounts ?? '-'} + 结算金额: {settlementAmount ?? '-'} +
+
-
-
- key 信息 -
- 余额: {keyBalance ?? '-'} - 来源: {keySource} - 绑定账号数: {boundAccounts ?? '-'} - 结算金额: {settlementAmount ?? '-'} -
-
-
- )} -
+ )} + - - - key 当前额度:{keyBalance ?? '-'} - - ({ label: String(amount), value: amount }))} + /> + + + {redeemResponse ? ( + {formatResultText(redeemResponse)}} + /> + ) : null} + + + + ) : ( + { void fetchCustomStats() }}> + 刷新信息 + + )} + > + {!contributionEnabled ? ( + + ) : !customContributionToken ? ( + + ) : ( + +
+ 余额信息 +
+ 余额: {pickNumber(asRecord(customBalanceResponse), ['balance']) ?? '-'} +
+
+
+ 贡献记录 +
+ 成功: {pickNumber(asRecord(customStatsResponse), ['success_count']) ?? '-'} + 待处理: {pickNumber(asRecord(customStatsResponse), ['pending_count']) ?? '-'} + 失败: {pickNumber(asRecord(customStatsResponse), ['failed_count']) ?? '-'} +
+
+
+ )} +
+ )}
) } @@ -1647,6 +1799,12 @@ export default function Settings() { if (!data.contribution_server_url) { data.contribution_server_url = 'http://new.xem8k5.top:7317/' } + if (!data.contribution_mode) { + data.contribution_mode = 'codex' + } + if (!data.custom_contribution_url) { + data.custom_contribution_url = 'http://127.0.0.1:5000' + } if (!data.cloudmail_timeout) { data.cloudmail_timeout = 30 } @@ -1663,6 +1821,10 @@ export default function Settings() { data.cfworker_random_subdomain = parseBooleanConfigValue(data.cfworker_random_subdomain) data.cfworker_random_name_subdomain = parseBooleanConfigValue(data.cfworker_random_name_subdomain) data.contribution_enabled = parseBooleanConfigValue(data.contribution_enabled) + data.email_domain_rule_enabled = parseBooleanConfigValue(data.email_domain_rule_enabled) + if (!String(data.email_domain_level_count ?? '').trim()) { + data.email_domain_level_count = 2 + } data.mail_import_source = configMailProvider === 'applemail' ? 'applemail' : 'microsoft' data.mail_provider = isMailImportProvider ? 'mail_import' : configMailProvider form.setFieldsValue(data) @@ -1727,6 +1889,19 @@ export default function Settings() { values.cfworker_random_subdomain = parseBooleanConfigValue(values.cfworker_random_subdomain) values.cfworker_random_name_subdomain = parseBooleanConfigValue(values.cfworker_random_name_subdomain) values.contribution_enabled = parseBooleanConfigValue(values.contribution_enabled) + values.email_domain_rule_enabled = parseBooleanConfigValue(values.email_domain_rule_enabled) + const rawDomainLevelCount = Number.parseInt(String(values.email_domain_level_count ?? '').trim(), 10) + if (values.mail_provider === 'cfworker' && values.email_domain_rule_enabled) { + if (!Number.isInteger(rawDomainLevelCount) || rawDomainLevelCount < 2) { + setActiveTab('mailbox') + message.error('域名级数必须是大于等于 2 的整数') + return + } + } + values.email_domain_level_count = + Number.isInteger(rawDomainLevelCount) && rawDomainLevelCount >= 2 + ? String(rawDomainLevelCount) + : '2' await apiFetch('/config', { method: 'PUT', body: JSON.stringify({ data: values }) }) form.setFieldsValue({ @@ -1740,6 +1915,11 @@ export default function Settings() { cfworker_random_subdomain: values.cfworker_random_subdomain, cfworker_random_name_subdomain: values.cfworker_random_name_subdomain, contribution_enabled: values.contribution_enabled, + contribution_mode: values.contribution_mode, + custom_contribution_url: values.custom_contribution_url, + custom_contribution_token: values.custom_contribution_token, + email_domain_rule_enabled: values.email_domain_rule_enabled, + email_domain_level_count: values.email_domain_level_count, }) message.success('保存成功') setSaved(true) diff --git a/frontend/src/pages/TaskHistory.tsx b/frontend/src/pages/TaskHistory.tsx index 46443d2..0275ed3 100644 --- a/frontend/src/pages/TaskHistory.tsx +++ b/frontend/src/pages/TaskHistory.tsx @@ -121,6 +121,9 @@ export default function TaskHistory() {