diff --git a/app/models.py b/app/models.py index 58cc0d8..6ddcc32 100644 --- a/app/models.py +++ b/app/models.py @@ -89,6 +89,7 @@ class AccountRecord(BaseModel): preview_concurrency_time: str = "" ticket_pool_size: int = 0 # 0 = disabled; N > 0 = pool mode: collect N tickets first ticket_pool_drain_interval_ms: int = 0 # 0 = parallel drain; N > 0 = serial drain interval + preview_warmup_lead_seconds: int = 0 # scheduler fires this many seconds before scheduled_start_time stock_monitor_enabled: bool = False stock_monitor_last_checked_at: str | None = None stock_monitor_last_message: str = "" @@ -130,6 +131,7 @@ class PublicAccountRecord(BaseModel): preview_concurrency_time: str = "" ticket_pool_size: int = 0 ticket_pool_drain_interval_ms: int = 0 + preview_warmup_lead_seconds: int = 0 invitation_code: str = DEFAULT_INVITATION_CODE stock_monitor_enabled: bool = False stock_monitor_last_checked_at: str | None = None @@ -289,6 +291,7 @@ class AccountPreferencesRequest(BaseModel): preview_concurrency_time: str | None = None ticket_pool_size: int | None = None ticket_pool_drain_interval_ms: int | None = None + preview_warmup_lead_seconds: int | None = None schedule_enabled: bool | None = None scheduled_start_time: str | None = None @@ -324,6 +327,18 @@ def validate_ticket_pool_drain_interval_ms(cls, value: int | None) -> int | None raise ValueError("ticket 发射间隔不能超过 10000ms") return v + @field_validator("preview_warmup_lead_seconds") + @classmethod + def validate_preview_warmup_lead_seconds(cls, value: int | None) -> int | None: + if value is None: + return None + v = int(value) + if v < 0: + raise ValueError("warmup 提前秒数不能为负数") + if v > 120: + raise ValueError("warmup 提前秒数不能超过 120 秒") + return v + class NetworkModeRequest(BaseModel): """Runtime network egress mode switch payload.""" diff --git a/app/runtime_logging.py b/app/runtime_logging.py index 53097a9..8e06e51 100644 --- a/app/runtime_logging.py +++ b/app/runtime_logging.py @@ -31,7 +31,9 @@ "randstr", "sign", "ticket", + "ticket_value", "token", + "randstr_value", } diff --git a/app/services/account_state.py b/app/services/account_state.py index 9cae897..1efd509 100644 --- a/app/services/account_state.py +++ b/app/services/account_state.py @@ -5,6 +5,7 @@ import secrets import shutil import logging +import threading from datetime import datetime, timezone from functools import lru_cache from http.cookies import SimpleCookie @@ -49,6 +50,22 @@ def __init__(self) -> None: self.settings = settings self.accounts_store = JsonFileStore(settings.accounts_path, default_factory=list) self.tasks_store = JsonFileStore(settings.tasks_path, default_factory=list) + self._session_stores: dict[str, JsonFileStore] = {} + self._session_stores_lock = threading.Lock() + + def _get_session_store(self, account_id: str) -> JsonFileStore: + with self._session_stores_lock: + store = self._session_stores.get(account_id) + if store is None: + store = JsonFileStore( + self._session_path(account_id), + default_factory=lambda aid=account_id: AccountSessionState( + account_id=aid, + updated_at=utc_now_iso(), + ).model_dump(), + ) + self._session_stores[account_id] = store + return store def list_accounts(self) -> list[PublicAccountRecord]: accounts = [AccountRecord.model_validate(item) for item in self.accounts_store.read()] @@ -132,6 +149,12 @@ def updater(records: list[dict[str, Any]]) -> list[dict[str, Any]]: ) if existing else 0, + preview_warmup_lead_seconds=max( + 0, + min(120, int(existing.get("preview_warmup_lead_seconds") or 0)), + ) + if existing + else 0, stock_monitor_enabled=bool(existing.get("stock_monitor_enabled")) if existing else False, stock_monitor_last_checked_at=existing.get("stock_monitor_last_checked_at") if existing else None, stock_monitor_last_message=str(existing.get("stock_monitor_last_message") or "") if existing else "", @@ -284,6 +307,8 @@ def update_preferences(self, account_id: str, request: AccountPreferencesRequest account.ticket_pool_size = max(0, min(50, int(request.ticket_pool_size))) if request.ticket_pool_drain_interval_ms is not None: account.ticket_pool_drain_interval_ms = max(0, min(10_000, int(request.ticket_pool_drain_interval_ms))) + if request.preview_warmup_lead_seconds is not None: + account.preview_warmup_lead_seconds = max(0, min(120, int(request.preview_warmup_lead_seconds))) if self._should_skip_today_after_schedule_update( account=account, previous_schedule_enabled=previous_schedule_enabled, @@ -328,22 +353,12 @@ def _scheduled_run_key(self, current_date: str, scheduled_start_time: str) -> st return f"{current_date}|{(scheduled_start_time or '').strip()}" def load_session(self, account_id: str) -> AccountSessionState: - path = self._session_path(account_id) - store = JsonFileStore( - path, - default_factory=lambda: AccountSessionState( - account_id=account_id, - updated_at=utc_now_iso(), - ).model_dump(), - ) + store = self._get_session_store(account_id) return AccountSessionState.model_validate(store.read()) def save_session(self, session: AccountSessionState) -> AccountSessionState: session.updated_at = utc_now_iso() - store = JsonFileStore( - self._session_path(session.account_id), - default_factory=dict, - ) + store = self._get_session_store(session.account_id) store.write(session.model_dump()) return session @@ -391,6 +406,8 @@ def update_tasks(records: list[dict[str, Any]]) -> list[dict[str, Any]]: session_path = self._session_path(account_id) if session_path.exists(): session_path.unlink() + with self._session_stores_lock: + self._session_stores.pop(account_id, None) self._remove_account_artifacts(account_id) get_runtime_log_service().log_account_event( account_id=account_id, @@ -431,6 +448,7 @@ def to_public_account(self, account: AccountRecord) -> PublicAccountRecord: preview_concurrency_time=account.preview_concurrency_time, ticket_pool_size=account.ticket_pool_size, ticket_pool_drain_interval_ms=account.ticket_pool_drain_interval_ms, + preview_warmup_lead_seconds=account.preview_warmup_lead_seconds, invitation_code=account.invitation_code, stock_monitor_enabled=account.stock_monitor_enabled, stock_monitor_last_checked_at=account.stock_monitor_last_checked_at, diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 65e627f..b6e59a7 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -36,6 +36,7 @@ PreviewResult, PreviewSeedRequest, ProductOffer, + TicketPoolEntry, ) from app.runtime_logging import FlowRun, RuntimeLogService, get_runtime_log_service from app.services.account_state import ( @@ -73,6 +74,9 @@ class PreviewRaceWinner: PREVIEW_RACE_MAX_ROUNDS = 999 +PREVIEW_TICKET_REUSE_ON_555_ATTEMPTS = 2 +PREVIEW_TICKET_REUSE_DELAY_SECONDS = 0.05 +PREVIEW_TICKET_POOL_MAX_CYCLES = 999 STATIC_PRODUCTS: tuple[StaticProduct, ...] = ( @@ -259,6 +263,65 @@ def _preview_response_log_details( "preview_response": raw, } + def _call_preview_with_ticket_reuse( + self, + account_id: str, + account: AccountRecord, + session: AccountSessionState, + request: PreviewPaymentRequest, + invitation: str, + *, + ticket: str, + randstr: str, + allow_fallback_proxy: bool, + flow: FlowRun | None, + context: dict[str, Any] | None = None, + stop_event: threading.Event | None = None, + ): + """Call /preview, retrying code 555 briefly with the same captcha ticket.""" + attempts = max(1, 1 + PREVIEW_TICKET_REUSE_ON_555_ATTEMPTS) + details = context or {} + for attempt in range(1, attempts + 1): + self._ensure_not_paused(account_id) + if stop_event is not None and stop_event.is_set(): + raise RunPausedError("preview race 已有其他任务胜出") + + result = self.bigmodel_client.preview_payment( + account, + session, + request, + invitation_code=invitation, + ticket=ticket, + randstr=randstr, + allow_fallback_proxy=allow_fallback_proxy, + ) + raw = result.raw + if raw.get("code") != 555 or attempt >= attempts: + return result + + self.runtime_logs.log_event( + flow, + stage="preview_same_ticket_retry", + status="retry", + message="preview 返回 555,复用当前 ticket 快速重试", + details={ + **details, + "same_ticket_attempt": attempt, + "same_ticket_max_attempts": attempts, + "delay_seconds": PREVIEW_TICKET_REUSE_DELAY_SECONDS, + **self._preview_response_log_details( + raw=raw, + ticket=ticket, + randstr=randstr, + ), + }, + level=logging.WARNING, + ) + if PREVIEW_TICKET_REUSE_DELAY_SECONDS > 0: + time.sleep(PREVIEW_TICKET_REUSE_DELAY_SECONDS) + + raise RuntimeError("preview retry loop exited unexpectedly") + def _preview_concurrency_wait_seconds(self, target_time: str) -> float: parts = target_time.split(":") if len(parts) != 3: @@ -313,7 +376,11 @@ def _wait_preview_concurrency_time( ) remaining = wait_seconds while remaining > 0: - if stop_event.wait(min(0.1, remaining)): + # Tighten the busy-wait granularity to ~5ms in the final 0.5s so all + # lanes fire within a few ms of the target instant; cheaper 100ms + # ticks the rest of the way. + tick = 0.005 if remaining < 0.5 else 0.1 + if stop_event.wait(min(tick, remaining)): raise RunPausedError("preview race 已有其他任务胜出") self._ensure_not_paused(account_id) remaining = self._preview_concurrency_wait_seconds(target_time) @@ -1135,6 +1202,7 @@ def _fill_ticket_pool( *, flow: FlowRun, deadline_time: str = "", + cycle: int = 1, ) -> AccountSessionState: """Collect captcha tickets until pool has ``target_size`` unused entries. @@ -1149,7 +1217,7 @@ def _fill_ticket_pool( stage="ticket_pool_fill", status="started", message=f"开始填充 ticket 池,目标 {target_size} 个,当前已有 {unused_count} 个未使用", - details={"target": target_size, "already_collected": unused_count}, + details={"cycle": cycle, "target": target_size, "already_collected": unused_count}, ) self._push_runtime_message(account_id, f"ticket 池填充中 ({unused_count}/{target_size})") @@ -1162,7 +1230,7 @@ def _fill_ticket_pool( stage="ticket_pool_fill", status="deadline_reached", message=f"并发时间已到,中止填充(已收集 {unused_count}/{target_size})", - details={"target": target_size, "collected": unused_count}, + details={"cycle": cycle, "target": target_size, "collected": unused_count}, level=logging.WARNING, ) break @@ -1182,7 +1250,7 @@ def _fill_ticket_pool( stage="ticket_pool_fill", status="retry", message=f"ticket 池填充:验证码识别异常,重试 ({exc})", - details={"error": str(exc)}, + details={"cycle": cycle, "error": str(exc)}, level=logging.WARNING, ) continue @@ -1192,7 +1260,6 @@ def _fill_ticket_pool( if not ticket or not randstr: continue - from app.models import TicketPoolEntry entry = TicketPoolEntry( ticket=ticket, randstr=randstr, @@ -1209,7 +1276,7 @@ def _fill_ticket_pool( stage="ticket_pool_fill", status="progress", message=f"ticket 池: {unused_count}/{target_size} 已收集", - details={"collected": unused_count, "target": target_size, "ticket_prefix": ticket[:12]}, + details={"cycle": cycle, "collected": unused_count, "target": target_size, "ticket_prefix": ticket[:12]}, ) self._push_runtime_message(account_id, f"ticket 池 {unused_count}/{target_size} 已就绪") @@ -1218,7 +1285,33 @@ def _fill_ticket_pool( stage="ticket_pool_fill", status="done", message=f"ticket 池填充完成,共 {unused_count} 个可用 ticket", - details={"collected": unused_count, "target": target_size}, + details={"cycle": cycle, "collected": unused_count, "target": target_size}, + ) + return session + + def _prune_used_ticket_pool_entries( + self, + account_id: str, + session: AccountSessionState, + *, + flow: FlowRun, + reason: str, + cycle: int, + ) -> AccountSessionState: + pool = list(session.ticket_pool) + unused = [entry for entry in pool if not entry.used] + removed = len(pool) - len(unused) + if removed <= 0: + return session + + session.ticket_pool = unused + self.state_service.save_session(session) + self.runtime_logs.log_event( + flow, + stage="ticket_pool", + status="pruned", + message=f"已清理 {removed} 个用过的 ticket,保留 {len(unused)} 个未使用 ticket", + details={"cycle": cycle, "reason": reason, "removed": removed, "remaining_unused": len(unused)}, ) return session @@ -1330,14 +1423,22 @@ def _drain_ticket_pool_serial( ) try: - result = self.bigmodel_client.preview_payment( + result = self._call_preview_with_ticket_reuse( + account_id, account, session, request, - invitation_code=invitation, + invitation, ticket=ticket, randstr=randstr, allow_fallback_proxy=True, + flow=flow, + context={ + "idx": idx, + "total": len(unused), + "mode": "serial", + "pool_mode": True, + }, ) except UpstreamRequestError as exc: self.runtime_logs.log_event( @@ -1538,14 +1639,24 @@ def _drain_ticket_pool_parallel_lane( ) try: - result = self.bigmodel_client.preview_payment( + result = self._call_preview_with_ticket_reuse( + account_id, account, session, request, - invitation_code=invitation, + invitation, ticket=ticket, randstr=randstr, allow_fallback_proxy=True, + flow=flow, + context={ + "idx": idx, + "total": total, + "mode": "parallel", + "pool_mode": True, + "dispatch_delay_ms": round(delay_ms, 3), + }, + stop_event=stop_event, ) except UpstreamRequestError as exc: self.runtime_logs.log_event( @@ -1701,13 +1812,17 @@ def preview_payment( continue try: - result = self.bigmodel_client.preview_payment( + result = self._call_preview_with_ticket_reuse( + account_id, account, session, request, - invitation_code=invitation, + invitation, ticket=ticket, randstr=randstr, + allow_fallback_proxy=False, + flow=flow, + context={"round": preview_round, "product_id": request.product_id}, ) except UpstreamRequestError as exc: preview_attempts.append( @@ -2103,13 +2218,18 @@ def _preview_race_lane( ) preview_wait_used = True try: - result = self.bigmodel_client.preview_payment( + result = self._call_preview_with_ticket_reuse( + account_id, account, session, request, - invitation_code=invitation, + invitation, ticket=ticket, randstr=randstr, + allow_fallback_proxy=False, + flow=flow, + context={**details, "product_id": request.product_id}, + stop_event=stop_event, ) except UpstreamRequestError as exc: self.runtime_logs.log_event( @@ -2464,10 +2584,8 @@ def run_payment_flow( ticket_pool_size = current_account.ticket_pool_size if ticket_pool_size > 0: - # Pool mode: pre-collect N tickets, then drain them into /preview one by one. - # The pool is used ONLY for the first attempt; if all tickets are exhausted - # without a bizId, _run_pool_preview falls back internally to - # race_preview_payment — no exception escapes this branch. + # Pool mode: keep pre-collecting N tickets, then drain them into + # /preview bursts until a bizId is received. deadline_time = ( current_account.preview_concurrency_time if current_account.preview_concurrency_time_enabled @@ -2547,101 +2665,121 @@ def _run_pool_preview( deadline_time: str, flow: "FlowRun", ) -> PreviewResult: - """Fill the ticket pool then drain it one-by-one against /preview. - - Executes only **once** — does NOT loop on exhaustion. - If all pool tickets are consumed without a bizId, falls back directly - to ``race_preview_payment`` without raising, so the caller's chain - is never interrupted by the pool exhaustion case. - """ + """Keep filling and draining ticket batches until /preview returns a bizId.""" self._ensure_not_paused(account_id) - current_account, session = self._ensure_context(account_id) - - # Step 1: fill until we have ticket_pool_size unused entries - session = self._fill_ticket_pool( - account_id, - current_account, - session, - ticket_pool_size, - flow=flow, - deadline_time=deadline_time, - ) + first_deadline_wait_done = False + last_exhaustion: UpstreamRequestError | None = None - # Reload after fill — session was saved incrementally inside _fill_ticket_pool - current_account = self.state_service.get_account(account_id) - session = self.state_service.load_session(account_id) - invitation = current_account.invitation_code.strip() - - # If deadline is configured and pool filled BEFORE the deadline, - # hold here until the deadline arrives so that drain fires exactly on time. - if deadline_time: - wait_secs = self._preview_concurrency_wait_seconds(deadline_time) - if wait_secs > 0: - unused_count = sum(1 for e in session.ticket_pool if not e.used) - self.runtime_logs.log_event( - flow, - stage="ticket_pool_wait", - status="waiting", - message=f"ticket 池已满({unused_count} 张),等待并发时间 {deadline_time}(剩余 {wait_secs:.1f} 秒)", - details={"deadline": deadline_time, "wait_seconds": round(wait_secs, 3), "pool_collected": unused_count}, - ) - self._push_runtime_message(account_id, f"ticket 池已满,等待 {deadline_time} 开始抢购…") - while True: - self._ensure_not_paused(account_id) - remaining = self._preview_concurrency_wait_seconds(deadline_time) - if remaining <= 0: - break - import time as _time - _time.sleep(min(0.1, remaining)) - self.runtime_logs.log_event( - flow, - stage="ticket_pool_wait", - status="ready", - message="并发时间到,开始消耗 ticket 池", - details={"deadline": deadline_time, "pool_collected": unused_count}, - ) - self._push_runtime_message(account_id, "并发时间到,开始消耗 ticket 池抢购") + for cycle in range(1, PREVIEW_TICKET_POOL_MAX_CYCLES + 1): + self._ensure_not_paused(account_id) + current_account = self.state_service.get_account(account_id) + session = self.state_service.load_session(account_id) + session = self._prune_used_ticket_pool_entries( + account_id, + session, + flow=flow, + reason="cycle_start", + cycle=cycle, + ) - # Step 2: drain pool tickets into /preview until bizId is received. - # If all tickets are exhausted without success, fall back directly to - # race_preview_payment — no exception propagates out of this method. - try: - preview = self._drain_ticket_pool( + fill_deadline = deadline_time if not first_deadline_wait_done else "" + session = self._fill_ticket_pool( account_id, current_account, session, - PreviewPaymentRequest(product_id=product_id), - invitation, + ticket_pool_size, flow=flow, + deadline_time=fill_deadline, + cycle=cycle, ) - except UpstreamRequestError as exc: - if "已耗尽" not in exc.message: - raise - self.runtime_logs.log_event( - flow, - stage="ticket_pool", - status="fallback", - message="ticket 池已耗尽未拿到 bizId,切换竞速模式继续抢购", + + current_account = self.state_service.get_account(account_id) + session = self.state_service.load_session(account_id) + invitation = current_account.invitation_code.strip() + + if deadline_time and not first_deadline_wait_done: + wait_secs = self._preview_concurrency_wait_seconds(deadline_time) + if wait_secs > 0: + unused_count = sum(1 for e in session.ticket_pool if not e.used) + self.runtime_logs.log_event( + flow, + stage="ticket_pool_wait", + status="waiting", + message=f"ticket 池已满({unused_count} 张),等待并发时间 {deadline_time}(剩余 {wait_secs:.1f} 秒)", + details={ + "cycle": cycle, + "deadline": deadline_time, + "wait_seconds": round(wait_secs, 3), + "pool_collected": unused_count, + }, + ) + self._push_runtime_message(account_id, f"ticket 池已满,等待 {deadline_time} 开始抢购…") + while True: + self._ensure_not_paused(account_id) + remaining = self._preview_concurrency_wait_seconds(deadline_time) + if remaining <= 0: + break + tick = 0.005 if remaining < 0.5 else 0.1 + time.sleep(min(tick, remaining)) + self.runtime_logs.log_event( + flow, + stage="ticket_pool_wait", + status="ready", + message="并发时间到,开始消耗 ticket 池", + details={"cycle": cycle, "deadline": deadline_time, "pool_collected": unused_count}, + ) + self._push_runtime_message(account_id, "并发时间到,开始消耗 ticket 池抢购") + first_deadline_wait_done = True + + try: + preview = self._drain_ticket_pool( + account_id, + current_account, + session, + PreviewPaymentRequest(product_id=product_id), + invitation, + flow=flow, + ) + except UpstreamRequestError as exc: + if "已耗尽" not in exc.message and "为空" not in exc.message: + raise + last_exhaustion = exc + self.runtime_logs.log_event( + flow, + stage="ticket_pool", + status="refill", + message="ticket 池未拿到 bizId,继续补票后再次发射", + details={ + "cycle": cycle, + "pool_size": ticket_pool_size, + "reason": exc.message, + "fallback_proxy_ticket_pool_only": self.settings.fallback_proxy_ticket_pool_only, + }, + level=logging.WARNING, + ) + self._push_runtime_message(account_id, f"ticket 池第 {cycle} 轮未拿到 bizId,继续补票抢购…") + continue + break + else: + raise UpstreamRequestError( + f"ticket 池已达到最大循环 {PREVIEW_TICKET_POOL_MAX_CYCLES} 轮,仍未拿到 bizId", details={ + "account_id": account_id, "pool_size": ticket_pool_size, - "fallback_proxy_ticket_pool_only": self.settings.fallback_proxy_ticket_pool_only, - "fallback_retry_uses_fallback_proxy": not self.settings.fallback_proxy_ticket_pool_only, + "last_error": last_exhaustion.message if last_exhaustion is not None else "", + "last_details": last_exhaustion.details if last_exhaustion is not None else {}, }, - level=logging.WARNING, - ) - self._push_runtime_message(account_id, "ticket 池已耗尽,切换竞速模式继续抢购…") - return self.race_preview_payment( - account_id, - PreviewPaymentRequest(product_id=product_id), - concurrency=current_account.preview_concurrency, - preview_concurrency_time=current_account.preview_concurrency_time - if current_account.preview_concurrency_time_enabled - else "", - flow=flow, ) # Persist preview to session so create_qr can read it session = self.state_service.load_session(account_id) + session = self._prune_used_ticket_pool_entries( + account_id, + session, + flow=flow, + reason="success", + cycle=cycle, + ) session.preview = preview session.selected_product_id = product_id self.state_service.save_session(session) diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index 3f029bc..35cedb8 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -108,9 +108,15 @@ def poll_once(self) -> None: for public_account in self.state_service.list_accounts(): if not public_account.schedule_enabled or not public_account.scheduled_start_time: continue - if public_account.scheduled_start_time > current_hms: + effective_start = self._effective_trigger_hms( + public_account.scheduled_start_time, + getattr(public_account, "preview_warmup_lead_seconds", 0) or 0, + ) + if effective_start > current_hms: continue account = self.state_service.get_account(public_account.id) + # run_key is keyed on the user-facing scheduled_start_time so warmup-lead + # changes don't accidentally re-fire an already-ran schedule for today. run_key = self._scheduled_run_key(current_date, account.scheduled_start_time) if self._already_ran_schedule(account, run_key): continue @@ -118,6 +124,19 @@ def poll_once(self) -> None: continue self.start_account_flow(account.id, source="scheduled", scheduled_run_key=run_key) + @staticmethod + def _effective_trigger_hms(scheduled_start_time: str, lead_seconds: int) -> str: + if lead_seconds <= 0: + return scheduled_start_time + try: + hh, mm, ss = (int(p) for p in scheduled_start_time.split(":")) + except ValueError: + return scheduled_start_time + total = hh * 3600 + mm * 60 + ss - max(0, int(lead_seconds)) + if total < 0: + total = 0 + return f"{total // 3600:02d}:{(total // 60) % 60:02d}:{total % 60:02d}" + def check_cached_accounts_once(self) -> None: for public_account in self.state_service.list_accounts(): account_id = public_account.id diff --git a/web/src/App.vue b/web/src/App.vue index 97b76b1..fa2a350 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -70,10 +70,12 @@ async function updateSchedule( accountId: string, enabled: boolean, time: string, + warmupLeadSeconds: number, ) { await dashboard.updatePreferences(accountId, { schedule_enabled: enabled, scheduled_start_time: time, + preview_warmup_lead_seconds: warmupLeadSeconds, }); } diff --git a/web/src/components/AccountTable.vue b/web/src/components/AccountTable.vue index c9de136..f622395 100644 --- a/web/src/components/AccountTable.vue +++ b/web/src/components/AccountTable.vue @@ -18,7 +18,12 @@ defineProps<{ const emit = defineEmits<{ openContext: [detail: AccountDetailResponse]; selectProduct: [accountId: string, productId: string]; - updateSchedule: [accountId: string, enabled: boolean, time: string]; + updateSchedule: [ + accountId: string, + enabled: boolean, + time: string, + warmupLeadSeconds: number, + ]; updatePreviewConcurrency: [accountId: string, value: number]; updatePreviewConcurrencyTimeEnabled: [ accountId: string, @@ -74,7 +79,9 @@ function scheduleStateText(detail: AccountDetailResponse) { if (!detail.account.schedule_enabled) { return copy.table.scheduleDisabled; } - return `${copy.table.scheduleEnabled} ${detail.account.scheduled_start_time || "00:00:00"}`; + const leadSeconds = warmupLeadSeconds(detail); + const leadText = leadSeconds > 0 ? ` / ${copy.schedule.warmupLeadShort} ${leadSeconds}` : ""; + return `${copy.table.scheduleEnabled} ${detail.account.scheduled_start_time || "00:00:00"}${leadText}`; } function productOptions(detail: AccountDetailResponse) { @@ -112,6 +119,13 @@ function previewConcurrencyTimeEnabled(detail: AccountDetailResponse) { return Boolean(detail.account.preview_concurrency_time_enabled); } +function warmupLeadSeconds(detail: AccountDetailResponse) { + return Math.max( + 0, + Math.min(120, Number(detail.account.preview_warmup_lead_seconds || 0)), + ); +} + function updatePreviewConcurrencyTimeEnabled( detail: AccountDetailResponse, enabled: boolean, @@ -271,13 +285,17 @@ function onTicketPoolUpdate( detail.account.scheduled_start_time || '00:00:00' " + :warmup-lead-seconds=" + warmupLeadSeconds(detail) + " @update=" - (id, enabled, time) => + (id, enabled, time, leadSeconds) => emit( 'updateSchedule', id, enabled, time, + leadSeconds, ) " /> diff --git a/web/src/components/ScheduleEditor.vue b/web/src/components/ScheduleEditor.vue index 80a9398..769ea51 100644 --- a/web/src/components/ScheduleEditor.vue +++ b/web/src/components/ScheduleEditor.vue @@ -6,21 +6,31 @@ const props = defineProps<{ accountId: string; enabled: boolean; time: string; + warmupLeadSeconds: number; }>(); const emit = defineEmits<{ - update: [accountId: string, enabled: boolean, time: string]; + update: [accountId: string, enabled: boolean, time: string, warmupLeadSeconds: number]; }>(); const normalizedTime = computed(() => props.time || "00:00:00"); +const normalizedWarmupLeadSeconds = computed(() => + Math.max(0, Math.min(120, Number(props.warmupLeadSeconds || 0))), +); function updateEnabled(enabled: boolean) { - emit("update", props.accountId, enabled, normalizedTime.value); + emit("update", props.accountId, enabled, normalizedTime.value, normalizedWarmupLeadSeconds.value); } function updateTime(event: Event) { const target = event.target as HTMLInputElement; - emit("update", props.accountId, props.enabled, target.value); + emit("update", props.accountId, props.enabled, target.value, normalizedWarmupLeadSeconds.value); +} + +function updateWarmupLead(event: Event) { + const raw = parseInt((event.target as HTMLInputElement).value, 10); + const seconds = Math.max(0, Math.min(120, isNaN(raw) ? 0 : raw)); + emit("update", props.accountId, props.enabled, normalizedTime.value, seconds); } @@ -37,5 +47,19 @@ function updateTime(event: Event) { @change="updateTime" /> {{ normalizedTime }} + diff --git a/web/src/locales/zhCN.ts b/web/src/locales/zhCN.ts index 4ca16b1..b172675 100644 --- a/web/src/locales/zhCN.ts +++ b/web/src/locales/zhCN.ts @@ -107,6 +107,9 @@ export const zhCN = { schedule: { enableLabel: "启用定时任务", timeLabel: "定时启动时间", + warmupLeadLabel: "提前预热秒数", + warmupLeadShort: "提前 s", + warmupLeadHint: "0-120:定时任务提前启动,用于先收集 ticket 并等到并发时间发射", }, qr: { alt: "最新支付二维码", diff --git a/web/src/services/API_CONTRACT.md b/web/src/services/API_CONTRACT.md index 4c3d6dd..05c59cd 100644 --- a/web/src/services/API_CONTRACT.md +++ b/web/src/services/API_CONTRACT.md @@ -21,7 +21,7 @@ Current dashboard refresh intentionally preserves the legacy N+1 pattern: list a ## Account management - `POST /api/accounts/import` with `AccountImportPayload` -> imports and syncs an account. -- `PATCH /api/accounts/{account_id}` with `AccountPreferencesPayload` -> updates selected product and schedule preferences. +- `PATCH /api/accounts/{account_id}` with `AccountPreferencesPayload` -> updates selected product, schedule timing, warmup lead, preview concurrency, and ticket-pool preferences. - `DELETE /api/accounts/{account_id}` -> deletes account and local cache. - `POST /api/accounts/{account_id}/bootstrap?refresh_fingerprint=true` -> syncs account context and rotates fingerprint. @@ -32,4 +32,4 @@ Current dashboard refresh intentionally preserves the legacy N+1 pattern: list a ## Error handling -`apiClient` unwraps `data` on success and throws `ApiClientError` on failed HTTP status or `{ ok: false }`. The dashboard composable converts thrown errors into a visible status banner and clears per-action loading state in `finally`. \ No newline at end of file +`apiClient` unwraps `data` on success and throws `ApiClientError` on failed HTTP status or `{ ok: false }`. The dashboard composable converts thrown errors into a visible status banner and clears per-action loading state in `finally`. diff --git a/web/src/styles.css b/web/src/styles.css index d22cf1c..98a3be8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -410,7 +410,7 @@ textarea:focus-visible, } .schedule-cell { - grid-template-columns: auto 1fr; + grid-template-columns: auto minmax(108px, 1fr) minmax(88px, auto); align-items: center; align-content: center; } @@ -605,6 +605,29 @@ textarea:focus-visible, background: #fff; } +.schedule-lead-field { + display: grid; + grid-template-columns: auto minmax(46px, 1fr); + align-items: center; + gap: 6px; + min-width: 88px; + color: var(--desk-muted); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.schedule-lead-input { + min-height: 36px; + width: 100%; + min-width: 0; + border: 1px solid var(--desk-line); + border-radius: 12px; + padding: 6px 8px; + color: var(--desk-ink); + background: #fff; +} + .schedule-time-readonly { min-height: 36px; display: inline-grid; diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 7829537..20adfd6 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -97,6 +97,7 @@ export interface PublicAccountRecord { preview_concurrency_time?: string; ticket_pool_size?: number; ticket_pool_drain_interval_ms?: number; + preview_warmup_lead_seconds?: number; invitation_code?: string; stock_monitor_enabled?: boolean; stock_monitor_last_checked_at?: string | null; @@ -182,4 +183,5 @@ export interface AccountPreferencesPayload { scheduled_start_time?: string | null; ticket_pool_size?: number | null; ticket_pool_drain_interval_ms?: number | null; + preview_warmup_lead_seconds?: number | null; }