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;
}