Skip to content

Commit c0bbda2

Browse files
committed
feat: API 配额保护 - 同步前预估调用量,超阈值提醒确认
1 parent 0723803 commit c0bbda2

9 files changed

Lines changed: 443 additions & 36 deletions

File tree

quantclass_sync_internal/cli.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ def cmd_update(
481481
products: List[str] = typer.Option([], "--products", help="临时覆盖默认产品清单。"),
482482
force_update: bool = typer.Option(False, "--force", help="强制更新:跳过 timestamp 门控。"),
483483
workers: int = typer.Option(1, "--workers", "-w", min=1, max=8, help="并发下载线程数(默认 1,推荐 2-3)。"),
484+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过同步前确认提示,直接执行"),
484485
) -> None:
485486
"""
486487
一键更新入口(日常只需这个命令)。
@@ -561,9 +562,15 @@ def cmd_update(
561562
command_name="update",
562563
fallback_products=fallback_products,
563564
max_workers=workers,
565+
# 从 user_config 读取 API 调用限额和课程类型(影响确认提示文本)
566+
api_call_limit=user_config.api_call_limit,
567+
course_type=user_config.course_type,
568+
auto_confirm=yes,
564569
)
565570
log_info("update 执行完成。", event="CMD_DONE", exit_code=exit_code)
566-
if exit_code != 0:
571+
if exit_code == -1:
572+
pass # 用户取消,正常退出(exit 0)
573+
elif exit_code != 0:
567574
raise typer.Exit(code=exit_code)
568575

569576
@app.command("repair-sort")
@@ -806,6 +813,7 @@ def cmd_all_data(
806813
mode: str = typer.Option("local", "--mode", help="local=本地存量更新;catalog=全量轮询。"),
807814
products: List[str] = typer.Option([], "--products", help="显式产品(可重复传参,也支持逗号分隔)。"),
808815
force_update: bool = typer.Option(False, "--force", help="强制更新:跳过 timestamp 门控。"),
816+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过同步前确认提示,直接执行"),
809817
) -> None:
810818
"""
811819
批量更新产品(兼容命令)。
@@ -814,7 +822,13 @@ def cmd_all_data(
814822
mode=catalog:按 catalog 清单轮询(补齐或巡检时使用)。
815823
"""
816824

817-
command_ctx = _init_command(ctx, "all_data")
825+
# 一次性解析路径和 user_config,复用结果,避免 _init_command 内部重复调用
826+
base_ctx = _ctx(ctx)
827+
data_root, secrets_file, user_config_all, data_root_source, secrets_source = _resolve_command_paths(base_ctx)
828+
command_ctx = _build_command_ctx_with_overrides(base_ctx, data_root, secrets_file)
829+
ctx.obj = command_ctx
830+
log_debug("all_data 运行来源已解析.", event="PATHS",
831+
data_root_source=data_root_source, secrets_source=secrets_source)
818832
try:
819833
normalized_mode = validate_run_mode(mode)
820834
except ValueError as exc:
@@ -825,9 +839,15 @@ def cmd_all_data(
825839
products=products,
826840
force_update=force_update,
827841
command_name="all_data",
842+
auto_confirm=yes,
843+
# 从 user_config 读取 API 调用限额和课程类型(影响确认提示文本,无配置时用默认值)
844+
api_call_limit=getattr(user_config_all, "api_call_limit", 50),
845+
course_type=getattr(user_config_all, "course_type", ""),
828846
)
829847
log_info("all_data 执行完成。", event="CMD_DONE", exit_code=exit_code)
830-
if exit_code != 0:
848+
if exit_code == -1:
849+
pass # 用户取消,正常退出(exit 0)
850+
elif exit_code != 0:
831851
raise typer.Exit(code=exit_code)
832852

833853
@app.command("gui")

quantclass_sync_internal/gui/api.py

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def _format_run_summary(raw_run: Dict[str, Any]) -> Dict[str, Any]:
6969

7070
# _progress 的初始结构,每次 start_sync 前重置为此形态
7171
_PROGRESS_INIT: Dict[str, Any] = {
72-
"status": "idle", # idle / syncing / done / error
72+
"status": "idle", # idle / syncing / confirm_needed / done / error
7373
"current_product": "", # 最近完成的产品名
7474
"completed": 0, # 已完成产品数
7575
"total": 0, # 本次同步产品总数
@@ -78,6 +78,7 @@ def _format_run_summary(raw_run: Dict[str, Any]) -> Dict[str, Any]:
7878
"run_summary": None, # 同步完成后填充 run_summary dict
7979
"products": [], # 已完成产品列表 [{name, status, elapsed_seconds, files_count}]
8080
"all_products": [], # 全部产品名列表(由 progress_callback 初始化调用时传入)
81+
"estimate": None, # EstimateResult 的 dict 表示(confirm_needed 时填充)
8182
}
8283

8384

@@ -94,6 +95,11 @@ def __init__(self) -> None:
9495
self._health_progress: Dict[str, Any] = {
9596
"checking": False, "current": 0, "total": 0, "product": "", "result": None,
9697
}
98+
# 同步确认事件(每次 _run_sync 重置,用于与前端双向通信)
99+
self._confirm_event: threading.Event = threading.Event()
100+
self._confirm_result: bool = False
101+
# 明确标记用户是否主动取消(区别于凭证错误等其他 error 场景)
102+
self._was_cancelled: bool = False
97103

98104
# ------------------------------------------------------------------
99105
# 内部辅助方法
@@ -293,7 +299,7 @@ def get_config(self) -> Dict[str, Any]:
293299
}
294300

295301
def run_setup(self, data_root: str, api_key: str, hid: str,
296-
create_dir: bool = False) -> dict:
302+
create_dir: bool = False, course_type: str = "basic") -> dict:
297303
"""GUI setup 向导调用。先保存配置,再验证连通性。
298304
299305
流程:
@@ -330,10 +336,15 @@ def run_setup(self, data_root: str, api_key: str, hid: str,
330336
secrets_file = DEFAULT_USER_SECRETS_FILE.resolve()
331337

332338
try:
339+
# basic 固定 10 次/天,premium 固定 100 次/天
340+
_limit_map = {"basic": 10, "premium": 100}
341+
_api_call_limit = _limit_map.get(course_type, 50)
333342
config = UserConfig(
334343
data_root=str(dr),
335344
product_mode="local_scan",
336345
default_products=[],
346+
course_type=course_type,
347+
api_call_limit=_api_call_limit,
337348
)
338349
save_setup_artifacts_atomic(
339350
config_path=config_file,
@@ -386,6 +397,23 @@ def run_setup(self, data_root: str, api_key: str, hid: str,
386397
"warning": "配置已保存,但连接验证未通过,请检查网络连接",
387398
}
388399

400+
def confirm_sync(self) -> Dict[str, Any]:
401+
"""前端点击"继续同步"时调用,唤醒后台线程继续执行。"""
402+
self._confirm_result = True
403+
self._confirm_event.set()
404+
return {"ok": True}
405+
406+
def cancel_sync(self) -> Dict[str, Any]:
407+
"""前端点击"取消"时调用,唤醒后台线程并标记取消。
408+
409+
只设置取消标志和事件,最终状态由 _run_sync 的 exit_code==-1 分支统一写入,
410+
避免与 _run_sync 写 status="idle" 产生竞争。
411+
"""
412+
self._was_cancelled = True
413+
self._confirm_result = False
414+
self._confirm_event.set()
415+
return {"ok": True}
416+
389417
def start_sync(self, retry_failed: bool = False) -> Dict[str, Any]:
390418
"""启动同步线程。
391419
@@ -401,7 +429,11 @@ def start_sync(self, retry_failed: bool = False) -> Dict[str, Any]:
401429
retry_products = None
402430
# 读-判断-写合并在同一个锁块,防止双击连续启动
403431
with self._lock:
404-
if self._progress.get("status") == "syncing":
432+
# 检查 worker 线程是否仍在运行(cancel 后 status 变 error,但线程可能未退出)
433+
if hasattr(self, "_sync_thread") and self._sync_thread and self._sync_thread.is_alive():
434+
return {"started": False, "message": "同步正在进行中,请等待完成后再试。"}
435+
# confirm_needed 状态表示同步已在进行中(等待用户确认),也需拦截
436+
if self._progress.get("status") in ("syncing", "confirm_needed"):
405437
return {"started": False, "message": "同步正在进行中,请等待完成后再试。"}
406438

407439
# retry_failed 分支:从上次 run_summary 读取失败产品名
@@ -431,6 +463,8 @@ def start_sync(self, retry_failed: bool = False) -> Dict[str, Any]:
431463
daemon=True, # 主进程退出时自动结束
432464
name="gui-sync-worker",
433465
)
466+
# 保存线程引用,供下次 start_sync 调用时检查是否仍在运行
467+
self._sync_thread = thread
434468
thread.start()
435469
log_info("GUI 同步线程已启动。", event="GUI_SYNC")
436470

@@ -516,9 +550,9 @@ def open_data_dir(self) -> Dict[str, Any]:
516550
return {"ok": True}
517551

518552
def start_health_check(self) -> Dict[str, Any]:
519-
"""启动后台健康检查线程。同步中拒绝,重复启动拒绝。"""
553+
"""启动后台健康检查线程。同步中(含等待确认)拒绝,重复启动拒绝。"""
520554
with self._lock:
521-
if self._progress.get("status") == "syncing":
555+
if self._progress.get("status") in ("syncing", "confirm_needed"):
522556
return {"ok": False, "error": "同步进行中,请稍后再试"}
523557
if self._health_progress["checking"]:
524558
return {"ok": False, "error": "检查已在进行中"}
@@ -572,9 +606,9 @@ def get_health_result(self) -> Dict[str, Any]:
572606
return self._health_progress.get("result")
573607

574608
def repair_health_issues(self) -> Dict[str, Any]:
575-
"""修复可修复的数据问题。同步中拒绝。"""
609+
"""修复可修复的数据问题。同步中(含等待确认)拒绝。"""
576610
with self._lock:
577-
if self._progress.get("status") == "syncing":
611+
if self._progress.get("status") in ("syncing", "confirm_needed"):
578612
return {"ok": False, "error": "同步进行中,请稍后修复"}
579613
result = self._health_progress.get("result")
580614
if not result or not result.get("ok"):
@@ -740,6 +774,29 @@ def _run_sync(self, user_config: object, data_root: Path,
740774
"""
741775
t_start = time.time()
742776

777+
# 每次同步重置确认事件和取消标志,防止上次状态残留
778+
self._confirm_event = threading.Event()
779+
self._confirm_result = False
780+
self._was_cancelled = False
781+
782+
def _gui_confirm(estimate) -> bool:
783+
"""GUI 确认回调:设状态 -> 等前端点击 -> 返回结果。
784+
785+
在 gui-sync-worker 线程等待,不持 _lock,不阻塞 pywebview 主线程。
786+
等待超时(300s)视为取消。
787+
"""
788+
from dataclasses import asdict
789+
with self._lock:
790+
self._progress["status"] = "confirm_needed"
791+
self._progress["estimate"] = asdict(estimate)
792+
# 等待前端调用 confirm_sync() 或 cancel_sync()
793+
self._confirm_event.wait(timeout=300)
794+
# wait 返回后清除确认状态,防止前端轮询时 confirm_needed 卡片重复弹出
795+
with self._lock:
796+
self._progress["status"] = "syncing"
797+
self._progress["estimate"] = None
798+
return self._confirm_result
799+
743800
try:
744801
config_file = DEFAULT_USER_CONFIG_FILE.resolve()
745802
secrets_file = DEFAULT_USER_SECRETS_FILE.resolve()
@@ -843,6 +900,7 @@ def progress_callback(product_name: str, completed: int, total: int, *,
843900
fallback_products = user_config.default_products or []
844901

845902
# 执行同步,接收退出码判断业务结果
903+
# 传入 api_call_limit/course_type 供预估函数使用,confirm_callback 供 GUI 确认流程
846904
exit_code = run_update_with_settings(
847905
command_ctx=command_ctx,
848906
mode="local", # 产品发现策略(按本地已有目录扫描)
@@ -852,6 +910,9 @@ def progress_callback(product_name: str, completed: int, total: int, *,
852910
fallback_products=fallback_products,
853911
max_workers=DEFAULT_GUI_WORKERS,
854912
progress_callback=progress_callback,
913+
api_call_limit=getattr(user_config, "api_call_limit", 50),
914+
course_type=getattr(user_config, "course_type", ""),
915+
confirm_callback=_gui_confirm,
855916
)
856917

857918
# 读取 run_summary 并转换为前端友好格式
@@ -865,27 +926,41 @@ def progress_callback(product_name: str, completed: int, total: int, *,
865926
except Exception as summary_exc:
866927
log_error(f"同步完成但运行摘要读取失败:{summary_exc}", event="GUI_SYNC")
867928

929+
# orchestrator 返回 -1 表示用户主动取消,静默回到 idle(不显示成功也不显示错误)
930+
if exit_code == -1:
931+
self._update_progress(status="idle")
932+
log_info("GUI 同步已取消。", event="GUI_SYNC", elapsed=round(elapsed, 1))
933+
return
934+
868935
# 退出码非零表示有产品失败或无可执行产品,标记为 error 并附带 run_summary
869-
if exit_code != EXIT_CODE_SUCCESS:
870-
error_msg = "部分产品同步失败" if run_summary and run_summary.get("error", 0) > 0 else "同步未成功完成"
871-
self._update_progress(
872-
status="error",
873-
elapsed_seconds=round(elapsed, 1),
874-
error_message=error_msg,
875-
run_summary=run_summary,
876-
)
877-
log_info("GUI 同步结束(有失败)。", event="GUI_SYNC", exit_code=exit_code, elapsed=round(elapsed, 1))
878-
else:
879-
self._update_progress(
880-
status="done",
881-
elapsed_seconds=round(elapsed, 1),
882-
run_summary=run_summary,
883-
)
884-
log_info("GUI 同步完成。", event="GUI_SYNC", elapsed=round(elapsed, 1))
936+
# 注意:cancel_sync() 会直接设 status=error,用 _was_cancelled 精确判断,
937+
# 避免凭证错误等场景误判为"已取消"
938+
already_cancelled = self._was_cancelled
939+
if not already_cancelled:
940+
if exit_code != EXIT_CODE_SUCCESS:
941+
error_msg = "部分产品同步失败" if run_summary and run_summary.get("error", 0) > 0 else "同步未成功完成"
942+
self._update_progress(
943+
status="error",
944+
elapsed_seconds=round(elapsed, 1),
945+
error_message=error_msg,
946+
run_summary=run_summary,
947+
)
948+
log_info("GUI 同步结束(有失败)。", event="GUI_SYNC", exit_code=exit_code, elapsed=round(elapsed, 1))
949+
else:
950+
self._update_progress(
951+
status="done",
952+
elapsed_seconds=round(elapsed, 1),
953+
run_summary=run_summary,
954+
)
955+
log_info("GUI 同步完成。", event="GUI_SYNC", elapsed=round(elapsed, 1))
885956

886957
except Exception as exc:
887958
# 预检阶段异常(凭证缺失、配置错误等),尚未进入 run_update_with_settings,
888959
# 无 run_summary 可填(保持初始值 None),前端据此不展示摘要详情
960+
# 若用户已主动取消(_was_cancelled),静默回到 idle,不展示 error
961+
if self._was_cancelled:
962+
self._update_progress(status="idle")
963+
return
889964
elapsed = time.time() - t_start
890965
error_msg = str(exc)
891966
log_error(f"GUI 同步出错:{error_msg}", event="GUI_SYNC")

0 commit comments

Comments
 (0)